feat: 调用日志 Tab 增加筛选栏、展开行、分页和详情弹窗

This commit is contained in:
2026-05-24 11:56:33 +08:00
parent b4af9fc99b
commit e3edc319e8
3 changed files with 261 additions and 16 deletions
+1
View File
@@ -6,6 +6,7 @@ import type {
AiProvider,
AiEndpointConfig,
AiSceneBinding,
AiCallLog,
AiRuntimeRequest,
AiEndpointRuntimeRequest
} from '@/types/aiconfig'
+258 -14
View File
@@ -31,7 +31,7 @@
</el-col>
<el-col :span="6">
<div class="stat-card stat-log">
<div class="stat-value">{{ logs.length }}</div>
<div class="stat-value">{{ logs.total }}</div>
<div class="stat-label">最近调用日志</div>
</div>
</el-col>
@@ -146,23 +146,95 @@
</el-tab-pane>
<el-tab-pane label="调用日志" name="logs">
<el-table :data="logs" v-loading="loading" stripe empty-text="暂无调用日志">
<div class="log-filter-bar">
<el-select v-model="logQuery.status" placeholder="状态" clearable style="width: 110px" @change="searchLogs">
<el-option label="全部" value="" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
<el-option label="运行中" value="running" />
</el-select>
<el-select v-model="logQuery.sceneCode" placeholder="场景" clearable style="width: 160px" @change="searchLogs">
<el-option v-for="item in scenes" :key="item.sceneCode" :label="item.sceneName" :value="item.sceneCode" />
</el-select>
<el-select v-model="logQuery.providerCode" placeholder="服务商" clearable style="width: 150px" @change="searchLogs">
<el-option v-for="item in providers" :key="item.providerCode" :label="item.providerName" :value="item.providerCode" />
</el-select>
<el-select v-model="logQuery.endpointCode" placeholder="接口" clearable style="width: 180px" @change="searchLogs">
<el-option v-for="item in endpoints" :key="item.endpointCode" :label="item.endpointName" :value="item.endpointCode" />
</el-select>
<el-select v-model="selectedTimeRange" placeholder="时间" clearable style="width: 120px" @change="handleTimeRangeChange">
<el-option v-for="opt in timeQuickOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 240px"
@change="handleDateRangeChange"
/>
<el-input v-model="logQuery.keyword" placeholder="关键词搜索" clearable style="width: 180px" @keyup.enter="searchLogs">
<template #append>
<el-button :icon="Search" @click="searchLogs" />
</template>
</el-input>
</div>
<el-table :data="logs.records" v-loading="loading" stripe empty-text="暂无调用日志" @expand-change="">
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-expand-content">
<div class="expand-section">
<div class="expand-label">入参预览</div>
<div class="expand-preview">{{ previewText(row.inputText, 300) }}</div>
</div>
<div class="expand-section">
<div class="expand-label">出参预览</div>
<div class="expand-preview">{{ previewText(row.outputText, 300) }}</div>
</div>
<div v-if="row.status === 'failed'" class="expand-section">
<div class="expand-label">错误信息</div>
<div class="expand-error">{{ row.errorCode }}: {{ row.errorMessage }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="createTime" label="调用时间" width="175" />
<el-table-column prop="sceneCode" label="场景" width="160" show-overflow-tooltip />
<el-table-column prop="providerCode" label="服务商" width="150" show-overflow-tooltip />
<el-table-column prop="endpointCode" label="接口" min-width="210" show-overflow-tooltip />
<el-table-column label="状态" width="100" align="center">
<el-table-column prop="endpointCode" label="接口" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'" effect="plain">
<el-tag :type="row.status === 'success' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'" effect="plain" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="streamChunks" label="片段数" width="90" align="center" />
<el-table-column prop="firstTokenMs" label="首字耗时" width="110" align="center" />
<el-table-column prop="durationMs" label="总耗时" width="100" align="center" />
<el-table-column prop="errorCode" label="错误码" min-width="180" show-overflow-tooltip />
<el-table-column label="首字耗时" width="100" align="center">
<template #default="{ row }">{{ formatMs(row.firstTokenMs) }}</template>
</el-table-column>
<el-table-column label="总耗时" width="90" align="center">
<template #default="{ row }">{{ formatMs(row.durationMs) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="logQuery.pageNum"
v-model:page-size="logQuery.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="logs.total"
layout="total, sizes, prev, pager, next, jumper"
class="log-pagination"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</el-tab-pane>
</el-tabs>
</el-card>
@@ -339,25 +411,28 @@
<el-button type="primary" :loading="endpointTesting" @click="submitEndpointStreamTest">流式测试</el-button>
</template>
</el-dialog>
<AiCallLogDetailDialog v-model="detailDialogVisible" :log="selectedLog" />
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
import { Plus, Refresh, Search, VideoPlay } from '@element-plus/icons-vue'
import MarkdownPreview from '@/components/MarkdownPreview.vue'
import AiCallLogDetailDialog from './components/AiCallLogDetailDialog.vue'
import {
deleteAiEndpoint,
deleteAiProvider,
deleteAiScene,
getEndpointTestTemplate,
getSceneTestTemplate,
listAiCallLogs,
listAiEndpoints,
listAiProviders,
listAiScenes,
normalizeAiText,
queryAiCallLogs,
saveAiEndpoint,
saveAiProvider,
saveAiScene,
@@ -367,13 +442,37 @@ import {
testEndpointRuntime
} from '@/api/aiconfig'
import type { AiCallLog, AiEndpointConfig, AiProvider, AiRuntimeTestResponse, AiSceneBinding, TestParamField, ParamDefinition, AiTestTemplateResponse } from '@/types/aiconfig'
import type { LogQueryParams, PageResult } from '@/types/common'
const activeTab = ref('providers')
const loading = ref(false)
const providers = ref<AiProvider[]>([])
const endpoints = ref<AiEndpointConfig[]>([])
const scenes = ref<AiSceneBinding[]>([])
const logs = ref<AiCallLog[]>([])
const logs = ref<PageResult<AiCallLog>>({ records: [], total: 0, current: 1, size: 20, pages: 0 })
const selectedLog = ref<AiCallLog | null>(null)
const detailDialogVisible = ref(false)
const logQuery = reactive<LogQueryParams>({
status: '',
sceneCode: '',
providerCode: '',
endpointCode: '',
startTime: '',
endTime: '',
keyword: '',
pageNum: 1,
pageSize: 20
})
const timeQuickOptions = [
{ label: '全部', value: '' },
{ label: '近7天', value: '7d' },
{ label: '近30天', value: '30d' }
]
const selectedTimeRange = ref('')
const dateRange = ref<[string, string] | null>(null)
const providerDialog = ref(false)
const endpointDialog = ref(false)
@@ -464,6 +563,83 @@ function statusLabel(status?: string) {
return status ? map[status] || status : '-'
}
function previewText(jsonStr: string | undefined, maxLen = 200): string {
if (!jsonStr) return '-'
if (jsonStr.length <= maxLen) return jsonStr
const truncated = jsonStr.slice(0, maxLen)
const lastSpace = truncated.lastIndexOf(' ')
const cutAt = lastSpace > maxLen * 0.5 ? lastSpace : maxLen
return truncated.slice(0, cutAt) + '...'
}
function openDetail(row: AiCallLog) {
selectedLog.value = row
detailDialogVisible.value = true
}
function buildTimeRange(range: string): { startTime: string; endTime: string } {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59)
let start: Date
if (range === '7d') {
start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6, 0, 0, 0)
} else if (range === '30d') {
start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29, 0, 0, 0)
} else {
return { startTime: '', endTime: '' }
}
return { startTime: start.toISOString(), endTime: end.toISOString() }
}
function handleTimeRangeChange(val: string) {
if (!val) {
logQuery.startTime = ''
logQuery.endTime = ''
dateRange.value = null
} else {
const range = buildTimeRange(val)
logQuery.startTime = range.startTime
logQuery.endTime = range.endTime
dateRange.value = null
}
}
function handleDateRangeChange(val: [string, string] | null) {
if (val) {
logQuery.startTime = val[0] + 'T00:00:00'
logQuery.endTime = val[1] + 'T23:59:59'
} else {
logQuery.startTime = ''
logQuery.endTime = ''
}
}
async function searchLogs() {
loading.value = true
try {
const res = await queryAiCallLogs({ ...logQuery })
logs.value = res.data || { records: [], total: 0, current: 1, size: 20, pages: 0 }
} finally {
loading.value = false
}
}
function handlePageChange(page: number) {
logQuery.pageNum = page
searchLogs()
}
function handleSizeChange(size: number) {
logQuery.pageSize = size
logQuery.pageNum = 1
searchLogs()
}
function formatMs(ms?: number) {
if (ms == null) return '-'
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`
}
function testOutput(result?: AiRuntimeTestResponse | null) {
return normalizeAiText(result?.output || result?.errorMessage || '暂无输出')
}
@@ -484,15 +660,21 @@ async function loadAll() {
listAiProviders(),
listAiEndpoints(),
listAiScenes(),
listAiCallLogs(80)
queryAiCallLogs({ pageNum: 1, pageSize: 20 })
])
providers.value = providerRes.data || []
endpoints.value = endpointRes.data || []
scenes.value = sceneRes.data || []
logs.value = logRes.data || []
logs.value = logRes.data || { records: [], total: 0, current: 1, size: 20, pages: 0 }
} finally {
loading.value = false
}
selectedTimeRange.value = ''
dateRange.value = null
Object.assign(logQuery, {
status: '', sceneCode: '', providerCode: '', endpointCode: '',
startTime: '', endTime: '', keyword: '', pageNum: 1, pageSize: 20
})
}
function openProvider(row?: AiProvider) {
@@ -1031,4 +1213,66 @@ onMounted(loadAll)
color: var(--ls-text-muted);
margin-right: 6px;
}
/* 调用日志筛选栏 */
.log-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
align-items: center;
}
/* 展开行内容 */
.log-expand-content {
padding: 14px 20px;
background: rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--ls-radius-md);
display: flex;
flex-direction: column;
gap: 14px;
}
.expand-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.expand-label {
color: var(--ls-text-muted);
font-size: 12px;
font-weight: 500;
}
.expand-preview {
padding: 10px 12px;
color: rgba(226, 232, 240, 0.85);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow: auto;
}
.expand-error {
padding: 10px 12px;
color: #f56c6c;
font-size: 12px;
background: rgba(245, 108, 108, 0.08);
border: 1px solid rgba(245, 108, 108, 0.2);
border-radius: 6px;
}
/* 分页 */
.log-pagination {
margin-top: 14px;
justify-content: flex-end;
}
</style>
@@ -59,11 +59,11 @@
</div>
<div class="detail-section">
<JsonViewer title="入参" :data="log.inputText" />
<JsonViewer title="入参" :data="log.inputText || null" />
</div>
<div class="detail-section">
<JsonViewer title="出参" :data="log.outputText" />
<JsonViewer title="出参" :data="log.outputText || null" />
</div>
<div v-if="log.status === 'failed'" class="detail-section">