diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue index dd7a8fc..4ad8760 100644 --- a/src/components/Sidebar.vue +++ b/src/components/Sidebar.vue @@ -18,6 +18,21 @@ 本地音乐 + + + 搜索 + + + + +
+ + diff --git a/src/components/player/PlayerBar.vue b/src/components/player/PlayerBar.vue index fdaccb9..3097fd8 100644 --- a/src/components/player/PlayerBar.vue +++ b/src/components/player/PlayerBar.vue @@ -147,7 +147,7 @@ const next = () => playerStore.next(); const prev = () => playerStore.prev(); const toggleMode = () => playerStore.toggleMode(); const toggleLike = () => { isLiked.value = !isLiked.value; }; -const togglePlaylist = () => { /* TODO: Toggle Playlist Drawer */ }; +const togglePlaylist = () => playerStore.togglePlaylist(); const onSeek = (e: Event) => { const val = Number((e.target as HTMLInputElement).value); diff --git a/src/components/player/PlaylistDrawer.vue b/src/components/player/PlaylistDrawer.vue new file mode 100644 index 0000000..80c33c7 --- /dev/null +++ b/src/components/player/PlaylistDrawer.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/src/layout/MainLayout.vue b/src/layout/MainLayout.vue index 5e3db62..57b46b5 100644 --- a/src/layout/MainLayout.vue +++ b/src/layout/MainLayout.vue @@ -12,6 +12,7 @@ + @@ -20,6 +21,7 @@ import { computed } from 'vue'; import Sidebar from '../components/Sidebar.vue'; import TopBar from '../components/TopBar.vue'; import PlayerBar from '../components/player/PlayerBar.vue'; +import PlaylistDrawer from '../components/player/PlaylistDrawer.vue'; import { usePlayerStore } from '../stores/player'; const playerStore = usePlayerStore(); diff --git a/src/main.ts b/src/main.ts index f781527..c4fb520 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,6 +34,11 @@ const router = createRouter({ path: '/search', name: 'Search', component: () => import('./views/Search.vue') + }, + { + path: '/logs', + name: 'Logs', + component: () => import('./views/LogView.vue') } ] }) diff --git a/src/plugins/pluginManager.ts b/src/plugins/pluginManager.ts index 86ec559..02ed31d 100644 --- a/src/plugins/pluginManager.ts +++ b/src/plugins/pluginManager.ts @@ -1,5 +1,6 @@ import { createSandboxRequire } from './nodePolyfills'; import type { PluginFullInfo, PluginSearchResult, UrlResponse, PluginModule } from '../types/plugin'; +import { useLogStore } from '../stores/log'; // ===== 官方音源插件注册表 ===== export interface BuiltinPluginDef { @@ -134,18 +135,24 @@ export class PluginManager { try { module = this._executePluginCode(code); } catch (err: any) { + const msg = '执行插件代码时出现异常: ' + (err?.message || String(err)); + try { useLogStore().error('plugin', msg, err); } catch { /* ignore */ } return { success: false, pluginId: '', - error: '执行插件代码时出现异常: ' + (err?.message || String(err)), + error: msg, }; } if (!module || typeof module !== 'object') { - return { success: false, pluginId: '', error: '插件未导出对象' }; + const msg = '插件未导出对象'; + try { useLogStore().warn('plugin', msg); } catch { /* ignore */ } + return { success: false, pluginId: '', error: msg }; } if (!module.pluginInfo || !module.pluginInfo.info) { - return { success: false, pluginId: '', error: '插件缺少 pluginInfo.info 字段' }; + const msg = '插件缺少 pluginInfo.info 字段'; + try { useLogStore().warn('plugin', msg); } catch { /* ignore */ } + return { success: false, pluginId: '', error: msg }; } const id = preferredId || module.pluginInfo.info.id || 'anonymous'; @@ -166,6 +173,8 @@ export class PluginManager { this._persistUserPlugins(); } + try { useLogStore().info('plugin', `已加载音源插件: ${name} (${id}) v${version}`); } catch { /* ignore */ } + return { success: true, pluginId: id, module }; } @@ -315,6 +324,12 @@ export class PluginManager { if (!this.activePluginId && this.plugins.size > 0) { this._pickFallbackActive(); } + try { + useLogStore().info('plugin', `官方音源加载完成: ${loaded}/${BUILTIN_PLUGINS.length} 已加载,激活音源: ${this.activePluginId || '(无)'}`); + if (failed.length > 0) { + useLogStore().warn('plugin', `未能加载的音源: ${failed.join(', ')}`); + } + } catch { /* ignore */ } return { loaded, total: BUILTIN_PLUGINS.length, failed }; } @@ -496,6 +511,11 @@ export class PluginManager { const fakeGlobal: any = { process: processObj, Buffer: BufferCtor, + require: requireFn, + module: moduleObj, + exports: moduleObj.exports, + __dirname: '/', + __filename: '/plugin.js', console, setTimeout: setTimeout as any, clearTimeout, @@ -538,36 +558,19 @@ export class PluginManager { fakeGlobal.globalThis = fakeGlobal; fakeGlobal.root = fakeGlobal; + // 用 with(fakeGlobal) 包裹代码,让所有嵌套层都能访问 + // require / module / exports / process / Buffer / zlib / http 等 + // 这是 webpack 打包的 CommonJS 插件在浏览器环境下能正确运行的关键 const wrapperCode = - '(function(module,exports,require,__dirname,__filename,process,Buffer,console,setTimeout,clearTimeout,setInterval,clearInterval,Promise,JSON,Math,Date,Array,Object,global,globalThis){' + + '(function(__g){' + + 'with(__g){' + code + - '\n;return module.exports;})'; + ';return module.exports;' + + '}' + + '})'; const fn: any = (new Function('return ' + wrapperCode))(); - const runArgs: any[] = [ - moduleObj, - moduleObj.exports, - requireFn, - '/', - '/plugin.js', - processObj, - BufferCtor, - console, - setTimeout, - clearTimeout, - setInterval, - clearInterval, - Promise, - JSON, - Math, - Date, - Array, - Object, - fakeGlobal, - fakeGlobal, - ]; - - const result = fn.apply(fakeGlobal, runArgs); + const result = fn(fakeGlobal); const mod = (result && typeof result === 'object' && (result as any).exports) ? (result as any).exports : result; diff --git a/src/stores/log.ts b/src/stores/log.ts new file mode 100644 index 0000000..b5a967d --- /dev/null +++ b/src/stores/log.ts @@ -0,0 +1,124 @@ +import { defineStore } from 'pinia'; +import { ref, watch } from 'vue'; + +export type LogLevel = 'info' | 'warn' | 'error' | 'debug'; + +export interface LogEntry { + id: number; + time: string; + level: LogLevel; + module: string; + message: string; + detail?: any; +} + +const MAX_LOGS = 500; +const STORAGE_KEY = 'qz-app-logs'; +const LEVEL_ORDER: Record = { debug: 0, info: 1, warn: 2, error: 3 }; + +function loadFromStorage(): LogEntry[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed as LogEntry[]; + } + } catch { + // ignore + } + return []; +} + +export const useLogStore = defineStore('log', () => { + const logs = ref(loadFromStorage()); + let nextId = logs.value.length > 0 ? Math.max(...logs.value.map((l) => l.id)) + 1 : 1; + + watch( + logs, + (newLogs) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(newLogs)); + } catch { + // ignore quota issues + } + }, + { deep: true } + ); + + const add = (level: LogLevel, module: string, message: string, detail?: any) => { + const now = new Date(); + const hh = now.getHours().toString().padStart(2, '0'); + const mm = now.getMinutes().toString().padStart(2, '0'); + const ss = now.getSeconds().toString().padStart(2, '0'); + const ms = now.getMilliseconds().toString().padStart(3, '0'); + + const entry: LogEntry = { + id: nextId++, + time: `${hh}:${mm}:${ss}.${ms}`, + level, + module, + message, + detail, + }; + + logs.value.push(entry); + if (logs.value.length > MAX_LOGS) { + logs.value = logs.value.slice(-MAX_LOGS); + } + + // 同步到浏览器控制台,方便调试 + const consoleArgs = [`[${entry.time}] [${level.toUpperCase()}] [${module}]`, message]; + if (detail !== undefined) consoleArgs.push(detail); + try { + if (level === 'error') console.error(...consoleArgs); + else if (level === 'warn') console.warn(...consoleArgs); + else if (level === 'debug') console.debug(...consoleArgs); + else console.log(...consoleArgs); + } catch { + // ignore + } + }; + + const info = (module: string, message: string, detail?: any) => add('info', module, message, detail); + const warn = (module: string, message: string, detail?: any) => add('warn', module, message, detail); + const error = (module: string, message: string, detail?: any) => add('error', module, message, detail); + const debug = (module: string, message: string, detail?: any) => add('debug', module, message, detail); + + const clear = () => { + logs.value = []; + nextId = 1; + }; + + const filter = (module?: string, level?: LogLevel) => { + return logs.value.filter((l) => { + if (module && l.module !== module) return false; + if (level && LEVEL_ORDER[l.level] < LEVEL_ORDER[level]) return false; + return true; + }); + }; + + const moduleList = () => { + const set = new Set(); + for (const l of logs.value) set.add(l.module); + return Array.from(set).sort(); + }; + + const countByLevel = () => { + const c = { info: 0, warn: 0, error: 0, debug: 0 }; + for (const l of logs.value) c[l.level]++; + return c; + }; + + return { + logs, + info, + warn, + error, + debug, + add, + clear, + filter, + moduleList, + countByLevel, + }; +}); diff --git a/src/stores/player.ts b/src/stores/player.ts index c5e7dcc..9d82fe1 100644 --- a/src/stores/player.ts +++ b/src/stores/player.ts @@ -4,6 +4,7 @@ import { MessagePlugin } from 'tdesign-vue-next'; import type { Song } from '../types/song'; import { pluginManager } from '../plugins/index'; import { parseAnyLyric } from '../utils/lyricUtil'; +import { useLogStore } from './log'; export enum PlayMode { List = 'list', @@ -41,6 +42,7 @@ export const usePlayerStore = defineStore('player', () => { // UI State const isPlayerFullScreen = ref(false); const hideLyricView = ref(false); + const showPlaylist = ref(false); // Playlist State const savedPlaylist = localStorage.getItem('qz-player-playlist'); @@ -194,12 +196,17 @@ export const usePlayerStore = defineStore('player', () => { if (!isValidUrl) { try { + useLogStore().debug('player', `通过音源插件获取播放URL: ${song.name} (${song.id || 'no-id'})`); const res = await pluginManager.getSongUrl(song); if (res?.success && res.url) { playUrl = res.url; song.url = res.url; + useLogStore().info('player', `已获取播放URL: ${song.name}`); + } else { + useLogStore().warn('player', `未能获取播放URL: ${song.name}`, res); } } catch (e) { + useLogStore().error('player', `插件获取URL异常: ${song.name}`, e as any); console.warn('[Player] 插件获取 URL 失败:', e); } } @@ -213,13 +220,16 @@ export const usePlayerStore = defineStore('player', () => { audioElement.load(); if (autoPlay) { await audioElement.play(); + useLogStore().info('player', `开始播放: ${song.name} - ${song.artist}`); } playErrorCount.value = 0; } catch (e) { + useLogStore().error('player', `播放失败: ${song.name}`, e as any); console.error('[Player] 播放失败:', e); if (autoPlay) handlePlayError(); } } else { + useLogStore().warn('player', `歌曲无可用 URL: ${song.name}`); console.warn('[Player] 歌曲无可用 URL:', song.name); MessagePlugin.warning('当前音源插件无法获取这首歌的播放地址').then(); if (autoPlay) handlePlayError(); @@ -345,6 +355,41 @@ export const usePlayerStore = defineStore('player', () => { isPlayerFullScreen.value = !isPlayerFullScreen.value; }; + const togglePlaylist = () => { + showPlaylist.value = !showPlaylist.value; + }; + + const removeFromPlaylist = (index: number) => { + if (index < 0 || index >= playlist.value.length) return; + const wasCurrent = index === currentIndex.value; + playlist.value.splice(index, 1); + if (wasCurrent) { + if (playlist.value.length === 0) { + currentIndex.value = -1; + currentSong.value = null; + isPlaying.value = false; + audioElement?.pause(); + } else { + currentIndex.value = Math.min(currentIndex.value, playlist.value.length - 1); + const newSong = playlist.value[currentIndex.value]; + currentSong.value = newSong; + if (isPlaying.value && newSong) { + playSong(newSong).then(); + } + } + } else if (index < currentIndex.value) { + currentIndex.value -= 1; + } + }; + + const clearPlaylist = () => { + playlist.value = []; + currentIndex.value = -1; + currentSong.value = null; + isPlaying.value = false; + audioElement?.pause(); + }; + // Persistence Listeners watch(() => [playlist.value, currentIndex.value], () => { localStorage.setItem('qz-player-playlist', JSON.stringify(playlist.value)); @@ -375,6 +420,7 @@ export const usePlayerStore = defineStore('player', () => { loudness, spectrum, isPlayerFullScreen, + showPlaylist, setPlaylist, playSong, next, @@ -384,6 +430,10 @@ export const usePlayerStore = defineStore('player', () => { seek, toggleMode, toggleFullScreen, + togglePlaylist, + removeFromPlaylist, + clearPlaylist, + currentIndex, lyrics, fetchLyrics, addListMode, diff --git a/src/views/LogView.vue b/src/views/LogView.vue new file mode 100644 index 0000000..75d5cbc --- /dev/null +++ b/src/views/LogView.vue @@ -0,0 +1,423 @@ + + + + +