feat: add exam score management
Some checks failed
CI 检查 / lint (pull_request) Has been cancelled
CI 检查 / typecheck (pull_request) Has been cancelled
CI 检查 / test (pull_request) Has been cancelled

This commit is contained in:
2026-07-21 16:04:17 +08:00
parent 37ef6f9dd7
commit cbc04fea4f
27 changed files with 1061 additions and 16 deletions

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { DatePicker, Form, Input, Modal, Select } from 'antd';
import type { FormInstance } from 'antd';
import type { ClassOption, ExamFormValues } from './types';
import { EXAM_TYPE_OPTIONS } from './types';
interface Props {
open: boolean;
editing: boolean;
saving: boolean;
form: FormInstance<ExamFormValues>;
classes: ClassOption[];
onCancel: () => void;
onSubmit: () => void;
}
const ExamFormModal: React.FC<Props> = ({
open,
editing,
saving,
form,
classes,
onCancel,
onSubmit,
}) => (
<Modal
title={editing ? '编辑考试' : '创建考试'}
open={open}
confirmLoading={saving}
onCancel={onCancel}
onOk={onSubmit}
width={560}
>
<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="考试名称" rules={[{ required: true, message: '请输入考试名称' }]}>
<Input placeholder="如2026 年 7 月月考" />
</Form.Item>
<Form.Item name="subject" label="科目" rules={[{ required: true, message: '请输入科目' }]}>
<Input placeholder="如:数学" />
</Form.Item>
<Form.Item name="examDate" label="考试日期" rules={[{ required: true, message: '请选择考试日期' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="classId" label="考试班级" rules={[{ required: true, message: '请选择考试班级' }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="选择在读班级"
options={classes.filter((item) => !item.isArchived).map((item) => ({ value: item.id, label: item.name }))}
/>
</Form.Item>
</Form>
</Modal>
);
export default ExamFormModal;

View 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;

View File

@@ -0,0 +1,129 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Card, Col, Empty, Form, Input, Progress, Row, Select, Space, Tag } from 'antd';
import { CalendarOutlined, PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import api from '../../api';
import { message } from '../../ui/app-message';
import ExamFormModal from './ExamFormModal';
import type { ClassOption, ExamFormValues, ExamItem } from './types';
import { EXAM_TYPE_OPTIONS } from './types';
import './style.css';
const ExamsPage: React.FC = () => {
const navigate = useNavigate();
const [form] = Form.useForm<ExamFormValues>();
const [data, setData] = useState<ExamItem[]>([]);
const [classes, setClasses] = useState<ClassOption[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [keyword, setKeyword] = useState('');
const [examType, setExamType] = useState<string>();
const [classId, setClassId] = useState<number>();
const loadClasses = useCallback(async () => {
const result = await api.get<ClassOption[]>('/classes');
setClasses(result ?? []);
}, []);
const loadExams = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (keyword.trim()) params.set('keyword', keyword.trim());
if (examType) params.set('examType', examType);
if (classId) params.set('classId', String(classId));
const result = await api.get<ExamItem[]>(`/exams?${params.toString()}`);
setData(result ?? []);
} catch (error) {
message.error((error as { message?: string })?.message || '加载考试失败');
} finally {
setLoading(false);
}
}, [classId, examType, keyword]);
useEffect(() => {
void loadClasses().catch((error: { message?: string }) => message.error(error?.message || '加载班级失败'));
}, [loadClasses]);
useEffect(() => {
const timer = window.setTimeout(() => void loadExams(), 200);
return () => window.clearTimeout(timer);
}, [loadExams]);
const classOptions = useMemo(
() => classes.map((item) => ({ value: item.id, label: item.name })),
[classes],
);
const openCreate = () => {
form.resetFields();
form.setFieldValue('examDate', dayjs());
setModalOpen(true);
};
const submit = async () => {
try {
const values = await form.validateFields();
setSaving(true);
const payload = { ...values, examDate: values.examDate.format('YYYY-MM-DD') };
await api.post('/exams', payload);
message.success('考试已创建');
setModalOpen(false);
await loadExams();
} catch (error) {
if ((error as { errorFields?: unknown[] }).errorFields) return;
message.error((error as { message?: string })?.message || '保存失败');
} finally {
setSaving(false);
}
};
return (
<div className="exam-page">
<div className="exam-toolbar">
<Space wrap>
<Input value={keyword} onChange={(event) => setKeyword(event.target.value)} prefix={<SearchOutlined />} placeholder="搜索考试名称" allowClear />
<Select value={examType} onChange={setExamType} options={EXAM_TYPE_OPTIONS} placeholder="考试类型" allowClear style={{ width: 140 }} />
<Select value={classId} onChange={setClassId} options={classOptions} placeholder="考试班级" allowClear showSearch optionFilterProp="label" style={{ width: 180 }} />
</Space>
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</Space>
</div>
{data.length === 0 && !loading ? (
<div className="exam-empty"><Empty description="暂无考试" /></div>
) : (
<Row gutter={[16, 16]}>
{data.map((exam) => {
const percent = exam.totalStudents === 0 ? 0 : Math.round((exam.enteredScores / exam.totalStudents) * 100);
return (
<Col key={exam.id} xs={24} sm={12} xl={8} xxl={6}>
<Card
className="exam-card"
loading={loading}
title={<Space><Tag color="blue">{exam.examType}</Tag><span>{exam.examName}</span></Space>}
extra={<Tag color="green"></Tag>}
actions={[
<span key="detail" onClick={() => navigate(`/exams/${exam.id}`)}></span>,
]}
>
<div className="exam-meta"><span></span><strong>{exam.subject}</strong></div>
<div className="exam-meta"><span><TeamOutlined /> </span><strong>{exam.className}</strong></div>
<div className="exam-meta"><span><CalendarOutlined /> </span><strong>{exam.examDate}</strong></div>
<div className="exam-progress"><div><span></span><strong>{exam.enteredScores}/{exam.totalStudents}</strong></div><Progress percent={percent} size="small" /></div>
</Card>
</Col>
);
})}
</Row>
)}
<ExamFormModal open={modalOpen} editing={false} saving={saving} form={form} classes={classes} onCancel={() => setModalOpen(false)} onSubmit={() => void submit()} />
</div>
);
};
export default ExamsPage;

View File

@@ -0,0 +1,77 @@
.exam-page,
.exam-detail-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.exam-toolbar,
.exam-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.exam-detail-header h2 {
margin: 0;
font-size: 20px;
letter-spacing: 0;
}
.exam-card {
height: 100%;
border-radius: 8px;
}
.exam-card .ant-card-head-title {
min-width: 0;
}
.exam-card .ant-card-head-title > .ant-space {
max-width: 100%;
}
.exam-card .ant-card-head-title span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exam-meta,
.exam-progress > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 10px;
}
.exam-meta span,
.exam-progress span {
color: rgba(0, 0, 0, 0.55);
}
.exam-progress {
margin-top: 16px;
}
.exam-empty,
.exam-detail-loading {
min-height: 360px;
display: grid;
place-items: center;
}
.exam-summary {
border-radius: 8px;
}
@media (max-width: 575px) {
.exam-toolbar > .ant-space,
.exam-toolbar .ant-input-affix-wrapper,
.exam-toolbar .ant-select {
width: 100% !important;
}
}

View File

@@ -0,0 +1,37 @@
import type dayjs from 'dayjs';
export interface ExamItem {
id: number;
examType: string;
examName: string;
subject: string;
examDate: string;
classId: number;
className: string;
status: 'active' | 'archived';
totalStudents: number;
enteredScores: number;
}
export interface ExamFormValues {
examType: string;
examName: string;
subject: string;
examDate: dayjs.Dayjs;
classId: number;
}
export interface ClassOption {
id: number;
name: string;
isArchived: boolean;
}
export const EXAM_TYPE_OPTIONS = [
{ value: '月考', label: '月考' },
{ value: '周测', label: '周测' },
{ value: '期中考试', label: '期中考试' },
{ value: '期末考试', label: '期末考试' },
{ value: '模拟考试', label: '模拟考试' },
{ value: '入学测试', label: '入学测试' },
];