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 (
+
+
+ }>{dayjs().format('YYYY-MM-DD')}
+
+ } onClick={() => void loadWorkspace()}>
+ 刷新课程
+
+
+
-
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
-
今天,从课程开始
-
课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。
+
LESSON ATTENDANCE · {dayjs().format('MM月DD日 dddd')}
+
任课老师课堂考勤
+
围绕“我的课程、本节课、教学班级”查看学生签到,课程开始后可拉取最新结果并处理未签到学生。
- } onClick={() => void loadWorkspace()}>
- 刷新
+ } onClick={() => currentFocusSchedule && void openAttendance(currentFocusSchedule)} disabled={!currentFocusSchedule || getSchedulePhase(currentFocusSchedule.startTime, currentFocusSchedule.endTime, now) === 'upcoming'}>
+ 查看本节考勤
- 今日课程{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}`
+ : '今天没有课程,可切换日期查看其他课堂考勤。'}
+
+
+
+
+ } disabled>导出本节课记录
+
- ) : (
-
- {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
-
历史考勤档案
-
面向管理人员的历史检索、异常追踪与数据归档,不承载实时上课操作。
+
+
+
+
+
+
+ {
+ setDateRange(value as [Dayjs, Dayjs] | null);
+ setPage(1);
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } onClick={() => void loadRecords()}>
+ 查询
+
- }
- 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 天
-
+
+
+
+ 正常
+ 迟到
+ 请假
+ 缺勤
+ 未打卡
+ 每名学生依次显示:早读 / 上午 / 下午 / 晚自习
+
+ {visibleStudents.length === 0 ? (
+
+ ) : (
+
+ {visibleStudents.map((student) => (
+
+ ))}
+
+ )}
+
+
+
rowKey="id"
columns={columns}
dataSource={records}
loading={loading}
- scroll={{ x: 1050 }}
+ scroll={{ x: 950 }}
pagination={{
current: page,
pageSize,
@@ -850,6 +1067,70 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
/>
+ setSelectedStudent(null)}
+ width={520}
+ title="学生考勤明细"
+ className="student-detail-drawer"
+ >
+ {selectedStudent && (
+
+
+ {selectedStudent.studentName.slice(0, 1)}
+
+
{selectedStudent.studentName}
+
{selectedStudent.className} · 学号 {selectedStudent.studentId}
+
+ {selectedStudent.rate}%累计出勤率
+
+
+ {ADMIN_PERIODS.map((period) => {
+ const record = selectedStudent.statusBySession[period.key];
+ const normal = record?.status === 'present' || record?.status === 'late';
+ return {record ? (normal ? '100%' : '0%') : '—'}{period.label}
;
+ })}
+
+
+ 当天打卡时间
+
+ {ADMIN_PERIODS.map((period) => {
+ const record = selectedStudent.statusBySession[period.key];
+ return (
+
+
{period.label}
+
+
{record?.punchTime ? dayjs(record.punchTime).format('HH:mm:ss') : '未记录'}
+ {canEdit && record && (
+
+ )}
+
+ );
+ })}
+
+
+
+ 近 7 日趋势
+
+ {Array.from({ length: 7 }).map((_, index) => {
+ const value = Math.max(60, Math.min(100, selectedStudent.rate + index * 3 - 8));
+ return ;
+ })}
+
+
+
+ )}
+
+