Jelajahi Sumber

feat: 新增书籍生成器和视频生成器模块 v1.0.0

主要功能:
- 书籍生成器: 支持 AI 生成书籍大纲、章节内容、语音合成
- 视频生成器: 支持素材管理和视频合成
- LangGraph 工作流: 智能书籍生成流程管理
- 新增数据模型: Book, BookChapter, LearningPath 等
- 前端页面: 书籍列表、创建、详情、播放器等页面

技术栈:
- 后端: Koa + Prisma + LangChain
- 前端: uni-app (Vue3)
- 部署: Nginx 反向代理

域名配置:
- 后端: bookapi.rrbrr.com
- 前端: book.rrbrr.com
MyFramework User 4 bulan lalu
induk
melakukan
d819eb1a32
31 mengubah file dengan 8742 tambahan dan 132 penghapusan
  1. 1825 0
      my-uniapp-vue3/src/pages/book-generator/index.vue
  2. 354 0
      my-uniapp-vue3/src/pages/video-generator/create.vue
  3. 514 0
      my-uniapp-vue3/src/pages/video-generator/index.vue
  4. 182 0
      my-uniapp-vue3/src/pages/video-generator/preview.vue
  5. 115 0
      my-uniapp-vue3/src/types/video-generator.ts
  6. 330 0
      my-uniapp-vue3/src/utils/book-generator-api.ts
  7. 64 0
      my-uniapp-vue3/src/utils/config.ts
  8. 22 20
      my-uniapp-vue3/src/utils/request.ts
  9. 187 0
      my-uniapp-vue3/src/utils/video-generator-api.ts
  10. 6 0
      server/package.json
  11. 160 107
      server/prisma/schema.prisma
  12. 18 5
      server/src/app.ts
  13. 177 0
      server/src/config/models-validator.ts
  14. 117 0
      server/src/config/models.json
  15. 220 0
      server/src/modules/book-generator/README.md
  16. 535 0
      server/src/modules/book-generator/book-generator.controller.ts
  17. 837 0
      server/src/modules/book-generator/book-generator.service.ts
  18. 351 0
      server/src/modules/book-generator/book-generator.store.ts
  19. 184 0
      server/src/modules/book-generator/book-generator.types.ts
  20. 316 0
      server/src/modules/book-generator/book-generator.workflow.ts
  21. 7 0
      server/src/modules/book-generator/index.ts
  22. 315 0
      server/src/modules/book-generator/langgraph/book-langgraph.ts
  23. 121 0
      server/src/modules/book-generator/langgraph/controller.ts
  24. 6 0
      server/src/modules/book-generator/langgraph/index.ts
  25. 45 0
      server/src/modules/book-generator/langgraph/types.ts
  26. 225 0
      server/src/modules/video-generator/README.md
  27. 243 0
      server/src/modules/video-generator/video-generator.controller.ts
  28. 299 0
      server/src/modules/video-generator/video-generator.ffmpeg.ts
  29. 545 0
      server/src/modules/video-generator/video-generator.service.ts
  30. 307 0
      server/src/modules/video-generator/video-generator.types.ts
  31. 115 0
      server/src/services/llm/index.ts

+ 1825 - 0
my-uniapp-vue3/src/pages/book-generator/index.vue

