管理后台用量统计支持按用户筛选:用户名输入 + 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
|
raw_response?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
user?: string
|
user?: string
|
||||||
|
user_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Paged<T> {
|
export interface Paged<T> {
|
||||||
|
|||||||
@@ -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="按模型过滤"
|
||||||
|
|||||||
Reference in New Issue
Block a user