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:
39
src/shared/components/a11y/aria-status.tsx
Normal file
39
src/shared/components/a11y/aria-status.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
src/shared/components/a11y/focus-trap.tsx
Normal file
126
src/shared/components/a11y/focus-trap.tsx
Normal 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
|
||||
}
|
||||
39
src/shared/components/a11y/skip-link.tsx
Normal file
39
src/shared/components/a11y/skip-link.tsx
Normal 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"
|
||||
26
src/shared/components/a11y/visually-hidden.tsx
Normal file
26
src/shared/components/a11y/visually-hidden.tsx
Normal 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"
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user