Files
gongxue-base/apps/admin/src/pages/Classes/index.tsx
wangziqi c10476d203 feat(admin): 弹窗未保存内容保护与空状态引导扩展
- 新增 useDirtyGuard hook:关闭弹窗时若表单被修改,先确认再关闭
- 接入 17 个弹窗:账号/密码/档案、角色、教室、宿舍/床位/柜子、
  考勤机、机构、班级、教师、考试、排课、入住/退宿/换房、费用
  (子组件弹窗用 useEffect 在回填后快照,时序正确)
- 学生列表空状态带「添加学生」引导动作
- 排课创建成功提示「同步钉钉」,考试创建成功提示「去详情录成绩」

aislop scan: 5 引擎 0 issues
2026-08-07 17:56:13 +08:00

504 lines
15 KiB
TypeScript

// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useState, useMemo, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { classesSchema } from '../../api/schemas';
import {
App,
Table,
Button,
Input,
Select,
Space,
Tag,
Modal,
Form,
InputNumber,
DatePicker,
Popconfirm,
Card,
Switch,
Empty,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
interface ClassItem {
id: number;
name: string;
code: string;
classType: string;
startDate: string | null;
endDate: string | null;
status: string;
headTeacherId: number | null;
lifeTeacherId: number | null;
academicTeacherId: number | null;
maxStudents: number;
notes: string | null;
studentCount: number;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
interface ClassFormValues {
name: string;
code: string;
classType: string;
startDate?: dayjs.Dayjs;
endDate?: dayjs.Dayjs;
maxStudents?: number;
status?: string;
notes?: string;
}
const STATUS_MAP: Record<string, { color: string; text: string }> = {
enrolling: { color: 'blue', text: '招生中' },
active: { color: 'green', text: '在读' },
ended: { color: 'default', text: '结课' },
suspended: { color: 'orange', text: '停课' },
};
const TYPE_MAP: Record<string, string> = {
culture: '文化课',
professional: '专业课',
bootcamp: '集训营',
sprint: '冲刺营',
};
const ClassesPage: React.FC = () => {
const { modal } = App.useApp();
const navigate = useNavigate();
const { hasPermission } = usePermission();
const canPurgeClass = hasPermission('class:purge');
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ClassItem | null>(null);
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string>();
const [filterType, setFilterType] = useState<string>();
const [form] = Form.useForm<ClassFormValues>();
const classFormGuard = useDirtyGuard(form);
const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const {
data = [],
isLoading,
isFetching,
isError,
refetch,
} = useQuery<ClassItem[]>({
queryKey: ['classes', filterStatus, filterType, showArchived],
queryFn: async () => {
const params: Record<string, string | boolean | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterType) params.classType = filterType;
params.isArchived = showArchived;
return validateResponse<ClassItem[]>(
classesSchema,
await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>),
);
},
});
const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
useVisibleRefetch(['classes']);
const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
editing ? api.put(`/classes/${editing.id}`, payload) : api.post('/classes', payload),
{ invalidate: [['classes']] },
);
const saveCellMutation = useApiMutation(
async ({ record, field, value }: { record: ClassItem; field: string; value: unknown }) =>
api.put(`/classes/${record.id}`, { [field]: value }),
{ invalidate: [['classes']] },
);
const archiveMutation = useApiMutation(
async ({ id, archive }: { id: number; archive: boolean }) =>
api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`),
{ invalidate: [['classes']] },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/classes/${id}/permanent`),
{ invalidate: [['classes']] },
);
const handleArchive = useCallback(
async (id: number, archive: boolean) => {
try {
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handlePurge = useCallback(
(record: ClassItem) => {
modal.confirm({
title: `永久删除班级「${record.name}」?`,
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const filtered = useMemo(() => {
if (!searchText) return data;
const q = searchText.toLowerCase();
return data.filter(
(c) => c.name?.toLowerCase().includes(q) || c.code?.toLowerCase().includes(q),
);
}, [data, searchText]);
const handleCreate = () => {
setEditing(null);
form.resetFields();
classFormGuard.snapshot();
setModalOpen(true);
};
const handleEdit = useCallback(
(record: ClassItem) => {
setEditing(record);
form.setFieldsValue({
...record,
notes: record.notes ?? undefined,
startDate: record.startDate ? dayjs(record.startDate) : undefined,
endDate: record.endDate ? dayjs(record.endDate) : undefined,
});
classFormGuard.snapshot();
setModalOpen(true);
},
[form, classFormGuard],
);
const handleSubmit = async () => {
setSaving(true);
try {
const values = await form.validateFields();
const payload = {
...values,
startDate: values.startDate?.format('YYYY-MM-DD'),
endDate: values.endDate?.format('YYYY-MM-DD'),
};
await saveMutation.mutateAsync(payload);
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = useCallback(
async (record: ClassItem, field: string, value: unknown) => {
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[saveCellMutation],
);
const columns: ColumnsType<ClassItem> = useMemo(
() => [
{
title: '班级名称',
dataIndex: 'name',
width: 120,
sorter: (a, b) => a.name.localeCompare(b.name),
render: (v: string, r: ClassItem) => (
<EditableCell
value={v}
required
permission="class:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'name', next)}
>
{v}
</EditableCell>
),
},
{
title: '编码',
dataIndex: 'code',
width: 140,
render: (v: string, r: ClassItem) => (
<EditableCell
value={v}
required
permission="class:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'code', next)}
>
{v}
</EditableCell>
),
},
{
title: '班型',
dataIndex: 'classType',
width: 100,
render: (v: string, r: ClassItem) => (
<EditableCell
value={v}
editor="select"
options={Object.entries(TYPE_MAP).map(([value, label]) => ({ value, label }))}
permission="class:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'classType', next)}
>
<Tag>{TYPE_MAP[v] || v}</Tag>
</EditableCell>
),
},
{
title: '开班日期',
dataIndex: 'startDate',
width: 110,
render: (v: string | null, r: ClassItem) => (
<EditableCell
value={v}
editor="date"
permission="class:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'startDate', next)}
>
{v || '-'}
</EditableCell>
),
},
{
title: '学员',
width: 100,
render: (_: unknown, r: ClassItem) => (
<EditableCell
value={r.maxStudents}
editor="number"
min={0}
permission="class:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'maxStudents', next)}
>{`${r.studentCount || 0}/${r.maxStudents || '-'}`}</EditableCell>
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string, r: ClassItem) => (
<EditableCell
value={v}
editor="select"
options={Object.entries(STATUS_MAP).map(([value, item]) => ({
value,
label: item.text,
}))}
permission="class:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'status', next)}
>
{(() => {
const cfg = STATUS_MAP[v] || { color: 'default', text: v };
return <Tag color={cfg.color}>{cfg.text}</Tag>;
})()}
</EditableCell>
),
},
{
title: '操作',
width: 280,
render: (_: unknown, r: ClassItem) => (
<Space>
<Button
size="small"
icon={<TeamOutlined />}
onClick={() => navigate(`/classes/${r.id}`)}
>
</Button>
<PermissionButton permission="class:edit" size="small" onClick={() => handleEdit(r)}>
</PermissionButton>
{r.isArchived ? (
<>
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(r.id, false)}>
<PermissionButton permission="class:edit" size="small">
</PermissionButton>
</Popconfirm>
{canPurgeClass ? (
<Button size="small" danger type="link" onClick={() => handlePurge(r)}>
</Button>
) : null}
</>
) : (
<Popconfirm
title="归档后可恢复,确认归档?"
onConfirm={() => handleArchive(r.id, true)}
>
<PermissionButton permission="class:edit" size="small">
</PermissionButton>
</Popconfirm>
)}
</Space>
),
},
],
[saveCell, canPurgeClass, handlePurge, navigate, handleEdit, handleArchive],
);
return (
<Card>
<Space
style={{ marginBottom: 16 }}
wrap
className="responsive-toolbar responsive-toolbar--single"
>
<Input
placeholder="搜索名称/编码"
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 200 }}
/>
<Select
placeholder="班型"
allowClear
style={{ width: 120 }}
value={filterType}
onChange={setFilterType}
options={Object.entries(TYPE_MAP).map(([k, v]) => ({ value: k, label: v }))}
/>
<Select
placeholder="状态"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={setFilterStatus}
options={Object.entries(STATUS_MAP).map(([k, v]) => ({ value: k, label: v.text }))}
/>
<PermissionButton
permission="class:create"
type="primary"
icon={<PlusOutlined />}
onClick={handleCreate}
>
</PermissionButton>
<span style={{ marginLeft: 8 }}>
<InboxOutlined style={{ marginRight: 4 }} />
<Switch
size="small"
style={{ marginLeft: 4 }}
checked={showArchived}
onChange={setShowArchived}
/>
</span>
</Space>
{isError ? (
<QueryErrorState
title="班级数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<Table<ClassItem>
columns={columns}
dataSource={filtered}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
scroll={{ x: 1100 }}
/>
)}
<Modal
title={editing ? '编辑班级' : '创建班级'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => classFormGuard.confirmClose(() => setModalOpen(false))}
confirmLoading={saving}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
<Select options={Object.entries(TYPE_MAP).map(([k, v]) => ({ value: k, label: v }))} />
</Form.Item>
<Space>
<Form.Item name="startDate" label="开班日期">
<DatePicker />
</Form.Item>
<Form.Item name="endDate" label="结课日期">
<DatePicker />
</Form.Item>
<Form.Item name="maxStudents" label="人数上限">
<InputNumber min={1} />
</Form.Item>
</Space>
<Form.Item name="status" label="状态" initialValue="enrolling">
<Select
options={Object.entries(STATUS_MAP).map(([k, v]) => ({ value: k, label: v.text }))}
/>
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default ClassesPage;