index.vue 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173
  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">书籍生成</text>
  10. <view class="nav-right">
  11. <text class="nav-btn" @click="switchTab">📚</text>
  12. </view>
  13. </view>
  14. </view>
  15. <!-- 主内容区 -->
  16. <view class="main-content">
  17. <!-- 视图1:书籍列表 -->
  18. <view v-if="currentView === 'list'" class="list-view">
  19. <!-- 创建新书籍按钮 -->
  20. <view class="create-card" @click="currentView = 'create'">
  21. <text class="create-icon">+</text>
  22. <text class="create-text">创建新书籍</text>
  23. </view>
  24. <!-- 视频生成入口 -->
  25. <view class="video-nav-card" @click="goToVideoGenerator">
  26. <view class="video-nav-content">
  27. <text class="video-nav-icon">🎬</text>
  28. <view class="video-nav-text">
  29. <text class="video-nav-title">视频生成</text>
  30. <text class="video-nav-desc">图片+音频生成精美视频</text>
  31. </view>
  32. </view>
  33. <text class="video-nav-arrow">→</text>
  34. </view>
  35. <!-- 书籍列表 -->
  36. <view v-if="books.length > 0" class="book-list">
  37. <view
  38. v-for="book in books"
  39. :key="book.id"
  40. class="book-card"
  41. >
  42. <view class="book-header" @click="openBook(book)">
  43. <text class="book-title">{{ book.title }}</text>
  44. <view :class="['status-badge', book.status]">
  45. {{ getStatusText(book.status) }}
  46. </view>
  47. </view>
  48. <text class="book-desc" @click="openBook(book)">{{ book.description }}</text>
  49. <view class="book-meta" @click="openBook(book)">
  50. <text>{{ book.totalChapters }}章</text>
  51. <text v-if="book.progress > 0">{{ book.progress }}%</text>
  52. </view>
  53. <view v-if="book.progress > 0 && book.progress < 100" class="progress-bar" @click="openBook(book)">
  54. <view class="progress-fill" :style="{ width: book.progress + '%' }"></view>
  55. </view>
  56. <!-- 批量生成音频按钮 -->
  57. <view v-if="book.status === 'completed'" class="book-actions">
  58. <button
  59. class="audio-btn"
  60. :disabled="generatingAudio"
  61. @click.stop="handleGenerateAllAudio(book)"
  62. >
  63. {{ generatingAudio ? '生成中...' : '🎵 生成全部音频' }}
  64. </button>
  65. <button
  66. class="video-btn"
  67. :disabled="generatingVideo"
  68. @click.stop="handleGenerateAllVideo(book)"
  69. >
  70. {{ generatingVideo ? '生成中...' : '🎬 生成全部视频' }}
  71. </button>
  72. </view>
  73. </view>
  74. </view>
  75. <!-- 空状态 -->
  76. <view v-else class="empty-state">
  77. <text class="empty-icon">📖</text>
  78. <text class="empty-text">暂无书籍</text>
  79. <text class="empty-hint">点击上方按钮创建第一本书</text>
  80. </view>
  81. </view>
  82. <!-- 视图2:创建书籍 -->
  83. <view v-if="currentView === 'create'" class="create-view">
  84. <view class="card">
  85. <view class="card-header">
  86. <text class="card-title">📖 创建新书籍</text>
  87. </view>
  88. <!-- 书名 -->
  89. <view class="form-item">
  90. <text class="form-label">书名 *</text>
  91. <input
  92. v-model="newBook.title"
  93. class="form-input"
  94. placeholder="例如:《时间是什么》"
  95. />
  96. </view>
  97. <!-- 副标题 -->
  98. <view class="form-item">
  99. <text class="form-label">副标题</text>
  100. <input
  101. v-model="newBook.subtitle"
  102. class="form-input"
  103. placeholder="例如:一本写给青少年的科普书"
  104. />
  105. </view>
  106. <!-- 描述/主题 -->
  107. <view class="form-item">
  108. <text class="form-label">内容描述 *</text>
  109. <textarea
  110. v-model="newBook.description"
  111. class="form-textarea"
  112. placeholder="描述这本书的内容、主题、写作目的..."
  113. :maxlength="500"
  114. />
  115. </view>
  116. <!-- 目标受众 -->
  117. <view class="form-item">
  118. <text class="form-label">目标受众</text>
  119. <view class="chip-group">
  120. <view
  121. v-for="audience in audiences"
  122. :key="audience"
  123. :class="['chip', newBook.targetAudience === audience ? 'active' : '']"
  124. @click="newBook.targetAudience = audience"
  125. >
  126. {{ audience }}
  127. </view>
  128. </view>
  129. </view>
  130. <!-- 写作风格 -->
  131. <view class="form-item">
  132. <text class="form-label">写作风格</text>
  133. <view class="chip-group">
  134. <view
  135. v-for="style in styles"
  136. :key="style"
  137. :class="['chip', newBook.style === style ? 'active' : '']"
  138. @click="newBook.style = style"
  139. >
  140. {{ style }}
  141. </view>
  142. </view>
  143. </view>
  144. <!-- 书籍规模 -->
  145. <view class="form-item">
  146. <text class="form-label">书籍规模</text>
  147. <view class="scale-picker">
  148. <view
  149. v-for="scale in bookScales"
  150. :key="scale.value"
  151. :class="['scale-option', { active: newBook.bookScale === scale.value }]"
  152. @click="selectBookScale(scale.value)"
  153. >
  154. <text class="scale-words">{{ scale.words }}</text>
  155. <text class="scale-label">{{ scale.label }}</text>
  156. <text class="scale-pages">{{ scale.pages }}</text>
  157. </view>
  158. </view>
  159. </view>
  160. <!-- 预估信息(选择规模后显示) -->
  161. <view v-if="bookEstimate" class="estimate-card">
  162. <view class="estimate-header">
  163. <text class="estimate-title">📊 生成预估</text>
  164. </view>
  165. <view class="estimate-row">
  166. <text class="estimate-label">预估字数:</text>
  167. <text class="estimate-value">{{ bookEstimate.words.min }}~{{ bookEstimate.words.max }}字</text>
  168. </view>
  169. <view class="estimate-row">
  170. <text class="estimate-label">预估时长:</text>
  171. <text class="estimate-value">{{ bookEstimate.audioMinutes.min }}~{{ bookEstimate.audioMinutes.max }}分钟</text>
  172. </view>
  173. <view class="estimate-row">
  174. <text class="estimate-label">预估章节:</text>
  175. <text class="estimate-value">约{{ bookEstimate.estimatedChapters }}章</text>
  176. </view>
  177. <!-- 配额检查结果 -->
  178. <view v-if="quotaCheck" class="quota-check">
  179. <view v-if="quotaCheck.allowed" class="quota-ok">
  180. <text class="quota-icon">✅</text>
  181. <text class="quota-text">额度充足,可生成</text>
  182. </view>
  183. <view v-else class="quota-warning">
  184. <text class="quota-icon">⚠️</text>
  185. <text class="quota-text">{{ quotaCheck.reason }}</text>
  186. </view>
  187. <!-- 详细配额信息 -->
  188. <view class="quota-detail">
  189. <text class="quota-info">您当前额度:{{ quotaCheck.quota.remainingMinutes }}分钟 / {{ quotaCheck.quota.totalMinutes }}分钟</text>
  190. <view v-if="quotaCheck.costEstimate.overageMinutes > 0" class="quota-overage">
  191. <text>预计超出:{{ quotaCheck.costEstimate.overageMinutes }}分钟</text>
  192. <text class="quota-price">额外费用:¥{{ quotaCheck.costEstimate.estimatedPrice }}</text>
  193. </view>
  194. </view>
  195. </view>
  196. </view>
  197. <view class="btn-group">
  198. <button class="btn-cancel" @click="currentView = 'list'">取消</button>
  199. <button
  200. class="btn-primary"
  201. :disabled="!canCreateBook || creating"
  202. @click="createNewBook"
  203. >
  204. {{ creating ? '创建中...' : '创建书籍' }}
  205. </button>
  206. </view>
  207. </view>
  208. </view>
  209. <!-- 视图3:书籍详情/生成 -->
  210. <view v-if="currentView === 'detail'" class="detail-view">
  211. <!-- 书籍信息 -->
  212. <view class="card book-info">
  213. <view class="book-title-row">
  214. <text class="book-title-large">{{ currentBook?.title }}</text>
  215. <view :class="['status-badge', currentBook?.status]">
  216. {{ getStatusText(currentBook?.status || 'draft') }}
  217. </view>
  218. </view>
  219. <text v-if="currentBook?.subtitle" class="book-subtitle">{{ currentBook.subtitle }}</text>
  220. <text class="book-desc">{{ currentBook?.description }}</text>
  221. <view class="book-meta-row">
  222. <text>章节:{{ currentBook?.totalChapters }} 章</text>
  223. <text>预估:{{ currentBook?.estimatedWords || 0 }} 字</text>
  224. </view>
  225. </view>
  226. <!-- 进度显示 -->
  227. <view v-if="currentBook && currentBook.progress > 0" class="card progress-card">
  228. <text class="card-title">📊 生成进度</text>
  229. <view class="progress-display">
  230. <text class="progress-text">{{ currentBook.progress }}%</text>
  231. <text class="progress-detail">
  232. {{ completedChapters }}/{{ currentBook.totalChapters }} 章
  233. </text>
  234. </view>
  235. <view class="progress-bar-large">
  236. <view class="progress-fill" :style="{ width: currentBook.progress + '%' }"></view>
  237. </view>
  238. <!-- 实时配额显示 -->
  239. <view v-if="currentBook.status === 'generating'" class="quota-monitor">
  240. <text class="quota-monitor-title">🎧 额度监控</text>
  241. <view class="quota-monitor-row">
  242. <text>已用:{{ usedAudioMinutes }}分钟</text>
  243. <text>剩余:{{ remainingAudioMinutes }}分钟</text>
  244. </view>
  245. <view v-if="overageMinutes > 0" class="quota-overage-warning">
  246. <text>⚠️ 已超出配额 {{ overageMinutes }} 分钟</text>
  247. </view>
  248. </view>
  249. <!-- 中断提示 -->
  250. <view v-if="currentBook.status === 'interrupted'" class="interrupted-tip">
  251. <text class="interrupted-icon">⚠️</text>
  252. <text class="interrupted-text">生成已中断:额度不足,已保存当前进度</text>
  253. <text class="interrupted-hint">可升级套餐后继续生成</text>
  254. </view>
  255. </view>
  256. <!-- 大纲展示 -->
  257. <view v-if="currentBook?.outline" class="card outline-card">
  258. <view class="card-header-row">
  259. <text class="card-title">📋 书籍大纲</text>
  260. <text class="outline-theme">{{ currentBook.outline.mainTheme }}</text>
  261. </view>
  262. <view class="outline-list">
  263. <view
  264. v-for="chapter in currentBook.outline.chapters"
  265. :key="chapter.number"
  266. class="outline-item"
  267. >
  268. <view class="chapter-num">{{ chapter.number }}</view>
  269. <view class="chapter-info">
  270. <text class="chapter-title">{{ chapter.title }}</text>
  271. <text class="chapter-summary">{{ chapter.summary }}</text>
  272. <view class="chapter-points">
  273. <text v-for="(point, idx) in chapter.keyPoints.slice(0, 3)" :key="idx" class="point-tag">
  274. {{ point }}
  275. </text>
  276. </view>
  277. </view>
  278. <view :class="['chapter-status', getChapterStatus(chapter.number)]">
  279. {{ getChapterStatusText(chapter.number) }}
  280. </view>
  281. </view>
  282. </view>
  283. </view>
  284. <!-- 操作按钮 -->
  285. <view class="card action-card">
  286. <!-- 未生成大纲:显示所有生成方式 -->
  287. <view v-if="!currentBook?.outline && currentBook?.status !== 'planning'" class="btn-col">
  288. <view class="btn-row">
  289. <button
  290. class="action-btn"
  291. :disabled="generating"
  292. @click="handleGenerateOutline"
  293. >
  294. {{ generating ? '生成中...' : '📋 生成大纲' }}
  295. </button>
  296. <button
  297. class="action-btn primary"
  298. :disabled="generating"
  299. @click="handleGenerateAll"
  300. >
  301. {{ generating ? '生成中...' : '🚀 一键生成' }}
  302. </button>
  303. <button
  304. class="action-btn langgraph-btn"
  305. :disabled="generating"
  306. @click="handleLangGraphGenerate"
  307. >
  308. {{ generating ? '生成中...' : '🤖 LangGraph' }}
  309. </button>
  310. </view>
  311. </view>
  312. <!-- 大纲已生成,显示所有生成方式 -->
  313. <view v-if="currentBook?.outline && currentBook.status !== 'generating'" class="btn-col">
  314. <view class="btn-row">
  315. <button class="action-btn primary" :disabled="generating" @click="handleGenerateAll">
  316. {{ generating ? '生成中...' : '🚀 一键生成' }}
  317. </button>
  318. <button class="action-btn langgraph-btn" :disabled="generating" @click="handleLangGraphGenerate">
  319. {{ generating ? '生成中...' : '🤖 LangGraph' }}
  320. </button>
  321. </view>
  322. <view class="btn-row">
  323. <button class="action-btn" @click="handleGenerateForeword">前言</button>
  324. <button
  325. class="action-btn"
  326. :disabled="generating"
  327. @click="handleGenerateAllChapters"
  328. >
  329. {{ generating ? '生成中...' : '📝 章节' }}
  330. </button>
  331. <button class="action-btn" @click="handleGenerateAfterword">后记</button>
  332. </view>
  333. </view>
  334. <!-- 生成中 -->
  335. <view v-if="generating" class="generating-tip">
  336. <text>正在生成中,请稍候...</text>
  337. </view>
  338. <!-- 查看完整内容 -->
  339. <button
  340. v-if="currentBook?.status === 'completed'"
  341. class="action-btn primary full-width"
  342. @click="showFullContent"
  343. >
  344. 📖 查看完整书籍
  345. </button>
  346. <!-- 返回列表 -->
  347. <button class="action-btn full-width" @click="currentView = 'list'; loadBooks()">
  348. 返回书籍列表
  349. </button>
  350. </view>
  351. </view>
  352. </view>
  353. <!-- 目录视图 -->
  354. <view v-if="currentView === 'toc'" class="toc-view">
  355. <!-- 顶部导航 -->
  356. <view class="nav-bar">
  357. <view class="nav-content">
  358. <view class="nav-left" @click="currentView = 'detail'">
  359. <text class="back-icon">←</text>
  360. </view>
  361. <text class="page-title">目录</text>
  362. <view class="nav-right"></view>
  363. </view>
  364. </view>
  365. <view class="toc-content">
  366. <!-- 前言入口 -->
  367. <view v-if="currentBook?.metadata?.foreword" class="toc-item foreword" @click="showForeword">
  368. <text class="toc-label">前言</text>
  369. <text class="toc-arrow">→</text>
  370. </view>
  371. <!-- 章节列表 -->
  372. <view
  373. v-for="chapter in currentBook?.chapters"
  374. :key="chapter.id"
  375. :class="['toc-item', 'chapter', chapter.status === 'completed' ? 'done' : 'pending']"
  376. >
  377. <view class="toc-left" @click="openChapter(chapter)">
  378. <text class="chapter-num">第{{ chapter.number }}章</text>
  379. <view class="chapter-info-col">
  380. <text class="chapter-title">{{ chapter.title }}</text>
  381. <text v-if="chapter.audioUrl" class="audio-status">🎵 已生成音频</text>
  382. </view>
  383. </view>
  384. <view class="toc-actions">
  385. <!-- 生成音频按钮 -->
  386. <button
  387. v-if="chapter.status === 'completed' && !chapter.audioUrl"
  388. class="mini-audio-btn"
  389. :disabled="generatingAudio"
  390. @click.stop="handleGenerateChapterAudio(chapter)"
  391. >
  392. 🎵
  393. </button>
  394. <!-- 生成视频按钮 -->
  395. <button
  396. v-if="chapter.status === 'completed' && chapter.audioUrl && !chapter.videoUrl"
  397. class="mini-video-btn"
  398. :disabled="generatingVideo"
  399. @click.stop="handleGenerateChapterVideo(chapter)"
  400. >
  401. 🎬
  402. </button>
  403. <text v-if="chapter.videoUrl" class="video-status">🎬 已生成</text>
  404. <text class="toc-arrow" @click="openChapter(chapter)">→</text>
  405. </view>
  406. </view>
  407. <!-- 后记入口 -->
  408. <view v-if="currentBook?.metadata?.afterword" class="toc-item afterword" @click="showAfterword">
  409. <text class="toc-label">后记</text>
  410. <text class="toc-arrow">→</text>
  411. </view>
  412. </view>
  413. </view>
  414. <!-- 章节详情视图 -->
  415. <view v-if="currentView === 'chapter-detail'" class="chapter-detail-view">
  416. <!-- 顶部导航 -->
  417. <view class="nav-bar">
  418. <view class="nav-content">
  419. <view class="nav-left" @click="currentView = 'toc'">
  420. <text class="back-icon">←</text>
  421. </view>
  422. <text class="page-title">第{{ currentChapter?.number }}章</text>
  423. <view class="nav-right"></view>
  424. </view>
  425. </view>
  426. <view class="chapter-content">
  427. <scroll-view class="chapter-scroll" scroll-y>
  428. <view class="chapter-header">
  429. <text class="chapter-title-large">{{ currentChapter?.title }}</text>
  430. <text class="chapter-word-count">{{ currentChapter?.wordCount || 0 }}字</text>
  431. </view>
  432. <view class="chapter-body">
  433. <text class="chapter-text">{{ currentChapter?.content }}</text>
  434. </view>
  435. </scroll-view>
  436. </view>
  437. </view>
  438. </view>
  439. </template>
  440. <script setup lang="ts">
  441. import { ref, computed, onMounted, watch, nextTick } from 'vue';
  442. import * as api from '../../utils/book-generator-api';
  443. import type { Book, BookOutline, Chapter } from '../../utils/book-generator-api';
  444. // 使用相对路径(Nginx 反向代理)
  445. const BASE_URL = '/api';
  446. // 视图状态
  447. const currentView = ref<'list' | 'create' | 'detail' | 'toc' | 'chapter-detail'>('list');
  448. // 监听视图变化
  449. watch(currentView, (newView) => {
  450. if (newView === 'create') {
  451. // 打开创建视图时,加载默认规模的预估
  452. nextTick(() => {
  453. selectBookScale(newBook.value.bookScale);
  454. });
  455. }
  456. });
  457. // 书籍列表
  458. const books = ref<Book[]>([]);
  459. // 当前书籍
  460. const currentBook = ref<Book | null>(null);
  461. // 当前查看的章节
  462. const currentChapter = ref<Chapter | null>(null);
  463. // 创建表单
  464. const showCreateModal = ref(false);
  465. const creating = ref(false);
  466. const newBook = ref({
  467. title: '',
  468. subtitle: '',
  469. description: '',
  470. targetAudience: '',
  471. style: '',
  472. bookScale: 'medium',
  473. });
  474. // 生成状态
  475. const generating = ref(false);
  476. // 书籍预估信息
  477. const bookEstimate = ref<{
  478. scale: string;
  479. words: { min: number; max: number; avg: number };
  480. audioMinutes: { min: number; max: number; avg: number };
  481. estimatedChapters: number;
  482. } | null>(null);
  483. // 配额检查结果
  484. const quotaCheck = ref<{
  485. allowed: boolean;
  486. reason: string | null;
  487. estimatedWords: { min: number; max: number; avg: number };
  488. estimatedAudioMinutes: { min: number; max: number; avg: number };
  489. quota: {
  490. totalMinutes: number;
  491. usedMinutes: number;
  492. remainingMinutes: number;
  493. overageEnabled: boolean;
  494. overagePrice: number;
  495. };
  496. costEstimate: {
  497. inQuotaMinutes: number;
  498. overageMinutes: number;
  499. estimatedPrice: number;
  500. displayText: string;
  501. };
  502. } | null>(null);
  503. // 音频生成状态
  504. const generatingAudio = ref(false);
  505. // 视频生成状态
  506. const generatingVideo = ref(false);
  507. // 选项配置
  508. const audiences = ['不填', '通用', '青少年', '儿童', '大学生', '专业人士', '老年'];
  509. const styles = ['不填', '通俗易懂', '专业严谨', '轻松幽默', '诗意优美', '故事化'];
  510. const bookScales = [
  511. { value: '800', label: '短文', words: '800字', pages: '约3页' },
  512. { value: '2000', label: '短文', words: '2000字', pages: '约8页' },
  513. { value: '5000', label: '短文', words: '5000字', pages: '约20页' },
  514. { value: '小册子', label: '小册子', words: '1~5万字', pages: '约50~120页' },
  515. { value: '标准教程', label: '标准教程', words: '5~15万字', pages: '约150~300页' },
  516. { value: '系统教材', label: '系统教材', words: '15~30万字', pages: '约300~550页' },
  517. // { value: '专业厚本', label: '专业厚本', words: '30~60万字', pages: '约550~900页' },
  518. // { value: '大部头', label: '大部头', words: '60万字以上', pages: '900页以上' },
  519. ];
  520. // 计算属性
  521. const canCreateBook = computed(() => {
  522. // 免费版需要检查配额
  523. if (quotaCheck.value && !quotaCheck.value.allowed) {
  524. return false;
  525. }
  526. return newBook.value.title.trim() && newBook.value.description.trim();
  527. });
  528. const completedChapters = computed(() => {
  529. if (!currentBook.value) return 0;
  530. return currentBook.value.chapters.filter((c) => c.status === 'completed').length;
  531. });
  532. // 计算已使用的音频时长(基于已完成章节的字数)
  533. const usedAudioMinutes = computed(() => {
  534. if (!currentBook.value) return 0;
  535. const totalWords = currentBook.value.chapters
  536. .filter((c) => c.status === 'completed')
  537. .reduce((sum, c) => sum + (c.wordCount || 0), 0);
  538. return Math.ceil(totalWords / 150); // 150字/分钟
  539. });
  540. // 计算剩余配额
  541. const remainingAudioMinutes = computed(() => {
  542. if (!quotaInfo.value) return 0;
  543. return Math.max(0, quotaInfo.value.totalMinutes - quotaInfo.value.usedMinutes);
  544. });
  545. // 计算超出配额分钟数
  546. const overageMinutes = computed(() => {
  547. if (!currentBook.value) return 0;
  548. const used = usedAudioMinutes.value;
  549. if (!quotaInfo.value) return 0;
  550. return Math.max(0, used - quotaInfo.value.totalMinutes);
  551. });
  552. // 配额信息(从API获取)
  553. const quotaInfo = ref<{
  554. totalMinutes: number;
  555. usedMinutes: number;
  556. remainingMinutes: number;
  557. overageEnabled: boolean;
  558. } | null>(null);
  559. // 选择书籍规模时加载预估
  560. async function selectBookScale(scale: string) {
  561. newBook.value.bookScale = scale;
  562. try {
  563. const response = await uni.request({
  564. url: `/api/book-generator/langgraph/estimate?scale=${encodeURIComponent(scale)}&userId=1`,
  565. method: 'GET'
  566. });
  567. const res = response.data as any;
  568. if (res.code === 0 && res.data) {
  569. bookEstimate.value = {
  570. scale: res.data.scale,
  571. words: res.data.words,
  572. audioMinutes: res.data.audioMinutes,
  573. estimatedChapters: res.data.estimatedChapters
  574. };
  575. // 如果有配额检查结果
  576. if (res.data.quotaCheck) {
  577. quotaCheck.value = res.data.quotaCheck;
  578. }
  579. }
  580. } catch (e) {
  581. console.error('加载预估失败:', e);
  582. bookEstimate.value = null;
  583. quotaCheck.value = null;
  584. }
  585. }
  586. // 方法
  587. function goBack() {
  588. uni.navigateBack();
  589. }
  590. function switchTab() {
  591. uni.switchTab({ url: '/pages/index/index' });
  592. }
  593. function goToVideoGenerator() {
  594. uni.navigateTo({ url: '/pages/video-generator/index' });
  595. }
  596. function getStatusText(status: string): string {
  597. const map: Record<string, string> = {
  598. draft: '草稿',
  599. planning: '规划中',
  600. generating: '生成中',
  601. completed: '已完成',
  602. failed: '失败',
  603. };
  604. return map[status] || status;
  605. }
  606. function getChapterStatus(chapterNum: number): string {
  607. const chapter = currentBook.value?.chapters.find((c) => c.number === chapterNum);
  608. if (!chapter) return 'pending';
  609. return chapter.status === 'completed' ? 'done' : 'pending';
  610. }
  611. function getChapterStatusText(chapterNum: number): string {
  612. const chapter = currentBook.value?.chapters.find((c) => c.number === chapterNum);
  613. if (!chapter) return '待生成';
  614. return chapter.status === 'completed' ? '✓' : '○';
  615. }
  616. // 加载书籍列表
  617. async function loadBooks() {
  618. try {
  619. const data = await api.getBooks();
  620. books.value = data || [];
  621. } catch (e) {
  622. console.error('加载书籍失败:', e);
  623. books.value = [];
  624. }
  625. }
  626. // 创建新书籍
  627. async function createNewBook() {
  628. if (!canCreateBook.value) return;
  629. creating.value = true;
  630. try {
  631. await api.createBook({
  632. title: newBook.value.title,
  633. subtitle: newBook.value.subtitle,
  634. description: newBook.value.description,
  635. ...(newBook.value.targetAudience ? { targetAudience: newBook.value.targetAudience } : {}),
  636. ...(newBook.value.style ? { style: newBook.value.style } : {}),
  637. bookScale: newBook.value.bookScale,
  638. });
  639. // 重置表单
  640. newBook.value = {
  641. title: '',
  642. subtitle: '',
  643. description: '',
  644. targetAudience: '',
  645. style: '',
  646. bookScale: 'medium',
  647. };
  648. showCreateModal.value = false;
  649. currentView.value = 'list';
  650. await loadBooks();
  651. uni.showToast({ title: '创建成功', icon: 'success' });
  652. } catch (e: any) {
  653. uni.showToast({ title: e.message || '创建失败', icon: 'none' });
  654. } finally {
  655. creating.value = false;
  656. }
  657. }
  658. // 打开书籍
  659. async function openBook(book: Book) {
  660. try {
  661. currentBook.value = await api.getBook(book.id);
  662. currentView.value = 'detail';
  663. // 加载配额信息
  664. loadQuotaInfo();
  665. } catch (e) {
  666. uni.showToast({ title: '加载失败', icon: 'none' });
  667. }
  668. }
  669. // 加载用户配额信息
  670. async function loadQuotaInfo() {
  671. try {
  672. const response = await uni.request({
  673. url: '/api/subscription/audio-balance',
  674. method: 'GET'
  675. });
  676. const res = response.data as any;
  677. if (res.code === 0 && res.data) {
  678. quotaInfo.value = {
  679. totalMinutes: res.data.totalMinutes,
  680. usedMinutes: res.data.usedMinutes,
  681. remainingMinutes: res.data.remainingMinutes,
  682. overageEnabled: res.data.overageEnabled
  683. };
  684. }
  685. } catch (e) {
  686. console.error('加载配额信息失败:', e);
  687. }
  688. }
  689. // 生成大纲
  690. async function handleGenerateOutline() {
  691. if (!currentBook.value) return;
  692. generating.value = true;
  693. try {
  694. const outline = await api.generateOutline(currentBook.value.id);
  695. currentBook.value.outline = outline;
  696. currentBook.value.status = 'planning';
  697. // 初始化章节状态
  698. currentBook.value.chapters = outline.chapters.map((c) => ({
  699. id: '',
  700. bookId: currentBook.value!.id,
  701. number: c.number,
  702. title: c.title,
  703. content: '',
  704. wordCount: 0,
  705. status: 'pending' as const,
  706. }));
  707. uni.showToast({ title: '大纲生成成功', icon: 'success' });
  708. } catch (e: any) {
  709. uni.showToast({ title: e.message || '生成失败', icon: 'none' });
  710. } finally {
  711. generating.value = false;
  712. }
  713. }
  714. // 一键生成整本书(大纲+章节+前言+后记)- 异步模式
  715. let pollTimer: ReturnType<typeof setInterval> | null = null;
  716. async function handleGenerateAll() {
  717. if (!currentBook.value) return;
  718. generating.value = true;
  719. currentBook.value.status = 'generating';
  720. currentBook.value.progress = 0;
  721. try {
  722. // 启动异步生成任务(立即返回)
  723. await api.generateBook(currentBook.value.id, {
  724. generateForeword: true,
  725. generateAfterword: true,
  726. });
  727. // 显示提示
  728. uni.showToast({ title: '生成任务已启动,请稍候...', icon: 'none', duration: 2000 });
  729. // 开始轮询进度
  730. startPollingProgress(currentBook.value.id);
  731. } catch (e: any) {
  732. generating.value = false;
  733. currentBook.value.status = 'failed';
  734. uni.showToast({ title: e.message || '启动失败', icon: 'none' });
  735. }
  736. }
  737. // 轮询进度
  738. function startPollingProgress(bookId: string) {
  739. // 停止之前的轮询
  740. stopPollingProgress();
  741. pollTimer = setInterval(async () => {
  742. try {
  743. // 直接轮询书籍进度,不依赖工作流
  744. const progress = await api.getProgress(bookId);
  745. if (!progress) {
  746. return;
  747. }
  748. // 更新进度
  749. if (currentBook.value) {
  750. currentBook.value.progress = progress.progress;
  751. currentBook.value.status = progress.status as any;
  752. }
  753. // 检查是否完成
  754. if (progress.status === 'completed' || progress.status === 'failed') {
  755. stopPollingProgress();
  756. generating.value = false;
  757. // 刷新书籍详情
  758. currentBook.value = await api.getBook(bookId);
  759. if (progress.status === 'completed') {
  760. uni.showToast({ title: '整本书生成完成!', icon: 'success' });
  761. } else if (progress.status === 'failed') {
  762. uni.showToast({ title: '生成过程中有错误', icon: 'none' });
  763. }
  764. }
  765. } catch (e) {
  766. console.error('轮询进度失败:', e);
  767. }
  768. }, 3000); // 每3秒轮询一次
  769. }
  770. // 停止轮询
  771. function stopPollingProgress() {
  772. if (pollTimer) {
  773. clearInterval(pollTimer);
  774. pollTimer = null;
  775. }
  776. }
  777. // LangGraph 生成
  778. async function handleLangGraphGenerate() {
  779. if (!currentBook.value) return;
  780. generating.value = true;
  781. currentBook.value.status = 'generating';
  782. currentBook.value.progress = 0;
  783. try {
  784. const result = await api.generateWithLangGraph(currentBook.value.id);
  785. uni.showToast({ title: 'LangGraph 生成已启动', icon: 'none', duration: 2000 });
  786. startPollingProgress(currentBook.value.id);
  787. } catch (e: any) {
  788. generating.value = false;
  789. currentBook.value.status = 'failed';
  790. uni.showToast({ title: e.message || '启动失败', icon: 'none' });
  791. }
  792. }
  793. // 生成全部章节
  794. async function handleGenerateAllChapters() {
  795. if (!currentBook.value || !currentBook.value.outline) {
  796. uni.showToast({ title: '请先生成大纲', icon: 'none' });
  797. return;
  798. }
  799. generating.value = true;
  800. currentBook.value.status = 'generating';
  801. try {
  802. for (const outlineChapter of currentBook.value.outline.chapters) {
  803. try {
  804. const chapter = await api.generateChapter(currentBook.value.id, outlineChapter.number);
  805. // 更新章节
  806. const idx = currentBook.value.chapters.findIndex((c) => c.number === chapter.number);
  807. if (idx >= 0) {
  808. currentBook.value.chapters[idx] = chapter;
  809. }
  810. // 更新进度
  811. const completed = currentBook.value.chapters.filter((c) => c.status === 'completed').length;
  812. currentBook.value.progress = Math.round((completed / currentBook.value.totalChapters) * 100);
  813. } catch (e) {
  814. console.error(`生成第${outlineChapter.number}章失败:`, e);
  815. }
  816. }
  817. currentBook.value.status = 'completed';
  818. uni.showToast({ title: '全部章节生成完成', icon: 'success' });
  819. } catch (e: any) {
  820. currentBook.value.status = 'failed';
  821. uni.showToast({ title: e.message || '生成失败', icon: 'none' });
  822. } finally {
  823. generating.value = false;
  824. }
  825. }
  826. // 生成前言
  827. async function handleGenerateForeword() {
  828. if (!currentBook.value) return;
  829. generating.value = true;
  830. try {
  831. const foreword = await api.generateForeword(currentBook.value.id);
  832. currentBook.value.metadata = currentBook.value.metadata || {};
  833. currentBook.value.metadata.foreword = foreword;
  834. uni.showToast({ title: '前言生成成功', icon: 'success' });
  835. } catch (e: any) {
  836. uni.showToast({ title: e.message || '生成失败', icon: 'none' });
  837. } finally {
  838. generating.value = false;
  839. }
  840. }
  841. // 生成后记
  842. async function handleGenerateAfterword() {
  843. if (!currentBook.value) return;
  844. generating.value = true;
  845. try {
  846. const afterword = await api.generateAfterword(currentBook.value.id);
  847. currentBook.value.metadata = currentBook.value.metadata || {};
  848. currentBook.value.metadata.afterword = afterword;
  849. uni.showToast({ title: '后记生成成功', icon: 'success' });
  850. } catch (e: any) {
  851. uni.showToast({ title: e.message || '生成失败', icon: 'none' });
  852. } finally {
  853. generating.value = false;
  854. }
  855. }
  856. // 显示完整内容 - 现在显示目录
  857. async function showFullContent() {
  858. if (!currentBook.value) return;
  859. // 刷新书籍详情获取最新章节
  860. currentBook.value = await api.getBook(currentBook.value.id);
  861. currentView.value = 'toc';
  862. }
  863. // 打开章节详情
  864. function openChapter(chapter: Chapter) {
  865. if (chapter.status !== 'completed') {
  866. uni.showToast({ title: '该章节尚未生成', icon: 'none' });
  867. return;
  868. }
  869. currentChapter.value = chapter;
  870. currentView.value = 'chapter-detail';
  871. }
  872. // 显示前言
  873. function showForeword() {
  874. if (!currentBook.value?.metadata?.foreword) {
  875. uni.showToast({ title: '前言尚未生成', icon: 'none' });
  876. return;
  877. }
  878. // 创建一个临时章节对象显示前言
  879. currentChapter.value = {
  880. id: 'foreword',
  881. bookId: currentBook.value.id,
  882. number: 0,
  883. title: '前言',
  884. content: currentBook.value.metadata.foreword,
  885. wordCount: currentBook.value.metadata.foreword.length,
  886. status: 'completed',
  887. };
  888. currentView.value = 'chapter-detail';
  889. }
  890. // 显示后记
  891. function showAfterword() {
  892. if (!currentBook.value?.metadata?.afterword) {
  893. uni.showToast({ title: '后记尚未生成', icon: 'none' });
  894. return;
  895. }
  896. currentChapter.value = {
  897. id: 'afterword',
  898. bookId: currentBook.value.id,
  899. number: currentBook.value.chapters.length + 1,
  900. title: '后记',
  901. content: currentBook.value.metadata.afterword,
  902. wordCount: currentBook.value.metadata.afterword.length,
  903. status: 'completed',
  904. };
  905. currentView.value = 'chapter-detail';
  906. }
  907. // ============ 音频生成 ============
  908. /**
  909. * 生成单个章节音频
  910. */
  911. async function handleGenerateChapterAudio(chapter: Chapter) {
  912. if (!currentBook.value) return;
  913. generatingAudio.value = true;
  914. try {
  915. await api.generateChapterAudio(currentBook.value.id, chapter.number, 'cherry');
  916. uni.showToast({ title: '音频生成任务已启动', icon: 'none', duration: 2000 });
  917. // 3秒后刷新书籍信息,检查音频是否生成完成
  918. setTimeout(async () => {
  919. if (currentBook.value) {
  920. currentBook.value = await api.getBook(currentBook.value.id);
  921. }
  922. }, 3000);
  923. } catch (e: any) {
  924. uni.showToast({ title: e.message || '生成失败', icon: 'none' });
  925. } finally {
  926. generatingAudio.value = false;
  927. }
  928. }
  929. /**
  930. * 批量生成书籍所有章节音频
  931. */
  932. async function handleGenerateAllAudio(book: Book) {
  933. generatingAudio.value = true;
  934. try {
  935. const result = await api.generateAllChaptersAudio(book.id, 'cherry');
  936. uni.showToast({
  937. title: `已启动 ${result.totalChapters} 个章节的音频生成`,
  938. icon: 'none',
  939. duration: 2500,
  940. });
  941. // 5秒后刷新书籍信息
  942. setTimeout(async () => {
  943. await loadBooks();
  944. if (currentBook.value?.id === book.id) {
  945. currentBook.value = await api.getBook(book.id);
  946. }
  947. }, 5000);
  948. } catch (e: any) {
  949. uni.showToast({ title: e.message || '生成失败', icon: 'none' });
  950. } finally {
  951. generatingAudio.value = false;
  952. }
  953. }
  954. // ============ 视频生成 ============
  955. /**
  956. * 生成单个章节视频
  957. */
  958. async function handleGenerateChapterVideo(chapter: Chapter) {
  959. if (!currentBook.value) return;
  960. generatingVideo.value = true;
  961. try {
  962. await api.generateChapterVideo(currentBook.value.id, chapter.number);
  963. uni.showToast({ title: '视频生成任务已启动', icon: 'none', duration: 2000 });
  964. // 3秒后刷新书籍信息,检查视频是否生成完成
  965. setTimeout(async () => {
  966. if (currentBook.value) {
  967. currentBook.value = await api.getBook(currentBook.value.id);
  968. }
  969. }, 3000);
  970. } catch (e: any) {
  971. uni.showToast({ title: e.message || '生成失败', icon: 'none' });
  972. } finally {
  973. generatingVideo.value = false;
  974. }
  975. }
  976. /**
  977. * 批量生成书籍所有章节视频
  978. */
  979. async function handleGenerateAllVideo(book: Book) {
  980. generatingVideo.value = true;
  981. try {
  982. const result = await api.generateAllChaptersVideo(book.id);
  983. uni.showToast({
  984. title: `已启动 ${result.totalChapters} 个章节的视频生成`,
  985. icon: 'none',
  986. duration: 2500,
  987. });
  988. // 5秒后刷新书籍信息
  989. setTimeout(async () => {
  990. await loadBooks();
  991. if (currentBook.value?.id === book.id) {
  992. currentBook.value = await api.getBook(book.id);
  993. }
  994. }, 5000);
  995. } catch (e: any) {
  996. uni.showToast({ title: e.message || '生成失败', icon: 'none' });
  997. } finally {
  998. generatingVideo.value = false;
  999. }
  1000. }
  1001. // 页面加载
  1002. onMounted(() => {
  1003. loadBooks();
  1004. });
  1005. </script>
  1006. <style scoped>
  1007. .page {
  1008. min-height: 100vh;
  1009. background: #f9fafb;
  1010. }
  1011. .nav-bar {
  1012. position: fixed;
  1013. top: 0;
  1014. left: 0;
  1015. right: 0;
  1016. z-index: 100;
  1017. background: #ffffff;
  1018. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  1019. }
  1020. .nav-content {
  1021. display: flex;
  1022. align-items: center;
  1023. justify-content: space-between;
  1024. height: 88rpx;
  1025. padding: 0 32rpx;
  1026. padding-top: env(safe-area-inset-top);
  1027. }
  1028. .nav-left,
  1029. .nav-right {
  1030. width: 80rpx;
  1031. }
  1032. .back-icon,
  1033. .nav-btn {
  1034. font-size: 40rpx;
  1035. color: #1f2937;
  1036. }
  1037. .page-title {
  1038. font-size: 32rpx;
  1039. font-weight: 600;
  1040. color: #1f2937;
  1041. }
  1042. .main-content {
  1043. padding: 120rpx 32rpx 32rpx;
  1044. }
  1045. /* 卡片样式 */
  1046. .card {
  1047. background: #ffffff;
  1048. border-radius: 24rpx;
  1049. padding: 32rpx;
  1050. margin-bottom: 24rpx;
  1051. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
  1052. }
  1053. .card-header {
  1054. margin-bottom: 24rpx;
  1055. }
  1056. .card-header-row {
  1057. display: flex;
  1058. align-items: center;
  1059. justify-content: space-between;
  1060. margin-bottom: 24rpx;
  1061. }
  1062. .card-title {
  1063. font-size: 32rpx;
  1064. font-weight: 600;
  1065. color: #1f2937;
  1066. }
  1067. /* 创建卡片 */
  1068. .create-card {
  1069. display: flex;
  1070. align-items: center;
  1071. justify-content: center;
  1072. gap: 16rpx;
  1073. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1074. border-radius: 24rpx;
  1075. padding: 48rpx;
  1076. margin-bottom: 24rpx;
  1077. }
  1078. .create-icon {
  1079. font-size: 48rpx;
  1080. color: #ffffff;
  1081. }
  1082. .create-text {
  1083. font-size: 32rpx;
  1084. font-weight: 600;
  1085. color: #ffffff;
  1086. }
  1087. /* 视频导航卡片 */
  1088. .video-nav-card {
  1089. display: flex;
  1090. align-items: center;
  1091. justify-content: space-between;
  1092. background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
  1093. border-radius: 24rpx;
  1094. padding: 36rpx;
  1095. margin-bottom: 24rpx;
  1096. }
  1097. .video-nav-content {
  1098. display: flex;
  1099. align-items: center;
  1100. gap: 24rpx;
  1101. }
  1102. .video-nav-icon {
  1103. font-size: 56rpx;
  1104. }
  1105. .video-nav-text {
  1106. display: flex;
  1107. flex-direction: column;
  1108. gap: 6rpx;
  1109. }
  1110. .video-nav-title {
  1111. font-size: 30rpx;
  1112. font-weight: 600;
  1113. color: #ffffff;
  1114. }
  1115. .video-nav-desc {
  1116. font-size: 22rpx;
  1117. color: rgba(255, 255, 255, 0.8);
  1118. }
  1119. .video-nav-arrow {
  1120. font-size: 40rpx;
  1121. color: #ffffff;
  1122. }
  1123. /* 书籍列表 */
  1124. .book-list {
  1125. display: flex;
  1126. flex-direction: column;
  1127. gap: 20rpx;
  1128. }
  1129. .book-card {
  1130. background: #ffffff;
  1131. border-radius: 20rpx;
  1132. padding: 28rpx;
  1133. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
  1134. }
  1135. .book-header {
  1136. display: flex;
  1137. align-items: center;
  1138. justify-content: space-between;
  1139. margin-bottom: 12rpx;
  1140. }
  1141. .book-title {
  1142. font-size: 30rpx;
  1143. font-weight: 600;
  1144. color: #1f2937;
  1145. flex: 1;
  1146. }
  1147. .status-badge {
  1148. font-size: 22rpx;
  1149. padding: 6rpx 16rpx;
  1150. border-radius: 20rpx;
  1151. background: #e5e7eb;
  1152. color: #6b7280;
  1153. }
  1154. .status-badge.planning,
  1155. .status-badge.generating {
  1156. background: #fef3c7;
  1157. color: #d97706;
  1158. }
  1159. .status-badge.completed {
  1160. background: #d1fae5;
  1161. color: #059669;
  1162. }
  1163. .status-badge.failed {
  1164. background: #fee2e2;
  1165. color: #dc2626;
  1166. }
  1167. .book-desc {
  1168. font-size: 26rpx;
  1169. color: #6b7280;
  1170. display: -webkit-box;
  1171. -webkit-line-clamp: 2;
  1172. -webkit-box-orient: vertical;
  1173. overflow: hidden;
  1174. margin-bottom: 16rpx;
  1175. }
  1176. .book-meta {
  1177. display: flex;
  1178. gap: 24rpx;
  1179. font-size: 24rpx;
  1180. color: #9ca3af;
  1181. margin-bottom: 12rpx;
  1182. }
  1183. .progress-bar {
  1184. height: 6rpx;
  1185. background: #e5e7eb;
  1186. border-radius: 3rpx;
  1187. }
  1188. .progress-fill {
  1189. height: 100%;
  1190. background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
  1191. border-radius: 3rpx;
  1192. transition: width 0.3s;
  1193. }
  1194. /* 空状态 */
  1195. .empty-state {
  1196. display: flex;
  1197. flex-direction: column;
  1198. align-items: center;
  1199. padding: 80rpx 0;
  1200. }
  1201. .empty-icon {
  1202. font-size: 120rpx;
  1203. margin-bottom: 24rpx;
  1204. }
  1205. .empty-text {
  1206. font-size: 32rpx;
  1207. color: #6b7280;
  1208. margin-bottom: 12rpx;
  1209. }
  1210. .empty-hint {
  1211. font-size: 26rpx;
  1212. color: #9ca3af;
  1213. }
  1214. /* 表单 */
  1215. .form-item {
  1216. margin-bottom: 28rpx;
  1217. }
  1218. .form-label {
  1219. display: block;
  1220. font-size: 28rpx;
  1221. color: #374151;
  1222. margin-bottom: 12rpx;
  1223. }
  1224. .form-input {
  1225. width: 100%;
  1226. height: 88rpx;
  1227. padding: 0 24rpx;
  1228. background: #f9fafb;
  1229. border-radius: 16rpx;
  1230. font-size: 28rpx;
  1231. box-sizing: border-box;
  1232. }
  1233. .form-textarea {
  1234. width: 100%;
  1235. height: 200rpx;
  1236. padding: 24rpx;
  1237. background: #f9fafb;
  1238. border-radius: 16rpx;
  1239. font-size: 28rpx;
  1240. box-sizing: border-box;
  1241. }
  1242. .slider-range {
  1243. display: flex;
  1244. justify-content: space-between;
  1245. font-size: 24rpx;
  1246. color: #9ca3af;
  1247. margin-top: 8rpx;
  1248. }
  1249. .scale-picker {
  1250. display: flex;
  1251. flex-direction: column;
  1252. gap: 12rpx;
  1253. margin-top: 12rpx;
  1254. }
  1255. .scale-option {
  1256. display: flex;
  1257. align-items: center;
  1258. padding: 20rpx 16rpx;
  1259. border: 2rpx solid #e5e7eb;
  1260. border-radius: 12rpx;
  1261. background: #f9fafb;
  1262. transition: all 0.2s;
  1263. }
  1264. .scale-option.active {
  1265. border-color: #4F46E5;
  1266. background: #eef2ff;
  1267. }
  1268. .scale-label {
  1269. font-size: 28rpx;
  1270. font-weight: 600;
  1271. color: #374151;
  1272. min-width: 160rpx;
  1273. margin-left: 24rpx;
  1274. }
  1275. .scale-option.active .scale-label {
  1276. color: #4F46E5;
  1277. }
  1278. .scale-words {
  1279. font-size: 28rpx;
  1280. color: #374151;
  1281. min-width: 160rpx;
  1282. font-weight: 500;
  1283. }
  1284. .scale-pages {
  1285. font-size: 22rpx;
  1286. color: #6b7280;
  1287. flex: 1;
  1288. text-align: right;
  1289. }
  1290. /* 预估信息卡片 */
  1291. .estimate-card {
  1292. background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
  1293. border-radius: 16rpx;
  1294. padding: 24rpx;
  1295. margin-top: 20rpx;
  1296. border: 1px solid #fcd34d;
  1297. }
  1298. .estimate-header {
  1299. margin-bottom: 16rpx;
  1300. }
  1301. .estimate-title {
  1302. font-size: 28rpx;
  1303. font-weight: 600;
  1304. color: #92400e;
  1305. }
  1306. .estimate-row {
  1307. display: flex;
  1308. justify-content: space-between;
  1309. align-items: center;
  1310. padding: 8rpx 0;
  1311. }
  1312. .estimate-label {
  1313. font-size: 26rpx;
  1314. color: #78350f;
  1315. }
  1316. .estimate-value {
  1317. font-size: 26rpx;
  1318. font-weight: 600;
  1319. color: #92400e;
  1320. }
  1321. /* 配额检查结果 */
  1322. .quota-check {
  1323. margin-top: 16rpx;
  1324. padding-top: 16rpx;
  1325. border-top: 1px dashed #fcd34d;
  1326. }
  1327. .quota-ok, .quota-warning {
  1328. display: flex;
  1329. align-items: center;
  1330. gap: 12rpx;
  1331. padding: 12rpx 16rpx;
  1332. border-radius: 12rpx;
  1333. margin-bottom: 12rpx;
  1334. }
  1335. .quota-ok {
  1336. background: rgba(16, 185, 129, 0.1);
  1337. }
  1338. .quota-warning {
  1339. background: rgba(245, 158, 11, 0.1);
  1340. }
  1341. .quota-icon {
  1342. font-size: 32rpx;
  1343. }
  1344. .quota-text {
  1345. font-size: 26rpx;
  1346. color: #374151;
  1347. }
  1348. .quota-detail {
  1349. padding: 8rpx 0;
  1350. }
  1351. .quota-info {
  1352. font-size: 24rpx;
  1353. color: #6b7280;
  1354. }
  1355. .quota-overage {
  1356. display: flex;
  1357. justify-content: space-between;
  1358. align-items: center;
  1359. margin-top: 8rpx;
  1360. padding: 8rpx 12rpx;
  1361. background: rgba(239, 68, 68, 0.1);
  1362. border-radius: 8rpx;
  1363. }
  1364. .quota-overage text {
  1365. font-size: 24rpx;
  1366. color: #dc2626;
  1367. }
  1368. .quota-price {
  1369. font-weight: 600;
  1370. }
  1371. /* 标签组 */
  1372. .chip-group {
  1373. display: flex;
  1374. flex-wrap: wrap;
  1375. gap: 16rpx;
  1376. }
  1377. .chip {
  1378. padding: 12rpx 24rpx;
  1379. background: #f3f4f6;
  1380. border-radius: 30rpx;
  1381. font-size: 26rpx;
  1382. color: #6b7280;
  1383. }
  1384. .chip.active {
  1385. background: #4f46e5;
  1386. color: #ffffff;
  1387. }
  1388. /* 按钮组 */
  1389. .btn-group {
  1390. display: flex;
  1391. gap: 20rpx;
  1392. margin-top: 32rpx;
  1393. }
  1394. .btn-cancel,
  1395. .btn-primary {
  1396. flex: 1;
  1397. height: 88rpx;
  1398. border-radius: 16rpx;
  1399. font-size: 28rpx;
  1400. display: flex;
  1401. align-items: center;
  1402. justify-content: center;
  1403. border: none;
  1404. }
  1405. .btn-cancel {
  1406. background: #f3f4f6;
  1407. color: #6b7280;
  1408. }
  1409. .btn-primary {
  1410. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1411. color: #ffffff;
  1412. }
  1413. .btn-primary[disabled] {
  1414. opacity: 0.6;
  1415. }
  1416. /* 详情页 */
  1417. .book-info {
  1418. margin-bottom: 24rpx;
  1419. }
  1420. .book-title-row {
  1421. display: flex;
  1422. align-items: center;
  1423. justify-content: space-between;
  1424. margin-bottom: 8rpx;
  1425. }
  1426. .book-title-large {
  1427. font-size: 36rpx;
  1428. font-weight: 700;
  1429. color: #1f2937;
  1430. flex: 1;
  1431. }
  1432. .book-subtitle {
  1433. font-size: 28rpx;
  1434. color: #6b7280;
  1435. margin-bottom: 16rpx;
  1436. }
  1437. .book-meta-row {
  1438. display: flex;
  1439. gap: 24rpx;
  1440. font-size: 26rpx;
  1441. color: #9ca3af;
  1442. margin-top: 16rpx;
  1443. }
  1444. /* 进度卡片 */
  1445. .progress-card {
  1446. margin-bottom: 24rpx;
  1447. }
  1448. .progress-display {
  1449. display: flex;
  1450. align-items: baseline;
  1451. gap: 16rpx;
  1452. margin: 16rpx 0;
  1453. }
  1454. .progress-text {
  1455. font-size: 48rpx;
  1456. font-weight: 700;
  1457. color: #4f46e5;
  1458. }
  1459. .progress-detail {
  1460. font-size: 28rpx;
  1461. color: #6b7280;
  1462. }
  1463. .progress-bar-large {
  1464. height: 12rpx;
  1465. background: #e5e7eb;
  1466. border-radius: 6rpx;
  1467. }
  1468. /* 大纲卡片 */
  1469. .outline-theme {
  1470. font-size: 24rpx;
  1471. color: #6b7280;
  1472. }
  1473. .outline-list {
  1474. margin-top: 20rpx;
  1475. }
  1476. .outline-item {
  1477. display: flex;
  1478. align-items: flex-start;
  1479. gap: 20rpx;
  1480. padding: 20rpx 0;
  1481. border-bottom: 1px solid #f3f4f6;
  1482. }
  1483. .outline-item:last-child {
  1484. border-bottom: none;
  1485. }
  1486. .chapter-num {
  1487. width: 48rpx;
  1488. height: 48rpx;
  1489. background: #4f46e5;
  1490. color: #ffffff;
  1491. border-radius: 50%;
  1492. display: flex;
  1493. align-items: center;
  1494. justify-content: center;
  1495. font-size: 24rpx;
  1496. font-weight: 600;
  1497. flex-shrink: 0;
  1498. }
  1499. .chapter-info {
  1500. flex: 1;
  1501. }
  1502. .chapter-title {
  1503. font-size: 28rpx;
  1504. font-weight: 600;
  1505. color: #1f2937;
  1506. margin-bottom: 6rpx;
  1507. display: block;
  1508. }
  1509. .chapter-summary {
  1510. font-size: 24rpx;
  1511. color: #6b7280;
  1512. display: block;
  1513. margin-bottom: 10rpx;
  1514. }
  1515. .chapter-points {
  1516. display: flex;
  1517. flex-wrap: wrap;
  1518. gap: 8rpx;
  1519. }
  1520. .point-tag {
  1521. font-size: 20rpx;
  1522. padding: 4rpx 12rpx;
  1523. background: #f3f4f6;
  1524. color: #6b7280;
  1525. border-radius: 6rpx;
  1526. }
  1527. .chapter-status {
  1528. font-size: 28rpx;
  1529. color: #9ca3af;
  1530. flex-shrink: 0;
  1531. }
  1532. .chapter-status.done {
  1533. color: #059669;
  1534. }
  1535. /* 操作卡片 */
  1536. .action-card {
  1537. margin-bottom: 24rpx;
  1538. }
  1539. .btn-row {
  1540. display: flex;
  1541. gap: 16rpx;
  1542. margin-bottom: 20rpx;
  1543. }
  1544. .action-btn {
  1545. flex: 1;
  1546. height: 88rpx;
  1547. background: #f3f4f6;
  1548. border-radius: 16rpx;
  1549. font-size: 28rpx;
  1550. color: #374151;
  1551. display: flex;
  1552. align-items: center;
  1553. justify-content: center;
  1554. border: none;
  1555. }
  1556. .action-btn.primary {
  1557. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1558. color: #ffffff;
  1559. }
  1560. .action-btn.full-width {
  1561. width: 100%;
  1562. flex: none;
  1563. margin-bottom: 16rpx;
  1564. }
  1565. .action-btn[disabled] {
  1566. opacity: 0.6;
  1567. }
  1568. .generating-tip {
  1569. text-align: center;
  1570. padding: 20rpx;
  1571. color: #6b7280;
  1572. font-size: 26rpx;
  1573. }
  1574. /* LangGraph 按钮 */
  1575. .action-btn.langgraph-btn {
  1576. background: linear-gradient(135deg, #10b981 0%, #059669 100%);
  1577. color: #ffffff;
  1578. }
  1579. /* 全屏弹窗 */
  1580. .modal-overlay {
  1581. position: fixed;
  1582. top: 0;
  1583. left: 0;
  1584. right: 0;
  1585. bottom: 0;
  1586. background: rgba(0, 0, 0, 0.5);
  1587. z-index: 1000;
  1588. display: flex;
  1589. align-items: flex-end;
  1590. }
  1591. .content-modal {
  1592. width: 100%;
  1593. height: 90vh;
  1594. background: #ffffff;
  1595. border-radius: 32rpx 32rpx 0 0;
  1596. display: flex;
  1597. flex-direction: column;
  1598. }
  1599. .modal-header {
  1600. display: flex;
  1601. align-items: center;
  1602. justify-content: space-between;
  1603. padding: 32rpx;
  1604. border-bottom: 1px solid #f3f4f6;
  1605. flex-shrink: 0;
  1606. }
  1607. .modal-title {
  1608. font-size: 32rpx;
  1609. font-weight: 600;
  1610. color: #1f2937;
  1611. }
  1612. .modal-close {
  1613. font-size: 40rpx;
  1614. color: #9ca3af;
  1615. padding: 8rpx;
  1616. }
  1617. .content-scroll {
  1618. flex: 1;
  1619. padding: 32rpx;
  1620. }
  1621. .content-body {
  1622. font-size: 28rpx;
  1623. line-height: 1.8;
  1624. color: #374151;
  1625. }
  1626. /* ==================== 目录视图 ==================== */
  1627. .toc-view {
  1628. position: fixed;
  1629. top: 0;
  1630. left: 0;
  1631. right: 0;
  1632. bottom: 0;
  1633. background: #f9fafb;
  1634. z-index: 200;
  1635. display: flex;
  1636. flex-direction: column;
  1637. }
  1638. .toc-view .nav-bar {
  1639. position: relative;
  1640. flex-shrink: 0;
  1641. }
  1642. .toc-content {
  1643. flex: 1;
  1644. padding: 24rpx 32rpx;
  1645. padding-top: calc(120rpx + env(safe-area-inset-top));
  1646. overflow-y: auto;
  1647. }
  1648. .toc-item {
  1649. display: flex;
  1650. align-items: center;
  1651. justify-content: space-between;
  1652. background: #ffffff;
  1653. border-radius: 16rpx;
  1654. padding: 28rpx 32rpx;
  1655. margin-bottom: 16rpx;
  1656. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
  1657. }
  1658. .toc-item.foreword,
  1659. .toc-item.afterword {
  1660. background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
  1661. }
  1662. .toc-item.foreword .toc-label,
  1663. .toc-item.afterword .toc-label {
  1664. font-size: 30rpx;
  1665. font-weight: 600;
  1666. color: #92400e;
  1667. }
  1668. .toc-item.chapter {
  1669. cursor: pointer;
  1670. }
  1671. .toc-item.chapter.pending {
  1672. opacity: 0.6;
  1673. }
  1674. .toc-item.chapter.done {
  1675. background: #ffffff;
  1676. }
  1677. .toc-left {
  1678. display: flex;
  1679. align-items: center;
  1680. gap: 20rpx;
  1681. flex: 1;
  1682. }
  1683. .toc-left .chapter-num {
  1684. width: 56rpx;
  1685. height: 56rpx;
  1686. background: #4f46e5;
  1687. color: #ffffff;
  1688. border-radius: 50%;
  1689. display: flex;
  1690. align-items: center;
  1691. justify-content: center;
  1692. font-size: 24rpx;
  1693. font-weight: 600;
  1694. flex-shrink: 0;
  1695. }
  1696. .toc-item.chapter.pending .chapter-num {
  1697. background: #9ca3af;
  1698. }
  1699. .toc-left .chapter-title {
  1700. font-size: 28rpx;
  1701. font-weight: 500;
  1702. color: #1f2937;
  1703. }
  1704. .toc-arrow {
  1705. font-size: 32rpx;
  1706. color: #9ca3af;
  1707. }
  1708. /* ==================== 章节详情视图 ==================== */
  1709. .chapter-detail-view {
  1710. position: fixed;
  1711. top: 0;
  1712. left: 0;
  1713. right: 0;
  1714. bottom: 0;
  1715. background: #f9fafb;
  1716. z-index: 200;
  1717. display: flex;
  1718. flex-direction: column;
  1719. }
  1720. .chapter-detail-view .nav-bar {
  1721. position: relative;
  1722. flex-shrink: 0;
  1723. }
  1724. .chapter-content {
  1725. flex: 1;
  1726. padding-top: calc(88rpx + env(safe-area-inset-top));
  1727. overflow: hidden;
  1728. }
  1729. .chapter-scroll {
  1730. height: 100%;
  1731. padding: 32rpx;
  1732. }
  1733. .chapter-header {
  1734. margin-bottom: 32rpx;
  1735. padding-bottom: 24rpx;
  1736. border-bottom: 1px solid #e5e7eb;
  1737. }
  1738. .chapter-title-large {
  1739. display: block;
  1740. font-size: 36rpx;
  1741. font-weight: 700;
  1742. color: #1f2937;
  1743. margin-bottom: 12rpx;
  1744. }
  1745. .chapter-word-count {
  1746. font-size: 24rpx;
  1747. color: #9ca3af;
  1748. }
  1749. .chapter-body {
  1750. background: #ffffff;
  1751. border-radius: 16rpx;
  1752. padding: 32rpx;
  1753. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
  1754. }
  1755. .chapter-text {
  1756. font-size: 30rpx;
  1757. line-height: 1.9;
  1758. color: #374151;
  1759. text-align: justify;
  1760. }
  1761. /* ==================== 音频生成相关样式 ==================== */
  1762. /* 书籍卡片操作区 */
  1763. .book-actions {
  1764. margin-top: 20rpx;
  1765. padding-top: 20rpx;
  1766. border-top: 1px solid #f3f4f6;
  1767. }
  1768. .audio-btn {
  1769. width: 100%;
  1770. height: 72rpx;
  1771. background: linear-gradient(135deg, #10b981 0%, #059669 100%);
  1772. border-radius: 12rpx;
  1773. font-size: 26rpx;
  1774. color: #ffffff;
  1775. display: flex;
  1776. align-items: center;
  1777. justify-content: center;
  1778. border: none;
  1779. }
  1780. .audio-btn[disabled] {
  1781. opacity: 0.6;
  1782. }
  1783. /* 视频按钮样式 */
  1784. .video-btn {
  1785. width: 100%;
  1786. height: 72rpx;
  1787. background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
  1788. border-radius: 12rpx;
  1789. font-size: 26rpx;
  1790. color: #ffffff;
  1791. display: flex;
  1792. align-items: center;
  1793. justify-content: center;
  1794. border: none;
  1795. margin-top: 16rpx;
  1796. }
  1797. .video-btn[disabled] {
  1798. opacity: 0.6;
  1799. }
  1800. /* 目录项操作区 */
  1801. .toc-actions {
  1802. display: flex;
  1803. align-items: center;
  1804. gap: 16rpx;
  1805. }
  1806. .chapter-info-col {
  1807. display: flex;
  1808. flex-direction: column;
  1809. gap: 6rpx;
  1810. }
  1811. .audio-status {
  1812. font-size: 20rpx;
  1813. color: #059669;
  1814. }
  1815. .mini-audio-btn {
  1816. width: 64rpx;
  1817. height: 64rpx;
  1818. background: linear-gradient(135deg, #10b981 0%, #059669 100%);
  1819. border-radius: 50%;
  1820. font-size: 28rpx;
  1821. display: flex;
  1822. align-items: center;
  1823. justify-content: center;
  1824. border: none;
  1825. padding: 0;
  1826. }
  1827. .mini-audio-btn[disabled] {
  1828. opacity: 0.6;
  1829. }
  1830. .mini-video-btn {
  1831. width: 64rpx;
  1832. height: 64rpx;
  1833. background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
  1834. border-radius: 50%;
  1835. font-size: 28rpx;
  1836. display: flex;
  1837. align-items: center;
  1838. justify-content: center;
  1839. border: none;
  1840. padding: 0;
  1841. }
  1842. .mini-video-btn[disabled] {
  1843. opacity: 0.6;
  1844. }
  1845. .video-status {
  1846. font-size: 20rpx;
  1847. color: #f5576c;
  1848. }
  1849. /* 额度监控 */
  1850. .quota-monitor {
  1851. margin-top: 20rpx;
  1852. padding: 16rpx;
  1853. background: rgba(16, 185, 129, 0.1);
  1854. border-radius: 12rpx;
  1855. }
  1856. .quota-monitor-title {
  1857. font-size: 24rpx;
  1858. font-weight: 600;
  1859. color: #059669;
  1860. margin-bottom: 8rpx;
  1861. display: block;
  1862. }
  1863. .quota-monitor-row {
  1864. display: flex;
  1865. justify-content: space-between;
  1866. font-size: 24rpx;
  1867. color: #374151;
  1868. }
  1869. .quota-overage-warning {
  1870. margin-top: 8rpx;
  1871. padding: 8rpx 12rpx;
  1872. background: rgba(239, 68, 68, 0.1);
  1873. border-radius: 8rpx;
  1874. }
  1875. .quota-overage-warning text {
  1876. font-size: 22rpx;
  1877. color: #dc2626;
  1878. }
  1879. /* 中断提示 */
  1880. .interrupted-tip {
  1881. margin-top: 20rpx;
  1882. padding: 20rpx;
  1883. background: rgba(245, 158, 11, 0.1);
  1884. border: 1px solid #fbbf24;
  1885. border-radius: 12rpx;
  1886. display: flex;
  1887. flex-direction: column;
  1888. gap: 8rpx;
  1889. }
  1890. .interrupted-icon {
  1891. font-size: 36rpx;
  1892. }
  1893. .interrupted-text {
  1894. font-size: 26rpx;
  1895. color: #92400e;
  1896. font-weight: 600;
  1897. }
  1898. .interrupted-hint {
  1899. font-size: 24rpx;
  1900. color: #b45309;
  1901. }
  1902. </style>