feat: 插件沙箱注入 window.require + 日志系统升级(图片渲染/自动展开/多格式导出)

This commit is contained in:
auto-bot
2026-06-14 00:54:26 +00:00
parent fa7f6fec15
commit b26b9be807
2 changed files with 441 additions and 215 deletions

View File

@@ -561,29 +561,68 @@ export class PluginManager {
// 让 with(fakeGlobal) 在查找这些未覆盖变量时自动回落到浏览器真实全局作用域 // 让 with(fakeGlobal) 在查找这些未覆盖变量时自动回落到浏览器真实全局作用域
// 确保 window.location.href / document.cookie / navigator.userAgent 等正常工作 // 确保 window.location.href / document.cookie / navigator.userAgent 等正常工作
// 用 with(fakeGlobal) 包裹代码,让所有嵌套层都能访问 // ============================================================
// require / module / exports / process / Buffer / zlib / http 等 // 关键修复:临时把 Node 风格全局挂到 window 上
// 这是 webpack 打包的 CommonJS 插件在浏览器环境下能正确运行的关键 // webpack/ncc 内部的 __nccwpck_require__ 会通过嵌套 eval/Function
const wrapperCode = // 在**全局作用域**查找 require/process/Buffer 等with(fakeGlobal) 覆盖不到它们
'(function(__g){' + // 执行后立即恢复,避免污染页面其他代码
'with(__g){' + // ============================================================
code + const win: any = typeof window !== 'undefined' ? window : globalThis;
';return module.exports;' + const backup: Record<string, any> = {};
'}' + const globalsToInject: [string, any][] = [
'})'; ['require', requireFn],
const fn: any = (new Function('return ' + wrapperCode))(); ['process', processObj],
['Buffer', BufferCtor],
const result = fn(fakeGlobal); ['module', moduleObj],
const mod = (result && typeof result === 'object' && (result as any).exports) ['exports', moduleObj.exports],
? (result as any).exports ['__dirname', '/'],
: result; ['__filename', '/plugin.js'],
['global', globalThis],
if (!mod || (typeof mod === 'object' && Object.keys(mod).length === 0)) { ['globalThis', globalThis],
const direct = (fakeGlobal.__plugin_install__ as any) || (fakeGlobal as any).plugin; ['setImmediate', (function (fn: (...rest: any[]) => void, ...rest: any[]) { return setTimeout(fn, 0, ...rest); }) as any],
if (direct && typeof direct === 'object' && direct.pluginInfo) return direct as PluginModule; ['clearImmediate', (id: any) => clearTimeout(id)],
];
for (const [key, value] of globalsToInject) {
backup[key] = (win as any)[key];
try { (win as any)[key] = value; } catch { /* ignore readonly */ }
} }
return mod as PluginModule; try {
// 用 with(fakeGlobal) 包裹插件代码,同时在顶层参数显式绑定 CommonJS 变量
const wrapperCode =
'(function(require,__dirname,__filename,module,exports,process,Buffer){' +
code +
'\n;return module.exports;' +
'})';
const fn: any = (new Function('return ' + wrapperCode))();
const args: any[] = [
requireFn, '/', '/plugin.js', moduleObj, moduleObj.exports, processObj, BufferCtor
];
// 通过 with(fakeGlobal) 调用,让自由变量查找回落到 fakeGlobal
const withWrapper: any = new Function(
'__g__', '__fn__', '__args__',
'with(__g__){ return __fn__.apply(null,__args__); }'
);
const result = withWrapper(fakeGlobal, fn, args);
const mod = (result && typeof result === 'object' && (result as any).exports)
? (result as any).exports
: result;
if (!mod || (typeof mod === 'object' && Object.keys(mod).length === 0)) {
const direct = (fakeGlobal.__plugin_install__ as any) || (fakeGlobal as any).plugin;
if (direct && typeof direct === 'object' && direct.pluginInfo) return direct as PluginModule;
}
return mod as PluginModule;
} finally {
// 恢复原始全局,避免污染页面其他代码
for (const key of Object.keys(backup)) {
try {
if (backup[key] === undefined) delete (win as any)[key];
else (win as any)[key] = backup[key];
} catch { /* ignore readonly */ }
}
}
} }
} }

