Files
gongxue-base/apps/admin/src/pages/Attendance/index.tsx
wangziqi a7a7af1667 fix: close permission review gaps
fix: harden permission-gated UI — minimum-org endpoint, modal/Popconfirm fail-closed on revocation
2026-07-23 14:28:58 +08:00

1402 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Avatar,
Button,
Card,
Col,
DatePicker,
Drawer,
Grid,
Empty,
Form,
Input,
Modal,
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 EditableCell from '../../components/EditableCell';
import { usePermission } from '../../hooks/usePermission';
import { message } from '../../ui/app-message';
import {
canPullAttendance,
getAttendanceExperience,
getSchedulePhase,
getPunchDisplayInfo,
type AttendanceSummary,
type SchedulePhase,
} from './attendance-workspace';
import LessonAttendanceDetail from './LessonAttendanceDetail';
import type { LessonAttendanceRecord, LessonAttendanceSchedule } from './types';
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 ClassTeacherOption {
userId: number;
username: string | null;
name: string | null;
roleType: string;
subject: string | null;
}
interface ClassOption {
classId: number;
className: string;
teachers?: ClassTeacherOption[];
}
type AttendanceRecordItem = LessonAttendanceRecord;
interface AssignedClass {
classId: number;
className: string;
classCode: string;
roleType: string;
subject: string;
}
type TodaySchedule = LessonAttendanceSchedule;
interface HistoryScheduleOption {
id: number;
classId: number;
weekDay: number;
startTime: string;
endTime: string;
startDate: string;
endDate: string;
subject: string;
teacherId: number | null;
teacherName?: string | null;
teacherUsername?: string | null;
status: string;
}
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 getTeacherDisplayName(teacher?: {
name?: string | null;
username?: string | null;
}): string {
const name = teacher?.name?.trim();
if (name) return name;
return teacher?.username?.trim() || '未设置';
}
function formatTeacherNames(
teachers: readonly { name?: string | null; username?: string | null }[],
): string {
const names = [
...new Set(
teachers
.map((teacher) => getTeacherDisplayName(teacher))
.filter((name) => name && name !== '未设置'),
),
];
return names.length > 0 ? names.join('、') : '未设置';
}
function AttendanceStatusTag({ status }: { status: string }) {
const displayStatus = displayAttendanceStatus(status);
const meta = STATUS_META[displayStatus] ?? {
label: displayStatus,
color: 'default',
className: 'is-absent',
short: '?',
};
return (
<span className={`attendance-status ${meta.className}`}>
<span className="attendance-status__dot" />
{meta.label}
</span>
);
}
const AttendancePage: React.FC = () => {
const { permissions, hasPermission } = usePermission();
const roles = useMemo(readCurrentRoles, []);
const experience = getAttendanceExperience(permissions, roles);
if (experience === 'teacher') {
return <TeacherAttendanceWorkspace canCreate={hasPermission('attendance:create')} />;
}
return <AdminAttendanceArchive canEdit={hasPermission('attendance:edit')} />;
};
const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ canCreate }) => {
const [loading, setLoading] = useState(true);
const [workspace, setWorkspace] = useState<TeacherWorkspaceData | null>(null);
const [selectedSchedule, setSelectedSchedule] = useState<TodaySchedule | null>(null);
const loadWorkspace = useCallback(async () => {
setLoading(true);
try {
setWorkspace(await api.get<TeacherWorkspaceData>('/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((schedule: TodaySchedule) => {
setSelectedSchedule(schedule);
}, []);
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',
);
return (
<div className="attendance-page teacher-attendance">
<section className="attendance-hero attendance-hero--teacher">
<div>
<span className="attendance-eyebrow">
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
</span>
<h1></h1>
<p></p>
</div>
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
</Button>
</section>
<Row gutter={[16, 16]} className="teacher-overview">
<Col xs={24} md={8}>
<div className="teacher-kpi">
<span></span>
<strong>{schedules.length}</strong>
<small></small>
</div>
</Col>
<Col xs={24} md={8}>
<div className="teacher-kpi">
<span></span>
<strong>{startedCount}</strong>
<small></small>
</div>
</Col>
<Col xs={24} md={8}>
<div className="teacher-kpi teacher-kpi--next">
<span></span>
<strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong>
<small>{nextSchedule?.subject || '今天没有更多课程'}</small>
</div>
</Col>
</Row>
<div className="attendance-section-heading">
<div>
<span></span>
<h2></h2>
</div>
<span className="attendance-section-note"></span>
</div>
<Spin spinning={loading}>
{schedules.length === 0 ? (
<Card className="attendance-empty-card">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div>
<strong></strong>
<p></p>
</div>
}
/>
</Card>
) : (
<div className="lesson-timeline">
{schedules.map((schedule, index) => {
const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
return (
<LessonCard
key={schedule.id}
schedule={schedule}
phase={phase}
className={classNameById.get(schedule.classId) || `班级 ${schedule.classId}`}
index={index + 1}
onOpen={canCreate ? () => openAttendance(schedule) : undefined}
/>
);
})}
</div>
)}
</Spin>
{canCreate ? (
<LessonAttendanceDetail
schedule={selectedSchedule}
className={
selectedSchedule
? classNameById.get(selectedSchedule.classId) || `班级 ${selectedSchedule.classId}`
: ''
}
onClose={() => setSelectedSchedule(null)}
/>
) : null}
</div>
);
};
const LessonCard: React.FC<{
schedule: TodaySchedule;
phase: SchedulePhase;
className: string;
index: number;
onOpen?: () => void;
}> = ({ schedule, phase, className, index, onOpen }) => {
const phaseMeta = {
upcoming: { label: '待上课', icon: <ClockCircleOutlined />, tone: 'upcoming' },
ongoing: { label: '进行中', icon: <ScheduleOutlined />, tone: 'ongoing' },
ended: { label: '已结束', icon: <CheckCircleFilled />, tone: 'ended' },
}[phase];
return (
<article className={`lesson-card lesson-card--${phaseMeta.tone}`}>
<div className="lesson-sequence">{String(index).padStart(2, '0')}</div>
<div className="lesson-time">
<strong>{schedule.startTime}</strong>
<span />
<strong>{schedule.endTime}</strong>
</div>
<div className="lesson-main">
<div className="lesson-title-row">
<h3>{schedule.subject}</h3>
<Tag icon={phaseMeta.icon}>{phaseMeta.label}</Tag>
</div>
<p>
<TeamOutlined /> {className}
<span> {schedule.classroomId}</span>
</p>
</div>
<div className="lesson-action">
{phase === 'upcoming' ? (
<Tooltip title="课程尚未开始">
<Button disabled></Button>
</Tooltip>
) : onOpen ? (
<Button type="primary" onClick={onOpen}>
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
</Button>
) : null}
</div>
</article>
);
};
interface AdminStudentPanel {
key: string;
studentId: number;
studentName: string;
studentNo: string;
className: string;
records: AttendanceRecordItem[];
statusBySession: Partial<Record<string, AttendanceRecordItem>>;
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<number, AdminStudentPanel>();
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<AttendanceRecordItem[]>([]);
const [summary, setSummary] = useState<AttendanceSummary>(EMPTY_SUMMARY);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
const [scheduleOptions, setScheduleOptions] = useState<HistoryScheduleOption[]>([]);
const [scheduleOptionsLoading, setScheduleOptionsLoading] = useState(false);
const [periods, setPeriods] = useState<AttendancePeriodConfigItem[]>(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<DingTalkSyncStatus | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(48);
const [total, setTotal] = useState(0);
const [classId, setClassId] = useState<number>();
const [attendanceDate, setAttendanceDate] = useState<Dayjs | null>(dayjs());
const [status, setStatus] = useState<string>();
const [session, setSession] = useState<string>();
const [scheduleId, setScheduleId] = useState<number>();
const [metricFilter, setMetricFilter] = useState('all');
const [studentSearch, setStudentSearch] = useState('');
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(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<AttendancePeriodConfigItem[]>(
'/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<AttendancePeriodConfigItem[]>('/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<string, string | number> = {};
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<DingTalkSyncStatus>('/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<AttendanceSummary>('/attendance-records/summary', {
params: buildParams(false),
}),
api.get<DingTalkSyncStatus>('/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<ClassOption[]>('/attendance-records/classes').then(setClassOptions),
api.get<AlertItem[]>('/attendance-records/alerts').then(setAlerts),
api.get<AttendancePeriodConfigItem[]>('/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<HistoryScheduleOption[]>('/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 saveAdminRecordCell = async (
record: AttendanceRecordItem,
field: 'status' | 'remark',
value: unknown,
) => {
await api.put(`/attendance-records/${record.id}`, { [field]: value });
message.success('考勤记录已保存');
await loadRecords();
};
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 selectedClassOption = classId
? classOptions.find((item) => item.classId === classId)
: undefined;
const selectedClass =
selectedClassOption?.className || (classId ? `班级 ${classId}` : '全部班级');
const overviewTeachers = selectedClassOption?.teachers ?? [];
const headTeacherNames = classId
? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'head_teacher'))
: '请选择班级';
const lifeTeacherNames = classId
? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'life_teacher'))
: '请选择班级';
const currentSchedule = scheduleId
? scheduleOptions.find((item) => item.id === scheduleId)
: undefined;
const subjectTeacherNames = currentSchedule
? getTeacherDisplayName({
name: currentSchedule.teacherName,
username: currentSchedule.teacherUsername,
})
: classId
? formatTeacherNames(
overviewTeachers.filter((teacher) => teacher.roleType === 'subject_teacher'),
)
: '请选择班级';
const attendanceRate =
summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0;
const dateLabel = attendanceDate?.format('YYYY-MM-DD') || '未选择日期';
const columns: ColumnsType<AttendanceRecordItem> = [
{
title: '学生',
dataIndex: ['student', 'name'],
fixed: isMobile ? undefined : 'left',
width: 150,
render: (name: string, record) => (
<div className="student-cell">
<Avatar size={34}>{name?.slice(0, 1)}</Avatar>
<div>
<strong>{name || '-'}</strong>
<span>{record.class?.name || '未关联班级'}</span>
</div>
</div>
),
},
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
{
title: '时段',
dataIndex: 'session',
width: 110,
render: (value: string) => sessionMap[value] || value,
},
{
title: '状态',
dataIndex: 'status',
width: 105,
render: (value: string, record) => (
<EditableCell
value={displayAttendanceStatus(value)}
editor="select"
options={ADMIN_CORRECTION_OPTIONS.map((item) => ({
value: String(item.value),
label: item.label,
}))}
disabled={!canEdit}
onSave={(next) => saveAdminRecordCell(record, 'status', next)}
>
<AttendanceStatusTag status={displayAttendanceStatus(value)} />
</EditableCell>
),
},
{
title: '签到来源',
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => {
const info = getPunchDisplayInfo(record);
if (!info) return <span className="muted-text"></span>;
return (
<div className="punch-device-cell">
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
{info.detail && <strong>{info.detail}</strong>}
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
</div>
);
},
},
{
title: '备注',
dataIndex: 'remark',
ellipsis: true,
render: (value: string | null, record) => (
<EditableCell
value={value}
editor="textarea"
disabled={!canEdit}
onSave={(next) => saveAdminRecordCell(record, 'remark', next)}
>
{value || <span className="muted-text"></span>}
</EditableCell>
),
},
...(canEdit
? [
{
title: '操作',
key: 'action',
fixed: isMobile ? undefined : ('right' as const),
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => (
<Segmented
size="small"
className="attendance-correction-segment"
options={ADMIN_CORRECTION_OPTIONS}
value={displayAttendanceStatus(record.status)}
disabled={correctingRecordId === record.id}
onChange={(value) => void updateAdminRecordStatus(record, String(value))}
/>
),
},
]
: []),
];
return (
<div className="attendance-page admin-attendance student-attendance-center">
<header className="student-center-topbar">
<div className="student-center-title">
<h1></h1>
<span></span>
</div>
<div className="student-center-actions">
<Tooltip title={syncStatus?.detail || syncStatus?.action || '暂无钉钉拉取记录'}>
<span className="student-sync-status">
<i />
{syncStatus?.lastPulledAt
? `最近拉取钉钉 ${dayjs(syncStatus.lastPulledAt).format('YYYY-MM-DD HH:mm')}`
: '暂无钉钉拉取记录'}
</span>
</Tooltip>
{canEdit && (
<Button icon={<ScheduleOutlined />} onClick={openPeriodConfig}>
</Button>
)}
<PermissionButton
permission="attendance:create"
icon={<ReloadOutlined />}
loading={refreshingDingTalk}
onClick={() => void refreshDingTalkAttendance()}
>
</PermissionButton>
<PermissionButton
permission="attendance:export"
icon={<ExportOutlined />}
onClick={handleExport}
>
</PermissionButton>
</div>
</header>
<section className="student-filter-panel" aria-label="考勤筛选">
<div className="student-filter-field student-filter-field--date">
<label></label>
<DatePicker
allowClear={false}
value={attendanceDate}
disabledDate={(current) => current.isAfter(dayjs(), 'day')}
onChange={(value) => {
setAttendanceDate(value);
setScheduleId(undefined);
setPage(1);
}}
/>
</div>
<div className="student-filter-field">
<label></label>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部班级"
value={classId}
onChange={(value) => {
setClassId(value);
setScheduleId(undefined);
setPage(1);
}}
options={classOptions.map((item) => ({ value: item.classId, label: item.className }))}
/>
</div>
<div className="student-filter-field">
<label></label>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder={classId ? '全部科目' : '请先选择班级'}
value={scheduleId}
loading={scheduleOptionsLoading}
disabled={!classId || !attendanceDate}
notFoundContent={scheduleOptionsLoading ? <Spin size="small" /> : '当前日期没有排课'}
onChange={(value) => {
setScheduleId(value);
setPage(1);
}}
options={subjectScheduleOptions}
/>
</div>
<div className="student-filter-field">
<label></label>
<Select disabled placeholder="全部老师" />
</div>
<div className="student-filter-field">
<label></label>
<Select
allowClear
placeholder="全部时段"
value={session}
onChange={(value) => {
setSession(value);
setPage(1);
}}
options={sessionOptions}
/>
</div>
<div className="student-filter-actions">
<Button onClick={resetFilters}></Button>
<Button type="primary" icon={<FileSearchOutlined />} onClick={() => void loadRecords()}>
</Button>
</div>
</section>
<section className="student-class-overview" aria-label="班级考勤汇总">
<div className="student-class-identity">
<div className="student-class-heading">
<span className="student-overview-kicker"></span>
<h2>{selectedClass}</h2>
<p>
{dateLabel} · {visibleStudents.length} / {total}
</p>
</div>
<div className="student-teacher-list" aria-label="筛选上下文">
<div className="student-teacher-item">
<Avatar></Avatar>
<div>
<span></span>
<strong>{headTeacherNames}</strong>
</div>
</div>
<div className="student-teacher-item">
<Avatar></Avatar>
<div>
<span></span>
<strong>{lifeTeacherNames}</strong>
</div>
</div>
<div className="student-teacher-item">
<Avatar></Avatar>
<div>
<span></span>
<strong>{subjectTeacherNames}</strong>
</div>
</div>
</div>
</div>
<div className="student-metric-strip">
{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 (
<button
type="button"
key={metric.key}
className={`student-metric-card ${metricFilter === metric.key ? 'active' : ''}`}
onClick={() => setMetricFilter(metric.key)}
>
<span className={`student-metric-icon ${meta.className}`}>{metric.short}</span>
<span className="student-metric-copy">
<strong>{value}</strong>
<small>{metric.label}</small>
</span>
</button>
);
})}
</div>
</section>
{alerts.length > 0 && (
<div className="archive-alert">
<WarningFilled />
<div>
<strong>{alerts.length} </strong>
<span> 14 </span>
</div>
<Tooltip
title={alerts
.slice(0, 5)
.map((item) => `${item.studentName}${item.type}${item.count}`)
.join('')}
>
<Button type="link"></Button>
</Tooltip>
</div>
)}
<section className="student-workspace">
<header className="student-workspace-header">
<div>
<h3></h3>
<span>
{metricFilter === 'all'
? `显示全部 ${visibleStudents.length} 名学生`
: `筛出 ${ADMIN_METRIC_META.find((item) => item.key === metricFilter)?.label || ''}相关 ${visibleStudents.length} 名学生`}
</span>
</div>
<div className="student-workspace-tools">
<Input.Search
allowClear
placeholder="搜索姓名或学号"
value={studentSearch}
onChange={(event) => setStudentSearch(event.target.value)}
/>
<PermissionButton permission="attendance:export" icon={<ExportOutlined />} onClick={handleExport}>
</PermissionButton>
</div>
</header>
<div className="student-legend">
<span>
<i className="is-present" />
</span>
<span>
<i className="is-leave" />
</span>
<span>
<i className="is-absent" />
</span>
<em>/</em>
</div>
<Spin spinning={loading}>
{visibleStudents.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到匹配学生,请调整筛选条件或搜索关键词"
/>
) : (
<div className="student-card-grid">
{visibleStudents.map((student) => (
<button
type="button"
key={student.key}
className={`student-attendance-card ${selectedStudent?.studentId === student.studentId ? 'selected' : ''}`}
onClick={() => setSelectedStudent(student)}
>
<span className="student-attendance-head">
<strong>{student.studentName}</strong>
{student.studentNo && <small> {student.studentNo}</small>}
</span>
<span className="student-status-blocks">
{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 (
<span
key={record.id}
className={`student-status-block ${meta.className}`}
title={`${sessionLabel}${meta.label}`}
>
{meta.label === '出勤' ? '正常' : meta.label}
</span>
);
})}
</span>
</button>
))}
</div>
)}
</Spin>
</section>
<Card className="student-record-card" bordered={false} title="原始考勤明细">
<Table<AttendanceRecordItem>
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: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="当前条件下没有历史考勤记录"
/>
),
}}
/>
</Card>
<Drawer
open={Boolean(selectedStudent)}
onClose={() => setSelectedStudent(null)}
width={isMobile ? '100%' : 520}
title="学生考勤明细"
className="student-detail-drawer"
>
{selectedStudent && (
<div className="student-detail-panel">
<section className="student-detail-profile">
<Avatar size={54}>{selectedStudent.studentName.slice(0, 1)}</Avatar>
<div>
<h4>{selectedStudent.studentName}</h4>
<p>
{selectedStudent.className}
{selectedStudent.studentNo ? ` · 学号 ${selectedStudent.studentNo}` : ''}
</p>
</div>
<div className="student-detail-rate">
<strong>{selectedStudent.rate}%</strong>
<span></span>
</div>
</section>
<section className="student-detail-rates">
{sortAttendanceRecords(selectedStudent.records).map((record) => {
const normal = record.status === 'present';
return (
<div key={record.id}>
<strong>{normal ? '100%' : '0%'}</strong>
<span>{sessionMap[record.session] || record.session}</span>
</div>
);
})}
</section>
<section className="student-detail-section">
<h5></h5>
<div className="student-detail-timeline">
{sortAttendanceRecords(selectedStudent.records).map((record) => (
<div key={record.id}>
<span>{sessionMap[record.session] || record.session}</span>
<AttendanceStatusTag status={displayAttendanceStatus(record.status)} />
<strong>
{record.punchTime ? dayjs(record.punchTime).format('HH:mm:ss') : '未记录'}
</strong>
{canEdit && (
<Segmented
size="small"
className="attendance-correction-segment"
options={ADMIN_CORRECTION_OPTIONS}
value={displayAttendanceStatus(record.status)}
disabled={correctingRecordId === record.id}
onChange={(value) => void updateAdminRecordStatus(record, String(value))}
/>
)}
</div>
))}
</div>
</section>
</div>
)}
</Drawer>
<Modal
open={periodModalOpen}
title="考勤时段配置"
okText="保存配置"
width={860}
className="attendance-period-modal"
onOk={savePeriodConfig}
onCancel={() => setPeriodModalOpen(false)}
footer={(_, { OkBtn, CancelBtn }) => (
<>
<Button onClick={resetPeriodConfig}></Button>
<CancelBtn />
<OkBtn />
</>
)}
>
<Alert
type="info"
showIcon
title="按课程开始时间自动匹配时段"
description="默认07:30-08:30 早自习09:00-12:00 早课14:00-17:00 晚课18:30-21:00 晚自习。"
style={{ marginBottom: 16 }}
/>
<Form form={periodForm} layout="vertical">
<Form.List name="periods">
{(fields, { add, remove }) => (
<div className="attendance-period-editor">
{fields.map((field) => (
<div className="attendance-period-row" key={field.key}>
<Form.Item
name={[field.name, 'label']}
label="显示名称"
rules={[{ required: true, message: '请输入名称' }]}
>
<Input placeholder="早课" />
</Form.Item>
<Form.Item
name={[field.name, 'periodKey']}
label="类型标识"
rules={[{ required: true, message: '请输入标识' }]}
>
<Input placeholder="morning" />
</Form.Item>
<Form.Item
name={[field.name, 'startTime']}
label="开始时间"
rules={[{ required: true, message: '请选择开始时间' }]}
>
<Input type="time" />
</Form.Item>
<Form.Item
name={[field.name, 'endTime']}
label="结束时间"
rules={[{ required: true, message: '请选择结束时间' }]}
>
<Input type="time" />
</Form.Item>
<Form.Item name={[field.name, 'enabled']} label="状态">
<Select
options={[
{ value: true, label: '启用' },
{ value: false, label: '停用' },
]}
/>
</Form.Item>
<Button
danger
className="attendance-period-row__delete"
onClick={() => remove(field.name)}
>
</Button>
</div>
))}
<Button
block
type="dashed"
className="attendance-period-add"
onClick={() =>
add({
periodKey: '',
label: '',
startTime: '00:00',
endTime: '00:30',
enabled: true,
})
}
>
</Button>
</div>
)}
</Form.List>
</Form>
</Modal>
</div>
);
};
export default AttendancePage;