feat: 重构各业务模块管理页面与服务
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
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';
|
||||
import { DatePicker, Form, Input, Modal, Select, type FormInstance } from 'antd';
|
||||
import { EXAM_TYPE_OPTIONS, type ClassOption, type ExamFormValues } from './types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -32,24 +30,42 @@ const ExamFormModal: React.FC<Props> = ({
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="examType" label="考试类型" rules={[{ required: true, message: '请选择考试类型' }]}>
|
||||
<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: '请输入考试名称' }]}>
|
||||
<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: '请选择考试日期' }]}>
|
||||
<Form.Item
|
||||
name="examDate"
|
||||
label="考试日期"
|
||||
rules={[{ required: true, message: '请选择考试日期' }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="classId" label="考试班级" rules={[{ required: true, message: '请选择考试班级' }]}>
|
||||
<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 }))}
|
||||
options={classes
|
||||
.filter((item) => !item.isArchived)
|
||||
.map((item) => ({ value: item.id, label: item.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Alert, Button, Card, Descriptions, Empty, Space, Spin, Table, Tag, 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 { 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';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
|
||||
interface ScoreRow {
|
||||
id: number;
|
||||
@@ -50,30 +55,38 @@ const PhoneCell: React.FC<{ row: ScoreRow }> = ({ row }) => {
|
||||
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]);
|
||||
const { data: detail, isLoading, isFetching } = useQuery<ExamDetail | null>({
|
||||
queryKey: ['exams', 'detail', id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return validateResponse<ExamDetail>(
|
||||
examDetailSchema,
|
||||
await api.get<ExamDetail>(`/exams/${id}`),
|
||||
);
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '加载考试失败'));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
const loading = isLoading || isFetching;
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
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) => {
|
||||
await api.put(`/exams/${id}/scores/${row.id}`, { score: value ?? null });
|
||||
message.success('成绩已保存');
|
||||
await load();
|
||||
try {
|
||||
await saveScoreMutation.mutateAsync({ rowId: row.id, score: value ?? null });
|
||||
message.success('成绩已保存');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
},
|
||||
[id, load],
|
||||
[saveScoreMutation],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnsType<ScoreRow>>(() => {
|
||||
|
||||
@@ -1,22 +1,51 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Card, Checkbox, Col, Empty, Form, Input, Popconfirm, Progress, Row, Select, Space, Switch, Tag } from 'antd';
|
||||
import { CalendarOutlined, InboxOutlined, PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useDebounceValue } from 'usehooks-ts';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
DeleteOutlined,
|
||||
InboxOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import ExamFormModal from './ExamFormModal';
|
||||
import { selectAllExamIds, toggleExamSelection } from './selection';
|
||||
import type { ClassOption, ExamFormValues, ExamItem } from './types';
|
||||
import { EXAM_TYPE_OPTIONS } from './types';
|
||||
import { EXAM_TYPE_OPTIONS, type ClassOption, type ExamFormValues, type ExamItem } from './types';
|
||||
import './style.css';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { classOptionsSchema, examsSchema } from '../../api/schemas';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
|
||||
const ExamsPage: React.FC = () => {
|
||||
const { modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = usePermission();
|
||||
const canPurgeExam = hasPermission('exam:purge');
|
||||
const [form] = Form.useForm<ExamFormValues>();
|
||||
const [data, setData] = useState<ExamItem[]>([]);
|
||||
const [classes, setClasses] = useState<ClassOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -25,38 +54,96 @@ const ExamsPage: React.FC = () => {
|
||||
const [classId, setClassId] = useState<number>();
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [selectedExamIds, setSelectedExamIds] = useState<number[]>([]);
|
||||
const [debouncedFilters] = useDebounceValue(
|
||||
{ keyword, examType, classId, showArchived },
|
||||
200,
|
||||
);
|
||||
|
||||
const loadClasses = useCallback(async () => {
|
||||
const result = await api.get<ClassOption[]>('/classes');
|
||||
setClasses(result ?? []);
|
||||
}, []);
|
||||
const { data: classes = [] } = useQuery<ClassOption[]>({
|
||||
queryKey: ['exams', 'classes'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return (
|
||||
validateResponse<ClassOption[]>(
|
||||
classOptionsSchema,
|
||||
await api.get<ClassOption[]>('/classes'),
|
||||
) ?? []
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
message.error(getErrorMessage(error, '加载班级失败'));
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const loadExams = useCallback(async () => {
|
||||
const { data = [], isFetching } = useQuery<ExamItem[]>({
|
||||
queryKey: [
|
||||
'exams',
|
||||
debouncedFilters.keyword,
|
||||
debouncedFilters.examType,
|
||||
debouncedFilters.classId,
|
||||
debouncedFilters.showArchived,
|
||||
],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (debouncedFilters.keyword.trim())
|
||||
params.set('keyword', debouncedFilters.keyword.trim());
|
||||
if (debouncedFilters.examType) params.set('examType', debouncedFilters.examType);
|
||||
if (debouncedFilters.classId) params.set('classId', String(debouncedFilters.classId));
|
||||
params.set('isArchived', String(debouncedFilters.showArchived));
|
||||
return (
|
||||
validateResponse<ExamItem[]>(
|
||||
examsSchema,
|
||||
await api.get<ExamItem[]>(`/exams?${params.toString()}`),
|
||||
) ?? []
|
||||
);
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '加载考试失败'));
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
const loading = isFetching;
|
||||
|
||||
const saveMutation = useApiMutation(
|
||||
async (payload: Record<string, unknown>) => api.post('/exams', payload),
|
||||
{ invalidate: [['exams']] },
|
||||
);
|
||||
const archiveMutation = useApiMutation(
|
||||
async ({ id, archive }: { id: number; archive: boolean }) =>
|
||||
api.put(`/exams/${id}/${archive ? 'archive' : 'restore'}`),
|
||||
{ invalidate: [['exams']] },
|
||||
);
|
||||
const purgeMutation = useApiMutation(
|
||||
async (id: number) => api.delete(`/exams/${id}/permanent`),
|
||||
{ invalidate: [['exams']] },
|
||||
);
|
||||
const batchPurgeMutation = useApiMutation(
|
||||
async (ids: number[]) => api.post<{ deleted: number; skipped: number }>('/exams/batch-permanent-delete', { ids }),
|
||||
{ invalidate: [['exams']] },
|
||||
);
|
||||
const batchArchiveMutation = useApiMutation(
|
||||
async (ids: number[]) => api.put<{ archived: number; skipped: number }>('/exams/batch-archive', { ids }),
|
||||
{ invalidate: [['exams']] },
|
||||
);
|
||||
const batchRestoreMutation = useApiMutation(
|
||||
async (ids: number[]) => api.put<{ restored: number; skipped: number }>('/exams/batch-restore', { ids }),
|
||||
{ invalidate: [['exams']] },
|
||||
);
|
||||
|
||||
const updateKeyword = (value: string) => {
|
||||
setKeyword(value);
|
||||
setSelectedExamIds([]);
|
||||
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));
|
||||
params.set('isArchived', String(showArchived));
|
||||
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, showArchived]);
|
||||
|
||||
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 updateExamType = (value: string | undefined) => {
|
||||
setExamType(value);
|
||||
setSelectedExamIds([]);
|
||||
};
|
||||
const updateClassId = (value: number | undefined) => {
|
||||
setClassId(value);
|
||||
setSelectedExamIds([]);
|
||||
};
|
||||
|
||||
const classOptions = useMemo(
|
||||
() => classes.map((item) => ({ value: item.id, label: item.name })),
|
||||
@@ -74,13 +161,11 @@ const ExamsPage: React.FC = () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
const payload = { ...values, examDate: values.examDate.format('YYYY-MM-DD') };
|
||||
await api.post('/exams', payload);
|
||||
await saveMutation.mutateAsync(payload);
|
||||
message.success('考试已创建');
|
||||
setModalOpen(false);
|
||||
await loadExams();
|
||||
} catch (error) {
|
||||
if ((error as { errorFields?: unknown[] }).errorFields) return;
|
||||
message.error((error as { message?: string })?.message || '保存失败');
|
||||
} catch {
|
||||
// 校验错误静默,接口错误由 useApiMutation 统一提示
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -88,11 +173,44 @@ const ExamsPage: React.FC = () => {
|
||||
|
||||
const changeArchiveStatus = async (exam: ExamItem, archive: boolean) => {
|
||||
try {
|
||||
await api.put(`/exams/${exam.id}/${archive ? 'archive' : 'restore'}`);
|
||||
await archiveMutation.mutateAsync({ id: exam.id, archive });
|
||||
message.success(archive ? '考试已归档' : '考试已恢复');
|
||||
await loadExams();
|
||||
} catch (error) {
|
||||
message.error((error as { message?: string })?.message || '操作失败');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurge = (exam: ExamItem) => {
|
||||
modal.confirm({
|
||||
title: `永久删除考试「${exam.examName}」?`,
|
||||
content: '删除后不可恢复,该考试及其成绩记录将被物理删除。确定继续?',
|
||||
okText: '永久删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await purgeMutation.mutateAsync(exam.id);
|
||||
message.success('已永久删除(不可恢复)');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const batchPurge = async () => {
|
||||
if (selectedExamIds.length === 0 || batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchPurgeMutation.mutateAsync(selectedExamIds);
|
||||
message.success(
|
||||
`已永久删除 ${result.deleted} 场考试${result.skipped ? `,跳过 ${result.skipped} 场` : ''}`,
|
||||
);
|
||||
setSelectedExamIds([]);
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,7 +218,12 @@ const ExamsPage: React.FC = () => {
|
||||
const partiallySelected = selectedExamIds.length > 0 && !allCurrentSelected;
|
||||
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
setSelectedExamIds(selectAllExamIds(data.map((exam) => exam.id), checked));
|
||||
setSelectedExamIds(
|
||||
selectAllExamIds(
|
||||
data.map((exam) => exam.id),
|
||||
checked,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const changeArchiveView = (checked: boolean) => {
|
||||
@@ -113,24 +236,19 @@ const ExamsPage: React.FC = () => {
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
if (archive) {
|
||||
const result = await api.put<{ archived: number; skipped: number }>('/exams/batch-archive', {
|
||||
ids: selectedExamIds,
|
||||
});
|
||||
const result = await batchArchiveMutation.mutateAsync(selectedExamIds);
|
||||
message.success(
|
||||
`已归档 ${result.archived} 场考试${result.skipped ? `,跳过 ${result.skipped} 场` : ''}`,
|
||||
);
|
||||
} else {
|
||||
const result = await api.put<{ restored: number; skipped: number }>('/exams/batch-restore', {
|
||||
ids: selectedExamIds,
|
||||
});
|
||||
const result = await batchRestoreMutation.mutateAsync(selectedExamIds);
|
||||
message.success(
|
||||
`已恢复 ${result.restored} 场考试${result.skipped ? `,跳过 ${result.skipped} 场` : ''}`,
|
||||
);
|
||||
}
|
||||
setSelectedExamIds([]);
|
||||
await loadExams();
|
||||
} catch (error) {
|
||||
message.error((error as { message?: string })?.message || '批量操作失败');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
@@ -140,9 +258,31 @@ const ExamsPage: React.FC = () => {
|
||||
<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 }} />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => updateKeyword(event.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索考试名称"
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
value={examType}
|
||||
onChange={updateExamType}
|
||||
options={EXAM_TYPE_OPTIONS}
|
||||
placeholder="考试类型"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Select
|
||||
value={classId}
|
||||
onChange={updateClassId}
|
||||
options={classOptions}
|
||||
placeholder="考试班级"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Checkbox
|
||||
@@ -171,29 +311,55 @@ const ExamsPage: React.FC = () => {
|
||||
{showArchived ? '批量恢复' : '批量归档'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
{showArchived && canPurgeExam ? (
|
||||
<Popconfirm
|
||||
title={`确认永久删除选中的 ${selectedExamIds.length} 场考试?`}
|
||||
description="删除后不可恢复,相关成绩将一并清除。"
|
||||
disabled={selectedExamIds.length === 0 || batchLoading}
|
||||
onConfirm={() => void batchPurge()}
|
||||
okText="永久删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={batchLoading}
|
||||
disabled={selectedExamIds.length === 0}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<span className="exam-archive-toggle">
|
||||
<InboxOutlined />
|
||||
归档
|
||||
<Switch size="small" checked={showArchived} onChange={changeArchiveView} />
|
||||
</span>
|
||||
{!showArchived ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>创建考试</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
创建考试
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{data.length === 0 && !loading ? (
|
||||
<div className="exam-empty"><Empty description="暂无考试" /></div>
|
||||
<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);
|
||||
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${selectedExamIds.includes(exam.id) ? ' exam-card-selected' : ''}`}
|
||||
loading={loading}
|
||||
title={(
|
||||
title={
|
||||
<Space>
|
||||
<Checkbox
|
||||
aria-label={`选择考试 ${exam.examName}`}
|
||||
@@ -208,18 +374,38 @@ const ExamsPage: React.FC = () => {
|
||||
<Tag color="blue">{exam.examType}</Tag>
|
||||
<span>{exam.examName}</span>
|
||||
</Space>
|
||||
)}
|
||||
extra={<Tag color={exam.status === 'archived' ? 'default' : 'green'}>{exam.status === 'archived' ? '已归档' : '成绩录入'}</Tag>}
|
||||
}
|
||||
extra={
|
||||
<Tag color={exam.status === 'archived' ? 'default' : 'green'}>
|
||||
{exam.status === 'archived' ? '已归档' : '成绩录入'}
|
||||
</Tag>
|
||||
}
|
||||
actions={[
|
||||
<span key="detail" onClick={() => navigate(`/exams/${exam.id}`)}>查看成绩</span>,
|
||||
<span key="detail" onClick={() => navigate(`/exams/${exam.id}`)}>
|
||||
查看成绩
|
||||
</span>,
|
||||
exam.status === 'archived' ? (
|
||||
<Popconfirm
|
||||
key="restore"
|
||||
title="确认恢复该考试?"
|
||||
onConfirm={() => changeArchiveStatus(exam, false)}
|
||||
>
|
||||
<span>恢复</span>
|
||||
</Popconfirm>
|
||||
<>
|
||||
<Popconfirm
|
||||
key="restore"
|
||||
title="确认恢复该考试?"
|
||||
onConfirm={() => changeArchiveStatus(exam, false)}
|
||||
>
|
||||
<span>恢复</span>
|
||||
</Popconfirm>
|
||||
{canPurgeExam ? (
|
||||
<Popconfirm
|
||||
key="purge"
|
||||
title="确认永久删除该考试?"
|
||||
description="删除后不可恢复,成绩记录将一并清除。"
|
||||
onConfirm={() => handlePurge(exam)}
|
||||
okText="永久删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<span className="exam-purge-action">删除</span>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Popconfirm
|
||||
key="archive"
|
||||
@@ -232,10 +418,31 @@ const ExamsPage: React.FC = () => {
|
||||
),
|
||||
]}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
@@ -243,7 +450,15 @@ const ExamsPage: React.FC = () => {
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<ExamFormModal open={modalOpen} editing={false} saving={saving} form={form} classes={classes} onCancel={() => setModalOpen(false)} onSubmit={() => void submit()} />
|
||||
<ExamFormModal
|
||||
open={modalOpen}
|
||||
editing={false}
|
||||
saving={saving}
|
||||
form={form}
|
||||
classes={classes}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onSubmit={() => void submit()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exam-purge-action {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.exam-toolbar > .ant-space,
|
||||
.exam-toolbar .ant-input-affix-wrapper,
|
||||
|
||||
Reference in New Issue
Block a user