feat(a11y): apply ARIA roles, landmarks, focus management, touch targets and contrast fixes across components

This commit is contained in:
DrSmoothl
2026-03-05 21:57:36 +08:00
parent c12d1ca42a
commit c658b2314d
32 changed files with 365 additions and 156 deletions

View File

@@ -143,12 +143,20 @@ function createWindow() {
mainWindow.on('unmaximize', () => { mainWindow.on('unmaximize', () => {
mainWindow?.webContents.send('electron:window-unmaximized') mainWindow?.webContents.send('electron:window-unmaximized')
}) })
// 窗口获得焦点时确保焦点传递到 webContents支持屏幕阅读器正确工作
mainWindow.on('focus', () => {
mainWindow?.webContents.focus()
})
} }
/** /**
* App event: when app is ready * App event: when app is ready
*/ */
app.whenReady().then(() => { app.whenReady().then(() => {
// 确保 Chromium a11y tree 始终激活(供屏幕阅读器使用)
app.setAccessibilitySupportEnabled(true)
registerAppProtocol() registerAppProtocol()
// Set Content Security Policy // Set Content Security Policy

View File

@@ -34,6 +34,7 @@ export function TitleBar() {
onClick={minimize} onClick={minimize}
tabIndex={-1} tabIndex={-1}
type="button" type="button"
aria-label="最小化"
> >
<Minus className="h-3.5 w-3.5" /> <Minus className="h-3.5 w-3.5" />
</button> </button>
@@ -42,6 +43,7 @@ export function TitleBar() {
onClick={toggleMaximize} onClick={toggleMaximize}
tabIndex={-1} tabIndex={-1}
type="button" type="button"
aria-label={isMaximized ? "还原窗口" : "最大化"}
> >
{isMaximized ? ( {isMaximized ? (
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
@@ -54,6 +56,7 @@ export function TitleBar() {
onClick={close} onClick={close}
tabIndex={-1} tabIndex={-1}
type="button" type="button"
aria-label="关闭窗口"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>

View File

@@ -8,7 +8,8 @@
* 4. 冲突检测防止与AI自动检查冲突 * 4. 冲突检测防止与AI自动检查冲突
*/ */
import { useState, useEffect, useCallback, useRef } from 'react' import { animated, useSpring } from '@react-spring/web'
import { useCallback, useEffect, useRef, useState } from 'react'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -80,9 +81,10 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
const [quickLoading, setQuickLoading] = useState(false) const [quickLoading, setQuickLoading] = useState(false)
const [quickTotal, setQuickTotal] = useState(0) const [quickTotal, setQuickTotal] = useState(0)
const [quickPage, setQuickPage] = useState(1) const [quickPage, setQuickPage] = useState(1)
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null) const swipeDirectionRef = useRef<'left' | 'right' | null>(null)
const [swipeOffset, setSwipeOffset] = useState(0) const isAnimatingRef = useRef(false)
const [isAnimating, setIsAnimating] = useState(false) const [cardSpring, cardApi] = useSpring(() => ({ x: 0, opacity: 1, rotate: 0, config: { tension: 300, friction: 30 } }))
const swipeOffsetRef = useRef(0)
const [conflictId, setConflictId] = useState<number | null>(null) const [conflictId, setConflictId] = useState<number | null>(null)
const cardRef = useRef<HTMLDivElement>(null) const cardRef = useRef<HTMLDivElement>(null)
const dragStartRef = useRef<{ x: number; y: number } | null>(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 handleQuickReview = useCallback(async (rejected: boolean) => {
const currentExpr = quickExpressions[quickCurrentIndex] const currentExpr = quickExpressions[quickCurrentIndex]
if (!currentExpr || isAnimating) return if (!currentExpr || isAnimatingRef.current) return
const directions = getAllowedDirections(currentExpr) const directions = getAllowedDirections(currentExpr)
if ((rejected && !directions.left) || (!rejected && !directions.right)) { if ((rejected && !directions.left) || (!rejected && !directions.right)) {
return return
} }
setIsAnimating(true) isAnimatingRef.current = true
setSwipeDirection(rejected ? 'left' : 'right') swipeDirectionRef.current = rejected ? 'left' : 'right'
setSwipeOffset(rejected ? -400 : 400) cardApi.start({ x: rejected ? -400 : 400, rotate: rejected ? -20 : 20, opacity: 0 })
try { try {
const result = await batchReviewExpressions([{ const result = await batchReviewExpressions([{
@@ -303,9 +305,10 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
} }
// 重置状态 // 重置状态
setSwipeDirection(null) swipeDirectionRef.current = null
setSwipeOffset(0) swipeOffsetRef.current = 0
setIsAnimating(false) cardApi.set({ x: 0, opacity: 1, rotate: 0 })
isAnimatingRef.current = false
// 刷新统计 // 刷新统计
loadStats() loadStats()
@@ -327,9 +330,10 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
// 播放冲突动画后刷新 // 播放冲突动画后刷新
setTimeout(() => { setTimeout(() => {
setConflictId(null) setConflictId(null)
setSwipeDirection(null) swipeDirectionRef.current = null
setSwipeOffset(0) swipeOffsetRef.current = 0
setIsAnimating(false) cardApi.set({ x: 0, opacity: 1, rotate: 0 })
isAnimatingRef.current = false
loadQuickList(false) // 重新加载当前页 loadQuickList(false) // 重新加载当前页
loadStats() loadStats()
}, 1500) }, 1500)
@@ -340,35 +344,36 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
description: error instanceof Error ? error.message : '未知错误', description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive', variant: 'destructive',
}) })
setSwipeDirection(null) swipeDirectionRef.current = null
setSwipeOffset(0) swipeOffsetRef.current = 0
setIsAnimating(false) 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) => { const handleDragStart = useCallback((clientX: number, clientY: number) => {
if (isAnimating) return if (isAnimatingRef.current) return
dragStartRef.current = { x: clientX, y: clientY } dragStartRef.current = { x: clientX, y: clientY }
isDraggingRef.current = false isDraggingRef.current = false
}, [isAnimating]) }, [isAnimatingRef])
// 触发无效操作动画 // 触发无效操作动画
const triggerInvalidAnimation = useCallback((direction: 'left' | 'right') => { const triggerInvalidAnimation = useCallback((direction: 'left' | 'right') => {
if (isAnimating) return if (isAnimatingRef.current) return
setIsAnimating(true) isAnimatingRef.current = true
// 模拟向该方向移动一点 // 模拟向该方向移动一点
setSwipeOffset(direction === 'left' ? -30 : 30) cardApi.start({ x: direction === 'left' ? -30 : 30, immediate: true })
setTimeout(() => { setTimeout(() => {
setSwipeOffset(0) cardApi.start({ x: 0 })
setTimeout(() => setIsAnimating(false), 300) setTimeout(() => { isAnimatingRef.current = false }, 300)
}, 150) }, 150)
}, [isAnimating]) }, [cardApi])
// 拖拽移动 // 拖拽移动
const handleDragMove = useCallback((clientX: number) => { const handleDragMove = useCallback((clientX: number) => {
if (!dragStartRef.current || isAnimating) return if (!dragStartRef.current || isAnimatingRef.current) return
const deltaX = clientX - dragStartRef.current.x const deltaX = clientX - dragStartRef.current.x
const currentExpr = quickExpressions[quickCurrentIndex] const currentExpr = quickExpressions[quickCurrentIndex]
@@ -376,42 +381,48 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
// 检查方向限制 // 检查方向限制
if (deltaX < 0 && !directions.left) { if (deltaX < 0 && !directions.left) {
setSwipeOffset(deltaX * 0.2) // 提供阻力反馈 cardApi.start({ x: deltaX * 0.2, immediate: true }) // 提供阻力反馈
setSwipeDirection(null) swipeOffsetRef.current = deltaX * 0.2
swipeDirectionRef.current = null
return return
} }
if (deltaX > 0 && !directions.right) { if (deltaX > 0 && !directions.right) {
setSwipeOffset(deltaX * 0.2) cardApi.start({ x: deltaX * 0.2, immediate: true })
setSwipeDirection(null) swipeOffsetRef.current = deltaX * 0.2
swipeDirectionRef.current = null
return return
} }
isDraggingRef.current = true 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) { if (Math.abs(deltaX) > 50) {
setSwipeDirection(deltaX > 0 ? 'right' : 'left') swipeDirectionRef.current = deltaX > 0 ? 'right' : 'left'
} else { } else {
setSwipeDirection(null) swipeDirectionRef.current = null
} }
}, [quickExpressions, quickCurrentIndex, getAllowedDirections, isAnimating]) }, [quickExpressions, quickCurrentIndex, getAllowedDirections, cardApi])
// 拖拽结束 // 拖拽结束
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
if (!dragStartRef.current) return if (!dragStartRef.current) return
const threshold = 100 const threshold = 100
if (Math.abs(swipeOffset) > threshold && swipeDirection) { const currentX = cardSpring.x.get()
handleQuickReview(swipeDirection === 'left') if (Math.abs(currentX) > threshold && swipeDirectionRef.current) {
handleQuickReview(swipeDirectionRef.current === 'left')
} else { } else {
// 回弹 // 回弹
setSwipeOffset(0) cardApi.start({ x: 0, rotate: 0, opacity: 1 })
setSwipeDirection(null) swipeOffsetRef.current = 0
swipeDirectionRef.current = null
} }
dragStartRef.current = null dragStartRef.current = null
isDraggingRef.current = false isDraggingRef.current = false
}, [swipeOffset, swipeDirection, handleQuickReview]) }, [cardSpring.x, handleQuickReview, cardApi])
// 鼠标事件处理 // 鼠标事件处理
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
@@ -463,7 +474,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
e.stopPropagation() e.stopPropagation()
e.stopImmediatePropagation() e.stopImmediatePropagation()
if (isAnimating || quickLoading) return if (isAnimatingRef.current || quickLoading) return
const currentExpr = quickExpressions[quickCurrentIndex] const currentExpr = quickExpressions[quickCurrentIndex]
const directions = getAllowedDirections(currentExpr) const directions = getAllowedDirections(currentExpr)
@@ -496,7 +507,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
// 使用 capture 模式,在事件到达 Tabs 之前拦截 // 使用 capture 模式,在事件到达 Tabs 之前拦截
window.addEventListener('keydown', handleKeyDown, true) window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('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(() => { useEffect(() => {
@@ -1406,7 +1417,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
<> <>
<div className={cn( <div className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-300', 'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-300',
swipeDirection === 'left' ? 'bg-red-500/20 text-red-500 scale-110' : 'bg-muted/50 text-muted-foreground opacity-0', swipeDirectionRef.current === 'left' ? 'bg-red-500/20 text-red-500 scale-110' : 'bg-muted/50 text-muted-foreground opacity-0',
!directions.left && 'invisible' !directions.left && 'invisible'
)}> )}>
<XCircle className="h-8 w-8" /> <XCircle className="h-8 w-8" />
@@ -1414,7 +1425,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
</div> </div>
<div className={cn( <div className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-300', 'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-300',
swipeDirection === 'right' ? 'bg-green-500/20 text-green-500 scale-110' : 'bg-muted/50 text-muted-foreground opacity-0', swipeDirectionRef.current === 'right' ? 'bg-green-500/20 text-green-500 scale-110' : 'bg-muted/50 text-muted-foreground opacity-0',
!directions.right && 'invisible' !directions.right && 'invisible'
)}> )}>
<span className="font-bold text-lg hidden sm:inline"></span> <span className="font-bold text-lg hidden sm:inline"></span>
@@ -1426,6 +1437,12 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
</div> </div>
{/* 堆叠卡片 */} {/* 堆叠卡片 */}
<div
className="relative w-full max-w-md h-[400px] flex items-center justify-center"
role="listbox"
aria-label="待审核的表达方式"
aria-activedescendant={quickExpressions[quickCurrentIndex] ? `quick-expr-${quickExpressions[quickCurrentIndex].id}` : undefined}
>
<div className="relative w-full max-w-md h-[400px] flex items-center justify-center"> <div className="relative w-full max-w-md h-[400px] flex items-center justify-center">
{quickExpressions {quickExpressions
.slice(quickCurrentIndex, quickCurrentIndex + 5) .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', 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 = {
...style, ...style,
transform: `translateX(${swipeOffset}px) rotate(${swipeOffset * 0.05}deg)`,
opacity: Math.max(0, 1 - Math.abs(swipeOffset) / 500),
cursor: 'grab', cursor: 'grab',
} }
} else { } 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) => { const getStyleForIndex = (i: number) => {
@@ -1487,27 +1503,30 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
} }
} }
return ( return isCurrent ? (
<div <animated.div
key={expr.id} key={expr.id}
ref={isCurrent ? cardRef : undefined} ref={cardRef}
role="option"
id={`quick-expr-${expr.id}`}
aria-selected={true}
className={cn( className={cn(
'bg-card border rounded-xl shadow-xl p-6 select-none h-full flex flex-col', 'bg-card border rounded-xl shadow-xl p-6 select-none h-full flex flex-col',
isCurrent && 'active:cursor-grabbing shadow-2xl ring-1 ring-border/50', 'active:cursor-grabbing shadow-2xl ring-1 ring-border/50',
// 冲突动效 // 冲突动效
isCurrent && conflictId === expr.id && 'ring-4 ring-orange-500/50 bg-orange-50/10' conflictId === expr.id && 'ring-4 ring-orange-500/50 bg-orange-50/10'
)} )}
style={style} style={{ ...style, ...cardSpring }}
onMouseDown={isCurrent ? handleMouseDown : undefined} onMouseDown={handleMouseDown}
onMouseMove={isCurrent ? handleMouseMove : undefined} onMouseMove={handleMouseMove}
onMouseUp={isCurrent ? handleMouseUp : undefined} onMouseUp={handleMouseUp}
onMouseLeave={isCurrent ? handleMouseLeave : undefined} onMouseLeave={handleMouseLeave}
onTouchStart={isCurrent ? handleTouchStart : undefined} onTouchStart={handleTouchStart}
onTouchMove={isCurrent ? handleTouchMove : undefined} onTouchMove={handleTouchMove}
onTouchEnd={isCurrent ? handleTouchEnd : undefined} onTouchEnd={handleTouchEnd}
> >
{/* 冲突提示遮罩 */} {/* 冲突提示遮罩 */}
{isCurrent && conflictId === expr.id && ( {conflictId === expr.id && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm animate-in fade-in duration-300 rounded-xl"> <div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm animate-in fade-in duration-300 rounded-xl">
<div className="relative"> <div className="relative">
<div className="absolute inset-0 bg-orange-500/20 rounded-full animate-ping" /> <div className="absolute inset-0 bg-orange-500/20 rounded-full animate-ping" />
@@ -1517,21 +1536,71 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
<p className="text-muted-foreground mt-2 animate-in slide-in-from-bottom-3 fade-in duration-700"></p> <p className="text-muted-foreground mt-2 animate-in slide-in-from-bottom-3 fade-in duration-700"></p>
</div> </div>
)} )}
{/* 无效操作提示 */} {/* 无效操作提示 */}
{isCurrent && ( <div className={cn(
<div className={cn( "absolute inset-0 flex items-center justify-center z-20 pointer-events-none transition-opacity duration-200",
"absolute inset-0 flex items-center justify-center z-20 pointer-events-none transition-opacity duration-200", ((swipeOffsetRef.current < -10 && !getAllowedDirections(expr).left) || (swipeOffsetRef.current > 10 && !getAllowedDirections(expr).right))
((swipeOffset < -10 && !getAllowedDirections(expr).left) || (swipeOffset > 10 && !getAllowedDirections(expr).right)) ? "opacity-100"
? "opacity-100" : "opacity-0"
: "opacity-0" )}>
)}> <div className="bg-background/80 backdrop-blur-sm p-4 rounded-full shadow-lg border border-border">
<div className="bg-background/80 backdrop-blur-sm p-4 rounded-full shadow-lg border border-border"> <Ban className="h-12 w-12 text-muted-foreground" />
<Ban className="h-12 w-12 text-muted-foreground" />
</div>
</div> </div>
)} </div>
{/* 内容区 */}
<div className="space-y-4 flex-1">
{/* 状态和ID */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground font-mono">#{expr.id}</span>
<div className="flex items-center gap-2">
{getStatusBadge(expr)}
{getModifierBadge(expr.modified_by)}
</div>
</div>
{/* 情景 */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></label>
<div className="p-3 bg-muted/30 rounded-lg border border-border/50">
<p className="text-lg font-medium leading-relaxed">{expr.situation}</p>
</div>
</div>
{/* 风格 */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></label>
<div className="flex flex-wrap gap-2">
{expr.style.split(/[,]/).map((s, i) => (
<Badge key={i} variant="secondary" className="font-normal">
{s.trim()}
</Badge>
))}
</div>
</div>
</div>
{/* 底部信息 */}
<div className="mt-auto pt-4 border-t flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<User className="h-3 w-3" />
</div>
<span title={getChatName(expr.chat_id)} className="truncate max-w-[120px] font-medium">
{getChatName(expr.chat_id)}
</span>
</div>
<span className="font-mono">{formatTime(expr.create_date)}</span>
</div>
</animated.div>
) : (
<div
key={expr.id}
role="option"
id={`quick-expr-${expr.id}`}
aria-selected={false}
className={cn(
'bg-card border rounded-xl shadow-xl p-6 select-none h-full flex flex-col'
)}
style={style}
>
{/* 内容区 */}
<div className="space-y-4 flex-1"> <div className="space-y-4 flex-1">
{/* 状态和ID */} {/* 状态和ID */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -1541,7 +1610,6 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
{getModifierBadge(expr.modified_by)} {getModifierBadge(expr.modified_by)}
</div> </div>
</div> </div>
{/* 情景 */} {/* 情景 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></label> <label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></label>
@@ -1549,7 +1617,6 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
<p className="text-lg font-medium leading-relaxed">{expr.situation}</p> <p className="text-lg font-medium leading-relaxed">{expr.situation}</p>
</div> </div>
</div> </div>
{/* 风格 */} {/* 风格 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></label> <label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></label>
@@ -1562,7 +1629,6 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
</div> </div>
</div> </div>
</div> </div>
{/* 底部信息 */} {/* 底部信息 */}
<div className="mt-auto pt-4 border-t flex items-center justify-between text-xs text-muted-foreground"> <div className="mt-auto pt-4 border-t flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -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' !directions.left ? 'opacity-30 cursor-not-allowed' : 'hover:bg-red-50 hover:text-red-600 hover:border-red-200'
)} )}
onClick={() => directions.left && handleQuickReview(true)} onClick={() => directions.left && handleQuickReview(true)}
disabled={!directions.left || isAnimating} disabled={!directions.left || isAnimatingRef.current}
> >
<XCircle className="h-8 w-8" /> <XCircle className="h-8 w-8" />
</Button> </Button>
@@ -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' !directions.right ? 'opacity-30 cursor-not-allowed' : 'hover:bg-green-50 hover:text-green-600 hover:border-green-200'
)} )}
onClick={() => directions.right && handleQuickReview(false)} onClick={() => directions.right && handleQuickReview(false)}
disabled={!directions.right || isAnimating} disabled={!directions.right || isAnimatingRef.current}
> >
<CheckCircle2 className="h-8 w-8" /> <CheckCircle2 className="h-8 w-8" />
</Button> </Button>

