|
|
@@ -0,0 +1,878 @@
|
|
|
+<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);
|
|
|
+ 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>
|