From 4931005102143440119d459425bc9e0efb725c93 Mon Sep 17 00:00:00 2001 From: Peanut Date: Sun, 26 Jul 2026 11:37:26 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=BF=83=E6=84=BF=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=B0=8F=E8=AF=B4=E4=BF=9D=E5=AD=98=E4=B8=8E=E5=A4=8D=E7=8E=B0?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7=E4=BF=AE=E5=A4=8D=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...7-26-mini-program-novel-save-completion.md | 2006 +++++++++++++++++ 1 file changed, 2006 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-26-mini-program-novel-save-completion.md diff --git a/docs/superpowers/plans/2026-07-26-mini-program-novel-save-completion.md b/docs/superpowers/plans/2026-07-26-mini-program-novel-save-completion.md new file mode 100644 index 0000000..eb7e65f --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-mini-program-novel-save-completion.md @@ -0,0 +1,2006 @@ +# 心愿实现小说保存与复现完整性修复实施计划 + +> **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:** 修复小程序"心愿实现"(小说生成)页面的保存不完整、历史列表看不到、详情页无法复现全流程的问题,确保每一步用户交互(澄清、大纲、生成)都能完整保存并可在详情页完整复现。 + +**Architecture:** 后端采用分阶段保存(NovelStageService)+ NovelSessionService(Redis 缓存 originalQuery);前端采用 store 持久化原始参数 + 可靠刷新 + 详情页按 messageOrder 顺序渲染全流程消息。验收强制要求 H5 浏览器端到端通过。 + +**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus 3.5.3.1 + Redis 7.0+ + Vue 3 (UniApp/H5) + Vite + +--- + +## 全局约束 + +1. 后端代码修改后必须本地 `mvn clean install -pl :server -am -DskipTests` 编译通过 +2. 后端通过 `python deploy-remote.py backend` 部署到服务器,**不在本地启动后端** +3. 前端修改后通过 `python dev-services.py restart mini-program` 重启 H5 +4. 验收必须在 `http://localhost:5180` 的 H5 浏览器中完成端到端测试 +5. 禁止使用任何 mock、默认值、兜底逻辑掩盖错误 +6. 禁止写枚举类型作为 Entity、Request、Response、DTO 对象的字段(项目硬性规则) +7. 所有 ID 字段后端使用 `Long`,前端 DTO 使用 `String`(防止 JS 大数精度丢失) +8. 中文提交信息,格式:`类型:描述` +9. 所有数据库表必须包含 `create_time` 和 `update_time` 字段 +10. 删除操作使用逻辑删除(`is_deleted=0` 标识) +11. 禁止使用 `try-catch` 吞异常后返回默认值/空对象;仅允许用于资源关闭、事务回滚、日志记录 +12. Controller 层路由禁止添加 `/api` 前缀;接口方法参数不超过两个 +13. 禁止使用 `@PathVariable`;路径参数统一使用 `@RequestParam` +14. 用户相关接口禁止直接传递用户 id,后端从 token 获取当前登录用户 + +--- + +## 文件结构映射 + +**后端新增:** +1. `server/src/main/java/com/emotion/enums/NovelStage.java` - 阶段枚举 +2. `server/src/main/java/com/emotion/service/NovelStageService.java` - 分阶段保存服务接口 +3. `server/src/main/java/com/emotion/service/impl/NovelStageServiceImpl.java` - 分阶段保存实现 +4. `server/src/main/java/com/emotion/service/NovelSessionService.java` - session 元数据缓存服务接口 +5. `server/src/main/java/com/emotion/service/impl/NovelSessionServiceImpl.java` - Redis 缓存实现 +6. `server/src/main/java/com/emotion/dto/response/NovelStageSaveResponse.java` - 分阶段保存响应 DTO + +**后端修改:** +1. `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java` - 增加 style/length 字段 +2. `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java` - 增加 style/length 字段 +3. `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java` - 拦截所有 stage 事件并调用 saveStage +4. `server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java` - 重构 saveNovelResult 委托给 NovelStageService +5. `server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java` - 修复 getListByCurrentUser 过滤逻辑 + 移除隐性写 +6. `server/src/main/java/com/emotion/entity/Message.java` - 扩展 type 枚举(clarification/outline) +7. `sql/migration/2026-07-26-novel-save-completion.sql` - DDL 补列 + +**前端修改:** +1. `mini-program/src/stores/app.js` - 增加 currentSessionMeta 状态 +2. `mini-program/src/pages/main/ScriptView.vue` - 增强 originalQuery 持久化、可靠刷新、错误处理 +3. `mini-program/src/pages/main/ScriptLibraryView.vue` - 默认不传过滤参数 + 列表项展示 +4. `mini-program/src/pages/main/ScriptDetailView.vue` - 完整复现全流程消息 + +**前端新增:** +1. `mini-program/src/services/novelSession.js` - session 元数据辅助工具(可选) + +--- + +## Task 1: 数据库 DDL 补列 + +**Files:** +- Create: `sql/migration/2026-07-26-novel-save-completion.sql` + +- [ ] **Step 1: 检查现有 DDL** + +在项目根目录运行: + +```bash +grep -n "conversation_id\|current_version_message_id" sql/emotion_museum.sql +``` + +确认 `t_epic_script` 是否真的有缺失列。如果已有则跳过此 Task。 + +- [ ] **Step 2: 创建 migration 文件** + +创建 `sql/migration/2026-07-26-novel-save-completion.sql`: + +```sql +-- 心愿实现小说保存与复现完整性修复 +-- 作者:Claude +-- 创建日期:2026-07-26 +-- 目的:补全 t_epic_script 缺失列,支持 conversation_id / current_version_message_id 关联 + +-- 1. 补 t_epic_script 缺失列 +ALTER TABLE t_epic_script + ADD COLUMN conversation_id BIGINT NULL COMMENT '关联对话ID', + ADD COLUMN current_version_message_id BIGINT NULL COMMENT '当前版本消息ID'; + +-- 2. 补 t_message 缺失列(如果缺失) +ALTER TABLE t_message + ADD COLUMN script_id BIGINT NULL COMMENT '关联剧本ID(冗余反查)'; + +-- 3. 添加索引(可选,提升查询性能) +CREATE INDEX idx_epic_script_conversation_id ON t_epic_script(conversation_id); +CREATE INDEX idx_epic_script_current_version_msg_id ON t_epic_script(current_version_message_id); +CREATE INDEX idx_message_script_id ON t_message(script_id); +``` + +- [ ] **Step 3: 在服务器执行 migration** + +通过 SSH 连接到生产服务器,执行: + +```bash +mysql -u -p < sql/migration/2026-07-26-novel-save-completion.sql +``` + +或在服务器本地运行: + +```bash +cd /opt/emotion-museun +mysql -uroot -p emotion_museum < sql/migration/2026-07-26-novel-save-completion.sql +``` + +- [ ] **Step 4: 验证列已添加** + +```bash +mysql -uroot -p emotion_museum -e "DESC t_epic_script" | grep conversation +mysql -uroot -p emotion_museum -e "DESC t_message" | grep script_id +``` + +Expected: 两个命令都返回包含目标列名的行。 + +- [ ] **Step 5: 提交 migration 文件** + +```bash +git add sql/migration/2026-07-26-novel-save-completion.sql +git commit -m "feat: 补全 t_epic_script 与 t_message 缺失列" +``` + +--- + +## Task 2: 后端 NovelStage 枚举 + +**Files:** +- Create: `server/src/main/java/com/emotion/enums/NovelStage.java` + +- [ ] **Step 1: 创建 NovelStage 枚举** + +```java +package com.emotion.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 小说生成阶段枚举 + *

