- 删除 LogQueryParams 重复定义 - JSON 高亮改为无依赖正则方案(highlight.js 未安装) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
16 KiB
author, created_at, purpose
| author | created_at | purpose |
|---|---|---|
| Peanut | 2026-05-24 | AI 调用日志详情查看功能设计文档 |
AI 调用日志详情查看功能设计
背景
后台管理 web-admin 中 AI 配置管理的「调用日志」页签目前仅展示基础列表信息(调用时间、场景、服务商、接口、状态、片段数、首字耗时、总耗时、错误码)。入参(inputText)、出参(outputText)、错误详情(errorMessage)等字段已有存储但完全隐藏,不利于后期统计和问题排查。
目标
- 在调用日志列表中支持展开行快速预览入参/出参
- 提供详情弹窗,完整展示所有字段,入参和出参需 JSON 格式化高亮显示
- 支持一键复制入参/出参 JSON 内容
- 后端支持分页、多条件筛选、关键词搜索入参出参内容
交互设计
列表页(调用日志 Tab)
┌─────────────────────────────────────────────────────────────────────┐
│ [筛选栏] 状态[全部▼] 场景[全部▼] 时间[近7天▼] 关键词[____] [搜索] │
├─────────────────────────────────────────────────────────────────────┤
│ ▼ 调用时间 场景 服务商 接口 状态 ... │
│ ───────────────────────────────────────────────────────────────── │
│ ▶ 2025-05-24 script_gen dify dify.xxx 成功 ... │
│ ▼ 2025-05-24 emotion coze coze.summary 失败 ... │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 入参预览: {"messages":[{"role":"user","content":"..."}]} │ │
│ │ 出参预览: AI 分析完成,用户情绪状态为... │ │
│ │ [查看完整详情] │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ▶ 2025-05-23 short_story dify dify.story 成功 ... │
├─────────────────────────────────────────────────────────────────────┤
│ 共 156 条 [1][2][3]...[16] 每页 10/20/50 条 │
└─────────────────────────────────────────────────────────────────────┘
展开行交互:
- 点击行首展开/收起图标切换展开状态
- 展开区域显示入参和出参的前 200 字符预览(超长截断 +
...) - 截断逻辑确保不在 Unicode 转义序列中间截断(如
"中"),优先在空白字符处截断,保证预览可读性 - 提供「查看完整详情」按钮,点击打开详情弹窗
详情弹窗
┌────────────────────────────────────────────────────────────┐
│ 调用详情 [×] │
├────────────────────────────────────────────────────────────┤
│ 基本信息 │
│ ┌────────────┬────────────┬────────────┬────────────┐ │
│ │ 调用时间 │ 场景 │ 服务商 │ 接口 │ │
│ │ 状态 │ 首字耗时 │ 总耗时 │ 片段数 │ │
│ │ 用户 ID │ 请求 ID │ │ │ │
│ └────────────┴────────────┴────────────┴────────────┘ │
│ │
│ 入参 [📋 复制] │
│ ┌────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ "messages": [ │ │
│ │ { "role": "user", "content": "我最近很焦虑..." }│ │
│ │ ] │ │
│ │ } │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ 出参 [📋 复制] │
│ ┌────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ "response": "我理解你的感受..." │ │
│ │ } │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ 错误信息(仅失败时显示) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ AI_STREAM_INTERRUPTED: 连接超时 │ │
│ └────────────────────────────────────────────────────┘ │
├────────────────────────────────────────────────────────────┤
│ [关闭] │
└────────────────────────────────────────────────────────────┘
弹窗特性:
- 入参/出参使用代码高亮 + 等宽字体,支持纵向滚动
- 每个代码块右上角有「复制」按钮,点击复制原始 JSON 到剪贴板
- 错误信息区域仅在
status === 'failed'时显示,格式为{errorCode}: {errorMessage}(如AI_STREAM_INTERRUPTED: 连接超时) - 弹窗宽度 800px,响应式限制
max-width: 90vw,防止窄屏溢出 - 支持 ESC 关闭和点击遮罩关闭(Element Plus Dialog 默认行为)
前端组件设计
文件变更
| 文件 | 说明 |
|---|---|
web-admin/src/views/aiconfig/AiRoutingList.vue |
修改:增加展开行、筛选栏、分页逻辑 |
web-admin/src/views/aiconfig/components/AiCallLogDetailDialog.vue |
新增:详情弹窗 |
web-admin/src/components/JsonViewer.vue |
新增:通用 JSON 格式化展示组件 |
JSON 高亮方案
JSON 格式化使用原生 JSON.stringify(obj, null, 2) 生成,再通过简单正则替换为带 class 的 HTML 实现语法高亮。不引入新依赖,实现轻量。高亮规则:key、字符串、数字/布尔/null、标点符号分别用正则匹配并包裹 <span class="..."> 后输出。
JsonViewer.vue Props 接口:
interface JsonViewerProps {
data: string | object // JSON 字符串或对象
title?: string // 标题,如"入参""出参"
showCopy?: boolean // 是否显示复制按钮,默认 true
maxHeight?: string // 最大高度,默认 "300px"
}
配色方案:
- 字符串值 →
#a5d6a7(浅绿) - 数字/布尔 →
#90caf9(浅蓝) - key →
#ce93d8(浅紫) - 标点符号 →
#b0bec5(灰)
背景使用深色主题(#1e1e1e),与当前管理后台暗色风格一致。
非法 JSON 处理: 若 inputText/outputText 不是合法 JSON,直接展示原始文本(等宽字体、自动换行),不报错。
筛选栏参数
使用 web-admin/src/types/common.ts 中定义的 LogQueryParams 接口(见「类型定义」章节)。
时间筛选交互: 前端筛选栏提供快捷选项(近7天、近30天、自定义范围),由前端组件将快捷选项转换为 startTime/endTime 后传给后端。自定义范围时使用 Element Plus el-date-picker 选择起止时间。
- "近7天":今天 00:00:00 往前推 6 天(含今天)
- "近30天":今天 00:00:00 往前推 29 天(含今天)
- 自定义:用户选择起止日期,开始时间为所选日期 00:00:00,结束时间为所选日期 23:59:59
下拉数据来源: 场景、服务商、接口下拉选项复用页面已加载的 providers / endpoints / scenes 数据(loadAll() 中已有),无需额外接口。
展开行预览逻辑
function previewText(jsonStr: string, maxLen = 200): string {
if (!jsonStr) return '-'
if (jsonStr.length <= maxLen) return jsonStr
const truncated = jsonStr.slice(0, maxLen)
// 优先在空白字符处截断;无空白字符时直接截断到 maxLen
const lastSpace = truncated.lastIndexOf(' ')
const cutAt = lastSpace > maxLen * 0.5 ? lastSpace : maxLen
return truncated.slice(0, cutAt) + '...'
}
类型定义
在 web-admin/src/types/common.ts 中新增通用分页类型(如文件不存在则创建):
export interface LogQueryParams {
status?: string
sceneCode?: string
providerCode?: string
endpointCode?: string
startTime?: string
endTime?: string
keyword?: string
pageNum?: number
pageSize?: number
}
export interface PageResult<T> {
records: T[]
total: number
pageNum: number
pageSize: number
}
AiRoutingList.vue 中的 logs 数据从 AiCallLog[] 改为分页结构:const logs = ref<PageResult<AiCallLog>>({ records: [], total: 0, pageNum: 1, pageSize: 20 }),表格数据源绑定 logs.value.records。
API 变更
// 原接口(废弃,仅 AiRoutingList.vue 中使用,直接替换)
export function listAiCallLogs(limit = 50) { ... }
// 新接口
export function queryAiCallLogs(params: LogQueryParams) {
return request({ url: '/ai/call-logs', method: 'post', data: params })
}
兼容性说明: 经检查,listAiCallLogs 仅在 AiRoutingList.vue 的调用日志 Tab 中使用,无其他调用方。新接口使用 POST,旧接口使用 GET,路径相同方法不同,技术上可共存。实现时直接替换前端调用为 POST 新接口,旧 GET 接口保留不删除(避免影响其他潜在调用方)。
后端 API 设计
接口定义
@PostMapping("/call-logs")
@Operation(summary = "分页查询 AI 调用日志")
public Result<PageResult<AiCallLog>> queryCallLogs(@RequestBody @Valid AiCallLogQueryRequest request)
Request 对象
存放路径:backend-single/src/main/java/com/emotion/dto/request/ai/AiCallLogQueryRequest.java
@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;
}
Service 接口变更
在 AiCallLogService 接口中新增方法:
PageResult<AiCallLog> query(AiCallLogQueryRequest request);
原 latest(Integer limit) 方法保留(前端旧接口调用或后台其他功能可能依赖)。
Service 查询逻辑
@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);
// keyword 为空字符串或纯空格时不执行 LIKE 搜索(isNotBlank 已处理)
// MyBatis-Plus like 默认使用预编译语句,无 SQL 注入风险
// LIKE 是否区分大小写取决于数据库字符集(项目使用 utf8mb4,默认不区分大小写)
if (StringUtils.isNotBlank(request.getKeyword())) {
wrapper.and(w -> w.like(AiCallLog::getInputText, request.getKeyword())
.or()
.like(AiCallLog::getOutputText, request.getKeyword()));
}
Page<AiCallLog> page = page(pageParam, wrapper);
// 转换为 PageResult,字段名与前端对齐
PageResult<AiCallLog> result = new PageResult<>();
result.setRecords(page.getRecords());
result.setTotal(page.getTotal());
result.setPageNum((int) page.getCurrent());
result.setPageSize((int) page.getSize());
return result;
}
性能考虑
input_text和output_text可能存储大 JSON(聊天记录),LIKE 搜索在数据量极大时会变慢- 量化阈值: 当日志表数据量超过 10 万条,或
keyword搜索响应时间超过 500ms 时,启动优化评估:- 方案 1:为
input_text、output_text增加 MySQL 全文索引(FULLTEXT) - 方案 2:限制 keyword 搜索只查询近 30 天的数据(配合
create_time索引)
- 方案 1:为
- 当前日志量不大,先使用 LIKE。
数据模型
复用现有 AiCallLog 实体,无需新增表或字段。详情弹窗展示字段:
| 字段 | 说明 |
|---|---|
| createTime | 调用时间 |
| sceneCode | 场景编码 |
| providerCode | 服务商编码 |
| endpointCode | 接口编码 |
| status | 状态 |
| firstTokenMs | 首字耗时 |
| durationMs | 总耗时 |
| streamChunks | 片段数 |
| inputText | 入参(JSON 格式化) |
| outputText | 出参(JSON 格式化) |
| errorCode | 错误码 |
| errorMessage | 错误信息 |
| userId | 用户 ID |
| requestId | 请求 ID |