# 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 增加筛选栏、展开行、分页 |
| `server/src/main/java/com/emotion/dto/request/ai/AiCallLogQueryRequest.java` | 创建 | 日志查询请求 DTO |
| `server/src/main/java/com/emotion/service/AiCallLogService.java` | 修改 | 新增 `query` 方法签名 |
| `server/src/main/java/com/emotion/service/impl/AiCallLogServiceImpl.java` | 修改 | 实现 `query` 分页查询 |
| `server/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, '&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**

```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: `server/src/main/java/com/emotion/dto/request/ai/AiCallLogQueryRequest.java`

- [ ] **Step 1: 确认 dto/request/ai 目录存在**

```bash
ls server/src/main/java/com/emotion/dto/request/ai/
```

如果不存在则创建：

```bash
mkdir -p server/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 server/src/main/java/com/emotion/dto/request/ai/AiCallLogQueryRequest.java
git commit -m "feat: 新增 AI 调用日志查询请求 DTO"
```

---

## Task 6: 后端 Service 接口和实现

**Files:**
- Modify: `server/src/main/java/com/emotion/service/AiCallLogService.java`
- Modify: `server/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 server/src/main/java/com/emotion/service/AiCallLogService.java
git add server/src/main/java/com/emotion/service/impl/AiCallLogServiceImpl.java
git commit -m "feat: AI 调用日志 Service 增加分页查询方法"
```

---

## Task 7: 后端 Controller 新增 POST 接口

**Files:**
- Modify: `server/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 server/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 server
mvn clean install -DskipTests
```

Expected: BUILD SUCCESS

- [ ] **Step 2: 如编译失败，修复错误**

常见问题：
- 缺少 import → 添加对应 import
- `PageResult` 构造问题 → 确认使用了 `PageResult.of(page)`

---

## Task 10: 启动服务并浏览器验证

**Files:** N/A（验证任务）

- [ ] **Step 1: 启动后端**

```bash
cd server
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` 类型复用已有定义
