Files
gongxue-base/apps/admin/src/pages/Classes/index.tsx
wangziqi c59fd6ce92 fix(admin): UX improvements — silent fetch failures, empty states, batch loading guards, dashboard refresh
- Replace console.error-only catches with message.error user-facing notifications
  across Bills, Classes, ClassroomRentals, ClassroomSchedule, Classrooms, Deposits,
  Expenses, OperationLogs, Permissions, Roles, RoomVisual, Rooms, Students,
  Tenants, Users
- Add Empty component via Table locale prop on list pages: Bills, Classes,
  ClassroomRentals, Classrooms, Deposits, Expenses (room+personal), Occupancies,
  Rooms, Students, Tenants, Roles
- Add batchLoading state to batch delete/update operations: Bills (batchDelete,
  batchUpdateStatus), Expenses (batchDeleteRoom, batchDeletePersonal),
  Occupancies (batchCheckOut, batchDelete), Rooms (batchDelete),
  Students (batchDelete)
- Add refreshLoading indicator to Dashboard header when re-fetching data
- Consistent error pattern: catch (e: unknown) { const err = e as { message?: string }; message.error(...); }
2026-07-09 18:03:42 +08:00

317 lines
9.9 KiB
TypeScript

import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber,
DatePicker, Popconfirm, message, 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-dom';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
// ---- Types ----
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;
}
interface ClassQueryParams {
status?: string;
classType?: string;
}
// ---- Constants ----
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: '冲刺营',
};
// ---- Component ----
const ClassesPage: React.FC = () => {
const navigate = useNavigate();
const [data, setData] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(false);
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 [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const handleArchive = async (id: number, archive: boolean) => {
try {
await api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`);
message.success(archive ? '已归档' : '已恢复');
fetchData();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '操作失败');
}
};
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | boolean | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterType) params.classType = filterType;
params.isArchived = showArchived;
const res = await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>);
setData(res);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
} finally {
setLoading(false);
}
}, [filterStatus, filterType, showArchived]);
useEffect(() => { fetchData(); }, [fetchData]);
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();
setModalOpen(true);
};
const handleEdit = (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,
});
setModalOpen(true);
};
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'),
};
if (editing) {
await api.put(`/classes/${editing.id}`, payload);
message.success('更新成功');
} else {
await api.post('/classes', payload);
message.success('创建成功');
}
setModalOpen(false);
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/classes/${id}`);
message.success('已删除');
fetchData();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const columns: ColumnsType<ClassItem> = useMemo(() => [
{
title: '班级名称', dataIndex: 'name', width: 120,
sorter: (a, b) => a.name.localeCompare(b.name),
},
{ title: '编码', dataIndex: 'code', width: 140 },
{
title: '班型', dataIndex: 'classType', width: 100,
render: (v: string) => <Tag>{TYPE_MAP[v] || v}</Tag>,
},
{
title: '开班日期', dataIndex: 'startDate', width: 110,
render: (v: string | null) => v || '-',
},
{
title: '学员', width: 100,
render: (_: unknown, r: ClassItem) => `${r.studentCount || 0}/${r.maxStudents || '-'}`,
},
{
title: '状态', dataIndex: 'status', width: 100,
render: (v: string) => {
const cfg = STATUS_MAP[v] || { color: 'default', text: v };
return <Tag color={cfg.color}>{cfg.text}</Tag>;
},
},
{
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>
) : (
<Popconfirm title="归档后可恢复,确认归档?" onConfirm={() => handleArchive(r.id, true)}>
<PermissionButton permission="class:edit" size="small"></PermissionButton>
</Popconfirm>
)}
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
<PermissionButton permission="class:delete" size="small" danger>
</PermissionButton>
</Popconfirm>
</Space>
),
},
], []);
return (
<Card>
<Space style={{ marginBottom: 16 }} wrap>
<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>
<Table<ClassItem>
columns={columns}
dataSource={filtered}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20 }}
scroll={{ x: 1100 }}
/>
<Modal
title={editing ? '编辑班级' : '创建班级'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => 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;