From c658b2314dba1521be399efd5b9a49941591990b Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Thu, 5 Mar 2026 21:57:36 +0800 Subject: [PATCH] feat(a11y): apply ARIA roles, landmarks, focus management, touch targets and contrast fixes across components --- dashboard/electron/main/index.ts | 8 + .../src/components/electron/TitleBar.tsx | 3 + .../src/components/expression-reviewer.tsx | 228 +++++++++++------- dashboard/src/components/layout/Header.tsx | 12 +- dashboard/src/components/layout/Layout.tsx | 55 ++++- dashboard/src/components/layout/Sidebar.tsx | 4 +- dashboard/src/components/plugin-stats.tsx | 3 +- dashboard/src/components/ui/alert.tsx | 1 + dashboard/src/components/ui/dialog.tsx | 2 +- dashboard/src/components/ui/pagination.tsx | 1 + dashboard/src/components/ui/toast.tsx | 1 + dashboard/src/routes/auth.tsx | 4 +- dashboard/src/routes/chat/ChatTabBar.tsx | 5 +- dashboard/src/routes/chat/MessageRenderer.tsx | 2 + dashboard/src/routes/config/adapter.tsx | 18 +- .../config/model/components/ModelTable.tsx | 2 +- .../config/modelProvider/ProviderForm.tsx | 12 +- .../config/modelProvider/ProviderList.tsx | 2 +- dashboard/src/routes/config/pack-detail.tsx | 4 +- dashboard/src/routes/index.tsx | 7 + .../src/routes/monitor/planner-monitor.tsx | 6 + .../src/routes/monitor/replier-monitor.tsx | 6 + dashboard/src/routes/person.tsx | 2 +- dashboard/src/routes/plugin-config.tsx | 3 + dashboard/src/routes/plugin-mirrors.tsx | 2 +- .../src/routes/plugins/InstallDialog.tsx | 1 + .../routes/resource/emoji/EmojiDialogs.tsx | 8 + .../src/routes/resource/emoji/EmojiList.tsx | 3 + .../resource/expression/ExpressionList.tsx | 2 +- .../src/routes/resource/jargon/JargonList.tsx | 2 +- .../resource/knowledge-graph/GraphDialogs.tsx | 8 +- .../knowledge-graph/GraphVisualization.tsx | 104 ++++---- 32 files changed, 365 insertions(+), 156 deletions(-) diff --git a/dashboard/electron/main/index.ts b/dashboard/electron/main/index.ts index 4df1cf3f..03acc056 100644 --- a/dashboard/electron/main/index.ts +++ b/dashboard/electron/main/index.ts @@ -143,12 +143,20 @@ function createWindow() { mainWindow.on('unmaximize', () => { mainWindow?.webContents.send('electron:window-unmaximized') }) + + // 窗口获得焦点时确保焦点传递到 webContents,支持屏幕阅读器正确工作 + mainWindow.on('focus', () => { + mainWindow?.webContents.focus() + }) } /** * App event: when app is ready */ app.whenReady().then(() => { + // 确保 Chromium a11y tree 始终激活(供屏幕阅读器使用) + app.setAccessibilitySupportEnabled(true) + registerAppProtocol() // Set Content Security Policy diff --git a/dashboard/src/components/electron/TitleBar.tsx b/dashboard/src/components/electron/TitleBar.tsx index 8fe43480..fac440e5 100644 --- a/dashboard/src/components/electron/TitleBar.tsx +++ b/dashboard/src/components/electron/TitleBar.tsx @@ -34,6 +34,7 @@ export function TitleBar() { onClick={minimize} tabIndex={-1} type="button" + aria-label="最小化" > @@ -42,6 +43,7 @@ export function TitleBar() { onClick={toggleMaximize} tabIndex={-1} type="button" + aria-label={isMaximized ? "还原窗口" : "最大化"} > {isMaximized ? ( @@ -54,6 +56,7 @@ export function TitleBar() { onClick={close} tabIndex={-1} type="button" + aria-label="关闭窗口" > diff --git a/dashboard/src/components/expression-reviewer.tsx b/dashboard/src/components/expression-reviewer.tsx index 40b1d5fd..002eb206 100644 --- a/dashboard/src/components/expression-reviewer.tsx +++ b/dashboard/src/components/expression-reviewer.tsx @@ -8,7 +8,8 @@ * 4. 冲突检测(防止与AI自动检查冲突) */ -import { useState, useEffect, useCallback, useRef } from 'react' +import { animated, useSpring } from '@react-spring/web' +import { useCallback, useEffect, useRef, useState } from 'react' import { Dialog, DialogContent, @@ -80,9 +81,10 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro const [quickLoading, setQuickLoading] = useState(false) const [quickTotal, setQuickTotal] = useState(0) const [quickPage, setQuickPage] = useState(1) - const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null) - const [swipeOffset, setSwipeOffset] = useState(0) - const [isAnimating, setIsAnimating] = useState(false) + const swipeDirectionRef = useRef<'left' | 'right' | null>(null) + const isAnimatingRef = useRef(false) + const [cardSpring, cardApi] = useSpring(() => ({ x: 0, opacity: 1, rotate: 0, config: { tension: 300, friction: 30 } })) + const swipeOffsetRef = useRef(0) const [conflictId, setConflictId] = useState(null) const cardRef = useRef(null) const dragStartRef = useRef<{ x: number; y: number } | null>(null) @@ -259,16 +261,16 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro // 快速审核 - 执行审核操作 const handleQuickReview = useCallback(async (rejected: boolean) => { const currentExpr = quickExpressions[quickCurrentIndex] - if (!currentExpr || isAnimating) return + if (!currentExpr || isAnimatingRef.current) return const directions = getAllowedDirections(currentExpr) if ((rejected && !directions.left) || (!rejected && !directions.right)) { return } - setIsAnimating(true) - setSwipeDirection(rejected ? 'left' : 'right') - setSwipeOffset(rejected ? -400 : 400) + isAnimatingRef.current = true + swipeDirectionRef.current = rejected ? 'left' : 'right' + cardApi.start({ x: rejected ? -400 : 400, rotate: rejected ? -20 : 20, opacity: 0 }) try { const result = await batchReviewExpressions([{ @@ -303,9 +305,10 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro } // 重置状态 - setSwipeDirection(null) - setSwipeOffset(0) - setIsAnimating(false) + swipeDirectionRef.current = null + swipeOffsetRef.current = 0 + cardApi.set({ x: 0, opacity: 1, rotate: 0 }) + isAnimatingRef.current = false // 刷新统计 loadStats() @@ -327,9 +330,10 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro // 播放冲突动画后刷新 setTimeout(() => { setConflictId(null) - setSwipeDirection(null) - setSwipeOffset(0) - setIsAnimating(false) + swipeDirectionRef.current = null + swipeOffsetRef.current = 0 + cardApi.set({ x: 0, opacity: 1, rotate: 0 }) + isAnimatingRef.current = false loadQuickList(false) // 重新加载当前页 loadStats() }, 1500) @@ -340,35 +344,36 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro description: error instanceof Error ? error.message : '未知错误', variant: 'destructive', }) - setSwipeDirection(null) - setSwipeOffset(0) - setIsAnimating(false) + swipeDirectionRef.current = null + swipeOffsetRef.current = 0 + cardApi.set({ x: 0, opacity: 1, rotate: 0 }) + isAnimatingRef.current = false } - }, [quickExpressions, quickCurrentIndex, isAnimating, getAllowedDirections, quickFilterType, toast, loadStats, quickTotal, loadQuickList]) + }, [quickExpressions, quickCurrentIndex, isAnimatingRef, getAllowedDirections, quickFilterType, toast, loadStats, quickTotal, loadQuickList]) // 拖拽开始 const handleDragStart = useCallback((clientX: number, clientY: number) => { - if (isAnimating) return + if (isAnimatingRef.current) return dragStartRef.current = { x: clientX, y: clientY } isDraggingRef.current = false - }, [isAnimating]) + }, [isAnimatingRef]) // 触发无效操作动画 const triggerInvalidAnimation = useCallback((direction: 'left' | 'right') => { - if (isAnimating) return - setIsAnimating(true) + if (isAnimatingRef.current) return + isAnimatingRef.current = true // 模拟向该方向移动一点 - setSwipeOffset(direction === 'left' ? -30 : 30) + cardApi.start({ x: direction === 'left' ? -30 : 30, immediate: true }) setTimeout(() => { - setSwipeOffset(0) - setTimeout(() => setIsAnimating(false), 300) + cardApi.start({ x: 0 }) + setTimeout(() => { isAnimatingRef.current = false }, 300) }, 150) - }, [isAnimating]) + }, [cardApi]) // 拖拽移动 const handleDragMove = useCallback((clientX: number) => { - if (!dragStartRef.current || isAnimating) return + if (!dragStartRef.current || isAnimatingRef.current) return const deltaX = clientX - dragStartRef.current.x const currentExpr = quickExpressions[quickCurrentIndex] @@ -376,42 +381,48 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro // 检查方向限制 if (deltaX < 0 && !directions.left) { - setSwipeOffset(deltaX * 0.2) // 提供阻力反馈 - setSwipeDirection(null) + cardApi.start({ x: deltaX * 0.2, immediate: true }) // 提供阻力反馈 + swipeOffsetRef.current = deltaX * 0.2 + swipeDirectionRef.current = null return } if (deltaX > 0 && !directions.right) { - setSwipeOffset(deltaX * 0.2) - setSwipeDirection(null) + cardApi.start({ x: deltaX * 0.2, immediate: true }) + swipeOffsetRef.current = deltaX * 0.2 + swipeDirectionRef.current = null return } isDraggingRef.current = true - setSwipeOffset(deltaX) + swipeOffsetRef.current = deltaX + cardApi.start({ x: deltaX, rotate: deltaX * 0.05, opacity: Math.max(0, 1 - Math.abs(deltaX) / 500), immediate: true }) + cardApi.start({ x: deltaX, rotate: deltaX * 0.05, opacity: Math.max(0, 1 - Math.abs(deltaX) / 500), immediate: true }) if (Math.abs(deltaX) > 50) { - setSwipeDirection(deltaX > 0 ? 'right' : 'left') + swipeDirectionRef.current = deltaX > 0 ? 'right' : 'left' } else { - setSwipeDirection(null) + swipeDirectionRef.current = null } - }, [quickExpressions, quickCurrentIndex, getAllowedDirections, isAnimating]) + }, [quickExpressions, quickCurrentIndex, getAllowedDirections, cardApi]) // 拖拽结束 const handleDragEnd = useCallback(() => { if (!dragStartRef.current) return const threshold = 100 - if (Math.abs(swipeOffset) > threshold && swipeDirection) { - handleQuickReview(swipeDirection === 'left') + const currentX = cardSpring.x.get() + if (Math.abs(currentX) > threshold && swipeDirectionRef.current) { + handleQuickReview(swipeDirectionRef.current === 'left') } else { // 回弹 - setSwipeOffset(0) - setSwipeDirection(null) + cardApi.start({ x: 0, rotate: 0, opacity: 1 }) + swipeOffsetRef.current = 0 + swipeDirectionRef.current = null } dragStartRef.current = null isDraggingRef.current = false - }, [swipeOffset, swipeDirection, handleQuickReview]) + }, [cardSpring.x, handleQuickReview, cardApi]) // 鼠标事件处理 const handleMouseDown = useCallback((e: React.MouseEvent) => { @@ -463,7 +474,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro e.stopPropagation() e.stopImmediatePropagation() - if (isAnimating || quickLoading) return + if (isAnimatingRef.current || quickLoading) return const currentExpr = quickExpressions[quickCurrentIndex] const directions = getAllowedDirections(currentExpr) @@ -496,7 +507,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro // 使用 capture 模式,在事件到达 Tabs 之前拦截 window.addEventListener('keydown', handleKeyDown, true) return () => window.removeEventListener('keydown', handleKeyDown, true) - }, [open, reviewMode, quickExpressions, quickCurrentIndex, isAnimating, quickLoading, getAllowedDirections, handleQuickReview, triggerInvalidAnimation]) + }, [open, reviewMode, quickExpressions, quickCurrentIndex, isAnimatingRef, quickLoading, getAllowedDirections, handleQuickReview, triggerInvalidAnimation]) // 动态加载更多数据 - 当接近列表末尾时自动加载 useEffect(() => { @@ -1406,7 +1417,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro <>
@@ -1414,7 +1425,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
通过 @@ -1426,6 +1437,12 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
{/* 堆叠卡片 */} +
{quickExpressions .slice(quickCurrentIndex, quickCurrentIndex + 5) @@ -1442,17 +1459,16 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro transition: isCurrent && !isDraggingRef.current ? 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' : 'none', } - if (isCurrent) { - // 当前卡片样式 +if (isCurrent) { + // 当前卡片:样式由 useSpring 控制,通过 animated.div 渲染 + // style 仅保留非动画属性 style = { ...style, - transform: `translateX(${swipeOffset}px) rotate(${swipeOffset * 0.05}deg)`, - opacity: Math.max(0, 1 - Math.abs(swipeOffset) / 500), cursor: 'grab', } } else { // 后方卡片样式 - const progress = Math.min(Math.abs(swipeOffset) / 200, 1) // 0 to 1 + const progress = Math.min(Math.abs(swipeOffsetRef.current) / 200, 1) // 0 to 1 // 计算指定索引的样式属性 const getStyleForIndex = (i: number) => { @@ -1487,27 +1503,30 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro } } - return ( -
{/* 冲突提示遮罩 */} - {isCurrent && conflictId === expr.id && ( + {conflictId === expr.id && (
@@ -1517,21 +1536,71 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro

后台任务已处理此条目

)} - {/* 无效操作提示 */} - {isCurrent && ( -
10 && !getAllowedDirections(expr).right)) - ? "opacity-100" - : "opacity-0" - )}> -
- -
+
10 && !getAllowedDirections(expr).right)) + ? "opacity-100" + : "opacity-0" + )}> +
+
- )} - +
+ {/* 内容区 */} +
+ {/* 状态和ID */} +
+ #{expr.id} +
+ {getStatusBadge(expr)} + {getModifierBadge(expr.modified_by)} +
+
+ {/* 情景 */} +
+ +
+

