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:
@@ -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()
|
||||
// 显示右键菜单
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
760
frontend/src/pages/logs/index.vue
Normal file
760
frontend/src/pages/logs/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user