diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index a825537..b15ec8e 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -949,6 +949,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ P1 已修复:~~`updateNotificationPreferencesAction` 缺少 Zod 校验~~ 已添加 `UpdateNotificationPreferencesSchema` 校验 8 个布尔字段 - ✅ P2 已修复:`data-access.ts` 中 3 处 `or(...)!` 非空断言清理为安全守卫(条件 push) - ✅ P0-b 已修复:~~`notification-preferences.ts` re-export shim 文件~~ 已删除(通知模块去重),8 个消费方改为直接从 `@/modules/notifications/preferences` 导入 `getNotificationPreferences` / `upsertNotificationPreferences`,消除 messaging 模块对通知偏好的冗余 re-export 层 +- ✅ P1 已修复:~~全模块零 i18n,中英文案硬编码~~ 所有组件接入 next-intl(`useTranslations("messages")`),新增 `src/shared/i18n/messages/{zh-CN,en}/messages.json` 翻译字典(title/description/tabs/actions/form/status/meta/notificationType/search/empty/messages/error 共 13 个命名空间);所有页面 `page.tsx` 使用 `generateMetadata` + `getTranslations` 替代硬编码 metadata +- ✅ P1 已修复:~~缺 Error Boundary~~ 新增 3 个 `error.tsx` 错误边界(`/messages`、`/messages/[id]`、`/messages/compose`),统一使用 `EmptyState` + i18n 错误文案 + 重试按钮 +- ✅ P2 已修复:a11y 改进,`message-list.tsx` / `notification-dropdown.tsx` 添加 `aria-label` / `aria-hidden` **文件清单**: | 文件 | 行数 | 职责 | diff --git a/docs/architecture/audit/announcements-messages-audit-report.md b/docs/architecture/audit/announcements-messages-audit-report.md new file mode 100644 index 0000000..994d11a --- /dev/null +++ b/docs/architecture/audit/announcements-messages-audit-report.md @@ -0,0 +1,323 @@ +# 公告和消息模块审计报告 + +> 审查日期:2026-06-22 +> 审查范围:`src/modules/announcements/**`、`src/modules/messaging/**`、`src/modules/notifications/**`、`src/app/(dashboard)/announcements/**`、`src/app/(dashboard)/admin/announcements/**`、`src/app/(dashboard)/messages/**` +> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.13 / §2.14 / §2.16、`docs/architecture/005_architecture_data.json` + +--- + +## 一、现有实现概要 + +### 1.1 文件分布 + +| 层 | 路径 | 文件数 | 说明 | +|----|------|--------|------| +| 路由层 - 用户端公告 | `src/app/(dashboard)/announcements/` | 2 个 `page.tsx` + 1 个 `loading.tsx` | 列表 + 详情,所有角色共用 | +| 路由层 - 管理端公告 | `src/app/(dashboard)/admin/announcements/` | 2 个 `page.tsx` + 1 个 `loading.tsx` | 管理列表 + 编辑 | +| 路由层 - 消息 | `src/app/(dashboard)/messages/` | 3 个 `page.tsx` + 3 个 `loading.tsx` + 1 个 `error.tsx` | 列表 + 详情 + 撰写 | +| 模块层 - announcements | `src/modules/announcements/` | 4 个核心文件 + 5 个组件 | actions(296行) / data-access(197行) / types(61行) / schema(45行) | +| 模块层 - messaging | `src/modules/messaging/` | 4 个核心文件 + 6 个组件 | actions(312行) / data-access(246行) / types(52行) / schema(44行) | +| 模块层 - notifications | `src/modules/notifications/` | 6 个核心文件 + 5 个渠道文件 | actions(159行) / data-access(174行) / dispatcher(152行) / preferences(191行) / types(153行) | + +### 1.2 数据流 + +``` +[Route] /announcements/page.tsx + └─▶ announcements/data-access.getAnnouncements (status=published, audience={gradeId,classId}) + └─▶ classes/data-access.getClassGradeId / getStudentActiveClassId / getStudentActiveGradeId + +[Route] /admin/announcements/page.tsx + ├─▶ announcements/data-access.getAnnouncements + ├─▶ school/data-access.getGrades + └─▶ classes/data-access.getAdminClasses + (页面层直接编排 3 个模块的 data-access) + +[Route] /messages/page.tsx + ├─▶ messaging/data-access.getMessages + └─▶ notifications/data-access.getNotifications + (页面层直接编排 2 个模块的 data-access) + +[Route] /messages/compose/page.tsx + └─▶ messaging/data-access.getRecipients + └─▶ classes/data-access.getStudentIdsByClassIds / getTeacherIdsByClassIds / getClassesByGradeId / getStudentActiveClassId + └─▶ users/data-access.getUserNamesByIds + +[Action] announcements/actions.createAnnouncementAction + └─▶ notifications.sendBatchNotifications (发布公告时批量通知) + +[Action] messaging/actions.sendMessageAction + └─▶ notifications.dispatcher.sendNotification (发消息时通知收件人) +``` + +### 1.3 架构图记录情况 + +`004_architecture_impact_map.md` 对三个模块的记录较为完整: +- §2.13 messaging:记录了 P0-4 / P1-5 已修复的双向依赖问题,文件清单准确 +- §2.14 notifications:记录了渠道抽象和从 messaging 迁移的历史 +- §2.16 announcements:记录了模块职责和依赖关系 + +**但存在以下遗漏**: +- 未记录 messaging 组件目录下 `notification-dropdown.tsx` 和 `unread-message-badge.tsx` 两个组件 +- 未记录 announcements 模块的 `components/` 子目录(5 个组件文件未在文件清单中列出) +- 未记录消息列表的客户端搜索行为(`getMessagesAction` 在客户端被调用) +- 未记录通知下拉菜单的 30 秒轮询机制 + +--- + +## 二、现存问题与原因分析 + +### 2.1 国际化完全缺失(P0) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| [announcements/components/announcement-list.tsx](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-list.tsx) L24-29 | `"All"` / `"Published"` / `"Draft"` / `"Archived"` 硬编码 | "所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键" | +| [announcements/components/announcement-detail.tsx](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-detail.tsx) L29-38 | `STATUS_LABEL` / `TYPE_LABEL` 全英文硬编码 | 同上 | +| [announcements/components/announcement-card.tsx](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-card.tsx) L9-28 | `STATUS_LABEL` / `TYPE_LABEL` 重复定义且硬编码 | 同上 | +| [announcements/components/announcement-form.tsx](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-form.tsx) L86,92,98,108 | `"New Announcement"` / `"Title"` / `"Content"` 等硬编码 | 同上 | +| [messaging/components/message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L81-88 | `"Inbox"` / `"Sent"` / `"Compose"` 硬编码 | 同上 | +| [messaging/components/message-detail.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-detail.tsx) L38,74,99-106 | `"From"` / `"To"` / `"Message"` / `"New"` / `"Read"` / `"Sent"` 硬编码 | 同上 | +| [messaging/components/message-compose.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-compose.tsx) L78,84,102,113 | `"Reply"` / `"New Message"` / `"To"` / `"Subject"` 硬编码 | 同上 | +| [messaging/components/notification-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/notification-list.tsx) L25-30,69-70 | `TYPE_LABEL` 硬编码,`"Notifications"` 标题硬编码 | 同上 | +| [messaging/components/notification-dropdown.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/notification-dropdown.tsx) L113 | `"Notifications"` / `"Mark all read"` 硬编码 | 同上 | +| `src/shared/i18n/messages/` | **无 `announcements.json` 或 `messages.json`** | 翻译文件结构不完整 | +| [i18n/request.ts](file:///e:/Desktop/CICD/src/i18n/request.ts) L22-29 | 未加载 announcements/messages 翻译文件 | 翻译文件未注册 | + +**后果**:所有用户可见文本无法切换语言,中文用户看到全英文界面,严重影响 K12 学校教师/家长/学生的使用体验。同一组件中 `STATUS_LABEL` 重复定义(card 和 detail 各一份),维护成本高。 + +### 2.2 角色硬编码与配置驱动缺失(P0) + +| 位置 | 代码 | 违反规则 | +|------|------|----------| +| [layout/config/navigation.ts](file:///e:/Desktop/CICD/src/modules/layout/config/navigation.ts) L39 | `NAV_CONFIG: Partial>` 按角色分组 | "前端权限判断统一使用 `usePermission().hasPermission()`,严禁出现 `role === 'xxx'` 硬编码" | +| 同上 L99-103, L247-251, L307-311, L343-347 | admin/teacher/student/parent 各自配置 `Announcements` 和 `Messages` 导航项 | 配置未抽象,新增角色需复制粘贴 | + +**后果**:新增角色(如 `grade_head` 已存在)无法享受公告/消息导航;导航配置按角色而非权限驱动,违反"配置驱动设计"原则。 + +### 2.3 架构分层:页面层越权编排(P1) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| [admin/announcements/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/announcements/page.tsx) L33-37 | 页面层 `Promise.all` 调用 announcements/school/classes 三个模块的 data-access | "app/ 只能调用 modules/ 的 Server Actions 和 data-access" — 虽语法允许,但编排逻辑应在模块 actions 层完成 | +| [messages/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/messages/page.tsx) L17-20 | 页面层并行调用 messaging 和 notifications 两个模块的 data-access | 同上 | +| [announcements/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/announcements/page.tsx) L27-76 | `resolveAudience` 函数包含 50 行业务逻辑(根据 dataScope 解析受众) | 纯逻辑应抽为 hooks 或 data-access 层函数 | +| announcements 模块无 `getAdminAnnouncementsPageData` 编排函数 | 缺失编排层 | "模块标准结构"要求 actions.ts 承担编排职责 | + +**后果**:页面层臃肿、逻辑不可复用、不可测试;多个页面需要相同数据时需复制编排逻辑。 + +### 2.4 模块间组件耦合(P1) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| [messaging/components/notification-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/notification-list.tsx) L16 | 直接 `import type { Notification, NotificationType } from "@/modules/notifications/types"` | "模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access(只能通过注入的接口调用)" | +| [messaging/components/notification-dropdown.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/notification-dropdown.tsx) L27 | 同上,直接 import notifications 模块类型 | 同上 | +| [messaging/components/notification-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/notification-list.tsx) L15 | 直接 import `../actions` 中的 `markAllNotificationsAsReadAction` / `markNotificationAsReadAction` | messaging 模块的 actions re-export 了 notifications 的 actions,造成职责混乱 | +| [messaging/actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L196-248 | messaging 模块定义了 6 个通知相关 Action(`getNotificationsAction` / `markNotificationAsReadAction` 等) | 通知 Action 应由 notifications 模块提供,messaging 仅负责私信 | + +**后果**:messaging 和 notifications 模块在 UI 层和 Action 层深度耦合,无法独立替换或测试;notifications 模块的 UI 组件无法复用到其他场景。 + +### 2.5 错误边界缺失(P1) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| `src/app/(dashboard)/announcements/error.tsx` | **缺失** | "每个独立的数据区块必须用 React Error Boundary 包裹" | +| `src/app/(dashboard)/announcements/[id]/error.tsx` | **缺失** | 同上 | +| `src/app/(dashboard)/admin/announcements/error.tsx` | **缺失** | 同上 | +| `src/app/(dashboard)/admin/announcements/[id]/error.tsx` | **缺失** | 同上 | +| `src/app/(dashboard)/messages/[id]/error.tsx` | **缺失** | 同上 | +| `src/app/(dashboard)/messages/compose/error.tsx` | **缺失** | 同上 | +| `src/app/(dashboard)/admin/announcements/loading.tsx` | **缺失**(仅有用户端 loading) | 加载骨架屏不完整 | + +**后果**:数据加载失败时整页崩溃,用户体验差;无权限访问时显示原始错误而非友好提示。 + +### 2.6 通知轮询性能问题(P1) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| [notification-dropdown.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/notification-dropdown.tsx) L65-68 | 每 30 秒轮询 `getNotificationsAction` + `getUnreadNotificationCountAction` | "性能:优先使用 React Server Components 获取初始数据" | +| [unread-message-badge.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/unread-message-badge.tsx) L31-33 | 每 60 秒轮询 `getUnreadMessageCountAction` | 同上 | +| 两个组件未使用 RSC 初始数据 | 客户端首次渲染无数据,需等待轮询 | "客户端组件仅负责交互" | + +**后果**:多用户同时在线时,每分钟产生大量无效请求;首屏渲染时无数据,显示空状态闪烁。 + +### 2.7 公告表单校验不足(P1) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| [schema.ts](file:///e:/Desktop/CICD/src/modules/announcements/schema.ts) L9-10 | `targetGradeId` / `targetClassId` 为 optional,未根据 `type` 做条件必填校验 | "输入使用 Zod 验证,验证失败返回结构化错误" | +| [announcement-form.tsx](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-form.tsx) L49-54 | `type === "grade"` 时不强制选择年级,`type === "class"` 时不强制选择班级 | 同上 | +| [actions.ts](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts) L43-61 | `resolveTargetUserIds` 在 `type === "grade"` 但 `targetGradeId` 为空时返回空数组,公告无人接收 | 数据完整性缺失 | + +**后果**:管理员可能创建无受众的公告,发布公告后无人收到通知,且无任何错误提示。 + +### 2.8 消息列表搜索逻辑复杂且无分页 UI(P1) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L38-58 | 客户端 `useEffect` + `setTimeout` 防抖搜索,但未取消已发出的请求 | "可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks" | +| 同上 L71-74 | `filtered` 在客户端再次过滤 `displayMessages`,与已搜索结果重复过滤 | 逻辑冗余 | +| 同上 L17 | 初始加载 `pageSize: 50`,但无分页 UI,超过 50 条无法查看 | "明确处理空数据、无权限、网络异常等边界状态" | +| [messages/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/messages/page.tsx) L18 | 一次性加载 50 条消息,无虚拟滚动 | 性能问题 | + +**后果**:消息超过 50 条时用户无法查看历史;搜索逻辑与 UI 混合,无法单独测试。 + +### 2.9 无权限与空状态处理不友好(P1) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| 所有页面 | `requirePermission` 抛出 `PermissionDeniedError` 后,由上层 `error.tsx` 处理,但无专门的无权限空状态 | "明确处理空数据、无权限、网络异常等边界状态" | +| [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L116-127 | 空状态文本硬编码且未区分"无权限"与"无数据" | 同上 | +| [notification-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/notification-list.tsx) L80-86 | 通知空状态未提供"去设置通知偏好"等引导操作 | 用户体验不完整 | + +**后果**:用户无法区分"无数据"和"无权限",无法找到下一步操作引导。 + +### 2.10 可访问性问题(P2) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L104-110 | 搜索框无 `aria-label`,仅靠 `placeholder` | "可访问性(a11y):语义化标签、ARIA 属性、键盘导航" | +| [notification-dropdown.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/notification-dropdown.tsx) L139-144 | `DropdownMenuItem` 的 `onSelect` 阻止默认行为后手动调用 `handleMarkRead`,键盘导航时焦点处理不明确 | 同上 | +| [announcement-card.tsx](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-card.tsx) L66-72 | 整个 Card 作为链接,但无 `aria-label` 描述跳转目标 | 同上 | +| [notification-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/notification-list.tsx) L118-124 | "Mark as read" 按钮无 `aria-label`,屏幕阅读器无法识别 | 同上 | + +**后果**:视障用户无法有效使用公告和消息功能,不符合 WCAG 2.1 AA 标准。 + +### 2.11 监控埋点缺失(P2) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| [announcements/actions.ts](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts) | 发布/归档/删除公告无埋点 | "监控:方案中预留关键操作埋点接口" | +| [messaging/actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) | 发送/删除消息无埋点 | 同上 | +| [notifications/data-access.ts](file:///e:/Desktop/CICD/src/modules/notifications/data-access.ts) L167-173 | 仅 `console.info` 输出发送日志,无结构化埋点 | 同上 | + +**后果**:无法追踪公告阅读率、消息回复率等关键指标;通知发送失败无法告警。 + +### 2.12 消息软删除无事务(P2) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| [messaging/data-access.ts](file:///e:/Desktop/CICD/src/modules/messaging/data-access.ts) L180-191 | `deleteMessage` 执行两个独立的 UPDATE(senderDeletedAt + receiverDeletedAt),无事务 | "安全性:所有敏感数据查询必须在 data-access 层结合当前用户权限过滤" | +| 同上 | 两个 UPDATE 之间可能部分失败,导致数据不一致 | 数据完整性问题 | + +**后果**:发送方删除后接收方可能仍可见,或反之,造成数据不一致。 + +### 2.13 测试覆盖不足(P2) + +| 位置 | 问题 | 违反规则 | +|------|------|----------| +| `tests/e2e/announcements.spec.ts` | 仅 2 个测试(未登录重定向 + 登录后可见),无管理端测试 | "可测试性" | +| `tests/e2e/` | **无 messaging 模块 E2E 测试** | 同上 | +| `src/modules/announcements/` | 无单元测试 | 同上 | +| `src/modules/messaging/` | 无单元测试 | 同上 | +| `src/modules/notifications/` | 无单元测试 | 同上 | + +**后果**:重构时无回归保障,关键业务逻辑(权限过滤、受众解析、通知分发)错误无法及时发现。 + +--- + +## 三、行业差距对比 + +### 3.1 公告模块差距 + +| 功能 | 行业优秀实践 | 当前状态 | 影响 | +|------|-------------|----------|------| +| 公告分类标签 | 支持自定义标签(紧急、活动、政策),可按标签筛选 | 仅 type(school/grade/class)和 status,无标签 | 教师无法快速筛选紧急公告 | +| 已读回执 | 显示已读/未读用户列表,支持提醒未读 | 无已读回执,仅通知发送 | 管理员无法知道公告是否被阅读 | +| 富文本编辑 | 支持富文本、图片、附件 | 仅纯文本 Textarea | 公告内容单调,无法插入图片 | +| 定时发布 | 支持指定时间自动发布 | `publishedAt` 字段存在但表单未暴露 | 管理员无法提前安排公告 | +| 公告置顶 | 支持置顶重要公告 | 无置顶功能 | 重要公告可能被新公告淹没 | +| 多渠道推送 | 站内 + 短信 + 邮件 + 微信 | 已实现多渠道(notifications 模块) | ✅ 已达标 | +| 评论互动 | 支持公告下评论或确认收到 | 无互动功能 | 无法收集公告反馈 | + +### 3.2 消息模块差距 + +| 功能 | 行业优秀实践 | 当前状态 | 影响 | +|------|-------------|----------|------| +| 消息分组 | 按联系人分组显示对话 | 仅按时间列表,无对话分组 | 教师与同一家长的来回消息散落各处 | +| 实时推送 | WebSocket / SSE 实时推送 | 30/60 秒轮询 | 消息延迟最高 30 秒,服务器压力大 | +| 消息草稿 | 支持草稿自动保存 | 无草稿功能 | 用户意外离开页面内容丢失 | +| 附件支持 | 支持发送文件附件 | 仅纯文本 | 无法发送作业截图等 | +| 消息星标 | 支持标记重要消息 | 无星标功能 | 重要消息无法快速找回 | +| 消息模板 | 支持常用消息模板 | 无模板 | 教师重复输入相同内容 | +| 群发消息 | 支持按班级/年级群发 | 仅支持单发 | 教师需逐个发送通知 | +| 消息搜索 | 全文搜索 + 按联系人/时间筛选 | 仅关键词搜索 subject + content | 无法按联系人筛选历史消息 | +| 已读回执 | 实时显示对方已读状态 | 仅 `readAt` 字段,无实时更新 | 发送方不知道消息是否被看到 | + +### 3.3 通知模块差距 + +| 功能 | 行业优秀实践 | 当前状态 | 影响 | +|------|-------------|----------|------| +| 通知分类管理 | 支持按类型分组(作业/成绩/公告/消息) | 仅按时间列表,类型仅作为 Badge | 用户无法快速找到特定类型通知 | +| 通知静音 | 支持单类通知静音 | 有 `quietHours` 但仅全局免打扰 | 用户想静音作业通知但保留成绩通知无法实现 | +| 通知归档 | 支持归档已处理通知 | 仅标记已读,无归档 | 通知列表越来越长 | +| 通知优先级 | 支持高/中/低优先级 | 无优先级 | 紧急通知被普通通知淹没 | +| 桌面推送 | 支持浏览器桌面通知 | 仅站内下拉 | 用户不打开页面就收不到通知 | + +### 3.4 多角色体验差距 + +| 角色 | 痛点 | 当前状态 | 影响 | +|------|------|----------|------| +| admin | 公告管理需切换到独立页面 | `/admin/announcements` 与 `/announcements` 分离 | 管理员查看用户视角需切换路由 | +| teacher | 消息收件人列表无法搜索 | `MessageCompose` 仅 Select 下拉 | 班级多时难以找到目标家长 | +| parent | 无法主动给教师发消息 | 依赖 `getRecipients` 返回的列表 | 家长需等待教师先发消息才能回复 | +| student | 公告无"确认收到"按钮 | 仅被动查看 | 学校无法确认学生是否看到公告 | + +--- + +## 四、改进优先级建议 + +### P0(紧急,影响核心功能与安全) + +1. **i18n 全覆盖**:创建 `announcements.json` 和 `messages.json` 翻译文件,重构所有组件使用 `useTranslations` 替换硬编码文本,更新 `i18n/request.ts` 加载新文件。 +2. **消除角色硬编码**:将 `NAV_CONFIG` 改为权限驱动配置,公告和消息导航项仅声明 `permission`,不按角色分组。 +3. **补充错误边界**:为所有缺失的页面添加 `error.tsx`,区分"无权限"、"未找到"、"网络错误"三种状态。 + +### P1(重要,影响架构与体验) + +4. **解耦 messaging 与 notifications**:将通知相关组件(`notification-list.tsx`、`notification-dropdown.tsx`)迁移至 notifications 模块;messaging 模块仅保留私信组件;通过 Context 注入数据服务接口。 +5. **页面编排下沉**:在 announcements 和 messaging 模块新增 `getAdminAnnouncementsPageData` / `getMessagesPageData` 编排函数,页面层仅调用单一函数。 +6. **公告表单条件校验**:使用 Zod `superRefine` 根据 `type` 强制要求 `targetGradeId` / `targetClassId`。 +7. **消息列表分页与虚拟滚动**:添加分页 UI,超过 50 条时支持加载更多;搜索逻辑抽离为 `useMessageSearch` hook。 +8. **通知实时推送**:将 30 秒轮询替换为 SSE 或 WebSocket,减少无效请求;首屏使用 RSC 获取初始数据。 +9. **消息软删除事务化**:使用数据库事务包裹 `senderDeletedAt` 和 `receiverDeletedAt` 更新。 + +### P2(优化,提升完整性与可维护性) + +10. **a11y 改进**:为搜索框、按钮、链接添加 `aria-label`;确保键盘导航完整。 +11. **监控埋点**:在关键 Action 中预留 `trackEvent` 接口,记录发布公告、发送消息、标记已读等操作。 +12. **测试覆盖**:补充 messaging 模块 E2E 测试;为 `resolveTargetUserIds`、`getRecipients`、`selectChannels` 等纯函数添加单元测试。 +13. **行业功能补齐**:公告已读回执、消息分组对话、消息草稿、通知优先级(按业务优先级逐步实施)。 +14. **架构图同步**:补充 announcements 组件目录、messaging 的 notification-dropdown/unread-message-badge 组件、客户端搜索行为、轮询机制。 + +--- + +## 五、架构图同步说明 + +本次审计发现架构图存在以下遗漏,需补充: + +### 5.1 `004_architecture_impact_map.md` 需补充 + +**§2.13 messaging 模块文件清单**: +- 当前记录:`actions.ts` 276 行 / `data-access.ts` / `schema.ts` 41 行 +- 实际状态:`actions.ts` 312 行 / `data-access.ts` 246 行 / `schema.ts` 44 行 / `types.ts` 52 行 +- **遗漏组件**:`components/notification-dropdown.tsx`、`components/unread-message-badge.tsx` 未在文件清单中列出 +- **遗漏行为**:`notification-dropdown.tsx` 每 30 秒轮询、`unread-message-badge.tsx` 每 60 秒轮询 + +**§2.16 announcements 模块文件清单**: +- 当前记录:仅列出 actions/data-access/schema/types +- **遗漏组件目录**:`components/` 下 5 个组件(`admin-announcements-view.tsx`、`announcement-card.tsx`、`announcement-detail.tsx`、`announcement-form.tsx`、`announcement-list.tsx`)未列出 + +**§2.13 messaging 依赖关系**: +- **遗漏**:`messaging/components/notification-list.tsx` 和 `notification-dropdown.tsx` 直接 import `@/modules/notifications/types`,存在跨模块 UI 类型依赖 + +### 5.2 `005_architecture_data.json` 需补充 + +- `modules.messaging.components` 数组缺少 `notification-dropdown.tsx` 和 `unread-message-badge.tsx` 两个节点 +- `modules.announcements.components` 数组完全缺失(5 个组件节点未记录) +- `modules.messaging.exports` 缺少 `UnreadMessageBadge` 组件导出 +- `routes` 节点中 `/messages` 路由的 `dataAccess` 字段未记录客户端搜索行为(`getMessagesAction` 在客户端被调用) + +### 5.3 无需修改的部分 + +- §2.14 notifications 模块记录完整准确 +- P0-4 / P1-5 修复历史记录准确 +- 依赖矩阵(§3)中 messaging → notifications 的单向依赖记录正确 diff --git a/src/app/(dashboard)/admin/announcements/[id]/error.tsx b/src/app/(dashboard)/admin/announcements/[id]/error.tsx new file mode 100644 index 0000000..da92bec --- /dev/null +++ b/src/app/(dashboard)/admin/announcements/[id]/error.tsx @@ -0,0 +1,24 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function EditAnnouncementError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("announcements") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/announcements/[id]/page.tsx b/src/app/(dashboard)/admin/announcements/[id]/page.tsx index d7504ca..437d176 100644 --- a/src/app/(dashboard)/admin/announcements/[id]/page.tsx +++ b/src/app/(dashboard)/admin/announcements/[id]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation" import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -8,13 +9,16 @@ import { getAnnouncementById } from "@/modules/announcements/data-access" import { getGrades } from "@/modules/school/data-access" import { AnnouncementForm } from "@/modules/announcements/components/announcement-form" -export const metadata: Metadata = { - title: "编辑公告 - Next_Edu", - description: "更新公告详情", -} - export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("announcements") + return { + title: t("title.edit"), + description: t("description.edit"), + } +} + export default async function EditAnnouncementPage({ params, }: { @@ -22,6 +26,7 @@ export default async function EditAnnouncementPage({ }): Promise { await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) const { id } = await params + const t = await getTranslations("announcements") const [announcement, grades] = await Promise.all([ getAnnouncementById(id), @@ -33,8 +38,8 @@ export default async function EditAnnouncementPage({ return (
-

