feat: add exam score management
This commit is contained in:
94
apps/admin/src/pages/Exams/detail.tsx
Normal file
94
apps/admin/src/pages/Exams/detail.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Card, Descriptions, Empty, Space, Spin, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
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 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 reveal = useViewSensitive(row.studentId, '考试管理');
|
||||
if (!row.phone) return <>-</>;
|
||||
return <Space size={4}><span>{maskPhone(row.phone)}</span><Tooltip title="查看完整手机号"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => reveal('手机号', row.phone)} /></Tooltip></Space>;
|
||||
};
|
||||
|
||||
const ExamDetailPage: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [detail, setDetail] = useState<ExamDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setDetail(await api.get<ExamDetail>(`/exams/${id}`)); }
|
||||
catch (error) { message.error((error as { message?: string })?.message || '加载考试失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
const saveScore = async (row: ScoreRow, value: number | undefined) => {
|
||||
await api.put(`/exams/${id}/scores/${row.id}`, { score: value ?? null });
|
||||
message.success('成绩已保存');
|
||||
await load();
|
||||
};
|
||||
|
||||
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) => <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]);
|
||||
|
||||
if (loading && !detail) return <div className="exam-detail-loading"><Spin size="large" /></div>;
|
||||
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></Space></div>
|
||||
<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;
|
||||
Reference in New Issue
Block a user