Add log system with three log types (agent/system/chat)

Implemented a complete log system for tracking:
- Agent logs:智能体调用
- System logs: 系统运行
- Chat logs: 问答对话

Backend:
- Log model with type, level, user_id, message, source, duration_ms
- LogService with methods for logging and querying
- API endpoints: GET /api/logs, GET /api/logs/stats, GET /api/logs/recent

Frontend:
- LogView.vue with filters, stats, pagination, auto-refresh
- log.ts API client with TypeScript interfaces
- Added "运行日志" nav item to sidebar
This commit is contained in:
2026-03-21 11:58:51 +08:00
parent 30568846b3
commit 9e4e94c75e
9 changed files with 979 additions and 1 deletions

61
frontend/src/api/log.ts Normal file
View File

@@ -0,0 +1,61 @@
import api from './index'
import type { AxiosResponse } from 'axios'
export interface Log {
id: string
level: 'debug' | 'info' | 'warning' | 'error'
type: 'agent' | 'system' | 'chat'
user_id: string | null
message: string
source: string | null
details: string | null
duration_ms: string | null
created_at: string
updated_at: string
}
export interface LogStats {
total: number
by_type: {
agent: number
system: number
chat: number
}
by_level: {
debug: number
info: number
warning: number
error: number
}
}
export interface LogQueryResult {
logs: Log[]
total: number
page: number
page_size: number
}
export const logApi = {
list: (params?: {
log_type?: string
level?: string
source?: string
page?: number
page_size?: number
}): Promise<AxiosResponse<LogQueryResult>> => {
return api.get('/api/logs', { params })
},
getStats: (hours?: number): Promise<AxiosResponse<LogStats>> => {
return api.get('/api/logs/stats', { params: { hours } })
},
getRecent: (params?: {
log_type?: string
hours?: number
limit?: number
}): Promise<AxiosResponse<Log[]>> => {
return api.get('/api/logs/recent', { params })
},
}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { MessageCircle, BookOpen, Network, LayoutGrid, MessageSquare, LogOut, Cpu, Bot, Activity, CheckSquare, Settings, Star } from 'lucide-vue-next'
import { MessageCircle, BookOpen, Network, LayoutGrid, MessageSquare, LogOut, Cpu, Bot, Activity, CheckSquare, Settings, Star, Terminal } from 'lucide-vue-next'
const router = useRouter()
const route = useRoute()
@@ -17,6 +17,7 @@ const navItems = [
{ name: '任务调度', path: '/todo', icon: CheckSquare },
{ name: '信息交易所', path: '/forum', icon: MessageSquare },
{ name: '运行状态', path: '/stats', icon: Activity },
{ name: '运行日志', path: '/logs', icon: Terminal },
{ name: '系统设置', path: '/settings', icon: Settings },
]

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Terminal } from 'lucide-vue-next'
const router = createRouter({
history: createWebHistory(),
@@ -69,6 +70,11 @@ const router = createRouter({
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
},
{
path: 'logs',
name: 'logs',
component: () => import('@/views/LogView.vue'),
},
],
},
],

View File

@@ -0,0 +1,519 @@
<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>