Files
QZMusic-Web/src/views/LogView.vue

761 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="scrollToTop()" title="滚到顶部">
<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="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>
</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 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: isExpanded(entry.id) }"
/>
</div>
<div v-if="entry.detail !== undefined && isExpanded(entry.id)" class="row-detail" @click.stop>
<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="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-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" />
<span>复制</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, nextTick, onMounted } 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 expandedAll = ref(false);
const forcedExpanded = ref<Set<number>>(new Set());
const forcedCollapsed = 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 isExpanded = (id: number) => {
if (forcedExpanded.value.has(id)) return true;
if (forcedCollapsed.value.has(id)) return false;
return false; // 默认不展开任何条目
};
const toggleExpand = (id: number) => {
const currentlyExpanded = isExpanded(id);
if (currentlyExpanded) {
forcedCollapsed.value.add(id);
forcedExpanded.value.delete(id);
} else {
forcedExpanded.value.add(id);
forcedCollapsed.value.delete(id);
}
};
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 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);
} catch {
return String(val);
}
};
const scrollToBottom = () => {
nextTick(() => {
if (listRef.value) listRef.value.scrollTop = listRef.value.scrollHeight;
});
};
const scrollToTop = () => {
nextTick(() => {
if (listRef.value) listRef.value.scrollTop = 0;
});
};
const buildTextLines = () => {
return logStore.logs.map((l) => `[${l.time}] [${l.level.toUpperCase()}] [${l.module}] ${l.message}${
l.detail !== undefined ? '\n ' + formatDetailForText(l.detail) : ''
}`);
};
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 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const onClear = () => {
if (confirm('确定清空所有日志?')) {
logStore.clear();
forcedExpanded.value.clear();
forcedCollapsed.value.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;
flex-wrap: wrap;
}
.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: 12px;
}
.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;
cursor: default;
max-width: 100%;
}
.detail-image {
max-width: 100%;
max-height: 320px;
border-radius: 8px;
display: block;
margin: 0 auto;
border: 1px solid var(--color-border);
}
.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);
}
/* 打印时隐藏工具栏和按钮,图片完整展开 */
@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>