303 lines
8.4 KiB
Vue
303 lines
8.4 KiB
Vue
<template>
|
|
<div class="logs-page">
|
|
<!-- Stats row -->
|
|
<div class="stats-row animate-in">
|
|
<div class="stat-card">
|
|
<div class="stat-icon" style="background: rgba(99,102,241,0.1)">
|
|
<el-icon :size="20" color="#6366f1"><Notebook /></el-icon>
|
|
</div>
|
|
<div class="stat-info">
|
|
<span class="stat-value">{{ logStats.today_count }}</span>
|
|
<span class="stat-label">今日请求</span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon" style="background: rgba(239,68,68,0.1)">
|
|
<el-icon :size="20" color="#ef4444"><Warning /></el-icon>
|
|
</div>
|
|
<div class="stat-info">
|
|
<span class="stat-value">{{ logStats.error_count }}</span>
|
|
<span class="stat-label">错误数</span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon" style="background: rgba(16,185,129,0.1)">
|
|
<el-icon :size="20" color="#10b981"><Timer /></el-icon>
|
|
</div>
|
|
<div class="stat-info">
|
|
<span class="stat-value">{{ logStats.avg_duration_ms }}ms</span>
|
|
<span class="stat-label">平均耗时</span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
|
|
<el-icon :size="20" color="#f59e0b"><Warning /></el-icon>
|
|
</div>
|
|
<div class="stat-info">
|
|
<span class="stat-value">{{ logStats.error_rate }}%</span>
|
|
<span class="stat-label">错误率</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main table card -->
|
|
<div class="card animate-in animate-in-delay-1">
|
|
<div class="card-head">
|
|
<h3>请求日志</h3>
|
|
<div class="card-actions">
|
|
<el-select v-model="filterMethod" placeholder="方法" clearable size="small" style="width: 100px" @change="loadData">
|
|
<el-option label="全部" value="" />
|
|
<el-option label="GET" value="GET" />
|
|
<el-option label="POST" value="POST" />
|
|
<el-option label="PUT" value="PUT" />
|
|
<el-option label="DELETE" value="DELETE" />
|
|
</el-select>
|
|
<el-select v-model="filterStatus" placeholder="状态码" clearable size="small" style="width: 100px" @change="loadData">
|
|
<el-option label="全部" value="" />
|
|
<el-option label="2xx" value="200" />
|
|
<el-option label="4xx" value="400" />
|
|
<el-option label="5xx" value="500" />
|
|
</el-select>
|
|
<el-input v-model="searchPath" placeholder="搜索路径..." clearable size="small" style="width: 180px" @keyup.enter="loadData" @clear="loadData">
|
|
<template #prefix><el-icon><Search /></el-icon></template>
|
|
</el-input>
|
|
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<el-table :data="items" v-loading="loading" stripe max-height="500" size="small" class="log-table">
|
|
<el-table-column prop="timestamp" label="时间" width="170">
|
|
<template #default="{ row }">
|
|
<span class="time-cell">{{ formatTime(row.timestamp) }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="method" label="方法" width="80">
|
|
<template #default="{ row }">
|
|
<span class="method-tag" :class="row.method.toLowerCase()">{{ row.method }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="path" label="路径" min-width="250" show-overflow-tooltip />
|
|
<el-table-column prop="status_code" label="状态码" width="80">
|
|
<template #default="{ row }">
|
|
<span class="status-code" :class="statusCls(row.status_code)">{{ row.status_code }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="duration_ms" label="耗时" width="90">
|
|
<template #default="{ row }">
|
|
<span class="duration-cell" :class="{ slow: row.duration_ms > 1000 }">{{ row.duration_ms?.toFixed(0) }}ms</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="user" label="用户" width="80" />
|
|
</el-table>
|
|
|
|
<div class="pagination-bar">
|
|
<span class="pagination-info">共 {{ total }} 条记录</span>
|
|
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="loadData" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, onMounted } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { Notebook, Warning, Timer, Search, Refresh } from '@element-plus/icons-vue'
|
|
import api from '../api'
|
|
|
|
const loading = ref(false)
|
|
const searchPath = ref('')
|
|
const filterMethod = ref('')
|
|
const filterStatus = ref('')
|
|
const items = ref<any[]>([])
|
|
const page = ref(1)
|
|
const pageSize = ref(50)
|
|
const total = ref(0)
|
|
|
|
const logStats = reactive({ today_count: 0, error_count: 0, avg_duration_ms: 0, error_rate: 0 })
|
|
|
|
function formatTime(iso: string) {
|
|
if (!iso) return '-'
|
|
const d = new Date(iso)
|
|
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
}
|
|
|
|
function statusCls(code: number) {
|
|
if (code >= 500) return 's5xx'
|
|
if (code >= 400) return 's4xx'
|
|
return 's2xx'
|
|
}
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
const params: any = { page: page.value, page_size: pageSize.value }
|
|
if (filterMethod.value) params.method = filterMethod.value
|
|
if (filterStatus.value) params.status_code = parseInt(filterStatus.value)
|
|
if (searchPath.value) params.path = searchPath.value
|
|
const res = await api.get('/logs', { params })
|
|
items.value = res.data.items
|
|
total.value = res.data.total
|
|
} catch {
|
|
ElMessage.error('加载日志失败')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const res = await api.get('/logs/stats')
|
|
Object.assign(logStats, res.data)
|
|
} catch {}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadData()
|
|
loadStats()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.logs-page {
|
|
width: 100%;
|
|
}
|
|
|
|
.stats-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.stat-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 18px 20px;
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border-light);
|
|
transition: all 0.2s var(--ease-out);
|
|
}
|
|
|
|
.stat-card:hover {
|
|
box-shadow: var(--shadow-md);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.stat-icon {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.stat-value {
|
|
display: block;
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
line-height: 1;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.card {
|
|
background: #fff;
|
|
border: 1px solid var(--border-light);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
transition: box-shadow 0.2s;
|
|
}
|
|
|
|
.card:hover {
|
|
box-shadow: var(--shadow-md);
|
|
}
|
|
|
|
.card-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.card-head h3 {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.card-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.log-table {
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.time-cell {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.method-tag {
|
|
display: inline-block;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.method-tag.get { background: rgba(16,185,129,0.1); color: #10b981; }
|
|
.method-tag.post { background: rgba(99,102,241,0.1); color: #6366f1; }
|
|
.method-tag.put { background: rgba(245,158,11,0.1); color: #f59e0b; }
|
|
.method-tag.delete { background: rgba(239,68,68,0.1); color: #ef4444; }
|
|
|
|
.status-code {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.status-code.s2xx { color: #10b981; }
|
|
.status-code.s4xx { color: #f59e0b; }
|
|
.status-code.s5xx { color: #ef4444; }
|
|
|
|
.duration-cell {
|
|
font-size: 12px;
|
|
font-family: var(--font-mono);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.duration-cell.slow {
|
|
color: #ef4444;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.pagination-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-top: 16px;
|
|
padding-top: 12px;
|
|
border-top: 1px solid var(--border-subtle);
|
|
}
|
|
|
|
.pagination-info {
|
|
font-size: 13px;
|
|
color: var(--text-muted);
|
|
}
|
|
</style>
|