From a0829f17ce0365cc10dfc46a2e35c4e504c4e5d0 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 15:05:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=AE=A1=E7=90=86=E7=AB=AF=20UI?= =?UTF-8?q?/UX=20=E6=B7=B1=E5=BA=A6=E4=BC=98=E5=8C=96=E2=80=94=E2=80=94?= =?UTF-8?q?=E9=94=99=E8=AF=AF/=E5=8A=A0=E8=BD=BD/=E7=A9=BA=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E3=80=81=E8=A1=A8=E5=8D=95=E5=BF=AB=E6=8D=B7=E9=94=AE?= =?UTF-8?q?=E3=80=81=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=80=82=E9=85=8D=E4=B8=8E?= =?UTF-8?q?=E6=97=A0=E9=9A=9C=E7=A2=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/App.tsx | 2 + apps/admin/src/components/BackTop.tsx | 40 +++++ .../admin/src/components/NotificationBell.tsx | 15 +- .../src/components/Onboarding/RoleTour.tsx | 162 ------------------ apps/admin/src/components/RefreshButton.tsx | 17 ++ apps/admin/src/components/ScrollToTop.tsx | 16 ++ .../StudentProfileContent/AttachmentsTab.tsx | 2 +- .../StudentProfileContent/EnrollmentsTab.tsx | 2 +- .../StudentProfileContent/ExamScoresTab.tsx | 2 +- .../StudentProfileContent/LearningTab.tsx | 2 +- .../StudentProfileContent/index.tsx | 2 +- .../src/components/ux.integration.test.tsx | 125 ++++++++++++++ apps/admin/src/hooks/useSubmitShortcut.ts | 19 ++ apps/admin/src/index.css | 65 +++++++ apps/admin/src/layouts/MainLayout.tsx | 4 +- apps/admin/src/pages/AiConfig/index.tsx | 6 +- .../Attendance/AttendanceAdminModals.tsx | 6 +- .../Attendance/LessonAttendanceDetail.tsx | 2 +- apps/admin/src/pages/Attendance/admin.tsx | 1 + apps/admin/src/pages/AttendanceDevices.tsx | 2 +- apps/admin/src/pages/Bills/index.tsx | 7 +- .../src/pages/Classes/ClassDetailTabs.tsx | 57 +++--- apps/admin/src/pages/Classes/detail.tsx | 16 +- .../src/pages/ClassroomRentals/index.tsx | 4 +- apps/admin/src/pages/Classrooms/index.tsx | 7 +- .../src/pages/Dashboard/DashboardCharts.ts | 12 +- .../pages/Dashboard/DashboardLazyCards.tsx | 6 +- apps/admin/src/pages/Dashboard/index.tsx | 69 +++++++- .../src/pages/Deposits/DepositModals.tsx | 17 +- .../admin/src/pages/Deposits/DepositTable.tsx | 1 + apps/admin/src/pages/Deposits/index.tsx | 2 + apps/admin/src/pages/Exams/detail.tsx | 30 ++-- .../src/pages/Expenses/ExpenseModals.tsx | 10 +- .../src/pages/IntegrationConfig/index.tsx | 2 +- apps/admin/src/pages/Login/index.tsx | 2 +- apps/admin/src/pages/OperationLogs/index.tsx | 74 ++++---- apps/admin/src/pages/Organizations/index.tsx | 84 ++++----- apps/admin/src/pages/RoomVisual/index.tsx | 25 +-- apps/admin/src/pages/Rooms/RoomColumns.tsx | 1 + apps/admin/src/pages/Rooms/RoomDrawer.tsx | 4 +- apps/admin/src/pages/Rooms/RoomModals.tsx | 10 +- apps/admin/src/pages/Rooms/RoomsToolbar.tsx | 6 + apps/admin/src/pages/Rooms/index.tsx | 2 + .../src/pages/Schedules/ScheduleModals.tsx | 2 +- .../src/pages/Students/StudentColumns.tsx | 1 + .../src/pages/Students/StudentModals.tsx | 4 +- .../src/pages/Students/StudentsTable.tsx | 11 +- .../src/pages/Students/StudentsToolbar.tsx | 6 + apps/admin/src/pages/Students/index.tsx | 2 + .../src/pages/TeacherWorkspace/index.tsx | 12 +- apps/admin/src/pages/Teachers/index.tsx | 3 + apps/admin/src/pages/Wallets/index.tsx | 6 +- apps/admin/vitest.config.ts | 4 +- docs/ux-optimization-summary.md | 80 +++++++++ 54 files changed, 702 insertions(+), 369 deletions(-) create mode 100644 apps/admin/src/components/BackTop.tsx delete mode 100644 apps/admin/src/components/Onboarding/RoleTour.tsx create mode 100644 apps/admin/src/components/RefreshButton.tsx create mode 100644 apps/admin/src/components/ScrollToTop.tsx create mode 100644 apps/admin/src/components/ux.integration.test.tsx create mode 100644 apps/admin/src/hooks/useSubmitShortcut.ts create mode 100644 docs/ux-optimization-summary.md diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index f17a264..d032245 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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 = () => { + diff --git a/apps/admin/src/components/BackTop.tsx b/apps/admin/src/components/BackTop.tsx new file mode 100644 index 0000000..59ace44 --- /dev/null +++ b/apps/admin/src/components/BackTop.tsx @@ -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 ( + + @@ -199,7 +201,12 @@ const NotificationBell: React.FC = () => { placement="bottomRight" > - + ) : null} - + scroll={{ x: 'max-content' }} columns={columns} dataSource={data} rowKey="id" diff --git a/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx b/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx index a6038ee..9bec384 100644 --- a/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx +++ b/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx @@ -205,7 +205,7 @@ export const EnrollmentsTab: React.FC = > 添加报读记录 - + scroll={{ x: 'max-content' }} columns={columns} dataSource={data} rowKey="id" diff --git a/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx b/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx index ed45f3f..32de840 100644 --- a/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx +++ b/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx @@ -195,7 +195,7 @@ export const ExamScoresTab: React.FC< > 添加考试成绩 - + scroll={{ x: 'max-content' }} columns={columns} dataSource={data} rowKey="id" diff --git a/apps/admin/src/components/StudentProfileContent/LearningTab.tsx b/apps/admin/src/components/StudentProfileContent/LearningTab.tsx index abbed61..c14e8cf 100644 --- a/apps/admin/src/components/StudentProfileContent/LearningTab.tsx +++ b/apps/admin/src/components/StudentProfileContent/LearningTab.tsx @@ -162,7 +162,7 @@ export const LearningTab: React.FC = ({ > 添加学情记录 - + scroll={{ x: 'max-content' }} columns={columns} dataSource={data} rowKey="id" diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index ac49ff5..7ee66ce 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -187,7 +187,7 @@ const InlineArchiveSummary: React.FC<{ ); return ( - + | 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(); + 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(); + expect(container?.querySelector('.ant-btn-loading')).toBeTruthy(); + }); + + it('useSubmitShortcut 未激活时不响应 Cmd+Enter', async () => { + const onSubmit = vi.fn(); + const Harness = () => { + useSubmitShortcut(false, onSubmit); + return ; + }; + mount(); + 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 ; + }; + mount(); + + 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(); + 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 ( + + ); + }; + mount( + + + + } /> + other} /> + + , + ); + 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); + }); +}); diff --git a/apps/admin/src/hooks/useSubmitShortcut.ts b/apps/admin/src/hooks/useSubmitShortcut.ts new file mode 100644 index 0000000..cb365c3 --- /dev/null +++ b/apps/admin/src/hooks/useSubmitShortcut.ts @@ -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]); +} diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css index 8cd0068..9ad23dd 100644 --- a/apps/admin/src/index.css +++ b/apps/admin/src/index.css @@ -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; + } +} diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 6b33e96..5c7f907 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -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 = () => { /> )} - + ); }; diff --git a/apps/admin/src/pages/AiConfig/index.tsx b/apps/admin/src/pages/AiConfig/index.tsx index d443e72..c2dc4ce 100644 --- a/apps/admin/src/pages/AiConfig/index.tsx +++ b/apps/admin/src/pages/AiConfig/index.tsx @@ -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 ( -
- +
+
); } diff --git a/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx b/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx index 3af0f74..b107e67 100644 --- a/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx +++ b/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx @@ -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 ( ( <>