Bladeren bron

feat(detail): 增加分阶段进度展示卡 (解决进度卡 95% 不动问题)

原进度条问题:book.progress 字段 0~100,PROGRESS.CONTENT_END=95 就到顶。
内容生成完后(progress=95),音频/合并阶段不计入,页面显示 95% 卡住,
用户看不出后面还要多久。

新增 stage-progress-card:
- 6 个阶段:📝 大纲 → ✍️ 内容 → 📖 前言/后记 → 🎵 音频 → 🔗 合并 → ✅ 完成
- 每阶段显示:图标(✓/◐/○)+ 名称 + 实时百分比 + 进度条 + 详情文案
- 当前阶段高亮(黄色 + 旋转动画),已完成阶段(绿色)
- 音频/合并阶段由 startPollingAudioStatus 实时填充(5s 轮询 /books/:id/audio-status)

应用时机:isCurrentlyGenerating || isContentCompleted(内容生成后仍然展示)
MyFramework User 1 maand geleden
bovenliggende
commit
169eaacb3a
1 gewijzigde bestanden met toevoegingen van 125 en 0 verwijderingen
  1. 125 0
      my-uniapp-vue3/src/pages/book-generator/detail.vue

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

@@ -104,6 +104,38 @@
           </view>
           </view>
         </view>
         </view>
 
 
