feat: 插件沙箱 with(fakeGlobal) 修复 Z_SYNC_FLUSH 未定义 + 播放列表抽屉 + 日志系统 + 侧边栏日志入口
This commit is contained in:
@@ -18,6 +18,21 @@
|
||||
<Icon icon="lucide:hard-drive" class="nav-icon" />
|
||||
<span class="nav-text">本地音乐</span>
|
||||
</router-link>
|
||||
<router-link to="/search" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:search" class="nav-icon" />
|
||||
<span class="nav-text">搜索</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 工具 -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="section-title">工具</div>
|
||||
<router-link to="/logs" class="nav-item" active-class="active">
|
||||
<Icon icon="lucide:terminal" class="nav-icon" />
|
||||
<span class="nav-text">运行日志</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 我的音乐 -->
|
||||
|
||||
@@ -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);
|
||||
|
||||
305
src/components/player/PlaylistDrawer.vue
Normal file
305
src/components/player/PlaylistDrawer.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div v-if="playerStore.showPlaylist" class="drawer-backdrop" @click.self="playerStore.togglePlaylist()">
|
||||
<transition name="slide-right" appear>
|
||||
<div v-if="playerStore.showPlaylist" class="drawer-panel" @click.stop>
|
||||
<div class="drawer-header">
|
||||
<div class="header-left">
|
||||
<Icon icon="lucide:list-music" class="header-icon" />
|
||||
<span class="header-title">播放列表</span>
|
||||
<span class="header-count">({{ playerStore.playlist.length }})</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn small" @click="playerStore.clearPlaylist()" title="清空列表">
|
||||
<Icon icon="lucide:trash-2" />
|
||||
</button>
|
||||
<button class="icon-btn small" @click="playerStore.togglePlaylist()" title="关闭">
|
||||
<Icon icon="lucide:x" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-body">
|
||||
<div v-if="playerStore.playlist.length === 0" class="empty-state">
|
||||
<Icon icon="lucide:music-4" class="empty-icon" />
|
||||
<span class="empty-text">暂无歌曲</span>
|
||||
<span class="empty-hint">搜索并添加歌曲吧</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="song-list">
|
||||
<div
|
||||
v-for="(song, index) in playerStore.playlist"
|
||||
:key="song.id || song.name + song.artist + index"
|
||||
class="song-row"
|
||||
:class="{ current: index === playerStore.currentIndex }"
|
||||
@dblclick="playSongAt(index)"
|
||||
>
|
||||
<div class="song-index">
|
||||
<Icon v-if="index === playerStore.currentIndex && playerStore.isPlaying" icon="lucide:volume-2" class="playing-icon" />
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<div class="song-name">{{ song.name }}</div>
|
||||
<div class="song-artist">{{ song.artist || '未知歌手' }}</div>
|
||||
</div>
|
||||
<div class="song-actions">
|
||||
<button class="icon-btn tiny" @click.stop="playSongAt(index)" title="播放">
|
||||
<Icon icon="lucide:play" />
|
||||
</button>
|
||||
<button class="icon-btn tiny" @click.stop="playerStore.removeFromPlaylist(index)" title="从列表移除">
|
||||
<Icon icon="lucide:x" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../../stores/player';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
const playSongAt = async (index: number) => {
|
||||
const song = playerStore.playlist[index];
|
||||
if (song) {
|
||||
await playerStore.playFromList(song, playerStore.playlist);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
width: 420px;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-left: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: var(--color-accent);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.header-count {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: var(--color-text-muted);
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 60px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Song list */
|
||||
.song-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.song-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.song-row:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.song-row.current {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.song-row.current .song-name {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.song-index {
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playing-icon {
|
||||
color: var(--color-accent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-name {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.song-row:hover .song-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Icon btn (local compact variant) */
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.icon-btn.small { width: 30px; height: 30px; }
|
||||
.icon-btn.tiny { width: 26px; height: 26px; padding: 0; color: var(--color-text-muted); font-size: 13px; }
|
||||
|
||||
/* Scrollbar */
|
||||
.drawer-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.drawer-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.drawer-body::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.slide-right-enter-from,
|
||||
.slide-right-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.drawer-panel {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@
|
||||
</div>
|
||||
</main>
|
||||
<PlayerBar />
|
||||
<PlaylistDrawer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
124
src/stores/log.ts
Normal file
124
src/stores/log.ts
Normal file
@@ -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<LogLevel, number> = { 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<LogEntry[]>(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<string>();
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
423
src/views/LogView.vue
Normal file
423
src/views/LogView.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<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="exportLogs()" title="导出">
|
||||
<Icon icon="lucide:download" />
|
||||
</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: expandedIds.has(entry.id) }" />
|
||||
</div>
|
||||
<div v-if="entry.detail !== undefined && expandedIds.has(entry.id)" class="row-detail">
|
||||
<pre>{{ formatDetail(entry.detail) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, nextTick } 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 expandedIds = 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 toggleExpand = (id: number) => {
|
||||
if (expandedIds.value.has(id)) {
|
||||
expandedIds.value.delete(id);
|
||||
} else {
|
||||
expandedIds.value.add(id);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDetail = (d: any) => {
|
||||
if (d instanceof Error) {
|
||||
return `${d.name}: ${d.message}\n${d.stack || ''}`;
|
||||
}
|
||||
if (typeof d === 'string') return d;
|
||||
try {
|
||||
return JSON.stringify(d, null, 2);
|
||||
} catch {
|
||||
return String(d);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (listRef.value) {
|
||||
nextTick(() => {
|
||||
listRef.value!.scrollTop = listRef.value!.scrollHeight;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const exportLogs = () => {
|
||||
const lines = logStore.logs.map((l) => {
|
||||
const detail = l.detail !== undefined ? ' | ' + formatDetail(l.detail).replace(/\n/g, ' ') : '';
|
||||
return `[${l.time}] [${l.level.toUpperCase()}] [${l.module}] ${l.message}${detail}`;
|
||||
});
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `qzmusic-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
if (confirm('确定清空所有日志?')) {
|
||||
logStore.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;
|
||||
}
|
||||
|
||||
.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: 13px;
|
||||
}
|
||||
|
||||
.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;
|
||||
overflow-x: auto;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.row-detail pre {
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user