2026-06-14 00:29:58 +00:00
|
|
|
<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>
|
2026-06-14 00:54:26 +00:00
|
|
|
<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="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" />
|
2026-06-14 00:29:58 +00:00
|
|
|
</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>
|
2026-06-14 00:54:26 +00:00
|
|
|
<Icon
|
|
|
|
|
v-if="entry.detail !== undefined"
|
|
|
|
|
icon="lucide:chevron-down"
|
|
|
|
|
class="expand-icon"
|
|
|
|
|
:class="{ expanded: isExpanded(entry.id) }"
|
|
|
|
|
/>
|
2026-06-14 00:29:58 +00:00
|
|
|
</div>
|
2026-06-14 00:54:26 +00:00
|
|
|
<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="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>
|
2026-06-14 00:29:58 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-06-14 00:54:26 +00:00
|
|
|
import { computed, ref, nextTick, onMounted } from 'vue';
|
2026-06-14 00:29:58 +00:00
|
|
|
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);
|
2026-06-14 00:54:26 +00:00
|
|
|
const expandedAll = ref(false);
|
|
|
|
|
const forcedExpanded = ref<Set<number>>(new Set());
|
|
|
|
|
const forcedCollapsed = ref<Set<number>>(new Set());
|
2026-06-14 00:29:58 +00:00
|
|
|
|
|
|
|
|
const modules = computed(() => logStore.moduleList());
|
|
|
|
|
const counts = computed(() => logStore.countByLevel());
|
|
|
|
|
|
|
|
|
|
const filtered = computed(() => {
|
2026-06-14 00:54:26 +00:00
|
|
|
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;
|
|
|
|
|
});
|
2026-06-14 00:29:58 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-14 00:54:26 +00:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-14 00:29:58 +00:00
|
|
|
const toggleExpand = (id: number) => {
|
2026-06-14 00:54:26 +00:00
|
|
|
const currentlyExpanded = isExpanded(id);
|
|
|
|
|
if (currentlyExpanded) {
|
|
|
|
|
forcedCollapsed.value.add(id);
|
|
|
|
|
forcedExpanded.value.delete(id);
|
|
|
|
|
} else {
|
|
|
|
|
forcedExpanded.value.add(id);
|
|
|
|
|
forcedCollapsed.value.delete(id);
|
|
|
|
|
}
|
2026-06-14 00:29:58 +00:00
|
|
|
};
|
|
|
|
|
|
2026-06-14 00:54:26 +00:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-14 00:29:58 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const scrollToBottom = () => {
|
2026-06-14 00:54:26 +00:00
|
|
|
nextTick(() => {
|
|
|
|
|
if (listRef.value) listRef.value.scrollTop = listRef.value.scrollHeight;
|
|
|
|
|
});
|
2026-06-14 00:29:58 +00:00
|
|
|
};
|
|
|
|
|
|
2026-06-14 00:54:26 +00:00
|
|
|
const scrollToTop = () => {
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
if (listRef.value) listRef.value.scrollTop = 0;
|
|
|
|
|
});
|
2026-06-14 00:29:58 +00:00
|
|
|
};
|
|
|
|
|
|
2026-06-14 00:54:26 +00:00
|
|
|
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);
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
2026-06-14 00:54:26 +00:00
|
|
|
} catch {
|
|
|
|
|
// 静默失败
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onClear = () => {
|
|
|
|
|
if (confirm('确定清空所有日志?')) {
|
|
|
|
|
logStore.clear();
|
|
|
|
|
forcedExpanded.value.clear();
|
|
|
|
|
forcedCollapsed.value.clear();
|
|
|
|
|
}
|
2026-06-14 00:29:58 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
2026-06-14 00:54:26 +00:00
|
|
|
scrollToBottom();
|
2026-06-14 00:29:58 +00:00
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.log-view {
|
2026-06-14 00:54:26 +00:00
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
padding: 24px;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-header {
|
2026-06-14 00:54:26 +00:00
|
|
|
background: var(--color-bg-secondary);
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
padding: 16px 20px;
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 12px;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.title-row {
|
2026-06-14 00:54:26 +00:00
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 12px;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.title-icon {
|
2026-06-14 00:54:26 +00:00
|
|
|
font-size: 22px;
|
|
|
|
|
color: var(--color-accent);
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.title {
|
2026-06-14 00:54:26 +00:00
|
|
|
font-size: 20px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
margin: 0;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-count {
|
2026-06-14 00:54:26 +00:00
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
margin-left: 8px;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stats-row {
|
2026-06-14 00:54:26 +00:00
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat {
|
2026-06-14 00:54:26 +00:00
|
|
|
font-size: 12px;
|
|
|
|
|
padding: 4px 10px;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
font-family: ui-monospace, monospace;
|
|
|
|
|
font-weight: 500;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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 {
|
2026-06-14 00:54:26 +00:00
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
border-top: 1px solid var(--color-border);
|
|
|
|
|
padding-top: 12px;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.filter-group {
|
2026-06-14 00:54:26 +00:00
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.filter-label {
|
2026-06-14 00:54:26 +00:00
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--color-text-muted);
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.filter-select,
|
|
|
|
|
.filter-input {
|
2026-06-14 00:54:26 +00:00
|
|
|
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;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.filter-select:focus,
|
|
|
|
|
.filter-input:focus {
|
2026-06-14 00:54:26 +00:00
|
|
|
border-color: var(--color-accent);
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.filter-input {
|
2026-06-14 00:54:26 +00:00
|
|
|
width: 160px;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.action-group {
|
2026-06-14 00:54:26 +00:00
|
|
|
display: flex;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
flex-wrap: wrap;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.btn {
|
2026-06-14 00:54:26 +00:00
|
|
|
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;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.btn:hover {
|
2026-06-14 00:54:26 +00:00
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
background: var(--color-bg-tertiary);
|
|
|
|
|
border-color: var(--color-text-muted);
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.btn.danger:hover {
|
2026-06-14 00:54:26 +00:00
|
|
|
color: #ef4444;
|
|
|
|
|
border-color: #ef4444;
|
|
|
|
|
background: rgba(239, 68, 68, 0.08);
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-body {
|
2026-06-14 00:54:26 +00:00
|
|
|
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;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-body::-webkit-scrollbar {
|
2026-06-14 00:54:26 +00:00
|
|
|
width: 8px;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-body::-webkit-scrollbar-track {
|
2026-06-14 00:54:26 +00:00
|
|
|
background: transparent;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-body::-webkit-scrollbar-thumb {
|
2026-06-14 00:54:26 +00:00
|
|
|
background: var(--color-border);
|
|
|
|
|
border-radius: 4px;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.empty-state {
|
2026-06-14 00:54:26 +00:00
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
padding: 60px 20px;
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
gap: 12px;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.empty-icon {
|
2026-06-14 00:54:26 +00:00
|
|
|
font-size: 48px;
|
|
|
|
|
opacity: 0.3;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.empty-text {
|
2026-06-14 00:54:26 +00:00
|
|
|
font-size: 14px;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-row {
|
2026-06-14 00:54:26 +00:00
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
border-bottom: 1px solid transparent;
|
|
|
|
|
transition: background 0.1s;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-row:hover {
|
2026-06-14 00:54:26 +00:00
|
|
|
background: var(--color-bg-tertiary);
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.row-main {
|
2026-06-14 00:54:26 +00:00
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
flex-wrap: wrap;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-time {
|
2026-06-14 00:54:26 +00:00
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
flex-shrink: 0;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-level {
|
2026-06-14 00:54:26 +00:00
|
|
|
font-weight: 700;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
min-width: 48px;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-module {
|
2026-06-14 00:54:26 +00:00
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
flex-shrink: 0;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-message {
|
2026-06-14 00:54:26 +00:00
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
flex: 1;
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
word-break: break-word;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.expand-icon {
|
2026-06-14 00:54:26 +00:00
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
transition: transform 0.15s;
|
|
|
|
|
flex-shrink: 0;
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.expand-icon.expanded {
|
2026-06-14 00:54:26 +00:00
|
|
|
transform: rotate(180deg);
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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 {
|
2026-06-14 00:54:26 +00:00
|
|
|
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);
|
2026-06-14 00:29:58 +00:00
|
|
|
}
|
|
|
|
|
</style>
|