|
|
@@ -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; }
|