Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c3bd5c0e3 | |||
| 4b3fa1552c | |||
| f8d6e881df | |||
| 4afc632cac | |||
| f1ad9a6b47 | |||
| 180d21b66c | |||
| 41c2112c5b | |||
| 412d3f3a9a | |||
| 8693b3febc | |||
| d560f63d70 | |||
| 979fcb5897 | |||
| 95ce23f172 | |||
| 4884099b9b | |||
| c0245d37fb | |||
| 3666c3d530 | |||
| 33b24450db | |||
| dd11cfae73 | |||
| a3bfad099a | |||
| bebb7f1667 | |||
| ce9c9a8dc5 | |||
| 10d3c72082 | |||
| 05ea918ee1 | |||
| 50f1bdba38 | |||
| 74fa304cd9 | |||
| 9d352f40a0 | |||
| 674188f471 | |||
| 62ca1730b9 | |||
| 0d27d1a072 | |||
| d74d8b22c6 | |||
| 489131a04b | |||
| 43ac037f5b | |||
| b20f30f91b | |||
| 7e52bb9ffb | |||
| 8a2001af3d | |||
| 35b4f97527 | |||
| f5b26c2fc7 | |||
| cb965fadff | |||
| cafa8717ab | |||
| 283914690a | |||
| 04ffa2dceb | |||
| 0a9ea60795 | |||
| 118d20914d | |||
| b194b343ad | |||
| 7a0e729ab4 | |||
| f07817aaff | |||
| 30ecf9f4a3 | |||
| f6ef0222b3 | |||
| e109e59f1a | |||
| e41266e6f4 | |||
| b4fca0abfa | |||
| 84b6a77632 | |||
| c0adb48949 | |||
| 25d8a9f8f2 | |||
| b2df7d4de0 | |||
| 30e2836742 | |||
| c1293c28ac | |||
| d9a061a6bc | |||
| e313086d9b | |||
| 1b566d574c | |||
| 0190941df7 | |||
| 34f443939e | |||
| b3a2f3a3d3 | |||
| ab13ac258b | |||
| 00f17d2f47 | |||
| c06b04869b | |||
| 02c58767bd | |||
| 7018f5780a | |||
| 031d824dd0 | |||
| 8f96239b56 | |||
| 21ce56c6da | |||
| ce244f9ad5 | |||
| 99a3cb883e | |||
| 2c778fabbb | |||
| 6aeb5319e3 | |||
| 3bb6716e0f | |||
| 86543df75d | |||
| 1073123360 | |||
| 3829a3ef92 | |||
| f45fee7cce | |||
| a713e8823c | |||
| 4a7914eefe | |||
| 88ff280b78 | |||
| 912fc42860 | |||
| 7fb90299d1 | |||
| 6ae721eb89 | |||
| 808a590b8d | |||
| 19337b9371 | |||
| e3eb1afbb0 | |||
| caaeb94477 | |||
| bd3ccbd757 | |||
| bf6f2797ad | |||
| 6109202a26 | |||
| 3b9a8222de | |||
| 8537c27015 | |||
| a66a78a0f3 | |||
| 57f1e311b6 | |||
| 8618b8b80f | |||
| b11a3463b2 | |||
| e252dc5fd2 | |||
| 0520f30d84 | |||
| 752fd30686 | |||
| 56369fdeb2 | |||
| 88d586bab1 | |||
| 0149bac281 | |||
| 42d1350aff | |||
| 43622be5b0 | |||
| 74183ccb30 | |||
| 06b2ee58e2 | |||
| 83c379d5d5 | |||
| 15fce9eadf | |||
| 0ba2960c1b | |||
| 0e2cc42a41 |
@@ -387,3 +387,7 @@ server/backend.err
|
||||
|
||||
# Windows 临时空文件
|
||||
nul
|
||||
|
||||
# 服务器日志下载(tools/download-server-log.py 产物)
|
||||
logs/server/*.log
|
||||
logs/server/*.bak
|
||||
|
||||
@@ -266,6 +266,73 @@ bash deploy-all.sh admin
|
||||
|
||||
---
|
||||
|
||||
## 自动验证验收规则(最高优先级,不可违反)
|
||||
|
||||
**每次代码变更任务完成前,必须自动通过浏览器进行验证验收,无需用户额外要求。**
|
||||
|
||||
### 自动触发条件
|
||||
|
||||
以下任一种情况都视为"任务完成前",必须自动执行验证:
|
||||
|
||||
- 修改了后端 Java 代码,完成部署后,必须通过 H5/浏览器调用真实接口验证
|
||||
- 修改了 mini-program 前端代码,热加载生效后,必须在浏览器中操作验证
|
||||
- 修改了任意代码并声称"完成",必须走一遍受影响功能的完整体验流程
|
||||
|
||||
### 验证方式(按受影响端选择)
|
||||
|
||||
| 变更范围 | 验证方式 |
|
||||
|---|---|
|
||||
| 后端 Java 代码 | 部署后,通过本地 H5(连服务器 API)或 curl 验证接口 |
|
||||
| mini-program(小程序) | H5 模式浏览器验证(`http://localhost:5180`) |
|
||||
| web 用户端 | 浏览器访问 `http://localhost:5178` 或服务器 URL |
|
||||
| web-admin 管理后台 | 浏览器访问 `http://localhost:5179` 或服务器 URL |
|
||||
|
||||
### 禁止行为
|
||||
|
||||
- ❌ 禁止代码改完就直接回复"完成",不执行验证
|
||||
- ❌ 禁止仅凭编译通过就认为任务结束
|
||||
- ❌ 禁止等用户提醒后才去验证
|
||||
- ❌ 禁止跳过浏览器 Console / Network 面板检查
|
||||
|
||||
### 验收通过标准
|
||||
|
||||
- 浏览器 Console 无**新增**错误(既有的已知兼容性警告除外)
|
||||
- Network 面板中受影响的 API 返回正常(200 或业务预期状态码)
|
||||
- 受影响的 UI 交互逻辑按预期工作(点击、切换、展示)
|
||||
|
||||
---
|
||||
|
||||
## 部署强制规则(最高优先级,不可违反)
|
||||
|
||||
**本地开发完成、验证通过、验收没有问题后,必须执行 `deploy.py` 部署脚本,将应用部署到服务器上。**
|
||||
|
||||
### 强制流程
|
||||
|
||||
每次功能开发或 bug 修复完成后,必须按以下顺序操作:
|
||||
|
||||
1. **本地验证**:在本地开发环境中完成功能开发和自测,确认无报错。
|
||||
2. **验收通过**:按"服务器端验收强制规则"完成验收,确认功能正常。
|
||||
3. **执行部署**:验收通过后,立即执行部署脚本将应用部署到服务器:
|
||||
- 后端:`python deploy.py backend`
|
||||
- 前端:`python deploy.py frontend`
|
||||
- 管理后台:`python deploy.py admin`
|
||||
- 全部:`python deploy.py all`
|
||||
4. **部署验证**:部署完成后,在服务器上验证功能正常,无报错。
|
||||
|
||||
### 禁止行为
|
||||
|
||||
- ❌ 禁止本地开发完成后不部署到服务器就认为任务完成
|
||||
- ❌ 禁止跳过验收直接部署
|
||||
- ❌ 禁止部署后不验证就结束任务
|
||||
- ❌ 禁止在编译或验收有报错的情况下部署
|
||||
|
||||
### 允许的唯一例外
|
||||
|
||||
- ✅ 纯文档修改(不涉及代码变更)
|
||||
- ✅ 仅在本地运行的脚本工具(不涉及线上服务)
|
||||
|
||||
---
|
||||
|
||||
## 本地服务管理规则(强制)
|
||||
|
||||
**启动、重启、停止本地前后端服务时,必须使用项目根目录下的 `dev-services.py` 脚本。**
|
||||
@@ -384,3 +451,53 @@ bash deploy-all.sh admin
|
||||
- 关键业务操作必须记录操作日志
|
||||
- 异常信息要包含足够的上下文信息
|
||||
- 生产环境禁止输出 debug 级别日志
|
||||
|
||||
---
|
||||
|
||||
## 日志排查规则(强制)
|
||||
|
||||
**遇到"对话不存在或无权限"错误时,按以下顺序排查:**
|
||||
|
||||
### 1. 检查 conversationId 是否为空
|
||||
|
||||
- **前端检查**:在浏览器 DevTools Network 面板查看请求参数 `conversationId`
|
||||
- **后端检查**:查看 `UserContextHolder.getCurrentUserId()` 是否返回有效值
|
||||
- **数据库检查**:执行 `SELECT * FROM t_conversation WHERE id = '<conversationId>'` 确认对话是否存在
|
||||
|
||||
### 2. 检查权限匹配
|
||||
|
||||
- **前端问题**:当前登录用户的 userId 与对话的 userId 不一致
|
||||
- **后端逻辑**:`ScriptMessageServiceImpl.listByConversation` 中会校验 `conversation.getUserId().equals(currentUserId)`
|
||||
- **修复方案**:确认前端传递的是正确的 conversationId,或重新创建对话
|
||||
|
||||
### 3. 常见原因
|
||||
|
||||
| 原因 | 解决方案 |
|
||||
|------|----------|
|
||||
| conversationId 为空字符串 | 前端增加短路保护,显示"暂无对话记录"提示 |
|
||||
| 对话不存在(已删除) | 引导用户创建新对话 |
|
||||
| 用户未登录 | 检查 Token 是否有效,重新登录 |
|
||||
| 对话属于其他用户 | 确认前端传递的 conversationId 正确 |
|
||||
|
||||
### 4. 快速定位方法
|
||||
|
||||
使用 `tools/download-server-log.py` 脚本下载服务器日志:
|
||||
|
||||
```bash
|
||||
# 下载完整日志
|
||||
python tools/download-server-log.py latest
|
||||
|
||||
# 只看最近 50 条错误
|
||||
python tools/download-server-log.py errors 50
|
||||
|
||||
# 搜索特定关键词(如 listByConversation)
|
||||
python tools/download-server-log.py grep "listByConversation" 20
|
||||
```
|
||||
|
||||
日志位置:`logs/server/` 目录
|
||||
|
||||
### 5. 防御性编程原则
|
||||
|
||||
- **Service 层**:对空参数做防御性检查,返回空列表而非抛异常
|
||||
- **Controller 层**:参数校验失败时返回友好错误提示
|
||||
- **前端**:接口调用前做空值检查,避免发送无效请求
|
||||
|
||||
+33
-113
@@ -1,26 +1,12 @@
|
||||
# Emotion Museum HTTPS Nginx 配置
|
||||
# 配置路径:/www/server/panel/vhost/nginx/emotion-museum.conf
|
||||
# 域名:lifescript.happylifeos.com
|
||||
|
||||
# HTTP 服务器 - 强制跳转 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name lifescript.happylifeos.com;
|
||||
|
||||
# 强制跳转 HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS 服务器
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name lifescript.happylifeos.com;
|
||||
|
||||
# SSL 证书配置
|
||||
ssl_certificate /etc/letsencrypt/live/lifescript.happylifeos.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/lifescript.happylifeos.com/privkey.pem;
|
||||
|
||||
# SSL 优化配置
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# WeChat mini program/Cronet compatibility: keep TLS 1.2 enabled with broad modern suites.
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
|
||||
@@ -29,97 +15,73 @@ server {
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
add_header Strict-Transport-Security max-age=31536000 always;
|
||||
|
||||
# HSTS (可选,生产环境建议开启)
|
||||
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# ACME 挑战目录 - 用于 SSL 证书申请和续期
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /data/www/acme-challenge;
|
||||
allow all;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 根路径 - 用户前端应用
|
||||
location = / {
|
||||
return 404;
|
||||
rewrite ^ /emotion-museum/ permanent;
|
||||
}
|
||||
|
||||
location / {
|
||||
location /emotion-museum/ {
|
||||
alias /data/www/emotion-museum/;
|
||||
autoindex off;
|
||||
|
||||
# 处理 Vue Router 的 history 模式
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# HTML 文件不缓存
|
||||
location ~ \.html?$ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# 静态资源缓存 1 年
|
||||
location ~ \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# 静态资源直接返回文件,不经过 try_files
|
||||
location ~* ^/emotion-museum/(assets|static)/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
expires 1y;
|
||||
try_files $uri =404;
|
||||
}
|
||||
# SPA 路由兜底
|
||||
try_files $uri $uri/ /emotion-museum/index.html;
|
||||
}
|
||||
|
||||
location = /emotion-museum {
|
||||
rewrite ^(.*)$ $1/ permanent;
|
||||
}
|
||||
|
||||
# 管理后台应用路径
|
||||
location /emotion-museum-admin/ {
|
||||
alias /data/www/emotion-museum-admin/;
|
||||
autoindex off;
|
||||
|
||||
# 处理 Vue Router 的 history 模式
|
||||
try_files $uri $uri/ /emotion-museum-admin/index.html;
|
||||
|
||||
# HTML 文件不缓存
|
||||
location ~ \.html?$ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# 静态资源缓存 1 年
|
||||
location ~ \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
expires 1y;
|
||||
}
|
||||
}
|
||||
|
||||
location = /emotion-museum-admin {
|
||||
rewrite ^(.*)$ $1/ permanent;
|
||||
}
|
||||
|
||||
# Life-Script 应用路径
|
||||
location /course-of-life/ {
|
||||
alias /data/www/course-of-life/;
|
||||
autoindex off;
|
||||
try_files $uri $uri/ /course-of-life/index.html;
|
||||
}
|
||||
|
||||
location = /course-of-life {
|
||||
rewrite ^ /course-of-life/ last;
|
||||
}
|
||||
|
||||
# life-script React 应用
|
||||
location /life-script/ {
|
||||
alias /data/www/life-script/;
|
||||
autoindex off;
|
||||
|
||||
# 处理 React Router 的 history 模式
|
||||
try_files $uri $uri/ /life-script/index.html;
|
||||
|
||||
# HTML 文件不缓存
|
||||
location ~ \.html?$ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# 静态资源缓存 1 年
|
||||
location ~ \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# 静态资源直接返回文件
|
||||
location ~* ^/life-script/(assets|static)/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
expires 1y;
|
||||
try_files $uri =404;
|
||||
}
|
||||
# SPA 路由兜底
|
||||
try_files $uri $uri/ /life-script/index.html;
|
||||
}
|
||||
|
||||
location = /life-script {
|
||||
rewrite ^ /life-script/ last;
|
||||
}
|
||||
|
||||
# 后端 API 代理
|
||||
location /api {
|
||||
proxy_pass http://127.0.0.1:19089;
|
||||
proxy_set_header Host $host;
|
||||
@@ -131,50 +93,6 @@ server {
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Swagger UI / Knife4j API 文档代理
|
||||
# 使用 ^~ 前缀匹配,防止被 / 的 try_files 拦截
|
||||
location ^~ /swagger-ui {
|
||||
proxy_pass http://127.0.0.1:19089;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /v3/api-docs {
|
||||
proxy_pass http://127.0.0.1:19089;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /doc.html {
|
||||
proxy_pass http://127.0.0.1:19089;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /webjars {
|
||||
proxy_pass http://127.0.0.1:19089;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Knife4j 4.x 额外需要的路径(swagger-resources 等)
|
||||
location ^~ /swagger-resources {
|
||||
proxy_pass http://127.0.0.1:19089;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket 代理
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:19089;
|
||||
proxy_http_version 1.1;
|
||||
@@ -189,21 +107,23 @@ server {
|
||||
proxy_read_timeout 7d;
|
||||
}
|
||||
|
||||
# 健康检查端点
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# 隐藏文件访问控制
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# 日志配置
|
||||
access_log /www/wwwlogs/ssl-access.log;
|
||||
error_log /www/wwwlogs/ssl-error.log;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name lifescript.happylifeos.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ Purpose: 统一部署所有服务到生产服务器,替代原有的多个 shel
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
@@ -524,8 +525,10 @@ def verify_deploy():
|
||||
]
|
||||
|
||||
for url, label in endpoints:
|
||||
null_device = 'NUL' if sys.platform == 'win32' else '/dev/null'
|
||||
safe_url = shlex.quote(url)
|
||||
ok, stdout, _ = run_command(
|
||||
f'curl -k -s -o /dev/null -w "HTTP %{{http_code}}" {url}',
|
||||
f'curl -k -s -o {null_device} -w "HTTP %{{http_code}}" {safe_url}',
|
||||
timeout=30
|
||||
)
|
||||
if ok and stdout:
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# Chat 视图底部遮挡架构层修复实施计划
|
||||
|
||||
> **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:** 修复 ScriptView chat 视图 outline 输入框 placeholder 和修改/确认大纲按钮被主页 bottom-nav 遮挡的问题。`.chat-page` 从 `height: 100vh` 改为 `height: 100%`,让父级 `.content`(`padding-bottom: 132rpx`)自然生效;同时把 `.chat-scroll-content` 的 `padding-bottom` 从 120rpx 改回 40rpx,避免双重 padding。
|
||||
|
||||
**Architecture:** 架构层修正而非 CSS 遮罩。让 chat-page 正确填充父级已预留 bottom-nav 空间的内容区,chat 滚动区底部自然避开 bottom-nav。iPhone 安全区用 `env(safe-area-inset-bottom)` 处理。
|
||||
|
||||
**Tech Stack:** UniApp Vue 3 `<script setup>` + mp-weixin 编译 + rpx 单位 + `env(safe-area-inset-bottom)` 安全区适配
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 修改 .chat-page 从 height: 100vh 改为 height: 100%
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue:3589-3598`
|
||||
|
||||
- [ ] **Step 1: 修改 .chat-page 样式**
|
||||
|
||||
打开 `mini-program/src/pages/main/ScriptView.vue`,定位到第 3589-3598 行的 `.chat-page` 样式块:
|
||||
|
||||
```css
|
||||
.chat-page {
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #13091f;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
```
|
||||
|
||||
改为(`height: 100vh` → `height: 100%`,删除旧版 iOS 兼容的 `constant()`):
|
||||
|
||||
```css
|
||||
.chat-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #13091f;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "refactor: .chat-page 改为 height: 100% 让父级 padding 生效"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 修改 .chat-scroll-content 改回 padding-bottom: 40rpx
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue:3617-3619`
|
||||
|
||||
- [ ] **Step 1: 修改 .chat-scroll-content 样式**
|
||||
|
||||
打开 `mini-program/src/pages/main/ScriptView.vue`,定位到第 3617-3619 行的 `.chat-scroll-content` 样式块:
|
||||
|
||||
```css
|
||||
.chat-scroll-content {
|
||||
padding: 0 24rpx 120rpx;
|
||||
}
|
||||
```
|
||||
|
||||
改为(`padding-bottom` 从 120rpx 改回 40rpx,避免与父级 `.content` 的 `padding-bottom: 132rpx` 双重叠加):
|
||||
|
||||
```css
|
||||
.chat-scroll-content {
|
||||
padding: 0 24rpx 40rpx;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "fix: .chat-scroll-content padding-bottom 改回 40rpx 避免与父级 padding 叠加"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: H5 浏览器验证修复效果
|
||||
|
||||
**Files:**
|
||||
- Test: `http://localhost:5180` (H5 开发服务器,端口 5180 由 `dev-services.py` 管理)
|
||||
|
||||
- [ ] **Step 1: 确认 H5 开发服务器运行中**
|
||||
|
||||
```bash
|
||||
python dev-services.py status
|
||||
```
|
||||
Expected: `mini-program` 服务在端口 5180 上运行。如未运行,执行:
|
||||
```bash
|
||||
python dev-services.py start mini-program
|
||||
```
|
||||
等待 `VITE` 编译完成,确认输出包含 `Local: http://localhost:5180/`。
|
||||
|
||||
- [ ] **Step 2: 浏览器打开页面并进入 chat 视图**
|
||||
|
||||
1. 用浏览器打开 `http://localhost:5180`
|
||||
2. 确认已登录(如未登录,使用手机号验证码 123456 登录)
|
||||
3. 点击底部导航「爽文生成」
|
||||
4. 点击任意一条灵感推荐填充输入框,点击「发送」
|
||||
|
||||
- [ ] **Step 3: 检查 .chat-page 计算样式**
|
||||
|
||||
在浏览器 DevTools Console 中执行:
|
||||
|
||||
```javascript
|
||||
() => {
|
||||
const el = document.querySelector('.chat-page');
|
||||
if (!el) return 'chat-page not found';
|
||||
const styles = window.getComputedStyle(el);
|
||||
const parent = el.parentElement;
|
||||
const parentStyles = parent ? window.getComputedStyle(parent) : null;
|
||||
return {
|
||||
chatPage: {
|
||||
height: styles.height,
|
||||
paddingBottom: styles.paddingBottom
|
||||
},
|
||||
parent: parent ? {
|
||||
tag: parent.tagName,
|
||||
className: parent.className,
|
||||
paddingBottom: parentStyles.paddingBottom
|
||||
} : null
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Expected: `chatPage.height` 应小于视口高度(不再 100vh),`parent.paddingBottom` 应为 `66px` (= 132rpx @ 750rpx 设计稿/2 = 实际 132rpx / 2 = 66px in 375px viewport)。
|
||||
|
||||
- [ ] **Step 4: 等待 SSE 返回澄清卡片或 outline**
|
||||
|
||||
在 chat 视图等待 10-15 秒:
|
||||
- 若出现澄清卡片(ClarificationCard):向下滚动,确认「提交」按钮在 bottom-nav 上方完整可见
|
||||
- 若出现 outline(带 beats 列表):向下滚动,确认 placeholder "如需修改请输入意见" 完整可见、「修改大纲」「确认大纲」按钮在 bottom-nav 上方完整可点击
|
||||
|
||||
- [ ] **Step 5: 验证 chat-scroll-content 实际高度**
|
||||
|
||||
在 Console 中执行:
|
||||
|
||||
```javascript
|
||||
() => {
|
||||
const scrollContent = document.querySelector('.chat-scroll-content');
|
||||
const scroll = document.querySelector('.chat-scroll');
|
||||
if (!scrollContent || !scroll) return 'not found';
|
||||
return {
|
||||
scrollContentPaddingBottom: window.getComputedStyle(scrollContent).paddingBottom,
|
||||
scrollHeight: scroll.offsetHeight,
|
||||
scrollClientHeight: scroll.clientHeight,
|
||||
scrollScrollHeight: scroll.scrollHeight
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Expected: `scrollContentPaddingBottom` = `20px` (= 40rpx / 2 in 375px viewport),`scrollScrollHeight > scrollClientHeight` 表示有内容可滚动。
|
||||
|
||||
- [ ] **Step 6: 验证 home 视图不受影响**
|
||||
|
||||
点击 chat 视图右上角「×」关闭按钮返回 home 视图,确认:
|
||||
- ✅ 首页正常渲染
|
||||
- ✅ 灵感推荐、输入框、语音球布局完整
|
||||
- ✅ bottom-nav 与 home 内容无重叠
|
||||
|
||||
---
|
||||
|
||||
### Task 4: mp-weixin 编译验证
|
||||
|
||||
**Files:**
|
||||
- Build: `mini-program/unpackage/dist/build/mp-weixin/`
|
||||
|
||||
- [ ] **Step 1: 执行 mp-weixin 编译**
|
||||
|
||||
```bash
|
||||
cd mini-program
|
||||
npm run build:mp-weixin
|
||||
```
|
||||
Expected: 编译成功,无 error 输出。编译产物在 `unpackage/dist/build/mp-weixin/`。
|
||||
|
||||
- [ ] **Step 2: 验证编译产物样式已更新**
|
||||
|
||||
```bash
|
||||
grep -o '.chat-page[^}]*}' mini-program/unpackage/dist/build/mp-weixin/pages/main/ScriptView.wxss
|
||||
grep -o '.chat-scroll-content[^}]*}' mini-program/unpackage/dist/build/mp-weixin/pages/main/ScriptView.wxss
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `.chat-page` 包含 `height:100%` 和 `padding-bottom:env(safe-area-inset-bottom)`
|
||||
- `.chat-page` 不再包含 `height:100vh` 或 `constant(safe-area-inset-bottom)`
|
||||
- `.chat-scroll-content` 的 padding 为 `0 24rpx 40rpx`(不再是 120rpx)
|
||||
|
||||
- [ ] **Step 3: 提交(如有微调)**
|
||||
|
||||
如有需要,执行:
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "style: mp-weixin 编译产物验证通过"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 通知用户在微信开发者工具中验证
|
||||
|
||||
- [ ] **Step 1: 提示用户操作**
|
||||
|
||||
告知用户:
|
||||
> mp-weixin 编译产物已更新(`mini-program/unpackage/dist/build/mp-weixin/`)。请在**微信开发者工具**中:
|
||||
> 1. 打开 `unpackage/dist/build/mp-weixin` 目录
|
||||
> 2. 点击「编译」按钮重新编译
|
||||
> 3. 进入心愿实现页面,触发对话流直至出现 outline 消息(带 beats 列表)
|
||||
> 4. 向下滚动,确认:
|
||||
> - outline 输入框的 placeholder "如需修改请输入意见" 完整可见(不被截断)
|
||||
> - 「修改大纲」「确认大纲」按钮在底部导航栏上方完整可见可点击
|
||||
> 5. 在 iPhone 真机/模拟器上验证底部 home indicator 区域不遮挡内容
|
||||
>
|
||||
> 如有问题,截图反馈。
|
||||
@@ -0,0 +1,188 @@
|
||||
# Chat 视图底部内边距修复实施计划
|
||||
|
||||
> **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:** 修复 ScriptView chat 视图 ClarificationCard 提交按钮被主页 bottom-nav(104rpx)遮挡导致用户无法滚动到可见的问题。
|
||||
|
||||
**Architecture:** 在 `ScriptView.vue` 样式中,给 `.chat-page` 增加 `env(safe-area-inset-bottom)` 适配 iPhone 安全区,给 `.chat-scroll-content` 的 `padding-bottom` 从 40rpx 增至 120rpx,使滚动内容底部留出 bottom-nav 高度(104rpx)+ 16rpx 呼吸间距。
|
||||
|
||||
**Tech Stack:** UniApp Vue 3 `<script setup>` + mp-weixin 编译 + rpx 单位 + `env(safe-area-inset-bottom)` 安全区适配
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 修改 .chat-page 增加 iPhone 安全区内边距
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue:3589-3596`
|
||||
|
||||
- [ ] **Step 1: 修改 .chat-page 样式**
|
||||
|
||||
打开 `mini-program/src/pages/main/ScriptView.vue`,定位到第 3589-3596 行的 `.chat-page` 样式块:
|
||||
|
||||
```css
|
||||
.chat-page {
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #13091f;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
```
|
||||
|
||||
改为(新增 `padding-bottom` 两行,与 `App.vue` 中 `.safe-area-bottom` 的写法保持一致,`constant()` 兼容 iOS 11.0-11.2,`env()` 覆盖 iOS 11.2+):
|
||||
|
||||
```css
|
||||
.chat-page {
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #13091f;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "style: .chat-page 增加 safe-area-inset-bottom 适配 iPhone 底部"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 修改 .chat-scroll-content 增加底部内边距
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue:3615-3617`
|
||||
|
||||
- [ ] **Step 1: 修改 .chat-scroll-content 样式**
|
||||
|
||||
打开 `mini-program/src/pages/main/ScriptView.vue`,定位到第 3615-3617 行的 `.chat-scroll-content` 样式块:
|
||||
|
||||
```css
|
||||
.chat-scroll-content {
|
||||
padding: 0 24rpx 40rpx;
|
||||
}
|
||||
```
|
||||
|
||||
改为(`padding-bottom` 从 40rpx 增至 120rpx = bottom-nav 104rpx + 16rpx 呼吸间距):
|
||||
|
||||
```css
|
||||
.chat-scroll-content {
|
||||
padding: 0 24rpx 120rpx;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "fix: .chat-scroll-content padding-bottom 增至 120rpx 避开 bottom-nav 遮挡"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: H5 浏览器验证修复效果
|
||||
|
||||
**Files:**
|
||||
- Test: `http://localhost:5180` (H5 开发服务器,端口 5180 由 `dev-services.py` 管理)
|
||||
|
||||
- [ ] **Step 1: 确认 H5 开发服务器运行中**
|
||||
|
||||
```bash
|
||||
python dev-services.py status
|
||||
```
|
||||
Expected: `mini-program` 服务在端口 5180 上运行。如未运行,执行:
|
||||
```bash
|
||||
python dev-services.py start mini-program
|
||||
```
|
||||
等待 `VITE` 编译完成,确认输出包含 `Local: http://localhost:5180/`。
|
||||
|
||||
- [ ] **Step 2: 浏览器打开页面并进入 chat 视图**
|
||||
|
||||
1. 用浏览器打开 `http://localhost:5180`
|
||||
2. 确认已登录(如未登录,使用手机号验证码 123456 登录)
|
||||
3. 点击底部导航「爽文生成」
|
||||
4. 点击任意一条灵感推荐(如「我想把最近一次低谷,改写成主角觉醒的开端。」)
|
||||
5. 点击「发送」按钮
|
||||
|
||||
- [ ] **Step 3: 验证澄清卡片提交按钮可见且可点击**
|
||||
|
||||
进入 chat 视图后,等待 SSE 返回澄清卡片。在浏览器中验证:
|
||||
- ✅ 澄清卡片完整渲染(标题、描述、选项、自定义输入框)
|
||||
- ✅ 向下滚动时「提交」按钮在 bottom-nav(「人生轨迹/爽文生成/我的」)上方,不被遮挡
|
||||
- ✅ 选中一个选项后「提交」按钮变为可点击状态(不再灰色/禁用)
|
||||
- ✅ 点击「提交」后流程正常继续(追加一条 user 消息气泡)
|
||||
|
||||
- [ ] **Step 4: 检查浏览器 Console 无报错**
|
||||
|
||||
打开浏览器 DevTools → Console 面板,确认:
|
||||
- ✅ 无新增红色 ERROR(`uni.getRecorderManager not supported` 是 H5 环境预期内的 warning,可忽略)
|
||||
- ✅ Network 面板中 `/shortNovel/followup` 请求返回正常响应
|
||||
|
||||
- [ ] **Step 5: 验证 home 视图不受影响**
|
||||
|
||||
点击 chat 视图右上角「×」关闭按钮,确认:
|
||||
- ✅ 返回首页(wish-home)正常渲染
|
||||
- ✅ 灵感推荐、输入框、语音球布局未受 `padding-bottom` 影响
|
||||
- ✅ 主页 bottom-nav 正常显示,与 home 视图内容无重叠
|
||||
|
||||
- [ ] **Step 6: 验证 chat-input-bar 出现时布局正确**
|
||||
|
||||
当 `generationPhase === 'done'` 时(即 novel_done 后或从历史记录打开已有剧本时):
|
||||
- ✅ 底部输入栏(`chat-input-bar`)在 bottom-nav 上方显示,无重叠
|
||||
- ✅ 输入栏与滚动内容之间无多余空隙
|
||||
- ✅ 输入栏内的 textarea 和「发送」按钮均可正常交互
|
||||
|
||||
---
|
||||
|
||||
### Task 4: mp-weixin 编译验证
|
||||
|
||||
**Files:**
|
||||
- Build: `mini-program/unpackage/dist/dev/mp-weixin/`
|
||||
|
||||
- [ ] **Step 1: 执行 mp-weixin 编译**
|
||||
|
||||
```bash
|
||||
cd mini-program
|
||||
npm run build:mp-weixin
|
||||
```
|
||||
Expected: 编译成功,无 error 输出。编译产物在 `unpackage/dist/dev/mp-weixin/`。
|
||||
|
||||
- [ ] **Step 2: 验证编译产物样式已更新**
|
||||
|
||||
```bash
|
||||
grep "padding-bottom.*env" unpackage/dist/dev/mp-weixin/pages/main/ScriptView.wxss
|
||||
grep "120rpx" unpackage/dist/dev/mp-weixin/pages/main/ScriptView.wxss
|
||||
```
|
||||
Expected:
|
||||
- `padding-bottom: env(safe-area-inset-bottom);` 出现在 `ScriptView.wxss`
|
||||
- `.chat-scroll-content` 的 `padding-bottom` 值为 `120rpx`
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "style: mp-weixin 编译产物验证通过"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 通知用户在微信开发者工具中验证
|
||||
|
||||
- [ ] **Step 1: 提示用户操作**
|
||||
|
||||
告知用户:
|
||||
> mp-weixin 编译产物已更新(`unpackage/dist/dev/mp-weixin/`)。请在**微信开发者工具**中:
|
||||
> 1. 打开 `unpackage/dist/dev/mp-weixin` 目录
|
||||
> 2. 点击「编译」按钮重新编译
|
||||
> 3. 进入心愿实现页面,选择灵感推荐 → 发送
|
||||
> 4. 等待澄清卡片出现后,向下滚动,确认「提交」按钮在底部导航栏上方完整可见
|
||||
> 5. 选中一个选项,点击「提交」,确认流程正常继续
|
||||
> 6. 在 iPhone 真机/模拟器上验证底部 home indicator 区域不遮挡内容
|
||||
>
|
||||
> 如有问题,截图反馈。
|
||||
@@ -0,0 +1,163 @@
|
||||
# 心愿实现 chat 视图 Loading 标识修复实施计划
|
||||
|
||||
> **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:** 修复 ScriptView chat 视图在等待后端 SSE 响应期间 loading 立即消失导致页面空白的问题。
|
||||
|
||||
**Architecture:** 新增 computed `showChatLoading`,基于 `generationPhase === 'generating'` + `generationStatus !== 'failed'` + `resultMessages 最后一条消息 role === 'user'` 三个条件判断是否显示 loading。替换原 `v-if="generating && generationStatus !== 'failed'"`。
|
||||
|
||||
**Tech Stack:** UniApp Vue 3 `<script setup>` + mp-weixin 编译
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 新增 computed showChatLoading 并替换 v-if
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue`
|
||||
- 新增 computed `showChatLoading`(在 `generationCopy` 附近,约 line 942)
|
||||
- 替换 line 177 的 `v-if` 条件
|
||||
|
||||
- [ ] **Step 1: 新增 computed showChatLoading**
|
||||
|
||||
打开 `mini-program/src/pages/main/ScriptView.vue`,在 line 942(`generationCopy` computed 之后)新增:
|
||||
|
||||
```js
|
||||
const showChatLoading = computed(() => {
|
||||
if (generationPhase.value !== 'generating') return false
|
||||
if (generationStatus.value === 'failed') return false
|
||||
const lastMsg = resultMessages.value[resultMessages.value.length - 1]
|
||||
// 最后一条消息是 user(刚发送的请求),说明还没收到 assistant 响应
|
||||
return lastMsg && lastMsg.role === 'user'
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 替换 line 177 的 v-if 条件**
|
||||
|
||||
定位到 line 177:
|
||||
|
||||
```html
|
||||
<view v-if="generating && generationStatus !== 'failed'" class="chat-loading">
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```html
|
||||
<view v-if="showChatLoading" class="chat-loading">
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "fix: chat 视图 loading 在等待首个 assistant 响应期间持续显示"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: H5 浏览器验证
|
||||
|
||||
**Files:**
|
||||
- Test: `http://localhost:5180` (H5 开发服务器,端口 5180 由 `dev-services.py` 管理)
|
||||
|
||||
- [ ] **Step 1: 确认 H5 开发服务器运行中**
|
||||
|
||||
```bash
|
||||
python dev-services.py status
|
||||
```
|
||||
|
||||
Expected: `mini-program` 服务在端口 5180 上运行。如未运行,执行:
|
||||
|
||||
```bash
|
||||
python dev-services.py start mini-program
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 浏览器打开页面并进入 chat 视图**
|
||||
|
||||
1. 用浏览器打开 `http://localhost:5180`
|
||||
2. 确认已登录(如未登录,使用手机号验证码 123456 登录)
|
||||
3. 点击底部导航「爽文生成」
|
||||
4. 在输入框输入内容(或点击灵感推荐),点击「发送」
|
||||
|
||||
- [ ] **Step 3: 验证 loading 立即显示**
|
||||
|
||||
发送后**立即**观察 chat 视图:
|
||||
- ✅ 用户消息气泡出现后,下方立刻显示 loading 动画(loading-orbit + loading-copy 文本,如 "正在把你的心愿写成故事")
|
||||
- ✅ loading 不是空白,也不是瞬间消失
|
||||
- ✅ 浏览器 Network 面板能看到 SSE 请求正在进行
|
||||
|
||||
- [ ] **Step 4: 验证 loading 在收到首个 assistant 响应后隐藏**
|
||||
|
||||
等待 SSE 响应(通常 3-10 秒):
|
||||
- ✅ 收到 `clarification_card` 后,澄清卡片出现,loading 自动隐藏
|
||||
- ✅ 或直接收到 `outline_created` 后,大纲出现,loading 自动隐藏
|
||||
- ✅ 或直接收到 `novel_start` 后,小说内容开始流式出现,loading 自动隐藏
|
||||
|
||||
- [ ] **Step 5: 验证澄清回答后 loading 重新显示**
|
||||
|
||||
如果出现澄清卡片:
|
||||
1. 选择一个选项,点击「提交」
|
||||
2. ✅ 用户回答作为消息追加后,loading 立即重新显示
|
||||
3. ✅ 等待下一个 assistant 响应(outline_created / novel_start)
|
||||
4. ✅ 响应到达后,loading 自动隐藏
|
||||
|
||||
- [ ] **Step 6: 检查浏览器 Console 无报错**
|
||||
|
||||
打开浏览器 DevTools → Console 面板,确认:
|
||||
- ✅ 无新增红色 ERROR(`uni.getRecorderManager not supported` 是 H5 环境预期内的 warning,可忽略)
|
||||
|
||||
- [ ] **Step 7: 验证失败状态不显示 loading**
|
||||
|
||||
如果生成失败(网络异常或后端错误):
|
||||
- ✅ loading 不显示
|
||||
- ✅ 显示「继续创作」按钮(resume 按钮)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: mp-weixin 编译验证
|
||||
|
||||
**Files:**
|
||||
- Build: `mini-program/unpackage/dist/build/mp-weixin/`
|
||||
|
||||
- [ ] **Step 1: 执行 mp-weixin 编译**
|
||||
|
||||
```bash
|
||||
cd mini-program
|
||||
npm run build:mp-weixin
|
||||
```
|
||||
|
||||
Expected: 编译成功,无 error 输出。编译产物在 `unpackage/dist/build/mp-weixin/`。
|
||||
|
||||
- [ ] **Step 2: 验证编译产物**
|
||||
|
||||
```bash
|
||||
grep "showChatLoading" mini-program/unpackage/dist/build/mp-weixin/pages/main/ScriptView.wxml
|
||||
```
|
||||
|
||||
Expected: 编译产物中 `.chat-loading` 的 `v-if` 使用了 `showChatLoading`(或编译后的等效表达式),不再使用原来的 `generating && generationStatus !== 'failed'`。
|
||||
|
||||
- [ ] **Step 3: 提交(如有微调)**
|
||||
|
||||
如有需要,执行:
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "style: mp-weixin 编译产物验证通过"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 通知用户在微信开发者工具中验证
|
||||
|
||||
- [ ] **Step 1: 提示用户操作**
|
||||
|
||||
告知用户:
|
||||
|
||||
> mp-weixin 编译产物已更新(`mini-program/unpackage/dist/build/mp-weixin/`)。请在**微信开发者工具**中:
|
||||
> 1. 打开 `unpackage/dist/build/mp-weixin` 目录
|
||||
> 2. 点击「编译」按钮重新编译
|
||||
> 3. 进入心愿实现页面,发送一条消息
|
||||
> 4. 观察等待 SSE 响应期间是否有 loading 动画持续显示(loading-orbit 动画 + "正在把你的心愿写成故事" 等文案)
|
||||
> 5. 等待澄清卡片 / 大纲 / 小说内容出现后,确认 loading 自动隐藏
|
||||
> 6. 如有澄清问题流程,回答后确认 loading 重新显示直到下一个响应到达
|
||||
>
|
||||
> 如有问题,截图反馈。
|
||||
@@ -0,0 +1,183 @@
|
||||
# 澄清卡片已回答显示修复实施计划
|
||||
|
||||
> **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:** 修复澄清卡片提交后"已回答:xxx"显示英文 value 而非中文 label,以及已回答状态不显示 AI 问题的问题。
|
||||
|
||||
**Architecture:** 在 `ScriptView.vue` 的 `submitClarification` 函数中,通过 value 查找 `card.options` 对应的 label,用 label 显示(`card.answer` 和 `addResultMessage`),后端 payload 仍用 value。在 `card-answered` 模板中显示 AI 的问题(`msg.card?.question`)。
|
||||
|
||||
**Tech Stack:** UniApp Vue 3 `<script setup>` + mp-weixin 编译
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 修改 submitClarification 用 label 显示
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue`
|
||||
- 修改 `submitClarification` 函数(约 line 1746-1764)
|
||||
|
||||
- [ ] **Step 1: 修改 submitClarification**
|
||||
|
||||
打开 `mini-program/src/pages/main/ScriptView.vue`,定位到 `submitClarification` 函数(约 line 1746-1764)。
|
||||
|
||||
替换为:
|
||||
|
||||
```js
|
||||
const submitClarification = (answer) => {
|
||||
if (answeringClarification.value) return
|
||||
// 找最后一张未提交的卡片消息(mp-weixin 模板不支持内联箭头函数传 msg,改为自己查找)
|
||||
const card = [...resultMessages.value].reverse().find(m => m.kind === 'card' && !m.submitted)
|
||||
// 通过 value 查找选项的 label,用于前端显示;后端 payload 仍用 value(语义化)
|
||||
const option = card?.card?.options?.find(opt => opt.value === answer)
|
||||
const label = option?.label || answer
|
||||
if (card) {
|
||||
card.submitted = true
|
||||
card.answer = label // 显示用 label(中文)
|
||||
}
|
||||
addResultMessage({ role: 'user', kind: 'text', content: label })
|
||||
answeringClarification.value = true
|
||||
currentStreamTask.value = followupStream({
|
||||
sessionId: novelSessionId.value,
|
||||
action: 'answer_clarification',
|
||||
payload: { answer }, // 后端仍收 value
|
||||
onEvent: handleShortNovelEvent,
|
||||
onError: (errMsg) => markGenerationFailed(errMsg)
|
||||
})
|
||||
setTimeout(() => { answeringClarification.value = false }, 200)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "fix: 澄清卡片已回答用 label 显示,后端 payload 仍用 value"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 修改 card-answered 模板显示 AI 的问题
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue`
|
||||
- 修改 `card-answered` 模板(约 line 131-133)
|
||||
- 新增 `.card-question-text` 样式(在 `.card-answered` 样式附近)
|
||||
|
||||
- [ ] **Step 1: 修改 card-answered 模板**
|
||||
|
||||
定位到约 line 131-133:
|
||||
|
||||
```html
|
||||
<view v-else class="card-answered">
|
||||
<text>已回答:{{ msg.answer }}</text>
|
||||
</view>
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```html
|
||||
<view v-else class="card-answered">
|
||||
<text v-if="msg.card?.question" class="card-question-text">{{ msg.card.question }}</text>
|
||||
<text>已回答:{{ msg.answer }}</text>
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 新增 .card-question-text 样式**
|
||||
|
||||
在 `.card-answered` 样式块附近(用 Grep 找 `.card-answered {` 位置),新增样式:
|
||||
|
||||
```css
|
||||
.card-question-text {
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16rpx;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "fix: 已回答状态显示 AI 的问题(question)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: H5 浏览器验证
|
||||
|
||||
**Files:**
|
||||
- Test: `http://localhost:5180` (H5 开发服务器)
|
||||
|
||||
- [ ] **Step 1: 确认 H5 开发服务器运行中**
|
||||
|
||||
```bash
|
||||
python dev-services.py status
|
||||
```
|
||||
|
||||
如未运行:`python dev-services.py start mini-program`
|
||||
|
||||
- [ ] **Step 2: 浏览器打开页面并进入 chat 视图**
|
||||
|
||||
1. 打开 `http://localhost:5180`
|
||||
2. 登录(如未登录,用 123456 验证码)
|
||||
3. 点击「爽文生成」→ 输入内容 → 点击「发送」
|
||||
|
||||
- [ ] **Step 3: 验证澄清卡片提交后的显示**
|
||||
|
||||
等待澄清卡片出现:
|
||||
1. 选择一个选项(如"工作/学习上的事"),点击「提交」
|
||||
2. 确认:
|
||||
- ✅ 澄清卡片位置显示 AI 的问题(如"你想重写今天的什么经历?")
|
||||
- ✅ "已回答:"后面显示中文 label(如"工作/学习上的事"),而非英文 value(如"competition")
|
||||
- ✅ 下方用户气泡显示中文 label(如"工作/学习上的事")
|
||||
|
||||
- [ ] **Step 4: 检查浏览器 Console 无报错**
|
||||
|
||||
打开 DevTools → Console 面板,确认:
|
||||
- ✅ 无新增红色 ERROR(预先存在的 favicon 404、`uni.getRecorderManager not supported` 可忽略)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: mp-weixin 编译验证
|
||||
|
||||
**Files:**
|
||||
- Build: `mini-program/unpackage/dist/build/mp-weixin/`
|
||||
|
||||
- [ ] **Step 1: 执行 mp-weixin 编译**
|
||||
|
||||
```bash
|
||||
cd mini-program
|
||||
npm run build:mp-weixin
|
||||
```
|
||||
|
||||
Expected: 编译成功,无 error 输出。
|
||||
|
||||
- [ ] **Step 2: 提交(如有微调)**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "style: mp-weixin 编译产物验证通过"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 通知用户在微信开发者工具中验证
|
||||
|
||||
- [ ] **Step 1: 提示用户操作**
|
||||
|
||||
告知用户:
|
||||
|
||||
> mp-weixin 编译产物已更新。请在**微信开发者工具**中:
|
||||
> 1. 打开 `unpackage/dist/build/mp-weixin` 目录
|
||||
> 2. 点击「编译」重新编译
|
||||
> 3. 进入心愿实现页面,发送一条消息
|
||||
> 4. 等待澄清卡片出现,选择一个选项,点击「提交」
|
||||
> 5. 确认:
|
||||
> - 已回答状态显示 AI 的问题(如"你想重写今天的什么经历?")
|
||||
> - "已回答:"后面显示中文 label(如"工作/学习上的事"),而非英文 value
|
||||
> - 用户气泡显示中文 label
|
||||
>
|
||||
> 如有问题,截图反馈。
|
||||
@@ -0,0 +1,939 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-19
|
||||
purpose: 小程序收藏功能真实 API 化实施计划
|
||||
---
|
||||
|
||||
# 收藏功能真实 API 化实施计划
|
||||
|
||||
> **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:** 前端收藏功能从 localStorage 切换到真实 API。剧本收藏沿用现有后端 API,人生事件收藏新建独立表和 API。
|
||||
|
||||
**Architecture:** 剧本收藏后端已完整(`t_script_favorite` 表 + `ScriptFavoriteService`),前端 `ScriptLibraryView.vue` 未对接。人生事件收藏仿照剧本,新建 `t_event_favorite` 表 + `EventFavoriteService` + Controller,删除假接口 `/lifeEvent/favorite-placeholder`。前端所有 localStorage 收藏逻辑切换到 API。
|
||||
|
||||
**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus 3.5.3.1 + UniApp Vue 3 + Pinia + Element Plus + rpx
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端人生事件收藏功能(Entity + Mapper + Service + Controller)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql`
|
||||
- Create: `server/src/main/java/com/emotion/entity/EventFavorite.java`
|
||||
- Create: `server/src/main/java/com/emotion/mapper/EventFavoriteMapper.java`
|
||||
- Create: `server/src/main/java/com/emotion/dto/request/EventFavoriteToggleRequest.java`
|
||||
- Create: `server/src/main/java/com/emotion/dto/response/EventFavoriteResponse.java`
|
||||
- Create: `server/src/main/java/com/emotion/service/EventFavoriteService.java`
|
||||
- Create: `server/src/main/java/com/emotion/service/impl/EventFavoriteServiceImpl.java`
|
||||
- Modify: `server/src/main/java/com/emotion/controller/LifeEventController.java`
|
||||
|
||||
- [ ] **Step 1: 创建数据库迁移 SQL**
|
||||
|
||||
```sql
|
||||
-- server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql
|
||||
CREATE TABLE t_event_favorite (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '事件ID',
|
||||
create_by VARCHAR(64) COMMENT '创建人',
|
||||
create_time DATETIME COMMENT '创建时间',
|
||||
update_by VARCHAR(64) COMMENT '更新人',
|
||||
update_time DATETIME COMMENT '更新时间',
|
||||
is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是',
|
||||
remarks VARCHAR(500) COMMENT '备注',
|
||||
INDEX idx_user_event (user_id, event_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 EventFavorite 实体类**
|
||||
|
||||
```java
|
||||
// server/src/main/java/com/emotion/entity/EventFavorite.java
|
||||
package com.emotion.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.emotion.common.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
/**
|
||||
* 人生事件收藏关联实体
|
||||
*
|
||||
* @author AI Assistant
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@TableName("t_event_favorite")
|
||||
public class EventFavorite extends BaseEntity {
|
||||
|
||||
@TableField("user_id")
|
||||
private String userId;
|
||||
|
||||
@TableField("event_id")
|
||||
private String eventId;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 创建 EventFavoriteMapper**
|
||||
|
||||
```java
|
||||
// server/src/main/java/com/emotion/mapper/EventFavoriteMapper.java
|
||||
package com.emotion.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.emotion.entity.EventFavorite;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 人生事件收藏 Mapper
|
||||
*
|
||||
* @author AI Assistant
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Mapper
|
||||
public interface EventFavoriteMapper extends BaseMapper<EventFavorite> {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 创建 EventFavoriteToggleRequest DTO**
|
||||
|
||||
```java
|
||||
// server/src/main/java/com/emotion/dto/request/EventFavoriteToggleRequest.java
|
||||
package com.emotion.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 人生事件收藏切换请求
|
||||
*
|
||||
* @author AI Assistant
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
public class EventFavoriteToggleRequest {
|
||||
|
||||
@NotBlank(message = "事件ID不能为空")
|
||||
private String eventId;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 创建 EventFavoriteResponse DTO**
|
||||
|
||||
```java
|
||||
// server/src/main/java/com/emotion/dto/response/EventFavoriteResponse.java
|
||||
package com.emotion.dto.response;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 人生事件收藏状态响应
|
||||
*
|
||||
* @author AI Assistant
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
public class EventFavoriteResponse {
|
||||
|
||||
private String eventId;
|
||||
|
||||
private Boolean isFavorited;
|
||||
|
||||
private String favoriteTime;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 创建 EventFavoriteService 接口**
|
||||
|
||||
```java
|
||||
// server/src/main/java/com/emotion/service/EventFavoriteService.java
|
||||
package com.emotion.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.emotion.common.PageResult;
|
||||
import com.emotion.dto.response.EventFavoriteResponse;
|
||||
import com.emotion.dto.response.LifeEventResponse;
|
||||
import com.emotion.entity.EventFavorite;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 人生事件收藏服务接口
|
||||
*
|
||||
* @author AI Assistant
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
public interface EventFavoriteService extends IService<EventFavorite> {
|
||||
|
||||
EventFavoriteResponse toggleFavorite(String eventId);
|
||||
|
||||
PageResult<LifeEventResponse> getFavoritePage(long current, long size);
|
||||
|
||||
Set<String> getFavoritedEventIds(Set<String> eventIds);
|
||||
|
||||
boolean isFavorited(String eventId);
|
||||
|
||||
long getFavoriteCount();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 创建 EventFavoriteServiceImpl 实现**
|
||||
|
||||
```java
|
||||
// server/src/main/java/com/emotion/service/impl/EventFavoriteServiceImpl.java
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.emotion.common.PageResult;
|
||||
import com.emotion.dto.response.EventFavoriteResponse;
|
||||
import com.emotion.dto.response.LifeEventResponse;
|
||||
import com.emotion.entity.EventFavorite;
|
||||
import com.emotion.mapper.EventFavoriteMapper;
|
||||
import com.emotion.service.EventFavoriteService;
|
||||
import com.emotion.service.LifeEventService;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 人生事件收藏服务实现
|
||||
*
|
||||
* @author AI Assistant
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class EventFavoriteServiceImpl extends ServiceImpl<EventFavoriteMapper, EventFavorite>
|
||||
implements EventFavoriteService {
|
||||
|
||||
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Autowired
|
||||
private LifeEventService lifeEventService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public EventFavoriteResponse toggleFavorite(String eventId) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
EventFavoriteResponse r = new EventFavoriteResponse();
|
||||
r.setEventId(eventId);
|
||||
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
r.setIsFavorited(false);
|
||||
return r;
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(EventFavorite::getUserId, userId)
|
||||
.eq(EventFavorite::getEventId, eventId)
|
||||
.eq(EventFavorite::getIsDeleted, 0);
|
||||
|
||||
EventFavorite existing = this.getOne(w);
|
||||
if (existing != null) {
|
||||
this.removeById(existing.getId());
|
||||
r.setIsFavorited(false);
|
||||
} else {
|
||||
EventFavorite fav = new EventFavorite();
|
||||
fav.setUserId(userId);
|
||||
fav.setEventId(eventId);
|
||||
this.save(fav);
|
||||
r.setIsFavorited(true);
|
||||
if (fav.getCreateTime() != null) {
|
||||
r.setFavoriteTime(fav.getCreateTime().format(FMT));
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<LifeEventResponse> getFavoritePage(long current, long size) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
return new PageResult<>();
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(EventFavorite::getUserId, userId)
|
||||
.eq(EventFavorite::getIsDeleted, 0)
|
||||
.orderByDesc(EventFavorite::getCreateTime);
|
||||
|
||||
Page<EventFavorite> page = new Page<>(current, size);
|
||||
Page<EventFavorite> result = this.page(page, w);
|
||||
|
||||
List<LifeEventResponse> records = result.getRecords().stream()
|
||||
.map(EventFavorite::getEventId)
|
||||
.map(lifeEventService::getEventById)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
PageResult<LifeEventResponse> pr = new PageResult<>();
|
||||
pr.setCurrent(result.getCurrent());
|
||||
pr.setSize(result.getSize());
|
||||
pr.setTotal(result.getTotal());
|
||||
pr.setPages(result.getPages());
|
||||
pr.setRecords(records);
|
||||
return pr;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getFavoritedEventIds(Set<String> eventIds) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId) || eventIds == null || eventIds.isEmpty()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(EventFavorite::getUserId, userId)
|
||||
.in(EventFavorite::getEventId, eventIds)
|
||||
.eq(EventFavorite::getIsDeleted, 0);
|
||||
|
||||
return this.list(w).stream()
|
||||
.map(EventFavorite::getEventId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFavorited(String eventId) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId) || !StringUtils.hasText(eventId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(EventFavorite::getUserId, userId)
|
||||
.eq(EventFavorite::getEventId, eventId)
|
||||
.eq(EventFavorite::getIsDeleted, 0);
|
||||
|
||||
return this.count(w) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getFavoriteCount() {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(EventFavorite::getUserId, userId)
|
||||
.eq(EventFavorite::getIsDeleted, 0);
|
||||
|
||||
return this.count(w);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 修改 LifeEventController 添加收藏 API**
|
||||
|
||||
在 `server/src/main/java/com/emotion/controller/LifeEventController.java` 中添加:
|
||||
|
||||
1. 在类顶部添加注入:
|
||||
|
||||
```java
|
||||
@Autowired
|
||||
private EventFavoriteService eventFavoriteService;
|
||||
```
|
||||
|
||||
2. 添加 import:
|
||||
|
||||
```java
|
||||
import com.emotion.dto.request.EventFavoriteToggleRequest;
|
||||
import com.emotion.dto.response.EventFavoriteResponse;
|
||||
import com.emotion.service.EventFavoriteService;
|
||||
```
|
||||
|
||||
3. 在类末尾(`readTags` 方法之前)添加收藏 API:
|
||||
|
||||
```java
|
||||
// ==================== 人生事件收藏 ====================
|
||||
|
||||
@Operation(summary = "切换收藏", description = "收藏或取消收藏指定事件。")
|
||||
@PostMapping(value = "/favorite/toggle")
|
||||
public Result<EventFavoriteResponse> toggleFavorite(
|
||||
@Valid @RequestBody EventFavoriteToggleRequest request) {
|
||||
return Result.success(eventFavoriteService.toggleFavorite(request.getEventId()));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询收藏", description = "分页查询当前用户收藏的事件列表。")
|
||||
@GetMapping(value = "/favorite/page")
|
||||
public Result<PageResult<LifeEventResponse>> getFavoritePage(
|
||||
@RequestParam(defaultValue = "1") long current,
|
||||
@RequestParam(defaultValue = "10") long size) {
|
||||
return Result.success(eventFavoriteService.getFavoritePage(current, size));
|
||||
}
|
||||
|
||||
@Operation(summary = "检查收藏状态", description = "检查指定事件是否已被当前用户收藏。")
|
||||
@GetMapping(value = "/favorite/check")
|
||||
public Result<EventFavoriteResponse> checkFavorite(@RequestParam String eventId) {
|
||||
boolean favorited = eventFavoriteService.isFavorited(eventId);
|
||||
EventFavoriteResponse r = new EventFavoriteResponse();
|
||||
r.setEventId(eventId);
|
||||
r.setIsFavorited(favorited);
|
||||
return Result.success(r);
|
||||
}
|
||||
```
|
||||
|
||||
4. **删除** `/lifeEvent/favorite-placeholder` 假接口(第 153-163 行):
|
||||
|
||||
```java
|
||||
// 删除以下方法
|
||||
@Operation(summary = "收藏占位接口", description = "返回收藏功能的占位响应。")
|
||||
@PostMapping(value = "/favorite-placeholder")
|
||||
public Result<Map<String, Object>> favoritePlaceholder(@RequestBody Map<String, Object> request) {
|
||||
// ... 整段删除
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: 提交**
|
||||
|
||||
```bash
|
||||
git add server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql
|
||||
git add server/src/main/java/com/emotion/entity/EventFavorite.java
|
||||
git add server/src/main/java/com/emotion/mapper/EventFavoriteMapper.java
|
||||
git add server/src/main/java/com/emotion/dto/request/EventFavoriteToggleRequest.java
|
||||
git add server/src/main/java/com/emotion/dto/response/EventFavoriteResponse.java
|
||||
git add server/src/main/java/com/emotion/service/EventFavoriteService.java
|
||||
git add server/src/main/java/com/emotion/service/impl/EventFavoriteServiceImpl.java
|
||||
git add server/src/main/java/com/emotion/controller/LifeEventController.java
|
||||
git commit -m "feat: 新增人生事件收藏功能(独立收藏表 + API)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端编译 + 部署到服务器
|
||||
|
||||
**Files:**
|
||||
- Build: `server/target/`
|
||||
- Deploy: 服务器
|
||||
|
||||
- [ ] **Step 1: 本地编译验证**
|
||||
|
||||
```bash
|
||||
cd server
|
||||
mvn clean install -DskipTests
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESS,无编译错误。
|
||||
|
||||
- [ ] **Step 2: 部署到服务器**
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
python deploy.py backend
|
||||
```
|
||||
|
||||
Expected: 部署成功,服务器自动重启。
|
||||
|
||||
- [ ] **Step 3: 验证部署**
|
||||
|
||||
```bash
|
||||
python deploy.py verify
|
||||
```
|
||||
|
||||
Expected: 后端服务运行正常。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "chore: 后端人生事件收藏功能已部署" --allow-empty
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 前端 ScriptLibraryView.vue 切换到剧本收藏 API
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptLibraryView.vue`
|
||||
|
||||
- [ ] **Step 1: 删除 localFavorites ref,改用内存状态**
|
||||
|
||||
删除第 160 行:
|
||||
|
||||
```javascript
|
||||
// 删除这一行
|
||||
const localFavorites = ref(uni.getStorageSync('script_favorites') || {})
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```javascript
|
||||
const favoriteStatus = ref({})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 isFavorite 计算**
|
||||
|
||||
修改第 245-247 行:
|
||||
|
||||
```javascript
|
||||
// 之前:
|
||||
const isFavorite = (script) => {
|
||||
return Boolean(script.isFavorite || script.favorite || localFavorites.value[String(script.id)])
|
||||
}
|
||||
|
||||
// 之后:
|
||||
const isFavorite = (script) => {
|
||||
return Boolean(favoriteStatus.value[String(script.id)])
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修改 toggleFavorite,删除 localStorage 写**
|
||||
|
||||
修改第 320-348 行:
|
||||
|
||||
```javascript
|
||||
// 之前:
|
||||
const toggleFavorite = async (script) => {
|
||||
const wasFav = isFavorite(script)
|
||||
// 乐观更新:先改本地缓存
|
||||
const next = { ...localFavorites.value }
|
||||
if (wasFav) {
|
||||
delete next[String(script.id)]
|
||||
} else {
|
||||
next[String(script.id)] = true
|
||||
}
|
||||
localFavorites.value = next
|
||||
uni.setStorageSync('script_favorites', next)
|
||||
closeScriptMenu()
|
||||
|
||||
try {
|
||||
const result = await toggleFavoriteScript(script.id)
|
||||
uni.showToast({ title: result.isFavorited ? '已收藏' : '已取消收藏', icon: 'success' })
|
||||
} catch (error) {
|
||||
// 回滚乐观更新
|
||||
const restored = { ...localFavorites.value }
|
||||
if (wasFav) {
|
||||
restored[String(script.id)] = true
|
||||
} else {
|
||||
delete restored[String(script.id)]
|
||||
}
|
||||
localFavorites.value = restored
|
||||
uni.setStorageSync('script_favorites', restored)
|
||||
uni.showToast({ title: error?.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
// 之后:
|
||||
const toggleFavorite = async (script) => {
|
||||
const wasFav = isFavorite(script)
|
||||
// 乐观更新:先改内存状态
|
||||
const next = { ...favoriteStatus.value }
|
||||
if (wasFav) {
|
||||
delete next[String(script.id)]
|
||||
} else {
|
||||
next[String(script.id)] = true
|
||||
}
|
||||
favoriteStatus.value = next
|
||||
closeScriptMenu()
|
||||
|
||||
try {
|
||||
const result = await toggleFavoriteScript(script.id)
|
||||
uni.showToast({ title: result.isFavorited ? '已收藏' : '已取消收藏', icon: 'success' })
|
||||
} catch (error) {
|
||||
// 回滚乐观更新
|
||||
const restored = { ...favoriteStatus.value }
|
||||
if (wasFav) {
|
||||
restored[String(script.id)] = true
|
||||
} else {
|
||||
delete restored[String(script.id)]
|
||||
}
|
||||
favoriteStatus.value = restored
|
||||
uni.showToast({ title: error?.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 修改 confirmDeleteScript,删除 localStorage 清理**
|
||||
|
||||
修改第 386-389 行:
|
||||
|
||||
```javascript
|
||||
// 之前:
|
||||
const next = { ...localFavorites.value }
|
||||
delete next[id]
|
||||
localFavorites.value = next
|
||||
uni.setStorageSync('script_favorites', next)
|
||||
|
||||
// 之后:
|
||||
const next = { ...favoriteStatus.value }
|
||||
delete next[id]
|
||||
favoriteStatus.value = next
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 列表加载后批量检查收藏状态**
|
||||
|
||||
在 `visibleScripts` computed 后添加 watch 或 onMounted 钩子,批量调用 `checkFavoriteScript`:
|
||||
|
||||
在文件顶部添加 import:
|
||||
|
||||
```javascript
|
||||
import { checkFavoriteScript } from '../../services/epicScript.js'
|
||||
```
|
||||
|
||||
在 `visibleScripts` computed 后添加:
|
||||
|
||||
```javascript
|
||||
// 列表加载后批量检查收藏状态
|
||||
const loadFavoriteStatus = async () => {
|
||||
const ids = scripts.value.map(s => String(s.id)).filter(id => !id.startsWith('demo-'))
|
||||
if (!ids.length) return
|
||||
const status = {}
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
try {
|
||||
status[id] = await checkFavoriteScript(id)
|
||||
} catch {
|
||||
status[id] = false
|
||||
}
|
||||
}))
|
||||
favoriteStatus.value = status
|
||||
}
|
||||
|
||||
// 监听 scripts 变化时重新加载收藏状态
|
||||
watch(scripts, () => {
|
||||
loadFavoriteStatus()
|
||||
}, { immediate: true })
|
||||
```
|
||||
|
||||
确保 import watch:
|
||||
|
||||
```javascript
|
||||
import { computed, ref, watch } from 'vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptLibraryView.vue
|
||||
git commit -m "refactor: 剧本收藏从 localStorage 切换到真实 API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 life-event 切换到人生事件收藏 API
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/services/lifeEvent.js`
|
||||
- Modify: `mini-program/src/pages/life-event/detail.vue`
|
||||
- Modify: `mini-program/src/stores/app.js`
|
||||
|
||||
- [ ] **Step 1: 修改 lifeEvent.js 新增收藏 API 封装**
|
||||
|
||||
在 `mini-program/src/services/lifeEvent.js` 中:
|
||||
|
||||
1. **删除**旧的 `favoriteEvent` 方法(第 67-69 行):
|
||||
|
||||
```javascript
|
||||
// 删除:
|
||||
export const favoriteEvent = async ({ id, favorite }) => {
|
||||
return post('/lifeEvent/favorite-placeholder', { id, favorite })
|
||||
}
|
||||
```
|
||||
|
||||
2. **新增**真实的收藏 API:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 切换人生事件收藏状态(收藏/取消收藏)
|
||||
* @param {string} eventId 事件ID
|
||||
* @returns {Promise<{success: boolean, isFavorited: boolean}>}
|
||||
*/
|
||||
export const toggleFavoriteEvent = async (eventId) => {
|
||||
const res = await post('/lifeEvent/favorite/toggle', { eventId })
|
||||
return {
|
||||
success: true,
|
||||
isFavorited: res.data?.isFavorited ?? false,
|
||||
favoriteTime: res.data?.favoriteTime ?? null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查人生事件是否已收藏
|
||||
* @param {string} eventId 事件ID
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const checkFavoriteEvent = async (eventId) => {
|
||||
const res = await get('/lifeEvent/favorite/check', { eventId })
|
||||
return res.data?.isFavorited ?? false
|
||||
}
|
||||
```
|
||||
|
||||
3. 在 default export 中更新方法列表:
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
getEventList,
|
||||
getEventPage,
|
||||
getEventById,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
assistEventWriting,
|
||||
chatAboutEvent,
|
||||
shareEvent,
|
||||
toggleFavoriteEvent,
|
||||
checkFavoriteEvent,
|
||||
transformToFrontendFormat,
|
||||
transformListToFrontend
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 stores/app.js 的 favoriteEvent action**
|
||||
|
||||
在 `mini-program/src/stores/app.js` 中:
|
||||
|
||||
1. **删除**旧的 `favoriteEvent` action(第 235-242 行):
|
||||
|
||||
```javascript
|
||||
// 删除:
|
||||
const favoriteEvent = async ({ id, favorite }) => {
|
||||
try {
|
||||
const res = await lifeEventService.favoriteEvent({ id, favorite })
|
||||
return { success: true, data: res.data }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **新增**新的 action:
|
||||
|
||||
```javascript
|
||||
const toggleEventFavorite = async (eventId) => {
|
||||
try {
|
||||
const result = await lifeEventService.toggleFavoriteEvent(eventId)
|
||||
return { success: true, data: result }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const checkEventFavorite = async (eventId) => {
|
||||
try {
|
||||
const isFavorited = await lifeEventService.checkFavoriteEvent(eventId)
|
||||
return { success: true, data: isFavorited }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. 在 return 对象中更新导出:
|
||||
|
||||
```javascript
|
||||
return {
|
||||
// ... 其他方法
|
||||
toggleEventFavorite,
|
||||
checkEventFavorite,
|
||||
// 删除 favoriteEvent
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修改 detail.vue 初始化时检查收藏状态**
|
||||
|
||||
在 `mini-program/src/pages/life-event/detail.vue` 中:
|
||||
|
||||
1. 修改 `onMounted`(第 137-149 行):
|
||||
|
||||
```javascript
|
||||
// 之前:
|
||||
onMounted(async () => {
|
||||
const info = uni.getWindowInfo()
|
||||
safeAreaBottom.value = info.safeAreaInsets?.bottom || 0
|
||||
const pages = getCurrentPages()
|
||||
eventId.value = pages[pages.length - 1]?.options?.id || ''
|
||||
cachedEvent.value = uni.getStorageSync('current_life_event') || null
|
||||
if (!store.events?.length) await store.fetchEvents()
|
||||
isFavorite.value = Boolean(uni.getStorageSync(`event_favorite_${eventId.value}`))
|
||||
analytics.trackPageView(pagePath, {
|
||||
life_event_id: eventId.value,
|
||||
tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0
|
||||
})
|
||||
})
|
||||
|
||||
// 之后:
|
||||
onMounted(async () => {
|
||||
const info = uni.getWindowInfo()
|
||||
safeAreaBottom.value = info.safeAreaInsets?.bottom || 0
|
||||
const pages = getCurrentPages()
|
||||
eventId.value = pages[pages.length - 1]?.options?.id || ''
|
||||
cachedEvent.value = uni.getStorageSync('current_life_event') || null
|
||||
if (!store.events?.length) await store.fetchEvents()
|
||||
// 从 API 检查收藏状态
|
||||
const result = await store.checkEventFavorite(eventId.value)
|
||||
if (result.success) {
|
||||
isFavorite.value = result.data
|
||||
}
|
||||
analytics.trackPageView(pagePath, {
|
||||
life_event_id: eventId.value,
|
||||
tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 修改 detail.vue 的 toggleFavorite,删除 localStorage 写**
|
||||
|
||||
修改第 306-322 行:
|
||||
|
||||
```javascript
|
||||
// 之前:
|
||||
const toggleFavorite = async () => {
|
||||
if (!eventId.value) return
|
||||
const next = !isFavorite.value
|
||||
const result = await store.favoriteEvent({ id: eventId.value, favorite: next })
|
||||
if (!result.success) {
|
||||
uni.showToast({ title: result.error || '收藏失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
isFavorite.value = next
|
||||
if (next) uni.setStorageSync(`event_favorite_${eventId.value}`, '1')
|
||||
else uni.removeStorageSync(`event_favorite_${eventId.value}`)
|
||||
analytics.track('life_event_favorite', {
|
||||
life_event_id: eventId.value,
|
||||
favorite: next
|
||||
}, { eventType: 'life_event', pagePath })
|
||||
uni.showToast({ title: next ? '已收藏' : '已取消收藏', icon: 'success' })
|
||||
}
|
||||
|
||||
// 之后:
|
||||
const toggleFavorite = async () => {
|
||||
if (!eventId.value) return
|
||||
const result = await store.toggleEventFavorite(eventId.value)
|
||||
if (!result.success) {
|
||||
uni.showToast({ title: result.error || '收藏失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
isFavorite.value = result.data?.isFavorited ?? false
|
||||
analytics.track('life_event_favorite', {
|
||||
life_event_id: eventId.value,
|
||||
favorite: isFavorite.value
|
||||
}, { eventType: 'life_event', pagePath })
|
||||
uni.showToast({ title: isFavorite.value ? '已收藏' : '已取消收藏', icon: 'success' })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/services/lifeEvent.js
|
||||
git add mini-program/src/stores/app.js
|
||||
git add mini-program/src/pages/life-event/detail.vue
|
||||
git commit -m "refactor: 人生事件收藏从 localStorage 切换到真实 API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: H5 浏览器验证
|
||||
|
||||
**Files:**
|
||||
- Test: `http://localhost:5180` (H5 开发服务器)
|
||||
|
||||
- [ ] **Step 1: 确认 H5 开发服务器运行中**
|
||||
|
||||
```bash
|
||||
python dev-services.py status
|
||||
```
|
||||
|
||||
Expected: `mini-program` 服务在端口 5180 上运行。如未运行,执行:
|
||||
|
||||
```bash
|
||||
python dev-services.py start mini-program
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 浏览器打开页面并登录**
|
||||
|
||||
1. 打开 `http://localhost:5180`
|
||||
2. 如未登录,使用手机号验证码 123456 登录
|
||||
|
||||
- [ ] **Step 3: 验证剧本收藏**
|
||||
|
||||
1. 点击底部导航「爽文生成」
|
||||
2. 点击「剧本库」
|
||||
3. 点击任意剧本的收藏图标(心形)
|
||||
4. 确认:
|
||||
- ✅ Toast 提示「已收藏」
|
||||
- ✅ 心形图标变为实心
|
||||
- ✅ Network 面板中有 `POST /epicScript/favorite/toggle` 请求,返回 `isFavorited: true`
|
||||
5. 刷新页面
|
||||
6. 确认收藏状态仍保持(心形图标仍为实心)
|
||||
7. 再次点击收藏图标取消收藏
|
||||
8. 确认:
|
||||
- ✅ Toast 提示「已取消收藏」
|
||||
- ✅ 心形图标变为空心
|
||||
- ✅ Network 面板中有 `POST /epicScript/favorite/toggle` 请求,返回 `isFavorited: false`
|
||||
|
||||
- [ ] **Step 4: 验证人生事件收藏**
|
||||
|
||||
1. 点击底部导航「人生轨迹」
|
||||
2. 点击任意事件进入详情页
|
||||
3. 点击收藏按钮
|
||||
4. 确认:
|
||||
- ✅ Toast 提示「已收藏」
|
||||
- ✅ 收藏按钮状态变化
|
||||
- ✅ Network 面板中有 `POST /lifeEvent/favorite/toggle` 请求,返回 `isFavorited: true`
|
||||
5. 返回列表页,再次进入同一事件详情
|
||||
6. 确认收藏状态仍保持
|
||||
7. 再次点击收藏按钮取消收藏
|
||||
8. 确认:
|
||||
- ✅ Toast 提示「已取消收藏」
|
||||
- ✅ 收藏按钮状态恢复
|
||||
- ✅ Network 面板中有 `POST /lifeEvent/favorite/toggle` 请求,返回 `isFavorited: false`
|
||||
|
||||
- [ ] **Step 5: 检查浏览器 Console 无报错**
|
||||
|
||||
打开浏览器 DevTools → Console 面板,确认:
|
||||
- ✅ 无红色 ERROR
|
||||
- ✅ 无 localStorage 相关的 `script_favorites` 或 `event_favorite_` 读写
|
||||
|
||||
- [ ] **Step 6: 提交(如有微调)**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/
|
||||
git commit -m "test: H5 浏览器验证收藏功能通过"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 通知用户验证
|
||||
|
||||
- [ ] **Step 1: 提示用户操作**
|
||||
|
||||
告知用户:
|
||||
|
||||
> 收藏功能已完成真实 API 化改造:
|
||||
>
|
||||
> **剧本收藏**:
|
||||
> - 前端从 localStorage 切换到现有后端 API(`POST /epicScript/favorite/toggle`)
|
||||
> - 收藏状态从服务器读取,不再依赖本地缓存
|
||||
>
|
||||
> **人生事件收藏**:
|
||||
> - 后端新建独立的 `t_event_favorite` 表 + API
|
||||
> - 前端从 localStorage 切换到真实 API(`POST /lifeEvent/favorite/toggle`)
|
||||
> - 删除了假接口 `/lifeEvent/favorite-placeholder`
|
||||
>
|
||||
> **验证方式**:
|
||||
> 1. 本地 H5(`http://localhost:5180`)已验证通过
|
||||
> 2. 后端已部署到服务器
|
||||
> 3. 请在**微信开发者工具**中:
|
||||
> - 打开 `unpackage/dist/dev/mp-weixin` 目录
|
||||
> - 点击「编译」重新编译
|
||||
> - 测试剧本收藏和人生事件收藏功能
|
||||
> - 确认收藏状态刷新后仍保持
|
||||
>
|
||||
> 如有问题,截图反馈。
|
||||
@@ -0,0 +1,874 @@
|
||||
# ScriptView 统一对话页改造实现计划
|
||||
|
||||
> **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:** 把 ScriptView 心愿实现页的澄清/大纲/小说生成从独立视图切换改为统一对话流,用户提交心愿后始终停留在同一对话页面
|
||||
|
||||
**Architecture:** viewState 简化为 home+chat;扩展 resultMessages 消息模型支持 kind: text/card/outline/novel + 交互态字段;handleShortNovelEvent 把 SSE 事件转为对话消息追加;卡片/大纲消息内嵌交互按钮,用户操作后转 user 消息并调 followupStream;novel_done 后底部输入栏按 generationPhase 切换到旧接口 streamScriptChat 做自由对话修改
|
||||
|
||||
**Tech Stack:** UniApp + Vue 3 Composition API(`<script setup>`),shortNovel.js(新接口 SSE),scriptChat.js(旧接口流式对话),H5 热加载验证
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
### 修改文件
|
||||
- `mini-program/src/pages/main/ScriptView.vue` — 核心改造文件(约 3300 行)
|
||||
- 扩展 `addResultMessage` 消息模型
|
||||
- 新增 `generationPhase` 状态
|
||||
- 改造 `handleShortNovelEvent` 为对话流追加消息
|
||||
- 改造 `runGeneration`/`submitClarification`/`confirmOutline`/`modifyOutline`/`resumeSession` 操作对话消息
|
||||
- 改造 `sendChat` 按 generationPhase 切换接口
|
||||
- 新增统一 chat 视图模板,删除 clarifying/outlining/novel-generating 独立视图
|
||||
|
||||
### 不变文件
|
||||
- `mini-program/src/components/ClarificationCard.vue` — 复用,由父组件控制 submitted 态
|
||||
- `mini-program/src/services/shortNovel.js` — 新接口不变
|
||||
- `mini-program/src/services/scriptChat.js` — 旧接口不变
|
||||
|
||||
### 验证方式
|
||||
本项目无前端单元测试框架,采用 **H5 热加载 + Playwright 浏览器验证**。每个任务结束后在浏览器确认页面行为。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 扩展消息模型 + 新增 generationPhase 状态
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue`(addResultMessage 函数约 1283 行,新增 generationPhase ref 约 350 行附近)
|
||||
|
||||
- [ ] **Step 1: 扩展 addResultMessage 支持 card/outline/交互态字段**
|
||||
|
||||
找到 `addResultMessage` 函数(约 1283 行),当前实现:
|
||||
|
||||
```javascript
|
||||
const addResultMessage = ({ role, content, pending = false, kind = '' }) => {
|
||||
const message = {
|
||||
id: createMessageId(),
|
||||
role,
|
||||
kind,
|
||||
content,
|
||||
pending,
|
||||
time: formatMessageTime()
|
||||
}
|
||||
resultMessages.value.push(message)
|
||||
if (!pending) persistResultMessages()
|
||||
keepResultAtBottom()
|
||||
return message
|
||||
}
|
||||
```
|
||||
|
||||
替换为(新增 card/outline/submitted/confirmed/failed/answer/feedback 字段):
|
||||
|
||||
```javascript
|
||||
const addResultMessage = ({ role, content, pending = false, kind = 'text', card = null, outline = null, answer = '', feedback = '', failed = false }) => {
|
||||
const message = {
|
||||
id: createMessageId(),
|
||||
role,
|
||||
kind,
|
||||
content,
|
||||
pending,
|
||||
time: formatMessageTime(),
|
||||
card,
|
||||
outline,
|
||||
answer,
|
||||
feedback,
|
||||
failed,
|
||||
submitted: false,
|
||||
confirmed: false
|
||||
}
|
||||
resultMessages.value.push(message)
|
||||
if (!pending) persistResultMessages()
|
||||
keepResultAtBottom()
|
||||
return message
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 新增 generationPhase ref**
|
||||
|
||||
找到 `const resumeableSessionId = ref('')`(约 350 行),在其后新增一行:
|
||||
|
||||
```javascript
|
||||
const resumeableSessionId = ref('')
|
||||
const generationPhase = ref('idle')
|
||||
```
|
||||
|
||||
`generationPhase` 取值:`'idle'`(未开始)/`'generating'`(生成流程中,用新接口)/`'done'`(novel_done 后,用旧接口)。
|
||||
|
||||
- [ ] **Step 3: 验证热加载无编译错误**
|
||||
|
||||
H5 服务在后台运行(端口 5180),保存文件后 Vite 自动热加载。打开浏览器 http://localhost:5180 确认页面无白屏、Console 无语法错误。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "refactor(mini-program): 扩展 addResultMessage 消息模型 + 新增 generationPhase"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 改造 handleShortNovelEvent 为对话流追加消息
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue`(handleShortNovelEvent 函数约 1682 行)
|
||||
|
||||
- [ ] **Step 1: 改造 handleShortNovelEvent**
|
||||
|
||||
找到 `handleShortNovelEvent` 函数(约 1682 行),当前实现是切换 viewState。替换为往 resultMessages 追加消息:
|
||||
|
||||
```javascript
|
||||
const handleShortNovelEvent = (event) => {
|
||||
const { type, session_id, payload = {} } = event
|
||||
if (session_id) novelSessionId.value = session_id
|
||||
|
||||
switch (type) {
|
||||
case 'status':
|
||||
// 仅更新加载提示,不追加独立消息
|
||||
generationStatus.value = payload.stage || 'processing'
|
||||
break
|
||||
case 'clarification_card':
|
||||
addResultMessage({ role: 'assistant', kind: 'card', card: payload.card || null })
|
||||
generationStatus.value = 'idle'
|
||||
break
|
||||
case 'outline_created':
|
||||
addResultMessage({ role: 'assistant', kind: 'outline', outline: payload.outline || null })
|
||||
generationStatus.value = 'idle'
|
||||
break
|
||||
case 'novel_start':
|
||||
addResultMessage({ role: 'assistant', kind: 'novel', content: '', pending: true })
|
||||
generationStatus.value = 'streaming'
|
||||
break
|
||||
case 'novel_delta': {
|
||||
// 往最后一条 novel 消息追加 delta
|
||||
const lastNovel = [...resultMessages.value].reverse().find(m => m.kind === 'novel' && m.pending)
|
||||
if (lastNovel) {
|
||||
lastNovel.content += payload.delta || ''
|
||||
}
|
||||
keepResultAtBottom()
|
||||
break
|
||||
}
|
||||
case 'novel_done': {
|
||||
// 标记最后一条 novel 消息完成
|
||||
const lastNovel = [...resultMessages.value].reverse().find(m => m.kind === 'novel')
|
||||
if (lastNovel) {
|
||||
if (payload.full_text) lastNovel.content = payload.full_text
|
||||
lastNovel.pending = false
|
||||
}
|
||||
scriptId.value = payload.scriptId || ''
|
||||
conversationId.value = payload.conversationId || ''
|
||||
currentVersionMessageId.value = payload.currentVersionMessageId || ''
|
||||
generationPhase.value = 'done'
|
||||
generationStatus.value = 'idle'
|
||||
persistResultMessages()
|
||||
break
|
||||
}
|
||||
case 'error':
|
||||
if (payload.code === 'DAILY_GENERATION_IN_PROGRESS' && session_id) {
|
||||
resumeableSessionId.value = session_id
|
||||
markGenerationFailed(payload.message || '今天已有未完成的创作,可点击继续创作恢复')
|
||||
} else {
|
||||
markGenerationFailed(payload.message || '生成失败')
|
||||
addResultMessage({ role: 'assistant', kind: 'text', content: payload.message || '生成失败', failed: true })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键变化:
|
||||
- 不再切换 `viewState`(始终保持 `chat`)
|
||||
- `clarification_card` → 追加 kind:'card' 消息
|
||||
- `outline_created` → 追加 kind:'outline' 消息
|
||||
- `novel_start` → 追加 pending 的 kind:'novel' 消息
|
||||
- `novel_delta` → 往最后一条 pending novel 消息追加文本
|
||||
- `novel_done` → 标记 novel 消息完成,设置 `generationPhase='done'`
|
||||
|
||||
- [ ] **Step 2: 验证热加载无错误**
|
||||
|
||||
保存后浏览器确认无 Console 错误(此时 UI 还未改造,但逻辑应无语法错误)。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "refactor(mini-program): handleShortNovelEvent 改为对话流追加消息"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 改造 runGeneration 和交互函数
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue`(runGeneration 约 1626 行,submitClarification/confirmOutline/modifyOutline/resumeSession 约 1727 行)
|
||||
|
||||
- [ ] **Step 1: 改造 runGeneration 进入 chat 视图并追加用户消息**
|
||||
|
||||
找到 `runGeneration` 函数(约 1626 行)。当前在函数中段设置 `viewState.value = 'generating'` 和 `generationDisplayText.value = display`。
|
||||
|
||||
把 `viewState.value = 'generating'` 改为 `viewState.value = 'chat'`,并在 `generationDisplayText.value = display` 后新增追加用户消息的逻辑:
|
||||
|
||||
找到这一段:
|
||||
```javascript
|
||||
streamWriter.reset()
|
||||
startGenerationFeedback()
|
||||
ttsPlayer.reset()
|
||||
viewState.value = 'generating'
|
||||
keepGenerationAtBottom()
|
||||
```
|
||||
|
||||
替换为:
|
||||
```javascript
|
||||
streamWriter.reset()
|
||||
startGenerationFeedback()
|
||||
ttsPlayer.reset()
|
||||
viewState.value = 'chat'
|
||||
generationPhase.value = 'generating'
|
||||
resultMessages.value = []
|
||||
addResultMessage({ role: 'user', kind: 'text', content: display })
|
||||
keepResultAtBottom()
|
||||
```
|
||||
|
||||
说明:进入 chat 视图,清空旧消息,把用户心愿作为第一条 user 消息追加。
|
||||
|
||||
- [ ] **Step 2: 改造 submitClarification 接收消息对象**
|
||||
|
||||
找到 `submitClarification` 函数(约 1727 行),当前签名 `const submitClarification = (answer) =>`。替换为接收消息对象 + answer:
|
||||
|
||||
```javascript
|
||||
const submitClarification = (msg, answer) => {
|
||||
if (answeringClarification.value) return
|
||||
if (msg) {
|
||||
msg.submitted = true
|
||||
msg.answer = answer
|
||||
}
|
||||
addResultMessage({ role: 'user', kind: 'text', content: answer })
|
||||
answeringClarification.value = true
|
||||
currentStreamTask.value = followupStream({
|
||||
sessionId: novelSessionId.value,
|
||||
action: 'answer_clarification',
|
||||
payload: { answer },
|
||||
onEvent: handleShortNovelEvent,
|
||||
onError: (msg) => markGenerationFailed(msg)
|
||||
})
|
||||
setTimeout(() => { answeringClarification.value = false }, 200)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 改造 confirmOutline 接收消息对象**
|
||||
|
||||
找到 `confirmOutline` 函数(约 1740 行),替换为:
|
||||
|
||||
```javascript
|
||||
const confirmOutline = (msg) => {
|
||||
if (msg) msg.confirmed = true
|
||||
addResultMessage({ role: 'user', kind: 'text', content: '确认大纲' })
|
||||
currentStreamTask.value = followupStream({
|
||||
sessionId: novelSessionId.value,
|
||||
action: 'confirm_outline',
|
||||
payload: null,
|
||||
onEvent: handleShortNovelEvent,
|
||||
onError: (msg) => markGenerationFailed(msg)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 改造 modifyOutline 接收消息对象**
|
||||
|
||||
找到 `modifyOutline` 函数(约 1750 行),替换为:
|
||||
|
||||
```javascript
|
||||
const modifyOutline = (msg) => {
|
||||
const feedback = msg?.feedback || ''
|
||||
if (!feedback.trim()) {
|
||||
uni.showToast({ title: '请先填写修改意见', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (msg) msg.confirmed = true
|
||||
addResultMessage({ role: 'user', kind: 'text', content: feedback })
|
||||
currentStreamTask.value = followupStream({
|
||||
sessionId: novelSessionId.value,
|
||||
action: 'modify_outline',
|
||||
payload: { feedback },
|
||||
onEvent: handleShortNovelEvent,
|
||||
onError: (msg) => markGenerationFailed(msg)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 改造 resumeSession 在对话流内恢复**
|
||||
|
||||
找到 `resumeSession` 函数(约 1761 行),替换为:
|
||||
|
||||
```javascript
|
||||
const resumeSession = () => {
|
||||
if (!resumeableSessionId.value || generating.value) return
|
||||
novelSessionId.value = resumeableSessionId.value
|
||||
resumeableSessionId.value = ''
|
||||
generating.value = true
|
||||
generationPhase.value = 'generating'
|
||||
generationStatus.value = 'waiting'
|
||||
generationError.value = ''
|
||||
addResultMessage({ role: 'user', kind: 'text', content: '继续之前的创作' })
|
||||
streamWriter.reset()
|
||||
startGenerationFeedback()
|
||||
keepResultAtBottom()
|
||||
|
||||
try {
|
||||
currentStreamTask.value = followupStream({
|
||||
sessionId: novelSessionId.value,
|
||||
action: 'retry',
|
||||
payload: null,
|
||||
onEvent: handleShortNovelEvent,
|
||||
onError: (msg) => {
|
||||
markGenerationFailed(msg)
|
||||
analytics.track('script_generate_fail', {
|
||||
source: 'resume',
|
||||
error: msg
|
||||
}, { eventType: 'script', pagePath })
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
markGenerationFailed(error.message || '恢复失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键变化:不再切换 viewState(保持 chat),改为追加 user 消息"继续之前的创作"。
|
||||
|
||||
- [ ] **Step 6: 验证热加载无错误**
|
||||
|
||||
保存后浏览器确认无 Console 错误。
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "refactor(mini-program): runGeneration+交互函数改为操作对话消息"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 新增统一 chat 视图模板 + 删除旧独立视图
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue`(template 部分,约 89-183 行的 clarifying/outlining 块 + 139-183 的 generating 块 + 185-302 的 result 块)
|
||||
|
||||
- [ ] **Step 1: 删除 clarifying 和 outlining 独立视图块**
|
||||
|
||||
找到 `clarifying` 视图块(约 89 行 `<view v-else-if="viewState === 'clarifying'"` 到 102 行 `</view>`)和 `outlining` 视图块(约 104 行 `<view v-else-if="viewState === 'outlining'"` 到 137 行 `</view>`),**整块删除**。
|
||||
|
||||
删除这两个块后,template 里不再有 viewState==='clarifying' 和 'outlining' 的分支。
|
||||
|
||||
- [ ] **Step 2: 改造 generating 视图为 chat 视图**
|
||||
|
||||
找到 generating 视图块(约 139 行 `<view v-else-if="viewState === 'generating"`,但由于 Step 1 删除了前面两个块,行号会前移)。把整个 generating 块替换为统一 chat 视图:
|
||||
|
||||
把 `<view v-else-if="viewState === 'generating'" class="generation-view">` 整块(到对应的 `</view>` 结束)替换为:
|
||||
|
||||
```vue
|
||||
<view v-else-if="viewState === 'chat'" class="chat-page">
|
||||
<view class="chat-top-actions">
|
||||
<view class="history-button" @click="openScriptLibrary">
|
||||
<view class="history-lines">
|
||||
<view></view>
|
||||
<view></view>
|
||||
<view></view>
|
||||
</view>
|
||||
<text>历史</text>
|
||||
</view>
|
||||
<button class="page-close-btn" @click="closeResult">×</button>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="chat-scroll"
|
||||
scroll-y
|
||||
:scroll-top="resultCommandScrollTop"
|
||||
:scroll-into-view="resultScrollTarget"
|
||||
:scroll-with-animation="true"
|
||||
:enhanced="true"
|
||||
:show-scrollbar="false"
|
||||
@scroll="handleResultScroll"
|
||||
>
|
||||
<view class="chat-scroll-content">
|
||||
<view id="result-scroll-top" class="result-scroll-top"></view>
|
||||
|
||||
<view class="conversation compact">
|
||||
<view
|
||||
v-for="msg in resultMessages"
|
||||
:key="msg.id"
|
||||
>
|
||||
<!-- user text -->
|
||||
<view v-if="msg.role === 'user' && msg.kind === 'text'" class="chat-bubble user">
|
||||
<text>{{ msg.content }}</text>
|
||||
<text class="bubble-time">{{ msg.time }}</text>
|
||||
</view>
|
||||
|
||||
<!-- assistant card -->
|
||||
<view v-else-if="msg.kind === 'card'" class="chat-bubble system">
|
||||
<ClarificationCard
|
||||
v-if="!msg.submitted && msg.card"
|
||||
:card="msg.card"
|
||||
@submit="(answer) => submitClarification(msg, answer)"
|
||||
/>
|
||||
<view v-else class="card-answered">
|
||||
<text>已回答:{{ msg.answer }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- assistant outline -->
|
||||
<view v-else-if="msg.kind === 'outline'" class="chat-bubble system">
|
||||
<view v-if="msg.outline">
|
||||
<view v-if="msg.outline.title" class="outline-title">
|
||||
<text>{{ msg.outline.title }}</text>
|
||||
</view>
|
||||
<view v-if="msg.outline.logline" class="outline-logline">
|
||||
<text>{{ msg.outline.logline }}</text>
|
||||
</view>
|
||||
<view v-if="Array.isArray(msg.outline.beats)" class="outline-beats">
|
||||
<view v-for="(beat, idx) in msg.outline.beats" :key="idx" class="beat-item">
|
||||
<text class="beat-order">{{ beat.order || idx + 1 }}</text>
|
||||
<view class="beat-content">
|
||||
<text class="beat-title">{{ beat.title || '' }}</text>
|
||||
<text v-if="beat.summary" class="beat-summary">{{ beat.summary }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="msg.outline.ending" class="outline-ending">
|
||||
<text class="ending-label">结局</text>
|
||||
<text class="ending-text">{{ msg.outline.ending }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!msg.confirmed" class="outline-actions">
|
||||
<input v-model="msg.feedback" placeholder="如需修改请输入意见" class="outline-feedback" />
|
||||
<view class="outline-buttons">
|
||||
<view class="btn-secondary" @click="modifyOutline(msg)">修改大纲</view>
|
||||
<view class="btn-primary" @click="confirmOutline(msg)">确认大纲</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- assistant novel -->
|
||||
<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>
|
||||
</view>
|
||||
|
||||
<!-- assistant text (status/error) -->
|
||||
<view v-else class="chat-bubble system">
|
||||
<text :class="{ 'generation-error': msg.failed }">{{ msg.content }}</text>
|
||||
<text class="bubble-time">{{ msg.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部 loading -->
|
||||
<view v-if="generating && generationStatus !== 'failed'" class="chat-loading">
|
||||
<view class="loading-orbit" :class="{ streaming: generationStatus === 'streaming' }">
|
||||
<view class="orbit-ring outer"></view>
|
||||
<view class="orbit-ring inner"></view>
|
||||
<view class="orbit-core"></view>
|
||||
</view>
|
||||
<text class="loading-copy">{{ generationCopy }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 失败继续创作入口 -->
|
||||
<view v-if="generationStatus === 'failed' && resumeableSessionId" class="resume-action">
|
||||
<button class="generation-action primary" @click="resumeSession">继续创作</button>
|
||||
</view>
|
||||
|
||||
<view :id="resultScrollAnchor" class="result-scroll-anchor"></view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部输入栏:novel_done 后启用 -->
|
||||
<view v-if="generationPhase === 'done'" class="chat-input-bar">
|
||||
<view class="result-input-shell">
|
||||
<textarea
|
||||
class="result-chat-input"
|
||||
v-model="chatInput"
|
||||
:auto-height="true"
|
||||
:show-confirm-bar="false"
|
||||
maxlength="500"
|
||||
confirm-type="send"
|
||||
placeholder="输入修改建议,或让 AI 继续生成"
|
||||
@confirm="sendChat"
|
||||
/>
|
||||
</view>
|
||||
<view class="chat-send-btn" :class="{ disabled: !chatInput.trim() || generating }" @click="sendChat">发送</view>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 删除独立的 result 视图块**
|
||||
|
||||
找到原 result 视图块(`<view v-else class="result-page">`,约 185 行到 302 行)。由于 chat 视图已包含所有对话功能,**整块删除**原 result-page 块。
|
||||
|
||||
删除后 template 的 v-if/v-else-if 链为:`home` → `chat`。
|
||||
|
||||
- [ ] **Step 4: 新增 chat 视图相关样式**
|
||||
|
||||
在 `<style scoped>` 部分末尾添加 chat 视图样式(复用现有 result/generation 样式,新增缺失部分):
|
||||
|
||||
```scss
|
||||
.chat-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #13091f;
|
||||
}
|
||||
|
||||
.chat-top-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
flex: 1;
|
||||
height: calc(100vh - 180rpx);
|
||||
}
|
||||
|
||||
.chat-scroll-content {
|
||||
padding: 0 24rpx 40rpx;
|
||||
}
|
||||
|
||||
.card-answered {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 26rpx;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.outline-title {
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.outline-logline {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.outline-beats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.beat-item {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.beat-order {
|
||||
flex-shrink: 0;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
background: #087e8b;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.beat-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.beat-title {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.beat-summary {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 26rpx;
|
||||
margin-top: 8rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.outline-ending {
|
||||
margin-top: 24rpx;
|
||||
padding-top: 24rpx;
|
||||
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ending-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 24rpx;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.ending-text {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.outline-actions {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.outline-feedback {
|
||||
width: 100%;
|
||||
padding: 20rpx;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12rpx;
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.outline-buttons {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.novel-text {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.typing-cursor {
|
||||
color: #087e8b;
|
||||
animation: blink 900ms steps(1) infinite;
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
|
||||
.resume-action {
|
||||
padding: 24rpx 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.result-input-shell {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
}
|
||||
|
||||
.result-chat-input {
|
||||
width: 100%;
|
||||
min-height: 60rpx;
|
||||
padding: 12rpx 0;
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
padding: 16rpx 32rpx;
|
||||
background: #087e8b;
|
||||
border-radius: 32rpx;
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.chat-send-btn.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证热加载渲染**
|
||||
|
||||
保存后浏览器打开 http://localhost:5180,进入心愿页输入心愿发送,确认:
|
||||
- 页面进入 chat 视图(对话页)
|
||||
- 用户心愿作为气泡显示
|
||||
- 澄清卡片作为 AI 消息出现在对话流
|
||||
- Console 无错误
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "feat(mini-program): 统一 chat 视图,删除 clarifying/outlining/result 独立视图"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 改造 sendChat 按 generationPhase 切换接口
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/ScriptView.vue`(sendChat 函数约 701 行)
|
||||
|
||||
- [ ] **Step 1: 改造 sendChat 根据 generationPhase 切换接口**
|
||||
|
||||
找到 `sendChat` 函数(约 701 行)。当前实现用旧接口 `streamScriptChat` + `createMessage`。
|
||||
|
||||
在函数开头插入 generationPhase 判断:如果是 `'generating'`(生成流程中,理论上 novel_done 前底部输入栏不显示,但防御性处理),直接 return;如果是 `'done'`,走原有旧接口逻辑。
|
||||
|
||||
把 `sendChat` 函数替换为:
|
||||
|
||||
```javascript
|
||||
const sendChat = async () => {
|
||||
if (!chatInput.value.trim() || generating.value) return
|
||||
const content = chatInput.value.trim()
|
||||
chatInput.value = ''
|
||||
|
||||
// novel_done 前底部输入栏不显示,此处只处理 done 态的后续对话
|
||||
if (generationPhase.value !== 'done') return
|
||||
|
||||
const messageId = currentVersionMessageId.value
|
||||
|
||||
const userMessageRes = await createMessage({
|
||||
conversationId: conversationId.value,
|
||||
userId: getCurrentUserId(),
|
||||
content,
|
||||
senderType: 'user',
|
||||
contentType: 'chat'
|
||||
})
|
||||
const userMessageId = userMessageRes.data?.id
|
||||
|
||||
resultMessages.value.push({
|
||||
id: userMessageId || createMessageId(),
|
||||
role: 'user',
|
||||
kind: 'text',
|
||||
content,
|
||||
pending: false,
|
||||
time: formatMessageTime(),
|
||||
submitted: false,
|
||||
confirmed: false
|
||||
})
|
||||
keepResultAtBottom()
|
||||
|
||||
generating.value = true
|
||||
generationStatus.value = 'streaming'
|
||||
await streamScriptChat({
|
||||
operationType: 'chat',
|
||||
conversationId: conversationId.value,
|
||||
messageId,
|
||||
userMessageId,
|
||||
content,
|
||||
scriptId: scriptId.value,
|
||||
onDelta: (delta) => {
|
||||
// 往最后一条 AI 消息追加(如有)
|
||||
const lastAssistant = [...resultMessages.value].reverse().find(m => m.role === 'assistant' && m.kind === 'novel')
|
||||
if (lastAssistant) {
|
||||
lastAssistant.content += delta
|
||||
}
|
||||
},
|
||||
onDone: () => {
|
||||
generating.value = false
|
||||
generationStatus.value = 'idle'
|
||||
// 重新加载持久化消息以获取最新版本
|
||||
loadMessages()
|
||||
},
|
||||
onError: (error) => {
|
||||
generating.value = false
|
||||
generationStatus.value = 'idle'
|
||||
uni.showToast({ title: error || '生成失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
关键变化:
|
||||
- 只在 `generationPhase === 'done'` 时处理(底部输入栏此时才显示)
|
||||
- 用户消息追加到 `resultMessages`(统一对话流)
|
||||
- `onDelta` 往最后一条 AI 消息追加文本
|
||||
|
||||
- [ ] **Step 2: 验证热加载无错误**
|
||||
|
||||
保存后浏览器确认无 Console 错误。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/ScriptView.vue
|
||||
git commit -m "feat(mini-program): sendChat 按 generationPhase 切换接口,消息进统一对话流"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 端到端浏览器验证
|
||||
|
||||
**Files:**
|
||||
- 无代码改动,纯浏览器验证
|
||||
|
||||
- [ ] **Step 1: 确认 H5 服务运行**
|
||||
|
||||
Run: `python dev-services.py status`
|
||||
Expected: mini-program 端口 5180 运行中
|
||||
|
||||
若未运行,启动:`cd mini-program && npm run dev:h5 -- --host 127.0.0.1 --port 5180`(后台运行)
|
||||
|
||||
- [ ] **Step 2: Playwright 验证完整对话流程**
|
||||
|
||||
使用 Playwright MCP 打开 http://localhost:5180:
|
||||
|
||||
1. 登录(手机号 13800138000 + 验证码 123456)
|
||||
2. 进入心愿页,输入"今天我想当 CEO",发送
|
||||
3. **验证**:进入 chat 对话页,用户心愿作为气泡显示在对话流
|
||||
4. **验证**:澄清卡片作为 AI 消息出现在对话流(不在独立页面)
|
||||
5. 选择选项,提交
|
||||
6. **验证**:卡片变为"已回答"态,user 消息追加,下一轮澄清或大纲出现
|
||||
7. 若进入大纲:**验证**大纲作为 AI 消息出现(带确认/修改按钮)
|
||||
8. 确认大纲
|
||||
9. **验证**:小说流式生成作为一条 AI 消息逐字出现
|
||||
10. novel_done 后:**验证**底部输入栏出现,可输入修改建议
|
||||
|
||||
- [ ] **Step 3: 验证继续创作(DAILY_GENERATION_IN_PROGRESS)**
|
||||
|
||||
若触发每日限制:
|
||||
1. **验证**:对话流内显示失败提示 + "继续创作"按钮
|
||||
2. 点击"继续创作"
|
||||
3. **验证**:恢复会话,新澄清/大纲作为消息追加到对话流
|
||||
|
||||
- [ ] **Step 4: 检查 Console 和 Network**
|
||||
|
||||
- Console:无业务错误(`uni.getRecorderManager not supported` 平台限制可忽略)
|
||||
- Network:`/shortNovel/stream` 和 `/shortNovel/followup` 返回 SSE 事件流,无 4xx/5xx
|
||||
|
||||
- [ ] **Step 5: 验收报告**
|
||||
|
||||
输出验收报告:每个步骤通过/失败 + 截图证据 + 最终结论
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 提交心愿后进入 chat 对话页,用户心愿作为气泡显示
|
||||
- [ ] 澄清卡片作为 AI 消息出现在对话流(非独立页面)
|
||||
- [ ] 卡片提交后变"已回答"态,user 消息追加
|
||||
- [ ] 大纲作为 AI 消息出现,确认/修改后按钮消失
|
||||
- [ ] 小说流式生成作为一条 AI 消息逐字出现
|
||||
- [ ] novel_done 后底部输入栏出现,继续修改用旧接口工作
|
||||
- [ ] 失败时对话流内显示"继续创作"入口
|
||||
- [ ] 整个过程不切换视图,用户始终在同一对话页
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
---
|
||||
author: Peanut
|
||||
created_at: 2026-06-28
|
||||
purpose: 修复生产环境首页 404,删除 nginx 配置中显式阻断根路径的 location 块
|
||||
---
|
||||
|
||||
# 修复 nginx 根路径 404 设计
|
||||
|
||||
## 背景
|
||||
|
||||
生产环境 `https://lifescript.happylifeos.com/` 访问首页返回 404 Not Found,但:
|
||||
- 前端文件 `/data/www/emotion-museum/index.html` 实际已正确部署
|
||||
- 子路径 `https://lifescript.happylifeos.com/emotion-museum-admin/`、`/life-script/`、`/emotion-museum/` 可以正常访问
|
||||
- 后端 API 代理 `/api` 正常工作
|
||||
|
||||
## 根因(实际)
|
||||
|
||||
**真实生效的 nginx 配置不在 `conf/emotion-museum.conf`**,而是在 aaPanel 托管的:
|
||||
`/www/server/panel/vhost/nginx/emotion-museum.conf`
|
||||
|
||||
原因:nginx 主配置 `/www/server/nginx/conf/nginx.conf` 的 include 顺序是:
|
||||
```nginx
|
||||
include /www/server/panel/vhost/nginx/*.conf;
|
||||
```
|
||||
它只 include aaPanel 的 vhost 目录。`/etc/nginx/sites-available/` 是部署脚本的"期望"路径,但**从未被 include**,所以 `deploy.py nginx` 实际上一直没真正生效。
|
||||
|
||||
真实配置中第 31-33 行有一个显式阻断根路径的 exact match:
|
||||
|
||||
```nginx
|
||||
location = / {
|
||||
return 404;
|
||||
}
|
||||
```
|
||||
|
||||
并且前端在真实配置中映射的路径是 `/emotion-museum/`(不是根路径):
|
||||
|
||||
```nginx
|
||||
location /emotion-museum/ {
|
||||
alias /data/www/emotion-museum/;
|
||||
...
|
||||
try_files $uri $uri/ /emotion-museum/index.html;
|
||||
}
|
||||
```
|
||||
|
||||
所以访问 `/` 时,被 `location = / { return 404; }` 精确匹配拦截,根本不会走到任何 location 块。
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 第一层:修改真实生效的 aaPanel 配置
|
||||
|
||||
直接 ssh 编辑 `/www/server/panel/vhost/nginx/emotion-museum.conf`,把:
|
||||
|
||||
```nginx
|
||||
location = / {
|
||||
return 404;
|
||||
}
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```nginx
|
||||
location = / {
|
||||
rewrite ^ /emotion-museum/ permanent;
|
||||
}
|
||||
```
|
||||
|
||||
这样根路径会被 301 永久重定向到 `/emotion-museum/`(前端实际部署位置)。
|
||||
|
||||
然后 `nginx -t && nginx -s reload` 让配置生效。
|
||||
|
||||
### 第二层:同步更新仓库内 `conf/emotion-museum.conf`
|
||||
|
||||
保持仓库内的配置文件与真实服务器一致(作为基线参考)。同步修改:
|
||||
1. 删除之前的 `location / { alias /data/www/emotion-museum/; ... }` 通用块(真实配置没有)
|
||||
2. 新增 `location /emotion-museum/ { ... }` 前端块(与真实配置对齐)
|
||||
3. `location = /` 改为 `rewrite ^ /emotion-museum/ permanent;`
|
||||
|
||||
**注意**:虽然仓库内配置现在不被真实 nginx include,但保持同步是为了:
|
||||
- 作为文档,记录服务器实际配置
|
||||
- 未来如果切回 sites-available 部署模式,能直接生效
|
||||
- 避免"仓库配置"和"服务器配置"分叉导致混乱
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. ssh 直接编辑服务器上的 `/www/server/panel/vhost/nginx/emotion-museum.conf`,改 `location = /` 块
|
||||
2. `nginx -t` 验证语法
|
||||
3. `nginx -s reload` 重载配置
|
||||
4. 浏览器访问 `https://lifescript.happylifeos.com/` 确认 301 → `https://lifescript.happylifeos.com/emotion-museum/`
|
||||
5. `/emotion-museum/` 正常返回前端页面(200)
|
||||
6. DevTools Network 无 404、Console 无报错
|
||||
7. 其他子路径(`/emotion-museum-admin/`、`/life-script/`、`/api/`、`/ws`、`/health`)不受影响
|
||||
8. 同步更新 `conf/emotion-museum.conf` 作为仓库基线
|
||||
9. 提交 git commit
|
||||
|
||||
## 风险与回退
|
||||
|
||||
- **影响面小**:只改 1 个 location 块(3 行),其他 location 块完全不动
|
||||
- **回退方案**:若出问题,把 `rewrite ^ /emotion-museum/ permanent;` 改回 `return 404;` 即可立即回滚
|
||||
- **不会冲突**:`/emotion-museum/`、`/emotion-museum-admin/`、`/life-script/` 都有自己的 location 块,`location = /` 精确匹配只对根路径生效
|
||||
|
||||
## 长期改进(不在本次范围)
|
||||
|
||||
`deploy.py nginx` 当前部署到 `/etc/nginx/sites-available/`,但服务器主配置 include 的是 aaPanel 路径,所以部署脚本实际上一直无效。后续应该:
|
||||
- 让 `deploy.py nginx` 同时更新 `/www/server/panel/vhost/nginx/emotion-museum.conf`
|
||||
- 或者在 nginx 主配置中增加 `include /etc/nginx/sites-enabled/*.conf;`
|
||||
- 本次不处理,单独跟踪
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
author: Peanut
|
||||
created_at: 2026-06-28
|
||||
purpose: 回滚 ScriptView 的临时 conversationId 正则短路保护,修复历史剧本消息卡片不显示的回归
|
||||
---
|
||||
|
||||
# 回滚 ScriptView 临时 ID 正则短路保护
|
||||
|
||||
## 背景
|
||||
|
||||
commit 19337b9("ScriptView loadMessages 对临时 conversationId 正则短路保护")引入了一个回归 bug:
|
||||
用户从"历史剧本"列表点进详情再点"继续"进入 ScriptView,**看不到完整的消息卡片**。
|
||||
|
||||
## 根因
|
||||
|
||||
DB 中历史剧本的 `conversation_id` 字段值就是 `conv-<ts>-<rand>` 格式(前端在早期版本自造后写入):
|
||||
|
||||
```
|
||||
conv-1782552967659-615gen
|
||||
conv-1782228787632-fkifia
|
||||
conv-1781497026473-7l8jco
|
||||
...
|
||||
```
|
||||
|
||||
这些 conversationId 完全匹配 commit 19337b9 加的正则 `^conv-\d+-[a-z0-9]{6}$`。
|
||||
并且这些 conversation 在 `t_conversation` 表中大多没有对应记录(`current_version_message_id` 字段全为 NULL,佐证了当时 conversation 没有完整落库)。
|
||||
|
||||
用户打开历史剧本的 ScriptView 时:
|
||||
1. 从 store 拿到 script,`conversationId = script.conversationId` = `conv-1782552967659-615gen`
|
||||
2. 进入 `loadMessages()`
|
||||
3. 正则匹配 + `backedByScript=false`(script 上没有 conversationId 背书?错,有背书但被误判)
|
||||
4. 实际上 `currentResult.value.conversationId` 在 `openScriptChat` 里被赋值为 `script.conversationId`,但 `openScriptChat` 是在 ScriptView 页内调用的,详情页 "继续" 按钮走的是 `continueCurrent` → `uni.reLaunch({ url: '/pages/main/index?tab=script' })`,ScriptView 重新初始化时 `currentResult.value` 还未被赋值
|
||||
5. 正则保护触发 → `messages.value = []` → **所有消息卡片不显示**
|
||||
|
||||
## 修复方案
|
||||
|
||||
**删除** commit 19337b9 在 `mini-program/src/pages/main/ScriptView.vue` 的 `loadMessages` 中添加的正则短路保护代码。恢复为:
|
||||
|
||||
```javascript
|
||||
const loadMessages = async () => {
|
||||
if (!conversationId.value) return
|
||||
try {
|
||||
const res = await listMessagesByConversation({ conversationId: conversationId.value, includeVersions: false })
|
||||
messages.value = res.data || []
|
||||
const scriptMessages = messages.value.filter(m => m.type === 'script')
|
||||
if (scriptMessages.length > 0) {
|
||||
const rootMessage = scriptMessages[0]
|
||||
const versionRes = await listMessageVersions(rootMessage.id)
|
||||
versions.value = versionRes.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
uni.showToast({ title: error.message || '加载消息失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**为什么可以直接回滚**:
|
||||
- commit e3eb1af 已让后端 `listByConversation` 在 conversation 不存在时返回空列表(不抛 500)
|
||||
- 所以前端调 API 时即使传了"不存在"的 conversationId,也只会得到空数据,不会再 500
|
||||
- 5xx 噪音问题已由后端修复兜底,前端的正则保护属于过度防御
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. 修改 `mini-program/src/pages/main/ScriptView.vue`,删除正则短路保护
|
||||
2. 启动 H5:`python dev-services.py start mini-program`(端口 5180)
|
||||
3. 浏览器访问 `http://localhost:5180`,登录一个有历史剧本的账号
|
||||
4. 进入任一历史剧本的 ScriptView
|
||||
5. 验证:
|
||||
- 不再看到正则保护导致的"消息全空"
|
||||
- Network 面板有 `/message/listByConversation` 请求
|
||||
- 响应状态 200,body 为 `{"code":200,"data":[]}`(conversation 不存在)或有数据
|
||||
- Console 无报错
|
||||
|
||||
## 风险与回退
|
||||
|
||||
- **回退方案**:如果回滚后出现 500 噪音,可以再在前端加判断(但后端已兜底,不应再出现)
|
||||
- **影响面**:仅影响 ScriptView.vue 的 loadMessages 函数,其他页面不受影响
|
||||
|
||||
## 长期改进(不在本次范围)
|
||||
|
||||
历史剧本的 conversation_id 是前端自造的临时 ID,但被当作"真实 conversationId"写入了 `t_epic_script.conversation_id`。这是数据模型上的不一致:
|
||||
- 要么在写入 epic_script 时同步创建 t_conversation 记录
|
||||
- 要么 epic_script.conversation_id 字段只存真实落库的 ID
|
||||
- 本次不处理,单独跟踪
|
||||
@@ -0,0 +1,286 @@
|
||||
---
|
||||
author: Peanut
|
||||
created_at: 2026-06-29
|
||||
purpose: 完整修复 ScriptView 对话模式的按钮缺失问题,补齐 spec 要求的版本/改写/续写/历史版本/删除功能
|
||||
---
|
||||
|
||||
# ScriptView 对话模式按钮完整修复设计
|
||||
|
||||
## 背景
|
||||
|
||||
`bf6f279 feat: ScriptView 改造为对话中心查看/修改模式` 重构后,对话模式的按钮出现多处丢失:
|
||||
|
||||
1. **旧版按钮丢失**:`MessageCard` 在 `isShortMessage=true`(短消息)分支没有按钮组,而旧版 assistant 消息后面有「复制/换个方向/不像我/继续生成/播放」按钮组(commit `0db434c` 添加)
|
||||
2. **Spec 功能未实现**:`2026-06-28-script-dialogue-design.md` 第 5.4 节要求 AI 剧本消息有「改写 / 续写 / 查看历史版本 / 删除版本」按钮,当前完全没实现
|
||||
3. **根版本被过滤**:`displayMessages` 用 `m.type !== 'script' || !m.parentMessageId` 过滤,把根版本 AI 剧本消息丢掉了
|
||||
4. **版本标签缺失**:AI 消息没有 `V1 / V2` 版本号标签和「(当前)」标记
|
||||
|
||||
## 设计
|
||||
|
||||
### 1. MessageCard.vue 重构
|
||||
|
||||
**新增 props**:
|
||||
- `messageType`: `'script' | 'chat' | 'system'`,默认 `'script'`
|
||||
- `versionLabel`: 版本号显示(如 `V1 (当前)`),script 类型必填,其他类型不传
|
||||
- `hasChildren`: 是否有子版本(布尔,用于控制「查看历史版本」按钮是否显示)
|
||||
- `canDelete`: 是否可删除(布尔,当前生效版本不可删除)
|
||||
- `canRewrite`: 是否可改写(布尔,默认 true)
|
||||
- `canContinue`: 是否可续写(布尔,默认 true)
|
||||
|
||||
**渲染规则**:
|
||||
|
||||
| messageType | 渲染方式 |
|
||||
|---|---|
|
||||
| `'script'` | **永远**走长文本 story-card 分支(不论 `isShortMessage`),因为剧本消息总是需要按钮组 |
|
||||
| `'chat'` 或 `'system'` | 保持原逻辑(短消息气泡 / 长消息 story-card) |
|
||||
|
||||
**按钮组**(按 messageType 切换):
|
||||
|
||||
```
|
||||
script 消息按钮组:
|
||||
┌─ 顶部操作(story-head-actions,不变)
|
||||
│ · 收起/展开
|
||||
│ · 复制
|
||||
├─ 版本标签(新增,仅在 versionLabel 有值时显示)
|
||||
│ · V1 (当前) / V2 / ...
|
||||
├─ 功能按钮(新增,script 专属)
|
||||
│ · 改写(emit 'rewrite')
|
||||
│ · 续写(emit 'continue-script')
|
||||
│ · 查看历史版本(emit 'view-versions',hasChildren=true 才显示)
|
||||
│ · 删除版本(emit 'delete-version',canDelete=true 才显示)
|
||||
└─ 通用操作(保留旧版)
|
||||
· TTS 播放(emit 'play-tts')
|
||||
|
||||
chat 消息按钮组(isShortMessage=false 时):
|
||||
┌─ 复制
|
||||
├─ 收起/展开
|
||||
└─ TTS 播放(emit 'play-tts')
|
||||
|
||||
system 消息(isShortMessage=true 默认走气泡):
|
||||
└─ 不变
|
||||
```
|
||||
|
||||
**新增 emits**:
|
||||
- `rewrite` — 改写按钮
|
||||
- `continue-script` — 续写按钮(避免和旧 `continue` 语义冲突,但保留旧 `continue` 兼容)
|
||||
- `view-versions` — 查看历史版本
|
||||
- `delete-version` — 删除版本
|
||||
|
||||
### 2. ScriptView.vue 改动
|
||||
|
||||
**改动 2.1:显示根版本**
|
||||
|
||||
```js
|
||||
// 旧
|
||||
const displayMessages = computed(() => {
|
||||
return messages.value.filter(m => m.type !== 'script' || !m.parentMessageId)
|
||||
})
|
||||
|
||||
// 新:保留所有消息,根版本作为 chat 列表首条
|
||||
const displayMessages = computed(() => messages.value)
|
||||
```
|
||||
|
||||
**改动 2.2:传 messageType 给 MessageCard**
|
||||
|
||||
```vue
|
||||
<MessageCard
|
||||
v-if="message.sender === 'assistant' || message.type === 'script'"
|
||||
message-type="script"
|
||||
:version-label="formatVersionLabel(message)"
|
||||
:has-children="messageHasChildren(message)"
|
||||
:can-delete="message.id !== currentVersionMessageId"
|
||||
:can-rewrite="true"
|
||||
:can-continue="true"
|
||||
:content="message.content"
|
||||
:collapsed="isMessageCollapsed(message)"
|
||||
:content-length="message.content.length"
|
||||
:is-short-message="false"
|
||||
:tts-icon="..."
|
||||
:tts-text="..."
|
||||
@toggle-collapse="toggleMessageCollapse(message)"
|
||||
@copy="copyMessageContent(message)"
|
||||
@rewrite="rewriteMessage(message)"
|
||||
@continue-script="continueMessage(message)"
|
||||
@view-versions="viewMessageVersions(message)"
|
||||
@delete-version="removeVersion(message)"
|
||||
@play-tts="playMessageTts(message)"
|
||||
@change-direction="changeDirection"
|
||||
@not-like-me="notLikeMe"
|
||||
/>
|
||||
```
|
||||
|
||||
**改动 2.3:版本标签生成函数**
|
||||
|
||||
```js
|
||||
const formatVersionLabel = (message) => {
|
||||
if (message.type !== 'script') return ''
|
||||
const versionNum = message.versionNumber || 1
|
||||
const isCurrent = message.id === currentVersionMessageId.value
|
||||
return isCurrent ? `V${versionNum} (当前)` : `V${versionNum}`
|
||||
}
|
||||
|
||||
const messageHasChildren = (message) => {
|
||||
// 通过 versions 数组长度判断(已加载的)或 metadata 推断
|
||||
return versions.value.some(v => v.parentMessageId === message.id)
|
||||
|| (message.versionNumber && message.versionNumber > 1)
|
||||
|| (message.childrenCount && message.childrenCount > 0)
|
||||
}
|
||||
```
|
||||
|
||||
**改动 2.4:查看历史版本**
|
||||
|
||||
新增 `viewMessageVersions(msg)` 函数:
|
||||
1. 调用 `listMessageVersions(msg.id)`
|
||||
2. 用 `uni.showActionSheet` 列出所有版本(V1、V2...)
|
||||
3. 用户选择后:
|
||||
- 如果选的是当前版本:`uni.showToast` 提示「当前已生效」
|
||||
- 如果选的是其他版本:调用 `switchVersion({ scriptId, messageId: selectedId })` → 更新 `currentVersionMessageId` → `loadMessages()` 刷新
|
||||
|
||||
### 4. 历史剧本兼容层(高优先级)
|
||||
|
||||
### 现状
|
||||
|
||||
DB 中绝大多数历史剧本的 `conversation_id` 是前端早期自造的临时 ID(`conv-<ts>-<rand>` 格式),**`t_conversation` 表中没有对应记录**。
|
||||
|
||||
```
|
||||
329987360219471872 我的人生剧本 → YES (最新剧本,走新流程创建)
|
||||
f82818b702d5ad4a... 高考 → NO
|
||||
a3dadb7e85a6c75b... 我高考了 → NO
|
||||
49ae0f432253aaff... 我中了100w → NO
|
||||
... 其余 6 条 → NO
|
||||
```
|
||||
|
||||
### 影响分析
|
||||
|
||||
| 场景 | 是否受影响 | 原因 |
|
||||
|---|---|---|
|
||||
| ScriptDetailView 阅读模式 | ✅ 正常 | 读 `t_epic_script.plotJson`,不依赖 conversation |
|
||||
| ScriptView `viewMode='read'` 阅读模式 | ✅ 正常 | 通过 `currentScriptContent` 读 `currentVersionMessageId` 指向的 message;fallback 到 `plotJson` |
|
||||
| ScriptView `viewMode='chat'` 聊天模式 | ❌ 异常 | `listByConversation` 返回空,对话历史丢失 |
|
||||
| 改写 / 续写 / 版本切换 | ❌ 异常 | 需要有效的 conversation 和 message 才能操作 |
|
||||
|
||||
### 兼容策略:前端 fallback + 后端自动建表
|
||||
|
||||
**改动 A:后端自动建 conversation**
|
||||
|
||||
在 `EpicScriptServiceImpl.getScriptById`(或 `getListByCurrentUser` 返回前)检查:
|
||||
- 若 `script.conversation_id` 指向的 conversation 不存在 → 自动创建一条 `t_conversation` 记录,写入 `user_id`、`script_id`、`type='script'`、`status='active'`,并回填 `t_epic_script.conversation_id` 为新 ID
|
||||
|
||||
**改动 B:前端首次进入聊天模式时,基于 `plotJson` 初始化首条消息**
|
||||
|
||||
在 ScriptView.vue 的 `loadMessages` 中:
|
||||
- 若 API 返回空列表 && `script.plotJson?.fullContent` 存在 → 自动调用 `/message/create` 创建一条 `type='script'` 的 AI 剧本消息(content 来自 plotJson)
|
||||
- 后续改写/续写/版本切换基于这条真实消息操作
|
||||
|
||||
**改动 C:ScriptView 阅读模式 fallback 强化**
|
||||
|
||||
当前 `currentScriptContent` 已在 message 不存在时 fallback 到 `plotJson`。需确保:
|
||||
- `viewMode='read'` 下,版本标签在没有 versions 数据时隐藏(不显示"V0"之类)
|
||||
- "进入对话修改"按钮在没有 conversation 时仍可点击,触发改动 B 的初始化流程
|
||||
|
||||
### 验收标准(历史剧本)
|
||||
|
||||
- [ ] 历史剧本在 ScriptDetailView 完整显示标题、summary、content
|
||||
- [ ] 历史剧本在 ScriptView `viewMode='read'` 完整显示标题、章节内容(无版本标签时 UI 不异常)
|
||||
- [ ] 历史剧本点击"进入对话修改" → 自动创建 conversation + 初始化首条 AI 消息 → 进入 chat 模式
|
||||
- [ ] 历史剧本进入 chat 模式后,能看到完整对话历史(首条为用户心愿 + AI 剧本)
|
||||
- [ ] 历史剧本的"改写/续写/查看历史版本"功能可正常使用
|
||||
- [ ] 新创建的剧本所有功能不受影响(回归验证)
|
||||
|
||||
## 5. 实施顺序
|
||||
|
||||
| 顺序 | 任务 | 类型 |
|
||||
|---|---|---|
|
||||
| 1 | 后端 `getScriptById` 自动建 conversation | 数据兼容 |
|
||||
| 2 | 前端 MessageCard 重构 + 按钮组 | 功能补齐 |
|
||||
| 3 | 前端 ScriptView displayMessages 去掉根版本过滤 | Bug 修复 |
|
||||
| 4 | 前端 loadMessages 空结果时自动初始化首条消息 | 数据兼容 |
|
||||
| 5 | 前端 chat 模式传 messageType/versionLabel 等 props | 功能补齐 |
|
||||
| 6 | 端到端验收(新剧本 + 历史剧本) | 验收 |
|
||||
|
||||
## 6. 影响范围
|
||||
|
||||
| 文件 | 改动量 |
|
||||
|---|---|
|
||||
| `server/.../EpicScriptServiceImpl.java` | 小(getScriptById 检查 + 自动建 conversation) |
|
||||
| `mini-program/src/components/MessageCard.vue` | 中(+props、+emits、+按钮组条件渲染) |
|
||||
| `mini-program/src/pages/main/ScriptView.vue` | 中(displayMessages filter、MessageCard props、formatVersionLabel、viewMessageVersions、loadMessages 初始化) |
|
||||
|
||||
## 验收标准(代码层面)
|
||||
|
||||
- [ ] chat 模式下,根版本 AI 剧本消息显示为 chat 列表首条
|
||||
- [ ] AI 剧本消息显示 V1 / V2 版本号 + (当前) 标记
|
||||
- [ ] AI 剧本消息按钮组包含:收起/展开、复制、TTS 播放、改写、续写、查看历史版本、删除版本
|
||||
- [ ] 用户消息(type=chat)保持气泡样式,有收起/展开按钮
|
||||
- [ ] 点击「改写」弹出 modal 输入改写意图,提交后生成新版本
|
||||
- [ ] 点击「续写」直接生成新版本
|
||||
- [ ] 点击「查看历史版本」弹 ActionSheet 列出版本,切换后 UI 更新
|
||||
- [ ] 当前生效版本不显示「删除版本」按钮
|
||||
- [ ] 历史剧本进入 ScriptView chat 模式 → 自动创建 conversation + 初始化首条 AI 消息
|
||||
- [ ] 浏览器 Console 无报错
|
||||
- [ ] 不影响 read 模式(阅读模式保持现状)
|
||||
|
||||
## H5 端到端验收流程(强制)
|
||||
|
||||
**后端验证码**:`AuthServiceImpl.java:80` 已硬编码 `DEFAULT_SMS_CODE = "123456"`,无需改动。
|
||||
|
||||
**测试账号**:手机号 `19928748688`,验证码 `123456`。
|
||||
|
||||
### 步骤 1:启动本地 H5
|
||||
```bash
|
||||
python dev-services.py start mini-program
|
||||
# 访问 http://localhost:5180
|
||||
```
|
||||
|
||||
### 步骤 2:登录
|
||||
1. 打开 `http://localhost:5180`
|
||||
2. 输入手机号 `19928748688`
|
||||
3. **必须点击"发送验证码"按钮**(触发 `/auth/sms-code` 接口,后端写入 Redis 并返回固定验证码)
|
||||
4. 输入验证码 `123456`
|
||||
5. 提交登录
|
||||
|
||||
### 步骤 3:新剧本完整流程
|
||||
1. 首页输入心愿 → 生成第一个剧本
|
||||
2. 查看剧本详情 → 标题、summary、content 完整显示
|
||||
3. 点击"继续" → 进入 ScriptView chat 模式
|
||||
4. **验证**:
|
||||
- 用户心愿气泡显示
|
||||
- AI 剧本消息显示,带 `V1 (当前)` 版本标签
|
||||
- AI 消息按钮组包含:收起/展开、复制、TTS 播放、**改写**、**续写**、**查看历史版本**、**删除版本**(当前版本不显示删除)
|
||||
5. 点"改写" → 输入改写意图 → 生成新版本 V2
|
||||
6. 点"查看历史版本" → ActionSheet 列出 V1/V2,点击切换
|
||||
7. 点"续写" → 生成新内容
|
||||
|
||||
### 步骤 4:历史剧本完整流程
|
||||
1. 点"历史"按钮 → 进入剧本库
|
||||
2. 点任一**历史剧本**(非本次新生成的)→ 进入 ScriptDetailView
|
||||
3. **验证**:
|
||||
- 标题、summary、content 完整显示(读 `plotJson.fullContent`)
|
||||
- 风格、篇幅、字数正确
|
||||
4. 点"继续" → 进入 ScriptView
|
||||
5. **验证**:
|
||||
- 自动创建 `t_conversation`(DB 中新增记录)
|
||||
- 自动初始化首条 AI 消息(基于 plotJson)
|
||||
- chat 模式看到完整对话历史(心愿 + AI 剧本)
|
||||
- AI 消息带版本标签
|
||||
- 改写/续写/查看历史版本 可正常操作
|
||||
|
||||
### 步骤 5:Console 与 Network 检查
|
||||
- DevTools Console 无报错
|
||||
- Network 中所有 API 调用返回 2xx(无 5xx、无 401、无 403)
|
||||
- `listByConversation` 调用返回 200 且有数据
|
||||
|
||||
### 步骤 6:验收通过标准
|
||||
- ✅ 新剧本 + 历史剧本所有功能正常
|
||||
- ✅ 按钮组完整
|
||||
- ✅ 版本标签正确
|
||||
- ✅ Console 无报错
|
||||
- ✅ 历史剧本 conversation 自动创建成功
|
||||
|
||||
**未通过的处理**:任一验收项失败 → 停止 → 定位问题 → 修复 → 重新验收 → 直到全部通过才算完成
|
||||
|
||||
## 风险与回退
|
||||
|
||||
- **MessageCard 重构风险**:新增 props 都带默认值,不会破坏非对话模式(生成页、结果页)的现有用法
|
||||
- **向后兼容**:旧 `continue` emit 保留,新增 `continue-script` 作为 script 专属,避免语义冲突
|
||||
- **回退方案**:git revert 单个 commit 即可
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
author: Peanut
|
||||
created_at: 2026-06-29
|
||||
purpose: ScriptView 简化为纯对话流模式:删除 read/chat 二段切换 + 按旧版布局统一 AI 消息按钮组
|
||||
---
|
||||
|
||||
# ScriptView 简化为纯对话流模式
|
||||
|
||||
## 背景
|
||||
|
||||
`bf6f279 feat: ScriptView 改造为对话中心查看/修改模式` 引入了 read/chat 二段切换,但偏离了原始设计。用户的原始设计意图:
|
||||
|
||||
- **从剧本库点卡片进入**:直接以对话形式查看完整的从创建剧本开始的对话(用户心愿 → AI 剧本 → 改写对话 → 续写对话 → ...)
|
||||
- **AI 消息卡片**:按旧版布局展示按钮组
|
||||
- **不要二段切换**:不要先 read 再点按钮进 chat
|
||||
|
||||
## 旧版布局参考(来自 commit `cbf5157` 和 `0db434c`)
|
||||
|
||||
```
|
||||
MessageCard 顶部(story-head-actions):
|
||||
┌──────────────────────────────────┐
|
||||
│ [剧本标题] [收起/展开] [复制] │
|
||||
└──────────────────────────────────┘
|
||||
|
||||
MessageCard 底部(result-actions):
|
||||
┌──────────────────────────────────┐
|
||||
│ [换个方向] [不像我] [继续生成] [▶ 播放] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 设计
|
||||
|
||||
### 1. 入口流程简化
|
||||
|
||||
| 入口 | 改动 |
|
||||
|---|---|
|
||||
| 剧本库点卡片 | `openScriptChat` → 直接设 `viewState='result'`,`viewMode='chat'` |
|
||||
| 首页心愿输入生成新剧本 | 保持现状(home → generating → result) |
|
||||
| read 模式 | **整个删除** |
|
||||
|
||||
### 2. 删除 read 模式相关代码
|
||||
|
||||
**删除的位置**:
|
||||
|
||||
| 文件 | 位置 | 内容 |
|
||||
|---|---|---|
|
||||
| `mini-program/src/pages/main/ScriptView.vue` | template `view v-if="viewMode === 'read'"` | 整个版本切换器 + 章节展示 + read-action-bar |
|
||||
| `mini-program/src/pages/main/ScriptView.vue` | `selectedVersionMessageId` ref | 只被 read 模式用 |
|
||||
| `mini-program/src/pages/main/ScriptView.vue` | `onSelectVersion` | 版本切换(read 模式用) |
|
||||
| `mini-program/src/pages/main/ScriptView.vue` | `setAsCurrentVersion` | 设为当前版本(read 模式用) |
|
||||
| `mini-program/src/pages/main/ScriptView.vue` | `enterChatMode` | read → chat 切换(不再需要) |
|
||||
| `mini-program/src/pages/main/ScriptView.vue` | `rewriteCurrentVersion` | read 模式改写入口 |
|
||||
| `mini-program/src/pages/main/ScriptView.vue` | `continueCurrentVersion` | read 模式续写入口 |
|
||||
|
||||
`currentVersionMessageId` 保留:chat 模式下 AI 消息的"删除版本"按钮需要它来判断 `canDelete`。
|
||||
|
||||
### 3. 按旧版布局统一 AI 消息按钮组
|
||||
|
||||
**MessageCard 改造**:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 短消息气泡:保持现状 -->
|
||||
<view v-if="isShortMessage && messageType !== 'script'" class="chat-bubble system">
|
||||
<text>{{ content }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 长文本卡片:story-card -->
|
||||
<view v-else class="story-card" :class="{ collapsed }">
|
||||
<!-- 顶部 story-head-actions:收起/展开 + 复制 + 版本号 -->
|
||||
<view class="story-head">
|
||||
<view class="story-title-wrap">
|
||||
<text v-if="versionLabel" class="version-label">{{ versionLabel }}</text>
|
||||
<text class="story-title">{{ title || '我的人生剧本' }}</text>
|
||||
<view v-if="tags?.length" class="tag-row">
|
||||
<text v-for="tag in tags" :key="tag" class="tag">{{ tag }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="story-head-actions">
|
||||
<button class="collapse-icon" @click="$emit('toggle-collapse')">
|
||||
<text class="collapse-icon-text">{{ collapsed ? '展开' : '收起' }}</text>
|
||||
<view class="collapse-chevron" :class="{ down: collapsed }">
|
||||
<view></view><view></view>
|
||||
</view>
|
||||
</button>
|
||||
<button class="copy-card-btn" @click="$emit('copy')">复制</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 正文 -->
|
||||
<scroll-view v-if="collapsed" class="story-body-scroll" scroll-y>
|
||||
<text class="story-body" :selectable="true" :user-select="true">{{ content }}</text>
|
||||
</scroll-view>
|
||||
<text v-else class="story-body" :selectable="true" :user-select="true">{{ content }}</text>
|
||||
|
||||
<!-- 底部收起/展开行 -->
|
||||
<view class="collapse-row" @click="$emit('toggle-collapse')">
|
||||
<text class="collapse-row-text">{{ collapsed ? '展开全文' : '收起全文' }}</text>
|
||||
<view class="collapse-chevron" :class="{ down: collapsed }">
|
||||
<view></view><view></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部 result-actions:按旧版布局 + 加上 script 专属按钮 -->
|
||||
<view class="result-actions">
|
||||
<!-- 旧版基础按钮 -->
|
||||
<button class="action-btn" @click="$emit('change-direction')">换个方向</button>
|
||||
<button class="action-btn" @click="$emit('not-like-me')">不像我</button>
|
||||
<button v-if="canContinue" class="action-btn" @click="$emit('continue-script')">续写</button>
|
||||
|
||||
<!-- script 专属功能按钮 -->
|
||||
<button v-if="canRewrite" class="action-btn" @click="$emit('rewrite')">改写</button>
|
||||
<button v-if="hasChildren" class="action-btn" @click="$emit('view-versions')">查看历史版本</button>
|
||||
<button v-if="canDelete" class="action-btn danger" @click="$emit('delete-version')">删除版本</button>
|
||||
|
||||
<!-- TTS 播放 -->
|
||||
<button class="action-btn primary" @click="$emit('play-tts')">
|
||||
<text class="action-icon">{{ ttsIcon || '▶' }}</text>
|
||||
<text>{{ ttsText || '播放' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
**关键变化**:
|
||||
- `messageType === 'script'` 分支去掉(长文本统一渲染)
|
||||
- `result-actions` 在 script 类型下按旧版布局 + 加 script 专属按钮
|
||||
- 短消息气泡(chat/system)保持不变
|
||||
|
||||
### 4. ScriptView template 简化
|
||||
|
||||
**简化前的 read 模式 section**:
|
||||
```vue
|
||||
<view v-if="viewMode === 'read'">
|
||||
<view class="version-bar">...</view>
|
||||
<view class="script-read-content">...</view>
|
||||
<view class="read-action-bar">...</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
**删除**。
|
||||
|
||||
**简化后的 chat 模式 section**:
|
||||
```vue
|
||||
<view class="result-chat-view">
|
||||
<view class="conversation compact">
|
||||
<!-- 用户心愿气泡 -->
|
||||
<view class="chat-bubble user">
|
||||
<text>{{ wishText }}</text>
|
||||
<text class="bubble-time">{{ currentMessageTime }}</text>
|
||||
</view>
|
||||
<!-- 系统提示 -->
|
||||
<view class="chat-bubble system done">
|
||||
<text>心愿已实现,故事已为你展开</text>
|
||||
<text class="bubble-time">{{ currentResultTime }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 对话消息列表 -->
|
||||
<view v-if="displayMessages.length" class="result-chat-list">
|
||||
<view v-for="message in displayMessages" :key="message.id">
|
||||
<MessageCard
|
||||
v-if="message.sender === 'assistant' || message.type === 'script'"
|
||||
message-type="script"
|
||||
:version-label="formatVersionLabel(message)"
|
||||
:has-children="messageHasChildren(message)"
|
||||
:can-delete="message.id !== currentVersionMessageId && message.parentMessageId !== null"
|
||||
:can-rewrite="true"
|
||||
:can-continue="true"
|
||||
:content="message.content"
|
||||
:collapsed="isMessageCollapsed(message)"
|
||||
:content-length="message.content.length"
|
||||
:is-short-message="false"
|
||||
:tts-icon="..."
|
||||
:tts-text="..."
|
||||
@toggle-collapse="..."
|
||||
@copy="..."
|
||||
@rewrite="..."
|
||||
@continue-script="..."
|
||||
@view-versions="..."
|
||||
@delete-version="..."
|
||||
@play-tts="..."
|
||||
@change-direction="..."
|
||||
@not-like-me="..."
|
||||
/>
|
||||
<view v-else class="chat-bubble user">
|
||||
<text>{{ message.content }}</text>
|
||||
<text class="bubble-time">{{ message.time || formatMessageTime() }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 输入栏 -->
|
||||
<view class="result-chat-bar">...</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
### 5. ScriptView 的 `continue` / `change-direction` / `not-like-me` emits
|
||||
|
||||
旧版 emit 在 script-actions 下不存在,需要保留以兼容旧版按钮。
|
||||
|
||||
- `continue`(旧版 "继续生成"按钮)→ 触发 `continueMessage` 或 `continueInChat`
|
||||
- `change-direction`("换个方向")→ 触发 `changeDirection`
|
||||
- `not-like-me`("不像我")→ 触发 `notLikeMe`
|
||||
|
||||
**这些函数已经存在于 ScriptView.vue 中**(来自旧版代码)。直接绑定。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 从剧本库点卡片 → 直接进入对话流(无需点"进入对话修改")
|
||||
- [ ] 对话流完整显示:用户心愿 + AI 剧本 + 改写/续写对话
|
||||
- [ ] AI 消息卡片顶部:收起/展开、复制、版本号标签
|
||||
- [ ] AI 消息卡片底部:换个方向、不像我、续写、改写、查看历史版本、删除版本、TTS 播放
|
||||
- [ ] read 模式相关代码完全删除(不再有任何切换按钮)
|
||||
- [ ] Console 无报错
|
||||
|
||||
## 风险与回退
|
||||
|
||||
- **风险低**:只删除 read 模式 + 调整 MessageCard 按钮布局,不影响后端
|
||||
- **回退**:`git revert` 单个 commit 即可恢复
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-19
|
||||
purpose: 修复 chat 视图底部发送按钮过高过大、与输入框不等高的问题
|
||||
---
|
||||
|
||||
# Chat 视图发送按钮等高修复设计
|
||||
|
||||
## 概述
|
||||
|
||||
chat 视图底部 `.chat-input-bar` 中"发送"按钮(`.chat-send-btn`)的 `padding: 16rpx 32rpx` 撑大了按钮高度,与文本输入框 `.result-chat-input`(`min-height: 60rpx`)不等高,视觉上按钮比输入框大。
|
||||
|
||||
## 问题
|
||||
|
||||
- `.chat-send-btn { padding: 16rpx 32rpx; }` — 上下各 16rpx padding 撑大按钮
|
||||
- `.result-chat-input { min-height: 60rpx; }` — 输入框有 min-height,但实际渲染高度随内容变化
|
||||
- `.chat-input-bar { align-items: flex-end; }` — 底部对齐进一步放大不等高视觉差异
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 改动文件
|
||||
|
||||
- `mini-program/src/pages/main/ScriptView.vue`(样式部分)
|
||||
|
||||
### 改动 1:`.chat-send-btn` 改 padding + 设 height
|
||||
|
||||
```css
|
||||
.chat-send-btn {
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 32rpx;
|
||||
background: #087e8b;
|
||||
border-radius: 32rpx;
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
```
|
||||
|
||||
**关键变化**:
|
||||
- 新增 `height: 60rpx`:与 `.result-chat-input` 的 `min-height: 60rpx` 保持一致
|
||||
- `padding: 16rpx 32rpx` → `padding: 0 32rpx`:去掉上下 padding,靠 height+flex 居中文字
|
||||
- 新增 `display: flex; align-items: center; justify-content: center;`:文字垂直居中
|
||||
|
||||
### 改动 2:`.chat-input-bar` 改 `align-items`
|
||||
|
||||
```css
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
```
|
||||
|
||||
**关键变化**:
|
||||
- `align-items: flex-end` → `align-items: center`:按钮和输入框垂直居中对齐
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 发送按钮与文本输入框等高
|
||||
- [ ] 按钮文字垂直居中
|
||||
- [ ] mp-weixin 编译通过
|
||||
- [ ] H5 浏览器 Console 无报错
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-19
|
||||
purpose: 修复 ScriptView chat 视图底部 outline 输入框和按钮被主页 bottom-nav 遮挡的问题(架构层修正)
|
||||
---
|
||||
|
||||
# Chat 视图底部遮挡架构层修复设计
|
||||
|
||||
## 概述
|
||||
|
||||
前一轮 CSS 修复(`padding-bottom: 120rpx`)未能彻底解决 bottom-nav 遮挡问题:`.outline-feedback` 输入框的 placeholder "如需修改请输入意见" 被截断,"修改大纲" 和 "确认大纲" 按钮也无法完整可见。根因是 `.chat-page` 使用 `height: 100vh`,完全无视父级 `.content` 已有的 `padding-bottom: 132rpx`(已经为 bottom-nav 预留了空间)。
|
||||
|
||||
## 问题根因
|
||||
|
||||
主页面 `pages/main/index.vue` 结构:
|
||||
|
||||
```css
|
||||
.app-shell { height: 100vh; display: flex; flex-direction: column; }
|
||||
.content { flex: 1; height: 0; padding: 0 28rpx 132rpx; } /* 父级已经预留 132rpx */
|
||||
.bottom-nav { position: absolute; bottom: 0; height: 104rpx; z-index: 20; }
|
||||
```
|
||||
|
||||
ScriptView 组件被渲染在 `.content` 内。父级 `.content` 已经预留了 132rpx 底部空间给 bottom-nav,但 `.chat-page` 设置了 `height: 100vh`,这让它撑满整个视口高度,**无视了父级的 padding-bottom**,导致 chat 滚动区底部仍然被 bottom-nav 覆盖约 104-134rpx。
|
||||
|
||||
## 修复方案
|
||||
|
||||
让 `.chat-page` 填充父级内容区(不设固定视口高度),让父级已有的 132rpx padding-bottom 自然生效。
|
||||
|
||||
### 改动文件
|
||||
|
||||
- `mini-program/src/pages/main/ScriptView.vue`(样式部分)
|
||||
|
||||
### 改动 1:`.chat-page` 从 `height: 100vh` 改为 `height: 100%`
|
||||
|
||||
```css
|
||||
.chat-page {
|
||||
/* height: 100vh → height: 100% */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #13091f;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
```
|
||||
|
||||
**关键变化**:
|
||||
- `height: 100%`:填充父级 `.content` 的可用高度,父级的 132rpx padding-bottom 自然生效
|
||||
- 保留 `min-height: 0`:让 flex 子项可以正确收缩
|
||||
- 保留 `padding-bottom: env(safe-area-inset-bottom)`:适配 iPhone home indicator
|
||||
- 删除 `constant(safe-area-inset-bottom)`:iOS 11.0-11.2 已极少,无需兼容
|
||||
|
||||
### 改动 2:`.chat-scroll-content` 改回 `padding: 0 24rpx 40rpx`
|
||||
|
||||
```css
|
||||
.chat-scroll-content {
|
||||
padding: 0 24rpx 40rpx;
|
||||
}
|
||||
```
|
||||
|
||||
**为什么改回 40rpx**:
|
||||
- 父级 `.content` 已经预留 132rpx 给 bottom-nav
|
||||
- chat-page 用 `height: 100%` 后,滚动区底部已经被父级 padding 隔开
|
||||
- 不再需要额外的 120rpx padding(否则会双重叠加变成 252rpx 空白)
|
||||
|
||||
## 为什么这样能解决问题
|
||||
|
||||
1. 父级 `.content` 的 132rpx padding-bottom 创建了一个 "安全区"
|
||||
2. chat-page 用 `height: 100%` 填充父级内容可用高度(不含 132rpx 安全区)
|
||||
3. chat-scroll(flex: 1 + height: 100%)继承这个受限高度
|
||||
4. chat-scroll-content 在受限区域内滚动,底部自动避开 bottom-nav
|
||||
5. outline 输入框和按钮都在 chat-scroll-content 内,自然在安全区内
|
||||
6. placeholder "如需修改请输入意见" 完整显示,按钮可点击
|
||||
|
||||
## 不影响范围
|
||||
|
||||
- home 视图(`.wish-home`):独立样式,不受影响
|
||||
- generation 视图(`.generation-view`):独立样式,不受影响
|
||||
- 其他 message 类型(user bubble、novel、text、card-answered):样式不变
|
||||
- chat-input-bar:条件渲染在 scroll-view 外,不受影响
|
||||
- mp-weixin 滚动:`.chat-scroll` 仍保持 `flex: 1; height: 100%; min-height: 0`,原生 scroll-view 仍能正确计算可滚动区域
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] H5(http://localhost:5180)心愿实现 chat 视图:outline 输入框的 placeholder "如需修改请输入意见" 完整可见(不被截断)
|
||||
- [ ] "修改大纲" 和 "确认大纲" 按钮在 bottom-nav 上方完整可见可点击
|
||||
- [ ] 向上滚动时大刚内容(标题、beats、结局)正常显示
|
||||
- [ ] iPhone 真机(带 home indicator)底部也能完整看到所有内容
|
||||
- [ ] Console 无报错
|
||||
- [ ] mp-weixin 编译通过(`npm run build:mp-weixin` 无错误)
|
||||
- [ ] 编译产物中 `.chat-page` 的 `height` 为 `100%`,`padding-bottom` 含 `env(safe-area-inset-bottom)`
|
||||
- [ ] 编译产物中 `.chat-scroll-content` 的 `padding` 为 `0 24rpx 40rpx`
|
||||
- [ ] home 视图、generation 视图、chat-input-bar 布局不受影响
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-19
|
||||
purpose: 修复 ScriptView chat 视图 ClarificationCard 提交按钮被主页 bottom-nav 遮挡的问题
|
||||
---
|
||||
|
||||
# Chat 视图底部内边距修复设计
|
||||
|
||||
## 概述
|
||||
|
||||
小程序心愿实现页面(ScriptView)chat 视图的 ClarificationCard 提交按钮无法滚动到可见区域,根因是主页面 `pages/main/index.vue` 的 `.bottom-nav`(`position: absolute; bottom: 0; z-index: 20; height: 104rpx`)叠在 script-view 组件底部,遮挡了 chat 滚动内容的最后一段。
|
||||
|
||||
## 问题
|
||||
|
||||
用户在 chat 视图选择澄清卡片的选项后,需要点击卡片最下方的「提交」按钮继续流程,但:
|
||||
- 滚动内容被 bottom-nav 遮住约 104rpx
|
||||
- iPhone 真机底部 home indicator 进一步压缩可见区域
|
||||
- 用户能看到卡片上半部分(问题描述、选项、自定义输入框)但看不到「提交」按钮
|
||||
- 无法继续后续流程
|
||||
|
||||
## 修复方案
|
||||
|
||||
调整 chat 视图容器的底部内边距,让滚动内容可以滚到「提交」按钮完全可见在 bottom-nav 上方。
|
||||
|
||||
### 改动文件
|
||||
|
||||
- `mini-program/src/pages/main/ScriptView.vue`(样式部分)
|
||||
|
||||
### 改动 1:`.chat-page` 增加 iPhone 安全区内边距
|
||||
|
||||
```css
|
||||
.chat-page {
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #13091f;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
```
|
||||
|
||||
作用:让 chat 容器适配 iPhone 底部 home indicator 区域,避免聊天内容被 home indicator 遮挡。
|
||||
|
||||
### 改动 2:`.chat-scroll-content` 增加底部内边距
|
||||
|
||||
```css
|
||||
.chat-scroll-content {
|
||||
padding: 0 24rpx 120rpx;
|
||||
}
|
||||
```
|
||||
|
||||
作用:在 chat 滚动区底部留出 120rpx 空间:
|
||||
- 104rpx = bottom-nav 高度
|
||||
- 16rpx = 呼吸间距,避免按钮贴边
|
||||
|
||||
保证 ClarificationCard 的「提交」按钮可滚动到 bottom-nav 上方完整可见。
|
||||
|
||||
## 不影响范围
|
||||
|
||||
- home 视图:使用独立的样式(`.wish-home`),不受影响
|
||||
- chat-input-bar:条件渲染且在 scroll-view 外部,不受影响
|
||||
- 其他 message 类型(user bubble、outline、novel、error):样式结构不变
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 在 H5(http://localhost:5180)心愿实现 chat 视图,澄清卡片可滚动到「提交」按钮完全可见
|
||||
- [ ] 「提交」按钮在主页 bottom-nav 导航栏上方,无遮挡
|
||||
- [ ] iPhone 真机(带 home indicator)底部也能完整看到「提交」按钮
|
||||
- [ ] Console 无报错
|
||||
- [ ] mp-weixin 编译通过(`npm run build:mp-weixin` 无错误)
|
||||
- [ ] 点击「提交」后流程正常继续(追加 user 消息 + 调 followupStream)
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-19
|
||||
purpose: 修复心愿实现 chat 视图在等待接口响应期间没有 loading 标识、呈现空白等待状态的问题
|
||||
---
|
||||
|
||||
# 心愿实现 chat 视图 Loading 标识修复设计
|
||||
|
||||
## 概述
|
||||
|
||||
心愿实现页面(`ScriptView.vue` 的 chat 视图)在用户发送消息后、等待后端 SSE 接口响应的阶段,页面处于空白状态,没有任何 loading 标识,无法判断是否卡住。需要在等待期间展示动态 loading,提升等待体验。
|
||||
|
||||
## 问题根因
|
||||
|
||||
`ScriptView.vue` 中 chat-loading 组件的显示条件(line 177):
|
||||
|
||||
```html
|
||||
<view v-if="generating && generationStatus !== 'failed'" class="chat-loading">
|
||||
```
|
||||
|
||||
`runGeneration` 函数(line 1618-1672)中:
|
||||
|
||||
```js
|
||||
generating.value = true // Line 1630
|
||||
// ...
|
||||
try {
|
||||
currentStreamTask.value = startNovelStream({...}) // Line 1653 - 异步 fire-and-forget
|
||||
} finally {
|
||||
generating.value = false // Line 1671 - 立即执行!
|
||||
}
|
||||
```
|
||||
|
||||
**Bug**:`startNovelStream` 是异步流式调用(fire-and-forget,没有 await),`finally` 在流启动后**立即**把 `generating` 设回 `false`。结果:
|
||||
|
||||
1. 用户点发送 → `generating = true` → 切换到 chat 视图
|
||||
2. 几乎同时 → `finally` 执行 → `generating = false` → chat-loading 立即隐藏
|
||||
3. 在第一个 SSE 事件(`status`)到达前,页面只有用户消息气泡,下方空白
|
||||
4. 用户看到"发送 → 空白等待 → 突然跳出内容",无法判断系统是否在工作
|
||||
|
||||
## 修复方案
|
||||
|
||||
**用新的 computed `showChatLoading` 替换原有显示条件**,语义精确匹配"等待首个 assistant 响应":
|
||||
|
||||
```js
|
||||
const showChatLoading = computed(() => {
|
||||
if (generationPhase.value !== 'generating') return false
|
||||
if (generationStatus.value === 'failed') return false
|
||||
const lastMsg = resultMessages.value[resultMessages.value.length - 1]
|
||||
// 最后一条消息是 user(刚发送的请求),说明还没收到 assistant 响应
|
||||
return lastMsg && lastMsg.role === 'user'
|
||||
})
|
||||
```
|
||||
|
||||
```html
|
||||
<view v-if="showChatLoading" class="chat-loading">
|
||||
<view class="loading-orbit" :class="{ streaming: generationStatus === 'streaming' }">
|
||||
<view class="orbit-ring outer"></view>
|
||||
<view class="orbit-ring inner"></view>
|
||||
<view class="orbit-core"></view>
|
||||
</view>
|
||||
<text class="loading-copy">{{ generationCopy }}</text>
|
||||
</view>
|
||||
```
|
||||
|
||||
### 场景覆盖表
|
||||
|
||||
| 场景 | generationPhase | 最后一条消息 | loading 显示 |
|
||||
|---|---|---|---|
|
||||
| 用户刚发送 | 'generating' | user | ✅ 显示 |
|
||||
| SSE status 事件(processing 阶段)| 'generating' | user | ✅ 显示 |
|
||||
| `clarification_card` 到达 | 'generating' | assistant (card) | ❌ 隐藏 |
|
||||
| 用户回答澄清问题 | 'generating' | user | ✅ 显示 |
|
||||
| `outline_created` 到达 | 'generating' | assistant (outline) | ❌ 隐藏 |
|
||||
| 用户提交大纲修改意见 | 'generating' | user | ✅ 显示 |
|
||||
| `novel_start` 到达 | 'generating' | assistant (novel) | ❌ 隐藏 |
|
||||
| `novel_done` 到达 | 'done' | assistant | ❌ 隐藏 |
|
||||
|
||||
### 为什么这样设计
|
||||
|
||||
1. **用 `generationPhase` 而非 `generating`**:`generationPhase` 在 `runGeneration` 开始时设为 'generating',在 `novel_done` 时设为 'done',完整覆盖生成流程生命周期。`generating` 因为 try-finally 立即重置,不可靠。
|
||||
|
||||
2. **用最后一条消息的 role 判断**:assistant 消息(card/outline/novel)到达后,用户看到的是交互内容而非空白,loading 应自动隐藏。通过判断最后一条消息的 role,精确实现"等待首个响应"的语义。
|
||||
|
||||
3. **支持多轮交互**:用户回答澄清问题、提交大纲修改后,最后一条又变回 user,loading 自动重新显示。无需额外的 flag 管理。
|
||||
|
||||
## 改动文件
|
||||
|
||||
- `mini-program/src/pages/main/ScriptView.vue`
|
||||
- 新增 computed `showChatLoading`(在 `generationCopy` 附近,约 line 930-950)
|
||||
- 替换 line 177 的 `v-if` 条件
|
||||
|
||||
## 不影响范围
|
||||
|
||||
- `chat-loading` 组件的样式、动画内容保持不变
|
||||
- `generating` 变量的其他用途(按钮禁用、重试逻辑等)不变
|
||||
- `generationStatus`、`generationPhase`、`generationCopy` 等 computed 不变
|
||||
- `home` 视图、`generation` 视图、`chat-input-bar` 不受影响
|
||||
- `sendChat`、`sendRewrite`、`sendChatAsContinue` 等其他流程不受影响(它们本身有 await,generating 状态正确)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] H5(`http://localhost:5180`):点击发送后,chat 视图立即显示 loading 动画(loading-orbit + generationCopy 文本)
|
||||
- [ ] Loading 持续到 `clarification_card` / `outline_created` / `novel_start` 任一事件到达前
|
||||
- [ ] 澄清卡片 / 大纲 / 小说消息出现后,loading 自动隐藏
|
||||
- [ ] 用户回答澄清、提交大纲修改后,loading 重新显示直到下一个 assistant 响应到达
|
||||
- [ ] 失败状态(`generationStatus === 'failed'`)不显示 loading,显示 resume 按钮
|
||||
- [ ] 浏览器 Console 无新增错误
|
||||
- [ ] mp-weixin 编译通过(`npm run build:mp-weixin` 无 error)
|
||||
- [ ] 编译产物中 `.chat-loading` 的 v-if 使用 `showChatLoading`
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-19
|
||||
purpose: 修复澄清卡片已回答显示英文 value 而非中文 label,以及已回答状态不显示 AI 问题的问题
|
||||
---
|
||||
|
||||
# 澄清卡片已回答显示修复设计
|
||||
|
||||
## 概述
|
||||
|
||||
ScriptView chat 视图中,澄清卡片(ClarificationCard)提交后:
|
||||
1. **Bug 1**:"已回答:competition" 显示的是英文 value,而非中文 label("工作/学习上的事")
|
||||
2. **Bug 2**:已回答状态不显示 AI 的问题(如"你想重写今天的什么经历?")
|
||||
|
||||
根因:ClarificationCard 的 `handleSubmit` 发出的是选项的 value(如 'competition'),`submitClarification` 直接用 value 作为显示文本和后端 payload。
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 改动文件
|
||||
|
||||
- `mini-program/src/pages/main/ScriptView.vue`
|
||||
|
||||
### 改动 1:submitClarification 用 label 显示,value 发给后端
|
||||
|
||||
```js
|
||||
const submitClarification = (answer) => {
|
||||
if (answeringClarification.value) return
|
||||
const card = [...resultMessages.value].reverse().find(m => m.kind === 'card' && !m.submitted)
|
||||
if (card) {
|
||||
// 通过 value 查找选项的 label
|
||||
const option = card.card?.options?.find(opt => opt.value === answer)
|
||||
const label = option?.label || answer
|
||||
card.submitted = true
|
||||
card.answer = label // 显示用 label(中文)
|
||||
}
|
||||
// 用户气泡显示 label
|
||||
const displayAnswer = card?.card?.options?.find(opt => opt.value === answer)?.label || answer
|
||||
addResultMessage({ role: 'user', kind: 'text', content: displayAnswer })
|
||||
answeringClarification.value = true
|
||||
currentStreamTask.value = followupStream({
|
||||
sessionId: novelSessionId.value,
|
||||
action: 'answer_clarification',
|
||||
payload: { answer }, // 后端仍收 value(语义化)
|
||||
onEvent: handleShortNovelEvent,
|
||||
onError: (errMsg) => markGenerationFailed(errMsg)
|
||||
})
|
||||
setTimeout(() => { answeringClarification.value = false }, 200)
|
||||
}
|
||||
```
|
||||
|
||||
### 改动 2:card-answered 显示 AI 的问题
|
||||
|
||||
```html
|
||||
<view v-else class="card-answered">
|
||||
<text class="card-question-text">{{ msg.card?.question || '' }}</text>
|
||||
<text>已回答:{{ msg.answer }}</text>
|
||||
</view>
|
||||
```
|
||||
|
||||
新增 `.card-question-text` 样式(复用 ClarificationCard 的 `.card-question` 样式):
|
||||
|
||||
```css
|
||||
.card-question-text {
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16rpx;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
用户点选"工作/学习上的事"(value: 'competition')
|
||||
└─ ClarificationCard.handleSubmit
|
||||
└─ emit('submit', 'competition')
|
||||
└─ ScriptView.submitClarification('competition')
|
||||
├─ 查找 option → label = '工作/学习上的事'
|
||||
├─ card.answer = '工作/学习上的事'(显示)
|
||||
├─ addResultMessage({ content: '工作/学习上的事' })
|
||||
└─ followupStream({ payload: { answer: 'competition' } })(后端)
|
||||
```
|
||||
|
||||
## 不影响范围
|
||||
|
||||
- ClarificationCard.vue 组件本身不修改(保持 value 的 emit 行为)
|
||||
- 后端收到的 answer 仍是 value(语义化,便于判断选择)
|
||||
- outline、novel、text 等其他消息类型不受影响
|
||||
- mp-weixin 模板限制:`v-if` 不能用箭头函数,`submitClarification` 中查找 option 的逻辑在 JS 层完成
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 澄清卡片提交后,"已回答:"后面显示中文 label(如"工作/学习上的事")
|
||||
- [ ] 用户气泡显示中文 label(如"工作/学习上的事")
|
||||
- [ ] 已回答状态显示 AI 的问题(如"你想重写今天的什么经历?")
|
||||
- [ ] 后端收到的 answer 仍是 value(如 'competition')
|
||||
- [ ] 浏览器 Console 无新增错误
|
||||
- [ ] mp-weixin 编译通过
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-19
|
||||
purpose: 小程序收藏功能真实 API 化,沿用现有独立收藏表架构
|
||||
---
|
||||
|
||||
# 小程序收藏功能真实 API 化设计(v2)
|
||||
|
||||
## 概述
|
||||
|
||||
当前小程序的「收藏」功能(剧本收藏、人生事件收藏)完全基于前端 localStorage,未对接后端 API。
|
||||
|
||||
**重要发现**:剧本收藏后端 API 已经完整实现(独立的 `t_script_favorite` 表 + `ScriptFavoriteService` + Controller),但前端 `ScriptLibraryView.vue` 没有使用这些 API,还在用 localStorage。
|
||||
|
||||
本次修复:
|
||||
1. **剧本收藏**:前端从 localStorage 切换到现有 API
|
||||
2. **人生事件收藏**:仿照剧本收藏,新建独立的收藏表和 API,删除假接口
|
||||
|
||||
## 现状问题
|
||||
|
||||
### 剧本收藏
|
||||
|
||||
| 位置 | 问题 |
|
||||
|---|---|
|
||||
| `ScriptLibraryView.vue:159` | `localFavorites = ref(uni.getStorageSync('script_favorites') \|\| {})` |
|
||||
| `ScriptLibraryView.vue:319-369` | `toggleFavorite` / `deleteScriptFromFavorites` 只写 localStorage |
|
||||
| `ScriptLibraryView.vue:244` | `isFavorite` 从 `script.isFavorite \|\| localFavorites.value[id]` 读取 |
|
||||
| `services/epicScript.js:206-232` | ✅ 已有 `toggleFavoriteScript` / `checkFavoriteScript` API 封装,但前端未调用 |
|
||||
|
||||
**后端已实现**:
|
||||
- `POST /epicScript/favorite/toggle` — 切换收藏
|
||||
- `GET /epicScript/favorite/check?scriptId=xxx` — 检查单个剧本收藏状态
|
||||
- `GET /epicScript/favorite/page` — 分页查询收藏列表
|
||||
- `ScriptFavoriteService.getFavoritedScriptIds(Set<String>)` — 批量检查(Controller 未暴露)
|
||||
|
||||
### 人生事件收藏
|
||||
|
||||
| 位置 | 问题 |
|
||||
|---|---|
|
||||
| `life-event/detail.vue:144` | `isFavorite` 从 `event_favorite_${id}` localStorage 读 |
|
||||
| `life-event/detail.vue:315-316` | toggle 写/删 `event_favorite_${id}` localStorage |
|
||||
| `services/lifeEvent.js:67-69` | `favoriteEvent` 调用 `/lifeEvent/favorite-placeholder` 假接口 |
|
||||
| `LifeEventController.java:153-163` | `/lifeEvent/favorite-placeholder` 是空壳,返回 echo + `placeholder: true` |
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 存储方案(沿用现有架构)
|
||||
|
||||
**剧本收藏**:沿用现有 `t_script_favorite` 独立表(无需改动)。
|
||||
|
||||
**人生事件收藏**:仿照剧本,新建独立的 `t_event_favorite` 表。
|
||||
|
||||
```sql
|
||||
CREATE TABLE t_event_favorite (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '事件ID',
|
||||
create_by VARCHAR(64) COMMENT '创建人',
|
||||
create_time DATETIME COMMENT '创建时间',
|
||||
update_by VARCHAR(64) COMMENT '更新人',
|
||||
update_time DATETIME COMMENT '更新时间',
|
||||
is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是',
|
||||
remarks VARCHAR(500) COMMENT '备注',
|
||||
INDEX idx_user_event (user_id, event_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表';
|
||||
```
|
||||
|
||||
**选择独立表的理由**:
|
||||
1. 与剧本收藏架构保持一致,两套收藏机制对称
|
||||
2. 支持多用户场景(每个用户独立收藏)
|
||||
3. 支持分页查询"我的收藏"列表
|
||||
4. 逻辑删除保留历史记录
|
||||
|
||||
### 后端改动
|
||||
|
||||
#### 1. 人生事件收藏(新增)
|
||||
|
||||
**Entity**:`EventFavorite.java`
|
||||
|
||||
```java
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@TableName("t_event_favorite")
|
||||
public class EventFavorite extends BaseEntity {
|
||||
@TableField("user_id")
|
||||
private String userId;
|
||||
|
||||
@TableField("event_id")
|
||||
private String eventId;
|
||||
}
|
||||
```
|
||||
|
||||
**Mapper**:`EventFavoriteMapper.java`
|
||||
|
||||
```java
|
||||
@Mapper
|
||||
public interface EventFavoriteMapper extends BaseMapper<EventFavorite> {
|
||||
}
|
||||
```
|
||||
|
||||
**Service**:`EventFavoriteService.java` + `EventFavoriteServiceImpl.java`
|
||||
|
||||
```java
|
||||
public interface EventFavoriteService extends IService<EventFavorite> {
|
||||
EventFavoriteResponse toggleFavorite(String eventId);
|
||||
PageResult<LifeEventResponse> getFavoritePage(long current, long size);
|
||||
Set<String> getFavoritedEventIds(Set<String> eventIds);
|
||||
boolean isFavorited(String eventId);
|
||||
long getFavoriteCount();
|
||||
}
|
||||
```
|
||||
|
||||
实现完全仿照 `ScriptFavoriteServiceImpl`。
|
||||
|
||||
**DTO**:
|
||||
- `EventFavoriteToggleRequest.java`:`{ eventId: String }`
|
||||
- `EventFavoriteResponse.java`:`{ eventId, isFavorited, favoriteTime }`
|
||||
|
||||
**Controller**:`LifeEventController.java` 新增
|
||||
|
||||
```java
|
||||
// ==================== 人生事件收藏 ====================
|
||||
|
||||
@PostMapping(value = "/favorite/toggle")
|
||||
public Result<EventFavoriteResponse> toggleFavorite(
|
||||
@Valid @RequestBody EventFavoriteToggleRequest request) {
|
||||
return Result.success(eventFavoriteService.toggleFavorite(request.getEventId()));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/favorite/page")
|
||||
public Result<PageResult<LifeEventResponse>> getFavoritePage(
|
||||
@RequestParam(defaultValue = "1") long current,
|
||||
@RequestParam(defaultValue = "10") long size) {
|
||||
return Result.success(eventFavoriteService.getFavoritePage(current, size));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/favorite/check")
|
||||
public Result<EventFavoriteResponse> checkFavorite(@RequestParam String eventId) {
|
||||
boolean favorited = eventFavoriteService.isFavorited(eventId);
|
||||
EventFavoriteResponse r = new EventFavoriteResponse();
|
||||
r.setEventId(eventId);
|
||||
r.setIsFavorited(favorited);
|
||||
return Result.success(r);
|
||||
}
|
||||
```
|
||||
|
||||
**删除**:`/lifeEvent/favorite-placeholder` 假接口
|
||||
|
||||
#### 2. 剧本收藏(无需改动)
|
||||
|
||||
后端已完整,无需新增。
|
||||
|
||||
### 前端改动
|
||||
|
||||
#### 1. `ScriptLibraryView.vue`
|
||||
|
||||
**删除**:
|
||||
- `const localFavorites = ref(uni.getStorageSync('script_favorites') || {})`
|
||||
- `toggleFavorite` / `deleteScriptFromFavorites` 的 localStorage 写/删逻辑
|
||||
|
||||
**修改**:
|
||||
- `isFavorite` 计算改为:调用 `epicScriptService.checkFavoriteScript(script.id)` 或在列表加载后批量检查
|
||||
- `toggleFavorite` 改为调 `epicScriptService.toggleFavoriteScript(script.id)`
|
||||
|
||||
**优化**:为避免 N+1 请求,可在后端新增 `GET /epicScript/favorite/ids` 接口,返回当前用户所有收藏的 scriptId 集合,前端列表加载后一次性拿到。但本次先用 `checkFavoriteScript` 逐个检查,后续优化。
|
||||
|
||||
#### 2. `services/lifeEvent.js`
|
||||
|
||||
**修改**:
|
||||
```js
|
||||
// 之前:post('/lifeEvent/favorite-placeholder', { id, favorite })
|
||||
// 之后:
|
||||
export const toggleFavoriteEvent = async (eventId) => {
|
||||
const res = await post('/lifeEvent/favorite/toggle', { eventId })
|
||||
return {
|
||||
success: true,
|
||||
isFavorited: res.data?.isFavorited ?? false,
|
||||
favoriteTime: res.data?.favoriteTime ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export const checkFavoriteEvent = async (eventId) => {
|
||||
const res = await get('/lifeEvent/favorite/check', { eventId })
|
||||
return res.data?.isFavorited ?? false
|
||||
}
|
||||
```
|
||||
|
||||
**删除**:旧的 `favoriteEvent` 方法
|
||||
|
||||
#### 3. `life-event/detail.vue`
|
||||
|
||||
**删除**:
|
||||
- `const isFavorite = ref(Boolean(uni.getStorageSync('event_favorite_${id}')))`
|
||||
- `toggleFavorite` 的 localStorage 写/删
|
||||
|
||||
**修改**:
|
||||
- `isFavorite` 改为 `ref(false)`,页面加载时调用 `checkFavoriteEvent(id)` 初始化
|
||||
- `toggleFavorite` 改为调 `toggleFavoriteEvent(id)`
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
剧本列表页 ScriptLibraryView
|
||||
└─ getScriptList() (GET /epicScript/listAll)
|
||||
└─ 返回剧本列表
|
||||
└─ 对每个剧本调用 checkFavoriteScript(id) (GET /epicScript/favorite/check)
|
||||
└─ 返回 isFavorited
|
||||
└─ 前端渲染收藏状态
|
||||
|
||||
用户点剧本收藏
|
||||
└─ toggleFavoriteScript(id) (POST /epicScript/favorite/toggle)
|
||||
└─ 后端 t_script_favorite 表 insert/delete
|
||||
└─ 返回 isFavorited
|
||||
└─ 前端更新 UI
|
||||
|
||||
人生事件详情页 life-event/detail
|
||||
└─ getEventById(id) (GET /lifeEvent/detail)
|
||||
└─ 返回事件详情
|
||||
└─ checkFavoriteEvent(id) (GET /lifeEvent/favorite/check)
|
||||
└─ 返回 isFavorited
|
||||
└─ 前端渲染收藏状态
|
||||
|
||||
用户点事件收藏
|
||||
└─ toggleFavoriteEvent(id) (POST /lifeEvent/favorite/toggle)
|
||||
└─ 后端 t_event_favorite 表 insert/delete
|
||||
└─ 返回 isFavorited
|
||||
└─ 前端更新 UI
|
||||
```
|
||||
|
||||
## 不影响范围
|
||||
|
||||
- 列表渲染、筛选、排序逻辑不变,只换数据源(localStorage → API)
|
||||
- 详情页展示逻辑不变(数据源变化在 sub-project B 清理)
|
||||
- 其他页面的剧本/事件展示(如首页推荐)不受影响
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
新增迁移 SQL 文件 `server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE t_event_favorite (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '事件ID',
|
||||
create_by VARCHAR(64) COMMENT '创建人',
|
||||
create_time DATETIME COMMENT '创建时间',
|
||||
update_by VARCHAR(64) COMMENT '更新人',
|
||||
update_time DATETIME COMMENT '更新时间',
|
||||
is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是',
|
||||
remarks VARCHAR(500) COMMENT '备注',
|
||||
INDEX idx_user_event (user_id, event_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表';
|
||||
```
|
||||
|
||||
如果项目不用 Flyway,则手动执行或写入项目约定的 SQL 目录。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 剧本收藏:点击心形图标 → 调用 `POST /epicScript/favorite/toggle` → 后端字段更新 → 刷新页面后仍保持状态
|
||||
- [ ] 人生事件收藏:点击收藏 → 调用 `POST /lifeEvent/favorite/toggle` → 同上
|
||||
- [ ] 取消收藏:再次点击 → 同上
|
||||
- [ ] 列表页 `isFavorite` 来自 API,不再依赖 localStorage
|
||||
- [ ] 详情页 `isFavorite` 来自 API,不再依赖 localStorage
|
||||
- [ ] `/lifeEvent/favorite-placeholder` 假接口已删除
|
||||
- [ ] 浏览器 Console 无报错
|
||||
- [ ] 后端 `mvn clean install` 编译通过
|
||||
- [ ] 通过 `deploy.py backend` 部署到服务器后,在服务器环境验证
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-19
|
||||
purpose: ScriptView 心愿实现页面统一对话流改造设计,将澄清/大纲/小说生成全部纳入单一对话页面
|
||||
---
|
||||
|
||||
# ScriptView 统一对话页改造设计
|
||||
|
||||
## 概述
|
||||
|
||||
将小程序心愿实现页面(ScriptView.vue)的生成流程从"多独立视图切换"改为"单一对话页面流"。用户从提交心愿开始,始终停留在同一个对话页面,澄清问答、大纲确认/修改、小说流式生成、后续对话修改都作为对话消息依次出现在聊天流里。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
### 当前架构(AS-IS)
|
||||
|
||||
ScriptView.vue 用 `viewState` 在 5 个独立视图间切换:
|
||||
|
||||
| viewState | 内容 | 对话页? |
|
||||
|---|---|---|
|
||||
| `home` | 心愿输入首页(灵感推荐+输入框) | 否 |
|
||||
| `generating` | 生成中加载页(用户气泡+loading orbit) | 否 |
|
||||
| `clarifying` | 澄清卡片(只有 ClarificationCard) | 否 |
|
||||
| `outlining` | 大纲确认(大纲+确认/修改按钮) | 否 |
|
||||
| `result` | 结果对话页(MessageCard+用户气泡+输入栏) | 是 |
|
||||
|
||||
问题:澄清、大纲、小说生成阶段用户"不在对话页面",体验割裂。用户要求"心愿实现时必须一直处于对话的页面,必须支持对话和选择的澄清判断"。
|
||||
|
||||
### 可复用资产
|
||||
|
||||
`result` 视图已有完整对话机制:
|
||||
- `resultMessages` 数组 + `addResultMessage({id, role, kind, content, pending, time})`
|
||||
- `MessageCard` 组件渲染 AI 消息
|
||||
- 用户气泡渲染用户消息
|
||||
- 底部输入栏 `sendChat`(当前接旧接口 `streamScriptChat`)
|
||||
- 滚动控制(`keepResultAtBottom`、`resultScrollTop` 等)
|
||||
|
||||
## 目标架构(TO-BE)
|
||||
|
||||
### viewState 简化
|
||||
|
||||
从 5 个视图简化为 **2 个**:
|
||||
|
||||
```
|
||||
home(首页:灵感推荐+输入框,保留不变)
|
||||
↓ 用户提交心愿
|
||||
chat(统一对话页:澄清→大纲→小说→后续修改 全在这里)
|
||||
```
|
||||
|
||||
删除独立的 `generating`/`clarifying`/`outlining`/`novel-generating` 视图。
|
||||
|
||||
### 统一对话消息模型
|
||||
|
||||
复用 `resultMessages` + `addResultMessage`,扩展 `kind` 字段:
|
||||
|
||||
```javascript
|
||||
{
|
||||
id, // createMessageId()
|
||||
role, // 'user' | 'assistant'
|
||||
kind, // 'text' | 'card' | 'outline' | 'novel'
|
||||
content, // 文本内容(text/novel)
|
||||
pending, // 是否加载中(novel 流式时 true)
|
||||
time, // formatMessageTime()
|
||||
card?, // kind:'card' 时携带澄清卡片数据
|
||||
outline?, // kind:'outline' 时携带大纲数据
|
||||
submitted?, // kind:'card' 时标记是否已提交(禁用交互)
|
||||
confirmed?, // kind:'outline' 时标记是否已确认/修改(禁用交互)
|
||||
failed? // 是否失败(error 事件)
|
||||
}
|
||||
```
|
||||
|
||||
### 消息类型与渲染
|
||||
|
||||
`chat` 视图对话区用 `v-for` 遍历 `resultMessages`,根据 `kind` 渲染不同气泡内容:
|
||||
|
||||
| kind | role | 渲染方式 |
|
||||
|---|---|---|
|
||||
| `text` | user | 用户气泡(`chat-bubble user`) |
|
||||
| `text` | assistant | AI 文本气泡(`chat-bubble system`),含状态提示/错误 |
|
||||
| `card` | assistant | AI 气泡内嵌 `ClarificationCard`,`submitted` 后禁用交互并显示已选答案 |
|
||||
| `outline` | assistant | AI 气泡内嵌大纲卡片+确认/修改按钮,`confirmed` 后禁用按钮 |
|
||||
| `novel` | assistant | AI 气泡内嵌小说正文,`pending` 时流式逐字追加,`pending=false` 后完成 |
|
||||
|
||||
新消息追加到底部,`keepResultAtBottom()` 自动滚动跟随。
|
||||
|
||||
### SSE 事件 → 对话消息映射
|
||||
|
||||
`handleShortNovelEvent` 改为往统一对话流追加/更新消息:
|
||||
|
||||
| SSE 事件 | 对话流行为 |
|
||||
|---|---|
|
||||
| `status` | 更新底部 loading 提示文案(不追加独立消息) |
|
||||
| `clarification_card` | `addResultMessage({role:'assistant', kind:'card', card: payload.card, submitted:false})` |
|
||||
| `outline_created` | `addResultMessage({role:'assistant', kind:'outline', outline: payload.outline, confirmed:false})` |
|
||||
| `novel_start` | `addResultMessage({role:'assistant', kind:'novel', content:'', pending:true})` |
|
||||
| `novel_delta` | 往最后一条 `kind:'novel'` 消息的 `content` 追加 `payload.delta` |
|
||||
| `novel_done` | 标记最后一条 novel 消息 `pending=false`,保存 `scriptId`/`conversationId`/`currentVersionMessageId` |
|
||||
| `error` | 若 `DAILY_GENERATION_IN_PROGRESS` 且有 `session_id`:保存 `resumeableSessionId`,更新底部 loading 为失败态+继续创作入口;其他错误:追加 `kind:'text', role:'assistant', failed:true` 消息 |
|
||||
|
||||
### 用户交互 → 接口调用
|
||||
|
||||
每张卡片/大纲消息内部渲染交互按钮,用户操作时**先追加一条 user 消息到对话流,再调接口**:
|
||||
|
||||
| 阶段 | 交互 | 追加 user 消息 | 接口调用 |
|
||||
|---|---|---|---|
|
||||
| 生成 | ClarificationCard 提交 | `{kind:'text', content: 答案}` | `followupStream(action:'answer_clarification', payload:{answer})` |
|
||||
| 生成 | 大纲"确认" | `{kind:'text', content:'确认大纲'}` | `followupStream(action:'confirm_outline')` |
|
||||
| 生成 | 大纲"修改" | `{kind:'text', content: 修改意见}` | `followupStream(action:'modify_outline', payload:{feedback})` |
|
||||
| 生成 | 失败"继续创作" | `{kind:'text', content:'继续之前的创作'}` | `followupStream(action:'retry')` |
|
||||
| 后续 | novel_done 后底部输入 | `{kind:'text', content: 修改建议}` | `streamScriptChat`(旧接口,支持改写/继续/版本) |
|
||||
|
||||
### 接口切换边界
|
||||
|
||||
- **生成流程**(澄清→大纲→小说):用新接口 `startNovelStream` / `followupStream`
|
||||
- **novel_done 后**:保存记录后,底部输入栏 `sendChat` 切换用现有旧接口 `streamScriptChat` + `createMessage`(已成熟,支持改写/继续/版本切换)
|
||||
|
||||
切换时机:`novel_done` 事件处理时,设置 `generationPhase = 'done'`,底部输入栏根据该标志切换接口。两套接口的消息都进同一个 `resultMessages` 对话流,用户无感知。
|
||||
|
||||
## 交互态管理
|
||||
|
||||
### 卡片消息(kind:'card')
|
||||
|
||||
- 渲染 `ClarificationCard`,传入 `:card="message.card"`
|
||||
- `submitted=false` 时:正常交互,`@submit` 触发 `submitClarification`
|
||||
- `submitClarification` 内:设置 `message.submitted=true`(禁用卡片),追加 user 消息,调 followupStream
|
||||
- `submitted=true` 时:卡片显示"已回答:xxx",禁用交互
|
||||
|
||||
### 大纲消息(kind:'outline')
|
||||
|
||||
- 渲染大纲内容(title/logline/beats/ending)+ 确认/修改按钮
|
||||
- `confirmed=false` 时:显示输入框+确认+修改按钮
|
||||
- 用户确认/修改后:设置 `message.confirmed=true`(隐藏按钮),追加 user 消息,调 followupStream
|
||||
- `confirmed=true` 时:只展示大纲内容,不再有按钮
|
||||
|
||||
### 小说消息(kind:'novel')
|
||||
|
||||
- `pending=true` 时:流式追加 `novel_delta` 文本,显示打字光标
|
||||
- `pending=false`(novel_done)时:完整小说正文,无光标,可复制/朗读
|
||||
|
||||
## chat 视图模板结构
|
||||
|
||||
```vue
|
||||
<view v-else-if="viewState === 'chat'" class="chat-page">
|
||||
<!-- 顶部:历史 + 关闭 -->
|
||||
<view class="chat-top-actions">...</view>
|
||||
|
||||
<!-- 对话流 -->
|
||||
<scroll-view class="chat-scroll" scroll-y ...>
|
||||
<view class="chat-scroll-content">
|
||||
<view v-for="msg in resultMessages" :key="msg.id">
|
||||
<!-- user text -->
|
||||
<view v-if="msg.role==='user' && msg.kind==='text'" class="chat-bubble user">
|
||||
<text>{{ msg.content }}</text>
|
||||
<text class="bubble-time">{{ msg.time }}</text>
|
||||
</view>
|
||||
|
||||
<!-- assistant card -->
|
||||
<view v-else-if="msg.kind==='card'" class="chat-bubble system">
|
||||
<ClarificationCard
|
||||
v-if="!msg.submitted"
|
||||
:card="msg.card"
|
||||
@submit="answerClarification(msg, $event)"
|
||||
/>
|
||||
<view v-else class="card-answered">
|
||||
<text>已回答:{{ msg.answer }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- assistant outline -->
|
||||
<view v-else-if="msg.kind==='outline'" class="chat-bubble system">
|
||||
<!-- 大纲内联渲染(title/logline/beats/ending),复用当前 outlining 视图样式 -->
|
||||
<view v-if="msg.outline?.title" class="outline-title">{{ msg.outline.title }}</view>
|
||||
<view v-if="msg.outline?.logline" class="outline-logline">{{ msg.outline.logline }}</view>
|
||||
<view v-if="Array.isArray(msg.outline?.beats)" class="outline-beats">
|
||||
<view v-for="(beat, idx) in msg.outline.beats" :key="idx" class="beat-item">
|
||||
<text class="beat-order">{{ beat.order || idx + 1 }}</text>
|
||||
<view class="beat-content">
|
||||
<text class="beat-title">{{ beat.title }}</text>
|
||||
<text v-if="beat.summary" class="beat-summary">{{ beat.summary }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="msg.outline?.ending" class="outline-ending">
|
||||
<text class="ending-label">结局</text>
|
||||
<text class="ending-text">{{ msg.outline.ending }}</text>
|
||||
</view>
|
||||
<view v-if="!msg.confirmed" class="outline-actions">
|
||||
<input v-model="msg.feedback" placeholder="如需修改请输入意见" />
|
||||
<button @click="modifyOutline(msg)">修改大纲</button>
|
||||
<button @click="confirmOutline(msg)">确认大纲</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- assistant novel -->
|
||||
<view v-else-if="msg.kind==='novel'" class="chat-bubble system">
|
||||
<text>{{ msg.content }}<text v-if="msg.pending" class="typing-cursor">|</text></text>
|
||||
</view>
|
||||
|
||||
<!-- assistant text (status/error) -->
|
||||
<view v-else class="chat-bubble system">
|
||||
<text :class="{ failed: msg.failed }">{{ msg.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部 loading -->
|
||||
<view v-if="generating" class="chat-loading">
|
||||
<view class="loading-orbit streaming">...</view>
|
||||
<text>正在为你重写人生</text>
|
||||
</view>
|
||||
<!-- 失败继续创作入口 -->
|
||||
<view v-if="generationStatus==='failed' && resumeableSessionId" class="resume-action">
|
||||
<button @click="resumeSession">继续创作</button>
|
||||
</view>
|
||||
|
||||
<view :id="resultScrollAnchor" class="chat-scroll-anchor"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部输入栏(novel_done 后启用) -->
|
||||
<view class="chat-input-bar">
|
||||
<textarea v-model="chatInput" @confirm="sendChat" />
|
||||
<view @click="sendChat">发送</view>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
## 文件改动清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|---|---|---|
|
||||
| `mini-program/src/pages/main/ScriptView.vue` | 修改 | 删除 clarifying/outlining/novel-generating 独立视图;新增统一 chat 视图;扩展 addResultMessage 支持 kind/card/outline/submitted/confirmed;改造 handleShortNovelEvent 往对话流追加消息;submitClarification/confirmOutline/modifyOutline 改为操作对话消息;sendChat 按 generationPhase 切换接口 |
|
||||
| `mini-program/src/components/ClarificationCard.vue` | 不变 | 复用,由父组件控制 submitted 态 |
|
||||
|
||||
## 不在本次范围内
|
||||
|
||||
- 首页 home 的灵感推荐/语音输入 — 保留不变
|
||||
- MessageCard 的版本切换/改写/继续功能 — 保留不变(novel_done 后用旧接口时复用)
|
||||
- 后端接口 — 不变(新接口 followupStream + 旧接口 streamScriptChat 都已就绪)
|
||||
- 历史剧本库 — 保留不变
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 用户提交心愿后进入对话页,用户消息作为气泡显示
|
||||
- 澄清卡片作为 AI 消息出现在对话流,用户回答后卡片变为"已回答"态
|
||||
- 大纲作为 AI 消息出现,确认/修改后按钮消失
|
||||
- 小说流式生成作为一条 AI 消息逐字出现
|
||||
- novel_done 后底部输入栏启用,继续修改用旧接口正常工作
|
||||
- 失败时(DAILY_GENERATION_IN_PROGRESS)对话流内显示继续创作入口
|
||||
- 整个过程不切换视图,用户始终在同一个对话页面
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
author: AI Assistant
|
||||
created_at: 2026-07-19
|
||||
purpose: 小程序心愿实现页面的短篇小说生成接口从内部 AI Runtime 迁移到外部 short-novel-service 的设计方案
|
||||
---
|
||||
|
||||
# 短篇小说服务接口迁移设计
|
||||
|
||||
## 概述
|
||||
|
||||
将小程序"心愿实现"页面(ScriptView.vue)的短篇小说生成流程,从当前内部 AI Runtime 同步调用模式,迁移到外部 short-novel-service(`http://49.232.138.53:8010`)的流式 SSE 多轮交互模式。同时开启小程序的手机号短信登录功能(验证码固定 123456)。
|
||||
|
||||
## 架构决策
|
||||
|
||||
- **架构模式**:后端 SSE 代理 + 前端完整多轮交互适配
|
||||
- **持久化策略**:小说生成完成后一次性保存(中间交互阶段不持久化)
|
||||
- **认证方式**:外部服务 API Token 保存在后端配置中,前端不接触
|
||||
|
||||
## 当前架构(AS-IS)
|
||||
|
||||
### 前端流程
|
||||
|
||||
```
|
||||
ScriptView.vue
|
||||
→ submitWish() → runGeneration()
|
||||
→ createScriptWithDialogue(payload) // POST /epicScript/createWithDialogue
|
||||
→ 返回 { scriptId, conversationId, title, plotIntro, plotTurning, plotClimax, plotEnding }
|
||||
→ loadMessages()
|
||||
→ viewState = 'result'
|
||||
```
|
||||
|
||||
### 后端流程
|
||||
|
||||
```
|
||||
EpicScriptController.createWithDialogue()
|
||||
→ EpicScriptDialogueServiceImpl.createWithDialogue()
|
||||
→ 创建 EpicScript + Conversation 记录
|
||||
→ invokeScriptGenerate() → aiRuntimeService.test("script_generate") // 同步 AI 调用
|
||||
→ parseScriptOutput() → 解析 JSON 输出
|
||||
→ 创建 3 条 Message(系统欢迎、用户输入、AI 回复)
|
||||
→ 返回 EpicScriptCreateWithDialogueResponse
|
||||
```
|
||||
|
||||
### 当前接口特征
|
||||
|
||||
- 同步调用,一次性返回完整结果
|
||||
- AI 输出为 JSON 格式(title、plotIntro、plotTurning、plotClimax、plotEnding)
|
||||
- 前端无中间交互阶段
|
||||
|
||||
## 目标架构(TO-BE)
|
||||
|
||||
### 前端新流程
|
||||
|
||||
```
|
||||
ScriptView.vue 状态机:
|
||||
home → clarifying ⇄ outlining → novel-generating → result
|
||||
```
|
||||
|
||||
#### 阶段详情
|
||||
|
||||
| 阶段 | 触发事件 | UI 行为 | 用户操作 |
|
||||
|---|---|---|---|
|
||||
| `home` | 用户输入心愿 | 展示输入框+灵感推荐+语音按钮 | 输入文本/语音,点击发送 |
|
||||
| `clarifying` | `clarification_card` SSE 事件 | 展示卡片式问题 | 选择选项或输入文本回答 |
|
||||
| `outlining` | `outline_created` SSE 事件 | 展示大纲(标题/logline/beats/结局) | 确认大纲 或 提出修改意见 |
|
||||
| `novel-generating` | `novel_start` SSE 事件 | 逐字流式展示小说正文 | 等待生成完成 |
|
||||
| `result` | `novel_done` SSE 事件 | 展示完成的小说+后续对话 | 继续对话修改/朗读/分享 |
|
||||
|
||||
#### 澄清卡片类型
|
||||
|
||||
| card_type | UI 渲染 | 交互方式 |
|
||||
|---|---|---|
|
||||
| `single_select` | 单选按钮组 | 选择一个选项 |
|
||||
| `multi_select` | 多选标签组 | 选择多个选项 |
|
||||
| `text_input` | 文本输入框 | 自由输入 |
|
||||
| `mixed` | 选项 + 输入框 | 选择或自由输入 |
|
||||
|
||||
### 后端新架构
|
||||
|
||||
#### 新增 Controller
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/shortNovel")
|
||||
public class ShortNovelController {
|
||||
|
||||
// 首次发起(SSE 流式代理)
|
||||
@PostMapping("/stream")
|
||||
public SseEmitter stream(@RequestBody ShortNovelStreamRequest request);
|
||||
|
||||
// 后续轮次(SSE 流式代理)
|
||||
@PostMapping("/followup")
|
||||
public SseEmitter followup(@RequestBody ShortNovelFollowupRequest request);
|
||||
}
|
||||
```
|
||||
|
||||
#### 请求/响应格式
|
||||
|
||||
**POST /shortNovel/stream**
|
||||
|
||||
入参:
|
||||
```json
|
||||
{ "query": "用户的心愿文本" }
|
||||
```
|
||||
|
||||
后端转发到外部服务:
|
||||
```json
|
||||
{
|
||||
"user_id": "当前登录用户ID",
|
||||
"message_id": "web_{timestamp}",
|
||||
"query": "用户的心愿文本"
|
||||
}
|
||||
```
|
||||
|
||||
**POST /shortNovel/followup**
|
||||
|
||||
入参:
|
||||
```json
|
||||
{
|
||||
"sessionId": "外部服务返回的 session_id",
|
||||
"action": "answer_clarification | confirm_outline | modify_outline | retry",
|
||||
"payload": {
|
||||
"answer": "用户回答(answer_clarification 时)",
|
||||
"feedback": "修改意见(modify_outline 时)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
后端转发到外部服务:
|
||||
```json
|
||||
{
|
||||
"session_id": "sessionId",
|
||||
"user_id": "当前登录用户ID",
|
||||
"message_id": "web_{timestamp}",
|
||||
"action": "answer_clarification | confirm_outline | modify_outline | retry",
|
||||
"payload": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### SSE 事件格式(透传)
|
||||
|
||||
外部服务返回的 SSE 事件格式:
|
||||
```
|
||||
data: {"type":"status","session_id":"xxx","payload":{"message":"处理中","stage":"clarification"}}
|
||||
|
||||
data: {"type":"clarification_card","session_id":"xxx","payload":{"card":{"card_type":"single_select","question":"...","options":[...]}}}
|
||||
|
||||
data: {"type":"outline_created","session_id":"xxx","payload":{"outline":{"title":"...","beats":[...]}}}
|
||||
|
||||
data: {"type":"novel_start","session_id":"xxx","payload":{}}
|
||||
|
||||
data: {"type":"novel_delta","session_id":"xxx","payload":{"delta":"文本片段"}}
|
||||
|
||||
data: {"type":"novel_done","session_id":"xxx","payload":{"full_text":"完整小说文本"}}
|
||||
|
||||
data: {"type":"error","session_id":"xxx","payload":{"code":"ERROR_CODE","message":"错误描述"}}
|
||||
```
|
||||
|
||||
后端 SseEmitter 代理逐行转发这些事件,不做任何修改。
|
||||
|
||||
#### SSE 代理实现
|
||||
|
||||
```java
|
||||
// 使用 RestTemplate 读取外部服务 SSE 流
|
||||
// 逐行解析 data: 行,通过 SseEmitter.send() 转发
|
||||
// 超时设置 300 秒
|
||||
// 错误处理:连接失败、超时、外部服务错误
|
||||
```
|
||||
|
||||
#### 配置
|
||||
|
||||
```yaml
|
||||
# application.yml
|
||||
short-novel:
|
||||
api-base-url: http://49.232.138.53:8010
|
||||
api-token: c67d4a95b0bb92470a24d534302c0d40
|
||||
connect-timeout: 10000
|
||||
read-timeout: 300000
|
||||
```
|
||||
|
||||
### 数据持久化
|
||||
|
||||
在收到 `novel_done` 事件后,后端执行一次性保存:
|
||||
|
||||
1. 从 SSE 流中捕获 `novel_done` 事件的 `full_text`
|
||||
2. 创建 `EpicScript` 记录
|
||||
3. 创建 `Conversation` 记录
|
||||
4. 创建 3 条 `Message` 记录(系统欢迎、用户初始输入、AI 小说全文)
|
||||
5. 在最后一个 SSE 事件(`novel_done`)中附加 `scriptId` 和 `conversationId`
|
||||
|
||||
实现方式:在 SSE 代理层拦截 `novel_done` 事件,在转发给前端之前执行保存逻辑,并将 ID 信息注入到事件的 payload 中。
|
||||
|
||||
### 短信验证码修复
|
||||
|
||||
#### 改动范围
|
||||
|
||||
| 文件 | 行号 | 当前值 | 修改为 |
|
||||
|---|---|---|---|
|
||||
| `mini-program/src/pages/login/index.vue` | 149 | `'验证码已发送(模拟: 888888)'` | `'验证码已发送(模拟: 123456)'` |
|
||||
|
||||
后端已使用 `DEFAULT_SMS_CODE = "123456"`(AuthServiceImpl.java 第 80 行),无需修改。
|
||||
|
||||
## 前端新增/修改文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|---|---|---|
|
||||
| `services/shortNovel.js` | 新增 | 短篇小说流式 API 服务 |
|
||||
| `components/ClarificationCard.vue` | 新增 | 澄清卡片组件 |
|
||||
| `pages/main/ScriptView.vue` | 修改 | 生成流程改为状态机,新增 clarifying/outlining/novel-generating 阶段 |
|
||||
|
||||
## 后端新增/修改文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|---|---|---|
|
||||
| `controller/ShortNovelController.java` | 新增 | SSE 代理接口 |
|
||||
| `service/ShortNovelService.java` | 新增 | SSE 代理转发逻辑 |
|
||||
| `service/impl/ShortNovelServiceImpl.java` | 新增 | SSE 代理实现 |
|
||||
| `dto/request/ShortNovelStreamRequest.java` | 新增 | 首次请求 DTO |
|
||||
| `dto/request/ShortNovelFollowupRequest.java` | 新增 | 后续轮次 DTO |
|
||||
| `config/ShortNovelConfig.java` | 新增 | 配置类(api-base-url、api-token) |
|
||||
| `application.yml` | 修改 | 新增 short-novel 配置段 |
|
||||
| `EpicScriptDialogueServiceImpl.java` | 修改 | 抽取保存逻辑为可复用方法 |
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|---|---|
|
||||
| 外部服务不可达 | SSE 发送 `error` 事件,前端显示"服务暂时不可用" |
|
||||
| 外部服务超时(>300s) | SseEmitter 超时回调,前端显示"生成超时" |
|
||||
| 外部服务返回错误事件 | 透传 error 事件给前端 |
|
||||
| 用户中途退出 | 前端关闭 SSE 连接,后端 SseEmitter 自动清理 |
|
||||
| 小说生成失败 | 外部服务发送 `error` 事件,前端提供"重试"按钮 |
|
||||
|
||||
## 不在本次范围内
|
||||
|
||||
- 外部服务的知识库图谱(knowledge-graph)功能 — 暂不集成
|
||||
- 外部服务的 prompt 自定义配置 — 使用外部服务默认配置
|
||||
- 历史对话的恢复/继续 — 已完成的历史记录按现有方式展示
|
||||
- 真实的短信发送服务 — 继续使用固定验证码 123456
|
||||
@@ -1,12 +1,12 @@
|
||||
# 开发环境配置(本地开发调试)
|
||||
VITE_APP_ENV=dev
|
||||
# 本地环境
|
||||
# 本地环境(本地不启动后端,禁用)
|
||||
# VITE_API_BASE_URL=http://localhost:19089/api
|
||||
# VITE_WS_URL=ws://localhost:19089/ws
|
||||
# 直连后端服务(不经过 Nginx)
|
||||
# VITE_API_BASE_URL=http://101.200.208.45:19089/api
|
||||
# VITE_WS_URL=ws://101.200.208.45:19089/ws
|
||||
# 测试环境
|
||||
# 测试环境(H5 走生产服务器后端,符合 CLAUDE.md 服务器端验收规则)
|
||||
VITE_API_BASE_URL=https://lifescript.happylifeos.com/api
|
||||
VITE_WS_URL=wss://lifescript.happylifeos.com/ws
|
||||
VITE_DEBUG=true
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<view class="clarification-card">
|
||||
<view v-if="card.question" class="card-question">
|
||||
<text>{{ card.question }}</text>
|
||||
</view>
|
||||
<view v-if="card.description" class="card-description">
|
||||
<text>{{ card.description }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="hasOptions" class="card-options">
|
||||
<view
|
||||
v-for="opt in card.options"
|
||||
:key="opt.value"
|
||||
class="option-item"
|
||||
:class="{ active: isSelected(opt.value) }"
|
||||
@click="toggleOption(opt)"
|
||||
>
|
||||
<view class="option-content">
|
||||
<text class="option-label">{{ opt.label }}</text>
|
||||
<text v-if="opt.description" class="option-desc">{{ opt.description }}</text>
|
||||
</view>
|
||||
<view v-if="isSelected(opt.value)" class="option-check">✓</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="card.allow_custom || isTextInput" class="card-custom">
|
||||
<textarea
|
||||
v-model="customText"
|
||||
:placeholder="card.input_placeholder || '请输入你的回答'"
|
||||
class="custom-textarea"
|
||||
:auto-height="true"
|
||||
:show-confirm-bar="false"
|
||||
maxlength="200"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="card-actions">
|
||||
<view class="submit-btn" :class="{ disabled: !canSubmit }" @click="handleSubmit">
|
||||
<text>提交</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
card: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
const selectedValues = ref([])
|
||||
const customText = ref('')
|
||||
|
||||
const isTextInput = computed(() => props.card.card_type === 'text_input')
|
||||
const hasOptions = computed(() => Array.isArray(props.card.options) && props.card.options.length > 0)
|
||||
const isSingle = computed(() => props.card.card_type === 'single_select')
|
||||
const isMulti = computed(() => props.card.card_type === 'multi_select' || props.card.card_type === 'mixed')
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const minSel = props.card.min_selections || 1
|
||||
if (selectedValues.value.length < minSel) return false
|
||||
if (isTextInput.value && !customText.value.trim()) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function isSelected(value) {
|
||||
return selectedValues.value.includes(value)
|
||||
}
|
||||
|
||||
function toggleOption(opt) {
|
||||
const value = opt.value
|
||||
if (isSingle.value) {
|
||||
selectedValues.value = [value]
|
||||
} else if (isMulti.value) {
|
||||
const idx = selectedValues.value.indexOf(value)
|
||||
if (idx >= 0) {
|
||||
selectedValues.value.splice(idx, 1)
|
||||
} else {
|
||||
const maxSel = props.card.max_selections || selectedValues.value.length + 1
|
||||
if (selectedValues.value.length < maxSel) {
|
||||
selectedValues.value.push(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
let answer
|
||||
if (selectedValues.value.length > 0) {
|
||||
answer = selectedValues.value.join('、')
|
||||
if (customText.value.trim()) {
|
||||
answer = `${answer}(${customText.value.trim()})`
|
||||
}
|
||||
} else {
|
||||
answer = customText.value.trim()
|
||||
}
|
||||
|
||||
emit('submit', answer)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.clarification-card {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx 28rpx;
|
||||
margin: 24rpx 0;
|
||||
}
|
||||
|
||||
.card-question {
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 26rpx;
|
||||
margin-bottom: 24rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.option-item.active {
|
||||
background: rgba(8, 126, 139, 0.3);
|
||||
border-color: #087e8b;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 24rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.option-check {
|
||||
color: #087e8b;
|
||||
font-size: 32rpx;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.card-custom {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.custom-textarea {
|
||||
width: 100%;
|
||||
min-height: 120rpx;
|
||||
padding: 20rpx;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12rpx;
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 16rpx 48rpx;
|
||||
background: #087e8b;
|
||||
border-radius: 32rpx;
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.submit-btn.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<!-- 短消息气泡(短消息) -->
|
||||
<view v-if="isShortMessage" class="chat-bubble system">
|
||||
<text>{{ content }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 长文本卡片(story-card) -->
|
||||
<view v-else class="story-card" :class="{ collapsed }">
|
||||
<view class="story-head">
|
||||
<view class="story-title-wrap">
|
||||
<text v-if="versionLabel" class="version-label">{{ versionLabel }}</text>
|
||||
<text class="story-title">{{ title || '我的人生剧本' }}</text>
|
||||
<view v-if="tags?.length" class="tag-row">
|
||||
<text v-for="tag in tags" :key="tag" class="tag">{{ tag }}</text>
|
||||
@@ -41,10 +45,14 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 按旧版布局统一按钮组 -->
|
||||
<view class="result-actions">
|
||||
<button class="action-btn" @click="$emit('change-direction')">换个方向</button>
|
||||
<button class="action-btn" @click="$emit('not-like-me')">不像我</button>
|
||||
<button class="action-btn" @click="$emit('continue')">继续生成</button>
|
||||
<button v-if="canContinue" class="action-btn" @click="$emit('continue-script')">续写</button>
|
||||
<button v-if="canRewrite" class="action-btn" @click="$emit('rewrite')">改写</button>
|
||||
<button v-if="hasChildren" class="action-btn" @click="$emit('view-versions')">查看历史版本</button>
|
||||
<button v-if="canDelete" class="action-btn danger" @click="$emit('delete-version')">删除版本</button>
|
||||
<button class="action-btn primary" @click="$emit('play-tts')">
|
||||
<text class="action-icon">{{ ttsIcon || '▶' }}</text>
|
||||
<text>{{ ttsText || '播放' }}</text>
|
||||
@@ -62,7 +70,13 @@ const props = defineProps({
|
||||
contentLength: { type: Number, required: true },
|
||||
isShortMessage: { type: Boolean, required: true },
|
||||
ttsIcon: { type: String, default: '▶' },
|
||||
ttsText: { type: String, default: '播放' }
|
||||
ttsText: { type: String, default: '播放' },
|
||||
// 新增
|
||||
versionLabel: { type: String, default: '' },
|
||||
hasChildren: { type: Boolean, default: false },
|
||||
canDelete: { type: Boolean, default: false },
|
||||
canRewrite: { type: Boolean, default: true },
|
||||
canContinue: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
defineEmits([
|
||||
@@ -71,7 +85,12 @@ defineEmits([
|
||||
'change-direction',
|
||||
'not-like-me',
|
||||
'continue',
|
||||
'play-tts'
|
||||
'play-tts',
|
||||
// 新增
|
||||
'rewrite',
|
||||
'continue-script',
|
||||
'view-versions',
|
||||
'delete-version'
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -370,4 +389,28 @@ defineEmits([
|
||||
.action-btn::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* 新增样式 */
|
||||
.version-label {
|
||||
display: inline-block;
|
||||
padding: 4rpx 16rpx;
|
||||
margin-right: 12rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(140, 68, 242, 0.28);
|
||||
color: #d6b8ff;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.script-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
color: #ff8080;
|
||||
border-color: rgba(255, 128, 128, 0.4);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -134,14 +134,41 @@ const isDescriptionCollapsed = ref(false)
|
||||
const scrollTop = ref(0)
|
||||
const currentScrollTop = ref(0)
|
||||
|
||||
// 从 localStorage 读取缓存的收藏状态
|
||||
const getCachedFavoriteStatus = (id) => {
|
||||
const cached = uni.getStorageSync(`favorite_event_${id}`)
|
||||
return cached === 'true'
|
||||
}
|
||||
|
||||
// 缓存收藏状态到 localStorage
|
||||
const setCachedFavoriteStatus = (id, status) => {
|
||||
uni.setStorageSync(`favorite_event_${id}`, status ? 'true' : 'false')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const info = uni.getWindowInfo()
|
||||
safeAreaBottom.value = info.safeAreaInsets?.bottom || 0
|
||||
const pages = getCurrentPages()
|
||||
eventId.value = pages[pages.length - 1]?.options?.id || ''
|
||||
|
||||
// 先从缓存读取收藏状态,避免闪烁
|
||||
if (eventId.value) {
|
||||
isFavorite.value = getCachedFavoriteStatus(eventId.value)
|
||||
}
|
||||
|
||||
cachedEvent.value = uni.getStorageSync('current_life_event') || null
|
||||
if (!store.events?.length) await store.fetchEvents()
|
||||
isFavorite.value = Boolean(uni.getStorageSync(`event_favorite_${eventId.value}`))
|
||||
|
||||
// 异步校验真实收藏状态并更新缓存
|
||||
if (eventId.value) {
|
||||
const result = await store.checkEventFavorite(eventId.value)
|
||||
if (result.success) {
|
||||
const realStatus = result.data
|
||||
isFavorite.value = realStatus
|
||||
setCachedFavoriteStatus(eventId.value, realStatus)
|
||||
}
|
||||
}
|
||||
|
||||
analytics.trackPageView(pagePath, {
|
||||
life_event_id: eventId.value,
|
||||
tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0
|
||||
@@ -305,20 +332,19 @@ const editEvent = () => {
|
||||
|
||||
const toggleFavorite = async () => {
|
||||
if (!eventId.value) return
|
||||
const next = !isFavorite.value
|
||||
const result = await store.favoriteEvent({ id: eventId.value, favorite: next })
|
||||
const result = await store.toggleEventFavorite(eventId.value)
|
||||
if (!result.success) {
|
||||
uni.showToast({ title: result.error || '收藏失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
isFavorite.value = next
|
||||
if (next) uni.setStorageSync(`event_favorite_${eventId.value}`, '1')
|
||||
else uni.removeStorageSync(`event_favorite_${eventId.value}`)
|
||||
const newStatus = result.data?.isFavorited ?? false
|
||||
isFavorite.value = newStatus
|
||||
setCachedFavoriteStatus(eventId.value, newStatus)
|
||||
analytics.track('life_event_favorite', {
|
||||
life_event_id: eventId.value,
|
||||
favorite: next
|
||||
favorite: newStatus
|
||||
}, { eventType: 'life_event', pagePath })
|
||||
uni.showToast({ title: next ? '已收藏' : '已取消收藏', icon: 'success' })
|
||||
uni.showToast({ title: newStatus ? '已收藏' : '已取消收藏', icon: 'success' })
|
||||
}
|
||||
|
||||
const chatEvent = async () => {
|
||||
|
||||
@@ -108,7 +108,7 @@ const activeMethod = ref('')
|
||||
const countdown = ref(60)
|
||||
const isCountingDown = ref(false)
|
||||
|
||||
const loginConfig = ref({ wechatLoginEnabled: true, smsLoginEnabled: false })
|
||||
const loginConfig = ref({ wechatLoginEnabled: true, smsLoginEnabled: true })
|
||||
|
||||
onMounted(async () => {
|
||||
const windowInfo = uni.getWindowInfo()
|
||||
@@ -120,10 +120,12 @@ onMounted(async () => {
|
||||
const res = await getLoginConfig()
|
||||
if (res.data) {
|
||||
loginConfig.value = res.data
|
||||
// 临时开启手机号验证码登录,便于验收
|
||||
loginConfig.value.smsLoginEnabled = true
|
||||
}
|
||||
} catch (e) {
|
||||
// 接口失败时默认只显示微信登录
|
||||
loginConfig.value = { wechatLoginEnabled: true, smsLoginEnabled: false }
|
||||
// 接口失败时默认显示手机号登录
|
||||
loginConfig.value = { wechatLoginEnabled: true, smsLoginEnabled: true }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -144,7 +146,7 @@ const handleGetCode = async () => {
|
||||
uni.showToast({ title: '验证码已发送', icon: 'success' })
|
||||
startCountdown()
|
||||
} catch (error) {
|
||||
uni.showToast({ title: '验证码已发送(模拟: 888888)', icon: 'none' })
|
||||
uni.showToast({ title: '验证码已发送(模拟: 123456)', icon: 'none' })
|
||||
startCountdown()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useAppStore } from '../../stores/app.js'
|
||||
import { toggleFavoriteScript, checkFavoriteScript } from '../../services/epicScript.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const activeType = ref('long')
|
||||
@@ -156,7 +157,7 @@ const activeStatus = ref('all')
|
||||
const keyword = ref('')
|
||||
const sortMode = ref('updated')
|
||||
const viewMode = ref('list')
|
||||
const localFavorites = ref(uni.getStorageSync('script_favorites') || {})
|
||||
const favoriteStatus = ref({})
|
||||
const activeMenuId = ref('')
|
||||
const deleteTarget = ref(null)
|
||||
const deletingScript = ref(false)
|
||||
@@ -197,6 +198,26 @@ const visibleScripts = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 列表加载后批量检查收藏状态
|
||||
const loadFavoriteStatus = async () => {
|
||||
const ids = scripts.value.map(s => String(s.id)).filter(id => !id.startsWith('demo-'))
|
||||
if (!ids.length) return
|
||||
const status = {}
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
try {
|
||||
status[id] = await checkFavoriteScript(id)
|
||||
} catch {
|
||||
status[id] = false
|
||||
}
|
||||
}))
|
||||
favoriteStatus.value = status
|
||||
}
|
||||
|
||||
// 监听 scripts 变化时重新加载收藏状态
|
||||
watch(scripts, () => {
|
||||
loadFavoriteStatus()
|
||||
}, { immediate: true })
|
||||
|
||||
const sortLabel = computed(() => {
|
||||
const map = { updated: '最近更新⌄', words: '字数最多⌄', progress: '进度最高⌄' }
|
||||
return map[sortMode.value] || '最近更新⌄'
|
||||
@@ -242,7 +263,7 @@ const getProgress = (script) => Math.max(0, Math.min(99, Number(script.progress
|
||||
const getInitial = (script) => (script.title || '剧').slice(0, 1)
|
||||
|
||||
const isFavorite = (script) => {
|
||||
return Boolean(script.isFavorite || script.favorite || localFavorites.value[String(script.id)])
|
||||
return Boolean(favoriteStatus.value[String(script.id)])
|
||||
}
|
||||
|
||||
const openScriptChat = (script) => {
|
||||
@@ -316,15 +337,32 @@ const toggleScriptMenu = (script) => {
|
||||
activeMenuId.value = activeMenuId.value === id ? '' : id
|
||||
}
|
||||
|
||||
const toggleFavorite = (script) => {
|
||||
const favorite = isFavorite(script)
|
||||
const next = { ...localFavorites.value }
|
||||
if (favorite) delete next[String(script.id)]
|
||||
else next[String(script.id)] = true
|
||||
localFavorites.value = next
|
||||
uni.setStorageSync('script_favorites', next)
|
||||
const toggleFavorite = async (script) => {
|
||||
const wasFav = isFavorite(script)
|
||||
// 乐观更新:先改内存状态
|
||||
const next = { ...favoriteStatus.value }
|
||||
if (wasFav) {
|
||||
delete next[String(script.id)]
|
||||
} else {
|
||||
next[String(script.id)] = true
|
||||
}
|
||||
favoriteStatus.value = next
|
||||
closeScriptMenu()
|
||||
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
|
||||
|
||||
try {
|
||||
const result = await toggleFavoriteScript(script.id)
|
||||
uni.showToast({ title: result.isFavorited ? '已收藏' : '已取消收藏', icon: 'success' })
|
||||
} catch (error) {
|
||||
// 回滚乐观更新
|
||||
const restored = { ...favoriteStatus.value }
|
||||
if (wasFav) {
|
||||
restored[String(script.id)] = true
|
||||
} else {
|
||||
delete restored[String(script.id)]
|
||||
}
|
||||
favoriteStatus.value = restored
|
||||
uni.showToast({ title: error?.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const continueScript = (script) => {
|
||||
@@ -363,10 +401,9 @@ const confirmDeleteScript = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...localFavorites.value }
|
||||
const next = { ...favoriteStatus.value }
|
||||
delete next[id]
|
||||
localFavorites.value = next
|
||||
uni.setStorageSync('script_favorites', next)
|
||||
favoriteStatus.value = next
|
||||
const conversationId = deleteTarget.value.conversationId || deleteTarget.value.plotJson?.conversationId
|
||||
if (conversationId) uni.removeStorageSync(`script_conversation_history_${conversationId}`)
|
||||
uni.removeStorageSync(`script_chat_history_${id}`)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -148,7 +148,9 @@ export const transformToFrontendFormat = (backendData) => {
|
||||
plotJson,
|
||||
isSelected,
|
||||
createTime,
|
||||
updateTime
|
||||
updateTime,
|
||||
conversationId,
|
||||
currentVersionMessageId
|
||||
} = backendData
|
||||
|
||||
let content = ''
|
||||
@@ -182,11 +184,12 @@ export const transformToFrontendFormat = (backendData) => {
|
||||
date: createTime ? String(createTime).slice(0, 10) : new Date().toLocaleDateString(),
|
||||
mode: plotJson?.mode || 'custom',
|
||||
prompt: plotJson?.prompt || '',
|
||||
conversationId: plotJson?.conversationId || '',
|
||||
conversationId: conversationId || plotJson?.conversationId || '',
|
||||
parentScriptId: plotJson?.parentScriptId || '',
|
||||
revisionIndex: plotJson?.currentRevisionIndex || plotJson?.revisionIndex || 0,
|
||||
revisionOf: plotJson?.revisionOf || '',
|
||||
wordCount: content ? content.length : 0
|
||||
wordCount: content ? content.length : 0,
|
||||
currentVersionMessageId: currentVersionMessageId || ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +198,39 @@ export const transformListToFrontend = (backendList) => {
|
||||
return backendList.map(transformToFrontendFormat)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换剧本收藏状态(收藏/取消收藏)
|
||||
* @param {string} scriptId 剧本ID
|
||||
* @returns {Promise<{success: boolean, isFavorited: boolean}>}
|
||||
*/
|
||||
export const toggleFavoriteScript = async (scriptId) => {
|
||||
const res = await post('/epicScript/favorite/toggle', { scriptId })
|
||||
return {
|
||||
success: true,
|
||||
isFavorited: res.data?.isFavorited ?? false,
|
||||
favoriteTime: res.data?.favoriteTime ?? null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏剧本列表
|
||||
* @param {number} current 当前页
|
||||
* @param {number} size 每页大小
|
||||
*/
|
||||
export const getFavoriteScriptPage = async (current = 1, size = 10) => {
|
||||
return get('/epicScript/favorite/page', { current, size })
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查剧本是否已收藏
|
||||
* @param {string} scriptId 剧本ID
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const checkFavoriteScript = async (scriptId) => {
|
||||
const res = await get('/epicScript/favorite/check', { scriptId })
|
||||
return res.data?.isFavorited ?? false
|
||||
}
|
||||
|
||||
export default {
|
||||
getScriptList,
|
||||
getScriptPage,
|
||||
@@ -209,5 +245,8 @@ export default {
|
||||
buildCharacterInfo,
|
||||
buildLifeEventsSummary,
|
||||
transformToFrontendFormat,
|
||||
transformListToFrontend
|
||||
transformListToFrontend,
|
||||
toggleFavoriteScript,
|
||||
getFavoriteScriptPage,
|
||||
checkFavoriteScript,
|
||||
}
|
||||
|
||||
@@ -64,8 +64,28 @@ export const shareEvent = async (eventData = {}) => {
|
||||
return post('/lifeEvent/share-placeholder', eventData)
|
||||
}
|
||||
|
||||
export const favoriteEvent = async ({ id, favorite }) => {
|
||||
return post('/lifeEvent/favorite-placeholder', { id, favorite })
|
||||
/**
|
||||
* 切换人生事件收藏状态(收藏/取消收藏)
|
||||
* @param {string} eventId 事件ID
|
||||
* @returns {Promise<{success: boolean, isFavorited: boolean}>}
|
||||
*/
|
||||
export const toggleFavoriteEvent = async (eventId) => {
|
||||
const res = await post('/lifeEvent/favorite/toggle', { eventId })
|
||||
return {
|
||||
success: true,
|
||||
isFavorited: res.data?.isFavorited ?? false,
|
||||
favoriteTime: res.data?.favoriteTime ?? null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查人生事件是否已收藏
|
||||
* @param {string} eventId 事件ID
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const checkFavoriteEvent = async (eventId) => {
|
||||
const res = await get('/lifeEvent/favorite/check', { eventId })
|
||||
return res.data?.isFavorited ?? false
|
||||
}
|
||||
|
||||
const transformToBackendFormat = (frontendData) => {
|
||||
@@ -153,7 +173,8 @@ export default {
|
||||
assistEventWriting,
|
||||
chatAboutEvent,
|
||||
shareEvent,
|
||||
favoriteEvent,
|
||||
toggleFavoriteEvent,
|
||||
checkFavoriteEvent,
|
||||
transformToFrontendFormat,
|
||||
transformListToFrontend
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { get, post, del } from './request.js'
|
||||
|
||||
export const createMessage = async (payload) => {
|
||||
return post('/message/create', payload)
|
||||
}
|
||||
|
||||
export const getMessageById = async (id) => {
|
||||
return get('/message/detail', { id })
|
||||
}
|
||||
|
||||
export const deleteMessage = async (id) => {
|
||||
return del('/message/delete', { id })
|
||||
}
|
||||
@@ -266,6 +266,13 @@ export const upload = (url, filePath, formData = {}, name = 'file') => {
|
||||
})
|
||||
}
|
||||
|
||||
export const getApiBaseUrl = () => API_BASE_URL
|
||||
|
||||
export const getAuthHeader = () => {
|
||||
const token = uni.getStorageSync('access_token')
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
export default {
|
||||
get,
|
||||
post,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { get, post, put, del } from './request.js'
|
||||
import { getApiBaseUrl, getAuthHeader } from './request.js'
|
||||
|
||||
export const createScriptWithDialogue = async (payload) => {
|
||||
return post('/epicScript/createWithDialogue', payload)
|
||||
}
|
||||
|
||||
export const streamScriptChat = async ({ operationType, conversationId, messageId, userMessageId, content, scriptId, onDelta, onDone, onError }) => {
|
||||
const requestTask = uni.request({
|
||||
url: `${getApiBaseUrl()}/chat/stream`,
|
||||
method: 'POST',
|
||||
data: { operationType, conversationId, messageId, userMessageId, content, scriptId },
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeader()
|
||||
},
|
||||
enableChunked: true,
|
||||
timeout: 300000,
|
||||
success: (res) => {
|
||||
if (res.statusCode >= 400) {
|
||||
onError?.(res.data?.message || '流式请求失败')
|
||||
return
|
||||
}
|
||||
if (typeof res.data === 'string' && res.data) {
|
||||
consumeSseText(res.data, onDelta, onDone, onError)
|
||||
}
|
||||
},
|
||||
fail: (error) => {
|
||||
onError?.(error.errMsg || '流式请求失败')
|
||||
}
|
||||
})
|
||||
|
||||
requestTask?.onChunkReceived?.((res) => {
|
||||
try {
|
||||
const text = decodeChunk(res.data)
|
||||
consumeSseText(text, onDelta, onDone, onError)
|
||||
} catch (error) {
|
||||
onError?.(error.message || '流式解析失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const listMessagesByConversation = async ({ conversationId, includeVersions = false }) => {
|
||||
return get('/message/listByConversation', { conversationId, includeVersions })
|
||||
}
|
||||
|
||||
export const listMessageVersions = async (messageId) => {
|
||||
return get('/message/versions', { messageId })
|
||||
}
|
||||
|
||||
export const switchVersion = async ({ scriptId, messageId }) => {
|
||||
return put('/epicScript/switchVersion', { scriptId, messageId })
|
||||
}
|
||||
|
||||
export const deleteVersion = async (messageId) => {
|
||||
return del('/message/deleteVersion', { id: messageId })
|
||||
}
|
||||
|
||||
function decodeChunk(chunk) {
|
||||
if (typeof chunk === 'string') return chunk
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
return decoder.decode(new Uint8Array(chunk))
|
||||
}
|
||||
|
||||
function consumeSseText(text, onDelta, onDone, onError) {
|
||||
const lines = text.split(/\r?\n/)
|
||||
let eventName = ''
|
||||
let dataBuffer = ''
|
||||
lines.forEach((line) => {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataBuffer += line.slice(5).trim()
|
||||
} else if (line === '' && dataBuffer) {
|
||||
try {
|
||||
if (eventName === 'delta') {
|
||||
onDelta?.(dataBuffer)
|
||||
} else if (eventName === 'metadata') {
|
||||
onDone?.({ type: 'metadata', data: JSON.parse(dataBuffer) })
|
||||
} else if (eventName === 'done') {
|
||||
onDone?.({ type: 'done', data: JSON.parse(dataBuffer) })
|
||||
} else if (eventName === 'error') {
|
||||
onError?.(dataBuffer)
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.(e.message)
|
||||
}
|
||||
eventName = ''
|
||||
dataBuffer = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { getApiBaseUrl, getAuthHeader } from './request.js'
|
||||
|
||||
/**
|
||||
* 短篇小说外部服务 API 封装
|
||||
* 通过后端 SSE 代理调用外部 short-novel-service
|
||||
*/
|
||||
|
||||
/**
|
||||
* 首次发起短篇小说生成(SSE 流式)
|
||||
* @param {Object} params
|
||||
* @param {string} params.query - 用户心愿文本
|
||||
* @param {Function} params.onEvent - 事件回调 (event) => void,event 包含 type、session_id、payload
|
||||
* @param {Function} params.onError - 错误回调
|
||||
* @returns {Object} uni.request 任务对象(用于 abort)
|
||||
*/
|
||||
export const startNovelStream = ({ query, onEvent, onError }) => {
|
||||
let chunkProcessed = false
|
||||
const task = uni.request({
|
||||
url: `${getApiBaseUrl()}/shortNovel/stream`,
|
||||
method: 'POST',
|
||||
data: { query },
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
...getAuthHeader()
|
||||
},
|
||||
enableChunked: true,
|
||||
timeout: 300000,
|
||||
success: (res) => {
|
||||
if (res.statusCode >= 400) {
|
||||
onError?.(res.data?.message || '请求失败')
|
||||
return
|
||||
}
|
||||
// 仅在 chunk 未处理时才处理完整 data(避免 H5 双重消费)
|
||||
if (!chunkProcessed && typeof res.data === 'string' && res.data) {
|
||||
consumeSseText(res.data, onEvent, onError)
|
||||
}
|
||||
},
|
||||
fail: (error) => {
|
||||
onError?.(error.errMsg || '网络请求失败')
|
||||
}
|
||||
})
|
||||
|
||||
task?.onChunkReceived?.((res) => {
|
||||
chunkProcessed = true
|
||||
try {
|
||||
const text = decodeChunk(res.data)
|
||||
consumeSseText(text, onEvent, onError)
|
||||
} catch (error) {
|
||||
onError?.(error.message || '流式解析失败')
|
||||
}
|
||||
})
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* 后续轮次(澄清回答、大纲确认/修改、重试)
|
||||
* @param {Object} params
|
||||
* @param {string} params.sessionId - 外部服务的 session_id
|
||||
* @param {string} params.action - answer_clarification | confirm_outline | modify_outline | retry
|
||||
* @param {Object} params.payload - 操作载荷
|
||||
* @param {Function} params.onEvent
|
||||
* @param {Function} params.onError
|
||||
* @returns {Object} uni.request 任务对象
|
||||
*/
|
||||
export const followupStream = ({ sessionId, action, payload, onEvent, onError }) => {
|
||||
let chunkProcessed = false
|
||||
const task = uni.request({
|
||||
url: `${getApiBaseUrl()}/shortNovel/followup`,
|
||||
method: 'POST',
|
||||
data: { sessionId, action, payload },
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
...getAuthHeader()
|
||||
},
|
||||
enableChunked: true,
|
||||
timeout: 300000,
|
||||
success: (res) => {
|
||||
if (res.statusCode >= 400) {
|
||||
onError?.(res.data?.message || '请求失败')
|
||||
return
|
||||
}
|
||||
if (!chunkProcessed && typeof res.data === 'string' && res.data) {
|
||||
consumeSseText(res.data, onEvent, onError)
|
||||
}
|
||||
},
|
||||
fail: (error) => {
|
||||
onError?.(error.errMsg || '网络请求失败')
|
||||
}
|
||||
})
|
||||
|
||||
task?.onChunkReceived?.((res) => {
|
||||
chunkProcessed = true
|
||||
try {
|
||||
const text = decodeChunk(res.data)
|
||||
consumeSseText(text, onEvent, onError)
|
||||
} catch (error) {
|
||||
onError?.(error.message || '流式解析失败')
|
||||
}
|
||||
})
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 chunk 数据
|
||||
* 兼容 mp-weixin(不支持 TextDecoder)
|
||||
*/
|
||||
function decodeChunk(chunk) {
|
||||
if (typeof chunk === 'string') return chunk
|
||||
// 小程序环境 chunk 可能是 ArrayBuffer,转成 UTF-8 字符串
|
||||
const bytes = new Uint8Array(chunk)
|
||||
let result = ''
|
||||
let i = 0
|
||||
while (i < bytes.length) {
|
||||
const byte1 = bytes[i++]
|
||||
if (byte1 < 0x80) {
|
||||
result += String.fromCharCode(byte1)
|
||||
} else if (byte1 < 0xE0) {
|
||||
const byte2 = bytes[i++]
|
||||
result += String.fromCharCode(((byte1 & 0x1F) << 6) | (byte2 & 0x3F))
|
||||
} else if (byte1 < 0xF0) {
|
||||
const byte2 = bytes[i++]
|
||||
const byte3 = bytes[i++]
|
||||
result += String.fromCharCode(((byte1 & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | (byte3 & 0x3F))
|
||||
} else {
|
||||
const byte2 = bytes[i++]
|
||||
const byte3 = bytes[i++]
|
||||
const byte4 = bytes[i++]
|
||||
const codePoint = ((byte1 & 0x07) << 18) | ((byte2 & 0x3F) << 12) | ((byte3 & 0x3F) << 6) | (byte4 & 0x3F)
|
||||
// 转成 UTF-16 代理对
|
||||
const adjusted = codePoint - 0x10000
|
||||
result += String.fromCharCode(0xD800 | (adjusted >> 10)) + String.fromCharCode(0xDC00 | (adjusted & 0x3FF))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 SSE 文本
|
||||
* 外部服务事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
|
||||
*/
|
||||
function consumeSseText(text, onEvent, onError) {
|
||||
const lines = text.split(/\r?\n/)
|
||||
let dataBuffer = ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataBuffer += line.slice(5).trim()
|
||||
} else if (line === '' && dataBuffer) {
|
||||
const dataStr = dataBuffer
|
||||
dataBuffer = ''
|
||||
if (dataStr === '[DONE]') return
|
||||
try {
|
||||
const event = JSON.parse(dataStr)
|
||||
onEvent?.(event)
|
||||
} catch (e) {
|
||||
onError?.(`SSE 解析失败: ${e.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,10 +232,19 @@ const shareEvent = async (eventData) => {
|
||||
}
|
||||
}
|
||||
|
||||
const favoriteEvent = async ({ id, favorite }) => {
|
||||
const toggleEventFavorite = async (eventId) => {
|
||||
try {
|
||||
const res = await lifeEventService.favoriteEvent({ id, favorite })
|
||||
return { success: true, data: res.data }
|
||||
const result = await lifeEventService.toggleFavoriteEvent(eventId)
|
||||
return { success: true, data: result }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const checkEventFavorite = async (eventId) => {
|
||||
try {
|
||||
const isFavorited = await lifeEventService.checkFavoriteEvent(eventId)
|
||||
return { success: true, data: isFavorited }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
@@ -479,7 +488,8 @@ export const useAppStore = () => {
|
||||
assistEventWriting,
|
||||
chatAboutEvent,
|
||||
shareEvent,
|
||||
favoriteEvent,
|
||||
toggleEventFavorite,
|
||||
checkEventFavorite,
|
||||
getEventById,
|
||||
fetchScripts,
|
||||
createScript,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.emotion.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 短篇小说外部服务配置
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "short-novel")
|
||||
public class ShortNovelConfig {
|
||||
|
||||
/**
|
||||
* 外部服务基础 URL
|
||||
*/
|
||||
private String apiBaseUrl;
|
||||
|
||||
/**
|
||||
* 外部服务 API Token
|
||||
*/
|
||||
private String apiToken;
|
||||
|
||||
/**
|
||||
* 连接超时(毫秒)
|
||||
*/
|
||||
private Integer connectTimeout = 10000;
|
||||
|
||||
/**
|
||||
* 读取超时(毫秒)
|
||||
*/
|
||||
private Integer readTimeout = 300000;
|
||||
}
|
||||
@@ -3,14 +3,21 @@ package com.emotion.controller;
|
||||
import com.emotion.common.PageResult;
|
||||
import com.emotion.common.Result;
|
||||
import com.emotion.dto.request.EpicScriptCreateRequest;
|
||||
import com.emotion.dto.request.EpicScriptCreateWithDialogueRequest;
|
||||
import com.emotion.dto.request.EpicScriptInspirationRequest;
|
||||
import com.emotion.dto.request.EpicScriptPageRequest;
|
||||
import com.emotion.dto.request.EpicScriptUpdateRequest;
|
||||
import com.emotion.dto.request.SwitchVersionRequest;
|
||||
import com.emotion.dto.request.ScriptFavoriteToggleRequest;
|
||||
import com.emotion.dto.response.EpicScriptCreateWithDialogueResponse;
|
||||
import com.emotion.dto.response.EpicScriptInspirationResponse;
|
||||
import com.emotion.dto.response.EpicScriptResponse;
|
||||
import com.emotion.dto.response.InspirationSuggestionResponse;
|
||||
import com.emotion.service.EpicScriptDialogueService;
|
||||
import com.emotion.dto.response.ScriptFavoriteResponse;
|
||||
import com.emotion.service.EpicScriptService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import com.emotion.service.ScriptFavoriteService;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -34,6 +41,12 @@ public class EpicScriptController {
|
||||
@Autowired
|
||||
private EpicScriptService epicScriptService;
|
||||
|
||||
@Autowired
|
||||
private EpicScriptDialogueService epicScriptDialogueService;
|
||||
|
||||
@Autowired
|
||||
private ScriptFavoriteService scriptFavoriteService;
|
||||
|
||||
/**
|
||||
* 分页查询当前用户的爽文剧本
|
||||
*/
|
||||
@@ -109,6 +122,14 @@ public class EpicScriptController {
|
||||
return Result.success(script);
|
||||
}
|
||||
|
||||
@Operation(summary = "创建剧本并创建对话", description = "用户输入心愿后,创建剧本、创建对话并生成第一条 AI 剧本消息。")
|
||||
@PostMapping(value = "/createWithDialogue")
|
||||
public Result<EpicScriptCreateWithDialogueResponse> createWithDialogue(
|
||||
@Valid @RequestBody EpicScriptCreateWithDialogueRequest request) {
|
||||
EpicScriptCreateWithDialogueResponse response = epicScriptDialogueService.createWithDialogue(request);
|
||||
return Result.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新爽文剧本
|
||||
*/
|
||||
@@ -135,6 +156,16 @@ public class EpicScriptController {
|
||||
return Result.success(script);
|
||||
}
|
||||
|
||||
@Operation(summary = "切换剧本当前版本", description = "将指定消息设为剧本当前生效版本。")
|
||||
@PutMapping(value = "/switchVersion")
|
||||
public Result<EpicScriptResponse> switchVersion(@Valid @RequestBody SwitchVersionRequest request) {
|
||||
EpicScriptResponse response = epicScriptService.switchVersion(request.getScriptId(), request.getMessageId());
|
||||
if (response == null) {
|
||||
return Result.error("切换失败");
|
||||
}
|
||||
return Result.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除爽文剧本
|
||||
*/
|
||||
@@ -147,4 +178,31 @@ public class EpicScriptController {
|
||||
}
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// ==================== 剧本收藏 ====================
|
||||
|
||||
@Operation(summary = "切换收藏", description = "收藏或取消收藏指定剧本。")
|
||||
@PostMapping(value = "/favorite/toggle")
|
||||
public Result<ScriptFavoriteResponse> toggleFavorite(
|
||||
@Valid @RequestBody ScriptFavoriteToggleRequest request) {
|
||||
return Result.success(scriptFavoriteService.toggleFavorite(request.getScriptId()));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询收藏", description = "分页查询当前用户收藏的剧本列表。")
|
||||
@GetMapping(value = "/favorite/page")
|
||||
public Result<PageResult<EpicScriptResponse>> getFavoritePage(
|
||||
@RequestParam(defaultValue = "1") long current,
|
||||
@RequestParam(defaultValue = "10") long size) {
|
||||
return Result.success(scriptFavoriteService.getFavoritePage(current, size));
|
||||
}
|
||||
|
||||
@Operation(summary = "检查收藏状态", description = "检查指定剧本是否已被当前用户收藏。")
|
||||
@GetMapping(value = "/favorite/check")
|
||||
public Result<ScriptFavoriteResponse> checkFavorite(@RequestParam String scriptId) {
|
||||
boolean favorited = scriptFavoriteService.isFavorited(scriptId);
|
||||
ScriptFavoriteResponse r = new ScriptFavoriteResponse();
|
||||
r.setScriptId(scriptId);
|
||||
r.setIsFavorited(favorited);
|
||||
return Result.success(r);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package com.emotion.controller;
|
||||
|
||||
import com.emotion.common.PageResult;
|
||||
import com.emotion.common.Result;
|
||||
import com.emotion.dto.request.EventFavoriteToggleRequest;
|
||||
import com.emotion.dto.request.LifeEventCreateRequest;
|
||||
import com.emotion.dto.request.LifeEventPageRequest;
|
||||
import com.emotion.dto.request.LifeEventUpdateRequest;
|
||||
import com.emotion.dto.response.EventFavoriteResponse;
|
||||
import com.emotion.dto.response.LifeEventResponse;
|
||||
import com.emotion.service.EventFavoriteService;
|
||||
import com.emotion.service.LifeEventService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@@ -35,6 +38,9 @@ public class LifeEventController {
|
||||
@Autowired
|
||||
private LifeEventService lifeEventService;
|
||||
|
||||
@Autowired
|
||||
private EventFavoriteService eventFavoriteService;
|
||||
|
||||
/**
|
||||
* 分页查询当前用户的生命事件
|
||||
*/
|
||||
@@ -150,18 +156,6 @@ public class LifeEventController {
|
||||
return Result.success(data);
|
||||
}
|
||||
|
||||
@Operation(summary = "收藏占位接口", description = "返回收藏功能的占位响应。")
|
||||
@PostMapping(value = "/favorite-placeholder")
|
||||
public Result<Map<String, Object>> favoritePlaceholder(@RequestBody Map<String, Object> request) {
|
||||
String id = stringValue(request.get("id"), "");
|
||||
Boolean favorite = Boolean.TRUE.equals(request.get("favorite"));
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("id", id);
|
||||
data.put("favorite", favorite);
|
||||
data.put("placeholder", true);
|
||||
return Result.success(data);
|
||||
}
|
||||
|
||||
private String stringValue(Object value, String fallback) {
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
@@ -170,6 +164,30 @@ public class LifeEventController {
|
||||
return text.isEmpty() ? fallback : text;
|
||||
}
|
||||
|
||||
// ==================== 人生事件收藏 ====================
|
||||
|
||||
@Operation(summary = "切换收藏", description = "收藏或取消收藏指定事件。")
|
||||
@PostMapping(value = "/favorite/toggle")
|
||||
public Result<EventFavoriteResponse> toggleFavorite(
|
||||
@Valid @RequestBody EventFavoriteToggleRequest request) {
|
||||
return Result.success(eventFavoriteService.toggleFavorite(request.getEventId()));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询收藏", description = "分页查询当前用户收藏的事件列表。")
|
||||
@GetMapping(value = "/favorite/page")
|
||||
public Result<PageResult<LifeEventResponse>> getFavoritePage(
|
||||
@RequestParam(defaultValue = "1") long current,
|
||||
@RequestParam(defaultValue = "10") long size) {
|
||||
return Result.success(eventFavoriteService.getFavoritePage(current, size));
|
||||
}
|
||||
|
||||
@Operation(summary = "检查收藏状态", description = "检查指定事件是否已被当前用户收藏。")
|
||||
@GetMapping(value = "/favorite/check")
|
||||
public Result<EventFavoriteResponse> checkFavorite(
|
||||
@Parameter(description = "事件 ID") @RequestParam String eventId) {
|
||||
return Result.success(eventFavoriteService.checkFavorite(eventId));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<String> readTags(Object value) {
|
||||
List<String> tags = new ArrayList<>();
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.emotion.dto.request.MessageSearchRequest;
|
||||
import com.emotion.dto.request.MessageRecentRequest;
|
||||
import com.emotion.dto.response.MessageResponse;
|
||||
import com.emotion.service.MessageService;
|
||||
import com.emotion.service.ScriptMessageService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -17,6 +18,7 @@ import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 消息控制器
|
||||
@@ -33,6 +35,9 @@ public class MessageController {
|
||||
@Autowired
|
||||
private MessageService messageService;
|
||||
|
||||
@Autowired
|
||||
private ScriptMessageService scriptMessageService;
|
||||
|
||||
/**
|
||||
* 创建消息
|
||||
*/
|
||||
@@ -86,6 +91,23 @@ public class MessageController {
|
||||
return Result.success(pageResult);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取会话消息列表", description = "获取指定会话下的完整消息历史。")
|
||||
@GetMapping(value = "/listByConversation")
|
||||
public Result<List<MessageResponse>> listByConversation(
|
||||
@Parameter(description = "会话 ID") @RequestParam String conversationId,
|
||||
@RequestParam(required = false, defaultValue = "false") Boolean includeVersions) {
|
||||
List<MessageResponse> messages = scriptMessageService.listByConversation(conversationId, includeVersions);
|
||||
return Result.success(messages);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取消息版本链", description = "获取指定剧本消息的所有历史版本。")
|
||||
@GetMapping(value = "/versions")
|
||||
public Result<List<MessageResponse>> getVersions(
|
||||
@Parameter(description = "消息 ID") @RequestParam String messageId) {
|
||||
List<MessageResponse> versions = scriptMessageService.getVersions(messageId);
|
||||
return Result.success(versions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新消息
|
||||
*/
|
||||
@@ -102,7 +124,7 @@ public class MessageController {
|
||||
/**
|
||||
* 删除消息
|
||||
*/
|
||||
@Operation(summary = "删除消息", description = "删除指定的消息记录。")
|
||||
@Operation(summary = "删除消息", description = "删除指定的消息。")
|
||||
@DeleteMapping(value = "/delete")
|
||||
public Result<Void> delete(@Parameter(description = "消息 ID") @RequestParam String id) {
|
||||
boolean deleted = messageService.deleteMessage(id);
|
||||
@@ -111,4 +133,14 @@ public class MessageController {
|
||||
}
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@Operation(summary = "删除版本消息", description = "删除指定的剧本版本消息,不能删除当前生效版本。")
|
||||
@DeleteMapping(value = "/deleteVersion")
|
||||
public Result<Void> deleteVersion(@Parameter(description = "消息 ID") @RequestParam String id) {
|
||||
boolean deleted = scriptMessageService.deleteVersion(id);
|
||||
if (!deleted) {
|
||||
return Result.error("删除失败");
|
||||
}
|
||||
return Result.success();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.emotion.controller;
|
||||
|
||||
import com.emotion.dto.request.ScriptChatStreamRequest;
|
||||
import com.emotion.service.ScriptChatService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
/**
|
||||
* 剧本对话流式控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/chat")
|
||||
@Tag(name = "剧本对话", description = "剧本改写、续写、聊天的统一流式接口")
|
||||
public class ScriptChatController {
|
||||
|
||||
@Autowired
|
||||
private ScriptChatService scriptChatService;
|
||||
|
||||
@Operation(summary = "流式对话", description = "统一的剧本改写/续写/聊天流式接口。")
|
||||
@PostMapping(value = "/stream", produces = "text/event-stream")
|
||||
public SseEmitter stream(@Valid @RequestBody ScriptChatStreamRequest request) {
|
||||
return scriptChatService.streamChat(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.emotion.controller;
|
||||
|
||||
import com.emotion.dto.request.ShortNovelFollowupRequest;
|
||||
import com.emotion.dto.request.ShortNovelStreamRequest;
|
||||
import com.emotion.service.ShortNovelService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
/**
|
||||
* 短篇小说外部服务代理 Controller
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Tag(name = "短篇小说管理", description = "短篇小说生成 SSE 代理接口")
|
||||
@RestController
|
||||
@RequestMapping("/shortNovel")
|
||||
public class ShortNovelController {
|
||||
|
||||
@Autowired
|
||||
private ShortNovelService shortNovelService;
|
||||
|
||||
@Operation(summary = "首次发起短篇小说生成", description = "用户输入心愿文本,发起流式生成")
|
||||
@PostMapping("/stream")
|
||||
public SseEmitter stream(@Valid @RequestBody ShortNovelStreamRequest request) {
|
||||
return shortNovelService.stream(request);
|
||||
}
|
||||
|
||||
@Operation(summary = "短篇小说后续轮次", description = "处理澄清回答、大纲确认/修改、重试等")
|
||||
@PostMapping("/followup")
|
||||
public SseEmitter followup(@Valid @RequestBody ShortNovelFollowupRequest request) {
|
||||
return shortNovelService.followup(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.emotion.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 创建剧本并创建对话请求
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class EpicScriptCreateWithDialogueRequest extends BaseRequest {
|
||||
|
||||
@NotBlank(message = "心愿/主题不能为空")
|
||||
private String theme;
|
||||
|
||||
private String style;
|
||||
|
||||
private String length;
|
||||
|
||||
private String characterInfo;
|
||||
|
||||
private String lifeEventsSummary;
|
||||
|
||||
private Boolean useSocialInsights;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.emotion.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 人生事件收藏切换请求
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
public class EventFavoriteToggleRequest {
|
||||
|
||||
@NotBlank(message = "事件ID不能为空")
|
||||
private String eventId;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.emotion.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 剧本对话流式请求
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ScriptChatStreamRequest extends BaseRequest {
|
||||
|
||||
@NotBlank(message = "操作类型不能为空")
|
||||
private String operationType;
|
||||
|
||||
@NotBlank(message = "对话ID不能为空")
|
||||
private String conversationId;
|
||||
|
||||
private String messageId;
|
||||
|
||||
private String userMessageId;
|
||||
|
||||
@NotBlank(message = "消息内容不能为空")
|
||||
private String content;
|
||||
|
||||
private String scriptId;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.emotion.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 剧本收藏切换请求
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
public class ScriptFavoriteToggleRequest {
|
||||
|
||||
@NotBlank(message = "剧本ID不能为空")
|
||||
private String scriptId;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.emotion.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 短篇小说后续轮次请求(澄清回答、大纲确认/修改等)
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ShortNovelFollowupRequest extends BaseRequest {
|
||||
|
||||
/**
|
||||
* 外部服务返回的 session_id
|
||||
*/
|
||||
@NotBlank(message = "sessionId 不能为空")
|
||||
private String sessionId;
|
||||
|
||||
/**
|
||||
* 操作类型:answer_clarification | confirm_outline | modify_outline | retry
|
||||
*/
|
||||
@NotBlank(message = "action 不能为空")
|
||||
private String action;
|
||||
|
||||
/**
|
||||
* 操作载荷(如回答内容、修改意见)
|
||||
*/
|
||||
private Map<String, Object> payload;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.emotion.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 短篇小说首次发起请求
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ShortNovelStreamRequest extends BaseRequest {
|
||||
|
||||
/**
|
||||
* 用户的心愿文本
|
||||
*/
|
||||
@NotBlank(message = "心愿文本不能为空")
|
||||
@Size(max = 500, message = "心愿文本不能超过 500 字")
|
||||
private String query;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.emotion.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 切换剧本当前生效版本请求
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class SwitchVersionRequest extends BaseRequest {
|
||||
|
||||
@NotBlank(message = "剧本ID不能为空")
|
||||
private String scriptId;
|
||||
|
||||
@NotBlank(message = "消息ID不能为空")
|
||||
private String messageId;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.emotion.dto.response;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 创建剧本并创建对话响应
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class EpicScriptCreateWithDialogueResponse extends BaseResponse {
|
||||
|
||||
private String scriptId;
|
||||
|
||||
private String conversationId;
|
||||
|
||||
private String currentVersionMessageId;
|
||||
|
||||
private String title;
|
||||
|
||||
private String theme;
|
||||
|
||||
private String style;
|
||||
|
||||
private String length;
|
||||
|
||||
private String plotIntro;
|
||||
|
||||
private String plotTurning;
|
||||
|
||||
private String plotClimax;
|
||||
|
||||
private String plotEnding;
|
||||
}
|
||||
@@ -60,6 +60,16 @@ public class EpicScriptResponse extends BaseResponse {
|
||||
*/
|
||||
private String plotEnding;
|
||||
|
||||
/**
|
||||
* 对话ID
|
||||
*/
|
||||
private String conversationId;
|
||||
|
||||
/**
|
||||
* 当前生效版本的消息ID
|
||||
*/
|
||||
private String currentVersionMessageId;
|
||||
|
||||
/**
|
||||
* 完整剧情JSON结构
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.emotion.dto.response;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 人生事件收藏状态响应
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
public class EventFavoriteResponse {
|
||||
|
||||
private String eventId;
|
||||
|
||||
private Boolean isFavorited;
|
||||
|
||||
private String favoriteTime;
|
||||
}
|
||||
@@ -52,4 +52,34 @@ public class MessageResponse extends BaseResponse {
|
||||
* 消息顺序 - 在同一个会话中递增,用于确保消息展示顺序
|
||||
*/
|
||||
private Long messageOrder;
|
||||
|
||||
/**
|
||||
* 剧本ID
|
||||
*/
|
||||
private String scriptId;
|
||||
|
||||
/**
|
||||
* 父消息ID
|
||||
*/
|
||||
private String parentMessageId;
|
||||
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
private Integer versionNumber;
|
||||
|
||||
/**
|
||||
* 元数据
|
||||
*/
|
||||
private String metadata;
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 时间戳
|
||||
*/
|
||||
private String timestamp;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.emotion.dto.response;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 剧本收藏状态响应
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
public class ScriptFavoriteResponse {
|
||||
|
||||
private String scriptId;
|
||||
|
||||
private Boolean isFavorited;
|
||||
|
||||
private String favoriteTime;
|
||||
}
|
||||
@@ -43,6 +43,12 @@ public class Conversation extends BaseEntity {
|
||||
@TableField("title")
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 关联剧本ID
|
||||
*/
|
||||
@TableField("script_id")
|
||||
private String scriptId;
|
||||
|
||||
/**
|
||||
* 对话类型
|
||||
*/
|
||||
@@ -186,4 +192,10 @@ public class Conversation extends BaseEntity {
|
||||
*/
|
||||
@TableField("metadata")
|
||||
private String metadata;
|
||||
|
||||
/**
|
||||
* 当前生效的剧本消息ID
|
||||
*/
|
||||
@TableField("current_message_id")
|
||||
private String currentMessageId;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,18 @@ public class EpicScript extends BaseEntity {
|
||||
@TableField(value = "plot_json", typeHandler = JacksonTypeHandler.class)
|
||||
private Map<String, Object> plotJson;
|
||||
|
||||
/**
|
||||
* 关联对话ID
|
||||
*/
|
||||
@TableField("conversation_id")
|
||||
private String conversationId;
|
||||
|
||||
/**
|
||||
* 当前生效版本消息ID
|
||||
*/
|
||||
@TableField("current_version_message_id")
|
||||
private String currentVersionMessageId;
|
||||
|
||||
/**
|
||||
* 是否当前选中: 0-否, 1-是
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.emotion.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.emotion.common.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
/**
|
||||
* 人生事件收藏关联实体
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@TableName("t_event_favorite")
|
||||
public class EventFavorite extends BaseEntity {
|
||||
|
||||
@TableField("user_id")
|
||||
private String userId;
|
||||
|
||||
@TableField("event_id")
|
||||
private String eventId;
|
||||
}
|
||||
@@ -31,6 +31,12 @@ public class Message extends BaseEntity {
|
||||
@TableField("conversation_id")
|
||||
private String conversationId;
|
||||
|
||||
/**
|
||||
* 关联剧本ID(冗余字段)
|
||||
*/
|
||||
@TableField("script_id")
|
||||
private String scriptId;
|
||||
|
||||
/**
|
||||
* 消息内容
|
||||
*/
|
||||
@@ -133,6 +139,12 @@ public class Message extends BaseEntity {
|
||||
@TableField("parent_message_id")
|
||||
private String parentMessageId;
|
||||
|
||||
/**
|
||||
* 版本序号
|
||||
*/
|
||||
@TableField("version_number")
|
||||
private Integer versionNumber;
|
||||
|
||||
/**
|
||||
* 情绪分析结果
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.emotion.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.emotion.common.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
/**
|
||||
* 剧本收藏关联实体
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@TableName("t_script_favorite")
|
||||
public class ScriptFavorite extends BaseEntity {
|
||||
|
||||
@TableField("user_id")
|
||||
private String userId;
|
||||
|
||||
@TableField("script_id")
|
||||
private String scriptId;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.emotion.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.emotion.entity.EventFavorite;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/**
|
||||
* 人生事件收藏 Mapper
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Mapper
|
||||
public interface EventFavoriteMapper extends BaseMapper<EventFavorite> {
|
||||
|
||||
/**
|
||||
* 查询用户对某事件的收藏记录(包含已逻辑删除的记录,绕过 @TableLogic 自动过滤)
|
||||
* 用于 toggle 时判断是否存在历史记录,避免重新收藏时产生重复记录
|
||||
*/
|
||||
@Select("SELECT * FROM t_event_favorite WHERE user_id = #{userId} AND event_id = #{eventId} LIMIT 1")
|
||||
EventFavorite selectAnyByUserAndEvent(@Param("userId") String userId, @Param("eventId") String eventId);
|
||||
|
||||
/**
|
||||
* 物理删除记录(绕过 @TableLogic 的逻辑删除)
|
||||
*/
|
||||
@Update("DELETE FROM t_event_favorite WHERE id = #{id}")
|
||||
int physicalDeleteById(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* 恢复逻辑删除的记录为已收藏状态
|
||||
*/
|
||||
@Update("UPDATE t_event_favorite SET is_deleted = 0, update_time = NOW() WHERE id = #{id}")
|
||||
int restoreById(@Param("id") String id);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.emotion.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.emotion.entity.ScriptFavorite;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/**
|
||||
* 剧本收藏Mapper接口
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Mapper
|
||||
public interface ScriptFavoriteMapper extends BaseMapper<ScriptFavorite> {
|
||||
|
||||
/**
|
||||
* 查询用户对某剧本的收藏记录(包含已逻辑删除的记录,绕过 @TableLogic 自动过滤)
|
||||
* 用于 toggle 时判断是否存在历史记录,避免 unique key 冲突
|
||||
*/
|
||||
@Select("SELECT * FROM t_script_favorite WHERE user_id = #{userId} AND script_id = #{scriptId} LIMIT 1")
|
||||
ScriptFavorite selectAnyByUserAndScript(@Param("userId") String userId, @Param("scriptId") String scriptId);
|
||||
|
||||
/**
|
||||
* 物理删除记录(绕过 @TableLogic 的逻辑删除)
|
||||
*/
|
||||
@Update("DELETE FROM t_script_favorite WHERE id = #{id}")
|
||||
int physicalDeleteById(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* 恢复逻辑删除的记录为已收藏状态
|
||||
*/
|
||||
@Update("UPDATE t_script_favorite SET is_deleted = 0, update_time = NOW() WHERE id = #{id}")
|
||||
int restoreById(@Param("id") String id);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.request.EpicScriptCreateWithDialogueRequest;
|
||||
import com.emotion.dto.response.EpicScriptCreateWithDialogueResponse;
|
||||
|
||||
/**
|
||||
* 剧本对话创建服务
|
||||
*/
|
||||
public interface EpicScriptDialogueService {
|
||||
|
||||
/**
|
||||
* 创建剧本并创建对话,生成第一条 AI 剧本消息
|
||||
*/
|
||||
EpicScriptCreateWithDialogueResponse createWithDialogue(EpicScriptCreateWithDialogueRequest request);
|
||||
}
|
||||
@@ -74,6 +74,15 @@ public interface EpicScriptService extends IService<EpicScript> {
|
||||
*/
|
||||
EpicScriptResponse selectScript(String id);
|
||||
|
||||
/**
|
||||
* 切换剧本当前生效版本
|
||||
*
|
||||
* @param scriptId 剧本ID
|
||||
* @param messageId 消息ID
|
||||
* @return 剧本响应
|
||||
*/
|
||||
EpicScriptResponse switchVersion(String scriptId, String messageId);
|
||||
|
||||
/**
|
||||
* 删除剧本
|
||||
*
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.emotion.common.PageResult;
|
||||
import com.emotion.dto.response.EventFavoriteResponse;
|
||||
import com.emotion.dto.response.LifeEventResponse;
|
||||
import com.emotion.entity.EventFavorite;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 人生事件收藏服务接口
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
public interface EventFavoriteService extends IService<EventFavorite> {
|
||||
|
||||
/**
|
||||
* 切换收藏状态(收藏/取消收藏)
|
||||
*
|
||||
* @param eventId 事件ID
|
||||
* @return 收藏状态响应
|
||||
*/
|
||||
EventFavoriteResponse toggleFavorite(String eventId);
|
||||
|
||||
/**
|
||||
* 分页查询当前用户收藏的事件列表
|
||||
*
|
||||
* @param current 当前页码
|
||||
* @param size 每页大小
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<LifeEventResponse> getFavoritePage(long current, long size);
|
||||
|
||||
/**
|
||||
* 批量查询已收藏的事件ID集合
|
||||
*
|
||||
* @param eventIds 待查询的事件ID集合
|
||||
* @return 已收藏的事件ID集合
|
||||
*/
|
||||
Set<String> getFavoritedEventIds(Set<String> eventIds);
|
||||
|
||||
/**
|
||||
* 检查收藏状态
|
||||
*
|
||||
* @param eventId 事件ID
|
||||
* @return 收藏状态响应
|
||||
*/
|
||||
EventFavoriteResponse checkFavorite(String eventId);
|
||||
|
||||
/**
|
||||
* 检查指定事件是否已被当前用户收藏
|
||||
*
|
||||
* @param eventId 事件ID
|
||||
* @return 是否已收藏
|
||||
*/
|
||||
boolean isFavorited(String eventId);
|
||||
|
||||
/**
|
||||
* 获取当前用户的收藏总数
|
||||
*
|
||||
* @return 收藏总数
|
||||
*/
|
||||
long getFavoriteCount();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.request.ScriptChatStreamRequest;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
/**
|
||||
* 剧本对话流式服务
|
||||
*/
|
||||
public interface ScriptChatService {
|
||||
|
||||
/**
|
||||
* 流式处理改写/续写/聊天请求
|
||||
*/
|
||||
SseEmitter streamChat(ScriptChatStreamRequest request);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.emotion.common.PageResult;
|
||||
import com.emotion.dto.response.EpicScriptResponse;
|
||||
import com.emotion.dto.response.ScriptFavoriteResponse;
|
||||
import com.emotion.entity.ScriptFavorite;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 剧本收藏服务接口
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
public interface ScriptFavoriteService extends IService<ScriptFavorite> {
|
||||
|
||||
ScriptFavoriteResponse toggleFavorite(String scriptId);
|
||||
|
||||
PageResult<EpicScriptResponse> getFavoritePage(long current, long size);
|
||||
|
||||
Set<String> getFavoritedScriptIds(Set<String> scriptIds);
|
||||
|
||||
boolean isFavorited(String scriptId);
|
||||
|
||||
long getFavoriteCount();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.response.MessageResponse;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 剧本消息版本管理服务
|
||||
*/
|
||||
public interface ScriptMessageService {
|
||||
|
||||
/**
|
||||
* 获取对话下的消息历史
|
||||
*/
|
||||
List<MessageResponse> listByConversation(String conversationId, boolean includeVersions);
|
||||
|
||||
/**
|
||||
* 获取某条消息的版本链
|
||||
*/
|
||||
List<MessageResponse> getVersions(String messageId);
|
||||
|
||||
/**
|
||||
* 删除指定版本消息
|
||||
*/
|
||||
boolean deleteVersion(String messageId);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.request.ShortNovelFollowupRequest;
|
||||
import com.emotion.dto.request.ShortNovelStreamRequest;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
/**
|
||||
* 短篇小说外部服务代理接口
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
public interface ShortNovelService {
|
||||
|
||||
/**
|
||||
* 首次发起短篇小说生成请求
|
||||
*
|
||||
* @param request 包含用户心愿文本
|
||||
* @return SSE 发射器
|
||||
*/
|
||||
SseEmitter stream(ShortNovelStreamRequest request);
|
||||
|
||||
/**
|
||||
* 处理后续轮次(澄清回答、大纲确认/修改、重试)
|
||||
*
|
||||
* @param request 包含 sessionId、action、payload
|
||||
* @return SSE 发射器
|
||||
*/
|
||||
SseEmitter followup(ShortNovelFollowupRequest request);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.emotion.dto.request.EpicScriptCreateWithDialogueRequest;
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.response.EpicScriptCreateWithDialogueResponse;
|
||||
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||
import com.emotion.entity.Conversation;
|
||||
import com.emotion.entity.EpicScript;
|
||||
import com.emotion.entity.Message;
|
||||
import com.emotion.mapper.EpicScriptMapper;
|
||||
import com.emotion.service.AiRuntimeService;
|
||||
import com.emotion.service.ConversationService;
|
||||
import com.emotion.service.EpicScriptDialogueService;
|
||||
import com.emotion.service.EpicScriptService;
|
||||
import com.emotion.service.MessageService;
|
||||
import com.emotion.util.SnowflakeIdGenerator;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 剧本对话创建服务实现类
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService {
|
||||
|
||||
@Autowired
|
||||
private EpicScriptService epicScriptService;
|
||||
|
||||
@Autowired
|
||||
private ConversationService conversationService;
|
||||
|
||||
@Autowired
|
||||
private MessageService messageService;
|
||||
|
||||
@Autowired
|
||||
private AiRuntimeService aiRuntimeService;
|
||||
|
||||
@Autowired
|
||||
private SnowflakeIdGenerator snowflakeIdGenerator;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public EpicScriptCreateWithDialogueResponse createWithDialogue(EpicScriptCreateWithDialogueRequest request) {
|
||||
String currentUserId = UserContextHolder.getCurrentUserId();
|
||||
if (currentUserId == null) {
|
||||
throw new IllegalStateException("用户未登录");
|
||||
}
|
||||
|
||||
String scriptId = snowflakeIdGenerator.nextIdAsString();
|
||||
String conversationId = snowflakeIdGenerator.nextIdAsString();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
// 1. 创建剧本
|
||||
EpicScript script = new EpicScript();
|
||||
script.setId(scriptId);
|
||||
script.setUserId(currentUserId);
|
||||
script.setTitle("我的人生剧本"); // 默认标题,后续会被 AI 生成的标题覆盖
|
||||
script.setTheme(request.getTheme());
|
||||
script.setStyle(StringUtils.hasText(request.getStyle()) ? request.getStyle() : "career");
|
||||
script.setLength(StringUtils.hasText(request.getLength()) ? request.getLength() : "medium");
|
||||
script.setConversationId(conversationId);
|
||||
script.setPlotJson(new HashMap<>());
|
||||
script.setIsSelected(0);
|
||||
epicScriptService.save(script);
|
||||
|
||||
// 2. 创建对话
|
||||
Conversation conversation = new Conversation();
|
||||
conversation.setId(conversationId);
|
||||
conversation.setUserId(currentUserId);
|
||||
conversation.setUserType("registered");
|
||||
conversation.setScriptId(scriptId);
|
||||
conversation.setTitle(script.getTitle());
|
||||
conversation.setType("script");
|
||||
conversation.setConversationStatus("active");
|
||||
conversation.setStartTime(now);
|
||||
conversation.setLastActiveTime(now);
|
||||
conversation.setMessageCount(0);
|
||||
conversationService.save(conversation);
|
||||
|
||||
// 3. 调用 AI 生成完整剧本
|
||||
String aiOutput = invokeScriptGenerate(request, currentUserId, conversationId, null);
|
||||
|
||||
// 4. 解析 AI 输出获取剧本元数据
|
||||
Map<String, Object> metadata = parseScriptOutput(aiOutput);
|
||||
|
||||
// 从 AI 输出中提取标题,如果没有则使用默认值
|
||||
String aiTitle = (String) metadata.getOrDefault("title", null);
|
||||
if (StringUtils.hasText(aiTitle)) {
|
||||
script.setTitle(aiTitle);
|
||||
}
|
||||
script.setPlotIntro((String) metadata.getOrDefault("plotIntro", ""));
|
||||
script.setPlotTurning((String) metadata.getOrDefault("plotTurning", ""));
|
||||
script.setPlotClimax((String) metadata.getOrDefault("plotClimax", ""));
|
||||
script.setPlotEnding((String) metadata.getOrDefault("plotEnding", ""));
|
||||
Map<String, Object> plotJson = script.getPlotJson();
|
||||
plotJson.putAll(metadata);
|
||||
script.setPlotJson(plotJson);
|
||||
|
||||
// 5. 创建系统欢迎消息(messageOrder = 1)
|
||||
Message systemWelcome = new Message();
|
||||
systemWelcome.setId(snowflakeIdGenerator.nextIdAsString());
|
||||
systemWelcome.setConversationId(conversationId);
|
||||
systemWelcome.setScriptId(scriptId);
|
||||
systemWelcome.setUserId(currentUserId);
|
||||
systemWelcome.setContent(buildSystemWelcome(metadata));
|
||||
systemWelcome.setType("system");
|
||||
systemWelcome.setSender("system");
|
||||
systemWelcome.setTimestamp(now);
|
||||
systemWelcome.setMessageOrder(1L);
|
||||
systemWelcome.setStatus("sent");
|
||||
systemWelcome.setIsRead(1);
|
||||
messageService.createMessage(systemWelcome);
|
||||
|
||||
// 6. 创建用户消息(messageOrder = 2)
|
||||
Message userMessage = new Message();
|
||||
userMessage.setId(snowflakeIdGenerator.nextIdAsString());
|
||||
userMessage.setConversationId(conversationId);
|
||||
userMessage.setScriptId(scriptId);
|
||||
userMessage.setUserId(currentUserId);
|
||||
userMessage.setContent(request.getTheme());
|
||||
userMessage.setType("chat");
|
||||
userMessage.setSender("user");
|
||||
userMessage.setTimestamp(now);
|
||||
userMessage.setMessageOrder(2L);
|
||||
userMessage.setStatus("sent");
|
||||
userMessage.setIsRead(1);
|
||||
messageService.createMessage(userMessage);
|
||||
|
||||
// 7. 创建 AI 助手开场白消息(messageOrder = 3)
|
||||
String aiContent = buildDisplayContent(metadata);
|
||||
Message aiMessage = new Message();
|
||||
aiMessage.setId(snowflakeIdGenerator.nextIdAsString());
|
||||
aiMessage.setConversationId(conversationId);
|
||||
aiMessage.setScriptId(scriptId);
|
||||
aiMessage.setUserId(currentUserId);
|
||||
aiMessage.setContent(aiContent);
|
||||
aiMessage.setType("script");
|
||||
aiMessage.setSender("assistant");
|
||||
aiMessage.setTimestamp(LocalDateTime.now());
|
||||
aiMessage.setMessageOrder(3L);
|
||||
aiMessage.setVersionNumber(1);
|
||||
aiMessage.setStatus("sent");
|
||||
aiMessage.setMetadata(JSON.toJSONString(metadata));
|
||||
messageService.createMessage(aiMessage);
|
||||
|
||||
// 8. 更新剧本信息(已在前面完成)
|
||||
script.setCurrentVersionMessageId(aiMessage.getId());
|
||||
epicScriptService.updateById(script);
|
||||
|
||||
// 9. 更新对话当前消息和消息计数
|
||||
conversation.setCurrentMessageId(aiMessage.getId());
|
||||
conversation.setMessageCount(3);
|
||||
conversationService.updateById(conversation);
|
||||
|
||||
// 10. 构建响应
|
||||
EpicScriptCreateWithDialogueResponse response = new EpicScriptCreateWithDialogueResponse();
|
||||
BeanUtils.copyProperties(script, response);
|
||||
response.setScriptId(scriptId);
|
||||
response.setConversationId(conversationId);
|
||||
response.setCurrentVersionMessageId(aiMessage.getId());
|
||||
return response;
|
||||
}
|
||||
|
||||
private String invokeScriptGenerate(EpicScriptCreateWithDialogueRequest request, String userId,
|
||||
String conversationId, String userMessageId) {
|
||||
JSONObject inputs = new JSONObject();
|
||||
inputs.put("input", request.getTheme());
|
||||
inputs.put("prompt", request.getTheme());
|
||||
inputs.put("theme", request.getTheme());
|
||||
inputs.put("style", StringUtils.hasText(request.getStyle()) ? request.getStyle() : "career");
|
||||
inputs.put("length", StringUtils.hasText(request.getLength()) ? request.getLength() : "medium");
|
||||
inputs.put("characterInfo", request.getCharacterInfo());
|
||||
inputs.put("lifeEventsSummary", request.getLifeEventsSummary());
|
||||
inputs.put("useSocialInsights", request.getUseSocialInsights());
|
||||
inputs.put("conversationId", conversationId);
|
||||
inputs.put("userMessageId", userMessageId);
|
||||
|
||||
// 使用 AiRuntimeRequest 调用 AI 运行时
|
||||
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
|
||||
runtimeRequest.setSceneCode("script_generate");
|
||||
runtimeRequest.setUserId(userId);
|
||||
runtimeRequest.setInputs(inputs);
|
||||
|
||||
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
|
||||
if (response == null || !"success".equals(response.getStatus()) || !StringUtils.hasText(response.getOutput())) {
|
||||
String message = response == null ? "AI_RUNTIME_EMPTY_RESPONSE" : response.getErrorMessage();
|
||||
throw new IllegalStateException(StringUtils.hasText(message) ? message : "AI_RUNTIME_FAILED");
|
||||
}
|
||||
return response.getOutput();
|
||||
}
|
||||
|
||||
private Map<String, Object> parseScriptOutput(String output) {
|
||||
if (!StringUtils.hasText(output)) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
try {
|
||||
return JSON.parseObject(output, Map.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("剧本输出解析失败,使用原始文本作为内容: {}", e.getMessage());
|
||||
Map<String, Object> fallback = new HashMap<>();
|
||||
fallback.put("title", "我的人生剧本");
|
||||
fallback.put("plotIntro", output);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存小说生成结果到数据库
|
||||
* 创建 EpicScript、Conversation 和 3 条 Message 记录
|
||||
*
|
||||
* @param currentUserId 当前用户 ID
|
||||
* @param theme 用户主题
|
||||
* @param fullText 完整小说文本
|
||||
* @param metadata 大纲元数据(标题等)
|
||||
* @return Map 包含 scriptId、conversationId、currentVersionMessageId
|
||||
*/
|
||||
public Map<String, String> saveNovelResult(String currentUserId, String theme,
|
||||
String fullText, Map<String, Object> metadata) {
|
||||
String scriptId = snowflakeIdGenerator.nextIdAsString();
|
||||
String conversationId = snowflakeIdGenerator.nextIdAsString();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
// 1. 创建剧本
|
||||
EpicScript script = new EpicScript();
|
||||
script.setId(scriptId);
|
||||
script.setUserId(currentUserId);
|
||||
script.setTitle(metadata != null && metadata.get("title") != null
|
||||
? (String) metadata.get("title") : "我的人生剧本");
|
||||
script.setTheme(theme);
|
||||
script.setStyle("career");
|
||||
script.setLength("medium");
|
||||
script.setConversationId(conversationId);
|
||||
script.setPlotIntro(fullText);
|
||||
script.setPlotTurning("");
|
||||
script.setPlotClimax("");
|
||||
script.setPlotEnding("");
|
||||
script.setPlotJson(metadata != null ? metadata : new HashMap<>());
|
||||
script.setIsSelected(0);
|
||||
epicScriptService.save(script);
|
||||
|
||||
// 2. 创建对话
|
||||
Conversation conversation = new Conversation();
|
||||
conversation.setId(conversationId);
|
||||
conversation.setUserId(currentUserId);
|
||||
conversation.setUserType("registered");
|
||||
conversation.setScriptId(scriptId);
|
||||
conversation.setTitle(script.getTitle());
|
||||
conversation.setType("script");
|
||||
conversation.setConversationStatus("active");
|
||||
conversation.setStartTime(now);
|
||||
conversation.setLastActiveTime(now);
|
||||
conversation.setMessageCount(3);
|
||||
conversationService.save(conversation);
|
||||
|
||||
// 3. 系统欢迎消息(messageOrder = 1)
|
||||
Message systemWelcome = new Message();
|
||||
systemWelcome.setId(snowflakeIdGenerator.nextIdAsString());
|
||||
systemWelcome.setConversationId(conversationId);
|
||||
systemWelcome.setScriptId(scriptId);
|
||||
systemWelcome.setUserId(currentUserId);
|
||||
systemWelcome.setContent("欢迎来到《" + script.getTitle() + "》剧本世界。\n\n让我们一起探索你的心愿故事。");
|
||||
systemWelcome.setType("system");
|
||||
systemWelcome.setSender("system");
|
||||
systemWelcome.setTimestamp(now);
|
||||
systemWelcome.setMessageOrder(1L);
|
||||
systemWelcome.setStatus("sent");
|
||||
systemWelcome.setIsRead(1);
|
||||
messageService.createMessage(systemWelcome);
|
||||
|
||||
// 4. 用户输入消息(messageOrder = 2)
|
||||
Message userMessage = new Message();
|
||||
userMessage.setId(snowflakeIdGenerator.nextIdAsString());
|
||||
userMessage.setConversationId(conversationId);
|
||||
userMessage.setScriptId(scriptId);
|
||||
userMessage.setUserId(currentUserId);
|
||||
userMessage.setContent(theme);
|
||||
userMessage.setType("chat");
|
||||
userMessage.setSender("user");
|
||||
userMessage.setTimestamp(now);
|
||||
userMessage.setMessageOrder(2L);
|
||||
userMessage.setStatus("sent");
|
||||
userMessage.setIsRead(1);
|
||||
messageService.createMessage(userMessage);
|
||||
|
||||
// 5. AI 小说全文消息(messageOrder = 3)
|
||||
Message aiMessage = new Message();
|
||||
aiMessage.setId(snowflakeIdGenerator.nextIdAsString());
|
||||
aiMessage.setConversationId(conversationId);
|
||||
aiMessage.setScriptId(scriptId);
|
||||
aiMessage.setUserId(currentUserId);
|
||||
aiMessage.setContent(fullText);
|
||||
aiMessage.setType("script");
|
||||
aiMessage.setSender("assistant");
|
||||
aiMessage.setTimestamp(now);
|
||||
aiMessage.setMessageOrder(3L);
|
||||
aiMessage.setVersionNumber(1);
|
||||
aiMessage.setStatus("sent");
|
||||
aiMessage.setMetadata(metadata != null ? JSON.toJSONString(metadata) : "{}");
|
||||
messageService.createMessage(aiMessage);
|
||||
|
||||
// 6. 更新剧本和对话
|
||||
script.setCurrentVersionMessageId(aiMessage.getId());
|
||||
epicScriptService.updateById(script);
|
||||
|
||||
conversation.setCurrentMessageId(aiMessage.getId());
|
||||
conversationService.updateById(conversation);
|
||||
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("scriptId", scriptId);
|
||||
result.put("conversationId", conversationId);
|
||||
result.put("currentVersionMessageId", aiMessage.getId());
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildDisplayContent(Map<String, Object> metadata) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
appendSection(sb, metadata.get("title"), null);
|
||||
appendSection(sb, metadata.get("plotIntro"), "序幕:低谷回响");
|
||||
appendSection(sb, metadata.get("plotTurning"), "转折:契机出现");
|
||||
appendSection(sb, metadata.get("plotClimax"), "高潮:命运抉择");
|
||||
appendSection(sb, metadata.get("plotEnding"), "结局:新的开始");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildSystemWelcome(Map<String, Object> metadata) {
|
||||
if (metadata == null || metadata.isEmpty()) {
|
||||
return "欢迎来到情绪博物馆,让我们一起探索你的内心世界。";
|
||||
}
|
||||
String title = (String) metadata.getOrDefault("title", "我的人生剧本");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("欢迎来到《").append(title).append("》剧本世界。\n\n");
|
||||
sb.append("在这里,你将体验一段关于");
|
||||
sb.append(metadata.getOrDefault("plotIntro", "情感探索")).append("的旅程。");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void appendSection(StringBuilder sb, Object value, String label) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
String text = String.valueOf(value);
|
||||
if (!StringUtils.hasText(text)) {
|
||||
return;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append("\n\n");
|
||||
}
|
||||
if (StringUtils.hasText(label)) {
|
||||
sb.append("【").append(label).append("】\n");
|
||||
}
|
||||
sb.append(text);
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,15 @@ import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||
import com.emotion.dto.response.EpicScriptInspirationResponse;
|
||||
import com.emotion.dto.response.EpicScriptResponse;
|
||||
import com.emotion.dto.response.InspirationSuggestionResponse;
|
||||
import com.emotion.entity.Conversation;
|
||||
import com.emotion.entity.EpicScript;
|
||||
import com.emotion.entity.Message;
|
||||
import com.emotion.mapper.EpicScriptMapper;
|
||||
import com.emotion.service.AiRuntimeService;
|
||||
import com.emotion.service.ConversationService;
|
||||
import com.emotion.service.EpicScriptService;
|
||||
import com.emotion.service.LifePathService;
|
||||
import com.emotion.service.MessageService;
|
||||
import com.emotion.service.ScriptContextService;
|
||||
import com.emotion.service.TtsTaskService;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
@@ -79,6 +83,12 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
@Autowired
|
||||
private TtsTaskService ttsTaskService;
|
||||
|
||||
@Autowired
|
||||
private MessageService messageService;
|
||||
|
||||
@Autowired
|
||||
private ConversationService conversationService;
|
||||
|
||||
@Override
|
||||
public PageResult<EpicScriptResponse> getPageByCurrentUser(EpicScriptPageRequest request) {
|
||||
String currentUserId = UserContextHolder.getCurrentUserId();
|
||||
@@ -137,7 +147,12 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
.eq(EpicScript::getIsDeleted, 0)
|
||||
.orderByDesc(EpicScript::getCreateTime);
|
||||
|
||||
return this.list(wrapper).stream()
|
||||
List<EpicScript> scripts = this.list(wrapper);
|
||||
for (EpicScript script : scripts) {
|
||||
ensureConversationForScript(script, currentUserId);
|
||||
}
|
||||
|
||||
return scripts.stream()
|
||||
.map(this::convertToResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@@ -155,6 +170,9 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
return null;
|
||||
}
|
||||
|
||||
// 历史剧本兼容:conversation 不存在时自动创建
|
||||
ensureConversationForScript(script, currentUserId);
|
||||
|
||||
return convertToResponse(script);
|
||||
}
|
||||
|
||||
@@ -675,6 +693,46 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
return convertToResponse(script);
|
||||
}
|
||||
|
||||
@Override
|
||||
@org.springframework.transaction.annotation.Transactional(rollbackFor = Exception.class)
|
||||
public EpicScriptResponse switchVersion(String scriptId, String messageId) {
|
||||
String currentUserId = UserContextHolder.getCurrentUserId();
|
||||
EpicScript script = this.getById(scriptId);
|
||||
if (script == null || !script.getUserId().equals(currentUserId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Message message = messageService.getById(messageId);
|
||||
if (message == null || !"script".equals(message.getType())
|
||||
|| !script.getConversationId().equals(message.getConversationId())) {
|
||||
throw new IllegalStateException("消息不属于该剧本");
|
||||
}
|
||||
|
||||
script.setCurrentVersionMessageId(messageId);
|
||||
Map<String, Object> metadata = parseMetadata(message.getMetadata());
|
||||
script.setTitle((String) metadata.getOrDefault("title", script.getTitle()));
|
||||
script.setPlotIntro((String) metadata.getOrDefault("plotIntro", ""));
|
||||
script.setPlotTurning((String) metadata.getOrDefault("plotTurning", ""));
|
||||
script.setPlotClimax((String) metadata.getOrDefault("plotClimax", ""));
|
||||
script.setPlotEnding((String) metadata.getOrDefault("plotEnding", ""));
|
||||
|
||||
Map<String, Object> plotJson = script.getPlotJson();
|
||||
if (plotJson == null) {
|
||||
plotJson = new java.util.HashMap<>();
|
||||
}
|
||||
plotJson.putAll(metadata);
|
||||
script.setPlotJson(plotJson);
|
||||
this.updateById(script);
|
||||
|
||||
Conversation conversation = conversationService.getById(script.getConversationId());
|
||||
if (conversation != null) {
|
||||
conversation.setCurrentMessageId(messageId);
|
||||
conversationService.updateById(conversation);
|
||||
}
|
||||
|
||||
return convertToResponse(script);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteScript(String id) {
|
||||
EpicScript script = this.getById(id);
|
||||
@@ -695,6 +753,20 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
return this.removeById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析消息元数据JSON
|
||||
*/
|
||||
private Map<String, Object> parseMetadata(String metadataJson) {
|
||||
if (!StringUtils.hasText(metadataJson)) {
|
||||
return new java.util.HashMap<>();
|
||||
}
|
||||
try {
|
||||
return com.alibaba.fastjson2.JSON.parseObject(metadataJson, Map.class);
|
||||
} catch (Exception e) {
|
||||
return new java.util.HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为响应对象
|
||||
*/
|
||||
@@ -711,4 +783,38 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为剧本补建 conversation(历史剧本兼容)
|
||||
*
|
||||
* 触发条件:script.conversationId 指向的 conversation 不存在(DB 中无对应记录)
|
||||
* 副作用:创建新 conversation,回填 script.conversationId,更新 script
|
||||
*/
|
||||
private void ensureConversationForScript(EpicScript script, String currentUserId) {
|
||||
String conversationId = script.getConversationId();
|
||||
if (!org.springframework.util.StringUtils.hasText(conversationId)) {
|
||||
// conversationId 字段本身为空,直接创建
|
||||
createAndAttachConversation(script, currentUserId);
|
||||
return;
|
||||
}
|
||||
Conversation existing = conversationService.getById(conversationId);
|
||||
if (existing != null) {
|
||||
return; // 已存在,无需处理
|
||||
}
|
||||
// 存在 conversationId 但 conversation 不存在(历史数据),创建新的
|
||||
createAndAttachConversation(script, currentUserId);
|
||||
}
|
||||
|
||||
private void createAndAttachConversation(EpicScript script, String currentUserId) {
|
||||
Conversation conversation = new Conversation();
|
||||
conversation.setUserId(currentUserId);
|
||||
conversation.setScriptId(script.getId());
|
||||
conversation.setTitle(script.getTitle() != null ? script.getTitle() : "未命名剧本");
|
||||
conversation.setType("script");
|
||||
conversation.setConversationStatus("active");
|
||||
conversationService.save(conversation);
|
||||
|
||||
script.setConversationId(conversation.getId());
|
||||
this.updateById(script);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.emotion.common.PageResult;
|
||||
import com.emotion.exception.BusinessException;
|
||||
import com.emotion.dto.response.EventFavoriteResponse;
|
||||
import com.emotion.dto.response.LifeEventResponse;
|
||||
import com.emotion.entity.EventFavorite;
|
||||
import com.emotion.mapper.EventFavoriteMapper;
|
||||
import com.emotion.service.EventFavoriteService;
|
||||
import com.emotion.service.LifeEventService;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 人生事件收藏服务实现
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class EventFavoriteServiceImpl extends ServiceImpl<EventFavoriteMapper, EventFavorite>
|
||||
implements EventFavoriteService {
|
||||
|
||||
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Autowired
|
||||
private LifeEventService lifeEventService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public EventFavoriteResponse toggleFavorite(String eventId) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
EventFavoriteResponse r = new EventFavoriteResponse();
|
||||
r.setEventId(eventId);
|
||||
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
r.setIsFavorited(false);
|
||||
return r;
|
||||
}
|
||||
|
||||
// 验证事件存在且属于当前用户
|
||||
LifeEventResponse event = lifeEventService.getEventById(eventId);
|
||||
if (event == null) {
|
||||
throw new BusinessException("事件不存在或无权限访问");
|
||||
}
|
||||
|
||||
// 查询时绕过 @TableLogic 自动过滤,包含已逻辑删除的记录
|
||||
// 避免重新收藏时产生重复记录
|
||||
EventFavorite existing = baseMapper.selectAnyByUserAndEvent(userId, eventId);
|
||||
if (existing != null) {
|
||||
if (existing.getIsDeleted() != null && existing.getIsDeleted() == 0) {
|
||||
// 已收藏 -> 物理删除(取消收藏)
|
||||
baseMapper.physicalDeleteById(existing.getId());
|
||||
r.setIsFavorited(false);
|
||||
} else {
|
||||
// 历史逻辑删除记录 -> 恢复为已收藏
|
||||
baseMapper.restoreById(existing.getId());
|
||||
r.setIsFavorited(true);
|
||||
if (existing.getCreateTime() != null) {
|
||||
r.setFavoriteTime(existing.getCreateTime().format(FMT));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EventFavorite fav = new EventFavorite();
|
||||
fav.setUserId(userId);
|
||||
fav.setEventId(eventId);
|
||||
this.save(fav);
|
||||
r.setIsFavorited(true);
|
||||
if (fav.getCreateTime() != null) {
|
||||
r.setFavoriteTime(fav.getCreateTime().format(FMT));
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<LifeEventResponse> getFavoritePage(long current, long size) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
return new PageResult<>();
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(EventFavorite::getUserId, userId)
|
||||
.eq(EventFavorite::getIsDeleted, 0)
|
||||
.orderByDesc(EventFavorite::getCreateTime);
|
||||
|
||||
Page<EventFavorite> page = new Page<>(current, size);
|
||||
Page<EventFavorite> result = this.page(page, w);
|
||||
|
||||
List<LifeEventResponse> records = result.getRecords().stream()
|
||||
.map(EventFavorite::getEventId)
|
||||
.map(lifeEventService::getEventById)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
PageResult<LifeEventResponse> pr = new PageResult<>();
|
||||
pr.setCurrent(result.getCurrent());
|
||||
pr.setSize(result.getSize());
|
||||
pr.setTotal(result.getTotal());
|
||||
pr.setPages(result.getPages());
|
||||
pr.setRecords(records);
|
||||
return pr;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getFavoritedEventIds(Set<String> eventIds) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId) || eventIds == null || eventIds.isEmpty()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(EventFavorite::getUserId, userId)
|
||||
.in(EventFavorite::getEventId, eventIds)
|
||||
.eq(EventFavorite::getIsDeleted, 0);
|
||||
|
||||
return this.list(w).stream()
|
||||
.map(EventFavorite::getEventId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventFavoriteResponse checkFavorite(String eventId) {
|
||||
EventFavoriteResponse r = new EventFavoriteResponse();
|
||||
r.setEventId(eventId);
|
||||
r.setIsFavorited(isFavorited(eventId));
|
||||
return r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFavorited(String eventId) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId) || !StringUtils.hasText(eventId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(EventFavorite::getUserId, userId)
|
||||
.eq(EventFavorite::getEventId, eventId)
|
||||
.eq(EventFavorite::getIsDeleted, 0);
|
||||
|
||||
return this.count(w) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getFavoriteCount() {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(EventFavorite::getUserId, userId)
|
||||
.eq(EventFavorite::getIsDeleted, 0);
|
||||
|
||||
return this.count(w);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.emotion.dto.request.ScriptChatStreamRequest;
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||
import com.emotion.entity.Conversation;
|
||||
import com.emotion.entity.EpicScript;
|
||||
import com.emotion.entity.Message;
|
||||
import com.emotion.service.*;
|
||||
import com.emotion.util.SnowflakeIdGenerator;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ScriptChatServiceImpl implements ScriptChatService {
|
||||
|
||||
@Autowired
|
||||
private ConversationService conversationService;
|
||||
|
||||
@Autowired
|
||||
private EpicScriptService epicScriptService;
|
||||
|
||||
@Autowired
|
||||
private MessageService messageService;
|
||||
|
||||
@Autowired
|
||||
private AiRuntimeService aiRuntimeService;
|
||||
|
||||
@Autowired
|
||||
private SnowflakeIdGenerator snowflakeIdGenerator;
|
||||
|
||||
@Autowired
|
||||
private Executor taskExecutor;
|
||||
|
||||
@Override
|
||||
public SseEmitter streamChat(ScriptChatStreamRequest request) {
|
||||
String currentUserId = UserContextHolder.getCurrentUserId();
|
||||
if (currentUserId == null) {
|
||||
throw new IllegalStateException("用户未登录");
|
||||
}
|
||||
|
||||
Conversation conversation = conversationService.getById(request.getConversationId());
|
||||
if (conversation == null || !currentUserId.equals(conversation.getUserId())) {
|
||||
throw new IllegalStateException("对话不存在或无权限");
|
||||
}
|
||||
|
||||
EpicScript script = epicScriptService.getById(conversation.getScriptId());
|
||||
if (script == null) {
|
||||
throw new IllegalStateException("关联剧本不存在");
|
||||
}
|
||||
|
||||
SseEmitter emitter = new SseEmitter(300_000L);
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
processStream(request, currentUserId, conversation, script, emitter);
|
||||
} catch (Exception e) {
|
||||
log.error("流式处理失败", e);
|
||||
sendError(emitter, e.getMessage());
|
||||
}
|
||||
}, taskExecutor);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
private void processStream(ScriptChatStreamRequest request, String userId,
|
||||
Conversation conversation, EpicScript script, SseEmitter emitter) {
|
||||
String operationType = request.getOperationType();
|
||||
String conversationId = conversation.getId();
|
||||
String scriptId = script.getId();
|
||||
|
||||
// 查询用户指令消息
|
||||
Message userMessage = messageService.getById(request.getUserMessageId());
|
||||
if (userMessage == null || !conversationId.equals(userMessage.getConversationId())) {
|
||||
throw new IllegalStateException("用户指令消息不存在");
|
||||
}
|
||||
|
||||
// 查询被改写的 AI 消息(rewrite/continue 需要)
|
||||
Message targetMessage = null;
|
||||
if ("rewrite".equals(operationType) || "continue".equals(operationType)) {
|
||||
targetMessage = messageService.getById(request.getMessageId());
|
||||
if (targetMessage == null || !"script".equals(targetMessage.getType())) {
|
||||
throw new IllegalStateException("被改写的剧本消息不存在");
|
||||
}
|
||||
}
|
||||
|
||||
// 组装 prompt 和上下文
|
||||
String prompt = buildPrompt(operationType, targetMessage, userMessage);
|
||||
Map<String, Object> extraInputs = buildExtraInputs(operationType, conversationId,
|
||||
request.getMessageId(), request.getUserMessageId(), scriptId);
|
||||
|
||||
// 调用 AI(当前项目 AI 运行时不直接支持 SSE,先按非流式调用,再按字符分片发送)
|
||||
String aiOutput = invokeAiRuntime(operationType, prompt, userId, extraInputs);
|
||||
|
||||
// 保存 AI 消息
|
||||
Map<String, Object> metadata = parseScriptOutput(aiOutput);
|
||||
String content = buildDisplayContent(metadata);
|
||||
int nextVersionNumber = calculateNextVersionNumber(targetMessage);
|
||||
|
||||
Message aiMessage = new Message();
|
||||
aiMessage.setId(snowflakeIdGenerator.nextIdAsString());
|
||||
aiMessage.setConversationId(conversationId);
|
||||
aiMessage.setScriptId(scriptId);
|
||||
aiMessage.setUserId(userId);
|
||||
aiMessage.setContent(content);
|
||||
aiMessage.setType("chat".equals(operationType) ? "chat" : "script");
|
||||
aiMessage.setSender("assistant");
|
||||
aiMessage.setTimestamp(LocalDateTime.now());
|
||||
aiMessage.setMessageOrder(calculateNextMessageOrder(conversationId));
|
||||
aiMessage.setParentMessageId(targetMessage != null ? targetMessage.getId() : null);
|
||||
aiMessage.setVersionNumber("chat".equals(operationType) ? 0 : nextVersionNumber);
|
||||
aiMessage.setStatus("sent");
|
||||
aiMessage.setMetadata("chat".equals(operationType) ? null : JSON.toJSONString(metadata));
|
||||
messageService.createMessage(aiMessage);
|
||||
|
||||
// 更新剧本快照(仅 script 类型)
|
||||
if (!"chat".equals(operationType)) {
|
||||
script.setCurrentVersionMessageId(aiMessage.getId());
|
||||
script.setTitle((String) metadata.getOrDefault("title", script.getTitle()));
|
||||
script.setPlotIntro((String) metadata.getOrDefault("plotIntro", ""));
|
||||
script.setPlotTurning((String) metadata.getOrDefault("plotTurning", ""));
|
||||
script.setPlotClimax((String) metadata.getOrDefault("plotClimax", ""));
|
||||
script.setPlotEnding((String) metadata.getOrDefault("plotEnding", ""));
|
||||
Map<String, Object> plotJson = script.getPlotJson();
|
||||
if (plotJson == null) {
|
||||
plotJson = new HashMap<>();
|
||||
}
|
||||
plotJson.putAll(metadata);
|
||||
script.setPlotJson(plotJson);
|
||||
epicScriptService.updateById(script);
|
||||
|
||||
conversation.setCurrentMessageId(aiMessage.getId());
|
||||
}
|
||||
conversation.setLastActiveTime(LocalDateTime.now());
|
||||
conversation.setMessageCount(conversation.getMessageCount() + 1);
|
||||
conversationService.updateById(conversation);
|
||||
|
||||
// 流式发送内容(简单分片模拟,后续可接入真正 SSE)
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("start").data("{}"));
|
||||
int chunkSize = 20;
|
||||
for (int i = 0; i < content.length(); i += chunkSize) {
|
||||
String chunk = content.substring(i, Math.min(i + chunkSize, content.length()));
|
||||
emitter.send(SseEmitter.event().name("delta").data(chunk));
|
||||
Thread.sleep(30);
|
||||
}
|
||||
emitter.send(SseEmitter.event().name("metadata").data(JSON.toJSONString(metadata)));
|
||||
emitter.send(SseEmitter.event().name("done").data(JSON.toJSONString(Map.of(
|
||||
"messageId", aiMessage.getId(),
|
||||
"versionNumber", aiMessage.getVersionNumber()
|
||||
))));
|
||||
emitter.complete();
|
||||
} catch (IOException | InterruptedException e) {
|
||||
log.error("发送 SSE 事件失败", e);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildPrompt(String operationType, Message targetMessage, Message userMessage) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("操作类型:").append(operationType).append("\n");
|
||||
if (targetMessage != null && StringUtils.hasText(targetMessage.getMetadata())) {
|
||||
sb.append("当前剧本内容:\n").append(targetMessage.getMetadata()).append("\n\n");
|
||||
}
|
||||
sb.append("用户指令:").append(userMessage.getContent());
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private Map<String, Object> buildExtraInputs(String operationType, String conversationId,
|
||||
String messageId, String userMessageId, String scriptId) {
|
||||
Map<String, Object> extras = new HashMap<>();
|
||||
extras.put("operationType", operationType);
|
||||
extras.put("conversationId", conversationId);
|
||||
extras.put("messageId", messageId);
|
||||
extras.put("userMessageId", userMessageId);
|
||||
extras.put("scriptId", scriptId);
|
||||
return extras;
|
||||
}
|
||||
|
||||
private String invokeAiRuntime(String operationType, String input, String userId, Map<String, Object> extraInputs) {
|
||||
JSONObject inputs = new JSONObject();
|
||||
inputs.put("input", input);
|
||||
inputs.put("prompt", input);
|
||||
inputs.put("message", input);
|
||||
if (extraInputs != null) {
|
||||
inputs.putAll(extraInputs);
|
||||
}
|
||||
|
||||
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
|
||||
runtimeRequest.setSceneCode("script_generate");
|
||||
runtimeRequest.setUserId(userId);
|
||||
runtimeRequest.setInputs(inputs);
|
||||
|
||||
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
|
||||
if (response == null || !"success".equals(response.getStatus()) || !StringUtils.hasText(response.getOutput())) {
|
||||
String message = response == null ? "AI_RUNTIME_EMPTY_RESPONSE" : response.getErrorMessage();
|
||||
throw new IllegalStateException(StringUtils.hasText(message) ? message : "AI_RUNTIME_FAILED");
|
||||
}
|
||||
return response.getOutput();
|
||||
}
|
||||
|
||||
private Map<String, Object> parseScriptOutput(String output) {
|
||||
if (!StringUtils.hasText(output)) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
try {
|
||||
return JSON.parseObject(output, Map.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("JSON解析失败,返回空Map: {}", output, e);
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private String buildDisplayContent(Map<String, Object> metadata) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
appendSection(sb, metadata.get("title"), null);
|
||||
appendSection(sb, metadata.get("plotIntro"), "序幕:低谷回响");
|
||||
appendSection(sb, metadata.get("plotTurning"), "转折:契机出现");
|
||||
appendSection(sb, metadata.get("plotClimax"), "高潮:命运抉择");
|
||||
appendSection(sb, metadata.get("plotEnding"), "结局:新的开始");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void appendSection(StringBuilder sb, Object value, String label) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
String text = String.valueOf(value);
|
||||
if (!StringUtils.hasText(text)) {
|
||||
return;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append("\n\n");
|
||||
}
|
||||
if (StringUtils.hasText(label)) {
|
||||
sb.append("【").append(label).append("】\n");
|
||||
}
|
||||
sb.append(text);
|
||||
}
|
||||
|
||||
private int calculateNextVersionNumber(Message targetMessage) {
|
||||
if (targetMessage == null || !StringUtils.hasText(targetMessage.getParentMessageId())) {
|
||||
return 1;
|
||||
}
|
||||
// 查询同一 parentMessageId 下最大 version_number
|
||||
LambdaQueryWrapper<Message> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Message::getParentMessageId, targetMessage.getParentMessageId())
|
||||
.orderByDesc(Message::getVersionNumber)
|
||||
.last("LIMIT 1");
|
||||
Message latest = messageService.getOne(wrapper);
|
||||
if (latest == null) {
|
||||
return 1;
|
||||
}
|
||||
return latest.getVersionNumber() + 1;
|
||||
}
|
||||
|
||||
private long calculateNextMessageOrder(String conversationId) {
|
||||
Message lastMessage = messageService.getLastMessageByConversationId(conversationId);
|
||||
return lastMessage == null ? 1L : lastMessage.getMessageOrder() + 1;
|
||||
}
|
||||
|
||||
private void sendError(SseEmitter emitter, String message) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("error").data(message));
|
||||
emitter.complete();
|
||||
} catch (IOException e) {
|
||||
log.error("发送错误事件失败", e);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.emotion.common.PageResult;
|
||||
import com.emotion.exception.BusinessException;
|
||||
import com.emotion.dto.response.EpicScriptResponse;
|
||||
import com.emotion.dto.response.ScriptFavoriteResponse;
|
||||
import com.emotion.entity.ScriptFavorite;
|
||||
import com.emotion.mapper.ScriptFavoriteMapper;
|
||||
import com.emotion.service.EpicScriptService;
|
||||
import com.emotion.service.ScriptFavoriteService;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 剧本收藏服务实现
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ScriptFavoriteServiceImpl extends ServiceImpl<ScriptFavoriteMapper, ScriptFavorite>
|
||||
implements ScriptFavoriteService {
|
||||
|
||||
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Autowired
|
||||
private EpicScriptService epicScriptService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ScriptFavoriteResponse toggleFavorite(String scriptId) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
ScriptFavoriteResponse r = new ScriptFavoriteResponse();
|
||||
r.setScriptId(scriptId);
|
||||
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
r.setIsFavorited(false);
|
||||
return r;
|
||||
}
|
||||
|
||||
// 验证剧本存在且属于当前用户
|
||||
EpicScriptResponse script = epicScriptService.getScriptById(scriptId);
|
||||
if (script == null) {
|
||||
throw new BusinessException("剧本不存在或无权限访问");
|
||||
}
|
||||
|
||||
// 查询时绕过 @TableLogic 自动过滤,包含已逻辑删除的记录
|
||||
// 避免 unique key 冲突导致无法重新收藏
|
||||
ScriptFavorite existing = baseMapper.selectAnyByUserAndScript(userId, scriptId);
|
||||
if (existing != null) {
|
||||
if (existing.getIsDeleted() != null && existing.getIsDeleted() == 0) {
|
||||
// 已收藏 -> 物理删除(取消收藏)
|
||||
baseMapper.physicalDeleteById(existing.getId());
|
||||
r.setIsFavorited(false);
|
||||
} else {
|
||||
// 历史逻辑删除记录 -> 恢复为已收藏
|
||||
baseMapper.restoreById(existing.getId());
|
||||
r.setIsFavorited(true);
|
||||
if (existing.getCreateTime() != null) {
|
||||
r.setFavoriteTime(existing.getCreateTime().format(FMT));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ScriptFavorite fav = new ScriptFavorite();
|
||||
fav.setUserId(userId);
|
||||
fav.setScriptId(scriptId);
|
||||
this.save(fav);
|
||||
r.setIsFavorited(true);
|
||||
if (fav.getCreateTime() != null) {
|
||||
r.setFavoriteTime(fav.getCreateTime().format(FMT));
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<EpicScriptResponse> getFavoritePage(long current, long size) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
return new PageResult<>();
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<ScriptFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(ScriptFavorite::getUserId, userId)
|
||||
.eq(ScriptFavorite::getIsDeleted, 0)
|
||||
.orderByDesc(ScriptFavorite::getCreateTime);
|
||||
|
||||
Page<ScriptFavorite> page = new Page<>(current, size);
|
||||
Page<ScriptFavorite> result = this.page(page, w);
|
||||
|
||||
List<EpicScriptResponse> records = result.getRecords().stream()
|
||||
.map(ScriptFavorite::getScriptId)
|
||||
.map(epicScriptService::getScriptById)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
PageResult<EpicScriptResponse> pr = new PageResult<>();
|
||||
pr.setCurrent(result.getCurrent());
|
||||
pr.setSize(result.getSize());
|
||||
pr.setTotal(result.getTotal());
|
||||
pr.setPages(result.getPages());
|
||||
pr.setRecords(records);
|
||||
return pr;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getFavoritedScriptIds(Set<String> scriptIds) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId) || scriptIds == null || scriptIds.isEmpty()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<ScriptFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(ScriptFavorite::getUserId, userId)
|
||||
.in(ScriptFavorite::getScriptId, scriptIds)
|
||||
.eq(ScriptFavorite::getIsDeleted, 0);
|
||||
|
||||
return this.list(w).stream()
|
||||
.map(ScriptFavorite::getScriptId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFavorited(String scriptId) {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId) || !StringUtils.hasText(scriptId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<ScriptFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(ScriptFavorite::getUserId, userId)
|
||||
.eq(ScriptFavorite::getScriptId, scriptId)
|
||||
.eq(ScriptFavorite::getIsDeleted, 0);
|
||||
|
||||
return this.count(w) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getFavoriteCount() {
|
||||
String userId = UserContextHolder.getCurrentUserId();
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<ScriptFavorite> w = new LambdaQueryWrapper<>();
|
||||
w.eq(ScriptFavorite::getUserId, userId)
|
||||
.eq(ScriptFavorite::getIsDeleted, 0);
|
||||
|
||||
return this.count(w);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.emotion.dto.response.MessageResponse;
|
||||
import com.emotion.entity.Conversation;
|
||||
import com.emotion.entity.EpicScript;
|
||||
import com.emotion.entity.Message;
|
||||
import com.emotion.service.ConversationService;
|
||||
import com.emotion.service.EpicScriptService;
|
||||
import com.emotion.service.MessageService;
|
||||
import com.emotion.service.ScriptMessageService;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 剧本消息版本管理服务实现
|
||||
*/
|
||||
@Service
|
||||
public class ScriptMessageServiceImpl implements ScriptMessageService {
|
||||
|
||||
@Autowired
|
||||
private MessageService messageService;
|
||||
|
||||
@Autowired
|
||||
private ConversationService conversationService;
|
||||
|
||||
@Autowired
|
||||
private EpicScriptService epicScriptService;
|
||||
|
||||
@Override
|
||||
public List<MessageResponse> listByConversation(String conversationId, boolean includeVersions) {
|
||||
String currentUserId = UserContextHolder.getCurrentUserId();
|
||||
Conversation conversation = conversationService.getById(conversationId);
|
||||
|
||||
// 对话不存在:视为"尚无消息",返回空列表(前端用临时 ID 查询时走这条分支)
|
||||
if (conversation == null) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
|
||||
// 对话存在但归属他人:权限问题,必须暴露
|
||||
if (!conversation.getUserId().equals(currentUserId)) {
|
||||
throw new IllegalStateException("无权限访问该对话");
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<Message> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Message::getConversationId, conversationId);
|
||||
if (!includeVersions) {
|
||||
// 只返回当前生效版本链上的消息:parent_message_id 为空的 script 消息 + 所有 chat 消息
|
||||
wrapper.and(w -> w.isNull(Message::getParentMessageId).or().ne(Message::getType, "script"));
|
||||
}
|
||||
wrapper.orderByAsc(Message::getMessageOrder);
|
||||
List<Message> messages = messageService.list(wrapper);
|
||||
return messages.stream().map(this::convertToResponse).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MessageResponse> getVersions(String messageId) {
|
||||
String currentUserId = UserContextHolder.getCurrentUserId();
|
||||
Message message = messageService.getById(messageId);
|
||||
if (message == null) {
|
||||
throw new IllegalStateException("消息不存在");
|
||||
}
|
||||
Conversation conversation = conversationService.getById(message.getConversationId());
|
||||
if (conversation == null || !conversation.getUserId().equals(currentUserId)) {
|
||||
throw new IllegalStateException("无权限");
|
||||
}
|
||||
|
||||
// 找到根消息
|
||||
String rootId = message.getParentMessageId() == null ? message.getId() : message.getParentMessageId();
|
||||
Message root = messageService.getById(rootId);
|
||||
|
||||
LambdaQueryWrapper<Message> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Message::getParentMessageId, rootId)
|
||||
.orderByAsc(Message::getVersionNumber);
|
||||
List<Message> versions = messageService.list(wrapper);
|
||||
|
||||
List<MessageResponse> result = new ArrayList<>();
|
||||
if (root != null) {
|
||||
result.add(convertToResponse(root));
|
||||
}
|
||||
result.addAll(versions.stream().map(this::convertToResponse).collect(Collectors.toList()));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteVersion(String messageId) {
|
||||
String currentUserId = UserContextHolder.getCurrentUserId();
|
||||
Message message = messageService.getById(messageId);
|
||||
if (message == null) {
|
||||
throw new IllegalStateException("消息不存在");
|
||||
}
|
||||
Conversation conversation = conversationService.getById(message.getConversationId());
|
||||
if (conversation == null || !conversation.getUserId().equals(currentUserId)) {
|
||||
throw new IllegalStateException("无权限");
|
||||
}
|
||||
|
||||
EpicScript script = epicScriptService.getById(conversation.getScriptId());
|
||||
if (script == null) {
|
||||
throw new IllegalStateException("剧本不存在");
|
||||
}
|
||||
|
||||
// 不能删除当前生效版本
|
||||
if (messageId.equals(script.getCurrentVersionMessageId())) {
|
||||
throw new IllegalStateException("不能删除当前生效版本");
|
||||
}
|
||||
|
||||
// 不能删除有子版本的父消息
|
||||
if (message.getParentMessageId() == null) {
|
||||
LambdaQueryWrapper<Message> childWrapper = new LambdaQueryWrapper<>();
|
||||
childWrapper.eq(Message::getParentMessageId, messageId);
|
||||
long childCount = messageService.count(childWrapper);
|
||||
if (childCount > 0) {
|
||||
throw new IllegalStateException("不能删除有子版本的父消息");
|
||||
}
|
||||
}
|
||||
|
||||
String parentId = message.getParentMessageId();
|
||||
boolean removed = messageService.removeById(messageId);
|
||||
if (!removed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 重新计算同一 parent 下剩余版本的 version_number
|
||||
if (StringUtils.hasText(parentId)) {
|
||||
LambdaQueryWrapper<Message> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Message::getParentMessageId, parentId)
|
||||
.orderByAsc(Message::getMessageOrder);
|
||||
List<Message> siblings = messageService.list(wrapper);
|
||||
for (int i = 0; i < siblings.size(); i++) {
|
||||
Message sibling = siblings.get(i);
|
||||
sibling.setVersionNumber(i + 2);
|
||||
messageService.updateById(sibling);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private MessageResponse convertToResponse(Message message) {
|
||||
MessageResponse response = new MessageResponse();
|
||||
BeanUtils.copyProperties(message, response);
|
||||
response.setId(message.getId());
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.emotion.config.ShortNovelConfig;
|
||||
import com.emotion.dto.request.ShortNovelFollowupRequest;
|
||||
import com.emotion.dto.request.ShortNovelStreamRequest;
|
||||
import com.emotion.exception.BusinessException;
|
||||
import com.emotion.service.ShortNovelService;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* 短篇小说外部服务代理实现
|
||||
* 使用 RestTemplate 读取外部 SSE 流并通过 SseEmitter 转发给小程序前端
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@Service
|
||||
public class ShortNovelServiceImpl implements ShortNovelService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ShortNovelServiceImpl.class);
|
||||
private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool();
|
||||
|
||||
@Autowired
|
||||
private ShortNovelConfig config;
|
||||
|
||||
@Autowired
|
||||
private EpicScriptDialogueServiceImpl epicScriptDialogueServiceImpl;
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
@Override
|
||||
public SseEmitter stream(ShortNovelStreamRequest request) {
|
||||
String currentUserId = UserContextHolder.getCurrentUserId();
|
||||
if (currentUserId == null) {
|
||||
throw new BusinessException("用户未登录");
|
||||
}
|
||||
|
||||
Map<String, Object> upstreamBody = new HashMap<>();
|
||||
upstreamBody.put("user_id", currentUserId);
|
||||
upstreamBody.put("message_id", "web_" + System.currentTimeMillis());
|
||||
upstreamBody.put("query", request.getQuery());
|
||||
|
||||
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, request.getQuery());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SseEmitter followup(ShortNovelFollowupRequest request) {
|
||||
String currentUserId = UserContextHolder.getCurrentUserId();
|
||||
if (currentUserId == null) {
|
||||
throw new BusinessException("用户未登录");
|
||||
}
|
||||
|
||||
Map<String, Object> upstreamBody = new HashMap<>();
|
||||
upstreamBody.put("session_id", request.getSessionId());
|
||||
upstreamBody.put("user_id", currentUserId);
|
||||
upstreamBody.put("message_id", "web_" + System.currentTimeMillis());
|
||||
upstreamBody.put("action", request.getAction());
|
||||
upstreamBody.put("payload", request.getPayload());
|
||||
|
||||
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 SSE 转发逻辑
|
||||
*
|
||||
* @param path 外部服务路径
|
||||
* @param body 请求体
|
||||
* @param currentUserId 当前用户ID(从主线程捕获,避免 ThreadLocal 在异步线程中失效)
|
||||
* @param originalQuery 原始用户查询(首次请求时传入,用于保存)
|
||||
*/
|
||||
private SseEmitter forwardSse(String path, Map<String, Object> body, String currentUserId, String originalQuery) {
|
||||
SseEmitter emitter = new SseEmitter(config.getReadTimeout().longValue());
|
||||
|
||||
EXECUTOR.execute(() -> {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-API-Token", config.getApiToken());
|
||||
headers.set("Accept", "text/event-stream");
|
||||
|
||||
String url = config.getApiBaseUrl() + path;
|
||||
|
||||
try {
|
||||
ResponseEntity<org.springframework.core.io.Resource> response = restTemplate.exchange(
|
||||
url, HttpMethod.POST,
|
||||
new HttpEntity<>(JSON.toJSONString(body), headers),
|
||||
org.springframework.core.io.Resource.class);
|
||||
|
||||
InputStream inputStream = response.getBody().getInputStream();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
|
||||
|
||||
String line;
|
||||
StringBuilder dataBuffer = new StringBuilder();
|
||||
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.startsWith("data:")) {
|
||||
dataBuffer.append(line.substring(5).trim());
|
||||
} else if (line.isEmpty() && dataBuffer.length() > 0) {
|
||||
String dataStr = dataBuffer.toString();
|
||||
dataBuffer.setLength(0);
|
||||
|
||||
if ("[DONE]".equals(dataStr)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
JSONObject event = JSON.parseObject(dataStr);
|
||||
String type = event.getString("type");
|
||||
|
||||
// 拦截 novel_done 事件,保存到数据库
|
||||
if ("novel_done".equals(type)) {
|
||||
JSONObject payload = event.getJSONObject("payload");
|
||||
if (payload != null && originalQuery != null) {
|
||||
String fullText = payload.getString("full_text");
|
||||
if (fullText != null) {
|
||||
Map<String, Object> metadata = new HashMap<>();
|
||||
if (payload.get("title") != null) {
|
||||
metadata.put("title", payload.get("title"));
|
||||
}
|
||||
Map<String, String> saveResult = epicScriptDialogueServiceImpl.saveNovelResult(
|
||||
currentUserId,
|
||||
originalQuery,
|
||||
fullText,
|
||||
metadata);
|
||||
|
||||
// 注入 scriptId 到事件中
|
||||
payload.put("scriptId", saveResult.get("scriptId"));
|
||||
payload.put("conversationId", saveResult.get("conversationId"));
|
||||
payload.put("currentVersionMessageId", saveResult.get("currentVersionMessageId"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转发事件给前端
|
||||
emitter.send(SseEmitter.event().name(type).data(event.toJSONString()));
|
||||
} catch (Exception parseEx) {
|
||||
log.warn("SSE 事件解析失败: {}", parseEx.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
log.error("SSE 代理异常: {}", e.getMessage(), e);
|
||||
try {
|
||||
JSONObject errorEvent = new JSONObject();
|
||||
errorEvent.put("type", "error");
|
||||
JSONObject errorPayload = new JSONObject();
|
||||
errorPayload.put("code", "UPSTREAM_ERROR");
|
||||
errorPayload.put("message", "外部服务异常,请稍后重试");
|
||||
errorEvent.put("payload", errorPayload);
|
||||
emitter.send(SseEmitter.event().name("error").data(errorEvent.toJSONString()));
|
||||
} catch (Exception ignored) {
|
||||
// 忽略发送错误事件时的异常
|
||||
}
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
}
|
||||
@@ -142,3 +142,10 @@ async:
|
||||
queue-capacity: 200
|
||||
keep-alive-seconds: 60
|
||||
thread-name-prefix: single-async-
|
||||
|
||||
# 短篇小说外部服务配置
|
||||
short-novel:
|
||||
api-base-url: http://49.232.138.53:8010
|
||||
api-token: c67d4a95b0bb92470a24d534302c0d40
|
||||
connect-timeout: 10000
|
||||
read-timeout: 300000
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- 剧本表新增对话关联字段
|
||||
ALTER TABLE t_epic_script
|
||||
ADD COLUMN conversation_id VARCHAR(64) DEFAULT NULL COMMENT '关联对话ID',
|
||||
ADD COLUMN current_version_message_id VARCHAR(64) DEFAULT NULL COMMENT '当前生效版本消息ID',
|
||||
ADD UNIQUE KEY uk_user_conversation (user_id, conversation_id),
|
||||
ADD KEY idx_conversation_id (conversation_id);
|
||||
|
||||
-- 对话表新增剧本关联字段
|
||||
ALTER TABLE t_conversation
|
||||
ADD COLUMN script_id VARCHAR(64) DEFAULT NULL COMMENT '关联剧本ID',
|
||||
ADD COLUMN current_message_id VARCHAR(64) DEFAULT NULL COMMENT '当前生效的剧本消息ID',
|
||||
ADD KEY idx_script_id (script_id),
|
||||
ADD KEY idx_user_type (user_id, type);
|
||||
|
||||
-- 消息表新增剧本关联和版本字段
|
||||
ALTER TABLE t_message
|
||||
ADD COLUMN script_id VARCHAR(64) DEFAULT NULL COMMENT '关联剧本ID(冗余字段)',
|
||||
ADD COLUMN version_number INT DEFAULT 0 COMMENT '版本序号(同一parent_message_id下递增)',
|
||||
ADD KEY idx_script_id (script_id),
|
||||
ADD KEY idx_parent_message_id (parent_message_id);
|
||||
|
||||
-- 注意:type、metadata 字段已存在,无需新增
|
||||
|
||||
-- 历史数据回填:根据 plot_json 中的 conversationId 回填 t_epic_script.conversation_id
|
||||
UPDATE t_epic_script s
|
||||
LEFT JOIN t_conversation c ON c.id = JSON_UNQUOTE(JSON_EXTRACT(s.plot_json, '$.conversationId'))
|
||||
SET s.conversation_id = c.id
|
||||
WHERE s.conversation_id IS NULL
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(s.plot_json, '$.conversationId')) IS NOT NULL;
|
||||
|
||||
-- 历史数据回填:根据 conversation_id 回填 t_conversation.script_id
|
||||
UPDATE t_conversation c
|
||||
JOIN t_epic_script s ON s.conversation_id = c.id
|
||||
SET c.script_id = s.id
|
||||
WHERE c.script_id IS NULL;
|
||||
|
||||
-- 历史消息回填 script_id 和 type
|
||||
UPDATE t_message m
|
||||
JOIN t_conversation c ON c.id = m.conversation_id
|
||||
SET m.script_id = c.script_id
|
||||
WHERE c.script_id IS NOT NULL;
|
||||
|
||||
UPDATE t_message
|
||||
SET type = 'chat'
|
||||
WHERE type IS NULL OR type = '';
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE t_event_favorite (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '事件ID',
|
||||
create_by VARCHAR(64) COMMENT '创建人',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_by VARCHAR(64) COMMENT '更新人',
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是',
|
||||
remarks VARCHAR(500) COMMENT '备注',
|
||||
UNIQUE KEY uk_user_event (user_id, event_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表';
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 剧本收藏关联表
|
||||
-- 存储用户对剧本的收藏关系,多对多关联
|
||||
CREATE TABLE t_script_favorite (
|
||||
id VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '主键ID',
|
||||
user_id VARCHAR(64) NOT NULL COMMENT '用户ID,关联t_user.id',
|
||||
script_id VARCHAR(64) NOT NULL COMMENT '剧本ID,关联t_epic_script.id',
|
||||
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间',
|
||||
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人',
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除',
|
||||
remarks VARCHAR(500) DEFAULT '' COMMENT '备注(预留,可存收藏夹名或备注)',
|
||||
|
||||
UNIQUE KEY uk_user_script (user_id, script_id),
|
||||
KEY idx_user_id (user_id),
|
||||
KEY idx_script_id (script_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='剧本收藏关联表';
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.emotion.controller;
|
||||
|
||||
import com.emotion.service.ShortNovelService;
|
||||
import com.emotion.util.JwtUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* ShortNovelController 测试
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
public class ShortNovelControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private ShortNovelService shortNovelService;
|
||||
|
||||
@MockBean
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
when(jwtUtil.validateToken("test-token")).thenReturn(true);
|
||||
when(jwtUtil.getUserIdFromToken("test-token")).thenReturn("test-user");
|
||||
when(jwtUtil.getUsernameFromToken("test-token")).thenReturn("tester");
|
||||
when(jwtUtil.getUserTypeFromToken("test-token")).thenReturn("user");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStreamEndpoint() throws Exception {
|
||||
SseEmitter emitter = new SseEmitter();
|
||||
when(shortNovelService.stream(any())).thenReturn(emitter);
|
||||
|
||||
mockMvc.perform(post("/shortNovel/stream")
|
||||
.header("Authorization", "Bearer test-token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"今天我想当 CEO\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFollowupEndpoint() throws Exception {
|
||||
SseEmitter emitter = new SseEmitter();
|
||||
when(shortNovelService.followup(any())).thenReturn(emitter);
|
||||
|
||||
mockMvc.perform(post("/shortNovel/followup")
|
||||
.header("Authorization", "Bearer test-token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"sessionId\":\"sess-123\",\"action\":\"confirm_outline\",\"payload\":{}}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStreamWithBlankQuery() throws Exception {
|
||||
mockMvc.perform(post("/shortNovel/stream")
|
||||
.header("Authorization", "Bearer test-token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.emotion.dto.request.EpicScriptCreateWithDialogueRequest;
|
||||
import com.emotion.dto.response.EpicScriptCreateWithDialogueResponse;
|
||||
import com.emotion.service.EpicScriptDialogueService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@SpringBootTest
|
||||
public class EpicScriptDialogueServiceImplTest {
|
||||
|
||||
@Autowired
|
||||
private EpicScriptDialogueService epicScriptDialogueService;
|
||||
|
||||
@Test
|
||||
public void testCreateWithDialogue_unauthenticated_shouldFail() {
|
||||
EpicScriptCreateWithDialogueRequest request = new EpicScriptCreateWithDialogueRequest();
|
||||
request.setTheme("我想成为作家");
|
||||
assertThrows(IllegalStateException.class, () -> {
|
||||
epicScriptDialogueService.createWithDialogue(request);
|
||||
});
|
||||
}
|
||||
}
|
||||
+201
-50
@@ -3,19 +3,30 @@
|
||||
"""
|
||||
服务器日志下载脚本
|
||||
|
||||
从生产服务器下载后端日志到本地 logs/server/ 目录
|
||||
从生产服务器下载后端日志到本地 logs/server/ 目录,支持多种查询模式。
|
||||
|
||||
使用方法:
|
||||
python tools/download-server-log.py
|
||||
python tools/download-server-log.py # 默认: 下载完整日志
|
||||
python tools/download-server-log.py latest # 下载完整日志
|
||||
python tools/download-server-log.py tail [N] # 只看最近 N 行 (默认 200)
|
||||
python tools/download-server-log.py errors [N] # 只看最近 N 条 ERROR/Exception
|
||||
python tools/download-server-log.py grep <keyword> [N] # 按关键词搜索 (默认 200 条)
|
||||
|
||||
公共参数:
|
||||
--lines N tail/errors/grep 返回的最大行数,默认 200
|
||||
--context N grep 时显示的上下文行数,默认 3
|
||||
-q, --quiet 静默模式,只写文件不打印到 stdout
|
||||
|
||||
Author: Peanut
|
||||
Created: 2026-06-03
|
||||
Purpose: 从服务器下载 emotion-single.log 到本地,方便排查问题
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================================================
|
||||
@@ -25,43 +36,47 @@ SERVER_IP = "101.200.208.45"
|
||||
USERNAME = "root"
|
||||
REMOTE_LOG = "/data/logs/emotion-museum/emotion-single.log"
|
||||
|
||||
# 本地路径(项目根目录下的 logs/server/)
|
||||
PROJECT_DIR = Path(__file__).parent.parent.absolute()
|
||||
LOCAL_LOG_DIR = PROJECT_DIR / "logs" / "server"
|
||||
LOCAL_LOG = LOCAL_LOG_DIR / "emotion-single.log"
|
||||
BACKUP_LOG = LOCAL_LOG_DIR / "emotion-single.log.bak"
|
||||
|
||||
SSH_COMMON = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=no"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 日志输出
|
||||
# ============================================================================
|
||||
def log_info(msg):
|
||||
print(f"\033[32m[INFO]\033[0m {msg}")
|
||||
print(f"\033[32m[INFO]\033[0m {msg}", flush=True)
|
||||
|
||||
|
||||
def log_warn(msg):
|
||||
print(f"\033[33m[WARN]\033[0m {msg}")
|
||||
print(f"\033[33m[WARN]\033[0m {msg}", flush=True)
|
||||
|
||||
|
||||
def log_error(msg):
|
||||
print(f"\033[31m[ERROR]\033[0m {msg}")
|
||||
print(f"\033[31m[ERROR]\033[0m {msg}", flush=True)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 核心逻辑
|
||||
# 基础工具
|
||||
# ============================================================================
|
||||
def timestamp_suffix():
|
||||
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
|
||||
def ensure_local_dir():
|
||||
LOCAL_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def check_ssh():
|
||||
"""检查 SSH 连接是否正常"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
|
||||
f"{USERNAME}@{SERVER_IP}", "echo ok"],
|
||||
["ssh", *SSH_COMMON, f"{USERNAME}@{SERVER_IP}", "echo ok"],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
if result.returncode == 0:
|
||||
log_info("SSH 连接正常")
|
||||
return True
|
||||
else:
|
||||
log_error(f"SSH 连接失败: {result.stderr.strip()}")
|
||||
log_error(f"请确认已配置免密登录: ssh {USERNAME}@{SERVER_IP}")
|
||||
return False
|
||||
@@ -69,16 +84,15 @@ def check_ssh():
|
||||
log_error("SSH 连接超时")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
log_error("未找到 ssh 命令,请确认已安装 OpenSSH 客户端")
|
||||
log_error("未找到 ssh 命令,请确认已安装 OpenSSH client")
|
||||
return False
|
||||
|
||||
|
||||
def get_remote_file_size():
|
||||
"""获取远程日志文件大小(字节)"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
|
||||
f"{USERNAME}@{SERVER_IP}", f"stat -c %s {REMOTE_LOG} 2>/dev/null || echo -1"],
|
||||
["ssh", *SSH_COMMON, f"{USERNAME}@{SERVER_IP}",
|
||||
f"stat -c %s {REMOTE_LOG} 2>/dev/null || echo -1"],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
size = int(result.stdout.strip())
|
||||
@@ -89,7 +103,6 @@ def get_remote_file_size():
|
||||
|
||||
|
||||
def format_size(size_bytes):
|
||||
"""格式化文件大小"""
|
||||
if size_bytes < 0:
|
||||
return "未知"
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
@@ -99,67 +112,205 @@ def format_size(size_bytes):
|
||||
return f"{size_bytes:.1f} TB"
|
||||
|
||||
|
||||
def backup_local_log():
|
||||
"""备份本地旧日志文件"""
|
||||
if LOCAL_LOG.exists():
|
||||
shutil.copy2(LOCAL_LOG, BACKUP_LOG)
|
||||
log_info(f"已备份旧日志: {BACKUP_LOG}")
|
||||
else:
|
||||
log_info("本地无旧日志,跳过备份")
|
||||
def backup_local_log(target: Path):
|
||||
bak = target.with_suffix(target.suffix + ".bak")
|
||||
if target.exists():
|
||||
shutil.copy2(target, bak)
|
||||
log_info(f"已备份旧文件: {bak}")
|
||||
|
||||
|
||||
def download_log():
|
||||
"""下载远程日志文件"""
|
||||
LOCAL_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_info(f"本地目录: {LOCAL_LOG_DIR}")
|
||||
def write_local_file(path: Path, content: str, quiet: bool):
|
||||
path.write_text(content, encoding="utf-8")
|
||||
log_info(f"已写入本地文件: {path} ({format_size(len(content.encode('utf-8')))})")
|
||||
if not quiet:
|
||||
sys.stdout.write(content)
|
||||
if not content.endswith("\n"):
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
backup_local_log()
|
||||
|
||||
def ssh_capture(remote_cmd, timeout=120):
|
||||
"""在远端执行命令并捕获 stdout。失败返回 None。"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", *SSH_COMMON, f"{USERNAME}@{SERVER_IP}", remote_cmd],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
timeout=timeout
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log_error(f"远端命令失败: {remote_cmd}")
|
||||
if result.stderr.strip():
|
||||
log_error(result.stderr.strip())
|
||||
return None
|
||||
return result.stdout
|
||||
except subprocess.TimeoutExpired:
|
||||
log_error(f"远端命令超时 ({timeout}s): {remote_cmd}")
|
||||
return None
|
||||
except Exception as e:
|
||||
log_error(f"远端命令异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def shlex_quote(s):
|
||||
"""简易 shell 转义:用单引号包裹,内部单引号用 '\\'' 处理。"""
|
||||
return "'" + s.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 子命令实现
|
||||
# ============================================================================
|
||||
def cmd_latest(args):
|
||||
ensure_local_dir()
|
||||
remote_size = get_remote_file_size()
|
||||
if remote_size < 0:
|
||||
log_error(f"远程日志文件不存在或无法访问: {REMOTE_LOG}")
|
||||
return False
|
||||
|
||||
log_info(f"远程日志大小: {format_size(remote_size)}")
|
||||
local_path = LOCAL_LOG_DIR / f"emotion-single-{timestamp_suffix()}.log"
|
||||
stable_link = LOCAL_LOG_DIR / "emotion-single-latest.log"
|
||||
|
||||
log_info(f"正在下载: {USERNAME}@{SERVER_IP}:{REMOTE_LOG}")
|
||||
log_info(f" -> {LOCAL_LOG}")
|
||||
log_info(f" -> {local_path}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["scp", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
|
||||
f"{USERNAME}@{SERVER_IP}:{REMOTE_LOG}", str(LOCAL_LOG)],
|
||||
["scp", *SSH_COMMON,
|
||||
f"{USERNAME}@{SERVER_IP}:{REMOTE_LOG}", str(local_path)],
|
||||
capture_output=True, text=True, timeout=300
|
||||
)
|
||||
if result.returncode == 0:
|
||||
local_size = LOCAL_LOG.stat().st_size
|
||||
log_info(f"下载成功: {format_size(local_size)}")
|
||||
return True
|
||||
else:
|
||||
if result.returncode != 0:
|
||||
log_error(f"下载失败: {result.stderr.strip()}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
log_error("下载超时(300 秒)")
|
||||
log_error("下载超时 (300s)")
|
||||
return False
|
||||
except Exception as e:
|
||||
log_error(f"下载异常: {e}")
|
||||
return False
|
||||
|
||||
# 维护一个固定名的拷贝,方便快速打开
|
||||
try:
|
||||
if stable_link.exists() or stable_link.is_symlink():
|
||||
stable_link.unlink()
|
||||
shutil.copy2(local_path, stable_link)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
log_info(f"=== 下载完成: {local_path} ({format_size(local_path.stat().st_size)}) ===")
|
||||
return True
|
||||
|
||||
|
||||
def cmd_tail(args):
|
||||
ensure_local_dir()
|
||||
n = args.lines
|
||||
log_info(f"拉取远程日志最后 {n} 行...")
|
||||
output = ssh_capture(f"tail -n {n} {REMOTE_LOG}")
|
||||
if output is None:
|
||||
return False
|
||||
local_path = LOCAL_LOG_DIR / f"tail-{timestamp_suffix()}.log"
|
||||
write_local_file(local_path, output, args.quiet)
|
||||
return True
|
||||
|
||||
|
||||
def cmd_errors(args):
|
||||
ensure_local_dir()
|
||||
n = args.lines
|
||||
log_info(f"拉取远程日志中最近 {n} 条 ERROR/Exception ...")
|
||||
cmd = f"grep -nE 'ERROR|Exception' {REMOTE_LOG} | tail -n {n}"
|
||||
output = ssh_capture(cmd)
|
||||
if output is None:
|
||||
return False
|
||||
local_path = LOCAL_LOG_DIR / f"errors-{timestamp_suffix()}.log"
|
||||
write_local_file(local_path, output, args.quiet)
|
||||
return True
|
||||
|
||||
|
||||
def cmd_grep(args):
|
||||
ensure_local_dir()
|
||||
keyword = args.keyword
|
||||
if not keyword:
|
||||
log_error("grep 子命令必须提供 keyword")
|
||||
return False
|
||||
# 防御:禁止 keyword 中包含 shell 元字符
|
||||
if any(ch in keyword for ch in "`$()|;&<>'\"\\"):
|
||||
log_error(f"keyword 含有非法字符: {keyword}")
|
||||
return False
|
||||
|
||||
n = args.lines
|
||||
c = args.context
|
||||
log_info(f"按关键词搜索: {keyword!r} (上下文 {c} 行, 最多 {n} 条)")
|
||||
cmd = f"grep -nF -C {c} -- {shlex_quote(keyword)} {REMOTE_LOG} | tail -n {n}"
|
||||
output = ssh_capture(cmd)
|
||||
if output is None:
|
||||
return False
|
||||
local_path = LOCAL_LOG_DIR / f"grep-{timestamp_suffix()}.log"
|
||||
write_local_file(local_path, output, args.quiet)
|
||||
return True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 主入口
|
||||
# ============================================================================
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="从服务器下载 emotion-single.log,支持多种查询模式"
|
||||
)
|
||||
parser.add_argument("--lines", type=int, default=200,
|
||||
help="tail/errors/grep 返回的最大行数 (默认 200)")
|
||||
parser.add_argument("--context", type=int, default=3,
|
||||
help="grep 时显示的上下文行数 (默认 3)")
|
||||
parser.add_argument("-q", "--quiet", action="store_true",
|
||||
help="静默模式:只写文件,不打印到 stdout")
|
||||
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
sub.add_parser("latest", help="下载完整日志 (默认)")
|
||||
|
||||
p_tail = sub.add_parser("tail", help="只看最后 N 行")
|
||||
p_tail.add_argument("n", nargs="?", type=int, help="行数 (默认用 --lines)")
|
||||
|
||||
p_err = sub.add_parser("errors", help="只看最近 N 条 ERROR/Exception")
|
||||
p_err.add_argument("n", nargs="?", type=int, help="条数 (默认用 --lines)")
|
||||
|
||||
p_grep = sub.add_parser("grep", help="按关键词搜索")
|
||||
p_grep.add_argument("keyword", help="搜索关键词")
|
||||
p_grep.add_argument("n", nargs="?", type=int, help="最多返回条数 (默认用 --lines)")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
log_info("=== 情绪博物馆 - 日志下载工具 ===")
|
||||
log_info(f"服务器: {USERNAME}@{SERVER_IP}")
|
||||
log_info(f"远程日志: {REMOTE_LOG}")
|
||||
|
||||
# 检查 SSH
|
||||
if not check_ssh():
|
||||
sys.exit(1)
|
||||
|
||||
# 检查远程文件
|
||||
remote_size = get_remote_file_size()
|
||||
if remote_size < 0:
|
||||
log_error(f"远程日志文件不存在或无法访问: {REMOTE_LOG}")
|
||||
sys.exit(1)
|
||||
log_info(f"远程日志大小: {format_size(remote_size)}")
|
||||
# 兼容老版本无参数调用 → 当作 latest
|
||||
if args.command is None:
|
||||
args.command = "latest"
|
||||
|
||||
# 下载
|
||||
if download_log():
|
||||
log_info("=== 下载完成 ===")
|
||||
# 把位置参数 n 覆盖到 args.lines (如果用户显式传了)
|
||||
if hasattr(args, "n") and args.n is not None:
|
||||
args.lines = args.n
|
||||
|
||||
dispatch = {
|
||||
"latest": cmd_latest,
|
||||
"tail": cmd_tail,
|
||||
"errors": cmd_errors,
|
||||
"grep": cmd_grep,
|
||||
}
|
||||
|
||||
ok = dispatch[args.command](args)
|
||||
if ok:
|
||||
log_info("=== 完成 ===")
|
||||
else:
|
||||
log_error("下载失败")
|
||||
log_error("操作失败")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user