管理后台用量统计支持按用户筛选:用户名输入 + datalist 自动补全,精确匹配解析为 user_id

- types: UsageLog 补 user_id 字段
- UsageView: 新增用户筛选输入框,防抖 300ms 拉取 admin/users 候选做下拉补全
- search 时将用户名经 admin/users 精确解析为 user_id 再过滤 admin/usage;未找到用户时 toast 报错
- 支持清除筛选一键还原
This commit is contained in:
Sakurasan
2026-08-20 01:58:02 +08:00
parent 10f51cbdae
commit 42214d6f5e
2 changed files with 72 additions and 5 deletions
+1
View File
@@ -94,6 +94,7 @@ export interface UsageLog {
raw_response?: string
created_at: string
user?: string
user_id?: number
}
export interface Paged<T> {
+71 -5
View File
@@ -7,13 +7,16 @@ import { fmtCost, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Modal from '@/components/ui/Modal.vue'
import type { UsageLog } from '@/types'
import type { UsageLog, User } from '@/types'
const toast = useToastStore()
const logs = ref<UsageLog[]>([])
const total = ref(0)
const page = ref(1)
const modelFilter = ref('')
const userFilter = ref('')
const userCandidates = ref<User[]>([])
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
const pageSize = 15
const viewing = ref<UsageLog | null>(null)
@@ -24,11 +27,47 @@ function openRaw(l: UsageLog) {
viewTab.value = 'request'
}
// 用户自动补全候选(防抖 300ms)
function onUserInput() {
if (userSearchTimer) clearTimeout(userSearchTimer)
const kw = userFilter.value.trim()
if (!kw) {
userCandidates.value = []
return
}
userSearchTimer = setTimeout(async () => {
try {
const { data } = await http.get(`/admin/users?q=${encodeURIComponent(kw)}&page_size=10`)
userCandidates.value = data.data.items ?? []
} catch {
userCandidates.value = []
}
}, 300)
}
// 用户名 → user_id(精确匹配),查不到返回 null
async function resolveUserId(username: string): Promise<number | null> {
const { data } = await http.get(`/admin/users?q=${encodeURIComponent(username)}&page_size=10`)
const items: User[] = data.data.items ?? []
const hit = items.find((u) => u.username === username)
return hit ? hit.id : null
}
async function load() {
try {
const { data } = await http.get(
`/admin/usage?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`,
)
const params = new URLSearchParams()
params.set('page', String(page.value))
params.set('page_size', String(pageSize))
if (modelFilter.value.trim()) params.set('model', modelFilter.value.trim())
if (userFilter.value.trim()) {
const uid = await resolveUserId(userFilter.value.trim())
if (uid === null) {
toast.err(`未找到用户:${userFilter.value.trim()}`)
return
}
params.set('user_id', String(uid))
}
const { data } = await http.get(`/admin/usage?${params.toString()}`)
logs.value = data.data.items
total.value = data.data.total
} catch (e) {
@@ -41,6 +80,12 @@ function search() {
load()
}
function clearUser() {
userFilter.value = ''
userCandidates.value = []
search()
}
function goPage(p: number) {
page.value = p
load()
@@ -56,7 +101,28 @@ onMounted(load)
<h1 class="text-lg font-semibold">用量统计</h1>
<p class="text-sm text-muted">全局请求明细与成本</p>
</div>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<div class="relative">
<input
v-model="userFilter"
list="user-options"
placeholder="按用户过滤"
class="h-10 w-44 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
@input="onUserInput"
@keyup.enter="search"
/>
<button
v-if="userFilter"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted hover:text-ink"
title="清除用户筛选"
@click="clearUser"
>
✕
</button>
</div>
<datalist id="user-options">
<option v-for="u in userCandidates" :key="u.id" :value="u.username" />
</datalist>
<input
v-model="modelFilter"
placeholder="按模型过滤"