From 98d335b8897f190012c3dbe6949b6b942d9acd55 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 18 Jul 2026 15:34:02 +0800 Subject: [PATCH] feat: redesign teacher attendance workspace --- .../admin/src/pages/Attendance/attendance.css | 615 ++++++++++++++++- apps/admin/src/pages/Attendance/index.tsx | 643 +++++++++++++----- 2 files changed, 1076 insertions(+), 182 deletions(-) diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css index f80a2de..03befa0 100644 --- a/apps/admin/src/pages/Attendance/attendance.css +++ b/apps/admin/src/pages/Attendance/attendance.css @@ -66,6 +66,102 @@ opacity: 0.72; } +.teacher-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: #fff; + box-shadow: 0 5px 18px rgb(23 32 51 / 4%); +} + +.teacher-topbar-select { + min-width: 160px; +} + +.teacher-workspace-layout { + display: grid; + grid-template-columns: 168px minmax(0, 1fr); + gap: 16px; +} + +.teacher-filter-rail { + position: sticky; + top: 16px; + align-self: start; + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: #fff; + box-shadow: 0 5px 18px rgb(23 32 51 / 4%); +} + +.teacher-filter-title { + display: flex; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.teacher-filter-select { + width: 100%; +} + +.teacher-filter-hint { + color: var(--muted); + font-size: 12px; + line-height: 1.6; +} + +.teacher-main-panel { + min-width: 0; +} + +.current-lesson-card { + margin-bottom: 18px; + border: 1px solid #d8e6fb; + border-radius: 14px; + background: linear-gradient(120deg, #f7fbff 0%, #fff 70%); +} + +.current-lesson-card .ant-card-body { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.current-lesson-copy > span { + color: var(--muted); + font-size: 12px; +} + +.current-lesson-copy h2 { + margin: 4px 0; + font-size: 22px; +} + +.current-lesson-copy p { + margin: 0; + color: var(--muted); +} + +.current-lesson-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + .teacher-overview { margin-bottom: 30px; } @@ -458,7 +554,8 @@ @media (max-width: 900px) { .attendance-hero, - .archive-toolbar { + .archive-toolbar, + .teacher-topbar { align-items: flex-start; flex-direction: column; } @@ -494,6 +591,23 @@ grid-column: 2 / -1; padding: 14px 0 0; } + + .teacher-workspace-layout { + grid-template-columns: 1fr; + } + + .teacher-filter-rail { + position: static; + } + + .current-lesson-card .ant-card-body { + align-items: flex-start; + flex-direction: column; + } + + .current-lesson-actions { + justify-content: flex-start; + } } @media (max-width: 576px) { @@ -566,3 +680,502 @@ color: #8c8c8c; font-size: 12px; } + +/* Student attendance center — adapted from the provided class overview reference */ +.student-attendance-center { + --student-bg: #f4f7f7; + --student-surface: #ffffff; + --student-soft: #f7faf9; + --student-ink: #17201e; + --student-muted: #66736f; + --student-quiet: #8a9692; + --student-line: #e1e8e5; + --student-primary: #157a65; + --student-primary-soft: #e8f4f0; + min-height: calc(100vh - 64px); + margin: -24px; + padding: 0 24px 28px; + background: var(--student-bg); + color: var(--student-ink); +} + +.student-center-topbar { + position: sticky; + top: 0; + z-index: 12; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + min-height: 60px; + margin: 0 -24px 18px; + padding: 0 24px; + border-bottom: 1px solid var(--student-line); + background: rgb(255 255 255 / 96%); + backdrop-filter: blur(10px); +} + +.student-center-title { + display: flex; + align-items: baseline; + gap: 12px; +} + +.student-center-title h1 { + margin: 0; + font-size: 18px; + letter-spacing: 0; +} + +.student-center-title span, +.student-sync-status { + color: var(--student-quiet); + font-size: 12px; +} + +.student-center-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.student-sync-status { + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: 6px; +} + +.student-sync-status i { + width: 7px; + height: 7px; + border-radius: 50%; + background: #2f9c78; +} + +.student-filter-panel { + display: grid; + grid-template-columns: minmax(260px, 1.4fr) repeat(4, minmax(150px, 1fr)) auto; + gap: 12px; + align-items: end; + margin-bottom: 16px; + padding: 16px; + border: 1px solid var(--student-line); + border-radius: 10px; + background: var(--student-surface); +} + +.student-filter-field { + display: grid; + gap: 6px; + min-width: 0; +} + +.student-filter-field label { + color: var(--student-muted); + font-size: 12px; +} + +.student-filter-field .ant-picker, +.student-filter-field .ant-select { + width: 100%; +} + +.student-filter-actions { + display: flex; + gap: 8px; +} + +.student-class-overview { + display: grid; + grid-template-columns: minmax(320px, .9fr) minmax(0, 1.1fr); + gap: 16px; + margin-bottom: 16px; +} + +.student-class-identity, +.student-metric-strip, +.student-workspace, +.student-record-card { + border: 1px solid var(--student-line); + border-radius: 10px; + background: var(--student-surface); +} + +.student-class-identity { + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 156px; + padding: 18px; +} + +.student-class-identity h2 { + margin: 0 0 5px; + font-size: 22px; +} + +.student-class-identity span { + color: var(--student-muted); + font-size: 12px; +} + +.student-teacher-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 20px; +} + +.student-teacher-item { + display: flex; + align-items: center; + gap: 9px; + min-width: 150px; + padding: 9px; + border-radius: 8px; + background: var(--student-soft); +} + +.student-teacher-item .ant-avatar { + color: var(--student-primary); + background: var(--student-primary-soft); +} + +.student-teacher-item strong, +.student-teacher-item span { + display: block; +} + +.student-teacher-item strong { + margin-top: 2px; + font-size: 13px; +} + +.student-metric-strip { + display: grid; + grid-template-columns: repeat(6, minmax(82px, 1fr)); + overflow: hidden; +} + +.student-metric-card { + appearance: none; + display: grid; + align-content: center; + gap: 6px; + min-height: 156px; + padding: 16px 12px; + border: 0; + border-right: 1px solid var(--student-line); + background: #fff; + color: inherit; + text-align: left; + cursor: pointer; +} + +.student-metric-card:last-child { + border-right: 0; +} + +.student-metric-card:hover, +.student-metric-card.active { + background: var(--student-primary-soft); +} + +.student-metric-icon { + display: inline-grid; + width: max-content; + min-width: 34px; + height: 26px; + place-items: center; + padding: 0 7px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.student-metric-card strong { + font-size: 24px; + line-height: 1; +} + +.student-metric-card small { + color: var(--student-muted); +} + +.student-workspace { + margin-bottom: 16px; + padding: 0 18px 20px; +} + +.student-workspace-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + min-height: 70px; + border-bottom: 1px solid var(--student-line); +} + +.student-workspace-header h3 { + margin: 0 0 4px; + font-size: 17px; +} + +.student-workspace-header span { + color: var(--student-muted); + font-size: 12px; +} + +.student-workspace-tools { + display: flex; + align-items: center; + gap: 8px; +} + +.student-workspace-tools .ant-input-search { + width: 240px; +} + +.student-legend { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + color: var(--student-muted); + font-size: 12px; +} + +.student-legend span { + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: 12px; +} + +.student-legend i { + width: 9px; + height: 9px; + border-radius: 2px; +} + +.student-legend i.is-present { background: #2f9c78; } +.student-legend i.is-late { background: #d78a18; } +.student-legend i.is-leave { background: #397bbf; } +.student-legend i.is-absent { background: #d44d4d; } +.student-legend i.is-pending { background: #8a9692; } + +.student-legend em { + color: var(--student-quiet); + font-style: normal; +} + +.student-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(184px, 1fr)); + gap: 10px; +} + +.student-attendance-card { + appearance: none; + display: grid; + gap: 12px; + min-height: 96px; + padding: 13px; + border: 1px solid var(--student-line); + border-radius: 8px; + background: #fff; + color: inherit; + text-align: left; + cursor: pointer; + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.student-attendance-card:hover, +.student-attendance-card.selected { + transform: translateY(-1px); + border-color: #91c8b9; + box-shadow: 0 8px 20px rgb(24 44 38 / 8%); +} + +.student-attendance-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.student-attendance-head strong { + font-size: 15px; +} + +.student-attendance-head small { + color: var(--student-quiet); +} + +.student-status-blocks { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} + +.student-status-block { + display: grid; + min-height: 28px; + place-items: center; + border-radius: 5px; + font-size: 12px; + font-weight: 700; +} + +.student-record-card { + overflow: hidden; + margin-bottom: 16px; +} + +.student-record-card .ant-card-body { + padding: 0 18px 14px; +} + +.student-detail-panel { + display: grid; + gap: 18px; +} + +.student-detail-profile { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding-bottom: 18px; + border-bottom: 1px solid var(--student-line); +} + +.student-detail-profile h4 { + margin: 0 0 4px; + font-size: 18px; +} + +.student-detail-profile p, +.student-detail-rate span { + margin: 0; + color: var(--student-muted); + font-size: 12px; +} + +.student-detail-rate { + text-align: right; +} + +.student-detail-rate strong { + display: block; + color: var(--student-primary); + font-size: 24px; +} + +.student-detail-rates { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.student-detail-rates > div { + padding: 12px 8px; + border-radius: 8px; + background: var(--student-soft); + text-align: center; +} + +.student-detail-rates strong, +.student-detail-rates span { + display: block; +} + +.student-detail-rates span { + margin-top: 4px; + color: var(--student-muted); + font-size: 12px; +} + +.student-detail-section h5 { + margin: 0 0 10px; + font-size: 14px; +} + +.student-detail-timeline { + display: grid; + gap: 8px; +} + +.student-detail-timeline > div { + display: grid; + grid-template-columns: 64px 96px 1fr auto; + gap: 8px; + align-items: center; + padding: 10px; + border: 1px solid var(--student-line); + border-radius: 8px; +} + +.student-detail-timeline > div > span:first-child { + color: var(--student-muted); +} + +.student-trend-bars { + display: flex; + align-items: end; + gap: 8px; + height: 110px; + padding: 12px; + border-radius: 8px; + background: var(--student-soft); +} + +.student-trend-bars i { + flex: 1; + min-height: 18px; + border-radius: 5px 5px 0 0; + background: linear-gradient(180deg, #56bea3, #157a65); +} + +@media (max-width: 1180px) { + .student-filter-panel, + .student-class-overview { + grid-template-columns: 1fr 1fr; + } + + .student-filter-actions { + grid-column: 1 / -1; + justify-content: flex-end; + } + + .student-metric-strip { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 760px) { + .student-attendance-center { + margin: -16px; + padding: 0 16px 22px; + } + + .student-center-topbar, + .student-workspace-header, + .student-center-actions, + .student-legend { + align-items: flex-start; + flex-direction: column; + } + + .student-filter-panel, + .student-class-overview { + grid-template-columns: 1fr; + } + + .student-workspace-tools, + .student-workspace-tools .ant-input-search { + width: 100%; + } +} diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index 3c43313..a047553 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -27,7 +27,9 @@ import { ClockCircleOutlined, EditOutlined, ExportOutlined, + CalendarOutlined, FileSearchOutlined, + FilterOutlined, ReloadOutlined, ScheduleOutlined, TeamOutlined, @@ -183,45 +185,6 @@ function AttendanceStatusTag({ status }: { status: string }) { ); } -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} -
-
- ); - })} -
- ); -} - function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) { const summary = summarizeLessonCheckins(records); const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0; @@ -265,6 +228,8 @@ const TeacherAttendanceWorkspace: React.FC = () => { const [drawerOpen, setDrawerOpen] = useState(false); const [studentKeyword, setStudentKeyword] = useState(''); const [checkinFilter, setCheckinFilter] = useState('all'); + const [selectedClassId, setSelectedClassId] = useState('all'); + const [phaseFilter, setPhaseFilter] = useState('all'); const loadWorkspace = useCallback(async () => { setLoading(true); @@ -286,6 +251,17 @@ const TeacherAttendanceWorkspace: React.FC = () => { [workspace], ); + const classFilterOptions = useMemo( + () => [ + { value: 'all' as const, label: '全部教学班' }, + ...(workspace?.assignedClasses.map((item) => ({ + value: item.classId, + label: item.className, + })) ?? []), + ], + [workspace], + ); + const openAttendance = useCallback(async (schedule: TodaySchedule) => { setStudentKeyword(''); @@ -324,7 +300,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { setLessonRecords((items) => items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)), ); - message.error((error as { message?: string })?.message || '更新考勤失败'); + message.error((error as { message?: string })?.message || '更新课堂考勤失败'); } }, [], @@ -339,6 +315,16 @@ const TeacherAttendanceWorkspace: React.FC = () => { const nextSchedule = schedules.find( (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended', ); + const filteredSchedules = schedules.filter((item) => { + const phase = getSchedulePhase(item.startTime, item.endTime, now); + const matchesClass = selectedClassId === 'all' || item.classId === selectedClassId; + const matchesPhase = phaseFilter === 'all' || phase === phaseFilter; + return matchesClass && matchesPhase; + }); + const currentFocusSchedule = nextSchedule ?? schedules.at(-1) ?? null; + const currentFocusClassName = currentFocusSchedule + ? classNameById.get(currentFocusSchedule.classId) || `班级 ${currentFocusSchedule.classId}` + : '暂无教学班'; const isAttendanceCompleted = lessonSession?.status === 'completed'; const filteredLessonRecords = useMemo( () => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter), @@ -347,72 +333,135 @@ const TeacherAttendanceWorkspace: React.FC = () => { return (
+
+ + + + value={selectedClassId} + onChange={setSelectedClassId} + options={classFilterOptions} + className="teacher-topbar-select" + /> + + + + + + +
+
- TEACHING DAY · {dayjs().format('MM月DD日 dddd')} -

今天,从课程开始

-

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

+ LESSON ATTENDANCE · {dayjs().format('MM月DD日 dddd')} +

任课老师课堂考勤

+

围绕“我的课程、本节课、教学班级”查看学生签到,课程开始后可拉取最新结果并处理未签到学生。

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

我的课程

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

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

} - /> +
+ +
+ 主页主功能区 +

{currentFocusSchedule?.subject || '暂无待处理课程'}

+

+ {currentFocusSchedule + ? `${currentFocusClassName} · ${currentFocusSchedule.startTime}-${currentFocusSchedule.endTime} · 教室 ${currentFocusSchedule.classroomId}` + : '今天没有课程,可切换日期查看其他课堂考勤。'} +

+
+
+ + +
- ) : ( -
- {schedules.map((schedule, index) => { - const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now); - return ( - void openAttendance(schedule)} - /> - ); - })} + +
+
课堂考勤列表

我的课程

+ 课程开始后可查看本节课学生签到和异常
- )} - + + + {schedules.length === 0 ? ( + + 今天还没有课程

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

} + /> + + ) : filteredSchedules.length === 0 ? ( + + 没有符合筛选条件的课程

请调整左侧教学班级或课程状态。

} + /> + + ) : ( +
+ {filteredSchedules.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')}

+

{selectedSchedule?.subject || '本节课考勤'}

+

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

{lessonSession && ( )} @@ -430,8 +479,8 @@ const TeacherAttendanceWorkspace: React.FC = () => { onChange={setCheckinFilter} options={[ { value: 'all', label: '全部学生' }, - { value: 'checked_in', label: '已打卡' }, - { value: 'not_checked_in', label: '未打卡' }, + { value: 'checked_in', label: '已到学生' }, + { value: 'not_checked_in', label: '未签到/旷课' }, ]} className="lesson-record-filter-select" /> @@ -447,7 +496,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { locale={{ emptyText: , }} columns={[ @@ -456,7 +505,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { render: (name: string) =>
{name?.slice(0, 1)}{name || '-'}
, }, { - title: '考勤结果', dataIndex: 'status', width: 230, + title: '课堂考勤操作', dataIndex: 'status', width: 250, render: (value: string, record: AttendanceRecordItem) => { const checkedIn = value === 'present' || value === 'late'; return ( @@ -466,7 +515,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { type={checkedIn ? 'primary' : 'default'} onClick={() => void updateLessonRecord(record, 'present')} > - 已打卡 + 标记已到 ); }, }, - { title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => }, + { title: '当前状态', dataIndex: 'status', width: 118, render: (value: string) => }, { - title: '打卡设备', + title: '签到来源', width: 220, render: (_: unknown, record: AttendanceRecordItem) => { const info = getPunchDisplayInfo(record); @@ -496,7 +545,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { ); }, }, - { title: '备注', dataIndex: 'remark', render: (value: string | null) => value || }, + { title: '备注/异常处理', dataIndex: 'remark', render: (value: string | null) => value || 暂无备注 }, ]} />
@@ -530,7 +579,7 @@ const LessonCard: React.FC<{ ) : ( )} @@ -539,6 +588,71 @@ const LessonCard: React.FC<{ }; +interface AdminStudentPanel { + key: string; + studentId: number; + studentName: string; + className: string; + records: AttendanceRecordItem[]; + statusBySession: Partial>; + primaryStatus: string; + rate: number; + latestDate: string; +} + +const ADMIN_PERIODS = [ + { key: 'morning_reading', label: '早读' }, + { key: 'morning', label: '上午' }, + { key: 'afternoon', label: '下午' }, + { key: 'evening_study', label: '晚自习' }, +]; + +const ADMIN_METRIC_META = [ + { key: 'all', label: '出勤率', short: '率' }, + { key: 'present', label: '正常', short: '正常' }, + { key: 'late', label: '迟到', short: '迟到' }, + { key: 'leave', label: '请假', short: '请假' }, + { key: 'absent', label: '缺勤', short: '缺勤' }, + { key: 'pending', label: '未打卡', short: '待核' }, +]; + +function pickPrimaryStatus(records: AttendanceRecordItem[]) { + const priority = ['absent', 'late', 'leave', 'pending', 'present']; + return priority.find((item) => records.some((record) => record.status === item)) || 'pending'; +} + +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 || '未知学生', + className: record.class?.name || '未关联班级', + records: [], + statusBySession: {}, + primaryStatus: 'pending', + 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' || record.status === 'late').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 [records, setRecords] = useState([]); const [summary, setSummary] = useState(EMPTY_SUMMARY); @@ -546,15 +660,15 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => const [classOptions, setClassOptions] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); + const [pageSize, setPageSize] = useState(48); const [total, setTotal] = useState(0); const [classId, setClassId] = useState(); - const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([ - dayjs().subtract(30, 'day'), - dayjs(), - ]); + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([dayjs(), dayjs()]); const [status, setStatus] = useState(); const [session, setSession] = useState(); + const [metricFilter, setMetricFilter] = useState('all'); + const [studentSearch, setStudentSearch] = useState(''); + const [selectedStudent, setSelectedStudent] = useState(null); const [editRecord, setEditRecord] = useState(null); const [editForm] = Form.useForm(); @@ -587,7 +701,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => setTotal(recordData.total); setSummary({ ...EMPTY_SUMMARY, ...summaryData }); } catch (error: unknown) { - message.error((error as { message?: string })?.message || '加载历史考勤失败'); + message.error((error as { message?: string })?.message || '加载学生考勤失败'); } finally { setLoading(false); } @@ -606,9 +720,11 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => const resetFilters = () => { setClassId(undefined); - setDateRange([dayjs().subtract(30, 'day'), dayjs()]); + setDateRange([dayjs(), dayjs()]); setStatus(undefined); setSession(undefined); + setMetricFilter('all'); + setStudentSearch(''); setPage(1); }; @@ -627,7 +743,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; - anchor.download = `历史考勤-${dayjs().format('YYYYMMDD')}.xlsx`; + anchor.download = `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`; anchor.click(); URL.revokeObjectURL(url); }) @@ -648,6 +764,28 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => } }; + 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) || + String(student.studentId).includes(query); + const matchesMetric = + metricFilter === 'all' || student.records.some((record) => 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 = dateRange?.[0]?.isSame(dateRange?.[1], 'day') + ? dateRange?.[0]?.format('YYYY-MM-DD') + : `${dateRange?.[0]?.format('YYYY-MM-DD') || '开始日期'} 至 ${dateRange?.[1]?.format('YYYY-MM-DD') || '结束日期'}`; + const columns: ColumnsType = [ { title: '学生', @@ -672,19 +810,13 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => render: (value: string) => SESSION_MAP[value] || value, }, { - title: '结果', + title: '状态', dataIndex: 'status', - width: 110, + width: 105, render: (value: string) => , }, { - title: '记录来源', - dataIndex: 'source', - width: 110, - render: (value: string) => (value === 'dingtalk' ? '钉钉同步' : value === 'schedule' ? '课程生成' : value === 'lesson' ? '课堂点名' : '人工记录'), - }, - { - title: '打卡设备', + title: '签到来源', width: 220, render: (_: unknown, record: AttendanceRecordItem) => { const info = getPunchDisplayInfo(record); @@ -704,12 +836,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => ellipsis: true, render: (value: string | null) => value || , }, - { - title: '归档时间', - dataIndex: 'createdAt', - width: 165, - render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm'), - }, ...(canEdit ? [ { @@ -735,24 +861,109 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => ]; return ( -
-
-
- ATTENDANCE ARCHIVE -

历史考勤档案

-

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

+
+
+
+

学生考勤中心

+ 班级考勤总览 +
+
+ 数据已更新 {dayjs().format('HH:mm')} + + } onClick={handleExport}> + 导出当前报表 + +
+
+ +
+
+ + { + setDateRange(value as [Dayjs, Dayjs] | null); + setPage(1); + }} + /> +
+
+ + +
+
+ + { + setSession(value); + setPage(1); + }} + options={SESSION_OPTIONS} + /> +
+
+ +
- } - size="large" - onClick={handleExport} - > - 导出当前结果 -
- +
+
+
+

{selectedClass}

+ {dateLabel} · 当前展示 {visibleStudents.length} 名学生 / {total} 条记录 +
+
+
班主任按班级筛选后查看
+
生活老师暂未接入
+
当前任课{session ? SESSION_MAP[session] : '全部时段'}
+
+
+
+ {ADMIN_METRIC_META.map((metric) => { + const value = metric.key === 'all' + ? `${attendanceRate}%` + : summary[metric.key as keyof AttendanceSummary] ?? 0; + const meta = STATUS_META[metric.key] ?? { className: 'is-present' }; + return ( + + ); + })} +
+
{alerts.length > 0 && (
@@ -767,70 +978,76 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
)} - -
-
- -
- 档案检索 - 默认查看最近 30 天 -
+
+
+
+

班级学生考勤

+ {metricFilter === 'all' ? `显示全部 ${visibleStudents.length} 名学生` : `筛出 ${ADMIN_METRIC_META.find((item) => item.key === metricFilter)?.label || ''}相关 ${visibleStudents.length} 名学生`}
- - { - setStatus(value); - setPage(1); - }} - options={STATUS_OPTIONS} - style={{ width: 130 }} - /> -