feat(admin): 管理端 UI/UX 深度优化——错误/加载/空状态、表单快捷键、移动端适配与无障碍
This commit is contained in:
@@ -7,6 +7,7 @@ import zhCN from 'antd/es/locale/zh_CN';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import PermissionRoute from './components/PermissionRoute';
|
||||
import DefaultRoute from './components/DefaultRoute';
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
import AppMessageBridge from './ui/AppMessageBridge';
|
||||
import { useUserStore } from './store/user/userStore';
|
||||
|
||||
@@ -74,6 +75,7 @@ const App: React.FC = () => {
|
||||
<AntdApp>
|
||||
<AppMessageBridge />
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}>
|
||||
|
||||
40
apps/admin/src/components/BackTop.tsx
Normal file
40
apps/admin/src/components/BackTop.tsx
Normal 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;
|
||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router';
|
||||
import dayjs from 'dayjs';
|
||||
import { useInterval } from 'usehooks-ts';
|
||||
import api from '../api';
|
||||
import { message } from '../ui/app-message';
|
||||
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
|
||||
@@ -33,8 +34,9 @@ const NotificationBell: React.FC = () => {
|
||||
try {
|
||||
const data = await api.get<NotificationItem[]>('/notifications?limit=20');
|
||||
setNotifications(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} catch (error) {
|
||||
console.error('全部已读失败', error);
|
||||
message.error('全部已读失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,7 +120,7 @@ const NotificationBell: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>通知中心</Typography.Text>
|
||||
<Button type="link" size="small" onClick={handleMarkAll}>
|
||||
<Button type="link" size="small" disabled={unreadCount === 0} onClick={handleMarkAll}>
|
||||
全部已读
|
||||
</Button>
|
||||
</div>
|
||||
@@ -199,7 +201,12 @@ const NotificationBell: React.FC = () => {
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Badge count={unreadCount} size="small" offset={[-2, 2]}>
|
||||
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<BellOutlined />}
|
||||
aria-label="通知中心"
|
||||
/>
|
||||
</Badge>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
17
apps/admin/src/components/RefreshButton.tsx
Normal file
17
apps/admin/src/components/RefreshButton.tsx
Normal 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;
|
||||
16
apps/admin/src/components/ScrollToTop.tsx
Normal file
16
apps/admin/src/components/ScrollToTop.tsx
Normal 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;
|
||||
@@ -137,7 +137,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
||||
</Button>
|
||||
</Upload>
|
||||
) : null}
|
||||
<Table<AttachmentRecord>
|
||||
<Table<AttachmentRecord> scroll={{ x: 'max-content' }}
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
|
||||
@@ -205,7 +205,7 @@ export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> =
|
||||
>
|
||||
添加报读记录
|
||||
</PermissionButton>
|
||||
<Table<EnrollmentRecord>
|
||||
<Table<EnrollmentRecord> scroll={{ x: 'max-content' }}
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
|
||||
@@ -195,7 +195,7 @@ export const ExamScoresTab: React.FC<
|
||||
>
|
||||
添加考试成绩
|
||||
</PermissionButton>
|
||||
<Table<ExamScoreRecord>
|
||||
<Table<ExamScoreRecord> scroll={{ x: 'max-content' }}
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
|
||||
@@ -162,7 +162,7 @@ export const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
|
||||
>
|
||||
添加学情记录
|
||||
</PermissionButton>
|
||||
<Table<LearningRecord>
|
||||
<Table<LearningRecord> scroll={{ x: 'max-content' }}
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
|
||||
@@ -187,7 +187,7 @@ const InlineArchiveSummary: React.FC<{
|
||||
);
|
||||
|
||||
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="手机号">
|
||||
<EditableCell
|
||||
value={student.phone}
|
||||
|
||||
125
apps/admin/src/components/ux.integration.test.tsx
Normal file
125
apps/admin/src/components/ux.integration.test.tsx
Normal 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 点击触发 onRefresh,loading 时展示加载态', 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);
|
||||
});
|
||||
});
|
||||
19
apps/admin/src/hooks/useSubmitShortcut.ts
Normal file
19
apps/admin/src/hooks/useSubmitShortcut.ts
Normal 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]);
|
||||
}
|
||||
@@ -514,3 +514,68 @@ canvas {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/
|
||||
import NotificationBell from '../components/NotificationBell';
|
||||
import RouteDock from '../components/RouteDock';
|
||||
import RouteKeeper from '../components/RouteKeeper';
|
||||
import RoleTour from '../components/Onboarding/RoleTour';
|
||||
import BackTop from '../components/BackTop';
|
||||
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
||||
|
||||
const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer'));
|
||||
@@ -432,7 +432,7 @@ const MainLayout: React.FC = () => {
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
<RoleTour roles={user?.roles ?? []} permissions={permissions} />
|
||||
<BackTop />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
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 { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
@@ -332,8 +332,8 @@ const AiConfigPage: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.container} style={{ textAlign: 'center', paddingTop: 80 }}>
|
||||
<Spin size="large" />
|
||||
<div className={styles.container} style={{ paddingTop: 24 }}>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ export const PeriodConfigModal: React.FC<{
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
onReset: () => void;
|
||||
}> = ({ open, form, onOk, onCancel, onReset }) => {
|
||||
confirmLoading?: boolean;
|
||||
}> = ({ open, form, onOk, onCancel, onReset, confirmLoading }) => {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -37,6 +38,7 @@ export const PeriodConfigModal: React.FC<{
|
||||
className="attendance-period-modal"
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={confirmLoading}
|
||||
footer={(_, { OkBtn, CancelBtn }) => (
|
||||
<>
|
||||
<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 晚自习。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form form={form} layout="vertical" scrollToFirstError>
|
||||
<Form.List name="periods">
|
||||
{(fields, { add, remove }) => (
|
||||
<div className="attendance-period-editor">
|
||||
|
||||
@@ -278,7 +278,7 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
||||
onRetry={() => void loadLesson()}
|
||||
/>
|
||||
) : (
|
||||
<Table<LessonAttendanceRecord>
|
||||
<Table<LessonAttendanceRecord> scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={filteredRecords}
|
||||
|
||||
@@ -615,6 +615,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
||||
onOk={savePeriodConfig}
|
||||
onCancel={() => setPeriodModalOpen(false)}
|
||||
onReset={() => void resetPeriodConfig()}
|
||||
confirmLoading={savePeriodConfigMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -319,7 +319,7 @@ const AttendanceDevicesPage: React.FC = () => {
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<Table<AttendanceDeviceRow>
|
||||
<Table<AttendanceDeviceRow> scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { NextStepHint } from '../../components/NextStepHint';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
import { useDownload } from '../../hooks/useDownload';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { RefreshButton } from '../../components/RefreshButton';
|
||||
import { buildBillPrintHtml, type BillPrintData } from './bill-print';
|
||||
import { newOperationId } from '../../utils/operation-id';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
@@ -334,6 +335,7 @@ const BillsPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
fixed: 'right' as const,
|
||||
width: 320,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
@@ -461,6 +463,7 @@ const BillsPage: React.FC = () => {
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||
<PermissionButton
|
||||
permission="bill:generate"
|
||||
type="primary"
|
||||
@@ -564,7 +567,7 @@ const BillsPage: React.FC = () => {
|
||||
>
|
||||
{detailModal && (
|
||||
<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="状态">
|
||||
<Tag color={statusMap[detailModal.status]?.color}>
|
||||
@@ -589,7 +592,7 @@ const BillsPage: React.FC = () => {
|
||||
</strong>
|
||||
</Descriptions.Item>
|
||||
</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="已扣余额">
|
||||
¥{Number(detailModal.paidAmount || 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -23,6 +23,7 @@ import dayjs from 'dayjs';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { QueryEmpty } from '../../components/QueryState';
|
||||
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
|
||||
|
||||
@@ -205,7 +206,7 @@ export const ClassInfoTab: React.FC<{
|
||||
</Form>
|
||||
) : (
|
||||
<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="开班日期">
|
||||
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
|
||||
@@ -247,6 +248,7 @@ export const ClassStudentsTab: React.FC<{
|
||||
onClose: () => void;
|
||||
onRemove: (studentId: number) => void;
|
||||
onSelect: (ids: number[]) => void;
|
||||
adding?: boolean;
|
||||
}> = ({
|
||||
id,
|
||||
detail,
|
||||
@@ -259,7 +261,9 @@ export const ClassStudentsTab: React.FC<{
|
||||
onClose,
|
||||
onRemove,
|
||||
onSelect,
|
||||
adding,
|
||||
}) => {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const studentColumns: ColumnsType<ClassStudent> = [
|
||||
{ title: '姓名', dataIndex: 'studentName' },
|
||||
{ title: '学号', dataIndex: 'studentNo' },
|
||||
@@ -298,25 +302,29 @@ export const ClassStudentsTab: React.FC<{
|
||||
<PermissionButton
|
||||
permission="class:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const token = useUserStore.getState().token;
|
||||
fetch(`/api/classes/${id}/roster/export`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('导出失败');
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `班级花名册-${detail?.name || id}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('花名册导出成功');
|
||||
})
|
||||
.catch(() => message.error('花名册导出失败'));
|
||||
loading={exporting}
|
||||
onClick={async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const token = useUserStore.getState().token;
|
||||
const res = await fetch(`/api/classes/${id}/roster/export`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('导出失败');
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `班级花名册-${detail?.name || id}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('花名册导出成功');
|
||||
} catch (error) {
|
||||
console.error('花名册导出失败', error);
|
||||
message.error('花名册导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
导出花名册
|
||||
@@ -344,7 +352,7 @@ export const ClassStudentsTab: React.FC<{
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
/>
|
||||
<Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose}>
|
||||
<Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose} confirmLoading={adding}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
@@ -379,6 +387,7 @@ export const ClassTeachersTab: React.FC<{
|
||||
onSubjectChange: (subject: string) => void;
|
||||
onUserChange: (userId?: number) => void;
|
||||
getTeacherName: (teacher: ClassTeacher) => string;
|
||||
adding?: boolean;
|
||||
}> = ({
|
||||
teachers,
|
||||
allUsers,
|
||||
@@ -394,7 +403,9 @@ export const ClassTeachersTab: React.FC<{
|
||||
onSubjectChange,
|
||||
onUserChange,
|
||||
getTeacherName,
|
||||
adding,
|
||||
}) => {
|
||||
useSubmitShortcut(modalOpen, onAdd);
|
||||
const teacherColumns: ColumnsType<ClassTeacher> = [
|
||||
{ title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) },
|
||||
{
|
||||
@@ -439,7 +450,7 @@ export const ClassTeachersTab: React.FC<{
|
||||
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%' }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
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 dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
@@ -33,6 +33,8 @@ const ClassDetailPage: React.FC = () => {
|
||||
const [studentModalOpen, setStudentModalOpen] = useState(false);
|
||||
const [allStudents, setAllStudents] = useState<StudentItem[]>([]);
|
||||
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
||||
const [addingStudents, setAddingStudents] = useState(false);
|
||||
const [addingTeacher, setAddingTeacher] = useState(false);
|
||||
|
||||
// Teacher modal state
|
||||
const [teacherModalOpen, setTeacherModalOpen] = useState(false);
|
||||
@@ -151,6 +153,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
|
||||
const handleAddStudents = async () => {
|
||||
if (!selectedStudentIds.length) return;
|
||||
setAddingStudents(true);
|
||||
try {
|
||||
await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds });
|
||||
setStudentModalOpen(false);
|
||||
@@ -159,11 +162,14 @@ const ClassDetailPage: React.FC = () => {
|
||||
message.success('已添加');
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '添加失败'));
|
||||
} finally {
|
||||
setAddingStudents(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTeacher = async () => {
|
||||
if (!teacherUserId) return;
|
||||
setAddingTeacher(true);
|
||||
try {
|
||||
await api.post(`/classes/${id}/teachers`, {
|
||||
userId: teacherUserId,
|
||||
@@ -175,6 +181,8 @@ const ClassDetailPage: React.FC = () => {
|
||||
message.success('已添加');
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '添加失败'));
|
||||
} finally {
|
||||
setAddingTeacher(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -219,8 +227,8 @@ const ClassDetailPage: React.FC = () => {
|
||||
if (!detail) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ padding: 24 }}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -296,6 +304,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
onClose={() => setStudentModalOpen(false)}
|
||||
onRemove={handleRemoveStudent}
|
||||
onSelect={setSelectedStudentIds}
|
||||
adding={addingStudents}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -324,6 +333,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
onSubjectChange={setTeacherSubject}
|
||||
onUserChange={setTeacherUserId}
|
||||
getTeacherName={getTeacherName}
|
||||
adding={addingTeacher}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import { downloadBlob } from '../../utils/download';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -44,6 +45,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
useSubmitShortcut(modalOpen && !saving, () => handleSave());
|
||||
const [unavailableDates, setUnavailableDates] = useImmer<Set<string>>(new Set());
|
||||
const loadedUnavailableMonths = useRef<Set<string>>(new Set());
|
||||
const unavailableRequestVersion = useRef(0);
|
||||
@@ -454,7 +456,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
okText="保存"
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form form={form} layout="vertical" scrollToFirstError>
|
||||
<Form.Item name="classroomId" label="教室" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
|
||||
@@ -30,6 +30,8 @@ import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { RefreshButton } from '../../components/RefreshButton';
|
||||
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
@@ -68,6 +70,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
useSubmitShortcut(modalOpen && !saving, () => handleSave());
|
||||
|
||||
const {
|
||||
data = [],
|
||||
@@ -357,6 +360,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
|
||||
{
|
||||
title: '操作',
|
||||
fixed: 'right' as const,
|
||||
width: 180,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
@@ -463,6 +467,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||
<PermissionButton
|
||||
permission="classroom:create"
|
||||
type="primary"
|
||||
@@ -565,7 +570,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form form={form} layout="vertical" scrollToFirstError>
|
||||
<Form.Item name="name" label="教室名" rules={[{ required: true }]}>
|
||||
<Input placeholder="如:A201 / B301" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -170,16 +170,6 @@ export function buildClassroomHeatmapOption(
|
||||
data: classroomOccupancy.map((r) => r.name),
|
||||
inverse: true,
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: 1,
|
||||
orient: 'horizontal',
|
||||
left: 'center',
|
||||
bottom: 0,
|
||||
inRange: {
|
||||
color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'],
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
@@ -190,7 +180,7 @@ export function buildClassroomHeatmapOption(
|
||||
rentalCount: r.rentalCount,
|
||||
occupancy: r.occupancy,
|
||||
})),
|
||||
itemStyle: { borderRadius: [0, 4, 4, 0] },
|
||||
itemStyle: { color: '#1677ff', borderRadius: [0, 4, 4, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
|
||||
@@ -49,13 +49,15 @@ export const ClassroomHeatmapCard: React.FC<{
|
||||
minHeight={isMobile ? 340 : 440}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
{data.length > 0 ? (
|
||||
{data.some((r) => Number(r.occupancy) > 0) ? (
|
||||
<ReactECharts
|
||||
option={buildClassroomHeatmapOption(data)}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ganttRoomsSchema,
|
||||
roomRankingSchema,
|
||||
} 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 {
|
||||
TeamOutlined,
|
||||
HomeOutlined,
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
import ReactECharts from '../../components/ECharts';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import {
|
||||
buildAttendanceLineOption,
|
||||
buildAttendanceRingOption,
|
||||
@@ -46,12 +45,14 @@ import {
|
||||
type GanttRoom,
|
||||
} from './Dashboard.types';
|
||||
import { DashboardTodoCards } from './DashboardTodoCards';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.sm;
|
||||
const [partialAlertClosed, setPartialAlertClosed] = useState(false);
|
||||
const [period, setPeriod] = useState<[string, string]>([
|
||||
dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||
dayjs().endOf('month').format('YYYY-MM-DD'),
|
||||
@@ -65,9 +66,12 @@ const DashboardPage: React.FC = () => {
|
||||
ganttData: [],
|
||||
roomRanking: [],
|
||||
classroomUtil: null,
|
||||
partialFailures: 0,
|
||||
},
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery<{
|
||||
stats: DashboardStats | null;
|
||||
classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] };
|
||||
@@ -75,6 +79,7 @@ const DashboardPage: React.FC = () => {
|
||||
ganttData: GanttRoom[];
|
||||
roomRanking: Array<{ roomNumber: string; total: string }>;
|
||||
classroomUtil: ClassroomUtilStats | null;
|
||||
partialFailures: number;
|
||||
}>({
|
||||
queryKey: ['dashboard', period],
|
||||
queryFn: async () => {
|
||||
@@ -101,9 +106,9 @@ const DashboardPage: React.FC = () => {
|
||||
console.error('看板数据加载失败', rejected);
|
||||
throw new Error('看板数据加载失败');
|
||||
}
|
||||
if (rejected.length > 0) {
|
||||
const partialFailures = rejected.length;
|
||||
if (partialFailures > 0) {
|
||||
console.error('部分看板数据加载失败', rejected);
|
||||
message.warning(`有 ${rejected.length} 项数据加载失败,其余数据已正常显示`);
|
||||
}
|
||||
// 校验失败的模块降级为对应空值,不影响其他模块
|
||||
type ValidateSchema = Parameters<typeof validateResponse>[0];
|
||||
@@ -140,7 +145,15 @@ const DashboardPage: React.FC = () => {
|
||||
value(settled[5]),
|
||||
null,
|
||||
);
|
||||
return { stats, classRanking, classroomOccupancy, ganttData, roomRanking, classroomUtil };
|
||||
return {
|
||||
stats,
|
||||
classRanking,
|
||||
classroomOccupancy,
|
||||
ganttData,
|
||||
roomRanking,
|
||||
classroomUtil,
|
||||
partialFailures,
|
||||
};
|
||||
},
|
||||
});
|
||||
const stats = fetchResult.stats;
|
||||
@@ -182,8 +195,35 @@ const DashboardPage: React.FC = () => {
|
||||
const draftTotal = draftBill ? Number(draftBill.total) : 0;
|
||||
const pendingDeposits = stats?.pendingDeposits ?? 0;
|
||||
|
||||
if (loading && !stats)
|
||||
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||
if (loading && !stats) {
|
||||
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 (
|
||||
<div>
|
||||
@@ -205,12 +245,25 @@ const DashboardPage: React.FC = () => {
|
||||
aria-label="选择日期范围"
|
||||
value={[dayjs(period[0]), dayjs(period[1])]}
|
||||
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')]);
|
||||
setPartialAlertClosed(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fetchResult.partialFailures > 0 && !partialAlertClosed ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setPartialAlertClosed(true)}
|
||||
message={`有 ${fetchResult.partialFailures} 项数据加载失败,其余数据已正常显示`}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 待办与异常 */}
|
||||
<DashboardTodoCards
|
||||
absentCount={absentCount}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DollarOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import type { DepositStudentLookup } from './deposit-student-option';
|
||||
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||
|
||||
export interface DepositRecord {
|
||||
id: number;
|
||||
@@ -140,6 +141,10 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
||||
onOpenInstallment,
|
||||
onSelectEligible,
|
||||
}) => {
|
||||
useSubmitShortcut(batchModal && !saving, onBatchCreate);
|
||||
useSubmitShortcut(createModal && !saving, onCreate);
|
||||
useSubmitShortcut(!!refundModal && !saving, onRefund);
|
||||
useSubmitShortcut(!!installmentModal && !saving, onAddInstallment);
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -152,7 +157,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
||||
okButtonProps={{ disabled: effectiveSelectedEligibleIds.length === 0 }}
|
||||
width={760}
|
||||
>
|
||||
<Form form={batchForm} layout="vertical">
|
||||
<Form form={batchForm} layout="vertical" scrollToFirstError>
|
||||
<Space style={{ width: '100%' }} align="start" wrap>
|
||||
<Form.Item
|
||||
name="roomType"
|
||||
@@ -193,7 +198,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Table
|
||||
<Table scroll={{ x: 'max-content' }}
|
||||
size="small"
|
||||
columns={eligibleColumns as never}
|
||||
dataSource={eligibleStudents}
|
||||
@@ -216,7 +221,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
||||
okText="确认"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form form={createForm} layout="vertical" scrollToFirstError>
|
||||
<Form.Item
|
||||
name="studentId"
|
||||
label="学生"
|
||||
@@ -249,7 +254,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
||||
okText="确认退还"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={refundForm} layout="vertical">
|
||||
<Form form={refundForm} layout="vertical" scrollToFirstError>
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
当前可用押金: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
|
||||
</div>
|
||||
@@ -311,7 +316,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
||||
</PermissionButton>
|
||||
</div>
|
||||
{detailModal.installments && detailModal.installments.length > 0 ? (
|
||||
<Table
|
||||
<Table scroll={{ x: 'max-content' }}
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
@@ -410,7 +415,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
||||
okText="确认"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={installmentForm} layout="vertical">
|
||||
<Form form={installmentForm} layout="vertical" scrollToFirstError>
|
||||
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -63,6 +63,7 @@ export const DepositTable: React.FC<DepositTableProps> = ({
|
||||
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' },
|
||||
{
|
||||
title: '操作',
|
||||
fixed: 'right' as const,
|
||||
width: 240,
|
||||
render: (_: unknown, record: any) => {
|
||||
const hasDeposit = typeof record.id === 'number';
|
||||
|
||||
@@ -11,6 +11,7 @@ import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { RefreshButton } from '../../components/RefreshButton';
|
||||
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||
@@ -426,6 +427,7 @@ const DepositsPage: React.FC = () => {
|
||||
/>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||
<PermissionButton
|
||||
permission="deposit:create"
|
||||
icon={<TeamOutlined />}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
@@ -15,7 +16,6 @@ import { examDetailSchema } from '../../api/schemas';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import type { ExamItem } from './types';
|
||||
import './style.css';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
|
||||
interface ScoreRow {
|
||||
id: number;
|
||||
@@ -56,19 +56,10 @@ const ExamDetailPage: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
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],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return validateResponse<ExamDetail>(
|
||||
examDetailSchema,
|
||||
await api.get<ExamDetail>(`/exams/${id}`),
|
||||
);
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '加载考试失败'));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
queryFn: async () =>
|
||||
validateResponse<ExamDetail>(examDetailSchema, await api.get<ExamDetail>(`/exams/${id}`)),
|
||||
});
|
||||
const loading = isLoading || isFetching;
|
||||
|
||||
@@ -145,9 +136,18 @@ const ExamDetailPage: React.FC = () => {
|
||||
if (loading && !detail)
|
||||
return (
|
||||
<div className="exam-detail-loading">
|
||||
<Spin size="large" />
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</div>
|
||||
);
|
||||
if (isError) {
|
||||
return (
|
||||
<QueryErrorState
|
||||
title="考试详情加载失败"
|
||||
description="请检查网络后重试。"
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!detail) return <Empty description="考试不存在或无权访问" />;
|
||||
|
||||
const average = detail.scores.find((row) => row.classAvg !== null)?.classAvg ?? null;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Select,
|
||||
} from 'antd';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -22,6 +23,7 @@ export const RoomExpenseModal: React.FC<{
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => {
|
||||
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||
const roomExpenseGuard = useDirtyGuard(form);
|
||||
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
|
||||
useEffect(() => {
|
||||
@@ -37,7 +39,7 @@ export const RoomExpenseModal: React.FC<{
|
||||
okText={editing ? '保存' : '确认录入'}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form form={form} layout="vertical" scrollToFirstError>
|
||||
<Form.Item name="roomId" label="宿舍" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
@@ -77,6 +79,7 @@ export const UtilityModal: React.FC<{
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ open, saving, form, students, onOk, onCancel }) => {
|
||||
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||
const utilityGuard = useDirtyGuard(form);
|
||||
// 打开弹窗时记录当前表单值为「未修改」基准
|
||||
useEffect(() => {
|
||||
@@ -92,7 +95,7 @@ export const UtilityModal: React.FC<{
|
||||
okText="生成账单并扣余额"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form form={form} layout="vertical" scrollToFirstError>
|
||||
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
@@ -136,6 +139,7 @@ export const PersonalExpenseModal: React.FC<{
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => {
|
||||
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||
const personalExpenseGuard = useDirtyGuard(form);
|
||||
// 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准
|
||||
useEffect(() => {
|
||||
@@ -151,7 +155,7 @@ export const PersonalExpenseModal: React.FC<{
|
||||
okText={editing ? '保存' : '确认录入'}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form form={form} layout="vertical" scrollToFirstError>
|
||||
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
|
||||
@@ -162,7 +162,7 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{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="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="同步方式">手动触发</Descriptions.Item>
|
||||
|
||||
@@ -83,7 +83,7 @@ const LoginPage: React.FC = () => {
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" autoComplete="username" />
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" autoComplete="username" autoFocus />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="密码"
|
||||
|
||||
@@ -3,10 +3,9 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { operationLogsSchema } from '../../api/schemas';
|
||||
import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -35,24 +34,21 @@ const OperationLogsPage: React.FC = () => {
|
||||
data: fetchResult = { data: [], total: 0 },
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery<{ data: any[]; total: number }>({
|
||||
queryKey: ['operation-logs', page, pageSize, filterModule, dateRange],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const params: any = { page, pageSize };
|
||||
if (filterModule) params.module = filterModule;
|
||||
if (dateRange) {
|
||||
params.startDate = dateRange[0];
|
||||
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 };
|
||||
const params: any = { page, pageSize };
|
||||
if (filterModule) params.module = filterModule;
|
||||
if (dateRange) {
|
||||
params.startDate = dateRange[0];
|
||||
params.endDate = dateRange[1];
|
||||
}
|
||||
return validateResponse<{ data: any[]; total: number }>(
|
||||
operationLogsSchema,
|
||||
await api.get('/operation-logs', { params }),
|
||||
);
|
||||
},
|
||||
});
|
||||
const data = fetchResult.data;
|
||||
@@ -161,25 +157,33 @@ const OperationLogsPage: React.FC = () => {
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
{isError ? (
|
||||
<QueryErrorState
|
||||
title="操作日志加载失败"
|
||||
description="请检查网络后重试。"
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,12 +7,13 @@ import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { RefreshButton } from '../../components/RefreshButton';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { organizationsSchema } from '../../api/schemas';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
import { QueryEmpty } from '../../components/QueryState';
|
||||
import { QueryEmpty, QueryErrorState } from '../../components/QueryState';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#ff7875',
|
||||
@@ -59,21 +60,15 @@ const OrganizationsPage: React.FC = () => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string>();
|
||||
|
||||
const { data = [], isLoading, isFetching } = useQuery({
|
||||
const { data = [], isLoading, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return validateResponse<OrganizationItem[]>(
|
||||
organizationsSchema,
|
||||
await api.get<OrganizationItem[]>('/organizations', {
|
||||
params: { includeArchived: true },
|
||||
}),
|
||||
);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '机构数据加载失败');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
queryFn: async () =>
|
||||
validateResponse<OrganizationItem[]>(
|
||||
organizationsSchema,
|
||||
await api.get<OrganizationItem[]>('/organizations', {
|
||||
params: { includeArchived: true },
|
||||
}),
|
||||
),
|
||||
});
|
||||
const loading = isLoading || isFetching;
|
||||
|
||||
@@ -385,6 +380,7 @@ const OrganizationsPage: React.FC = () => {
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||
<PermissionButton
|
||||
permission="organization:create"
|
||||
type="primary"
|
||||
@@ -394,31 +390,39 @@ const OrganizationsPage: React.FC = () => {
|
||||
添加机构
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<QueryEmpty
|
||||
description="暂无机构"
|
||||
action={
|
||||
hasPermission('organization:create')
|
||||
? { label: '添加机构', icon: <PlusOutlined />, onClick: () => openEditor() }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 个机构`,
|
||||
}}
|
||||
/>
|
||||
{isError ? (
|
||||
<QueryErrorState
|
||||
title="机构数据加载失败"
|
||||
description="请检查网络后重试。"
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<QueryEmpty
|
||||
description="暂无机构"
|
||||
action={
|
||||
hasPermission('organization:create')
|
||||
? { label: '添加机构', icon: <PlusOutlined />, onClick: () => openEditor() }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 个机构`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
||||
open={modalOpen}
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Tag,
|
||||
Select,
|
||||
Statistic,
|
||||
Modal,
|
||||
Spin,
|
||||
Badge,
|
||||
Tooltip,
|
||||
DatePicker,
|
||||
Alert,
|
||||
Button,
|
||||
Switch,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import {Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, DatePicker, Alert, Button, Switch, Space, Skeleton} from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
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) => {
|
||||
if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false;
|
||||
|
||||
@@ -253,6 +253,7 @@ function buildRoomActionColumn(ctx: RoomColumnContext) {
|
||||
} = ctx;
|
||||
return {
|
||||
title: '操作',
|
||||
fixed: 'right' as const,
|
||||
width: 220,
|
||||
render: (_: unknown, record: unknown) => {
|
||||
const r = record as { status?: string; id: number; roomNumber?: string };
|
||||
|
||||
@@ -211,7 +211,7 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
|
||||
</Popconfirm>
|
||||
</div>
|
||||
) : null}
|
||||
<Table
|
||||
<Table scroll={{ x: 'max-content' }}
|
||||
dataSource={beds}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
@@ -329,7 +329,7 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
|
||||
</Popconfirm>
|
||||
</div>
|
||||
) : null}
|
||||
<Table
|
||||
<Table scroll={{ x: 'max-content' }}
|
||||
dataSource={lockers}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Form, Input, InputNumber, Modal, Select } from 'antd';
|
||||
import { RoomDrawer } from './RoomDrawer';
|
||||
import type { BedItem, LockerItem } from './RoomColumns';
|
||||
import { parseRoomNumber } from './RoomColumns';
|
||||
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||
|
||||
export const RoomItemFormFields: React.FC<{
|
||||
fieldName: 'bedNumber' | 'lockerNumber';
|
||||
@@ -37,6 +38,7 @@ export const RoomEditModal: React.FC<{
|
||||
onOk?: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
|
||||
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||
return (
|
||||
<Modal
|
||||
title={editing ? '编辑宿舍' : '添加宿舍'}
|
||||
@@ -46,7 +48,7 @@ export const RoomEditModal: React.FC<{
|
||||
okText="保存"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form form={form} layout="vertical" scrollToFirstError>
|
||||
<Form.Item name="roomNumber" label="房间号" rules={[{ required: true }]}>
|
||||
<Input
|
||||
placeholder="如:4-102(自动解析楼栋楼层)"
|
||||
@@ -114,6 +116,7 @@ export const BedModal: React.FC<{
|
||||
onOk?: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
|
||||
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||
return (
|
||||
<Modal
|
||||
title={editing ? '编辑床位' : '添加床位'}
|
||||
@@ -123,7 +126,7 @@ export const BedModal: React.FC<{
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form form={form} layout="vertical" scrollToFirstError>
|
||||
<RoomItemFormFields fieldName="bedNumber" label="床位编号" placeholder="如:1号床" />
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -138,6 +141,7 @@ export const LockerModal: React.FC<{
|
||||
onOk?: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
|
||||
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||
return (
|
||||
<Modal
|
||||
title={editing ? '编辑柜子' : '添加柜子'}
|
||||
@@ -147,7 +151,7 @@ export const LockerModal: React.FC<{
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form form={form} layout="vertical" scrollToFirstError>
|
||||
<RoomItemFormFields fieldName="lockerNumber" label="柜子编号" placeholder="如:1号柜" />
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { RefreshButton } from '../../components/RefreshButton';
|
||||
|
||||
export interface RoomsToolbarProps {
|
||||
onSearch: (value: string) => void;
|
||||
@@ -39,6 +40,8 @@ export interface RoomsToolbarProps {
|
||||
onExport: () => void;
|
||||
templateLoading?: boolean;
|
||||
exportLoading?: boolean;
|
||||
refreshLoading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
|
||||
@@ -67,6 +70,8 @@ export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
|
||||
onExport,
|
||||
templateLoading,
|
||||
exportLoading,
|
||||
refreshLoading,
|
||||
onRefresh,
|
||||
}) => {
|
||||
return (
|
||||
<div className="responsive-toolbar">
|
||||
@@ -115,6 +120,7 @@ export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
{onRefresh ? <RefreshButton loading={refreshLoading} onRefresh={onRefresh} /> : null}
|
||||
{showArchived && canEditRooms ? (
|
||||
<>
|
||||
<Popconfirm
|
||||
|
||||
@@ -488,6 +488,8 @@ const RoomsPage: React.FC = () => {
|
||||
templateLoading={templateDownloading}
|
||||
onExport={handleExport}
|
||||
exportLoading={exportDownloading}
|
||||
refreshLoading={isFetching}
|
||||
onRefresh={() => void refetch()}
|
||||
/>
|
||||
|
||||
{isError ? (
|
||||
|
||||
@@ -109,7 +109,7 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
destroyOnHidden
|
||||
>
|
||||
{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: '请选择班级' }]}>
|
||||
<Select
|
||||
placeholder="选择班级"
|
||||
|
||||
@@ -318,6 +318,7 @@ function buildActionColumn(ctx: StudentColumnContext) {
|
||||
} = ctx;
|
||||
return {
|
||||
title: '操作',
|
||||
fixed: 'right' as const,
|
||||
width: 180,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { App, Descriptions, Drawer, Form, Input, Modal, Select } from 'antd';
|
||||
import JinshujuMatchModal from '../../components/JinshujuMatchModal';
|
||||
import StudentProfileContent from '../../components/StudentProfileContent';
|
||||
import { SENSITIVE_LABELS } from './StudentColumns';
|
||||
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||
|
||||
type AppModal = ReturnType<typeof App.useApp>['modal'];
|
||||
|
||||
@@ -78,6 +79,7 @@ export const StudentEditModal: React.FC<{
|
||||
onOk,
|
||||
onCancel,
|
||||
}) => {
|
||||
useSubmitShortcut(open && !saving, () => onOk?.());
|
||||
return (
|
||||
<Modal
|
||||
title={editing ? '编辑学生' : '添加学生'}
|
||||
@@ -89,7 +91,7 @@ export const StudentEditModal: React.FC<{
|
||||
okText="保存"
|
||||
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 }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 api from '../../api';
|
||||
import { QueryEmpty } from '../../components/QueryState';
|
||||
@@ -102,7 +102,14 @@ export const StudentsTable: React.FC<{
|
||||
rowExpandable: () => true,
|
||||
expandedRowRender: (record) => {
|
||||
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) {
|
||||
return (
|
||||
<div style={{ padding: 8, color: '#999', fontSize: 13 }}>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { RefreshButton } from '../../components/RefreshButton';
|
||||
import { statusMap } from './StudentColumns';
|
||||
|
||||
export interface StudentsToolbarProps {
|
||||
@@ -60,6 +61,8 @@ export interface StudentsToolbarProps {
|
||||
onExport: () => void;
|
||||
templateLoading?: boolean;
|
||||
exportLoading?: boolean;
|
||||
refreshLoading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
||||
@@ -98,6 +101,8 @@ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
||||
onExport,
|
||||
templateLoading,
|
||||
exportLoading,
|
||||
refreshLoading,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const { modal } = App.useApp();
|
||||
return (
|
||||
@@ -171,6 +176,7 @@ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
{onRefresh ? <RefreshButton loading={refreshLoading} onRefresh={onRefresh} /> : null}
|
||||
{showArchived && canEditStudent ? (
|
||||
<>
|
||||
<Popconfirm
|
||||
|
||||
@@ -570,6 +570,8 @@ const StudentsPage: React.FC = () => {
|
||||
templateLoading={templateDownloading}
|
||||
onExport={handleExport}
|
||||
exportLoading={exportDownloading}
|
||||
refreshLoading={isFetching}
|
||||
onRefresh={() => void refetch()}
|
||||
/>
|
||||
{nextStepHint === 'class' && (
|
||||
<NextStepHint
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
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 api from '../../api';
|
||||
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||
@@ -142,8 +142,8 @@ const TeacherWorkspacePage: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ padding: 24 }}>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -157,7 +157,7 @@ const TeacherWorkspacePage: React.FC = () => {
|
||||
key: 'classes',
|
||||
label: `我的班级 (${data?.assignedClasses.length || 0})`,
|
||||
children: data?.assignedClasses.length ? (
|
||||
<Table<AssignedClass>
|
||||
<Table<AssignedClass> scroll={{ x: 'max-content' }}
|
||||
columns={classColumns}
|
||||
dataSource={data.assignedClasses}
|
||||
rowKey="classId"
|
||||
@@ -177,7 +177,7 @@ const TeacherWorkspacePage: React.FC = () => {
|
||||
key: 'schedule',
|
||||
label: `今日课程 (${data?.todaySchedules.length || 0})`,
|
||||
children: data?.todaySchedules.length ? (
|
||||
<Table<ScheduleItem>
|
||||
<Table<ScheduleItem> scroll={{ x: 'max-content' }}
|
||||
columns={scheduleColumns}
|
||||
dataSource={data.todaySchedules}
|
||||
rowKey="id"
|
||||
@@ -203,7 +203,7 @@ const TeacherWorkspacePage: React.FC = () => {
|
||||
key: 'students',
|
||||
label: `我的学生 (${data?.myStudents.length || 0})`,
|
||||
children: data?.myStudents.length ? (
|
||||
<Table<StudentItem>
|
||||
<Table<StudentItem> scroll={{ x: 'max-content' }}
|
||||
columns={studentColumns}
|
||||
dataSource={data.myStudents}
|
||||
rowKey="studentId"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EditOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { RefreshButton } from '../../components/RefreshButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
@@ -211,6 +212,7 @@ const TeachersPage: React.FC = () => {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
fixed: 'right' as const,
|
||||
width: 100,
|
||||
render: (_: unknown, r: TeacherRow) => (
|
||||
<PermissionButton
|
||||
@@ -252,6 +254,7 @@ const TeachersPage: React.FC = () => {
|
||||
}}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||
</Space>
|
||||
{isError ? (
|
||||
<QueryErrorState
|
||||
|
||||
@@ -252,6 +252,8 @@ const WalletsPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
fixed: 'right' as const,
|
||||
width: 140,
|
||||
render: (_: unknown, row: WalletRow) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
@@ -325,7 +327,7 @@ const WalletsPage: React.FC = () => {
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
<Table scroll={{ x: 'max-content' }}
|
||||
rowKey="studentId"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <QueryEmpty description="暂无学生余额数据" /> }}
|
||||
@@ -425,7 +427,7 @@ const WalletsPage: React.FC = () => {
|
||||
onRetry={() => void refetchTransactions()}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
<Table scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
dataSource={transactions}
|
||||
loading={txLoading || txFetching}
|
||||
|
||||
@@ -24,8 +24,8 @@ export default defineConfig({
|
||||
// Slow down interactions slightly so UI animations settle
|
||||
slowHijackESM: false,
|
||||
},
|
||||
// Integration tests: match *.integration.test.{ts,tsx}
|
||||
include: ['src/**/*.integration.test.{ts,tsx}'],
|
||||
// Unit and integration tests run in the browser test environment.
|
||||
include: ['src/**/*.integration.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}'],
|
||||
// Global timeout for browser operations
|
||||
testTimeout: 30_000,
|
||||
// Retry flaky browser tests once
|
||||
|
||||
80
docs/ux-optimization-summary.md
Normal file
80
docs/ux-optimization-summary.md
Normal 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 + 提示,不再空白。
|
||||
- 班级花名册导出按钮 loading(try/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)
|
||||
|
||||
## 验证记录
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| typecheck(admin + server) | ✅ |
|
||||
| lint(oxlint + eslint) | ✅ 0 warning / 0 error |
|
||||
| admin vitest(浏览器) | ✅ 31 文件 / 166 用例 |
|
||||
| server jest | ✅ 191 用例 |
|
||||
| aislop scan --changes | ✅ 100/100 |
|
||||
|
||||
## 未做/后续(可选)
|
||||
|
||||
- 运行时视觉走查:需要 MySQL + 后端 + 前端环境(当前本地未运行),用于最终逐页截图核对。
|
||||
- AI 助手抽屉:已具备响应式/空态/取消/技能锁定,`submit` 已有请求中防护,未做额外改动。
|
||||
Reference in New Issue
Block a user