feat(admin): 用户体验体系化提升与高危缺陷修复
UX 缺陷修复: - 校验失败不再卡死弹窗按钮(Users/Roles/Bills) - 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选 - AI 表单/批量确认不再出现"假成功" - Dashboard 各数据模块独立加载,单接口失败不再整页清零 - 房间可视化加载失败显示错误态而非永久转圈 - 学生编辑表单回填前重置,避免字段残留污染 - 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单 - 金数据匹配关闭前确认,同步中禁止误关 体验提升: - 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试 - 全局 ErrorBoundary + RouteKeeper 逐页兜底 - 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据 - 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡 - 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期, ArtifactErrorBoundary 渲染降级,图表空数据占位 - AI 助手欢迎语与建议话术按角色定制,会话列表空态引导 - 更新 a2ui-contract.md 契约文档说明实现现状
This commit is contained in:
@@ -18,6 +18,8 @@ import {
|
||||
import { archiveViewPolicy, expenseStatusForView } from '../archive-view';
|
||||
import { ExpenseTablePanel } from './ExpenseTablePanel';
|
||||
import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
|
||||
const ExpensesPage: React.FC = () => {
|
||||
const { modal } = App.useApp();
|
||||
@@ -64,6 +66,8 @@ const ExpensesPage: React.FC = () => {
|
||||
data: expenseResult = { rooms: [], personal: [], students: [], roomsList: [] },
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery<{
|
||||
rooms: any[];
|
||||
personal: any[];
|
||||
@@ -72,27 +76,22 @@ const ExpensesPage: React.FC = () => {
|
||||
}>({
|
||||
queryKey: ['expenses', showArchived ? 'archived' : 'active'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const [rooms, personal, students, roomsList] = await Promise.all([
|
||||
api.get('/expenses/room', {
|
||||
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
|
||||
}),
|
||||
api.get('/expenses/personal', {
|
||||
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
|
||||
}),
|
||||
api.get('/expenses/student-lookups'),
|
||||
api.get('/rooms'),
|
||||
]);
|
||||
return {
|
||||
rooms: validateResponse(expenseRecordsSchema, rooms),
|
||||
personal: validateResponse(expenseRecordsSchema, personal),
|
||||
students: validateResponse(expenseStudentLookupsSchema, students),
|
||||
roomsList: validateResponse(expenseRoomsListSchema, roomsList),
|
||||
};
|
||||
} catch {
|
||||
message.error('加载费用数据失败');
|
||||
return { rooms: [], personal: [], students: [], roomsList: [] };
|
||||
}
|
||||
const [rooms, personal, students, roomsList] = await Promise.all([
|
||||
api.get('/expenses/room', {
|
||||
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
|
||||
}),
|
||||
api.get('/expenses/personal', {
|
||||
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
|
||||
}),
|
||||
api.get('/expenses/student-lookups'),
|
||||
api.get('/rooms'),
|
||||
]);
|
||||
return {
|
||||
rooms: validateResponse(expenseRecordsSchema, rooms),
|
||||
personal: validateResponse(expenseRecordsSchema, personal),
|
||||
students: validateResponse(expenseStudentLookupsSchema, students),
|
||||
roomsList: validateResponse(expenseRoomsListSchema, roomsList),
|
||||
};
|
||||
},
|
||||
});
|
||||
const roomExpenses = expenseResult.rooms;
|
||||
@@ -100,6 +99,8 @@ const ExpensesPage: React.FC = () => {
|
||||
const students = expenseResult.students;
|
||||
const rooms = expenseResult.roomsList;
|
||||
const loading = isLoading || isFetching;
|
||||
// RouteKeeper 保活页面切回时刷新费用列表
|
||||
useVisibleRefetch(['expenses']);
|
||||
|
||||
const mutations = {
|
||||
saveRoom: useApiMutation(
|
||||
@@ -456,103 +457,111 @@ const ExpensesPage: React.FC = () => {
|
||||
已归档费用
|
||||
</Button>
|
||||
</Space>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'room',
|
||||
label: '宿舍费用',
|
||||
children: (
|
||||
<ExpenseTablePanel
|
||||
kind="room"
|
||||
searchText={roomSearch}
|
||||
onSearchChange={setRoomSearch}
|
||||
typeFilter={roomTypeFilter}
|
||||
onTypeFilterChange={setRoomTypeFilter}
|
||||
typeOptions={typeOptions}
|
||||
typeMap={typeMap}
|
||||
data={filteredRoomExpenses}
|
||||
loading={loading}
|
||||
selectedKeys={selectedRoomKeys}
|
||||
onSelect={setSelectedRoomKeys}
|
||||
rooms={rooms}
|
||||
students={students}
|
||||
readonly={expenseViewPolicy.readonly}
|
||||
showArchived={showArchived}
|
||||
canPurgeExpense={canPurgeExpense}
|
||||
batchLoading={batchLoading}
|
||||
canImport={hasPermission('expense:create')}
|
||||
onBatchRestore={handleBatchRestoreRoom}
|
||||
onBatchPurge={handleBatchPurgeRoom}
|
||||
onBatchDelete={handleBatchDeleteRoom}
|
||||
onSaveCell={saveRoomCell}
|
||||
onPeriodSave={async (id, periodStart, periodEnd) => {
|
||||
try {
|
||||
await mutations.period.mutateAsync({ id, periodStart, periodEnd });
|
||||
message.success('已保存');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
}}
|
||||
onEdit={openEditRoom}
|
||||
onArchive={(id) => mutations.archiveRoom.mutateAsync(id)}
|
||||
onPurge={handlePurgeRoom}
|
||||
onImport={(formData) => mutations.importUtility.mutateAsync(formData)}
|
||||
onTemplateDownload={() => {
|
||||
void downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(
|
||||
() => message.error('下载失败'),
|
||||
);
|
||||
}}
|
||||
onAddUtility={() => setUtilityModal(true)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'personal',
|
||||
label: '个人附加费',
|
||||
children: (
|
||||
<ExpenseTablePanel
|
||||
kind="personal"
|
||||
searchText={personalSearch}
|
||||
onSearchChange={setPersonalSearch}
|
||||
typeFilter={personalTypeFilter}
|
||||
onTypeFilterChange={setPersonalTypeFilter}
|
||||
typeOptions={personalTypeOptions}
|
||||
typeMap={typeMap}
|
||||
data={filteredPersonalExpenses}
|
||||
loading={loading}
|
||||
selectedKeys={selectedPersonalKeys}
|
||||
onSelect={setSelectedPersonalKeys}
|
||||
rooms={rooms}
|
||||
students={students}
|
||||
readonly={expenseViewPolicy.readonly}
|
||||
showArchived={showArchived}
|
||||
canPurgeExpense={canPurgeExpense}
|
||||
batchLoading={batchLoading}
|
||||
canImport={hasPermission('expense:create')}
|
||||
onBatchRestore={handleBatchRestorePersonal}
|
||||
onBatchPurge={handleBatchPurgePersonal}
|
||||
onBatchDelete={handleBatchDeletePersonal}
|
||||
onSaveCell={savePersonalCell}
|
||||
onPeriodSave={async () => undefined}
|
||||
onEdit={openEditPersonal}
|
||||
onArchive={(id) => mutations.archivePersonal.mutateAsync(id)}
|
||||
onPurge={handlePurgePersonal}
|
||||
onImport={(formData) => mutations.importPersonal.mutateAsync(formData)}
|
||||
onTemplateDownload={() => {
|
||||
void downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch(
|
||||
() => message.error('下载失败'),
|
||||
);
|
||||
}}
|
||||
onExport={() => {
|
||||
downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() =>
|
||||
message.error('导出失败'),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{isError ? (
|
||||
<QueryErrorState
|
||||
title="费用数据加载失败"
|
||||
description="请检查网络后重试。"
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'room',
|
||||
label: '宿舍费用',
|
||||
children: (
|
||||
<ExpenseTablePanel
|
||||
kind="room"
|
||||
searchText={roomSearch}
|
||||
onSearchChange={setRoomSearch}
|
||||
typeFilter={roomTypeFilter}
|
||||
onTypeFilterChange={setRoomTypeFilter}
|
||||
typeOptions={typeOptions}
|
||||
typeMap={typeMap}
|
||||
data={filteredRoomExpenses}
|
||||
loading={loading}
|
||||
selectedKeys={selectedRoomKeys}
|
||||
onSelect={setSelectedRoomKeys}
|
||||
rooms={rooms}
|
||||
students={students}
|
||||
readonly={expenseViewPolicy.readonly}
|
||||
showArchived={showArchived}
|
||||
canPurgeExpense={canPurgeExpense}
|
||||
batchLoading={batchLoading}
|
||||
canImport={hasPermission('expense:create')}
|
||||
onBatchRestore={handleBatchRestoreRoom}
|
||||
onBatchPurge={handleBatchPurgeRoom}
|
||||
onBatchDelete={handleBatchDeleteRoom}
|
||||
onSaveCell={saveRoomCell}
|
||||
onPeriodSave={async (id, periodStart, periodEnd) => {
|
||||
try {
|
||||
await mutations.period.mutateAsync({ id, periodStart, periodEnd });
|
||||
message.success('已保存');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
}}
|
||||
onEdit={openEditRoom}
|
||||
onArchive={(id) => mutations.archiveRoom.mutateAsync(id)}
|
||||
onPurge={handlePurgeRoom}
|
||||
onImport={(formData) => mutations.importUtility.mutateAsync(formData)}
|
||||
onTemplateDownload={() => {
|
||||
void downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(
|
||||
() => message.error('下载失败'),
|
||||
);
|
||||
}}
|
||||
onAddUtility={() => setUtilityModal(true)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'personal',
|
||||
label: '个人附加费',
|
||||
children: (
|
||||
<ExpenseTablePanel
|
||||
kind="personal"
|
||||
searchText={personalSearch}
|
||||
onSearchChange={setPersonalSearch}
|
||||
typeFilter={personalTypeFilter}
|
||||
onTypeFilterChange={setPersonalTypeFilter}
|
||||
typeOptions={personalTypeOptions}
|
||||
typeMap={typeMap}
|
||||
data={filteredPersonalExpenses}
|
||||
loading={loading}
|
||||
selectedKeys={selectedPersonalKeys}
|
||||
onSelect={setSelectedPersonalKeys}
|
||||
rooms={rooms}
|
||||
students={students}
|
||||
readonly={expenseViewPolicy.readonly}
|
||||
showArchived={showArchived}
|
||||
canPurgeExpense={canPurgeExpense}
|
||||
batchLoading={batchLoading}
|
||||
canImport={hasPermission('expense:create')}
|
||||
onBatchRestore={handleBatchRestorePersonal}
|
||||
onBatchPurge={handleBatchPurgePersonal}
|
||||
onBatchDelete={handleBatchDeletePersonal}
|
||||
onSaveCell={savePersonalCell}
|
||||
onPeriodSave={async () => undefined}
|
||||
onEdit={openEditPersonal}
|
||||
onArchive={(id) => mutations.archivePersonal.mutateAsync(id)}
|
||||
onPurge={handlePurgePersonal}
|
||||
onImport={(formData) => mutations.importPersonal.mutateAsync(formData)}
|
||||
onTemplateDownload={() => {
|
||||
void downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch(
|
||||
() => message.error('下载失败'),
|
||||
);
|
||||
}}
|
||||
onExport={() => {
|
||||
downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() =>
|
||||
message.error('导出失败'),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoomExpenseModal
|
||||
open={roomModal}
|
||||
|
||||
Reference in New Issue
Block a user