Files
oci-portal-dash/src/components/logs/AiCallLogPanel.vue
T
2026-07-22 16:51:45 +08:00

314 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NDataTable, NModal, NTooltip, type DataTableColumns } from 'naive-ui'
import { computed, h, onMounted, reactive, ref } from 'vue'
import { listAiCallLogs, listAiContentLogs } from '@/api/aigateway'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import PanelHeader from '@/components/PanelHeader.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
import { fmtTime } from '@/composables/useFormat'
import { useIsMobile } from '@/composables/useIsMobile'
import { useMobileModal } from '@/composables/useMobileModal'
import type { AiCallLog, AiContentLog } from '@/types/api'
import { useToast } from '@/composables/useToast'
const message = useToast()
const isMobile = useIsMobile()
const { modalStyle, modalClass } = useMobileModal(600)
const rows = ref<AiCallLog[]>([])
const loading = ref(false)
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
showSizePicker: true,
pageSizes: [20, 50, 100],
// 移动端页码槽位放不下会被裁切,切 simple 形态(‹ n/总页 )
get simple() {
return isMobile.value
},
onUpdatePage: (p: number) => {
pagination.page = p
void load()
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
pagination.page = 1
void load()
},
})
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
// 不得覆盖已切换的新页
let loadSeq = 0
async function load(silent = false) {
const seq = ++loadSeq
if (!silent) loading.value = true
try {
const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize })
if (seq !== loadSeq) return
rows.value = data.items
pagination.itemCount = data.total
} catch {
// 自动刷新失败静默,手动路径由全局兜底
} finally {
if (!silent && seq === loadSeq) loading.value = false
}
}
onMounted(() => void load())
useAutoRefresh(() => load(true))
function fmtLatency(ms: number) {
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`
}
function statusCell(row: AiCallLog) {
const ok = row.status >= 200 && row.status < 300
const badge = h(StatusBadge, { kind: ok ? 'run' : 'term', label: String(row.status) })
if (ok || !row.errMsg) return badge
return h(
NTooltip,
{ style: { maxWidth: '380px' } },
{
trigger: () => h('span', { class: 'inline-flex cursor-help' }, [badge]),
default: () => h('span', { class: 'text-xs leading-relaxed whitespace-pre-wrap break-all' }, row.errMsg),
},
)
}
function tokensCell(row: AiCallLog) {
if (row.errMsg && !row.totalTokens) return h('span', { class: 'text-[13px] text-ink-3' }, '—')
const flow = row.stream ? ' · 流' : ''
return h('span', { class: 'mono text-[13px] whitespace-nowrap' }, `${row.promptTokens} / ${row.completionTokens}${flow}`)
}
const columns: DataTableColumns<AiCallLog> = [
{ title: '时间', key: 'createdAt', width: 150, render: (r) => h('span', { class: 'text-[13px] whitespace-nowrap text-ink-2' }, fmtTime(r.createdAt)) },
{ title: '密钥', key: 'keyName', width: 140, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px]' }, r.keyName || '—') },
{ title: '请求 IP', key: 'clientIp', width: 130, render: (r) => (r.clientIp ? h('span', { class: 'mono text-[12.5px] text-ink-2' }, r.clientIp) : h('span', { class: 'text-[13px] text-ink-3' }, '—')) },
{ title: '模型', key: 'model', minWidth: 220, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'mono text-xs' }, r.model) },
{ title: '渠道', key: 'channelName', minWidth: 180, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px]' }, r.channelName || '—') },
{ title: '状态', key: 'status', width: 80, render: statusCell },
{ title: 'Tokens(入/出)', key: 'tokens', width: 190, render: tokensCell },
{ title: '耗时', key: 'latencyMs', width: 80, render: (r) => h('span', { class: 'mono text-[13px]' }, fmtLatency(r.latencyMs)) },
{ title: '重试', key: 'retries', width: 60, render: (r) => h('span', { class: 'mono text-[13px]' }, String(r.retries)) },
]
// ---- 行点击详情 ----
const EP_PATH: Record<string, string> = {
openai: '/ai/v1/chat/completions',
anthropic: '/ai/v1/messages',
responses: '/ai/v1/responses',
}
const detail = ref<AiCallLog | null>(null)
const showDetail = ref(false)
// ---- 内容日志正文(仅密钥内容日志窗口期内的调用存在) ----
const contentLog = ref<AiContentLog | null>(null)
// 正文加载代次:快速连开两条日志时,先开那条的迟到正文不得盖住当前详情
let contentSeq = 0
async function loadContent(callLogId: number) {
const seq = ++contentSeq
contentLog.value = null
try {
const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId })
if (seq === contentSeq) contentLog.value = items[0] ?? null
} catch {
// 正文加载失败不阻塞元数据详情
}
}
/** 展示层深截断:base64 data URI 只留协议头与少量前缀,多轮对话里的
* 长文本(历史回答/长 prompt)留前 500 字符;存储仍是完整快照,全文走「复制完整」 */
function truncateText(s: string): string {
const b64 = /^(data:[\w/+.-]+;base64,)/.exec(s)
if (b64) {
const keep = b64[1].length + 24
return s.length > keep ? `${s.slice(0, keep)}…(已截断,共 ${s.length} 字符)` : s
}
if (s.length > 600) return `${s.slice(0, 500)}…(已截断,共 ${s.length} 字符)`
return s
}
function truncateDeep(v: unknown): unknown {
if (typeof v === 'string') return truncateText(v)
if (Array.isArray(v)) return v.map(truncateDeep)
if (v && typeof v === 'object')
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, truncateDeep(x)]))
return v
}
/** 正文按 JSON 美化并深截断超长值;非 JSON(如 64KB 尾部截断)按文本截断展示 */
function fmtBody(s: string) {
try {
return JSON.stringify(truncateDeep(JSON.parse(s)), null, 2)
} catch {
return s.length > 2000 ? `${s.slice(0, 2000)}…(内容过长,完整正文请复制查看)` : s
}
}
async function copyBody(text: string) {
try {
await navigator.clipboard.writeText(text)
message.success('已复制完整正文')
} catch {
message.error('复制失败,请手动选择复制')
}
}
const rowProps = (row: AiCallLog) => ({
style: 'cursor: pointer',
onClick: () => {
detail.value = row
showDetail.value = true
void loadContent(row.id)
},
})
const detailOk = computed(() => !!detail.value && detail.value.status >= 200 && detail.value.status < 300)
const heroSub = computed(() => {
const d = detail.value
if (!d) return ''
const path = EP_PATH[d.endpoint] ?? d.endpoint
return `POST ${path} · ${d.stream ? '流式(SSE)' : '非流式'} · ${fmtLatency(d.latencyMs)}`
})
const heroChips = computed<HeroChip[]>(() => {
const d = detail.value
if (!d) return []
const chips: HeroChip[] = [{ label: '密钥', value: d.keyName || '—' }]
if (d.clientIp) chips.push({ label: '来源 IP', value: d.clientIp, mono: true })
chips.push({ label: '时间', value: fmtTime(d.createdAt) })
return chips
})
const detailRows = computed<DetailRow[]>(() => {
const d = detail.value
if (!d) return []
const list: DetailRow[] = []
if (d.errMsg) list.push({ label: '失败原因', value: d.errMsg, tone: 'err', span: 2 })
list.push({ label: '渠道', value: d.channelName || '—(未命中渠道)' })
list.push({ label: '重试次数', value: String(d.retries), mono: true })
return list
})
/** 失败且无用量时隐藏 Token 区(无意义的 0/0) */
const showTokens = computed(() => detailOk.value || (detail.value?.totalTokens ?? 0) > 0)
const footerText = computed(() => {
if (contentLog.value) return '该调用命中密钥内容日志窗口,正文为截断快照(上限 64KB,保留 7 天 / 1 万行)'
return detailOk.value ? '无计费信息,OCI 成本见总览成本卡' : '仅记录元数据与 token 用量,不含任何对话内容'
})
</script>
<template>
<div class="panel">
<PanelHeader
title="AI 调用日志"
desc="仅元数据与 token 用量;保留 90 天 / 5 万行"
/>
<NDataTable
remote
:columns="columns"
:data="rows"
:loading="loading"
:pagination="pagination"
:bordered="false"
:row-props="rowProps"
:scroll-x="1230"
size="small"
/>
<NModal v-model:show="showDetail" preset="card" closable :style="modalStyle" :class="modalClass">
<template #header>
<DetailHero
v-if="detail"
:kind="`AI 调用 · ${detail.endpoint.toUpperCase()} 协议`"
:title="detail.model"
title-mono
:sub="heroSub"
:chips="heroChips"
>
<StatusBadge
:kind="detailOk ? 'run' : 'term'"
:label="`${detailOk ? '成功' : '失败'} ${detail.status}`"
:dot="false"
/>
</DetailHero>
</template>
<div v-if="detail" class="flex flex-col gap-3.5">
<DetailFieldList :rows="detailRows" :cols="2" />
<div v-if="showTokens" class="overflow-hidden rounded-[10px] border border-line-soft">
<div class="flex items-baseline gap-2 border-b border-line-soft bg-wash px-3.5 py-2">
<span class="text-xs font-semibold text-ink-2">Token 明细</span>
<span class="text-[11px] text-ink-3">仅用量计数 · 不含对话内容</span>
</div>
<div class="grid" :class="detail.cachedTokens > 0 ? 'grid-cols-4' : 'grid-cols-3'">
<div class="px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold">{{ detail.promptTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">输入 Token</div>
</div>
<div v-if="detail.cachedTokens > 0" class="border-l border-line-soft px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold text-ok">{{ detail.cachedTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">缓存读取</div>
</div>
<div class="border-l border-line-soft px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold">{{ detail.completionTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">输出 Token</div>
</div>
<div class="border-l border-line-soft px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold">{{ detail.totalTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">合计</div>
</div>
</div>
</div>
<template v-if="contentLog">
<div class="overflow-hidden rounded-[10px] border border-line-soft">
<div class="flex items-baseline gap-2 border-b border-line-soft bg-wash px-3.5 py-2">
<span class="text-xs font-semibold text-ink-2">请求正文</span>
<span class="text-[11px] text-ink-3">内容日志 · 超长值已截断展示</span>
<span class="flex-1"></span>
<button
type="button"
class="cursor-pointer text-[11px] font-medium text-accent hover:text-accent-hover"
@click="copyBody(contentLog.requestBody)"
>
复制完整
</button>
</div>
<pre class="mono max-h-56 overflow-auto px-3.5 py-2.5 text-[11.5px] leading-relaxed break-all whitespace-pre-wrap">{{ fmtBody(contentLog.requestBody) }}</pre>
</div>
<div v-if="contentLog.responseBody" class="overflow-hidden rounded-[10px] border border-line-soft">
<div class="flex items-baseline gap-2 border-b border-line-soft bg-wash px-3.5 py-2">
<span class="text-xs font-semibold text-ink-2">响应正文</span>
<span class="text-[11px] text-ink-3">内容日志 · 超长值已截断展示</span>
<span class="flex-1"></span>
<button
type="button"
class="cursor-pointer text-[11px] font-medium text-accent hover:text-accent-hover"
@click="copyBody(contentLog.responseBody)"
>
复制完整
</button>
</div>
<pre class="mono max-h-56 overflow-auto px-3.5 py-2.5 text-[11.5px] leading-relaxed break-all whitespace-pre-wrap">{{ fmtBody(contentLog.responseBody) }}</pre>
</div>
<div v-else class="text-[11px] text-ink-3">无响应正文:流式响应向量结果与失败调用只记请求</div>
</template>
<div class="text-xs leading-relaxed text-ink-3">
{{ footerText }}
</div>
</div>
</NModal>
</div>
</template>