feat: 学生档案与报告生成

This commit is contained in:
2026-08-05 17:11:15 +08:00
parent 0e6e3e2d96
commit c622e40a12
23 changed files with 2491 additions and 2148 deletions

View File

@@ -0,0 +1,260 @@
import React, { useState } from 'react';
import { App, Button, DatePicker, Form, Input, InputNumber, Modal, Select, Table } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined } from '@ant-design/icons';
import api from '../../api';
import { message } from '../../ui/app-message';
import { useApiMutation } from '../../hooks/useApiMutation';
import { usePermission } from '../../hooks/usePermission';
import PermissionButton from '../PermissionButton';
import { EditableArchiveCell } from './EditableArchiveCell';
import { EXAM_TYPE_OPTIONS, formatEnrollmentDisplayName, getClassTypeLabel } from './shared';
import type { EnrollmentRecord, ExamScoreRecord, TabProps } from './shared';
const EXAM_SCORE_FIELDS = {
examType: 'examType',
examName: 'examName',
subject: 'subject',
score: 'score',
classAvg: 'classAvg',
rank: 'rank',
examDate: 'examDate',
enrollmentId: 'enrollmentId',
} as const;
export const ExamScoresTab: React.FC<
TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] }
> = ({ data, studentId, enrollments }) => {
const { modal } = App.useApp();
const { hasPermission } = usePermission();
const canPurgeArchive = hasPermission('archive:purge');
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const addExamScoreMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
api.post(`/archive/${studentId}/exam-scores`, payload),
{ invalidate: [['archive', studentId]] },
);
const saveExamScoreCellMutation = useApiMutation(
async ({ id, field, value }: { id: number; field: string; value: unknown }) =>
api.put(`/archive/exam-scores/${id}`, { [field]: value }),
{ invalidate: [['archive', studentId]] },
);
const purgeExamScoreMutation = useApiMutation(
async (id: number) => api.delete(`/archive/exam-scores/${id}/permanent`),
{ invalidate: [['archive', studentId]] },
);
const handleAdd = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await addExamScoreMutation.mutateAsync({
...values,
examDate: values.examDate?.format('YYYY-MM-DD'),
});
message.success('考试成绩已添加');
setModalOpen(false);
form.resetFields();
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = async (record: ExamScoreRecord, field: string, value: unknown) => {
try {
await saveExamScoreCellMutation.mutateAsync({ id: record.id, field, value });
message.success('考试成绩已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (record: ExamScoreRecord) => {
modal.confirm({
title: `永久删除考试成绩(${record.examName || record.subject || `记录${record.id}`}`,
content: '删除后不可恢复,成绩记录将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeExamScoreMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const columns: ColumnsType<ExamScoreRecord> = [
{
title: '考试类型',
dataIndex: 'examType',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.examType} record={r} editor="select" options={EXAM_TYPE_OPTIONS} onSave={saveCell}>
{EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}
</EditableArchiveCell>
),
},
{
title: '考试名称',
dataIndex: 'examName',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.examName} record={r} onSave={saveCell}>
{v || '-'}
</EditableArchiveCell>
),
},
{
title: '科目',
dataIndex: 'subject',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.subject} record={r} required onSave={saveCell}>
{v}
</EditableArchiveCell>
),
},
{
title: '成绩',
dataIndex: 'score',
render: (v: number | null, r) => (
<EditableArchiveCell value={v ?? undefined} field={EXAM_SCORE_FIELDS.score} record={r} editor="number" min={0} onSave={saveCell}>
{v ?? '-'}
</EditableArchiveCell>
),
},
{
title: '班级均分',
dataIndex: 'classAvg',
render: (v: number | undefined, r) => (
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.classAvg} record={r} editor="number" min={0} onSave={saveCell}>
{v !== undefined ? v : '-'}
</EditableArchiveCell>
),
},
{
title: '排名',
dataIndex: 'rank',
render: (v: number | undefined, r) => (
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.rank} record={r} editor="number" min={1} onSave={saveCell}>
{v !== undefined ? v : '-'}
</EditableArchiveCell>
),
},
{
title: '考试日期',
dataIndex: 'examDate',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.examDate} record={r} editor="date" onSave={saveCell}>
{v || '-'}
</EditableArchiveCell>
),
},
{
title: '关联报读',
dataIndex: 'enrollmentId',
render: (v: number | undefined, r) => (
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.enrollmentId} record={r} editor="select" options={enrollments.map((item) => ({ value: item.id, label: formatEnrollmentDisplayName(item), }))} onSave={saveCell}>
{(() => {
if (r.examId) return r.exam?.class?.name || '-';
if (v === undefined) return '-';
const enr = enrollments.find((e) => e.id === v);
return enr ? formatEnrollmentDisplayName(enr) : String(v);
})()}
</EditableArchiveCell>
),
},
{
title: '操作',
render: (_: unknown, r: ExamScoreRecord) =>
r.status === 'archived' && canPurgeArchive ? (
<Button size="small" danger type="link" onClick={() => handlePurge(r)}>
</Button>
) : null,
},
];
return (
<div>
<PermissionButton
permission="student:edit"
icon={<PlusOutlined />}
type="primary"
onClick={() => {
form.resetFields();
setModalOpen(true);
}}
style={{ marginBottom: 16 }}
>
</PermissionButton>
<Table<ExamScoreRecord>
columns={columns}
dataSource={data}
rowKey="id"
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加考试成绩"
open={modalOpen && hasPermission('student:edit')}
onOk={hasPermission('student:edit') ? handleAdd : undefined}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item
name="examType"
label="考试类型"
rules={[{ required: true, message: '请选择考试类型' }]}
>
<Select options={EXAM_TYPE_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item name="examName" label="考试名称">
<Input placeholder="如2024第一次月考" />
</Form.Item>
<Form.Item
name="subject"
label="科目"
rules={[{ required: true, message: '请输入科目' }]}
>
<Input placeholder="如:数学" />
</Form.Item>
<Form.Item name="score" label="成绩" rules={[{ required: true, message: '请输入成绩' }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="classAvg" label="班级均分">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="rank" label="排名">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="examDate" label="考试日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="enrollmentId" label="关联报读">
<Select
allowClear
placeholder="选择关联的报读记录"
options={enrollments.map((e) => ({
value: e.id,
label: `${formatEnrollmentDisplayName(e)}${getClassTypeLabel(e.classType)}`,
}))}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
};