feat: 新增 JsonViewer 和 AiCallLogDetailDialog 组件
This commit is contained in:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
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>
|
||||
Reference in New Issue
Block a user