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