Преглед изворни кода

feat: AI 规划阶段输出 TTS 配置(音色/语速/情感)

- 在 BookPlan/DeepBookPlan 中扩展 TTS 字段:audienceType/ageRange/suggestedVoice/suggestedSpeed/suggestedEmotion/toneFeature
- 扩展 DEEP_PLAN_SYSTEM_PROMPT 和 plan 提示词,要求 AI 同时归纳这些字段
- 持久化 bookAnalysis 时,若用户未显式传 voiceSpeed,用 AI 推荐的 suggestedSpeed 覆盖 book.voiceSpeed
- 4 个 strategy 接受并传递 userSpecifiedVoiceSpeed 状态,保证用户偏好被尊重
- 前端播放器 audio store 修复 H5 模式下 playbackRate 设置(底层 audio 元素兼容)
- 整体实现'AI 归纳内容 → 数据表字段 → TTS 阶段直接读字段'的链路,不再依赖关键词匹配

测试通过:
- T1 儿童书不传 -> 0.78
- T2 儿童书+用户传0.95 -> 0.95 (尊重用户)
- T3 商业书不传 -> 1.0
- T4 商业书+用户传1.2 -> 1.2 (尊重用户)
MyFramework User пре 2 месеци
родитељ
комит
04ad7a956a
100 измењених фајлова са 2582 додато и 270 уклоњено
  1. 3 1
      .claude/settings.local.json
  2. 4 0
      README.md
  3. 68 0
      TESTING.md
  4. BIN
      after-fix-final.png
  5. BIN
      after-fix-full.png
  6. BIN
      after-fix1.png
  7. BIN
      album-dropdown-check.png
  8. BIN
      album-panel-opened.png
  9. 11 0
      auth.json
  10. BIN
      book-create-toolbar.png
  11. BIN
      create-full.png
  12. BIN
      create-page-new.png
  13. BIN
      dark-panel-female.png
  14. BIN
      dark-panel-selected.png
  15. BIN
      dark-panel-test1.png
  16. BIN
      dark-voice-panel.png
  17. 491 0
      docs/pixelle-video-design-patterns.md
  18. 228 0
      docs/pixelle-video-integration.md
  19. 292 0
      docs/your-advantages-over-pixelle-video.md
  20. 288 0
      docs/your-disadvantages-vs-pixelle-video.md
  21. BIN
      light-voice-panel.png
  22. BIN
      mobile-create-page.png
  23. BIN
      mobile-fullpage.png
  24. 6 0
      my-uniapp-vue3/src/config.ts
  25. 2 5
      my-uniapp-vue3/src/pages/albums/index.vue
  26. 0 4
      my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue
  27. 181 71
      my-uniapp-vue3/src/pages/book-generator/create.vue
  28. 197 2
      my-uniapp-vue3/src/pages/create/index.vue
  29. 1 4
      my-uniapp-vue3/src/pages/drafts/index.vue
  30. 1 4
      my-uniapp-vue3/src/pages/history/index.vue
  31. 2 5
      my-uniapp-vue3/src/pages/index/index.vue
  32. 1 4
      my-uniapp-vue3/src/pages/notifications/index.vue
  33. 44 1
      my-uniapp-vue3/src/pages/pixelle-video/index.vue
  34. 1 4
      my-uniapp-vue3/src/pages/playlists/detail.vue
  35. 1 4
      my-uniapp-vue3/src/pages/playlists/index.vue
  36. 2 5
      my-uniapp-vue3/src/pages/search/index.vue
  37. 2 5
      my-uniapp-vue3/src/pages/settings/index.vue
  38. 42 3
      my-uniapp-vue3/src/store/audio.ts
  39. 1 0
      my-uniapp-vue3/src/types/index.ts
  40. BIN
      no-gap-fix.png
  41. BIN
      panel-album-open.png
  42. BIN
      panel-voice-open.png
  43. BIN
      restructured-final.png
  44. BIN
      restructured-layout.png
  45. BIN
      scrolled-bottom.png
  46. 0 0
      server/logs/requests.json
  47. 2 0
      server/prisma/schema.prisma
  48. 4 4
      server/src/config/models.json
  49. 12 0
      server/src/modules/audioedit/audioedit.controller.ts
  50. 22 8
      server/src/modules/book-generator/book-generator.store.ts
  51. 1 0
      server/src/modules/book-generator/book-generator.types.ts
  52. 5 0
      server/src/modules/book-generator/graph.ts
  53. 3 2
      server/src/modules/book-generator/index.ts
  54. 18 3
      server/src/modules/book-generator/langgraph-controller.ts
  55. 27 3
      server/src/modules/book-generator/nodes/deep-plan.node.ts
  56. 61 4
      server/src/modules/book-generator/nodes/plan.node.ts
  57. 29 2
      server/src/modules/book-generator/prompts/templates.ts
  58. 4 2
      server/src/modules/book-generator/strategies/deep-plan-parallel.strategy.ts
  59. 4 2
      server/src/modules/book-generator/strategies/one-step-outline.strategy.ts
  60. 4 2
      server/src/modules/book-generator/strategies/per-chapter.strategy.ts
  61. 4 2
      server/src/modules/book-generator/strategies/sequential.strategy.ts
  62. 2 1
      server/src/modules/book-generator/strategies/types.ts
  63. 1 2
      server/src/modules/book-generator/tts-queue.ts
  64. 23 1
      server/src/modules/comments/comments.controller.ts
  65. 13 0
      server/src/modules/drafts/drafts.controller.ts
  66. 14 0
      server/src/modules/favorites/favorites.controller.ts
  67. 56 0
      server/src/modules/history/history.controller.ts
  68. 12 0
      server/src/modules/member/member.controller.ts
  69. 23 1
      server/src/modules/notifications/notifications.controller.ts
  70. 89 0
      server/src/modules/player/player.controller.ts
  71. 5 7
      server/src/modules/tts/aliyun.provider.ts
  72. 48 3
      server/src/modules/tts/tts.controller.ts
  73. 204 37
      server/src/modules/tts/tts.service.ts
  74. 1 0
      server/src/types/index.ts
  75. BIN
      tab-click-voice.png
  76. BIN
      tab-no-gap.png
  77. BIN
      tab-toolbar-design.png
  78. BIN
      tab-toolbar-final.png
  79. BIN
      tab-toolbar-v2.png
  80. BIN
      tab-toolbar-v3.png
  81. 16 0
      test-book-42-ch1.txt
  82. 4 0
      test-book-42.md
  83. 2 11
      test-results/.last-run.json
  84. 0 3
      test-results/screenshots/e2e-E01-index.png
  85. 0 3
      test-results/screenshots/e2e-E02-create.png
  86. 0 3
      test-results/screenshots/e2e-E03-book-list.png
  87. 0 3
      test-results/screenshots/e2e-E04-mine.png
  88. 0 3
      test-results/screenshots/e2e-E05-player.png
  89. 0 3
      test-results/screenshots/e2e-E06-book-create.png
  90. 0 3
      test-results/screenshots/e2e-E07-interactive.png
  91. 0 3
      test-results/screenshots/e2e-E09-member.png
  92. 0 3
      test-results/screenshots/e2e-E14-favorites.png
  93. 0 3
      test-results/screenshots/e2e-E16-notifications.png
  94. 0 3
      test-results/screenshots/e2e-E18-orders.png
  95. 0 3
      test-results/screenshots/e2e-E19-publish.png
  96. 0 3
      test-results/screenshots/e2e-E20-ai-generate.png
  97. 0 3
      test-results/screenshots/e2e-E21-video-create.png
  98. 0 3
      test-results/screenshots/e2e-E22-drafts.png
  99. 0 3
      test-results/screenshots/e2e-E23-album.png
  100. 0 3
      test-results/screenshots/e2e-E24-payment-confirm.png

+ 3 - 1
.claude/settings.local.json

@@ -15,7 +15,9 @@
       "Bash(rm -rf ~/.claude/plugins/marketplaces/thedotmack-claude-mem)",
       "Bash(unzip -o claude-mem-main.zip -d ~/.claude/plugins/marketplaces/thedotmack-claude-mem)",
       "Bash(mv ~/.claude/plugins/marketplaces/thedotmack-claude-mem/claude-mem-main/* ~/.claude/plugins/marketplaces/thedotmack-claude-mem/)",
-      "Bash(rmdir ~/.claude/plugins/marketplaces/thedotmack-claude-mem/claude-mem-main)"
+      "Bash(rmdir ~/.claude/plugins/marketplaces/thedotmack-claude-mem/claude-mem-main)",
+      "Bash(rm -rf C:/Users/caoyg/ai/audio-tts/audio_codebuddy/test-results/*)",
+      "Bash(rm -rf C:/Users/caoyg/ai/audio-tts/audio_codebuddy/tests/test-results/*)"
     ]
   }
 }

+ 4 - 0
README.md

@@ -629,6 +629,10 @@ AI 编辑器可直接读取该文件判断测试结果。
 - [ ] 音频编辑功能
 - [ ] 导出多种格式
 
+## 🎙️ TTS 参考文档
+
+- **[tts-summary.md](./tts-summary.md)** — 阿里云百炼 TTS 语音合成完整参考,涵盖三大模型对比、CosyVoice 80+ 音色速查表、指令控制(Instruct)使用指南、声音设计 API、场景推荐等内容。
+
 ## 📄 License
 
 MIT License

+ 68 - 0
TESTING.md

@@ -373,4 +373,72 @@ npx playwright test --config=tests/playwright.config.ts
 # === 查看报告 ===
 npx playwright show-report tests/test-results/report   # E2E 报告
 open server/coverage/index.html                        # 覆盖率报告
