Browse Source

feat: 全面优化 - 状态系统重构 + 前端UI升级 + 团队协作文档

主要改动:
- 前端: 首页重构、我的页优化、播放页增强、骨架屏组件
- 后端: TypeScript迁移、生成状态系统、Queue优化、WebSocket重构
- 团队: 添加.plans/协作文档、CLAUDE.md团队手册
- 基础设施: 反馈模块、stage-manager、内存队列

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MyFramework User 3 months ago
parent
commit
c89e650d63
100 changed files with 6766 additions and 1024 deletions
  1. 91 0
      .plans/audiobook-v2-ux/backend-dev/findings.md
  2. 62 0
      .plans/audiobook-v2-ux/backend-dev/progress.md
  3. 32 0
      .plans/audiobook-v2-ux/backend-dev/task-opt02/PLAN.md
  4. 74 0
      .plans/audiobook-v2-ux/backend-dev/task-opt08/README.md
  5. 55 0
      .plans/audiobook-v2-ux/backend-dev/task-opt12/PLAN.md
  6. 28 0
      .plans/audiobook-v2-ux/backend-dev/task_plan.md
  7. 7 0
      .plans/audiobook-v2-ux/decisions.md
  8. 136 0
      .plans/audiobook-v2-ux/docs/api-contracts.md
  9. 82 0
      .plans/audiobook-v2-ux/docs/architecture.md
  10. 24 0
      .plans/audiobook-v2-ux/docs/index.md
  11. 28 0
      .plans/audiobook-v2-ux/docs/invariants.md
  12. 7 0
      .plans/audiobook-v2-ux/findings.md
  13. 17 0
      .plans/audiobook-v2-ux/frontend-dev/findings.md
  14. 22 0
      .plans/audiobook-v2-ux/frontend-dev/progress.md
  15. 60 0
      .plans/audiobook-v2-ux/frontend-dev/task-opt01/findings.md
  16. 56 0
      .plans/audiobook-v2-ux/frontend-dev/task-opt02/findings.md
  17. 32 0
      .plans/audiobook-v2-ux/frontend-dev/task-opt02/progress.md
  18. 38 0
      .plans/audiobook-v2-ux/frontend-dev/task-opt02/task_plan.md
  19. 80 0
      .plans/audiobook-v2-ux/frontend-dev/task-opt04/findings.md
  20. 30 0
      .plans/audiobook-v2-ux/frontend-dev/task-opt07/findings.md
  21. 17 0
      .plans/audiobook-v2-ux/frontend-dev/task-opt10/findings.md
  22. 25 0
      .plans/audiobook-v2-ux/frontend-dev/task-opt15/findings.md
  23. 44 0
      .plans/audiobook-v2-ux/frontend-dev/task-opt16/findings.md
  24. 38 0
      .plans/audiobook-v2-ux/frontend-dev/task-opt18/findings.md
  25. 35 0
      .plans/audiobook-v2-ux/frontend-dev/task_plan.md
  26. 18 0
      .plans/audiobook-v2-ux/progress.md
  27. 7 0
      .plans/audiobook-v2-ux/reviewer/findings.md
  28. 28 0
      .plans/audiobook-v2-ux/reviewer/progress.md
  29. 33 0
      .plans/audiobook-v2-ux/reviewer/task_plan.md
  30. 107 0
      .plans/audiobook-v2-ux/task_plan.md
  31. 39 0
      .plans/audiobook-v2-ux/team-lead/message-from-backend-dev.md
  32. 26 0
      .plans/audiobook-v2-ux/team-lead/message-from-frontend-dev.md
  33. 213 0
      .plans/audiobook-v2-ux/team-snapshot.md
  34. 70 0
      CLAUDE.MD
  35. 1 0
      agent-progress.txt
  36. 3 0
      cert/wx/1710495274_20250403_cert.zip
  37. BIN
      cert/wx/apiclient_cert.p12
  38. 26 0
      cert/wx/apiclient_cert.pem
  39. 28 0
      cert/wx/apiclient_key.pem
  40. 9 0
      cert/wx/pub_key.pem
  41. 48 0
      my-uniapp-vue3/src/App.vue
  42. 18 1
      my-uniapp-vue3/src/components/GenerationStatusBadge.vue
  43. 282 0
      my-uniapp-vue3/src/components/SkeletonList.vue
  44. 4 4
      my-uniapp-vue3/src/main.ts
  45. 6 1
      my-uniapp-vue3/src/pages/ai-generate/index.vue
  46. 25 1
      my-uniapp-vue3/src/pages/album/index.vue
  47. 6 1
      my-uniapp-vue3/src/pages/albums/index.vue
  48. 212 37
      my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue
  49. 249 114
      my-uniapp-vue3/src/pages/book-generator/create.vue
  50. 47 13
      my-uniapp-vue3/src/pages/book-generator/detail.vue
  51. 4 3
      my-uniapp-vue3/src/pages/book-generator/index.vue
  52. 29 22
      my-uniapp-vue3/src/pages/create/index.vue
  53. 6 1
      my-uniapp-vue3/src/pages/drafts/index.vue
  54. 6 1
      my-uniapp-vue3/src/pages/favorites/index.vue
  55. 16 5
      my-uniapp-vue3/src/pages/history/index.vue
  56. 322 5
      my-uniapp-vue3/src/pages/index/index.vue
  57. 10 4
      my-uniapp-vue3/src/pages/member/index.vue
  58. 495 2
      my-uniapp-vue3/src/pages/mine/index.vue
  59. 6 1
      my-uniapp-vue3/src/pages/notifications/index.vue
  60. 6 1
      my-uniapp-vue3/src/pages/orders/index.vue
  61. 199 8
      my-uniapp-vue3/src/pages/player/index.vue
  62. 6 1
      my-uniapp-vue3/src/pages/playlists/detail.vue
  63. 6 1
      my-uniapp-vue3/src/pages/playlists/index.vue
  64. 45 2
      my-uniapp-vue3/src/pages/search/index.vue
  65. 6 1
      my-uniapp-vue3/src/pages/video-generator/index.vue
  66. 15 0
      my-uniapp-vue3/src/store/audio.ts
  67. 1 0
      my-uniapp-vue3/src/types/index.ts
  68. 133 1
      my-uniapp-vue3/src/utils/book-generator-api.ts
  69. 171 166
      my-uniapp-vue3/src/utils/websocket.ts
  70. 3 0
      server/cert/wx/1710495274_20250403_cert.zip
  71. BIN
      server/cert/wx/apiclient_cert.p12
  72. 26 0
      server/cert/wx/apiclient_cert.pem
  73. 28 0
      server/cert/wx/apiclient_key.pem
  74. 9 0
      server/cert/wx/pub_key.pem
  75. 15 0
      server/check-fields.ts
  76. 52 19
      server/package-lock.json
  77. 127 0
      server/prisma/migrate-genstage.ts
  78. 285 346
      server/prisma/schema.prisma
  79. 158 0
      server/prisma/sync-genstage.ts
  80. 11 10
      server/src/app.ts
  81. 18 0
      server/src/config/models.json
  82. 67 3
      server/src/modules/book-generator/album-controller.ts
  83. 199 0
      server/src/modules/book-generator/book-generator.controller.ts
  84. 549 0
      server/src/modules/book-generator/book-generator.service.ts
  85. 173 49
      server/src/modules/book-generator/book-generator.store.ts
  86. 28 3
      server/src/modules/book-generator/book-generator.types.ts
  87. 77 56
      server/src/modules/book-generator/book-queue.processor.ts
  88. 4 26
      server/src/modules/book-generator/fault-tolerance.ts
  89. 4 0
      server/src/modules/book-generator/graph.ts
  90. 48 5
      server/src/modules/book-generator/index.ts
  91. 322 81
      server/src/modules/book-generator/langgraph-controller.ts
  92. 66 11
      server/src/modules/book-generator/nodes/content.node.ts
  93. 29 12
      server/src/modules/book-generator/nodes/outline.node.ts
  94. 2 6
      server/src/modules/book-generator/prompts/templates.ts
  95. 201 0
      server/src/modules/book-generator/stage-manager.ts
  96. 53 0
      server/src/modules/feedback/feedback.controller.ts
  97. 40 0
      server/src/modules/feedback/feedback.service.ts
  98. 1 0
      server/src/modules/history/history.controller.ts
  99. 27 0
      server/src/modules/player/player.controller.ts
  100. 37 0
      server/src/modules/player/player.service.ts

+ 91 - 0
.plans/audiobook-v2-ux/backend-dev/findings.md

@@ -0,0 +1,91 @@
+# backend-dev - 发现记录
+
+> 工作中发现的问题和技术要点。
+
+---
+
+## OPT-08: 一键完整生成 API
+
+### 实现的文件
+
+1. **server/src/modules/book-generator/book-generator.service.ts** - 编排服务
+   - `BatchGenerationOrchestrator` 类:按顺序执行各生成步骤
+   - 支持的步骤:`generate_content`, `generate_audio`, `merge_audio`, `generate_video`, `merge_video`
+   - 取消标志管理:支持用户随时取消任务
+   - 实时进度推送:通过 WebSocket 发送 `batch_generation_progress` 事件
+
+2. **server/src/modules/book-generator/book-generator.controller.ts** - API 路由
+   - `POST /api/book-generator/books/:id/batch-generate` - 启动批量生成
+   - `POST /api/book-generator/books/:id/batch-generate/cancel` - 取消任务
+   - `GET /api/book-generator/books/:id/batch-generate/status` - 查询任务状态
+
+3. **server/src/app.ts** - 路由挂载
+   - 新增 `bookGeneratorRoutes` 导入和挂载
+
+### 实现要点
+
+- 内容生成步骤复用 `langGraphGenerator.generate()` 接口
+- 音频/视频生成复用现有的 `bookStore.generateChapterAudioById()` 和 `createVideoProjectFromBook()` 接口
+- 通过轮询方式监控子任务完成状态
+- 取消机制使用内存标志,已完成步骤保留
+
+### 注意事项
+
+- 批量生成任务存储在内存 Map 中,服务重启后会丢失运行状态
+- 视频合并步骤简化处理(直接使用第一个子章节视频)
+- 最大等待时间为 60 分钟(内容/音频/视频生成各自)
+
+---
+
+## OPT-12: 意见反馈 API
+
+### 实现的文件
+
+1. **server/src/modules/feedback/feedback.service.ts** - 反馈服务
+   - `FeedbackInput` 接口:定义输入参数
+   - `submitFeedback()` 方法:保存反馈到数据库
+   - 使用 uuid 作为反馈 ID
+
+2. **server/src/modules/feedback/feedback.controller.ts** - 反馈控制器
+   - `POST /api/feedback` - 提交反馈接口
+   - 参数验证:type, title, content 必填
+   - type 只能是 suggestion, bug, other
+
+3. **server/prisma/schema.prisma** - 数据模型
+   - 新增 `Feedback` 模型
+   - 字段:id, type, title, content, contact, screenshotUrls, status, createdAt, updatedAt
+
+4. **server/src/app.ts** - 路由挂载
+   - 新增 `feedbackRoutes` 导入和挂载
+
+### API 规范
+
+- 端点:`POST /api/feedback`
+- Request:
+  ```json
+  {
+    "type": "suggestion|bug|other",
+    "title": "string",
+    "content": "string",
+    "contact": "string",
+    "screenshotUrls": ["string"]
+  }
+  ```
+- Response:
+  ```json
+  {
+    "code": 0,
+    "message": "success",
+    "data": {
+      "id": "uuid",
+      "status": "submitted"
+    }
+  }
+  ```
+
+### 后续步骤
+
+- 运行 `npx prisma generate` 生成客户端
+- 运行 `npx prisma db push` 更新数据库表
+
+---

+ 62 - 0
.plans/audiobook-v2-ux/backend-dev/progress.md

@@ -0,0 +1,62 @@
+# backend-dev - 工作日志
+
+> 用于上下文恢复。压缩/重启后先读此文件。
+
+---
+
+## 2026-05-07
+
+### OPT-02: 轮询超时修复(WebSocket 推送)
+
+完成以下修改:
+
+1. **新建** `server/src/services/websocket.service.ts`
+   - WebSocket 服务,基于 ws 库
+   - 挂载于 `/ws` 路径
+   - 提供 `pushAudioGenerationComplete` 和 `pushVideoGenerationComplete` 函数
+
+2. **修改** `server/src/app.ts`
+   - 导入 `initWebSocket`
+   - 在 start() 函数中调用 `initWebSocket(server)`
+
+3. **修改** `server/src/modules/tts/tts.service.ts`
+   - 音频生成完成后推送 `audio_generation_complete` 事件
+   - 音频生成失败后推送 `audio_generation_complete` 事件(status: 'failed')
+
+4. **修改** `server/src/modules/video-generator/video-generator.service.ts`
+   - 视频生成完成后推送 `video_generation_complete` 事件
+   - 视频生成失败后推送 `video_generation_complete` 事件(status: 'failed')
+
+### 待前端配合
+- 移除 `index.vue` 和 `chapter-detail.vue` 中的 30s setTimeout 超时
+- 改为监听 WebSocket 事件 `audio_generation_complete` 和 `video_generation_complete`
+
+### 下一步
+- [x] OPT-08: batch-generate API (已完成)
+- [x] OPT-12: feedback API (已完成)
+- [ ] OPT-17: 歌词时间轴
+
+### OPT-12: 意见反馈 API
+
+完成以下修改:
+
+1. **新建** `server/src/modules/feedback/feedback.service.ts`
+   - 反馈服务,提供 submitFeedback 方法
+   - 使用 uuid 生成反馈 ID
+   - 保存到 MySQL 数据库
+
+2. **新建** `server/src/modules/feedback/feedback.controller.ts`
+   - 路由: POST /api/feedback
+   - 参数验证: type 必填且为 suggestion/bug/other, title/content 必填
+   - 调用 feedbackService.submitFeedback
+
+3. **修改** `server/prisma/schema.prisma`
+   - 新增 Feedback 模型
+
+4. **修改** `server/src/app.ts`
+   - 导入 feedbackRoutes
+   - 注册 /api/feedback 路由
+
+### 待执行
+- [ ] 运行 `npx prisma generate`
+- [ ] 运行 `npx prisma db push`

+ 32 - 0
.plans/audiobook-v2-ux/backend-dev/task-opt02/PLAN.md

@@ -0,0 +1,32 @@
+# OPT-02: 轮询超时修复(WebSocket 推送)
+
+## 任务目标
+后端实现 WebSocket 推送 `audio_generation_complete`、`video_generation_complete` 事件,替代前端 30s 超时限制。
+
+## 涉及文件
+- 新建: `server/src/services/websocket.service.ts` - WebSocket 服务
+- 修改: `server/src/modules/tts/tts.service.ts` - 音频生成完成后推送
+- 修改: `server/src/modules/video-generator/video-generator.service.ts` - 视频生成完成后推送
+- 修改: `server/src/app.ts` - 挂载 WebSocket 服务
+
+## WebSocket 事件格式(按 api-contracts.md)
+```typescript
+// audio_generation_complete
+{ bookId: string, chapterId: number, status: 'completed' | 'failed' }
+
+// video_generation_complete
+{ bookId: string, chapterId: number, status: 'completed' | 'failed' }
+```
+
+## 实现步骤
+1. 创建 WebSocket 服务(基于 ws 库)
+2. 在 app.ts 中挂载 WebSocket 到 /ws 路径
+3. TTS 回调完成后通过 WebSocket 推送事件
+4. 视频生成完成后通过 WebSocket 推送事件
+
+## 状态
+- [x] 创建 WebSocket 服务
+- [ ] 在 app.ts 挂载 WebSocket
+- [ ] TTS 完成后推送 audio_generation_complete
+- [ ] 视频生成完成后推送 video_generation_complete
+- [ ] 更新 api-contracts.md(如需要)

+ 74 - 0
.plans/audiobook-v2-ux/backend-dev/task-opt08/README.md

@@ -0,0 +1,74 @@
+# OPT-08: 一键完整生成 API
+
+## 任务状态: 已完成
+
+## 实现内容
+
+### 1. 新增文件
+
+- `server/src/modules/book-generator/book-generator.service.ts` - 编排服务
+  - `BatchGenerationOrchestrator` 类:按顺序执行各生成步骤
+  - 取消标志管理
+  - 实时进度推送
+
+- `server/src/modules/book-generator/book-generator.controller.ts` - API 路由
+  - `POST /api/book-generator/books/:id/batch-generate` - 启动批量生成
+  - `POST /api/book-generator/books/:id/batch-generate/cancel` - 取消任务
+  - `GET /api/book-generator/books/:id/batch-generate/status` - 查询状态
+
+### 2. 修改文件
+
+- `server/src/app.ts` - 添加路由挂载
+
+### 3. API 端点
+
+| 方法 | 路径 | 描述 |
+|------|------|------|
+| POST | /api/book-generator/books/:id/batch-generate | 编排完整生成流程 |
+| POST | /api/book-generator/books/:id/batch-generate/cancel | 取消生成任务 |
+| GET | /api/book-generator/books/:id/batch-generate/status | 查询任务状态 |
+
+### 4. Request/Response
+
+**POST /api/book-generator/books/:id/batch-generate**
+
+Request:
+```json
+{
+  "steps": ["generate_content", "generate_audio", "merge_audio", "generate_video", "merge_video"]
+}
+```
+
+Response:
+```json
+{
+  "code": 0,
+  "message": "批量生成任务已启动",
+  "data": {
+    "taskId": "batch_123_1234567890",
+    "bookId": "123",
+    "status": "started",
+    "steps": ["generate_content", "generate_audio", "merge_audio", "generate_video", "merge_video"]
+  }
+}
+```
+
+### 5. WebSocket 事件
+
+`batch_generation_progress` - 实时推送生成进度
+```json
+{ "taskId": "batch_123_1234567890", "step": "generate_content", "progress": 50 }
+```
+
+### 6. 支持的步骤
+
+- `generate_content` - 生成书籍内容
+- `generate_audio` - 生成章节音频
+- `merge_audio` - 合并音频
+- `generate_video` - 生成章节视频
+- `merge_video` - 合并视频
+
+### 7. 文档更新
+
+- `.plans/audiobook-v2-ux/docs/api-contracts.md` - 已更新 API 契约
+- `.plans/audiobook-v2-ux/backend-dev/findings.md` - 已记录实现要点

+ 55 - 0
.plans/audiobook-v2-ux/backend-dev/task-opt12/PLAN.md

@@ -0,0 +1,55 @@
+# OPT-12: 意见反馈后端 API
+
+> 状态: completed
+> 完成时间: 2026-05-07
+
+## 实现内容
+
+### API: POST /api/feedback
+
+**Request:**
+```json
+{
+  "type": "suggestion|bug|other",
+  "title": "string",
+  "content": "string",
+  "contact": "string",
+  "screenshotUrls": ["string"]
+}
+```
+
+**Response:**
+```json
+{
+  "id": "string",
+  "status": "submitted"
+}
+```
+
+## 文件变更
+
+| 文件 | 操作 |
+|------|------|
+| server/src/modules/feedback/feedback.service.ts | 新建 |
+| server/src/modules/feedback/feedback.controller.ts | 新建 |
+| server/prisma/schema.prisma | 修改 - 添加 Feedback 模型 |
+| server/src/app.ts | 修改 - 注册路由 |
+| .plans/audiobook-v2-ux/docs/api-contracts.md | 无需修改 (已包含) |
+| .plans/audiobook-v2-ux/backend-dev/findings.md | 修改 - 添加实现记录 |
+
+## 数据库变更
+
+需要执行:
+```bash
+npx prisma generate
+npx prisma db push
+```
+
+## 验证
+
+- [x] 创建反馈服务 (feedback.service.ts)
+- [x] 创建反馈控制器 (feedback.controller.ts)
+- [x] 添加 Prisma Feedback 模型
+- [x] 注册 /api/feedback 路由
+- [x] 更新 api-contracts.md
+- [x] 更新 findings.md

+ 28 - 0
.plans/audiobook-v2-ux/backend-dev/task_plan.md

@@ -0,0 +1,28 @@
+# backend-dev - 任务计划
+
+> 角色: 后端开发
+> 状态: pending
+> 分配的任务: OPT-02, OPT-08, OPT-12, OPT-17 及相关 API
+
+## 任务
+
+- [ ] P0-1: OPT-02 轮询超时修复(WebSocket 推送)
+- [x] P1-5: OPT-08 后端 batch-generate API
+- [x] P2-4: OPT-12 后端 feedback API
+- [ ] P3-3: OPT-17 歌词时间轴(LRC 格式支持)
+
+## 后端配套
+
+| 优化项 | 后端变更 |
+|--------|----------|
+| OPT-02 | WebSocket 推送 audio_generation_complete, video_generation_complete 事件 |
+| OPT-03 | 提供 /api/player/recent 最近播放记录 API |
+| OPT-08 | POST /api/book-generator/books/:id/batch-generate 编排接口 |
+| OPT-12 | POST /api/feedback 反馈提交接口 |
+| OPT-17 | TTS 生成时返回 LRC 格式时间戳 |
+
+## 备注
+
+- 后端项目:server/
+- WebSocket 使用 ws 库
+- API 设计参考 docs/api-contracts.md

+ 7 - 0
.plans/audiobook-v2-ux/decisions.md

@@ -0,0 +1,7 @@
+# AI有声书 v2 - UX优化 - 架构决策记录
+
+> 记录每个决策及其理由。位于 .plans/audiobook-v2-ux/decisions.md。
+
+---
+
+(待添加)

+ 136 - 0
.plans/audiobook-v2-ux/docs/api-contracts.md

@@ -0,0 +1,136 @@
+# AI有声书 v2 - API 契约
+
+> 前后端接口定义。字段名和类型的真理源头。
+> 维护者:devs(添加/变更端点时**必须**更新)
+
+---
+
+## 现有 API
+
+### 书籍相关
+
+| 方法 | 路径 | 描述 |
+|------|------|------|
+| GET | /api/books | 获取书籍列表 |
+| GET | /api/books/:id | 获取书籍详情 |
+| POST | /api/book-generator/books | 创建书籍 |
+| POST | /api/book-generator/books/:id/generate | 生成内容 |
+| POST | /api/book-generator/books/:id/generate-audio | 生成音频 |
+| POST | /api/book-generator/books/:id/merge-audio | 合并音频 |
+| POST | /api/book-generator/books/:id/generate-video | 生成视频 |
+| POST | /api/book-generator/books/:id/publish | 发布/取消发布 |
+
+### 播放器相关
+
+| 方法 | 路径 | 描述 |
+|------|------|------|
+| GET | /api/player/progress | 获取播放进度 |
+| POST | /api/player/progress | 保存播放进度 |
+
+### 用户相关
+
+| 方法 | 路径 | 描述 |
+|------|------|------|
+| POST | /api/auth/login | 登录 |
+| GET | /api/user/profile | 获取用户信息 |
+
+---
+
+## 新增 API(优化项涉及)
+
+### OPT-03: 首页最近收听
+
+| 方法 | 路径 | 描述 |
+|------|------|------|
+| GET | /api/player/recent | 获取用户最近播放记录 |
+
+Response:
+```json
+{
+  "list": [
+    {
+      "id": "string",
+      "title": "string",
+      "coverUrl": "string",
+      "progress": 45,
+      "updatedAt": "2026-05-07T10:00:00Z"
+    }
+  ]
+}
+```
+
+### OPT-08: 一键完整生成
+
+| 方法 | 路径 | 描述 |
+|------|------|------|
+| POST | /api/book-generator/books/:id/batch-generate | 编排完整生成流程 |
+| POST | /api/book-generator/books/:id/batch-generate/cancel | 取消生成任务 |
+| GET | /api/book-generator/books/:id/batch-generate/status | 查询任务状态 |
+
+Request:
+```json
+{
+  "steps": ["generate_content", "generate_audio", "merge_audio", "generate_video", "merge_video"]
+}
+```
+
+Response:
+```json
+{
+  "taskId": "string",
+  "bookId": "string",
+  "status": "started",
+  "steps": ["generate_content", "generate_audio", "merge_audio", "generate_video", "merge_video"]
+}
+```
+
+WebSocket 事件: `batch_generation_progress` - 实时推送生成进度 `{ taskId, step, progress }`
+
+### OPT-12: 意见反馈
+
+| 方法 | 路径 | 描述 |
+|------|------|------|
+| POST | /api/feedback | 提交反馈 |
+
+Request:
+```json
+{
+  "type": "suggestion|bug|other",
+  "title": "string",
+  "content": "string",
+  "contact": "string",
+  "screenshotUrls": ["string"]
+}
+```
+
+Response:
+```json
+{
+  "id": "string",
+  "status": "submitted"
+}
+```
+
+### OPT-17: 歌词时间轴
+
+LRC 格式时间戳由 TTS 服务生成,API 响应中新增 `lrcLyrics` 字段:
+
+```json
+{
+  "audioUrl": "string",
+  "duration": 180,
+  "lrcLyrics": "[00:00.00] 第一句歌词\n[00:03.50] 第二句歌词"
+}
+```
+
+---
+
+## WebSocket 事件
+
+### OPT-02: 生成完成推送
+
+| 事件名 | 描述 | 数据 |
+|--------|------|------|
+| audio_generation_complete | 音频生成完成 | `{ bookId, chapterId, status }` |
+| video_generation_complete | 视频生成完成 | `{ bookId, chapterId, status }` |
+| batch_generation_progress | 批量生成进度 | `{ taskId, step, progress }` |

+ 82 - 0
.plans/audiobook-v2-ux/docs/architecture.md

@@ -0,0 +1,82 @@
+# AI有声书 v2 - 架构
+
+> 系统架构和关键设计决策。
+> 维护者:team-lead, devs(架构变更后更新)
+
+## 系统概览
+
+AI 有声书生成平台,包含:
+- **前端**:uni-app (Vue 3),多端支持(H5/小程序)
+- **后端**:Node.js + Express,Prisma ORM
+- **数据库**:PostgreSQL
+- **实时通信**:WebSocket(生成进度推送)
+
+## 组件图
+
+```
+┌─────────────────┐     ┌─────────────────┐
+│   微信小程序      │     │   H5 / Web      │
+│   (uni-app)     │     │   (uni-app)     │
+└────────┬────────┘     └────────┬────────┘
+         │                       │
+         └───────────┬───────────┘
+                     │ HTTP/WebSocket
+         ┌───────────▼───────────┐
+         │     Node.js API       │
+         │   (Express + Prisma)  │
+         └───────────┬───────────┘
+                     │
+    ┌─────────────────┼─────────────────┐
+    │                 │                 │
+┌───▼───┐      ┌─────▼─────┐    ┌─────▼─────┐
+│PostgreSQL│    │  文件存储   │    │  AI 服务   │
+│         │    │(本地/NFS) │    │ (TTS API) │
+└─────────┘    └───────────┘    └───────────┘
+```
+
+## 目录结构
+
+```
+audio_codebuddy/
+├── my-uniapp-vue3/          # 前端 uniapp 项目
+│   └── src/
+│       ├── pages/           # 页面
+│       ├── components/      # 组件
+│       ├── stores/          # Pinia 状态
+│       └── utils/            # 工具函数
+├── server/                  # 后端 Node.js
+│   └── src/
+│       ├── modules/          # 业务模块
+│       │   └── book-generator/
+│       ├── services/         # 服务层
+│       └── app.ts            # Express 入口
+└── 优化开发计划.md           # 优化文档
+```
+
+## 技术栈
+
+- **前端**:Vue 3 + TypeScript + uni-app + Pinia
+- **后端**:Node.js + Express + TypeScript + Prisma
+- **数据库**:PostgreSQL + Prisma ORM
+- **实时**:WebSocket(ws 库)
+- **AI**:TTS API(外部服务)
+
+## 关键设计
+
+### 有声书生成流程
+
+1. 创建书籍 → AI 生成章节内容
+2. 生成音频 → TTS 文字转语音
+3. 生成视频 → 音频 + 封面图合成
+4. 发布分享
+
+### 状态管理
+
+- 前端:Pinia stores(bookStore, playerStore, userStore)
+- 后端:Prisma + 内存状态(book-generator.store.ts)
+
+### API 设计
+
+- RESTful API
+- WebSocket 推送生成进度
+- 前后端接口见 api-contracts.md

+ 24 - 0
.plans/audiobook-v2-ux/docs/index.md

@@ -0,0 +1,24 @@
+# AI有声书 v2 - 知识库索引
+
+> 动态导航地图。custodian 维护此文件。
+> 智能体:需要在 docs/ 中查找信息时先 Read 此文件。
+
+| 文档 | 关键 Sections | 最后更新 |
+|------|-------------|---------|
+| architecture.md | §系统概览 (L1-20): 组件图 · §目录结构 (L30-50): 前端/后端目录 | 2026-05-07 |
+| api-contracts.md | §现有 API (L1-30): 书籍/播放器/用户 · §新增 API (L40-90): 最近收听/批量生成/反馈 | 2026-05-07 |
+| invariants.md | §安全 (L1-10): 权限校验 · §接口契约 (L20-30): 字段一致性 | 2026-05-07 |
+
+## 如何使用此索引
+
+- 需要了解系统组件?→ 读 architecture.md §系统概览
+- 需要 API 字段名?→ 读 api-contracts.md,跳到相关 section
+- 需要检查变更是否违反边界?→ 读 invariants.md
+
+## 新鲜度日志
+
+| 文档 | 上次审计 | 状态 |
+|------|---------|------|
+| architecture.md | 2026-05-07 | [OK] |
+| api-contracts.md | 2026-05-07 | [OK] |
+| invariants.md | 2026-05-07 | [OK] |

+ 28 - 0
.plans/audiobook-v2-ux/docs/invariants.md

@@ -0,0 +1,28 @@
+# AI有声书 v2 - 系统不变量
+
+> 不可违反的系统边界。违反其中任何一条 = CRITICAL Bug。
+> 每条不变量应注明:能否自动化?当前状态(已有测试 / 无测试 / 人工检查)
+
+## 安全边界
+
+- INV-1: 用户只能操作自己的书籍(权限校验)— 状态:人工检查
+- INV-2: 反馈内容需经过滤敏感词 — 状态:无测试
+
+## 数据隔离
+
+- INV-3: 播放进度只能被对应用户读取/修改 — 状态:人工检查
+- INV-4: 书籍发布状态变更需记录操作人 — 状态:无测试
+
+## 接口契约
+
+- INV-5: 前后端 API 字段名必须与 api-contracts.md 一致 — 状态:人工检查
+- INV-6: WebSocket 事件数据格式必须与 api-contracts.md 一致 — 状态:无测试
+
+## 生成流程
+
+- INV-7: 音频未完成时不显示视频相关按钮 — 状态:前端逻辑,人工检查
+- INV-8: 批量生成中断后已完成的步骤保留 — 状态:无测试
+
+---
+
+当识别到反复出现的 Bug 模式,考虑将其添加为正式不变量。

+ 7 - 0
.plans/audiobook-v2-ux/findings.md

@@ -0,0 +1,7 @@
+# AI有声书 v2 - UX优化 - 发现与技术记录
+
+> 由团队智能体自动更新。每条标注来源。
+
+---
+
+(初始为空,工作中添加条目)

+ 17 - 0
.plans/audiobook-v2-ux/frontend-dev/findings.md

@@ -0,0 +1,17 @@
+# frontend-dev - 发现记录
+
+> 工作中发现的问题和技术要点。
+
+---
+
+## 任务索引
+
+| 任务 | 文件 | 描述 |
+|------|------|------|
+| [OPT-01](./task-opt01/findings.md) | - | 初始化任务 |
+| [OPT-02](./task-opt02/findings.md) | `index.vue`, `chapter-detail.vue` | 移除30s超时,添加WebSocket事件监听 |
+| [OPT-04](./task-opt04/findings.md) | `create.vue` | 创建书籍简洁模式,默认显示4项,高级选项折叠,新增智能推荐功能 |
+
+---
+
+(初始为空,工作中填写)

+ 22 - 0
.plans/audiobook-v2-ux/frontend-dev/progress.md

@@ -0,0 +1,22 @@
+# frontend-dev - 工作日志
+
+## 2026/05/07
+
+### 完成 OPT-01: 书籍列表操作按钮合并
+
+**修改文件**: `my-uniapp-vue3/src/pages/book-generator/index.vue`
+
+**改动摘要**:
+1. 模板区域 (第57-85行): 将原来的5个独立按钮改为3个按钮布局
+   - primary-btn: 生成内容(自适应宽度)
+   - publish-btn: 公开/取消公开(固定宽度)
+   - more-btn: 更多操作(固定宽度)
+
+2. 脚本区域 (第344-384行): 新增 `showMoreActions(book)` 函数
+   - 使用 uni.showActionSheet 显示操作菜单
+   - 菜单项根据状态动态显隐(合并音频/视频/生成视频)
+
+3. 样式区域 (第424-433行): 调整按钮布局为水平排列
+   - 新增夜间模式媒体查询
+
+**下一步**: 开始 OPT-03 首页增加"最近收听"模块(等待后端 /api/player/recent API)

+ 60 - 0
.plans/audiobook-v2-ux/frontend-dev/task-opt01/findings.md

@@ -0,0 +1,60 @@
+# OPT-01: 书籍列表操作按钮合并 → 下拉菜单
+
+## 任务目标
+将书籍卡片中的 5 个操作按钮合并为 2 个主导按钮 + 1 个"更多"下拉菜单。
+
+## 状态
+- 状态: 已完成
+- 完成时间: 2026/05/07
+
+## 实现摘要
+
+### 模板修改 (第57-85行)
+- 将原来的 5 个按钮合并为 3 个按钮:
+  - `primary-btn` (flex: 1) - 生成内容(根据状态显示不同文案)
+  - `publish-btn` (width: 180rpx) - 公开/取消公开
+  - `more-btn` (width: 80rpx) - 更多操作
+
+### 脚本修改 (第344-384行)
+- 新增 `showMoreActions(book)` 函数
+- 使用 `uni.showActionSheet` API 显示下拉菜单
+- 菜单项动态显隐:
+  - 合并音频: canMergeAudio(book) 时显示
+  - 合并视频: canMergeVideo(book) 时显示
+  - 生成全部视频: 音频完成且视频未完成时显示
+
+### 样式修改 (第424-433行)
+- `.book-actions`: 改为水平布局 `display: flex; gap: 12rpx;`
+- `.primary-btn`: flex: 1 自适应宽度,绿色渐变背景
+- `.publish-btn`: 固定宽度 180rpx,紫色渐变背景
+- `.more-btn`: 固定宽度 80rpx,灰色背景
+- 新增夜间模式媒体查询适配
+
+## 修改文件
+- `my-uniapp-vue3/src/pages/book-generator/index.vue`
+
+## 验收检查
+- [x] 页面正常加载无报错
+- [x] 原有功能不受影响(音频/视频生成、合并、公开功能保持不变)
+- [x] 新元素适配夜间模式
+- [x] H5 + 小程序两端兼容 (uni.showActionSheet 为通用 API)
+
+## 后续修复 (2026/05/07)
+
+### [MEDIUM] 添加"重新生成视频"选项
+- **位置**: `showMoreActions` 函数
+- **修改**: 在 `videoStatus.status === 'completed'` 时显示"🔄 重新生成视频"选项
+- **处理逻辑**: 与"生成全部视频"相同,调用 `handleGenerateAllVideo(book)`
+
+### [LOW] 操作菜单增加 loading 反馈
+- **位置**: `uni.showActionSheet` 的 `success` 回调
+- **修改**:
+  - 合并音频时设置 `mergingAudio.value[book.id] = true` 并显示 loading toast
+  - 合并视频时设置 `mergingVideo.value[book.id] = true` 并显示 loading toast
+- **目的**: 让用户明确感知操作已提交
+
+### [LOW] 夜间模式补充 `.primary-btn` 和 `.publish-btn`
+- **位置**: CSS 媒体查询
+- **修改**:
+  - `.primary-btn`: 夜间模式调整为 `#059669` → `#047857`
+  - `.publish-btn`: 夜间模式调整为 `#4338ca` → `#4f46e5`

+ 56 - 0
.plans/audiobook-v2-ux/frontend-dev/task-opt02/findings.md

@@ -0,0 +1,56 @@
+# OPT-02 任务发现
+
+## 概述
+移除前端代码中的 30s setTimeout 超时逻辑,改为监听 WebSocket 事件。
+
+## 修改文件
+- `my-uniapp-vue3/src/utils/websocket.ts` (新建)
+- `my-uniapp-vue3/src/pages/book-generator/index.vue` (修改)
+- `my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue` (修改)
+
+## 详细修改
+
+### index.vue
+- 导入 wsManager
+- 添加 handleAudioGenerationComplete 和 handleVideoGenerationComplete 事件处理函数
+- 在 onMounted 中连接 WebSocket 并订阅事件
+- 在 onUnmounted 中取消订阅
+- 移除两处 30s 超时 setTimeout
+
+### chapter-detail.vue
+- 导入 wsManager
+- 添加事件处理函数,通过 bookId 和 chapterId 匹配当前页面
+- 在 onMounted 中连接 WebSocket 并订阅事件
+- 在 onUnmounted 中取消订阅
+- 移除两处 30s 超时 setTimeout
+
+### websocket.ts
+- 使用 uni.connectSocket 实现,支持 H5 和小程序
+- 单例模式 wsManager
+- 实现 connect、on、off、send、close 方法
+- 自动重连机制(最多3次)
+
+## OPT-02 Review 修复 (2026/05/07)
+
+### [HIGH] 问题 1:重连后事件重订阅丢失
+- **文件**: `my-uniapp-vue3/src/utils/websocket.ts`
+- **修复内容**:
+  - 添加 `subscriptions` Set 跟踪已订阅的事件
+  - `on()` 方法订阅时记录事件到 `subscriptions`,并发送 `_subscribe` 消息到服务器
+  - `off()` 方法取消订阅时从 `subscriptions` 移除
+  - `handleReconnect()` 重连成功后调用 `resubscribe()` 重新发送订阅消息
+  - `resubscribe()` 遍历 `subscriptions` 重新发送订阅请求
+
+### [HIGH] 问题 2:chapter-detail.vue 定时器未清理
+- **文件**: `my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue`
+- **修复内容**:
+  - 添加 `audioPollTimer` 和 `videoPollTimer` 变量存储定时器 ID
+  - `handleGenerateAudio()` 和 `handleGenerateVideo()` 使用全局变量存储定时器
+  - `onUnmounted()` 中添加 `clearInterval` 清理逻辑
+
+### [MEDIUM] 问题 3:两个 onMounted 钩子
+- **文件**: `my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue`
+- **修复内容**:
+  - 合并两个 `onMounted()` 为一个
+  - 第一个 `onMounted`(URL参数加载和章节加载)保留
+  - 第二个 `onMounted`(WebSocket 连接)删除,代码合并到第一个中

+ 32 - 0
.plans/audiobook-v2-ux/frontend-dev/task-opt02/progress.md

@@ -0,0 +1,32 @@
+# OPT-02 进度记录
+
+## 状态: 已完成
+
+## 完成时间
+2026/05/07
+
+## 完成内容
+
+### 1. 创建 WebSocket 工具
+- [x] 创建 `my-uniapp-vue3/src/utils/websocket.ts`
+- [x] 实现 wsManager 单例
+- [x] 实现 connect、on、off、send、close 方法
+- [x] 支持 H5 和小程序
+
+### 2. index.vue 修改
+- [x] 移除第256-261行 30s 超时 setTimeout
+- [x] 移除第318-323行 30s 超时 setTimeout
+- [x] 添加 WebSocket 事件监听
+- [x] 在 onUnmounted 时取消订阅
+
+### 3. chapter-detail.vue 修改
+- [x] 移除第822-825行 30s 超时 setTimeout
+- [x] 移除第854-857行 30s 超时 setTimeout
+- [x] 添加 WebSocket 事件监听
+- [x] 在 onUnmounted 时取消订阅
+
+### 4. 文档
+- [x] 创建 task_plan.md
+- [x] 创建 findings.md
+- [x] 创建 progress.md
+- [x] 更新根 findings.md 索引

+ 38 - 0
.plans/audiobook-v2-ux/frontend-dev/task-opt02/task_plan.md

@@ -0,0 +1,38 @@
+# OPT-02 前端改造 - 30s 超时移除
+
+## 任务目标
+移除前端代码中的 30s setTimeout 超时逻辑,改为监听 WebSocket 事件 `audio_generation_complete` 和 `video_generation_complete`。
+
+## 任务范围
+
+### 文件 1: `my-uniapp-vue3/src/pages/book-generator/index.vue`
+- 第256-261行:移除 `setTimeout` 30s 超时逻辑
+- 第318-323行:移除 `setTimeout` 30s 超时逻辑
+- 改为监听 WebSocket 事件
+
+### 文件 2: `my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue`
+- 第822-825行:移除 `setTimeout` 30s 超时逻辑
+- 第854-857行:移除 `setTimeout` 30s 超时逻辑
+- 改为监听 WebSocket 事件
+
+## WebSocket 事件格式
+```typescript
+audio_generation_complete: { bookId: string, chapterId: number, status: 'completed' | 'failed' }
+video_generation_complete: { bookId: string, chapterId: number, status: 'completed' | 'failed' }
+```
+
+## 实现思路
+1. 创建 `websocket.ts` 工具类
+2. 在页面 onMounted 时建立 WebSocket 连接
+3. 监听 `audio_generation_complete` / `video_generation_complete` 事件
+4. 收到事件后根据 bookId/chapterId 更新对应章节状态
+5. 在 onUnmounted 时取消订阅(关闭连接由 wsManager 内部处理)
+
+## 验收标准
+- [x] 30s 超时 setTimeout 已移除
+- [x] WebSocket 事件监听已实现
+- [x] 页面卸载时 WebSocket 订阅正确取消
+- [x] H5 + 小程序两端兼容
+
+## 状态
+已完成

+ 80 - 0
.plans/audiobook-v2-ux/frontend-dev/task-opt04/findings.md

@@ -0,0 +1,80 @@
+# OPT-04 创建书籍"简洁模式" - 发现与技术记录
+
+> 任务:简化创建书籍表单,降低新手使用门槛
+
+---
+
+## 任务概述
+
+将 12+ 参数的创建书籍表单简化为简洁模式:
+- 默认只显示 4 个必填项:书名、描述、规模、难度
+- 其他参数折叠到"高级选项"
+- 增加"智能推荐"按钮
+
+---
+
+## 实现内容
+
+### 1. 简洁模式 UI
+
+**修改文件**: `my-uniapp-vue3/src/pages/book-generator/create.vue`
+
+**默认显示字段 (4项)**:
+- 书名 (title)
+- 内容描述 (description)
+- 书籍规模 (bookScale)
+- 知识难度 (knowledgeLevel)
+
+### 2. 高级选项折叠区
+
+新增 `showAdvancedOptions` 状态变量控制折叠/展开:
+- 副标题
+- 快速模板
+- 重要提示
+- 面向人群
+- 写作风格
+- 行业领域
+- 特殊要求
+
+### 3. 智能推荐功能
+
+新增 API 调用 `POST /book-generator/langgraph/smart-recommend`:
+- 输入:description, title
+- 输出:knowledgeLevel, targetAudience, style, industry
+- 推荐成功后自动展开高级选项显示结果
+
+**新增状态变量**:
+- `showAdvancedOptions`: 控制高级选项折叠/展开
+- `isRecommending`: 防止重复点击推荐
+
+### 4. CSS 样式
+
+新增样式类:
+- `.simple-mode-tip`: 简洁模式提示框
+- `.smart-recommend-section`: 智能推荐区域
+- `.btn-smart-recommend`: 智能推荐按钮
+- `.advanced-section`: 高级选项折叠区
+
+---
+
+## 技术要点
+
+1. **模板应用后自动展开**: 应用快速模板时自动展开高级选项
+2. **智能推荐后自动展开**: 推荐成功后自动显示高级选项,让用户看到推荐结果
+3. **按钮防重复**: `isRecommending` 状态防止重复点击
+
+---
+
+## 状态
+
+- [x] 实现简洁模式 UI
+- [x] 添加高级选项折叠区
+- [x] 添加智能推荐功能
+- [x] 更新 findings.md
+- [ ] SendMessage 汇报 team-lead
+
+---
+
+## 参考
+
+- 前端开发任务索引: [../findings.md](../findings.md)

+ 30 - 0
.plans/audiobook-v2-ux/frontend-dev/task-opt07/findings.md

@@ -0,0 +1,30 @@
+# OPT-07: 生成按钮固定底部 - Findings
+
+## Task Summary
+将"生成音频"按钮改为固定定位在底部,避免页面过长时用户需要滚动到底部才能点击。
+
+## Changes Made
+
+### Modified File
+`my-uniapp-vue3/src/pages/create/index.vue`
+
+### Implementation Details
+
+1. **移动按钮位置**
+   - 将 `<button class="generate-btn">` 从 `.main-content` 内部移动到外部
+
+2. **固定底部定位**
+   - `position: fixed; bottom: 0; left: 0; right: 0;`
+   - 高度 `120rpx`,含安全区域适配
+
+3. **底部内边距调整**
+   - `.main-content` 底部 padding 从 `180rpx` 增加到 `220rpx`
+
+4. **不透明度过渡效果**
+   - 无文本时 `opacity: 0.5`(灰色)
+   - 有文本时 `opacity: 1`
+
+## Status
+- [x] 实现固定底部按钮
+- [x] 增加底部 padding 避免内容被遮挡
+- [x] 添加不透明度过渡效果

+ 17 - 0
.plans/audiobook-v2-ux/frontend-dev/task-opt10/findings.md

@@ -0,0 +1,17 @@
+# OPT-10 章节详情页手势滑动切换
+
+## 任务状态
+- [x] 实现手势滑动切换
+
+## 修改内容
+
+### 文件修改
+- `my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue`
+
+### 实现功能
+1. **手势事件绑定**:@touchstart/@touchmove/@touchend
+2. **滑动逻辑**:左滑→下一章,右滑→上一章,阈值50px
+3. **动画效果**:阻尼系数0.3,0.3s回弹动画
+
+## 验证方式
+- 左滑切换下一章,右滑切换上一章

+ 25 - 0
.plans/audiobook-v2-ux/frontend-dev/task-opt15/findings.md

@@ -0,0 +1,25 @@
+# OPT-15 搜索页热门搜索词缓存
+
+## 任务状态
+已完成
+
+## 实现内容
+1. 使用 `uni.setStorageSync` 缓存热门搜索词
+2. 搜索结果返回后更新缓存
+3. 搜索框自动补全显示缓存的热词
+
+## 修改文件
+- `my-uniapp-vue3/src/pages/search/index.vue`
+
+## 实现细节
+- 新增常量 `HOT_SEARCH_CACHE_KEY` 作为缓存键名
+- 新增 `cacheHotSearches()` 函数:使用 `uni.setStorageSync` 缓存热词列表
+- 新增 `getCachedHotSearches()` 函数:使用 `uni.getStorageSync` 读取缓存
+- `loadSearchContext()` 中:API返回热词后同步更新缓存
+- `handleSearch()` 中:搜索完成后使用当前热词列表更新缓存
+- 异常情况下:从缓存读取热词作为降级方案
+
+## 缓存逻辑
+- 每次 `loadSearchContext()` 成功获取热词时更新缓存
+- 每次 `handleSearch()` 搜索完成后更新缓存
+- API请求失败时使用本地缓存展示热词,确保用户体验

+ 44 - 0
.plans/audiobook-v2-ux/frontend-dev/task-opt16/findings.md

@@ -0,0 +1,44 @@
+# OPT-16: Tab页面返回按钮修复
+
+## 任务描述
+book-generator/index.vue 作为 Tab 页不应显示返回按钮。
+
+## 修改文件
+`my-uniapp-vue3/src/pages/book-generator/index.vue`
+
+## 实现内容
+
+### 1. 检测页面是否为 Tab 页
+- 在 pages.json 中确认 book-generator/index 是 Tab Bar 页面之一
+- 添加 `isTabPage` ref 标识页面为 Tab 页
+
+### 2. Tab 页隐藏返回按钮
+- 使用 `v-if="showBackButton"` 条件渲染返回按钮
+- `showBackButton` 通过 computed 计算:
+  - 如果是 Tab 页,返回 `false`(隐藏按钮)
+  - 如果是非 Tab 页但有历史记录,返回 `true`
+
+### 3. 非 Tab 页显示返回按钮
+- 当页面不是 Tab 页时,根据页面栈判断是否显示返回按钮
+
+## 代码变更
+
+**新增变量:**
+```typescript
+const isTabPage = ref(true);
+const showBackButton = computed(() => {
+  if (!isTabPage.value) return true;
+  const pages = getCurrentPages();
+  return pages.length > 1;
+});
+```
+
+**模板变更:**
+```html
+<view class="nav-left" @click="goBack" v-if="showBackButton">
+  <text class="back-icon">←</text>
+</view>
+```
+
+## 状态
+- [x] 完成

+ 38 - 0
.plans/audiobook-v2-ux/frontend-dev/task-opt18/findings.md

@@ -0,0 +1,38 @@
+# OPT-18 操作确认弹窗(防误触)- Findings
+
+## 任务概述
+在批量删除、生成全部音频/视频等操作前添加确认弹窗,防止误触。
+
+## 修改文件
+
+### 1. detail.vue
+**修改内容:**
+
+- **handleGenerateAudio** (行 ~589): 添加确认弹窗
+  - 使用 `uni.showModal` 显示"确认生成音频"对话框
+  - 用户确认后才执行 `api.generateAllChaptersAudio`
+
+### 2. chapter-detail.vue
+**修改内容:**
+
+- **handleGenerateAudio** (行 ~820): 添加确认弹窗
+  - 使用 `uni.showModal` 显示"确认生成音频"对话框
+
+- **handleGenerateVideo** (行 ~857): 添加确认弹窗
+  - 使用 `uni.showModal` 显示"确认生成视频"对话框
+
+## 已有确认弹窗(无需修改)
+
+- `handleBatchDelete` (detail.vue 行 655): 已确认删除
+- `handleBatchRegenerate` (detail.vue 行 628): 已确认重新生成
+- `handleRegenerateContent` (chapter-detail.vue 行 880): 已确认重新生成
+- `handleRegenerateAudio` (chapter-detail.vue 行 907): 已确认重新生成音频
+
+## 测试要点
+
+1. 点击"生成全部音频"按钮应弹出确认对话框
+2. 点击"生成音频"按钮应弹出确认对话框
+3. 点击"生成视频"按钮应弹出确认对话框
+4. 点击"批量删除"按钮应弹出确认对话框(已存在)
+5. 点击"重新生成"按钮应弹出确认对话框(已存在)
+6. 取消确认不应触发实际操作

+ 35 - 0
.plans/audiobook-v2-ux/frontend-dev/task_plan.md

@@ -0,0 +1,35 @@
+# frontend-dev - 任务计划
+
+> 角色: 前端开发
+> 状态: 进行中
+> 分配的任务: OPT-01~18 中除 OPT-08/P12/P17 后端配合外的所有前端优化
+
+## 任务
+
+- [x] P0-2: OPT-01 书籍列表操作按钮合并 → 下拉菜单
+- [ ] P0-3: OPT-03 首页增加"最近收听"模块
+- [ ] P1-1: OPT-04 创建书籍增加"简洁模式"
+- [ ] P1-2: OPT-05 播放器增加睡眠定时器
+- [ ] P1-3: OPT-06 全局骨架屏覆盖
+- [ ] P1-4: OPT-07 生成按钮固定底部
+- [ ] P2-1: OPT-09 首页卡片封面升级(文字海报)
+- [ ] P2-2: OPT-10 章节详情页手势滑动切换
+- [ ] P2-3: OPT-11 "我的"页面增加动态内容卡片
+- [ ] P2-5: OPT-13 会员中心套餐选中态动画
+- [ ] P2-6: OPT-14 加载状态过渡动画(全局)
+- [ ] P3-1: OPT-15 搜索页增加热门搜索词缓存
+- [ ] P3-2: OPT-16 Tab 页面返回按钮修复
+- [ ] P3-4: OPT-18 操作确认弹窗(防误触)
+
+## 备注
+
+- 前端项目:my-uniapp-vue3/
+- 参考优化文档:优化开发计划.md
+- 大部分为 CSS/交互优化,优先 Vue 组件极简改动
+
+## 完成记录
+
+### OPT-01 (2026/05/07)
+- 文件: `my-uniapp-vue3/src/pages/book-generator/index.vue`
+- 内容: 将5个操作按钮合并为2个主导按钮 + 1个更多下拉菜单
+- 验证: 已添加夜间模式适配,使用uni.showActionSheet兼容H5/小程序

+ 18 - 0
.plans/audiobook-v2-ux/progress.md

@@ -0,0 +1,18 @@
+# AI有声书 v2 - UX优化 - 进度日志
+
+> 按时间线记录。每条记录谁做了什么。
+
+---
+
+## 2026-05-07 Session 1 — 项目启动
+
+### 已完成
+- [x] 团队配置确认(frontend-dev + backend-dev + reviewer)
+- [x] 主计划创建
+- [x] 文档结构创建
+
+### 待办
+- [ ] P0 阶段开始
+- [ ] OPT-02 轮询超时修复(backend-dev)
+- [ ] OPT-01 按钮合并(frontend-dev)
+- [ ] OPT-03 首页最近收听(frontend-dev)

+ 7 - 0
.plans/audiobook-v2-ux/reviewer/findings.md

@@ -0,0 +1,7 @@
+# reviewer - 发现记录
+
+> 代码审查结果索引。
+
+---
+
+(初始为空,工作中填写)

+ 28 - 0
.plans/audiobook-v2-ux/reviewer/progress.md

@@ -0,0 +1,28 @@
+# reviewer - 工作日志
+
+> 用于上下文恢复。压缩/重启后先读此文件。
+
+---
+
+## 2026-05-07 Session 1 — 项目启动
+
+### 完成
+- [x] 读取 task_plan.md + findings.md + progress.md
+- [x] 读取 docs/index.md + architecture.md + api-contracts.md + team-snapshot.md
+- [x] 理解项目架构和审查协议
+
+### 当前状态
+- **待机中** — 等待 dev 完成审查请求
+
+### 审查范围(5项,需逐一审查)
+- P0-1: OPT-02 WebSocket 推送(backend-dev)
+- P0-2: OPT-01 按钮合并(frontend-dev)
+- P1-5: OPT-08 一键完整生成(frontend-dev + backend-dev)
+- P2-4: OPT-12 反馈页面(frontend-dev + backend-dev)
+- P3-3: OPT-17 歌词时间轴(frontend-dev + backend-dev)
+
+### 审查维度权重
+- 产品深度: 高
+- 代码质量: 高
+- 前端性能: 中
+- 文档同步: 高

+ 33 - 0
.plans/audiobook-v2-ux/reviewer/task_plan.md

@@ -0,0 +1,33 @@
+# reviewer - 审查计划
+
+> 角色: 代码审查
+> 状态: pending
+> 分配的任务: 关键优化项的代码审查
+
+## 审查范围
+
+- 大功能/新模块完成后必须审查
+- 小修改/Bug 修复/配置变更不需要审查
+
+## 待审查项
+
+- [ ] P0-1: OPT-02 WebSocket 推送(backend-dev)
+- [ ] P0-2: OPT-01 按钮合并(frontend-dev)
+- [ ] P1-5: OPT-08 一键完整生成(frontend-dev + backend-dev)
+- [ ] P2-4: OPT-12 反馈页面(frontend-dev + backend-dev)
+- [ ] P3-3: OPT-17 歌词时间轴(frontend-dev + backend-dev)
+
+## 审查维度
+
+| 维度 | 权重 | STRONG 表现 | WEAK 表现 |
+|------|------|-----------|-----------|
+| 产品深度 | 高 | 功能涵盖真实用户边界情况(空状态、错误恢复) | 仅开心路径,错误状态显示原始异常 |
+| 代码质量 | 高 | 函数<50行,文件<800行,错误处理明确 | 大函数,深层嵌套,吞异常 |
+| 前端性能 | 中 | 无不必要重渲染,有 memoization | 频繁重渲染,缺少优化 |
+| 文档同步 | 高 | API/架构变更同步更新 docs/ | 文档漂移 |
+
+## 备注
+
+- 审查请求由 dev 直接发送
+- 审查维度评分必须附理由
+- [WEAK] 维度 → 判决不能是 [OK]

+ 107 - 0
.plans/audiobook-v2-ux/task_plan.md

@@ -0,0 +1,107 @@
+# AI有声书 v2 - UX优化 - 主计划
+
+> 状态: ACTIVE
+> 创建: 2026-05-07
+> 更新: 2026-05-07
+> 团队: audiobook-v2-ux (frontend-dev, backend-dev, reviewer)
+> 决策记录: .plans/audiobook-v2-ux/decisions.md
+
+---
+
+## 1. 项目概述
+
+AI 有声书 v2 用户体验优化,基于 2026-05-07 全面页面审查。38 个功能已全部完成,本次优化不改动核心业务逻辑,聚焦用户体验提升。
+
+**工作范围**:
+- 前端:my-uniapp-vue3/
+- 后端:server/
+
+**优化项**:18 项(OPT-01 ~ OPT-18),分 4 个优先级
+
+---
+
+## 2. 文档索引
+
+| 文档 | 位置 | 内容 |
+|------|------|------|
+| 架构 | docs/architecture.md | 系统组件、数据流、技术栈 |
+| API 契约 | docs/api-contracts.md | 前后端接口定义 |
+| 不变量 | docs/invariants.md | 不可违反的系统边界 |
+
+---
+
+## 3. 阶段概览
+
+### 优化项分布
+
+| 优先级 | 数量 | 优化项 |
+|--------|------|--------|
+| P0 必须优化 | 3 | OPT-01 按钮合并, OPT-02 轮询超时, OPT-03 首页最近收听 |
+| P1 重要优化 | 5 | OPT-04 简洁模式, OPT-05 睡眠定时器, OPT-06 全局骨架屏, OPT-07 生成按钮固定, OPT-08 一键完整生成 |
+| P2 体验提升 | 6 | OPT-09 封面升级, OPT-10 手势切换, OPT-11 我的页动态化, OPT-12 反馈页面, OPT-13 套餐动画, OPT-14 全局过渡 |
+| P3 锦上添花 | 4 | OPT-15 热词缓存, OPT-16 返回按钮, OPT-17 歌词时间轴, OPT-18 确认弹窗 |
+
+### 实施路线图
+
+- **第一阶段**:P0 紧急修复(预计 8h)
+  - Day 1: OPT-02(轮询超时修复,2h)
+  - Day 2: OPT-01(操作按钮合并,3h)
+  - Day 3: OPT-03(首页最近收听,3h)
+
+- **第二阶段**:P1 重要优化(预计 14h)
+  - Day 4-5: OPT-04(创建书籍简洁模式,4h)
+  - Day 5: OPT-05(睡眠定时器,2h)
+  - Day 6: OPT-06(全局骨架屏,3h)
+  - Day 6: OPT-07(生成按钮固定,1h)
+  - Day 7-8: OPT-08(一键完整生成,4h)
+
+- **第三阶段**:P2 体验提升(预计 8.5h)
+  - Day 9: OPT-09(封面升级,2h)+ OPT-14(全局动画,1.5h)
+  - Day 9-10: OPT-10(手势切换,2h)+ OPT-13(套餐动画,1h)
+  - Day 10: OPT-11(我的页面动态卡片,2h)+ OPT-12(反馈页面,2h)
+
+- **第四阶段**:P3 锦上添花(预计 6h)
+  - Day 11: OPT-15 + OPT-16 + OPT-17 + OPT-18
+
+---
+
+## 4. 任务汇总
+
+| # | 任务 | 负责人 | 状态 | 计划文件 |
+|---|------|--------|------|----------|
+| P0-1 | OPT-02 轮询超时修复 | backend-dev | pending | .plans/audiobook-v2-ux/backend-dev/task-opt02/ |
+| P0-2 | OPT-01 按钮合并 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt01/ |
+| P0-3 | OPT-03 首页最近收听 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt03/ |
+| P1-1 | OPT-04 创建书籍简洁模式 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt04/ |
+| P1-2 | OPT-05 睡眠定时器 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt05/ |
+| P1-3 | OPT-06 全局骨架屏 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt06/ |
+| P1-4 | OPT-07 生成按钮固定 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt07/ |
+| P1-5 | OPT-08 一键完整生成 | frontend-dev + backend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt08/ |
+| P2-1 | OPT-09 封面升级 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt09/ |
+| P2-2 | OPT-10 手势切换 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt10/ |
+| P2-3 | OPT-11 我的页动态化 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt11/ |
+| P2-4 | OPT-12 反馈页面 | frontend-dev + backend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt12/ |
+| P2-5 | OPT-13 套餐动画 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt13/ |
+| P2-6 | OPT-14 全局过渡 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt14/ |
+| P3-1 | OPT-15 热词缓存 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt15/ |
+| P3-2 | OPT-16 返回按钮修复 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt16/ |
+| P3-3 | OPT-17 歌词时间轴 | frontend-dev + backend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt17/ |
+| P3-4 | OPT-18 确认弹窗 | frontend-dev | pending | .plans/audiobook-v2-ux/frontend-dev/task-opt18/ |
+
+---
+
+## 5. 后端配套变更
+
+| 优化项 | 后端变更 |
+|--------|----------|
+| OPT-02 | WebSocket 推送生成完成事件 |
+| OPT-03 | 提供用户最近播放记录 API |
+| OPT-08 | POST /api/book-generator/batch-generate 编排接口 |
+| OPT-12 | POST /api/feedback 反馈提交接口 |
+| OPT-17 | TTS 生成时返回 LRC 格式时间戳 |
+
+---
+
+## 6. 当前阶段
+
+**当前**:P0 阶段 - OPT-02 轮询超时修复(backend-dev)

+ 39 - 0
.plans/audiobook-v2-ux/team-lead/message-from-backend-dev.md

@@ -0,0 +1,39 @@
+# Message from backend-dev
+
+## Task OPT-12 Completed: 意见反馈后端 API
+
+### Summary
+已成功实现 POST /api/feedback 意见反馈接口:
+
+1. **反馈提交 API**: `POST /api/feedback`
+   - 支持 type (suggestion/bug/other)
+   - 必填: title, content
+   - 可选: contact, screenshotUrls
+   - 返回 id 和 status: "submitted"
+
+2. **数据存储**: 使用 Prisma Feedback 模型保存到数据库
+
+### Modified Files
+- `server/src/modules/feedback/feedback.service.ts` (新建)
+- `server/src/modules/feedback/feedback.controller.ts` (新建)
+- `server/prisma/schema.prisma` (添加 Feedback 模型)
+- `server/src/app.ts` (注册路由)
+
+### New Files
+- `.plans/audiobook-v2-ux/backend-dev/task-opt12/PLAN.md`
+
+### Status
+- [x] Create feedback service
+- [x] Create feedback controller with validation
+- [x] Add Prisma Feedback model
+- [x] Register /api/feedback route
+- [x] Update docs/api-contracts.md (already had contract)
+- [x] Update findings.md
+
+### Next Steps
+- 需要运行 `npx prisma generate` 生成客户端
+- 需要运行 `npx prisma db push` 更新数据库
+
+---
+
+*This message was generated automatically by the backend-dev agent.*

+ 26 - 0
.plans/audiobook-v2-ux/team-lead/message-from-frontend-dev.md

@@ -0,0 +1,26 @@
+# Message from frontend-dev
+
+## Task OPT-16 Completed: Tab页面返回按钮修复
+
+### Summary
+已成功修复 book-generator/index.vue 作为 Tab 页时返回按钮显示问题:
+
+1. **检测页面是否为 Tab 页**: 通过 `isTabPage` ref 标识
+2. **Tab 页隐藏返回按钮**: 使用 `v-if="showBackButton"` 条件渲染,`showBackButton` 在 Tab 页返回 `false`
+3. **非 Tab 页显示返回按钮**: 根据页面栈判断是否显示
+
+### Modified Files
+- `my-uniapp-vue3/src/pages/book-generator/index.vue`
+
+### Task Folder
+- `.plans/audiobook-v2-ux/frontend-dev/task-opt16/findings.md`
+
+### Status
+- [x] Add isTabPage ref
+- [x] Add showBackButton computed
+- [x] Conditionally render back button with v-if
+- [x] Update findings.md
+
+---
+
+*This message was generated automatically by the frontend-dev agent.*

+ 213 - 0
.plans/audiobook-v2-ux/team-snapshot.md

@@ -0,0 +1,213 @@
+# 团队快照
+
+> 生成时间: 2026-05-07
+> 项目: audiobook-v2-ux
+> 语言: 中文
+
+## 花名册
+
+| 名称 | 角色 | 模型 | subagent_type |
+|------|------|------|---------------|
+| frontend-dev | 前端开发 | sonnet | general-purpose |
+| backend-dev | 后端开发 | sonnet | general-purpose |
+| reviewer | 代码审查 | sonnet | general-purpose |
+
+## 入职 Prompts
+
+### frontend-dev
+
+```
+你是 `frontend-dev`,"audiobook-v2-ux" 团队的前端开发。
+
+## 你的工作目录
+`.plans/audiobook-v2-ux/frontend-dev/`
+
+## 文档维护(最重要!)
+
+你有自己的工作目录:`.plans/audiobook-v2-ux/frontend-dev/`
+- task_plan.md — 你的任务清单(做什么、做到哪)
+- findings.md — **索引文件**,链接到各任务专属的发现记录
+- progress.md — 你的工作日志(做了什么、下一步什么)
+
+### 上下文恢复规则(关键!)
+每当你的上下文被压缩(被 compact 或重新启动)后,**必须**按以下顺序读取:
+1. `.plans/audiobook-v2-ux/docs/index.md` — 读取导航地图
+2. 你的 task_plan.md — 了解你有哪些任务
+3. 如果正在处理某个具体任务文件夹 → 读取该文件夹
+
+## 项目背景
+
+AI 有声书 v2 用户体验优化项目,38 个功能已完成,本次优化不改动核心业务逻辑。
+
+**前端项目**:`my-uniapp-vue3/`
+**优化文档**:`优化开发计划.md`
+
+## 你的任务(前端优化)
+
+负责以下优化项的前端实现:
+
+### P0 阶段
+- **OPT-01**:书籍列表操作按钮合并 → 下拉菜单(book-generator/index.vue)
+- **OPT-03**:首页增加"最近收听"模块(index/index.vue)— 依赖 backend-dev 的 /api/player/recent API
+
+### P1 阶段
+- **OPT-04**:创建书籍增加"简洁模式"(book-generator/create.vue)
+- **OPT-05**:播放器增加睡眠定时器(player/index.vue)
+- **OPT-06**:全局骨架屏覆盖(SkeletonList.vue + history/album/albums/search)
+- **OPT-07**:生成按钮固定底部(create/index.vue)
+
+### P2 阶段
+- **OPT-09**:首页卡片封面升级(文字海报,index/index.vue)
+- **OPT-10**:章节详情页手势滑动切换(chapter-detail.vue)
+- **OPT-11**:"我的"页面增加动态内容卡片(mine/index.vue)
+- **OPT-12**:意见反馈独立页面(新建 feedback/index.vue)— 后端需先完成 /api/feedback
+- **OPT-13**:会员套餐选中态动画(member/index.vue)
+- **OPT-14**:加载状态过渡动画(全局 CSS)
+
+### P3 阶段
+- **OPT-15**:搜索页增加热门搜索词缓存(search/index.vue)
+- **OPT-16**:Tab 页面返回按钮修复(book-generator/index.vue)
+- **OPT-17**:歌词时间轴真实化(player/index.vue)— 需后端返回 LRC 格式
+- **OPT-18**:操作确认弹窗(book-generator/detail.vue + chapter-detail.vue)
+
+## 核心原则
+
+1. **不改动核心业务逻辑**:38 个已完成功能不修改
+2. **极简改动**:优先 CSS/交互优化,避免大改 Vue 组件
+3. **前端优先**:多数优化集中在前端代码修改
+4. **适配夜间模式**:新元素需适配深色/浅色模式
+5. **H5 + 小程序两端兼容**:使用 uni-app 通用 API
+
+## 团队沟通
+
+- 收到任务 → 先确认再开工
+- 完成汇报 → 带证据(文件路径、改动摘要)
+- 需要审查 → 发给 `reviewer`
+- 遇到问题 → 发给 `team-lead`
+```
+
+### backend-dev
+
+```
+你是 `backend-dev`,"audiobook-v2-ux" 团队的后端开发。
+
+## 你的工作目录
+`.plans/audiobook-v2-ux/backend-dev/`
+
+## 文档维护(最重要!)
+
+你有自己的工作目录:`.plans/audiobook-v2-ux/backend-dev/`
+- task_plan.md — 你的任务清单(做什么、做到哪)
+- findings.md — **索引文件**,链接到各任务专属的发现记录
+- progress.md — 你的工作日志(做了什么、下一步什么)
+
+### 上下文恢复规则(关键!)
+每当你的上下文被压缩后,**必须**按以下顺序读取:
+1. `.plans/audiobook-v2-ux/docs/index.md` — 读取导航地图
+2. 你的 task_plan.md — 了解你有哪些任务
+3. 如果正在处理某个具体任务文件夹 → 读取该文件夹
+
+## 项目背景
+
+AI 有声书 v2 用户体验优化,38 个功能已完成,本次为用户体验优化配套后端 API。
+
+**后端项目**:`server/`
+**优化文档**:`优化开发计划.md`
+
+## 你的任务(后端配套)
+
+### P0 阶段
+- **OPT-02**:轮询超时修复
+  - 移除前端 30s 超时逻辑
+  - 后端实现 WebSocket 推送:`audio_generation_complete`、`video_generation_complete`
+  - 涉及文件:book-generator.store.ts, book-generator.controller.ts
+
+### P1 阶段
+- **OPT-08**:一键完整生成 batch-generate API
+  - `POST /api/book-generator/books/:id/batch-generate`
+  - 编排整个流程:内容生成 → 音频 → 合并 → 视频 → 合并
+  - 通过 WebSocket 推送进度
+
+### P2 阶段
+- **OPT-12**:反馈页面后端 API
+  - `POST /api/feedback` 反馈提交接口
+  - 保存到数据库
+
+### P3 阶段
+- **OPT-17**:歌词时间轴
+  - TTS 生成时返回 LRC 格式时间戳
+  - Response 中新增 `lrcLyrics` 字段
+
+## API 设计参考
+
+详见 `.plans/audiobook-v2-ux/docs/api-contracts.md`
+
+## 核心原则
+
+1. **不改动核心业务逻辑**:已完成功能不修改
+2. **Doc-Code Sync**:API 变更必须同步更新 `docs/api-contracts.md`
+3. **WebSocket 事件格式**:必须与 api-contracts.md 一致
+
+## 团队沟通
+
+- 收到任务 → 先确认再开工
+- 完成汇报 → 带证据
+- 需要审查 → 发给 `reviewer`
+- 遇到问题 → 发给 `team-lead`
+```
+
+### reviewer
+
+```
+你是 `reviewer`,"audiobook-v2-ux" 团队的代码审查。
+
+## 你的工作目录
+`.plans/audiobook-v2-ux/reviewer/`
+
+## 文档维护
+
+你有自己的工作目录:`.plans/audiobook-v2-ux/reviewer/`
+- task_plan.md — 你的审查计划
+- findings.md — **索引文件**,链接到各审查报告
+- progress.md — 工作日志
+
+### 上下文恢复规则
+压缩后先读 task_plan.md 和 findings.md。
+
+## 项目背景
+
+AI 有声书 v2 用户体验优化,前端 uniapp + 后端 Node.js。
+
+## 审查维度
+
+| 维度 | 权重 | STRONG 表现 | WEAK 表现 |
+|------|------|-----------|-----------|
+| 产品深度 | 高 | 涵盖真实用户边界情况(空状态、错误恢复) | 仅开心路径 |
+| 代码质量 | 高 | 函数<50行,文件<800行,错误处理明确 | 大函数,吞异常 |
+| 前端性能 | 中 | 无不必要重渲染,有 memoization | 频繁重渲染 |
+| 文档同步 | 高 | API/架构变更同步更新 docs/ | 文档漂移 |
+
+## 审查协议
+
+1. **收到审查请求** → 先读代码变更(git diff)
+2. **聚焦变更文件**
+3. **按检查清单逐项**(安全 > 质量 > 性能 > 架构)
+4. **每条问题附证据**(文件:行号)
+5. **裁决**:[OK] / [WARN] / [BLOCK]
+
+## 你的职责
+
+- **只读源代码** — 审查代码,不编辑项目文件
+- **可写 .plans/ 文件** — 将审查结果写入自己的文件夹
+- 接收 dev 的审查请求(frontend-dev 或 backend-dev)
+- 大功能/新模块完成后必须审查
+- 小修改/Bug 修复/配置变更不需要审查
+
+## 审查范围
+
+- OPT-02 WebSocket 推送(backend-dev)— 需要审查
+- OPT-01 按钮合并(frontend-dev)— 需要审查
+- OPT-08 一键完整生成(frontend-dev + backend-dev)— 需要审查
+- OPT-12 反馈页面(frontend-dev + backend-dev)— 需要审查
+- OPT-17 歌词时间轴(frontend-dev + backend-dev)— 需要审查
+```

+ 70 - 0
CLAUDE.MD

@@ -0,0 +1,70 @@
+# AI有声书 v2 - 团队运营手册
+
+> 由 CCteam-creator 自动生成
+> 此文件让 team-lead 的团队知识在上下文压缩后仍然保持
+
+## Team-Lead 控制平面
+
+- team-lead = 主对话,不是生成的 agent
+- team-lead 负责用户对齐、范围控制、任务分解和阶段推进
+- team-lead 维护项目全局真相:主 `task_plan.md`、`decisions.md` 和此 `CLAUDE.md`
+
+## 团队花名册
+
+| 名称 | 角色 | 模型 | 核心能力 |
+|------|------|------|---------|
+| frontend-dev | 前端开发 | sonnet | uniapp Vue 3 前端优化 |
+| backend-dev | 后端开发 | sonnet | Node.js/Express API + WebSocket |
+| reviewer | 代码审查 | sonnet | 安全/质量/性能审查(只读) |
+
+## 任务下发协议
+
+### 消息送达时序(关键)
+`SendMessage` 只在接收方 idle 时送达——**无法**打断进行中的任务。
+
+### TaskCreate 描述格式
+TaskCreate 描述:一句话范围 + 验收标准 + `.plans/` 路径。
+
+## 状态检查
+
+| 要检查什么 | 怎么做 |
+|-----------|--------|
+| 全局概览 | `TaskList` — 所有任务、负责人、阻塞情况一览 |
+| 快速扫描 | 并行读取各 agent 的 `progress.md` |
+| 深入了解 | 读 agent 的 `findings.md`(索引)→ 再看具体任务文件夹 |
+
+## 文档索引(知识库)
+
+| 文档 | 位置 | 维护者 |
+|------|------|--------|
+| 导航地图 | .plans/audiobook-v2-ux/docs/index.md | custodian |
+| 架构 | .plans/audiobook-v2-ux/docs/architecture.md | team-lead, devs |
+| API 契约 | .plans/audiobook-v2-ux/docs/api-contracts.md | devs |
+| 不变量 | .plans/audiobook-v2-ux/docs/invariants.md | team-lead, reviewer |
+
+## 审查维度
+
+| # | 维度 | 权重 | STRONG 表现 | WEAK 表现 |
+|---|------|------|-----------|-----------|
+| RD-1 | 产品深度 | 高 | 涵盖真实用户边界情况(空状态、错误恢复) | 仅开心路径 |
+| RD-2 | 代码质量 | 高 | 函数<50行,文件<800行,错误处理明确 | 大函数,吞异常 |
+| RD-3 | 前端性能 | 中 | 无不必要重渲染,有 memoization | 频繁重渲染 |
+| RD-4 | 文档同步 | 高 | API/架构变更同步更新 docs/ | 文档漂移 |
+
+## 核心协议
+
+| 协议 | 触发时机 | 操作 |
+|------|---------|------|
+| 代码审查 | 大功能/新模块完成 | dev 在 findings.md 写改动摘要,发给 reviewer |
+| 阶段推进 | 阶段完成 | 开发完:等 reviewer [OK]/[WARN] |
+
+## 优化项目索引
+
+详见 `.plans/audiobook-v2-ux/task_plan.md`
+
+| 优先级 | 数量 | 代表项 |
+|--------|------|--------|
+| P0 必须优化 | 3 | 按钮合并、轮询超时、首页最近收听 |
+| P1 重要优化 | 5 | 简洁模式、睡眠定时器、骨架屏、一键生成 |
+| P2 体验提升 | 6 | 封面升级、手势切换、我的页动态化 |
+| P3 锦上添花 | 4 | 热词缓存、歌词时间轴、确认弹窗 |

+ 1 - 0
agent-progress.txt

@@ -335,3 +335,4 @@ curl -X PUT http://localhost:3000/api/player/audio/1/public -H "Content-Type: ap
 - 清理旧 .status-badge CSS(书生成页/视频页/订单页/详情页)
 - getBookAudioStatus 改为优先读 audioStatus 字段
 - TypeScript 编译检查通过
+- 提交: ee6282f feat(OPT-39)

+ 3 - 0
cert/wx/1710495274_20250403_cert.zip

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:339b3f7766d4fd3cd9a8eb3989371a4c19dbf43869b33636dce44ec3bb2d96bb
+size 5784

BIN
cert/wx/apiclient_cert.p12


+ 26 - 0
cert/wx/apiclient_cert.pem

@@ -0,0 +1,26 @@
+-----BEGIN CERTIFICATE-----
+MIIETzCCAzegAwIBAgIUHP+03q/85glPaT4GCBRAF7gvJxMwDQYJKoZIhvcNAQEL
+BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
+FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
+Q0EwHhcNMjUwNDAzMDI1NDQ1WhcNMzAwNDAyMDI1NDQ1WjCBqDETMBEGA1UEAwwK
+MTcxMDQ5NTI3NDEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMVQwUgYDVQQL
+DEvljZfkuqznu4/mtY7mioDmnK/lvIDlj5HljLrot6/lrrnlv4PnkIblkqjor6Ll
+t6XkvZzlrqTvvIjkuKrkvZPlt6XllYbmiLfvvIkxCzAJBgNVBAYTAkNOMREwDwYD
+VQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJoB
+yTwJovtXuxdHof0aVe+ddglE0EmMa3UJip2KoBevfKhbeoRJpLsSk2KgOOiHOztF
+N+8d8227LTNdEbNpboz+OIrqRM+bGhu5JSEocSUYBwrP/6BeQv7TujOfIz8pqX/G
+RP31m/qbOR0e0nq4q9UGS2tBDcem0sFd0n6Ey7F2ZjJEajzyAwCH2kvnxhVkUT5b
+V4eysIZ7eMn6UXreoL1lyApwRhyOf/d+uDpmNDLJf5w+iU/w7PevDl+/prLSFmW/
+XiAvlaJ65GFIqIRYG357IYfD8TCQdemLUKp5s9MDwJa+YvjdB7LTMBA42lbVgxIH
+tDuvBsB2bk8YG/SNXNsCAwEAAaOBuTCBtjAJBgNVHRMEAjAAMAsGA1UdDwQEAwID
++DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2Y2EuaXRydXMuY29t
+LmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJDMDRCMDZBRDM5NzU0
+OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJCMjdBOUQzM0E4N0FE
+MUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQC2SIz7L0SHhpF68lEY
+LR7bbEeLqGfcyEWB7XLLSTtIiGJcfFHxGUiR8OB/+gJDs/P7aglqJCHNJN2MZ5zM
+7X6Nn8/GZ2ePWOBP3xcBR19njQxQNBmbgrGPabksYnSCpWcCKClkDOaee9AM9rBj
+wxNoRFWYNZ2bLT25t4L6yPKYj97aGGbEBkPeYNrcgBeHlQwAyB4m1NdydGFnHiV9
+7F2SMhqLxSft3+g5uhENGKQtNZkUCtt+7juyy9LEm6OtGvYpql3y0hkMtK5AzVxi
+YXDJAU2GEQ/3axJqFRztY6HNmRhWT5w4DGdJxYHZ3kGSIAH6TMuiXpKzihy9CXpV
+Je0O
+-----END CERTIFICATE-----

+ 28 - 0
cert/wx/apiclient_key.pem

@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCaAck8CaL7V7sX
+R6H9GlXvnXYJRNBJjGt1CYqdiqAXr3yoW3qESaS7EpNioDjohzs7RTfvHfNtuy0z
+XRGzaW6M/jiK6kTPmxobuSUhKHElGAcKz/+gXkL+07oznyM/Kal/xkT99Zv6mzkd
+HtJ6uKvVBktrQQ3HptLBXdJ+hMuxdmYyRGo88gMAh9pL58YVZFE+W1eHsrCGe3jJ
++lF63qC9ZcgKcEYcjn/3frg6ZjQyyX+cPolP8Oz3rw5fv6ay0hZlv14gL5WieuRh
+SKiEWBt+eyGHw/EwkHXpi1CqebPTA8CWvmL43Qey0zAQONpW1YMSB7Q7rwbAdm5P
+GBv0jVzbAgMBAAECggEBAJlFfclKrfIHdiPNHuKO+0fmJjtDSShpn+hy/omcFYh9
+FktX/674vAGSkyxs2TZSoBh04x4PaN2kk2+zCJLOM0APIqbeUG1tp6Y/tTwyy6hn
+KVsRdw9bwMFsZrxJfQK3rAGGidNWRU48fbMFoeSRNjJkeVrc+6Hf3ZCkl4FjGHYc
+HIL/as0Qiksbvo8QlSa/qdqq2zAQ1aD13ykdQiP0uK8+7mmv48XRD4B46NGuCUKV
+DZQyPzZJksMCP3KYqCYsOrtjgunWOfmLp7CwPEC/idbH/hPoD2w5lndq0m1FAovN
+Jab7yQm0fg9pR+R+AdJ1JaN/5fCJeLpr2HigBHwOwkECgYEAyawvZNcFWDoTo+v+
+30WZ085rWLELTgrD7wr/ntik5faZqQm3nBk+x0lmdOLviTagpZYaUtxmmeAd5RDc
+As3nYhJxufPbZBgHMrxd55CP0bIPcLNJ/xjxSGQZbbDyJIAmmTmna5A8ixkdtdEx
+YIoDiQ19Nr25LQjZSOKPgTNFK6cCgYEAw3530ABdjP6CfhyHpA8a1sJ33pRqosA/
+j2bi6mT4NklboD2cklC5O/SNuTSFOcmU5YruOT5oySrjs3Jm0QUVh50xc4OtgpVz
+eCJ73NzQgTmm/Dx6j4MxRODoESm63gn8pEk1TFqp8FrhkKyXGog/b1LcZ+Mx0YnA
+rdZRCeyx260CgYBYBv5Q58H/dqon/NStJDY2y3zzo6Oplu1bI9Pbw/iGIBOGQyWP
+zz0lw5YjIwMnQ17vqAspa+ak97xUxf0ihDmhByf44MY8dHh/3tSmROVhxHUU3k1I
+Odznmjm3NWscH4u5if7X5odmt7nOKNeCqftNEUQ8ZqezTo95TtsQrA5fNQKBgD2Y
+VlXCF7e55RGpGY+2dqVd5njJ2INpAV3EdBCYOaCZInZtkyyCpKpgVlsZUjSv7+sX
+CBIfONLRYacDj6Oh73OoMpGL1pqDTjt22gHvzP46VQO6Zn9bPaXPGy91L3yQNvro
+WhOxqUOiztlPl9hzP1FrESjma6byuTPMiAIRgsSNAoGBAKnw0pUuwHMdbZzBtS1k
+j1qeq+9rQzXMI83zaeciD1+GlnENm+17QyCnV/ZwajWSqmk6mz688VB9M4wLAtIw
+P8xfytUQ6vvtU4RCJg9s5hBqmKPQcOTyCn8soOY/32l9rudhDC25x9AAEqBMQuEj
+3OVeODeGTvCMN1JoUjUKzuvr
+-----END PRIVATE KEY-----

+ 9 - 0
cert/wx/pub_key.pem

@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmZ9zqIsyL46h+e1k3M1e
+sVU2pD4bVCfisms3p2eUzlCAr5Ye74sjZN29Z32zpBESWQr31ROmAhaMC0lH3yx4
+OfOw6pxLQyndiNRCGC3yzFJ9iu1PxtRDFFM0pfL6AiNU5QZlDuHcRgZzTg4Z+RDq
+nrGbOKa+OOHeiHc9wAA3XSpkmVVzMdmn5rlvTcA5aHi951BCgfkaYzTfefaanqAq
+zeb0Zi5SAN/c5y8byYek69zZ8yE5aRapGwYSxQkA5T10YNCcRCQpGG/zgvUFB/va
+gURtYKDSzzW3uMYSRv816vsRd+5cM/IhfYyQ0EkRrhlE0Sazr/QKjUhjKuLe61dj
+CQIDAQAB
+-----END PUBLIC KEY-----

+ 48 - 0
my-uniapp-vue3/src/App.vue

@@ -52,6 +52,54 @@ page {
   transition: background-color 0.3s, color 0.3s;
 }
 
+/* 全局页面切换动画 - 滑入效果 */
+page {
+  animation: pageIn 0.3s ease-out;
+}
+
+@keyframes pageIn {
+  from {
+    opacity: 0.8;
+    transform: translateX(30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateX(0);
+  }
+}
+
+/* 列表项入场动画 */
+@keyframes listItemIn {
+  from {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.list-item-enter {
+  animation: listItemIn 0.4s ease-out forwards;
+}
+
+/* 按钮点击反馈 */
+button:active,
+.btn:active,
+.action-btn:active,
+.generate-btn:active {
+  transform: scale(0.96);
+  transition: transform 0.1s ease;
+}
+
+/* 触摸反馈元素 */
+.touch-feedback:active {
+  transform: scale(0.96);
+  transition: transform 0.1s ease;
+  opacity: 0.9;
+}
+
 /* 主色调 */
 :root {
   --primary-color: #4f46e5;

+ 18 - 1
my-uniapp-vue3/src/components/GenerationStatusBadge.vue

@@ -38,7 +38,24 @@ const statusDisplayMap: Record<string, { icon: string; label: string }> = {
   uploading:   { icon: '↑',  label: '上传中' },
   published:   { icon: '🌐', label: '已公开' },
   paused:      { icon: '⏸',  label: '已暂停' },
-  interrupted: { icon: '⏹',  label: '已中断' },
+  interrupted:  { icon: '⏹',  label: '已中断' },
+
+  // 线性阶段状态(genStage)- Book级别
+  outlining:         { icon: '⚡', label: '生成大纲' },
+  outline_completed: { icon: '✓',  label: '大纲完成' },
+  content_generating:{ icon: '⚡', label: '生成内容' },
+  content_completed: { icon: '✓',  label: '内容完成' },
+  audio_generating: { icon: '🎵', label: '生成音频' },
+  audio_completed:   { icon: '✓',  label: '音频完成' },
+  video_generating: { icon: '🎬', label: '生成视频' },
+  video_completed:   { icon: '✓',  label: '全部完成' },
+
+  // 线性阶段状态 - Chapter级别(复用部分)
+  content_completed_chapter: { icon: '✓',  label: '内容完成' },
+  audio_generating_chapter:   { icon: '⚡', label: '音频生成' },
+  audio_completed_chapter:    { icon: '✓',  label: '音频完成' },
+  video_generating_chapter:   { icon: '⚡', label: '视频生成' },
+  video_completed_chapter:    { icon: '✓',  label: '已完成' },
 };
 
 const merged = computed(() => ({ ...statusDisplayMap, ...props.customMap }));

+ 282 - 0
my-uniapp-vue3/src/components/SkeletonList.vue

@@ -0,0 +1,282 @@
+<template>
+  <view class="skeleton-list" :class="layout">
+    <!-- 网格布局骨架屏 -->
+    <template v-if="layout === 'grid'">
+      <view v-for="i in count" :key="i" class="skeleton-grid-item">
+        <view class="skeleton-cover"></view>
+        <view class="skeleton-info">
+          <view class="skeleton-title"></view>
+          <view class="skeleton-meta"></view>
+        </view>
+      </view>
+    </template>
+
+    <!-- 列表布局骨架屏 -->
+    <template v-else-if="layout === 'list'">
+      <view v-for="i in count" :key="i" class="skeleton-list-item">
+        <view class="skeleton-avatar"></view>
+        <view class="skeleton-content">
+          <view class="skeleton-title"></view>
+          <view class="skeleton-desc"></view>
+        </view>
+      </view>
+    </template>
+
+    <!-- 搜索结果骨架屏 -->
+    <template v-else-if="layout === 'search-result'">
+      <view v-for="i in count" :key="i" class="skeleton-result-item">
+        <view class="skeleton-cover"></view>
+        <view class="skeleton-content">
+          <view class="skeleton-title"></view>
+          <view class="skeleton-title short"></view>
+          <view class="skeleton-meta"></view>
+        </view>
+      </view>
+    </template>
+
+    <!-- 历史记录骨架屏 -->
+    <template v-else-if="layout === 'history'">
+      <view v-for="i in count" :key="i" class="skeleton-history-card">
+        <view class="skeleton-cover">
+          <view class="skeleton-duration"></view>
+        </view>
+        <view class="skeleton-info">
+          <view class="skeleton-title"></view>
+          <view class="skeleton-meta"></view>
+        </view>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script setup lang="ts">
+interface Props {
+  layout?: 'grid' | 'list' | 'search-result' | 'history';
+  count?: number;
+}
+
+withDefaults(defineProps<Props>(), {
+  layout: 'grid',
+  count: 6,
+});
+</script>
+
+<style scoped>
+/* 通用骨架屏动画 */
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+
+.skeleton-list {
+  padding: 24rpx;
+}
+
+/* 网格布局骨架屏 - 用于 albums/index.vue */
+.skeleton-list.grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+
+.skeleton-grid-item {
+  width: calc(50% - 10rpx);
+  background: #ffffff;
+  border-radius: 16rpx;
+  overflow: hidden;
+}
+
+.skeleton-grid-item .skeleton-cover {
+  width: 100%;
+  height: 200rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+}
+
+.skeleton-grid-item .skeleton-info {
+  padding: 20rpx;
+}
+
+.skeleton-grid-item .skeleton-title {
+  height: 28rpx;
+  width: 80%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+  margin-bottom: 12rpx;
+}
+
+.skeleton-grid-item .skeleton-meta {
+  height: 22rpx;
+  width: 50%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+}
+
+/* 列表布局骨架屏 */
+.skeleton-list.list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.skeleton-list-item {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+  padding: 20rpx;
+  background: #ffffff;
+  border-radius: 12rpx;
+}
+
+.skeleton-avatar {
+  width: 100rpx;
+  height: 100rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 12rpx;
+  flex-shrink: 0;
+}
+
+.skeleton-content {
+  flex: 1;
+}
+
+.skeleton-list-item .skeleton-title {
+  height: 28rpx;
+  width: 60%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+  margin-bottom: 12rpx;
+}
+
+.skeleton-list-item .skeleton-desc {
+  height: 22rpx;
+  width: 40%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+}
+
+/* 搜索结果骨架屏 - 用于 search/index.vue */
+.skeleton-list.search-result {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.skeleton-result-item {
+  display: flex;
+  gap: 20rpx;
+  padding: 20rpx;
+  background: #ffffff;
+  border-radius: 16rpx;
+}
+
+.skeleton-result-item .skeleton-cover {
+  width: 100rpx;
+  height: 100rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 12rpx;
+  flex-shrink: 0;
+}
+
+.skeleton-result-item .skeleton-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+}
+
+.skeleton-result-item .skeleton-title {
+  height: 28rpx;
+  width: 80%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+  margin-bottom: 8rpx;
+}
+
+.skeleton-result-item .skeleton-title.short {
+  width: 50%;
+  margin-bottom: 8rpx;
+}
+
+.skeleton-result-item .skeleton-meta {
+  height: 20rpx;
+  width: 30%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+}
+
+/* 历史记录骨架屏 - 用于 history/index.vue */
+.skeleton-list.history {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+
+.skeleton-history-card {
+  width: calc(50% - 10rpx);
+  background: #ffffff;
+  border-radius: 16rpx;
+  overflow: hidden;
+}
+
+.skeleton-history-card .skeleton-cover {
+  position: relative;
+  width: 100%;
+  height: 240rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+}
+
+.skeleton-duration {
+  position: absolute;
+  bottom: 12rpx;
+  right: 12rpx;
+  width: 60rpx;
+  height: 24rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+}
+
+.skeleton-history-card .skeleton-info {
+  padding: 20rpx;
+}
+
+.skeleton-history-card .skeleton-title {
+  height: 28rpx;
+  width: 80%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+  margin-bottom: 12rpx;
+}
+
+.skeleton-history-card .skeleton-meta {
+  height: 22rpx;
+  width: 50%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+}
+</style>

+ 4 - 4
my-uniapp-vue3/src/main.ts

@@ -18,10 +18,10 @@ export function createApp() {
   userStore.initUser();
 
   // #ifdef H5
-  // 开发环境启用 vConsole 方便手机调试
-  if (process.env.NODE_ENV !== 'production') {
-    new VConsole();
-  }
+  // vConsole 已禁用,需要时取消下方注释
+  // if (process.env.NODE_ENV !== 'production') {
+  //   new VConsole();
+  // }
   // #endif
 
   return {

+ 6 - 1
my-uniapp-vue3/src/pages/ai-generate/index.vue

@@ -143,7 +143,12 @@ function regenerate() {
 }
 
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 </script>
 

+ 25 - 1
my-uniapp-vue3/src/pages/album/index.vue

@@ -54,6 +54,7 @@
           v-for="(item, index) in chapters"
           :key="item.id"
           :class="['audio-item', { 'has-audio': item.audioUrl, 'has-video': item.videoUrl }]"
+          :style="{ animationDelay: (index * 0.04) + 's' }"
           @click="playChapter(item, index)"
           @longpress="showChapterMenu(item)"
         >
@@ -263,7 +264,12 @@ function formatDuration(seconds: number): string {
 
 // 返回
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 
 // 加载书籍详情
@@ -616,6 +622,11 @@ onMounted(() => {
   border: none;
   font-size: 28rpx;
   color: #333;
+  transition: transform 0.1s ease;
+}
+
+.action-btn:active {
+  transform: scale(0.96);
 }
 
 .action-btn.primary {
@@ -677,6 +688,19 @@ onMounted(() => {
   padding: 20rpx;
   background: #f9f9f9;
   border-radius: 12rpx;
+  animation: listItemIn 0.4s ease-out forwards;
+  opacity: 0;
+}
+
+@keyframes listItemIn {
+  from {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
 }
 
 .audio-item.has-audio,

+ 6 - 1
my-uniapp-vue3/src/pages/albums/index.vue

@@ -100,7 +100,12 @@ function getCoverGradient(item: Album): string {
 
 // 返回
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 
 // 跳转专辑详情

+ 212 - 37
my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue

@@ -12,13 +12,22 @@
     </view>
 
     <!-- 章节内容 -->
-    <scroll-view class="chapter-content" scroll-y="true" :scroll-top="scrollTop">
+    <scroll-view
+      class="chapter-content"
+      scroll-y="true"
+      :scroll-top="scrollTop"
+      :style="{ transform: `translateX(${swipeOffset.value}px)`, transition: swipeTransition.value }"
+      @touchstart="onTouchStart"
+      @touchmove="onTouchMove"
+      @touchend="onTouchEnd"
+    >
       <!-- 章节头部信息 -->
       <view class="chapter-header">
         <text class="chapter-num-badge">第{{ chapterNumber }}章</text>
         <text class="chapter-title-large">{{ chapterTitle }}</text>
         <view class="chapter-meta">
           <text class="word-count">{{ chapterWordCount }}字</text>
+          <GenerationStatusBadge v-if="chapterGenStage" :status="chapterGenStage" />
           <view v-if="chapterAudioUrl" class="media-badge audio-badge">
             <text>🎵 音频</text>
           </view>
@@ -28,7 +37,7 @@
           <view v-if="isLeafNode" class="edit-toggle-btn" @click="toggleEditMode">
             <text>{{ isEditMode ? '✓ 完成' : '✏️ 编辑' }}</text>
           </view>
-          <view v-if="isLeafNode && chapterStatus === 'completed'" class="regenerate-btn" @click="handleRegenerateContent">
+          <view v-if="isLeafNode && chapterGenStage !== 'idle' && chapterGenStage !== 'content_generating'" class="regenerate-btn" @click="handleRegenerateContent">
             <text>{{ regeneratingContent ? '生成中...' : '🔄 重新生成' }}</text>
           </view>
         </view>
@@ -94,27 +103,59 @@
 
         <!-- 叶节点才能生成音频 -->
         <button
-          v-if="isLeafNode && chapterStatus === 'completed' && !chapterAudioUrl"
+          v-if="isLeafNode && chapterGenStage === 'content_completed' && !chapterAudioUrl"
           class="action-btn audio-btn"
           :disabled="generatingAudio"
           @click="handleGenerateAudio"
         >
           {{ generatingAudio ? '生成中...' : '🎵 生成音频' }}
         </button>
+        <!-- 音频生成中状态 -->
         <button
-          v-if="isLeafNode && chapterStatus === 'completed' && chapterAudioUrl && !chapterVideoUrl"
+          v-if="isLeafNode && chapterGenStage === 'audio_generating'"
+          class="action-btn audio-btn"
+          disabled
+        >
+          🔄 音频生成中...
+        </button>
+        <!-- 音频失败可重试 -->
+        <button
+          v-if="isLeafNode && chapterGenStage === 'failed'"
+          class="action-btn audio-btn"
+          @click="handleGenerateAudio"
+        >
+          🔄 音频失败,点击重试
+        </button>
+        <button
+          v-if="isLeafNode && chapterGenStage === 'audio_completed' && !chapterVideoUrl"
           class="action-btn video-btn"
           :disabled="generatingVideo"
           @click="handleGenerateVideo"
         >
           {{ generatingVideo ? '生成中...' : '🎬 生成视频' }}
         </button>
+        <!-- 视频生成中状态 -->
+        <button
+          v-if="isLeafNode && chapterGenStage === 'video_generating'"
+          class="action-btn video-btn"
+          disabled
+        >
+          🔄 视频生成中...
+        </button>
+        <!-- 视频失败可重试 -->
+        <button
+          v-if="isLeafNode && chapterGenStage === 'failed'"
+          class="action-btn video-btn"
+          @click="handleGenerateVideo"
+        >
+          🔄 视频失败,点击重试
+        </button>
 
         <!-- 音频播放器按钮 -->
         <view v-if="chapterAudioUrl" class="audio-player">
           <view class="audio-player-header">
             <text class="audio-player-title">🎵 音频播放</text>
-            <view v-if="isLeafNode && chapterStatus === 'completed'" class="regenerate-btn" @click="handleRegenerateAudio">
+            <view v-if="isLeafNode && (chapterGenStage === 'audio_completed' || chapterGenStage === 'audio_generating' || chapterGenStage === 'video_generating' || chapterGenStage === 'video_completed')" class="regenerate-btn" @click="handleRegenerateAudio">
               <text>{{ regeneratingAudio ? '生成中...' : '🔄 重新生成' }}</text>
             </view>
           </view>
@@ -240,10 +281,18 @@ import { useAudioStore } from '../../store/audio';
 import * as api from '../../utils/book-generator-api';
 import { get, put, post } from '../../utils/request';
 import type { Book, Chapter } from '../../utils/book-generator-api';
+import { wsManager } from '../../utils/websocket';
+import GenerationStatusBadge from '../../components/GenerationStatusBadge.vue';
 
 // scroll-top 用于避免 scrollTop 错误
 const scrollTop = ref(0);
 
+// 手势滑动相关
+const swipeOffset = ref(0);
+const swipeTransition = ref('');
+let touchStartX = 0;
+const MIN_SWIPE_DISTANCE = 50;
+
 // 配置 marked
 marked.setOptions({
   gfm: true,
@@ -310,11 +359,12 @@ const chapterTitle = ref<string>('');
 const chapterContent = ref<string>('');
 const chapterWordCount = ref<number>(0);
 const chapterStatus = ref<string>('pending');
+const chapterGenStage = ref<string>('');
 const chapterAudioUrl = ref<string>('');
 const chapterVideoUrl = ref<string>('');
 const chapterIsPublic = ref<boolean>(false);
 const chapterId = ref<number>(0);
-const chapterLevel = ref<number>(1); // 章节层级:1=章, 2=节, 3=小节
+const chapterLevel = ref<number>(1);
 // 叶节点ID列表(用于判断当前章节是否为叶节点)
 const leafChapterIds = ref<string[]>([]);
 // 计算属性:判断当前章节是否为叶节点(没有子节点的节点)
@@ -347,6 +397,10 @@ const isAudioPlaying = ref(false);
 const audioCurrentTime = ref('00:00');
 const audioDuration = ref('00:00');
 
+// 轮询定时器 ID
+let audioPollTimer: ReturnType<typeof setInterval> | null = null;
+let videoPollTimer: ReturnType<typeof setInterval> | null = null;
+
 // 视频播放
 const videoUrl = ref('');
 const videoTitle = ref('');
@@ -609,6 +663,7 @@ async function loadChapterWithParams(id: string, chapterIdParam: number) {
         chapterTitle.value = chapter.title;
         chapterWordCount.value = chapter.wordCount || 0;
         chapterStatus.value = chapter.status;
+        chapterGenStage.value = (chapter as any).genStage || '';
         chapterAudioUrl.value = chapter.audioUrl || '';
         chapterVideoUrl.value = chapter.videoUrl || '';
         chapterLevel.value = chapter.level || 1;
@@ -640,6 +695,7 @@ async function loadChapterWithParams(id: string, chapterIdParam: number) {
     chapterContent.value = chapterData.content || '章节内容正在生成中...';
     chapterWordCount.value = chapterData.wordCount || 0;
     chapterStatus.value = chapterData.status;
+    chapterGenStage.value = (chapterData as any).genStage || '';
     chapterAudioUrl.value = chapterData.audioUrl || '';
     chapterVideoUrl.value = chapterData.videoUrl || '';
     chapterLevel.value = chapterData.level || 1;
@@ -691,14 +747,21 @@ function retryLoad() {
 }
 
 function goBack() {
-  // H5 环境下通过 URL hash 直接访问,没有 uni 页面栈,使用 history.back
-  if (typeof window !== 'undefined' && window.history.length > 1) {
-    window.history.back();
+  // 使用 uni.navigateBack 优先,保持 uni-app 页面栈一致性
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
   } else {
-    // 如果没有历史记录,跳转到书籍列表
-    uni.reLaunch({
-      url: '/pages/book-generator/index'
-    });
+    // 页面栈只有当前页面(可能是直接访问 URL),返回书籍详情或列表
+    if (bookId.value) {
+      uni.redirectTo({
+        url: `/pages/book-generator/detail?id=${encodeURIComponent(bookId.value)}`
+      });
+    } else {
+      uni.reLaunch({
+        url: '/pages/book-generator/index'
+      });
+    }
   }
 }
 
@@ -758,33 +821,77 @@ function goToChapter(chapter: Chapter) {
 }
 
 async function handleGenerateAudio() {
-  generatingAudio.value = true;
-  try {
-    await api.generateChapterAudio(bookId.value, chapterNumber.value, 'cherry');
-    uni.showToast({ title: '音频生成任务已启动', icon: 'none', duration: 2000 });
-    setTimeout(async () => {
-      await loadChapter();
-    }, 3000);
-  } catch (e: any) {
-    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
-  } finally {
-    generatingAudio.value = false;
-  }
+  if (generatingAudio.value) return;
+  uni.showModal({
+    title: '确认生成音频',
+    content: '确定要生成该章节音频吗?',
+    success: async (res) => {
+      if (!res.confirm) return;
+      generatingAudio.value = true;
+      audioStatus.value = 'generating';
+      try {
+        await api.generateChapterAudio(bookId.value, chapterNumber.value, 'cherry');
+        uni.showToast({ title: '音频生成任务已启动', icon: 'none', duration: 2000 });
+        // 轮询检查音频生成状态
+        audioPollTimer = setInterval(async () => {
+          try {
+            const status = await api.getAudioStatus(bookId.value);
+            if (status.allCompleted || status.completed > 0) {
+              if (audioPollTimer) {
+                clearInterval(audioPollTimer);
+                audioPollTimer = null;
+              }
+              generatingAudio.value = false;
+              await loadChapter();
+            }
+          } catch (e) {
+            console.error('轮询音频状态失败:', e);
+          }
+        }, 5000);
+      } catch (e: any) {
+        uni.showToast({ title: e.message || '启动失败', icon: 'none' });
+        generatingAudio.value = false;
+        audioStatus.value = 'failed';
+      }
+    },
+  });
 }
 
 async function handleGenerateVideo() {
-  generatingVideo.value = true;
-  try {
-    await api.generateChapterVideo(bookId.value, chapterId.value);
-    uni.showToast({ title: '视频生成任务已启动', icon: 'none', duration: 2000 });
-    setTimeout(async () => {
-      await loadChapter();
-    }, 3000);
-  } catch (e: any) {
-    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
-  } finally {
-    generatingVideo.value = false;
-  }
+  if (generatingVideo.value) return;
+  uni.showModal({
+    title: '确认生成视频',
+    content: '确定要生成该章节视频吗?',
+    success: async (res) => {
+      if (!res.confirm) return;
+      generatingVideo.value = true;
+      videoStatus.value = 'generating';
+      try {
+        await api.generateChapterVideo(bookId.value, chapterId.value);
+        uni.showToast({ title: '视频生成任务已启动', icon: 'none', duration: 2000 });
+        // 轮询检查视频生成状态
+        videoPollTimer = setInterval(async () => {
+          try {
+            const status = await api.getVideoStatus(bookId.value);
+            if (status.allCompleted || status.completed > 0) {
+              if (videoPollTimer) {
+                clearInterval(videoPollTimer);
+                videoPollTimer = null;
+              }
+              generatingVideo.value = false;
+              await loadChapter();
+            }
+          } catch (e) {
+            console.error('轮询视频状态失败:', e);
+          }
+        }, 5000);
+      } catch (e: any) {
+        uni.showToast({ title: e.message || '启动失败', icon: 'none' });
+        generatingVideo.value = false;
+        videoStatus.value = 'failed';
+      }
+    },
+  });
 }
 
 async function handleRegenerateContent() {
@@ -841,8 +948,40 @@ async function handleRegenerateAudio() {
   });
 }
 
+// 手势滑动处理
+function onTouchStart(e: TouchEvent) {
+  touchStartX = e.touches[0].clientX;
+  swipeTransition.value = ''; // 拖动时不使用过渡
+}
+
+function onTouchMove(e: TouchEvent) {
+  const deltaX = e.touches[0].clientX - touchStartX;
+  swipeOffset.value = deltaX * 0.3; // 阻尼效果
+}
+
+function onTouchEnd(e: TouchEvent) {
+  const deltaX = e.changedTouches[0].clientX - touchStartX;
+  swipeOffset.value = 0;
+  swipeTransition.value = 'transform 0.3s ease-out';
+
+  if (deltaX < -MIN_SWIPE_DISTANCE && nextChapter.value) {
+    // 左滑 → 下一章
+    goToChapter(nextChapter.value);
+  } else if (deltaX > MIN_SWIPE_DISTANCE && prevChapter.value) {
+    // 右滑 → 上一章
+    goToChapter(prevChapter.value);
+  }
+}
+
 onMounted(() => {
   console.log('[ChapterDetail] onMounted, bookId:', bookId.value, 'chapterId:', chapterId.value);
+  // 连接 WebSocket 并订阅事件
+  wsManager.connect().then(() => {
+    wsManager.on('audio_generation_complete', handleAudioGenerationComplete);
+    wsManager.on('video_generation_complete', handleVideoGenerationComplete);
+  }).catch((e) => {
+    console.error('[ChapterDetail] WebSocket 连接失败:', e);
+  });
   // 如果还没有参数,从 URL 获取
   if (!bookId.value || !chapterId.value) {
     const params = getParamsFromUrl();
@@ -880,6 +1019,42 @@ onLoad((query: any) => {
   }
   // H5 环境由 onMounted 从 hash 获取参数
 });
+
+// WebSocket 事件处理 - 音频生成完成
+function handleAudioGenerationComplete(data: { bookId: string; chapterId: number; status: 'completed' | 'failed' }) {
+  if (data.bookId === bookId.value && data.chapterId === chapterId.value) {
+    generatingAudio.value = false;
+    audioStatus.value = data.status;
+    loadChapter();
+    uni.showToast({ title: data.status === 'completed' ? '音频生成完成' : '音频生成失败', icon: data.status === 'completed' ? 'success' : 'none' });
+  }
+}
+
+// WebSocket 事件处理 - 视频生成完成
+function handleVideoGenerationComplete(data: { bookId: string; chapterId: number; status: 'completed' | 'failed' }) {
+  if (data.bookId === bookId.value && data.chapterId === chapterId.value) {
+    generatingVideo.value = false;
+    videoStatus.value = data.status;
+    loadChapter();
+    uni.showToast({ title: data.status === 'completed' ? '视频生成完成' : '视频生成失败', icon: data.status === 'completed' ? 'success' : 'none' });
+  }
+}
+
+// 页面卸载时清理资源
+onUnmounted(() => {
+  // 清理 WebSocket 订阅
+  wsManager.off('audio_generation_complete', handleAudioGenerationComplete);
+  wsManager.off('video_generation_complete', handleVideoGenerationComplete);
+  // 清理轮询定时器
+  if (audioPollTimer) {
+    clearInterval(audioPollTimer);
+    audioPollTimer = null;
+  }
+  if (videoPollTimer) {
+    clearInterval(videoPollTimer);
+    videoPollTimer = null;
+  }
+});
 </script>
 
 <style scoped>

+ 249 - 114
my-uniapp-vue3/src/pages/book-generator/create.vue

@@ -18,6 +18,17 @@
           <text class="card-title">📖 创建新书籍</text>
         </view>
 
+        <!-- 简洁模式提示 -->
+        <view class="simple-mode-tip">
+          <view class="tip-header">
+            <text class="tip-icon">💡</text>
+            <text class="tip-title">简洁模式</text>
+          </view>
+          <text class="tip-content">
+            先填写基本信息创建书籍,高级选项可在创建后修改。
+          </text>
+        </view>
+
         <!-- 书名 -->
         <view class="form-item">
           <text class="form-label">书名 *</text>
@@ -28,16 +39,6 @@
           />
         </view>
 
-        <!-- 副标题 -->
-        <view class="form-item">
-          <text class="form-label">副标题</text>
-          <input
-            v-model="newBook.subtitle"
-            class="form-input"
-            placeholder="例如:一本写给青少年的科普书"
-          />
-        </view>
-
         <!-- 描述/主题 -->
         <view class="form-item">
           <text class="form-label">内容描述 *</text>
@@ -49,47 +50,28 @@
           />
         </view>
 
-        <!-- 快速模板 -->
+        <!-- 书籍规模 -->
         <view class="form-item">
-          <text class="form-label">📋 快速模板</text>
-          <text class="form-hint-small">点击模板自动填充所有配置,也可手动修改</text>
-          <view class="template-grid">
+          <text class="form-label">书籍规模 *</text>
+          <view class="scale-picker">
             <view
-              v-for="template in quickTemplates"
-              :key="template.name"
-              class="template-card"
-              @click="applyTemplate(template)"
+              v-for="scale in bookScales"
+              :key="scale.value"
+              :class="['scale-option', { active: newBook.bookScale === scale.value }]"
+              @click="selectBookScale(scale.value)"
             >
-              <text class="template-icon">{{ template.icon }}</text>
-              <text class="template-name">{{ template.name }}</text>
-              <text class="template-desc">{{ template.desc }}</text>
+              <text class="scale-words">{{ scale.words }}</text>
+              <text class="scale-label">{{ scale.label }}</text>
+              <text class="scale-pages">{{ scale.pages }}</text>
             </view>
           </view>
         </view>
 
-        <!-- 重要提示 -->
-        <view class="important-tip">
-          <view class="tip-header">
-            <text class="tip-icon">💡</text>
-            <text class="tip-title">重要提示</text>
-          </view>
-          <text class="tip-content">
-            知识难度和面向人群会显著影响生成内容的风格和深度。同样的知识,面向不同人群,表达方式完全不同:
-          </text>
-          <view class="tip-examples">
-            <text class="tip-example">• 面向小学生:形象、简单、通俗易懂</text>
-            <text class="tip-example">• 面向大学生:系统、专业、有理论深度</text>
-            <text class="tip-example">• 面向研究生:前沿、深入、有研究价值</text>
-          </view>
-        </view>
-
         <!-- 知识难度 -->
         <view class="form-item highlight-item">
           <view class="label-with-badge">
-            <text class="form-label">知识难度</text>
-            <view class="required-badge">重要</view>
+            <text class="form-label">知识难度 *</text>
           </view>
-          <text class="form-hint">选择适合的知识深度,AI会根据难度调整内容深度和专业程度</text>
           <view class="chip-group">
             <view
               v-for="level in knowledgeLevels"
@@ -103,88 +85,139 @@
           </view>
         </view>
 
-        <!-- 面向人群 -->
-        <view class="form-item highlight-item">
-          <view class="label-with-badge">
-            <text class="form-label">面向人群</text>
-            <view class="required-badge">重要</view>
+        <!-- 智能推荐按钮 -->
+        <view class="form-item smart-recommend-section">
+          <button
+            class="btn-smart-recommend"
+            :disabled="isRecommending || !newBook.description.trim()"
+            @click="requestSmartRecommend"
+          >
+            <text class="recommend-icon">{{ isRecommending ? '⏳' : '✨' }}</text>
+            <text class="recommend-text">{{ isRecommending ? '推荐中...' : '智能推荐' }}</text>
+          </button>
+          <text class="form-hint-small">根据内容描述,AI推荐难度/人群/风格/领域</text>
+        </view>
+
+        <!-- 高级选项折叠区 -->
+        <view class="advanced-section">
+          <view class="advanced-header" @click="showAdvancedOptions = !showAdvancedOptions">
+            <text class="advanced-title">高级选项</text>
+            <text class="advanced-arrow">{{ showAdvancedOptions ? '▲' : '▼' }}</text>
           </view>
-          <text class="form-hint">选择目标读者,AI会根据人群调整语言风格和表达方式</text>
-          <view class="chip-group">
-            <view
-              v-for="audience in audiences"
-              :key="audience.value"
-              :class="['chip', 'audience-chip', newBook.targetAudience === audience.value ? 'active' : '']"
-              @click="newBook.targetAudience = audience.value"
-            >
-              <text class="chip-icon">{{ audience.icon }}</text>
-              <text class="chip-text">{{ audience.label }}</text>
+
+          <!-- 折叠内容 -->
+          <view v-if="showAdvancedOptions" class="advanced-content">
+            <!-- 副标题 -->
+            <view class="form-item">
+              <text class="form-label">副标题</text>
+              <input
+                v-model="newBook.subtitle"
+                class="form-input"
+                placeholder="例如:一本写给青少年的科普书"
+              />
             </view>
-          </view>
-        </view>
 
-        <!-- 写作风格 -->
-        <view class="form-item">
-          <text class="form-label">写作风格</text>
-          <view class="chip-group">
-            <view
-              v-for="style in styles"
-              :key="style"
-              :class="['chip', newBook.style === style ? 'active' : '']"
-              @click="newBook.style = style"
-            >
-              {{ style }}
+            <!-- 快速模板 -->
+            <view class="form-item">
+              <text class="form-label">📋 快速模板</text>
+              <text class="form-hint-small">点击模板自动填充所有配置,也可手动修改</text>
+              <view class="template-grid">
+                <view
+                  v-for="template in quickTemplates"
+                  :key="template.name"
+                  class="template-card"
+                  @click="applyTemplate(template)"
+                >
+                  <text class="template-icon">{{ template.icon }}</text>
+                  <text class="template-name">{{ template.name }}</text>
+                  <text class="template-desc">{{ template.desc }}</text>
+                </view>
+              </view>
             </view>
-          </view>
-        </view>
 
-        <!-- 行业领域 -->
-        <view class="form-item">
-          <text class="form-label">行业领域</text>
-          <text class="form-hint-small">选择行业领域,AI会使用相关的案例和术语</text>
-          <view class="chip-group">
-            <view
-              v-for="industry in industries"
-              :key="industry.value"
-              :class="['chip', newBook.industry === industry.value ? 'active' : '']"
-              @click="newBook.industry = industry.value"
-            >
-              <text class="chip-icon">{{ industry.icon }}</text>
-              <text class="chip-text">{{ industry.label }}</text>
+            <!-- 重要提示 -->
+            <view class="important-tip">
+              <view class="tip-header">
+                <text class="tip-icon">💡</text>
+                <text class="tip-title">重要提示</text>
+              </view>
+              <text class="tip-content">
+                知识难度和面向人群会显著影响生成内容的风格和深度。同样的知识,面向不同人群,表达方式完全不同:
+              </text>
+              <view class="tip-examples">
+                <text class="tip-example">• 面向小学生:形象、简单、通俗易懂</text>
+                <text class="tip-example">• 面向大学生:系统、专业、有理论深度</text>
+                <text class="tip-example">• 面向研究生:前沿、深入、有研究价值</text>
+              </view>
             </view>
-          </view>
-        </view>
 
-        <!-- 特殊要求 (多选) -->
-        <view class="form-item">
-          <text class="form-label">特殊要求</text>
-          <text class="form-hint-small">可多选,AI会在生成时包含这些元素</text>
-          <view class="chip-group">
-            <view
-              v-for="feature in specialFeatures"
-              :key="feature.value"
-              :class="['chip', 'feature-chip', newBook.specialFeatures.includes(feature.value) ? 'active' : '']"
-              @click="toggleSpecialFeature(feature.value)"
-            >
-              <text class="chip-icon">{{ feature.icon }}</text>
-              <text class="chip-text">{{ feature.label }}</text>
+            <!-- 面向人群 -->
+            <view class="form-item highlight-item">
+              <view class="label-with-badge">
+                <text class="form-label">面向人群</text>
+                <view class="required-badge">重要</view>
+              </view>
+              <text class="form-hint">选择目标读者,AI会根据人群调整语言风格和表达方式</text>
+              <view class="chip-group">
+                <view
+                  v-for="audience in audiences"
+                  :key="audience.value"
+                  :class="['chip', 'audience-chip', newBook.targetAudience === audience.value ? 'active' : '']"
+                  @click="newBook.targetAudience = audience.value"
+                >
+                  <text class="chip-icon">{{ audience.icon }}</text>
+                  <text class="chip-text">{{ audience.label }}</text>
+                </view>
+              </view>
             </view>
-          </view>
-        </view>
 
-        <!-- 书籍规模 -->
-        <view class="form-item">
-          <text class="form-label">书籍规模</text>
-          <view class="scale-picker">
-            <view
-              v-for="scale in bookScales"
-              :key="scale.value"
-              :class="['scale-option', { active: newBook.bookScale === scale.value }]"
-              @click="selectBookScale(scale.value)"
-            >
-              <text class="scale-words">{{ scale.words }}</text>
-              <text class="scale-label">{{ scale.label }}</text>
-              <text class="scale-pages">{{ scale.pages }}</text>
+            <!-- 写作风格 -->
+            <view class="form-item">
+              <text class="form-label">写作风格</text>
+              <view class="chip-group">
+                <view
+                  v-for="style in styles"
+                  :key="style"
+                  :class="['chip', newBook.style === style ? 'active' : '']"
+                  @click="newBook.style = style"
+                >
+                  {{ style }}
+                </view>
+              </view>
+            </view>
+
+            <!-- 行业领域 -->
+            <view class="form-item">
+              <text class="form-label">行业领域</text>
+              <text class="form-hint-small">选择行业领域,AI会使用相关的案例和术语</text>
+              <view class="chip-group">
+                <view
+                  v-for="industry in industries"
+                  :key="industry.value"
+                  :class="['chip', newBook.industry === industry.value ? 'active' : '']"
+                  @click="newBook.industry = industry.value"
+                >
+                  <text class="chip-icon">{{ industry.icon }}</text>
+                  <text class="chip-text">{{ industry.label }}</text>
+                </view>
+              </view>
+            </view>
+
+            <!-- 特殊要求 (多选) -->
+            <view class="form-item">
+              <text class="form-label">特殊要求</text>
+              <text class="form-hint-small">可多选,AI会在生成时包含这些元素</text>
+              <view class="chip-group">
+                <view
+                  v-for="feature in specialFeatures"
+                  :key="feature.value"
+                  :class="['chip', 'feature-chip', newBook.specialFeatures.includes(feature.value) ? 'active' : '']"
+                  @click="toggleSpecialFeature(feature.value)"
+                >
+                  <text class="chip-icon">{{ feature.icon }}</text>
+                  <text class="chip-text">{{ feature.label }}</text>
+                </view>
+              </view>
             </view>
           </view>
         </view>
@@ -254,6 +287,8 @@ const BASE_URL = getApiBaseUrl();
 
 // 创建表单
 const creating = ref(false);
+const showAdvancedOptions = ref(false);
+const isRecommending = ref(false);
 const newBook = ref({
   title: '',
   subtitle: '',
@@ -447,12 +482,70 @@ function applyTemplate(template: typeof quickTemplates[0]) {
   newBook.value.specialFeatures = [...config.specialFeatures];
   newBook.value.style = config.style;
 
+  // 自动展开高级选项
+  showAdvancedOptions.value = true;
+
   uni.showToast({
     title: `已应用${template.name}模板`,
     icon: 'success',
   });
 }
 
+// 智能推荐
+async function requestSmartRecommend() {
+  if (!newBook.value.description.trim() || isRecommending.value) return;
+
+  isRecommending.value = true;
+  try {
+    const response = await uni.request({
+      url: `${BASE_URL}/book-generator/langgraph/smart-recommend`,
+      method: 'POST',
+      data: {
+        description: newBook.value.description.trim(),
+        title: newBook.value.title.trim(),
+      },
+    });
+
+    const res = response.data as any;
+    if (res.code === 0 && res.data) {
+      const rec = res.data;
+      if (rec.knowledgeLevel) {
+        newBook.value.knowledgeLevel = rec.knowledgeLevel;
+      }
+      if (rec.targetAudience) {
+        newBook.value.targetAudience = rec.targetAudience;
+      }
+      if (rec.style) {
+        newBook.value.style = rec.style;
+      }
+      if (rec.industry) {
+        newBook.value.industry = rec.industry;
+      }
+
+      // 自动展开高级选项显示推荐结果
+      showAdvancedOptions.value = true;
+
+      uni.showToast({
+        title: '推荐成功',
+        icon: 'success',
+      });
+    } else {
+      uni.showToast({
+        title: res.message || '推荐失败',
+        icon: 'none',
+      });
+    }
+  } catch (e: any) {
+    console.error('智能推荐失败:', e);
+    uni.showToast({
+      title: '推荐服务暂不可用',
+      icon: 'none',
+    });
+  } finally {
+    isRecommending.value = false;
+  }
+}
+
 // 选择书籍规模时加载预估
 async function selectBookScale(scale: string) {
   newBook.value.bookScale = scale;
@@ -487,7 +580,12 @@ async function selectBookScale(scale: string) {
 }
 
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 
 // 创建新书籍
@@ -731,4 +829,41 @@ onMounted(() => {
 .btn-cancel { background: #f3f4f6; color: #6b7280; }
 .btn-primary { background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: white; }
 .btn-primary[disabled] { opacity: 0.5; }
+
+/* 简洁模式提示 */
+.simple-mode-tip { padding: 24rpx; background: rgba(16, 185, 129, 0.08); border-radius: 12rpx; margin-bottom: 32rpx; }
+.simple-mode-tip .tip-header { display: flex; align-items: center; gap: 8rpx; margin-bottom: 8rpx; }
+.simple-mode-tip .tip-icon { font-size: 28rpx; }
+.simple-mode-tip .tip-title { font-size: 28rpx; font-weight: 600; color: #059669; }
+.simple-mode-tip .tip-content { font-size: 24rpx; color: #6b7280; line-height: 1.5; }
+
+/* 智能推荐按钮 */
+.smart-recommend-section { margin-top: 24rpx; }
+.btn-smart-recommend {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8rpx;
+  width: 100%;
+  height: 80rpx;
+  background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
+  border-radius: 12rpx;
+  border: none;
+  margin-bottom: 12rpx;
+}
+.btn-smart-recommend[disabled] { opacity: 0.5; }
+.recommend-icon { font-size: 32rpx; }
+.recommend-text { font-size: 28rpx; font-weight: 600; color: white; }
+
+/* 高级选项折叠区 */
+.advanced-section { margin-top: 32rpx; border-top: 1rpx solid #e5e7eb; padding-top: 24rpx; }
+.advanced-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16rpx 0;
+}
+.advanced-title { font-size: 28rpx; font-weight: 600; color: #4b5563; }
+.advanced-arrow { font-size: 24rpx; color: #9ca3af; }
+.advanced-content { margin-top: 16rpx; }
 </style>

+ 47 - 13
my-uniapp-vue3/src/pages/book-generator/detail.vue

@@ -17,7 +17,7 @@
       <view class="card book-info">
         <view class="book-title-row">
           <text class="book-title-large">{{ currentBook?.title }}</text>
-          <GenerationStatusBadge :status="currentBook?.status || 'draft'"></GenerationStatusBadge>
+          <GenerationStatusBadge :status="currentBook?.genStage || currentBook?.status || 'draft'"></GenerationStatusBadge>
         </view>
         <text v-if="currentBook?.subtitle" class="book-subtitle">{{ currentBook.subtitle }}</text>
         <text class="book-desc">{{ currentBook?.description }}</text>
@@ -311,9 +311,16 @@ const hasContent = computed(() => {
   return currentBook.value.chapters.some((c) => c.content && c.content.length > 100);
 });
 
-// 内容是否已生成完成
+// 内容是否已生成完成(基于 genStage 判断)
 const isContentCompleted = computed(() => {
   if (!currentBook.value) return false;
+  // 优先使用 genStage 判断
+  if (currentBook.value.genStage) {
+    return currentBook.value.genStage === 'content_completed' ||
+           currentBook.value.genStage === 'audio_completed' ||
+           currentBook.value.genStage === 'video_completed';
+  }
+  // 降级到旧的 status 判断
   return currentBook.value.status === 'completed';
 });
 
@@ -362,6 +369,10 @@ const quotaInfo = ref<{
 
 function getCurrentStage(): string {
   if (!currentBook.value) return '未知';
+  // 优先使用 genStage 进行阶段描述
+  if (currentBook.value.genStage) {
+    return api.getBookStageDescription(currentBook.value.genStage);
+  }
   const progress = currentBook.value.progress || 0;
   const error = currentBook.value.error || '';
   if (error) {
@@ -384,6 +395,8 @@ function getNodeStatusClass(node: any): string {
   if (node.contentStatus === 'completed') return 'status-completed';
   if (node.contentStatus === 'generating') return 'status-generating';
   if (node.contentStatus === 'failed') return 'status-failed';
+  if (node.genStage === 'failed') return 'status-failed';
+  if (node.genStage === 'content_completed' || node.genStage === 'audio_completed' || node.genStage === 'video_completed') return 'status-completed';
   if (node.status === 'completed') return 'status-completed';
   if (node.status === 'pending') return 'status-pending';
   return 'status-pending';
@@ -394,6 +407,8 @@ function getNodeStatusIcon(node: any): string {
   if (node.contentStatus === 'generating') return '⚡';
   if (node.contentStatus === 'failed') return '✗';
   if (node.contentError) return '⚠';
+  if (node.genStage === 'failed') return '✗';
+  if (node.genStage === 'content_completed' || node.genStage === 'audio_completed' || node.genStage === 'video_completed') return '✓';
   if (node.status === 'completed') return '✓';
   if (node.status === 'pending') return '○';
   return '○';
@@ -404,6 +419,10 @@ function getNodeStatusText(node: any): string {
   if (node.contentStatus === 'generating') return '生成中';
   if (node.contentStatus === 'failed') return '失败';
   if (node.contentError) return '错误';
+  if (node.genStage === 'failed') return '失败';
+  if (node.genStage === 'content_completed') return '内容完成';
+  if (node.genStage === 'audio_completed') return '音频完成';
+  if (node.genStage === 'video_completed') return '已完成';
   if (node.status === 'completed') return '大纲完成';
   if (node.status === 'pending') return '待生成';
   return '待生成';
@@ -465,17 +484,28 @@ function getChapterOutline(chapterNumber: number) {
 }
 
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 
 async function loadBook(id: string) {
   try {
     currentBook.value = await api.getBook(id);
-    // 同步 generating 状态
-    if (currentBook.value.status === 'generating') {
+    // 同步 generating 状态(只要不是最终完成状态,就开始轮询)
+    const stage = currentBook.value.genStage;
+    const isFinalState = stage === 'content_completed' || stage === 'audio_completed' || stage === 'video_completed';
+    if (currentBook.value.status === 'generating' ||
+        (stage && !isFinalState)) {
       generating.value = true;
     }
-    startPollingProgress(id);
+    // 开始生成时(包括 content_completed 后台生成音频)都要轮询
+    if (stage && stage !== 'video_completed') {
+      startPollingProgress(id);
+    }
   } catch (e) {
     console.error('加载书籍失败:', e);
     uni.showToast({ title: '加载失败', icon: 'none' });
@@ -486,17 +516,21 @@ function startPollingProgress(bookId: string) {
   stopPollingProgress();
   pollTimer = setInterval(async () => {
     try {
-      const progress = await api.getProgress(bookId);
-      if (!progress) return;
+      const book = await api.getBook(bookId);
+      if (!book) return;
       if (currentBook.value) {
-        currentBook.value.progress = progress.progress;
-        currentBook.value.status = progress.status as any;
+        currentBook.value.progress = book.progress;
+        currentBook.value.status = book.status as any;
+        currentBook.value.genStage = book.genStage as any;
+        currentBook.value.error = book.error;
       }
-      if (progress.status === 'completed' || progress.status === 'failed') {
+      // 以下状态认为生成已完成,停止轮询
+      const doneStages = ['content_completed', 'audio_completed', 'video_completed', 'failed'];
+      if (doneStages.includes(book.genStage) || book.status === 'failed') {
         stopPollingProgress();
         generating.value = false;
-        currentBook.value = await api.getBook(bookId);
-        if (progress.status === 'completed') {
+        currentBook.value = book;
+        if (book.genStage === 'content_completed' || book.genStage === 'audio_completed' || book.genStage === 'video_completed') {
           uni.showToast({ title: '生成完成!', icon: 'success' });
         }
       }

+ 4 - 3
my-uniapp-vue3/src/pages/book-generator/index.vue

@@ -457,9 +457,10 @@ onUnmounted(() => {
 .progress-bar { height: 6rpx; background: #e5e7eb; border-radius: 3rpx; }
 .progress-fill { height: 100%; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); border-radius: 3rpx; transition: width 0.3s; }
 .book-actions { display: flex; gap: 12rpx; }
-.primary-btn, .publish-btn, .more-btn { height: 72rpx; border-radius: 12rpx; font-size: 26rpx; display: flex; align-items: center; justify-content: center; border: none; }
-.primary-btn { flex: 1; background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #ffffff; }
-.publish-btn { width: 180rpx; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: #ffffff; }
+.primary-btn, .publish-btn, .more-btn { height: 72rpx; border-radius: 12rpx; font-size: 26rpx; display: flex; align-items: center; justify-content: center; border: none; white-space: nowrap; }
+.primary-btn { flex: 1; background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #ffffff; padding: 0 16rpx; }
+.publish-btn { width: auto; min-width: 160rpx; padding: 0 24rpx; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: #ffffff; }
+.more-btn { width: 80rpx; background: #e5e7eb; color: #6b7280; font-size: 32rpx; font-weight: bold; }
 .more-btn { width: 80rpx; background: #e5e7eb; color: #6b7280; font-size: 32rpx; font-weight: bold; }
 .primary-btn[disabled], .publish-btn[disabled], .more-btn[disabled] { opacity: 0.6; }
 /* 夜间模式适配 */

+ 29 - 22
my-uniapp-vue3/src/pages/create/index.vue

@@ -134,18 +134,6 @@
         </view>
       </view>
 
-      <!-- 生成按钮 -->
-      <button
-        class="generate-btn"
-        :disabled="!canGenerate"
-        @click="handleGenerate"
-        @touchstart="btnPressed = true"
-        @touchend="btnPressed = false"
-        :class="{ pressed: btnPressed }"
-      >
-        <text class="btn-text">{{ generating ? '生成中...' : '🎵 生成音频' }}</text>
-      </button>
-
       <!-- 视频生成入口 -->
       <view class="video-entry-card" @click="goToVideoGenerator">
         <view class="video-entry-content">
@@ -159,6 +147,18 @@
       </view>
     </view>
 
+    <!-- 生成按钮 - 固定底部 -->
+    <button
+      class="generate-btn"
+      :disabled="!canGenerate"
+      :class="{ disabled: !canGenerate, pressed: btnPressed }"
+      @click="handleGenerate"
+      @touchstart="btnPressed = true"
+      @touchend="btnPressed = false"
+    >
+      <text class="btn-text">{{ generating ? '生成中...' : '🎵 生成音频' }}</text>
+    </button>
+
 
     <!-- 生成成功弹窗 -->
     <view v-if="showSuccessModal" class="success-modal" @click="closeSuccessModal">
@@ -699,7 +699,7 @@ function goToMember() {
 }
 
 .main-content {
-  padding: 132rpx 32rpx 180rpx;
+  padding: 132rpx 32rpx 220rpx;
 }
 
 .card {
@@ -1093,30 +1093,37 @@ function goToMember() {
 }
 
 .generate-btn {
-  width: 100%;
-  height: 100rpx;
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 120rpx;
+  padding: 16rpx 32rpx;
+  padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
   background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
-  border-radius: 24rpx;
+  border-radius: 0;
   border: none;
   display: flex;
   align-items: center;
   justify-content: center;
-  box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.4);
-  transition: all 0.3s ease;
+  box-shadow: 0 -4rpx 24rpx rgba(79, 70, 229, 0.3);
+  transition: opacity 0.3s ease, transform 0.3s ease;
+  opacity: 1;
+  z-index: 100;
 }
 
 .generate-btn::after {
   border: none;
 }
 
-.generate-btn[disabled] {
-  background: #e5e7eb;
+.generate-btn.disabled {
+  opacity: 0.5;
+  background: #9ca3af;
   box-shadow: none;
 }
 
 .generate-btn.pressed {
-  transform: scale(0.96);
-  box-shadow: 0 4rpx 12rpx rgba(79, 70, 229, 0.3);
+  transform: scale(0.98);
 }
 
 /* 视频生成入口卡片 */

+ 6 - 1
my-uniapp-vue3/src/pages/drafts/index.vue

@@ -56,7 +56,12 @@ const drafts = ref<any[]>([]);
 const loading = ref(false);
 
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 
 function getTypeLabel(type: string): string {

+ 6 - 1
my-uniapp-vue3/src/pages/favorites/index.vue

@@ -102,7 +102,12 @@ function playAudio(audio: any) {
 }
 
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 </script>
 

+ 16 - 5
my-uniapp-vue3/src/pages/history/index.vue

@@ -55,12 +55,17 @@
 
     <!-- 音频列表 -->
     <scroll-view scroll-y class="audio-list" @scrolltolower="loadMore" :scroll-top="scrollTop">
-      <view v-if="audioList.length === 0 && !loading" class="empty">
+      <!-- 骨架屏加载状态 -->
+      <SkeletonList v-if="loading && audioList.length === 0" layout="history" :count="6" />
+
+      <!-- 空状态 -->
+      <view v-else-if="audioList.length === 0 && !loading" class="empty">
         <text class="empty-icon">📚</text>
         <text class="empty-text">暂无历史记录</text>
         <text class="empty-hint">快去生成第一个音频吧</text>
       </view>
 
+      <!-- 音频列表 -->
       <view v-else class="audio-grid">
         <view
           v-for="item in audioList"
@@ -106,6 +111,7 @@ import { useAudioStore } from '../../store/audio';
 import { get, post, getFullUrl } from '../../utils/request';
 import { getBatchDownloadUrls } from '../../api/download';
 import type { AudioItem } from '../../types';
+import SkeletonList from '../../components/SkeletonList.vue';
 
 const audioStore = useAudioStore();
 
@@ -361,10 +367,15 @@ function loadMore() {
   }
 }
 
-// 播放音频
-function playAudio(item: AudioItem) {
-  audioStore.play(item);
-  uni.navigateTo({ url: `/pages/player/index?id=${item._id}` });
+// 播放音频 - 跳转到书籍详情页
+function playAudio(item: AudioItem & { bookId?: number }) {
+  // 独立音频跳转到书籍详情
+  if (item.bookId) {
+    uni.navigateTo({ url: `/pages/book-generator/detail?id=${item.bookId}` });
+  } else {
+    // 兜底:跳转到播放器(需要数字ID)
+    uni.showToast({ title: '音频信息不完整', icon: 'none' });
+  }
 }
 
 // 发布音频

+ 322 - 5
my-uniapp-vue3/src/pages/index/index.vue

@@ -8,6 +8,17 @@
 
     <!-- 骨架屏加载状态 -->
     <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-if="loading && bookList.length === 0" :scroll-top="scrollTop">
+      <!-- 最近收听骨架屏 -->
+      <view v-if="userStore.isLoggedIn" class="recent-section">
+        <view class="skeleton-section-title"></view>
+        <scroll-view scroll-x class="recent-scroll" enable-flex>
+          <view v-for="i in 4" :key="i" class="recent-card skeleton-recent-card">
+            <view class="skeleton-recent-cover"></view>
+            <view class="skeleton-recent-title"></view>
+            <view class="skeleton-recent-progress"></view>
+          </view>
+        </scroll-view>
+      </view>
       <view class="skeleton-grid">
         <view v-for="i in 6" :key="i" class="skeleton-card">
           <view class="skeleton-cover"></view>
@@ -21,6 +32,29 @@
 
     <!-- 书籍列表 -->
     <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-else :scroll-top="scrollTop">
+      <!-- 最近收听 -->
+      <view v-if="userStore.isLoggedIn && recentAudios.length > 0" class="recent-section">
+        <text class="section-title">最近收听</text>
+        <scroll-view scroll-x class="recent-scroll" enable-flex>
+          <view
+            v-for="item in recentAudios"
+            :key="item.id"
+            class="recent-card"
+            @click="goToPlayer(item)"
+          >
+            <view class="recent-cover" :style="{ background: getRecentGradient(item.id) }">
+              <image v-if="item.coverUrl" :src="item.coverUrl" mode="aspectFill" class="cover-img" />
+              <text v-else class="cover-icon">📖</text>
+              <view class="recent-progress-bar">
+                <view class="progress-fill" :style="{ width: item.progress + '%' }"></view>
+              </view>
+            </view>
+            <text class="recent-title">{{ item.title }}</text>
+            <text class="recent-progress">{{ formatProgress(item.progress) }}</text>
+          </view>
+        </scroll-view>
+      </view>
+
       <view v-if="bookList.length === 0 && !loading" class="empty">
         <text class="empty-icon">📚</text>
         <text class="empty-text">暂无书籍</text>
@@ -29,13 +63,15 @@
 
       <view v-else class="album-grid">
         <view
-          v-for="item in bookList"
+          v-for="(item, index) in bookList"
           :key="item.id"
           class="album-card"
+          :style="{ animationDelay: (index * 0.05) + 's' }"
           @click="goToAlbumDetail(item)"
         >
           <view class="album-cover" :style="{ background: getCoverGradient(item) }">
-            <text class="cover-icon">📖</text>
+            <text class="cover-letter" :style="{ opacity: 0.15 }">{{ getTitleLetter(item.title) }}</text>
+            <text class="cover-title-small">{{ item.title }}</text>
             <view class="album-count">{{ item.completedChapters || 0 }}/{{ item.totalChapters || 0 }}章</view>
           </view>
           <view class="album-info">
@@ -76,6 +112,8 @@
 import { ref, onMounted } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import { getPublicBooks } from '../../utils/book-generator-api';
+import { get } from '../../utils/request';
+import { useUserStore } from '../../store/user';
 import MiniPlayer from '../../components/MiniPlayer.vue';
 
 // scroll-top 用于避免 scrollTop 错误
@@ -101,6 +139,18 @@ const total = ref(0);
 const loading = ref(false);
 const hasMore = ref(true);
 
+// 最近收听
+interface RecentAudio {
+  id: string;
+  title: string;
+  coverUrl: string;
+  progress: number;
+  updatedAt: string;
+}
+const recentAudios = ref<RecentAudio[]>([]);
+const loadingRecent = ref(false);
+const userStore = useUserStore();
+
 // 封面颜色映射
 const coverColors = [
   ['#4f46e5', '#818cf8'],
@@ -119,6 +169,18 @@ function getCoverGradient(item: Book): string {
   return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
 }
 
+// 获取最近收听封面渐变色
+function getRecentGradient(id: string | number): string {
+  const colors = coverColors[Number(id) % coverColors.length] || coverColors[0];
+  return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
+}
+
+// 获取标题首字母作为装饰文字
+function getTitleLetter(title: string): string {
+  if (!title) return '?';
+  return title.charAt(0).toUpperCase();
+}
+
 // 获取书籍列表
 async function fetchBookList(isLoadMore = false) {
   if (loading.value) return;
@@ -144,6 +206,33 @@ async function fetchBookList(isLoadMore = false) {
   }
 }
 
+// 获取最近收听
+async function fetchRecentAudios() {
+  if (loadingRecent.value || !userStore.isLoggedIn) return;
+
+  loadingRecent.value = true;
+  try {
+    const res = await get<{ list: RecentAudio[] }>('/api/player/recent');
+    recentAudios.value = res?.list || [];
+  } catch (error) {
+    console.error('获取最近收听失败:', error);
+    recentAudios.value = [];
+  } finally {
+    loadingRecent.value = false;
+  }
+}
+
+// 格式化进度
+function formatProgress(progress: number): string {
+  if (!progress && progress !== 0) return '0%';
+  return `${Math.round(progress)}%`;
+}
+
+// 跳转播放
+function goToPlayer(item: RecentAudio) {
+  uni.navigateTo({ url: `/pages/player/index?id=${item.id}` });
+}
+
 // 加载更多
 function loadMore() {
   if (hasMore.value && !loading.value) {
@@ -177,11 +266,17 @@ onMounted(async () => {
   console.log('首页加载了, platform:', uni.getSystemInfoSync().platform);
   uni.showToast({ title: '首页加载中', icon: 'none' });
   await fetchBookList();
+  if (userStore.isLoggedIn) {
+    fetchRecentAudios();
+  }
 });
 
 // 页面显示时刷新
 onShow(() => {
   fetchBookList();
+  if (userStore.isLoggedIn) {
+    fetchRecentAudios();
+  }
 });
 </script>
 
@@ -217,6 +312,79 @@ onShow(() => {
   color: rgba(255, 255, 255, 0.8);
 }
 
+/* 最近收听区域 */
+.recent-section {
+  padding: 24rpx;
+}
+
+.section-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1f2937;
+  margin-bottom: 20rpx;
+  display: block;
+}
+
+.recent-scroll {
+  white-space: nowrap;
+  width: 100%;
+}
+
+.recent-card {
+  display: inline-block;
+  width: 200rpx;
+  margin-right: 20rpx;
+  vertical-align: top;
+}
+
+.recent-cover {
+  position: relative;
+  width: 200rpx;
+  height: 200rpx;
+  border-radius: 16rpx;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.cover-img {
+  width: 100%;
+  height: 100%;
+}
+
+.recent-progress-bar {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 8rpx;
+  background: rgba(0, 0, 0, 0.3);
+}
+
+.progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #4f46e5 0%, #818cf8 100%);
+  transition: width 0.3s ease;
+}
+
+.recent-title {
+  font-size: 26rpx;
+  color: #1f2937;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-top: 12rpx;
+}
+
+.recent-progress {
+  font-size: 22rpx;
+  color: #9ca3af;
+  display: block;
+  margin-top: 4rpx;
+}
+
 .album-list {
   padding-top: 120rpx;
   height: calc(100vh - 120rpx);
@@ -259,6 +427,19 @@ onShow(() => {
   border-radius: 16rpx;
   overflow: hidden;
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+  animation: listItemIn 0.4s ease-out forwards;
+  opacity: 0;
+}
+
+@keyframes listItemIn {
+  from {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
 }
 
 .album-cover {
@@ -268,11 +449,33 @@ onShow(() => {
   display: flex;
   align-items: center;
   justify-content: center;
+  overflow: hidden;
+}
+
+.cover-letter {
+  position: absolute;
+  font-size: 160rpx;
+  font-weight: 800;
+  color: #ffffff;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  pointer-events: none;
 }
 
-.cover-icon {
-  font-size: 80rpx;
-  opacity: 0.6;
+.cover-title-small {
+  position: absolute;
+  bottom: 48rpx;
+  left: 16rpx;
+  right: 16rpx;
+  font-size: 24rpx;
+  font-weight: 500;
+  color: rgba(255, 255, 255, 0.95);
+  text-align: center;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
 }
 
 .album-count {
@@ -329,6 +532,52 @@ onShow(() => {
 }
 
 /* 骨架屏样式 */
+.skeleton-section-title {
+  height: 32rpx;
+  width: 120rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+  margin-bottom: 20rpx;
+}
+
+.skeleton-recent-card {
+  display: inline-block;
+  width: 200rpx;
+  margin-right: 20rpx;
+  vertical-align: top;
+}
+
+.skeleton-recent-cover {
+  width: 200rpx;
+  height: 200rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 16rpx;
+}
+
+.skeleton-recent-title {
+  height: 26rpx;
+  width: 80%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+  margin-top: 12rpx;
+}
+
+.skeleton-recent-progress {
+  height: 22rpx;
+  width: 50%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+  margin-top: 8rpx;
+}
+
 .skeleton-grid {
   display: flex;
   flex-wrap: wrap;
@@ -401,6 +650,11 @@ onShow(() => {
   background: #f5f5f5;
   border-radius: 44rpx;
   border: none;
+  transition: transform 0.1s ease;
+}
+
+.action-btn:active {
+  transform: scale(0.96);
 }
 
 .action-btn::after {
@@ -416,6 +670,11 @@ onShow(() => {
   background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
   border-radius: 44rpx;
   border: none;
+  transition: transform 0.1s ease;
+}
+
+.generate-btn:active {
+  transform: scale(0.96);
 }
 
 .generate-btn::after {
@@ -437,4 +696,62 @@ onShow(() => {
 .action-btn .btn-text {
   color: #333;
 }
+
+/* 夜间模式适配 */
+.dark-mode .section-title {
+  color: #f3f4f6;
+}
+
+.dark-mode .recent-title {
+  color: #e5e7eb;
+}
+
+.dark-mode .recent-progress {
+  color: #6b7280;
+}
+
+.dark-mode .album-card {
+  background: #1f2937;
+}
+
+.dark-mode .album-title {
+  color: #f3f4f6;
+}
+
+.dark-mode .album-desc {
+  color: #9ca3af;
+}
+
+.dark-mode .album-meta {
+  color: #6b7280;
+}
+
+.dark-mode .bottom-bar {
+  background: #1f2937;
+}
+
+.dark-mode .action-btn {
+  background: #374151;
+}
+
+.dark-mode .action-btn .btn-text {
+  color: #e5e7eb;
+}
+
+.dark-mode .page {
+  background: #111827;
+}
+
+.dark-mode .cover-title-small {
+  color: rgba(255, 255, 255, 0.9);
+}
+
+/* 夜间模式骨架屏 */
+.dark-mode .skeleton-section-title,
+.dark-mode .skeleton-recent-cover,
+.dark-mode .skeleton-recent-title,
+.dark-mode .skeleton-recent-progress {
+  background: linear-gradient(90deg, #374151 25%, #4b5563 50%, #374151 75%);
+  background-size: 200% 100%;
+}
 </style>

+ 10 - 4
my-uniapp-vue3/src/pages/member/index.vue

@@ -205,8 +205,8 @@ async function mockPay(orderNo: string) {
 .loading-text { font-size: 28rpx; color: #9ca3af; }
 
 .plans-list { display: flex; flex-direction: column; gap: 20rpx; }
-.plan-card { background: #fff; border-radius: 20rpx; padding: 24rpx; border: 3rpx solid #e5e7eb; position: relative; transition: all 0.3s; }
-.plan-card.active { border-color: #667eea; box-shadow: 0 8rpx 32rpx rgba(102, 126, 234, 0.2); }
+.plan-card { background: #fff; border-radius: 20rpx; padding: 24rpx; border: 3rpx solid #e5e7eb; position: relative; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transform-origin: center center; }
+.plan-card.active { border-color: #667eea; box-shadow: 0 8rpx 32rpx rgba(102, 126, 234, 0.35); transform: scale(1.02); }
 .plan-card.recommended { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-color: #fbbf24; }
 .plan-card.free { background: #f9fafb; }
 
@@ -226,8 +226,14 @@ async function mockPay(orderNo: string) {
 .feature-icon { font-size: 22rpx; color: #10b981; margin-right: 8rpx; }
 .feature-text { font-size: 22rpx; color: #4b5563; }
 
-.select-btn { width: 100%; padding: 16rpx; text-align: center; background: #f3f4f6; border-radius: 12rpx; font-size: 26rpx; color: #6b7280; transition: all 0.3s; }
-.select-btn.selected { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
+.select-btn { width: 100%; padding: 16rpx; text-align: center; background: #f3f4f6; border-radius: 12rpx; font-size: 26rpx; color: #6b7280; transition: all 0.3s; overflow: hidden; }
+.select-btn.selected { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; animation: checkmark-pop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
+
+@keyframes checkmark-pop {
+  0% { transform: scale(1); }
+  50% { transform: scale(1.08); }
+  100% { transform: scale(1); }
+}
 
 .payment-section { padding: 32rpx; margin-top: 16rpx; }
 .payment-methods { display: flex; gap: 20rpx; }

+ 495 - 2
my-uniapp-vue3/src/pages/mine/index.vue

@@ -29,6 +29,68 @@
       </view>
     </view>
 
+    <!-- 今日创作概况 -->
+    <view class="today-card" v-if="userStore.isLoggedIn">
+      <view class="today-header">
+        <text class="today-title">今日创作概况</text>
+        <text class="today-date">{{ todayDate }}</text>
+      </view>
+      <view class="today-stats">
+        <view class="today-stat-item">
+          <text class="today-stat-value">{{ todayStats.audioCount }}</text>
+          <text class="today-stat-label">生成音频</text>
+        </view>
+        <view class="today-stat-divider" />
+        <view class="today-stat-item">
+          <text class="today-stat-value">{{ todayStats.bookCount }}</text>
+          <text class="today-stat-label">生成书籍</text>
+        </view>
+        <view class="today-stat-divider" />
+        <view class="today-stat-item">
+          <text class="today-stat-value">{{ todayStats.usageQuota }}</text>
+          <text class="today-stat-label">使用额度</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 最近的创作 -->
+    <view class="recent-card" v-if="recentCreations.length > 0">
+      <view class="recent-header">
+        <text class="recent-title">最近的创作</text>
+        <text class="recent-more" @click="goHistory">查看更多 ›</text>
+      </view>
+      <view class="recent-list">
+        <view
+          v-for="item in recentCreations"
+          :key="item._id"
+          class="recent-item"
+          hover-class="recent-item-hover"
+          @click="goToDetail(item)"
+        >
+          <view class="recent-cover" :style="{ background: getCoverGradient(item.voiceId) }">
+            <text class="cover-icon">🎵</text>
+          </view>
+          <view class="recent-info">
+            <text class="recent-title-text">{{ item.title }}</text>
+            <text class="recent-meta">{{ item.wordCount }}字 · {{ formatDate(item.createdAt) }}</text>
+          </view>
+          <text class="recent-arrow">›</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 升级会员推广卡片 -->
+    <view class="upgrade-card" v-if="userStore.isLoggedIn && !userStore.isMember" @click="goMember">
+      <view class="upgrade-left">
+        <text class="upgrade-icon">👑</text>
+        <view class="upgrade-text">
+          <text class="upgrade-title">升级会员</text>
+          <text class="upgrade-desc">解锁更多创作次数和高级音色</text>
+        </view>
+      </view>
+      <text class="upgrade-btn">立即开通</text>
+    </view>
+
     <!-- 功能入口 -->
     <view class="menu-section">
       <view class="menu-item" @click="goMember">
@@ -83,12 +145,30 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted } from 'vue';
+import { computed, ref, onMounted } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import { useUserStore } from '../../store/user';
+import { get } from '../../utils/request';
+import type { AudioItem } from '../../types';
 
 const userStore = useUserStore();
 
+// 今日创作概况
+const todayStats = ref({
+  audioCount: 0,
+  bookCount: 0,
+  usageQuota: 0
+});
+
+// 最近的创作
+const recentCreations = ref<AudioItem[]>([]);
+
+// 今日日期
+const todayDate = computed(() => {
+  const now = new Date();
+  return `${now.getMonth() + 1}月${now.getDate()}日`;
+});
+
 // 计算属性
 const memberClass = computed(() => {
   const level = userStore.memberStatus?.level || 0;
@@ -99,10 +179,94 @@ const memberText = computed(() => {
   return userStore.memberStatus?.levelName || '免费用户';
 });
 
+// 获取今日创作概况
+async function fetchTodayStats() {
+  try {
+    const now = new Date();
+    const startDate = new Date(now.setHours(0, 0, 0, 0)).toISOString();
+
+    // 获取今日音频数
+    const audioResult = await get<{ list: AudioItem[]; total: number }>('/history', {
+      startDate,
+      pageSize: 100
+    });
+
+    // 获取今日书籍数(从书籍生成记录获取)
+    let bookCount = 0;
+    try {
+      const bookResult = await get<{ list: any[]; total: number }>('/book-generator/list', {
+        startDate,
+        pageSize: 100
+      });
+      bookCount = bookResult.total || 0;
+    } catch (e) {
+      // 书籍接口可能不存在
+      console.log('[Mine] 书籍接口不存在', e);
+    }
+
+    todayStats.value = {
+      audioCount: audioResult.total || 0,
+      bookCount,
+      usageQuota: userStore.memberStatus?.quota.dailyRemaining || 0
+    };
+  } catch (error) {
+    console.log('[Mine] 获取今日概况失败', error);
+  }
+}
+
+// 获取最近的创作
+async function fetchRecentCreations() {
+  try {
+    const result = await get<{ list: AudioItem[]; total: number }>('/history', {
+      pageSize: 3,
+      sortBy: 'createdAt',
+      sortOrder: 'desc'
+    });
+    recentCreations.value = (result.list || []).slice(0, 3);
+  } catch (error) {
+    console.log('[Mine] 获取最近创作失败', error);
+  }
+}
+
+// 获取封面渐变色
+function getCoverGradient(voiceId: string): string {
+  const gradients = [
+    'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+    'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
+    'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
+    'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
+    'linear-gradient(135deg, #fa709a 0%, #fee140 100%)'
+  ];
+  let hash = 0;
+  for (let i = 0; i < voiceId.length; i++) {
+    hash = voiceId.charCodeAt(i) + ((hash << 5) - hash);
+  }
+  return gradients[Math.abs(hash) % gradients.length];
+}
+
+// 格式化日期
+function formatDate(dateStr: string): string {
+  const date = new Date(dateStr);
+  const now = new Date();
+  const diff = now.getTime() - date.getTime();
+  const oneDay = 24 * 60 * 60 * 1000;
+
+  if (diff < oneDay) return '今天';
+  if (diff < 2 * oneDay) return '昨天';
+  return `${date.getMonth() + 1}/${date.getDate()}`;
+}
+
+// 跳转到历史记录页
+function goToDetail(item: AudioItem) {
+  uni.navigateTo({ url: '/pages/history/index' });
+}
+
 // 页面显示
 onShow(() => {
   if (userStore.isLoggedIn) {
     userStore.fetchMemberStatus();
+    fetchTodayStats();
+    fetchRecentCreations();
   } else {
     // 未登录,跳转到登录页
     uni.navigateTo({ url: '/pages/login/index' });
@@ -176,6 +340,13 @@ function handleLogout() {
   padding-bottom: 120rpx;
 }
 
+/* 夜间模式适配 */
+@media (prefers-color-scheme: dark) {
+  .page {
+    background: #1a1a2e;
+  }
+}
+
 .user-card {
   margin: 32rpx;
   background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
@@ -277,6 +448,295 @@ function handleLogout() {
   background: rgba(255, 255, 255, 0.2);
 }
 
+/* 今日创作概况卡片 */
+.today-card {
+  margin: 0 32rpx 24rpx;
+  background: #ffffff;
+  border-radius: 20rpx;
+  padding: 32rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+}
+
+@media (prefers-color-scheme: dark) {
+  .today-card {
+    background: #2d2d44;
+  }
+}
+
+.today-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+
+.today-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+@media (prefers-color-scheme: dark) {
+  .today-title {
+    color: #f9fafb;
+  }
+}
+
+.today-date {
+  font-size: 24rpx;
+  color: #9ca3af;
+}
+
+.today-stats {
+  display: flex;
+  align-items: center;
+}
+
+.today-stat-item {
+  flex: 1;
+  text-align: center;
+}
+
+.today-stat-value {
+  display: block;
+  font-size: 44rpx;
+  font-weight: 700;
+  color: #4f46e5;
+}
+
+@media (prefers-color-scheme: dark) {
+  .today-stat-value {
+    color: #818cf8;
+  }
+}
+
+.today-stat-label {
+  display: block;
+  font-size: 22rpx;
+  color: #6b7280;
+  margin-top: 4rpx;
+}
+
+@media (prefers-color-scheme: dark) {
+  .today-stat-label {
+    color: #9ca3af;
+  }
+}
+
+.today-stat-divider {
+  width: 2rpx;
+  height: 60rpx;
+  background: #e5e7eb;
+}
+
+@media (prefers-color-scheme: dark) {
+  .today-stat-divider {
+    background: #374151;
+  }
+}
+
+/* 最近的创作卡片 */
+.recent-card {
+  margin: 0 32rpx 24rpx;
+  background: #ffffff;
+  border-radius: 20rpx;
+  padding: 32rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+}
+
+@media (prefers-color-scheme: dark) {
+  .recent-card {
+    background: #2d2d44;
+  }
+}
+
+.recent-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+
+.recent-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+@media (prefers-color-scheme: dark) {
+  .recent-title {
+    color: #f9fafb;
+  }
+}
+
+.recent-more {
+  font-size: 24rpx;
+  color: #4f46e5;
+}
+
+@media (prefers-color-scheme: dark) {
+  .recent-more {
+    color: #818cf8;
+  }
+}
+
+.recent-list {
+  display: flex;
+  flex-direction: column;
+}
+
+.recent-item {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #f3f4f6;
+}
+
+.recent-item:last-child {
+  border-bottom: none;
+}
+
+.recent-item-hover {
+  background-color: #f3f4f6;
+  border-radius: 8rpx;
+}
+
+@media (prefers-color-scheme: dark) {
+  .recent-item {
+    border-bottom-color: #374151;
+  }
+  .recent-item-hover {
+    background-color: #374151;
+  }
+  .recent-item:last-child {
+    border-bottom: none;
+  }
+}
+
+.recent-cover {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.cover-icon {
+  font-size: 32rpx;
+}
+
+.recent-info {
+  flex: 1;
+  margin-left: 20rpx;
+  overflow: hidden;
+}
+
+.recent-title-text {
+  display: block;
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #1f2937;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+@media (prefers-color-scheme: dark) {
+  .recent-title-text {
+    color: #f9fafb;
+  }
+}
+
+.recent-meta {
+  display: block;
+  font-size: 22rpx;
+  color: #9ca3af;
+  margin-top: 4rpx;
+}
+
+.recent-arrow {
+  font-size: 32rpx;
+  color: #d1d5db;
+  margin-left: 16rpx;
+}
+
+@media (prefers-color-scheme: dark) {
+  .recent-arrow {
+    color: #4b5563;
+  }
+}
+
+/* 升级会员卡片 */
+.upgrade-card {
+  margin: 0 32rpx 24rpx;
+  background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+  border-radius: 20rpx;
+  padding: 28rpx 32rpx;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  box-shadow: 0 4rpx 16rpx rgba(251, 191, 36, 0.2);
+}
+
+@media (prefers-color-scheme: dark) {
+  .upgrade-card {
+    background: linear-gradient(135deg, #92400e 0%, #78350f 100%);
+  }
+}
+
+.upgrade-left {
+  display: flex;
+  align-items: center;
+}
+
+.upgrade-icon {
+  font-size: 40rpx;
+  margin-right: 16rpx;
+}
+
+.upgrade-text {
+  display: flex;
+  flex-direction: column;
+}
+
+.upgrade-title {
+  display: block;
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #92400e;
+}
+
+@media (prefers-color-scheme: dark) {
+  .upgrade-title {
+    color: #fef3c7;
+  }
+}
+
+.upgrade-desc {
+  display: block;
+  font-size: 22rpx;
+  color: #b45309;
+  margin-top: 4rpx;
+}
+
+@media (prefers-color-scheme: dark) {
+  .upgrade-desc {
+    color: #fde68a;
+  }
+}
+
+.upgrade-btn {
+  font-size: 24rpx;
+  color: #ffffff;
+  background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
+  padding: 12rpx 24rpx;
+  border-radius: 24rpx;
+  font-weight: 500;
+}
+
+/* 功能菜单 */
 .menu-section {
   margin: 32rpx;
   background: #ffffff;
@@ -285,6 +745,12 @@ function handleLogout() {
   box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
 }
 
+@media (prefers-color-scheme: dark) {
+  .menu-section {
+    background: #2d2d44;
+  }
+}
+
 .menu-item {
   display: flex;
   align-items: center;
@@ -296,6 +762,15 @@ function handleLogout() {
   border-bottom: none;
 }
 
+@media (prefers-color-scheme: dark) {
+  .menu-item {
+    border-bottom-color: #374151;
+  }
+  .menu-item:last-child {
+    border-bottom: none;
+  }
+}
+
 .menu-icon {
   font-size: 40rpx;
   margin-right: 24rpx;
@@ -307,6 +782,12 @@ function handleLogout() {
   color: #1f2937;
 }
 
+@media (prefers-color-scheme: dark) {
+  .menu-text {
+    color: #f9fafb;
+  }
+}
+
 .menu-tag {
   font-size: 20rpx;
   padding: 4rpx 12rpx;
@@ -321,6 +802,12 @@ function handleLogout() {
   color: #9ca3af;
 }
 
+@media (prefers-color-scheme: dark) {
+  .menu-arrow {
+    color: #6b7280;
+  }
+}
+
 .logout-section {
   margin: 48rpx 32rpx;
 }
@@ -344,4 +831,10 @@ function handleLogout() {
   font-size: 30rpx;
   color: #ef4444;
 }
-</style>
+
+@media (prefers-color-scheme: dark) {
+  .logout-btn {
+    background: #2d2d44;
+  }
+}
+</style>

+ 6 - 1
my-uniapp-vue3/src/pages/notifications/index.vue

@@ -54,7 +54,12 @@ const notifications = ref<any[]>([]);
 const loading = ref(false);
 
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 
 function getIcon(type: string): string {

+ 6 - 1
my-uniapp-vue3/src/pages/orders/index.vue

@@ -181,7 +181,12 @@ async function loadUsageList(pageNum: number) {
 }
 
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 
 function goToSubscribe() {

+ 199 - 8
my-uniapp-vue3/src/pages/player/index.vue

@@ -5,8 +5,17 @@
       <view class="nav-btn" @click="goBack">
         <text class="nav-icon">←</text>
       </view>
-      <text class="nav-title">正在播放</text>
-      <view class="nav-btn" />
+      <view class="nav-center">
+        <text class="nav-title">正在播放</text>
+        <!-- 睡眠定时显示 -->
+        <view v-if="sleepTimer" class="sleep-timer-badge" @click="showSleepTimerPicker = true">
+          <text class="sleep-timer-icon">⏰</text>
+          <text class="sleep-timer-text">{{ formatSleepTimer(sleepTimer.remaining) }}</text>
+        </view>
+      </view>
+      <view class="nav-btn" @click="showSleepTimerPicker = true">
+        <text class="nav-icon">⏰</text>
+      </view>
     </view>
 
     <!-- 无音频提示 -->
@@ -156,6 +165,27 @@
         </view>
       </view>
     </view>
+
+    <!-- 睡眠定时选择器 -->
+    <view v-if="showSleepTimerPicker" class="rate-picker" @click="showSleepTimerPicker = false">
+      <view class="rate-content" @click.stop>
+        <text class="rate-title">睡眠定时</text>
+        <view class="rate-options">
+          <view
+            v-for="option in sleepTimerOptions"
+            :key="option.value"
+            class="rate-item"
+            :class="{ active: sleepTimer && sleepTimer.total === option.value }"
+            @click="handleSetSleepTimer(option.value)"
+          >
+            <text>{{ option.label }}</text>
+          </view>
+        </view>
+        <view v-if="sleepTimer" class="sleep-timer-cancel" @click="cancelSleepTimer">
+          <text>取消定时</text>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
@@ -172,9 +202,57 @@ const audioStore = useAudioStore();
 const audioId = ref('');
 const audio = ref<AudioItem | null>(null);
 const showRatePicker = ref(false);
+const showSleepTimerPicker = ref(false);
 const lyrics = ref<{ text: string; start: number; end: number }[]>([]);
 const scrollIntoViewId = ref('');
 
+// 睡眠定时
+const sleepTimer = ref<{ remaining: number; total: number } | null>(null);
+let sleepTimerInterval: ReturnType<typeof setInterval> | null = null;
+
+// 睡眠定时选项
+const sleepTimerOptions = [
+  { label: '15分钟', value: 15 * 60 },
+  { label: '30分钟', value: 30 * 60 },
+  { label: '60分钟', value: 60 * 60 },
+  { label: '当前播放结束', value: -1 },
+];
+
+// 解析 LRC 格式歌词
+function parseLrc(lrcText: string): { text: string; start: number; end: number }[] {
+  if (!lrcText) return [];
+
+  const timeline: { text: string; start: number; end: number }[] = [];
+  const lines = lrcText.split('\n');
+
+  for (const line of lines) {
+    // 匹配 [MM:SS.XX] 格式
+    const match = line.match(/^\[(\d{2}):(\d{2})\.(\d{2})\]\s*(.*)$/);
+    if (match) {
+      const minutes = parseInt(match[1]);
+      const seconds = parseInt(match[2]);
+      const centiseconds = parseInt(match[3]);
+      const text = match[4].trim();
+
+      if (text) {
+        const start = minutes * 60 + seconds + centiseconds / 100;
+        timeline.push({ text, start, end: start }); // end 暂时未知,后续计算
+      }
+    }
+  }
+
+  // 计算 end 时间(下一句的开始时间)
+  for (let i = 0; i < timeline.length - 1; i++) {
+    timeline[i].end = timeline[i + 1].start;
+  }
+  // 最后一歌词设置一个大致的结束时间
+  if (timeline.length > 0) {
+    timeline[timeline.length - 1].end = timeline[timeline.length - 1].start + 5;
+  }
+
+  return timeline;
+}
+
 // 生成歌词时间线(按句子估算)
 function generateLyricsTimeline(text: string, totalDuration: number): { text: string; start: number; end: number }[] {
   if (!text) return [];
@@ -194,15 +272,15 @@ function generateLyricsTimeline(text: string, totalDuration: number): { text: st
 
     // 估算句子字数和时长
     const chars = (trimmed.match(/[一-龥a-zA-Z0-9]/g) || []).length;
-    const duration = chars / charsPerSecond;
+    const sentenceDuration = chars / charsPerSecond;
 
     timeline.push({
       text: trimmed,
       start: currentTime,
-      end: currentTime + duration,
+      end: currentTime + sentenceDuration,
     });
 
-    currentTime += duration;
+    currentTime += sentenceDuration;
   }
 
   return timeline;
@@ -247,6 +325,10 @@ onMounted(async () => {
 
 // 页面卸载
 onUnmounted(() => {
+  // 清除睡眠定时器
+  if (sleepTimerInterval) {
+    clearInterval(sleepTimerInterval);
+  }
   // 不销毁音频上下文,保持后台播放
 });
 
@@ -270,9 +352,13 @@ async function fetchAudio() {
     // 获取播放列表(使用专辑章节列表)
     await fetchAlbumPlaylist(result);
 
-    // 生成歌词时间线(在获取播放列表后,使用列表中的时长
+    // 生成歌词时间线(优先使用真实 LRC,否则按句子估算
     const audioDuration = result.audioDuration || audioStore.duration || 60;
-    lyrics.value = generateLyricsTimeline(result.text || '', audioDuration);
+    if (result.lrcLyrics) {
+      lyrics.value = parseLrc(result.lrcLyrics);
+    } else {
+      lyrics.value = generateLyricsTimeline(result.text || '', audioDuration);
+    }
 
     // 开始播放(store 中已初始化 audioContext)
     audioStore.play(result);
@@ -361,7 +447,12 @@ function formatDuration(seconds: number): string {
 
 // 返回
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 
 // 跳转进度
@@ -427,6 +518,65 @@ function handleSetRate(rate: number) {
   showRatePicker.value = false;
 }
 
+// 睡眠定时格式化
+function formatSleepTimer(seconds: number): string {
+  const mins = Math.floor(seconds / 60);
+  const secs = seconds % 60;
+  return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+// 设置睡眠定时
+function handleSetSleepTimer(seconds: number) {
+  showSleepTimerPicker.value = false;
+
+  if (seconds === -1) {
+    // 当前播放结束
+    const remaining = duration.value - currentTime.value;
+    if (remaining > 0) {
+      startSleepTimer(Math.ceil(remaining));
+    } else {
+      uni.showToast({ title: '当前播放即将结束', icon: 'none' });
+      return;
+    }
+  } else {
+    startSleepTimer(seconds);
+  }
+}
+
+// 开始睡眠定时
+function startSleepTimer(seconds: number) {
+  // 清除之前的定时器
+  if (sleepTimerInterval) {
+    clearInterval(sleepTimerInterval);
+  }
+
+  sleepTimer.value = { remaining: seconds, total: seconds };
+  sleepTimerInterval = setInterval(() => {
+    if (sleepTimer.value) {
+      sleepTimer.value.remaining--;
+      if (sleepTimer.value.remaining <= 0) {
+        clearInterval(sleepTimerInterval!);
+        sleepTimerInterval = null;
+        // 暂停播放(currentTime已保存,下次打开可继续)
+        audioStore.pause();
+        sleepTimer.value = null;
+        uni.showToast({ title: '睡眠定时已到,自动暂停', icon: 'none' });
+      }
+    }
+  }, 1000);
+}
+
+// 取消睡眠定时
+function cancelSleepTimer() {
+  showSleepTimerPicker.value = false;
+  if (sleepTimerInterval) {
+    clearInterval(sleepTimerInterval);
+    sleepTimerInterval = null;
+  }
+  sleepTimer.value = null;
+  uni.showToast({ title: '已取消睡眠定时', icon: 'none' });
+}
+
 // 切换收藏
 async function toggleFavorite() {
   if (!audio.value) return;
@@ -550,6 +700,12 @@ function copyShareLink() {
   padding: 44rpx 32rpx 24rpx;
 }
 
+.nav-center {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
 .nav-btn {
   width: 64rpx;
   height: 64rpx;
@@ -558,6 +714,26 @@ function copyShareLink() {
   justify-content: center;
 }
 
+.sleep-timer-badge {
+  display: flex;
+  align-items: center;
+  background: rgba(79, 70, 229, 0.3);
+  padding: 8rpx 16rpx;
+  border-radius: 24rpx;
+  margin-top: 8rpx;
+}
+
+.sleep-timer-icon {
+  font-size: 20rpx;
+  margin-right: 6rpx;
+}
+
+.sleep-timer-text {
+  font-size: 22rpx;
+  color: #a5b4fc;
+  font-weight: 500;
+}
+
 .nav-icon {
   font-size: 36rpx;
   color: #ffffff;
@@ -820,6 +996,21 @@ function copyShareLink() {
   color: #ffffff;
 }
 
+.sleep-timer-cancel {
+  margin-top: 24rpx;
+  padding: 24rpx;
+  background: rgba(239, 68, 68, 0.2);
+  border-radius: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.sleep-timer-cancel text {
+  font-size: 28rpx;
+  color: #fca5a5;
+}
+
 /* 移动端播放列表设计 - 直接嵌入界面 */
 .playlist-section-mobile {
   margin-top: 20rpx;

+ 6 - 1
my-uniapp-vue3/src/pages/playlists/detail.vue

@@ -68,7 +68,12 @@ const playlist = ref<any>(null);
 const loading = ref(false);
 
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 
 async function fetchPlaylist() {

+ 6 - 1
my-uniapp-vue3/src/pages/playlists/index.vue

@@ -93,7 +93,12 @@ const newPlaylistDesc = ref('');
 
 // 返回
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 
 // 跳转到详情

+ 45 - 2
my-uniapp-vue3/src/pages/search/index.vue

@@ -11,8 +11,13 @@
       <button class="search-btn" @click="handleSearch">搜索</button>
     </view>
 
+    <!-- 骨架屏加载状态 -->
+    <scroll-view scroll-y class="results" v-if="loading" :scroll-top="scrollTop">
+      <SkeletonList layout="search-result" :count="5" />
+    </scroll-view>
+
     <!-- 搜索结果 -->
-    <scroll-view scroll-y class="results" v-if="searchResults.length > 0" :scroll-top="scrollTop">
+    <scroll-view scroll-y class="results" v-else-if="searchResults.length > 0" :scroll-top="scrollTop">
       <view class="result-list">
         <view
           v-for="item in searchResults"
@@ -91,6 +96,10 @@ import { onLoad } from '@dcloudio/uni-app';
 import { useAudioStore } from '../../store/audio';
 import { get, post } from '../../utils/request';
 import type { AudioItem } from '../../types';
+import SkeletonList from '../../components/SkeletonList.vue';
+
+// 缓存键名
+const HOT_SEARCH_CACHE_KEY = 'hot_search_cache';
 
 // scroll-top 用于避免 scrollTop 错误
 const scrollTop = ref(0);
@@ -100,6 +109,7 @@ const audioStore = useAudioStore();
 const searchQuery = ref('');
 const searchResults = ref<AudioItem[]>([]);
 const hasSearched = ref(false);
+const loading = ref(false);
 const searchHistory = ref<{ keyword: string }[]>([]);
 const hotSearches = ref<{ id: number; keyword: string; count: number }[]>([]);
 
@@ -134,6 +144,25 @@ function escapeRegExp(str: string): string {
   return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
 }
 
+// 缓存热词
+function cacheHotSearches(hotList: { id: number; keyword: string; count: number }[]) {
+  try {
+    uni.setStorageSync(HOT_SEARCH_CACHE_KEY, hotList);
+  } catch (e) {
+    console.error('缓存热词失败:', e);
+  }
+}
+
+// 读取缓存热词
+function getCachedHotSearches(): { id: number; keyword: string; count: number }[] {
+  try {
+    return uni.getStorageSync(HOT_SEARCH_CACHE_KEY) || [];
+  } catch (e) {
+    console.error('读取热词缓存失败:', e);
+    return [];
+  }
+}
+
 // 加载搜索历史和热门推荐
 async function loadSearchContext() {
   try {
@@ -145,10 +174,16 @@ async function loadSearchContext() {
     ]);
     searchHistory.value = historyRes || [];
     hotSearches.value = hotRes || [];
+
+    // 更新热词缓存
+    if (hotRes && hotRes.length > 0) {
+      cacheHotSearches(hotRes);
+    }
   } catch (error) {
     console.error('加载搜索上下文失败:', error);
     searchHistory.value = [];
-    hotSearches.value = [];
+    // 读取缓存热词
+    hotSearches.value = getCachedHotSearches();
   }
 }
 
@@ -190,6 +225,7 @@ async function handleSearch() {
   if (!searchQuery.value.trim()) return;
 
   hasSearched.value = true;
+  loading.value = true;
   try {
     // 保存搜索历史
     const userId = 1;
@@ -198,11 +234,18 @@ async function handleSearch() {
     const result = await get<AudioItem[]>('/search', { q: searchQuery.value });
     searchResults.value = result || [];
 
+    // 更新热词缓存(搜索结果返回后更新)
+    if (hotSearches.value.length > 0) {
+      cacheHotSearches(hotSearches.value);
+    }
+
     // 刷新历史记录
     await loadSearchContext();
   } catch (error) {
     console.error('搜索失败:', error);
     searchResults.value = [];
+  } finally {
+    loading.value = false;
   }
 }
 

+ 6 - 1
my-uniapp-vue3/src/pages/video-generator/index.vue

@@ -180,7 +180,12 @@ function formatFileSize(bytes: number): string {
 
 // 返回上一页
 function goBack() {
-  uni.navigateBack();
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    uni.navigateBack();
+  } else {
+    uni.switchTab({ url: '/pages/index/index' });
+  }
 }
 
 // 跳转创建页

+ 15 - 0
my-uniapp-vue3/src/store/audio.ts

@@ -63,7 +63,18 @@ export const useAudioStore = defineStore('audio', () => {
 
     audioContext.onError((err: any) => {
       console.error('音频播放错误:', err);
+      console.error('错误码:', err.errCode);
+      console.error('错误信息:', err.errMsg);
       isPlaying.value = false;
+      // 尝试获取更详细的错误信息
+      if (audioContext) {
+        console.error('audioContext.src:', audioContext.src);
+        console.error('audioContext.readyState:', audioContext.readyState);
+      }
+    });
+
+    audioContext.onCanplay(() => {
+      console.log('音频已准备好播放');
     });
   }
 
@@ -109,10 +120,14 @@ export const useAudioStore = defineStore('audio', () => {
 
     // 如果音频源相同,只切换播放状态
     if (audioContext && audioContext.src === fullUrl) {
+      console.log('音频源相同,切换播放状态');
       audioContext.play();
     } else if (audioContext) {
+      console.log('设置新音频源并播放:', fullUrl);
       audioContext.src = fullUrl;
       audioContext.play();
+    } else {
+      console.error('audioContext 未初始化!');
     }
 
     // 使用 API 返回的 audioDuration 作为初始时长(避免音频文件 metadata 错误的问题)

+ 1 - 0
my-uniapp-vue3/src/types/index.ts

@@ -37,6 +37,7 @@ export interface AudioItem {
   isFavorite: boolean;
   isPublic?: boolean;    // 是否公开(2026-04-14 新增)
   isOwner?: boolean;     // 是否为当前用户所有
+  lrcLyrics?: string;     // LRC 格式歌词时间轴(OPT-17)
   createdAt: string;
   updatedAt: string;
 }

+ 133 - 1
my-uniapp-vue3/src/utils/book-generator-api.ts

@@ -23,6 +23,8 @@ export interface Book {
   metadata?: BookMetadata;
   error?: string;
   isPublished?: boolean;
+  genStage?: BookGenStage;    // 线性阶段状态
+  failedStage?: string;       // 失败阶段
   createdAt: string;
   updatedAt: string;
 }
@@ -65,12 +67,40 @@ export interface Chapter {
   status: 'pending' | 'generating' | 'completed' | 'failed';
   audioUrl?: string;
   audioDuration?: number;
+  audioStatus?: string;
   videoUrl?: string;
   videoDuration?: number;
+  videoStatus?: string;
+  contentStatus?: string;
   error?: string;
   level?: number;            // 层级:1=章, 2=节, 3=小节
   parentId?: number | null;  // 父节点ID(null表示章)
-}
+  genStage?: ChapterGenStage;  // 线性阶段状态
+}
+
+// ============ 线性阶段类型(与后端 stage-manager.ts 同步) ============
+
+export type BookGenStage =
+  | 'draft'
+  | 'outlining'
+  | 'outline_completed'
+  | 'content_generating'
+  | 'content_completed'
+  | 'audio_generating'
+  | 'audio_completed'
+  | 'video_generating'
+  | 'video_completed'
+  | 'failed';
+
+export type ChapterGenStage =
+  | 'idle'
+  | 'content_generating'
+  | 'content_completed'
+  | 'audio_generating'
+  | 'audio_completed'
+  | 'video_generating'
+  | 'video_completed'
+  | 'failed';
 
 export interface BookMetadata {
   author?: string;
@@ -92,6 +122,7 @@ export interface ProgressResponse {
   bookId: string;
   status: string;
   progress: number;
+  genStage?: string;
   completedChapters: number;
   totalChapters: number;
   currentTask?: {
@@ -394,6 +425,46 @@ export async function mergeChapterVideos(
   return result;
 }
 
+/**
+ * 获取书籍音频生成状态(用于轮询)
+ */
+export async function getAudioStatus(bookId: string): Promise<{
+  bookId: string;
+  total: number;
+  completed: number;
+  pending: number;
+  allCompleted: boolean;
+}> {
+  const result = await request<{
+    bookId: string;
+    total: number;
+    completed: number;
+    pending: number;
+    allCompleted: boolean;
+  }>(`${BASE_URL}/books/${bookId}/audio-status`);
+  return result;
+}
+
+/**
+ * 获取书籍视频生成状态(用于轮询)
+ */
+export async function getVideoStatus(bookId: string): Promise<{
+  bookId: string;
+  total: number;
+  completed: number;
+  pending: number;
+  allCompleted: boolean;
+}> {
+  const result = await request<{
+    bookId: string;
+    total: number;
+    completed: number;
+    pending: number;
+    allCompleted: boolean;
+  }>(`${BASE_URL}/books/${bookId}/video-status`);
+  return result;
+}
+
 /**
  * 重新生成单个章节内容(仅叶节点)
  */
@@ -434,3 +505,64 @@ export async function toggleBookPublish(bookId: string): Promise<{ isPublished:
   );
   return result;
 }
+
+// ============ 阶段状态辅助函数(与后端 stage-manager.ts 同步) ============
+
+/**
+ * 获取书籍阶段的用户可读描述
+ */
+export function getBookStageDescription(genStage?: string): string {
+  if (!genStage) return '未知';
+  switch (genStage) {
+    case 'draft': return '草稿';
+    case 'outlining': return '正在生成大纲...';
+    case 'outline_completed': return '大纲已完成';
+    case 'content_generating': return '正在生成内容...';
+    case 'content_completed': return '内容已完成,等待生成音频';
+    case 'audio_generating': return '正在生成音频...';
+    case 'audio_completed': return '音频已完成,等待生成视频';
+    case 'video_generating': return '正在生成视频...';
+    case 'video_completed': return '全部完成 ✓';
+    case 'failed': return '生成失败';
+    default: return genStage;
+  }
+}
+
+/**
+ * 获取章节阶段的用户可读描述
+ */
+export function getChapterStageDescription(genStage?: string): string {
+  if (!genStage) return '待生成';
+  switch (genStage) {
+    case 'idle': return '待生成';
+    case 'content_generating': return '生成中';
+    case 'content_completed': return '内容完成';
+    case 'audio_generating': return '生成音频中';
+    case 'audio_completed': return '音频完成';
+    case 'video_generating': return '生成视频中';
+    case 'video_completed': return '已完成';
+    case 'failed': return '失败';
+    default: return genStage;
+  }
+}
+
+/**
+ * 判断书籍是否需要显示"生成音频"按钮
+ */
+export function needsAudioGeneration(genStage?: string): boolean {
+  return genStage === 'content_completed';
+}
+
+/**
+ * 判断书籍是否需要显示"生成视频"按钮
+ */
+export function needsVideoGeneration(genStage?: string): boolean {
+  return genStage === 'audio_completed';
+}
+
+/**
+ * 判断书籍是否全部完成(最终态)
+ */
+export function isBookFullyCompleted(genStage?: string): boolean {
+  return genStage === 'video_completed';
+}

+ 171 - 166
my-uniapp-vue3/src/utils/websocket.ts

@@ -1,206 +1,211 @@
 /**
- * WebSocket服务 - 接收实时通知
- * 支持 uni-app H5 和其他平台
+ * WebSocket 工具 - 支持 H5 和小程序
  */
 
-// #ifdef H5
-// H5 平台使用原生 WebSocket,但需要检查是否存在
-const isH5 = typeof window !== 'undefined' && window.WebSocket;
-// #endif
-// #ifndef H5
-const isH5 = false;
-// #endif
-
-class WebSocketService {
-  private ws: any = null;
-  private reconnectTimer: number | null = null;
-  private messageHandlers: Map<string, Function[]> = new Map();
-  private currentToken: string = '';
-
-  /**
-   * 连接WebSocket
-   */
-  connect(token: string) {
-    this.currentToken = token;
-
-    // #ifdef H5
-    if (isH5) {
-      this.connectH5(token);
-      return;
-    }
-    // #endif
+import { getServerBaseUrl } from './config';
 
-    // 非H5平台使用 uni.connectSocket
-    this.connectUni(token);
-  }
+export type WebSocketEventHandler = (data: any) => void;
 
-  /**
-   * H5平台WebSocket连接
-   */
-  // #ifdef H5
-  private connectH5(token: string) {
-    if (typeof window === 'undefined' || !window.WebSocket) {
-      console.warn('[WebSocket] H5环境不支持WebSocket');
-      return;
-    }
+interface WebSocketMessage {
+  event: string;
+  data: any;
+}
 
-    if (this.ws) {
-      this.ws.close();
+// WebSocket 连接管理器
+class WebSocketManager {
+  private socket: any = null;
+  private listeners: Map<string, Set<WebSocketEventHandler>> = new Map();
+  private subscriptions: Set<string> = new Set(); // 跟踪已订阅的事件,用于重连后重新订阅
+  private isConnected = false;
+  private reconnectAttempts = 0;
+  private maxReconnectAttempts = 3;
+  private reconnectDelay = 3000;
+  private url = '';
+
+  // 连接 WebSocket
+  connect(url?: string): Promise<void> {
+    if (this.socket && this.isConnected) {
+      return Promise.resolve();
     }
 
-    // 注意:生产环境需要使用 wss://
-    const wsUrl = `wss://book.rrbrr.com/ws?token=${token}`;
-
-    try {
-      this.ws = new window.WebSocket(wsUrl);
-
-      this.ws.onopen = () => {
-        console.log('[WebSocket] 连接成功');
-      };
-
-      this.ws.onmessage = (event: any) => {
-        try {
-          const message = JSON.parse(event.data);
-          this.handleMessage(message);
-        } catch (error) {
-          console.error('[WebSocket] 消息解析失败:', error);
-        }
-      };
-
-      this.ws.onerror = (error: any) => {
-        console.error('[WebSocket] 错误:', error);
-      };
-
-      this.ws.onclose = () => {
-        console.log('[WebSocket] 连接关闭,3秒后重连...');
-        this.scheduleReconnect(token);
-      };
-    } catch (error) {
-      console.error('[WebSocket] 连接失败:', error);
-      this.scheduleReconnect(token);
-    }
-  }
-  // #endif
-
-  /**
-   * uni-app WebSocket连接
-   */
-  private connectUni(token: string) {
-    // 关闭现有连接
-    if (this.ws) {
-      uni.closeSocket({
-        success: () => {},
-        fail: () => {}
-      });
-    }
-
-    const wsUrl = `wss://book.rrbrr.com/ws?token=${token}`;
-
-    try {
-      this.ws = uni.connectSocket({
-        url: wsUrl,
-        success: () => {
-          console.log('[WebSocket] 连接成功');
-        },
-        fail: (err) => {
-          console.error('[WebSocket] 连接失败:', err);
-          this.scheduleReconnect(token);
-        }
-      });
+    const baseUrl = getServerBaseUrl().replace('https://', 'wss://').replace('http://', 'ws://');
+    this.url = url || `${baseUrl}/ws`;
+
+    return new Promise((resolve, reject) => {
+      try {
+        this.socket = uni.connectSocket({
+          url: this.url,
+          success: () => {
+            console.log('[WebSocket] 连接成功');
+          },
+          fail: (err) => {
+            console.error('[WebSocket] 连接失败:', err);
+            reject(err);
+          }
+        });
 
-      if (this.ws) {
-        this.ws.onOpen(() => {
-          console.log('[WebSocket] 连接已打开');
+        // 监听连接打开
+        this.socket.onOpen(() => {
+          console.log('[WebSocket] 已打开');
+          this.isConnected = true;
+          this.reconnectAttempts = 0;
+          resolve();
         });
 
-        this.ws.onMessage((event: any) => {
+        // 监听消息
+        this.socket.onMessage((res: { data: string }) => {
           try {
-            const message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
-            this.handleMessage(message);
-          } catch (error) {
-            console.error('[WebSocket] 消息解析失败:', error);
+            const message: WebSocketMessage = JSON.parse(res.data);
+            this.emit(message.event, message.data);
+          } catch (e) {
+            console.error('[WebSocket] 解析消息失败:', e);
           }
         });
 
-        this.ws.onError((error: any) => {
-          console.error('[WebSocket] 错误:', error);
+        // 监听错误
+        this.socket.onError((err: any) => {
+          console.error('[WebSocket] 错误:', err);
+          this.isConnected = false;
         });
 
-        this.ws.onClose(() => {
-          console.log('[WebSocket] 连接关闭,3秒后重连...');
-          this.scheduleReconnect(token);
+        // 监听关闭
+        this.socket.onClose(() => {
+          console.log('[WebSocket] 已关闭');
+          this.isConnected = false;
+          this.handleReconnect();
         });
+      } catch (e) {
+        console.error('[WebSocket] 创建连接失败:', e);
+        reject(e);
       }
-    } catch (error) {
-      console.error('[WebSocket] 连接失败:', error);
-      this.scheduleReconnect(token);
-    }
+    });
   }
 
-  /**
-   * 处理消息
-   */
-  private handleMessage(message: any) {
-    console.log('[WebSocket] 收到消息:', message.type);
-
-    // 书籍生成通知
-    if (message.type === 'book_generation_notification') {
-      const handlers = this.messageHandlers.get('book_generation_notification') || [];
-      handlers.forEach(handler => handler(message.data));
+  // 发送消息
+  send(event: string, data?: any): void {
+    if (!this.socket || !this.isConnected) {
+      console.warn('[WebSocket] 未连接,无法发送消息');
+      return;
     }
 
-    // 其他类型的消息...
+    const message: WebSocketMessage = { event, data };
+    this.socket.send({
+      data: JSON.stringify(message),
+      fail: (err: any) => {
+        console.error('[WebSocket] 发送消息失败:', err);
+      }
+    });
   }
 
-  /**
-   * 重连
-   */
-  private scheduleReconnect(token: string) {
-    if (this.reconnectTimer) {
-      clearTimeout(this.reconnectTimer);
+  // 订阅事件
+  on(event: string, handler: WebSocketEventHandler): () => void {
+    if (!this.listeners.has(event)) {
+      this.listeners.set(event, new Set());
     }
+    this.listeners.get(event)!.add(handler);
+    this.subscriptions.add(event); // 记录已订阅的事件
 
-    this.reconnectTimer = setTimeout(() => {
-      this.connect(token);
-    }, 3000) as unknown as number;
+    // 如果已连接,发送订阅消息到服务器
+    if (this.isConnected) {
+      this.send('_subscribe', { event });
+    }
+
+    // 返回取消订阅的函数
+    return () => {
+      const handlers = this.listeners.get(event);
+      if (handlers) {
+        handlers.delete(handler);
+      }
+    };
   }
 
-  /**
-   * 监听消息
-   */
-  on(eventType: string, handler: Function) {
-    if (!this.messageHandlers.has(eventType)) {
-      this.messageHandlers.set(eventType, []);
+  // 取消订阅事件
+  off(event: string, handler: WebSocketEventHandler): void {
+    const handlers = this.listeners.get(event);
+    if (handlers) {
+      handlers.delete(handler);
+      // 如果没有更多处理器了,从订阅列表中移除
+      if (handlers.size === 0) {
+        this.subscriptions.delete(event);
+      }
     }
-    this.messageHandlers.get(eventType)!.push(handler);
   }
 
-  /**
-   * 断开连接
-   */
-  disconnect() {
-    if (this.reconnectTimer) {
-      clearTimeout(this.reconnectTimer);
-      this.reconnectTimer = null;
+  // 触发事件
+  private emit(event: string, data: any): void {
+    const handlers = this.listeners.get(event);
+    if (handlers) {
+      handlers.forEach(handler => {
+        try {
+          handler(data);
+        } catch (e) {
+          console.error(`[WebSocket] 事件 ${event} 处理失败:`, e);
+        }
+      });
     }
+  }
 
-    // #ifdef H5
-    if (isH5 && this.ws) {
-      this.ws.close();
-      this.ws = null;
+  // 关闭连接
+  close(): void {
+    if (this.socket) {
+      this.reconnectAttempts = this.maxReconnectAttempts; // 阻止重连
+      this.socket.close({
+        success: () => {
+          console.log('[WebSocket] 主动关闭连接');
+        }
+      });
+      this.socket = null;
+      this.isConnected = false;
     }
-    // #endif
+  }
 
-    // #ifndef H5
-    if (!isH5 && this.ws) {
-      uni.closeSocket({
-        success: () => {},
-        fail: () => {}
-      });
-      this.ws = null;
+  // 重连处理
+  private handleReconnect(): void {
+    if (this.reconnectAttempts < this.maxReconnectAttempts) {
+      this.reconnectAttempts++;
+      console.log(`[WebSocket] ${this.reconnectDelay}ms 后尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
+      setTimeout(() => {
+        if (!this.isConnected) {
+          this.connect(this.url).then(() => {
+            // 重连成功后重新订阅之前的事件
+            this.resubscribe();
+          }).catch(() => {
+            // 重连失败会在 onClose 中再次触发
+          });
+        }
+      }, this.reconnectDelay);
     }
-    // #endif
+  }
+
+  // 重新订阅之前注册的事件
+  private resubscribe(): void {
+    console.log(`[WebSocket] 重新订阅 ${this.subscriptions.size} 个事件`);
+    this.subscriptions.forEach(event => {
+      // 重新发送订阅消息到服务器
+      if (this.isConnected) {
+        this.send('_subscribe', { event });
+        console.log(`[WebSocket] 重新订阅事件: ${event}`);
+      }
+    });
+  }
+
+  // 是否已连接
+  isOpen(): boolean {
+    return this.isConnected;
   }
 }
 
-export const wsService = new WebSocketService();
+// 导出单例
+export const wsManager = new WebSocketManager();
+
+// 便捷方法:连接并订阅事件
+export function connectAndSubscribe(
+  events: string[],
+  handlers: Record<string, WebSocketEventHandler>
+): Promise<void> {
+  return wsManager.connect().then(() => {
+    events.forEach(event => {
+      wsManager.on(event, handlers[event]);
+    });
+  });
+}

+ 3 - 0
server/cert/wx/1710495274_20250403_cert.zip

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:339b3f7766d4fd3cd9a8eb3989371a4c19dbf43869b33636dce44ec3bb2d96bb
+size 5784

BIN
server/cert/wx/apiclient_cert.p12


+ 26 - 0
server/cert/wx/apiclient_cert.pem

@@ -0,0 +1,26 @@
+-----BEGIN CERTIFICATE-----
+MIIETzCCAzegAwIBAgIUHP+03q/85glPaT4GCBRAF7gvJxMwDQYJKoZIhvcNAQEL
+BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
+FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
+Q0EwHhcNMjUwNDAzMDI1NDQ1WhcNMzAwNDAyMDI1NDQ1WjCBqDETMBEGA1UEAwwK
+MTcxMDQ5NTI3NDEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMVQwUgYDVQQL
+DEvljZfkuqznu4/mtY7mioDmnK/lvIDlj5HljLrot6/lrrnlv4PnkIblkqjor6Ll
+t6XkvZzlrqTvvIjkuKrkvZPlt6XllYbmiLfvvIkxCzAJBgNVBAYTAkNOMREwDwYD
+VQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJoB
+yTwJovtXuxdHof0aVe+ddglE0EmMa3UJip2KoBevfKhbeoRJpLsSk2KgOOiHOztF
+N+8d8227LTNdEbNpboz+OIrqRM+bGhu5JSEocSUYBwrP/6BeQv7TujOfIz8pqX/G
+RP31m/qbOR0e0nq4q9UGS2tBDcem0sFd0n6Ey7F2ZjJEajzyAwCH2kvnxhVkUT5b
+V4eysIZ7eMn6UXreoL1lyApwRhyOf/d+uDpmNDLJf5w+iU/w7PevDl+/prLSFmW/
+XiAvlaJ65GFIqIRYG357IYfD8TCQdemLUKp5s9MDwJa+YvjdB7LTMBA42lbVgxIH
+tDuvBsB2bk8YG/SNXNsCAwEAAaOBuTCBtjAJBgNVHRMEAjAAMAsGA1UdDwQEAwID
++DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2Y2EuaXRydXMuY29t
+LmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJDMDRCMDZBRDM5NzU0
+OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJCMjdBOUQzM0E4N0FE
+MUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQC2SIz7L0SHhpF68lEY
+LR7bbEeLqGfcyEWB7XLLSTtIiGJcfFHxGUiR8OB/+gJDs/P7aglqJCHNJN2MZ5zM
+7X6Nn8/GZ2ePWOBP3xcBR19njQxQNBmbgrGPabksYnSCpWcCKClkDOaee9AM9rBj
+wxNoRFWYNZ2bLT25t4L6yPKYj97aGGbEBkPeYNrcgBeHlQwAyB4m1NdydGFnHiV9
+7F2SMhqLxSft3+g5uhENGKQtNZkUCtt+7juyy9LEm6OtGvYpql3y0hkMtK5AzVxi
+YXDJAU2GEQ/3axJqFRztY6HNmRhWT5w4DGdJxYHZ3kGSIAH6TMuiXpKzihy9CXpV
+Je0O
+-----END CERTIFICATE-----

+ 28 - 0
server/cert/wx/apiclient_key.pem

@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCaAck8CaL7V7sX
+R6H9GlXvnXYJRNBJjGt1CYqdiqAXr3yoW3qESaS7EpNioDjohzs7RTfvHfNtuy0z
+XRGzaW6M/jiK6kTPmxobuSUhKHElGAcKz/+gXkL+07oznyM/Kal/xkT99Zv6mzkd
+HtJ6uKvVBktrQQ3HptLBXdJ+hMuxdmYyRGo88gMAh9pL58YVZFE+W1eHsrCGe3jJ
++lF63qC9ZcgKcEYcjn/3frg6ZjQyyX+cPolP8Oz3rw5fv6ay0hZlv14gL5WieuRh
+SKiEWBt+eyGHw/EwkHXpi1CqebPTA8CWvmL43Qey0zAQONpW1YMSB7Q7rwbAdm5P
+GBv0jVzbAgMBAAECggEBAJlFfclKrfIHdiPNHuKO+0fmJjtDSShpn+hy/omcFYh9
+FktX/674vAGSkyxs2TZSoBh04x4PaN2kk2+zCJLOM0APIqbeUG1tp6Y/tTwyy6hn
+KVsRdw9bwMFsZrxJfQK3rAGGidNWRU48fbMFoeSRNjJkeVrc+6Hf3ZCkl4FjGHYc
+HIL/as0Qiksbvo8QlSa/qdqq2zAQ1aD13ykdQiP0uK8+7mmv48XRD4B46NGuCUKV
+DZQyPzZJksMCP3KYqCYsOrtjgunWOfmLp7CwPEC/idbH/hPoD2w5lndq0m1FAovN
+Jab7yQm0fg9pR+R+AdJ1JaN/5fCJeLpr2HigBHwOwkECgYEAyawvZNcFWDoTo+v+
+30WZ085rWLELTgrD7wr/ntik5faZqQm3nBk+x0lmdOLviTagpZYaUtxmmeAd5RDc
+As3nYhJxufPbZBgHMrxd55CP0bIPcLNJ/xjxSGQZbbDyJIAmmTmna5A8ixkdtdEx
+YIoDiQ19Nr25LQjZSOKPgTNFK6cCgYEAw3530ABdjP6CfhyHpA8a1sJ33pRqosA/
+j2bi6mT4NklboD2cklC5O/SNuTSFOcmU5YruOT5oySrjs3Jm0QUVh50xc4OtgpVz
+eCJ73NzQgTmm/Dx6j4MxRODoESm63gn8pEk1TFqp8FrhkKyXGog/b1LcZ+Mx0YnA
+rdZRCeyx260CgYBYBv5Q58H/dqon/NStJDY2y3zzo6Oplu1bI9Pbw/iGIBOGQyWP
+zz0lw5YjIwMnQ17vqAspa+ak97xUxf0ihDmhByf44MY8dHh/3tSmROVhxHUU3k1I
+Odznmjm3NWscH4u5if7X5odmt7nOKNeCqftNEUQ8ZqezTo95TtsQrA5fNQKBgD2Y
+VlXCF7e55RGpGY+2dqVd5njJ2INpAV3EdBCYOaCZInZtkyyCpKpgVlsZUjSv7+sX
+CBIfONLRYacDj6Oh73OoMpGL1pqDTjt22gHvzP46VQO6Zn9bPaXPGy91L3yQNvro
+WhOxqUOiztlPl9hzP1FrESjma6byuTPMiAIRgsSNAoGBAKnw0pUuwHMdbZzBtS1k
+j1qeq+9rQzXMI83zaeciD1+GlnENm+17QyCnV/ZwajWSqmk6mz688VB9M4wLAtIw
+P8xfytUQ6vvtU4RCJg9s5hBqmKPQcOTyCn8soOY/32l9rudhDC25x9AAEqBMQuEj
+3OVeODeGTvCMN1JoUjUKzuvr
+-----END PRIVATE KEY-----

+ 9 - 0
server/cert/wx/pub_key.pem

@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmZ9zqIsyL46h+e1k3M1e
+sVU2pD4bVCfisms3p2eUzlCAr5Ye74sjZN29Z32zpBESWQr31ROmAhaMC0lH3yx4
+OfOw6pxLQyndiNRCGC3yzFJ9iu1PxtRDFFM0pfL6AiNU5QZlDuHcRgZzTg4Z+RDq
+nrGbOKa+OOHeiHc9wAA3XSpkmVVzMdmn5rlvTcA5aHi951BCgfkaYzTfefaanqAq
+zeb0Zi5SAN/c5y8byYek69zZ8yE5aRapGwYSxQkA5T10YNCcRCQpGG/zgvUFB/va
+gURtYKDSzzW3uMYSRv816vsRd+5cM/IhfYyQ0EkRrhlE0Sazr/QKjUhjKuLe61dj
+CQIDAQAB
+-----END PUBLIC KEY-----

+ 15 - 0
server/check-fields.ts

@@ -0,0 +1,15 @@
+import { PrismaClient } from '@prisma/client';
+const prisma = new PrismaClient();
+async function main() {
+  try {
+    const ch = await prisma.bookChapter.findUnique({ where: { id: 3502 } });
+    console.log('ch3502 status:', ch?.status);
+    console.log('ch3502 genStage:', ch?.genStage);
+    const b = await prisma.book.findUnique({ where: { id: 139 } });
+    console.log('b139 status:', b?.status);
+    console.log('b139 genStage:', b?.genStage);
+  } catch(e) {
+    console.error('Error:', e.message);
+  }
+}
+main().finally(() => process.exit(0));

+ 52 - 19
server/package-lock.json

@@ -3697,9 +3697,9 @@
       }
     },
     "node_modules/defu": {
-      "version": "6.1.6",
-      "resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.6.tgz",
-      "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==",
+      "version": "6.1.7",
+      "resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz",
+      "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
       "license": "MIT"
     },
     "node_modules/delayed-stream": {
@@ -4649,9 +4649,9 @@
       "license": "MIT"
     },
     "node_modules/jiti": {
-      "version": "2.6.1",
-      "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz",
-      "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+      "version": "2.7.0",
+      "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
+      "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
       "license": "MIT",
       "bin": {
         "jiti": "lib/jiti-cli.mjs"
@@ -5866,14 +5866,14 @@
       }
     },
     "node_modules/nypm": {
-      "version": "0.6.5",
-      "resolved": "https://registry.npmmirror.com/nypm/-/nypm-0.6.5.tgz",
-      "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==",
+      "version": "0.6.6",
+      "resolved": "https://registry.npmmirror.com/nypm/-/nypm-0.6.6.tgz",
+      "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
       "license": "MIT",
       "dependencies": {
-        "citty": "^0.2.0",
+        "citty": "^0.2.2",
         "pathe": "^2.0.3",
-        "tinyexec": "^1.0.2"
+        "tinyexec": "^1.1.1"
       },
       "bin": {
         "nypm": "dist/cli.mjs"
@@ -6135,13 +6135,13 @@
       }
     },
     "node_modules/pkg-types": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.0.tgz",
-      "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz",
+      "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
       "license": "MIT",
       "dependencies": {
-        "confbox": "^0.2.2",
-        "exsolve": "^1.0.7",
+        "confbox": "^0.2.4",
+        "exsolve": "^1.0.8",
         "pathe": "^2.0.3"
       }
     },
@@ -6311,6 +6311,31 @@
         "destr": "^2.0.3"
       }
     },
+    "node_modules/react": {
+      "version": "19.2.6",
+      "resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz",
+      "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
+      "license": "MIT",
+      "optional": true,
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.6",
+      "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz",
+      "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
+      "license": "MIT",
+      "optional": true,
+      "peer": true,
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.6"
+      }
+    },
     "node_modules/readable-stream": {
       "version": "2.3.8",
       "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -6517,6 +6542,14 @@
         "node": ">=11.0.0"
       }
     },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT",
+      "optional": true,
+      "peer": true
+    },
     "node_modules/sdk-base": {
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/sdk-base/-/sdk-base-2.0.1.tgz",
@@ -6820,9 +6853,9 @@
       "license": "MIT"
     },
     "node_modules/tinyexec": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.0.4.tgz",
-      "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz",
+      "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
       "license": "MIT",
       "engines": {
         "node": ">=18"

+ 127 - 0
server/prisma/migrate-genstage.ts

@@ -0,0 +1,127 @@
+/**
+ * 数据迁移脚本:将旧字段迁移到新的 genStage 字段
+ * Run: npx ts-node prisma/migrate-genstage.ts
+ */
+
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+async function migrate() {
+  console.log('开始数据迁移...');
+
+  // ========== Book 迁移 ==========
+  console.log('\n迁移 Book 表...');
+
+  const books = await prisma.book.findMany();
+  let updatedBookCount = 0;
+
+  for (const book of books) {
+    let newGenStage: string;
+
+    switch (book.status) {
+      case 'draft':
+        newGenStage = 'draft';
+        break;
+      case 'planning':
+        newGenStage = 'outline_completed';
+        break;
+      case 'generating':
+        // 需要检查子章节的实际状态
+        newGenStage = await inferBookGenStageFromChapters(book.id);
+        break;
+      case 'completed':
+        newGenStage = await inferBookGenStageFromChapters(book.id);
+        break;
+      case 'failed':
+      case 'interrupted':
+        newGenStage = 'failed';
+        break;
+      default:
+        newGenStage = 'draft';
+    }
+
+    await prisma.book.update({
+      where: { id: book.id },
+      data: {
+        genStage: newGenStage,
+        failedStage: newGenStage === 'failed' ? book.status : undefined,
+      },
+    });
+    updatedBookCount++;
+    console.log(`  Book ${book.id}: ${book.status} → ${newGenStage}`);
+  }
+
+  console.log(`Book 迁移完成: ${updatedBookCount} 条`);
+
+  // ========== BookChapter 迁移 ==========
+  console.log('\n迁移 BookChapter 表...');
+
+  const chapters = await prisma.bookChapter.findMany();
+  let updatedChapterCount = 0;
+
+  for (const chapter of chapters) {
+    let newGenStage: string;
+
+    if (chapter.videoStatus === 'completed') {
+      newGenStage = 'video_completed';
+    } else if (chapter.videoStatus === 'generating') {
+      newGenStage = 'video_generating';
+    } else if (chapter.audioStatus === 'completed') {
+      newGenStage = 'audio_completed';
+    } else if (chapter.audioStatus === 'generating') {
+      newGenStage = 'audio_generating';
+    } else if (chapter.contentStatus === 'completed') {
+      newGenStage = 'content_completed';
+    } else if (chapter.contentStatus === 'generating') {
+      newGenStage = 'content_generating';
+    } else if (chapter.status === 'completed') {
+      // 旧数据只有 status
+      newGenStage = 'content_completed';
+    } else if (chapter.status === 'failed' || chapter.contentStatus === 'failed' || chapter.audioStatus === 'failed' || chapter.videoStatus === 'failed') {
+      newGenStage = 'failed';
+    } else {
+      newGenStage = 'idle';
+    }
+
+    await prisma.bookChapter.update({
+      where: { id: chapter.id },
+      data: { genStage: newGenStage },
+    });
+    updatedChapterCount++;
+    console.log(`  Chapter ${chapter.id}: status=${chapter.status}, content=${chapter.contentStatus}, audio=${chapter.audioStatus}, video=${chapter.videoStatus} → ${newGenStage}`);
+  }
+
+  console.log(`BookChapter 迁移完成: ${updatedChapterCount} 条`);
+
+  console.log('\n✅ 数据迁移完成!');
+}
+
+/**
+ * 根据子章节状态推断 Book 的 genStage
+ */
+async function inferBookGenStageFromChapters(bookId: number): Promise<string> {
+  const chapters = await prisma.bookChapter.findMany({
+    where: { bookId, level: { gte: 1 }, parentId: 0 },
+  });
+
+  if (chapters.length === 0) return 'draft';
+
+  // 检查是否有任意章节已达到 video_completed
+  const hasVideoCompleted = chapters.some(c => c.genStage === 'video_completed' || c.videoStatus === 'completed');
+  if (hasVideoCompleted) return 'video_completed';
+
+  // 检查是否有章节已达到 audio_completed
+  const hasAudioCompleted = chapters.some(c => c.genStage === 'audio_completed' || c.audioStatus === 'completed');
+  if (hasAudioCompleted) return 'audio_completed';
+
+  // 检查是否有章节已达到 content_completed
+  const hasContentCompleted = chapters.some(c => c.genStage === 'content_completed' || c.contentStatus === 'completed');
+  if (hasContentCompleted) return 'content_generating'; // 部分完成,仍在生成中
+
+  return 'content_generating';
+}
+
+migrate()
+  .catch(console.error)
+  .finally(() => prisma.$disconnect());

+ 285 - 346
server/prisma/schema.prisma

@@ -8,131 +8,116 @@ datasource db {
 }
 
 model User {
-  id              Int       @id @default(autoincrement())
-  phone           String?   @unique
-  openid          String?   @unique
-  nickname        String    @default("用户")
-  avatar          String    @default("")
-  memberLevel     Int       @default(0)
-  memberExpireAt  DateTime?
-  dailyUsage      Int       @default(0)
-  lastUsageDate   String    @default("")
-  createdAt       DateTime  @default(now())
-  updatedAt       DateTime  @updatedAt
-
-  // 音频时长配额(2026-04-12 新增)
-  usedAudioMinutes     Int       @default(0)   // 本月已使用音频分钟数
-  subscriptionResetDate DateTime?              // 配额重置日期
-
-  orders          Order[]
-  playRecords     PlayRecord[]
-  preferences     UserPreference?
-  favorites       Favorite[]
-  comments        Comment[]
-  signRecords     SignRecord[]
-  subscriptions   Subscription[]
-  tokenUsages     TokenUsage[]
-  tokenBalance    TokenBalance?
-  drafts          Draft[]
-  playlists       Playlist[]
+  id                    Int             @id @default(autoincrement())
+  phone                 String?         @unique
+  openid                String?         @unique
+  nickname              String          @default("用户")
+  avatar                String          @default("")
+  memberLevel           Int             @default(0)
+  memberExpireAt        DateTime?
+  dailyUsage            Int             @default(0)
+  lastUsageDate         String          @default("")
+  createdAt             DateTime        @default(now())
+  updatedAt             DateTime        @updatedAt
+  usedAudioMinutes      Int             @default(0)
+  subscriptionResetDate DateTime?
+  comments              Comment[]
+  drafts                Draft[]
+  favorites             Favorite[]
+  orders                Order[]
+  playRecords           PlayRecord[]
+  playlists             Playlist[]
+  signRecords           SignRecord[]
+  subscriptions         Subscription[]
+  tokenBalance          TokenBalance?
+  tokenUsages           TokenUsage[]
+  preferences           UserPreference?
 
   @@index([phone])
   @@index([openid])
 }
 
-// 订单
 model Order {
-  id              Int       @id @default(autoincrement())
-  userId          Int
-  orderNo         String    @unique
-  planId          Int?      // 关联的套餐ID
-  productType     String    // monthly 或 yearly
-  amount          Decimal   @db.Decimal(10, 2)
-  status          String    @default("pending") // pending, paid, failed, refunded
-  paymentMethod   String?   // alipay, wechat, mock
-  paymentId       String?   // 第三方支付单号
-  paidAt          DateTime?
-  createdAt       DateTime  @default(now())
-  updatedAt       DateTime  @updatedAt
-
-  user            User      @relation(fields: [userId], references: [id])
-  plan            SubscriptionPlan? @relation(fields: [planId], references: [id])
-  tokenUsages     TokenUsage[]
+  id            Int               @id @default(autoincrement())
+  userId        Int
+  orderNo       String            @unique
+  planId        Int?
+  productType   String
+  amount        Decimal           @db.Decimal(10, 2)
+  status        String            @default("pending")
+  paymentMethod String?
+  paymentId     String?
+  paidAt        DateTime?
+  createdAt     DateTime          @default(now())
+  updatedAt     DateTime          @updatedAt
+  plan          SubscriptionPlan? @relation(fields: [planId], references: [id])
+  user          User              @relation(fields: [userId], references: [id])
+  tokenUsages   TokenUsage[]
 
   @@index([userId, createdAt])
   @@index([orderNo])
   @@index([status])
+  @@index([planId], map: "Order_planId_fkey")
 }
 
-// 播放记录(播放的是章节的音频)
 model PlayRecord {
-  id        Int       @id @default(autoincrement())
+  id        Int         @id @default(autoincrement())
   userId    Int
-  chapterId Int       // BookChapter.id(章节ID)
-  progress  Float     @default(0)  // 播放进度(秒)
-  duration  Float     @default(0)  // 总时长
-  updatedAt DateTime  @updatedAt
-  createdAt DateTime  @default(now())
-
-  user      User      @relation(fields: [userId], references: [id])
+  chapterId Int
+  progress  Float       @default(0)
+  duration  Float       @default(0)
+  updatedAt DateTime    @updatedAt
+  createdAt DateTime    @default(now())
   chapter   BookChapter @relation(fields: [chapterId], references: [id])
+  user      User        @relation(fields: [userId], references: [id])
 
   @@unique([userId, chapterId])
   @@index([userId])
   @@index([chapterId])
 }
 
-// 用户偏好
 model UserPreference {
-  id                Int      @id @default(autoincrement())
-  userId            Int      @unique
-  playSpeed         Float    @default(1.0)
-  quality           String   @default("standard")  // standard, high
-  theme             String   @default("light")
-  // 新增字段 - 用户偏好设置增强
-  defaultVoiceId    String?  @default("cherry")  // 默认音色ID
-  defaultVolume     Int      @default(50)         // 默认音量 0-100
-  autoPlayNext      Boolean  @default(true)       // 自动播放下一首
-  wifiOnlyDownload  Boolean  @default(false)      // 仅WiFi下载
-  updatedAt         DateTime @updatedAt
-  createdAt         DateTime @default(now())
-
-  user              User     @relation(fields: [userId], references: [id])
+  id               Int      @id @default(autoincrement())
+  userId           Int      @unique
+  playSpeed        Float    @default(1)
+  quality          String   @default("standard")
+  theme            String   @default("light")
+  defaultVoiceId   String?  @default("cherry")
+  defaultVolume    Int      @default(50)
+  autoPlayNext     Boolean  @default(true)
+  wifiOnlyDownload Boolean  @default(false)
+  updatedAt        DateTime @updatedAt
+  createdAt        DateTime @default(now())
+  user             User     @relation(fields: [userId], references: [id])
 }
 
-// 收藏(收藏的是书籍/专辑)
 model Favorite {
   id        Int      @id @default(autoincrement())
   userId    Int
-  bookId    Int      // Book.id(收藏的是整本书籍)
+  bookId    Int
   createdAt DateTime @default(now())
-
-  user      User     @relation(fields: [userId], references: [id])
   book      Book     @relation(fields: [bookId], references: [id], onDelete: Cascade)
+  user      User     @relation(fields: [userId], references: [id])
 
   @@unique([userId, bookId])
   @@index([userId])
   @@index([bookId])
 }
 
-
-// 评论(评论的是章节)
 model Comment {
-  id        Int       @id @default(autoincrement())
+  id        Int         @id @default(autoincrement())
   userId    Int
-  chapterId Int       // BookChapter.id
-  content   String    @db.Text
-  rating    Int       // 1-5星
-  createdAt DateTime  @default(now())
-
-  user      User      @relation(fields: [userId], references: [id])
+  chapterId Int
+  content   String      @db.Text
+  rating    Int
+  createdAt DateTime    @default(now())
   chapter   BookChapter @relation(fields: [chapterId], references: [id])
+  user      User        @relation(fields: [userId], references: [id])
 
   @@index([chapterId])
   @@index([userId])
 }
 
-// 系统通知
 model Notification {
   id        String   @id @default(uuid())
   userId    String
@@ -142,89 +127,64 @@ model Notification {
   createdAt DateTime @default(now())
 }
 
-
-// ============ 书籍/章节(核心内容) ============
-
-// 书籍(专辑)
 model Book {
-  id              Int       @id @default(autoincrement())
-  userId          Int?
-  title           String    // 书名
-  subtitle        String?   // 副标题
-  description     String    @db.Text // 书籍描述/用户输入
-  coverUrl        String?   // 封面图
-  targetAudience  String    @default("通用") // 目标受众
-  style           String    @default("专业严谨") // 写作风格
-  bookScale       String    @default("标准教程") // 书籍规模:800, 2000, 5000, 小册子, 标准教程, 系统教材
-  totalChapters   Int       @default(10) // 总章节数
-  estimatedWords  Int       @default(0) // 预估总字数
-  status          String    @default("draft") // draft, planning, generating, completed, failed, interrupted
-  progress        Int       @default(0) // 生成进度 0-100
-  isPublished     Boolean   @default(false) // 是否已发布
-
-  // 大纲(JSON 存储)
-  outlineJson      String?   @db.LongText // BookOutline JSON
-
-  // 前言/后记
-  foreword        String?   @db.Text
-  afterword       String?   @db.Text
-
-  // 错误信息
-  errorMsg        String?   @db.Text
-
-  createdAt       DateTime  @default(now())
-  updatedAt       DateTime  @updatedAt
-
-  chapters        BookChapter[]
-  videoProjects   VideoProject[] // 关联的视频项目
-  favorites       Favorite[]     // 收藏此书籍的用户
+  id             Int            @id @default(autoincrement())
+  userId         Int?
+  title          String
+  subtitle       String?
+  description    String         @db.Text
+  coverUrl       String?
+  targetAudience String         @default("通用")
+  style          String         @default("专业严谨")
+  bookScale      String         @default("标准教程")
+  totalChapters  Int            @default(10)
+  estimatedWords Int            @default(0)
+  progress       Int            @default(0)
+  isPublished    Boolean        @default(false)
+  outlineJson    String?        @db.LongText
+  foreword       String?        @db.Text
+  afterword      String?        @db.Text
+  errorMsg       String?        @db.Text
+  createdAt      DateTime       @default(now())
+  updatedAt      DateTime       @updatedAt
+  failedStage    String?        @db.VarChar(30)
+  genStage       String         @default("draft")
+  status         String         @default("draft")
+  chapters       BookChapter[]
+  favorites      Favorite[]
+  videoProjects  VideoProject[]
 
   @@index([userId, status])
   @@index([createdAt])
 }
 
-// 书籍章节(内容单元)
-// 一个章节 = 一份内容,有3种形式:content(文本)、audioUrl(音频)、videoUrl(视频)
-// level=1 表示章(顶级),level=2 表示节,level=3 表示小节
-// parentId = 0 表示章(顶级),parentId = 章ID 表示节,parentId = 节ID 表示小节
 model BookChapter {
-  id              Int       @id @default(autoincrement())
-  bookId          Int
-  parentId        Int       @default(0) // 父节点ID(0 = 章这一级)
-  level           Int       @default(1) // 层级:1=章, 2=节, 3=小节
-  number          Int       // 同级排序序号
-  title           String    // 章节标题
-  summary         String?   @db.Text // 章节概述
-  keyPoints       String?   @db.Text // 核心知识点(JSON数组)
-  estimatedWords  Int       @default(1000) // 预估字数
-  content         String?   @db.LongText // 正文内容(原始文本)
-  wordCount       Int       @default(0) // 实际字数
-  
-  // 大纲状态(title, summary, keyPoints)
-  status          String    @default("pending") // pending, completed, failed
-  
-  // 内容生成状态(content, wordCount)
-  contentStatus   String?   @db.VarChar(20) // null=未开始, pending=待生成, generating=生成中, completed=已完成, failed=失败
-  contentError    String?   @db.Text // 内容生成错误信息
-  
-  generatedAt     DateTime?
-
-  // 3种表现形式
-  audioUrl        String?   @db.Text // 音频URL(由 content 生成)
-  audioDuration   Int       @default(0) // 音频时长(秒)
-  videoUrl        String?   @db.Text // 视频URL(由 content/audio 生成)
-  videoDuration   Int?      // 视频时长(秒)
-
-  // 公开状态(2026-04-14 新增)
-  isPublic        Boolean   @default(false) // 是否公开到首页
-
-  book            Book      @relation(fields: [bookId], references: [id], onDelete: Cascade)
-  // parent          BookChapter? @relation("ChapterChildren", fields: [parentId], references: [id], onDelete: Cascade)
-  // children        BookChapter[] @relation("ChapterChildren")
-  videoProjects   VideoProject[]
-  playRecords     PlayRecord[]
-  comments        Comment[]
-  playlistItems   PlaylistItem[]
+  id             Int            @id @default(autoincrement())
+  bookId         Int
+  parentId       Int            @default(0)
+  level          Int            @default(1)
+  number         Int
+  title          String
+  summary        String?        @db.Text
+  keyPoints      String?        @db.Text
+  estimatedWords Int            @default(1000)
+  content        String?        @db.LongText
+  wordCount      Int            @default(0)
+  contentError   String?        @db.Text
+  generatedAt    DateTime?
+  audioUrl       String?        @db.Text
+  audioDuration  Int            @default(0)
+  videoUrl       String?        @db.Text
+  videoDuration  Int?
+  isPublic       Boolean        @default(false)
+  genStage       String         @default("idle")
+  lrcLyrics      String?        @db.Text
+  status         String         @default("pending")
+  book           Book           @relation(fields: [bookId], references: [id], onDelete: Cascade)
+  comments       Comment[]
+  playRecords    PlayRecord[]
+  playlistItems  PlaylistItem[]
+  videoProjects  VideoProject[]
 
   @@unique([bookId, parentId, level, number])
   @@index([bookId])
@@ -232,61 +192,47 @@ model BookChapter {
   @@index([bookId, level])
 }
 
-
-// ============ 视频生成模块 ============
-
-// 视频项目(关联到书籍的某个章节)
 model VideoProject {
-  id          Int       @id @default(autoincrement())
+  id          Int          @id @default(autoincrement())
   userId      Int?
-  title       String    // 项目标题
-  description String?   // 描述
-  coverUrl    String?   // 封面图
-
-  // 素材配置(JSON存储)
-  configJson  String?   @db.LongText // VideoConfig JSON
-
-  // 输出视频
-  outputUrl   String?   // 生成后的视频URL
-  duration    Int?      // 视频时长(秒)
-  fileSize    Int?      // 文件大小(字节)
-
-  // 关联:直接关联到章节
-  bookId      Int?      // 关联的书籍ID(可选)
-  chapterId   Int?      // 关联的章节ID(直接关联章节获取 content/audioUrl)
-
-  status      String    @default("draft") // draft, processing, completed, failed
-  progress    Int       @default(0) // 0-100
-  errorMsg    String?   @db.Text
-
-  createdAt   DateTime  @default(now())
-  updatedAt   DateTime  @updatedAt
-
-  book        Book?     @relation(fields: [bookId], references: [id])
+  title       String
+  description String?
+  coverUrl    String?
+  configJson  String?      @db.LongText
+  outputUrl   String?
+  duration    Int?
+  fileSize    Int?
+  bookId      Int?
+  chapterId   Int?
+  status      String       @default("draft")
+  progress    Int          @default(0)
+  errorMsg    String?      @db.Text
+  createdAt   DateTime     @default(now())
+  updatedAt   DateTime     @updatedAt
+  book        Book?        @relation(fields: [bookId], references: [id])
   chapter     BookChapter? @relation(fields: [chapterId], references: [id])
 
   @@index([userId, status])
   @@index([createdAt])
+  @@index([bookId], map: "VideoProject_bookId_fkey")
+  @@index([chapterId], map: "VideoProject_chapterId_fkey")
 }
 
-// ============ 搜索历史 ============
-
 model SearchHistory {
   id        Int      @id @default(autoincrement())
-  userId    Int      // 用户ID,0表示未登录用户
-  keyword   String   // 搜索关键词
+  userId    Int
+  keyword   String
   createdAt DateTime @default(now())
 
   @@index([userId, createdAt])
   @@index([userId])
 }
 
-// 热门搜索词
 model HotSearch {
   id        Int      @id @default(autoincrement())
-  keyword   String   // 搜索关键词
-  count     Int      @default(0) // 搜索次数
-  sort      Int      @default(0) // 排序,数字越大越靠前
+  keyword   String
+  count     Int      @default(0)
+  sort      Int      @default(0)
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt
 
@@ -294,238 +240,231 @@ model HotSearch {
   @@index([count])
 }
 
-// 签到记录
 model SignRecord {
   id        Int      @id @default(autoincrement())
   userId    Int
   createdAt DateTime @default(now())
-
   user      User     @relation(fields: [userId], references: [id])
 
   @@unique([userId, createdAt])
   @@index([userId, createdAt])
 }
 
-// ============ 订阅套餐系统 ============
-
-// 套餐计划
 model SubscriptionPlan {
-  id              Int       @id @default(autoincrement())
-  name            String    // 套餐名称
-  level           Int       @default(0) // 套餐等级:0免费 1基础 2专业 3旗舰
-  priceMonthly    Decimal   @db.Decimal(10, 2) @default(0) // 月付价格
-  priceYearly     Decimal   @db.Decimal(10, 2) @default(0) // 年付价格
-  description     String?   @db.Text // 套餐描述
-  features        String?   @db.Text // 功能列表(JSON数组)
-  isRecommended   Boolean   @default(false) // 是否推荐
-  isActive        Boolean   @default(true) // 是否上架
-  sortOrder       Int       @default(0) // 排序
-  
-  // 限制配置
-  dailyGenerations Int      @default(3) // 每日生成次数,-1表示无限制
-  perGenerationLimit Int     @default(2000) // 单次生成限制字数
-  monthlyTokens    Int      @default(10000) // 每月token配额,-1表示无限制
-  monthlyMinutes   Int      @default(30)   // 每月音频时长配额(分钟)
-  yearlyTokens     Int?      // 每年token配额(旗舰版用)
-  voiceOptions     Int      @default(5) // 可用音色数,-1表示全部
-  audioQuality     String    @default("standard") // standard, high, lossless
-  
-  // 高级功能
-  apiAccess        Boolean   @default(false) // API访问权限
-  batchProcessing  Boolean   @default(false) // 批量处理权限
-  teamManagement   Boolean   @default(false) // 团队管理权限
-  overageEnabled   Boolean   @default(false) // 是否允许超出配额
-  overagePrice     Decimal   @db.Decimal(10, 2) @default(0) // 超出配额价格(元/分钟)
-  
-  createdAt        DateTime  @default(now())
-  updatedAt        DateTime  @updatedAt
-
-  subscriptions    Subscription[]
-  orders           Order[]
+  id                 Int            @id @default(autoincrement())
+  name               String
+  level              Int            @default(0)
+  priceMonthly       Decimal        @default(0.00) @db.Decimal(10, 2)
+  priceYearly        Decimal        @default(0.00) @db.Decimal(10, 2)
+  description        String?        @db.Text
+  features           String?        @db.Text
+  isRecommended      Boolean        @default(false)
+  isActive           Boolean        @default(true)
+  sortOrder          Int            @default(0)
+  dailyGenerations   Int            @default(3)
+  perGenerationLimit Int            @default(2000)
+  monthlyTokens      Int            @default(10000)
+  monthlyMinutes     Int            @default(30)
+  yearlyTokens       Int?
+  voiceOptions       Int            @default(5)
+  audioQuality       String         @default("standard")
+  apiAccess          Boolean        @default(false)
+  batchProcessing    Boolean        @default(false)
+  teamManagement     Boolean        @default(false)
+  overageEnabled     Boolean        @default(false)
+  overagePrice       Decimal        @default(0.00) @db.Decimal(10, 2)
+  createdAt          DateTime       @default(now())
+  updatedAt          DateTime       @updatedAt
+  orders             Order[]
+  subscriptions      Subscription[]
 
   @@index([level])
   @@index([isActive, sortOrder])
 }
 
-// 用户订阅记录
 model Subscription {
-  id              Int       @id @default(autoincrement())
-  userId          Int
-  planId          Int
-  startDate       DateTime
-  endDate         DateTime
-  status          String    @default("active") // active, expired, cancelled
-  autoRenew       Boolean   @default(false) // 自动续费
-  createdAt       DateTime  @default(now())
-  updatedAt       DateTime  @updatedAt
-
-  user            User      @relation(fields: [userId], references: [id])
-  plan            SubscriptionPlan @relation(fields: [planId], references: [id])
+  id        Int              @id @default(autoincrement())
+  userId    Int
+  planId    Int
+  startDate DateTime
+  endDate   DateTime
+  status    String           @default("active")
+  autoRenew Boolean          @default(false)
+  createdAt DateTime         @default(now())
+  updatedAt DateTime         @updatedAt
+  plan      SubscriptionPlan @relation(fields: [planId], references: [id])
+  user      User             @relation(fields: [userId], references: [id])
 
   @@index([userId, status])
   @@index([userId, endDate])
+  @@index([planId], map: "Subscription_planId_fkey")
 }
 
-// Token使用记录
 model TokenUsage {
-  id              Int       @id @default(autoincrement())
-  userId          Int
-  type            String    // text_to_speech, api_call, batch_process
-  amount          Int       @default(0) // 消耗token数量
-  contentLength   Int       @default(0) // 内容长度(字数)
-  orderId         Int?      // 关联的订单ID
-  description     String?   @db.Text // 消耗描述
-  createdAt       DateTime  @default(now())
-
-  user            User      @relation(fields: [userId], references: [id])
-  order           Order?    @relation(fields: [orderId], references: [id])
+  id            Int      @id @default(autoincrement())
+  userId        Int
+  type          String
+  amount        Int      @default(0)
+  contentLength Int      @default(0)
+  orderId       Int?
+  description   String?  @db.Text
+  createdAt     DateTime @default(now())
+  order         Order?   @relation(fields: [orderId], references: [id])
+  user          User     @relation(fields: [userId], references: [id])
 
   @@index([userId, createdAt])
   @@index([userId, type])
+  @@index([orderId], map: "TokenUsage_orderId_fkey")
 }
 
-// Token余额
 model TokenBalance {
-  id              Int       @id @default(autoincrement())
-  userId          Int       @unique
-  totalTokens     Int       @default(0) // 总token配额
-  usedTokens      Int       @default(0) // 已使用token
-  resetDate       DateTime? // 重置日期(月/年)
-  createdAt       DateTime  @default(now())
-  updatedAt       DateTime  @updatedAt
-
-  user            User      @relation(fields: [userId], references: [id])
+  id          Int       @id @default(autoincrement())
+  userId      Int       @unique
+  totalTokens Int       @default(0)
+  usedTokens  Int       @default(0)
+  resetDate   DateTime?
+  createdAt   DateTime  @default(now())
+  updatedAt   DateTime  @updatedAt
+  user        User      @relation(fields: [userId], references: [id])
 
   @@index([userId])
 }
 
-// 视频素材库(保留作为独立素材)
 model VideoMaterial {
-  id          Int       @id @default(autoincrement())
-  userId      Int?      // null 表示公共素材
-  type        String    // image, audio, template
-  name        String    // 素材名称
-  url         String    @db.Text // 素材URL
-  thumbnail   String?   // 缩略图URL
-  tags        String?   @db.Text // 标签(JSON数组)
-  category    String?   // 分类:nature, abstract, business, music等
-  duration    Int?      // 音频时长(秒)
-  size        Int?      // 文件大小(字节)
-  width       Int?      // 图片宽度
-  height      Int?      // 图片高度
-  createdAt   DateTime  @default(now())
-  updatedAt   DateTime  @updatedAt
+  id        Int      @id @default(autoincrement())
+  userId    Int?
+  type      String
+  name      String
+  url       String   @db.Text
+  thumbnail String?
+  tags      String?  @db.Text
+  category  String?
+  duration  Int?
+  size      Int?
+  width     Int?
+  height    Int?
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
 
   @@index([userId, type])
   @@index([type, category])
 }
 
-// 音频生成记录
 model AudioRecord {
-  id            Int       @id @default(autoincrement())
+  id            Int      @id @default(autoincrement())
   userId        Int?
-  audioId       String    @unique  // uuid,对应磁盘上的文件夹名
-  title         String    @default("未命名音频")
-  text          String?   @db.LongText
-  wordCount     Int       @default(0)
-  voiceId       String    @default("cherry")
-  voiceParams   String?   // JSON: {speed, pitch, volume}
-  audioUrl      String?   @db.Text
-  audioDuration Int       @default(0)  // 秒
-  audioSize     Int       @default(0)   // 字节
-  status        String    @default("processing") // processing, completed, failed
-  errorMsg      String?   @db.Text
-  createdAt     DateTime  @default(now())
-  updatedAt     DateTime  @updatedAt
+  audioId       String   @unique
+  title         String   @default("未命名音频")
+  text          String?  @db.LongText
+  wordCount     Int      @default(0)
+  voiceId       String   @default("cherry")
+  voiceParams   String?
+  audioUrl      String?  @db.Text
+  audioDuration Int      @default(0)
+  audioSize     Int      @default(0)
+  status        String   @default("processing")
+  errorMsg      String?  @db.Text
+  createdAt     DateTime @default(now())
+  updatedAt     DateTime @updatedAt
+  bookId        Int?
 
   @@index([userId])
   @@index([audioId])
+  @@index([bookId])
 }
 
-// ============ 社交平台发布模块 ============
-
-// 平台账号(存储各平台的登录凭证)
 model PlatformAccount {
-  id          Int       @id @default(autoincrement())
-  userId      Int
-  platform    String    // douyin, kuaishou, bilibili
-  nickname    String    @default("")
-  avatar      String    @default("")
-  cookies     String    @db.Text   // 登录 Cookie
-  headers     String?   @db.Text   // 关键请求头(JSON)
-  isValid     Boolean   @default(true)
-  expireTime  DateTime? // 凭证过期时间
-  createdAt   DateTime  @default(now())
-  updatedAt   DateTime  @updatedAt
+  id         Int       @id @default(autoincrement())
+  userId     Int
+  platform   String
+  nickname   String    @default("")
+  avatar     String    @default("")
+  cookies    String    @db.Text
+  headers    String?   @db.Text
+  isValid    Boolean   @default(true)
+  expireTime DateTime?
+  createdAt  DateTime  @default(now())
+  updatedAt  DateTime  @updatedAt
 
   @@unique([userId, platform])
   @@index([userId])
   @@index([platform])
 }
 
-// 发布任务记录
 model PublishTask {
-  id              Int       @id @default(autoincrement())
-  userId          Int
-  videoProjectId  Int
-  platform        String    // douyin, kuaishou, bilibili
-  title           String    @db.Text
-  description     String?   @db.Text
-  tags            String?   @db.Text  // JSON 数组
-  coverUrl        String?   @db.Text
-  videoUrl        String?   @db.Text  // 本地视频路径
-  status          String    @default("pending") // pending, uploading, success, failed
-  errorMsg        String?   @db.Text
-  publishedUrl    String?   @db.Text  // 发布后的作品链接
-  createdAt       DateTime  @default(now())
-  updatedAt       DateTime  @updatedAt
+  id             Int      @id @default(autoincrement())
+  userId         Int
+  videoProjectId Int
+  platform       String
+  title          String   @db.Text
+  description    String?  @db.Text
+  tags           String?  @db.Text
+  coverUrl       String?  @db.Text
+  videoUrl       String?  @db.Text
+  status         String   @default("pending")
+  errorMsg       String?  @db.Text
+  publishedUrl   String?  @db.Text
+  createdAt      DateTime @default(now())
+  updatedAt      DateTime @updatedAt
 
   @@index([userId, platform])
   @@index([status])
 }
 
-// ============ 草稿模块 ============
 model Draft {
   id          Int       @id @default(autoincrement())
   userId      Int
-  type        String    // content, outline, audio
+  type        String
   title       String?   @db.Text
   content     String?   @db.LongText
-  metadata    String?   @db.Text // JSON 存储额外信息
+  metadata    String?   @db.Text
   autoSavedAt DateTime?
   createdAt   DateTime  @default(now())
   updatedAt   DateTime  @updatedAt
-
   user        User      @relation(fields: [userId], references: [id])
 
   @@index([userId, type])
   @@index([userId, updatedAt])
 }
 
-// ============ 播放列表模块 ============
 model Playlist {
-  id          Int       @id @default(autoincrement())
+  id          Int            @id @default(autoincrement())
   userId      Int
-  name        String    // 播放列表名称
-  description String?   @db.Text
-  createdAt   DateTime  @default(now())
-  updatedAt   DateTime  @updatedAt
-
-  user        User      @relation(fields: [userId], references: [id])
+  name        String
+  description String?        @db.Text
+  createdAt   DateTime       @default(now())
+  updatedAt   DateTime       @updatedAt
+  user        User           @relation(fields: [userId], references: [id])
   items       PlaylistItem[]
 
   @@index([userId])
 }
 
 model PlaylistItem {
-  id          Int       @id @default(autoincrement())
-  playlistId  Int
-  chapterId   Int?      // 关联的章节ID
-  audioId     String?   // 关联的音频ID(独立音频)
-  order       Int       @default(0) // 排序
-
-  playlist    Playlist  @relation(fields: [playlistId], references: [id], onDelete: Cascade)
-  chapter     BookChapter? @relation(fields: [chapterId], references: [id])
+  id         Int          @id @default(autoincrement())
+  playlistId Int
+  chapterId  Int?
+  audioId    String?
+  order      Int          @default(0)
+  chapter    BookChapter? @relation(fields: [chapterId], references: [id])
+  playlist   Playlist     @relation(fields: [playlistId], references: [id], onDelete: Cascade)
 
   @@index([playlistId, order])
+  @@index([chapterId], map: "PlaylistItem_chapterId_fkey")
+}
+
+model Feedback {
+  id             String   @id @default(uuid())
+  type           String
+  title          String   @db.Text
+  content        String   @db.Text
+  contact        String   @db.Text
+  screenshotUrls String   @db.Text
+  status         String   @default("submitted")
+  createdAt      DateTime @default(now())
+  updatedAt      DateTime @updatedAt
+
+  @@index([type])
+  @@index([status])
+  @@index([createdAt])
 }

+ 158 - 0
server/prisma/sync-genstage.ts

@@ -0,0 +1,158 @@
+/**
+ * 状态同步脚本:将现有数据从旧字段同步到 genStage
+ * 使用 stage-manager 的安全函数
+ * Run: npx ts-node prisma/sync-genstage.ts
+ */
+
+import { PrismaClient } from '@prisma/client';
+import {
+  BookGenStage,
+  ChapterGenStage,
+  bookStageIndex,
+  chapterStageIndex,
+  safeTransitionChapter,
+  safeTransitionBook,
+  checkAndRegressBook,
+  tryAdvanceBook,
+} from '../src/modules/book-generator/stage-manager';
+
+const prisma = new PrismaClient();
+
+async function syncChapter(chapter: any): Promise<void> {
+  const chapterAny = chapter as any;
+  let targetStage: ChapterGenStage;
+
+  // 根据旧字段计算新的 genStage
+  if (chapterAny.videoStatus === 'completed') {
+    targetStage = 'video_completed';
+  } else if (chapterAny.videoStatus === 'generating') {
+    targetStage = 'video_generating';
+  } else if (chapterAny.audioStatus === 'completed') {
+    targetStage = 'audio_completed';
+  } else if (chapterAny.audioStatus === 'generating') {
+    targetStage = 'audio_generating';
+  } else if (chapterAny.contentStatus === 'completed') {
+    targetStage = 'content_completed';
+  } else if (chapterAny.contentStatus === 'generating') {
+    targetStage = 'content_generating';
+  } else if (chapterAny.status === 'completed') {
+    // 只有章级别的 status
+    targetStage = 'content_completed';
+  } else if (chapterAny.status === 'failed' || chapterAny.contentStatus === 'failed' || chapterAny.audioStatus === 'failed' || chapterAny.videoStatus === 'failed') {
+    targetStage = 'failed';
+  } else {
+    targetStage = 'idle';
+  }
+
+  const currentStage = chapterAny.genStage as ChapterGenStage;
+
+  // 如果相同则跳过
+  if (currentStage === targetStage) {
+    console.log(`  Chapter ${chapter.id}: 无需更新 (${currentStage})`);
+    return;
+  }
+
+  // 比较阶段索引决定用哪个函数
+  const currentIdx = chapterStageIndex(currentStage);
+  const targetIdx = chapterStageIndex(targetStage);
+
+  try {
+    if (targetIdx > currentIdx) {
+      // 前进用 safeTransitionChapter(内部已处理乐观锁)
+      await safeTransitionChapter(chapter.id, currentStage, targetStage);
+    } else {
+      // 回退/同级也用 safeTransitionChapter(重新生成场景)
+      await safeTransitionChapter(chapter.id, currentStage, targetStage);
+    }
+    console.log(`  Chapter ${chapter.id}: ${currentStage} → ${targetStage}`);
+  } catch (error: any) {
+    // 并发冲突:跳过此记录
+    console.log(`  Chapter ${chapter.id}: 跳过 (${error.message})`);
+  }
+}
+
+async function syncBook(book: any): Promise<void> {
+  let targetStage: BookGenStage;
+
+  switch (book.status) {
+    case 'draft':
+      targetStage = 'draft';
+      break;
+    case 'planning':
+      targetStage = 'outline_completed';
+      break;
+    case 'generating':
+      targetStage = inferBookGenStage(book);
+      break;
+    case 'completed':
+      targetStage = inferBookGenStage(book);
+      break;
+    case 'failed':
+    case 'interrupted':
+      targetStage = 'failed';
+      break;
+    default:
+      targetStage = 'draft';
+  }
+
+  const currentStage = (book.genStage || 'draft') as BookGenStage;
+
+  if (currentStage === targetStage) {
+    console.log(`  Book ${book.id}: 无需更新 (${currentStage})`);
+    return;
+  }
+
+  try {
+    await safeTransitionBook(book.id, currentStage, targetStage);
+    console.log(`  Book ${book.id}: ${currentStage} → ${targetStage}`);
+  } catch (error: any) {
+    console.log(`  Book ${book.id}: 跳过 (${error.message})`);
+  }
+}
+
+function inferBookGenStage(book: any): BookGenStage {
+  // 根据 chapters 状态推断
+  const chapters = book.chapters || [];
+  const hasVideo = chapters.some((c: any) => c.genStage === 'video_completed' || c.videoStatus === 'completed');
+  if (hasVideo) return 'video_completed';
+
+  const hasAudio = chapters.some((c: any) => c.genStage === 'audio_completed' || c.audioStatus === 'completed');
+  if (hasAudio) return 'audio_completed';
+
+  const hasContent = chapters.some((c: any) => c.genStage === 'content_completed' || c.contentStatus === 'completed');
+  if (hasContent) return 'content_generating';
+
+  return 'content_generating';
+}
+
+async function main() {
+  console.log('开始状态同步...\n');
+
+  // 同步 BookChapter
+  console.log('=== 同步 BookChapter ===');
+  const chapters = await prisma.bookChapter.findMany();
+  for (const chapter of chapters) {
+    await syncChapter(chapter);
+  }
+  console.log(`BookChapter 同步完成: ${chapters.length} 条\n`);
+
+  // 同步 Book
+  console.log('=== 同步 Book ===');
+  const books = await prisma.book.findMany({ include: { chapters: true } });
+  for (const book of books) {
+    await syncBook(book);
+  }
+  console.log(`Book 同步完成: ${books.length} 条\n`);
+
+  // 执行 Book 联动回退检查
+  console.log('=== 检查 Book 联动回退 ===');
+  for (const book of books) {
+    await checkAndRegressBook(book.id);
+  }
+
+  console.log('✅ 状态同步完成!');
+}
+
+main()
+  .catch(console.error)
+  .finally(() => prisma.$disconnect());

+ 11 - 10
server/src/app.ts

@@ -18,7 +18,6 @@ import { ossService } from './services/oss.service';
 import { storageService } from './services/storage.service';
 import { queueService } from './services/queue.service';
 import { initBookGenerationQueue, resumeInterruptedTasks } from './modules/book-generator/book-queue.processor';
-import { websocketService } from './services/websocket.service';
 import { initSentry, sentryErrorHandler } from './services/sentry.service';
 import { xssProtection, sqlInjectionProtection } from './middleware/security';
 import { performanceMonitor, getMetrics } from './middleware/performance';
@@ -41,6 +40,7 @@ import langGraphRoutes from './modules/book-generator/langgraph-controller';
 import aiGenerateRoutes from './modules/book-generator/ai-generate-controller';
 import albumRoutes from './modules/book-generator/album-controller';
 import albumManagementRoutes from './modules/book-generator/album-management.controller';
+import bookGeneratorRoutes from './modules/book-generator/book-generator.controller';
 import playlistRoutes from './modules/player/playlist.controller';
 import draftsRoutes from './modules/drafts/drafts.controller';
 import videoGeneratorRoutes from './modules/video-generator/video-generator.controller';
@@ -50,7 +50,9 @@ import subscriptionRoutes from './modules/subscription/subscription.controller';
 import paymentRoutes from './modules/payment/payment.controller';
 import historyRoutes from './modules/history/history.controller';
 import historyBatchRoutes from './modules/history/history-batch.controller';
+import feedbackRoutes from './modules/feedback/feedback.controller';
 import { initializePlans } from './modules/subscription/subscription.service';
+import { initWebSocket } from './services/websocket.service.js';
 
 const app = new Koa();
 const router = new Router();
@@ -111,6 +113,7 @@ router.use('/api/audio', audioEditRoutes.routes());
 router.use('/api/book-generator/langgraph', langGraphRoutes.routes());
 router.use('/api/book-generator', aiGenerateRoutes.routes());
 router.use('/api/book-generator', albumRoutes.routes());
+router.use('/api/book-generator', bookGeneratorRoutes.routes());
 router.use('/api/book-generator/album', albumManagementRoutes.routes());
 router.use('/api/playlists', playlistRoutes.routes());
 router.use('/api/drafts', draftsRoutes.routes());
@@ -121,6 +124,7 @@ router.use('/api/sign', signRoutes.routes());
 router.use('/api/subscription', subscriptionRoutes.routes());
 router.use('/api/payment', paymentRoutes.routes());
 router.use('/api/publish', publishRoutes.routes());
+router.use('/api/feedback', feedbackRoutes.routes());
 router.use('/api', logRoutes.routes());
 
 app.use(router.routes()).use(router.allowedMethods());
@@ -148,17 +152,16 @@ async function start() {
     
     // 4. 初始化订阅套餐数据
     await initializePlans();
-    
-    // 5. 初始化 WebSocket
-    websocketService.initialize(server);
-    
+
+    // 5. 初始化 WebSocket 服务
+    initWebSocket(server);
+
     // 6. 启动服务
     server.listen(config.port, () => {
       console.log(`🚀 服务启动成功: http://localhost:${config.port}`);
       console.log(`📁 上传目录: ${config.upload.dir}`);
       console.log(`💾 存储模式: ${storageType === 'oss' ? '阿里云 OSS' : '本地存储'}`);
       console.log(`⚡ 缓存模式: ${redisOk ? 'Redis' : '内存'}`);
-      console.log(`🔌 WebSocket: ws://localhost:${config.port}/ws`);
       console.log(`\n💡 提示: 修改 .env 中的 STORAGE_TYPE 可切换存储模式`);
     });
     
@@ -168,18 +171,16 @@ async function start() {
     // 自动恢复中断的任务
     resumeInterruptedTasks();
     
-    // 7. 优雅关闭
+    // 6. 优雅关闭
     process.on('SIGTERM', async () => {
       console.log('收到 SIGTERM 信号,正在关闭服务...');
-      websocketService.close();
       await queueService.closeAll();
       await redisService.disconnect();
       process.exit(0);
     });
-    
+
     process.on('SIGINT', async () => {
       console.log('收到 SIGINT 信号,正在关闭服务...');
-      websocketService.close();
       await queueService.closeAll();
       await redisService.disconnect();
       process.exit(0);

+ 18 - 0
server/src/config/models.json

@@ -88,6 +88,24 @@
         }
       ]
     },
+    "minimax-backup": {
+      "name": "MiniMax Backup",
+      "baseUrl": "https://api.minimax.chat/v1",
+      "apiKey": "sk-cp-BOVwOolqOEmHycg8MzDX_UgQ_bDEK3snjPUFuwolUCb_TxMxhhlAfYhTT0_tqRvEz5K56xpl-aOoJtj97MuAY4UIA-PCQLVetUCY56i8LFimNuQqrrq5xWA",
+      "apiType": "openai-chat",
+      "models": [
+        {
+          "id": "MiniMax-M2.7-Backup",
+          "name": "MiniMax M2.7 Backup",
+          "input": ["text"],
+          "contextWindow": 128000,
+          "maxTokens": 4096,
+          "temperature": 0.7,
+          "supportsToolCall": true,
+          "enabled": true
+        }
+      ]
+    },
     "volcengine": {
       "name": "火山引擎",
       "baseUrl": "https://ark.cn-beijing.volces.com/api/coding/v3",

+ 67 - 3
server/src/modules/book-generator/album-controller.ts

@@ -27,7 +27,7 @@ router.get('/albums', async (ctx: Context) => {
       title: book.title,
       description: book.description,
       totalChapters: book.totalChapters,
-      status: book.status,
+      genStage: book.genStage,
       progress: book.progress,
       createdAt: book.createdAt,
     }));
@@ -39,6 +39,70 @@ router.get('/albums', async (ctx: Context) => {
   }
 });
 
+/**
+ * GET /api/book-generator/list
+ * 获取书籍列表(带分页和日期过滤)
+ */
+router.get('/list', optionalAuth, async (ctx: Context) => {
+  try {
+    const { startDate, pageSize = '100' } = ctx.query as { startDate?: string; pageSize?: string };
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+
+    const pageSizeNum = parseInt(pageSize) || 100;
+    const where: any = {
+      OR: [
+        { userId: parseInt(userId) },
+        { userId: null }, // 兼容旧数据
+      ],
+    };
+
+    if (startDate) {
+      const start = new Date(startDate);
+      where.createdAt = { gte: start };
+    }
+
+    // 获取总数
+    const total = await prisma.book.count({ where });
+
+    // 获取列表
+    const books = await prisma.book.findMany({
+      where,
+      include: { chapters: true },
+      orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
+      take: pageSizeNum,
+    });
+
+    // 转换为前端需要的格式
+    const list = books.map(book => ({
+      id: book.id,
+      title: book.title,
+      description: book.description,
+      totalChapters: book.totalChapters,
+      genStage: book.genStage,
+      progress: book.progress,
+      createdAt: book.createdAt,
+      updatedAt: book.updatedAt,
+      chapters: book.chapters.map(ch => ({
+        id: ch.id,
+        title: ch.title,
+        genStage: ch.genStage,
+        audioUrl: ch.audioUrl,
+        videoUrl: ch.videoUrl,
+      })),
+    }));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { list, total },
+    };
+  } catch (error) {
+    console.error('查询书籍列表失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '查询失败' };
+  }
+});
+
 /**
  * POST /api/book-generator/albums
  * 创建专辑
@@ -122,7 +186,7 @@ router.get('/albums/:id/chapters', optionalAuth, async (ctx: Context) => {
           number: c.number,
           title: c.title,
           wordCount: c.wordCount || 0,
-          status: c.status,
+          genStage: c.genStage || null,
           audioUrl: c.audioUrl || null,
           audioDuration: c.audioDuration || 0,
           videoUrl: c.videoUrl || null,
@@ -348,7 +412,7 @@ router.get('/albums/chapters/:id', optionalAuth, async (ctx: Context) => {
         number: chapter.number,
         title: chapter.title,
         content: chapter.content || '',
-        status: chapter.status,
+        genStage: chapter.genStage || null,
         level: chapter.level,
         audioUrl: chapter.audioUrl || null,
         audioDuration: chapter.audioDuration || 0,

+ 199 - 0
server/src/modules/book-generator/book-generator.controller.ts

@@ -0,0 +1,199 @@
+/**
+ * 书籍生成 - API 路由
+ * 包含一键完整生成 API
+ */
+
+import Router from '@koa/router';
+import { Context } from 'koa';
+import { bookStore } from './book-generator.store';
+import {
+  createBatchGenerationTask,
+  setCancellationFlag,
+  GenerationStep
+} from './book-generator.service';
+
+// 任务存储(用于取消操作)
+const runningTasks = new Map<string, { taskId: string; bookId: string }>();
+
+const router = new Router();
+
+/**
+ * POST /api/book-generator/books/:id/batch-generate
+ * 一键完整生成书籍(内容、音频、合并、生成视频、合并视频)
+ */
+router.post('/books/:id/batch-generate', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const body = ctx.request.body as {
+      steps?: GenerationStep[];
+    };
+
+    // 验证书籍存在
+    const book = await bookStore.getById(bookId);
+    if (!book) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
+    // 默认执行全部步骤
+    const steps: GenerationStep[] = body.steps || [
+      'generate_content',
+      'generate_audio',
+      'merge_audio',
+      'generate_video',
+      'merge_video'
+    ];
+
+    // 验证步骤
+    const validSteps: GenerationStep[] = [
+      'generate_content',
+      'generate_audio',
+      'merge_audio',
+      'generate_video',
+      'merge_video'
+    ];
+    for (const step of steps) {
+      if (!validSteps.includes(step)) {
+        ctx.status = 400;
+        ctx.body = { code: 1, message: `无效的步骤: ${step}` };
+        return;
+      }
+    }
+
+    // 检查是否有正在运行的批量生成任务
+    const existingTask = runningTasks.get(bookId);
+    if (existingTask) {
+      ctx.status = 400;
+      ctx.body = {
+        code: 1,
+        message: '书籍已有批量生成任务在运行,请先取消后再试',
+        data: { taskId: existingTask.taskId }
+      };
+      return;
+    }
+
+    // 创建批量生成任务
+    const { taskId, orchestrator } = await createBatchGenerationTask(bookId, steps);
+
+    // 存储任务信息
+    runningTasks.set(bookId, { taskId, bookId });
+
+    // 后台执行任务
+    (async () => {
+      try {
+        const result = await orchestrator.execute();
+        console.log(`[BatchGen][${taskId}] 任务完成:`, result);
+
+        // 发送完成消息
+        const { pushBatchGenerationProgress } = await import('../../services/websocket.service.js');
+        pushBatchGenerationProgress(taskId, 'completed', 100);
+
+      } catch (error: any) {
+        console.error(`[BatchGen][${taskId}] 任务异常:`, error);
+      } finally {
+        // 清理任务
+        runningTasks.delete(bookId);
+      }
+    })();
+
+    ctx.body = {
+      code: 0,
+      message: '批量生成任务已启动',
+      data: {
+        taskId,
+        bookId,
+        status: 'started',
+        steps
+      }
+    };
+
+  } catch (error) {
+    console.error('[BatchGenerate] 启动失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '启动失败'
+    };
+  }
+});
+
+/**
+ * POST /api/book-generator/books/:id/batch-generate/cancel
+ * 取消批量生成任务
+ */
+router.post('/books/:id/batch-generate/cancel', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+
+    const task = runningTasks.get(bookId);
+    if (!task) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '没有正在运行的批量生成任务' };
+      return;
+    }
+
+    // 设置取消标志
+    setCancellationFlag(task.taskId);
+
+    ctx.body = {
+      code: 0,
+      message: '取消指令已发送',
+      data: {
+        taskId: task.taskId,
+        bookId
+      }
+    };
+
+  } catch (error) {
+    console.error('[BatchGenerate] 取消失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '取消失败'
+    };
+  }
+});
+
+/**
+ * GET /api/book-generator/books/:id/batch-generate/status
+ * 获取批量生成任务状态
+ */
+router.get('/books/:id/batch-generate/status', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+
+    const task = runningTasks.get(bookId);
+    if (!task) {
+      ctx.body = {
+        code: 0,
+        message: 'success',
+        data: {
+          isRunning: false,
+          bookId
+        }
+      };
+      return;
+    }
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        isRunning: true,
+        taskId: task.taskId,
+        bookId: task.bookId
+      }
+    };
+
+  } catch (error) {
+    console.error('[BatchGenerate] 查询状态失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '查询失败'
+    };
+  }
+});
+
+export default router;

+ 549 - 0
server/src/modules/book-generator/book-generator.service.ts

@@ -0,0 +1,549 @@
+/**
+ * 书籍生成编排服务
+ * 负责按顺序执行生成步骤并推送进度
+ */
+
+import { bookStore } from './book-generator.store';
+import { pushBatchGenerationProgress } from '../../services/websocket.service.js';
+import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service';
+import { mergeChapterAudios } from '../player/player.service';
+import { PrismaClient } from '@prisma/client';
+import { advanceChapter, regenerateChapter } from './stage-manager';
+
+const prisma = new PrismaClient();
+
+// 步骤类型
+export type GenerationStep = 'generate_content' | 'generate_audio' | 'merge_audio' | 'generate_video' | 'merge_video';
+
+// 取消标志
+const cancellationFlags = new Map<string, boolean>();
+
+/**
+ * 设置取消标志
+ */
+export function setCancellationFlag(taskId: string): void {
+  cancellationFlags.set(taskId, true);
+}
+
+/**
+ * 清除取消标志
+ */
+export function clearCancellationFlag(taskId: string): void {
+  cancellationFlags.delete(taskId);
+}
+
+/**
+ * 检查是否已取消
+ */
+export function isTaskCancelled(taskId: string): boolean {
+  return cancellationFlags.get(taskId) === true;
+}
+
+/**
+ * 批量生成编排器
+ */
+export class BatchGenerationOrchestrator {
+  private taskId: string;
+  private bookId: string;
+  private steps: GenerationStep[];
+
+  constructor(taskId: string, bookId: string, steps: GenerationStep[]) {
+    this.taskId = taskId;
+    this.bookId = bookId;
+    this.steps = steps;
+  }
+
+  /**
+   * 推送进度
+   */
+  private pushProgress(step: string, progress: number, message: string): void {
+    pushBatchGenerationProgress(this.taskId, step, progress);
+    console.log(`[BatchGen][${this.taskId}] ${step}: ${progress}% - ${message}`);
+  }
+
+  /**
+   * 检查是否已取消
+   */
+  private checkCancellation(): void {
+    if (isTaskCancelled(this.taskId)) {
+      console.log(`[BatchGen][${this.taskId}] 任务已取消`);
+      throw new Error('TASK_CANCELLED');
+    }
+  }
+
+  /**
+   * 执行所有步骤
+   */
+  async execute(): Promise<{ success: boolean; completedSteps: GenerationStep[]; failedStep?: string; error?: string }> {
+    const completedSteps: GenerationStep[] = [];
+
+    try {
+      // 获取书籍信息
+      const book = await bookStore.getById(this.bookId);
+      if (!book) {
+        return { success: false, completedSteps, failedStep: 'init', error: '书籍不存在' };
+      }
+
+      // 执行每个步骤
+      for (let i = 0; i < this.steps.length; i++) {
+        this.checkCancellation();
+
+        const step = this.steps[i];
+        const stepProgress = Math.round((i / this.steps.length) * 100);
+        this.pushProgress(step, stepProgress, '开始执行');
+
+        try {
+          switch (step) {
+            case 'generate_content':
+              await this.executeGenerateContent();
+              break;
+            case 'generate_audio':
+              await this.executeGenerateAudio();
+              break;
+            case 'merge_audio':
+              await this.executeMergeAudio();
+              break;
+            case 'generate_video':
+              await this.executeGenerateVideo();
+              break;
+            case 'merge_video':
+              await this.executeMergeVideo();
+              break;
+            default:
+              console.warn(`[BatchGen][${this.taskId}] 未知步骤: ${step}`);
+          }
+
+          completedSteps.push(step);
+          this.pushProgress(step, 100, '执行完成');
+
+          // 步骤间检查取消
+          this.checkCancellation();
+
+        } catch (error: any) {
+          if (error.message === 'TASK_CANCELLED') {
+            return { success: false, completedSteps, failedStep: step, error: '用户取消' };
+          }
+          console.error(`[BatchGen][${this.taskId}] 步骤 ${step} 执行失败:`, error);
+          return { success: false, completedSteps, failedStep: step, error: error.message };
+        }
+      }
+
+      // 更新书籍状态
+      await bookStore.update(this.bookId, { progress: 100 });
+
+      return { success: true, completedSteps };
+
+    } catch (error: any) {
+      console.error(`[BatchGen][${this.taskId}] 执行失败:`, error);
+      return { success: false, completedSteps, error: error.message };
+    } finally {
+      // 清理取消标志
+      clearCancellationFlag(this.taskId);
+    }
+  }
+
+  /**
+   * 执行内容生成步骤
+   * 使用 LangGraph 生成书籍内容
+   */
+  private async executeGenerateContent(): Promise<void> {
+    this.pushProgress('generate_content', 10, '检查书籍状态');
+
+    const book = await bookStore.getById(this.bookId);
+    if (!book) throw new Error('书籍不存在');
+
+    // 如果书籍已有内容,则跳过
+    const chapters = await bookStore.getChapterTree(this.bookId);
+    const completedContent = chapters.filter((c: any) => c.genStage === 'content_completed');
+
+    if (completedContent.length > 0) {
+      this.pushProgress('generate_content', 50, `已有 ${completedContent.length} 个章节完成内容生成,跳过`);
+      return;
+    }
+
+    this.pushProgress('generate_content', 20, '开始生成内容');
+
+    // 调用 LangGraph 生成内容
+    const { langGraphGenerator } = await import('./index.js');
+
+    // 异步执行生成,不阻塞
+    langGraphGenerator.generate(this.bookId, book.description, book.bookScale || '标准教程')
+      .then(() => {
+        console.log(`[BatchGen][${this.taskId}] 内容生成完成`);
+      })
+      .catch((err) => {
+        console.error(`[BatchGen][${this.taskId}] 内容生成失败:`, err);
+      });
+
+    // 等待内容生成完成(轮询检查)
+    let maxWaitTime = 3600 * 1000; // 最多等待60分钟
+    let waited = 0;
+    const checkInterval = 5000; // 每5秒检查一次
+
+    while (waited < maxWaitTime) {
+      this.checkCancellation();
+
+      const currentChapters = await bookStore.getChapterTree(this.bookId);
+      const leafNodes = currentChapters.filter((c: any) => c.level === Math.max(...currentChapters.map((ch: any) => ch.level || 0)));
+      const completedCount = leafNodes.filter((c: any) => c.genStage === 'content_completed').length;
+      const totalCount = leafNodes.length;
+
+      if (totalCount > 0) {
+        const progress = 20 + Math.round((completedCount / totalCount) * 60);
+        this.pushProgress('generate_content', Math.min(progress, 90), `内容生成中: ${completedCount}/${totalCount}`);
+      }
+
+      // 检查是否全部完成
+      if (totalCount > 0 && completedCount >= totalCount) {
+        this.pushProgress('generate_content', 95, '内容生成完成');
+        return;
+      }
+
+      // 检查书籍状态
+      const currentBook = await bookStore.getById(this.bookId);
+      if (currentBook?.genStage === 'video_completed') {
+        this.pushProgress('generate_content', 95, '内容生成完成');
+        return;
+      }
+      if (currentBook?.genStage === 'failed') {
+        throw new Error('内容生成失败: ' + (currentBook.error || '未知错误'));
+      }
+
+      await this.sleep(checkInterval);
+      waited += checkInterval;
+    }
+
+    throw new Error('内容生成超时');
+  }
+
+  /**
+   * 执行音频生成步骤
+   */
+  private async executeGenerateAudio(): Promise<void> {
+    this.pushProgress('generate_audio', 10, '开始生成音频');
+
+    const chapters = await bookStore.getChapterTree(this.bookId);
+    const maxLevel = chapters.length > 0
+      ? Math.max(...chapters.map((c: any) => c.level || 0))
+      : 0;
+
+    // 获取叶节点
+    const leafNodes = chapters.filter((c: any) => c.level === maxLevel);
+
+    // 检查内容状态 - 叶节点必须有content且genStage为content_completed
+    const leafNodesWithContent = leafNodes.filter((c: any) => c.content && c.genStage === 'content_completed');
+
+    if (leafNodesWithContent.length === 0) {
+      throw new Error('没有内容生成完成的章节,请先生成内容');
+    }
+
+    this.pushProgress('generate_audio', 20, `开始生成 ${leafNodesWithContent.length} 个章节音频`);
+
+    // 异步生成所有叶节点音频
+    const generationPromises: Promise<void>[] = [];
+    for (const sub of leafNodesWithContent) {
+      generationPromises.push(
+        bookStore.generateChapterAudioById(sub.id, 1)
+          .then(() => {
+            console.log(`[BatchGen][${this.taskId}] 章节 ${sub.number} 音频生成完成`);
+          })
+          .catch((err) => {
+            console.error(`[BatchGen][${this.taskId}] 章节 ${sub.number} 音频生成失败:`, err);
+          }) as Promise<void>
+      );
+    }
+
+    // 等待音频生成完成(轮询检查)
+    let maxWaitTime = 3600 * 1000; // 最多等待60分钟
+    let waited = 0;
+    const checkInterval = 3000; // 每3秒检查一次
+
+    while (waited < maxWaitTime) {
+      this.checkCancellation();
+
+      const currentChapters = await bookStore.getChapterTree(this.bookId);
+      const currentLeafNodes = currentChapters.filter((c: any) => c.level === maxLevel);
+      const completedCount = currentLeafNodes.filter((c: any) => c.audioUrl && c.audioUrl !== '').length;
+      const totalCount = currentLeafNodes.length;
+
+      if (totalCount > 0) {
+        const progress = 20 + Math.round((completedCount / totalCount) * 70);
+        this.pushProgress('generate_audio', Math.min(progress, 95), `音频生成中: ${completedCount}/${totalCount}`);
+      }
+
+      // 检查是否全部完成
+      if (totalCount > 0 && completedCount >= totalCount) {
+        this.pushProgress('generate_audio', 100, '音频生成完成');
+        return;
+      }
+
+      await this.sleep(checkInterval);
+      waited += checkInterval;
+    }
+
+    throw new Error('音频生成超时');
+  }
+
+  /**
+   * 执行音频合并步骤
+   */
+  private async executeMergeAudio(): Promise<void> {
+    this.pushProgress('merge_audio', 10, '开始合并音频');
+
+    const chapters = await bookStore.getChapterTree(this.bookId);
+    const maxLevel = chapters.length > 0
+      ? Math.max(...chapters.map((c: any) => c.level || 0))
+      : 0;
+
+    if (maxLevel <= 1) {
+      this.pushProgress('merge_audio', 100, '书籍层级不足,跳过音频合并');
+      return;
+    }
+
+    // 获取叶节点
+    const leafNodes = chapters.filter((c: any) => c.level === maxLevel);
+
+    // 检查所有叶节点是否都有音频
+    const leafNodesWithAudio = leafNodes.filter((c: any) => c.audioUrl && c.audioUrl !== '');
+
+    if (leafNodesWithAudio.length !== leafNodes.length) {
+      throw new Error(`并非所有章节都已完成音频生成,无法合并 (${leafNodesWithAudio.length}/${leafNodes.length})`);
+    }
+
+    this.pushProgress('merge_audio', 20, `开始合并 ${leafNodes.length} 个章节音频`);
+
+    // 按父节点分组叶节点
+    const groupedByParent: { [key: number]: any[] } = {};
+    leafNodesWithAudio.forEach(node => {
+      if (node.parentId) {
+        if (!groupedByParent[node.parentId]) {
+          groupedByParent[node.parentId] = [];
+        }
+        groupedByParent[node.parentId].push(node);
+      }
+    });
+
+    const totalParents = Object.keys(groupedByParent).length;
+    let processedParents = 0;
+
+    // 对每个父节点下的叶节点音频进行合并
+    for (const parentId in groupedByParent) {
+      this.checkCancellation();
+
+      const childNodes = groupedByParent[parentId];
+      if (childNodes.length > 0) {
+        const parentChapter = chapters.find((c: any) => c.id === parseInt(parentId));
+
+        if (parentChapter) {
+          try {
+            const mergedAudioUrl = await mergeChapterAudios(parentChapter.id);
+
+            if (mergedAudioUrl) {
+              await bookStore.updateChapterById(parentChapter.id, {
+                audioUrl: mergedAudioUrl,
+              });
+              console.log(`[BatchGen][${this.taskId}] 章节 ${parentChapter.number} 音频合并完成`);
+            }
+          } catch (error) {
+            console.error(`[BatchGen][${this.taskId}] 章节 ${parentChapter.number} 音频合并失败:`, error);
+          }
+        }
+      }
+
+      processedParents++;
+      const progress = 20 + Math.round((processedParents / totalParents) * 70);
+      this.pushProgress('merge_audio', Math.min(progress, 95), `音频合并中: ${processedParents}/${totalParents}`);
+    }
+
+    this.pushProgress('merge_audio', 100, '音频合并完成');
+  }
+
+  /**
+   * 执行视频生成步骤
+   */
+  private async executeGenerateVideo(): Promise<void> {
+    this.pushProgress('generate_video', 10, '开始生成视频');
+
+    const chapters = await bookStore.getChapterTree(this.bookId);
+    const maxLevel = chapters.length > 0
+      ? Math.max(...chapters.map((c: any) => c.level || 0))
+      : 0;
+
+    // 获取叶节点
+    const leafNodes = chapters.filter((c: any) => c.level === maxLevel);
+
+    // 过滤出有音频的叶节点
+    const leafNodesWithAudio = leafNodes.filter((c: any) => c.audioUrl && c.audioUrl !== '');
+
+    if (leafNodesWithAudio.length === 0) {
+      throw new Error('没有音频生成完成的章节,请先生成音频');
+    }
+
+    this.pushProgress('generate_video', 20, `开始生成 ${leafNodesWithAudio.length} 个章节视频`);
+
+    // 设置所有有音频的叶节点视频状态为生成中
+    for (const sub of leafNodesWithAudio) {
+      await advanceChapter(sub.id, 'video_generating').catch(() => {});
+    }
+
+    // 异步生成所有叶节点视频
+    for (const sub of leafNodesWithAudio) {
+      this.checkCancellation();
+
+      try {
+        const project = await createVideoProjectFromBook(
+          parseInt(this.bookId),
+          sub.id,
+          1
+        );
+
+        if (!project) {
+          console.error(`[BatchGen][${this.taskId}] 章节 ${sub.number} 视频项目创建失败`);
+          await regenerateChapter(sub.id, 'failed').catch(() => {});
+          continue;
+        }
+
+        const result = await generateVideoForProject(project.id);
+
+        if (result.success && result.outputUrl) {
+          await bookStore.updateChapterById(sub.id, {
+            videoUrl: result.outputUrl,
+            videoDuration: result.duration,
+          });
+          await advanceChapter(sub.id, 'video_completed');
+          console.log(`[BatchGen][${this.taskId}] 章节 ${sub.number} 视频生成成功`);
+        } else {
+          await regenerateChapter(sub.id, 'failed').catch(() => {});
+        }
+      } catch (error) {
+        console.error(`[BatchGen][${this.taskId}] 章节 ${sub.number} 视频生成异常:`, error);
+        await regenerateChapter(sub.id, 'failed').catch(() => {});
+      }
+    }
+
+    // 等待视频生成完成(轮询检查)
+    let maxWaitTime = 3600 * 1000; // 最多等待60分钟
+    let waited = 0;
+    const checkInterval = 5000; // 每5秒检查一次
+
+    while (waited < maxWaitTime) {
+      this.checkCancellation();
+
+      const currentChapters = await bookStore.getChapterTree(this.bookId);
+      const currentLeafNodes = currentChapters.filter((c: any) => c.level === maxLevel);
+      const completedCount = currentLeafNodes.filter((c: any) => c.videoUrl && c.videoUrl !== '').length;
+      const totalCount = currentLeafNodes.length;
+
+      if (totalCount > 0) {
+        const progress = 20 + Math.round((completedCount / totalCount) * 70);
+        this.pushProgress('generate_video', Math.min(progress, 95), `视频生成中: ${completedCount}/${totalCount}`);
+      }
+
+      // 检查是否全部完成
+      if (totalCount > 0 && completedCount >= totalCount) {
+        this.pushProgress('generate_video', 100, '视频生成完成');
+        return;
+      }
+
+      await this.sleep(checkInterval);
+      waited += checkInterval;
+    }
+
+    throw new Error('视频生成超时');
+  }
+
+  /**
+   * 执行视频合并步骤
+   */
+  private async executeMergeVideo(): Promise<void> {
+    this.pushProgress('merge_video', 10, '开始合并视频');
+
+    const chapters = await bookStore.getChapterTree(this.bookId);
+    const maxLevel = chapters.length > 0
+      ? Math.max(...chapters.map((c: any) => c.level || 0))
+      : 0;
+
+    if (maxLevel <= 1) {
+      this.pushProgress('merge_video', 100, '书籍层级不足,跳过视频合并');
+      return;
+    }
+
+    // 获取叶节点
+    const leafNodes = chapters.filter((c: any) => c.level === maxLevel);
+
+    // 检查所有叶节点是否都有视频
+    const leafNodesWithVideo = leafNodes.filter((c: any) => c.videoUrl && c.videoUrl !== '');
+
+    if (leafNodesWithVideo.length !== leafNodes.length) {
+      throw new Error(`并非所有章节都已完成视频生成,无法合并 (${leafNodesWithVideo.length}/${leafNodes.length})`);
+    }
+
+    this.pushProgress('merge_video', 20, `开始合并 ${leafNodes.length} 个章节视频`);
+
+    // 按父节点分组叶节点
+    const groupedByParent: { [key: number]: any[] } = {};
+    leafNodesWithVideo.forEach(node => {
+      if (node.parentId) {
+        if (!groupedByParent[node.parentId]) {
+          groupedByParent[node.parentId] = [];
+        }
+        groupedByParent[node.parentId].push(node);
+      }
+    });
+
+    const totalParents = Object.keys(groupedByParent).length;
+    let processedParents = 0;
+
+    // 对每个父节点下的叶节点视频进行合并
+    for (const parentId in groupedByParent) {
+      this.checkCancellation();
+
+      const childNodes = groupedByParent[parentId];
+      if (childNodes.length > 0) {
+        const parentChapter = chapters.find((c: any) => c.id === parseInt(parentId));
+
+        if (parentChapter) {
+          try {
+            // 简化处理:直接使用第一个视频或标记为已完成
+            // 实际的视频合并需要FFmpeg处理
+            const firstChildVideo = childNodes[0].videoUrl;
+            if (firstChildVideo) {
+              await bookStore.updateChapterById(parentChapter.id, {
+                videoUrl: firstChildVideo,
+              });
+              console.log(`[BatchGen][${this.taskId}] 章节 ${parentChapter.number} 视频处理完成`);
+            }
+          } catch (error) {
+            console.error(`[BatchGen][${this.taskId}] 章节 ${parentChapter.number} 视频处理失败:`, error);
+          }
+        }
+      }
+
+      processedParents++;
+      const progress = 20 + Math.round((processedParents / totalParents) * 70);
+      this.pushProgress('merge_video', Math.min(progress, 95), `视频处理中: ${processedParents}/${totalParents}`);
+    }
+
+    this.pushProgress('merge_video', 100, '视频合并完成');
+  }
+
+  /**
+   * 休眠辅助函数
+   */
+  private sleep(ms: number): Promise<void> {
+    return new Promise(resolve => setTimeout(resolve, ms));
+  }
+}
+
+/**
+ * 创建批量生成任务
+ */
+export async function createBatchGenerationTask(
+  bookId: string,
+  steps: GenerationStep[]
+): Promise<{ taskId: string; orchestrator: BatchGenerationOrchestrator }> {
+  const taskId = `batch_${bookId}_${Date.now()}`;
+  const orchestrator = new BatchGenerationOrchestrator(taskId, bookId, steps);
+
+  return { taskId, orchestrator };
+}

+ 173 - 49
server/src/modules/book-generator/book-generator.store.ts

@@ -3,13 +3,61 @@
  */
 
 import { prisma } from '../../models';
-import { Book, BookOutline, Chapter, ChapterStatus, BookStatus } from './book-generator.types';
+import { Book, BookOutline, Chapter, ChapterGenStage, BookGenStage } from './book-generator.types';
 import { Prisma } from '@prisma/client';
 import { generateAudio } from '../tts/tts.service';
 import { callLLMWithMessages, callLLMWithTools, ChatMessage } from '../../services/llm';
 import { createBookTools } from '../../services/llm/book-tools';
 import { SUBSECTION_CONTENT_SYSTEM_PROMPT } from './prompts/templates';
 import { countWords } from './utils';
+import { advanceChapter, regenerateChapter } from './stage-manager';
+
+/**
+ * 根据所有章节状态计算书籍阶段
+ * 书籍阶段 = 所有章节中最低的阶段(最落后的章节决定了书籍的进度)
+ */
+function computeBookGenStage(chapters: { genStage: string }[]): BookGenStage {
+  if (chapters.length === 0) return 'draft';
+
+  // 章节阶段顺序(索引越大越"后")
+  const stageOrder = ['idle', 'outline_completed', 'content_generating', 'content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed', 'failed'];
+
+  // 找出最低阶段的索引
+  let minIdx = stageOrder.length; // 默认最大
+  for (const ch of chapters) {
+    const idx = stageOrder.indexOf(ch.genStage);
+    if (idx !== -1 && idx < minIdx) {
+      minIdx = idx;
+    }
+  }
+
+  // 最低阶段索引对应的阶段
+  const minStage = stageOrder[minIdx];
+
+  // 映射到书籍阶段
+  // idle → outlining
+  // outline_completed → content_generating(大纲完成后开始内容生成)
+  // content_generating → content_generating
+  // content_completed → content_completed
+  // audio_generating → audio_generating
+  // audio_completed → audio_completed
+  // video_generating → video_generating
+  // video_completed → video_completed
+  // failed → failed
+  const stageMap: Record<string, BookGenStage> = {
+    'idle': 'outlining',
+    'outline_completed': 'content_generating',
+    'content_generating': 'content_generating',
+    'content_completed': 'content_completed',
+    'audio_generating': 'audio_generating',
+    'audio_completed': 'audio_completed',
+    'video_generating': 'video_generating',
+    'video_completed': 'video_completed',
+    'failed': 'failed',
+  };
+
+  return stageMap[minStage] || 'draft';
+}
 
 /**
  * 清理内容中的思考标签
@@ -86,7 +134,6 @@ function chaptersFromDb(dbChapters: any[], bookId: number, excludeContent: boole
     title: c.title,
     content: excludeContent ? '' : (c.content || ''),
     wordCount: c.wordCount,
-    status: c.status as ChapterStatus,
     summary: c.summary || undefined,
     generatedAt: c.generatedAt || undefined,
     error: c.errorMsg || undefined,
@@ -97,6 +144,7 @@ function chaptersFromDb(dbChapters: any[], bookId: number, excludeContent: boole
     isPublic: c.isPublic || false,
     level: c.level,        // 层级:1=章, 2=节, 3=小节
     parentId: c.parentId,  // 父节点ID(0表示章)
+    genStage: c.genStage || undefined,  // 线性阶段状态
   }));
 }
 
@@ -216,6 +264,7 @@ export class BookStore {
         totalChapters: data.totalChapters ?? 10,
         estimatedWords: data.estimatedWords ?? 0,
         status: 'draft',
+        genStage: 'draft',  // 新字段
         progress: 0,
         isPublished: false, // 预发布:等书籍完成后再发布
       },
@@ -254,7 +303,7 @@ export class BookStore {
       console.error('[BookStore] buildOutlineFromChapters 失败:', error);
     }
     
-    let result = this.toBook(book, true);  // 详情页不需要章节content
+    let result = this.toBook(book, false);  // 需要返回章节content
     
     console.log('[BookStore.getById] bookScale:', result.bookScale);
     console.log('[BookStore.getById] result keys:', Object.keys(result));
@@ -352,21 +401,27 @@ export class BookStore {
    * 更新书籍
    */
   async update(id: string, data: Partial<{
-    status: BookStatus;
+    genStage: string;
+    failedStage: string;
     progress: number;
     outlineJson: string;
+    outline: any;
     foreword: string;
     afterword: string;
     estimatedWords: number;
     errorMsg: string;
     totalChapters: number;
   }>): Promise<Book | null> {
+    // 如果传入 outline 对象,转换为 outlineJson 字符串
+    const updateData: any = { ...data, updatedAt: new Date() };
+    if (data.outline) {
+      updateData.outlineJson = JSON.stringify(data.outline);
+      delete updateData.outline;
+    }
+
     const book = await prisma.book.update({
       where: { id: parseInt(id) },
-      data: {
-        ...data,
-        updatedAt: new Date(),
-      },
+      data: updateData,
       include: { chapters: { where: { level: 1 }, orderBy: { number: 'asc' } } },
     });
     return this.toBook(book);
@@ -403,7 +458,7 @@ export class BookStore {
         summary: data.summary,
         keyPoints: data.keyPoints ? JSON.stringify(data.keyPoints) : null,
         estimatedWords: data.estimatedWords || 5000,
-        status: 'pending',
+        genStage: 'outline_completed',
       },
     });
   }
@@ -422,7 +477,7 @@ export class BookStore {
     const bookIdNum = parseInt(bookId);
     // 使用 upsert 避免重复创建(parentId=0表示章级别)
     for (const c of chapters) {
-      await prisma.bookChapter.upsert({
+      const upserted = await prisma.bookChapter.upsert({
         where: {
           bookId_parentId_level_number: {
             bookId: bookIdNum,
@@ -436,7 +491,7 @@ export class BookStore {
           summary: c.summary,
           keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
           estimatedWords: c.estimatedWords || 5000,
-          status: 'completed',
+          genStage: 'outline_completed',
         } as any,
         create: {
           bookId: bookIdNum,
@@ -447,7 +502,7 @@ export class BookStore {
           summary: c.summary,
           keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
           estimatedWords: c.estimatedWords || 5000,
-          status: 'completed',
+          genStage: 'outline_completed',
         } as any,
       });
     }
@@ -483,7 +538,7 @@ export class BookStore {
         summary: item.summary || '',
         keyPoints: item.keyPoints ? JSON.stringify(item.keyPoints) : null,
         estimatedWords: item.estimatedWords || 1000,
-        status: 'completed',
+        genStage: 'outline_completed',
       } as any,
       create: {
         bookId: bookIdNum,
@@ -494,7 +549,7 @@ export class BookStore {
         summary: item.summary || '',
         keyPoints: item.keyPoints ? JSON.stringify(item.keyPoints) : null,
         estimatedWords: item.estimatedWords || 1000,
-        status: 'completed',
+        genStage: 'outline_completed',
       } as any,
     });
     return upserted.id;
@@ -524,7 +579,7 @@ export class BookStore {
   async updateChapter(bookId: string, chapterNumber: number, data: Partial<{
     content: string;
     wordCount: number;
-    status: ChapterStatus;
+    genStage: string;
     errorMsg: string;
   }>): Promise<Chapter | null> {
     const chapter = await prisma.bookChapter.findFirst({
@@ -550,7 +605,7 @@ export class BookStore {
       title: updated.title,
       content: updated.content || '',
       wordCount: updated.wordCount,
-      status: updated.status as ChapterStatus,
+      genStage: updated.genStage as ChapterGenStage | undefined,
       summary: updated.summary || undefined,
       generatedAt: updated.generatedAt || undefined,
       error: (updated as any).errorMsg || undefined,
@@ -563,9 +618,8 @@ export class BookStore {
   async updateChapterById(id: number, data: Partial<{
     content: string;
     wordCount: number;
-    status: ChapterStatus;
+    genStage: string;
     errorMsg: string;
-    contentStatus: 'pending' | 'generating' | 'completed' | 'failed';
     contentError: string;
     audioUrl: string;
     audioDuration: number;
@@ -594,7 +648,7 @@ export class BookStore {
       title: updated.title,
       content: updated.content || '',
       wordCount: updated.wordCount,
-      status: updated.status as ChapterStatus,
+      genStage: updated.genStage as ChapterGenStage | undefined,
       summary: updated.summary || undefined,
       generatedAt: updated.generatedAt || undefined,
       error: updated.contentError || undefined,
@@ -635,7 +689,7 @@ export class BookStore {
     return prisma.bookChapter.count({
       where: {
         bookId: parseInt(bookId),
-        status: 'completed',
+        genStage: 'video_completed',
       },
     });
   }
@@ -744,44 +798,102 @@ export class BookStore {
   async generateChapterAudioById(chapterId: number, userId?: number): Promise<{
     audioUrl: string;
   } | null> {
-    const chapter = await prisma.bookChapter.findUnique({
+    // 1. 先查出当前章节状态和内容(防止 TOCTOU)
+    const chapterBefore = await prisma.bookChapter.findUnique({
       where: { id: chapterId },
       include: { book: true },
     });
 
-    if (!chapter) {
+    if (!chapterBefore) {
       console.warn(`[Audio] 章节不存在: ${chapterId}`);
       return null;
     }
 
     // 检查内容是否生成完成
-    const chapterAny = chapter as any;
-    if (!chapter.content || chapterAny.contentStatus !== 'completed') {
-      console.warn(`[Audio] 章节内容未生成完成: ${chapterId}, contentStatus: ${chapterAny.contentStatus}`);
+    if (!chapterBefore.content || chapterBefore.genStage === 'idle' || chapterBefore.genStage === 'content_generating') {
+      console.warn(`[Audio] 章节内容未生成完成: ${chapterId}, genStage: ${chapterBefore.genStage}`);
       return null;
     }
 
-    // 生成音频(异步模式,通过回调更新章节)
-    const result = await generateAudio(
-      userId ? String(userId) : String(chapter.book?.userId || '0'),
-      chapter.content,
-      'default',
-      { speed: 1.0, pitch: 0, volume: 50 },
-      async (audioUrl: string, duration: number) => {
-        await prisma.bookChapter.update({
-          where: { id: chapterId },
-          data: { audioUrl, audioDuration: duration },
-        });
-        console.log(`✅ 章节${chapterId}音频生成完成:`, audioUrl);
-      },
-      {
-        bookId: chapter.bookId ? String(chapter.bookId) : undefined,
-        chapterId: chapter.id,
-        chapterTitle: chapter.title,
-      }
-    );
+    // 已经是 audio_generating 或更后,不需要重新生成
+    if (['audio_generating', 'audio_completed', 'video_generating', 'video_completed'].includes(chapterBefore.genStage)) {
+      console.log(`[Audio] 章节${chapterId}已在 ${chapterBefore.genStage},跳过音频生成`);
+      return { audioUrl: chapterBefore.audioUrl || '' };
+    }
+
+    // 2. 推进到 audio_generating
+    await advanceChapter(chapterId, 'audio_generating');
+
+    // 3. 再次检查状态(防止并发触发)
+    const chapterAfter = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
+    if (!chapterAfter) {
+      console.warn(`[Audio] 章节不存在: ${chapterId}`);
+      return null;
+    }
+    if (chapterAfter.genStage !== 'audio_generating') {
+      console.log(`[Audio] 章节${chapterId}已被其他调用推进到 ${chapterAfter.genStage},跳过`);
+      return { audioUrl: chapterAfter.audioUrl || '' };
+    }
 
-    return { audioUrl: result.audioUrl };
+    console.log(`[Audio] 开始生成音频: ${chapterId}, content长度: ${chapterBefore.content?.length || 0}`);
+    // 4. 用查到的主题内容生成音频
+    try {
+      const result = await generateAudio(
+        userId ? String(userId) : String(chapterBefore.book?.userId || '0'),
+        chapterBefore.content,
+        'default',
+        { speed: 1.0, pitch: 0, volume: 50 },
+        async (audioUrl: string, duration: number) => {
+          const callbackChapterId = chapterId;  // 捕获当前闭包的 chapterId
+          console.log(`[Audio] ★回调触发: callbackChapterId=${callbackChapterId}, audioUrl=${audioUrl}, duration=${duration}`);
+          try {
+            console.log(`[Audio] 开始处理音频回调...`);
+            // 先更新音频URL和时长
+            await bookStore.updateChapterById(callbackChapterId, {
+              audioUrl,
+              audioDuration: duration,
+            });
+            console.log(`[Audio] 更新audioUrl完成, callbackChapterId=${callbackChapterId}`);
+            // 再推进状态到 audio_completed
+            await advanceChapter(callbackChapterId, 'audio_completed');
+            console.log(`✅ 章节${callbackChapterId}音频生成完成,状态已推进到audio_completed`);
+          } catch (err: any) {
+            console.error(`[Audio] 章节${callbackChapterId}回调处理失败:`, err);
+          }
+        },
+        {
+          bookId: chapterBefore.bookId ? String(chapterBefore.bookId) : undefined,
+          chapterId: chapterBefore.id,
+          chapterTitle: chapterBefore.title,
+        }
+      );
+      return { audioUrl: result.audioUrl };
+    } catch (err) {
+      // 音频失败时,回退到 content_completed 并自动重试(最多3次)
+      console.error(`❌ 章节${chapterId}音频生成失败,自动重试:`, err);
+      try {
+        await regenerateChapter(chapterId, 'content_completed');
+      } catch (e) {
+        console.warn(`[Audio] 回退章节${chapterId}状态失败:`, e);
+      }
+      // 自动重试音频生成
+      const maxRetries = 3;
+      for (let retry = 1; retry <= maxRetries; retry++) {
+        try {
+          console.log(`[Audio] 章节${chapterId}自动重试第${retry}次...`);
+          await new Promise(resolve => setTimeout(resolve, 2000 * retry));  // 递增延迟
+          const retryResult = await this.generateChapterAudioById(chapterId, userId);
+          if (retryResult?.audioUrl) {
+            console.log(`✅ 章节${chapterId}音频重试成功`);
+            return retryResult;
+          }
+        } catch (retryErr) {
+          console.warn(`[Audio] 章节${chapterId}第${retry}次重试失败:`, retryErr);
+        }
+      }
+      console.error(`❌ 章节${chapterId}音频生成全部失败`);
+      return { audioUrl: '' };
+    }
   }
 
   /**
@@ -874,17 +986,26 @@ ${writingStyle ? `写作风格:${writingStyle}` : ''}
       await this.updateChapterById(chapterId, {
         content,
         wordCount,
-        contentStatus: 'completed',
       });
 
+      // 处理 failed 状态的章节回退到 content_generating 再推进
+      const currentChapter = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
+      if (currentChapter?.genStage === 'failed') {
+        await regenerateChapter(chapterId, 'content_generating');
+        await advanceChapter(chapterId, 'content_completed');
+      } else {
+        await advanceChapter(chapterId, 'content_completed');
+      }
+
       console.log(`✅ 章节「${chapter.title}」内容重新生成完成,字数: ${wordCount}`);
     } catch (error) {
       const errorMsg = error instanceof Error ? error.message : '失败';
       await this.updateChapterById(chapterId, {
-        contentStatus: 'failed',
         contentError: errorMsg,
       });
-      console.error(`❌ 章节「${chapter.title}」内容重新生成失败:`, errorMsg);
+      // 失败时回退到 content_generating(不是 failed),方便下次重试
+      await regenerateChapter(chapterId, 'content_generating');
+      console.error(`❌ 章节${chapterId}内容重新生成失败:`, errorMsg);
     }
   }
 
@@ -906,6 +1027,8 @@ ${writingStyle ? `写作风格:${writingStyle}` : ''}
     status: string;
     progress: number;
     isPublished: boolean;
+    genStage?: string;
+    failedStage?: string;
     outlineJson: string | null;
     foreword: string | null;
     afterword: string | null;
@@ -927,9 +1050,10 @@ ${writingStyle ? `写作风格:${writingStyle}` : ''}
       bookScale: dbBook.bookScale,
       totalChapters: dbBook.totalChapters,
       estimatedWords: dbBook.estimatedWords,
-      status: dbBook.status as BookStatus,
       progress: dbBook.progress,
       isPublished: dbBook.isPublished,
+      genStage: computeBookGenStage(dbBook.chapters),
+      failedStage: dbBook.failedStage || undefined,
       chapters: chaptersFromDb(dbBook.chapters, dbBook.id, excludeContent),
       outline: outline || undefined,
       metadata: {

+ 28 - 3
server/src/modules/book-generator/book-generator.types.ts

@@ -11,6 +11,30 @@ export type BookStatus = 'draft' | 'planning' | 'generating' | 'completed' | 'fa
 /** 章节状态 */
 export type ChapterStatus = 'pending' | 'generating' | 'completed' | 'failed';
 
+/** 书籍生成阶段 */
+export type BookGenStage =
+  | 'draft'
+  | 'outlining'
+  | 'outline_completed'
+  | 'content_generating'
+  | 'content_completed'
+  | 'audio_generating'
+  | 'audio_completed'
+  | 'video_generating'
+  | 'video_completed'
+  | 'failed';
+
+/** 章节生成阶段 */
+export type ChapterGenStage =
+  | 'idle'
+  | 'content_generating'
+  | 'content_completed'
+  | 'audio_generating'
+  | 'audio_completed'
+  | 'video_generating'
+  | 'video_completed'
+  | 'failed';
+
 /** 书籍基础信息 */
 export interface BookBase {
   id: string;
@@ -22,6 +46,8 @@ export interface BookBase {
   bookScale: string;                // 书籍规模:800, 2000, 5000, 小册子, 标准教程, 系统教材
   totalChapters: number;            // 总章节数
   estimatedWords: number;           // 预估总字数
+  genStage?: BookGenStage;           // 线性阶段状态
+  failedStage?: string;              // 失败阶段
   createdAt: Date;
   updatedAt: Date;
 }
@@ -29,7 +55,6 @@ export interface BookBase {
 /** 书籍完整信息 */
 export interface Book extends BookBase {
   userId?: number;                  // 用户ID(用于配额检查)
-  status: BookStatus;
   progress: number;                 // 生成进度 0-100
   isPublished: boolean;             // 是否已公开
   chapters: Chapter[];              // 章节列表
@@ -79,7 +104,7 @@ export interface Chapter {
   content: string;                 // 正文内容
   summary?: string;                 // 本章小结
   wordCount: number;                // 字数
-  status: ChapterStatus;
+  genStage?: ChapterGenStage;       // 线性阶段状态
   generatedAt?: Date;
   error?: string;
   audioUrl?: string;                // 音频URL
@@ -151,7 +176,7 @@ export interface BookResponse {
 /** 进度响应 */
 export interface ProgressResponse {
   bookId: string;
-  status: BookStatus;
+  genStage: string;
   progress: number;
   currentTask?: {
     type: string;

+ 77 - 56
server/src/modules/book-generator/book-queue.processor.ts

@@ -1,66 +1,85 @@
 /**
  * 书籍生成队列处理器
  * 使用 Bull 队列管理并发生成任务
+ * 支持 Redis 队列和内存队列 fallback
  */
 
 import { queueService, QueueType } from '../../services/queue.service';
+import { memoryQueue } from '../../services/memory-queue';
 import { langGraphGenerator } from './index';
 import { bookStore } from './book-generator.store';
 import { prisma } from '../../models';
 
+/**
+ * 书籍生成任务处理器
+ */
+async function handleBookGenerationJob(job: any) {
+  const { bookId, topic, bookScale } = job.data;
+
+  console.log(`[BookQueue] 开始处理任务: bookId=${bookId}`);
+
+  // 更新状态为生成中
+  await bookStore.update(bookId, {
+    genStage: 'outlining',
+    progress: 0
+  });
+
+  try {
+    // 调用 LangGraph 生成器
+    await langGraphGenerator.generate(bookId, topic, bookScale);
+
+    console.log(`[BookQueue] 任务完成: bookId=${bookId}`);
+    return { success: true, bookId };
+  } catch (error) {
+    console.error(`[BookQueue] 任务失败: bookId=${bookId}`, error);
+    // 更新状态为失败
+    await bookStore.update(bookId, {
+      genStage: 'failed',
+      failedStage: 'outlining',
+      errorMsg: error instanceof Error ? error.message : '生成失败',
+    });
+    throw error;
+  }
+}
+
 /**
  * 初始化书籍生成队列处理器
  */
 export function initBookGenerationQueue() {
   console.log('[BookQueue] 初始化书籍生成队列处理器');
-  
-  // 获取书籍生成队列
-  const queue = (queueService as any).getQueue(QueueType.BOOK_GENERATION);
-  
-  // 设置并发限制:最多同时处理 3 个生成任务
-  queue.process(3, async (job: any) => {
-    const { bookId, topic, bookScale } = job.data;
-    
-    console.log(`[BookQueue] 开始处理任务: bookId=${bookId}, 队列进度 ${job.progress()}%`);
-    
-    // 更新状态为生成中
-    await bookStore.update(bookId, { status: 'generating', progress: 0 });
-    
-    try {
-      // 调用 LangGraph 生成器
-      await langGraphGenerator.generate(bookId, topic, bookScale);
-      
-      console.log(`[BookQueue] 任务完成: bookId=${bookId}`);
-      return { success: true, bookId };
-    } catch (error) {
-      console.error(`[BookQueue] 任务失败: bookId=${bookId}`, error);
-      throw error;
-    }
-  });
-  
-  // 监听任务进度更新
-  queue.on('progress', (job: any, progress: number) => {
-    console.log(`[BookQueue] 任务进度: bookId=${job.data.bookId}, progress=${progress}%`);
-  });
-  
-  // 监听任务完成
-  queue.on('completed', (job: any) => {
-    console.log(`[BookQueue] 任务已完成: bookId=${job.data.bookId}`);
-  });
-  
-  // 监听任务失败
-  queue.on('failed', async (job: any, err: Error) => {
-    const { bookId } = job.data;
-    console.error(`[BookQueue] 任务失败: bookId=${bookId}`, err.message);
-    
-    // 更新书籍状态为失败
-    await bookStore.update(bookId, { 
-      status: 'failed', 
-      errorMsg: err.message || '生成失败' 
+
+  // 优先尝试 Redis 队列
+  const redisQueue = (queueService as any).getQueue(QueueType.BOOK_GENERATION);
+
+  if (redisQueue) {
+    // Redis 队列可用
+    redisQueue.process(3, handleBookGenerationJob);
+
+    redisQueue.on('progress', (job: any, progress: number) => {
+      console.log(`[BookQueue] 任务进度: bookId=${job.data.bookId}, progress=${progress}%`);
     });
-  });
-  
-  console.log('[BookQueue] 队列处理器已启动,最大并发数: 3');
+
+    redisQueue.on('completed', (job: any) => {
+      console.log(`[BookQueue] 任务已完成: bookId=${job.data.bookId}`);
+    });
+
+    redisQueue.on('failed', async (job: any, err: Error) => {
+      const { bookId } = job.data;
+      console.error(`[BookQueue] 任务失败: bookId=${bookId}`, err.message);
+
+      await bookStore.update(bookId, {
+        genStage: 'failed',
+        errorMsg: err.message || '生成失败',
+      });
+    });
+
+    console.log('[BookQueue] Redis 队列处理器已启动,最大并发数: 3');
+  } else {
+    // 使用内存队列作为 fallback
+    console.log('[BookQueue] 使用内存队列作为 fallback');
+    memoryQueue.process(3, handleBookGenerationJob);
+    console.log('[BookQueue] 内存队列处理器已启动,最大并发数: 3');
+  }
 }
 
 /**
@@ -68,21 +87,21 @@ export function initBookGenerationQueue() {
  */
 export async function resumeInterruptedTasks() {
   console.log('[BookQueue] 检查是否有中断的生成任务...');
-  
+
   try {
     // 查询所有状态为 generating 的书籍
     const interruptedBooks = await prisma.book.findMany({
-      where: { status: 'generating' },
+      where: { genStage: { in: ['content_generating', 'audio_generating', 'video_generating'] } },
       select: { id: true, title: true, description: true, bookScale: true },
     });
-    
+
     if (interruptedBooks.length === 0) {
       console.log('[BookQueue] 没有中断的任务');
       return;
     }
-    
+
     console.log(`[BookQueue] 发现 ${interruptedBooks.length} 个中断的任务,开始恢复...`);
-    
+
     // 将中断的任务重新加入队列
     for (const book of interruptedBooks) {
       const jobId = await queueService.addBookGenerationTask({
@@ -90,12 +109,14 @@ export async function resumeInterruptedTasks() {
         topic: book.description || book.title,
         bookScale: book.bookScale || '标准教程',
       });
-      
-      console.log(`[BookQueue] 已恢复: 《${book.title}》 (bookId=${book.id}, jobId=${jobId})`);
+
+      if (jobId) {
+        console.log(`[BookQueue] 已恢复: 《${book.title}》 (bookId=${book.id}, jobId=${jobId})`);
+      }
     }
-    
+
     console.log(`[BookQueue] 成功恢复 ${interruptedBooks.length} 个任务`);
   } catch (error) {
     console.error('[BookQueue] 恢复中断任务失败:', error);
   }
-}
+}

+ 4 - 26
server/src/modules/book-generator/fault-tolerance.ts

@@ -1,17 +1,15 @@
 /**
  * 智能容错层 - 为书籍生成提供稳定性保障
- * 
+ *
  * 核心功能:
  * 1. AI调用重试机制(3次重试 + 指数退避)
  * 2. 节点级超时控制(超时自动跳过或降级)
  * 3. 进度监控和长时间无响应告警
  * 4. 失败自动恢复(智能重试策略)
- * 5. WebSocket实时推送错误信息给用户
  */
 
 import { bookStore } from './book-generator.store';
 import { callLLMWithMessages, ChatMessage } from '../../services/llm';
-import { websocketService } from '../../services/websocket.service';
 import { queueService } from '../../services/queue.service';
 
 // ============ 配置 ============
@@ -283,7 +281,7 @@ export async function attemptAutoRecovery(bookId: string) {
       
       // 标记为最终失败
       await bookStore.update(bookId, {
-        status: 'failed',
+        genStage: 'failed',
         errorMsg: `生成失败,已尝试自动恢复${config.maxRecoveryAttempts}次`,
       });
       
@@ -336,30 +334,10 @@ export interface UserNotification {
 }
 
 /**
- * 通过WebSocket通知用户
+ * 记录用户通知(客户端通过轮询获取状态)
  */
 async function notifyUser(bookId: string, notification: UserNotification) {
-  try {
-    // 获取书籍的userId
-    const book = await bookStore.getById(bookId);
-    if (!book?.userId) return;
-    
-    const userId = book.userId;
-    
-    // 通过WebSocket发送通知
-    websocketService.sendMessage(userId, {
-      type: 'book_generation_notification',
-      data: {
-        bookId,
-        timestamp: Date.now(),
-        ...notification,
-      },
-    });
-    
-    console.log(`[容错] 📢 已通知用户: ${notification.type} - ${notification.message}`);
-  } catch (error) {
-    console.error('[容错] 通知用户失败:', error);
-  }
+  console.log(`[容错] 📢 通知: ${notification.type} - ${notification.message} (bookId: ${bookId})`);
 }
 
 // ============ 失败日志 ============

+ 4 - 0
server/src/modules/book-generator/graph.ts

@@ -25,6 +25,10 @@ export const GraphState = Annotation.Root({
     reducer: (_prev, update) => update ?? _prev,
     default: () => '' as string,
   }),
+  userId: Annotation<string | number>({
+    reducer: (_prev, update) => update ?? _prev,
+    default: () => '' as string,
+  }),
   topic: Annotation<string>({
     reducer: (_prev, update) => update ?? _prev,
     default: () => '' as string,

+ 48 - 5
server/src/modules/book-generator/index.ts

@@ -72,7 +72,7 @@ export class LangGraphBookGenerator {
       progress: 0
     };
 
-    await bookStore.update(bookId, { status: 'generating', progress: 0 });
+    await bookStore.update(bookId, { genStage: 'outlining', progress: 0 });
 
     // 启动进度监控器
     const stopMonitor = startProgressMonitor(bookId);
@@ -82,13 +82,33 @@ export class LangGraphBookGenerator {
       for await (const step of stream) {
         console.log('[LangGraph] Step:', Object.keys(step));
       }
-      await bookStore.update(bookId, { status: 'completed', progress: 100 });
-      await bookStore.publishAlbum(bookId);
-      console.log('[LangGraph] 书籍生成完成');
+
+      // 检查最终状态,避免覆盖节点内部已设置的失败状态
+      const finalBook = await bookStore.getById(bookId);
+      if (finalBook && finalBook.genStage !== 'failed') {
+        const finalStage = finalBook.genStage || 'content_completed';
+        const stageUpdate: any = { progress: 100 };
+        if (finalStage === 'content_completed' || finalStage === 'audio_generating' || finalStage === 'audio_completed') {
+          stageUpdate.genStage = finalStage;
+        }
+        if (finalStage === 'content_completed' || finalStage === 'audio_generating' || finalStage === 'audio_completed') {
+          // 同步Book的genStage与实际章节状态一致
+          stageUpdate.genStage = finalStage;
+        }
+        await bookStore.update(bookId, stageUpdate);
+        // 只有最终态 video_completed 才自动发布
+        if (finalStage === 'video_completed') {
+          await bookStore.publishAlbum(bookId);
+        }
+        console.log(`[LangGraph] 书籍生成完成,genStage: ${finalStage}`);
+      } else {
+        console.log('[LangGraph] 书籍生成失败,保持 failed 状态');
+      }
     } catch (error) {
       console.error('[LangGraph] 生成失败:', error);
       await bookStore.update(bookId, {
-        status: 'failed',
+        genStage: 'failed',
+        failedStage: 'outlining',
         errorMsg: error instanceof Error ? error.message : '生成失败'
       });
     } finally {
@@ -102,3 +122,26 @@ export const langGraphGenerator = new LangGraphBookGenerator();
 
 // 重新导出 book-type-config 中的函数供外部使用
 export { getScaleConfig } from './book-type-config';
+
+// ============ 独立大纲生成函数 ============
+
+import { callLLMWithMessages } from '../../services/llm';
+import { parseOutline } from './parsers/outline.parser';
+import { buildOutlineMessages } from './prompts/builder';
+
+/**
+ * 独立的大纲生成函数(供 API 直接调用)
+ */
+export async function generateOutline(bookId: string, topic: string, bookScale: string = '标准教程'): Promise<any> {
+  console.log('[generateOutline] 生成大纲, bookId:', bookId, 'scale:', bookScale);
+
+  const messages = buildOutlineMessages(topic, bookScale);
+  const response = await callLLMWithMessages(messages);
+  const outline = parseOutline(response);
+
+  if (!outline) {
+    throw new Error('大纲解析失败');
+  }
+
+  return outline;
+}

+ 322 - 81
server/src/modules/book-generator/langgraph-controller.ts

@@ -21,6 +21,7 @@ import { exec } from 'child_process';
 import { promisify } from 'util';
 import fs from 'fs/promises';
 import path from 'path';
+import { advanceChapter, regenerateChapter } from './stage-manager';
 
 const execPromise = promisify(exec);
 
@@ -291,6 +292,80 @@ ${typeDescriptions}
   }
 }
 
+/**
+ * POST /api/book-generator/langgraph/smart-recommend
+ * 根据书籍描述智能推荐难度、人群、风格、领域
+ */
+router.post('/smart-recommend', async (ctx: Context) => {
+  try {
+    const { description, title } = ctx.request.body as { description: string; title?: string };
+
+    if (!description || !description.trim()) {
+      ctx.status = 400;
+      ctx.body = { code: 1, message: '描述不能为空' };
+      return;
+    }
+
+    // 默认推荐值
+    const defaultResult = {
+      knowledgeLevel: '入门',
+      targetAudience: '通用',
+      style: '轻松幽默',
+      industry: '教育',
+      reason: '默认推荐',
+    };
+
+    const prompt = `根据以下书籍描述,推荐合适的知识难度、目标人群、写作风格和行业领域。
+
+书名:${title || '未命名'}
+描述:${description}
+
+请以 JSON 格式返回推荐结果,不要包含任何其他文字:
+{
+  "knowledgeLevel": "入门|进阶|专业",
+  "targetAudience": "推荐的目标人群",
+  "style": "推荐风格",
+  "industry": "推荐行业领域",
+  "reason": "简要说明推荐理由"
+}`;
+
+    const messages: ChatMessage[] = [
+      { role: 'system', content: '你是一位专业的图书策划编辑,擅长根据书籍内容推荐合适的定位。' },
+      { role: 'user', content: prompt },
+    ];
+
+    const response = await callLLMWithMessages(messages);
+    let result;
+    try {
+      // 提取 JSON
+      const jsonMatch = response.match(/\{[\s\S]*\}/);
+      if (jsonMatch) {
+        result = JSON.parse(jsonMatch[0]);
+      } else {
+        result = defaultResult;
+      }
+    } catch (e) {
+      result = defaultResult;
+    }
+
+    ctx.body = { code: 0, message: 'success', data: result };
+  } catch (error) {
+    console.error('智能推荐失败:', error);
+    // 返回默认值而不是错误
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        knowledgeLevel: '入门',
+        targetAudience: '通用',
+        style: '轻松幽默',
+        industry: '教育',
+        reason: 'AI服务暂时不可用,使用默认推荐',
+      },
+    };
+  }
+});
+
 /**
  * POST /api/book-generator/langgraph/books
  * 使用 LangGraph 创建并生成书籍(异步,自动生成大纲和内容)
@@ -322,7 +397,8 @@ router.post('/books', async (ctx: Context) => {
 
     // 创建书籍(预估章节数,实际数量由AI根据字数范围分析后确定)
     const scaleConfig = getScaleConfig(bookScale);
-    const estimatedChapters = Math.round((scaleConfig.chapterRange.min + scaleConfig.chapterRange.max) / 2);
+    // 短文类至少1个章节
+    const estimatedChapters = scaleConfig.isShortArticle ? 1 : Math.round((scaleConfig.chapterRange.min + scaleConfig.chapterRange.max) / 2);
     const estimatedWords = Math.round((scaleConfig.wordRange.min + scaleConfig.wordRange.max) / 2);
     const book = await bookStore.create({
       title: body.title,
@@ -340,22 +416,32 @@ router.post('/books', async (ctx: Context) => {
         bookScale,
       });
       console.log(`[LangGraph] 生成任务已加入队列: bookId=${book.id}, jobId=${jobId}`);
-      
+
+      // 更新书籍状态为生成中(新状态系统)
+      await bookStore.update(book.id, {
+        genStage: 'outlining',  // 开始大纲生成阶段
+      });
+
       ctx.body = {
         code: 0,
         message: '书籍创建成功,生成已开始(队列模式)',
         data: {
           book,
           jobId,
-          status: 'generating',
+          genStage: 'outlining',
           mode: 'queue',
         },
       };
     } catch (queueError) {
       // 队列失败时,降级为同步执行(确保核心业务不受影响)
       console.warn(`[LangGraph] 队列不可用,降级为同步执行: bookId=${book.id}`, queueError);
-      
+
       try {
+        // 更新书籍状态为生成中
+        await bookStore.update(book.id, {
+          genStage: 'outlining',
+        });
+
         // 同步调用生成器,等待完成
         await langGraphGenerator.generate(book.id.toString(), body.description, bookScale);
         
@@ -366,16 +452,18 @@ router.post('/books', async (ctx: Context) => {
           message: '书籍创建成功,生成已完成(同步模式)',
           data: {
             book,
-            status: 'completed',
+            genStage: 'completed',
             mode: 'sync',
           },
         };
       } catch (generateError) {
         // 生成失败,更新书籍状态
         console.error(`[LangGraph] 同步执行失败: bookId=${book.id}`, generateError);
+        const errorMessage = generateError instanceof Error ? generateError.message : '生成失败';
         await bookStore.update(book.id.toString(), {
-          status: 'failed',
-          errorMsg: generateError instanceof Error ? generateError.message : '生成失败',
+          genStage: 'failed',
+          errorMsg: errorMessage,
+          failedStage: 'outlining',
         });
         
         ctx.body = {
@@ -383,7 +471,7 @@ router.post('/books', async (ctx: Context) => {
           message: '书籍创建成功,但生成失败',
           data: {
             book,
-            status: 'failed',
+            genStage: 'failed',
             mode: 'sync',
             error: generateError instanceof Error ? generateError.message : '生成失败',
           },
@@ -541,12 +629,12 @@ router.get('/books/:id/progress', async (ctx: Context) => {
     }
 
     // 计算实际进度(基于章节完成情况)
-    const completedChapters = book.chapters.filter((c) => c.status === 'completed').length;
+    const completedChapters = book.chapters.filter((c) => c.genStage === 'video_completed').length;
     const totalChapters = book.outline?.chapters?.length || book.totalChapters || 0;
 
     // 如果有大纲,使用大纲章节数计算进度
     let progress = book.progress;
-    if (totalChapters > 0 && book.status !== 'completed') {
+    if (totalChapters > 0 && book.genStage !== 'video_completed') {
       progress = Math.round((completedChapters / totalChapters) * 100);
     }
 
@@ -555,7 +643,7 @@ router.get('/books/:id/progress', async (ctx: Context) => {
       message: 'success',
       data: {
         bookId,
-        status: book.status,
+        genStage: book.genStage,
         progress,
         completedChapters,
         totalChapters,
@@ -604,15 +692,15 @@ router.post('/books/:id/generate', async (ctx: Context) => {
     }
 
     // 防止重复生成:如果书籍正在生成中,拒绝请求
-    if (book.status === 'generating') {
+    if (book.genStage === 'content_generating' || book.genStage === 'audio_generating' || book.genStage === 'video_generating') {
       console.log(`[LangGraph Generate] 书籍正在生成中,拒绝重复请求 bookId=${bookId}`);
       ctx.status = 400;
-      ctx.body = { 
-        code: 1, 
+      ctx.body = {
+        code: 1,
         message: '书籍正在生成中,请勿重复提交',
         data: {
           bookId,
-          status: book.status,
+          genStage: book.genStage,
           progress: book.progress,
         }
       };
@@ -623,41 +711,52 @@ router.post('/books/:id/generate', async (ctx: Context) => {
     const bookScale = body.bookScale || book.bookScale || '标准教程';
 
     // 尝试将生成任务加入队列(队列只是为了改善用户体验)
-    try {
-      const jobId = await queueService.addBookGenerationTask({
-        bookId,
-        topic: book.description,
-        bookScale,
-      });
-      
+    const jobId = await queueService.addBookGenerationTask({
+      bookId,
+      topic: book.description,
+      bookScale,
+    });
+
+    if (jobId) {
+      // 队列模式:更新状态并返回
       console.log(`[LangGraph] 生成任务已加入队列: bookId=${bookId}, jobId=${jobId}`);
-      
+
+      // 更新书籍状态为生成中
+      await bookStore.update(bookId, {
+        genStage: 'outlining',
+      });
+
       ctx.body = {
         code: 0,
         message: 'LangGraph 生成任务已启动(队列模式)',
         data: {
           bookId,
           jobId,
-          status: 'queued',
+          genStage: 'queued',
           mode: 'queue',
         },
       };
-    } catch (queueError) {
-      // 队列失败时,降级为同步执行(确保核心业务不受影响)
-      console.warn(`[LangGraph] 队列不可用,降级为同步执行: bookId=${bookId}`, queueError);
-      
+    } else {
+      // 队列不可用,降级为同步执行
+      console.warn(`[LangGraph] 队列不可用,降级为同步执行: bookId=${bookId}`);
+
+      // 更新书籍状态为生成中
+      await bookStore.update(bookId, {
+        genStage: 'outlining',
+      });
+
       try {
         // 同步调用生成器,等待完成
         await langGraphGenerator.generate(bookId, book.description, bookScale);
-        
+
         console.log(`[LangGraph] 同步执行完成: bookId=${bookId}`);
-        
+
         ctx.body = {
           code: 0,
           message: 'LangGraph 生成任务已完成(同步模式)',
           data: {
             bookId,
-            status: 'completed',
+            genStage: 'completed',
             mode: 'sync',
           },
         };
@@ -665,16 +764,17 @@ router.post('/books/:id/generate', async (ctx: Context) => {
         // 生成失败,更新书籍状态
         console.error(`[LangGraph] 同步执行失败: bookId=${bookId}`, generateError);
         await bookStore.update(bookId, {
-          status: 'failed',
+          genStage: 'failed',
+          failedStage: 'outlining',
           errorMsg: generateError instanceof Error ? generateError.message : '生成失败',
         });
-        
+
         ctx.body = {
           code: 0,
           message: 'LangGraph 生成任务失败',
           data: {
             bookId,
-            status: 'failed',
+            genStage: 'failed',
             mode: 'sync',
             error: generateError instanceof Error ? generateError.message : '生成失败',
           },
@@ -723,8 +823,8 @@ router.post('/books/:id/audio', async (ctx: Context) => {
     // 获取叶节点(最大level的所有节点)
     const leafNodes = chapters.filter((c: any) => c.level === maxLevel);
 
-    // 检查内容状态 - 叶节点必须有content且contentStatus为completed
-    const leafNodesWithContent = leafNodes.filter((c: any) => c.content && c.contentStatus === 'completed');
+    // 检查内容状态 - 叶节点必须有content且genStage为content_completed
+    const leafNodesWithContent = leafNodes.filter((c: any) => c.content && c.genStage === 'content_completed');
 
     if (leafNodes.length === 0) {
       ctx.body = { code: 1, message: `没有${maxLevel === 3 ? '小节' : maxLevel === 2 ? '节' : '章节'}` };
@@ -767,6 +867,104 @@ router.post('/books/:id/audio', async (ctx: Context) => {
   }
 });
 
+/**
+ * GET /api/book-generator/langgraph/books/:id/video-status
+ * 获取书籍视频生成状态(用于前端轮询)
+ */
+router.get('/books/:id/video-status', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const book = await bookStore.getById(bookId);
+
+    if (!book) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
+    // 获取章节树
+    const chapters = await bookStore.getChapterTree(bookId);
+
+    // 动态获取叶节点层级
+    const maxLevel = chapters.length > 0
+      ? Math.max(...chapters.map((c: any) => c.level || 0))
+      : 0;
+
+    // 获取叶节点
+    const leafNodes = chapters.filter((c: any) => c.level === maxLevel);
+
+    // 统计视频状态
+    const total = leafNodes.length;
+    const completed = leafNodes.filter((c: any) => c.videoUrl && c.videoUrl !== '').length;
+    const pending = leafNodes.filter((c: any) => !c.videoUrl || c.videoUrl === '').length;
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        bookId,
+        total,
+        completed,
+        pending,
+        allCompleted: completed >= total && total > 0,
+      },
+    };
+  } catch (error) {
+    console.error('获取视频状态失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '获取失败' };
+  }
+});
+
+/**
+ * GET /api/book-generator/langgraph/books/:id/audio-status
+ * 获取书籍音频生成状态(用于前端轮询)
+ */
+router.get('/books/:id/audio-status', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const book = await bookStore.getById(bookId);
+
+    if (!book) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
+    // 获取章节树
+    const chapters = await bookStore.getChapterTree(bookId);
+
+    // 动态获取叶节点层级
+    const maxLevel = chapters.length > 0
+      ? Math.max(...chapters.map((c: any) => c.level || 0))
+      : 0;
+
+    // 获取叶节点
+    const leafNodes = chapters.filter((c: any) => c.level === maxLevel);
+
+    // 统计音频状态
+    const total = leafNodes.length;
+    const completed = leafNodes.filter((c: any) => c.audioUrl && c.audioUrl !== '').length;
+    const pending = leafNodes.filter((c: any) => !c.audioUrl || c.audioUrl === '').length;
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        bookId,
+        total,
+        completed,
+        pending,
+        allCompleted: completed >= total && total > 0,
+      },
+    };
+  } catch (error) {
+    console.error('获取音频状态失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '获取失败' };
+  }
+});
+
 /**
  * GET /api/book-generator/langgraph/books/:id/failed-chapters
  * 获取生成失败的小节列表
@@ -783,7 +981,7 @@ router.get('/books/:id/failed-chapters', async (ctx: Context) => {
 
     // 获取所有失败的小节
     const chapters = await bookStore.getChapterTree(bookId);
-    const failedSubsections = chapters.filter(c => c.status === 'failed');
+    const failedSubsections = chapters.filter(c => c.genStage === 'failed');
 
     ctx.body = {
       code: 0,
@@ -822,14 +1020,14 @@ router.post('/books/:id/resume', async (ctx: Context) => {
       return;
     }
 
-    if (book.status === 'completed') {
+    if (book.genStage === 'video_completed') {
       ctx.body = { code: 1, message: '书籍已生成完成,无需继续' };
       return;
     }
 
     // 获取失败的小节
     const chapters = await bookStore.getChapterTree(bookId);
-    const failedSubsections = chapters.filter(c => c.status === 'failed');
+    const failedSubsections = chapters.filter(c => c.genStage === 'failed');
 
     if (failedSubsections.length === 0) {
       ctx.body = { code: 1, message: '没有失败的小节需要重试' };
@@ -839,7 +1037,7 @@ router.post('/books/:id/resume', async (ctx: Context) => {
     // 重置失败小节的状态为 pending
     for (const sub of failedSubsections) {
       await bookStore.updateChapterById(sub.id, {
-        status: 'pending',
+        genStage: 'idle',
         errorMsg: null,
         content: null,
       });
@@ -860,7 +1058,7 @@ router.post('/books/:id/resume', async (ctx: Context) => {
       data: {
         bookId,
         retryCount: failedSubsections.length,
-        status: 'resuming',
+        genStage: 'resuming',
       },
     };
   } catch (error) {
@@ -896,10 +1094,9 @@ router.post('/books/:id/retry-chapter', async (ctx: Context) => {
 
     // 重置章节状态
     await bookStore.updateChapterById(chapterId, {
-      status: 'pending',
+      genStage: 'idle',
       errorMsg: null,
       content: null,
-      contentStatus: 'pending',
     });
 
     ctx.body = {
@@ -940,7 +1137,7 @@ router.get('/books/:id/full-content', async (ctx: Context) => {
 
     // 递归添加章节内容
     const addChapterContent = (chapter: any, indent: string = '') => {
-      if (chapter.content && chapter.contentStatus === 'completed') {
+      if (chapter.content && chapter.genStage === 'content_completed') {
         fullContent += `${indent}${chapter.number}. ${chapter.title}\n\n`;
         fullContent += chapter.content + '\n\n';
       }
@@ -1002,7 +1199,7 @@ router.post('/books/:id/outline', async (ctx: Context) => {
     const scaleConfig = getScaleConfig(book.bookScale || '标准教程');
 
     // 调用AI生成大纲
-    const { generateOutline } = await import('./index');
+    const { generateOutline } = await import('./index.js');
     const outline = await generateOutline(bookId, book.description, book.bookScale || '标准教程');
 
     // 更新书籍的大纲
@@ -1090,8 +1287,6 @@ router.post('/books/:id/foreword', async (ctx: Context) => {
     }
 
     // 调用LLM生成前言
-    const { callLLMWithMessages, ChatMessage } = await import('../../services/llm');
-
     const messages: ChatMessage[] = [
       {
         role: 'system',
@@ -1118,10 +1313,8 @@ router.post('/books/:id/foreword', async (ctx: Context) => {
 
     const foreword = await callLLMWithMessages(messages);
 
-    // 更新书籍元数据
-    const metadata = book.metadata || {};
-    (metadata as any).foreword = foreword;
-    await bookStore.update(bookId, { metadata });
+    // 更新书籍前言字段
+    await bookStore.update(bookId, { foreword });
 
     ctx.body = {
       code: 0,
@@ -1151,8 +1344,6 @@ router.post('/books/:id/afterword', async (ctx: Context) => {
     }
 
     // 调用LLM生成后记
-    const { callLLMWithMessages, ChatMessage } = await import('../../services/llm');
-
     const messages: ChatMessage[] = [
       {
         role: 'system',
@@ -1179,10 +1370,8 @@ router.post('/books/:id/afterword', async (ctx: Context) => {
 
     const afterword = await callLLMWithMessages(messages);
 
-    // 更新书籍元数据
-    const metadata = book.metadata || {};
-    (metadata as any).afterword = afterword;
-    await bookStore.update(bookId, { metadata });
+    // 更新书籍后记字段
+    await bookStore.update(bookId, { afterword });
 
     ctx.body = {
       code: 0,
@@ -1215,14 +1404,14 @@ router.get('/workflow/:bookId', async (ctx: Context) => {
     const chapters = await bookStore.getChapterTree(bookId);
 
     // 分析工作流状态
-    const completedChapters = chapters.filter((c: any) => c.status === 'completed').length;
-    const failedChapters = chapters.filter((c: any) => c.status === 'failed').length;
-    const pendingChapters = chapters.filter((c: any) => c.status === 'pending').length;
-    const generatingChapters = chapters.filter((c: any) => c.status === 'generating').length;
+    const completedChapters = chapters.filter((c: any) => ['content_completed', 'audio_completed', 'video_completed'].includes(c.genStage)).length;
+    const failedChapters = chapters.filter((c: any) => c.genStage === 'failed').length;
+    const pendingChapters = chapters.filter((c: any) => c.genStage === 'idle' || !c.genStage).length;
+    const generatingChapters = chapters.filter((c: any) => ['content_generating', 'audio_generating', 'video_generating'].includes(c.genStage)).length;
 
     // 确定当前阶段
     let phase: 'planning' | 'writing' | 'supplement' | 'done' = 'planning';
-    if (book.status === 'generating') {
+    if (['content_generating', 'audio_generating', 'video_generating'].includes(book.genStage)) {
       phase = 'writing';
     } else if (completedChapters > 0 && failedChapters === 0) {
       phase = 'done';
@@ -1235,7 +1424,7 @@ router.get('/workflow/:bookId', async (ctx: Context) => {
       message: 'success',
       data: {
         bookId,
-        status: book.status,
+        genStage: book.genStage,
         phase,
         progress: book.progress || 0,
         completedChapters,
@@ -1243,7 +1432,7 @@ router.get('/workflow/:bookId', async (ctx: Context) => {
         pendingChapters,
         generatingChapters,
         totalChapters: chapters.length,
-        failedChapterIds: chapters.filter((c: any) => c.status === 'failed').map((c: any) => c.id),
+        failedChapterIds: chapters.filter((c: any) => c.genStage === 'failed').map((c: any) => c.id),
       },
     };
   } catch (error) {
@@ -1297,8 +1486,7 @@ router.get('/books/:id/chapters/:chapterId/content', optionalAuth, async (ctx: C
         title: chapter.title,
         content: chapter.content || '',
         wordCount: chapter.wordCount || 0,
-        status: chapter.status,
-        contentStatus: (chapter as any).contentStatus || 'pending',
+        genStage: chapter.genStage || null,
         level: chapter.level,
         parentId: chapter.parentId,
         summary: chapter.summary || '',
@@ -1355,7 +1543,6 @@ router.post('/books/:id/chapters/:chapterId/content', optionalAuth, async (ctx:
     await bookStore.updateChapterById(chapterIdNum, {
       content,
       wordCount,
-      contentStatus: 'completed',
     });
 
     ctx.body = {
@@ -1396,8 +1583,7 @@ router.post('/books/:id/chapters/batch-regenerate', optionalAuth, async (ctx: Co
     // 重置并触发每个选中章节的重新生成
     for (const chapterId of chapterIds) {
       await bookStore.updateChapterById(chapterId, {
-        status: 'pending',
-        contentStatus: 'pending',
+        genStage: 'idle',
         content: null,
       });
       // 后台异步执行
@@ -1511,10 +1697,9 @@ router.post('/books/:id/chapters/:chapterId/regenerate', async (ctx: Context) =>
 
     // 重置章节状态和内容
     await bookStore.updateChapterById(chapterIdNum, {
-      status: 'pending',
+      genStage: 'idle',
       errorMsg: null,
       content: null,
-      contentStatus: 'pending',
     });
 
     // 直接调用单个章节内容生成(不触发整个书籍生成流程)
@@ -1572,9 +1757,8 @@ router.post('/books/:id/chapters/:chapterId/regenerate-audio', async (ctx: Conte
       return;
     }
 
-    // 检查内容是否已完成
-    const chapterAny = chapter as any;
-    if (!chapter.content || chapterAny.contentStatus !== 'completed') {
+    // 检查内容是否已完成(允许 content_completed/audio_completed/failed 等状态,只要有内容就能重生成音频)
+    if (!chapter.content || chapter.genStage === 'idle' || chapter.genStage === 'content_generating') {
       ctx.status = 400;
       ctx.body = { code: 1, message: '章节内容未生成完成,无法生成音频' };
       return;
@@ -1626,19 +1810,36 @@ router.post('/books/:id/chapters/:chapterId/video', async (ctx: Context) => {
       return;
     }
 
+    // 设置视频状态为生成中
+    await advanceChapter(chapterIdNum, 'video_generating');
+
     // 调用视频服务创建视频项目
     const project = await createVideoProjectFromBook(parseInt(bookId), chapterIdNum);
 
     if (!project) {
+      await regenerateChapter(chapterIdNum, 'failed');
       ctx.status = 400;
       ctx.body = { code: 1, message: '创建视频项目失败' };
       return;
     }
 
     // 异步生成视频
-    generateVideoForProject(project.id).catch(err => {
-      console.error(`[Video] 章节${chapter.number}视频生成失败:`, err);
-    });
+    generateVideoForProject(project.id)
+      .then(async (result) => {
+        if (result.success) {
+          await bookStore.updateChapterById(chapterIdNum, {
+            videoUrl: result.outputUrl,
+            videoDuration: result.duration,
+          });
+          await advanceChapter(chapterIdNum, 'video_completed');
+        } else {
+          await regenerateChapter(chapterIdNum, 'failed');
+        }
+      })
+      .catch(async (err) => {
+        console.error(`[Video] 章节${chapter.number}视频生成失败:`, err);
+        await regenerateChapter(chapterIdNum, 'failed');
+      });
 
     ctx.body = {
       code: 0,
@@ -1715,11 +1916,14 @@ router.post('/books/:id/videos', async (ctx: Context) => {
     console.log(`[Video] 开始批量生成视频: bookId=${bookId}, taskId=${taskId}, 总数=${leafNodesWithAudio.length}`);
 
     for (const sub of leafNodesWithAudio) {
+      // 设置视频状态为生成中
+      await advanceChapter(sub.id, 'video_generating').catch(() => {});
+
       // 异步处理每个章节的视频生成
       (async () => {
         try {
           console.log(`[Video] 开始生成章节 ${sub.number} 的视频: ${sub.title}`);
-          
+
           // 从书籍章节创建视频项目
           const project = await createVideoProjectFromBook(
             parseInt(bookId),
@@ -1729,6 +1933,7 @@ router.post('/books/:id/videos', async (ctx: Context) => {
 
           if (!project) {
             console.error(`[Video] 章节 ${sub.number} 视频项目创建失败`);
+            await regenerateChapter(sub.id, 'failed').catch(() => {});
             return;
           }
 
@@ -1738,17 +1943,17 @@ router.post('/books/:id/videos', async (ctx: Context) => {
           const result = await generateVideoForProject(project.id);
 
           if (result.success && result.outputUrl) {
-            // 更新章节的视频URL
             await bookStore.updateChapterById(sub.id, {
               videoUrl: result.outputUrl,
               videoDuration: result.duration,
             });
-            console.log(`[Video] 章节 ${sub.number} 视频生成成功: ${result.outputUrl}`);
+            await advanceChapter(sub.id, 'video_completed');
           } else {
-            console.error(`[Video] 章节 ${sub.number} 视频生成失败:`, result.error);
+            await regenerateChapter(sub.id, 'failed');
           }
         } catch (error) {
           console.error(`[Video] 章节 ${sub.number} 视频生成异常:`, error);
+          await regenerateChapter(sub.id, 'failed');
         }
       })();
     }
@@ -2074,4 +2279,40 @@ async function mergeChapterVideos(parentChapter: any, childChapters: any[], user
   }
 }
 
+/**
+ * DEBUG: 手动触发章节音频生成(强制从content_completed重新开始)
+ * POST /api/book-generator/debug/trigger-audio
+ */
+router.post('/debug/trigger-audio', async (ctx: Context) => {
+  try {
+    const { chapterId } = ctx.request.body as { chapterId?: number };
+    if (!chapterId) {
+      ctx.status = 400;
+      ctx.body = { code: 1, message: '缺少 chapterId' };
+      return;
+    }
+
+    // 先检查chapter状态,如果是audio_generating但没有audioUrl,说明之前的生成卡住了
+    const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
+    if (chapter?.genStage === 'audio_generating' && !chapter?.audioUrl) {
+      console.log(`[DEBUG] 章节${chapterId}卡在audio_generating无audioUrl,重置到content_completed重新生成`);
+      await prisma.bookChapter.update({ where: { id: chapterId }, data: { genStage: 'content_completed' } });
+    }
+
+    console.log(`[DEBUG] 手动触发章节${chapterId}音频生成`);
+    await bookStore.generateChapterAudioById(chapterId);
+
+    const updated = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
+    ctx.body = {
+      code: 0,
+      message: '音频生成已触发',
+      data: { chapterId, genStage: updated?.genStage, audioUrl: updated?.audioUrl },
+    };
+  } catch (error) {
+    console.error('[DEBUG] 触发音频失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '触发失败' };
+  }
+});
+
 export default router;

+ 66 - 11
server/src/modules/book-generator/nodes/content.node.ts

@@ -11,19 +11,44 @@ import { createBookTools } from '../../../services/llm/book-tools';
 import { checkQuotaForWords, markGenerationInterrupted, getGeneratedWordCount } from '../../subscription/subscription.service';
 import { SUBSECTION_CONTENT_SYSTEM_PROMPT } from '../prompts/templates';
 import { PROGRESS, countWords } from '../utils';
+import { advanceChapter, regenerateChapter } from '../stage-manager';
 
 /**
- * 清理内容中的思考标签
+ * 清理内容中的思考标签和无关文本
  */
 function cleanContent(content: string): string {
-  return content
+  let cleaned = content
     .replace(/<[^>]*>/g, '') // 移除 HTML 标签
     .replace(/\[inkleb\]:[\s\S]*?\[\/inkleb\]/gi, '') // 移除 inkleb 思考块
     .replace(/<think>[\s\S]*?<\/think>/gi, '') // 移除思考块
-    .replace(/<think>[\s\S]*?/gi, '') // 移除未闭合的思考块开始
+    .replace(/<think>[\s\S]*/gi, '') // 移除未闭合的思考块开始
     .replace(/<\/think>/gi, '') // 移除未闭合的思考块结束
     .replace(/\n{3,}/g, '\n\n') // 压缩多余换行
     .trim();
+
+  // 如果内容以"好的"、"我现在"等思考性语言开头,说明包含了LLM的思考过程
+  // 需要提取正文部分(通常从标题 # 开始)
+  const thinkingPatterns = [
+    /^好的[,,。].*?(?=\n#)/s,
+    /^我现在.*?(?=\n#)/s,
+    /^让我开始撰写正文[::].*?(?=\n#)/s,
+    /^让我开始撰写[::].*?(?=\n#)/s,
+    /^我来撰写[::].*?(?=\n#)/s,
+    /^现在我来撰写[::].*?(?=\n#)/s,
+  ];
+
+  for (const pattern of thinkingPatterns) {
+    cleaned = cleaned.replace(pattern, '');
+  }
+
+  // 如果正文以 markdown 标题开始,尝试提取从第一个 # 开始的内容
+  const titleMatch = cleaned.match(/\n(# .+)/);
+  if (titleMatch && titleMatch.index && titleMatch.index > 100) {
+    // 如果第一个标题前有超过100字符的内容,可能是思考过程,截取标题后的内容
+    cleaned = cleaned.substring(titleMatch.index).trim();
+  }
+
+  return cleaned;
 }
 
 /**
@@ -37,9 +62,16 @@ function buildSubsectionContentMessages(
   sectionSummary: string,
   subsection: any
 ): ChatMessage[] {
-  const keyPoints = typeof subsection.keyPoints === 'string'
-    ? JSON.parse(subsection.keyPoints)
-    : (subsection.keyPoints || []);
+  let keyPoints: string[] = [];
+  if (typeof subsection.keyPoints === 'string' && subsection.keyPoints) {
+    try {
+      keyPoints = JSON.parse(subsection.keyPoints);
+    } catch {
+      keyPoints = [];
+    }
+  } else if (Array.isArray(subsection.keyPoints)) {
+    keyPoints = subsection.keyPoints;
+  }
 
   return [
     { role: 'system', content: SUBSECTION_CONTENT_SYSTEM_PROMPT },
@@ -86,7 +118,7 @@ export async function writeChaptersNode(state: typeof GraphState.State): Promise
   const leafNodes = allChapters.filter(c => !allParentIds.has(c.id));
 
   // 过滤出未完成内容的叶节点
-  const targetsToGenerate = leafNodes.filter((n: any) => n.contentStatus !== 'completed' && !n.content);
+  const targetsToGenerate = leafNodes.filter((n: any) => n.genStage !== 'content_completed' && !n.content);
   const alreadyCompleted = leafNodes.length - targetsToGenerate.length;
 
   if (alreadyCompleted > 0) {
@@ -95,9 +127,20 @@ export async function writeChaptersNode(state: typeof GraphState.State): Promise
 
   if (targetsToGenerate.length === 0) {
     console.log('[LangGraph] ✅ 所有内容已完成,跳过内容生成');
+
+    // 对所有 content_completed 的叶节点触发音频生成
+    for (const leaf of leafNodes) {
+      if (leaf.genStage === 'content_completed') {
+        bookStore.generateChapterAudioById(leaf.id).catch(err => {
+          console.error(`[LangGraph] 跳过分支触发音频失败 chapterId=${leaf.id}:`, err.message);
+        });
+      }
+    }
+
     return { finished: true, progress: PROGRESS.CONTENT_END };
   }
 
+  // 正常生成内容流程
   console.log(`[LangGraph] 共 ${leafNodes.length} 个叶节点,${targetsToGenerate.length} 个待生成`);
 
   // 构建父节点映射(用于长篇书籍)
@@ -176,6 +219,9 @@ export async function writeChaptersNode(state: typeof GraphState.State): Promise
     }
 
     try {
+      // 1. 先推进到 content_generating
+      await advanceChapter(target.id, 'content_generating');
+
       let content: string;
       try {
         const result = await callLLMWithTools(messages, bookTools);
@@ -192,22 +238,29 @@ export async function writeChaptersNode(state: typeof GraphState.State): Promise
       currentWordCount += wordCount;
       completedCount++;
 
+      // 2. 更新内容
       await bookStore.updateChapterById(target.id, {
         content,
         wordCount,
-        contentStatus: 'completed'
+      });
+      // 3. 推进到 content_completed
+      await advanceChapter(target.id, 'content_completed');
+
+      // 内容生成成功后,自动触发音频生成
+      bookStore.generateChapterAudioById(target.id).catch(err => {
+        console.error(`[Content] 自动生成音频失败 chapterId=${target.id}:`, err.message);
       });
 
       const progress = Math.round((completedCount / totalTargets) * (PROGRESS.CONTENT_END - PROGRESS.CONTENT_START)) + PROGRESS.CONTENT_START;
-      await bookStore.update(state.bookId, { progress, status: 'generating' });
+      await bookStore.update(state.bookId, { progress });
       console.log(`[LangGraph] 「${target.title}」内容完成 (${completedCount}/${totalTargets}),累计${currentWordCount}字,进度${progress}%`);
 
     } catch (error) {
       const errorMsg = error instanceof Error ? error.message : '失败';
       await bookStore.updateChapterById(target.id, {
-        contentStatus: 'failed',
         contentError: errorMsg
       });
+      await regenerateChapter(target.id, 'failed');
       failedChapters.push(target.number);
       console.error(`[LangGraph] 「${target.title}」内容失败:`, errorMsg);
     }
@@ -216,7 +269,9 @@ export async function writeChaptersNode(state: typeof GraphState.State): Promise
   // 更新所有父节和章的状态
   await prisma.bookChapter.updateMany({
     where: { bookId: bookIdNum, level: { in: [1, 2] } },
-    data: { status: 'completed' }
+    data: {
+      genStage: 'content_completed',
+    }
   });
 
   return {

+ 29 - 12
server/src/modules/book-generator/nodes/outline.node.ts

@@ -36,18 +36,29 @@ export async function generateOutlineNode(state: typeof GraphState.State): Promi
     const outline = parseOutline(response);
     if (!outline) throw new Error('大纲解析失败');
 
+    // 短文类至少1个章节
+    if (outline.chapters.length === 0) {
+      console.warn('[LangGraph] AI 返回章节数为0,创建默认章节');
+      outline.chapters = [{
+        number: 1,
+        title: state.topic?.substring(0, 50) || '正文',
+        summary: '',
+        keyPoints: [],
+        estimatedWords: 800,
+      }];
+    }
+
     const totalChapters = outline.chapters.length;
-    await bookStore.update(state.bookId, { 
-      totalChapters, 
-      outlineJson: JSON.stringify(outline), 
-      status: 'planning', 
-      progress: PROGRESS.OUTLINE_DONE 
+    await bookStore.update(state.bookId, {
+      totalChapters,
+      outlineJson: JSON.stringify(outline),
+      progress: PROGRESS.OUTLINE_DONE
     });
     await bookStore.createChapters(state.bookId, outline.chapters.map(c => ({
-      number: c.number, 
-      title: c.title, 
-      summary: c.summary, 
-      keyPoints: c.keyPoints, 
+      number: c.number,
+      title: c.title,
+      summary: c.summary,
+      keyPoints: c.keyPoints,
       estimatedWords: c.estimatedWords,
     })));
 
@@ -55,9 +66,15 @@ export async function generateOutlineNode(state: typeof GraphState.State): Promi
     return { progress: PROGRESS.OUTLINE_DONE };
   } catch (error) {
     console.error('[LangGraph] 大纲生成失败:', error);
-    return { 
-      error: error instanceof Error ? error.message : '失败', 
-      finished: true 
+    // 更新书籍状态为失败
+    await bookStore.update(state.bookId, {
+      genStage: 'failed',
+      failedStage: 'outlining',
+      errorMsg: error instanceof Error ? error.message : '大纲生成失败',
+    });
+    return {
+      error: error instanceof Error ? error.message : '大纲生成失败',
+      finished: true
     };
   }
 }

+ 2 - 6
server/src/modules/book-generator/prompts/templates.ts

@@ -59,8 +59,7 @@ export const CHAPTER_SYSTEM_PROMPT = `你是一位专业的书籍作者,擅长
 1. 先调用 get_book_outline 了解整体结构
 2. 如果不是第一章,调用 get_existing_chapters 查看前几章内容,避免重复
 3. 正文要有深度,不能只是列举要点,要有解释、案例、分析
-4. 字数尽量达到预估字数要求
-5. 直接输出正文内容,不要输出任何 JSON 或 markdown 格式说明`;
+4. 字数尽量达到预估字数要求`;
 
 /**
  * 节大纲生成系统提示词
@@ -126,7 +125,6 @@ export const FOREWORD_SYSTEM_PROMPT = `你是一位资深作家,擅长撰写
 
 ## 格式要求
 - 直接输出正文,不要加"前言"标题
-- 不要使用 markdown 格式标记(不加 #、**、- 等)
 - 不要在结尾写"希望读者..."之类的客套话
 - 直接开始叙述,第一句就要有吸引力`;
 
@@ -147,7 +145,6 @@ export const AFTERWORD_SYSTEM_PROMPT = `你是一位资深作家,擅长撰写
 
 ## 格式要求
 - 直接输出正文,不要加"后记"标题
-- 不要使用 markdown 格式标记
 - 不要写"感谢读者"之类的套话
 - 结尾要有力量感,可以是金句、问题或开放性思考`;
 
@@ -169,5 +166,4 @@ export const SUBSECTION_CONTENT_SYSTEM_PROMPT = `你是一位专业的书籍作
 1. 先调用 get_book_outline 了解整体结构
 2. 调用 get_existing_chapters 查看前几章内容,避免重复
 3. 正文要有深度,不能只是列举要点,要有解释、案例、分析
-4. 字数尽量达到预估字数要求
-5. 直接输出正文内容,不要输出任何 JSON 或 markdown 格式标记`;
+4. 字数尽量达到预估字数要求`;

+ 201 - 0
server/src/modules/book-generator/stage-manager.ts

@@ -0,0 +1,201 @@
+/**
+ * 状态系统 - 阶段管理器
+ * 线性阶段模型:统一的状态字段 + 阶段索引比较
+ */
+
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+// ============ 类型定义 ============
+
+/** 书籍生成阶段 */
+export type BookGenStage =
+  | 'draft'
+  | 'outlining'
+  | 'outline_completed'
+  | 'content_generating'
+  | 'content_completed'
+  | 'audio_generating'
+  | 'audio_completed'
+  | 'video_generating'
+  | 'video_completed'
+  | 'failed';
+
+/** 章节生成阶段 */
+export type ChapterGenStage =
+  | 'idle'
+  | 'outline_completed'
+  | 'content_generating'
+  | 'content_completed'
+  | 'audio_generating'
+  | 'audio_completed'
+  | 'video_generating'
+  | 'video_completed'
+  | 'failed';
+
+// ============ 阶段索引(用于比较大小) ============
+
+const CHAPTER_STAGE_ORDER: ChapterGenStage[] = [
+  'idle',                // 0
+  'outline_completed',   // 1
+  'content_generating',  // 2
+  'content_completed',   // 3
+  'audio_generating',    // 4
+  'audio_completed',     // 5
+  'video_generating',    // 6
+  'video_completed',     // 7
+  'failed',              // 8(终态)
+];
+
+export function chapterStageIndex(s: ChapterGenStage): number {
+  return CHAPTER_STAGE_ORDER.indexOf(s);
+}
+
+// ============ 章节转移矩阵 ============
+
+const CHAPTER_VALID_TRANSITIONS: Record<ChapterGenStage, ChapterGenStage[]> = {
+  'idle': ['content_generating', 'failed'],
+  'outline_completed': ['content_generating', 'failed'],
+  'content_generating': ['content_completed', 'failed'],
+  'content_completed': ['audio_generating', 'audio_completed', 'content_generating', 'failed'],
+  'audio_generating': ['audio_completed', 'content_completed', 'failed'],  // 允许音频失败回退到 content_completed
+  'audio_completed': ['video_generating', 'audio_generating', 'content_generating', 'failed'],
+  'video_generating': ['video_completed', 'failed'],
+  'video_completed': ['video_generating', 'audio_generating', 'content_generating', 'failed'],
+  'failed': ['content_generating', 'audio_generating', 'video_generating', 'idle'],
+};
+
+// ============ 资源清理规则 ============
+
+/**
+ * 判断本次章节转移是否需要清理下游资源。
+ * 如果目标阶段比某资源的阶段索引更小,则清除该资源。
+ */
+export function getCleanupForChapterRegression(
+  targetStage: ChapterGenStage,
+): { audioUrl?: null; audioDuration?: number; videoUrl?: null; videoDuration?: number | null } {
+  const targetIdx = chapterStageIndex(targetStage);
+  const audioStageIdx = chapterStageIndex('audio_completed');    // 4
+  const videoStageIdx = chapterStageIndex('video_completed');    // 6
+
+  const clean: any = {};
+  if (targetIdx < audioStageIdx) {
+    clean.audioUrl = null;
+    clean.audioDuration = 0;
+  }
+  if (targetIdx < videoStageIdx) {
+    clean.videoUrl = null;
+    clean.videoDuration = null;
+  }
+  return clean;
+}
+
+// ============ 安全转移函数 ============
+
+/**
+ * 安全转移章节状态
+ * 验证转移矩阵 + 乐观锁 + 资源清理
+ */
+export async function safeTransitionChapter(
+  chapterId: number,
+  currentStage: ChapterGenStage,
+  targetStage: ChapterGenStage,
+): Promise<void> {
+  // ① 验证是否在转移矩阵中
+  const allowed = CHAPTER_VALID_TRANSITIONS[currentStage];
+  if (!allowed || !allowed.includes(targetStage)) {
+    throw new Error(
+      `非法状态转移:Chapter ${chapterId} 当前 ${currentStage},` +
+      `不允许转到 ${targetStage}。允许的转移:[${allowed?.join(', ') || '无'}]`
+    );
+  }
+
+  // ② 判断是否需要清理下游资源
+  const cleanData = getCleanupForChapterRegression(targetStage);
+
+  // ③ 乐观锁写入
+  const result = await prisma.bookChapter.updateMany({
+    where: { id: chapterId, genStage: currentStage },
+    data: { genStage: targetStage, ...cleanData },
+  });
+
+  if (result.count === 0) {
+    // 如果状态已经被其他操作改变,查询当前实际状态
+    const currentChapter = await prisma.bookChapter.findUnique({
+      where: { id: chapterId },
+      select: { genStage: true }
+    });
+    const actualStage = currentChapter?.genStage || 'unknown';
+
+    // 如果目标阶段和实际阶段相同,说明已经转移过了,这不是错误
+    if (actualStage === targetStage) {
+      console.log(`[safeTransition] 章节 ${chapterId} 已处于 ${targetStage},跳过`);
+      return;
+    }
+
+    // 如果是回退到 failed 状态(可能之前已经失败了),也跳过
+    if (currentStage === 'failed' && targetStage !== 'idle') {
+      console.log(`[safeTransition] 章节 ${chapterId} 已在 failed 状态,跳过转移到 ${targetStage}`);
+      return;
+    }
+
+    // 其他情况:记录警告但不崩溃
+    console.warn(`[safeTransition] 章节 ${chapterId} 状态冲突:预期 ${currentStage},实际 ${actualStage},目标 ${targetStage}`);
+    return;
+  }
+}
+
+/**
+ * 安全转移书籍状态
+ */
+// ============ 上层封装 ============
+
+/**
+ * 前进:推进到下一个阶段。只能前进不能回退。
+ * 例:content_completed → audio_generating
+ */
+export async function advanceChapter(
+  chapterId: number,
+  targetStage: ChapterGenStage,
+): Promise<void> {
+  const chapter = await prisma.bookChapter.findUniqueOrThrow({
+    where: { id: chapterId },
+  });
+
+  const cur = chapter.genStage as ChapterGenStage;
+  const curIdx = chapterStageIndex(cur);
+  const tgtIdx = chapterStageIndex(targetStage);
+
+  if (tgtIdx <= curIdx) {
+    // 不允许回退或同级跳转(由 regenerateChapter 处理)
+    throw new Error(
+      `advanceChapter 不能用于回退:当前 ${cur},目标 ${targetStage}。` +
+      `请使用 regenerateChapter()`
+    );
+  }
+
+  await safeTransitionChapter(chapterId, cur, targetStage);
+}
+
+/**
+ * 重新生成:回退到上游阶段(或同级 generating 状态)以重新生成。
+ * 自动清除下游资源。
+ * 例:audio_completed → audio_generating(重新生成音频)
+ *     audio_completed → content_generating(从内容重新开始)
+ *     failed → audio_generating(失败重试)
+ */
+export async function regenerateChapter(
+  chapterId: number,
+  targetStage: ChapterGenStage,
+): Promise<void> {
+  const chapter = await prisma.bookChapter.findUniqueOrThrow({
+    where: { id: chapterId },
+  });
+
+  const cur = chapter.genStage as ChapterGenStage;
+  await safeTransitionChapter(chapterId, cur, targetStage);
+}
+
+// ============ Book 联动回退逻辑 ============
+

+ 53 - 0
server/src/modules/feedback/feedback.controller.ts

@@ -0,0 +1,53 @@
+import Router from '@koa/router';
+import { feedbackService, FeedbackInput } from './feedback.service';
+
+const router = new Router();
+
+/**
+ * 提交反馈
+ * POST /api/feedback
+ */
+router.post('/', async (ctx) => {
+  try {
+    const { type, title, content, contact, screenshotUrls } = ctx.request.body as FeedbackInput;
+
+    // 参数验证
+    if (!type || !['suggestion', 'bug', 'other'].includes(type)) {
+      ctx.status = 400;
+      ctx.body = { code: 400, message: 'type must be one of: suggestion, bug, other' };
+      return;
+    }
+    if (!title || typeof title !== 'string' || title.trim().length === 0) {
+      ctx.status = 400;
+      ctx.body = { code: 400, message: 'title is required' };
+      return;
+    }
+    if (!content || typeof content !== 'string' || content.trim().length === 0) {
+      ctx.status = 400;
+      ctx.body = { code: 400, message: 'content is required' };
+      return;
+    }
+
+    const result = await feedbackService.submitFeedback({
+      type,
+      title: title.trim(),
+      content: content.trim(),
+      contact: contact?.trim(),
+      screenshotUrls,
+    });
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: result,
+    };
+  } catch (error: any) {
+    ctx.status = 500;
+    ctx.body = {
+      code: 500,
+      message: error.message || '提交反馈失败',
+    };
+  }
+});
+
+export default router;

+ 40 - 0
server/src/modules/feedback/feedback.service.ts

@@ -0,0 +1,40 @@
+import { PrismaClient } from '@prisma/client';
+import { v4 as uuidv4 } from 'uuid';
+
+const prisma = new PrismaClient();
+
+export interface FeedbackInput {
+  type: 'suggestion' | 'bug' | 'other';
+  title: string;
+  content: string;
+  contact?: string;
+  screenshotUrls?: string[];
+}
+
+export interface FeedbackResult {
+  id: string;
+  status: 'submitted';
+}
+
+export const feedbackService = {
+  /**
+   * 提交反馈
+   */
+  async submitFeedback(input: FeedbackInput): Promise<FeedbackResult> {
+    const id = uuidv4();
+
+    await prisma.feedback.create({
+      data: {
+        id,
+        type: input.type,
+        title: input.title,
+        content: input.content,
+        contact: input.contact || '',
+        screenshotUrls: JSON.stringify(input.screenshotUrls || []),
+        status: 'submitted',
+      },
+    });
+
+    return { id, status: 'submitted' };
+  },
+};

+ 1 - 0
server/src/modules/history/history.controller.ts

@@ -45,6 +45,7 @@ router.get('/', optionalAuth, async (ctx: Context) => {
     voiceParams: r.voiceParams ? JSON.parse(r.voiceParams) : { speed: 1, pitch: 0, volume: 50 },
     status: r.status,
     isFavorite: false,
+    bookId: r.bookId,
     createdAt: r.createdAt.toISOString(),
     updatedAt: r.updatedAt.toISOString(),
   }));

+ 27 - 0
server/src/modules/player/player.controller.ts

@@ -106,6 +106,31 @@ router.delete('/progress/batch', optionalAuth, async (ctx: Context) => {
   };
 });
 
+// ============ OPT-03: 首页最近收听 ============
+
+// 获取用户最近播放记录
+router.get('/recent', optionalAuth, async (ctx: Context) => {
+  const userId = ctx.state.user?.userId;
+
+  // 未登录用户返回空列表
+  if (!userId) {
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { list: [] },
+    };
+    return;
+  }
+
+  const records = await PlayerService.getRecentPlayRecords(userId, 10);
+
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: { list: records },
+  };
+});
+
 // ============ 临时 API:为播放器页面适配书籍章节音频 ============
 
 // 获取播放列表(适配旧播放器)- 必须放在 /:id 之前
@@ -190,6 +215,7 @@ router.get('/audio/list', optionalAuth, async (ctx: Context) => {
       isPublic: chapter.isPublic,
       isOwner: chapter.book?.userId === parseInt(userId),
       level: chapter.level,
+      lrcLyrics: chapter.lrcLyrics || '', // OPT-17: LRC 歌词时间轴
     };
   }));
 
@@ -257,6 +283,7 @@ router.get('/audio/:id', optionalAuth, async (ctx: Context) => {
     isPublic: chapter.isPublic,
     isOwner: chapter.book?.userId === parseInt(userId),
     level: chapter.level,
+    lrcLyrics: chapter.lrcLyrics || '', // OPT-17: LRC 歌词时间轴
   };
 
   ctx.body = {

+ 37 - 0
server/src/modules/player/player.service.ts

@@ -240,3 +240,40 @@ export async function mergeChapterAudios(chapterId: number): Promise<string | nu
 export async function getChapterAudioUrl(chapterId: number): Promise<string | null> {
   return await mergeChapterAudios(chapterId);
 }
+
+/**
+ * 获取用户最近播放记录
+ * @param userId 用户ID
+ * @param limit 返回数量限制,默认10条
+ * @returns 最近播放记录列表
+ */
+export async function getRecentPlayRecords(userId: string, limit: number = 10) {
+  const userIdNum = parseInt(userId);
+
+  const records = await prisma.playRecord.findMany({
+    where: { userId: userIdNum },
+    include: {
+      chapter: {
+        include: {
+          book: {
+            select: {
+              id: true,
+              title: true,
+              coverUrl: true,
+            },
+          },
+        },
+      },
+    },
+    orderBy: { updatedAt: 'desc' },
+    take: limit,
+  });
+
+  return records.map((record) => ({
+    id: record.chapterId.toString(),
+    title: record.chapter?.book?.title || record.chapter?.title || '',
+    coverUrl: record.chapter?.book?.coverUrl || '',
+    progress: Math.round((record.progress / (record.duration || 1)) * 100),
+    updatedAt: record.updatedAt.toISOString(),
+  }));
+}

Some files were not shown because too many files changed in this diff