+        <!-- 分阶段进度(解决进度条卡 95% 不动的问题:内容完成后继续展示音频/合并/完成阶段) -->
+        <view v-if="currentBook && (isCurrentlyGenerating || isContentCompleted)" class="stage-progress-card">
+          <view class="stage-progress-header">
+            <text class="stage-progress-icon">📊</text>
+            <text class="stage-progress-title">生成进度</text>
+          </view>
+          <view class="stage-progress-list">
+            <view
+              v-for="(stage, idx) in stageProgress"
+              :key="stage.key"
+              class="stage-row"
+              :class="['stage-' + stage.status]"
+            >
+              <view class="stage-icon-cell">
+                <text class="stage-icon">{{ stage.status === 'done' ? '✓' : (stage.status === 'active' ? '◐' : '○') }}</text>
+              </view>
+              <view class="stage-content-cell">
+                <view class="stage-line">
+                  <text class="stage-name">{{ stage.name }}</text>
+                  <text class="stage-percent">{{ stage.percent }}%</text>
+                </view>
+                <view v-if="stage.status !== 'pending' || stage.percent > 0" class="stage-bar-wrap">
+                  <view class="stage-bar">
+                    <view class="stage-bar-fill" :style="{ width: stage.percent + '%' }"></view>
+                  </view>
+                </view>
+                <text class="stage-detail">{{ stage.detail }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+
         <!-- 实时配额显示 -->
         <!-- 实时配额显示 -->
         <view v-if="isCurrentlyGenerating" class="quota-monitor">
         <view v-if="isCurrentlyGenerating" class="quota-monitor">
           <text class="quota-monitor-title">🎧 额度监控</text>
           <text class="quota-monitor-title">🎧 额度监控</text>
@@ -430,6 +462,11 @@ const currentBook = ref<Book | null>(null);
 // 当前正在查看的 bookId(来自 onLoad 的 query.id / H5 hash),供致命错误时重试用
 // 当前正在查看的 bookId(来自 onLoad 的 query.id / H5 hash),供致命错误时重试用
 const currentBookId = ref<string>('');
 const currentBookId = ref<string>('');
 
 
+// 音频生成阶段进度(由 /books/:id/audio-status 轮询填充)
+const audioStatus = ref<{ total: number; completed: number; pending: number; allCompleted: boolean }>({
+  total: 0, completed: 0, pending: 0, allCompleted: false,
+});
+
 // 加载书籍时的致命错误(如"书籍不存在"、网络不可达、权限拒绝等)
 // 加载书籍时的致命错误(如"书籍不存在"、网络不可达、权限拒绝等)
 // 不只是 toast 一闪而过,而是落到页面让用户看清,并可返回书籍列表
 // 不只是 toast 一闪而过,而是落到页面让用户看清,并可返回书籍列表
 const loadError = ref<{ title: string; message: string; recoverable: boolean } | null>(null);
 const loadError = ref<{ title: string; message: string; recoverable: boolean } | null>(null);
@@ -541,6 +578,56 @@ const STALLED_THRESHOLD = 40;             // 40 次 × 3s ≈ 2 分钟无变化
 // 质量报告折叠
 // 质量报告折叠
 const reportExpanded = ref(false);
 const reportExpanded = ref(false);
 
 
+// 分阶段进度:把生成流程拆成 6 个阶段,每阶段返回 { name, status, detail, percent }
+// status: 'done' | 'active' | 'pending'
+// 解决原页面"内容生成完进度卡在 95% 不动"的问题——音频/合并阶段独立显示。
+const stageProgress = computed(() => {
+  const b = currentBook.value;
+  if (!b) return [];
+  const gs = b.genStage || 'draft';
+
+  // 1. 大纲
+  const outlineDone = ['outline_ready', 'outline_completed', 'content_generating', 'content_generating_timeout', 'content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed'].includes(gs);
+  const outlineActive = gs === 'outlining';
+
+  // 2. 内容:用 book.progress (0-95) + completedChapters / totalChapters 推算
+  // book.progress 包含了"内容"的整体进度,0~95 区间
+  const contentPct = Math.min(100, Math.max(0, Math.round(((b.progress ?? 0) / 95) * 100)));
+  const contentDone = ['content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed'].includes(gs);
+  const contentActive = gs === 'content_generating';
+
+  // 3. 前言/后记(content_completed 之后才生成,耗时短,不单独轮询进度)
+  const postDone = contentDone; // 只要内容完结,前言/后记也已经包含在 content 阶段
+  const postActive = false;
+
+  // 4. 音频:用 audioStatus 实时填充
+  const aTotal = audioStatus.value.total;
+  const aDone = audioStatus.value.completed;
+  const aPct = aTotal > 0 ? Math.round((aDone / aTotal) * 100) : 0;
+  const audioDone = aTotal > 0 && audioStatus.value.allCompleted;
+  const audioActive = gs === 'audio_generating';
+
+  // 5. 合并章节音频:audio_done 之后开始(暂无独立进度字段)
+  const mergeDone = audioDone && (gs === 'audio_completed' || gs === 'video_completed');
+  const mergeActive = audioDone && gs !== 'audio_completed' && gs !== 'video_completed';
+
+  // 6. 整体完成
+  const allDone = gs === 'audio_completed' || gs === 'video_completed';
+
+  const ch = b.chapters || [];
+  const totalCh = ch.length;
+  const contentDoneCh = ch.filter((c: any) => c.genStage === 'content_completed' || c.genStage === 'audio_completed' || c.genStage === 'video_completed').length;
+
+  return [
+    { key: 'outline', name: '📝 大纲生成', status: outlineDone ? 'done' : (outlineActive ? 'active' : 'pending'), detail: outlineDone ? '已完成' : (outlineActive ? '生成中...' : '等待'), percent: outlineDone ? 100 : 0 },
+    { key: 'content', name: '✍️ 内容生成', status: contentDone ? 'done' : (contentActive ? 'active' : 'pending'), detail: contentDone ? `已完成 ${contentDoneCh}/${totalCh}` : (contentActive ? `生成中 ${contentDoneCh}/${totalCh}` : '等待'), percent: contentDone ? 100 : contentPct },
+    { key: 'post',    name: '📖 前言/后记', status: postDone ? 'done' : (postActive ? 'active' : 'pending'), detail: postDone ? '已完成' : (postActive ? '生成中...' : '等待'), percent: postDone ? 100 : 0 },
+    { key: 'audio',   name: '🎵 音频生成', status: audioDone ? 'done' : (audioActive ? 'active' : (aTotal > 0 ? 'active' : 'pending')), detail: aTotal > 0 ? `${aDone}/${aTotal} 节` : '等待', percent: audioDone ? 100 : aPct },
+    { key: 'merge',   name: '🔗 章节合并', status: mergeDone ? 'done' : (mergeActive ? 'active' : 'pending'), detail: mergeDone ? '已完成' : (mergeActive ? '合并中...' : '等待'), percent: mergeDone ? 100 : 0 },
+    { key: 'done',    name: '✅ 全部完成', status: allDone ? 'done' : 'pending', detail: allDone ? '可播放/下载' : '等待', percent: allDone ? 100 : 0 },
+  ];
+});
+
 // 计算属性
 // 计算属性
 const completedChapters = computed(() => {
 const completedChapters = computed(() => {
   if (!currentBook.value) return 0;
   if (!currentBook.value) return 0;
@@ -975,6 +1062,13 @@ function startPollingAudioStatus(bookId: string) {
   audioPollTimer = setInterval(async () => {
   audioPollTimer = setInterval(async () => {
     try {
     try {
       const status = await api.getAudioStatus(bookId);
       const status = await api.getAudioStatus(bookId);
+      // 实时回填 audioStatus(驱动 detail 页的"分阶段进度"展示)
+      audioStatus.value = {
+        total: status.total ?? 0,
+        completed: status.completed ?? 0,
+        pending: status.pending ?? 0,
+        allCompleted: !!status.allCompleted,
+      };
       if (status.allCompleted) {
       if (status.allCompleted) {
         stopPollingAudioStatus();
         stopPollingAudioStatus();
         generatingAudio.value = false;
         generatingAudio.value = false;
@@ -1421,6 +1515,37 @@ onUnmounted(() => {
 .status-tip { padding: 12rpx; background: rgba(255, 255, 255, 0.1); border-radius: 8rpx; }
 .status-tip { padding: 12rpx; background: rgba(255, 255, 255, 0.1); border-radius: 8rpx; }
 .tip-text { font-size: 24rpx; opacity: 0.9; }
 .tip-text { font-size: 24rpx; opacity: 0.9; }
 
 
+/* 分阶段进度卡片(生成中 + 内容完成后都显示) */
+.stage-progress-card { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border-radius: 16rpx; border: 2rpx solid #7dd3fc; }
+.stage-progress-header { display: flex; align-items: center; margin-bottom: 16rpx; padding-bottom: 12rpx; border-bottom: 2rpx solid rgba(125, 211, 252, 0.3); }
+.stage-progress-icon { font-size: 32rpx; margin-right: 8rpx; }
+.stage-progress-title { font-size: 30rpx; font-weight: 600; color: #075985; }
+.stage-progress-list { display: flex; flex-direction: column; gap: 12rpx; }
+.stage-row { display: flex; align-items: flex-start; padding: 14rpx 16rpx; background: rgba(255, 255, 255, 0.6); border-radius: 10rpx; border-left: 6rpx solid #cbd5e1; transition: all 0.3s; }
+.stage-row.stage-active { background: linear-gradient(90deg, #fef3c7 0%, #fffbeb 100%); border-left-color: #f59e0b; box-shadow: 0 2rpx 8rpx rgba(245, 158, 11, 0.15); }
+.stage-row.stage-done { background: linear-gradient(90deg, #d1fae5 0%, #ecfdf5 100%); border-left-color: #10b981; }
+.stage-icon-cell { width: 40rpx; flex-shrink: 0; display: flex; justify-content: center; padding-top: 4rpx; }
+.stage-icon { font-size: 32rpx; font-weight: 700; color: #94a3b8; }
+.stage-row.stage-active .stage-icon { color: #f59e0b; animation: stage-spin 1.6s linear infinite; }
+.stage-row.stage-done .stage-icon { color: #10b981; }
+@keyframes stage-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
+.stage-content-cell { flex: 1; min-width: 0; }
+.stage-line { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6rpx; }
+.stage-name { font-size: 28rpx; font-weight: 500; color: #334155; }
+.stage-row.stage-active .stage-name { color: #92400e; font-weight: 600; }
+.stage-row.stage-done .stage-name { color: #065f46; }
+.stage-percent { font-size: 24rpx; color: #64748b; font-weight: 600; font-variant-numeric: tabular-nums; }
+.stage-row.stage-active .stage-percent { color: #b45309; }
+.stage-row.stage-done .stage-percent { color: #047857; }
+.stage-bar-wrap { margin: 4rpx 0; }
+.stage-bar { height: 8rpx; background: rgba(203, 213, 225, 0.4); border-radius: 4rpx; overflow: hidden; }
+.stage-bar-fill { height: 100%; background: linear-gradient(90deg, #94a3b8 0%, #64748b 100%); border-radius: 4rpx; transition: width 0.4s ease; }
+.stage-row.stage-active .stage-bar-fill { background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%); }
+.stage-row.stage-done .stage-bar-fill { background: linear-gradient(90deg, #34d399 0%, #10b981 100%); }
+.stage-detail { font-size: 22rpx; color: #64748b; margin-top: 4rpx; }
+.stage-row.stage-active .stage-detail { color: #92400e; }
+.stage-row.stage-done .stage-detail { color: #065f46; }
+
 .failed-tip { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); border-radius: 16rpx; border: 2rpx solid #fca5a5; }
 .failed-tip { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); border-radius: 16rpx; border: 2rpx solid #fca5a5; }
 .failed-icon { font-size: 40rpx; display: block; text-align: center; margin-bottom: 12rpx; }
 .failed-icon { font-size: 40rpx; display: block; text-align: center; margin-bottom: 12rpx; }
 .failed-text { font-size: 32rpx; font-weight: 600; color: #dc2626; display: block; text-align: center; margin-bottom: 12rpx; }
 .failed-text { font-size: 32rpx; font-weight: 600; color: #dc2626; display: block; text-align: center; margin-bottom: 12rpx; }