Просмотр исходного кода

feat: 创建有声书时新增'自动生成文本/音频'开关(默认勾选)

后端:
- Book model 新增 autoGenerateContent / autoGenerateAudio 两列(默认 true)
- POST /api/book-generator/langgraph/books 接受 autoGenerateContent/autoGenerateAudio 参数
  - autoGenerateContent=false 时创建后停在 draft,不调 langGraphGenerator.generate
- AudioScanner 新增过滤:book.autoGenerateAudio=false 时跳过该书,避免 30s 兜底扫描覆盖用户意图

前端:
- pages/book-generator/create.vue 新增'自动生成设置'卡片(两个开关,默认勾选)
- utils/book-generator-api.ts 扩展 CreateBookRequest 类型
- pages/book-generator/detail.vue 在用户关闭自动生成时显示手动触发提示

Co-Authored-By: Claude <noreply@anthropic.com>
MyFramework User 2 месяцев назад
Родитель
Сommit
c30c6e9b25

+ 64 - 0
my-uniapp-vue3/src/pages/book-generator/create.vue

@@ -251,6 +251,32 @@
         <view class="param-range"><text>0.5x</text><text>2.0x</text></view>
       </view>
 
+      <!-- ===== 区域 5.5:自动生成设置 ===== -->
+      <view class="card">
+        <view class="section-label">
+          <text class="label-icon">⚙️</text>
+          <text class="label-text">自动生成设置</text>
+        </view>
+        <view class="switch-row" @click="toggleAutoContent">
+          <view class="switch-text">
+            <text class="switch-title">📝 自动生成文本</text>
+            <text class="switch-desc">关闭后,创建后停在草稿态,可在详情页手动开始</text>
+          </view>
+          <view class="switch-toggle" :class="{ active: autoGenerateContent }">
+            <view class="switch-knob"></view>
+          </view>
+        </view>
+        <view class="switch-row" @click="toggleAutoAudio">
+          <view class="switch-text">
+            <text class="switch-title">🎵 自动生成音频</text>
+            <text class="switch-desc">关闭后,文本生成完不自动入队 TTS,需手动触发</text>
+          </view>
+          <view class="switch-toggle" :class="{ active: autoGenerateAudio }">
+            <view class="switch-knob"></view>
+          </view>
+        </view>
+      </view>
+
       <!-- ===== 区域 6:高级定制(可折叠) ===== -->
       <view class="card">
         <view class="section-label collapsible" @click="showAdvancedOptions = !showAdvancedOptions">
@@ -372,6 +398,12 @@ const attemptedCreate = ref(false);
 const isEditMode = ref(false);
 const editingBookId = ref<string | null>(null);
 const recommendSource = ref<'ai' | 'template' | 'manual' | ''>('');
