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

761 lines
22 KiB
Vue
Raw Normal View History

<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>