305 lines
12 KiB
Vue
305 lines
12 KiB
Vue
<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()
|
||
},
|
||
})
|
||
|
||
async function load(silent = false) {
|
||
if (!silent) loading.value = true
|
||
try {
|
||
const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize })
|
||
rows.value = data.items
|
||
pagination.itemCount = data.total
|
||
} catch {
|
||
// 自动刷新失败静默,手动路径由全局兜底
|
||
} finally {
|
||
if (!silent) 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)
|
||
|
||
async function loadContent(callLogId: number) {
|
||
contentLog.value = null
|
||
try {
|
||
const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId })
|
||
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>
|