mirror of
https://github.com/lqtmcstudio/QZMusic_PC.git
synced 2026-08-18 15:33:27 +08:00
feat: 实现收藏歌单、修复TopBar拖拽穿透问题、适配Linux amd64
This commit is contained in:
@@ -395,16 +395,57 @@ export async function addRecentSong(userId: string, song: any): Promise<any> {
|
||||
})
|
||||
}
|
||||
|
||||
// === Playlist Likes ===
|
||||
// === Favorite Playlists ===
|
||||
|
||||
export async function togglePlaylistLike(playlistId: string): Promise<{ status: string; liked: boolean; like_count: number }> {
|
||||
return qzFetch(`/playlist/${encodeURIComponent(playlistId)}/like`, {
|
||||
export interface FavoritePlaylistRef {
|
||||
id: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface FavoritePlaylistStatus {
|
||||
status: string
|
||||
collected: boolean
|
||||
collection_count?: number | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
function normalizeFavoritePlaylistSource(source: string): string {
|
||||
const normalized = String(source || '').trim()
|
||||
return normalized === 'cloud' ? '' : normalized
|
||||
}
|
||||
|
||||
export async function getFavoritePlaylists(userId?: string): Promise<FavoritePlaylistRef[]> {
|
||||
const state = loadAuthState()
|
||||
const targetUserId = userId || state.userInfo?.id
|
||||
if (!targetUserId) throw new Error('Not logged in')
|
||||
const result = await qzFetch(`/user/${encodeURIComponent(targetUserId)}/fav/playlists`)
|
||||
return Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
export async function collectPlaylist(playlistId: string, source = ''): Promise<FavoritePlaylistStatus> {
|
||||
const state = loadAuthState()
|
||||
const userId = state.userInfo?.id
|
||||
if (!userId) throw new Error('Not logged in')
|
||||
return qzFetch(`/user/${encodeURIComponent(userId)}/fav/playlists`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
id: String(playlistId || '').trim(),
|
||||
source: normalizeFavoritePlaylistSource(source),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPlaylistLike(playlistId: string): Promise<{ status: string; liked: boolean; like_count: number }> {
|
||||
return qzFetch(`/playlist/${encodeURIComponent(playlistId)}/like`)
|
||||
export async function uncollectPlaylist(playlistId: string, source = ''): Promise<FavoritePlaylistStatus> {
|
||||
const state = loadAuthState()
|
||||
const userId = state.userInfo?.id
|
||||
if (!userId) throw new Error('Not logged in')
|
||||
const query = new URLSearchParams({
|
||||
id: String(playlistId || '').trim(),
|
||||
source: normalizeFavoritePlaylistSource(source),
|
||||
})
|
||||
return qzFetch(`/user/${encodeURIComponent(userId)}/fav/playlists?${query.toString()}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// === User Follow ===
|
||||
|
||||
@@ -31,8 +31,9 @@ import {
|
||||
uploadImage,
|
||||
getRecentSongs,
|
||||
addRecentSong,
|
||||
togglePlaylistLike,
|
||||
getPlaylistLike,
|
||||
getFavoritePlaylists,
|
||||
collectPlaylist,
|
||||
uncollectPlaylist,
|
||||
toggleUserFollow,
|
||||
getUserSubscriptions,
|
||||
type AuthCallbackPayload,
|
||||
@@ -375,12 +376,13 @@ ipcMain.handle('user:addRecentSong', (_event, userId: string, song: any) => {
|
||||
return addRecentSong(String(userId || ''), song || {})
|
||||
})
|
||||
|
||||
// Playlist Likes
|
||||
ipcMain.handle('playlist:toggleLike', (_event, playlistId: string) => {
|
||||
return togglePlaylistLike(String(playlistId || ''))
|
||||
// Favorite Playlists
|
||||
ipcMain.handle('playlist:getFavorites', () => getFavoritePlaylists())
|
||||
ipcMain.handle('playlist:collect', (_event, playlistId: string, source = '') => {
|
||||
return collectPlaylist(String(playlistId || ''), String(source || ''))
|
||||
})
|
||||
ipcMain.handle('playlist:getLike', (_event, playlistId: string) => {
|
||||
return getPlaylistLike(String(playlistId || ''))
|
||||
ipcMain.handle('playlist:uncollect', (_event, playlistId: string, source = '') => {
|
||||
return uncollectPlaylist(String(playlistId || ''), String(source || ''))
|
||||
})
|
||||
|
||||
// User Follow
|
||||
@@ -421,8 +423,8 @@ ipcMain.handle('playlist:publicList', (_event, search = '', sort = 'visit', page
|
||||
return listPublicPlaylists(String(search || ''), String(sort || 'visit'), Number(page) || 1, Number(limit) || 50)
|
||||
})
|
||||
|
||||
ipcMain.handle('playlist:get', (_event, scope: PlaylistScope, id: string) => {
|
||||
return getPlaylist(scope, id)
|
||||
ipcMain.handle('playlist:get', (_event, scope: PlaylistScope, id: string, recordVisit = true) => {
|
||||
return getPlaylist(scope, id, Boolean(recordVisit))
|
||||
})
|
||||
|
||||
ipcMain.handle('playlist:create', (_event, scope: PlaylistScope, data: { name: string; desc?: string; is_public?: boolean }) => {
|
||||
|
||||
@@ -54,12 +54,18 @@ function getLibraryPath(): string {
|
||||
}
|
||||
|
||||
function getReaderExe(): string {
|
||||
const binaryName = process.platform === 'win32' ? 'taglib_reader_cli.exe' : 'taglib_reader_cli';
|
||||
const candidates = [
|
||||
path.join(process.env.APP_ROOT || '', 'native', 'taglib_reader', 'build', 'taglib_reader_cli.exe'),
|
||||
path.join(process.resourcesPath || '', 'native', 'taglib_reader_cli.exe'),
|
||||
path.join(process.env.APP_ROOT || '', 'native', 'taglib_reader', 'build', binaryName),
|
||||
path.join(process.resourcesPath || '', 'native', binaryName),
|
||||
]
|
||||
const target = candidates.find((candidate) => candidate && fs.existsSync(candidate))
|
||||
if (!target) throw new Error('TagLib reader executable not found')
|
||||
if (!target) throw new Error(`TagLib reader executable not found (${binaryName})`)
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(target, 0o755)
|
||||
} catch {}
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
@@ -68,14 +74,22 @@ function getArtworkDir(): string {
|
||||
}
|
||||
|
||||
function getDefaultRoots(): string[] {
|
||||
const username = path.basename(app.getPath('home'))
|
||||
const roots: string[] = []
|
||||
for (let code = 67; code <= 90; code++) {
|
||||
const drive = `${String.fromCharCode(code)}:\\`
|
||||
if (!fs.existsSync(drive)) continue
|
||||
const userRoot = path.join(drive, 'Users', username)
|
||||
if (process.platform === 'win32') {
|
||||
const username = path.basename(app.getPath('home'))
|
||||
for (let code = 67; code <= 90; code++) {
|
||||
const drive = `${String.fromCharCode(code)}:\\`
|
||||
if (!fs.existsSync(drive)) continue
|
||||
const userRoot = path.join(drive, 'Users', username)
|
||||
for (const dirName of ['Music', '音乐', 'Downloads', '下载']) {
|
||||
const candidate = path.join(userRoot, dirName)
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) roots.push(candidate)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const home = app.getPath('home')
|
||||
for (const dirName of ['Music', '音乐', 'Downloads', '下载']) {
|
||||
const candidate = path.join(userRoot, dirName)
|
||||
const candidate = path.join(home, dirName)
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) roots.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface PlaylistInfo {
|
||||
author?: string
|
||||
play_count?: string
|
||||
visit_count?: number
|
||||
like_count?: number
|
||||
collection_count?: number
|
||||
is_public?: boolean
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ function normalizeCloudPlaylist(raw: any): AppPlaylist {
|
||||
author: info?.author || '',
|
||||
play_count: info?.play_count || '',
|
||||
visit_count: Number(info?.visit_count ?? info?.play_count ?? 0) || 0,
|
||||
like_count: Number(info?.like_count ?? 0) || 0,
|
||||
collection_count: Number(info?.collection_count ?? info?.like_count ?? 0) || 0,
|
||||
is_public: Boolean(info?.is_public ?? info?.public ?? false),
|
||||
},
|
||||
list,
|
||||
@@ -269,7 +269,7 @@ export async function listPublicPlaylists(
|
||||
const query = new URLSearchParams({
|
||||
page: String(Math.max(1, Number(page) || 1)),
|
||||
limit: String(Math.max(1, Math.min(50, Number(limit) || 50))),
|
||||
sort: ['visit', 'name', 'total', 'like'].includes(sort) ? sort : 'visit',
|
||||
sort: ['visit', 'name', 'total', 'collection'].includes(sort) ? sort : 'visit',
|
||||
})
|
||||
if (search.trim()) query.set('search', search.trim())
|
||||
const raw = await qzFetch(`/playlist/public?${query.toString()}`)
|
||||
@@ -283,9 +283,9 @@ export async function listPublicPlaylists(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlaylist(scope: PlaylistScope, id: string): Promise<AppPlaylist> {
|
||||
export async function getPlaylist(scope: PlaylistScope, id: string, recordVisit = true): Promise<AppPlaylist> {
|
||||
if (scope === 'local') return readLocalPlaylist(id)
|
||||
const raw = await qzFetch(`/playlist/${encodeURIComponent(id)}`)
|
||||
const raw = await qzFetch(`/playlist/${encodeURIComponent(id)}?record_visit=${recordVisit}`)
|
||||
return normalizeCloudPlaylist(raw)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { Socket } from 'node:net';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app } from 'electron';
|
||||
|
||||
@@ -35,15 +36,25 @@ export class QzpController extends EventEmitter {
|
||||
if (process.platform === 'win32') {
|
||||
return '\\\\.\\pipe\\qzplayer';
|
||||
}
|
||||
return '/tmp/qzmusic_mpv_socket';
|
||||
return '/tmp/qzplayer.sock';
|
||||
}
|
||||
|
||||
private getCorePath(): string {
|
||||
const appRoot = process.env.APP_ROOT || process.cwd();
|
||||
const binaryName = process.platform === 'win32' ? 'qzplayer.exe' : 'qzplayer';
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'core', 'qzplayer.exe');
|
||||
return path.join(process.resourcesPath, 'core', binaryName);
|
||||
}
|
||||
return path.join(appRoot, 'core', binaryName);
|
||||
}
|
||||
|
||||
private isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return path.join(appRoot, 'core', 'qzplayer.exe');
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -56,9 +67,36 @@ export class QzpController extends EventEmitter {
|
||||
console.log('Starting QZPlayer from:', playerPath);
|
||||
|
||||
try {
|
||||
const child = spawn(playerPath, [], {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
let spawnPath = playerPath;
|
||||
if (process.platform !== 'win32') {
|
||||
// 让动态链接器在二进制同级目录查找自带共享库 (如 libfftw3f.so.3)
|
||||
const coreDir = path.dirname(playerPath);
|
||||
env.LD_LIBRARY_PATH = coreDir + (process.env.LD_LIBRARY_PATH ? path.delimiter + process.env.LD_LIBRARY_PATH : '');
|
||||
|
||||
// git / Windows 文件系统可能丢失可执行位, 打包到 Linux 后需要补上
|
||||
if (!this.isExecutable(spawnPath)) {
|
||||
try { fs.chmodSync(spawnPath, 0o755); } catch { /* ignore */ }
|
||||
}
|
||||
if (!this.isExecutable(spawnPath)) {
|
||||
// AppImage 只读挂载 / deb 安装到 root 目录时无 chmod 权限, 复制到用户数据目录再执行
|
||||
const altPath = path.join(app.getPath('userData'), 'bin', path.basename(spawnPath));
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(altPath), { recursive: true });
|
||||
fs.copyFileSync(spawnPath, altPath);
|
||||
fs.chmodSync(altPath, 0o755);
|
||||
spawnPath = altPath;
|
||||
console.log('QZPlayer copied to writable path:', altPath);
|
||||
} catch (err) {
|
||||
console.warn('Failed to prepare writable QZPlayer:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(spawnPath, [], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env,
|
||||
});
|
||||
this.process = child;
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
playlist: {
|
||||
list: () => ipcRenderer.invoke('playlist:list'),
|
||||
publicList: (search = '', sort = 'visit', page = 1, limit = 50) => ipcRenderer.invoke('playlist:publicList', search, sort, page, limit),
|
||||
get: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:get', scope, id),
|
||||
get: (scope: 'local' | 'cloud', id: string, recordVisit = true) => ipcRenderer.invoke('playlist:get', scope, id, recordVisit),
|
||||
create: (scope: 'local' | 'cloud', data: { name: string; desc?: string; is_public?: boolean }) => ipcRenderer.invoke('playlist:create', scope, data),
|
||||
update: (scope: 'local' | 'cloud', id: string, info: any) => ipcRenderer.invoke('playlist:update', scope, id, info),
|
||||
delete: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:delete', scope, id),
|
||||
@@ -98,8 +98,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
import: () => ipcRenderer.invoke('playlist:import'),
|
||||
convertScope: (scope: 'local' | 'cloud', id: string, targetScope: 'local' | 'cloud') => ipcRenderer.invoke('playlist:convertScope', scope, id, targetScope),
|
||||
copyToLocal: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:copyToLocal', scope, id),
|
||||
toggleLike: (playlistId: string) => ipcRenderer.invoke('playlist:toggleLike', playlistId),
|
||||
getLike: (playlistId: string) => ipcRenderer.invoke('playlist:getLike', playlistId),
|
||||
getFavorites: () => ipcRenderer.invoke('playlist:getFavorites'),
|
||||
collect: (playlistId: string, source = '') => ipcRenderer.invoke('playlist:collect', playlistId, source),
|
||||
uncollect: (playlistId: string, source = '') => ipcRenderer.invoke('playlist:uncollect', playlistId, source),
|
||||
},
|
||||
|
||||
image: {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<MainLayout />
|
||||
<FullScreenPlayer />
|
||||
<LoginDialog v-model:visible="showLoginDialog" />
|
||||
<Settings v-if="showSettings" @close="showSettings = false" />
|
||||
<Settings v-show="settingsVisible" v-if="showSettings" @close="showSettings = false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, provide, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ref, watch, provide, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import MainLayout from './layout/MainLayout.vue';
|
||||
import Settings from './components/Settings.vue';
|
||||
@@ -24,9 +24,27 @@ const playlistsStore = usePlaylistsStore();
|
||||
const playerStore = usePlayerStore();
|
||||
const together = useListenTogetherStore();
|
||||
|
||||
// 全屏播放时隐藏设置页,避免底层 -webkit-app-region 干扰播放页拖拽
|
||||
// 延迟隐藏以等待播放页入场动画完成 (transform 0.46s)
|
||||
const settingsVisible = ref(true);
|
||||
let settingsHideTimer: number | undefined;
|
||||
|
||||
watch(() => playerStore.isPlayerFullScreen, (fullscreen) => {
|
||||
if (fullscreen) {
|
||||
clearTimeout(settingsHideTimer);
|
||||
settingsHideTimer = window.setTimeout(() => {
|
||||
settingsVisible.value = false;
|
||||
}, 500);
|
||||
} else {
|
||||
clearTimeout(settingsHideTimer);
|
||||
settingsVisible.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Provide to child components
|
||||
provide('openSettings', () => { showSettings.value = true; });
|
||||
provide('openLoginDialog', () => { showLoginDialog.value = true; });
|
||||
provide('isSettingsOpen', showSettings);
|
||||
|
||||
const isTypingTarget = (target: EventTarget | null) => {
|
||||
const element = target as HTMLElement | null;
|
||||
@@ -119,6 +137,7 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleGlobalShortcut);
|
||||
window.removeEventListener('focus', checkClipboardInvite);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
clearTimeout(settingsHideTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
<Icon icon="lucide:heart" />
|
||||
<span>我喜欢的</span>
|
||||
</router-link>
|
||||
<router-link to="/favorite-playlists" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:bookmark" />
|
||||
<span>收藏歌单</span>
|
||||
</router-link>
|
||||
<router-link to="/recent" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:clock-3" />
|
||||
<span>最近播放</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<header class="topbar" :class="{ 'no-drag': isDragDisabled }">
|
||||
<div class="left-controls">
|
||||
<div class="nav-group">
|
||||
<button class="nav-btn" @click="goBack" title="返回">
|
||||
@@ -78,19 +78,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, inject } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayerStore } from '../stores/player'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const playerStore = usePlayerStore()
|
||||
const isMaximized = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const showUserMenu = ref(false)
|
||||
const ignoreNextUserClick = ref(false)
|
||||
let userPressTimer: number | undefined
|
||||
|
||||
// Disable drag region when overlays are active to prevent conflicts
|
||||
const settingsOpen = inject<import('vue').Ref<boolean>>('isSettingsOpen', ref(false))
|
||||
const isDragDisabled = computed(() => playerStore.isPlayerFullScreen || settingsOpen.value)
|
||||
|
||||
const goBack = () => router.back()
|
||||
const goForward = () => router.forward()
|
||||
|
||||
@@ -205,6 +211,10 @@ onUnmounted(() => {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.topbar.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.left-controls,
|
||||
.right-controls,
|
||||
.nav-group,
|
||||
|
||||
@@ -25,6 +25,11 @@ const router = createRouter({
|
||||
name: 'Liked',
|
||||
component: () => import('./views/Playlist.vue')
|
||||
},
|
||||
{
|
||||
path: '/favorite-playlists',
|
||||
name: 'FavoritePlaylists',
|
||||
component: () => import('./views/FavoritePlaylists.vue')
|
||||
},
|
||||
{
|
||||
path: '/recent',
|
||||
name: 'Recent',
|
||||
|
||||
@@ -276,6 +276,11 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
return;
|
||||
}
|
||||
loadingSongKey = songKey;
|
||||
// Reset retry count only when switching to a genuinely different song
|
||||
const prevSongKey = currentSong.value ? `${currentSong.value.source}:${currentSong.value.id}` : null;
|
||||
if (prevSongKey !== songKey) {
|
||||
currentSongRetryCount.value = 0;
|
||||
}
|
||||
try {
|
||||
console.log(song);
|
||||
currentSong.value = song;
|
||||
@@ -314,7 +319,6 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
syncDummyAudioState(false);
|
||||
}
|
||||
song.url = playUrl;
|
||||
currentSongRetryCount.value = 0;
|
||||
// Record to recent plays (fire-and-forget)
|
||||
recordRecentSong(song);
|
||||
playErrorCount.value = 0;
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface PlaylistInfo {
|
||||
author?: string
|
||||
play_count?: string
|
||||
visit_count?: number
|
||||
like_count?: number
|
||||
collection_count?: number
|
||||
is_public?: boolean
|
||||
}
|
||||
|
||||
|
||||
9
src/renderer/src/types/electron.d.ts
vendored
9
src/renderer/src/types/electron.d.ts
vendored
@@ -86,7 +86,7 @@ export interface IElectronAPI {
|
||||
playlist: {
|
||||
list: () => Promise<{ local: AppPlaylist[]; cloud: AppPlaylist[]; items: AppPlaylist[] }>;
|
||||
publicList: (search?: string, sort?: string, page?: number, limit?: number) => Promise<{ items: AppPlaylist[]; total: number; page: number; limit: number; sort: string }>;
|
||||
get: (scope: ManagedPlaylistScope, id: string) => Promise<AppPlaylist>;
|
||||
get: (scope: ManagedPlaylistScope, id: string, recordVisit?: boolean) => Promise<AppPlaylist>;
|
||||
create: (scope: ManagedPlaylistScope, data: { name: string; desc?: string; is_public?: boolean }) => Promise<AppPlaylist>;
|
||||
update: (scope: ManagedPlaylistScope, id: string, info: Partial<PlaylistInfo>) => Promise<AppPlaylist>;
|
||||
delete: (scope: ManagedPlaylistScope, id: string) => Promise<{ success: boolean }>;
|
||||
@@ -96,8 +96,9 @@ export interface IElectronAPI {
|
||||
import: () => Promise<{ success: boolean; canceled?: boolean; playlist?: AppPlaylist }>;
|
||||
convertScope: (scope: ManagedPlaylistScope, id: string, targetScope: ManagedPlaylistScope) => Promise<AppPlaylist>;
|
||||
copyToLocal: (scope: ManagedPlaylistScope, id: string) => Promise<AppPlaylist>;
|
||||
toggleLike: (playlistId: string) => Promise<{ status: string; liked: boolean; like_count: number }>;
|
||||
getLike: (playlistId: string) => Promise<{ status: string; liked: boolean; like_count: number }>;
|
||||
getFavorites: () => Promise<Array<{ id: string; source: string }>>;
|
||||
collect: (playlistId: string, source?: string) => Promise<{ status: string; collected: boolean; collection_count?: number | null; message?: string | null }>;
|
||||
uncollect: (playlistId: string, source?: string) => Promise<{ status: string; collected: boolean; collection_count?: number | null; message?: string | null }>;
|
||||
};
|
||||
image: {
|
||||
selectAndUpload: () => Promise<{ success: boolean; canceled?: boolean; url?: string; message?: string }>;
|
||||
@@ -205,7 +206,7 @@ export interface PlaylistInfo {
|
||||
author?: string;
|
||||
play_count?: string;
|
||||
visit_count?: number;
|
||||
like_count?: number;
|
||||
collection_count?: number;
|
||||
is_public?: boolean;
|
||||
}
|
||||
|
||||
|
||||
293
src/renderer/src/views/FavoritePlaylists.vue
Normal file
293
src/renderer/src/views/FavoritePlaylists.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<div class="favorite-playlists-view">
|
||||
<div class="content-wrapper">
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<div class="eyebrow">COLLECTIONS</div>
|
||||
<h1>收藏歌单</h1>
|
||||
<p>插件与云端歌单会同步到你的账号。</p>
|
||||
</div>
|
||||
<button class="refresh-btn" :disabled="loading || !authStore.isLoggedIn" @click="loadFavorites">
|
||||
<Icon :icon="loading ? 'lucide:loader-2' : 'lucide:refresh-cw'" :class="{ spin: loading }" />
|
||||
刷新
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div v-if="!authStore.isLoggedIn" class="empty-state">
|
||||
<Icon icon="lucide:log-in" />
|
||||
<span>登录后可同步收藏歌单</span>
|
||||
</div>
|
||||
<div v-else-if="loading" class="playlist-grid">
|
||||
<div v-for="index in 8" :key="index" class="playlist-card skeleton"></div>
|
||||
</div>
|
||||
<div v-else-if="favorites.length === 0" class="empty-state">
|
||||
<Icon icon="lucide:bookmark" />
|
||||
<span>还没有收藏歌单</span>
|
||||
</div>
|
||||
<div v-else class="playlist-grid">
|
||||
<article v-for="item in favorites" :key="`${item.source}:${item.id}`" class="playlist-card">
|
||||
<button class="card-main" @click="openPlaylist(item)">
|
||||
<div class="cover">
|
||||
<img v-if="item.playlist?.info.img" :src="item.playlist.info.img" alt="" />
|
||||
<Icon v-else :icon="item.source ? 'lucide:radio' : 'lucide:cloud'" />
|
||||
</div>
|
||||
<div class="card-copy">
|
||||
<h2>{{ item.playlist?.info.name || '不可用的收藏歌单' }}</h2>
|
||||
<p>{{ item.playlist?.info.desc || (item.source ? `插件 ${item.source} 暂不可用` : '云端歌单暂不可用') }}</p>
|
||||
<span>{{ item.source ? `插件 · ${item.source}` : '云端歌单' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="remove-btn" title="取消收藏" @click="removeFavorite(item)">
|
||||
<Icon icon="lucide:bookmark-minus" />
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { AppPlaylist } from '../stores/playlists'
|
||||
|
||||
interface FavoritePlaylistItem {
|
||||
id: string
|
||||
source: string
|
||||
playlist: AppPlaylist | null
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const favorites = ref<FavoritePlaylistItem[]>([])
|
||||
|
||||
const resolveFavorite = async (ref: { id: string; source: string }): Promise<FavoritePlaylistItem> => {
|
||||
try {
|
||||
const playlist = ref.source
|
||||
? await window.electronAPI.plugin.getPlaylist(ref.source, ref.id, 1, 1)
|
||||
: await window.electronAPI.playlist.get('cloud', ref.id, false)
|
||||
return { ...ref, playlist }
|
||||
} catch {
|
||||
return { ...ref, playlist: null }
|
||||
}
|
||||
}
|
||||
|
||||
const loadFavorites = async () => {
|
||||
if (!authStore.isLoggedIn) {
|
||||
favorites.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const refs = await window.electronAPI.playlist.getFavorites()
|
||||
favorites.value = await Promise.all(refs.map(resolveFavorite))
|
||||
} catch (err: any) {
|
||||
favorites.value = []
|
||||
ElMessage.error(err?.message || '收藏歌单加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openPlaylist = (item: FavoritePlaylistItem) => {
|
||||
if (item.source) {
|
||||
router.push({
|
||||
name: 'PluginCollection',
|
||||
params: { pluginId: item.source, kind: 'playlist', id: item.id },
|
||||
})
|
||||
} else {
|
||||
router.push({ name: 'PlaylistDetail', params: { scope: 'cloud', id: item.id } })
|
||||
}
|
||||
}
|
||||
|
||||
const removeFavorite = async (item: FavoritePlaylistItem) => {
|
||||
try {
|
||||
const result = await window.electronAPI.playlist.uncollect(item.id, item.source)
|
||||
if (result.status !== 'success') throw new Error(result.message || '取消收藏失败')
|
||||
favorites.value = favorites.value.filter((current) =>
|
||||
current.id !== item.id || current.source !== item.source
|
||||
)
|
||||
ElMessage.success('已取消收藏')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.message || '取消收藏失败')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => authStore.state.userInfo?.id, loadFavorites, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorite-playlists-view {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
box-sizing: border-box;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 34px 32px 48px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--color-accent);
|
||||
font-size: 11px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 6px 0 8px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.card-copy p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
min-height: 38px;
|
||||
padding: 0 15px;
|
||||
border-radius: var(--radius-full);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.playlist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
min-height: 118px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--color-bg-secondary) 92%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.card-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 17px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.card-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-copy h2,
|
||||
.card-copy p,
|
||||
.card-copy span {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-copy h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.card-copy p {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-copy span {
|
||||
display: block;
|
||||
margin-top: 9px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
width: 42px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
color: var(--color-danger, #ef4444);
|
||||
background: color-mix(in srgb, #ef4444 8%, transparent);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 260px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
animation: pulse 1.2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from { opacity: 0.45; }
|
||||
to { opacity: 0.85; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -2,10 +2,10 @@
|
||||
<div class="playlist-view">
|
||||
<div class="content-wrapper">
|
||||
<section class="playlist-hero">
|
||||
<div class="cover" :class="{ editable: isManagedPlaylist }" @click="isManagedPlaylist && openCoverDialog()">
|
||||
<div class="cover" :class="{ editable: isEditablePlaylist }" @click="isEditablePlaylist && openCoverDialog()">
|
||||
<img v-if="playlist?.info.img" :src="playlist.info.img" alt="" />
|
||||
<Icon v-else :icon="heroIcon" />
|
||||
<button v-if="isManagedPlaylist" class="cover-edit" title="设置封面" @click.stop="openCoverDialog">
|
||||
<button v-if="isEditablePlaylist" class="cover-edit" title="设置封面" @click.stop="openCoverDialog">
|
||||
<Icon icon="lucide:image-up" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -33,9 +33,9 @@
|
||||
</router-link>
|
||||
<span v-else-if="playlist?.info.author">{{ playlist.info.author }}</span>
|
||||
<span v-if="isCloudPlaylist">访问 {{ accessCount }} 次</span>
|
||||
<span v-if="isCloudPlaylist && likeCount >= 0">
|
||||
<Icon icon="lucide:heart" style="width:13px;height:13px;vertical-align:-1px" />
|
||||
{{ likeCount }}
|
||||
<span v-if="isCloudPlaylist && collectionCount >= 0">
|
||||
<Icon icon="lucide:bookmark" style="width:13px;height:13px;vertical-align:-1px" />
|
||||
{{ collectionCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
@@ -43,7 +43,7 @@
|
||||
<Icon icon="lucide:play" />
|
||||
播放全部
|
||||
</button>
|
||||
<button v-if="isManagedPlaylist" class="soft-btn" @click="openEditDialog">
|
||||
<button v-if="isEditablePlaylist" class="soft-btn" @click="openEditDialog">
|
||||
<Icon icon="lucide:pencil" />
|
||||
编辑
|
||||
</button>
|
||||
@@ -55,26 +55,26 @@
|
||||
<Icon icon="lucide:hard-drive-download" />
|
||||
另存本地
|
||||
</button>
|
||||
<button v-if="isCloudPlaylist" class="soft-btn" @click="openCoverDialog">
|
||||
<button v-if="isCloudPlaylist && isEditablePlaylist" class="soft-btn" @click="openCoverDialog">
|
||||
<Icon icon="lucide:image-up" />
|
||||
封面
|
||||
</button>
|
||||
<button v-else-if="isManagedPlaylist" class="soft-btn" @click="convertPlaylistMode">
|
||||
<button v-else-if="isEditablePlaylist" class="soft-btn" @click="convertPlaylistMode">
|
||||
<Icon :icon="targetScope === 'cloud' ? 'lucide:cloud-upload' : 'lucide:hard-drive-download'" />
|
||||
{{ targetScope === 'cloud' ? '转为云端' : '转为本地' }}
|
||||
</button>
|
||||
<button v-if="isManagedPlaylist" class="icon-btn danger" title="删除歌单" @click="deletePlaylist">
|
||||
<button v-if="isEditablePlaylist" class="icon-btn danger" title="删除歌单" @click="deletePlaylist">
|
||||
<Icon icon="lucide:trash-2" />
|
||||
</button>
|
||||
<button
|
||||
v-if="showLikeButton"
|
||||
class="icon-btn like-btn"
|
||||
:class="{ liked: isLikedByUser }"
|
||||
:title="isLikedByUser ? '取消喜欢' : '喜欢'"
|
||||
:disabled="likingPlaylist"
|
||||
@click="toggleLikePlaylist"
|
||||
v-if="showCollectButton"
|
||||
class="icon-btn collect-btn"
|
||||
:class="{ collected: isCollectedByUser }"
|
||||
:title="isCollectedByUser ? '取消收藏' : '收藏'"
|
||||
:disabled="collectingPlaylist"
|
||||
@click="toggleCollectPlaylist"
|
||||
>
|
||||
<Icon :icon="isLikedByUser ? 'lucide:heart' : 'lucide:heart'" />
|
||||
<Icon icon="lucide:bookmark" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@
|
||||
:key="`${song.source}:${song.id}:${index}`"
|
||||
:song="song"
|
||||
:display-index="displayStartIndex + index + 1"
|
||||
:removable="isManagedPlaylist"
|
||||
:removable="isEditablePlaylist"
|
||||
reserve-action
|
||||
@play="playSong(index)"
|
||||
@remove="removeSong(index)"
|
||||
@@ -238,10 +238,10 @@ const loadMoreTrigger = ref<HTMLElement | null>(null)
|
||||
const pageSize = 50
|
||||
let loadMoreObserver: IntersectionObserver | null = null
|
||||
|
||||
// Like state
|
||||
const isLikedByUser = ref(false)
|
||||
const likeCount = ref(-1)
|
||||
const likingPlaylist = ref(false)
|
||||
// Playlist collection state
|
||||
const isCollectedByUser = ref(false)
|
||||
const collectionCount = ref(-1)
|
||||
const collectingPlaylist = ref(false)
|
||||
|
||||
type PublicSong = Partial<Song> & {
|
||||
// 兼容云端 API 可能返回的旧字段名
|
||||
@@ -270,17 +270,20 @@ const isOwnPlaylist = computed(() => {
|
||||
if (!authStore.state.userInfo?.id || !playlist.value?.owner) return false
|
||||
return authStore.state.userInfo.id === playlist.value.owner.id
|
||||
})
|
||||
const isEditablePlaylist = computed(() =>
|
||||
routeScope.value === 'local' || (isCloudPlaylist.value && isOwnPlaylist.value)
|
||||
)
|
||||
const showAuthorLink = computed(() =>
|
||||
isCloudPlaylist.value &&
|
||||
!isOwnPlaylist.value &&
|
||||
playlist.value?.info.is_public &&
|
||||
playlist.value?.owner
|
||||
)
|
||||
const showLikeButton = computed(() =>
|
||||
isCloudPlaylist.value &&
|
||||
!isOwnPlaylist.value &&
|
||||
authStore.isLoggedIn
|
||||
)
|
||||
const collectionSource = computed(() => isCloudPlaylist.value ? '' : String(routePluginId.value || ''))
|
||||
const showCollectButton = computed(() => authStore.isLoggedIn && Boolean(
|
||||
(isCloudPlaylist.value && routeId.value) ||
|
||||
(isPluginCollection.value && routeKind.value === 'playlist' && routePluginId.value && routeId.value)
|
||||
))
|
||||
const targetScope = computed<ManagedPlaylistScope>(() => routeScope.value === 'local' ? 'cloud' : 'local')
|
||||
const songCount = computed(() => playlist.value?.total ?? playlist.value?.list.length ?? 0)
|
||||
const accessCount = computed(() => Number(playlist.value?.info.visit_count ?? playlist.value?.info.play_count ?? 0) || 0)
|
||||
@@ -433,8 +436,8 @@ const loadPlaylist = async () => {
|
||||
descriptionExpanded.value = false
|
||||
currentPage.value = 1
|
||||
loadedPage.value = 1
|
||||
isLikedByUser.value = false
|
||||
likeCount.value = -1
|
||||
isCollectedByUser.value = false
|
||||
collectionCount.value = -1
|
||||
if (isLiked.value) {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -470,6 +473,7 @@ const loadPlaylist = async () => {
|
||||
try {
|
||||
await loadPageMode()
|
||||
await fetchPluginCollectionPage(1, false)
|
||||
await loadCollectionState()
|
||||
} catch (err: any) {
|
||||
playlist.value = null
|
||||
errorMessage.value = getErrorMessage(err)
|
||||
@@ -488,7 +492,7 @@ const loadPlaylist = async () => {
|
||||
try {
|
||||
await loadPageMode()
|
||||
playlist.value = await playlistStore.get(routeScope.value as ManagedPlaylistScope, routeId.value)
|
||||
if (isCloudPlaylist.value) await loadLikeState()
|
||||
if (isCloudPlaylist.value) await loadCollectionState()
|
||||
} catch (err: any) {
|
||||
playlist.value = null
|
||||
errorMessage.value = getErrorMessage(err)
|
||||
@@ -603,35 +607,51 @@ const removeSong = async (index: number) => {
|
||||
playlist.value = await playlistStore.removeSong(routeScope.value, routeId.value, displayStartIndex.value + index)
|
||||
}
|
||||
|
||||
// === Like Feature ===
|
||||
// === Playlist Collection Feature ===
|
||||
|
||||
const loadLikeState = async () => {
|
||||
if (!isCloudPlaylist.value || !routeId.value) {
|
||||
isLikedByUser.value = false
|
||||
likeCount.value = -1
|
||||
const loadCollectionState = async () => {
|
||||
if (!showCollectButton.value || !routeId.value) {
|
||||
isCollectedByUser.value = false
|
||||
collectionCount.value = isCloudPlaylist.value
|
||||
? Number(playlist.value?.info.collection_count ?? 0) || 0
|
||||
: -1
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await window.electronAPI.playlist.getLike(routeId.value)
|
||||
isLikedByUser.value = result.liked
|
||||
likeCount.value = result.like_count
|
||||
const favorites = await window.electronAPI.playlist.getFavorites()
|
||||
const source = collectionSource.value
|
||||
isCollectedByUser.value = favorites.some((item) =>
|
||||
String(item.id) === routeId.value && String(item.source || '') === source
|
||||
)
|
||||
collectionCount.value = isCloudPlaylist.value
|
||||
? Number(playlist.value?.info.collection_count ?? 0) || 0
|
||||
: -1
|
||||
} catch {
|
||||
isLikedByUser.value = false
|
||||
likeCount.value = -1
|
||||
isCollectedByUser.value = false
|
||||
collectionCount.value = isCloudPlaylist.value
|
||||
? Number(playlist.value?.info.collection_count ?? 0) || 0
|
||||
: -1
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLikePlaylist = async () => {
|
||||
if (!routeId.value || likingPlaylist.value) return
|
||||
likingPlaylist.value = true
|
||||
const toggleCollectPlaylist = async () => {
|
||||
if (!routeId.value || collectingPlaylist.value || !showCollectButton.value) return
|
||||
collectingPlaylist.value = true
|
||||
try {
|
||||
const result = await window.electronAPI.playlist.toggleLike(routeId.value)
|
||||
isLikedByUser.value = result.liked
|
||||
likeCount.value = result.like_count
|
||||
const result = isCollectedByUser.value
|
||||
? await window.electronAPI.playlist.uncollect(routeId.value, collectionSource.value)
|
||||
: await window.electronAPI.playlist.collect(routeId.value, collectionSource.value)
|
||||
if (result.status !== 'success') throw new Error(result.message || '收藏同步失败')
|
||||
isCollectedByUser.value = result.collected
|
||||
if (isCloudPlaylist.value && result.collection_count != null) {
|
||||
collectionCount.value = Number(result.collection_count) || 0
|
||||
if (playlist.value) playlist.value.info.collection_count = collectionCount.value
|
||||
}
|
||||
ElMessage.success(result.collected ? '已收藏歌单' : '已取消收藏')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.message || '操作失败')
|
||||
} finally {
|
||||
likingPlaylist.value = false
|
||||
collectingPlaylist.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,19 +904,19 @@ h1 {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.icon-btn.like-btn {
|
||||
.icon-btn.collect-btn {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.icon-btn.like-btn:hover {
|
||||
.icon-btn.collect-btn:hover {
|
||||
color: #ff6b8a;
|
||||
}
|
||||
|
||||
.icon-btn.like-btn.liked {
|
||||
.icon-btn.collect-btn.collected {
|
||||
color: #ff4d6d;
|
||||
}
|
||||
|
||||
.icon-btn.like-btn.liked svg {
|
||||
.icon-btn.collect-btn.collected svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
<Icon icon="lucide:trending-up" />
|
||||
访问量
|
||||
</button>
|
||||
<button :class="{ active: sort === 'like' }" @click="setSort('like')">
|
||||
<Icon icon="lucide:heart" />
|
||||
喜欢
|
||||
<button :class="{ active: sort === 'collection' }" @click="setSort('collection')">
|
||||
<Icon icon="lucide:bookmark" />
|
||||
收藏
|
||||
</button>
|
||||
<button :class="{ active: sort === 'total' }" @click="setSort('total')">
|
||||
<Icon icon="lucide:list-music" />
|
||||
@@ -66,8 +66,8 @@
|
||||
{{ Number(playlist.info.visit_count || playlist.info.play_count || 0) || 0 }}
|
||||
</span>
|
||||
<span>
|
||||
<Icon icon="lucide:heart" />
|
||||
{{ Number(playlist.info.like_count || 0) || 0 }}
|
||||
<Icon icon="lucide:bookmark" />
|
||||
{{ Number(playlist.info.collection_count || 0) || 0 }}
|
||||
</span>
|
||||
<span>
|
||||
<Icon icon="lucide:music" />
|
||||
@@ -103,7 +103,7 @@ import { usePlaylistsStore, type AppPlaylist } from '../stores/playlists'
|
||||
const router = useRouter()
|
||||
const playlistStore = usePlaylistsStore()
|
||||
const query = ref('')
|
||||
const sort = ref<'visit' | 'like' | 'total' | 'name'>('visit')
|
||||
const sort = ref<'visit' | 'collection' | 'total' | 'name'>('visit')
|
||||
const playlists = ref<AppPlaylist[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
@@ -117,7 +117,7 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))
|
||||
const sortLabel = computed(() => {
|
||||
if (sort.value === 'total') return '歌曲数'
|
||||
if (sort.value === 'name') return '名称'
|
||||
if (sort.value === 'like') return '喜欢数'
|
||||
if (sort.value === 'collection') return '收藏数'
|
||||
return '访问量'
|
||||
})
|
||||
|
||||
@@ -152,7 +152,7 @@ watch(query, () => {
|
||||
|
||||
watch(page, () => scheduleLoad(120))
|
||||
|
||||
const setSort = (nextSort: 'visit' | 'like' | 'total' | 'name') => {
|
||||
const setSort = (nextSort: 'visit' | 'collection' | 'total' | 'name') => {
|
||||
if (sort.value === nextSort) return
|
||||
sort.value = nextSort
|
||||
page.value = 1
|
||||
|
||||
Reference in New Issue
Block a user