430 lines
14 KiB
Vue
430 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { ref, reactive, onMounted, h } from 'vue'
|
|
import { Message, Modal, Tag, Button, Space } from '@arco-design/web-vue'
|
|
import type { TableColumnData } from '@arco-design/web-vue'
|
|
import PageHeader from '@/components/PageHeader.vue'
|
|
import TableStats from '@/components/TableStats.vue'
|
|
import request from '@/utils/request'
|
|
import { hasPermission } from '@/utils/permission'
|
|
|
|
const loading = ref(false)
|
|
const data = ref<any[]>([])
|
|
const total = ref(0)
|
|
const pagination = reactive({ current: 1, pageSize: 20 })
|
|
|
|
const filterSource = ref('')
|
|
const filterErrorCode = ref<number | undefined>(undefined)
|
|
const filterResolved = ref('')
|
|
const filterDateRange = ref<string[]>([])
|
|
const filterKeyword = ref('')
|
|
|
|
// Stats
|
|
const stats = ref<any>(null)
|
|
const statsLoading = ref(false)
|
|
|
|
// Detail drawer
|
|
const detailVisible = ref(false)
|
|
const detailData = ref<any>(null)
|
|
const detailLoading = ref(false)
|
|
|
|
// Batch resolve
|
|
const selectedKeys = ref<number[]>([])
|
|
|
|
const sourceColorMap: Record<string, string> = {
|
|
api: 'arcoblue',
|
|
panic: 'red',
|
|
cron: 'orange',
|
|
background: 'purple',
|
|
}
|
|
const sourceLabelMap: Record<string, string> = {
|
|
api: 'API',
|
|
panic: 'Panic',
|
|
cron: '定时任务',
|
|
background: '后台任务',
|
|
}
|
|
|
|
const columns: TableColumnData[] = [
|
|
{
|
|
title: 'ID',
|
|
dataIndex: 'id',
|
|
width: 60,
|
|
},
|
|
{
|
|
title: '来源',
|
|
dataIndex: 'source',
|
|
width: 100,
|
|
render({ record }: { record: any }) {
|
|
return h(Tag, { color: sourceColorMap[record.source] || 'blue', size: 'small' }, () => sourceLabelMap[record.source] || record.source)
|
|
},
|
|
},
|
|
{
|
|
title: '错误码',
|
|
dataIndex: 'error_code',
|
|
width: 80,
|
|
render({ record }: { record: any }) {
|
|
const color = record.error_code >= 500 ? 'red' : 'orange'
|
|
return h(Tag, { color, size: 'small' }, () => String(record.error_code))
|
|
},
|
|
},
|
|
{
|
|
title: '错误消息',
|
|
dataIndex: 'error_message',
|
|
ellipsis: true,
|
|
width: 260,
|
|
},
|
|
{
|
|
title: '请求路径',
|
|
dataIndex: 'request_path',
|
|
ellipsis: true,
|
|
width: 180,
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'resolved',
|
|
width: 80,
|
|
render({ record }: { record: any }) {
|
|
if (record.resolved) {
|
|
return h(Tag, { color: 'green', size: 'small' }, () => '已处理')
|
|
}
|
|
return h(Tag, { color: 'red', size: 'small' }, () => '未处理')
|
|
},
|
|
},
|
|
{
|
|
title: '时间',
|
|
dataIndex: 'created_at',
|
|
width: 160,
|
|
render({ record }: { record: any }) {
|
|
return new Date(record.created_at).toLocaleString('zh-CN')
|
|
},
|
|
},
|
|
{
|
|
title: '操作',
|
|
width: 120,
|
|
fixed: 'right',
|
|
render({ record }: { record: any }) {
|
|
const buttons: any[] = []
|
|
buttons.push(h(Button, { size: 'small', type: 'text', onClick: () => openDetail(record.id) }, () => '详情'))
|
|
if (!record.resolved) {
|
|
buttons.push(h(Button, { size: 'small', type: 'text', status: 'success', onClick: () => resolveSingle(record.id) }, () => '处理'))
|
|
}
|
|
return h(Space, { size: 4 }, () => buttons)
|
|
},
|
|
},
|
|
]
|
|
|
|
async function fetchData() {
|
|
loading.value = true
|
|
try {
|
|
const params: any = {
|
|
page: pagination.current,
|
|
page_size: pagination.pageSize,
|
|
}
|
|
if (filterSource.value) params.source = filterSource.value
|
|
if (filterErrorCode.value) params.error_code = filterErrorCode.value
|
|
if (filterResolved.value) params.resolved = filterResolved.value
|
|
if (filterKeyword.value) params.keyword = filterKeyword.value
|
|
if (filterDateRange.value?.length === 2) {
|
|
params.start_date = filterDateRange.value[0]
|
|
params.end_date = filterDateRange.value[1]
|
|
}
|
|
const res = await request.get('/admin/error-logs', { params })
|
|
data.value = res.data?.data?.list || []
|
|
total.value = res.data?.data?.total || 0
|
|
} catch {
|
|
// error auto-shown
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function fetchStats() {
|
|
statsLoading.value = true
|
|
try {
|
|
const res = await request.get('/admin/error-logs/stats')
|
|
stats.value = res.data?.data?.data
|
|
} catch {
|
|
// error auto-shown
|
|
} finally {
|
|
statsLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function openDetail(id: number) {
|
|
detailVisible.value = true
|
|
detailLoading.value = true
|
|
try {
|
|
const res = await request.get(`/admin/error-logs/${id}`)
|
|
detailData.value = res.data?.data
|
|
} catch {
|
|
// error auto-shown
|
|
} finally {
|
|
detailLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function resolveSingle(id: number) {
|
|
try {
|
|
await request.put(`/admin/error-logs/${id}/resolve`)
|
|
Message.success('已标记为已处理')
|
|
fetchData()
|
|
fetchStats()
|
|
} catch {
|
|
// error auto-shown
|
|
}
|
|
}
|
|
|
|
async function batchResolve() {
|
|
if (!selectedKeys.value.length) {
|
|
Message.warning('请先选择要处理的记录')
|
|
return
|
|
}
|
|
try {
|
|
await request.put('/admin/error-logs/batch-resolve', { ids: selectedKeys.value })
|
|
Message.success(`已处理 ${selectedKeys.value.length} 条记录`)
|
|
selectedKeys.value = []
|
|
fetchData()
|
|
fetchStats()
|
|
} catch {
|
|
// error auto-shown
|
|
}
|
|
}
|
|
|
|
// 清空全部错误日志(硬删除):系统报错大量堆积时快速释放数据库空间,需二次确认。
|
|
function clearLogs() {
|
|
Modal.confirm({
|
|
title: '清空全部错误日志',
|
|
content: '将硬删除全部错误日志记录,此操作不可恢复。系统报错频繁时可使用该操作快速释放数据库空间,确定继续?',
|
|
okText: '清空',
|
|
okButtonProps: { status: 'danger' },
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
try {
|
|
await request.delete('/admin/error-logs/clear')
|
|
Message.success('错误日志已清空')
|
|
selectedKeys.value = []
|
|
fetchData()
|
|
fetchStats()
|
|
} catch {
|
|
// error auto-shown by Axios interceptor
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
function handleFilter() {
|
|
pagination.current = 1
|
|
fetchData()
|
|
}
|
|
|
|
function resetFilter() {
|
|
filterSource.value = ''
|
|
filterErrorCode.value = undefined
|
|
filterResolved.value = ''
|
|
filterDateRange.value = []
|
|
filterKeyword.value = ''
|
|
pagination.current = 1
|
|
fetchData()
|
|
}
|
|
|
|
function handlePageChange(page: number) {
|
|
pagination.current = page
|
|
fetchData()
|
|
}
|
|
|
|
function handleSelection(keys: number[]) {
|
|
selectedKeys.value = keys
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchData()
|
|
fetchStats()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page-table">
|
|
<PageHeader title="系统错误日志" description="查看和管理系统运行中的错误记录">
|
|
<template #actions>
|
|
<a-button
|
|
v-if="selectedKeys.length"
|
|
type="primary"
|
|
status="success"
|
|
@click="batchResolve"
|
|
>
|
|
批量处理 ({{ selectedKeys.length }})
|
|
</a-button>
|
|
<a-button v-if="hasPermission('monitor:edit')" status="danger" @click="clearLogs">
|
|
清空日志
|
|
</a-button>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<!-- Stats Cards -->
|
|
<div v-if="stats" class="stats-row">
|
|
<a-card :bordered="false" class="stat-card">
|
|
<a-statistic title="未处理错误" :value="stats.unresolved" :value-style="{ color: stats.unresolved > 0 ? '#f53f3f' : '#00b42a' }" />
|
|
</a-card>
|
|
<a-card :bordered="false" class="stat-card">
|
|
<a-statistic title="今日新增" :value="stats.today_count" />
|
|
</a-card>
|
|
<a-card :bordered="false" class="stat-card">
|
|
<div class="stat-title">来源分布(近7天)</div>
|
|
<div class="stat-tags">
|
|
<Tag v-for="item in stats.by_source || []" :key="item.source" :color="sourceColorMap[item.source] || 'blue'" size="small">
|
|
{{ sourceLabelMap[item.source] || item.source }}: {{ item.count }}
|
|
</Tag>
|
|
<span v-if="!stats.by_source?.length" class="stat-empty">暂无数据</span>
|
|
</div>
|
|
</a-card>
|
|
<a-card :bordered="false" class="stat-card">
|
|
<div class="stat-title">错误码 Top(近7天)</div>
|
|
<div class="stat-tags">
|
|
<Tag v-for="item in stats.by_error_code || []" :key="item.error_code" color="orangered" size="small">
|
|
{{ item.error_code }}: {{ item.count }}次
|
|
</Tag>
|
|
<span v-if="!stats.by_error_code?.length" class="stat-empty">暂无数据</span>
|
|
</div>
|
|
</a-card>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<a-card :bordered="false" class="mb-4">
|
|
<a-space wrap>
|
|
<a-select v-model="filterSource" placeholder="错误来源" allow-clear style="width: 130px">
|
|
<a-option value="api">API</a-option>
|
|
<a-option value="panic">Panic</a-option>
|
|
<a-option value="cron">定时任务</a-option>
|
|
<a-option value="background">后台任务</a-option>
|
|
</a-select>
|
|
<a-input-number v-model="filterErrorCode" placeholder="错误码" allow-clear style="width: 120px" :min="1" />
|
|
<a-select v-model="filterResolved" placeholder="处理状态" allow-clear style="width: 120px">
|
|
<a-option value="false">未处理</a-option>
|
|
<a-option value="true">已处理</a-option>
|
|
</a-select>
|
|
<a-range-picker v-model="filterDateRange" style="width: 260px" />
|
|
<a-input v-model="filterKeyword" placeholder="搜索错误消息/路径" allow-clear style="width: 200px" />
|
|
<a-button type="primary" @click="handleFilter">搜索</a-button>
|
|
<a-button @click="resetFilter">重置</a-button>
|
|
</a-space>
|
|
</a-card>
|
|
|
|
<!-- Table -->
|
|
<a-card :bordered="false">
|
|
<TableStats :total="total" />
|
|
<a-table
|
|
:data="data"
|
|
:columns="columns"
|
|
:loading="loading"
|
|
:pagination="false"
|
|
:bordered="false"
|
|
:stripe="true"
|
|
row-key="id"
|
|
:row-selection="{ type: 'checkbox', showCheckedAll: true, selectedRowKeys: selectedKeys, onSelect: handleSelection }"
|
|
/>
|
|
<div class="table-footer">
|
|
<a-pagination
|
|
:current="pagination.current"
|
|
:page-size="pagination.pageSize"
|
|
:total="total"
|
|
show-page-size
|
|
:page-size-options="[10, 20, 50]"
|
|
@change="handlePageChange"
|
|
@page-size-change="(ps: number) => { pagination.pageSize = ps; pagination.current = 1; fetchData() }"
|
|
/>
|
|
</div>
|
|
</a-card>
|
|
|
|
<!-- Detail Drawer -->
|
|
<a-drawer
|
|
v-model:visible="detailVisible"
|
|
title="错误详情"
|
|
:width="640"
|
|
:footer="false"
|
|
>
|
|
<a-spin :loading="detailLoading" style="width: 100%">
|
|
<template v-if="detailData">
|
|
<a-descriptions :column="2" bordered size="medium">
|
|
<a-descriptions-item label="ID">{{ detailData.id }}</a-descriptions-item>
|
|
<a-descriptions-item label="Request ID">
|
|
<a-typography-text copyable code style="font-size: 12px">{{ detailData.request_id || '-' }}</a-typography-text>
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="来源">
|
|
<Tag :color="sourceColorMap[detailData.source] || 'blue'" size="small">
|
|
{{ sourceLabelMap[detailData.source] || detailData.source }}
|
|
</Tag>
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="错误码">
|
|
<Tag :color="detailData.error_code >= 500 ? 'red' : 'orange'" size="small">
|
|
{{ detailData.error_code }}
|
|
</Tag>
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="请求方法">{{ detailData.http_method || '-' }}</a-descriptions-item>
|
|
<a-descriptions-item label="状态">
|
|
<Tag :color="detailData.resolved ? 'green' : 'red'" size="small">
|
|
{{ detailData.resolved ? '已处理' : '未处理' }}
|
|
</Tag>
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="请求路径" :span="2">{{ detailData.request_path || '-' }}</a-descriptions-item>
|
|
<a-descriptions-item label="错误消息" :span="2">
|
|
<div style="color: #f53f3f; word-break: break-all">{{ detailData.error_message }}</div>
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="请求体" :span="2">
|
|
<pre v-if="detailData.request_body" class="stack-trace">{{ detailData.request_body }}</pre>
|
|
<span v-else>-</span>
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="堆栈跟踪" :span="2">
|
|
<pre v-if="detailData.stack_trace" class="stack-trace">{{ detailData.stack_trace }}</pre>
|
|
<span v-else>-</span>
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="发生时间">{{ detailData.created_at ? new Date(detailData.created_at).toLocaleString('zh-CN') : '-' }}</a-descriptions-item>
|
|
<a-descriptions-item label="处理时间">{{ detailData.resolved_at ? new Date(detailData.resolved_at).toLocaleString('zh-CN') : '-' }}</a-descriptions-item>
|
|
</a-descriptions>
|
|
|
|
<div v-if="!detailData.resolved" style="margin-top: 16px; text-align: right">
|
|
<a-button type="primary" status="success" @click="resolveSingle(detailData.id); detailVisible = false">
|
|
标记为已处理
|
|
</a-button>
|
|
</div>
|
|
</template>
|
|
</a-spin>
|
|
</a-drawer>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.mb-4 { margin-bottom: 16px; }
|
|
|
|
.stats-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.stat-card { text-align: center; }
|
|
.stat-title {
|
|
font-size: 12px;
|
|
color: var(--color-text-3);
|
|
margin-bottom: 8px;
|
|
}
|
|
.stat-tags { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
|
|
.stat-empty { color: var(--color-text-4); font-size: 12px; }
|
|
|
|
.stack-trace {
|
|
background: #1e293b;
|
|
color: #e2e8f0;
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
margin: 0;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
|
}
|
|
</style>
|