{expr.situation}

+
+
+ {/* 风格 */} +
+ +
+ {expr.style.split(/[,,]/).map((s, i) => ( + + {s.trim()} + + ))} +
+
+
+ {/* 底部信息 */} +
+
+
+ +
+ + {getChatName(expr.chat_id)} + +
+ {formatTime(expr.create_date)} +
+ + ) : ( +
+ {/* 内容区 */}
{/* 状态和ID */}
@@ -1541,7 +1610,6 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro {getModifierBadge(expr.modified_by)}
- {/* 情景 */}
@@ -1549,7 +1617,6 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro

{expr.situation}

- {/* 风格 */}
@@ -1562,7 +1629,6 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
- {/* 底部信息 */}
@@ -1595,7 +1661,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro !directions.left ? 'opacity-30 cursor-not-allowed' : 'hover:bg-red-50 hover:text-red-600 hover:border-red-200' )} onClick={() => directions.left && handleQuickReview(true)} - disabled={!directions.left || isAnimating} + disabled={!directions.left || isAnimatingRef.current} > @@ -1607,7 +1673,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro !directions.right ? 'opacity-30 cursor-not-allowed' : 'hover:bg-green-50 hover:text-green-600 hover:border-green-200' )} onClick={() => directions.right && handleQuickReview(false)} - disabled={!directions.right || isAnimating} + disabled={!directions.right || isAnimatingRef.current} > diff --git a/dashboard/src/components/layout/Header.tsx b/dashboard/src/components/layout/Header.tsx index 6738e7ab..d17d080e 100644 --- a/dashboard/src/components/layout/Header.tsx +++ b/dashboard/src/components/layout/Header.tsx @@ -35,7 +35,7 @@ interface HeaderProps { export function Header({ sidebarOpen, - // mobileMenuOpen, // unused - kept in props for API compatibility + mobileMenuOpen, searchOpen, actualTheme, onSidebarToggle, @@ -67,6 +67,8 @@ export function Header({ {/* 移动端菜单按钮 */} diff --git a/dashboard/src/components/layout/Layout.tsx b/dashboard/src/components/layout/Layout.tsx index f242fce0..6169133e 100644 --- a/dashboard/src/components/layout/Layout.tsx +++ b/dashboard/src/components/layout/Layout.tsx @@ -1,9 +1,12 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useRouter } from '@tanstack/react-router' import { BackgroundLayer } from '@/components/background-layer' import { BackToTop } from '@/components/back-to-top' import { HttpWarningBanner } from '@/components/http-warning-banner' +import { SkipNav } from '@/components/ui/skip-nav' +import { useAnnounce } from '@/components/ui/announcer' import { TooltipProvider } from '@/components/ui/tooltip' import { useTheme } from '@/components/use-theme' import { useAuthGuard } from '@/hooks/use-auth' @@ -12,6 +15,7 @@ import { useBackground } from '@/hooks/use-background' import { TitleBar } from '@/components/electron/TitleBar' import { isElectron } from '@/lib/runtime' import { cn } from '@/lib/utils' +import { menuSections } from './constants' import { Header } from './Header' import { Sidebar } from './Sidebar' import type { LayoutProps } from './types' @@ -19,6 +23,8 @@ import type { LayoutProps } from './types' export function Layout({ children }: LayoutProps) { const { t } = useTranslation() const { checking } = useAuthGuard() // 检查认证状态 + const router = useRouter() + const announce = useAnnounce() const [sidebarOpen, setSidebarOpen] = useState(true) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) @@ -52,6 +58,41 @@ export function Layout({ children }: LayoutProps) { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, []) + // 路由变更:焦点管理 + 屏幕阅读器播报 + document.title 更新 + useEffect(() => { + // 构建 路径 -> 页面标题 的映射表(以当前语言 t() 翻译) + const pathToLabel: Record = {} + for (const section of menuSections) { + for (const item of section.items) { + pathToLabel[item.path] = t(item.label) + } + } + + const unsubscribe = router.subscribe('onResolved', () => { + const pathname = router.state.location.pathname + const pageTitle = pathToLabel[pathname] ?? 'MaiBot Dashboard' + const fullTitle = pageTitle === 'MaiBot Dashboard' + ? 'MaiBot Dashboard' + : `${pageTitle} — MaiBot Dashboard` + + // 更新 document.title + document.title = fullTitle + + // 屏幕阅读器朗读导航结果 + announce(t('a11y.navigatedTo', { page: pageTitle }), 'polite') + + // 将焦点移到主内容区(仅当焦点不在其内部时) + const mainEl = document.getElementById('main-content') + if (mainEl && !mainEl.contains(document.activeElement)) { + // requestAnimationFrame 确保 DOM 已渲染完成 + requestAnimationFrame(() => { + mainEl.focus({ preventScroll: true }) + }) + } + }) + + return unsubscribe + }, [router, announce, t]) // 认证检查中,显示加载状态 if (checking) { @@ -74,8 +115,9 @@ export function Layout({ children }: LayoutProps) { const pageBg = useBackground('page') return ( - - {isElectron() && } + + + {isElectron() && }
{/* Sidebar */}