feat: add exam score management
This commit is contained in:
129
apps/admin/src/pages/Exams/index.tsx
Normal file
129
apps/admin/src/pages/Exams/index.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user