feat(frontend): migrate runtime log page and restore build

Move the runtime log screen into the new pages structure, add compact page navigation, and apply the minimal component fixes needed to keep the refactored frontend buildable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 22:16:19 +08:00
parent b024a2bcb5
commit a9ddf3c9b4
4 changed files with 776 additions and 525 deletions

View File

@@ -0,0 +1,760 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { logApi, type Log, type LogQueryParams, type LogStats } from '@/api/log'
import { Terminal, RefreshCw, Bot, MessageSquare, Settings, AlertCircle, ChevronDown, ChevronRight } from 'lucide-vue-next'
type TimePreset = '1h' | '6h' | '24h' | '7d' | 'custom'
interface LogFilters {
preset: TimePreset
start_at: string
end_at: string
request_id: string
route: string
operation: string
status_code: string
log_type: string
level: string
page: number
page_size: number
}
const logs = ref<Log[]>([])
const stats = ref<LogStats | null>(null)
const loading = ref(false)
const errorMessage = ref('')
const expandedLogId = ref<string | null>(null)
const autoRefresh = ref(false)
let refreshInterval: ReturnType<typeof window.setInterval> | null = null
const pageSizeOptions = [20, 50, 100]
const timePresets: Array<{ value: TimePreset; label: string }> = [
{ value: '1h', label: '1h' },
{ value: '6h', label: '6h' },
{ value: '24h', label: '24h' },
{ value: '7d', label: '7d' },
{ value: 'custom', label: '自定义' },
]
const logTypes = [
{ value: '', label: '全部', icon: null },
{ value: 'agent', label: '智能体', icon: Bot },
{ value: 'system', label: '系统', icon: Settings },
{ value: 'chat', label: '问答', icon: MessageSquare },
]
const logLevels = [
{ value: '', label: '全部' },
{ value: 'debug', label: '调试' },
{ value: 'info', label: '信息' },
{ value: 'warning', label: '警告' },
{ value: 'error', label: '错误' },
]
const statusCodeOptions = [
{ value: '', label: '全部状态' },
{ value: '200', label: '200' },
{ value: '400', label: '400' },
{ value: '401', label: '401' },
{ value: '404', label: '404' },
{ value: '422', label: '422' },
{ value: '500', label: '500' },
]
const levelColors: Record<string, string> = {
debug: 'var(--text-dim)',
info: 'var(--accent-cyan)',
warning: 'var(--accent-amber)',
error: 'var(--accent-red)',
}
const typeLabels: Record<string, string> = {
agent: '智能体',
system: '系统',
chat: '问答',
}
function toDatetimeLocalValue(date: Date): string {
const offsetMs = date.getTimezoneOffset() * 60 * 1000
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
}
function getPresetRange(preset: Exclude<TimePreset, 'custom'>) {
const end = new Date()
const start = new Date(end)
const hours = preset === '1h' ? 1 : preset === '6h' ? 6 : preset === '24h' ? 24 : 24 * 7
start.setHours(start.getHours() - hours)
return {
start_at: toDatetimeLocalValue(start),
end_at: toDatetimeLocalValue(end),
}
}
function createDefaultFilters(): LogFilters {
return {
preset: '24h',
...getPresetRange('24h'),
request_id: '',
route: '',
operation: '',
status_code: '',
log_type: '',
level: '',
page: 1,
page_size: 50,
}
}
const filters = ref<LogFilters>(createDefaultFilters())
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / filters.value.page_size)))
const visiblePageNumbers = computed(() => {
const totalPageCount = totalPages.value
const currentPage = filters.value.page
if (totalPageCount <= 5) {
return Array.from({ length: totalPageCount }, (_, index) => index + 1)
}
let startPage = Math.max(1, currentPage - 2)
let endPage = Math.min(totalPageCount, startPage + 4)
if (endPage - startPage < 4) {
startPage = Math.max(1, endPage - 4)
}
return Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index)
})
const total = ref(0)
function formatTime(dateStr: string | null): string {
if (!dateStr) return '-'
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return dateStr
return d.toLocaleString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
month: '2-digit',
day: '2-digit',
})
}
function formatDuration(ms: number | null): string {
if (ms == null) return ''
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function formatDetails(details: Record<string, unknown> | null): string {
if (!details) return ''
return JSON.stringify(details, null, 2)
}
function toggleDetails(logId: string) {
expandedLogId.value = expandedLogId.value === logId ? null : logId
}
function normalizeFilters(): LogQueryParams {
return {
log_type: filters.value.log_type || undefined,
level: filters.value.level || undefined,
request_id: filters.value.request_id.trim() || undefined,
route: filters.value.route.trim() || undefined,
operation: filters.value.operation.trim() || undefined,
status_code: filters.value.status_code ? Number(filters.value.status_code) : undefined,
start_at: filters.value.start_at ? new Date(filters.value.start_at).toISOString() : undefined,
end_at: filters.value.end_at ? new Date(filters.value.end_at).toISOString() : undefined,
page: filters.value.page,
page_size: filters.value.page_size,
}
}
function validateDateRange() {
if (filters.value.start_at && filters.value.end_at && filters.value.start_at > filters.value.end_at) {
errorMessage.value = '开始时间不能晚于结束时间'
return false
}
return true
}
async function fetchLogs() {
loading.value = true
errorMessage.value = ''
try {
const requestedPage = filters.value.page
const res = await logApi.list(normalizeFilters())
logs.value = res.data.logs
total.value = res.data.total
const maxPage = Math.max(1, Math.ceil(res.data.total / filters.value.page_size))
if (requestedPage > maxPage) {
filters.value.page = maxPage
if (maxPage !== requestedPage) {
await fetchLogs()
}
return
}
} catch (e: unknown) {
errorMessage.value = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '加载日志失败'
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
const res = await logApi.getStats(normalizeFilters())
stats.value = res.data
} catch {
if (!errorMessage.value) {
errorMessage.value = '加载日志统计失败'
}
}
}
async function loadData() {
if (!validateDateRange()) return
await Promise.all([fetchLogs(), fetchStats()])
}
function applyFilters() {
filters.value.page = 1
loadData()
}
function resetFilters() {
filters.value = createDefaultFilters()
loadData()
}
function applyPreset(preset: TimePreset) {
filters.value.preset = preset
if (preset !== 'custom') {
const range = getPresetRange(preset)
filters.value.start_at = range.start_at
filters.value.end_at = range.end_at
}
}
function markCustomRange() {
filters.value.preset = 'custom'
}
function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value
if (autoRefresh.value) {
refreshInterval = window.setInterval(() => {
loadData()
}, 5000)
} else if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
}
function goToPage(page: number) {
if (page < 1 || page > totalPages.value || page === filters.value.page) {
return
}
filters.value.page = page
fetchLogs()
}
function prevPage() {
goToPage(filters.value.page - 1)
}
function nextPage() {
goToPage(filters.value.page + 1)
}
function changePageSize(size: number) {
filters.value.page_size = size
filters.value.page = 1
loadData()
}
onMounted(loadData)
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script>
<template>
<div class="log-view">
<div class="view-header">
<div class="header-title">
<Terminal :size="16" />
<span>运行日志</span>
</div>
<div class="header-actions">
<button class="btn-icon" :class="{ active: autoRefresh }" @click="toggleAutoRefresh" title="自动刷新">
<RefreshCw :size="14" :class="{ spinning: autoRefresh }" />
</button>
<button class="btn-icon" @click="loadData" title="刷新">
<RefreshCw :size="14" />
</button>
</div>
</div>
<div v-if="stats" class="stats-overview">
<div class="stat-card">
<div class="stat-label">总计</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<div class="stat-card">
<div class="stat-label">智能体</div>
<div class="stat-value cyan">{{ stats.by_type.agent }}</div>
</div>
<div class="stat-card">
<div class="stat-label">系统</div>
<div class="stat-value purple">{{ stats.by_type.system }}</div>
</div>
<div class="stat-card">
<div class="stat-label">问答</div>
<div class="stat-value amber">{{ stats.by_type.chat }}</div>
</div>
<div class="stat-card">
<div class="stat-label">错误</div>
<div class="stat-value red">{{ stats.by_level.error }}</div>
</div>
</div>
<div class="filters">
<div class="toolbar-row presets-row">
<button
v-for="preset in timePresets"
:key="preset.value"
class="filter-btn"
:class="{ active: filters.preset === preset.value }"
@click="applyPreset(preset.value)"
>
{{ preset.label }}
</button>
</div>
<div class="toolbar-row fields-row">
<input v-model="filters.start_at" class="filter-input" type="datetime-local" @input="markCustomRange" />
<input v-model="filters.end_at" class="filter-input" type="datetime-local" @input="markCustomRange" />
<input v-model="filters.request_id" class="filter-input" type="text" placeholder="Request ID" />
<input v-model="filters.route" class="filter-input" type="text" placeholder="Route" />
<input v-model="filters.operation" class="filter-input" type="text" placeholder="Operation" />
<select v-model="filters.status_code" class="filter-select">
<option v-for="option in statusCodeOptions" :key="option.label" :value="option.value">{{ option.label }}</option>
</select>
<select v-model="filters.log_type" class="filter-select">
<option v-for="option in logTypes" :key="option.label" :value="option.value">{{ option.label }}</option>
</select>
<select v-model="filters.level" class="filter-select">
<option v-for="option in logLevels" :key="option.label" :value="option.value">{{ option.label }}</option>
</select>
<button class="action-btn primary" @click="applyFilters">查询</button>
<button class="action-btn" @click="resetFilters">重置</button>
</div>
</div>
<div class="log-list">
<div v-if="loading && logs.length === 0" class="loading-state">
<RefreshCw :size="20" class="spinning" />
<span>加载中...</span>
</div>
<div v-else-if="errorMessage" class="empty-state error-state">
<AlertCircle :size="32" />
<span>{{ errorMessage }}</span>
</div>
<div v-else-if="logs.length === 0" class="empty-state">
<Terminal :size="32" />
<span>暂无日志</span>
</div>
<div v-else class="log-items">
<div v-for="log in logs" :key="log.id" class="log-item">
<div class="log-summary" @click="toggleDetails(log.id)">
<div class="log-meta">
<span class="log-type" :class="log.type">{{ typeLabels[log.type] || log.type }}</span>
<span class="log-level" :style="{ color: levelColors[log.level] }">{{ log.level }}</span>
<span v-if="log.duration_ms !== null" class="log-duration">{{ formatDuration(log.duration_ms) }}</span>
<span v-if="log.status_code" class="log-status">HTTP {{ log.status_code }}</span>
</div>
<div class="log-message">{{ log.message }}</div>
<div class="log-footer">
<span v-if="log.source" class="log-source">{{ log.source }}</span>
<span v-if="log.operation" class="log-operation">{{ log.operation }}</span>
<span class="log-time">{{ formatTime(log.created_at) }}</span>
<span class="log-expand">
<ChevronDown v-if="expandedLogId === log.id" :size="14" />
<ChevronRight v-else :size="14" />
</span>
</div>
</div>
<div v-if="expandedLogId === log.id" class="log-details">
<div v-if="log.request_id" class="detail-row"><strong>Request ID:</strong> {{ log.request_id }}</div>
<div v-if="log.route || log.method" class="detail-row"><strong>Route:</strong> {{ log.method || '-' }} {{ log.route || '-' }}</div>
<div v-if="log.error_type" class="detail-row"><strong>Error:</strong> {{ log.error_type }}</div>
<pre v-if="log.details" class="detail-json">{{ formatDetails(log.details) }}</pre>
</div>
</div>
</div>
</div>
<div class="pagination">
<div class="page-summary"> {{ total }} · {{ filters.page }} / {{ totalPages }} </div>
<div class="page-controls">
<button class="page-btn" :disabled="filters.page === 1" @click="prevPage">上一页</button>
<button
v-for="page in visiblePageNumbers"
:key="page"
class="page-btn page-number"
:class="{ active: page === filters.page }"
@click="goToPage(page)"
>
{{ page }}
</button>
<button class="page-btn" :disabled="filters.page >= totalPages" @click="nextPage">下一页</button>
<select class="filter-select page-size-select" :value="filters.page_size" @change="changePageSize(Number(($event.target as HTMLSelectElement).value))">
<option v-for="size in pageSizeOptions" :key="size" :value="size">{{ size }}/</option>
</select>
</div>
</div>
</div>
</template>
<style scoped>
.log-view {
height: 100%;
display: flex;
flex-direction: column;
padding: 24px;
background: var(--bg-void);
}
.view-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
}
.header-actions {
display: flex;
gap: 8px;
}
.btn-icon {
padding: 8px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-icon:hover, .btn-icon.active {
background: var(--accent-cyan-dim);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.btn-icon .spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.stats-overview {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
flex: 1;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 12px 16px;
text-align: center;
}
.stat-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--text-dim);
text-transform: uppercase;
margin-bottom: 4px;
}
.stat-value {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.stat-value.cyan { color: var(--accent-cyan); }
.stat-value.purple { color: var(--accent-purple); }
.stat-value.amber { color: var(--accent-amber); }
.stat-value.red { color: var(--accent-red); }
.filters {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
}
.toolbar-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.filter-btn,
.filter-input,
.filter-select,
.action-btn,
.page-btn {
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
transition: all var(--transition-fast);
}
.filter-btn,
.action-btn,
.page-btn {
padding: 6px 12px;
cursor: pointer;
}
.filter-btn.active,
.filter-btn:hover,
.action-btn:hover,
.page-btn:hover:not(:disabled),
.page-btn.active,
.filter-input:focus,
.filter-select:focus {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
outline: none;
}
.filter-btn.active,
.action-btn.primary,
.page-btn.active {
background: var(--accent-cyan-dim);
}
.filter-input,
.filter-select {
min-width: 120px;
padding: 7px 10px;
}
.filter-input {
color-scheme: dark;
}
.action-btn {
background: var(--bg-void);
}
.log-list {
flex: 1;
overflow-y: auto;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 60px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
.log-items {
padding: 8px;
}
.log-item {
border-bottom: 1px solid var(--border-dim);
transition: background var(--transition-fast);
}
.log-summary {
padding: 12px;
cursor: pointer;
}
.log-item:last-child {
border-bottom: none;
}
.log-item:hover {
background: rgba(0, 245, 212, 0.02);
}
.log-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.log-type {
font-family: var(--font-mono);
font-size: 9px;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
}
.log-type.agent {
background: rgba(0, 245, 212, 0.1);
color: var(--accent-cyan);
}
.log-type.system {
background: rgba(168, 85, 247, 0.1);
color: var(--accent-purple);
}
.log-type.chat {
background: rgba(249, 168, 37, 0.1);
color: var(--accent-amber);
}
.log-level {
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
}
.log-duration {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-muted);
}
.log-message {
font-size: 13px;
color: var(--text-primary);
line-height: 1.5;
margin-bottom: 6px;
}
.log-footer {
display: flex;
justify-content: space-between;
gap: 12px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
}
.log-status,
.log-operation,
.log-expand {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
}
.log-details {
padding: 0 12px 12px;
border-top: 1px solid var(--border-dim);
background: rgba(255, 255, 255, 0.02);
}
.detail-row {
margin-top: 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.detail-json {
margin-top: 10px;
padding: 10px;
background: var(--bg-void);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
overflow-x: auto;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-primary);
white-space: pre-wrap;
}
.error-state {
color: var(--accent-red);
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-top: 16px;
}
.page-summary {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}
.page-controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.page-number {
min-width: 36px;
padding-inline: 0;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-size-select {
min-width: 90px;
}
</style>