index.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446
  1. <template>
  2. <view class="page">
  3. <!-- 顶部导航栏 -->
  4. <view class="nav-bar">
  5. <view class="nav-content">
  6. <text class="page-title">生成音频</text>
  7. </view>
  8. </view>
  9. <!-- 主内容区 -->
  10. <view class="main-content">
  11. <!-- 文本输入区 -->
  12. <view class="card input-card">
  13. <view class="card-header">
  14. <text class="card-title">输入文本</text>
  15. <text class="word-count" :class="{ warning: text.length > 1800, error: text.length >= 2000 }">
  16. {{ text.length }}/2000 字
  17. </text>
  18. </view>
  19. <textarea
  20. v-model="text"
  21. class="text-input"
  22. placeholder="请输入要转换的文本内容..."
  23. :maxlength="2000"
  24. auto-height
  25. />
  26. <view class="input-actions">
  27. <button class="action-btn ai-generate-btn" @click="goToAIGenerate">
  28. <text>🤖 AI 生成内容</text>
  29. </button>
  30. <button class="action-btn" @click="clearText">清空</button>
  31. <button class="action-btn" @click="pasteText">粘贴</button>
  32. </view>
  33. <view class="input-hint">
  34. <text>支持长按粘贴文本内容</text>
  35. </view>
  36. </view>
  37. <!-- 音色选择 - 网格布局 -->
  38. <view class="card voice-card">
  39. <text class="card-title">选择音色</text>
  40. <view class="voice-grid">
  41. <view
  42. v-for="voice in audioStore.voices"
  43. :key="voice.id"
  44. class="voice-item"
  45. :class="{ active: selectedVoice === voice.id }"
  46. @click="selectedVoice = voice.id"
  47. >
  48. <view class="voice-icon">
  49. <text>{{ voice.gender === 'female' ? '👩' : '👨' }}</text>
  50. </view>
  51. <text class="voice-name">{{ voice.name }}</text>
  52. <view class="voice-preview-btn" @click.stop="previewVoice(voice.id)">
  53. <text v-if="previewingVoice !== voice.id">▶</text>
  54. <text v-else>⏹</text>
  55. </view>
  56. </view>
  57. </view>
  58. </view>
  59. <!-- 参数调节 -->
  60. <view class="card params-card">
  61. <text class="card-title">参数调节</text>
  62. <view class="param-item">
  63. <view class="param-header">
  64. <text class="param-label">语速</text>
  65. <view class="param-value">{{ voiceParams.speed.toFixed(1) }}x</view>
  66. </view>
  67. <slider
  68. :value="(voiceParams.speed - 0.5) * 100 / 1.5"
  69. :min="0"
  70. :max="100"
  71. @changing="(e: any) => voiceParams.speed = Number((0.5 + (e.detail.value / 100) * 1.5).toFixed(1))"
  72. activeColor="#4F46E5"
  73. backgroundColor="#e5e7eb"
  74. block-size="20"
  75. />
  76. <view class="param-range">
  77. <text>0.5x</text>
  78. <text>2.0x</text>
  79. </view>
  80. </view>
  81. <view class="param-item">
  82. <view class="param-header">
  83. <text class="param-label">音调</text>
  84. <view class="param-value">{{ voiceParams.pitch > 0 ? '+' : '' }}{{ voiceParams.pitch }}</view>
  85. </view>
  86. <slider
  87. :value="(voiceParams.pitch + 500) / 10"
  88. :min="0"
  89. :max="100"
  90. @changing="(e: any) => voiceParams.pitch = Math.round(e.detail.value * 10 - 500)"
  91. activeColor="#4F46E5"
  92. backgroundColor="#e5e7eb"
  93. block-size="20"
  94. />
  95. <view class="param-range">
  96. <text>-500</text>
  97. <text>+500</text>
  98. </view>
  99. </view>
  100. <view class="param-item">
  101. <view class="param-header">
  102. <text class="param-label">音量</text>
  103. <view class="param-value">{{ voiceParams.volume }}%</view>
  104. </view>
  105. <slider
  106. :value="voiceParams.volume"
  107. :min="0"
  108. :max="100"
  109. @changing="(e: any) => voiceParams.volume = e.detail.value"
  110. activeColor="#4F46E5"
  111. backgroundColor="#e5e7eb"
  112. block-size="20"
  113. />
  114. <view class="param-range">
  115. <text>0%</text>
  116. <text>100%</text>
  117. </view>
  118. </view>
  119. </view>
  120. <!-- 专辑选择 -->
  121. <view class="card album-card">
  122. <view class="card-header-row">
  123. <text class="card-title">📚 所属专辑</text>
  124. <text class="album-add-btn" @click="showAlbumPanel = true">+ 新建专辑</text>
  125. </view>
  126. <view class="album-selector" @click="showAlbumListPanel = true">
  127. <text v-if="selectedAlbum" class="album-name">{{ selectedAlbum.title }}</text>
  128. <text v-else class="album-placeholder">点击选择专辑(可选)</text>
  129. <text class="album-arrow">›</text>
  130. </view>
  131. </view>
  132. <!-- 生成按钮 -->
  133. <button
  134. class="generate-btn"
  135. :disabled="!canGenerate"
  136. @click="handleGenerate"
  137. @touchstart="btnPressed = true"
  138. @touchend="btnPressed = false"
  139. :class="{ pressed: btnPressed }"
  140. >
  141. <text class="btn-text">{{ generating ? '生成中...' : '🎵 生成音频' }}</text>
  142. </button>
  143. <!-- 视频生成入口 -->
  144. <view class="video-entry-card" @click="goToVideoGenerator">
  145. <view class="video-entry-content">
  146. <text class="video-entry-icon">🎬</text>
  147. <view class="video-entry-text">
  148. <text class="video-entry-title">视频生成</text>
  149. <text class="video-entry-desc">图片 + 音频 = 精美视频</text>
  150. </view>
  151. </view>
  152. <text class="video-entry-arrow">→</text>
  153. </view>
  154. </view>
  155. <!-- 生成成功弹窗 -->
  156. <view v-if="showSuccessModal" class="success-modal" @click="closeSuccessModal">
  157. <view class="success-content" @click.stop>
  158. <!-- 成功动画 -->
  159. <view class="success-animation">
  160. <view class="success-circle">
  161. <text class="success-checkmark">✓</text>
  162. </view>
  163. <view class="success-sparkles">
  164. <text class="sparkle">✨</text>
  165. <text class="sparkle">🌟</text>
  166. <text class="sparkle">✨</text>
  167. </view>
  168. </view>
  169. <text class="success-title">生成成功!</text>
  170. <text class="success-desc">音频已准备就绪</text>
  171. <!-- 操作按钮 -->
  172. <view class="success-actions">
  173. <button class="success-btn play-btn" @click="goToPlayer">
  174. <text>▶ 立即播放</text>
  175. </button>
  176. <button class="success-btn share-btn" @click="shareAudio">
  177. <text>📤 分享给好友</text>
  178. </button>
  179. <button class="success-btn close-btn" @click="closeSuccessModal">
  180. <text>继续生成</text>
  181. </button>
  182. </view>
  183. </view>
  184. </view>
  185. <!-- 新手引导 -->
  186. <view v-if="showGuide" class="guide-overlay">
  187. <view class="guide-content">
  188. <view class="guide-header">
  189. <text class="guide-step">步骤 {{ guideStep }} / {{ guideSteps.length }}</text>
  190. </view>
  191. <view class="guide-body">
  192. <text class="guide-title">{{ guideSteps[guideStep - 1]?.title }}</text>
  193. <text class="guide-desc">{{ guideSteps[guideStep - 1]?.desc }}</text>
  194. </view>
  195. <view class="guide-dots">
  196. <view
  197. v-for="i in guideSteps.length"
  198. :key="i"
  199. class="guide-dot"
  200. :class="{ active: i === guideStep }"
  201. />
  202. </view>
  203. <view class="guide-actions">
  204. <button v-if="guideStep > 1" class="guide-btn skip" @click="closeGuide">
  205. <text>跳过</text>
  206. </button>
  207. <button v-if="guideStep < guideSteps.length" class="guide-btn next" @click="nextGuideStep">
  208. <text>下一步</text>
  209. </button>
  210. <button v-else class="guide-btn finish" @click="closeGuide">
  211. <text>开始使用</text>
  212. </button>
  213. </view>
  214. </view>
  215. </view>
  216. <!-- 额度用尽弹窗 -->
  217. <view v-if="showQuotaModal" class="quota-modal" @click="showQuotaModal = false">
  218. <view class="quota-content" @click.stop>
  219. <view class="quota-icon">👑</view>
  220. <text class="quota-title">今日免费额度已用完</text>
  221. <text class="quota-desc">开通会员解锁更多生成次数</text>
  222. <view class="quota-actions">
  223. <button class="quota-btn primary" @click="goToMember">
  224. <text>开通会员</text>
  225. </button>
  226. <button class="quota-btn secondary" @click="showQuotaModal = false">
  227. <text>稍后再说</text>
  228. </button>
  229. </view>
  230. </view>
  231. </view>
  232. <!-- 专辑列表面板 -->
  233. <view v-if="showAlbumListPanel" class="album-panel" @click="showAlbumListPanel = false">
  234. <view class="album-content" @click.stop>
  235. <view class="album-header">
  236. <text class="album-title">选择专辑</text>
  237. <text class="album-close" @click="showAlbumListPanel = false">✕</text>
  238. </view>
  239. <scroll-view scroll-y class="album-list">
  240. <view
  241. v-for="album in albums"
  242. :key="album.id"
  243. class="album-item"
  244. :class="{ active: selectedAlbum?.id === album.id }"
  245. @click="selectAlbum(album)"
  246. >
  247. <view class="album-item-content">
  248. <text class="album-item-title">{{ album.title }}</text>
  249. <text class="album-item-desc">{{ album.totalChapters }} 个章节</text>
  250. </view>
  251. <text v-if="selectedAlbum?.id === album.id" class="album-check">✓</text>
  252. </view>
  253. <view v-if="albums.length === 0" class="album-empty">
  254. <text>暂无专辑</text>
  255. </view>
  256. </scroll-view>
  257. <button class="album-create-btn" @click="showAlbumListPanel = false; showAlbumPanel = true">
  258. <text>+ 新建专辑</text>
  259. </button>
  260. </view>
  261. </view>
  262. <!-- 新建专辑面板 -->
  263. <view v-if="showAlbumPanel" class="album-panel" @click="showAlbumPanel = false">
  264. <view class="album-content" @click.stop>
  265. <view class="album-header">
  266. <text class="album-title">新建专辑</text>
  267. <text class="album-close" @click="showAlbumPanel = false">✕</text>
  268. </view>
  269. <view class="album-form">
  270. <view class="form-item">
  271. <text class="form-label">专辑名称 *</text>
  272. <input
  273. v-model="newAlbumTitle"
  274. class="form-input"
  275. placeholder="例如:我的有声书"
  276. maxlength="50"
  277. />
  278. </view>
  279. <view class="form-item">
  280. <text class="form-label">描述(可选)</text>
  281. <textarea
  282. v-model="newAlbumDescription"
  283. class="form-textarea"
  284. placeholder="简单描述这个专辑..."
  285. maxlength="200"
  286. />
  287. </view>
  288. </view>
  289. <button class="album-submit-btn" :disabled="!newAlbumTitle.trim()" @click="createAlbum">
  290. <text>创建专辑</text>
  291. </button>
  292. </view>
  293. </view>
  294. </view>
  295. </template>
  296. <script setup lang="ts">
  297. import { ref, computed, onMounted } from 'vue';
  298. import { onShow } from '@dcloudio/uni-app';
  299. import { useUserStore } from '../../store/user';
  300. import { useAudioStore } from '../../store/audio';
  301. import { get, post } from '../../utils/request';
  302. import type { VoiceParams } from '../../types';
  303. const userStore = useUserStore();
  304. const audioStore = useAudioStore();
  305. // 状态
  306. const text = ref('');
  307. const selectedVoice = ref('cherry');
  308. const voiceParams = ref<VoiceParams>({
  309. speed: 1.0,
  310. pitch: 0,
  311. volume: 50,
  312. });
  313. const generating = ref(false);
  314. const btnPressed = ref(false);
  315. const previewingVoice = ref<string | null>(null);
  316. let previewAudio: HTMLAudioElement | null = null;
  317. // 专辑相关状态
  318. const selectedAlbum = ref<{ id: string; title: string } | null>(null);
  319. const albums = ref<any[]>([]);
  320. const showAlbumListPanel = ref(false);
  321. const showAlbumPanel = ref(false);
  322. const newAlbumTitle = ref('');
  323. const newAlbumDescription = ref('');
  324. // 成功弹窗状态
  325. const showSuccessModal = ref(false);
  326. const generatedAudioId = ref('');
  327. // 新手引导状态
  328. const showGuide = ref(false);
  329. const guideStep = ref(1);
  330. const guideSteps = [
  331. { step: 1, title: '输入文本', desc: '在文本框中输入要转换的文本内容', target: '.input-card' },
  332. { step: 2, title: '选择音色', desc: '选择你喜欢的声音音色', target: '.voice-card' },
  333. { step: 3, title: '调整参数', desc: '可以调整语速、音调、音量', target: '.params-card' },
  334. { step: 4, title: '生成音频', desc: '点击按钮开始生成', target: '.generate-btn' },
  335. ];
  336. // 额度用尽弹窗
  337. const showQuotaModal = ref(false);
  338. // 计算属性
  339. const canGenerate = computed(() => {
  340. return text.value.trim().length > 0 && !generating.value;
  341. });
  342. // 专辑相关函数
  343. async function fetchAlbums() {
  344. try {
  345. const result = await get<{ data: { albums: any[] } }>('/book-generator/albums');
  346. albums.value = result.data?.albums || [];
  347. } catch (error) {
  348. console.error('获取专辑列表失败:', error);
  349. }
  350. }
  351. function selectAlbum(album: any) {
  352. selectedAlbum.value = { id: album.id, title: album.title };
  353. showAlbumListPanel.value = false;
  354. }
  355. async function createAlbum() {
  356. if (!newAlbumTitle.value.trim()) return;
  357. try {
  358. const result = await post<{ data: { id: string; title: string } }>('/book-generator/albums', {
  359. title: newAlbumTitle.value.trim(),
  360. description: newAlbumDescription.value.trim(),
  361. });
  362. if (result.code === 0 && result.data) {
  363. selectedAlbum.value = { id: String(result.data.id), title: result.data.title };
  364. await fetchAlbums(); // 刷新列表
  365. showAlbumPanel.value = false;
  366. newAlbumTitle.value = '';
  367. newAlbumDescription.value = '';
  368. uni.showToast({ title: '专辑创建成功', icon: 'success' });
  369. }
  370. } catch (error) {
  371. console.error('创建专辑失败:', error);
  372. uni.showToast({ title: '创建失败', icon: 'none' });
  373. }
  374. }
  375. // 初始化
  376. onMounted(async () => {
  377. await audioStore.fetchVoices();
  378. await fetchAlbums(); // 获取专辑列表
  379. // 检查是否显示新手引导(只显示一次)
  380. const hasSeenGuide = uni.getStorageSync('hasSeenCreateGuide');
  381. if (!hasSeenGuide) {
  382. setTimeout(() => {
  383. showGuide.value = true;
  384. }, 500);
  385. }
  386. });
  387. // 跳转到视频生成页面
  388. function goToVideoGenerator() {
  389. uni.navigateTo({ url: '/pages/video-generator/index' });
  390. }
  391. // 跳转到 AI 生成内容页面
  392. function goToAIGenerate() {
  393. uni.navigateTo({ url: '/pages/ai-generate/index' });
  394. }
  395. // 页面显示时检查是否有 AI 生成的文本
  396. onShow(() => {
  397. const aiText = uni.getStorageSync('ai_generated_text');
  398. if (aiText) {
  399. text.value = aiText;
  400. uni.removeStorageSync('ai_generated_text');
  401. uni.showToast({ title: '已填充 AI 生成的文本', icon: 'success' });
  402. }
  403. });
  404. // 清空文本
  405. function clearText() {
  406. text.value = '';
  407. }
  408. // 粘贴文本
  409. async function pasteText() {
  410. const content = await uni.getClipboardData();
  411. if (content.data) {
  412. text.value = content.data;
  413. }
  414. }
  415. // 试听音色
  416. async function previewVoice(voiceId: string) {
  417. // 如果正在试听当前音色,停止试听
  418. if (previewingVoice.value === voiceId) {
  419. stopPreview();
  420. return;
  421. }
  422. // 停止之前的试听
  423. stopPreview();
  424. try {
  425. previewingVoice.value = voiceId;
  426. uni.showLoading({ title: '生成试听音频...' });
  427. // 调用API生成预览音频
  428. const res = await post<{ audioUrl: string }>('/tts/preview', {
  429. voiceId,
  430. voiceParams: voiceParams.value,
  431. });
  432. uni.hideLoading();
  433. if (res.audioUrl) {
  434. // #ifdef H5
  435. previewAudio = new Audio('http://localhost:3000' + res.audioUrl);
  436. previewAudio.play();
  437. previewAudio.onended = () => {
  438. previewingVoice.value = null;
  439. previewAudio = null;
  440. };
  441. // #endif
  442. // #ifndef H5
  443. // 非H5环境直接停止
  444. uni.showToast({ title: '试听已播放', icon: 'success' });
  445. setTimeout(() => {
  446. previewingVoice.value = null;
  447. }, 2000);
  448. // #endif
  449. }
  450. } catch (error: any) {
  451. uni.hideLoading();
  452. uni.showToast({ title: '试听生成失败', icon: 'none' });
  453. previewingVoice.value = null;
  454. }
  455. }
  456. // 停止试听
  457. function stopPreview() {
  458. if (previewAudio) {
  459. previewAudio.pause();
  460. previewAudio = null;
  461. }
  462. previewingVoice.value = null;
  463. }
  464. // 生成音频
  465. async function handleGenerate() {
  466. if (!canGenerate.value) return;
  467. // 检查额度(模拟)
  468. const quotaExhausted = !userStore.memberStatus || userStore.memberStatus.quota.dailyRemaining <= 0;
  469. if (quotaExhausted) {
  470. showQuotaModal.value = true;
  471. return;
  472. }
  473. generating.value = true;
  474. try {
  475. const result = await audioStore.generateAudio(
  476. text.value,
  477. selectedVoice.value,
  478. voiceParams.value,
  479. {
  480. bookId: selectedAlbum.value?.id,
  481. }
  482. );
  483. // 保存生成的音频ID
  484. generatedAudioId.value = result.audioId;
  485. // 显示成功弹窗
  486. showSuccessModal.value = true;
  487. // 清空文本,方便下次输入
  488. text.value = '';
  489. } catch (error: any) {
  490. console.error('生成失败:', error);
  491. // 检查是否是额度用尽错误
  492. if (error.message?.includes('额度') || error.message?.includes('quota')) {
  493. showQuotaModal.value = true;
  494. } else {
  495. uni.showToast({
  496. title: error.message || '生成失败,请重试',
  497. icon: 'none'
  498. });
  499. }
  500. } finally {
  501. generating.value = false;
  502. }
  503. }
  504. // 关闭成功弹窗
  505. function closeSuccessModal() {
  506. showSuccessModal.value = false;
  507. generatedAudioId.value = '';
  508. }
  509. // 跳转到播放器
  510. function goToPlayer() {
  511. if (generatedAudioId.value) {
  512. uni.navigateTo({
  513. url: `/pages/player/index?id=${generatedAudioId.value}`,
  514. });
  515. }
  516. closeSuccessModal();
  517. }
  518. // 分享音频
  519. function shareAudio() {
  520. if (generatedAudioId.value) {
  521. // #ifdef H5
  522. const shareUrl = `${window.location.origin}/#/pages/player/index?id=${generatedAudioId.value}`;
  523. uni.setClipboardData({
  524. data: shareUrl,
  525. success: () => {
  526. uni.showToast({ title: '链接已复制', icon: 'success' });
  527. }
  528. });
  529. // #endif
  530. // #ifndef H5
  531. uni.showToast({ title: '分享功能开发中', icon: 'none' });
  532. // #endif
  533. }
  534. }
  535. // 新手引导 - 下一步
  536. function nextGuideStep() {
  537. if (guideStep.value < guideSteps.length) {
  538. guideStep.value++;
  539. }
  540. }
  541. // 新手引导 - 关闭
  542. function closeGuide() {
  543. showGuide.value = false;
  544. uni.setStorageSync('hasSeenCreateGuide', true);
  545. }
  546. // 跳转到会员页
  547. function goToMember() {
  548. showQuotaModal.value = false;
  549. uni.navigateTo({ url: '/pages/member/index' });
  550. }
  551. </script>
  552. <style scoped>
  553. .page {
  554. min-height: 100vh;
  555. background: #f5f5f5;
  556. }
  557. .nav-bar {
  558. position: fixed;
  559. top: 0;
  560. left: 0;
  561. right: 0;
  562. height: 88rpx;
  563. background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
  564. z-index: 100;
  565. padding-top: 44rpx;
  566. }
  567. .nav-content {
  568. display: flex;
  569. align-items: center;
  570. justify-content: center;
  571. padding: 0 32rpx;
  572. height: 88rpx;
  573. }
  574. .page-title {
  575. font-size: 34rpx;
  576. font-weight: 600;
  577. color: #ffffff;
  578. }
  579. .main-content {
  580. padding: 132rpx 32rpx 180rpx;
  581. }
  582. .card {
  583. background: #ffffff;
  584. border-radius: 24rpx;
  585. padding: 32rpx;
  586. margin-bottom: 24rpx;
  587. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  588. }
  589. .card-header {
  590. display: flex;
  591. justify-content: space-between;
  592. align-items: center;
  593. margin-bottom: 24rpx;
  594. }
  595. .card-title {
  596. font-size: 32rpx;
  597. font-weight: 600;
  598. color: #1f2937;
  599. }
  600. .word-count {
  601. font-size: 24rpx;
  602. color: #6b7280;
  603. }
  604. .word-count.warning {
  605. color: #f59e0b;
  606. }
  607. .word-count.error {
  608. color: #ef4444;
  609. font-weight: 600;
  610. }
  611. .text-input {
  612. width: 100%;
  613. height: 300rpx;
  614. font-size: 28rpx;
  615. color: #1f2937;
  616. line-height: 1.6;
  617. overflow-y: auto;
  618. }
  619. .input-actions {
  620. display: flex;
  621. justify-content: flex-end;
  622. gap: 16rpx;
  623. margin-top: 16rpx;
  624. }
  625. .input-hint {
  626. margin-top: 12rpx;
  627. text-align: right;
  628. }
  629. .input-hint text {
  630. font-size: 22rpx;
  631. color: #9ca3af;
  632. }
  633. .action-btn {
  634. font-size: 24rpx;
  635. color: #6b7280;
  636. background: #f3f4f6;
  637. padding: 12rpx 24rpx;
  638. border-radius: 12rpx;
  639. border: none;
  640. transition: all 0.2s;
  641. }
  642. .action-btn:active {
  643. opacity: 0.7;
  644. transform: scale(0.98);
  645. }
  646. .action-btn::after {
  647. border: none;
  648. }
  649. .voice-list {
  650. white-space: nowrap;
  651. margin-top: 24rpx;
  652. }
  653. /* 音色网格布局 - 2列 */
  654. .voice-grid {
  655. display: grid;
  656. grid-template-columns: repeat(2, 1fr);
  657. gap: 16rpx;
  658. margin-top: 24rpx;
  659. }
  660. .voice-item {
  661. position: relative;
  662. display: flex;
  663. flex-direction: column;
  664. align-items: center;
  665. padding: 24rpx 16rpx;
  666. border-radius: 16rpx;
  667. background: #f9fafb;
  668. border: 2rpx solid transparent;
  669. transition: all 0.3s;
  670. }
  671. .voice-item.active {
  672. background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
  673. border-color: #4f46e5;
  674. }
  675. .voice-item.active .voice-name {
  676. color: #ffffff;
  677. }
  678. .voice-item:active {
  679. transform: scale(0.95);
  680. }
  681. .voice-icon {
  682. font-size: 48rpx;
  683. margin-bottom: 12rpx;
  684. }
  685. .voice-name {
  686. font-size: 26rpx;
  687. font-weight: 500;
  688. color: #1f2937;
  689. margin-bottom: 4rpx;
  690. }
  691. .voice-preview-btn {
  692. position: absolute;
  693. top: 8rpx;
  694. right: 8rpx;
  695. width: 40rpx;
  696. height: 40rpx;
  697. background: rgba(79, 70, 229, 0.9);
  698. border-radius: 50%;
  699. display: flex;
  700. align-items: center;
  701. justify-content: center;
  702. font-size: 20rpx;
  703. color: #ffffff;
  704. }
  705. .voice-item.active .voice-preview-btn {
  706. background: rgba(255, 255, 255, 0.9);
  707. color: #4f46e5;
  708. }
  709. .voice-desc {
  710. font-size: 20rpx;
  711. color: #6b7280;
  712. }
  713. .param-item {
  714. margin-top: 24rpx;
  715. }
  716. .param-header {
  717. display: flex;
  718. justify-content: space-between;
  719. align-items: center;
  720. margin-bottom: 12rpx;
  721. }
  722. .param-label {
  723. font-size: 26rpx;
  724. color: #6b7280;
  725. }
  726. .param-value {
  727. font-size: 28rpx;
  728. font-weight: 600;
  729. color: #4F46E5;
  730. background: #EEF2FF;
  731. padding: 4rpx 16rpx;
  732. border-radius: 8rpx;
  733. }
  734. .param-range {
  735. display: flex;
  736. justify-content: space-between;
  737. margin-top: 8rpx;
  738. }
  739. .param-range text {
  740. font-size: 22rpx;
  741. color: #9ca3af;
  742. }
  743. /* 专辑选择 */
  744. .album-card {
  745. background: #ffffff;
  746. border-radius: 24rpx;
  747. padding: 32rpx;
  748. margin-bottom: 24rpx;
  749. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
  750. }
  751. .card-header-row {
  752. display: flex;
  753. justify-content: space-between;
  754. align-items: center;
  755. margin-bottom: 20rpx;
  756. }
  757. .album-add-btn {
  758. font-size: 28rpx;
  759. color: #4f46e5;
  760. font-weight: 500;
  761. }
  762. .album-selector {
  763. display: flex;
  764. justify-content: space-between;
  765. align-items: center;
  766. background: #f9fafb;
  767. border-radius: 16rpx;
  768. padding: 24rpx;
  769. }
  770. .album-name {
  771. font-size: 28rpx;
  772. color: #1f2937;
  773. font-weight: 500;
  774. }
  775. .album-placeholder {
  776. font-size: 28rpx;
  777. color: #9ca3af;
  778. }
  779. .album-arrow {
  780. font-size: 40rpx;
  781. color: #9ca3af;
  782. }
  783. /* 专辑面板 */
  784. .album-panel {
  785. position: fixed;
  786. top: 0;
  787. left: 0;
  788. right: 0;
  789. bottom: 0;
  790. background: rgba(0, 0, 0, 0.5);
  791. z-index: 1000;
  792. display: flex;
  793. align-items: flex-end;
  794. }
  795. .album-content {
  796. width: 100%;
  797. max-height: 80vh;
  798. background: #ffffff;
  799. border-radius: 32rpx 32rpx 0 0;
  800. padding: 32rpx;
  801. display: flex;
  802. flex-direction: column;
  803. }
  804. .album-header {
  805. display: flex;
  806. justify-content: space-between;
  807. align-items: center;
  808. margin-bottom: 24rpx;
  809. }
  810. .album-title {
  811. font-size: 34rpx;
  812. font-weight: 600;
  813. color: #1f2937;
  814. }
  815. .album-close {
  816. font-size: 40rpx;
  817. color: #9ca3af;
  818. padding: 8rpx;
  819. }
  820. .album-list {
  821. flex: 1;
  822. max-height: 50vh;
  823. }
  824. .album-item {
  825. display: flex;
  826. justify-content: space-between;
  827. align-items: center;
  828. padding: 24rpx;
  829. border-radius: 16rpx;
  830. background: #f9fafb;
  831. margin-bottom: 16rpx;
  832. }
  833. .album-item.active {
  834. background: #eef2ff;
  835. border: 2px solid #4f46e5;
  836. }
  837. .album-item-content {
  838. flex: 1;
  839. }
  840. .album-item-title {
  841. display: block;
  842. font-size: 28rpx;
  843. font-weight: 600;
  844. color: #1f2937;
  845. margin-bottom: 8rpx;
  846. }
  847. .album-item-desc {
  848. display: block;
  849. font-size: 24rpx;
  850. color: #6b7280;
  851. }
  852. .album-check {
  853. font-size: 32rpx;
  854. color: #4f46e5;
  855. margin-left: 16rpx;
  856. }
  857. .album-empty {
  858. text-align: center;
  859. padding: 60rpx 0;
  860. color: #9ca3af;
  861. font-size: 28rpx;
  862. }
  863. .album-create-btn,
  864. .album-submit-btn {
  865. width: 100%;
  866. height: 100rpx;
  867. background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
  868. border-radius: 24rpx;
  869. border: none;
  870. display: flex;
  871. align-items: center;
  872. justify-content: center;
  873. font-size: 32rpx;
  874. font-weight: 600;
  875. color: #ffffff;
  876. margin-top: 24rpx;
  877. }
  878. .album-create-btn::after,
  879. .album-submit-btn::after {
  880. border: none;
  881. }
  882. /* 专辑表单 */
  883. .album-form {
  884. margin: 24rpx 0;
  885. }
  886. .form-item {
  887. margin-bottom: 24rpx;
  888. }
  889. .form-label {
  890. display: block;
  891. font-size: 28rpx;
  892. color: #1f2937;
  893. font-weight: 500;
  894. margin-bottom: 12rpx;
  895. }
  896. .form-input {
  897. width: 100%;
  898. height: 88rpx;
  899. background: #f9fafb;
  900. border-radius: 16rpx;
  901. padding: 0 24rpx;
  902. font-size: 28rpx;
  903. color: #1f2937;
  904. }
  905. .form-textarea {
  906. width: 100%;
  907. min-height: 160rpx;
  908. background: #f9fafb;
  909. border-radius: 16rpx;
  910. padding: 24rpx;
  911. font-size: 28rpx;
  912. color: #1f2937;
  913. line-height: 1.6;
  914. }
  915. .generate-btn {
  916. width: 100%;
  917. height: 100rpx;
  918. background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
  919. border-radius: 24rpx;
  920. border: none;
  921. display: flex;
  922. align-items: center;
  923. justify-content: center;
  924. box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.4);
  925. transition: all 0.3s ease;
  926. }
  927. .generate-btn::after {
  928. border: none;
  929. }
  930. .generate-btn[disabled] {
  931. background: #e5e7eb;
  932. box-shadow: none;
  933. }
  934. .generate-btn.pressed {
  935. transform: scale(0.96);
  936. box-shadow: 0 4rpx 12rpx rgba(79, 70, 229, 0.3);
  937. }
  938. /* 视频生成入口卡片 */
  939. .video-entry-card {
  940. display: flex;
  941. align-items: center;
  942. justify-content: space-between;
  943. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  944. border-radius: 16rpx;
  945. padding: 30rpx;
  946. margin-top: 20rpx;
  947. }
  948. .video-entry-content {
  949. display: flex;
  950. align-items: center;
  951. gap: 20rpx;
  952. }
  953. .video-entry-icon {
  954. font-size: 60rpx;
  955. }
  956. .video-entry-text {
  957. display: flex;
  958. flex-direction: column;
  959. gap: 6rpx;
  960. }
  961. .video-entry-title {
  962. font-size: 32rpx;
  963. font-weight: 600;
  964. color: #ffffff;
  965. }
  966. .video-entry-desc {
  967. font-size: 24rpx;
  968. color: rgba(255, 255, 255, 0.8);
  969. }
  970. .video-entry-arrow {
  971. font-size: 40rpx;
  972. color: #ffffff;
  973. }
  974. .btn-text {
  975. font-size: 34rpx;
  976. font-weight: 600;
  977. color: #ffffff;
  978. letter-spacing: 2rpx;
  979. }
  980. .ai-btn {
  981. display: flex;
  982. align-items: center;
  983. gap: 8rpx;
  984. }
  985. /* 成功弹窗 */
  986. .success-modal {
  987. position: fixed;
  988. top: 0;
  989. left: 0;
  990. right: 0;
  991. bottom: 0;
  992. background: rgba(0, 0, 0, 0.6);
  993. z-index: 2000;
  994. display: flex;
  995. align-items: center;
  996. justify-content: center;
  997. }
  998. .success-content {
  999. width: 600rpx;
  1000. background: #ffffff;
  1001. border-radius: 32rpx;
  1002. padding: 60rpx 40rpx;
  1003. display: flex;
  1004. flex-direction: column;
  1005. align-items: center;
  1006. }
  1007. .success-animation {
  1008. position: relative;
  1009. margin-bottom: 40rpx;
  1010. }
  1011. .success-circle {
  1012. width: 160rpx;
  1013. height: 160rpx;
  1014. border-radius: 50%;
  1015. background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
  1016. display: flex;
  1017. align-items: center;
  1018. justify-content: center;
  1019. animation: success-pop 0.5s ease;
  1020. }
  1021. .success-checkmark {
  1022. font-size: 80rpx;
  1023. color: #ffffff;
  1024. font-weight: bold;
  1025. }
  1026. .success-sparkles {
  1027. position: absolute;
  1028. top: 50%;
  1029. left: 50%;
  1030. transform: translate(-50%, -50%);
  1031. width: 200rpx;
  1032. height: 200rpx;
  1033. }
  1034. .sparkle {
  1035. position: absolute;
  1036. font-size: 32rpx;
  1037. animation: sparkle 1.5s ease-in-out infinite;
  1038. }
  1039. .sparkle:nth-child(1) {
  1040. top: 0;
  1041. left: 50%;
  1042. animation-delay: 0s;
  1043. }
  1044. .sparkle:nth-child(2) {
  1045. top: 50%;
  1046. right: 0;
  1047. animation-delay: 0.5s;
  1048. }
  1049. .sparkle:nth-child(3) {
  1050. bottom: 0;
  1051. left: 50%;
  1052. animation-delay: 1s;
  1053. }
  1054. @keyframes success-pop {
  1055. 0% {
  1056. transform: scale(0);
  1057. }
  1058. 50% {
  1059. transform: scale(1.1);
  1060. }
  1061. 100% {
  1062. transform: scale(1);
  1063. }
  1064. }
  1065. @keyframes sparkle {
  1066. 0%, 100% {
  1067. opacity: 0;
  1068. transform: scale(0.5);
  1069. }
  1070. 50% {
  1071. opacity: 1;
  1072. transform: scale(1);
  1073. }
  1074. }
  1075. .success-title {
  1076. font-size: 40rpx;
  1077. font-weight: 600;
  1078. color: #1f2937;
  1079. margin-bottom: 12rpx;
  1080. }
  1081. .success-desc {
  1082. font-size: 28rpx;
  1083. color: #6b7280;
  1084. margin-bottom: 40rpx;
  1085. }
  1086. .success-actions {
  1087. width: 100%;
  1088. display: flex;
  1089. flex-direction: column;
  1090. gap: 20rpx;
  1091. }
  1092. .success-btn {
  1093. width: 100%;
  1094. height: 88rpx;
  1095. border-radius: 44rpx;
  1096. display: flex;
  1097. align-items: center;
  1098. justify-content: center;
  1099. font-size: 30rpx;
  1100. border: none;
  1101. }
  1102. .success-btn::after {
  1103. border: none;
  1104. }
  1105. .play-btn {
  1106. background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
  1107. color: #ffffff;
  1108. }
  1109. .share-btn {
  1110. background: #f3f4f6;
  1111. color: #4b5563;
  1112. }
  1113. .close-btn {
  1114. background: transparent;
  1115. color: #9ca3af;
  1116. }
  1117. /* 新手引导 */
  1118. .guide-overlay {
  1119. position: fixed;
  1120. top: 0;
  1121. left: 0;
  1122. right: 0;
  1123. bottom: 0;
  1124. background: rgba(0, 0, 0, 0.8);
  1125. z-index: 3000;
  1126. display: flex;
  1127. align-items: center;
  1128. justify-content: center;
  1129. }
  1130. .guide-content {
  1131. width: 600rpx;
  1132. background: #ffffff;
  1133. border-radius: 32rpx;
  1134. padding: 48rpx 40rpx;
  1135. display: flex;
  1136. flex-direction: column;
  1137. align-items: center;
  1138. }
  1139. .guide-header {
  1140. margin-bottom: 24rpx;
  1141. }
  1142. .guide-step {
  1143. font-size: 24rpx;
  1144. color: #9ca3af;
  1145. }
  1146. .guide-body {
  1147. text-align: center;
  1148. margin-bottom: 32rpx;
  1149. }
  1150. .guide-title {
  1151. font-size: 36rpx;
  1152. font-weight: 600;
  1153. color: #1f2937;
  1154. display: block;
  1155. margin-bottom: 16rpx;
  1156. }
  1157. .guide-desc {
  1158. font-size: 28rpx;
  1159. color: #6b7280;
  1160. display: block;
  1161. line-height: 1.6;
  1162. }
  1163. .guide-dots {
  1164. display: flex;
  1165. gap: 12rpx;
  1166. margin-bottom: 32rpx;
  1167. }
  1168. .guide-dot {
  1169. width: 12rpx;
  1170. height: 12rpx;
  1171. border-radius: 50%;
  1172. background: #e5e7eb;
  1173. }
  1174. .guide-dot.active {
  1175. background: #4f46e5;
  1176. width: 32rpx;
  1177. border-radius: 6rpx;
  1178. }
  1179. .guide-actions {
  1180. display: flex;
  1181. gap: 20rpx;
  1182. width: 100%;
  1183. }
  1184. .guide-btn {
  1185. flex: 1;
  1186. height: 88rpx;
  1187. border-radius: 44rpx;
  1188. display: flex;
  1189. align-items: center;
  1190. justify-content: center;
  1191. font-size: 30rpx;
  1192. border: none;
  1193. }
  1194. .guide-btn::after {
  1195. border: none;
  1196. }
  1197. .guide-btn.skip {
  1198. background: #f3f4f6;
  1199. color: #6b7280;
  1200. }
  1201. .guide-btn.next,
  1202. .guide-btn.finish {
  1203. background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
  1204. color: #ffffff;
  1205. }
  1206. /* 额度用尽弹窗 */
  1207. .quota-modal {
  1208. position: fixed;
  1209. top: 0;
  1210. left: 0;
  1211. right: 0;
  1212. bottom: 0;
  1213. background: rgba(0, 0, 0, 0.6);
  1214. z-index: 3000;
  1215. display: flex;
  1216. align-items: center;
  1217. justify-content: center;
  1218. }
  1219. .quota-content {
  1220. width: 600rpx;
  1221. background: #ffffff;
  1222. border-radius: 32rpx;
  1223. padding: 60rpx 40rpx;
  1224. display: flex;
  1225. flex-direction: column;
  1226. align-items: center;
  1227. }
  1228. .quota-icon {
  1229. font-size: 100rpx;
  1230. margin-bottom: 24rpx;
  1231. }
  1232. .quota-title {
  1233. font-size: 36rpx;
  1234. font-weight: 600;
  1235. color: #1f2937;
  1236. margin-bottom: 12rpx;
  1237. }
  1238. .quota-desc {
  1239. font-size: 28rpx;
  1240. color: #6b7280;
  1241. margin-bottom: 40rpx;
  1242. }
  1243. .quota-actions {
  1244. width: 100%;
  1245. display: flex;
  1246. flex-direction: column;
  1247. gap: 20rpx;
  1248. }
  1249. .quota-btn {
  1250. width: 100%;
  1251. height: 88rpx;
  1252. border-radius: 44rpx;
  1253. display: flex;
  1254. align-items: center;
  1255. justify-content: center;
  1256. font-size: 30rpx;
  1257. border: none;
  1258. }
  1259. .quota-btn::after {
  1260. border: none;
  1261. }
  1262. .quota-btn.primary {
  1263. background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
  1264. color: #ffffff;
  1265. }
  1266. .quota-btn.secondary {
  1267. background: #f3f4f6;
  1268. color: #6b7280;
  1269. }
  1270. </style>