Files
gongxue-base/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx

261 lines
9.1 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, { 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>
);
};