diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css index dd834d6f..8e542ff4 100644 --- a/apps/admin/src/pages/Attendance/attendance.css +++ b/apps/admin/src/pages/Attendance/attendance.css @@ -369,6 +369,36 @@ font-size: 20px; } + +.lesson-summary-strip { + grid-template-columns: minmax(210px, 1fr) repeat(2, minmax(120px, 0.7fr)); +} + +.lesson-summary-strip .attendance-rate, +.lesson-summary-strip .attendance-summary-cell { + justify-content: center; +} + +.lesson-summary-strip .attendance-rate { + text-align: left; +} + +.attendance-correction-segment.ant-segmented { + padding: 2px; + border: 1px solid var(--student-line, #e1e8e5); + background: #f8faf9; +} + +.attendance-correction-segment .ant-segmented-item { + min-width: 52px; +} + +.attendance-correction-segment .ant-segmented-item-label { + min-height: 28px; + line-height: 28px; + font-size: 12px; +} + .attendance-summary-icon { display: grid !important; flex: 0 0 auto; @@ -774,9 +804,10 @@ .student-filter-panel { display: grid; - grid-template-columns: minmax(230px, 1.4fr) repeat(4, minmax(120px, 1fr)) auto; - gap: 12px; + grid-template-columns: 176px 220px 142px 142px 142px auto; + gap: 10px; align-items: end; + justify-content: start; min-width: 0; margin-bottom: 16px; padding: 16px; @@ -803,13 +834,54 @@ .student-filter-actions { display: flex; - flex-wrap: wrap; + justify-content: flex-start; + flex-wrap: nowrap; gap: 8px; + padding-top: 2px; +} + +.student-filter-actions .ant-btn { + min-width: 88px; +} + + +.attendance-period-editor { + display: grid; + gap: 12px; +} + +.attendance-period-row { + display: grid; + grid-template-columns: 1.05fr 1fr 132px 132px 92px auto; + gap: 10px; + align-items: start; + padding: 12px; + border: 1px solid var(--student-line); + border-radius: 12px; + background: #fff; +} + +.attendance-period-row .ant-form-item { + margin-bottom: 0; +} + +.attendance-period-row__delete { + align-self: end; + min-width: 72px; +} + +.attendance-period-add { + height: 44px; + border-radius: 12px; +} + +.attendance-period-modal .ant-modal-body { + padding-top: 12px; } .student-class-overview { display: grid; - grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.1fr); + grid-template-columns: minmax(360px, 0.95fr) minmax(360px, 1.05fr); gap: 16px; min-width: 0; margin-bottom: 16px; @@ -819,9 +891,10 @@ .student-metric-strip, .student-workspace, .student-record-card { - border: 1px solid var(--student-line); - border-radius: 10px; + border: 1px solid rgb(21 122 101 / 12%); + border-radius: 18px; background: var(--student-surface); + box-shadow: 0 14px 36px rgb(24 44 38 / 7%); } .student-class-overview > *, @@ -831,26 +904,66 @@ } .student-class-identity { + position: relative; display: flex; flex-direction: column; justify-content: space-between; - min-height: 156px; - padding: 18px; + min-height: 176px; + overflow: hidden; + padding: 20px; + background: + radial-gradient(circle at 100% 0%, rgb(21 122 101 / 10%), transparent 34%), + linear-gradient(135deg, #ffffff 0%, #f8fcfa 100%); +} + +.student-class-identity::after { + content: ''; + position: absolute; + right: -36px; + bottom: -44px; + width: 118px; + height: 118px; + border: 18px solid rgb(21 122 101 / 7%); + border-radius: 999px; + pointer-events: none; +} + +.student-class-heading { + position: relative; + z-index: 1; +} + +.student-overview-kicker { + display: inline-flex; + align-items: center; + min-height: 24px; + margin-bottom: 8px; + padding: 0 9px; + border-radius: 999px; + background: rgb(21 122 101 / 10%); + color: var(--student-primary); + font-size: 12px; + font-weight: 700; } .student-class-identity h2 { - margin: 0 0 5px; - font-size: 22px; + margin: 0 0 6px; + color: #111c18; + font-size: 24px; + line-height: 1.2; } -.student-class-identity span { +.student-class-identity p { + margin: 0; color: var(--student-muted); - font-size: 12px; + font-size: 13px; } .student-teacher-list { - display: flex; - flex-wrap: wrap; + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-top: 20px; } @@ -858,78 +971,118 @@ .student-teacher-item { display: flex; align-items: center; - gap: 9px; - min-width: 150px; - padding: 9px; - border-radius: 8px; - background: var(--student-soft); + gap: 10px; + min-width: 0; + padding: 10px; + border: 1px solid rgb(21 122 101 / 10%); + border-radius: 12px; + background: rgb(255 255 255 / 76%); +} + +.student-teacher-item.is-muted { + opacity: 0.72; } .student-teacher-item .ant-avatar { + flex: 0 0 auto; color: var(--student-primary); background: var(--student-primary-soft); } +.student-teacher-item > div { + min-width: 0; +} + .student-teacher-item strong, .student-teacher-item span { display: block; } +.student-teacher-item span { + color: var(--student-muted); + font-size: 12px; +} + .student-teacher-item strong { + min-width: 0; margin-top: 2px; + overflow-wrap: anywhere; + color: #15231f; font-size: 13px; + line-height: 1.35; } .student-metric-strip { display: grid; - grid-template-columns: repeat(6, minmax(70px, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; min-width: 0; - overflow: hidden; + padding: 10px; } .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; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + min-height: 76px; + padding: 13px 14px; + border: 1px solid transparent; + border-radius: 14px; + background: #f9fbfa; color: inherit; text-align: left; cursor: pointer; -} - -.student-metric-card:last-child { - border-right: 0; + transition: + background 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; } .student-metric-card:hover, .student-metric-card.active { - background: var(--student-primary-soft); + transform: translateY(-1px); + border-color: rgb(21 122 101 / 24%); + background: #ffffff; + box-shadow: 0 10px 22px rgb(24 44 38 / 8%); +} + +.student-metric-card:focus-visible { + outline: 3px solid rgb(21 122 101 / 22%); + outline-offset: 2px; } .student-metric-icon { display: inline-grid; - width: max-content; - min-width: 34px; - height: 26px; + flex: 0 0 auto; + width: 40px; + height: 40px; place-items: center; - padding: 0 7px; - border-radius: 999px; + border-radius: 12px; font-size: 12px; - font-weight: 700; + font-weight: 800; +} + +.student-metric-copy { + display: grid; + gap: 4px; + min-width: 0; } .student-metric-card strong { - font-size: 24px; + overflow: hidden; + color: #111c18; + font-size: clamp(19px, 3vw, 24px); line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; } .student-metric-card small { color: var(--student-muted); + font-size: 12px; } .student-workspace { @@ -959,13 +1112,20 @@ .student-workspace-tools { display: flex; align-items: center; - flex-wrap: wrap; + flex: 0 0 auto; + flex-wrap: nowrap; gap: 8px; min-width: 0; + white-space: nowrap; } .student-workspace-tools .ant-input-search { - width: min(240px, 100%); + flex: 0 0 240px; + width: 240px; +} + +.student-workspace-tools .ant-btn { + flex: 0 0 auto; } .student-legend { @@ -1014,7 +1174,7 @@ .student-card-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(184px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(216px, 1fr)); gap: 10px; } @@ -1055,7 +1215,12 @@ } .student-attendance-head small { + flex: 0 0 auto; + max-width: 112px; + overflow: hidden; color: var(--student-quiet); + text-overflow: ellipsis; + white-space: nowrap; } .student-status-blocks { @@ -1189,10 +1354,11 @@ } .student-filter-field--date { - grid-column: span 2; + grid-column: auto; } .student-filter-actions { + grid-column: 1 / -1; justify-content: flex-end; } } @@ -1203,11 +1369,20 @@ } .student-metric-strip { - grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 760px) { + .lesson-summary-strip { + grid-template-columns: 1fr; + } + + .lesson-summary-strip .attendance-rate, + .lesson-summary-strip .attendance-summary-cell { + justify-content: flex-start; + } + .student-attendance-center { margin: -12px; padding: 0 12px 20px; @@ -1233,6 +1408,10 @@ } .student-filter-panel, + .attendance-period-row { + grid-template-columns: 1fr; + } + .student-class-overview { grid-template-columns: 1fr; } @@ -1242,12 +1421,21 @@ } .student-filter-actions > *, - .student-center-actions .ant-btn, - .student-workspace-tools, - .student-workspace-tools .ant-input-search { + .student-center-actions .ant-btn { width: 100%; } + .student-workspace-tools { + align-self: stretch; + width: 100%; + } + + .student-workspace-tools .ant-input-search { + flex: 1 1 auto; + min-width: 0; + width: auto; + } + .student-filter-actions > * { flex: 1; } @@ -1257,8 +1445,7 @@ } .student-metric-card { - min-height: 112px; - border-bottom: 1px solid var(--student-line); + min-height: 72px; } .student-workspace { @@ -1269,6 +1456,10 @@ grid-template-columns: 1fr; } + .student-teacher-list { + grid-template-columns: 1fr; + } + .student-detail-profile { grid-template-columns: auto minmax(0, 1fr); } @@ -1282,3 +1473,19 @@ grid-template-columns: 56px minmax(0, 1fr); } } + +@media (max-width: 430px) { + .student-metric-strip { + grid-template-columns: 1fr; + } + + .student-attendance-head { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + + .student-attendance-head small { + max-width: 100%; + } +} diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index fcbc8a14..82d5f7b6 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -13,9 +13,9 @@ import { Input, Modal, Progress, + Segmented, Row, Select, - Space, Spin, Table, Tag, @@ -26,11 +26,8 @@ import { ArrowRightOutlined, CheckCircleFilled, ClockCircleOutlined, - EditOutlined, ExportOutlined, - CalendarOutlined, FileSearchOutlined, - FilterOutlined, ReloadOutlined, ScheduleOutlined, TeamOutlined, @@ -54,34 +51,28 @@ import { } 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 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 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 })); +const ADMIN_CORRECTION_OPTIONS = [ + { value: 'present', label: '正常' }, + { value: 'leave', label: '请假' }, + { value: 'absent', label: '缺勤' }, +]; interface ClassOption { classId: number; @@ -98,7 +89,7 @@ interface AttendanceRecordItem { source?: string; remark: string | null; createdAt: string; - student: { id: number; name: string }; + student: { id: number; name: string; studentNo?: string | null }; class: { id: number; name: string } | null; scheduleId?: number | null; attendanceSessionId?: number | null; @@ -125,6 +116,19 @@ interface TodaySchedule { 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; @@ -147,12 +151,30 @@ interface TeacherWorkspaceData { 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, @@ -171,11 +193,16 @@ function readCurrentRoles(): string[] { } } +function displayAttendanceStatus(status?: string | null): string { + return status === 'pending' || !status ? 'absent' : status; +} + function AttendanceStatusTag({ status }: { status: string }) { - const meta = STATUS_META[status] ?? { - label: status, + const displayStatus = displayAttendanceStatus(status); + const meta = STATUS_META[displayStatus] ?? { + label: displayStatus, color: 'default', - className: 'is-pending', + className: 'is-absent', short: '?', }; return ( @@ -190,7 +217,7 @@ function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRe const summary = summarizeLessonCheckins(records); const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0; return ( -
+
@@ -238,8 +265,6 @@ 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); @@ -261,16 +286,6 @@ 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(''); @@ -297,39 +312,33 @@ const TeacherAttendanceWorkspace: React.FC = () => { } }, []); - 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) { + const updateLessonRecord = useCallback( + async (record: AttendanceRecordItem, status: string) => { + const previous = record.status; setLessonRecords((items) => - items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)), + items.map((item) => (item.id === record.id ? { ...item, status } : item)), ); - message.error((error as { message?: string })?.message || '更新课堂考勤失败'); - } - }, []); + 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)), + 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 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), @@ -338,232 +347,72 @@ const TeacherAttendanceWorkspace: React.FC = () => { return (
-
- - - - value={selectedClassId} - onChange={setSelectedClassId} - options={classFilterOptions} - className="teacher-topbar-select" - /> - - - - - - -
-
- - LESSON ATTENDANCE · {dayjs().format('MM月DD日 dddd')} - -

任课老师课堂考勤

-

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

+ TEACHING DAY · {dayjs().format('MM月DD日 dddd')} +

今天,从课程开始

+

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

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

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

-

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

-
-
- - -
-
- -
-
- 课堂考勤列表 -

我的课程

-
- 课程开始后可查看本节课学生签到和异常 -
- - - {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" - > + + {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')} -

+

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

+

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

{lessonSession && ( )} @@ -581,8 +430,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" /> @@ -596,30 +445,18 @@ const TeacherAttendanceWorkspace: React.FC = () => { dataSource={filteredLessonRecords} pagination={false} locale={{ - emptyText: ( - - ), + emptyText: , }} columns={[ { - title: '学生', - dataIndex: ['student', 'name'], - render: (name: string) => ( -
- {name?.slice(0, 1)} - {name || '-'} -
- ), + title: '学生', dataIndex: ['student', 'name'], + render: (name: string) =>
{name?.slice(0, 1)}{name || '-'}
, }, { - title: '课堂考勤操作', - dataIndex: 'status', - width: 250, + title: '考勤结果', dataIndex: 'status', width: 230, render: (value: string, record: AttendanceRecordItem) => { const checkedIn = value === 'present' || value === 'late'; return ( @@ -629,7 +466,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); @@ -668,12 +496,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { ); }, }, - { - title: '备注/异常处理', - dataIndex: 'remark', - render: (value: string | null) => - value || 暂无备注, - }, + { title: '备注', dataIndex: 'remark', render: (value: string | null) => value || }, ]} /> @@ -697,29 +520,17 @@ const LessonCard: React.FC<{ return (
{String(index).padStart(2, '0')}
-
- {schedule.startTime} - - {schedule.endTime} -
+
{schedule.startTime}{schedule.endTime}
-
-

{schedule.subject}

- {phaseMeta.label} -
-

- {className} - 教室 {schedule.classroomId} -

+

{schedule.subject}

{phaseMeta.label}
+

{className}教室 {schedule.classroomId}

{phase === 'upcoming' ? ( - - - + ) : ( )}
@@ -727,10 +538,12 @@ const LessonCard: React.FC<{ ); }; + interface AdminStudentPanel { key: string; studentId: number; studentName: string; + studentNo: string; className: string; records: AttendanceRecordItem[]; statusBySession: Partial>; @@ -739,25 +552,16 @@ interface AdminStudentPanel { 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'; + const priority = ['absent', 'leave', 'present']; + return priority.find((item) => records.some((record) => displayAttendanceStatus(record.status) === item)) || 'absent'; } function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] { @@ -767,10 +571,11 @@ function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentP key: String(record.studentId), studentId: record.studentId, studentName: record.student?.name || '未知学生', + studentNo: record.student?.studentNo?.trim() || '', className: record.class?.name || '未关联班级', records: [], statusBySession: {}, - primaryStatus: 'pending', + primaryStatus: 'absent', rate: 0, latestDate: record.attendanceDate, }; @@ -784,7 +589,7 @@ function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentP return Array.from(map.values()).map((item) => { const checked = item.records.filter( - (record) => record.status === 'present' || record.status === 'late', + (record) => record.status === 'present', ).length; return { ...item, @@ -801,48 +606,145 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => 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 [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([dayjs(), dayjs()]); + 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 [editRecord, setEditRecord] = useState(null); - const [editForm] = Form.useForm(); + 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 (dateRange?.[0]) params.dateFrom = dateRange[0].format('YYYY-MM-DD'); - if (dateRange?.[1]) params.dateTo = dateRange[1].format('YYYY-MM-DD'); + 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, dateRange, status, session], + [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] = await Promise.all([ + 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 { @@ -850,22 +752,87 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => } }, [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); - setDateRange([dayjs(), dayjs()]); + setAttendanceDate(dayjs()); setStatus(undefined); setSession(undefined); + setScheduleId(undefined); + setScheduleOptions([]); setMetricFilter('all'); setStudentSearch(''); setPage(1); @@ -893,20 +860,47 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => .catch(() => message.error('导出失败')); }, [buildParams]); - const submitEdit = async () => { - if (!editRecord) return; + 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 { - const values = await editForm.validateFields(); - await api.put(`/attendance-records/${editRecord.id}`, values); - message.success('考勤记录已更新'); - setEditRecord(null); + await api.put(`/attendance-records/${record.id}`, { status: nextStatus }); + message.success('考勤结果已更新'); void loadRecords(); } catch (error: unknown) { - if ((error as { errorFields?: unknown })?.errorFields) return; + 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'); @@ -914,9 +908,10 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => const matchesQuery = !query || student.studentName.toLocaleLowerCase('zh-CN').includes(query) || - String(student.studentId).includes(query); + student.studentNo.toLocaleLowerCase('zh-CN').includes(query); const matchesMetric = - metricFilter === 'all' || student.records.some((record) => record.status === metricFilter); + metricFilter === 'all' || + student.records.some((record) => displayAttendanceStatus(record.status) === metricFilter); return matchesQuery && matchesMetric; }); }, [metricFilter, studentPanels, studentSearch]); @@ -926,9 +921,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => : '全部班级'; 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 dateLabel = attendanceDate?.format('YYYY-MM-DD') || '未选择日期'; const columns: ColumnsType = [ { @@ -951,13 +944,13 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => title: '时段', dataIndex: 'session', width: 110, - render: (value: string) => SESSION_MAP[value] || value, + render: (value: string) => sessionMap[value] || value, }, { title: '状态', dataIndex: 'status', width: 105, - render: (value: string) => , + render: (value: string) => , }, { title: '签到来源', @@ -986,18 +979,16 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => title: '操作', key: 'action', fixed: isMobile ? undefined : ('right' as const), - width: 80, + width: 220, render: (_: unknown, record: AttendanceRecordItem) => ( - + void updateAdminRecordStatus(record, String(value))} + /> ), }, ] @@ -1012,13 +1003,27 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => 班级考勤总览
- - - 数据已更新 {dayjs().format('HH:mm')} - - + + + + {syncStatus?.lastPulledAt + ? `最近拉取钉钉 ${dayjs(syncStatus.lastPulledAt).format('YYYY-MM-DD HH:mm')}` + : '暂无钉钉拉取记录'} + + + {canEdit && ( + + )} + } + loading={refreshingDingTalk} + onClick={() => void refreshDingTalkAttendance()} + > + 刷新钉钉考勤 + } @@ -1032,10 +1037,13 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
- current.isAfter(dayjs(), 'day')} onChange={(value) => { - setDateRange(value as [Dayjs, Dayjs] | null); + setAttendanceDate(value); + setScheduleId(undefined); setPage(1); }} /> @@ -1050,6 +1058,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => value={classId} onChange={(value) => { setClassId(value); + setScheduleId(undefined); setPage(1); }} options={classOptions.map((item) => ({ value: item.classId, label: item.className }))} @@ -1057,7 +1066,21 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
- : '当前日期没有排课'} + onChange={(value) => { + setScheduleId(value); + setPage(1); + }} + options={subjectScheduleOptions} + />
@@ -1073,7 +1096,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => setSession(value); setPage(1); }} - options={SESSION_OPTIONS} + options={sessionOptions} />
@@ -1086,13 +1109,14 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
-
+
+ 班级考勤概览

