admin: - 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/ 教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等, 有创建权限的页面附带主操作按钮,无权限时纯展示 - 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮: 仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量 server: - 新增 PUT /attendance-records/batch-status 批量改状态接口 (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds, 审计日志记录批量结果;路由声明在 :id 之前避免被捕获) aislop scan: 5 引擎 0 issues
514 lines
15 KiB
TypeScript
514 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,
|
|
} 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, QueryEmpty } 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: (
|
|
<QueryEmpty
|
|
description="暂无班级数据"
|
|
action={
|
|
hasPermission('class:create')
|
|
? { label: '创建班级', icon: <PlusOutlined />, onClick: handleCreate }
|
|
: undefined
|
|
}
|
|
/>
|
|
),
|
|
}}
|
|
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;
|