管理后台用量统计支持按用户筛选:用户名输入 + 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 raw_response?: string
created_at: string created_at: string
user?: string user?: string
user_id?: number
} }
export interface Paged<T> { 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 Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import Modal from '@/components/ui/Modal.vue' import Modal from '@/components/ui/Modal.vue'
import type { UsageLog } from '@/types' import type { UsageLog, User } from '@/types'
const toast = useToastStore() const toast = useToastStore()
const logs = ref<UsageLog[]>([]) const logs = ref<UsageLog[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
const modelFilter = ref('') const modelFilter = ref('')
const userFilter = ref('')
const userCandidates = ref<User[]>([])
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
const pageSize = 15 const pageSize = 15
const viewing = ref<UsageLog | null>(null) const viewing = ref<UsageLog | null>(null)
@@ -24,11 +27,47 @@ function openRaw(l: UsageLog) {
viewTab.value = 'request' 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() { async function load() {
try { try {
const { data } = await http.get( const params = new URLSearchParams()
`/admin/usage?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`, 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 logs.value = data.data.items
total.value = data.data.total total.value = data.data.total
} catch (e) { } catch (e) {
@@ -41,6 +80,12 @@ function search() {
load() load()
} }
function clearUser() {
userFilter.value = ''
userCandidates.value = []
search()
}
function goPage(p: number) { function goPage(p: number) {
page.value = p page.value = p
load() load()
@@ -56,7 +101,28 @@ onMounted(load)
<h1 class="text-lg font-semibold">用量统计</h1> <h1 class="text-lg font-semibold">用量统计</h1>
<p class="text-sm text-muted">全局请求明细与成本</p> <p class="text-sm text-muted">全局请求明细与成本</p>
</div> </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 <input
v-model="modelFilter" v-model="modelFilter"
placeholder="按模型过滤" placeholder="按模型过滤"