{selectedClass}

- +

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

-
+
@@ -1100,7 +1124,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => 按班级筛选后查看
-
+
生活老师 @@ -1111,7 +1135,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
当前任课 - {session ? SESSION_MAP[session] : '全部时段'} + {session ? sessionMap[session] : '全部时段'}
@@ -1121,7 +1145,9 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => const value = metric.key === 'all' ? `${attendanceRate}%` - : (summary[metric.key as keyof AttendanceSummary] ?? 0); + : metric.key === 'absent' + ? summary.absent + summary.pending + : (summary[metric.key as keyof AttendanceSummary] ?? 0); const meta = STATUS_META[metric.key] ?? { className: 'is-present' }; return ( ); })} @@ -1144,7 +1172,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
{alerts.length} 名学生存在连续异常 - 建议优先核查最近 14 天的缺勤与迟到记录 + 建议优先核查最近 14 天的缺勤记录
= ({ canEdit }) => 正常 - - - 迟到 - 请假 @@ -1196,11 +1220,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => 缺勤 - - - 未打卡 - - 每名学生依次显示:早读 / 上午 / 下午 / 晚自习 + 仅显示当天已生成考勤的课程/时段,不再为无课时段补默认缺勤
{visibleStudents.length === 0 ? ( @@ -1219,18 +1239,18 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => > {student.studentName} - {String(student.studentId).slice(-4)} + {student.studentNo && 学号 {student.studentNo}} - {ADMIN_PERIODS.map((period) => { - const record = student.statusBySession[period.key]; - const currentStatus = record?.status || 'pending'; - const meta = STATUS_META[currentStatus] ?? STATUS_META.pending; + {sortAttendanceRecords(student.records).map((record) => { + const currentStatus = displayAttendanceStatus(record.status); + const meta = STATUS_META[currentStatus] ?? STATUS_META.absent; + const sessionLabel = sessionMap[record.session] || record.session; return ( {meta.label === '出勤' ? '正常' : meta.label} @@ -1287,7 +1307,8 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>

{selectedStudent.studentName}

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

@@ -1296,13 +1317,12 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
- {ADMIN_PERIODS.map((period) => { - const record = selectedStudent.statusBySession[period.key]; - const normal = record?.status === 'present' || record?.status === 'late'; + {sortAttendanceRecords(selectedStudent.records).map((record) => { + const normal = record.status === 'present'; return ( -
- {record ? (normal ? '100%' : '0%') : '—'} - {period.label} +
+ {normal ? '100%' : '0%'} + {sessionMap[record.session] || record.session}
); })} @@ -1310,33 +1330,25 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
当天打卡时间
- {ADMIN_PERIODS.map((period) => { - const record = selectedStudent.statusBySession[period.key]; - return ( -
- {period.label} - - - {record?.punchTime ? dayjs(record.punchTime).format('HH:mm:ss') : '未记录'} - - {canEdit && record && ( - - )} -
- ); - })} + {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))} + /> + )} +
+ ))}
@@ -1352,23 +1364,103 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => )} + setEditRecord(null)} + open={periodModalOpen} + title="考勤时段配置" + okText="保存配置" + width={860} + className="attendance-period-modal" + onOk={savePeriodConfig} + onCancel={() => setPeriodModalOpen(false)} + footer={(_, { OkBtn, CancelBtn }) => ( + <> + + + + + )} > - -
- - + + + + + + + + + + + + - + { + { + setFilterTeacherId(v); + }} + options={teacherOptions.map((item) => ({ + value: item.id, + label: item.name === item.username ? item.name : `${item.name}(${item.username})`, + }))} + />