Files
happy-life-star/docs/superpowers/plans/2026-07-26-novel-list-detail-display.md
T

1009 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 小说列表页/详情页展示改造实施计划
> **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 + OkHttpUniApp Vue 3 + PiniaMySQL 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: PASS5 个测试全绿
- [ ] **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
// 当前流的 sessionIdstream 首次从 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 "featclarification_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 "featfollowup 累积用户澄清回答到全局缓存"
```
---
## 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` 的 messagemessageOrder 按顺序递增
- [ ] **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 创建中间步骤 messageclarification_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 "featsaveNovelResult 创建 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 传入 saveNovelResultnovel_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 多条 messagemessage_order 连续递增
- [ ] **Step 4: 提交**
```bash
git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java
git commit -m "featnovel_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 "featScriptDetailView 改造为 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 "featScriptLibraryView 新增大纲预览行"
```
---
## 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 时统一创建。这是对设计文档的技术细化,不改变用户需求。