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(...); }
This commit is contained in:
2026-07-09 18:03:42 +08:00
parent 6029d8e2fd
commit c59fd6ce92
17 changed files with 141 additions and 59 deletions

View File

@@ -13,6 +13,7 @@ import {
Select, Select,
Tooltip, Tooltip,
Spin, Spin,
Empty,
} from 'antd'; } from 'antd';
import { import {
FileTextOutlined, FileTextOutlined,
@@ -54,6 +55,7 @@ const BillsPage: React.FC = () => {
const [generateForm] = Form.useForm(); const [generateForm] = Form.useForm();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -63,8 +65,8 @@ const BillsPage: React.FC = () => {
if (filterExpenseType) params.expenseType = filterExpenseType; if (filterExpenseType) params.expenseType = filterExpenseType;
const res = await api.get('/bills', { params }) as unknown[]; const res = await api.get('/bills', { params }) as unknown[];
setBills(res); setBills(res);
} catch (e) { } catch (e: any) {
console.error(e); message.error(e?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}, [filterStatus, filterExpenseType]); }, [filterStatus, filterExpenseType]);
@@ -110,8 +112,9 @@ const BillsPage: React.FC = () => {
try { try {
const res = await api.get(`/bills/${id}`); const res = await api.get(`/bills/${id}`);
setDetailModal(res); setDetailModal(res);
} catch (e) { } catch (e: any) {
console.error(e); message.error(e?.message || '加载详情失败');
}
} finally { } finally {
setDetailLoading(false); setDetailLoading(false);
} }
@@ -132,6 +135,7 @@ const BillsPage: React.FC = () => {
const batchUpdateStatus = async (status: string) => { const batchUpdateStatus = async (status: string) => {
if (selectedRows.length === 0) return message.warning('请先选择账单'); if (selectedRows.length === 0) return message.warning('请先选择账单');
setBatchLoading(true);
try { try {
await api.put('/bills/batch/status', { ids: selectedRows, status }); await api.put('/bills/batch/status', { ids: selectedRows, status });
message.success(`已批量更新 ${selectedRows.length} 条账单`); message.success(`已批量更新 ${selectedRows.length} 条账单`);
@@ -139,6 +143,8 @@ const BillsPage: React.FC = () => {
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '操作失败'); message.error(e?.message || '操作失败');
} finally {
setBatchLoading(false);
} }
}; };
@@ -151,9 +157,9 @@ const BillsPage: React.FC = () => {
message.error(e?.message || '删除失败'); message.error(e?.message || '删除失败');
} }
}; };
const batchDelete = async () => { const batchDelete = async () => {
if (selectedRows.length === 0) return message.warning('请先选择账单'); if (selectedRows.length === 0) return message.warning('请先选择账单');
setBatchLoading(true);
try { try {
await api.post('/bills/batch/delete', { ids: selectedRows }); await api.post('/bills/batch/delete', { ids: selectedRows });
message.success(`已删除 ${selectedRows.length} 条账单`); message.success(`已删除 ${selectedRows.length} 条账单`);
@@ -161,6 +167,8 @@ const BillsPage: React.FC = () => {
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '操作失败'); message.error(e?.message || '操作失败');
} finally {
setBatchLoading(false);
} }
}; };
@@ -334,6 +342,7 @@ const BillsPage: React.FC = () => {
permission="bill:confirm" permission="bill:confirm"
onClick={() => batchUpdateStatus('confirmed')} onClick={() => batchUpdateStatus('confirmed')}
disabled={selectedRows.length === 0} disabled={selectedRows.length === 0}
loading={batchLoading}
> >
</PermissionButton> </PermissionButton>
@@ -342,6 +351,7 @@ const BillsPage: React.FC = () => {
type="primary" type="primary"
onClick={() => batchUpdateStatus('paid')} onClick={() => batchUpdateStatus('paid')}
disabled={selectedRows.length === 0} disabled={selectedRows.length === 0}
loading={batchLoading}
> >
</PermissionButton> </PermissionButton>
@@ -357,6 +367,7 @@ const BillsPage: React.FC = () => {
danger danger
disabled={selectedRows.length === 0} disabled={selectedRows.length === 0}
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
loading={batchLoading}
> >
</PermissionButton> </PermissionButton>
@@ -389,6 +400,7 @@ const BillsPage: React.FC = () => {
dataSource={filteredBills} dataSource={filteredBills}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={{ rowSelection={{
selectedRowKeys: selectedRows, selectedRowKeys: selectedRows,

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { import {
Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber, Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber,
DatePicker, Popconfirm, message, Card, Switch, DatePicker, Popconfirm, message, Card, Switch, Empty,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons'; import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
@@ -98,8 +98,9 @@ const ClassesPage: React.FC = () => {
params.isArchived = showArchived; params.isArchived = showArchived;
const res = await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>); const res = await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>);
setData(res); setData(res);
} catch (e) { } catch (e: any) {
console.error(e); message.error(e?.message || '加载失败,请稍后重试');
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -266,6 +267,7 @@ const ClassesPage: React.FC = () => {
dataSource={filtered} dataSource={filtered}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20 }} pagination={{ pageSize: 20 }}
scroll={{ x: 1100 }} scroll={{ x: 1100 }}
/> />

View File

@@ -14,6 +14,7 @@ import {
Popconfirm, Popconfirm,
Upload, Upload,
Tooltip, Tooltip,
Empty,
} from 'antd'; } from 'antd';
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons'; import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
@@ -50,8 +51,8 @@ const ClassroomRentalsPage: React.FC = () => {
if (filterMonth) params.month = filterMonth.format('YYYY-MM'); if (filterMonth) params.month = filterMonth.format('YYYY-MM');
const res: any = await api.get('/classroom-rentals', { params }); const res: any = await api.get('/classroom-rentals', { params });
setData(res); setData(res);
} catch (e) { } catch (e: any) {
console.error(e); message.error(e?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}; };
@@ -61,8 +62,8 @@ const ClassroomRentalsPage: React.FC = () => {
const [cr, tn]: any = await Promise.all([api.get('/classrooms'), api.get('/tenants')]); const [cr, tn]: any = await Promise.all([api.get('/classrooms'), api.get('/tenants')]);
setClassrooms(cr); setClassrooms(cr);
setTenants(tn); setTenants(tn);
} catch (e) { } catch (e: any) {
console.error(e); message.error(e?.message || '加载教室列表失败');
} }
}; };
@@ -311,10 +312,10 @@ const ClassroomRentalsPage: React.FC = () => {
dataSource={filteredData} dataSource={filteredData}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
scroll={{ x: 1200 }} scroll={{ x: 1200 }}
/> />
<Modal <Modal
title={editing ? '编辑租赁' : '新增租赁'} title={editing ? '编辑租赁' : '新增租赁'}
open={modalOpen} open={modalOpen}

View File

@@ -12,6 +12,7 @@ import {
Spin, Spin,
Empty, Empty,
Tooltip, Tooltip,
message,
} from 'antd'; } from 'antd';
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons'; import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
@@ -44,8 +45,9 @@ const ClassroomSchedulePage: React.FC = () => {
params: { year: month.year(), month: month.month() + 1 }, params: { year: month.year(), month: month.month() + 1 },
}); });
setData(res); setData(res);
} catch (e) { } catch (e: unknown) {
console.error(e); const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}, [month]); }, [month]);
@@ -85,8 +87,9 @@ const ClassroomSchedulePage: React.FC = () => {
try { try {
const res: any = await api.get(`/classroom-rentals/${rentalId}`); const res: any = await api.get(`/classroom-rentals/${rentalId}`);
setDetailModal(res); setDetailModal(res);
} catch (e) { } catch (e: unknown) {
console.error(e); const err = e as { message?: string };
message.error(err?.message || '加载详情失败');
} }
}; };