@@ -0,0 +1,1825 @@
+<template>
+  <view class="page">
+    <!-- 顶部导航栏 -->
+    <view class="nav-bar">
+      <view class="nav-content">
+        <view class="nav-left" @click="goBack">
+          <text class="back-icon">←</text>
+        </view>
+        <text class="page-title">书籍生成</text>
+        <view class="nav-right">
+          <text class="nav-btn" @click="switchTab">📚</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 主内容区 -->
+    <view class="main-content">
+      <!-- 视图1:书籍列表 -->
+      <view v-if="currentView === 'list'" class="list-view">
+        <!-- 创建新书籍按钮 -->
+        <view class="create-card" @click="currentView = 'create'">
+          <text class="create-icon">+</text>
+          <text class="create-text">创建新书籍</text>
+        </view>
+
+        <!-- 视频生成入口 -->
+        <view class="video-nav-card" @click="goToVideoGenerator">
+          <view class="video-nav-content">
+            <text class="video-nav-icon">🎬</text>
+            <view class="video-nav-text">
+              <text class="video-nav-title">视频生成</text>
+              <text class="video-nav-desc">图片+音频生成精美视频</text>
+            </view>
+          </view>
+          <text class="video-nav-arrow">→</text>
+        </view>
+
+        <!-- 书籍列表 -->
+        <view v-if="books.length > 0" class="book-list">
+          <view
+            v-for="book in books"
+            :key="book.id"
+            class="book-card"
+          >
+            <view class="book-header" @click="openBook(book)">
+              <text class="book-title">{{ book.title }}</text>
+              <view :class="['status-badge', book.status]">
+                {{ getStatusText(book.status) }}
+              </view>
+            </view>
+            <text class="book-desc" @click="openBook(book)">{{ book.description }}</text>
+            <view class="book-meta" @click="openBook(book)">
+              <text>{{ book.totalChapters }}章</text>
+              <text v-if="book.progress > 0">{{ book.progress }}%</text>
+            </view>
+            <view v-if="book.progress > 0 && book.progress < 100" class="progress-bar" @click="openBook(book)">
+              <view class="progress-fill" :style="{ width: book.progress + '%' }"></view>
+            </view>
+            <!-- 批量生成音频按钮 -->
+            <view v-if="book.status === 'completed'" class="book-actions">
+              <button
+                class="audio-btn"
+                :disabled="generatingAudio"
+                @click.stop="handleGenerateAllAudio(book)"
+              >
+                {{ generatingAudio ? '生成中...' : '🎵 生成全部音频' }}
+              </button>
+              <button
+                class="video-btn"
+                :disabled="generatingVideo"
+                @click.stop="handleGenerateAllVideo(book)"
+              >
+                {{ generatingVideo ? '生成中...' : '🎬 生成全部视频' }}
+              </button>
+            </view>
+          </view>
+        </view>
+
+        <!-- 空状态 -->
+        <view v-else class="empty-state">
+          <text class="empty-icon">📖</text>
+          <text class="empty-text">暂无书籍</text>
+          <text class="empty-hint">点击上方按钮创建第一本书</text>
+        </view>
+      </view>
+
+      <!-- 视图2:创建书籍 -->
+      <view v-if="currentView === 'create'" class="create-view">
+        <view class="card">
+          <view class="card-header">
+            <text class="card-title">📖 创建新书籍</text>
+          </view>
+
+          <!-- 书名 -->
+          <view class="form-item">
+            <text class="form-label">书名 *</text>
+            <input
+              v-model="newBook.title"
+              class="form-input"
+              placeholder="例如:《时间是什么》"
+            />
+          </view>
+
+          <!-- 副标题 -->
+          <view class="form-item">
+            <text class="form-label">副标题</text>
+            <input
+              v-model="newBook.subtitle"
+              class="form-input"
+              placeholder="例如:一本写给青少年的科普书"
+            />
+          </view>
+
+          <!-- 描述/主题 -->
+          <view class="form-item">
+            <text class="form-label">内容描述 *</text>
+            <textarea
+              v-model="newBook.description"
+              class="form-textarea"
+              placeholder="描述这本书的内容、主题、写作目的..."
+              :maxlength="500"
+            />
+          </view>
+
+          <!-- 目标受众 -->
+          <view class="form-item">
+            <text class="form-label">目标受众</text>
+            <view class="chip-group">
+              <view
+                v-for="audience in audiences"
+                :key="audience"
+                :class="['chip', newBook.targetAudience === audience ? 'active' : '']"
+                @click="newBook.targetAudience = audience"
+              >
+                {{ audience }}
+              </view>
+            </view>
+          </view>
+
+          <!-- 写作风格 -->
+          <view class="form-item">
+            <text class="form-label">写作风格</text>
+            <view class="chip-group">
+              <view
+                v-for="style in styles"
+                :key="style"
+                :class="['chip', newBook.style === style ? 'active' : '']"
+                @click="newBook.style = style"
+              >
+                {{ style }}
+              </view>
+            </view>
+          </view>
+
+          <!-- 书籍规模 -->
+          <view class="form-item">
+            <text class="form-label">书籍规模</text>
+            <view class="scale-picker">
+              <view
+                v-for="scale in bookScales"
+                :key="scale.value"
+                :class="['scale-option', { active: newBook.bookScale === scale.value }]"
+                @click="newBook.bookScale = scale.value"
+              >
+                <text class="scale-words">{{ scale.words }}</text>
+                <text class="scale-label">{{ scale.label }}</text>
+                <text class="scale-pages">{{ scale.pages }}</text>
+              </view>
+            </view>
+          </view>
+
+          <view class="btn-group">
+            <button class="btn-cancel" @click="currentView = 'list'">取消</button>
+            <button
+              class="btn-primary"
+              :disabled="!canCreateBook || creating"
+              @click="createNewBook"
+            >
+              {{ creating ? '创建中...' : '创建书籍' }}
+            </button>
+          </view>
+        </view>
+      </view>
+
+      <!-- 视图3:书籍详情/生成 -->
+      <view v-if="currentView === 'detail'" class="detail-view">
+        <!-- 书籍信息 -->
+        <view class="card book-info">
+          <view class="book-title-row">
+            <text class="book-title-large">{{ currentBook?.title }}</text>
+            <view :class="['status-badge', currentBook?.status]">
+              {{ getStatusText(currentBook?.status || 'draft') }}
+            </view>
+          </view>
+          <text v-if="currentBook?.subtitle" class="book-subtitle">{{ currentBook.subtitle }}</text>
+          <text class="book-desc">{{ currentBook?.description }}</text>
+          <view class="book-meta-row">
+            <text>章节:{{ currentBook?.totalChapters }} 章</text>
+            <text>预估:{{ currentBook?.estimatedWords || 0 }} 字</text>
+          </view>
+        </view>
+
+        <!-- 进度显示 -->
+        <view v-if="currentBook && currentBook.progress > 0" class="card progress-card">
+          <text class="card-title">📊 生成进度</text>
+          <view class="progress-display">
+            <text class="progress-text">{{ currentBook.progress }}%</text>
+            <text class="progress-detail">
+              {{ completedChapters }}/{{ currentBook.totalChapters }} 章
+            </text>
+          </view>
+          <view class="progress-bar-large">
+            <view class="progress-fill" :style="{ width: currentBook.progress + '%' }"></view>
+          </view>
+        </view>
+
+        <!-- 大纲展示 -->
+        <view v-if="currentBook?.outline" class="card outline-card">
+          <view class="card-header-row">
+            <text class="card-title">📋 书籍大纲</text>
+            <text class="outline-theme">{{ currentBook.outline.mainTheme }}</text>
+          </view>
+
+          <view class="outline-list">
+            <view
+              v-for="chapter in currentBook.outline.chapters"
+              :key="chapter.number"
+              class="outline-item"
+            >
+              <view class="chapter-num">{{ chapter.number }}</view>
+              <view class="chapter-info">
+                <text class="chapter-title">{{ chapter.title }}</text>
+                <text class="chapter-summary">{{ chapter.summary }}</text>
+                <view class="chapter-points">
+                  <text v-for="(point, idx) in chapter.keyPoints.slice(0, 3)" :key="idx" class="point-tag">
+                    {{ point }}
+                  </text>
+                </view>
+              </view>
+              <view :class="['chapter-status', getChapterStatus(chapter.number)]">
+                {{ getChapterStatusText(chapter.number) }}
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <!-- 操作按钮 -->
+        <view class="card action-card">
+          <!-- 未生成大纲:显示所有生成方式 -->
+          <view v-if="!currentBook?.outline && currentBook?.status !== 'planning'" class="btn-col">
+            <view class="btn-row">
+              <button
+                class="action-btn"
+                :disabled="generating"
+                @click="handleGenerateOutline"
+              >
+                {{ generating ? '生成中...' : '📋 生成大纲' }}
+              </button>
+              <button
+                class="action-btn primary"
+                :disabled="generating"
+                @click="handleGenerateAll"
+              >
+                {{ generating ? '生成中...' : '🚀 一键生成' }}
+              </button>
+              <button
+                class="action-btn langgraph-btn"
+                :disabled="generating"
+                @click="handleLangGraphGenerate"
+              >
+                {{ generating ? '生成中...' : '🤖 LangGraph' }}
+              </button>
+            </view>
+          </view>
+
+          <!-- 大纲已生成,显示所有生成方式 -->
+          <view v-if="currentBook?.outline && currentBook.status !== 'generating'" class="btn-col">
+            <view class="btn-row">
+              <button class="action-btn primary" :disabled="generating" @click="handleGenerateAll">
+                {{ generating ? '生成中...' : '🚀 一键生成' }}
+              </button>
+              <button class="action-btn langgraph-btn" :disabled="generating" @click="handleLangGraphGenerate">
+                {{ generating ? '生成中...' : '🤖 LangGraph' }}
+              </button>
+            </view>
+            <view class="btn-row">
+              <button class="action-btn" @click="handleGenerateForeword">前言</button>
+              <button
+                class="action-btn"
+                :disabled="generating"
+                @click="handleGenerateAllChapters"
+              >
+                {{ generating ? '生成中...' : '📝 章节' }}
+              </button>
+              <button class="action-btn" @click="handleGenerateAfterword">后记</button>
+            </view>
+          </view>
+
+          <!-- 生成中 -->
+          <view v-if="generating" class="generating-tip">
+            <text>正在生成中,请稍候...</text>
+          </view>
+
+          <!-- 查看完整内容 -->
+          <button
+            v-if="currentBook?.status === 'completed'"
+            class="action-btn primary full-width"
+            @click="showFullContent"
+          >
+            📖 查看完整书籍
+          </button>
+
+          <!-- 返回列表 -->
+          <button class="action-btn full-width" @click="currentView = 'list'; loadBooks()">
+            返回书籍列表
+          </button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 目录视图 -->
+    <view v-if="currentView === 'toc'" class="toc-view">
+      <!-- 顶部导航 -->
+      <view class="nav-bar">
+        <view class="nav-content">
+          <view class="nav-left" @click="currentView = 'detail'">
+            <text class="back-icon">←</text>
+          </view>
+          <text class="page-title">目录</text>
+          <view class="nav-right"></view>
+        </view>
+      </view>
+
+      <view class="toc-content">
+        <!-- 前言入口 -->
+        <view v-if="currentBook?.metadata?.foreword" class="toc-item foreword" @click="showForeword">
+          <text class="toc-label">前言</text>
+          <text class="toc-arrow">→</text>
+        </view>
+
+        <!-- 章节列表 -->
+        <view
+          v-for="chapter in currentBook?.chapters"
+          :key="chapter.id"
+          :class="['toc-item', 'chapter', chapter.status === 'completed' ? 'done' : 'pending']"
+        >
+          <view class="toc-left" @click="openChapter(chapter)">
+            <text class="chapter-num">第{{ chapter.number }}章</text>
+            <view class="chapter-info-col">
+              <text class="chapter-title">{{ chapter.title }}</text>
+              <text v-if="chapter.audioUrl" class="audio-status">🎵 已生成音频</text>
+            </view>
+          </view>
+          <view class="toc-actions">
+            <!-- 生成音频按钮 -->
+            <button
+              v-if="chapter.status === 'completed' && !chapter.audioUrl"
+              class="mini-audio-btn"
+              :disabled="generatingAudio"
+              @click.stop="handleGenerateChapterAudio(chapter)"
+            >
+              🎵
+            </button>
+            <!-- 生成视频按钮 -->
+            <button
+              v-if="chapter.status === 'completed' && chapter.audioUrl && !chapter.videoUrl"
+              class="mini-video-btn"
+              :disabled="generatingVideo"
+              @click.stop="handleGenerateChapterVideo(chapter)"
+            >
+              🎬
+            </button>
+            <text v-if="chapter.videoUrl" class="video-status">🎬 已生成</text>
+            <text class="toc-arrow" @click="openChapter(chapter)">→</text>
+          </view>
+        </view>
+
+        <!-- 后记入口 -->
+        <view v-if="currentBook?.metadata?.afterword" class="toc-item afterword" @click="showAfterword">
+          <text class="toc-label">后记</text>
+          <text class="toc-arrow">→</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 章节详情视图 -->
+    <view v-if="currentView === 'chapter-detail'" class="chapter-detail-view">
+      <!-- 顶部导航 -->
+      <view class="nav-bar">
+        <view class="nav-content">
+          <view class="nav-left" @click="currentView = 'toc'">
+            <text class="back-icon">←</text>
+          </view>
+          <text class="page-title">第{{ currentChapter?.number }}章</text>
+          <view class="nav-right"></view>
+        </view>
+      </view>
+
+      <view class="chapter-content">
+        <scroll-view class="chapter-scroll" scroll-y>
+          <view class="chapter-header">
+            <text class="chapter-title-large">{{ currentChapter?.title }}</text>
+            <text class="chapter-word-count">{{ currentChapter?.wordCount || 0 }}字</text>
+          </view>
+          <view class="chapter-body">
+            <text class="chapter-text">{{ currentChapter?.content }}</text>
+          </view>
+        </scroll-view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import * as api from '../../utils/book-generator-api';
+import type { Book, BookOutline, Chapter } from '../../utils/book-generator-api';
+
+// 使用相对路径(Nginx 反向代理)
+const BASE_URL = '/api';
+
+// 视图状态
+const currentView = ref<'list' | 'create' | 'detail' | 'toc' | 'chapter-detail'>('list');
+
+// 书籍列表
+const books = ref<Book[]>([]);
+
+// 当前书籍
+const currentBook = ref<Book | null>(null);
+
+// 当前查看的章节
+const currentChapter = ref<Chapter | null>(null);
+
+// 创建表单
+const showCreateModal = ref(false);
+const creating = ref(false);
+const newBook = ref({
+  title: '',
+  subtitle: '',
+  description: '',
+  targetAudience: '',
+  style: '',
+  bookScale: 'medium',
+});
+
+// 生成状态
+const generating = ref(false);
+
+// 音频生成状态
+const generatingAudio = ref(false);
+
+// 视频生成状态
+const generatingVideo = ref(false);
+
+// 选项配置
+const audiences = ['不填', '通用', '青少年', '儿童', '大学生', '专业人士', '老年'];
+const styles = ['不填', '通俗易懂', '专业严谨', '轻松幽默', '诗意优美', '故事化'];
+const bookScales = [
+  { value: '800', label: '短文', words: '800字', pages: '约3页' },
+  { value: '2000', label: '短文', words: '2000字', pages: '约8页' },
+  { value: '5000', label: '短文', words: '5000字', pages: '约20页' },
+  { value: '小册子', label: '小册子', words: '1~5万字', pages: '约50~120页' },
+  { value: '标准教程', label: '标准教程', words: '5~15万字', pages: '约150~300页' },
+  { value: '系统教材', label: '系统教材', words: '15~30万字', pages: '约300~550页' },
+  // { value: '专业厚本', label: '专业厚本', words: '30~60万字', pages: '约550~900页' },
+  // { value: '大部头', label: '大部头', words: '60万字以上', pages: '900页以上' },
+];
+
+// 计算属性
+const canCreateBook = computed(() => {
+  return newBook.value.title.trim() && newBook.value.description.trim();
+});
+
+const completedChapters = computed(() => {
+  if (!currentBook.value) return 0;
+  return currentBook.value.chapters.filter((c) => c.status === 'completed').length;
+});
+
+// 方法
+function goBack() {
+  uni.navigateBack();
+}
+
+function switchTab() {
+  uni.switchTab({ url: '/pages/index/index' });
+}
+
+function goToVideoGenerator() {
+  uni.navigateTo({ url: '/pages/video-generator/index' });
+}
+
+function getStatusText(status: string): string {
+  const map: Record<string, string> = {
+    draft: '草稿',
+    planning: '规划中',
+    generating: '生成中',
+    completed: '已完成',
+    failed: '失败',
+  };
+  return map[status] || status;
+}
+
+function getChapterStatus(chapterNum: number): string {
+  const chapter = currentBook.value?.chapters.find((c) => c.number === chapterNum);
+  if (!chapter) return 'pending';
+  return chapter.status === 'completed' ? 'done' : 'pending';
+}
+
+function getChapterStatusText(chapterNum: number): string {
+  const chapter = currentBook.value?.chapters.find((c) => c.number === chapterNum);
+  if (!chapter) return '待生成';
+  return chapter.status === 'completed' ? '✓' : '○';
+}
+
+// 加载书籍列表
+async function loadBooks() {
+  try {
+    const data = await api.getBooks();
+    books.value = data || [];
+  } catch (e) {
+    console.error('加载书籍失败:', e);
+    books.value = [];
+  }
+}
+
+// 创建新书籍
+async function createNewBook() {
+  if (!canCreateBook.value) return;
+
+  creating.value = true;
+  try {
+    await api.createBook({
+      title: newBook.value.title,
+      subtitle: newBook.value.subtitle,
+      description: newBook.value.description,
+      ...(newBook.value.targetAudience ? { targetAudience: newBook.value.targetAudience } : {}),
+      ...(newBook.value.style ? { style: newBook.value.style } : {}),
+      bookScale: newBook.value.bookScale,
+    });
+
+    // 重置表单
+    newBook.value = {
+      title: '',
+      subtitle: '',
+      description: '',
+      targetAudience: '',
+      style: '',
+      bookScale: 'medium',
+    };
+    showCreateModal.value = false;
+    currentView.value = 'list';
+    await loadBooks();
+    uni.showToast({ title: '创建成功', icon: 'success' });
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '创建失败', icon: 'none' });
+  } finally {
+    creating.value = false;
+  }
+}
+
+// 打开书籍
+async function openBook(book: Book) {
+  try {
+    currentBook.value = await api.getBook(book.id);
+    currentView.value = 'detail';
+  } catch (e) {
+    uni.showToast({ title: '加载失败', icon: 'none' });
+  }
+}
+
+// 生成大纲
+async function handleGenerateOutline() {
+  if (!currentBook.value) return;
+
+  generating.value = true;
+  try {
+    const outline = await api.generateOutline(currentBook.value.id);
+    currentBook.value.outline = outline;
+    currentBook.value.status = 'planning';
+    // 初始化章节状态
+    currentBook.value.chapters = outline.chapters.map((c) => ({
+      id: '',
+      bookId: currentBook.value!.id,
+      number: c.number,
+      title: c.title,
+      content: '',
+      wordCount: 0,
+      status: 'pending' as const,
+    }));
+    uni.showToast({ title: '大纲生成成功', icon: 'success' });
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
+  } finally {
+    generating.value = false;
+  }
+}
+
+// 一键生成整本书(大纲+章节+前言+后记)- 异步模式
+let pollTimer: ReturnType<typeof setInterval> | null = null;
+
+async function handleGenerateAll() {
+  if (!currentBook.value) return;
+
+  generating.value = true;
+  currentBook.value.status = 'generating';
+  currentBook.value.progress = 0;
+
+  try {
+    // 启动异步生成任务(立即返回)
+    await api.generateBook(currentBook.value.id, {
+      generateForeword: true,
+      generateAfterword: true,
+    });
+
+    // 显示提示
+    uni.showToast({ title: '生成任务已启动,请稍候...', icon: 'none', duration: 2000 });
+
+    // 开始轮询进度
+    startPollingProgress(currentBook.value.id);
+  } catch (e: any) {
+    generating.value = false;
+    currentBook.value.status = 'failed';
+    uni.showToast({ title: e.message || '启动失败', icon: 'none' });
+  }
+}
+
+// 轮询进度
+function startPollingProgress(bookId: string) {
+  // 停止之前的轮询
+  stopPollingProgress();
+
+  pollTimer = setInterval(async () => {
+    try {
+      // 直接轮询书籍进度,不依赖工作流
+      const progress = await api.getProgress(bookId);
+
+      if (!progress) {
+        return;
+      }
+
+      // 更新进度
+      if (currentBook.value) {
+        currentBook.value.progress = progress.progress;
+        currentBook.value.status = progress.status as any;
+      }
+
+      // 检查是否完成
+      if (progress.status === 'completed' || progress.status === 'failed') {
+        stopPollingProgress();
+        generating.value = false;
+
+        // 刷新书籍详情
+        currentBook.value = await api.getBook(bookId);
+
+        if (progress.status === 'completed') {
+          uni.showToast({ title: '整本书生成完成!', icon: 'success' });
+        } else if (progress.status === 'failed') {
+          uni.showToast({ title: '生成过程中有错误', icon: 'none' });
+        }
+      }
+    } catch (e) {
+      console.error('轮询进度失败:', e);
+    }
+  }, 3000); // 每3秒轮询一次
+}
+
+// 停止轮询
+function stopPollingProgress() {
+  if (pollTimer) {
+    clearInterval(pollTimer);
+    pollTimer = null;
+  }
+}
+
+// LangGraph 生成
+async function handleLangGraphGenerate() {
+  if (!currentBook.value) return;
+
+  generating.value = true;
+  currentBook.value.status = 'generating';
+  currentBook.value.progress = 0;
+
+  try {
+    const result = await api.generateWithLangGraph(currentBook.value.id);
+    uni.showToast({ title: 'LangGraph 生成已启动', icon: 'none', duration: 2000 });
+    startPollingProgress(currentBook.value.id);
+  } catch (e: any) {
+    generating.value = false;
+    currentBook.value.status = 'failed';
+    uni.showToast({ title: e.message || '启动失败', icon: 'none' });
+  }
+}
+
+// 生成全部章节
+async function handleGenerateAllChapters() {
+  if (!currentBook.value || !currentBook.value.outline) {
+    uni.showToast({ title: '请先生成大纲', icon: 'none' });
+    return;
+  }
+
+  generating.value = true;
+  currentBook.value.status = 'generating';
+
+  try {
+    for (const outlineChapter of currentBook.value.outline.chapters) {
+      try {
+        const chapter = await api.generateChapter(currentBook.value.id, outlineChapter.number);
+        // 更新章节
+        const idx = currentBook.value.chapters.findIndex((c) => c.number === chapter.number);
+        if (idx >= 0) {
+          currentBook.value.chapters[idx] = chapter;
+        }
+        // 更新进度
+        const completed = currentBook.value.chapters.filter((c) => c.status === 'completed').length;
+        currentBook.value.progress = Math.round((completed / currentBook.value.totalChapters) * 100);
+      } catch (e) {
+        console.error(`生成第${outlineChapter.number}章失败:`, e);
+      }
+    }
+
+    currentBook.value.status = 'completed';
+    uni.showToast({ title: '全部章节生成完成', icon: 'success' });
+  } catch (e: any) {
+    currentBook.value.status = 'failed';
+    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
+  } finally {
+    generating.value = false;
+  }
+}
+
+// 生成前言
+async function handleGenerateForeword() {
+  if (!currentBook.value) return;
+
+  generating.value = true;
+  try {
+    const foreword = await api.generateForeword(currentBook.value.id);
+    currentBook.value.metadata = currentBook.value.metadata || {};
+    currentBook.value.metadata.foreword = foreword;
+    uni.showToast({ title: '前言生成成功', icon: 'success' });
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
+  } finally {
+    generating.value = false;
+  }
+}
+
+// 生成后记
+async function handleGenerateAfterword() {
+  if (!currentBook.value) return;
+
+  generating.value = true;
+  try {
+    const afterword = await api.generateAfterword(currentBook.value.id);
+    currentBook.value.metadata = currentBook.value.metadata || {};
+    currentBook.value.metadata.afterword = afterword;
+    uni.showToast({ title: '后记生成成功', icon: 'success' });
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
+  } finally {
+    generating.value = false;
+  }
+}
+
+// 显示完整内容 - 现在显示目录
+async function showFullContent() {
+  if (!currentBook.value) return;
+
+  // 刷新书籍详情获取最新章节
+  currentBook.value = await api.getBook(currentBook.value.id);
+  currentView.value = 'toc';
+}
+
+// 打开章节详情
+function openChapter(chapter: Chapter) {
+  if (chapter.status !== 'completed') {
+    uni.showToast({ title: '该章节尚未生成', icon: 'none' });
+    return;
+  }
+  currentChapter.value = chapter;
+  currentView.value = 'chapter-detail';
+}
+
+// 显示前言
+function showForeword() {
+  if (!currentBook.value?.metadata?.foreword) {
+    uni.showToast({ title: '前言尚未生成', icon: 'none' });
+    return;
+  }
+  // 创建一个临时章节对象显示前言
+  currentChapter.value = {
+    id: 'foreword',
+    bookId: currentBook.value.id,
+    number: 0,
+    title: '前言',
+    content: currentBook.value.metadata.foreword,
+    wordCount: currentBook.value.metadata.foreword.length,
+    status: 'completed',
+  };
+  currentView.value = 'chapter-detail';
+}
+
+// 显示后记
+function showAfterword() {
+  if (!currentBook.value?.metadata?.afterword) {
+    uni.showToast({ title: '后记尚未生成', icon: 'none' });
+    return;
+  }
+  currentChapter.value = {
+    id: 'afterword',
+    bookId: currentBook.value.id,
+    number: currentBook.value.chapters.length + 1,
+    title: '后记',
+    content: currentBook.value.metadata.afterword,
+    wordCount: currentBook.value.metadata.afterword.length,
+    status: 'completed',
+  };
+  currentView.value = 'chapter-detail';
+}
+
+// ============ 音频生成 ============
+
+/**
+ * 生成单个章节音频
+ */
+async function handleGenerateChapterAudio(chapter: Chapter) {
+  if (!currentBook.value) return;
+
+  generatingAudio.value = true;
+  try {
+    await api.generateChapterAudio(currentBook.value.id, chapter.number, 'cherry');
+    uni.showToast({ title: '音频生成任务已启动', icon: 'none', duration: 2000 });
+
+    // 3秒后刷新书籍信息,检查音频是否生成完成
+    setTimeout(async () => {
+      if (currentBook.value) {
+        currentBook.value = await api.getBook(currentBook.value.id);
+      }
+    }, 3000);
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
+  } finally {
+    generatingAudio.value = false;
+  }
+}
+
+/**
+ * 批量生成书籍所有章节音频
+ */
+async function handleGenerateAllAudio(book: Book) {
+  generatingAudio.value = true;
+  try {
+    const result = await api.generateAllChaptersAudio(book.id, 'cherry');
+    uni.showToast({
+      title: `已启动 ${result.totalChapters} 个章节的音频生成`,
+      icon: 'none',
+      duration: 2500,
+    });
+
+    // 5秒后刷新书籍信息
+    setTimeout(async () => {
+      await loadBooks();
+      if (currentBook.value?.id === book.id) {
+        currentBook.value = await api.getBook(book.id);
+      }
+    }, 5000);
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
+  } finally {
+    generatingAudio.value = false;
+  }
+}
+
+// ============ 视频生成 ============
+
+/**
+ * 生成单个章节视频
+ */
+async function handleGenerateChapterVideo(chapter: Chapter) {
+  if (!currentBook.value) return;
+
+  generatingVideo.value = true;
+  try {
+    await api.generateChapterVideo(currentBook.value.id, chapter.number);
+    uni.showToast({ title: '视频生成任务已启动', icon: 'none', duration: 2000 });
+
+    // 3秒后刷新书籍信息,检查视频是否生成完成
+    setTimeout(async () => {
+      if (currentBook.value) {
+        currentBook.value = await api.getBook(currentBook.value.id);
+      }
+    }, 3000);
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
+  } finally {
+    generatingVideo.value = false;
+  }
+}
+
+/**
+ * 批量生成书籍所有章节视频
+ */
+async function handleGenerateAllVideo(book: Book) {
+  generatingVideo.value = true;
+  try {
+    const result = await api.generateAllChaptersVideo(book.id);
+    uni.showToast({
+      title: `已启动 ${result.totalChapters} 个章节的视频生成`,
+      icon: 'none',
+      duration: 2500,
+    });
+
+    // 5秒后刷新书籍信息
+    setTimeout(async () => {
+      await loadBooks();
+      if (currentBook.value?.id === book.id) {
+        currentBook.value = await api.getBook(book.id);
+      }
+    }, 5000);
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
+  } finally {
+    generatingVideo.value = false;
+  }
+}
+
+// 页面加载
+onMounted(() => {
+  loadBooks();
+});
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: #f9fafb;
+}
+
+.nav-bar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  z-index: 100;
+  background: #ffffff;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.nav-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 88rpx;
+  padding: 0 32rpx;
+  padding-top: env(safe-area-inset-top);
+}
+
+.nav-left,
+.nav-right {
+  width: 80rpx;
+}
+
+.back-icon,
+.nav-btn {
+  font-size: 40rpx;
+  color: #1f2937;
+}
+
+.page-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.main-content {
+  padding: 120rpx 32rpx 32rpx;
+}
+
+/* 卡片样式 */
+.card {
+  background: #ffffff;
+  border-radius: 24rpx;
+  padding: 32rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.card-header {
+  margin-bottom: 24rpx;
+}
+
+.card-header-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 24rpx;
+}
+
+.card-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+/* 创建卡片 */
+.create-card {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 16rpx;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border-radius: 24rpx;
+  padding: 48rpx;
+  margin-bottom: 24rpx;
+}
+
+.create-icon {
+  font-size: 48rpx;
+  color: #ffffff;
+}
+
+.create-text {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #ffffff;
+}
+
+/* 视频导航卡片 */
+.video-nav-card {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+  border-radius: 24rpx;
+  padding: 36rpx;
+  margin-bottom: 24rpx;
+}
+
+.video-nav-content {
+  display: flex;
+  align-items: center;
+  gap: 24rpx;
+}
+
+.video-nav-icon {
+  font-size: 56rpx;
+}
+
+.video-nav-text {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+
+.video-nav-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #ffffff;
+}
+
+.video-nav-desc {
+  font-size: 22rpx;
+  color: rgba(255, 255, 255, 0.8);
+}
+
+.video-nav-arrow {
+  font-size: 40rpx;
+  color: #ffffff;
+}
+
+/* 书籍列表 */
+.book-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.book-card {
+  background: #ffffff;
+  border-radius: 20rpx;
+  padding: 28rpx;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.book-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12rpx;
+}
+
+.book-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1f2937;
+  flex: 1;
+}
+
+.status-badge {
+  font-size: 22rpx;
+  padding: 6rpx 16rpx;
+  border-radius: 20rpx;
+  background: #e5e7eb;
+  color: #6b7280;
+}
+
+.status-badge.planning,
+.status-badge.generating {
+  background: #fef3c7;
+  color: #d97706;
+}
+
+.status-badge.completed {
+  background: #d1fae5;
+  color: #059669;
+}
+
+.status-badge.failed {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
+.book-desc {
+  font-size: 26rpx;
+  color: #6b7280;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  margin-bottom: 16rpx;
+}
+
+.book-meta {
+  display: flex;
+  gap: 24rpx;
+  font-size: 24rpx;
+  color: #9ca3af;
+  margin-bottom: 12rpx;
+}
+
+.progress-bar {
+  height: 6rpx;
+  background: #e5e7eb;
+  border-radius: 3rpx;
+}
+
+.progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
+  border-radius: 3rpx;
+  transition: width 0.3s;
+}
+
+/* 空状态 */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 0;
+}
+
+.empty-icon {
+  font-size: 120rpx;
+  margin-bottom: 24rpx;
+}
+
+.empty-text {
+  font-size: 32rpx;
+  color: #6b7280;
+  margin-bottom: 12rpx;
+}
+
+.empty-hint {
+  font-size: 26rpx;
+  color: #9ca3af;
+}
+
+/* 表单 */
+.form-item {
+  margin-bottom: 28rpx;
+}
+
+.form-label {
+  display: block;
+  font-size: 28rpx;
+  color: #374151;
+  margin-bottom: 12rpx;
+}
+
+.form-input {
+  width: 100%;
+  height: 88rpx;
+  padding: 0 24rpx;
+  background: #f9fafb;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+
+.form-textarea {
+  width: 100%;
+  height: 200rpx;
+  padding: 24rpx;
+  background: #f9fafb;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+
+.slider-range {
+  display: flex;
+  justify-content: space-between;
+  font-size: 24rpx;
+  color: #9ca3af;
+  margin-top: 8rpx;
+}
+
+.scale-picker {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+  margin-top: 12rpx;
+}
+
+.scale-option {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 16rpx;
+  border: 2rpx solid #e5e7eb;
+  border-radius: 12rpx;
+  background: #f9fafb;
+  transition: all 0.2s;
+}
+
+.scale-option.active {
+  border-color: #4F46E5;
+  background: #eef2ff;
+}
+
+.scale-label {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #374151;
+  min-width: 160rpx;
+  margin-left: 24rpx;
+}
+
+.scale-option.active .scale-label {
+  color: #4F46E5;
+}
+
+.scale-words {
+  font-size: 28rpx;
+  color: #374151;
+  min-width: 160rpx;
+  font-weight: 500;
+}
+
+.scale-pages {
+  font-size: 22rpx;
+  color: #6b7280;
+  flex: 1;
+  text-align: right;
+}
+
+/* 标签组 */
+.chip-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+
+.chip {
+  padding: 12rpx 24rpx;
+  background: #f3f4f6;
+  border-radius: 30rpx;
+  font-size: 26rpx;
+  color: #6b7280;
+}
+
+.chip.active {
+  background: #4f46e5;
+  color: #ffffff;
+}
+
+/* 按钮组 */
+.btn-group {
+  display: flex;
+  gap: 20rpx;
+  margin-top: 32rpx;
+}
+
+.btn-cancel,
+.btn-primary {
+  flex: 1;
+  height: 88rpx;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+}
+
+.btn-cancel {
+  background: #f3f4f6;
+  color: #6b7280;
+}
+
+.btn-primary {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: #ffffff;
+}
+
+.btn-primary[disabled] {
+  opacity: 0.6;
+}
+
+/* 详情页 */
+.book-info {
+  margin-bottom: 24rpx;
+}
+
+.book-title-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8rpx;
+}
+
+.book-title-large {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #1f2937;
+  flex: 1;
+}
+
+.book-subtitle {
+  font-size: 28rpx;
+  color: #6b7280;
+  margin-bottom: 16rpx;
+}
+
+.book-meta-row {
+  display: flex;
+  gap: 24rpx;
+  font-size: 26rpx;
+  color: #9ca3af;
+  margin-top: 16rpx;
+}
+
+/* 进度卡片 */
+.progress-card {
+  margin-bottom: 24rpx;
+}
+
+.progress-display {
+  display: flex;
+  align-items: baseline;
+  gap: 16rpx;
+  margin: 16rpx 0;
+}
+
+.progress-text {
+  font-size: 48rpx;
+  font-weight: 700;
+  color: #4f46e5;
+}
+
+.progress-detail {
+  font-size: 28rpx;
+  color: #6b7280;
+}
+
+.progress-bar-large {
+  height: 12rpx;
+  background: #e5e7eb;
+  border-radius: 6rpx;
+}
+
+/* 大纲卡片 */
+.outline-theme {
+  font-size: 24rpx;
+  color: #6b7280;
+}
+
+.outline-list {
+  margin-top: 20rpx;
+}
+
+.outline-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 20rpx;
+  padding: 20rpx 0;
+  border-bottom: 1px solid #f3f4f6;
+}
+
+.outline-item:last-child {
+  border-bottom: none;
+}
+
+.chapter-num {
+  width: 48rpx;
+  height: 48rpx;
+  background: #4f46e5;
+  color: #ffffff;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24rpx;
+  font-weight: 600;
+  flex-shrink: 0;
+}
+
+.chapter-info {
+  flex: 1;
+}
+
+.chapter-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1f2937;
+  margin-bottom: 6rpx;
+  display: block;
+}
+
+.chapter-summary {
+  font-size: 24rpx;
+  color: #6b7280;
+  display: block;
+  margin-bottom: 10rpx;
+}
+
+.chapter-points {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8rpx;
+}
+
+.point-tag {
+  font-size: 20rpx;
+  padding: 4rpx 12rpx;
+  background: #f3f4f6;
+  color: #6b7280;
+  border-radius: 6rpx;
+}
+
+.chapter-status {
+  font-size: 28rpx;
+  color: #9ca3af;
+  flex-shrink: 0;
+}
+
+.chapter-status.done {
+  color: #059669;
+}
+
+/* 操作卡片 */
+.action-card {
+  margin-bottom: 24rpx;
+}
+
+.btn-row {
+  display: flex;
+  gap: 16rpx;
+  margin-bottom: 20rpx;
+}
+
+.action-btn {
+  flex: 1;
+  height: 88rpx;
+  background: #f3f4f6;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  color: #374151;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+}
+
+.action-btn.primary {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: #ffffff;
+}
+
+.action-btn.full-width {
+  width: 100%;
+  flex: none;
+  margin-bottom: 16rpx;
+}
+
+.action-btn[disabled] {
+  opacity: 0.6;
+}
+
+.generating-tip {
+  text-align: center;
+  padding: 20rpx;
+  color: #6b7280;
+  font-size: 26rpx;
+}
+
+/* LangGraph 按钮 */
+.action-btn.langgraph-btn {
+  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+  color: #ffffff;
+}
+
+/* 全屏弹窗 */
+.modal-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 1000;
+  display: flex;
+  align-items: flex-end;
+}
+
+.content-modal {
+  width: 100%;
+  height: 90vh;
+  background: #ffffff;
+  border-radius: 32rpx 32rpx 0 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.modal-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 32rpx;
+  border-bottom: 1px solid #f3f4f6;
+  flex-shrink: 0;
+}
+
+.modal-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.modal-close {
+  font-size: 40rpx;
+  color: #9ca3af;
+  padding: 8rpx;
+}
+
+.content-scroll {
+  flex: 1;
+  padding: 32rpx;
+}
+
+.content-body {
+  font-size: 28rpx;
+  line-height: 1.8;
+  color: #374151;
+}
+
+/* ==================== 目录视图 ==================== */
+.toc-view {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: #f9fafb;
+  z-index: 200;
+  display: flex;
+  flex-direction: column;
+}
+
+.toc-view .nav-bar {
+  position: relative;
+  flex-shrink: 0;
+}
+
+.toc-content {
+  flex: 1;
+  padding: 24rpx 32rpx;
+  padding-top: calc(120rpx + env(safe-area-inset-top));
+  overflow-y: auto;
+}
+
+.toc-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #ffffff;
+  border-radius: 16rpx;
+  padding: 28rpx 32rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.toc-item.foreword,
+.toc-item.afterword {
+  background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+}
+
+.toc-item.foreword .toc-label,
+.toc-item.afterword .toc-label {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #92400e;
+}
+
+.toc-item.chapter {
+  cursor: pointer;
+}
+
+.toc-item.chapter.pending {
+  opacity: 0.6;
+}
+
+.toc-item.chapter.done {
+  background: #ffffff;
+}
+
+.toc-left {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+  flex: 1;
+}
+
+.toc-left .chapter-num {
+  width: 56rpx;
+  height: 56rpx;
+  background: #4f46e5;
+  color: #ffffff;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24rpx;
+  font-weight: 600;
+  flex-shrink: 0;
+}
+
+.toc-item.chapter.pending .chapter-num {
+  background: #9ca3af;
+}
+
+.toc-left .chapter-title {
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #1f2937;
+}
+
+.toc-arrow {
+  font-size: 32rpx;
+  color: #9ca3af;
+}
+
+/* ==================== 章节详情视图 ==================== */
+.chapter-detail-view {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: #f9fafb;
+  z-index: 200;
+  display: flex;
+  flex-direction: column;
+}
+
+.chapter-detail-view .nav-bar {
+  position: relative;
+  flex-shrink: 0;
+}
+
+.chapter-content {
+  flex: 1;
+  padding-top: calc(88rpx + env(safe-area-inset-top));
+  overflow: hidden;
+}
+
+.chapter-scroll {
+  height: 100%;
+  padding: 32rpx;
+}
+
+.chapter-header {
+  margin-bottom: 32rpx;
+  padding-bottom: 24rpx;
+  border-bottom: 1px solid #e5e7eb;
+}
+
+.chapter-title-large {
+  display: block;
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #1f2937;
+  margin-bottom: 12rpx;
+}
+
+.chapter-word-count {
+  font-size: 24rpx;
+  color: #9ca3af;
+}
+
+.chapter-body {
+  background: #ffffff;
+  border-radius: 16rpx;
+  padding: 32rpx;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.chapter-text {
+  font-size: 30rpx;
+  line-height: 1.9;
+  color: #374151;
+  text-align: justify;
+}
+
+/* ==================== 音频生成相关样式 ==================== */
+
+/* 书籍卡片操作区 */
+.book-actions {
+  margin-top: 20rpx;
+  padding-top: 20rpx;
+  border-top: 1px solid #f3f4f6;
+}
+
+.audio-btn {
+  width: 100%;
+  height: 72rpx;
+  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+  border-radius: 12rpx;
+  font-size: 26rpx;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+}
+
+.audio-btn[disabled] {
+  opacity: 0.6;
+}
+
+/* 视频按钮样式 */
+.video-btn {
+  width: 100%;
+  height: 72rpx;
+  background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+  border-radius: 12rpx;
+  font-size: 26rpx;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  margin-top: 16rpx;
+}
+
+.video-btn[disabled] {
+  opacity: 0.6;
+}
+
+/* 目录项操作区 */
+.toc-actions {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+
+.chapter-info-col {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+
+.audio-status {
+  font-size: 20rpx;
+  color: #059669;
+}
+
+.mini-audio-btn {
+  width: 64rpx;
+  height: 64rpx;
+  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+  border-radius: 50%;
+  font-size: 28rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  padding: 0;
+}
+
+.mini-audio-btn[disabled] {
+  opacity: 0.6;
+}
+
+.mini-video-btn {
+  width: 64rpx;
+  height: 64rpx;
+  background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+  border-radius: 50%;
+  font-size: 28rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  padding: 0;
+}
+
+.mini-video-btn[disabled] {
+  opacity: 0.6;
+}
+
+.video-status {
+  font-size: 20rpx;
+  color: #f5576c;
+}
+</style>

+ 354 - 0
my-uniapp-vue3/src/pages/video-generator/create.vue

@@ -0,0 +1,354 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-content">
+        <view class="nav-left" @click="goBack"><text class="back-icon">←</text></view>
+        <text class="page-title">创建视频</text>
+        <view class="nav-right"><text class="save-btn" @click="saveProject">保存</text></view>
+      </view>
+    </view>
+
+    <view class="step-nav">
+      <view v-for="(step, index) in steps" :key="index" :class="['step-item', { active: currentStep === index }]">
+        <view class="step-number">{{ index + 1 }}</view>
+        <text class="step-text">{{ step }}</text>
+      </view>
+    </view>
+
+    <view class="step-content">
+      <view v-show="currentStep === 0" class="step-panel">
+        <view class="form-section">
+          <text class="section-title">基本信息</text>
+          <view class="form-item">
+            <text class="form-label">视频标题 *</text>
+            <textarea
+              v-model="formData.title"
+              class="form-input"
+              placeholder="给视频起个标题"
+              :maxlength="100"
+              auto-height
+            />
+          </view>
+        </view>
+
+        <view class="form-section">
+          <text class="section-title">选择配图</text>
+          <view v-if="formData.config.images.length > 0" class="selected-images">
+            <view v-for="(img, index) in formData.config.images" :key="index" class="selected-image-item">
+              <image :src="img.url" mode="aspectFill" class="selected-image" />
+              <view class="image-remove" @click="removeImage(index)">×</view>
+            </view>
+          </view>
+          <view class="upload-area" @click="chooseImage">
+            <text class="upload-icon">+</text>
+            <text class="upload-text">添加图片</text>
+          </view>
+        </view>
+
+        <view class="form-section">
+          <text class="section-title">选择音频</text>
+          <view v-if="formData.config.audio.url" class="audio-preview">
+            <text class="audio-name">已选择音频</text>
+            <view class="audio-remove" @click="removeAudio">更换音频</view>
+          </view>
+          <view v-else class="upload-area" @click="chooseAudio">
+            <text class="upload-icon">+</text>
+            <text class="upload-text">添加音频</text>
+          </view>
+        </view>
+      </view>
+
+      <view v-show="currentStep === 1" class="step-panel">
+        <view class="form-section">
+          <text class="section-title">视频尺寸</text>
+          <view class="ratio-options">
+            <view v-for="ratio in ratios" :key="ratio.value" :class="['ratio-option', { active: formData.config.video.width === ratio.width }]" @click="selectRatio(ratio)">
+              <text class="ratio-label">{{ ratio.label }}</text>
+            </view>
+          </view>
+        </view>
+
+        <view class="form-section">
+          <text class="section-title">Ken Burns 效果</text>
+          <view class="switch-item">
+            <text class="switch-label">启用效果</text>
+            <switch :checked="formData.config.kenburns.enabled" @change="toggleKenBurns" color="#667eea" />
+          </view>
+        </view>
+      </view>
+
+      <view v-show="currentStep === 2" class="step-panel">
+        <view class="preview-card">
+          <text class="preview-title">{{ formData.title || '未命名视频' }}</text>
+          <text class="meta-item">尺寸: {{ formData.config.video.width }}×{{ formData.config.video.height }}</text>
+          <text class="meta-item">图片: {{ formData.config.images.length }}张</text>
+        </view>
+
+        <view v-if="!isGenerating" class="generate-section">
+          <button class="generate-btn" @click="startGenerate">开始生成视频</button>
+        </view>
+
+        <view v-else class="generating-section">
+          <text class="generating-text">正在生成视频... {{ generateProgress }}%</text>
+          <view class="progress-bar-large">
+            <view class="progress-fill-large" :style="{ width: generateProgress + '%' }"></view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="bottom-nav">
+      <view v-if="currentStep > 0" class="nav-btn prev" @click="currentStep--">← 上一步</view>
+      <view v-if="currentStep < steps.length - 1" class="nav-btn next" @click="nextStep">下一步 →</view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { onLoad } from '@dcloudio/uni-app';
+import { createVideoProject, generateVideo, getGenerateProgress } from '@/utils/video-generator-api';
+import { PRESET_VIDEO_CONFIGS, type VideoConfig } from '@/types/video-generator';
+
+const steps = ['选择素材', '配置效果', '生成视频'];
+const currentStep = ref(0);
+const projectId = ref<number | null>(null);
+const isGenerating = ref(false);
+const generateProgress = ref(0);
+
+const ratios = [
+  { value: 'portrait', label: '竖屏', width: 720, height: 1280 },
+  { value: 'landscape', label: '横屏', width: 1920, height: 1080 },
+  { value: 'square', label: '方形', width: 1080, height: 1080 },
+];
+
+const formData = ref({
+  title: '',
+  config: { ...PRESET_VIDEO_CONFIGS.portrait, audio: { url: '', volume: 1 } } as VideoConfig,
+});
+
+function goBack() { uni.navigateBack(); }
+
+function nextStep() {
+  if (currentStep.value === 0) {
+    if (!formData.value.title) { uni.showToast({ title: '请输入标题', icon: 'none' }); return; }
+    if (formData.value.config.images.length === 0) { uni.showToast({ title: '请添加图片', icon: 'none' }); return; }
+  }
+  if (currentStep.value < steps.length - 1) currentStep.value++;
+}
+
+function selectRatio(ratio: any) {
+  const preset = PRESET_VIDEO_CONFIGS[ratio.value];
+  formData.value.config.video = { ...preset.video };
+}
+
+function toggleKenBurns(e: any) { formData.value.config.kenburns.enabled = e.detail.value; }
+
+function chooseImage() {
+  uni.chooseImage({
+    count: 9,
+    success: (res) => {
+      uni.showLoading({ title: '上传图片中...' });
+      
+      let uploadedCount = 0;
+      const totalFiles = res.tempFilePaths.length;
+      
+      res.tempFilePaths.forEach((filePath: string) => {
+        uni.uploadFile({
+          url: 'http://localhost:3000/api/video/materials/upload',
+          filePath: filePath,
+          name: 'file',
+          formData: { type: 'image', category: 'custom' },
+          success: (uploadRes: any) => {
+            uploadedCount++;
+            
+            if (uploadRes.statusCode === 200) {
+              try {
+                const data = JSON.parse(uploadRes.data);
+                if (data.success) {
+                  formData.value.config.images.push({ 
+                    url: data.data.url, 
+                    duration: 5, 
+                    transition: 'fade' 
+                  });
+                }
+              } catch (e) {
+                console.error('解析响应失败:', e);
+              }
+            }
+            
+            if (uploadedCount === totalFiles) {
+              uni.hideLoading();
+              uni.showToast({ title: '上传完成', icon: 'success' });
+            }
+          },
+          fail: (err: any) => {
+            uploadedCount++;
+            console.error('上传失败:', err);
+            
+            if (uploadedCount === totalFiles) {
+              uni.hideLoading();
+              uni.showToast({ title: '部分上传失败', icon: 'none' });
+            }
+          }
+        });
+      });
+    },
+    fail: (err) => {
+      console.error('选择图片失败:', err);
+    }
+  });
+}
+
+function removeImage(index: number) { formData.value.config.images.splice(index, 1); }
+
+function chooseAudio() {
+  // 使用 chooseFile 替代 chooseMessageFile(H5端兼容)
+  uni.chooseFile({
+    count: 1,
+    type: 'audio',
+    success: (res: any) => {
+      uni.showLoading({ title: '上传音频中...' });
+      
+      const file = res.tempFiles[0];
+      const filePath = file.path;
+      
+      uni.uploadFile({
+        url: 'http://localhost:3000/api/video/materials/upload',
+        filePath: filePath,
+        name: 'file',
+        formData: { type: 'audio', category: 'custom' },
+        success: (uploadRes: any) => {
+          uni.hideLoading();
+          
+          if (uploadRes.statusCode === 200) {
+            try {
+              const data = JSON.parse(uploadRes.data);
+              if (data.success) {
+                formData.value.config.audio.url = data.data.url;
+                uni.showToast({ title: '音频上传完成', icon: 'success' });
+              } else {
+                uni.showToast({ title: '上传失败', icon: 'none' });
+              }
+            } catch (e) {
+              console.error('解析响应失败:', e);
+              uni.showToast({ title: '上传失败', icon: 'none' });
+            }
+          } else {
+            uni.showToast({ title: '上传失败', icon: 'none' });
+          }
+        },
+        fail: (err: any) => {
+          uni.hideLoading();
+          console.error('上传失败:', err);
+          uni.showToast({ title: '上传失败', icon: 'none' });
+        }
+      });
+    },
+    fail: (err: any) => {
+      console.error('选择音频失败:', err);
+      uni.showToast({ title: '请选择音频文件', icon: 'none' });
+    }
+  });
+}
+
+function removeAudio() { formData.value.config.audio.url = ''; }
+
+async function saveProject() {
+  try {
+    uni.showLoading({ title: '保存中...' });
+    const res = await createVideoProject({ title: formData.value.title, config: formData.value.config });
+    console.log('保存成功,响应:', res);
+    projectId.value = res.id;
+    uni.hideLoading();
+    uni.showToast({ title: '保存成功', icon: 'success' });
+  } catch (error: any) {
+    uni.hideLoading();
+    console.error('保存失败:', error);
+    uni.showToast({ title: '保存失败: ' + (error.message || '未知错误'), icon: 'none' });
+  }
+}
+
+async function startGenerate() {
+  if (!projectId.value) {
+    await saveProject();
+    if (!projectId.value) { uni.showToast({ title: '请先保存', icon: 'none' }); return; }
+  }
+
+  try {
+    isGenerating.value = true;
+    generateProgress.value = 0;
+    await generateVideo(projectId.value);
+    const pollInterval = setInterval(async () => {
+      try {
+        const data = await getGenerateProgress(projectId.value!);
+        generateProgress.value = data.progress;
+        if (data.status === 'completed') {
+          clearInterval(pollInterval);
+          uni.showToast({ title: '生成成功', icon: 'success' });
+          setTimeout(() => { uni.navigateTo({ url: '/pages/video-generator/preview?id=' + projectId.value }); }, 1500);
+        } else if (data.status === 'failed') {
+          clearInterval(pollInterval);
+          isGenerating.value = false;
+          uni.showToast({ title: '生成失败', icon: 'none' });
+        }
+      } catch (error) { console.error('获取进度失败:', error); }
+    }, 2000);
+  } catch (error) {
+    isGenerating.value = false;
+    uni.showToast({ title: '启动生成失败', icon: 'none' });
+  }
+}
+
+onLoad((query: any) => { if (query?.id) console.log('编辑项目:', query.id); });
+</script>
+
+<style scoped>
+.page { min-height: 100vh; background: #f5f5f5; }
+.nav-bar { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px 15px; }
+.nav-content { display: flex; align-items: center; justify-content: space-between; }
+.nav-left, .nav-right { width: 40px; }
+.back-icon, .save-btn { font-size: 18px; color: white; }
+.page-title { font-size: 18px; font-weight: 600; color: white; }
+.step-nav { display: flex; background: white; padding: 15px; gap: 10px; }
+.step-item { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 5px; }
+.step-number { width: 28px; height: 28px; border-radius: 50%; background: #e0e0e0; color: #999; display: flex; align-items: center; justify-content: center; font-size: 14px; }
+.step-item.active .step-number { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
+.step-text { font-size: 12px; color: #666; }
+.step-content { padding: 15px; }
+.step-panel { background: white; border-radius: 12px; padding: 20px; }
+.form-section { margin-bottom: 25px; }
+.section-title { font-size: 16px; font-weight: 600; color: #333; display: block; margin-bottom: 15px; }
+.form-item { margin-bottom: 15px; }
+.form-label { font-size: 14px; color: #666; display: block; margin-bottom: 8px; }
+.form-input { border: 1px solid #e0e0e0; border-radius: 8px; padding: 12px; font-size: 14px; width: 100%; box-sizing: border-box; }
+.upload-area { border: 2px dashed #e0e0e0; border-radius: 12px; padding: 30px; text-align: center; }
+.upload-icon { font-size: 32px; display: block; margin-bottom: 8px; }
+.upload-text { font-size: 14px; color: #999; }
+.selected-images { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 15px; }
+.selected-image-item { position: relative; width: 80px; height: 80px; }
+.selected-image { width: 100%; height: 100%; border-radius: 8px; }
+.image-remove { position: absolute; top: -8px; right: -8px; width: 20px; height: 20px; background: #f56c6c; color: white; border-radius: 50%; font-size: 12px; display: flex; align-items: center; justify-content: center; }
+.audio-preview { background: #f5f5f5; padding: 15px; border-radius: 8px; }
+.audio-name { font-size: 14px; color: #333; display: block; margin-bottom: 10px; }
+.audio-remove { font-size: 14px; color: #409eff; margin-top: 10px; }
+.ratio-options { display: flex; gap: 10px; }
+.ratio-option { flex: 1; padding: 15px; border: 2px solid #e0e0e0; border-radius: 8px; text-align: center; }
+.ratio-option.active { border-color: #667eea; background: rgba(102, 126, 234, 0.1); }
+.ratio-label { font-size: 14px; color: #333; }
+.switch-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; }
+.switch-label { font-size: 14px; color: #333; }
+.preview-card { background: #f5f5f5; padding: 20px; border-radius: 12px; margin-bottom: 20px; }
+.preview-title { font-size: 18px; font-weight: 600; color: #333; display: block; margin-bottom: 10px; }
+.meta-item { font-size: 14px; color: #666; display: block; margin-top: 5px; }
+.generate-section { text-align: center; }
+.generate-btn { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; padding: 15px 40px; border-radius: 25px; font-size: 16px; }
+.generating-section { text-align: center; padding: 30px; }
+.generating-text { font-size: 16px; color: #333; display: block; margin-bottom: 20px; }
+.progress-bar-large { height: 10px; background: #e0e0e0; border-radius: 5px; overflow: hidden; margin-bottom: 10px; }
+.progress-fill-large { height: 100%; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); transition: width 0.3s; }
+.bottom-nav { display: flex; padding: 15px; gap: 10px; background: white; }
+.nav-btn { flex: 1; padding: 12px; border-radius: 8px; text-align: center; font-size: 14px; }
+.nav-btn.prev { background: #f5f5f5; color: #666; }
+.nav-btn.next { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
+</style>

+ 514 - 0
my-uniapp-vue3/src/pages/video-generator/index.vue

@@ -0,0 +1,514 @@
+<template>
+  <view class="page">
+    <!-- 顶部导航栏 -->
+    <view class="nav-bar">
+      <view class="nav-content">
+        <view class="nav-left" @click="goBack">
+          <text class="back-icon">←</text>
+        </view>
+        <text class="page-title">视频生成</text>
+        <view class="nav-right"></view>
+      </view>
+    </view>
+
+    <!-- 主内容区 -->
+    <view class="main-content">
+      <!-- 创建新视频按钮 -->
+      <view class="create-card" @click="goToCreate">
+        <text class="create-icon">🎬</text>
+        <text class="create-text">创建新视频</text>
+        <text class="create-hint">图片 + 音频 = 精美视频</text>
+      </view>
+
+      <!-- 视频项目列表 -->
+      <view v-if="projects.length > 0" class="project-list">
+        <view
+          v-for="project in projects"
+          :key="project.id"
+          class="project-card"
+          @click="openProject(project)"
+        >
+          <!-- 封面图 -->
+          <view class="card-cover">
+            <image
+              v-if="project.coverUrl"
+              :src="project.coverUrl"
+              mode="aspectFill"
+              class="cover-image"
+            />
+            <view v-else class="cover-placeholder">
+              <text class="placeholder-icon">🎥</text>
+            </view>
+
+            <!-- 状态标签 -->
+            <view :class="['status-badge', project.status]">
+              {{ getStatusText(project.status) }}
+            </view>
+
+            <!-- 进度条 -->
+            <view v-if="project.status === 'processing'" class="progress-overlay">
+              <view class="progress-bar">
+                <view class="progress-fill" :style="{ width: project.progress + '%' }"></view>
+              </view>
+              <text class="progress-text">{{ project.progress }}%</text>
+            </view>
+          </view>
+
+          <!-- 项目信息 -->
+          <view class="card-info">
+            <text class="project-title">{{ project.title }}</text>
+            <view class="project-meta">
+              <text v-if="project.duration" class="meta-item">
+                ⏱️ {{ formatDuration(project.duration) }}
+              </text>
+              <text v-if="project.fileSize" class="meta-item">
+                📦 {{ formatFileSize(project.fileSize) }}
+              </text>
+            </view>
+          </view>
+
+          <!-- 操作按钮 -->
+          <view class="card-actions">
+            <view
+              v-if="project.status === 'completed'"
+              class="action-btn primary"
+              @click.stop="previewVideo(project)"
+            >
+              预览
+            </view>
+            <view
+              v-if="project.status === 'failed'"
+              class="action-btn warning"
+              @click.stop="retryGenerate(project.id)"
+            >
+              重试
+            </view>
+            <view class="action-btn danger" @click.stop="deleteProject(project.id)">
+              删除
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 空状态 -->
+      <view v-else class="empty-state">
+        <text class="empty-icon">🎥</text>
+        <text class="empty-text">暂无视频项目</text>
+        <text class="empty-hint">点击上方按钮创建第一个视频</text>
+      </view>
+
+      <!-- 分页加载 -->
+      <view v-if="hasMore" class="load-more" @click="loadMore">
+        <text v-if="loading">加载中...</text>
+        <text v-else>加载更多</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { onShow } from '@dcloudio/uni-app';
+import {
+  getVideoProjects,
+  deleteVideoProject,
+  generateVideo,
+  type VideoProjectResponse,
+} from '@/utils/video-generator-api';
+
+const projects = ref<VideoProjectResponse[]>([]);
+const loading = ref(false);
+const page = ref(1);
+const pageSize = 10;
+const hasMore = ref(true);
+
+// 加载项目列表
+async function loadProjects(reset = false) {
+  if (loading.value) return;
+
+  if (reset) {
+    page.value = 1;
+    projects.value = [];
+    hasMore.value = true;
+  }
+
+  loading.value = true;
+
+  try {
+    const data = await getVideoProjects({ page: page.value, pageSize });
+    
+    if (!data || !data.items) {
+      console.error('数据格式错误:', data);
+      uni.showToast({ title: '数据格式错误', icon: 'none' });
+      return;
+    }
+
+    if (reset) {
+      projects.value = data.items;
+    } else {
+      projects.value.push(...data.items);
+    }
+
+    hasMore.value = data.items.length === pageSize;
+    page.value++;
+  } catch (error) {
+    console.error('加载项目列表失败:', error);
+    uni.showToast({ title: '加载失败', icon: 'none' });
+  } finally {
+    loading.value = false;
+  }
+}
+
+// 加载更多
+function loadMore() {
+  if (!hasMore.value || loading.value) return;
+  loadProjects();
+}
+
+// 获取状态文本
+function getStatusText(status: string): string {
+  const map: Record<string, string> = {
+    draft: '草稿',
+    processing: '生成中',
+    completed: '已完成',
+    failed: '失败',
+  };
+  return map[status] || status;
+}
+
+// 格式化时长
+function formatDuration(seconds: number): string {
+  const mins = Math.floor(seconds / 60);
+  const secs = seconds % 60;
+  return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+// 格式化文件大小
+function formatFileSize(bytes: number): string {
+  if (bytes < 1024) return bytes + 'B';
+  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
+  return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
+}
+
+// 返回上一页
+function goBack() {
+  uni.navigateBack();
+}
+
+// 跳转创建页
+function goToCreate() {
+  uni.navigateTo({ url: '/pages/video-generator/create' });
+}
+
+// 打开项目
+function openProject(project: VideoProjectResponse) {
+  if (project.status === 'draft') {
+    uni.navigateTo({ url: `/pages/video-generator/create?id=${project.id}` });
+  } else if (project.status === 'completed') {
+    previewVideo(project);
+  }
+}
+
+// 预览视频
+function previewVideo(project: VideoProjectResponse) {
+  if (!project.outputUrl) return;
+  uni.navigateTo({ url: `/pages/video-generator/preview?id=${project.id}` });
+}
+
+// 重试生成
+async function retryGenerate(projectId: number) {
+  try {
+    uni.showLoading({ title: '正在生成...' });
+    await generateVideo(projectId);
+    uni.hideLoading();
+    uni.showToast({ title: '开始生成', icon: 'success' });
+    loadProjects(true);
+  } catch (error) {
+    uni.hideLoading();
+    console.error('生成失败:', error);
+    uni.showToast({ title: '生成失败', icon: 'none' });
+  }
+}
+
+// 删除项目
+async function deleteProject(projectId: number) {
+  uni.showModal({
+    title: '确认删除',
+    content: '确定要删除这个视频项目吗?',
+    success: async (res) => {
+      if (res.confirm) {
+        try {
+          await deleteVideoProject(projectId);
+          uni.showToast({ title: '删除成功', icon: 'success' });
+          loadProjects(true);
+        } catch (error) {
+          console.error('删除失败:', error);
+          uni.showToast({ title: '删除失败', icon: 'none' });
+        }
+      }
+    },
+  });
+}
+
+// 页面显示时刷新
+onShow(() => {
+  loadProjects(true);
+});
+
+onMounted(() => {
+  loadProjects(true);
+});
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: #f5f5f5;
+}
+
+.nav-bar {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  padding: 20px 15px;
+  padding-top: max(20px, env(safe-area-inset-top));
+}
+
+.nav-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.nav-left,
+.nav-right {
+  width: 40px;
+}
+
+.back-icon {
+  font-size: 24px;
+  color: white;
+}
+
+.page-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: white;
+}
+
+.main-content {
+  padding: 15px;
+}
+
+/* 创建卡片 */
+.create-card {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border-radius: 16px;
+  padding: 30px;
+  margin-bottom: 20px;
+  text-align: center;
+  box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
+}
+
+.create-icon {
+  font-size: 48px;
+  display: block;
+  margin-bottom: 10px;
+}
+
+.create-text {
+  font-size: 20px;
+  font-weight: 600;
+  color: white;
+  display: block;
+  margin-bottom: 5px;
+}
+
+.create-hint {
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.8);
+}
+
+/* 项目列表 */
+.project-list {
+  display: flex;
+  flex-direction: column;
+  gap: 15px;
+}
+
+.project-card {
+  background: white;
+  border-radius: 12px;
+  overflow: hidden;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.card-cover {
+  position: relative;
+  height: 160px;
+  background: #f0f0f0;
+}
+
+.cover-image {
+  width: 100%;
+  height: 100%;
+}
+
+.cover-placeholder {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.placeholder-icon {
+  font-size: 48px;
+  opacity: 0.5;
+}
+
+/* 状态标签 */
+.status-badge {
+  position: absolute;
+  top: 10px;
+  right: 10px;
+  padding: 4px 12px;
+  border-radius: 20px;
+  font-size: 12px;
+  font-weight: 500;
+  color: white;
+}
+
+.status-badge.draft {
+  background: #909399;
+}
+
+.status-badge.processing {
+  background: #409eff;
+}
+
+.status-badge.completed {
+  background: #67c23a;
+}
+
+.status-badge.failed {
+  background: #f56c6c;
+}
+
+/* 进度覆盖层 */
+.progress-overlay {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: rgba(0, 0, 0, 0.6);
+  padding: 10px;
+}
+
+.progress-bar {
+  height: 6px;
+  background: rgba(255, 255, 255, 0.3);
+  border-radius: 3px;
+  overflow: hidden;
+}
+
+.progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #667eea, #764ba2);
+  transition: width 0.3s;
+}
+
+.progress-text {
+  color: white;
+  font-size: 12px;
+  text-align: center;
+  display: block;
+  margin-top: 5px;
+}
+
+/* 卡片信息 */
+.card-info {
+  padding: 15px;
+}
+
+.project-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #333;
+  display: block;
+  margin-bottom: 8px;
+}
+
+.project-meta {
+  display: flex;
+  gap: 15px;
+}
+
+.meta-item {
+  font-size: 13px;
+  color: #909399;
+}
+
+/* 操作按钮 */
+.card-actions {
+  display: flex;
+  padding: 0 15px 15px;
+  gap: 10px;
+}
+
+.action-btn {
+  flex: 1;
+  padding: 10px;
+  border-radius: 8px;
+  font-size: 14px;
+  text-align: center;
+  background: #f5f5f5;
+  color: #666;
+}
+
+.action-btn.primary {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+}
+
+.action-btn.warning {
+  background: #e6a23c;
+  color: white;
+}
+
+.action-btn.danger {
+  background: #f56c6c;
+  color: white;
+}
+
+/* 空状态 */
+.empty-state {
+  text-align: center;
+  padding: 60px 20px;
+}
+
+.empty-icon {
+  font-size: 64px;
+  display: block;
+  margin-bottom: 15px;
+}
+
+.empty-text {
+  font-size: 18px;
+  font-weight: 600;
+  color: #333;
+  display: block;
+  margin-bottom: 10px;
+}
+
+.empty-hint {
+  font-size: 14px;
+  color: #909399;
+}
+
+/* 加载更多 */
+.load-more {
+  text-align: center;
+  padding: 20px;
+  color: #409eff;
+  font-size: 14px;
+}
+</style>

+ 182 - 0
my-uniapp-vue3/src/pages/video-generator/preview.vue

@@ -0,0 +1,182 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-content">
+        <view class="nav-left" @click="goBack"><text class="back-icon">←</text></view>
+        <text class="page-title">视频预览</text>
+        <view class="nav-right"></view>
+      </view>
+    </view>
+
+    <view class="video-container" v-if="project?.outputUrl">
+      <video
+        :src="baseUrl + project.outputUrl"
+        class="video-player"
+        controls
+        autoplay
+        show-fullscreen-btn
+      ></video>
+    </view>
+
+    <view class="empty-state" v-else>
+      <text class="empty-icon">🎥</text>
+      <text class="empty-text">暂无视频</text>
+    </view>
+
+    <view class="action-buttons">
+      <button class="action-btn download" @click="downloadVideo" v-if="project?.outputUrl">
+        📥 下载视频
+      </button>
+      <button class="action-btn share" @click="shareVideo" v-if="project?.outputUrl">
+        📤 分享视频
+      </button>
+      <button class="action-btn regenerate" @click="regenerateVideo">
+        🔄 重新生成
+      </button>
+    </view>
+
+    <view class="video-info" v-if="project">
+      <text class="info-title">{{ project.title }}</text>
+      <view class="info-meta">
+        <text class="meta-item">时长: {{ formatDuration(project.duration) }}</text>
+        <text class="meta-item">大小: {{ formatFileSize(project.fileSize) }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { onLoad } from '@dcloudio/uni-app';
+import { getVideoProject, generateVideo, getGenerateProgress, type VideoProjectResponse } from '@/utils/video-generator-api';
+
+const baseUrl = 'http://localhost:3000';
+const project = ref<VideoProjectResponse | null>(null);
+
+function goBack() { uni.navigateBack(); }
+
+function formatDuration(seconds: number | null): string {
+  if (!seconds) return '--:--';
+  const mins = Math.floor(seconds / 60);
+  const secs = seconds % 60;
+  return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+function formatFileSize(bytes: number | null): string {
+  if (!bytes) return '--';
+  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
+  return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
+}
+
+async function loadProject(id: number) {
+  try {
+    project.value = await getVideoProject(id);
+  } catch (error) {
+    console.error('加载项目失败:', error);
+    uni.showToast({ title: '加载失败', icon: 'none' });
+  }
+}
+
+async function downloadVideo() {
+  if (!project.value?.outputUrl) return;
+  const url = baseUrl + project.value.outputUrl;
+
+  // #ifdef H5
+  // H5 平台:打开新窗口下载
+  window.open(url, '_blank');
+  // #endif
+
+  // #ifndef H5
+  // App 平台:下载并保存到相册
+  uni.downloadFile({
+    url: url,
+    success: (res) => {
+      if (res.statusCode === 200) {
+        uni.saveVideoToPhotosAlbum({
+          filePath: res.tempFilePath,
+          success: () => {
+            uni.showToast({ title: '保存成功', icon: 'success' });
+          },
+          fail: (err) => {
+            console.error('保存失败:', err);
+            uni.showToast({ title: '保存失败', icon: 'none' });
+          }
+        });
+      } else {
+        uni.showToast({ title: '下载失败', icon: 'none' });
+      }
+    },
+    fail: (err) => {
+      console.error('下载失败:', err);
+      uni.showToast({ title: '下载失败', icon: 'none' });
+    }
+  });
+  // #endif
+}
+
+async function shareVideo() {
+  if (!project.value?.outputUrl) return;
+  const url = baseUrl + project.value.outputUrl;
+  uni.share({
+    provider: 'weixin',
+    scene: 'WXSession',
+    title: project.value.title,
+    type: 5,
+    videoUrl: url,
+  });
+}
+
+async function regenerateVideo() {
+  if (!project.value?.id) return;
+  try {
+    uni.showLoading({ title: '正在重新生成...' });
+    await generateVideo(project.value.id);
+    const pollInterval = setInterval(async () => {
+      const data = await getGenerateProgress(project.value!.id);
+      if (data.status === 'completed') {
+        clearInterval(pollInterval);
+        uni.hideLoading();
+        uni.showToast({ title: '重新生成成功', icon: 'success' });
+        loadProject(project.value!.id);
+      } else if (data.status === 'failed') {
+        clearInterval(pollInterval);
+        uni.hideLoading();
+        uni.showToast({ title: '生成失败', icon: 'none' });
+      }
+    }, 2000);
+  } catch (error) {
+    uni.hideLoading();
+    console.error('重新生成失败:', error);
+    uni.showToast({ title: '重新生成失败', icon: 'none' });
+  }
+}
+
+onLoad((query: any) => {
+  if (query?.id) loadProject(Number(query.id));
+});
+
+onMounted(() => {});
+</script>
+
+<style scoped>
+.page { min-height: 100vh; background: #f5f5f5; }
+.nav-bar { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px 15px; }
+.nav-content { display: flex; align-items: center; justify-content: space-between; }
+.nav-left { width: 40px; }
+.back-icon { font-size: 24px; color: white; }
+.page-title { font-size: 18px; font-weight: 600; color: white; }
+.video-container { padding: 15px; }
+.video-player { width: 100%; border-radius: 12px; }
+.empty-state { padding: 60px 20px; text-align: center; }
+.empty-icon { font-size: 64px; display: block; margin-bottom: 15px; }
+.empty-text { font-size: 18px; color: #666; }
+.action-buttons { padding: 15px; display: flex; flex-direction: column; gap: 10px; }
+.action-btn { border: none; padding: 15px; border-radius: 8px; font-size: 16px; color: white; }
+.action-btn.download { background: #67c23a; }
+.action-btn.share { background: #409eff; }
+.action-btn.regenerate { background: #909399; }
+.video-info { padding: 20px 15px; }
+.info-title { font-size: 18px; font-weight: 600; color: #333; display: block; margin-bottom: 10px; }
+.info-meta { display: flex; gap: 20px; }
+.meta-item { font-size: 14px; color: #666; }
+</style>

+ 115 - 0
my-uniapp-vue3/src/types/video-generator.ts

@@ -0,0 +1,115 @@
+/**
+ * 视频生成模块 - 类型定义
+ */
+
+// ============ 核心类型 ============
+
+/** 视频项目状态 */
+export type VideoProjectStatus = 'draft' | 'processing' | 'completed' | 'failed';
+
+/** 素材类型 */
+export type MaterialType = 'image' | 'audio' | 'template';
+
+/** 转场效果 */
+export type TransitionEffect = 'none' | 'fade' | 'slide';
+
+/** 字幕位置 */
+export type SubtitlePosition = 'top' | 'bottom' | 'center';
+
+/** 图片配置项 */
+export interface ImageConfig {
+  url: string;
+  duration: number;
+  transition: TransitionEffect;
+  kenburns?: KenBurnsConfig;
+}
+
+/** Ken Burns 效果配置 */
+export interface KenBurnsConfig {
+  enabled: boolean;
+  minZoom: number;
+  maxZoom: number;
+  panDirection?: 'in' | 'out' | 'left' | 'right' | 'random';
+  zoomCurve?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
+}
+
+/** 音频配置 */
+export interface AudioConfig {
+  url: string;
+  startTime?: number;
+  endTime?: number;
+  volume: number;
+}
+
+/** 背景音乐配置 */
+export interface BgmConfig {
+  url: string;
+  volume: number;
+  loop: boolean;
+  fadeIn?: number;
+  fadeOut?: number;
+}
+
+/** 字幕配置 */
+export interface SubtitleConfig {
+  text: string;
+  fontSize?: number;
+  fontColor?: string;
+  backgroundColor?: string;
+  position: SubtitlePosition;
+  margin?: number;
+  style?: 'normal' | 'bold';
+}
+
+/** 视频参数 */
+export interface VideoParams {
+  width: number;
+  height: number;
+  fps: number;
+  bitrate: string;
+  format: string;
+}
+
+/** 完整视频配置 */
+export interface VideoConfig {
+  images: ImageConfig[];
+  audio: AudioConfig;
+  bgm?: BgmConfig;
+  subtitle?: SubtitleConfig;
+  video: VideoParams;
+  kenburns: KenBurnsConfig;
+}
+
+// ============ 预设配置 ============
+
+/** 预设视频配置 */
+export const PRESET_VIDEO_CONFIGS: Record<string, VideoConfig> = {
+  portrait: {
+    images: [],
+    audio: { url: '', volume: 1 },
+    video: { width: 720, height: 1280, fps: 30, bitrate: '2M', format: 'mp4' },
+    kenburns: { enabled: true, minZoom: 1.0, maxZoom: 1.2 },
+  },
+  landscape: {
+    images: [],
+    audio: { url: '', volume: 1 },
+    video: { width: 1920, height: 1080, fps: 30, bitrate: '4M', format: 'mp4' },
+    kenburns: { enabled: true, minZoom: 1.0, maxZoom: 1.15 },
+  },
+  square: {
+    images: [],
+    audio: { url: '', volume: 1 },
+    video: { width: 1080, height: 1080, fps: 30, bitrate: '2M', format: 'mp4' },
+    kenburns: { enabled: true, minZoom: 1.0, maxZoom: 1.25 },
+  },
+};
+
+/** 素材分类 */
+export const MATERIAL_CATEGORIES = [
+  { value: 'nature', label: '自然风景' },
+  { value: 'abstract', label: '抽象艺术' },
+  { value: 'business', label: '商务办公' },
+  { value: 'technology', label: '科技数码' },
+  { value: 'lifestyle', label: '生活方式' },
+  { value: 'music', label: '背景音乐' },
+];

+ 330 - 0
my-uniapp-vue3/src/utils/book-generator-api.ts

@@ -0,0 +1,330 @@
+/**
+ * 书籍生成模块 API
+ */
+
+import { request, get, post } from './request';
+
+// ============ 类型定义 ============
+
+export interface Book {
+  id: string;
+  title: string;
+  subtitle?: string;
+  description: string;
+  targetAudience: string;
+  style: string;
+  totalChapters: number;
+  estimatedWords: number;
+  status: 'draft' | 'planning' | 'generating' | 'completed' | 'failed';
+  progress: number;
+  chapters: Chapter[];
+  outline?: BookOutline;
+  metadata?: BookMetadata;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface BookOutline {
+  mainTheme: string;
+  structureLogic: string;
+  chapters: OutlineChapter[];
+}
+
+export interface OutlineChapter {
+  number: number;
+  title: string;
+  summary: string;
+  keyPoints: string[];
+  estimatedWords: number;
+  stories?: string[];
+}
+
+export interface Chapter {
+  id: string;
+  bookId: string;
+  number: number;
+  title: string;
+  content: string;
+  summary?: string;
+  wordCount: number;
+  status: 'pending' | 'generating' | 'completed' | 'failed';
+  audioUrl?: string;
+  audioDuration?: number;
+  videoUrl?: string;
+  videoDuration?: number;
+  error?: string;
+}
+
+export interface BookMetadata {
+  author?: string;
+  foreword?: string;
+  afterword?: string;
+}
+
+export interface CreateBookRequest {
+  title: string;
+  subtitle?: string;
+  description: string;
+  targetAudience?: string;
+  style?: string;
+  bookScale?: string;
+  language?: string;
+}
+
+export interface ProgressResponse {
+  bookId: string;
+  status: string;
+  progress: number;
+  completedChapters: number;
+  totalChapters: number;
+  currentTask?: {
+    type: string;
+    message: string;
+  };
+}
+
+// ============ API 函数 ============
+
+const BASE_URL = '/book-generator';
+
+/**
+ * 创建书籍
+ */
+export async function createBook(data: CreateBookRequest): Promise<Book> {
+  const result = await request<{ book: Book }>(`${BASE_URL}/books`, { method: 'POST', data: { ...data } });
+  return result.book;
+}
+
+/**
+ * 获取所有书籍
+ */
+export async function getBooks(): Promise<Book[]> {
+  const result = await request<{ books: Book[] }>(`${BASE_URL}/books`);
+  return result.books || [];
+}
+
+/**
+ * 获取书籍详情
+ */
+export async function getBook(id: string): Promise<Book> {
+  const result = await request<{ book: Book }>(`${BASE_URL}/books/${id}`);
+  return result.book;
+}
+
+/**
+ * 删除书籍
+ */
+export async function deleteBook(id: string): Promise<void> {
+  await request(`${BASE_URL}/books/${id}`, { method: 'DELETE' });
+}
+
+/**
+ * 生成大纲
+ */
+export async function generateOutline(id: string): Promise<BookOutline> {
+  const result = await request<{ outline: BookOutline }>(`${BASE_URL}/books/${id}/outline`, { method: 'POST' });
+  return result.outline;
+}
+
+/**
+ * 生成单个章节
+ */
+export async function generateChapter(id: string, chapterNumber: number): Promise<Chapter> {
+  const result = await request<{ chapter: Chapter }>(`${BASE_URL}/books/${id}/chapters`, {
+    method: 'POST',
+    data: { chapterNumber },
+  });
+  return result.chapter;
+}
+
+/**
+ * 生成全部章节
+ */
+export async function generateAllChapters(id: string): Promise<Chapter[]> {
+  const result = await request<{ chapters: Chapter[] }>(`${BASE_URL}/books/${id}/chapters`, { method: 'POST' });
+  return result.chapters || [];
+}
+
+/**
+ * 生成前言
+ */
+export async function generateForeword(id: string): Promise<string> {
+  const result = await request<{ foreword: string }>(`${BASE_URL}/books/${id}/foreword`, { method: 'POST' });
+  return result.foreword;
+}
+
+/**
+ * 生成后记
+ */
+export async function generateAfterword(id: string): Promise<string> {
+  const result = await request<{ afterword: string }>(`${BASE_URL}/books/${id}/afterword`, { method: 'POST' });
+  return result.afterword;
+}
+
+/**
+ * 获取完整书籍内容
+ */
+export async function getFullContent(id: string): Promise<string> {
+  const result = await request<{ content: string }>(`${BASE_URL}/books/${id}/full-content`);
+  return result.content;
+}
+
+/**
+ * 获取生成进度
+ */
+export async function getProgress(id: string): Promise<ProgressResponse> {
+  const result = await request<ProgressResponse>(`${BASE_URL}/books/${id}/progress`);
+  return result;
+}
+
+/**
+ * 一键生成整本书
+ */
+export async function generateBook(
+  id: string,
+  options?: {
+    generateForeword?: boolean;
+    generateAfterword?: boolean;
+  }
+): Promise<{
+  success: boolean;
+  completedChapters: number;
+  failedChapters: number;
+  foreword?: string;
+  afterword?: string;
+  errors: string[];
+}> {
+  const result = await request<{
+    success: boolean;
+    completedChapters: number;
+    failedChapters: number;
+    foreword?: string;
+    afterword?: string;
+    errors: string[];
+  }>(`${BASE_URL}/books/${id}/generate`, {
+    method: 'POST',
+    data: options || {},
+  });
+  return result;
+}
+
+/**
+ * 工作流状态
+ */
+export interface WorkflowProgress {
+  phase: 'planning' | 'writing' | 'supplement' | 'done';
+  currentNode: string;
+  currentChapter: number;
+  completedChapters: number;
+  totalChapters: number;
+  pendingChapters: number[];
+  failedChapters: number[];
+  progress: number;
+}
+
+/**
+ * 获取工作流状态
+ */
+export async function getWorkflowProgress(bookId: string): Promise<WorkflowProgress | null> {
+  const result = await request<WorkflowProgress>(`${BASE_URL}/workflow/${bookId}`);
+  return result;
+}
+
+// ============ LangGraph API ============
+
+const LANGGRAPH_BASE = `${BASE_URL}/langgraph`;
+
+/**
+ * 使用 LangGraph 创建并生成书籍
+ */
+export async function createBookWithLangGraph(data: {
+  title: string;
+  description: string;
+  totalChapters?: number;
+}): Promise<{ bookId: string; taskId: string }> {
+  const result = await request<{ bookId: string; taskId: string }>(`${LANGGRAPH_BASE}/books`, {
+    method: 'POST',
+    data,
+  });
+  return result;
+}
+
+/**
+ * 使用 LangGraph 生成已有书籍
+ */
+export async function generateWithLangGraph(bookId: string): Promise<{ bookId: string; taskId: string }> {
+  const result = await request<{ bookId: string; taskId: string }>(`${LANGGRAPH_BASE}/books/${bookId}/generate`, {
+    method: 'POST',
+  });
+  return result;
+}
+
+// ============ 音频生成 API ============
+
+/**
+ * 生成单个章节音频
+ */
+export async function generateChapterAudio(
+  bookId: string,
+  chapterNumber: number,
+  voiceId: string = 'cherry'
+): Promise<{ taskId: string; chapterId: string }> {
+  const result = await request<{ taskId: string; chapterId: string }>(
+    `${BASE_URL}/books/${bookId}/chapters/${chapterNumber}/audio`,
+    {
+      method: 'POST',
+      data: { voiceId },
+    }
+  );
+  return result;
+}
+
+/**
+ * 批量生成书籍所有章节音频
+ */
+export async function generateAllChaptersAudio(
+  bookId: string,
+  voiceId: string = 'cherry'
+): Promise<{ taskId: string; totalChapters: number }> {
+  const result = await request<{ taskId: string; totalChapters: number }>(
+    `${BASE_URL}/books/${bookId}/audio`,
+    {
+      method: 'POST',
+      data: { voiceId },
+    }
+  );
+  return result;
+}
+
+// ============ 视频生成 API ============
+
+/**
+ * 生成单个章节视频(从音频转视频)
+ */
+export async function generateChapterVideo(
+  bookId: string,
+  chapterNumber: number
+): Promise<{ projectId: number; chapterId: string }> {
+  const result = await request<{ projectId: number; chapterId: string }>(
+    `${BASE_URL}/books/${bookId}/chapters/${chapterNumber}/video`,
+    {
+      method: 'POST',
+    }
+  );
+  return result;
+}
+
+/**
+ * 批量生成书籍所有章节视频
+ */
+export async function generateAllChaptersVideo(
+  bookId: string
+): Promise<{ taskId: string; totalChapters: number }> {
+  const result = await request<{ taskId: string; totalChapters: number }>(
+    `${BASE_URL}/books/${bookId}/videos`,
+    {
+      method: 'POST',
+    }
+  );
+  return result;
+}

+ 64 - 0
my-uniapp-vue3/src/utils/config.ts

@@ -0,0 +1,64 @@
+/**
+ * API 配置工具
+ * 根据运行环境自动选择正确的 API 地址
+ */
+
+// 开发环境配置
+const DEV_CONFIG = {
+  // 开发模式(PC浏览器):使用 localhost
+  web: 'http://localhost:3000/api',
+  // 安卓模拟器:使用电脑的局域网 IP
+  // 可以通过 ipconfig (Windows) 或 ifconfig (Mac/Linux) 查看
+  android: 'http://192.168.1.100:3000/api', // ⚠️ 请修改为你的电脑局域网IP
+  // iOS 模拟器
+  ios: 'http://192.168.1.100:3000/api', // ⚠️ 请修改为你的电脑局域网IP
+  // H5 - 生产环境使用相对路径(通过 Nginx 反向代理)
+  h5: '/api',
+};
+
+// 判断运行环境
+function getPlatform(): 'web' | 'android' | 'ios' | 'h5' {
+  // #ifdef H5
+  return 'h5';
+  // #endif
+
+  // #ifdef APP-PLUS
+  // @ts-ignore
+  const systemInfo = uni.getSystemInfoSync();
+  if (systemInfo.platform === 'android') return 'android';
+  if (systemInfo.platform === 'ios') return 'ios';
+  return 'android';
+  // #endif
+
+  return 'web';
+}
+
+// 获取当前 API 地址
+export function getApiBaseUrl(): string {
+  const platform = getPlatform();
+
+  // #ifdef H5
+  // H5 环境判断是否在微信小程序模拟器中运行
+  // @ts-ignore
+  if (typeof window !== 'undefined' && window.__uniConfig && window.__uniConfig.platform === 'mp-weixin') {
+    return DEV_CONFIG.web;
+  }
+  return DEV_CONFIG.h5;
+  // #endif
+
+  // @ts-ignore
+  return DEV_CONFIG[platform];
+}
+
+// 获取后端服务器地址(不含 /api)
+export function getServerBaseUrl(): string {
+  const apiUrl = getApiBaseUrl();
+  return apiUrl.replace('/api', '');
+}
+
+// 获取局域网 IP 地址
+export function getLocalIP(): string {
+  // 可以通过执行系统命令获取,或者硬编码配置
+  // 这里返回空字符串,用户需要在 DEV_CONFIG 中配置
+  return '';
+}

+ 22 - 20
my-uniapp-vue3/src/utils/request.ts

@@ -1,12 +1,13 @@
-// API 基础配置 - 开发环境直接使用后端 3000 端口
-// @ts-ignore
-const isDevEnv = typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.DEV;
-const BASE_URL = isDevEnv
-  ? 'http://localhost:3000/api'
-  : (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_API_BASE_URL) || '/api';
-
-// 开发环境使用本地 API,生产环境使用线上 API
-const isDev = typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'development';
+// API 基础配置
+// #ifdef H5
+// H5 环境判断:本地开发用 localhost:3000,生产环境用相对路径 /api(Nginx 代理)
+const isLocalhost = typeof window !== 'undefined' && window.location.hostname === 'localhost';
+const BASE_URL = isLocalhost ? 'http://localhost:3000/api' : '/api';
+// #endif
+// #ifndef H5
+// 非 H5 环境用相对路径
+const BASE_URL = '/api';
+// #endif
 
 // 获取完整的后端 URL(用于音频文件等静态资源)
 export function getFullUrl(path: string): string {
@@ -14,12 +15,10 @@ export function getFullUrl(path: string): string {
   if (path.startsWith('http://') || path.startsWith('https://')) {
     return path;
   }
-  // 开发环境使用完整的后端 URL
-  if (isDevEnv) {
-    return 'http://localhost:3000' + path;
-  }
-  // 生产环境使用相对路径
-  return path;
+  // 本地开发用 localhost:300,生产环境用相对路径
+  const isLocalhost = typeof window !== 'undefined' && window.location.hostname === 'localhost';
+  const baseUrl = isLocalhost ? 'http://localhost:3000' : '';
+  return baseUrl + path;
 }
 
 // 请求封装
@@ -56,9 +55,12 @@ export async function request<T = unknown>(
           uni.hideLoading();
         }
 
-        const result = res.data as { code: number; message: string; data?: T };
+        const result = res.data as { code?: number; message?: string; data?: T; success?: boolean };
 
-        if (result.code === 0) {
+        // 处理两种响应格式
+        // 格式1: { success: true, data: {...} } - 视频生成模块使用
+        // 格式2: { code: 0, data: {...} } - 其他模块使用
+        if (result.success === true || result.code === 0) {
           resolve(result.data as T);
         } else if (result.code === 401) {
           // token 过期,跳转登录
@@ -74,10 +76,10 @@ export async function request<T = unknown>(
         } else if (result.code >= 500) {
           // 服务器错误
           uni.showToast({ title: result.message || '服务器错误', icon: 'none' });
-          reject(new Error(result.message));
+          reject(new Error(result.message || '服务器错误'));
         } else {
-          uni.showToast({ title: result.message, icon: 'none' });
-          reject(new Error(result.message));
+          uni.showToast({ title: result.message || '请求失败', icon: 'none' });
+          reject(new Error(result.message || '请求失败'));
         }
       },
       fail: (err) => {

+ 187 - 0
my-uniapp-vue3/src/utils/video-generator-api.ts

@@ -0,0 +1,187 @@
+/**
+ * 视频生成模块 - 前端 API 调用封装
+ */
+
+import { request, getFullUrl } from './request';
+import type { VideoConfig, VideoProjectStatus } from './types/video-generator';
+
+// ============ 类型定义 ============
+
+/** 创建视频项目请求 */
+export interface CreateVideoProjectRequest {
+  title: string;
+  description?: string;
+  coverUrl?: string;
+  config?: VideoConfig;
+  bookId?: number;
+  audioId?: number;
+}
+
+/** 更新视频项目请求 */
+export interface UpdateVideoProjectRequest {
+  title?: string;
+  description?: string;
+  coverUrl?: string;
+  config?: VideoConfig;
+}
+
+/** 视频项目响应 */
+export interface VideoProjectResponse {
+  id: number;
+  userId: number | null;
+  title: string;
+  description: string | null;
+  coverUrl: string | null;
+  config?: VideoConfig;
+  outputUrl: string | null;
+  duration: number | null;
+  fileSize: number | null;
+  bookId: number | null;
+  audioId: number | null;
+  status: VideoProjectStatus;
+  progress: number;
+  errorMsg: string | null;
+  createdAt: string;
+  updatedAt: string;
+}
+
+/** 素材响应 */
+export interface VideoMaterialResponse {
+  id: number;
+  userId: number | null;
+  type: 'image' | 'audio' | 'template';
+  name: string;
+  url: string;
+  thumbnail: string | null;
+  tags: string[];
+  category: string | null;
+  duration: number | null;
+  size: number | null;
+  width: number | null;
+  height: number | null;
+  createdAt: string;
+  updatedAt: string;
+}
+
+/** 生成进度响应 */
+export interface GenerateProgressResponse {
+  status: VideoProjectStatus;
+  progress: number;
+  outputUrl?: string;
+  duration?: number;
+  fileSize?: number;
+  errorMsg?: string;
+}
+
+// ============ API 函数 ============
+
+/**
+ * 获取视频项目列表
+ */
+export function getVideoProjects(params?: {
+  userId?: number;
+  status?: VideoProjectStatus;
+  page?: number;
+  pageSize?: number;
+}) {
+  return request<{
+    items: VideoProjectResponse[];
+    total: number;
+    page: number;
+    pageSize: number;
+  }>('/video/projects', { method: 'GET', data: params });
+}
+
+/**
+ * 创建视频项目
+ */
+export function createVideoProject(data: CreateVideoProjectRequest) {
+  return request<VideoProjectResponse>('/video/projects', { method: 'POST', data });
+}
+
+/**
+ * 获取视频项目详情
+ */
+export function getVideoProject(id: number) {
+  return request<VideoProjectResponse>(`/video/projects/${id}`, { method: 'GET' });
+}
+
+/**
+ * 更新视频项目
+ */
+export function updateVideoProject(id: number, data: UpdateVideoProjectRequest) {
+  return request<VideoProjectResponse>(`/video/projects/${id}`, { method: 'PUT', data });
+}
+
+/**
+ * 删除视频项目
+ */
+export function deleteVideoProject(id: number) {
+  return request<void>(`/video/projects/${id}`, { method: 'DELETE' });
+}
+
+/**
+ * 开始生成视频
+ */
+export function generateVideo(id: number) {
+  return request<{
+    outputUrl: string;
+    duration: number;
+    fileSize: number;
+  }>(`/video/projects/${id}/generate`, { method: 'POST' });
+}
+
+/**
+ * 获取生成进度
+ */
+export function getGenerateProgress(id: number) {
+  return request<GenerateProgressResponse>(`/video/projects/${id}/status`, { method: 'GET' });
+}
+
+/**
+ * 获取素材列表
+ */
+export function getMaterials(params?: {
+  userId?: number;
+  type?: 'image' | 'audio' | 'template';
+  category?: string;
+  page?: number;
+  pageSize?: number;
+}) {
+  return request<{
+    items: VideoMaterialResponse[];
+    total: number;
+  }>('/video/materials', { method: 'GET', data: params });
+}
+
+/**
+ * 上传素材
+ */
+export function uploadMaterial(data: {
+  type: 'image' | 'audio' | 'template';
+  name: string;
+  url: string;
+  thumbnail?: string;
+  tags?: string[];
+  category?: string;
+  duration?: number;
+  size?: number;
+  width?: number;
+  height?: number;
+}) {
+  return request<VideoMaterialResponse>('/video/materials/upload', { method: 'POST', data });
+}
+
+/**
+ * 删除素材
+ */
+export function deleteMaterial(id: number) {
+  return request<void>(`/video/materials/${id}`, { method: 'DELETE' });
+}
+
+/**
+ * 从书籍生成视频项目
+ */
+export function createVideoProjectFromBook(bookId: number) {
+  return request<VideoProjectResponse>(`/video/books/${bookId}/generate`, { method: 'POST' });
+}

+ 6 - 0
server/package.json

@@ -12,6 +12,9 @@
     "@koa/bodyparser": "^6.1.0",
     "@koa/cors": "^5.0.0",
     "@koa/router": "^12.0.1",
+    "@langchain/community": "^0.0.56",
+    "@langchain/core": "^1.1.39",
+    "@langchain/langgraph": "^1.2.8",
     "@prisma/client": "^6.19.3",
     "@types/ws": "^8.18.1",
     "axios": "^1.7.2",
@@ -21,9 +24,12 @@
     "is-generator-function": "^1.1.2",
     "jsonwebtoken": "^9.0.2",
     "koa": "^3.1.2",
+    "koa-body": "^7.0.1",
     "koa-mount": "^4.2.0",
     "koa-static": "^5.0.0",
+    "langchain": "^0.1.36",
     "mysql2": "^3.20.0",
+    "openai": "^6.34.0",
     "prisma": "^6.19.3",
     "uuid": "^9.0.1",
     "ws": "^8.20.0"

+ 160 - 107
server/prisma/schema.prisma

@@ -20,53 +20,17 @@ model User {
   createdAt       DateTime  @default(now())
   updatedAt       DateTime  @updatedAt
 
-  audios          Audio[]
   orders          Order[]
   playRecords     PlayRecord[]
   preferences     UserPreference?
   favorites       Favorite[]
   comments        Comment[]
-  albumSubscriptions AlbumSubscription[]
 
   @@index([phone])
   @@index([openid])
 }
 
-model Audio {
-  id              Int       @id @default(autoincrement())
-  userId          Int?
-  title           String
-  text            String    @db.Text
-  summary         String?   @db.Text
-  tags            String?   @db.Text // JSON 字符串存储数组
-  audioUrl        String    @db.Text
-  audioDuration   Int       @default(0)
-  audioSize       Int       @default(0)
-  wordCount       Int
-  voiceId         String
-  voiceParams     String?   @db.Text // JSON 存储 {speed, pitch, volume}
-  status          String    @default("processing")
-  isFavorite      Boolean   @default(false)
-  categoryId      Int?
-  albumId         Int?      // 专辑ID,没有则归入默认专辑
-  createdAt       DateTime  @default(now())
-  updatedAt       DateTime  @updatedAt
-
-  user            User?     @relation(fields: [userId], references: [id])
-  category        Category? @relation("CategoryAudios", fields: [categoryId], references: [id])
-  album           Album?    @relation(fields: [albumId], references: [id])
-
-  playRecords     PlayRecord[]
-  favorites       Favorite[]
-  comments        Comment[]
-  albumAudios     AlbumAudio[]
-
-  @@index([userId, createdAt])
-  @@index([userId, isFavorite])
-  @@index([categoryId])
-  @@index([albumId])
-}
-
+// 订单
 model Order {
   id              Int       @id @default(autoincrement())
   userId          Int
@@ -78,30 +42,32 @@ model Order {
   paidAt          DateTime?
   createdAt       DateTime  @default(now())
   updatedAt       DateTime  @updatedAt
-  
+
   user            User      @relation(fields: [userId], references: [id])
-  
+
   @@index([userId, createdAt])
   @@index([orderNo])
 }
 
+// 播放记录(播放的是章节的音频)
 model PlayRecord {
   id        Int       @id @default(autoincrement())
   userId    Int
-  audioId   Int
+  chapterId Int       // BookChapter.id(章节ID)
   progress  Float     @default(0)  // 播放进度(秒)
   duration  Float     @default(0)  // 总时长
   updatedAt DateTime  @updatedAt
   createdAt DateTime  @default(now())
-  
+
   user      User      @relation(fields: [userId], references: [id])
-  audio     Audio    @relation(fields: [audioId], references: [id])
-  
-  @@unique([userId, audioId])
+  chapter   BookChapter @relation(fields: [chapterId], references: [id])
+
+  @@unique([userId, chapterId])
   @@index([userId])
-  @@index([audioId])
+  @@index([chapterId])
 }
 
+// 用户偏好
 model UserPreference {
   id        Int      @id @default(autoincrement())
   userId    Int      @unique
@@ -110,48 +76,51 @@ model UserPreference {
   theme     String   @default("light")
   updatedAt DateTime @updatedAt
   createdAt DateTime @default(now())
-  
+
   user      User     @relation(fields: [userId], references: [id])
 }
 
+// 收藏(收藏的是书籍/专辑)
 model Favorite {
   id        Int      @id @default(autoincrement())
   userId    Int
-  audioId   Int
+  bookId    Int      // Book.id(收藏的是整本书籍)
   createdAt DateTime @default(now())
-  
+
   user      User     @relation(fields: [userId], references: [id])
-  audio     Audio   @relation(fields: [audioId], references: [id])
-  
-  @@unique([userId, audioId])
+  book      Book     @relation(fields: [bookId], references: [id], onDelete: Cascade)
+
+  @@unique([userId, bookId])
   @@index([userId])
+  @@index([bookId])
 }
 
+// 分类
 model Category {
   id        Int      @id @default(autoincrement())
   name      String
   icon      String   @default("")
   sort      Int      @default(0)
   createdAt DateTime @default(now())
-  
-  audios    Audio[]  @relation("CategoryAudios")
 }
 
+// 评论(评论的是章节)
 model Comment {
-  id        Int      @id @default(autoincrement())
+  id        Int       @id @default(autoincrement())
   userId    Int
-  audioId   Int
-  content   String   @db.Text
-  rating    Int      // 1-5星
-  createdAt DateTime @default(now())
+  chapterId Int       // BookChapter.id
+  content   String    @db.Text
+  rating    Int       // 1-5星
+  createdAt DateTime  @default(now())
 
-  user      User     @relation(fields: [userId], references: [id])
-  audio     Audio   @relation(fields: [audioId], references: [id])
+  user      User      @relation(fields: [userId], references: [id])
+  chapter   BookChapter @relation(fields: [chapterId], references: [id])
 
-  @@index([audioId])
+  @@index([chapterId])
   @@index([userId])
 }
 
+// 系统通知
 model Notification {
   id        String   @id @default(uuid())
   userId    String
@@ -161,52 +130,7 @@ model Notification {
   createdAt DateTime @default(now())
 }
 
-model Album {
-  id          Int       @id @default(autoincrement())
-  name        String
-  description String?   @db.Text
-  coverUrl    String?
-  userId      Int?
-  isDefault   Boolean   @default(false) // 是否是默认专辑
-  createdAt   DateTime  @default(now())
-  updatedAt   DateTime  @updatedAt
-
-  albumAudios AlbumAudio[]
-  subscriptions AlbumSubscription[]
-  audios       Audio[]
-
-  @@index([userId])
-  @@index([createdAt])
-}
-
-model AlbumAudio {
-  id        Int      @id @default(autoincrement())
-  albumId   Int
-  audioId   Int
-  order     Int      @default(0)
-  addedAt   DateTime @default(now())
-
-  album     Album    @relation(fields: [albumId], references: [id], onDelete: Cascade)
-  audio     Audio    @relation(fields: [audioId], references: [id], onDelete: Cascade)
-
-  @@unique([albumId, audioId])
-  @@index([albumId])
-  @@index([audioId])
-}
-
-model AlbumSubscription {
-  id        Int      @id @default(autoincrement())
-  userId    Int
-  albumId   Int
-  createdAt DateTime @default(now())
-
-  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
-  album     Album    @relation(fields: [albumId], references: [id], onDelete: Cascade)
-
-  @@unique([userId, albumId])
-  @@index([userId])
-  @@index([albumId])
-}
+// ============ 模板管理 ============
 
 model Template {
   id          Int      @id @default(autoincrement())
@@ -220,6 +144,78 @@ model Template {
   @@index([category])
 }
 
+// ============ 书籍/章节(核心内容) ============
+
+// 书籍(专辑)
+model Book {
+  id              Int       @id @default(autoincrement())
+  userId          Int?
+  title           String    // 书名
+  subtitle        String?   // 副标题
+  description     String    @db.Text // 书籍描述/用户输入
+  coverUrl        String?   // 封面图
+  targetAudience  String    @default("通用") // 目标受众
+  style           String    @default("专业严谨") // 写作风格
+  totalChapters   Int       @default(10) // 总章节数
+  estimatedWords  Int       @default(0) // 预估总字数
+  status          String    @default("draft") // draft, planning, generating, completed, failed
+  progress        Int       @default(0) // 生成进度 0-100
+  isPublished     Boolean   @default(false) // 是否已发布
+
+  // 大纲(JSON 存储)
+  outlineJson      String?   @db.LongText // BookOutline JSON
+
+  // 前言/后记
+  foreword        String?   @db.Text
+  afterword       String?   @db.Text
+
+  // 错误信息
+  errorMsg        String?   @db.Text
+
+  createdAt       DateTime  @default(now())
+  updatedAt       DateTime  @updatedAt
+
+  chapters        BookChapter[]
+  videoProjects   VideoProject[] // 关联的视频项目
+  favorites       Favorite[]     // 收藏此书籍的用户
+
+  @@index([userId, status])
+  @@index([createdAt])
+}
+
+// 书籍章节(内容单元)
+// 一个章节 = 一份内容,有3种形式:content(文本)、audioUrl(音频)、videoUrl(视频)
+model BookChapter {
+  id              Int       @id @default(autoincrement())
+  bookId          Int
+  number          Int       // 章节序号
+  title           String    // 章节标题
+  summary         String?   @db.Text // 章节概述
+  keyPoints       String?   @db.Text // 核心知识点(JSON数组)
+  estimatedWords  Int       @default(1000) // 预估字数
+  content         String?   @db.LongText // 正文内容(原始文本)
+  wordCount       Int       @default(0) // 实际字数
+  status          String    @default("pending") // pending, generating, completed, failed
+  errorMsg        String?   @db.Text
+  generatedAt     DateTime?
+
+  // 3种表现形式
+  audioUrl        String?   @db.Text // 音频URL(由 content 生成)
+  audioDuration   Int       @default(0) // 音频时长(秒)
+  videoUrl        String?   @db.Text // 视频URL(由 content/audio 生成)
+  videoDuration   Int?      // 视频时长(秒)
+
+  book            Book      @relation(fields: [bookId], references: [id], onDelete: Cascade)
+  videoProjects   VideoProject[]
+  playRecords     PlayRecord[]
+  comments        Comment[]
+
+  @@unique([bookId, number])
+  @@index([bookId])
+}
+
+// ============ 学习路径模块 ============
+
 // 学习路径任务
 model LearningPath {
   id          Int       @id @default(autoincrement())
@@ -317,3 +313,60 @@ model ContentBlock {
 
   @@index([sectionId, orderIndex])
 }
+
+// ============ 视频生成模块 ============
+
+// 视频项目(关联到书籍的某个章节)
+model VideoProject {
+  id          Int       @id @default(autoincrement())
+  userId      Int?
+  title       String    // 项目标题
+  description String?   // 描述
+  coverUrl    String?   // 封面图
+
+  // 素材配置(JSON存储)
+  configJson  String?   @db.LongText // VideoConfig JSON
+
+  // 输出视频
+  outputUrl   String?   // 生成后的视频URL
+  duration    Int?      // 视频时长(秒)
+  fileSize    Int?      // 文件大小(字节)
+
+  // 关联:直接关联到章节
+  bookId      Int?      // 关联的书籍ID(可选)
+  chapterId   Int?      // 关联的章节ID(直接关联章节获取 content/audioUrl)
+
+  status      String    @default("draft") // draft, processing, completed, failed
+  progress    Int       @default(0) // 0-100
+  errorMsg    String?   @db.Text
+
+  createdAt   DateTime  @default(now())
+  updatedAt   DateTime  @updatedAt
+
+  book        Book?     @relation(fields: [bookId], references: [id])
+  chapter     BookChapter? @relation(fields: [chapterId], references: [id])
+
+  @@index([userId, status])
+  @@index([createdAt])
+}
+
+// 视频素材库(保留作为独立素材)
+model VideoMaterial {
+  id          Int       @id @default(autoincrement())
+  userId      Int?      // null 表示公共素材
+  type        String    // image, audio, template
+  name        String    // 素材名称
+  url         String    @db.Text // 素材URL
+  thumbnail   String?   // 缩略图URL
+  tags        String?   @db.Text // 标签(JSON数组)
+  category    String?   // 分类:nature, abstract, business, music等
+  duration    Int?      // 音频时长(秒)
+  size        Int?      // 文件大小(字节)
+  width       Int?      // 图片宽度
+  height      Int?      // 图片高度
+  createdAt   DateTime  @default(now())
+  updatedAt   DateTime  @updatedAt
+
+  @@index([userId, type])
+  @@index([type, category])
+}

+ 18 - 5
server/src/app.ts

@@ -2,6 +2,7 @@ import Koa from 'koa';
 import cors from '@koa/cors';
 import Router from '@koa/router';
 import bodyParser from '@koa/bodyparser';
+import koaBody from 'koa-body';
 import serve from 'koa-static';
 import mount from 'koa-mount';
 import path from 'path';
@@ -10,7 +11,6 @@ import { connectDatabase } from './models';
 import { errorHandler } from './middleware/errorHandler';
 import authRoutes from './modules/auth/auth.controller';
 import ttsRoutes from './modules/tts/tts.controller';
-import audioRoutes from './modules/audio/audio.controller';
 import memberRoutes from './modules/member/member.controller';
 import shareRoutes from './modules/share/share.controller';
 import aiRoutes from './modules/ai/ai.controller';
@@ -22,10 +22,12 @@ import searchRoutes from './modules/search/search.controller';
 import categoriesRoutes from './modules/categories/categories.controller';
 import commentsRoutes from './modules/comments/comments.controller';
 import notificationsRoutes from './modules/notifications/notifications.controller';
-import albumsRoutes from './modules/albums/albums.controller';
 import templatesRoutes from './modules/templates/templates.controller';
 import bgmRoutes from './modules/bgm/bgm.controller';
 import audioEditRoutes from './modules/audioedit/audioedit.controller';
+import bookGeneratorRoutes from './modules/book-generator/book-generator.controller';
+import langGraphRoutes from './modules/book-generator/langgraph/controller';
+import videoGeneratorRoutes from './modules/video-generator/video-generator.controller';
 
 const app = new Koa();
 const router = new Router();
@@ -37,11 +39,21 @@ app.use(cors({
   allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
   allowHeaders: ['Content-Type', 'Authorization'],
 }));
-app.use(bodyParser());
+app.use(koaBody({
+  multipart: true,
+  formidable: {
+    uploadDir: path.join(process.cwd(), 'uploads', 'materials'),
+    keepExtensions: true,
+    maxFileSize: 100 * 1024 * 1024, // 100MB
+  },
+}));
 
 // 静态文件服务 - 音频文件
 app.use(mount('/uploads', serve(config.upload.dir)));
 
+// 静态文件服务 - 视频文件
+app.use(mount('/videos', serve(path.join(process.cwd(), 'public', 'videos'))));
+
 // 健康检查
 router.get('/health', (ctx) => {
   ctx.body = { code: 0, message: 'ok', data: { status: 'healthy' } };
@@ -50,7 +62,6 @@ router.get('/health', (ctx) => {
 // 注册路由
 router.use('/api/auth', authRoutes.routes());
 router.use('/api/tts', ttsRoutes.routes());
-router.use('/api/audio', audioRoutes.routes());
 router.use('/api/member', memberRoutes.routes());
 router.use('/api/share', shareRoutes.routes());
 router.use('/api/ai', aiRoutes.routes());
@@ -62,10 +73,12 @@ router.use('/api/search', searchRoutes.routes());
 router.use('/api/categories', categoriesRoutes.routes());
 router.use('/api/comments', commentsRoutes.routes());
 router.use('/api/notifications', notificationsRoutes.routes());
-router.use('/api/albums', albumsRoutes.routes());
 router.use('/api/templates', templatesRoutes.routes());
 router.use('/api/bgm', bgmRoutes.routes());
 router.use('/api/audio', audioEditRoutes.routes());
+router.use('/api/book-generator', bookGeneratorRoutes.routes());
+router.use('/api/book-generator/langgraph', langGraphRoutes.routes());
+router.use('/api/video', videoGeneratorRoutes.routes());
 
 app.use(router.routes()).use(router.allowedMethods());
 

+ 177 - 0
server/src/config/models-validator.ts

@@ -0,0 +1,177 @@
+/**
+ * 模型验证工具 - 批量验证所有模型是否可用
+ */
+
+import { ChatOpenAI } from '@langchain/openai';
+import { config } from './index';
+import fs from 'fs';
+import path from 'path';
+
+interface ModelValidationResult {
+  id: string;
+  name: string;
+  input: string[];
+  vendor: string;
+  available: boolean;
+  error?: string;
+  responseTime?: number; // 毫秒
+}
+
+interface ValidationReport {
+  timestamp: string;
+  totalModels: number;
+  availableModels: number;
+  unavailableModels: number;
+  results: ModelValidationResult[];
+}
+
+/**
+ * 验证单个模型是否可用
+ */
+async function validateModel(model: any): Promise<ModelValidationResult> {
+  const result: ModelValidationResult = {
+    id: model.id,
+    name: model.name,
+    input: model.input,
+    vendor: model.vendorName || model.vendor,
+    available: false,
+  };
+
+  // 检查 baseUrl 和 apiKey(现在在 vendor 级别,通过 config 注入到 model)
+  if (!model.apiKey || !model.baseUrl) {
+    result.error = '缺少 apiKey 或 baseUrl';
+    return result;
+  }
+
+  const startTime = Date.now();
+
+  try {
+    // 只有 text 类型用 LangChain 测试
+    if (model.input?.includes('text')) {
+      const llm = new ChatOpenAI({
+        model: model.id,
+        apiKey: model.apiKey,
+        configuration: { baseURL: model.baseUrl },
+        temperature: 0.7,
+        timeout: 15000,
+      });
+      await llm.invoke('你好');
+    } else {
+      // TTS/Image/Video 暂时标记为待验证(需要不同的 SDK)
+      result.available = false;
+      result.responseTime = Date.now() - startTime;
+      result.error = '需要手动验证';
+      return result;
+    }
+
+    result.available = true;
+    result.responseTime = Date.now() - startTime;
+  } catch (error: any) {
+    result.responseTime = Date.now() - startTime;
+    const errorMsg = error.response?.data?.message || error.message || '未知错误';
+    result.error = errorMsg;
+
+    // 检查是否是认证错误(key 无效)
+    if (error.response?.status === 401 || errorMsg.includes('invalid')) {
+      result.error = `认证失败: ${errorMsg}`;
+    } else if (error.response?.status === 403) {
+      result.error = `权限不足: ${errorMsg}`;
+    } else if (error.response?.status === 429) {
+      result.error = `限流: ${errorMsg}`;
+    }
+  }
+
+  return result;
+}
+
+/**
+ * 批量验证所有模型
+ */
+async function validateAllModels(): Promise<ValidationReport> {
+  const models = config.models.list.filter((m: any) => m.enabled !== false);
+  const results: ModelValidationResult[] = [];
+
+  console.log('\n========== 模型验证开始 ==========\n');
+
+  for (const model of models) {
+    process.stdout.write(`验证 ${model.id}... `);
+    const result = await validateModel(model);
+    results.push(result);
+
+    if (result.available) {
+      console.log(`✅ 可用 (${result.responseTime}ms)`);
+    } else {
+      console.log(`❌ 不可用 - ${result.error}`);
+    }
+  }
+
+  const availableCount = results.filter(r => r.available).length;
+  const unavailableCount = results.filter(r => !r.available).length;
+
+  console.log('\n========== 验证结果 ==========');
+  console.log(`总计: ${models.length} | 可用: ${availableCount} | 不可用: ${unavailableCount}`);
+
+  const report: ValidationReport = {
+    timestamp: new Date().toISOString(),
+    totalModels: models.length,
+    availableModels: availableCount,
+    unavailableModels: unavailableCount,
+    results,
+  };
+
+  // 保存报告到文件
+  const reportPath = path.join(process.cwd(), 'model-validation-report.json');
+  fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
+  console.log(`\n详细报告已保存: ${reportPath}`);
+
+  return report;
+}
+
+/**
+ * 仅验证特定类型的模型
+ */
+async function validateModelsByType(type: 'text' | 'tts' | 'image' | 'video'): Promise<ValidationReport> {
+  const models = config.models.getModelsByType(type);
+  const results: ModelValidationResult[] = [];
+
+  console.log(`\n========== 验证 ${type} 模型 ==========\n`);
+
+  for (const model of models) {
+    process.stdout.write(`验证 ${model.id}... `);
+    const result = await validateModel(model);
+    results.push(result);
+
+    if (result.available) {
+      console.log(`✅ 可用 (${result.responseTime}ms)`);
+    } else {
+      console.log(`❌ 不可用 - ${result.error}`);
+    }
+  }
+
+  const report: ValidationReport = {
+    timestamp: new Date().toISOString(),
+    totalModels: models.length,
+    availableModels: results.filter(r => r.available).length,
+    unavailableModels: results.filter(r => !r.available).length,
+    results,
+  };
+
+  return report;
+}
+
+// 如果直接运行此文件,执行验证
+if (require.main === module) {
+  const args = process.argv.slice(2);
+  const type = args[0] as 'text' | 'tts' | 'image' | 'video' | undefined;
+
+  (async () => {
+    if (type) {
+      await validateModelsByType(type);
+    } else {
+      await validateAllModels();
+    }
+    process.exit(0);
+  })();
+}
+
+export { validateAllModels, validateModelsByType, validateModel, type ModelValidationResult, type ValidationReport };

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

@@ -0,0 +1,117 @@
+{
+  "vendors": {
+    "bailian": {
+      "name": "阿里云百炼",
+      "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
+      "apiKey": "sk-c25679401ba24c749f53be86b0c9a7a6",
+      "apiType": "openai-chat",
+      "models": [
+        {
+          "id": "qwen3.6-plus",
+          "name": "qwen3.6-plus",
+          "input": ["text", "image"],
+          "contextWindow": 128000,
+          "maxTokens": 4000,
+          "temperature": 0.7,
+          "supportsToolCall": false,
+          "enabled": true
+        },
+        {
+          "id": "qwen3.5-flash",
+          "name": "qwen3.5-flash",
+          "input": ["text", "image"],
+          "contextWindow": 128000,
+          "maxTokens": 4000,
+          "temperature": 0.7,
+          "supportsToolCall": false,
+          "enabled": true
+        },
+        {
+          "id": "tongyi-xiaomi-analysis-pro",
+          "name": "通义小米分析Pro",
+          "input": ["text"],
+          "contextWindow": 128000,
+          "maxTokens": 4000,
+          "temperature": 0.7,
+          "supportsToolCall": false,
+          "enabled": false,
+          "reason": "LangChain不兼容,需直接API调用"
+        },
+        {
+          "id": "qwen3.5-122b-a10b",
+          "name": "qwen3.5-122b-A10B",
+          "input": ["text"],
+          "contextWindow": 128000,
+          "maxTokens": 4000,
+          "temperature": 0.7,
+          "supportsToolCall": false,
+          "enabled": true
+        }
+      ]
+    },
+    "minimax": {
+      "name": "MiniMax",
+      "baseUrl": "https://api.minimax.chat/v1",
+      "apiKey": "sk-cp-s8hGJr4ACdlZ4GM0LjZ1l2ZNBUAMGwd2ahl0c3W2g6C0EiNH0UvwOBx2q0oJOYO6ca5HqJccP_KRUPucoBizavlwDSZp-ohOGeUw2ymAdXPuM6r3CIDmmlY",
+      "apiType": "openai-chat",
+      "models": [
+        {
+          "id": "MiniMax-M2.7",
+          "name": "MiniMax M2.7",
+          "input": ["text"],
+          "contextWindow": 128000,
+          "maxTokens": 4096,
+          "temperature": 0.7,
+          "supportsToolCall": true,
+          "enabled": true
+        },
+        {
+          "id": "MiniMax-Speech-2.8-Turbo",
+          "name": "MiniMax Speech-2.8-Turbo",
+          "input": ["tts"],
+          "apiPath": "/t2a_v2",
+          "enabled": true
+        },
+        {
+          "id": "MiniMax-image-01",
+          "name": "MiniMax image-01",
+          "input": ["image"],
+          "apiPath": "/image_generation",
+          "enabled": true
+        },
+        {
+          "id": "MiniMax-Hailuo-02",
+          "name": "MiniMax Hailuo 02",
+          "input": ["video"],
+          "apiPath": "/video_generation",
+          "enabled": true
+        }
+      ]
+    },
+    "volcengine": {
+      "name": "火山引擎",
+      "baseUrl": "https://ark.cn-beijing.volces.com/api/coding/v3",
+      "apiKey": "16a23d6b-8544-4ea9-8096-58789c858390",
+      "apiType": "openai-chat",
+      "models": [
+        {
+          "id": "doubao-seed-2.0-code",
+          "name": "doubao-seed-2.0-code",
+          "input": ["text"],
+          "contextWindow": 128000,
+          "maxTokens": 4096,
+          "temperature": 0.7,
+          "supportsToolCall": true,
+          "enabled": true
+        }
+      ]
+    }
+  },
+  "textGeneration": {
+    "defaultModel": "qwen3.6-plus"
+  },
+  "tts": {
+    "defaultModel": "MiniMax-Speech-2.8-Turbo",
+    "defaultVoice": "Cherry"
+  }
+}

+ 220 - 0
server/src/modules/book-generator/README.md

@@ -0,0 +1,220 @@
+# 书籍生成模块 (Book Generator)
+
+基于"先出大纲、再写章节"设计思路的长文本生成模块。
+
+## 核心设计
+
+```
+用户输入 → 生成大纲 → 逐章生成 → 拼接成书
+```
+
+### 设计原理
+
+| 步骤 | 作用 |
+|------|------|
+| 1. 需求理解 | 解析用户描述,确定主题、受众、风格 |
+| 2. 生成大纲 | AI 生成结构化章节计划 |
+| 3. 逐章生成 | 按大纲顺序逐章生成内容 |
+| 4. 质量校验 | 检查完整性、术语一致性 |
+| 5. 拼接输出 | 合并前言、章节、后记 |
+
+## API 接口
+
+### 1. 创建书籍
+
+```
+POST /api/book-generator/books
+```
+
+请求体:
+```json
+{
+  "title": "《时间是什么》",
+  "subtitle": "一本写给青少年的科普书",
+  "description": "用通俗易懂的方式解释时间的概念",
+  "targetAudience": "青少年",
+  "style": "通俗易懂,带小故事",
+  "totalChapters": 10
+}
+```
+
+### 2. 生成大纲
+
+```
+POST /api/book-generator/books/:id/outline
+```
+
+返回结构化大纲,包含:
+- 主线主题
+- 结构逻辑
+- 各章节标题、概述、核心知识点
+
+### 3. 生成章节
+
+```bash
+# 生成单个章节
+POST /api/book-generator/books/:id/chapters
+{ "chapterNumber": 1 }
+
+# 生成全部章节
+POST /api/book-generator/books/:id/chapters
+```
+
+### 4. 生成前言/后记
+
+```bash
+POST /api/book-generator/books/:id/foreword
+POST /api/book-generator/books/:id/afterword
+```
+
+### 5. 获取完整内容
+
+```bash
+GET /api/book-generator/books/:id/full-content
+```
+
+### 6. 查询进度
+
+```bash
+GET /api/book-generator/books/:id/progress
+```
+
+## 使用示例
+
+### 完整生成流程
+
+```bash
+# 1. 创建书籍
+curl -X POST http://localhost:3000/api/book-generator/books \
+  -H "Content-Type: application/json" \
+  -d '{
+    "title": "《操作系统原理》",
+    "description": "一本面向计算机专业本科生的操作系统教材",
+    "targetAudience": "计算机专业本科生",
+    "style": "专业严谨,配合代码示例",
+    "totalChapters": 12
+  }'
+
+# 返回 bookId,例如: "abc-123-xyz"
+
+# 2. 生成大纲
+curl -X POST http://localhost:3000/api/book-generator/books/abc-123-xyz/outline
+
+# 3. 生成全部章节
+curl -X POST http://localhost:3000/api/book-generator/books/abc-123-xyz/chapters
+
+# 4. 生成前言/后记(可选)
+curl -X POST http://localhost:3000/api/book-generator/books/abc-123-xyz/foreword
+curl -X POST http://localhost:3000/api/book-generator/books/abc-123-xyz/afterword
+
+# 5. 获取完整书籍
+curl http://localhost:3000/api/book-generator/books/abc-123-xyz/full-content
+```
+
+## 数据结构
+
+### Book (书籍)
+
+```typescript
+{
+  id: string;
+  title: string;              // 书名
+  subtitle?: string;          // 副标题
+  description: string;       // 描述/用户输入
+  targetAudience: string;    // 目标受众
+  style: string;             // 写作风格
+  totalChapters: number;     // 总章节数
+  estimatedWords: number;    // 预估总字数
+  status: BookStatus;        // draft | planning | generating | completed | failed
+  progress: number;           // 0-100
+  outline?: BookOutline;     // 大纲
+  chapters: Chapter[];       // 章节列表
+  metadata?: BookMetadata;    // 元数据(前言、后记等)
+}
+```
+
+### BookOutline (大纲)
+
+```typescript
+{
+  mainTheme: string;         // 核心主题
+  structureLogic: string;    // 结构逻辑
+  chapters: OutlineChapter[];// 章节大纲
+}
+
+OutlineChapter {
+  number: number;             // 章节序号
+  title: string;              // 章节标题
+  summary: string;            // 章节概述
+  keyPoints: string[];        // 核心知识点
+  estimatedWords: number;     // 预估字数
+  stories?: string[];         // 故事/案例
+}
+```
+
+### Chapter (章节)
+
+```typescript
+{
+  id: string;
+  bookId: string;
+  number: number;
+  title: string;
+  content: string;           // 正文
+  summary?: string;           // 本章小结
+  wordCount: number;          // 字数
+  status: ChapterStatus;     // pending | generating | completed | failed
+}
+```
+
+## 提示词工程
+
+### 大纲生成提示词
+
+```
+请为以下书籍生成详细的章节大纲。
+
+## 书籍信息
+- 书名:{title}
+- 主题:{description}
+- 目标受众:{targetAudience}
+- 章节数:{totalChapters}章
+
+## 输出要求
+请生成JSON格式的大纲:
+1. mainTheme: 本书的核心主题
+2. structureLogic: 整体结构逻辑
+3. chapters: 章节大纲数组
+```
+
+### 章节生成提示词
+
+```
+请撰写书籍《{bookTitle}》第{number}章的完整内容。
+
+## 章节信息
+- 章节标题:{title}
+- 章节概述:{summary}
+- 核心知识点:
+  1. {keyPoint1}
+  2. {keyPoint2}
+  ...
+
+## 全书上下文
+- 主题主线:{mainTheme}
+- 结构逻辑:{structureLogic}
+
+## 写作要求
+1. 语言通俗易懂
+2. 包含引入、正文、总结
+3. 适当使用小标题
+```
+
+## 扩展方向
+
+1. **流式输出**:支持 SSE 流式生成,实时返回进度
+2. **持久化存储**:对接数据库,支持断点续传
+3. **批量管理**:支持多本书同时生成
+4. **内容校验**:自动检查内容质量、术语一致性
+5. **多语言支持**:支持生成英文、日文等书籍
+6. **导出格式**:支持导出 PDF、EPUB、DOCX 等格式

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

@@ -0,0 +1,535 @@
+/**
+ * 书籍生成控制器 - API 路由
+ */
+
+import Router from '@koa/router';
+import { Context } from 'koa';
+import { bookGeneratorService } from './book-generator.service';
+import { bookStore } from './book-generator.store';
+import { CreateBookRequest } from './book-generator.types';
+import { workflowEngine } from './book-generator.workflow';
+
+const router = new Router();
+
+/**
+ * POST /book-generator/books
+ * 创建新书籍
+ */
+router.post('/books', async (ctx: Context) => {
+  try {
+    const request: CreateBookRequest = ctx.request.body as CreateBookRequest;
+
+    if (!request.title || !request.description) {
+      ctx.status = 400;
+      ctx.body = {
+        code: 1,
+        message: '书名和描述不能为空',
+      };
+      return;
+    }
+
+    const book = await bookGeneratorService.createBook(request);
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { book },
+    };
+  } catch (error) {
+    console.error('创建书籍失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '创建失败',
+    };
+  }
+});
+
+/**
+ * GET /api/book-generator/books
+ * 获取所有书籍列表
+ */
+router.get('/books', async (ctx: Context) => {
+  try {
+    const books = await bookStore.getAllByUser();
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        books: books.map((b) => ({
+          id: b.id,
+          title: b.title,
+          subtitle: b.subtitle,
+          description: b.description,
+          status: b.status,
+          progress: b.progress,
+          totalChapters: b.totalChapters,
+          completedChapters: b.chapters.filter((c) => c.status === 'completed').length,
+          createdAt: b.createdAt,
+        })),
+      },
+    };
+  } catch (error) {
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '获取失败',
+    };
+  }
+});
+
+/**
+ * GET /api/book-generator/books/:id
+ * 获取书籍详情
+ */
+router.get('/books/:id', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const book = await bookStore.getById(bookId);
+
+    if (!book) {
+      ctx.status = 404;
+      ctx.body = {
+        code: 1,
+        message: '书籍不存在',
+      };
+      return;
+    }
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { book },
+    };
+  } catch (error) {
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '获取失败',
+    };
+  }
+});
+
+/**
+ * DELETE /api/book-generator/books/:id
+ * 删除书籍
+ */
+router.delete('/books/:id', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const deleted = await bookStore.delete(bookId);
+
+    if (!deleted) {
+      ctx.status = 404;
+      ctx.body = {
+        code: 1,
+        message: '书籍不存在',
+      };
+      return;
+    }
+
+    ctx.body = {
+      code: 0,
+      message: '删除成功',
+    };
+  } catch (error) {
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '删除失败',
+    };
+  }
+});
+
+/**
+ * POST /api/book-generator/books/:id/outline
+ * 生成大纲
+ */
+router.post('/books/:id/outline', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const outline = await bookGeneratorService.generateOutline(bookId);
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { outline },
+    };
+  } catch (error) {
+    console.error('生成大纲失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '生成大纲失败',
+    };
+  }
+});
+
+/**
+ * POST /api/book-generator/books/:id/chapters
+ * 生成章节
+ */
+router.post('/books/:id/chapters', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const body = ctx.request.body as { chapterNumber?: number };
+
+    if (body.chapterNumber) {
+      // 生成单个章节
+      const chapter = await bookGeneratorService.generateChapter(bookId, body.chapterNumber);
+      ctx.body = {
+        code: 0,
+        message: 'success',
+        data: { chapter },
+      };
+    } else {
+      // 生成全部章节
+      const chapters = await bookGeneratorService.generateAllChapters(bookId);
+      ctx.body = {
+        code: 0,
+        message: 'success',
+        data: { chapters },
+      };
+    }
+  } catch (error) {
+    console.error('生成章节失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '生成章节失败',
+    };
+  }
+});
+
+/**
+ * GET /api/book-generator/books/:id/chapters/:chapterNumber
+ * 获取指定章节
+ */
+router.get('/books/:id/chapters/:chapterNumber', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const book = await bookStore.getById(bookId);
+
+    if (!book) {
+      ctx.status = 404;
+      ctx.body = {
+        code: 1,
+        message: '书籍不存在',
+      };
+      return;
+    }
+
+    const chapterNumber = parseInt(ctx.params.chapterNumber as string);
+    const chapter = book.chapters.find((c) => c.number === chapterNumber);
+
+    if (!chapter) {
+      ctx.status = 404;
+      ctx.body = {
+        code: 1,
+        message: '章节不存在',
+      };
+      return;
+    }
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { chapter },
+    };
+  } catch (error) {
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '获取章节失败',
+    };
+  }
+});
+
+/**
+ * POST /api/book-generator/books/:id/foreword
+ * 生成前言
+ */
+router.post('/books/:id/foreword', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const foreword = await bookGeneratorService.generateForeword(bookId);
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { foreword },
+    };
+  } catch (error) {
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '生成前言失败',
+    };
+  }
+});
+
+/**
+ * POST /api/book-generator/books/:id/afterword
+ * 生成后记
+ */
+router.post('/books/:id/afterword', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const afterword = await bookGeneratorService.generateAfterword(bookId);
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { afterword },
+    };
+  } catch (error) {
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '生成后记失败',
+    };
+  }
+});
+
+/**
+ * GET /api/book-generator/books/:id/full-content
+ * 获取完整书籍内容
+ */
+router.get('/books/:id/full-content', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const content = await bookGeneratorService.getFullContent(bookId);
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { content },
+    };
+  } catch (error) {
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '获取内容失败',
+    };
+  }
+});
+
+/**
+ * GET /api/book-generator/books/:id/progress
+ * 获取生成进度
+ */
+router.get('/books/:id/progress', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const progress = await bookGeneratorService.getProgress(bookId);
+
+    if (!progress) {
+      ctx.status = 404;
+      ctx.body = {
+        code: 1,
+        message: '书籍不存在',
+      };
+      return;
+    }
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: progress,
+    };
+  } catch (error) {
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '获取进度失败',
+    };
+  }
+});
+
+/**
+ * POST /api/book-generator/books/:id/generate
+ * 一键生成整本书(异步,后端立即返回任务ID,前端轮询进度)
+ */
+router.post('/books/:id/generate', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const body = ctx.request.body as {
+      generateForeword?: boolean;
+      generateAfterword?: boolean;
+    };
+
+    // 立即返回任务ID,不等待生成完成
+    const taskId = `task_${bookId}_${Date.now()}`;
+
+    // 启动异步生成任务(不阻塞,立即返回)
+    bookGeneratorService.generateBookAsync(bookId, {
+      generateForeword: body.generateForeword ?? true,
+      generateAfterword: body.generateAfterword ?? true,
+    }).catch(err => {
+      console.error('异步生成失败:', err);
+    });
+
+    ctx.body = {
+      code: 0,
+      message: '生成任务已启动',
+      data: {
+        taskId,
+        bookId,
+        status: 'started',
+      },
+    };
+  } catch (error) {
+    console.error('启动生成失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '启动失败',
+    };
+  }
+});
+
+/**
+ * GET /api/book-generator/workflow/:bookId
+ * 获取工作流状态
+ */
+router.get('/workflow/:bookId', (ctx: Context) => {
+  try {
+    const bookId = ctx.params.bookId as string;
+    const progress = workflowEngine.getProgress(bookId);
+
+    if (!progress) {
+      ctx.status = 404;
+      ctx.body = {
+        code: 1,
+        message: '工作流不存在',
+      };
+      return;
+    }
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: progress,
+    };
+  } catch (error) {
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '获取工作流状态失败',
+    };
+  }
+});
+
+/**
+ * POST /api/book-generator/books/:id/chapters/:chapterNumber/audio
+ * 生成单个章节音频
+ */
+router.post('/books/:id/chapters/:chapterNumber/audio', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const chapterNumber = parseInt(ctx.params.chapterNumber as string);
+    const { voiceId } = ctx.request.body as { voiceId?: string };
+
+    const result = await bookGeneratorService.generateChapterAudio(
+      bookId,
+      chapterNumber,
+      voiceId || 'cherry'
+    );
+
+    ctx.body = {
+      code: 0,
+      message: '音频生成任务已启动',
+      data: result,
+    };
+  } catch (error) {
+    console.error('生成章节音频失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '生成音频失败',
+    };
+  }
+});
+
+/**
+ * POST /api/book-generator/books/:id/audio
+ * 批量生成书籍所有章节音频
+ */
+router.post('/books/:id/audio', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const { voiceId } = ctx.request.body as { voiceId?: string };
+
+    const result = await bookGeneratorService.generateAllChaptersAudio(
+      bookId,
+      voiceId || 'cherry'
+    );
+
+    ctx.body = {
+      code: 0,
+      message: '批量音频生成任务已启动',
+      data: result,
+    };
+  } catch (error) {
+    console.error('批量生成音频失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '批量生成音频失败',
+    };
+  }
+});
+
+/**
+ * POST /api/book-generator/books/:id/chapters/:chapterNumber/video
+ * 生成单个章节视频(从音频转视频)
+ */
+router.post('/books/:id/chapters/:chapterNumber/video', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const chapterNumber = parseInt(ctx.params.chapterNumber as string);
+
+    const result = await bookGeneratorService.generateChapterVideo(bookId, chapterNumber);
+
+    ctx.body = {
+      code: 0,
+      message: '视频生成任务已启动',
+      data: result,
+    };
+  } catch (error) {
+    console.error('生成章节视频失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '生成视频失败',
+    };
+  }
+});
+
+/**
+ * POST /api/book-generator/books/:id/videos
+ * 批量生成书籍所有章节视频
+ */
+router.post('/books/:id/videos', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+
+    const result = await bookGeneratorService.generateAllChaptersVideo(bookId);
+
+    ctx.body = {
+      code: 0,
+      message: '批量视频生成任务已启动',
+      data: result,
+    };
+  } catch (error) {
+    console.error('批量生成视频失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '批量生成视频失败',
+    };
+  }
+});
+
+export default router;

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

