import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, Avatar, Button, Card, Col, DatePicker, Drawer, Empty, Form, Input, Modal, Progress, Row, Select, Space, Spin, Table, Tag, Tooltip, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { ArrowRightOutlined, CheckCircleFilled, ClockCircleOutlined, EditOutlined, 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, getAttendanceExperience, getSchedulePhase, summarizeAttendance, type AttendanceSummary, type SchedulePhase, } from './attendance-workspace'; import './attendance.css'; const { RangePicker } = DatePicker; const SESSION_OPTIONS = [ { value: 'morning_reading', label: '早自习' }, { value: 'morning', label: '上午' }, { value: 'afternoon', label: '下午' }, { value: 'evening_study', label: '晚自习' }, { value: 'night_check', label: '晚寝' }, ]; const SESSION_MAP: Record = Object.fromEntries( SESSION_OPTIONS.map((item) => [item.value, item.label]), ); const STATUS_META: Record< string, { label: string; color: string; className: string; short: string } > = { present: { label: '出勤', color: 'success', className: 'is-present', short: '勤' }, late: { label: '迟到', color: 'warning', className: 'is-late', 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 STATUS_OPTIONS = Object.entries(STATUS_META) .filter(([value]) => value !== 'pending') .map(([value, item]) => ({ value, label: item.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 }; class: { id: number; name: string } | null; scheduleId?: number | null; attendanceSessionId?: number | 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 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; className: string; type: string; count: number; lastDate: string; } 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 AttendanceStatusTag({ status }: { status: string }) { const meta = STATUS_META[status] ?? { label: status, color: 'default', className: 'is-pending', short: '?', }; return ( {meta.label} ); } function SummaryStrip({ summary }: { summary: AttendanceSummary }) { const rate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0; return (
出勤率 {summary.total} 条记录
{[ ['present', summary.present], ['late', summary.late], ['absent', summary.absent], ['leave', summary.leave], ].map(([status, value]) => { const meta = STATUS_META[String(status)]; return (
{meta.short}
{value} {meta.label}
); })}
); } 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 [finishing, setFinishing] = useState(false); 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 loadLessonAttendance = useCallback(async (schedule: TodaySchedule) => { const today = dayjs().format('YYYY-MM-DD'); const data = await api.get( `/attendance-lessons/schedules/${schedule.id}`, { params: { date: today } }, ); setLessonSession(data.session); setLessonRecords(data.records); }, []); const openAttendance = useCallback(async (schedule: TodaySchedule) => { 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 }, ); 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 completeAttendance = useCallback(async () => { if (!lessonSession || !selectedSchedule) return; setFinishing(true); try { const result = await api.post( `/attendance-lessons/${lessonSession.id}/complete`, ); setLessonSession(result.session); setLessonRecords(result.records); message.success('考勤核对已完成'); await loadLessonAttendance(selectedSchedule); } catch (error: unknown) { message.error((error as { message?: string })?.message || '完成点名失败'); } finally { setFinishing(false); } }, [lessonSession, selectedSchedule, loadLessonAttendance]); 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 drawerSummary = summarizeAttendance(lessonRecords); const isAttendanceCompleted = lessonSession?.status === 'completed'; 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={820} title={null} className="attendance-drawer">
LESSON ATTENDANCE

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

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

{lessonSession && ( void completeAttendance()}> 完成核对 ) : undefined } /> )} rowKey="id" loading={recordLoading} dataSource={lessonRecords} pagination={false} locale={{ emptyText: }} columns={[ { title: '学生', dataIndex: ['student', 'name'], render: (name: string) =>
{name?.slice(0, 1)}{name || '-'}
, }, { title: '考勤结果', dataIndex: 'status', width: 310, render: (value: string, record: AttendanceRecordItem) => (
{STATUS_OPTIONS.map((option) => ( ))}
), }, { title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => }, { 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' ? ( ) : ( )}
); }; const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [records, setRecords] = useState([]); const [summary, setSummary] = useState(EMPTY_SUMMARY); const [alerts, setAlerts] = useState([]); const [classOptions, setClassOptions] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [total, setTotal] = useState(0); const [classId, setClassId] = useState(); const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([ dayjs().subtract(30, 'day'), dayjs(), ]); const [status, setStatus] = useState(); const [session, setSession] = useState(); const [editRecord, setEditRecord] = useState(null); const [editForm] = Form.useForm(); const buildParams = useCallback( (withPagination = true) => { const params: Record = {}; if (withPagination) Object.assign(params, { page, pageSize }); if (classId) params.classId = classId; if (dateRange?.[0]) params.dateFrom = dateRange[0].format('YYYY-MM-DD'); if (dateRange?.[1]) params.dateTo = dateRange[1].format('YYYY-MM-DD'); if (status) params.status = status; if (session) params.session = session; return params; }, [page, pageSize, classId, dateRange, status, session], ); const loadRecords = useCallback(async () => { setLoading(true); try { const [recordData, summaryData] = await Promise.all([ api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { params: buildParams(true), }), api.get('/attendance-records/summary', { params: buildParams(false), }), ]); setRecords(recordData.list); setTotal(recordData.total); setSummary({ ...EMPTY_SUMMARY, ...summaryData }); } catch (error: unknown) { message.error((error as { message?: string })?.message || '加载历史考勤失败'); } finally { setLoading(false); } }, [buildParams]); useEffect(() => { void Promise.all([ api.get('/attendance-records/classes').then(setClassOptions), api.get('/attendance-records/alerts').then(setAlerts), ]).catch(() => undefined); }, []); useEffect(() => { void loadRecords(); }, [loadRecords]); const resetFilters = () => { setClassId(undefined); setDateRange([dayjs().subtract(30, 'day'), dayjs()]); setStatus(undefined); setSession(undefined); 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 submitEdit = async () => { if (!editRecord) return; try { const values = await editForm.validateFields(); await api.put(`/attendance-records/${editRecord.id}`, values); message.success('考勤记录已更新'); setEditRecord(null); void loadRecords(); } catch (error: unknown) { if ((error as { errorFields?: unknown })?.errorFields) return; message.error((error as { message?: string })?.message || '更新失败'); } }; const columns: ColumnsType = [ { title: '学生', dataIndex: ['student', 'name'], fixed: '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) => SESSION_MAP[value] || value, }, { title: '结果', dataIndex: 'status', width: 110, render: (value: string) => , }, { title: '记录来源', dataIndex: 'source', width: 110, render: (value: string) => (value === 'dingtalk' ? '钉钉同步' : value === 'schedule' ? '课程生成' : value === 'lesson' ? '课堂点名' : '人工记录'), }, { title: '备注', dataIndex: 'remark', ellipsis: true, render: (value: string | null) => value || , }, { title: '归档时间', dataIndex: 'createdAt', width: 165, render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm'), }, ...(canEdit ? [ { title: '操作', key: 'action', fixed: 'right' as const, width: 80, render: (_: unknown, record: AttendanceRecordItem) => ( ), }, ] : []), ]; return (
ATTENDANCE ARCHIVE

历史考勤档案

面向管理人员的历史检索、异常追踪与数据归档,不承载实时上课操作。

} size="large" onClick={handleExport} > 导出当前结果
{alerts.length > 0 && (
{alerts.length} 名学生存在连续异常 建议优先核查最近 14 天的缺勤与迟到记录
`${item.studentName}:${item.type}${item.count}次`).join(';')}>
)}
档案检索 默认查看最近 30 天
{ setStatus(value); setPage(1); }} options={STATUS_OPTIONS} style={{ width: 130 }} />
); }; export default AttendancePage;