feat: 插件沙箱 with(fakeGlobal) 修复 Z_SYNC_FLUSH 未定义 + 播放列表抽屉 + 日志系统 + 侧边栏日志入口

This commit is contained in:
auto-bot
2026-06-14 00:29:58 +00:00
parent 4434c255a2
commit 9062a4fe5b
9 changed files with 957 additions and 30 deletions

423
src/views/LogView.vue Normal file
View 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>