+
+// 自动生成开关(默认勾选:与历史行为一致)
+const autoGenerateContent = ref(true);
+const autoGenerateAudio = ref(true);
+function toggleAutoContent() { autoGenerateContent.value = !autoGenerateContent.value; }
+function toggleAutoAudio() { autoGenerateAudio.value = !autoGenerateAudio.value; }
 const newBook = ref({
   title: '',
   subtitle: '',
@@ -806,6 +838,8 @@ async function createNewBook() {
       bookType,
       genLevel,
       voiceSpeed: newBook.value.voiceSpeed !== 1.0 ? newBook.value.voiceSpeed : undefined,
+      autoGenerateContent: autoGenerateContent.value,
+      autoGenerateAudio: autoGenerateAudio.value,
     });
 
     uni.showToast({ title: '创建成功,正在跳转...', icon: 'success' });
@@ -1110,6 +1144,36 @@ onLoad(async (query: any) => {
   margin-top: 4rpx;
 }
 
+/* ========== 自动生成开关 ========== */
+.switch-row {
+  display: flex; align-items: center; justify-content: space-between;
+  padding: 20rpx 4rpx;
+  border-bottom: 1rpx solid #f3f4f6;
+}
+.switch-row:last-child { border-bottom: none; padding-bottom: 4rpx; }
+.switch-row:active { opacity: 0.7; }
+.switch-text { flex: 1; min-width: 0; padding-right: 16rpx; }
+.switch-title { display: block; font-size: 28rpx; font-weight: 600; color: #1f2937; }
+.switch-desc { display: block; font-size: 22rpx; color: #6b7280; margin-top: 6rpx; line-height: 1.5; }
+
+.switch-toggle {
+  position: relative; flex-shrink: 0;
+  width: 88rpx; height: 48rpx;
+  background: #d1d5db;
+  border-radius: 24rpx;
+  transition: background 0.2s;
+}
+.switch-toggle.active { background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); }
+.switch-knob {
+  position: absolute; top: 4rpx; left: 4rpx;
+  width: 40rpx; height: 40rpx;
+  background: #ffffff;
+  border-radius: 50%;
+  box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.2);
+  transition: transform 0.2s;
+}
+.switch-toggle.active .switch-knob { transform: translateX(40rpx); }
+
 /* ========== 底部操作区 ========== */
 .bottom-bar {
   position: fixed; bottom: 110rpx; left: 0; right: 0;

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

@@ -293,6 +293,15 @@
 
       <!-- 操作按钮 -->
       <view class="card action-card">
+        <!-- 手动触发生成提示(用户在创建时关闭了自动生成) -->
+        <view v-if="showManualContentHint" class="manual-hint">
+          <text class="manual-hint-icon">📝</text>
+          <text class="manual-hint-text">创建时已关闭自动生成文本,点击下方按钮开始生成</text>
+        </view>
+        <view v-if="showManualAudioHint" class="manual-hint">
+          <text class="manual-hint-icon">🎵</text>
+          <text class="manual-hint-text">创建时已关闭自动生成音频,点击下方按钮开始生成</text>
+        </view>
         <view class="btn-col">
           <view class="btn-row">
             <button
@@ -430,6 +439,22 @@ const allLeafAudioCompleted = computed(() => {
   );
 });
 
+// 手动触发生成文本提示:用户创建时关闭了自动生成文本,且书处于 draft/outline_completed 等未生成内容态
+const showManualContentHint = computed(() => {
+  if (!currentBook.value) return false;
+  if (currentBook.value.autoGenerateContent !== false) return false;  // 默认 true 不提示
+  const stage = currentBook.value.genStage;
+  return stage === 'draft' || stage === 'outline_completed' || stage === 'outlining' || stage === 'failed';
+});
+
+// 手动触发生成音频提示:用户创建时关闭了自动生成音频,且内容已就绪但音频为空
+const showManualAudioHint = computed(() => {
+  if (!currentBook.value) return false;
+  if (currentBook.value.autoGenerateAudio !== false) return false;  // 默认 true 不提示
+  if (!isContentCompleted.value) return false;
+  return !allLeafAudioCompleted.value;
+});
+
 // 轮询定时器
 let pollTimer: ReturnType<typeof setInterval> | null = null;
 let audioPollTimer: ReturnType<typeof setInterval> | null = null;
@@ -1303,6 +1328,17 @@ onUnmounted(() => {
 .subsection-status.status-failed { background: var(--color-error-bg, #fef2f2); color: #dc2626; }
 
 .action-card { margin-bottom: 24rpx; }
+
+/* 手动触发生成提示(用户创建时关闭了自动生成) */
+.manual-hint {
+  display: flex; align-items: center; gap: 12rpx;
+  padding: 16rpx 20rpx;
+  background: #fffbeb; border: 1rpx solid #fde68a;
+  border-radius: 12rpx;
+  margin-bottom: 16rpx;
+}
+.manual-hint-icon { font-size: 32rpx; flex-shrink: 0; }
+.manual-hint-text { flex: 1; font-size: 24rpx; color: #92400e; line-height: 1.5; }
 .btn-col { margin-bottom: 20rpx; }
 .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; }

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

@@ -25,6 +25,10 @@ export interface Book {
   isPublished?: boolean;
   genStage?: BookGenStage;    // 线性阶段状态(替代旧 status 字段)
   failedStage?: string;       // 失败阶段
+  /** 自动生成文本开关:true=创建后立即跑 langGraph,false=停在 draft 等手动触发 */
+  autoGenerateContent?: boolean;
+  /** 自动生成音频开关:true=AudioScanner 自动入队,false=用户需手动触发 */
+  autoGenerateAudio?: boolean;
   createdAt: string;
   updatedAt: string;
 }
@@ -119,6 +123,10 @@ export interface CreateBookRequest {
   bookType?: string;
   genLevel?: number;
   language?: string;
+  /** 自动生成章节文本:默认 true;关闭后创建后停在 draft 等手动触发 */
+  autoGenerateContent?: boolean;
+  /** 自动生成章节音频:默认 true;关闭后 AudioScanner 跳过,需手动触发 */
+  autoGenerateAudio?: boolean;
 }
 
 export interface ProgressResponse {

+ 6 - 0
server/prisma/migrations/20260628100000_add_auto_generate_flags/migration.sql

@@ -0,0 +1,6 @@
+-- 为 Book 表新增自动生成开关字段
+-- 用途:创建有声书时,用户可选择是否自动跑内容生成 / 音频生成
+-- 默认值 true 保证存量数据行为不变
+
+ALTER TABLE `Book` ADD COLUMN `autoGenerateContent` BOOLEAN NOT NULL DEFAULT true;
+ALTER TABLE `Book` ADD COLUMN `autoGenerateAudio` BOOLEAN NOT NULL DEFAULT true;

+ 2 - 0
server/prisma/schema.prisma

@@ -160,6 +160,8 @@ model Book {
   failedStage    String?        @db.VarChar(30)
   genStage       String         @default("draft")
   bookAnalysis   String?        @db.Text
+  autoGenerateContent Boolean   @default(true)
+  autoGenerateAudio   Boolean   @default(true)
   chapters       BookChapter[]
   favorites      Favorite[]
   videoProjects  VideoProject[]

+ 2 - 0
server/src/modules/book-generator/audio-scanner.ts

@@ -97,6 +97,8 @@ async function scanAndEnqueue(): Promise<void> {
         ttsTasks: {
           none: { status: { in: ['pending', 'processing'] } },
         },
+        // 尊重书籍自动生成音频开关:用户取消时扫描器跳过该书
+        book: { autoGenerateAudio: true },
       },
       include: { book: true },
       take: 50,  // 每次最多处理 50 条,避免一次性拉取过多

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

@@ -278,7 +278,7 @@ export async function tryAutoMerge(
     // 5. 检查该章节是否已有合并音频(幂等)
     // 但如果父章节的合并音频时长与子节时长总和不匹配,需要重新合并
     const chapter = nodeMap.get(chapterId);
-    if (chapter?.audioUrl && chapter.audioUrl.includes('_merged')) {
+    if (chapter?.audioUrl && (chapter.audioUrl.includes('/merged/chapter') || chapter.audioUrl.includes('_merged'))) {
       // 检查合并音频时长是否与子节时长总和匹配
       const childDurations = leafNodesUnderChapter
         .filter(ch => ch.audioUrl)
@@ -315,7 +315,7 @@ export async function tryAutoMerge(
         where: { id: chapterId },
         select: { audioUrl: true },
       });
-      if (fresh?.audioUrl && fresh.audioUrl.includes('_merged')) {
+      if (fresh?.audioUrl && (fresh.audioUrl.includes('/merged/chapter') || fresh.audioUrl.includes('_merged'))) {
         console.log(`[AutoMerge] 章节${chapterId}已被合并,跳过`);
         return;
       }
@@ -517,6 +517,8 @@ export class BookStore {
     totalChapters?: number;
     estimatedWords?: number;
     voiceSpeed?: number;
+    autoGenerateContent?: boolean;
+    autoGenerateAudio?: boolean;
   }): Promise<Book> {
     const book = await prisma.book.create({
       data: {
@@ -533,6 +535,8 @@ export class BookStore {
         genStage: 'draft',
         progress: 0,
         isPublished: false, // 预发布:等书籍完成后再发布
+        autoGenerateContent: data.autoGenerateContent ?? true,
+        autoGenerateAudio: data.autoGenerateAudio ?? true,
       },
       include: { chapters: true },
     });

+ 41 - 2
server/src/modules/book-generator/langgraph-controller.ts

@@ -437,9 +437,17 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
       generateForeword?: boolean;
       generateAfterword?: boolean;
       immediateGenerate?: boolean;
+      autoGenerateContent?: boolean;
+      autoGenerateAudio?: boolean;
       voiceSpeed?: number;
     };
 
+    // 自动生成开关:未传时默认 true(与历史行为一致)
+    // - autoGenerateContent=false:创建后停在 draft 状态,等用户手动触发
+    // - autoGenerateAudio=false:内容生成完成后 AudioScanner 跳过该书,等用户手动触发
+    const autoGenContent = body.autoGenerateContent ?? true;
+    const autoGenAudio = body.autoGenerateAudio ?? true;
+
     if (!body.description) {
       ctx.status = 400;
       ctx.body = { code: 1, message: '描述不能为空' };
@@ -500,6 +508,8 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
         totalChapters: scaleConfig.isShortArticle ? 1 : scaleConfig.chapters,
         estimatedWords: totalWords,
         voiceSpeed: finalVoiceSpeed,
+        autoGenerateContent: autoGenContent,
+        autoGenerateAudio: autoGenAudio,
       });
       console.log(`[LangGraph.fastPath] finalVoiceSpeed=${finalVoiceSpeed}, type=${typeof finalVoiceSpeed}, body.voiceSpeed=${body.voiceSpeed}`);
 
@@ -508,6 +518,17 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
         return;
       }
 
+      // 用户取消自动生成文本:仅创建草稿,等待用户手动触发
+      if (!autoGenContent) {
+        console.log(`[LangGraph] 用户关闭自动生成文本,创建后保持 draft: bookId=${book.id}`);
+        ctx.body = {
+          code: 0,
+          message: '书籍创建成功,未启用自动生成文本',
+          data: { book, genStage: 'draft', mode: 'manual_content' },
+        };
+        return;
+      }
+
       await bookStore.update(book.id, { genStage: 'outlining' });
       langGraphGenerator.generate(book.id.toString(), body.description, bookScale, genLevel, userId, userSpecifiedVoiceSpeed)
         .then(() => console.log(`[LangGraph] 生成完成: bookId=${book.id}`))
@@ -554,6 +575,8 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
       totalChapters: fallbackConfig.isShortArticle ? 1 : fallbackConfig.chapters,
       estimatedWords: fallbackWords,
       voiceSpeed: finalVoiceSpeed,
+      autoGenerateContent: autoGenContent,
+      autoGenerateAudio: autoGenAudio,
     });
 
     // 标记为 AI 正在准备中
@@ -571,6 +594,22 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
       return;
     }
 
+    // 用户取消自动生成文本:仅创建草稿 + 异步回填标题/规模,不启动 LangGraph 生成
+    if (!autoGenContent) {
+      if (aiPreparing) {
+        fillAiResultsAsync(book.id.toString(), body.description, providedTitle, providedBookScale, userId);
+      }
+      // 草稿态下不消耗音频额度,等用户手动触发时再预留
+      await releaseQuota(userId, fallbackCost);
+      console.log(`[LangGraph] 用户关闭自动生成文本,创建后保持 draft: bookId=${book.id}`);
+      ctx.body = {
+        code: 0,
+        message: '书籍创建成功,未启用自动生成文本',
+        data: { book, genStage: 'draft', mode: 'manual_content', aiPreparing },
+      };
+      return;
+    }
+
     // 非交互模式:需要等 AI 完成后才能确定 bookScale/genLevel 启动生成
     await bookStore.update(book.id, { genStage: 'outlining' });
 
@@ -2288,7 +2327,7 @@ router.post('/books/:id/chapters/:chapterId/regenerate-audio', requireAuth, asyn
       const parentChapter = await prisma.bookChapter.findUnique({
         where: { id: chapter.parentId },
       });
-      if (parentChapter?.audioUrl?.includes('_merged')) {
+      if (parentChapter?.audioUrl && (parentChapter.audioUrl.includes('/merged/chapter') || parentChapter.audioUrl.includes('_merged'))) {
         console.log(`[RegenerateAudio] 清除父章节 ${parentChapter.id} 的合并音频(子节${chapterIdNum}已重新生成)`);
         await bookStore.updateChapterById(parentChapter.id, {
           audioUrl: '',
@@ -2299,7 +2338,7 @@ router.post('/books/:id/chapters/:chapterId/regenerate-audio', requireAuth, asyn
           const grandParent = await prisma.bookChapter.findUnique({
             where: { id: parentChapter.parentId },
           });
-          if (grandParent?.audioUrl?.includes('_merged')) {
+          if (grandParent?.audioUrl && (grandParent.audioUrl.includes('/merged/chapter') || grandParent.audioUrl.includes('_merged'))) {
             console.log(`[RegenerateAudio] 清除祖父章节 ${grandParent.id} 的合并音频`);
             await bookStore.updateChapterById(grandParent.id, {
               audioUrl: '',