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:
119
src/modules/notifications/actions.ts
Normal file
119
src/modules/notifications/actions.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
"use server"
|
||||
|
||||
/**
|
||||
* 通知 Server Actions
|
||||
*
|
||||
* - sendNotificationAction: 发送通知给指定用户(需要 MESSAGE_SEND 权限)
|
||||
* - sendClassNotificationAction: 发送班级通知(教师权限,按班级查询学生后批量发送)
|
||||
*
|
||||
* 权限说明:
|
||||
* 项目无独立 NOTIFICATION_SEND 权限点,复用 MESSAGE_SEND(教师/管理员/年级主任均拥有)。
|
||||
* 班级通知按教师所教班级过滤,确保教师只能给自己班级发通知。
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { classEnrollments, classes } from "@/shared/db/schema"
|
||||
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import { sendNotification, sendBatchNotifications } from "./dispatcher"
|
||||
import type { NotificationPayload, ChannelSendResult } from "./types"
|
||||
|
||||
/**
|
||||
* 发送通知给指定用户。
|
||||
*
|
||||
* @param payload 通知负载(payload.userId 为接收人)
|
||||
*/
|
||||
export async function sendNotificationAction(
|
||||
payload: NotificationPayload
|
||||
): Promise<ActionState<ChannelSendResult[]>> {
|
||||
try {
|
||||
await requirePermission(Permissions.MESSAGE_SEND)
|
||||
|
||||
if (!payload.userId || !payload.title || !payload.content) {
|
||||
return { success: false, message: "Missing required fields: userId, title, content" }
|
||||
}
|
||||
|
||||
const results = await sendNotification(payload)
|
||||
const allSuccess = results.every((r) => r.success)
|
||||
return {
|
||||
success: allSuccess,
|
||||
message: allSuccess ? "Notification sent" : "Some channels failed",
|
||||
data: results,
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送班级通知(批量发送给班级所有学生)。
|
||||
*
|
||||
* 教师只能给自己所教班级发送通知(通过 dataScope 校验)。
|
||||
*
|
||||
* @param classId 班级 ID
|
||||
* @param payload 通知负载模板(payload.userId 会被覆盖为每个学生的 userId)
|
||||
*/
|
||||
export async function sendClassNotificationAction(
|
||||
classId: string,
|
||||
payload: Omit<NotificationPayload, "userId">
|
||||
): Promise<ActionState<ChannelSendResult[][]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
|
||||
|
||||
if (!classId || !payload.title || !payload.content) {
|
||||
return { success: false, message: "Missing required fields: classId, title, content" }
|
||||
}
|
||||
|
||||
// 权限校验: 教师只能给自己所教班级发通知;管理员可发任意班级
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
const allowedClassIds =
|
||||
ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : []
|
||||
if (!allowedClassIds.includes(classId)) {
|
||||
return { success: false, message: "You can only send notifications to your own classes" }
|
||||
}
|
||||
}
|
||||
|
||||
// 查询班级所有学生
|
||||
const [classRow] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
|
||||
if (!classRow) {
|
||||
return { success: false, message: "Class not found" }
|
||||
}
|
||||
|
||||
const enrollments = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(eq(classEnrollments.classId, classId))
|
||||
|
||||
if (enrollments.length === 0) {
|
||||
return { success: true, message: "No students in this class", data: [] }
|
||||
}
|
||||
|
||||
// 构造每个学生的通知负载
|
||||
const payloads: NotificationPayload[] = enrollments.map((e) => ({
|
||||
...payload,
|
||||
userId: e.studentId,
|
||||
}))
|
||||
|
||||
const results = await sendBatchNotifications(payloads)
|
||||
return {
|
||||
success: true,
|
||||
message: `Notification sent to ${enrollments.length} students`,
|
||||
data: results,
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
183
src/modules/notifications/channels/email-channel.ts
Normal file
183
src/modules/notifications/channels/email-channel.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 邮件通知渠道
|
||||
*
|
||||
* 使用 nodemailer(动态 import)通过 SMTP 发送邮件。
|
||||
* 支持 HTML 邮件模板,根据 payload.type 渲染不同颜色样式。
|
||||
*
|
||||
* 环境变量:
|
||||
* - EMAIL_HOST: SMTP 主机
|
||||
* - EMAIL_PORT: SMTP 端口(默认 587)
|
||||
* - EMAIL_USER: SMTP 用户名
|
||||
* - EMAIL_PASS: SMTP 密码
|
||||
* - EMAIL_FROM: 发件人地址(默认 noreply@example.com)
|
||||
*
|
||||
* Mock 实现: 当 EMAIL_HOST 未配置时启用,仅记录日志不实际发送。
|
||||
*/
|
||||
|
||||
import type {
|
||||
NotificationPayload,
|
||||
ChannelSendResult,
|
||||
NotificationChannel,
|
||||
} from "../types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
|
||||
const channel: NotificationChannel = "email"
|
||||
|
||||
/** 从环境变量读取邮件配置 */
|
||||
function getEmailConfig() {
|
||||
return {
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: Number(process.env.EMAIL_PORT ?? "587"),
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
from: process.env.EMAIL_FROM ?? "noreply@example.com",
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否启用邮件渠道(SMTP 主机配置即启用) */
|
||||
export function isEmailEnabled(): boolean {
|
||||
return Boolean(process.env.EMAIL_HOST)
|
||||
}
|
||||
|
||||
/** 根据通知类型返回主题色(用于 HTML 模板) */
|
||||
function getTypeColor(type: NotificationPayload["type"]): string {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return "#16a34a"
|
||||
case "warning":
|
||||
return "#d97706"
|
||||
case "error":
|
||||
return "#dc2626"
|
||||
case "info":
|
||||
default:
|
||||
return "#2563eb"
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成 HTML 邮件内容 */
|
||||
function buildHtmlContent(payload: NotificationPayload): string {
|
||||
const color = getTypeColor(payload.type)
|
||||
const actionLink = payload.actionUrl
|
||||
? `<p style="margin-top:16px;"><a href="${payload.actionUrl}" style="color:${color};text-decoration:none;">点击查看详情 →</a></p>`
|
||||
: ""
|
||||
return `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<div style="border-left:4px solid ${color};padding-left:16px;">
|
||||
<h2 style="color:${color};margin:0 0 12px 0;">${escapeHtml(payload.title)}</h2>
|
||||
<p style="color:#374151;line-height:1.6;margin:0;">${escapeHtml(payload.content)}</p>
|
||||
${actionLink}
|
||||
</div>
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;" />
|
||||
<p style="color:#9ca3af;font-size:12px;margin:0;">此邮件由系统自动发送,请勿回复。</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
/** HTML 转义,防止 XSS */
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
/** Mock 邮件发送器(开发环境使用) */
|
||||
class MockEmailSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
console.info(
|
||||
`[MockEmail] send to ${recipient.email ?? "(no email)"}: subject="${payload.title}"`
|
||||
)
|
||||
return {
|
||||
channel,
|
||||
success: true,
|
||||
messageId: `mock-email-${Date.now()}`,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(
|
||||
items: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }>
|
||||
): Promise<ChannelSendResult[]> {
|
||||
return Promise.all(items.map((item) => this.send(item.payload, item.recipient)))
|
||||
}
|
||||
}
|
||||
|
||||
/** Nodemailer SMTP 邮件发送器 */
|
||||
class NodemailerEmailSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
if (!recipient.email) {
|
||||
return { channel, success: false, error: "Missing recipient email", sentAt: new Date() }
|
||||
}
|
||||
|
||||
const config = getEmailConfig()
|
||||
if (!config.host) {
|
||||
return { channel, success: false, error: "EMAIL_HOST not configured", sentAt: new Date() }
|
||||
}
|
||||
|
||||
try {
|
||||
// 动态 import nodemailer,避免增加构建体积
|
||||
const nodemailer = await import("nodemailer")
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.port === 465,
|
||||
auth: config.user
|
||||
? { user: config.user, pass: config.pass ?? "" }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: config.from,
|
||||
to: recipient.email,
|
||||
subject: payload.title,
|
||||
html: buildHtmlContent(payload),
|
||||
text: payload.content,
|
||||
})
|
||||
|
||||
return {
|
||||
channel,
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Email send failed",
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(
|
||||
items: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }>
|
||||
): Promise<ChannelSendResult[]> {
|
||||
return Promise.all(items.map((item) => this.send(item.payload, item.recipient)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建邮件渠道发送器。
|
||||
* 配置了 EMAIL_HOST 时使用 Nodemailer,否则使用 Mock 实现。
|
||||
*/
|
||||
export function createEmailSender(): NotificationChannelSender {
|
||||
if (isEmailEnabled()) {
|
||||
return new NodemailerEmailSender()
|
||||
}
|
||||
return new MockEmailSender()
|
||||
}
|
||||
83
src/modules/notifications/channels/in-app-channel.ts
Normal file
83
src/modules/notifications/channels/in-app-channel.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 站内消息渠道
|
||||
*
|
||||
* 封装现有 messaging 模块的 data-access.createNotification,
|
||||
* 将其适配为统一的 NotificationChannelSender 接口。
|
||||
*
|
||||
* 这是默认渠道,总是启用。所有通知都会写入 message_notifications 表,
|
||||
* 用户可在站内通知中心查看。
|
||||
*
|
||||
* 注意: messaging.NotificationType 为 "message" | "announcement" | "homework" | "grade",
|
||||
* 而本模块 NotificationPayload.type 为 "info" | "warning" | "error" | "success"。
|
||||
* 此处将 payload.type 作为字符串写入 DB(DB 列为 varchar(128),支持任意值),
|
||||
* 不破坏现有 messaging 模块的类型约束。
|
||||
*/
|
||||
|
||||
import type {
|
||||
NotificationPayload,
|
||||
ChannelSendResult,
|
||||
NotificationChannel,
|
||||
} from "../types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
import { createNotification } from "@/modules/messaging/data-access"
|
||||
|
||||
const channel: NotificationChannel = "in_app"
|
||||
|
||||
/** 站内消息发送器(调用现有 messaging data-access) */
|
||||
class InAppChannelSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
try {
|
||||
// 校验收件人一致,防止误发
|
||||
if (recipient.userId !== payload.userId) {
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: "Recipient userId does not match payload.userId",
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
const id = await createNotification({
|
||||
userId: payload.userId,
|
||||
// DB 列为 varchar(128),支持任意字符串;保留 payload.type 语义
|
||||
type: payload.type as "message" | "announcement" | "homework" | "grade",
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
link: payload.actionUrl ?? null,
|
||||
})
|
||||
return {
|
||||
channel,
|
||||
success: true,
|
||||
messageId: id,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "In-app notification failed",
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(
|
||||
items: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }>
|
||||
): Promise<ChannelSendResult[]> {
|
||||
return Promise.all(items.map((item) => this.send(item.payload, item.recipient)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建站内消息渠道发送器。
|
||||
* 站内渠道总是启用,无需配置。
|
||||
*/
|
||||
export function createInAppSender(): NotificationChannelSender {
|
||||
return new InAppChannelSender()
|
||||
}
|
||||
236
src/modules/notifications/channels/sms-channel.ts
Normal file
236
src/modules/notifications/channels/sms-channel.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 短信通知渠道
|
||||
*
|
||||
* 支持的 Provider:
|
||||
* - aliyun: 阿里云短信(@alicloud/dysmsapi20170525,动态 import)
|
||||
* - tencent: 腾讯云短信(tencentcloud-sdk,动态 import)
|
||||
* - mock: 开发环境模拟(仅记录日志,不实际发送)
|
||||
*
|
||||
* 环境变量:
|
||||
* - SMS_PROVIDER: "aliyun" | "tencent" | "mock"(默认 mock)
|
||||
* - SMS_ACCESS_KEY_ID / SMS_ACCESS_KEY_SECRET
|
||||
* - SMS_SIGN_NAME: 短信签名
|
||||
* - SMS_TEMPLATE_CODE: 短信模板 ID
|
||||
*
|
||||
* 模板变量替换:将 payload.title / payload.content 填入模板变量 name/title/content。
|
||||
*/
|
||||
|
||||
import type {
|
||||
NotificationPayload,
|
||||
ChannelSendResult,
|
||||
NotificationChannel,
|
||||
} from "../types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
|
||||
const channel: NotificationChannel = "sms"
|
||||
|
||||
/** 从环境变量读取 SMS 配置 */
|
||||
function getSmsConfig() {
|
||||
return {
|
||||
provider: (process.env.SMS_PROVIDER ?? "mock") as "aliyun" | "tencent" | "mock",
|
||||
accessKeyId: process.env.SMS_ACCESS_KEY_ID,
|
||||
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
|
||||
signName: process.env.SMS_SIGN_NAME,
|
||||
templateCode: process.env.SMS_TEMPLATE_CODE,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造短信模板变量。
|
||||
* 阿里云/腾讯云模板使用 ${name} / ${title} / ${content} 占位符。
|
||||
*/
|
||||
function buildTemplateParams(payload: NotificationPayload): Record<string, string> {
|
||||
return {
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
type: payload.type,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock 短信发送器(开发环境使用) */
|
||||
class MockSmsSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
const params = buildTemplateParams(payload)
|
||||
// 开发环境仅记录日志,不实际发送
|
||||
console.info(
|
||||
`[MockSms] send to ${recipient.phone ?? "(no phone)"}: title="${payload.title}" params=`,
|
||||
params
|
||||
)
|
||||
return {
|
||||
channel,
|
||||
success: true,
|
||||
messageId: `mock-sms-${Date.now()}`,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(
|
||||
items: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }>
|
||||
): Promise<ChannelSendResult[]> {
|
||||
return Promise.all(items.map((item) => this.send(item.payload, item.recipient)))
|
||||
}
|
||||
}
|
||||
|
||||
/** 阿里云短信发送器 */
|
||||
class AliyunSmsSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
private config: ReturnType<typeof getSmsConfig>
|
||||
|
||||
constructor() {
|
||||
this.config = getSmsConfig()
|
||||
}
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
if (!recipient.phone) {
|
||||
return { channel, success: false, error: "Missing recipient phone", sentAt: new Date() }
|
||||
}
|
||||
if (!this.config.accessKeyId || !this.config.accessKeySecret) {
|
||||
return { channel, success: false, error: "SMS_ACCESS_KEY_ID/SECRET not configured", sentAt: new Date() }
|
||||
}
|
||||
|
||||
try {
|
||||
// 动态 import 阿里云 SDK,避免增加构建体积
|
||||
const { default: Dysmsapi } = await import("@alicloud/dysmsapi20170525")
|
||||
const { default: OpenApi } = await import("@alicloud/openapi-client")
|
||||
const { default: Credential } = await import("@alicloud/credentials")
|
||||
|
||||
const credential = new Credential({
|
||||
accessKeyId: this.config.accessKeyId,
|
||||
accessKeySecret: this.config.accessKeySecret,
|
||||
})
|
||||
const apiConfig = new OpenApi({ credential })
|
||||
apiConfig.endpoint = "dysmsapi.aliyuncs.com"
|
||||
const client = new Dysmsapi(apiConfig)
|
||||
|
||||
const { SendSmsRequest } = await import("@alicloud/dysmsapi20170525")
|
||||
const request = new SendSmsRequest({
|
||||
phoneNumbers: recipient.phone,
|
||||
signName: this.config.signName,
|
||||
templateCode: this.config.templateCode,
|
||||
templateParam: JSON.stringify(buildTemplateParams(payload)),
|
||||
})
|
||||
|
||||
const response = await client.sendSms(request)
|
||||
const code = response?.body?.code
|
||||
const success = code === "OK"
|
||||
return {
|
||||
channel,
|
||||
success,
|
||||
messageId: response?.body?.bizId ?? undefined,
|
||||
error: success ? undefined : response?.body?.message,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Aliyun SMS send failed",
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(
|
||||
items: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }>
|
||||
): Promise<ChannelSendResult[]> {
|
||||
return Promise.all(items.map((item) => this.send(item.payload, item.recipient)))
|
||||
}
|
||||
}
|
||||
|
||||
/** 腾讯云短信发送器 */
|
||||
class TencentSmsSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
private config: ReturnType<typeof getSmsConfig>
|
||||
|
||||
constructor() {
|
||||
this.config = getSmsConfig()
|
||||
}
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
if (!recipient.phone) {
|
||||
return { channel, success: false, error: "Missing recipient phone", sentAt: new Date() }
|
||||
}
|
||||
if (!this.config.accessKeyId || !this.config.accessKeySecret) {
|
||||
return { channel, success: false, error: "SMS_ACCESS_KEY_ID/SECRET not configured", sentAt: new Date() }
|
||||
}
|
||||
|
||||
try {
|
||||
// 动态 import 腾讯云 SDK
|
||||
const tencentcloud = await import("tencentcloud-sdk-nodejs")
|
||||
const SmsClient = tencentcloud.sms.v20210111.Client
|
||||
|
||||
const client = new SmsClient({
|
||||
credential: {
|
||||
secretId: this.config.accessKeyId,
|
||||
secretKey: this.config.accessKeySecret,
|
||||
},
|
||||
region: "ap-guangzhou",
|
||||
profile: {
|
||||
httpProfile: { endpoint: "sms.tencentcloudapi.com" },
|
||||
},
|
||||
})
|
||||
|
||||
const params = buildTemplateParams(payload)
|
||||
const response = await client.SendSms({
|
||||
PhoneNumberSet: [`+86${recipient.phone}`],
|
||||
SmsSdkAppId: this.config.templateCode ?? "",
|
||||
SignName: this.config.signName ?? "",
|
||||
TemplateId: this.config.templateCode ?? "",
|
||||
TemplateParamSet: [params.title, params.content],
|
||||
})
|
||||
|
||||
const sendStatus = response?.SendStatusSet?.[0]
|
||||
const success = sendStatus?.Code === "Ok"
|
||||
return {
|
||||
channel,
|
||||
success,
|
||||
messageId: sendStatus?.SerialNo ?? undefined,
|
||||
error: success ? undefined : sendStatus?.Message,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Tencent SMS send failed",
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(
|
||||
items: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }>
|
||||
): Promise<ChannelSendResult[]> {
|
||||
return Promise.all(items.map((item) => this.send(item.payload, item.recipient)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 SMS 渠道发送器(根据 SMS_PROVIDER 环境变量选择实现)。
|
||||
* 默认使用 Mock 实现,确保开发环境无外部服务时也可用。
|
||||
*/
|
||||
export function createSmsSender(): NotificationChannelSender {
|
||||
const config = getSmsConfig()
|
||||
switch (config.provider) {
|
||||
case "aliyun":
|
||||
return new AliyunSmsSender()
|
||||
case "tencent":
|
||||
return new TencentSmsSender()
|
||||
case "mock":
|
||||
default:
|
||||
return new MockSmsSender()
|
||||
}
|
||||
}
|
||||
38
src/modules/notifications/channels/types.ts
Normal file
38
src/modules/notifications/channels/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 渠道发送者接口定义
|
||||
*
|
||||
* 所有渠道(SMS/微信/邮件/站内)均实现 NotificationChannelSender 接口,
|
||||
* 由 dispatcher 统一调度。新增渠道只需实现此接口并注册到 dispatcher。
|
||||
*/
|
||||
|
||||
import type { NotificationPayload, ChannelSendResult, NotificationChannel } from "../types"
|
||||
|
||||
/** 渠道接收人信息(由 data-access.getUserContactInfo 填充) */
|
||||
export interface ChannelRecipient {
|
||||
userId: string
|
||||
/** 手机号(SMS 渠道必需) */
|
||||
phone?: string
|
||||
/** 邮箱(邮件渠道必需) */
|
||||
email?: string
|
||||
/** 微信 OpenID(微信公众号模板消息渠道必需) */
|
||||
wechatOpenId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知渠道发送者接口
|
||||
*
|
||||
* 实现方需保证:
|
||||
* - send 失败时抛出 Error 或返回 success:false(不抛出以避免阻塞其他渠道)
|
||||
* - sendBatch 内部并行发送,单条失败不影响其他条
|
||||
* - 所有实现需在文件首行 import "server-only"
|
||||
*/
|
||||
export interface NotificationChannelSender {
|
||||
/** 渠道标识 */
|
||||
readonly channel: NotificationChannel
|
||||
/** 发送单条通知 */
|
||||
send(payload: NotificationPayload, recipient: ChannelRecipient): Promise<ChannelSendResult>
|
||||
/** 批量发送(默认实现串行调用 send,可覆写为并行) */
|
||||
sendBatch(
|
||||
payloads: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }>
|
||||
): Promise<ChannelSendResult[]>
|
||||
}
|
||||
208
src/modules/notifications/channels/wechat-channel.ts
Normal file
208
src/modules/notifications/channels/wechat-channel.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 微信公众号模板消息渠道
|
||||
*
|
||||
* 使用微信官方 API:
|
||||
* 1. 获取 access_token: GET https://api.weixin.qq.com/cgi-bin/token
|
||||
* 2. 发送模板消息: POST https://api.weixin.qq.com/cgi-bin/message/template/send
|
||||
*
|
||||
* access_token 带缓存(有效期 7200 秒,提前 5 分钟刷新),避免频繁请求。
|
||||
*
|
||||
* 环境变量:
|
||||
* - WECHAT_APP_ID: 公众号 AppID
|
||||
* - WECHAT_APP_SECRET: 公众号 AppSecret
|
||||
* - WECHAT_TEMPLATE_ID: 模板消息 ID
|
||||
*
|
||||
* 模板数据映射: payload.title -> keyword1, payload.content -> keyword2,
|
||||
* payload.type -> keyword3(可在微信公众号后台配置模板字段)。
|
||||
*/
|
||||
|
||||
import type {
|
||||
NotificationPayload,
|
||||
ChannelSendResult,
|
||||
NotificationChannel,
|
||||
} from "../types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
|
||||
const channel: NotificationChannel = "wechat"
|
||||
const WECHAT_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"
|
||||
const WECHAT_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/template/send"
|
||||
|
||||
/** access_token 缓存(进程级) */
|
||||
interface TokenCache {
|
||||
accessToken: string
|
||||
expiresAt: number // 毫秒时间戳
|
||||
}
|
||||
let tokenCache: TokenCache | null = null
|
||||
|
||||
/** 从环境变量读取微信配置 */
|
||||
function getWechatConfig() {
|
||||
return {
|
||||
appId: process.env.WECHAT_APP_ID,
|
||||
appSecret: process.env.WECHAT_APP_SECRET,
|
||||
templateId: process.env.WECHAT_TEMPLATE_ID,
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否启用微信渠道(配置完整才启用) */
|
||||
export function isWechatEnabled(): boolean {
|
||||
const config = getWechatConfig()
|
||||
return Boolean(config.appId && config.appSecret && config.templateId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信 access_token(带缓存)。
|
||||
* 缓存有效期内的 token 直接复用;过期或不存在时重新请求。
|
||||
*/
|
||||
async function getAccessToken(): Promise<string> {
|
||||
const now = Date.now()
|
||||
// 缓存有效(提前 5 分钟刷新,避免边界过期)
|
||||
if (tokenCache && tokenCache.expiresAt - now > 5 * 60 * 1000) {
|
||||
return tokenCache.accessToken
|
||||
}
|
||||
|
||||
const config = getWechatConfig()
|
||||
if (!config.appId || !config.appSecret) {
|
||||
throw new Error("WECHAT_APP_ID/WECHAT_APP_SECRET not configured")
|
||||
}
|
||||
|
||||
const url = `${WECHAT_TOKEN_URL}?grant_type=client_credential&appid=${encodeURIComponent(
|
||||
config.appId
|
||||
)}&secret=${encodeURIComponent(config.appSecret)}`
|
||||
const res = await fetch(url, { method: "GET" })
|
||||
const data = (await res.json()) as {
|
||||
access_token?: string
|
||||
expires_in?: number
|
||||
errcode?: number
|
||||
errmsg?: string
|
||||
}
|
||||
|
||||
if (!data.access_token) {
|
||||
throw new Error(
|
||||
`Failed to get WeChat access_token: ${data.errcode} ${data.errmsg ?? ""}`
|
||||
)
|
||||
}
|
||||
|
||||
tokenCache = {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: now + (data.expires_in ?? 7200) * 1000,
|
||||
}
|
||||
return tokenCache.accessToken
|
||||
}
|
||||
|
||||
/**
|
||||
* 将通知负载映射为微信模板数据。
|
||||
* 默认映射: title -> keyword1, content -> keyword2, type -> keyword3。
|
||||
* 如需自定义映射,可在 metadata 中提供 wechatKeywords 覆盖。
|
||||
*/
|
||||
function buildTemplateData(
|
||||
payload: NotificationPayload
|
||||
): Record<string, { value: string }> {
|
||||
const custom = (payload.metadata?.wechatKeywords as Record<string, string> | undefined) ?? {}
|
||||
return {
|
||||
keyword1: { value: custom.keyword1 ?? payload.title },
|
||||
keyword2: { value: custom.keyword2 ?? payload.content },
|
||||
keyword3: { value: custom.keyword3 ?? payload.type },
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock 微信发送器(开发环境使用) */
|
||||
class MockWechatSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
console.info(
|
||||
`[MockWechat] send to openId=${recipient.wechatOpenId ?? "(no openId)"}: title="${payload.title}"`
|
||||
)
|
||||
return {
|
||||
channel,
|
||||
success: true,
|
||||
messageId: `mock-wechat-${Date.now()}`,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(
|
||||
items: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }>
|
||||
): Promise<ChannelSendResult[]> {
|
||||
return Promise.all(items.map((item) => this.send(item.payload, item.recipient)))
|
||||
}
|
||||
}
|
||||
|
||||
/** 微信公众号模板消息发送器 */
|
||||
class WechatTemplateSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
if (!recipient.wechatOpenId) {
|
||||
return { channel, success: false, error: "Missing recipient wechatOpenId", sentAt: new Date() }
|
||||
}
|
||||
|
||||
const config = getWechatConfig()
|
||||
if (!config.templateId) {
|
||||
return { channel, success: false, error: "WECHAT_TEMPLATE_ID not configured", sentAt: new Date() }
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken()
|
||||
const url = `${WECHAT_SEND_URL}?access_token=${encodeURIComponent(accessToken)}`
|
||||
const body = {
|
||||
touser: recipient.wechatOpenId,
|
||||
template_id: config.templateId,
|
||||
url: payload.actionUrl ?? "",
|
||||
data: buildTemplateData(payload),
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = (await res.json()) as {
|
||||
errcode?: number
|
||||
errmsg?: string
|
||||
msgid?: number
|
||||
}
|
||||
|
||||
const success = data.errcode === 0
|
||||
return {
|
||||
channel,
|
||||
success,
|
||||
messageId: data.msgid != null ? String(data.msgid) : undefined,
|
||||
error: success ? undefined : `${data.errcode}: ${data.errmsg ?? ""}`,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "WeChat send failed",
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(
|
||||
items: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }>
|
||||
): Promise<ChannelSendResult[]> {
|
||||
return Promise.all(items.map((item) => this.send(item.payload, item.recipient)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建微信渠道发送器。
|
||||
* 配置完整时使用真实发送器,否则使用 Mock 实现。
|
||||
*/
|
||||
export function createWechatSender(): NotificationChannelSender {
|
||||
if (isWechatEnabled()) {
|
||||
return new WechatTemplateSender()
|
||||
}
|
||||
return new MockWechatSender()
|
||||
}
|
||||
86
src/modules/notifications/data-access.ts
Normal file
86
src/modules/notifications/data-access.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 通知数据访问层
|
||||
*
|
||||
* 职责:
|
||||
* - getUserNotificationPreferences: 获取用户通知偏好(复用 messaging 模块)
|
||||
* - getUserContactInfo: 获取用户联系方式(手机/邮箱,用于渠道发送)
|
||||
* - logNotificationSend: 记录发送日志(当前项目无 notification_logs 表,使用 console 输出)
|
||||
*
|
||||
* 注意: users 表当前无 wechatOpenId 字段,wechatOpenId 暂返回 undefined。
|
||||
* 未来扩展 users 表增加 wechat_open_id 列后,此处补充查询即可。
|
||||
*/
|
||||
|
||||
import { cache } from "react"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { users } from "@/shared/db/schema"
|
||||
import { getNotificationPreferences } from "@/modules/messaging/notification-preferences"
|
||||
import type { NotificationPreferences } from "@/modules/messaging/types"
|
||||
import type { ChannelRecipient } from "./channels/types"
|
||||
import type { ChannelSendResult } from "./types"
|
||||
|
||||
/**
|
||||
* 获取用户通知偏好(复用 messaging 模块的 cache 包装函数)。
|
||||
* 若用户无记录,messaging 模块会自动创建默认记录。
|
||||
*/
|
||||
export async function getUserNotificationPreferences(
|
||||
userId: string
|
||||
): Promise<NotificationPreferences> {
|
||||
return getNotificationPreferences(userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户联系方式(手机号、邮箱)。
|
||||
* wechatOpenId 暂不支持(users 表无此字段),返回 undefined。
|
||||
*/
|
||||
export const getUserContactInfo = cache(
|
||||
async (userId: string): Promise<ChannelRecipient> => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
phone: users.phone,
|
||||
email: users.email,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!row) {
|
||||
return { userId }
|
||||
}
|
||||
|
||||
return {
|
||||
userId: row.id,
|
||||
phone: row.phone ?? undefined,
|
||||
email: row.email ?? undefined,
|
||||
// users 表暂无 wechat_open_id 字段;扩展 schema 后在此补充
|
||||
wechatOpenId: undefined,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 记录通知发送日志。
|
||||
*
|
||||
* 当前项目无 notification_logs 表,使用 console.info 输出。
|
||||
* 未来新增 notification_logs 表后,可在此处写入 DB。
|
||||
*/
|
||||
export function logNotificationSend(result: ChannelSendResult): void {
|
||||
const status = result.success ? "OK" : "FAIL"
|
||||
const errorPart = result.error ? ` error="${result.error}"` : ""
|
||||
console.info(
|
||||
`[NotificationLog] ${status} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量记录发送日志。
|
||||
*/
|
||||
export function logNotificationSendBatch(results: ChannelSendResult[]): void {
|
||||
for (const result of results) {
|
||||
logNotificationSend(result)
|
||||
}
|
||||
}
|
||||
152
src/modules/notifications/dispatcher.ts
Normal file
152
src/modules/notifications/dispatcher.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 通知分发器
|
||||
*
|
||||
* 职责:
|
||||
* - 根据用户通知偏好(notification_preferences)决定使用哪些渠道
|
||||
* - 并行发送到多个渠道(in_app 总是启用)
|
||||
* - 记录每个渠道的发送结果到日志
|
||||
*
|
||||
* 渠道选择逻辑:
|
||||
* - in_app: 总是启用(pushEnabled 控制是否发送站内推送)
|
||||
* - sms: smsEnabled && 用户有手机号
|
||||
* - email: emailEnabled && 用户有邮箱
|
||||
* - wechat: pushEnabled && 用户有 wechatOpenId(当前 users 表无此字段,暂不发送)
|
||||
*
|
||||
* 注意: 通知偏好中的 homeworkNotifications/gradeNotifications 等是按通知类别控制,
|
||||
* 由调用方在构造 payload 时决定是否调用 sendNotification。
|
||||
*/
|
||||
|
||||
import type { NotificationPayload, ChannelSendResult, NotificationChannel } from "./types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./channels/types"
|
||||
import { createSmsSender } from "./channels/sms-channel"
|
||||
import { createWechatSender } from "./channels/wechat-channel"
|
||||
import { createEmailSender } from "./channels/email-channel"
|
||||
import { createInAppSender } from "./channels/in-app-channel"
|
||||
import {
|
||||
getUserNotificationPreferences,
|
||||
getUserContactInfo,
|
||||
logNotificationSendBatch,
|
||||
} from "./data-access"
|
||||
|
||||
/** 渠道发送器实例缓存(避免每次发送重新创建) */
|
||||
interface SenderRegistry {
|
||||
in_app: NotificationChannelSender
|
||||
sms: NotificationChannelSender
|
||||
wechat: NotificationChannelSender
|
||||
email: NotificationChannelSender
|
||||
}
|
||||
|
||||
let senderRegistry: SenderRegistry | null = null
|
||||
|
||||
/** 获取渠道发送器注册表(单例) */
|
||||
function getSenders(): SenderRegistry {
|
||||
if (!senderRegistry) {
|
||||
senderRegistry = {
|
||||
in_app: createInAppSender(),
|
||||
sms: createSmsSender(),
|
||||
wechat: createWechatSender(),
|
||||
email: createEmailSender(),
|
||||
}
|
||||
}
|
||||
return senderRegistry
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户通知偏好和联系方式,决定启用的渠道列表。
|
||||
*/
|
||||
function selectChannels(
|
||||
prefs: {
|
||||
smsEnabled: boolean
|
||||
emailEnabled: boolean
|
||||
pushEnabled: boolean
|
||||
},
|
||||
contact: ChannelRecipient
|
||||
): NotificationChannel[] {
|
||||
const channels: NotificationChannel[] = []
|
||||
|
||||
// 站内消息总是启用(pushEnabled 控制站内推送,默认 true)
|
||||
if (prefs.pushEnabled) {
|
||||
channels.push("in_app")
|
||||
}
|
||||
|
||||
// SMS: 偏好启用且有手机号
|
||||
if (prefs.smsEnabled && contact.phone) {
|
||||
channels.push("sms")
|
||||
}
|
||||
|
||||
// Email: 偏好启用且有邮箱
|
||||
if (prefs.emailEnabled && contact.email) {
|
||||
channels.push("email")
|
||||
}
|
||||
|
||||
// WeChat: 偏好启用且有 openId(当前 users 表无此字段,暂不会触发)
|
||||
if (prefs.pushEnabled && contact.wechatOpenId) {
|
||||
channels.push("wechat")
|
||||
}
|
||||
|
||||
// 兜底: 如果所有渠道都未启用,至少发站内消息
|
||||
if (channels.length === 0) {
|
||||
channels.push("in_app")
|
||||
}
|
||||
|
||||
return channels
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送单条通知到用户。
|
||||
*
|
||||
* 根据用户通知偏好选择渠道,并行发送,返回所有渠道的发送结果。
|
||||
*
|
||||
* @param payload 通知负载(payload.userId 决定接收人)
|
||||
* @returns 各渠道的发送结果
|
||||
*/
|
||||
export async function sendNotification(
|
||||
payload: NotificationPayload
|
||||
): Promise<ChannelSendResult[]> {
|
||||
const userId = payload.userId
|
||||
|
||||
// 并行获取用户偏好和联系方式
|
||||
const [prefs, contact] = await Promise.all([
|
||||
getUserNotificationPreferences(userId),
|
||||
getUserContactInfo(userId),
|
||||
])
|
||||
|
||||
const channels = selectChannels(prefs, contact)
|
||||
const senders = getSenders()
|
||||
|
||||
// 并行发送到所有选中渠道
|
||||
const results = await Promise.all(
|
||||
channels.map((ch) => {
|
||||
const sender = senders[ch]
|
||||
return sender.send(payload, contact)
|
||||
})
|
||||
)
|
||||
|
||||
// 记录发送日志
|
||||
logNotificationSendBatch(results)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发送通知到多个用户。
|
||||
*
|
||||
* 每个用户的渠道选择独立计算,并行发送。
|
||||
*
|
||||
* @param payloads 通知负载数组(每个 payload.userId 决定各自接收人)
|
||||
* @returns 各用户各渠道的发送结果(按输入顺序对应)
|
||||
*/
|
||||
export async function sendBatchNotifications(
|
||||
payloads: NotificationPayload[]
|
||||
): Promise<ChannelSendResult[][]> {
|
||||
// 并行处理每个 payload
|
||||
const results = await Promise.all(payloads.map((p) => sendNotification(p)))
|
||||
|
||||
// 汇总日志
|
||||
const flatResults = results.flat()
|
||||
logNotificationSendBatch(flatResults)
|
||||
|
||||
return results
|
||||
}
|
||||
47
src/modules/notifications/external-sdk.d.ts
vendored
Normal file
47
src/modules/notifications/external-sdk.d.ts
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/**
|
||||
* 外部 SDK 类型声明(可选依赖)
|
||||
*
|
||||
* 这些 SDK 通过动态 import 在运行时加载,开发环境(Mock 模式)无需安装。
|
||||
* 安装对应 SDK 后,其自带的类型声明将覆盖此处的 any 声明。
|
||||
*
|
||||
* 安装命令见 docs/notifications/channels.md
|
||||
*/
|
||||
|
||||
declare module "@alicloud/dysmsapi20170525" {
|
||||
const _default: any
|
||||
export { _default as default }
|
||||
export class SendSmsRequest {
|
||||
constructor(params?: Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@alicloud/openapi-client" {
|
||||
const _default: new (config: any) => any
|
||||
export { _default as default }
|
||||
}
|
||||
|
||||
declare module "@alicloud/credentials" {
|
||||
const _default: new (config: any) => any
|
||||
export { _default as default }
|
||||
}
|
||||
|
||||
declare module "tencentcloud-sdk-nodejs" {
|
||||
const _default: {
|
||||
sms: {
|
||||
v20210111: {
|
||||
Client: new (config: any) => any
|
||||
}
|
||||
}
|
||||
}
|
||||
export = _default
|
||||
}
|
||||
|
||||
declare module "nodemailer" {
|
||||
const _default: {
|
||||
createTransport: (config: any) => {
|
||||
sendMail: (options: any) => Promise<{ messageId: string }>
|
||||
}
|
||||
}
|
||||
export = _default
|
||||
}
|
||||
38
src/modules/notifications/index.ts
Normal file
38
src/modules/notifications/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 通知渠道集成模块
|
||||
*
|
||||
* 对外导出:
|
||||
* - sendNotification / sendBatchNotifications: 分发器入口
|
||||
* - 类型定义: NotificationPayload, ChannelSendResult, NotificationChannel 等
|
||||
* - 渠道发送器工厂: createSmsSender, createWechatSender, createEmailSender, createInAppSender
|
||||
*
|
||||
* 典型用法:
|
||||
* ```ts
|
||||
* import { sendNotification } from "@/modules/notifications"
|
||||
* await sendNotification({
|
||||
* userId: "user-xxx",
|
||||
* title: "作业提醒",
|
||||
* content: "您有一份新作业待提交",
|
||||
* type: "info",
|
||||
* actionUrl: "/homework/123",
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { sendNotification, sendBatchNotifications } from "./dispatcher"
|
||||
export type {
|
||||
NotificationChannel,
|
||||
NotificationPayload,
|
||||
ChannelSendResult,
|
||||
NotificationChannelConfig,
|
||||
SmsChannelConfig,
|
||||
WechatChannelConfig,
|
||||
EmailChannelConfig,
|
||||
} from "./types"
|
||||
export type { NotificationChannelSender, ChannelRecipient } from "./channels/types"
|
||||
|
||||
// 渠道发送器工厂(供高级用法直接调用单个渠道)
|
||||
export { createSmsSender } from "./channels/sms-channel"
|
||||
export { createWechatSender, isWechatEnabled } from "./channels/wechat-channel"
|
||||
export { createEmailSender, isEmailEnabled } from "./channels/email-channel"
|
||||
export { createInAppSender } from "./channels/in-app-channel"
|
||||
70
src/modules/notifications/types.ts
Normal file
70
src/modules/notifications/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 通知渠道类型定义
|
||||
*
|
||||
* 本文件定义了通知分发系统使用的核心类型:
|
||||
* - NotificationChannel: 支持的渠道枚举
|
||||
* - NotificationPayload: 通知负载(跨渠道统一)
|
||||
* - ChannelSendResult: 单次发送结果
|
||||
* - NotificationChannelConfig: 渠道配置(从环境变量加载)
|
||||
*/
|
||||
|
||||
/** 支持的通知渠道 */
|
||||
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
|
||||
|
||||
/** 通知负载(跨渠道统一格式) */
|
||||
export interface NotificationPayload {
|
||||
userId: string
|
||||
title: string
|
||||
content: string
|
||||
/** 通知语义类型(用于渠道内模板映射,不与 messaging.NotificationType 耦合) */
|
||||
type: "info" | "warning" | "error" | "success"
|
||||
metadata?: Record<string, unknown>
|
||||
/** 点击通知后的跳转地址(站内相对路径或外链) */
|
||||
actionUrl?: string
|
||||
}
|
||||
|
||||
/** 单次渠道发送结果 */
|
||||
export interface ChannelSendResult {
|
||||
channel: NotificationChannel
|
||||
success: boolean
|
||||
/** 渠道返回的消息 ID(用于追踪) */
|
||||
messageId?: string
|
||||
/** 失败时的错误信息 */
|
||||
error?: string
|
||||
sentAt: Date
|
||||
}
|
||||
|
||||
/** SMS 渠道配置 */
|
||||
export interface SmsChannelConfig {
|
||||
provider: "aliyun" | "tencent" | "mock"
|
||||
accessKeyId?: string
|
||||
accessKeySecret?: string
|
||||
signName?: string
|
||||
templateCode?: string
|
||||
}
|
||||
|
||||
/** 微信公众号渠道配置 */
|
||||
export interface WechatChannelConfig {
|
||||
appId?: string
|
||||
appSecret?: string
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
/** 邮件渠道配置 */
|
||||
export interface EmailChannelConfig {
|
||||
provider: "resend" | "nodemailer" | "mock"
|
||||
apiKey?: string
|
||||
from?: string
|
||||
host?: string
|
||||
port?: number
|
||||
user?: string
|
||||
pass?: string
|
||||
}
|
||||
|
||||
/** 通知渠道总配置 */
|
||||
export interface NotificationChannelConfig {
|
||||
enabled: boolean
|
||||
sms?: SmsChannelConfig
|
||||
wechat?: WechatChannelConfig
|
||||
email?: EmailChannelConfig
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
99
src/shared/hooks/use-aria-live.ts
Normal file
99
src/shared/hooks/use-aria-live.ts
Normal 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
74
src/shared/lib/a11y.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
* 生成唯一 ID(用于 aria-describedby、aria-labelledby 等)。
|
||||
* 基于 React.useId,SSR 安全,服务端与客户端一致。
|
||||
*/
|
||||
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",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user