feat(log): 默认不展开详情 + Error详情规范化 + 打印/HTML导出图片
This commit is contained in:
@@ -52,13 +52,37 @@ export const useLogStore = defineStore('log', () => {
|
|||||||
const ss = now.getSeconds().toString().padStart(2, '0');
|
const ss = now.getSeconds().toString().padStart(2, '0');
|
||||||
const ms = now.getMilliseconds().toString().padStart(3, '0');
|
const ms = now.getMilliseconds().toString().padStart(3, '0');
|
||||||
|
|
||||||
|
// Error 对象属性不可枚举(JSON.stringify 会得到 {}),这里先规范化
|
||||||
|
let normalizedDetail = detail;
|
||||||
|
if (detail instanceof Error) {
|
||||||
|
normalizedDetail = {
|
||||||
|
name: detail.name,
|
||||||
|
message: detail.message,
|
||||||
|
stack: detail.stack,
|
||||||
|
cause: (detail as any).cause,
|
||||||
|
__isError: true,
|
||||||
|
};
|
||||||
|
} else if (detail && typeof detail === 'object') {
|
||||||
|
// 避免 Proxy 等响应式包装的干扰 — 简单浅拷贝为纯对象
|
||||||
|
try {
|
||||||
|
normalizedDetail = JSON.parse(JSON.stringify(detail, (_, v) => {
|
||||||
|
if (v instanceof Error) {
|
||||||
|
return { name: v.name, message: v.message, stack: v.stack, __isError: true };
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
normalizedDetail = String(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const entry: LogEntry = {
|
const entry: LogEntry = {
|
||||||
id: nextId++,
|
id: nextId++,
|
||||||
time: `${hh}:${mm}:${ss}.${ms}`,
|
time: `${hh}:${mm}:${ss}.${ms}`,
|
||||||
level,
|
level,
|
||||||
module,
|
module,
|
||||||
message,
|
message,
|
||||||
detail,
|
detail: normalizedDetail,
|
||||||
};
|
};
|
||||||
|
|
||||||
logs.value.push(entry);
|
logs.value.push(entry);
|
||||||
|
|||||||
@@ -46,6 +46,14 @@
|
|||||||
<Icon :icon="expandedAll ? 'lucide:minimize-2' : 'lucide:maximize-2'" />
|
<Icon :icon="expandedAll ? 'lucide:minimize-2' : 'lucide:maximize-2'" />
|
||||||
<span>{{ expandedAll ? '收起详情' : '展开详情' }}</span>
|
<span>{{ expandedAll ? '收起详情' : '展开详情' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn" @click="printLogs()" title="打印/导出图片">
|
||||||
|
<Icon icon="lucide:printer" />
|
||||||
|
<span>打印图片</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn" @click="exportHtml()" title="导出 HTML(含图片)">
|
||||||
|
<Icon icon="lucide:file-image" />
|
||||||
|
<span>HTML</span>
|
||||||
|
</button>
|
||||||
<button class="btn" @click="exportTxt()" title="导出 TXT">
|
<button class="btn" @click="exportTxt()" title="导出 TXT">
|
||||||
<Icon icon="lucide:file-text" />
|
<Icon icon="lucide:file-text" />
|
||||||
<span>TXT</span>
|
<span>TXT</span>
|
||||||
@@ -94,15 +102,24 @@
|
|||||||
<img :src="String(entry.detail)" class="detail-image" alt="图片日志" loading="lazy" />
|
<img :src="String(entry.detail)" class="detail-image" alt="图片日志" loading="lazy" />
|
||||||
<div class="detail-src">{{ String(entry.detail) }}</div>
|
<div class="detail-src">{{ String(entry.detail) }}</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="Array.isArray(entry.detail) || typeof entry.detail === 'object'">
|
<template v-else-if="isErrorDetail(entry.detail)">
|
||||||
<pre class="json-block">{{ formatJson(entry.detail) }}</pre>
|
<pre class="text-block">{{ formatErrorDetail(entry.detail) }}</pre>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="typeof entry.detail === 'string' && (entry.detail.startsWith('http:') || entry.detail.startsWith('https:'))">
|
<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>
|
<a :href="entry.detail" target="_blank" rel="noopener" class="detail-link">{{ entry.detail }}</a>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else-if="typeof entry.detail === 'string'">
|
||||||
|
<pre class="text-block">{{ entry.detail }}</pre>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="typeof entry.detail === 'number' || typeof entry.detail === 'boolean'">
|
||||||
<pre class="text-block">{{ String(entry.detail) }}</pre>
|
<pre class="text-block">{{ String(entry.detail) }}</pre>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="typeof entry.detail === 'object' && entry.detail !== null">
|
||||||
|
<pre class="json-block">{{ formatJson(entry.detail) }}</pre>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<pre class="text-block">{{ formatDetailForText(entry.detail) }}</pre>
|
||||||
|
</template>
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
<button class="mini-btn" @click.stop="copyDetail(entry.detail)">
|
<button class="mini-btn" @click.stop="copyDetail(entry.detail)">
|
||||||
<Icon icon="lucide:copy" />
|
<Icon icon="lucide:copy" />
|
||||||
@@ -149,8 +166,7 @@ const filtered = computed(() => {
|
|||||||
const isExpanded = (id: number) => {
|
const isExpanded = (id: number) => {
|
||||||
if (forcedExpanded.value.has(id)) return true;
|
if (forcedExpanded.value.has(id)) return true;
|
||||||
if (forcedCollapsed.value.has(id)) return false;
|
if (forcedCollapsed.value.has(id)) return false;
|
||||||
const entry = logStore.logs.find((e) => e.id === id);
|
return false; // 默认不展开任何条目
|
||||||
return entry ? entry.level === 'error' : false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleExpand = (id: number) => {
|
const toggleExpand = (id: number) => {
|
||||||
@@ -182,6 +198,27 @@ const isImageUrl = (detail: any) => {
|
|||||||
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(lower) || lower.startsWith('data:image/');
|
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(lower) || lower.startsWith('data:image/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isErrorDetail = (detail: any) => {
|
||||||
|
if (!detail || typeof detail !== 'object') return false;
|
||||||
|
return (detail as any).__isError === true || (detail as any).stack || (detail as any).message;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatErrorDetail = (detail: any): string => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (detail.name) parts.push(`${detail.name}: ${detail.message || '(no message)'}`);
|
||||||
|
else if (detail.message) parts.push(detail.message);
|
||||||
|
if (detail.stack) parts.push(detail.stack);
|
||||||
|
if (detail.cause !== undefined) parts.push(`\nCause: ${formatDetailForText(detail.cause)}`);
|
||||||
|
// 把其他自定义字段也显示出来
|
||||||
|
const otherKeys = Object.keys(detail).filter((k) => !['name', 'message', 'stack', 'cause', '__isError'].includes(k));
|
||||||
|
if (otherKeys.length > 0) {
|
||||||
|
const rest: Record<string, any> = {};
|
||||||
|
for (const k of otherKeys) rest[k] = (detail as any)[k];
|
||||||
|
parts.push(`\nExtra: ${formatJson(rest)}`);
|
||||||
|
}
|
||||||
|
return parts.join('\n') || String(detail);
|
||||||
|
};
|
||||||
|
|
||||||
const formatJson = (val: any) => {
|
const formatJson = (val: any) => {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(val, null, 2);
|
return JSON.stringify(val, null, 2);
|
||||||
@@ -275,6 +312,108 @@ const writeToClipboard = async (text: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 打印/导出图片:先展开所有条目(优先展开有图片的),然后等待图片加载完再打印
|
||||||
|
const printLogs = () => {
|
||||||
|
// 1. 展开所有带 detail 的条目
|
||||||
|
for (const e of logStore.logs) {
|
||||||
|
if (e.detail !== undefined) forcedExpanded.value.add(e.id);
|
||||||
|
}
|
||||||
|
forcedCollapsed.value.clear();
|
||||||
|
expandedAll.value = true;
|
||||||
|
|
||||||
|
// 2. 等 Vue 渲染完,再给图片 1.5 秒加载时间,然后触发打印
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出完整 HTML(含图片渲染),保存为文件后双击打开即可打印/截图
|
||||||
|
const exportHtml = () => {
|
||||||
|
const rowsHtml = logStore.logs.map((e) => {
|
||||||
|
const levelCls = e.level;
|
||||||
|
let detailHtml = '';
|
||||||
|
if (e.detail !== undefined) {
|
||||||
|
if (isImageUrl(e.detail)) {
|
||||||
|
detailHtml = `
|
||||||
|
<div class="row-detail">
|
||||||
|
<img src="${String(e.detail)}" class="detail-image" alt="图片日志" />
|
||||||
|
<div class="detail-src">${String(e.detail)}</div>
|
||||||
|
</div>`;
|
||||||
|
} else if (isErrorDetail(e.detail)) {
|
||||||
|
detailHtml = `<div class="row-detail"><pre>${escapeHtml(formatErrorDetail(e.detail))}</pre></div>`;
|
||||||
|
} else if (typeof e.detail === 'string' && (e.detail.startsWith('http:') || e.detail.startsWith('https:'))) {
|
||||||
|
detailHtml = `<div class="row-detail"><a href="${e.detail}" target="_blank">${e.detail}</a></div>`;
|
||||||
|
} else if (typeof e.detail === 'string') {
|
||||||
|
detailHtml = `<div class="row-detail"><pre>${escapeHtml(e.detail)}</pre></div>`;
|
||||||
|
} else if (typeof e.detail === 'object' && e.detail !== null) {
|
||||||
|
detailHtml = `<div class="row-detail"><pre>${escapeHtml(formatJson(e.detail))}</pre></div>`;
|
||||||
|
} else {
|
||||||
|
detailHtml = `<div class="row-detail"><pre>${escapeHtml(formatDetailForText(e.detail))}</pre></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="log-row ${levelCls}">
|
||||||
|
<span class="log-time">${e.time}</span>
|
||||||
|
<span class="log-level">${e.level.toUpperCase()}</span>
|
||||||
|
<span class="log-module">[${e.module}]</span>
|
||||||
|
<span class="log-message">${escapeHtml(e.message)}</span>
|
||||||
|
${detailHtml}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>QZ Music 运行日志 - 导出 ${new Date().toISOString()}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; background: #fff; color: #222; padding: 24px; }
|
||||||
|
h1 { font-size: 20px; margin-bottom: 4px; }
|
||||||
|
.sub { color: #666; font-size: 12px; margin-bottom: 20px; }
|
||||||
|
.log-row { display: flex; gap: 10px; padding: 10px 12px; border-bottom: 1px solid #eee; flex-wrap: wrap; align-items: start; }
|
||||||
|
.log-row.error { background: #fff5f5; }
|
||||||
|
.log-row.warn { background: #fffbeb; }
|
||||||
|
.log-level { font-weight: 700; min-width: 52px; }
|
||||||
|
.log-row.info .log-level { color: #3b82f6; }
|
||||||
|
.log-row.warn .log-level { color: #f59e0b; }
|
||||||
|
.log-row.error .log-level { color: #ef4444; }
|
||||||
|
.log-row.debug .log-level { color: #8b5cf6; }
|
||||||
|
.log-module { color: #666; }
|
||||||
|
.log-time { color: #888; font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
.log-message { flex: 1; min-width: 200px; }
|
||||||
|
.row-detail { margin-top: 8px; width: 100%; padding: 8px 12px; background: #f5f5f5; border-left: 3px solid #3b82f6; border-radius: 0 4px 4px 0; }
|
||||||
|
.detail-image { max-width: 100%; max-height: 600px; border-radius: 4px; display: block; margin: 0 auto; border: 1px solid #ddd; }
|
||||||
|
.detail-src { text-align: center; color: #888; font-size: 11px; margin-top: 4px; word-break: break-all; }
|
||||||
|
.row-detail pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, monospace; font-size: 12px; color: #333; }
|
||||||
|
.row-detail a { color: #2563eb; text-decoration: none; }
|
||||||
|
@media print {
|
||||||
|
body { padding: 12px; }
|
||||||
|
.log-row { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.row-detail { break-inside: avoid; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>QZ Music 运行日志</h1>
|
||||||
|
<div class="sub">共 ${logStore.logs.length} 条 · 导出时间 ${new Date().toLocaleString()}</div>
|
||||||
|
${rowsHtml}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||||
|
downloadBlob(blob, `qzmusic-logs-${tsFileName()}.html`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// HTML 转义
|
||||||
|
const escapeHtml = (s: string) => String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
const onClear = () => {
|
const onClear = () => {
|
||||||
if (confirm('确定清空所有日志?')) {
|
if (confirm('确定清空所有日志?')) {
|
||||||
logStore.clear();
|
logStore.clear();
|
||||||
@@ -607,4 +746,15 @@ onMounted(() => {
|
|||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
border-color: var(--color-text-muted);
|
border-color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 打印时隐藏工具栏和按钮,图片完整展开 */
|
||||||
|
@media print {
|
||||||
|
.action-group, .filter-group, .toolbar, .detail-actions, .mini-btn { display: none !important; }
|
||||||
|
.log-view { padding: 0; }
|
||||||
|
.log-header { border: none; background: transparent; padding: 0 0 12px 0; }
|
||||||
|
.log-body { background: transparent; border: none; padding: 0; }
|
||||||
|
.log-row { break-inside: avoid; page-break-inside: avoid; background: transparent !important; border-bottom: 1px solid #ddd; }
|
||||||
|
.row-detail { break-inside: avoid; background: #f5f5f5 !important; }
|
||||||
|
.detail-image { max-width: 100%; max-height: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user