261 lines
9.1 KiB
TypeScript
261 lines
9.1 KiB
TypeScript
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>
|
||
);
|
||
};
|