feat: 内置酷狗搜索/排行榜/热门搜索 + 内嵌歌词提取ID3v2 USLT/SYLT
This commit is contained in:
538
qzmusic-web/src/views/Home.vue
Normal file
538
qzmusic-web/src/views/Home.vue
Normal file
@@ -0,0 +1,538 @@
|
||||
<template>
|
||||
<div class="view-container home-view">
|
||||
<div class="content-wrapper">
|
||||
<!-- Search bar -->
|
||||
<div class="search-section" @click="focusSearch">
|
||||
<div class="search-bar" :class="{ focused: searchFocused }">
|
||||
<Icon icon="lucide:search" class="search-icon" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索歌曲、歌手、专辑..."
|
||||
class="search-input"
|
||||
@focus="searchFocused = true"
|
||||
@blur="searchFocused = false"
|
||||
@keydown.enter="doSearch"
|
||||
/>
|
||||
<button class="search-btn" @click.stop="doSearch">搜索</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hot search terms -->
|
||||
<div class="section" v-if="hotTerms.length > 0">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">热门搜索</h3>
|
||||
</div>
|
||||
<div class="hot-tags">
|
||||
<span
|
||||
v-for="(term, idx) in hotTerms"
|
||||
:key="idx"
|
||||
class="hot-tag"
|
||||
:class="{ 'top-three': idx < 3 }"
|
||||
@click="searchTerm(term)"
|
||||
>
|
||||
<span class="tag-rank">{{ idx + 1 }}</span>
|
||||
{{ term }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard -->
|
||||
<div class="section" v-if="rankCategories.length > 0">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">排行榜</h3>
|
||||
</div>
|
||||
<div class="rank-tabs">
|
||||
<button
|
||||
v-for="cat in rankCategories"
|
||||
:key="cat.rankid"
|
||||
class="rank-tab"
|
||||
:class="{ active: activeRankId === cat.rankid }"
|
||||
@click="selectRank(cat.rankid)"
|
||||
>
|
||||
{{ cat.rankname }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="song-list" v-if="rankSongs.length > 0">
|
||||
<div
|
||||
v-for="(song, idx) in rankSongs"
|
||||
:key="song.hash || idx"
|
||||
class="song-item"
|
||||
@dblclick="playRankSong(idx)"
|
||||
>
|
||||
<div class="song-rank-badge" :class="{ 'top-three': idx < 3 }">{{ idx + 1 }}</div>
|
||||
<div class="song-cover" v-if="song.imgUrl || song.album_img">
|
||||
<img :src="song.imgUrl || song.album_img" :alt="song.songname" loading="lazy" />
|
||||
</div>
|
||||
<div class="song-cover placeholder" v-else>
|
||||
<Icon icon="lucide:music" />
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<h4 class="song-title">{{ song.songname || song.filename }}</h4>
|
||||
<p class="song-artist">{{ song.singername || '未知歌手' }}</p>
|
||||
</div>
|
||||
<span class="song-duration">{{ formatDuration(song.duration! || (song.second ?? 0) * 1000) }}</span>
|
||||
<button class="play-btn" @click.stop="playRankSong(idx)" title="播放">
|
||||
<Icon icon="lucide:play" width="16" height="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-state" v-else-if="loadingRank">
|
||||
<Icon icon="lucide:loader-2" class="spin" width="24" height="24" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div class="empty-state" v-else-if="rankError">
|
||||
<Icon icon="lucide:alert-circle" width="24" height="24" />
|
||||
<span>{{ rankError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { transformSearchSong } from '../utils/songUtils';
|
||||
import type { Song } from '../types/song';
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const searchQuery = ref('');
|
||||
const searchFocused = ref(false);
|
||||
|
||||
const hotTerms = ref<string[]>([]);
|
||||
|
||||
interface RankCategory {
|
||||
rankid: number;
|
||||
rankname: string;
|
||||
imgUrl?: string;
|
||||
}
|
||||
|
||||
interface RankSong {
|
||||
hash: string;
|
||||
songname: string;
|
||||
singername: string;
|
||||
album_name?: string;
|
||||
album_img?: string;
|
||||
imgUrl?: string;
|
||||
filename?: string;
|
||||
duration?: number;
|
||||
second?: number;
|
||||
}
|
||||
|
||||
const rankCategories = ref<RankCategory[]>([]);
|
||||
const activeRankId = ref<number>(0);
|
||||
const rankSongs = ref<RankSong[]>([]);
|
||||
const loadingRank = ref(false);
|
||||
const rankError = ref('');
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (!ms || ms <= 0) return '0:00';
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
searchInput.value?.focus();
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
const q = searchQuery.value.trim();
|
||||
if (q) {
|
||||
router.push({ path: '/search', query: { q } });
|
||||
}
|
||||
}
|
||||
|
||||
function searchTerm(term: string) {
|
||||
searchQuery.value = term;
|
||||
router.push({ path: '/search', query: { q: term } });
|
||||
}
|
||||
|
||||
async function fetchHotSearch() {
|
||||
try {
|
||||
const url = 'https://msearchcdn.kugou.com/api/v3/search/hot';
|
||||
const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const list = data?.data?.info;
|
||||
if (Array.isArray(list)) {
|
||||
hotTerms.value = list.slice(0, 15).map((item: any) => item.keyword || item.word || '');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function fetchRankList() {
|
||||
try {
|
||||
const url = 'https://mobilecdnbj.kugou.com/api/v3/rank/list';
|
||||
const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const ranks = data?.data?.info || data?.data?.list || [];
|
||||
if (Array.isArray(ranks)) {
|
||||
rankCategories.value = ranks.slice(0, 20).map((r: any) => ({
|
||||
rankid: r.rankid || r.id,
|
||||
rankname: r.rankname || r.name,
|
||||
imgUrl: r.imgUrl || r.img,
|
||||
}));
|
||||
if (rankCategories.value.length > 0 && !activeRankId.value) {
|
||||
activeRankId.value = rankCategories.value[0].rankid;
|
||||
fetchRankSongs(activeRankId.value);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function fetchRankSongs(rankid: number) {
|
||||
loadingRank.value = true;
|
||||
rankError.value = '';
|
||||
rankSongs.value = [];
|
||||
try {
|
||||
const url = `https://mobilecdnbj.kugou.com/api/v3/rank/info?rankid=${rankid}&page=1&pagesize=30`;
|
||||
const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||
if (!resp.ok) { rankError.value = '加载失败'; loadingRank.value = false; return; }
|
||||
const data = await resp.json();
|
||||
const list = data?.data?.info || data?.data?.songs || [];
|
||||
if (Array.isArray(list)) {
|
||||
rankSongs.value = list.map((s: any) => ({
|
||||
hash: s.hash || s.songhash || '',
|
||||
songname: s.songname || s.name || s.title || '',
|
||||
singername: s.singername || s.singer || s.artist || '',
|
||||
album_name: s.album_name || s.album || '',
|
||||
album_img: s.album_img || s.img || '',
|
||||
imgUrl: s.imgUrl || s.img || '',
|
||||
filename: s.filename || '',
|
||||
duration: s.duration ? Number(s.duration) * 1000 : (s.second ? Number(s.second) * 1000 : 0),
|
||||
second: s.second ? Number(s.second) : 0,
|
||||
}));
|
||||
} else {
|
||||
rankError.value = '暂无数据';
|
||||
}
|
||||
} catch { rankError.value = '网络请求失败'; }
|
||||
loadingRank.value = false;
|
||||
}
|
||||
|
||||
function selectRank(rankid: number) {
|
||||
activeRankId.value = rankid;
|
||||
fetchRankSongs(rankid);
|
||||
}
|
||||
|
||||
function playRankSong(idx: number) {
|
||||
const rs = rankSongs.value[idx];
|
||||
if (!rs) return;
|
||||
const song: Song = transformSearchSong({
|
||||
songmid: rs.hash,
|
||||
hash: rs.hash,
|
||||
name: rs.songname || rs.filename,
|
||||
singer: rs.singername,
|
||||
img: rs.imgUrl || rs.album_img,
|
||||
interval: rs.duration || (rs.second ? rs.second * 1000 : 0),
|
||||
source: 'kugou',
|
||||
albumName: rs.album_name,
|
||||
});
|
||||
const list: Song[] = rankSongs.value.map((s) =>
|
||||
transformSearchSong({
|
||||
songmid: s.hash,
|
||||
hash: s.hash,
|
||||
name: s.songname || s.filename,
|
||||
singer: s.singername,
|
||||
img: s.imgUrl || s.album_img,
|
||||
interval: s.duration || (s.second ? s.second * 1000 : 0),
|
||||
source: 'kugou',
|
||||
albumName: s.album_name,
|
||||
})
|
||||
);
|
||||
player.playFromList(song, list);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchHotSearch();
|
||||
fetchRankList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 8px 8px 8px 20px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.search-bar.focused {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 8px 12px;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 8px 24px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Hot tags */
|
||||
.hot-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hot-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 14px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.hot-tag:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.hot-tag.top-three {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-rank {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Rank tabs */
|
||||
.rank-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.rank-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 18px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rank-tab:hover {
|
||||
border-color: var(--color-border-light);
|
||||
}
|
||||
|
||||
.rank-tab.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Song list */
|
||||
.song-list {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.song-rank-badge {
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-rank-badge.top-three {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.song-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.song-cover.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.loading-state, .empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 60px 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
344
qzmusic-web/src/views/LocalMusic.vue
Normal file
344
qzmusic-web/src/views/LocalMusic.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<template>
|
||||
<div class="view-container local-view">
|
||||
<h1 class="view-title">本地音乐</h1>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<button class="action-btn" @click="selectFolder" :disabled="scanning" v-if="supportsFolder">
|
||||
<Icon :icon="scanning ? 'lucide:loader-2' : 'lucide:folder-open'" width="18" height="18" />
|
||||
{{ scanning ? '扫描中...' : '选择文件夹' }}
|
||||
</button>
|
||||
<button class="action-btn" @click="selectFiles" :disabled="scanning">
|
||||
<Icon icon="lucide:file-plus" width="18" height="18" />
|
||||
选择文件
|
||||
</button>
|
||||
<button class="action-btn secondary" @click="clearAll" v-if="tracks.length > 0">
|
||||
<Icon icon="lucide:trash-2" width="18" height="18" />
|
||||
清空列表
|
||||
</button>
|
||||
<span class="track-count" v-if="tracks.length > 0">{{ tracks.length }} 首歌曲</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="progress-bar" v-if="scanProgress > 0 && scanProgress < 100">
|
||||
<div class="progress-fill" :style="{ width: scanProgress + '%' }"></div>
|
||||
</div>
|
||||
<p class="scan-status" v-if="scanMessage">{{ scanMessage }}</p>
|
||||
|
||||
<!-- Song list -->
|
||||
<div class="track-list" v-if="tracks.length > 0">
|
||||
<div
|
||||
v-for="(track, idx) in tracks"
|
||||
:key="track.id"
|
||||
class="track-item"
|
||||
:class="{ active: currentTrackId === track.id }"
|
||||
@dblclick="playTrack(idx)"
|
||||
>
|
||||
<span class="track-idx">{{ idx + 1 }}</span>
|
||||
<div class="track-info">
|
||||
<span class="track-name">{{ track.name }}</span>
|
||||
<span class="track-artist">{{ track.artist }}</span>
|
||||
</div>
|
||||
<span class="track-duration">{{ formatDuration(track.duration) }}</span>
|
||||
<button class="play-btn" @click="playTrack(idx)" title="播放">
|
||||
<Icon icon="lucide:play" width="16" height="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div class="empty-state" v-else-if="!scanning">
|
||||
<div class="icon-box">
|
||||
<Icon icon="lucide:music" width="48" height="48" />
|
||||
</div>
|
||||
<p>{{ scanned ? '未找到音频文件' : '暂无本地音乐' }}</p>
|
||||
<p class="hint">选择包含音乐文件的文件夹或直接选择文件</p>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*"
|
||||
style="display:none"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { scanFile, scanDirectory, loadTracks, saveTracks, clearSavedTracks, supportsDirectoryPicker, trackToSong, formatDuration } from '../utils/localMusic';
|
||||
import type { ScannedTrack } from '../utils/localMusic';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const tracks = ref<ScannedTrack[]>([]);
|
||||
const scanning = ref(false);
|
||||
const scanProgress = ref(0);
|
||||
const scanMessage = ref('');
|
||||
const scanned = ref(false);
|
||||
const supportsFolder = supportsDirectoryPicker();
|
||||
const currentTrackId = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = await loadTracks();
|
||||
if (saved.length > 0) {
|
||||
tracks.value = saved as ScannedTrack[];
|
||||
scanned.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function selectFolder() {
|
||||
try {
|
||||
const dirHandle = await (window as any).showDirectoryPicker();
|
||||
scanning.value = true;
|
||||
scanProgress.value = 5;
|
||||
scanMessage.value = '正在扫描文件夹...';
|
||||
const found = await scanDirectory(dirHandle, (name) => {
|
||||
scanMessage.value = `扫描: ${name}`;
|
||||
});
|
||||
scanProgress.value = 90;
|
||||
scanMessage.value = `找到 ${found.length} 首歌曲`;
|
||||
if (found.length > 0) {
|
||||
tracks.value = found;
|
||||
await saveTracks(found);
|
||||
scanned.value = true;
|
||||
}
|
||||
scanProgress.value = 100;
|
||||
setTimeout(() => { scanProgress.value = 0; scanMessage.value = ''; }, 2000);
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'AbortError' && err?.name !== 'SecurityError') {
|
||||
console.warn('选择文件夹失败:', err);
|
||||
}
|
||||
scanMessage.value = '';
|
||||
} finally {
|
||||
scanning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectFiles() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onFileSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
scanning.value = true;
|
||||
scanProgress.value = 10;
|
||||
scanMessage.value = `处理 ${input.files.length} 个文件...`;
|
||||
const found: ScannedTrack[] = [];
|
||||
let done = 0;
|
||||
for (const file of Array.from(input.files)) {
|
||||
const track = await scanFile(file);
|
||||
if (track) found.push(track);
|
||||
done++;
|
||||
scanProgress.value = 10 + Math.round((done / input.files.length) * 80);
|
||||
scanMessage.value = `处理: ${file.name}`;
|
||||
}
|
||||
if (found.length > 0) {
|
||||
tracks.value = found;
|
||||
await saveTracks(found);
|
||||
scanned.value = true;
|
||||
}
|
||||
scanProgress.value = 100;
|
||||
setTimeout(() => { scanProgress.value = 0; scanMessage.value = ''; }, 1000);
|
||||
scanning.value = false;
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function playTrack(idx: number) {
|
||||
const track = tracks.value[idx];
|
||||
if (!track) return;
|
||||
currentTrackId.value = track.id;
|
||||
const song = trackToSong(track);
|
||||
song.url = URL.createObjectURL(track.file);
|
||||
const list = tracks.value.map(t => {
|
||||
const s = trackToSong(t);
|
||||
s.url = URL.createObjectURL(t.file);
|
||||
return s;
|
||||
});
|
||||
player.playFromList(song, list);
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
tracks.value = [];
|
||||
scanned.value = false;
|
||||
await clearSavedTracks();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg-primary);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.track-count {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.scan-status {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.track-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: default;
|
||||
transition: background 0.15s;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.track-item.active {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.track-idx {
|
||||
width: 24px;
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-bg-tertiary, rgba(128,128,128,0.1));
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
background-color: var(--color-bg-secondary);
|
||||
padding: 24px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
760
qzmusic-web/src/views/LogView.vue
Normal file
760
qzmusic-web/src/views/LogView.vue
Normal file
@@ -0,0 +1,760 @@
|
||||
<template>
|
||||
<div class="log-view">
|
||||
<div class="log-header">
|
||||
<div class="title-row">
|
||||
<Icon icon="lucide:terminal" class="title-icon" />
|
||||
<h1 class="title">运行日志</h1>
|
||||
<span class="log-count">共 {{ logStore.logs.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<span class="stat info">info {{ counts.info }}</span>
|
||||
<span class="stat warn">warn {{ counts.warn }}</span>
|
||||
<span class="stat error">error {{ counts.error }}</span>
|
||||
<span class="stat debug">debug {{ counts.debug }}</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">模块</label>
|
||||
<select v-model="selectedModule" class="filter-select">
|
||||
<option value="">全部</option>
|
||||
<option v-for="m in modules" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
|
||||
<label class="filter-label">级别</label>
|
||||
<select v-model="selectedLevel" class="filter-select">
|
||||
<option value="">全部</option>
|
||||
<option value="debug">debug</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
|
||||
<label class="filter-label">搜索</label>
|
||||
<input v-model="searchText" class="filter-input" placeholder="关键字..." />
|
||||
</div>
|
||||
|
||||
<div class="action-group">
|
||||
<button class="btn" @click="scrollToBottom()" title="滚到底部">
|
||||
<Icon icon="lucide:arrow-down-to-line" />
|
||||
</button>
|
||||
<button class="btn" @click="scrollToTop()" title="滚到顶部">
|
||||
<Icon icon="lucide:arrow-up-to-line" />
|
||||
</button>
|
||||
<button class="btn" @click="toggleExpandAll()">
|
||||
<Icon :icon="expandedAll ? 'lucide:minimize-2' : 'lucide:maximize-2'" />
|
||||
<span>{{ expandedAll ? '收起详情' : '展开详情' }}</span>
|
||||
</button>
|
||||
<button class="btn" @click="printLogs()" title="打印/导出图片">
|
||||
<Icon icon="lucide:printer" />
|
||||
<span>打印图片</span>
|
||||
</button>
|
||||
<button class="btn" @click="exportHtml()" title="导出 HTML(含图片)">
|
||||
<Icon icon="lucide:file-image" />
|
||||
<span>HTML</span>
|
||||
</button>
|
||||
<button class="btn" @click="exportTxt()" title="导出 TXT">
|
||||
<Icon icon="lucide:file-text" />
|
||||
<span>TXT</span>
|
||||
</button>
|
||||
<button class="btn" @click="exportJson()" title="导出 JSON">
|
||||
<Icon icon="lucide:file-code" />
|
||||
<span>JSON</span>
|
||||
</button>
|
||||
<button class="btn" @click="copyAll()" title="复制全部">
|
||||
<Icon icon="lucide:copy" />
|
||||
</button>
|
||||
<button class="btn danger" @click="onClear()" title="清空">
|
||||
<Icon icon="lucide:trash-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="listRef" class="log-body">
|
||||
<div v-if="filtered.length === 0" class="empty-state">
|
||||
<Icon icon="lucide:file-text" class="empty-icon" />
|
||||
<div class="empty-text">暂无日志</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="entry in filtered"
|
||||
:key="entry.id"
|
||||
class="log-row"
|
||||
:class="entry.level"
|
||||
@click="toggleExpand(entry.id)"
|
||||
>
|
||||
<div class="row-main">
|
||||
<span class="log-time">{{ entry.time }}</span>
|
||||
<span class="log-level">{{ entry.level.toUpperCase() }}</span>
|
||||
<span class="log-module">[{{ entry.module }}]</span>
|
||||
<span class="log-message">{{ entry.message }}</span>
|
||||
<Icon
|
||||
v-if="entry.detail !== undefined"
|
||||
icon="lucide:chevron-down"
|
||||
class="expand-icon"
|
||||
:class="{ expanded: isExpanded(entry.id) }"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="entry.detail !== undefined && isExpanded(entry.id)" class="row-detail" @click.stop>
|
||||
<template v-if="isImageUrl(entry.detail)">
|
||||
<img :src="String(entry.detail)" class="detail-image" alt="图片日志" loading="lazy" />
|
||||
<div class="detail-src">{{ String(entry.detail) }}</div>
|
||||
</template>
|
||||
<template v-else-if="isErrorDetail(entry.detail)">
|
||||
<pre class="text-block">{{ formatErrorDetail(entry.detail) }}</pre>
|
||||
</template>
|
||||
<template v-else-if="typeof entry.detail === 'string' && (entry.detail.startsWith('http:') || entry.detail.startsWith('https:'))">
|
||||
<a :href="entry.detail" target="_blank" rel="noopener" class="detail-link">{{ entry.detail }}</a>
|
||||
</template>
|
||||
<template v-else-if="typeof entry.detail === 'string'">
|
||||
<pre class="text-block">{{ entry.detail }}</pre>
|
||||
</template>
|
||||
<template v-else-if="typeof entry.detail === 'number' || typeof entry.detail === 'boolean'">
|
||||
<pre class="text-block">{{ String(entry.detail) }}</pre>
|
||||
</template>
|
||||
<template v-else-if="typeof entry.detail === 'object' && entry.detail !== null">
|
||||
<pre class="json-block">{{ formatJson(entry.detail) }}</pre>
|
||||
</template>
|
||||
<template v-else>
|
||||
<pre class="text-block">{{ formatDetailForText(entry.detail) }}</pre>
|
||||
</template>
|
||||
<div class="detail-actions">
|
||||
<button class="mini-btn" @click.stop="copyDetail(entry.detail)">
|
||||
<Icon icon="lucide:copy" />
|
||||
<span>复制</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, nextTick, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { useLogStore } from '../stores/log';
|
||||
|
||||
const logStore = useLogStore();
|
||||
|
||||
const selectedModule = ref('');
|
||||
const selectedLevel = ref('');
|
||||
const searchText = ref('');
|
||||
const listRef = ref<HTMLDivElement | null>(null);
|
||||
const expandedAll = ref(false);
|
||||
const forcedExpanded = ref<Set<number>>(new Set());
|
||||
const forcedCollapsed = ref<Set<number>>(new Set());
|
||||
|
||||
const modules = computed(() => logStore.moduleList());
|
||||
const counts = computed(() => logStore.countByLevel());
|
||||
|
||||
const filtered = computed(() => {
|
||||
const keyword = searchText.value.trim().toLowerCase();
|
||||
return logStore.logs.filter((l) => {
|
||||
if (selectedModule.value && l.module !== selectedModule.value) return false;
|
||||
if (selectedLevel.value && l.level !== selectedLevel.value) return false;
|
||||
if (keyword) {
|
||||
const hay = `${l.message} ${l.module}`.toLowerCase();
|
||||
if (!hay.includes(keyword)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const isExpanded = (id: number) => {
|
||||
if (forcedExpanded.value.has(id)) return true;
|
||||
if (forcedCollapsed.value.has(id)) return false;
|
||||
return false; // 默认不展开任何条目
|
||||
};
|
||||
|
||||
const toggleExpand = (id: number) => {
|
||||
const currentlyExpanded = isExpanded(id);
|
||||
if (currentlyExpanded) {
|
||||
forcedCollapsed.value.add(id);
|
||||
forcedExpanded.value.delete(id);
|
||||
} else {
|
||||
forcedExpanded.value.add(id);
|
||||
forcedCollapsed.value.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpandAll = () => {
|
||||
expandedAll.value = !expandedAll.value;
|
||||
if (expandedAll.value) {
|
||||
for (const e of filtered.value) forcedExpanded.value.add(e.id);
|
||||
forcedCollapsed.value.clear();
|
||||
} else {
|
||||
for (const e of filtered.value) forcedCollapsed.value.add(e.id);
|
||||
forcedExpanded.value.clear();
|
||||
}
|
||||
};
|
||||
|
||||
const isImageUrl = (detail: any) => {
|
||||
if (typeof detail !== 'string') return false;
|
||||
const lower = detail.trim().toLowerCase();
|
||||
if (!lower.startsWith('http://') && !lower.startsWith('https://') && !lower.startsWith('data:image/')) return false;
|
||||
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(lower) || lower.startsWith('data:image/');
|
||||
};
|
||||
|
||||
const isErrorDetail = (detail: any) => {
|
||||
if (!detail || typeof detail !== 'object') return false;
|
||||
return (detail as any).__isError === true || (detail as any).stack || (detail as any).message;
|
||||
};
|
||||
|
||||
const formatErrorDetail = (detail: any): string => {
|
||||
const parts: string[] = [];
|
||||
if (detail.name) parts.push(`${detail.name}: ${detail.message || '(no message)'}`);
|
||||
else if (detail.message) parts.push(detail.message);
|
||||
if (detail.stack) parts.push(detail.stack);
|
||||
if (detail.cause !== undefined) parts.push(`\nCause: ${formatDetailForText(detail.cause)}`);
|
||||
// 把其他自定义字段也显示出来
|
||||
const otherKeys = Object.keys(detail).filter((k) => !['name', 'message', 'stack', 'cause', '__isError'].includes(k));
|
||||
if (otherKeys.length > 0) {
|
||||
const rest: Record<string, any> = {};
|
||||
for (const k of otherKeys) rest[k] = (detail as any)[k];
|
||||
parts.push(`\nExtra: ${formatJson(rest)}`);
|
||||
}
|
||||
return parts.join('\n') || String(detail);
|
||||
};
|
||||
|
||||
const formatJson = (val: any) => {
|
||||
try {
|
||||
return JSON.stringify(val, null, 2);
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (listRef.value) listRef.value.scrollTop = listRef.value.scrollHeight;
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
nextTick(() => {
|
||||
if (listRef.value) listRef.value.scrollTop = 0;
|
||||
});
|
||||
};
|
||||
|
||||
const buildTextLines = () => {
|
||||
return logStore.logs.map((l) => `[${l.time}] [${l.level.toUpperCase()}] [${l.module}] ${l.message}${
|
||||
l.detail !== undefined ? '\n ' + formatDetailForText(l.detail) : ''
|
||||
}`);
|
||||
};
|
||||
|
||||
const formatDetailForText = (d: any): string => {
|
||||
if (typeof d === 'string') return d;
|
||||
if (d instanceof Error) return `${d.name}: ${d.message}\n${d.stack || ''}`;
|
||||
try { return JSON.stringify(d, null, 2); } catch { return String(d); }
|
||||
};
|
||||
|
||||
const exportTxt = () => {
|
||||
const blob = new Blob([buildTextLines().join('\n')], { type: 'text/plain;charset=utf-8' });
|
||||
downloadBlob(blob, `qzmusic-logs-${tsFileName()}.txt`);
|
||||
};
|
||||
|
||||
const exportJson = () => {
|
||||
const data = JSON.stringify(
|
||||
{
|
||||
exportAt: new Date().toISOString(),
|
||||
total: logStore.logs.length,
|
||||
counts: counts.value,
|
||||
logs: logStore.logs,
|
||||
},
|
||||
(_, v) => (v instanceof Error ? { name: v.name, message: v.message, stack: v.stack } : v),
|
||||
2,
|
||||
);
|
||||
const blob = new Blob([data], { type: 'application/json;charset=utf-8' });
|
||||
downloadBlob(blob, `qzmusic-logs-${tsFileName()}.json`);
|
||||
};
|
||||
|
||||
const copyAll = async () => {
|
||||
const text = buildTextLines().join('\n');
|
||||
await writeToClipboard(text);
|
||||
};
|
||||
|
||||
const copyDetail = async (detail: any) => {
|
||||
const text = typeof detail === 'string' ? detail : formatDetailForText(detail);
|
||||
await writeToClipboard(text);
|
||||
};
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
const tsFileName = () =>
|
||||
new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
|
||||
|
||||
const writeToClipboard = async (text: string) => {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
};
|
||||
|
||||
// 打印/导出图片:先展开所有条目(优先展开有图片的),然后等待图片加载完再打印
|
||||
const printLogs = () => {
|
||||
// 1. 展开所有带 detail 的条目
|
||||
for (const e of logStore.logs) {
|
||||
if (e.detail !== undefined) forcedExpanded.value.add(e.id);
|
||||
}
|
||||
forcedCollapsed.value.clear();
|
||||
expandedAll.value = true;
|
||||
|
||||
// 2. 等 Vue 渲染完,再给图片 1.5 秒加载时间,然后触发打印
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
}, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
// 导出完整 HTML(含图片渲染),保存为文件后双击打开即可打印/截图
|
||||
const exportHtml = () => {
|
||||
const rowsHtml = logStore.logs.map((e) => {
|
||||
const levelCls = e.level;
|
||||
let detailHtml = '';
|
||||
if (e.detail !== undefined) {
|
||||
if (isImageUrl(e.detail)) {
|
||||
detailHtml = `
|
||||
<div class="row-detail">
|
||||
<img src="${String(e.detail)}" class="detail-image" alt="图片日志" />
|
||||
<div class="detail-src">${String(e.detail)}</div>
|
||||
</div>`;
|
||||
} else if (isErrorDetail(e.detail)) {
|
||||
detailHtml = `<div class="row-detail"><pre>${escapeHtml(formatErrorDetail(e.detail))}</pre></div>`;
|
||||
} else if (typeof e.detail === 'string' && (e.detail.startsWith('http:') || e.detail.startsWith('https:'))) {
|
||||
detailHtml = `<div class="row-detail"><a href="${e.detail}" target="_blank">${e.detail}</a></div>`;
|
||||
} else if (typeof e.detail === 'string') {
|
||||
detailHtml = `<div class="row-detail"><pre>${escapeHtml(e.detail)}</pre></div>`;
|
||||
} else if (typeof e.detail === 'object' && e.detail !== null) {
|
||||
detailHtml = `<div class="row-detail"><pre>${escapeHtml(formatJson(e.detail))}</pre></div>`;
|
||||
} else {
|
||||
detailHtml = `<div class="row-detail"><pre>${escapeHtml(formatDetailForText(e.detail))}</pre></div>`;
|
||||
}
|
||||
}
|
||||
return `
|
||||
<div class="log-row ${levelCls}">
|
||||
<span class="log-time">${e.time}</span>
|
||||
<span class="log-level">${e.level.toUpperCase()}</span>
|
||||
<span class="log-module">[${e.module}]</span>
|
||||
<span class="log-message">${escapeHtml(e.message)}</span>
|
||||
${detailHtml}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>QZ Music 运行日志 - 导出 ${new Date().toISOString()}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; background: #fff; color: #222; padding: 24px; }
|
||||
h1 { font-size: 20px; margin-bottom: 4px; }
|
||||
.sub { color: #666; font-size: 12px; margin-bottom: 20px; }
|
||||
.log-row { display: flex; gap: 10px; padding: 10px 12px; border-bottom: 1px solid #eee; flex-wrap: wrap; align-items: start; }
|
||||
.log-row.error { background: #fff5f5; }
|
||||
.log-row.warn { background: #fffbeb; }
|
||||
.log-level { font-weight: 700; min-width: 52px; }
|
||||
.log-row.info .log-level { color: #3b82f6; }
|
||||
.log-row.warn .log-level { color: #f59e0b; }
|
||||
.log-row.error .log-level { color: #ef4444; }
|
||||
.log-row.debug .log-level { color: #8b5cf6; }
|
||||
.log-module { color: #666; }
|
||||
.log-time { color: #888; font-family: ui-monospace, monospace; font-size: 12px; }
|
||||
.log-message { flex: 1; min-width: 200px; }
|
||||
.row-detail { margin-top: 8px; width: 100%; padding: 8px 12px; background: #f5f5f5; border-left: 3px solid #3b82f6; border-radius: 0 4px 4px 0; }
|
||||
.detail-image { max-width: 100%; max-height: 600px; border-radius: 4px; display: block; margin: 0 auto; border: 1px solid #ddd; }
|
||||
.detail-src { text-align: center; color: #888; font-size: 11px; margin-top: 4px; word-break: break-all; }
|
||||
.row-detail pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, monospace; font-size: 12px; color: #333; }
|
||||
.row-detail a { color: #2563eb; text-decoration: none; }
|
||||
@media print {
|
||||
body { padding: 12px; }
|
||||
.log-row { break-inside: avoid; page-break-inside: avoid; }
|
||||
.row-detail { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>QZ Music 运行日志</h1>
|
||||
<div class="sub">共 ${logStore.logs.length} 条 · 导出时间 ${new Date().toLocaleString()}</div>
|
||||
${rowsHtml}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||
downloadBlob(blob, `qzmusic-logs-${tsFileName()}.html`);
|
||||
};
|
||||
|
||||
// HTML 转义
|
||||
const escapeHtml = (s: string) => String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const onClear = () => {
|
||||
if (confirm('确定清空所有日志?')) {
|
||||
logStore.clear();
|
||||
forcedExpanded.value.clear();
|
||||
forcedCollapsed.value.clear();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 22px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.log-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat.info { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
|
||||
.stat.warn { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
|
||||
.stat.error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
.stat.debug { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.filter-select,
|
||||
.filter-input {
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.filter-select:focus,
|
||||
.filter-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn.danger:hover {
|
||||
color: #ef4444;
|
||||
border-color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.log-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 8px 0;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.log-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.log-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.log-body::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-text-muted);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.log-row:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.log-module {
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: var(--color-text-primary);
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
transition: transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.log-row.info .log-level { color: #3b82f6; }
|
||||
.log-row.warn .log-level { color: #f59e0b; }
|
||||
.log-row.error .log-level { color: #ef4444; }
|
||||
.log-row.error { background: rgba(239, 68, 68, 0.04); }
|
||||
.log-row.error:hover { background: rgba(239, 68, 68, 0.08); }
|
||||
.log-row.debug .log-level { color: #8b5cf6; }
|
||||
|
||||
.row-detail {
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--color-bg-primary);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
border-radius: 0 6px 6px 0;
|
||||
cursor: default;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
max-width: 100%;
|
||||
max-height: 320px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.detail-src {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
color: var(--color-accent);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.json-block,
|
||||
.text-block {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.mini-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 打印时隐藏工具栏和按钮,图片完整展开 */
|
||||
@media print {
|
||||
.action-group, .filter-group, .toolbar, .detail-actions, .mini-btn { display: none !important; }
|
||||
.log-view { padding: 0; }
|
||||
.log-header { border: none; background: transparent; padding: 0 0 12px 0; }
|
||||
.log-body { background: transparent; border: none; padding: 0; }
|
||||
.log-row { break-inside: avoid; page-break-inside: avoid; background: transparent !important; border-bottom: 1px solid #ddd; }
|
||||
.row-detail { break-inside: avoid; background: #f5f5f5 !important; }
|
||||
.detail-image { max-width: 100%; max-height: none; }
|
||||
}
|
||||
</style>
|
||||
456
qzmusic-web/src/views/Playlist.vue
Normal file
456
qzmusic-web/src/views/Playlist.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<div class="view-container playlist-view">
|
||||
<div class="content-wrapper">
|
||||
<div class="playlist-header">
|
||||
<div class="header-bg" :class="themeClass">
|
||||
<div class="flow-circle c1"></div>
|
||||
<div class="flow-circle c2"></div>
|
||||
</div>
|
||||
|
||||
<div class="header-content">
|
||||
<div class="cover-box" :style="{ background: coverGradient }">
|
||||
<div class="icon-wrapper">
|
||||
<Icon :icon="iconName" class="big-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<div class="sub-title">PLAYLIST</div>
|
||||
<h1 class="title">{{ title }}</h1>
|
||||
<div class="meta-info">
|
||||
<div class="avatar-row">
|
||||
<div class="user-avatar">
|
||||
<Icon icon="lucide:user" />
|
||||
</div>
|
||||
<span class="user-name">User</span>
|
||||
</div>
|
||||
<span class="divider">•</span>
|
||||
<span class="count">{{ songCount }} 首歌曲</span>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button class="play-all-btn" :class="themeClass" @click="handlePlayAll">
|
||||
<Icon icon="lucide:play" class="btn-icon" />
|
||||
播放全部
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<Icon icon="lucide:download" />
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<Icon icon="lucide:share-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="song-list-container">
|
||||
<div class="list-header">
|
||||
<div class="col-index">#</div>
|
||||
<div class="col-title">标题</div>
|
||||
<div class="col-album">专辑</div>
|
||||
<div class="col-time">时长</div>
|
||||
</div>
|
||||
|
||||
<div class="song-list">
|
||||
<div class="song-item" v-for="(song, i) in songs" :key="song.id" @dblclick="handlePlaySong(i)">
|
||||
<div class="song-index">{{ i + 1 }}</div>
|
||||
<div class="song-cover">
|
||||
<div class="cover-gradient" :class="themeClass"></div>
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<h4 class="song-title">{{ song.name }}</h4>
|
||||
<p class="song-artist">{{ song.artist }}</p>
|
||||
</div>
|
||||
<div class="song-album">{{ song.albumName }}</div>
|
||||
<div class="song-duration">{{ song.duration }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import type { Song } from '../types/song';
|
||||
|
||||
const route = useRoute();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
const isLiked = computed(() => route.path.includes('liked'));
|
||||
const isRecent = computed(() => route.path.includes('recent'));
|
||||
|
||||
const title = computed(() => {
|
||||
if (isLiked.value) return '我喜欢的音乐';
|
||||
if (isRecent.value) return '最近播放';
|
||||
return '歌单';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (isLiked.value) return 'lucide:heart';
|
||||
if (isRecent.value) return 'lucide:clock';
|
||||
return 'lucide:music';
|
||||
});
|
||||
|
||||
const themeClass = computed(() => {
|
||||
if (isLiked.value) return 'theme-liked';
|
||||
if (isRecent.value) return 'theme-recent';
|
||||
return 'theme-default';
|
||||
});
|
||||
|
||||
const coverGradient = computed(() => {
|
||||
if (isLiked.value) return 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%)';
|
||||
if (isRecent.value) return 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)';
|
||||
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
||||
});
|
||||
|
||||
const songs = ref<Song[]>(Array.from({ length: 15 }, (_, i) => ({
|
||||
id: String(i + 1),
|
||||
name: `Song Title ${i + 1}`,
|
||||
artist: `Artist Name ${i + 1}`,
|
||||
albumName: `Album Mock`,
|
||||
duration: '03:30',
|
||||
url: 'http://commondatastorage.googleapis.com/codeskulptor-demos/riceracer_assets/music/win.ogg',
|
||||
picUrl: '',
|
||||
source: 'local',
|
||||
type: 'Local'
|
||||
})));
|
||||
|
||||
const songCount = computed(() => songs.value.length);
|
||||
|
||||
const handlePlayAll = () => {
|
||||
playerStore.setPlaylist(songs.value);
|
||||
};
|
||||
|
||||
const handlePlaySong = (index: number) => {
|
||||
playerStore.playFromList(songs.value[index], songs.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.playlist-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 30px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.playlist-header {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
border-radius: var(--radius-2xl);
|
||||
overflow: hidden;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 40px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.header-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: 400% 400%;
|
||||
animation: gradientBG 15s ease infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-bg {
|
||||
background-image: linear-gradient(-45deg, #1e1e1e, #2a2a2a, #3a1c1c, #1a1a1a);
|
||||
}
|
||||
|
||||
.header-bg.theme-liked {
|
||||
background-image: linear-gradient(-45deg, #2a1a1a, #4a2c2c, #3a1c1c, #1a1a1a);
|
||||
}
|
||||
|
||||
.header-bg.theme-recent {
|
||||
background-image: linear-gradient(-45deg, #1a1a2a, #2c2c4a, #1c1c3a, #1a1a1a);
|
||||
}
|
||||
|
||||
@keyframes gradientBG {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.flow-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
opacity: 0.4;
|
||||
animation: float 10s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: #ec4141;
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: #4facfe;
|
||||
bottom: -100px;
|
||||
right: -50px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.theme-recent .c1 { background: #a18cd1; }
|
||||
.theme-recent .c2 { background: #fbc2eb; }
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(20px) scale(1.1); }
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cover-box {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
background: rgba(255,255,255,0.2);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.big-icon {
|
||||
color: #fff;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
flex: 1;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 42px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #555;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.play-all-btn {
|
||||
background: #ec4141;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.play-all-btn.theme-recent {
|
||||
background: #a18cd1;
|
||||
}
|
||||
|
||||
.play-all-btn:hover {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
padding: 0 16px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.col-index { width: 40px; text-align: center; }
|
||||
.col-title { flex: 1; }
|
||||
.col-album { width: 200px; }
|
||||
.col-time { width: 60px; text-align: right; }
|
||||
|
||||
.song-item {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.song-item:hover .song-index {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.song-index {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
margin-right: 16px;
|
||||
background: #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-gradient {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
}
|
||||
|
||||
.cover-gradient.theme-liked {
|
||||
background: linear-gradient(45deg, #ff9a9e, #fecfef);
|
||||
}
|
||||
|
||||
.cover-gradient.theme-recent {
|
||||
background: linear-gradient(45deg, #a18cd1, #fbc2eb);
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.song-album {
|
||||
width: 200px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
783
qzmusic-web/src/views/Search.vue
Normal file
783
qzmusic-web/src/views/Search.vue
Normal file
@@ -0,0 +1,783 @@
|
||||
<template>
|
||||
<div class="view-container search-view">
|
||||
<div class="content-wrapper">
|
||||
<div class="search-header">
|
||||
<div class="header-left">
|
||||
<h1 class="search-title">搜索: "{{ query }}"</h1>
|
||||
<span class="result-count">找到 {{ total }} 个结果</span>
|
||||
</div>
|
||||
<button class="icon-btn" @click="showSettings = !showSettings" :class="{ active: showSettings }" title="搜索设置">
|
||||
<Icon icon="lucide:settings-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<transition name="slide-fade">
|
||||
<div class="settings-panel" v-if="showSettings">
|
||||
<div class="limit-setting">
|
||||
<span class="setting-label">每页显示: {{ limit }} 首</span>
|
||||
<div class="slider-container">
|
||||
<input type="range" min="10" max="100" step="10" v-model.number="limit" class="setting-slider">
|
||||
<div class="slider-track" :style="{ width: ((limit - 10) / 90) * 100 + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plugin-select-container">
|
||||
<button class="select-trigger" @click.stop="toggleDropdown" :title="'当前源: ' + activePluginName">
|
||||
<span>{{ activePluginName }}</span>
|
||||
<Icon icon="lucide:chevron-down" class="dropdown-icon" :class="{ open: isDropdownOpen }" />
|
||||
</button>
|
||||
|
||||
<transition name="fade">
|
||||
<div class="select-options" v-if="isDropdownOpen">
|
||||
<div
|
||||
v-for="plugin in plugins"
|
||||
:key="plugin.id"
|
||||
class="option"
|
||||
:class="{ active: plugin.id === activePlugin }"
|
||||
@click="selectPlugin(plugin)"
|
||||
>
|
||||
{{ plugin.name }}
|
||||
<Icon icon="lucide:check" v-if="plugin.id === activePlugin" class="check-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="error-state" v-if="!loading && error">
|
||||
<div class="icon-box">
|
||||
<Icon icon="lucide:alert-circle" class="state-icon" />
|
||||
</div>
|
||||
<p class="state-text">搜索出错了,请稍后重试</p>
|
||||
<button class="retry-btn" @click="fetchData">
|
||||
<Icon icon="lucide:refresh-cw" class="btn-icon" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="song-list-container" v-else-if="!loading && songs.length > 0">
|
||||
<div class="list-header">
|
||||
<div class="col-index">#</div>
|
||||
<div class="col-title">标题</div>
|
||||
<div class="col-album">专辑</div>
|
||||
<div class="col-time">时长</div>
|
||||
</div>
|
||||
|
||||
<div class="song-list">
|
||||
<div
|
||||
class="song-item"
|
||||
v-for="(song, i) in songs"
|
||||
:key="song.id"
|
||||
@click="handlePlaySong(i)"
|
||||
>
|
||||
<div class="song-index">{{ (currentPage - 1) * limit + i + 1 }}</div>
|
||||
<div class="song-cover">
|
||||
<img v-if="song.picUrl" :src="song.picUrl" loading="lazy" />
|
||||
<div v-else class="cover-placeholder"></div>
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<h4 class="song-title" v-html="highlight(song.name)"></h4>
|
||||
<p class="song-artist" v-html="highlight(song.artist)"></p>
|
||||
</div>
|
||||
<div class="song-album" v-html="highlight(song.albumName || '-')"></div>
|
||||
<div class="song-duration">{{ song.duration }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<button class="pagination-btn" :disabled="currentPage <= 1" @click="changePage(currentPage - 1)">
|
||||
<Icon icon="lucide:chevron-left" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
class="pagination-btn"
|
||||
:class="{ active: page === currentPage }"
|
||||
@click="changePage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
|
||||
<button class="pagination-btn" :disabled="currentPage >= totalPages" @click="changePage(currentPage + 1)">
|
||||
<Icon icon="lucide:chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading-state" v-if="loading">
|
||||
<Icon icon="lucide:loader-2" class="spin" />
|
||||
<span>正在搜索...</span>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" v-if="!loading && !error && songs.length === 0">
|
||||
<span>未找到相关歌曲</span>
|
||||
<button class="retry-btn" @click="fetchData">
|
||||
<Icon icon="lucide:refresh-cw" class="btn-icon" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { pluginManager } from '../plugins/index';
|
||||
import { transformSearchResults } from '../utils/songUtils';
|
||||
import type { PluginFullInfo, Song } from '../types';
|
||||
|
||||
const route = useRoute();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
const query = computed(() => route.query.q as string || '');
|
||||
const currentPage = ref(1);
|
||||
const showSettings = ref(false);
|
||||
const savedLimit = localStorage.getItem('qz-search-limit');
|
||||
const limit = ref(savedLimit ? Number(savedLimit) : 30);
|
||||
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const error = ref(false);
|
||||
const songs = ref<Song[]>([]);
|
||||
|
||||
const plugins = ref<PluginFullInfo[]>([]);
|
||||
const activePlugin = ref<string>('');
|
||||
const isDropdownOpen = ref(false);
|
||||
|
||||
const activePluginName = computed(() => {
|
||||
const p = plugins.value.find(p => p.id === activePlugin.value);
|
||||
return p ? p.name : '选择源';
|
||||
});
|
||||
|
||||
const toggleDropdown = () => {
|
||||
isDropdownOpen.value = !isDropdownOpen.value;
|
||||
};
|
||||
|
||||
const selectPlugin = (plugin: PluginFullInfo) => {
|
||||
if (activePlugin.value !== plugin.id) {
|
||||
activePlugin.value = plugin.id;
|
||||
pluginManager.setActivePlugin(plugin.id);
|
||||
isDropdownOpen.value = false;
|
||||
} else {
|
||||
isDropdownOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.plugin-select-container')) {
|
||||
isDropdownOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = computed(() => {
|
||||
const t = Number(total.value) || 0;
|
||||
const l = Number(limit.value) || 30;
|
||||
return Math.ceil(t / l) || 1;
|
||||
});
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const current = currentPage.value;
|
||||
const total = totalPages.value;
|
||||
const delta = 2;
|
||||
|
||||
let start = Math.max(1, current - delta);
|
||||
let end = Math.min(total, current + delta);
|
||||
|
||||
if (current - delta < 1) {
|
||||
end = Math.min(total, end + (1 - (current - delta)));
|
||||
}
|
||||
if (current + delta > total) {
|
||||
start = Math.max(1, start - ((current + delta) - total));
|
||||
}
|
||||
|
||||
const pages: number[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
|
||||
const loadPlugins = () => {
|
||||
plugins.value = pluginManager.getAll();
|
||||
const saved = sessionStorage.getItem('qz-active-plugin');
|
||||
if (saved && plugins.value.find(p => p.id === saved)) {
|
||||
activePlugin.value = saved;
|
||||
} else if (plugins.value.length > 0) {
|
||||
activePlugin.value = plugins.value[0].id;
|
||||
pluginManager.setActivePlugin(activePlugin.value);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!query.value || !activePlugin.value) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
songs.value = [];
|
||||
|
||||
try {
|
||||
const result = await pluginManager.search(query.value, currentPage.value, limit.value);
|
||||
|
||||
if (result && result.list) {
|
||||
songs.value = transformSearchResults(result.list);
|
||||
total.value = result.songCount || result.total || 0;
|
||||
} else {
|
||||
total.value = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Search failed", e);
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const changePage = (page: number) => {
|
||||
currentPage.value = page;
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const handlePlaySong = (index: number) => {
|
||||
playerStore.playFromList(songs.value[index], songs.value);
|
||||
};
|
||||
|
||||
const getHighlightRegex = (q: string) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) return null;
|
||||
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (trimmed.length <= 2) {
|
||||
return new RegExp(escapeRegExp(trimmed), 'gi');
|
||||
}
|
||||
const substrings = new Set<string>();
|
||||
substrings.add(trimmed);
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
for (let j = i + 2; j <= trimmed.length; j++) {
|
||||
substrings.add(trimmed.slice(i, j));
|
||||
}
|
||||
}
|
||||
const sorted = Array.from(substrings).sort((a, b) => b.length - a.length);
|
||||
const pattern = sorted.map(s => escapeRegExp(s)).join('|');
|
||||
return new RegExp(pattern, 'gi');
|
||||
};
|
||||
|
||||
const highlight = (text: string) => {
|
||||
if (!query.value || !text) return text;
|
||||
const regex = getHighlightRegex(query.value);
|
||||
if (!regex) return text;
|
||||
return text.replace(regex, match => `<span class="highlight">${match}</span>`);
|
||||
};
|
||||
|
||||
watch(query, () => {
|
||||
currentPage.value = 1;
|
||||
if (activePlugin.value) fetchData();
|
||||
}, { immediate: false });
|
||||
|
||||
watch(limit, (newLimit) => {
|
||||
localStorage.setItem('qz-search-limit', newLimit.toString());
|
||||
currentPage.value = 1;
|
||||
fetchData();
|
||||
});
|
||||
|
||||
watch(activePlugin, (newVal, oldVal) => {
|
||||
if (newVal && newVal !== oldVal && query.value) {
|
||||
currentPage.value = 1;
|
||||
fetchData();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeDropdown);
|
||||
loadPlugins();
|
||||
if (query.value && activePlugin.value) {
|
||||
fetchData();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeDropdown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 20px 30px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.search-title::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 70%;
|
||||
background-color: var(--color-accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.icon-btn.active {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.limit-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plugin-select-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-bg-primary);
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
min-width: 120px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.select-trigger:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 16px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-icon.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.select-options {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 160px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.option.active {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-slider {
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
max-height: 100px;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-bottom: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.list-header, .song-item {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 4fr 3fr 60px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.col-index, .col-title, .col-album, .col-time {
|
||||
width: auto;
|
||||
}
|
||||
.col-index { text-align: center; }
|
||||
.col-time { text-align: right; }
|
||||
|
||||
.song-item {
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.song-index {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.list-header, .song-item {
|
||||
grid-template-columns: 50px 40px 4fr 3fr 60px;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
grid-template-columns: 50px 40px 4fr 3fr 60px;
|
||||
}
|
||||
|
||||
.list-header .col-title {
|
||||
grid-column: 3;
|
||||
}
|
||||
.list-header .col-album {
|
||||
grid-column: 4;
|
||||
}
|
||||
.list-header .col-time {
|
||||
grid-column: 5;
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.song-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.song-album {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.highlight) {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
background: transparent;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border-color: var(--color-accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.loading-state, .empty-state, .error-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--color-text-muted);
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
font-size: 32px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.error-state .icon-box {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
font-size: 32px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 8px;
|
||||
padding: 8px 24px;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.retry-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.retry-btn .btn-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user