detail.vue 73 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601
  1. <template>
  2. <view class="page">
  3. <!-- 顶部导航栏 -->
  4. <view class="nav-bar">
  5. <view class="nav-content">
  6. <view class="nav-left" @click="goBack">
  7. <text class="back-icon">←</text>
  8. </view>
  9. <text class="page-title">{{ isTitleGenerating ? 'AI 生成标题中...' : (currentBook?.title || '书籍详情') }}</text>
  10. <view class="nav-right">
  11. <text v-if="canEdit" class="nav-edit-btn" @click="goToEdit">编辑</text>
  12. </view>
  13. </view>
  14. </view>
  15. <!-- 主内容区 -->
  16. <view class="main-content">
  17. <!-- 书籍信息 -->
  18. <view class="card book-info">
  19. <view class="book-title-row">
  20. <text class="book-title-large">{{ isTitleGenerating ? '✨ AI 正在生成标题...' : currentBook?.title }}</text>
  21. <GenerationStatusBadge :status="currentBook?.genStage || 'draft'"></GenerationStatusBadge>
  22. </view>
  23. <text v-if="currentBook?.subtitle" class="book-subtitle">{{ currentBook.subtitle }}</text>
  24. <text class="book-desc">{{ currentBook?.description }}</text>
  25. <view class="book-meta-row">
  26. <text>章节:{{ currentBook?.totalChapters }} 章</text>
  27. <text>预估:{{ currentBook?.estimatedWords || 0 }} 字</text>
  28. </view>
  29. </view>
  30. <!-- 进度显示 -->
  31. <view v-if="currentBook" class="card progress-card">
  32. <text class="card-title">📊 生成进度</text>
  33. <view class="progress-display">
  34. <text class="progress-text" :class="{ 'progress-failed': isGenerationFailed }">{{ isGenerationFailed ? '失败' : currentBook.progress }}%</text>
  35. <text class="progress-detail">
  36. {{ completedChapters }}/{{ currentBook.totalChapters }} 章
  37. </text>
  38. </view>
  39. <view class="progress-bar-large">
  40. <view class="progress-fill" :class="{ 'progress-fill-failed': isGenerationFailed }" :style="{ width: (isGenerationFailed ? 0 : currentBook.progress) + '%' }"></view>
  41. </view>
  42. <!-- 失败状态 -->
  43. <view v-if="isGenerationFailed" class="failed-tip">
  44. <text class="failed-icon">❌</text>
  45. <text class="failed-text">生成失败</text>
  46. <text v-if="currentBook.error" class="failed-error">{{ currentBook.error }}</text>
  47. <text class="failed-hint">请检查网络或稍后重试</text>
  48. <button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
  49. </view>
  50. <!-- 大纲已就绪、等待触发内容生成(interactive 模式特有状态) -->
  51. <view v-if="currentBook.genStage === 'outline_ready' && !isGenerationFailed" class="failed-tip" style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-color: #f59e0b;">
  52. <text class="failed-icon">📋</text>
  53. <text class="failed-text" style="color: #b45309;">大纲已生成,等待生成正文</text>
  54. <text class="failed-hint">进度长时间停在 5%,是因为大纲确认后内容生成尚未启动。点击下方按钮立即开始生成。</text>
  55. <button class="retry-btn" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);" :disabled="outlineStarting" @click="handleStartContentGenerate">🚀 继续生成内容</button>
  56. </view>
  57. <!-- 卡死状态:进度长时间无变化,后台疑似已停 -->
  58. <view v-if="stalledTip && !isGenerationFailed && currentBook.genStage !== 'outline_ready'" class="failed-tip">
  59. <text class="failed-icon">⏸️</text>
  60. <text class="failed-text">生成似乎已停止</text>
  61. <text class="failed-hint">进度长时间未更新,后台任务可能已中断。可继续补齐未完成的章节,或整本重新生成。</text>
  62. <button class="retry-btn" @click="handleResume">▶️ 继续生成</button>
  63. <button class="retry-btn" style="margin-top: 12rpx; background: linear-gradient(135deg, #10b981 0%, #059669 100%);" :disabled="fillingMissing" @click="handleFillMissing">⚡ 补全缺失章节</button>
  64. <button class="retry-btn" style="margin-top: 12rpx; background: #888;" @click="handleLangGraphGenerate">🔄 整本重新生成</button>
  65. </view>
  66. <!-- 始终可用的兜底按钮:只要有缺失章节就显示 -->
  67. <view v-if="hasMissingChapters && !isCurrentlyGenerating && !isGenerationFailed" class="failed-tip" style="background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); border-color: #3b82f6;">
  68. <text class="failed-icon">🩹</text>
  69. <text class="failed-text" style="color: #1d4ed8;">检测到 {{ missingChapterCount }} 个章节未生成内容</text>
  70. <text class="failed-hint">这本书可能有章节被 LangGraph 节点漏掉。点击下方按钮并发补全(不依赖 LangGraph 重跑)。</text>
  71. <button class="retry-btn" style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);" :disabled="fillingMissing" @click="handleFillMissing">⚡ 补全 {{ missingChapterCount }} 个缺失章节</button>
  72. </view>
  73. <!-- 实时生成状态面板 -->
  74. <view v-if="isCurrentlyGenerating && !isGenerationFailed && !stalledTip" class="generation-status">
  75. <view class="status-header">
  76. <text class="status-icon">⚡</text>
  77. <text class="status-title">正在生成中...【时间较长,请耐心等待,可关闭页面,后台会继续生成】</text>
  78. </view>
  79. <view class="status-stage">
  80. <text class="stage-label">当前阶段:</text>
  81. <text class="stage-value">{{ getCurrentStage() }}</text>
  82. </view>
  83. <view v-if="currentGeneratingChapter" class="current-chapter-info">
  84. <text class="chapter-label">正在处理:</text>
  85. <text class="chapter-name">第{{ currentGeneratingChapter.number }}{{ currentGeneratingChapter.levelLabel }} {{ currentGeneratingChapter.title }}</text>
  86. </view>
  87. <view v-if="isGenerationFailed && currentBook.error" class="status-error">
  88. <text class="error-icon">⚠️</text>
  89. <text class="error-text">{{ currentBook.error }}</text>
  90. </view>
  91. <view v-if="currentBook.error && currentBook.error.includes('自动恢复')" class="status-recovery">
  92. <text class="recovery-icon">🔄</text>
  93. <text class="recovery-text">系统正在尝试自动恢复...</text>
  94. </view>
  95. <view class="status-tip">
  96. <text class="tip-text">💡 生成过程可能需要几分钟,请耐心等待</text>
  97. </view>
  98. </view>
  99. <!-- 实时配额显示 -->
  100. <view v-if="isCurrentlyGenerating" class="quota-monitor">
  101. <text class="quota-monitor-title">🎧 额度监控</text>
  102. <view class="quota-monitor-row">
  103. <text>已用:{{ usedAudioMinutes }}分钟</text>
  104. <text>剩余:{{ remainingAudioMinutes }}分钟</text>
  105. </view>
  106. <view v-if="overageMinutes > 0" class="quota-overage-warning">
  107. <text>⚠️ 已超出配额 {{ overageMinutes }} 分钟</text>
  108. </view>
  109. </view>
  110. <!-- 中断提示 -->
  111. <view v-if="currentBook.genStage === 'failed' && currentBook?.error?.includes('额度')" class="interrupted-tip">
  112. <text class="interrupted-icon">⚠️</text>
  113. <text class="interrupted-text">生成已中断:额度不足,已保存当前进度</text>
  114. <text class="interrupted-hint">可升级套餐后继续生成</text>
  115. </view>
  116. <!-- 失败提示 -->
  117. <view v-if="isGenerationFailed && !currentBook?.error?.includes('额度')" class="failed-tip">
  118. <text class="failed-icon">❌</text>
  119. <text class="failed-text">生成失败</text>
  120. <text v-if="currentBook.error" class="failed-error">{{ currentBook.error }}</text>
  121. <text class="failed-hint">请检查网络或稍后重试</text>
  122. <button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
  123. </view>
  124. <!-- 质量报告(成功路径的日志,不是错误) -->
  125. <view v-if="!isGenerationFailed && (currentBook.qualityReport || currentBook.continuityReport || currentBook.rewriteReport)" class="report-card">
  126. <view class="report-header" @click="reportExpanded = !reportExpanded">
  127. <text class="report-icon">📊</text>
  128. <text class="report-title">质量/连贯性报告</text>
  129. <text class="report-toggle">{{ reportExpanded ? '收起' : '展开' }}</text>
  130. </view>
  131. <view v-if="reportExpanded" class="report-body">
  132. <view v-if="currentBook.qualityReport" class="report-item">
  133. <text class="report-label">质量校验:</text>
  134. <text class="report-text">{{ currentBook.qualityReport }}</text>
  135. </view>
  136. <view v-if="currentBook.continuityReport" class="report-item">
  137. <text class="report-label">连贯性编辑:</text>
  138. <text class="report-text">{{ currentBook.continuityReport }}</text>
  139. </view>
  140. <view v-if="currentBook.rewriteReport" class="report-item">
  141. <text class="report-label">改写优化:</text>
  142. <text class="report-text">{{ currentBook.rewriteReport }}</text>
  143. </view>
  144. </view>
  145. </view>
  146. </view>
  147. <!-- 完整内容区 -->
  148. <view v-if="currentBook?.chapters && currentBook.chapters.length > 0" class="card content-card">
  149. <view class="card-header-row">
  150. <text class="card-title">📖 完整内容</text>
  151. <view class="header-actions">
  152. <text v-if="currentBook.outline?.mainTheme" class="outline-theme">{{ currentBook.outline.mainTheme }}</text>
  153. <view v-if="!isBatchMode" class="edit-btn" @click="enterBatchMode">
  154. <text>批量</text>
  155. </view>
  156. </view>
  157. </view>
  158. <!-- 批量操作栏 -->
  159. <view v-if="isBatchMode" class="batch-actions-bar">
  160. <view class="batch-btn" @click="toggleSelectAll">
  161. <text>{{ isAllSelected ? '取消全选' : '全选' }}</text>
  162. </view>
  163. <view class="batch-btn regenerate" @click="handleBatchRegenerate" :class="{ disabled: selectedChapterIds.length === 0 }">
  164. <text>重新生成 ({{ selectedChapterIds.length }})</text>
  165. </view>
  166. <view class="batch-btn delete" @click="handleBatchDelete" :class="{ disabled: selectedChapterIds.length === 0 }">
  167. <text>删除 ({{ selectedChapterIds.length }})</text>
  168. </view>
  169. <view class="batch-btn cancel" @click="exitBatchMode">
  170. <text>取消</text>
  171. </view>
  172. </view>
  173. <!-- 章节搜索与折叠工具栏 -->
  174. <view class="chapter-toolbar">
  175. <view class="search-box">
  176. <text class="search-icon-small">🔍</text>
  177. <input
  178. v-model="searchKeyword"
  179. class="search-input"
  180. placeholder="搜索章节标题、摘要..."
  181. placeholder-class="search-placeholder-text"
  182. />
  183. <text v-if="searchKeyword" class="search-clear" @click="searchKeyword = ''">✕</text>
  184. </view>
  185. <view class="toggle-btn" @click="toggleExpandAll">
  186. <text>{{ allExpanded ? '📁 折叠全部' : '📂 展开全部' }}</text>
  187. </view>
  188. </view>
  189. <view v-if="searchKeyword && filteredChaptersLevel1.length === 0" class="search-empty">
  190. <text>未找到匹配的章节</text>
  191. </view>
  192. <!-- 前言 -->
  193. <view v-if="currentBook?.metadata?.foreword" class="content-item foreword" @click="viewForeword">
  194. <view class="content-item-left">
  195. <text class="content-label">前言</text>
  196. <text class="content-word-count">{{ currentBook?.metadata?.foreword?.length || 0 }}字</text>
  197. </view>
  198. <view class="content-item-right">
  199. <text class="expand-icon">→</text>
  200. </view>
  201. </view>
  202. <!-- 章节列表 -->
  203. <view class="content-tree-list">
  204. <view
  205. v-for="chapter in filteredChaptersLevel1"
  206. :key="chapter.id"
  207. class="content-tree-item content-chapter"
  208. >
  209. <view class="content-tree-row chapter-row" @click="goToChapterDetail(chapter)">
  210. <view class="chapter-num-badge">{{ chapter.number }}</view>
  211. <view class="chapter-info">
  212. <text class="chapter-title">{{ chapter.title }}</text>
  213. <view v-if="chapter.content && chapter.content.length > 100" class="chapter-content-hint">
  214. <text class="content-hint-tag">📄 正文 {{ chapter.wordCount || 0 }}字</text>
  215. </view>
  216. <text v-else-if="getChapterOutline(chapter.number)?.summary" class="chapter-outline-summary">
  217. {{ getChapterOutline(chapter.number)?.summary }}
  218. </text>
  219. <view v-if="!chapter.content || chapter.content.length <= 100" class="chapter-key-points">
  220. <text
  221. v-for="(point, idx) in getChapterOutline(chapter.number)?.keyPoints?.slice(0, 3) || []"
  222. :key="idx"
  223. class="point-tag"
  224. >
  225. {{ point }}
  226. </text>
  227. </view>
  228. <view class="chapter-media-badges">
  229. <text v-if="chapterHasAudio(chapter)" class="media-badge">🎵 音频</text>
  230. <text v-if="chapter.videoUrl" class="media-badge">🎬 视频</text>
  231. </view>
  232. </view>
  233. <view :class="['chapter-status', getNodeStatusClass(chapter)]">
  234. <text class="status-icon">{{ getNodeStatusIcon(chapter) }}</text>
  235. <text class="status-text">{{ getNodeStatusText(chapter) }}</text>
  236. </view>
  237. </view>
  238. <!-- 节和小节包裹折叠控制 -->
  239. <view v-show="allExpanded">
  240. <!-- 节 -->
  241. <view v-if="getChapterSectionsFromDB(chapter.id) && getChapterSectionsFromDB(chapter.id).length > 0" class="content-sections-list">
  242. <view
  243. v-for="section in getChapterSectionsFromDB(chapter.id)"
  244. :key="'sec-' + section.id"
  245. class="content-tree-item content-section"
  246. @click="goToChapterDetail(section)"
  247. >
  248. <view class="content-tree-row section-row">
  249. <view class="section-num-badge">{{ section.number }}</view>
  250. <view class="section-info">
  251. <text class="section-title">{{ section.title }}</text>
  252. <text v-if="section.summary" class="section-summary">{{ section.summary }}</text>
  253. </view>
  254. <view :class="['section-status', getNodeStatusClass(section)]">
  255. <text class="status-icon">{{ getNodeStatusIcon(section) }}</text>
  256. <text class="status-text">{{ getNodeStatusText(section) }}</text>
  257. </view>
  258. </view>
  259. <!-- 小节 -->
  260. <view v-if="getSectionSubsectionsFromDB(section.id) && getSectionSubsectionsFromDB(section.id).length > 0" class="content-subsections-list">
  261. <view
  262. v-for="subsection in getSectionSubsectionsFromDB(section.id)"
  263. :key="'sub-' + subsection.id"
  264. class="content-tree-item content-subsection"
  265. @click="goToChapterDetail(subsection)"
  266. >
  267. <view class="content-tree-row subsection-row">
  268. <view class="subsection-num-badge">{{ subsection.number }}</view>
  269. <view class="subsection-info">
  270. <text class="subsection-title">{{ subsection.title }}</text>
  271. <view class="subsection-media-badges">
  272. <text v-if="subsection.audioUrl" class="media-badge-small">🎵 音频</text>
  273. <text v-if="subsection.videoUrl" class="media-badge-small">🎬 视频</text>
  274. </view>
  275. <text v-if="subsection.content" class="subsection-content-preview">
  276. {{ subsection.content.substring(0, 50) }}...
  277. </text>
  278. </view>
  279. <view :class="['subsection-status', getNodeStatusClass(subsection)]">
  280. <text class="status-icon">{{ getNodeStatusIcon(subsection) }}</text>
  281. <text class="status-text">{{ getNodeStatusText(subsection) }}</text>
  282. </view>
  283. </view>
  284. </view>
  285. </view>
  286. </view>
  287. </view>
  288. <!-- 章没有节时章本身就是叶子节点 -->
  289. <view v-else class="content-tree-item content-subsection" @click="goToChapterDetail(chapter)">
  290. <view class="content-tree-row subsection-row">
  291. <view class="subsection-info" style="margin-left: 56rpx;">
  292. <text class="subsection-title">{{ chapter.title }}</text>
  293. </view>
  294. <view class="content-item-right">
  295. <text class="expand-icon">→</text>
  296. </view>
  297. </view>
  298. </view>
  299. </view>
  300. </view>
  301. </view>
  302. <!-- 后记 -->
  303. <view v-if="currentBook?.metadata?.afterword" class="content-item afterword" @click="viewAfterword">
  304. <view class="content-item-left">
  305. <text class="content-label">后记</text>
  306. <text class="content-word-count">{{ currentBook?.metadata?.afterword?.length || 0 }}字</text>
  307. </view>
  308. <view class="content-item-right">
  309. <text class="expand-icon">→</text>
  310. </view>
  311. </view>
  312. </view>
  313. <!-- 致命错误显示:书籍不存在 / 加载失败 / 权限不足等 -->
  314. <view v-if="loadError" class="card fatal-error-card">
  315. <text class="fatal-error-icon">{{ loadError.icon || '⚠️' }}</text>
  316. <text class="fatal-error-title">{{ loadError.title }}</text>
  317. <text class="fatal-error-message">{{ loadError.message }}</text>
  318. <view class="fatal-error-actions">
  319. <button v-if="loadError.recoverable" class="fatal-error-btn primary" @click="loadBook(currentBookId)">🔄 重试</button>
  320. <button class="fatal-error-btn" @click="goBackToList">📋 返回书籍列表</button>
  321. </view>
  322. </view>
  323. <!-- 操作按钮 -->
  324. <view v-if="!loadError" class="card action-card">
  325. <!-- 手动触发生成提示(用户在创建时关闭了自动生成) -->
  326. <view v-if="showManualContentHint" class="manual-hint">
  327. <text class="manual-hint-icon">📝</text>
  328. <text class="manual-hint-text">创建时已关闭自动生成文本,点击下方按钮开始生成</text>
  329. </view>
  330. <view v-if="showManualAudioHint" class="manual-hint">
  331. <text class="manual-hint-icon">🎵</text>
  332. <text class="manual-hint-text">创建时已关闭自动生成音频,点击下方按钮开始生成</text>
  333. </view>
  334. <view class="btn-col">
  335. <view class="btn-row">
  336. <button
  337. class="action-btn langgraph-btn"
  338. :disabled="generating"
  339. @click="handleLangGraphGenerate"
  340. >
  341. {{ generating ? '生成中...' : (isContentCompleted ? '🔄 重新生成' : '🤖 开始生成') }}
  342. </button>
  343. </view>
  344. <!-- 音频生成按钮 - 内容生成完成后显示 -->
  345. <view v-if="isContentCompleted" class="btn-row">
  346. <view class="btn-with-quota">
  347. <button
  348. v-if="hasEnoughQuota"
  349. class="action-btn audio-btn"
  350. :disabled="generatingAudio"
  351. @click="handleGenerateAudio"
  352. >
  353. {{ generatingAudio ? '生成中...' : (allLeafAudioCompleted ? '🔄 重新生成全部音频' : '🎵 生成全部音频') }}
  354. </button>
  355. <button
  356. v-else
  357. class="action-btn upgrade-btn"
  358. @click="goToSubscription"
  359. >
  360. 🚀 升级套餐
  361. </button>
  362. <view class="quota-info-inline">
  363. <text class="quota-text">预估消耗 {{ estimatedAudioMinutes }} 分钟</text>
  364. <text v-if="quotaInfo" class="quota-text">剩余 {{ quotaInfo.remainingMinutes }} 分钟</text>
  365. <text v-if="!hasEnoughQuota" class="quota-warn">⚠️ 配额不足</text>
  366. </view>
  367. </view>
  368. </view>
  369. </view>
  370. <view v-if="generating" class="generating-tip">
  371. <text>正在生成中,可关闭页面,后台会继续生成...</text>
  372. </view>
  373. <view v-if="isBookDone && (!currentBook.chapters || currentBook.chapters.length === 0)" class="warning-tip">
  374. <text class="warning-icon">⚠️</text>
  375. <text class="warning-text">该书籍状态标记为已完成,但没有任何章节内容。</text>
  376. <text class="warning-hint">请尝试点击上方按钮重新生成。</text>
  377. </view>
  378. <button class="action-btn full-width" @click="goBack">
  379. 返回书籍列表
  380. </button>
  381. </view>
  382. </view>
  383. </view>
  384. </template>
  385. <script setup lang="ts">
  386. import { ref, computed, onMounted, onUnmounted } from 'vue';
  387. import { onLoad, onShow } from '@dcloudio/uni-app';
  388. import * as api from '../../utils/book-generator-api';
  389. import { post, get } from '../../utils/request';
  390. import type { Book, Chapter } from '../../utils/book-generator-api';
  391. import { useNotificationStore } from '../../store/notification';
  392. import GenerationStatusBadge from '../../components/GenerationStatusBadge.vue';
  393. // 批量操作状态
  394. const isBatchMode = ref(false);
  395. const selectedChapterIds = ref<string[]>([]);
  396. const isAllSelected = ref(false);
  397. // 章节搜索与折叠
  398. const searchKeyword = ref('');
  399. const allExpanded = ref(true);
  400. // 当前书籍
  401. const currentBook = ref<Book | null>(null);
  402. // 当前正在查看的 bookId(来自 onLoad 的 query.id / H5 hash),供致命错误时重试用
  403. const currentBookId = ref<string>('');
  404. // 加载书籍时的致命错误(如"书籍不存在"、网络不可达、权限拒绝等)
  405. // 不只是 toast 一闪而过,而是落到页面让用户看清,并可返回书籍列表
  406. const loadError = ref<{ title: string; message: string; recoverable: boolean } | null>(null);
  407. // 生成状态
  408. const generating = ref(false);
  409. const generatingAudio = ref(false);
  410. const generatingVideo = ref(false);
  411. // interactive 模式下点击"继续生成内容"的请求中状态
  412. const outlineStarting = ref(false);
  413. // 手动补全缺失章节的请求中状态
  414. const fillingMissing = ref(false);
  415. // 是否有内容(章节有实际内容文本)
  416. const hasContent = computed(() => {
  417. if (!currentBook.value?.chapters) return false;
  418. return currentBook.value.chapters.some((c) => c.content && c.content.length > 100);
  419. });
  420. // 检测生成是否失败(有 failedStage 表明在某个阶段失败了)
  421. const isGenerationFailed = computed(() => {
  422. if (!currentBook.value) return false;
  423. return !!currentBook.value.failedStage;
  424. });
  425. // 书籍是否已完成(替代旧 status === 'completed' 判断)
  426. const isBookDone = computed(() => {
  427. if (!currentBook.value?.genStage) return false;
  428. return ['content_completed', 'audio_completed', 'video_completed'].includes(currentBook.value.genStage);
  429. });
  430. // AI 标题是否正在生成中(标题为临时占位符时)
  431. const isTitleGenerating = computed(() => {
  432. return currentBook.value?.title === '正在生成标题...' && isCurrentlyGenerating.value;
  433. });
  434. // 是否正在生成中(基于 genStage 判断,或正在生成音频)
  435. const isCurrentlyGenerating = computed(() => {
  436. if (!currentBook.value) return false;
  437. if (generatingAudio.value) return true;
  438. const gs = currentBook.value.genStage;
  439. return gs === 'outlining' || gs === 'content_generating' || gs === 'audio_generating' || gs === 'video_generating';
  440. });
  441. // 是否可编辑(内容生成完成前可编辑基本信息)
  442. const canEdit = computed(() => {
  443. if (!currentBook.value) return false;
  444. const stage = currentBook.value.genStage;
  445. return !stage || stage === 'draft' || stage === 'outlining';
  446. });
  447. // 内容是否已生成完成(基于 genStage 判断)
  448. const isContentCompleted = computed(() => {
  449. if (!currentBook.value?.genStage) return false;
  450. return currentBook.value.genStage === 'content_completed' ||
  451. currentBook.value.genStage === 'audio_completed' ||
  452. currentBook.value.genStage === 'video_completed';
  453. });
  454. // 是否有章节内容缺失(level>=2 且 content 为空)
  455. const missingChapterCount = computed(() => {
  456. if (!currentBook.value?.chapters) return 0;
  457. return currentBook.value.chapters.filter((c: any) =>
  458. c.level >= 2 && (!c.content || c.content.length < 50)
  459. ).length;
  460. });
  461. const hasMissingChapters = computed(() => missingChapterCount.value > 0);
  462. // 所有叶子节点音频是否全部生成完成
  463. const allLeafAudioCompleted = computed(() => {
  464. if (!currentBook.value?.chapters?.length) return false;
  465. const leafLevel = getBookLeafLevel(currentBook.value);
  466. const leafNodes = getBookLeafNodes(currentBook.value, leafLevel);
  467. if (leafNodes.length === 0) return false;
  468. return leafNodes.every((c: any) =>
  469. c.genStage === 'audio_completed' || c.genStage === 'video_completed'
  470. );
  471. });
  472. // 手动触发生成文本提示:用户创建时关闭了自动生成文本,且书处于 draft/outline_completed 等未生成内容态
  473. const showManualContentHint = computed(() => {
  474. if (!currentBook.value) return false;
  475. if (currentBook.value.autoGenerateContent !== false) return false; // 默认 true 不提示
  476. const stage = currentBook.value.genStage;
  477. return stage === 'draft' || stage === 'outline_completed' || stage === 'outlining' || stage === 'failed';
  478. });
  479. // 手动触发生成音频提示:用户创建时关闭了自动生成音频,且内容已就绪但音频为空
  480. const showManualAudioHint = computed(() => {
  481. if (!currentBook.value) return false;
  482. if (currentBook.value.autoGenerateAudio !== false) return false; // 默认 true 不提示
  483. if (!isContentCompleted.value) return false;
  484. return !allLeafAudioCompleted.value;
  485. });
  486. // 轮询定时器
  487. let pollTimer: ReturnType<typeof setInterval> | null = null;
  488. let audioPollTimer: ReturnType<typeof setInterval> | null = null;
  489. let videoPollTimer: ReturnType<typeof setInterval> | null = null;
  490. // outline_ready 状态下的"等待后端自愈"轮询定时器(间隔较长,避免过多请求)
  491. let outlineReadyPollTimer: ReturnType<typeof setInterval> | null = null;
  492. // 卡死检测:进度/阶段长时间无变化则判定后台已停(避免永久"生成中"转圈)
  493. const stalledTip = ref(false); // 是否疑似卡死
  494. let lastProgressSnapshot = ''; // 上次的 progress+genStage 快照
  495. let stalledTicks = 0; // 连续无变化的轮询次数
  496. const STALLED_THRESHOLD = 40; // 40 次 × 3s ≈ 2 分钟无变化判定卡死
  497. // 质量报告折叠
  498. const reportExpanded = ref(false);
  499. // 计算属性
  500. const completedChapters = computed(() => {
  501. if (!currentBook.value) return 0;
  502. const doneGenStages = ['content_completed', 'audio_completed', 'audio_generating', 'video_generating', 'video_completed'];
  503. return currentBook.value.chapters.filter((c) => doneGenStages.includes(c.genStage || '')).length;
  504. });
  505. const chaptersLevel1 = computed(() => {
  506. if (!currentBook.value?.chapters) return [];
  507. return currentBook.value.chapters.filter((c) => c.level === 1);
  508. });
  509. // 当前正在生成的章节信息
  510. const currentGeneratingChapter = computed(() => {
  511. if (!currentBook.value?.chapters) return null;
  512. const generating = currentBook.value.chapters.find(
  513. (c: any) => c.genStage === 'content_generating' || c.genStage === 'audio_generating' || c.genStage === 'video_generating' || c.contentStatus === 'generating'
  514. );
  515. if (!generating) return null;
  516. const levelLabels = ['', '章', '节', '小节'];
  517. const levelLabel = levelLabels[generating.level || 1] || '章';
  518. return { number: generating.number, title: generating.title, levelLabel };
  519. });
  520. // 根据搜索关键词过滤章节
  521. const filteredChaptersLevel1 = computed(() => {
  522. const chapters = chaptersLevel1.value;
  523. if (!searchKeyword.value.trim()) return chapters;
  524. const keyword = searchKeyword.value.trim().toLowerCase();
  525. return chapters.filter(ch => {
  526. if (ch.title?.toLowerCase().includes(keyword)) return true;
  527. const outline = getChapterOutline(ch.number);
  528. if (outline?.summary?.toLowerCase().includes(keyword)) return true;
  529. if (outline?.keyPoints?.some((kp: string) => kp.toLowerCase().includes(keyword))) return true;
  530. const sections = getChapterSectionsFromDB(ch.id);
  531. if (sections.some((s: any) => s.title?.toLowerCase().includes(keyword) || s.summary?.toLowerCase().includes(keyword))) return true;
  532. return false;
  533. });
  534. });
  535. function toggleExpandAll() {
  536. allExpanded.value = !allExpanded.value;
  537. }
  538. const usedAudioMinutes = computed(() => {
  539. if (!currentBook.value) return 0;
  540. const doneGenStages = ['content_completed', 'audio_completed', 'audio_generating', 'video_generating', 'video_completed'];
  541. const totalWords = currentBook.value.chapters
  542. .filter((c) => doneGenStages.includes(c.genStage || ''))
  543. .reduce((sum, c) => sum + (c.wordCount || 0), 0);
  544. return Math.ceil(totalWords / 150);
  545. });
  546. // 预估全部音频时长(基于所有叶子节点字数)
  547. const estimatedAudioMinutes = computed(() => {
  548. if (!currentBook.value) return 0;
  549. const leafLevel = getBookLeafLevel(currentBook.value);
  550. const leafNodes = getBookLeafNodes(currentBook.value, leafLevel);
  551. const totalWords = leafNodes.reduce((sum, c: any) => sum + (c.wordCount || 0), 0);
  552. return Math.max(1, Math.ceil(totalWords / 150));
  553. });
  554. // 配额是否足够
  555. const hasEnoughQuota = computed(() => {
  556. if (!quotaInfo.value) return true; // 未加载配额信息时不限制
  557. const remaining = quotaInfo.value.remainingMinutes;
  558. // 如果启用了超额,总是允许
  559. if (quotaInfo.value.overageEnabled) return true;
  560. return remaining >= estimatedAudioMinutes.value;
  561. });
  562. function goToSubscription() {
  563. uni.navigateTo({ url: '/pages/subscription/index' });
  564. }
  565. function goToEdit() {
  566. if (!currentBook.value) return;
  567. uni.navigateTo({
  568. url: `/pages/book-generator/create?editId=${currentBook.value.id}`
  569. });
  570. }
  571. // 获取用户音频配额信息
  572. async function fetchQuotaInfo() {
  573. try {
  574. const res = await get<{
  575. totalMinutes: number;
  576. usedMinutes: number;
  577. remainingMinutes: number;
  578. overageEnabled: boolean;
  579. }>('/subscription/audio-balance');
  580. if (res) {
  581. quotaInfo.value = {
  582. totalMinutes: res.totalMinutes,
  583. usedMinutes: res.usedMinutes,
  584. remainingMinutes: res.remainingMinutes,
  585. overageEnabled: res.overageEnabled,
  586. };
  587. }
  588. } catch {
  589. // 未登录或获取失败时不显示配额信息
  590. quotaInfo.value = null;
  591. }
  592. }
  593. const remainingAudioMinutes = computed(() => {
  594. if (!quotaInfo.value) return 0;
  595. return Math.max(0, quotaInfo.value.totalMinutes - quotaInfo.value.usedMinutes);
  596. });
  597. const overageMinutes = computed(() => {
  598. if (!currentBook.value) return 0;
  599. const used = usedAudioMinutes.value;
  600. if (!quotaInfo.value) return 0;
  601. return Math.max(0, used - quotaInfo.value.totalMinutes);
  602. });
  603. const quotaInfo = ref<{
  604. totalMinutes: number;
  605. usedMinutes: number;
  606. remainingMinutes: number;
  607. overageEnabled: boolean;
  608. } | null>(null);
  609. function getCurrentStage(): string {
  610. if (!currentBook.value) return '未知';
  611. // 优先使用 genStage 进行阶段描述
  612. if (currentBook.value.genStage) {
  613. return api.getBookStageDescription(currentBook.value.genStage);
  614. }
  615. const progress = currentBook.value.progress || 0;
  616. const error = currentBook.value.error || '';
  617. if (error) {
  618. if (error.includes('AI调用')) return 'AI调用失败,正在重试...';
  619. if (error.includes('超时')) return '节点执行超时,正在恢复...';
  620. if (error.includes('自动恢复')) return '系统正在自动恢复...';
  621. }
  622. if (progress === 0) return '准备中...';
  623. if (progress <= 10) return '正在生成章大纲...';
  624. if (progress <= 20) return '正在生成节大纲...';
  625. if (progress <= 30) return '正在生成小节大纲...';
  626. if (progress <= 90) return '正在生成章节内容...';
  627. if (progress <= 95) return '正在生成前言...';
  628. if (progress < 100) return '正在生成后记...';
  629. if (progress === 100) return '生成完成!';
  630. return '生成中...';
  631. }
  632. function getNodeStatusClass(node: any): string {
  633. if (node.contentStatus === 'completed') return 'status-completed';
  634. if (node.contentStatus === 'generating') return 'status-generating';
  635. if (node.contentStatus === 'failed') return 'status-failed';
  636. if (node.genStage === 'failed') return 'status-failed';
  637. if (node.genStage === 'content_generating' || node.genStage === 'audio_generating' || node.genStage === 'video_generating') return 'status-generating';
  638. if (node.genStage === 'outline_completed' || node.genStage === 'content_completed' || node.genStage === 'audio_completed' || node.genStage === 'video_completed') return 'status-completed';
  639. return 'status-pending';
  640. }
  641. function getNodeStatusIcon(node: any): string {
  642. if (node.contentStatus === 'completed') return '✓';
  643. if (node.contentStatus === 'generating') return '⚡';
  644. if (node.contentStatus === 'failed') return '✗';
  645. if (node.contentError) return '⚠';
  646. if (node.genStage === 'failed') return '✗';
  647. if (node.genStage === 'content_generating' || node.genStage === 'audio_generating' || node.genStage === 'video_generating') return '⚡';
  648. if (node.genStage === 'outline_completed' || node.genStage === 'content_completed' || node.genStage === 'audio_completed' || node.genStage === 'video_completed') return '✓';
  649. return '○';
  650. }
  651. function getNodeStatusText(node: any): string {
  652. if (node.contentStatus === 'completed') return '已完成';
  653. if (node.contentStatus === 'generating') return '生成中';
  654. if (node.contentStatus === 'failed') return '失败';
  655. if (node.contentError) return '错误';
  656. if (node.genStage === 'failed') return '失败';
  657. if (node.genStage === 'outline_completed') return '大纲完成';
  658. if (node.genStage === 'content_generating') return '生成中';
  659. if (node.genStage === 'content_completed') return '内容完成';
  660. if (node.genStage === 'audio_generating') return '生成音频';
  661. if (node.genStage === 'audio_completed') return '音频完成';
  662. if (node.genStage === 'video_generating') return '生成视频';
  663. if (node.genStage === 'video_completed') return '已完成';
  664. return '待生成';
  665. }
  666. function getChapterSectionsFromDB(chapterId: string | number) {
  667. if (!currentBook.value?.chapters) return [];
  668. const chapterIdNum = typeof chapterId === 'string' ? parseInt(chapterId) : chapterId;
  669. return currentBook.value.chapters.filter((c: any) => {
  670. const cParentId = typeof c.parentId === 'string' ? parseInt(c.parentId) : c.parentId;
  671. return cParentId === chapterIdNum && c.level === 2;
  672. });
  673. }
  674. function getSectionSubsectionsFromDB(sectionId: string | number) {
  675. if (!currentBook.value?.chapters) return [];
  676. const sectionIdNum = typeof sectionId === 'string' ? parseInt(sectionId) : sectionId;
  677. return currentBook.value.chapters.filter((c: any) => {
  678. const cParentId = typeof c.parentId === 'string' ? parseInt(c.parentId) : (c.parentId || 0);
  679. return cParentId === sectionIdNum && c.level === 3;
  680. });
  681. }
  682. function chapterHasAudio(chapter: any) {
  683. const leafLevel = getBookLeafLevel({ chapters: [chapter] } as Book);
  684. if (leafLevel === 1) {
  685. return !!chapter.audioUrl;
  686. }
  687. if (chapter.level === leafLevel) {
  688. return !!chapter.audioUrl;
  689. }
  690. const sections = getChapterSectionsFromDB(chapter.id);
  691. for (const section of sections) {
  692. if (section.level === leafLevel) {
  693. if (section.audioUrl) return true;
  694. } else {
  695. const subsections = getSectionSubsectionsFromDB(section.id);
  696. if (subsections.some((s: any) => s.audioUrl)) return true;
  697. }
  698. }
  699. return false;
  700. }
  701. function getBookLeafLevel(book: Book): number {
  702. const chapters = book.chapters || [];
  703. if (chapters.length === 0) return 0;
  704. const maxLevel = Math.max(...chapters.map(c => c.level || 0));
  705. return maxLevel > 0 ? maxLevel : 1;
  706. }
  707. function getBookLeafNodes(book: Book, leafLevel: number): any[] {
  708. const chapters = book.chapters || [];
  709. return chapters.filter(c => c.level === leafLevel);
  710. }
  711. function getChapterOutline(chapterNumber: number) {
  712. if (!currentBook.value?.outline?.chapters) return null;
  713. return currentBook.value.outline.chapters.find(c => c.number === chapterNumber) || null;
  714. }
  715. function goBack() {
  716. const pages = getCurrentPages();
  717. if (pages.length > 1) {
  718. uni.navigateBack();
  719. } else {
  720. uni.switchTab({ url: '/pages/index/index' });
  721. }
  722. }
  723. async function loadBook(id: string) {
  724. // 每次重试前先清空错误状态
  725. loadError.value = null;
  726. try {
  727. currentBook.value = await api.getBook(id);
  728. if (!currentBook.value) {
  729. loadError.value = {
  730. icon: '📭',
  731. title: '书籍不存在',
  732. message: '该书籍可能已被删除,或当前账号无权限访问。可返回书籍列表查看其它书籍。',
  733. recoverable: false,
  734. };
  735. return;
  736. }
  737. // 同步 generating 状态(只要不是最终完成状态,就开始轮询)
  738. const stage = currentBook.value.genStage;
  739. const failedStage = currentBook.value.failedStage;
  740. const isFinalState = stage === 'content_completed' || stage === 'audio_completed' || stage === 'video_completed';
  741. // 有 failedStage 表示生成失败
  742. const isFailed = !!failedStage;
  743. if ((stage && !isFinalState && !isFailed)) {
  744. generating.value = true;
  745. }
  746. // 开始生成时(包括 content_completed 后台生成音频)都要轮询,失败状态不轮询
  747. if (stage === 'outline_ready') {
  748. // 大纲就绪、等用户操作/或后端 book-recovery-scanner 自动推进:每 30 秒降频轮询
  749. startOutlineReadyPolling(id);
  750. } else if (stage && stage !== 'video_completed' && !isFailed) {
  751. startPollingProgress(id);
  752. }
  753. } catch (e: any) {
  754. console.error('加载书籍失败:', e);
  755. // 致命错误落到页面(不要只 toast 一闪而过),让用户明确知道发生了什么
  756. const raw = (e?.message || '').trim();
  757. const msg = raw || '网络请求失败,请稍后重试';
  758. const looksLikeNotExist = /书籍不存在|不存在|not\s*found|404/i.test(msg);
  759. loadError.value = {
  760. icon: looksLikeNotExist ? '📭' : '⚠️',
  761. title: looksLikeNotExist ? '书籍不存在' : '加载失败',
  762. message: looksLikeNotExist
  763. ? '该书籍可能已被删除,或当前账号无权限访问。可返回书籍列表查看其它书籍。'
  764. : msg,
  765. recoverable: !looksLikeNotExist, // 网络错误可重试,书籍不存在重试无意义
  766. };
  767. }
  768. }
  769. // 返回书籍列表(致命错误时给用户一个明确出口)
  770. function goBackToList() {
  771. const pages = getCurrentPages();
  772. if (pages.length > 1) {
  773. uni.navigateBack();
  774. } else {
  775. uni.reLaunch({ url: '/pages/book-generator/index' });
  776. }
  777. }
  778. function startPollingProgress(bookId: string) {
  779. stopPollingProgress();
  780. // 重置卡死检测
  781. stalledTip.value = false;
  782. lastProgressSnapshot = '';
  783. stalledTicks = 0;
  784. pollTimer = setInterval(async () => {
  785. try {
  786. const book = await api.getBook(bookId);
  787. if (!book) return;
  788. if (currentBook.value) {
  789. currentBook.value.progress = book.progress;
  790. currentBook.value.genStage = book.genStage as any;
  791. currentBook.value.error = book.error;
  792. // AI 异步回填标题/规模时自动更新
  793. if (book.title && book.title !== currentBook.value.title) {
  794. currentBook.value.title = book.title;
  795. }
  796. if (book.bookScale && book.bookScale !== currentBook.value.bookScale) {
  797. currentBook.value.bookScale = book.bookScale;
  798. }
  799. if (book.estimatedWords && book.estimatedWords !== currentBook.value.estimatedWords) {
  800. currentBook.value.estimatedWords = book.estimatedWords;
  801. }
  802. if (book.totalChapters && book.totalChapters !== currentBook.value.totalChapters) {
  803. currentBook.value.totalChapters = book.totalChapters;
  804. }
  805. }
  806. // 以下状态认为生成已完成,停止轮询
  807. // outline_ready 不在 doneStages 里——它有专门的降频轮询 startOutlineReadyPolling 检测后端自愈
  808. const doneStages = ['content_completed', 'audio_completed', 'video_completed', 'failed'];
  809. if (doneStages.includes(book.genStage)) {
  810. stopPollingProgress();
  811. generating.value = false;
  812. stalledTip.value = false;
  813. currentBook.value = book;
  814. if (book.genStage === 'content_completed' || book.genStage === 'audio_completed' || book.genStage === 'video_completed') {
  815. uni.showToast({ title: '生成完成!', icon: 'success' });
  816. // 写入通知
  817. const notifStore = useNotificationStore();
  818. const stageLabel = book.genStage === 'content_completed' ? '内容' : book.genStage === 'audio_completed' ? '音频' : '视频';
  819. notifStore.add({ type: 'content_complete', title: `${stageLabel}生成完成`, message: `《${currentBook.value?.title || '书籍'}》${stageLabel}已生成完毕`, bookId });
  820. }
  821. return;
  822. }
  823. // 卡死检测:进度+阶段连续 STALLED_THRESHOLD 次无变化 → 后台疑似已停
  824. // 用每章 genStage 拼快照,能感知"内容在逐章推进"的细微变化,避免误判
  825. const chapterSig = (book.chapters || []).map((c: any) => c.genStage).join(',');
  826. const snapshot = `${book.progress}|${book.genStage}|${chapterSig}`;
  827. if (snapshot === lastProgressSnapshot) {
  828. stalledTicks++;
  829. if (stalledTicks >= STALLED_THRESHOLD) {
  830. // 判定卡死:停止轮询,退出"生成中"假象,提示用户可重新生成/续生成
  831. stopPollingProgress();
  832. generating.value = false;
  833. stalledTip.value = true;
  834. console.warn('[Detail] 生成进度长时间无变化,判定后台已停止,停止轮询');
  835. }
  836. } else {
  837. lastProgressSnapshot = snapshot;
  838. stalledTicks = 0;
  839. }
  840. } catch (e) {
  841. console.error('轮询进度失败:', e);
  842. }
  843. }, 3000);
  844. }
  845. /**
  846. * outline_ready 状态的降频轮询:每 30 秒拉一次,检测后端 book-recovery-scanner 是否自动推进
  847. * 若 genStage 变为 content_generating / generating_content / audio_generating 等活跃态,
  848. * 自动切回正常 polling,让用户实时看到进度
  849. */
  850. function startOutlineReadyPolling(bookId: string) {
  851. stopOutlineReadyPolling();
  852. console.log('[Detail] 启动 outline_ready 降频轮询(30s 间隔)');
  853. outlineReadyPollTimer = setInterval(async () => {
  854. try {
  855. const book = await api.getBook(bookId);
  856. if (!book) return;
  857. const newStage = book.genStage as string;
  858. // 一旦进入"活跃生成"状态,切回正常 polling
  859. const activeStages = ['content_generating', 'audio_generating', 'video_generating', 'generating_content', 'outline_generating'];
  860. if (activeStages.includes(newStage)) {
  861. console.log(`[Detail] 检测到后端已自愈推进 (genStage=${newStage}),切回正常轮询`);
  862. if (currentBook.value) {
  863. currentBook.value.genStage = newStage as any;
  864. currentBook.value.progress = book.progress;
  865. }
  866. stopOutlineReadyPolling();
  867. startPollingProgress(bookId);
  868. generating.value = true;
  869. } else if (newStage !== 'outline_ready') {
  870. // 变成其它稳定态(completed/failed 等),停止降频轮询
  871. stopOutlineReadyPolling();
  872. } else {
  873. // 还在 outline_ready:更新一下 progress(虽然后端没改)
  874. if (currentBook.value && book.progress !== currentBook.value.progress) {
  875. currentBook.value.progress = book.progress;
  876. }
  877. }
  878. } catch (e) {
  879. console.error('[Detail] outline_ready 降频轮询失败:', e);
  880. }
  881. }, 30_000);
  882. }
  883. function stopOutlineReadyPolling() {
  884. if (outlineReadyPollTimer) {
  885. clearInterval(outlineReadyPollTimer);
  886. outlineReadyPollTimer = null;
  887. console.log('[Detail] outline_ready 降频轮询已停止');
  888. }
  889. }
  890. function stopPollingProgress() {
  891. if (pollTimer) {
  892. clearInterval(pollTimer);
  893. pollTimer = null;
  894. }
  895. }
  896. function startPollingAudioStatus(bookId: string) {
  897. stopPollingAudioStatus();
  898. audioPollTimer = setInterval(async () => {
  899. try {
  900. const status = await api.getAudioStatus(bookId);
  901. if (status.allCompleted) {
  902. stopPollingAudioStatus();
  903. generatingAudio.value = false;
  904. await loadBook(bookId);
  905. uni.showToast({ title: '音频生成完成', icon: 'success' });
  906. // 写入通知
  907. const notifStore = useNotificationStore();
  908. notifStore.add({ type: 'audio_complete', title: '音频生成完成', message: `《${currentBook.value?.title || '书籍'}》音频已生成完毕`, bookId });
  909. }
  910. } catch (e) {
  911. console.error('轮询音频状态失败:', e);
  912. }
  913. }, 5000);
  914. }
  915. function stopPollingAudioStatus() {
  916. if (audioPollTimer) {
  917. clearInterval(audioPollTimer);
  918. audioPollTimer = null;
  919. }
  920. }
  921. function startPollingVideoStatus(bookId: string) {
  922. stopPollingVideoStatus();
  923. videoPollTimer = setInterval(async () => {
  924. try {
  925. const status = await api.getVideoStatus(bookId);
  926. if (status.allCompleted) {
  927. stopPollingVideoStatus();
  928. generatingVideo.value = false;
  929. await loadBook(bookId);
  930. uni.showToast({ title: '视频生成完成', icon: 'success' });
  931. // 写入通知
  932. const notifStore = useNotificationStore();
  933. notifStore.add({ type: 'video_complete', title: '视频生成完成', message: `《${currentBook.value?.title || '书籍'}》视频已生成完毕`, bookId });
  934. }
  935. } catch (e) {
  936. console.error('轮询视频状态失败:', e);
  937. }
  938. }, 5000);
  939. }
  940. function stopPollingVideoStatus() {
  941. if (videoPollTimer) {
  942. clearInterval(videoPollTimer);
  943. videoPollTimer = null;
  944. }
  945. }
  946. // interactive 模式下 outline 已就绪,触发正文内容生成(调 /interactive/generate)
  947. async function handleStartContentGenerate() {
  948. if (!currentBook.value || outlineStarting.value) return;
  949. const bookId = currentBook.value.id;
  950. outlineStarting.value = true;
  951. try {
  952. uni.showLoading({ title: '启动内容生成...', mask: true });
  953. const res: any = await post(`/book-generator/langgraph/books/${bookId}/interactive/generate`, {});
  954. uni.hideLoading();
  955. // 成功后端返回 genStage=generating_content,前端轮询会自然跟进进度
  956. if (currentBook.value) {
  957. currentBook.value.genStage = (res?.genStage || 'content_generating') as any;
  958. currentBook.value.progress = 10;
  959. }
  960. uni.showToast({ title: '已开始生成内容', icon: 'success' });
  961. // 重新拉取详情(后端可能立刻推进 genStage)
  962. await loadBook(bookId);
  963. // 启动轮询
  964. startPollingProgress(bookId);
  965. } catch (e: any) {
  966. uni.hideLoading();
  967. uni.showToast({ title: e?.message || '启动失败', icon: 'none' });
  968. console.error('[handleStartContentGenerate] 失败:', e);
  969. } finally {
  970. outlineStarting.value = false;
  971. }
  972. }
  973. // 手动补全缺失章节(不依赖 LangGraph,绕过 rich_outline 超时问题)
  974. async function handleFillMissing() {
  975. if (!currentBook.value || fillingMissing.value) return;
  976. const bookId = currentBook.value.id;
  977. fillingMissing.value = true;
  978. try {
  979. uni.showLoading({ title: '启动补全...', mask: true });
  980. const res: any = await post(`/book-generator/langgraph/books/${bookId}/fill-missing`, {});
  981. uni.hideLoading();
  982. if (res?.data?.missingCount === 0) {
  983. uni.showToast({ title: '书籍已完整', icon: 'success' });
  984. await loadBook(bookId);
  985. return;
  986. }
  987. const cnt = res?.data?.missingCount || '?';
  988. uni.showToast({ title: `已启动补全 ${cnt} 节`, icon: 'success' });
  989. // 立即刷新页面状态,启动 polling 跟进
  990. if (currentBook.value) {
  991. currentBook.value.genStage = 'generating_content' as any;
  992. }
  993. await loadBook(bookId);
  994. startPollingProgress(bookId);
  995. } catch (e: any) {
  996. uni.hideLoading();
  997. uni.showToast({ title: e?.message || '启动失败', icon: 'none' });
  998. console.error('[handleFillMissing] 失败:', e);
  999. } finally {
  1000. fillingMissing.value = false;
  1001. }
  1002. }
  1003. async function handleRetryGenerate() {
  1004. if (!currentBook.value) return;
  1005. uni.showModal({
  1006. title: '确认重新生成',
  1007. content: '将重新开始生成,是否继续?',
  1008. success: async (res) => {
  1009. if (res.confirm) {
  1010. await handleLangGraphGenerate();
  1011. }
  1012. },
  1013. });
  1014. }
  1015. // 续生成:只补齐未完成(失败/空壳)的章节,保留已生成的内容,不重建大纲
  1016. async function handleResume() {
  1017. if (!currentBook.value) return;
  1018. const bookId = currentBook.value.id;
  1019. uni.showModal({
  1020. title: '继续生成',
  1021. content: '将补齐未完成的章节内容(保留已生成的部分),是否继续?',
  1022. success: async (res) => {
  1023. if (!res.confirm) return;
  1024. try {
  1025. uni.showLoading({ title: '提交中...', mask: true });
  1026. await post(`/book-generator/langgraph/books/${bookId}/resume`, {});
  1027. uni.hideLoading();
  1028. uni.showToast({ title: '已开始续生成', icon: 'success' });
  1029. // 切回生成中并重新轮询
  1030. stalledTip.value = false;
  1031. generating.value = true;
  1032. startPollingProgress(bookId);
  1033. } catch (e: any) {
  1034. uni.hideLoading();
  1035. uni.showToast({ title: e?.message || '续生成启动失败', icon: 'none' });
  1036. }
  1037. },
  1038. });
  1039. }
  1040. async function handleGenerateAudio() {
  1041. if (!currentBook.value || generatingAudio.value) return;
  1042. uni.showModal({
  1043. title: '确认生成音频',
  1044. content: `将为 ${currentBook.value.totalChapters || '所有'} 个章节生成音频,是否继续?`,
  1045. success: async (res) => {
  1046. if (!res.confirm) return;
  1047. generatingAudio.value = true;
  1048. try {
  1049. const result = await api.generateAllChaptersAudio(currentBook.value.id, 'cherry');
  1050. uni.showToast({ title: `已启动 ${result.totalChapters} 个音频生成`, icon: 'none' });
  1051. // 开始轮询音频生成状态,不再立即清除 generatingAudio
  1052. startPollingAudioStatus(currentBook.value.id);
  1053. // 失败时才清除状态,成功后由轮询完成时清除
  1054. } catch (e: any) {
  1055. uni.showToast({ title: e.message || '启动失败', icon: 'none' });
  1056. generatingAudio.value = false; // 失败时才清除
  1057. }
  1058. },
  1059. });
  1060. }
  1061. function enterBatchMode() {
  1062. isBatchMode.value = true;
  1063. selectedChapterIds.value = [];
  1064. isAllSelected.value = false;
  1065. }
  1066. function exitBatchMode() {
  1067. isBatchMode.value = false;
  1068. selectedChapterIds.value = [];
  1069. isAllSelected.value = false;
  1070. }
  1071. function toggleSelectAll() {
  1072. if (isAllSelected.value) {
  1073. selectedChapterIds.value = [];
  1074. isAllSelected.value = false;
  1075. } else {
  1076. const allSubsections = currentBook.value?.chapters?.filter(c => c.level === 3) || [];
  1077. selectedChapterIds.value = allSubsections.map((ch: Chapter) => ch.id);
  1078. isAllSelected.value = true;
  1079. }
  1080. }
  1081. async function handleBatchRegenerate() {
  1082. if (selectedChapterIds.value.length === 0 || !currentBook.value) return;
  1083. const bookId = currentBook.value.id;
  1084. uni.showModal({
  1085. title: '确认重新生成',
  1086. content: `将重新生成选中的 ${selectedChapterIds.value.length} 个章节,是否继续?`,
  1087. success: async (res) => {
  1088. if (res.confirm) {
  1089. try {
  1090. uni.showLoading({ title: '提交中...', mask: true });
  1091. await post(`/book-generator/langgraph/books/${bookId}/chapters/batch-regenerate`, {
  1092. chapterIds: selectedChapterIds.value,
  1093. });
  1094. uni.hideLoading();
  1095. uni.showToast({ title: '已提交批量生成任务', icon: 'success' });
  1096. exitBatchMode();
  1097. currentBook.value = await api.getBook(bookId);
  1098. } catch (error) {
  1099. uni.hideLoading();
  1100. console.error('[BatchRegenerate] 失败:', error);
  1101. uni.showToast({ title: '批量重新生成失败', icon: 'none' });
  1102. }
  1103. }
  1104. },
  1105. });
  1106. }
  1107. async function handleBatchDelete() {
  1108. if (selectedChapterIds.value.length === 0 || !currentBook.value) return;
  1109. const bookId = currentBook.value.id;
  1110. uni.showModal({
  1111. title: '确认删除',
  1112. content: `将删除选中的 ${selectedChapterIds.value.length} 个章节,是否继续?`,
  1113. success: async (res) => {
  1114. if (res.confirm) {
  1115. try {
  1116. uni.showLoading({ title: '删除中...', mask: true });
  1117. await post(`/book-generator/langgraph/books/${bookId}/chapters/batch-delete`, {
  1118. chapterIds: selectedChapterIds.value,
  1119. });
  1120. uni.hideLoading();
  1121. uni.showToast({ title: '删除成功', icon: 'success' });
  1122. exitBatchMode();
  1123. currentBook.value = await api.getBook(bookId);
  1124. } catch (error) {
  1125. uni.hideLoading();
  1126. console.error('[BatchDelete] 失败:', error);
  1127. uni.showToast({ title: '批量删除失败', icon: 'none' });
  1128. }
  1129. }
  1130. },
  1131. });
  1132. }
  1133. async function handleLangGraphGenerate() {
  1134. if (!currentBook.value) return;
  1135. uni.showModal({
  1136. title: '确认重新生成',
  1137. content: '将清除所有章节数据(含已生成的音频),根据当前书籍规模重新生成全部内容。确定继续吗?',
  1138. success: async (res) => {
  1139. if (!res.confirm) return;
  1140. generating.value = true;
  1141. currentBook.value.genStage = 'outlining';
  1142. currentBook.value.progress = 0;
  1143. try {
  1144. await api.generateWithLangGraph(currentBook.value.id);
  1145. uni.showToast({ title: 'LangGraph 生成已启动', icon: 'none', duration: 2000 });
  1146. startPollingProgress(currentBook.value.id);
  1147. } catch (e: any) {
  1148. generating.value = false;
  1149. currentBook.value.genStage = 'failed';
  1150. uni.showToast({ title: e.message || '启动失败', icon: 'none' });
  1151. }
  1152. },
  1153. });
  1154. }
  1155. function goToChapterDetail(chapter: Chapter) {
  1156. if (!currentBook.value) return;
  1157. (globalThis as any).__navBookData = currentBook.value;
  1158. uni.navigateTo({
  1159. url: `/pages/book-generator/chapter-detail?bookId=${encodeURIComponent(currentBook.value.id)}&chapter=${chapter.id}`
  1160. });
  1161. }
  1162. function viewForeword() {
  1163. uni.showModal({
  1164. title: '前言',
  1165. content: currentBook.value?.metadata?.foreword || '',
  1166. showCancel: true,
  1167. cancelText: '关闭',
  1168. confirmText: '复制',
  1169. success: (res) => {
  1170. if (res.confirm) {
  1171. uni.setClipboardData({
  1172. data: currentBook.value?.metadata?.foreword || '',
  1173. success: () => uni.showToast({ title: '已复制', icon: 'success' }),
  1174. });
  1175. }
  1176. }
  1177. });
  1178. }
  1179. function viewAfterword() {
  1180. uni.showModal({
  1181. title: '后记',
  1182. content: currentBook.value?.metadata?.afterword || '',
  1183. showCancel: true,
  1184. cancelText: '关闭',
  1185. confirmText: '复制',
  1186. success: (res) => {
  1187. if (res.confirm) {
  1188. uni.setClipboardData({
  1189. data: currentBook.value?.metadata?.afterword || '',
  1190. success: () => uni.showToast({ title: '已复制', icon: 'success' }),
  1191. });
  1192. }
  1193. }
  1194. });
  1195. }
  1196. onLoad((query: any) => {
  1197. if (query?.id) {
  1198. currentBookId.value = String(query.id);
  1199. loadBook(currentBookId.value);
  1200. }
  1201. });
  1202. onMounted(() => {
  1203. // H5 环境:从 URL hash 获取参数
  1204. // #ifdef H5
  1205. if (typeof window !== 'undefined') {
  1206. const hash = window.location.hash;
  1207. const match = hash.match(/detail\?id=([^&]+)/);
  1208. if (match && !currentBook.value) {
  1209. currentBookId.value = decodeURIComponent(match[1]);
  1210. loadBook(currentBookId.value);
  1211. }
  1212. }
  1213. // #endif
  1214. // 获取配额信息
  1215. fetchQuotaInfo();
  1216. });
  1217. onShow(() => {
  1218. // onShow 时刷新书籍数据(H5 环境下 onMounted 时 hash 可能未更新)
  1219. if (currentBook.value?.id) {
  1220. loadBook(currentBook.value.id);
  1221. }
  1222. // 刷新配额信息
  1223. fetchQuotaInfo();
  1224. });
  1225. onUnmounted(() => {
  1226. stopPollingProgress();
  1227. stopPollingAudioStatus();
  1228. stopPollingVideoStatus();
  1229. });
  1230. </script>
  1231. <style scoped>
  1232. .page {
  1233. min-height: 100vh;
  1234. background: var(--color-bg-page, #f3f4f6);
  1235. }
  1236. /* --- 导航栏 --- */
  1237. .nav-bar {
  1238. position: fixed; top: 0; left: 0; right: 0; z-index: 100;
  1239. background: rgba(255, 255, 255, 0.88);
  1240. backdrop-filter: blur(24px);
  1241. -webkit-backdrop-filter: blur(24px);
  1242. border-bottom: 0.5rpx solid rgba(0, 0, 0, 0.06);
  1243. }
  1244. .nav-content {
  1245. display: flex; align-items: center; justify-content: space-between;
  1246. height: 88rpx; padding: 0 32rpx; padding-top: env(safe-area-inset-top);
  1247. }
  1248. .nav-left, .nav-right { width: 80rpx; }
  1249. .nav-edit-btn { font-size: 28rpx; color: var(--color-primary-600, #4f46e5); font-weight: 600; }
  1250. .back-icon { font-size: 36rpx; color: var(--color-text-primary, #111827); }
  1251. .page-title {
  1252. font-size: var(--text-lg, 32rpx); font-weight: 700; color: var(--color-text-primary, #111827);
  1253. max-width: 400rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  1254. }
  1255. .main-content {
  1256. padding-top: calc(88rpx + env(safe-area-inset-top));
  1257. padding: calc(88rpx + env(safe-area-inset-top) + 16rpx) 24rpx 32rpx;
  1258. }
  1259. /* --- 卡片 --- */
  1260. .card {
  1261. background: var(--color-bg-card, #ffffff);
  1262. border-radius: var(--radius-lg, 24rpx);
  1263. padding: 28rpx;
  1264. margin-bottom: 16rpx;
  1265. box-shadow: var(--shadow-xs, 0 1rpx 2rpx rgba(0,0,0,0.04));
  1266. }
  1267. .card-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20rpx; }
  1268. .header-actions { display: flex; align-items: center; gap: 12rpx; }
  1269. .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; }
  1270. .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; }
  1271. .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; }
  1272. .batch-btn.regenerate { background: linear-gradient(135deg, #10b981, #059669); color: white; border: none; }
  1273. .batch-btn.regenerate.disabled { opacity: 0.5; }
  1274. .batch-btn.delete { background: linear-gradient(135deg, #ef4444, #dc2626); color: white; border: none; }
  1275. .batch-btn.delete.disabled { opacity: 0.5; }
  1276. .batch-btn.cancel { background: var(--color-bg-surface, #f9fafb); color: var(--color-text-secondary, #4b5563); border: none; }
  1277. .card-title { font-size: var(--text-lg, 32rpx); font-weight: 700; color: var(--color-text-primary, #111827); }
  1278. .book-info { margin-bottom: 16rpx; }
  1279. .book-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8rpx; }
  1280. .book-title-large { font-size: var(--text-xl, 36rpx); font-weight: 700; color: var(--color-text-primary, #111827); flex: 1; letter-spacing: -0.3rpx; }
  1281. .book-subtitle { font-size: var(--text-base, 28rpx); color: var(--color-text-secondary, #4b5563); margin-bottom: 16rpx; display: block; }
  1282. .book-desc { font-size: 24rpx; color: var(--color-text-secondary, #4b5563); line-height: 1.6; display: block; }
  1283. .book-meta-row { display: flex; gap: 24rpx; font-size: 24rpx; color: var(--color-text-tertiary, #9ca3af); margin-top: 16rpx; }
  1284. /* 状态标签由 GenerationStatusBadge 组件统一管理 */
  1285. .progress-card { margin-bottom: 16rpx; }
  1286. .progress-display { display: flex; align-items: baseline; gap: 16rpx; margin: 20rpx 0 12rpx; }
  1287. .progress-text { font-size: 52rpx; font-weight: 800; color: var(--color-primary-600, #4f46e5); letter-spacing: -1rpx; }
  1288. .progress-text.progress-failed { color: #ef4444; }
  1289. .progress-fill-failed { background: linear-gradient(90deg, #ef4444, #dc2626) !important; }
  1290. .progress-detail { font-size: var(--text-base, 28rpx); color: var(--color-text-secondary, #4b5563); }
  1291. .progress-bar-large { height: 10rpx; background: var(--color-bg-divider, #e5e7eb); border-radius: 5rpx; overflow: hidden; }
  1292. .generation-status { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 16rpx; color: white; }
  1293. .status-header { display: flex; align-items: center; margin-bottom: 16rpx; }
  1294. .status-icon { font-size: 36rpx; margin-right: 12rpx; animation: pulse 2s ease-in-out infinite; }
  1295. @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
  1296. .status-title { font-size: 32rpx; font-weight: 600; }
  1297. .status-stage { display: flex; align-items: center; padding: 16rpx; background: rgba(255, 255, 255, 0.2); border-radius: 12rpx; margin-bottom: 16rpx; }
  1298. .stage-label { font-size: 26rpx; opacity: 0.9; margin-right: 8rpx; }
  1299. .stage-value { font-size: 28rpx; font-weight: 600; }
  1300. .current-chapter-info { display: flex; align-items: center; padding: 16rpx; background: rgba(255, 255, 255, 0.15); border-radius: 12rpx; margin-bottom: 16rpx; }
  1301. .chapter-label { font-size: 24rpx; opacity: 0.8; margin-right: 8rpx; flex-shrink: 0; }
  1302. .chapter-name { font-size: 26rpx; font-weight: 600; flex: 1; }
  1303. .status-error { display: flex; align-items: flex-start; padding: 16rpx; background: rgba(239, 68, 68, 0.3); border-radius: 12rpx; margin-bottom: 16rpx; }
  1304. .error-icon { font-size: 28rpx; margin-right: 12rpx; flex-shrink: 0; }
  1305. .error-text { font-size: 24rpx; line-height: 1.6; flex: 1; }
  1306. .status-recovery { display: flex; align-items: center; padding: 16rpx; background: rgba(251, 191, 36, 0.3); border-radius: 12rpx; margin-bottom: 16rpx; }
  1307. .recovery-icon { font-size: 32rpx; margin-right: 12rpx; animation: spin 1s linear infinite; }
  1308. @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
  1309. .recovery-text { font-size: 26rpx; font-weight: 500; }
  1310. .status-tip { padding: 12rpx; background: rgba(255, 255, 255, 0.1); border-radius: 8rpx; }
  1311. .tip-text { font-size: 24rpx; opacity: 0.9; }
  1312. .failed-tip { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); border-radius: 16rpx; border: 2rpx solid #fca5a5; }
  1313. .failed-icon { font-size: 40rpx; display: block; text-align: center; margin-bottom: 12rpx; }
  1314. .failed-text { font-size: 32rpx; font-weight: 600; color: #dc2626; display: block; text-align: center; margin-bottom: 12rpx; }
  1315. .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; }
  1316. .failed-hint { font-size: 24rpx; color: #6b7280; display: block; text-align: center; margin-bottom: 16rpx; }
  1317. .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; }
  1318. /* 质量报告卡片(成功路径,不是错误) */
  1319. .report-card { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #ecfeff 0%, #cffafe 100%); border-radius: 16rpx; border: 2rpx solid #67e8f9; }
  1320. .report-header { display: flex; align-items: center; gap: 12rpx; }
  1321. .report-icon { font-size: 32rpx; flex-shrink: 0; }
  1322. .report-title { flex: 1; font-size: 28rpx; font-weight: 600; color: #155e75; }
  1323. .report-toggle { font-size: 24rpx; color: #0e7490; font-weight: 500; }
  1324. .report-body { margin-top: 16rpx; padding-top: 16rpx; border-top: 1rpx solid rgba(14, 116, 144, 0.2); }
  1325. .report-item { margin-bottom: 12rpx; }
  1326. .report-label { font-size: 24rpx; font-weight: 600; color: #155e75; display: block; margin-bottom: 6rpx; }
  1327. .report-text { font-size: 24rpx; color: #0e7490; line-height: 1.6; display: block; }
  1328. /* 致命错误卡:书籍不存在 / 加载失败 / 权限不足等,全屏卡片式提示 */
  1329. .fatal-error-card { display: flex; flex-direction: column; align-items: center; padding: 64rpx 32rpx; margin-top: 40rpx; }
  1330. .fatal-error-icon { font-size: 96rpx; display: block; margin-bottom: 24rpx; }
  1331. .fatal-error-title { font-size: 36rpx; font-weight: 700; color: #dc2626; display: block; margin-bottom: 16rpx; text-align: center; }
  1332. .fatal-error-message { font-size: 28rpx; color: #4b5563; line-height: 1.6; text-align: center; margin-bottom: 40rpx; padding: 0 24rpx; }
  1333. .fatal-error-actions { display: flex; flex-direction: column; gap: 20rpx; width: 100%; max-width: 600rpx; }
  1334. .fatal-error-btn { width: 100%; height: 80rpx; background: #ffffff; color: #374151; border: 2rpx solid #d1d5db; border-radius: 12rpx; font-size: 28rpx; font-weight: 600; }
  1335. .fatal-error-btn.primary { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: white; border: none; }
  1336. .outline-theme { font-size: 24rpx; color: #6b7280; }
  1337. /* 章节搜索工具栏 */
  1338. .chapter-toolbar {
  1339. display: flex; align-items: center; gap: 12rpx;
  1340. margin-top: 20rpx; margin-bottom: 12rpx;
  1341. }
  1342. .search-box {
  1343. flex: 1; display: flex; align-items: center;
  1344. background: var(--color-bg-surface, #f9fafb);
  1345. border-radius: var(--radius-md, 16rpx);
  1346. padding: 14rpx 20rpx;
  1347. border: 1.5rpx solid transparent;
  1348. transition: all 0.2s;
  1349. }
  1350. .search-box:focus-within {
  1351. border-color: var(--color-primary-600, #4f46e5);
  1352. background: var(--color-bg-card, #ffffff);
  1353. box-shadow: 0 0 0 4rpx rgba(79, 70, 229, 0.08);
  1354. }
  1355. .search-icon-small { font-size: 26rpx; margin-right: 10rpx; flex-shrink: 0; opacity: 0.4; }
  1356. .search-input { flex: 1; font-size: 26rpx; color: var(--color-text-primary, #111827); background: transparent; }
  1357. .search-placeholder-text { color: var(--color-text-tertiary, #9ca3af); font-size: 26rpx; }
  1358. .search-clear { font-size: 26rpx; color: var(--color-text-tertiary, #9ca3af); padding: 4rpx 8rpx; flex-shrink: 0; }
  1359. .toggle-btn {
  1360. flex-shrink: 0; padding: 14rpx 20rpx;
  1361. background: var(--color-bg-surface, #f9fafb);
  1362. border-radius: var(--radius-md, 16rpx);
  1363. font-size: 24rpx; color: var(--color-primary-600, #4f46e5);
  1364. white-space: nowrap; transition: all 0.15s;
  1365. }
  1366. .toggle-btn:active { background: var(--color-bg-hover, #f3f4f6); }
  1367. .search-empty { text-align: center; padding: 40rpx; color: var(--color-text-tertiary, #9ca3af); font-size: 26rpx; }
  1368. .content-item {
  1369. display: flex; align-items: center; justify-content: space-between;
  1370. padding: 20rpx; background: var(--color-bg-surface, #f9fafb);
  1371. border-radius: var(--radius-md, 16rpx); margin-top: 16rpx;
  1372. transition: transform 0.15s, background 0.15s;
  1373. }
  1374. .content-item:active { background: var(--color-bg-hover, #f3f4f6); }
  1375. .content-item.foreword, .content-item.afterword {
  1376. background: linear-gradient(135deg, #fef3c7, #fde68a);
  1377. cursor: pointer;
  1378. }
  1379. .content-item-left { display: flex; align-items: center; gap: 16rpx; }
  1380. .content-label { font-size: var(--text-base, 28rpx); font-weight: 600; color: var(--color-text-primary, #111827); }
  1381. .content-word-count { font-size: 22rpx; color: var(--color-text-tertiary, #9ca3af); }
  1382. .content-item-right { display: flex; align-items: center; }
  1383. .expand-icon { font-size: 26rpx; color: var(--color-text-tertiary, #9ca3af); }
  1384. .content-card { margin-top: 0; }
  1385. .content-tree-list { margin-top: 16rpx; }
  1386. .content-tree-item { margin-bottom: 10rpx; }
  1387. .content-tree-row {
  1388. display: flex; align-items: center; gap: 14rpx;
  1389. padding: 16rpx 14rpx;
  1390. background: var(--color-bg-surface, #f9fafb);
  1391. border-radius: var(--radius-sm, 10rpx);
  1392. transition: background 0.15s;
  1393. }
  1394. .content-tree-row:active { background: var(--color-bg-hover, #f3f4f6); }
  1395. .chapter-num-badge {
  1396. width: 44rpx; height: 44rpx;
  1397. background: var(--color-primary-600, #4f46e5);
  1398. color: #ffffff; border-radius: 50%;
  1399. display: flex; align-items: center; justify-content: center;
  1400. font-size: 22rpx; font-weight: 700; flex-shrink: 0;
  1401. }
  1402. .chapter-info { flex: 1; }
  1403. .chapter-title { font-size: 26rpx; font-weight: 600; color: var(--color-text-primary, #111827); display: block; margin-bottom: 6rpx; }
  1404. .chapter-content-hint { margin-bottom: 6rpx; }
  1405. .content-hint-tag { font-size: 20rpx; padding: 3rpx 10rpx; background: var(--color-info-bg, #eff6ff); color: #0369a1; border-radius: 4rpx; font-weight: 500; }
  1406. .chapter-outline-summary { font-size: 22rpx; color: var(--color-text-secondary, #4b5563); display: block; margin-bottom: 6rpx; }
  1407. .chapter-key-points { display: flex; flex-wrap: wrap; gap: 6rpx; margin-bottom: 6rpx; }
  1408. .point-tag { font-size: 18rpx; padding: 3rpx 10rpx; background: var(--color-bg-hover, #f3f4f6); color: var(--color-text-secondary, #4b5563); border-radius: 4rpx; }
  1409. .chapter-media-badges { display: flex; gap: 6rpx; }
  1410. .media-badge { font-size: 18rpx; padding: 3rpx 10rpx; background: rgba(16, 185, 129, 0.1); color: #059669; border-radius: 4rpx; font-weight: 500; }
  1411. .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; }
  1412. .chapter-status.status-completed { background: #d1fae5; color: #059669; }
  1413. .chapter-status.status-pending { background: var(--color-bg-divider, #e5e7eb); color: var(--color-text-tertiary, #9ca3af); }
  1414. .chapter-status.status-generating { background: var(--color-warning-bg, #fffbeb); color: #d97706; animation: pulse-bg 2s ease-in-out infinite; }
  1415. .chapter-status.status-failed { background: var(--color-error-bg, #fef2f2); color: #dc2626; }
  1416. @keyframes pulse-bg { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
  1417. .status-icon { font-size: 24rpx; font-weight: bold; line-height: 1; }
  1418. .status-text { font-size: 18rpx; margin-top: 2rpx; white-space: nowrap; }
  1419. .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; }
  1420. .section-info { flex: 1; }
  1421. .section-title { font-size: 24rpx; font-weight: 500; color: #374151; display: block; margin-bottom: 2rpx; }
  1422. .section-summary { font-size: 20rpx; color: var(--color-text-tertiary, #9ca3af); display: block; }
  1423. .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; }
  1424. .section-status.status-completed { background: #d1fae5; color: #059669; }
  1425. .section-status.status-pending { background: var(--color-bg-hover, #f3f4f6); color: var(--color-text-tertiary, #9ca3af); }
  1426. .section-status.status-generating { background: var(--color-warning-bg, #fffbeb); color: #d97706; }
  1427. .section-status.status-failed { background: var(--color-error-bg, #fef2f2); color: #dc2626; }
  1428. .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; }
  1429. .subsection-info { flex: 1; }
  1430. .subsection-title { font-size: 22rpx; color: var(--color-text-secondary, #4b5563); display: block; }
  1431. .subsection-media-badges { display: flex; gap: 6rpx; margin-top: 4rpx; }
  1432. .media-badge-small { font-size: 16rpx; padding: 2rpx 8rpx; background: rgba(16, 185, 129, 0.1); color: #059669; border-radius: 4rpx; }
  1433. .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; }
  1434. .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; }
  1435. .subsection-status.status-completed { background: #d1fae5; color: #059669; }
  1436. .subsection-status.status-pending { background: var(--color-bg-hover, #f3f4f6); color: var(--color-text-tertiary, #9ca3af); }
  1437. .subsection-status.status-generating { background: var(--color-warning-bg, #fffbeb); color: #d97706; }
  1438. .subsection-status.status-failed { background: var(--color-error-bg, #fef2f2); color: #dc2626; }
  1439. .action-card { margin-bottom: 24rpx; }
  1440. /* 手动触发生成提示(用户创建时关闭了自动生成) */
  1441. .manual-hint {
  1442. display: flex; align-items: center; gap: 12rpx;
  1443. padding: 16rpx 20rpx;
  1444. background: #fffbeb; border: 1rpx solid #fde68a;
  1445. border-radius: 12rpx;
  1446. margin-bottom: 16rpx;
  1447. }
  1448. .manual-hint-icon { font-size: 32rpx; flex-shrink: 0; }
  1449. .manual-hint-text { flex: 1; font-size: 24rpx; color: #92400e; line-height: 1.5; }
  1450. .btn-col { margin-bottom: 20rpx; }
  1451. .btn-row { display: flex; gap: 16rpx; margin-bottom: 20rpx; }
  1452. .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; }
  1453. .action-btn.primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; }
  1454. .action-btn.full-width { width: 100%; flex: none; margin-bottom: 16rpx; }
  1455. .action-btn[disabled] { opacity: 0.6; }
  1456. .action-btn.langgraph-btn { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #ffffff; }
  1457. .action-btn.audio-btn { background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: #ffffff; }
  1458. .action-btn.upgrade-btn { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #ffffff; }
  1459. .btn-with-quota { display: flex; flex-direction: column; gap: 8rpx; width: 100%; }
  1460. .btn-with-quota .action-btn { width: 100%; flex: none; }
  1461. .quota-info-inline { display: flex; flex-wrap: wrap; gap: 12rpx; padding: 8rpx 0; }
  1462. .quota-text { font-size: 22rpx; color: #6b7280; }
  1463. .quota-warn { font-size: 22rpx; color: #ef4444; font-weight: 600; }
  1464. .generating-tip { text-align: center; padding: 20rpx; color: #6b7280; font-size: 26rpx; }
  1465. .warning-tip { padding: 24rpx; background: #fef3c7; border-radius: 12rpx; margin-top: 16rpx; }
  1466. .warning-icon { font-size: 32rpx; display: block; margin-bottom: 8rpx; }
  1467. .warning-text { font-size: 26rpx; color: #92400e; display: block; margin-bottom: 8rpx; }
  1468. .warning-hint { font-size: 24rpx; color: #b45309; display: block; }
  1469. </style>