管理后台用量统计支持按用户筛选:用户名输入 + datalist 自动补全,精确匹配解析为 user_id
- types: UsageLog 补 user_id 字段 - UsageView: 新增用户筛选输入框,防抖 300ms 拉取 admin/users 候选做下拉补全 - search 时将用户名经 admin/users 精确解析为 user_id 再过滤 admin/usage;未找到用户时 toast 报错 - 支持清除筛选一键还原
This commit is contained in:
@@ -94,6 +94,7 @@ export interface UsageLog {
|
||||
raw_response?: string
|
||||
created_at: string
|
||||
user?: string
|
||||
user_id?: number
|
||||
}
|
||||
|
||||
export interface Paged<T> {
|
||||
|
||||
@@ -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="按模型过滤"
|
||||
|
||||
Reference in New Issue
Block a user