1009 lines
39 KiB
Markdown
1009 lines
39 KiB
Markdown
# 小说列表页/详情页展示改造实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 修复详情页重复展示问题,列表页增加大纲预览,后端跨流保存完整澄清问答消息,详情页复刻生成时的 chat-bubble 流程视图。
|
||
|
||
**Architecture:** 后端用 sessionId 全局累积器跨 SSE 流保存 clarification_question/clarification_answer/outline 中间步骤(因 clarification_card 发生在 conversation 创建之前,followup 是独立 SSE 流),novel_done 时统一创建 message。前端 ScriptDetailView 移除 hero-card+tabs,复用 ScriptView 的 chat-bubble 结构从 conversation.messages 重建完整流程;ScriptLibraryView 新增大纲预览行。
|
||
|
||
**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + fastjson2 + OkHttp;UniApp Vue 3 + Pinia;MySQL 8.0
|
||
|
||
## Global Constraints
|
||
|
||
- 所有代码注释用中文,所有对话/文档用中文
|
||
- 禁止 mock/兜底数据:数据为空就返回空,异常必须抛出
|
||
- 后端编译必须用 `mvn clean install -DskipTests`(禁止 `mvn clean compile`)
|
||
- 后端修改后必须 `python deploy.py backend` 部署到服务器验收,本地不启动后端
|
||
- 移动端样式单位必须用 rpx(禁止 px)
|
||
- ID 字段后端 Long,前端 String
|
||
- 验收必须用 H5 浏览器(http://localhost:5180),不能仅靠 curl
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### 后端
|
||
| 文件 | 职责 | 操作 |
|
||
|---|---|---|
|
||
| `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java` | SSE 转发 + session 缓存 | 修改 |
|
||
| `server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java` | saveNovelResult 创建 message | 修改 |
|
||
| `server/src/test/java/com/emotion/service/impl/ShortNovelServiceImplTest.java` | 缓存累积器单元测试 | 新建 |
|
||
|
||
### 前端
|
||
| 文件 | 职责 | 操作 |
|
||
|---|---|---|
|
||
| `mini-program/src/pages/main/ScriptDetailView.vue` | 详情页流程视图 | 修改 |
|
||
| `mini-program/src/pages/main/ScriptLibraryView.vue` | 列表页大纲预览 | 修改 |
|
||
| `mini-program/src/services/scriptChat.js` | listMessagesByConversation(已存在) | 复用 |
|
||
|
||
---
|
||
|
||
## Task 1: SESSION_STAGES_ACCUMULATOR 缓存基础设施
|
||
|
||
**Files:**
|
||
- Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:45-100`
|
||
- Test: `server/src/test/java/com/emotion/service/impl/ShortNovelServiceImplTest.java`
|
||
|
||
**Interfaces:**
|
||
- Produces: `accumulateStage(String sessionId, JSONObject stageEntry)` (static, 包级可见)、`drainStages(String sessionId)` (static, 包级可见,返回 `List<JSONObject>`)
|
||
|
||
- [ ] **Step 1: 写失败的单元测试**
|
||
|
||
创建 `server/src/test/java/com/emotion/service/impl/ShortNovelServiceImplTest.java`:
|
||
|
||
```java
|
||
package com.emotion.service.impl;
|
||
|
||
import com.alibaba.fastjson2.JSONObject;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.util.List;
|
||
|
||
import static org.junit.jupiter.api.Assertions.*;
|
||
|
||
/**
|
||
* ShortNovelServiceImpl 静态缓存累积器单元测试
|
||
*
|
||
* @author huazhongmin
|
||
* @date 2026-07-26
|
||
*/
|
||
public class ShortNovelServiceImplTest {
|
||
|
||
@Test
|
||
public void testAccumulateStage_singleEntry() {
|
||
String sessionId = "test-session-1";
|
||
JSONObject entry = new JSONObject();
|
||
entry.put("kind", "clarification_question");
|
||
entry.put("card", new JSONObject().fluentPut("question", "你的咖啡馆特色?"));
|
||
|
||
ShortNovelServiceImpl.accumulateStage(sessionId, entry);
|
||
|
||
List<JSONObject> stages = ShortNovelServiceImpl.drainStages(sessionId);
|
||
assertEquals(1, stages.size());
|
||
assertEquals("clarification_question", stages.get(0).getString("kind"));
|
||
}
|
||
|
||
@Test
|
||
public void testAccumulateStage_multipleEntries_preservesOrder() {
|
||
String sessionId = "test-session-2";
|
||
ShortNovelServiceImpl.accumulateStage(sessionId,
|
||
new JSONObject().fluentPut("kind", "clarification_question").fluentPut("question", "Q1"));
|
||
ShortNovelServiceImpl.accumulateStage(sessionId,
|
||
new JSONObject().fluentPut("kind", "clarification_answer").fluentPut("answer", "A1"));
|
||
ShortNovelServiceImpl.accumulateStage(sessionId,
|
||
new JSONObject().fluentPut("kind", "outline").fluentPut("outline", new JSONObject()));
|
||
|
||
List<JSONObject> stages = ShortNovelServiceImpl.drainStages(sessionId);
|
||
assertEquals(3, stages.size());
|
||
assertEquals("clarification_question", stages.get(0).getString("kind"));
|
||
assertEquals("clarification_answer", stages.get(1).getString("kind"));
|
||
assertEquals("outline", stages.get(2).getString("kind"));
|
||
}
|
||
|
||
@Test
|
||
public void testDrainStages_clearsCache() {
|
||
String sessionId = "test-session-3";
|
||
ShortNovelServiceImpl.accumulateStage(sessionId,
|
||
new JSONObject().fluentPut("kind", "outline"));
|
||
|
||
List<JSONObject> first = ShortNovelServiceImpl.drainStages(sessionId);
|
||
assertEquals(1, first.size());
|
||
|
||
// 二次读取应为空(drain 后已清理)
|
||
List<JSONObject> second = ShortNovelServiceImpl.drainStages(sessionId);
|
||
assertTrue(second.isEmpty());
|
||
}
|
||
|
||
@Test
|
||
public void testAccumulateStage_nullSessionId_ignored() {
|
||
ShortNovelServiceImpl.accumulateStage(null,
|
||
new JSONObject().fluentPut("kind", "outline"));
|
||
assertTrue(ShortNovelServiceImpl.drainStages(null).isEmpty());
|
||
}
|
||
|
||
@Test
|
||
public void testDrainStages_unknownSession_returnsEmpty() {
|
||
List<JSONObject> stages = ShortNovelServiceImpl.drainStages("nonexistent-session");
|
||
assertTrue(stages.isEmpty());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试验证失败**
|
||
|
||
Run: `cd server && mvn test -Dtest=ShortNovelServiceImplTest -q`
|
||
Expected: FAIL,编译错误 `accumulateStage/drainStages 方法不存在`
|
||
|
||
- [ ] **Step 3: 实现缓存累积器**
|
||
|
||
在 `ShortNovelServiceImpl.java` 的 `SESSION_ORIGINAL_QUERY_CACHE` 声明附近(约 line 50 之后)新增:
|
||
|
||
```java
|
||
/**
|
||
* sessionId -> 中间步骤累积列表(跨 SSE 流保存 clarification_question/answer/outline)
|
||
* 用于在 novel_done 时统一创建 message,还原完整生成流程
|
||
* 会话超时 30 分钟(与 SESSION_ORIGINAL_QUERY_CACHE 对齐)
|
||
*/
|
||
private static final java.util.concurrent.ConcurrentHashMap<String, java.util.List<JSONObject>> SESSION_STAGES_ACCUMULATOR =
|
||
new java.util.concurrent.ConcurrentHashMap<>();
|
||
|
||
/**
|
||
* 累积中间步骤到 session 缓存
|
||
* 包级可见,便于单元测试
|
||
*
|
||
* @param sessionId 会话ID
|
||
* @param stageEntry 步骤条目(含 kind 字段:clarification_question/clarification_answer/outline)
|
||
*/
|
||
static void accumulateStage(String sessionId, JSONObject stageEntry) {
|
||
if (sessionId == null || sessionId.isEmpty() || stageEntry == null) {
|
||
return;
|
||
}
|
||
SESSION_STAGES_ACCUMULATOR.computeIfAbsent(sessionId,
|
||
k -> new java.util.concurrent.CopyOnWriteArrayList<>()).add(stageEntry);
|
||
}
|
||
|
||
/**
|
||
* 读取并清理 session 累积的中间步骤
|
||
* 包级可见,便于单元测试
|
||
*
|
||
* @param sessionId 会话ID
|
||
* @return 步骤列表(按累积顺序),无则返回空列表
|
||
*/
|
||
static java.util.List<JSONObject> drainStages(String sessionId) {
|
||
if (sessionId == null || sessionId.isEmpty()) {
|
||
return java.util.Collections.emptyList();
|
||
}
|
||
java.util.List<JSONObject> stages = SESSION_STAGES_ACCUMULATOR.remove(sessionId);
|
||
return stages != null ? stages : java.util.Collections.emptyList();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试验证通过**
|
||
|
||
Run: `cd server && mvn test -Dtest=ShortNovelServiceImplTest -q`
|
||
Expected: PASS,5 个测试全绿
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java server/src/test/java/com/emotion/service/impl/ShortNovelServiceImplTest.java
|
||
git commit -m "feat:新增 SESSION_STAGES_ACCUMULATOR 跨流中间步骤累积器"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: clarification_card 与 outline_created 事件累积
|
||
|
||
**Files:**
|
||
- Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:183-285` (forwardSse 方法)
|
||
- Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:133-145` (stream 方法)
|
||
- Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:147-173` (followup 方法)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1 的 `accumulateStage`
|
||
- Produces: forwardSse 增加 `knownSessionId` 参数;clarification_card/outline_created 事件累积到全局缓存
|
||
|
||
- [ ] **Step 1: 修改 forwardSse 签名增加 knownSessionId 参数**
|
||
|
||
将 `forwardSse` 方法签名(约 line 183)从:
|
||
```java
|
||
private SseEmitter forwardSse(String path, Map<String, Object> body, String currentUserId, String originalQuery) {
|
||
```
|
||
改为:
|
||
```java
|
||
private SseEmitter forwardSse(String path, Map<String, Object> body, String currentUserId,
|
||
String originalQuery, String knownSessionId) {
|
||
```
|
||
|
||
在方法体内 `stageMetaRef` 声明后(约 line 197)新增当前 sessionId 持有者:
|
||
```java
|
||
// 当前流的 sessionId(stream 首次从 status 事件获取,followup 从请求参数获取)
|
||
final String[] currentSessionId = {knownSessionId};
|
||
```
|
||
|
||
- [ ] **Step 2: 在 status 事件处理中更新 currentSessionId**
|
||
|
||
在 status 事件处理的 `cacheSessionMeta` 调用后(约 line 260)新增:
|
||
```java
|
||
if (sessionId != null && !sessionId.isEmpty()) {
|
||
currentSessionId[0] = sessionId;
|
||
cacheSessionMeta(sessionId, originalQuery, style, length);
|
||
log.debug("[ShortNovel] 缓存 session 元数据: sessionId={}", sessionId);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: clarification_card 事件累积 question**
|
||
|
||
将 clarification_card 处理(约 line 273-277)从:
|
||
```java
|
||
} else if ("clarification_card".equals(type)) {
|
||
JSONObject card = stagePayload.getJSONObject("card");
|
||
if (card != null) {
|
||
stageMetaRef.get().put("clarification", card);
|
||
}
|
||
}
|
||
```
|
||
改为:
|
||
```java
|
||
} else if ("clarification_card".equals(type)) {
|
||
JSONObject card = stagePayload.getJSONObject("card");
|
||
if (card != null) {
|
||
stageMetaRef.get().put("clarification", card);
|
||
// 累积到全局 session 缓存,供详情页还原完整澄清流程
|
||
if (currentSessionId[0] != null) {
|
||
JSONObject entry = new JSONObject();
|
||
entry.put("kind", "clarification_question");
|
||
entry.put("card", card);
|
||
accumulateStage(currentSessionId[0], entry);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: outline_created 事件累积 outline**
|
||
|
||
将 outline_created 处理(约 line 268-272)从:
|
||
```java
|
||
if ("outline_created".equals(type)) {
|
||
JSONObject outline = stagePayload.getJSONObject("outline");
|
||
if (outline != null) {
|
||
stageMetaRef.get().put("outline", outline);
|
||
}
|
||
}
|
||
```
|
||
改为:
|
||
```java
|
||
if ("outline_created".equals(type)) {
|
||
JSONObject outline = stagePayload.getJSONObject("outline");
|
||
if (outline != null) {
|
||
stageMetaRef.get().put("outline", outline);
|
||
// 累积到全局 session 缓存,供详情页还原大纲步骤
|
||
if (currentSessionId[0] != null) {
|
||
JSONObject entry = new JSONObject();
|
||
entry.put("kind", "outline");
|
||
entry.put("outline", outline);
|
||
accumulateStage(currentSessionId[0], entry);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: stream 调用 forwardSse 传 null sessionId**
|
||
|
||
将 stream 方法(约 line 144)从:
|
||
```java
|
||
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, request.getQuery());
|
||
```
|
||
改为:
|
||
```java
|
||
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId,
|
||
request.getQuery(), null);
|
||
```
|
||
|
||
- [ ] **Step 6: followup 调用 forwardSse 传 request.getSessionId()**
|
||
|
||
将 followup 方法(约 line 172)从:
|
||
```java
|
||
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, effectiveOriginalQuery);
|
||
```
|
||
改为:
|
||
```java
|
||
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId,
|
||
effectiveOriginalQuery, request.getSessionId());
|
||
```
|
||
|
||
- [ ] **Step 7: 编译验证**
|
||
|
||
Run: `cd server && mvn clean install -DskipTests -pl :server -q`
|
||
Expected: BUILD SUCCESS
|
||
|
||
- [ ] **Step 8: 提交**
|
||
|
||
```bash
|
||
git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java
|
||
git commit -m "feat:clarification_card 与 outline_created 事件累积到全局缓存"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: followup 累积用户回答
|
||
|
||
**Files:**
|
||
- Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:147-173` (followup 方法)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1 的 `accumulateStage`、`ShortNovelFollowupRequest.getAction()` / `getSessionId()` / `getPayload()`
|
||
- Produces: followup 在 action=answer_clarification 时累积 answer 到全局缓存
|
||
|
||
- [ ] **Step 1: 确认 ShortNovelFollowupRequest 的 payload 结构**
|
||
|
||
Run: `cd server && grep -n "payload\|action\|sessionId" src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java`
|
||
Expected: 确认 `getAction()`、`getSessionId()`、`getPayload()` 返回 `Map<String, Object>`
|
||
|
||
- [ ] **Step 2: 在 followup 方法入口累积 answer**
|
||
|
||
在 followup 方法(约 line 163,`effectiveOriginalQuery` 兜底逻辑之后、构建 `upstreamBody` 之前)新增:
|
||
```java
|
||
// 累积用户澄清回答到 session 缓存(用于详情页还原完整澄清流程)
|
||
if ("answer_clarification".equals(request.getAction())
|
||
&& request.getSessionId() != null && !request.getSessionId().isEmpty()) {
|
||
Object answer = request.getPayload() != null ? request.getPayload().get("answer") : null;
|
||
if (answer == null && request.getPayload() != null) {
|
||
// 兼容前端可能用 value 字段传答案
|
||
answer = request.getPayload().get("value");
|
||
}
|
||
if (answer != null) {
|
||
JSONObject entry = new JSONObject();
|
||
entry.put("kind", "clarification_answer");
|
||
entry.put("answer", String.valueOf(answer));
|
||
accumulateStage(request.getSessionId(), entry);
|
||
log.debug("[ShortNovel] 累积澄清回答: sessionId={}", request.getSessionId());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 编译验证**
|
||
|
||
Run: `cd server && mvn clean install -DskipTests -pl :server -q`
|
||
Expected: BUILD SUCCESS
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java
|
||
git commit -m "feat:followup 累积用户澄清回答到全局缓存"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: saveNovelResult 创建中间步骤 message
|
||
|
||
**Files:**
|
||
- Modify: `server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java:231-335` (saveNovelResult 方法)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `metadata.stagesHistory` (List<JSONObject>),每项含 `kind` 字段
|
||
- Produces: 创建 type=`clarification_question`/`clarification_answer`/`outline` 的 message,messageOrder 按顺序递增
|
||
|
||
- [ ] **Step 1: 修改 saveNovelResult 在创建 3 条 message 之前插入中间步骤 message**
|
||
|
||
在 `saveNovelResult` 方法中,找到"3. 系统欢迎消息"注释(约 line 277)之前,插入中间步骤 message 创建逻辑。
|
||
|
||
将 messageOrder 从硬编码 `1L`/`2L`/`3L` 改为动态计数器。在 `conversationService.save(conversation)` 之后(约 line 275)新增:
|
||
|
||
```java
|
||
// 读取累积的中间步骤(clarification_question/answer/outline)
|
||
@SuppressWarnings("unchecked")
|
||
java.util.List<com.alibaba.fastjson2.JSONObject> stagesHistory =
|
||
metadata != null && metadata.get("stagesHistory") instanceof java.util.List
|
||
? (java.util.List<com.alibaba.fastjson2.JSONObject>) metadata.get("stagesHistory")
|
||
: java.util.Collections.emptyList();
|
||
|
||
long messageOrder = 1L;
|
||
|
||
// 2.5 创建中间步骤 message(clarification_question/answer/outline),按累积顺序
|
||
for (com.alibaba.fastjson2.JSONObject stage : stagesHistory) {
|
||
String kind = stage.getString("kind");
|
||
if ("clarification_question".equals(kind)) {
|
||
com.alibaba.fastjson2.JSONObject card = stage.getJSONObject("card");
|
||
Message qMsg = new Message();
|
||
qMsg.setId(snowflakeIdGenerator.nextIdAsString());
|
||
qMsg.setConversationId(conversationId);
|
||
qMsg.setScriptId(scriptId);
|
||
qMsg.setUserId(currentUserId);
|
||
qMsg.setContent(card != null ? card.toJSONString() : "{}");
|
||
qMsg.setType("clarification_question");
|
||
qMsg.setSender("assistant");
|
||
qMsg.setTimestamp(now);
|
||
qMsg.setMessageOrder(messageOrder++);
|
||
qMsg.setStatus("sent");
|
||
qMsg.setIsRead(1);
|
||
messageService.createMessage(qMsg);
|
||
} else if ("clarification_answer".equals(kind)) {
|
||
Message aMsg = new Message();
|
||
aMsg.setId(snowflakeIdGenerator.nextIdAsString());
|
||
aMsg.setConversationId(conversationId);
|
||
aMsg.setScriptId(scriptId);
|
||
aMsg.setUserId(currentUserId);
|
||
aMsg.setContent(stage.getString("answer"));
|
||
aMsg.setType("clarification_answer");
|
||
aMsg.setSender("user");
|
||
aMsg.setTimestamp(now);
|
||
aMsg.setMessageOrder(messageOrder++);
|
||
aMsg.setStatus("sent");
|
||
aMsg.setIsRead(1);
|
||
messageService.createMessage(aMsg);
|
||
} else if ("outline".equals(kind)) {
|
||
com.alibaba.fastjson2.JSONObject outline = stage.getJSONObject("outline");
|
||
Message oMsg = new Message();
|
||
oMsg.setId(snowflakeIdGenerator.nextIdAsString());
|
||
oMsg.setConversationId(conversationId);
|
||
oMsg.setScriptId(scriptId);
|
||
oMsg.setUserId(currentUserId);
|
||
oMsg.setContent(outline != null ? outline.toJSONString() : "{}");
|
||
oMsg.setType("outline");
|
||
oMsg.setSender("assistant");
|
||
oMsg.setTimestamp(now);
|
||
oMsg.setMessageOrder(messageOrder++);
|
||
oMsg.setStatus("sent");
|
||
oMsg.setIsRead(1);
|
||
messageService.createMessage(oMsg);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 修改 system/user/assistant message 的 messageOrder 使用计数器**
|
||
|
||
将系统欢迎消息的 `systemWelcome.setMessageOrder(1L)`(约 line 287)改为:
|
||
```java
|
||
systemWelcome.setMessageOrder(messageOrder++);
|
||
```
|
||
|
||
将用户消息的 `userMessage.setMessageOrder(2L)`(约 line 302)改为:
|
||
```java
|
||
userMessage.setMessageOrder(messageOrder++);
|
||
```
|
||
|
||
将 AI 消息的 `aiMessage.setMessageOrder(3L)`(约 line 317)改为:
|
||
```java
|
||
aiMessage.setMessageOrder(messageOrder++);
|
||
```
|
||
|
||
- [ ] **Step 3: 更新 conversation 的 messageCount**
|
||
|
||
将 `conversation.setMessageCount(3)`(约 line 274)改为:
|
||
```java
|
||
conversation.setMessageCount((int) (stagesHistory.size() + 3));
|
||
```
|
||
|
||
- [ ] **Step 4: 编译验证**
|
||
|
||
Run: `cd server && mvn clean install -DskipTests -pl :server -q`
|
||
Expected: BUILD SUCCESS
|
||
|
||
- [ ] **Step 5: 部署到服务器**
|
||
|
||
Run: `python deploy.py backend`
|
||
Expected: 部署成功,无报错
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java
|
||
git commit -m "feat:saveNovelResult 创建 clarification/outline 中间步骤 message"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: novel_done 读取累积 stages 并清理缓存
|
||
|
||
**Files:**
|
||
- Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:288-333` (novel_done 处理)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1 的 `drainStages`
|
||
- Produces: metadata.stagesHistory 传入 saveNovelResult;novel_done 后清理 session 缓存
|
||
|
||
- [ ] **Step 1: 在 novel_done 处理中读取累积 stages 放入 metadata**
|
||
|
||
在 novel_done 处理的 `metadata.put("stages", stageMeta)` 之后(约 line 312)新增:
|
||
```java
|
||
// 读取跨流累积的中间步骤(clarification/outline),供详情页还原完整流程
|
||
if (currentSessionId[0] != null) {
|
||
java.util.List<JSONObject> stagesHistory = drainStages(currentSessionId[0]);
|
||
if (!stagesHistory.isEmpty()) {
|
||
metadata.put("stagesHistory", stagesHistory);
|
||
log.info("[ShortNovel SSE] 读取累积中间步骤: count={}", stagesHistory.size());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
Run: `cd server && mvn clean install -DskipTests -pl :server -q`
|
||
Expected: BUILD SUCCESS
|
||
|
||
- [ ] **Step 3: 部署并验证后端流程**
|
||
|
||
Run: `python deploy.py backend`
|
||
Expected: 部署成功
|
||
|
||
部署后用 curl 触发一次小说生成(或通过 H5),然后查询数据库验证:
|
||
```bash
|
||
mysql -e "SELECT id, type, sender, message_order, LEFT(content, 50) FROM t_message WHERE conversation_id='<新生成的conversationId>' ORDER BY message_order"
|
||
```
|
||
Expected: 看到 clarification_question/clarification_answer/outline/system/user/script 多条 message,message_order 连续递增
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java
|
||
git commit -m "feat:novel_done 读取累积 stages 并传入 saveNovelResult"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: 前端 ScriptDetailView 改造为 chat-bubble 流程视图
|
||
|
||
**Files:**
|
||
- Modify: `mini-program/src/pages/main/ScriptDetailView.vue` (完整改造 template + script)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `listMessagesByConversation` from `../../services/scriptChat.js`、`ClarificationCard` from `../../components/ClarificationCard.vue`、`Markdown` from `../../components/Markdown.vue`
|
||
- Produces: 详情页 chat-bubble 流程视图,复刻 ScriptView 生成时展示
|
||
|
||
- [ ] **Step 1: 备份当前 ScriptDetailView 的 script setup 逻辑**
|
||
|
||
Read `mini-program/src/pages/main/ScriptDetailView.vue` 全文,记录现有的 `ttsPlayer`、`useMenuButtonSafeArea`、`continueCurrent`、`goBack` 等逻辑(这些需要保留)。
|
||
|
||
- [ ] **Step 2: 改造 template 为 chat-bubble 流程视图**
|
||
|
||
将 `<template>` 内的 hero-card + tabs 结构(约 line 13-63)替换为:
|
||
|
||
```vue
|
||
<scroll-view class="scroll" scroll-y :show-scrollbar="false">
|
||
<view class="detail-flow">
|
||
<view
|
||
v-for="msg in resultMessages"
|
||
:key="msg.id"
|
||
>
|
||
<!-- 用户心愿气泡 -->
|
||
<view v-if="msg.role === 'user' && msg.kind === 'text'" class="chat-bubble user">
|
||
<text>{{ msg.content }}</text>
|
||
</view>
|
||
|
||
<!-- 澄清问答卡片(只读,已回答状态) -->
|
||
<view v-else-if="msg.kind === 'card'" class="chat-bubble system">
|
||
<view class="card-answered">
|
||
<text v-if="msg.card?.question" class="card-question-text">{{ msg.card.question }}</text>
|
||
<text class="card-answer-text">已回答:{{ msg.answer || '未回答' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 大纲(beats 列表,只读) -->
|
||
<view v-else-if="msg.kind === 'outline'" class="chat-bubble system">
|
||
<view v-if="msg.outline">
|
||
<view v-if="msg.outline.title" class="outline-title">
|
||
<text>{{ msg.outline.title }}</text>
|
||
</view>
|
||
<view v-if="msg.outline.logline" class="outline-logline">
|
||
<text>{{ msg.outline.logline }}</text>
|
||
</view>
|
||
<view v-if="Array.isArray(msg.outline.beats)" class="outline-beats">
|
||
<view v-for="(beat, idx) in msg.outline.beats" :key="idx" class="beat-item">
|
||
<text class="beat-order">{{ beat.order || idx + 1 }}</text>
|
||
<view class="beat-content">
|
||
<text class="beat-title">{{ beat.title || '' }}</text>
|
||
<text v-if="beat.summary" class="beat-summary">{{ beat.summary }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="msg.outline.ending" class="outline-ending">
|
||
<text class="ending-label">结局</text>
|
||
<text class="ending-text">{{ msg.outline.ending }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 小说正文(Markdown 渲染) -->
|
||
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">
|
||
<Markdown :content="msg.content" />
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="resultMessages.length === 0" class="empty-flow">
|
||
<text>暂无剧本内容</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
```
|
||
|
||
保留顶部 topbar 和底部 bottom-actions 不变。
|
||
|
||
- [ ] **Step 3: 改造 script setup 加载 messages 并重建 resultMessages**
|
||
|
||
在 `<script setup>` 中,替换 `fullContent`/`userWish`/`outlineFromMeta` 等 computed,改为:
|
||
|
||
```javascript
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import { useAppStore } from '../../stores/app.js'
|
||
import Markdown from '../../components/Markdown.vue'
|
||
import analytics from '../../services/analytics.js'
|
||
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
|
||
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||
import { listMessagesByConversation } from '../../services/scriptChat.js'
|
||
|
||
const store = useAppStore()
|
||
const scriptId = ref('')
|
||
const script = ref(null)
|
||
const resultMessages = ref([])
|
||
const pagePath = '/pages/main/ScriptDetailView'
|
||
const ttsPlayer = useTtsPlayer({ pagePath })
|
||
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
||
|
||
const lengthText = computed(() => {
|
||
const map = { short: '短篇', medium: '中篇', long: '长篇' }
|
||
return map[script.value?.length] || script.value?.length || '中篇'
|
||
})
|
||
|
||
/**
|
||
* 从后端 message 列表重建统一对话消息流
|
||
* 复刻 ScriptView 生成时的 chat-bubble 展示结构
|
||
*/
|
||
const rebuildResultMessages = (scriptData, messages) => {
|
||
const result = []
|
||
|
||
// 1. 用户心愿作为首条 user 消息(如果 messages 中没有 user 心愿消息则用 theme 补)
|
||
const hasUserWishInMessages = messages.some(m => m.sender === 'user' && m.type === 'chat')
|
||
if (!hasUserWishInMessages && scriptData?.theme) {
|
||
result.push({
|
||
id: `wish-${scriptData.id}`,
|
||
role: 'user',
|
||
kind: 'text',
|
||
content: scriptData.theme
|
||
})
|
||
}
|
||
|
||
// 2. 按 messageOrder 遍历 messages,转换为对应 kind
|
||
messages.forEach(m => {
|
||
if (m.type === 'clarification_question') {
|
||
let card = {}
|
||
try { card = JSON.parse(m.content || '{}') } catch (e) { card = {} }
|
||
result.push({ id: m.id, role: 'assistant', kind: 'card', card, answer: '', submitted: true })
|
||
} else if (m.type === 'clarification_answer') {
|
||
const lastCard = [...result].reverse().find(x => x.kind === 'card')
|
||
if (lastCard) {
|
||
lastCard.answer = m.content || '未回答'
|
||
} else {
|
||
// 无对应 question,作为独立 user 消息
|
||
result.push({ id: m.id, role: 'user', kind: 'text', content: m.content || '' })
|
||
}
|
||
} else if (m.type === 'outline') {
|
||
let outline = {}
|
||
try { outline = JSON.parse(m.content || '{}') } catch (e) { outline = {} }
|
||
result.push({ id: m.id, role: 'assistant', kind: 'outline', outline })
|
||
} else if (m.type === 'script') {
|
||
result.push({ id: m.id, role: 'assistant', kind: 'novel', content: m.content || '' })
|
||
} else if (m.type === 'system') {
|
||
// 跳过系统欢迎消息,不展示
|
||
} else if (m.type === 'chat' && m.sender === 'user') {
|
||
result.push({ id: m.id, role: 'user', kind: 'text', content: m.content || '' })
|
||
}
|
||
})
|
||
|
||
// 3. 如果 messages 中没有 script 类型消息,fallback 到 plotJson.fullContent
|
||
const hasNovel = result.some(m => m.kind === 'novel')
|
||
if (!hasNovel && scriptData?.plotJson?.fullContent) {
|
||
result.push({
|
||
id: `fallback-novel-${scriptData.id}`,
|
||
role: 'assistant',
|
||
kind: 'novel',
|
||
content: scriptData.plotJson.fullContent
|
||
})
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
onMounted(async () => {
|
||
// scriptId 从路由参数获取
|
||
const pages = getCurrentPages()
|
||
const currentPage = pages[pages.length - 1]
|
||
scriptId.value = currentPage?.options?.id || currentPage?.$page?.options?.id || ''
|
||
|
||
// 1. 加载 script 数据
|
||
await store.fetchScripts()
|
||
script.value = store.getScriptById(scriptId.value)
|
||
|
||
// 2. 加载 conversation messages
|
||
const conversationId = script.value?.conversationId
|
||
if (conversationId) {
|
||
try {
|
||
const res = await listMessagesByConversation({ conversationId, includeVersions: false })
|
||
const messages = Array.isArray(res?.data) ? res.data : []
|
||
resultMessages.value = rebuildResultMessages(script.value, messages)
|
||
} catch (e) {
|
||
console.error('[ScriptDetailView] 加载 messages 失败:', e)
|
||
// 降级:仅展示心愿 + 正文
|
||
resultMessages.value = rebuildResultMessages(script.value, [])
|
||
}
|
||
} else {
|
||
// 无 conversationId,降级展示
|
||
resultMessages.value = rebuildResultMessages(script.value, [])
|
||
}
|
||
})
|
||
```
|
||
|
||
保留 `goBack`、`continueCurrent`、`trackTtsClick`、`detailTtsIcon`、`detailTtsActionText` 等现有方法不变。
|
||
|
||
- [ ] **Step 4: 添加 chat-bubble 样式**
|
||
|
||
在 `<style>` 中新增(rpx 单位):
|
||
|
||
```css
|
||
.detail-flow {
|
||
padding: 20rpx 24rpx 160rpx;
|
||
}
|
||
.chat-bubble {
|
||
margin-bottom: 24rpx;
|
||
padding: 24rpx;
|
||
border-radius: 24rpx;
|
||
}
|
||
.chat-bubble.user {
|
||
background: rgba(168, 85, 247, 0.12);
|
||
align-self: flex-end;
|
||
margin-left: 80rpx;
|
||
}
|
||
.chat-bubble.system {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
margin-right: 40rpx;
|
||
}
|
||
.card-answered {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
}
|
||
.card-question-text {
|
||
font-size: 28rpx;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
font-weight: 500;
|
||
}
|
||
.card-answer-text {
|
||
font-size: 26rpx;
|
||
color: rgba(193, 134, 255, 0.88);
|
||
}
|
||
.outline-title {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
.outline-logline {
|
||
font-size: 26rpx;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.outline-beats {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
}
|
||
.beat-item {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
}
|
||
.beat-order {
|
||
font-size: 24rpx;
|
||
color: rgba(193, 134, 255, 0.88);
|
||
width: 40rpx;
|
||
}
|
||
.beat-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6rpx;
|
||
}
|
||
.beat-title {
|
||
font-size: 28rpx;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
.beat-summary {
|
||
font-size: 24rpx;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
}
|
||
.outline-ending {
|
||
margin-top: 16rpx;
|
||
padding-top: 16rpx;
|
||
border-top: 1rpx solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
.ending-label {
|
||
font-size: 24rpx;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
margin-right: 12rpx;
|
||
}
|
||
.ending-text {
|
||
font-size: 26rpx;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
.novel-bubble {
|
||
padding: 32rpx;
|
||
}
|
||
.empty-flow {
|
||
padding: 80rpx 0;
|
||
text-align: center;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
font-size: 28rpx;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 启动 H5 验证**
|
||
|
||
Run: `python dev-services.py restart mini-program`(如已在运行,热加载自动生效)
|
||
打开 `http://localhost:5180`,登录后生成一篇新小说,然后从列表进入详情页
|
||
Expected: 详情页展示 chat-bubble 流程视图(心愿 -> 澄清问答 -> 大纲 -> 正文),无重复内容,浏览器 Console 无报错
|
||
|
||
- [ ] **Step 6: 验证老剧本兼容**
|
||
|
||
从列表点击一个改造前生成的老小说进入详情页
|
||
Expected: 不报错,展示心愿 + 正文(老剧本无 clarification/outline message,自动跳过)
|
||
|
||
- [ ] **Step 7: 提交**
|
||
|
||
```bash
|
||
git add mini-program/src/pages/main/ScriptDetailView.vue
|
||
git commit -m "feat:ScriptDetailView 改造为 chat-bubble 流程视图复刻生成时展示"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 前端 ScriptLibraryView 新增大纲预览行
|
||
|
||
**Files:**
|
||
- Modify: `mini-program/src/pages/main/ScriptLibraryView.vue:85-97` (template) + script + style
|
||
|
||
**Interfaces:**
|
||
- Consumes: `script.plotJson.stages.outline.beats`
|
||
- Produces: 列表卡片在标签行下方显示大纲前 2 章预览
|
||
|
||
- [ ] **Step 1: 在 script 中新增 getOutlinePreview 函数**
|
||
|
||
在 ScriptLibraryView.vue 的 `<script setup>` 中(与其他 get* 函数同级)新增:
|
||
|
||
```javascript
|
||
/**
|
||
* 获取大纲预览文本(前 2 章标题)
|
||
* 数据来源:script.plotJson.stages.outline.beats
|
||
*/
|
||
const getOutlinePreview = (script) => {
|
||
const beats = script?.plotJson?.stages?.outline?.beats
|
||
if (!Array.isArray(beats) || beats.length === 0) return ''
|
||
const parts = beats.slice(0, 2).map((beat, i) => {
|
||
const title = beat.title || ''
|
||
return `第${i + 1}章:${title}`
|
||
})
|
||
return `📋 ${parts.join(' · ')}`
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 template 中新增大纲预览行**
|
||
|
||
在 tag-row 和 summary 之间(约 line 87-89)插入:
|
||
|
||
```vue
|
||
<view class="tag-row">
|
||
<text v-for="tag in getTags(script)" :key="tag" class="tag">{{ tag }}</text>
|
||
</view>
|
||
|
||
<view v-if="getOutlinePreview(script)" class="outline-preview-row">
|
||
<text class="outline-preview-text">{{ getOutlinePreview(script) }}</text>
|
||
</view>
|
||
|
||
<text class="summary">{{ script.summary || script.content || '一段正在生成中的平行人生剧本。' }}</text>
|
||
```
|
||
|
||
- [ ] **Step 3: 添加大纲预览行样式**
|
||
|
||
在 `<style>` 中新增(rpx 单位):
|
||
|
||
```css
|
||
.outline-preview-row {
|
||
margin-top: 10rpx;
|
||
padding: 10rpx 14rpx;
|
||
border-radius: 14rpx;
|
||
background: rgba(168, 85, 247, 0.08);
|
||
border: 1rpx solid rgba(168, 85, 247, 0.16);
|
||
}
|
||
.outline-preview-text {
|
||
font-size: 22rpx;
|
||
color: rgba(193, 134, 255, 0.88);
|
||
line-height: 1.4;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: H5 验证**
|
||
|
||
打开 `http://localhost:5180`,进入剧本列表页
|
||
Expected: 每个有大纲数据的剧本卡片显示"📋 第1章:xx · 第2章:xx"预览行;无大纲数据的卡片不显示该行;浏览器 Console 无报错
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add mini-program/src/pages/main/ScriptLibraryView.vue
|
||
git commit -m "feat:ScriptLibraryView 新增大纲预览行"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: 端到端验收与 mp-weixin 产物重建
|
||
|
||
**Files:**
|
||
- 无代码修改,仅验收
|
||
|
||
- [ ] **Step 1: 后端最终部署验证**
|
||
|
||
Run: `python deploy.py backend`
|
||
Expected: 部署成功,无报错
|
||
|
||
- [ ] **Step 2: H5 完整流程验收**
|
||
|
||
打开 `http://localhost:5180`,执行完整流程:
|
||
1. 登录
|
||
2. 在 ScriptView 生成一篇新小说(观察生成时展示:心愿 -> 澄清 -> 大纲 -> 正文)
|
||
3. 返回列表页,确认新小说卡片显示大纲预览行
|
||
4. 点击新小说进入详情页,确认展示与生成时一致(chat-bubble 流程视图,4 个步骤完整)
|
||
5. 点击一个老小说,确认兼容正常(跳过澄清步骤)
|
||
6. 全程检查浏览器 Console 无新增错误,Network 面板 API 返回正常
|
||
|
||
Expected: 所有验收点通过
|
||
|
||
- [ ] **Step 3: 重建 mp-weixin 产物**
|
||
|
||
Run: `cd mini-program && npm run dev:mp-weixin`
|
||
Expected: 构建成功,产物输出到 `unpackage/dist/dev/mp-weixin`
|
||
|
||
- [ ] **Step 4: 微信开发者工具验证**
|
||
|
||
在微信开发者工具中打开 `mini-program/unpackage/dist/dev/mp-weixin`,登录真实用户(如 62bb),生成新小说并查看详情页
|
||
Expected: 详情页展示完整流程视图,与 H5 一致
|
||
|
||
- [ ] **Step 5: 数据库验证 message 完整性**
|
||
|
||
对新生成的小说,查询数据库:
|
||
```bash
|
||
mysql -e "SELECT type, sender, message_order, LEFT(content, 60) FROM t_message WHERE conversation_id='<conversationId>' ORDER BY message_order"
|
||
```
|
||
Expected: 包含 clarification_question/clarification_answer/outline/system/user/script 多条记录,message_order 连续递增
|
||
|
||
- [ ] **Step 6: 提交验收记录**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "test:小说列表/详情页展示改造端到端验收通过"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review 结果
|
||
|
||
### 1. Spec coverage
|
||
- ✅ 段 1 数据层改造:Task 1-5 覆盖(缓存累积器 + 事件累积 + followup answer + saveNovelResult + novel_done 读取)
|
||
- ✅ 段 2 详情页流程视图:Task 6 覆盖(chat-bubble + messages 重建 + 只读模式 + 降级)
|
||
- ✅ 段 3 列表页增强:Task 7 覆盖(大纲预览行 + 数据源 + 降级)
|
||
- ✅ 验收标准:Task 8 覆盖(H5 + mp-weixin + 数据库)
|
||
|
||
### 2. Placeholder scan
|
||
- ✅ 无 TBD/TODO
|
||
- ✅ 所有代码步骤都有具体代码
|
||
- ✅ 所有测试步骤都有具体测试代码
|
||
- ✅ 所有命令都是可执行的
|
||
|
||
### 3. Type consistency
|
||
- ✅ `accumulateStage(String, JSONObject)` 在 Task 1 定义,Task 2/3 使用
|
||
- ✅ `drainStages(String)` 在 Task 1 定义,Task 5 使用
|
||
- ✅ `metadata.stagesHistory` 在 Task 5 放入,Task 4 读取
|
||
- ✅ message type 字符串一致:`clarification_question`/`clarification_answer`/`outline`/`script`/`system`/`chat`
|
||
- ✅ 前端 `listMessagesByConversation` 已存在,Task 6 直接 import
|
||
|
||
### 4. 架构修正说明
|
||
设计文档段 1 说"clarification_card 事件时创建 message",但实际不可行(此时无 conversationId)。本计划修正为:用 sessionId 全局累积器跨流保存,novel_done 时统一创建。这是对设计文档的技术细化,不改变用户需求。
|