Merge remote-tracking branch 'origin/pr/395' into aether-rust-pioneer

This commit is contained in:
fawney19
2026-05-08 02:47:07 +08:00
37 changed files with 2836 additions and 103 deletions

View File

@@ -149,13 +149,38 @@ export interface UpsertUserApiKeyRequest {
export type UserSession = SessionRecord
export interface GetAllUsersOptions {
search?: string
skip?: number
limit?: number
cacheTtlMs?: number
}
export const usersApi = {
async getAllUsers(options: { cacheTtlMs?: number } = {}): Promise<User[]> {
async getAllUsers(options: GetAllUsersOptions = {}): Promise<User[]> {
const cacheTtlMs = options.cacheTtlMs ?? 0
const params: Record<string, string | number> = {}
const search = options.search?.trim()
if (search) params.search = search
if (options.skip !== undefined) params.skip = options.skip
if (options.limit !== undefined) params.limit = options.limit
const cacheKey = Object.keys(params).length === 0
? 'admin:users:list'
: [
'admin:users:list',
search ?? '',
options.skip ?? '',
options.limit ?? '',
].join(':')
return cachedRequest(
'admin:users:list',
cacheKey,
async () => {
const response = await apiClient.get<User[]>('/api/admin/users')
const response = await apiClient.get<User[]>('/api/admin/users', {
params: Object.keys(params).length > 0 ? params : undefined,
})
return response.data
},
cacheTtlMs,

View File

@@ -0,0 +1,231 @@
<template>
<div
ref="rootRef"
:class="dropdown ? 'relative' : ''"
>
<button
v-if="dropdown"
type="button"
class="flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-border/60 bg-background px-3 text-left text-xs"
@click="toggleOpen"
>
<span class="truncate">{{ selectedLabel }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
<div
v-if="!dropdown || open"
:class="dropdown ? 'absolute left-0 top-full z-50 mt-1 w-64 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg' : ''"
>
<div class="relative mb-1">
<Search class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchText"
class="h-8 pl-8 text-xs"
placeholder="搜索用户"
@keydown.stop
/>
</div>
<div class="max-h-64 overflow-y-auto pr-0.5">
<button
type="button"
class="relative flex w-full items-center rounded-lg py-1.5 pl-8 pr-2 text-left text-sm transition-colors hover:bg-accent focus:bg-accent"
@click="selectUser('__all__')"
>
<Check
class="absolute left-2 h-4 w-4"
:class="modelValue === '__all__' ? 'opacity-100' : 'opacity-0'"
/>
<span>全部用户</span>
</button>
<div
v-if="pinnedUser"
class="my-1 border-t border-border/60 pt-1"
>
<button
type="button"
class="relative flex w-full items-center rounded-lg py-1.5 pl-8 pr-2 text-left text-sm transition-colors hover:bg-accent focus:bg-accent"
@click="selectUser(pinnedUser.id)"
>
<Check class="absolute left-2 h-4 w-4 opacity-100" />
<span class="min-w-0">
<span class="block truncate">{{ getUserLabel(pinnedUser) }}</span>
<span
v-if="pinnedUser.email && pinnedUser.email !== pinnedUser.username"
class="block truncate text-xs text-muted-foreground"
>{{ pinnedUser.email }}</span>
</span>
</button>
</div>
<div
v-if="loading"
class="px-3 py-6 text-center text-xs text-muted-foreground"
>
加载中...
</div>
<div
v-else-if="visibleUsers.length === 0"
class="px-3 py-6 text-center text-xs text-muted-foreground"
>
未找到用户
</div>
<button
v-for="user in visibleUsers"
v-else
:key="user.id"
type="button"
class="relative flex w-full items-center rounded-lg py-1.5 pl-8 pr-2 text-left text-sm transition-colors hover:bg-accent focus:bg-accent"
@click="selectUser(user.id)"
>
<Check
class="absolute left-2 h-4 w-4"
:class="modelValue === user.id ? 'opacity-100' : 'opacity-0'"
/>
<span class="min-w-0">
<span class="block truncate">{{ getUserLabel(user) }}</span>
<span
v-if="user.email && user.email !== user.username"
class="block truncate text-xs text-muted-foreground"
>{{ user.email }}</span>
</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { Check, ChevronDown, Search } from 'lucide-vue-next'
import { Input } from '@/components/ui'
import { usersApi } from '@/api/users'
import type { UserOption } from './UsageRecordsTable.vue'
const props = withDefaults(defineProps<{
modelValue: string
initialUsers?: UserOption[]
dropdown?: boolean
}>(), {
initialUsers: () => [],
dropdown: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
select: [value: string]
}>()
const rootRef = ref<HTMLElement | null>(null)
const open = ref(false)
const loading = ref(false)
const users = ref<UserOption[]>([])
const knownUsers = ref(new Map<string, UserOption>())
const searchText = ref('')
let requestId = 0
let loadedInitialBatch = false
const selectedUser = computed(() => knownUsers.value.get(props.modelValue))
const selectedLabel = computed(() => {
if (props.modelValue === '__all__') return '全部用户'
const user = selectedUser.value
return user ? getUserLabel(user) : `User ${props.modelValue}`
})
const pinnedUser = computed(() => {
if (props.modelValue === '__all__') return null
const selected = selectedUser.value
if (!selected) return null
return users.value.some((user) => user.id === selected.id) ? null : selected
})
const visibleUsers = computed(() => {
if (!pinnedUser.value) return users.value
return users.value.filter((user) => user.id !== pinnedUser.value?.id)
})
watch(() => props.initialUsers, (nextUsers) => {
rememberUsers(nextUsers)
if (users.value.length === 0 && nextUsers.length > 0) {
users.value = [...nextUsers]
}
}, { immediate: true })
watch(searchText, useDebounceFn(() => {
void loadUsers(searchText.value)
}, 300))
watch(open, (isOpen) => {
if (isOpen && !loadedInitialBatch && !loading.value) {
void loadUsers('')
}
})
function getUserLabel(user: UserOption): string {
return user.username || user.email || user.id
}
function rememberUsers(nextUsers: UserOption[]) {
const nextMap = new Map(knownUsers.value)
for (const user of nextUsers) {
nextMap.set(user.id, user)
}
knownUsers.value = nextMap
}
async function loadUsers(search: string) {
const currentRequest = ++requestId
loading.value = true
try {
const result = await usersApi.getAllUsers({
search,
skip: 0,
limit: 50,
cacheTtlMs: search.trim() ? 0 : 30_000,
})
if (currentRequest !== requestId) return
const options = result.map((user) => ({
id: user.id,
username: user.username,
email: user.email,
}))
if (!search.trim()) loadedInitialBatch = true
users.value = options
rememberUsers(options)
} catch {
if (currentRequest === requestId) users.value = []
} finally {
if (currentRequest === requestId) loading.value = false
}
}
function selectUser(value: string) {
emit('update:modelValue', value)
emit('select', value)
if (props.dropdown) open.value = false
}
function toggleOpen() {
open.value = !open.value
}
function handleDocumentPointerDown(event: PointerEvent) {
if (!props.dropdown || !open.value) return
const target = event.target
if (target instanceof Node && rootRef.value?.contains(target)) return
open.value = false
}
onMounted(() => {
if (!props.dropdown && !loadedInitialBatch) {
void loadUsers('')
}
document.addEventListener('pointerdown', handleDocumentPointerDown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', handleDocumentPointerDown)
})
</script>

View File

@@ -23,27 +23,14 @@
<div class="contents md:hidden">
<!-- 用户筛选仅管理员可见 -->
<Select
v-if="isAdmin && availableUsers.length > 0"
<ServerUserSelector
v-if="isAdmin"
class="flex-1 min-w-0 sm:flex-none sm:w-40"
:model-value="filterUser"
:initial-users="availableUsers"
dropdown
@update:model-value="$emit('update:filterUser', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="用户" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部用户
</SelectItem>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
/>
<!-- 模型筛选 -->
<Select
@@ -340,13 +327,13 @@
:sortable="false"
:filter-active="filterUser !== '__all__'"
filter-title="筛选用户"
filter-content-class="w-48 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
filter-content-class="w-64 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
用户
<template #filter="{ close }">
<TableFilterMenu
<ServerUserSelector
:model-value="filterUser"
:options="userFilterOptions"
:initial-users="availableUsers"
@update:model-value="$emit('update:filterUser', $event)"
@select="close"
/>
@@ -823,6 +810,7 @@ import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { DateRangeParams, UsageRecord } from '../types'
import { TimeRangePicker } from '@/components/common'
import ElapsedTimeText from './ElapsedTimeText.vue'
import ServerUserSelector from './ServerUserSelector.vue'
export interface UserOption {
id: string
@@ -893,14 +881,6 @@ const AVAILABLE_API_FORMATS = [
// 使用模块级常量
const availableApiFormats = AVAILABLE_API_FORMATS
const userFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部用户' },
...props.availableUsers.map((user) => ({
value: user.id,
label: user.username || user.email,
})),
])
const modelFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部模型' },
...props.availableModels.map((model) => ({

View File

@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import ServerUserSelector from '../ServerUserSelector.vue'
const getAllUsersMock = vi.hoisted(() => vi.fn())
vi.mock('@/api/users', () => ({
usersApi: {
getAllUsers: getAllUsersMock,
},
}))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
return {
Input: defineComponent({
name: 'InputStub',
props: { modelValue: String },
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
value: props.modelValue ?? '',
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
})
},
}),
}
})
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
Check: Icon,
ChevronDown: Icon,
Search: Icon,
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function flushPromises() {
return Promise.resolve().then(() => undefined)
}
function mountSelector(props: Record<string, unknown> = {}) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(ServerUserSelector, {
modelValue: '__all__',
initialUsers: [],
dropdown: true,
...props,
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
return root
}
beforeEach(() => {
vi.useRealTimers()
getAllUsersMock.mockReset()
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
vi.useRealTimers()
})
describe('ServerUserSelector', () => {
it('loads the initial user batch when opened', async () => {
getAllUsersMock.mockResolvedValue([
{ id: 'user-1', username: 'alice', email: 'alice@example.com' },
])
const root = mountSelector()
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
await flushPromises()
expect(getAllUsersMock).toHaveBeenCalledWith({
search: '',
skip: 0,
limit: 50,
cacheTtlMs: 30_000,
})
expect(root.textContent).toContain('alice')
})
it('debounces remote search and bypasses cache for typed queries', async () => {
vi.useFakeTimers()
getAllUsersMock.mockResolvedValue([])
const root = mountSelector()
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const input = root.querySelector('input') as HTMLInputElement
input.value = 'bob'
input.dispatchEvent(new Event('input', { bubbles: true }))
await vi.advanceTimersByTimeAsync(299)
expect(getAllUsersMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
expect(getAllUsersMock).toHaveBeenLastCalledWith({
search: 'bob',
skip: 0,
limit: 50,
cacheTtlMs: 0,
})
})
it('keeps the selected user pinned when search results do not include it', async () => {
getAllUsersMock.mockResolvedValue([
{ id: 'user-1', username: 'alice', email: 'alice@example.com' },
])
const root = mountSelector({
modelValue: 'user-99',
initialUsers: [
{ id: 'user-99', username: 'pinned', email: 'pinned@example.com' },
],
})
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
await flushPromises()
expect(root.textContent).toContain('pinned')
expect(root.textContent).toContain('alice')
})
})

View File

@@ -76,6 +76,8 @@ vi.mock('lucide-vue-next', async () => {
return {
RefreshCcw: Icon,
Search: Icon,
ChevronDown: Icon,
Check: Icon,
}
})
@@ -88,6 +90,15 @@ vi.mock('../ElapsedTimeText.vue', () => ({
}),
}))
vi.mock('../ServerUserSelector.vue', () => ({
default: defineComponent({
name: 'ServerUserSelectorStub',
setup() {
return () => h('div', 'user selector')
},
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function buildRecord(overrides: Partial<UsageRecord> = {}): UsageRecord {

View File

@@ -106,11 +106,11 @@ const purgeItems: PurgeItem[] = [
},
{
key: 'stats',
title: '清空聚合数据',
description: '清空仪表盘统计聚合数据,保留原始使用记录',
buttonText: '清空聚合数据',
title: '清空统计聚合',
description: '删除统计聚合数据,保留原始使用记录;统计可从原始使用记录重新构建',
buttonText: '清空统计聚合',
icon: markRaw(PieChart),
confirmMessage: '确定要清空全部聚合统计数据吗?仪表盘数据将被清除,用户和 Key 的累计统计也会归零,操作不可逆。',
confirmMessage: '确定要清空全部统计聚合数据吗?原始使用记录会保留,仪表盘和累计统计可从原始记录重新构建。',
action: () => adminApi.purgeStats(),
},
]
@@ -122,9 +122,9 @@ async function handlePurge(item: PurgeItem) {
loadingKey.value = item.key
try {
const result = await item.action()
success(result.message)
success(result.message || '操作成功')
} catch (e) {
error(parseApiError(e))
error(parseApiError(e, '清空失败'))
} finally {
loadingKey.value = null
}