index.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967
  1. <template>
  2. <view class="page">
  3. <!-- 顶部搜索栏 -->
  4. <view class="search-bar">
  5. <view class="search-inner">
  6. <text class="search-icon">🔍</text>
  7. <input
  8. class="search-input"
  9. v-model="searchKeyword"
  10. placeholder="搜索历史记录..."
  11. @confirm="handleSearch"
  12. @clear="handleClearSearch"
  13. />
  14. <text v-if="searchKeyword" class="clear-btn" @click="handleClearSearch">✕</text>
  15. </view>
  16. </view>
  17. <!-- 编辑栏 -->
  18. <view class="edit-bar">
  19. <view v-if="!isEditing" class="edit-btn" @click="isEditing = true">
  20. <text>编辑</text>
  21. </view>
  22. <view v-else class="edit-actions">
  23. <view class="action-btn" @click="toggleSelectAll">
  24. <text>{{ isAllSelected ? '取消全选' : '全选' }}</text>
  25. </view>
  26. <view class="action-btn download" @click="handleBatchDownload" :disabled="selectedIds.length === 0">
  27. <text>下载 ({{ selectedIds.length }})</text>
  28. </view>
  29. <view class="action-btn delete" @click="handleBatchDelete" :disabled="selectedIds.length === 0">
  30. <text>删除 ({{ selectedIds.length }})</text>
  31. </view>
  32. <view class="action-btn cancel" @click="cancelEdit">
  33. <text>取消</text>
  34. </view>
  35. </view>
  36. </view>
  37. <!-- 标签栏 -->
  38. <view class="tab-bar">
  39. <scroll-view class="tab-scroll" scroll-x="true" :show-scrollbar="false">
  40. <view class="tab-list">
  41. <view
  42. v-for="tab in tabs"
  43. :key="tab.id"
  44. class="tab-item"
  45. :class="{ active: selectedTab === tab.id }"
  46. @click="selectTab(tab.id)"
  47. >
  48. <text>{{ tab.name }}</text>
  49. </view>
  50. </view>
  51. </scroll-view>
  52. <view class="group-toggle" @click="groupByBook = !groupByBook">
  53. <text class="group-toggle-text">{{ groupByBook ? '📋 列表' : '📚 聚合' }}</text>
  54. </view>
  55. </view>
  56. <!-- 音频列表 -->
  57. <scroll-view scroll-y class="audio-list" @scrolltolower="loadMore" :scroll-top="scrollTop">
  58. <!-- 骨架屏加载状态 -->
  59. <SkeletonList v-if="loading && audioList.length === 0" layout="history" :count="6" />
  60. <!-- 空状态 -->
  61. <view v-else-if="audioList.length === 0 && !loading" class="empty">
  62. <text class="empty-icon">📚</text>
  63. <text class="empty-text">暂无历史记录</text>
  64. <text class="empty-hint">快去生成第一个音频吧</text>
  65. </view>
  66. <!-- 音频列表 -->
  67. <view v-else class="audio-grid">
  68. <!-- 分组模式 -->
  69. <view v-if="groupByBook" v-for="group in displayList" :key="group._id" class="group-card">
  70. <view class="group-header" @click="goToBookDetail(group.bookId)">
  71. <text class="group-title">{{ group.bookId ? '📖 书籍 #' + group.bookId : '🎵 其他音频' }}</text>
  72. <text class="group-count">{{ group.items.length }} 个音频 · {{ formatDuration(group.totalDuration) }}</text>
  73. </view>
  74. <view
  75. v-for="item in group.items"
  76. :key="item._id"
  77. class="audio-card group-item"
  78. :class="{ selected: selectedIds.includes(item._id) }"
  79. @click="isEditing ? toggleSelect(item._id) : playAudio(item)"
  80. >
  81. <view v-if="isEditing" class="checkbox">
  82. <text>{{ selectedIds.includes(item._id) ? '✓' : '' }}</text>
  83. </view>
  84. <view class="audio-cover-mini" :style="{ background: getCoverGradient(item.voiceId) }">
  85. <text class="cover-icon-small">{{ getTitleLetter(item.title) }}</text>
  86. </view>
  87. <view class="audio-info">
  88. <text class="audio-title">{{ item.title }}</text>
  89. <text class="audio-meta">{{ item.wordCount }}字 · {{ formatDuration(item.audioDuration) }}</text>
  90. </view>
  91. </view>
  92. </view>
  93. <!-- 列表模式 -->
  94. <template v-else>
  95. <view
  96. v-for="item in audioList"
  97. :key="item._id"
  98. class="audio-card"
  99. :class="{ selected: selectedIds.includes(item._id) }"
  100. @click="isEditing ? toggleSelect(item._id) : playAudio(item)"
  101. >
  102. <view v-if="isEditing" class="checkbox">
  103. <text>{{ selectedIds.includes(item._id) ? '✓' : '' }}</text>
  104. </view>
  105. <view class="audio-cover" :style="{ background: getCoverGradient(item.voiceId) }">
  106. <text class="cover-letter">{{ getTitleLetter(item.title) }}</text>
  107. <text class="cover-title-small">{{ item.title }}</text>
  108. <view class="play-overlay">
  109. <text class="play-icon">▶</text>
  110. </view>
  111. </view>
  112. <view class="audio-info">
  113. <text class="audio-title">{{ item.title }}</text>
  114. <view class="audio-progress-row">
  115. <view class="mini-progress-bar">
  116. <view class="mini-progress-fill" :style="{ width: (item.progress || 0) + '%' }"></view>
  117. </view>
  118. <text class="audio-meta">{{ item.wordCount }}字 · {{ formatDuration(item.audioDuration) }}</text>
  119. </view>
  120. <text class="audio-date">{{ formatDate(item.createdAt) }}</text>
  121. </view>
  122. <view class="publish-btn" @click.stop="publishAudio(item)" v-if="!isEditing">
  123. <text class="publish-icon">🚀</text>
  124. </view>
  125. </view>
  126. </template>
  127. </view>
  128. <view v-if="loading" class="loading">
  129. <text>加载中...</text>
  130. </view>
  131. <view v-if="!hasMore && audioList.length > 0" class="no-more">
  132. <text>没有更多了</text>
  133. </view>
  134. </scroll-view>
  135. </view>
  136. </template>
  137. <script setup lang="ts">
  138. import { ref, computed, onMounted, nextTick } from 'vue';
  139. import { useAudioStore } from '../../store/audio';
  140. import { get, post, getFullUrl } from '../../utils/request';
  141. import { getBatchDownloadUrls } from '../../api/download';
  142. import type { AudioItem } from '../../types';
  143. import SkeletonList from '../../components/SkeletonList.vue';
  144. import { getCoverGradient, getTitleLetter, getBookGradient } from '../../composables/useCoverStyle';
  145. import { getTodayStartISO, getDaysAgoISO, getMonthsAgoISO } from '../../utils/time';
  146. const audioStore = useAudioStore();
  147. // 编辑模式状态
  148. const isEditing = ref(false);
  149. const selectedIds = ref<string[]>([]);
  150. const isAllSelected = ref(false);
  151. // 搜索状态
  152. const searchKeyword = ref('');
  153. const isSearching = ref(false);
  154. // scroll-top 用于避免 scrollTop 错误
  155. const scrollTop = ref(0);
  156. // 标签状态
  157. const tabs = ref([
  158. { id: 1, name: '全部' },
  159. { id: 2, name: '今天' },
  160. { id: 3, name: '本周' },
  161. { id: 4, name: '本月' },
  162. ]);
  163. const selectedTab = ref(1);
  164. // 音频列表状态
  165. const audioList = ref<AudioItem[]>([]);
  166. const page = ref(1);
  167. const pageSize = 20;
  168. const total = ref(0);
  169. const loading = ref(false);
  170. const hasMore = ref(true);
  171. // 按书籍聚合
  172. const groupByBook = ref(false);
  173. // 分组后的列表
  174. const groupedAudioList = computed(() => {
  175. if (!groupByBook.value) return null;
  176. const groups: Record<string, { bookId: number; items: AudioItem[]; totalDuration: number }> = {};
  177. audioList.value.forEach(item => {
  178. const key = item.bookId ? `book_${item.bookId}` : 'other';
  179. if (!groups[key]) {
  180. groups[key] = { bookId: item.bookId || 0, items: [], totalDuration: 0 };
  181. }
  182. groups[key].items.push(item);
  183. groups[key].totalDuration += item.audioDuration || 0;
  184. });
  185. return Object.values(groups);
  186. });
  187. // 用于展示的列表(根据分组模式切换)
  188. const displayList = computed(() => {
  189. if (groupByBook.value && groupedAudioList.value) {
  190. return groupedAudioList.value.map(g => ({
  191. _id: `group_${g.bookId || 'other'}`,
  192. isGroup: true,
  193. bookId: g.bookId,
  194. items: g.items,
  195. totalDuration: g.totalDuration,
  196. title: g.bookId ? `书籍 #${g.bookId}` : '其他音频',
  197. } as any));
  198. }
  199. return audioList.value;
  200. });
  201. // 选择标签
  202. async function selectTab(tabId: number) {
  203. selectedTab.value = tabId;
  204. page.value = 1;
  205. await fetchHistoryList();
  206. }
  207. // 切换选择
  208. function toggleSelect(id: string) {
  209. const index = selectedIds.value.indexOf(id);
  210. if (index > -1) {
  211. selectedIds.value.splice(index, 1);
  212. } else {
  213. selectedIds.value.push(id);
  214. }
  215. isAllSelected.value = selectedIds.value.length === audioList.value.length;
  216. }
  217. // 全选/取消全选
  218. function toggleSelectAll() {
  219. if (isAllSelected.value) {
  220. selectedIds.value = [];
  221. } else {
  222. selectedIds.value = audioList.value.map(item => item._id);
  223. }
  224. isAllSelected.value = !isAllSelected.value;
  225. }
  226. // 取消编辑
  227. function cancelEdit() {
  228. isEditing.value = false;
  229. selectedIds.value = [];
  230. isAllSelected.value = false;
  231. }
  232. // 批量删除
  233. async function handleBatchDelete() {
  234. if (selectedIds.value.length === 0) return;
  235. uni.showModal({
  236. title: '确认删除',
  237. content: `确定要删除选中的 ${selectedIds.value.length} 条记录吗?`,
  238. success: async (res) => {
  239. if (res.confirm) {
  240. try {
  241. await post('/history/batch-delete', { ids: selectedIds.value });
  242. uni.showToast({ title: '删除成功', icon: 'success' });
  243. await fetchHistoryList();
  244. cancelEdit();
  245. } catch (error) {
  246. uni.showToast({ title: '删除失败', icon: 'none' });
  247. }
  248. }
  249. },
  250. });
  251. }
  252. // 批量下载
  253. async function handleBatchDownload() {
  254. if (selectedIds.value.length === 0) return;
  255. try {
  256. uni.showLoading({ title: '准备下载...', mask: true });
  257. const result = await getBatchDownloadUrls(selectedIds.value);
  258. uni.hideLoading();
  259. if (!result || result.audios.length === 0) {
  260. uni.showToast({ title: '没有可下载的音频', icon: 'none' });
  261. return;
  262. }
  263. // 确认下载
  264. uni.showModal({
  265. title: '确认下载',
  266. content: `将下载 ${result.audios.length} 个音频文件`,
  267. success: async (res) => {
  268. if (res.confirm) {
  269. // 逐个下载
  270. let successCount = 0;
  271. let failCount = 0;
  272. for (const audio of result.audios) {
  273. try {
  274. await new Promise<void>((resolve, reject) => {
  275. uni.downloadFile({
  276. url: getFullUrl(audio.downloadUrl),
  277. success: (downloadRes) => {
  278. if (downloadRes.statusCode === 200) {
  279. successCount++;
  280. resolve();
  281. } else {
  282. failCount++;
  283. reject(new Error('下载失败'));
  284. }
  285. },
  286. fail: () => {
  287. failCount++;
  288. reject(new Error('下载失败'));
  289. },
  290. });
  291. });
  292. } catch (error) {
  293. console.error(`下载失败: ${audio.title}`, error);
  294. }
  295. }
  296. // 显示结果
  297. uni.showModal({
  298. title: '下载完成',
  299. content: `成功: ${successCount} 个\n失败: ${failCount} 个`,
  300. showCancel: false,
  301. });
  302. cancelEdit();
  303. }
  304. },
  305. });
  306. } catch (error) {
  307. uni.hideLoading();
  308. console.error('[BatchDownload] 批量下载失败:', error);
  309. uni.showToast({ title: '下载失败', icon: 'none' });
  310. }
  311. }
  312. // 搜索功能
  313. function handleSearch() {
  314. isSearching.value = true;
  315. page.value = 1;
  316. fetchHistoryList();
  317. }
  318. function handleClearSearch() {
  319. searchKeyword.value = '';
  320. isSearching.value = false;
  321. page.value = 1;
  322. fetchHistoryList();
  323. }
  324. // 获取历史列表
  325. async function fetchHistoryList(isLoadMore = false) {
  326. if (loading.value) return;
  327. if (!isLoadMore) {
  328. page.value = 1;
  329. }
  330. loading.value = true;
  331. try {
  332. // 按时间过滤
  333. let startDate = '';
  334. switch (selectedTab.value) {
  335. case 2: // 今天
  336. startDate = getTodayStartISO();
  337. break;
  338. case 3: // 本周
  339. startDate = getDaysAgoISO(7);
  340. break;
  341. case 4: // 本月
  342. startDate = getMonthsAgoISO(1);
  343. break;
  344. }
  345. const params: any = { page: page.value, pageSize };
  346. if (startDate) {
  347. params.startDate = startDate;
  348. }
  349. // 添加搜索关键字
  350. if (searchKeyword.value) {
  351. params.keyword = searchKeyword.value;
  352. }
  353. const result = await get<{ list: AudioItem[]; total: number; totalPages: number }>('/history', params);
  354. if (isLoadMore) {
  355. audioList.value.push(...result.list);
  356. } else {
  357. audioList.value = result.list;
  358. }
  359. total.value = result.total;
  360. hasMore.value = page.value < result.totalPages;
  361. } catch (error) {
  362. console.error('获取历史记录失败:', error);
  363. } finally {
  364. loading.value = false;
  365. }
  366. }
  367. // 加载更多
  368. function loadMore() {
  369. if (hasMore.value && !loading.value) {
  370. page.value++;
  371. fetchHistoryList(true);
  372. }
  373. }
  374. // 播放音频 - 跳转到书籍详情页
  375. function playAudio(item: AudioItem & { bookId?: number }) {
  376. // 独立音频跳转到书籍详情
  377. if (item.bookId) {
  378. uni.navigateTo({ url: `/pages/book-generator/detail?id=${item.bookId}` });
  379. } else {
  380. // 兜底:跳转到播放器(需要数字ID)
  381. uni.showToast({ title: '音频信息不完整', icon: 'none' });
  382. }
  383. }
  384. // 发布音频
  385. function publishAudio(item: AudioItem) {
  386. uni.navigateTo({ url: `/pages/publish/index?audioId=${item._id}` });
  387. }
  388. // 格式化时长
  389. function formatDuration(seconds: number): string {
  390. if (!seconds) return '0:00';
  391. const mins = Math.floor(seconds / 60);
  392. const secs = Math.floor(seconds % 60);
  393. return `${mins}:${secs.toString().padStart(2, '0')}`;
  394. }
  395. // 跳转书籍详情
  396. function goToBookDetail(bookId: number) {
  397. if (bookId) {
  398. uni.navigateTo({ url: `/pages/book-generator/detail?id=${bookId}` });
  399. }
  400. }
  401. // 格式化日期
  402. function formatDate(dateStr: string): string {
  403. const date = new Date(dateStr);
  404. const now = new Date();
  405. const diff = now.getTime() - date.getTime();
  406. const days = Math.floor(diff / (1000 * 60 * 60 * 24));
  407. if (days === 0) return '今天';
  408. if (days === 1) return '昨天';
  409. if (days < 7) return `${days}天前`;
  410. return `${date.getMonth() + 1}/${date.getDate()}`;
  411. }
  412. // 初始化
  413. onMounted(async () => {
  414. await fetchHistoryList();
  415. });
  416. </script>
  417. <style scoped>
  418. .page {
  419. min-height: 100vh;
  420. background: var(--color-bg-page, #f3f4f6);
  421. }
  422. .search-bar {
  423. position: fixed;
  424. top: 0;
  425. left: 0;
  426. right: 0;
  427. height: 100rpx;
  428. padding-top: env(safe-area-inset-top);
  429. background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
  430. display: flex;
  431. align-items: center;
  432. padding-left: 24rpx;
  433. padding-right: 24rpx;
  434. z-index: 100;
  435. }
  436. .search-inner {
  437. flex: 1;
  438. height: 64rpx;
  439. background: rgba(255, 255, 255, 0.2);
  440. border-radius: 32rpx;
  441. display: flex;
  442. align-items: center;
  443. padding: 0 24rpx;
  444. }
  445. .search-icon {
  446. font-size: 28rpx;
  447. margin-right: 12rpx;
  448. }
  449. .search-input {
  450. flex: 1;
  451. font-size: 28rpx;
  452. color: white;
  453. }
  454. .clear-btn {
  455. font-size: 32rpx;
  456. color: rgba(255, 255, 255, 0.8);
  457. padding: 0 8rpx;
  458. }
  459. .search-placeholder {
  460. font-size: 26rpx;
  461. color: rgba(255, 255, 255, 0.7);
  462. }
  463. .search-input::placeholder { color: rgba(255, 255, 255, 0.7); }
  464. .edit-bar {
  465. position: fixed;
  466. top: 100rpx;
  467. left: 0;
  468. right: 0;
  469. z-index: 98;
  470. background: #ffffff;
  471. padding: 16rpx 24rpx;
  472. display: flex;
  473. justify-content: flex-end;
  474. border-bottom: 1rpx solid #f3f4f6;
  475. }
  476. .edit-btn {
  477. padding: 12rpx 24rpx;
  478. background: #4f46e5;
  479. color: white;
  480. border-radius: 8rpx;
  481. font-size: 28rpx;
  482. }
  483. .edit-actions {
  484. display: flex;
  485. gap: 16rpx;
  486. }
  487. .action-btn {
  488. padding: 12rpx 24rpx;
  489. border-radius: 8rpx;
  490. font-size: 28rpx;
  491. border: none;
  492. transition: transform 0.15s, opacity 0.15s;
  493. }
  494. .action-btn:active { transform: scale(0.96); opacity: 0.8; }
  495. .action-btn[disabled] { opacity: 0.5; pointer-events: none; }
  496. .action-btn.delete {
  497. background: #ef4444;
  498. color: white;
  499. }
  500. .action-btn.delete[disabled] {
  501. opacity: 0.5;
  502. }
  503. .action-btn.download {
  504. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  505. color: white;
  506. }
  507. .action-btn.download[disabled] {
  508. opacity: 0.5;
  509. }
  510. .action-btn.cancel {
  511. background: #f3f4f6;
  512. color: #374151;
  513. }
  514. .tab-bar {
  515. position: fixed;
  516. top: 168rpx;
  517. left: 0;
  518. right: 0;
  519. background: #ffffff;
  520. z-index: 99;
  521. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
  522. }
  523. .tab-scroll {
  524. width: 100%;
  525. white-space: nowrap;
  526. }
  527. .tab-list {
  528. display: inline-flex;
  529. gap: 16rpx;
  530. padding: 16rpx 24rpx;
  531. }
  532. .tab-item {
  533. padding: 12rpx 32rpx;
  534. font-size: 28rpx;
  535. color: #666;
  536. flex-shrink: 0;
  537. position: relative;
  538. transition: color 0.2s;
  539. }
  540. .tab-item.active {
  541. color: #4f46e5;
  542. font-weight: 600;
  543. }
  544. .tab-item.active::after {
  545. content: '';
  546. position: absolute;
  547. bottom: 0;
  548. left: 50%;
  549. transform: translateX(-50%);
  550. width: 40rpx;
  551. height: 6rpx;
  552. background: #4f46e5;
  553. border-radius: 3rpx;
  554. }
  555. .audio-list {
  556. padding-top: 240rpx;
  557. height: calc(100vh - 240rpx);
  558. }
  559. .empty {
  560. display: flex;
  561. flex-direction: column;
  562. align-items: center;
  563. justify-content: center;
  564. padding: 200rpx 0;
  565. }
  566. .empty-icon {
  567. font-size: 120rpx;
  568. margin-bottom: 24rpx;
  569. }
  570. .empty-text {
  571. font-size: 32rpx;
  572. color: #6b7280;
  573. margin-bottom: 12rpx;
  574. }
  575. .empty-hint {
  576. font-size: 26rpx;
  577. color: #9ca3af;
  578. }
  579. .audio-grid {
  580. display: flex;
  581. flex-wrap: wrap;
  582. padding: 24rpx;
  583. gap: 20rpx;
  584. }
  585. .audio-card {
  586. width: calc(50% - 10rpx);
  587. background: var(--color-bg-card, #ffffff);
  588. border-radius: 16rpx;
  589. overflow: hidden;
  590. box-shadow: var(--shadow-sm, 0 2rpx 8rpx rgba(0,0,0,0.06));
  591. position: relative;
  592. transition: transform 0.15s;
  593. }
  594. .audio-card:active { transform: scale(0.98); }
  595. .audio-card.selected {
  596. background: #eff6ff;
  597. border: 2rpx solid #4f46e5;
  598. }
  599. .checkbox {
  600. position: absolute;
  601. top: 16rpx;
  602. left: 16rpx;
  603. width: 48rpx;
  604. height: 48rpx;
  605. background: #4f46e5;
  606. border-radius: 50%;
  607. display: flex;
  608. align-items: center;
  609. justify-content: center;
  610. color: white;
  611. font-size: 28rpx;
  612. z-index: 10;
  613. }
  614. .audio-cover {
  615. position: relative;
  616. width: 100%;
  617. height: 240rpx;
  618. display: flex;
  619. align-items: center;
  620. justify-content: center;
  621. }
  622. .cover-icon {
  623. font-size: 80rpx;
  624. opacity: 0.6;
  625. }
  626. /* 文字海报风格封面 */
  627. .cover-letter {
  628. position: absolute;
  629. top: -10rpx;
  630. left: 12rpx;
  631. font-size: 140rpx;
  632. font-weight: 900;
  633. color: rgba(255, 255, 255, 0.12);
  634. line-height: 1;
  635. pointer-events: none;
  636. }
  637. .cover-title-small {
  638. position: absolute;
  639. bottom: 40rpx;
  640. left: 16rpx;
  641. right: 16rpx;
  642. font-size: 22rpx;
  643. color: rgba(255, 255, 255, 0.85);
  644. font-weight: 500;
  645. overflow: hidden;
  646. text-overflow: ellipsis;
  647. white-space: nowrap;
  648. }
  649. .play-overlay {
  650. position: absolute;
  651. top: 0;
  652. left: 0;
  653. right: 0;
  654. bottom: 0;
  655. background: rgba(0, 0, 0, 0.2);
  656. display: flex;
  657. align-items: center;
  658. justify-content: center;
  659. opacity: 0;
  660. transition: opacity 0.3s;
  661. }
  662. .audio-card:active .play-overlay {
  663. opacity: 1;
  664. }
  665. .play-icon {
  666. width: 80rpx;
  667. height: 80rpx;
  668. background: rgba(255, 255, 255, 0.9);
  669. border-radius: 50%;
  670. display: flex;
  671. align-items: center;
  672. justify-content: center;
  673. font-size: 32rpx;
  674. color: #4f46e5;
  675. padding-left: 8rpx;
  676. }
  677. .audio-duration-badge {
  678. position: absolute;
  679. bottom: 12rpx;
  680. right: 12rpx;
  681. background: rgba(0, 0, 0, 0.6);
  682. color: #ffffff;
  683. font-size: 22rpx;
  684. padding: 4rpx 12rpx;
  685. border-radius: 8rpx;
  686. }
  687. .audio-info {
  688. padding: 20rpx;
  689. }
  690. .audio-title {
  691. font-size: 28rpx;
  692. font-weight: 500;
  693. color: #1f2937;
  694. display: block;
  695. overflow: hidden;
  696. text-overflow: ellipsis;
  697. white-space: nowrap;
  698. margin-bottom: 8rpx;
  699. }
  700. .audio-meta {
  701. font-size: 22rpx;
  702. color: #9ca3af;
  703. }
  704. .publish-btn {
  705. position: absolute;
  706. top: 16rpx;
  707. right: 16rpx;
  708. width: 56rpx;
  709. height: 56rpx;
  710. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  711. border-radius: 50%;
  712. display: flex;
  713. align-items: center;
  714. justify-content: center;
  715. box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.4);
  716. z-index: 10;
  717. transition: transform 0.15s;
  718. }
  719. .publish-btn:active { transform: scale(0.9); }
  720. .publish-icon {
  721. font-size: 28rpx;
  722. }
  723. .loading,
  724. .no-more {
  725. text-align: center;
  726. padding: 32rpx;
  727. }
  728. .loading text,
  729. .no-more text {
  730. font-size: 24rpx;
  731. color: #9ca3af;
  732. }
  733. /* 分组切换按钮 */
  734. .group-toggle {
  735. padding: 12rpx 24rpx;
  736. display: flex;
  737. align-items: center;
  738. position: absolute;
  739. right: 24rpx;
  740. top: 50%;
  741. transform: translateY(-50%);
  742. background: rgba(79, 70, 229, 0.08);
  743. border-radius: 32rpx;
  744. border: 2rpx solid rgba(79, 70, 229, 0.15);
  745. cursor: pointer;
  746. transition: background 0.15s;
  747. }
  748. .group-toggle:active { background: rgba(79, 70, 229, 0.15); }
  749. .group-toggle-text {
  750. font-size: 24rpx;
  751. color: #4f46e5;
  752. white-space: nowrap;
  753. }
  754. /* 分组卡片 */
  755. .group-card {
  756. width: 100%;
  757. background: #ffffff;
  758. border-radius: 16rpx;
  759. overflow: hidden;
  760. margin-bottom: 24rpx;
  761. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
  762. }
  763. .group-header {
  764. padding: 24rpx;
  765. background: linear-gradient(135deg, #f0f0ff 0%, #faf5ff 100%);
  766. border-bottom: 1rpx solid #e5e7eb;
  767. display: flex;
  768. justify-content: space-between;
  769. align-items: center;
  770. }
  771. .group-title {
  772. font-size: 28rpx;
  773. font-weight: 600;
  774. color: #1f2937;
  775. }
  776. .group-count {
  777. font-size: 22rpx;
  778. color: #9ca3af;
  779. }
  780. /* 分组内音频卡片 */
  781. .group-item {
  782. width: 100%;
  783. border-radius: 0;
  784. box-shadow: none;
  785. margin-bottom: 0;
  786. display: flex;
  787. flex-direction: row;
  788. align-items: center;
  789. padding: 16rpx 24rpx;
  790. border-bottom: 1rpx solid #f3f4f6;
  791. }
  792. .group-item:last-child {
  793. border-bottom: none;
  794. }
  795. .group-item .checkbox {
  796. position: relative;
  797. top: auto;
  798. left: auto;
  799. margin-right: 16rpx;
  800. flex-shrink: 0;
  801. }
  802. /* 分组模式小封面 */
  803. .audio-cover-mini {
  804. width: 80rpx;
  805. height: 80rpx;
  806. border-radius: 12rpx;
  807. display: flex;
  808. align-items: center;
  809. justify-content: center;
  810. flex-shrink: 0;
  811. margin-right: 20rpx;
  812. }
  813. .cover-icon-small {
  814. font-size: 36rpx;
  815. }
  816. /* 进度条样式 */
  817. .audio-progress-row {
  818. display: flex;
  819. flex-direction: column;
  820. gap: 8rpx;
  821. margin-bottom: 4rpx;
  822. }
  823. .mini-progress-bar {
  824. width: 100%;
  825. height: 6rpx;
  826. background: #e5e7eb;
  827. border-radius: 3rpx;
  828. overflow: hidden;
  829. }
  830. .mini-progress-fill {
  831. height: 100%;
  832. background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 100%);
  833. border-radius: 3rpx;
  834. transition: width 0.3s ease;
  835. }
  836. /* 日期标签 */
  837. .audio-date {
  838. font-size: 20rpx;
  839. color: #9ca3af;
  840. margin-top: 4rpx;
  841. }
  842. /* 分组模式下音频信息 */
  843. .group-item .audio-info {
  844. flex: 1;
  845. min-width: 0;
  846. padding: 0;
  847. }
  848. .group-item .audio-title {
  849. font-size: 26rpx;
  850. margin-bottom: 4rpx;
  851. }
  852. </style>