feat: 调用日志 Tab 增加筛选栏、展开行、分页和详情弹窗
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
||||
AiProvider,
|
||||
AiEndpointConfig,
|
||||
AiSceneBinding,
|
||||
AiCallLog,
|
||||
AiRuntimeRequest,
|
||||
AiEndpointRuntimeRequest
|
||||
} from '@/types/aiconfig'
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user