2025-05-07 22:36:52 +08:00
|
|
|
<template>
|
2026-02-04 20:16:52 +08:00
|
|
|
<div class="music-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
|
|
|
|
<n-scrollbar class="h-full" @scroll="handleScroll">
|
|
|
|
|
<div class="music-list-content pb-32">
|
|
|
|
|
<!-- Hero Section 和 Action Bar -->
|
|
|
|
|
<n-spin :show="loading">
|
|
|
|
|
<!-- Hero Section -->
|
|
|
|
|
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
|
|
|
|
|
<!-- Background Image with Blur -->
|
|
|
|
|
<div class="hero-bg absolute inset-0 -top-20">
|
|
|
|
|
<div
|
|
|
|
|
class="absolute inset-0 bg-cover bg-center scale-110 blur-3xl opacity-40 dark:opacity-30"
|
|
|
|
|
:style="{
|
|
|
|
|
backgroundImage: `url(${getImgUrl(getCoverImgUrl, '800y800')})`
|
|
|
|
|
}"
|
|
|
|
|
></div>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute inset-0 bg-gradient-to-b from-transparent via-white/80 to-white dark:via-black/80 dark:to-black"
|
|
|
|
|
></div>
|
2025-05-15 21:20:01 +08:00
|
|
|
</div>
|
2025-06-04 20:19:44 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
<!-- Hero Content -->
|
|
|
|
|
<div class="hero-content relative z-10 px-4 md:px-8 pt-4 md:pt-10 pb-8">
|
|
|
|
|
<div class="flex flex-col md:flex-row gap-8 md:gap-12 items-center md:items-end">
|
|
|
|
|
<!-- Playlist Cover -->
|
|
|
|
|
<div class="cover-wrapper relative group">
|
|
|
|
|
<div
|
|
|
|
|
class="cover-glow absolute -inset-2 rounded-2xl bg-gradient-to-br from-primary/30 via-primary/10 to-transparent blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
|
|
|
|
></div>
|
|
|
|
|
<div
|
|
|
|
|
class="cover-container relative w-48 h-48 md:w-64 md:h-64 rounded-2xl overflow-hidden shadow-2xl ring-4 ring-white/50 dark:ring-neutral-800/50"
|
|
|
|
|
>
|
|
|
|
|
<n-image
|
|
|
|
|
:src="getImgUrl(getCoverImgUrl, '500y500')"
|
|
|
|
|
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
|
|
|
|
preview-disabled
|
|
|
|
|
/>
|
|
|
|
|
<!-- Play overlay on cover -->
|
|
|
|
|
<div
|
|
|
|
|
class="absolute inset-0 flex items-center justify-center bg-transparent group-hover:bg-black/30 transition-all duration-300 cursor-pointer"
|
|
|
|
|
@click="handlePlayAll"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="play-icon w-16 h-16 rounded-full bg-white/90 flex items-center justify-center opacity-0 scale-75 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 shadow-xl hover:scale-110 active:scale-95"
|
|
|
|
|
>
|
|
|
|
|
<i class="ri-play-fill text-3xl text-neutral-900 ml-1" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-06-04 20:19:44 +08:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
<!-- Playlist Info -->
|
|
|
|
|
<div class="playlist-info flex-1 text-center md:text-left">
|
|
|
|
|
<div class="playlist-badge mb-3">
|
|
|
|
|
<span
|
|
|
|
|
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 dark:bg-primary/20 text-primary text-xs font-semibold uppercase tracking-wider"
|
|
|
|
|
>
|
|
|
|
|
{{ isAlbum ? 'Album' : 'Playlist' }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<h1
|
|
|
|
|
class="playlist-name text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight mb-4"
|
|
|
|
|
>
|
|
|
|
|
{{ name }}
|
|
|
|
|
</h1>
|
|
|
|
|
|
|
|
|
|
<!-- Meta Info -->
|
|
|
|
|
<div
|
|
|
|
|
class="flex flex-wrap items-center justify-center md:justify-start gap-4 mb-6"
|
|
|
|
|
>
|
|
|
|
|
<div v-if="isAlbum && listInfo?.artist" class="flex items-center gap-2">
|
|
|
|
|
<n-avatar
|
|
|
|
|
round
|
|
|
|
|
:size="28"
|
|
|
|
|
:src="getImgUrl(listInfo.artist.picUrl, '50y50')"
|
|
|
|
|
/>
|
|
|
|
|
<span
|
|
|
|
|
class="text-sm font-semibold text-neutral-700 dark:text-neutral-200 hover:text-primary cursor-pointer transition-colors"
|
|
|
|
|
>{{ listInfo.artist.name }}</span
|
|
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else-if="!isAlbum && listInfo?.creator" class="flex items-center gap-2">
|
|
|
|
|
<n-avatar
|
|
|
|
|
round
|
|
|
|
|
:size="28"
|
|
|
|
|
:src="getImgUrl(listInfo.creator.avatarUrl, '50y50')"
|
|
|
|
|
/>
|
|
|
|
|
<span class="text-sm font-semibold text-neutral-700 dark:text-neutral-200">{{
|
|
|
|
|
listInfo.creator.nickname
|
|
|
|
|
}}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="h-1 w-1 rounded-full bg-neutral-300 dark:bg-neutral-700"></div>
|
|
|
|
|
<span class="text-sm text-neutral-500 dark:text-neutral-400">
|
|
|
|
|
{{ t('player.songNum', { num: total }) }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2025-05-15 21:20:01 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
<p
|
|
|
|
|
v-if="listInfo?.description"
|
|
|
|
|
class="text-sm md:text-base text-neutral-500 dark:text-neutral-400 line-clamp-2 leading-relaxed max-w-3xl"
|
|
|
|
|
>
|
|
|
|
|
{{ listInfo.description }}
|
|
|
|
|
</p>
|
2025-05-15 21:20:01 +08:00
|
|
|
</div>
|
2026-02-04 20:16:52 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</n-spin>
|
|
|
|
|
|
|
|
|
|
<!-- Action Bar (Sticky) -->
|
|
|
|
|
<section
|
|
|
|
|
v-if="songList.length > 0"
|
|
|
|
|
class="action-bar sticky top-0 z-20 px-4 md:px-8 py-3 md:py-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border-b border-neutral-100 dark:border-neutral-800/50"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center justify-between gap-4">
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
<!-- Play All Button -->
|
|
|
|
|
<button
|
|
|
|
|
class="play-all-btn flex items-center gap-2 px-6 py-2.5 rounded-full bg-primary hover:bg-primary/90 text-white font-semibold text-sm transition-all duration-200 hover:scale-105 active:scale-95 shadow-lg shadow-primary/25"
|
|
|
|
|
@click="handlePlayAll"
|
|
|
|
|
>
|
|
|
|
|
<i class="ri-play-circle-line text-lg" />
|
|
|
|
|
<span>{{ t('comp.musicList.playAll') }}</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<!-- Collect Button -->
|
|
|
|
|
<button
|
|
|
|
|
v-if="canCollect"
|
|
|
|
|
class="action-btn-pill flex items-center gap-2 px-6 py-2.5 rounded-full font-semibold text-sm transition-all duration-200 hover:scale-105 active:scale-95 shadow-sm border"
|
|
|
|
|
:class="
|
|
|
|
|
isCollected
|
|
|
|
|
? 'bg-neutral-100 dark:bg-neutral-800 text-red-500 border-neutral-200 dark:border-neutral-700'
|
|
|
|
|
: 'bg-neutral-50 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 border-neutral-200 dark:border-neutral-800'
|
|
|
|
|
"
|
|
|
|
|
@click="toggleCollect"
|
|
|
|
|
>
|
|
|
|
|
<i :class="isCollected ? 'ri-heart-fill' : 'ri-heart-line'" class="text-lg" />
|
|
|
|
|
<span>{{
|
|
|
|
|
isCollected ? t('comp.musicList.cancelCollect') : t('comp.musicList.collect')
|
|
|
|
|
}}</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<!-- Batch Actions -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="filteredSongs.length > 0 && isElectron"
|
|
|
|
|
class="h-8 w-[1px] bg-neutral-200 dark:bg-neutral-800 mx-1 hidden md:block"
|
|
|
|
|
></div>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
v-if="!isSelecting && isElectron"
|
|
|
|
|
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
|
|
|
|
|
@click="startSelect"
|
|
|
|
|
>
|
|
|
|
|
<i class="ri-checkbox-multiple-line text-lg" />
|
|
|
|
|
</button>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
<div
|
|
|
|
|
v-if="isSelecting"
|
|
|
|
|
class="flex items-center gap-2 animate-in fade-in slide-in-from-left-2"
|
|
|
|
|
>
|
|
|
|
|
<n-checkbox
|
|
|
|
|
:checked="isAllSelected"
|
|
|
|
|
:indeterminate="isIndeterminate"
|
|
|
|
|
@update:checked="handleSelectAll"
|
|
|
|
|
>
|
|
|
|
|
{{ t('common.selectAll') }}
|
|
|
|
|
</n-checkbox>
|
|
|
|
|
<button
|
|
|
|
|
class="px-4 py-1.5 rounded-full bg-primary/10 text-primary text-xs font-bold hover:bg-primary/20 transition-all"
|
|
|
|
|
:disabled="selectedSongs.length === 0 || isDownloading"
|
|
|
|
|
@click="handleBatchDownload"
|
|
|
|
|
>
|
|
|
|
|
<i class="ri-download-line mr-1" />
|
|
|
|
|
{{ t('favorite.download', { count: selectedSongs.length }) }}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class="text-xs text-neutral-400 hover:text-neutral-600"
|
|
|
|
|
@click="cancelSelect"
|
|
|
|
|
>
|
|
|
|
|
{{ t('common.cancel') }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-22 21:52:22 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
<!-- Right Tools -->
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
<!-- Search within list -->
|
|
|
|
|
<div class="relative group hidden sm:block">
|
|
|
|
|
<n-input
|
|
|
|
|
v-model:value="searchKeyword"
|
|
|
|
|
:placeholder="t('comp.musicList.searchSongs')"
|
|
|
|
|
round
|
|
|
|
|
clearable
|
|
|
|
|
size="small"
|
|
|
|
|
class="w-48 focus:w-64 transition-all duration-300 !bg-neutral-100 dark:!bg-neutral-900 border-none"
|
|
|
|
|
>
|
|
|
|
|
<template #prefix>
|
|
|
|
|
<i class="ri-search-line text-neutral-400"></i>
|
|
|
|
|
</template>
|
|
|
|
|
</n-input>
|
|
|
|
|
</div>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
<!-- Layout Toggle -->
|
|
|
|
|
<button
|
|
|
|
|
v-if="!isMobile"
|
|
|
|
|
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
|
|
|
|
|
@click="toggleLayout"
|
|
|
|
|
>
|
|
|
|
|
<i :class="isCompactLayout ? 'ri-list-check-2' : 'ri-grid-line'" class="text-lg" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-05-07 22:36:52 +08:00
|
|
|
</div>
|
2026-02-04 20:16:52 +08:00
|
|
|
</section>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
<!-- List Content -->
|
|
|
|
|
<section class="song-list-section px-4 md:px-8 mt-6">
|
|
|
|
|
<n-spin :show="loadingList">
|
|
|
|
|
<div
|
|
|
|
|
v-if="filteredSongs.length === 0 && searchKeyword"
|
|
|
|
|
class="empty-state py-20 text-center text-neutral-400"
|
|
|
|
|
>
|
|
|
|
|
<i class="ri-search-line text-4xl mb-4 opacity-20" />
|
|
|
|
|
<p>{{ t('comp.musicList.noSearchResults') }}</p>
|
|
|
|
|
</div>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
<div v-else class="song-list-container">
|
|
|
|
|
<div
|
|
|
|
|
v-for="(item, index) in filteredSongs"
|
|
|
|
|
:key="item.id"
|
|
|
|
|
class="mb-2 animate-item"
|
|
|
|
|
:style="{ animationDelay: calculateAnimationDelay(index % 20, 0.03) }"
|
2025-05-07 22:36:52 +08:00
|
|
|
>
|
2026-02-04 20:16:52 +08:00
|
|
|
<song-item
|
|
|
|
|
:index="index"
|
|
|
|
|
:compact="isCompactLayout"
|
|
|
|
|
:item="formatSong(item)"
|
|
|
|
|
:can-remove="canRemove"
|
|
|
|
|
:selectable="isSelecting"
|
|
|
|
|
:selected="selectedSongs.includes(item.id as number)"
|
|
|
|
|
@play="handlePlayItem(item)"
|
|
|
|
|
@remove-song="handleRemoveSong"
|
|
|
|
|
@select="(id, selected) => handleSelect(id, selected)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-05-07 22:36:52 +08:00
|
|
|
</div>
|
|
|
|
|
</n-spin>
|
2026-02-04 20:16:52 +08:00
|
|
|
</section>
|
2025-05-07 22:36:52 +08:00
|
|
|
</div>
|
2026-02-04 20:16:52 +08:00
|
|
|
</n-scrollbar>
|
2025-05-07 22:36:52 +08:00
|
|
|
<play-bottom />
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2025-07-23 23:54:35 +08:00
|
|
|
import { useMessage } from 'naive-ui';
|
2025-05-07 22:36:52 +08:00
|
|
|
import PinyinMatch from 'pinyin-match';
|
2026-02-04 20:16:52 +08:00
|
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
2025-05-07 22:36:52 +08:00
|
|
|
import { useI18n } from 'vue-i18n';
|
2026-02-04 20:16:52 +08:00
|
|
|
import { useRoute } from 'vue-router';
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
import { getAlbum, getListDetail } from '@/api/list';
|
2025-10-22 21:50:20 +08:00
|
|
|
import {
|
|
|
|
|
getMusicDetail,
|
|
|
|
|
subscribeAlbum,
|
|
|
|
|
subscribePlaylist,
|
|
|
|
|
updatePlaylistTracks
|
|
|
|
|
} from '@/api/music';
|
2025-05-07 22:36:52 +08:00
|
|
|
import PlayBottom from '@/components/common/PlayBottom.vue';
|
2025-07-23 23:54:35 +08:00
|
|
|
import SongItem from '@/components/common/SongItem.vue';
|
2025-10-22 21:51:16 +08:00
|
|
|
import { useAlbumHistory } from '@/hooks/AlbumHistoryHook';
|
|
|
|
|
import { usePlaylistHistory } from '@/hooks/PlaylistHistoryHook';
|
2025-07-23 23:54:35 +08:00
|
|
|
import { useDownload } from '@/hooks/useDownload';
|
2025-10-22 21:50:20 +08:00
|
|
|
import { useMusicStore, usePlayerStore, useRecommendStore, useUserStore } from '@/store';
|
2025-08-07 22:57:17 +08:00
|
|
|
import { SongResult } from '@/types/music';
|
2026-02-04 20:16:52 +08:00
|
|
|
import { calculateAnimationDelay, getImgUrl, isElectron, isMobile } from '@/utils';
|
2025-09-14 00:34:35 +08:00
|
|
|
import { getLoginErrorMessage, hasPermission } from '@/utils/auth';
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
defineOptions({
|
|
|
|
|
name: 'MusicList'
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-07 22:36:52 +08:00
|
|
|
const { t } = useI18n();
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
const playerStore = usePlayerStore();
|
|
|
|
|
const musicStore = useMusicStore();
|
2025-09-10 00:29:50 +08:00
|
|
|
const recommendStore = useRecommendStore();
|
2025-10-22 21:50:20 +08:00
|
|
|
const userStore = useUserStore();
|
2025-05-07 22:36:52 +08:00
|
|
|
const message = useMessage();
|
2025-10-22 21:51:16 +08:00
|
|
|
const { addPlaylist } = usePlaylistHistory();
|
|
|
|
|
const { addAlbum } = useAlbumHistory();
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
|
|
|
const loading = ref(false);
|
2026-02-04 20:16:52 +08:00
|
|
|
|
|
|
|
|
const fetchData = async () => {
|
|
|
|
|
const id = route.params.id;
|
|
|
|
|
const type = route.query.type;
|
|
|
|
|
|
|
|
|
|
if (!id || type === 'dailyRecommend') return;
|
|
|
|
|
|
|
|
|
|
// 检查是否需要加载数据
|
|
|
|
|
if (
|
|
|
|
|
musicStore.currentListInfo?.id?.toString() === id.toString() &&
|
|
|
|
|
musicStore.currentMusicList &&
|
|
|
|
|
musicStore.currentMusicList.length > 0
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
let data: any;
|
|
|
|
|
if (type === 'album') {
|
|
|
|
|
const res = await getAlbum(Number(id));
|
|
|
|
|
data = res.data;
|
|
|
|
|
if (data.code === 200) {
|
|
|
|
|
musicStore.setCurrentMusicList(
|
|
|
|
|
data.songs,
|
|
|
|
|
data.album.name,
|
|
|
|
|
{ ...data.album, picUrl: data.album.picUrl },
|
|
|
|
|
false
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
message.error(t('common.loadFailed'));
|
|
|
|
|
}
|
|
|
|
|
} else if (type === 'playlist') {
|
|
|
|
|
const res = await getListDetail(id.toString());
|
|
|
|
|
data = res.data;
|
|
|
|
|
if (data.code === 200) {
|
|
|
|
|
const playlist = data.playlist;
|
|
|
|
|
musicStore.setCurrentMusicList(
|
|
|
|
|
playlist.tracks || [],
|
|
|
|
|
playlist.name,
|
|
|
|
|
playlist,
|
|
|
|
|
playlist.creator?.userId === userStore.user?.userId
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
message.error(t('common.loadFailed'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('加载列表数据失败:', error);
|
|
|
|
|
message.error(t('common.loadFailed'));
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => route.fullPath,
|
|
|
|
|
() => {
|
|
|
|
|
fetchData();
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
);
|
2025-09-10 00:29:50 +08:00
|
|
|
const isDailyRecommend = computed(() => route.query.type === 'dailyRecommend');
|
2025-10-22 21:50:20 +08:00
|
|
|
const isAlbum = computed(() => route.query.type === 'album');
|
2026-02-04 20:16:52 +08:00
|
|
|
|
2025-09-10 00:29:50 +08:00
|
|
|
const name = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
if (isDailyRecommend.value) return t('comp.recommendSinger.songlist');
|
|
|
|
|
return musicStore.currentMusicListName || '';
|
2025-09-10 00:29:50 +08:00
|
|
|
});
|
2026-02-04 20:16:52 +08:00
|
|
|
|
2025-09-10 00:29:50 +08:00
|
|
|
const songList = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
if (isDailyRecommend.value) return recommendStore.dailyRecommendSongs;
|
2025-09-10 00:29:50 +08:00
|
|
|
return musicStore.currentMusicList || [];
|
|
|
|
|
});
|
2026-02-04 20:16:52 +08:00
|
|
|
|
2025-09-10 00:29:50 +08:00
|
|
|
const listInfo = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
if (isDailyRecommend.value) return null;
|
2025-09-10 00:29:50 +08:00
|
|
|
return musicStore.currentListInfo || null;
|
|
|
|
|
});
|
2026-02-04 20:16:52 +08:00
|
|
|
|
2025-09-10 00:29:50 +08:00
|
|
|
const canRemove = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
if (isDailyRecommend.value) return false;
|
2025-09-10 00:29:50 +08:00
|
|
|
return musicStore.canRemoveSong || false;
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-15 21:20:01 +08:00
|
|
|
const canCollect = ref(false);
|
|
|
|
|
const isCollected = ref(false);
|
2025-05-07 22:36:52 +08:00
|
|
|
const pageSize = 40;
|
|
|
|
|
const displayedSongs = ref<SongResult[]>([]);
|
|
|
|
|
const loadingList = ref(false);
|
2026-02-04 20:16:52 +08:00
|
|
|
const loadedIds = ref(new Set<number>());
|
|
|
|
|
const isPlaylistLoading = ref(false);
|
|
|
|
|
const completePlaylist = ref<SongResult[]>([]);
|
|
|
|
|
const hasMore = ref(true);
|
|
|
|
|
const searchKeyword = ref('');
|
|
|
|
|
const isFullPlaylistLoaded = ref(false);
|
|
|
|
|
|
|
|
|
|
const isSelecting = ref(false);
|
|
|
|
|
const selectedSongs = ref<number[]>([]);
|
|
|
|
|
const { isDownloading, batchDownloadMusic } = useDownload();
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2025-07-23 23:54:35 +08:00
|
|
|
const isCompactLayout = ref(
|
|
|
|
|
isMobile.value ? false : localStorage.getItem('musicListLayout') === 'compact'
|
2026-02-04 20:16:52 +08:00
|
|
|
);
|
2025-05-15 21:20:01 +08:00
|
|
|
|
2025-05-07 22:36:52 +08:00
|
|
|
const total = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
if (listInfo.value?.trackIds) return listInfo.value.trackIds.length;
|
2025-05-07 22:36:52 +08:00
|
|
|
return songList.value.length;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const getCoverImgUrl = computed(() => {
|
2025-10-22 21:50:20 +08:00
|
|
|
const coverImgUrl = listInfo.value?.coverImgUrl || listInfo.value?.picUrl;
|
2026-02-04 20:16:52 +08:00
|
|
|
if (coverImgUrl) return coverImgUrl;
|
2025-05-07 22:36:52 +08:00
|
|
|
const song = songList.value[0];
|
2026-02-04 20:16:52 +08:00
|
|
|
return song?.picUrl || song?.al?.picUrl || song?.album?.picUrl || '';
|
2025-05-07 22:36:52 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const filteredSongs = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
const sourceList = isDailyRecommend.value ? songList.value : displayedSongs.value;
|
|
|
|
|
const dislikeFilteredList = sourceList.filter((s) => !playerStore.dislikeList.includes(s.id));
|
2025-09-10 00:29:50 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
if (!searchKeyword.value) return dislikeFilteredList;
|
2025-09-10 00:29:50 +08:00
|
|
|
|
2025-05-07 22:36:52 +08:00
|
|
|
const keyword = searchKeyword.value.toLowerCase().trim();
|
2025-09-10 00:29:50 +08:00
|
|
|
return dislikeFilteredList.filter((song) => {
|
2025-05-07 22:36:52 +08:00
|
|
|
const songName = song.name?.toLowerCase() || '';
|
|
|
|
|
const albumName = song.al?.name?.toLowerCase() || '';
|
|
|
|
|
const artists = song.ar || song.artists || [];
|
|
|
|
|
return (
|
2026-02-04 20:16:52 +08:00
|
|
|
songName.includes(keyword) ||
|
|
|
|
|
albumName.includes(keyword) ||
|
|
|
|
|
artists.some((a: any) => a.name?.toLowerCase().includes(keyword)) ||
|
|
|
|
|
PinyinMatch.match(songName, keyword)
|
2025-05-07 22:36:52 +08:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-10 00:29:50 +08:00
|
|
|
const resetListState = () => {
|
|
|
|
|
loadedIds.value.clear();
|
|
|
|
|
displayedSongs.value = [];
|
|
|
|
|
completePlaylist.value = [];
|
|
|
|
|
hasMore.value = true;
|
|
|
|
|
isFullPlaylistLoaded.value = false;
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-07 22:36:52 +08:00
|
|
|
const formatSong = (item: any) => {
|
2026-02-04 20:16:52 +08:00
|
|
|
if (!item) return null;
|
2025-05-07 22:36:52 +08:00
|
|
|
return {
|
|
|
|
|
...item,
|
|
|
|
|
picUrl: item.al?.picUrl || item.picUrl,
|
|
|
|
|
song: {
|
|
|
|
|
artists: item.ar || item.artists,
|
2026-02-04 20:16:52 +08:00
|
|
|
name: item.name,
|
|
|
|
|
id: item.id
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadSongs = async (ids: number[], appendToList = true, updateComplete = false) => {
|
|
|
|
|
if (ids.length === 0) return [];
|
|
|
|
|
try {
|
|
|
|
|
const { data } = await getMusicDetail(ids);
|
|
|
|
|
if (data?.songs) {
|
|
|
|
|
const { songs } = data;
|
2026-02-04 20:16:52 +08:00
|
|
|
songs.forEach((song: any) => loadedIds.value.add(song.id));
|
|
|
|
|
if (appendToList) displayedSongs.value.push(...songs);
|
|
|
|
|
if (updateComplete) completePlaylist.value.push(...songs);
|
|
|
|
|
return songs;
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('加载歌曲失败:', error);
|
|
|
|
|
}
|
|
|
|
|
return [];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadFullPlaylist = async () => {
|
|
|
|
|
if (isPlaylistLoading.value || isFullPlaylistLoaded.value) return;
|
|
|
|
|
isPlaylistLoading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
if (!listInfo.value?.trackIds) {
|
|
|
|
|
isFullPlaylistLoaded.value = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const allIds = listInfo.value.trackIds.map((item) => item.id);
|
2026-02-04 20:16:52 +08:00
|
|
|
const loadedSongIds = new Set(displayedSongs.value.map((s) => s.id as number));
|
|
|
|
|
completePlaylist.value = [...displayedSongs.value];
|
2025-05-07 22:36:52 +08:00
|
|
|
const unloadedIds = allIds.filter((id) => !loadedSongIds.has(id));
|
|
|
|
|
|
|
|
|
|
if (unloadedIds.length === 0) {
|
|
|
|
|
isFullPlaylistLoaded.value = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
const batchSize = 500;
|
2025-05-07 22:36:52 +08:00
|
|
|
for (let i = 0; i < unloadedIds.length; i += batchSize) {
|
|
|
|
|
const batchIds = unloadedIds.slice(i, i + batchSize);
|
|
|
|
|
const loadedBatch = await loadSongs(batchIds, false, false);
|
|
|
|
|
if (loadedBatch.length > 0) {
|
2026-02-04 20:16:52 +08:00
|
|
|
displayedSongs.value = [...displayedSongs.value, ...loadedBatch];
|
|
|
|
|
completePlaylist.value = [...completePlaylist.value, ...loadedBatch];
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
isFullPlaylistLoaded.value = true;
|
|
|
|
|
hasMore.value = false;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('加载完整播放列表失败:', error);
|
|
|
|
|
} finally {
|
|
|
|
|
isPlaylistLoading.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
const handlePlayAll = () => {
|
|
|
|
|
if (displayedSongs.value.length === 0) return;
|
2025-10-22 21:51:16 +08:00
|
|
|
saveHistory();
|
2026-02-04 20:16:52 +08:00
|
|
|
const list = searchKeyword.value
|
|
|
|
|
? filteredSongs.value
|
|
|
|
|
: isFullPlaylistLoaded.value
|
|
|
|
|
? completePlaylist.value
|
|
|
|
|
: displayedSongs.value;
|
|
|
|
|
playerStore.setPlayList(list.map(formatSong));
|
|
|
|
|
playerStore.setPlay(formatSong(list[0]));
|
|
|
|
|
if (!isFullPlaylistLoaded.value) loadFullPlaylist();
|
|
|
|
|
};
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
const handlePlayItem = (item: any) => {
|
|
|
|
|
playerStore.setPlay(formatSong(item));
|
|
|
|
|
if (!playerStore.playList.some((s) => s.id === item.id)) {
|
|
|
|
|
playerStore.addToNextPlay(formatSong(item));
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRemoveSong = async (songId: number) => {
|
|
|
|
|
if (!listInfo.value?.id || !canRemove.value) return;
|
|
|
|
|
try {
|
|
|
|
|
const res = await updatePlaylistTracks({
|
|
|
|
|
op: 'del',
|
|
|
|
|
pid: listInfo.value.id,
|
|
|
|
|
tracks: songId.toString()
|
|
|
|
|
});
|
|
|
|
|
if (res.status === 200) {
|
|
|
|
|
message.success(t('user.message.deleteSuccess'));
|
2026-02-04 20:16:52 +08:00
|
|
|
displayedSongs.value = displayedSongs.value.filter((s) => s.id !== songId);
|
|
|
|
|
completePlaylist.value = completePlaylist.value.filter((s) => s.id !== songId);
|
|
|
|
|
musicStore.removeSongFromList(songId);
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
} catch (error) {
|
2025-05-07 22:36:52 +08:00
|
|
|
console.error('删除歌曲失败:', error);
|
2026-02-04 20:16:52 +08:00
|
|
|
message.error(t('user.message.deleteFailed'));
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
const handleScroll = (e: Event) => {
|
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = target;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
// 懒加载:滚动到底部时加载更多
|
|
|
|
|
if (
|
|
|
|
|
scrollHeight - scrollTop - clientHeight < 200 &&
|
|
|
|
|
!loadingList.value &&
|
|
|
|
|
hasMore.value &&
|
|
|
|
|
!searchKeyword.value
|
|
|
|
|
) {
|
|
|
|
|
loadMoreSongs();
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
};
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
const loadMoreSongs = async () => {
|
|
|
|
|
if (
|
|
|
|
|
isFullPlaylistLoaded.value ||
|
|
|
|
|
searchKeyword.value ||
|
|
|
|
|
displayedSongs.value.length >= total.value
|
|
|
|
|
)
|
2025-05-07 22:36:52 +08:00
|
|
|
return;
|
2026-02-04 20:16:52 +08:00
|
|
|
loadingList.value = true;
|
2025-05-07 22:36:52 +08:00
|
|
|
try {
|
|
|
|
|
const start = displayedSongs.value.length;
|
|
|
|
|
const end = Math.min(start + pageSize, total.value);
|
|
|
|
|
if (listInfo.value?.trackIds) {
|
2026-02-04 20:16:52 +08:00
|
|
|
const ids = listInfo.value.trackIds
|
2025-05-07 22:36:52 +08:00
|
|
|
.slice(start, end)
|
2026-02-04 20:16:52 +08:00
|
|
|
.map((i) => i.id)
|
2025-05-07 22:36:52 +08:00
|
|
|
.filter((id) => !loadedIds.value.has(id));
|
2026-02-04 20:16:52 +08:00
|
|
|
if (ids.length > 0) await loadSongs(ids);
|
|
|
|
|
} else {
|
2025-05-07 22:36:52 +08:00
|
|
|
const newSongs = songList.value.slice(start, end);
|
2026-02-04 20:16:52 +08:00
|
|
|
newSongs.forEach((s) => {
|
|
|
|
|
if (!loadedIds.value.has(s.id)) {
|
|
|
|
|
loadedIds.value.add(s.id);
|
|
|
|
|
displayedSongs.value.push(s);
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
hasMore.value = displayedSongs.value.length < total.value;
|
|
|
|
|
} finally {
|
|
|
|
|
loadingList.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
const saveHistory = () => {
|
|
|
|
|
if (!listInfo.value?.id) return;
|
|
|
|
|
if (isAlbum.value) {
|
|
|
|
|
addAlbum({
|
|
|
|
|
id: listInfo.value.id,
|
|
|
|
|
name: listInfo.value.name || '',
|
|
|
|
|
picUrl: getCoverImgUrl.value,
|
|
|
|
|
size: total.value,
|
|
|
|
|
artist: listInfo.value.artist
|
|
|
|
|
});
|
|
|
|
|
} else if (route.query.type === 'playlist') {
|
|
|
|
|
addPlaylist({
|
|
|
|
|
id: listInfo.value.id,
|
|
|
|
|
name: listInfo.value.name || '',
|
|
|
|
|
coverImgUrl: getCoverImgUrl.value,
|
|
|
|
|
trackCount: total.value,
|
|
|
|
|
playCount: listInfo.value.playCount,
|
|
|
|
|
creator: listInfo.value.creator
|
|
|
|
|
});
|
2025-05-15 21:20:01 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleCollect = async () => {
|
2026-02-04 20:16:52 +08:00
|
|
|
if (!listInfo.value?.id || !hasPermission(true)) {
|
|
|
|
|
if (!listInfo.value?.id) return;
|
2025-09-14 00:34:35 +08:00
|
|
|
message.error(getLoginErrorMessage(true));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-10-22 21:50:20 +08:00
|
|
|
const type = route.query.type as string;
|
2025-05-15 21:20:01 +08:00
|
|
|
try {
|
2026-02-04 20:16:52 +08:00
|
|
|
const tVal = isCollected.value ? 2 : 1;
|
2025-10-22 21:50:20 +08:00
|
|
|
const response =
|
|
|
|
|
type === 'album'
|
2026-02-04 20:16:52 +08:00
|
|
|
? await subscribeAlbum({ t: tVal, id: listInfo.value.id })
|
|
|
|
|
: await subscribePlaylist({ t: tVal, id: listInfo.value.id });
|
|
|
|
|
if (response.data.code === 200) {
|
2025-05-15 21:20:01 +08:00
|
|
|
isCollected.value = !isCollected.value;
|
2026-02-04 20:16:52 +08:00
|
|
|
message.success(
|
|
|
|
|
t(
|
|
|
|
|
isCollected.value
|
|
|
|
|
? 'comp.musicList.collectSuccess'
|
|
|
|
|
: 'comp.musicList.cancelCollectSuccess'
|
|
|
|
|
)
|
|
|
|
|
);
|
2025-10-22 21:50:20 +08:00
|
|
|
if (type === 'album') {
|
2026-02-04 20:16:52 +08:00
|
|
|
isCollected.value
|
|
|
|
|
? userStore.addCollectedAlbum(listInfo.value.id)
|
|
|
|
|
: userStore.removeCollectedAlbum(listInfo.value.id);
|
2025-10-22 21:50:20 +08:00
|
|
|
} else {
|
|
|
|
|
listInfo.value.subscribed = isCollected.value;
|
|
|
|
|
}
|
2025-05-15 21:20:01 +08:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2026-02-04 20:16:52 +08:00
|
|
|
console.error('操作收藏失败:', error);
|
2025-05-15 21:20:01 +08:00
|
|
|
message.error(t('comp.musicList.operationFailed'));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-06-04 20:19:44 +08:00
|
|
|
const startSelect = () => {
|
|
|
|
|
isSelecting.value = true;
|
|
|
|
|
selectedSongs.value = [];
|
|
|
|
|
};
|
|
|
|
|
const cancelSelect = () => {
|
|
|
|
|
isSelecting.value = false;
|
|
|
|
|
selectedSongs.value = [];
|
|
|
|
|
};
|
2026-02-04 20:16:52 +08:00
|
|
|
const handleSelect = (id: number, selected: boolean) => {
|
|
|
|
|
selected
|
|
|
|
|
? selectedSongs.value.push(id)
|
|
|
|
|
: (selectedSongs.value = selectedSongs.value.filter((i) => i !== id));
|
2025-06-04 20:19:44 +08:00
|
|
|
};
|
2026-02-04 20:16:52 +08:00
|
|
|
const isAllSelected = computed(
|
|
|
|
|
() => filteredSongs.value.length > 0 && selectedSongs.value.length === filteredSongs.value.length
|
|
|
|
|
);
|
|
|
|
|
const isIndeterminate = computed(
|
|
|
|
|
() => selectedSongs.value.length > 0 && selectedSongs.value.length < filteredSongs.value.length
|
|
|
|
|
);
|
2025-06-04 20:19:44 +08:00
|
|
|
const handleSelectAll = (checked: boolean) => {
|
2026-02-04 20:16:52 +08:00
|
|
|
selectedSongs.value = checked ? filteredSongs.value.map((s) => s.id as number) : [];
|
2025-06-04 20:19:44 +08:00
|
|
|
};
|
|
|
|
|
const handleBatchDownload = async () => {
|
2026-02-04 20:16:52 +08:00
|
|
|
const list = selectedSongs.value
|
|
|
|
|
.map((id) => filteredSongs.value.find((s) => s.id === id))
|
|
|
|
|
.filter((s) => s) as SongResult[];
|
|
|
|
|
await batchDownloadMusic(list);
|
2025-06-04 20:19:44 +08:00
|
|
|
cancelSelect();
|
|
|
|
|
};
|
2025-10-22 21:52:22 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
const toggleLayout = () => {
|
|
|
|
|
isCompactLayout.value = !isCompactLayout.value;
|
|
|
|
|
localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal');
|
2025-10-22 21:52:22 +08:00
|
|
|
};
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
const checkCollectionStatus = () => {
|
|
|
|
|
const type = route.query.type as string;
|
|
|
|
|
if (type === 'playlist' && listInfo.value?.id) {
|
|
|
|
|
canCollect.value = true;
|
|
|
|
|
isCollected.value = listInfo.value.subscribed || false;
|
|
|
|
|
} else if (type === 'album' && listInfo.value?.id) {
|
|
|
|
|
canCollect.value = true;
|
|
|
|
|
isCollected.value = userStore.isAlbumCollected(listInfo.value.id);
|
|
|
|
|
} else {
|
|
|
|
|
canCollect.value = false;
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
};
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
watch(
|
|
|
|
|
songList,
|
|
|
|
|
(newSongs) => {
|
|
|
|
|
resetListState();
|
|
|
|
|
if (newSongs.length > 0) {
|
|
|
|
|
displayedSongs.value = [...newSongs];
|
|
|
|
|
newSongs.forEach((s) => loadedIds.value.add(s.id));
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
hasMore.value = displayedSongs.value.length < total.value;
|
|
|
|
|
checkCollectionStatus();
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
);
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
onMounted(checkCollectionStatus);
|
|
|
|
|
</script>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
<style scoped lang="scss">
|
|
|
|
|
.music-list-page {
|
|
|
|
|
position: relative;
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
.hero-section {
|
|
|
|
|
min-height: 300px;
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
.action-bar {
|
|
|
|
|
transition:
|
|
|
|
|
background-color 0.3s,
|
|
|
|
|
box-shadow 0.3s;
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
.animate-item {
|
|
|
|
|
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
@keyframes fadeInUp {
|
|
|
|
|
from {
|
|
|
|
|
opacity: 0;
|
|
|
|
|
transform: translateY(20px);
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
to {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
transform: translateY(0);
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
.action-btn-pill {
|
|
|
|
|
@apply transition-all border-neutral-200 dark:border-neutral-800;
|
|
|
|
|
&:hover:not(:disabled) {
|
|
|
|
|
@apply border-primary/30 bg-primary/5;
|
2025-05-07 22:36:52 +08:00
|
|
|
}
|
|
|
|
|
}
|
2025-05-15 21:20:01 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
.action-btn-icon {
|
|
|
|
|
@apply transition-all;
|
|
|
|
|
&:hover {
|
|
|
|
|
@apply scale-110 text-primary bg-primary/10;
|
2025-05-15 21:20:01 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
.song-list-container {
|
|
|
|
|
padding-bottom: 100px;
|
|
|
|
|
}
|
2025-05-15 21:20:01 +08:00
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
.mobile {
|
|
|
|
|
.hero-section {
|
|
|
|
|
min-height: auto;
|
2025-05-15 21:20:01 +08:00
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
.action-bar {
|
|
|
|
|
@apply py-2;
|
2025-05-15 21:20:01 +08:00
|
|
|
}
|
|
|
|
|
}
|
2025-07-23 23:54:35 +08:00
|
|
|
</style>
|