# 无障碍审计报告 (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` | 已有 `<caption>` 元素，为表格提供可访问标题 |

所有 `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 页面语言 | ✅ | `<html lang="zh-CN">` |
| 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(): JSX.Element {
  const { announce, liveRegion } = useAriaLive()

  const handleSubmit = async (): Promise<void> => {
    const result = await submitAction()
    if (result.success) {
      announce("保存成功", { politeness: "polite" })
    } else {
      announce(`保存失败：${result.message}`, { politeness: "assertive" })
    }
  }

  return (
    <>
      <form onSubmit={handleSubmit}>{/* ... */}</form>
      {liveRegion}
    </>
  )
}
```

### `describeInput` — 输入框错误关联

```tsx
import { useA11yId, describeInput } from "@/shared/lib/a11y"

function EmailField({ error }: { error?: string }): JSX.Element {
  const hintId = useA11yId("email-hint")
  const errorId = useA11yId("email-error")
  const { ariaDescribedBy, ariaInvalid } = describeInput(
    hintId,
    error ? errorId : undefined
  )

  return (
    <>
      <Input
        aria-describedby={ariaDescribedBy}
        aria-invalid={ariaInvalid}
      />
      <span id={hintId} className="text-muted-foreground text-sm">
        请输入有效邮箱地址
      </span>
      {error && (
        <span id={errorId} className="text-destructive text-sm" role="alert">
          {error}
        </span>
      )}
    </>
  )
}
```

### `FocusTrap` — 自定义模态框

```tsx
import { FocusTrap } from "@/shared/components/a11y/focus-trap"

interface CustomModalProps {
  open: boolean
  onClose: () => void
  children: React.ReactNode
}

function CustomModal({ open, onClose, children }: CustomModalProps): JSX.Element {
  return (
    <FocusTrap active={open} restoreFocus>
      <div role="dialog" aria-modal="true">
        {children}
        <button onClick={onClose}>关闭</button>
      </div>
    </FocusTrap>
  )
}
```
