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

@@ -1,15 +1,15 @@
<script setup lang="ts">
import { ref } from 'vue'
import { type FolderTree } from '@/api/folder'
import { type FolderTree as FolderTreeNode } from '@/api/folder'
import { Folder, FolderOpen, ChevronRight, Plus, Edit2, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
folders: FolderTree[]
folders: FolderTreeNode[]
selectedId?: string | null
onSelect: (folder: FolderTree) => void
onSelect: (folder: FolderTreeNode) => void
onCreate: (parentId: string | null) => void
onRename: (folder: FolderTree) => void
onDelete: (folder: FolderTree) => void
onRename: (folder: FolderTreeNode) => void
onDelete: (folder: FolderTreeNode) => void
}>()
const expandedIds = ref<Set<string>>(new Set())
@@ -22,7 +22,7 @@ function toggleExpand(id: string) {
}
}
function handleContextMenu(e: MouseEvent, folder: FolderTree) {
function handleContextMenu(e: MouseEvent, _folder: FolderTreeNode) {
e.preventDefault()
// 显示右键菜单
}

View File

@@ -27,6 +27,16 @@ watch(() => props.isExpanded, (expanded, wasExpanded) => {
}
})
watch(() => props.model, (model) => {
if (!props.isExpanded) {
editingModel.value = { ...model }
}
}, { deep: true })
watch(editingModel, (model) => {
emit('update', { ...model })
}, { deep: true })
// 当 test 通过后props.model.enabled 会更新,需要同步到 editingModel
watch(() => props.model.enabled, (enabled) => {
editingModel.value.enabled = enabled

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>

View File

@@ -1,519 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { logApi, type Log, type LogStats } from '@/api/log'
import { Terminal, RefreshCw, Filter, Clock, Bot, MessageSquare, Settings, AlertCircle } from 'lucide-vue-next'
const logs = ref<Log[]>([])
const stats = ref<LogStats | null>(null)
const loading = ref(false)
const currentPage = ref(1)
const pageSize = ref(50)
const total = ref(0)
// Filters
const selectedType = ref<string | null>(null)
const selectedLevel = ref<string | null>(null)
// Auto-refresh
const autoRefresh = ref(false)
let refreshInterval: number | null = null
const logTypes = [
{ value: null, label: '全部' },
{ value: 'agent', label: '智能体', icon: Bot },
{ value: 'system', label: '系统', icon: Settings },
{ value: 'chat', label: '问答', icon: MessageSquare },
]
const logLevels = [
{ value: null, label: '全部' },
{ value: 'debug', label: '调试' },
{ value: 'info', label: '信息' },
{ value: 'warning', label: '警告' },
{ value: 'error', label: '错误' },
]
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 formatTime(dateStr: string): string {
const d = new Date(dateStr)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatDuration(ms: string | null): string {
if (!ms) return ''
const msNum = parseInt(ms)
if (msNum < 1000) return `${msNum}ms`
return `${(msNum / 1000).toFixed(1)}s`
}
async function fetchLogs() {
loading.value = true
try {
const res = await logApi.list({
log_type: selectedType.value || undefined,
level: selectedLevel.value || undefined,
page: currentPage.value,
page_size: pageSize.value,
})
logs.value = res.data.logs
total.value = res.data.total
} catch (e) {
console.error('Failed to fetch logs:', e)
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
const res = await logApi.getStats(24)
stats.value = res.data
} catch (e) {
console.error('Failed to fetch stats:', e)
}
}
async function loadData() {
await Promise.all([fetchLogs(), fetchStats()])
}
function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value
if (autoRefresh.value) {
refreshInterval = window.setInterval(() => {
fetchLogs()
}, 5000)
} else if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
}
function filterByType(type: string | null) {
selectedType.value = type
currentPage.value = 1
fetchLogs()
}
function filterByLevel(level: string | null) {
selectedLevel.value = level
currentPage.value = 1
fetchLogs()
}
function prevPage() {
if (currentPage.value > 1) {
currentPage.value--
fetchLogs()
}
}
function nextPage() {
if (currentPage.value * pageSize.value < total.value) {
currentPage.value++
fetchLogs()
}
}
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>
<!-- Stats Overview -->
<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" v-if="stats.by_level.error > 0">
<div class="stat-label">错误</div>
<div class="stat-value red">{{ stats.by_level.error }}</div>
</div>
</div>
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<span class="filter-label">类型:</span>
<button
v-for="t in logTypes"
:key="t.value ?? 'all'"
class="filter-btn"
:class="{ active: selectedType === t.value }"
@click="filterByType(t.value)"
>
<component :is="t.icon" :size="12" v-if="t.icon" />
{{ t.label }}
</button>
</div>
<div class="filter-group">
<span class="filter-label">级别:</span>
<button
v-for="l in logLevels"
:key="l.value ?? 'all'"
class="filter-btn"
:class="{ active: selectedLevel === l.value }"
@click="filterByLevel(l.value)"
>
{{ l.label }}
</button>
</div>
</div>
<!-- Log List -->
<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="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-meta">
<span class="log-type" :class="log.type">{{ typeLabels[log.type] }}</span>
<span class="log-level" :style="{ color: levelColors[log.level] }">{{ log.level }}</span>
<span v-if="log.duration_ms" class="log-duration">{{ formatDuration(log.duration_ms) }}</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 class="log-time">{{ formatTime(log.created_at) }}</span>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div class="pagination" v-if="total > pageSize">
<button class="page-btn" :disabled="currentPage === 1" @click="prevPage">上一页</button>
<span class="page-info">{{ currentPage }} / {{ Math.ceil(total / pageSize) }}</span>
<button class="page-btn" :disabled="currentPage * pageSize >= total" @click="nextPage">下一页</button>
</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 */
.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 */
.filters {
display: flex;
gap: 20px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
}
.filter-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: transparent;
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
color: var(--text-dim);
font-size: 11px;
cursor: pointer;
transition: all var(--transition-fast);
}
.filter-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.filter-btn.active {
background: var(--accent-cyan-dim);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* Log List */
.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 {
padding: 12px;
border-bottom: 1px solid var(--border-dim);
transition: background var(--transition-fast);
}
.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;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 16px;
}
.page-btn {
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
transition: all var(--transition-fast);
}
.page-btn:hover:not(:disabled) {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}
</style>