View File

@@ -39,8 +39,23 @@
<button class="btn" @click="scrollToBottom()" title="滚到底部"> <button class="btn" @click="scrollToBottom()" title="滚到底部">
<Icon icon="lucide:arrow-down-to-line" /> <Icon icon="lucide:arrow-down-to-line" />
</button> </button>
<button class="btn" @click="exportLogs()" title="导出"> <button class="btn" @click="scrollToTop()" title="滚到顶部">
<Icon icon="lucide:download" /> <Icon icon="lucide:arrow-up-to-line" />
</button>
<button class="btn" @click="toggleExpandAll()">
<Icon :icon="expandedAll ? 'lucide:minimize-2' : 'lucide:maximize-2'" />
<span>{{ expandedAll ? '收起详情' : '展开详情' }}</span>
</button>
<button class="btn" @click="exportTxt()" title="导出 TXT">
<Icon icon="lucide:file-text" />
<span>TXT</span>
</button>
<button class="btn" @click="exportJson()" title="导出 JSON">
<Icon icon="lucide:file-code" />
<span>JSON</span>
</button>
<button class="btn" @click="copyAll()" title="复制全部">
<Icon icon="lucide:copy" />
</button> </button>
<button class="btn danger" @click="onClear()" title="清空"> <button class="btn danger" @click="onClear()" title="清空">
<Icon icon="lucide:trash-2" /> <Icon icon="lucide:trash-2" />
@@ -67,10 +82,33 @@
<span class="log-level">{{ entry.level.toUpperCase() }}</span> <span class="log-level">{{ entry.level.toUpperCase() }}</span>
<span class="log-module">[{{ entry.module }}]</span> <span class="log-module">[{{ entry.module }}]</span>
<span class="log-message">{{ entry.message }}</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) }" /> <Icon
v-if="entry.detail !== undefined"
icon="lucide:chevron-down"
class="expand-icon"
:class="{ expanded: isExpanded(entry.id) }"
/>
</div> </div>
<div v-if="entry.detail !== undefined && expandedIds.has(entry.id)" class="row-detail"> <div v-if="entry.detail !== undefined && isExpanded(entry.id)" class="row-detail" @click.stop>
<pre>{{ formatDetail(entry.detail) }}</pre> <template v-if="isImageUrl(entry.detail)">
<img :src="String(entry.detail)" class="detail-image" alt="图片日志" loading="lazy" />
<div class="detail-src">{{ String(entry.detail) }}</div>
</template>
<template v-else-if="Array.isArray(entry.detail) || typeof entry.detail === 'object'">
<pre class="json-block">{{ formatJson(entry.detail) }}</pre>
</template>
<template v-else-if="typeof entry.detail === 'string' && (entry.detail.startsWith('http:') || entry.detail.startsWith('https:'))">
<a :href="entry.detail" target="_blank" rel="noopener" class="detail-link">{{ entry.detail }}</a>
</template>
<template v-else>
<pre class="text-block">{{ String(entry.detail) }}</pre>
</template>
<div class="detail-actions">
<button class="mini-btn" @click.stop="copyDetail(entry.detail)">
<Icon icon="lucide:copy" />
<span>复制</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -78,7 +116,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, nextTick } from 'vue'; import { computed, ref, nextTick, onMounted } from 'vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { useLogStore } from '../stores/log'; import { useLogStore } from '../stores/log';
@@ -88,133 +126,224 @@ const selectedModule = ref('');
const selectedLevel = ref(''); const selectedLevel = ref('');
const searchText = ref(''); const searchText = ref('');
const listRef = ref<HTMLDivElement | null>(null); const listRef = ref<HTMLDivElement | null>(null);
const expandedIds = ref<Set<number>>(new Set()); const expandedAll = ref(false);
const forcedExpanded = ref<Set<number>>(new Set());
const forcedCollapsed = ref<Set<number>>(new Set());
const modules = computed(() => logStore.moduleList()); const modules = computed(() => logStore.moduleList());
const counts = computed(() => logStore.countByLevel()); const counts = computed(() => logStore.countByLevel());
const filtered = computed(() => { const filtered = computed(() => {
const keyword = searchText.value.trim().toLowerCase(); const keyword = searchText.value.trim().toLowerCase();
return logStore.logs.filter((l) => { return logStore.logs.filter((l) => {
if (selectedModule.value && l.module !== selectedModule.value) return false; if (selectedModule.value && l.module !== selectedModule.value) return false;
if (selectedLevel.value && l.level !== selectedLevel.value) return false; if (selectedLevel.value && l.level !== selectedLevel.value) return false;
if (keyword) { if (keyword) {
const hay = `${l.message} ${l.module}`.toLowerCase(); const hay = `${l.message} ${l.module}`.toLowerCase();
if (!hay.includes(keyword)) return false; if (!hay.includes(keyword)) return false;
} }
return true; return true;
}); });
}); });
const toggleExpand = (id: number) => { const isExpanded = (id: number) => {
if (expandedIds.value.has(id)) { if (forcedExpanded.value.has(id)) return true;
expandedIds.value.delete(id); if (forcedCollapsed.value.has(id)) return false;
} else { const entry = logStore.logs.find((e) => e.id === id);
expandedIds.value.add(id); return entry ? entry.level === 'error' : false;
}
}; };
const formatDetail = (d: any) => { const toggleExpand = (id: number) => {
if (d instanceof Error) { const currentlyExpanded = isExpanded(id);
return `${d.name}: ${d.message}\n${d.stack || ''}`; if (currentlyExpanded) {
} forcedCollapsed.value.add(id);
if (typeof d === 'string') return d; forcedExpanded.value.delete(id);
try { } else {
return JSON.stringify(d, null, 2); forcedExpanded.value.add(id);
} catch { forcedCollapsed.value.delete(id);
return String(d); }
} };
const toggleExpandAll = () => {
expandedAll.value = !expandedAll.value;
if (expandedAll.value) {
for (const e of filtered.value) forcedExpanded.value.add(e.id);
forcedCollapsed.value.clear();
} else {
for (const e of filtered.value) forcedCollapsed.value.add(e.id);
forcedExpanded.value.clear();
}
};
const isImageUrl = (detail: any) => {
if (typeof detail !== 'string') return false;
const lower = detail.trim().toLowerCase();
if (!lower.startsWith('http://') && !lower.startsWith('https://') && !lower.startsWith('data:image/')) return false;
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(lower) || lower.startsWith('data:image/');
};
const formatJson = (val: any) => {
try {
return JSON.stringify(val, null, 2);
} catch {
return String(val);
}
}; };
const scrollToBottom = () => { const scrollToBottom = () => {
if (listRef.value) { nextTick(() => {
nextTick(() => { if (listRef.value) listRef.value.scrollTop = listRef.value.scrollHeight;
listRef.value!.scrollTop = listRef.value!.scrollHeight; });
});
}
}; };
const exportLogs = () => { const scrollToTop = () => {
const lines = logStore.logs.map((l) => { nextTick(() => {
const detail = l.detail !== undefined ? ' | ' + formatDetail(l.detail).replace(/\n/g, ' ') : ''; if (listRef.value) listRef.value.scrollTop = 0;
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 buildTextLines = () => {
const a = document.createElement('a'); return logStore.logs.map((l) => `[${l.time}] [${l.level.toUpperCase()}] [${l.module}] ${l.message}${
a.href = url; l.detail !== undefined ? '\n ' + formatDetailForText(l.detail) : ''
a.download = `qzmusic-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`; }`);
a.click(); };
URL.revokeObjectURL(url);
const formatDetailForText = (d: any): string => {
if (typeof d === 'string') return d;
if (d instanceof Error) return `${d.name}: ${d.message}\n${d.stack || ''}`;
try { return JSON.stringify(d, null, 2); } catch { return String(d); }
};
const exportTxt = () => {
const blob = new Blob([buildTextLines().join('\n')], { type: 'text/plain;charset=utf-8' });
downloadBlob(blob, `qzmusic-logs-${tsFileName()}.txt`);
};
const exportJson = () => {
const data = JSON.stringify(
{
exportAt: new Date().toISOString(),
total: logStore.logs.length,
counts: counts.value,
logs: logStore.logs,
},
(_, v) => (v instanceof Error ? { name: v.name, message: v.message, stack: v.stack } : v),
2,
);
const blob = new Blob([data], { type: 'application/json;charset=utf-8' });
downloadBlob(blob, `qzmusic-logs-${tsFileName()}.json`);
};
const copyAll = async () => {
const text = buildTextLines().join('\n');
await writeToClipboard(text);
};
const copyDetail = async (detail: any) => {
const text = typeof detail === 'string' ? detail : formatDetailForText(detail);
await writeToClipboard(text);
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const tsFileName = () =>
new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
const writeToClipboard = async (text: string) => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
} catch {
// 静默失败
}
}; };
const onClear = () => { const onClear = () => {
if (confirm('确定清空所有日志?')) { if (confirm('确定清空所有日志?')) {
logStore.clear(); logStore.clear();
} forcedExpanded.value.clear();
forcedCollapsed.value.clear();
}
}; };
onMounted(() => { onMounted(() => {
scrollToBottom(); scrollToBottom();
}); });
</script> </script>
<style scoped> <style scoped>
.log-view { .log-view {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
padding: 24px; padding: 24px;
} }
.log-header { .log-header {
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
border-radius: 12px; border-radius: 12px;
padding: 16px 20px; padding: 16px 20px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.title-row { .title-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.title-icon { .title-icon {
font-size: 22px; font-size: 22px;
color: var(--color-accent); color: var(--color-accent);
} }
.title { .title {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: var(--color-text-primary); color: var(--color-text-primary);
margin: 0; margin: 0;
} }
.log-count { .log-count {
font-size: 12px; font-size: 12px;
color: var(--color-text-muted); color: var(--color-text-muted);
margin-left: 8px; margin-left: 8px;
} }
.stats-row { .stats-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.stat { .stat {
font-size: 12px; font-size: 12px;
padding: 4px 10px; padding: 4px 10px;
border-radius: 6px; border-radius: 6px;
font-family: ui-monospace, monospace; font-family: ui-monospace, monospace;
font-weight: 500; font-weight: 500;
} }
.stat.info { background: rgba(59, 130, 246, 0.12); color: #3b82f6; } .stat.info { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
@@ -223,175 +352,176 @@ onMounted(() => {
.stat.debug { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; } .stat.debug { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
.toolbar { .toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
padding-top: 12px; padding-top: 12px;
} }
.filter-group { .filter-group {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.filter-label { .filter-label {
font-size: 12px; font-size: 12px;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.filter-select, .filter-select,
.filter-input { .filter-input {
background: var(--color-bg-primary); background: var(--color-bg-primary);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
color: var(--color-text-primary); color: var(--color-text-primary);
padding: 6px 10px; padding: 6px 10px;
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 12px;
outline: none; outline: none;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.filter-select:focus, .filter-select:focus,
.filter-input:focus { .filter-input:focus {
border-color: var(--color-accent); border-color: var(--color-accent);
} }
.filter-input { .filter-input {
width: 160px; width: 160px;
} }
.action-group { .action-group {
display: flex; display: flex;
gap: 6px; gap: 6px;
flex-wrap: wrap;
} }
.btn { .btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
padding: 8px 12px; padding: 8px 12px;
background: var(--color-bg-primary); background: var(--color-bg-primary);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
color: var(--color-text-secondary); color: var(--color-text-secondary);
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
font-size: 13px; font-size: 12px;
} }
.btn:hover { .btn:hover {
color: var(--color-text-primary); color: var(--color-text-primary);
background: var(--color-bg-tertiary); background: var(--color-bg-tertiary);
border-color: var(--color-text-muted); border-color: var(--color-text-muted);
} }
.btn.danger:hover { .btn.danger:hover {
color: #ef4444; color: #ef4444;
border-color: #ef4444; border-color: #ef4444;
background: rgba(239, 68, 68, 0.08); background: rgba(239, 68, 68, 0.08);
} }
.log-body { .log-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
padding: 8px 0; padding: 8px 0;
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
font-size: 12.5px; font-size: 12.5px;
} }
.log-body::-webkit-scrollbar { .log-body::-webkit-scrollbar {
width: 8px; width: 8px;
} }
.log-body::-webkit-scrollbar-track { .log-body::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.log-body::-webkit-scrollbar-thumb { .log-body::-webkit-scrollbar-thumb {
background: var(--color-border); background: var(--color-border);
border-radius: 4px; border-radius: 4px;
} }
.empty-state { .empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 60px 20px; padding: 60px 20px;
color: var(--color-text-muted); color: var(--color-text-muted);
gap: 12px; gap: 12px;
} }
.empty-icon { .empty-icon {
font-size: 48px; font-size: 48px;
opacity: 0.3; opacity: 0.3;
} }
.empty-text { .empty-text {
font-size: 14px; font-size: 14px;
} }
.log-row { .log-row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 8px 16px; padding: 8px 16px;
cursor: pointer; cursor: pointer;
border-bottom: 1px solid transparent; border-bottom: 1px solid transparent;
transition: background 0.1s; transition: background 0.1s;
} }
.log-row:hover { .log-row:hover {
background: var(--color-bg-tertiary); background: var(--color-bg-tertiary);
} }
.row-main { .row-main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.log-time { .log-time {
color: var(--color-text-muted); color: var(--color-text-muted);
flex-shrink: 0; flex-shrink: 0;
} }
.log-level { .log-level {
font-weight: 700; font-weight: 700;
flex-shrink: 0; flex-shrink: 0;
min-width: 48px; min-width: 48px;
} }
.log-module { .log-module {
color: var(--color-text-muted); color: var(--color-text-muted);
flex-shrink: 0; flex-shrink: 0;
} }
.log-message { .log-message {
color: var(--color-text-primary); color: var(--color-text-primary);
flex: 1; flex: 1;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.expand-icon { .expand-icon {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 14px; font-size: 14px;
transition: transform 0.15s; transition: transform 0.15s;
flex-shrink: 0; flex-shrink: 0;
} }
.expand-icon.expanded { .expand-icon.expanded {
transform: rotate(180deg); transform: rotate(180deg);
} }
.log-row.info .log-level { color: #3b82f6; } .log-row.info .log-level { color: #3b82f6; }
@@ -402,22 +532,79 @@ onMounted(() => {
.log-row.debug .log-level { color: #8b5cf6; } .log-row.debug .log-level { color: #8b5cf6; }
.row-detail { .row-detail {
margin-top: 8px; margin-top: 8px;
padding: 10px 12px; padding: 10px 12px;
background: var(--color-bg-primary); background: var(--color-bg-primary);
border-left: 3px solid var(--color-accent); border-left: 3px solid var(--color-accent);
border-radius: 0 6px 6px 0; border-radius: 0 6px 6px 0;
overflow-x: auto; cursor: default;
cursor: text; max-width: 100%;
} }
.row-detail pre { .detail-image {
margin: 0; max-width: 100%;
font-family: inherit; max-height: 320px;
font-size: 12px; border-radius: 8px;
color: var(--color-text-secondary); display: block;
white-space: pre-wrap; margin: 0 auto;
word-break: break-word; border: 1px solid var(--color-border);
line-height: 1.6; }
.detail-src {
margin-top: 8px;
font-size: 11px;
color: var(--color-text-muted);
text-align: center;
word-break: break-all;
}
.detail-link {
color: var(--color-accent);
font-size: 12px;
text-decoration: none;
word-break: break-all;
}
.detail-link:hover {
text-decoration: underline;
}
.json-block,
.text-block {
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;
max-height: 400px;
overflow: auto;
}
.detail-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
.mini-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
border-radius: 6px;
cursor: pointer;
font-size: 11px;
transition: all 0.15s;
}
.mini-btn:hover {
color: var(--color-text-primary);
background: var(--color-bg-tertiary);
border-color: var(--color-text-muted);
} }
</style> </style>