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 警告
This commit is contained in:
SpecialX
2026-06-17 20:18:29 +08:00
parent b86255f0ea
commit 6585e10c6f
53 changed files with 7491 additions and 37 deletions

View File

@@ -0,0 +1,39 @@
"use client"
import * as React from "react"
import { cn } from "@/shared/lib/utils"
export interface AriaStatusProps
extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode
/** 通知礼貌级别,默认 polite */
politeness?: "polite" | "assertive"
/** 是否原子播报(整体内容),默认 true */
atomic?: boolean
}
/**
* ARIA 状态通知区域。
* 渲染 aria-live 区域,用于页面级状态通知(如"加载中"、"已保存")。
* 视觉隐藏,仅屏幕阅读器可读。
*/
export function AriaStatus({
children,
politeness = "polite",
atomic = true,
className,
...props
}: AriaStatusProps) {
return (
<div
role="status"
aria-live={politeness}
aria-atomic={atomic}
className={cn("sr-only", className)}
{...props}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,126 @@
"use client"
import * as React from "react"
import { cn } from "@/shared/lib/utils"
export interface FocusTrapProps {
children: React.ReactNode
/** 是否激活焦点陷阱,默认 true */
active?: boolean
/** 初始焦点元素,未指定时聚焦第一个可聚焦元素 */
initialFocusRef?: React.RefObject<HTMLElement | null>
/** 关闭时是否恢复焦点到触发元素,默认 true */
restoreFocus?: boolean
className?: string
}
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"textarea:not([disabled])",
"select:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
"[contenteditable='true']",
"audio[controls]",
"video[controls]",
].join(",")
/**
* 焦点陷阱组件(用于模态框/对话框)。
* - 捕获 Tab/Shift+Tab 在容器内循环
* - 支持初始焦点元素
* - 支持恢复焦点到触发元素
*/
export function FocusTrap({
children,
active = true,
initialFocusRef,
restoreFocus = true,
className,
}: FocusTrapProps) {
const containerRef = React.useRef<HTMLDivElement>(null)
const previouslyFocusedRef = React.useRef<HTMLElement | null>(null)
React.useEffect(() => {
if (!active) return
previouslyFocusedRef.current = document.activeElement as HTMLElement | null
const container = containerRef.current
if (container) {
const focusTarget =
initialFocusRef?.current ?? getFirstFocusable(container)
if (focusTarget) {
focusTarget.focus()
}
}
return () => {
if (restoreFocus && previouslyFocusedRef.current) {
previouslyFocusedRef.current.focus()
}
}
}, [active, initialFocusRef, restoreFocus])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Tab") return
const container = containerRef.current
if (!container) return
const focusables = getFocusables(container)
if (focusables.length === 0) {
event.preventDefault()
container.focus()
return
}
const first = focusables[0]
const last = focusables[focusables.length - 1]
const activeEl = document.activeElement
if (event.shiftKey) {
if (activeEl === first || !container.contains(activeEl)) {
event.preventDefault()
last.focus()
}
} else {
if (activeEl === last || !container.contains(activeEl)) {
event.preventDefault()
first.focus()
}
}
},
[]
)
if (!active) {
return <>{children}</>
}
return (
<div
ref={containerRef}
onKeyDown={handleKeyDown}
tabIndex={-1}
className={cn("outline-none", className)}
>
{children}
</div>
)
}
function getFocusables(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
).filter((el) => {
if (el.hasAttribute("disabled")) return false
if (el.getAttribute("aria-hidden") === "true") return false
return el.offsetParent !== null || el.getClientRects().length > 0
})
}
function getFirstFocusable(container: HTMLElement): HTMLElement | null {
return getFocusables(container)[0] ?? null
}

View File

@@ -0,0 +1,39 @@
"use client"
import * as React from "react"
import { cn } from "@/shared/lib/utils"
export interface SkipLinkProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
/** 跳转目标锚点,默认 #main-content */
href?: string
/** 链接文字,默认"跳转到主内容" */
children?: React.ReactNode
}
/**
* 跳转链接组件。
* 视觉隐藏,获得焦点时高对比度显示,供键盘用户跳过导航直达主内容。
*/
export const SkipLink = React.forwardRef<HTMLAnchorElement, SkipLinkProps>(
(
{ href = "#main-content", className, children = "跳转到主内容", ...props },
ref
) => {
return (
<a
ref={ref}
href={href}
className={cn(
"sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:border focus:border-border focus:bg-background focus:p-4 focus:text-foreground focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
className
)}
{...props}
>
{children}
</a>
)
}
)
SkipLink.displayName = "SkipLink"

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import { cn } from "@/shared/lib/utils"
export interface VisuallyHiddenProps
extends React.HTMLAttributes<HTMLSpanElement> {
children?: React.ReactNode
}
/**
* 视觉隐藏但屏幕阅读器可读的组件。
* 用于图标按钮的文字描述、表单标签的辅助说明等。
*/
export const VisuallyHidden = React.forwardRef<
HTMLSpanElement,
VisuallyHiddenProps
>(({ className, children, ...props }, ref) => {
return (
<span ref={ref} className={cn("sr-only", className)} {...props}>
{children}
</span>
)
})
VisuallyHidden.displayName = "VisuallyHidden"

