import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, Avatar, Button, Card, Col, DatePicker, Drawer, Grid, Empty, Form, Input, Modal, Progress, Segmented, Row, Select, Spin, Table, Tag, Tooltip, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { ArrowRightOutlined, CheckCircleFilled, ClockCircleOutlined, ExportOutlined, FileSearchOutlined, ReloadOutlined, ScheduleOutlined, TeamOutlined, WarningFilled, } from '@ant-design/icons'; import dayjs, { type Dayjs } from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { usePermission } from '../../hooks/usePermission'; import { message } from '../../ui/app-message'; import { canPullAttendance, filterLessonAttendanceRecords, getAttendanceExperience, getPunchDisplayInfo, getSchedulePhase, summarizeLessonCheckins, type AttendanceSummary, type LessonAttendanceFilter, type SchedulePhase, } from './attendance-workspace'; import './attendance.css'; const DEFAULT_ATTENDANCE_PERIODS = [ { periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true }, { periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true }, { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true }, { periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true }, ]; const STATUS_META: Record< string, { label: string; color: string; className: string; short: string } > = { present: { label: '出勤', color: 'success', className: 'is-present', short: '勤' }, absent: { label: '缺勤', color: 'error', className: 'is-absent', short: '缺' }, leave: { label: '请假', color: 'processing', className: 'is-leave', short: '假' }, pending: { label: '待确认', color: 'default', className: 'is-pending', short: '待' }, }; const ADMIN_CORRECTION_OPTIONS = [ { value: 'present', label: '正常' }, { value: 'leave', label: '请假' }, { value: 'absent', label: '缺勤' }, ]; interface ClassOption { classId: number; className: string; } interface AttendanceRecordItem { id: number; studentId: number; classId: number | null; attendanceDate: string; session: string; status: string; source?: string; remark: string | null; createdAt: string; student: { id: number; name: string; studentNo?: string | null }; class: { id: number; name: string } | null; scheduleId?: number | null; attendanceSessionId?: number | null; punchTime?: string | null; punchSource?: string | null; punchDeviceName?: string | null; punchDeviceId?: string | null; } interface AssignedClass { classId: number; className: string; classCode: string; roleType: string; subject: string; } interface TodaySchedule { id: number; classId: number; classroomId: number; startTime: string; endTime: string; subject: string; } interface HistoryScheduleOption { id: number; classId: number; weekDay: number; startTime: string; endTime: string; startDate: string; endDate: string; subject: string; teacherId: number | null; status: string; } interface LessonAttendanceSession { id: number; scheduleId: number; classId: number; lessonDate: string; status: 'in_progress' | 'completed'; } interface LessonAttendanceResponse { schedule: TodaySchedule; session: LessonAttendanceSession | null; records: AttendanceRecordItem[]; } interface TeacherWorkspaceData { assignedClasses: AssignedClass[]; todaySchedules: TodaySchedule[]; } interface AlertItem { studentId: number; studentName: string; studentNo: string; className: string; type: string; count: number; lastDate: string; } interface AttendancePeriodConfigItem { id?: number; periodKey: string; label: string; startTime: string; endTime: string; sortOrder: number; enabled: boolean; } interface DingTalkSyncStatus { lastPulledAt: string | null; action: string | null; username: string | null; detail: string | null; } const EMPTY_SUMMARY: AttendanceSummary = { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, }; function readCurrentRoles(): string[] { try { const user = JSON.parse(localStorage.getItem('user') || '{}') as { roles?: string[] }; return Array.isArray(user.roles) ? user.roles : []; } catch { return []; } } function displayAttendanceStatus(status?: string | null): string { return status === 'pending' || !status ? 'absent' : status; } function AttendanceStatusTag({ status }: { status: string }) { const displayStatus = displayAttendanceStatus(status); const meta = STATUS_META[displayStatus] ?? { label: displayStatus, color: 'default', className: 'is-absent', short: '?', }; return ( {meta.label} ); } function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) { const summary = summarizeLessonCheckins(records); const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0; return (
打卡率 {summary.total} 人
{summary.checkedIn} 已打卡
{summary.notCheckedIn} 未打卡
); } const AttendancePage: React.FC = () => { const { permissions, hasPermission } = usePermission(); const roles = useMemo(readCurrentRoles, []); const experience = getAttendanceExperience(permissions, roles); if (experience === 'teacher') { return ; } return ; }; const TeacherAttendanceWorkspace: React.FC = () => { const [loading, setLoading] = useState(true); const [workspace, setWorkspace] = useState(null); const [selectedSchedule, setSelectedSchedule] = useState(null); const [lessonSession, setLessonSession] = useState(null); const [lessonRecords, setLessonRecords] = useState([]); const [recordLoading, setRecordLoading] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [studentKeyword, setStudentKeyword] = useState(''); const [checkinFilter, setCheckinFilter] = useState('all'); const loadWorkspace = useCallback(async () => { setLoading(true); try { setWorkspace(await api.get('/rbac/teacher-workspace')); } catch (error: unknown) { message.error((error as { message?: string })?.message || '加载今日课程失败'); } finally { setLoading(false); } }, []); useEffect(() => { void loadWorkspace(); }, [loadWorkspace]); const classNameById = useMemo( () => new Map(workspace?.assignedClasses.map((item) => [item.classId, item.className]) ?? []), [workspace], ); const openAttendance = useCallback(async (schedule: TodaySchedule) => { setStudentKeyword(''); setCheckinFilter('all'); setSelectedSchedule(schedule); setDrawerOpen(true); setRecordLoading(true); try { const today = dayjs().format('YYYY-MM-DD'); const data = await api.post( `/attendance-lessons/schedules/${schedule.id}/pull`, { date: today }, ); setSelectedSchedule(data.schedule); setLessonSession(data.session); setLessonRecords(data.records); message.success('钉钉打卡已更新;课程截止后系统将自动结算'); } catch (error: unknown) { setLessonSession(null); setLessonRecords([]); message.error(error instanceof Error ? error.message : '加载本节课考勤失败'); } finally { setRecordLoading(false); } }, []); const updateLessonRecord = useCallback( async (record: AttendanceRecordItem, status: string) => { const previous = record.status; setLessonRecords((items) => items.map((item) => (item.id === record.id ? { ...item, status } : item)), ); try { await api.put(`/attendance-records/${record.id}`, { status }); } catch (error: unknown) { setLessonRecords((items) => items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)), ); message.error((error as { message?: string })?.message || '更新考勤失败'); } }, [], ); const now = new Date(); const schedules = workspace?.todaySchedules ?? []; const startedCount = schedules.filter( (item) => canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)), ).length; const nextSchedule = schedules.find( (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended', ); const isAttendanceCompleted = lessonSession?.status === 'completed'; const filteredLessonRecords = useMemo( () => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter), [lessonRecords, studentKeyword, checkinFilter], ); return (
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}

今天,从课程开始

课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。

今日课程{schedules.length}
已开始{startedCount}节,可查看考勤
下一节{nextSchedule ? nextSchedule.startTime : '—'}{nextSchedule?.subject || '今天没有更多课程'}
今日教学节奏

我的课程

课程开始后可拉取钉钉考勤记录
{schedules.length === 0 ? ( 今天还没有课程

请联系教务管理员安排课程。

} /> ) : (
{schedules.map((schedule, index) => { const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now); return ( void openAttendance(schedule)} /> ); })}
)} setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
LESSON ATTENDANCE

