Jelajahi Sumber

feat: 优化有声书生成器UI和后端处理

- create.vue: 重构600行组件,优化AI预览检测
- index.vue: 修复页面逻辑
- book-generator-api.ts: 新增API端点
- book-generator.store.ts: 新增状态管理
- langgraph-controller.ts: 新增LangGraph控制器
- ffmpeg.processor.ts: 优化FFmpeg处理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MyFramework User 3 bulan lalu
induk
melakukan
755d82c673

+ 267 - 330
my-uniapp-vue3/src/pages/book-generator/create.vue

@@ -17,177 +17,156 @@
     <!-- 主内容区 -->
     <view class="main-content">
       <view class="card">
-        <view class="card-header">
-          <text class="card-title">📖 创建新书籍</text>
-        </view>
 
-        <!-- 简洁模式提示 -->
-        <view class="simple-mode-tip">
-          <view class="tip-header">
-            <text class="tip-icon">💡</text>
-            <text class="tip-title">简洁模式</text>
-          </view>
-          <text class="tip-content">
-            先填写基本信息创建书籍,高级选项可在创建后修改。
-          </text>
-        </view>
-
-        <!-- 书名:由AI根据描述自动生成,无需手动输入 -->
-
-        <!-- 描述/主题 -->
-        <view class="form-item">
-          <text class="form-label">内容描述 *</text>
-          <textarea
-            v-model="newBook.description"
-            class="form-textarea"
-            placeholder="描述这本书的内容、主题、写作目的..."
-            :maxlength="500"
-            @blur="detectScaleFromDesc"
-          />
-        </view>
-
-        <!-- 面向人群 -->
-        <view class="form-item highlight-item">
-          <view class="label-with-badge">
-            <text class="form-label">面向人群</text>
-            <view class="required-badge">重要</view>
-          </view>
-          <text class="form-hint">选择目标读者,AI会根据人群调整语言风格和表达方式</text>
-          <view class="chip-group">
-            <view
-              v-for="audience in audiences"
-              :key="audience.value"
-              :class="['chip', 'audience-chip', newBook.targetAudience === audience.value ? 'active' : '']"
-              @click="newBook.targetAudience = audience.value"
-            >
-              <text class="chip-icon">{{ audience.icon }}</text>
-              <text class="chip-text">{{ audience.label }}</text>
+        <!-- ========== 基础信息区 ========== -->
+        <view class="section">
+          <text class="section-title">基础信息</text>
+
+          <!-- 内容描述 + 字数计数 -->
+          <view class="form-item">
+            <text class="form-label">内容描述 *</text>
+            <view class="textarea-wrapper">
+              <textarea
+                v-model="newBook.description"
+                class="form-textarea"
+                :class="{ 'error': attemptedCreate && !newBook.description.trim() }"
+                placeholder="描述这本书的内容、主题、写作目的..."
+                :maxlength="500"
+                @blur="detectScaleFromDesc"
+              />
+              <text class="char-count">{{ newBook.description.length }}/500</text>
             </view>
           </view>
-        </view>
 
-        <!-- 书籍类型 -->
-        <view class="form-item">
-          <view class="label-with-badge">
-            <text class="form-label">书籍类型</text>
-            <view class="required-badge" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">影响大纲结构</view>
-          </view>
-          <text class="form-hint-small">选择类型决定大纲层级。不选则由AI自动判断</text>
-          <view class="chip-group" style="margin-top: 8rpx;">
-            <view
-              v-for="bt in bookTypes"
-              :key="bt.value"
-              :class="['chip', newBook.bookType === bt.value ? 'active' : '']"
-              @click="newBook.bookType = bt.value"
+          <!-- AI自动优化描述按钮 -->
+          <view class="form-item">
+            <button
+              class="btn-smart-recommend"
+              :disabled="isRecommending || !newBook.description.trim()"
+              @click="requestSmartRecommend"
             >
-              <text class="chip-icon">{{ bt.icon }}</text>
-              <text class="chip-text">{{ bt.label }}</text>
-              <text class="chip-desc" style="font-size: 20rpx; margin-left: 4rpx;">{{ bt.desc }}</text>
-            </view>
+              <text class="recommend-icon">{{ isRecommending ? '⏳' : '✨' }}</text>
+              <text class="recommend-text">{{ isRecommending ? '推荐中...' : 'AI自动优化描述' }}</text>
+            </button>
           </view>
-        </view>
 
-        <!-- 书籍规模 -->
-        <view class="form-item">
-          <text class="form-label">书籍规模 *</text>
-          <view class="scale-picker">
-            <view
-              v-for="scale in bookScales"
-              :key="scale.value"
-              :class="['scale-option', { active: newBook.bookScale === scale.value }]"
-              @click="selectBookScale(scale.value)"
-            >
-              <text class="scale-words">{{ scale.words }}</text>
-              <text class="scale-label">{{ scale.label }} {{ scale.chapters }}</text>
-              <text class="scale-audio">{{ scale.audio }}</text>
+          <!-- 书籍规模 chip 选择 -->
+          <view class="form-item">
+            <text class="form-label">书籍规模 *</text>
+            <view class="scale-chips">
+              <view
+                v-for="scale in commonScales"
+                :key="scale.value"
+                :class="['chip', newBook.bookScale === scale.value ? 'active' : '']"
+                @click="onScaleChipClick(scale.value)"
+              >
+                {{ scale.label }}
+              </view>
             </view>
+            <picker
+              mode="selector"
+              :range="bookScales"
+              range-key="label"
+              :value="bookScales.findIndex(s => s.value === newBook.bookScale)"
+              @change="onBookScaleChange"
+            >
+              <text class="more-scales">更多规模 ▼</text>
+            </picker>
           </view>
-        </view>
 
