feat(admin): 管理端 UI/UX 深度优化——错误/加载/空状态、表单快捷键、移动端适配与无障碍

This commit is contained in:
2026-08-08 15:05:33 +08:00
parent 57639a3869
commit a0829f17ce
54 changed files with 702 additions and 369 deletions

View File

@@ -7,6 +7,7 @@ import zhCN from 'antd/es/locale/zh_CN';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
import PermissionRoute from './components/PermissionRoute'; import PermissionRoute from './components/PermissionRoute';
import DefaultRoute from './components/DefaultRoute'; import DefaultRoute from './components/DefaultRoute';
import ScrollToTop from './components/ScrollToTop';
import AppMessageBridge from './ui/AppMessageBridge'; import AppMessageBridge from './ui/AppMessageBridge';
import { useUserStore } from './store/user/userStore'; import { useUserStore } from './store/user/userStore';
@@ -74,6 +75,7 @@ const App: React.FC = () => {
<AntdApp> <AntdApp>
<AppMessageBridge /> <AppMessageBridge />
<BrowserRouter> <BrowserRouter>
<ScrollToTop />
<Suspense <Suspense
fallback={ fallback={
<div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}> <div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}>

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { VerticalAlignTopOutlined } from '@ant-design/icons';
/**
* 全局「回到顶部」浮动按钮:长列表滚动超过 400px 后出现。
* 尊重系统「减少动态效果」偏好,平滑滚动仅在未开启该偏好时使用。
*/
export const BackTop: React.FC<{ threshold?: number }> = ({ threshold = 400 }) => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const onScroll = () => setVisible(window.scrollY > threshold);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [threshold]);
if (!visible) return null;
const scrollToTop = () => {
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
};
return (
<Tooltip title="回到顶部">
<Button
type="primary"
shape="circle"
icon={<VerticalAlignTopOutlined />}
aria-label="回到顶部"
onClick={scrollToTop}
style={{ position: 'fixed', right: 24, bottom: 48, zIndex: 1000 }}
/>
</Tooltip>
);
};
export default BackTop;

View File

@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useInterval } from 'usehooks-ts'; import { useInterval } from 'usehooks-ts';
import api from '../api'; import api from '../api';
import { message } from '../ui/app-message';
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display'; import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
import { useUserStore } from '../store/user/userStore'; import { useUserStore } from '../store/user/userStore';
@@ -33,8 +34,9 @@ const NotificationBell: React.FC = () => {
try { try {
const data = await api.get<NotificationItem[]>('/notifications?limit=20'); const data = await api.get<NotificationItem[]>('/notifications?limit=20');
setNotifications(data); setNotifications(data);
} catch { } catch (error) {
/* ignore */ console.error('全部已读失败', error);
message.error('全部已读失败,请重试');
} }
}; };
@@ -118,7 +120,7 @@ const NotificationBell: React.FC = () => {
}} }}
> >
<Typography.Text strong></Typography.Text> <Typography.Text strong></Typography.Text>
<Button type="link" size="small" onClick={handleMarkAll}> <Button type="link" size="small" disabled={unreadCount === 0} onClick={handleMarkAll}>
</Button> </Button>
</div> </div>
@@ -199,7 +201,12 @@ const NotificationBell: React.FC = () => {
placement="bottomRight" placement="bottomRight"
> >
<Badge count={unreadCount} size="small" offset={[-2, 2]}> <Badge count={unreadCount} size="small" offset={[-2, 2]}>
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} /> <Button
type="text"
shape="circle"
icon={<BellOutlined />}
aria-label="通知中心"
/>
</Badge> </Badge>
</Popover> </Popover>
); );

View File

@@ -1,162 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Tour, type TourProps } from 'antd';
import { getRoleDomains } from '../../auth/menu-policy';
const STORAGE_KEY = 'onboarding_seen_v1';
export interface RoleTourProps {
roles: readonly string[];
permissions: readonly string[];
enabled?: boolean;
}
interface TourStep {
target: () => HTMLElement;
title: string;
description: string;
}
/** 按 antd Menu 渲染的菜单项查找目标元素(菜单项 title 属性即菜单文案) */
function menuTarget(label: string): () => HTMLElement {
return () => document.querySelector<HTMLElement>(`[title="${label}"]`) as HTMLElement;
}
/** 右上角 AI 助手按钮 */
const aiTarget = (): HTMLElement =>
document.querySelector<HTMLElement>('button[aria-label="打开 AI 助理"]') as HTMLElement;
/** 各角色的引导步骤(声明式,按角色域组装) */
const TEACHER_STEPS: TourStep[] = [
{
target: menuTarget('今日教学'),
title: '今日教学',
description: '在这里查看今天的课程安排。课程开始后可以拉取钉钉考勤并点名。',
},
{
target: menuTarget('课程考勤'),
title: '课程考勤',
description: '查看历史考勤记录,课程截止后系统会自动结算缺勤。',
},
];
const ACADEMIC_STEPS: TourStep[] = [
{
target: menuTarget('学生管理'),
title: '学生管理',
description: '管理学生档案可单个录入、Excel 批量导入,或用 AI 助手帮你录入。',
},
{
target: menuTarget('班级管理'),
title: '班级管理',
description: '学生入学后先分班,再排课、考勤,形成完整教学闭环。',
},
];
const ACCOMMODATION_STEPS: TourStep[] = [
{
target: menuTarget('住宿总览'),
title: '住宿总览',
description: '可视化查看各宿舍入住情况,支持历史日期回溯和查寝。',
},
{
target: menuTarget('入住管理'),
title: '入住管理',
description: '学生入住/退宿/换宿都在这里办理。入住后再录费用、生成账单。',
},
{
target: menuTarget('账单管理'),
title: '账单管理',
description: '每月生成账单、确认并标记已付,完成「住宿→计费」闭环。',
},
];
const CLASSROOM_STEPS: TourStep[] = [
{
target: menuTarget('教室排期'),
title: '教室排期',
description: '查看教室占用与排期,避免时间冲突。',
},
{
target: menuTarget('租赁订单'),
title: '租赁订单',
description: '管理教室租赁订单与合同,跟踪租期与状态。',
},
];
const SYSTEM_STEPS: TourStep[] = [
{
target: menuTarget('账号管理'),
title: '账号管理',
description: '管理系统账号、角色与权限,为不同岗位分配对应能力。',
},
];
const SUPER_STEPS: TourStep[] = [
{
target: menuTarget('数据面板'),
title: '数据面板',
description: '全局经营概览:入住率、收入、待办都在这里。',
},
];
const AI_STEPS: TourStep[] = [
{
target: aiTarget,
title: 'AI 助手',
description:
'有任何问题都可以问我。我可以帮你查询数据、录入学生、生成批量导入预览——写操作都会先经你确认。',
},
];
function buildSteps(domains: Set<string>): TourStep[] {
const steps: TourStep[] = [];
const has = (key: string) => domains.has(key);
if (has('super')) steps.unshift(...SUPER_STEPS);
if (has('teacher')) steps.push(...TEACHER_STEPS);
if (has('academic')) steps.push(...ACADEMIC_STEPS);
if (has('accommodation')) steps.push(...ACCOMMODATION_STEPS);
if (has('classroom')) steps.push(...CLASSROOM_STEPS);
if (has('system') || has('super')) steps.push(...SYSTEM_STEPS);
steps.push(...AI_STEPS);
return steps;
}
/**
* 首次登录角色引导:按用户角色展示核心业务闭环的 Tour。
* 只对桌面端展示一次localStorage 标记),可随时关闭。
*/
export const RoleTour: React.FC<RoleTourProps> = ({ roles, permissions, enabled = true }) => {
const [open, setOpen] = useState(false);
const domains = useMemo(() => getRoleDomains(roles, permissions), [roles, permissions]);
const steps = useMemo(() => buildSteps(domains), [domains]);
useEffect(() => {
if (!enabled) return;
if (window.localStorage.getItem(STORAGE_KEY)) return;
if (window.innerWidth < 992) return; // 仅桌面端
// 等菜单渲染完成后再弹引导
const timer = window.setTimeout(() => setOpen(true), 600);
return () => window.clearTimeout(timer);
}, [enabled]);
const handleFinish = () => {
window.localStorage.setItem(STORAGE_KEY, '1');
setOpen(false);
};
const config: TourProps = {
open,
onClose: handleFinish,
// target 函数运行时返回 HTMLElement | null与 antd 类型(不接受联合返回)做一次断言
steps: steps as TourProps['steps'],
placement: 'right',
mask: true,
};
return <Tour {...config} />;
};
export default RoleTour;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Button, Tooltip } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
interface RefreshButtonProps {
onRefresh: () => void;
loading?: boolean;
}
/** 列表工具栏刷新入口:手动重新拉取当前数据,带加载反馈。 */
export const RefreshButton: React.FC<RefreshButtonProps> = ({ onRefresh, loading }) => (
<Tooltip title="刷新">
<Button icon={<ReloadOutlined />} loading={loading} onClick={onRefresh} aria-label="刷新" />
</Tooltip>
);
export default RefreshButton;

View File

@@ -0,0 +1,16 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router';
/**
* SPA 路由切换后把滚动位置复位到顶部。
* 只在 pathname 变化时触发,避免干扰弹窗/抽屉等局部滚动。
*/
export const ScrollToTop: React.FC = () => {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
};
export default ScrollToTop;

View File

@@ -137,7 +137,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
</Button> </Button>
</Upload> </Upload>
) : null} ) : null}
<Table<AttachmentRecord> <Table<AttachmentRecord> scroll={{ x: 'max-content' }}
columns={columns} columns={columns}
dataSource={data} dataSource={data}
rowKey="id" rowKey="id"