{selectedSchedule?.subject || '课程考勤'}

{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}

{lessonSession && ( )}
setStudentKeyword(event.target.value)} className="lesson-record-search" /> value={checkinFilter} onChange={setCheckinFilter} options={[ { value: 'all', label: '全部学生' }, { value: 'checked_in', label: '已打卡' }, { value: 'not_checked_in', label: '未打卡' }, ]} className="lesson-record-filter-select" /> 显示 {filteredLessonRecords.length} / {lessonRecords.length} 人
rowKey="id" loading={recordLoading} dataSource={filteredLessonRecords} pagination={false} locale={{ emptyText: , }} columns={[ { title: '学生', dataIndex: ['student', 'name'], render: (name: string) =>
{name?.slice(0, 1)}{name || '-'}
, }, { title: '考勤结果', dataIndex: 'status', width: 230, render: (value: string, record: AttendanceRecordItem) => { const checkedIn = value === 'present' || value === 'late'; return (
); }, }, { title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => }, { title: '打卡设备', width: 220, render: (_: unknown, record: AttendanceRecordItem) => { const info = getPunchDisplayInfo(record); if (!info) return ; return (
{info.label} {info.detail && {info.detail}} {info.time && {dayjs(info.time).format('HH:mm:ss')}}
); }, }, { title: '备注', dataIndex: 'remark', render: (value: string | null) => value || }, ]} />
); }; const LessonCard: React.FC<{ schedule: TodaySchedule; phase: SchedulePhase; className: string; index: number; onOpen: () => void; }> = ({ schedule, phase, className, index, onOpen }) => { const phaseMeta = { upcoming: { label: '待上课', icon: , tone: 'upcoming' }, ongoing: { label: '进行中', icon: , tone: 'ongoing' }, ended: { label: '已结束', icon: , tone: 'ended' }, }[phase]; return (
{String(index).padStart(2, '0')}
{schedule.startTime}{schedule.endTime}

{schedule.subject}

{phaseMeta.label}

{className}教室 {schedule.classroomId}

{phase === 'upcoming' ? ( ) : ( )}
); }; interface AdminStudentPanel { key: string; studentId: number; studentName: string; studentNo: string; className: string; records: AttendanceRecordItem[]; statusBySession: Partial>; primaryStatus: string; rate: number; latestDate: string; } const ADMIN_METRIC_META = [ { key: 'all', label: '出勤率', short: '率' }, { key: 'present', label: '正常', short: '正常' }, { key: 'leave', label: '请假', short: '请假' }, { key: 'absent', label: '缺勤', short: '缺勤' }, ]; function pickPrimaryStatus(records: AttendanceRecordItem[]) { const priority = ['absent', 'leave', 'present']; return priority.find((item) => records.some((record) => displayAttendanceStatus(record.status) === item)) || 'absent'; } function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] { const map = new Map(); for (const record of records) { const current = map.get(record.studentId) ?? { key: String(record.studentId), studentId: record.studentId, studentName: record.student?.name || '未知学生', studentNo: record.student?.studentNo?.trim() || '', className: record.class?.name || '未关联班级', records: [], statusBySession: {}, primaryStatus: 'absent', rate: 0, latestDate: record.attendanceDate, }; current.records.push(record); if (!current.statusBySession[record.session]) current.statusBySession[record.session] = record; if (dayjs(record.attendanceDate).isAfter(dayjs(current.latestDate))) { current.latestDate = record.attendanceDate; } map.set(record.studentId, current); } return Array.from(map.values()).map((item) => { const checked = item.records.filter( (record) => record.status === 'present', ).length; return { ...item, primaryStatus: pickPrimaryStatus(item.records), rate: item.records.length > 0 ? Math.round((checked / item.records.length) * 100) : 0, }; }); } const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; const [records, setRecords] = useState([]); const [summary, setSummary] = useState(EMPTY_SUMMARY); const [alerts, setAlerts] = useState([]); const [classOptions, setClassOptions] = useState([]); const [scheduleOptions, setScheduleOptions] = useState([]); const [scheduleOptionsLoading, setScheduleOptionsLoading] = useState(false); const [periods, setPeriods] = useState(DEFAULT_ATTENDANCE_PERIODS); const [periodModalOpen, setPeriodModalOpen] = useState(false); const [periodForm] = Form.useForm<{ periods: AttendancePeriodConfigItem[] }>(); const [loading, setLoading] = useState(true); const [refreshingDingTalk, setRefreshingDingTalk] = useState(false); const [syncStatus, setSyncStatus] = useState(null); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(48); const [total, setTotal] = useState(0); const [classId, setClassId] = useState(); const [attendanceDate, setAttendanceDate] = useState(dayjs()); const [status, setStatus] = useState(); const [session, setSession] = useState(); const [scheduleId, setScheduleId] = useState(); const [metricFilter, setMetricFilter] = useState('all'); const [studentSearch, setStudentSearch] = useState(''); const [selectedStudent, setSelectedStudent] = useState(null); const [correctingRecordId, setCorrectingRecordId] = useState(null); const enabledPeriods = useMemo( () => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder), [periods], ); const sessionOptions = useMemo( () => enabledPeriods.map((period) => ({ value: period.periodKey, label: period.label })), [enabledPeriods], ); const sessionMap = useMemo( () => Object.fromEntries(periods.map((period) => [period.periodKey, period.label])), [periods], ); const periodOrder = useMemo( () => new Map(periods.map((period, index) => [period.periodKey, index])), [periods], ); const sortAttendanceRecords = useCallback( (items: readonly AttendanceRecordItem[]) => [...items].sort((a, b) => { const periodDiff = (periodOrder.get(a.session) ?? Number.MAX_SAFE_INTEGER) - (periodOrder.get(b.session) ?? Number.MAX_SAFE_INTEGER); if (periodDiff !== 0) return periodDiff; return (a.punchTime || a.createdAt || '').localeCompare(b.punchTime || b.createdAt || ''); }), [periodOrder], ); const subjectScheduleOptions = useMemo( () => scheduleOptions.map((schedule) => ({ value: schedule.id, label: `${schedule.subject}(${schedule.startTime}-${schedule.endTime})`, })), [scheduleOptions], ); const openPeriodConfig = () => { periodForm.setFieldsValue({ periods }); setPeriodModalOpen(true); }; const savePeriodConfig = async () => { try { const values = await periodForm.validateFields(); const payload = { periods: values.periods.map((period, index) => ({ ...period, sortOrder: index + 1, enabled: period.enabled ?? true, })), }; const data = await api.put('/attendance-period-configs', payload); setPeriods(data); setPeriodModalOpen(false); message.success('考勤时段配置已保存'); if (session && !data.some((period) => period.enabled && period.periodKey === session)) { setSession(undefined); } } catch (error: unknown) { if ((error as { errorFields?: unknown })?.errorFields) return; message.error((error as { message?: string })?.message || '保存考勤时段配置失败'); } }; const resetPeriodConfig = async () => { try { const data = await api.post('/attendance-period-configs/reset'); setPeriods(data); periodForm.setFieldsValue({ periods: data }); message.success('已恢复默认考勤时段'); } catch (error: unknown) { message.error((error as { message?: string })?.message || '恢复默认配置失败'); } }; const buildParams = useCallback( (withPagination = true) => { const params: Record = {}; if (withPagination) Object.assign(params, { page, pageSize }); if (classId) params.classId = classId; if (attendanceDate) { const selectedDate = attendanceDate.format('YYYY-MM-DD'); params.dateFrom = selectedDate; params.dateTo = selectedDate; } if (status) params.status = status; if (session) params.session = session; if (scheduleId) params.scheduleId = scheduleId; return params; }, [page, pageSize, classId, attendanceDate, status, session, scheduleId], ); const loadSyncStatus = useCallback(async () => { try { setSyncStatus(await api.get('/attendance-records/dingtalk-sync-status')); } catch { setSyncStatus(null); } }, []); const loadRecords = useCallback(async () => { setLoading(true); try { const [recordData, summaryData, statusData] = await Promise.all([ api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { params: buildParams(true), }), api.get('/attendance-records/summary', { params: buildParams(false), }), api.get('/attendance-records/dingtalk-sync-status'), ]); setRecords(recordData.list); setTotal(recordData.total); setSummary({ ...EMPTY_SUMMARY, ...summaryData }); setSyncStatus(statusData); } catch (error: unknown) { message.error((error as { message?: string })?.message || '加载学生考勤失败'); } finally { setLoading(false); } }, [buildParams]); const refreshDingTalkAttendance = useCallback(async () => { if (!attendanceDate) { message.warning('请先选择日期'); return; } if (attendanceDate.isAfter(dayjs(), 'day')) { message.warning('不能查看或刷新未来日期的考勤'); return; } setRefreshingDingTalk(true); try { const result = await api.post<{ refreshed: number; imported: number; matched: number; errors: string[] }>( '/attendance-records/refresh-dingtalk', { date: attendanceDate.format('YYYY-MM-DD'), classId, session, }, ); if (result.errors.length > 0) { message.warning(result.errors[0]); } else { message.success(`已刷新 ${result.refreshed} 节课程考勤`); } await Promise.all([loadRecords(), loadSyncStatus()]); } catch (error: unknown) { message.error((error as { message?: string })?.message || '刷新钉钉考勤失败'); } finally { setRefreshingDingTalk(false); } }, [attendanceDate, classId, loadRecords, loadSyncStatus, session]); useEffect(() => { void Promise.all([ api.get('/attendance-records/classes').then(setClassOptions), api.get('/attendance-records/alerts').then(setAlerts), api.get('/attendance-period-configs').then((data) => { setPeriods(data); periodForm.setFieldsValue({ periods: data }); }), ]).catch(() => undefined); }, []); useEffect(() => { if (!classId || !attendanceDate) { setScheduleOptions([]); setScheduleId(undefined); return; } const selectedDate = attendanceDate.format('YYYY-MM-DD'); setScheduleOptionsLoading(true); void api .get('/attendance-records/schedules', { params: { classId, date: selectedDate }, }) .then((options) => { setScheduleOptions(options); setScheduleId((current) => current && options.some((schedule) => schedule.id === current) ? current : undefined, ); }) .catch((error: unknown) => { setScheduleOptions([]); setScheduleId(undefined); message.error((error as { message?: string })?.message || '加载班级科目失败'); }) .finally(() => setScheduleOptionsLoading(false)); }, [attendanceDate, classId]); useEffect(() => { void loadRecords(); }, [loadRecords]); const resetFilters = () => { setClassId(undefined); setAttendanceDate(dayjs()); setStatus(undefined); setSession(undefined); setScheduleId(undefined); setScheduleOptions([]); setMetricFilter('all'); setStudentSearch(''); setPage(1); }; const handleExport = useCallback(() => { const params = new URLSearchParams(); for (const [key, value] of Object.entries(buildParams(false))) params.set(key, String(value)); const token = localStorage.getItem('token'); fetch(`/api/attendance-records/export?${params.toString()}`, { headers: { Authorization: `Bearer ${token}` }, }) .then((response) => { if (!response.ok) throw new Error('导出失败'); return response.blob(); }) .then((blob) => { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`; anchor.click(); URL.revokeObjectURL(url); }) .catch(() => message.error('导出失败')); }, [buildParams]); const patchRecordStatus = (recordId: number, nextStatus: string) => { setRecords((items) => items.map((item) => (item.id === recordId ? { ...item, status: nextStatus } : item)), ); setSelectedStudent((student) => { if (!student) return student; return { ...student, records: student.records.map((item) => item.id === recordId ? { ...item, status: nextStatus } : item, ), statusBySession: Object.fromEntries( Object.entries(student.statusBySession).map(([key, item]) => [ key, item?.id === recordId ? { ...item, status: nextStatus } : item, ]), ), }; }); }; const updateAdminRecordStatus = async (record: AttendanceRecordItem, nextStatus: string) => { const previousStatus = record.status; const normalizedPrevious = displayAttendanceStatus(previousStatus); if (normalizedPrevious === nextStatus && previousStatus !== 'pending') return; patchRecordStatus(record.id, nextStatus); setCorrectingRecordId(record.id); try { await api.put(`/attendance-records/${record.id}`, { status: nextStatus }); message.success('考勤结果已更新'); void loadRecords(); } catch (error: unknown) { patchRecordStatus(record.id, previousStatus); message.error((error as { message?: string })?.message || '更新失败'); } finally { setCorrectingRecordId(null); } }; const studentPanels = useMemo(() => buildAdminStudentPanels(records), [records]); const visibleStudents = useMemo(() => { const query = studentSearch.trim().toLocaleLowerCase('zh-CN'); return studentPanels.filter((student) => { const matchesQuery = !query || student.studentName.toLocaleLowerCase('zh-CN').includes(query) || student.studentNo.toLocaleLowerCase('zh-CN').includes(query); const matchesMetric = metricFilter === 'all' || student.records.some((record) => displayAttendanceStatus(record.status) === metricFilter); return matchesQuery && matchesMetric; }); }, [metricFilter, studentPanels, studentSearch]); const selectedClass = classId ? classOptions.find((item) => item.classId === classId)?.className || `班级 ${classId}` : '全部班级'; const attendanceRate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0; const dateLabel = attendanceDate?.format('YYYY-MM-DD') || '未选择日期'; const columns: ColumnsType = [ { title: '学生', dataIndex: ['student', 'name'], fixed: isMobile ? undefined : 'left', width: 150, render: (name: string, record) => (
{name?.slice(0, 1)}
{name || '-'} {record.class?.name || '未关联班级'}
), }, { title: '日期', dataIndex: 'attendanceDate', width: 120 }, { title: '时段', dataIndex: 'session', width: 110, render: (value: string) => sessionMap[value] || value, }, { title: '状态', dataIndex: 'status', width: 105, render: (value: string) => , }, { title: '签到来源', width: 220, render: (_: unknown, record: AttendanceRecordItem) => { const info = getPunchDisplayInfo(record); if (!info) return ; return (
{info.label} {info.detail && {info.detail}} {info.time && {dayjs(info.time).format('HH:mm:ss')}}
); }, }, { title: '备注', dataIndex: 'remark', ellipsis: true, render: (value: string | null) => value || , }, ...(canEdit ? [ { title: '操作', key: 'action', fixed: isMobile ? undefined : ('right' as const), width: 220, render: (_: unknown, record: AttendanceRecordItem) => ( void updateAdminRecordStatus(record, String(value))} /> ), }, ] : []), ]; return (

学生考勤中心

班级考勤总览
{syncStatus?.lastPulledAt ? `最近拉取钉钉 ${dayjs(syncStatus.lastPulledAt).format('YYYY-MM-DD HH:mm')}` : '暂无钉钉拉取记录'} {canEdit && ( )} } loading={refreshingDingTalk} onClick={() => void refreshDingTalkAttendance()} > 刷新钉钉考勤 } onClick={handleExport} > 导出当前报表
current.isAfter(dayjs(), 'day')} onChange={(value) => { setAttendanceDate(value); setScheduleId(undefined); setPage(1); }} />
: '当前日期没有排课'} onChange={(value) => { setScheduleId(value); setPage(1); }} options={subjectScheduleOptions} />
{ setSession(value); setPage(1); }} options={sessionOptions} />
班级考勤概览

{selectedClass}

{dateLabel} · 当前展示 {visibleStudents.length} 名学生 / {total} 条记录

班主任 按班级筛选后查看
生活老师 暂未接入
当前任课 {session ? sessionMap[session] : '全部时段'}
{ADMIN_METRIC_META.map((metric) => { const value = metric.key === 'all' ? `${attendanceRate}%` : metric.key === 'absent' ? summary.absent + summary.pending : (summary[metric.key as keyof AttendanceSummary] ?? 0); const meta = STATUS_META[metric.key] ?? { className: 'is-present' }; return ( ); })}
{alerts.length > 0 && (
{alerts.length} 名学生存在连续异常 建议优先核查最近 14 天的缺勤记录
`${item.studentName}:${item.type}${item.count}次`) .join(';')} >
)}

班级学生考勤

{metricFilter === 'all' ? `显示全部 ${visibleStudents.length} 名学生` : `筛出 ${ADMIN_METRIC_META.find((item) => item.key === metricFilter)?.label || ''}相关 ${visibleStudents.length} 名学生`}
setStudentSearch(event.target.value)} />
正常 请假 缺勤 仅显示当天已生成考勤的课程/时段,不再为无课时段补默认缺勤
{visibleStudents.length === 0 ? ( ) : (
{visibleStudents.map((student) => ( ))}
)}
rowKey="id" columns={columns} dataSource={records} loading={loading} scroll={{ x: 'max-content' }} pagination={{ current: page, pageSize, total, showSizeChanger: true, showTotal: (value) => `共 ${value} 条历史记录`, onChange: (nextPage, nextPageSize) => { setPage(nextPage); setPageSize(nextPageSize); }, }} locale={{ emptyText: ( ), }} /> setSelectedStudent(null)} width={isMobile ? '100%' : 520} title="学生考勤明细" className="student-detail-drawer" > {selectedStudent && (
{selectedStudent.studentName.slice(0, 1)}

{selectedStudent.studentName}

{selectedStudent.className} {selectedStudent.studentNo ? ` · 学号 ${selectedStudent.studentNo}` : ''}

{selectedStudent.rate}% 累计出勤率
{sortAttendanceRecords(selectedStudent.records).map((record) => { const normal = record.status === 'present'; return (
{normal ? '100%' : '0%'} {sessionMap[record.session] || record.session}
); })}
当天打卡时间
{sortAttendanceRecords(selectedStudent.records).map((record) => (
{sessionMap[record.session] || record.session} {record.punchTime ? dayjs(record.punchTime).format('HH:mm:ss') : '未记录'} {canEdit && ( void updateAdminRecordStatus(record, String(value))} /> )}
))}
近 7 日趋势
{Array.from({ length: 7 }).map((_, index) => { const value = Math.max(60, Math.min(100, selectedStudent.rate + index * 3 - 8)); return ; })}
)}
setPeriodModalOpen(false)} footer={(_, { OkBtn, CancelBtn }) => ( <> )} >
{(fields, { add, remove }) => (
{fields.map((field) => (