View File

@@ -35,7 +35,7 @@ interface HeaderProps {
export function Header({ export function Header({
sidebarOpen, sidebarOpen,
// mobileMenuOpen, // unused - kept in props for API compatibility mobileMenuOpen,
searchOpen, searchOpen,
actualTheme, actualTheme,
onSidebarToggle, onSidebarToggle,
@@ -67,6 +67,8 @@ export function Header({
{/* 移动端菜单按钮 */} {/* 移动端菜单按钮 */}
<button <button
onClick={onMobileMenuToggle} onClick={onMobileMenuToggle}
aria-label={t('a11y.closeMenu')}
aria-expanded={mobileMenuOpen}
className="rounded-lg p-2 hover:bg-accent lg:hidden" className="rounded-lg p-2 hover:bg-accent lg:hidden"
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
@@ -75,8 +77,9 @@ export function Header({
{/* 桌面端侧边栏收起/展开按钮 */} {/* 桌面端侧边栏收起/展开按钮 */}
<button <button
onClick={onSidebarToggle} onClick={onSidebarToggle}
aria-label={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
aria-expanded={sidebarOpen}
className="hidden rounded-lg p-2 hover:bg-accent lg:block" className="hidden rounded-lg p-2 hover:bg-accent lg:block"
title={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
> >
<ChevronLeft <ChevronLeft
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')} className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
@@ -120,9 +123,10 @@ export function Header({
{/* 搜索框 */} {/* 搜索框 */}
<button <button
onClick={() => onSearchOpenChange(true)} onClick={() => onSearchOpenChange(true)}
aria-label={t('header.searchPlaceholder')}
className="relative hidden md:flex items-center w-64 h-9 pl-9 pr-16 bg-background/50 border rounded-md hover:bg-accent/50 transition-colors text-left" className="relative hidden md:flex items-center w-64 h-9 pl-9 pr-16 bg-background/50 border rounded-md hover:bg-accent/50 transition-colors text-left"
> >
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<span className="text-sm text-muted-foreground">{t('header.searchPlaceholder')}</span> <span className="text-sm text-muted-foreground">{t('header.searchPlaceholder')}</span>
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2"> <Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
<span className="text-xs"></span>K <span className="text-xs"></span>K
@@ -179,8 +183,8 @@ export function Header({
const newTheme = actualTheme === 'dark' ? 'light' : 'dark' const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
toggleThemeWithTransition(newTheme, onThemeChange, e) toggleThemeWithTransition(newTheme, onThemeChange, e)
}} }}
aria-label={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')}
className="rounded-lg p-2 hover:bg-accent" className="rounded-lg p-2 hover:bg-accent"
title={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')}
> >
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />} {actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button> </button>

View File

@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useRouter } from '@tanstack/react-router'
import { BackgroundLayer } from '@/components/background-layer' import { BackgroundLayer } from '@/components/background-layer'
import { BackToTop } from '@/components/back-to-top' import { BackToTop } from '@/components/back-to-top'
import { HttpWarningBanner } from '@/components/http-warning-banner' 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 { TooltipProvider } from '@/components/ui/tooltip'
import { useTheme } from '@/components/use-theme' import { useTheme } from '@/components/use-theme'
import { useAuthGuard } from '@/hooks/use-auth' import { useAuthGuard } from '@/hooks/use-auth'
@@ -12,6 +15,7 @@ import { useBackground } from '@/hooks/use-background'
import { TitleBar } from '@/components/electron/TitleBar' import { TitleBar } from '@/components/electron/TitleBar'
import { isElectron } from '@/lib/runtime' import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { menuSections } from './constants'
import { Header } from './Header' import { Header } from './Header'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
import type { LayoutProps } from './types' import type { LayoutProps } from './types'
@@ -19,6 +23,8 @@ import type { LayoutProps } from './types'
export function Layout({ children }: LayoutProps) { export function Layout({ children }: LayoutProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { checking } = useAuthGuard() // 检查认证状态 const { checking } = useAuthGuard() // 检查认证状态
const router = useRouter()
const announce = useAnnounce()
const [sidebarOpen, setSidebarOpen] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(true)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
@@ -52,6 +58,41 @@ export function Layout({ children }: LayoutProps) {
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, []) }, [])
// 路由变更:焦点管理 + 屏幕阅读器播报 + document.title 更新
useEffect(() => {
// 构建 路径 -> 页面标题 的映射表(以当前语言 t() 翻译)
const pathToLabel: Record<string, string> = {}
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) { if (checking) {
@@ -74,8 +115,9 @@ export function Layout({ children }: LayoutProps) {
const pageBg = useBackground('page') const pageBg = useBackground('page')
return ( return (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
{isElectron() && <TitleBar />} <SkipNav />
{isElectron() && <TitleBar />}
<div className={cn('flex h-screen overflow-hidden', isElectron() && 'pt-8')}> <div className={cn('flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
{/* Sidebar */} {/* Sidebar */}
<Sidebar <Sidebar
@@ -88,11 +130,12 @@ export function Layout({ children }: LayoutProps) {
{/* Mobile overlay */} {/* Mobile overlay */}
{mobileMenuOpen && ( {mobileMenuOpen && (
<div <div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 lg:hidden" className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
/> />
)}
)}
{/* Main content */} {/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* HTTP 安全警告横幅 */} {/* HTTP 安全警告横幅 */}
@@ -111,7 +154,11 @@ export function Layout({ children }: LayoutProps) {
/> />
{/* Page content */} {/* Page content */}
<main className="relative flex-1 overflow-hidden bg-background"> <main
id="main-content"
tabIndex={-1}
className="relative flex-1 overflow-hidden bg-background outline-none"
>
<BackgroundLayer config={pageBg} layerId="page" /> <BackgroundLayer config={pageBg} layerId="page" />
{children} {children}
</main> </main>

View File

@@ -44,7 +44,9 @@ export function Sidebar({
"flex-1 overflow-x-hidden", "flex-1 overflow-x-hidden",
!sidebarOpen && "lg:w-16" !sidebarOpen && "lg:w-16"
)}> )}>
<nav className={cn( <nav
aria-label={t('a11y.sidebarNav')}
className={cn(
"p-4", "p-4",
!sidebarOpen && "lg:p-2 lg:w-16" !sidebarOpen && "lg:p-2 lg:w-16"
)}> )}>

View File

@@ -239,9 +239,10 @@ export function PluginStats({ pluginId, compact = false }: PluginStatsProps) {
{/* 评论 */} {/* 评论 */}
<div> <div>
<label className="text-sm font-medium mb-2 block"></label> <label htmlFor="plugin-rating-comment" className="text-sm font-medium mb-2 block"></label>
<Textarea <Textarea
value={userComment} value={userComment}
id="plugin-rating-comment"
onChange={(e) => setUserComment(e.target.value)} onChange={(e) => setUserComment(e.target.value)}
placeholder="分享你的使用体验..." placeholder="分享你的使用体验..."
rows={4} rows={4}

View File

@@ -36,6 +36,7 @@ const AlertTitle = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement> React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
// eslint-disable-next-line jsx-a11y/heading-has-content -- content passed via spread props
<h5 <h5
ref={ref} ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)} className={cn("mb-1 font-medium leading-none tracking-tight", className)}

View File

@@ -54,7 +54,7 @@ const DialogContent = React.forwardRef<
{!hideCloseButton && ( {!hideCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only"></span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>

View File

@@ -45,6 +45,7 @@ const PaginationLink = ({
size = "icon", size = "icon",
...props ...props
}: PaginationLinkProps) => ( }: PaginationLinkProps) => (
// eslint-disable-next-line jsx-a11y/anchor-has-content -- content passed via spread props
<a <a
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
className={cn( className={cn(

View File

@@ -93,6 +93,7 @@ const ToastClose = React.forwardRef<
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className className
)} )}
aria-label="关闭提示"
toast-close="" toast-close=""
{...props} {...props}
> >

View File

@@ -237,13 +237,15 @@ export function AuthPage() {
disabled={isValidating} disabled={isValidating}
autoFocus autoFocus
autoComplete="off" autoComplete="off"
aria-invalid={error ? true : undefined}
aria-describedby={error ? 'token-error' : undefined}
/> />
</div> </div>
</div> </div>
{/* 错误提示 */} {/* 错误提示 */}
{error && ( {error && (
<div className="flex items-center gap-2 rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/50 dark:text-red-400"> <div id="token-error" role="alert" className="flex items-center gap-2 rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/50 dark:text-red-400">
<AlertCircle className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" /> <AlertCircle className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
<span>{error}</span> <span>{error}</span>
</div> </div>

View File

@@ -23,7 +23,7 @@ export function ChatTabBar({
<div className="max-w-4xl mx-auto px-2 sm:px-4"> <div className="max-w-4xl mx-auto px-2 sm:px-4">
<div className="flex items-center gap-1 overflow-x-auto py-1.5 scrollbar-thin"> <div className="flex items-center gap-1 overflow-x-auto py-1.5 scrollbar-thin">
{tabs.map((tab) => ( {tabs.map((tab) => (
<div <button
key={tab.id} key={tab.id}
className={cn( className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors cursor-pointer", "flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors cursor-pointer",
@@ -32,6 +32,7 @@ export function ChatTabBar({
? "bg-background shadow-sm border" ? "bg-background shadow-sm border"
: "text-muted-foreground" : "text-muted-foreground"
)} )}
type="button"
onClick={() => onSwitch(tab.id)} onClick={() => onSwitch(tab.id)}
> >
{tab.type === 'webui' ? ( {tab.type === 'webui' ? (
@@ -62,7 +63,7 @@ export function ChatTabBar({
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</span> </span>
)} )}
</div> </button>
))} ))}
{/* 新建虚拟身份标签页按钮 */} {/* 新建虚拟身份标签页按钮 */}
<button <button

View File

@@ -39,6 +39,7 @@ export function RenderMessageSegment({ segment }: { segment: MessageSegment }) {
src={String(segment.data)} src={String(segment.data)}
className="max-w-[200px] h-8" className="max-w-[200px] h-8"
> >
<track kind="captions" src="" label="无字幕" default />
</audio> </audio>
</div> </div>
@@ -51,6 +52,7 @@ export function RenderMessageSegment({ segment }: { segment: MessageSegment }) {
src={String(segment.data)} src={String(segment.data)}
className="rounded-lg max-w-full max-h-64" className="rounded-lg max-w-full max-h-64"
> >
<track kind="captions" src="" label="无字幕" default />
</video> </video>
) )

View File

@@ -418,7 +418,7 @@ export function AdapterConfigPage() {
</CardHeader> </CardHeader>
<CollapsibleContent> <CollapsibleContent>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-4" role="radiogroup" aria-label="部署模式选择">
{/* 预设模式 */} {/* 预设模式 */}
<div <div
className={`border-2 rounded-lg p-3 md:p-4 cursor-pointer transition-all ${ className={`border-2 rounded-lg p-3 md:p-4 cursor-pointer transition-all ${
@@ -426,7 +426,11 @@ export function AdapterConfigPage() {
? 'border-primary bg-primary/5' ? 'border-primary bg-primary/5'
: 'border-muted hover:border-primary/50 active:border-primary/70' : 'border-muted hover:border-primary/50 active:border-primary/70'
}`} }`}
role="radio"
aria-checked={mode === 'preset'}
tabIndex={0}
onClick={() => handleModeChange('preset')} onClick={() => handleModeChange('preset')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('preset') } }}
> >
<div className="flex items-start gap-2 md:gap-3"> <div className="flex items-start gap-2 md:gap-3">
<Package className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" /> <Package className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" />
@@ -446,7 +450,11 @@ export function AdapterConfigPage() {
? 'border-primary bg-primary/5' ? 'border-primary bg-primary/5'
: 'border-muted hover:border-primary/50 active:border-primary/70' : 'border-muted hover:border-primary/50 active:border-primary/70'
}`} }`}
role="radio"
aria-checked={mode === 'upload'}
tabIndex={0}
onClick={() => handleModeChange('upload')} onClick={() => handleModeChange('upload')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('upload') } }}
> >
<div className="flex items-start gap-2 md:gap-3"> <div className="flex items-start gap-2 md:gap-3">
<Upload className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" /> <Upload className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" />
@@ -466,7 +474,11 @@ export function AdapterConfigPage() {
? 'border-primary bg-primary/5' ? 'border-primary bg-primary/5'
: 'border-muted hover:border-primary/50 active:border-primary/70' : 'border-muted hover:border-primary/50 active:border-primary/70'
}`} }`}
role="radio"
aria-checked={mode === 'path'}
tabIndex={0}
onClick={() => handleModeChange('path')} onClick={() => handleModeChange('path')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('path') } }}
> >
<div className="flex items-start gap-2 md:gap-3"> <div className="flex items-start gap-2 md:gap-3">
<FolderOpen className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" /> <FolderOpen className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" />
@@ -496,10 +508,14 @@ export function AdapterConfigPage() {
? 'border-primary bg-primary/5' ? 'border-primary bg-primary/5'
: 'border-muted hover:border-primary/50' : 'border-muted hover:border-primary/50'
}`} }`}
role="radio"
aria-checked={isSelected}
tabIndex={0}
onClick={() => { onClick={() => {
setSelectedPreset(key as PresetKey) setSelectedPreset(key as PresetKey)
handleLoadFromPreset(key as PresetKey) handleLoadFromPreset(key as PresetKey)
}} }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedPreset(key as PresetKey); handleLoadFromPreset(key as PresetKey) } }}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Icon className="h-5 w-5 mt-0.5 flex-shrink-0" /> <Icon className="h-5 w-5 mt-0.5 flex-shrink-0" />

View File

@@ -54,7 +54,7 @@ export const ModelTable = React.memo(function ModelTable({
return ( return (
<div className="hidden md:block rounded-lg border bg-card overflow-hidden"> <div className="hidden md:block rounded-lg border bg-card overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table aria-label="模型列表">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-12"> <TableHead className="w-12">

View File

@@ -209,10 +209,12 @@ export function ProviderForm({
} }
}} }}
placeholder="例如: DeepSeek, SiliconFlow" placeholder="例如: DeepSeek, SiliconFlow"
aria-invalid={formErrors.name ? true : undefined}
aria-describedby={formErrors.name ? 'name-error' : undefined}
className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''} className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
/> />
{formErrors.name && ( {formErrors.name && (
<p className="text-xs text-destructive">{formErrors.name}</p> <p id="name-error" role="alert" className="text-xs text-destructive">{formErrors.name}</p>
)} )}
</div> </div>
@@ -249,10 +251,12 @@ export function ProviderForm({
}} }}
placeholder="https://api.example.com/v1" placeholder="https://api.example.com/v1"
disabled={isUsingTemplate} disabled={isUsingTemplate}
aria-invalid={formErrors.base_url ? true : undefined}
aria-describedby={formErrors.base_url ? 'base-url-error' : undefined}
className={`${isUsingTemplate ? 'bg-muted cursor-not-allowed' : ''} ${formErrors.base_url ? 'border-destructive focus-visible:ring-destructive' : ''}`} className={`${isUsingTemplate ? 'bg-muted cursor-not-allowed' : ''} ${formErrors.base_url ? 'border-destructive focus-visible:ring-destructive' : ''}`}
/> />
{formErrors.base_url && ( {formErrors.base_url && (
<p className="text-xs text-destructive">{formErrors.base_url}</p> <p id="base-url-error" role="alert" className="text-xs text-destructive">{formErrors.base_url}</p>
)} )}
{isUsingTemplate && !formErrors.base_url && ( {isUsingTemplate && !formErrors.base_url && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -295,6 +299,8 @@ export function ProviderForm({
} }
}} }}
placeholder="sk-..." placeholder="sk-..."
aria-invalid={formErrors.api_key ? true : undefined}
aria-describedby={formErrors.api_key ? 'api-key-error' : undefined}
className={`flex-1 ${formErrors.api_key ? 'border-destructive focus-visible:ring-destructive' : ''}`} className={`flex-1 ${formErrors.api_key ? 'border-destructive focus-visible:ring-destructive' : ''}`}
/> />
<Button <Button
@@ -321,7 +327,7 @@ export function ProviderForm({
</Button> </Button>
</div> </div>
{formErrors.api_key && ( {formErrors.api_key && (
<p className="text-xs text-destructive">{formErrors.api_key}</p> <p id="api-key-error" role="alert" className="text-xs text-destructive">{formErrors.api_key}</p>
)} )}
</div> </div>

View File

@@ -170,7 +170,7 @@ export function ProviderList({
{/* 桌面端表格视图 */} {/* 桌面端表格视图 */}
<div className="hidden md:block rounded-lg border bg-card overflow-hidden"> <div className="hidden md:block rounded-lg border bg-card overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table aria-label="AI 模型提供商列表">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-12"> <TableHead className="w-12">

View File

@@ -400,7 +400,7 @@ export default function PackDetailPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table aria-label="API 提供商配置列表">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead></TableHead>
@@ -435,7 +435,7 @@ export default function PackDetailPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table aria-label="模型配置列表">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead></TableHead>

View File

@@ -52,6 +52,7 @@ import { RestartProvider, useRestart } from '@/lib/restart-context'
import { RestartOverlay } from '@/components/restart-overlay' import { RestartOverlay } from '@/components/restart-overlay'
import { ExpressionReviewer } from '@/components/expression-reviewer' import { ExpressionReviewer } from '@/components/expression-reviewer'
import { getReviewStats } from '@/lib/expression-api' import { getReviewStats } from '@/lib/expression-api'
import { ZoomableChart } from '@/components/ui/zoomable-chart'
// 主导出组件:包装 RestartProvider // 主导出组件:包装 RestartProvider
export function IndexPage() { export function IndexPage() {
@@ -736,6 +737,7 @@ function IndexPageContent() {
<CardDescription>{timeRange}</CardDescription> <CardDescription>{timeRange}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ZoomableChart aria-label="每小时请求量趋势图,显示最近若干小时的请求次数变化">
<ChartContainer config={chartConfig} className="h-[300px] sm:h-[400px] w-full aspect-auto"> <ChartContainer config={chartConfig} className="h-[300px] sm:h-[400px] w-full aspect-auto">
<LineChart data={hourly_data}> <LineChart data={hourly_data}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" /> <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
@@ -760,6 +762,7 @@ function IndexPageContent() {
/> />
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
</ZoomableChart>
</CardContent> </CardContent>
</Card> </Card>
@@ -770,6 +773,7 @@ function IndexPageContent() {
<CardDescription>API调用成本变化</CardDescription> <CardDescription>API调用成本变化</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ZoomableChart aria-label="API花费趋势图显示最近若干小时的API调用成本变化">
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto"> <ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
<BarChart data={hourly_data}> <BarChart data={hourly_data}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" /> <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
@@ -789,6 +793,7 @@ function IndexPageContent() {
<Bar dataKey="cost" fill="var(--color-cost)" /> <Bar dataKey="cost" fill="var(--color-cost)" />
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
</ZoomableChart>
</CardContent> </CardContent>
</Card> </Card>
@@ -798,6 +803,7 @@ function IndexPageContent() {
<CardDescription>Token使用量变化</CardDescription> <CardDescription>Token使用量变化</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ZoomableChart aria-label="Token消耗趋势图显示最近若干小时的Token使用量变化">
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto"> <ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
<BarChart data={hourly_data}> <BarChart data={hourly_data}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" /> <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
@@ -817,6 +823,7 @@ function IndexPageContent() {
<Bar dataKey="tokens" fill="var(--color-tokens)" /> <Bar dataKey="tokens" fill="var(--color-tokens)" />
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
</ZoomableChart>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -228,7 +228,10 @@ export function PlannerMonitor({ autoRefresh, refreshKey }: PlannerMonitorProps)
<div <div
key={chat.chat_id} key={chat.chat_id}
className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer" className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer"
role="button"
tabIndex={0}
onClick={() => handleChatClick(chat)} onClick={() => handleChatClick(chat)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleChatClick(chat) } }}
> >
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -327,7 +330,10 @@ export function PlannerMonitor({ autoRefresh, refreshKey }: PlannerMonitorProps)
<div <div
key={plan.filename} key={plan.filename}
className="border rounded-lg p-3 hover:bg-accent/50 transition-colors cursor-pointer" className="border rounded-lg p-3 hover:bg-accent/50 transition-colors cursor-pointer"
role="button"
tabIndex={0}
onClick={() => handleLogClick(plan.chat_id, plan.filename)} onClick={() => handleLogClick(plan.chat_id, plan.filename)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleLogClick(plan.chat_id, plan.filename) } }}
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">

View File

@@ -228,7 +228,10 @@ export function ReplierMonitor({ autoRefresh, refreshKey }: ReplierMonitorProps)
<div <div
key={chat.chat_id} key={chat.chat_id}
className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer" className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer"
role="button"
tabIndex={0}
onClick={() => handleChatClick(chat)} onClick={() => handleChatClick(chat)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleChatClick(chat) } }}
> >
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -327,7 +330,10 @@ export function ReplierMonitor({ autoRefresh, refreshKey }: ReplierMonitorProps)
<div <div
key={reply.filename} key={reply.filename}
className="border rounded-lg p-3 hover:bg-accent/50 transition-colors cursor-pointer" className="border rounded-lg p-3 hover:bg-accent/50 transition-colors cursor-pointer"
role="button"
tabIndex={0}
onClick={() => handleLogClick(reply.chat_id, reply.filename)} onClick={() => handleLogClick(reply.chat_id, reply.filename)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleLogClick(reply.chat_id, reply.filename) } }}
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">

View File

@@ -415,7 +415,7 @@ export function PersonManagementPage() {
<div className="rounded-lg border bg-card"> <div className="rounded-lg border bg-card">
{/* 桌面端表格视图 */} {/* 桌面端表格视图 */}
<div className="hidden md:block"> <div className="hidden md:block">
<Table> <Table aria-label="人物信息列表">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-12"> <TableHead className="w-12">

View File

@@ -924,7 +924,10 @@ function PluginConfigPageContent() {
<div <div
key={plugin.id} key={plugin.id}
className="flex items-center justify-between p-4 rounded-lg border hover:bg-muted/50 cursor-pointer transition-colors" className="flex items-center justify-between p-4 rounded-lg border hover:bg-muted/50 cursor-pointer transition-colors"
role="button"
tabIndex={0}
onClick={() => setSelectedPlugin(plugin)} onClick={() => setSelectedPlugin(plugin)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedPlugin(plugin) } }}
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0"> <div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">

View File

@@ -302,7 +302,7 @@ export function PluginMirrorsPage() {
<Card> <Card>
{/* 桌面端表格 */} {/* 桌面端表格 */}
<div className="hidden md:block"> <div className="hidden md:block">
<Table> <Table aria-label="插件镜像源列表">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead></TableHead>

View File

@@ -74,6 +74,7 @@ export function InstallDialog({ open, plugin, onOpenChange, onInstall }: Install
{showAdvancedOptions && ( {showAdvancedOptions && (
<div className="space-y-4 p-4 border rounded-lg"> <div className="space-y-4 p-4 border rounded-lg">
<div className="space-y-2"> <div className="space-y-2">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- section heading above Tabs, not a form label */}
<label className="text-sm font-medium"></label> <label className="text-sm font-medium"></label>
<Tabs value={branchInputMode} onValueChange={(value) => setBranchInputMode(value as 'preset' | 'custom')}> <Tabs value={branchInputMode} onValueChange={(value) => setBranchInputMode(value as 'preset' | 'custom')}>

View File

@@ -773,6 +773,14 @@ export function EmojiUploadDialog({
<div <div
key={file.id} key={file.id}
onClick={() => setSelectedFileId(file.id)} onClick={() => setSelectedFileId(file.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedFileId(file.id) } }}
className={`
flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all
${isSelected ? 'ring-2 ring-primary' : ''}
${complete ? 'border-green-500 bg-green-50 dark:bg-green-950/20' : 'border-border hover:border-muted-foreground/50'}
`}
className={` className={`
flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all
${isSelected ? 'ring-2 ring-primary' : ''} ${isSelected ? 'ring-2 ring-primary' : ''}

View File

@@ -76,7 +76,10 @@ export function EmojiList({
? 'ring-2 ring-primary bg-primary/5' ? 'ring-2 ring-primary bg-primary/5'
: '' : ''
}`} }`}
role="button"
tabIndex={0}
onClick={() => onToggleSelect(emoji.id)} onClick={() => onToggleSelect(emoji.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleSelect(emoji.id) } }}
> >
{/* 选中指示器 */} {/* 选中指示器 */}
<div <div

View File

@@ -74,7 +74,7 @@ export function ExpressionList({
<div className="rounded-lg border bg-card"> <div className="rounded-lg border bg-card">
{/* 桌面端表格视图 */} {/* 桌面端表格视图 */}
<div className="hidden md:block"> <div className="hidden md:block">
<Table> <Table aria-label="表达方式列表">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-12"> <TableHead className="w-12">

View File

@@ -74,7 +74,7 @@ export function JargonList({
<div className="rounded-lg border bg-card"> <div className="rounded-lg border bg-card">
{/* 桁面端表格视图 */} {/* 桁面端表格视图 */}
<div className="hidden md:block"> <div className="hidden md:block">
<Table> <Table aria-label="黑话列表">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-12"> <TableHead className="w-12">

View File

@@ -28,7 +28,7 @@ export function NodeDetailDialog({ open, onOpenChange, selectedNodeData }: NodeD
<div className="space-y-4 pb-2"> <div className="space-y-4 pb-2">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-sm font-medium text-muted-foreground"></label> <p className="text-sm font-medium text-muted-foreground"></p>
<div className="mt-1"> <div className="mt-1">
<Badge variant={selectedNodeData.type === 'entity' ? 'default' : 'secondary'}> <Badge variant={selectedNodeData.type === 'entity' ? 'default' : 'secondary'}>
{selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'} {selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'}
@@ -38,14 +38,14 @@ export function NodeDetailDialog({ open, onOpenChange, selectedNodeData }: NodeD
</div> </div>
<div> <div>
<label className="text-sm font-medium text-muted-foreground">ID</label> <p className="text-sm font-medium text-muted-foreground">ID</p>
<code className="mt-1 block p-2 bg-muted rounded text-xs break-all"> <code className="mt-1 block p-2 bg-muted rounded text-xs break-all">
{selectedNodeData.id} {selectedNodeData.id}
</code> </code>
</div> </div>
<div> <div>
<label className="text-sm font-medium text-muted-foreground"></label> <p className="text-sm font-medium text-muted-foreground"></p>
<div className="mt-1 p-3 bg-muted rounded border"> <div className="mt-1 p-3 bg-muted rounded border">
<p className="text-sm whitespace-pre-wrap break-words">{selectedNodeData.content}</p> <p className="text-sm whitespace-pre-wrap break-words">{selectedNodeData.content}</p>
</div> </div>
@@ -106,7 +106,7 @@ export function EdgeDetailDialog({ open, onOpenChange, selectedEdgeData }: EdgeD
</div> </div>
<div> <div>
<label className="text-sm font-medium text-muted-foreground"></label> <p className="text-sm font-medium text-muted-foreground"></p>
<div className="mt-1"> <div className="mt-1">
<Badge variant="outline" className="text-base font-mono"> <Badge variant="outline" className="text-base font-mono">
{selectedEdgeData.edge.weight.toFixed(4)} {selectedEdgeData.edge.weight.toFixed(4)}

View File

@@ -130,53 +130,67 @@ export function GraphVisualization({ graphData, onNodeClick, onEdgeClick, loadin
} }
return ( return (
<ReactFlow <div
nodes={nodes} style={{ touchAction: 'none' }}
edges={edges} role="img"
onNodesChange={onNodesChange} aria-label={`知识图谱可视化,共 ${nodeCount} 个节点,${edges.length} 条关系`}
onEdgesChange={onEdgesChange} className="w-full h-full"
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
nodeTypes={nodeTypes}
fitView
minZoom={0.05}
maxZoom={1.5}
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
elevateNodesOnSelect={nodeCount <= 500}
nodesDraggable={nodeCount <= 1000}
attributionPosition="bottom-left"
> >
<Background variant={BackgroundVariant.Dots} gap={12} size={1} /> <span className="sr-only">
<Controls /> {`知识图谱包含 ${nodeCount} 个节点和 ${edges.length} 条关系。`}
{nodeCount <= 500 && ( </span>
<MiniMap <ReactFlow
nodeColor={miniMapNodeColor} nodes={nodes}
nodeBorderRadius={8} edges={edges}
pannable onNodesChange={onNodesChange}
zoomable onEdgesChange={onEdgesChange}
/> onNodeClick={onNodeClick}
)} onEdgeClick={onEdgeClick}
nodeTypes={nodeTypes}
fitView
minZoom={0.05}
maxZoom={1.5}
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
elevateNodesOnSelect={nodeCount <= 500}
nodesDraggable={nodeCount <= 1000}
attributionPosition="bottom-left"
panOnScroll
panOnScrollMode={undefined}
panOnDrag
zoomOnPinch
>
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
<Controls />
{nodeCount <= 500 && (
<MiniMap
nodeColor={miniMapNodeColor}
nodeBorderRadius={8}
pannable
zoomable
/>
)}
<Panel position="top-right" className="bg-background/95 backdrop-blur-sm rounded-lg border p-3 shadow-lg"> <Panel position="top-right" className="bg-background/95 backdrop-blur-sm rounded-lg border p-3 shadow-lg">
<div className="text-sm font-semibold mb-2"></div> <div className="text-sm font-semibold mb-2"></div>
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700" /> <div className="w-4 h-4 rounded bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700" aria-hidden="true" />
<span></span> <span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700" />
<span></span>
</div>
{nodeCount > 200 && (
<div className="mt-2 pt-2 border-t text-yellow-600 dark:text-yellow-500">
<div className="font-semibold"></div>
<div></div>
{nodeCount > 500 && <div></div>}
</div> </div>
)} <div className="flex items-center gap-2">
</div> <div className="w-4 h-4 rounded bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700" aria-hidden="true" />
</Panel> <span></span>
</ReactFlow> </div>
{nodeCount > 200 && (
<div className="mt-2 pt-2 border-t text-yellow-600 dark:text-yellow-500">
<div className="font-semibold"></div>
<div></div>
{nodeCount > 500 && <div></div>}
</div>
)}
</div>
</Panel>
</ReactFlow>
</div>
) )
} }