Files
gpt-load/web/src/components/logs/LogTable.vue
2025-07-12 16:20:56 +08:00

366 lines
9.6 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.

<script setup lang="ts">
import { logApi } from "@/api/logs";
import type { LogFilter, RequestLog } from "@/types/models";
import { maskKey } from "@/utils/display";
import { EyeOffOutline, EyeOutline, Search } from "@vicons/ionicons5";
import {
NButton,
NDataTable,
NDatePicker,
NEllipsis,
NEmpty,
NIcon,
NInput,
NInputGroup,
NSelect,
NSpace,
NSpin,
NTag,
} from "naive-ui";
import { computed, h, onMounted, reactive, ref, watch } from "vue";
interface LogRow extends RequestLog {
is_key_visible: boolean;
}
// Data
const loading = ref(false);
const logs = ref<LogRow[]>([]);
const currentPage = ref(1);
const pageSize = ref(15);
const total = ref(0);
const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
// Filters
const filters = reactive({
group_name: "",
key_value: "",
is_success: "" as "true" | "false" | "",
status_code: "",
source_ip: "",
error_contains: "",
start_time: null as number | null,
end_time: null as number | null,
});
const successOptions = [
{ label: "全部状态", value: "" },
{ label: "成功", value: "true" },
{ label: "失败", value: "false" },
];
// Fetch data
const loadLogs = async () => {
loading.value = true;
try {
const params: LogFilter = {
page: currentPage.value,
page_size: pageSize.value,
group_name: filters.group_name || undefined,
key_value: filters.key_value || undefined,
is_success: filters.is_success === "" ? undefined : filters.is_success === "true",
status_code: filters.status_code ? parseInt(filters.status_code, 10) : undefined,
source_ip: filters.source_ip || undefined,
error_contains: filters.error_contains || undefined,
start_time: filters.start_time ? new Date(filters.start_time).toISOString() : undefined,
end_time: filters.end_time ? new Date(filters.end_time).toISOString() : undefined,
};
const res = await logApi.getLogs(params);
if (res.code === 0 && res.data) {
logs.value = res.data.items.map(log => ({ ...log, is_key_visible: false }));
total.value = res.data.pagination.total_items;
} else {
logs.value = [];
total.value = 0;
window.$message.error(res.message || "加载日志失败");
}
} catch (_error) {
window.$message.error("加载日志请求失败");
} finally {
loading.value = false;
}
};
const formatDateTime = (timestamp: string) => {
if (!timestamp) {
return "-";
}
const date = new Date(timestamp);
return date.toLocaleString("zh-CN", { hour12: false }).replace(/\//g, "-");
};
const toggleKeyVisibility = (row: LogRow) => {
row.is_key_visible = !row.is_key_visible;
};
// Columns definition
const createColumns = () => [
{
title: "时间",
key: "timestamp",
width: 180,
render: (row: LogRow) => formatDateTime(row.timestamp),
},
{
title: "状态",
key: "is_success",
width: 80,
render: (row: LogRow) =>
h(
NTag,
{ type: row.is_success ? "success" : "error", size: "small", round: true },
{ default: () => (row.is_success ? "成功" : "失败") }
),
},
{ title: "状态码", key: "status_code", width: 80 },
{ title: "耗时(ms)", key: "duration_ms", width: 100 },
{ title: "分组", key: "group_name", width: 120 },
{
title: "Key",
key: "key_value",
width: 200,
render: (row: LogRow) =>
h(NSpace, { align: "center", wrap: false }, () => [
h(
NEllipsis,
{ style: "max-width: 150px" },
{ default: () => (row.is_key_visible ? row.key_value : maskKey(row.key_value || "")) }
),
h(
NButton,
{ size: "tiny", text: true, onClick: () => toggleKeyVisibility(row) },
{
icon: () =>
h(NIcon, null, { default: () => h(row.is_key_visible ? EyeOffOutline : EyeOutline) }),
}
),
]),
},
{
title: "请求路径",
key: "request_path",
render: (row: LogRow) =>
h(NEllipsis, { style: "max-width: 200px" }, { default: () => row.request_path }),
},
{ title: "源IP", key: "source_ip", width: 130 },
{
title: "错误信息",
key: "error_message",
render: (row: LogRow) =>
h(NEllipsis, { style: "max-width: 250px" }, { default: () => row.error_message || "-" }),
},
];
const columns = createColumns();
// Lifecycle and Watchers
onMounted(loadLogs);
watch([currentPage, pageSize], loadLogs);
const handleSearch = () => {
currentPage.value = 1;
loadLogs();
};
const resetFilters = () => {
filters.group_name = "";
filters.key_value = "";
filters.is_success = "";
filters.status_code = "";
filters.source_ip = "";
filters.error_contains = "";
filters.start_time = null;
filters.end_time = null;
handleSearch();
};
function changePage(page: number) {
currentPage.value = page;
}
function changePageSize(size: number) {
pageSize.value = size;
currentPage.value = 1;
}
</script>
<template>
<div class="log-table-container">
<n-space vertical>
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<n-space>
<n-select
v-model:value="filters.is_success"
:options="successOptions"
size="small"
style="width: 120px"
@update:value="handleSearch"
/>
<n-date-picker
v-model:value="filters.start_time"
type="datetime"
clearable
size="small"
placeholder="开始时间"
style="width: 180px"
/>
<n-date-picker
v-model:value="filters.end_time"
type="datetime"
clearable
size="small"
placeholder="结束时间"
style="width: 180px"
/>
</n-space>
</div>
<div class="toolbar-right">
<n-space>
<n-input
v-model:value="filters.group_name"
placeholder="分组名"
size="small"
clearable
style="width: 120px"
@keyup.enter="handleSearch"
/>
<n-input
v-model:value="filters.key_value"
placeholder="Key 片段"
size="small"
clearable
style="width: 150px"
@keyup.enter="handleSearch"
/>
<n-input-group>
<n-input
v-model:value="filters.error_contains"
placeholder="错误信息"
size="small"
clearable
style="width: 150px"
@keyup.enter="handleSearch"
/>
<n-button ghost size="small" :disabled="loading" @click="handleSearch">
<n-icon :component="Search" />
</n-button>
</n-input-group>
<n-button size="small" @click="resetFilters">重置</n-button>
</n-space>
</div>
</div>
<div class="table-main">
<!-- 表格 -->
<div class="table-container">
<n-spin :show="loading">
<n-data-table :columns="columns" :data="logs" :bordered="false" remote size="small" />
<div v-if="logs.length === 0 && !loading" class="empty-container">
<n-empty description="没有找到匹配的日志" />
</div>
</n-spin>
</div>
<!-- 分页 -->
<div class="pagination-container">
<div class="pagination-info">
<span> {{ total }} 条记录</span>
<n-select
v-model:value="pageSize"
:options="[
{ label: '15条/页', value: 15 },
{ label: '30条/页', value: 30 },
{ label: '50条/页', value: 50 },
{ label: '100条/页', value: 100 },
]"
size="small"
style="width: 100px; margin-left: 12px"
@update:value="changePageSize"
/>
</div>
<div class="pagination-controls">
<n-button
size="small"
:disabled="currentPage <= 1"
@click="changePage(currentPage - 1)"
>
上一页
</n-button>
<span class="page-info"> {{ currentPage }} {{ totalPages }} </span>
<n-button
size="small"
:disabled="currentPage >= totalPages"
@click="changePage(currentPage + 1)"
>
下一页
</n-button>
</div>
</div>
</div>
</n-space>
</div>
</template>
<style scoped>
.log-table-container {
/* background: white; */
/* border-radius: 8px; */
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
/* height: 100%; */
}
.toolbar {
background: white;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid #f0f0f0;
}
.table-main {
background: white;
border-radius: 8px;
overflow: hidden;
}
.table-container {
/* background: white;
border-radius: 8px; */
flex: 1;
overflow: hidden;
position: relative;
}
.empty-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.pagination-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-top: 1px solid #f0f0f0;
}
.pagination-info {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: #666;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 12px;
}
.page-info {
font-size: 13px;
color: #666;
}
</style>