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

feat(OPT-39): 生成状态显示优化 - 统一状态组件 + CSS补充 + 音频状态直读

MyFramework User пре 4 месеци
родитељ
комит
ee6282fb9d

+ 11 - 0
agent-progress.txt

@@ -324,3 +324,14 @@ curl -X PUT http://localhost:3000/api/player/audio/1/public -H "Content-Type: ap
 - 合并后存到章的 audioUrl 字段
 
 测试: curl http://localhost:3000/api/player/audio/list
+
+=== 2026-05-07 优化阶段 ===
+功能39: 生成状态显示优化
+- 创建统一 GenerationStatusBadge 组件 (src/components/GenerationStatusBadge.vue)
+- 替换 book-generator/index.vue 状态标签
+- 替换 book-generator/detail.vue 状态标签
+- 替换 video-generator/index.vue 状态标签
+- 替换 orders/index.vue 状态标签
+- 清理旧 .status-badge CSS(书生成页/视频页/订单页/详情页)
+- getBookAudioStatus 改为优先读 audioStatus 字段
+- TypeScript 编译检查通过

+ 17 - 0
feature_list.json

@@ -660,6 +660,23 @@
       ],
       "status": "done",
       "passes": true
+    },
+    {
+      "id": 39,
+      "description": "生成状态显示优化 - 统一状态组件 + 补充CSS + 音频状态直读",
+      "backend_test_steps": [
+        "1. curl http://localhost:3000/api/book-generator/books - 验证书籍列表返回各状态字段",
+        "2. curl http://localhost:3000/api/player/audio/list - 验证音频列表返回 audioStatus 字段"
+      ],
+      "frontend_test_steps": [
+        "1. 打开书籍生成列表页,验证状态标签颜色正确",
+        "2. 打开视频生成列表页,验证状态标签颜色正确",
+        "3. 打开订单页面,验证状态标签颜色正确",
+        "4. 在书籍详情页,验证章节树状态图标显示正确",
+        "5. 验证 generating 状态有黄色动画效果"
+      ],
+      "status": "done",
+      "passes": true
     }
   ]
 }

+ 162 - 0
my-uniapp-vue3/src/components/GenerationStatusBadge.vue

