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

39 KiB
Raw Blame History

小说列表页/详情页展示改造实施计划

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

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.javaSESSION_ORIGINAL_QUERY_CACHE 声明附近(约 line 50 之后)新增:

    /**
     * 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: 提交
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)从:

private SseEmitter forwardSse(String path, Map<String, Object> body, String currentUserId, String originalQuery) {

改为:

private SseEmitter forwardSse(String path, Map<String, Object> body, String currentUserId,
                              String originalQuery, String knownSessionId) {

在方法体内 stageMetaRef 声明后(约 line 197)新增当前 sessionId 持有者:

            // 当前流的 sessionIdstream 首次从 status 事件获取,followup 从请求参数获取)
            final String[] currentSessionId = {knownSessionId};
  • Step 2: 在 status 事件处理中更新 currentSessionId

在 status 事件处理的 cacheSessionMeta 调用后(约 line 260)新增:

                                        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)从:

                                    } else if ("clarification_card".equals(type)) {
                                        JSONObject card = stagePayload.getJSONObject("card");
                                        if (card != null) {
                                            stageMetaRef.get().put("clarification", card);
                                        }
                                    }

改为:

                                    } 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)从:

                                    if ("outline_created".equals(type)) {
                                        JSONObject outline = stagePayload.getJSONObject("outline");
                                        if (outline != null) {
                                            stageMetaRef.get().put("outline", outline);
                                        }
                                    }

改为:

                                    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)从:

        return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, request.getQuery());

改为:

        return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId,
                request.getQuery(), null);
  • Step 6: followup 调用 forwardSse 传 request.getSessionId()

将 followup 方法(约 line 172)从:

        return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, effectiveOriginalQuery);

改为:

        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: 提交
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 的 accumulateStageShortNovelFollowupRequest.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 163effectiveOriginalQuery 兜底逻辑之后、构建 upstreamBody 之前)新增:

        // 累积用户澄清回答到 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: 提交
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),每项含 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)新增:

        // 读取累积的中间步骤(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)改为:

        systemWelcome.setMessageOrder(messageOrder++);

将用户消息的 userMessage.setMessageOrder(2L)(约 line 302)改为:

        userMessage.setMessageOrder(messageOrder++);

将 AI 消息的 aiMessage.setMessageOrder(3L)(约 line 317)改为:

        aiMessage.setMessageOrder(messageOrder++);
  • Step 3: 更新 conversation 的 messageCount

conversation.setMessageCount(3)(约 line 274)改为:

        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: 提交
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)新增:

                                            // 读取跨流累积的中间步骤(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),然后查询数据库验证:

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: 提交
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.jsClarificationCard from ../../components/ClarificationCard.vueMarkdown from ../../components/Markdown.vue

  • Produces: 详情页 chat-bubble 流程视图,复刻 ScriptView 生成时展示

  • Step 1: 备份当前 ScriptDetailView 的 script setup 逻辑

Read mini-program/src/pages/main/ScriptDetailView.vue 全文,记录现有的 ttsPlayeruseMenuButtonSafeAreacontinueCurrentgoBack 等逻辑(这些需要保留)。

  • Step 2: 改造 template 为 chat-bubble 流程视图

<template> 内的 hero-card + tabs 结构(约 line 13-63)替换为:

    <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,改为:

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, [])
  }
})

保留 goBackcontinueCurrenttrackTtsClickdetailTtsIcondetailTtsActionText 等现有方法不变。

  • Step 4: 添加 chat-bubble 样式

<style> 中新增(rpx 单位):

.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: 提交
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* 函数同级)新增:

/**
 * 获取大纲预览文本(前 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)插入:

          <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 单位):

.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: 提交
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 完整性

对新生成的小说,查询数据库:

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: 提交验收记录
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 时统一创建。这是对设计文档的技术细化,不改变用户需求。