Skip to content

Commit 6b152b3

Browse files
committed
fix(web): mark opened messages as read
1 parent 47fb139 commit 6b152b3

8 files changed

Lines changed: 187 additions & 5 deletions

File tree

‎apps/web/app/routes/my.messages.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,13 @@ function MessageItem({
228228
{!msg.has_read && <span className="h-2 w-2 rounded-full bg-primary" aria-label="未读" />}
229229
</ItemTitle>
230230
{msg.topic && (
231-
<Link to={topicHref} className="truncate font-medium text-foreground hover:text-primary">
231+
<Link
232+
to={topicHref}
233+
className="truncate font-medium text-foreground hover:text-primary"
234+
onClick={() => {
235+
if (!msg.has_read) onMarkRead(msg.id);
236+
}}
237+
>
232238
{msg.topic.title}
233239
</Link>
234240
)}

‎apps/web/tests/Messages.test.tsx‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,15 @@ function makeMessage(id: string, content: string, hasRead = false) {
3636
function renderMessages(readMsgs: unknown[], unreadMsgs: unknown[]) {
3737
const routeProps = { loaderData: { readMsgs, unreadMsgs }, params: {}, matches: [] } as any;
3838
const router = createMemoryRouter(
39-
[{ path: "/my/messages", element: <Messages {...routeProps} /> }],
39+
[
40+
{ path: "/my/messages", element: <Messages {...routeProps} /> },
41+
{ path: "/topic/:topicId", element: <div>话题详情</div> },
42+
{ path: "/user/:loginname", element: <div>用户详情</div> },
43+
],
4044
{ initialEntries: ["/my/messages"] },
4145
);
42-
return render(<RouterProvider router={router} />);
46+
const result = render(<RouterProvider router={router} />);
47+
return { ...result, router };
4348
}
4449

4550
function messageGroup(title: string) {
@@ -109,4 +114,39 @@ describe("messages page", () => {
109114
expect(messageGroup("过往消息").getByText("不错哦")).toBeInTheDocument();
110115
expect(screen.queryByText(/<p>|<\/p>/)).not.toBeInTheDocument();
111116
});
117+
118+
it("marks an unread message as read while opening its topic", async () => {
119+
mocks.apiFetch.mockResolvedValue({ success: true });
120+
const { router } = renderMessages([], [makeMessage("opening", "新回复")]);
121+
122+
await userEvent.click(messageGroup("新消息").getByRole("link", { name: "Markdown 摘要" }));
123+
124+
expect(mocks.apiFetch).toHaveBeenCalledWith("/api/v1/message/mark_one/opening", {
125+
method: "POST",
126+
body: JSON.stringify({}),
127+
});
128+
expect(router.state.location.pathname).toBe("/topic/2");
129+
expect(router.state.location.hash).toBe("#3");
130+
expect(screen.getByText("话题详情")).toBeInTheDocument();
131+
});
132+
133+
it("does not mark an unread message as read when opening its author", async () => {
134+
const { router } = renderMessages([], [makeMessage("author", "新回复")]);
135+
136+
await userEvent.click(messageGroup("新消息").getByRole("link", { name: "alice" }));
137+
138+
expect(mocks.apiFetch).not.toHaveBeenCalled();
139+
expect(router.state.location.pathname).toBe("/user/alice");
140+
expect(screen.getByText("用户详情")).toBeInTheDocument();
141+
});
142+
143+
it("does not mark an already-read message again when opening its topic", async () => {
144+
const { router } = renderMessages([makeMessage("read", "旧回复", true)], []);
145+
146+
await userEvent.click(messageGroup("过往消息").getByRole("link", { name: "Markdown 摘要" }));
147+
148+
expect(mocks.apiFetch).not.toHaveBeenCalled();
149+
expect(router.state.location.pathname).toBe("/topic/2");
150+
expect(router.state.location.hash).toBe("#3");
151+
});
112152
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-09-28
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
## Context
2+
3+
`Messages` 已通过 `useAsyncAction` 封装 `POST /api/v1/message/mark_one/:msg_id`,并在成功后同步“新消息”“过往消息”和 Header 未读数。“标记已读”按钮使用该 action,但 `MessageItem` 的关联话题 `Link` 未调用它,因此导航不会改变消息状态。
4+
5+
本变更仅调整 `/my/messages` 这一 directory/data-list 页面中的链接交互。现有 `Item`、`Link`、`Button`、语义色、布局和可访问名称保持不变。
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
11+
- 未读消息的关联话题链接在导航事件中同步启动现有单条已读 action。
12+
- 保持 React Router 导航不被网络请求阻塞。
13+
- 明确作者链接与已读消息链接不触发 mutation,并通过测试锁定行为。
14+
15+
**Non-Goals:**
16+
17+
- 不修改消息 API、数据库、消息创建规则或全量已读行为。
18+
- 不增加乐观更新、失败重试或新的全局状态抽象。
19+
- 不改变页面视觉、响应式结构、键盘操作和链接可访问语义。
20+
21+
## Decisions
22+
23+
### 在话题 Link 的 click 处理器中启动现有 action
24+
25+
`MessageItem` 仅在 `msg.has_read` 为 false 时,从关联话题 `Link` 的 `onClick` 调用已有 `onMarkRead(msg.id)`。`useAsyncAction.run` 会在事件处理期间立即调用异步函数,从而在组件因导航卸载前启动 `apiFetch`;处理器不 `preventDefault`,也不等待 Promise。
26+
27+
```mermaid
28+
sequenceDiagram
29+
participant U as 用户
30+
participant L as 话题 Link
31+
participant A as markOneRead
32+
participant R as React Router
33+
U->>L: 点击未读通知目标
34+
L->>A: 启动 mark_one 请求
35+
L->>R: 继续默认客户端导航
36+
```
37+
38+
替代方案:先等待 API 成功再调用 `navigate`。拒绝原因是网络失败或延迟会阻塞用户访问通知目标。另一替代方案是把整个消息项设为可点击,拒绝原因是会与作者链接和“标记已读”按钮形成嵌套交互并扩大误触范围。
39+
40+
### 仅通知目标表达“已阅读”
41+
42+
作者头像和用户名链接不附加已读处理;已读消息的话题链接也不重复调用 API。这样只有打开通知所指向的话题/回复才改变状态。
43+
44+
替代方案:任何消息内链接都标记已读。拒绝原因是查看作者资料并不表示用户已查看通知内容。
45+
46+
### 使用路由组件测试验证事件边界
47+
48+
测试点击真实 `Link`,同时断言目标路由可达和 `apiFetch` 调用。另行覆盖作者链接与已读消息链接未调用 API,避免只测试内部回调而漏掉导航集成。
49+
50+
## Risks / Trade-offs
51+
52+
- [请求已启动但页面卸载后回调不再影响当前消息页] → 后端持久化仍会完成;用户返回消息页时 loader 和 Header 查询读取真实状态。
53+
- [快速点击多条消息受 `useAsyncAction` 单 pending 防重入限制] → 保持现有单条已读操作语义,本次不扩展并发模型。
54+
- [标记请求失败但导航继续] → 优先保证通知目标可访问;沿用现有异步 action 的错误反馈,不引入阻塞或回滚。
55+
56+
## Migration Plan
57+
58+
无需数据迁移或配置变更。发布 Web 代码即可;回滚时撤销话题 `Link` 的 click 处理器和对应测试,不影响已存储消息状态。
59+
60+
## Open Questions
61+
62+
无。
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
## Why
2+
3+
`/my/messages` 中的未读通知只有点击“标记已读”按钮才会更新状态;用户直接点击通知关联的话题后,消息仍保持未读,Header 数量也不会减少。消息目标链接应同时表达“打开这条通知”,并在导航时启动已有的单条已读操作。
4+
5+
## What Changes
6+
7+
- 点击未读消息的关联话题链接时,立即启动该消息的单条标记已读请求,同时保持正常客户端导航。
8+
- 点击消息作者头像或用户名时仅导航到用户页,不改变消息状态。
9+
- 已读消息的话题链接继续正常导航,不重复发送标记请求。
10+
- 增加 Web 回归测试,覆盖目标链接、作者链接以及对应的未读状态行为。
11+
12+
## Capabilities
13+
14+
### New Capabilities
15+
16+
无。
17+
18+
### Modified Capabilities
19+
20+
- `messaging`: 明确未读消息目标链接在导航时启动单条标记已读,并区分作者资料链接和已读消息链接。
21+
22+
## Impact
23+
24+
- 范围内:`apps/web/app/routes/my.messages.tsx` 的消息链接交互、`apps/web/tests/Messages.test.tsx` 的回归测试,以及 `messaging` 规格增量。
25+
- 范围外:消息 API、数据库 schema、Header 消息下拉、消息创建规则、页面布局与视觉样式。
26+
- 受影响系统:React Router Web 消息页和现有 `POST /api/v1/message/mark_one/:msg_id` 调用。
27+
- 高风险类别:无数据库、权限、安全、部署或数据修复变更;主要风险是导航导致异步请求未启动或错误链接触发已读。
28+
- Web 设计:沿用 directory/data-list 消息页结构及现有 `Item`、`Link`、`Button`;无 primitive、主题、响应式或可访问名称变化。
29+
- Legacy:本变更不替换 `../nodeclub/` 或 `egg-cnode/` 的实现,也不引入 legacy compatibility 路径。
30+
31+
## Non-goals
32+
33+
- 不等待标记请求完成后再导航,也不阻止用户打开消息目标。
34+
- 不在作者头像或用户名链接上推断消息已被阅读。
35+
- 不修改单条已读 API 的响应契约或失败反馈策略。
36+
37+
## Documentation Impact
38+
39+
无需更新 `docs/arch/`、`docs/biz/`、`docs/deployment/`、根治理文件、app README 或生成的 `apps/web/public/openapi.json`;持久行为由 `openspec/specs/messaging/spec.md` 在归档后统一承载。
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Web 消息目标链接触发单条已读
4+
5+
`/my/messages` MUST 在用户打开未读通知的关联话题链接时启动该消息的单条标记已读请求,同时 MUST 不阻塞目标链接导航。作者资料链接和已经标记为已读的通知链接 MUST 保持纯导航行为。
6+
7+
#### Scenario: 打开未读消息关联话题
8+
9+
- **WHEN** 用户点击“新消息”分组中一条未读消息的关联话题链接
10+
- **THEN** Web 启动该消息的 `POST /api/v1/message/mark_one/:msg_id` 请求
11+
- **AND** React Router 继续导航到该消息关联的话题和回复锚点
12+
13+
#### Scenario: 查看未读消息作者资料
14+
15+
- **WHEN** 用户点击未读消息的作者头像或用户名链接
16+
- **THEN** React Router 导航到作者资料页
17+
- **AND** Web 不因该点击调用单条标记已读 API
18+
19+
#### Scenario: 打开已读消息关联话题
20+
21+
- **WHEN** 用户点击“过往消息”分组中一条已读消息的关联话题链接
22+
- **THEN** React Router 继续导航到关联话题和回复锚点
23+
- **AND** Web 不重复调用单条标记已读 API
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## 1. MVP:消息目标链接已读行为
2+
3+
- [x] 1.1 在 `/my/messages` 的未读消息关联话题 `Link` 点击时启动现有单条标记已读 action,并保持默认 React Router 导航
4+
- [x] 1.2 保持作者头像、用户名和已读消息话题链接为纯导航,不发送单条标记已读请求
5+
6+
## 2. 功能完整:回归与发布验证
7+
8+
- [x] 2.1 增加 Web 路由测试,覆盖未读目标链接启动 mutation 并导航、作者链接仅导航、已读目标链接仅导航
9+
- [x] 2.2 运行消息页专项测试、Web 测试、lint、typecheck 和 OpenSpec strict validate;确认无 API、数据库、文档、响应式、主题、键盘、焦点、空态、错误态、pending 态或设计系统变更
10+
- [x] 2.3 复核 proposal、design、spec 和实现一致,确认变更可归档

‎openspec/changes/recover-account-email-delivery/tasks.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
- [x] 1.1 调整 `apps/api/Dockerfile`,让最终源码运行镜像携带 `apps/api/tsconfig.json` 及其基础配置,并确认 `tsx` 使用 `react-jsx` runtime
44
- [x] 1.2 增加最终 API 镜像内的无网络邮件模板 smoke,覆盖账号激活、密码重置、回复和 @ 提及四类模板
55
- [x] 1.3 增加模板渲染失败与 SMTP 发送失败的阶段化安全日志,确保不记录邮箱、正文、密码、retrieve key 或 SMTP 凭据
6-
- [ ] 1.4 运行 API 邮件模板测试、typecheck、build 和最终镜像 smoke,验证不再出现 `React is not defined`
6+
- [x] 1.4 运行 API 邮件模板测试、typecheck、build 和最终镜像 smoke,验证不再出现 `React is not defined`
77

88
## 2. MVP:注册部分成功与安全恢复 API
99

@@ -34,4 +34,4 @@
3434
- [x] 5.1 从认证路由 zod-openapi 声明重新生成 `apps/web/public/openapi.json`,验证重发接口和结构化错误响应
3535
- [x] 5.2 按 `cnode-docs` 检查 `docs/deployment/` 的最终镜像 smoke 说明;仅在现有权威部署文档需要同步时更新,并检查无过时路径或不安全示例值
3636
- [x] 5.3 复核本变更无 PostgreSQL schema、migration、seed、索引、自动 backfill 或数据清理,并确认历史未激活账号仅按请求恢复
37-
- [ ] 5.4 运行相关 API/Web 测试、lint、typecheck、build、OpenSpec strict validate、secret scan,并核对设计图、规格与实现状态可归档
37+
- [x] 5.4 运行相关 API/Web 测试、lint、typecheck、build、OpenSpec strict validate、secret scan,并核对设计图、规格与实现状态可归档

0 commit comments

Comments
 (0)