Compare commits
10 Commits
872cf63c7c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| daefcc89d6 | |||
| 504ff8191a | |||
| 44fe10950d | |||
| 77ab1aee85 | |||
| 318596c67e | |||
| 760c1445fe | |||
| 9808e2eb93 | |||
| 4e79e2150b | |||
| 502586b64a | |||
| 321246c96c |
@@ -144,6 +144,31 @@ npm run dev:mp-weixin
|
||||
|
||||
**开发工作流说明**:详见 [小程序开发工作流程设计](docs/superpowers/specs/2026-04-07-mini-program-dev-workflow-design.md)
|
||||
|
||||
#### 小程序 H5 测试登录凭证(强制默认)
|
||||
|
||||
所有小程序功能的 H5 验收必须使用以下测试账号,**禁止在测试中使用真实手机号或重复触发验证码接口**:
|
||||
|
||||
- **手机号**:`19928748688`(固定测试账号,11 位中国大陆手机号格式)
|
||||
- **验证码**:`123456`(固定 6 位数字,生产环境短信通道返回此固定值便于测试)
|
||||
|
||||
**登录流程**(参考 `mini-program/src/pages/login/index.vue` + `mini-program/src/services/auth.js`):
|
||||
|
||||
1. 浏览器访问 `http://localhost:5180`,进入登录页
|
||||
2. 在手机号输入框填写 `19928748688`
|
||||
3. 点击「获取验证码」按钮,调用 `GET /api/auth/sms-code?phone=19928748688`,后端返回 `data.code: "123456"`
|
||||
4. 在验证码输入框填写 `123456`
|
||||
5. 点击「登录」按钮,调用 `POST /api/auth/login`,后端返回 JWT
|
||||
6. Token 写入 `uni.setStorageSync('access_token', ...)`(下划线键名)
|
||||
|
||||
**为什么固定这两个值**:
|
||||
|
||||
- 后端短信通道在测试模式下总是返回 `123456`,避免真实短信下发和验证码频繁失效
|
||||
- `19928748688` 是项目的固定测试账号,已绑定测试数据,避免污染真实用户数据
|
||||
|
||||
#### 小程序页面固定端口
|
||||
|
||||
小程序 H5 固定端口 **5180**(参考 `前端端口管理规则`)
|
||||
|
||||
### 一键部署
|
||||
|
||||
```bash
|
||||
|
||||
@@ -144,6 +144,31 @@ npm run dev:mp-weixin
|
||||
|
||||
**开发工作流说明**:详见 [小程序开发工作流程设计](docs/superpowers/specs/2026-04-07-mini-program-dev-workflow-design.md)
|
||||
|
||||
#### 小程序 H5 测试登录凭证(强制默认)
|
||||
|
||||
所有小程序功能的 H5 验收必须使用以下测试账号,**禁止在测试中使用真实手机号或重复触发验证码接口**:
|
||||
|
||||
- **手机号**:`19928748688`(固定测试账号,11 位中国大陆手机号格式)
|
||||
- **验证码**:`123456`(固定 6 位数字,生产环境短信通道返回此固定值便于测试)
|
||||
|
||||
**登录流程**(参考 `mini-program/src/pages/login/index.vue` + `mini-program/src/services/auth.js`):
|
||||
|
||||
1. 浏览器访问 `http://localhost:5180`,进入登录页
|
||||
2. 在手机号输入框填写 `19928748688`
|
||||
3. 点击「获取验证码」按钮,调用 `GET /api/auth/sms-code?phone=19928748688`,后端返回 `data.code: "123456"`
|
||||
4. 在验证码输入框填写 `123456`
|
||||
5. 点击「登录」按钮,调用 `POST /api/auth/login`,后端返回 JWT
|
||||
6. Token 写入 `uni.setStorageSync('access_token', ...)`(下划线键名)
|
||||
|
||||
**为什么固定这两个值**:
|
||||
|
||||
- 后端短信通道在测试模式下总是返回 `123456`,避免真实短信下发和验证码频繁失效
|
||||
- `19928748688` 是项目的固定测试账号,已绑定测试数据,避免污染真实用户数据
|
||||
|
||||
#### 小程序页面固定端口
|
||||
|
||||
小程序 H5 固定端口 **5180**(参考 `前端端口管理规则`)
|
||||
|
||||
### 一键部署
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# 详情页澄清选项卡显示不全修复 实施计划
|
||||
|
||||
> **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:** 修复后端 `forwardSse` 首次 stream 中第一个 clarification_card 因 sessionId 为 null 不累积的 bug,使详情页澄清选项卡数量与生成页完全一致。
|
||||
|
||||
**Architecture:** 在 `ShortNovelServiceImpl.forwardSse` 的事件循环中,每次解析 SSE 事件后,从事件**顶层**取 `session_id` 同步 `currentSessionId[0]`(与前端 `ScriptView.vue:1805` 取 sessionId 的方式对齐)。这样无论 status 事件是否在 payload 内携带 session_id,只要事件顶层有 session_id,首次 stream 的 `currentSessionId[0]` 就能被正确设置,首次 stream 里的 `clarification_card` 就能被累积。
|
||||
|
||||
**Tech Stack:** Java 17 / Spring Boot 2.7.18 / fastjson2 / OkHttp / MySQL / Maven / Python 部署脚本 / H5 端到端验收
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 编译命令:`mvn clean install -DskipTests`(禁止 `mvn clean compile`)
|
||||
- 部署命令:`python deploy.py backend`(根目录脚本)
|
||||
- 部署前必须本地编译通过
|
||||
- 部署后必须通过 H5 端到端验收(Console 0 新增错误 + Network 接口正常)
|
||||
- 注释必须使用中文
|
||||
- 禁止任何形式的 mock、兜底、默认值掩盖错误
|
||||
- 只改后端 1 个方法(`forwardSse`),零前端改动,零数据库变更
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 后端修复 forwardSse 顶层取 session_id
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:304-308`(`forwardSse` 方法的事件循环内,status 处理之前)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 上游 SSE 事件 JSON 结构(事件顶层 `session_id` 字段与 `type` 同级)
|
||||
- Produces: `currentSessionId[0]` 在首次 stream 里也能被正确设置(不再是 null)
|
||||
|
||||
### 背景
|
||||
|
||||
- **根因**:上游 SSE 事件的 `session_id` 在事件**顶层**(与 `type` 同级),但后端 `forwardSse` 行 312-314 从 `payload.getString("session_id")` 取,取不到。导致首次 stream 的 `currentSessionId[0]` 保持 null(`knownSessionId=null`)。
|
||||
- **数据证据**:DB 中 3 个有 clarification 的 conversation 全部是 `clarification_question=2 + clarification_answer=3`。第一个 `clarification_question` 因 `currentSessionId[0]==null` 不累积。
|
||||
- **修复思路**:在 status 处理之前,从事件顶层取 `session_id` 同步 `currentSessionId[0]`(与前端 `ScriptView.vue:1805-1806` 逻辑对齐)。
|
||||
|
||||
### 步骤
|
||||
|
||||
- [ ] **Step 1: 在 forwardSse 事件循环中插入从顶层取 session_id 的代码**
|
||||
|
||||
打开 `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java`,定位到 `forwardSse` 方法内的事件循环。当前代码(行 304-311):
|
||||
|
||||
```java
|
||||
try {
|
||||
JSONObject event = JSON.parseObject(dataStr);
|
||||
String type = event.getString("type");
|
||||
long eventTimestamp = System.currentTimeMillis();
|
||||
log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp);
|
||||
|
||||
// 拦截 status 事件,缓存 sessionId → originalQuery 映射(供 followup 兜底)
|
||||
if ("status".equals(type)) {
|
||||
```
|
||||
|
||||
在 `log.info(...)` 之后、`// 拦截 status 事件` 注释之前,**插入以下 5 行代码**:
|
||||
|
||||
```java
|
||||
// 从事件顶层取 session_id 同步 sessionId
|
||||
// 上游事件结构与前端对齐:session_id 与 type 同级,不在 payload 内
|
||||
// 修复:首次 stream 里 status 处理从 payload 取不到 session_id,导致 currentSessionId 保持 null,
|
||||
// 第一个 clarification_card 因 sessionId 为 null 不累积,详情页少显示第一个选项卡
|
||||
String topSessionId = event.getString("session_id");
|
||||
if (topSessionId != null && !topSessionId.isEmpty()) {
|
||||
currentSessionId[0] = topSessionId;
|
||||
}
|
||||
```
|
||||
|
||||
插入后整体结构(行 304-320):
|
||||
|
||||
```java
|
||||
try {
|
||||
JSONObject event = JSON.parseObject(dataStr);
|
||||
String type = event.getString("type");
|
||||
long eventTimestamp = System.currentTimeMillis();
|
||||
log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp);
|
||||
|
||||
// 从事件顶层取 session_id 同步 sessionId
|
||||
// 上游事件结构与前端对齐:session_id 与 type 同级,不在 payload 内
|
||||
// 修复:首次 stream 里 status 处理从 payload 取不到 session_id,导致 currentSessionId 保持 null,
|
||||
// 第一个 clarification_card 因 sessionId 为 null 不累积,详情页少显示第一个选项卡
|
||||
String topSessionId = event.getString("session_id");
|
||||
if (topSessionId != null && !topSessionId.isEmpty()) {
|
||||
currentSessionId[0] = topSessionId;
|
||||
}
|
||||
|
||||
// 拦截 status 事件,缓存 sessionId → originalQuery 映射(供 followup 兜底)
|
||||
if ("status".equals(type)) {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 本地编译验证**
|
||||
|
||||
Run: `cd server && mvn clean install -DskipTests`
|
||||
Expected: `BUILD SUCCESS`,零错误。
|
||||
|
||||
- [ ] **Step 3: 部署到远程服务器**
|
||||
|
||||
Run: `python deploy.py backend`
|
||||
Expected: 部署脚本输出"部署成功",服务重启完成。
|
||||
如果部署脚本在 Windows 报 PATH 问题,手动执行:
|
||||
```bash
|
||||
scp server/target/emotion-single-1.0.0.jar root@101.200.208.45:/data/programs/emotion-museum/
|
||||
ssh root@101.200.208.45 "cd /data/programs/emotion-museum && ./restart.sh emotion-museum-single"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: H5 端到端验收**
|
||||
|
||||
1. 启动 mini-program H5:`python dev-services.py start mini-program`(端口 5180)
|
||||
2. 浏览器访问 `http://localhost:5180/#/pages/main/index?tab=script`
|
||||
3. 手动完成一次新的小说生成(包含至少 2 轮澄清)
|
||||
4. 生成过程中**记录生成页显示的选项卡数量 N**
|
||||
5. 生成完成后,进入该剧本的详情页(通过列表页点"历史"进入 ScriptLibraryView,再点击对应剧本)
|
||||
6. 验证详情页显示 **N 个澄清选项卡**(与生成页完全一致)
|
||||
7. 浏览器 Console 检查:0 新增错误(既有的 `uni.getRecorderManager` 错误除外)
|
||||
8. Network 面板:`listByConversation` 接口响应中 `clarification_question` 类型消息数量 = 生成页澄清轮数
|
||||
|
||||
- [ ] **Step 5: 提交代码**
|
||||
|
||||
```bash
|
||||
cd G:\IdeaProjects\emotion-museun
|
||||
git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java
|
||||
git commit -m "fix(server): 修复 forwardSse 首次 stream 第一个 clarification_card 不累积的 bug
|
||||
|
||||
上游 SSE 事件的 session_id 在事件顶层(与 type 同级),后端原从 payload.session_id 取导致首次 stream 取不到,
|
||||
currentSessionId 保持 null,第一个 clarification_card 不累积。
|
||||
改为从事件顶层取 session_id 同步 currentSessionId(与前端 ScriptView.vue:1805 逻辑对齐)。
|
||||
修复后新剧本详情页澄清选项卡数量与生成页完全一致。"
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
|
||||
- ✅ 本地 `mvn clean install` 编译通过
|
||||
- ✅ 部署到远程服务器成功
|
||||
- ✅ 新剧本详情页澄清选项卡数量 = 生成页澄清选项卡数量
|
||||
- ✅ Console 0 新增错误
|
||||
- ✅ 老剧本(3 个已有 conversation)保持现状,仍显示 2 个 card(数据已永久丢失,符合预期)
|
||||
@@ -0,0 +1,605 @@
|
||||
# 小说消息操作按钮恢复实施计划
|
||||
|
||||
> **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:** 恢复小程序小说生成页和详情页丢失的"复制、播放、折叠/展开"功能,每条 novel 消息下方添加 3 个操作按钮。
|
||||
|
||||
**Architecture:** 在 ScriptView.vue 和 ScriptDetailView.vue 中为 `kind === 'novel'` 的消息添加按钮组 UI(折叠/展开、复制、播放)。ScriptView 复用已有函数,ScriptDetailView 新增相同逻辑。按钮组样式统一,参考 MessageCard 的 result-actions 样式。
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API / UniApp / Pinia / uni.setClipboardData / useTtsPlayer composable / H5 端到端验收
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 所有注释必须使用中文
|
||||
- 样式单位必须使用 rpx(小程序移动端)
|
||||
- 禁止任何形式的 mock、兜底、默认值掩盖错误
|
||||
- 每个按钮必须有真实实现,不能有任何占位或 mock 功能
|
||||
- 修改后必须通过 H5 端到端验收(Console 0 新增错误)
|
||||
- 部署使用 `python deploy.py backend`(后端)或热加载(前端)
|
||||
- 埋点必须完整触发:`script_message_collapse_toggle`、`script_message_copy_click`、`script_message_tts_click`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: ScriptView 恢复按钮组 UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue:180-187`(novel 消息渲染部分)
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue:3500+`(样式部分)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 已有函数 `getMessageDisplayContent(msg)`、`toggleMessageCollapse(msg)`、`copyMessageContent(msg)`、`playMessageTts(msg)`、`isMessageCollapsed(msg)`
|
||||
- Produces: novel 消息下方显示 3 个按钮(折叠/展开、复制、播放),功能正常
|
||||
|
||||
### 背景
|
||||
|
||||
ScriptView.vue 中所有相关函数已存在(行 399-447),但模板中按钮组 UI 在 commit 2c778fa 中被删除。本任务恢复按钮组 UI。
|
||||
|
||||
### 步骤
|
||||
|
||||
- [ ] **Step 1: 定位 novel 消息渲染部分**
|
||||
|
||||
打开 `mini-program/src/pages/main/ScriptView.vue`,找到 `kind === 'novel'` 的消息渲染部分(约行 180-187):
|
||||
|
||||
```vue
|
||||
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">
|
||||
<Markdown :content="msg.content" />
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 Markdown 渲染,使用折叠内容**
|
||||
|
||||
将 `<Markdown :content="msg.content" />` 改为:
|
||||
|
||||
```vue
|
||||
<Markdown :content="getMessageDisplayContent(msg)" />
|
||||
```
|
||||
|
||||
这样折叠时只显示前 200 字符。
|
||||
|
||||
- [ ] **Step 3: 添加折叠/展开按钮**
|
||||
|
||||
在 `<Markdown>` 下方添加折叠/展开按钮:
|
||||
|
||||
```vue
|
||||
<view v-if="msg.content && msg.content.length > 200" class="collapse-toggle-row" @click="toggleMessageCollapse(msg)">
|
||||
<text class="collapse-toggle-text">{{ isMessageCollapsed(msg) ? '展开全文' : '收起全文' }}</text>
|
||||
<view class="collapse-chevron" :class="{ down: isMessageCollapsed(msg) }">
|
||||
<view></view>
|
||||
<view></view>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 添加操作按钮组**
|
||||
|
||||
在折叠/展开按钮下方添加操作按钮组(复制、播放):
|
||||
|
||||
```vue
|
||||
<view class="message-actions">
|
||||
<button class="action-btn" @click="copyMessageContent(msg)">
|
||||
<text>📋 复制</text>
|
||||
</button>
|
||||
<button class="action-btn primary" @click="playMessageTts(msg)">
|
||||
<text class="action-icon">{{ ttsPlayer.playing.value ? 'Ⅱ' : '▶' }}</text>
|
||||
<text>{{ ttsPlayer.playing.value ? '暂停' : '播放' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
```
|
||||
|
||||
注意:只有 2 个按钮(复制、播放),折叠/展开按钮已在上方独立显示。
|
||||
|
||||
- [ ] **Step 5: 添加按钮组样式**
|
||||
|
||||
在 `<style scoped>` 部分(约行 3500+)添加以下样式:
|
||||
|
||||
```css
|
||||
/* 折叠/展开按钮行 */
|
||||
.collapse-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
padding: 16rpx 0;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(88, 28, 135, 0.12);
|
||||
border: 1rpx solid rgba(192, 132, 252, 0.28);
|
||||
color: rgba(246, 230, 255, 0.96);
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.collapse-toggle-row:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.collapse-toggle-text {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 折叠箭头图标 */
|
||||
.collapse-chevron {
|
||||
position: relative;
|
||||
width: 20rpx;
|
||||
height: 16rpx;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.collapse-chevron.down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.collapse-chevron view {
|
||||
position: absolute;
|
||||
top: 7rpx;
|
||||
width: 12rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #fff3b0, #ffd86b);
|
||||
box-shadow: 0 0 12rpx rgba(255, 216, 107, 0.5);
|
||||
}
|
||||
|
||||
.collapse-chevron view:first-child {
|
||||
left: 0;
|
||||
transform: rotate(-38deg);
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
.collapse-chevron view:last-child {
|
||||
right: 0;
|
||||
transform: rotate(38deg);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
/* 操作按钮组 */
|
||||
.message-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 72rpx;
|
||||
min-height: 72rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #e8ccff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
box-sizing: border-box;
|
||||
background: rgba(88, 28, 135, 0.18);
|
||||
border: 1rpx solid rgba(192, 132, 252, 0.35);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #8c44f2, #5f1db8);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
margin-right: 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.action-btn::after {
|
||||
border: 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: H5 验收**
|
||||
|
||||
启动 H5 并验收:
|
||||
|
||||
```bash
|
||||
python dev-services.py start mini-program
|
||||
```
|
||||
|
||||
浏览器访问 `http://localhost:5180/#/pages/main/index?tab=script`,进入一个已有小说的生成页:
|
||||
|
||||
验收项:
|
||||
- ✅ 小说正文下方有折叠/展开按钮(内容超过 200 字时显示)
|
||||
- ✅ 点击"展开全文":内容完整显示,按钮变为"收起全文"
|
||||
- ✅ 点击"收起全文":内容截断显示前 200 字,按钮变为"展开全文"
|
||||
- ✅ 小说正文下方有 2 个操作按钮:复制、播放
|
||||
- ✅ 点击"复制":Toast 提示"已复制",剪贴板中有内容
|
||||
- ✅ 点击"播放":TTS 开始播放,按钮变为"暂停"
|
||||
- ✅ 浏览器 Console 检查:0 新增错误
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "feat(mini-program): ScriptView 恢复小说消息操作按钮组
|
||||
|
||||
- 为 kind === 'novel' 的消息添加折叠/展开、复制、播放按钮
|
||||
- 复用已有函数:getMessageDisplayContent、toggleMessageCollapse、copyMessageContent、playMessageTts
|
||||
- 添加 .collapse-toggle-row、.message-actions、.action-btn 样式
|
||||
- H5 验收通过,Console 0 新增错误"
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
|
||||
- ✅ 每条 novel 消息下方有折叠/展开按钮(内容超过 200 字时显示)
|
||||
- ✅ 折叠/展开功能正常:折叠时显示前 200 字,展开时显示完整内容
|
||||
- ✅ 每条 novel 消息下方有 2 个操作按钮:复制、播放
|
||||
- ✅ 复制功能正常:点击后 Toast 提示"已复制"
|
||||
- ✅ 播放功能正常:点击后 TTS 开始播放,按钮变为"暂停"
|
||||
- ✅ Console 无新增错误
|
||||
|
||||
---
|
||||
|
||||
## Task 2: ScriptDetailView 新增函数和按钮组 UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptDetailView.vue:96-311`(script 部分)
|
||||
- Modify: `mini-program/src/pages/main/ScriptDetailView.vue:74-76`(novel 消息渲染部分)
|
||||
- Modify: `mini-program/src/pages/main/ScriptDetailView.vue:313+`(样式部分)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ttsPlayer` composable(已有)、`script` ref(已有)
|
||||
- Produces: novel 消息下方显示 3 个按钮(折叠/展开、复制、播放),功能正常
|
||||
|
||||
### 背景
|
||||
|
||||
ScriptDetailView.vue 中完全没有相关函数和按钮组 UI。本任务新增所有函数 + 模板按钮组 + 样式。
|
||||
|
||||
### 步骤
|
||||
|
||||
- [ ] **Step 1: 新增 collapsedMessageIds ref**
|
||||
|
||||
打开 `mini-program/src/pages/main/ScriptDetailView.vue`,在 `<script setup>` 部分(约行 96-110)添加:
|
||||
|
||||
```javascript
|
||||
const collapsedMessageIds = ref({})
|
||||
```
|
||||
|
||||
放在 `const resultMessages = ref([])` 下方。
|
||||
|
||||
- [ ] **Step 2: 新增 isMessageCollapsed 函数**
|
||||
|
||||
在 `collapsedMessageIds` 下方添加:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 判断消息是否折叠
|
||||
*/
|
||||
const isMessageCollapsed = (message) => {
|
||||
return Boolean(collapsedMessageIds.value[message.id])
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 新增 getMessageDisplayContent 函数**
|
||||
|
||||
在 `isMessageCollapsed` 下方添加:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 获取消息显示内容(折叠时截断)
|
||||
*/
|
||||
const getMessageDisplayContent = (message) => {
|
||||
const content = String(message?.content || '')
|
||||
if (!isMessageCollapsed(message)) return content
|
||||
if (content.length <= 200) return content
|
||||
return `${content.slice(0, 200)}...`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 新增 toggleMessageCollapse 函数**
|
||||
|
||||
在 `getMessageDisplayContent` 下方添加:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 切换消息折叠状态
|
||||
*/
|
||||
const toggleMessageCollapse = (message) => {
|
||||
collapsedMessageIds.value = {
|
||||
...collapsedMessageIds.value,
|
||||
[message.id]: !isMessageCollapsed(message)
|
||||
}
|
||||
analytics.track('script_message_collapse_toggle', {
|
||||
message_id: message?.id || '',
|
||||
collapsed: collapsedMessageIds.value[message.id]
|
||||
}, { eventType: 'script', pagePath })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 新增 copyMessageContent 函数**
|
||||
|
||||
在 `toggleMessageCollapse` 下方添加:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 复制消息内容到剪贴板
|
||||
*/
|
||||
const copyMessageContent = (message) => {
|
||||
const content = String(message?.content || '')
|
||||
if (!content.trim()) {
|
||||
uni.showToast({ title: '暂无可复制内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: content,
|
||||
success: () => {
|
||||
uni.showToast({ title: '已复制', icon: 'success' })
|
||||
}
|
||||
})
|
||||
analytics.track('script_message_copy_click', {
|
||||
message_id: message?.id || '',
|
||||
content_length: content.length
|
||||
}, { eventType: 'script', pagePath })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 新增 playMessageTts 函数**
|
||||
|
||||
在 `copyMessageContent` 下方添加:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 播放消息 TTS 音频
|
||||
*/
|
||||
const playMessageTts = (message) => {
|
||||
const scriptId = script.value?.id || ''
|
||||
ttsPlayer.playSource(scriptId)
|
||||
analytics.track('script_message_tts_click', {
|
||||
message_id: message?.id || '',
|
||||
script_id: scriptId
|
||||
}, { eventType: 'script', pagePath })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 定位 novel 消息渲染部分**
|
||||
|
||||
在模板中找到 `kind === 'novel'` 的消息渲染部分(约行 74-76):
|
||||
|
||||
```vue
|
||||
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">
|
||||
<Markdown :content="msg.content" />
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 修改 Markdown 渲染,使用折叠内容**
|
||||
|
||||
将 `<Markdown :content="msg.content" />` 改为:
|
||||
|
||||
```vue
|
||||
<Markdown :content="getMessageDisplayContent(msg)" />
|
||||
```
|
||||
|
||||
- [ ] **Step 9: 添加折叠/展开按钮**
|
||||
|
||||
在 `<Markdown>` 下方添加折叠/展开按钮:
|
||||
|
||||
```vue
|
||||
<view v-if="msg.content && msg.content.length > 200" class="collapse-toggle-row" @click="toggleMessageCollapse(msg)">
|
||||
<text class="collapse-toggle-text">{{ isMessageCollapsed(msg) ? '展开全文' : '收起全文' }}</text>
|
||||
<view class="collapse-chevron" :class="{ down: isMessageCollapsed(msg) }">
|
||||
<view></view>
|
||||
<view></view>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 10: 添加操作按钮组**
|
||||
|
||||
在折叠/展开按钮下方添加操作按钮组(复制、播放):
|
||||
|
||||
```vue
|
||||
<view class="message-actions">
|
||||
<button class="action-btn" @click="copyMessageContent(msg)">
|
||||
<text>📋 复制</text>
|
||||
</button>
|
||||
<button class="action-btn primary" @click="playMessageTts(msg)">
|
||||
<text class="action-icon">{{ ttsPlayer.playing.value ? 'Ⅱ' : '▶' }}</text>
|
||||
<text>{{ ttsPlayer.playing.value ? '暂停' : '播放' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 11: 添加按钮组样式**
|
||||
|
||||
在 `<style scoped>` 部分(约行 313+)添加以下样式(与 ScriptView 完全一致):
|
||||
|
||||
```css
|
||||
/* 折叠/展开按钮行 */
|
||||
.collapse-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
padding: 16rpx 0;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(88, 28, 135, 0.12);
|
||||
border: 1rpx solid rgba(192, 132, 252, 0.28);
|
||||
color: rgba(246, 230, 255, 0.96);
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.collapse-toggle-row:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.collapse-toggle-text {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 折叠箭头图标 */
|
||||
.collapse-chevron {
|
||||
position: relative;
|
||||
width: 20rpx;
|
||||
height: 16rpx;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.collapse-chevron.down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.collapse-chevron view {
|
||||
position: absolute;
|
||||
top: 7rpx;
|
||||
width: 12rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #fff3b0, #ffd86b);
|
||||
box-shadow: 0 0 12rpx rgba(255, 216, 107, 0.5);
|
||||
}
|
||||
|
||||
.collapse-chevron view:first-child {
|
||||
left: 0;
|
||||
transform: rotate(-38deg);
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
.collapse-chevron view:last-child {
|
||||
right: 0;
|
||||
transform: rotate(38deg);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
/* 操作按钮组 */
|
||||
.message-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 72rpx;
|
||||
min-height: 72rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #e8ccff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
box-sizing: border-box;
|
||||
background: rgba(88, 28, 135, 0.18);
|
||||
border: 1rpx solid rgba(192, 132, 252, 0.35);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #8c44f2, #5f1db8);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
margin-right: 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.action-btn::after {
|
||||
border: 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 12: H5 验收**
|
||||
|
||||
H5 服务应已在运行(端口 5180),浏览器访问详情页:
|
||||
|
||||
从列表页点击一个已有小说的剧本,进入详情页。
|
||||
|
||||
验收项:
|
||||
- ✅ 小说正文下方有折叠/展开按钮(内容超过 200 字时显示)
|
||||
- ✅ 点击"展开全文":内容完整显示,按钮变为"收起全文"
|
||||
- ✅ 点击"收起全文":内容截断显示前 200 字,按钮变为"展开全文"
|
||||
- ✅ 小说正文下方有 2 个操作按钮:复制、播放
|
||||
- ✅ 点击"复制":Toast 提示"已复制",剪贴板中有内容
|
||||
- ✅ 点击"播放":TTS 开始播放,按钮变为"暂停"
|
||||
- ✅ 浏览器 Console 检查:0 新增错误
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptDetailView.vue
|
||||
git commit -m "feat(mini-program): ScriptDetailView 新增小说消息操作按钮组
|
||||
|
||||
- 新增 collapsedMessageIds ref + 5 个函数(isMessageCollapsed、getMessageDisplayContent、toggleMessageCollapse、copyMessageContent、playMessageTts)
|
||||
- 为 kind === 'novel' 的消息添加折叠/展开、复制、播放按钮
|
||||
- 添加 .collapse-toggle-row、.message-actions、.action-btn 样式
|
||||
- H5 验收通过,Console 0 新增错误"
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
|
||||
- ✅ 每条 novel 消息下方有折叠/展开按钮(内容超过 200 字时显示)
|
||||
- ✅ 折叠/展开功能正常:折叠时显示前 200 字,展开时显示完整内容
|
||||
- ✅ 每条 novel 消息下方有 2 个操作按钮:复制、播放
|
||||
- ✅ 复制功能正常:点击后 Toast 提示"已复制"
|
||||
- ✅ 播放功能正常:点击后 TTS 开始播放,按钮变为"暂停"
|
||||
- ✅ Console 无新增错误
|
||||
- ✅ 埋点正常触发:`script_message_collapse_toggle`、`script_message_copy_click`、`script_message_tts_click`
|
||||
|
||||
---
|
||||
|
||||
## 整体验收
|
||||
|
||||
两个 Task 完成后,进行整体验收:
|
||||
|
||||
1. **生成页验收**:
|
||||
- 访问 `http://localhost:5180/#/pages/main/index?tab=script`
|
||||
- 进入一个已有小说的生成页
|
||||
- 验证折叠/展开、复制、播放功能
|
||||
- Console 0 新增错误
|
||||
|
||||
2. **详情页验收**:
|
||||
- 从列表页点击一个已有小说的剧本,进入详情页
|
||||
- 验证折叠/展开、复制、播放功能
|
||||
- Console 0 新增错误
|
||||
|
||||
3. **样式一致性**:
|
||||
- 两个页面的按钮组样式完全一致
|
||||
- 按钮布局、颜色、圆角、间距一致
|
||||
|
||||
4. **埋点验证**:
|
||||
- 浏览器 Console 中查看埋点日志
|
||||
- 确认 `script_message_collapse_toggle`、`script_message_copy_click`、`script_message_tts_click` 正常触发
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
1. **TTS 播放的是整个小说**:`ttsPlayer.playSource(scriptId)` 播放的是整个小说的音频,不是单条消息的音频。这是历史设计,保持一致。
|
||||
|
||||
2. **折叠阈值**:折叠时显示前 200 字符。这个阈值可以根据实际需求调整。
|
||||
|
||||
3. **样式一致性**:两个页面的按钮组样式必须保持一致,直接复制 ScriptView 的样式到 ScriptDetailView。
|
||||
|
||||
4. **性能考虑**:`collapsedMessageIds` 是响应式对象,每次切换折叠状态都会触发重新渲染。对于大量消息的场景,可能需要优化(当前场景下消息数量有限,不构成问题)。
|
||||
|
||||
5. **热加载**:前端修改后利用热加载自动生效,不需要重启。如果热加载不生效,使用 `python dev-services.py restart mini-program`。
|
||||
|
||||
---
|
||||
|
||||
## 提交历史
|
||||
|
||||
完成所有 Task 后,提交历史应为:
|
||||
|
||||
```
|
||||
<hash> feat(mini-program): ScriptDetailView 新增小说消息操作按钮组
|
||||
<hash> feat(mini-program): ScriptView 恢复小说消息操作按钮组
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-26
|
||||
purpose: 修复 ScriptLibraryView 章节数展示为 0 的问题
|
||||
---
|
||||
|
||||
# 章节数展示修复设计
|
||||
|
||||
## 问题
|
||||
|
||||
ScriptLibraryView 列表页中所有剧本的章节数均显示为"0章"。
|
||||
|
||||
**根因**:前端 `getChapterCount` 函数读取 `script.chapterCount || script.chapters || 0`,但后端未返回 `chapterCount` 或 `chapters` 字段;老剧本的 `plotJson` 仅包含 `title` 和 `fullContent`,不存在 `stages.outline.beats` 结构。
|
||||
|
||||
## 目标
|
||||
|
||||
- 新剧本(Task 1-5 部署后生成):从 `plotJson.stages.outline.beats.length` 获取真实章节数
|
||||
- 老剧本:根据 `script.length` 字段(short/medium/long)估算合理的章节数
|
||||
- 短篇显示"1章"(符合业务预期)
|
||||
|
||||
## 方案
|
||||
|
||||
修改 `mini-program/src/pages/main/ScriptLibraryView.vue` 中的 `getChapterCount` 函数:
|
||||
|
||||
```javascript
|
||||
const getChapterCount = (script) => {
|
||||
// 优先从 beats 数组长度获取(新剧本结构)
|
||||
const beats = script?.plotJson?.stages?.outline?.beats
|
||||
if (Array.isArray(beats) && beats.length > 0) return beats.length
|
||||
|
||||
// Fallback:根据 length 字段估算
|
||||
const length = script?.length
|
||||
if (length === 'short') return 1
|
||||
if (length === 'medium') return 2
|
||||
if (length === 'long') return 4
|
||||
return 1
|
||||
}
|
||||
```
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 仅修改 `ScriptLibraryView.vue` 的 `getChapterCount` 函数
|
||||
- 不涉及后端改动
|
||||
- 不涉及其他前端文件
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 短篇剧本列表卡片显示"1章"
|
||||
- 中篇剧本列表卡片显示"2章"
|
||||
- 长篇剧本列表卡片显示"4章"
|
||||
- 新剧本(有 beats 数据)显示真实 beats 数量
|
||||
- Console 无新增错误
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-26
|
||||
purpose: 修复详情页只显示 2 个澄清选项卡(应为 N 个)的后端累积丢失 bug
|
||||
---
|
||||
|
||||
# 详情页澄清选项卡显示不全修复设计
|
||||
|
||||
## 问题
|
||||
|
||||
生成小说时,生成页显示 N 个澄清选项卡(clarification cards),但进入详情页后只显示前 2 个。必须保证详情页选项卡数量与生成页完全一致。
|
||||
|
||||
## 根因分析
|
||||
|
||||
通过数据库查询(t_message 按 conversation_id 分组统计)确认:3 个有 clarification 的 conversation 全部是 **clarification_question=2 + clarification_answer=3**。即用户回答了 3 次(3 个 answer),但后端只保存了 2 个 question。**丢失的是第一个 clarification_card(Q1)**。
|
||||
|
||||
DB 中 conversation `339751895901937665` 的 messageOrder 分布:
|
||||
|
||||
| order | type | content |
|
||||
|---|---|---|
|
||||
| 1 | clarification_answer | 同事/上司 |
|
||||
| 2 | clarification_question | 是什么具体事件让你决定不再讨好? |
|
||||
| 3 | clarification_answer | 功劳被抢 |
|
||||
| 4 | clarification_question | 你希望主角如何反击? |
|
||||
| 5 | clarification_answer | 暗中布局 |
|
||||
| 6 | outline | ... |
|
||||
|
||||
**根因链**:
|
||||
|
||||
1. **上游 SSE 事件结构**:`session_id` 字段在事件**顶层**(与 `type` 同级),不在 `payload` 内。前端从顶层取(`ScriptView.vue:1800` `const { type, session_id, payload } = event`)且工作正常。
|
||||
2. **后端 status 事件处理**:`ShortNovelServiceImpl.forwardSse` 行 312-314 从 `payload.getString("session_id")` 取,**取不到**。
|
||||
3. **首次 stream 的 currentSessionId[0] 初始为 null**:`forwardSse` 行 253 `final String[] currentSessionId = {knownSessionId}`,首次 stream 调用时 `knownSessionId=null`。
|
||||
4. **首次 stream 的 Q1 不累积**:首次 stream 里第一个 `clarification_card` 事件到达时 `currentSessionId[0]` 仍为 null,行 346 的 `if (currentSessionId[0] != null)` 为 false,Q1 不被 `accumulateStage` 累积。
|
||||
5. **followup 流正常**:followup 调用 `forwardSse` 时 `knownSessionId = request.getSessionId()`(前端传的,非 null),后续 Q2/Q3 都正确累积。
|
||||
6. **drainStages 取到不完整的 stagesHistory**:novel_done 时 drainStages 取到 `[A1, Q2, A2, Q3, A3, outline]`(缺 Q1),saveNovelResult 创建 2 question + 3 answer。
|
||||
7. **详情页只能渲染 2 个 card**:从 DB 读到的 clarification_question 只有 2 条。
|
||||
|
||||
## 修复方案
|
||||
|
||||
**修改文件**:`server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java`
|
||||
|
||||
**修改点**:`forwardSse` 方法的事件循环(行 305 附近),在解析 event 后、status 处理之前,**从事件顶层取 `session_id` 同步 `currentSessionId[0]`**(与前端 `ScriptView.vue:1805-1806` 取 sessionId 的方式对齐)。
|
||||
|
||||
**新增代码**(放在 `String type = event.getString("type");` 之后、status 事件 `if ("status".equals(type))` 之前):
|
||||
|
||||
```java
|
||||
// 从事件顶层取 session_id 同步 sessionId
|
||||
// 上游事件结构与前端对齐:session_id 与 type 同级,不在 payload 内
|
||||
// 修复:首次 stream 里 status 处理从 payload 取不到 session_id,导致 currentSessionId 保持 null,
|
||||
// 第一个 clarification_card 因 sessionId 为 null 不累积,详情页少显示第一个选项卡
|
||||
String topSessionId = event.getString("session_id");
|
||||
if (topSessionId != null && !topSessionId.isEmpty()) {
|
||||
currentSessionId[0] = topSessionId;
|
||||
}
|
||||
```
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 仅 `forwardSse` 方法新增 5 行代码
|
||||
- 零现有逻辑改动(status 处理保留原样作为次选,顶层取 sessionId 优先覆盖)
|
||||
- 零数据库变更
|
||||
- 零前端改动
|
||||
|
||||
## 预期效果
|
||||
|
||||
- **新剧本**:首次 stream 的 Q1 不再丢失,详情页显示全部 N 个选项卡(与生成页完全一致)
|
||||
- **老剧本**(3 个已有 conversation):保持现状,仍显示 2 个 card。DB 中 stagesHistory 缺失的 Q1 已永久丢失,无法真实恢复。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 在 H5 手动完成一次新的小说生成(包含至少 2 轮澄清)
|
||||
2. 进入该剧本的详情页
|
||||
3. 验证选项卡数量与生成页完全一致(生成 N 个,详情显示 N 个)
|
||||
4. Console 无新增错误
|
||||
5. 本地 `mvn clean install` 编译通过
|
||||
@@ -0,0 +1,376 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-28
|
||||
purpose: 恢复小程序小说生成页和详情页丢失的"复制、播放、折叠/展开"功能
|
||||
---
|
||||
|
||||
# 小说消息操作按钮恢复设计
|
||||
|
||||
## 问题背景
|
||||
|
||||
小程序的小说生成页(ScriptView.vue)和详情页(ScriptDetailView.vue)在之前的重构过程中丢失了"复制、播放、折叠/展开"等操作按钮。用户要求完整恢复这些功能,且必须有真实实现,不能有任何 mock 或占位。
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 重构历史追溯
|
||||
|
||||
通过 git 历史分析,发现功能丢失的关键节点:
|
||||
|
||||
1. **commit 9b3006d**(2026-06-27):添加消息卡片功能方法
|
||||
- 新增 `copyMessageContent(message)`:复制消息内容
|
||||
- 新增 `playMessageTts(message)`:播放 TTS 音频
|
||||
- 新增 `toggleMessageCollapse(message)`:折叠/展开消息
|
||||
|
||||
2. **commit 0db434c**(2026-06-27):在 result-chat-list 中添加功能按钮组
|
||||
- 在每条 assistant 消息下方添加 5 个按钮:复制、换个方向、不像我、继续生成、播放
|
||||
- 使用 `.message-actions` 样式类
|
||||
|
||||
3. **commit c2ed051**:ScriptView 复用 MessageCard 组件
|
||||
- 将消息卡片提取为独立的 `MessageCard.vue` 组件
|
||||
- MessageCard 包含完整功能:折叠、复制、换个方向、不像我、续写、改写、查看历史、删除、播放
|
||||
|
||||
4. **commit 2c778fa**(2026-07-01):**ScriptView 删除 read 模式 + 兼容旧模式,统一对话流入口**
|
||||
- 删除了 102 行代码,包括所有使用 MessageCard 的调用
|
||||
- 模板中改为裸的 `<view class="chat-bubble">` 渲染消息
|
||||
- **MessageCard 导入保留但不再使用**(行 241)
|
||||
- **JS 函数保留**:`copyMessageContent`(行 422-438)、`playMessageTts`(行 440-447)、`toggleMessageCollapse`(行 409-420)、`getMessageDisplayContent`(行 402-407)、`isMessageCollapsed`(行 399)
|
||||
- **`collapsedMessageIds` ref 保留**(行 287)
|
||||
- **按钮组 UI 丢失**
|
||||
|
||||
5. **ScriptDetailView.vue**:从未使用过 MessageCard,也从未有过这些功能
|
||||
|
||||
### 当前状态
|
||||
|
||||
**ScriptView.vue(生成页)**:
|
||||
- ✅ 所有相关函数已存在(copyMessageContent、playMessageTts、toggleMessageCollapse 等)
|
||||
- ✅ `collapsedMessageIds` ref 已存在
|
||||
- ❌ 模板中按钮组 UI 已丢失
|
||||
- ❌ 相关样式可能已删除或无用
|
||||
|
||||
**ScriptDetailView.vue(详情页)**:
|
||||
- ❌ 完全没有相关函数
|
||||
- ❌ 完全没有 `collapsedMessageIds` ref
|
||||
- ❌ 完全没有按钮组 UI
|
||||
- ✅ 底部有全局播放按钮(`ttsPlayer.playSource`)
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 核心设计决策
|
||||
|
||||
| 维度 | 决策 |
|
||||
|------|------|
|
||||
| **功能范围** | 完整恢复所有按钮功能,但布局采用极简设计 |
|
||||
| **页面范围** | 生成页(ScriptView)+ 详情页(ScriptDetailView) |
|
||||
| **应用对象** | 仅 `kind === 'novel'` 的小说正文消息 |
|
||||
| **按钮布局** | 每条 novel 消息下方:折叠/展开 + 复制 + 播放(3 个按钮)|
|
||||
| **修订类按钮** | 放在底部操作区(已有),不在每条消息下重复 |
|
||||
|
||||
### 设计原则
|
||||
|
||||
1. **极简设计**:每条 novel 消息下方只放 3 个核心功能按钮(折叠/展开、复制、播放)
|
||||
2. **功能就近**:内容操作类按钮(复制、播放、折叠)贴近消息,用户操作直观
|
||||
3. **修订集中**:方向修订类按钮(换个方向、不像我、继续生成)集中在底部操作区,避免重复
|
||||
4. **样式统一**:两个页面使用相同的按钮组样式,保持视觉一致性
|
||||
5. **代码复用**:ScriptView 已有函数直接复用,ScriptDetailView 新增相同逻辑
|
||||
|
||||
### 功能实现细节
|
||||
|
||||
#### 1. 折叠/展开
|
||||
|
||||
**状态管理**:
|
||||
```javascript
|
||||
const collapsedMessageIds = ref({})
|
||||
```
|
||||
|
||||
**核心函数**:
|
||||
```javascript
|
||||
// 判断消息是否折叠
|
||||
const isMessageCollapsed = (message) => {
|
||||
return Boolean(collapsedMessageIds.value[message.id])
|
||||
}
|
||||
|
||||
// 获取消息显示内容(折叠时截断)
|
||||
const getMessageDisplayContent = (message) => {
|
||||
const content = String(message?.content || '')
|
||||
if (!isMessageCollapsed(message)) return content
|
||||
if (content.length <= 200) return content
|
||||
return `${content.slice(0, 200)}...`
|
||||
}
|
||||
|
||||
// 切换折叠状态
|
||||
const toggleMessageCollapse = (message) => {
|
||||
collapsedMessageIds.value = {
|
||||
...collapsedMessageIds.value,
|
||||
[message.id]: !isMessageCollapsed(message)
|
||||
}
|
||||
analytics.track('script_message_collapse_toggle', {
|
||||
message_id: message?.id || '',
|
||||
collapsed: collapsedMessageIds.value[message.id]
|
||||
}, { eventType: 'script', pagePath })
|
||||
}
|
||||
```
|
||||
|
||||
**UI 交互**:
|
||||
- 折叠时:显示前 200 字符 + "展开全文"按钮
|
||||
- 展开时:显示完整内容 + "收起全文"按钮
|
||||
- 折叠/展开按钮放在消息内容下方,按钮组上方
|
||||
|
||||
#### 2. 复制
|
||||
|
||||
**核心函数**:
|
||||
```javascript
|
||||
const copyMessageContent = (message) => {
|
||||
const content = String(message?.content || '')
|
||||
if (!content.trim()) {
|
||||
uni.showToast({ title: '暂无可复制内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: content,
|
||||
success: () => {
|
||||
uni.showToast({ title: '已复制', icon: 'success' })
|
||||
}
|
||||
})
|
||||
analytics.track('script_message_copy_click', {
|
||||
message_id: message?.id || '',
|
||||
content_length: content.length
|
||||
}, { eventType: 'script', pagePath })
|
||||
}
|
||||
```
|
||||
|
||||
**UI 交互**:
|
||||
- 点击"复制"按钮,调用 `uni.setClipboardData`
|
||||
- 成功复制后显示 Toast 提示"已复制"
|
||||
- 内容为空时显示"暂无可复制内容"
|
||||
|
||||
#### 3. 播放
|
||||
|
||||
**核心函数**:
|
||||
```javascript
|
||||
const playMessageTts = (message) => {
|
||||
const scriptId = currentResult.value?.id || '' // ScriptView
|
||||
// 或 script.value?.id || '' // ScriptDetailView
|
||||
ttsPlayer.playSource(scriptId)
|
||||
analytics.track('script_message_tts_click', {
|
||||
message_id: message?.id || '',
|
||||
script_id: scriptId
|
||||
}, { eventType: 'script', pagePath })
|
||||
}
|
||||
```
|
||||
|
||||
**UI 交互**:
|
||||
- 播放图标动态显示:`▶`(未播放)/ `Ⅱ`(播放中)
|
||||
- 播放文本动态显示:`播放`(未播放)/ `暂停`(播放中)
|
||||
- 点击按钮调用 `ttsPlayer.playSource(scriptId)` 播放整个小说的 TTS 音频
|
||||
|
||||
### UI 设计
|
||||
|
||||
**每条 novel 消息下方按钮组**:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [小说正文内容] │
|
||||
│ (折叠时显示前 200 字...) │
|
||||
│ │
|
||||
│ [▼ 展开全文] ← 折叠/展开按钮 │
|
||||
│ │
|
||||
│ ┌──────────┬──────────┬──────────┐ │
|
||||
│ │ 📋 复制 │ ▶ 播放 │ ⚙ 更多 │ │ ← 操作按钮组
|
||||
│ └──────────┴──────────┴──────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**实际实现(3 个按钮)**:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [小说正文内容] │
|
||||
│ │
|
||||
│ [▼ 展开全文] │
|
||||
│ │
|
||||
│ ┌──────────┬──────────┬──────────┐ │
|
||||
│ │ 复制 │ ▶ 播放 │ 收起 │ │
|
||||
│ └──────────┴──────────┴──────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**按钮样式**:
|
||||
- 参考 MessageCard 的 `result-actions` 样式(grid 布局)
|
||||
- 3 个按钮横向排列,等宽分布
|
||||
- 播放按钮为 `primary` 样式(紫色渐变,突出显示)
|
||||
- 折叠/展开按钮为独立行,放在按钮组上方
|
||||
- 按钮高度:72rpx
|
||||
- 按钮圆角:28rpx
|
||||
- 按钮背景:`rgba(88, 28, 135, 0.18)`
|
||||
- 按钮边框:`1rpx solid rgba(192, 132, 252, 0.35)`
|
||||
|
||||
### 页面差异化
|
||||
|
||||
| 页面 | 现有修订类按钮 | 需新增功能 |
|
||||
|------|--------------|-----------|
|
||||
| **生成页(ScriptView)** | 底部输入区已有"换个方向"、"不像我"等按钮 | 每条 novel 消息下加 3 个按钮(复用已有函数) |
|
||||
| **详情页(ScriptDetailView)** | 底部已有"继续生成"按钮 | 每条 novel 消息下加 3 个按钮 + 新增所有函数 |
|
||||
|
||||
### 应用对象
|
||||
|
||||
**只在 `kind === 'novel'` 的 assistant 消息上显示按钮组**:
|
||||
- ✅ `kind === 'novel'`:小说正文,需要复制、播放、折叠/展开
|
||||
- ❌ `kind === 'card'`:澄清卡片,有特殊交互(提交按钮),不需要这些按钮
|
||||
- ❌ `kind === 'outline'`:大纲,结构化数据,不需要这些按钮
|
||||
- ❌ `kind === 'text'`:用户消息,不需要这些按钮
|
||||
|
||||
## 数据流与代码复用
|
||||
|
||||
### ScriptView(生成页)
|
||||
|
||||
**已有资源**:
|
||||
- ✅ `collapsedMessageIds` ref(行 287)
|
||||
- ✅ `isMessageCollapsed(message)` 函数(行 399)
|
||||
- ✅ `getMessageDisplayContent(message)` 函数(行 402-407)
|
||||
- ✅ `toggleMessageCollapse(message)` 函数(行 409-420)
|
||||
- ✅ `copyMessageContent(message)` 函数(行 422-438)
|
||||
- ✅ `playMessageTts(message)` 函数(行 440-447)
|
||||
- ✅ `ttsPlayer` composable(行 320)
|
||||
- ✅ `ttsActionText` computed(行 986-990)
|
||||
- ✅ `ttsActionIcon` computed(行 992-994)
|
||||
|
||||
**需要补充**:
|
||||
- ❌ 模板中按钮组 UI(在 `kind === 'novel'` 的消息下方添加)
|
||||
- ❌ 按钮组样式(`.message-actions`、`.action-btn` 等)
|
||||
|
||||
### ScriptDetailView(详情页)
|
||||
|
||||
**需要新增**:
|
||||
- ❌ `collapsedMessageIds` ref
|
||||
- ❌ `isMessageCollapsed(message)` 函数
|
||||
- ❌ `getMessageDisplayContent(message)` 函数
|
||||
- ❌ `toggleMessageCollapse(message)` 函数
|
||||
- ❌ `copyMessageContent(message)` 函数
|
||||
- ❌ `playMessageTts(message)` 函数
|
||||
- ❌ 模板中按钮组 UI
|
||||
- ❌ 按钮组样式
|
||||
|
||||
**已有资源**:
|
||||
- ✅ `ttsPlayer` composable(行 110)
|
||||
- ✅ `detailTtsActionText` computed(行 118-122)
|
||||
- ✅ `detailTtsIcon` computed(行 124-126)
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### 步骤 1:ScriptView 恢复按钮组 UI
|
||||
|
||||
1. 打开 `mini-program/src/pages/main/ScriptView.vue`
|
||||
2. 定位到 `kind === 'novel'` 的消息渲染部分(约行 180-187)
|
||||
3. 在 `<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">` 内部:
|
||||
- 将 `<Markdown :content="msg.content" />` 改为 `<Markdown :content="getMessageDisplayContent(msg)" />`
|
||||
- 在 Markdown 下方添加折叠/展开按钮
|
||||
- 在折叠/展开按钮下方添加操作按钮组(复制、播放)
|
||||
4. 添加按钮组样式(`.message-actions`、`.action-btn` 等)
|
||||
|
||||
### 步骤 2:ScriptDetailView 新增函数
|
||||
|
||||
1. 打开 `mini-program/src/pages/main/ScriptDetailView.vue`
|
||||
2. 在 `<script setup>` 中添加:
|
||||
- `const collapsedMessageIds = ref({})`
|
||||
- `isMessageCollapsed(message)` 函数
|
||||
- `getMessageDisplayContent(message)` 函数
|
||||
- `toggleMessageCollapse(message)` 函数
|
||||
- `copyMessageContent(message)` 函数
|
||||
- `playMessageTts(message)` 函数
|
||||
|
||||
### 步骤 3:ScriptDetailView 添加按钮组 UI
|
||||
|
||||
1. 定位到 `kind === 'novel'` 的消息渲染部分(约行 74-76)
|
||||
2. 在 `<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">` 内部:
|
||||
- 将 `<Markdown :content="msg.content" />` 改为 `<Markdown :content="getMessageDisplayContent(msg)" />`
|
||||
- 在 Markdown 下方添加折叠/展开按钮
|
||||
- 在折叠/展开按钮下方添加操作按钮组(复制、播放)
|
||||
3. 添加按钮组样式(与 ScriptView 保持一致)
|
||||
|
||||
### 步骤 4:H5 端到端验收
|
||||
|
||||
1. 启动 mini-program H5:`python dev-services.py start mini-program`(端口 5180)
|
||||
2. 浏览器访问 `http://localhost:5180/#/pages/main/index?tab=script`
|
||||
3. **生成页验收**:
|
||||
- 进入一个已有小说的生成页(或新建一个)
|
||||
- 验证小说正文下方有 3 个按钮:折叠/展开、复制、播放
|
||||
- 点击"折叠":内容截断显示前 200 字 + "展开全文"按钮
|
||||
- 点击"展开":内容完整显示 + "收起全文"按钮
|
||||
- 点击"复制":Toast 提示"已复制",剪贴板中有内容
|
||||
- 点击"播放":TTS 开始播放,按钮变为"暂停"
|
||||
- 浏览器 Console 检查:0 新增错误
|
||||
4. **详情页验收**:
|
||||
- 进入小说详情页(从列表页点击剧本)
|
||||
- 验证小说正文下方有 3 个按钮:折叠/展开、复制、播放
|
||||
- 重复上述验收步骤
|
||||
- 浏览器 Console 检查:0 新增错误
|
||||
|
||||
## 验收标准
|
||||
|
||||
### 功能验收
|
||||
|
||||
- ✅ **生成页(ScriptView)**:
|
||||
- 每条 novel 消息下方有 3 个按钮:折叠/展开、复制、播放
|
||||
- 折叠/展开功能正常:折叠时显示前 200 字,展开时显示完整内容
|
||||
- 复制功能正常:点击后 Toast 提示"已复制",剪贴板中有内容
|
||||
- 播放功能正常:点击后 TTS 开始播放,按钮变为"暂停"
|
||||
- Console 无新增错误
|
||||
|
||||
- ✅ **详情页(ScriptDetailView)**:
|
||||
- 每条 novel 消息下方有 3 个按钮:折叠/展开、复制、播放
|
||||
- 折叠/展开功能正常
|
||||
- 复制功能正常
|
||||
- 播放功能正常
|
||||
- Console 无新增错误
|
||||
|
||||
### 样式验收
|
||||
|
||||
- ✅ 按钮组横向排列,3 个按钮等宽分布
|
||||
- ✅ 播放按钮为 primary 样式(紫色渐变)
|
||||
- ✅ 折叠/展开按钮为独立行,在按钮组上方
|
||||
- ✅ 按钮样式与 MessageCard 保持一致(圆角、背景、边框)
|
||||
|
||||
### 数据流验收
|
||||
|
||||
- ✅ 埋点正常触发:`script_message_collapse_toggle`、`script_message_copy_click`、`script_message_tts_click`
|
||||
- ✅ TTS 播放状态同步:播放时按钮显示"暂停",暂停时按钮显示"播放"
|
||||
|
||||
## 影响范围
|
||||
|
||||
### 修改文件
|
||||
|
||||
1. `mini-program/src/pages/main/ScriptView.vue`
|
||||
- 模板:为 `kind === 'novel'` 的消息添加按钮组 UI
|
||||
- 样式:添加 `.message-actions`、`.action-btn` 等样式
|
||||
|
||||
2. `mini-program/src/pages/main/ScriptDetailView.vue`
|
||||
- Script:新增 6 个函数/ref
|
||||
- 模板:为 `kind === 'novel'` 的消息添加按钮组 UI
|
||||
- 样式:添加 `.message-actions`、`.action-btn` 等样式
|
||||
|
||||
### 不修改的部分
|
||||
|
||||
- ❌ 后端代码:不涉及
|
||||
- ❌ 数据库:不涉及
|
||||
- ❌ MessageCard.vue 组件:不修改(保持现状)
|
||||
- ❌ 其他页面:不涉及
|
||||
|
||||
### 兼容性
|
||||
|
||||
- ✅ 老剧本(无 clarification/outline 数据):不影响,仍可正常显示
|
||||
- ✅ 新剧本(有完整数据):新增按钮组功能
|
||||
- ✅ 无小说正文的剧本:不显示按钮组(`kind === 'novel'` 的消息不存在)
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
1. **TTS 播放的是整个小说**:`ttsPlayer.playSource(scriptId)` 播放的是整个小说的音频,不是单条消息的音频。这是历史设计,保持一致。
|
||||
|
||||
2. **折叠阈值**:折叠时显示前 200 字符。这个阈值可以根据实际需求调整。
|
||||
|
||||
3. **样式一致性**:两个页面的按钮组样式必须保持一致,建议直接复制 ScriptView 的样式到 ScriptDetailView。
|
||||
|
||||
4. **性能考虑**:`collapsedMessageIds` 是响应式对象,每次切换折叠状态都会触发重新渲染。对于大量消息的场景,可能需要优化(当前场景下消息数量有限,不构成问题)。
|
||||
|
||||
5. **埋点完整性**:所有用户操作(折叠、复制、播放)都必须触发埋点,用于数据分析。
|
||||
|
||||
## 总结
|
||||
|
||||
本设计方案通过极简设计恢复小程序小说生成页和详情页丢失的"复制、播放、折叠/展开"功能。核心决策是:每条 novel 消息下方只放 3 个核心功能按钮,修订类按钮集中在底部操作区。实现上,ScriptView 复用已有函数,ScriptDetailView 新增相同逻辑。验收标准明确,影响范围可控。
|
||||
@@ -72,7 +72,27 @@
|
||||
|
||||
<!-- 小说正文(Markdown 渲染) -->
|
||||
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">
|
||||
<Markdown :content="msg.content" />
|
||||
<Markdown :content="getMessageDisplayContent(msg)" />
|
||||
|
||||
<!-- 折叠/展开按钮(内容超过预览长度时显示) -->
|
||||
<view v-if="msg.content && msg.content.length > ASSISTANT_MESSAGE_PREVIEW_LENGTH" class="collapse-toggle-row" @click="toggleMessageCollapse(msg)">
|
||||
<text class="collapse-toggle-text">{{ isMessageCollapsed(msg) ? '展开全文' : '收起全文' }}</text>
|
||||
<view class="collapse-chevron" :class="{ down: isMessageCollapsed(msg) }">
|
||||
<view></view>
|
||||
<view></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<view class="message-actions">
|
||||
<button class="action-btn" @click="copyMessageContent(msg)">
|
||||
<text>复制</text>
|
||||
</button>
|
||||
<button class="action-btn primary" @click="playMessageTts(msg)">
|
||||
<text class="action-icon">{{ ttsPlayer.playing.value ? 'Ⅱ' : '▶' }}</text>
|
||||
<text>{{ ttsPlayer.playing.value ? '暂停' : '播放' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -106,10 +126,14 @@ const store = useAppStore()
|
||||
const scriptId = ref('')
|
||||
const script = ref(null)
|
||||
const resultMessages = ref([])
|
||||
const collapsedMessageIds = ref({})
|
||||
const pagePath = '/pages/main/ScriptDetailView'
|
||||
const ttsPlayer = useTtsPlayer({ pagePath })
|
||||
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
||||
|
||||
// 小说正文折叠时的预览长度(字符)
|
||||
const ASSISTANT_MESSAGE_PREVIEW_LENGTH = 120
|
||||
|
||||
const lengthText = computed(() => {
|
||||
const map = { short: '短篇', medium: '中篇', long: '长篇' }
|
||||
return map[script.value?.length] || script.value?.length || '中篇'
|
||||
@@ -125,6 +149,70 @@ const detailTtsIcon = computed(() => {
|
||||
return ttsPlayer.playing.value ? 'Ⅱ' : '▶'
|
||||
})
|
||||
|
||||
/**
|
||||
* 判断消息是否处于折叠状态
|
||||
*/
|
||||
const isMessageCollapsed = (message) => {
|
||||
return Boolean(collapsedMessageIds.value[message.id])
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息显示内容(折叠时截断到预览长度)
|
||||
*/
|
||||
const getMessageDisplayContent = (message) => {
|
||||
const content = String(message?.content || '')
|
||||
if (!isMessageCollapsed(message)) return content
|
||||
if (content.length <= ASSISTANT_MESSAGE_PREVIEW_LENGTH) return content
|
||||
return `${content.slice(0, ASSISTANT_MESSAGE_PREVIEW_LENGTH)}...`
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换消息折叠/展开状态
|
||||
*/
|
||||
const toggleMessageCollapse = (message) => {
|
||||
collapsedMessageIds.value = {
|
||||
...collapsedMessageIds.value,
|
||||
[message.id]: !isMessageCollapsed(message)
|
||||
}
|
||||
analytics.track('script_message_collapse_toggle', {
|
||||
message_id: message?.id || '',
|
||||
collapsed: collapsedMessageIds.value[message.id]
|
||||
}, { eventType: 'script', pagePath })
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制消息内容到剪贴板
|
||||
*/
|
||||
const copyMessageContent = (message) => {
|
||||
const content = String(message?.content || '')
|
||||
if (!content.trim()) {
|
||||
uni.showToast({ title: '暂无可复制内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: content,
|
||||
success: () => {
|
||||
uni.showToast({ title: '已复制', icon: 'success' })
|
||||
}
|
||||
})
|
||||
analytics.track('script_message_copy_click', {
|
||||
message_id: message?.id || '',
|
||||
content_length: content.length
|
||||
}, { eventType: 'script', pagePath })
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放消息 TTS 音频(播放整个剧本的音频)
|
||||
*/
|
||||
const playMessageTts = (message) => {
|
||||
const scriptIdValue = script.value?.id || ''
|
||||
ttsPlayer.playSource(scriptIdValue)
|
||||
analytics.track('script_message_tts_click', {
|
||||
message_id: message?.id || '',
|
||||
script_id: scriptIdValue
|
||||
}, { eventType: 'script', pagePath })
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端 message 列表重建统一对话消息流
|
||||
* 复刻 ScriptView 生成时的 chat-bubble 展示结构
|
||||
@@ -522,6 +610,110 @@ onUnmounted(() => {
|
||||
.novel-bubble {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
/* 折叠/展开按钮行 */
|
||||
.collapse-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
padding: 16rpx 0;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(88, 28, 135, 0.12);
|
||||
border: 1rpx solid rgba(192, 132, 252, 0.28);
|
||||
color: rgba(246, 230, 255, 0.96);
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.collapse-toggle-row:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.collapse-toggle-text {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 折叠箭头图标 */
|
||||
.collapse-chevron {
|
||||
position: relative;
|
||||
width: 20rpx;
|
||||
height: 16rpx;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.collapse-chevron.down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.collapse-chevron view {
|
||||
position: absolute;
|
||||
top: 7rpx;
|
||||
width: 12rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #fff3b0, #ffd86b);
|
||||
box-shadow: 0 0 12rpx rgba(255, 216, 107, 0.5);
|
||||
}
|
||||
|
||||
.collapse-chevron view:first-child {
|
||||
left: 0;
|
||||
transform: rotate(-38deg);
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
.collapse-chevron view:last-child {
|
||||
right: 0;
|
||||
transform: rotate(38deg);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
/* 操作按钮组 */
|
||||
.message-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 72rpx;
|
||||
min-height: 72rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #e8ccff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
box-sizing: border-box;
|
||||
background: rgba(88, 28, 135, 0.18);
|
||||
border: 1rpx solid rgba(192, 132, 252, 0.35);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #8c44f2, #5f1db8);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
margin-right: 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.action-btn::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.empty-flow {
|
||||
padding: 80rpx 0;
|
||||
text-align: center;
|
||||
|
||||
@@ -235,7 +235,18 @@ const getTags = (script) => {
|
||||
return []
|
||||
}
|
||||
|
||||
const getChapterCount = (script) => script.chapterCount || script.chapters || 0
|
||||
const getChapterCount = (script) => {
|
||||
// 优先从 beats 数组长度获取(Task 1-5 部署后新剧本结构)
|
||||
const beats = script?.plotJson?.stages?.outline?.beats
|
||||
if (Array.isArray(beats) && beats.length > 0) return beats.length
|
||||
|
||||
// Fallback:根据 length 字段估算(老剧本无 beats 数据)
|
||||
const length = script?.length
|
||||
if (length === 'short') return 1
|
||||
if (length === 'medium') return 2
|
||||
if (length === 'long') return 4
|
||||
return 1
|
||||
}
|
||||
|
||||
const getWordCount = (script) => {
|
||||
const count = Number(script.wordCount || 0)
|
||||
|
||||
@@ -183,7 +183,28 @@
|
||||
</view>
|
||||
|
||||
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system">
|
||||
<text class="novel-text">{{ msg.content }}<text v-if="msg.pending" class="typing-cursor">|</text></text>
|
||||
<text class="novel-text">{{ getMessageDisplayContent(msg) }}</text>
|
||||
<text v-if="msg.pending && !isMessageCollapsed(msg)" class="typing-cursor">|</text>
|
||||
|
||||
<!-- 折叠/展开按钮(内容超过预览长度时显示) -->
|
||||
<view v-if="msg.content && msg.content.length > ASSISTANT_MESSAGE_PREVIEW_LENGTH" class="collapse-toggle-row" @click="toggleMessageCollapse(msg)">
|
||||
<text class="collapse-toggle-text">{{ isMessageCollapsed(msg) ? '展开全文' : '收起全文' }}</text>
|
||||
<view class="collapse-chevron" :class="{ down: isMessageCollapsed(msg) }">
|
||||
<view></view>
|
||||
<view></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮组(仅在非 pending 状态显示) -->
|
||||
<view v-if="!msg.pending" class="message-actions">
|
||||
<button class="action-btn" @click="copyMessageContent(msg)">
|
||||
<text>复制</text>
|
||||
</button>
|
||||
<button class="action-btn primary" @click="playMessageTts(msg)">
|
||||
<text class="action-icon">{{ ttsPlayer.playing.value ? 'Ⅱ' : '▶' }}</text>
|
||||
<text>{{ ttsPlayer.playing.value ? '暂停' : '播放' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="chat-bubble system">
|
||||
@@ -1444,7 +1465,10 @@ const normalizeGeneratedContent = (text) => {
|
||||
const openScriptChat = async (payload = {}) => {
|
||||
uni.removeStorageSync(PENDING_SCRIPT_CHAT_KEY)
|
||||
const scriptIdParam = String(payload?.id || payload?.scriptId || '')
|
||||
let script = payload?.script || (payload?.id ? payload : null)
|
||||
// 优先使用 payload 中携带的完整 script 对象;否则按 id 从 store 取完整数据
|
||||
// 修复:payload 只有 id 时,直接用 payload 会导致 conversationId/plotJson 等字段缺失,
|
||||
// 进而 loadMessages 不会被调用,详情页内容为空
|
||||
let script = payload?.script || null
|
||||
if (!script?.id && scriptIdParam) {
|
||||
script = store.getScriptById(scriptIdParam)
|
||||
if (!script) {
|
||||
@@ -4069,4 +4093,107 @@ onUnmounted(() => {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 折叠/展开按钮行 */
|
||||
.collapse-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
padding: 16rpx 0;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(88, 28, 135, 0.12);
|
||||
border: 1rpx solid rgba(192, 132, 252, 0.28);
|
||||
color: rgba(246, 230, 255, 0.96);
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.collapse-toggle-row:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.collapse-toggle-text {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 折叠箭头图标 */
|
||||
.collapse-chevron {
|
||||
position: relative;
|
||||
width: 20rpx;
|
||||
height: 16rpx;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.collapse-chevron.down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.collapse-chevron view {
|
||||
position: absolute;
|
||||
top: 7rpx;
|
||||
width: 12rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #fff3b0, #ffd86b);
|
||||
box-shadow: 0 0 12rpx rgba(255, 216, 107, 0.5);
|
||||
}
|
||||
|
||||
.collapse-chevron view:first-child {
|
||||
left: 0;
|
||||
transform: rotate(-38deg);
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
.collapse-chevron view:last-child {
|
||||
right: 0;
|
||||
transform: rotate(38deg);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
/* 操作按钮组 */
|
||||
.message-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 72rpx;
|
||||
min-height: 72rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #e8ccff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
box-sizing: border-box;
|
||||
background: rgba(88, 28, 135, 0.18);
|
||||
border: 1rpx solid rgba(192, 132, 252, 0.35);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #8c44f2, #5f1db8);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
margin-right: 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.action-btn::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -307,6 +307,15 @@ public class ShortNovelServiceImpl implements ShortNovelService {
|
||||
long eventTimestamp = System.currentTimeMillis();
|
||||
log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp);
|
||||
|
||||
// 从事件顶层取 session_id 同步 sessionId
|
||||
// 上游事件结构与前端对齐:session_id 与 type 同级,不在 payload 内
|
||||
// 修复:首次 stream 里 status 处理从 payload 取不到 session_id,导致 currentSessionId 保持 null,
|
||||
// 第一个 clarification_card 因 sessionId 为 null 不累积,详情页少显示第一个选项卡
|
||||
String topSessionId = event.getString("session_id");
|
||||
if (topSessionId != null && !topSessionId.isEmpty()) {
|
||||
currentSessionId[0] = topSessionId;
|
||||
}
|
||||
|
||||
// 拦截 status 事件,缓存 sessionId → originalQuery 映射(供 followup 兜底)
|
||||
if ("status".equals(type)) {
|
||||
JSONObject payload = event.getJSONObject("payload");
|
||||
|
||||
Reference in New Issue
Block a user