@@ -0,0 +1,837 @@
+/**
+ * 书籍生成服务 - 核心业务逻辑
+ * 使用 Prisma 数据库存储
+ */
+
+import axios from 'axios';
+import { config } from '../../config';
+import { prisma } from '../../models';
+import * as TtsService from '../tts/tts.service';
+import * as VideoService from '../video-generator/video-generator.service';
+import {
+  Book,
+  BookBase,
+  BookOutline,
+  Chapter,
+  CreateBookRequest,
+  GenerateConfig,
+  PromptTemplate,
+  ChapterStatus,
+  OutlineChapter,
+} from './book-generator.types';
+import { bookStore } from './book-generator.store';
+
+// ============ 默认配置 ============
+
+const DEFAULT_CONFIG: GenerateConfig = {
+  model: 'qwen3.6-plus',
+  temperature: 0.7,
+  maxTokens: 4000,
+  chapterWordRange: {
+    min: 800,
+    max: 2000,
+  },
+  retryPolicy: {
+    maxRetries: 3,
+    retryDelay: 2000,
+  },
+};
+
+// ============ 提示词模板 ============
+
+const PROMPT_TEMPLATES: PromptTemplate = {
+  systemPrompt: `你是专业的书籍作者,擅长撰写结构严谨、内容丰富、通俗易懂的作品。`,
+
+  outlinePrompt: `
+请为以下书籍生成详细的章节大纲。
+
+## 书籍信息
+- 书名:{title}
+- 副标题:{subtitle}
+- 主题:{description}
+- 目标受众:{targetAudience}
+- 风格:{style}
+- 章节数:{totalChapters}章
+
+## 输出要求
+请生成JSON格式的大纲,包含:
+1. mainTheme: 本书的核心主题
+2. structureLogic: 整体结构逻辑
+3. chapters: 章节大纲数组,每个章节包含:
+   - number: 章节序号
+   - title: 章节标题
+   - summary: 章节概述(1-2句话)
+   - keyPoints: 核心知识点(3-5个)
+   - estimatedWords: 预估字数
+
+请直接输出JSON,不要其他内容:
+`,
+
+  chapterPrompt: (chapter: OutlineChapter, context: BookOutline) => `
+请撰写书籍《{bookTitle}》第${chapter.number}章的完整内容。
+
+## 章节信息
+- 章节标题:${chapter.title}
+- 章节概述:${chapter.summary}
+- 核心知识点:
+${chapter.keyPoints.map((p, i) => `  ${i + 1}. ${p}`).join('\n')}
+- 预估字数:${chapter.estimatedWords}字
+
+## 全书上下文
+- 主题主线:${context.mainTheme}
+- 结构逻辑:${context.structureLogic}
+
+## 写作要求
+1. 语言通俗易懂,适合目标受众
+2. 包含引入、正文、总结三个部分
+3. 适当使用小标题划分内容
+4. 长度控制在${chapter.estimatedWords}字左右
+5. 使用markdown格式输出
+
+请直接输出正文内容:
+`,
+
+  forewordPrompt: (book: BookBase) => `
+请为书籍《${book.title}》撰写前言。
+主题:${book.description}
+目标受众:${book.targetAudience}
+写作风格:${book.style}
+长度:300-500字
+请直接输出前言内容:
+`,
+
+  afterwordPrompt: (book: BookBase) => `
+请为书籍《${book.title}》撰写后记。
+主题:${book.description}
+写作风格:${book.style}
+长度:300-500字
+请直接输出后记内容:
+`,
+};
+
+// ============ 核心服务类 ============
+
+export class BookGeneratorService {
+  private apiKey: string = '';
+  private config: GenerateConfig;
+
+  constructor(config?: Partial<GenerateConfig>) {
+    this.config = { ...DEFAULT_CONFIG, ...config };
+  }
+
+  setApiKey(apiKey: string): void {
+    this.apiKey = apiKey;
+  }
+
+  // ============ 书籍 CRUD ============
+
+  async createBook(request: CreateBookRequest): Promise<Book> {
+    return bookStore.create({
+      title: request.title,
+      subtitle: request.subtitle,
+      description: request.description,
+      targetAudience: request.targetAudience,
+      style: request.style,
+      totalChapters: request.totalChapters,
+    });
+  }
+
+  async getBook(id: string): Promise<Book | null> {
+    return bookStore.getById(id);
+  }
+
+  async getAllBooks(userId?: number): Promise<Book[]> {
+    return bookStore.getAllByUser(userId);
+  }
+
+  async deleteBook(id: string): Promise<boolean> {
+    return bookStore.delete(id);
+  }
+
+  // ============ 生成大纲 ============
+
+  async generateOutline(bookId: string): Promise<BookOutline> {
+    const book = await bookStore.getById(bookId);
+    if (!book) throw new Error('书籍不存在');
+
+    await bookStore.update(bookId, { status: 'planning' });
+
+    try {
+      const outlinePrompt = this.buildOutlinePrompt(book);
+      const outlineJson = await this.callLLM(outlinePrompt);
+      const outline = this.parseOutline(outlineJson);
+
+      await bookStore.update(bookId, {
+        outlineJson: JSON.stringify(outline),
+        estimatedWords: outline.chapters.reduce((sum, c) => sum + c.estimatedWords, 0),
+      });
+
+      // 创建章节记录
+      await bookStore.createChapters(bookId, outline.chapters.map(c => ({
+        number: c.number,
+        title: c.title,
+        summary: c.summary,
+        keyPoints: c.keyPoints,
+        estimatedWords: c.estimatedWords,
+      })));
+
+      return outline;
+    } catch (error) {
+      await bookStore.update(bookId, { status: 'failed', errorMsg: error instanceof Error ? error.message : '生成失败' });
+      throw error;
+    }
+  }
+
+  // ============ 生成章节 ============
+
+  async generateChapter(bookId: string, chapterNumber: number): Promise<Chapter> {
+    const book = await bookStore.getById(bookId);
+    if (!book) throw new Error('书籍不存在');
+    if (!book.outline) throw new Error('请先生成大纲');
+
+    const outlineChapter = book.outline.chapters.find((c) => c.number === chapterNumber);
+    if (!outlineChapter) throw new Error(`第${chapterNumber}章不存在`);
+
+    await bookStore.update(bookId, { status: 'generating' });
+
+    try {
+      const chapterPrompt = this.buildChapterPrompt(book, outlineChapter);
+      const content = await this.callLLM(chapterPrompt);
+      const wordCount = this.countWords(content);
+
+      const chapter = await bookStore.updateChapter(bookId, chapterNumber, {
+        content,
+        wordCount,
+        status: 'completed',
+      });
+
+      // 更新进度
+      const completedCount = await bookStore.countCompletedChapters(bookId);
+      const progress = Math.round((completedCount / book.totalChapters) * 100);
+      await bookStore.update(bookId, {
+        progress,
+        status: progress >= 100 ? 'completed' : 'generating',
+      });
+
+      return chapter!;
+    } catch (error) {
+      await bookStore.updateChapter(bookId, chapterNumber, {
+        status: 'failed',
+        errorMsg: error instanceof Error ? error.message : '生成失败',
+      });
+      throw error;
+    }
+  }
+
+  async generateAllChapters(bookId: string): Promise<Chapter[]> {
+    const book = await bookStore.getById(bookId);
+    if (!book) throw new Error('书籍不存在');
+    if (!book.outline) throw new Error('请先生成大纲');
+
+    const results: Chapter[] = [];
+
+    for (const outlineChapter of book.outline.chapters) {
+      const existingChapter = book.chapters.find((c) => c.number === outlineChapter.number);
+      if (existingChapter?.status === 'completed') {
+        results.push(existingChapter);
+        continue;
+      }
+
+      try {
+        const chapter = await this.generateChapter(bookId, outlineChapter.number);
+        results.push(chapter);
+      } catch (error) {
+        console.error(`生成第${outlineChapter.number}章失败:`, error);
+      }
+    }
+
+    return results;
+  }
+
+  // ============ 前言/后记 ============
+
+  async generateForeword(bookId: string): Promise<string> {
+    const book = await bookStore.getById(bookId);
+    if (!book) throw new Error('书籍不存在');
+
+    try {
+      const prompt = PROMPT_TEMPLATES.forewordPrompt(book);
+      const foreword = await this.callLLM(prompt);
+      await bookStore.update(bookId, { foreword });
+      return foreword;
+    } catch (error) {
+      throw new Error(`生成前言失败: ${error instanceof Error ? error.message : error}`);
+    }
+  }
+
+  async generateAfterword(bookId: string): Promise<string> {
+    const book = await bookStore.getById(bookId);
+    if (!book) throw new Error('书籍不存在');
+
+    try {
+      const prompt = PROMPT_TEMPLATES.afterwordPrompt(book);
+      const afterword = await this.callLLM(prompt);
+      await bookStore.update(bookId, { afterword });
+      return afterword;
+    } catch (error) {
+      throw new Error(`生成后记失败: ${error instanceof Error ? error.message : error}`);
+    }
+  }
+
+  // ============ 内容获取 ============
+
+  async getFullContent(bookId: string): Promise<string> {
+    const book = await bookStore.getById(bookId);
+    if (!book) throw new Error('书籍不存在');
+
+    const parts: string[] = [];
+    parts.push(`# ${book.title}`);
+    if (book.subtitle) parts.push(`## ${book.subtitle}`);
+
+    if (book.metadata?.foreword) {
+      parts.push('\n## 前言\n');
+      parts.push(book.metadata.foreword);
+    }
+
+    if (book.outline) {
+      parts.push('\n## 目录\n');
+      book.outline.chapters.forEach((ch) => {
+        parts.push(`${ch.number}. ${ch.title}`);
+      });
+    }
+
+    book.chapters.forEach((ch) => {
+      parts.push(`\n## 第${ch.number}章 ${ch.title}\n`);
+      parts.push(ch.content);
+    });
+
+    if (book.metadata?.afterword) {
+      parts.push('\n## 后记\n');
+      parts.push(book.metadata.afterword);
+    }
+
+    return parts.join('\n');
+  }
+
+  async getProgress(bookId: string) {
+    const book = await bookStore.getById(bookId);
+    if (!book) return null;
+
+    const completedChapters = book.chapters.filter((c) => c.status === 'completed').length;
+
+    return {
+      bookId,
+      status: book.status,
+      progress: book.progress,
+      completedChapters,
+      totalChapters: book.totalChapters,
+    };
+  }
+
+  // ============ 一键生成(同步阻塞) ============
+
+  async generateBook(bookId: string, options?: {
+    generateForeword?: boolean;
+    generateAfterword?: boolean;
+    onProgress?: (progress: number, chapterNum: number) => void;
+  }): Promise<{
+    success: boolean;
+    book?: Book;
+    completedChapters: number;
+    failedChapters: number;
+    errors: string[];
+  }> {
+    const book = await bookStore.getById(bookId);
+    if (!book) return { success: false, completedChapters: 0, failedChapters: 0, errors: ['书籍不存在'] };
+
+    const errors: string[] = [];
+
+    try {
+      if (!book.outline) {
+        await this.generateOutline(bookId);
+      }
+    } catch (error) {
+      return { success: false, completedChapters: 0, failedChapters: 0, errors: [`大纲: ${error instanceof Error ? error.message : error}`] };
+    }
+
+    const results: Chapter[] = [];
+    let failedCount = 0;
+
+    for (const outlineChapter of book.outline?.chapters || []) {
+      try {
+        const chapter = await this.generateChapter(bookId, outlineChapter.number);
+        results.push(chapter);
+        options?.onProgress?.(Math.round((results.length / book.totalChapters) * 100), outlineChapter.number);
+      } catch (error) {
+        failedCount++;
+        errors.push(`第${outlineChapter.number}章: ${error instanceof Error ? error.message : error}`);
+      }
+    }
+
+    if (options?.generateForeword) {
+      try {
+        await this.generateForeword(bookId);
+      } catch (error) {
+        errors.push(`前言: ${error instanceof Error ? error.message : error}`);
+      }
+    }
+
+    if (options?.generateAfterword) {
+      try {
+        await this.generateAfterword(bookId);
+      } catch (error) {
+        errors.push(`后记: ${error instanceof Error ? error.message : error}`);
+      }
+    }
+
+    const updatedBook = await bookStore.getById(bookId);
+    return {
+      success: failedCount === 0 && errors.length === 0,
+      book: updatedBook || undefined,
+      completedChapters: results.length,
+      failedChapters: failedCount,
+      errors,
+    };
+  }
+
+  // ============ 一键生成(异步不阻塞) ============
+
+  async generateBookAsync(bookId: string, options?: {
+    generateForeword?: boolean;
+    generateAfterword?: boolean;
+  }): Promise<void> {
+    const book = await bookStore.getById(bookId);
+    if (!book) return;
+
+    try {
+      await bookStore.update(bookId, { status: 'generating', progress: 0 });
+
+      if (!book.outline) {
+        await this.generateOutline(bookId);
+      }
+
+      // 生成章节
+      for (const outlineChapter of book.outline?.chapters || []) {
+        try {
+          await this.generateChapter(bookId, outlineChapter.number);
+          const updatedBook = await bookStore.getById(bookId);
+          await bookStore.update(bookId, { progress: updatedBook!.progress });
+        } catch (error) {
+          console.error(`生成第${outlineChapter.number}章失败:`, error);
+        }
+      }
+
+      if (options?.generateForeword) {
+        await this.generateForeword(bookId);
+      }
+
+      if (options?.generateAfterword) {
+        await this.generateAfterword(bookId);
+      }
+
+      await bookStore.update(bookId, { status: 'completed', progress: 100 });
+    } catch (error) {
+      await bookStore.update(bookId, { status: 'failed', errorMsg: error instanceof Error ? error.message : '生成失败' });
+    }
+  }
+
+  // ============ 私有方法 ============
+
+  private buildOutlinePrompt(book: BookBase): string {
+    return PROMPT_TEMPLATES.outlinePrompt
+      .replace('{title}', book.title)
+      .replace('{subtitle}', book.subtitle || '无')
+      .replace('{description}', book.description)
+      .replace('{targetAudience}', book.targetAudience)
+      .replace('{style}', book.style)
+      .replace('{totalChapters}', String(book.totalChapters));
+  }
+
+  private buildChapterPrompt(book: Book, chapter: OutlineChapter): string {
+    if (!book.outline) throw new Error('书籍大纲不存在');
+    return PROMPT_TEMPLATES.chapterPrompt(chapter, book.outline).replace('{bookTitle}', book.title);
+  }
+
+  private parseOutline(jsonStr: string): BookOutline {
+    try {
+      const jsonMatch = jsonStr.match(/\{[\s\S]*\}/);
+      if (jsonMatch) {
+        return this.validateOutline(JSON.parse(jsonMatch[0]));
+      }
+    } catch {
+      console.error('解析大纲 JSON 失败');
+    }
+    return this.createDefaultOutline();
+  }
+
+  private validateOutline(data: any): BookOutline {
+    if (!data.chapters || !Array.isArray(data.chapters)) {
+      throw new Error('大纲格式不正确');
+    }
+    return {
+      mainTheme: data.mainTheme || '主题待定',
+      structureLogic: data.structureLogic || '由浅入深',
+      chapters: data.chapters.map((c: any, i: number) => ({
+        number: c.number || i + 1,
+        title: c.title || `第${i + 1}章`,
+        summary: c.summary || '',
+        keyPoints: c.keyPoints || [],
+        estimatedWords: c.estimatedWords || 1000,
+        stories: c.stories || [],
+      })),
+    };
+  }
+
+  private createDefaultOutline(): BookOutline {
+    return {
+      mainTheme: '核心主题',
+      structureLogic: '由浅入深',
+      chapters: [{ number: 1, title: '概述', summary: '介绍', keyPoints: ['基础概念'], estimatedWords: 1000 }],
+    };
+  }
+
+  private countWords(text: string): number {
+    return (text.match(/[\u4e00-\u9fa5]/g) || []).length;
+  }
+
+  private async callLLM(prompt: string, retryCount = 0, currentModel?: string): Promise<string> {
+    const modelId = currentModel || this.config.model;
+
+    // 从模型配置获取 API Key 和 URL
+    const modelConfig = config.models.getModel(modelId);
+
+    if (!modelConfig?.apiKey || !modelConfig?.baseUrl) {
+      throw new Error(`模型 ${modelId} 缺少 API 配置`);
+    }
+
+    const { apiKey, baseUrl } = modelConfig;
+
+    try {
+      const response = await axios.post(
+        `${baseUrl}/chat/completions`,
+        {
+          model: modelId,
+          messages: [{ role: 'user', content: prompt }],
+        },
+        {
+          headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
+          timeout: 120000,
+        }
+      );
+
+      const result = response.data?.choices?.[0]?.message?.content || '';
+      if (!result) throw new Error('API 返回内容为空');
+      return result;
+    } catch (error: any) {
+      const errorMessage = error.response?.data?.message || error.message || 'API 调用失败';
+      console.log(`[LLM] 模型 ${modelId} 调用失败: ${errorMessage}`);
+
+      // 检查是否需要切换模型
+      if (config.models.shouldSwitchModel(errorMessage)) {
+        const nextModel = config.models.getNextModel(modelId, 'text');
+        if (nextModel) {
+          console.log(`[LLM] 自动切换到模型: ${nextModel}`);
+          return this.callLLM(prompt, 0, nextModel); // 重置 retryCount
+        }
+      }
+
+      // 重试当前模型
+      if (retryCount < this.config.retryPolicy.maxRetries) {
+        await this.delay(this.config.retryPolicy.retryDelay);
+        return this.callLLM(prompt, retryCount + 1, currentModel);
+      }
+
+      throw new Error(errorMessage);
+    }
+  }
+
+  private delay(ms: number): Promise<void> {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
+
+  // ============ 音频生成 ============
+
+  /**
+   * 生成单个章节音频
+   */
+  async generateChapterAudio(
+    bookId: string,
+    chapterNumber: number,
+    voiceId: string
+  ): Promise<{ taskId: string; chapterId: string }> {
+    const book = await bookStore.getById(bookId);
+    if (!book) throw new Error('书籍不存在');
+
+    const chapter = book.chapters.find((c) => c.number === chapterNumber);
+    if (!chapter) throw new Error(`第${chapterNumber}章不存在`);
+    if (!chapter.content || chapter.content.trim().length === 0) {
+      throw new Error('章节内容为空,请先生成章节内容');
+    }
+
+    // 从数据库获取章节记录
+    const dbChapter = await prisma.bookChapter.findFirst({
+      where: {
+        bookId: parseInt(bookId),
+        number: chapterNumber,
+      },
+    });
+
+    if (!dbChapter) throw new Error('章节数据库记录不存在');
+
+    const taskId = `audio_${bookId}_${chapterNumber}_${Date.now()}`;
+
+    // 异步生成音频
+    this.processChapterAudio(dbChapter.id, chapter.content, voiceId).catch((err) => {
+      console.error(`章节 ${chapterNumber} 音频生成失败:`, err);
+    });
+
+    return { taskId, chapterId: String(dbChapter.id) };
+  }
+
+  /**
+   * 批量生成书籍所有章节音频
+   */
+  async generateAllChaptersAudio(
+    bookId: string,
+    voiceId: string
+  ): Promise<{ taskId: string; totalChapters: number }> {
+    const book = await bookStore.getById(bookId);
+    if (!book) throw new Error('书籍不存在');
+
+    const completedChapters = book.chapters.filter(
+      (c) => c.status === 'completed' && c.content && c.content.trim().length > 0
+    );
+
+    if (completedChapters.length === 0) {
+      throw new Error('没有可生成音频的章节,请先生成章节内容');
+    }
+
+    const taskId = `audio_book_${bookId}_${Date.now()}`;
+
+    // 异步批量生成音频
+    this.processAllChaptersAudio(bookId, completedChapters, voiceId).catch((err) => {
+      console.error(`书籍 ${bookId} 批量音频生成失败:`, err);
+    });
+
+    return { taskId, totalChapters: completedChapters.length };
+  }
+
+  /**
+   * 处理单个章节音频生成
+   */
+  private async processChapterAudio(
+    chapterId: number,
+    content: string,
+    voiceId: string
+  ): Promise<void> {
+    try {
+      console.log(`🎵 开始生成章节 ${chapterId} 音频...`);
+
+      // 清理内容(移除 markdown 格式)
+      const cleanContent = content
+        .replace(/^#{1,6}\s+/gm, '') // 移除标题标记
+        .replace(/\*\*(.*?)\*\*/g, '$1') // 移除粗体
+        .replace(/\*(.*?)\*/g, '$1') // 移除斜体
+        .replace(/`(.*?)`/g, '$1') // 移除行内代码
+        .replace(/^\s*[-*+]\s+/gm, '') // 移除列表标记
+        .replace(/^\s*\d+\.\s+/gm, '') // 移除数字列表标记
+        .trim();
+
+      // 调用 TTS 服务生成音频
+      const result = await TtsService.generateAudio(
+        'system',
+        cleanContent,
+        voiceId,
+        { speed: 1.0, pitch: 0, volume: 50 },
+        async (audioUrl, duration) => {
+          // 音频生成完成后更新数据库
+          await prisma.bookChapter.update({
+            where: { id: chapterId },
+            data: {
+              audioUrl,
+              audioDuration: duration,
+            },
+          });
+          console.log(`✅ 章节 ${chapterId} 音频生成完成: ${audioUrl}`);
+        }
+      );
+
+      console.log(`🎵 章节 ${chapterId} 音频任务已启动: ${result.audioId}`);
+    } catch (error) {
+      console.error(`❌ 章节 ${chapterId} 音频生成失败:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * 处理批量章节音频生成
+   */
+  private async processAllChaptersAudio(
+    bookId: string,
+    chapters: any[],
+    voiceId: string
+  ): Promise<void> {
+    console.log(`🎵 开始批量生成书籍 ${bookId} 的 ${chapters.length} 个章节音频...`);
+
+    // 从数据库获取所有章节记录
+    const dbChapters = await prisma.bookChapter.findMany({
+      where: { bookId: parseInt(bookId) },
+    });
+
+    for (const chapter of chapters) {
+      const dbChapter = dbChapters.find((c) => c.number === chapter.number);
+      if (dbChapter && chapter.content) {
+        try {
+          await this.processChapterAudio(dbChapter.id, chapter.content, voiceId);
+          // 每个章节之间稍作延迟,避免请求过于密集
+          await this.delay(1000);
+        } catch (error) {
+          console.error(`第${chapter.number}章音频生成失败,继续下一个:`, error);
+        }
+      }
+    }
+
+    console.log(`✅ 书籍 ${bookId} 批量音频生成任务完成`);
+  }
+
+  // ============ 视频生成 ============
+
+  /**
+   * 生成单个章节视频(从音频转视频)
+   */
+  async generateChapterVideo(
+    bookId: string,
+    chapterNumber: number
+  ): Promise<{ projectId: number; chapterId: string }> {
+    const book = await bookStore.getById(bookId);
+    if (!book) throw new Error('书籍不存在');
+
+    const chapter = book.chapters.find((c) => c.number === chapterNumber);
+    if (!chapter) throw new Error(`第${chapterNumber}章不存在`);
+    if (!chapter.audioUrl) {
+      throw new Error('章节音频不存在,请先生成音频');
+    }
+
+    // 获取数据库中的章节记录
+    const dbChapter = await prisma.bookChapter.findFirst({
+      where: {
+        bookId: parseInt(bookId),
+        number: chapterNumber,
+      },
+    });
+
+    if (!dbChapter) throw new Error('章节数据库记录不存在');
+
+    // 创建视频项目并生成
+    const videoProject = await VideoService.createVideoProjectFromBook(
+      parseInt(bookId),
+      dbChapter.id
+    );
+
+    if (!videoProject) {
+      throw new Error('创建视频项目失败');
+    }
+
+    // 异步生成视频
+    this.processChapterVideo(videoProject.id).catch((err) => {
+      console.error(`章节 ${chapterNumber} 视频生成失败:`, err);
+    });
+
+    return { projectId: videoProject.id, chapterId: String(dbChapter.id) };
+  }
+
+  /**
+   * 批量生成书籍所有章节视频
+   */
+  async generateAllChaptersVideo(
+    bookId: string
+  ): Promise<{ taskId: string; totalChapters: number }> {
+    const book = await bookStore.getById(bookId);
+    if (!book) throw new Error('书籍不存在');
+
+    const chaptersWithAudio = book.chapters.filter(
+      (c) => c.status === 'completed' && c.audioUrl
+    );
+
+    if (chaptersWithAudio.length === 0) {
+      throw new Error('没有可生成视频的章节(需要先有音频)');
+    }
+
+    const taskId = `video_book_${bookId}_${Date.now()}`;
+
+    // 异步批量生成视频
+    this.processAllChaptersVideo(bookId, chaptersWithAudio).catch((err) => {
+      console.error(`书籍 ${bookId} 批量视频生成失败:`, err);
+    });
+
+    return { taskId, totalChapters: chaptersWithAudio.length };
+  }
+
+  /**
+   * 处理单个章节视频生成
+   */
+  private async processChapterVideo(projectId: number): Promise<void> {
+    try {
+      console.log(`🎬 开始生成视频项目 ${projectId}...`);
+
+      const result = await VideoService.generateVideoForProject(projectId);
+
+      if (result.success) {
+        console.log(`✅ 视频项目 ${projectId} 生成完成: ${result.outputUrl}`);
+      } else {
+        console.error(`❌ 视频项目 ${projectId} 生成失败: ${result.error}`);
+      }
+    } catch (error) {
+      console.error(`❌ 视频项目 ${projectId} 生成失败:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * 处理批量章节视频生成
+   */
+  private async processAllChaptersVideo(
+    bookId: string,
+    chapters: any[]
+  ): Promise<void> {
+    console.log(`🎬 开始批量生成书籍 ${bookId} 的 ${chapters.length} 个章节视频...`);
+
+    for (const chapter of chapters) {
+      try {
+        const dbChapter = await prisma.bookChapter.findFirst({
+          where: {
+            bookId: parseInt(bookId),
+            number: chapter.number,
+          },
+        });
+
+        if (!dbChapter || !chapter.audioUrl) {
+          console.warn(`第${chapter.number}章跳过:缺少音频或数据库记录`);
+          continue;
+        }
+
+        // 创建视频项目
+        const videoProject = await VideoService.createVideoProjectFromBook(
+          parseInt(bookId),
+          dbChapter.id
+        );
+
+        if (videoProject) {
+          // 生成视频
+          await this.processChapterVideo(videoProject.id);
+        }
+
+        // 每个视频之间稍作延迟
+        await this.delay(2000);
+      } catch (error) {
+        console.error(`第${chapter.number}章视频生成失败,继续下一个:`, error);
+      }
+    }
+
+    console.log(`✅ 书籍 ${bookId} 批量视频生成任务完成`);
+  }
+}
+
+// 导出单例
+export const bookGeneratorService = new BookGeneratorService();
+bookGeneratorService.setApiKey(config.dashscope?.apiKey || '');

+ 351 - 0
server/src/modules/book-generator/book-generator.store.ts

@@ -0,0 +1,351 @@
+/**
+ * 书籍生成模块 - Prisma 数据库存储
+ */
+
+import { prisma } from '../../models';
+import { Book, BookOutline, Chapter, ChapterStatus, BookStatus } from './book-generator.types';
+import { Prisma } from '@prisma/client';
+import { generateAudio } from '../tts/tts.service';
+
+// ============ 类型转换 ============
+
+function parseOutlineJson(jsonStr: string | null): BookOutline | null {
+  if (!jsonStr) return null;
+  try {
+    return JSON.parse(jsonStr);
+  } catch {
+    return null;
+  }
+}
+
+function chaptersFromDb(dbChapters: any[], bookId: number): Chapter[] {
+  return dbChapters.map((c) => ({
+    id: String(c.id),
+    bookId: String(c.bookId),
+    number: c.number,
+    title: c.title,
+    content: c.content || '',
+    wordCount: c.wordCount,
+    status: c.status as ChapterStatus,
+    summary: c.summary || undefined,
+    generatedAt: c.generatedAt || undefined,
+    error: c.errorMsg || undefined,
+    audioUrl: c.audioUrl || undefined,
+    audioDuration: c.audioDuration || 0,
+    videoUrl: c.videoUrl || undefined,
+    videoDuration: c.videoDuration || undefined,
+  }));
+}
+
+function outlineChapterFromDb(dbChapter: any) {
+  return {
+    number: dbChapter.number,
+    title: dbChapter.title,
+    summary: dbChapter.summary || '',
+    keyPoints: dbChapter.keyPoints ? JSON.parse(dbChapter.keyPoints) : [],
+    estimatedWords: dbChapter.estimatedWords,
+  };
+}
+
+// ============ 存储类 ============
+
+export class BookStore {
+  /**
+   * 创建书籍
+   */
+  async create(data: {
+    userId?: number;
+    title: string;
+    subtitle?: string;
+    description: string;
+    targetAudience?: string;
+    style?: string;
+    totalChapters?: number;
+  }): Promise<Book> {
+    const book = await prisma.book.create({
+      data: {
+        userId: data.userId,
+        title: data.title,
+        subtitle: data.subtitle,
+        description: data.description,
+        targetAudience: data.targetAudience || '通用',
+        style: data.style || '专业严谨',
+        totalChapters: data.totalChapters || 10,
+        status: 'draft',
+        progress: 0,
+        isPublished: false, // 预发布:等书籍完成后再发布
+      },
+      include: { chapters: true },
+    });
+
+    return this.toBook(book);
+  }
+
+  /**
+   * 获取书籍
+   */
+  async getById(id: string): Promise<Book | null> {
+    const book = await prisma.book.findUnique({
+      where: { id: parseInt(id) },
+      include: { chapters: { orderBy: { number: 'asc' } } },
+    });
+    return book ? this.toBook(book) : null;
+  }
+
+  /**
+   * 获取用户的所有书籍
+   */
+  async getAllByUser(userId?: number): Promise<Book[]> {
+    const books = await prisma.book.findMany({
+      where: userId ? { userId } : {},
+      include: { chapters: true },
+      orderBy: { createdAt: 'desc' },
+    });
+    return books.map((b) => this.toBook(b));
+  }
+
+  /**
+   * 更新书籍
+   */
+  async update(id: string, data: Partial<{
+    status: BookStatus;
+    progress: number;
+    outlineJson: string;
+    foreword: string;
+    afterword: string;
+    estimatedWords: number;
+    errorMsg: string;
+    totalChapters: number;
+  }>): Promise<Book | null> {
+    const book = await prisma.book.update({
+      where: { id: parseInt(id) },
+      data: {
+        ...data,
+        updatedAt: new Date(),
+      },
+      include: { chapters: { orderBy: { number: 'asc' } } },
+    });
+    return this.toBook(book);
+  }
+
+  /**
+   * 删除书籍
+   */
+  async delete(id: string): Promise<boolean> {
+    try {
+      await prisma.book.delete({ where: { id: parseInt(id) } });
+      return true;
+    } catch {
+      return false;
+    }
+  }
+
+  /**
+   * 创建章节
+   */
+  async createChapter(data: {
+    bookId: string;
+    number: number;
+    title: string;
+    summary?: string;
+    keyPoints?: string[];
+    estimatedWords?: number;
+  }): Promise<void> {
+    await prisma.bookChapter.create({
+      data: {
+        bookId: parseInt(data.bookId),
+        number: data.number,
+        title: data.title,
+        summary: data.summary,
+        keyPoints: data.keyPoints ? JSON.stringify(data.keyPoints) : null,
+        estimatedWords: data.estimatedWords || 1000,
+        status: 'pending',
+      },
+    });
+  }
+
+  /**
+   * 批量创建章节
+   */
+  async createChapters(bookId: string, chapters: Array<{
+    number: number;
+    title: string;
+    summary?: string;
+    keyPoints?: string[];
+    estimatedWords?: number;
+  }>): Promise<void> {
+    await prisma.bookChapter.createMany({
+      data: chapters.map((c) => ({
+        bookId: parseInt(bookId),
+        number: c.number,
+        title: c.title,
+        summary: c.summary,
+        keyPoints: c.keyPoints ? JSON.stringify(c.keyPoints) : null,
+        estimatedWords: c.estimatedWords || 1000,
+        status: 'pending',
+      })),
+    });
+  }
+
+  /**
+   * 更新章节内容
+   */
+  async updateChapter(bookId: string, chapterNumber: number, data: Partial<{
+    content: string;
+    wordCount: number;
+    status: ChapterStatus;
+    errorMsg: string;
+  }>): Promise<Chapter | null> {
+    const chapter = await prisma.bookChapter.findFirst({
+      where: {
+        bookId: parseInt(bookId),
+        number: chapterNumber,
+      },
+    });
+    if (!chapter) return null;
+
+    const updated = await prisma.bookChapter.update({
+      where: { id: chapter.id },
+      data: {
+        ...data,
+        generatedAt: data.content ? new Date() : undefined,
+      },
+    });
+
+    return {
+      id: String(updated.id),
+      bookId: String(updated.bookId),
+      number: updated.number,
+      title: updated.title,
+      content: updated.content || '',
+      wordCount: updated.wordCount,
+      status: updated.status as ChapterStatus,
+      summary: updated.summary || undefined,
+      generatedAt: updated.generatedAt || undefined,
+      error: updated.errorMsg || undefined,
+    };
+  }
+
+  /**
+   * 获取书籍的章节
+   */
+  async getChapters(bookId: string): Promise<Chapter[]> {
+    const chapters = await prisma.bookChapter.findMany({
+      where: { bookId: parseInt(bookId) },
+      orderBy: { number: 'asc' },
+    });
+    return chaptersFromDb(chapters, parseInt(bookId));
+  }
+
+  /**
+   * 统计书籍完成章节数
+   */
+  async countCompletedChapters(bookId: string): Promise<number> {
+    return prisma.bookChapter.count({
+      where: {
+        bookId: parseInt(bookId),
+        status: 'completed',
+      },
+    });
+  }
+
+  /**
+   * 发布书籍(将 isPublished 设为 true)
+   */
+  async publishAlbum(bookId: string): Promise<void> {
+    await prisma.book.update({
+      where: { id: parseInt(bookId) },
+      data: { isPublished: true },
+    });
+  }
+
+  /**
+   * 为书籍章节生成音频并关联(更新 BookChapter.audioUrl)
+   */
+  async generateChapterAudio(bookId: string, chapterNumber: number, userId?: number): Promise<{
+    audioUrl: string;
+  } | null> {
+    const chapter = await prisma.bookChapter.findFirst({
+      where: { bookId: parseInt(bookId), number: chapterNumber },
+      include: { book: true },
+    });
+
+    if (!chapter || !chapter.content) {
+      return null;
+    }
+
+    // 生成音频(异步模式,通过回调更新章节)
+    const result = await generateAudio(
+      userId ? String(userId) : String(chapter.book?.userId || '0'),
+      chapter.content,
+      'default',
+      { speed: 1.0, pitch: 0, volume: 50 },
+      async (audioUrl: string, duration: number) => {
+        // 音频生成完成后更新章节
+        await prisma.bookChapter.update({
+          where: { id: chapter.id },
+          data: {
+            audioUrl,
+            audioDuration: duration,
+          },
+        });
+        console.log(`✅ 章节${chapterNumber}音频生成完成:`, audioUrl);
+      }
+    );
+
+    return {
+      audioUrl: result.audioUrl, // 初始为空字符串,实际URL通过回调更新
+    };
+  }
+
+  /**
+   * 转换数据库模型到 Book 类型
+   */
+  private toBook(dbBook: {
+    id: number;
+    userId: number | null;
+    title: string;
+    subtitle: string | null;
+    description: string;
+    targetAudience: string;
+    style: string;
+    totalChapters: number;
+    estimatedWords: number;
+    status: string;
+    progress: number;
+    outlineJson: string | null;
+    foreword: string | null;
+    afterword: string | null;
+    errorMsg: string | null;
+    createdAt: Date;
+    updatedAt: Date;
+    chapters: any[];
+  }): Book {
+    const outline = parseOutlineJson(dbBook.outlineJson);
+
+    return {
+      id: String(dbBook.id),
+      title: dbBook.title,
+      subtitle: dbBook.subtitle || undefined,
+      description: dbBook.description,
+      targetAudience: dbBook.targetAudience,
+      style: dbBook.style,
+      totalChapters: dbBook.totalChapters,
+      estimatedWords: dbBook.estimatedWords,
+      status: dbBook.status as BookStatus,
+      progress: dbBook.progress,
+      chapters: chaptersFromDb(dbBook.chapters, dbBook.id),
+      outline: outline || undefined,
+      metadata: {
+        foreword: dbBook.foreword || undefined,
+        afterword: dbBook.afterword || undefined,
+      },
+      error: dbBook.errorMsg || undefined,
+      createdAt: dbBook.createdAt,
+      updatedAt: dbBook.updatedAt,
+    };
+  }
+}
+
+// 导出单例
+export const bookStore = new BookStore();

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

@@ -0,0 +1,184 @@
+/**
+ * 书籍生成模块 - 类型定义
+ * 定义书籍、章节、任务的数据结构
+ */
+
+// ============ 核心类型 ============
+
+/** 书籍状态 */
+export type BookStatus = 'draft' | 'planning' | 'generating' | 'completed' | 'failed';
+
+/** 章节状态 */
+export type ChapterStatus = 'pending' | 'generating' | 'completed' | 'failed';
+
+/** 书籍基础信息 */
+export interface BookBase {
+  id: string;
+  title: string;                    // 书名
+  subtitle?: string;                // 副标题
+  description: string;              // 书籍描述/用户输入
+  targetAudience: string;            // 目标受众
+  style: string;                    // 写作风格
+  totalChapters: number;            // 总章节数
+  estimatedWords: number;           // 预估总字数
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+/** 书籍完整信息 */
+export interface Book extends BookBase {
+  status: BookStatus;
+  progress: number;                 // 生成进度 0-100
+  chapters: Chapter[];              // 章节列表
+  outline?: BookOutline;            // 书籍大纲
+  metadata?: BookMetadata;          // 元数据(作者、前言、后记等)
+  error?: string;                   // 错误信息
+}
+
+/** 书籍大纲 */
+export interface BookOutline {
+  mainTheme: string;                // 主题主线
+  structureLogic: string;           // 结构逻辑
+  chapters: OutlineChapter[];      // 章节大纲
+}
+
+/** 章节大纲(规划阶段)*/
+export interface OutlineChapter {
+  number: number;
+  title: string;
+  summary: string;                  // 章节概述
+  keyPoints: string[];              // 核心知识点
+  estimatedWords: number;           // 预估字数
+  stories?: string[];               // 故事/案例
+}
+
+/** 章节内容 */
+export interface Chapter {
+  id: string;
+  bookId: string;
+  number: number;
+  title: string;
+  content: string;                 // 正文内容
+  summary?: string;                 // 本章小结
+  wordCount: number;                // 字数
+  status: ChapterStatus;
+  generatedAt?: Date;
+  error?: string;
+  audioUrl?: string;                // 音频URL
+  audioDuration?: number;           // 音频时长
+  videoUrl?: string;                // 视频URL
+  videoDuration?: number;           // 视频时长
+}
+
+/** 书籍元数据 */
+export interface BookMetadata {
+  author?: string;
+  foreword?: string;                // 前言
+  afterword?: string;               // 后记
+  references?: string[];            // 参考文献
+  appendix?: string;                // 附录
+}
+
+/** 生成任务 */
+export interface GenerateTask {
+  id: string;
+  bookId: string;
+  chapterId?: string;               // 如果是章节任务
+  type: 'outline' | 'chapter' | 'foreword' | 'afterword';
+  status: 'pending' | 'running' | 'completed' | 'failed';
+  progress: number;
+  message: string;
+  retryCount: number;
+  maxRetries: number;
+  createdAt: Date;
+  startedAt?: Date;
+  completedAt?: Date;
+  error?: string;
+}
+
+// ============ 请求/响应类型 ============
+
+/** 创建书籍请求 */
+export interface CreateBookRequest {
+  title: string;
+  subtitle?: string;
+  description: string;              // 核心输入:用户要写的内容描述
+  targetAudience?: string;          // 默认:通用
+  style?: string;                   // 默认:专业严谨
+  totalChapters?: number;           // 默认:10章
+  language?: string;                // 默认:中文
+}
+
+/** 生成章节请求 */
+export interface GenerateChapterRequest {
+  bookId: string;
+  chapterNumber?: number;           // 指定章节,不指定则按顺序生成
+}
+
+/** 批量生成请求 */
+export interface BatchGenerateRequest {
+  bookId: string;
+  chapterNumbers?: number[];        // 指定章节号数组
+}
+
+/** 书籍响应 */
+export interface BookResponse {
+  book: Book;
+  tasks: GenerateTask[];
+}
+
+/** 进度响应 */
+export interface ProgressResponse {
+  bookId: string;
+  status: BookStatus;
+  progress: number;
+  currentTask?: {
+    type: string;
+    message: string;
+  };
+  completedChapters: number;
+  totalChapters: number;
+}
+
+// ============ 配置类型 ============
+
+/** 生成配置 */
+export interface GenerateConfig {
+  model: string;                    // 使用的模型
+  temperature: number;              // 温度参数
+  maxTokens: number;                // 最大 token 数
+  chapterWordRange: {               // 章节字数范围
+    min: number;
+    max: number;
+  };
+  retryPolicy: {
+    maxRetries: number;
+    retryDelay: number;            // ms
+  };
+}
+
+// ============ 提示词模板类型 ============
+
+/** 提示词模板 */
+export interface PromptTemplate {
+  systemPrompt: string;
+  outlinePrompt: string;
+  chapterPrompt: (chapter: OutlineChapter, context: BookOutline) => string;
+  forewordPrompt: (book: BookBase) => string;
+  afterwordPrompt: (book: BookBase) => string;
+}
+
+// ============ 存储结构 ============
+
+/** 内存存储 */
+export interface BookStore {
+  books: Map<string, Book>;
+  tasks: Map<string, GenerateTask>;
+}
+
+/** 存储键名 */
+export const StoreKeys = {
+  BOOK_PREFIX: 'book:',
+  TASK_PREFIX: 'task:',
+  TASK_QUEUE: 'task:queue',
+} as const;

+ 316 - 0
server/src/modules/book-generator/book-generator.workflow.ts

@@ -0,0 +1,316 @@
+/**
+ * 书籍生成工作流引擎
+ * 参考 LangGraph 思路:状态机 + 条件边 + 节点执行
+ */
+
+import { Book, BookOutline, Chapter, GenerateTask } from './book-generator.types';
+import { bookGeneratorService } from './book-generator.service';
+import { bookStore } from './book-generator.store';
+
+// ============ 工作流状态 ============
+
+/** 工作流节点类型 */
+export type WorkflowNode = 'idle' | 'planning' | 'writing_chapter' | 'writing_foreword' | 'writing_afterword' | 'finished' | 'failed';
+
+/** 工作流边类型 */
+export type WorkflowEdge =
+  | 'plan_next'           // 规划下一步
+  | 'write_next_chapter'  // 写下一章
+  | 'finish_chapter'       // 本章写完
+  | 'all_chapters_done'    // 全部章节写完
+  | 'finish_foreword'       // 前言写完
+  | 'finish_afterword'      // 后记写完
+  | 'error';               // 发生错误
+
+/** 工作流状态 */
+export interface WorkflowState {
+  bookId: string;
+  currentNode: WorkflowNode;
+  currentChapter: number;      // 当前在写第几章
+  totalChapters: number;
+  completedChapters: number;
+  pendingChapters: number[];   // 待生成的章节列表
+  failedChapters: number[];   // 失败的章节列表
+  phase: 'planning' | 'writing' | 'supplement' | 'done';
+  error?: string;
+  startedAt: Date;
+  updatedAt: Date;
+}
+
+// ============ 工作流引擎 ============
+
+class BookWorkflowEngine {
+  private workflows: Map<string, WorkflowState> = new Map();
+
+  /**
+   * 启动工作流
+   */
+  startWorkflow(bookId: string, totalChapters: number): WorkflowState {
+    const state: WorkflowState = {
+      bookId,
+      currentNode: 'idle',
+      currentChapter: 0,
+      totalChapters,
+      completedChapters: 0,
+      pendingChapters: Array.from({ length: totalChapters }, (_, i) => i + 1),
+      failedChapters: [],
+      phase: 'planning',
+      startedAt: new Date(),
+      updatedAt: new Date(),
+    };
+    this.workflows.set(bookId, state);
+    return state;
+  }
+
+  /**
+   * 获取工作流状态
+   */
+  getState(bookId: string): WorkflowState | undefined {
+    return this.workflows.get(bookId);
+  }
+
+  /**
+   * 更新工作流状态
+   */
+  private updateState(bookId: string, updates: Partial<WorkflowState>): WorkflowState | undefined {
+    const state = this.workflows.get(bookId);
+    if (!state) return undefined;
+
+    Object.assign(state, updates, { updatedAt: new Date() });
+    return state;
+  }
+
+  /**
+   * 执行规划阶段
+   */
+  async executePlanningPhase(bookId: string): Promise<{ success: boolean; outline?: BookOutline; error?: string }> {
+    const state = this.workflows.get(bookId);
+    if (!state) return { success: false, error: '工作流不存在' };
+
+    this.updateState(bookId, { currentNode: 'planning', phase: 'planning' });
+
+    try {
+      const outline = await bookGeneratorService.generateOutline(bookId);
+
+      // 更新工作流状态
+      this.updateState(bookId, {
+        currentNode: 'idle',
+        phase: 'writing',
+        pendingChapters: outline.chapters.map(c => c.number),
+      });
+
+      return { success: true, outline };
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '规划失败';
+      this.updateState(bookId, { currentNode: 'failed', error: errorMessage });
+      return { success: false, error: errorMessage };
+    }
+  }
+
+  /**
+   * 执行章节生成 - 单步
+   * 返回下一步应该执行什么
+   */
+  async executeNextChapter(bookId: string): Promise<{
+    done: boolean;
+    chapter?: Chapter;
+    nextChapter?: number;
+    error?: string;
+  }> {
+    const state = this.workflows.get(bookId);
+    if (!state) return { done: false, error: '工作流不存在' };
+
+    if (state.pendingChapters.length === 0) {
+      return { done: true };
+    }
+
+    // 取下一个待生成的章节
+    const nextChapterNum = state.pendingChapters[0];
+
+    this.updateState(bookId, {
+      currentNode: 'writing_chapter',
+      currentChapter: nextChapterNum,
+    });
+
+    try {
+      const chapter = await bookGeneratorService.generateChapter(bookId, nextChapterNum);
+
+      // 更新工作流状态
+      const newPending = state.pendingChapters.filter(n => n !== nextChapterNum);
+      this.updateState(bookId, {
+        currentNode: 'idle',
+        completedChapters: state.completedChapters + 1,
+        pendingChapters: newPending,
+      });
+
+      if (newPending.length === 0) {
+        // 全部章节完成
+        this.updateState(bookId, { phase: 'supplement' });
+        return { done: true, chapter };
+      }
+
+      return { done: false, chapter, nextChapter: newPending[0] };
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '生成失败';
+
+      // 标记本章失败,继续下一章
+      const newPending = state.pendingChapters.filter(n => n !== nextChapterNum);
+      const newFailed = [...state.failedChapters, nextChapterNum];
+
+      this.updateState(bookId, {
+        currentNode: 'idle',
+        pendingChapters: newPending,
+        failedChapters: newFailed,
+      });
+
+      if (newPending.length === 0) {
+        return { done: true, error: `第${nextChapterNum}章失败` };
+      }
+
+      return { done: false, error: `第${nextChapterNum}章失败: ${errorMessage}` };
+    }
+  }
+
+  /**
+   * 执行全部章节生成(自动循环)
+   */
+  async executeAllChapters(bookId: string, onProgress?: (progress: number, chapterNum: number) => void): Promise<{
+    success: boolean;
+    completedCount: number;
+    failedCount: number;
+    errors: string[];
+  }> {
+    const state = this.workflows.get(bookId);
+    if (!state) return { success: false, completedCount: 0, failedCount: 0, errors: ['工作流不存在'] };
+
+    const errors: string[] = [];
+
+    while (true) {
+      const currentState = this.workflows.get(bookId);
+      if (!currentState || currentState.pendingChapters.length === 0) {
+        break;
+      }
+
+      const nextChapter = currentState.pendingChapters[0];
+
+      if (onProgress) {
+        const progress = Math.round((currentState.completedChapters / currentState.totalChapters) * 100);
+        onProgress(progress, nextChapter);
+      }
+
+      try {
+        await bookGeneratorService.generateChapter(bookId, nextChapter);
+
+        const updated = this.workflows.get(bookId);
+        if (updated) {
+          this.updateState(bookId, {
+            completedChapters: updated.completedChapters + 1,
+            pendingChapters: updated.pendingChapters.filter(n => n !== nextChapter),
+          });
+        }
+      } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : '生成失败';
+        errors.push(`第${nextChapter}章: ${errorMessage}`);
+
+        const updated = this.workflows.get(bookId);
+        if (updated) {
+          this.updateState(bookId, {
+            pendingChapters: updated.pendingChapters.filter(n => n !== nextChapter),
+            failedChapters: [...updated.failedChapters, nextChapter],
+          });
+        }
+      }
+    }
+
+    this.updateState(bookId, { phase: 'supplement' });
+
+    const finalState = this.workflows.get(bookId);
+    return {
+      success: errors.length === 0,
+      completedCount: finalState?.completedChapters || 0,
+      failedCount: finalState?.failedChapters?.length || 0,
+      errors,
+    };
+  }
+
+  /**
+   * 生成前言
+   */
+  async executeForeword(bookId: string): Promise<{ success: boolean; foreword?: string; error?: string }> {
+    this.updateState(bookId, { currentNode: 'writing_foreword' });
+
+    try {
+      const foreword = await bookGeneratorService.generateForeword(bookId);
+      this.updateState(bookId, { currentNode: 'idle' });
+      return { success: true, foreword };
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '生成前言失败';
+      return { success: false, error: errorMessage };
+    }
+  }
+
+  /**
+   * 生成后记
+   */
+  async executeAfterword(bookId: string): Promise<{ success: boolean; afterword?: string; error?: string }> {
+    this.updateState(bookId, { currentNode: 'writing_afterword' });
+
+    try {
+      const afterword = await bookGeneratorService.generateAfterword(bookId);
+      this.updateState(bookId, { currentNode: 'idle' });
+      return { success: true, afterword };
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '生成后记失败';
+      return { success: false, error: errorMessage };
+    }
+  }
+
+  /**
+   * 完成工作流
+   */
+  finishWorkflow(bookId: string): WorkflowState | undefined {
+    const state = this.workflows.get(bookId);
+    if (!state) return undefined;
+
+    this.updateState(bookId, { currentNode: 'finished', phase: 'done' });
+    return this.workflows.get(bookId);
+  }
+
+  /**
+   * 获取工作流进度
+   */
+  getProgress(bookId: string): {
+    phase: string;
+    currentNode: string;
+    currentChapter: number;
+    completedChapters: number;
+    totalChapters: number;
+    pendingChapters: number[];
+    failedChapters: number[];
+    progress: number;
+  } | null {
+    const state = this.workflows.get(bookId);
+    if (!state) return null;
+
+    return {
+      phase: state.phase,
+      currentNode: state.currentNode,
+      currentChapter: state.currentChapter,
+      completedChapters: state.completedChapters,
+      totalChapters: state.totalChapters,
+      pendingChapters: state.pendingChapters,
+      failedChapters: state.failedChapters,
+      progress: Math.round((state.completedChapters / state.totalChapters) * 100),
+    };
+  }
+
+  /**
+   * 取消工作流
+   */
+  cancelWorkflow(bookId: string): boolean {
+    return this.workflows.delete(bookId);
+  }
+}
+
+// 导出单例
+export const workflowEngine = new BookWorkflowEngine();

+ 7 - 0
server/src/modules/book-generator/index.ts

@@ -0,0 +1,7 @@
+/**
+ * 书籍生成模块 - 入口文件
+ */
+
+export * from './book-generator.types';
+export * from './book-generator.service';
+export { default as bookGeneratorRouter } from './book-generator.controller';

+ 315 - 0
server/src/modules/book-generator/langgraph/book-langgraph.ts

@@ -0,0 +1,315 @@
+/**
+ * LangGraph 书籍生成器
+ * 使用 @langchain/langgraph v1.2.8 API
+ * 状态通过数据库传递,LangGraph 只负责流程控制
+ */
+
+import axios from 'axios';
+import { config } from '../../../config';
+import { BookGenerationState, ChapterResult } from './types';
+import { bookStore } from '../book-generator.store';
+import { Annotation, StateGraph, END } from '@langchain/langgraph';
+
+// ============ 定义 State ============
+
+const GraphState = Annotation.Root({
+  bookId: Annotation<string>,
+  topic: Annotation<string>,
+  bookScale: Annotation<string>,  // '小册子' | '标准教程' | '系统教材' | '专业厚本' | '大部头'
+  currentChapter: Annotation<number>,
+  finished: Annotation<boolean>,
+  error: Annotation<string | undefined>,
+  progress: Annotation<number>,
+});
+
+// ============ LLM 配置 ============
+
+const TEMPERATURE = 0.7;
+const MAX_RETRIES = 2;
+
+async function callLLM(prompt: string, currentModel?: string): Promise<string> {
+  const modelId = currentModel || config.models.textGeneration.defaultModel;
+
+  const modelConfig = config.models.getModel(modelId);
+  const apiKey = modelConfig?.apiKey;
+  const baseUrl = modelConfig?.baseUrl;
+
+  if (!apiKey || !baseUrl) throw new Error(`模型 ${modelId} 缺少 API 配置`);
+
+  try {
+    const response = await axios.post(
+      `${baseUrl}/chat/completions`,
+      { model: modelId, messages: [{ role: 'user', content: prompt }] },
+      { headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, timeout: 180000 }
+    );
+
+    const result = response.data?.choices?.[0]?.message?.content || '';
+    if (!result) throw new Error('API 返回内容为空');
+    return result;
+  } catch (error: any) {
+    const errorMessage = error.response?.data?.message || error.message || 'API 调用失败';
+    console.log(`[LLM] 模型 ${modelId} 调用失败: ${errorMessage}`);
+
+    // 检查是否需要切换模型
+    if (config.models.shouldSwitchModel(errorMessage)) {
+      const nextModel = config.models.getNextModel(modelId, 'text');
+      if (nextModel) {
+        console.log(`[LLM] 自动切换到模型: ${nextModel}`);
+        return callLLM(prompt, nextModel);
+      }
+    }
+
+    throw new Error(errorMessage);
+  }
+}
+
+function countWords(text: string): number {
+  return (text.match(/[\u4e00-\u9fa5]/g) || []).length;
+}
+
+function parseOutline(jsonStr: string): any {
+  try {
+    const match = jsonStr.match(/\{[\s\S]*\}/);
+    if (match) {
+      const data = JSON.parse(match[0]);
+      return {
+        mainTheme: data.mainTheme || '主题待定',
+        structureLogic: data.structureLogic || '由浅入深',
+        chapters: (data.chapters || []).map((c: any, i: number) => ({
+          number: c.number || i + 1,
+          title: c.title || `第${i + 1}章`,
+          summary: c.summary || '',
+          keyPoints: c.keyPoints || [],
+          estimatedWords: c.estimatedWords || 1000,
+        })),
+      };
+    }
+  } catch { console.error('解析大纲失败'); }
+  return null;
+}
+
+// ============ LangGraph 节点(从数据库读取状态)============
+
+// 规模对应的章节范围
+const SCALE_CHAPTER_RANGE = {
+  '800': { min: 1, max: 1, wordsPerChapter: 800 },
+  '2000': { min: 1, max: 1, wordsPerChapter: 2000 },
+  '5000': { min: 1, max: 1, wordsPerChapter: 5000 },
+  小册子: { min: 3, max: 8, wordsPerChapter: 3000 },
+  标准教程: { min: 5, max: 15, wordsPerChapter: 2500 },
+  系统教材: { min: 10, max: 20, wordsPerChapter: 2500 },
+};
+
+async function generateOutlineNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
+  console.log('[LangGraph] 生成大纲, bookId:', state.bookId, 'scale:', state.bookScale);
+
+  // 规模说明
+  const scaleDesc = {
+    '800': '短文,约800字',
+    '2000': '短文,约2000字',
+    '5000': '短文,约5000字',
+    小册子: '小册子,1-5万字,快速上手/技巧合集,3-8章',
+    标准教程: '标准教程,5-15万字,大众技能/职场工具,5-15章',
+    系统教材: '系统教材,15-30万字,完整课程/职业培训,10-20章',
+  };
+
+  const prompt = `请为书籍《${state.topic}》设计大纲。
+
+书籍规模:${scaleDesc[state.bookScale as keyof typeof scaleDesc] || '中篇'}
+
+请分析这个主题的复杂程度,决定合适的章节数量(${SCALE_CHAPTER_RANGE[state.bookScale as keyof typeof SCALE_CHAPTER_RANGE]?.min || 5}-${SCALE_CHAPTER_RANGE[state.bookScale as keyof typeof SCALE_CHAPTER_RANGE]?.max || 10}章之间),并生成完整大纲。
+
+返回JSON格式:
+{
+  "mainTheme": "主题一句话描述",
+  "structureLogic": "结构逻辑说明",
+  "chapters": [
+    {
+      "number": 1,
+      "title": "章节标题",
+      "summary": "章节摘要,50字左右",
+      "keyPoints": ["要点1", "要点2", "要点3"],
+      "estimatedWords": 预估字数
+    }
+  ]
+}
+
+请确保:
+1. 章节数根据主题实际复杂度决定,不要固定用中间值
+2. 章节之间有清晰的逻辑递进关系
+3. 每章的预估字数要符合规模要求`;
+
+  try {
+    const response = await callLLM(prompt);
+    const outline = parseOutline(response);
+
+    if (!outline) throw new Error('大纲解析失败');
+
+    // 更新书籍的总章节数
+    const totalChapters = outline.chapters.length;
+
+    // 保存大纲和创建章节到数据库
+    await bookStore.update(state.bookId, {
+      totalChapters,
+      outlineJson: JSON.stringify(outline),
+      status: 'planning',
+      progress: 5,
+    });
+
+    // 创建章节记录
+    await bookStore.createChapters(state.bookId, outline.chapters.map(c => ({
+      number: c.number,
+      title: c.title,
+      summary: c.summary,
+      keyPoints: c.keyPoints,
+      estimatedWords: c.estimatedWords,
+    })));
+
+    console.log('[LangGraph] 大纲生成完成,章节数:', outline.chapters.length);
+    return { progress: 5 };
+  } catch (error) {
+    console.error('[LangGraph] 大纲生成失败:', error);
+    return { error: error instanceof Error ? error.message : '失败', finished: true };
+  }
+}
+
+async function writeChaptersNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
+  console.log('[LangGraph] 生成章节, bookId:', state.bookId);
+
+  // 从数据库读取书籍和章节
+  const book = await bookStore.getById(state.bookId);
+  if (!book || !book.outline) {
+    console.log('[LangGraph] 无大纲或书籍,跳过章节生成');
+    return { finished: true, progress: 90 };
+  }
+
+  const chapters: ChapterResult[] = [];
+  const failedChapters: number[] = [];
+
+  for (const chapterOutline of book.outline.chapters) {
+    console.log(`[LangGraph] 生成第${chapterOutline.number}章: ${chapterOutline.title}`);
+
+    const prompt = `撰写《${state.topic}》第${chapterOutline.number}章。章节:${chapterOutline.title},概述:${chapterOutline.summary},知识点:${chapterOutline.keyPoints.join(', ')},长度${chapterOutline.estimatedWords}字左右。直接输出正文:`;
+
+    try {
+      const content = await callLLM(prompt);
+      const wordCount = countWords(content);
+
+      await bookStore.updateChapter(state.bookId, chapterOutline.number, {
+        content,
+        wordCount,
+        status: 'completed',
+      });
+
+      chapters.push({
+        number: chapterOutline.number,
+        title: chapterOutline.title,
+        content,
+        wordCount,
+        status: 'completed',
+      });
+
+      const progress = Math.round((chapters.length / book.outline.chapters.length) * 80) + 10;
+      await bookStore.update(state.bookId, { progress, status: 'generating' });
+      console.log(`[LangGraph] 第${chapterOutline.number}章完成,进度${progress}%`);
+    } catch (error) {
+      const errorMsg = error instanceof Error ? error.message : '失败';
+      await bookStore.updateChapter(state.bookId, chapterOutline.number, { status: 'failed', errorMsg });
+      failedChapters.push(chapterOutline.number);
+      console.error(`[LangGraph] 第${chapterOutline.number}章失败:`, errorMsg);
+    }
+  }
+
+  return {
+    currentChapter: book.outline.chapters.length,
+    progress: 90,
+    error: failedChapters.length > 0 ? `章节${failedChapters.join(',')}失败` : undefined,
+  };
+}
+
+async function writeForewordNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
+  console.log('[LangGraph] 生成前言, bookId:', state.bookId);
+
+  const prompt = `为《${state.topic}》写前言。主题:${state.topic},300-500字。直接输出:`;
+
+  try {
+    const foreword = await callLLM(prompt);
+    await bookStore.update(state.bookId, { foreword, progress: 95 });
+    return { progress: 95 };
+  } catch (error) {
+    console.error('[LangGraph] 前言生成失败:', error);
+    return { progress: 95 };
+  }
+}
+
+async function writeAfterwordNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
+  console.log('[LangGraph] 生成后记, bookId:', state.bookId);
+
+  const prompt = `为《${state.topic}》写后记。主题:${state.topic},300-500字。直接输出:`;
+
+  try {
+    const afterword = await callLLM(prompt);
+    await bookStore.update(state.bookId, { afterword });
+    return { finished: true, progress: 100 };
+  } catch (error) {
+    console.error('[LangGraph] 后记生成失败:', error);
+    return { finished: true, progress: 100 };
+  }
+}
+
+// ============ 创建工作流 ============
+
+function createGraph() {
+  const workflow = new StateGraph(GraphState)
+    .addNode('generate_outline', generateOutlineNode)
+    .addNode('write_chapters', writeChaptersNode)
+    .addNode('write_foreword', writeForewordNode)
+    .addNode('write_afterword', writeAfterwordNode)
+
+    .setEntryPoint('generate_outline')
+
+    // 顺序流程
+    .addEdge('generate_outline', 'write_chapters')
+    .addEdge('write_chapters', 'write_foreword')
+    .addEdge('write_foreword', 'write_afterword')
+    .addEdge('write_afterword', END);
+
+  return workflow.compile();
+}
+
+// ============ 主类 ============
+
+export class LangGraphBookGenerator {
+  private graph = createGraph();
+
+  async generate(bookId: string, topic: string, bookScale: string = 'medium'): Promise<void> {
+    const initialState = {
+      bookId,
+      topic,
+      bookScale,
+      currentChapter: 0,
+      finished: false,
+      error: undefined,
+      progress: 0,
+    };
+
+    await bookStore.update(bookId, { status: 'generating', progress: 0 });
+
+    try {
+      const stream = await this.graph.stream(initialState);
+
+      for await (const step of stream) {
+        console.log('[LangGraph] Step:', Object.keys(step));
+      }
+
+      await bookStore.update(bookId, { status: 'completed', progress: 100 });
+      await bookStore.publishAlbum(bookId); // 发布专辑
+      console.log('[LangGraph] 书籍生成完成');
+    } catch (error) {
+      console.error('[LangGraph] 生成失败:', error);
+      await bookStore.update(bookId, { status: 'failed', errorMsg: error instanceof Error ? error.message : '生成失败' });
+    }
+  }
+}
+
+export const langGraphGenerator = new LangGraphBookGenerator();

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

@@ -0,0 +1,121 @@
+/**
+ * LangGraph 书籍生成 - API 路由
+ */
+
+import Router from '@koa/router';
+import { Context } from 'koa';
+import { langGraphGenerator } from './book-langgraph';
+import { bookStore } from '../book-generator.store';
+
+const router = new Router();
+
+/**
+ * POST /api/book-generator/langgraph/books
+ * 使用 LangGraph 创建并生成书籍
+ */
+router.post('/books', async (ctx: Context) => {
+  try {
+    const body = ctx.request.body as {
+      title: string;
+      description: string;
+      bookScale?: 'short' | 'medium' | 'long';
+      generateForeword?: boolean;
+      generateAfterword?: boolean;
+    };
+
+    if (!body.title || !body.description) {
+      ctx.status = 400;
+      ctx.body = { code: 1, message: '书名和描述不能为空' };
+      return;
+    }
+
+    const bookScale = body.bookScale || '标准教程';
+
+    // 创建书籍(先设置一个预估章节数,实际数量由AI分析后确定)
+    const scaleToChapters: Record<string, number> = {
+      '800': 1,
+      '2000': 1,
+      '5000': 1,
+      小册子: 5,
+      标准教程: 10,
+      系统教材: 15,
+    };
+    const estimatedChapters = scaleToChapters[bookScale] || 10;
+    const book = await bookStore.create({
+      title: body.title,
+      description: body.description,
+      totalChapters: estimatedChapters,
+    });
+
+    // 启动 LangGraph 生成(异步,不阻塞)
+    langGraphGenerator.generate(
+      book.id,
+      body.description,
+      bookScale
+    ).catch(err => {
+      console.error('[LangGraph] 生成失败:', err);
+    });
+
+    ctx.body = {
+      code: 0,
+      message: 'LangGraph 生成任务已启动',
+      data: {
+        bookId: book.id,
+        taskId: `lg_${book.id}_${Date.now()}`,
+        status: 'started',
+      },
+    };
+  } catch (error) {
+    console.error('启动失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '启动失败',
+    };
+  }
+});
+
+/**
+ * POST /api/book-generator/langgraph/books/:id/generate
+ * 对已有书籍使用 LangGraph 生成
+ */
+router.post('/books/:id/generate', async (ctx: Context) => {
+  try {
+    const bookId = ctx.params.id as string;
+    const book = await bookStore.getById(bookId);
+
+    if (!book) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
+    // 启动 LangGraph 生成
+    langGraphGenerator.generate(
+      bookId,
+      book.description,
+      '标准教程'  // 固定为标准教程规模
+    ).catch(err => {
+      console.error('[LangGraph] 生成失败:', err);
+    });
+
+    ctx.body = {
+      code: 0,
+      message: 'LangGraph 生成任务已启动',
+      data: {
+        bookId,
+        taskId: `lg_${bookId}_${Date.now()}`,
+        status: 'started',
+      },
+    };
+  } catch (error) {
+    console.error('启动失败:', error);
+    ctx.status = 500;
+    ctx.body = {
+      code: 1,
+      message: error instanceof Error ? error.message : '启动失败',
+    };
+  }
+});
+
+export default router;

+ 6 - 0
server/src/modules/book-generator/langgraph/index.ts

@@ -0,0 +1,6 @@
+/**
+ * LangGraph 书籍生成模块
+ */
+
+export * from './types';
+export * from './book-langgraph';

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

@@ -0,0 +1,45 @@
+/**
+ * LangGraph 书籍生成 - 类型定义
+ */
+
+import { Book, BookOutline, Chapter } from '../book-generator.types';
+
+// 书籍规模
+export type BookScale = '800' | '2000' | '5000' | '小册子' | '标准教程' | '系统教材' | '专业厚本' | '大部头';
+
+// LangGraph 状态
+export interface BookGenerationState {
+  // 输入
+  bookId: string;
+  topic: string;
+  bookScale: BookScale;
+
+  // 规划阶段
+  outline?: BookOutline;
+  outlineError?: string;
+
+  // 章节阶段
+  currentChapter: number;
+  chapters: ChapterResult[];
+  failedChapters: number[];
+
+  // 补充阶段
+  foreword?: string;
+  afterword?: string;
+
+  // 完成标志
+  finished: boolean;
+  error?: string;
+
+  // 进度
+  progress: number;
+}
+
+export interface ChapterResult {
+  number: number;
+  title: string;
+  content?: string;
+  wordCount: number;
+  status: 'pending' | 'completed' | 'failed';
+  error?: string;
+}

+ 225 - 0
server/src/modules/video-generator/README.md

@@ -0,0 +1,225 @@
+# 视频生成模块
+
+## 功能概述
+
+基于 FFmpeg 实现图片+音频生成配图视频功能,支持 Ken Burns 效果、字幕叠加、背景音乐混音等。
+
+## 核心能力
+
+- ✅ **Ken Burns 效果**:慢速缩放/平移动画,让静态图片"动起来"
+- ✅ **音频同步**:图片切换与音频时长自动匹配
+- ✅ **字幕叠加**:可选添加文字字幕
+- ✅ **背景音乐**:可选添加背景 BGM 混音
+- ✅ **多图轮播**:支持多个图片自动切换
+- ✅ **多种尺寸**:支持竖屏(9:16)、横屏(16:9)、方形(1:1)
+
+## 技术栈
+
+- **后端**:Node.js + TypeScript + fluent-ffmpeg
+- **前端**:uniapp-vue3
+- **数据库**:MySQL + Prisma ORM
+- **视频处理**:FFmpeg(开源免费,CPU 计算)
+
+## 项目结构
+
+```
+server/src/modules/video-generator/
+├── video-generator.controller.ts  # API 路由控制器
+├── video-generator.service.ts     # 业务逻辑
+├── video-generator.types.ts       # 类型定义
+├── video-generator.ffmpeg.ts     # FFmpeg 命令封装
+└── README.md                     # 模块文档
+
+my-uniapp-vue3/src/
+├── pages/video-generator/
+│   ├── index.vue      # 视频项目列表页
+│   ├── create.vue    # 创建/编辑视频页
+│   └── preview.vue   # 视频预览页
+├── utils/video-generator-api.ts  # 前端 API 调用封装
+└── types/video-generator.ts       # 前端类型定义
+```
+
+## API 接口
+
+### 视频项目管理
+
+| 接口 | 方法 | 描述 |
+|------|------|------|
+| `/api/video/projects` | GET | 获取视频项目列表 |
+| `/api/video/projects` | POST | 创建视频项目 |
+| `/api/video/projects/:id` | GET | 获取项目详情 |
+| `/api/video/projects/:id` | PUT | 更新项目配置 |
+| `/api/video/projects/:id` | DELETE | 删除项目 |
+| `/api/video/projects/:id/generate` | POST | 开始生成视频 |
+| `/api/video/projects/:id/status` | GET | 获取生成状态 |
+
+### 素材管理
+
+| 接口 | 方法 | 描述 |
+|------|------|------|
+| `/api/video/materials` | GET | 获取素材列表 |
+| `/api/video/materials/upload` | POST | 上传素材 |
+| `/api/video/materials/:id` | DELETE | 删除素材 |
+
+### 快捷入口
+
+| 接口 | 方法 | 描述 |
+|------|------|------|
+| `/api/video/books/:bookId/generate` | POST | 从书籍生成视频 |
+
+## 使用流程
+
+### 1. 创建视频项目
+
+```bash
+curl -X POST http://localhost:3000/api/video/projects \
+  -H 'Content-Type: application/json' \
+  -d '{
+    "title": "我的有声书视频",
+    "description": "测试视频描述",
+    "config": {
+      "images": [{"url": "https://xxx.jpg", "duration": 5, "transition": "fade"}],
+      "audio": {"url": "https://xxx.mp3", "volume": 1},
+      "video": {"width": 720, "height": 1280, "fps": 30, "bitrate": "2M", "format": "mp4"},
+      "kenburns": {"enabled": true, "minZoom": 1.0, "maxZoom": 1.2}
+    }
+  }'
+```
+
+### 2. 启动生成
+
+```bash
+curl -X POST http://localhost:3000/api/video/projects/1/generate
+```
+
+### 3. 查询进度
+
+```bash
+curl http://localhost:3000/api/video/projects/1/status
+```
+
+## 前端页面
+
+### 列表页 (`/pages/video-generator/index`)
+- 显示所有视频项目
+- 创建新视频按钮
+- 项目状态和进度显示
+- 预览、删除、重试操作
+
+### 创建页 (`/pages/video-generator/create`)
+- **步骤1**:选择素材(图片+音频)
+- **步骤2**:配置效果(尺寸、Ken Burns、字幕)
+- **步骤3**:生成视频并查看进度
+
+### 预览页 (`/pages/video-generator/preview`)
+- 视频播放器
+- 下载和分享功能
+- 重新生成选项
+
+## FFmpeg 命令示例
+
+### Ken Burns 效果
+
+```bash
+ffmpeg -loop 1 -i image.jpg -i audio.mp3 \
+  -vf "zoompan=z='if(gte(zoom,1.0),min(zoom+0.001,1.2),1.0)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s=720x1280:fps=30" \
+  -c:v libx264 -preset fast -crf 18 \
+  -c:a aac -b:a 192k \
+  -pix_fmt yuv420p -shortest \
+  output.mp4
+```
+
+### 带字幕
+
+```bash
+ffmpeg -loop 1 -i image.jpg -i audio.mp3 \
+  -vf "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:color=black,drawtext=text='标题':fontsize=24:fontcolor=white:borderw=2:bordercolor=black:x=(w-text_w)/2:y=h-text_h-20" \
+  -c:v libx264 -preset fast \
+  -c:a aac -b:a 192k \
+  -pix_fmt yuv420p -shortest \
+  output.mp4
+```
+
+## 数据库模型
+
+### VideoProject
+
+| 字段 | 类型 | 描述 |
+|------|------|------|
+| id | Int | 主键 |
+| userId | Int? | 用户ID |
+| title | String | 项目标题 |
+| description | String? | 描述 |
+| coverUrl | String? | 封面图 |
+| configJson | String? | 配置JSON |
+| outputUrl | String? | 输出视频URL |
+| duration | Int? | 视频时长(秒) |
+| fileSize | Int? | 文件大小(字节) |
+| bookId | Int? | 关联书籍ID |
+| audioId | Int? | 关联音频ID |
+| status | String | 状态 |
+| progress | Int | 生成进度 |
+
+### VideoMaterial
+
+| 字段 | 类型 | 描述 |
+|------|------|------|
+| id | Int | 主键 |
+| userId | Int? | 用户ID(null=公共素材) |
+| type | String | 类型(image/audio/template) |
+| name | String | 素材名称 |
+| url | String | 素材URL |
+| thumbnail | String? | 缩略图 |
+| tags | String? | 标签(JSON数组) |
+| category | String? | 分类 |
+
+## 待优化项
+
+- [ ] 实现素材库功能(图片/音频素材管理)
+- [ ] 添加更多转场效果(滑动、缩放等)
+- [ ] 实现视频剪辑(截取音频片段)
+- [ ] 添加更多字幕样式(描边、阴影等)
+- [ ] 集成 AI 图片生成(根据描述生成配图)
+- [ ] 实现视频压缩优化
+- [ ] 添加水印功能
+
+## 注意事项
+
+1. **FFmpeg 依赖**:确保服务器已安装 FFmpeg
+2. **临时文件**:定期清理 `temp/` 目录下的临时文件
+3. **文件大小**:建议单次生成视频不超过 5 分钟
+4. **并发限制**:建议限制同时生成的视频数量(防止资源耗尽)
+
+## 部署
+
+### 1. 安装 FFmpeg
+
+**macOS**:
+```bash
+brew install ffmpeg
+```
+
+**Ubuntu/Debian**:
+```bash
+sudo apt update
+sudo apt install ffmpeg
+```
+
+**Windows**:
+从 https://ffmpeg.org/download.html 下载并配置环境变量
+
+### 2. 创建输出目录
+
+```bash
+mkdir -p public/videos
+mkdir -p temp/images
+mkdir -p temp/audio
+```
+
+### 3. 运行服务
+
+```bash
+cd server
+npm install
+npm run dev
+```

+ 243 - 0
server/src/modules/video-generator/video-generator.controller.ts

@@ -0,0 +1,243 @@
+/**
+ * 视频生成模块 - API 路由控制器
+ */
+
+import Router from '@koa/router';
+import {
+  createVideoProject,
+  getVideoProjects,
+  getVideoProject,
+  updateVideoProject,
+  deleteVideoProject,
+  generateVideoForProject,
+  getGenerateProgress,
+  getMaterials,
+  uploadMaterial,
+  deleteMaterial,
+  createVideoProjectFromBook,
+} from './video-generator.service';
+
+const router = new Router();
+
+// ============ 视频项目管理 ============
+
+/**
+ * GET /api/video/projects
+ * 获取视频项目列表
+ */
+router.get('/projects', async (ctx) => {
+  const query = {
+    userId: ctx.query.userId ? Number(ctx.query.userId) : undefined,
+    status: ctx.query.status as any,
+    page: ctx.query.page ? Number(ctx.query.page) : 1,
+    pageSize: ctx.query.pageSize ? Number(ctx.query.pageSize) : 10,
+  };
+
+  const result = await getVideoProjects(query);
+  ctx.body = { success: true, data: result };
+});
+
+/**
+ * POST /api/video/projects
+ * 创建视频项目
+ */
+router.post('/projects', async (ctx) => {
+  const body = ctx.request.body as any;
+  const userId = ctx.state.user?.id;
+
+  const project = await createVideoProject(body, userId);
+  ctx.body = { success: true, data: project };
+});
+
+/**
+ * GET /api/video/projects/:id
+ * 获取视频项目详情
+ */
+router.get('/projects/:id', async (ctx) => {
+  const id = Number(ctx.params.id);
+  const project = await getVideoProject(id);
+
+  if (!project) {
+    ctx.status = 404;
+    ctx.body = { success: false, error: '项目不存在' };
+    return;
+  }
+
+  ctx.body = { success: true, data: project };
+});
+
+/**
+ * PUT /api/video/projects/:id
+ * 更新视频项目
+ */
+router.put('/projects/:id', async (ctx) => {
+  const id = Number(ctx.params.id);
+  const body = ctx.request.body as any;
+
+  const project = await updateVideoProject(id, body);
+
+  if (!project) {
+    ctx.status = 404;
+    ctx.body = { success: false, error: '项目不存在' };
+    return;
+  }
+
+  ctx.body = { success: true, data: project };
+});
+
+/**
+ * DELETE /api/video/projects/:id
+ * 删除视频项目
+ */
+router.delete('/projects/:id', async (ctx) => {
+  const id = Number(ctx.params.id);
+  const success = await deleteVideoProject(id);
+
+  if (!success) {
+    ctx.status = 404;
+    ctx.body = { success: false, error: '项目不存在' };
+    return;
+  }
+
+  ctx.body = { success: true };
+});
+
+// ============ 视频生成 ============
+
+/**
+ * POST /api/video/projects/:id/generate
+ * 开始生成视频
+ */
+router.post('/projects/:id/generate', async (ctx) => {
+  const id = Number(ctx.params.id);
+  const result = await generateVideoForProject(id);
+
+  if (!result.success) {
+    ctx.status = 400;
+    ctx.body = { success: false, error: result.error };
+    return;
+  }
+
+  ctx.body = {
+    success: true,
+    data: {
+      outputUrl: result.outputUrl,
+      duration: result.duration,
+      fileSize: result.fileSize,
+    },
+  };
+});
+
+/**
+ * GET /api/video/projects/:id/status
+ * 获取生成状态
+ */
+router.get('/projects/:id/status', async (ctx) => {
+  const id = Number(ctx.params.id);
+  const status = await getGenerateProgress(id);
+
+  ctx.body = { success: true, data: status };
+});
+
+// ============ 素材管理 ============
+
+/**
+ * GET /api/video/materials
+ * 获取素材列表
+ */
+router.get('/materials', async (ctx) => {
+  const query = {
+    userId: ctx.query.userId ? Number(ctx.query.userId) : undefined,
+    type: ctx.query.type as any,
+    category: ctx.query.category as string,
+    page: ctx.query.page ? Number(ctx.query.page) : 1,
+    pageSize: ctx.query.pageSize ? Number(ctx.query.pageSize) : 20,
+  };
+
+  const result = await getMaterials(query);
+  ctx.body = { success: true, data: result };
+});
+
+/**
+ * POST /api/video/materials/upload
+ * 上传素材(处理 multipart/form-data 文件上传)
+ */
+router.post('/materials/upload', async (ctx) => {
+  const userId = ctx.state.user?.id;
+
+  // 处理 multipart form data
+  const body = ctx.request.body as any;
+  const files = (ctx.request as any).files as any;
+
+  // 获取上传的文件
+  const file = files?.file;
+  
+  let materialUrl = '';
+  
+  if (file) {
+    // 文件已上传,url 就是文件的路径
+    materialUrl = `/uploads/materials/${file.newFilename || file.filename}`;
+  } else if (body.url) {
+    // 如果没有文件,使用传入的url
+    materialUrl = body.url;
+  } else {
+    ctx.status = 400;
+    ctx.body = { success: false, error: 'No file uploaded' };
+    return;
+  }
+
+  const material = await uploadMaterial({
+    type: body.type || 'image',
+    name: body.name || file?.newFilename || 'unnamed',
+    url: materialUrl,
+    thumbnail: body.thumbnail,
+    tags: body.tags ? JSON.parse(body.tags) : [],
+    category: body.category,
+    duration: body.duration ? Number(body.duration) : undefined,
+    size: body.size ? Number(body.size) : undefined,
+    width: body.width ? Number(body.width) : undefined,
+    height: body.height ? Number(body.height) : undefined,
+  }, userId);
+
+  ctx.body = { success: true, data: material };
+});
+
+/**
+ * DELETE /api/video/materials/:id
+ * 删除素材
+ */
+router.delete('/materials/:id', async (ctx) => {
+  const id = Number(ctx.params.id);
+  const success = await deleteMaterial(id);
+
+  if (!success) {
+    ctx.status = 404;
+    ctx.body = { success: false, error: '素材不存在' };
+    return;
+  }
+
+  ctx.body = { success: true };
+});
+
+// ============ 快捷入口 ============
+
+/**
+ * POST /api/video/books/:bookId/generate
+ * 从书籍生成视频项目
+ */
+router.post('/books/:bookId/generate', async (ctx) => {
+  const bookId = Number(ctx.params.bookId);
+  const userId = ctx.state.user?.id;
+
+  const project = await createVideoProjectFromBook(bookId, userId);
+
+  if (!project) {
+    ctx.status = 404;
+    ctx.body = { success: false, error: '书籍不存在' };
+    return;
+  }
+
+  ctx.body = { success: true, data: project };
+});
+
+export default router;

+ 299 - 0
server/src/modules/video-generator/video-generator.ffmpeg.ts

@@ -0,0 +1,299 @@
+/**
+ * 视频生成模块 - FFmpeg 命令封装
+ * 实现 Ken Burns 效果、图片+音频合成等功能
+ */
+
+import ffmpeg from 'fluent-ffmpeg';
+import path from 'path';
+import fs from 'fs';
+import { VideoConfig } from './video-generator.types';
+
+const OUTPUT_DIR = path.join(process.cwd(), 'public', 'videos');
+const TEMP_DIR = path.join(process.cwd(), 'temp');
+
+// 确保目录存在
+function ensureDirectories() {
+  if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
+  if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
+}
+
+/**
+ * 合成图片+音频生成最终视频
+ */
+export async function generateVideo(
+  imagePath: string,
+  audioPath: string,
+  outputPath: string,
+  config: VideoConfig
+): Promise<{ duration: number; fileSize: number }> {
+  ensureDirectories();
+
+  return new Promise((resolve, reject) => {
+    const command = ffmpeg();
+    command
+      .input(imagePath)
+      .inputOptions(['-loop', '1'])
+      .input(audioPath);
+
+    // 构建视频滤镜
+    const videoFilters: any[] = [];
+
+    // Ken Burns 效果
+    if (config.kenburns?.enabled) {
+      const kb = config.kenburns;
+      videoFilters.push({
+        filter: 'zoompan',
+        options: {
+          z: `if(gte(zoom,${kb.minZoom}),min(zoom+0.001,${kb.maxZoom}),${kb.minZoom})`,
+          x: 'iw/2-(iw/zoom/2)',
+          y: 'ih/2-(ih/zoom/2)',
+          d: 1,
+          s: `${config.video.width}x${config.video.height}`,
+          fps: config.video.fps,
+        },
+      });
+    }
+
+    // 缩放和填充
+    videoFilters.push({
+      filter: 'scale',
+      options: `${config.video.width}:${config.video.height}:force_original_aspect_ratio=decrease`,
+    });
+    videoFilters.push({
+      filter: 'pad',
+      options: `${config.video.width}:${config.video.height}:(ow-iw)/2:(oh-ih)/2:color=black`,
+    });
+
+    // 字幕
+    if (config.subtitle) {
+      const sub = config.subtitle;
+      const yPos = sub.position === 'top' ? 20 : sub.position === 'center' ? (config.video.height - 40) / 2 : config.video.height - 60;
+      videoFilters.push({
+        filter: 'drawtext',
+        options: {
+          text: sub.text,
+          fontsize: sub.fontSize || 24,
+          fontcolor: sub.fontColor || 'white',
+          borderw: 2,
+          bordercolor: 'black',
+          x: '(w-text_w)/2',
+          y: yPos,
+        },
+      });
+    }
+
+    command.videoFilters(videoFilters);
+
+    command
+      .outputOptions([`-af volume=${config.audio.volume}`])
+      .outputOptions([
+        '-c:v libx264',
+        '-preset fast',
+        '-crf 18',
+        '-c:a aac',
+        '-b:a 192k',
+        '-pix_fmt yuv420p',
+        '-shortest',
+      ])
+      .output(outputPath);
+
+    command.on('progress', (progress) => {
+      if (progress.percent) console.log(`Processing: ${progress.percent.toFixed(1)}% done`);
+    });
+
+    command.on('end', () => {
+      ffmpeg.ffprobe(outputPath, (err, metadata) => {
+        if (err) { reject(err); return; }
+        const stats = fs.statSync(outputPath);
+        resolve({
+          duration: Math.round(metadata.format.duration || 0),
+          fileSize: stats.size,
+        });
+      });
+    });
+
+    command.on('error', reject);
+    command.run();
+  });
+}
+
+/**
+ * 合成带背景音乐的混音视频
+ */
+export async function generateVideoWithBgm(
+  imagePath: string,
+  audioPath: string,
+  bgmPath: string,
+  outputPath: string,
+  config: VideoConfig
+): Promise<{ duration: number; fileSize: number }> {
+  ensureDirectories();
+
+  return new Promise((resolve, reject) => {
+    const command = ffmpeg();
+    command
+      .input(imagePath)
+      .inputOptions(['-loop 1'])
+      .input(audioPath)
+      .input(bgmPath)
+      .inputOptions(['-stream_loop -1']);
+
+    const videoFilters: any[] = [];
+
+    if (config.kenburns?.enabled) {
+      const kb = config.kenburns;
+      videoFilters.push({
+        filter: 'zoompan',
+        options: {
+          z: `if(gte(zoom,${kb.minZoom}),min(zoom+0.001,${kb.maxZoom}),${kb.minZoom})`,
+          x: 'iw/2-(iw/zoom/2)',
+          y: 'ih/2-(ih/zoom/2)',
+          d: 1,
+          s: `${config.video.width}x${config.video.height}`,
+          fps: config.video.fps,
+        },
+      });
+    }
+
+    videoFilters.push({
+      filter: 'scale',
+      options: `${config.video.width}:${config.video.height}:force_original_aspect_ratio=decrease`,
+    });
+    videoFilters.push({
+      filter: 'pad',
+      options: `${config.video.width}:${config.video.height}:(ow-iw)/2:(oh-ih)/2:color=black`,
+    });
+
+    command.videoFilters(videoFilters);
+
+    // 音频混音
+    const bgm = config.bgm!;
+    command.audioFilters(`[1:a]volume=${config.audio.volume}[main];[2:a]volume=${bgm.volume}[bgm];[main][bgm]amix=inputs=2:duration=longest[aout]`);
+
+    command
+      .outputOptions([
+        '-tune stillimage',
+        '-c:v libx264',
+        '-preset fast',
+        '-crf 18',
+        '-c:a aac',
+        '-b:a 192k',
+        '-pix_fmt yuv420p',
+        '-shortest',
+        '-map 0:v',
+        '-map [aout]',
+      ])
+      .output(outputPath);
+
+    command.on('end', () => {
+      ffmpeg.ffprobe(outputPath, (err, metadata) => {
+        if (err) { reject(err); return; }
+        const stats = fs.statSync(outputPath);
+        resolve({ duration: Math.round(metadata.format.duration || 0), fileSize: stats.size });
+      });
+    });
+
+    command.on('error', reject);
+    command.run();
+  });
+}
+
+/**
+ * 生成多图轮播视频
+ */
+export async function generateSlideshow(
+  images: { path: string; duration: number }[],
+  audioPath: string,
+  outputPath: string,
+  config: VideoConfig
+): Promise<{ duration: number; fileSize: number }> {
+  ensureDirectories();
+
+  const concatListPath = path.join(TEMP_DIR, `concat_${Date.now()}.txt`);
+  const concatList = images.map((img) => `file '${img.path}'`).join('\n');
+  fs.writeFileSync(concatListPath, concatList);
+
+  return new Promise((resolve, reject) => {
+    const command = ffmpeg();
+    command
+      .input(concatListPath)
+      .inputOptions(['-f concat', '-safe 0'])
+      .input(audioPath);
+
+    const videoFilters: any[] = [];
+    videoFilters.push({
+      filter: 'scale',
+      options: `${config.video.width}:${config.video.height}:force_original_aspect_ratio=decrease`,
+    });
+    videoFilters.push({
+      filter: 'pad',
+      options: `${config.video.width}:${config.video.height}:(ow-iw)/2:(oh-ih)/2:color=black`,
+    });
+
+    if (config.subtitle) {
+      const sub = config.subtitle;
+      const yPos = sub.position === 'top' ? 20 : sub.position === 'center' ? (config.video.height - 40) / 2 : config.video.height - 60;
+      videoFilters.push({
+        filter: 'drawtext',
+        options: {
+          text: sub.text,
+          fontsize: sub.fontSize || 24,
+          fontcolor: sub.fontColor || 'white',
+          borderw: 2,
+          bordercolor: 'black',
+          x: '(w-text_w)/2',
+          y: yPos,
+        },
+      });
+    }
+
+    command.videoFilters(videoFilters);
+
+    command
+      .outputOptions([
+        '-c:v libx264',
+        '-preset fast',
+        '-crf 18',
+        '-c:a aac',
+        '-b:a 192k',
+        '-pix_fmt yuv420p',
+        '-shortest',
+      ])
+      .output(outputPath);
+
+    command.on('end', () => {
+      fs.unlinkSync(concatListPath);
+      ffmpeg.ffprobe(outputPath, (err, metadata) => {
+        if (err) { reject(err); return; }
+        const stats = fs.statSync(outputPath);
+        resolve({ duration: Math.round(metadata.format.duration || 0), fileSize: stats.size });
+      });
+    });
+
+    command.on('error', (err) => {
+      fs.unlinkSync(concatListPath);
+      reject(err);
+    });
+
+    command.run();
+  });
+}
+
+/**
+ * 获取视频信息
+ */
+export async function getVideoInfo(filePath: string): Promise<{ duration: number; width: number; height: number; size: number }> {
+  return new Promise((resolve, reject) => {
+    ffmpeg.ffprobe(filePath, (err, metadata) => {
+      if (err) { reject(err); return; }
+      const videoStream = metadata.streams.find((s) => s.codec_type === 'video');
+      const stats = fs.statSync(filePath);
+      resolve({
+        duration: Math.round(metadata.format.duration || 0),
+        width: videoStream?.width || 0,
+        height: videoStream?.height || 0,
+        size: stats.size,
+      });
+    });
+  });
+}

+ 545 - 0
server/src/modules/video-generator/video-generator.service.ts

@@ -0,0 +1,545 @@
+/**
+ * 视频生成模块 - 业务逻辑
+ * 处理视频项目的增删改查和生成逻辑
+ */
+
+import { PrismaClient } from '@prisma/client';
+import path from 'path';
+import { v4 as uuidv4 } from 'uuid';
+import {
+  VideoConfig,
+  VideoProjectStatus,
+  CreateVideoProjectRequest,
+  UpdateVideoProjectRequest,
+  GetVideoProjectsQuery,
+  VideoProjectResponse,
+  VideoMaterialResponse,
+  GetMaterialsQuery,
+  parseConfig,
+  serializeConfig,
+  parseTags,
+  serializeTags,
+  PRESET_VIDEO_CONFIGS,
+} from './video-generator.types';
+import { generateVideo, generateVideoWithBgm, generateSlideshow } from './video-generator.ffmpeg';
+
+const prisma = new PrismaClient();
+
+// ============ 视频项目管理 ============
+
+/**
+ * 创建视频项目
+ */
+export async function createVideoProject(
+  data: CreateVideoProjectRequest,
+  userId?: number
+): Promise<VideoProjectResponse> {
+  const config = data.config || PRESET_VIDEO_CONFIGS.portrait;
+
+  const project = await prisma.videoProject.create({
+    data: {
+      userId: userId,
+      title: data.title,
+      description: data.description,
+      coverUrl: data.coverUrl,
+      configJson: serializeConfig(config),
+      bookId: data.bookId,
+      chapterId: data.chapterId, // 直接关联章节
+      status: 'draft',
+      progress: 0,
+    },
+  });
+
+  return {
+    ...project,
+    config,
+  } as VideoProjectResponse;
+}
+
+/**
+ * 获取视频项目列表
+ */
+export async function getVideoProjects(query: GetVideoProjectsQuery): Promise<{
+  items: VideoProjectResponse[];
+  total: number;
+  page: number;
+  pageSize: number;
+}> {
+  const page = query.page || 1;
+  const pageSize = query.pageSize || 10;
+  const skip = (page - 1) * pageSize;
+
+  const where: any = {};
+  if (query.userId) where.userId = query.userId;
+  if (query.status) where.status = query.status;
+
+  const [items, total] = await Promise.all([
+    prisma.videoProject.findMany({
+      where,
+      orderBy: { createdAt: 'desc' },
+      skip,
+      take: pageSize,
+    }),
+    prisma.videoProject.count({ where }),
+  ]);
+
+  return {
+    items: items.map((item) => ({
+      ...item,
+      config: parseConfig(item.configJson),
+    })) as VideoProjectResponse[],
+    total,
+    page,
+    pageSize,
+  };
+}
+
+/**
+ * 获取视频项目详情
+ */
+export async function getVideoProject(id: number): Promise<VideoProjectResponse | null> {
+  const project = await prisma.videoProject.findUnique({
+    where: { id },
+  });
+
+  if (!project) return null;
+
+  return {
+    ...project,
+    config: parseConfig(project.configJson),
+  } as VideoProjectResponse;
+}
+
+/**
+ * 更新视频项目
+ */
+export async function updateVideoProject(
+  id: number,
+  data: UpdateVideoProjectRequest
+): Promise<VideoProjectResponse | null> {
+  const updateData: any = {};
+
+  if (data.title !== undefined) updateData.title = data.title;
+  if (data.description !== undefined) updateData.description = data.description;
+  if (data.coverUrl !== undefined) updateData.coverUrl = data.coverUrl;
+  if (data.config !== undefined) updateData.configJson = serializeConfig(data.config);
+
+  const project = await prisma.videoProject.update({
+    where: { id },
+    data: updateData,
+  });
+
+  return {
+    ...project,
+    config: parseConfig(project.configJson),
+  } as VideoProjectResponse;
+}
+
+/**
+ * 删除视频项目
+ */
+export async function deleteVideoProject(id: number): Promise<boolean> {
+  try {
+    await prisma.videoProject.delete({
+      where: { id },
+    });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+// ============ 视频生成 ============
+
+/**
+ * 生成视频
+ */
+export async function generateVideoForProject(projectId: number): Promise<{
+  success: boolean;
+  outputUrl?: string;
+  duration?: number;
+  fileSize?: number;
+  error?: string;
+}> {
+  // 1. 获取项目
+  const project = await getVideoProject(projectId);
+  if (!project) {
+    return { success: false, error: '项目不存在' };
+  }
+
+  // 2. 检查状态
+  if (project.status === 'processing') {
+    return { success: false, error: '项目正在生成中' };
+  }
+
+  // 3. 更新状态为处理中
+  await prisma.videoProject.update({
+    where: { id: projectId },
+    data: { status: 'processing', progress: 0 },
+  });
+
+  try {
+    // 4. 获取配置
+    const config = project.config;
+    if (!config) {
+      throw new Error('缺少视频配置');
+    }
+
+    // 5. 如果有 chapterId,优先从章节获取音频
+    let audioPath = config.audio?.url;
+    let textContent = '';
+
+    if (project.chapterId) {
+      const chapter = await prisma.bookChapter.findUnique({
+        where: { id: project.chapterId },
+      });
+      if (chapter?.audioUrl) {
+        audioPath = chapter.audioUrl;
+      }
+      if (chapter?.content) {
+        textContent = chapter.content;
+      }
+    }
+
+    if (!audioPath || !config.images?.[0]?.url) {
+      throw new Error('缺少必要的素材:音频或图片');
+    }
+
+    // 转换URL为本地文件路径
+    const fs = await import('fs');
+
+    // 获取 server 目录路径
+    const serverDir = process.cwd();
+
+    // 处理图片路径
+    let imagePath = config.images[0].url;
+    // 统一处理:所有非绝对路径都加上 server 目录和 public 前缀
+    if (!imagePath.match(/^[A-Za-z]:/)) {
+      // Windows 或 Unix 相对路径
+      imagePath = path.join(serverDir, 'public', imagePath.replace(/^\//, ''));
+    }
+
+    // 处理音频路径
+    if (!audioPath.match(/^[A-Za-z]:/)) {
+      audioPath = path.join(serverDir, audioPath.replace(/^\//, ''));
+    }
+
+    // 检查文件是否存在
+    if (!fs.existsSync(imagePath)) {
+      throw new Error('图片文件不存在: ' + imagePath);
+    }
+    if (!fs.existsSync(audioPath)) {
+      throw new Error('音频文件不存在: ' + audioPath);
+    }
+
+    // 6. 生成输出路径
+    const outputFileName = `video_${projectId}_${uuidv4()}.mp4`;
+    const outputPath = path.join(process.cwd(), 'public', 'videos', outputFileName);
+
+    // 7. 更新进度
+    await prisma.videoProject.update({
+      where: { id: projectId },
+      data: { progress: 30 },
+    });
+
+    // 8. 生成视频
+    let result: { duration: number; fileSize: number };
+
+    if (config.bgm?.url) {
+      // 带背景音乐
+      const bgmPath = await downloadFile(config.bgm.url, 'audio');
+      result = await generateVideoWithBgm(imagePath, audioPath, bgmPath, outputPath, config);
+    } else {
+      // 不带背景音乐
+      result = await generateVideo(imagePath, audioPath, outputPath, config);
+    }
+
+    // 9. 更新项目状态
+    const outputUrl = `/videos/${outputFileName}`;
+    await prisma.videoProject.update({
+      where: { id: projectId },
+      data: {
+        status: 'completed',
+        progress: 100,
+        outputUrl,
+        duration: result.duration,
+        fileSize: result.fileSize,
+      },
+    });
+
+    // 10. 如果有章节关联,更新章节的视频URL
+    if (project.chapterId) {
+      await prisma.bookChapter.update({
+        where: { id: project.chapterId },
+        data: {
+          videoUrl: outputUrl,
+          videoDuration: result.duration,
+        },
+      });
+    }
+
+    return {
+      success: true,
+      outputUrl,
+      duration: result.duration,
+      fileSize: result.fileSize,
+    };
+  } catch (error: any) {
+    // 生成失败,更新状态
+    await prisma.videoProject.update({
+      where: { id: projectId },
+      data: {
+        status: 'failed',
+        errorMsg: error.message,
+      },
+    });
+
+    return {
+      success: false,
+      error: error.message,
+    };
+  }
+}
+
+/**
+ * 获取生成进度
+ */
+export async function getGenerateProgress(projectId: number): Promise<{
+  status: VideoProjectStatus;
+  progress: number;
+  outputUrl?: string;
+  duration?: number;
+  fileSize?: number;
+  errorMsg?: string;
+}> {
+  const project = await prisma.videoProject.findUnique({
+    where: { id: projectId },
+    select: {
+      status: true,
+      progress: true,
+      outputUrl: true,
+      duration: true,
+      fileSize: true,
+      errorMsg: true,
+    },
+  });
+
+  if (!project) {
+    return { status: 'failed', progress: 0, errorMsg: '项目不存在' };
+  }
+
+  return project as any;
+}
+
+// ============ 素材管理 ============
+
+/**
+ * 获取素材列表
+ */
+export async function getMaterials(query: GetMaterialsQuery): Promise<{
+  items: VideoMaterialResponse[];
+  total: number;
+}> {
+  const page = query.page || 1;
+  const pageSize = query.pageSize || 20;
+  const skip = (page - 1) * pageSize;
+
+  const where: any = {
+    OR: [{ userId: null }, { userId: query.userId || 0 }],
+  };
+  if (query.type) where.type = query.type;
+  if (query.category) where.category = query.category;
+
+  const [items, total] = await Promise.all([
+    prisma.videoMaterial.findMany({
+      where,
+      orderBy: { createdAt: 'desc' },
+      skip,
+      take: pageSize,
+    }),
+    prisma.videoMaterial.count({ where }),
+  ]);
+
+  return {
+    items: items.map((item) => ({
+      ...item,
+      tags: parseTags(item.tags),
+    })) as VideoMaterialResponse[],
+    total,
+  };
+}
+
+/**
+ * 上传素材
+ */
+export async function uploadMaterial(
+  data: {
+    type: string;
+    name: string;
+    url: string;
+    thumbnail?: string;
+    tags?: string[];
+    category?: string;
+    duration?: number;
+    size?: number;
+    width?: number;
+    height?: number;
+  },
+  userId?: number
+): Promise<VideoMaterialResponse> {
+  const material = await prisma.videoMaterial.create({
+    data: {
+      userId: userId,
+      type: data.type,
+      name: data.name,
+      url: data.url,
+      thumbnail: data.thumbnail,
+      tags: serializeTags(data.tags || []),
+      category: data.category,
+      duration: data.duration,
+      size: data.size,
+      width: data.width,
+      height: data.height,
+    },
+  });
+
+  return {
+    ...material,
+    tags: parseTags(material.tags),
+  } as VideoMaterialResponse;
+}
+
+/**
+ * 删除素材
+ */
+export async function deleteMaterial(id: number): Promise<boolean> {
+  try {
+    await prisma.videoMaterial.delete({
+      where: { id },
+    });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+// ============ 辅助函数 ============
+
+/**
+ * 下载文件到临时目录
+ */
+async function downloadFile(url: string, type: 'images' | 'audio'): Promise<string> {
+  const https = await import('https');
+  const http = await import('http');
+  const fs = await import('fs');
+
+  const tempDir = path.join(process.cwd(), 'temp', type);
+  if (!fs.existsSync(tempDir)) {
+    fs.mkdirSync(tempDir, { recursive: true });
+  }
+
+  const ext = path.extname(url) || (type === 'images' ? '.jpg' : '.mp3');
+  const filePath = path.join(tempDir, `${uuidv4()}${ext}`);
+
+  return new Promise((resolve, reject) => {
+    const protocol = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(filePath);
+
+    protocol.get(url, (response) => {
+      if (response.statusCode === 301 || response.statusCode === 302) {
+        // 重定向
+        const redirectUrl = response.headers.location;
+        if (!redirectUrl) {
+          file.close();
+          reject(new Error('重定向但没有 location header'));
+          return;
+        }
+        const redirectProtocol = redirectUrl.startsWith('https') ? https : http;
+        redirectProtocol.get(redirectUrl, (redirectResponse) => {
+          redirectResponse.pipe(file);
+          file.on('finish', () => resolve(filePath));
+        });
+      } else {
+        response.pipe(file);
+        file.on('finish', () => resolve(filePath));
+      }
+    }).on('error', reject);
+  });
+}
+
+/**
+ * 清理临时文件
+ */
+async function cleanupTempFiles(...filePaths: string[]): Promise<void> {
+  const fs = await import('fs');
+  for (const filePath of filePaths) {
+    try {
+      if (fs.existsSync(filePath)) {
+        fs.unlinkSync(filePath);
+      }
+    } catch (error) {
+      console.error(`清理文件失败: ${filePath}`, error);
+    }
+  }
+}
+
+/**
+ * 从书籍生成视频项目(基于指定章节)
+ */
+export async function createVideoProjectFromBook(bookId: number, chapterId?: number, userId?: number): Promise<VideoProjectResponse | null> {
+  // 获取书籍信息
+  const book = await prisma.book.findUnique({
+    where: { id: bookId },
+    include: { chapters: { orderBy: { number: 'asc' } } },
+  });
+
+  if (!book) return null;
+
+  // 如果没有指定章节,使用第一个有音频的章节
+  let targetChapter = book.chapters.find(c => c.audioUrl) || book.chapters[0];
+  if (chapterId) {
+    const found = book.chapters.find(c => c.id === chapterId);
+    if (found) targetChapter = found;
+  }
+
+  if (!targetChapter) return null;
+
+  // 如果章节没有音频,返回错误
+  if (!targetChapter.audioUrl) {
+    console.error(`章节 ${targetChapter.number} 没有音频`);
+    return null;
+  }
+
+  // 创建视频项目,直接关联到章节
+  const project = await createVideoProject(
+    {
+      title: `《${book.title}》第${targetChapter.number}章 视频`,
+      description: targetChapter.summary || book.description,
+      bookId: book.id,
+      chapterId: targetChapter.id,
+      config: {
+        ...PRESET_VIDEO_CONFIGS.portrait,
+        audio: {
+          url: targetChapter.audioUrl,
+          volume: 1,
+        },
+        images: [
+          {
+            url: '/images/default-cover.jpg',
+            duration: 5,
+            transition: 'fade',
+          },
+        ],
+        subtitle: {
+          text: targetChapter.title,
+          position: 'bottom',
+          fontSize: 28,
+          fontColor: 'white',
+        },
+      },
+    },
+    userId
+  );
+
+  return project;
+}

+ 307 - 0
server/src/modules/video-generator/video-generator.types.ts

@@ -0,0 +1,307 @@
+/**
+ * 视频生成模块 - 类型定义
+ * 定义视频项目、素材、配置的数据结构
+ */
+
+// ============ 核心类型 ============
+
+/** 视频项目状态 */
+export type VideoProjectStatus = 'draft' | 'processing' | 'completed' | 'failed';
+
+/** 素材类型 */
+export type MaterialType = 'image' | 'audio' | 'template';
+
+/** 转场效果 */
+export type TransitionEffect = 'none' | 'fade' | 'slide';
+
+/** 字幕位置 */
+export type SubtitlePosition = 'top' | 'bottom' | 'center';
+
+/** 图片配置项 */
+export interface ImageConfig {
+  url: string;                // 图片URL
+  duration: number;           // 显示时长(秒)
+  transition: TransitionEffect; // 转场效果
+  kenburns?: KenBurnsConfig;  // Ken Burns 效果配置
+}
+
+/** Ken Burns 效果配置 */
+export interface KenBurnsConfig {
+  enabled: boolean;
+  minZoom: number;             // 最小缩放 (如 1.0)
+  maxZoom: number;             // 最大缩放 (如 1.3)
+  panDirection?: 'in' | 'out' | 'left' | 'right' | 'random';
+  zoomCurve?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
+}
+
+/** 音频配置 */
+export interface AudioConfig {
+  url: string;                // 音频URL(必填)
+  startTime?: number;         // 音频开始时间(截取)
+  endTime?: number;           // 音频结束时间(截取)
+  volume: number;             // 音量 0-1
+}
+
+/** 背景音乐配置 */
+export interface BgmConfig {
+  url: string;                // BGM URL
+  volume: number;             // 音量 0-1
+  loop: boolean;              // 是否循环
+  fadeIn?: number;            // 淡入时长(秒)
+  fadeOut?: number;           // 淡出时长(秒)
+}
+
+/** 字幕配置 */
+export interface SubtitleConfig {
+  text: string;               // 字幕文本
+  fontSize?: number;          // 字体大小
+  fontColor?: string;         // 字体颜色
+  backgroundColor?: string;   // 背景颜色
+  position: SubtitlePosition; // 位置
+  margin?: number;            // 边距
+  style?: 'normal' | 'bold'; // 样式
+}
+
+/** 视频参数 */
+export interface VideoParams {
+  width: number;               // 视频宽度
+  height: number;              // 视频高度
+  fps: number;                 // 帧率
+  bitrate: string;             // 码率 (如 "2M")
+  format: string;              // 格式 (如 "mp4")
+}
+
+/** 完整视频配置 */
+export interface VideoConfig {
+  // 图片配置
+  images: ImageConfig[];
+
+  // 音频配置
+  audio: AudioConfig;
+
+  // 背景音乐配置(可选)
+  bgm?: BgmConfig;
+
+  // 字幕配置(可选)
+  subtitle?: SubtitleConfig;
+
+  // 视频参数
+  video: VideoParams;
+
+  // Ken Burns 全局配置
+  kenburns: KenBurnsConfig;
+}
+
+// ============ 数据库模型类型 ============
+
+/** 视频项目(数据库) */
+export interface VideoProjectDB {
+  id: number;
+  userId: number | null;
+  title: string;
+  description: string | null;
+  coverUrl: string | null;
+  configJson: string | null;
+  outputUrl: string | null;
+  duration: number | null;
+  fileSize: number | null;
+  bookId: number | null;
+  chapterId: number | null;  // 直接关联章节
+  status: VideoProjectStatus;
+  progress: number;
+  errorMsg: string | null;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+/** 视频素材(数据库) */
+export interface VideoMaterialDB {
+  id: number;
+  userId: number | null;
+  type: MaterialType;
+  name: string;
+  url: string;
+  thumbnail: string | null;
+  tags: string | null;
+  category: string | null;
+  duration: number | null;
+  size: number | null;
+  width: number | null;
+  height: number | null;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+// ============ 请求/响应类型 ============
+
+/** 创建视频项目请求 */
+export interface CreateVideoProjectRequest {
+  title: string;
+  description?: string;
+  coverUrl?: string;
+  config?: VideoConfig;
+  bookId?: number;
+  chapterId?: number;  // 直接关联章节
+}
+
+/** 更新视频项目请求 */
+export interface UpdateVideoProjectRequest {
+  title?: string;
+  description?: string;
+  coverUrl?: string;
+  config?: VideoConfig;
+}
+
+/** 生成视频请求 */
+export interface GenerateVideoRequest {
+  projectId: number;
+}
+
+/** 获取视频项目列表查询 */
+export interface GetVideoProjectsQuery {
+  userId?: number;
+  status?: VideoProjectStatus;
+  page?: number;
+  pageSize?: number;
+}
+
+/** 视频项目响应 */
+export interface VideoProjectResponse extends VideoProjectDB {
+  config?: VideoConfig;
+}
+
+/** 素材列表查询 */
+export interface GetMaterialsQuery {
+  userId?: number;
+  type?: MaterialType;
+  category?: string;
+  page?: number;
+  pageSize?: number;
+}
+
+/** 素材响应 */
+export interface VideoMaterialResponse {
+  id: number;
+  userId: number | null;
+  type: MaterialType;
+  name: string;
+  url: string;
+  thumbnail: string | null;
+  tags?: string[];
+  category: string | null;
+  duration: number | null;
+  size: number | null;
+  width: number | null;
+  height: number | null;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+/** 生成进度响应 */
+export interface GenerateProgressResponse {
+  projectId: number;
+  status: VideoProjectStatus;
+  progress: number;
+  outputUrl?: string;
+  duration?: number;
+  fileSize?: number;
+  errorMsg?: string;
+}
+
+// ============ 预设配置 ============
+
+/** 预设视频配置 */
+export const PRESET_VIDEO_CONFIGS: Record<string, VideoConfig> = {
+  // 竖屏 9:16 (短视频)
+  portrait: {
+    images: [],
+    audio: {
+      url: '',
+      volume: 1,
+    },
+    video: {
+      width: 720,
+      height: 1280,
+      fps: 30,
+      bitrate: '2M',
+      format: 'mp4',
+    },
+    kenburns: {
+      enabled: true,
+      minZoom: 1.0,
+      maxZoom: 1.2,
+    },
+  },
+  // 横屏 16:9 (横版视频)
+  landscape: {
+    images: [],
+    audio: {
+      url: '',
+      volume: 1,
+    },
+    video: {
+      width: 1920,
+      height: 1080,
+      fps: 30,
+      bitrate: '4M',
+      format: 'mp4',
+    },
+    kenburns: {
+      enabled: true,
+      minZoom: 1.0,
+      maxZoom: 1.15,
+    },
+  },
+  // 方形 1:1 (社交媒体)
+  square: {
+    images: [],
+    audio: {
+      url: '',
+      volume: 1,
+    },
+    video: {
+      width: 1080,
+      height: 1080,
+      fps: 30,
+      bitrate: '2M',
+      format: 'mp4',
+    },
+    kenburns: {
+      enabled: true,
+      minZoom: 1.0,
+      maxZoom: 1.25,
+    },
+  },
+};
+
+// ============ 工具函数 ============
+
+/** 解析配置JSON */
+export function parseConfig(json: string | null): VideoConfig | null {
+  if (!json) return null;
+  try {
+    return JSON.parse(json);
+  } catch {
+    return null;
+  }
+}
+
+/** 序列化配置为JSON */
+export function serializeConfig(config: VideoConfig): string {
+  return JSON.stringify(config);
+}
+
+/** 解析标签JSON */
+export function parseTags(tags: string | null): string[] {
+  if (!tags) return [];
+  try {
+    return JSON.parse(tags);
+  } catch {
+    return [];
+  }
+}
+
+/** 序列化标签为JSON */
+export function serializeTags(tags: string[]): string {
+  return JSON.stringify(tags);
+}

+ 115 - 0
server/src/services/llm/index.ts

@@ -0,0 +1,115 @@
+/**
+ * 统一的 LLM 服务 - 使用 LangChain 管理多模型
+ */
+
+import { ChatOpenAI } from '@langchain/openai';
+import { config } from '../../config';
+
+// 模型缓存
+const modelCache: Map<string, ChatOpenAI> = new Map();
+
+/**
+ * 获取 ChatOpenAI 实例
+ */
+export function getLLM(modelId?: string): ChatOpenAI {
+  const id = modelId || config.models.textGeneration.defaultModel;
+
+  if (modelCache.has(id)) {
+    return modelCache.get(id)!;
+  }
+
+  const modelConfig = config.models.getModel(id);
+  if (!modelConfig) {
+    throw new Error(`模型 ${id} 不存在`);
+  }
+
+  if (!modelConfig.apiKey) {
+    throw new Error(`模型 ${id} 缺少 API Key`);
+  }
+
+  const llm = new ChatOpenAI({
+    model: id,
+    apiKey: modelConfig.apiKey,
+    temperature: modelConfig.temperature,
+    maxTokens: modelConfig.maxTokens,
+    configuration: {
+      baseURL: modelConfig.baseUrl,
+    },
+  });
+
+  modelCache.set(id, llm);
+  return llm;
+}
+
+/**
+ * 统一调用 - 带自动切换
+ */
+export async function callLLM(
+  prompt: string,
+  modelId?: string
+): Promise<string> {
+  const id = modelId || config.models.textGeneration.defaultModel;
+
+  try {
+    const llm = getLLM(id);
+    const response = await llm.invoke(prompt);
+    return response.content as string;
+  } catch (error: any) {
+    const errorMessage = error?.message || '';
+
+    // 检查是否需要切换模型
+    if (config.models.shouldSwitchModel(errorMessage)) {
+      const nextModel = config.models.getNextModel(id, 'text');
+      if (nextModel) {
+        console.log(`[LLM] ${id} 失败,自动切换到 ${nextModel}`);
+        return callLLM(prompt, nextModel);
+      }
+    }
+    throw error;
+  }
+}
+
+/**
+ * 流式调用
+ */
+export async function* callLLMStream(
+  prompt: string,
+  modelId?: string
+): AsyncGenerator<string> {
+  const id = modelId || config.models.textGeneration.defaultModel;
+
+  try {
+    const llm = getLLM(id);
+    const stream = await llm.stream(prompt);
+
+    for await (const chunk of stream) {
+      yield chunk.content as string;
+    }
+  } catch (error: any) {
+    const errorMessage = error?.message || '';
+
+    if (config.models.shouldSwitchModel(errorMessage)) {
+      const nextModel = config.models.getNextModel(id, 'text');
+      if (nextModel) {
+        console.log(`[LLM] ${id} 失败,自动切换到 ${nextModel}`);
+        yield* callLLMStream(prompt, nextModel);
+        return;
+      }
+    }
+    throw error;
+  }
+}
+
+/**
+ * 获取可用模型列表
+ */
+export function getAvailableModels() {
+  return config.models.getModelsByType('text');
+}
+
+/**
+ * 清除模型缓存
+ */
+export function clearModelCache() {
+  modelCache.clear();
+}