Files
gongxue-base/apps/admin/src/pages/Exams/detail.tsx

199 lines
6.6 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, { 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;