Files
opencatd-open/frontend/src/views/dashboard/UsageLogs.vue
T
Sakurasan 104ecc4691 fix: 原始数据弹窗改为标签页切换,手机友好
- 原始请求/响应不再上下堆叠,改为「请求 / 响应」两个标签页
- 单个内容区(pre 内滚动 + 自动换行),避免手机上需要长距离滚动
- 打开时默认停在有内容的标签(请求优先);仅有一个数据时只显示对应标签
- 弹窗标题补充模型名与协议信息
2026-09-01 03:16:22 +08:00

266 lines
10 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.
<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>