-        <!-- 知识难度 -->
-        <view class="form-item highlight-item">
-          <view class="label-with-badge">
-            <text class="form-label">知识难度 *</text>
-          </view>
-          <view class="chip-group">
-            <view
-              v-for="level in knowledgeLevels"
-              :key="level.value"
-              :class="['chip', 'level-chip', newBook.knowledgeLevel === level.value ? 'active' : '']"
-              @click="newBook.knowledgeLevel = level.value"
-            >
-              <text class="chip-icon">{{ level.icon }}</text>
-              <text class="chip-text">{{ level.label }}</text>
+          <!-- 预估信息(选择规模后显示在最上方) -->
+          <view v-if="bookEstimate && bookEstimate.words && bookEstimate.audioMinutes" class="estimate-card">
+            <view class="estimate-row">
+              <text class="estimate-label">📊 预估:</text>
+              <text class="estimate-value">{{ bookEstimate.words.min }}~{{ bookEstimate.words.max }}字</text>
+              <text class="estimate-sep">|</text>
+              <text class="estimate-value">{{ bookEstimate.audioMinutes.min }}~{{ bookEstimate.audioMinutes.max }}分钟</text>
+              <text class="estimate-sep">|</text>
+              <text class="estimate-value">约{{ bookEstimate.estimatedChapters }}章</text>
+            </view>
+            <view v-if="quotaCheck" class="quota-row">
+              <text v-if="quotaCheck.allowed" class="quota-ok">✅ 额度充足</text>
+              <text v-else class="quota-warn">⚠️ {{ quotaCheck.reason }}</text>
             </view>
           </view>
         </view>
 
-        <!-- 智能推荐按钮 -->
-        <view class="form-item smart-recommend-section">
-          <button
-            class="btn-smart-recommend"
-            :disabled="isRecommending || !newBook.description.trim()"
-            @click="requestSmartRecommend"
-          >
-            <text class="recommend-icon">{{ isRecommending ? '⏳' : '✨' }}</text>
-            <text class="recommend-text">{{ isRecommending ? '推荐中...' : '智能推荐' }}</text>
-          </button>
-          <text class="form-hint-small">根据内容描述,AI推荐难度/人群/风格/领域</text>
-        </view>
+        <!-- ========== 读者配置区 ========== -->
+        <view class="section">
+          <text class="section-title">读者配置</text>
 
-        <!-- 高级选项折叠区 -->
-        <view class="advanced-section">
-          <view class="advanced-header" @click="showAdvancedOptions = !showAdvancedOptions">
-            <text class="advanced-title">高级选项</text>
-            <text class="advanced-arrow">{{ showAdvancedOptions ? '▲' : '▼' }}</text>
+          <!-- 面向人群 -->
+          <view class="form-item">
+            <text class="form-label">面向人群</text>
+            <view class="chip-group">
+              <view
+                v-for="audience in audiences"
+                :key="audience.value"
+                :class="['chip', newBook.targetAudience === audience.value ? 'active' : '']"
+                @click="newBook.targetAudience = audience.value; syncToDescription('面向人群', audience.value)"
+              >
+                <text class="chip-icon">{{ audience.icon }}</text>
+                <text class="chip-text">{{ audience.label }}</text>
+              </view>
+            </view>
           </view>
 
-          <!-- 折叠内容 -->
-          <view v-if="showAdvancedOptions" class="advanced-content">
-            <!-- 副标题 -->
-            <view class="form-item">
-              <text class="form-label">副标题</text>
-              <input
-                v-model="newBook.subtitle"
-                class="form-input"
-                placeholder="例如:一本写给青少年的科普书"
-              />
+          <!-- 知识难度 -->
+          <view class="form-item">
+            <text class="form-label">知识难度</text>
+            <view class="chip-group">
+              <view
+                v-for="level in knowledgeLevels"
+                :key="level.value"
+                :class="['chip', newBook.knowledgeLevel === level.value ? 'active' : '']"
+                @click="newBook.knowledgeLevel = level.value; syncToDescription('知识难度', level.value)"
+              >
+                <text class="chip-icon">{{ level.icon }}</text>
+                <text class="chip-text">{{ level.label }}</text>
+              </view>
             </view>
+          </view>
 
-            <!-- 快速模板 -->
-            <view class="form-item">
-              <text class="form-label">📋 快速模板</text>
-              <text class="form-hint-small">点击模板自动填充所有配置,也可手动修改</text>
-              <view class="template-grid">
-                <view
-                  v-for="template in quickTemplates"
-                  :key="template.name"
-                  class="template-card"
-                  @click="applyTemplate(template)"
-                >
-                  <text class="template-icon">{{ template.icon }}</text>
-                  <text class="template-name">{{ template.name }}</text>
-                  <text class="template-desc">{{ template.desc }}</text>
-                </view>
+          <!-- 书籍类型 -->
+          <view class="form-item">
+            <text class="form-label">书籍类型</text>
+            <view class="chip-group">
+              <view
+                v-for="bt in bookTypes"
+                :key="bt.value"
+                :class="['chip', newBook.bookType === bt.value ? 'active' : '']"
+                @click="newBook.bookType = bt.value; syncToDescription('书籍类型', bt.value)"
+              >
+                <text class="chip-icon">{{ bt.icon }}</text>
+                <text class="chip-text">{{ bt.label }}</text>
               </view>
             </view>
+          </view>
 
-            <!-- 重要提示 -->
-            <view class="important-tip">
-              <view class="tip-header">
-                <text class="tip-icon">💡</text>
-                <text class="tip-title">重要提示</text>
-              </view>
-              <text class="tip-content">
-                知识难度和面向人群会显著影响生成内容的风格和深度。同样的知识,面向不同人群,表达方式完全不同:
-              </text>
-              <view class="tip-examples">
-                <text class="tip-example">• 面向小学生:形象、简单、通俗易懂</text>
-                <text class="tip-example">• 面向大学生:系统、专业、有理论深度</text>
-                <text class="tip-example">• 面向研究生:前沿、深入、有研究价值</text>
+          <!-- 大纲层级 -->
+          <view class="form-item">
+            <text class="form-label">大纲层级</text>
+            <view class="chip-group">
+              <view
+                v-for="opt in outlineLevelOptions"
+                :key="opt.value"
+                :class="['chip', newBook.outlineLevel === opt.value ? 'active' : '']"
+                @click="newBook.outlineLevel = opt.value; syncToDescription('大纲层级', opt.value)"
+              >
+                <text class="chip-text">{{ opt.label }}</text>
               </view>
             </view>
