Files
gongxue-base/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx
wangziqi 90cc321221 fix: UI improvements and DingTalk attendance refresh for completed sessions
- DatePicker editors open immediately on double-click
- Student archive drawer hides empty studentNo parentheses
- Student add/edit form uses 2-column grid layout
- Table horizontal scrollbars show only on hover/focus
- Remove fake 近7日趋势 stats from attendance student detail
- Allow DingTalk refresh to update completed attendance sessions
  (late-arriving punches can now change absent → present)
- Switch dev command from concurrently to turbo run dev
- Remove concurrently/wait-on dependencies
2026-07-22 17:50:59 +08:00

273 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd';
import dayjs from 'dayjs';
import api from '../../api';
import { message } from '../../ui/app-message';
import {
filterLessonAttendanceRecords,
getPunchDisplayInfo,
summarizeLessonCheckins,
type LessonAttendanceFilter,
} from './attendance-workspace';
import type { LessonAttendanceRecord, LessonAttendanceSchedule } from './types';
interface LessonAttendanceSession {
id: number;
scheduleId: number;
classId: number;
lessonDate: string;
status: 'in_progress' | 'completed';
}
interface LessonAttendanceResponse {
schedule: LessonAttendanceSchedule;
session: LessonAttendanceSession | null;
records: LessonAttendanceRecord[];
}
interface LessonAttendanceDetailProps {
schedule: LessonAttendanceSchedule | null;
className: string;
onClose: () => void;
}
function AttendanceStatus({ status }: { status: string }) {
const checkedIn = status === 'present' || status === 'late';
return (
<span className={`attendance-status ${checkedIn ? 'is-present' : 'is-absent'}`}>
<span className="attendance-status__dot" />
{checkedIn ? '出勤' : '缺勤'}
</span>
);
}
function LessonSummary({ records }: { records: readonly LessonAttendanceRecord[] }) {
const summary = summarizeLessonCheckins(records);
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
return (
<div className="attendance-summary-strip lesson-summary-strip">
<div className="attendance-rate">
<Progress type="circle" percent={rate} size={64} strokeWidth={9} />
<div>
<span></span>
<strong>{summary.total} </strong>
</div>
</div>
<div className="attendance-summary-cell">
<span className="attendance-summary-icon is-present"></span>
<div>
<strong>{summary.checkedIn}</strong>
<span></span>
</div>
</div>
<div className="attendance-summary-cell">
<span className="attendance-summary-icon is-absent"></span>
<div>
<strong>{summary.notCheckedIn}</strong>
<span></span>
</div>
</div>
</div>
);
}
const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
schedule,
className,
onClose,
}) => {
const [loadedSchedule, setLoadedSchedule] = useState<LessonAttendanceSchedule | null>(null);
const [session, setSession] = useState<LessonAttendanceSession | null>(null);
const [records, setRecords] = useState<LessonAttendanceRecord[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [filter, setFilter] = useState<LessonAttendanceFilter>('all');
useEffect(() => {
if (!schedule) return;
let cancelled = false;
setLoadedSchedule(schedule);
setSession(null);
setRecords([]);
setKeyword('');
setFilter('all');
setLoading(true);
const date = dayjs().format('YYYY-MM-DD');
void api
.post<LessonAttendanceResponse>(`/attendance-lessons/schedules/${schedule.id}/pull`, {
date,
})
.then((data) => {
if (cancelled) return;
setLoadedSchedule(data.schedule);
setSession(data.session);
setRecords(data.records);
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
})
.catch((error: unknown) => {
if (cancelled) return;
message.error((error as { message?: string })?.message || '加载本节课考勤失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [schedule]);
const updateRecord = useCallback(async (record: LessonAttendanceRecord, status: string) => {
const previous = record.status;
setRecords((items) =>
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
);
try {
await api.put(`/attendance-records/${record.id}`, { status });
} catch (error: unknown) {
setRecords((items) =>
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
);
message.error((error as { message?: string })?.message || '更新考勤失败');
}
}, []);
const filteredRecords = useMemo(
() => filterLessonAttendanceRecords(records, keyword, filter),
[filter, keyword, records],
);
const completed = session?.status === 'completed';
const displayedSchedule = loadedSchedule ?? schedule;
return (
<Drawer
open={schedule !== null}
onClose={onClose}
size={960}
title={null}
className="attendance-drawer"
destroyOnHidden
>
<div className="lesson-record-header">
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
<h2>{displayedSchedule?.subject || '课程考勤'}</h2>
<p>
{className} · {displayedSchedule?.startTime}{displayedSchedule?.endTime} ·{' '}
{dayjs().format('YYYY-MM-DD')}
</p>
</div>
{session && (
<Alert
type={completed ? 'success' : 'info'}
showIcon
title={completed ? '本节课考勤已结算' : '当前打卡结果;课程截止后将自动做最终结算'}
style={{ marginBottom: 16 }}
/>
)}
<LessonSummary records={records} />
<div className="lesson-record-filters">
<Input.Search
allowClear
value={keyword}
placeholder="搜索学生姓名"
onChange={(event) => setKeyword(event.target.value)}
className="lesson-record-search"
/>
<Select<LessonAttendanceFilter>
value={filter}
onChange={setFilter}
options={[
{ value: 'all', label: '全部学生' },
{ value: 'checked_in', label: '已打卡' },
{ value: 'not_checked_in', label: '未打卡' },
]}
className="lesson-record-filter-select"
/>
<span className="lesson-record-filter-count">
{filteredRecords.length} / {records.length}
</span>
</div>
<Table<LessonAttendanceRecord>
rowKey="id"
loading={loading}
dataSource={filteredRecords}
pagination={false}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={records.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
/>
),
}}
columns={[
{
title: '学生',
dataIndex: ['student', 'name'],
render: (name: string) => (
<div className="student-cell">
<Avatar size={32}>{name?.slice(0, 1)}</Avatar>
<strong>{name || '-'}</strong>
</div>
),
},
{
title: '考勤结果',
dataIndex: 'status',
width: 230,
render: (value: string, record) => {
const checkedIn = value === 'present' || value === 'late';
return (
<div className="attendance-marking-actions">
<Button
size="small"
type={checkedIn ? 'primary' : 'default'}
onClick={() => void updateRecord(record, 'present')}
>
</Button>
<Button
size="small"
type={!checkedIn ? 'primary' : 'default'}
danger={!checkedIn}
onClick={() => void updateRecord(record, 'absent')}
>
</Button>
</div>
);
},
},
{
title: '当前状态',
dataIndex: 'status',
width: 105,
render: (value: string) => <AttendanceStatus status={value} />,
},
{
title: '打卡设备',
width: 220,
render: (_: unknown, record) => {
const info = getPunchDisplayInfo(record);
if (!info) return <span className="muted-text"></span>;
return (
<div className="punch-device-cell">
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
{info.detail && <strong>{info.detail}</strong>}
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
</div>
);
},
},
{
title: '备注',
dataIndex: 'remark',
render: (value: string | null) => value || <span className="muted-text"></span>,
},
]}
/>
</Drawer>
);
};
export default LessonAttendanceDetail;