31 KiB
公告(announcements)模块审计报告
审查日期:2026-06-25 审查范围:
src/modules/announcements/**、src/app/(dashboard)/announcements/**、src/app/(dashboard)/admin/announcements/**、src/shared/i18n/messages/{zh-CN,en}/announcements.json架构图参考:docs/architecture/004_architecture_impact_map.md§2.16、docs/architecture/005_architecture_data.json#announcements关联报告:announcements-messages-audit-report.md(合并版,2026-06-22,已不再维护,本文为公告模块的独立深度审计)
一、现有实现概要
1.1 文件分布
| 层 | 路径 | 文件 | 行数 | 说明 |
|---|---|---|---|---|
| 路由 · 用户端 | src/app/(dashboard)/announcements/page.tsx |
1 | 36 | 列表页(所有非管理角色共用) |
| 路由 · 用户端 | src/app/(dashboard)/announcements/[id]/page.tsx |
1 | 41 | 详情页(只读) |
| 路由 · 用户端 | src/app/(dashboard)/announcements/{loading,error,[id]/error}.tsx |
3 | 60 | 骨架屏 + 错误边界 |
| 路由 · 管理端 | src/app/(dashboard)/admin/announcements/page.tsx |
1 | 45 | 管理列表页 |
| 路由 · 管理端 | src/app/(dashboard)/admin/announcements/[id]/page.tsx |
1 | 47 | 编辑页(直接渲染表单,无详情视图) |
| 路由 · 管理端 | src/app/(dashboard)/admin/announcements/{loading,error,[id]/error}.tsx |
3 | 64 | 骨架屏 + 错误边界 |
| 模块 | src/modules/announcements/actions.ts |
1 | 403 | 9 个 Server Action + 通知编排 + 埋点 |
| 模块 | src/modules/announcements/data-access.ts |
1 | 413 | CRUD + 发布/归档 + 置顶/已读 + 3 个页面编排函数 |
| 模块 | src/modules/announcements/types.ts |
1 | 75 | 类型定义 |
| 模块 | src/modules/announcements/schema.ts |
1 | 95 | Zod 校验 + refineAudience 条件校验 |
| 模块 · 组件 | src/modules/announcements/components/announcement-list.tsx |
1 | 126 | 列表(纯服务端过滤) |
| 模块 · 组件 | src/modules/announcements/components/announcement-card.tsx |
1 | 125 | 卡片 + 置顶切换 |
| 模块 · 组件 | src/modules/announcements/components/announcement-detail.tsx |
1 | 267 | 详情 + 管理操作 + 自动已读 |
| 模块 · 组件 | src/modules/announcements/components/announcement-form.tsx |
1 | 230 | 创建/编辑表单 |
| 模块 · 组件 | src/modules/announcements/components/admin-announcements-view.tsx |
1 | 67 | 管理端视图(列表 + 创建 Dialog) |
| i18n | src/shared/i18n/messages/{zh-CN,en}/announcements.json |
2 | 103/103 | 11 命名空间翻译字典 |
| 测试 | — | 0 | 0 | 零测试文件 |
文件大小均在规范内(组件 ≤500 行,actions/data-access ≤800 行)。
1.2 数据流
[Route] /announcements/page.tsx
└─▶ announcements/data-access.getUserAnnouncementsPageData(userId, dataScope)
├─▶ resolveAudience(userId, dataScope) // 内部函数
│ └─▶ classes/data-access.{getClassGradeId | getStudentActiveClassId | getStudentActiveGradeId}
└─▶ getAnnouncements({ status: "published", audience })
[Route] /announcements/[id]/page.tsx ⚠️ 未做受众/状态过滤
├─▶ announcements/data-access.getAnnouncementById(id)
└─▶ announcements/data-access.isAnnouncementReadByUser(id, userId)
[Route] /admin/announcements/page.tsx
└─▶ announcements/data-access.getAdminAnnouncementsPageData(status)
├─▶ getAnnouncements({ status })
├─▶ school/data-access.getGrades()
└─▶ classes/data-access.getAdminClasses()
[Route] /admin/announcements/[id]/page.tsx
└─▶ announcements/data-access.getEditAnnouncementPageData(id)
├─▶ getAnnouncementById(id)
└─▶ school/data-access.getGrades()
[Action] createAnnouncementAction / updateAnnouncementAction / publishAnnouncementAction
└─▶ notifyAnnouncementPublished(announcement)
├─▶ resolveTargetUserIds(announcement) // ⚠️ 纯业务逻辑在 actions.ts
│ ├─▶ users/data-access.{getAllUserIds | getUserIdsByGradeId}
│ └─▶ classes/data-access.{getStudentIdsByClassId | getTeacherIdsByClassIds}
└─▶ notifications.sendBatchNotifications(payloads)
1.3 架构图记录情况
004_architecture_impact_map.md §2.16 对 announcements 模块的记录较为完整:
- ✅ 导出函数(9 个 Action + 14 个 data-access 函数)记录准确
- ✅ 依赖关系(
shared/*、@/auth、school、classes、users、notifications)记录准确 - ✅ 已修复问题清单(P1-2/P1-5/P1-6/V2-P0-2/V2-P1-1/V2-P1-4/V2-P2-13d/V3-P0-2)记录详实
- ✅ 文件清单与组件清单行数准确
但架构图存在以下遗漏/不一致(详见第五章):
- 未记录
AnnouncementDetail组件中canManage=true分支为死代码(无任何页面使用) - 未记录
getAnnouncementReadStatusAction为死代码(无任何调用方) - 未记录
/announcements/[id]路由层存在的安全越权风险(无受众过滤) - 未记录
resolveAudience内部函数的多孩子/多年级数据截断 Bug - 未记录 actions 返回的英文字符串未走 i18n 的问题
二、现存问题与原因分析
2.1 【P0 · 安全越权】用户端详情页无受众/状态过滤
- 位置:src/app/(dashboard)/announcements/[id]/page.tsx:26-27
- 问题:详情页直接调用
getAnnouncementById(id),未传入audience也未校验status === "published"。 - 后果:任意持有
ANNOUNCEMENT_READ权限的登录用户,只要知道/猜到公告 ID(cuid2),即可读取:- 草稿(
status="draft")公告——提前泄露未发布内容 - 已归档(
status="archived")公告——绕过归档语义 - 其他年级/班级的定向公告——跨班级信息泄露(如某班处分通知被外班学生读到)
- 草稿(
- 违反规则:项目规则"安全性:所有敏感数据查询必须在 data-access 层结合当前用户权限过滤" + "Parent routes must include permission checks with both
parentIdandstudentIdto prevent information leakage"。 - 根因:
getAnnouncementById设计为通用读取函数,未提供"按受众过滤"重载;路由层也未在读取后做二次校验。
2.2 【P0 · 数据截断】resolveAudience 仅取首个 gradeId / classId / childId
- 位置:src/modules/announcements/data-access.ts:351-395
- 问题:
if (dataScope.type === "grade_managed") { const gradeId = dataScope.gradeIds[0] // ⚠️ 仅取第一个 } if (dataScope.type === "class_members" || dataScope.type === "class_taught") { const classId = dataScope.classIds[0] // ⚠️ 仅取第一个 } if (dataScope.type === "children") { const childId = dataScope.childrenIds[0] // ⚠️ 仅取第一个孩子 } - 后果:
- 家长有多个孩子在不同班级/年级时,只能看到第一个孩子的定向公告,第二个孩子的班主任通知完全不可见——直接违反 K12 家长端核心诉求。
- 年级主任管理多个年级时,只能看到第一个年级的公告。
- 教师任课多个班级时,只能看到第一个班级的公告。
- 违反规则:项目规则"Parent routes must include permission checks with both
parentIdandstudentId" 与"data-access 层结合当前用户权限过滤"。 - 根因:
getAnnouncements的audience参数设计为单值{ gradeId?, classId? },不支持多值;resolveAudience为迁就该签名做了截断。
2.3 【P0 · 越权写】置顶/已读 Action 缺少资源所有权二次校验
- 位置:src/modules/announcements/actions.ts:335-376
- 问题:
toggleAnnouncementPinAction仅校验ANNOUNCEMENT_MANAGE,未校验公告是否存在、未校验调用者是否为该公告作者或管理员范围。markAnnouncementAsReadAction仅校验ANNOUNCEMENT_READ,未校验该公告是否对当前用户可见(即未结合 2.1 的受众过滤)。任意用户可对任意公告 ID(包括草稿、他人班级公告)写入已读记录,污染announcement_reads表。
- 后果:数据库完整性被破坏;统计
readCount失真;为后续基于已读率的分析埋下错误数据。 - 违反规则:项目规则"Server Action 二次校验"。
- 根因:Action 层信任了
requirePermission的角色校验,未做资源级(resource-level)授权。
2.4 【P1 · i18n 违规】Actions 返回英文硬编码消息
- 位置:src/modules/announcements/actions.ts 全文
- 问题:所有 Action 返回的
ActionState.message均为英文字符串:"Announcement created"/"Announcement updated"/"Announcement deleted""Announcement published"/"Announcement archived""Announcement not found"/"Invalid form data"/"Unexpected error""Pin status toggled"/"Announcement marked as read"
- 这些 message 通过
toast.success(res.message)/toast.error(res.message)直接展示给用户(见 announcement-detail.tsx:80,100,115 与 announcement-form.tsx:80,88)。 - 后果:中文用户在创建/发布/删除公告后看到英文 Toast,i18n 字典中已定义的
messages.created/messages.updated等翻译键完全未使用。 - 违反规则:项目规则"所有用户可见文本必须适配 i18n" + "Server Action 返回值统一采用
ActionState<T>类型"(隐含 message 应可本地化)。 - 根因:Actions 在 try 块内同步返回字符串,未通过
getTranslations("announcements")获取本地化文案;i18n 字典定义了键但 Action 未消费。
2.5 【P1 · 死代码】AnnouncementDetail 管理分支与 getAnnouncementReadStatusAction 无调用方
- 位置:
- src/modules/announcements/components/announcement-detail.tsx:165-200(
canManage为 true 时的发布/归档/删除/置顶/编辑按钮组) - src/modules/announcements/actions.ts:381-391(
getAnnouncementReadStatusAction)
- src/modules/announcements/components/announcement-detail.tsx:165-200(
- 问题:
- 全仓搜索
AnnouncementDetail的使用方,仅 src/app/(dashboard)/announcements/[id]/page.tsx:34-38 一处,且canManage={false}。管理端/admin/announcements/[id]直接渲染AnnouncementForm(编辑模式),没有管理端详情页。 - 全仓搜索
getAnnouncementReadStatusAction,零调用方。该 Action 返回Record<string,boolean>,本应用于列表页批量标记已读/未读,但列表页从未调用。
- 全仓搜索
- 后果:
- 管理员无法在 UI 中执行发布/归档/删除/置顶操作(除非进入编辑表单),严重限制了管理端可用性。
announcement.readCount字段在AnnouncementDetail中展示,但因canManage永远为 false,用户永远看不到已读人数——已读统计功能在 UI 层完全不可见。- 列表页公告卡片没有"已读/未读"视觉区分,已读回执的数据无法驱动 UI。
- 违反规则:项目规则"避免 backwards-compatibility hacks ... 如果确定未使用,应完全删除" + "识别四个角色共用的 UI 块"。
- 根因:管理端路由设计遗漏了详情视图;已读状态查询 Action 未被列表组件消费。
2.6 【P1 · 耦合】组件直接 import actions,未通过 Context/Provider 注入
- 位置:
- announcement-card.tsx:13:
import { toggleAnnouncementPinAction } from "../actions" - announcement-detail.tsx:25-31:
import { archiveAnnouncementAction, deleteAnnouncementAction, markAnnouncementAsReadAction, publishAnnouncementAction, toggleAnnouncementPinAction } from "../actions" - announcement-form.tsx:21:
import { createAnnouncementAction, updateAnnouncementAction } from "../actions"
- announcement-card.tsx:13:
- 问题:组件硬编码依赖具体 Server Action,无法在不修改组件代码的前提下替换为 mock 实现。
- 后果:
- 组件不可单元测试(必须 mock 整个
../actions模块)。 - 无法为不同角色注入不同实现(如家长端只读、教师端可编辑班级公告)。
- 未来若要将公告组件复用于"班级空间"或"家长端聚合页",必须重写组件。
- 组件不可单元测试(必须 mock 整个
- 违反规则:用户要求"完全解耦:通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务,模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access"。
- 根因:组件设计未遵循依赖注入原则。
2.7 【P1 · 耦合】AnnouncementForm 硬编码路由跳转
- 位置:announcement-form.tsx:82,217
- 问题:表单提交成功后
router.push("/admin/announcements"),取消按钮也跳转到/admin/announcements。 - 后果:表单无法在管理端以外的场景复用(如教师端发布班级公告、嵌入到班级详情页的快速发布公告入口)。
- 违反规则:用户要求"组合优先 ... 逻辑复用一律抽取为自定义 hooks" + "最大化复用"。
- 根因:表单未通过
onSuccess/onCancel回调或successHrefprop 解耦导航。
2.8 【P1 · 业务逻辑位置】resolveTargetUserIds 放在 actions.ts
- 位置:src/modules/announcements/actions.ts:48-66
- 问题:受众解析 + 用户 ID 聚合是纯业务逻辑(无 I/O 副作用之外的逻辑),却放在 Server Action 文件中,与 Action 编排逻辑混杂。
- 后果:
- 无法独立单元测试(必须 mock
getAllUserIds/getStudentIdsByClassId等跨模块 data-access)。 - 与
data-access.ts中的resolveAudience形成两套受众解析逻辑,职责重叠。
- 无法独立单元测试(必须 mock
- 违反规则:项目规则"可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离" + "Server Actions / Data Access 模块:建议 ≤ 800 行 ... 超过应考虑拆分"。
- 根因:actions.ts 既承担 HTTP 编排又承担业务规则,未分离 service 层。
2.9 【P1 · 性能】toggleAnnouncementPin 与 markAnnouncementAsRead 非原子操作
- 位置:src/modules/announcements/data-access.ts:210-224,234-250
- 问题:
toggleAnnouncementPin:先SELECT isPinned,再UPDATE。两次 DB 往返,且在并发场景下存在 lost update(两个管理员同时切换会得到错误结果)。markAnnouncementAsRead:先SELECT id,再INSERT。已有唯一索引保证幂等,但多一次 SELECT 浪费往返。
- 后果:高并发时数据不一致;DB 负载翻倍。
- 违反规则:项目规则"性能:优先使用 React Server Components"(隐含高效数据访问)。
- 根因:未使用 Drizzle 的
sql表达式或onDuplicateKeyUpdate/INSERT IGNORE语义。
2.10 【P1 · 错误处理】handleActionError 吞错误上下文
- 位置:src/modules/announcements/actions.ts:34-40
- 问题:
function handleActionError(e: unknown): ActionState<never> { 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" } }- 未
console.error记录错误堆栈,生产环境无法定位故障。 - 直接把
e.message返回给前端,可能泄露内部错误信息(如 SQL 错误)。 - "Unexpected error" 为英文硬编码。
- 未
- 后果:可观测性差;安全信息泄露风险。
- 违反规则:项目规则"错误与边界处理" + "i18n 就绪"。
- 根因:错误处理未与日志/埋点/i18n 集成。
2.11 【P2 · a11y】置顶按钮嵌套在 <Link> 内的键盘交互问题
- 位置:announcement-card.tsx:80-92,116-121
- 问题:
AnnouncementCard在有href时用<Link>包裹整个卡片,同时卡片内的"置顶"按钮是一个<button>。handleTogglePin调用e.preventDefault()+e.stopPropagation()处理鼠标点击,但:- 键盘聚焦到置顶按钮后按
Enter,部分浏览器会同时触发外层<a>的导航。 - 屏幕阅读器会朗读"链接 标题",但置顶按钮的
aria-label在链接上下文中语义模糊。
- 键盘聚焦到置顶按钮后按
- 后果:键盘用户可能误跳转;a11y 不达标。
- 违反规则:项目规则"可访问性(a11y):语义化标签、ARIA 属性、键盘导航"。
- 根因:交互按钮不应嵌套在导航链接内;应使用"卡片头部可点击 + 操作按钮独立"的布局。
2.12 【P2 · 类型不安全】mapRow 内联对象类型与 schema 脱钩
- 位置:src/modules/announcements/data-access.ts:23-53
- 问题:
mapRow的参数类型是手写的内联对象,未使用 Drizzle 推导类型typeof announcements.$inferSelect。 - 后果:schema 变更(如新增字段)时,
mapRow不会在编译期报错,导致类型漂移。 - 违反规则:项目规则"TypeScript 严格模式 ... 函数返回值必须显式标注"。
- 根因:未利用 Drizzle 的类型推导能力。
2.13 【P2 · i18n 字典冗余/缺失并存】
- 位置:src/shared/i18n/messages/zh-CN/announcements.json
- 问题:
- 已定义但未使用的键:
messages.created/messages.updated/messages.deleted/messages.published/messages.archived/messages.notFound/messages.createFailed/messages.invalidForm/messages.markedRead(共 9 个死键,因 actions 未消费)。 - 缺失的键:
description.detail(详情页描述)、description.create(创建 Dialog 描述)。
- 已定义但未使用的键:
- 后果:i18n 字典维护成本上升;新增页面时找不到对应键。
- 违反规则:项目规则"所有用户可见文本必须适配 i18n"。
- 根因:i18n 键与代码未做同步校验。
2.14 【P2 · 死分支】detailHrefBuilder prop 未被使用
- 位置:announcement-list.tsx:39,70-74
- 问题:
AnnouncementList同时支持detailHrefPrefix(字符串前缀)和detailHrefBuilder(函数)两种 prop,但全仓搜索detailHrefBuilder的传入方为零(所有调用方都使用detailHrefPrefix)。 - 后果:死代码增加维护负担。
- 违反规则:项目规则"避免 backwards-compatibility hacks"。
- 根因:V3 重构引入
detailHrefPrefix后未清理旧 prop。
2.15 【P2 · 重复骨架屏】用户端与管理端 loading.tsx 完全重复
- 位置:
- 问题:两个文件几乎逐行重复(仅管理端多一个"新建公告"按钮骨架),未抽取共享骨架屏组件。
- 后果:UI 调整需改两处。
- 违反规则:项目规则"Shared components must be extracted when page duplication exceeds 90%"。
- 根因:未识别到骨架屏也是可复用 UI 块。
2.16 【P2 · 无分页 UI】getAnnouncements 支持分页但 UI 未消费
- 位置:data-access.ts:55-112 支持
page/pageSize;announcement-list.tsx 无分页控件。 - 问题:列表页默认
pageSize=20,超过 20 条公告时静默截断,用户无法翻页。 - 后果:历史公告不可访问;K12 学校一学期公告数通常 > 20。
- 违反规则:用户要求"可扩展性:配置驱动设计"。
- 根因:分页参数未贯穿到 UI。
2.17 【P2 · 表单与 Dialog 行为冲突】
- 位置:admin-announcements-view.tsx:57-64
- 问题:
AdminAnnouncementsView在 Dialog 中渲染AnnouncementForm(创建模式)。但AnnouncementForm的 Cancel 按钮router.push("/admin/announcements")会触发整页跳转,而不是关闭 Dialog。提交成功后也是router.push而非onSuccess回调。 - 后果:用户体验割裂(Dialog 内按钮触发路由跳转);
handleOpenChange中的router.refresh()与表单跳转重复。 - 违反规则:项目规则"组合优先 ... 严禁使用继承或深层嵌套 HOC"。
- 根因:表单未与容器解耦。
三、行业差距对比
参考 Google Classroom、钉钉教育、企业微信家校通、飞书校园版、PowerSchool 等主流 K12 产品的公告/通知模块,对比差距如下:
| 维度 | 行业主流实践 | 当前实现 | 差距影响 |
|---|---|---|---|
| 多受众定向 | 支持多班级/多年级/多角色组合发布(如"高三1班+2班家长") | 仅支持单年级或单班级 | 年级组长需重复发布 N 次 |
| 富文本/附件 | 富文本编辑器 + 附件(PDF 通知、图片) | 纯文本 whitespace-pre-wrap |
学校正式通知无法排版、无法附带 PDF |
| 分类/标签 | 学科、活动、安全、家长信等分类筛选 | 仅按 status 筛选 | 家长在海量公告中找不到关注项 |
| 定时发布 | 选择未来时间自动发布 | schema 有 publishedAt 但 UI 未消费 |
管理员需手动踩点发布 |
| 到期/置顶 | 自动到期 + 多级优先级(紧急/普通) | 仅 pinned 布尔 | 紧急通知与普通通知无差异 |
| 已读统计仪表盘 | 管理端列表展示每条公告已读率、未读名单、可一键催读 | readCount 字段存在但 UI 未展示 |
管理员无法评估公告触达效果 |
| 草稿预览 | 编辑时预览发布后效果 | 无预览 | 发布前无法验证排版 |
| 批量操作 | 列表多选 + 批量归档/删除 | 逐条操作 | 学期末清理 50 条公告需 50 次点击 |
| 搜索 | 标题/正文全文搜索 | 无搜索 | 历史公告无法检索 |
| Dashboard 集成 | 首页"最新公告"Widget + 未读红点 | 仅家长端有快速入口链接 | 用户必须主动进入公告页 |
| 通知点击回跳 | 点击通知直达公告详情并自动已读 | 通知 actionUrl 指向详情页,但详情页无受众校验 | 通知点击可能触发越权 |
| 多语言/多角色文案 | 同一公告对家长/学生/教师展示不同侧重点 | 同一文案对所有角色 | 家长看到教师内部用语 |
| 无障碍 | 列表语义化 <ul>/<li>、键盘可达 |
<div> + <Link> 包裹按钮 |
屏幕阅读器用户导航困难 |
| 错误恢复 | 失败自动重试 + 离线草稿 | 失败仅 Toast 提示 | 网络波动时内容丢失 |
核心差距:当前实现停留在"CRUD + 状态机"的最小可用形态,缺少 K12 公告模块的"触达-反馈-统计"闭环。其中"已读统计不可见"和"无富文本/附件"是 K12 学校最痛的两个缺口。
四、改进优先级建议
P0(必须立即修复 · 安全与数据正确性)
| # | 问题 | 改进方向 |
|---|---|---|
| P0-1 | 详情页越权读取 | 新增 getAnnouncementByIdForUser(id, userId, dataScope) data-access 函数,结合 status="published" 与受众过滤;路由层调用此函数,未命中返回 notFound() |
| P0-2 | resolveAudience 多孩子/多年级截断 |
将 audience 参数升级为 { gradeIds: string[]; classIds: string[] };getAnnouncements 用 inArray 查询;resolveAudience 返回完整数组而非首个 |
| P0-3 | 置顶/已读 Action 缺资源级校验 | toggleAnnouncementPinAction 校验公告存在;markAnnouncementAsReadAction 调用新增的 getAnnouncementByIdForUser 校验可见性后再写入 |
P1(高优先级 · 架构与可维护性)
| # | 问题 | 改进方向 |
|---|---|---|
| P1-1 | Actions 返回英文硬编码 | 引入 getTranslations("announcements"),所有 ActionState.message 改用 i18n 键;新增 messageKey 字段或直接返回本地化字符串 |
| P1-2 | 死代码:管理端详情分支 / getAnnouncementReadStatusAction |
新增 /admin/announcements/[id]/view 详情页消费 AnnouncementDetail canManage=true;列表组件调用 getAnnouncementReadStatusAction 展示已读/未读角标;或删除死分支 |
| P1-3 | 组件直接 import actions | 新建 announcements-service-context.tsx,定义 AnnouncementsService 接口(含 togglePin / publish / archive / delete / markRead / create / update 方法签名),用 Provider 注入默认实现;组件 useContext 消费 |
| P1-4 | AnnouncementForm 硬编码路由 |
新增 onSuccess? / onCancel? 回调 prop,回调优先于 router.push;默认 successHref prop 兜底 |
| P1-5 | resolveTargetUserIds 放 actions.ts |
下沉到 data-access.ts 的 resolveAnnouncementTargetUserIds(announcement) 纯函数;actions.ts 仅做编排 |
| P1-6 | 非原子 toggle / markRead | toggleAnnouncementPin 改为 UPDATE ... SET is_pinned = NOT is_pinned;markAnnouncementAsRead 改为 INSERT ... ON DUPLICATE KEY UPDATE id=id(Drizzle 的 onDuplicateKeyUpdate) |
| P1-7 | handleActionError 吞错误 |
新增 console.error + trackEvent("announcement.action_error");message 走 i18n;不向客户端返回原始 e.message |
| P1-8 | 表单与 Dialog 行为冲突 | 表单通过 onSuccess 回调关闭 Dialog;移除表单内的 router.push |
P2(中优先级 · 体验与工程化)
| # | 问题 | 改进方向 |
|---|---|---|
| P2-1 | a11y:按钮嵌套在 Link 内 | 重构 AnnouncementCard:卡片本身为 <Link>,置顶按钮用绝对定位 + z-index 独立于链接,或改用 <article> + 独立链接 + 独立按钮的语义结构 |
| P2-2 | mapRow 类型脱钩 |
改用 typeof announcements.$inferSelect 推导;移除手写内联类型 |
| P2-3 | i18n 死键 / 缺键 | 删除未使用的 9 个 messages.* 死键(或随 P1-1 启用);补 description.detail / description.create |
| P2-4 | detailHrefBuilder 死 prop |
删除该 prop,仅保留 detailHrefPrefix |
| P2-5 | 重复骨架屏 | 抽取 AnnouncementListSkeleton 共享组件到 components/ |
| P2-6 | ✅ 已实施 | 无分页 UI → 新增 AnnouncementPagination 组件;getUserAnnouncementsPageData 返回 { items, total, page, pageSize } |
| P2-7 | ✅ 已实施 | 无测试 → 新增 schema.test.ts(18 测试,refineAudience 矩阵)、is-announcement-visible.test.ts(15 测试,纯函数含多孩子场景)、announcement-card.test.tsx(16 测试,交互 + a11y) |
中长期(P3 · 功能演进,对应行业差距)
| # | 方向 | 说明 |
|---|---|---|
| P3-1 | 富文本 + 附件 | 接入 files 模块(已支持 targetType="announcement");引入轻量富文本编辑器(如 Tiptap) |
| P3-2 | 分类/标签 | 新增 announcement_tags 表 + 列表筛选;预置 K12 分类(学科/活动/安全/家长信) |
| P3-3 | 定时发布 | 表单增加 publishedAt 日期选择器;新增 cron 校验到点自动 status="published" |
| P3-4 | 已读统计仪表盘 | 管理端列表展示已读率柱状图;详情页展示未读名单 + 一键催读(触发 sendBatchNotifications) |
| P3-5 | 批量操作 | 列表多选 + 批量归档/删除 Action |
| P3-6 | 全文搜索 | 接入 app/api/search 已有的全局搜索(当前已支持 announcement 类型) |
| P3-7 | Dashboard 集成 | 新增 AnnouncementsWidget(最新 3 条 + 未读红点),挂载到各角色 Dashboard |
| P3-8 | 草稿预览 | 表单"预览"按钮展开只读视图 |
五、架构图同步说明
本次审计发现 004_architecture_impact_map.md §2.16 与 005_architecture_data.json#announcements 存在以下遗漏,需在实施后同步更新:
-
新增节点:
data-access.resolveAnnouncementTargetUserIds(P1-5 下沉的纯函数)data-access.getAnnouncementByIdForUser(P0-1 新增的受众过滤读取)AnnouncementsServiceContext(P1-3 新增的依赖注入 Provider)AnnouncementListSkeleton(P2-5 抽取的共享骨架屏)AnnouncementPagination(P2-6 新增的分页组件)
-
删除节点:
actions.getAnnouncementReadStatusAction(若 P1-2 选择删除而非启用)AnnouncementList.detailHrefBuilderprop(P2-4 删除)
-
修改节点:
GetAnnouncementsParams.audience类型从{ gradeId?; classId? }改为{ gradeIds: string[]; classIds: string[] }AnnouncementDetail的canManage分支启用记录(新增管理端详情页后)actions.ts行数变化(下沉resolveTargetUserIds后减少)data-access.ts行数变化(新增函数后增加)
-
新增依赖关系:
announcements → files(P3-1 附件集成后)announcements → dashboard(P3-7 Widget 集成后)
-
已知问题清单更新:
- 新增"P0-1 详情页越权"(修复后标记 ✅)
- 新增"P0-2 多孩子截断"(修复后标记 ✅)
- 新增"P0-3 资源级校验缺失"(修复后标记 ✅)
- 新增"P1-1 Actions i18n"(修复后标记 ✅)
附:实施清单(与上述优先级一一对应)
实施将按 P0 → P1 → P2 → P3 顺序推进,P3 为中长期演进,本次实施聚焦 P0/P1/P2,P3 中富文本/附件/Dashboard 集成将择期推进。每完成一项同步更新架构图与运行 npm run lint + npx tsc --noEmit 验证。