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 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' }}>

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 { 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>
);

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>
</Upload>
) : null}
<Table<AttachmentRecord>
<Table<AttachmentRecord> scroll={{ x: 'max-content' }}
columns={columns}
dataSource={data}
rowKey="id"

View File

@@ -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"

View File

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

View File

@@ -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"

View File

@@ -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}

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;
}
}
/* ─── 全局无障碍与质感增强 ─── */
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 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>
);
};

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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}

View File

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

View File

@@ -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}

View File

@@ -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>

View File

@@ -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%' }}

View File

@@ -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}
/>
),
},

View File

@@ -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

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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>

View File

@@ -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';

View File

@@ -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 />}

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -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="密码"

View File

@@ -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>
);
};

View File

@@ -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}

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

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

View File

@@ -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="选择班级"

View File

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

View File

@@ -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>

View File

@@ -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 }}>

View File

@@ -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

View File

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

View File

@@ -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"

View File

@@ -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

View File

@@ -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}

View File

@@ -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

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` 已有请求中防护,未做额外改动。