mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge remote-tracking branch 'origin/pr/395' into aether-rust-pioneer
This commit is contained in:
@@ -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,
|
||||
|
||||
231
frontend/src/features/usage/components/ServerUserSelector.vue
Normal file
231
frontend/src/features/usage/components/ServerUserSelector.vue
Normal 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>
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user