index.vue 20 KB

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