322 lines
9.6 KiB
Vue
322 lines
9.6 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||
import { useViewport } from "@/composables/useViewport";
|
||
import type { SystemLog } from "@/types";
|
||
import { logLevelLabelMap } from "@/types";
|
||
|
||
const AUTO_REFRESH_INTERVAL_MS = 15000;
|
||
|
||
const initialLoading = ref(false);
|
||
const refreshing = ref(false);
|
||
const requestInFlight = ref(false);
|
||
const logs = ref<SystemLog[]>([]);
|
||
const loadError = ref("");
|
||
const { isMobile } = useViewport();
|
||
|
||
const filters = reactive({
|
||
liveRoomId: "",
|
||
level: undefined as number | undefined,
|
||
recordTaskId: "",
|
||
content: "",
|
||
take: 200
|
||
});
|
||
|
||
const logLevelOptions = Object.entries(logLevelLabelMap).map(([value, label]) => ({
|
||
value: Number(value),
|
||
label
|
||
}));
|
||
|
||
const totalWarnings = computed(() => logs.value.filter((item) => item.level === 2).length);
|
||
const totalErrors = computed(() => logs.value.filter((item) => item.level === 3).length);
|
||
const newestLogTime = computed(() => (logs.value[0] ? formatDate(logs.value[0].createdAt) : "-"));
|
||
|
||
let autoRefreshTimer: number | null = null;
|
||
|
||
function mergeLogs(nextLogs: SystemLog[]) {
|
||
const previousById = new Map(logs.value.map((item) => [item.id, item]));
|
||
return nextLogs.map((item) => {
|
||
const previous = previousById.get(item.id);
|
||
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
|
||
});
|
||
}
|
||
|
||
async function loadLogs(background = false) {
|
||
if (requestInFlight.value) {
|
||
return;
|
||
}
|
||
|
||
requestInFlight.value = true;
|
||
initialLoading.value = !background && logs.value.length === 0;
|
||
refreshing.value = !initialLoading.value;
|
||
if (!background) {
|
||
loadError.value = "";
|
||
}
|
||
|
||
try {
|
||
const { data } = await apiClient.get<SystemLog[]>("/logs", {
|
||
params: {
|
||
liveRoomId: filters.liveRoomId || undefined,
|
||
level: typeof filters.level === "number" ? filters.level : undefined,
|
||
recordTaskId: filters.recordTaskId || undefined,
|
||
content: filters.content || undefined,
|
||
take: filters.take
|
||
}
|
||
});
|
||
|
||
logs.value = mergeLogs(data);
|
||
} catch (error) {
|
||
loadError.value = getApiErrorMessage(error, "系统日志加载失败,请稍后重试。");
|
||
} finally {
|
||
requestInFlight.value = false;
|
||
initialLoading.value = false;
|
||
refreshing.value = false;
|
||
}
|
||
}
|
||
|
||
async function autoRefreshLogs() {
|
||
if (document.visibilityState !== "visible") {
|
||
return;
|
||
}
|
||
|
||
await loadLogs(true);
|
||
}
|
||
|
||
function startAutoRefresh() {
|
||
stopAutoRefresh();
|
||
autoRefreshTimer = window.setInterval(() => {
|
||
void autoRefreshLogs();
|
||
}, AUTO_REFRESH_INTERVAL_MS);
|
||
}
|
||
|
||
function stopAutoRefresh() {
|
||
if (autoRefreshTimer !== null) {
|
||
window.clearInterval(autoRefreshTimer);
|
||
autoRefreshTimer = null;
|
||
}
|
||
}
|
||
|
||
function handleVisibilityChange() {
|
||
if (document.visibilityState === "visible") {
|
||
void autoRefreshLogs();
|
||
}
|
||
}
|
||
|
||
function levelTagType(level: number) {
|
||
if (level === 3) {
|
||
return "danger";
|
||
}
|
||
|
||
if (level === 2) {
|
||
return "warning";
|
||
}
|
||
|
||
return "info";
|
||
}
|
||
|
||
function formatDate(value?: string) {
|
||
return value ? new Date(value).toLocaleString() : "-";
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadLogs();
|
||
startAutoRefresh();
|
||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
stopAutoRefresh();
|
||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-stack">
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="page-kicker">排障中心</div>
|
||
<h1 class="page-title">系统日志</h1>
|
||
<p class="page-subtitle">
|
||
面向排障的原始事件流。这里保留最近发生的关键日志,可以按直播间、任务和级别快速缩小范围。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="page-toolbar">
|
||
<el-button :loading="refreshing" @click="loadLogs(false)">刷新日志</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-card__label">当前加载</div>
|
||
<div class="stat-card__value">{{ logs.length }}</div>
|
||
<div class="stat-card__hint">按当前筛选条件拉取到的日志条目。</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-card__label">警告</div>
|
||
<div class="stat-card__value">{{ totalWarnings }}</div>
|
||
<div class="stat-card__hint">用于快速观察近期是否出现成批预警。</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-card__label">错误</div>
|
||
<div class="stat-card__value">{{ totalErrors }}</div>
|
||
<div class="stat-card__hint">优先定位对录制流程有破坏性的异常事件。</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-card__label">最新事件</div>
|
||
<div class="stat-card__value" style="font-size: 18px;">{{ newestLogTime }}</div>
|
||
<div class="stat-card__hint">默认按时间倒序,优先展示最近发生的事件。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-card class="surface-card filter-card" shadow="never">
|
||
<div class="toolbar-row">
|
||
<div>
|
||
<h3 class="section-title">日志过滤</h3>
|
||
<p class="section-subtitle">默认拉取最近 200 条日志,移动端也保留同一套过滤维度。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<el-form class="filter-form">
|
||
<el-form-item label="直播间 ID">
|
||
<el-input v-model="filters.liveRoomId" placeholder="可选" />
|
||
</el-form-item>
|
||
<el-form-item label="日志级别">
|
||
<el-select v-model="filters.level" clearable placeholder="全部级别">
|
||
<el-option
|
||
v-for="option in logLevelOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="任务 ID">
|
||
<el-input v-model="filters.recordTaskId" placeholder="可选" />
|
||
</el-form-item>
|
||
<el-form-item label="内容关键词">
|
||
<el-input v-model="filters.content" placeholder="按消息或详情内容搜索" />
|
||
</el-form-item>
|
||
<el-form-item label="数量">
|
||
<el-input-number v-model="filters.take" :min="1" :max="500" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="loadLogs(false)">应用筛选</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
|
||
<el-card class="surface-card table-card" shadow="never">
|
||
<div class="toolbar-row">
|
||
<div>
|
||
<h3 class="section-title">日志列表</h3>
|
||
<p class="section-subtitle">页面会自动刷新,移动端改成事件卡片,桌面端保留高密度表格。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="isMobile" class="data-card-list">
|
||
<article v-for="row in logs" :key="row.id" class="data-card">
|
||
<div class="data-card__header">
|
||
<div>
|
||
<div class="data-card__title">{{ row.message }}</div>
|
||
<div class="data-card__subtitle">{{ row.category }}</div>
|
||
</div>
|
||
<el-tag :type="levelTagType(row.level)">{{ logLevelLabelMap[row.level] }}</el-tag>
|
||
</div>
|
||
|
||
<div class="data-card__grid">
|
||
<div>
|
||
<dt>时间</dt>
|
||
<dd>{{ formatDate(row.createdAt) }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>任务</dt>
|
||
<dd class="monospace">{{ row.recordTaskId || "-" }}</dd>
|
||
</div>
|
||
<div style="grid-column: 1 / -1;">
|
||
<dt>详情</dt>
|
||
<dd class="log-detail monospace">{{ row.detail || "-" }}</dd>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-else class="table-scroll-shell logs-table-shell">
|
||
<el-table
|
||
:data="logs"
|
||
v-loading="initialLoading"
|
||
class="premium-table logs-table"
|
||
table-layout="auto"
|
||
>
|
||
<el-table-column label="级别" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag :type="levelTagType(row.level)">
|
||
{{ logLevelLabelMap[row.level] }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="分类" width="128" prop="category" />
|
||
<el-table-column label="消息" min-width="240" prop="message" />
|
||
<el-table-column label="详情" min-width="360">
|
||
<template #default="{ row }">
|
||
<span class="monospace log-detail">{{ row.detail || "-" }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="时间" width="180">
|
||
<template #default="{ row }">
|
||
<span class="table-date-text">{{ formatDate(row.createdAt) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.filter-form {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 8px 12px;
|
||
align-items: end;
|
||
}
|
||
|
||
.logs-table-shell {
|
||
margin-inline: -4px;
|
||
padding-inline: 4px;
|
||
}
|
||
|
||
.logs-table :deep(.el-table__cell) {
|
||
vertical-align: top;
|
||
}
|
||
|
||
.logs-table :deep(.cell) {
|
||
overflow: visible;
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.filter-form {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.filter-form {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 767px) {
|
||
.filter-form {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.filter-form :deep(.el-button) {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|