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 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 = {
|
||||
id: nextId++,
|
||||
time: `${hh}:${mm}:${ss}.${ms}`,
|
||||
level,
|
||||
module,
|
||||
message,
|
||||
detail,
|
||||
detail: normalizedDetail,
|
||||
};
|
||||
|
||||
logs.value.push(entry);
|
||||
|
||||
@@ -46,6 +46,14 @@
|
||||
<Icon :icon="expandedAll ? 'lucide:minimize-2' : 'lucide:maximize-2'" />
|
||||
<span>{{ expandedAll ? '收起详情' : '展开详情' }}</span>
|
||||
</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">
|
||||
<Icon icon="lucide:file-text" />
|
||||
<span>TXT</span>
|
||||
@@ -94,15 +102,24 @@
|
||||
<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 v-else-if="isErrorDetail(entry.detail)">
|
||||
<pre class="text-block">{{ formatErrorDetail(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>
|
||||
<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>
|
||||
</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">
|
||||
<button class="mini-btn" @click.stop="copyDetail(entry.detail)">
|
||||
<Icon icon="lucide:copy" />
|
||||
@@ -149,8 +166,7 @@ const filtered = computed(() => {
|
||||
const isExpanded = (id: number) => {
|
||||
if (forcedExpanded.value.has(id)) return true;
|
||||
if (forcedCollapsed.value.has(id)) return false;
|
||||
const entry = logStore.logs.find((e) => e.id === id);
|
||||
return entry ? entry.level === 'error' : false;
|
||||
return false; // 默认不展开任何条目
|
||||
};
|
||||
|
||||
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/');
|
||||
};
|
||||
|
||||
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) => {
|
||||
try {
|
||||
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 = () => {
|
||||
if (confirm('确定清空所有日志?')) {
|
||||
logStore.clear();
|
||||
@@ -607,4 +746,15 @@ onMounted(() => {
|
||||
background: var(--color-bg-tertiary);
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user