feat(a11y): apply ARIA roles, landmarks, focus management, touch targets and contrast fixes across components
This commit is contained in:
@@ -34,6 +34,7 @@ export function TitleBar() {
|
||||
onClick={minimize}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
aria-label="最小化"
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -42,6 +43,7 @@ export function TitleBar() {
|
||||
onClick={toggleMaximize}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
aria-label={isMaximized ? "还原窗口" : "最大化"}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
@@ -54,6 +56,7 @@ export function TitleBar() {
|
||||
onClick={close}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
aria-label="关闭窗口"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(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
|
||||
<>
|
||||
<div className={cn(
|
||||
'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'
|
||||
)}>
|
||||
<XCircle className="h-8 w-8" />
|
||||
@@ -1414,7 +1425,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
</div>
|
||||
<div className={cn(
|
||||
'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'
|
||||
)}>
|
||||
<span className="font-bold text-lg hidden sm:inline">通过</span>
|
||||
@@ -1426,6 +1437,12 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
</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">
|
||||
{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 (
|
||||
<div
|
||||
return isCurrent ? (
|
||||
<animated.div
|
||||
key={expr.id}
|
||||
ref={isCurrent ? cardRef : undefined}
|
||||
ref={cardRef}
|
||||
role="option"
|
||||
id={`quick-expr-${expr.id}`}
|
||||
aria-selected={true}
|
||||
className={cn(
|
||||
'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}
|
||||
onMouseDown={isCurrent ? handleMouseDown : undefined}
|
||||
onMouseMove={isCurrent ? handleMouseMove : undefined}
|
||||
onMouseUp={isCurrent ? handleMouseUp : undefined}
|
||||
onMouseLeave={isCurrent ? handleMouseLeave : undefined}
|
||||
onTouchStart={isCurrent ? handleTouchStart : undefined}
|
||||
onTouchMove={isCurrent ? handleTouchMove : undefined}
|
||||
onTouchEnd={isCurrent ? handleTouchEnd : undefined}
|
||||
style={{ ...style, ...cardSpring }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
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="relative">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无效操作提示 */}
|
||||
{isCurrent && (
|
||||
<div className={cn(
|
||||
"absolute inset-0 flex items-center justify-center z-20 pointer-events-none transition-opacity duration-200",
|
||||
((swipeOffset < -10 && !getAllowedDirections(expr).left) || (swipeOffset > 10 && !getAllowedDirections(expr).right))
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}>
|
||||
<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" />
|
||||
</div>
|
||||
<div className={cn(
|
||||
"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))
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}>
|
||||
<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" />
|
||||
</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">
|
||||
{/* 状态和ID */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1541,7 +1610,6 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
{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>
|
||||
@@ -1549,7 +1617,6 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
<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>
|
||||
@@ -1562,7 +1629,6 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
</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">
|
||||
@@ -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}
|
||||
>
|
||||
<XCircle className="h-8 w-8" />
|
||||
</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'
|
||||
)}
|
||||
onClick={() => directions.right && handleQuickReview(false)}
|
||||
disabled={!directions.right || isAnimating}
|
||||
disabled={!directions.right || isAnimatingRef.current}
|
||||
>
|
||||
<CheckCircle2 className="h-8 w-8" />
|
||||
</Button>
|
||||
|
||||
@@ -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({
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
onClick={onMobileMenuToggle}
|
||||
aria-label={t('a11y.closeMenu')}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
className="rounded-lg p-2 hover:bg-accent lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
@@ -75,8 +77,9 @@ export function Header({
|
||||
{/* 桌面端侧边栏收起/展开按钮 */}
|
||||
<button
|
||||
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"
|
||||
title={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
|
||||
@@ -120,9 +123,10 @@ export function Header({
|
||||
{/* 搜索框 */}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<span className="text-xs">⌘</span>K
|
||||
@@ -179,8 +183,8 @@ export function Header({
|
||||
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
|
||||
toggleThemeWithTransition(newTheme, onThemeChange, e)
|
||||
}}
|
||||
aria-label={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')}
|
||||
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" />}
|
||||
</button>
|
||||
|
||||
@@ -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<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) {
|
||||
@@ -74,8 +115,9 @@ export function Layout({ children }: LayoutProps) {
|
||||
const pageBg = useBackground('page')
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
{isElectron() && <TitleBar />}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<SkipNav />
|
||||
{isElectron() && <TitleBar />}
|
||||
<div className={cn('flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
@@ -88,11 +130,12 @@ export function Layout({ children }: LayoutProps) {
|
||||
{/* Mobile overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
)}
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* HTTP 安全警告横幅 */}
|
||||
@@ -111,7 +154,11 @@ export function Layout({ children }: LayoutProps) {
|
||||
/>
|
||||
|
||||
{/* 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" />
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@@ -44,7 +44,9 @@ export function Sidebar({
|
||||
"flex-1 overflow-x-hidden",
|
||||
!sidebarOpen && "lg:w-16"
|
||||
)}>
|
||||
<nav className={cn(
|
||||
<nav
|
||||
aria-label={t('a11y.sidebarNav')}
|
||||
className={cn(
|
||||
"p-4",
|
||||
!sidebarOpen && "lg:p-2 lg:w-16"
|
||||
)}>
|
||||
|
||||
@@ -239,9 +239,10 @@ export function PluginStats({ pluginId, compact = false }: PluginStatsProps) {
|
||||
|
||||
{/* 评论 */}
|
||||
<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
|
||||
value={userComment}
|
||||
id="plugin-rating-comment"
|
||||
onChange={(e) => setUserComment(e.target.value)}
|
||||
placeholder="分享你的使用体验..."
|
||||
rows={4}
|
||||
|
||||
@@ -36,6 +36,7 @@ const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
// eslint-disable-next-line jsx-a11y/heading-has-content -- content passed via spread props
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
|
||||
@@ -54,7 +54,7 @@ const DialogContent = React.forwardRef<
|
||||
{!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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">关闭</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -45,6 +45,7 @@ const PaginationLink = ({
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content -- content passed via spread props
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
|
||||
@@ -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",
|
||||
className
|
||||
)}
|
||||
aria-label="关闭提示"
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user