View File

@@ -13,6 +13,7 @@ import {
Popconfirm, Popconfirm,
Upload, Upload,
Tooltip, Tooltip,
Empty,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
@@ -69,8 +70,8 @@ const ClassroomsPage: React.FC = () => {
try { try {
const res: any = await api.get('/classrooms', { params: { includeArchived: showArchived } }); const res: any = await api.get('/classrooms', { params: { includeArchived: showArchived } });
setData(res); setData(res);
} catch (e) { } catch (e: any) {
console.error(e); message.error(e?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}; };
@@ -287,9 +288,9 @@ const ClassroomsPage: React.FC = () => {
dataSource={filteredData} dataSource={filteredData}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }} pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
/> />
<Modal <Modal
title={editing ? '编辑教室' : '添加教室'} title={editing ? '编辑教室' : '添加教室'}
open={modalOpen} open={modalOpen}

View File

@@ -83,13 +83,19 @@ const DashboardPage: React.FC = () => {
const [roomRanking, setRoomRanking] = useState<Array<{ roomNumber: string; total: string }>>([]); const [roomRanking, setRoomRanking] = useState<Array<{ roomNumber: string; total: string }>>([]);
const [classroomUtil, setClassroomUtil] = useState<ClassroomUtilStats | null>(null); const [classroomUtil, setClassroomUtil] = useState<ClassroomUtilStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshLoading, setRefreshLoading] = useState(false);
const [period, setPeriod] = useState<[string, string]>([ const [period, setPeriod] = useState<[string, string]>([
dayjs().startOf('month').format('YYYY-MM-DD'), dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().endOf('month').format('YYYY-MM-DD'), dayjs().endOf('month').format('YYYY-MM-DD'),
]); ]);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
const isRefresh = stats !== null;
if (isRefresh) {
setRefreshLoading(true);
} else {
setLoading(true); setLoading(true);
}
try { try {
const [s, rr, cr, g] = await Promise.all([ const [s, rr, cr, g] = await Promise.all([
api.get<DashboardStats>('/dashboard/stats'), api.get<DashboardStats>('/dashboard/stats'),
@@ -114,7 +120,8 @@ const DashboardPage: React.FC = () => {
message.error('数据加载失败,请稍后重试'); message.error('数据加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}, [period]); setRefreshLoading(false);
}, [period, stats]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@@ -321,7 +328,7 @@ const DashboardPage: React.FC = () => {
gap: 12, gap: 12,
}} }}
> >
<h2 style={{ margin: 0 }}></h2> <h2 style={{ margin: 0 }}>{refreshLoading && <Spin size="small" style={{ marginLeft: 12 }} />}</h2>
<RangePicker <RangePicker
aria-label="选择日期范围" aria-label="选择日期范围"
value={[dayjs(period[0]), dayjs(period[1])]} value={[dayjs(period[0]), dayjs(period[1])]}

View File

@@ -14,6 +14,7 @@ import {
Tabs, Tabs,
List, List,
Card, Card,
Empty,
} from 'antd'; } from 'antd';
import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons'; import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -75,8 +76,8 @@ const DepositsPage: React.FC = () => {
const [d, s]: any[] = await Promise.all([api.get('/deposits'), api.get('/students')]); const [d, s]: any[] = await Promise.all([api.get('/deposits'), api.get('/students')]);
setData(d); setData(d);
setStudents(s); setStudents(s);
} catch (e) { } catch (e: any) {
console.error(e); message.error(e?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}; };
@@ -86,8 +87,8 @@ const DepositsPage: React.FC = () => {
try { try {
const res = await api.get<PendingRefund[]>('/deposits/pending-refunds'); const res = await api.get<PendingRefund[]>('/deposits/pending-refunds');
setPendingRefunds(res || []); setPendingRefunds(res || []);
} catch (e) { } catch (e: any) {
console.error(e); message.error(e?.message || '加载待退款列表失败');
} }
setPendingLoading(false); setPendingLoading(false);
}; };
@@ -436,10 +437,10 @@ const DepositsPage: React.FC = () => {
dataSource={filteredData} dataSource={filteredData}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1200 }} scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
/> />
</>
), ),
}, },
{ {

View File

@@ -14,6 +14,7 @@ import {
Tabs, Tabs,
Popconfirm, Popconfirm,
Upload, Upload,
Empty,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
@@ -51,6 +52,7 @@ const ExpensesPage: React.FC = () => {
const [selectedRoomKeys, setSelectedRoomKeys] = useState<number[]>([]); const [selectedRoomKeys, setSelectedRoomKeys] = useState<number[]>([]);
const [selectedPersonalKeys, setSelectedPersonalKeys] = useState<number[]>([]); const [selectedPersonalKeys, setSelectedPersonalKeys] = useState<number[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
// Dynamic expense type options from API // Dynamic expense type options from API
const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]); const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]);
@@ -78,6 +80,7 @@ const ExpensesPage: React.FC = () => {
}, []); }, []);
const handleBatchDeleteRoom = async () => { const handleBatchDeleteRoom = async () => {
setBatchLoading(true);
try { try {
const res: any = await api.post('/expenses/room/batch-delete', { ids: selectedRoomKeys }); const res: any = await api.post('/expenses/room/batch-delete', { ids: selectedRoomKeys });
message.success(res?.message || `已删除 ${selectedRoomKeys.length}`); message.success(res?.message || `已删除 ${selectedRoomKeys.length}`);
@@ -85,10 +88,13 @@ const ExpensesPage: React.FC = () => {
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '批量删除失败'); message.error(e?.message || '批量删除失败');
} finally {
setBatchLoading(false);
} }
}; };
const handleBatchDeletePersonal = async () => { const handleBatchDeletePersonal = async () => {
setBatchLoading(true);
try { try {
const res: any = await api.post('/expenses/personal/batch-delete', { const res: any = await api.post('/expenses/personal/batch-delete', {
ids: selectedPersonalKeys, ids: selectedPersonalKeys,
@@ -98,6 +104,8 @@ const ExpensesPage: React.FC = () => {
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '批量删除失败'); message.error(e?.message || '批量删除失败');
} finally {
setBatchLoading(false);
} }
}; };
@@ -114,8 +122,9 @@ const ExpensesPage: React.FC = () => {
setPersonalExpenses(pe); setPersonalExpenses(pe);
setRooms(rm); setRooms(rm);
setStudents(st); setStudents(st);
} catch (e) { } catch (e: unknown) {
console.error(e); const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}, []); }, []);
@@ -410,6 +419,7 @@ const ExpensesPage: React.FC = () => {
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
disabled={selectedRoomKeys.length === 0} disabled={selectedRoomKeys.length === 0}
loading={batchLoading}
> >
</PermissionButton> </PermissionButton>
@@ -433,6 +443,7 @@ const ExpensesPage: React.FC = () => {
dataSource={filteredRoomExpenses} dataSource={filteredRoomExpenses}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1200 }} scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={{ rowSelection={{
@@ -440,7 +451,6 @@ const ExpensesPage: React.FC = () => {
onChange: (keys) => setSelectedRoomKeys(keys as number[]), onChange: (keys) => setSelectedRoomKeys(keys as number[]),
}} }}
/> />
</>
), ),
}, },
{ {
@@ -532,6 +542,7 @@ const ExpensesPage: React.FC = () => {
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
disabled={selectedPersonalKeys.length === 0} disabled={selectedPersonalKeys.length === 0}
loading={batchLoading}
> >
</PermissionButton> </PermissionButton>
@@ -549,12 +560,12 @@ const ExpensesPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</Space> </Space>
</div>
<Table <Table
columns={personalColumns} columns={personalColumns}
dataSource={filteredPersonalExpenses} dataSource={filteredPersonalExpenses}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1200 }} scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={{ rowSelection={{

View File

@@ -14,8 +14,8 @@ import {
Popconfirm, Popconfirm,
Upload, Upload,
Switch, Switch,
Alert,
Tooltip, Tooltip,
Empty,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
@@ -50,6 +50,7 @@ const OccupanciesPage: React.FC = () => {
const [batchCheckOutModal, setBatchCheckOutModal] = useState(false); const [batchCheckOutModal, setBatchCheckOutModal] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const [checkInForm] = Form.useForm(); const [checkInForm] = Form.useForm();
const [checkOutForm] = Form.useForm(); const [checkOutForm] = Form.useForm();
const [transferForm] = Form.useForm(); const [transferForm] = Form.useForm();
@@ -187,6 +188,7 @@ const OccupanciesPage: React.FC = () => {
const handleBatchCheckOut = async () => { const handleBatchCheckOut = async () => {
const values = await batchCheckOutForm.validateFields(); const values = await batchCheckOutForm.validateFields();
setBatchLoading(true);
try { try {
const res: any = await api.post('/occupancies/batch-check-out', { const res: any = await api.post('/occupancies/batch-check-out', {
ids: selectedRowKeys, ids: selectedRowKeys,
@@ -201,10 +203,13 @@ const OccupanciesPage: React.FC = () => {
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '批量退宿失败'); message.error(e?.message || '批量退宿失败');
} finally {
setBatchLoading(false);
} }
}; };
const handleBatchDelete = async () => { const handleBatchDelete = async () => {
setBatchLoading(true);
try { try {
const res: any = await api.post('/occupancies/batch-delete', { ids: selectedRowKeys }); const res: any = await api.post('/occupancies/batch-delete', { ids: selectedRowKeys });
message.success(res?.message || `已删除 ${selectedRowKeys.length}`); message.success(res?.message || `已删除 ${selectedRowKeys.length}`);
@@ -212,6 +217,8 @@ const OccupanciesPage: React.FC = () => {
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '批量删除失败'); message.error(e?.message || '批量删除失败');
} finally {
setBatchLoading(false);
} }
}; };
@@ -447,6 +454,7 @@ const OccupanciesPage: React.FC = () => {
setBatchCheckOutModal(true); setBatchCheckOutModal(true);
}} }}
style={{ marginLeft: 12 }} style={{ marginLeft: 12 }}
loading={batchLoading}
> >
退宿 退宿
</PermissionButton> </PermissionButton>
@@ -463,6 +471,7 @@ const OccupanciesPage: React.FC = () => {
size="small" size="small"
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
style={{ marginLeft: 12 }} style={{ marginLeft: 12 }}
loading={batchLoading}
> >
</PermissionButton> </PermissionButton>
@@ -482,12 +491,11 @@ const OccupanciesPage: React.FC = () => {
dataSource={filteredData} dataSource={filteredData}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1300 }} scroll={{ x: 1300 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={rowSelection} rowSelection={rowSelection}
/> />
{/* 入住登记弹窗 */}
<Modal <Modal
title="入住登记" title="入住登记"
open={checkInModal} open={checkInModal}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd'; import { Table, Select, DatePicker, Space, Tag, Tooltip, message } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
@@ -40,8 +40,9 @@ const OperationLogsPage: React.FC = () => {
const res: any = await api.get('/operation-logs', { params }); const res: any = await api.get('/operation-logs', { params });
setData(res.data); setData(res.data);
setTotal(res.total); setTotal(res.total);
} catch (e) { } catch (e: unknown) {
console.error(e); const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}, [page, filterModule, dateRange]); }, [page, filterModule, dateRange]);

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Tag, Input, Space, Spin, Empty } from 'antd'; import { Card, Tag, Input, Space, Spin, Empty, message } from 'antd';
import api from '../../api'; import api from '../../api';
interface PermissionItem { interface PermissionItem {
@@ -36,7 +36,10 @@ const PermissionsPage: React.FC = () => {
api api
.get('/rbac/permissions/tree') .get('/rbac/permissions/tree')
.then((res: any) => setPermTree(res)) .then((res: any) => setPermTree(res))
.catch(console.error) .catch((e: unknown) => {
const err = e as { message?: string };
message.error(err?.message || '加载权限失败');
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);

View File

@@ -10,6 +10,7 @@ import {
message, message,
Card, Card,
Checkbox, Checkbox,
Empty,
} from 'antd'; } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import api from '../../api'; import api from '../../api';
@@ -52,8 +53,9 @@ const RolesPage: React.FC = () => {
]); ]);
setData(roles); setData(roles);
setAllPerms(permTree); setAllPerms(permTree);
} catch (e) { } catch (e: unknown) {
console.error(e); const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}, []); }, []);
@@ -224,10 +226,10 @@ const RolesPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</div> </div>
<Table <Table
columns={columns}
dataSource={data} dataSource={data}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 900 }} scroll={{ x: 900 }}
pagination={false} pagination={false}
/> />

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, DatePicker, Alert, Button } from 'antd'; import { Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, DatePicker, Alert, Button, message } from 'antd';
import { HomeOutlined, UserOutlined, CalendarOutlined, BankOutlined, HistoryOutlined, ShopOutlined } from '@ant-design/icons'; import { HomeOutlined, UserOutlined, CalendarOutlined, BankOutlined, HistoryOutlined, ShopOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
@@ -64,8 +64,9 @@ const RoomVisualPage: React.FC = () => {
const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined; const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined;
const res: any = await api.get('/rooms/visual', { params }); const res: any = await api.get('/rooms/visual', { params });
setData(res); setData(res);
} catch (e) { } catch (e: unknown) {
console.error(e); const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}, [isHistorical, asOf]); }, [isHistorical, asOf]);

