Files
happy-life-star/docs/superpowers/plans/2026-05-24-ai-call-log-detail.md
T
2026-05-24 11:43:40 +08:00

1036 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 文件末尾追加:
```typescript
export interface LogQueryParams {
status?: string
sceneCode?: string
providerCode?: string
endpointCode?: string
startTime?: string
endTime?: string
keyword?: string
pageNum?: number
pageSize?: number
}
```
- [ ] **Step 2: Commit**
```bash
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 语句中追加:
```typescript
import type { LogQueryParams } from '@/types/common'
import type { PageResult } from '@/types/common'
```
- [ ] **Step 2: 新增 queryAiCallLogs 接口函数**
`listAiCallLogs` 函数下方追加:
```typescript
export function queryAiCallLogs(params: LogQueryParams) {
return request<PageResult<AiCallLog>>({
url: '/ai/call-logs',
method: 'post',
data: params
})
}
```
**注意:** 保留原有的 `listAiCallLogs` 函数不动,新旧接口共存。
- [ ] **Step 3: Commit**
```bash
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**
```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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 字符串值(绿色)
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**
```bash
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**
```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**
```bash
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 目录存在**
```bash
ls backend-single/src/main/java/com/emotion/dto/request/ai/
```
如果不存在则创建:
```bash
mkdir -p backend-single/src/main/java/com/emotion/dto/request/ai
```
- [ ] **Step 2: 创建 AiCallLogQueryRequest.java**
```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**
```bash
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` 方法下方添加:
```java
import com.emotion.common.PageResult;
import com.emotion.dto.request.ai.AiCallLogQueryRequest;
// ... 在接口中
PageResult<AiCallLog> query(AiCallLogQueryRequest request);
```
- [ ] **Step 2: 在 AiCallLogServiceImpl 中实现 query 方法**
`AiCallLogServiceImpl.java``latest` 方法下方添加:
```java
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**
```bash
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 区域添加:
```java
import com.emotion.common.PageResult;
import com.emotion.dto.request.ai.AiCallLogQueryRequest;
import javax.validation.Valid;
```
- [ ] **Step 2: 在 callLogs GET 方法下方新增 POST 方法**
`callLogs` 方法(第 157-161 行)下方添加:
```java
@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**
```bash
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 区域添加:
```typescript
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`
```typescript
import {
// ... 其他不变
queryAiCallLogs, // 替换 listAiCallLogs
// ... 其他不变
} from '@/api/aiconfig'
```
- [ ] **Step 2: 新增响应式数据**
`logs` 变量下方添加:
```typescript
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` 的统计卡片显示:
```typescript
// 将 stat-log 中的 {{ logs.length }} 改为 {{ logs.total }}
```
- [ ] **Step 3: 新增辅助函数**
`statusLabel` 函数下方添加:
```typescript
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 })`
```typescript
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">` 的内容替换为:
```vue
<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`
```typescript
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 结束)之前添加:
```vue
<AiCallLogDetailDialog v-model="detailDialogVisible" :log="selectedLog" />
```
- [ ] **Step 7: 添加样式**
`<style scoped>` 中添加:
```css
.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**
```bash
git add web-admin/src/views/aiconfig/AiRoutingList.vue
git commit -m "feat: 调用日志 Tab 增加筛选栏、展开行、分页和详情弹窗"
```
---
## Task 9: 编译验证后端
**Files:** N/A(验证任务)
- [ ] **Step 1: 编译后端**
```bash
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: 启动后端**
```bash
cd backend-single
mvn spring-boot:run
```
- [ ] **Step 2: 启动前端**
```bash
cd web-admin
npm run dev
```
- [ ] **Step 3: 浏览器验证**
1. 打开 `http://localhost:5174`(或实际端口)
2. 进入 AI 配置管理 → 调用日志 Tab
3. 验证:
- [ ] 筛选栏显示正常(状态、场景、服务商、时间、关键词)
- [ ] 点击搜索,分页数据正确加载
- [ ] 点击表格行展开,显示入参/出参预览
- [ ] 点击「查看完整详情」打开弹窗
- [ ] 弹窗中基本信息、入参、出参、错误信息显示正确
- [ ] 入参/出参 JSON 格式化高亮显示
- [ ] 点击「复制」按钮成功复制 JSON
- [ ] 分页切换正常
- [ ] 浏览器 Console 无报错
- [ ] **Step 4: Commit(如所有验证通过)**
```bash
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 7GET 方法不动)|
### Placeholder Scan
- 无 "TBD"、"TODO"、"implement later"
- 所有步骤包含完整代码
- 无 "Similar to Task N" 引用
### Type Consistency
- `LogQueryParams` 在 common.ts、api、aiconfig 中一致
- `PageResult<T>` 使用项目中已有的定义(`current`/`size` 而非 `pageNum`/`pageSize`
- `AiCallLog` 类型复用已有定义