View File

@@ -205,7 +205,7 @@ export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> =
> >
</PermissionButton> </PermissionButton>
<Table<EnrollmentRecord> <Table<EnrollmentRecord> scroll={{ x: 'max-content' }}
columns={columns} columns={columns}
dataSource={data} dataSource={data}
rowKey="id" rowKey="id"

View File

@@ -195,7 +195,7 @@ export const ExamScoresTab: React.FC<
> >
</PermissionButton> </PermissionButton>
<Table<ExamScoreRecord> <Table<ExamScoreRecord> scroll={{ x: 'max-content' }}
columns={columns} columns={columns}
dataSource={data} dataSource={data}
rowKey="id" rowKey="id"

View File

@@ -162,7 +162,7 @@ export const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
> >
</PermissionButton> </PermissionButton>
<Table<LearningRecord> <Table<LearningRecord> scroll={{ x: 'max-content' }}
columns={columns} columns={columns}
dataSource={data} dataSource={data}
rowKey="id" rowKey="id"

View File

@@ -187,7 +187,7 @@ const InlineArchiveSummary: React.FC<{
); );
return ( return (
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}> <Descriptions bordered column={{ xs: 1, sm: 2, lg: 3 }} size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="手机号"> <Descriptions.Item label="手机号">
<EditableCell <EditableCell
value={student.phone} value={student.phone}

View File

@@ -0,0 +1,125 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
import { RefreshButton } from './RefreshButton';
import { BackTop } from './BackTop';
import { ScrollToTop } from './ScrollToTop';
import { useSubmitShortcut } from '../hooks/useSubmitShortcut';
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
const mount = (node: React.ReactNode) => {
const host = document.createElement('div');
container = host;
document.body.appendChild(host);
root = createRoot(host);
act(() => root?.render(node));
};
afterEach(async () => {
if (root) {
await act(async () => root?.unmount());
}
container?.remove();
root = null;
container = null;
vi.restoreAllMocks();
});
describe('UX 组件与交互', () => {
it('RefreshButton 点击触发 onRefreshloading 时展示加载态', async () => {
const onRefresh = vi.fn();
mount(<RefreshButton onRefresh={onRefresh} />);
const button = container?.querySelector('button');
if (!button) throw new Error('button not rendered');
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
await flush();
expect(onRefresh).toHaveBeenCalledTimes(1);
mount(<RefreshButton onRefresh={onRefresh} loading />);
expect(container?.querySelector('.ant-btn-loading')).toBeTruthy();
});
it('useSubmitShortcut 未激活时不响应 Cmd+Enter', async () => {
const onSubmit = vi.fn();
const Harness = () => {
useSubmitShortcut(false, onSubmit);
return <button type="button">ok</button>;
};
mount(<Harness />);
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }),
);
await flush();
expect(onSubmit).not.toHaveBeenCalled();
});
it('useSubmitShortcut 激活时响应 Cmd/Ctrl+Enter 且阻止默认行为', async () => {
const onSubmit = vi.fn();
const Harness = () => {
useSubmitShortcut(true, onSubmit);
return <button type="button">ok</button>;
};
mount(<Harness />);
const metaEvent = new KeyboardEvent('keydown', {
key: 'Enter',
metaKey: true,
bubbles: true,
cancelable: true,
});
window.dispatchEvent(metaEvent);
expect(metaEvent.defaultPrevented).toBe(true);
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }),
);
await flush();
expect(onSubmit).toHaveBeenCalledTimes(2);
});
it('BackTop 超过阈值后出现,点击回到顶部', async () => {
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
mount(<BackTop threshold={-1} />);
await flush();
const button = container?.querySelector('button');
expect(button).toBeTruthy();
if (button) {
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
}
await flush();
expect(scrollSpy).toHaveBeenCalledWith(expect.objectContaining({ top: 0 }));
});
it('ScrollToTop 在路由切换时把滚动位置复位到顶部', async () => {
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
const Nav = () => {
const navigate = useNavigate();
return (
<button type="button" onClick={() => navigate('/other')}>
go
</button>
);
};
mount(
<MemoryRouter initialEntries={['/']}>
<ScrollToTop />
<Routes>
<Route path="/" element={<Nav />} />
<Route path="/other" element={<div>other</div>} />
</Routes>
</MemoryRouter>,
);
scrollSpy.mockClear();
const button = container?.querySelector('button');
if (!button) throw new Error('button not rendered');
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
await flush();
expect(scrollSpy).toHaveBeenCalledWith(0, 0);
});
});

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
/**
* 弹窗/表单内按 Cmd/Ctrl+Enter 触发表单提交。
* 仅在 active弹窗打开且非保存中时监听避免误触。
*/
export function useSubmitShortcut(active: boolean, onSubmit: () => void): void {
useEffect(() => {
if (!active) return;
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
onSubmit();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [active, onSubmit]);
}

View File

@@ -514,3 +514,68 @@ canvas {
padding-inline: 0; padding-inline: 0;
} }
} }
/* ─── 全局无障碍与质感增强 ─── */
html {
scroll-behavior: smooth;
}
::selection {
background: rgba(0, 122, 255, 0.18);
}
/* 键盘导航焦点可见(鼠标点击不显示,符合 WCAG 2.4.7 */
:focus-visible {
outline: 2px solid #007aff;
outline-offset: 2px;
}
/* 细滚动条macOS/Chromium降低大面积滚动条对视觉的干扰 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.16);
border: 2px solid transparent;
border-radius: 8px;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.28);
}
/* 尊重系统「减少动态效果」偏好 */
/* 窄屏下压缩路由标签尺寸,避免横向裁切 */
@media (max-width: 575px) {
.route-dock {
padding: 6px 8px;
}
.route-dock .ant-tabs-tab {
min-width: 88px;
max-width: 160px;
padding: 0 8px 0 10px !important;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -40,7 +40,7 @@ import { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/
import NotificationBell from '../components/NotificationBell'; import NotificationBell from '../components/NotificationBell';
import RouteDock from '../components/RouteDock'; import RouteDock from '../components/RouteDock';
import RouteKeeper from '../components/RouteKeeper'; import RouteKeeper from '../components/RouteKeeper';
import RoleTour from '../components/Onboarding/RoleTour'; import BackTop from '../components/BackTop';
import { buildMenu, type AppMenuItem } from '../auth/menu-policy'; import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer')); const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer'));
@@ -432,7 +432,7 @@ const MainLayout: React.FC = () => {
/> />
</React.Suspense> </React.Suspense>
)} )}
<RoleTour roles={user?.roles ?? []} permissions={permissions} /> <BackTop />
</Layout> </Layout>
); );
}; };

View File