+          </view>
+        </view>
+
+        <!-- ========== 高级定制区(默认展开) ========== -->
+        <view class="section">
+          <view class="section-header" @click="showAdvancedOptions = !showAdvancedOptions">
+            <text class="section-title">高级定制</text>
+            <text class="section-arrow">{{ showAdvancedOptions ? '▲' : '▼' }}</text>
+          </view>
 
+          <view v-if="showAdvancedOptions" class="section-content">
             <!-- 写作风格 -->
             <view class="form-item">
               <text class="form-label">写作风格</text>
@@ -196,7 +175,7 @@
                   v-for="style in styles"
                   :key="style"
                   :class="['chip', newBook.style === style ? 'active' : '']"
-                  @click="newBook.style = style"
+                  @click="newBook.style = style; syncToDescription('写作风格', style)"
                 >
                   {{ style }}
                 </view>
@@ -206,13 +185,12 @@
             <!-- 行业领域 -->
             <view class="form-item">
               <text class="form-label">行业领域</text>
-              <text class="form-hint-small">选择行业领域,AI会使用相关的案例和术语</text>
               <view class="chip-group">
                 <view
                   v-for="industry in industries"
                   :key="industry.value"
                   :class="['chip', newBook.industry === industry.value ? 'active' : '']"
-                  @click="newBook.industry = industry.value"
+                  @click="newBook.industry = industry.value; syncToDescription('行业领域', industry.label)"
                 >
                   <text class="chip-icon">{{ industry.icon }}</text>
                   <text class="chip-text">{{ industry.label }}</text>
@@ -223,12 +201,11 @@
             <!-- 特殊要求 (多选) -->
             <view class="form-item">
               <text class="form-label">特殊要求</text>
-              <text class="form-hint-small">可多选,AI会在生成时包含这些元素</text>
               <view class="chip-group">
                 <view
                   v-for="feature in specialFeatures"
                   :key="feature.value"
-                  :class="['chip', 'feature-chip', newBook.specialFeatures.includes(feature.value) ? 'active' : '']"
+                  :class="['chip', newBook.specialFeatures.includes(feature.value) ? 'active' : '']"
                   @click="toggleSpecialFeature(feature.value)"
                 >
                   <text class="chip-icon">{{ feature.icon }}</text>
@@ -236,77 +213,28 @@
                 </view>
               </view>
             </view>
-          </view>
-        </view>
-
-        <!-- 大纲层级 -->
-        <view class="form-item">
-          <view class="label-with-badge">
-            <text class="form-label">大纲层级</text>
-            <view class="required-badge" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">高级</view>
-          </view>
-          <text class="form-hint-small">控制章节结构的详细程度,默认"自动"会根据书籍规模智能选择</text>
-          <view class="chip-group" style="margin-top: 8rpx;">
-            <view
-              v-for="opt in outlineLevelOptions"
-              :key="opt.value"
-              :class="['chip', 'level-chip', newBook.outlineLevel === opt.value ? 'active' : '']"
-              @click="newBook.outlineLevel = opt.value"
-            >
-              <text class="chip-text">{{ opt.label }}</text>
-              <text class="chip-desc" style="font-size: 20rpx; color: #9ca3af; margin-left: 4rpx;">{{ opt.desc }}</text>
-            </view>
-          </view>
-        </view>
-
-        <!-- 预估信息(选择规模后显示) -->
-        <view v-if="bookEstimate && bookEstimate.words && bookEstimate.audioMinutes" class="estimate-card">
-          <view class="estimate-header">
-            <text class="estimate-title">📊 生成预估</text>
-          </view>
-          <view class="estimate-row">
-            <text class="estimate-label">预估字数:</text>
-            <text class="estimate-value">{{ bookEstimate.words.min }}~{{ bookEstimate.words.max }}字</text>
-          </view>
-          <view class="estimate-row">
-            <text class="estimate-label">预估时长:</text>
-            <text class="estimate-value">{{ bookEstimate.audioMinutes.min }}~{{ bookEstimate.audioMinutes.max }}分钟</text>
-          </view>
-          <view class="estimate-row">
-            <text class="estimate-label">预估章节:</text>
-            <text class="estimate-value">约{{ bookEstimate.estimatedChapters }}章</text>
-          </view>
-          <view v-if="bookEstimate.genLevelInfo" class="estimate-row genlevel-row">
-            <text class="estimate-label">大纲层级:</text>
-            <view class="genlevel-tag" :class="'level-' + bookEstimate.defaultGenLevel">
-              <text class="genlevel-label">{{ bookEstimate.genLevelInfo.label }}</text>
-              <text class="genlevel-desc">{{ bookEstimate.genLevelInfo.desc }}</text>
-            </view>
-          </view>
-
-          <!-- 配额检查结果 -->
-          <view v-if="quotaCheck" class="quota-check">
-            <view v-if="quotaCheck.allowed" class="quota-ok">
-              <text class="quota-icon">✅</text>
-              <text class="quota-text">额度充足,可生成</text>
-            </view>
-            <view v-else class="quota-warning">
-              <text class="quota-icon">⚠️</text>
-              <text class="quota-text">{{ quotaCheck.reason }}</text>
-            </view>
 
-            <!-- 详细配额信息 -->
-            <view class="quota-detail">
-              <text class="quota-info">您当前额度:{{ quotaCheck.quota.remainingMinutes }}分钟 / {{ quotaCheck.quota.totalMinutes }}分钟</text>
-              <view v-if="quotaCheck.costEstimate.overageMinutes > 0" class="quota-overage">
-                <text>预计超出:{{ quotaCheck.costEstimate.overageMinutes }}分钟</text>
-                <text class="quota-price">额外费用:¥{{ quotaCheck.costEstimate.estimatedPrice }}</text>
+            <!-- 快速模板 -->
+            <view class="form-item">
+              <text class="form-label">📋 快速模板</text>
+              <view class="template-grid">
+                <view
+                  v-for="template in quickTemplates"
+                  :key="template.name"
+                  class="template-card"
+                  @click="applyTemplate(template)"
+                >
+                  <text class="template-icon">{{ template.icon }}</text>
+                  <text class="template-name">{{ template.name }}</text>
+                  <text class="template-desc">{{ template.desc }}</text>
+                </view>
               </view>
             </view>
           </view>
         </view>
 
