import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Avatar,
Button,
Card,
Col,
DatePicker,
Drawer,
Grid,
Empty,
Form,
Input,
Modal,
Progress,
Segmented,
Row,
Select,
Spin,
Table,
Tag,
Tooltip,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ArrowRightOutlined,
CheckCircleFilled,
ClockCircleOutlined,
ExportOutlined,
FileSearchOutlined,
ReloadOutlined,
ScheduleOutlined,
TeamOutlined,
WarningFilled,
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { usePermission } from '../../hooks/usePermission';
import { message } from '../../ui/app-message';
import {
canPullAttendance,
filterLessonAttendanceRecords,
getAttendanceExperience,
getPunchDisplayInfo,
getSchedulePhase,
summarizeLessonCheckins,
type AttendanceSummary,
type LessonAttendanceFilter,
type SchedulePhase,
} from './attendance-workspace';
import './attendance.css';
const DEFAULT_ATTENDANCE_PERIODS = [
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true },
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true },
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true },
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true },
];
const STATUS_META: Record<
string,
{ label: string; color: string; className: string; short: string }
> = {
present: { label: '出勤', color: 'success', className: 'is-present', short: '勤' },
absent: { label: '缺勤', color: 'error', className: 'is-absent', short: '缺' },
leave: { label: '请假', color: 'processing', className: 'is-leave', short: '假' },
pending: { label: '待确认', color: 'default', className: 'is-pending', short: '待' },
};
const ADMIN_CORRECTION_OPTIONS = [
{ value: 'present', label: '正常' },
{ value: 'leave', label: '请假' },
{ value: 'absent', label: '缺勤' },
];
interface ClassOption {
classId: number;
className: string;
}
interface AttendanceRecordItem {
id: number;
studentId: number;
classId: number | null;
attendanceDate: string;
session: string;
status: string;
source?: string;
remark: string | null;
createdAt: string;
student: { id: number; name: string; studentNo?: string | null };
class: { id: number; name: string } | null;
scheduleId?: number | null;
attendanceSessionId?: number | null;
punchTime?: string | null;
punchSource?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
}
interface AssignedClass {
classId: number;
className: string;
classCode: string;
roleType: string;
subject: string;
}
interface TodaySchedule {
id: number;
classId: number;
classroomId: number;
startTime: string;
endTime: string;
subject: string;
}
interface HistoryScheduleOption {
id: number;
classId: number;
weekDay: number;
startTime: string;
endTime: string;
startDate: string;
endDate: string;
subject: string;
teacherId: number | null;
status: string;
}
interface LessonAttendanceSession {
id: number;
scheduleId: number;
classId: number;
lessonDate: string;
status: 'in_progress' | 'completed';
}
interface LessonAttendanceResponse {
schedule: TodaySchedule;
session: LessonAttendanceSession | null;
records: AttendanceRecordItem[];
}
interface TeacherWorkspaceData {
assignedClasses: AssignedClass[];
todaySchedules: TodaySchedule[];
}
interface AlertItem {
studentId: number;
studentName: string;
studentNo: string;
className: string;
type: string;
count: number;
lastDate: string;
}
interface AttendancePeriodConfigItem {
id?: number;
periodKey: string;
label: string;
startTime: string;
endTime: string;
sortOrder: number;
enabled: boolean;
}
interface DingTalkSyncStatus {
lastPulledAt: string | null;
action: string | null;
username: string | null;
detail: string | null;
}
const EMPTY_SUMMARY: AttendanceSummary = {
total: 0,
present: 0,
late: 0,
absent: 0,
leave: 0,
pending: 0,
};
function readCurrentRoles(): string[] {
try {
const user = JSON.parse(localStorage.getItem('user') || '{}') as { roles?: string[] };
return Array.isArray(user.roles) ? user.roles : [];
} catch {
return [];
}
}
function displayAttendanceStatus(status?: string | null): string {
return status === 'pending' || !status ? 'absent' : status;
}
function AttendanceStatusTag({ status }: { status: string }) {
const displayStatus = displayAttendanceStatus(status);
const meta = STATUS_META[displayStatus] ?? {
label: displayStatus,
color: 'default',
className: 'is-absent',
short: '?',
};
return (
{meta.label}
);
}
function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) {
const summary = summarizeLessonCheckins(records);
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
return (
勤
{summary.checkedIn}
已打卡
缺
{summary.notCheckedIn}
未打卡
);
}
const AttendancePage: React.FC = () => {
const { permissions, hasPermission } = usePermission();
const roles = useMemo(readCurrentRoles, []);
const experience = getAttendanceExperience(permissions, roles);
if (experience === 'teacher') {
return ;
}
return ;
};
const TeacherAttendanceWorkspace: React.FC = () => {
const [loading, setLoading] = useState(true);
const [workspace, setWorkspace] = useState(null);
const [selectedSchedule, setSelectedSchedule] = useState(null);
const [lessonSession, setLessonSession] = useState(null);
const [lessonRecords, setLessonRecords] = useState([]);
const [recordLoading, setRecordLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [studentKeyword, setStudentKeyword] = useState('');
const [checkinFilter, setCheckinFilter] = useState('all');
const loadWorkspace = useCallback(async () => {
setLoading(true);
try {
setWorkspace(await api.get('/rbac/teacher-workspace'));
} catch (error: unknown) {
message.error((error as { message?: string })?.message || '加载今日课程失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadWorkspace();
}, [loadWorkspace]);
const classNameById = useMemo(
() => new Map(workspace?.assignedClasses.map((item) => [item.classId, item.className]) ?? []),
[workspace],
);
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
setStudentKeyword('');
setCheckinFilter('all');
setSelectedSchedule(schedule);
setDrawerOpen(true);
setRecordLoading(true);
try {
const today = dayjs().format('YYYY-MM-DD');
const data = await api.post(
`/attendance-lessons/schedules/${schedule.id}/pull`,
{ date: today },
);
setSelectedSchedule(data.schedule);
setLessonSession(data.session);
setLessonRecords(data.records);
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
} catch (error: unknown) {
setLessonSession(null);
setLessonRecords([]);
message.error(error instanceof Error ? error.message : '加载本节课考勤失败');
} finally {
setRecordLoading(false);
}
}, []);
const updateLessonRecord = useCallback(
async (record: AttendanceRecordItem, status: string) => {
const previous = record.status;
setLessonRecords((items) =>
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
);
try {
await api.put(`/attendance-records/${record.id}`, { status });
} catch (error: unknown) {
setLessonRecords((items) =>
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
);
message.error((error as { message?: string })?.message || '更新考勤失败');
}
},
[],
);
const now = new Date();
const schedules = workspace?.todaySchedules ?? [];
const startedCount = schedules.filter(
(item) => canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)),
).length;
const nextSchedule = schedules.find(
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
);
const isAttendanceCompleted = lessonSession?.status === 'completed';
const filteredLessonRecords = useMemo(
() => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter),
[lessonRecords, studentKeyword, checkinFilter],
);
return (
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
今天,从课程开始
课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。
} onClick={() => void loadWorkspace()}>
刷新
今日课程{schedules.length}节
已开始{startedCount}节,可查看考勤
下一节{nextSchedule ? nextSchedule.startTime : '—'}{nextSchedule?.subject || '今天没有更多课程'}
今日教学节奏
我的课程
课程开始后可拉取钉钉考勤记录
{schedules.length === 0 ? (
今天还没有课程请联系教务管理员安排课程。
}
/>
) : (
{schedules.map((schedule, index) => {
const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
return (
void openAttendance(schedule)}
/>
);
})}
)}
setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
LESSON ATTENDANCE
{selectedSchedule?.subject || '课程考勤'}
{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}
{lessonSession && (
)}
setStudentKeyword(event.target.value)}
className="lesson-record-search"
/>
rowKey="id"
loading={recordLoading}
dataSource={filteredLessonRecords}
pagination={false}
locale={{
emptyText: ,
}}
columns={[
{
title: '学生', dataIndex: ['student', 'name'],
render: (name: string) => {name?.slice(0, 1)}{name || '-'} ,
},
{
title: '考勤结果', dataIndex: 'status', width: 230,
render: (value: string, record: AttendanceRecordItem) => {
const checkedIn = value === 'present' || value === 'late';
return (
);
},
},
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => },
{
title: '打卡设备',
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => {
const info = getPunchDisplayInfo(record);
if (!info) return —;
return (
{info.label}
{info.detail && {info.detail}}
{info.time && {dayjs(info.time).format('HH:mm:ss')}}
);
},
},
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || — },
]}
/>
);
};
const LessonCard: React.FC<{
schedule: TodaySchedule;
phase: SchedulePhase;
className: string;
index: number;
onOpen: () => void;
}> = ({ schedule, phase, className, index, onOpen }) => {
const phaseMeta = {
upcoming: { label: '待上课', icon: , tone: 'upcoming' },
ongoing: { label: '进行中', icon: , tone: 'ongoing' },
ended: { label: '已结束', icon: , tone: 'ended' },
}[phase];
return (
{String(index).padStart(2, '0')}
{schedule.startTime}{schedule.endTime}
{schedule.subject}
{phaseMeta.label}
{className}教室 {schedule.classroomId}
{phase === 'upcoming' ? (
) : (
)}
);
};
interface AdminStudentPanel {
key: string;
studentId: number;
studentName: string;
studentNo: string;
className: string;
records: AttendanceRecordItem[];
statusBySession: Partial>;
primaryStatus: string;
rate: number;
latestDate: string;
}
const ADMIN_METRIC_META = [
{ key: 'all', label: '出勤率', short: '率' },
{ key: 'present', label: '正常', short: '正常' },
{ key: 'leave', label: '请假', short: '请假' },
{ key: 'absent', label: '缺勤', short: '缺勤' },
];
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
const priority = ['absent', 'leave', 'present'];
return priority.find((item) => records.some((record) => displayAttendanceStatus(record.status) === item)) || 'absent';
}
function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] {
const map = new Map();
for (const record of records) {
const current = map.get(record.studentId) ?? {
key: String(record.studentId),
studentId: record.studentId,
studentName: record.student?.name || '未知学生',
studentNo: record.student?.studentNo?.trim() || '',
className: record.class?.name || '未关联班级',
records: [],
statusBySession: {},
primaryStatus: 'absent',
rate: 0,
latestDate: record.attendanceDate,
};
current.records.push(record);
if (!current.statusBySession[record.session]) current.statusBySession[record.session] = record;
if (dayjs(record.attendanceDate).isAfter(dayjs(current.latestDate))) {
current.latestDate = record.attendanceDate;
}
map.set(record.studentId, current);
}
return Array.from(map.values()).map((item) => {
const checked = item.records.filter(
(record) => record.status === 'present',
).length;
return {
...item,
primaryStatus: pickPrimaryStatus(item.records),
rate: item.records.length > 0 ? Math.round((checked / item.records.length) * 100) : 0,
};
});
}
const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
const [records, setRecords] = useState([]);
const [summary, setSummary] = useState(EMPTY_SUMMARY);
const [alerts, setAlerts] = useState([]);
const [classOptions, setClassOptions] = useState([]);
const [scheduleOptions, setScheduleOptions] = useState([]);
const [scheduleOptionsLoading, setScheduleOptionsLoading] = useState(false);
const [periods, setPeriods] = useState(DEFAULT_ATTENDANCE_PERIODS);
const [periodModalOpen, setPeriodModalOpen] = useState(false);
const [periodForm] = Form.useForm<{ periods: AttendancePeriodConfigItem[] }>();
const [loading, setLoading] = useState(true);
const [refreshingDingTalk, setRefreshingDingTalk] = useState(false);
const [syncStatus, setSyncStatus] = useState(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(48);
const [total, setTotal] = useState(0);
const [classId, setClassId] = useState();
const [attendanceDate, setAttendanceDate] = useState(dayjs());
const [status, setStatus] = useState();
const [session, setSession] = useState();
const [scheduleId, setScheduleId] = useState();
const [metricFilter, setMetricFilter] = useState('all');
const [studentSearch, setStudentSearch] = useState('');
const [selectedStudent, setSelectedStudent] = useState(null);
const [correctingRecordId, setCorrectingRecordId] = useState(null);
const enabledPeriods = useMemo(
() => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder),
[periods],
);
const sessionOptions = useMemo(
() => enabledPeriods.map((period) => ({ value: period.periodKey, label: period.label })),
[enabledPeriods],
);
const sessionMap = useMemo(
() => Object.fromEntries(periods.map((period) => [period.periodKey, period.label])),
[periods],
);
const periodOrder = useMemo(
() => new Map(periods.map((period, index) => [period.periodKey, index])),
[periods],
);
const sortAttendanceRecords = useCallback(
(items: readonly AttendanceRecordItem[]) =>
[...items].sort((a, b) => {
const periodDiff =
(periodOrder.get(a.session) ?? Number.MAX_SAFE_INTEGER) -
(periodOrder.get(b.session) ?? Number.MAX_SAFE_INTEGER);
if (periodDiff !== 0) return periodDiff;
return (a.punchTime || a.createdAt || '').localeCompare(b.punchTime || b.createdAt || '');
}),
[periodOrder],
);
const subjectScheduleOptions = useMemo(
() =>
scheduleOptions.map((schedule) => ({
value: schedule.id,
label: `${schedule.subject}(${schedule.startTime}-${schedule.endTime})`,
})),
[scheduleOptions],
);
const openPeriodConfig = () => {
periodForm.setFieldsValue({ periods });
setPeriodModalOpen(true);
};
const savePeriodConfig = async () => {
try {
const values = await periodForm.validateFields();
const payload = {
periods: values.periods.map((period, index) => ({
...period,
sortOrder: index + 1,
enabled: period.enabled ?? true,
})),
};
const data = await api.put('/attendance-period-configs', payload);
setPeriods(data);
setPeriodModalOpen(false);
message.success('考勤时段配置已保存');
if (session && !data.some((period) => period.enabled && period.periodKey === session)) {
setSession(undefined);
}
} catch (error: unknown) {
if ((error as { errorFields?: unknown })?.errorFields) return;
message.error((error as { message?: string })?.message || '保存考勤时段配置失败');
}
};
const resetPeriodConfig = async () => {
try {
const data = await api.post('/attendance-period-configs/reset');
setPeriods(data);
periodForm.setFieldsValue({ periods: data });
message.success('已恢复默认考勤时段');
} catch (error: unknown) {
message.error((error as { message?: string })?.message || '恢复默认配置失败');
}
};
const buildParams = useCallback(
(withPagination = true) => {
const params: Record = {};
if (withPagination) Object.assign(params, { page, pageSize });
if (classId) params.classId = classId;
if (attendanceDate) {
const selectedDate = attendanceDate.format('YYYY-MM-DD');
params.dateFrom = selectedDate;
params.dateTo = selectedDate;
}
if (status) params.status = status;
if (session) params.session = session;
if (scheduleId) params.scheduleId = scheduleId;
return params;
},
[page, pageSize, classId, attendanceDate, status, session, scheduleId],
);
const loadSyncStatus = useCallback(async () => {
try {
setSyncStatus(await api.get('/attendance-records/dingtalk-sync-status'));
} catch {
setSyncStatus(null);
}
}, []);
const loadRecords = useCallback(async () => {
setLoading(true);
try {
const [recordData, summaryData, statusData] = await Promise.all([
api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', {
params: buildParams(true),
}),
api.get('/attendance-records/summary', {
params: buildParams(false),
}),
api.get('/attendance-records/dingtalk-sync-status'),
]);
setRecords(recordData.list);
setTotal(recordData.total);
setSummary({ ...EMPTY_SUMMARY, ...summaryData });
setSyncStatus(statusData);
} catch (error: unknown) {
message.error((error as { message?: string })?.message || '加载学生考勤失败');
} finally {
setLoading(false);
}
}, [buildParams]);
const refreshDingTalkAttendance = useCallback(async () => {
if (!attendanceDate) {
message.warning('请先选择日期');
return;
}
if (attendanceDate.isAfter(dayjs(), 'day')) {
message.warning('不能查看或刷新未来日期的考勤');
return;
}
setRefreshingDingTalk(true);
try {
const result = await api.post<{ refreshed: number; imported: number; matched: number; errors: string[] }>(
'/attendance-records/refresh-dingtalk',
{
date: attendanceDate.format('YYYY-MM-DD'),
classId,
session,
},
);
if (result.errors.length > 0) {
message.warning(result.errors[0]);
} else {
message.success(`已刷新 ${result.refreshed} 节课程考勤`);
}
await Promise.all([loadRecords(), loadSyncStatus()]);
} catch (error: unknown) {
message.error((error as { message?: string })?.message || '刷新钉钉考勤失败');
} finally {
setRefreshingDingTalk(false);
}
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session]);
useEffect(() => {
void Promise.all([
api.get('/attendance-records/classes').then(setClassOptions),
api.get('/attendance-records/alerts').then(setAlerts),
api.get('/attendance-period-configs').then((data) => {
setPeriods(data);
periodForm.setFieldsValue({ periods: data });
}),
]).catch(() => undefined);
}, []);
useEffect(() => {
if (!classId || !attendanceDate) {
setScheduleOptions([]);
setScheduleId(undefined);
return;
}
const selectedDate = attendanceDate.format('YYYY-MM-DD');
setScheduleOptionsLoading(true);
void api
.get('/attendance-records/schedules', {
params: { classId, date: selectedDate },
})
.then((options) => {
setScheduleOptions(options);
setScheduleId((current) =>
current && options.some((schedule) => schedule.id === current) ? current : undefined,
);
})
.catch((error: unknown) => {
setScheduleOptions([]);
setScheduleId(undefined);
message.error((error as { message?: string })?.message || '加载班级科目失败');
})
.finally(() => setScheduleOptionsLoading(false));
}, [attendanceDate, classId]);
useEffect(() => {
void loadRecords();
}, [loadRecords]);
const resetFilters = () => {
setClassId(undefined);
setAttendanceDate(dayjs());
setStatus(undefined);
setSession(undefined);
setScheduleId(undefined);
setScheduleOptions([]);
setMetricFilter('all');
setStudentSearch('');
setPage(1);
};
const handleExport = useCallback(() => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(buildParams(false))) params.set(key, String(value));
const token = localStorage.getItem('token');
fetch(`/api/attendance-records/export?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((response) => {
if (!response.ok) throw new Error('导出失败');
return response.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
}, [buildParams]);
const patchRecordStatus = (recordId: number, nextStatus: string) => {
setRecords((items) =>
items.map((item) => (item.id === recordId ? { ...item, status: nextStatus } : item)),
);
setSelectedStudent((student) => {
if (!student) return student;
return {
...student,
records: student.records.map((item) =>
item.id === recordId ? { ...item, status: nextStatus } : item,
),
statusBySession: Object.fromEntries(
Object.entries(student.statusBySession).map(([key, item]) => [
key,
item?.id === recordId ? { ...item, status: nextStatus } : item,
]),
),
};
});
};
const updateAdminRecordStatus = async (record: AttendanceRecordItem, nextStatus: string) => {
const previousStatus = record.status;
const normalizedPrevious = displayAttendanceStatus(previousStatus);
if (normalizedPrevious === nextStatus && previousStatus !== 'pending') return;
patchRecordStatus(record.id, nextStatus);
setCorrectingRecordId(record.id);
try {
await api.put(`/attendance-records/${record.id}`, { status: nextStatus });
message.success('考勤结果已更新');
void loadRecords();
} catch (error: unknown) {
patchRecordStatus(record.id, previousStatus);
message.error((error as { message?: string })?.message || '更新失败');
} finally {
setCorrectingRecordId(null);
}
};
const studentPanels = useMemo(() => buildAdminStudentPanels(records), [records]);
const visibleStudents = useMemo(() => {
const query = studentSearch.trim().toLocaleLowerCase('zh-CN');
return studentPanels.filter((student) => {
const matchesQuery =
!query ||
student.studentName.toLocaleLowerCase('zh-CN').includes(query) ||
student.studentNo.toLocaleLowerCase('zh-CN').includes(query);
const matchesMetric =
metricFilter === 'all' ||
student.records.some((record) => displayAttendanceStatus(record.status) === metricFilter);
return matchesQuery && matchesMetric;
});
}, [metricFilter, studentPanels, studentSearch]);
const selectedClass = classId
? classOptions.find((item) => item.classId === classId)?.className || `班级 ${classId}`
: '全部班级';
const attendanceRate =
summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0;
const dateLabel = attendanceDate?.format('YYYY-MM-DD') || '未选择日期';
const columns: ColumnsType = [
{
title: '学生',
dataIndex: ['student', 'name'],
fixed: isMobile ? undefined : 'left',
width: 150,
render: (name: string, record) => (
{name?.slice(0, 1)}
{name || '-'}
{record.class?.name || '未关联班级'}
),
},
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
{
title: '时段',
dataIndex: 'session',
width: 110,
render: (value: string) => sessionMap[value] || value,
},
{
title: '状态',
dataIndex: 'status',
width: 105,
render: (value: string) => ,
},
{
title: '签到来源',
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => {
const info = getPunchDisplayInfo(record);
if (!info) return —;
return (
{info.label}
{info.detail && {info.detail}}
{info.time && {dayjs(info.time).format('HH:mm:ss')}}
);
},
},
{
title: '备注',
dataIndex: 'remark',
ellipsis: true,
render: (value: string | null) => value || —,
},
...(canEdit
? [
{
title: '操作',
key: 'action',
fixed: isMobile ? undefined : ('right' as const),
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => (
void updateAdminRecordStatus(record, String(value))}
/>
),
},
]
: []),
];
return (
current.isAfter(dayjs(), 'day')}
onChange={(value) => {
setAttendanceDate(value);
setScheduleId(undefined);
setPage(1);
}}
/>
: '当前日期没有排课'}
onChange={(value) => {
setScheduleId(value);
setPage(1);
}}
options={subjectScheduleOptions}
/>
} onClick={() => void loadRecords()}>
查询
班级考勤概览
{selectedClass}
{dateLabel} · 当前展示 {visibleStudents.length} 名学生 / {total} 条记录
任
当前任课
{session ? sessionMap[session] : '全部时段'}
{ADMIN_METRIC_META.map((metric) => {
const value =
metric.key === 'all'
? `${attendanceRate}%`
: metric.key === 'absent'
? summary.absent + summary.pending
: (summary[metric.key as keyof AttendanceSummary] ?? 0);
const meta = STATUS_META[metric.key] ?? { className: 'is-present' };
return (
);
})}
{alerts.length > 0 && (
{alerts.length} 名学生存在连续异常
建议优先核查最近 14 天的缺勤记录
`${item.studentName}:${item.type}${item.count}次`)
.join(';')}
>
)}
班级学生考勤
{metricFilter === 'all'
? `显示全部 ${visibleStudents.length} 名学生`
: `筛出 ${ADMIN_METRIC_META.find((item) => item.key === metricFilter)?.label || ''}相关 ${visibleStudents.length} 名学生`}
setStudentSearch(event.target.value)}
/>
} onClick={handleExport}>
导出
正常
请假
缺勤
仅显示当天已生成考勤的课程/时段,不再为无课时段补默认缺勤
{visibleStudents.length === 0 ? (
) : (
{visibleStudents.map((student) => (
))}
)}
rowKey="id"
columns={columns}
dataSource={records}
loading={loading}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (value) => `共 ${value} 条历史记录`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
},
}}
locale={{
emptyText: (
),
}}
/>
setSelectedStudent(null)}
width={isMobile ? '100%' : 520}
title="学生考勤明细"
className="student-detail-drawer"
>
{selectedStudent && (
{selectedStudent.studentName.slice(0, 1)}
{selectedStudent.studentName}
{selectedStudent.className}
{selectedStudent.studentNo ? ` · 学号 ${selectedStudent.studentNo}` : ''}
{selectedStudent.rate}%
累计出勤率
{sortAttendanceRecords(selectedStudent.records).map((record) => {
const normal = record.status === 'present';
return (
{normal ? '100%' : '0%'}
{sessionMap[record.session] || record.session}
);
})}
当天打卡时间
{sortAttendanceRecords(selectedStudent.records).map((record) => (
{sessionMap[record.session] || record.session}
{record.punchTime ? dayjs(record.punchTime).format('HH:mm:ss') : '未记录'}
{canEdit && (
void updateAdminRecordStatus(record, String(value))}
/>
)}
))}
近 7 日趋势
{Array.from({ length: 7 }).map((_, index) => {
const value = Math.max(60, Math.min(100, selectedStudent.rate + index * 3 - 8));
return ;
})}
)}
setPeriodModalOpen(false)}
footer={(_, { OkBtn, CancelBtn }) => (
<>
>
)}
>
{(fields, { add, remove }) => (
)}
);
};
export default AttendancePage;