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

@@ -237,13 +237,15 @@ export function AuthPage() {
disabled={isValidating}
autoFocus
autoComplete="off"
aria-invalid={error ? true : undefined}
aria-describedby={error ? 'token-error' : undefined}
/>
</div>
</div>
{/* 错误提示 */}
{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" />
<span>{error}</span>
</div>

View File

@@ -23,7 +23,7 @@ export function ChatTabBar({
<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">
{tabs.map((tab) => (
<div
<button
key={tab.id}
className={cn(
"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"
: "text-muted-foreground"
)}
type="button"
onClick={() => onSwitch(tab.id)}
>
{tab.type === 'webui' ? (
@@ -62,7 +63,7 @@ export function ChatTabBar({
<X className="h-3 w-3" />
</span>
)}
</div>
</button>
))}
{/* 新建虚拟身份标签页按钮 */}
<button

View File

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

View File

@@ -418,7 +418,7 @@ export function AdapterConfigPage() {
</CardHeader>
<CollapsibleContent>
<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
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-muted hover:border-primary/50 active:border-primary/70'
}`}
role="radio"
aria-checked={mode === 'preset'}
tabIndex={0}
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">
<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-muted hover:border-primary/50 active:border-primary/70'
}`}
role="radio"
aria-checked={mode === 'upload'}
tabIndex={0}
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">
<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-muted hover:border-primary/50 active:border-primary/70'
}`}
role="radio"
aria-checked={mode === 'path'}
tabIndex={0}
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">
<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-muted hover:border-primary/50'
}`}
role="radio"
aria-checked={isSelected}
tabIndex={0}
onClick={() => {
setSelectedPreset(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">
<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 (
<div className="hidden md:block rounded-lg border bg-card overflow-hidden">
<div className="overflow-x-auto">
<Table>
<Table aria-label="模型列表">
<TableHeader>
<TableRow>
<TableHead className="w-12">

View File

@@ -209,10 +209,12 @@ export function ProviderForm({
}
}}
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' : ''}
/>
{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>
@@ -249,10 +251,12 @@ export function ProviderForm({
}}
placeholder="https://api.example.com/v1"
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' : ''}`}
/>
{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 && (
<p className="text-xs text-muted-foreground">
@@ -295,6 +299,8 @@ export function ProviderForm({
}
}}
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' : ''}`}
/>
<Button
@@ -321,7 +327,7 @@ export function ProviderForm({
</Button>
</div>
{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>

View File

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

View File

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

View File

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

View File

@@ -228,7 +228,10 @@ export function PlannerMonitor({ autoRefresh, refreshKey }: PlannerMonitorProps)
<div
key={chat.chat_id}
className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer"
role="button"
tabIndex={0}
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-center gap-2">
@@ -327,7 +330,10 @@ export function PlannerMonitor({ autoRefresh, refreshKey }: PlannerMonitorProps)
<div
key={plan.filename}
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)}
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">
<span className="text-sm font-medium text-muted-foreground">

View File

@@ -228,7 +228,10 @@ export function ReplierMonitor({ autoRefresh, refreshKey }: ReplierMonitorProps)
<div
key={chat.chat_id}
className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer"
role="button"
tabIndex={0}
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-center gap-2">
@@ -327,7 +330,10 @@ export function ReplierMonitor({ autoRefresh, refreshKey }: ReplierMonitorProps)
<div
key={reply.filename}
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)}
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">
<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="hidden md:block">
<Table>
<Table aria-label="人物信息列表">
<TableHeader>
<TableRow>
<TableHead className="w-12">

View File

@@ -924,7 +924,10 @@ function PluginConfigPageContent() {
<div
key={plugin.id}
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)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedPlugin(plugin) } }}
>
<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">

View File

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

View File

@@ -74,6 +74,7 @@ export function InstallDialog({ open, plugin, onOpenChange, onInstall }: Install
{showAdvancedOptions && (
<div className="space-y-4 p-4 border rounded-lg">
<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>
<Tabs value={branchInputMode} onValueChange={(value) => setBranchInputMode(value as 'preset' | 'custom')}>

View File

@@ -773,6 +773,14 @@ export function EmojiUploadDialog({
<div
key={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={`
flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all
${isSelected ? 'ring-2 ring-primary' : ''}

View File

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

View File

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

View File

@@ -74,7 +74,7 @@ export function JargonList({
<div className="rounded-lg border bg-card">
{/* 桁面端表格视图 */}
<div className="hidden md:block">
<Table>
<Table aria-label="黑话列表">
<TableHeader>
<TableRow>
<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="grid grid-cols-2 gap-4">
<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">
<Badge variant={selectedNodeData.type === 'entity' ? 'default' : 'secondary'}>
{selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'}
@@ -38,14 +38,14 @@ export function NodeDetailDialog({ open, onOpenChange, selectedNodeData }: NodeD
</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">
{selectedNodeData.id}
</code>
</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">
<p className="text-sm whitespace-pre-wrap break-words">{selectedNodeData.content}</p>
</div>
@@ -106,7 +106,7 @@ export function EdgeDetailDialog({ open, onOpenChange, selectedEdgeData }: EdgeD
</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">
<Badge variant="outline" className="text-base font-mono">
{selectedEdgeData.edge.weight.toFixed(4)}

View File

@@ -130,53 +130,67 @@ export function GraphVisualization({ graphData, onNodeClick, onEdgeClick, loadin
}
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
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"
<div
style={{ touchAction: 'none' }}
role="img"
aria-label={`知识图谱可视化,共 ${nodeCount} 个节点,${edges.length} 条关系`}
className="w-full h-full"
>
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
<Controls />
{nodeCount <= 500 && (
<MiniMap
nodeColor={miniMapNodeColor}
nodeBorderRadius={8}
pannable
zoomable
/>
)}
<span className="sr-only">
{`知识图谱包含 ${nodeCount} 个节点和 ${edges.length} 条关系。`}
</span>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
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">
<div className="text-sm font-semibold mb-2"></div>
<div className="space-y-2 text-xs">
<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" />
<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>}
<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="space-y-2 text-xs">
<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" aria-hidden="true" />
<span></span>
</div>
)}
</div>
</Panel>
</ReactFlow>
<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" aria-hidden="true" />
<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>
</Panel>
</ReactFlow>
</div>
)
}