+```
+
+---
+
+## playwright-cli 交互式调试
+
+### 快速开始
+
+```bash
+# 打开浏览器并导航到页面
+playwright-cli open http://localhost:5173
+playwright-cli goto http://localhost:5173/#/pages/mine/index
+
+# 截图
+playwright-cli screenshot
+
+# 获取页面快照(元素引用)
+playwright-cli snapshot
+
+# 点击元素(使用 snapshot 中的 ref)
+playwright-cli click e15
+
+# 填写表单
+playwright-cli fill e5 "text to enter"
+```
+
+### 认证状态处理
+
+**登录绕过方式**(测试环境):
+```bash
+# API 登录(code=123456 可绕过验证码验证)
+curl -X POST http://localhost:3000/api/auth/login \
+  -H "Content-Type: application/json" \
+  -d '{"phone":"13900000001","code":"123456"}'
+```
+
+**保存/恢复认证状态**:
+```bash
+# 保存当前登录状态到文件
+playwright-cli state-save auth.json
+
+# 从文件恢复登录状态
+playwright-cli state-load auth.json
+```
+
+**手动设置 localStorage**:
+```bash
+# 设置 token
+playwright-cli localstorage-set token "your-jwt-token"
+
+# 设置用户信息
+playwright-cli localstorage-set userInfo '{"id":"1","phone":"13800000001"}'
+
+# 查看所有 localStorage
+playwright-cli localstorage-list
+```
+
+### 调试技巧
+
+```bash
+# 查看控制台输出
+playwright-cli console
+
+# 查看网络请求
+playwright-cli network
+
+# 使用持久化配置(保留登录状态)
+playwright-cli open --persistent http://localhost:5173
 ```

BIN
after-fix-final.png


BIN
after-fix-full.png



BIN
album-dropdown-check.png


BIN
album-panel-opened.png


+ 11 - 0
auth.json

@@ -0,0 +1,11 @@
+{
+  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMDMiLCJwaG9uZSI6IjEzOTAwMDAwMDAxIiwiaWF0IjoxNzgwNzUwNzI5LCJleHAiOjE3ODEzNTU1Mjl9.4VkzIcSLNvxtZ9Sa3ROIpiQpD0zmpC8kClZgK5nKcts",
+  "user": {
+    "id": "103",
+    "phone": "13900000001",
+    "nickname": "用户0001",
+    "avatar": "https://api.dicebear.com/7.x/avataaars/svg?seed=13900000001",
+    "memberLevel": 0,
+    "isNewUser": false
+  }
+}

BIN
book-create-toolbar.png



BIN
create-page-new.png


BIN
dark-panel-female.png


BIN
dark-panel-selected.png


BIN
dark-panel-test1.png


BIN
dark-voice-panel.png


+ 491 - 0
docs/pixelle-video-design-patterns.md

@@ -0,0 +1,491 @@
+# Pixelle-Video 优秀设计模式分析
+
+> 📅 分析日期:2026-06-05
+> 📌 项目:https://github.com/AIDC-AI/Pixelle-Video
+> 💡 用途:后期决定是否借鉴到自己的项目中
+
+---
+
+## 📋 设计模式清单
+
+| # | 设计模式 | 评分 | 复杂度 | 可借鉴度 |
+|---|---------|------|--------|---------|
+| 1 | Pipeline 架构模式 | ⭐⭐⭐⭐⭐ | 高 | 高 |
+| 2 | 配置管理单例模式 | ⭐⭐⭐⭐⭐ | 中 | 高 |
+| 3 | 任务管理异步模式 | ⭐⭐⭐⭐⭐ | 中 | 高 |
+| 4 | Core 服务层聚合 | ⭐⭐⭐⭐⭐ | 中 | 高 |
+| 5 | LLM 结构化输出 | ⭐⭐⭐⭐ | 中 | 中 |
+| 6 | Storyboard 数据模型 | ⭐⭐⭐⭐ | 低 | 高 |
+| 7 | Prompt 模板分离 | ⭐⭐⭐⭐ | 低 | 高 |
+| 8 | API Schema 规范化 | ⭐⭐⭐⭐ | 低 | 中 |
+| 9 | Lazy Initialization | ⭐⭐⭐⭐ | 低 | 高 |
+
+---
+
+## 1️⃣ Pipeline 架构模式 ⭐⭐⭐⭐⭐
+
+**文件位置**: `pixelle_video/pipelines/base.py`
+
+### 核心代码
+```python
+from abc import ABC, abstractmethod
+from typing import Optional, Callable
+
+class BasePipeline(ABC):
+    """所有自定义 Pipeline 必须继承此基类"""
+    
+    def __init__(self, pixelle_video_core):
+        self.core = pixelle_video_core
+        self.llm = pixelle_video_core.llm
+        self.tts = pixelle_video_core.tts
+        self.media = pixelle_video_core.media
+    
+    @abstractmethod
+    async def __call__(
+        self,
+        text: str,
+        progress_callback: Optional[Callable[[ProgressEvent], None]] = None,
+        **kwargs
+    ) -> VideoGenerationResult:
+        pass
+```
+
+### 使用示例
+```python
+# 内置 Pipeline
+self.pipelines = {
+    "standard": StandardPipeline(self),
+    "custom": CustomPipeline(self),
+    "asset_based": AssetBasedPipeline(self),
+}
+
+# 调用
+result = await self.pipelines["standard"](text="...", n_scenes=5)
+```
+
+### 优点
+- ✅ 解耦业务逻辑,每个 Pipeline 独立
+- ✅ 支持多种生成模式
+- ✅ 通过 `progress_callback` 实现进度追踪
+- ✅ 统一接口,易于扩展
+
+### 对比你的项目
+- 你的 `book-generator` 模块可以用 Pipeline 重构
+- TTS 生成、图片生成可以各自作为 Pipeline
+
+---
+
+## 2️⃣ 配置管理单例模式 ⭐⭐⭐⭐⭐
+
+**文件位置**: `pixelle_video/config/manager.py`
+
+### 核心代码
+```python
+class ConfigManager:
+    _instance: Optional['ConfigManager'] = None
+    
+    def __new__(cls, config_path: str = "config.yaml"):
+        if cls._instance is None:
+            cls._instance = super().__new__(cls)
+        return cls._instance
+    
+    def __init__(self, config_path: str = "config.yaml"):
+        if hasattr(self, '_initialized'):
+            return
+        self._initialized = True
+    
+    def update(self, updates: dict):
+        """深度合并更新"""
+        def deep_merge(base: dict, updates: dict) -> dict:
+            for key, value in updates.items():
+                if key in base and isinstance(base[key], dict):
+                    deep_merge(base[key], value)
+                else:
+                    base[key] = value
+            return base
+```
+
+### 优点
+- ✅ 全局单例,避免重复加载
+- ✅ 配置热重载支持
+- ✅ 深度合并更新
+- ✅ 配置验证机制
+
+### 对比你的项目
+- 你的 `.env` + `config.ts` 可参考此模式
+- 需要统一配置管理入口
+
+---
+
+## 3️⃣ 任务管理异步模式 ⭐⭐⭐⭐⭐
+
+**文件位置**: `api/tasks/manager.py`
+
+### 核心代码
+```python
+class TaskManager:
+    def __init__(self):
+        self._tasks: Dict[str, Task] = {}
+        self._task_futures: Dict[str, asyncio.Task] = {}
+    
+    async def execute_task(self, task_id: str, coro_func, *args, **kwargs):
+        async def _execute():
+            try:
+                task.status = TaskStatus.RUNNING
+                result = await coro_func(*args, **kwargs)
+                task.status = TaskStatus.COMPLETED
+                task.result = result
+            except Exception as e:
+                task.status = TaskStatus.FAILED
+                task.error = str(e)
+        
+        future = asyncio.create_task(_execute())
+        self._task_futures[task_id] = future
+    
+    def update_progress(self, task_id: str, current: int, total: int, message: str = ""):
+        percentage = (current / total * 100) if total > 0 else 0
+        task.progress = TaskProgress(current=current, total=total, percentage=percentage)
+    
+    def cancel_task(self, task_id: str) -> bool:
+        """取消运行中的任务"""
+        future = self._task_futures.get(task_id)
+        if future and not future.done():
+            future.cancel()
+        task.status = TaskStatus.CANCELLED
+        return True
+```
+
+### 优点
+- ✅ 异步任务生命周期管理
+- ✅ 自动清理过期任务
+- ✅ 进度追踪 + 取消机制
+- ✅ 状态持久化
+
+### 对比你的项目
+- 你的 Redis 队列可参考任务状态设计
+- 可增加任务取消功能
+
+---
+
+## 4️⃣ Core 服务层聚合模式 ⭐⭐⭐⭐⭐
+
+**文件位置**: `pixelle_video/service.py`
+
+### 核心代码
+```python
+class PixelleVideoCore:
+    async def initialize(self):
+        # 核心服务
+        self.llm = LLMService(self.config)
+        self.tts = TTSService(self.config, core=self)
+        self.media = MediaService(self.config, core=self)
+        self.api_media = APIProviderMediaService(self.config, core=self)
+        self.video = VideoService()
+        
+        # Pipeline 注册
+        self.pipelines = {
+            "standard": StandardPipeline(self),
+            "custom": CustomPipeline(self),
+        }
+    
+    # 每个服务都可以访问 core
+    async def some_method(self):
+        audio = await self.tts("hello")
+        image = await self.media.generate(prompt="...")
+```
+
+### 优点
+- ✅ 统一入口,封装所有能力
+- ✅ 依赖注入(每个服务可访问 core)
+- ✅ Pipeline 可插拔
+- ✅ 懒加载支持
+
+### 对比你的项目
+- 你的 `server/src/services/` 可以整合到 Core
+- TTS 服务、存储服务统一管理
+
+---
+
+## 5️⃣ LLM 结构化输出 ⭐⭐⭐⭐
+
+**文件位置**: `pixelle_video/services/llm_service.py`
+
+### 核心代码
+```python
+from pydantic import BaseModel
+from typing import TypeVar, Type
+
+T = TypeVar("T", bound=BaseModel)
+
+async def __call__(
+    self,
+    prompt: str,
+    response_type: Optional[Type[T]] = None,
+    **kwargs
+) -> Union[str, T]:
+    
+    if response_type is not None:
+        # 自动生成 JSON Schema 指令
+        schema = response_type.model_json_schema()
+        json_instruction = f"""## JSON Output Required
+```json
+{json.dumps(schema, indent=2)}
+```
+Only output JSON, no other text."""
+        
+        response = await client.chat.completions.create(
+            model=model,
+            messages=[{"role": "user", "content": f"{prompt}\n\n{json_instruction}"}],
+        )
+        
+        return self._parse_response_as_model(content, response_type)
+```
+
+### 使用示例
+```python
+from pydantic import BaseModel
+
+class BookOutline(BaseModel):
+    title: str
+    chapters: List[str]
+    total_words: int
+
+outline = await llm(
+    prompt="为《Atomic Habits》生成大纲",
+    response_type=BookOutline
+)
+print(outline.title)  # 强类型访问
+```
+
+### 优点
+- ✅ Pydantic 强类型输出
+- ✅ 自动生成 JSON Schema 指令
+- ✅ 兼容所有 OpenAI 兼容 API
+- ✅ 多层容错解析
+
+---
+
+## 6️⃣ Storyboard 数据模型 ⭐⭐⭐⭐
+
+**文件位置**: `pixelle_video/models/storyboard.py`
+
+### 核心代码
+```python
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import List, Optional
+
+@dataclass
+class StoryboardFrame:
+    index: int
+    narration: str
+    image_prompt: str
+    audio_path: Optional[str] = None
+    media_type: Optional[str] = None
+    video_segment_path: Optional[str] = None
+    duration: float = 0.0
+    created_at: Optional[datetime] = None
+    
+    def __post_init__(self):
+        if self.created_at is None:
+            self.created_at = datetime.now()
+
+@dataclass
+class Storyboard:
+    title: str
+    config: StoryboardConfig
+    frames: List[StoryboardFrame] = field(default_factory=list)
+    final_video_path: Optional[str] = None
+    
+    @property
+    def progress(self) -> float:
+        """自动计算进度"""
+        if not self.frames:
+            return 0.0
+        completed = sum(1 for f in self.frames if f.video_segment_path)
+        return completed / len(self.frames)
+    
+    @property
+    def is_completed(self) -> bool:
+        return all(f.video_segment_path for f in self.frames)
+```
+
+### 优点
+- ✅ `@dataclass` 简洁定义
+- ✅ 嵌套层级清晰
+- ✅ `@property` 计算属性
+- ✅ 类型安全
+
+---
+
+## 7️⃣ Prompt 模板分离 ⭐⭐⭐⭐
+
+**文件位置**: `pixelle_video/prompts/*.py`
+
+### 示例:标题生成
+```python
+# prompts/title_generation.py
+
+TITLE_GENERATION_PROMPT = """Please generate a short, attractive title.
+
+Requirements:
+1. **Language Consistency (CRITICAL)**: The title MUST be in the same language as input
+2. **Character Limit (CRITICAL)**: MUST NOT exceed {max_length} characters
+3. **Core Message (CRITICAL)**: MUST capture the MAIN POINT
+
+Title:"""
+
+def build_title_generation_prompt(content: str, max_length: int = 15) -> str:
+    return TITLE_GENERATION_PROMPT.format(
+        content=content[:500],  # 限制长度
+        max_length=max_length
+    )
+```
+
+### 目录结构
+```
+prompts/
+├── title_generation.py
+├── content_narration.py
+├── image_generation.py
+├── topic_narration.py
+└── video_generation.py
+```
+
+### 优点
+- ✅ Prompt 代码与业务逻辑分离
+- ✅ 参数化模板,易于复用
+- ✅ 注释详细,便于维护
+- ✅ 支持多语言
+
+---
+
+## 8️⃣ API Schema 规范化 ⭐⭐⭐⭐
+
+**文件位置**: `api/schemas/video.py`
+
+### 核心代码
+```python
+from pydantic import BaseModel, Field
+from typing import Optional, Literal, Dict, Any
+
+class VideoGenerateRequest(BaseModel):
+    text: str = Field(..., description="Source text for video generation")
+    
+    mode: Literal["generate", "fixed"] = Field(
+        "generate",
+        description="Processing mode"
+    )
+    
+    n_scenes: Optional[int] = Field(
+        5, 
+        ge=1, le=20,  # 范围约束
+        description="Number of scenes"
+    )
+    
+    template_params: Optional[Dict[str, Any]] = Field(
+        None,
+        description="Custom template parameters"
+    )
+    
+    class Config:
+        json_schema_extra = {
+            "example": {
+                "text": "Atomic Habits teaches us...",
+                "n_scenes": 5,
+            }
+        }
+```
+
+### 优点
+- ✅ Pydantic 验证 + 自动文档
+- ✅ 范围约束(`ge`, `le`)
+- ✅ 示例 JSON 可直接用于测试
+- ✅ 类型提示完善
+
+---
+
+## 9️⃣ Lazy Initialization 懒加载 ⭐⭐⭐⭐
+
+**文件位置**: `pixelle_video/service.py`
+
+### 核心代码
+```python
+async def _get_or_create_comfykit(self) -> ComfyKit:
+    current_config = self._get_comfykit_config()
+    current_hash = self._compute_comfykit_config_hash(current_config)
+    
+    # 配置变更检测
+    if self._comfykit is None or self._comfykit_config_hash != current_hash:
+        if self._comfykit is not None:
+            await self._comfykit.close()  # 清理旧实例
+        
+        self._comfykit = ComfyKit(**current_config)
+        self._comfykit_config_hash = current_hash
+    
+    return self._comfykit
+```
+
+### 优点
+- ✅ 首次使用时才初始化
+- ✅ 配置变更自动重建
+- ✅ 内存优化
+- ✅ 支持热重载
+
+---
+
+## 📊 借鉴优先级建议
+
+### 🔴 高优先级(可直接借鉴)
+
+| 模式 | 理由 | 借鉴方式 |
+|------|------|---------|
+| **Pipeline 架构** | 解耦能力强,适合复杂业务流程 | 重构 `book-generator` 模块 |
+| **Storyboard 模型** | 简单实用,可追踪复杂任务状态 | 新建视频生成模块 |
+| **Prompt 模板分离** | 低侵入,易实施 | 新建 `prompts/` 目录 |
+| **Lazy Loading** | 性能优化,减少启动时间 | 重构配置加载 |
+
+### 🟡 中优先级(有条件借鉴)
+
+| 模式 | 理由 | 借鉴条件 |
+|------|------|---------|
+| **Core 服务聚合** | 需要重构较大范围 | 先小范围试点 |
+| **配置单例** | 需统一配置入口 | 评估现有配置复杂度 |
+
+### 🟢 低优先级(了解即可)
+
+| 模式 | 理由 |
+|------|------|
+| LLM 结构化输出 | 需要 Pydantic 基础 |
+| API Schema | 项目已有类似方案 |
+
+---
+
+## 📝 实施建议
+
+### Phase 1:低成本高收益(1-2天)
+1. 创建 `server/src/prompts/` 目录,分离 Prompt 模板
+2. 引入 `Storyboard` 数据模型用于视频生成状态追踪
+
+### Phase 2:中等投入(3-5天)
+3. 实现 Pipeline 基类,重构 TTS 生成流程
+4. 实现配置热重载
+
+### Phase 3:长期优化(1-2周)
+5. Core 服务层聚合
+6. 任务管理增强(取消、进度)
+
+---
+
+## 🔗 相关文件索引
+
+| 设计模式 | 源文件 | 行数 |
+|---------|--------|------|
+| Pipeline | `pixelle_video/pipelines/base.py` | 117 |
+| 配置单例 | `pixelle_video/config/manager.py` | 182 |
+| 任务管理 | `api/tasks/manager.py` | 270 |
+| Core 聚合 | `pixelle_video/service.py` | 316 |
+| LLM 服务 | `pixelle_video/services/llm_service.py` | 340 |
+| 数据模型 | `pixelle_video/models/storyboard.py` | 144 |
+| Prompt 模板 | `pixelle_video/prompts/title_generation.py` | 85 |
+| API Schema | `api/schemas/video.py` | 117 |

+ 228 - 0
docs/pixelle-video-integration.md

@@ -0,0 +1,228 @@
+# Pixelle-Video 融合方案
+
+> 📅 记录日期:2026-06-05
+> 📌 状态:已规划,待开发
+
+---
+
+## 📋 方案概述
+
+将 [Pixelle-Video](https://github.com/AIDC-AI/Pixelle-Video)(21.4k Stars AI 视频生成引擎)融合到现有项目中。
+
+### 用户配置
+- **硬件**:8G 内存,无 GPU
+- **TTS**:项目已有多种模型可用(Edge-TTS、通义千问等)
+- **方案**:推荐方案 A + C(API 集成 + 能力融合)
+
+---
+
+## 🏗️ 架构设计
+
+```
+┌──────────────────────────────────────────────────────────┐
+│                      UniApp 前端                         │
+│                   (my-uniapp-vue3/)                      │
+└─────────────────────┬────────────────────────────────────┘
+                      │ HTTP
+                      ▼
+┌──────────────────────────────────────────────────────────┐
+│                   Nest.js 后端                           │
+│                   (server/)                              │
+│  ┌─────────────────────────────────────────────────────┐ │
+│  │  模块: pixelle-video/                               │ │
+│  │  ├── pixelle-video.controller.ts  ✅ 已创建          │ │
+│  │  ├── pixelle-video.service.ts   ✅ 已创建            │ │
+│  │  └── pixelle-video.types.ts     ✅ 已创建            │ │
+│  └─────────────────────────────────────────────────────┘ │
+└────────────┬──────────────────────┬──────────────────────┘
+             │                      │
+             ▼                      ▼
+┌────────────────────┐    ┌────────────────────────────────┐
+│  你的 TTS 服务       │    │     Pixelle-Video 微服务        │
+│  (Edge-TTS 等)      │    │     (独立部署,端口 8501)        │
+│  ✅ 已有            │    │     📋 待部署                   │
+└────────────────────┘    └────────────────────────────────┘
+```
+
+---
+
+## 📁 已完成工作
+
+### ✅ 后端模块(已创建)
+```
+server/src/modules/pixelle-video/
+├── pixelle-video.controller.ts   # API 路由
+├── pixelle-video.service.ts     # 业务逻辑
+├── pixelle-video.types.ts       # 类型定义
+└── index.ts                     # 模块导出
+```
+
+### ✅ 前端页面(已创建)
+```
+my-uniapp-vue3/src/pages/pixelle-video/
+└── index.vue                    # AI 视频生成页面
+```
+
+### ✅ 配置更新
+- `server/src/app.ts` - 路由已注册
+- `server/.env.example` - 配置项已添加
+- `my-uniapp-vue3/src/pages.json` - 页面和 tabBar 已添加
+
+---
+
+## 📱 硬件适配方案
+
+| 组件 | 推荐方案 | 费用 | 说明 |
+|------|---------|------|------|
+| LLM | 通义千问 API | ~0.01元/次 | 少量调用,成本极低 |
+| 图像生成 | DashScope Wan API | 按量计费 | 可用免费额度 |
+| 视频生成 | ❌ 跳过 | - | 无 GPU,暂不启用 |
+| 语音合成 | 复用现有 TTS | 免费 | Edge-TTS 等 |
+
+---
+
+## 🚀 待部署步骤
+
+### Step 1: 部署 Pixelle-Video 服务
+
+```bash
+# 克隆项目
+git clone https://github.com/AIDC-AI/Pixelle-Video.git
+cd Pixelle-Video
+
+# 安装依赖(需要 Python 3.10+)
+# 需要先安装 uv: https://docs.astral.sh/uv/
+uv sync
+
+# 安装 ffmpeg
+# Windows: 下载 ffmpeg.exe 并加入 PATH
+# macOS: brew install ffmpeg
+# Linux: sudo apt install ffmpeg
+
+# 启动服务
+uv run streamlit run web/app.py
+# 访问 http://localhost:8501
+```
+
+### Step 2: 配置环境变量
+
+在 `server/.env` 中添加:
+```bash
+# Pixelle-Video 服务地址
+PIXELLE_VIDEO_URL=http://localhost:8501
+
+# 启用服务
+PIXELLE_VIDEO_ENABLED=true
+```
+
+### Step 3: 重启服务
+
+```bash
+# 重启后端
+cd server
+npm run dev
+```
+
+---
+
+## 🔌 API 接口说明
+
+### 1. 健康检查
+```
+GET /api/pixelle-video/health
+
+Response:
+{
+  "code": 0,
+  "message": "Pixelle-Video 服务正常",
+  "data": { "status": "online" }
+}
+```
+
+### 2. 生成视频内容
+```
+POST /api/pixelle-video/generate
+
+Request:
+{
+  "topic": "人工智能的未来",
+  "style": "科技风",
+  "aspectRatio": "9:16",
+  "duration": 60
+}
+
+Response:
+{
+  "code": 0,
+  "message": "生成成功",
+  "data": {
+    "script": "生成的文案内容...",
+    "images": ["url1", "url2"],
+    "videoUrl": null,
+    "duration": 60
+  }
+}
+```
+
+### 3. 获取支持的风格
+```
+GET /api/pixelle-video/styles
+
+Response:
+{
+  "code": 0,
+  "data": {
+    "styles": ["科技风", "自然风", "商务风", ...]
+  }
+}
+```
+
+---
+
+## 📱 前端功能预览
+
+### 页面入口
+- **底部导航**:新增"视频生成" tab
+- **独立页面**:`/pages/pixelle-video/index`
+
+### 功能列表
+1. ✅ 输入视频主题
+2. ✅ 选择视频风格(10种可选)
+3. ✅ 选择画面比例(9:16 / 16:9 / 1:1)
+4. ✅ 调用 Pixelle-Video 生成文案 + 图片
+5. ✅ 使用项目 TTS 服务生成语音
+6. ✅ 预览和播放
+
+---
+
+## ⚠️ 功能限制说明
+
+由于硬件限制(8G 内存,无 GPU):
+
+| 功能 | 状态 | 说明 |
+|------|------|------|
+| 文案生成 | ✅ 可用 | 调用 LLM API |
+| AI 配图 | ✅ 可用 | 调用图像生成 API |
+| 语音合成 | ✅ 可用 | 复用现有 TTS |
+| AI 视频生成 | ❌ 暂不支持 | 需要 GPU |
+
+---
+
+## 🔄 后续扩展
+
+未来如果升级硬件,可以启用:
+- WAN 2.1 视频生成模型
+- 数字人口播
+- 动作迁移
+- 完整的视频合成功能
+
+---
+
+## 📚 参考资料
+
+- [Pixelle-Video GitHub](https://github.com/AIDC-AI/Pixelle-Video)
+- [Windows 一键整合包下载](https://github.com/AIDC-AI/Pixelle-Video/releases)
+- [安装指南](https://github.com/AIDC-AI/Pixelle-Video?tab=readme-ov-file#%E5%AE%89%E8%A3%85%E4%BD%BF%E7%94%A8)
+
+### 🎨 设计模式分析
+- [详细设计模式文档](./pixelle-video-design-patterns.md)

+ 292 - 0
docs/your-advantages-over-pixelle-video.md

@@ -0,0 +1,292 @@
+# 大模型 + 内容/语音生成对比分析
+
+> 📅 分析日期:2026-06-05
+> 📌 对比项目:你的项目 vs Pixelle-Video
+
+---
+
+## 📊 LLM 调用能力对比
+
+| 能力 | 你的项目 | Pixelle-Video | 优势方 |
+|------|---------|--------------|--------|
+| 多供应商注册 | ✅ ProviderRegistry | ❌ 单供应商 | 你的 |
+| 熔断保护 | ✅ Circuit Breaker | ❌ 无 | 你的 |
+| 自动故障切换 | ✅ 30s 健康检查 | ❌ 无 | 你的 |
+| 额度管理 | ✅ 额度追踪 | ❌ 无 | 你的 |
+| 上下文追踪 | ✅ AsyncLocalStorage | ❌ 无 | 你的 |
+| 策略模式 | ✅ 多种生成策略 | ✅ Pipeline 模式 | 平手 |
+| 结构化输出 | ✅ Pydantic | ✅ Pydantic | 平手 |
+
+---
+
+## 🏆 你的优势详解
+
+### 1️⃣ **ProviderRegistry + 熔断机制** ⭐⭐⭐⭐⭐
+
+**你的实现** (`services/llm/provider.registry.ts`):
+
+```typescript
+// 多供应商注册,自动故障切换
+_registry.register(provider, {
+  enabled: true,
+  breakerConfig: {
+    name: `${vendorKey}-llm`,
+    failureThreshold: 3,      // 3次失败触发熔断
+    cooldownMs: 60000,         // 60秒冷却
+  },
+});
+
+// 定时健康检查,自动恢复
+setInterval(async () => {
+  const broken = registry.listBroken();
+  for (const node of broken) {
+    if (node.breaker.getState() === 'HALF_OPEN') {
+      const healthy = await node.provider.healthCheck?.();
+      if (healthy) node.breaker.reset();
+    }
+  }
+}, 30_000);
+```
+
+**Pixelle-Video 实现**:
+```python
+# 简单直接调用,无容错
+response = await client.chat.completions.create(
+    model=final_model,
+    messages=[{"role": "user", "content": prompt}],
+)
+```
+
+**优势**:
+- ✅ 供应商故障时自动切换
+- ✅ 防止额度耗尽导致服务不可用
+- ✅ 成本控制(自动降级)
+
+---
+
+### 2️⃣ **AsyncLocalStorage 上下文追踪** ⭐⭐⭐⭐⭐
+
+**你的实现** (`services/llm-context.ts`):
+
+```typescript
+import { AsyncLocalStorage } from 'async_hooks';
+
+const storage = new AsyncLocalStorage<LlmContext>();
+
+interface LlmContext {
+  userId?: number;
+  bookId?: number;
+  chapterId?: number;
+}
+
+// 所有 LLM/TTS 调用自动携带上下文
+export async function runWithContext<T>(
+  ctx: LlmContext,
+  fn: () => Promise<T>,
+): Promise<T> {
+  return storage.run(ctx, fn);
+}
+
+// 用法示例
+await runWithContext({ bookId: 17, chapterId: 5 }, async () => {
+  await callLLMWithMessages(...);  // 自动携带 bookId
+  await ttsProvider.synthesize(...); // 自动携带 chapterId
+});
+```
+
+**Pixelle-Video**:
+- ❌ 无上下文追踪
+- ❌ 无法精确计算每个书籍的 LLM 成本
+
+**优势**:
+- ✅ 精确追踪每个书籍/章节的 API 消耗
+- ✅ 便于配额管理和计费
+- ✅ 日志追踪更清晰
+
+---
+
+### 3️⃣ **TTS 情感/场景识别** ⭐⭐⭐⭐⭐
+
+**你的实现** (`modules/tts/tts.service.ts`):
+
+```typescript
+// 情感关键词库
+const EMOTION_KEYWORDS = [
+  { emotion: 'fearful',   keywords: ['恐怖', '可怕', '惊悚', '恐惧', ...] },
+  { emotion: 'sad',       keywords: ['悲伤', '难过', '哭泣', ...] },
+  { emotion: 'angry',     keywords: ['愤怒', '生气', '怒火', ...] },
+  { emotion: 'happy',     keywords: ['快乐', '开心', '幸福', ...] },
+];
+
+// 场景关键词库
+const SCENE_KEYWORDS = [
+  { scene: '新闻播报', keywords: ['新闻', '报道', '记者', ...] },
+  { scene: '儿童内容', keywords: ['童话', '儿童', '小朋友', ...] },
+  { scene: '广告促销', keywords: ['促销', '优惠', '折扣', ...] },
+  { scene: '比赛解说', keywords: ['比赛', '进球', '冠军', ...] },
+];
+
+// 自动检测并调整 TTS 参数
+function detectEmotion(text: string): string { ... }
+function detectScene(text: string): string { ... }
+```
+
+**Pixelle-Video**:
+- ❌ 无情感识别
+- ❌ 固定音色参数
+
+**优势**:
+- ✅ 语音更自然(匹配内容情感)
+- ✅ 提升听众体验
+- ✅ 差异化竞争力
+
+---
+
+### 4️⃣ **多策略书籍生成** ⭐⭐⭐⭐⭐
+
+**你的实现** (`modules/book-generator/`):
+
+```typescript
+// 多种生成策略
+const strategies = {
+  'sequential': '串行生成',
+  'one-step-outline': '一步大纲 + 并行内容',
+  'per-chapter': '逐章内聚',
+  'deep-plan-parallel': 'DeepPlan + RichOutline + 并行编辑',  // 当前推荐
+};
+
+// DAG 工作流
+const graph = {
+  nodes: [
+    'deep-plan',      // 深度规划
+    'rich-outline',   // 丰富大纲
+    'write-chapters', // 并行写作
+    'continuity-edit' // 连续性编辑
+  ],
+  edges: [
+    { from: 'deep-plan', to: 'rich-outline' },
+    { from: 'rich-outline', to: 'write-chapters' },
+    { from: 'write-chapters', to: 'continuity-edit' },
+  ]
+};
+```
+
+**Pixelle-Video**:
+- ✅ 有 Pipeline 模式
+- ⚠️ 主要是串行流水线
+- ❌ 无多策略切换
+
+**优势**:
+- ✅ 可对比不同策略效果
+- ✅ 灵活适配不同书籍类型
+- ✅ 易于 A/B 测试优化
+
+---
+
+### 5️⃣ **统一音色映射层** ⭐⭐⭐⭐
+
+**你的实现** (`modules/tts/tts.service.ts`):
+
+```typescript
+// 10个统一音色 ID,前端使用
+const UNIFIED_VOICES = [
+  { id: 'voice_01', name: '温柔女声', gender: 'female' },
+  { id: 'voice_02', name: '磁性男声', gender: 'male' },
+  // ... 10个音色
+];
+
+// 统一音色 → 不同供应商的映射
+const ALIYUN_VOICE_MAP = { voice_01: 'longanyang', ... };
+const EDGE_VOICE_MAP = { voice_01: 'zh-CN-XiaoxiaoNeural', ... };
+
+// 自动映射到实际供应商音色
+function mapToProviderVoice(unifiedVoiceId: string, providerVendor: string): string {
+  if (providerVendor === 'edge') return EDGE_VOICE_MAP[unifiedVoiceId];
+  return ALIYUN_VOICE_MAP[unifiedVoiceId];
+}
+```
+
+**Pixelle-Video**:
+- ❌ 直接使用供应商音色 ID
+- ❌ 无统一抽象层
+
+**优势**:
+- ✅ 前端简单选择,后端自动适配
+- ✅ 供应商切换无感知
+- ✅ 便于扩展新音色
+
+---
+
+### 6️⃣ **书籍连续性编辑** ⭐⭐⭐⭐
+
+**你的实现** (`nodes/continuity-edit.node.ts`):
+
+```typescript
+// 章节间连续性保证
+async function continuityEdit(chapters: Chapter[]) {
+  // 1. 提取每个章节的关键信息
+  const summaries = await Promise.all(
+    chapters.map(ch => summarize(ch.content))
+  );
+  
+  // 2. 确保情节/人物/设定在章节间一致
+  const consistency = await checkConsistency(summaries);
+  
+  // 3. 修正不一致之处
+  if (consistency.issues.length > 0) {
+    await fixInconsistencies(chapters, consistency.issues);
+  }
+}
+```
+
+**Pixelle-Video**:
+- ❌ 场景独立生成
+- ❌ 无跨场景连续性保证
+
+**优势**:
+- ✅ 长篇内容质量更高
+- ✅ 避免矛盾和重复
+- ✅ 更适合书籍/有声书场景
+
+---
+
+## 📊 TTS 能力对比
+
+| 能力 | 你的项目 | Pixelle-Video | 优势方 |
+|------|---------|--------------|--------|
+| 多供应商 | ✅ Edge + 阿里云 | ⚠️ Edge + ComfyUI | 你的 |
+| 音色数量 | ✅ 10+ 固定音色 | ❌ 无统一管理 | 你的 |
+| 情感识别 | ✅ 7种情感 | ❌ 无 | 你的 |
+| 场景适配 | ✅ 6种场景 | ❌ 无 | 你的 |
+| 语速调节 | ✅ 支持 | ✅ 支持 | 平手 |
+| 长文本 | ✅ 分段处理 | ⚠️ 基本支持 | 你的 |
+| 实时 TTS | ✅ 通义千问实时 | ❌ 无 | 你的 |
+| 音色映射 | ✅ 统一抽象层 | ❌ 无 | 你的 |
+
+---
+
+## 🎯 总结:你的核心优势
+
+| # | 优势 | 竞争力 |
+|---|------|--------|
+| 1 | **ProviderRegistry + 熔断** | 企业级可靠性 |
+| 2 | **AsyncLocalStorage 追踪** | 精确计费/配额 |
+| 3 | **TTS 情感/场景识别** | 更自然的语音 |
+| 4 | **多策略书籍生成** | 灵活适配场景 |
+| 5 | **统一音色抽象层** | 易用性 + 可扩展 |
+| 6 | **章节连续性编辑** | 高质量长内容 |
+
+---
+
+## 💡 借鉴建议
+
+### 可以向 Pixelle-Video 学习的
+
+1. **Prompt 模板管理** - 分离 Prompt 代码到独立文件
+2. **Pipeline 模式的进度回调** - 统一进度汇报机制
+
+### 你比它强的地方
+
+1. **不需要借鉴** - 你的 LLM/TTS 架构更成熟
+2. **差异化竞争** - 情感 TTS 是独特优势
+3. **商业化完整性** - 配额定额管理是 Pixelle-Video 没有的

+ 288 - 0
docs/your-disadvantages-vs-pixelle-video.md

@@ -0,0 +1,288 @@
+# 你的项目 vs Pixelle-Video:不足之处
+
+> 📅 分析日期:2026-06-05
+> 📌 目的:客观分析差距,明确改进方向
+
+---
+
+## 📊 不足之处概览
+
+| 能力 | 你的项目 | Pixelle-Video | 差距 |
+|------|---------|--------------|------|
+| **视频生成** | ❌ 无 | ✅ 完整流水线 | 大 |
+| **本地 LLM** | ❌ 仅云端 | ✅ Ollama 支持 | 中 |
+| **图像生成** | ❌ 无 | ✅ ComfyUI/RunningHub | 大 |
+| **HTML 模板渲染** | ❌ 无 | ✅ 30+ 模板 | 大 |
+| **数字人口播** | ❌ 无 | ✅ 支持 | 大 |
+| **动作迁移** | ❌ 无 | ✅ 支持 | 大 |
+| **免费运行** | ❌ 需付费 API | ✅ Ollama 免费 | 中 |
+
+---
+
+## 🔴 差距较大的方面
+
+### 1️⃣ **视频生成能力** ⭐⭐⭐⭐⭐
+
+**你的项目**:主要生成音频/有声书
+```
+输入:文本 → 输出:音频
+```
+
+**Pixelle-Video**:完整视频生成流水线
+```
+输入:文本/主题
+  ↓
+1. LLM 生成文案(分镜头脚本)
+  ↓
+2. 图像生成(AI 配图)
+  ↓
+3. TTS 生成语音
+  ↓
+4. HTML 帧模板渲染(文字 + 图片)
+  ↓
+5. FFmpeg 合成视频
+  ↓
+输出:完整视频文件
+```
+
+**差距**:
+- 你的项目只能生成音频
+- Pixelle-Video 可以生成**可视化视频内容**
+- 视频比音频更有传播力和变现能力
+
+---
+
+### 2️⃣ **ComfyUI 工作流集成** ⭐⭐⭐⭐⭐
+
+**你的项目**:无图像生成能力
+
+**Pixelle-Video**:
+```python
+# 支持多种图像/视频生成后端
+comfyui_config = {
+    # 本地部署
+    "comfyui_url": "http://127.0.0.1:8188",
+    "comfyui_api_key": "optional",
+
+    # 或云端 RunningHub
+    "runninghub_api_key": "xxx",
+    "runninghub_instance_type": "24GB",  # or 48GB
+}
+
+# 支持多种工作流
+workflows = {
+    "image_qwen.json",      # 通义万相图像
+    "image_flux.json",       # FLUX 图像
+    "tts_edge.json",         # Edge TTS
+    "tts_index2.json",       # Index TTS
+}
+```
+
+**差距**:
+- 你的项目依赖外部 API 生成图像
+- Pixelle-Video 可以本地部署 AI 图像生成(需要 GPU)
+- 工作流可定制化程度更高
+
+---
+
+### 3️⃣ **HTML 帧模板系统** ⭐⭐⭐⭐⭐
+
+**你的项目**:无模板渲染能力
+
+**Pixelle-Video** 模板目录:
+```
+templates/
+├── 1080x1920/          # 竖屏模板(短视频)
+│   ├── default.html
+│   ├── image_default.html
+│   ├── video_default.html
+│   ├── dark_theme.html
+│   └── ... 25 个模板
+├── 1080x1080/          # 方形模板
+├── 1920x1080/          # 横屏模板
+```
+
+**模板功能**:
+```html
+<!-- 支持的参数 -->
+<div class="frame-container"
+     data-width="1080"
+     data-height="1920"
+     data-accent-color="#3498db"
+     data-font-family="sans-serif">
+  <div class="background">{{ image_url }}</div>
+  <div class="subtitle">{{ narration_text }}</div>
+  <div class="title">{{ video_title }}</div>
+</div>
+```
+
+**差距**:
+- 你的项目没有视频帧渲染能力
+- 无法将音频 + 图片合成视频
+- 缺乏多风格模板选择
+
+---
+
+### 4️⃣ **数字人口播** ⭐⭐⭐⭐⭐
+
+**你的项目**:无数字人能力
+
+**Pixelle-Video** (`web/pipelines/digital_human.py`):
+```python
+# 支持数字人视频生成
+async def generate_digital_human(
+    text: str,
+    ref_image: str,           # 参考人物图
+    voice_id: str,            # 语音 ID
+    background: str = None,    # 背景图
+):
+    # 1. TTS 生成音频
+    audio = await tts_service.synthesize(text, voice_id)
+
+    # 2. 口型同步 + 动作生成
+    video = await digital_human_api.generate(
+        audio_path=audio,
+        ref_image=ref_image,
+    )
+
+    return video
+```
+
+**差距**:
+- 数字人是热门应用场景(知识付费、个人IP)
+- 你的项目只有语音,没有形象
+- 无法生成"真人出镜"效果
+
+---
+
+### 5️⃣ **动作迁移** ⭐⭐⭐⭐
+
+**你的项目**:无动作迁移
+
+**Pixelle-Video** (`web/pipelines/action_transfer.py`):
+```python
+# 上传参考视频 + 图片,迁移动作
+async def action_transfer(
+    source_video: str,   # 动作来源视频
+    target_image: str,   # 目标人物图
+):
+    # 提取视频动作 → 应用到图片
+    result = await model_api.apply_motion(
+        source=source_video,
+        target=target_image,
+    )
+    return result
+```
+
+**差距**:
+- 动作迁移可实现"图片说话"效果
+- 适合虚拟主播、数字人场景
+
+---
+
+## 🟡 中等差距
+
+### 6️⃣ **本地 LLM 支持** ⭐⭐⭐⭐
+
+**你的项目**:`models.json` 配置
+```json
+{
+  "vendors": {
+    "minimax": { "baseUrl": "https://api.minimax.chat/v1" },
+    "bailian": { "baseUrl": "https://dashscope.aliyuncs.com/..." }
+  }
+}
+```
+- ❌ 不支持 Ollama
+- ❌ 不支持本地部署
+- ❌ 需要付费 API
+
+**Pixelle-Video**:
+```yaml
+# config.yaml
+llm:
+  api_key: (可选)
+  base_url: http://localhost:11434  # Ollama
+  model: llama3.2  # 或 qwen2.5
+```
+
+**差距**:
+- Ollama 可实现完全免费本地运行
+- 适合有 GPU 的用户
+- 隐私敏感场景更安全
+
+---
+
+### 7️⃣ **多 API 供应商灵活性** ⭐⭐⭐⭐
+
+**你的项目**:通过 `models.json` 配置
+
+**Pixelle-Video**:
+```yaml
+# 同时支持多种 API 供应商
+api_providers:
+  dashscope:
+    api_key: xxx
+    models: [wanx, qwen]
+  openai:
+    api_key: xxx
+    models: [gpt-4o]
+  # 可同时配置,运行时选择
+```
+
+**差距**:
+- Pixelle-Video 支持更多云服务商
+- 支持直接调用厂商原生 API(不仅限于 OpenAI 兼容格式)
+
+---
+
+### 8️⃣ **视频预览与调试 UI** ⭐⭐⭐⭐
+
+**你的项目**:无视频预览
+
+**Pixelle-Video** (`web/components/output_preview.py`):
+```python
+# 实时预览生成结果
+st.expander("🎬 生成结果预览"):
+    # 逐帧预览
+    for frame in storyboard.frames:
+        col.image(frame.composed_image_path)
+        col.audio(frame.audio_path)
+
+    # 视频预览
+    st.video(final_video_path)
+```
+
+**差距**:
+- 你的项目缺乏可视化调试工具
+- 无法实时查看生成进度
+
+---
+
+## 📊 总结:核心差距
+
+| 优先级 | 不足之处 | 影响 |
+|--------|---------|------|
+| 🔴 高 | **视频生成能力** | 无法生成可视化内容 |
+| 🔴 高 | **图像生成集成** | 依赖外部服务 |
+| 🔴 高 | **HTML 模板渲染** | 无法合成视频帧 |
+| 🟡 中 | **本地 LLM** | 无法免费运行 |
+| 🟡 中 | **数字人口播** | 缺少人像能力 |
+| 🟢 低 | **动作迁移** | 高级特效 |
+
+---
+
+## 💡 改进建议
+
+### 短期(1-2 周)
+1. **集成 Pixelle-Video 作为微服务**(已完成初步代码)
+2. **利用你的 TTS 优势**(情感 TTS 是差异化竞争力)
+3. **添加视频预览功能**
+
+### 中期(1-2 月)
+4. **支持 Ollama 本地 LLM**(降低成本)
+5. **增加 HTML 帧模板系统**(复用 Pixelle-Video 模板)
+
+### 长期(3-6 月)
+6. **数字人口播集成**(高价值功能)
+7. **ComfyUI 工作流集成**(需要 GPU)

BIN
light-voice-panel.png


BIN
mobile-create-page.png


BIN
mobile-fullpage.png


+ 6 - 0
my-uniapp-vue3/src/config.ts

@@ -0,0 +1,6 @@
+/**
+ * 全局配置
+ */
+import { getApiBaseUrl } from './utils/config'
+
+export const BASE_URL = getApiBaseUrl()

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

