56535dbcb0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1036 lines
30 KiB
Markdown
1036 lines
30 KiB
Markdown
# 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, '&')
|
||
.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**
|
||
|
||
```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 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` 类型复用已有定义
|