View File

@@ -37,6 +37,7 @@ const DialogContent = React.forwardRef<
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
aria-modal="true"
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-6 border bg-background p-6 shadow-xl shadow-black/5 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-xl",
className
@@ -44,9 +45,9 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<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">
<DialogPrimitive.Close aria-label="关闭" 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>
</DialogPortal>

View File

@@ -5,10 +5,11 @@ import { cn } from "@/shared/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
>(({ className, role = "table", ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
role={role}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
@@ -19,17 +20,23 @@ Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
>(({ className, role = "rowgroup", ...props }, ref) => (
<thead
ref={ref}
role={role}
className={cn("[&_tr]:border-b", className)}
{...props}
/>
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
>(({ className, role = "rowgroup", ...props }, ref) => (
<tbody
ref={ref}
role={role}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
@@ -39,9 +46,10 @@ TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
>(({ className, role = "rowgroup", ...props }, ref) => (
<tfoot
ref={ref}
role={role}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
@@ -54,9 +62,10 @@ TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
>(({ className, role = "row", ...props }, ref) => (
<tr
ref={ref}
role={role}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
@@ -69,9 +78,10 @@ TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
>(({ className, role = "columnheader", ...props }, ref) => (
<th
ref={ref}
role={role}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
@@ -84,9 +94,10 @@ TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
>(({ className, role = "cell", ...props }, ref) => (
<td
ref={ref}
role={role}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className

View File

@@ -1,4 +1,5 @@
export { useActionWithToast } from "./use-action-with-toast"
export { useAriaLive } from "./use-aria-live"
export { useDebounce } from "./use-debounce"
export { useMediaQuery } from "./use-media-query"
export { useLocalStorage } from "./use-local-storage"

View File

@@ -0,0 +1,99 @@
"use client"
import * as React from "react"
export interface AnnounceOptions {
/** 通知的礼貌级别,默认 polite */
politeness?: "polite" | "assertive"
/** 自动清除的超时时间毫秒0 表示不清除,默认 5000 */
clearAfter?: number
}
export interface UseAriaLiveReturn {
/** 播报一条消息到 aria-live 区域 */
announce: (message: string, options?: AnnounceOptions) => void
/** 渲染到页面中的 aria-live 区域(放在组件树根部即可) */
liveRegion: React.ReactNode
}
/**
* 管理 aria-live 区域的 Hook。
* - 支持 polite / assertive 两种模式
* - 自动清除过期通知(可配置超时)
* - 用于表单提交结果、数据加载状态、错误提示
*/
export function useAriaLive(
defaultOptions?: AnnounceOptions
): UseAriaLiveReturn {
const [politeMessage, setPoliteMessage] = React.useState("")
const [assertiveMessage, setAssertiveMessage] = React.useState("")
const [politeKey, setPoliteKey] = React.useState(0)
const [assertiveKey, setAssertiveKey] = React.useState(0)
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const announce = React.useCallback(
(message: string, options?: AnnounceOptions) => {
const politeness =
options?.politeness ?? defaultOptions?.politeness ?? "polite"
const clearAfter =
options?.clearAfter ?? defaultOptions?.clearAfter ?? 5000
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
if (politeness === "assertive") {
setAssertiveMessage(message)
setAssertiveKey((k) => k + 1)
} else {
setPoliteMessage(message)
setPoliteKey((k) => k + 1)
}
if (clearAfter > 0) {
timeoutRef.current = setTimeout(() => {
setPoliteMessage("")
setAssertiveMessage("")
timeoutRef.current = null
}, clearAfter)
}
},
[defaultOptions]
)
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
const liveRegion = React.createElement(
React.Fragment,
null,
React.createElement(
"div",
{
key: `polite-${politeKey}`,
"aria-live": "polite",
"aria-atomic": "true",
className: "sr-only",
},
politeMessage
),
React.createElement(
"div",
{
key: `assertive-${assertiveKey}`,
"aria-live": "assertive",
"aria-atomic": "true",
className: "sr-only",
},
assertiveMessage
)
)
return { announce, liveRegion }
}

74
src/shared/lib/a11y.ts Normal file
View File

@@ -0,0 +1,74 @@
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",
}
}