Compare commits

...

21 Commits

Author SHA1 Message Date
peanut 7c3bd5c0e3 fix: 心愿实现页面用 pendingNextResponse 标志修复第二轮 loading 显示
- 新增 pendingNextResponse ref 标志等待下一个 assistant 响应
- showChatLoading 改用该标志判断,替代不可靠的最后消息 role 判断
- submitClarification 移除重复 addResultMessage,消除重复用户气泡
- 规避 mp-weixin 对 v-for 中 computed 数组的错误编译导致的页面空白
- runGeneration/clarification/outline/novel_start/novel_done 正确设置标志

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:37:21 +08:00
peanut 4b3fa1552c fix: 第二轮 loading 不显示 — 用 filteredResultMessages 替代函数调用,避免 mp-weixin 编译丢失 2026-07-20 23:08:42 +08:00
peanut f8d6e881df fix: 恢复 addResultMessage + 渲染层过滤,避免重复气泡同时支持后续 loading 2026-07-20 22:57:24 +08:00
peanut 4afc632cac fix: 移除澄清卡片提交后的重复用户气泡(label 已在卡片内显示) 2026-07-20 21:55:35 +08:00
peanut f1ad9a6b47 test: H5 验证澄清卡片 label 显示 + AI 问题显示 2026-07-20 21:36:13 +08:00
peanut 180d21b66c fix: 已回答状态显示 AI 的问题(question) 2026-07-20 21:28:03 +08:00
peanut 41c2112c5b fix: 澄清卡片已回答用 label 显示,后端 payload 仍用 value 2026-07-20 21:25:09 +08:00
peanut 412d3f3a9a docs: 澄清卡片已回答显示修复实施计划 2026-07-20 21:17:34 +08:00
peanut 8693b3febc docs: 澄清卡片已回答显示修复设计(label 替代 value + 显示 AI 问题) 2026-07-20 21:15:36 +08:00
peanut d560f63d70 fix: chat 视图 loading 在等待首个 assistant 响应期间持续显示 2026-07-20 00:19:19 +08:00
peanut 979fcb5897 docs: 心愿实现 chat 视图 loading 标识修复实施计划 2026-07-20 00:17:18 +08:00
peanut 95ce23f172 docs: 心愿实现 chat 视图 loading 标识修复设计 2026-07-20 00:16:05 +08:00
peanut 4884099b9b fix: 收藏 toggle 用原生 SQL 绕过逻辑删除,彻底解决重新收藏冲突 2026-07-19 23:59:54 +08:00
peanut c0245d37fb fix: 收藏 toggle 支持重新收藏(修复逻辑删除与 unique key 冲突) 2026-07-19 23:56:41 +08:00
peanut 3666c3d530 fix: 收藏 toggle 增加实体存在性和权限校验(IDOR 修复) 2026-07-19 23:42:53 +08:00
peanut 33b24450db feat: 人生事件和剧本收藏从 localStorage 切换为真实 API
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 23:24:04 +08:00
peanut dd11cfae73 feat: 人生事件和剧本收藏从 localStorage 切换为真实 API
- 后端:新增 t_event_favorite 表(UNIQUE KEY + MyBatis-Plus Entity)
- 后端:LifeEventController 新增 /favorite/toggle、/favorite/page、/favorite/check 三个真实端点
- 后端:EventFavoriteService 完整 CRUD,含 toggle/分页/批量查询/状态检查
- 后端:删除旧的 favorite-placeholder mock 端点
- 前端:life-event/detail.vue 从 localStorage 假收藏切换为真实 API + localStorage 缓存防闪烁
- 前端:ScriptLibraryView.vue 从 localFavorites 切换为 checkFavoriteScript 真实 API
- 前端:stores/app.js 新增 toggleEventFavorite / checkEventFavorite 方法
- 修复:审核发现的 UNIQUE 约束缺失、未使用 watch 导入、Controller 参数注解遗漏、Controller 委托 Service 构造 Response

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 23:23:40 +08:00
peanut a3bfad099a docs: 收藏功能真实 API 化实施计划 2026-07-19 22:25:39 +08:00
peanut bebb7f1667 docs: 收藏功能真实 API 化设计文档 v2(沿用独立收藏表架构) 2026-07-19 22:16:15 +08:00
peanut ce9c9a8dc5 chore: 撤回旧收藏设计文档,改用独立收藏表方案 2026-07-19 22:13:17 +08:00
peanut 10d3c72082 docs: 收藏功能真实 API 化设计文档 2026-07-19 22:07:34 +08:00
22 changed files with 2364 additions and 52 deletions
+36
View File
@@ -266,6 +266,42 @@ 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` 部署脚本,将应用部署到服务器上。**
@@ -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,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`
### 改动 1submitClarification 用 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)
}
```
### 改动 2card-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` 部署到服务器后,在服务器环境验证
+34 -8
View File
@@ -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 () => {
@@ -147,9 +147,9 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useAppStore } from '../../stores/app.js'
import { toggleFavoriteScript } from '../../services/epicScript.js'
import { toggleFavoriteScript, checkFavoriteScript } from '../../services/epicScript.js'
const store = useAppStore()
const activeType = ref('long')
@@ -157,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)
@@ -198,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] || '最近更新⌄'
@@ -243,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) => {
@@ -319,15 +339,14 @@ const toggleScriptMenu = (script) => {
const toggleFavorite = async (script) => {
const wasFav = isFavorite(script)
// 乐观更新:先改本地缓存
const next = { ...localFavorites.value }
// 乐观更新:先改内存状态
const next = { ...favoriteStatus.value }
if (wasFav) {
delete next[String(script.id)]
} else {
next[String(script.id)] = true
}
localFavorites.value = next
uni.setStorageSync('script_favorites', next)
favoriteStatus.value = next
closeScriptMenu()
try {
@@ -335,14 +354,13 @@ const toggleFavorite = async (script) => {
uni.showToast({ title: result.isFavorited ? '已收藏' : '已取消收藏', icon: 'success' })
} catch (error) {
// 回滚乐观更新
const restored = { ...localFavorites.value }
const restored = { ...favoriteStatus.value }
if (wasFav) {
restored[String(script.id)] = true
} else {
delete restored[String(script.id)]
}
localFavorites.value = restored
uni.setStorageSync('script_favorites', restored)
favoriteStatus.value = restored
uni.showToast({ title: error?.message || '操作失败', icon: 'none' })
}
}
@@ -383,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}`)
+34 -4
View File
@@ -129,6 +129,7 @@
@submit="submitClarification"
/>
<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>
</view>
@@ -174,7 +175,7 @@
</view>
</view>
<view v-if="generating && generationStatus !== 'failed'" class="chat-loading">
<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>
@@ -292,6 +293,8 @@ const novelOutline = ref(null)
const clarificationCard = ref(null)
const currentStreamTask = ref(null)
const answeringClarification = ref(false)
// 等待下一个 assistant 响应(包括 followupStream 的所有响应)
const pendingNextResponse = ref(false)
const outlineFeedback = ref('')
const messages = ref([])
const versions = ref([])
@@ -941,6 +944,16 @@ const generationCopy = computed(() => {
return '正在把你的心愿写成故事'
})
// chat 视图 loading 显示条件:等待首个 assistant 响应期间
// - generationPhase === 'generating':生成流程未完成
// - generationStatus !== 'failed':非失败状态
// - pendingNextResponse:用户刚发完请求(心愿或澄清答案),等待下一个 assistant 响应
const showChatLoading = computed(() => {
if (generationPhase.value !== 'generating') return false
if (generationStatus.value === 'failed') return false
return pendingNextResponse.value === true
})
const generationSubcopy = computed(() => {
if (generationStatus.value === 'verySlow') return '如果网络波动,稍后会给你温和提示,不会丢掉当前心愿。'
if (generationStatus.value === 'slow') return 'AI 还没有吐出第一句话,但请求仍在进行中。'
@@ -1633,6 +1646,7 @@ const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme }
novelOutline.value = null
clarificationCard.value = null
novelSessionId.value = ''
pendingNextResponse.value = true // 标记等待下一个 assistant 响应
resumeableSessionId.value = ''
outlineFeedback.value = ''
generationDisplayText.value = display
@@ -1684,14 +1698,17 @@ const handleShortNovelEvent = (event) => {
case 'clarification_card':
addResultMessage({ role: 'assistant', kind: 'card', card: payload.card || null })
generationStatus.value = 'idle'
pendingNextResponse.value = false
break
case 'outline_created':
addResultMessage({ role: 'assistant', kind: 'outline', outline: payload.outline || null })
generationStatus.value = 'idle'
pendingNextResponse.value = false
break
case 'novel_start':
addResultMessage({ role: 'assistant', kind: 'novel', content: '', pending: true })
generationStatus.value = 'streaming'
pendingNextResponse.value = false
break
case 'novel_delta': {
// 往最后一条 novel 消息追加 delta
@@ -1714,6 +1731,7 @@ const handleShortNovelEvent = (event) => {
currentVersionMessageId.value = payload.currentVersionMessageId || ''
generationPhase.value = 'done'
generationStatus.value = 'idle'
pendingNextResponse.value = false
persistResultMessages()
break
}
@@ -1734,16 +1752,19 @@ 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 = answer
card.answer = label // 显示用 label(中文);答案已在卡片中显示,无需额外用户气泡
}
addResultMessage({ role: 'user', kind: 'text', content: answer })
pendingNextResponse.value = true // 标记等待下一个 assistant 响应
answeringClarification.value = true
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'answer_clarification',
payload: { answer },
payload: { answer }, // 后端仍收 value
onEvent: handleShortNovelEvent,
onError: (errMsg) => markGenerationFailed(errMsg)
})
@@ -3617,6 +3638,15 @@ onUnmounted(() => {
padding: 0 24rpx 40rpx;
}
.card-question-text {
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
margin-bottom: 16rpx;
line-height: 1.5;
display: block;
}
.card-answered {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
+24 -3
View File
@@ -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
}
+14 -4
View File
@@ -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,
@@ -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<>();
@@ -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,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;
}
@@ -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;
}
@@ -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);
}
@@ -3,6 +3,9 @@ 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接口
@@ -12,4 +15,23 @@ import org.apache.ibatis.annotations.Mapper;
*/
@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,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,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);
}
}
@@ -4,6 +4,7 @@ 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;
@@ -49,15 +50,28 @@ public class ScriptFavoriteServiceImpl extends ServiceImpl<ScriptFavoriteMapper,
return r;
}
LambdaQueryWrapper<ScriptFavorite> w = new LambdaQueryWrapper<>();
w.eq(ScriptFavorite::getUserId, userId)
.eq(ScriptFavorite::getScriptId, scriptId)
.eq(ScriptFavorite::getIsDeleted, 0);
// 验证剧本存在且属于当前用户
EpicScriptResponse script = epicScriptService.getScriptById(scriptId);
if (script == null) {
throw new BusinessException("剧本不存在或无权限访问");
}
ScriptFavorite existing = this.getOne(w);
// 查询时绕过 @TableLogic 自动过滤,包含已逻辑删除的记录
// 避免 unique key 冲突导致无法重新收藏
ScriptFavorite existing = baseMapper.selectAnyByUserAndScript(userId, scriptId);
if (existing != null) {
this.removeById(existing.getId());
r.setIsFavorited(false);
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);
@@ -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='人生事件收藏关联表';