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, Modal, Select, Table, Tag } 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 { CLASS_TYPE_OPTIONS, COURSE_CATEGORY_OPTIONS, ENROLLMENT_STATUS_MAP, formatEnrollmentDisplayName, getClassTypeLabel, getCourseCategoryLabel, getEnrollmentStatus } from './shared';
import type { EnrollmentRecord, TabProps } from './shared';
const ENROLLMENT_FIELDS = {
courseCategory: 'courseCategory',
classType: 'classType',
className: 'className',
headTeacher: 'headTeacher',
subjectTeacher: 'subjectTeacher',
startDate: 'startDate',
endDate: 'endDate',
status: 'status',
} as const;
export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
data,
studentId,
}) => {
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 addEnrollmentMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
api.post(`/archive/${studentId}/enrollments`, payload),
{ invalidate: [['archive', studentId]] },
);
const saveEnrollmentCellMutation = useApiMutation(
async ({ id, field, value }: { id: number; field: string; value: unknown }) =>
api.put(`/archive/enrollments/${id}`, { [field]: value }),
{ invalidate: [['archive', studentId]] },
);
const purgeEnrollmentMutation = useApiMutation(
async (id: number) => api.delete(`/archive/enrollments/${id}/permanent`),
{ invalidate: [['archive', studentId]] },
);
const handleAdd = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await addEnrollmentMutation.mutateAsync({
...values,
startDate: values.startDate?.format('YYYY-MM-DD'),
endDate: values.endDate?.format('YYYY-MM-DD'),
});
message.success('报读记录已添加');
setModalOpen(false);
form.resetFields();
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = async (record: EnrollmentRecord, field: string, value: unknown) => {
try {
await saveEnrollmentCellMutation.mutateAsync({ id: record.id, field, value });
message.success('报读记录已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (record: EnrollmentRecord) => {
modal.confirm({
title: `永久删除报读记录(${formatEnrollmentDisplayName(record)}`,
content: '删除后不可恢复,被考试成绩引用时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeEnrollmentMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const columns: ColumnsType<EnrollmentRecord> = [
{
title: '课程类别',
dataIndex: 'courseCategory',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.courseCategory} record={r} editor="select" options={COURSE_CATEGORY_OPTIONS} onSave={saveCell}>
{getCourseCategoryLabel(v)}
</EditableArchiveCell>
),
},
{
title: '班型',
dataIndex: 'classType',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.classType} record={r} editor="select" options={CLASS_TYPE_OPTIONS} onSave={saveCell}>
{getClassTypeLabel(v)}
</EditableArchiveCell>
),
},
{
title: '班级名称',
dataIndex: 'className',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.className} record={r} onSave={saveCell}>
{v || '-'}
</EditableArchiveCell>
),
},
{
title: '班主任',
dataIndex: 'headTeacher',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.headTeacher} record={r} onSave={saveCell}>
{v || '-'}
</EditableArchiveCell>
),
},
{
title: '任课教师',
dataIndex: 'subjectTeacher',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.subjectTeacher} record={r} onSave={saveCell}>
{v || '-'}
</EditableArchiveCell>
),
},
{
title: '开始日期',
dataIndex: 'startDate',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.startDate} record={r} editor="date" onSave={saveCell}>
{v || '-'}
</EditableArchiveCell>
),
},
{
title: '结束日期',
dataIndex: 'endDate',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.endDate} record={r} editor="date" onSave={saveCell}>
{v || '-'}
</EditableArchiveCell>
),
},
{
title: '状态',
dataIndex: 'status',
render: (v: string, r) => {
const status = getEnrollmentStatus(v);
return (
<EditableArchiveCell
value={v}
field={ENROLLMENT_FIELDS.status}
record={r}
editor="select"
options={Object.entries(ENROLLMENT_STATUS_MAP).map(([value, item]) => ({
value,
label: item.text,
}))}
onSave={saveCell}
>
<Tag color={status.color}>{status.text}</Tag>
</EditableArchiveCell>
);
},
},
{
title: '操作',
render: (_: unknown, r: EnrollmentRecord) =>
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<EnrollmentRecord>
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="courseCategory"
label="课程类别"
rules={[{ required: true, message: '请选择课程类别' }]}
>
<Select options={COURSE_CATEGORY_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item
name="classType"
label="班型"
rules={[{ required: true, message: '请选择班型' }]}
>
<Select options={CLASS_TYPE_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item name="className" label="班级名称">
<Input placeholder="如2024届冲刺班" />
</Form.Item>
<Form.Item name="headTeacher" label="班主任">
<Input placeholder="班主任姓名" />
</Form.Item>
<Form.Item name="subjectTeacher" label="任课教师">
<Input placeholder="任课教师姓名" />
</Form.Item>
<Form.Item name="startDate" label="开始日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="endDate" label="结束日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
);
};