# 无障碍审计报告 (A11y Audit) > 审计日期:2026-06-17 > 审计范围:`src/shared/` 核心组件与新增无障碍工具 > 合规目标:WCAG 2.1 AA --- ## 一、已审计组件与 ARIA 改进 ### 1. 新增无障碍工具库 | 文件 | 导出 | 用途 | |------|------|------| | `src/shared/lib/a11y.ts` | `useA11yId` | 基于 `React.useId` 生成 SSR 安全的唯一 ID,用于 `aria-describedby`、`aria-labelledby` | | `src/shared/lib/a11y.ts` | `mergeA11yProps` | 合并多组 aria/data 属性,`aria-*`/`data-*` 字符串属性以空格拼接 | | `src/shared/lib/a11y.ts` | `describeInput` | 计算输入框的 `aria-describedby` 与 `aria-invalid` | | `src/shared/lib/a11y.ts` | `loadingAria` | 提供加载状态的 `aria-busy` 与 `aria-live` 属性 | ### 2. 新增 Hook | 文件 | 导出 | 用途 | |------|------|------| | `src/shared/hooks/use-aria-live.ts` | `useAriaLive` | 管理 aria-live 区域,支持 polite/assertive 通知,自动清除过期通知(默认 5s),返回 `{ announce, liveRegion }` | ### 3. 新增 a11y 组件 | 文件 | 组件 | 用途 | |------|------|------| | `src/shared/components/a11y/skip-link.tsx` | `SkipLink` | 跳转链接,视觉隐藏,获得焦点时高对比度显示,默认跳转 `#main-content` | | `src/shared/components/a11y/visually-hidden.tsx` | `VisuallyHidden` | 视觉隐藏但屏幕阅读器可读,用于图标按钮文字描述、表单辅助说明 | | `src/shared/components/a11y/focus-trap.tsx` | `FocusTrap` | 焦点陷阱,捕获 Tab/Shift+Tab 循环,支持初始焦点与焦点恢复 | | `src/shared/components/a11y/aria-status.tsx` | `AriaStatus` | ARIA 状态通知区域,渲染 `aria-live` 区域,支持 polite/assertive | ### 4. 增强的核心 UI 组件 #### `src/shared/components/ui/table.tsx` | 组件 | ARIA 改进 | |------|-----------| | `Table` | 默认 `role="table"`(可覆盖),支持 `aria-rowcount`、`aria-colcount` | | `TableHeader` | 默认 `role="rowgroup"` | | `TableBody` | 默认 `role="rowgroup"` | | `TableFooter` | 默认 `role="rowgroup"` | | `TableRow` | 默认 `role="row"` | | `TableHead` | 默认 `role="columnheader"`,支持 `scope` 属性(`col`/`row`/`colgroup`/`rowgroup`) | | `TableCell` | 默认 `role="cell"` | | `TableCaption` | 已有 `` 元素,为表格提供可访问标题 | 所有 `role` 均为默认值,可通过 props 覆盖,**完全向后兼容**。 #### `src/shared/components/ui/dialog.tsx` | 改进项 | 说明 | |--------|------| | `aria-modal="true"` | 显式添加到 `DialogContent`(Radix 已内置,此处显式标注便于审计) | | 关闭按钮 `aria-label="关闭"` | 添加明确的中文无障碍标签 | | 关闭按钮 sr-only 文本 | 由 "Close" 改为 "关闭",与项目语言一致 | | 焦点管理 | Radix Dialog 原语已内置:打开时焦点移入内容区,关闭时恢复到触发元素 | | Esc 键关闭 | Radix Dialog 原语已内置 | | `aria-labelledby` | Radix 自动关联 `DialogTitle` 的 id 到 `aria-labelledby` | --- ## 二、待改进项 | 优先级 | 项目 | 说明 | |--------|------|------| | 高 | 表单组件 `aria-describedby` 关联 | `Input`、`Textarea`、`Select` 等需配合 `describeInput` 工具函数,将错误提示和帮助文本的 id 关联到输入框 | | 高 | 图标按钮 `aria-label` | 全项目排查仅含图标无文字的按钮,补充 `aria-label` 或使用 `VisuallyHidden` | | 中 | `Sheet`/`AlertDialog` 焦点管理 | 参照 `Dialog` 增强,显式添加 `aria-modal` 和中文关闭标签 | | 中 | 数据表格 `aria-rowcount`/`aria-colcount` | 在使用 `@tanstack/react-table` 的页面中,为 `Table` 传入总行数和列数 | | 中 | 面包屑 `aria-label="面包屑导航"` | `Breadcrumb` 容器添加 `nav` 的 `aria-label` | | 中 | 分页组件 `aria-label` | 分页导航添加 `aria-label="分页"`,当前页使用 `aria-current="page"` | | 低 | 动态内容变更播报 | 在表单提交、数据加载场景接入 `useAriaLive` 进行状态播报 | | 低 | 颜色对比度审查 | 使用 axe DevTools 全量扫描颜色对比度是否达到 4.5:1(正文)/ 3:1(大文字) | | 低 | 跳转链接全局应用 | 将 `app/(dashboard)/layout.tsx` 中的内联 skip-link 替换为 `SkipLink` 组件 | --- ## 三、屏幕阅读器测试指南 ### NVDA(Windows,免费) 1. **安装**:从 [nvaccess.org](https://www.nvaccess.org/) 下载安装 2. **启动/退出**:`Ctrl + Alt + N` 启动,`Insert + Q` 退出 3. **核心快捷键**: - `↓` / `↑`:逐行阅读 - `Tab` / `Shift + Tab`:在可聚焦元素间移动 - `H`:按标题跳转 - `T`:跳转到表格 - `F`:跳转到表单控件 - `B`:跳转到按钮 - `Insert + Tab`:播报当前焦点元素 - `Insert + Space`:切换浏览/焦点模式 4. **测试要点**: - 打开页面后 Tab 到 SkipLink,确认可跳转到主内容区 - Tab 遍历所有交互元素,确认每个元素有可读的名称 - 打开 Dialog,确认焦点移入对话框、Esc 可关闭、关闭后焦点回到触发按钮 - 在表格中按 `T` 跳转,确认表格标题和行列关系正确播报 ### VoiceOver(macOS,内置) 1. **启动/退出**:`Cmd + F5` 2. **核心快捷键**: - `Ctrl + Option + →` / `←`:逐元素导航 - `Ctrl + Option + Cmd + H`:按标题跳转 - `Ctrl + Option + Cmd + T`:跳转到表格 - `Ctrl + Option + Space`:激活当前元素 - `Ctrl + Option + U`:打开转子(Rotor)按元素类型浏览 3. **测试要点**: - 确认 SkipLink 获得焦点时高对比度显示 - 确认 `aria-live` 区域在表单提交后播报结果 - 确认 `VisuallyHidden` 内容被播报但不可见 - 确认 Dialog 打开时 VoiceOver 朗读对话框标题 ### 通用测试清单 - [ ] 所有交互元素可通过键盘访问(Tab/Shift+Tab/Enter/Space/Esc) - [ ] 焦点顺序符合视觉阅读顺序 - [ ] 焦点可见(focus 样式清晰) - [ ] 每个交互元素有可访问名称(`aria-label` 或可见文字) - [ ] 表单错误信息通过 `aria-live` 或 `aria-describedby` 播报 - [ ] 加载状态通过 `aria-busy` 或 `aria-live` 播报 - [ ] 模态框打开时焦点被困在框内,关闭后恢复 --- ## 四、WCAG 2.1 AA 合规检查清单 ### 原则一:可感知 (Perceivable) | 准则 | 状态 | 说明 | |------|------|------| | 1.1.1 非文本内容 | ✅ | 图标按钮通过 `aria-label` 或 `VisuallyHidden` 提供文字替代 | | 1.2.1 纯音频/视频 | ⚠️ | 项目暂无音视频内容,后续如需添加需提供字幕/文字稿 | | 1.3.1 信息与关系 | ✅ | 表格通过 `role` 和 `scope` 表达行列关系;表单通过 `aria-describedby` 关联说明 | | 1.3.2 有意义的顺序 | ✅ | DOM 顺序与视觉顺序一致 | | 1.3.3 感官特征 | ✅ | 不仅依赖颜色/位置传达信息,配合文字说明 | | 1.3.4 方向 | ✅ | 不限制屏幕方向 | | 1.4.1 颜色的使用 | ✅ | 错误状态除颜色外配合文字/图标 | | 1.4.3 对比度(最低) | ⚠️ | 需全量审查,语义色 `muted-foreground` 需确认对比度 ≥ 4.5:1 | | 1.4.4 文字缩放 | ✅ | 使用 `rem`/`em` 单位,支持 200% 缩放 | | 1.4.10 回流 | ✅ | 响应式布局,支持 320px 宽度 | | 1.4.11 非文字对比度 | ✅ | 边框、焦点环使用语义色,对比度 ≥ 3:1 | ### 原则二:可操作 (Operable) | 准则 | 状态 | 说明 | |------|------|------| | 2.1.1 键盘 | ✅ | 所有交互可通过键盘操作 | | 2.1.2 无键盘陷阱 | ✅ | `FocusTrap` 仅在模态框激活时使用,Esc 可退出 | | 2.1.4 字符快捷键 | ✅ | 无单字符快捷键 | | 2.2.1 计时可调 | ✅ | 无超时限制(会话超时由 NextAuth 管理,可延长) | | 2.3.1 三次闪烁 | ✅ | 无闪烁内容 | | 2.4.1 跳过区块 | ✅ | `SkipLink` 组件提供跳转到主内容 | | 2.4.2 页面标题 | ✅ | Next.js metadata 提供页面标题 | | 2.4.3 焦点顺序 | ✅ | DOM 顺序符合逻辑 | | 2.4.4 链接目的 | ✅ | 链接文字描述目的,避免"点击这里" | | 2.4.6 标题与标签 | ✅ | 表单字段使用 `Label` 组件关联 | | 2.4.7 焦点可见 | ✅ | 所有交互元素有 `focus:ring` 样式 | | 2.5.3 标签包含名称 | ✅ | 可见标签文字包含在可访问名称中 | ### 原则三:可理解 (Understandable) | 准则 | 状态 | 说明 | |------|------|------| | 3.1.1 页面语言 | ✅ | `` | | 3.1.2 部分语言 | ✅ | 暂无混语言内容 | | 3.2.1 聚焦 | ✅ | 聚焦不触发意外上下文变更 | | 3.2.2 输入 | ✅ | 表单提交需明确按钮触发 | | 3.2.3 一致导航 | ✅ | 侧边栏导航在页面间一致 | | 3.2.4 一致标识 | ✅ | 功能相同的组件使用一致标识 | | 3.3.1 错误识别 | ✅ | 表单错误通过 `aria-invalid` 和 `aria-describedby` 播报 | | 3.3.2 标签或说明 | ✅ | 表单字段使用 `Label` 关联,提供 `placeholder` 补充 | | 3.3.3 错误建议 | ⚠️ | 部分表单错误仅提示"必填",需补充修正建议 | | 3.3.4 错误预防 | ✅ | 删除/提交关键操作使用 `AlertDialog` 确认 | ### 原则四:健壮 (Robust) | 准则 | 状态 | 说明 | |------|------|------| | 4.1.1 解析 | ✅ | React 保证有效 HTML | | 4.1.2 名称、角色、值 | ✅ | ARIA 角色和属性正确设置,状态变化通过 `aria-live` 播报 | | 4.1.3 状态消息 | ✅ | `useAriaLive` 和 `AriaStatus` 提供 `aria-live` 状态播报 | --- ## 五、自动化测试工具推荐 | 工具 | 用途 | 链接 | |------|------|------| | axe DevTools | 浏览器插件,扫描页面无障碍问题 | https://www.deque.com/axe/devtools/ | | Lighthouse | Chrome 内置,生成无障碍评分 | Chrome DevTools → Lighthouse | | @axe-core/playwright | E2E 测试中集成 axe 检查 | https://github.com/dequelabs/axe-core-npm | | eslint-plugin-jsx-a11y | ESLint 静态检查 JSX 无障碍问题 | https://github.com/jsx-eslint/eslint-plugin-jsx-a11y | --- ## 六、使用示例 ### `useAriaLive` — 表单提交结果播报 ```tsx "use client" import { useAriaLive } from "@/shared/hooks/use-aria-live" function MyForm() { const { announce, liveRegion } = useAriaLive() const handleSubmit = async () => { const result = await submitAction() if (result.success) { announce("保存成功", { politeness: "polite" }) } else { announce(`保存失败:${result.message}`, { politeness: "assertive" }) } } return ( <>
{/* ... */}
{liveRegion} ) } ``` ### `describeInput` — 输入框错误关联 ```tsx import { useA11yId, describeInput } from "@/shared/lib/a11y" function EmailField({ error }: { error?: string }) { const hintId = useA11yId("email-hint") const errorId = useA11yId("email-error") const { ariaDescribedBy, ariaInvalid } = describeInput( hintId, error ? errorId : undefined ) return ( <> 请输入有效邮箱地址 {error && ( {error} )} ) } ``` ### `FocusTrap` — 自定义模态框 ```tsx import { FocusTrap } from "@/shared/components/a11y/focus-trap" function CustomModal({ open, onClose, children }) { return (
{children}
) } ```