+ * 用于 NovelStageService 分阶段保存时标识当前阶段 + * + * @author Claude + * @since 2026-07-26 + */ +@Getter +@AllArgsConstructor +public enum NovelStage { + + /** 首次发起,初始化 script + conversation + 首条 user 消息 */ + INIT("init"), + + /** AI 返回澄清卡片 */ + CLARIFICATION("clarification"), + + /** 用户回答澄清 */ + CLARIFICATION_ANSWER("clarification_answer"), + + /** AI 返回大纲 */ + OUTLINE("outline"), + + /** 用户确认/修改大纲 */ + OUTLINE_CONFIRM("outline_confirm"), + + /** AI 返回完整小说正文(最终补全) */ + NOVEL_DONE("novel_done"); + + private final String code; + + /** + * 根据 code 字符串获取枚举 + * + * @param code 阶段编码 + * @return 对应的 NovelStage,未匹配返回 null + */ + public static NovelStage fromCode(String code) { + if (code == null) { + return null; + } + for (NovelStage stage : values()) { + if (stage.code.equalsIgnoreCase(code)) { + return stage; + } + } + return null; + } +} +``` + +- [ ] **Step 2: 编译验证** + +```bash +mvn clean install -pl :server -am -DskipTests +``` + +Expected: BUILD SUCCESS,无报错。 + +- [ ] **Step 3: 提交** + +```bash +git add server/src/main/java/com/emotion/enums/NovelStage.java +git commit -m "feat: 新增 NovelStage 阶段枚举" +``` + +--- + +## Task 3: 后端 NovelSessionService 接口与实现 + +**Files:** +- Create: `server/src/main/java/com/emotion/service/NovelSessionService.java` +- Create: `server/src/main/java/com/emotion/service/impl/NovelSessionServiceImpl.java` + +- [ ] **Step 1: 创建 NovelSessionService 接口** + +```java +package com.emotion.service; + +import java.util.Map; + +/** + * 短篇小说 session 元数据服务 + *

+ * 缓存 sessionId → 元数据(originalQuery/style/length/scriptId/conversationId/userId)的映射 + * 目的:保证 followup 流程中即使前端没传 originalQuery,也能从 session 恢复 + * + * @author Claude + * @since 2026-07-26 + */ +public interface NovelSessionService { + + /** + * 保存 session 元数据 + * + * @param sessionId 外部 short-novel-service 返回的 session_id + * @param meta 元数据 Map(包含 originalQuery/style/length/scriptId/conversationId/userId) + */ + void saveMeta(String sessionId, Map meta); + + /** + * 获取 originalQuery + * + * @param sessionId session_id + * @return originalQuery,未找到返回 null + */ + String getOriginalQuery(String sessionId); + + /** + * 获取 style + * + * @param sessionId session_id + * @return style,未找到返回 null + */ + String getStyle(String sessionId); + + /** + * 获取 length + * + * @param sessionId session_id + * @return length,未找到返回 null + */ + String getLength(String sessionId); + + /** + * 获取 scriptId + * + * @param sessionId session_id + * @return scriptId,未找到返回 null + */ + Long getScriptId(String sessionId); + + /** + * 获取 conversationId + * + * @param sessionId session_id + * @return conversationId,未找到返回 null + */ + Long getConversationId(String sessionId); + + /** + * 删除 session 元数据 + * + * @param sessionId session_id + */ + void remove(String sessionId); +} +``` + +- [ ] **Step 2: 创建 NovelSessionServiceImpl 实现** + +```java +package com.emotion.service.impl; + +import com.emotion.service.NovelSessionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +/** + * 短篇小说 session 元数据服务实现(基于 Redis) + * + * @author Claude + * @since 2026-07-26 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NovelSessionServiceImpl implements NovelSessionService { + + /** Redis key 前缀 */ + private static final String KEY_PREFIX = "novel:session:"; + + /** session 元数据有效期(30 分钟) */ + private static final Duration TTL = Duration.ofMinutes(30); + + private final StringRedisTemplate redisTemplate; + + @Override + public void saveMeta(String sessionId, Map meta) { + if (sessionId == null || sessionId.isEmpty()) { + return; + } + String key = KEY_PREFIX + sessionId; + Map stringMeta = new HashMap<>(); + if (meta.get("originalQuery") != null) { + stringMeta.put("originalQuery", String.valueOf(meta.get("originalQuery"))); + } + if (meta.get("style") != null) { + stringMeta.put("style", String.valueOf(meta.get("style"))); + } + if (meta.get("length") != null) { + stringMeta.put("length", String.valueOf(meta.get("length"))); + } + if (meta.get("scriptId") != null) { + stringMeta.put("scriptId", String.valueOf(meta.get("scriptId"))); + } + if (meta.get("conversationId") != null) { + stringMeta.put("conversationId", String.valueOf(meta.get("conversationId"))); + } + if (meta.get("userId") != null) { + stringMeta.put("userId", String.valueOf(meta.get("userId"))); + } + redisTemplate.opsForHash().putAll(key, stringMeta); + redisTemplate.expire(key, TTL); + log.debug("[NovelSession] 保存元数据: sessionId={}, meta={}", sessionId, stringMeta); + } + + @Override + public String getOriginalQuery(String sessionId) { + return getField(sessionId, "originalQuery"); + } + + @Override + public String getStyle(String sessionId) { + return getField(sessionId, "style"); + } + + @Override + public String getLength(String sessionId) { + return getField(sessionId, "length"); + } + + @Override + public Long getScriptId(String sessionId) { + String value = getField(sessionId, "scriptId"); + return value == null ? null : Long.parseLong(value); + } + + @Override + public Long getConversationId(String sessionId) { + String value = getField(sessionId, "conversationId"); + return value == null ? null : Long.parseLong(value); + } + + @Override + public void remove(String sessionId) { + if (sessionId == null || sessionId.isEmpty()) { + return; + } + redisTemplate.delete(KEY_PREFIX + sessionId); + } + + private String getField(String sessionId, String field) { + if (sessionId == null || sessionId.isEmpty()) { + return null; + } + Object value = redisTemplate.opsForHash().get(KEY_PREFIX + sessionId, field); + return value == null ? null : value.toString(); + } +} +``` + +- [ ] **Step 3: 编译验证** + +```bash +mvn clean install -pl :server -am -DskipTests +``` + +Expected: BUILD SUCCESS,无报错。 + +- [ ] **Step 4: 提交** + +```bash +git add server/src/main/java/com/emotion/service/NovelSessionService.java +git add server/src/main/java/com/emotion/service/impl/NovelSessionServiceImpl.java +git commit -m "feat: 新增 NovelSessionService 提供 session 元数据缓存" +``` + +--- + +## Task 4: 后端 NovelStageSaveResponse DTO + +**Files:** +- Create: `server/src/main/java/com/emotion/dto/response/NovelStageSaveResponse.java` + +- [ ] **Step 1: 创建 NovelStageSaveResponse** + +```java +package com.emotion.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 分阶段保存响应 DTO + * + * @author Claude + * @since 2026-07-26 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NovelStageSaveResponse { + + /** script ID(INIT 时返回,初始化后所有阶段都可从 session 恢复) */ + private String scriptId; + + /** conversation ID */ + private String conversationId; + + /** 当前阶段保存的消息 ID */ + private String messageId; + + /** 当前消息的 messageOrder(1-based) */ + private Integer messageOrder; +} +``` + +- [ ] **Step 2: 编译验证** + +```bash +mvn clean install -pl :server -am -DskipTests +``` + +Expected: BUILD SUCCESS。 + +- [ ] **Step 3: 提交** + +```bash +git add server/src/main/java/com/emotion/dto/response/NovelStageSaveResponse.java +git commit -m "feat: 新增 NovelStageSaveResponse DTO" +``` + +--- + +## Task 5: 后端 NovelStageService 接口 + +**Files:** +- Create: `server/src/main/java/com/emotion/service/NovelStageService.java` + +- [ ] **Step 1: 创建 NovelStageService 接口** + +```java +package com.emotion.service; + +import com.emotion.dto.response.NovelStageSaveResponse; +import com.emotion.enums.NovelStage; + +/** + * 短篇小说分阶段保存服务 + *

+ * 负责在小说生成的多轮交互过程中,按阶段实时持久化到数据库: + * - INIT: 首次发起,初始化 script + conversation + 首条 user 消息 + * - CLARIFICATION: AI 返回澄清卡片 + * - CLARIFICATION_ANSWER: 用户回答澄清 + * - OUTLINE: AI 返回大纲 + * - OUTLINE_CONFIRM: 用户确认/修改大纲 + * - NOVEL_DONE: AI 返回完整小说正文(最终补全) + * + * @author Claude + * @since 2026-07-26 + */ +public interface NovelStageService { + + /** + * 保存小说生成过程的某一阶段 + * + * @param userId 当前用户 ID + * @param sessionId 外部 short-novel-service 返回的 session_id + * @param stage 当前阶段 + * @param stageData 阶段数据,包含 content/metadata/title 等 + * @return 保存结果,包含 scriptId/conversationId/messageId/messageOrder + */ + NovelStageSaveResponse saveStage(Long userId, String sessionId, NovelStage stage, + StageData stageData); + + /** + * 阶段数据封装 + */ + class StageData { + /** 消息文本内容(user 消息 / 小说正文) */ + private String content; + + /** 元数据 JSON(澄清卡片 / 大纲) */ + private String metadata; + + /** 标题(NOVEL_DONE 时更新 EpicScript.title) */ + private String title; + + /** 用户心愿原文(INIT 时必须) */ + private String originalQuery; + + /** 风格 */ + private String style; + + /** 长度 */ + private String length; + + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + + public String getMetadata() { return metadata; } + public void setMetadata(String metadata) { this.metadata = metadata; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getOriginalQuery() { return originalQuery; } + public void setOriginalQuery(String originalQuery) { this.originalQuery = originalQuery; } + + public String getStyle() { return style; } + public void setStyle(String style) { this.style = style; } + + public String getLength() { return length; } + public void setLength(String length) { this.length = length; } + } +} +``` + +- [ ] **Step 2: 编译验证** + +```bash +mvn clean install -pl :server -am -DskipTests +``` + +Expected: BUILD SUCCESS。 + +- [ ] **Step 3: 提交** + +```bash +git add server/src/main/java/com/emotion/service/NovelStageService.java +git commit -m "feat: 新增 NovelStageService 接口" +``` + +--- + +## Task 6: 后端 NovelStageServiceImpl 实现 + +**Files:** +- Create: `server/src/main/java/com/emotion/service/impl/NovelStageServiceImpl.java` + +- [ ] **Step 1: 创建 NovelStageServiceImpl 实现** + +```java +package com.emotion.service.impl; + +import com.emotion.dto.response.NovelStageSaveResponse; +import com.emotion.entity.Conversation; +import com.emotion.entity.EpicScript; +import com.emotion.entity.Message; +import com.emotion.enums.NovelStage; +import com.emotion.mapper.ConversationMapper; +import com.emotion.mapper.EpicScriptMapper; +import com.emotion.mapper.MessageMapper; +import com.emotion.service.NovelStageService; +import com.emotion.service.NovelSessionService; +import com.emotion.util.SnowflakeIdGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 短篇小说分阶段保存服务实现 + * + * @author Claude + * @since 2026-07-26 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NovelStageServiceImpl implements NovelStageService { + + private final EpicScriptMapper epicScriptMapper; + private final ConversationMapper conversationMapper; + private final MessageMapper messageMapper; + private final NovelSessionService novelSessionService; + private final SnowflakeIdGenerator snowflakeIdGenerator; + private final ObjectMapper objectMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public NovelStageSaveResponse saveStage(Long userId, String sessionId, NovelStage stage, + StageData stageData) { + log.info("[NovelStage] 保存阶段: userId={}, sessionId={}, stage={}", userId, sessionId, stage); + + switch (stage) { + case INIT: + return handleInit(userId, sessionId, stageData); + case CLARIFICATION: + return handleClarification(userId, sessionId, stageData); + case CLARIFICATION_ANSWER: + return handleClarificationAnswer(userId, sessionId, stageData); + case OUTLINE: + return handleOutline(userId, sessionId, stageData); + case OUTLINE_CONFIRM: + return handleOutlineConfirm(userId, sessionId, stageData); + case NOVEL_DONE: + return handleNovelDone(userId, sessionId, stageData); + default: + throw new IllegalArgumentException("未知的 NovelStage: " + stage); + } + } + + /** + * INIT 阶段:创建 EpicScript + Conversation + 3 条 Message(系统欢迎、用户心愿) + */ + private NovelStageSaveResponse handleInit(Long userId, String sessionId, StageData data) { + if (data.getOriginalQuery() == null || data.getOriginalQuery().isBlank()) { + throw new IllegalArgumentException("INIT 阶段必须提供 originalQuery"); + } + + // 1. 创建 EpicScript + Long scriptId = snowflakeIdGenerator.nextId(); + EpicScript script = new EpicScript(); + script.setId(scriptId); + script.setUserId(userId); + script.setTitle(data.getTitle() != null ? data.getTitle() : "生成中..."); + script.setTheme(data.getOriginalQuery()); + script.setStyle(data.getStyle() != null ? data.getStyle() : "career"); + script.setLength(data.getLength() != null ? data.getLength() : "short"); + script.setPlotJson("{}"); + script.setIsDeleted(0); + script.setCreateTime(LocalDateTime.now()); + script.setUpdateTime(LocalDateTime.now()); + epicScriptMapper.insert(script); + + // 2. 创建 Conversation + Long conversationId = snowflakeIdGenerator.nextId(); + Conversation conversation = new Conversation(); + conversation.setId(conversationId); + conversation.setUserId(userId); + conversation.setUserType("registered"); + conversation.setScriptId(scriptId); + conversation.setTitle(data.getTitle() != null ? data.getTitle() : "心愿剧本"); + conversation.setType("script"); + conversation.setStatus("active"); + conversation.setMessageCount(2); + conversation.setLastActiveTime(LocalDateTime.now()); + conversation.setCreateTime(LocalDateTime.now()); + conversation.setUpdateTime(LocalDateTime.now()); + conversationMapper.insert(conversation); + + // 回写 EpicScript.conversationId + script.setConversationId(conversationId); + epicScriptMapper.updateById(script); + + // 3. 创建 Message 1(系统欢迎) + Long systemMsgId = snowflakeIdGenerator.nextId(); + Message systemMsg = new Message(); + systemMsg.setId(systemMsgId); + systemMsg.setConversationId(conversationId); + systemMsg.setScriptId(scriptId); + systemMsg.setUserId(userId); + systemMsg.setSender("system"); + systemMsg.setType("system"); + systemMsg.setContent("欢迎来到心愿剧本的世界,我将为你生成一个精彩的故事。"); + systemMsg.setMessageOrder(1); + systemMsg.setTimestamp(LocalDateTime.now()); + systemMsg.setCreateTime(LocalDateTime.now()); + messageMapper.insert(systemMsg); + + // 4. 创建 Message 2(用户心愿原文) + Long userMsgId = snowflakeIdGenerator.nextId(); + Message userMsg = new Message(); + userMsg.setId(userMsgId); + userMsg.setConversationId(conversationId); + userMsg.setScriptId(scriptId); + userMsg.setUserId(userId); + userMsg.setSender("user"); + userMsg.setType("chat"); + userMsg.setContent(data.getOriginalQuery()); + userMsg.setMessageOrder(2); + userMsg.setTimestamp(LocalDateTime.now()); + userMsg.setCreateTime(LocalDateTime.now()); + messageMapper.insert(userMsg); + + // 5. 缓存 session 元数据 + Map meta = new HashMap<>(); + meta.put("originalQuery", data.getOriginalQuery()); + meta.put("style", script.getStyle()); + meta.put("length", script.getLength()); + meta.put("scriptId", scriptId); + meta.put("conversationId", conversationId); + meta.put("userId", userId); + novelSessionService.saveMeta(sessionId, meta); + + return NovelStageSaveResponse.builder() + .scriptId(String.valueOf(scriptId)) + .conversationId(String.valueOf(conversationId)) + .messageId(String.valueOf(userMsgId)) + .messageOrder(2) + .build(); + } + + /** + * CLARIFICATION 阶段:保存 AI 的澄清卡片 + */ + private NovelStageSaveResponse handleClarification(Long userId, String sessionId, StageData data) { + Long conversationId = resolveConversationId(sessionId); + Long scriptId = resolveScriptId(sessionId); + + Long msgId = snowflakeIdGenerator.nextId(); + Message msg = new Message(); + msg.setId(msgId); + msg.setConversationId(conversationId); + msg.setScriptId(scriptId); + msg.setUserId(userId); + msg.setSender("assistant"); + msg.setType("clarification"); + msg.setContent(data.getContent() != null ? data.getContent() : "(见元数据)"); + msg.setMetadata(data.getMetadata()); + msg.setMessageOrder(3); + msg.setTimestamp(LocalDateTime.now()); + msg.setCreateTime(LocalDateTime.now()); + messageMapper.insert(msg); + + updateConversationActive(conversationId, 3); + + return NovelStageSaveResponse.builder() + .scriptId(String.valueOf(scriptId)) + .conversationId(String.valueOf(conversationId)) + .messageId(String.valueOf(msgId)) + .messageOrder(3) + .build(); + } + + /** + * CLARIFICATION_ANSWER 阶段:保存用户回答 + */ + private NovelStageSaveResponse handleClarificationAnswer(Long userId, String sessionId, StageData data) { + Long conversationId = resolveConversationId(sessionId); + Long scriptId = resolveScriptId(sessionId); + + Long msgId = snowflakeIdGenerator.nextId(); + Message msg = new Message(); + msg.setId(msgId); + msg.setConversationId(conversationId); + msg.setScriptId(scriptId); + msg.setUserId(userId); + msg.setSender("user"); + msg.setType("chat"); + msg.setContent(data.getContent()); + msg.setMessageOrder(4); + msg.setTimestamp(LocalDateTime.now()); + msg.setCreateTime(LocalDateTime.now()); + messageMapper.insert(msg); + + updateConversationActive(conversationId, 4); + + return NovelStageSaveResponse.builder() + .scriptId(String.valueOf(scriptId)) + .conversationId(String.valueOf(conversationId)) + .messageId(String.valueOf(msgId)) + .messageOrder(4) + .build(); + } + + /** + * OUTLINE 阶段:保存 AI 大纲 + */ + private NovelStageSaveResponse handleOutline(Long userId, String sessionId, StageData data) { + Long conversationId = resolveConversationId(sessionId); + Long scriptId = resolveScriptId(sessionId); + + Long msgId = snowflakeIdGenerator.nextId(); + Message msg = new Message(); + msg.setId(msgId); + msg.setConversationId(conversationId); + msg.setScriptId(scriptId); + msg.setUserId(userId); + msg.setSender("assistant"); + msg.setType("outline"); + msg.setContent(data.getContent() != null ? data.getContent() : "(见元数据)"); + msg.setMetadata(data.getMetadata()); + msg.setMessageOrder(5); + msg.setTimestamp(LocalDateTime.now()); + msg.setCreateTime(LocalDateTime.now()); + messageMapper.insert(msg); + + // 更新 EpicScript.plotJson.outline + EpicScript script = epicScriptMapper.selectById(scriptId); + if (script != null) { + try { + Map plotJson = script.getPlotJson() == null || script.getPlotJson().isEmpty() + ? new HashMap<>() + : objectMapper.readValue(script.getPlotJson(), Map.class); + plotJson.put("outline", data.getMetadata()); + if (data.getTitle() != null) { + plotJson.put("title", data.getTitle()); + script.setTitle(data.getTitle()); + } + script.setPlotJson(objectMapper.writeValueAsString(plotJson)); + script.setUpdateTime(LocalDateTime.now()); + epicScriptMapper.updateById(script); + } catch (Exception e) { + log.error("[NovelStage] 更新 plotJson 失败: scriptId={}", scriptId, e); + throw new RuntimeException("更新 plotJson 失败", e); + } + } + + updateConversationActive(conversationId, 5); + + return NovelStageSaveResponse.builder() + .scriptId(String.valueOf(scriptId)) + .conversationId(String.valueOf(conversationId)) + .messageId(String.valueOf(msgId)) + .messageOrder(5) + .build(); + } + + /** + * OUTLINE_CONFIRM 阶段:保存用户确认/修改 + */ + private NovelStageSaveResponse handleOutlineConfirm(Long userId, String sessionId, StageData data) { + Long conversationId = resolveConversationId(sessionId); + Long scriptId = resolveScriptId(sessionId); + + Long msgId = snowflakeIdGenerator.nextId(); + Message msg = new Message(); + msg.setId(msgId); + msg.setConversationId(conversationId); + msg.setScriptId(scriptId); + msg.setUserId(userId); + msg.setSender("user"); + msg.setType("chat"); + msg.setContent(data.getContent()); + msg.setMessageOrder(6); + msg.setTimestamp(LocalDateTime.now()); + msg.setCreateTime(LocalDateTime.now()); + messageMapper.insert(msg); + + updateConversationActive(conversationId, 6); + + return NovelStageSaveResponse.builder() + .scriptId(String.valueOf(scriptId)) + .conversationId(String.valueOf(conversationId)) + .messageId(String.valueOf(msgId)) + .messageOrder(6) + .build(); + } + + /** + * NOVEL_DONE 阶段:保存完整小说正文(最终补全) + */ + private NovelStageSaveResponse handleNovelDone(Long userId, String sessionId, StageData data) { + Long conversationId = resolveConversationId(sessionId); + Long scriptId = resolveScriptId(sessionId); + + if (data.getContent() == null || data.getContent().isBlank()) { + throw new IllegalArgumentException("NOVEL_DONE 阶段必须提供完整小说正文"); + } + + Long msgId = snowflakeIdGenerator.nextId(); + Message msg = new Message(); + msg.setId(msgId); + msg.setConversationId(conversationId); + msg.setScriptId(scriptId); + msg.setUserId(userId); + msg.setSender("assistant"); + msg.setType("script"); + msg.setContent(data.getContent()); + msg.setMetadata(data.getMetadata()); + msg.setVersionNumber(1); + msg.setMessageOrder(7); + msg.setTimestamp(LocalDateTime.now()); + msg.setCreateTime(LocalDateTime.now()); + messageMapper.insert(msg); + + // 更新 EpicScript.title + currentVersionMessageId + plotJson + EpicScript script = epicScriptMapper.selectById(scriptId); + if (script != null) { + if (data.getTitle() != null && !data.getTitle().isBlank()) { + script.setTitle(data.getTitle()); + } else { + script.setTitle("我的心愿剧本"); + } + script.setCurrentVersionMessageId(msgId); + try { + Map plotJson = script.getPlotJson() == null || script.getPlotJson().isEmpty() + ? new HashMap<>() + : objectMapper.readValue(script.getPlotJson(), Map.class); + plotJson.put("fullContent", data.getContent()); + if (data.getMetadata() != null) { + plotJson.put("chapters", data.getMetadata()); + } + script.setPlotJson(objectMapper.writeValueAsString(plotJson)); + } catch (Exception e) { + log.error("[NovelStage] 更新 plotJson 失败: scriptId={}", scriptId, e); + throw new RuntimeException("更新 plotJson 失败", e); + } + script.setUpdateTime(LocalDateTime.now()); + epicScriptMapper.updateById(script); + } + + // 更新 Conversation.currentMessageId + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation != null) { + conversation.setCurrentMessageId(msgId); + conversation.setMessageCount(7); + conversation.setLastActiveTime(LocalDateTime.now()); + conversation.setUpdateTime(LocalDateTime.now()); + conversationMapper.updateById(conversation); + } + + return NovelStageSaveResponse.builder() + .scriptId(String.valueOf(scriptId)) + .conversationId(String.valueOf(conversationId)) + .messageId(String.valueOf(msgId)) + .messageOrder(7) + .build(); + } + + private Long resolveConversationId(String sessionId) { + Long cid = novelSessionService.getConversationId(sessionId); + if (cid == null) { + throw new IllegalStateException("session 未初始化或已过期: sessionId=" + sessionId); + } + return cid; + } + + private Long resolveScriptId(String sessionId) { + Long sid = novelSessionService.getScriptId(sessionId); + if (sid == null) { + throw new IllegalStateException("session 未初始化或已过期: sessionId=" + sessionId); + } + return sid; + } + + private void updateConversationActive(Long conversationId, int messageCount) { + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation != null) { + conversation.setMessageCount(messageCount); + conversation.setLastActiveTime(LocalDateTime.now()); + conversation.setUpdateTime(LocalDateTime.now()); + conversationMapper.updateById(conversation); + } + } +} +``` + +- [ ] **Step 2: 编译验证** + +```bash +mvn clean install -pl :server -am -DskipTests +``` + +Expected: BUILD SUCCESS。 + +如果编译失败,常见原因: +- `getPlotJson()` 方法不存在 → 检查 EpicScript Entity 是否已有 plotJson 字段 +- `SnowflakeIdGenerator` 类名不对 → 替换为项目中实际使用的 ID 生成器类名 +- `Message` 缺少 `setScriptId()` → 确认 Task 1 已执行 DDL 补列 + +- [ ] **Step 3: 提交** + +```bash +git add server/src/main/java/com/emotion/service/impl/NovelStageServiceImpl.java +git commit -m "feat: 实现 NovelStageService 分阶段保存逻辑" +``` + +--- + +## Task 7: 后端 ShortNovelRequest DTO 增加字段 + +**Files:** +- Modify: `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java` +- Modify: `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java` + +- [ ] **Step 1: 读取现有 ShortNovelStreamRequest** + +```bash +cat server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java +``` + +- [ ] **Step 2: 增加 style 和 length 字段** + +在现有 `ShortNovelStreamRequest` 类中增加: + +```java +/** 风格 */ +private String style; + +/** 长度 */ +private String length; + +// 对应的 getter 和 setter +public String getStyle() { return style; } +public void setStyle(String style) { this.style = style; } + +public String getLength() { return length; } +public void setLength(String length) { this.length = length; } +``` + +- [ ] **Step 3: 读取现有 ShortNovelFollowupRequest 并增加字段** + +```bash +cat server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java +``` + +在 `ShortNovelFollowupRequest` 类中增加 `style` 和 `length` 字段(同上模式)。 + +- [ ] **Step 4: 编译验证** + +```bash +mvn clean install -pl :server -am -DskipTests +``` + +Expected: BUILD SUCCESS。 + +- [ ] **Step 5: 提交** + +```bash +git add server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java +git add server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java +git commit -m "feat: ShortNovel 请求 DTO 增加 style/length 字段" +``` + +--- + +## Task 8: 后端 ShortNovelServiceImpl 拦截所有 stage 事件 + +**Files:** +- Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java` + +- [ ] **Step 1: 注入 NovelStageService 和 NovelSessionService** + +在 `ShortNovelServiceImpl` 类顶部,修改现有依赖注入: + +```java +private final NovelStageService novelStageService; +private final NovelSessionService novelSessionService; +``` + +如果使用 `@RequiredArgsConstructor`(Lombok),确保这两个字段在类中声明为 `private final`。 + +- [ ] **Step 2: 在 stream 方法中调用 INIT 阶段保存** + +找到处理 SSE 状态阶段的方法(例如 `status` 事件或 `processStatus`),在收到初始化阶段时调用: + +```java +// 第一次 stream 请求时调用 INIT 阶段 +NovelStageService.StageData initData = new NovelStageService.StageData(); +initData.setOriginalQuery(request.getQuery()); +initData.setStyle(request.getStyle()); +initData.setLength(request.getLength()); +initData.setTitle("生成中..."); + +NovelStageSaveResponse initResult = novelStageService.saveStage( + currentUserId, sessionId, NovelStage.INIT, initData); + +// 保存 scriptId/conversationId 到本地变量 +this.currentScriptId = initResult.getScriptId(); +this.currentConversationId = initResult.getConversationId(); +``` + +- [ ] **Step 3: 拦截 clarification_card 事件** + +找到 SSE 事件解析的 switch/if 分支,在 `clarification_card` 事件处添加: + +```java +case "clarification_card": + NovelStageService.StageData clarificationData = new NovelStageService.StageData(); + clarificationData.setMetadata(objectMapper.writeValueAsString(event.getJSONObject("payload").getJSONObject("card"))); + novelStageService.saveStage(currentUserId, sessionId, NovelStage.CLARIFICATION, clarificationData); + break; +``` + +- [ ] **Step 4: 拦截 outline_created 事件** + +```java +case "outline_created": + NovelStageService.StageData outlineData = new NovelStageService.StageData(); + JSONObject outlinePayload = event.getJSONObject("payload"); + outlineData.setMetadata(objectMapper.writeValueAsString(outlinePayload.getJSONObject("outline"))); + if (outlinePayload.getJSONObject("outline") != null + && outlinePayload.getJSONObject("outline").getString("title") != null) { + outlineData.setTitle(outlinePayload.getJSONObject("outline").getString("title")); + } + novelStageService.saveStage(currentUserId, sessionId, NovelStage.OUTLINE, outlineData); + break; +``` + +- [ ] **Step 5: 重构 novel_done 事件处理为 NOVEL_DONE 阶段** + +找到现有的 `novel_done` 事件处理逻辑(可能有 `saveNovelResult` 调用),替换为: + +```java +case "novel_done": + NovelStageService.StageData novelDoneData = new NovelStageService.StageData(); + JSONObject novelPayload = event.getJSONObject("payload"); + novelDoneData.setContent(novelPayload.getString("full_text")); + if (novelPayload.getString("title") != null) { + novelDoneData.setTitle(novelPayload.getString("title")); + } + if (novelPayload.getString("chapters") != null) { + novelDoneData.setMetadata(novelPayload.getString("chapters")); + } + NovelStageSaveResponse result = novelStageService.saveStage( + currentUserId, sessionId, NovelStage.NOVEL_DONE, novelDoneData); + + // 将 ID 信息注入到转发给前端的 payload 中 + novelPayload.put("scriptId", result.getScriptId()); + novelPayload.put("conversationId", result.getConversationId()); + novelPayload.put("currentVersionMessageId", result.getMessageId()); + break; +``` + +- [ ] **Step 6: 在 followup 方法中增加 CLARIFICATION_ANSWER / OUTLINE_CONFIRM 阶段保存** + +找到处理 followup 请求的方法,在转发外部请求前,根据 `action` 字段调用对应阶段保存: + +```java +// 在调用外部 short-novel-service 之前 +NovelStage actionStage = null; +NovelStageService.StageData actionData = new NovelStageService.StageData(); + +if ("answer_clarification".equals(request.getAction())) { + actionStage = NovelStage.CLARIFICATION_ANSWER; + actionData.setContent(request.getPayload().getString("answer")); +} else if ("confirm_outline".equals(request.getAction()) || "modify_outline".equals(request.getAction())) { + actionStage = NovelStage.OUTLINE_CONFIRM; + actionData.setContent(request.getPayload().getString("feedback")); +} + +if (actionStage != null) { + novelStageService.saveStage(currentUserId, request.getSessionId(), actionStage, actionData); +} +``` + +- [ ] **Step 7: 在 followup 方法中增加 originalQuery fallback** + +找到 followup 方法开头,添加: + +```java +String originalQuery = request.getOriginalQuery(); +if (originalQuery == null || originalQuery.isBlank()) { + originalQuery = novelSessionService.getOriginalQuery(request.getSessionId()); +} +if (originalQuery == null || originalQuery.isBlank()) { + throw new BizException("originalQuery 不能为空且无法从 session 恢复"); +} +// 将 originalQuery 放入转发到外部服务的请求体中 +``` + +- [ ] **Step 8: 编译验证** + +```bash +mvn clean install -pl :server -am -DskipTests +``` + +Expected: BUILD SUCCESS。 + +如果编译失败: +- 缺少 import → 补充 `import com.emotion.service.NovelStageService;` 等 +- `getPayload()` 返回类型不对 → 根据实际 DTO 调整 + +- [ ] **Step 9: 提交** + +```bash +git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java +git commit -m "feat: ShortNovelServiceImpl 拦截所有 stage 事件触发分阶段保存" +``` + +--- + +## Task 9: 后端修复 EpicScriptServiceImpl 列表查询 + +**Files:** +- Modify: `server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java` + +- [ ] **Step 1: 找到 getListByCurrentUser 方法** + +```bash +grep -n "getListByCurrentUser\|ensureConversationForScript" server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java +``` + +- [ ] **Step 2: 修改过滤逻辑为按需过滤** + +将现有的过滤逻辑(可能是无条件过滤 length/style)改为: + +```java +@Override +public List getListByCurrentUser(EpicScriptListRequest request) { + Long currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + throw new BizException("用户未登录"); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(EpicScript::getUserId, currentUserId) + .eq(EpicScript::getIsDeleted, 0); + + // 只在显式传入时才过滤 + if (request != null && StringUtils.isNotBlank(request.getStyle())) { + wrapper.eq(EpicScript::getStyle, request.getStyle()); + } + if (request != null && StringUtils.isNotBlank(request.getLength())) { + wrapper.eq(EpicScript::getLength, request.getLength()); + } + if (request != null && StringUtils.isNotBlank(request.getKeyword())) { + wrapper.and(w -> w.like(EpicScript::getTitle, request.getKeyword()) + .or().like(EpicScript::getTheme, request.getKeyword())); + } + + // create_time 倒序 + wrapper.orderByDesc(EpicScript::getCreateTime); + + return epicScriptMapper.selectList(wrapper); +} +``` + +- [ ] **Step 3: 移除 ensureConversationForScript 中的写操作** + +找到 `ensureConversationForScript` 方法,确保它只查询不修改: + +```java +private void ensureConversationForScript(EpicScript script) { + // 只查询 conversation 是否存在,不再自动补建 + if (script.getConversationId() != null) { + Conversation conversation = conversationMapper.selectById(script.getConversationId()); + if (conversation == null) { + log.warn("[EpicScript] conversation 不存在: scriptId={}, conversationId={}", + script.getId(), script.getConversationId()); + } + } +} +``` + +如果方法体是创建并写库,需要全部删除,只保留查询和日志。 + +- [ ] **Step 4: 编译验证** + +```bash +mvn clean install -pl :server -am -DskipTests +``` + +Expected: BUILD SUCCESS。 + +- [ ] **Step 5: 提交** + +```bash +git add server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java +git commit -m "fix: 修复列表查询按需过滤并移除隐性写操作" +``` + +--- + +## Task 10: 前端 stores/app.js 增加 currentSessionMeta + +**Files:** +- Modify: `mini-program/src/stores/app.js` + +- [ ] **Step 1: 读取现有 stores/app.js** + +```bash +cat mini-program/src/stores/app.js | head -100 +``` + +- [ ] **Step 2: 在 state 中增加 currentSessionMeta** + +找到 `state()` 方法,在其中增加: + +```javascript +currentSessionMeta: null, // 当前 session 元数据:{ sessionId, scriptId, conversationId, originalQuery, style, length } +``` + +- [ ] **Step 3: 增加 setter 和 getter** + +在文件中增加: + +```javascript +const setSessionMeta = (meta) => { + state.currentSessionMeta = meta +} + +const getSessionMeta = () => { + return state.currentSessionMeta +} + +const clearSessionMeta = () => { + state.currentSessionMeta = null +} +``` + +- [ ] **Step 4: 在导出 actions 中增加这三个方法** + +找到 `return { ... }` 导出语句,增加: + +```javascript +setSessionMeta, +getSessionMeta, +clearSessionMeta, +``` + +- [ ] **Step 5: 提交** + +```bash +git add mini-program/src/stores/app.js +git commit -m "feat: store 增加 currentSessionMeta 状态管理" +``` + +--- + +## Task 11: 前端 ScriptView.vue 增强保存逻辑 + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [ ] **Step 1: 读取 ScriptView.vue 找到关键函数** + +```bash +grep -n "runGeneration\|handleShortNovelEvent\|submitClarification\|confirmOutline\|fetchScripts" mini-program/src/pages/main/ScriptView.vue +``` + +- [ ] **Step 2: 在 runGeneration 开头保存 sessionMeta** + +找到 `runGeneration` 函数,在函数体开头添加: + +```javascript +// 持久化原始参数,用于后续 followup 调用 +store.setSessionMeta({ + sessionId: '', // stream 开始后会更新 + scriptId: '', + conversationId: '', + originalQuery: prompt, + style: userStyle.value || 'career', + length: userLength.value || 'short', + timestamp: Date.now() +}) +``` + +- [ ] **Step 3: 修改 SSE 事件处理** + +找到 `handleShortNovelEvent` 函数(或类似),在处理 `status` 事件(处理外部服务返回的 session_id)时: + +```javascript +if (event.type === 'status' && event.payload?.session_id) { + const meta = store.getSessionMeta() || {} + meta.sessionId = event.payload.session_id + store.setSessionMeta(meta) +} +``` + +- [ ] **Step 4: 修改 novel_done 事件处理为可靠刷新** + +找到 `novel_done` 事件处理,修改为: + +```javascript +if (event.type === 'novel_done') { + const meta = store.getSessionMeta() || {} + meta.scriptId = event.payload?.scriptId || meta.scriptId + meta.conversationId = event.payload?.conversationId || meta.conversationId + store.setSessionMeta(meta) + + isGenerating.value = false + + // 可靠刷新列表 + try { + await store.fetchScripts() + console.log('[ScriptView] novel_done 后列表刷新成功') + } catch (e) { + console.error('[ScriptView] novel_done 后列表刷新失败:', e) + uni.showToast({ title: '列表刷新失败,请手动刷新', icon: 'none' }) + } +} +``` + +- [ ] **Step 5: 修改 submitClarification 增加 originalQuery 兜底** + +找到 `submitClarification` 函数,添加: + +```javascript +const submitClarification = async (answer) => { + const meta = store.getSessionMeta() + if (!meta?.sessionId) { + uni.showToast({ title: '会话已失效,请重新开始', icon: 'none' }) + return + } + if (!meta?.originalQuery) { + uni.showToast({ title: '原始心愿已丢失,请重新开始', icon: 'none' }) + return + } + + await followupStream({ + sessionId: meta.sessionId, + action: 'answer_clarification', + payload: { answer }, + originalQuery: meta.originalQuery, + style: meta.style, + length: meta.length, + onEvent: handleShortNovelEvent, + onError: handleNovelError + }) +} +``` + +- [ ] **Step 6: 修改 confirmOutline 和 modifyOutline 增加 originalQuery 兜底** + +同样为 `confirmOutline` 和 `modifyOutline` 函数增加 originalQuery 兜底(同 submitClarification 模式)。 + +- [ ] **Step 7: 增强错误处理** + +找到错误处理函数(或新增): + +```javascript +const handleNovelError = (error) => { + isGenerating.value = false + uni.showModal({ + title: '生成失败', + content: error?.message || '请稍后重试', + showCancel: false, + confirmText: '知道了' + }) +} +``` + +- [ ] **Step 8: 提交** + +```bash +git add mini-program/src/pages/main/ScriptView.vue +git commit -m "feat: ScriptView 增强 sessionMeta 持久化和可靠刷新" +``` + +--- + +## Task 12: 前端 ScriptLibraryView.vue 优化列表项 + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptLibraryView.vue` + +- [ ] **Step 1: 找到 onMounted 或数据加载逻辑** + +```bash +grep -n "onMounted\|fetchScripts\|length\|style" mini-program/src/pages/main/ScriptLibraryView.vue +``` + +- [ ] **Step 2: 修改默认不传 length/style 过滤** + +找到调用 `store.fetchScripts()` 的地方,确保不传 length/style 过滤参数: + +```javascript +onMounted(async () => { + await store.fetchScripts() +}) +``` + +如果项目中有 `getScriptPage` 或带参数的 `fetchScripts`,修改为默认无参调用。 + +- [ ] **Step 3: 优化列表项展示** + +找到列表项的 `