feat: 新增 JsonViewer 和 AiCallLogDetailDialog 组件

This commit is contained in:
2026-05-24 11:48:07 +08:00
parent 902068387b
commit 3888a40a5c
2 changed files with 287 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
<template>
<div class="json-viewer">
<div class="json-header">
<span class="json-title">{{ title }}</span>
<el-button v-if="showCopy" size="small" @click="handleCopy">
<span class="copy-icon">📋</span> 复制
</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'
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>')
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;
}
.copy-icon {
margin-right: 4px;
}
.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>
@@ -0,0 +1,164 @@
<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>