View File

@@ -15,6 +15,7 @@ import {
Upload, Upload,
Drawer, Drawer,
Tabs, Tabs,
Empty,
} from 'antd'; } from 'antd';
import type { UploadRequestError, UploadRequestOption } from '@rc-component/upload/lib/interface'; import type { UploadRequestError, UploadRequestOption } from '@rc-component/upload/lib/interface';
import { import {
@@ -88,8 +89,10 @@ const RoomsPage: React.FC = () => {
const [lockerForm] = Form.useForm(); const [lockerForm] = Form.useForm();
const [savingBed, setSavingBed] = useState(false); const [savingBed, setSavingBed] = useState(false);
const [savingLocker, setSavingLocker] = useState(false); const [savingLocker, setSavingLocker] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const handleBatchDelete = async () => { const handleBatchDelete = async () => {
setBatchLoading(true);
try { try {
const res: any = await api.post('/rooms/batch-delete', { ids: selectedRowKeys }); const res: any = await api.post('/rooms/batch-delete', { ids: selectedRowKeys });
message.success(res?.message || `已批量归档 ${selectedRowKeys.length}`); message.success(res?.message || `已批量归档 ${selectedRowKeys.length}`);
@@ -97,6 +100,8 @@ const RoomsPage: React.FC = () => {
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '批量归档失败'); message.error(e?.message || '批量归档失败');
} finally {
setBatchLoading(false);
} }
}; };
@@ -109,8 +114,9 @@ const RoomsPage: React.FC = () => {
setArchivedCount(archived.length); setArchivedCount(archived.length);
const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived'); const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived');
setData(filtered); setData(filtered);
} catch (e) { } catch (e: unknown) {
console.error(e); const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}; };
@@ -162,14 +168,20 @@ const RoomsPage: React.FC = () => {
try { try {
const res = await api.get(`/rooms/${roomId}/beds`); const res = await api.get(`/rooms/${roomId}/beds`);
setBeds(res); setBeds(res);
} catch (e) { console.error(e); } } catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载床位失败');
}
}, []); }, []);
const fetchLockers = useCallback(async (roomId: number) => { const fetchLockers = useCallback(async (roomId: number) => {
try { try {
const res = await api.get(`/rooms/${roomId}/lockers`); const res = await api.get(`/rooms/${roomId}/lockers`);
setLockers(res); setLockers(res);
} catch (e) { console.error(e); } } catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载柜子失败');
}
}, []); }, []);
const handleSaveBed = async () => { const handleSaveBed = async () => {
@@ -425,7 +437,13 @@ const RoomsPage: React.FC = () => {
cancelText="取消" cancelText="取消"
disabled={selectedRowKeys.length === 0} disabled={selectedRowKeys.length === 0}
> >
<PermissionButton permission="room:delete" danger icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0}> <PermissionButton
permission="room:delete"
danger
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</PermissionButton> </PermissionButton>
</Popconfirm> </Popconfirm>
@@ -486,6 +504,7 @@ const RoomsPage: React.FC = () => {
rowKey="id" rowKey="id"
scroll={{ x: 1200 }} scroll={{ x: 1200 }}
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }} pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
rowSelection={{ rowSelection={{

View File

@@ -17,6 +17,7 @@ import {
Card, Card,
Drawer, Drawer,
Descriptions, Descriptions,
Empty,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
@@ -72,6 +73,7 @@ const StudentsPage: React.FC = () => {
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [archivedCount, setArchivedCount] = useState(0); const [archivedCount, setArchivedCount] = useState(0);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({}); const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(undefined); const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(undefined);
@@ -111,6 +113,7 @@ const StudentsPage: React.FC = () => {
}; };
const handleBatchDelete = async () => { const handleBatchDelete = async () => {
setBatchLoading(true);
try { try {
const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys }); const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys });
message.success(res?.message || `已批量归档 ${selectedRowKeys.length}`); message.success(res?.message || `已批量归档 ${selectedRowKeys.length}`);
@@ -118,6 +121,8 @@ const StudentsPage: React.FC = () => {
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '批量归档失败'); message.error(e?.message || '批量归档失败');
} finally {
setBatchLoading(false);
} }
}; };
@@ -132,8 +137,9 @@ const StudentsPage: React.FC = () => {
const archived = list.filter((r) => r.status === 'archived'); const archived = list.filter((r) => r.status === 'archived');
setArchivedCount(archived.length); setArchivedCount(archived.length);
setData(showArchived ? list : list.filter((r) => r.status !== 'archived')); setData(showArchived ? list : list.filter((r) => r.status !== 'archived'));
} catch (e) { } catch (e: unknown) {
console.error(e); const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}, [searchName, showArchived, filterStatus, filterTenantId]); }, [searchName, showArchived, filterStatus, filterTenantId]);
@@ -383,6 +389,7 @@ const StudentsPage: React.FC = () => {
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0} disabled={selectedRowKeys.length === 0}
loading={batchLoading}
> >
</PermissionButton> </PermissionButton>
@@ -441,6 +448,7 @@ const StudentsPage: React.FC = () => {
dataSource={data} dataSource={data}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1410 }} scroll={{ x: 1410 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')} rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { Table, Modal, Form, Input, Select, Space, message, Tag, Popconfirm } from 'antd'; import { Table, Modal, Form, Input, Select, Space, message, Tag, Popconfirm, Empty } from 'antd';
import { PlusOutlined, InboxOutlined } from '@ant-design/icons'; import { PlusOutlined, InboxOutlined } from '@ant-design/icons';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
@@ -44,8 +44,9 @@ const TenantsPage: React.FC = () => {
try { try {
const res: any = await api.get('/tenants'); const res: any = await api.get('/tenants');
setData(res); setData(res);
} catch (e) { } catch (e: unknown) {
console.error(e); const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}; };
@@ -195,9 +196,9 @@ const TenantsPage: React.FC = () => {
dataSource={filteredData} dataSource={filteredData}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }} pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
/> />
<Modal <Modal
title={editing ? '编辑租赁方' : '添加租赁方'} title={editing ? '编辑租赁方' : '添加租赁方'}
open={modalOpen} open={modalOpen}

View File

@@ -68,8 +68,9 @@ const UsersPage: React.FC = () => {
]); ]);
setData(users); setData(users);
setRoles(rolesRes); setRoles(rolesRes);
} catch (e) { } catch (e: unknown) {
console.error(e); const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
} }
setLoading(false); setLoading(false);
}, [showArchived]); }, [showArchived]);