feat: 学生档案与报告生成
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user