index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. <template>
  2. <view class="page">
  3. <!-- 顶部导航栏 -->
  4. <view class="nav-bar">
  5. <view class="nav-content">
  6. <view class="nav-left" @click="goBack" v-if="showBackButton">
  7. <text class="back-icon">←</text>
  8. </view>
  9. <text class="page-title">书籍生成</text>
  10. <view class="nav-right">
  11. <text class="nav-btn" @click="switchTab">📚</text>
  12. </view>
  13. </view>
  14. </view>
  15. <!-- 主内容区 -->
  16. <view class="main-content">
  17. <!-- 书籍列表 -->
  18. <view class="create-card" @click="goToCreate">
  19. <text class="create-icon">+</text>
  20. <text class="create-text">创建新书籍</text>
  21. </view>
  22. <!-- 书籍列表 -->
  23. <view v-if="books.length > 0" class="book-list">
  24. <view
  25. v-for="book in books"
  26. :key="book.id"
  27. class="book-card"
  28. >
  29. <view class="book-header" @click="openBook(book)">
  30. <text class="book-title">{{ book.title }}</text>
  31. <GenerationStatusBadge :status="book.isPublished ? 'published' : book.status"></GenerationStatusBadge>
  32. </view>
  33. <text class="book-desc" @click="openBook(book)">{{ book.description }}</text>
  34. <view class="book-meta" @click="openBook(book)">
  35. <text>{{ book.totalChapters }}章</text>
  36. <text v-if="book.progress > 0">{{ book.progress }}%</text>
  37. </view>
  38. <view v-if="book.progress > 0 && book.progress < 100" class="progress-bar" @click="openBook(book)">
  39. <view class="progress-fill" :style="{ width: book.progress + '%' }"></view>
  40. </view>
  41. <!-- 操作按钮区域 - 2个主导按钮 + 更多下拉菜单 -->
  42. <view v-if="book.chapters && book.chapters.length > 0" class="book-actions">
  43. <!-- 主按钮1: 生成内容 -->
  44. <button
  45. class="primary-btn"
  46. :disabled="generatingAudio[book.id]"
  47. @click.stop="handleGenerateAllAudio(book)"
  48. >
  49. <text v-if="generatingAudio[book.id]">🎵 生成中...</text>
  50. <text v-else-if="getBookAudioStatus(book).status === 'completed'">🔄 重新生成音频</text>
  51. <text v-else-if="getBookAudioStatus(book).status === 'partial'">🎵 继续生成({{ getBookAudioStatus(book).completed }}/{{ getBookAudioStatus(book).total }})</text>
  52. <text v-else>🎵 生成内容</text>
  53. </button>
  54. <!-- 主按钮2: 公开/取消公开 -->
  55. <button
  56. class="publish-btn"
  57. :disabled="togglingPublish[book.id]"
  58. @click.stop="handleTogglePublish(book)"
  59. >
  60. {{ togglingPublish[book.id] ? '处理中...' : (book.isPublished ? '🔒 取消公开' : '🌐 公开书籍') }}
  61. </button>
  62. <!-- 更多按钮 -->
  63. <button
  64. class="more-btn"
  65. @click.stop="showMoreActions(book)"
  66. >
  67. ···
  68. </button>
  69. </view>
  70. </view>
  71. </view>
  72. <!-- 空状态 -->
  73. <view v-else class="empty-state">
  74. <text class="empty-icon">📖</text>
  75. <text class="empty-text">暂无书籍</text>
  76. <text class="empty-hint">点击上方按钮创建第一本书</text>
  77. </view>
  78. </view>
  79. </view>
  80. </template>
  81. <script setup lang="ts">
  82. import { ref, computed, onMounted, onUnmounted } from 'vue';
  83. import { onShow } from '@dcloudio/uni-app';
  84. import * as api from '../../utils/book-generator-api';
  85. import type { Book } from '../../utils/book-generator-api';
  86. import { useNotificationStore } from '../../store/notification';
  87. import GenerationStatusBadge from '../../components/GenerationStatusBadge.vue';
  88. // Tab 页标识
  89. const isTabPage = ref(true);
  90. // 是否显示返回按钮(Tab 页隐藏,非 Tab 页显示)
  91. const showBackButton = computed(() => {
  92. if (!isTabPage.value) return true;
  93. const pages = getCurrentPages();
  94. return pages.length > 1;
  95. });
  96. // 书籍列表
  97. const books = ref<Book[]>([]);
  98. const isLoadingBooks = ref(false);
  99. // 音频/视频生成状态
  100. const generatingAudio = ref<Record<string, boolean> >({});
  101. const generatingVideo = ref<Record<string, boolean> >({});
  102. const mergingAudio = ref<Record<string, boolean> >({});
  103. const mergingVideo = ref<Record<string, boolean> >({});
  104. const togglingPublish = ref<Record<string, boolean> >({});
  105. // 轮询定时器
  106. const audioPollTimers = ref<Record<string, ReturnType<typeof setInterval>>>({});
  107. const videoPollTimers = ref<Record<string, ReturnType<typeof setInterval>>>({});
  108. function goBack() {
  109. const pages = getCurrentPages();
  110. if (pages.length > 1) {
  111. uni.navigateBack();
  112. } else {
  113. uni.switchTab({ url: '/pages/index/index' });
  114. }
  115. }
  116. function switchTab() {
  117. uni.switchTab({ url: '/pages/book-generator/index' });
  118. }
  119. function goToCreate() {
  120. uni.navigateTo({ url: '/pages/book-generator/create' });
  121. }
  122. function goToVideoGenerator() {
  123. uni.navigateTo({ url: '/pages/video-generator/index' });
  124. }
  125. function getBookLeafLevel(book: Book): number {
  126. const chapters = book.chapters || [];
  127. if (chapters.length === 0) return 0;
  128. const maxLevel = Math.max(...chapters.map(c => c.level || 0));
  129. return maxLevel > 0 ? maxLevel : 1;
  130. }
  131. function getBookLeafNodes(book: Book, leafLevel: number): any[] {
  132. return (book.chapters || []).filter(c => c.level === leafLevel);
  133. }
  134. function getBookAudioStatus(book: Book) {
  135. const leafLevel = getBookLeafLevel(book);
  136. const leafNodes = getBookLeafNodes(book, leafLevel);
  137. const total = leafNodes.length;
  138. // 优先使用 audioStatus 字段,降级到 audioUrl 推断
  139. const completed = leafNodes.filter(n => {
  140. if (n.audioStatus === 'completed') return true;
  141. return !n.audioStatus && n.audioUrl; // 兼容旧数据
  142. }).length;
  143. const anyFailed = leafNodes.some(n => n.audioStatus === 'failed');
  144. const anyProcessing = leafNodes.some(n => n.audioStatus === 'generating' || n.audioStatus === 'processing' || n.audioStatus === 'queued');
  145. let status: 'none' | 'partial' | 'completed' | 'generating' | 'failed' = 'none';
  146. if (completed === total && total > 0) status = 'completed';
  147. else if (anyFailed) status = 'failed';
  148. else if (anyProcessing || generatingAudio.value[book.id]) status = 'generating';
  149. else if (completed > 0) status = 'partial';
  150. return { total, completed, status };
  151. }
  152. function getBookVideoStatus(book: Book) {
  153. const leafLevel = getBookLeafLevel(book);
  154. const leafNodes = getBookLeafNodes(book, leafLevel);
  155. const total = leafNodes.length;
  156. const completed = leafNodes.filter(n => n.videoUrl).length;
  157. let status: 'none' | 'partial' | 'completed' | 'generating' = 'none';
  158. if (completed === total && total > 0) status = 'completed';
  159. else if (completed > 0) status = 'partial';
  160. if (generatingVideo.value[book.id]) status = 'generating';
  161. return { total, completed, status };
  162. }
  163. function canMergeAudio(book: Book): boolean {
  164. const leafLevel = getBookLeafLevel(book);
  165. if (leafLevel <= 1) return false;
  166. const leafNodes = getBookLeafNodes(book, leafLevel);
  167. return leafNodes.every(n => n.audioUrl) && leafNodes.length > 0;
  168. }
  169. function canMergeVideo(book: Book): boolean {
  170. const leafLevel = getBookLeafLevel(book);
  171. if (leafLevel <= 1) return false;
  172. const leafNodes = getBookLeafNodes(book, leafLevel);
  173. return leafNodes.every(n => n.videoUrl) && leafNodes.length > 0;
  174. }
  175. async function loadBooks() {
  176. if (isLoadingBooks.value) return;
  177. isLoadingBooks.value = true;
  178. try {
  179. books.value = await api.getBooks() || [];
  180. } catch (e) {
  181. console.error('加载书籍失败:', e);
  182. books.value = [];
  183. } finally {
  184. isLoadingBooks.value = false;
  185. }
  186. }
  187. async function openBook(book: Book) {
  188. uni.navigateTo({ url: `/pages/book-generator/detail?id=${book.id}` });
  189. }
  190. // 辅助函数:清理指定书籍的轮询定时器
  191. function clearAudioPollTimer(bookId: string) {
  192. if (audioPollTimers.value[bookId]) {
  193. clearInterval(audioPollTimers.value[bookId]);
  194. delete audioPollTimers.value[bookId];
  195. }
  196. }
  197. function clearVideoPollTimer(bookId: string) {
  198. if (videoPollTimers.value[bookId]) {
  199. clearInterval(videoPollTimers.value[bookId]);
  200. delete videoPollTimers.value[bookId];
  201. }
  202. }
  203. async function handleGenerateAllAudio(book: Book) {
  204. if (generatingAudio.value[book.id]) return;
  205. generatingAudio.value[book.id] = true;
  206. // 清理可能存在的旧定时器
  207. clearAudioPollTimer(book.id);
  208. try {
  209. const result = await api.generateAllChaptersAudio(book.id, 'cherry');
  210. uni.showToast({ title: `已启动 ${result.totalChapters} 个章节的音频生成`, icon: 'none', duration: 2500 });
  211. // 开始轮询音频生成状态
  212. audioPollTimers.value[book.id] = setInterval(async () => {
  213. try {
  214. const status = await api.getAudioStatus(book.id);
  215. if (status.allCompleted) {
  216. clearAudioPollTimer(book.id);
  217. generatingAudio.value[book.id] = false;
  218. await loadBooks();
  219. uni.showToast({ title: '音频生成完成', icon: 'success' });
  220. // 写入通知
  221. const notifStore = useNotificationStore();
  222. notifStore.add({ type: 'audio_complete', title: '音频生成完成', message: `《${book.title}》音频已全部生成完毕`, bookId: String(book.id) });
  223. }
  224. } catch (e) {
  225. console.error('轮询音频状态失败:', e);
  226. }
  227. }, 5000);
  228. } catch (e: any) {
  229. clearAudioPollTimer(book.id);
  230. generatingAudio.value[book.id] = false;
  231. uni.showToast({ title: e.message || '生成失败', icon: 'none' });
  232. }
  233. }
  234. async function handleMergeChapterAudio(book: Book) {
  235. if (!canMergeAudio(book)) { uni.showToast({ title: '并非所有叶节点音频都已生成完成', icon: 'none' }); return; }
  236. mergingAudio.value[book.id] = true;
  237. try {
  238. const result = await api.mergeChapterAudios(book.id);
  239. uni.showToast({ title: `已启动音频合并,处理${result.processedParents}个上级章节`, icon: 'none', duration: 2500 });
  240. setTimeout(async () => { await loadBooks(); mergingAudio.value[book.id] = false; }, 5000);
  241. } catch (e: any) {
  242. mergingAudio.value[book.id] = false;
  243. uni.showToast({ title: e.message || '音频合并失败', icon: 'none' });
  244. }
  245. }
  246. async function handleMergeChapterVideo(book: Book) {
  247. if (!canMergeVideo(book)) { uni.showToast({ title: '并非所有叶节点视频都已生成完成', icon: 'none' }); return; }
  248. mergingVideo.value[book.id] = true;
  249. try {
  250. const result = await api.mergeChapterVideos(book.id);
  251. uni.showToast({ title: `已启动视频合并,处理${result.processedParents}个上级章节`, icon: 'none', duration: 2500 });
  252. setTimeout(async () => { await loadBooks(); mergingVideo.value[book.id] = false; }, 5000);
  253. } catch (e: any) {
  254. mergingVideo.value[book.id] = false;
  255. uni.showToast({ title: e.message || '视频合并失败', icon: 'none' });
  256. }
  257. }
  258. async function handleGenerateAllVideo(book: Book) {
  259. if (generatingVideo.value[book.id]) return;
  260. generatingVideo.value[book.id] = true;
  261. // 清理可能存在的旧定时器
  262. clearVideoPollTimer(book.id);
  263. try {
  264. const result = await api.generateAllChaptersVideo(book.id);
  265. uni.showToast({ title: `已启动 ${result.totalChapters} 个章节的视频生成`, icon: 'none', duration: 2500 });
  266. // 开始轮询视频生成状态
  267. videoPollTimers.value[book.id] = setInterval(async () => {
  268. try {
  269. const status = await api.getVideoStatus(book.id);
  270. if (status.allCompleted) {
  271. clearVideoPollTimer(book.id);
  272. generatingVideo.value[book.id] = false;
  273. await loadBooks();
  274. uni.showToast({ title: '视频生成完成', icon: 'success' });
  275. // 写入通知
  276. const notifStore = useNotificationStore();
  277. notifStore.add({ type: 'video_complete', title: '视频生成完成', message: `《${book.title}》视频已全部生成完毕`, bookId: String(book.id) });
  278. }
  279. } catch (e) {
  280. console.error('轮询视频状态失败:', e);
  281. }
  282. }, 5000);
  283. } catch (e: any) {
  284. clearVideoPollTimer(book.id);
  285. generatingVideo.value[book.id] = false;
  286. uni.showToast({ title: e.message || '生成失败', icon: 'none' });
  287. }
  288. }
  289. async function handleTogglePublish(book: any) {
  290. togglingPublish.value[book.id] = true;
  291. try {
  292. const res = await api.toggleBookPublish(book.id);
  293. book.isPublished = res.isPublished;
  294. uni.showToast({ title: book.isPublished ? '已公开到首页' : '已取消公开', icon: 'none' });
  295. } catch (e: any) {
  296. uni.showToast({ title: e.message || '操作失败', icon: 'none' });
  297. } finally {
  298. togglingPublish.value[book.id] = false;
  299. }
  300. }
  301. // 显示更多操作菜单
  302. function showMoreActions(book: Book) {
  303. const audioStatus = getBookAudioStatus(book);
  304. const videoStatus = getBookVideoStatus(book);
  305. const actions: string[] = [];
  306. // 合并音频(音频有完成时才显示)
  307. if (canMergeAudio(book)) {
  308. actions.push('🔊 合并音频');
  309. }
  310. // 合并视频(视频有完成时才显示)
  311. if (canMergeVideo(book)) {
  312. actions.push('🎬 合并视频');
  313. }
  314. // 生成全部视频(音频完成且视频未完成或已完成时显示)
  315. if (audioStatus.status === 'completed' || audioStatus.status === 'none') {
  316. if (videoStatus.status !== 'completed' || generatingVideo.value[book.id]) {
  317. actions.push('🎬 生成全部视频');
  318. }
  319. }
  320. // 重新生成视频(视频已完成时显示)
  321. if (videoStatus.status === 'completed') {
  322. actions.push('🔄 重新生成视频');
  323. }
  324. // 如果没有可用操作,提示用户
  325. if (actions.length === 0) {
  326. uni.showToast({ title: '请先完成音频生成', icon: 'none' });
  327. return;
  328. }
  329. uni.showActionSheet({
  330. itemList: actions,
  331. success: (res) => {
  332. const action = actions[res.tapIndex];
  333. if (action === '🔊 合并音频') {
  334. mergingAudio.value[book.id] = true;
  335. uni.showToast({ title: '正在合并音频...', icon: 'loading', duration: 10000 });
  336. handleMergeChapterAudio(book);
  337. } else if (action === '🎬 合并视频') {
  338. mergingVideo.value[book.id] = true;
  339. uni.showToast({ title: '正在合并视频...', icon: 'loading', duration: 10000 });
  340. handleMergeChapterVideo(book);
  341. } else if (action === '🎬 生成全部视频') {
  342. handleGenerateAllVideo(book);
  343. } else if (action === '🔄 重新生成视频') {
  344. handleGenerateAllVideo(book);
  345. }
  346. }
  347. });
  348. }
  349. onShow(() => {
  350. loadBooks();
  351. });
  352. // 初始化
  353. onMounted(() => {
  354. loadBooks();
  355. });
  356. // 页面卸载时清理所有轮询
  357. onUnmounted(() => {
  358. // 清理所有轮询定时器
  359. Object.keys(audioPollTimers.value).forEach(key => clearAudioPollTimer(key));
  360. Object.keys(videoPollTimers.value).forEach(key => clearVideoPollTimer(key));
  361. });
  362. </script>
  363. <style scoped>
  364. .page { min-height: 100vh; background: #f9fafb; }
  365. .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); }
  366. .nav-content { display: flex; align-items: center; justify-content: space-between; height: 88rpx; padding: 0 32rpx; padding-top: env(safe-area-inset-top); }
  367. .nav-left, .nav-right { width: 80rpx; height: 88rpx; display: flex; align-items: center; justify-content: center; }
  368. .back-icon, .nav-btn { font-size: 40rpx; color: #1f2937; }
  369. .page-title { font-size: 32rpx; font-weight: 600; color: #1f2937; }
  370. .main-content { padding: 120rpx 32rpx 32rpx; }
  371. .card { background: #ffffff; border-radius: 24rpx; padding: 32rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); }
  372. .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; }
  373. .create-icon { font-size: 48rpx; color: #ffffff; }
  374. .create-text { font-size: 32rpx; font-weight: 600; color: #ffffff; }
  375. .book-list { display: flex; flex-direction: column; gap: 20rpx; }
  376. .book-card { background: #ffffff; border-radius: 20rpx; padding: 28rpx; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); }
  377. .book-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
  378. .book-title { font-size: 30rpx; font-weight: 600; color: #1f2937; flex: 1; }
  379. /* 状态标签由 GenerationStatusBadge 组件统一管理 */
  380. .book-desc { font-size: 26rpx; color: #6b7280; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 16rpx; }
  381. .book-meta { display: flex; gap: 24rpx; font-size: 24rpx; color: #9ca3af; margin-bottom: 12rpx; }
  382. .progress-bar { height: 6rpx; background: #e5e7eb; border-radius: 3rpx; }
  383. .progress-fill { height: 100%; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); border-radius: 3rpx; transition: width 0.3s; }
  384. .book-actions { display: flex; gap: 12rpx; }
  385. .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; }
  386. .primary-btn { flex: 1; background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #ffffff; padding: 0 16rpx; }
  387. .publish-btn { width: auto; min-width: 160rpx; padding: 0 24rpx; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: #ffffff; }
  388. .more-btn { width: 80rpx; background: #e5e7eb; color: #6b7280; font-size: 32rpx; font-weight: bold; }
  389. .more-btn { width: 80rpx; background: #e5e7eb; color: #6b7280; font-size: 32rpx; font-weight: bold; }
  390. .primary-btn[disabled], .publish-btn[disabled], .more-btn[disabled] { opacity: 0.6; }
  391. /* 夜间模式适配 */
  392. @media (prefers-color-scheme: dark) {
  393. .more-btn { background: #374151; color: #d1d5db; }
  394. .primary-btn { background: linear-gradient(135deg, #059669 0%, #047857 100%); }
  395. .publish-btn { background: linear-gradient(135deg, #4338ca 0%, #4f46e5 100%); }
  396. }
  397. .empty-state { display: flex; flex-direction: column; align-items: center; padding: 80rpx 0; }
  398. .empty-icon { font-size: 120rpx; margin-bottom: 24rpx; }
  399. .empty-text { font-size: 32rpx; color: #6b7280; margin-bottom: 12rpx; }
  400. .empty-hint { font-size: 26rpx; color: #9ca3af; }
  401. </style>