docs/plan/2026-06-05-session-unread-mark-design.md
Rama main · réplica de lectura del repositorio original.
# 会话未读标记(Unread Mark)功能技术设计
## 1. 需求概述
### 1.1 问题
任务列表中存在多个已完成的任务时,用户无法快速区分哪些是**刚刚在后台完成**的,哪些是早已完成的。所有已完成任务在列表中的视觉表现完全一致(绿色圆点 + 已完成耗时),缺乏新旧区分。
### 1.2 方案
当任务在后台完成时,如果用户**未点击查看过该任务**,在任务标题右侧显示一个**青色小圆点**作为未读标记。用户点击该任务后,小圆点立即消失。
### 1.3 规则总结
| 规则 | 说明 |
|------|------|
| 触发时机 | 任务从非终态(RUNNING/WAITING_*/RESUMING)变为终态(COMPLETED/FAILED/CANCELLED) |
| 未读条件 | 变为终态后,用户未点击过该任务 |
| 消除时机 | 用户在任务列表中点击该任务(`selectSession`) |
| 持久化 | 后端数据库存储,跨会话保留 |
| 存量数据 | 功能上线时,已有的终态任务默认已读 |
| 批量场景 | 每个未读任务独立显示小圆点 |
---
## 2. 现状分析
### 2.1 session 表现状
当前 `session` 表没有任何未读/已读相关字段。最接近的概念是 `notification` 表的 `is_read` 字段,但与会话无关。
相关列:`phase`(VARCHAR(32))、`last_activity_at`(DATETIME)。
### 2.2 相关代码
| 层级 | 文件 | 关键位置 |
|------|------|----------|
| 数据库迁移 | `db/migration/V028__*.sql` | 新增迁移文件 |
| Session 实体 | `session/entity/Session.java` | 新增字段 |
| SessionService | `session/service/SessionService.java:399` | `updatePhase()` 方法,终态判定逻辑 |
| SessionController | `session/controller/SessionController.java` | 新增已读接口;`SessionVO` 新增字段 |
| Session Store | `desktop/src/stores/session.ts:15` | `Session` 类型新增 `unread` 字段 |
| 任务列表 | `desktop/src/components/task/TaskIndexPanel.vue:71` | 标题右侧渲染未读圆点 |
| 任务列表点击 | `desktop/src/components/task/TaskIndexPanel.vue:300` | `selectSession()` 调用已读接口 |
| WS 事件 | `desktop/src/composables/useStreamWS.ts:264` | `session_status` 事件携带 unread 状态 |
---
## 3. 数据库设计
### 3.1 新增字段
在 `session` 表新增 `unread` 字段:
```sql
-- V028__add_session_unread.sql
ALTER TABLE `session` ADD COLUMN `unread` TINYINT(1) NOT NULL DEFAULT 0
COMMENT '后台完成未读标记:0=已读,1=未读';
```
- 类型:`TINYINT(1)`,与现有 `is_pinned`、`is_favorite` 保持一致
- 默认值:`0`(已读),存量数据自动兼容
- 命名:`unread`,简洁且语义明确(不使用 `is_unread`,因为 MyBatis-Plus 对 `is_` 前缀布尔字段的映射有特殊处理,容易产生歧义)
### 3.2 Session 实体新增字段
```java
// Session.java
private Integer unread;
```
无需额外注解,MyBatis-Plus 自动映射 `unread` → `unread`。
---
## 4. 后端设计
### 4.1 未读标记写入
**修改 `SessionService.updatePhase()` 方法**(`SessionService.java:399`):
在现有终态判定逻辑中,增加未读标记写入:
```java
public void updatePhase(Long sessionId, String phase) {
Session session = getSession(sessionId);
String oldPhase = session.getPhase();
session.setPhase(phase);
session.setLastActivityAt(LocalDateTime.now());
if ("RUNNING".equals(phase)) {
if (session.getStartedAt() == null) {
session.setStartedAt(LocalDateTime.now());
}
} else if ("IDLE".equals(phase) || "COMPLETED".equals(phase)
|| "FAILED".equals(phase) || "CANCELLED".equals(phase)) {
// 累计执行时间(现有逻辑)
if (session.getStartedAt() != null) {
long elapsed = Duration.between(session.getStartedAt(), LocalDateTime.now()).toMillis();
session.setElapsedMs((session.getElapsedMs() != null ? session.getElapsedMs() : 0) + elapsed);
session.setStartedAt(null);
}
// 新增:从非终态变为终态时,标记未读
if (!isTerminalPhase(oldPhase) && isTerminalPhase(phase)) {
session.setUnread(1);
}
}
sessionMapper.updateById(session);
}
private boolean isTerminalPhase(String phase) {
return "IDLE".equals(phase) || "COMPLETED".equals(phase)
|| "FAILED".equals(phase) || "CANCELLED".equals(phase);
}
```
**关键判定逻辑**:只有从非终态(RUNNING/RESUMING/WAITING_APPROVAL)变为终态(COMPLETED/FAILED/CANCELLED)时才设置 `unread=1`。从 IDLE 变为其他状态不触发(IDLE 是初始状态,不代表任务执行过)。
### 4.2 未读标记清除
**新增 `SessionService.markAsRead()` 方法**:
```java
public void markAsRead(Long sessionId) {
Session session = getSession(sessionId);
if (Integer.valueOf(1).equals(session.getUnread())) {
session.setUnread(0);
sessionMapper.updateById(session);
}
}
```
### 4.3 API 设计
#### 4.3.1 已读接口
**新增端点**:`PUT /v1/sessions/{id}/read`
```java
// SessionController.java
@PutMapping("/{id}/read")
public Result<Void> markAsRead(@PathVariable Long id,
@RequestAttribute Long userId) {
Session session = sessionService.getSession(id);
if (!session.getUserId().equals(userId)) {
return Result.error(403, "无权操作");
}
sessionService.markAsRead(id);
return Result.ok();
}
```
#### 4.3.2 SessionVO 新增字段
`SessionVO` 是 `SessionController` 的内部类(`SessionController.java:381`),负责将实体转换为前端响应。
新增 `unread` 字段:
```java
// SessionVO 内部类
private Boolean unread;
// 构造方法中
this.unread = Integer.valueOf(1).equals(session.getUnread());
```
使用 `Boolean` 而非 `Integer`,前端直接可用作布尔判断。
#### 4.3.3 列表接口响应
`GET /v1/sessions` 和 `GET /v1/sessions/{id}` 的响应中自动包含 `unread` 字段,无需额外修改。
---
## 5. 前端设计
### 5.1 Session 类型扩展
```typescript
// desktop/src/stores/session.ts
export interface Session {
// ... 现有字段
unread?: boolean
}
```
### 5.2 Session Store 新增方法
```typescript
// desktop/src/stores/session.ts
// 标记已读(本地状态 + 远端同步)
async function markAsRead(sessionId: string) {
const session = sessions.value.find(s => s.id === sessionId)
if (!session?.unread) return
session.unread = false
try {
await api.put(`/sessions/${sessionId}/read`)
} catch {
// 静默失败,下次 fetchSessions 会同步
}
}
```
乐观更新:先改本地状态(UI 立即响应),再发请求。失败时不影响用户体验,下次列表刷新会同步。
### 5.3 fetchSessions 合并逻辑
现有 `fetchSessions()` 合并远端数据时,需要保留 `unread` 字段:
```typescript
// session.ts fetchSessions() 合并逻辑中
unread: remote.unread ?? local.unread
```
确保在已读请求尚未完成时,本地乐观更新不被列表刷新覆盖。
### 5.4 任务列表未读圆点渲染
**修改文件**:`desktop/src/components/task/TaskIndexPanel.vue`
**位置**:在 `.session-item-meta` 区域(line 73-76),耗时文本 `session-elapsed` 之前插入未读圆点:
```html
<div class="session-item-meta">
<span v-if="session.running" class="session-spinner"></span>
<!-- 新增:未读圆点 -->
<span v-if="session.unread" class="session-unread-dot"></span>
<span class="session-elapsed">{{ formatElapsed(session) }}</span>
</div>
```
**样式**:
```css
.session-unread-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #00d4aa; /* 青色,与 Element Plus 的 --el-color-primary 接近 */
margin-right: 6px;
flex-shrink: 0;
}
```
- 尺寸 8px,与现有 phase-dot 视觉权重一致
- 颜色选择青色(#00d4aa),与已完成状态的绿色(#67c23a)、运行中的蓝色有明确区分
- 使用 `flex-shrink: 0` 防止在 flex 布局中被压缩
### 5.5 点击已读逻辑
**修改 `selectSession()` 函数**(`TaskIndexPanel.vue:300`):
```typescript
function selectSession(session: Session) {
if (editingSessionId.value === session.id) return
confirmingDeleteId.value = null
editingSessionId.value = null
// 新增:标记已读
if (session.unread) {
sessionStore.markAsRead(session.id)
}
sessionStore.setActiveSession(session.id)
router.push(`/tasks/${session.id}`)
}
```
在 `setActiveSession` 之前调用 `markAsRead`,确保 UI 即时响应(圆点在点击瞬间消失)。
### 5.6 WebSocket 实时同步
当后台任务完成时,后端通过 `session_status` 事件推送 phase 变更。需要在事件中携带 `unread` 状态。
**后端**:`WsStreamingEventListener` 发送 `session_status` 事件时,从数据库读取最新 session 并包含 `unread` 字段。
**前端**:`useStreamWS.ts` 的 `session_status` 处理逻辑中,同步更新 `unread`:
```typescript
case 'session_status':
if (sessionId) {
sessionStore.updateSessionPhase(sessionId, data.phase as TaskPhase)
// 新增:同步未读状态
if (data.unread !== undefined) {
sessionStore.updateSession(sessionId, { unread: data.unread })
}
// ... 现有终态处理逻辑
}
break
```
---
## 6. 数据流全景
```
后台任务执行完成
│
▼
AgentLoop 终态判定
│
▼
SessionService.updatePhase(sessionId, "COMPLETED")
├─ 旧 phase = RUNNING,新 phase = COMPLETED
├─ isTerminalPhase(RUNNING) = false, isTerminalPhase(COMPLETED) = true
├─ → 设置 unread = 1
└─ sessionMapper.updateById(session)
│
▼
WsStreamingEventListener 发送 session_status 事件
└─ data = { phase: "COMPLETED", unread: true }
│
├──────────────────────────────────┐
▼ ▼
用户在线(WebSocket 连接) 用户离线/不在该页面
│ │
useStreamWS.routeEvent() 下次打开客户端
├─ updateSessionPhase() │
├─ updateSession({ unread: true }) ▼
└─ TaskIndexPanel 响应式渲染 fetchSessions()
│ ├─ GET /sessions 返回 unread: true
▼ └─ TaskIndexPanel 响应式渲染
显示青色圆点 │
│ ▼
│ 显示青色圆点
▼
用户点击该任务
├─ selectSession() 调用 sessionStore.markAsRead()
├─ 本地 session.unread = false(圆点立即消失)
└─ PUT /sessions/{id}/read(持久化到 DB)
```
---
## 7. 边界场景处理
| 场景 | 处理方式 |
|------|----------|
| 任务 FAILED/CANCELLED | 同样标记未读,用户需要知道任务失败了 |
| 用户正在查看的任务完成 | 不标记未读(`updatePhase` 时无法判断当前查看的是哪个任务,由前端在 `session_status` 事件处理中,如果该 session 是 activeSession,则立即调用 `markAsRead`) |
| 任务重新执行(RUNNING → COMPLETED) | 再次标记未读,符合"每次完成都提示"的预期 |
| 功能上线时存量数据 | `unread` 默认 0,所有存量任务已读 |
| 多标签页/多窗口 | 通过 WebSocket 事件同步;已读操作通过 REST API 写 DB,其他标签页下次 fetchSessions 时同步 |
| session 被删除 | 无需处理,记录不存在则未读状态也不存在 |
### 7.1 当前查看任务的特殊处理
当用户正在查看某个任务时,该任务在后台完成,不应显示未读标记。处理方式:
**前端**:在 `useStreamWS.ts` 的 `session_status` 事件处理中:
```typescript
case 'session_status':
if (sessionId) {
sessionStore.updateSessionPhase(sessionId, data.phase as TaskPhase)
if (data.unread !== undefined) {
// 如果是当前正在查看的 session,直接标记已读
if (sessionId === sessionStore.activeSessionId) {
sessionStore.markAsRead(sessionId)
} else {
sessionStore.updateSession(sessionId, { unread: data.unread })
}
}
// ... 现有逻辑
}
break
```
这样用户正在查看的任务完成时,圆点不会出现;而其他后台任务完成时,圆点正常显示。
---
## 8. 改动文件清单
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `backend/.../db/migration/V028__add_session_unread.sql` | 新增 | 数据库迁移脚本 |
| `backend/.../session/entity/Session.java` | 修改 | 新增 `unread` 字段 |
| `backend/.../session/service/SessionService.java` | 修改 | `updatePhase()` 增加未读标记;新增 `markAsRead()` |
| `backend/.../session/controller/SessionController.java` | 修改 | 新增 `PUT /{id}/read` 端点;`SessionVO` 新增 `unread` |
| `desktop/src/stores/session.ts` | 修改 | `Session` 类型新增 `unread`;新增 `markAsRead()` |
| `desktop/src/components/task/TaskIndexPanel.vue` | 修改 | 模板新增圆点;`selectSession()` 调用已读 |
| `desktop/src/composables/useStreamWS.ts` | 修改 | `session_status` 事件同步 `unread` 状态 |
共 7 个文件,其中 1 个新增,6 个修改。