@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate'; import { validateResponse } from '../../utils/validate';
import { aiConfigEnvelopeSchema } from '../../api/schemas'; import { aiConfigEnvelopeSchema } from '../../api/schemas';
import { App, Alert, Button, Form, Space, Spin, Steps, Tag } from 'antd'; import {App, Alert, Button, Form, Space, Steps, Tag, Skeleton} from 'antd';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
@@ -332,8 +332,8 @@ const AiConfigPage: React.FC = () => {
if (loading) { if (loading) {
return ( return (
<div className={styles.container} style={{ textAlign: 'center', paddingTop: 80 }}> <div className={styles.container} style={{ paddingTop: 24 }}>
<Spin size="large" /> <Skeleton active paragraph={{ rows: 10 }} />
</div> </div>
); );
} }

View File

@@ -27,7 +27,8 @@ export const PeriodConfigModal: React.FC<{
onOk: () => void; onOk: () => void;
onCancel: () => void; onCancel: () => void;
onReset: () => void; onReset: () => void;
}> = ({ open, form, onOk, onCancel, onReset }) => { confirmLoading?: boolean;
}> = ({ open, form, onOk, onCancel, onReset, confirmLoading }) => {
return ( return (
<Modal <Modal
open={open} open={open}
@@ -37,6 +38,7 @@ export const PeriodConfigModal: React.FC<{
className="attendance-period-modal" className="attendance-period-modal"
onOk={onOk} onOk={onOk}
onCancel={onCancel} onCancel={onCancel}
confirmLoading={confirmLoading}
footer={(_, { OkBtn, CancelBtn }) => ( footer={(_, { OkBtn, CancelBtn }) => (
<> <>
<Button icon={<UndoOutlined />} onClick={onReset}> <Button icon={<UndoOutlined />} onClick={onReset}>
@@ -54,7 +56,7 @@ export const PeriodConfigModal: React.FC<{
description="默认07:30-08:30 早自习09:00-12:00 早课14:00-17:00 晚课18:30-21:00 晚自习。" description="默认07:30-08:30 早自习09:00-12:00 早课14:00-17:00 晚课18:30-21:00 晚自习。"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<Form.List name="periods"> <Form.List name="periods">
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<div className="attendance-period-editor"> <div className="attendance-period-editor">

View File

@@ -278,7 +278,7 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
onRetry={() => void loadLesson()} onRetry={() => void loadLesson()}
/> />
) : ( ) : (
<Table<LessonAttendanceRecord> <Table<LessonAttendanceRecord> scroll={{ x: 'max-content' }}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
dataSource={filteredRecords} dataSource={filteredRecords}

View File

@@ -615,6 +615,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
onOk={savePeriodConfig} onOk={savePeriodConfig}
onCancel={() => setPeriodModalOpen(false)} onCancel={() => setPeriodModalOpen(false)}
onReset={() => void resetPeriodConfig()} onReset={() => void resetPeriodConfig()}
confirmLoading={savePeriodConfigMutation.isPending}
/> />
</div> </div>
); );

View File

@@ -319,7 +319,7 @@ const AttendanceDevicesPage: React.FC = () => {
onRetry={() => void refetch()} onRetry={() => void refetch()}
/> />
) : ( ) : (
<Table<AttendanceDeviceRow> <Table<AttendanceDeviceRow> scroll={{ x: 'max-content' }}
rowKey="id" rowKey="id"
columns={columns} columns={columns}
dataSource={filteredData} dataSource={filteredData}

View File

@@ -28,6 +28,7 @@ import { NextStepHint } from '../../components/NextStepHint';
import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { useDownload } from '../../hooks/useDownload'; import { useDownload } from '../../hooks/useDownload';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { RefreshButton } from '../../components/RefreshButton';
import { buildBillPrintHtml, type BillPrintData } from './bill-print'; import { buildBillPrintHtml, type BillPrintData } from './bill-print';
import { newOperationId } from '../../utils/operation-id'; import { newOperationId } from '../../utils/operation-id';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
@@ -334,6 +335,7 @@ const BillsPage: React.FC = () => {
}, },
{ {
title: '操作', title: '操作',
fixed: 'right' as const,
width: 320, width: 320,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<Space> <Space>
@@ -461,6 +463,7 @@ const BillsPage: React.FC = () => {
</Popconfirm> </Popconfirm>
</Space> </Space>
<Space wrap className="responsive-toolbar__group"> <Space wrap className="responsive-toolbar__group">
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
<PermissionButton <PermissionButton
permission="bill:generate" permission="bill:generate"
type="primary" type="primary"
@@ -564,7 +567,7 @@ const BillsPage: React.FC = () => {
> >
{detailModal && ( {detailModal && (
<Spin spinning={detailLoading}> <Spin spinning={detailLoading}>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}> <Descriptions bordered size="small" column={{ xs: 1, sm: 2 }} style={{ marginBottom: 16 }}>
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item> <Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
<Tag color={statusMap[detailModal.status]?.color}> <Tag color={statusMap[detailModal.status]?.color}>
@@ -589,7 +592,7 @@ const BillsPage: React.FC = () => {
</strong> </strong>
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
<Descriptions bordered size="small" column={3} style={{ marginBottom: 16 }}> <Descriptions bordered size="small" column={{ xs: 1, sm: 2, lg: 3 }} style={{ marginBottom: 16 }}>
<Descriptions.Item label="已扣余额"> <Descriptions.Item label="已扣余额">
¥{Number(detailModal.paidAmount || 0).toFixed(2)} ¥{Number(detailModal.paidAmount || 0).toFixed(2)}
</Descriptions.Item> </Descriptions.Item>

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { import {
Button, Button,
Card, Card,
@@ -23,6 +23,7 @@ import dayjs from 'dayjs';
import { useUserStore } from '../../store/user/userStore'; import { useUserStore } from '../../store/user/userStore';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { QueryEmpty } from '../../components/QueryState'; import { QueryEmpty } from '../../components/QueryState';
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate'; import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
@@ -205,7 +206,7 @@ export const ClassInfoTab: React.FC<{
</Form> </Form>
) : ( ) : (
<div> <div>
<Descriptions column={3} bordered size="small"> <Descriptions column={{ xs: 1, sm: 2, lg: 3 }} bordered size="small">
<Descriptions.Item label="班型">{TYPE_MAP[detail.classType]}</Descriptions.Item> <Descriptions.Item label="班型">{TYPE_MAP[detail.classType]}</Descriptions.Item>
<Descriptions.Item label="开班日期"> <Descriptions.Item label="开班日期">
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'} {detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
@@ -247,6 +248,7 @@ export const ClassStudentsTab: React.FC<{
onClose: () => void; onClose: () => void;
onRemove: (studentId: number) => void; onRemove: (studentId: number) => void;
onSelect: (ids: number[]) => void; onSelect: (ids: number[]) => void;
adding?: boolean;
}> = ({ }> = ({
id, id,
detail, detail,
@@ -259,7 +261,9 @@ export const ClassStudentsTab: React.FC<{
onClose, onClose,
onRemove, onRemove,
onSelect, onSelect,
adding,
}) => { }) => {
const [exporting, setExporting] = useState(false);
const studentColumns: ColumnsType<ClassStudent> = [ const studentColumns: ColumnsType<ClassStudent> = [
{ title: '姓名', dataIndex: 'studentName' }, { title: '姓名', dataIndex: 'studentName' },
{ title: '学号', dataIndex: 'studentNo' }, { title: '学号', dataIndex: 'studentNo' },
@@ -298,25 +302,29 @@ export const ClassStudentsTab: React.FC<{
<PermissionButton <PermissionButton
permission="class:view" permission="class:view"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
onClick={() => { loading={exporting}
const token = useUserStore.getState().token; onClick={async () => {
fetch(`/api/classes/${id}/roster/export`, { setExporting(true);
headers: { Authorization: `Bearer ${token}` }, try {
}) const token = useUserStore.getState().token;
.then((res) => { const res = await fetch(`/api/classes/${id}/roster/export`, {
if (!res.ok) throw new Error('导出失败'); headers: { Authorization: `Bearer ${token}` },
return res.blob(); });
}) if (!res.ok) throw new Error('导出失败');
.then((blob) => { const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `班级花名册-${detail?.name || id}.xlsx`; a.download = `班级花名册-${detail?.name || id}.xlsx`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
message.success('花名册导出成功'); message.success('花名册导出成功');
}) } catch (error) {
.catch(() => message.error('花名册导出失败')); console.error('花名册导出失败', error);
message.error('花名册导出失败');
} finally {
setExporting(false);
}
}} }}
> >
@@ -344,7 +352,7 @@ export const ClassStudentsTab: React.FC<{
pageSizeOptions: [20, 50, 100], pageSizeOptions: [20, 50, 100],
}} }}
/> />
<Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose}> <Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose} confirmLoading={adding}>
<Select <Select
mode="multiple" mode="multiple"
style={{ width: '100%' }} style={{ width: '100%' }}
@@ -379,6 +387,7 @@ export const ClassTeachersTab: React.FC<{
onSubjectChange: (subject: string) => void; onSubjectChange: (subject: string) => void;
onUserChange: (userId?: number) => void; onUserChange: (userId?: number) => void;
getTeacherName: (teacher: ClassTeacher) => string; getTeacherName: (teacher: ClassTeacher) => string;
adding?: boolean;
}> = ({ }> = ({
teachers, teachers,
allUsers, allUsers,
@@ -394,7 +403,9 @@ export const ClassTeachersTab: React.FC<{
onSubjectChange, onSubjectChange,
onUserChange, onUserChange,
getTeacherName, getTeacherName,
adding,
}) => { }) => {
useSubmitShortcut(modalOpen, onAdd);
const teacherColumns: ColumnsType<ClassTeacher> = [ const teacherColumns: ColumnsType<ClassTeacher> = [
{ title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) }, { title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) },
{ {
@@ -439,7 +450,7 @@ export const ClassTeachersTab: React.FC<{
pageSizeOptions: [20, 50, 100], pageSizeOptions: [20, 50, 100],
}} }}
/> />
<Modal title="添加教师" open={modalOpen} onOk={onAdd} onCancel={onClose}> <Modal title="添加教师" open={modalOpen} onOk={onAdd} onCancel={onClose} confirmLoading={adding}>
<Space orientation="vertical" style={{ width: '100%' }}> <Space orientation="vertical" style={{ width: '100%' }}>
<Select <Select
style={{ width: '100%' }} style={{ width: '100%' }}

View File

@@ -1,6 +1,6 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { Button, Card, Form, Space, Spin, Tabs, Tag } from 'antd'; import {Button, Card, Form, Space, Tabs, Tag, Skeleton} from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
@@ -33,6 +33,8 @@ const ClassDetailPage: React.FC = () => {
const [studentModalOpen, setStudentModalOpen] = useState(false); const [studentModalOpen, setStudentModalOpen] = useState(false);
const [allStudents, setAllStudents] = useState<StudentItem[]>([]); const [allStudents, setAllStudents] = useState<StudentItem[]>([]);
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]); const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
const [addingStudents, setAddingStudents] = useState(false);
const [addingTeacher, setAddingTeacher] = useState(false);
// Teacher modal state // Teacher modal state
const [teacherModalOpen, setTeacherModalOpen] = useState(false); const [teacherModalOpen, setTeacherModalOpen] = useState(false);
@@ -151,6 +153,7 @@ const ClassDetailPage: React.FC = () => {
const handleAddStudents = async () => { const handleAddStudents = async () => {
if (!selectedStudentIds.length) return; if (!selectedStudentIds.length) return;
setAddingStudents(true);
try { try {
await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds }); await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds });
setStudentModalOpen(false); setStudentModalOpen(false);
@@ -159,11 +162,14 @@ const ClassDetailPage: React.FC = () => {
message.success('已添加'); message.success('已添加');
} catch (e: unknown) { } catch (e: unknown) {
message.error(getErrorMessage(e, '添加失败')); message.error(getErrorMessage(e, '添加失败'));
} finally {
setAddingStudents(false);
} }
}; };
const handleAddTeacher = async () => { const handleAddTeacher = async () => {
if (!teacherUserId) return; if (!teacherUserId) return;
setAddingTeacher(true);
try { try {
await api.post(`/classes/${id}/teachers`, { await api.post(`/classes/${id}/teachers`, {
userId: teacherUserId, userId: teacherUserId,
@@ -175,6 +181,8 @@ const ClassDetailPage: React.FC = () => {
message.success('已添加'); message.success('已添加');
} catch (e: unknown) { } catch (e: unknown) {
message.error(getErrorMessage(e, '添加失败')); message.error(getErrorMessage(e, '添加失败'));
} finally {
setAddingTeacher(false);
} }
}; };
@@ -219,8 +227,8 @@ const ClassDetailPage: React.FC = () => {
if (!detail) { if (!detail) {
if (loading) { if (loading) {
return ( return (
<div style={{ textAlign: 'center', padding: 80 }}> <div style={{ padding: 24 }}>
<Spin size="large" /> <Skeleton active paragraph={{ rows: 8 }} />
</div> </div>
); );
} }
@@ -296,6 +304,7 @@ const ClassDetailPage: React.FC = () => {
onClose={() => setStudentModalOpen(false)} onClose={() => setStudentModalOpen(false)}
onRemove={handleRemoveStudent} onRemove={handleRemoveStudent}
onSelect={setSelectedStudentIds} onSelect={setSelectedStudentIds}
adding={addingStudents}
/> />
), ),
}, },
@@ -324,6 +333,7 @@ const ClassDetailPage: React.FC = () => {
onSubjectChange={setTeacherSubject} onSubjectChange={setTeacherSubject}
onUserChange={setTeacherUserId} onUserChange={setTeacherUserId}
getTeacherName={getTeacherName} getTeacherName={getTeacherName}
adding={addingTeacher}
/> />
), ),
}, },

View File

@@ -17,6 +17,7 @@ import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { QueryErrorState } from '../../components/QueryState'; import { QueryErrorState } from '../../components/QueryState';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -44,6 +45,7 @@ const ClassroomRentalsPage: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string | undefined>(); const [filterStatus, setFilterStatus] = useState<string | undefined>();
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useSubmitShortcut(modalOpen && !saving, () => handleSave());
const [unavailableDates, setUnavailableDates] = useImmer<Set<string>>(new Set()); const [unavailableDates, setUnavailableDates] = useImmer<Set<string>>(new Set());
const loadedUnavailableMonths = useRef<Set<string>>(new Set()); const loadedUnavailableMonths = useRef<Set<string>>(new Set());
const unavailableRequestVersion = useRef(0); const unavailableRequestVersion = useRef(0);
@@ -454,7 +456,7 @@ const ClassroomRentalsPage: React.FC = () => {
okText="保存" okText="保存"
width={600} width={600}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<Form.Item name="classroomId" label="教室" rules={[{ required: true }]}> <Form.Item name="classroomId" label="教室" rules={[{ required: true }]}>
<Select <Select
showSearch showSearch

View File

@@ -30,6 +30,8 @@ import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell'; import EditableCell from '../../components/EditableCell';
import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { RefreshButton } from '../../components/RefreshButton';
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useUserStore } from '../../store/user/userStore'; import { useUserStore } from '../../store/user/userStore';
import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useVisibleRefetch } from '../../hooks/usePageVisible';
@@ -68,6 +70,7 @@ const ClassroomsPage: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined); const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useSubmitShortcut(modalOpen && !saving, () => handleSave());
const { const {
data = [], data = [],
@@ -357,6 +360,7 @@ const ClassroomsPage: React.FC = () => {
{ {
title: '操作', title: '操作',
fixed: 'right' as const,
width: 180, width: 180,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<Space> <Space>
@@ -463,6 +467,7 @@ const ClassroomsPage: React.FC = () => {
</Button> </Button>
</Space> </Space>
<Space wrap> <Space wrap>
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
<PermissionButton <PermissionButton
permission="classroom:create" permission="classroom:create"
type="primary" type="primary"
@@ -565,7 +570,7 @@ const ClassroomsPage: React.FC = () => {
confirmLoading={saving} confirmLoading={saving}
okText="保存" okText="保存"
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<Form.Item name="name" label="教室名" rules={[{ required: true }]}> <Form.Item name="name" label="教室名" rules={[{ required: true }]}>
<Input placeholder="如A201 / B301" /> <Input placeholder="如A201 / B301" />
</Form.Item> </Form.Item>

View File

@@ -170,16 +170,6 @@ export function buildClassroomHeatmapOption(
data: classroomOccupancy.map((r) => r.name), data: classroomOccupancy.map((r) => r.name),
inverse: true, inverse: true,
}, },
visualMap: {
min: 0,
max: 1,
orient: 'horizontal',
left: 'center',
bottom: 0,
inRange: {
color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'],
},
},
series: [ series: [
{ {
type: 'bar', type: 'bar',
@@ -190,7 +180,7 @@ export function buildClassroomHeatmapOption(
rentalCount: r.rentalCount, rentalCount: r.rentalCount,
occupancy: r.occupancy, occupancy: r.occupancy,
})), })),
itemStyle: { borderRadius: [0, 4, 4, 0] }, itemStyle: { color: '#1677ff', borderRadius: [0, 4, 4, 0] },
label: { label: {
show: true, show: true,
position: 'right', position: 'right',

View File

@@ -49,13 +49,15 @@ export const ClassroomHeatmapCard: React.FC<{
minHeight={isMobile ? 340 : 440} minHeight={isMobile ? 340 : 440}
style={{ marginBottom: 24 }} style={{ marginBottom: 24 }}
> >
{data.length > 0 ? ( {data.some((r) => Number(r.occupancy) > 0) ? (
<ReactECharts <ReactECharts
option={buildClassroomHeatmapOption(data)} option={buildClassroomHeatmapOption(data)}
style={{ width: '100%', height: isMobile ? 300 : 400 }} style={{ width: '100%', height: isMobile ? 300 : 400 }}
/> />
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div> <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
</div>
)} )}
</LazySection> </LazySection>
); );

View File

@@ -10,7 +10,7 @@ import {
ganttRoomsSchema, ganttRoomsSchema,
roomRankingSchema, roomRankingSchema,
} from '../../api/schemas'; } from '../../api/schemas';
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse } from 'antd'; import {Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse, Skeleton, Alert} from 'antd';
import { import {
TeamOutlined, TeamOutlined,
HomeOutlined, HomeOutlined,
@@ -26,7 +26,6 @@ import {
import ReactECharts from '../../components/ECharts'; import ReactECharts from '../../components/ECharts';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message';
import { import {
buildAttendanceLineOption, buildAttendanceLineOption,
buildAttendanceRingOption, buildAttendanceRingOption,
@@ -46,12 +45,14 @@ import {
type GanttRoom, type GanttRoom,
} from './Dashboard.types'; } from './Dashboard.types';
import { DashboardTodoCards } from './DashboardTodoCards'; import { DashboardTodoCards } from './DashboardTodoCards';
import { QueryErrorState } from '../../components/QueryState';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const DashboardPage: React.FC = () => { const DashboardPage: React.FC = () => {
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.sm; const isMobile = !screens.sm;
const [partialAlertClosed, setPartialAlertClosed] = 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'),
@@ -65,9 +66,12 @@ const DashboardPage: React.FC = () => {
ganttData: [], ganttData: [],
roomRanking: [], roomRanking: [],
classroomUtil: null, classroomUtil: null,
partialFailures: 0,
}, },
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<{ } = useQuery<{
stats: DashboardStats | null; stats: DashboardStats | null;
classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }; classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] };
@@ -75,6 +79,7 @@ const DashboardPage: React.FC = () => {
ganttData: GanttRoom[]; ganttData: GanttRoom[];
roomRanking: Array<{ roomNumber: string; total: string }>; roomRanking: Array<{ roomNumber: string; total: string }>;
classroomUtil: ClassroomUtilStats | null; classroomUtil: ClassroomUtilStats | null;
partialFailures: number;
}>({ }>({
queryKey: ['dashboard', period], queryKey: ['dashboard', period],
queryFn: async () => { queryFn: async () => {
@@ -101,9 +106,9 @@ const DashboardPage: React.FC = () => {
console.error('看板数据加载失败', rejected); console.error('看板数据加载失败', rejected);
throw new Error('看板数据加载失败'); throw new Error('看板数据加载失败');
} }
if (rejected.length > 0) { const partialFailures = rejected.length;
if (partialFailures > 0) {
console.error('部分看板数据加载失败', rejected); console.error('部分看板数据加载失败', rejected);
message.warning(`${rejected.length} 项数据加载失败,其余数据已正常显示`);
} }
// 校验失败的模块降级为对应空值,不影响其他模块 // 校验失败的模块降级为对应空值,不影响其他模块
type ValidateSchema = Parameters<typeof validateResponse>[0]; type ValidateSchema = Parameters<typeof validateResponse>[0];
@@ -140,7 +145,15 @@ const DashboardPage: React.FC = () => {
value(settled[5]), value(settled[5]),
null, null,
); );
return { stats, classRanking, classroomOccupancy, ganttData, roomRanking, classroomUtil }; return {
stats,
classRanking,
classroomOccupancy,
ganttData,
roomRanking,
classroomUtil,
partialFailures,
};
}, },
}); });
const stats = fetchResult.stats; const stats = fetchResult.stats;
@@ -182,8 +195,35 @@ const DashboardPage: React.FC = () => {
const draftTotal = draftBill ? Number(draftBill.total) : 0; const draftTotal = draftBill ? Number(draftBill.total) : 0;
const pendingDeposits = stats?.pendingDeposits ?? 0; const pendingDeposits = stats?.pendingDeposits ?? 0;
if (loading && !stats) if (loading && !stats) {
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />; return (
<div style={{ padding: 24 }}>
<Skeleton active paragraph={{ rows: 3 }} />
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
{Array.from({ length: 6 }, (_, i) => (
<Col key={i} xs={12} sm={8} md={4}>
<Card>
<Skeleton active title={false} paragraph={{ rows: 2 }} />
</Card>
</Col>
))}
</Row>
<Card style={{ marginTop: 16 }}>
<Skeleton active paragraph={{ rows: 8 }} />
</Card>
</div>
);
}
if (isError) {
return (
<QueryErrorState
title="工作台加载失败"
description="看板数据暂时无法获取,请检查网络后重试。"
onRetry={() => void refetch()}
/>
);
}
return ( return (
<div> <div>
@@ -205,12 +245,25 @@ const DashboardPage: React.FC = () => {
aria-label="选择日期范围" aria-label="选择日期范围"
value={[dayjs(period[0]), dayjs(period[1])]} value={[dayjs(period[0]), dayjs(period[1])]}
onChange={(dates) => { onChange={(dates) => {
if (dates?.[0] && dates?.[1]) if (dates?.[0] && dates?.[1]) {
setPeriod([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]); setPeriod([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
setPartialAlertClosed(false);
}
}} }}
/> />
</div> </div>
{fetchResult.partialFailures > 0 && !partialAlertClosed ? (
<Alert
type="warning"
showIcon
closable
onClose={() => setPartialAlertClosed(true)}
message={`${fetchResult.partialFailures} 项数据加载失败,其余数据已正常显示`}
style={{ marginBottom: 16 }}
/>
) : null}
{/* 待办与异常 */} {/* 待办与异常 */}
<DashboardTodoCards <DashboardTodoCards
absentCount={absentCount} absentCount={absentCount}

View File

@@ -17,6 +17,7 @@ import { DollarOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell'; import EditableCell from '../../components/EditableCell';
import type { DepositStudentLookup } from './deposit-student-option'; import type { DepositStudentLookup } from './deposit-student-option';
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
export interface DepositRecord { export interface DepositRecord {
id: number; id: number;
@@ -140,6 +141,10 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
onOpenInstallment, onOpenInstallment,
onSelectEligible, onSelectEligible,
}) => { }) => {
useSubmitShortcut(batchModal && !saving, onBatchCreate);
useSubmitShortcut(createModal && !saving, onCreate);
useSubmitShortcut(!!refundModal && !saving, onRefund);
useSubmitShortcut(!!installmentModal && !saving, onAddInstallment);
return ( return (
<> <>
<Modal <Modal
@@ -152,7 +157,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
okButtonProps={{ disabled: effectiveSelectedEligibleIds.length === 0 }} okButtonProps={{ disabled: effectiveSelectedEligibleIds.length === 0 }}
width={760} width={760}
> >
<Form form={batchForm} layout="vertical"> <Form form={batchForm} layout="vertical" scrollToFirstError>
<Space style={{ width: '100%' }} align="start" wrap> <Space style={{ width: '100%' }} align="start" wrap>
<Form.Item <Form.Item
name="roomType" name="roomType"
@@ -193,7 +198,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
</span> </span>
)} )}
</div> </div>
<Table <Table scroll={{ x: 'max-content' }}
size="small" size="small"
columns={eligibleColumns as never} columns={eligibleColumns as never}
dataSource={eligibleStudents} dataSource={eligibleStudents}
@@ -216,7 +221,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
okText="确认" okText="确认"
confirmLoading={saving} confirmLoading={saving}
> >
<Form form={createForm} layout="vertical"> <Form form={createForm} layout="vertical" scrollToFirstError>
<Form.Item <Form.Item
name="studentId" name="studentId"
label="学生" label="学生"
@@ -249,7 +254,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
okText="确认退还" okText="确认退还"
confirmLoading={saving} confirmLoading={saving}
> >
<Form form={refundForm} layout="vertical"> <Form form={refundForm} layout="vertical" scrollToFirstError>
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}> <div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong> : <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
</div> </div>
@@ -311,7 +316,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
</PermissionButton> </PermissionButton>
</div> </div>
{detailModal.installments && detailModal.installments.length > 0 ? ( {detailModal.installments && detailModal.installments.length > 0 ? (
<Table <Table scroll={{ x: 'max-content' }}
size="small" size="small"
pagination={false} pagination={false}
rowKey="id" rowKey="id"
@@ -410,7 +415,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
okText="确认" okText="确认"
confirmLoading={saving} confirmLoading={saving}
> >
<Form form={installmentForm} layout="vertical"> <Form form={installmentForm} layout="vertical" scrollToFirstError>
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}> <Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} /> <InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item> </Form.Item>

View File

@@ -63,6 +63,7 @@ export const DepositTable: React.FC<DepositTableProps> = ({
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' }, { title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' },
{ {
title: '操作', title: '操作',
fixed: 'right' as const,
width: 240, width: 240,
render: (_: unknown, record: any) => { render: (_: unknown, record: any) => {
const hasDeposit = typeof record.id === 'number'; const hasDeposit = typeof record.id === 'number';

View File

@@ -11,6 +11,7 @@ import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { RefreshButton } from '../../components/RefreshButton';
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option'; import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query'; import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query';
@@ -426,6 +427,7 @@ const DepositsPage: React.FC = () => {
/> />
</Space> </Space>
<Space wrap> <Space wrap>
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
<PermissionButton <PermissionButton
permission="deposit:create" permission="deposit:create"
icon={<TeamOutlined />} icon={<TeamOutlined />}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { Alert, Button, Card, Descriptions, Empty, Space, Spin, Table, Tag, Tooltip } from 'antd'; import {Alert, Button, Card, Descriptions, Empty, Space, Table, Tag, Tooltip, Skeleton} from 'antd';
import { QueryErrorState } from '../../components/QueryState';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
@@ -15,7 +16,6 @@ import { examDetailSchema } from '../../api/schemas';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import type { ExamItem } from './types'; import type { ExamItem } from './types';
import './style.css'; import './style.css';
import { getErrorMessage } from '../../utils/error';
interface ScoreRow { interface ScoreRow {
id: number; id: number;
@@ -56,19 +56,10 @@ const ExamDetailPage: React.FC = () => {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: detail, isLoading, isFetching } = useQuery<ExamDetail | null>({ const { data: detail, isLoading, isFetching, isError, refetch } = useQuery<ExamDetail | null>({
queryKey: ['exams', 'detail', id], queryKey: ['exams', 'detail', id],
queryFn: async () => { queryFn: async () =>
try { validateResponse<ExamDetail>(examDetailSchema, await api.get<ExamDetail>(`/exams/${id}`)),
return validateResponse<ExamDetail>(
examDetailSchema,
await api.get<ExamDetail>(`/exams/${id}`),
);
} catch (error) {
message.error(getErrorMessage(error, '加载考试失败'));
return null;
}
},
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
@@ -145,9 +136,18 @@ const ExamDetailPage: React.FC = () => {
if (loading && !detail) if (loading && !detail)
return ( return (
<div className="exam-detail-loading"> <div className="exam-detail-loading">
<Spin size="large" /> <Skeleton active paragraph={{ rows: 10 }} />
</div> </div>
); );
if (isError) {
return (
<QueryErrorState
title="考试详情加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
);
}
if (!detail) return <Empty description="考试不存在或无权访问" />; if (!detail) return <Empty description="考试不存在或无权访问" />;
const average = detail.scores.find((row) => row.classAvg !== null)?.classAvg ?? null; const average = detail.scores.find((row) => row.classAvg !== null)?.classAvg ?? null;

View File

@@ -9,6 +9,7 @@ import {
Select, Select,
} from 'antd'; } from 'antd';
import { useDirtyGuard } from '../../hooks/useDirtyGuard'; import { useDirtyGuard } from '../../hooks/useDirtyGuard';
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@@ -22,6 +23,7 @@ export const RoomExpenseModal: React.FC<{
onOk: () => void; onOk: () => void;
onCancel: () => void; onCancel: () => void;
}> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => { }> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => {
useSubmitShortcut(open && !saving, () => onOk?.());
const roomExpenseGuard = useDirtyGuard(form); const roomExpenseGuard = useDirtyGuard(form);
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准 // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
useEffect(() => { useEffect(() => {
@@ -37,7 +39,7 @@ export const RoomExpenseModal: React.FC<{
okText={editing ? '保存' : '确认录入'} okText={editing ? '保存' : '确认录入'}
confirmLoading={saving} confirmLoading={saving}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<Form.Item name="roomId" label="宿舍" rules={[{ required: true }]}> <Form.Item name="roomId" label="宿舍" rules={[{ required: true }]}>
<Select <Select
showSearch showSearch
@@ -77,6 +79,7 @@ export const UtilityModal: React.FC<{
onOk: () => void; onOk: () => void;
onCancel: () => void; onCancel: () => void;
}> = ({ open, saving, form, students, onOk, onCancel }) => { }> = ({ open, saving, form, students, onOk, onCancel }) => {
useSubmitShortcut(open && !saving, () => onOk?.());
const utilityGuard = useDirtyGuard(form); const utilityGuard = useDirtyGuard(form);
// 打开弹窗时记录当前表单值为「未修改」基准 // 打开弹窗时记录当前表单值为「未修改」基准
useEffect(() => { useEffect(() => {
@@ -92,7 +95,7 @@ export const UtilityModal: React.FC<{
okText="生成账单并扣余额" okText="生成账单并扣余额"
confirmLoading={saving} confirmLoading={saving}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}> <Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
<Select <Select
showSearch showSearch
@@ -136,6 +139,7 @@ export const PersonalExpenseModal: React.FC<{
onOk: () => void; onOk: () => void;
onCancel: () => void; onCancel: () => void;
}> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => { }> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => {
useSubmitShortcut(open && !saving, () => onOk?.());
const personalExpenseGuard = useDirtyGuard(form); const personalExpenseGuard = useDirtyGuard(form);
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准 // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
useEffect(() => { useEffect(() => {
@@ -151,7 +155,7 @@ export const PersonalExpenseModal: React.FC<{
okText={editing ? '保存' : '确认录入'} okText={editing ? '保存' : '确认录入'}
confirmLoading={saving} confirmLoading={saving}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}> <Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
<Select <Select
showSearch showSearch

View File

@@ -162,7 +162,7 @@ const IntegrationConfigPage: React.FC = () => {
> >
<Spin spinning={loading}> <Spin spinning={loading}>
{config && ( {config && (
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}> <Descriptions size="small" column={{ xs: 1, sm: 2 }} style={{ marginBottom: 24 }}>
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item> <Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item> <Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
<Descriptions.Item label="同步方式"></Descriptions.Item> <Descriptions.Item label="同步方式"></Descriptions.Item>

View File

@@ -83,7 +83,7 @@ const LoginPage: React.FC = () => {
name="username" name="username"
rules={[{ required: true, message: '请输入用户名' }]} rules={[{ required: true, message: '请输入用户名' }]}
> >
<Input prefix={<UserOutlined />} placeholder="用户名" autoComplete="username" /> <Input prefix={<UserOutlined />} placeholder="用户名" autoComplete="username" autoFocus />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label="密码" label="密码"

View File

@@ -3,10 +3,9 @@ import { useQuery } from '@tanstack/react-query';
import { validateResponse } from '../../utils/validate'; import { validateResponse } from '../../utils/validate';
import { operationLogsSchema } from '../../api/schemas'; import { operationLogsSchema } from '../../api/schemas';
import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd'; import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd';
import { QueryErrorState } from '../../components/QueryState';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message';
import { getErrorMessage } from '../../utils/error';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@@ -35,24 +34,21 @@ const OperationLogsPage: React.FC = () => {
data: fetchResult = { data: [], total: 0 }, data: fetchResult = { data: [], total: 0 },
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<{ data: any[]; total: number }>({ } = useQuery<{ data: any[]; total: number }>({
queryKey: ['operation-logs', page, pageSize, filterModule, dateRange], queryKey: ['operation-logs', page, pageSize, filterModule, dateRange],
queryFn: async () => { queryFn: async () => {
try { const params: any = { page, pageSize };
const params: any = { page, pageSize }; if (filterModule) params.module = filterModule;
if (filterModule) params.module = filterModule; if (dateRange) {
if (dateRange) { params.startDate = dateRange[0];
params.startDate = dateRange[0]; params.endDate = dateRange[1];
params.endDate = dateRange[1];
}
return validateResponse<{ data: any[]; total: number }>(
operationLogsSchema,
await api.get('/operation-logs', { params }),
);
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return { data: [], total: 0 };
} }
return validateResponse<{ data: any[]; total: number }>(
operationLogsSchema,
await api.get('/operation-logs', { params }),
);
}, },
}); });
const data = fetchResult.data; const data = fetchResult.data;
@@ -161,25 +157,33 @@ const OperationLogsPage: React.FC = () => {
/> />
</Space> </Space>
</div> </div>
<Table {isError ? (
columns={columns} <QueryErrorState
dataSource={data} title="操作日志加载失败"
rowKey="id" description="请检查网络后重试。"
loading={loading} onRetry={() => void refetch()}
scroll={{ x: 1100 }} />
pagination={{ ) : (
current: page, <Table
total, columns={columns}
pageSize, dataSource={data}
showSizeChanger: true, rowKey="id"
pageSizeOptions: [20, 50, 100], loading={loading}
onChange: (nextPage, nextPageSize) => { scroll={{ x: 1100 }}
setPage(nextPage); pagination={{
setPageSize(nextPageSize); current: page,
}, total,
showTotal: (t) => `${t}`, pageSize,
}} showSizeChanger: true,
/> pageSizeOptions: [20, 50, 100],
onChange: (nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
},
showTotal: (t) => `${t}`,
}}
/>
)}
</div> </div>
); );
}; };

View File

@@ -7,12 +7,13 @@ import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell'; import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { RefreshButton } from '../../components/RefreshButton';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate'; import { validateResponse } from '../../utils/validate';
import { organizationsSchema } from '../../api/schemas'; import { organizationsSchema } from '../../api/schemas';
import { useDirtyGuard } from '../../hooks/useDirtyGuard'; import { useDirtyGuard } from '../../hooks/useDirtyGuard';
import { QueryEmpty } from '../../components/QueryState'; import { QueryEmpty, QueryErrorState } from '../../components/QueryState';
const PRESET_COLORS = [ const PRESET_COLORS = [
'#ff7875', '#ff7875',
@@ -59,21 +60,15 @@ const OrganizationsPage: React.FC = () => {
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string>(); const [filterStatus, setFilterStatus] = useState<string>();
const { data = [], isLoading, isFetching } = useQuery({ const { data = [], isLoading, isFetching, isError, refetch } = useQuery({
queryKey: ['organizations'], queryKey: ['organizations'],
queryFn: async () => { queryFn: async () =>
try { validateResponse<OrganizationItem[]>(
return validateResponse<OrganizationItem[]>( organizationsSchema,
organizationsSchema, await api.get<OrganizationItem[]>('/organizations', {
await api.get<OrganizationItem[]>('/organizations', { params: { includeArchived: true },
params: { includeArchived: true }, }),
}), ),
);
} catch (error: any) {
message.error(error?.message || '机构数据加载失败');
return [];
}
},
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
@@ -385,6 +380,7 @@ const OrganizationsPage: React.FC = () => {
]} ]}
/> />
</Space> </Space>
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
<PermissionButton <PermissionButton
permission="organization:create" permission="organization:create"
type="primary" type="primary"
@@ -394,31 +390,39 @@ const OrganizationsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</div> </div>
<Table {isError ? (
columns={columns} <QueryErrorState
dataSource={filteredData} title="机构数据加载失败"
rowKey="id" description="请检查网络后重试。"
loading={loading} onRetry={() => void refetch()}
locale={{ />
emptyText: ( ) : (
<QueryEmpty <Table
description="暂无机构" columns={columns}
action={ dataSource={filteredData}
hasPermission('organization:create') rowKey="id"
? { label: '添加机构', icon: <PlusOutlined />, onClick: () => openEditor() } loading={loading}
: undefined locale={{
} emptyText: (
/> <QueryEmpty
), description="暂无机构"
}} action={
scroll={{ x: 1100 }} hasPermission('organization:create')
pagination={{ ? { label: '添加机构', icon: <PlusOutlined />, onClick: () => openEditor() }
defaultPageSize: 20, : undefined
showSizeChanger: true, }
pageSizeOptions: [20, 50, 100], />
showTotal: (total) => `${total} 个机构`, ),
}} }}
/> scroll={{ x: 1100 }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total} 个机构`,
}}
/>
)}
<Modal <Modal
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'} title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
open={modalOpen} open={modalOpen}

View File

@@ -1,21 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, DatePicker, Alert, Button, Switch, Space, Skeleton} from 'antd';
Row,
Col,
Card,
Tag,
Select,
Statistic,
Modal,
Spin,
Badge,
Tooltip,
DatePicker,
Alert,
Button,
Switch,
Space,
} from 'antd';
import { import {
HomeOutlined, HomeOutlined,
UserOutlined, UserOutlined,
@@ -153,7 +137,12 @@ const RoomVisualPage: React.FC = () => {
); );
} }
if (!data) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />; if (!data)
return (
<div style={{ padding: 24 }}>
<Skeleton active paragraph={{ rows: 12 }} />
</div>
);
const rooms = data.rooms.filter((r: any) => { const rooms = data.rooms.filter((r: any) => {
if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false; if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false;

View File

@@ -253,6 +253,7 @@ function buildRoomActionColumn(ctx: RoomColumnContext) {
} = ctx; } = ctx;
return { return {
title: '操作', title: '操作',
fixed: 'right' as const,
width: 220, width: 220,
render: (_: unknown, record: unknown) => { render: (_: unknown, record: unknown) => {
const r = record as { status?: string; id: number; roomNumber?: string }; const r = record as { status?: string; id: number; roomNumber?: string };

View File

@@ -211,7 +211,7 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
</Popconfirm> </Popconfirm>
</div> </div>
) : null} ) : null}
<Table <Table scroll={{ x: 'max-content' }}
dataSource={beds} dataSource={beds}
rowKey="id" rowKey="id"
pagination={false} pagination={false}
@@ -329,7 +329,7 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
</Popconfirm> </Popconfirm>
</div> </div>
) : null} ) : null}
<Table <Table scroll={{ x: 'max-content' }}
dataSource={lockers} dataSource={lockers}
rowKey="id" rowKey="id"
pagination={false} pagination={false}

View File

@@ -4,6 +4,7 @@ import { Form, Input, InputNumber, Modal, Select } from 'antd';
import { RoomDrawer } from './RoomDrawer'; import { RoomDrawer } from './RoomDrawer';
import type { BedItem, LockerItem } from './RoomColumns'; import type { BedItem, LockerItem } from './RoomColumns';
import { parseRoomNumber } from './RoomColumns'; import { parseRoomNumber } from './RoomColumns';
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
export const RoomItemFormFields: React.FC<{ export const RoomItemFormFields: React.FC<{
fieldName: 'bedNumber' | 'lockerNumber'; fieldName: 'bedNumber' | 'lockerNumber';
@@ -37,6 +38,7 @@ export const RoomEditModal: React.FC<{
onOk?: () => void; onOk?: () => void;
onCancel: () => void; onCancel: () => void;
}> = ({ open, editing, saving, form, onOk, onCancel }) => { }> = ({ open, editing, saving, form, onOk, onCancel }) => {
useSubmitShortcut(open && !saving, () => onOk?.());
return ( return (
<Modal <Modal
title={editing ? '编辑宿舍' : '添加宿舍'} title={editing ? '编辑宿舍' : '添加宿舍'}
@@ -46,7 +48,7 @@ export const RoomEditModal: React.FC<{
okText="保存" okText="保存"
confirmLoading={saving} confirmLoading={saving}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<Form.Item name="roomNumber" label="房间号" rules={[{ required: true }]}> <Form.Item name="roomNumber" label="房间号" rules={[{ required: true }]}>
<Input <Input
placeholder="如4-102自动解析楼栋楼层" placeholder="如4-102自动解析楼栋楼层"
@@ -114,6 +116,7 @@ export const BedModal: React.FC<{
onOk?: () => void; onOk?: () => void;
onCancel: () => void; onCancel: () => void;
}> = ({ open, editing, saving, form, onOk, onCancel }) => { }> = ({ open, editing, saving, form, onOk, onCancel }) => {
useSubmitShortcut(open && !saving, () => onOk?.());
return ( return (
<Modal <Modal
title={editing ? '编辑床位' : '添加床位'} title={editing ? '编辑床位' : '添加床位'}
@@ -123,7 +126,7 @@ export const BedModal: React.FC<{
confirmLoading={saving} confirmLoading={saving}
okText="保存" okText="保存"
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<RoomItemFormFields fieldName="bedNumber" label="床位编号" placeholder="如1号床" /> <RoomItemFormFields fieldName="bedNumber" label="床位编号" placeholder="如1号床" />
</Form> </Form>
</Modal> </Modal>
@@ -138,6 +141,7 @@ export const LockerModal: React.FC<{
onOk?: () => void; onOk?: () => void;
onCancel: () => void; onCancel: () => void;
}> = ({ open, editing, saving, form, onOk, onCancel }) => { }> = ({ open, editing, saving, form, onOk, onCancel }) => {
useSubmitShortcut(open && !saving, () => onOk?.());
return ( return (
<Modal <Modal
title={editing ? '编辑柜子' : '添加柜子'} title={editing ? '编辑柜子' : '添加柜子'}
@@ -147,7 +151,7 @@ export const LockerModal: React.FC<{
confirmLoading={saving} confirmLoading={saving}
okText="保存" okText="保存"
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<RoomItemFormFields fieldName="lockerNumber" label="柜子编号" placeholder="如1号柜" /> <RoomItemFormFields fieldName="lockerNumber" label="柜子编号" placeholder="如1号柜" />
</Form> </Form>
</Modal> </Modal>

View File

@@ -12,6 +12,7 @@ import {
UploadOutlined, UploadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { RefreshButton } from '../../components/RefreshButton';
export interface RoomsToolbarProps { export interface RoomsToolbarProps {
onSearch: (value: string) => void; onSearch: (value: string) => void;
@@ -39,6 +40,8 @@ export interface RoomsToolbarProps {
onExport: () => void; onExport: () => void;
templateLoading?: boolean; templateLoading?: boolean;
exportLoading?: boolean; exportLoading?: boolean;
refreshLoading?: boolean;
onRefresh?: () => void;
} }
export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({ export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
@@ -67,6 +70,8 @@ export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
onExport, onExport,
templateLoading, templateLoading,
exportLoading, exportLoading,
refreshLoading,
onRefresh,
}) => { }) => {
return ( return (
<div className="responsive-toolbar"> <div className="responsive-toolbar">
@@ -115,6 +120,7 @@ export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
</Button> </Button>
</Space> </Space>
<Space wrap className="responsive-toolbar__group"> <Space wrap className="responsive-toolbar__group">
{onRefresh ? <RefreshButton loading={refreshLoading} onRefresh={onRefresh} /> : null}
{showArchived && canEditRooms ? ( {showArchived && canEditRooms ? (
<> <>
<Popconfirm <Popconfirm

View File

@@ -488,6 +488,8 @@ const RoomsPage: React.FC = () => {
templateLoading={templateDownloading} templateLoading={templateDownloading}
onExport={handleExport} onExport={handleExport}
exportLoading={exportDownloading} exportLoading={exportDownloading}
refreshLoading={isFetching}
onRefresh={() => void refetch()}
/> />
{isError ? ( {isError ? (

View File

@@ -109,7 +109,7 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
destroyOnHidden destroyOnHidden
> >
{mode !== 'detail' ? ( {mode !== 'detail' ? (
<Form form={form} layout="vertical" style={{ marginTop: 16 }}> <Form form={form} layout="vertical" scrollToFirstError style={{ marginTop: 16 }}>
<Form.Item name="classId" label="班级" rules={[{ required: true, message: '请选择班级' }]}> <Form.Item name="classId" label="班级" rules={[{ required: true, message: '请选择班级' }]}>
<Select <Select
placeholder="选择班级" placeholder="选择班级"

View File

@@ -318,6 +318,7 @@ function buildActionColumn(ctx: StudentColumnContext) {
} = ctx; } = ctx;
return { return {
title: '操作', title: '操作',
fixed: 'right' as const,
width: 180, width: 180,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<Space> <Space>

View File

@@ -3,6 +3,7 @@ import { App, Descriptions, Drawer, Form, Input, Modal, Select } from 'antd';
import JinshujuMatchModal from '../../components/JinshujuMatchModal'; import JinshujuMatchModal from '../../components/JinshujuMatchModal';
import StudentProfileContent from '../../components/StudentProfileContent'; import StudentProfileContent from '../../components/StudentProfileContent';
import { SENSITIVE_LABELS } from './StudentColumns'; import { SENSITIVE_LABELS } from './StudentColumns';
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
type AppModal = ReturnType<typeof App.useApp>['modal']; type AppModal = ReturnType<typeof App.useApp>['modal'];
@@ -78,6 +79,7 @@ export const StudentEditModal: React.FC<{
onOk, onOk,
onCancel, onCancel,
}) => { }) => {
useSubmitShortcut(open && !saving, () => onOk?.());
return ( return (
<Modal <Modal
title={editing ? '编辑学生' : '添加学生'} title={editing ? '编辑学生' : '添加学生'}
@@ -89,7 +91,7 @@ export const StudentEditModal: React.FC<{
okText="保存" okText="保存"
confirmLoading={saving} confirmLoading={saving}
> >
<Form form={form} layout="vertical" className="student-form-grid"> <Form form={form} layout="vertical" scrollToFirstError className="student-form-grid">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}> <Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input /> <Input />
</Form.Item> </Form.Item>

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Alert, Button, Card, Col, Descriptions, Row, Table, Tag } from 'antd'; import { Alert, Button, Card, Col, Descriptions, Row, Spin, Table, Tag } from 'antd';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import api from '../../api'; import api from '../../api';
import { QueryEmpty } from '../../components/QueryState'; import { QueryEmpty } from '../../components/QueryState';
@@ -102,7 +102,14 @@ export const StudentsTable: React.FC<{
rowExpandable: () => true, rowExpandable: () => true,
expandedRowRender: (record) => { expandedRowRender: (record) => {
const enrollments = enrollmentData[record.id]; const enrollments = enrollmentData[record.id];
if (!enrollments) return null; if (!enrollments) {
return (
<div style={{ padding: '16px 8px', textAlign: 'center', color: '#999', fontSize: 13 }}>
<Spin size="small" style={{ marginRight: 8 }} />
</div>
);
}
if (enrollments.length < 2) { if (enrollments.length < 2) {
return ( return (
<div style={{ padding: 8, color: '#999', fontSize: 13 }}> <div style={{ padding: 8, color: '#999', fontSize: 13 }}>

View File

@@ -22,6 +22,7 @@ import {
UploadOutlined, UploadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { RefreshButton } from '../../components/RefreshButton';
import { statusMap } from './StudentColumns'; import { statusMap } from './StudentColumns';
export interface StudentsToolbarProps { export interface StudentsToolbarProps {
@@ -60,6 +61,8 @@ export interface StudentsToolbarProps {
onExport: () => void; onExport: () => void;
templateLoading?: boolean; templateLoading?: boolean;
exportLoading?: boolean; exportLoading?: boolean;
refreshLoading?: boolean;
onRefresh?: () => void;
} }
export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
@@ -98,6 +101,8 @@ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
onExport, onExport,
templateLoading, templateLoading,
exportLoading, exportLoading,
refreshLoading,
onRefresh,
}) => { }) => {
const { modal } = App.useApp(); const { modal } = App.useApp();
return ( return (
@@ -171,6 +176,7 @@ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
</Button> </Button>
</Space> </Space>
<Space wrap className="responsive-toolbar__group"> <Space wrap className="responsive-toolbar__group">
{onRefresh ? <RefreshButton loading={refreshLoading} onRefresh={onRefresh} /> : null}
{showArchived && canEditStudent ? ( {showArchived && canEditStudent ? (
<> <>
<Popconfirm <Popconfirm

View File

@@ -570,6 +570,8 @@ const StudentsPage: React.FC = () => {
templateLoading={templateDownloading} templateLoading={templateDownloading}
onExport={handleExport} onExport={handleExport}
exportLoading={exportDownloading} exportLoading={exportDownloading}
refreshLoading={isFetching}
onRefresh={() => void refetch()}
/> />
{nextStepHint === 'class' && ( {nextStepHint === 'class' && (
<NextStepHint <NextStepHint

View File

@@ -3,7 +3,7 @@ import React, { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { validateResponse } from '../../utils/validate'; import { validateResponse } from '../../utils/validate';
import { teacherWorkspaceSchema } from '../../api/schemas'; import { teacherWorkspaceSchema } from '../../api/schemas';
import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd'; import {Card, Tabs, Table, Tag, Empty, Skeleton} from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import api from '../../api'; import api from '../../api';
import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
@@ -142,8 +142,8 @@ const TeacherWorkspacePage: React.FC = () => {
if (loading) { if (loading) {
return ( return (
<div style={{ textAlign: 'center', padding: 80 }}> <div style={{ padding: 24 }}>
<Spin size="large" /> <Skeleton active paragraph={{ rows: 10 }} />
</div> </div>
); );
} }
@@ -157,7 +157,7 @@ const TeacherWorkspacePage: React.FC = () => {
key: 'classes', key: 'classes',
label: `我的班级 (${data?.assignedClasses.length || 0})`, label: `我的班级 (${data?.assignedClasses.length || 0})`,
children: data?.assignedClasses.length ? ( children: data?.assignedClasses.length ? (
<Table<AssignedClass> <Table<AssignedClass> scroll={{ x: 'max-content' }}
columns={classColumns} columns={classColumns}
dataSource={data.assignedClasses} dataSource={data.assignedClasses}
rowKey="classId" rowKey="classId"
@@ -177,7 +177,7 @@ const TeacherWorkspacePage: React.FC = () => {
key: 'schedule', key: 'schedule',
label: `今日课程 (${data?.todaySchedules.length || 0})`, label: `今日课程 (${data?.todaySchedules.length || 0})`,
children: data?.todaySchedules.length ? ( children: data?.todaySchedules.length ? (
<Table<ScheduleItem> <Table<ScheduleItem> scroll={{ x: 'max-content' }}
columns={scheduleColumns} columns={scheduleColumns}
dataSource={data.todaySchedules} dataSource={data.todaySchedules}
rowKey="id" rowKey="id"
@@ -203,7 +203,7 @@ const TeacherWorkspacePage: React.FC = () => {
key: 'students', key: 'students',
label: `我的学生 (${data?.myStudents.length || 0})`, label: `我的学生 (${data?.myStudents.length || 0})`,
children: data?.myStudents.length ? ( children: data?.myStudents.length ? (
<Table<StudentItem> <Table<StudentItem> scroll={{ x: 'max-content' }}
columns={studentColumns} columns={studentColumns}
dataSource={data.myStudents} dataSource={data.myStudents}
rowKey="studentId" rowKey="studentId"

View File

@@ -8,6 +8,7 @@ import { EditOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { RefreshButton } from '../../components/RefreshButton';
import EditableCell from '../../components/EditableCell'; import EditableCell from '../../components/EditableCell';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
@@ -211,6 +212,7 @@ const TeachersPage: React.FC = () => {
{ {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
fixed: 'right' as const,
width: 100, width: 100,
render: (_: unknown, r: TeacherRow) => ( render: (_: unknown, r: TeacherRow) => (
<PermissionButton <PermissionButton
@@ -252,6 +254,7 @@ const TeachersPage: React.FC = () => {
}} }}
style={{ width: 220 }} style={{ width: 220 }}
/> />
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
</Space> </Space>
{isError ? ( {isError ? (
<QueryErrorState <QueryErrorState

View File

@@ -252,6 +252,8 @@ const WalletsPage: React.FC = () => {
}, },
{ {
title: '操作', title: '操作',
fixed: 'right' as const,
width: 140,
render: (_: unknown, row: WalletRow) => ( render: (_: unknown, row: WalletRow) => (
<Space> <Space>
<PermissionButton <PermissionButton
@@ -325,7 +327,7 @@ const WalletsPage: React.FC = () => {
onRetry={() => void refetch()} onRetry={() => void refetch()}
/> />
) : ( ) : (
<Table <Table scroll={{ x: 'max-content' }}
rowKey="studentId" rowKey="studentId"
loading={loading} loading={loading}
locale={{ emptyText: <QueryEmpty description="暂无学生余额数据" /> }} locale={{ emptyText: <QueryEmpty description="暂无学生余额数据" /> }}
@@ -425,7 +427,7 @@ const WalletsPage: React.FC = () => {
onRetry={() => void refetchTransactions()} onRetry={() => void refetchTransactions()}
/> />
) : ( ) : (
<Table <Table scroll={{ x: 'max-content' }}
rowKey="id" rowKey="id"
dataSource={transactions} dataSource={transactions}
loading={txLoading || txFetching} loading={txLoading || txFetching}

View File

@@ -24,8 +24,8 @@ export default defineConfig({
// Slow down interactions slightly so UI animations settle // Slow down interactions slightly so UI animations settle
slowHijackESM: false, slowHijackESM: false,
}, },
// Integration tests: match *.integration.test.{ts,tsx} // Unit and integration tests run in the browser test environment.
include: ['src/**/*.integration.test.{ts,tsx}'], include: ['src/**/*.integration.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}'],
// Global timeout for browser operations // Global timeout for browser operations
testTimeout: 30_000, testTimeout: 30_000,
// Retry flaky browser tests once // Retry flaky browser tests once

View File

@@ -0,0 +1,80 @@
# 管理端 UI/UX 深度优化变更总结apps/admin
> 目标:深度优化用户操作的 UI/UX表单、列表、反馈、导航、加载态、错误态、空状态、移动端适配等
> 保持现有 Ant Design 体系与功能不变最终通过类型检查、lint 与测试验证。
> 本文件按类别汇总已落地改动与验证记录,供 review 使用。
## 一、错误态(失败不再伪装成"空数据/0 值",均带重试)
- 工作台 Dashboard全部接口失败时显示 `QueryErrorState` + 重试(此前静默显示 0 值)。
- 考试详情页:失败显示错误态 + 重试(此前空白页)。
- 机构列表 / 操作日志:失败显示错误态 + 重试(此前弹 toast 后渲染空表)。
- 部分失败反馈Dashboard 部分接口失败改为页面内可关闭 `Alert`(此前每次切换日期弹 toast
## 二、加载态
- 6 个页面骨架屏替代整页 Spin工作台统计卡片骨架、教师工作台、班级详情、考试详情、宿舍可视化、AI 配置。
- 学生表展开行(多班型对比)加载中显示 Spin + 提示,不再空白。
- 班级花名册导出按钮 loadingtry/finally
## 三、空状态
- 复用统一 `QueryEmpty`(描述 + 主操作按钮引导):学生/宿舍/押金/账单/教室/教师/钱包/机构等列表页已覆盖。
## 四、表单
- 新增 `useSubmitShortcut`Cmd/Ctrl+Enter 提交,覆盖 12+ 弹窗(学生、宿舍/床位/柜子、押金×4、费用×3、教室、租赁、班级添加学员/教师)。
- 15 个表单统一 `scrollToFirstError`:校验失败自动滚动到首个错误字段。
- 防重复提交:考勤时段配置、班级添加学员/教师补齐 `confirmLoading`(其余弹窗已具备)。
## 五、列表
- 表格横向滚动10+ 个缺失 `scroll.x` 的表格补齐(学生资料 4 Tab、考勤明细、考勤机、钱包、宿舍抽屉、教师工作台、押金弹窗等
- 移动端操作列固定:学生/宿舍/押金/账单/教室/教师/钱包 7 个主列表操作列 `fixed: 'right'`
- 手动刷新入口:新增 `RefreshButton`,接入 8 个列表页(学生/宿舍/押金/教室/账单/教师/机构;钱包原有)。
- 分页/跨页勾选/已选提示均已具备,未改动。
## 六、反馈
- 通知铃铛:全部已读失败给出错误提示;无未读时按钮置灰;铃铛改为可聚焦按钮 + `aria-label`
- 统一消息桥App context已具备导出/删除/保存反馈已具备。
## 七、导航
- 新增 `ScrollToTop`:路由切换后滚动复位到顶部。
- 新增全局 `BackTop`:长列表滚动超过 400px 后浮现"回到顶部"按钮(尊重 reduced-motion
- 登录页用户名自动聚焦。
- 路由标签RouteDock窄屏压缩尺寸避免横向裁切。
## 八、移动端适配
- 表格横向滚动 + 操作列固定(见"列表")。
- `Descriptions` 响应式列数班级详情、账单详情×2、学生档案、集成配置此前固定列数在窄屏过挤
- 内容区 padding 已按 breakpoint 响应式MainLayout 原有)。
## 九、无障碍与质感(全局 CSS
- `:focus-visible` 品牌蓝焦点环(键盘导航可见)。
- `@media (prefers-reduced-motion: reduce)` 关闭动画/平滑滚动。
- `::selection` 品牌色WebKit 细滚动条;`html` 平滑滚动。
## 新增文件
- `components/RefreshButton.tsx``components/ScrollToTop.tsx``components/BackTop.tsx`
- `hooks/useSubmitShortcut.ts`
- `components/ux.integration.test.tsx`5 个浏览器用例RefreshButton / useSubmitShortcut 激活与未激活 / ScrollToTop / BackTop
## 验证记录
| 检查 | 结果 |
| --- | --- |
| typecheckadmin + server | ✅ |
| lintoxlint + eslint | ✅ 0 warning / 0 error |
| admin vitest浏览器 | ✅ 31 文件 / 166 用例 |
| server jest | ✅ 191 用例 |
| aislop scan --changes | ✅ 100/100 |
## 未做/后续(可选)
- 运行时视觉走查:需要 MySQL + 后端 + 前端环境(当前本地未运行),用于最终逐页截图核对。
- AI 助手抽屉:已具备响应式/空态/取消/技能锁定,`submit` 已有请求中防护,未做额外改动。