@@ -0,0 +1,162 @@
+<template>
+  <view :class="['gen-badge', status]">
+    <text class="gen-icon">{{ icon }}</text>
+    <text class="gen-label">{{ label }}</text>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+
+const props = withDefaults(defineProps<{
+  status: string;
+  // 可覆盖默认映射
+  customMap?: Record<string, { icon: string; label: string }>;
+}>(), {
+  customMap: () => ({}),
+});
+
+const statusDisplayMap: Record<string, { icon: string; label: string }> = {
+  // 统一状态(推荐)
+  idle:        { icon: '○', label: '未开始' },
+  queued:      { icon: '⏳', label: '排队中' },
+  processing:  { icon: '⚡', label: '生成中' },
+  retrying:    { icon: '🔄', label: '重试中' },
+  completed:   { icon: '✓',  label: '已完成' },
+  failed:      { icon: '✗',  label: '失败'   },
+  cancelled:   { icon: '—',  label: '已取消' },
+
+  // 兼容旧状态名
+  draft:       { icon: '📝', label: '草稿' },
+  planning:    { icon: '📋', label: '规划中' },
+  pending:     { icon: '○',  label: '待处理' },
+  generating:  { icon: '⚡', label: '生成中' },
+  running:     { icon: '⚡', label: '运行中' },
+  success:     { icon: '✓',  label: '成功' },
+  paid:        { icon: '✓',  label: '已支付' },
+  refunded:    { icon: '↩',  label: '已退款' },
+  uploading:   { icon: '↑',  label: '上传中' },
+  published:   { icon: '🌐', label: '已公开' },
+  paused:      { icon: '⏸',  label: '已暂停' },
+  interrupted: { icon: '⏹',  label: '已中断' },
+};
+
+const merged = computed(() => ({ ...statusDisplayMap, ...props.customMap }));
+
+const display = computed(() => {
+  const s = props.status || '';
+  return merged.value[s] || { icon: '?', label: s };
+});
+
+const icon = computed(() => display.value.icon);
+const label = computed(() => display.value.label);
+</script>
+
+<style scoped>
+.gen-badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 6rpx;
+  padding: 6rpx 16rpx;
+  border-radius: 20rpx;
+  font-size: 22rpx;
+  font-weight: 500;
+  white-space: nowrap;
+  transition: all 0.3s ease;
+}
+
+.gen-icon {
+  font-size: 20rpx;
+}
+
+.gen-label {
+  /* 由父级控制文字颜色 */
+}
+
+/* ===== 状态颜色 ===== */
+
+/* 未开始类 */
+.gen-badge.idle,
+.gen-badge.pending,
+.gen-badge.paused {
+  background: #f3f4f6;
+  color: #6b7280;
+}
+
+/* 排队中 */
+.gen-badge.queued {
+  background: #f3f4f6;
+  color: #9ca3af;
+}
+
+/* 生成中/上传中 - 带呼吸动画 */
+.gen-badge.generating,
+.gen-badge.processing,
+.gen-badge.running,
+.gen-badge.uploading {
+  background: #fef3c7;
+  color: #d97706;
+  animation: gen-pulse 1.5s ease-in-out infinite;
+}
+
+/* 重试中 - 带旋转动画 */
+.gen-badge.retrying {
+  background: #fff7ed;
+  color: #ea580c;
+  animation: gen-pulse 1.5s ease-in-out infinite;
+}
+.gen-badge.retrying .gen-icon {
+  animation: gen-spin 1s linear infinite;
+}
+
+/* 已完成/成功 */
+.gen-badge.completed,
+.gen-badge.success,
+.gen-badge.paid {
+  background: #d1fae5;
+  color: #059669;
+}
+
+/* 已发布 */
+.gen-badge.published {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: #ffffff;
+  font-weight: 600;
+}
+
+/* 失败 */
+.gen-badge.failed {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
+/* 已取消/已中断 */
+.gen-badge.cancelled,
+.gen-badge.interrupted,
+.gen-badge.refunded {
+  background: #f3f4f6;
+  color: #9ca3af;
+  text-decoration: line-through;
+}
+
+/* 草稿/规划中 */
+.gen-badge.draft {
+  background: #e5e7eb;
+  color: #6b7280;
+}
+.gen-badge.planning {
+  background: #e0e7ff;
+  color: #4338ca;
+}
+
+/* ===== 动画 ===== */
+@keyframes gen-pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.7; }
+}
+
+@keyframes gen-spin {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+</style>

+ 103 - 38
my-uniapp-vue3/src/pages/book-generator/detail.vue

@@ -17,9 +17,7 @@
       <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>
+          <GenerationStatusBadge :status="currentBook?.status || 'draft'"></GenerationStatusBadge>
         </view>
         <text v-if="currentBook?.subtitle" class="book-subtitle">{{ currentBook.subtitle }}</text>
         <text class="book-desc">{{ currentBook?.description }}</text>
@@ -254,7 +252,17 @@
               :disabled="generating"
               @click="handleLangGraphGenerate"
             >
-              {{ generating ? '生成中...' : '🤖 开始生成' }}
+              {{ generating ? '生成中...' : (isContentCompleted ? '🔄 重新生成' : '🤖 开始生成') }}
+            </button>
+          </view>
+          <!-- 音频生成按钮 - 内容生成完成后显示 -->
+          <view v-if="isContentCompleted" class="btn-row">
+            <button
+              class="action-btn audio-btn"
+              :disabled="generatingAudio"
+              @click="handleGenerateAudio"
+            >
+              {{ generatingAudio ? '生成中...' : '🎵 生成全部音频' }}
             </button>
           </view>
         </view>
@@ -283,8 +291,7 @@ import { onLoad, onShow } from '@dcloudio/uni-app';
 import * as api from '../../utils/book-generator-api';
 import { post } from '../../utils/request';
 import type { Book, Chapter } from '../../utils/book-generator-api';
-import { wsService } from '../../utils/websocket';
-
+import GenerationStatusBadge from '../../components/GenerationStatusBadge.vue';
 // 批量操作状态
 const isBatchMode = ref(false);
 const selectedChapterIds = ref<string[]>([]);
@@ -295,9 +302,25 @@ const currentBook = ref<Book | null>(null);
 
 // 生成状态
 const generating = ref(false);
+const generatingAudio = ref(false);
+const generatingVideo = ref(false);
+
+// 是否有内容(章节有实际内容文本)
+const hasContent = computed(() => {
+  if (!currentBook.value?.chapters) return false;
+  return currentBook.value.chapters.some((c) => c.content && c.content.length > 100);
+});
+
+// 内容是否已生成完成
+const isContentCompleted = computed(() => {
+  if (!currentBook.value) return false;
+  return currentBook.value.status === 'completed';
+});
 
 // 轮询定时器
 let pollTimer: ReturnType<typeof setInterval> | null = null;
+let audioPollTimer: ReturnType<typeof setInterval> | null = null;
+let videoPollTimer: ReturnType<typeof setInterval> | null = null;
 
 // 计算属性
 const completedChapters = computed(() => {
@@ -337,17 +360,6 @@ const quotaInfo = ref<{
   overageEnabled: boolean;
 } | null>(null);
 
-function getStatusText(status: string): string {
-  const map: Record<string, string> = {
-    draft: '草稿',
-    planning: '规划中',
-    generating: '生成中',
-    completed: '已完成',
-    failed: '失败',
-  };
-  return map[status] || status;
-}
-
 function getCurrentStage(): string {
   if (!currentBook.value) return '未知';
   const progress = currentBook.value.progress || 0;
@@ -501,6 +513,54 @@ function stopPollingProgress() {
   }
 }
 
+function startPollingAudioStatus(bookId: string) {
+  stopPollingAudioStatus();
+  audioPollTimer = setInterval(async () => {
+    try {
+      const status = await api.getAudioStatus(bookId);
+      if (status.allCompleted) {
+        stopPollingAudioStatus();
+        generatingAudio.value = false;
+        await loadBook(bookId);
+        uni.showToast({ title: '音频生成完成', icon: 'success' });
+      }
+    } catch (e) {
+      console.error('轮询音频状态失败:', e);
+    }
+  }, 5000);
+}
+
+function stopPollingAudioStatus() {
+  if (audioPollTimer) {
+    clearInterval(audioPollTimer);
+    audioPollTimer = null;
+  }
+}
+
+function startPollingVideoStatus(bookId: string) {
+  stopPollingVideoStatus();
+  videoPollTimer = setInterval(async () => {
+    try {
+      const status = await api.getVideoStatus(bookId);
+      if (status.allCompleted) {
+        stopPollingVideoStatus();
+        generatingVideo.value = false;
+        await loadBook(bookId);
+        uni.showToast({ title: '视频生成完成', icon: 'success' });
+      }
+    } catch (e) {
+      console.error('轮询视频状态失败:', e);
+    }
+  }, 5000);
+}
+
+function stopPollingVideoStatus() {
+  if (videoPollTimer) {
+    clearInterval(videoPollTimer);
+    videoPollTimer = null;
+  }
+}
+
 async function handleRetryGenerate() {
   if (!currentBook.value) return;
   uni.showModal({
@@ -514,6 +574,28 @@ async function handleRetryGenerate() {
   });
 }
 
+async function handleGenerateAudio() {
+  if (!currentBook.value || generatingAudio.value) return;
+  uni.showModal({
+    title: '确认生成音频',
+    content: `将为 ${currentBook.value.totalChapters || '所有'} 个章节生成音频,是否继续?`,
+    success: async (res) => {
+      if (!res.confirm) return;
+      generatingAudio.value = true;
+      try {
+        const result = await api.generateAllChaptersAudio(currentBook.value.id, 'cherry');
+        uni.showToast({ title: `已启动 ${result.totalChapters} 个音频生成`, icon: 'none' });
+        // 开始轮询音频生成状态,不再立即清除 generatingAudio
+        startPollingAudioStatus(currentBook.value.id);
+        // 失败时才清除状态,成功后由轮询完成时清除
+      } catch (e: any) {
+        uni.showToast({ title: e.message || '启动失败', icon: 'none' });
+        generatingAudio.value = false; // 失败时才清除
+      }
+    },
+  });
+}
+
 function enterBatchMode() {
   isBatchMode.value = true;
   selectedChapterIds.value = [];
@@ -668,23 +750,6 @@ onMounted(() => {
     }
   }
   // #endif
-
-  try {
-    const token = uni.getStorageSync('token');
-    if (token) {
-      wsService.connect(token);
-      wsService.on('book_generation_notification', (data: any) => {
-        if (currentBook.value && data.bookId === currentBook.value.id.toString()) {
-          if (data.message) {
-            uni.showToast({ title: data.message, icon: 'none', duration: 3000 });
-          }
-          loadBook(currentBook.value.id);
-        }
-      });
-    }
-  } catch (error) {
-    console.error('[WebSocket] 初始化失败:', error);
-  }
 });
 
 onShow(() => {
@@ -696,6 +761,8 @@ onShow(() => {
 
 onUnmounted(() => {
   stopPollingProgress();
+  stopPollingAudioStatus();
+  stopPollingVideoStatus();
 });
 </script>
 
@@ -769,10 +836,7 @@ onUnmounted(() => {
 .book-subtitle { font-size: 28rpx; color: #6b7280; margin-bottom: 16rpx; display: block; }
 .book-meta-row { display: flex; gap: 24rpx; font-size: 26rpx; color: #9ca3af; margin-top: 16rpx; }
 
-.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; }
+/* 状态标签由 GenerationStatusBadge 组件统一管理 */
 
 .progress-card { margin-bottom: 24rpx; }
 .progress-display { display: flex; align-items: baseline; gap: 16rpx; margin: 16rpx 0; }
@@ -873,6 +937,7 @@ onUnmounted(() => {
 .action-btn.full-width { width: 100%; flex: none; margin-bottom: 16rpx; }
 .action-btn[disabled] { opacity: 0.6; }
 .action-btn.langgraph-btn { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #ffffff; }
+.action-btn.audio-btn { background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: #ffffff; }
 .generating-tip { text-align: center; padding: 20rpx; color: #6b7280; font-size: 26rpx; }
 
 .warning-tip { padding: 24rpx; background: #fef3c7; border-radius: 12rpx; margin-top: 16rpx; }

+ 194 - 72
my-uniapp-vue3/src/pages/book-generator/index.vue

@@ -3,7 +3,7 @@
     <!-- 顶部导航栏 -->
     <view class="nav-bar">
       <view class="nav-content">
-        <view class="nav-left" @click="goBack">
+        <view class="nav-left" @click="goBack" v-if="showBackButton">
           <text class="back-icon">←</text>
         </view>
         <text class="page-title">书籍生成</text>
@@ -42,9 +42,7 @@
           >
             <view class="book-header" @click="openBook(book)">
               <text class="book-title">{{ book.title }}</text>
-              <view :class="['status-badge', book.isPublished ? 'published' : book.status]">
-                {{ book.isPublished ? '已公开' : getStatusText(book.status) }}
-              </view>
+              <GenerationStatusBadge :status="book.isPublished ? 'published' : book.status"></GenerationStatusBadge>
             </view>
             <text class="book-desc" @click="openBook(book)">{{ book.description }}</text>
             <view class="book-meta" @click="openBook(book)">
@@ -54,53 +52,20 @@
             <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>
-            <!-- 批量生成音频按钮 - 根据状态显示不同文案 -->
+            <!-- 操作按钮区域 - 2个主导按钮 + 更多下拉菜单 -->
             <view v-if="book.chapters && book.chapters.length > 0" class="book-actions">
+              <!-- 主按钮1: 生成内容 -->
               <button
-                class="audio-btn"
+                class="primary-btn"
                 :disabled="generatingAudio[book.id]"
                 @click.stop="handleGenerateAllAudio(book)"
               >
                 <text v-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>
-              </button>
-              <button
-                class="merge-audio-btn"
-                :disabled="generatingAudio[book.id] || mergingAudio[book.id] || !canMergeAudio(book)"
-                @click.stop="handleMergeChapterAudio(book)"
-              >
-                <text v-if="mergingAudio[book.id]">🔄 合并中...</text>
-                <text v-else-if="canMergeAudio(book)">🔊 合并音频</text>
-                <text v-else>🔒 需先完成音频生成</text>
-              </button>
-              <button
-                class="merge-video-btn"
-                :disabled="generatingVideo[book.id] || mergingVideo[book.id] || !canMergeVideo(book)"
-                @click.stop="handleMergeChapterVideo(book)"
-              >
-                <text v-if="mergingVideo[book.id]">🔄 合并中...</text>
-                <text v-else-if="canMergeVideo(book)">🎬 合并视频</text>
-                <text v-else>🔒 需先完成视频生成</text>
-              </button>
-              <button
-                class="video-btn"
-                :disabled="generatingVideo[book.id]"
-                @click.stop="handleGenerateAllVideo(book)"
-              >
-                <text v-if="generatingVideo[book.id]">🎬 生成中...</text>
-                <text v-else-if="getBookVideoStatus(book).status === 'completed'">🔄 重新生成视频</text>
-                <text v-else-if="getBookVideoStatus(book).status === 'partial'">🎬 继续生成视频({{ getBookVideoStatus(book).completed }}/{{ getBookVideoStatus(book).total }})</text>
-                <text v-else>🎬 生成全部视频</text>
-              </button>
-              <button
-                v-if="false"
-                class="publish-all-btn"
-                @click.stop="goToPublishBook(book)"
-              >
-                🚀 发布到平台
+                <text v-else-if="getBookAudioStatus(book).status === 'partial'">🎵 继续生成({{ getBookAudioStatus(book).completed }}/{{ getBookAudioStatus(book).total }})</text>
+                <text v-else>🎵 生成内容</text>
               </button>
+              <!-- 主按钮2: 公开/取消公开 -->
               <button
                 class="publish-btn"
                 :disabled="togglingPublish[book.id]"
@@ -108,6 +73,13 @@
               >
                 {{ togglingPublish[book.id] ? '处理中...' : (book.isPublished ? '🔒 取消公开' : '🌐 公开书籍') }}
               </button>
+              <!-- 更多按钮 -->
+              <button
+                class="more-btn"
+                @click.stop="showMoreActions(book)"
+              >
+                ···
+              </button>
             </view>
           </view>
         </view>
@@ -123,10 +95,22 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from 'vue';
+import { ref, computed, onMounted, onUnmounted } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import * as api from '../../utils/book-generator-api';
 import type { Book } from '../../utils/book-generator-api';
+import { wsManager } from '../../utils/websocket';
+import GenerationStatusBadge from '../../components/GenerationStatusBadge.vue';
+
+// Tab 页标识
+const isTabPage = ref(true);
+
+// 是否显示返回按钮(Tab 页隐藏,非 Tab 页显示)
+const showBackButton = computed(() => {
+  if (!isTabPage.value) return true;
+  const pages = getCurrentPages();
+  return pages.length > 1;
+});
 
 // 书籍列表
 const books = ref<Book[]>([]);
@@ -139,6 +123,10 @@ const mergingAudio = ref<Record<string, boolean> >({});
 const mergingVideo = ref<Record<string, boolean> >({});
 const togglingPublish = ref<Record<string, boolean> >({});
 
+// 轮询定时器
+const audioPollTimers = ref<Record<string, ReturnType<typeof setInterval>>>({});
+const videoPollTimers = ref<Record<string, ReturnType<typeof setInterval>>>({});
+
 function goBack() {
   const pages = getCurrentPages();
   if (pages.length > 1) {
@@ -160,17 +148,6 @@ 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 getBookLeafLevel(book: Book): number {
   const chapters = book.chapters || [];
   if (chapters.length === 0) return 0;
@@ -186,11 +163,18 @@ function getBookAudioStatus(book: Book) {
   const leafLevel = getBookLeafLevel(book);
   const leafNodes = getBookLeafNodes(book, leafLevel);
   const total = leafNodes.length;
-  const completed = leafNodes.filter(n => n.audioUrl).length;
-  let status: 'none' | 'partial' | 'completed' = 'none';
+  // 优先使用 audioStatus 字段,降级到 audioUrl 推断
+  const completed = leafNodes.filter(n => {
+    if (n.audioStatus === 'completed') return true;
+    return !n.audioStatus && n.audioUrl; // 兼容旧数据
+  }).length;
+  const anyFailed = leafNodes.some(n => n.audioStatus === 'failed');
+  const anyProcessing = leafNodes.some(n => n.audioStatus === 'generating' || n.audioStatus === 'processing' || n.audioStatus === 'queued');
+  let status: 'none' | 'partial' | 'completed' | 'generating' | 'failed' = 'none';
   if (completed === total && total > 0) status = 'completed';
+  else if (anyFailed) status = 'failed';
+  else if (anyProcessing || generatingAudio.value[book.id]) status = 'generating';
   else if (completed > 0) status = 'partial';
-  if (generatingAudio.value[book.id]) status = 'generating';
   return { total, completed, status };
 }
 
@@ -237,13 +221,45 @@ async function openBook(book: Book) {
   uni.navigateTo({ url: `/pages/book-generator/detail?id=${book.id}` });
 }
 
+// 辅助函数:清理指定书籍的轮询定时器
+function clearAudioPollTimer(bookId: string) {
+  if (audioPollTimers.value[bookId]) {
+    clearInterval(audioPollTimers.value[bookId]);
+    delete audioPollTimers.value[bookId];
+  }
+}
+
+function clearVideoPollTimer(bookId: string) {
+  if (videoPollTimers.value[bookId]) {
+    clearInterval(videoPollTimers.value[bookId]);
+    delete videoPollTimers.value[bookId];
+  }
+}
+
 async function handleGenerateAllAudio(book: Book) {
+  if (generatingAudio.value[book.id]) return;
   generatingAudio.value[book.id] = true;
+  // 清理可能存在的旧定时器
+  clearAudioPollTimer(book.id);
   try {
     const result = await api.generateAllChaptersAudio(book.id, 'cherry');
     uni.showToast({ title: `已启动 ${result.totalChapters} 个章节的音频生成`, icon: 'none', duration: 2500 });
-    setTimeout(async () => { await loadBooks(); generatingAudio.value[book.id] = false; }, 5000);
+    // 开始轮询音频生成状态
+    audioPollTimers.value[book.id] = setInterval(async () => {
+      try {
+        const status = await api.getAudioStatus(book.id);
+        if (status.allCompleted) {
+          clearAudioPollTimer(book.id);
+          generatingAudio.value[book.id] = false;
+          await loadBooks();
+          uni.showToast({ title: '音频生成完成', icon: 'success' });
+        }
+      } catch (e) {
+        console.error('轮询音频状态失败:', e);
+      }
+    }, 5000);
   } catch (e: any) {
+    clearAudioPollTimer(book.id);
     generatingAudio.value[book.id] = false;
     uni.showToast({ title: e.message || '生成失败', icon: 'none' });
   }
@@ -276,12 +292,29 @@ async function handleMergeChapterVideo(book: Book) {
 }
 
 async function handleGenerateAllVideo(book: Book) {
+  if (generatingVideo.value[book.id]) return;
   generatingVideo.value[book.id] = true;
+  // 清理可能存在的旧定时器
+  clearVideoPollTimer(book.id);
   try {
     const result = await api.generateAllChaptersVideo(book.id);
     uni.showToast({ title: `已启动 ${result.totalChapters} 个章节的视频生成`, icon: 'none', duration: 2500 });
-    setTimeout(async () => { await loadBooks(); generatingVideo.value[book.id] = false; }, 5000);
+    // 开始轮询视频生成状态
+    videoPollTimers.value[book.id] = setInterval(async () => {
+      try {
+        const status = await api.getVideoStatus(book.id);
+        if (status.allCompleted) {
+          clearVideoPollTimer(book.id);
+          generatingVideo.value[book.id] = false;
+          await loadBooks();
+          uni.showToast({ title: '视频生成完成', icon: 'success' });
+        }
+      } catch (e) {
+        console.error('轮询视频状态失败:', e);
+      }
+    }, 5000);
   } catch (e: any) {
+    clearVideoPollTimer(book.id);
     generatingVideo.value[book.id] = false;
     uni.showToast({ title: e.message || '生成失败', icon: 'none' });
   }
@@ -300,12 +333,98 @@ async function handleTogglePublish(book: any) {
   }
 }
 
-onMounted(() => {
+// 显示更多操作菜单
+function showMoreActions(book: Book) {
+  const audioStatus = getBookAudioStatus(book);
+  const videoStatus = getBookVideoStatus(book);
+  const actions: string[] = [];
+
+  // 合并音频(音频有完成时才显示)
+  if (canMergeAudio(book)) {
+    actions.push('🔊 合并音频');
+  }
+  // 合并视频(视频有完成时才显示)
+  if (canMergeVideo(book)) {
+    actions.push('🎬 合并视频');
+  }
+  // 生成全部视频(音频完成且视频未完成或已完成时显示)
+  if (audioStatus.status === 'completed' || audioStatus.status === 'none') {
+    if (videoStatus.status !== 'completed' || generatingVideo.value[book.id]) {
+      actions.push('🎬 生成全部视频');
+    }
+  }
+  // 重新生成视频(视频已完成时显示)
+  if (videoStatus.status === 'completed') {
+    actions.push('🔄 重新生成视频');
+  }
+
+  // 如果没有可用操作,提示用户
+  if (actions.length === 0) {
+    uni.showToast({ title: '请先完成音频生成', icon: 'none' });
+    return;
+  }
+
+  uni.showActionSheet({
+    itemList: actions,
+    success: (res) => {
+      const action = actions[res.tapIndex];
+      if (action === '🔊 合并音频') {
+        mergingAudio.value[book.id] = true;
+        uni.showToast({ title: '正在合并音频...', icon: 'loading', duration: 10000 });
+        handleMergeChapterAudio(book);
+      } else if (action === '🎬 合并视频') {
+        mergingVideo.value[book.id] = true;
+        uni.showToast({ title: '正在合并视频...', icon: 'loading', duration: 10000 });
+        handleMergeChapterVideo(book);
+      } else if (action === '🎬 生成全部视频') {
+        handleGenerateAllVideo(book);
+      } else if (action === '🔄 重新生成视频') {
+        handleGenerateAllVideo(book);
+      }
+    }
+  });
+}
+
+onShow(() => {
   loadBooks();
 });
 
-onShow(() => {
+// WebSocket 事件处理
+function handleAudioGenerationComplete(data: { bookId: string; chapterId: number; status: 'completed' | 'failed' }) {
+  if (data.bookId && generatingAudio.value[data.bookId]) {
+    clearAudioPollTimer(data.bookId);
+    generatingAudio.value[data.bookId] = false;
+    loadBooks();
+    uni.showToast({ title: data.status === 'completed' ? '音频生成完成' : '音频生成失败', icon: data.status === 'completed' ? 'success' : 'none' });
+  }
+}
+
+function handleVideoGenerationComplete(data: { bookId: string; chapterId: number; status: 'completed' | 'failed' }) {
+  if (data.bookId && generatingVideo.value[data.bookId]) {
+    clearVideoPollTimer(data.bookId);
+    generatingVideo.value[data.bookId] = false;
+    loadBooks();
+    uni.showToast({ title: data.status === 'completed' ? '视频生成完成' : '视频生成失败', icon: data.status === 'completed' ? 'success' : 'none' });
+  }
+}
+
+// 初始化 WebSocket 连接
+onMounted(() => {
   loadBooks();
+  // 连接 WebSocket 并订阅事件
+  wsManager.connect().then(() => {
+    wsManager.on('audio_generation_complete', handleAudioGenerationComplete);
+    wsManager.on('video_generation_complete', handleVideoGenerationComplete);
+  }).catch((e) => {
+    console.error('[BookGenerator] WebSocket 连接失败:', e);
+  });
+});
+
+// 页面卸载时关闭 WebSocket
+onUnmounted(() => {
+  // 取消订阅
+  wsManager.off('audio_generation_complete', handleAudioGenerationComplete);
+  wsManager.off('video_generation_complete', handleVideoGenerationComplete);
 });
 </script>
 
@@ -332,20 +451,23 @@ onShow(() => {
 .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.published { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; font-weight: 600; }
+/* 状态标签由 GenerationStatusBadge 组件统一管理 */
 .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; }
-.book-actions { display: flex; flex-direction: column; gap: 12rpx; }
-.audio-btn, .merge-audio-btn, .merge-video-btn, .video-btn, .publish-btn { width: 100%; height: 72rpx; border-radius: 12rpx; font-size: 26rpx; display: flex; align-items: center; justify-content: center; border: none; }
-.audio-btn { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #ffffff; }
-.merge-audio-btn { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; }
-.merge-video-btn { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: #ffffff; }
-.video-btn { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #ffffff; }
-.publish-btn { background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: #ffffff; }
-.audio-btn[disabled], .merge-audio-btn[disabled], .merge-video-btn[disabled], .video-btn[disabled], .publish-btn[disabled] { opacity: 0.6; }
+.book-actions { display: flex; gap: 12rpx; }
+.primary-btn, .publish-btn, .more-btn { height: 72rpx; border-radius: 12rpx; font-size: 26rpx; display: flex; align-items: center; justify-content: center; border: none; }
+.primary-btn { flex: 1; background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #ffffff; }
+.publish-btn { width: 180rpx; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: #ffffff; }
+.more-btn { width: 80rpx; background: #e5e7eb; color: #6b7280; font-size: 32rpx; font-weight: bold; }
+.primary-btn[disabled], .publish-btn[disabled], .more-btn[disabled] { opacity: 0.6; }
+/* 夜间模式适配 */
+@media (prefers-color-scheme: dark) {
+  .more-btn { background: #374151; color: #d1d5db; }
+  .primary-btn { background: linear-gradient(135deg, #059669 0%, #047857 100%); }
+  .publish-btn { background: linear-gradient(135deg, #4338ca 0%, #4f46e5 100%); }
+}
 .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; }

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

@@ -38,9 +38,8 @@
         <view v-for="order in orders" :key="order.id" class="order-card">
           <view class="order-header">
             <text class="order-plan">{{ order.planName || '套餐订阅' }}</text>
-            <text class="order-status" :class="order.status">
-              {{ getStatusText(order.status) }}
-            </text>
+            <GenerationStatusBadge :status="order.status"></GenerationStatusBadge>
+
           </view>
 
           <view class="order-info">
@@ -111,6 +110,7 @@
 
 <script setup lang="ts">
 import { ref, computed, onMounted } from 'vue';
+import GenerationStatusBadge from '../../components/GenerationStatusBadge.vue';
 import { useUserStore } from '../../store/user';
 import { get } from '../../utils/request';
 
@@ -194,16 +194,6 @@ function formatDate(dateStr: string): string {
   return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
 }
 
-function getStatusText(status: string): string {
-  const statusMap: Record<string, string> = {
-    pending: '待支付',
-    paid: '已支付',
-    failed: '支付失败',
-    refunded: '已退款'
-  };
-  return statusMap[status] || status;
-}
-
 function getPaymentMethodText(method: string): string {
   const methodMap: Record<string, string> = {
     alipay: '支付宝',
@@ -255,11 +245,8 @@ function getUsageTypeText(type: string): string {
 .order-card { background: #fff; border-radius: 16rpx; padding: 24rpx; }
 .order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; padding-bottom: 16rpx; border-bottom: 1rpx solid #f3f4f6; }
 .order-plan { font-size: 30rpx; font-weight: 600; color: #1f2937; }
-.order-status { font-size: 24rpx; padding: 4rpx 16rpx; border-radius: 12rpx; }
-.order-status.pending { background: #fef3c7; color: #92400e; }
-.order-status.paid { background: #d1fae5; color: #065f46; }
-.order-status.failed { background: #fee2e2; color: #991b1b; }
-.order-status.refunded { background: #e5e7eb; color: #374151; }
+/* 订单状态由 GenerationStatusBadge 组件统一管理 */
+
 
 .order-info { display: flex; flex-direction: column; gap: 12rpx; margin-bottom: 16rpx; }
 .info-row { display: flex; justify-content: space-between; }

+ 4 - 42
my-uniapp-vue3/src/pages/video-generator/index.vue

@@ -41,9 +41,8 @@
             </view>
 
             <!-- 状态标签 -->
-            <view :class="['status-badge', project.status]">
-              {{ getStatusText(project.status) }}
-            </view>
+            <GenerationStatusBadge :status="project.status"></GenerationStatusBadge>
+
 
             <!-- 进度条 -->
             <view v-if="project.status === 'processing'" class="progress-overlay">
@@ -109,6 +108,7 @@
 <script setup lang="ts">
 import { ref, onMounted } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
+import GenerationStatusBadge from '../../components/GenerationStatusBadge';
 import {
   getVideoProjects,
   deleteVideoProject,
@@ -165,18 +165,6 @@ function loadMore() {
   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;
@@ -365,33 +353,7 @@ onMounted(() => {
   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;
-}
+/* 状态标签由 GenerationStatusBadge 组件统一管理 */
 
 /* 进度覆盖层 */
 .progress-overlay {