| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601 |
- <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">{{ isTitleGenerating ? 'AI 生成标题中...' : (currentBook?.title || '书籍详情') }}</text>
- <view class="nav-right">
- <text v-if="canEdit" class="nav-edit-btn" @click="goToEdit">编辑</text>
- </view>
- </view>
- </view>
- <!-- 主内容区 -->
- <view class="main-content">
- <!-- 书籍信息 -->
- <view class="card book-info">
- <view class="book-title-row">
- <text class="book-title-large">{{ isTitleGenerating ? '✨ AI 正在生成标题...' : currentBook?.title }}</text>
- <GenerationStatusBadge :status="currentBook?.genStage || 'draft'"></GenerationStatusBadge>
- </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" class="card progress-card">
- <text class="card-title">📊 生成进度</text>
- <view class="progress-display">
- <text class="progress-text" :class="{ 'progress-failed': isGenerationFailed }">{{ isGenerationFailed ? '失败' : currentBook.progress }}%</text>
- <text class="progress-detail">
- {{ completedChapters }}/{{ currentBook.totalChapters }} 章
- </text>
- </view>
- <view class="progress-bar-large">
- <view class="progress-fill" :class="{ 'progress-fill-failed': isGenerationFailed }" :style="{ width: (isGenerationFailed ? 0 : currentBook.progress) + '%' }"></view>
- </view>
- <!-- 失败状态 -->
- <view v-if="isGenerationFailed" 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>
- <!-- 大纲已就绪、等待触发内容生成(interactive 模式特有状态) -->
- <view v-if="currentBook.genStage === 'outline_ready' && !isGenerationFailed" class="failed-tip" style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-color: #f59e0b;">
- <text class="failed-icon">📋</text>
- <text class="failed-text" style="color: #b45309;">大纲已生成,等待生成正文</text>
- <text class="failed-hint">进度长时间停在 5%,是因为大纲确认后内容生成尚未启动。点击下方按钮立即开始生成。</text>
- <button class="retry-btn" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);" :disabled="outlineStarting" @click="handleStartContentGenerate">🚀 继续生成内容</button>
- </view>
- <!-- 卡死状态:进度长时间无变化,后台疑似已停 -->
- <view v-if="stalledTip && !isGenerationFailed && currentBook.genStage !== 'outline_ready'" class="failed-tip">
- <text class="failed-icon">⏸️</text>
- <text class="failed-text">生成似乎已停止</text>
- <text class="failed-hint">进度长时间未更新,后台任务可能已中断。可继续补齐未完成的章节,或整本重新生成。</text>
- <button class="retry-btn" @click="handleResume">▶️ 继续生成</button>
- <button class="retry-btn" style="margin-top: 12rpx; background: linear-gradient(135deg, #10b981 0%, #059669 100%);" :disabled="fillingMissing" @click="handleFillMissing">⚡ 补全缺失章节</button>
- <button class="retry-btn" style="margin-top: 12rpx; background: #888;" @click="handleLangGraphGenerate">🔄 整本重新生成</button>
- </view>
- <!-- 始终可用的兜底按钮:只要有缺失章节就显示 -->
- <view v-if="hasMissingChapters && !isCurrentlyGenerating && !isGenerationFailed" class="failed-tip" style="background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); border-color: #3b82f6;">
- <text class="failed-icon">🩹</text>
- <text class="failed-text" style="color: #1d4ed8;">检测到 {{ missingChapterCount }} 个章节未生成内容</text>
- <text class="failed-hint">这本书可能有章节被 LangGraph 节点漏掉。点击下方按钮并发补全(不依赖 LangGraph 重跑)。</text>
- <button class="retry-btn" style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);" :disabled="fillingMissing" @click="handleFillMissing">⚡ 补全 {{ missingChapterCount }} 个缺失章节</button>
- </view>
- <!-- 实时生成状态面板 -->
- <view v-if="isCurrentlyGenerating && !isGenerationFailed && !stalledTip" 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="currentGeneratingChapter" class="current-chapter-info">
- <text class="chapter-label">正在处理:</text>
- <text class="chapter-name">第{{ currentGeneratingChapter.number }}{{ currentGeneratingChapter.levelLabel }} {{ currentGeneratingChapter.title }}</text>
- </view>
- <view v-if="isGenerationFailed && 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="isCurrentlyGenerating" 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.genStage === '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="isGenerationFailed && !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 v-if="!isGenerationFailed && (currentBook.qualityReport || currentBook.continuityReport || currentBook.rewriteReport)" class="report-card">
- <view class="report-header" @click="reportExpanded = !reportExpanded">
- <text class="report-icon">📊</text>
- <text class="report-title">质量/连贯性报告</text>
- <text class="report-toggle">{{ reportExpanded ? '收起' : '展开' }}</text>
- </view>
- <view v-if="reportExpanded" class="report-body">
- <view v-if="currentBook.qualityReport" class="report-item">
- <text class="report-label">质量校验:</text>
- <text class="report-text">{{ currentBook.qualityReport }}</text>
- </view>
- <view v-if="currentBook.continuityReport" class="report-item">
- <text class="report-label">连贯性编辑:</text>
- <text class="report-text">{{ currentBook.continuityReport }}</text>
- </view>
- <view v-if="currentBook.rewriteReport" class="report-item">
- <text class="report-label">改写优化:</text>
- <text class="report-text">{{ currentBook.rewriteReport }}</text>
- </view>
- </view>
- </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 class="chapter-toolbar">
- <view class="search-box">
- <text class="search-icon-small">🔍</text>
- <input
- v-model="searchKeyword"
- class="search-input"
- placeholder="搜索章节标题、摘要..."
- placeholder-class="search-placeholder-text"
- />
- <text v-if="searchKeyword" class="search-clear" @click="searchKeyword = ''">✕</text>
- </view>
- <view class="toggle-btn" @click="toggleExpandAll">
- <text>{{ allExpanded ? '📁 折叠全部' : '📂 展开全部' }}</text>
- </view>
- </view>
- <view v-if="searchKeyword && filteredChaptersLevel1.length === 0" class="search-empty">
- <text>未找到匹配的章节</text>
- </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 filteredChaptersLevel1"
- :key="chapter.id"
- class="content-tree-item content-chapter"
- >
- <view class="content-tree-row chapter-row" @click="goToChapterDetail(chapter)">
- <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-show="allExpanded">
- <!-- 节 -->
- <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"
- @click="goToChapterDetail(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>
- <!-- 后记 -->
- <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 v-if="loadError" class="card fatal-error-card">
- <text class="fatal-error-icon">{{ loadError.icon || '⚠️' }}</text>
- <text class="fatal-error-title">{{ loadError.title }}</text>
- <text class="fatal-error-message">{{ loadError.message }}</text>
- <view class="fatal-error-actions">
- <button v-if="loadError.recoverable" class="fatal-error-btn primary" @click="loadBook(currentBookId)">🔄 重试</button>
- <button class="fatal-error-btn" @click="goBackToList">📋 返回书籍列表</button>
- </view>
- </view>
- <!-- 操作按钮 -->
- <view v-if="!loadError" class="card action-card">
- <!-- 手动触发生成提示(用户在创建时关闭了自动生成) -->
- <view v-if="showManualContentHint" class="manual-hint">
- <text class="manual-hint-icon">📝</text>
- <text class="manual-hint-text">创建时已关闭自动生成文本,点击下方按钮开始生成</text>
- </view>
- <view v-if="showManualAudioHint" class="manual-hint">
- <text class="manual-hint-icon">🎵</text>
- <text class="manual-hint-text">创建时已关闭自动生成音频,点击下方按钮开始生成</text>
- </view>
- <view class="btn-col">
- <view class="btn-row">
- <button
- class="action-btn langgraph-btn"
- :disabled="generating"
- @click="handleLangGraphGenerate"
- >
- {{ generating ? '生成中...' : (isContentCompleted ? '🔄 重新生成' : '🤖 开始生成') }}
- </button>
- </view>
- <!-- 音频生成按钮 - 内容生成完成后显示 -->
- <view v-if="isContentCompleted" class="btn-row">
- <view class="btn-with-quota">
- <button
- v-if="hasEnoughQuota"
- class="action-btn audio-btn"
- :disabled="generatingAudio"
- @click="handleGenerateAudio"
- >
- {{ generatingAudio ? '生成中...' : (allLeafAudioCompleted ? '🔄 重新生成全部音频' : '🎵 生成全部音频') }}
- </button>
- <button
- v-else
- class="action-btn upgrade-btn"
- @click="goToSubscription"
- >
- 🚀 升级套餐
- </button>
- <view class="quota-info-inline">
- <text class="quota-text">预估消耗 {{ estimatedAudioMinutes }} 分钟</text>
- <text v-if="quotaInfo" class="quota-text">剩余 {{ quotaInfo.remainingMinutes }} 分钟</text>
- <text v-if="!hasEnoughQuota" class="quota-warn">⚠️ 配额不足</text>
- </view>
- </view>
- </view>
- </view>
- <view v-if="generating" class="generating-tip">
- <text>正在生成中,可关闭页面,后台会继续生成...</text>
- </view>
- <view v-if="isBookDone && (!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, get } from '../../utils/request';
- import type { Book, Chapter } from '../../utils/book-generator-api';
- import { useNotificationStore } from '../../store/notification';
- import GenerationStatusBadge from '../../components/GenerationStatusBadge.vue';
- // 批量操作状态
- const isBatchMode = ref(false);
- const selectedChapterIds = ref<string[]>([]);
- const isAllSelected = ref(false);
- // 章节搜索与折叠
- const searchKeyword = ref('');
- const allExpanded = ref(true);
- // 当前书籍
- const currentBook = ref<Book | null>(null);
- // 当前正在查看的 bookId(来自 onLoad 的 query.id / H5 hash),供致命错误时重试用
- const currentBookId = ref<string>('');
- // 加载书籍时的致命错误(如"书籍不存在"、网络不可达、权限拒绝等)
- // 不只是 toast 一闪而过,而是落到页面让用户看清,并可返回书籍列表
- const loadError = ref<{ title: string; message: string; recoverable: boolean } | null>(null);
- // 生成状态
- const generating = ref(false);
- const generatingAudio = ref(false);
- const generatingVideo = ref(false);
- // interactive 模式下点击"继续生成内容"的请求中状态
- const outlineStarting = ref(false);
- // 手动补全缺失章节的请求中状态
- const fillingMissing = ref(false);
- // 是否有内容(章节有实际内容文本)
- const hasContent = computed(() => {
- if (!currentBook.value?.chapters) return false;
- return currentBook.value.chapters.some((c) => c.content && c.content.length > 100);
- });
- // 检测生成是否失败(有 failedStage 表明在某个阶段失败了)
- const isGenerationFailed = computed(() => {
- if (!currentBook.value) return false;
- return !!currentBook.value.failedStage;
- });
- // 书籍是否已完成(替代旧 status === 'completed' 判断)
- const isBookDone = computed(() => {
- if (!currentBook.value?.genStage) return false;
- return ['content_completed', 'audio_completed', 'video_completed'].includes(currentBook.value.genStage);
- });
- // AI 标题是否正在生成中(标题为临时占位符时)
- const isTitleGenerating = computed(() => {
- return currentBook.value?.title === '正在生成标题...' && isCurrentlyGenerating.value;
- });
- // 是否正在生成中(基于 genStage 判断,或正在生成音频)
- const isCurrentlyGenerating = computed(() => {
- if (!currentBook.value) return false;
- if (generatingAudio.value) return true;
- const gs = currentBook.value.genStage;
- return gs === 'outlining' || gs === 'content_generating' || gs === 'audio_generating' || gs === 'video_generating';
- });
- // 是否可编辑(内容生成完成前可编辑基本信息)
- const canEdit = computed(() => {
- if (!currentBook.value) return false;
- const stage = currentBook.value.genStage;
- return !stage || stage === 'draft' || stage === 'outlining';
- });
- // 内容是否已生成完成(基于 genStage 判断)
- const isContentCompleted = computed(() => {
- if (!currentBook.value?.genStage) return false;
- return currentBook.value.genStage === 'content_completed' ||
- currentBook.value.genStage === 'audio_completed' ||
- currentBook.value.genStage === 'video_completed';
- });
- // 是否有章节内容缺失(level>=2 且 content 为空)
- const missingChapterCount = computed(() => {
- if (!currentBook.value?.chapters) return 0;
- return currentBook.value.chapters.filter((c: any) =>
- c.level >= 2 && (!c.content || c.content.length < 50)
- ).length;
- });
- const hasMissingChapters = computed(() => missingChapterCount.value > 0);
- // 所有叶子节点音频是否全部生成完成
- const allLeafAudioCompleted = computed(() => {
- if (!currentBook.value?.chapters?.length) return false;
- const leafLevel = getBookLeafLevel(currentBook.value);
- const leafNodes = getBookLeafNodes(currentBook.value, leafLevel);
- if (leafNodes.length === 0) return false;
- return leafNodes.every((c: any) =>
- c.genStage === 'audio_completed' || c.genStage === 'video_completed'
- );
- });
- // 手动触发生成文本提示:用户创建时关闭了自动生成文本,且书处于 draft/outline_completed 等未生成内容态
- const showManualContentHint = computed(() => {
- if (!currentBook.value) return false;
- if (currentBook.value.autoGenerateContent !== false) return false; // 默认 true 不提示
- const stage = currentBook.value.genStage;
- return stage === 'draft' || stage === 'outline_completed' || stage === 'outlining' || stage === 'failed';
- });
- // 手动触发生成音频提示:用户创建时关闭了自动生成音频,且内容已就绪但音频为空
- const showManualAudioHint = computed(() => {
- if (!currentBook.value) return false;
- if (currentBook.value.autoGenerateAudio !== false) return false; // 默认 true 不提示
- if (!isContentCompleted.value) return false;
- return !allLeafAudioCompleted.value;
- });
- // 轮询定时器
- let pollTimer: ReturnType<typeof setInterval> | null = null;
- let audioPollTimer: ReturnType<typeof setInterval> | null = null;
- let videoPollTimer: ReturnType<typeof setInterval> | null = null;
- // outline_ready 状态下的"等待后端自愈"轮询定时器(间隔较长,避免过多请求)
- let outlineReadyPollTimer: ReturnType<typeof setInterval> | null = null;
- // 卡死检测:进度/阶段长时间无变化则判定后台已停(避免永久"生成中"转圈)
- const stalledTip = ref(false); // 是否疑似卡死
- let lastProgressSnapshot = ''; // 上次的 progress+genStage 快照
- let stalledTicks = 0; // 连续无变化的轮询次数
- const STALLED_THRESHOLD = 40; // 40 次 × 3s ≈ 2 分钟无变化判定卡死
- // 质量报告折叠
- const reportExpanded = ref(false);
- // 计算属性
- const completedChapters = computed(() => {
- if (!currentBook.value) return 0;
- const doneGenStages = ['content_completed', 'audio_completed', 'audio_generating', 'video_generating', 'video_completed'];
- return currentBook.value.chapters.filter((c) => doneGenStages.includes(c.genStage || '')).length;
- });
- const chaptersLevel1 = computed(() => {
- if (!currentBook.value?.chapters) return [];
- return currentBook.value.chapters.filter((c) => c.level === 1);
- });
- // 当前正在生成的章节信息
- const currentGeneratingChapter = computed(() => {
- if (!currentBook.value?.chapters) return null;
- const generating = currentBook.value.chapters.find(
- (c: any) => c.genStage === 'content_generating' || c.genStage === 'audio_generating' || c.genStage === 'video_generating' || c.contentStatus === 'generating'
- );
- if (!generating) return null;
- const levelLabels = ['', '章', '节', '小节'];
- const levelLabel = levelLabels[generating.level || 1] || '章';
- return { number: generating.number, title: generating.title, levelLabel };
- });
- // 根据搜索关键词过滤章节
- const filteredChaptersLevel1 = computed(() => {
- const chapters = chaptersLevel1.value;
- if (!searchKeyword.value.trim()) return chapters;
- const keyword = searchKeyword.value.trim().toLowerCase();
- return chapters.filter(ch => {
- if (ch.title?.toLowerCase().includes(keyword)) return true;
- const outline = getChapterOutline(ch.number);
- if (outline?.summary?.toLowerCase().includes(keyword)) return true;
- if (outline?.keyPoints?.some((kp: string) => kp.toLowerCase().includes(keyword))) return true;
- const sections = getChapterSectionsFromDB(ch.id);
- if (sections.some((s: any) => s.title?.toLowerCase().includes(keyword) || s.summary?.toLowerCase().includes(keyword))) return true;
- return false;
- });
- });
- function toggleExpandAll() {
- allExpanded.value = !allExpanded.value;
- }
- const usedAudioMinutes = computed(() => {
- if (!currentBook.value) return 0;
- const doneGenStages = ['content_completed', 'audio_completed', 'audio_generating', 'video_generating', 'video_completed'];
- const totalWords = currentBook.value.chapters
- .filter((c) => doneGenStages.includes(c.genStage || ''))
- .reduce((sum, c) => sum + (c.wordCount || 0), 0);
- return Math.ceil(totalWords / 150);
- });
- // 预估全部音频时长(基于所有叶子节点字数)
- const estimatedAudioMinutes = computed(() => {
- if (!currentBook.value) return 0;
- const leafLevel = getBookLeafLevel(currentBook.value);
- const leafNodes = getBookLeafNodes(currentBook.value, leafLevel);
- const totalWords = leafNodes.reduce((sum, c: any) => sum + (c.wordCount || 0), 0);
- return Math.max(1, Math.ceil(totalWords / 150));
- });
- // 配额是否足够
- const hasEnoughQuota = computed(() => {
- if (!quotaInfo.value) return true; // 未加载配额信息时不限制
- const remaining = quotaInfo.value.remainingMinutes;
- // 如果启用了超额,总是允许
- if (quotaInfo.value.overageEnabled) return true;
- return remaining >= estimatedAudioMinutes.value;
- });
- function goToSubscription() {
- uni.navigateTo({ url: '/pages/subscription/index' });
- }
- function goToEdit() {
- if (!currentBook.value) return;
- uni.navigateTo({
- url: `/pages/book-generator/create?editId=${currentBook.value.id}`
- });
- }
- // 获取用户音频配额信息
- async function fetchQuotaInfo() {
- try {
- const res = await get<{
- totalMinutes: number;
- usedMinutes: number;
- remainingMinutes: number;
- overageEnabled: boolean;
- }>('/subscription/audio-balance');
- if (res) {
- quotaInfo.value = {
- totalMinutes: res.totalMinutes,
- usedMinutes: res.usedMinutes,
- remainingMinutes: res.remainingMinutes,
- overageEnabled: res.overageEnabled,
- };
- }
- } catch {
- // 未登录或获取失败时不显示配额信息
- quotaInfo.value = null;
- }
- }
- 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 getCurrentStage(): string {
- if (!currentBook.value) return '未知';
- // 优先使用 genStage 进行阶段描述
- if (currentBook.value.genStage) {
- return api.getBookStageDescription(currentBook.value.genStage);
- }
- 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.genStage === 'failed') return 'status-failed';
- if (node.genStage === 'content_generating' || node.genStage === 'audio_generating' || node.genStage === 'video_generating') return 'status-generating';
- if (node.genStage === 'outline_completed' || node.genStage === 'content_completed' || node.genStage === 'audio_completed' || node.genStage === 'video_completed') return 'status-completed';
- 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.genStage === 'failed') return '✗';
- if (node.genStage === 'content_generating' || node.genStage === 'audio_generating' || node.genStage === 'video_generating') return '⚡';
- if (node.genStage === 'outline_completed' || node.genStage === 'content_completed' || node.genStage === 'audio_completed' || node.genStage === 'video_completed') 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.genStage === 'failed') return '失败';
- if (node.genStage === 'outline_completed') return '大纲完成';
- if (node.genStage === 'content_generating') return '生成中';
- if (node.genStage === 'content_completed') return '内容完成';
- if (node.genStage === 'audio_generating') return '生成音频';
- if (node.genStage === 'audio_completed') return '音频完成';
- if (node.genStage === 'video_generating') return '生成视频';
- if (node.genStage === 'video_completed') 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) : (c.parentId || 0);
- 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() {
- const pages = getCurrentPages();
- if (pages.length > 1) {
- uni.navigateBack();
- } else {
- uni.switchTab({ url: '/pages/index/index' });
- }
- }
- async function loadBook(id: string) {
- // 每次重试前先清空错误状态
- loadError.value = null;
- try {
- currentBook.value = await api.getBook(id);
- if (!currentBook.value) {
- loadError.value = {
- icon: '📭',
- title: '书籍不存在',
- message: '该书籍可能已被删除,或当前账号无权限访问。可返回书籍列表查看其它书籍。',
- recoverable: false,
- };
- return;
- }
- // 同步 generating 状态(只要不是最终完成状态,就开始轮询)
- const stage = currentBook.value.genStage;
- const failedStage = currentBook.value.failedStage;
- const isFinalState = stage === 'content_completed' || stage === 'audio_completed' || stage === 'video_completed';
- // 有 failedStage 表示生成失败
- const isFailed = !!failedStage;
- if ((stage && !isFinalState && !isFailed)) {
- generating.value = true;
- }
- // 开始生成时(包括 content_completed 后台生成音频)都要轮询,失败状态不轮询
- if (stage === 'outline_ready') {
- // 大纲就绪、等用户操作/或后端 book-recovery-scanner 自动推进:每 30 秒降频轮询
- startOutlineReadyPolling(id);
- } else if (stage && stage !== 'video_completed' && !isFailed) {
- startPollingProgress(id);
- }
- } catch (e: any) {
- console.error('加载书籍失败:', e);
- // 致命错误落到页面(不要只 toast 一闪而过),让用户明确知道发生了什么
- const raw = (e?.message || '').trim();
- const msg = raw || '网络请求失败,请稍后重试';
- const looksLikeNotExist = /书籍不存在|不存在|not\s*found|404/i.test(msg);
- loadError.value = {
- icon: looksLikeNotExist ? '📭' : '⚠️',
- title: looksLikeNotExist ? '书籍不存在' : '加载失败',
- message: looksLikeNotExist
- ? '该书籍可能已被删除,或当前账号无权限访问。可返回书籍列表查看其它书籍。'
- : msg,
- recoverable: !looksLikeNotExist, // 网络错误可重试,书籍不存在重试无意义
- };
- }
- }
- // 返回书籍列表(致命错误时给用户一个明确出口)
- function goBackToList() {
- const pages = getCurrentPages();
- if (pages.length > 1) {
- uni.navigateBack();
- } else {
- uni.reLaunch({ url: '/pages/book-generator/index' });
- }
- }
- function startPollingProgress(bookId: string) {
- stopPollingProgress();
- // 重置卡死检测
- stalledTip.value = false;
- lastProgressSnapshot = '';
- stalledTicks = 0;
- pollTimer = setInterval(async () => {
- try {
- const book = await api.getBook(bookId);
- if (!book) return;
- if (currentBook.value) {
- currentBook.value.progress = book.progress;
- currentBook.value.genStage = book.genStage as any;
- currentBook.value.error = book.error;
- // AI 异步回填标题/规模时自动更新
- if (book.title && book.title !== currentBook.value.title) {
- currentBook.value.title = book.title;
- }
- if (book.bookScale && book.bookScale !== currentBook.value.bookScale) {
- currentBook.value.bookScale = book.bookScale;
- }
- if (book.estimatedWords && book.estimatedWords !== currentBook.value.estimatedWords) {
- currentBook.value.estimatedWords = book.estimatedWords;
- }
- if (book.totalChapters && book.totalChapters !== currentBook.value.totalChapters) {
- currentBook.value.totalChapters = book.totalChapters;
- }
- }
- // 以下状态认为生成已完成,停止轮询
- // outline_ready 不在 doneStages 里——它有专门的降频轮询 startOutlineReadyPolling 检测后端自愈
- const doneStages = ['content_completed', 'audio_completed', 'video_completed', 'failed'];
- if (doneStages.includes(book.genStage)) {
- stopPollingProgress();
- generating.value = false;
- stalledTip.value = false;
- currentBook.value = book;
- if (book.genStage === 'content_completed' || book.genStage === 'audio_completed' || book.genStage === 'video_completed') {
- uni.showToast({ title: '生成完成!', icon: 'success' });
- // 写入通知
- const notifStore = useNotificationStore();
- const stageLabel = book.genStage === 'content_completed' ? '内容' : book.genStage === 'audio_completed' ? '音频' : '视频';
- notifStore.add({ type: 'content_complete', title: `${stageLabel}生成完成`, message: `《${currentBook.value?.title || '书籍'}》${stageLabel}已生成完毕`, bookId });
- }
- return;
- }
- // 卡死检测:进度+阶段连续 STALLED_THRESHOLD 次无变化 → 后台疑似已停
- // 用每章 genStage 拼快照,能感知"内容在逐章推进"的细微变化,避免误判
- const chapterSig = (book.chapters || []).map((c: any) => c.genStage).join(',');
- const snapshot = `${book.progress}|${book.genStage}|${chapterSig}`;
- if (snapshot === lastProgressSnapshot) {
- stalledTicks++;
- if (stalledTicks >= STALLED_THRESHOLD) {
- // 判定卡死:停止轮询,退出"生成中"假象,提示用户可重新生成/续生成
- stopPollingProgress();
- generating.value = false;
- stalledTip.value = true;
- console.warn('[Detail] 生成进度长时间无变化,判定后台已停止,停止轮询');
- }
- } else {
- lastProgressSnapshot = snapshot;
- stalledTicks = 0;
- }
- } catch (e) {
- console.error('轮询进度失败:', e);
- }
- }, 3000);
- }
- /**
- * outline_ready 状态的降频轮询:每 30 秒拉一次,检测后端 book-recovery-scanner 是否自动推进
- * 若 genStage 变为 content_generating / generating_content / audio_generating 等活跃态,
- * 自动切回正常 polling,让用户实时看到进度
- */
- function startOutlineReadyPolling(bookId: string) {
- stopOutlineReadyPolling();
- console.log('[Detail] 启动 outline_ready 降频轮询(30s 间隔)');
- outlineReadyPollTimer = setInterval(async () => {
- try {
- const book = await api.getBook(bookId);
- if (!book) return;
- const newStage = book.genStage as string;
- // 一旦进入"活跃生成"状态,切回正常 polling
- const activeStages = ['content_generating', 'audio_generating', 'video_generating', 'generating_content', 'outline_generating'];
- if (activeStages.includes(newStage)) {
- console.log(`[Detail] 检测到后端已自愈推进 (genStage=${newStage}),切回正常轮询`);
- if (currentBook.value) {
- currentBook.value.genStage = newStage as any;
- currentBook.value.progress = book.progress;
- }
- stopOutlineReadyPolling();
- startPollingProgress(bookId);
- generating.value = true;
- } else if (newStage !== 'outline_ready') {
- // 变成其它稳定态(completed/failed 等),停止降频轮询
- stopOutlineReadyPolling();
- } else {
- // 还在 outline_ready:更新一下 progress(虽然后端没改)
- if (currentBook.value && book.progress !== currentBook.value.progress) {
- currentBook.value.progress = book.progress;
- }
- }
- } catch (e) {
- console.error('[Detail] outline_ready 降频轮询失败:', e);
- }
- }, 30_000);
- }
- function stopOutlineReadyPolling() {
- if (outlineReadyPollTimer) {
- clearInterval(outlineReadyPollTimer);
- outlineReadyPollTimer = null;
- console.log('[Detail] outline_ready 降频轮询已停止');
- }
- }
- function stopPollingProgress() {
- if (pollTimer) {
- clearInterval(pollTimer);
- pollTimer = null;
- }
- }
- function startPollingAudioStatus(bookId: string) {
- stopPollingAudioStatus();
- audioPollTimer = setInterval(async () => {
- try {
- const status = await api.getAudioStatus(bookId);
- if (status.allCompleted) {
- stopPollingAudioStatus();
- generatingAudio.value = false;
- await loadBook(bookId);
- uni.showToast({ title: '音频生成完成', icon: 'success' });
- // 写入通知
- const notifStore = useNotificationStore();
- notifStore.add({ type: 'audio_complete', title: '音频生成完成', message: `《${currentBook.value?.title || '书籍'}》音频已生成完毕`, bookId });
- }
- } catch (e) {
- console.error('轮询音频状态失败:', e);
- }
- }, 5000);
- }
- function stopPollingAudioStatus() {
- if (audioPollTimer) {
- clearInterval(audioPollTimer);
- audioPollTimer = null;
- }
- }
- function startPollingVideoStatus(bookId: string) {
- stopPollingVideoStatus();
- videoPollTimer = setInterval(async () => {
- try {
- const status = await api.getVideoStatus(bookId);
- if (status.allCompleted) {
- stopPollingVideoStatus();
- generatingVideo.value = false;
- await loadBook(bookId);
- uni.showToast({ title: '视频生成完成', icon: 'success' });
- // 写入通知
- const notifStore = useNotificationStore();
- notifStore.add({ type: 'video_complete', title: '视频生成完成', message: `《${currentBook.value?.title || '书籍'}》视频已生成完毕`, bookId });
- }
- } catch (e) {
- console.error('轮询视频状态失败:', e);
- }
- }, 5000);
- }
- function stopPollingVideoStatus() {
- if (videoPollTimer) {
- clearInterval(videoPollTimer);
- videoPollTimer = null;
- }
- }
- // interactive 模式下 outline 已就绪,触发正文内容生成(调 /interactive/generate)
- async function handleStartContentGenerate() {
- if (!currentBook.value || outlineStarting.value) return;
- const bookId = currentBook.value.id;
- outlineStarting.value = true;
- try {
- uni.showLoading({ title: '启动内容生成...', mask: true });
- const res: any = await post(`/book-generator/langgraph/books/${bookId}/interactive/generate`, {});
- uni.hideLoading();
- // 成功后端返回 genStage=generating_content,前端轮询会自然跟进进度
- if (currentBook.value) {
- currentBook.value.genStage = (res?.genStage || 'content_generating') as any;
- currentBook.value.progress = 10;
- }
- uni.showToast({ title: '已开始生成内容', icon: 'success' });
- // 重新拉取详情(后端可能立刻推进 genStage)
- await loadBook(bookId);
- // 启动轮询
- startPollingProgress(bookId);
- } catch (e: any) {
- uni.hideLoading();
- uni.showToast({ title: e?.message || '启动失败', icon: 'none' });
- console.error('[handleStartContentGenerate] 失败:', e);
- } finally {
- outlineStarting.value = false;
- }
- }
- // 手动补全缺失章节(不依赖 LangGraph,绕过 rich_outline 超时问题)
- async function handleFillMissing() {
- if (!currentBook.value || fillingMissing.value) return;
- const bookId = currentBook.value.id;
- fillingMissing.value = true;
- try {
- uni.showLoading({ title: '启动补全...', mask: true });
- const res: any = await post(`/book-generator/langgraph/books/${bookId}/fill-missing`, {});
- uni.hideLoading();
- if (res?.data?.missingCount === 0) {
- uni.showToast({ title: '书籍已完整', icon: 'success' });
- await loadBook(bookId);
- return;
- }
- const cnt = res?.data?.missingCount || '?';
- uni.showToast({ title: `已启动补全 ${cnt} 节`, icon: 'success' });
- // 立即刷新页面状态,启动 polling 跟进
- if (currentBook.value) {
- currentBook.value.genStage = 'generating_content' as any;
- }
- await loadBook(bookId);
- startPollingProgress(bookId);
- } catch (e: any) {
- uni.hideLoading();
- uni.showToast({ title: e?.message || '启动失败', icon: 'none' });
- console.error('[handleFillMissing] 失败:', e);
- } finally {
- fillingMissing.value = false;
- }
- }
- async function handleRetryGenerate() {
- if (!currentBook.value) return;
- uni.showModal({
- title: '确认重新生成',
- content: '将重新开始生成,是否继续?',
- success: async (res) => {
- if (res.confirm) {
- await handleLangGraphGenerate();
- }
- },
- });
- }
- // 续生成:只补齐未完成(失败/空壳)的章节,保留已生成的内容,不重建大纲
- async function handleResume() {
- if (!currentBook.value) return;
- const bookId = currentBook.value.id;
- uni.showModal({
- title: '继续生成',
- content: '将补齐未完成的章节内容(保留已生成的部分),是否继续?',
- success: async (res) => {
- if (!res.confirm) return;
- try {
- uni.showLoading({ title: '提交中...', mask: true });
- await post(`/book-generator/langgraph/books/${bookId}/resume`, {});
- uni.hideLoading();
- uni.showToast({ title: '已开始续生成', icon: 'success' });
- // 切回生成中并重新轮询
- stalledTip.value = false;
- generating.value = true;
- startPollingProgress(bookId);
- } catch (e: any) {
- uni.hideLoading();
- uni.showToast({ title: e?.message || '续生成启动失败', icon: 'none' });
- }
- },
- });
- }
- async function handleGenerateAudio() {
- if (!currentBook.value || generatingAudio.value) return;
- uni.showModal({
- title: '确认生成音频',
- content: `将为 ${currentBook.value.totalChapters || '所有'} 个章节生成音频,是否继续?`,
- success: async (res) => {
- if (!res.confirm) return;
- generatingAudio.value = true;
- try {
- const result = await api.generateAllChaptersAudio(currentBook.value.id, 'cherry');
- uni.showToast({ title: `已启动 ${result.totalChapters} 个音频生成`, icon: 'none' });
- // 开始轮询音频生成状态,不再立即清除 generatingAudio
- startPollingAudioStatus(currentBook.value.id);
- // 失败时才清除状态,成功后由轮询完成时清除
- } catch (e: any) {
- uni.showToast({ title: e.message || '启动失败', icon: 'none' });
- generatingAudio.value = false; // 失败时才清除
- }
- },
- });
- }
- 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;
- uni.showModal({
- title: '确认重新生成',
- content: '将清除所有章节数据(含已生成的音频),根据当前书籍规模重新生成全部内容。确定继续吗?',
- success: async (res) => {
- if (!res.confirm) return;
- generating.value = true;
- currentBook.value.genStage = 'outlining';
- 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.genStage = '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) {
- currentBookId.value = String(query.id);
- loadBook(currentBookId.value);
- }
- });
- 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) {
- currentBookId.value = decodeURIComponent(match[1]);
- loadBook(currentBookId.value);
- }
- }
- // #endif
- // 获取配额信息
- fetchQuotaInfo();
- });
- onShow(() => {
- // onShow 时刷新书籍数据(H5 环境下 onMounted 时 hash 可能未更新)
- if (currentBook.value?.id) {
- loadBook(currentBook.value.id);
- }
- // 刷新配额信息
- fetchQuotaInfo();
- });
- onUnmounted(() => {
- stopPollingProgress();
- stopPollingAudioStatus();
- stopPollingVideoStatus();
- });
- </script>
- <style scoped>
- .page {
- min-height: 100vh;
- background: var(--color-bg-page, #f3f4f6);
- }
- /* --- 导航栏 --- */
- .nav-bar {
- position: fixed; top: 0; left: 0; right: 0; z-index: 100;
- background: rgba(255, 255, 255, 0.88);
- backdrop-filter: blur(24px);
- -webkit-backdrop-filter: blur(24px);
- border-bottom: 0.5rpx solid rgba(0, 0, 0, 0.06);
- }
- .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; }
- .nav-edit-btn { font-size: 28rpx; color: var(--color-primary-600, #4f46e5); font-weight: 600; }
- .back-icon { font-size: 36rpx; color: var(--color-text-primary, #111827); }
- .page-title {
- font-size: var(--text-lg, 32rpx); font-weight: 700; color: var(--color-text-primary, #111827);
- max-width: 400rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
- }
- .main-content {
- padding-top: calc(88rpx + env(safe-area-inset-top));
- padding: calc(88rpx + env(safe-area-inset-top) + 16rpx) 24rpx 32rpx;
- }
- /* --- 卡片 --- */
- .card {
- background: var(--color-bg-card, #ffffff);
- border-radius: var(--radius-lg, 24rpx);
- padding: 28rpx;
- margin-bottom: 16rpx;
- box-shadow: var(--shadow-xs, 0 1rpx 2rpx rgba(0,0,0,0.04));
- }
- .card-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20rpx; }
- .header-actions { display: flex; align-items: center; gap: 12rpx; }
- .edit-btn { padding: 10rpx 20rpx; background: linear-gradient(135deg, var(--color-primary-600, #4f46e5), #7c3aed); color: white; border-radius: 8rpx; font-size: 24rpx; font-weight: 600; }
- .batch-actions-bar { display: flex; gap: 12rpx; padding: 20rpx; background: var(--color-bg-surface, #f9fafb); border-radius: var(--radius-md, 16rpx); margin-bottom: 20rpx; flex-wrap: wrap; }
- .batch-btn { padding: 14rpx 28rpx; background: white; border: 1.5rpx solid var(--color-bg-divider, #e5e7eb); border-radius: var(--radius-sm, 10rpx); font-size: 26rpx; color: #374151; font-weight: 500; }
- .batch-btn.regenerate { background: linear-gradient(135deg, #10b981, #059669); color: white; border: none; }
- .batch-btn.regenerate.disabled { opacity: 0.5; }
- .batch-btn.delete { background: linear-gradient(135deg, #ef4444, #dc2626); color: white; border: none; }
- .batch-btn.delete.disabled { opacity: 0.5; }
- .batch-btn.cancel { background: var(--color-bg-surface, #f9fafb); color: var(--color-text-secondary, #4b5563); border: none; }
- .card-title { font-size: var(--text-lg, 32rpx); font-weight: 700; color: var(--color-text-primary, #111827); }
- .book-info { margin-bottom: 16rpx; }
- .book-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8rpx; }
- .book-title-large { font-size: var(--text-xl, 36rpx); font-weight: 700; color: var(--color-text-primary, #111827); flex: 1; letter-spacing: -0.3rpx; }
- .book-subtitle { font-size: var(--text-base, 28rpx); color: var(--color-text-secondary, #4b5563); margin-bottom: 16rpx; display: block; }
- .book-desc { font-size: 24rpx; color: var(--color-text-secondary, #4b5563); line-height: 1.6; display: block; }
- .book-meta-row { display: flex; gap: 24rpx; font-size: 24rpx; color: var(--color-text-tertiary, #9ca3af); margin-top: 16rpx; }
- /* 状态标签由 GenerationStatusBadge 组件统一管理 */
- .progress-card { margin-bottom: 16rpx; }
- .progress-display { display: flex; align-items: baseline; gap: 16rpx; margin: 20rpx 0 12rpx; }
- .progress-text { font-size: 52rpx; font-weight: 800; color: var(--color-primary-600, #4f46e5); letter-spacing: -1rpx; }
- .progress-text.progress-failed { color: #ef4444; }
- .progress-fill-failed { background: linear-gradient(90deg, #ef4444, #dc2626) !important; }
- .progress-detail { font-size: var(--text-base, 28rpx); color: var(--color-text-secondary, #4b5563); }
- .progress-bar-large { height: 10rpx; background: var(--color-bg-divider, #e5e7eb); border-radius: 5rpx; overflow: hidden; }
- .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; }
- .current-chapter-info { display: flex; align-items: center; padding: 16rpx; background: rgba(255, 255, 255, 0.15); border-radius: 12rpx; margin-bottom: 16rpx; }
- .chapter-label { font-size: 24rpx; opacity: 0.8; margin-right: 8rpx; flex-shrink: 0; }
- .chapter-name { font-size: 26rpx; font-weight: 600; flex: 1; }
- .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; }
- /* 质量报告卡片(成功路径,不是错误) */
- .report-card { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #ecfeff 0%, #cffafe 100%); border-radius: 16rpx; border: 2rpx solid #67e8f9; }
- .report-header { display: flex; align-items: center; gap: 12rpx; }
- .report-icon { font-size: 32rpx; flex-shrink: 0; }
- .report-title { flex: 1; font-size: 28rpx; font-weight: 600; color: #155e75; }
- .report-toggle { font-size: 24rpx; color: #0e7490; font-weight: 500; }
- .report-body { margin-top: 16rpx; padding-top: 16rpx; border-top: 1rpx solid rgba(14, 116, 144, 0.2); }
- .report-item { margin-bottom: 12rpx; }
- .report-label { font-size: 24rpx; font-weight: 600; color: #155e75; display: block; margin-bottom: 6rpx; }
- .report-text { font-size: 24rpx; color: #0e7490; line-height: 1.6; display: block; }
- /* 致命错误卡:书籍不存在 / 加载失败 / 权限不足等,全屏卡片式提示 */
- .fatal-error-card { display: flex; flex-direction: column; align-items: center; padding: 64rpx 32rpx; margin-top: 40rpx; }
- .fatal-error-icon { font-size: 96rpx; display: block; margin-bottom: 24rpx; }
- .fatal-error-title { font-size: 36rpx; font-weight: 700; color: #dc2626; display: block; margin-bottom: 16rpx; text-align: center; }
- .fatal-error-message { font-size: 28rpx; color: #4b5563; line-height: 1.6; text-align: center; margin-bottom: 40rpx; padding: 0 24rpx; }
- .fatal-error-actions { display: flex; flex-direction: column; gap: 20rpx; width: 100%; max-width: 600rpx; }
- .fatal-error-btn { width: 100%; height: 80rpx; background: #ffffff; color: #374151; border: 2rpx solid #d1d5db; border-radius: 12rpx; font-size: 28rpx; font-weight: 600; }
- .fatal-error-btn.primary { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: white; border: none; }
- .outline-theme { font-size: 24rpx; color: #6b7280; }
- /* 章节搜索工具栏 */
- .chapter-toolbar {
- display: flex; align-items: center; gap: 12rpx;
- margin-top: 20rpx; margin-bottom: 12rpx;
- }
- .search-box {
- flex: 1; display: flex; align-items: center;
- background: var(--color-bg-surface, #f9fafb);
- border-radius: var(--radius-md, 16rpx);
- padding: 14rpx 20rpx;
- border: 1.5rpx solid transparent;
- transition: all 0.2s;
- }
- .search-box:focus-within {
- border-color: var(--color-primary-600, #4f46e5);
- background: var(--color-bg-card, #ffffff);
- box-shadow: 0 0 0 4rpx rgba(79, 70, 229, 0.08);
- }
- .search-icon-small { font-size: 26rpx; margin-right: 10rpx; flex-shrink: 0; opacity: 0.4; }
- .search-input { flex: 1; font-size: 26rpx; color: var(--color-text-primary, #111827); background: transparent; }
- .search-placeholder-text { color: var(--color-text-tertiary, #9ca3af); font-size: 26rpx; }
- .search-clear { font-size: 26rpx; color: var(--color-text-tertiary, #9ca3af); padding: 4rpx 8rpx; flex-shrink: 0; }
- .toggle-btn {
- flex-shrink: 0; padding: 14rpx 20rpx;
- background: var(--color-bg-surface, #f9fafb);
- border-radius: var(--radius-md, 16rpx);
- font-size: 24rpx; color: var(--color-primary-600, #4f46e5);
- white-space: nowrap; transition: all 0.15s;
- }
- .toggle-btn:active { background: var(--color-bg-hover, #f3f4f6); }
- .search-empty { text-align: center; padding: 40rpx; color: var(--color-text-tertiary, #9ca3af); font-size: 26rpx; }
- .content-item {
- display: flex; align-items: center; justify-content: space-between;
- padding: 20rpx; background: var(--color-bg-surface, #f9fafb);
- border-radius: var(--radius-md, 16rpx); margin-top: 16rpx;
- transition: transform 0.15s, background 0.15s;
- }
- .content-item:active { background: var(--color-bg-hover, #f3f4f6); }
- .content-item.foreword, .content-item.afterword {
- background: linear-gradient(135deg, #fef3c7, #fde68a);
- cursor: pointer;
- }
- .content-item-left { display: flex; align-items: center; gap: 16rpx; }
- .content-label { font-size: var(--text-base, 28rpx); font-weight: 600; color: var(--color-text-primary, #111827); }
- .content-word-count { font-size: 22rpx; color: var(--color-text-tertiary, #9ca3af); }
- .content-item-right { display: flex; align-items: center; }
- .expand-icon { font-size: 26rpx; color: var(--color-text-tertiary, #9ca3af); }
- .content-card { margin-top: 0; }
- .content-tree-list { margin-top: 16rpx; }
- .content-tree-item { margin-bottom: 10rpx; }
- .content-tree-row {
- display: flex; align-items: center; gap: 14rpx;
- padding: 16rpx 14rpx;
- background: var(--color-bg-surface, #f9fafb);
- border-radius: var(--radius-sm, 10rpx);
- transition: background 0.15s;
- }
- .content-tree-row:active { background: var(--color-bg-hover, #f3f4f6); }
- .chapter-num-badge {
- width: 44rpx; height: 44rpx;
- background: var(--color-primary-600, #4f46e5);
- color: #ffffff; border-radius: 50%;
- display: flex; align-items: center; justify-content: center;
- font-size: 22rpx; font-weight: 700; flex-shrink: 0;
- }
- .chapter-info { flex: 1; }
- .chapter-title { font-size: 26rpx; font-weight: 600; color: var(--color-text-primary, #111827); display: block; margin-bottom: 6rpx; }
- .chapter-content-hint { margin-bottom: 6rpx; }
- .content-hint-tag { font-size: 20rpx; padding: 3rpx 10rpx; background: var(--color-info-bg, #eff6ff); color: #0369a1; border-radius: 4rpx; font-weight: 500; }
- .chapter-outline-summary { font-size: 22rpx; color: var(--color-text-secondary, #4b5563); display: block; margin-bottom: 6rpx; }
- .chapter-key-points { display: flex; flex-wrap: wrap; gap: 6rpx; margin-bottom: 6rpx; }
- .point-tag { font-size: 18rpx; padding: 3rpx 10rpx; background: var(--color-bg-hover, #f3f4f6); color: var(--color-text-secondary, #4b5563); border-radius: 4rpx; }
- .chapter-media-badges { display: flex; gap: 6rpx; }
- .media-badge { font-size: 18rpx; padding: 3rpx 10rpx; background: rgba(16, 185, 129, 0.1); color: #059669; border-radius: 4rpx; font-weight: 500; }
- .chapter-status { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 76rpx; padding: 8rpx 14rpx; border-radius: 8rpx; flex-shrink: 0; }
- .chapter-status.status-completed { background: #d1fae5; color: #059669; }
- .chapter-status.status-pending { background: var(--color-bg-divider, #e5e7eb); color: var(--color-text-tertiary, #9ca3af); }
- .chapter-status.status-generating { background: var(--color-warning-bg, #fffbeb); color: #d97706; animation: pulse-bg 2s ease-in-out infinite; }
- .chapter-status.status-failed { background: var(--color-error-bg, #fef2f2); color: #dc2626; }
- @keyframes pulse-bg { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
- .status-icon { font-size: 24rpx; font-weight: bold; line-height: 1; }
- .status-text { font-size: 18rpx; margin-top: 2rpx; white-space: nowrap; }
- .section-num-badge { width: 38rpx; height: 38rpx; background: #8b5cf6; color: #ffffff; border-radius: 8rpx; display: flex; align-items: center; justify-content: center; font-size: 20rpx; font-weight: 600; flex-shrink: 0; }
- .section-info { flex: 1; }
- .section-title { font-size: 24rpx; font-weight: 500; color: #374151; display: block; margin-bottom: 2rpx; }
- .section-summary { font-size: 20rpx; color: var(--color-text-tertiary, #9ca3af); display: block; }
- .section-status { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 66rpx; padding: 6rpx 10rpx; border-radius: 6rpx; flex-shrink: 0; }
- .section-status.status-completed { background: #d1fae5; color: #059669; }
- .section-status.status-pending { background: var(--color-bg-hover, #f3f4f6); color: var(--color-text-tertiary, #9ca3af); }
- .section-status.status-generating { background: var(--color-warning-bg, #fffbeb); color: #d97706; }
- .section-status.status-failed { background: var(--color-error-bg, #fef2f2); color: #dc2626; }
- .subsection-num-badge { width: 34rpx; height: 34rpx; background: #06b6d4; color: #ffffff; border-radius: 6rpx; display: flex; align-items: center; justify-content: center; font-size: 18rpx; font-weight: 500; flex-shrink: 0; }
- .subsection-info { flex: 1; }
- .subsection-title { font-size: 22rpx; color: var(--color-text-secondary, #4b5563); display: block; }
- .subsection-media-badges { display: flex; gap: 6rpx; margin-top: 4rpx; }
- .media-badge-small { font-size: 16rpx; padding: 2rpx 8rpx; background: rgba(16, 185, 129, 0.1); color: #059669; border-radius: 4rpx; }
- .subsection-content-preview { font-size: 20rpx; color: var(--color-text-secondary, #4b5563); 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: 66rpx; padding: 6rpx 10rpx; border-radius: 6rpx; flex-shrink: 0; }
- .subsection-status.status-completed { background: #d1fae5; color: #059669; }
- .subsection-status.status-pending { background: var(--color-bg-hover, #f3f4f6); color: var(--color-text-tertiary, #9ca3af); }
- .subsection-status.status-generating { background: var(--color-warning-bg, #fffbeb); color: #d97706; }
- .subsection-status.status-failed { background: var(--color-error-bg, #fef2f2); color: #dc2626; }
- .action-card { margin-bottom: 24rpx; }
- /* 手动触发生成提示(用户创建时关闭了自动生成) */
- .manual-hint {
- display: flex; align-items: center; gap: 12rpx;
- padding: 16rpx 20rpx;
- background: #fffbeb; border: 1rpx solid #fde68a;
- border-radius: 12rpx;
- margin-bottom: 16rpx;
- }
- .manual-hint-icon { font-size: 32rpx; flex-shrink: 0; }
- .manual-hint-text { flex: 1; font-size: 24rpx; color: #92400e; line-height: 1.5; }
- .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; }
- .action-btn.audio-btn { background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: #ffffff; }
- .action-btn.upgrade-btn { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #ffffff; }
- .btn-with-quota { display: flex; flex-direction: column; gap: 8rpx; width: 100%; }
- .btn-with-quota .action-btn { width: 100%; flex: none; }
- .quota-info-inline { display: flex; flex-wrap: wrap; gap: 12rpx; padding: 8rpx 0; }
- .quota-text { font-size: 22rpx; color: #6b7280; }
- .quota-warn { font-size: 22rpx; color: #ef4444; font-weight: 600; }
- .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>
|