-        <view class="btn-group">
+        <!-- 提交按钮(固定底部) -->
+        <view class="btn-group-fixed">
           <button class="btn-cancel" @click="goBack">取消</button>
           <button
             class="btn-primary"
@@ -331,8 +259,9 @@ const BASE_URL = getApiBaseUrl();
 
 // 创建表单
 const creating = ref(false);
-const showAdvancedOptions = ref(false);
+const showAdvancedOptions = ref(true); // 默认展开高级选项
 const isRecommending = ref(false);
+const attemptedCreate = ref(false);
 const newBook = ref({
   title: '',
   subtitle: '',
@@ -494,20 +423,27 @@ const quickTemplates = [
 ];
 
 const styles = ['不填', '通俗易懂', '专业严谨', '轻松幽默', '诗意优美', '故事化'];
+const commonScales = [
+  { value: '1000', label: '1千字' },
+  { value: '3000', label: '3千字' },
+  { value: '5000', label: '5千字' },
+  { value: '130000', label: '13万字' },
+];
+
 const bookScales = [
-  { value: '340000', label: '上2', words: '约34万字', chapters: '170章', audio: '约28小时' },
-  { value: '210000', label: '上1', words: '约21万字', chapters: '105章', audio: '约17小时' },
-  { value: '130000', label: '标准', words: '约13万字', chapters: '65章', audio: '约11小时' },
-  { value: '80000', label: '下1', words: '约8万字', chapters: '40章', audio: '约7小时' },
-  { value: '50000', label: '下2', words: '约5万字', chapters: '25章', audio: '约4小时' },
-  { value: '31000', label: '下3', words: '约3.1万字', chapters: '16章', audio: '约2.5小时' },
-  { value: '19000', label: '下4', words: '约1.9万字', chapters: '10章', audio: '约1.5小时' },
-  { value: '12000', label: '下5', words: '约1.2万字', chapters: '6章', audio: '约1小时' },
-  { value: '7000', label: '下6', words: '约7千字', chapters: '4章', audio: '约35分钟' },
-  { value: '4000', label: '下7', words: '约4千字', chapters: '2章', audio: '约20分钟' },
-  { value: '3000', label: '下8', words: '约3千字', chapters: '2章', audio: '约15分钟' },
-  { value: '2000', label: '下9', words: '约2千字', chapters: '1章', audio: '约10分钟' },
-  { value: '1000', label: '下10', words: '约1千字', chapters: '1章', audio: '约5分钟' },
+  { value: '340000', label: '上2 | 34万字 170章', words: '约34万字', chapters: '170章', audio: '约28小时' },
+  { value: '210000', label: '上1 | 21万字 105章', words: '约21万字', chapters: '105章', audio: '约17小时' },
+  { value: '130000', label: '标准 | 13万字 65章', words: '约13万字', chapters: '65章', audio: '约11小时' },
+  { value: '80000', label: '下1 | 8万字 40章', words: '约8万字', chapters: '40章', audio: '约7小时' },
+  { value: '50000', label: '下2 | 5万字 25章', words: '约5万字', chapters: '25章', audio: '约4小时' },
+  { value: '31000', label: '下3 | 3.1万字 16章', words: '约3.1万字', chapters: '16章', audio: '约2.5小时' },
+  { value: '19000', label: '下4 | 1.9万字 10章', words: '约1.9万字', chapters: '10章', audio: '约1.5小时' },
+  { value: '12000', label: '下5 | 1.2万字 6章', words: '约1.2万字', chapters: '6章', audio: '约1小时' },
+  { value: '7000', label: '下6 | 7千字 4章', words: '约7千字', chapters: '4章', audio: '约35分钟' },
+  { value: '4000', label: '下7 | 4千字 2章', words: '约4千字', chapters: '2章', audio: '约20分钟' },
+  { value: '3000', label: '下8 | 3千字 2章', words: '约3千字', chapters: '2章', audio: '约15分钟' },
+  { value: '2000', label: '下9 | 2千字 1章', words: '约2千字', chapters: '1章', audio: '约10分钟' },
+  { value: '1000', label: '下10 | 1千字 1章', words: '约1千字', chapters: '1章', audio: '约5分钟' },
 ];
 
 // 书籍类型选项
@@ -545,6 +481,7 @@ function toggleSpecialFeature(feature: string) {
   } else {
     features.push(feature);
   }
+  syncToDescription('特殊要求', newBook.value.specialFeatures.join('、'));
 }
 
 // 应用模板
@@ -565,7 +502,7 @@ function applyTemplate(template: typeof quickTemplates[0]) {
   });
 }
 