@@ -20,8 +20,8 @@
       </view>
     </view>
 
-    <!-- 专辑列表 - 使用 v-show 避免 scroll-top 初始化的 DOM 未就绪问题 -->
-    <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" :style="{ display: (loading && albumList.length === 0) ? 'none' : 'block' }" :scroll-top="scrollTop">
+    <!-- 专辑列表 -->
+    <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" :style="{ display: (loading && albumList.length === 0) ? 'none' : 'block' }">
       <view v-if="albumList.length === 0 && !loading" class="empty">
         <text class="empty-icon">📚</text>
         <text class="empty-text">暂无专辑</text>
@@ -63,9 +63,6 @@ import { ref, onMounted } from 'vue';
 import { get } from '../../utils/request';
 import { getBookGradient, getTitleLetter } from '../../composables/useCoverStyle';
 
-// scroll-top 用于避免 scrollTop 错误
-const scrollTop = ref(0);
-
 interface Album {
   id: number;
   name: string;

+ 0 - 4
my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue

@@ -15,7 +15,6 @@
     <scroll-view
       class="chapter-content"
       scroll-y="true"
-      :scroll-top="scrollTop"
       :style="{ transform: `translateX(${swipeOffset.value}px)`, transition: swipeTransition.value }"
       @touchstart="onTouchStart"
       @touchmove="onTouchMove"
@@ -300,9 +299,6 @@ import type { Book, Chapter } from '../../utils/book-generator-api';
 import { useNotificationStore } from '../../store/notification';
 import GenerationStatusBadge from '../../components/GenerationStatusBadge.vue';
 
-// scroll-top 用于避免 scrollTop 错误
-const scrollTop = ref(0);
-
 // 手势滑动相关
 const swipeOffset = ref(0);
 const swipeTransition = ref('');

+ 181 - 71
my-uniapp-vue3/src/pages/book-generator/create.vue

@@ -15,23 +15,19 @@
 
       <!-- ===== 区域 1:描述你想要的书籍 ===== -->
       <view class="card hero-card">
-        <view class="section-label">
-          <text class="label-icon">📝</text>
-          <text class="label-text">描述你想要的有声书</text>
-          <text class="label-required">*</text>
-        </view>
-        <text class="section-hint">用自然语言描述主题、风格和读者,越具体效果越好</text>
-
-        <!-- 示例提示词 -->
+        <!-- 示例提示词(轮播展示) -->
         <view class="examples-row">
           <text class="examples-label">试试:</text>
           <view class="example-chips">
-            <text
-              v-for="ex in examplePrompts"
-              :key="ex"
-              class="example-chip"
-              @click="newBook.description = ex"
-            >{{ ex }}</text>
+            <view
+              :key="currentExampleIndex"
+              class="example-chip example-chip-fade"
+              @click="useCurrentExample"
+            >
+              <text class="example-chip-icon">✨</text>
+              <text class="example-chip-text">{{ examplePrompts[currentExampleIndex] }}</text>
+              <text class="example-chip-hint">点击填入</text>
+            </view>
           </view>
         </view>
 
@@ -59,17 +55,14 @@
             :disabled="isRecommending || !newBook.description.trim()"
             @click="requestSmartRecommend"
           >
-            <text class="ai-btn-icon">✨</text>
-            <text>{{ isRecommending ? 'AI 分析中...' : 'AI 智能推荐配置' }}</text>
+          <text>{{ isRecommending ? 'AI 分析中...' : 'AI 智能推荐配置' }}</text>
           </button>
-          <text class="ai-btn-hint">AI 会分析描述并自动填充下方所有配置</text>
         </view>
       </view>
 
       <!-- ===== 区域 2:AI 推荐结果(有配置时显示) ===== -->
       <view v-if="!isEditMode && hasAnyConfig" class="card config-summary-card">
         <view class="section-label">
-          <text class="label-icon">🎯</text>
           <text class="label-text">当前配置</text>
           <text v-if="recommendSource === 'ai'" class="config-source-tag">AI 推荐</text>
           <text v-else-if="recommendSource === 'template'" class="config-source-tag template-tag">模板</text>
@@ -77,33 +70,27 @@
 
         <view class="config-tags">
           <view v-if="newBook.bookScale" class="config-tag scale-tag" @click="scrollToSection('scale')">
-            <text class="config-tag-icon">📏</text>
             <text class="config-tag-label">规模</text>
             <text class="config-tag-value">{{ getScaleShortLabel(newBook.bookScale) }}</text>
             <text class="config-tag-edit">修改</text>
           </view>
           <view v-if="newBook.targetAudience" class="config-tag" @click="scrollToSection('audience')">
-            <text class="config-tag-icon">{{ getAudienceIcon(newBook.targetAudience) }}</text>
             <text class="config-tag-value">{{ getAudienceLabel(newBook.targetAudience) }}</text>
             <text class="config-tag-edit">修改</text>
           </view>
           <view v-if="newBook.knowledgeLevel" class="config-tag" @click="scrollToSection('knowledge')">
-            <text class="config-tag-icon">{{ getKnowledgeIcon(newBook.knowledgeLevel) }}</text>
             <text class="config-tag-value">{{ getKnowledgeLevelLabel(newBook.knowledgeLevel) }}</text>
             <text class="config-tag-edit">修改</text>
           </view>
           <view v-if="newBook.style" class="config-tag" @click="scrollToSection('style')">
-            <text class="config-tag-icon">✍️</text>
             <text class="config-tag-value">{{ newBook.style }}</text>
             <text class="config-tag-edit">修改</text>
           </view>
           <view v-if="newBook.industry" class="config-tag" @click="scrollToSection('industry')">
-            <text class="config-tag-icon">🏭</text>
             <text class="config-tag-value">{{ getIndustryLabel(newBook.industry) || newBook.industry }}</text>
             <text class="config-tag-edit">修改</text>
           </view>
           <view v-if="newBook.specialFeatures.length > 0" class="config-tag" @click="scrollToSection('features')">
-            <text class="config-tag-icon">⭐</text>
             <text class="config-tag-value">{{ newBook.specialFeatures.length }}项特殊要求</text>
             <text class="config-tag-edit">修改</text>
           </view>
@@ -112,19 +99,16 @@
         <!-- 预估信息 -->
         <view v-if="bookEstimate && bookEstimate.words" class="estimate-bar">
           <view class="estimate-item">
-            <text class="est-icon">📖</text>
             <text class="est-num">{{ formatNumber(bookEstimate.words.avg) }}</text>
             <text class="est-unit">字</text>
           </view>
           <text class="est-divider">·</text>
           <view class="estimate-item">
-            <text class="est-icon">📑</text>
             <text class="est-num">{{ bookEstimate.estimatedChapters }}</text>
             <text class="est-unit">章</text>
           </view>
           <text class="est-divider">·</text>
           <view class="estimate-item">
-            <text class="est-icon">🎧</text>
             <text class="est-num">{{ bookEstimate.audioMinutes.avg }}</text>
             <text class="est-unit">分钟</text>
           </view>
@@ -141,8 +125,6 @@
           <text class="label-icon">📏</text>
           <text class="label-text">选择书籍规模</text>
         </view>
-        <text class="section-hint">规模决定总字数和章节数,AI 会根据规模规划大纲</text>
-
         <view class="scale-grid">
           <view
             v-for="scale in displayScales"
@@ -177,7 +159,6 @@
       <!-- ===== 区域 4:内容定位 ===== -->
       <view class="card" id="section-audience">
         <view class="section-label">
-          <text class="label-icon">👥</text>
           <text class="label-text">内容定位</text>
         </view>
 
@@ -191,7 +172,6 @@
               :class="['chip', newBook.targetAudience === a.value ? 'chip-active' : '']"
               @click="newBook.targetAudience = newBook.targetAudience === a.value ? '' : a.value"
             >
-              <text class="chip-icon">{{ a.icon }}</text>
               <view class="chip-body">
                 <text class="chip-text">{{ a.label }}</text>
                 <text class="chip-desc">{{ a.desc }}</text>
@@ -210,7 +190,6 @@
               :class="['chip chip-sm', newBook.knowledgeLevel === l.value ? 'chip-active' : '']"
               @click="newBook.knowledgeLevel = newBook.knowledgeLevel === l.value ? '' : l.value"
             >
-              <text class="chip-icon">{{ l.icon }}</text>
               <view class="chip-body">
                 <text class="chip-text">{{ l.label }}</text>
               </view>
@@ -228,7 +207,6 @@
               :class="['chip chip-sm', newBook.bookType === bt.value ? 'chip-active' : '']"
               @click="newBook.bookType = newBook.bookType === bt.value ? 'auto' : bt.value"
             >
-              <text class="chip-icon">{{ bt.icon }}</text>
               <view class="chip-body">
                 <text class="chip-text">{{ bt.label }}</text>
               </view>
@@ -253,10 +231,29 @@
         </view>
       </view>
 
-      <!-- ===== 区域 5:高级定制(可折叠) ===== -->
+      <!-- ===== 区域 5:声音速度 ===== -->
+      <view class="card" id="section-speed">
+        <view class="section-label">
+          <text class="label-text">语速</text>
+        </view>
+        <view class="speed-display">
+          <text class="speed-value">{{ newBook.voiceSpeed.toFixed(1) }}x</text>
+        </view>
+        <slider
+          :value="(newBook.voiceSpeed - 0.5) * 100 / 1.5"
+          :min="0"
+          :max="100"
+          @changing="(e: any) => newBook.voiceSpeed = Number((0.5 + (e.detail.value / 100) * 1.5).toFixed(1))"
+          activeColor="#4F46E5"
+          backgroundColor="#e5e7eb"
+          block-size="20"
+        />
+        <view class="param-range"><text>0.5x</text><text>2.0x</text></view>
+      </view>
+
+      <!-- ===== 区域 6:高级定制(可折叠) ===== -->
       <view class="card">
         <view class="section-label collapsible" @click="showAdvancedOptions = !showAdvancedOptions">
-          <text class="label-icon">⚙️</text>
           <text class="label-text">高级定制</text>
           <view v-if="advancedBadgeCount > 0" class="adv-badge">{{ advancedBadgeCount }}</view>
           <text class="collapse-icon">{{ showAdvancedOptions ? '▲' : '▼' }}</text>
@@ -273,7 +270,6 @@
                 :class="['chip chip-sm chip-template', selectedTemplateName === tmpl.name ? 'chip-active' : '']"
                 @click="applyTemplate(tmpl)"
               >
-                <text class="chip-icon">{{ tmpl.icon }}</text>
                 <view class="chip-body">
                   <text class="chip-text">{{ tmpl.name }}</text>
                   <text class="chip-desc chip-desc-inline">{{ tmpl.desc }}</text>
@@ -307,7 +303,6 @@
                 :class="['chip chip-sm', newBook.industry === ind.value ? 'chip-active' : '']"
                 @click="newBook.industry = newBook.industry === ind.value ? '' : ind.value"
               >
-                <text class="chip-icon">{{ ind.icon }}</text>
                 <text class="chip-text">{{ ind.label }}</text>
               </view>
             </view>
@@ -323,7 +318,6 @@
                 :class="['chip chip-sm', newBook.specialFeatures.includes(sf.value) ? 'chip-active' : '']"
                 @click="toggleSpecialFeature(sf.value)"
               >
-                <text class="chip-icon">{{ sf.icon }}</text>
                 <text class="chip-text">{{ sf.label }}</text>
                 <text v-if="newBook.specialFeatures.includes(sf.value)" class="chip-check">✓</text>
               </view>
@@ -348,7 +342,6 @@
             :disabled="!canCreateBook || creating"
             @click="createNewBook"
           >
-            <text class="btn-create-icon">🚀</text>
             <text>{{ creating ? '创建中...' : '开始创建有声书' }}</text>
           </button>
           <text class="bottom-link" @click="switchToInteractive">需要更精细控制?试试<text class="bottom-link-highlight">交互模式</text></text>
@@ -360,7 +353,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue';
+import { ref, computed, onMounted, onUnmounted, onBeforeUnmount } from 'vue';
 import { onLoad } from '@dcloudio/uni-app';
 import * as api from '../../utils/book-generator-api';
 import { getApiBaseUrl } from '../../utils/config';
@@ -388,6 +381,7 @@ const newBook = ref({
   bookScale: '',
   bookType: 'auto',
   outlineLevel: 'auto',
+  voiceSpeed: 1.0,
 });
 
 // 示例提示词
@@ -398,6 +392,30 @@ const examplePrompts = [
   '介绍中国历史的通识读物,按时间线讲述,通俗易懂适合大众读者',
 ];
 
+// 示例提示词轮播
+const currentExampleIndex = ref(0);
+let exampleTimer: ReturnType<typeof setInterval> | null = null;
+const EXAMPLE_INTERVAL_MS = 4000; // 每 4 秒切换一条
+
+function useCurrentExample() {
+  newBook.value.description = examplePrompts[currentExampleIndex.value];
+}
+
+function startExampleRotation() {
+  stopExampleRotation();
+  if (examplePrompts.length <= 1) return;
+  exampleTimer = setInterval(() => {
+    currentExampleIndex.value = (currentExampleIndex.value + 1) % examplePrompts.length;
+  }, EXAMPLE_INTERVAL_MS);
+}
+
+function stopExampleRotation() {
+  if (exampleTimer) {
+    clearInterval(exampleTimer);
+    exampleTimer = null;
+  }
+}
+
 // 书籍预估信息
 const bookEstimate = ref<{
   scale: string;
@@ -645,30 +663,64 @@ async function requestSmartRecommend() {
 let detectTimer: any = null;
 async function detectScaleFromDesc() {
   const desc = newBook.value.description?.trim();
-  if (!desc || newBook.value.bookScale) return;
+  if (!desc) return;
   clearTimeout(detectTimer);
   detectTimer = setTimeout(async () => {
-    try {
-      const response = await uni.request({
-        url: `${BASE_URL}/book-generator/langgraph/detect-scale`,
-        method: 'POST',
-        data: { description: desc }
-      });
-      const res = response.data as any;
-      if (res.code === 0 && res.data) {
-        newBook.value.bookScale = res.data.bookScale;
-        bookEstimate.value = {
-          scale: res.data.bookScale,
-          words: { min: res.data.totalWords, max: res.data.totalWords, avg: res.data.totalWords },
-          audioMinutes: { min: 0, max: 0, avg: 0 },
-          estimatedChapters: res.data.chapters,
-        };
-        loadEstimate(res.data.bookScale);
-      }
-    } catch { /* ignore */ }
+    // 检测是否为英文内容,自动调慢语速
+    if (detectEnglishContent(desc)) {
+      newBook.value.voiceSpeed = 0.5;
+    }
+
+    if (!newBook.value.bookScale) {
+      try {
+        const response = await uni.request({
+          url: `${BASE_URL}/book-generator/langgraph/detect-scale`,
+          method: 'POST',
+          data: { description: desc }
+        });
+        const res = response.data as any;
+        if (res.code === 0 && res.data) {
+          newBook.value.bookScale = res.data.bookScale;
+          bookEstimate.value = {
+            scale: res.data.bookScale,
+            words: { min: res.data.totalWords, max: res.data.totalWords, avg: res.data.totalWords },
+            audioMinutes: { min: 0, max: 0, avg: 0 },
+            estimatedChapters: res.data.chapters,
+          };
+          loadEstimate(res.data.bookScale);
+        }
+      } catch { /* ignore */ }
+    }
   }, 800);
 }
 
+// 检测文本中英文相关内容的比例
+function detectEnglishRatio(text: string): number {
+  // 检测纯英文单词的比例
+  const words = text.match(/[a-zA-Z]+/g) || [];
+  const totalWords = text.match(/[\u4e00-\u9fa5a-zA-Z]+/g) || [];
+  if (totalWords.length === 0) return 0;
+  return words.length / totalWords.length;
+}
+
+// 检测描述是否暗示内容为英文
+function detectEnglishContent(text: string): boolean {
+  const lowerText = text.toLowerCase();
+  // 检测英文相关关键词
+  const englishKeywords = [
+    'english', '英文', '美文', 'toeic', 'toefl', 'ielts', 'gre', 'gmat',
+    'python', 'javascript', 'java', '编程', 'code', '编程语言',
+    'literature', '文学', 'novel', '小说', 'essay', '散文',
+    'programming', 'software', 'software', 'algorithm', '算法',
+    'technical', '技术', 'tutorial', '教程',
+  ];
+  // 检测英文单词占比
+  const englishRatio = detectEnglishRatio(text);
+  if (englishRatio > 0.4) return true;
+  // 检测是否包含英文关键词
+  return englishKeywords.some(kw => lowerText.includes(kw));
+}
+
 function switchToInteractive() {
   uni.navigateTo({ url: '/pages/book-generator/interactive' });
 }
@@ -750,6 +802,7 @@ async function createNewBook() {
       bookScale: newBook.value.bookScale || undefined,
       bookType,
       genLevel,
+      voiceSpeed: newBook.value.voiceSpeed !== 1.0 ? newBook.value.voiceSpeed : undefined,
     });
 
     uni.showToast({ title: '创建成功,正在跳转...', icon: 'success' });
@@ -763,7 +816,15 @@ async function createNewBook() {
   }
 }
 
-onMounted(() => {});
+onMounted(() => {
+  startExampleRotation();
+});
+onBeforeUnmount(() => {
+  stopExampleRotation();
+});
+onUnmounted(() => {
+  stopExampleRotation();
+});
 onLoad(async (query: any) => {
   if (query?.editId) {
     isEditMode.value = true;
@@ -828,29 +889,59 @@ onLoad(async (query: any) => {
 .label-text { font-size: 30rpx; font-weight: 700; color: #1f2937; }
 .label-required { color: #ef4444; font-size: 28rpx; font-weight: 600; margin-left: 4rpx; }
 .collapse-icon { font-size: 24rpx; color: #9ca3af; margin-left: auto; }
-.section-hint { font-size: 24rpx; color: #9ca3af; display: block; margin-bottom: 24rpx; margin-top: 4rpx; }
 
 /* ========== Hero 区域(描述) ========== */
 .hero-card { background: linear-gradient(180deg, #ffffff 0%, #fefefe 100%); }
 
-/* 示例提示词 */
+/* 示例提示词(轮播卡片) */
 .examples-row { display: flex; align-items: flex-start; gap: 12rpx; margin-bottom: 20rpx; }
-.examples-label { font-size: 22rpx; color: #9ca3af; flex-shrink: 0; padding-top: 4rpx; }
-.example-chips { display: flex; flex-wrap: wrap; gap: 10rpx; flex: 1; }
+.examples-label { font-size: 22rpx; color: #9ca3af; flex-shrink: 0; padding-top: 12rpx; }
+.example-chips { display: flex; flex: 1; min-width: 0; }
+
 .example-chip {
-  font-size: 22rpx; color: #4f46e5; background: #eef2ff;
-  padding: 8rpx 16rpx; border-radius: 8rpx;
-  line-height: 1.4; max-width: 340rpx;
+  display: flex; align-items: center; gap: 12rpx;
+  width: 100%;
+  padding: 18rpx 20rpx;
+  background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
+  border: 1.5rpx solid #c7d2fe;
+  border-radius: 14rpx;
+  box-sizing: border-box;
+  transition: transform 0.15s, box-shadow 0.15s;
+}
+.example-chip:active {
+  transform: scale(0.98);
+  box-shadow: 0 2rpx 8rpx rgba(99,102,241,0.18);
+}
+.example-chip-icon { font-size: 28rpx; flex-shrink: 0; }
+.example-chip-text {
+  flex: 1; min-width: 0;
+  font-size: 26rpx; color: #4338ca; line-height: 1.5;
   display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
   overflow: hidden;
 }
-.example-chip:active { background: #e0e7ff; transform: scale(0.97); }
+.example-chip-hint {
+  flex-shrink: 0;
+  font-size: 20rpx; color: #6366f1;
+  padding: 4rpx 10rpx;
+  background: rgba(255,255,255,0.7);
+  border-radius: 6rpx;
+  font-weight: 500;
+}
+
+/* 切换时的淡入动画 */
+.example-chip-fade {
+  animation: exampleFadeIn 0.45s ease both;
+}
+@keyframes exampleFadeIn {
+  0%   { opacity: 0; transform: translateY(6rpx); }
+  100% { opacity: 1; transform: translateY(0); }
+}
 
 /* 文本域 */
 .textarea-wrapper { position: relative; margin-bottom: 16rpx; }
 .form-textarea {
-  width: 100%; min-height: 260rpx;
-  padding: 24rpx; padding-bottom: 56rpx;
+  width: 100%; min-height: 420rpx;
+  padding: 28rpx; padding-bottom: 60rpx;
   background: #f9fafb; border: 2rpx solid #e5e7eb;
   border-radius: 16rpx; font-size: 28rpx; line-height: 1.7;
   box-sizing: border-box; transition: border-color 0.2s, box-shadow 0.2s;
@@ -880,7 +971,6 @@ onLoad(async (query: any) => {
 .btn-ai-recommend[disabled] { opacity: 0.5; }
 .btn-ai-loading { opacity: 0.8; }
 .ai-btn-icon { font-size: 32rpx; }
-.ai-btn-hint { font-size: 22rpx; color: #9ca3af; text-align: center; display: block; }
 
 /* ========== 配置摘要卡片 ========== */
 .config-summary-card {
@@ -997,6 +1087,26 @@ onLoad(async (query: any) => {
 }
 .advanced-content { margin-top: 24rpx; padding-top: 24rpx; border-top: 1rpx solid #f3f4f6; }
 
+/* ========== 声音速度 ========== */
+.speed-display {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 8rpx;
+}
+.speed-value {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #4f46e5;
+}
+.param-range {
+  display: flex;
+  justify-content: space-between;
+  font-size: 22rpx;
+  color: #9ca3af;
+  margin-top: 4rpx;
+}
+
 /* ========== 底部操作区 ========== */
 .bottom-bar {
   position: fixed; bottom: 0; left: 0; right: 0;

+ 197 - 2
my-uniapp-vue3/src/pages/create/index.vue

@@ -56,6 +56,72 @@
       </view>
     </view>
 
+    <!-- 情感检测预览卡片(文本长度≥10时显示) -->
+    <view v-if="text.length >= 10" class="emotion-card">
+      <view class="emotion-card-header">
+        <text class="emotion-card-icon">🎭</text>
+        <text class="emotion-card-title">内容分析</text>
+        <text v-if="detecting" class="emotion-detecting">检测中...</text>
+      </view>
+
+      <!-- 检测成功:显示推荐 -->
+      <view v-if="detectedEmotion && !detecting" class="emotion-result">
+        <view class="emotion-row">
+          <text class="emotion-label">情感</text>
+          <text class="emotion-value">{{ detectedEmotionLabel }}</text>
+        </view>
+        <view v-if="detectedVoiceName" class="emotion-row">
+          <text class="emotion-label">推荐音色</text>
+          <text class="emotion-value voice-value">{{ detectedVoiceName }}</text>
+        </view>
+
+        <!-- 情感覆盖选择器 -->
+        <view class="emotion-override">
+          <text class="emotion-override-label">手动调整:</text>
+          <scroll-view scroll-x class="emotion-scroll">
+            <view class="emotion-chips">
+              <view
+                v-for="emo in emotionOptions"
+                :key="emo.key"
+                class="emotion-chip"
+                :class="{ active: selectedEmotion === emo.key }"
+                @click="selectEmotion(emo.key)"
+              >
+                <text>{{ emo.label }}</text>
+              </view>
+            </view>
+          </scroll-view>
+        </view>
+      </view>
+
+      <!-- 未检测到特征:仅显示兜底情感 -->
+      <view v-if="!detectedEmotion && !detecting && emotionDetectAttempted" class="emotion-result">
+        <view class="emotion-row">
+          <text class="emotion-label">情感</text>
+          <text class="emotion-value emotion-fallback">{{ detectedEmotionLabel || '中性' }}</text>
+        </view>
+        <text class="emotion-hint">未匹配到特定内容类型,使用通用音色</text>
+
+        <!-- 情感覆盖选择器 -->
+        <view class="emotion-override">
+          <text class="emotion-override-label">手动调整:</text>
+          <scroll-view scroll-x class="emotion-scroll">
+            <view class="emotion-chips">
+              <view
+                v-for="emo in emotionOptions"
+                :key="emo.key"
+                class="emotion-chip"
+                :class="{ active: selectedEmotion === emo.key }"
+                @click="selectEmotion(emo.key)"
+              >
+                <text>{{ emo.label }}</text>
+              </view>
+            </view>
+          </scroll-view>
+        </view>
+      </view>
+    </view>
+
     <!-- 底部固定区:Tab 工具栏 -->
     <view class="bottom-bar">
       <!-- 主操作行:生成按钮 + 快捷信息 -->
@@ -245,7 +311,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue';
+import { ref, computed, onMounted, watch } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import { useUserStore } from '../../store/user';
 import { useAudioStore } from '../../store/audio';
@@ -262,6 +328,73 @@ const generating = ref(false);
 const previewingVoice = ref<string | null>(null);
 let previewAudio: HTMLAudioElement | null = null;
 
+// ============ 情感检测 ============
+const detecting = ref(false);
+const detectedEmotion = ref(false);
+const detectedEmotionLabel = ref('');
+const detectedVoiceName = ref('');
+const emotionDetectAttempted = ref(false);
+const selectedEmotion = ref(''); // 用户手动选择的情感,空=不覆盖
+
+const emotionOptions = [
+  { key: 'neutral', label: '😐 中性' },
+  { key: 'happy', label: '😊 快乐' },
+  { key: 'sad', label: '😢 悲伤' },
+  { key: 'angry', label: '😠 愤怒' },
+  { key: 'fearful', label: '😨 恐惧' },
+  { key: 'surprised', label: '😲 惊喜' },
+  { key: 'disgusted', label: '🤢 厌恶' },
+];
+
+let detectTimer: ReturnType<typeof setTimeout> | null = null;
+
+function selectEmotion(key: string) {
+  selectedEmotion.value = selectedEmotion.value === key ? '' : key;
+}
+
+// 防抖检测文本情感
+function debouncedDetect() {
+  if (detectTimer) clearTimeout(detectTimer);
+  detectTimer = setTimeout(async () => {
+    if (text.value.trim().length < 10) {
+      detectedEmotion.value = false;
+      detectedEmotionLabel.value = '';
+      detectedVoiceName.value = '';
+      emotionDetectAttempted.value = false;
+      return;
+    }
+    detecting.value = true;
+    emotionDetectAttempted.value = true;
+    try {
+      const res = await post<{
+        detected: boolean;
+        voiceId?: string;
+        voiceName?: string;
+        emotion?: string;
+        emotionLabel?: string;
+        hint?: string;
+      }>('/tts/detect-emotion', { text: text.value });
+      detectedEmotion.value = res.detected || false;
+      detectedEmotionLabel.value = res.emotionLabel || '';
+      detectedVoiceName.value = res.voiceName || '';
+      if (res.emotion && !selectedEmotion.value) {
+        // 首次检测到情感时,自动选中(但不覆盖用户手动选择)
+      }
+    } catch (e) {
+      /* 静默失败 */
+    } finally {
+      detecting.value = false;
+    }
+  }, 800);
+}
+
+// 监听文本变化,触发检测
+watch(text, () => {
+  selectedEmotion.value = ''; // 文本变化时重置手动选择
+  debouncedDetect();
+});
+
+
 // 文档上传
 interface UploadedDoc { fileName: string; wordCount: number; fileSize: number; format: string; truncated: boolean; }
 const uploadedDoc = ref<UploadedDoc>({ fileName: '', wordCount: 0, fileSize: 0, format: '', truncated: false });
@@ -439,7 +572,12 @@ async function handleGenerate() {
   const bookId = selectedAlbum.value?.id;
   generating.value = true;
   try {
-    const result = await audioStore.generateAudio(text.value, selectedVoice.value, voiceParams.value, { bookId });
+    // 如果用户手动选择了情感,注入到 voiceParams
+    const params = { ...voiceParams.value };
+    if (selectedEmotion.value) {
+      (params as any).emotion = selectedEmotion.value;
+    }
+    const result = await audioStore.generateAudio(text.value, selectedVoice.value, params, { bookId });
     text.value = '';
     uni.showToast({ title: '任务已提交', icon: 'success' });
     setTimeout(() => { uni.navigateTo({ url: `/pages/book-generator/detail?id=${result.bookId}` }); }, 800);
@@ -459,6 +597,63 @@ function goToMember() { showQuotaModal.value = false; uni.navigateTo({ url: '/pa
 .main-content { flex: 1; padding: 16rpx 24rpx 0; overflow-y: auto; }
 .input-area { background: #ffffff; border-radius: 16rpx; padding: 24rpx; }
 
+/* 情感检测卡片 */
+.emotion-card {
+  margin: 16rpx 0;
+  padding: 20rpx 24rpx;
+  background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
+  border-radius: 16rpx;
+  border: 1rpx solid #e0e7ff;
+}
+.emotion-card-header {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  margin-bottom: 12rpx;
+}
+.emotion-card-icon { font-size: 28rpx; }
+.emotion-card-title { font-size: 24rpx; font-weight: 600; color: #4f46e5; }
+.emotion-detecting { font-size: 20rpx; color: #9ca3af; margin-left: auto; }
+
+.emotion-result {}
+.emotion-row {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  padding: 8rpx 0;
+}
+.emotion-label { font-size: 22rpx; color: #6b7280; width: 120rpx; flex-shrink: 0; }
+.emotion-value { font-size: 26rpx; font-weight: 600; color: #1f2937; }
+.emotion-value.voice-value { color: #4f46e5; }
+.emotion-value.emotion-fallback { color: #9ca3af; }
+.emotion-hint { font-size: 20rpx; color: #9ca3af; margin-top: 4rpx; }
+
+/* 情感覆盖选择器 */
+.emotion-override {
+  margin-top: 16rpx;
+  padding-top: 12rpx;
+  border-top: 1rpx solid #e0e7ff;
+}
+.emotion-override-label { font-size: 22rpx; color: #6b7280; }
+.emotion-scroll { white-space: nowrap; margin-top: 8rpx; }
+.emotion-chips { display: flex; gap: 10rpx; }
+.emotion-chip {
+  flex-shrink: 0;
+  padding: 8rpx 16rpx;
+  background: #ffffff;
+  border: 1rpx solid #d1d5db;
+  border-radius: 20rpx;
+  font-size: 22rpx;
+  color: #374151;
+  transition: all 0.2s;
+}
+.emotion-chip.active {
+  background: #4f46e5;
+  border-color: #4f46e5;
+  color: #ffffff;
+  font-weight: 600;
+}
+
 /* 专辑选择器(页面内下拉) */
 .album-selector {
   display: flex;

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

@@ -8,7 +8,7 @@
       <view class="nav-btn" />
     </view>
 
-    <scroll-view scroll-y class="content" :scroll-top="scrollTop">
+    <scroll-view scroll-y class="content">
       <view v-if="drafts.length === 0 && !loading" class="empty">
         <text class="empty-icon">📝</text>
         <text class="empty-text">暂无草稿</text>
@@ -49,9 +49,6 @@
 import { ref, onMounted } from 'vue';
 import { get, del } from '../../utils/request';
 
-// scroll-top 用于避免 scrollTop 错误
-const scrollTop = ref(0);
-
 const drafts = ref<any[]>([]);
 const loading = ref(false);
 

+ 1 - 4
my-uniapp-vue3/src/pages/history/index.vue

@@ -57,7 +57,7 @@
     </view>
 
     <!-- 音频列表 -->
-    <scroll-view scroll-y class="audio-list" @scrolltolower="loadMore" :scroll-top="scrollTop">
+    <scroll-view scroll-y class="audio-list" @scrolltolower="loadMore">
       <!-- 骨架屏加载状态 -->
       <SkeletonList v-if="loading && audioList.length === 0" layout="history" :count="6" />
 
@@ -163,9 +163,6 @@ const isAllSelected = ref(false);
 const searchKeyword = ref('');
 const isSearching = ref(false);
 
-// scroll-top 用于避免 scrollTop 错误
-const scrollTop = ref(0);
-
 // 标签状态
 const tabs = ref([
   { id: 1, name: '全部' },

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

@@ -12,7 +12,7 @@
     </view>
 
     <!-- 骨架屏加载状态 -->
-    <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-if="loading && bookList.length === 0" :scroll-top="scrollTop">
+    <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-if="loading && bookList.length === 0">
       <!-- 最近收听骨架屏 -->
       <view v-if="userStore.isLoggedIn" class="recent-section">
         <view class="skeleton-section-title"></view>
@@ -36,7 +36,7 @@
     </scroll-view>
 
     <!-- 书籍列表 -->
-    <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-else :scroll-top="scrollTop">
+    <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-else>
       <!-- 最近收听 -->
       <view v-if="userStore.isLoggedIn && recentAudios.length > 0" class="recent-section">
         <text class="section-title">最近收听</text>
@@ -138,9 +138,6 @@ import { useNotificationStore } from '../../store/notification';
 import MiniPlayer from '../../components/MiniPlayer.vue';
 import { getBookGradient, getTitleLetter } from '../../composables/useCoverStyle';
 
-// scroll-top 用于避免 scrollTop 错误
-const scrollTop = ref(0);
-
 interface Book {
   id: number;
   title: string;

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

@@ -10,7 +10,7 @@
       </view>
     </view>
 
-    <scroll-view scroll-y class="content" :scroll-top="scrollTop">
+    <scroll-view scroll-y class="content">
       <view v-if="notifications.length === 0 && !loading" class="empty">
         <text class="empty-icon">🔔</text>
         <text class="empty-text">暂无通知</text>
@@ -47,9 +47,6 @@
 import { ref, onMounted } from 'vue';
 import { get, put } from '../../utils/request';
 
-// scroll-top 用于避免 scrollTop 错误
-const scrollTop = ref(0);
-
 const notifications = ref<any[]>([]);
 const loading = ref(false);
 

+ 44 - 1
my-uniapp-vue3/src/pages/pixelle-video/index.vue

@@ -121,7 +121,9 @@
       <!-- 音频播放 -->
       <view v-if="audioUrl" class="result-card">
         <text class="card-title">🎧 音频预览</text>
-        <audio :src="audioUrl" controls class="audio-player"></audio>
+        <button class="audio-play-btn" @click="toggleAudioPlay">
+          {{ isPlayingAudio ? '⏸️ 暂停' : '▶️ 播放' }}
+        </button>
       </view>
     </view>
   </view>
@@ -139,6 +141,8 @@ const isGeneratingAudio = ref(false)
 const serviceStatus = ref('unknown')
 const result = ref<any>(null)
 const audioUrl = ref('')
+const isPlayingAudio = ref(false)
+let audioContext: UniApp.InnerAudioContext | null = null
 
 const styles = [
   '科技风', '自然风', '商务风', '清新风', '复古风',
@@ -233,7 +237,36 @@ async function generateAudio() {
   }
 }
 
+// 播放/暂停音频
+function toggleAudioPlay() {
+  if (!audioUrl.value) return
+
+  if (!audioContext) {
+    audioContext = uni.createInnerAudioContext()
+    audioContext.src = audioUrl.value
+    audioContext.onPlay(() => {
+      isPlayingAudio.value = true
+    })
+    audioContext.onPause(() => {
+      isPlayingAudio.value = false
+    })
+    audioContext.onEnded(() => {
+      isPlayingAudio.value = false
+    })
+  }
+
+  if (isPlayingAudio.value) {
+    audioContext.pause()
+  } else {
+    audioContext.play()
+  }
+}
+
 function goBack() {
+  if (audioContext) {
+    audioContext.destroy()
+    audioContext = null
+  }
   uni.navigateBack()
 }
 
@@ -454,4 +487,14 @@ onMounted(() => {
   width: 100%;
   margin-top: 10rpx;
 }
+
+.audio-play-btn {
+  margin-top: 16rpx;
+  padding: 16rpx 32rpx;
+  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+  color: #ffffff;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+  text-align: center;
+}
 </style>

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

@@ -8,7 +8,7 @@
       <view class="nav-btn" />
     </view>
 
-    <scroll-view scroll-y class="content" :scroll-top="scrollTop">
+    <scroll-view scroll-y class="content">
       <view v-if="!loading && playlist" class="playlist-header">
         <view class="playlist-cover">
           <text class="cover-icon">🎵</text>
@@ -59,9 +59,6 @@ import { onLoad, onShow } from '@dcloudio/uni-app';
 import { useAudioStore } from '../../store/audio';
 import { get, post, del } from '../../utils/request';
 
-// scroll-top 用于避免 scrollTop 错误
-const scrollTop = ref(0);
-
 const audioStore = useAudioStore();
 const playlistId = ref('');
 const playlist = ref<any>(null);

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

@@ -12,7 +12,7 @@
     </view>
 
     <!-- 播放列表 -->
-    <scroll-view scroll-y class="content" :scroll-top="scrollTop">
+    <scroll-view scroll-y class="content">
       <view v-if="playlists.length === 0 && !loading" class="empty">
         <text class="empty-icon">📋</text>
         <text class="empty-text">暂无播放列表</text>
@@ -82,9 +82,6 @@
 import { ref, onMounted } from 'vue';
 import { get, post } from '../../utils/request';
 
-// scroll-top 用于避免 scrollTop 错误
-const scrollTop = ref(0);
-
 const playlists = ref<any[]>([]);
 const loading = ref(false);
 const showCreateModal = ref(false);

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

@@ -12,12 +12,12 @@
     </view>
 
     <!-- 骨架屏加载状态 -->
-    <scroll-view scroll-y class="results" v-if="loading" :scroll-top="scrollTop">
+    <scroll-view scroll-y class="results" v-if="loading">
       <SkeletonList layout="search-result" :count="5" />
     </scroll-view>
 
     <!-- 搜索结果 -->
-    <scroll-view scroll-y class="results" v-else-if="searchResults.length > 0" :scroll-top="scrollTop">
+    <scroll-view scroll-y class="results" v-else-if="searchResults.length > 0">
       <view class="result-list">
         <view
           v-for="item in searchResults"
@@ -103,9 +103,6 @@ import { getCoverGradient, getTitleLetter } from '../../composables/useCoverStyl
 // 缓存键名
 const HOT_SEARCH_CACHE_KEY = 'hot_search_cache';
 
-// scroll-top 用于避免 scrollTop 错误
-const scrollTop = ref(0);
-
 const audioStore = useAudioStore();
 
 const searchQuery = ref('');

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

@@ -10,7 +10,7 @@
     </view>
 
     <!-- 设置列表 -->
-    <scroll-view scroll-y class="settings-list" :scroll-top="scrollTop">
+    <scroll-view scroll-y class="settings-list">
       <!-- 播放设置 -->
       <view class="settings-section">
         <text class="section-title">播放</text>
@@ -133,7 +133,7 @@
     <view v-if="showVoicePicker" class="picker-modal" @click="showVoicePicker = false">
       <view class="picker-content" @click.stop>
         <text class="picker-title">选择默认音色</text>
-        <scroll-view scroll-y class="voice-list" :scroll-top="scrollTop">
+        <scroll-view scroll-y class="voice-list">
           <view
             v-for="voice in voiceOptions"
             :key="voice.id"
@@ -160,9 +160,6 @@
 import { ref, onMounted } from 'vue';
 import { get, put } from '../../utils/request';
 
-// scroll-top 用于避免 scrollTop 错误
-const scrollTop = ref(0);
-
 // 主题相关
 const theme = ref('light');
 const themeLabels: Record<string, string> = {

+ 42 - 3
my-uniapp-vue3/src/store/audio.ts

@@ -230,6 +230,19 @@ export const useAudioStore = defineStore('audio', () => {
       ctx.play();
     }
 
+    // 重新应用 playRate(切歌后 audioContext 会重置 playbackRate)
+    if (!useBackgroundAudio && audioContext) {
+      try {
+        audioContext.playbackRate = playRate.value;
+        console.log(`[Audio] play() 后重新应用 playbackRate=${playRate.value}`);
+        // H5 兼容
+        // #ifdef H5
+        const innerAny = audioContext as any;
+        if (innerAny._audio) innerAny._audio.playbackRate = playRate.value;
+        // #endif
+      } catch (e) {}
+    }
+
     // 使用 API 返回的 audioDuration 作为初始时长
     if (audio.audioDuration && audio.audioDuration > 0 && audio.audioDuration < 3600) {
       duration.value = audio.audioDuration;
@@ -340,12 +353,38 @@ export const useAudioStore = defineStore('audio', () => {
   // 设置播放速度
   function setPlayRate(rate: number) {
     playRate.value = rate;
+    console.log(`[Audio] setPlayRate=${rate}, useBackgroundAudio=${useBackgroundAudio}, hasBg=${!!bgAudioManager}, hasCtx=${!!audioContext}`);
+
+    // BackgroundAudioManager:尝试设置(部分平台支持,失败不影响)
     if (useBackgroundAudio && bgAudioManager) {
-      // BackgroundAudioManager 不支持 playbackRate
-      return;
+      try {
+        // #ifdef APP-PLUS
+        // App 平台 BackgroundAudioManager 不支持 playbackRate
+        return;
+        // #endif
+      } catch (e) {}
     }
+
+    // H5/InnerAudioContext 模式:设置 playbackRate
     if (audioContext) {
-      audioContext.playbackRate = rate;
+      try {
+        audioContext.playbackRate = rate;
+        console.log(`[Audio] playbackRate=${rate} 设置成功`);
+      } catch (e) {
+        console.error('[Audio] 设置 playbackRate 失败:', e);
+      }
+      // H5 兼容:直接操作底层 audio 元素(解决 uniapp H5 端 playbackRate 不生效的问题)
+      // #ifdef H5
+      try {
+        const innerAny = audioContext as any;
+        if (innerAny._audio && innerAny._audio.playbackRate !== rate) {
+          innerAny._audio.playbackRate = rate;
+          console.log(`[Audio] H5 底层 audio.playbackRate=${rate} 设置成功`);
+        }
+      } catch (e) {}
+      // #endif
+    } else {
+      console.warn('[Audio] audioContext 为 null,无法设置 playbackRate');
     }
   }
 

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

@@ -49,6 +49,7 @@ export interface VoiceParams {
   speed: number;
   pitch: number;
   volume: number;
+  emotion?: string; // 用户手动指定的情感: neutral|happy|sad|angry|fearful|surprised|disgusted
 }
 
 // 音色类型


BIN
panel-album-open.png


BIN
panel-voice-open.png


BIN
restructured-final.png


BIN
restructured-layout.png


BIN
scrolled-bottom.png


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
server/logs/requests.json


+ 2 - 0
server/prisma/schema.prisma

@@ -154,6 +154,7 @@ model Book {
   foreword       String?        @db.Text
   afterword      String?        @db.Text
   errorMsg       String?        @db.Text
+  voiceSpeed     Float          @default(1.0)
   createdAt      DateTime       @default(now())
   updatedAt      DateTime       @updatedAt
   failedStage    String?        @db.VarChar(30)
@@ -401,6 +402,7 @@ model TtsTask {
   retryCount    Int          @default(0)
   maxRetries    Int          @default(3)
   voiceId       String       @default("default")
+  voiceSpeed    Float        @default(1.0)
   startedAt     DateTime?
   completedAt   DateTime?
   createdAt     DateTime     @default(now())

+ 4 - 4
server/src/config/models.json

@@ -81,7 +81,7 @@
           "input": ["tts"],
           "apiPath": "/api/v1/services/audio/tts/SpeechSynthesizer",
           "maxTextLength": 20000,
-          "defaultVoice": "longyingling_v3",
+          "defaultVoice": "longanhuan_v3",
           "enabled": true
         }
       ]
@@ -178,8 +178,8 @@
     "defaultModel": "MiniMax-M2.7"
   },
   "tts": {
-    "defaultVendor": "edge",
-    "defaultModel": "edge-tts",
-    "defaultVoice": "zh-CN-XiaoxiaoNeural"
+    "defaultVendor": "bailian",
+    "defaultModel": "cosyvoice-v3-flash",
+    "defaultVoice": "longanhuan_v3"
   }
 }

+ 12 - 0
server/src/modules/audioedit/audioedit.controller.ts

@@ -79,6 +79,18 @@ router.post('/merge', async (ctx) => {
   }
 });
 
+/**
+ * 获取音频列表(兼容前端 /api/audio/list)
+ * GET /api/audio/list
+ */
+router.get('/list', async (ctx) => {
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: { list: [], total: 0 },
+  };
+});
+
 /**
  * 获取音频信息
  * GET /api/audio/:audioId/info

+ 22 - 8
server/src/modules/book-generator/book-generator.store.ts

@@ -6,7 +6,7 @@ import crypto from 'crypto';
 import { prisma } from '../../models';
 import { Book, BookOutline, Chapter, ChapterGenStage, BookGenStage } from './book-generator.types';
 import { Prisma } from '@prisma/client';
-import { generateAudio } from '../tts/tts.service';
+import { generateAudio, detectBestVoiceAndEmotion, DEFAULT_VOICE_SPEED } 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';
@@ -17,6 +17,9 @@ import { mergeChapterAudios } from '../player/player.service';
 import { consumeAudioMinutes, canUseTtsProvider } from '../subscription/subscription.service';
 import { runWithContext } from '../../services/llm-context';
 
+// 注意:语速决策已上移到 langgraph-controller.ts 的「创建书籍」阶段
+// 此处不再做内容检测,voiceSpeed 字段即为最终语速
+
 /**
  * 取消书籍所有章节的音频生成(将 pending/processing 任务标记为 cancelled,回退章节阶段)
  */
@@ -446,6 +449,7 @@ export class BookStore {
     bookScale?: string;
     totalChapters?: number;
     estimatedWords?: number;
+    voiceSpeed?: number;
   }): Promise<Book> {
     const book = await prisma.book.create({
       data: {
@@ -458,6 +462,7 @@ export class BookStore {
         bookScale: data.bookScale || '1000',
         totalChapters: data.totalChapters ?? 10,
         estimatedWords: data.estimatedWords ?? 0,
+        voiceSpeed: data.voiceSpeed ?? 1.0,
         genStage: 'draft',
         progress: 0,
         isPublished: false, // 预发布:等书籍完成后再发布
@@ -465,6 +470,8 @@ export class BookStore {
       include: { chapters: true },
     });
 
+    console.log(`[BookStore.create] voiceSpeed=${data.voiceSpeed} -> stored=${book.voiceSpeed}, type=${typeof data.voiceSpeed}`);
+
     return this.toBook(book);
   }
 
@@ -1005,11 +1012,12 @@ export class BookStore {
     }
 
     // 生成音频(异步模式,通过回调更新章节)
+    // 语速已在「创建书籍」阶段根据用户设置或儿童内容检测确定,此处直接使用
     const result = await generateAudio(
       userId ? String(userId) : String(chapter.book?.userId || '0'),
       chapter.content,
       'longyingling_v3',
-      { speed: 1.0, pitch: 0, volume: 50 },
+      { speed: chapter.book?.voiceSpeed ?? DEFAULT_VOICE_SPEED, pitch: 0, volume: 50 },
       async (audioUrl: string, duration: number) => {
         // 音频生成完成后更新章节
         await prisma.bookChapter.update({
@@ -1143,11 +1151,13 @@ export class BookStore {
     });
     
     let task: any;
+    // 获取书籍的voiceSpeed
+    const bookVoiceSpeed = chapterBefore.book?.voiceSpeed ?? 1.0;
     if (existingFailed) {
       // 复用已有 failed 任务,重置为 pending,但累加 retryCount(保留历史重试记录)
       const prevRetryCount = existingFailed.retryCount || 0;
       // 关键:更新 voiceId 为当前有效的音色,避免旧任务用已禁用的 MiniMax 音色导致循环失败
-      const taskVoiceId = voiceId || 'longyingling_v3';
+      const taskVoiceId = voiceId || 'longanhuan_v3';
       task = await prisma.ttsTask.update({
         where: { id: existingFailed.id },
         data: {
@@ -1155,15 +1165,16 @@ export class BookStore {
           content: chapterBefore.content,
           contentHash,
           voiceId: taskVoiceId,
+          voiceSpeed: bookVoiceSpeed,
           retryCount: prevRetryCount,  // 保留历史重试次数,不重置为0
           errorMsg: null,
           startedAt: null,
           completedAt: null,
         },
       });
-      console.log(`[Audio] 复用已有任务#${existingFailed.id}: chapterId=${chapterId}, voiceId=${taskVoiceId}, 累计重试=${prevRetryCount}次`);
+      console.log(`[Audio] 复用已有任务#${existingFailed.id}: chapterId=${chapterId}, voiceId=${taskVoiceId}, voiceSpeed=${bookVoiceSpeed}, 累计重试=${prevRetryCount}次`);
     } else {
-      console.log(`[Audio] 创建TTS任务: chapterId=${chapterId}, 内容长度=${chapterBefore.content.length}, hash=${contentHash.substring(0, 12)}...`);
+      console.log(`[Audio] 创建TTS任务: chapterId=${chapterId}, 内容长度=${chapterBefore.content.length}, voiceSpeed=${bookVoiceSpeed}, hash=${contentHash.substring(0, 12)}...`);
       task = await prisma.ttsTask.create({
       data: {
         taskType: 'tts',
@@ -1172,7 +1183,8 @@ export class BookStore {
         userId: userId || chapterBefore.book?.userId || null,
         contentHash,
         content: chapterBefore.content, // 保存提交时的内容副本
-        voiceId: voiceId || 'longyingling_v3',  // 支持外部传入音色,默认兼容有声书
+        voiceId: voiceId || 'longanhuan_v3',  // 支持外部传入音色,默认兼容有声书
+        voiceSpeed: bookVoiceSpeed,
         status: 'pending',
       },
     });
@@ -1433,14 +1445,16 @@ export class BookStore {
 
       const userId = chapter.book?.userId || task.userId || 1;
       const bookId = chapter.bookId;
+      // 语速在创建书籍时已确定(用户传入 或 儿童内容自动检测),此处直接使用
+      const voiceSpeed = task.voiceSpeed ?? chapter.book?.voiceSpeed ?? DEFAULT_VOICE_SPEED;
 
       // 设置上下文,后续 TTS 调用自动携带 userId/bookId/chapterId
       await runWithContext({ userId, bookId, chapterId }, async () => {
       const result = await generateAudio(
         String(userId),
         content,
-        task.voiceId || 'longyingling_v3',
-        { speed: 1.0, pitch: 0, volume: 50 },
+        task.voiceId || 'longanhuan_v3',
+        { speed: voiceSpeed, pitch: 0, volume: 50 },
         async (audioUrl: string, duration: number) => {
           console.log(`[TtsTask] #${taskId} 音频就绪: ${audioUrl?.substring(0, 60)}...`);
           try {

+ 1 - 0
server/src/modules/book-generator/book-generator.types.ts

@@ -149,6 +149,7 @@ export interface CreateBookRequest {
   style?: string;                   // 默认:专业严谨
   totalChapters?: number;           // 默认:10章
   language?: string;                // 默认:中文
+  voiceSpeed?: number;              // 语速,默认1.0
 }
 
 /** 生成章节请求 */

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

@@ -52,6 +52,11 @@ export const GraphState = Annotation.Root({
     reducer: (_prev, update) => update ?? _prev,
     default: () => undefined as number | undefined,
   }),
+  /** 用户是否明确指定了 voiceSpeed(true=用户传的,要尊重用户;false=未传,可用 AI 推荐) */
+  userSpecifiedVoiceSpeed: Annotation<boolean>({
+    reducer: (_prev, update) => update ?? _prev,
+    default: () => false as boolean,
+  }),
   description: Annotation<string>({
     reducer: (_prev, update) => update ?? _prev,
     default: () => '' as string,

+ 3 - 2
server/src/modules/book-generator/index.ts

@@ -81,16 +81,17 @@ export class LangGraphBookGenerator {
     bookScale: string = '1000',
     genLevel?: number,
     userId?: number,
+    userSpecifiedVoiceSpeed?: boolean,
   ): Promise<void> {
     const userSpecified = genLevel !== undefined;
     const level = genLevel ?? resolveGenLevel(bookScale, undefined, topic);
 
     const strategy = getCurrentStrategy();
-    console.log(`[LangGraph] 开始生成: bookId=${bookId}, userId=${userId ?? '-'}, scale=${bookScale}, genLevel=${level}, strategy=${strategy.name}`);
+    console.log(`[LangGraph] 开始生成: bookId=${bookId}, userId=${userId ?? '-'}, scale=${bookScale}, genLevel=${level}, strategy=${strategy.name}, userSpecifiedVoiceSpeed=${userSpecifiedVoiceSpeed}`);
 
     // 设置异步上下文,后续所有 LLM/TTS 调用自动携带 userId / bookId
     await runWithContext({ userId, bookId: parseInt(bookId) }, async () => {
-      await strategy.generate(bookId, topic, bookScale, level, userSpecified ? level : undefined);
+      await strategy.generate(bookId, topic, bookScale, level, userSpecified ? level : undefined, userSpecifiedVoiceSpeed);
     });
   }
 }

+ 18 - 3
server/src/modules/book-generator/langgraph-controller.ts

@@ -16,6 +16,7 @@ import { cleanLlmShortText, extractJsonFromResponse } from '../../services/llm/r
 import { runWithContext } from '../../services/llm-context';
 import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service';
 import { mergeChapterAudios } from '../player/player.service';
+import { DEFAULT_VOICE_SPEED } from '../tts/tts.service';
 import { exec } from 'child_process';
 import { promisify } from 'util';
 import fs from 'fs/promises';
@@ -435,6 +436,7 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
       generateForeword?: boolean;
       generateAfterword?: boolean;
       immediateGenerate?: boolean;
+      voiceSpeed?: number;
     };
 
     if (!body.description) {
@@ -450,6 +452,16 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
     // 只接受数字格式的 bookScale(如 '200', '1000', '130000'),非数字当作未提供
     const providedBookScale = body.bookScale && /^\d+$/.test(body.bookScale) ? body.bookScale : undefined;
 
+    // ========== 语速决策 ==========
+    // 规则(更新于 2026-06-14):
+    // 1. 用户显式传了 voiceSpeed → 直接用用户的(userSpecifiedVoiceSpeed=true)
+    // 2. 用户没传 → 占位 1.0,由 plan 节点 AI 归纳后写回 book.voiceSpeed
+    //    (Plan 节点会输出 suggestedSpeed,持久化时覆盖 voiceSpeed 字段)
+    const userSpecifiedVoiceSpeed = (body.voiceSpeed !== undefined && body.voiceSpeed !== null);
+    const finalVoiceSpeed = userSpecifiedVoiceSpeed
+      ? body.voiceSpeed
+      : DEFAULT_VOICE_SPEED; // 临时占位,AI 规划后会被覆盖
+
     // ========== 快速路径:用户已提供 title 和 bookScale,无需 AI ==========
     if (providedTitle && providedBookScale) {
       const bookScale = providedBookScale;
@@ -486,7 +498,9 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
         bookScale,
         totalChapters: scaleConfig.isShortArticle ? 1 : scaleConfig.chapters,
         estimatedWords: totalWords,
+        voiceSpeed: finalVoiceSpeed,
       });
+      console.log(`[LangGraph.fastPath] finalVoiceSpeed=${finalVoiceSpeed}, type=${typeof finalVoiceSpeed}, body.voiceSpeed=${body.voiceSpeed}`);
 
       if (body.immediateGenerate === false) {
         ctx.body = { code: 0, message: '书籍创建成功(交互模式)', data: { book, genStage: 'draft', mode: 'interactive' } };
@@ -494,7 +508,7 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
       }
 
       await bookStore.update(book.id, { genStage: 'outlining' });
-      langGraphGenerator.generate(book.id.toString(), body.description, bookScale, genLevel, userId)
+      langGraphGenerator.generate(book.id.toString(), body.description, bookScale, genLevel, userId, userSpecifiedVoiceSpeed)
         .then(() => console.log(`[LangGraph] 生成完成: bookId=${book.id}`))
         .catch(err => {
           console.error(`[LangGraph] 生成失败: bookId=${book.id}`, err);
@@ -538,6 +552,7 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
       bookScale: fallbackScale,
       totalChapters: fallbackConfig.isShortArticle ? 1 : fallbackConfig.chapters,
       estimatedWords: fallbackWords,
+      voiceSpeed: finalVoiceSpeed,
     });
 
     // 标记为 AI 正在准备中
@@ -624,7 +639,7 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
             ? mapBookTypeToGenLevel(body.bookType)
             : resolveGenLevel(bookScale!);
 
-        await langGraphGenerator.generate(book.id.toString(), body.description, bookScale!, genLevel, userId);
+        await langGraphGenerator.generate(book.id.toString(), body.description, bookScale!, genLevel, userId, userSpecifiedVoiceSpeed);
         console.log(`[LangGraph] 生成完成: bookId=${book.id}`);
       } catch (err) {
         console.error(`[LangGraph] AI 准备或生成失败: bookId=${book.id}`, err);
@@ -996,7 +1011,7 @@ router.post('/books/:id/generate', optionalAuth, async (ctx: Context) => {
     await bookStore.deleteAllChapters(bookId);
     await bookStore.update(bookId, { genStage: 'outlining', totalChapters: 0, progress: 0 });
 
-    langGraphGenerator.generate(bookId, book.description, bookScale, genLevel, userId)
+    langGraphGenerator.generate(bookId, book.description, bookScale, genLevel, userId, false)
       .then(async () => {
         console.log(`[LangGraph] 生成完成: bookId=${bookId}`);
         // 根据实际章节状态更新书籍 genStage

+ 27 - 3
server/src/modules/book-generator/nodes/deep-plan.node.ts

@@ -41,6 +41,13 @@ export interface DeepBookPlan {
     painPoints: string[];
     desiredOutcome: string;
   };
+  // ============ TTS 配置(驱动音色/语速/情感)============
+  audienceType: 'children' | 'teen' | 'adult' | 'senior';
+  ageRange: string;
+  suggestedVoice: string;
+  suggestedSpeed: number;
+  suggestedEmotion: string;
+  toneFeature: string;
 }
 
 /**
@@ -143,6 +150,15 @@ function parseDeepPlanResponse(response: string): DeepBookPlan | null {
         painPoints: data.audienceCalibration?.painPoints || [],
         desiredOutcome: data.audienceCalibration?.desiredOutcome || '',
       },
+      // TTS 配置
+      audienceType: (['children', 'teen', 'adult', 'senior'].includes(data.audienceType) ? data.audienceType : 'adult') as DeepBookPlan['audienceType'],
+      ageRange: data.ageRange || '',
+      suggestedVoice: data.suggestedVoice || 'voice_01',
+      suggestedSpeed: typeof data.suggestedSpeed === 'number' && data.suggestedSpeed >= 0.5 && data.suggestedSpeed <= 2.0
+        ? data.suggestedSpeed
+        : 1.0,
+      suggestedEmotion: data.suggestedEmotion || 'neutral',
+      toneFeature: data.toneFeature || '',
     };
   } catch (err) {
     console.error('[DeepPlanNode] 解析失败:', err);
@@ -181,13 +197,21 @@ export async function deepPlanBookNode(state: typeof GraphState.State): Promise<
       console.log(`[DeepPlanNode] 用户选定 genLevel=${finalGenLevel},AI 建议=${plan.genLevel},保留用户选择`);
     }
 
-    console.log(`[DeepPlanNode] 规划完成: genLevel=${finalGenLevel}, 风格=${plan.writingStyle}, 主线=${plan.goldenThread}`);
+    console.log(`[DeepPlanNode] 规划完成: genLevel=${finalGenLevel}, 风格=${plan.writingStyle}, 主线=${plan.goldenThread}, TTS: audience=${plan.audienceType}, voice=${plan.suggestedVoice}, speed=${plan.suggestedSpeed}`);
 
     // 持久化到数据库
-    await bookStore.update(state.bookId, {
+    // ⚠️ 重要:把 AI 归纳的 TTS 配置同步写入 book.voiceSpeed,
+    //          这样后续 TTS 任务直接读 voiceSpeed 即可,无需再做内容检测
+    const updateData: any = {
       bookAnalysis: JSON.stringify(plan),
       progress: PROGRESS.OUTLINE_DONE,
-    });
+    };
+    // 如果用户没显式指定 voiceSpeed(占位 1.0),用 AI 推荐的
+    if (!state.userSpecifiedVoiceSpeed) {
+      updateData.voiceSpeed = plan.suggestedSpeed;
+    }
+    await bookStore.update(state.bookId, updateData);
+    console.log(`[DeepPlanNode] TTS配置: audience=${plan.audienceType}, ageRange=${plan.ageRange}, voice=${plan.suggestedVoice}, speed=${plan.suggestedSpeed}, emotion=${plan.suggestedEmotion}, voiceSpeed更新=${!state.userSpecifiedVoiceSpeed}`);
 
     return {
       progress: PROGRESS.OUTLINE_DONE,

+ 61 - 4
server/src/modules/book-generator/nodes/plan.node.ts

@@ -33,6 +33,19 @@ export interface BookPlan {
   targetAudienceAnalysis: string;
   /** 规划理由 */
   reasoning: string;
+  // ============ TTS 配置(驱动音色/语速/情感)============
+  /** 受众类型: children/teen/adult/senior */
+  audienceType: 'children' | 'teen' | 'adult' | 'senior';
+  /** 适用年龄范围,如 "3-6岁" / "20-40岁" */
+  ageRange: string;
+  /** 推荐音色(统一ID voice_01~voice_10) */
+  suggestedVoice: string;
+  /** 推荐语速(0.6~1.5) */
+  suggestedSpeed: number;
+  /** 推荐情感基调: neutral/happy/sad/angry/fearful/surprised */
+  suggestedEmotion: string;
+  /** 语气特征,如 "活泼可爱"/"沉稳专业"/"激情澎湃" */
+  toneFeature: string;
 }
 
 /**
@@ -111,8 +124,35 @@ function buildPlanPrompt(
   "structureLogic": "内容组织逻辑,如'由浅入深'、'由理论到实践'",
   "contentDepth": "内容深度评估(入门/基础/进阶/高级/专家)",
   "targetAudienceAnalysis": "目标读者分析,1-2句话",
-  "reasoning": "为什么选择这个大纲层级?1-2句话"
-}`;
+  "reasoning": "为什么选择这个大纲层级?1-2句话",
+
+  // ===== TTS 配置(用于驱动朗读音色/语速/情感)=====
+  "audienceType": "children|teen|adult|senior",
+  "ageRange": "适用年龄范围,如 '3-6岁' / '20-40岁'",
+  "suggestedVoice": "voice_01~voice_10 之一(参考下方音色表)",
+  "suggestedSpeed": 0.6~1.5 的小数(1.0=正常,0.78=慢速,1.25=快速),
+  "suggestedEmotion": "neutral|happy|sad|angry|fearful|surprised",
+  "toneFeature": "语气特征,如 '活泼可爱'/'沉稳专业'/'激情澎湃'"
+}
+
+### 4. 音色选择参考(统一ID)
+- voice_01 温柔女声 - 柔和温暖,适合情感故事
+- voice_02 磁性男声 - 低沉有力,适合悬疑推理
+- voice_03 活泼女声 - 清新明亮,适合儿童故事
+- voice_04 知性女声 - 知性稳重,适合科普知识
+- voice_05 阳光男声 - 阳光活力,适合校园青春
+- voice_06 沧桑男声 - 成熟沧桑,适合历史军事
+- voice_07 甜美女声 - 甜美可爱,适合爱情都市
+- voice_08 清朗男声 - 清朗干练,适合职场商战
+- voice_09 亲切女声 - 亲切自然,适合日常叙事
+- voice_10 稚嫩童声 - 稚嫩天真,适合童话寓言
+
+### 5. 语速建议
+- 儿童内容(3-12岁): 0.75~0.85
+- 青少年内容(13-18岁): 0.9~1.0
+- 成人内容(18岁以上): 0.95~1.1
+- 老人/养生内容: 0.85~0.95
+- 紧张/悬疑: 1.05~1.15`;
 }
 
 /**
@@ -151,6 +191,15 @@ function parsePlanResponse(response: string): BookPlan | null {
       contentDepth: data.contentDepth || '',
       targetAudienceAnalysis: data.targetAudienceAnalysis || '',
       reasoning: data.reasoning || '',
+      // TTS 配置(新增)
+      audienceType: (['children', 'teen', 'adult', 'senior'].includes(data.audienceType) ? data.audienceType : 'adult') as BookPlan['audienceType'],
+      ageRange: data.ageRange || '',
+      suggestedVoice: data.suggestedVoice || 'voice_01',
+      suggestedSpeed: typeof data.suggestedSpeed === 'number' && data.suggestedSpeed >= 0.5 && data.suggestedSpeed <= 2.0
+        ? data.suggestedSpeed
+        : 1.0,
+      suggestedEmotion: data.suggestedEmotion || 'neutral',
+      toneFeature: data.toneFeature || '',
     };
   } catch (err) {
     console.error('[PlanNode] 解析失败:', err);
@@ -199,9 +248,17 @@ export async function planBookNode(state: typeof GraphState.State): Promise<Part
     console.log(`[PlanNode] genLevel: ${state.genLevel} → ${finalGenLevel} (userSpecified=${state.userSpecifiedGenLevel}), 风格=${plan.writingStyle}, 结构=${plan.structureLogic}`);
 
     // 持久化到数据库 + 通过 GraphState 传递给后续节点
-    await bookStore.update(state.bookId, {
+    // ⚠️ 重要:把 AI 归纳的 TTS 配置同步写入 book.voiceSpeed,
+    //          这样后续 TTS 任务直接读 voiceSpeed 即可,无需再做内容检测
+    const updateData: any = {
       bookAnalysis: JSON.stringify(plan),
-    });
+    };
+    // 如果用户没显式指定 voiceSpeed(用默认值 1.0 占位),用 AI 推荐的
+    if (!state.userSpecifiedVoiceSpeed) {
+      updateData.voiceSpeed = plan.suggestedSpeed;
+    }
+    await bookStore.update(state.bookId, updateData);
+    console.log(`[PlanNode] TTS配置: audience=${plan.audienceType}, ageRange=${plan.ageRange}, voice=${plan.suggestedVoice}, speed=${plan.suggestedSpeed}, emotion=${plan.suggestedEmotion}, voiceSpeed更新=${!state.userSpecifiedVoiceSpeed}`);
 
     return {
       progress: PROGRESS.OUTLINE_DONE,

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

@@ -231,8 +231,35 @@ export const DEEP_PLAN_SYSTEM_PROMPT = `你是一位资深图书策划编辑,
     "assumedKnowledge": ["基础知识"],
     "painPoints": ["痛点"],
     "desiredOutcome": "期望结果"
-  }
-}`;
+  },
+
+  // ===== TTS 配置(驱动朗读音色/语速/情感)=====
+  "audienceType": "children|teen|adult|senior",
+  "ageRange": "适用年龄范围,如 '3-6岁' / '20-40岁'",
+  "suggestedVoice": "voice_01~voice_10 之一(参考下方音色表)",
+  "suggestedSpeed": 0.6~1.5 的小数(1.0=正常,0.78=慢速,1.25=快速),
+  "suggestedEmotion": "neutral|happy|sad|angry|fearful|surprised",
+  "toneFeature": "语气特征,如 '活泼可爱'/'沉稳专业'/'激情澎湃'"
+}
+
+### 7. TTS 音色选择参考(统一ID)
+- voice_01 温柔女声 - 柔和温暖,适合情感故事
+- voice_02 磁性男声 - 低沉有力,适合悬疑推理
+- voice_03 活泼女声 - 清新明亮,适合儿童故事
+- voice_04 知性女声 - 知性稳重,适合科普知识
+- voice_05 阳光男声 - 阳光活力,适合校园青春
+- voice_06 沧桑男声 - 成熟沧桑,适合历史军事
+- voice_07 甜美女声 - 甜美可爱,适合爱情都市
+- voice_08 清朗男声 - 清朗干练,适合职场商战
+- voice_09 亲切女声 - 亲切自然,适合日常叙事
+- voice_10 稚嫩童声 - 稚嫩天真,适合童话寓言
+
+### 8. 语速建议
+- 儿童内容(3-12岁): 0.75~0.85
+- 青少年内容(13-18岁): 0.9~1.0
+- 成人内容(18岁以上): 0.95~1.1
+- 老人/养生内容: 0.85~0.95
+- 紧张/悬疑: 1.05~1.15`;
 
 /**
  * 富信息大纲系统提示词

+ 4 - 2
server/src/modules/book-generator/strategies/deep-plan-parallel.strategy.ts

@@ -47,9 +47,10 @@ export class DeepPlanParallelStrategy implements GenerationStrategy {
     topic: string,
     bookScale: string,
     genLevel: number,
-    userSpecifiedGenLevel?: number
+    userSpecifiedGenLevel?: number,
+    userSpecifiedVoiceSpeed?: boolean
   ): Promise<void> {
-    console.log(`[Strategy-deep-plan-parallel] 开始, bookId=${bookId}, scale=${bookScale}, genLevel=${genLevel}, userSpecified=${userSpecifiedGenLevel}`);
+    console.log(`[Strategy-deep-plan-parallel] 开始, bookId=${bookId}, scale=${bookScale}, genLevel=${genLevel}, userSpecified=${userSpecifiedGenLevel}, userSpecifiedVoiceSpeed=${userSpecifiedVoiceSpeed}`);
 
     const workflow = new StateGraph(GraphState)
       .addNode('deep_plan', deepPlanBookNode)
@@ -84,6 +85,7 @@ export class DeepPlanParallelStrategy implements GenerationStrategy {
       bookScale,
       genLevel,
       userSpecifiedGenLevel,
+      userSpecifiedVoiceSpeed: userSpecifiedVoiceSpeed ?? false,
       userId: '',
       description: undefined,
       bookPlan: undefined,

+ 4 - 2
server/src/modules/book-generator/strategies/one-step-outline.strategy.ts

@@ -27,9 +27,10 @@ export class OneStepOutlineStrategy implements GenerationStrategy {
     topic: string,
     bookScale: string,
     genLevel: number,
-    userSpecifiedGenLevel?: number
+    userSpecifiedGenLevel?: number,
+    userSpecifiedVoiceSpeed?: boolean
   ): Promise<void> {
-    console.log(`[Strategy-one-step-outline] 开始, bookId=${bookId}, scale=${bookScale}, genLevel=${genLevel}, userSpecified=${userSpecifiedGenLevel}`);
+    console.log(`[Strategy-one-step-outline] 开始, bookId=${bookId}, scale=${bookScale}, genLevel=${genLevel}, userSpecified=${userSpecifiedGenLevel}, userSpecifiedVoiceSpeed=${userSpecifiedVoiceSpeed}`);
 
     const workflow = new StateGraph(GraphState)
       .addNode('plan_book', planBookNode)
@@ -46,6 +47,7 @@ export class OneStepOutlineStrategy implements GenerationStrategy {
       bookScale,
       genLevel,
       userSpecifiedGenLevel,
+      userSpecifiedVoiceSpeed: userSpecifiedVoiceSpeed ?? false,
       userId: '',
       description: undefined,
       bookPlan: undefined,

+ 4 - 2
server/src/modules/book-generator/strategies/per-chapter.strategy.ts

@@ -27,9 +27,10 @@ export class PerChapterStrategy implements GenerationStrategy {
     topic: string,
     bookScale: string,
     genLevel: number,
-    userSpecifiedGenLevel?: number
+    userSpecifiedGenLevel?: number,
+    userSpecifiedVoiceSpeed?: boolean
   ): Promise<void> {
-    console.log(`[Strategy-per-chapter] 开始, bookId=${bookId}, scale=${bookScale}, genLevel=${genLevel}, userSpecified=${userSpecifiedGenLevel}`);
+    console.log(`[Strategy-per-chapter] 开始, bookId=${bookId}, scale=${bookScale}, genLevel=${genLevel}, userSpecified=${userSpecifiedGenLevel}, userSpecifiedVoiceSpeed=${userSpecifiedVoiceSpeed}`);
 
     const workflow = new StateGraph(GraphState)
       .addNode('plan_book', planBookNode)
@@ -46,6 +47,7 @@ export class PerChapterStrategy implements GenerationStrategy {
       bookScale,
       genLevel,
       userSpecifiedGenLevel,
+      userSpecifiedVoiceSpeed: userSpecifiedVoiceSpeed ?? false,
       userId: '',
       description: undefined,
       bookPlan: undefined,

+ 4 - 2
server/src/modules/book-generator/strategies/sequential.strategy.ts

@@ -27,9 +27,10 @@ export class SequentialStrategy implements GenerationStrategy {
     topic: string,
     bookScale: string,
     genLevel: number,
-    userSpecifiedGenLevel?: number
+    userSpecifiedGenLevel?: number,
+    userSpecifiedVoiceSpeed?: boolean
   ): Promise<void> {
-    console.log(`[Strategy-sequential] 开始, bookId=${bookId}, scale=${bookScale}, genLevel=${genLevel}, userSpecified=${userSpecifiedGenLevel}`);
+    console.log(`[Strategy-sequential] 开始, bookId=${bookId}, scale=${bookScale}, genLevel=${genLevel}, userSpecified=${userSpecifiedGenLevel}, userSpecifiedVoiceSpeed=${userSpecifiedVoiceSpeed}`);
 
     const workflow = new StateGraph(GraphState)
       .addNode('plan_book', planBookNode)
@@ -50,6 +51,7 @@ export class SequentialStrategy implements GenerationStrategy {
       bookScale,
       genLevel,
       userSpecifiedGenLevel,
+      userSpecifiedVoiceSpeed: userSpecifiedVoiceSpeed ?? false,
       userId: '',
       description: undefined,
       bookPlan: undefined,

+ 2 - 1
server/src/modules/book-generator/strategies/types.ts

@@ -18,8 +18,9 @@ export interface GenerationStrategy {
    * @param bookScale 书籍规模 key
    * @param genLevel  大纲层级(已解析后的数值)
    * @param userSpecifiedGenLevel 用户明确指定的大纲层级(undefined=自动/AI决定)
+   * @param userSpecifiedVoiceSpeed 用户是否显式传了 voiceSpeed(true=尊重用户,false=可用 AI 推荐)
    */
-  generate(bookId: string, topic: string, bookScale: string, genLevel: number, userSpecifiedGenLevel?: number): Promise<void>;
+  generate(bookId: string, topic: string, bookScale: string, genLevel: number, userSpecifiedGenLevel?: number, userSpecifiedVoiceSpeed?: boolean): Promise<void>;
 }
 
 /** 策略配置 */

+ 1 - 2
server/src/modules/book-generator/tts-queue.ts

@@ -229,7 +229,6 @@ async function cleanupDoneBooks(): Promise<void> {
       by: ['bookId'],
       where: {
         genStage: { notIn: doneStages },
-        bookId: { not: null },
       },
       _count: { id: true },
     });
@@ -238,7 +237,7 @@ async function cleanupDoneBooks(): Promise<void> {
     // 第二步:找出所有有章节的书籍,排除有未完成章节的
     const allBookGroups = await prisma.bookChapter.groupBy({
       by: ['bookId'],
-      where: { bookId: { not: null } },
+      where: {},
       _count: { id: true },
     });
     const doneBookIds = allBookGroups

+ 23 - 1
server/src/modules/comments/comments.controller.ts

@@ -1,11 +1,33 @@
 import Router from '@koa/router';
 import { commentsService } from './comments.service';
 
-const router = new Router({ prefix: '/api/comments' });
+const router = new Router();
 
 // 测试用户ID
 const TEST_USER_ID = 1;
 
+/**
+ * 获取评论列表(兼容前端 /api/comments/list)
+ * GET /api/comments/list
+ */
+router.get('/list', async (ctx) => {
+  try {
+    const comments = await commentsService.getCommentsByChapterId(0);
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: comments,
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: [],
+    };
+  }
+});
+
 /**
  * 获取章节评论
  * GET /api/comments/:chapterId

+ 13 - 0
server/src/modules/drafts/drafts.controller.ts

@@ -6,6 +6,19 @@ import { prisma } from '../../models';
 const TEST_USER_ID = '1';
 const router = new Router();
 
+// 获取草稿列表(兼容前端 /api/drafts/list)
+router.get('/list', optionalAuth, async (ctx: Context) => {
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
+  const { type } = ctx.query as { type?: string };
+  const where: any = { userId: parseInt(userId) };
+  if (type) where.type = type;
+  const drafts = await prisma.draft.findMany({
+    where,
+    orderBy: { updatedAt: 'desc' },
+  });
+  ctx.body = { code: 0, message: 'success', data: drafts };
+});
+
 // 获取草稿列表
 router.get('/', optionalAuth, async (ctx: Context) => {
   const userId = ctx.state.user?.userId || TEST_USER_ID;

+ 14 - 0
server/src/modules/favorites/favorites.controller.ts

@@ -9,6 +9,20 @@ const TEST_USER_ID = '1';
 
 const router = new Router();
 
+// 获取收藏列表(兼容前端 /api/favorites/list)
+router.get('/list', optionalAuth, async (ctx: Context) => {
+  // 开发环境使用测试用户ID
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
+
+  const favorites = await FavoritesService.getFavorites(userId);
+
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: favorites,
+  };
+});
+
 // 获取收藏列表
 router.get('/', optionalAuth, async (ctx: Context) => {
   // 开发环境使用测试用户ID

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

@@ -7,6 +7,62 @@ const TEST_USER_ID = '1';
 
 const router = new Router();
 
+// 获取音频生成历史列表(兼容前端 /api/history/list)
+router.get('/list', optionalAuth, async (ctx: Context) => {
+  // 开发环境使用测试用户ID
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
+  const page = parseInt(ctx.query.page as string) || 1;
+  const pageSize = parseInt(ctx.query.pageSize as string) || 20;
+  const startDate = ctx.query.startDate as string;
+
+  const where: any = {};
+  if (startDate) {
+    where.createdAt = { gte: new Date(startDate) };
+  }
+
+  const [records, total] = await Promise.all([
+    prisma.audioRecord.findMany({
+      where,
+      orderBy: { createdAt: 'desc' },
+      skip: (page - 1) * pageSize,
+      take: pageSize,
+    }),
+    prisma.audioRecord.count({ where }),
+  ]);
+
+  const list = records.map((r) => ({
+    _id: r.audioId,
+    id: r.audioId,
+    title: r.title,
+    text: r.text || '',
+    summary: '',
+    tags: [],
+    audioUrl: r.audioUrl || '',
+    audioDuration: r.audioDuration,
+    audioSize: r.audioSize,
+    wordCount: r.wordCount,
+    voiceId: r.voiceId,
+    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(),
+  }));
+
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: {
+      list,
+      total,
+      page,
+      pageSize,
+      totalPages: Math.ceil(total / pageSize),
+    },
+  };
+});
+
 // 获取音频生成历史列表
 router.get('/', optionalAuth, async (ctx: Context) => {
   // 开发环境使用测试用户ID

+ 12 - 0
server/src/modules/member/member.controller.ts

@@ -17,6 +17,18 @@ router.get('/benefits', async (ctx: Context) => {
   };
 });
 
+// 获取用户会员信息(兼容前端 /api/member/info)
+router.get('/info', authMiddleware, async (ctx: Context) => {
+  const userId = ctx.state.user.userId;
+  const status = await MemberService.getMemberStatus(userId);
+
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: status,
+  };
+});
+
 // 获取用户会员状态
 router.get('/status', authMiddleware, async (ctx: Context) => {
   const userId = ctx.state.user.userId;

+ 23 - 1
server/src/modules/notifications/notifications.controller.ts

@@ -3,11 +3,33 @@ import { notificationsService } from './notifications.service';
 import { optionalAuth } from '../../middleware/auth';
 import { prisma } from '../../models';
 
-const router = new Router({ prefix: '/api/notifications' });
+const router = new Router();
 
 // 测试用户ID
 const TEST_USER_ID = '1';
 
+/**
+ * 获取通知列表(兼容前端 /api/notifications/list)
+ * GET /api/notifications/list
+ */
+router.get('/list', optionalAuth, async (ctx) => {
+  try {
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    const notifications = await notificationsService.getNotifications(String(userId));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: notifications,
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '获取通知失败',
+    };
+  }
+});
+
 /**
  * 获取通知列表
  * GET /api/notifications

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

@@ -133,6 +133,95 @@ router.get('/recent', optionalAuth, async (ctx: Context) => {
 
 // ============ 临时 API:为播放器页面适配书籍章节音频 ============
 
+// 获取播放列表(兼容前端 /api/player/playlist)
+router.get('/playlist', optionalAuth, async (ctx: Context) => {
+  const { page = '1', pageSize = '100' } = ctx.query as { page?: string; pageSize?: string };
+
+  // 开发环境使用测试用户ID
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
+
+  // 获取所有有音频的章节:公开的 + 当前用户自己的
+  const chapters = await prisma.bookChapter.findMany({
+    where: {
+      audioUrl: {
+        not: null,
+      },
+      AND: [
+        { audioUrl: { not: '' } },
+        {
+          OR: [
+            { isPublic: true },
+            { book: { userId: parseInt(userId) } },
+            { book: { userId: null } },
+          ]
+        }
+      ]
+    },
+    include: { book: true },
+    orderBy: [
+      { bookId: 'asc' },
+      { number: 'asc' },
+    ],
+    skip: (parseInt(page) - 1) * parseInt(pageSize),
+    take: parseInt(pageSize),
+  });
+
+  const total = await prisma.bookChapter.count({
+    where: {
+      audioUrl: {
+        not: null,
+      },
+      AND: [
+        { audioUrl: { not: '' } },
+        {
+          OR: [
+            { isPublic: true },
+            { book: { userId: parseInt(userId) } },
+          ]
+        }
+      ]
+    },
+  });
+
+  const list = await Promise.all(chapters.map(async (chapter) => {
+    const urlParts = (chapter.audioUrl || '').match(/\/uploads\/([^\/]+)\//);
+    const audioId = urlParts ? urlParts[1] : null;
+
+    let finalAudioUrl = chapter.audioUrl || '';
+    if (chapter.level === 1) {
+      const mergedUrl = await PlayerService.getChapterAudioUrl(chapter.id);
+      if (mergedUrl) {
+        finalAudioUrl = mergedUrl;
+      }
+    }
+
+    return {
+      id: chapter.id,
+      _id: chapter.id,
+      audioId,
+      title: chapter.title,
+      summary: chapter.summary || '',
+      text: chapter.content || '',
+      audioUrl: finalAudioUrl,
+      audioDuration: chapter.audioDuration || 0,
+      wordCount: chapter.wordCount || 0,
+      albumId: chapter.bookId,
+      albumName: chapter.book?.title || '默认专辑',
+      isFavorite: false,
+      isPublic: chapter.isPublic,
+      isOwner: chapter.book?.userId === parseInt(userId),
+      level: chapter.level,
+      lrcLyrics: chapter.lrcLyrics || '',
+    };
+  }));
+
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: { list, total, page: parseInt(page), pageSize: parseInt(pageSize) },
+  };
+});
+
 // 获取播放列表(适配旧播放器)- 必须放在 /:id 之前
 // 过滤规则:公开的音频 + 当前用户自己的音频
 router.get('/audio/list', optionalAuth, async (ctx: Context) => {

+ 5 - 7
server/src/modules/tts/aliyun.provider.ts

@@ -88,13 +88,11 @@ export class AliyunTtsProvider implements ITtsProvider {
         if (isCosyVoice) {
           requestBody.input.format = 'mp3';
           requestBody.input.sample_rate = 24000;
-          const parts: string[] = [];
-          // Instrut 情感/场景控制(优先级最高,放在最前面)
-          if ((params as any).instructText) parts.push((params as any).instructText);
-          if (params.speed !== undefined && params.speed !== 1) parts.push(`语速${params.speed > 1 ? '偏快' : '偏慢'}`);
-          if (params.pitch !== undefined && params.pitch !== 0) parts.push(`音调${params.pitch > 0 ? '偏高' : '偏低'}`);
-          if (params.volume !== undefined && params.volume !== 50) parts.push(`音量${params.volume > 50 ? '较大' : '较小'}`);
-          if (parts.length > 0) requestBody.input.instructions = parts.join(',') + '。';
+          // Instruct 情感控制:只支持固定格式 设置情感:{emotion}。
+          // CosyVoice v3-flash 系统音色不支持通过 instruction 控制语速/音调/音量
+          if ((params as any).instructText) {
+            requestBody.input.instruction = (params as any).instructText;
+          }
         }
         if (!isCosyVoice && activeModel.includes('instruct')) {
           const instructions: string[] = [];

+ 48 - 3
server/src/modules/tts/tts.controller.ts

@@ -32,6 +32,53 @@ router.get('/voices', async (ctx: Context) => {
   };
 });
 
+// 内容情感+音色检测(实时预览,无需生成音频)
+router.post('/detect-emotion', async (ctx: Context) => {
+  const { text } = ctx.request.body as { text: string };
+
+  if (!text || text.trim().length < 10) {
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { detected: false, hint: '文本太短,至少需要10个字符' },
+    };
+    return;
+  }
+
+  const detected = TtsService.detectBestVoiceAndEmotion(text);
+  if (!detected) {
+    // 兜底:至少返回情感检测结果
+    const fallbackEmotion = TtsService.getVoiceInstruct(text).replace('设置情感:', '').replace('。', '');
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        detected: false,
+        emotion: fallbackEmotion || 'neutral',
+        emotionLabel: TtsService.EMOTION_LABELS[fallbackEmotion] || '中性',
+        hint: '未匹配到特定内容类型,使用通用情感',
+      },
+    };
+    return;
+  }
+
+  const voiceInfo = TtsService.getVoiceById(detected.voiceId);
+  const emotion = detected.instructText.replace('设置情感:', '').replace('。', '');
+
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: {
+      detected: true,
+      voiceId: detected.voiceId,
+      voiceName: voiceInfo?.name || detected.voiceId,
+      emotion,
+      emotionLabel: TtsService.EMOTION_LABELS[emotion] || emotion,
+      instructText: detected.instructText,
+    },
+  };
+});
+
 // 获取可用 TTS 服务商列表
 router.get('/providers', async (ctx: Context) => {
   const providers = TtsService.getAvailableProviders();
@@ -87,9 +134,7 @@ router.post(
       throw new BadRequestError('文本过短,至少需要10个字符');
     }
 
-    if (!voiceId) {
-      throw new BadRequestError('请选择音色');
-    }
+    // voiceId 可选:为空时后端根据内容自动选择最佳音色
 
     // 如果指定了 bookId,验证书籍是否存在
     if (bookId) {

+ 204 - 37
server/src/modules/tts/tts.service.ts

@@ -29,12 +29,19 @@ const UNIFIED_VOICES: Voice[] = [
   { id: 'voice_10', name: '稚嫩童声',  gender: 'female', description: '稚嫩天真,适合童话寓言' },
 ];
 
-// 统一音色 → 阿里云 真实音色映射
+// 统一音色 → 阿里云 CosyVoice 真实音色映射
+// 选型标准:优先选用支持 Instruct 指令的标杆音色(龙安欢/龙安洋/龙呼呼/龙逸尘/龙安柔/龙泡泡)
 const ALIYUN_VOICE_MAP: Record<string, string> = {
-  voice_01: 'longanyang',    voice_02: 'longsanshu_v3', voice_03: 'longhuhu_v3',
-  voice_04: 'longyue_v3',    voice_05: 'longyichen_v3', voice_06: 'longlaobo_v3',
-  voice_07: 'longmiao_v3',   voice_08: 'longshuo_v3',   voice_09: 'longwan_v3',
-  voice_10: 'longhuhu_v3',
+  voice_01: 'longanhuan_v3',   // 温柔女声 → 龙安欢V3(元气女,支持Instruct)
+  voice_02: 'longanyang',      // 磁性男声 → 龙安洋(阳光男,支持Instruct)
+  voice_03: 'longhuhu_v3',     // 活泼女声 → 龙呼呼(女童音,支持Instruct)
+  voice_04: 'longyuan_v3',     // 知性女声 → 龙媛(温暖治愈)
+  voice_05: 'longyichen_v3',   // 阳光男声 → 龙逸尘(支持Instruct)
+  voice_06: 'longlaobo_v3',    // 沧桑男声 → 龙老伯
+  voice_07: 'longhua_v3',      // 甜美女声 → 龙华(元气甜美)
+  voice_08: 'longshuo_v3',     // 清朗男声 → 龙硕
+  voice_09: 'longanrou_v3',    // 亲切女声 → 龙安柔(温柔闺蜜,支持Instruct)
+  voice_10: 'longpaopao_v3',   // 稚嫩童声 → 龙泡泡(飞天泡泡音,支持Instruct)
 };
 
 // 统一音色 → Edge-TTS 真实音色映射
@@ -63,13 +70,14 @@ function mapToProviderVoice(unifiedVoiceId: string, providerVendor: string): str
   const mapped = ALIYUN_VOICE_MAP[unifiedVoiceId];
   if (mapped) return mapped;
   // 不在映射表中(如遗留的 'cherry' 等旧 MiniMax 音色)→ 用 CosyVoice 默认音色
-  console.warn(`⚠️ [VoiceMap] 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色 longyingling_v3`);
-  return 'longyingling_v3';
+  console.warn(`⚠️ [VoiceMap] 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色 longanhuan_v3`);
+  return 'longanhuan_v3';
 }
 
-// ============ Aliyun Instruct 情感/场景控制 ============
-// 后期优化功能,暂不启用。启用时改为 true
-const INSTRUCT_ENABLED = false;
+// ============ CosyVoice Instruct 情感控制 ============
+// CosyVoice v3-flash 系统音色支持 7 种情感指令:neutral/happy/sad/angry/fearful/surprised/disgusted
+// 格式:设置情感:{emotion}。(固定格式,不可自定义)
+const INSTRUCT_ENABLED = true;
 
 interface EmotionScene {
   emotion: string;  // neutral | fearful | angry | sad | surprised | happy | disgusted
@@ -86,17 +94,66 @@ const EMOTION_KEYWORDS: { emotion: string; keywords: string[] }[] = [
   { emotion: 'disgusted', keywords: ['恶心', '厌恶', '肮脏', '丑陋', '卑鄙'] },
 ];
 
-// 场景关键词库
-const SCENE_KEYWORDS: { scene: string; keywords: string[] }[] = [
-  { scene: '新闻播报',         keywords: ['新闻', '报道', '消息', '公告', '通知', '声明', '记者', '据悉', '据新华社', '人民日报'] },
-  { scene: '一些儿童内容解说',  keywords: ['童话', '儿童', '小朋友', '宝宝', '故事', '小熊', '小兔', '公主', '王子', '森林', '魔法', '精灵'] },
-  { scene: '广告促销',         keywords: ['促销', '优惠', '折扣', '限时', '秒杀', '购买', '抢购', '免费', '特价'] },
-  { scene: '脱口秀表演',       keywords: ['搞笑', '幽默', '笑话', '段子', '吐槽', '趣事'] },
-  { scene: '语音导航',         keywords: ['导航', '前方', '左转', '右转', '直行', '到达', '目的地', '路线'] },
-  { scene: '比赛解说',         keywords: ['比赛', '进球', '得分', '冠军', '决赛', '球队', '选手', '比分'] },
+// ============ 内容特征 → 最佳音色 + 情感 自动检测 ============
+// 每条配置 = { voiceId (统一ID), emotion, weight, keywords }
+// weight 越高优先级越高,用于区分强匹配和弱匹配
+interface ContentProfile {
+  voiceId: string;    // 统一音色 ID (voice_01~voice_10)
+  emotion: string;    // CosyVoice 7种情感之一
+  weight: number;     // 匹配权重
+  keywords: string[]; // 触发关键词
+}
+
+const CONTENT_PROFILES: ContentProfile[] = [
+  // 儿童/童话:童声 + 快乐
+  { voiceId: 'voice_03', emotion: 'happy', weight: 4, keywords: [
+    '童话', '儿童', '小朋友', '宝宝', '小熊', '小兔', '公主', '王子', '小动物',
+    '幼儿园', '睡前故事', '寓言', '儿歌', '童谣', '小鸭子', '小猫咪', '小狗狗',
+  ]},
+  // 恐怖/悬疑:磁性男声 + 恐惧
+  { voiceId: 'voice_02', emotion: 'fearful', weight: 3, keywords: [
+    '恐怖', '惊悚', '悬疑', '阴森', '黑暗', '鬼', '谋杀', '深渊', '噩梦', '死亡',
+    '罪案', '推理', '凶手', '密室', '诡异', '毛骨悚然',
+  ]},
+  // 奇幻/魔法:活泼女声 + 惊喜
+  { voiceId: 'voice_03', emotion: 'surprised', weight: 3, keywords: [
+    '魔法', '奇幻', '幻想', '精灵', '巫师', '龙', '异世界', '穿越', '奇迹', '仙境',
+  ]},
+  // 战斗/热血/武侠:清朗男声 + 愤怒
+  { voiceId: 'voice_08', emotion: 'angry', weight: 3, keywords: [
+    '战斗', '厮杀', '复仇', '战争', '侵略', '热血', '武侠', '剑客', '决斗',
+    '擂台', '武道', '拳法', '江湖',
+  ]},
+  // 历史/军事:沧桑男声 + 中性
+  { voiceId: 'voice_06', emotion: 'neutral', weight: 3, keywords: [
+    '历史', '古代', '王朝', '皇帝', '将军', '军事', '战场', '三国', '战国',
+    '秦始皇', '朱元璋', '李世民', '成吉思汗', '长征', '抗日',
+  ]},
+  // 悲伤/抒情:亲切女声 + 悲伤
+  { voiceId: 'voice_09', emotion: 'sad', weight: 3, keywords: [
+    '悲伤', '难过', '哭泣', '眼泪', '心痛', '遗憾', '孤独', '寂寞', '失落',
+    '离别', '思念', '哀伤', '去世', '失去', '回忆', '怀念',
+  ]},
+  // 搞笑/幽默:元气女声 + 惊喜
+  { voiceId: 'voice_01', emotion: 'surprised', weight: 2, keywords: [
+    '搞笑', '幽默', '笑话', '段子', '吐槽', '趣事', '逗比', '整蛊', '糗事',
+  ]},
+  // 浪漫/爱情:元气女声 + 快乐
+  { voiceId: 'voice_01', emotion: 'happy', weight: 2, keywords: [
+    '浪漫', '恋爱', '爱情', '甜蜜', '告白', '婚礼', '情侣', '约会', '暧昧',
+  ]},
+  // 科普/知识:知性女声 + 中性
+  { voiceId: 'voice_04', emotion: 'neutral', weight: 2, keywords: [
+    '科学', '科普', '知识', '原理', '发现', '实验', '研究', '探索', '宇宙',
+    '生物', '化学', '物理', '数学', '地理', '天文', '技术', '人工智能',
+  ]},
+  // 职场/商战:知性女声 + 中性
+  { voiceId: 'voice_04', emotion: 'neutral', weight: 2, keywords: [
+    '职场', '商战', '公司', '老板', '投资', '股票', '创业', '上市', '董事会',
+  ]},
 ];
 
-/** 根据文本内容分析情感 */
+/** 根据文本内容分析情感(兜底:纯情感关键词匹配) */
 function detectEmotion(text: string): string {
   const scores: Record<string, number> = {};
   for (const { emotion, keywords } of EMOTION_KEYWORDS) {
@@ -113,28 +170,109 @@ function detectEmotion(text: string): string {
   return best;
 }
 
-/** 根据文本内容分析场景 */
-function detectScene(text: string): string {
-  const scores: Record<string, number> = {};
-  for (const { scene, keywords } of SCENE_KEYWORDS) {
-    scores[scene] = 0;
-    for (const kw of keywords) {
-      if (text.includes(kw)) scores[scene]++;
+/**
+ * 根据文本内容自动检测最佳音色 + 情感
+ * 返回 { voiceId, instructText },voiceId 为统一音色 ID (voice_01~voice_10)
+ * 当没有任何特征匹配时返回 null(由调用方使用默认音色)
+ */
+export function detectBestVoiceAndEmotion(text: string): { voiceId: string; instructText: string } | null {
+  if (!text || text.length < 20) return null;
+
+  const scores = new Map<string, { voiceId: string; emotion: string; score: number }>();
+
+  for (const profile of CONTENT_PROFILES) {
+    let score = 0;
+    for (const kw of profile.keywords) {
+      // 中文关键词在文本中出现次数 × 权重
+      let idx = -1;
+      while ((idx = text.indexOf(kw, idx + 1)) !== -1) {
+        score += profile.weight;
+      }
+    }
+    if (score > 0) {
+      const key = profile.voiceId;
+      const existing = scores.get(key);
+      if (!existing || score > existing.score) {
+        scores.set(key, { voiceId: profile.voiceId, emotion: profile.emotion, score });
+      }
     }
   }
-  let best = '闲聊互动';
-  let bestScore = 0;
-  for (const [scene, score] of Object.entries(scores)) {
-    if (score > bestScore) { best = scene; bestScore = score; }
+
+  if (scores.size === 0) return null;
+
+  // 取最高分的音色
+  let best: { voiceId: string; emotion: string; score: number } | null = null;
+  for (const entry of scores.values()) {
+    if (!best || entry.score > best.score) {
+      best = entry;
+    }
   }
-  return best;
+
+  if (!best || best.score < 2) return null; // 置信度太低,不使用自动选择
+
+  console.log(`🔍 [AutoDetect] 内容分析: 最佳音色=${best.voiceId}, 情感=${best.emotion}, 匹配度=${best.score}`);
+  return {
+    voiceId: best.voiceId,
+    instructText: `设置情感:${best.emotion}。`,
+  };
 }
 
-/** 根据文本内容动态生成 Aliyun Instruct 文本 */
+/** 根据文本内容动态生成 CosyVoice Instruct 文本(固定格式) */
 export function getVoiceInstruct(text: string): string {
   const emotion = detectEmotion(text);
-  const scene = detectScene(text);
-  return `你正在进行${scene},你说话的情感是${emotion}。`;
+  // CosyVoice v3-flash 系统音色只支持固定格式:设置情感:{emotion}。
+  return `设置情感:${emotion}。`;
+}
+
+/** 儿童内容语速(3-6岁儿童友好语速,0.78 为业内经验值) */
+export const CHILDREN_VOICE_SPEED = 0.78;
+
+/** 默认语速 */
+export const DEFAULT_VOICE_SPEED = 1.0;
+
+/** 儿童/童话内容关键词(独立于 detectBestVoiceAndEmotion,对短文本也生效) */
+const CHILDREN_KEYWORDS = [
+  '童话', '儿童', '小朋友', '宝宝', '小熊', '小兔', '公主', '王子', '小动物',
+  '幼儿园', '睡前故事', '寓言', '儿歌', '童谣', '小鸭子', '小猫咪', '小狗狗',
+  '奇幻', '魔法', '小猪', '小羊', '小白兔', '小鹿', '小象', '小老鼠',
+];
+
+/**
+ * 根据文本内容检测是否为儿童内容(仅用于创建书籍时确定语速)
+ * 返回 { isChildren, suggestedSpeed }
+ */
+export function detectChildrenContent(text: string): { isChildren: boolean; suggestedSpeed: number } {
+  if (!text || text.length < 2) {
+    return { isChildren: false, suggestedSpeed: DEFAULT_VOICE_SPEED };
+  }
+  // 直接用儿童关键词匹配,对短文本(title)也有效
+  let score = 0;
+  for (const kw of CHILDREN_KEYWORDS) {
+    let idx = -1;
+    while ((idx = text.indexOf(kw, idx + 1)) !== -1) {
+      score++;
+    }
+  }
+  if (score >= 1) {
+    return { isChildren: true, suggestedSpeed: CHILDREN_VOICE_SPEED };
+  }
+  return { isChildren: false, suggestedSpeed: DEFAULT_VOICE_SPEED };
+}
+
+/** 7种情感的中文标签映射 */
+export const EMOTION_LABELS: Record<string, string> = {
+  neutral: '中性',
+  happy: '😊 快乐',
+  sad: '😢 悲伤',
+  angry: '😠 愤怒',
+  fearful: '😨 恐惧',
+  surprised: '😲 惊喜',
+  disgusted: '🤢 厌恶',
+};
+
+/** 根据统一音色ID查找音色信息 */
+export function getVoiceById(voiceId: string) {
+  return UNIFIED_VOICES.find(v => v.id === voiceId) || null;
 }
 
 // 兼容旧代码的旧版音色列表(保留但不再推荐使用)
@@ -569,11 +707,40 @@ async function processAudioGeneration(
     console.log(`🔊 使用 TTS Provider: ${tts.name} (vendor=${tts.vendor}, mode=${tts.mode})`);
     ttsLogger.debug(`Provider: ${tts.name}, vendor=${tts.vendor}, mode=${tts.mode}`);
 
+    // 🔍 情感检测:优先级 = 用户手动指定 > 内容自动检测 > 兜底情感关键词
+    // 用户手动指定了 emotion → 直接用,跳过所有检测
+    if ((voiceParams as any).emotion) {
+      const userEmotion = (voiceParams as any).emotion;
+      console.log(`🎭 [Emotion] 用户指定情感: ${userEmotion}`);
+      if (INSTRUCT_ENABLED && tts.vendor === 'bailian') {
+        voiceParams = { ...voiceParams, instructText: `设置情感:${userEmotion}。` };
+      }
+    }
+
+    // 🔍 自动检测:当用户未显式选择音色时,根据内容智能选择最佳音色+情感
+    // 触发条件:voiceId 为空 / 旧系统音色(如'cherry') / 不在统一音色表中
+    const isDefaultVoice = !voiceId
+      || voiceId === 'cherry'   // 旧系统默认音色
+      || !UNIFIED_VOICES.some(v => v.id === voiceId); // 不在10个统一音色中
+    let effectiveVoiceId = voiceId;
+
+    if (isDefaultVoice) {
+      const detected = detectBestVoiceAndEmotion(text);
+      if (detected) {
+        effectiveVoiceId = detected.voiceId;
+        console.log(`🔍 [AutoDetect] 自动选择音色: ${voiceId || '空'} → ${effectiveVoiceId}`);
+        // 自动检测的 instruct(仅在用户未手动指定情感时注入)
+        if (INSTRUCT_ENABLED && tts.vendor === 'bailian' && !(voiceParams as any).instructText) {
+          voiceParams = { ...voiceParams, instructText: detected.instructText };
+        }
+      }
+    }
+
     // 解析音色名称(使用统一音色映射到具体Provider的真实音色)
-    const voiceName = mapToProviderVoice(voiceId, tts.vendor);
+    const voiceName = mapToProviderVoice(effectiveVoiceId || voiceId, tts.vendor);
 
-    // 为 Aliyun 注入 Instruct 情感/场景控制(后期优化,暂不启用)
-    if (INSTRUCT_ENABLED && tts.vendor === 'bailian') {
+    // 为 Aliyun 注入 Instruct 情感控制(兜底:自动检测未触发时用情感关键词兜底
+    if (INSTRUCT_ENABLED && tts.vendor === 'bailian' && !(voiceParams as any).instructText) {
       const instructText = getVoiceInstruct(text);
       if (instructText) {
         voiceParams = { ...voiceParams, instructText };

+ 1 - 0
server/src/types/index.ts

@@ -42,6 +42,7 @@ export interface VoiceParams {
   pitch: number; // -500 - 500
   volume: number; // 0 - 100
   instructText?: string; // Aliyun Instruct 情感/场景控制文本
+  emotion?: string; // 用户手动指定的情感: neutral|happy|sad|angry|fearful|surprised|disgusted
 }
 
 export type AudioStatus = 'processing' | 'completed' | 'failed';

BIN
tab-click-voice.png



BIN
tab-toolbar-design.png


BIN
tab-toolbar-final.png


BIN
tab-toolbar-v2.png


BIN
tab-toolbar-v3.png


+ 16 - 0
test-book-42-ch1.txt

@@ -0,0 +1,16 @@
+小熊要出门
+# Смелый медвежонок
+
+## Глава 1. Маленький медвежонок выходит из дома
+
+Однажды тёплым летним утром маленький медвежонок проснулся в своём уютном дупле. Солнышко светило сквозь листочки и согревало его мягкую шёрстку.
+
+Тут медвежонок услышал странный звук. Буль-буль-буль! Это урчал его животик. «Я так голоден! — подумал медвежонок. — Хочу мёда!»
+
+Он вспомнил, как однажды попробовал сладкий мёд. Он был золотистым и очень-очень вкусным. «Пойду искать мёд!» — решил малыш.
+
+Мишка-мама подошла и нежно поцеловала медвежонка в лобик. «Будь осторожен, мой дорогой! Надень курточку и возвращайся скорее», — сказала она ласково.
+
+Маленький медвежонок помахал маме лапкой и воскликнул: «Не волнуйся! Я уже большой! Я вернусь с мёдом!»
+
+И храбрый медвежонок вышел из дупла навстречу приключениям. День обещал быть чудесным!

+ 4 - 0
test-book-42.md

@@ -0,0 +1,4 @@
+# 🧸 儿童书籍测试:《小星星的冒险》
+
+> 书籍ID: 42 | 5章 | 共 1252 字 | 适合 3-6 岁
+

+ 2 - 11
test-results/.last-run.json

@@ -1,13 +1,4 @@
 {
-  "status": "failed",
-  "failedTests": [
-    "ead26e9fcb04a030c644-c6da9f7c7de9b3cc93f8",
-    "ead26e9fcb04a030c644-22a13a9a4bb26fd5230b",
-    "ead26e9fcb04a030c644-959919d11bd91624377b",
-    "ead26e9fcb04a030c644-55ab96231552182a63ff",
-    "ead26e9fcb04a030c644-0e8b5747e809244fd70e",
-    "ead26e9fcb04a030c644-7cba62e2c1b9f49d16d0",
-    "ead26e9fcb04a030c644-5fd7ce379c2ad62d5e78",
-    "ead26e9fcb04a030c644-f91a106d6035b897ed44"
-  ]
+  "status": "passed",
+  "failedTests": []
 }

+ 0 - 3
test-results/screenshots/e2e-E01-index.png

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

+ 0 - 3
test-results/screenshots/e2e-E02-create.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:2bf08450f56663864e7c69c538bfb9c0dce03d542fa671e27715a5b8bb223655
-size 113415

+ 0 - 3
test-results/screenshots/e2e-E03-book-list.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:562850605990505acd33e98e5d6db5bf20daec8500e71499c7a6a140c2c28cd0
-size 57321

+ 0 - 3
test-results/screenshots/e2e-E04-mine.png

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

+ 0 - 3
test-results/screenshots/e2e-E05-player.png

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

+ 0 - 3
test-results/screenshots/e2e-E06-book-create.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9e536eecfb6d3c3e5cfca2f3fc3262ae832413bbc5ef11efcbc3a9b0b67b55f5
-size 90821

+ 0 - 3
test-results/screenshots/e2e-E07-interactive.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:04e177205c072338afad4b60f888aa5b85a205931a10f1079103e9268d6b2d55
-size 96324

+ 0 - 3
test-results/screenshots/e2e-E09-member.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:4c193e85b2d115f840a03f1e0e1108aae36f1ff2f17b7c50570fc2291fde23dc
-size 29200

+ 0 - 3
test-results/screenshots/e2e-E14-favorites.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6234be55a21981e0e4df11a6a930fa5dfb975eb42fc4f517b8074985998f01dd
-size 13017

+ 0 - 3
test-results/screenshots/e2e-E16-notifications.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:88f8ee1e0a423d9393d7019e4e307a18fde99784474b9d828aae37d26ced6d73
-size 4259

+ 0 - 3
test-results/screenshots/e2e-E18-orders.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:62c7a75c5810cd915a9499658bd24b18abf33655e7f5b402d70a416809cb4d9b
-size 14675

+ 0 - 3
test-results/screenshots/e2e-E19-publish.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:67e6a3db8f2147c9940882fb78fac629d849655926479d61f0ed739681519ba6
-size 80831

+ 0 - 3
test-results/screenshots/e2e-E20-ai-generate.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:989ff82e9a2ecb361f10e944567a485b00a019bdbe7339aa1de4c089407c53d4
-size 17396

+ 0 - 3
test-results/screenshots/e2e-E21-video-create.png

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

+ 0 - 3
test-results/screenshots/e2e-E22-drafts.png

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

+ 0 - 3
test-results/screenshots/e2e-E23-album.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:4528a90ef48f925c564d5490346baf7f5095a3ba109f480f0fed5cd003b3448b
-size 7447

+ 0 - 3
test-results/screenshots/e2e-E24-payment-confirm.png

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:73f5645bfb472b8f8cba64dcb958bc9f8673159db966e7d76b571326ef2dc019
-size 38468

Неке датотеке нису приказане због велике количине промена