create.vue 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138
  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">{{ isEditMode ? '编辑书籍' : '创建有声书' }}</text>
  10. </view>
  11. </view>
  12. <!-- 主内容区 -->
  13. <view class="main-content">
  14. <!-- ===== 区域 1:描述你想要的书籍 ===== -->
  15. <view class="card hero-card">
  16. <!-- 示例提示词(轮播展示) -->
  17. <view class="examples-row">
  18. <text class="examples-label">试试:</text>
  19. <view class="example-chips">
  20. <view
  21. :key="currentExampleIndex"
  22. class="example-chip example-chip-fade"
  23. @click="useCurrentExample"
  24. >
  25. <text class="example-chip-icon">✨</text>
  26. <text class="example-chip-text">{{ examplePrompts[currentExampleIndex] }}</text>
  27. <text class="example-chip-hint">点击填入</text>
  28. </view>
  29. </view>
  30. </view>
  31. <!-- 文本域 -->
  32. <view class="textarea-wrapper">
  33. <textarea
  34. v-model="newBook.description"
  35. class="form-textarea"
  36. :class="{ 'textarea-error': attemptedCreate && !newBook.description.trim() }"
  37. placeholder="用一句话描述你想要的书籍,AI 会自动分析并推荐最佳配置..."
  38. :maxlength="2000"
  39. @blur="detectScaleFromDesc"
  40. />
  41. <view class="textarea-footer">
  42. <text class="char-count">{{ newBook.description.length }}/2000</text>
  43. <text v-if="newBook.description.trim()" class="clear-btn" @click="newBook.description = ''">清空</text>
  44. </view>
  45. </view>
  46. <!-- AI 智能推荐按钮 -->
  47. <view v-if="!isEditMode" class="ai-btn-row">
  48. <button
  49. class="btn-ai-recommend"
  50. :class="{ 'btn-ai-loading': isRecommending }"
  51. :disabled="isRecommending || !newBook.description.trim()"
  52. @click="requestSmartRecommend"
  53. >
  54. <text>{{ isRecommending ? 'AI 分析中...' : 'AI 智能推荐配置' }}</text>
  55. </button>
  56. </view>
  57. </view>
  58. <!-- ===== 区域 2:AI 推荐结果(有配置时显示) ===== -->
  59. <view v-if="!isEditMode && hasAnyConfig" class="card config-summary-card">
  60. <view class="section-label">
  61. <text class="label-text">当前配置</text>
  62. <text v-if="recommendSource === 'ai'" class="config-source-tag">AI 推荐</text>
  63. <text v-else-if="recommendSource === 'template'" class="config-source-tag template-tag">模板</text>
  64. </view>
  65. <view class="config-tags">
  66. <view v-if="newBook.bookScale" class="config-tag scale-tag" @click="scrollToSection('scale')">
  67. <text class="config-tag-label">规模</text>
  68. <text class="config-tag-value">{{ getScaleShortLabel(newBook.bookScale) }}</text>
  69. <text class="config-tag-edit">修改</text>
  70. </view>
  71. <view v-if="newBook.targetAudience" class="config-tag" @click="scrollToSection('audience')">
  72. <text class="config-tag-value">{{ getAudienceLabel(newBook.targetAudience) }}</text>
  73. <text class="config-tag-edit">修改</text>
  74. </view>
  75. <view v-if="newBook.knowledgeLevel" class="config-tag" @click="scrollToSection('knowledge')">
  76. <text class="config-tag-value">{{ getKnowledgeLevelLabel(newBook.knowledgeLevel) }}</text>
  77. <text class="config-tag-edit">修改</text>
  78. </view>
  79. <view v-if="newBook.style" class="config-tag" @click="scrollToSection('style')">
  80. <text class="config-tag-value">{{ newBook.style }}</text>
  81. <text class="config-tag-edit">修改</text>
  82. </view>
  83. <view v-if="newBook.industry" class="config-tag" @click="scrollToSection('industry')">
  84. <text class="config-tag-value">{{ getIndustryLabel(newBook.industry) || newBook.industry }}</text>
  85. <text class="config-tag-edit">修改</text>
  86. </view>
  87. <view v-if="newBook.specialFeatures.length > 0" class="config-tag" @click="scrollToSection('features')">
  88. <text class="config-tag-value">{{ newBook.specialFeatures.length }}项特殊要求</text>
  89. <text class="config-tag-edit">修改</text>
  90. </view>
  91. </view>
  92. <!-- 预估信息 -->
  93. <view v-if="bookEstimate && bookEstimate.words" class="estimate-bar">
  94. <view class="estimate-item">
  95. <text class="est-num">{{ formatNumber(bookEstimate.words.avg) }}</text>
  96. <text class="est-unit">字</text>
  97. </view>
  98. <text class="est-divider">·</text>
  99. <view class="estimate-item">
  100. <text class="est-num">{{ bookEstimate.estimatedChapters }}</text>
  101. <text class="est-unit">章</text>
  102. </view>
  103. <text class="est-divider">·</text>
  104. <view class="estimate-item">
  105. <text class="est-num">{{ bookEstimate.audioMinutes.avg }}</text>
  106. <text class="est-unit">分钟</text>
  107. </view>
  108. <view v-if="quotaCheck" class="estimate-item quota-badge">
  109. <text v-if="quotaCheck.allowed" class="quota-ok">✅ 额度充足</text>
  110. <text v-else class="quota-warn">⚠️ {{ quotaCheck.reason }}</text>
  111. </view>
  112. </view>
  113. </view>
  114. <!-- ===== 区域 3:规模选择 ===== -->
  115. <view v-if="!isEditMode" class="card" id="section-scale">
  116. <view class="section-label">
  117. <text class="label-icon">📏</text>
  118. <text class="label-text">选择书籍规模</text>
  119. </view>
  120. <view class="scale-grid">
  121. <view
  122. v-for="scale in displayScales"
  123. :key="scale.value"
  124. :class="['scale-card', newBook.bookScale === scale.value ? 'scale-active' : '']"
  125. @click="selectScale(scale.value)"
  126. >
  127. <text class="scale-name">{{ scale.label }}</text>
  128. <text class="scale-words">{{ scale.words }}</text>
  129. <text class="scale-audio">{{ scale.audio }}</text>
  130. </view>
  131. </view>
  132. <!-- 更多规模 -->
  133. <view v-if="!showAllScales" class="show-more-row">
  134. <text class="show-more-btn" @click="showAllScales = true">展开全部规模 ({{ bookScales.length }}档) ▼</text>
  135. </view>
  136. <view v-else class="scale-grid">
  137. <view
  138. v-for="scale in bookScales.slice(displayScales.length)"
  139. :key="scale.value"
  140. :class="['scale-card', newBook.bookScale === scale.value ? 'scale-active' : '']"
  141. @click="selectScale(scale.value)"
  142. >
  143. <text class="scale-name">{{ scale.name }}</text>
  144. <text class="scale-words">{{ scale.words }}</text>
  145. <text class="scale-audio">{{ scale.audio }}</text>
  146. </view>
  147. </view>
  148. </view>
  149. <!-- ===== 区域 4:内容定位 ===== -->
  150. <view class="card" id="section-audience">
  151. <view class="section-label">
  152. <text class="label-text">内容定位</text>
  153. </view>
  154. <!-- 面向人群 -->
  155. <view class="chip-section">
  156. <text class="chip-title">面向人群 <text class="chip-required">*</text></text>
  157. <view class="chip-row">
  158. <view
  159. v-for="a in audiences"
  160. :key="a.value"
  161. :class="['chip', newBook.targetAudience === a.value ? 'chip-active' : '']"
  162. @click="newBook.targetAudience = newBook.targetAudience === a.value ? '' : a.value"
  163. >
  164. <view class="chip-body">
  165. <text class="chip-text">{{ a.label }}</text>
  166. <text class="chip-desc">{{ a.desc }}</text>
  167. </view>
  168. </view>
  169. </view>
  170. </view>
  171. <!-- 知识难度 -->
  172. <view class="chip-section" id="section-knowledge">
  173. <text class="chip-title">知识难度</text>
  174. <view class="chip-row">
  175. <view
  176. v-for="l in knowledgeLevels"
  177. :key="l.value"
  178. :class="['chip chip-sm', newBook.knowledgeLevel === l.value ? 'chip-active' : '']"
  179. @click="newBook.knowledgeLevel = newBook.knowledgeLevel === l.value ? '' : l.value"
  180. >
  181. <view class="chip-body">
  182. <text class="chip-text">{{ l.label }}</text>
  183. </view>
  184. </view>
  185. </view>
  186. </view>
  187. <!-- 书籍类型 -->
  188. <view class="chip-section">
  189. <text class="chip-title">书籍类型</text>
  190. <view class="chip-row">
  191. <view
  192. v-for="bt in bookTypes"
  193. :key="bt.value"
  194. :class="['chip chip-sm', newBook.bookType === bt.value ? 'chip-active' : '']"
  195. @click="newBook.bookType = newBook.bookType === bt.value ? 'auto' : bt.value"
  196. >
  197. <view class="chip-body">
  198. <text class="chip-text">{{ bt.label }}</text>
  199. </view>
  200. </view>
  201. </view>
  202. </view>
  203. <!-- 大纲层级 -->
  204. <view class="chip-section">
  205. <text class="chip-title">大纲层级</text>
  206. <view class="chip-row">
  207. <view
  208. v-for="opt in outlineLevelOptions"
  209. :key="opt.value"
  210. :class="['chip chip-xs', newBook.outlineLevel === opt.value ? 'chip-active' : '']"
  211. @click="newBook.outlineLevel = newBook.outlineLevel === opt.value ? 'auto' : opt.value"
  212. >
  213. <text class="chip-text">{{ opt.label }}</text>
  214. <text class="chip-desc chip-desc-inline">{{ opt.desc }}</text>
  215. </view>
  216. </view>
  217. </view>
  218. </view>
  219. <!-- ===== 区域 5:声音速度 ===== -->
  220. <view class="card" id="section-speed">
  221. <view class="section-label">
  222. <text class="label-text">语速</text>
  223. </view>
  224. <view class="speed-display">
  225. <text class="speed-value">{{ newBook.voiceSpeed.toFixed(1) }}x</text>
  226. </view>
  227. <slider
  228. :value="(newBook.voiceSpeed - 0.5) * 100 / 1.5"
  229. :min="0"
  230. :max="100"
  231. @changing="(e: any) => newBook.voiceSpeed = Number((0.5 + (e.detail.value / 100) * 1.5).toFixed(1))"
  232. activeColor="#4F46E5"
  233. backgroundColor="#e5e7eb"
  234. block-size="20"
  235. />
  236. <view class="param-range"><text>0.5x</text><text>2.0x</text></view>
  237. </view>
  238. <!-- ===== 区域 6:高级定制(可折叠) ===== -->
  239. <view class="card">
  240. <view class="section-label collapsible" @click="showAdvancedOptions = !showAdvancedOptions">
  241. <text class="label-text">高级定制</text>
  242. <view v-if="advancedBadgeCount > 0" class="adv-badge">{{ advancedBadgeCount }}</view>
  243. <text class="collapse-icon">{{ showAdvancedOptions ? '▲' : '▼' }}</text>
  244. </view>
  245. <view v-if="showAdvancedOptions" class="advanced-content">
  246. <!-- 快速模板 -->
  247. <view class="chip-section" id="section-template">
  248. <text class="chip-title">快速模板</text>
  249. <view class="chip-row">
  250. <view
  251. v-for="tmpl in quickTemplates"
  252. :key="tmpl.name"
  253. :class="['chip chip-sm chip-template', selectedTemplateName === tmpl.name ? 'chip-active' : '']"
  254. @click="applyTemplate(tmpl)"
  255. >
  256. <view class="chip-body">
  257. <text class="chip-text">{{ tmpl.name }}</text>
  258. <text class="chip-desc chip-desc-inline">{{ tmpl.desc }}</text>
  259. </view>
  260. </view>
  261. </view>
  262. </view>
  263. <!-- 写作风格 -->
  264. <view class="chip-section" id="section-style">
  265. <text class="chip-title">写作风格</text>
  266. <view class="chip-row">
  267. <view
  268. v-for="s in styleOptions"
  269. :key="s"
  270. :class="['chip chip-xs', newBook.style === s ? 'chip-active' : '']"
  271. @click="newBook.style = newBook.style === s ? '' : s"
  272. >
  273. <text class="chip-text">{{ s }}</text>
  274. </view>
  275. </view>
  276. </view>
  277. <!-- 行业领域 -->
  278. <view class="chip-section" id="section-industry">
  279. <text class="chip-title">行业领域</text>
  280. <view class="chip-row">
  281. <view
  282. v-for="ind in industries"
  283. :key="ind.value"
  284. :class="['chip chip-sm', newBook.industry === ind.value ? 'chip-active' : '']"
  285. @click="newBook.industry = newBook.industry === ind.value ? '' : ind.value"
  286. >
  287. <text class="chip-text">{{ ind.label }}</text>
  288. </view>
  289. </view>
  290. </view>
  291. <!-- 特殊要求 -->
  292. <view class="chip-section" id="section-features">
  293. <text class="chip-title">特殊要求(多选)</text>
  294. <view class="chip-row">
  295. <view
  296. v-for="sf in specialFeatures"
  297. :key="sf.value"
  298. :class="['chip chip-sm', newBook.specialFeatures.includes(sf.value) ? 'chip-active' : '']"
  299. @click="toggleSpecialFeature(sf.value)"
  300. >
  301. <text class="chip-text">{{ sf.label }}</text>
  302. <text v-if="newBook.specialFeatures.includes(sf.value)" class="chip-check">✓</text>
  303. </view>
  304. </view>
  305. </view>
  306. </view>
  307. </view>
  308. <!-- ===== 底部操作区 ===== -->
  309. <view class="bottom-bar">
  310. <button
  311. v-if="isEditMode"
  312. class="btn-create"
  313. :disabled="!canCreateBook || creating"
  314. @click="createNewBook"
  315. >
  316. <text>{{ creating ? '保存中...' : '保存修改' }}</text>
  317. </button>
  318. <template v-else>
  319. <button
  320. class="btn-create"
  321. :disabled="!canCreateBook || creating"
  322. @click="createNewBook"
  323. >
  324. <text>{{ creating ? '创建中...' : '开始创建有声书' }}</text>
  325. </button>
  326. <text class="bottom-link" @click="switchToInteractive">需要更精细控制?试试<text class="bottom-link-highlight">交互模式</text></text>
  327. </template>
  328. </view>
  329. </view>
  330. </view>
  331. </template>
  332. <script setup lang="ts">
  333. import { ref, computed, onMounted, onUnmounted, onBeforeUnmount } from 'vue';
  334. import { onLoad } from '@dcloudio/uni-app';
  335. import * as api from '../../utils/book-generator-api';
  336. import { getApiBaseUrl } from '../../utils/config';
  337. const BASE_URL = getApiBaseUrl();
  338. // 创建表单
  339. const creating = ref(false);
  340. const showAdvancedOptions = ref(false);
  341. const showAllScales = ref(false);
  342. const isRecommending = ref(false);
  343. const attemptedCreate = ref(false);
  344. const isEditMode = ref(false);
  345. const editingBookId = ref<string | null>(null);
  346. const recommendSource = ref<'ai' | 'template' | 'manual' | ''>('');
  347. const newBook = ref({
  348. title: '',
  349. subtitle: '',
  350. description: '',
  351. targetAudience: '',
  352. knowledgeLevel: '',
  353. industry: '',
  354. specialFeatures: [] as string[],
  355. style: '',
  356. bookScale: '',
  357. bookType: 'auto',
  358. outlineLevel: 'auto',
  359. voiceSpeed: 1.0,
  360. });
  361. // 示例提示词
  362. const examplePrompts = [
  363. '教小学生学习编程的入门书,内容生动有趣,通过趣味案例引导孩子入门Python',
  364. '面向中学生的科幻科普读物,用故事化方式讲解物理学基础概念',
  365. '给职场新人的沟通技巧指南,结合实际案例,实用性强',
  366. '介绍中国历史的通识读物,按时间线讲述,通俗易懂适合大众读者',
  367. ];
  368. // 示例提示词轮播
  369. const currentExampleIndex = ref(0);
  370. let exampleTimer: ReturnType<typeof setInterval> | null = null;
  371. const EXAMPLE_INTERVAL_MS = 4000; // 每 4 秒切换一条
  372. function useCurrentExample() {
  373. newBook.value.description = examplePrompts[currentExampleIndex.value];
  374. }
  375. function startExampleRotation() {
  376. stopExampleRotation();
  377. if (examplePrompts.length <= 1) return;
  378. exampleTimer = setInterval(() => {
  379. currentExampleIndex.value = (currentExampleIndex.value + 1) % examplePrompts.length;
  380. }, EXAMPLE_INTERVAL_MS);
  381. }
  382. function stopExampleRotation() {
  383. if (exampleTimer) {
  384. clearInterval(exampleTimer);
  385. exampleTimer = null;
  386. }
  387. }
  388. // 书籍预估信息
  389. const bookEstimate = ref<{
  390. scale: string;
  391. words: { min: number; max: number; avg: number };
  392. audioMinutes: { min: number; max: number; avg: number };
  393. estimatedChapters: number;
  394. } | null>(null);
  395. // 配额检查结果
  396. const quotaCheck = ref<{
  397. allowed: boolean;
  398. reason: string | null;
  399. } | null>(null);
  400. // 书籍规模配置(全新简洁标签)
  401. const bookScales = [
  402. { value: '340000', name: '超大', words: '34万字', audio: '约28h', label: '上2' },
  403. { value: '210000', name: '大型', words: '21万字', audio: '约17h', label: '上1' },
  404. { value: '130000', name: '标准', words: '13万字', audio: '约11h', label: '标准' },
  405. { value: '80000', name: '中型', words: '8万字', audio: '约7h', label: '下1' },
  406. { value: '50000', name: '小型', words: '5万字', audio: '约4h', label: '下2' },
  407. { value: '31000', name: '短篇', words: '3.1万字', audio: '约2.5h', label: '下3' },
  408. { value: '19000', name: '超短', words: '1.9万字', audio: '约1.5h', label: '下4' },
  409. { value: '12000', name: '微书', words: '1.2万字', audio: '约1h', label: '下5' },
  410. { value: '7000', name: '迷你', words: '7千字', audio: '约35m', label: '下6' },
  411. { value: '4000', name: '短文', words: '4千字', audio: '约20m', label: '下7' },
  412. { value: '3000', name: '速读', words: '3千字', audio: '约15m', label: '下8' },
  413. { value: '2000', name: '极短', words: '2千字', audio: '约10m', label: '下9' },
  414. { value: '1000', name: '试读', words: '1千字', audio: '约5m', label: '下10' },
  415. ];
  416. // 默认展示前6个规模
  417. const displayScales = computed(() => bookScales.slice(0, 6));
  418. // 选项配置
  419. const knowledgeLevels = [
  420. { value: '入门', label: '入门级', icon: '🌱', desc: '零基础,简单易懂' },
  421. { value: '基础', label: '基础级', icon: '📚', desc: '基本概念,循序渐进' },
  422. { value: '进阶', label: '进阶级', icon: '📈', desc: '深入讲解,系统全面' },
  423. { value: '高级', label: '高级', icon: '🎯', desc: '专业深入,前沿技术' },
  424. { value: '专家', label: '专家级', icon: '🔬', desc: '研究级别,底层原理' },
  425. ];
  426. const audiences = [
  427. { value: '儿童', label: '儿童', icon: '👶', desc: '6-12岁,形象生动' },
  428. { value: '青少年', label: '青少年', icon: '🧑', desc: '13-18岁,通俗易懂' },
  429. { value: '大学生', label: '大学生', icon: '🎓', desc: '系统专业,有理论' },
  430. { value: '专业人士', label: '专业人士', icon: '💼', desc: '从业者,实用深入' },
  431. { value: '研究生', label: '研究生', icon: '🔍', desc: '研究级别,前沿深入' },
  432. { value: '大众读者', label: '大众读者', icon: '👥', desc: '通俗普及,有趣味' },
  433. ];
  434. const industries = [
  435. { value: 'IT/计算机', label: 'IT/计算机', icon: '💻', desc: '编程、软件、互联网' },
  436. { value: '金融', label: '金融', icon: '💰', desc: '投资、银行、证券' },
  437. { value: '医学', label: '医学', icon: '🏥', desc: '医疗、健康、临床' },
  438. { value: '教育', label: '教育', icon: '📚', desc: '教学、学习、心理学' },
  439. { value: '文学', label: '文学', icon: '✍️', desc: '小说、散文、传记' },
  440. { value: '科普', label: '科普', icon: '🔬', desc: '科学、自然、探索' },
  441. { value: '商业', label: '商业', icon: '📊', desc: '管理、营销、创业' },
  442. { value: '艺术', label: '艺术', icon: '🎨', desc: '设计、音乐、影视' },
  443. ];
  444. const specialFeatures = [
  445. { value: '案例分析', label: '案例分析', icon: '📖' },
  446. { value: '代码示例', label: '代码示例', icon: '💻' },
  447. { value: '习题练习', label: '习题练习', icon: '✍️' },
  448. { value: '图表插图', label: '图表插图', icon: '📊' },
  449. { value: '项目实战', label: '项目实战', icon: '🎯' },
  450. { value: '思维导图', label: '思维导图', icon: '🧠' },
  451. { value: '总结回顾', label: '总结回顾', icon: '📝' },
  452. ];
  453. const quickTemplates = [
  454. { name: '大学教材', icon: '🎓', desc: '系统专业,有案例习题', config: { knowledgeLevel: '进阶', targetAudience: '大学生', industry: '教育', specialFeatures: ['案例分析', '习题练习', '总结回顾'], style: '专业严谨' } },
  455. { name: '技术教程', icon: '💻', desc: '有代码示例和实战项目', config: { knowledgeLevel: '入门', targetAudience: '专业人士', industry: 'IT/计算机', specialFeatures: ['代码示例', '项目实战', '案例分析'], style: '通俗易懂' } },
  456. { name: '科普读物', icon: '🔬', desc: '面向大众,通俗有趣', config: { knowledgeLevel: '入门', targetAudience: '大众读者', industry: '科普', specialFeatures: ['案例分析', '图表插图'], style: '轻松幽默' } },
  457. { name: '商业书籍', icon: '📊', desc: '有真实案例,实用性强', config: { knowledgeLevel: '进阶', targetAudience: '专业人士', industry: '商业', specialFeatures: ['案例分析', '总结回顾'], style: '专业严谨' } },
  458. { name: '儿童读物', icon: '👶', desc: '形象生动,有趣味性', config: { knowledgeLevel: '入门', targetAudience: '儿童', industry: '教育', specialFeatures: ['图表插图'], style: '轻松幽默' } },
  459. { name: '学术研究', icon: '🔍', desc: '前沿深入,有研究价值', config: { knowledgeLevel: '专家', targetAudience: '研究生', industry: 'IT/计算机', specialFeatures: ['案例分析', '总结回顾'], style: '专业严谨' } },
  460. ];
  461. const styleOptions = ['通俗易懂', '专业严谨', '轻松幽默', '诗意优美', '故事化'];
  462. const bookTypes = [
  463. { value: 'auto', label: '自动', icon: '🤖', desc: 'AI自行判断' },
  464. { value: '教材', label: '教材', icon: '📚', desc: '3层大纲' },
  465. { value: '技术教程', label: '技术教程', icon: '💻', desc: '3层大纲' },
  466. { value: '小说', label: '小说', icon: '📖', desc: '1层大纲' },
  467. { value: '商业', label: '商业', icon: '📊', desc: '2层大纲' },
  468. { value: '科普', label: '科普', icon: '🔬', desc: '2层大纲' },
  469. ];
  470. const outlineLevelOptions = [
  471. { value: 'auto', label: '自动', desc: '按规模适配' },
  472. { value: '1', label: '1层', desc: '仅章' },
  473. { value: '2', label: '2层', desc: '章→节' },
  474. { value: '3', label: '3层', desc: '章→节→小节' },
  475. ];
  476. // 计算属性
  477. const hasAnyConfig = computed(() => {
  478. return !!(newBook.value.bookScale || newBook.value.targetAudience ||
  479. newBook.value.knowledgeLevel || newBook.value.style ||
  480. newBook.value.industry || newBook.value.specialFeatures.length);
  481. });
  482. const advancedBadgeCount = computed(() => {
  483. let count = 0;
  484. if (selectedTemplateName.value) count++;
  485. if (newBook.value.style) count++;
  486. if (newBook.value.industry) count++;
  487. if (newBook.value.specialFeatures.length) count++;
  488. return count;
  489. });
  490. const canCreateBook = computed(() => {
  491. if (quotaCheck.value && !quotaCheck.value.allowed) return false;
  492. return !!newBook.value.description.trim();
  493. });
  494. const selectedTemplateName = ref('');
  495. // 辅助函数
  496. function getScaleShortLabel(value: string) {
  497. const scale = bookScales.find(s => s.value === value);
  498. return scale ? `${scale.name} (${scale.words})` : '';
  499. }
  500. function getAudienceLabel(val: string) { return audiences.find(a => a.value === val)?.label || ''; }
  501. function getAudienceIcon(val: string) { return audiences.find(a => a.value === val)?.icon || ''; }
  502. function getKnowledgeLevelLabel(val: string) { return knowledgeLevels.find(l => l.value === val)?.label || ''; }
  503. function getKnowledgeIcon(val: string) { return knowledgeLevels.find(l => l.value === val)?.icon || ''; }
  504. function getIndustryLabel(val: string) { return industries.find(i => i.value === val)?.label || ''; }
  505. function formatNumber(n: number): string {
  506. if (n >= 10000) return (n / 10000).toFixed(1) + '万';
  507. if (n >= 1000) return (n / 1000).toFixed(1) + '千';
  508. return String(n);
  509. }
  510. function scrollToSection(id: string) {
  511. // uniapp H5 环境下的 scrollTo
  512. // #ifdef H5
  513. const el = document.getElementById(`section-${id}`);
  514. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
  515. // #endif
  516. }
  517. // 选择规模
  518. function selectScale(value: string) {
  519. newBook.value.bookScale = value;
  520. loadEstimate(value);
  521. }
  522. // 切换特殊要求
  523. function toggleSpecialFeature(feature: string) {
  524. const features = newBook.value.specialFeatures;
  525. const index = features.indexOf(feature);
  526. if (index > -1) {
  527. features.splice(index, 1);
  528. } else {
  529. features.push(feature);
  530. }
  531. }
  532. // 加载预估
  533. async function loadEstimate(scale: string) {
  534. try {
  535. // #ifdef H5
  536. const response = await uni.request({
  537. url: `${BASE_URL}/book-generator/langgraph/estimate?scale=${encodeURIComponent(scale)}&userId=1`,
  538. method: 'GET'
  539. });
  540. const res = response.data as any;
  541. if (res.code === 0 && res.data?.words) {
  542. bookEstimate.value = {
  543. scale: res.data.scale,
  544. words: res.data.words,
  545. audioMinutes: res.data.audioMinutes,
  546. estimatedChapters: res.data.estimatedChapters,
  547. };
  548. if (res.data.quotaCheck) quotaCheck.value = res.data.quotaCheck;
  549. }
  550. // #endif
  551. } catch (e) { /* ignore */ }
  552. }
  553. // 应用模板
  554. function applyTemplate(template: typeof quickTemplates[0]) {
  555. if (selectedTemplateName.value === template.name) {
  556. // 再次点击取消模板
  557. selectedTemplateName.value = '';
  558. newBook.value.knowledgeLevel = '';
  559. newBook.value.targetAudience = '';
  560. newBook.value.industry = '';
  561. newBook.value.specialFeatures = [];
  562. newBook.value.style = '';
  563. recommendSource.value = '';
  564. return;
  565. }
  566. const config = template.config;
  567. newBook.value.knowledgeLevel = config.knowledgeLevel;
  568. newBook.value.targetAudience = config.targetAudience;
  569. newBook.value.industry = config.industry;
  570. newBook.value.specialFeatures = [...config.specialFeatures];
  571. newBook.value.style = config.style;
  572. selectedTemplateName.value = template.name;
  573. recommendSource.value = 'template';
  574. uni.showToast({ title: `已应用「${template.name}」模板`, icon: 'success' });
  575. }
  576. // AI 智能推荐
  577. async function requestSmartRecommend() {
  578. if (!newBook.value.description.trim() || isRecommending.value) return;
  579. isRecommending.value = true;
  580. try {
  581. const response = await uni.request({
  582. url: `${BASE_URL}/book-generator/langgraph/smart-recommend`,
  583. method: 'POST',
  584. data: { description: newBook.value.description.trim(), title: newBook.value.title.trim() },
  585. });
  586. const res = response.data as any;
  587. if (res.code === 0 && res.data) {
  588. const rec = res.data;
  589. if (rec.knowledgeLevel) newBook.value.knowledgeLevel = rec.knowledgeLevel;
  590. if (rec.targetAudience) newBook.value.targetAudience = rec.targetAudience;
  591. if (rec.style) newBook.value.style = rec.style;
  592. if (rec.industry) newBook.value.industry = rec.industry;
  593. recommendSource.value = 'ai';
  594. showAdvancedOptions.value = true;
  595. uni.showToast({ title: 'AI 推荐完成,可点击标签修改', icon: 'success' });
  596. } else {
  597. uni.showToast({ title: res.message || '推荐失败,请手动配置', icon: 'none' });
  598. }
  599. } catch (e: any) {
  600. uni.showToast({ title: '推荐服务暂不可用', icon: 'none' });
  601. } finally {
  602. isRecommending.value = false;
  603. }
  604. }
  605. // AI 检测规模
  606. let detectTimer: any = null;
  607. async function detectScaleFromDesc() {
  608. const desc = newBook.value.description?.trim();
  609. if (!desc) return;
  610. clearTimeout(detectTimer);
  611. detectTimer = setTimeout(async () => {
  612. // 检测是否为英文内容,自动调慢语速
  613. if (detectEnglishContent(desc)) {
  614. newBook.value.voiceSpeed = 0.5;
  615. }
  616. if (!newBook.value.bookScale) {
  617. try {
  618. const response = await uni.request({
  619. url: `${BASE_URL}/book-generator/langgraph/detect-scale`,
  620. method: 'POST',
  621. data: { description: desc }
  622. });
  623. const res = response.data as any;
  624. if (res.code === 0 && res.data) {
  625. newBook.value.bookScale = res.data.bookScale;
  626. bookEstimate.value = {
  627. scale: res.data.bookScale,
  628. words: { min: res.data.totalWords, max: res.data.totalWords, avg: res.data.totalWords },
  629. audioMinutes: { min: 0, max: 0, avg: 0 },
  630. estimatedChapters: res.data.chapters,
  631. };
  632. loadEstimate(res.data.bookScale);
  633. }
  634. } catch { /* ignore */ }
  635. }
  636. }, 800);
  637. }
  638. // 检测文本中英文相关内容的比例
  639. function detectEnglishRatio(text: string): number {
  640. // 检测纯英文单词的比例
  641. const words = text.match(/[a-zA-Z]+/g) || [];
  642. const totalWords = text.match(/[\u4e00-\u9fa5a-zA-Z]+/g) || [];
  643. if (totalWords.length === 0) return 0;
  644. return words.length / totalWords.length;
  645. }
  646. // 检测描述是否暗示内容为英文
  647. function detectEnglishContent(text: string): boolean {
  648. const lowerText = text.toLowerCase();
  649. // 检测英文相关关键词
  650. const englishKeywords = [
  651. 'english', '英文', '美文', 'toeic', 'toefl', 'ielts', 'gre', 'gmat',
  652. 'python', 'javascript', 'java', '编程', 'code', '编程语言',
  653. 'literature', '文学', 'novel', '小说', 'essay', '散文',
  654. 'programming', 'software', 'software', 'algorithm', '算法',
  655. 'technical', '技术', 'tutorial', '教程',
  656. ];
  657. // 检测英文单词占比
  658. const englishRatio = detectEnglishRatio(text);
  659. if (englishRatio > 0.4) return true;
  660. // 检测是否包含英文关键词
  661. return englishKeywords.some(kw => lowerText.includes(kw));
  662. }
  663. function switchToInteractive() {
  664. uni.navigateTo({ url: '/pages/book-generator/interactive' });
  665. }
  666. function goBack() {
  667. const pages = getCurrentPages();
  668. if (pages.length > 1) {
  669. uni.navigateBack();
  670. } else {
  671. uni.switchTab({ url: '/pages/index/index' });
  672. }
  673. }
  674. // 创建书籍
  675. async function createNewBook() {
  676. attemptedCreate.value = true;
  677. if (!canCreateBook.value) return;
  678. // 编辑模式
  679. if (isEditMode.value && editingBookId.value) {
  680. try {
  681. await api.updateBook(editingBookId.value, {
  682. title: newBook.value.title || undefined,
  683. description: newBook.value.description || undefined,
  684. targetAudience: newBook.value.targetAudience || undefined,
  685. style: newBook.value.style || undefined,
  686. bookScale: newBook.value.bookScale || undefined,
  687. });
  688. uni.showToast({ title: '更新成功', icon: 'success' });
  689. setTimeout(() => uni.redirectTo({ url: '/pages/book-generator/index' }), 800);
  690. } catch (e: any) {
  691. uni.showToast({ title: e.message || '更新失败', icon: 'none' });
  692. } finally {
  693. creating.value = false;
  694. }
  695. return;
  696. }
  697. // 创建模式
  698. const configSummary: string[] = [];
  699. if (newBook.value.knowledgeLevel) configSummary.push(`知识难度:${newBook.value.knowledgeLevel}`);
  700. if (newBook.value.targetAudience) configSummary.push(`面向人群:${newBook.value.targetAudience}`);
  701. if (newBook.value.industry) configSummary.push(`行业领域:${newBook.value.industry}`);
  702. if (newBook.value.specialFeatures.length > 0) configSummary.push(`特殊要求:${newBook.value.specialFeatures.join('、')}`);
  703. if (newBook.value.style) configSummary.push(`写作风格:${newBook.value.style}`);
  704. const confirmResult = await new Promise<boolean>((resolve) => {
  705. uni.showModal({
  706. title: '确认创建有声书',
  707. content: `${configSummary.join('\n')}\n\n书名将由AI根据描述自动生成\n\n确认开始生成?`,
  708. confirmText: '确认生成',
  709. cancelText: '返回修改',
  710. success: (res) => resolve(res.confirm),
  711. });
  712. });
  713. if (!confirmResult) return;
  714. creating.value = true;
  715. try {
  716. let mergedDesc = newBook.value.description.trim();
  717. if (newBook.value.knowledgeLevel && newBook.value.knowledgeLevel !== '不填')
  718. mergedDesc += `\n知识难度:${newBook.value.knowledgeLevel}`;
  719. if (newBook.value.targetAudience && newBook.value.targetAudience !== '不填')
  720. mergedDesc += `\n面向人群:${newBook.value.targetAudience}`;
  721. if (newBook.value.industry && newBook.value.industry !== '不填')
  722. mergedDesc += `\n行业领域:${newBook.value.industry}`;
  723. if (newBook.value.specialFeatures && newBook.value.specialFeatures.length > 0)
  724. mergedDesc += `\n特殊要求:${newBook.value.specialFeatures.join('、')}`;
  725. if (newBook.value.style && newBook.value.style !== '不填')
  726. mergedDesc += `\n写作风格:${newBook.value.style}`;
  727. const genLevel = newBook.value.outlineLevel === 'auto' ? undefined : parseInt(newBook.value.outlineLevel);
  728. const bookType = newBook.value.bookType === 'auto' ? undefined : newBook.value.bookType;
  729. const result = await api.createBook({
  730. title: newBook.value.title,
  731. subtitle: newBook.value.subtitle,
  732. description: mergedDesc,
  733. bookScale: newBook.value.bookScale || undefined,
  734. bookType,
  735. genLevel,
  736. voiceSpeed: newBook.value.voiceSpeed !== 1.0 ? newBook.value.voiceSpeed : undefined,
  737. });
  738. uni.showToast({ title: '创建成功,正在跳转...', icon: 'success' });
  739. setTimeout(() => {
  740. uni.navigateTo({ url: `/pages/book-generator/detail?id=${result.id}` });
  741. }, 1500);
  742. } catch (e: any) {
  743. uni.showToast({ title: e.message || '创建失败', icon: 'none' });
  744. } finally {
  745. creating.value = false;
  746. }
  747. }
  748. onMounted(() => {
  749. startExampleRotation();
  750. });
  751. onBeforeUnmount(() => {
  752. stopExampleRotation();
  753. });
  754. onUnmounted(() => {
  755. stopExampleRotation();
  756. });
  757. onLoad(async (query: any) => {
  758. if (query?.editId) {
  759. isEditMode.value = true;
  760. editingBookId.value = query.editId;
  761. try {
  762. const book = await api.getBookForEdit(query.editId);
  763. if (book) {
  764. newBook.value.title = book.title || '';
  765. newBook.value.description = book.description || '';
  766. newBook.value.targetAudience = book.targetAudience || '';
  767. newBook.value.style = book.style || '';
  768. newBook.value.bookScale = book.bookScale || '';
  769. }
  770. } catch (e) {
  771. console.error('加载书籍失败:', e);
  772. }
  773. }
  774. });
  775. </script>
  776. <style scoped>
  777. /* ========== 基础布局 ========== */
  778. .page { min-height: 100vh; background: #f3f4f6; }
  779. .nav-bar {
  780. position: fixed; top: 0; left: 0; right: 0; z-index: 100;
  781. background: rgba(255,255,255,0.92);
  782. backdrop-filter: blur(20px);
  783. -webkit-backdrop-filter: blur(20px);
  784. border-bottom: 0.5rpx solid rgba(0,0,0,0.06);
  785. }
  786. .nav-content {
  787. display: flex; align-items: center; height: 88rpx;
  788. padding: 0 32rpx; padding-top: env(safe-area-inset-top);
  789. }
  790. .nav-left { width: 60rpx; flex-shrink: 0; }
  791. .back-icon { font-size: 40rpx; color: #1f2937; }
  792. .page-title { font-size: 34rpx; font-weight: 700; color: #1f2937; flex: 1; text-align: center; margin-right: 60rpx; }
  793. .main-content {
  794. padding-top: calc(88rpx + 12rpx + env(safe-area-inset-top));
  795. padding-bottom: 200rpx;
  796. }
  797. /* ========== 卡片 ========== */
  798. .card {
  799. margin: 0 24rpx 20rpx;
  800. padding: 36rpx 28rpx;
  801. background: #ffffff;
  802. border-radius: 24rpx;
  803. box-shadow: 0 1rpx 3rpx rgba(0,0,0,0.04);
  804. overflow: hidden;
  805. }
  806. /* ========== 区域标题 ========== */
  807. .section-label {
  808. display: flex; align-items: center; gap: 10rpx;
  809. margin-bottom: 8rpx;
  810. }
  811. .section-label.collapsible { cursor: pointer; margin-bottom: 0; }
  812. .label-icon { font-size: 32rpx; }
  813. .label-text { font-size: 30rpx; font-weight: 700; color: #1f2937; }
  814. .label-required { color: #ef4444; font-size: 28rpx; font-weight: 600; margin-left: 4rpx; }
  815. .collapse-icon { font-size: 24rpx; color: #9ca3af; margin-left: auto; }
  816. /* ========== Hero 区域(描述) ========== */
  817. .hero-card { background: linear-gradient(180deg, #ffffff 0%, #fefefe 100%); }
  818. /* 示例提示词(轮播卡片) */
  819. .examples-row { display: flex; align-items: flex-start; gap: 12rpx; margin-bottom: 20rpx; }
  820. .examples-label { font-size: 22rpx; color: #9ca3af; flex-shrink: 0; padding-top: 12rpx; }
  821. .example-chips { display: flex; flex: 1; min-width: 0; }
  822. .example-chip {
  823. display: flex; align-items: center; gap: 12rpx;
  824. width: 100%;
  825. padding: 18rpx 20rpx;
  826. background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
  827. border: 1.5rpx solid #c7d2fe;
  828. border-radius: 14rpx;
  829. box-sizing: border-box;
  830. transition: transform 0.15s, box-shadow 0.15s;
  831. }
  832. .example-chip:active {
  833. transform: scale(0.98);
  834. box-shadow: 0 2rpx 8rpx rgba(99,102,241,0.18);
  835. }
  836. .example-chip-icon { font-size: 28rpx; flex-shrink: 0; }
  837. .example-chip-text {
  838. flex: 1; min-width: 0;
  839. font-size: 26rpx; color: #4338ca; line-height: 1.5;
  840. display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  841. overflow: hidden;
  842. }
  843. .example-chip-hint {
  844. flex-shrink: 0;
  845. font-size: 20rpx; color: #6366f1;
  846. padding: 4rpx 10rpx;
  847. background: rgba(255,255,255,0.7);
  848. border-radius: 6rpx;
  849. font-weight: 500;
  850. }
  851. /* 切换时的淡入动画 */
  852. .example-chip-fade {
  853. animation: exampleFadeIn 0.45s ease both;
  854. }
  855. @keyframes exampleFadeIn {
  856. 0% { opacity: 0; transform: translateY(6rpx); }
  857. 100% { opacity: 1; transform: translateY(0); }
  858. }
  859. /* 文本域 */
  860. .textarea-wrapper { position: relative; margin-bottom: 16rpx; }
  861. .form-textarea {
  862. width: 100%; min-height: 420rpx;
  863. padding: 28rpx; padding-bottom: 60rpx;
  864. background: #f9fafb; border: 2rpx solid #e5e7eb;
  865. border-radius: 16rpx; font-size: 28rpx; line-height: 1.7;
  866. box-sizing: border-box; transition: border-color 0.2s, box-shadow 0.2s;
  867. }
  868. .form-textarea:focus { border-color: #6366f1; box-shadow: 0 0 0 6rpx rgba(99,102,241,0.08); }
  869. .textarea-error { border-color: #ef4444 !important; }
  870. .textarea-footer {
  871. position: absolute; bottom: 8rpx; right: 24rpx; left: 24rpx;
  872. display: flex; align-items: center; justify-content: flex-end; gap: 20rpx;
  873. height: 40rpx; pointer-events: none;
  874. }
  875. .char-count { font-size: 22rpx; color: #9ca3af; pointer-events: auto; }
  876. .clear-btn { font-size: 22rpx; color: #ef4444; pointer-events: auto; }
  877. /* AI 推荐按钮 */
  878. .ai-btn-row { display: flex; flex-direction: column; gap: 10rpx; }
  879. .btn-ai-recommend {
  880. display: flex; align-items: center; justify-content: center; gap: 10rpx;
  881. width: 100%; height: 84rpx;
  882. background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
  883. border-radius: 16rpx; border: none;
  884. font-size: 28rpx; font-weight: 600; color: white;
  885. transition: transform 0.15s;
  886. }
  887. .btn-ai-recommend:active { transform: scale(0.98); }
  888. .btn-ai-recommend[disabled] { opacity: 0.5; }
  889. .btn-ai-loading { opacity: 0.8; }
  890. .ai-btn-icon { font-size: 32rpx; }
  891. /* ========== 配置摘要卡片 ========== */
  892. .config-summary-card {
  893. background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
  894. border: 2rpx solid #bbf7d0;
  895. }
  896. .config-source-tag {
  897. font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx;
  898. background: #10b981; color: white; font-weight: 600; margin-left: auto;
  899. }
  900. .config-source-tag.template-tag { background: #6366f1; }
  901. .config-tags {
  902. display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 16rpx;
  903. }
  904. .config-tag {
  905. display: flex; align-items: center; gap: 6rpx;
  906. padding: 12rpx 18rpx; background: white;
  907. border-radius: 12rpx; border: 1.5rpx solid #d1d5db;
  908. font-size: 24rpx; transition: all 0.15s;
  909. }
  910. .config-tag:active { background: #f9fafb; transform: scale(0.97); }
  911. .config-tag.scale-tag { border-color: #6366f1; background: #eef2ff; }
  912. .config-tag-icon { font-size: 24rpx; }
  913. .config-tag-label { font-size: 20rpx; color: #9ca3af; }
  914. .config-tag-value { font-size: 24rpx; font-weight: 600; color: #1f2937; }
  915. .config-tag-edit { font-size: 20rpx; color: #6366f1; margin-left: 4rpx; }
  916. /* 预估信息 */
  917. .estimate-bar {
  918. display: flex; align-items: center; flex-wrap: wrap; gap: 8rpx;
  919. margin-top: 20rpx; padding: 20rpx;
  920. background: rgba(255,255,255,0.7); border-radius: 14rpx;
  921. }
  922. .estimate-item { display: flex; align-items: baseline; gap: 2rpx; }
  923. .est-icon { font-size: 22rpx; }
  924. .est-num { font-size: 28rpx; font-weight: 700; color: #065f46; }
  925. .est-unit { font-size: 22rpx; color: #047857; margin-left: 2rpx; }
  926. .est-divider { font-size: 22rpx; color: #6b7280; margin: 0 6rpx; }
  927. .quota-badge { margin-left: auto; flex-shrink: 0; }
  928. .quota-ok { font-size: 22rpx; color: #059669; font-weight: 600; }
  929. .quota-warn { font-size: 22rpx; color: #dc2626; font-weight: 600; }
  930. /* ========== 规模选择器 ========== */
  931. .scale-grid {
  932. display: grid; grid-template-columns: repeat(3, 1fr); gap: 12rpx;
  933. margin-top: 16rpx;
  934. }
  935. .scale-card {
  936. display: flex; flex-direction: column; align-items: center; gap: 6rpx;
  937. padding: 20rpx 12rpx;
  938. background: #f9fafb; border: 2rpx solid #e5e7eb;
  939. border-radius: 14rpx; transition: all 0.2s;
  940. }
  941. .scale-card:active { transform: scale(0.96); }
  942. .scale-active {
  943. background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
  944. border-color: #6366f1; box-shadow: 0 0 0 3rpx rgba(99,102,241,0.1);
  945. }
  946. .scale-name { font-size: 26rpx; font-weight: 700; color: #1f2937; }
  947. .scale-active .scale-name { color: #4f46e5; }
  948. .scale-words { font-size: 22rpx; color: #6b7280; }
  949. .scale-active .scale-words { color: #6366f1; font-weight: 600; }
  950. .scale-audio { font-size: 20rpx; color: #9ca3af; }
  951. .scale-active .scale-audio { color: #6366f1; }
  952. .show-more-row { text-align: center; margin-top: 16rpx; }
  953. .show-more-btn {
  954. font-size: 24rpx; color: #6366f1; font-weight: 500;
  955. padding: 12rpx 32rpx; background: #eef2ff; border-radius: 12rpx;
  956. }
  957. /* ========== Chip 选择器组 ========== */
  958. .chip-section { margin-bottom: 28rpx; }
  959. .chip-section:last-child { margin-bottom: 0; }
  960. .chip-title {
  961. font-size: 26rpx; font-weight: 600; color: #374151;
  962. display: block; margin-bottom: 14rpx;
  963. }
  964. .chip-required { color: #ef4444; }
  965. .chip-row { display: flex; flex-wrap: wrap; gap: 12rpx; }
  966. .chip {
  967. display: flex; align-items: center; gap: 8rpx;
  968. padding: 14rpx 20rpx;
  969. background: #f9fafb; border: 2rpx solid #e5e7eb;
  970. border-radius: 14rpx; transition: all 0.15s;
  971. }
  972. .chip:active { transform: scale(0.97); }
  973. .chip-active {
  974. background: #eef2ff; border-color: #6366f1;
  975. box-shadow: 0 0 0 2rpx rgba(99,102,241,0.15);
  976. }
  977. .chip-sm { padding: 12rpx 18rpx; }
  978. .chip-xs { padding: 10rpx 16rpx; border-radius: 10rpx; }
  979. .chip-template { border-style: dashed; }
  980. .chip-icon { font-size: 26rpx; flex-shrink: 0; }
  981. .chip-sm .chip-icon { font-size: 24rpx; }
  982. .chip-body { display: flex; flex-direction: column; gap: 2rpx; }
  983. .chip-text { font-size: 26rpx; font-weight: 500; color: #374151; white-space: nowrap; }
  984. .chip-active .chip-text { color: #4f46e5; font-weight: 600; }
  985. .chip-sm .chip-text, .chip-xs .chip-text { font-size: 24rpx; }
  986. .chip-desc { font-size: 20rpx; color: #9ca3af; }
  987. .chip-active .chip-desc { color: #818cf8; }
  988. .chip-desc-inline { display: inline; margin-left: 6rpx; }
  989. .chip-check { font-size: 20rpx; color: #10b981; font-weight: 700; margin-left: 4rpx; }
  990. /* ========== 高级定制 ========== */
  991. .adv-badge {
  992. font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 10rpx;
  993. background: #6366f1; color: white; font-weight: 600; margin-left: 8rpx;
  994. }
  995. .advanced-content { margin-top: 24rpx; padding-top: 24rpx; border-top: 1rpx solid #f3f4f6; }
  996. /* ========== 声音速度 ========== */
  997. .speed-display {
  998. display: flex;
  999. align-items: center;
  1000. justify-content: center;
  1001. margin-bottom: 8rpx;
  1002. }
  1003. .speed-value {
  1004. font-size: 36rpx;
  1005. font-weight: 700;
  1006. color: #4f46e5;
  1007. }
  1008. .param-range {
  1009. display: flex;
  1010. justify-content: space-between;
  1011. font-size: 22rpx;
  1012. color: #9ca3af;
  1013. margin-top: 4rpx;
  1014. }
  1015. /* ========== 底部操作区 ========== */
  1016. .bottom-bar {
  1017. position: fixed; bottom: 0; left: 0; right: 0;
  1018. padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
  1019. background: rgba(255,255,255,0.92);
  1020. backdrop-filter: blur(20px);
  1021. -webkit-backdrop-filter: blur(20px);
  1022. border-top: 0.5rpx solid rgba(0,0,0,0.06);
  1023. display: flex; flex-direction: column; align-items: center; gap: 12rpx;
  1024. z-index: 100;
  1025. }
  1026. .btn-create {
  1027. width: 100%; height: 96rpx;
  1028. background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
  1029. border-radius: 20rpx; border: none;
  1030. font-size: 32rpx; font-weight: 700; color: white;
  1031. display: flex; align-items: center; justify-content: center; gap: 10rpx;
  1032. box-shadow: 0 8rpx 24rpx rgba(79,70,229,0.25);
  1033. transition: transform 0.15s, box-shadow 0.15s;
  1034. }
  1035. .btn-create:active { transform: scale(0.98); box-shadow: 0 4rpx 12rpx rgba(79,70,229,0.2); }
  1036. .btn-create[disabled] { opacity: 0.4; box-shadow: none; }
  1037. .btn-create-icon { font-size: 36rpx; }
  1038. .bottom-link {
  1039. font-size: 24rpx; color: #9ca3af; text-align: center;
  1040. }
  1041. .bottom-link-highlight { color: #6366f1; font-weight: 600; }
  1042. </style>