130 lines
5.3 KiB
TypeScript
130 lines
5.3 KiB
TypeScript
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;
|