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

30 KiB
Raw Blame History

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 文件末尾追加:

export interface LogQueryParams {
  status?: string
  sceneCode?: string
  providerCode?: string
  endpointCode?: string
  startTime?: string
  endTime?: string
  keyword?: string
  pageNum?: number
  pageSize?: number
}
  • Step 2: Commit
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 语句中追加:

import type { LogQueryParams } from '@/types/common'
import type { PageResult } from '@/types/common'
  • Step 2: 新增 queryAiCallLogs 接口函数

listAiCallLogs 函数下方追加:

export function queryAiCallLogs(params: LogQueryParams) {
  return request<PageResult<AiCallLog>>({
    url: '/ai/call-logs',
    method: 'post',
    data: params
  })
}

注意: 保留原有的 listAiCallLogs 函数不动,新旧接口共存。

  • Step 3: Commit
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

<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
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

<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
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 目录存在

ls backend-single/src/main/java/com/emotion/dto/request/ai/

如果不存在则创建:

mkdir -p backend-single/src/main/java/com/emotion/dto/request/ai
  • Step 2: 创建 AiCallLogQueryRequest.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
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.javalatest 方法下方添加:

import com.emotion.common.PageResult;
import com.emotion.dto.request.ai.AiCallLogQueryRequest;

// ... 在接口中
PageResult<AiCallLog> query(AiCallLogQueryRequest request);
  • Step 2: 在 AiCallLogServiceImpl 中实现 query 方法

AiCallLogServiceImpl.javalatest 方法下方添加:

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
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 区域添加:

import com.emotion.common.PageResult;
import com.emotion.dto.request.ai.AiCallLogQueryRequest;
import javax.validation.Valid;
  • Step 2: 在 callLogs GET 方法下方新增 POST 方法

callLogs 方法(第 157-161 行)下方添加:

@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
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 区域添加:

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

import {
  // ... 其他不变
  queryAiCallLogs,  // 替换 listAiCallLogs
  // ... 其他不变
} from '@/api/aiconfig'
  • Step 2: 新增响应式数据

logs 变量下方添加:

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 的统计卡片显示:

// 将 stat-log 中的 {{ logs.length }} 改为 {{ logs.total }}
  • Step 3: 新增辅助函数

statusLabel 函数下方添加:

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 })

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"> 的内容替换为:

<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 中补充 dateRangehandleDateRangeChange

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 结束)之前添加:

<AiCallLogDetailDialog v-model="detailDialogVisible" :log="selectedLog" />
  • Step 7: 添加样式

<style scoped> 中添加:

.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
git add web-admin/src/views/aiconfig/AiRoutingList.vue
git commit -m "feat: 调用日志 Tab 增加筛选栏、展开行、分页和详情弹窗"

Task 9: 编译验证后端

Files: N/A(验证任务)

  • Step 1: 编译后端
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: 启动后端
cd backend-single
mvn spring-boot:run
  • Step 2: 启动前端
cd web-admin
npm run dev
  • Step 3: 浏览器验证
  1. 打开 http://localhost:5174(或实际端口)
  2. 进入 AI 配置管理 → 调用日志 Tab
  3. 验证:
    • 筛选栏显示正常(状态、场景、服务商、时间、关键词)
    • 点击搜索,分页数据正确加载
    • 点击表格行展开,显示入参/出参预览
    • 点击「查看完整详情」打开弹窗
    • 弹窗中基本信息、入参、出参、错误信息显示正确
    • 入参/出参 JSON 格式化高亮显示
    • 点击「复制」按钮成功复制 JSON
    • 分页切换正常
    • 浏览器 Console 无报错
  • Step 4: Commit(如所有验证通过)
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 类型复用已有定义