Files
gongxue-base/apps/admin/src/pages/Attendance/index.tsx
wangziqi cc4f4dae4e fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- 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>
2026-07-12 22:59:03 +08:00

814 lines
27 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,
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;