-// 智能推荐
+// AI自动优化描述(直接应用)
 async function requestSmartRecommend() {
   if (!newBook.value.description.trim() || isRecommending.value) return;
 
@@ -583,20 +520,32 @@ async function requestSmartRecommend() {
     const res = response.data as any;
     if (res.code === 0 && res.data) {
       const rec = res.data;
+      const parts: string[] = [];
+
       if (rec.knowledgeLevel) {
         newBook.value.knowledgeLevel = rec.knowledgeLevel;
+        parts.push(`知识难度:${rec.knowledgeLevel}`);
       }
       if (rec.targetAudience) {
         newBook.value.targetAudience = rec.targetAudience;
+        parts.push(`面向人群:${rec.targetAudience}`);
       }
       if (rec.style) {
         newBook.value.style = rec.style;
+        parts.push(`写作风格:${rec.style}`);
       }
       if (rec.industry) {
         newBook.value.industry = rec.industry;
+        parts.push(`行业领域:${rec.industry}`);
       }
 
-      // 自动展开高级选项显示推荐结果
+      // 追加到description textarea
+      if (parts.length > 0) {
+        const separator = newBook.value.description.trim() ? '\n' : '';
+        newBook.value.description = newBook.value.description.trim() + separator + parts.join('\n');
+      }
+
+      // 自动展开高级选项
       showAdvancedOptions.value = true;
 
       uni.showToast({
@@ -610,7 +559,7 @@ async function requestSmartRecommend() {
       });
     }
   } catch (e: any) {
-    console.error('智能推荐失败:', e);
+    console.error('AI自动优化描述失败:', e);
     uni.showToast({
       title: '推荐服务暂不可用',
       icon: 'none',
@@ -620,9 +569,36 @@ async function requestSmartRecommend() {
   }
 }
 
+// scale chip 点击处理
+function onScaleChipClick(value: string) {
+  onBookScaleChange({ detail: { value: bookScales.findIndex(s => s.value === value) } });
+}
+
+// 将选项同步到description textarea
+function syncToDescription(label: string, value: string) {
+  if (!value || value === 'auto' || value === '不填') return;
+  const line = `${label}:${value}`;
+  const desc = newBook.value.description;
+  // 检查是否已存在该标签
+  if (desc.includes(`${label}:`)) {
+    // 替换已有行
+    newBook.value.description = desc.replace(new RegExp(`${label}:[^\\n]*`), line);
+  } else {
+    // 追加新行
+    newBook.value.description = desc.trim() ? `${desc.trim()}\n${line}` : line;
+  }
+}
+
 // 选择书籍规模时加载预估
-async function selectBookScale(scale: string) {
+function getScaleDisplay(value: string): string {
+  return bookScales.find(s => s.value === value)?.label || '';
+}
+
+async function onBookScaleChange(e: any) {
+  const index = e.detail.value;
+  const scale = bookScales[index].value;
   newBook.value.bookScale = scale;
+  syncToDescription('书籍规模', scale);
 
   try {
     const bt = newBook.value.bookType;
@@ -697,6 +673,7 @@ function goBack() {
 
 // 创建新书籍
 async function createNewBook() {
+  attemptedCreate.value = true;
   if (!canCreateBook.value) return;
 
   const configSummary: string[] = [];
@@ -840,33 +817,24 @@ onMounted(() => {
   width: 100%;
   min-height: 200rpx;
   padding: 24rpx;
+  padding-bottom: 60rpx;
   background: #f9fafb;
   border: 2rpx solid #e5e7eb;
   border-radius: 12rpx;
   font-size: 28rpx;
   line-height: 1.6;
+  box-sizing: border-box;
 }
-
-.label-with-badge { display: flex; align-items: center; gap: 12rpx; margin-bottom: 12rpx; }
-.required-badge {
-  padding: 4rpx 12rpx;
-  background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
-  color: white;
-  border-radius: 8rpx;
-  font-size: 20rpx;
-  font-weight: 500;
-}
-.optional-badge {
-  padding: 4rpx 12rpx;
-  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
-  color: white;
-  border-radius: 8rpx;
-  font-size: 20rpx;
-  font-weight: 500;
+.form-textarea.error { border-color: #ef4444; }
+.textarea-wrapper { position: relative; }
+.char-count {
+  position: absolute;
+  right: 24rpx;
+  bottom: 16rpx;
+  font-size: 22rpx;
+  color: #9ca3af;
 }
 
-.highlight-item { padding: 24rpx; background: rgba(79, 70, 229, 0.03); border-radius: 12rpx; border: 1rpx solid rgba(79, 70, 229, 0.1); }
-
 .chip-group { display: flex; flex-wrap: wrap; gap: 12rpx; }
 .chip {
   display: flex;
@@ -890,82 +858,32 @@ onMounted(() => {
   border: 2rpx solid #e5e7eb;
   border-radius: 12rpx;
   text-align: center;
+  transition: all 0.2s;
 }
+.template-card:active { transform: scale(1.02); box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.1); }
 .template-icon { font-size: 40rpx; display: block; margin-bottom: 8rpx; }
 .template-name { font-size: 26rpx; font-weight: 600; color: #1f2937; display: block; }
 .template-desc { font-size: 22rpx; color: #9ca3af; display: block; margin-top: 4rpx; }
 
-.important-tip { padding: 24rpx; background: rgba(79, 70, 229, 0.05); border-radius: 12rpx; margin: 24rpx 0; }
-.tip-header { display: flex; align-items: center; gap: 8rpx; margin-bottom: 12rpx; }
-.tip-icon { font-size: 28rpx; }
-.tip-title { font-size: 28rpx; font-weight: 600; color: #4f46e5; }
-.tip-content { font-size: 24rpx; color: #6b7280; line-height: 1.6; display: block; margin-bottom: 12rpx; }
-.tip-examples { display: flex; flex-direction: column; gap: 8rpx; }
-.tip-example { font-size: 24rpx; color: #9ca3af; }
+.scale-chips { display: flex; flex-wrap: wrap; gap: 12rpx; margin-bottom: 16rpx; }
+.more-scales { font-size: 24rpx; color: #6366f1; padding: 12rpx 0; }
 
-.scale-picker { display: flex; flex-wrap: wrap; gap: 12rpx; }
-.scale-option {
-  flex: 1;
-  min-width: 200rpx;
-  padding: 16rpx;
-  background: #f9fafb;
-  border: 2rpx solid #e5e7eb;
+/* 预估卡片 */
+.estimate-card {
+  background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
   border-radius: 12rpx;
-  text-align: center;
-}
-.scale-option.active { border-color: #4f46e5; background: rgba(79, 70, 229, 0.05); }
-.scale-words { font-size: 28rpx; font-weight: 600; color: #1f2937; display: block; }
-.scale-label { font-size: 24rpx; color: #6b7280; display: block; margin-top: 4rpx; }
-.scale-audio { font-size: 22rpx; color: #9ca3af; display: block; margin-top: 4rpx; }
-
-.estimate-card { padding: 24rpx; background: rgba(16, 185, 129, 0.05); border-radius: 12rpx; margin-top: 24rpx; }
-.estimate-header { margin-bottom: 16rpx; }
-.estimate-title { font-size: 28rpx; font-weight: 600; color: #059669; }
-.estimate-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
-.estimate-label { font-size: 24rpx; color: #6b7280; }
-.estimate-value { font-size: 24rpx; font-weight: 500; color: #1f2937; }
-.genlevel-row { margin-top: 8rpx; padding-top: 8rpx; border-top: 1rpx solid rgba(16, 185, 129, 0.15); }
-.genlevel-tag { display: flex; align-items: center; gap: 6rpx; padding: 4rpx 12rpx; border-radius: 8rpx; font-size: 22rpx; }
-.genlevel-tag.level-1 { background: rgba(239, 68, 68, 0.1); }
-.genlevel-tag.level-2 { background: rgba(245, 158, 11, 0.1); }
-.genlevel-tag.level-3 { background: rgba(79, 70, 229, 0.1); }
-.genlevel-label { font-weight: 600; color: #1f2937; font-size: 22rpx; }
-.genlevel-desc { color: #9ca3af; font-size: 20rpx; }
-
-.quota-check { margin-top: 16rpx; padding-top: 16rpx; border-top: 1rpx solid rgba(16, 185, 129, 0.2); }
-.quota-ok { display: flex; align-items: center; gap: 8rpx; margin-bottom: 12rpx; }
-.quota-icon { font-size: 24rpx; }
-.quota-text { font-size: 24rpx; color: #059669; }
-.quota-warning { display: flex; align-items: center; gap: 8rpx; margin-bottom: 12rpx; }
-.quota-detail { margin-top: 8rpx; }
-.quota-info { font-size: 22rpx; color: #9ca3af; display: block; }
-.quota-overage { margin-top: 8rpx; display: flex; justify-content: space-between; }
-.quota-price { font-size: 22rpx; color: #ef4444; }
-
-.btn-group { display: flex; gap: 16rpx; 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;
+  padding: 20rpx;
+  margin-bottom: 24rpx;
 }
-.btn-cancel { background: #f3f4f6; color: #6b7280; }
-.btn-primary { background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: white; }
-.btn-primary[disabled] { opacity: 0.5; }
-
-/* 简洁模式提示 */
-.simple-mode-tip { padding: 24rpx; background: rgba(16, 185, 129, 0.08); border-radius: 12rpx; margin-bottom: 32rpx; }
-.simple-mode-tip .tip-header { display: flex; align-items: center; gap: 8rpx; margin-bottom: 8rpx; }
-.simple-mode-tip .tip-icon { font-size: 28rpx; }
-.simple-mode-tip .tip-title { font-size: 28rpx; font-weight: 600; color: #059669; }
-.simple-mode-tip .tip-content { font-size: 24rpx; color: #6b7280; line-height: 1.5; }
-
-/* 智能推荐按钮 */
-.smart-recommend-section { margin-top: 24rpx; }
+.estimate-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8rpx; }
+.estimate-label { font-size: 24rpx; color: #065f46; }
+.estimate-value { font-size: 24rpx; font-weight: 500; color: #065f46; }
+.estimate-sep { font-size: 24rpx; color: #6b7280; }
+.quota-row { margin-top: 8rpx; }
+.quota-ok { font-size: 24rpx; color: #059669; }
+.quota-warn { font-size: 24rpx; color: #dc2626; }
+
+/* AI自动优化描述按钮 */
 .btn-smart-recommend {
   display: flex;
   align-items: center;
@@ -976,21 +894,40 @@ onMounted(() => {
   background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
   border-radius: 12rpx;
   border: none;
-  margin-bottom: 12rpx;
 }
 .btn-smart-recommend[disabled] { opacity: 0.5; }
 .recommend-icon { font-size: 32rpx; }
 .recommend-text { font-size: 28rpx; font-weight: 600; color: white; }
 
-/* 高级选项折叠区 */
-.advanced-section { margin-top: 32rpx; border-top: 1rpx solid #e5e7eb; padding-top: 24rpx; }
-.advanced-header {
+/* 区域样式 */
+.section { margin-bottom: 32rpx; padding-bottom: 32rpx; border-bottom: 1rpx solid #e5e7eb; }
+.section:last-of-type { border-bottom: none; }
+.section-title { font-size: 32rpx; font-weight: 600; color: #1f2937; display: block; margin-bottom: 24rpx; }
+.section-header { display: flex; align-items: center; justify-content: space-between; padding: 16rpx 0; }
+.section-arrow { font-size: 24rpx; color: #9ca3af; }
+.section-content { margin-top: 16rpx; }
+
+/* 按钮组固定底部 */
+.btn-group-fixed {
+  display: flex;
+  gap: 16rpx;
+  padding: 24rpx 0;
+  position: sticky;
+  bottom: 0;
+  background: white;
+  box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.05);
+}
+.btn-cancel, .btn-primary {
+  flex: 1;
+  height: 88rpx;
+  border-radius: 16rpx;
+  font-size: 28rpx;
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  padding: 16rpx 0;
+  justify-content: center;
+  border: none;
 }
-.advanced-title { font-size: 28rpx; font-weight: 600; color: #4b5563; }
-.advanced-arrow { font-size: 24rpx; color: #9ca3af; }
-.advanced-content { margin-top: 16rpx; }
+.btn-cancel { background: #f3f4f6; color: #6b7280; }
+.btn-primary { background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: white; }
+.btn-primary[disabled] { opacity: 0.5; }
 </style>

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

@@ -43,13 +43,14 @@
             </view>
             <!-- 操作按钮区域 - 2个主导按钮 + 更多下拉菜单 -->
             <view v-if="book.chapters && book.chapters.length > 0" class="book-actions">
-              <!-- 主按钮1: 生成内容 -->
+              <!-- 主按钮1: 生成内容 / 停止生成 -->
               <button
                 class="primary-btn"
-                :disabled="generatingAudio[book.id]"
-                @click.stop="handleGenerateAllAudio(book)"
+                :disabled="generatingAudio[book.id] && !stoppingAudio[book.id]"
+                @click.stop="generatingAudio[book.id] ? handleStopAudioGeneration(book) : handleGenerateAllAudio(book)"
               >
-                <text v-if="generatingAudio[book.id]">🎵 生成中...</text>
+                <text v-if="stoppingAudio[book.id]">⏸ 停止中...</text>
+                <text v-else-if="generatingAudio[book.id]">⏹ 停止生成</text>
                 <text v-else-if="getBookAudioStatus(book).status === 'completed'">🔄 重新生成音频</text>
                 <text v-else-if="getBookAudioStatus(book).status === 'partial'">🎵 继续生成({{ getBookAudioStatus(book).completed }}/{{ getBookAudioStatus(book).total }})</text>
                 <text v-else>🎵 生成内容</text>
@@ -111,6 +112,7 @@ const generatingVideo = ref<Record<string, boolean> >({});
 const mergingAudio = ref<Record<string, boolean> >({});
 const mergingVideo = ref<Record<string, boolean> >({});
 const togglingPublish = ref<Record<string, boolean> >({});
+const stoppingAudio = ref<Record<string, boolean> >({});
 
 // 轮询定时器
 const audioPollTimers = ref<Record<string, ReturnType<typeof setInterval>>>({});
@@ -257,6 +259,22 @@ async function handleGenerateAllAudio(book: Book) {
   }
 }
 
+async function handleStopAudioGeneration(book: Book) {
+  if (!generatingAudio.value[book.id] || stoppingAudio.value[book.id]) return;
+  stoppingAudio.value[book.id] = true;
+  try {
+    await api.cancelAudioGeneration(book.id);
+    clearAudioPollTimer(book.id);
+    generatingAudio.value[book.id] = false;
+    await loadBooks();
+    uni.showToast({ title: '已停止音频生成', icon: 'none' });
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '停止失败', icon: 'none' });
+  } finally {
+    stoppingAudio.value[book.id] = false;
+  }
+}
+
 async function handleMergeChapterAudio(book: Book) {
   if (!canMergeAudio(book)) { uni.showToast({ title: '并非所有叶节点音频都已生成完成', icon: 'none' }); return; }
   mergingAudio.value[book.id] = true;

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

@@ -436,6 +436,19 @@ export async function generateAllChaptersAudio(
 
 // ============ 视频生成 API ============
 
+/**
+ * 取消书籍所有章节音频生成
+ */
+export async function cancelAudioGeneration(
+  bookId: string
+): Promise<{ cancelledCount: number; rolledBackChapters: number[] }> {
+  const result = await request<{ cancelledCount: number; rolledBackChapters: number[] }>(
+    `${BASE_URL}/books/${bookId}/audio/cancel`,
+    { method: 'POST' }
+  );
+  return result;
+}
+
 /**
  * 生成单个章节视频(从音频转视频)
  */

+ 4 - 0
server/src/app.ts

@@ -16,6 +16,7 @@ import { httpLogger } from './services/logger.service';
 import { redisService } from './services/redis.service';
 import { ossService } from './services/oss.service';
 import { storageService } from './services/storage.service';
+import { FFmpegProcessor } from './services/ffmpeg.processor';
 import { resumeInterruptedTasks } from './modules/book-generator/book-queue.processor';
 import { startTtsQueue, stopTtsQueue } from './modules/book-generator/tts-queue';
 import { startAudioScanner, stopAudioScanner } from './modules/book-generator/audio-scanner';
@@ -178,6 +179,9 @@ async function start() {
     initWebSocket(server);
 
     // 6. 启动服务
+    // 清理 temp 目录中的残留临时文件
+    FFmpegProcessor.cleanupAllTempFiles();
+
     server.listen(config.port, () => {
       console.log(`🚀 服务启动成功: http://localhost:${config.port}`);
       console.log(`📁 上传目录: ${config.upload.dir}`);

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

@@ -17,6 +17,59 @@ import { mergeChapterAudios } from '../player/player.service';
 import { logAiCall } from '../../services/ai-call-logger';
 import { consumeAudioMinutes } from '../subscription/subscription.service';
 
+/**
+ * 取消书籍所有章节的音频生成(将 pending/processing 任务标记为 cancelled,回退章节阶段)
+ */
+export async function cancelAudioGeneration(bookId: string): Promise<{ cancelledCount: number; rolledBackChapters: number[] }> {
+  const chapters = await prisma.bookChapter.findMany({
+    where: { bookId: BigInt(bookId) },
+    select: { id: true, level: true },
+  });
+
+  if (chapters.length === 0) {
+    return { cancelledCount: 0, rolledBackChapters: [] };
+  }
+
+  const maxLevel = Math.max(...chapters.map(c => c.level || 0));
+  const leafChapterIds = chapters.filter(c => c.level === maxLevel).map(c => c.id);
+
+  // 找出所有 pending/processing 状态的 TTS 任务
+  const activeTasks = await prisma.ttsTask.findMany({
+    where: {
+      chapterId: { in: leafChapterIds },
+      taskType: 'tts',
+      status: { in: ['pending', 'processing'] },
+    },
+    select: { id: true, chapterId: true },
+  });
+
+  if (activeTasks.length === 0) {
+    return { cancelledCount: 0, rolledBackChapters: [] };
+  }
+
+  const taskIds = activeTasks.map(t => t.id);
+  const affectedChapterIds = [...new Set(activeTasks.map(t => t.chapterId))];
+
+  // 批量标记任务为 cancelled
+  await prisma.ttsTask.updateMany({
+    where: { id: { in: taskIds } },
+    data: { status: 'cancelled' },
+  });
+
+  // 回退受影响章节的 genStage 到 content_completed
+  await prisma.bookChapter.updateMany({
+    where: { id: { in: affectedChapterIds }, genStage: 'audio_generating' },
+    data: { genStage: 'content_completed' },
+  });
+
+  return {
+    cancelledCount: taskIds.length,
+    rolledBackChapters: affectedChapterIds,
+  };
+}
+
+// ============ 删除书籍 ============
+
 /**
  * 根据所有章节状态计算书籍阶段
  * 书籍阶段 = 所有章节中最低的阶段(最落后的章节决定了书籍的进度)

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

@@ -1045,6 +1045,46 @@ router.post('/books/:id/audio', async (ctx: Context) => {
   }
 });
 
+/**
+ * POST /api/book-generator/langgraph/books/:id/audio/cancel
+ * 取消书籍所有章节的音频生成
+ */
+router.post('/books/:id/audio/cancel', 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 result = await bookStore.cancelAudioGeneration(bookId);
+
+    if (result.cancelledCount === 0) {
+      ctx.body = {
+        code: 0,
+        message: '没有正在运行的音频生成任务',
+        data: result,
+      };
+      return;
+    }
+
+    console.log(`[Audio] 书籍 ${bookId} 取消音频生成,已取消 ${result.cancelledCount} 个任务,回退 ${result.rolledBackChapters.length} 个章节`);
+
+    ctx.body = {
+      code: 0,
+      message: `已取消 ${result.cancelledCount} 个音频生成任务`,
+      data: result,
+    };
+  } catch (error) {
+    console.error('取消音频生成失败:', error);
+    ctx.status = 500;
+    ctx.body = { code: 1, message: error instanceof Error ? error.message : '取消失败' };
+  }
+});
+
 /**
  * GET /api/book-generator/langgraph/books/:id/video-status
  * 获取书籍视频生成状态(用于前端轮询)

+ 13 - 18
server/src/services/ffmpeg.processor.ts

@@ -80,6 +80,8 @@ export class FFmpegProcessor {
     }
 
     const tempFiles: string[] = [];
+    const listFile = path.join(TEMP_DIR, `${uuidv4()}_list.txt`);
+    const outputFile = path.join(TEMP_DIR, `${uuidv4()}_merged.${outputFormat}`);
 
     try {
       // 1. 下载所有文件到本地
@@ -90,12 +92,8 @@ export class FFmpegProcessor {
       }
 
       // 2. 创建 FFmpeg 文件列表(路径中的反斜杠转为正斜杠,避免 Windows 下 FFmpeg 解析失败)
-      const listFile = path.join(TEMP_DIR, `${uuidv4()}_list.txt`);
       const listContent = tempFiles.map(f => `file '${f.replace(/\\/g, '/')}'`).join('\n');
       fs.writeFileSync(listFile, listContent);
-
-      // 3. 执行 FFmpeg 合并
-      const outputFile = path.join(TEMP_DIR, `${uuidv4()}_merged.${outputFormat}`);
       
       console.log(`[FFmpeg] 开始合并音频...`);
       
@@ -116,8 +114,8 @@ export class FFmpegProcessor {
 
       return finalUrl;
     } finally {
-      // 5. 清理临时文件
-      this.cleanupTempFiles(tempFiles);
+      // 5. 清理临时文件(下载的文件 + 列表文件 + 合并输出文件)
+      this.cleanupTempFiles([...tempFiles, listFile, outputFile]);
     }
   }
 
@@ -136,6 +134,7 @@ export class FFmpegProcessor {
     bgmVolume: number = 0.3
   ): Promise<string> {
     const tempFiles: string[] = [];
+    const outputFile = path.join(TEMP_DIR, `${uuidv4()}_output.mp4`);
 
     try {
       // 1. 下载文件
@@ -151,7 +150,6 @@ export class FFmpegProcessor {
       }
 
       // 2. 合并音频和视频
-      const outputFile = path.join(TEMP_DIR, `${uuidv4()}_output.mp4`);
       
       console.log(`[FFmpeg] 开始合并音视频...`);
       
@@ -177,8 +175,8 @@ export class FFmpegProcessor {
 
       return finalUrl;
     } finally {
-      // 4. 清理临时文件
-      this.cleanupTempFiles(tempFiles);
+      // 4. 清理临时文件(下载的文件 + 合并输出文件)
+      this.cleanupTempFiles([...tempFiles, outputFile]);
     }
   }
 
@@ -220,6 +218,7 @@ export class FFmpegProcessor {
     bitrate: string = '192k'
   ): Promise<string> {
     const tempFiles: string[] = [];
+    const outputFile = path.join(TEMP_DIR, `${uuidv4()}_converted.${outputFormat}`);
 
     try {
       // 1. 下载文件
@@ -227,8 +226,6 @@ export class FFmpegProcessor {
       tempFiles.push(inputFile);
 
       // 2. 转换格式
-      const outputFile = path.join(TEMP_DIR, `${uuidv4()}_converted.${outputFormat}`);
-      
       console.log(`[FFmpeg] 转换格式: ${inputUrl} -> ${outputFormat}`);
       
       let cmd: string;
@@ -256,7 +253,7 @@ export class FFmpegProcessor {
 
       return finalUrl;
     } finally {
-      this.cleanupTempFiles(tempFiles);
+      this.cleanupTempFiles([...tempFiles, outputFile]);
     }
   }
 
@@ -273,6 +270,7 @@ export class FFmpegProcessor {
     duration: number
   ): Promise<string> {
     const tempFiles: string[] = [];
+    const outputFile = path.join(TEMP_DIR, `${uuidv4()}_trimmed.mp3`);
 
     try {
       // 1. 下载文件
@@ -280,8 +278,6 @@ export class FFmpegProcessor {
       tempFiles.push(inputFile);
 
       // 2. 裁剪
-      const outputFile = path.join(TEMP_DIR, `${uuidv4()}_trimmed.mp3`);
-      
       console.log(`[FFmpeg] 裁剪音频: ${startTime}s - ${startTime + duration}s`);
       
       const cmd = `ffmpeg -i "${inputFile}" -ss ${startTime} -t ${duration} ` +
@@ -297,7 +293,7 @@ export class FFmpegProcessor {
 
       return finalUrl;
     } finally {
-      this.cleanupTempFiles(tempFiles);
+      this.cleanupTempFiles([...tempFiles, outputFile]);
     }
   }
 
@@ -312,6 +308,7 @@ export class FFmpegProcessor {
     volume: number
   ): Promise<string> {
     const tempFiles: string[] = [];
+    const outputFile = path.join(TEMP_DIR, `${uuidv4()}_volume.mp3`);
 
     try {
       // 1. 下载文件
@@ -319,8 +316,6 @@ export class FFmpegProcessor {
       tempFiles.push(inputFile);
 
       // 2. 调整音量
-      const outputFile = path.join(TEMP_DIR, `${uuidv4()}_volume.mp3`);
-      
       console.log(`[FFmpeg] 调整音量: ${volume}x`);
       
       const cmd = `ffmpeg -i "${inputFile}" -filter:a "volume=${volume}" ` +
@@ -336,7 +331,7 @@ export class FFmpegProcessor {
 
       return finalUrl;
     } finally {
-      this.cleanupTempFiles(tempFiles);
+      this.cleanupTempFiles([...tempFiles, outputFile]);
     }
   }