编辑公告

-

更新公告详情。

+

{t("title.edit")}

+

{t("description.edit")}

void }) { + const t = useTranslations("announcements") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/announcements/page.tsx b/src/app/(dashboard)/admin/announcements/page.tsx index 491c259..762ec12 100644 --- a/src/app/(dashboard)/admin/announcements/page.tsx +++ b/src/app/(dashboard)/admin/announcements/page.tsx @@ -1,5 +1,6 @@ -import type { Metadata } from "next" +import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -10,13 +11,16 @@ import { AdminAnnouncementsView } from "@/modules/announcements/components/admin import { getSearchParam, type SearchParams } from "@/shared/lib/utils" import type { AnnouncementStatus } from "@/modules/announcements/types" -export const metadata: Metadata = { - title: "公告管理 - Next_Edu", - description: "管理系统公告,支持草稿、发布与归档", -} - export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("announcements") + return { + title: t("title.adminList"), + description: t("description.adminList"), + } +} + const isValidStatus = (v?: string): v is AnnouncementStatus => v === "draft" || v === "published" || v === "archived" diff --git a/src/app/(dashboard)/announcements/[id]/error.tsx b/src/app/(dashboard)/announcements/[id]/error.tsx new file mode 100644 index 0000000..1df2a2c --- /dev/null +++ b/src/app/(dashboard)/announcements/[id]/error.tsx @@ -0,0 +1,24 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AnnouncementDetailError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("announcements") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/announcements/[id]/page.tsx b/src/app/(dashboard)/announcements/[id]/page.tsx index 254453f..d10938d 100644 --- a/src/app/(dashboard)/announcements/[id]/page.tsx +++ b/src/app/(dashboard)/announcements/[id]/page.tsx @@ -1,18 +1,20 @@ import { notFound } from "next/navigation" import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getAnnouncementById } from "@/modules/announcements/data-access" import { AnnouncementDetail } from "@/modules/announcements/components/announcement-detail" -export const metadata: Metadata = { - title: "Announcement - Next_Edu", -} - export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("announcements") + return { title: t("title.detail") } +} + export default async function AnnouncementDetailPage({ params, }: { diff --git a/src/app/(dashboard)/announcements/error.tsx b/src/app/(dashboard)/announcements/error.tsx new file mode 100644 index 0000000..bbe134a --- /dev/null +++ b/src/app/(dashboard)/announcements/error.tsx @@ -0,0 +1,24 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AnnouncementsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("announcements") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/announcements/page.tsx b/src/app/(dashboard)/announcements/page.tsx index af2cead..1abfd0c 100644 --- a/src/app/(dashboard)/announcements/page.tsx +++ b/src/app/(dashboard)/announcements/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -12,8 +13,9 @@ import { export const dynamic = "force-dynamic" -export const metadata: Metadata = { - title: "Announcements", +export async function generateMetadata(): Promise { + const t = await getTranslations("announcements") + return { title: t("title.list") } } /** @@ -76,6 +78,7 @@ async function resolveAudience(ctx: { } export default async function AnnouncementsPage() { + const t = await getTranslations("announcements") const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ) const audience = await resolveAudience(ctx) @@ -87,9 +90,9 @@ export default async function AnnouncementsPage() { return (
-

Announcements

+

{t("title.list")}

- Stay up to date with the latest school announcements. + {t("description.list")}

void }) { + const t = useTranslations("messages") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/messages/[id]/page.tsx b/src/app/(dashboard)/messages/[id]/page.tsx index b9ba839..3c4f3da 100644 --- a/src/app/(dashboard)/messages/[id]/page.tsx +++ b/src/app/(dashboard)/messages/[id]/page.tsx @@ -1,5 +1,7 @@ import { notFound } from "next/navigation" +import type { Metadata } from "next" import { after } from "next/server" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getMessageById, markMessageAsRead } from "@/modules/messaging/data-access" @@ -7,15 +9,16 @@ import { MessageDetail } from "@/modules/messaging/components/message-detail" export const dynamic = "force-dynamic" -export const metadata = { - title: "Message Detail", +export async function generateMetadata(): Promise { + const t = await getTranslations("messages") + return { title: t("title.detail") } } export default async function MessageDetailPage({ params, }: { params: Promise<{ id: string }> -}) { +}): Promise { const ctx = await requirePermission(Permissions.MESSAGE_READ) const { id } = await params diff --git a/src/app/(dashboard)/messages/compose/error.tsx b/src/app/(dashboard)/messages/compose/error.tsx new file mode 100644 index 0000000..7899cfc --- /dev/null +++ b/src/app/(dashboard)/messages/compose/error.tsx @@ -0,0 +1,24 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function ComposeMessageError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("messages") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/messages/compose/page.tsx b/src/app/(dashboard)/messages/compose/page.tsx index 5315ddb..f9044f5 100644 --- a/src/app/(dashboard)/messages/compose/page.tsx +++ b/src/app/(dashboard)/messages/compose/page.tsx @@ -1,3 +1,6 @@ +import type { Metadata } from "next" +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getRecipients } from "@/modules/messaging/data-access" @@ -5,17 +8,22 @@ import { MessageCompose } from "@/modules/messaging/components/message-compose" export const dynamic = "force-dynamic" -export const metadata = { - title: "Compose Message", +export async function generateMetadata(): Promise { + const t = await getTranslations("messages") + return { + title: t("title.compose"), + description: t("description.compose"), + } } export default async function ComposeMessagePage({ searchParams, }: { searchParams: Promise<{ parentId?: string; receiverId?: string; subject?: string }> -}) { +}): Promise { const ctx = await requirePermission(Permissions.MESSAGE_SEND) const sp = await searchParams + const t = await getTranslations("messages") const recipients = await getRecipients(ctx.userId, ctx.dataScope) @@ -23,8 +31,8 @@ export default async function ComposeMessagePage({
-

Compose Message

-

Send a message to another user.

+

{t("title.compose")}

+

{t("description.compose")}

void }) { + const t = useTranslations("messages") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/messages/page.tsx b/src/app/(dashboard)/messages/page.tsx index eb55a01..52c5287 100644 --- a/src/app/(dashboard)/messages/page.tsx +++ b/src/app/(dashboard)/messages/page.tsx @@ -1,3 +1,5 @@ +import { getTranslations } from "next-intl/server" + import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getMessages } from "@/modules/messaging/data-access" @@ -7,11 +9,13 @@ import { NotificationList } from "@/modules/messaging/components/notification-li export const dynamic = "force-dynamic" -export const metadata = { - title: "Messages", +export async function generateMetadata() { + const t = await getTranslations("messages") + return { title: t("title.list") } } export default async function MessagesPage() { + const t = await getTranslations("messages") const ctx = await requirePermission(Permissions.MESSAGE_READ) const [messagesResult, notificationsResult] = await Promise.all([ @@ -22,9 +26,9 @@ export default async function MessagesPage() { return (
-

Messages

+

{t("title.list")}

- Manage your inbox and stay updated with notifications. + {t("description.list")}

diff --git a/src/modules/announcements/components/admin-announcements-view.tsx b/src/modules/announcements/components/admin-announcements-view.tsx index 9c32de4..0171c67 100644 --- a/src/modules/announcements/components/admin-announcements-view.tsx +++ b/src/modules/announcements/components/admin-announcements-view.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { PlusCircle } from "lucide-react" +import { useTranslations } from "next-intl" import { Button } from "@/shared/components/ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" @@ -22,6 +23,7 @@ export function AdminAnnouncementsView({ classes?: { id: string; name: string }[] initialStatus?: AnnouncementStatus }) { + const t = useTranslations("announcements") const router = useRouter() const [createOpen, setCreateOpen] = useState(false) @@ -34,14 +36,14 @@ export function AdminAnnouncementsView({
-

Announcements

+

{t("title.adminList")}

- Create and manage school-wide announcements. + {t("description.adminList")}

@@ -55,7 +57,7 @@ export function AdminAnnouncementsView({ - New Announcement + {t("title.create")} diff --git a/src/modules/announcements/components/announcement-card.tsx b/src/modules/announcements/components/announcement-card.tsx index 5c8ff6e..e69f5f6 100644 --- a/src/modules/announcements/components/announcement-card.tsx +++ b/src/modules/announcements/components/announcement-card.tsx @@ -1,32 +1,13 @@ "use client" import Link from "next/link" +import { useTranslations } from "next-intl" + import { Badge } from "@/shared/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { formatDate } from "@/shared/lib/utils" import type { Announcement } from "../types" -const STATUS_LABEL: Record = { - draft: "Draft", - published: "Published", - archived: "Archived", -} - -const STATUS_VARIANT: Record< - Announcement["status"], - "default" | "secondary" | "outline" -> = { - draft: "secondary", - published: "default", - archived: "outline", -} - -const TYPE_LABEL: Record = { - school: "School", - grade: "Grade", - class: "Class", -} - export function AnnouncementCard({ announcement, href, @@ -34,12 +15,20 @@ export function AnnouncementCard({ announcement: Announcement href?: string }) { + const t = useTranslations("announcements") + + const statusVariant: Record = { + draft: "secondary", + published: "default", + archived: "outline", + } + const card = ( {announcement.title} - - {STATUS_LABEL[announcement.status]} + + {t(`status.${announcement.status}`)} @@ -48,15 +37,15 @@ export function AnnouncementCard({

- {TYPE_LABEL[announcement.type]} + {t(`type.${announcement.type}`)} {announcement.publishedAt - ? `Published ${formatDate(announcement.publishedAt)}` - : `Updated ${formatDate(announcement.updatedAt)}`} + ? t("meta.publishedAt", { date: formatDate(announcement.publishedAt) }) + : t("meta.updatedAt", { date: formatDate(announcement.updatedAt) })} {announcement.authorName ? ( - by {announcement.authorName} + {t("meta.author", { name: announcement.authorName })} ) : null}
@@ -65,7 +54,7 @@ export function AnnouncementCard({ if (href) { return ( - + {card} ) diff --git a/src/modules/announcements/components/announcement-detail.tsx b/src/modules/announcements/components/announcement-detail.tsx index e52d28e..9cd9779 100644 --- a/src/modules/announcements/components/announcement-detail.tsx +++ b/src/modules/announcements/components/announcement-detail.tsx @@ -4,6 +4,7 @@ import { useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { toast } from "sonner" +import { useTranslations } from "next-intl" import { Archive, ArrowLeft, @@ -16,16 +17,7 @@ import { import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/components/ui/alert-dialog" +import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog" import { formatDate } from "@/shared/lib/utils" import { @@ -35,18 +27,6 @@ import { } from "../actions" import type { Announcement } from "../types" -const STATUS_LABEL: Record = { - draft: "Draft", - published: "Published", - archived: "Archived", -} - -const TYPE_LABEL: Record = { - school: "School", - grade: "Grade", - class: "Class", -} - export function AnnouncementDetail({ announcement, canManage, @@ -58,6 +38,7 @@ export function AnnouncementDetail({ editHref?: string backHref?: string }) { + const t = useTranslations("announcements") const router = useRouter() const [isWorking, setIsWorking] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) @@ -70,10 +51,10 @@ export function AnnouncementDetail({ toast.success(res.message) router.refresh() } else { - toast.error(res.message || "Failed to publish") + toast.error(res.message || t("messages.publishFailed")) } } catch { - toast.error("Failed to publish") + toast.error(t("messages.publishFailed")) } finally { setIsWorking(false) } @@ -87,10 +68,10 @@ export function AnnouncementDetail({ toast.success(res.message) router.refresh() } else { - toast.error(res.message || "Failed to archive") + toast.error(res.message || t("messages.archiveFailed")) } } catch { - toast.error("Failed to archive") + toast.error(t("messages.archiveFailed")) } finally { setIsWorking(false) } @@ -105,10 +86,10 @@ export function AnnouncementDetail({ router.push("/admin/announcements") router.refresh() } else { - toast.error(res.message || "Failed to delete") + toast.error(res.message || t("messages.deleteFailed")) } } catch { - toast.error("Failed to delete") + toast.error(t("messages.deleteFailed")) } finally { setIsWorking(false) setDeleteOpen(false) @@ -120,33 +101,33 @@ export function AnnouncementDetail({
{backHref ? ( - ) : null} -

Announcement

+

{t("title.detail")}

{canManage ? (
{announcement.status !== "published" ? ( ) : null} {announcement.status !== "archived" ? ( ) : null} {editHref ? ( ) : null} @@ -156,7 +137,7 @@ export function AnnouncementDetail({ variant="destructive" > - Delete + {t("actions.delete")}
) : null} @@ -166,19 +147,19 @@ export function AnnouncementDetail({
- {TYPE_LABEL[announcement.type]} + {t(`type.${announcement.type}`)} - {STATUS_LABEL[announcement.status]} + {t(`status.${announcement.status}`)}
{announcement.title}
{announcement.publishedAt - ? `Published ${formatDate(announcement.publishedAt)}` - : `Created ${formatDate(announcement.createdAt)}`} + ? t("meta.publishedAt", { date: formatDate(announcement.publishedAt) }) + : t("meta.createdAt", { date: formatDate(announcement.createdAt) })} - {announcement.authorName ? by {announcement.authorName} : null} + {announcement.authorName ? {t("meta.author", { name: announcement.authorName })} : null}
@@ -186,22 +167,14 @@ export function AnnouncementDetail({ - - - - Delete announcement - - This will permanently delete "{announcement.title}". - - - - Cancel - - Delete - - - - +
) } diff --git a/src/modules/announcements/components/announcement-form.tsx b/src/modules/announcements/components/announcement-form.tsx index 163f5a8..c5c0ff4 100644 --- a/src/modules/announcements/components/announcement-form.tsx +++ b/src/modules/announcements/components/announcement-form.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { toast } from "sonner" +import { useTranslations } from "next-intl" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -33,6 +34,7 @@ export function AnnouncementForm({ grades?: { id: string; name: string }[] classes?: { id: string; name: string }[] }) { + const t = useTranslations("announcements") const router = useRouter() const [isWorking, setIsWorking] = useState(false) @@ -61,7 +63,7 @@ export function AnnouncementForm({ : null if (!res) { - toast.error("Invalid form state") + toast.error(t("messages.invalidState")) return } @@ -70,10 +72,10 @@ export function AnnouncementForm({ router.push("/admin/announcements") router.refresh() } else { - toast.error(res.message || "Failed to save announcement") + toast.error(res.message || t("messages.updateFailed")) } } catch { - toast.error("Failed to save announcement") + toast.error(t("messages.updateFailed")) } finally { setIsWorking(false) } @@ -83,28 +85,28 @@ export function AnnouncementForm({ - {mode === "create" ? "New Announcement" : "Edit Announcement"} + {mode === "create" ? t("title.create") : t("title.edit")}
- +
- +