- 原始请求/响应不再上下堆叠,改为「请求 / 响应」两个标签页 - 单个内容区(pre 内滚动 + 自动换行),避免手机上需要长距离滚动 - 打开时默认停在有内容的标签(请求优先);仅有一个数据时只显示对应标签 - 弹窗标题补充模型名与协议信息
266 lines
10 KiB
Vue
266 lines
10 KiB
Vue
<template>
|
||
<div class="space-y-5">
|
||
<BreadcrumbHeader />
|
||
|
||
<!-- 汇总卡片 -->
|
||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||
<p class="text-xs text-base-content/50">请求总数</p>
|
||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(summary?.requests) }}</p>
|
||
</div>
|
||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||
<p class="text-xs text-base-content/50">输入 Tokens</p>
|
||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(summary?.input_tokens) }}</p>
|
||
</div>
|
||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||
<p class="text-xs text-base-content/50">输出 Tokens</p>
|
||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(summary?.output_tokens) }}</p>
|
||
</div>
|
||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||
<p class="text-xs text-base-content/50">总费用 (USD)</p>
|
||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtCost(summary?.cost) }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 筛选栏 -->
|
||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<select v-model="filters.protocol" class="select select-sm border-base-300 bg-base-100" @change="applyFilters">
|
||
<option value="">全部协议</option>
|
||
<option value="chat">chat</option>
|
||
<option value="messages">messages</option>
|
||
<option value="responses">responses</option>
|
||
</select>
|
||
<select v-model="filters.status" class="select select-sm border-base-300 bg-base-100" @change="applyFilters">
|
||
<option value="">全部状态</option>
|
||
<option value="success">成功</option>
|
||
<option value="error">失败</option>
|
||
<option value="canceled">已取消</option>
|
||
</select>
|
||
<input
|
||
v-model="filters.model"
|
||
class="input input-sm w-44 border-base-300 bg-base-100"
|
||
placeholder="模型名称"
|
||
@keyup.enter="applyFilters"
|
||
/>
|
||
<input
|
||
v-model="filters.userId"
|
||
class="input input-sm w-32 border-base-300 bg-base-100"
|
||
placeholder="用户 ID"
|
||
@keyup.enter="applyFilters"
|
||
/>
|
||
<button class="btn btn-primary btn-sm" @click="applyFilters">
|
||
<SearchIcon class="h-4 w-4" aria-hidden="true" />筛选
|
||
</button>
|
||
<button class="btn btn-ghost btn-sm" @click="resetFilters">
|
||
<RotateCcwIcon class="h-4 w-4" aria-hidden="true" />重置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 明细表格 -->
|
||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||
<div v-if="store.loading && !store.adminLogs.length" class="px-4 py-12 text-center text-sm text-base-content/50">加载中…</div>
|
||
<div v-else-if="store.adminLogs.length" class="overflow-x-auto">
|
||
<table class="table table-sm">
|
||
<thead>
|
||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||
<th>ID</th>
|
||
<th>用户</th>
|
||
<th>时间</th>
|
||
<th>模型</th>
|
||
<th>协议</th>
|
||
<th class="text-right">输入</th>
|
||
<th class="text-right">输出</th>
|
||
<th class="text-right">费用</th>
|
||
<th>状态</th>
|
||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="l in store.adminLogs" :key="l.id" class="border-base-300/40 hover:bg-base-200/50">
|
||
<td class="tabular-nums text-base-content/60">{{ l.id }}</td>
|
||
<td class="whitespace-nowrap font-medium">
|
||
<span v-if="l.username">{{ l.username }}</span>
|
||
<span v-else class="text-base-content/50">#{{ l.user_id }}</span>
|
||
</td>
|
||
<td class="whitespace-nowrap tabular-nums text-base-content/70">{{ fmtTime(l.created_at) }}</td>
|
||
<td class="max-w-40 truncate" :title="l.model_name">{{ l.model_name }}</td>
|
||
<td><span class="badge badge-ghost badge-sm">{{ l.protocol }}</span></td>
|
||
<td class="text-right tabular-nums">{{ fmtInt(l.input_tokens) }}</td>
|
||
<td class="text-right tabular-nums">{{ fmtInt(l.output_tokens) }}</td>
|
||
<td class="text-right tabular-nums">{{ fmtCost(l.cost) }}</td>
|
||
<td><span class="badge badge-sm" :class="statusClass(l.status)">{{ statusLabel(l.status) }}</span></td>
|
||
<td class="pr-3">
|
||
<div class="flex items-center justify-end gap-1">
|
||
<button
|
||
v-if="l.raw_request || l.raw_response"
|
||
class="btn btn-ghost btn-xs btn-square"
|
||
:aria-label="`View raw data for request ${l.id}`"
|
||
@click="viewRaw(l)"
|
||
>
|
||
<FileTextIcon class="h-4 w-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div v-else class="px-4 py-12 text-center text-sm text-base-content/50">暂无用量记录</div>
|
||
<Pagination
|
||
v-if="store.adminLogsTotal > 0"
|
||
:current-page="page"
|
||
:total-items="store.adminLogsTotal"
|
||
:page-size="pageSize"
|
||
:page-size-options="[10, 20, 50, 100]"
|
||
@change-page="changePage"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 原始请求/响应 查看弹窗 -->
|
||
<dialog ref="rawModal" class="modal">
|
||
<div class="modal-box max-w-3xl">
|
||
<form method="dialog">
|
||
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close">✕</button>
|
||
</form>
|
||
<h3 class="text-lg font-semibold">原始数据 #{{ currentRaw?.id }}</h3>
|
||
<p class="mt-1 text-xs text-base-content/50">
|
||
{{ currentRaw?.model_name }} · {{ currentRaw?.protocol }}
|
||
</p>
|
||
|
||
<!-- 标签页切换:请求 / 响应,避免上下堆叠,手机友好 -->
|
||
<div v-if="hasAnyRaw" class="mt-4">
|
||
<div class="tabs tabs-boxed w-fit max-w-full overflow-x-auto">
|
||
<button
|
||
v-if="currentRaw?.raw_request"
|
||
type="button"
|
||
class="tab tab-sm"
|
||
:class="rawTab === 'request' && 'tab-active'"
|
||
@click="rawTab = 'request'"
|
||
>请求</button>
|
||
<button
|
||
v-if="currentRaw?.raw_response"
|
||
type="button"
|
||
class="tab tab-sm"
|
||
:class="rawTab === 'response' && 'tab-active'"
|
||
@click="rawTab = 'response'"
|
||
>响应</button>
|
||
</div>
|
||
<pre class="mt-3 max-h-[55vh] overflow-auto rounded-lg bg-base-200/50 p-3 text-xs leading-relaxed whitespace-pre-wrap break-words">{{ activeRawContent }}</pre>
|
||
</div>
|
||
<p v-else class="mt-4 text-sm text-base-content/50">该请求未记录原始数据(仅管理员且开关开启时记录)。</p>
|
||
</div>
|
||
<form method="dialog" class="modal-backdrop">
|
||
<button aria-label="Close">close</button>
|
||
</form>
|
||
</dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, computed } from 'vue'
|
||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'
|
||
import Pagination from '@/components/common/Pagination.vue'
|
||
import { useUsageStore } from '@/stores/usage'
|
||
import type { UsageLogItem } from '@/types'
|
||
import { SearchIcon, RotateCcwIcon, FileTextIcon } from '@lucide/vue'
|
||
|
||
const store = useUsageStore()
|
||
|
||
const page = ref(1)
|
||
const pageSize = ref(20)
|
||
const filters = reactive({ protocol: '', status: '', model: '', userId: '' })
|
||
|
||
const summary = computed(() => store.adminSummary?.totals)
|
||
|
||
// 原始数据弹窗
|
||
const rawModal = ref<HTMLDialogElement | null>(null)
|
||
const currentRaw = ref<UsageLogItem | null>(null)
|
||
const rawTab = ref<'request' | 'response'>('request')
|
||
|
||
const hasAnyRaw = computed(() => !!currentRaw.value?.raw_request || !!currentRaw.value?.raw_response)
|
||
const activeRawContent = computed(() => {
|
||
const item = currentRaw.value
|
||
if (!item) return ''
|
||
return rawTab.value === 'request' ? item.raw_request ?? '' : item.raw_response ?? ''
|
||
})
|
||
|
||
function viewRaw(item: UsageLogItem) {
|
||
currentRaw.value = item
|
||
// 默认停在第一个有内容的标签(请求优先)
|
||
rawTab.value = item.raw_request ? 'request' : 'response'
|
||
rawModal.value?.showModal()
|
||
}
|
||
|
||
async function loadLogs() {
|
||
const params: Record<string, any> = { page: page.value, pageSize: pageSize.value }
|
||
if (filters.protocol) params.protocol = filters.protocol
|
||
if (filters.status) params.status = filters.status
|
||
if (filters.model) params.model = filters.model
|
||
if (filters.userId) params.user_id = filters.userId
|
||
try {
|
||
await store.fetchAdminLogs(params)
|
||
} catch { /* store 已抛错 */ }
|
||
}
|
||
|
||
async function loadSummary() {
|
||
try {
|
||
await store.fetchAdminSummary()
|
||
} catch { /* 同上 */ }
|
||
}
|
||
|
||
function applyFilters() {
|
||
page.value = 1
|
||
loadLogs()
|
||
}
|
||
|
||
function resetFilters() {
|
||
filters.protocol = ''
|
||
filters.status = ''
|
||
filters.model = ''
|
||
filters.userId = ''
|
||
applyFilters()
|
||
}
|
||
|
||
function changePage(p: number, s: number) {
|
||
page.value = p
|
||
pageSize.value = s
|
||
loadLogs()
|
||
}
|
||
|
||
function fmtInt(n?: number): string {
|
||
return (n ?? 0).toLocaleString()
|
||
}
|
||
function fmtCost(n?: number): string {
|
||
return `$${(n ?? 0).toFixed(6)}`
|
||
}
|
||
function fmtTime(t?: string): string {
|
||
if (!t) return '—'
|
||
const d = new Date(t)
|
||
const pad = (x: number) => String(x).padStart(2, '0')
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||
}
|
||
function statusLabel(s: string): string {
|
||
switch (s) {
|
||
case 'success': return '成功'
|
||
case 'error': return '失败'
|
||
case 'canceled': return '已取消'
|
||
default: return s
|
||
}
|
||
}
|
||
function statusClass(s: string): string {
|
||
switch (s) {
|
||
case 'success': return 'badge-success badge-soft'
|
||
case 'error': return 'badge-error badge-soft'
|
||
case 'canceled': return 'badge-warning badge-soft'
|
||
default: return 'badge-ghost'
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadLogs()
|
||
loadSummary()
|
||
})
|
||
</script>
|