Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
30 KiB
AI 调用日志详情查看功能实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在 web-admin AI 配置管理的调用日志页签中增加展开行预览、详情弹窗、分页筛选搜索功能,后端提供 POST 分页查询接口。
Architecture: 前端新增 JsonViewer 通用组件和 AiCallLogDetailDialog 弹窗组件,改造 AiRoutingList 的调用日志 Tab 增加筛选栏、展开行、分页。后端新增 AiCallLogQueryRequest 和 query 分页查询方法,Controller 增加 POST /call-logs 接口,保留原 GET 接口。
Tech Stack: Vue 3 + TypeScript + Element Plus(前端),Spring Boot 2.7 + MyBatis-Plus(后端)
文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
web-admin/src/types/common.ts |
修改 | 新增 LogQueryParams 接口 |
web-admin/src/api/aiconfig.ts |
修改 | 新增 queryAiCallLogs 接口,保留旧接口 |
web-admin/src/components/JsonViewer.vue |
创建 | 通用 JSON 格式化高亮 + 复制组件 |
web-admin/src/views/aiconfig/components/AiCallLogDetailDialog.vue |
创建 | 调用日志详情弹窗 |
web-admin/src/views/aiconfig/AiRoutingList.vue |
修改 | 调用日志 Tab 增加筛选栏、展开行、分页 |
backend-single/src/main/java/com/emotion/dto/request/ai/AiCallLogQueryRequest.java |
创建 | 日志查询请求 DTO |
backend-single/src/main/java/com/emotion/service/AiCallLogService.java |
修改 | 新增 query 方法签名 |
backend-single/src/main/java/com/emotion/service/impl/AiCallLogServiceImpl.java |
修改 | 实现 query 分页查询 |
backend-single/src/main/java/com/emotion/controller/AiRoutingController.java |
修改 | 新增 POST /call-logs 接口 |
Task 1: 前端类型定义(LogQueryParams)
Files:
-
Modify:
web-admin/src/types/common.ts -
Step 1: 在 common.ts 中追加 LogQueryParams 接口
在 common.ts 文件末尾追加:
export interface LogQueryParams {
status?: string
sceneCode?: string
providerCode?: string
endpointCode?: string
startTime?: string
endTime?: string
keyword?: string
pageNum?: number
pageSize?: number
}
- Step 2: Commit
git add web-admin/src/types/common.ts
git commit -m "feat: 添加 AI 调用日志查询参数类型"
Task 2: 前端 API 层(queryAiCallLogs)
Files:
-
Modify:
web-admin/src/api/aiconfig.ts -
Step 1: 导入 LogQueryParams 和 PageResult
在 aiconfig.ts 的 import 语句中追加:
import type { LogQueryParams } from '@/types/common'
import type { PageResult } from '@/types/common'
- Step 2: 新增 queryAiCallLogs 接口函数
在 listAiCallLogs 函数下方追加:
export function queryAiCallLogs(params: LogQueryParams) {
return request<PageResult<AiCallLog>>({
url: '/ai/call-logs',
method: 'post',
data: params
})
}
注意: 保留原有的 listAiCallLogs 函数不动,新旧接口共存。
- Step 3: Commit
git add web-admin/src/api/aiconfig.ts
git commit -m "feat: 新增 AI 调用日志分页查询接口"
Task 3: JsonViewer 通用组件
Files:
-
Create:
web-admin/src/components/JsonViewer.vue -
Step 1: 创建 JsonViewer.vue
<template>
<div class="json-viewer">
<div class="json-header">
<span class="json-title">{{ title }}</span>
<el-button v-if="showCopy" size="small" @click="handleCopy">
<el-icon><DocumentCopy /></el-icon>
复制
</el-button>
</div>
<div class="json-body" :style="{ maxHeight }">
<pre v-if="isValidJson" class="json-code" v-html="highlightedJson" />
<pre v-else class="json-code json-raw">{{ formattedJson }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { DocumentCopy } from '@element-plus/icons-vue'
interface Props {
data: string | object | null
title?: string
showCopy?: boolean
maxHeight?: string
}
const props = withDefaults(defineProps<Props>(), {
title: '',
showCopy: true,
maxHeight: '300px'
})
const formattedJson = computed(() => {
if (!props.data) return ''
if (typeof props.data === 'string') {
try {
const parsed = JSON.parse(props.data)
return JSON.stringify(parsed, null, 2)
} catch {
return props.data
}
}
return JSON.stringify(props.data, null, 2)
})
const isValidJson = computed(() => {
if (!props.data) return false
const str = typeof props.data === 'string' ? props.data : JSON.stringify(props.data)
try {
JSON.parse(str)
return true
} catch {
return false
}
})
const highlightedJson = computed(() => {
if (!isValidJson.value) return formattedJson.value
let json = formattedJson.value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
// 字符串值(绿色)
json = json.replace(/("(?:[^"\\]|\\.)*")/g, '<span class="json-string">$1</span>')
// key(紫色)- 引号后跟冒号
json = json.replace(/(<span class="json-string">"[^"]*"<\/span>)(\s*:)/g, '<span class="json-key">$1</span>$2')
// 数字和布尔值(蓝色)
json = json.replace(/\b(true|false|null|\d+(?:\.\d+)?)\b/g, '<span class="json-number">$1</span>')
// 标点符号(灰色)
json = json.replace(/([{}[\],:])/g, '<span class="json-punct">$1</span>')
return json
})
async function handleCopy() {
try {
await navigator.clipboard.writeText(formattedJson.value)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.error('复制失败')
}
}
</script>
<style scoped>
.json-viewer {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
overflow: hidden;
}
.json-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: rgba(30, 30, 30, 0.8);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.json-title {
color: rgba(226, 232, 240, 0.85);
font-size: 13px;
font-weight: 500;
}
.json-body {
overflow: auto;
background: #1e1e1e;
}
.json-code {
margin: 0;
padding: 12px;
color: rgba(226, 232, 240, 0.92);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre;
}
.json-raw {
white-space: pre-wrap;
word-break: break-word;
}
:deep(.json-string) { color: #a5d6a7; }
:deep(.json-key) { color: #ce93d8; }
:deep(.json-number) { color: #90caf9; }
:deep(.json-punct) { color: #b0bec5; }
</style>
- Step 2: Commit
git add web-admin/src/components/JsonViewer.vue
git commit -m "feat: 新增 JsonViewer 通用 JSON 高亮组件"
Task 4: AiCallLogDetailDialog 详情弹窗
Files:
-
Create:
web-admin/src/views/aiconfig/components/AiCallLogDetailDialog.vue -
Step 1: 创建 AiCallLogDetailDialog.vue
<template>
<el-dialog
v-model="visible"
title="调用详情"
width="800px"
:close-on-click-modal="true"
:close-on-press-escape="true"
destroy-on-close
>
<div v-if="log" class="detail-dialog">
<div class="detail-section">
<div class="section-title">基本信息</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">调用时间</span>
<span class="info-value">{{ log.createTime }}</span>
</div>
<div class="info-item">
<span class="info-label">场景</span>
<span class="info-value">{{ log.sceneCode || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">服务商</span>
<span class="info-value">{{ log.providerCode || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">接口</span>
<span class="info-value">{{ log.endpointCode || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">状态</span>
<span class="info-value">
<el-tag :type="statusType(log.status)" size="small" effect="plain">
{{ statusLabel(log.status) }}
</el-tag>
</span>
</div>
<div class="info-item">
<span class="info-label">首字耗时</span>
<span class="info-value">{{ formatMs(log.firstTokenMs) }}</span>
</div>
<div class="info-item">
<span class="info-label">总耗时</span>
<span class="info-value">{{ formatMs(log.durationMs) }}</span>
</div>
<div class="info-item">
<span class="info-label">片段数</span>
<span class="info-value">{{ log.streamChunks ?? '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">用户 ID</span>
<span class="info-value">{{ log.userId || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">请求 ID</span>
<span class="info-value">{{ log.requestId || '-' }}</span>
</div>
</div>
</div>
<div class="detail-section">
<JsonViewer title="入参" :data="log.inputText" />
</div>
<div class="detail-section">
<JsonViewer title="出参" :data="log.outputText" />
</div>
<div v-if="log.status === 'failed'" class="detail-section">
<div class="section-title">错误信息</div>
<div class="error-box">
{{ log.errorCode }}: {{ log.errorMessage }}
</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import JsonViewer from '@/components/JsonViewer.vue'
import type { AiCallLog } from '@/types/aiconfig'
interface Props {
modelValue: boolean
log: AiCallLog | null
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
function statusType(status?: string) {
if (status === 'success') return 'success'
if (status === 'failed') return 'danger'
return 'warning'
}
function statusLabel(status?: string) {
const map: Record<string, string> = { running: '运行中', success: '成功', failed: '失败' }
return status ? map[status] || status : '-'
}
function formatMs(ms?: number) {
if (ms == null) return '-'
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`
}
</script>
<style scoped>
.detail-dialog {
display: flex;
flex-direction: column;
gap: 20px;
}
.detail-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.section-title {
color: rgba(226, 232, 240, 0.85);
font-size: 14px;
font-weight: 600;
}
.info-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px 16px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
color: var(--ls-text-muted);
font-size: 12px;
}
.info-value {
color: rgba(226, 232, 240, 0.92);
font-size: 13px;
}
.error-box {
padding: 12px;
color: #f56c6c;
font-size: 13px;
background: rgba(245, 108, 108, 0.08);
border: 1px solid rgba(245, 108, 108, 0.2);
border-radius: 6px;
}
@media (max-width: 768px) {
.info-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
- Step 2: Commit
git add web-admin/src/views/aiconfig/components/AiCallLogDetailDialog.vue
git commit -m "feat: 新增 AI 调用日志详情弹窗组件"
Task 5: 后端 AiCallLogQueryRequest DTO
Files:
-
Create:
backend-single/src/main/java/com/emotion/dto/request/ai/AiCallLogQueryRequest.java -
Step 1: 确认 dto/request/ai 目录存在
ls backend-single/src/main/java/com/emotion/dto/request/ai/
如果不存在则创建:
mkdir -p backend-single/src/main/java/com/emotion/dto/request/ai
- Step 2: 创建 AiCallLogQueryRequest.java
package com.emotion.dto.request.ai;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
/**
* AI 调用日志查询请求
*/
@Data
@Schema(description = "AI 调用日志查询请求")
public class AiCallLogQueryRequest {
@Schema(description = "状态:running / success / failed")
private String status;
@Schema(description = "场景编码")
private String sceneCode;
@Schema(description = "服务商编码")
private String providerCode;
@Schema(description = "接口编码")
private String endpointCode;
@Schema(description = "开始时间")
private LocalDateTime startTime;
@Schema(description = "结束时间")
private LocalDateTime endTime;
@Schema(description = "入参/出参关键词搜索")
@Size(max = 200, message = "关键词长度不能超过 200")
private String keyword;
@Schema(description = "页码", example = "1")
@Min(value = 1, message = "页码必须大于 0")
private Integer pageNum = 1;
@Schema(description = "每页条数", example = "20")
@Min(value = 1, message = "每页条数必须大于 0")
@Max(value = 100, message = "每页条数不能超过 100")
private Integer pageSize = 20;
}
- Step 3: Commit
git add backend-single/src/main/java/com/emotion/dto/request/ai/AiCallLogQueryRequest.java
git commit -m "feat: 新增 AI 调用日志查询请求 DTO"
Task 6: 后端 Service 接口和实现
Files:
-
Modify:
backend-single/src/main/java/com/emotion/service/AiCallLogService.java -
Modify:
backend-single/src/main/java/com/emotion/service/impl/AiCallLogServiceImpl.java -
Step 1: 在 AiCallLogService 接口中新增 query 方法
在 AiCallLogService.java 的 latest 方法下方添加:
import com.emotion.common.PageResult;
import com.emotion.dto.request.ai.AiCallLogQueryRequest;
// ... 在接口中
PageResult<AiCallLog> query(AiCallLogQueryRequest request);
- Step 2: 在 AiCallLogServiceImpl 中实现 query 方法
在 AiCallLogServiceImpl.java 的 latest 方法下方添加:
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.emotion.common.PageResult;
import com.emotion.dto.request.ai.AiCallLogQueryRequest;
import org.apache.commons.lang3.StringUtils;
// ... 在类中
@Override
public PageResult<AiCallLog> query(AiCallLogQueryRequest request) {
Page<AiCallLog> pageParam = new Page<>(request.getPageNum(), request.getPageSize());
LambdaQueryWrapper<AiCallLog> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AiCallLog::getIsDeleted, 0)
.eq(StringUtils.isNotBlank(request.getStatus()), AiCallLog::getStatus, request.getStatus())
.eq(StringUtils.isNotBlank(request.getSceneCode()), AiCallLog::getSceneCode, request.getSceneCode())
.eq(StringUtils.isNotBlank(request.getProviderCode()), AiCallLog::getProviderCode, request.getProviderCode())
.eq(StringUtils.isNotBlank(request.getEndpointCode()), AiCallLog::getEndpointCode, request.getEndpointCode())
.ge(request.getStartTime() != null, AiCallLog::getCreateTime, request.getStartTime())
.le(request.getEndTime() != null, AiCallLog::getCreateTime, request.getEndTime())
.orderByDesc(AiCallLog::getCreateTime);
if (StringUtils.isNotBlank(request.getKeyword())) {
wrapper.and(w -> w.like(AiCallLog::getInputText, request.getKeyword())
.or()
.like(AiCallLog::getOutputText, request.getKeyword()));
}
IPage<AiCallLog> page = page(pageParam, wrapper);
return PageResult.of(page);
}
- Step 3: Commit
git add backend-single/src/main/java/com/emotion/service/AiCallLogService.java
git add backend-single/src/main/java/com/emotion/service/impl/AiCallLogServiceImpl.java
git commit -m "feat: AI 调用日志 Service 增加分页查询方法"
Task 7: 后端 Controller 新增 POST 接口
Files:
-
Modify:
backend-single/src/main/java/com/emotion/controller/AiRoutingController.java -
Step 1: 在 AiRoutingController 中导入新类型
在 imports 区域添加:
import com.emotion.common.PageResult;
import com.emotion.dto.request.ai.AiCallLogQueryRequest;
import javax.validation.Valid;
- Step 2: 在 callLogs GET 方法下方新增 POST 方法
在 callLogs 方法(第 157-161 行)下方添加:
@Operation(summary = "分页查询调用日志", description = "分页查询 AI 调用日志,支持多条件筛选和关键词搜索。")
@PostMapping("/call-logs")
public Result<PageResult<AiCallLog>> queryCallLogs(@RequestBody @Valid AiCallLogQueryRequest request) {
return Result.success(callLogService.query(request));
}
- Step 3: Commit
git add backend-single/src/main/java/com/emotion/controller/AiRoutingController.java
git commit -m "feat: Controller 新增 AI 调用日志分页查询接口"
Task 8: 前端 AiRoutingList 改造(核心)
Files:
- Modify:
web-admin/src/views/aiconfig/AiRoutingList.vue
这是最大的改动任务。需要在调用日志 Tab 中增加:筛选栏、展开行、分页、详情弹窗。
- Step 1: 导入新增依赖
在 <script setup> 的 import 区域添加:
import { DocumentCopy, Search } from '@element-plus/icons-vue'
import JsonViewer from '@/components/JsonViewer.vue'
import AiCallLogDetailDialog from './components/AiCallLogDetailDialog.vue'
import type { LogQueryParams } from '@/types/common'
import type { PageResult } from '@/types/common'
同时修改 api 导入,将 listAiCallLogs 替换为新增 queryAiCallLogs:
import {
// ... 其他不变
queryAiCallLogs, // 替换 listAiCallLogs
// ... 其他不变
} from '@/api/aiconfig'
- Step 2: 新增响应式数据
在 logs 变量下方添加:
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 expandedLogs = ref<Set<string>>(new Set())
修改 logs 的统计卡片显示:
// 将 stat-log 中的 {{ logs.length }} 改为 {{ logs.total }}
- Step 3: 新增辅助函数
在 statusLabel 函数下方添加:
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 toggleExpand(row: AiCallLog) {
const id = row.id
if (expandedLogs.value.has(id)) {
expandedLogs.value.delete(id)
} else {
expandedLogs.value.add(id)
}
}
function isExpanded(row: AiCallLog): boolean {
return expandedLogs.value.has(row.id)
}
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 = ''
} else {
const range = buildTimeRange(val)
logQuery.startTime = range.startTime
logQuery.endTime = range.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()
}
- Step 4: 修改 loadAll 中的日志加载逻辑
将 loadAll 函数中的 listAiCallLogs(80) 调用改为 queryAiCallLogs({ pageNum: 1, pageSize: 20 }):
async function loadAll() {
loading.value = true
try {
const [providerRes, endpointRes, sceneRes, logRes] = await Promise.all([
listAiProviders(),
listAiEndpoints(),
listAiScenes(),
queryAiCallLogs({ pageNum: 1, pageSize: 20 })
])
providers.value = providerRes.data || []
endpoints.value = endpointRes.data || []
scenes.value = sceneRes.data || []
logs.value = logRes.data || { records: [], total: 0, current: 1, size: 20, pages: 0 }
// 重置筛选条件
selectedTimeRange.value = ''
Object.assign(logQuery, {
status: '', sceneCode: '', providerCode: '', endpointCode: '',
startTime: '', endTime: '', keyword: '', pageNum: 1, pageSize: 20
})
} finally {
loading.value = false
}
}
- Step 5: 改造调用日志 Tab 的模板
将调用日志 <el-tab-pane label="调用日志" name="logs"> 的内容替换为:
<el-tab-pane label="调用日志" name="logs">
<!-- 筛选栏 -->
<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="running" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
</el-select>
<el-select v-model="logQuery.sceneCode" placeholder="场景" clearable style="width: 160px" @change="searchLogs">
<el-option v-for="s in scenes" :key="s.sceneCode" :label="s.sceneName" :value="s.sceneCode" />
</el-select>
<el-select v-model="logQuery.providerCode" placeholder="服务商" clearable style="width: 150px" @change="searchLogs">
<el-option v-for="p in providers" :key="p.providerCode" :label="p.providerName" :value="p.providerCode" />
</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-if="!selectedTimeRange"
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DDTHH:mm:ss"
@change="handleDateRangeChange"
/>
<el-input v-model="logQuery.keyword" placeholder="搜索入参/出参" clearable style="width: 200px" @keyup.enter="searchLogs">
<template #suffix>
<el-icon @click="searchLogs"><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" :icon="Search" @click="searchLogs">搜索</el-button>
<el-button @click="loadAll">重置</el-button>
</div>
<!-- 日志表格 -->
<el-table :data="logs.records" v-loading="loading" stripe empty-text="暂无调用日志" row-key="id">
<el-table-column type="expand" width="40">
<template #default="{ row }">
<div class="log-expand">
<div class="expand-preview">
<span class="preview-label">入参预览:</span>
<span class="preview-text">{{ previewText(row.inputText) }}</span>
</div>
<div class="expand-preview">
<span class="preview-label">出参预览:</span>
<span class="preview-text">{{ previewText(row.outputText) }}</span>
</div>
<el-button link type="primary" @click="openDetail(row)">查看完整详情</el-button>
</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">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'" effect="plain">
{{ 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">
<template #default="{ row }">{{ formatMs(row.firstTokenMs) }}</template>
</el-table-column>
<el-table-column prop="durationMs" label="总耗时" width="100" align="center">
<template #default="{ row }">{{ formatMs(row.durationMs) }}</template>
</el-table-column>
<el-table-column prop="errorCode" label="错误码" min-width="180" show-overflow-tooltip />
</el-table>
<!-- 分页 -->
<el-pagination
v-if="logs.total > 0"
v-model:current-page="logQuery.pageNum"
v-model:page-size="logQuery.pageSize"
:total="logs.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</el-tab-pane>
注意:需要在 script 中补充 dateRange 和 handleDateRangeChange:
const dateRange = ref<[string, string] | null>(null)
function handleDateRangeChange(val: [string, string] | null) {
if (val) {
logQuery.startTime = val[0]
logQuery.endTime = val[1]
} else {
logQuery.startTime = ''
logQuery.endTime = ''
}
}
- Step 6: 在模板底部添加详情弹窗
在 </div>(最外层 div 结束)之前添加:
<AiCallLogDetailDialog v-model="detailDialogVisible" :log="selectedLog" />
- Step 7: 添加样式
在 <style scoped> 中添加:
.log-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
}
.log-expand {
padding: 12px 24px;
background: rgba(0, 0, 0, 0.15);
border-radius: 6px;
}
.expand-preview {
display: flex;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
}
.preview-label {
color: var(--ls-text-muted);
flex-shrink: 0;
}
.preview-text {
color: rgba(226, 232, 240, 0.85);
word-break: break-all;
}
- Step 8: Commit
git add web-admin/src/views/aiconfig/AiRoutingList.vue
git commit -m "feat: 调用日志 Tab 增加筛选栏、展开行、分页和详情弹窗"
Task 9: 编译验证后端
Files: N/A(验证任务)
- Step 1: 编译后端
cd backend-single
mvn clean install -DskipTests
Expected: BUILD SUCCESS
- Step 2: 如编译失败,修复错误
常见问题:
- 缺少 import → 添加对应 import
PageResult构造问题 → 确认使用了PageResult.of(page)
Task 10: 启动服务并浏览器验证
Files: N/A(验证任务)
- Step 1: 启动后端
cd backend-single
mvn spring-boot:run
- Step 2: 启动前端
cd web-admin
npm run dev
- Step 3: 浏览器验证
- 打开
http://localhost:5174(或实际端口) - 进入 AI 配置管理 → 调用日志 Tab
- 验证:
- 筛选栏显示正常(状态、场景、服务商、时间、关键词)
- 点击搜索,分页数据正确加载
- 点击表格行展开,显示入参/出参预览
- 点击「查看完整详情」打开弹窗
- 弹窗中基本信息、入参、出参、错误信息显示正确
- 入参/出参 JSON 格式化高亮显示
- 点击「复制」按钮成功复制 JSON
- 分页切换正常
- 浏览器 Console 无报错
- Step 4: Commit(如所有验证通过)
git commit --allow-empty -m "feat: AI 调用日志详情查看功能完成"
Self-Review Checklist
Spec Coverage
| Spec 需求 | 对应 Task |
|---|---|
| 展开行快速预览入参/出参 | Task 8 Step 5 |
| 详情弹窗展示所有字段 | Task 4 |
| JSON 格式化高亮 | Task 3 |
| 一键复制 | Task 3 |
| 分页查询 | Task 6 + Task 8 |
| 状态筛选 | Task 8 Step 5 |
| 场景/服务商/接口筛选 | Task 8 Step 5 |
| 时间范围筛选 | Task 8 Step 3/5 |
| 关键词搜索入参出参 | Task 6 + Task 8 |
| POST 分页接口 | Task 7 |
| 保留旧 GET 接口 | Task 7(GET 方法不动) |
Placeholder Scan
- 无 "TBD"、"TODO"、"implement later"
- 所有步骤包含完整代码
- 无 "Similar to Task N" 引用
Type Consistency
LogQueryParams在 common.ts、api、aiconfig 中一致PageResult<T>使用项目中已有的定义(current/size而非pageNum/pageSize)AiCallLog类型复用已有定义