Files
NextEdu/src/shared/lib/a11y.ts
SpecialX 6585e10c6f feat(P2): 实现质量保障类5项功能(无障碍/视觉回归/通知渠道/漏洞扫描/灾备)
## 新增功能

### 1. 屏幕阅读器兼容性增强(a11y)
- 无障碍工具库:src/shared/lib/a11y.ts
- aria-live Hook:src/shared/hooks/use-aria-live.ts
- a11y 组件:skip-link/visually-hidden/focus-trap/aria-status
- 增强 UI:table.tsx 系统性 ARIA role,dialog.tsx aria-modal
- 审计文档:docs/accessibility/a11y-audit.md(WCAG 2.1 AA 清单)

### 2. 视觉回归测试
- 测试套件:tests/visual/(homepage + 3 个 dashboard)
- 3 视口(desktop/tablet/mobile)× 2 主题(light/dark)
- 动态元素遮罩,避免误报
- playwright.config.ts 新增 visual-chromium 项目
- 文档:docs/testing/visual-regression.md

### 3. 短信/微信推送渠道集成
- 新模块:src/modules/notifications/
- 4 个渠道:SMS(阿里云/腾讯云)、WeChat(公众号)、Email(SMTP)、In-App
- 分发器按用户偏好并行多渠道发送
- 外部 SDK 动态 import,Mock 模式开发可用
- 文档:docs/notifications/channels.md

### 4. 漏洞扫描 CI 集成
- CI security-scan job:npm audit + Snyk + Trivy FS + OWASP ZAP
- 独立工作流 security.yml:每周一深度扫描 + 容器镜像扫描
- 配置:suppressions.json + .trivyignore
- 本地脚本:security-scan.sh/ps1
- 文档:docs/security/scanning.md(SLA 分级)

### 5. 灾备方案
- 脚本:backup-verify/backup-offsite-sync/dr-drill/failover/health-check
- CI 增强:备份后校验+异地同步,每周灾备演练
- 独立工作流 dr-drill.yml:每周一凌晨 4 点自动演练
- 文档:docs/dr/dr-plan.md(RTO 4h/RPO 24h)+ dr-runbook.md(6 故障场景)

## 验证
- npx tsc --noEmit:0 错误
- npm run lint:0 错误 0 警告
2026-06-17 20:18:29 +08:00

75 lines
2.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from "react"
/**
* 生成唯一 ID用于 aria-describedby、aria-labelledby 等)。
* 基于 React.useIdSSR 安全,服务端与客户端一致。
*/
export function useA11yId(prefix: string): string {
const id = React.useId()
return `${prefix}-${id}`
}
/**
* 合并多组 aria/data 属性。
* - 普通属性:后者覆盖前者
* - aria-* / data-* 字符串属性:以空格拼接,便于聚合 describedby 等
*/
export function mergeA11yProps<T extends Record<string, unknown>>(
...props: (T | undefined | null | false)[]
): T {
const result = {} as Record<string, unknown>
for (const prop of props) {
if (!prop) continue
for (const key of Object.keys(prop)) {
const value = prop[key]
if (value === undefined || value === null) continue
const isAriaOrData = key.startsWith("aria-") || key.startsWith("data-")
const existing = result[key]
if (
isAriaOrData &&
typeof existing === "string" &&
typeof value === "string"
) {
result[key] = `${existing} ${value}`.trim()
} else {
result[key] = value
}
}
}
return result as T
}
/**
* 计算输入框的 aria 属性。
* @param describedBy 额外描述元素的 ID
* @param error 错误信息元素的 ID存在则标记 invalid
* @param hint 提示信息元素的 ID
*/
export function describeInput(
describedBy?: string,
error?: string,
hint?: string
): { ariaDescribedBy?: string; ariaInvalid?: boolean } {
const ids = [describedBy, error, hint].filter(
(v): v is string => v != null && v.length > 0
)
return {
ariaDescribedBy: ids.length > 0 ? ids.join(" ") : undefined,
ariaInvalid: Boolean(error),
}
}
/**
* 提供加载状态的 aria 属性。
* aria-busy 标记区域正在加载aria-live=polite 让屏幕阅读器在空闲时播报。
*/
export function loadingAria(isLoading: boolean): {
ariaBusy: boolean
ariaLive: "polite" | "assertive"
} {
return {
ariaBusy: isLoading,
ariaLive: "polite",
}
}