199 lines
6.6 KiB
TypeScript
199 lines
6.6 KiB
TypeScript
import React, { useCallback, useMemo } from 'react';
|
||
import {Alert, Button, Card, Descriptions, Empty, Space, Table, Tag, Tooltip, Skeleton} from 'antd';
|
||
import { QueryErrorState } from '../../components/QueryState';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
||
import { useNavigate, useParams } from 'react-router';
|
||
import api from '../../api';
|
||
import EditableCell from '../../components/EditableCell';
|
||
import { useViewSensitive } from '../../hooks/useViewSensitive';
|
||
import { maskPhone } from '../../utils/sensitive';
|
||
import { message } from '../../ui/app-message';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||
import { validateResponse } from '../../utils/validate';
|
||
import { examDetailSchema } from '../../api/schemas';
|
||
import { usePermission } from '../../hooks/usePermission';
|
||
import type { ExamItem } from './types';
|
||
import './style.css';
|
||
|
||
interface ScoreRow {
|
||
id: number;
|
||
studentId: number;
|
||
phone: string;
|
||
name: string;
|
||
score: number | null;
|
||
classAvg: number | null;
|
||
rank: number | null;
|
||
}
|
||
|
||
interface ExamDetail extends ExamItem {
|
||
scores: ScoreRow[];
|
||
}
|
||
|
||
const PhoneCell: React.FC<{ row: ScoreRow }> = ({ row }) => {
|
||
const { hasPermission } = usePermission();
|
||
const reveal = useViewSensitive(row.studentId, '考试管理', hasPermission('log:create'));
|
||
if (!row.phone) return <>-</>;
|
||
return (
|
||
<Space size={4}>
|
||
<span>{maskPhone(row.phone)}</span>
|
||
{hasPermission('log:create') ? (
|
||
<Tooltip title="查看完整手机号">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<EyeOutlined />}
|
||
onClick={() => reveal('手机号', row.phone)}
|
||
/>
|
||
</Tooltip>
|
||
) : null}
|
||
</Space>
|
||
);
|
||
};
|
||
|
||
const ExamDetailPage: React.FC = () => {
|
||
const { id } = useParams();
|
||
const navigate = useNavigate();
|
||
|
||
const { data: detail, isLoading, isFetching, isError, refetch } = useQuery<ExamDetail | null>({
|
||
queryKey: ['exams', 'detail', id],
|
||
queryFn: async () =>
|
||
validateResponse<ExamDetail>(examDetailSchema, await api.get<ExamDetail>(`/exams/${id}`)),
|
||
});
|
||
const loading = isLoading || isFetching;
|
||
|
||
const saveScoreMutation = useApiMutation(
|
||
async ({ rowId, score }: { rowId: number; score: number | null }) =>
|
||
api.put(`/exams/${id}/scores/${rowId}`, { score }),
|
||
{ invalidate: [['exams', 'detail', id]] },
|
||
);
|
||
const saveScore = useCallback(
|
||
async (row: ScoreRow, value: number | undefined) => {
|
||
try {
|
||
await saveScoreMutation.mutateAsync({ rowId: row.id, score: value ?? null });
|
||
message.success('成绩已保存');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
},
|
||
[saveScoreMutation],
|
||
);
|
||
|
||
const columns = useMemo<ColumnsType<ScoreRow>>(() => {
|
||
if (!detail) return [];
|
||
const fixed = [
|
||
{
|
||
title: '手机号*',
|
||
width: 155,
|
||
render: (_: unknown, row: ScoreRow) => <PhoneCell row={row} />,
|
||
},
|
||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||
{ title: '考试类型*', width: 110, render: () => detail.examType },
|
||
{ title: '考试名称', width: 170, render: () => detail.examName },
|
||
{ title: '科目*', width: 90, render: () => detail.subject },
|
||
];
|
||
return [
|
||
...fixed,
|
||
{
|
||
title: '成绩*',
|
||
dataIndex: 'score',
|
||
width: 100,
|
||
render: (value: number | null, row: ScoreRow) => (
|
||
detail.status === 'archived' ? (
|
||
value ?? '-'
|
||
) : (
|
||
<EditableCell<number | undefined>
|
||
value={value ?? undefined}
|
||
editor="money"
|
||
min={0}
|
||
max={999.99}
|
||
permission="exam:view"
|
||
onSave={(next) => saveScore(row, next)}
|
||
>
|
||
{value ?? '-'}
|
||
</EditableCell>
|
||
)
|
||
),
|
||
},
|
||
{
|
||
title: '班级均分',
|
||
dataIndex: 'classAvg',
|
||
width: 110,
|
||
render: (value: number | null) => value ?? '-',
|
||
},
|
||
{
|
||
title: '排名',
|
||
dataIndex: 'rank',
|
||
width: 80,
|
||
render: (value: number | null) => value ?? '-',
|
||
},
|
||
{ title: '考试日期', width: 110, render: () => detail.examDate },
|
||
{ title: '关联报读(班级名)', width: 180, render: () => detail.className },
|
||
];
|
||
}, [detail, saveScore]);
|
||
|
||
if (loading && !detail)
|
||
return (
|
||
<div className="exam-detail-loading">
|
||
<Skeleton active paragraph={{ rows: 10 }} />
|
||
</div>
|
||
);
|
||
if (isError) {
|
||
return (
|
||
<QueryErrorState
|
||
title="考试详情加载失败"
|
||
description="请检查网络后重试。"
|
||
onRetry={() => void refetch()}
|
||
/>
|
||
);
|
||
}
|
||
if (!detail) return <Empty description="考试不存在或无权访问" />;
|
||
|
||
const average = detail.scores.find((row) => row.classAvg !== null)?.classAvg ?? null;
|
||
return (
|
||
<div className="exam-detail-page">
|
||
<div className="exam-detail-header">
|
||
<Space>
|
||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/exams')}>
|
||
返回
|
||
</Button>
|
||
<h2>{detail.examName}</h2>
|
||
{detail.status === 'archived' ? <Tag>已归档</Tag> : null}
|
||
</Space>
|
||
</div>
|
||
{detail.status === 'archived' ? (
|
||
<Alert type="info" showIcon title="该考试已归档,成绩仅供查看。如需继续录入,请先在考试列表中恢复。" />
|
||
) : null}
|
||
<Card className="exam-summary">
|
||
<Descriptions column={{ xs: 1, sm: 2, lg: 5 }}>
|
||
<Descriptions.Item label="考试类型">{detail.examType}</Descriptions.Item>
|
||
<Descriptions.Item label="科目">{detail.subject}</Descriptions.Item>
|
||
<Descriptions.Item label="考试班级">{detail.className}</Descriptions.Item>
|
||
<Descriptions.Item label="考试日期">{detail.examDate}</Descriptions.Item>
|
||
<Descriptions.Item label="录入进度">
|
||
{detail.enteredScores}/{detail.totalStudents},均分 {average ?? '-'}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
</Card>
|
||
<Card title="成绩表">
|
||
<Table<ScoreRow>
|
||
columns={columns}
|
||
dataSource={detail.scores}
|
||
rowKey="id"
|
||
loading={loading}
|
||
scroll={{ x: 1310 }}
|
||
pagination={{
|
||
defaultPageSize: 30,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [30, 50, 100],
|
||
}}
|
||
locale={{ emptyText: <Empty description="暂无学生名单" /> }}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ExamDetailPage;
|