interactive.vue 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069
  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>
  11. <!-- 模式切换独立行 -->
  12. <view class="mode-bar">
  13. <text class="mode-switch" @click="switchToSimple">切换到一键模式</text>
  14. </view>
  15. </view>
  16. <!-- 步骤指示器 -->
  17. <view class="steps-bar">
  18. <view v-for="(step, i) in steps" :key="i" :class="['step-dot', currentStep >= i ? 'active' : '', currentStep > i ? 'done' : '']">
  19. <view class="dot-num">{{ currentStep > i ? '✓' : step.num }}</view>
  20. <text class="dot-label">{{ step.label }}</text>
  21. </view>
  22. <view class="step-line" :style="{ width: (currentStep / (steps.length - 1) * 100) + '%' }"></view>
  23. </view>
  24. <!-- 主内容区 -->
  25. <view class="main-content">
  26. <!-- ============ Step 1: 信息输入 ============ -->
  27. <view v-if="currentStep === 0" class="card">
  28. <view class="card-header">
  29. <text class="card-title">📝 第一步:基本信息</text>
  30. </view>
  31. <view class="form-item">
  32. <text class="form-label">书名 *</text>
  33. <input v-model="form.title" class="form-input" placeholder="例如:《时间是什么》" />
  34. </view>
  35. <view class="form-item">
  36. <text class="form-label">内容描述 *</text>
  37. <textarea v-model="form.description" class="form-textarea" placeholder="描述这本书的内容、主题、写作目的..." :maxlength="500" />
  38. <view class="ai-recommend-row">
  39. <button class="btn-ai-recommend" :disabled="!form.description.trim() || recommendLoading" @click="doSmartRecommend">
  40. {{ recommendLoading ? 'AI分析中...' : '🤖 AI智能推荐' }}
  41. </button>
  42. <text class="ai-recommend-hint">根据描述自动填充目标人群和书籍类型</text>
  43. </view>
  44. </view>
  45. <view class="form-item">
  46. <text class="form-label">书籍规模 *</text>
  47. <view class="scale-picker">
  48. <view
  49. v-for="scale in bookScales"
  50. :key="scale.value"
  51. :class="['scale-chip', form.bookScale === scale.value ? 'active' : '']"
  52. @click="form.bookScale = scale.value"
  53. >
  54. <text class="scale-label">{{ scale.label }}</text>
  55. <text class="scale-desc">{{ scale.chapters }} · {{ scale.audio }}</text>
  56. </view>
  57. </view>
  58. </view>
  59. <!-- 目标人群 -->
  60. <view class="form-item">
  61. <view class="label-with-badge">
  62. <text class="form-label">目标人群</text>
  63. <view class="required-badge">核心</view>
  64. </view>
  65. <text class="form-hint-small">指定读者,AI会据此调整语言风格和深度</text>
  66. <view class="chip-group" style="margin-top: 8rpx;">
  67. <view
  68. v-for="a in audiences"
  69. :key="a.value"
  70. :class="['chip', form.targetAudience === a.value ? 'active' : '']"
  71. @click="form.targetAudience = a.value"
  72. >
  73. <text class="chip-icon">{{ a.icon }}</text>
  74. <text class="chip-text">{{ a.label }}</text>
  75. <text class="chip-desc" style="font-size: 20rpx; margin-left: 4rpx;">{{ a.desc }}</text>
  76. </view>
  77. </view>
  78. </view>
  79. <view class="form-item">
  80. <text class="form-label">书籍类型</text>
  81. <view class="chip-group">
  82. <view
  83. v-for="bt in bookTypes"
  84. :key="bt.value"
  85. :class="['chip', form.bookType === bt.value ? 'active' : '']"
  86. @click="form.bookType = bt.value"
  87. >
  88. <text class="chip-icon">{{ bt.icon }}</text>
  89. <text class="chip-text">{{ bt.label }}</text>
  90. </view>
  91. </view>
  92. </view>
  93. <view class="form-item">
  94. <text class="form-label">大纲层级</text>
  95. <view class="chip-group">
  96. <view
  97. v-for="opt in outlineLevelOptions"
  98. :key="opt.value"
  99. :class="['chip', form.outlineLevel === opt.value ? 'active' : '']"
  100. @click="form.outlineLevel = opt.value"
  101. >
  102. <text class="chip-text">{{ opt.label }}</text>
  103. </view>
  104. </view>
  105. </view>
  106. <!-- 预估 -->
  107. <view v-if="bookEstimate" class="estimate-card">
  108. <view class="estimate-row">
  109. <text class="estimate-label">预估字数:</text>
  110. <text class="estimate-value">{{ bookEstimate.words.min }}~{{ bookEstimate.words.max }}字</text>
  111. </view>
  112. <view class="estimate-row">
  113. <text class="estimate-label">预估章节:</text>
  114. <text class="estimate-value">约{{ bookEstimate.estimatedChapters }}章</text>
  115. </view>
  116. </view>
  117. <view class="btn-group">
  118. <button class="btn-cancel" @click="goBack">取消</button>
  119. <button class="btn-primary" :disabled="!canProceed || stepLoading" @click="doCreateBook">
  120. {{ stepLoading ? '创建中...' : '下一步:AI分析' }}
  121. </button>
  122. </view>
  123. </view>
  124. <!-- ============ Step 2: Plan 审核 ============ -->
  125. <view v-if="currentStep === 1" class="card">
  126. <view class="card-header">
  127. <text class="card-title">🧠 第二步:AI 深度规划</text>
  128. <text v-if="planLoading" class="loading-hint">AI 正在分析...</text>
  129. </view>
  130. <!-- AI自动生成提示 -->
  131. <view v-if="!planLoading && planData" class="ai-generated-notice">
  132. <text class="notice-icon">✨</text>
  133. <text class="notice-text">AI 已根据你的描述自动生成了以下方案,可直接确认或提出修改意见后重新分析</text>
  134. </view>
  135. <view v-if="planLoading" class="loading-box">
  136. <view class="loading-spinner"></view>
  137. <text class="loading-text">AI 正在制定书籍规划,请稍候...</text>
  138. </view>
  139. <view v-else-if="planData" class="plan-review">
  140. <!-- 概要信息 -->
  141. <view v-if="planSummaryInfo.length" class="plan-summary">
  142. <text v-for="s in planSummaryInfo" :key="s.label" class="summary-tag">
  143. {{ s.icon }} {{ s.label }}: {{ s.value }}
  144. </text>
  145. </view>
  146. <!-- 黄金主线 -->
  147. <view class="plan-section">
  148. <view class="plan-section-header">
  149. <text class="plan-section-icon">🎯</text>
  150. <text class="plan-section-title">黄金主线</text>
  151. <text class="plan-section-hint">一句话概括本书的核心线索</text>
  152. </view>
  153. <textarea v-model="planData.goldenThread" class="plan-textarea" :maxlength="500" placeholder="AI生成的核心主线..." />
  154. </view>
  155. <!-- 叙事弧 -->
  156. <view class="plan-section">
  157. <view class="plan-section-header">
  158. <text class="plan-section-icon">📈</text>
  159. <text class="plan-section-title">叙事弧</text>
  160. <text class="plan-section-hint">AI将全书拆分为几个大的叙事阶段</text>
  161. </view>
  162. <view v-if="narrativeParts.length" class="narrative-parts">
  163. <view v-for="(part, pi) in narrativeParts" :key="pi" class="narrative-part-card">
  164. <view class="part-badge">{{ part.key }}</view>
  165. <view class="part-body">
  166. <view class="part-field">
  167. <text class="part-label">主题</text>
  168. <input v-model="part.theme" class="form-input-sm" />
  169. </view>
  170. <view class="part-field">
  171. <text class="part-label">覆盖章节</text>
  172. <text class="part-text">{{ (part.chapters || []).join(', ') || '—' }}</text>
  173. </view>
  174. <view class="part-field">
  175. <text class="part-label">目标</text>
  176. <textarea v-model="part.goal" class="form-textarea-xs" :maxlength="300" />
  177. </view>
  178. </view>
  179. </view>
  180. </view>
  181. <view v-else class="plan-empty">
  182. <text class="empty-text">AI 未生成叙事弧结构</text>
  183. </view>
  184. </view>
  185. <!-- 语调风格 -->
  186. <view class="plan-section">
  187. <view class="plan-section-header">
  188. <text class="plan-section-icon">🎵</text>
  189. <text class="plan-section-title">语调风格</text>
  190. </view>
  191. <view v-if="toneFields" class="tone-fields">
  192. <view class="tone-row">
  193. <text class="tone-label">基础语调</text>
  194. <input v-model="toneFields.base" class="form-input-sm" />
  195. </view>
  196. <view class="tone-row">
  197. <text class="tone-label">示例用语</text>
  198. <textarea v-model="toneFields.examples" class="form-textarea-xs" :maxlength="300" />
  199. </view>
  200. <view v-if="toneFields.avoidPatterns" class="tone-row">
  201. <text class="tone-label">避免模式</text>
  202. <view class="tag-list">
  203. <view v-for="(ap, api) in toneFields.avoidPatterns" :key="api" class="tag-item">
  204. <text class="tag-text">{{ ap }}</text>
  205. <text class="tag-remove" @click="removeAvoidPattern(api)">x</text>
  206. </view>
  207. <view class="tag-add" @click="addAvoidPattern">+ 添加</view>
  208. </view>
  209. </view>
  210. </view>
  211. <view v-else class="plan-empty">
  212. <text class="empty-text">AI 未生成语调配置</text>
  213. </view>
  214. </view>
  215. <!-- 受众校准 -->
  216. <view class="plan-section">
  217. <view class="plan-section-header">
  218. <text class="plan-section-icon">👥</text>
  219. <text class="plan-section-title">受众校准</text>
  220. </view>
  221. <view v-if="audienceFields" class="audience-fields">
  222. <view class="audience-row">
  223. <text class="audience-label">已有知识</text>
  224. <view class="tag-list">
  225. <text v-for="(ak, aki) in audienceFields.assumedKnowledge" :key="aki" class="tag-item-static">{{ ak }}</text>
  226. </view>
  227. </view>
  228. <view class="audience-row">
  229. <text class="audience-label">阅读痛点</text>
  230. <view class="tag-list">
  231. <text v-for="(pp, ppi) in audienceFields.painPoints" :key="ppi" class="tag-item-static">{{ pp }}</text>
  232. </view>
  233. </view>
  234. <view class="audience-row">
  235. <text class="audience-label">期望收获</text>
  236. <textarea v-model="audienceFields.desiredOutcome" class="form-textarea-xs" :maxlength="300" />
  237. </view>
  238. </view>
  239. <view v-else class="plan-empty">
  240. <text class="empty-text">AI 未生成受众校准</text>
  241. </view>
  242. </view>
  243. <!-- 用户自然语言反馈 -->
  244. <view class="plan-section feedback-section">
  245. <view class="plan-section-header">
  246. <text class="plan-section-icon">💬</text>
  247. <text class="plan-section-title">修改意见</text>
  248. <text class="plan-section-hint">用自然语言告诉AI你想怎么调整</text>
  249. </view>
  250. <textarea v-model="userFeedback" class="feedback-textarea" :maxlength="500" placeholder="比如:「语调太严肃了,改得更活泼一点」「再加一章关于水星探索历史的」" />
  251. <view class="feedback-hint">
  252. <text v-if="userFeedback.trim()">点击「重新分析」AI会参考你的意见重新规划</text>
  253. <text v-else>输入修改意见后点击「重新分析」,AI会根据你的反馈调整方案</text>
  254. </view>
  255. </view>
  256. </view>
  257. <view v-if="planError" class="error-box">
  258. <text class="error-text">{{ planError }}</text>
  259. </view>
  260. <view v-if="!planLoading" class="btn-group">
  261. <button class="btn-primary" :disabled="stepLoading" @click="doSavePlan">
  262. {{ stepLoading ? '保存中...' : '使用此方案,生成大纲' }}
  263. </button>
  264. <button class="btn-secondary" :disabled="regeneratingPlan" @click="doRefinePlan">
  265. {{ regeneratingPlan ? '重新分析中...' : '重新分析' }}
  266. </button>
  267. <button class="btn-cancel" @click="currentStep = 0">返回修改</button>
  268. </view>
  269. </view>
  270. <!-- ============ Step 3: 大纲审核 ============ -->
  271. <view v-if="currentStep === 2" class="card">
  272. <view class="card-header">
  273. <text class="card-title">📚 第三步:审核大纲</text>
  274. <text v-if="outlineLoading" class="loading-hint">AI 正在生成大纲...</text>
  275. </view>
  276. <view v-if="outlineLoading" class="loading-box">
  277. <view class="loading-spinner"></view>
  278. <text class="loading-text">AI 正在生成详细大纲,请稍候...</text>
  279. </view>
  280. <view v-else-if="outlineChapters.length > 0" class="outline-review">
  281. <text class="outline-subtitle">共 {{ outlineChapters.length }} 章,点击展开编辑详情</text>
  282. <view v-for="(ch, ci) in outlineChapters" :key="ci" class="chapter-card">
  283. <view class="chapter-header" @click="toggleChapterExpand(ci)">
  284. <text class="chapter-num">第{{ ch.number }}章</text>
  285. <text class="expand-icon">{{ expandedChapters[ci] ? '▼' : '▶' }}</text>
  286. </view>
  287. <view v-if="expandedChapters[ci]" class="chapter-edit">
  288. <view class="form-item">
  289. <text class="form-label-sm">章节标题</text>
  290. <input v-model="ch.title" class="form-input-sm" />
  291. </view>
  292. <view class="form-item">
  293. <text class="form-label-sm">摘要</text>
  294. <textarea v-model="ch.summary" class="form-textarea-sm" :maxlength="500" />
  295. </view>
  296. <view class="form-item">
  297. <text class="form-label-sm">预估字数</text>
  298. <input v-model.number="ch.estimatedWords" class="form-input-sm" type="number" />
  299. </view>
  300. <view class="form-item">
  301. <text class="form-label-sm">写作指令</text>
  302. <textarea v-model="ch.writingInstructions" class="form-textarea-sm" :maxlength="500" placeholder="AI写作时会参考这些指令..." />
  303. </view>
  304. <view class="btn-row-sm">
  305. <button class="btn-delete-sm" @click="removeChapter(ci)">删除此章</button>
  306. <button class="btn-move-sm" :disabled="ci === 0" @click="moveChapter(ci, -1)">↑ 上移</button>
  307. <button class="btn-move-sm" :disabled="ci >= outlineChapters.length - 1" @click="moveChapter(ci, 1)">↓ 下移</button>
  308. </view>
  309. </view>
  310. </view>
  311. <button class="btn-add-chapter" @click="addChapter">+ 添加章节</button>
  312. </view>
  313. <view v-if="outlineError" class="error-box">
  314. <text class="error-text">{{ outlineError }}</text>
  315. </view>
  316. <view v-if="!outlineLoading" class="btn-group">
  317. <button class="btn-cancel" @click="currentStep = 1">返回修改</button>
  318. <button class="btn-secondary" :disabled="regeneratingOutline" @click="doGenerateOutline">
  319. {{ regeneratingOutline ? '重新生成中...' : '重新生成大纲' }}
  320. </button>
  321. <button class="btn-primary" :disabled="stepLoading" @click="doSaveOutline">
  322. {{ stepLoading ? '保存中...' : '确认,开始写内容' }}
  323. </button>
  324. </view>
  325. </view>
  326. <!-- ============ Step 4: 内容生成 ============ -->
  327. <view v-if="currentStep === 3" class="card">
  328. <view class="card-header">
  329. <text class="card-title">✍️ 第四步:生成内容</text>
  330. </view>
  331. <view class="generate-box">
  332. <view class="generate-stage" :class="{ 'completed': genProgress > 0 }">
  333. <text class="stage-icon">{{ genProgress > 0 ? '✅' : '⏳' }}</text>
  334. <view class="stage-info">
  335. <text class="stage-title">并行内容生成</text>
  336. <text class="stage-hint">{{ genProgress > 0 ? '已完成' : '等待开始...' }}</text>
  337. </view>
  338. </view>
  339. <view class="generate-stage" :class="{ 'completed': genProgress >= 100 }">
  340. <text class="stage-icon">{{ genProgress >= 100 ? '✅' : '⏳' }}</text>
  341. <view class="stage-info">
  342. <text class="stage-title">连贯性编辑</text>
  343. <text class="stage-hint">{{ genProgress >= 100 ? '已完成' : '等待中...' }}</text>
  344. </view>
  345. </view>
  346. <view v-if="genStarted && genProgress < 100" class="progress-bar-wrap">
  347. <view class="progress-bar" :style="{ width: genProgress + '%' }"></view>
  348. <text class="progress-text">生成进度 {{ genProgress }}%</text>
  349. </view>
  350. <view v-if="genError" class="error-box">
  351. <text class="error-text">{{ genError }}</text>
  352. </view>
  353. </view>
  354. <view v-if="genStarted && genProgress < 100" class="btn-group">
  355. <button class="btn-cancel" :disabled="true">请等待生成完成...</button>
  356. </view>
  357. <view v-if="genProgress >= 100" class="btn-group">
  358. <button class="btn-primary" @click="goToDetail">查看书籍详情</button>
  359. </view>
  360. </view>
  361. </view>
  362. </view>
  363. </template>
  364. <script setup lang="ts">
  365. import { ref, computed, onMounted } from 'vue';
  366. import * as api from '../../utils/book-generator-api';
  367. import { getApiBaseUrl } from '../../utils/config';
  368. const BASE_URL = getApiBaseUrl();
  369. // ============ 步骤状态 ============
  370. const currentStep = ref(0);
  371. const steps = [
  372. { num: 1, label: '信息输入' },
  373. { num: 2, label: 'AI规划' },
  374. { num: 3, label: '审核大纲' },
  375. { num: 4, label: '生成内容' },
  376. ];
  377. // ============ Step 1: 表单 ============
  378. const stepLoading = ref(false);
  379. const form = ref({
  380. title: '',
  381. description: '',
  382. bookScale: '80000',
  383. bookType: 'auto',
  384. outlineLevel: 'auto',
  385. targetAudience: '',
  386. });
  387. const bookId = ref('');
  388. const bookEstimate = ref<{ words: { min: number; max: number }; audioMinutes: { min: number; max: number }; estimatedChapters: number } | null>(null);
  389. const bookScales = [
  390. { value: '340000', label: '上2', chapters: '170章', audio: '约28小时' },
  391. { value: '210000', label: '上1', chapters: '105章', audio: '约17小时' },
  392. { value: '130000', label: '标准', chapters: '65章', audio: '约11小时' },
  393. { value: '80000', label: '下1', chapters: '40章', audio: '约7小时' },
  394. { value: '50000', label: '下2', chapters: '25章', audio: '约4小时' },
  395. { value: '31000', label: '下3', chapters: '16章', audio: '约2.5小时' },
  396. { value: '19000', label: '下4', chapters: '10章', audio: '约1.5小时' },
  397. { value: '12000', label: '下5', chapters: '6章', audio: '约1小时' },
  398. { value: '7000', label: '下6', chapters: '4章', audio: '约35分钟' },
  399. { value: '4000', label: '下7', chapters: '2章', audio: '约20分钟' },
  400. { value: '1000', label: '下10', chapters: '1章', audio: '约5分钟' },
  401. ];
  402. const bookTypes = [
  403. { value: 'auto', label: '自动检测', icon: '🤖' },
  404. { value: '教材', label: '教材/学术', icon: '📚' },
  405. { value: '技术教程', label: '技术教程', icon: '💻' },
  406. { value: '小说', label: '小说/文学', icon: '📖' },
  407. { value: '商业', label: '商业/经管', icon: '📊' },
  408. { value: '科普', label: '科普/大众', icon: '🔬' },
  409. ];
  410. const audiences = [
  411. { value: '儿童', label: '儿童', icon: '👶', desc: '6-12岁,形象生动' },
  412. { value: '青少年', label: '青少年', icon: '🧑', desc: '13-18岁,通俗易懂' },
  413. { value: '大学生', label: '大学生', icon: '🎓', desc: '系统专业,有理论' },
  414. { value: '专业人士', label: '专业人士', icon: '💼', desc: '从业者,实用深入' },
  415. { value: '研究生', label: '研究生', icon: '🔍', desc: '研究级别,前沿深入' },
  416. { value: '大众读者', label: '大众读者', icon: '👥', desc: '通俗普及,有趣味' },
  417. ];
  418. const outlineLevelOptions = [
  419. { value: 'auto', label: '自动' },
  420. { value: '1', label: '1层' },
  421. { value: '2', label: '2层' },
  422. { value: '3', label: '3层' },
  423. ];
  424. const canProceed = computed(() => form.value.title.trim() && form.value.description.trim());
  425. // AI智能推荐
  426. const recommendLoading = ref(false);
  427. async function doSmartRecommend() {
  428. if (!form.value.description.trim() || recommendLoading.value) return;
  429. recommendLoading.value = true;
  430. try {
  431. const response = await uni.request({
  432. url: `${BASE_URL}/book-generator/langgraph/smart-recommend`,
  433. method: 'POST',
  434. data: { description: form.value.description, title: form.value.title },
  435. });
  436. const res = response.data as any;
  437. if (res.code === 0 && res.data) {
  438. const data = res.data;
  439. // 自动填充目标人群
  440. if (data.targetAudience && data.targetAudience !== '通用') {
  441. const matched = audiences.find(a => a.value === data.targetAudience || a.label === data.targetAudience);
  442. if (matched) {
  443. form.value.targetAudience = matched.value;
  444. }
  445. }
  446. // 自动填充书籍类型
  447. if (data.industry || data.style) {
  448. const typeHint = data.industry || data.style;
  449. const matched = bookTypes.find(t => t.label.includes(typeHint) || typeHint.includes(t.label));
  450. if (matched && matched.value !== 'auto') {
  451. form.value.bookType = matched.value;
  452. }
  453. }
  454. uni.showToast({ title: `推荐: ${data.reason || '已自动填充'}`, icon: 'none', duration: 2000 });
  455. }
  456. } catch (e: any) {
  457. uni.showToast({ title: '推荐失败,请手动选择', icon: 'none' });
  458. } finally {
  459. recommendLoading.value = false;
  460. }
  461. }
  462. // ============ Step 2: Plan ============
  463. const planLoading = ref(false);
  464. const planData = ref<any>(null);
  465. const planRaw = ref('');
  466. const planError = ref('');
  467. const regeneratingPlan = ref(false);
  468. const userFeedback = ref('');
  469. // 解析叙事弧为 flat array
  470. const narrativeParts = computed(() => {
  471. const arc = planData.value?.narrativeArc;
  472. if (!arc || typeof arc !== 'object') return [];
  473. return Object.entries(arc).map(([key, val]: [string, any]) => ({
  474. key,
  475. theme: val.theme || '',
  476. chapters: val.chapters || [],
  477. goal: val.goal || '',
  478. }));
  479. });
  480. // 解析语调配置
  481. const toneFields = computed(() => {
  482. const tp = planData.value?.toneProfile;
  483. if (!tp || typeof tp !== 'object') return null;
  484. return {
  485. base: tp.base || '',
  486. examples: tp.examples || '',
  487. avoidPatterns: Array.isArray(tp.avoidPatterns) ? [...tp.avoidPatterns] : [],
  488. };
  489. });
  490. // 解析受众校准
  491. const audienceFields = computed(() => {
  492. const ac = planData.value?.audienceCalibration;
  493. if (!ac || typeof ac !== 'object') return null;
  494. return {
  495. assumedKnowledge: Array.isArray(ac.assumedKnowledge) ? ac.assumedKnowledge : [],
  496. painPoints: Array.isArray(ac.painPoints) ? ac.painPoints : [],
  497. desiredOutcome: ac.desiredOutcome || '',
  498. };
  499. });
  500. // 概要标签
  501. const summaryMeta = [
  502. { key: 'bookType', label: '类型', icon: '📖' },
  503. { key: 'writingStyle', label: '风格', icon: '✏️' },
  504. { key: 'contentDepth', label: '深度', icon: '📊' },
  505. { key: 'targetAudienceAnalysis', label: '目标读者', icon: '🎯' },
  506. { key: 'bookTypeAnalysis', label: '结构分析', icon: '🔍' },
  507. ];
  508. const planSummaryInfo = computed(() =>
  509. summaryMeta
  510. .filter(m => planData.value?.[m.key] && typeof planData.value[m.key] === 'string')
  511. .map(m => ({ ...m, value: planData.value[m.key] }))
  512. );
  513. // ============ Step 3: Outline ============
  514. const outlineLoading = ref(false);
  515. const outlineChapters = ref<any[]>([]);
  516. const outlineError = ref('');
  517. const regeneratingOutline = ref(false);
  518. const expandedChapters = ref<Record<number, boolean>>({});
  519. // ============ Step 4: Generation ============
  520. const genStarted = ref(false);
  521. const genProgress = ref(0);
  522. const genError = ref('');
  523. let progressTimer: any = null;
  524. // ============ 方法 ============
  525. function goBack() {
  526. if (currentStep.value === 0) {
  527. uni.navigateBack({ delta: 1 });
  528. return;
  529. }
  530. currentStep.value--;
  531. }
  532. function switchToSimple() {
  533. uni.navigateTo({ url: '/pages/book-generator/create' });
  534. }
  535. async function fetchEstimate() {
  536. try {
  537. const response = await uni.request({
  538. url: `${BASE_URL}/book-generator/langgraph/estimate?scale=${form.value.bookScale}&userId=1`,
  539. method: 'GET',
  540. });
  541. const res = response.data as any;
  542. if (res.code === 0 && res.data?.words) {
  543. bookEstimate.value = {
  544. words: res.data.words,
  545. audioMinutes: res.data.audioMinutes,
  546. estimatedChapters: res.data.estimatedChapters,
  547. };
  548. }
  549. } catch (e) { /* ignore */ }
  550. }
  551. // Step 1: 创建书籍(不自动生成)
  552. async function doCreateBook() {
  553. if (!canProceed.value) return;
  554. stepLoading.value = true;
  555. try {
  556. const genLevel = form.value.outlineLevel === 'auto' ? undefined : parseInt(form.value.outlineLevel);
  557. const bookType = form.value.bookType === 'auto' ? undefined : form.value.bookType;
  558. const book = await api.createBookInteractive({
  559. title: form.value.title,
  560. description: form.value.targetAudience ? form.value.description + '\n目标人群:' + form.value.targetAudience : form.value.description,
  561. bookScale: form.value.bookScale,
  562. bookType,
  563. genLevel,
  564. });
  565. bookId.value = book.id;
  566. currentStep.value = 1;
  567. // 自动触发 Plan 生成
  568. doGeneratePlan();
  569. } catch (e: any) {
  570. uni.showToast({ title: e.message || '创建失败', icon: 'none' });
  571. } finally {
  572. stepLoading.value = false;
  573. }
  574. }
  575. // Step 2: 生成 Plan (异步轮询)
  576. let planPollTimer: ReturnType<typeof setInterval> | null = null;
  577. function stopPlanPolling() {
  578. if (planPollTimer) { clearInterval(planPollTimer); planPollTimer = null; }
  579. }
  580. async function doGeneratePlan(feedback?: string) {
  581. planLoading.value = true;
  582. planError.value = '';
  583. regeneratingPlan.value = true;
  584. stopPlanPolling();
  585. try {
  586. // 1. 启动异步生成
  587. await api.startInteractivePlan(bookId.value, feedback);
  588. // 2. 轮询等待结果(最多等待 180s)
  589. const startTime = Date.now();
  590. planPollTimer = setInterval(async () => {
  591. try {
  592. const res = await api.pollInteractivePlan(bookId.value);
  593. if (res.genStage === 'plan_failed') {
  594. stopPlanPolling();
  595. planError.value = res.error || 'AI 规划失败';
  596. planLoading.value = false;
  597. regeneratingPlan.value = false;
  598. return;
  599. }
  600. if (res.genStage === 'plan_ready' && res.planData) {
  601. stopPlanPolling();
  602. planRaw.value = res.rawPlan || '';
  603. planData.value = res.planData;
  604. planLoading.value = false;
  605. regeneratingPlan.value = false;
  606. return;
  607. }
  608. // 超时检查
  609. if (Date.now() - startTime > 600000) {
  610. stopPlanPolling();
  611. planError.value = 'AI 规划超时,请重新分析';
  612. planLoading.value = false;
  613. regeneratingPlan.value = false;
  614. }
  615. } catch { /* ignore polling errors */ }
  616. }, 3000);
  617. } catch (e: any) {
  618. planError.value = e.message || '启动失败';
  619. planLoading.value = false;
  620. regeneratingPlan.value = false;
  621. }
  622. }
  623. // Step 2: 保存 Plan
  624. async function doSavePlan() {
  625. stepLoading.value = true;
  626. try {
  627. const dataToSave: any = { ...planData.value };
  628. if (narrativeParts.value.length) {
  629. const arc: any = {};
  630. narrativeParts.value.forEach(p => { arc[p.key] = { theme: p.theme, chapters: p.chapters, goal: p.goal }; });
  631. dataToSave.narrativeArc = arc;
  632. }
  633. if (toneFields.value) dataToSave.toneProfile = { ...toneFields.value };
  634. if (audienceFields.value) dataToSave.audienceCalibration = { ...audienceFields.value };
  635. await api.updateInteractivePlan(bookId.value, JSON.stringify(dataToSave));
  636. currentStep.value = 2;
  637. doGenerateOutline();
  638. } catch (e: any) {
  639. uni.showToast({ title: e.message || '保存失败', icon: 'none' });
  640. } finally {
  641. stepLoading.value = false;
  642. }
  643. }
  644. async function doRefinePlan() {
  645. if (regeneratingPlan.value) return;
  646. const dataToSave: any = { ...planData.value };
  647. if (narrativeParts.value.length) {
  648. const arc: any = {};
  649. narrativeParts.value.forEach(p => { arc[p.key] = { theme: p.theme, chapters: p.chapters, goal: p.goal }; });
  650. dataToSave.narrativeArc = arc;
  651. }
  652. if (toneFields.value) dataToSave.toneProfile = { ...toneFields.value };
  653. if (audienceFields.value) dataToSave.audienceCalibration = { ...audienceFields.value };
  654. try { await api.updateInteractivePlan(bookId.value, JSON.stringify(dataToSave)); } catch { /* ignore */ }
  655. planData.value = null;
  656. planRaw.value = '';
  657. planError.value = '';
  658. doGeneratePlan(userFeedback.value);
  659. }
  660. function removeAvoidPattern(index: number) {
  661. if (toneFields.value?.avoidPatterns) toneFields.value.avoidPatterns.splice(index, 1);
  662. }
  663. function addAvoidPattern() {
  664. if (toneFields.value?.avoidPatterns) toneFields.value.avoidPatterns.push('新避免项');
  665. }
  666. // Step 3: 生成 Outline (异步轮询)
  667. let outlinePollTimer: ReturnType<typeof setInterval> | null = null;
  668. function stopOutlinePolling() {
  669. if (outlinePollTimer) { clearInterval(outlinePollTimer); outlinePollTimer = null; }
  670. }
  671. async function doGenerateOutline() {
  672. outlineLoading.value = true;
  673. outlineError.value = '';
  674. regeneratingOutline.value = true;
  675. stopOutlinePolling();
  676. try {
  677. // 1. 启动异步生成
  678. await api.startInteractiveOutline(bookId.value);
  679. // 2. 轮询等待结果(最多等待 180s)
  680. const startTime = Date.now();
  681. outlinePollTimer = setInterval(async () => {
  682. try {
  683. const res = await api.pollInteractiveOutline(bookId.value);
  684. if (res.genStage === 'outline_failed') {
  685. stopOutlinePolling();
  686. outlineError.value = res.error || '大纲生成失败';
  687. outlineLoading.value = false;
  688. regeneratingOutline.value = false;
  689. return;
  690. }
  691. if (res.genStage === 'outline_ready' && res.outlineData?.chapters) {
  692. stopOutlinePolling();
  693. outlineChapters.value = res.outlineData.chapters.map((ch: any, i: number) => ({
  694. number: ch.number || (i + 1),
  695. title: ch.title || '',
  696. summary: ch.summary || '',
  697. estimatedWords: ch.estimatedWords || 2000,
  698. writingInstructions: ch.writingInstructions || '',
  699. sections: ch.sections || [],
  700. subsections: ch.subsections || [],
  701. }));
  702. // 默认展开前3章
  703. const exp: Record<number, boolean> = {};
  704. outlineChapters.value.forEach((_: any, i: number) => { if (i < 3) exp[i] = true; });
  705. expandedChapters.value = exp;
  706. outlineLoading.value = false;
  707. regeneratingOutline.value = false;
  708. return;
  709. }
  710. // 超时检查
  711. if (Date.now() - startTime > 600000) {
  712. stopOutlinePolling();
  713. outlineError.value = '大纲生成超时,请重新生成';
  714. outlineLoading.value = false;
  715. regeneratingOutline.value = false;
  716. }
  717. } catch { /* ignore polling errors */ }
  718. }, 3000);
  719. } catch (e: any) {
  720. outlineError.value = e.message || '启动失败';
  721. outlineLoading.value = false;
  722. regeneratingOutline.value = false;
  723. }
  724. }
  725. // Step 3: 章节操作
  726. function toggleChapterExpand(i: number) {
  727. expandedChapters.value = { ...expandedChapters.value, [i]: !expandedChapters.value[i] };
  728. }
  729. function removeChapter(i: number) {
  730. outlineChapters.value.splice(i, 1);
  731. // 重新编号
  732. outlineChapters.value.forEach((ch: any, idx: number) => { ch.number = idx + 1; });
  733. }
  734. function moveChapter(i: number, dir: number) {
  735. const target = i + dir;
  736. if (target < 0 || target >= outlineChapters.value.length) return;
  737. const tmp = outlineChapters.value[i];
  738. outlineChapters.value[i] = outlineChapters.value[target];
  739. outlineChapters.value[target] = tmp;
  740. // 重新编号
  741. outlineChapters.value.forEach((ch: any, idx: number) => { ch.number = idx + 1; });
  742. }
  743. function addChapter() {
  744. const newNum = outlineChapters.value.length + 1;
  745. outlineChapters.value.push({
  746. number: newNum,
  747. title: `第${newNum}章`,
  748. summary: '',
  749. estimatedWords: 2000,
  750. writingInstructions: '',
  751. sections: [],
  752. subsections: [],
  753. });
  754. }
  755. // Step 3: 保存 Outline
  756. async function doSaveOutline() {
  757. stepLoading.value = true;
  758. try {
  759. const outlineObj = { chapters: outlineChapters.value };
  760. await api.updateInteractiveOutline(bookId.value, outlineObj);
  761. currentStep.value = 3;
  762. doGenerateContent();
  763. } catch (e: any) {
  764. uni.showToast({ title: e.message || '保存失败', icon: 'none' });
  765. } finally {
  766. stepLoading.value = false;
  767. }
  768. }
  769. // Step 4: 生成内容
  770. async function doGenerateContent() {
  771. genStarted.value = true;
  772. genProgress.value = 0;
  773. genError.value = '';
  774. try {
  775. await api.generateInteractiveContent(bookId.value);
  776. // 开始轮询进度
  777. startProgressPolling();
  778. } catch (e: any) {
  779. genError.value = e.message || '生成失败';
  780. }
  781. }
  782. function startProgressPolling() {
  783. progressTimer = setInterval(async () => {
  784. try {
  785. const response = await uni.request({
  786. url: `${BASE_URL}/book-generator/langgraph/books/${bookId.value}/progress`,
  787. method: 'GET',
  788. });
  789. const res = response.data as any;
  790. if (res.code === 0) {
  791. genProgress.value = Math.min(res.data.progress || 0, 100);
  792. if (genProgress.value >= 100) {
  793. genProgress.value = 100;
  794. stopProgressPolling();
  795. }
  796. }
  797. } catch (e) { /* ignore */ }
  798. }, 3000);
  799. }
  800. function stopProgressPolling() {
  801. if (progressTimer) {
  802. clearInterval(progressTimer);
  803. progressTimer = null;
  804. }
  805. }
  806. function goToDetail() {
  807. uni.redirectTo({ url: `/pages/book-generator/detail?id=${bookId.value}` });
  808. }
  809. onMounted(() => {
  810. fetchEstimate();
  811. });
  812. </script>
  813. <style scoped>
  814. .page { min-height: 100vh; background: #f9fafb; }
  815. .nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(255,255,255,0.88); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-bottom: 0.5rpx solid rgba(0,0,0,0.06); }
  816. .nav-content { display: flex; align-items: center; height: 88rpx; padding: 0 32rpx; padding-top: env(safe-area-inset-top); }
  817. .nav-left { width: 60rpx; flex-shrink: 0; cursor: pointer; }
  818. .page-title { font-size: 32rpx; font-weight: 700; color: var(--color-text-primary, #111827); text-align: center; flex: 1; }
  819. .back-icon { font-size: 36rpx; color: var(--color-text-primary, #111827); }
  820. /* 模式切换独立行 */
  821. .mode-bar { padding: 0 32rpx 16rpx 32rpx; }
  822. .mode-switch { display: flex; align-items: center; justify-content: center; width: 100%; height: 72rpx; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: #fff; border-radius: 12rpx; font-size: 26rpx; font-weight: 600; }
  823. /* 覆盖 steps-bar 顶部间距 — nav-bar 现在更高了 */
  824. .steps-bar { display: flex; align-items: center; justify-content: center; padding: 24rpx 32rpx; padding-top: calc(88rpx + 16rpx + 72rpx + 16rpx + env(safe-area-inset-top) + 16rpx); background: #fff; position: relative; gap: 60rpx; }
  825. .step-dot { display: flex; flex-direction: column; align-items: center; gap: 8rpx; z-index: 1; }
  826. .dot-num { width: 48rpx; height: 48rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 24rpx; font-weight: 600; background: #e5e7eb; color: #9ca3af; transition: all .3s; }
  827. .step-dot.active .dot-num { background: #4f46e5; color: #fff; }
  828. .step-dot.done .dot-num { background: #10b981; color: #fff; }
  829. .dot-label { font-size: 20rpx; color: #9ca3af; white-space: nowrap; }
  830. .step-dot.active .dot-label { color: #4f46e5; font-weight: 600; }
  831. .step-line { position: absolute; top: calc(48rpx + 12rpx); left: calc(32rpx + 24rpx); height: 4rpx; background: #4f46e5; z-index: 0; transition: width .5s; }
  832. .main-content { padding-top: 16rpx; padding-bottom: 48rpx; }
  833. .card { margin: 24rpx; padding: 32rpx; background: #ffffff; border-radius: 16rpx; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
  834. .card-header { margin-bottom: 32rpx; display: flex; align-items: center; justify-content: space-between; }
  835. .card-title { font-size: 36rpx; font-weight: 700; color: #1f2937; }
  836. .loading-hint { font-size: 24rpx; color: #4f46e5; }
  837. .form-item { margin-bottom: 24rpx; }
  838. .form-label { font-size: 28rpx; font-weight: 600; color: #374151; margin-bottom: 12rpx; display: block; }
  839. .form-hint-small { font-size: 24rpx; color: #9ca3af; margin-bottom: 16rpx; display: block; }
  840. .label-with-badge { display: flex; align-items: center; gap: 12rpx; margin-bottom: 12rpx; }
  841. .required-badge { padding: 4rpx 12rpx; background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white; border-radius: 8rpx; font-size: 20rpx; font-weight: 500; display: inline-block; }
  842. .chip-desc { font-size: 20rpx; color: inherit; }
  843. .form-label-sm { font-size: 24rpx; font-weight: 600; color: #374151; margin-bottom: 8rpx; display: block; }
  844. .form-input { width: 100%; height: 88rpx; padding: 0 24rpx; background: var(--color-bg-surface, #f9fafb); border: 2rpx solid var(--color-bg-divider, #e5e7eb); border-radius: 12rpx; font-size: 28rpx; transition: border-color 0.2s, box-shadow 0.2s; }
  845. .form-input:focus { border-color: #4f46e5; box-shadow: 0 0 0 4rpx rgba(79,70,229,0.08); }
  846. .form-input-sm { width: 100%; height: 72rpx; padding: 0 16rpx; background: var(--color-bg-surface, #f9fafb); border: 2rpx solid var(--color-bg-divider, #e5e7eb); border-radius: 8rpx; font-size: 26rpx; transition: border-color 0.2s; }
  847. .form-input-sm:focus { border-color: #4f46e5; }
  848. .form-textarea { width: 100%; min-height: 160rpx; padding: 24rpx; background: #f9fafb; border: 2rpx solid #e5e7eb; border-radius: 12rpx; font-size: 28rpx; line-height: 1.6; }
  849. .form-textarea-sm { width: 100%; min-height: 100rpx; padding: 16rpx; background: #f9fafb; border: 2rpx solid #e5e7eb; border-radius: 8rpx; font-size: 24rpx; line-height: 1.5; }
  850. .chip-group { display: flex; flex-wrap: wrap; gap: 12rpx; }
  851. .chip { display: flex; align-items: center; gap: 8rpx; padding: 12rpx 20rpx; background: var(--color-bg-surface, #f3f4f6); border-radius: 24rpx; font-size: 24rpx; color: #4b5563; transition: all .2s; }
  852. .chip:active { transform: scale(0.96); }
  853. .chip.active { background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: white; }
  854. .chip-icon { font-size: 24rpx; }
  855. .chip-text { font-size: 24rpx; }
  856. .scale-picker { display: flex; flex-wrap: wrap; gap: 12rpx; }
  857. .scale-chip { display: flex; flex-direction: column; align-items: center; gap: 4rpx; padding: 12rpx 16rpx; background: var(--color-bg-surface, #f3f4f6); border-radius: 12rpx; min-width: 100rpx; transition: all .2s; }
  858. .scale-chip:active { transform: scale(0.96); }
  859. .scale-chip.active { background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: white; }
  860. .scale-chip.active .scale-desc { color: rgba(255,255,255,0.7); }
  861. .scale-label { font-size: 24rpx; font-weight: 600; }
  862. .scale-desc { font-size: 20rpx; color: #9ca3af; }
  863. .estimate-card { padding: 24rpx; background: rgba(79,70,229,.05); border-radius: 12rpx; margin-bottom: 24rpx; }
  864. .estimate-row { display: flex; justify-content: space-between; padding: 8rpx 0; }
  865. .estimate-label { font-size: 24rpx; color: #6b7280; }
  866. .estimate-value { font-size: 24rpx; color: #4f46e5; font-weight: 600; }
  867. .btn-group { display: flex; flex-direction: column; gap: 12rpx; margin-top: 32rpx; }
  868. .btn-primary { width: 100%; height: 88rpx; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: white; border-radius: 12rpx; font-size: 28rpx; font-weight: 600; border: none; display: flex; align-items: center; justify-content: center; white-space: nowrap; transition: transform 0.15s, opacity 0.15s; }
  869. .btn-primary:active { transform: scale(0.98); opacity: 0.9; }
  870. .btn-secondary { width: 100%; height: 88rpx; background: var(--color-bg-surface, #f3f4f6); color: #4b5563; border-radius: 12rpx; font-size: 28rpx; font-weight: 600; border: none; display: flex; align-items: center; justify-content: center; white-space: nowrap; transition: transform 0.15s, background 0.15s; }
  871. .btn-secondary:active { background: var(--color-bg-hover, #e5e7eb); transform: scale(0.98); }
  872. .btn-cancel { width: 100%; height: 88rpx; background: var(--color-bg-surface, #f3f4f6); color: #6b7280; border-radius: 12rpx; font-size: 28rpx; border: none; display: flex; align-items: center; justify-content: center; white-space: nowrap; transition: transform 0.15s, background 0.15s; }
  873. .btn-cancel:active { background: var(--color-bg-hover, #e5e7eb); transform: scale(0.98); }
  874. .btn-row-sm { display: flex; gap: 8rpx; margin-top: 12rpx; }
  875. .btn-delete-sm { padding: 8rpx 16rpx; background: #fef2f2; color: #ef4444; border-radius: 8rpx; font-size: 22rpx; border: none; }
  876. .btn-move-sm { padding: 8rpx 16rpx; background: #f3f4f6; color: #6b7280; border-radius: 8rpx; font-size: 22rpx; border: none; }
  877. .btn-add-chapter { width: 100%; height: 72rpx; margin-top: 16rpx; background: #eef2ff; color: #4f46e5; border: 2rpx dashed #c7d2fe; border-radius: 12rpx; font-size: 26rpx; font-weight: 600; display: flex; align-items: center; justify-content: center; }
  878. button[disabled] { opacity: .5; }
  879. /* Plan Review */
  880. .plan-review { margin-top: 16rpx; }
  881. .plan-section { margin-bottom: 24rpx; padding: 20rpx; background: #f9fafb; border-radius: 12rpx; }
  882. .plan-section-header { display: flex; align-items: center; gap: 8rpx; margin-bottom: 12rpx; }
  883. .plan-section-icon { font-size: 28rpx; }
  884. .plan-section-title { font-size: 26rpx; font-weight: 600; color: #374151; }
  885. .plan-section-hint { font-size: 22rpx; color: #9ca3af; margin-left: auto; }
  886. .plan-textarea { width: 100%; min-height: 80rpx; padding: 16rpx; background: #ffffff; border: 2rpx solid #e5e7eb; border-radius: 8rpx; font-size: 26rpx; line-height: 1.6; }
  887. .plan-empty { padding: 24rpx; text-align: center; }
  888. .empty-text { font-size: 24rpx; color: #9ca3af; }
  889. /* Plan Summary Tags */
  890. .plan-summary { display: flex; flex-wrap: wrap; gap: 12rpx; margin-bottom: 24rpx; }
  891. .summary-tag { font-size: 22rpx; padding: 8rpx 16rpx; background: #eef2ff; color: #4338ca; border-radius: 8rpx; line-height: 1.5; }
  892. /* Narrative Arc */
  893. .narrative-parts { display: flex; flex-direction: column; gap: 16rpx; }
  894. .narrative-part-card { background: #ffffff; border: 2rpx solid #e5e7eb; border-radius: 12rpx; overflow: hidden; }
  895. .part-badge { padding: 10rpx 20rpx; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: #fff; font-size: 24rpx; font-weight: 600; }
  896. .part-body { padding: 20rpx; display: flex; flex-direction: column; gap: 16rpx; }
  897. .part-field { display: flex; flex-direction: column; gap: 6rpx; }
  898. .part-label { font-size: 22rpx; color: #6b7280; font-weight: 600; }
  899. .part-text { font-size: 24rpx; color: #374151; }
  900. /* Tone Fields */
  901. .tone-fields { display: flex; flex-direction: column; gap: 16rpx; }
  902. .tone-row { display: flex; flex-direction: column; gap: 8rpx; }
  903. .tone-label { font-size: 24rpx; color: #6b7280; font-weight: 600; }
  904. /* Audience Fields */
  905. .audience-fields { display: flex; flex-direction: column; gap: 16rpx; }
  906. .audience-row { display: flex; flex-direction: column; gap: 8rpx; }
  907. .audience-label { font-size: 24rpx; color: #6b7280; font-weight: 600; }
  908. /* Tag List (avoidPatterns etc) */
  909. .tag-list { display: flex; flex-wrap: wrap; gap: 10rpx; }
  910. .tag-item { display: flex; align-items: center; gap: 6rpx; padding: 8rpx 14rpx; background: #fee2e2; border-radius: 8rpx; font-size: 22rpx; }
  911. .tag-item-static { padding: 8rpx 14rpx; background: #eef2ff; color: #4338ca; border-radius: 8rpx; font-size: 22rpx; }
  912. .tag-text { color: #dc2626; }
  913. .tag-remove { color: #ef4444; font-weight: 700; font-size: 24rpx; padding: 0 4rpx; }
  914. .tag-add { padding: 8rpx 14rpx; background: #f3f4f6; color: #6b7280; border-radius: 8rpx; font-size: 22rpx; border: 2rpx dashed #d1d5db; }
  915. /* Feedback Section */
  916. .feedback-section { background: #fef9c3; }
  917. .feedback-textarea { width: 100%; min-height: 120rpx; padding: 16rpx; background: #ffffff; border: 2rpx solid #fde68a; border-radius: 8rpx; font-size: 26rpx; line-height: 1.6; }
  918. .feedback-hint { font-size: 22rpx; color: #a16207; margin-top: 10rpx; }
  919. /* Form textarea xs (compact) */
  920. .form-textarea-xs { width: 100%; min-height: 72rpx; padding: 12rpx 16rpx; background: #ffffff; border: 2rpx solid #e5e7eb; border-radius: 8rpx; font-size: 24rpx; line-height: 1.5; }
  921. /* Outline Review */
  922. .outline-review { margin-top: 16rpx; }
  923. .outline-subtitle { font-size: 24rpx; color: #9ca3af; margin-bottom: 16rpx; display: block; }
  924. .chapter-card { margin-bottom: 16rpx; background: #f9fafb; border-radius: 12rpx; overflow: hidden; }
  925. .chapter-header { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; cursor: pointer; }
  926. .chapter-num { font-size: 26rpx; font-weight: 600; color: #374151; }
  927. .expand-icon { font-size: 24rpx; color: #9ca3af; }
  928. .chapter-edit { padding: 0 24rpx 24rpx; }
  929. /* Loading */
  930. .loading-box { display: flex; flex-direction: column; align-items: center; padding: 64rpx 0; gap: 24rpx; }
  931. .loading-spinner { width: 56rpx; height: 56rpx; border: 4rpx solid #e5e7eb; border-top-color: #4f46e5; border-radius: 50%; animation: spin 1s linear infinite; }
  932. @keyframes spin { to { transform: rotate(360deg); } }
  933. .loading-text { font-size: 26rpx; color: #6b7280; }
  934. /* Error */
  935. .error-box { padding: 24rpx; background: #fef2f2; border-radius: 12rpx; margin-top: 16rpx; }
  936. .error-text { font-size: 24rpx; color: #ef4444; }
  937. /* Step 4 Generate */
  938. .generate-box { display: flex; flex-direction: column; gap: 32rpx; padding: 32rpx 0; }
  939. .generate-stage { display: flex; align-items: center; gap: 16rpx; opacity: .4; }
  940. .generate-stage.completed { opacity: 1; }
  941. .stage-icon { font-size: 40rpx; }
  942. .stage-info { display: flex; flex-direction: column; gap: 4rpx; }
  943. .stage-title { font-size: 28rpx; font-weight: 600; color: #374151; }
  944. .stage-hint { font-size: 22rpx; color: #9ca3af; }
  945. .progress-bar-wrap { padding: 16rpx 0; }
  946. .progress-bar { height: 12rpx; background: linear-gradient(90deg, #4f46e5, #6366f1); border-radius: 6rpx; transition: width .5s; }
  947. .progress-text { font-size: 24rpx; color: #4f46e5; text-align: center; display: block; margin-top: 12rpx; }
  948. /* AI智能推荐 */
  949. .ai-recommend-row { display: flex; align-items: center; gap: 12rpx; margin-top: 16rpx; }
  950. .btn-ai-recommend { padding: 14rpx 24rpx; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: #ffffff; border-radius: 12rpx; font-size: 24rpx; font-weight: 600; border: none; white-space: nowrap; flex-shrink: 0; }
  951. .btn-ai-recommend[disabled] { opacity: 0.5; }
  952. .ai-recommend-hint { font-size: 22rpx; color: #9ca3af; flex: 1; }
  953. /* AI自动生成提示 */
  954. .ai-generated-notice { display: flex; align-items: flex-start; gap: 12rpx; padding: 20rpx 24rpx; background: linear-gradient(135deg, #eef2ff 0%, #f0fdf4 100%); border-radius: 12rpx; border: 2rpx solid #c7d2fe; margin-bottom: 20rpx; }
  955. .notice-icon { font-size: 32rpx; flex-shrink: 0; margin-top: 2rpx; }
  956. .notice-text { font-size: 26rpx; color: #4338ca; line-height: 1.5; }
  957. </style>