--- author: Peanut created_at: 2026-05-24 purpose: AI 调用日志详情查看功能设计文档 --- # AI 调用日志详情查看功能设计 ## 背景 后台管理 `web-admin` 中 AI 配置管理的「调用日志」页签目前仅展示基础列表信息(调用时间、场景、服务商、接口、状态、片段数、首字耗时、总耗时、错误码)。入参(`inputText`)、出参(`outputText`)、错误详情(`errorMessage`)等字段已有存储但完全隐藏,不利于后期统计和问题排查。 ## 目标 1. 在调用日志列表中支持展开行快速预览入参/出参 2. 提供详情弹窗,完整展示所有字段,入参和出参需 JSON 格式化高亮显示 3. 支持一键复制入参/出参 JSON 内容 4. 后端支持分页、多条件筛选、关键词搜索入参出参内容 ## 交互设计 ### 列表页(调用日志 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、标点符号分别用正则匹配并包裹 `` 后输出。 **`JsonViewer.vue` Props 接口:** ```typescript 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()` 中已有),无需额外接口。 ### 展开行预览逻辑 ```typescript 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` 中新增通用分页类型(如文件不存在则创建): ```typescript export interface LogQueryParams { status?: string sceneCode?: string providerCode?: string endpointCode?: string startTime?: string endTime?: string keyword?: string pageNum?: number pageSize?: number } export interface PageResult { records: T[] total: number pageNum: number pageSize: number } ``` `AiRoutingList.vue` 中的 `logs` 数据从 `AiCallLog[]` 改为分页结构:`const logs = ref>({ records: [], total: 0, pageNum: 1, pageSize: 20 })`,表格数据源绑定 `logs.value.records`。 ### API 变更 ```typescript // 原接口(废弃,仅 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 设计 ### 接口定义 ```java @PostMapping("/call-logs") @Operation(summary = "分页查询 AI 调用日志") public Result> queryCallLogs(@RequestBody @Valid AiCallLogQueryRequest request) ``` ### Request 对象 存放路径:`backend-single/src/main/java/com/emotion/dto/request/ai/AiCallLogQueryRequest.java` ```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` 接口中新增方法: ```java PageResult query(AiCallLogQueryRequest request); ``` 原 `latest(Integer limit)` 方法保留(前端旧接口调用或后台其他功能可能依赖)。 ### Service 查询逻辑 ```java @Override public PageResult query(AiCallLogQueryRequest request) { Page pageParam = new Page<>(request.getPageNum(), request.getPageSize()); LambdaQueryWrapper 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 page = page(pageParam, wrapper); // 转换为 PageResult,字段名与前端对齐 PageResult 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` 索引) - 当前日志量不大,先使用 LIKE。 ## 数据模型 复用现有 `AiCallLog` 实体,无需新增表或字段。详情弹窗展示字段: | 字段 | 说明 | |------|------| | createTime | 调用时间 | | sceneCode | 场景编码 | | providerCode | 服务商编码 | | endpointCode | 接口编码 | | status | 状态 | | firstTokenMs | 首字耗时 | | durationMs | 总耗时 | | streamChunks | 片段数 | | inputText | 入参(JSON 格式化) | | outputText | 出参(JSON 格式化) | | errorCode | 错误码 | | errorMessage | 错误信息 | | userId | 用户 ID | | requestId | 请求 ID |