| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882 |
- <template>
- <view class="page">
- <!-- 顶部导航栏 -->
- <view class="nav-bar">
- <view class="nav-content">
- <view class="nav-left" @click="goBack">
- <text class="back-icon">←</text>
- </view>
- <text class="page-title">{{ currentBook?.title || '书籍详情' }}</text>
- <view class="nav-right"></view>
- </view>
- </view>
- <!-- 主内容区 -->
- <view class="main-content">
- <!-- 书籍信息 -->
- <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>
- </view>
- <text v-if="currentBook?.subtitle" class="book-subtitle">{{ currentBook.subtitle }}</text>
- <text class="book-desc">{{ currentBook?.description }}</text>
- <view class="book-meta-row">
- <text>章节:{{ currentBook?.totalChapters }} 章</text>
- <text>预估:{{ currentBook?.estimatedWords || 0 }} 字</text>
- </view>
- </view>
- <!-- 进度显示 -->
- <view v-if="currentBook && currentBook.progress > 0" class="card progress-card">
- <text class="card-title">📊 生成进度</text>
- <view class="progress-display">
- <text class="progress-text">{{ currentBook.progress }}%</text>
- <text class="progress-detail">
- {{ completedChapters }}/{{ currentBook.totalChapters }} 章
- </text>
- </view>
- <view class="progress-bar-large">
- <view class="progress-fill" :style="{ width: currentBook.progress + '%' }"></view>
- </view>
- <!-- 实时生成状态面板 -->
- <view v-if="currentBook.status === 'generating'" class="generation-status">
- <view class="status-header">
- <text class="status-icon">⚡</text>
- <text class="status-title">正在生成中...</text>
- </view>
- <view class="status-stage">
- <text class="stage-label">当前阶段:</text>
- <text class="stage-value">{{ getCurrentStage() }}</text>
- </view>
- <view v-if="currentBook.error" class="status-error">
- <text class="error-icon">⚠️</text>
- <text class="error-text">{{ currentBook.error }}</text>
- </view>
- <view v-if="currentBook.error && currentBook.error.includes('自动恢复')" class="status-recovery">
- <text class="recovery-icon">🔄</text>
- <text class="recovery-text">系统正在尝试自动恢复...</text>
- </view>
- <view class="status-tip">
- <text class="tip-text">💡 生成过程可能需要几分钟,请耐心等待</text>
- </view>
- </view>
- <!-- 实时配额显示 -->
- <view v-if="currentBook.status === 'generating'" class="quota-monitor">
- <text class="quota-monitor-title">🎧 额度监控</text>
- <view class="quota-monitor-row">
- <text>已用:{{ usedAudioMinutes }}分钟</text>
- <text>剩余:{{ remainingAudioMinutes }}分钟</text>
- </view>
- <view v-if="overageMinutes > 0" class="quota-overage-warning">
- <text>⚠️ 已超出配额 {{ overageMinutes }} 分钟</text>
- </view>
- </view>
- <!-- 中断提示 -->
- <view v-if="currentBook.status === 'failed' && currentBook?.error?.includes('额度')" class="interrupted-tip">
- <text class="interrupted-icon">⚠️</text>
- <text class="interrupted-text">生成已中断:额度不足,已保存当前进度</text>
- <text class="interrupted-hint">可升级套餐后继续生成</text>
- </view>
- <!-- 失败提示 -->
- <view v-if="currentBook.status === 'failed' && !currentBook?.error?.includes('额度')" class="failed-tip">
- <text class="failed-icon">❌</text>
- <text class="failed-text">生成失败</text>
- <text v-if="currentBook.error" class="failed-error">{{ currentBook.error }}</text>
- <text class="failed-hint">请检查网络或稍后重试</text>
- <button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
- </view>
- </view>
- <!-- 完整内容区 -->
- <view v-if="currentBook?.chapters && currentBook.chapters.length > 0" class="card content-card">
- <view class="card-header-row">
- <text class="card-title">📖 完整内容</text>
- <view class="header-actions">
- <text v-if="currentBook.outline?.mainTheme" class="outline-theme">{{ currentBook.outline.mainTheme }}</text>
- <view v-if="!isBatchMode" class="edit-btn" @click="enterBatchMode">
- <text>批量</text>
- </view>
- </view>
- </view>
- <!-- 批量操作栏 -->
- <view v-if="isBatchMode" class="batch-actions-bar">
- <view class="batch-btn" @click="toggleSelectAll">
- <text>{{ isAllSelected ? '取消全选' : '全选' }}</text>
- </view>
- <view class="batch-btn regenerate" @click="handleBatchRegenerate" :class="{ disabled: selectedChapterIds.length === 0 }">
- <text>重新生成 ({{ selectedChapterIds.length }})</text>
- </view>
- <view class="batch-btn delete" @click="handleBatchDelete" :class="{ disabled: selectedChapterIds.length === 0 }">
- <text>删除 ({{ selectedChapterIds.length }})</text>
- </view>
- <view class="batch-btn cancel" @click="exitBatchMode">
- <text>取消</text>
- </view>
- </view>
- <!-- 前言 -->
- <view v-if="currentBook?.metadata?.foreword" class="content-item foreword" @click="viewForeword">
- <view class="content-item-left">
- <text class="content-label">前言</text>
- <text class="content-word-count">{{ currentBook?.metadata?.foreword?.length || 0 }}字</text>
- </view>
- <view class="content-item-right">
- <text class="expand-icon">→</text>
- </view>
- </view>
- <!-- 章节列表 -->
- <view class="content-tree-list">
- <view
- v-for="chapter in chaptersLevel1"
- :key="chapter.id"
- class="content-tree-item content-chapter"
- >
- <view class="content-tree-row chapter-row">
- <view class="chapter-num-badge">{{ chapter.number }}</view>
- <view class="chapter-info">
- <text class="chapter-title">{{ chapter.title }}</text>
- <view v-if="chapter.content && chapter.content.length > 100" class="chapter-content-hint">
- <text class="content-hint-tag">📄 正文 {{ chapter.wordCount || 0 }}字</text>
- </view>
- <text v-else-if="getChapterOutline(chapter.number)?.summary" class="chapter-outline-summary">
- {{ getChapterOutline(chapter.number)?.summary }}
- </text>
- <view v-if="!chapter.content || chapter.content.length <= 100" class="chapter-key-points">
- <text
- v-for="(point, idx) in getChapterOutline(chapter.number)?.keyPoints?.slice(0, 3) || []"
- :key="idx"
- class="point-tag"
- >
- {{ point }}
- </text>
- </view>
- <view class="chapter-media-badges">
- <text v-if="chapterHasAudio(chapter)" class="media-badge">🎵 音频</text>
- <text v-if="chapter.videoUrl" class="media-badge">🎬 视频</text>
- </view>
- </view>
- <view :class="['chapter-status', getNodeStatusClass(chapter)]">
- <text class="status-icon">{{ getNodeStatusIcon(chapter) }}</text>
- <text class="status-text">{{ getNodeStatusText(chapter) }}</text>
- </view>
- </view>
- <!-- 节 -->
- <view v-if="getChapterSectionsFromDB(chapter.id) && getChapterSectionsFromDB(chapter.id).length > 0" class="content-sections-list">
- <view
- v-for="section in getChapterSectionsFromDB(chapter.id)"
- :key="'sec-' + section.id"
- class="content-tree-item content-section"
- >
- <view class="content-tree-row section-row">
- <view class="section-num-badge">{{ section.number }}</view>
- <view class="section-info">
- <text class="section-title">{{ section.title }}</text>
- <text v-if="section.summary" class="section-summary">{{ section.summary }}</text>
- </view>
- <view :class="['section-status', getNodeStatusClass(section)]">
- <text class="status-icon">{{ getNodeStatusIcon(section) }}</text>
- <text class="status-text">{{ getNodeStatusText(section) }}</text>
- </view>
- </view>
- <!-- 小节 -->
- <view v-if="getSectionSubsectionsFromDB(section.id) && getSectionSubsectionsFromDB(section.id).length > 0" class="content-subsections-list">
- <view
- v-for="subsection in getSectionSubsectionsFromDB(section.id)"
- :key="'sub-' + subsection.id"
- class="content-tree-item content-subsection"
- @click="goToChapterDetail(subsection)"
- >
- <view class="content-tree-row subsection-row">
- <view class="subsection-num-badge">{{ subsection.number }}</view>
- <view class="subsection-info">
- <text class="subsection-title">{{ subsection.title }}</text>
- <view class="subsection-media-badges">
- <text v-if="subsection.audioUrl" class="media-badge-small">🎵 音频</text>
- <text v-if="subsection.videoUrl" class="media-badge-small">🎬 视频</text>
- </view>
- <text v-if="subsection.content" class="subsection-content-preview">
- {{ subsection.content.substring(0, 50) }}...
- </text>
- </view>
- <view :class="['subsection-status', getNodeStatusClass(subsection)]">
- <text class="status-icon">{{ getNodeStatusIcon(subsection) }}</text>
- <text class="status-text">{{ getNodeStatusText(subsection) }}</text>
- </view>
- </view>
- </view>
- </view>
- </view>
- </view>
- <!-- 章没有节时章本身就是叶子节点 -->
- <view v-else class="content-tree-item content-subsection" @click="goToChapterDetail(chapter)">
- <view class="content-tree-row subsection-row">
- <view class="subsection-info" style="margin-left: 56rpx;">
- <text class="subsection-title">{{ chapter.title }}</text>
- </view>
- <view class="content-item-right">
- <text class="expand-icon">→</text>
- </view>
- </view>
- </view>
- </view>
- </view>
- <!-- 后记 -->
- <view v-if="currentBook?.metadata?.afterword" class="content-item afterword" @click="viewAfterword">
- <view class="content-item-left">
- <text class="content-label">后记</text>
- <text class="content-word-count">{{ currentBook?.metadata?.afterword?.length || 0 }}字</text>
- </view>
- <view class="content-item-right">
- <text class="expand-icon">→</text>
- </view>
- </view>
- </view>
- <!-- 操作按钮 -->
- <view class="card action-card">
- <view class="btn-col">
- <view class="btn-row">
- <button
- class="action-btn langgraph-btn"
- :disabled="generating"
- @click="handleLangGraphGenerate"
- >
- {{ generating ? '生成中...' : '🤖 开始生成' }}
- </button>
- </view>
- </view>
- <view v-if="generating" class="generating-tip">
- <text>正在生成中,请稍候...</text>
- </view>
- <view v-if="currentBook?.status === 'completed' && (!currentBook.chapters || currentBook.chapters.length === 0)" class="warning-tip">
- <text class="warning-icon">⚠️</text>
- <text class="warning-text">该书籍状态标记为已完成,但没有任何章节内容。</text>
- <text class="warning-hint">请尝试点击上方按钮重新生成。</text>
- </view>
- <button class="action-btn full-width" @click="goBack">
- 返回书籍列表
- </button>
- </view>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, computed, onMounted, onUnmounted } from 'vue';
- 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';
- // 批量操作状态
- const isBatchMode = ref(false);
- const selectedChapterIds = ref<string[]>([]);
- const isAllSelected = ref(false);
- // 当前书籍
- const currentBook = ref<Book | null>(null);
- // 生成状态
- const generating = ref(false);
- // 轮询定时器
- let pollTimer: ReturnType<typeof setInterval> | null = null;
- // 计算属性
- const completedChapters = computed(() => {
- if (!currentBook.value) return 0;
- return currentBook.value.chapters.filter((c) => c.status === 'completed').length;
- });
- const chaptersLevel1 = computed(() => {
- if (!currentBook.value?.chapters) return [];
- return currentBook.value.chapters.filter((c) => c.level === 1);
- });
- const usedAudioMinutes = computed(() => {
- if (!currentBook.value) return 0;
- const totalWords = currentBook.value.chapters
- .filter((c) => c.status === 'completed')
- .reduce((sum, c) => sum + (c.wordCount || 0), 0);
- return Math.ceil(totalWords / 150);
- });
- const remainingAudioMinutes = computed(() => {
- if (!quotaInfo.value) return 0;
- return Math.max(0, quotaInfo.value.totalMinutes - quotaInfo.value.usedMinutes);
- });
- const overageMinutes = computed(() => {
- if (!currentBook.value) return 0;
- const used = usedAudioMinutes.value;
- if (!quotaInfo.value) return 0;
- return Math.max(0, used - quotaInfo.value.totalMinutes);
- });
- const quotaInfo = ref<{
- totalMinutes: number;
- usedMinutes: number;
- remainingMinutes: number;
- 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;
- const error = currentBook.value.error || '';
- if (error) {
- if (error.includes('AI调用')) return 'AI调用失败,正在重试...';
- if (error.includes('超时')) return '节点执行超时,正在恢复...';
- if (error.includes('自动恢复')) return '系统正在自动恢复...';
- }
- if (progress === 0) return '准备中...';
- if (progress <= 10) return '正在生成章大纲...';
- if (progress <= 20) return '正在生成节大纲...';
- if (progress <= 30) return '正在生成小节大纲...';
- if (progress <= 90) return '正在生成章节内容...';
- if (progress <= 95) return '正在生成前言...';
- if (progress < 100) return '正在生成后记...';
- if (progress === 100) return '生成完成!';
- return '生成中...';
- }
- function getNodeStatusClass(node: any): string {
- if (node.contentStatus === 'completed') return 'status-completed';
- if (node.contentStatus === 'generating') return 'status-generating';
- if (node.contentStatus === 'failed') return 'status-failed';
- if (node.status === 'completed') return 'status-completed';
- if (node.status === 'pending') return 'status-pending';
- return 'status-pending';
- }
- function getNodeStatusIcon(node: any): string {
- if (node.contentStatus === 'completed') return '✓';
- if (node.contentStatus === 'generating') return '⚡';
- if (node.contentStatus === 'failed') return '✗';
- if (node.contentError) return '⚠';
- if (node.status === 'completed') return '✓';
- if (node.status === 'pending') return '○';
- return '○';
- }
- function getNodeStatusText(node: any): string {
- if (node.contentStatus === 'completed') return '已完成';
- if (node.contentStatus === 'generating') return '生成中';
- if (node.contentStatus === 'failed') return '失败';
- if (node.contentError) return '错误';
- if (node.status === 'completed') return '大纲完成';
- if (node.status === 'pending') return '待生成';
- return '待生成';
- }
- function getChapterSectionsFromDB(chapterId: string | number) {
- if (!currentBook.value?.chapters) return [];
- const chapterIdNum = typeof chapterId === 'string' ? parseInt(chapterId) : chapterId;
- return currentBook.value.chapters.filter((c: any) => {
- const cParentId = typeof c.parentId === 'string' ? parseInt(c.parentId) : c.parentId;
- return cParentId === chapterIdNum && c.level === 2;
- });
- }
- function getSectionSubsectionsFromDB(sectionId: string | number) {
- if (!currentBook.value?.chapters) return [];
- const sectionIdNum = typeof sectionId === 'string' ? parseInt(sectionId) : sectionId;
- return currentBook.value.chapters.filter((c: any) => {
- const cParentId = typeof c.parentId === 'string' ? parseInt(c.parentId) : cParentId;
- return cParentId === sectionIdNum && c.level === 3;
- });
- }
- function chapterHasAudio(chapter: any) {
- const leafLevel = getBookLeafLevel({ chapters: [chapter] } as Book);
- if (leafLevel === 1) {
- return !!chapter.audioUrl;
- }
- if (chapter.level === leafLevel) {
- return !!chapter.audioUrl;
- }
- const sections = getChapterSectionsFromDB(chapter.id);
- for (const section of sections) {
- if (section.level === leafLevel) {
- if (section.audioUrl) return true;
- } else {
- const subsections = getSectionSubsectionsFromDB(section.id);
- if (subsections.some((s: any) => s.audioUrl)) return true;
- }
- }
- return false;
- }
- 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[] {
- const chapters = book.chapters || [];
- return chapters.filter(c => c.level === leafLevel);
- }
- function getChapterOutline(chapterNumber: number) {
- if (!currentBook.value?.outline?.chapters) return null;
- return currentBook.value.outline.chapters.find(c => c.number === chapterNumber) || null;
- }
- function goBack() {
- uni.navigateBack();
- }
- async function loadBook(id: string) {
- try {
- currentBook.value = await api.getBook(id);
- // 同步 generating 状态
- if (currentBook.value.status === 'generating') {
- generating.value = true;
- }
- startPollingProgress(id);
- } catch (e) {
- console.error('加载书籍失败:', e);
- uni.showToast({ title: '加载失败', icon: 'none' });
- }
- }
- function startPollingProgress(bookId: string) {
- stopPollingProgress();
- pollTimer = setInterval(async () => {
- try {
- const progress = await api.getProgress(bookId);
- if (!progress) return;
- if (currentBook.value) {
- currentBook.value.progress = progress.progress;
- currentBook.value.status = progress.status as any;
- }
- if (progress.status === 'completed' || progress.status === 'failed') {
- stopPollingProgress();
- generating.value = false;
- currentBook.value = await api.getBook(bookId);
- if (progress.status === 'completed') {
- uni.showToast({ title: '生成完成!', icon: 'success' });
- }
- }
- } catch (e) {
- console.error('轮询进度失败:', e);
- }
- }, 3000);
- }
- function stopPollingProgress() {
- if (pollTimer) {
- clearInterval(pollTimer);
- pollTimer = null;
- }
- }
- async function handleRetryGenerate() {
- if (!currentBook.value) return;
- uni.showModal({
- title: '确认重新生成',
- content: '将重新开始生成,是否继续?',
- success: async (res) => {
- if (res.confirm) {
- await handleLangGraphGenerate();
- }
- },
- });
- }
- function enterBatchMode() {
- isBatchMode.value = true;
- selectedChapterIds.value = [];
- isAllSelected.value = false;
- }
- function exitBatchMode() {
- isBatchMode.value = false;
- selectedChapterIds.value = [];
- isAllSelected.value = false;
- }
- function toggleSelectAll() {
- if (isAllSelected.value) {
- selectedChapterIds.value = [];
- isAllSelected.value = false;
- } else {
- const allSubsections = currentBook.value?.chapters?.filter(c => c.level === 3) || [];
- selectedChapterIds.value = allSubsections.map((ch: Chapter) => ch.id);
- isAllSelected.value = true;
- }
- }
- async function handleBatchRegenerate() {
- if (selectedChapterIds.value.length === 0 || !currentBook.value) return;
- const bookId = currentBook.value.id;
- uni.showModal({
- title: '确认重新生成',
- content: `将重新生成选中的 ${selectedChapterIds.value.length} 个章节,是否继续?`,
- success: async (res) => {
- if (res.confirm) {
- try {
- uni.showLoading({ title: '提交中...', mask: true });
- await post(`/book-generator/langgraph/books/${bookId}/chapters/batch-regenerate`, {
- chapterIds: selectedChapterIds.value,
- });
- uni.hideLoading();
- uni.showToast({ title: '已提交批量生成任务', icon: 'success' });
- exitBatchMode();
- currentBook.value = await api.getBook(bookId);
- } catch (error) {
- uni.hideLoading();
- console.error('[BatchRegenerate] 失败:', error);
- uni.showToast({ title: '批量重新生成失败', icon: 'none' });
- }
- }
- },
- });
- }
- async function handleBatchDelete() {
- if (selectedChapterIds.value.length === 0 || !currentBook.value) return;
- const bookId = currentBook.value.id;
- uni.showModal({
- title: '确认删除',
- content: `将删除选中的 ${selectedChapterIds.value.length} 个章节,是否继续?`,
- success: async (res) => {
- if (res.confirm) {
- try {
- uni.showLoading({ title: '删除中...', mask: true });
- await post(`/book-generator/langgraph/books/${bookId}/chapters/batch-delete`, {
- chapterIds: selectedChapterIds.value,
- });
- uni.hideLoading();
- uni.showToast({ title: '删除成功', icon: 'success' });
- exitBatchMode();
- currentBook.value = await api.getBook(bookId);
- } catch (error) {
- uni.hideLoading();
- console.error('[BatchDelete] 失败:', error);
- uni.showToast({ title: '批量删除失败', icon: 'none' });
- }
- }
- },
- });
- }
- async function handleLangGraphGenerate() {
- if (!currentBook.value) return;
- generating.value = true;
- currentBook.value.status = 'generating';
- currentBook.value.progress = 0;
- try {
- await api.generateWithLangGraph(currentBook.value.id);
- uni.showToast({ title: 'LangGraph 生成已启动', icon: 'none', duration: 2000 });
- startPollingProgress(currentBook.value.id);
- } catch (e: any) {
- generating.value = false;
- currentBook.value.status = 'failed';
- uni.showToast({ title: e.message || '启动失败', icon: 'none' });
- }
- }
- function goToChapterDetail(chapter: Chapter) {
- if (!currentBook.value) return;
- (globalThis as any).__navBookData = currentBook.value;
- uni.navigateTo({
- url: `/pages/book-generator/chapter-detail?bookId=${encodeURIComponent(currentBook.value.id)}&chapter=${chapter.id}`
- });
- }
- function viewForeword() {
- uni.showModal({
- title: '前言',
- content: currentBook.value?.metadata?.foreword || '',
- showCancel: true,
- cancelText: '关闭',
- confirmText: '复制',
- success: (res) => {
- if (res.confirm) {
- uni.setClipboardData({
- data: currentBook.value?.metadata?.foreword || '',
- success: () => uni.showToast({ title: '已复制', icon: 'success' }),
- });
- }
- }
- });
- }
- function viewAfterword() {
- uni.showModal({
- title: '后记',
- content: currentBook.value?.metadata?.afterword || '',
- showCancel: true,
- cancelText: '关闭',
- confirmText: '复制',
- success: (res) => {
- if (res.confirm) {
- uni.setClipboardData({
- data: currentBook.value?.metadata?.afterword || '',
- success: () => uni.showToast({ title: '已复制', icon: 'success' }),
- });
- }
- }
- });
- }
- onLoad((query: any) => {
- if (query?.id) {
- loadBook(query.id);
- }
- });
- onMounted(() => {
- // H5 环境:从 URL hash 获取参数
- // #ifdef H5
- if (typeof window !== 'undefined') {
- const hash = window.location.hash;
- const match = hash.match(/detail\?id=([^&]+)/);
- if (match && !currentBook.value) {
- loadBook(decodeURIComponent(match[1]));
- }
- }
- // #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(() => {
- // onShow 时刷新书籍数据(H5 环境下 onMounted 时 hash 可能未更新)
- if (currentBook.value?.id) {
- loadBook(currentBook.value.id);
- }
- });
- onUnmounted(() => {
- stopPollingProgress();
- });
- </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; }
- .back-icon { font-size: 40rpx; color: #1f2937; }
- .page-title {
- font-size: 32rpx;
- font-weight: 600;
- color: #1f2937;
- max-width: 400rpx;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .main-content {
- padding-top: calc(88rpx + env(safe-area-inset-top));
- 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);
- }
- .card-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
- .header-actions { display: flex; align-items: center; gap: 16rpx; }
- .edit-btn { padding: 12rpx 24rpx; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 8rpx; font-size: 28rpx; }
- .batch-actions-bar { display: flex; gap: 16rpx; padding: 24rpx; background: #f9fafb; border-radius: 16rpx; margin-bottom: 24rpx; flex-wrap: wrap; }
- .batch-btn { padding: 16rpx 32rpx; background: white; border: 2rpx solid #e5e7eb; border-radius: 12rpx; font-size: 28rpx; color: #374151; }
- .batch-btn.regenerate { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; border: none; }
- .batch-btn.regenerate.disabled { opacity: 0.5; }
- .batch-btn.delete { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white; border: none; }
- .batch-btn.delete.disabled { opacity: 0.5; }
- .batch-btn.cancel { background: #f3f4f6; color: #6b7280; border: none; }
- .card-title { font-size: 32rpx; font-weight: 600; color: #1f2937; }
- .book-info { margin-bottom: 24rpx; }
- .book-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8rpx; }
- .book-title-large { font-size: 36rpx; font-weight: 700; color: #1f2937; flex: 1; }
- .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; }
- .progress-card { margin-bottom: 24rpx; }
- .progress-display { display: flex; align-items: baseline; gap: 16rpx; margin: 16rpx 0; }
- .progress-text { font-size: 48rpx; font-weight: 700; color: #4f46e5; }
- .progress-detail { font-size: 28rpx; color: #6b7280; }
- .progress-bar-large { height: 12rpx; background: #e5e7eb; border-radius: 6rpx; }
- .generation-status { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 16rpx; color: white; }
- .status-header { display: flex; align-items: center; margin-bottom: 16rpx; }
- .status-icon { font-size: 36rpx; margin-right: 12rpx; animation: pulse 2s ease-in-out infinite; }
- @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
- .status-title { font-size: 32rpx; font-weight: 600; }
- .status-stage { display: flex; align-items: center; padding: 16rpx; background: rgba(255, 255, 255, 0.2); border-radius: 12rpx; margin-bottom: 16rpx; }
- .stage-label { font-size: 26rpx; opacity: 0.9; margin-right: 8rpx; }
- .stage-value { font-size: 28rpx; font-weight: 600; }
- .status-error { display: flex; align-items: flex-start; padding: 16rpx; background: rgba(239, 68, 68, 0.3); border-radius: 12rpx; margin-bottom: 16rpx; }
- .error-icon { font-size: 28rpx; margin-right: 12rpx; flex-shrink: 0; }
- .error-text { font-size: 24rpx; line-height: 1.6; flex: 1; }
- .status-recovery { display: flex; align-items: center; padding: 16rpx; background: rgba(251, 191, 36, 0.3); border-radius: 12rpx; margin-bottom: 16rpx; }
- .recovery-icon { font-size: 32rpx; margin-right: 12rpx; animation: spin 1s linear infinite; }
- @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
- .recovery-text { font-size: 26rpx; font-weight: 500; }
- .status-tip { padding: 12rpx; background: rgba(255, 255, 255, 0.1); border-radius: 8rpx; }
- .tip-text { font-size: 24rpx; opacity: 0.9; }
- .failed-tip { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); border-radius: 16rpx; border: 2rpx solid #fca5a5; }
- .failed-icon { font-size: 40rpx; display: block; text-align: center; margin-bottom: 12rpx; }
- .failed-text { font-size: 32rpx; font-weight: 600; color: #dc2626; display: block; text-align: center; margin-bottom: 12rpx; }
- .failed-error { font-size: 26rpx; color: #991b1b; display: block; padding: 16rpx; background: rgba(255, 255, 255, 0.5); border-radius: 8rpx; margin-bottom: 12rpx; line-height: 1.6; }
- .failed-hint { font-size: 24rpx; color: #6b7280; display: block; text-align: center; margin-bottom: 16rpx; }
- .retry-btn { width: 100%; height: 72rpx; background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white; border: none; border-radius: 12rpx; font-size: 28rpx; font-weight: 600; }
- .outline-theme { font-size: 24rpx; color: #6b7280; }
- .content-item { display: flex; align-items: center; justify-content: space-between; padding: 24rpx; background: #f9fafb; border-radius: 16rpx; margin-top: 16rpx; }
- .content-item.foreword, .content-item.afterword { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); cursor: pointer; }
- .content-item-left { display: flex; align-items: center; gap: 16rpx; }
- .content-label { font-size: 28rpx; font-weight: 600; color: #1f2937; }
- .content-word-count { font-size: 24rpx; color: #9ca3af; }
- .content-item-right { display: flex; align-items: center; }
- .expand-icon { font-size: 28rpx; color: #9ca3af; }
- .content-card { margin-top: 0; }
- .content-tree-list { margin-top: 20rpx; }
- .content-tree-item { margin-bottom: 12rpx; }
- .content-tree-row { display: flex; align-items: center; gap: 16rpx; padding: 16rpx; background: #f9fafb; border-radius: 12rpx; }
- .chapter-num-badge { width: 48rpx; height: 48rpx; background: #4f46e5; color: #ffffff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 24rpx; font-weight: 600; flex-shrink: 0; }
- .chapter-info { flex: 1; }
- .chapter-title { font-size: 28rpx; font-weight: 600; color: #1f2937; display: block; margin-bottom: 6rpx; }
- .chapter-content-hint { margin-bottom: 6rpx; }
- .content-hint-tag { font-size: 22rpx; padding: 4rpx 12rpx; background: #e0f2fe; color: #0369a1; border-radius: 6rpx; }
- .chapter-outline-summary { font-size: 24rpx; color: #6b7280; display: block; margin-bottom: 8rpx; }
- .chapter-key-points { display: flex; flex-wrap: wrap; gap: 8rpx; margin-bottom: 8rpx; }
- .point-tag { font-size: 20rpx; padding: 4rpx 12rpx; background: #f3f4f6; color: #6b7280; border-radius: 6rpx; }
- .chapter-media-badges { display: flex; gap: 8rpx; }
- .media-badge { font-size: 20rpx; padding: 4rpx 12rpx; background: rgba(16, 185, 129, 0.1); color: #059669; border-radius: 6rpx; }
- .chapter-status { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 80rpx; padding: 8rpx 16rpx; border-radius: 8rpx; flex-shrink: 0; }
- .chapter-status.status-completed { background: #d1fae5; color: #059669; }
- .chapter-status.status-pending { background: #e5e7eb; color: #6b7280; }
- .chapter-status.status-generating { background: #fef3c7; color: #d97706; animation: pulse-bg 2s ease-in-out infinite; }
- .chapter-status.status-failed { background: #fee2e2; color: #dc2626; }
- @keyframes pulse-bg { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
- .status-icon { font-size: 28rpx; font-weight: bold; line-height: 1; }
- .status-text { font-size: 20rpx; margin-top: 4rpx; white-space: nowrap; }
- .section-num-badge { width: 40rpx; height: 40rpx; background: #8b5cf6; color: #ffffff; border-radius: 8rpx; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 600; flex-shrink: 0; }
- .section-info { flex: 1; }
- .section-title { font-size: 26rpx; font-weight: 500; color: #374151; display: block; margin-bottom: 4rpx; }
- .section-summary { font-size: 22rpx; color: #9ca3af; display: block; }
- .section-status { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 70rpx; padding: 6rpx 12rpx; border-radius: 6rpx; flex-shrink: 0; }
- .section-status.status-completed { background: #d1fae5; color: #059669; }
- .section-status.status-pending { background: #f3f4f6; color: #9ca3af; }
- .section-status.status-generating { background: #fef3c7; color: #d97706; }
- .section-status.status-failed { background: #fee2e2; color: #dc2626; }
- .subsection-num-badge { width: 36rpx; height: 36rpx; background: #06b6d4; color: #ffffff; border-radius: 6rpx; display: flex; align-items: center; justify-content: center; font-size: 20rpx; font-weight: 500; flex-shrink: 0; }
- .subsection-info { flex: 1; }
- .subsection-title { font-size: 24rpx; color: #6b7280; display: block; }
- .subsection-media-badges { display: flex; gap: 8rpx; margin-top: 4rpx; }
- .media-badge-small { font-size: 18rpx; padding: 2rpx 8rpx; background: rgba(16, 185, 129, 0.1); color: #059669; border-radius: 4rpx; }
- .subsection-content-preview { font-size: 22rpx; color: #6b7280; margin-top: 4rpx; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
- .subsection-status { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 70rpx; padding: 6rpx 12rpx; border-radius: 6rpx; flex-shrink: 0; }
- .subsection-status.status-completed { background: #d1fae5; color: #059669; }
- .subsection-status.status-pending { background: #f3f4f6; color: #9ca3af; }
- .subsection-status.status-generating { background: #fef3c7; color: #d97706; }
- .subsection-status.status-failed { background: #fee2e2; color: #dc2626; }
- .action-card { margin-bottom: 24rpx; }
- .btn-col { margin-bottom: 20rpx; }
- .btn-row { display: flex; gap: 16rpx; margin-bottom: 20rpx; }
- .action-btn { flex: 1; height: 88rpx; background: #f3f4f6; border-radius: 16rpx; font-size: 28rpx; color: #374151; display: flex; align-items: center; justify-content: center; border: none; }
- .action-btn.primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; }
- .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; }
- .generating-tip { text-align: center; padding: 20rpx; color: #6b7280; font-size: 26rpx; }
- .warning-tip { padding: 24rpx; background: #fef3c7; border-radius: 12rpx; margin-top: 16rpx; }
- .warning-icon { font-size: 32rpx; display: block; margin-bottom: 8rpx; }
- .warning-text { font-size: 26rpx; color: #92400e; display: block; margin-bottom: 8rpx; }
- .warning-hint { font-size: 24rpx; color: #b45309; display: block; }
- </style>
|