---
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、标点符号分别用正则匹配并包裹 `<span class="...">` 后输出。

**`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<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 变更

```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<PageResult<AiCallLog>> queryCallLogs(@RequestBody @Valid AiCallLogQueryRequest request)
```

### Request 对象

存放路径：`server/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<AiCallLog> query(AiCallLogQueryRequest request);
```

原 `latest(Integer limit)` 方法保留（前端旧接口调用或后台其他功能可能依赖）。

### Service 查询逻辑

```java
@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` 索引）
- 当前日志量不大，先使用 LIKE。

## 数据模型

复用现有 `AiCallLog` 实体，无需新增表或字段。详情弹窗展示字段：

| 字段 | 说明 |
|------|------|
| createTime | 调用时间 |
| sceneCode | 场景编码 |
| providerCode | 服务商编码 |
| endpointCode | 接口编码 |
| status | 状态 |
| firstTokenMs | 首字耗时 |
| durationMs | 总耗时 |
| streamChunks | 片段数 |
| inputText | 入参（JSON 格式化） |
| outputText | 出参（JSON 格式化） |
| errorCode | 错误码 |
| errorMessage | 错误信息 |
| userId | 用户 ID |
| requestId | 请求 ID |
