| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498 |
- <template>
- <view class="page">
- <!-- 顶部导航栏 -->
- <view class="nav-bar">
- <view class="nav-content">
- <view class="nav-left" @click="goBack" v-if="showBackButton">
- <text class="back-icon">←</text>
- </view>
- <text class="page-title">书籍生成</text>
- <view class="nav-right">
- <text class="nav-btn" @click="switchTab">📚</text>
- </view>
- </view>
- </view>
- <!-- 主内容区 -->
- <view class="main-content">
- <!-- 书籍列表 -->
- <view class="create-card" @click="goToCreate">
- <text class="create-icon">+</text>
- <text class="create-text">创建新书籍</text>
- </view>
-
- <!-- 书籍列表 -->
- <view v-if="books.length > 0" class="book-list">
- <view
- v-for="book in books"
- :key="book.id"
- class="book-card"
- >
- <view class="book-header" @click="openBook(book)">
- <text class="book-title">{{ book.title }}</text>
- <view class="header-right">
- <text class="edit-icon" @click.stop="goToEdit(book)">✏️</text>
- <GenerationStatusBadge :status="book.isPublished ? 'published' : (book.genStage || book.status || 'draft')"></GenerationStatusBadge>
- </view>
- </view>
- <text class="book-desc" @click="openBook(book)">{{ book.description }}</text>
- <view class="book-meta" @click="openBook(book)">
- <text>{{ book.totalChapters }}章</text>
- <text v-if="book.progress > 0">{{ book.progress }}%</text>
- </view>
- <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="primary-btn"
- :disabled="generatingAudio[book.id] && !stoppingAudio[book.id]"
- @click.stop="generatingAudio[book.id] ? handleStopAudioGeneration(book) : handleGenerateAllAudio(book)"
- >
- <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>
- </button>
- <!-- 主按钮2: 公开/取消公开 -->
- <button
- class="publish-btn"
- :disabled="togglingPublish[book.id]"
- @click.stop="handleTogglePublish(book)"
- >
- {{ togglingPublish[book.id] ? '处理中...' : (book.isPublished ? '🔒 取消公开' : '🌐 公开书籍') }}
- </button>
- <!-- 更多按钮 -->
- <button
- class="more-btn"
- @click.stop="showMoreActions(book)"
- >
- ···
- </button>
- </view>
- </view>
- </view>
- <!-- 空状态 -->
- <view v-else class="empty-state">
- <text class="empty-icon">📖</text>
- <text class="empty-text">暂无书籍</text>
- <text class="empty-hint">点击上方按钮创建第一本书</text>
- </view>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- 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 { useNotificationStore } from '../../store/notification';
- 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[]>([]);
- const isLoadingBooks = ref(false);
- // 音频/视频生成状态
- const generatingAudio = ref<Record<string, boolean> >({});
- 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>>>({});
- const videoPollTimers = ref<Record<string, ReturnType<typeof setInterval>>>({});
- function goBack() {
- const pages = getCurrentPages();
- if (pages.length > 1) {
- uni.navigateBack();
- } else {
- uni.switchTab({ url: '/pages/index/index' });
- }
- }
- function switchTab() {
- uni.switchTab({ url: '/pages/book-generator/index' });
- }
- function goToCreate() {
- uni.navigateTo({ url: '/pages/book-generator/create' });
- }
- function goToVideoGenerator() {
- uni.navigateTo({ url: '/pages/video-generator/index' });
- }
- function getBookLeafLevel(book: Book): number {
- const chapters = book.chapters || [];
- if (chapters.length === 0) return 0;
- const maxLevel = Math.max(...chapters.map(c => c.level || 0));
- return maxLevel > 0 ? maxLevel : 1;
- }
- function getBookLeafNodes(book: Book, leafLevel: number): any[] {
- return (book.chapters || []).filter(c => c.level === leafLevel);
- }
- function getBookAudioStatus(book: Book) {
- const leafLevel = getBookLeafLevel(book);
- const leafNodes = getBookLeafNodes(book, leafLevel);
- const total = leafNodes.length;
- // 优先使用 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';
- return { total, completed, status };
- }
- function getBookVideoStatus(book: Book) {
- const leafLevel = getBookLeafLevel(book);
- const leafNodes = getBookLeafNodes(book, leafLevel);
- const total = leafNodes.length;
- const completed = leafNodes.filter(n => n.videoUrl).length;
- let status: 'none' | 'partial' | 'completed' | 'generating' = 'none';
- if (completed === total && total > 0) status = 'completed';
- else if (completed > 0) status = 'partial';
- if (generatingVideo.value[book.id]) status = 'generating';
- return { total, completed, status };
- }
- function canMergeAudio(book: Book): boolean {
- const leafLevel = getBookLeafLevel(book);
- if (leafLevel <= 1) return false;
- const leafNodes = getBookLeafNodes(book, leafLevel);
- return leafNodes.every(n => n.audioUrl) && leafNodes.length > 0;
- }
- function canMergeVideo(book: Book): boolean {
- const leafLevel = getBookLeafLevel(book);
- if (leafLevel <= 1) return false;
- const leafNodes = getBookLeafNodes(book, leafLevel);
- return leafNodes.every(n => n.videoUrl) && leafNodes.length > 0;
- }
- async function loadBooks() {
- if (isLoadingBooks.value) return;
- isLoadingBooks.value = true;
- try {
- books.value = await api.getBooks() || [];
- } catch (e) {
- console.error('加载书籍失败:', e);
- books.value = [];
- } finally {
- isLoadingBooks.value = false;
- }
- }
- async function openBook(book: Book) {
- uni.navigateTo({ url: `/pages/book-generator/detail?id=${book.id}` });
- }
- function goToEdit(book: Book) {
- uni.navigateTo({ url: `/pages/book-generator/create?editId=${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 });
- // 开始轮询音频生成状态
- 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' });
- // 写入通知
- const notifStore = useNotificationStore();
- notifStore.add({ type: 'audio_complete', title: '音频生成完成', message: `《${book.title}》音频已全部生成完毕`, bookId: String(book.id) });
- }
- } catch (e) {
- console.error('轮询音频状态失败:', e);
- }
- }, 5000);
- } catch (e: any) {
- clearAudioPollTimer(book.id);
- generatingAudio.value[book.id] = false;
- uni.showToast({ title: e.message || '生成失败', icon: 'none' });
- }
- }
- 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;
- try {
- const result = await api.mergeChapterAudios(book.id);
- uni.showToast({ title: `已启动音频合并,处理${result.processedParents}个上级章节`, icon: 'none', duration: 2500 });
- setTimeout(async () => { await loadBooks(); mergingAudio.value[book.id] = false; }, 5000);
- } catch (e: any) {
- mergingAudio.value[book.id] = false;
- uni.showToast({ title: e.message || '音频合并失败', icon: 'none' });
- }
- }
- async function handleMergeChapterVideo(book: Book) {
- if (!canMergeVideo(book)) { uni.showToast({ title: '并非所有叶节点视频都已生成完成', icon: 'none' }); return; }
- mergingVideo.value[book.id] = true;
- try {
- const result = await api.mergeChapterVideos(book.id);
- uni.showToast({ title: `已启动视频合并,处理${result.processedParents}个上级章节`, icon: 'none', duration: 2500 });
- setTimeout(async () => { await loadBooks(); mergingVideo.value[book.id] = false; }, 5000);
- } catch (e: any) {
- mergingVideo.value[book.id] = false;
- uni.showToast({ title: e.message || '视频合并失败', icon: 'none' });
- }
- }
- async function handleGenerateAllVideo(book: Book) {
- if (generatingVideo.value[book.id]) return;
- uni.showModal({
- title: '确认重新生成视频',
- content: `确定要重新生成《${book.title}》的所有视频吗?这将清除现有视频并重新生成。`,
- success: async (res) => {
- if (!res.confirm) 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 });
- // 开始轮询视频生成状态
- 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' });
- // 写入通知
- const notifStore = useNotificationStore();
- notifStore.add({ type: 'video_complete', title: '视频生成完成', message: `《${book.title}》视频已全部生成完毕`, bookId: String(book.id) });
- }
- } catch (e) {
- console.error('轮询视频状态失败:', e);
- }
- }, 5000);
- } catch (e: any) {
- clearVideoPollTimer(book.id);
- generatingVideo.value[book.id] = false;
- uni.showToast({ title: e.message || '生成失败', icon: 'none' });
- }
- },
- });
- }
- async function handleTogglePublish(book: any) {
- togglingPublish.value[book.id] = true;
- try {
- const res = await api.toggleBookPublish(book.id);
- book.isPublished = res.isPublished;
- uni.showToast({ title: book.isPublished ? '已公开到首页' : '已取消公开', icon: 'none' });
- } catch (e: any) {
- uni.showToast({ title: e.message || '操作失败', icon: 'none' });
- } finally {
- togglingPublish.value[book.id] = false;
- }
- }
- // 显示更多操作菜单
- 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('🔄 重新生成视频');
- }
- // 编辑书籍
- actions.push('✏️ 编辑书籍');
- // 删除书籍
- 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);
- } else if (action === '✏️ 编辑书籍') {
- goToEdit(book);
- } else if (action === '🗑 删除书籍') {
- handleDeleteBook(book);
- }
- }
- });
- }
- async function handleDeleteBook(book: Book) {
- uni.showModal({
- title: '确认删除',
- content: `确定要删除《${book.title}》吗?此操作不可恢复。`,
- success: async (res) => {
- if (!res.confirm) return;
- try {
- await api.deleteBook(book.id);
- uni.showToast({ title: '删除成功', icon: 'success' });
- await loadBooks();
- } catch (e: any) {
- uni.showToast({ title: e.message || '删除失败', icon: 'none' });
- }
- }
- });
- }
- onShow(() => {
- loadBooks();
- });
- // 初始化
- onMounted(() => {
- loadBooks();
- });
- // 页面卸载时清理所有轮询
- onUnmounted(() => {
- // 清理所有轮询定时器
- Object.keys(audioPollTimers.value).forEach(key => clearAudioPollTimer(key));
- Object.keys(videoPollTimers.value).forEach(key => clearVideoPollTimer(key));
- });
- </script>
- <style scoped>
- .page { min-height: 100vh; background: #f9fafb; }
- .nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: #ffffff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
- .nav-content { display: flex; align-items: center; justify-content: space-between; height: 88rpx; padding: 0 32rpx; padding-top: env(safe-area-inset-top); }
- .nav-left, .nav-right { width: 80rpx; height: 88rpx; display: flex; align-items: center; justify-content: center; }
- .back-icon, .nav-btn { font-size: 40rpx; color: #1f2937; }
- .page-title { font-size: 32rpx; font-weight: 600; color: #1f2937; }
- .main-content { padding: 120rpx 32rpx 32rpx; }
- .card { background: #ffffff; border-radius: 24rpx; padding: 32rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); }
- .create-card { display: flex; align-items: center; justify-content: center; gap: 16rpx; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 24rpx; padding: 48rpx; margin-bottom: 24rpx; }
- .create-icon { font-size: 48rpx; color: #ffffff; }
- .create-text { font-size: 32rpx; font-weight: 600; color: #ffffff; }
- .book-list { display: flex; flex-direction: column; gap: 20rpx; }
- .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; }
- .header-right { display: flex; align-items: center; gap: 12rpx; flex-shrink: 0; }
- .edit-icon { font-size: 28rpx; padding: 4rpx; opacity: 0.5; }
- .edit-icon:active { opacity: 1; }
- /* 状态标签由 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; 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; white-space: nowrap; }
- .primary-btn { flex: 1; background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #ffffff; padding: 0 16rpx; }
- .publish-btn { width: auto; min-width: 160rpx; padding: 0 24rpx; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: #ffffff; }
- .more-btn { width: 80rpx; background: #e5e7eb; color: #6b7280; font-size: 32rpx; font-weight: bold; }
- .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; }
- .empty-hint { font-size: 26rpx; color: #9ca3af; }
- </style>
|