@@ -0,0 +1,464 @@
< script setup lang = "ts" >
import { computed , onMounted , reactive , ref , watch } from 'vue'
import { opsAPI , type OpsRuntimeLogConfig , type OpsSystemLog , type OpsSystemLogSinkHealth } from '@/api/admin/ops'
import Pagination from '@/components/common/Pagination.vue'
import { useAppStore } from '@/stores'
const appStore = useAppStore ( )
const props = withDefaults ( defineProps < {
platformFilter ? : string
refreshToken ? : number
} > ( ) , {
platformFilter : '' ,
refreshToken : 0
} )
const loading = ref ( false )
const logs = ref < OpsSystemLog [ ] > ( [ ] )
const total = ref ( 0 )
const page = ref ( 1 )
const pageSize = ref ( 20 )
const health = ref < OpsSystemLogSinkHealth > ( {
queue _depth : 0 ,
queue _capacity : 0 ,
dropped _count : 0 ,
write _failed _count : 0 ,
written _count : 0 ,
avg _write _delay _ms : 0
} )
const runtimeLoading = ref ( false )
const runtimeSaving = ref ( false )
const runtimeConfig = reactive < OpsRuntimeLogConfig > ( {
level : 'info' ,
enable _sampling : false ,
sampling _initial : 100 ,
sampling _thereafter : 100 ,
caller : true ,
stacktrace _level : 'error' ,
retention _days : 30
} )
const filters = reactive ( {
time _range : '1h' as '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d' ,
start _time : '' ,
end _time : '' ,
level : '' ,
component : '' ,
request _id : '' ,
client _request _id : '' ,
user _id : '' ,
account _id : '' ,
platform : '' ,
model : '' ,
q : ''
} )
const levelBadgeClass = ( level : string ) => {
const v = String ( level || '' ) . toLowerCase ( )
if ( v === 'error' || v === 'fatal' ) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
if ( v === 'warn' || v === 'warning' ) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
if ( v === 'debug' ) return 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}
const formatTime = ( value : string ) => {
if ( ! value ) return '-'
const d = new Date ( value )
if ( Number . isNaN ( d . getTime ( ) ) ) return value
return d . toLocaleString ( )
}
const toRFC3339 = ( value : string ) => {
if ( ! value ) return undefined
const d = new Date ( value )
if ( Number . isNaN ( d . getTime ( ) ) ) return undefined
return d . toISOString ( )
}
const buildQuery = ( ) => {
const query : Record < string , any > = {
page : page . value ,
page _size : pageSize . value ,
time _range : filters . time _range
}
if ( filters . time _range === '30d' ) {
query . time _range = '30d'
}
if ( filters . start _time ) query . start _time = toRFC3339 ( filters . start _time )
if ( filters . end _time ) query . end _time = toRFC3339 ( filters . end _time )
if ( filters . level . trim ( ) ) query . level = filters . level . trim ( )
if ( filters . component . trim ( ) ) query . component = filters . component . trim ( )
if ( filters . request _id . trim ( ) ) query . request _id = filters . request _id . trim ( )
if ( filters . client _request _id . trim ( ) ) query . client _request _id = filters . client _request _id . trim ( )
if ( filters . user _id . trim ( ) ) {
const v = Number . parseInt ( filters . user _id . trim ( ) , 10 )
if ( Number . isFinite ( v ) && v > 0 ) query . user _id = v
}
if ( filters . account _id . trim ( ) ) {
const v = Number . parseInt ( filters . account _id . trim ( ) , 10 )
if ( Number . isFinite ( v ) && v > 0 ) query . account _id = v
}
if ( filters . platform . trim ( ) ) query . platform = filters . platform . trim ( )
if ( filters . model . trim ( ) ) query . model = filters . model . trim ( )
if ( filters . q . trim ( ) ) query . q = filters . q . trim ( )
return query
}
const fetchLogs = async ( ) => {
loading . value = true
try {
const res = await opsAPI . listSystemLogs ( buildQuery ( ) )
logs . value = res . items || [ ]
total . value = res . total || 0
} catch ( err : any ) {
console . error ( '[OpsSystemLogTable] Failed to fetch logs' , err )
appStore . showError ( err ? . response ? . data ? . detail || '系统日志加载失败' )
} finally {
loading . value = false
}
}
const fetchHealth = async ( ) => {
try {
health . value = await opsAPI . getSystemLogSinkHealth ( )
} catch {
// 忽略健康数据读取失败,不影响主流程。
}
}
const loadRuntimeConfig = async ( ) => {
runtimeLoading . value = true
try {
const cfg = await opsAPI . getRuntimeLogConfig ( )
runtimeConfig . level = cfg . level
runtimeConfig . enable _sampling = cfg . enable _sampling
runtimeConfig . sampling _initial = cfg . sampling _initial
runtimeConfig . sampling _thereafter = cfg . sampling _thereafter
runtimeConfig . caller = cfg . caller
runtimeConfig . stacktrace _level = cfg . stacktrace _level
runtimeConfig . retention _days = cfg . retention _days
} catch ( err : any ) {
console . error ( '[OpsSystemLogTable] Failed to load runtime log config' , err )
} finally {
runtimeLoading . value = false
}
}
const saveRuntimeConfig = async ( ) => {
runtimeSaving . value = true
try {
const saved = await opsAPI . updateRuntimeLogConfig ( { ... runtimeConfig } )
runtimeConfig . level = saved . level
runtimeConfig . enable _sampling = saved . enable _sampling
runtimeConfig . sampling _initial = saved . sampling _initial
runtimeConfig . sampling _thereafter = saved . sampling _thereafter
runtimeConfig . caller = saved . caller
runtimeConfig . stacktrace _level = saved . stacktrace _level
runtimeConfig . retention _days = saved . retention _days
appStore . showSuccess ( '日志运行时配置已生效' )
} catch ( err : any ) {
console . error ( '[OpsSystemLogTable] Failed to save runtime log config' , err )
appStore . showError ( err ? . response ? . data ? . detail || '保存日志配置失败' )
} finally {
runtimeSaving . value = false
}
}
const resetRuntimeConfig = async ( ) => {
const ok = window . confirm ( '确认回滚为启动配置(env/yaml)并立即生效?' )
if ( ! ok ) return
runtimeSaving . value = true
try {
const saved = await opsAPI . resetRuntimeLogConfig ( )
runtimeConfig . level = saved . level
runtimeConfig . enable _sampling = saved . enable _sampling
runtimeConfig . sampling _initial = saved . sampling _initial
runtimeConfig . sampling _thereafter = saved . sampling _thereafter
runtimeConfig . caller = saved . caller
runtimeConfig . stacktrace _level = saved . stacktrace _level
runtimeConfig . retention _days = saved . retention _days
appStore . showSuccess ( '已回滚到启动日志配置' )
await fetchHealth ( )
} catch ( err : any ) {
console . error ( '[OpsSystemLogTable] Failed to reset runtime log config' , err )
appStore . showError ( err ? . response ? . data ? . detail || '回滚日志配置失败' )
} finally {
runtimeSaving . value = false
}
}
const cleanupCurrentFilter = async ( ) => {
const ok = window . confirm ( '确认按当前筛选条件清理系统日志?该操作不可撤销。' )
if ( ! ok ) return
try {
const payload = {
start _time : toRFC3339 ( filters . start _time ) ,
end _time : toRFC3339 ( filters . end _time ) ,
level : filters . level . trim ( ) || undefined ,
component : filters . component . trim ( ) || undefined ,
request _id : filters . request _id . trim ( ) || undefined ,
client _request _id : filters . client _request _id . trim ( ) || undefined ,
user _id : filters . user _id . trim ( ) ? Number . parseInt ( filters . user _id . trim ( ) , 10 ) : undefined ,
account _id : filters . account _id . trim ( ) ? Number . parseInt ( filters . account _id . trim ( ) , 10 ) : undefined ,
platform : filters . platform . trim ( ) || undefined ,
model : filters . model . trim ( ) || undefined ,
q : filters . q . trim ( ) || undefined
}
const res = await opsAPI . cleanupSystemLogs ( payload )
appStore . showSuccess ( ` 清理完成,删除 ${ res . deleted || 0 } 条日志 ` )
page . value = 1
await Promise . all ( [ fetchLogs ( ) , fetchHealth ( ) ] )
} catch ( err : any ) {
console . error ( '[OpsSystemLogTable] Failed to cleanup logs' , err )
appStore . showError ( err ? . response ? . data ? . detail || '清理系统日志失败' )
}
}
const resetFilters = ( ) => {
filters . time _range = '1h'
filters . start _time = ''
filters . end _time = ''
filters . level = ''
filters . component = ''
filters . request _id = ''
filters . client _request _id = ''
filters . user _id = ''
filters . account _id = ''
filters . platform = props . platformFilter || ''
filters . model = ''
filters . q = ''
page . value = 1
fetchLogs ( )
}
watch ( ( ) => props . platformFilter , ( v ) => {
if ( v && ! filters . platform ) {
filters . platform = v
page . value = 1
fetchLogs ( )
}
} )
watch ( ( ) => props . refreshToken , ( ) => {
fetchLogs ( )
fetchHealth ( )
} )
const onPageChange = ( next : number ) => {
page . value = next
fetchLogs ( )
}
const onPageSizeChange = ( next : number ) => {
pageSize . value = next
page . value = 1
fetchLogs ( )
}
const applyFilters = ( ) => {
page . value = 1
fetchLogs ( )
}
const hasData = computed ( ( ) => logs . value . length > 0 )
onMounted ( async ( ) => {
if ( props . platformFilter ) {
filters . platform = props . platformFilter
}
await Promise . all ( [ fetchLogs ( ) , fetchHealth ( ) , loadRuntimeConfig ( ) ] )
} )
< / script >
< template >
< section class = "rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-900/60" >
< div class = "mb-4 flex flex-wrap items-center justify-between gap-3" >
< div >
< h3 class = "text-sm font-bold text-gray-900 dark:text-white" > 系统日志 < / h3 >
< p class = "mt-1 text-xs text-gray-500 dark:text-gray-400" > 默认按最新时间倒序 , 支持筛选搜索与按条件清理 。 < / p >
< / div >
< div class = "flex flex-wrap items-center gap-2 text-xs" >
< span class = "rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200" > 队列 { { health . queue _depth } } / { { health . queue _capacity } } < / span >
< span class = "rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200" > 写入 { { health . written _count } } < / span >
< span class = "rounded-md bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300" > 丢弃 { { health . dropped _count } } < / span >
< span class = "rounded-md bg-red-100 px-2 py-1 text-red-700 dark:bg-red-900/30 dark:text-red-300" > 失败 { { health . write _failed _count } } < / span >
< / div >
< / div >
< div class = "mb-4 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-800/70" >
< div class = "mb-2 flex items-center justify-between" >
< div class = "text-xs font-semibold text-gray-700 dark:text-gray-200" > 运行时日志配置 ( 实时生效 ) < / div >
< span v-if = "runtimeLoading" class="text-xs text-gray-500" > 加载中... < / span >
< / div >
< div class = "grid grid-cols-1 gap-3 md:grid-cols-6" >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
级别
< select v-model = "runtimeConfig.level" class="input mt-1" >
< option value = "debug" > debug < / option >
< option value = "info" > info < / option >
< option value = "warn" > warn < / option >
< option value = "error" > error < / option >
< / select >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
堆栈阈值
< select v-model = "runtimeConfig.stacktrace_level" class="input mt-1" >
< option value = "none" > none < / option >
< option value = "error" > error < / option >
< option value = "fatal" > fatal < / option >
< / select >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
采样初始
< input v -model .number = " runtimeConfig.sampling_initial " type = "number" min = "1" class = "input mt-1" / >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
采样后续
< input v -model .number = " runtimeConfig.sampling_thereafter " type = "number" min = "1" class = "input mt-1" / >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
保留天数
< input v -model .number = " runtimeConfig.retention_days " type = "number" min = "1" max = "3650" class = "input mt-1" / >
< / label >
< div class = "flex items-end gap-2" >
< label class = "inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300" >
< input v-model = "runtimeConfig.caller" type="checkbox" / >
caller
< / label >
< label class = "inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300" >
< input v-model = "runtimeConfig.enable_sampling" type="checkbox" / >
sampling
< / label >
< button type = "button" class = "btn btn-primary btn-sm" :disabled = "runtimeSaving" @click ="saveRuntimeConfig" >
{{ runtimeSaving ? ' 保存中... ' : ' 保存并生效 ' }}
< / button >
< button type = "button" class = "btn btn-secondary btn-sm" :disabled = "runtimeSaving" @click ="resetRuntimeConfig" >
回滚默认值
< / button >
< / div >
< / div >
< p v-if = "health.last_error" class="mt-2 text-xs text-red-600 dark:text-red-400" > 最近写入错误 : {{ health.last_error }} < / p >
< / div >
< div class = "mb-4 grid grid-cols-1 gap-3 md:grid-cols-5" >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
时间范围
< select v-model = "filters.time_range" class="input mt-1" >
< option value = "5m" > 5 m < / option >
< option value = "30m" > 30 m < / option >
< option value = "1h" > 1 h < / option >
< option value = "6h" > 6 h < / option >
< option value = "24h" > 24 h < / option >
< option value = "7d" > 7 d < / option >
< option value = "30d" > 30 d < / option >
< / select >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
开始时间 ( 可选 )
< input v-model = "filters.start_time" type="datetime-local" class="input mt-1" / >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
结束时间 ( 可选 )
< input v-model = "filters.end_time" type="datetime-local" class="input mt-1" / >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
级别
< select v-model = "filters.level" class="input mt-1" >
< option value = "" > 全部 < / option >
< option value = "debug" > debug < / option >
< option value = "info" > info < / option >
< option value = "warn" > warn < / option >
< option value = "error" > error < / option >
< / select >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
组件
< input v-model = "filters.component" type="text" class="input mt-1" placeholder="如 http.access" / >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
request _id
< input v-model = "filters.request_id" type="text" class="input mt-1" / >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
client _request _id
< input v-model = "filters.client_request_id" type="text" class="input mt-1" / >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
user _id
< input v-model = "filters.user_id" type="text" class="input mt-1" / >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
account _id
< input v-model = "filters.account_id" type="text" class="input mt-1" / >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
平台
< input v-model = "filters.platform" type="text" class="input mt-1" / >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
模型
< input v-model = "filters.model" type="text" class="input mt-1" / >
< / label >
< label class = "text-xs text-gray-600 dark:text-gray-300" >
关键词
< input v-model = "filters.q" type="text" class="input mt-1" placeholder="消息/request_id" / >
< / label >
< / div >
< div class = "mb-3 flex flex-wrap gap-2" >
< button type = "button" class = "btn btn-primary btn-sm" @click ="applyFilters" > 查询 < / button >
< button type = "button" class = "btn btn-secondary btn-sm" @click ="resetFilters" > 重置 < / button >
< button type = "button" class = "btn btn-danger btn-sm" @click ="cleanupCurrentFilter" > 按当前筛选清理 < / button >
< button type = "button" class = "btn btn-secondary btn-sm" @click ="fetchHealth" > 刷新健康指标 < / button >
< / div >
< div class = "overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700" >
< div v-if = "loading" class="px-4 py-8 text-center text-sm text-gray-500" > 加载中... < / div >
< div v-else-if = "!hasData" class="px-4 py-8 text-center text-sm text-gray-500" > 暂无系统日志 < / div >
< div v-else class = "overflow-auto" >
< table class = "min-w-full divide-y divide-gray-200 dark:divide-dark-700" >
< thead class = "bg-gray-50 dark:bg-dark-900" >
< tr >
< th class = "px-3 py-2 text-left text-[11px] font-semibold text-gray-500" > 时间 < / th >
< th class = "px-3 py-2 text-left text-[11px] font-semibold text-gray-500" > 级别 < / th >
< th class = "px-3 py-2 text-left text-[11px] font-semibold text-gray-500" > 组件 < / th >
< th class = "px-3 py-2 text-left text-[11px] font-semibold text-gray-500" > 消息 < / th >
< th class = "px-3 py-2 text-left text-[11px] font-semibold text-gray-500" > 关联 < / th >
< / tr >
< / thead >
< tbody class = "divide-y divide-gray-100 dark:divide-dark-800" >
< tr v-for = "row in logs" :key="row.id" class="align-top" >
< td class = "px-3 py-2 text-xs text-gray-700 dark:text-gray-300" > { { formatTime ( row . created _at ) } } < / td >
< td class = "px-3 py-2 text-xs" >
< span class = "inline-flex rounded-full px-2 py-0.5 font-semibold" :class = "levelBadgeClass(row.level)" >
{ { row . level } }
< / span >
< / td >
< td class = "px-3 py-2 text-xs text-gray-700 dark:text-gray-300" > { { row . component || '-' } } < / td >
< td class = "max-w-[680px] px-3 py-2 text-xs text-gray-700 dark:text-gray-300" > { { row . message } } < / td >
< td class = "px-3 py-2 text-xs text-gray-600 dark:text-gray-400" >
< div > req : { { row . request _id || '-' } } < / div >
< div > client : { { row . client _request _id || '-' } } < / div >
< div > user : { { row . user _id || '-' } } / acc : { { row . account _id || '-' } } < / div >
< div > { { row . platform || '-' } } / { { row . model || '-' } } < / div >
< / td >
< / tr >
< / tbody >
< / table >
< / div >
< Pagination
:total = "total"
:page = "page"
:page-size = "pageSize"
: page -size -options = " [ 10 , 20 , 50 , 100 , 200 ] "
@ update :page = "onPageChange"
@ update :page-size = "onPageSizeChange"
/ >
< / div >
< / section >
< / template >