feat: 实现收藏歌单、修复TopBar拖拽穿透问题、适配Linux amd64

This commit is contained in:
2026-08-13 11:00:03 +08:00
parent adbf9ca5cf
commit 1c63cd0139
27 changed files with 1104 additions and 141 deletions

View File

@@ -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 ===

View File

@@ -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 }) => {

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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',

View File

@@ -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;

View File

@@ -15,7 +15,7 @@ export interface PlaylistInfo {
author?: string
play_count?: string
visit_count?: number
like_count?: number
collection_count?: number
is_public?: boolean
}

View File

@@ -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;
}

View 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>

View File

@@ -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;
}

View File

@@ -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