- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
814 lines
27 KiB
TypeScript
814 lines
27 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Alert,
|
||
Avatar,
|
||
Button,
|
||
Card,
|
||
Col,
|
||
DatePicker,
|
||
Drawer,
|
||
Empty,
|
||
Form,
|
||
Input,
|
||
Modal,
|
||
Progress,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Spin,
|
||
Table,
|
||
Tag,
|
||
Tooltip,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
ArrowRightOutlined,
|
||
CheckCircleFilled,
|
||
ClockCircleOutlined,
|
||
EditOutlined,
|
||
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,
|
||
getAttendanceExperience,
|
||
getSchedulePhase,
|
||
summarizeAttendance,
|
||
type AttendanceSummary,
|
||
type SchedulePhase,
|
||
} 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 SESSION_MAP: Record<string, string> = 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 }));
|
||
|
||
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 };
|
||
class: { id: number; name: string } | null;
|
||
scheduleId?: number | null;
|
||
attendanceSessionId?: number | 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 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;
|
||
className: string;
|
||
type: string;
|
||
count: number;
|
||
lastDate: string;
|
||
}
|
||
|
||
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 AttendanceStatusTag({ status }: { status: string }) {
|
||
const meta = STATUS_META[status] ?? {
|
||
label: status,
|
||
color: 'default',
|
||
className: 'is-pending',
|
||
short: '?',
|
||
};
|
||
return (
|
||
<span className={`attendance-status ${meta.className}`}>
|
||
<span className="attendance-status__dot" />
|
||
{meta.label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function SummaryStrip({ summary }: { summary: AttendanceSummary }) {
|
||
const rate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0;
|
||
return (
|
||
<div className="attendance-summary-strip">
|
||
<div className="attendance-rate">
|
||
<Progress
|
||
type="circle"
|
||
percent={rate}
|
||
size={64}
|
||
strokeWidth={9}
|
||
strokeColor="#1677ff"
|
||
trailColor="#e8eef7"
|
||
/>
|
||
<div>
|
||
<span>出勤率</span>
|
||
<strong>{summary.total} 条记录</strong>
|
||
</div>
|
||
</div>
|
||
{[
|
||
['present', summary.present],
|
||
['late', summary.late],
|
||
['absent', summary.absent],
|
||
['leave', summary.leave],
|
||
].map(([status, value]) => {
|
||
const meta = STATUS_META[String(status)];
|
||
return (
|
||
<div className="attendance-summary-cell" key={String(status)}>
|
||
<span className={`attendance-summary-icon ${meta.className}`}>{meta.short}</span>
|
||
<div>
|
||
<strong>{value}</strong>
|
||
<span>{meta.label}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const AttendancePage: React.FC = () => {
|
||
const { permissions, hasPermission } = usePermission();
|
||
const roles = useMemo(readCurrentRoles, []);
|
||
const experience = getAttendanceExperience(permissions, roles);
|
||
|
||
if (experience === 'teacher') {
|
||
return <TeacherAttendanceWorkspace />;
|
||
}
|
||
|
||
return <AdminAttendanceArchive canEdit={hasPermission('attendance:edit')} />;
|
||
};
|
||
|
||
const TeacherAttendanceWorkspace: React.FC = () => {
|
||
const [loading, setLoading] = useState(true);
|
||
const [workspace, setWorkspace] = useState<TeacherWorkspaceData | null>(null);
|
||
const [selectedSchedule, setSelectedSchedule] = useState<TodaySchedule | null>(null);
|
||
const [lessonSession, setLessonSession] = useState<LessonAttendanceSession | null>(null);
|
||
const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]);
|
||
const [recordLoading, setRecordLoading] = useState(false);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [finishing, setFinishing] = useState(false);
|
||
|
||
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 loadLessonAttendance = useCallback(async (schedule: TodaySchedule) => {
|
||
const today = dayjs().format('YYYY-MM-DD');
|
||
const data = await api.get<LessonAttendanceResponse>(
|
||
`/attendance-lessons/schedules/${schedule.id}`,
|
||
{ params: { date: today } },
|
||
);
|
||
setLessonSession(data.session);
|
||
setLessonRecords(data.records);
|
||
}, []);
|
||
|
||
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
|
||
setSelectedSchedule(schedule);
|
||
setDrawerOpen(true);
|
||
setRecordLoading(true);
|
||
try {
|
||
const today = dayjs().format('YYYY-MM-DD');
|
||
const data = await api.post<LessonAttendanceResponse>(
|
||
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
||
{ date: today },
|
||
);
|
||
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 completeAttendance = useCallback(async () => {
|
||
if (!lessonSession || !selectedSchedule) return;
|
||
setFinishing(true);
|
||
try {
|
||
const result = await api.post<LessonAttendanceResponse>(
|
||
`/attendance-lessons/${lessonSession.id}/complete`,
|
||
);
|
||
setLessonSession(result.session);
|
||
setLessonRecords(result.records);
|
||
message.success('考勤核对已完成');
|
||
await loadLessonAttendance(selectedSchedule);
|
||
} catch (error: unknown) {
|
||
message.error((error as { message?: string })?.message || '完成点名失败');
|
||
} finally {
|
||
setFinishing(false);
|
||
}
|
||
}, [lessonSession, selectedSchedule, loadLessonAttendance]);
|
||
|
||
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 drawerSummary = summarizeAttendance(lessonRecords);
|
||
const isAttendanceCompleted = lessonSession?.status === 'completed';
|
||
|
||
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={() => void openAttendance(schedule)}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</Spin>
|
||
|
||
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={820} title={null} className="attendance-drawer">
|
||
<div className="lesson-record-header">
|
||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
||
<p>{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}</p>
|
||
</div>
|
||
{lessonSession && (
|
||
<Alert
|
||
type={isAttendanceCompleted ? 'success' : 'info'}
|
||
showIcon
|
||
title={isAttendanceCompleted ? '本节课考勤已核对完成;如有错误仍可直接修正' : '钉钉考勤已拉取,请核对待确认和异常学生'}
|
||
style={{ marginBottom: 16 }}
|
||
action={
|
||
!isAttendanceCompleted ? (
|
||
<Button type="primary" loading={finishing} onClick={() => void completeAttendance()}>
|
||
完成核对
|
||
</Button>
|
||
) : undefined
|
||
}
|
||
/>
|
||
)}
|
||
<SummaryStrip summary={drawerSummary} />
|
||
<Table<AttendanceRecordItem>
|
||
rowKey="id"
|
||
loading={recordLoading}
|
||
dataSource={lessonRecords}
|
||
pagination={false}
|
||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本节课尚未开始点名" /> }}
|
||
columns={[
|
||
{
|
||
title: '学生', dataIndex: ['student', 'name'],
|
||
render: (name: string) => <div className="student-cell"><Avatar size={32}>{name?.slice(0, 1)}</Avatar><strong>{name || '-'}</strong></div>,
|
||
},
|
||
{
|
||
title: '考勤结果', dataIndex: 'status', width: 310,
|
||
render: (value: string, record: AttendanceRecordItem) => (
|
||
<div className="attendance-marking-actions">
|
||
{STATUS_OPTIONS.map((option) => (
|
||
<Button
|
||
key={option.value}
|
||
size="small"
|
||
type={value === option.value ? 'primary' : 'default'}
|
||
danger={value === option.value && option.value === 'absent'}
|
||
onClick={() => void updateLessonRecord(record, option.value)}
|
||
>
|
||
{option.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
),
|
||
},
|
||
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value} /> },
|
||
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
||
]}
|
||
/>
|
||
</Drawer>
|
||
</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>
|
||
) : (
|
||
<Button type="primary" onClick={onOpen}>
|
||
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</article>
|
||
);
|
||
};
|
||
|
||
|
||
const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||
const [records, setRecords] = useState<AttendanceRecordItem[]>([]);
|
||
const [summary, setSummary] = useState<AttendanceSummary>(EMPTY_SUMMARY);
|
||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(20);
|
||
const [total, setTotal] = useState(0);
|
||
const [classId, setClassId] = useState<number>();
|
||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([
|
||
dayjs().subtract(30, 'day'),
|
||
dayjs(),
|
||
]);
|
||
const [status, setStatus] = useState<string>();
|
||
const [session, setSession] = useState<string>();
|
||
const [editRecord, setEditRecord] = useState<AttendanceRecordItem | null>(null);
|
||
const [editForm] = Form.useForm();
|
||
|
||
const buildParams = useCallback(
|
||
(withPagination = true) => {
|
||
const params: Record<string, string | number> = {};
|
||
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 (status) params.status = status;
|
||
if (session) params.session = session;
|
||
return params;
|
||
},
|
||
[page, pageSize, classId, dateRange, status, session],
|
||
);
|
||
|
||
const loadRecords = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [recordData, summaryData] = await Promise.all([
|
||
api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', {
|
||
params: buildParams(true),
|
||
}),
|
||
api.get<AttendanceSummary>('/attendance-records/summary', {
|
||
params: buildParams(false),
|
||
}),
|
||
]);
|
||
setRecords(recordData.list);
|
||
setTotal(recordData.total);
|
||
setSummary({ ...EMPTY_SUMMARY, ...summaryData });
|
||
} catch (error: unknown) {
|
||
message.error((error as { message?: string })?.message || '加载历史考勤失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [buildParams]);
|
||
|
||
useEffect(() => {
|
||
void Promise.all([
|
||
api.get<ClassOption[]>('/attendance-records/classes').then(setClassOptions),
|
||
api.get<AlertItem[]>('/attendance-records/alerts').then(setAlerts),
|
||
]).catch(() => undefined);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void loadRecords();
|
||
}, [loadRecords]);
|
||
|
||
const resetFilters = () => {
|
||
setClassId(undefined);
|
||
setDateRange([dayjs().subtract(30, 'day'), dayjs()]);
|
||
setStatus(undefined);
|
||
setSession(undefined);
|
||
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 submitEdit = async () => {
|
||
if (!editRecord) return;
|
||
try {
|
||
const values = await editForm.validateFields();
|
||
await api.put(`/attendance-records/${editRecord.id}`, values);
|
||
message.success('考勤记录已更新');
|
||
setEditRecord(null);
|
||
void loadRecords();
|
||
} catch (error: unknown) {
|
||
if ((error as { errorFields?: unknown })?.errorFields) return;
|
||
message.error((error as { message?: string })?.message || '更新失败');
|
||
}
|
||
};
|
||
|
||
const columns: ColumnsType<AttendanceRecordItem> = [
|
||
{
|
||
title: '学生',
|
||
dataIndex: ['student', 'name'],
|
||
fixed: '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) => SESSION_MAP[value] || value,
|
||
},
|
||
{
|
||
title: '结果',
|
||
dataIndex: 'status',
|
||
width: 110,
|
||
render: (value: string) => <AttendanceStatusTag status={value} />,
|
||
},
|
||
{
|
||
title: '记录来源',
|
||
dataIndex: 'source',
|
||
width: 110,
|
||
render: (value: string) => (value === 'dingtalk' ? '钉钉同步' : value === 'schedule' ? '课程生成' : value === 'lesson' ? '课堂点名' : '人工记录'),
|
||
},
|
||
{
|
||
title: '备注',
|
||
dataIndex: 'remark',
|
||
ellipsis: true,
|
||
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
||
},
|
||
{
|
||
title: '归档时间',
|
||
dataIndex: 'createdAt',
|
||
width: 165,
|
||
render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm'),
|
||
},
|
||
...(canEdit
|
||
? [
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
fixed: 'right' as const,
|
||
width: 80,
|
||
render: (_: unknown, record: AttendanceRecordItem) => (
|
||
<Button
|
||
type="text"
|
||
icon={<EditOutlined />}
|
||
onClick={() => {
|
||
setEditRecord(record);
|
||
editForm.setFieldsValue({ status: record.status, remark: record.remark });
|
||
}}
|
||
>
|
||
纠错
|
||
</Button>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
|
||
return (
|
||
<div className="attendance-page admin-attendance">
|
||
<section className="attendance-hero attendance-hero--admin">
|
||
<div>
|
||
<span className="attendance-eyebrow">ATTENDANCE ARCHIVE</span>
|
||
<h1>历史考勤档案</h1>
|
||
<p>面向管理人员的历史检索、异常追踪与数据归档,不承载实时上课操作。</p>
|
||
</div>
|
||
<PermissionButton
|
||
permission="attendance:export"
|
||
icon={<ExportOutlined />}
|
||
size="large"
|
||
onClick={handleExport}
|
||
>
|
||
导出当前结果
|
||
</PermissionButton>
|
||
</section>
|
||
|
||
<SummaryStrip summary={summary} />
|
||
|
||
{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>
|
||
)}
|
||
|
||
<Card className="archive-card" bordered={false}>
|
||
<div className="archive-toolbar">
|
||
<div className="archive-toolbar__title">
|
||
<FileSearchOutlined />
|
||
<div>
|
||
<strong>档案检索</strong>
|
||
<span>默认查看最近 30 天</span>
|
||
</div>
|
||
</div>
|
||
<Space wrap size={10}>
|
||
<Select
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
placeholder="全部班级"
|
||
value={classId}
|
||
onChange={(value) => {
|
||
setClassId(value);
|
||
setPage(1);
|
||
}}
|
||
options={classOptions.map((item) => ({ value: item.classId, label: item.className }))}
|
||
style={{ width: 170 }}
|
||
/>
|
||
<RangePicker
|
||
value={dateRange}
|
||
onChange={(value) => {
|
||
setDateRange(value as [Dayjs, Dayjs] | null);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
<Select
|
||
allowClear
|
||
placeholder="考勤结果"
|
||
value={status}
|
||
onChange={(value) => {
|
||
setStatus(value);
|
||
setPage(1);
|
||
}}
|
||
options={STATUS_OPTIONS}
|
||
style={{ width: 130 }}
|
||
/>
|
||
<Select
|
||
allowClear
|
||
placeholder="课程时段"
|
||
value={session}
|
||
onChange={(value) => {
|
||
setSession(value);
|
||
setPage(1);
|
||
}}
|
||
options={SESSION_OPTIONS}
|
||
style={{ width: 130 }}
|
||
/>
|
||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||
重置
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
|
||
<Table<AttendanceRecordItem>
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={records}
|
||
loading={loading}
|
||
scroll={{ x: 1050 }}
|
||
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>
|
||
|
||
<Modal
|
||
open={Boolean(editRecord)}
|
||
title="考勤记录纠错"
|
||
okText="保存修正"
|
||
onOk={submitEdit}
|
||
onCancel={() => setEditRecord(null)}
|
||
>
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
title="管理员修正会保留操作日志"
|
||
style={{ marginBottom: 20 }}
|
||
/>
|
||
<Form form={editForm} layout="vertical">
|
||
<Form.Item name="status" label="考勤结果" rules={[{ required: true }]}>
|
||
<Select options={STATUS_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item name="remark" label="修正说明">
|
||
<Input.TextArea rows={3} placeholder="建议填写修正原因,便于后续审计" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AttendancePage;
|