mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge pull request #522 from RWDai/fix/admin-users-server-pagination
Fix admin users server-side pagination
This commit is contained in:
@@ -3,7 +3,7 @@ import { cachedRequest } from '@/utils/cache'
|
||||
import type { UserSession as SessionRecord } from '@/types/session'
|
||||
import type { BillingPlan, UserPlanEntitlement } from './billing'
|
||||
|
||||
export type UserRole = 'admin' | 'user'
|
||||
export type UserRole = 'admin' | 'audit_admin' | 'user'
|
||||
export type ListPolicyMode = 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
|
||||
export type RateLimitPolicyMode = 'inherit' | 'system' | 'custom'
|
||||
export type FeatureSettings = Record<string, unknown>
|
||||
@@ -264,10 +264,32 @@ export interface GetAllUsersOptions {
|
||||
skip?: number
|
||||
limit?: number
|
||||
cacheTtlMs?: number
|
||||
cacheKeySuffix?: string
|
||||
}
|
||||
|
||||
export interface AdminUsersListResponse {
|
||||
items: User[]
|
||||
total: number
|
||||
skip: number
|
||||
limit: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
function normalizeAdminUsersListResponse(payload: User[] | AdminUsersListResponse): AdminUsersListResponse {
|
||||
if (Array.isArray(payload)) {
|
||||
return {
|
||||
items: payload,
|
||||
total: payload.length,
|
||||
skip: 0,
|
||||
limit: payload.length,
|
||||
has_more: false,
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
async getAllUsers(options: GetAllUsersOptions = {}): Promise<User[]> {
|
||||
async getAllUsersPage(options: GetAllUsersOptions = {}): Promise<AdminUsersListResponse> {
|
||||
const cacheTtlMs = options.cacheTtlMs ?? 0
|
||||
const params: Record<string, string | number> = {}
|
||||
const search = options.search?.trim()
|
||||
@@ -289,20 +311,26 @@ export const usersApi = {
|
||||
options.group_id ?? '',
|
||||
options.skip ?? '',
|
||||
options.limit ?? '',
|
||||
options.cacheKeySuffix ?? '',
|
||||
].join(':')
|
||||
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<User[]>('/api/admin/users', {
|
||||
const response = await apiClient.get<User[] | AdminUsersListResponse>('/api/admin/users', {
|
||||
params: Object.keys(params).length > 0 ? params : undefined,
|
||||
})
|
||||
return response.data
|
||||
return normalizeAdminUsersListResponse(response.data)
|
||||
},
|
||||
cacheTtlMs,
|
||||
)
|
||||
},
|
||||
|
||||
async getAllUsers(options: GetAllUsersOptions = {}): Promise<User[]> {
|
||||
const response = await this.getAllUsersPage(options)
|
||||
return response.items
|
||||
},
|
||||
|
||||
async getUser(userId: string): Promise<User> {
|
||||
const response = await apiClient.get<User>(`/api/admin/users/${userId}`)
|
||||
return response.data
|
||||
|
||||
@@ -274,7 +274,7 @@ import type {
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
users: User[]
|
||||
usersVersion: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -295,8 +295,12 @@ const {
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const groups = ref<UserGroup[]>([])
|
||||
const dialogUsers = ref<User[]>([])
|
||||
const editingGroupId = ref<string | null>(null)
|
||||
const memberUserIds = ref<string[]>([])
|
||||
const USER_OPTIONS_CACHE_TTL_MS = 30 * 1000
|
||||
let dialogUsersLoadedAt = 0
|
||||
let dialogUsersLoadedVersion = -1
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
@@ -311,7 +315,7 @@ const form = ref({
|
||||
})
|
||||
|
||||
const selectedGroup = computed(() => groups.value.find((group) => group.id === editingGroupId.value) ?? null)
|
||||
const userOptions = computed(() => props.users.map((user) => ({
|
||||
const userOptions = computed(() => dialogUsers.value.map((user) => ({
|
||||
label: `${user.username}${user.email ? ` (${user.email})` : ''}`,
|
||||
value: user.id,
|
||||
})))
|
||||
@@ -334,8 +338,11 @@ function handleDialogUpdate(value: boolean): void {
|
||||
async function loadDialogData(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await usersStore.listUserGroups()
|
||||
groups.value = response.items
|
||||
const [groupsResponse] = await Promise.all([
|
||||
usersStore.listUserGroups(),
|
||||
ensureDialogUsers(),
|
||||
])
|
||||
groups.value = groupsResponse.items
|
||||
if (editingGroupId.value && !groups.value.some((group) => group.id === editingGroupId.value)) {
|
||||
editingGroupId.value = null
|
||||
}
|
||||
@@ -354,6 +361,25 @@ async function loadDialogData(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDialogUsers(): Promise<void> {
|
||||
const now = Date.now()
|
||||
const isSameUserVersion = dialogUsersLoadedVersion === props.usersVersion
|
||||
if (
|
||||
isSameUserVersion
|
||||
&& dialogUsersLoadedAt > 0
|
||||
&& now - dialogUsersLoadedAt < USER_OPTIONS_CACHE_TTL_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
dialogUsers.value = await usersStore.listAllUsers({
|
||||
cacheTtlMs: isSameUserVersion ? USER_OPTIONS_CACHE_TTL_MS : 0,
|
||||
cacheKeySuffix: isSameUserVersion ? undefined : `users-version-${props.usersVersion}`,
|
||||
})
|
||||
dialogUsersLoadedAt = Date.now()
|
||||
dialogUsersLoadedVersion = props.usersVersion
|
||||
}
|
||||
|
||||
async function selectGroup(groupId: string): Promise<void> {
|
||||
const group = groups.value.find((item) => item.id === groupId)
|
||||
if (!group) return
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import {
|
||||
usersApi,
|
||||
type User,
|
||||
type GetAllUsersOptions,
|
||||
type CreateUserRequest,
|
||||
type UpdateUserRequest,
|
||||
type ApiKey,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
type ResolveUserBatchSelectionResponse,
|
||||
type UserBatchActionRequest,
|
||||
type UserBatchActionResponse,
|
||||
type UserRole,
|
||||
type UserGroup,
|
||||
type UserGroupMember,
|
||||
type UpsertUserGroupRequest,
|
||||
@@ -24,25 +26,61 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
export const useUsersStore = defineStore('users', () => {
|
||||
const users = ref<User[]>([])
|
||||
const total = ref(0)
|
||||
const skip = ref(0)
|
||||
const limit = ref(0)
|
||||
const hasMore = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let fetchUsersRequestId = 0
|
||||
|
||||
async function fetchUsers(options: {
|
||||
cacheTtlMs?: number
|
||||
search?: string
|
||||
role?: 'admin' | 'user'
|
||||
role?: UserRole
|
||||
is_active?: boolean
|
||||
group_id?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
} = {}) {
|
||||
const requestId = ++fetchUsersRequestId
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
users.value = await usersApi.getAllUsers(options)
|
||||
const response = await usersApi.getAllUsersPage(options)
|
||||
if (requestId !== fetchUsersRequestId) return
|
||||
users.value = response.items
|
||||
total.value = response.total
|
||||
skip.value = response.skip
|
||||
limit.value = response.limit
|
||||
hasMore.value = response.has_more
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== fetchUsersRequestId) return
|
||||
error.value = parseApiError(err, '获取用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === fetchUsersRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listAllUsers(options: Omit<GetAllUsersOptions, 'skip' | 'limit'> = {}): Promise<User[]> {
|
||||
const pageSize = 1000
|
||||
const allUsers: User[] = []
|
||||
let skip = 0
|
||||
|
||||
for (;;) {
|
||||
const response = await usersApi.getAllUsersPage({
|
||||
...options,
|
||||
skip,
|
||||
limit: pageSize,
|
||||
})
|
||||
allUsers.push(...response.items)
|
||||
if (!response.has_more || response.items.length === 0) {
|
||||
return allUsers
|
||||
}
|
||||
skip += response.items.length
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,9 +334,14 @@ export const useUsersStore = defineStore('users', () => {
|
||||
|
||||
return {
|
||||
users,
|
||||
total,
|
||||
skip,
|
||||
limit,
|
||||
hasMore,
|
||||
loading,
|
||||
error,
|
||||
fetchUsers,
|
||||
listAllUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
|
||||
@@ -243,12 +243,12 @@
|
||||
<Checkbox
|
||||
:checked="isAllFilteredSelected"
|
||||
:indeterminate="isPartiallyFilteredSelected"
|
||||
:disabled="filteredUsers.length === 0 || usersStore.loading"
|
||||
:disabled="filteredUserCount === 0 || usersStore.loading"
|
||||
@update:checked="toggleSelectFiltered"
|
||||
/>
|
||||
<span>全选筛选结果</span>
|
||||
</label>
|
||||
<span>匹配 {{ filteredUsers.length }} 个,当前页 {{ paginatedUsers.length }} 个,已选 {{ selectedCount }} 个</span>
|
||||
<span>匹配 {{ filteredUserCount }} 个,当前页 {{ paginatedUsers.length }} 个,已选 {{ selectedCount }} 个</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
@@ -576,10 +576,10 @@
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ searchQuery || filterRole !== 'all' || filterStatus !== 'all' ? '未找到匹配的用户' : '暂无用户' }}
|
||||
{{ searchQuery || filterRole !== 'all' || filterStatus !== 'all' || filterGroup !== 'all' ? '未找到匹配的用户' : '暂无用户' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="searchQuery || filterRole !== 'all' || filterStatus !== 'all'"
|
||||
v-if="searchQuery || filterRole !== 'all' || filterStatus !== 'all' || filterGroup !== 'all'"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
尝试调整筛选条件
|
||||
@@ -812,11 +812,11 @@
|
||||
<!-- 分页控件 -->
|
||||
<Pagination
|
||||
:current="currentPage"
|
||||
:total="filteredUsers.length"
|
||||
:total="filteredUserCount"
|
||||
:page-size="pageSize"
|
||||
cache-key="users-page-size"
|
||||
@update:current="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:current="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -843,7 +843,7 @@
|
||||
|
||||
<UserGroupsDialog
|
||||
:open="showUserGroupsDialog"
|
||||
:users="usersStore.users"
|
||||
:users-version="userOptionsVersion"
|
||||
@close="showUserGroupsDialog = false"
|
||||
@changed="handleUserGroupsChanged"
|
||||
/>
|
||||
@@ -1592,10 +1592,11 @@ const showWalletActionDialogState = ref(false)
|
||||
const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
|
||||
const showUserBatchDialog = ref(false)
|
||||
const showUserGroupsDialog = ref(false)
|
||||
const userOptionsVersion = ref(0)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterRole = ref('all')
|
||||
const filterStatus = ref('all')
|
||||
const filterRole = ref<'all' | User['role']>('all')
|
||||
const filterStatus = ref<'all' | 'active' | 'inactive'>('all')
|
||||
const filterGroup = ref('all')
|
||||
const userGroups = ref<UserGroup[]>([])
|
||||
const userRoleFilterOptions = [
|
||||
@@ -1616,50 +1617,11 @@ const USERS_PAGE_CACHE_TTL_MS = 10 * 1000
|
||||
const USER_WALLETS_CACHE_TTL_MS = 10 * 1000
|
||||
let userWalletsRequestId = 0
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
let filtered = [...usersStore.users]
|
||||
const filteredUsers = computed(() => usersStore.users)
|
||||
|
||||
// 先排序:管理员优先,然后按创建时间倒序
|
||||
filtered.sort((a, b) => {
|
||||
const roleRank = (role: string) => role === 'admin' ? 0 : role === 'audit_admin' ? 1 : 2
|
||||
const roleDiff = roleRank(a.role) - roleRank(b.role)
|
||||
if (roleDiff !== 0) return roleDiff
|
||||
// 同角色按创建时间倒序(新用户在前)
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
const paginatedUsers = computed(() => filteredUsers.value)
|
||||
|
||||
// 搜索(支持空格分隔的多关键词 AND 搜索)
|
||||
if (searchQuery.value) {
|
||||
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||
filtered = filtered.filter(u => {
|
||||
const searchableText = `${u.username} ${u.email || ''}`.toLowerCase()
|
||||
return keywords.every(keyword => searchableText.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
if (filterRole.value !== 'all') {
|
||||
filtered = filtered.filter(u => u.role === filterRole.value)
|
||||
}
|
||||
|
||||
if (filterStatus.value !== 'all') {
|
||||
filtered = filtered.filter(u =>
|
||||
filterStatus.value === 'active' ? u.is_active : !u.is_active
|
||||
)
|
||||
}
|
||||
|
||||
if (filterGroup.value !== 'all') {
|
||||
filtered = filtered.filter(u => (u.groups || []).some(group => group.id === filterGroup.value))
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const paginatedUsers = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredUsers.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const filteredUserCount = computed(() => filteredUsers.value.length)
|
||||
const filteredUserCount = computed(() => usersStore.total)
|
||||
const {
|
||||
selectedIds,
|
||||
selectAllFiltered,
|
||||
@@ -1700,6 +1662,7 @@ const grantableBillingPlans = computed(() =>
|
||||
watch([searchQuery, filterRole, filterStatus, filterGroup], () => {
|
||||
currentPage.value = 1
|
||||
resetBatchSelection()
|
||||
void refreshUsers()
|
||||
})
|
||||
|
||||
watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true })
|
||||
@@ -1720,8 +1683,17 @@ onMounted(() => {
|
||||
|
||||
async function refreshUsers(options: { preferCache?: boolean } = {}) {
|
||||
const cacheTtlMs = options.preferCache ? USERS_PAGE_CACHE_TTL_MS : 0
|
||||
const search = searchQuery.value.trim()
|
||||
await Promise.all([
|
||||
usersStore.fetchUsers({ cacheTtlMs }),
|
||||
usersStore.fetchUsers({
|
||||
cacheTtlMs,
|
||||
search: search || undefined,
|
||||
role: filterRole.value === 'all' ? undefined : filterRole.value,
|
||||
is_active: filterStatus.value === 'all' ? undefined : filterStatus.value === 'active',
|
||||
group_id: filterGroup.value === 'all' ? undefined : filterGroup.value,
|
||||
skip: (currentPage.value - 1) * pageSize.value,
|
||||
limit: pageSize.value,
|
||||
}),
|
||||
loadUserGroups(),
|
||||
])
|
||||
void loadUserWallets({
|
||||
@@ -1729,6 +1701,18 @@ async function refreshUsers(options: { preferCache?: boolean } = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
function handlePageChange(page: number): void {
|
||||
currentPage.value = page
|
||||
void refreshUsers({ preferCache: true })
|
||||
}
|
||||
|
||||
function handlePageSizeChange(size: number): void {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
resetBatchSelection()
|
||||
void refreshUsers()
|
||||
}
|
||||
|
||||
async function loadUserGroups(): Promise<void> {
|
||||
try {
|
||||
const response = await usersStore.listUserGroups()
|
||||
@@ -1755,6 +1739,10 @@ async function handleUserBatchCompleted(_result: UserBatchActionResponse): Promi
|
||||
resetBatchSelection(true)
|
||||
}
|
||||
|
||||
function invalidateUserOptions(): void {
|
||||
userOptionsVersion.value += 1
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleDateString('zh-CN')
|
||||
}
|
||||
@@ -1932,6 +1920,8 @@ async function toggleUserStatus(user: User) {
|
||||
|
||||
try {
|
||||
await usersStore.updateUser(user.id, { is_active: !user.is_active })
|
||||
invalidateUserOptions()
|
||||
await refreshUsers()
|
||||
success(`用户已${action}`)
|
||||
} catch (err: unknown) {
|
||||
error(parseApiError(err, '未知错误'), `${action}用户失败`)
|
||||
@@ -1982,7 +1972,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
|
||||
updateData.password = data.password
|
||||
}
|
||||
await usersStore.updateUser(data.id, updateData)
|
||||
await loadUserWallets()
|
||||
invalidateUserOptions()
|
||||
success('用户信息已更新')
|
||||
} else {
|
||||
// 创建用户
|
||||
@@ -2000,10 +1990,11 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
|
||||
if (data.is_active === false && newUser) {
|
||||
await usersStore.updateUser(newUser.id, { is_active: false })
|
||||
}
|
||||
await loadUserWallets()
|
||||
invalidateUserOptions()
|
||||
success('用户创建成功')
|
||||
}
|
||||
closeUserFormDialog()
|
||||
await refreshUsers()
|
||||
} catch (err: unknown) {
|
||||
const title = data.id ? '更新用户失败' : '创建用户失败'
|
||||
error(parseApiError(err, '未知错误'), title)
|
||||
@@ -2313,6 +2304,11 @@ async function deleteUser(user: User) {
|
||||
|
||||
try {
|
||||
await usersStore.deleteUser(user.id)
|
||||
invalidateUserOptions()
|
||||
if (usersStore.users.length === 0 && currentPage.value > 1) {
|
||||
currentPage.value -= 1
|
||||
}
|
||||
await refreshUsers()
|
||||
success('用户已删除')
|
||||
} catch (err: unknown) {
|
||||
error(parseApiError(err, '未知错误'), '删除用户失败')
|
||||
|
||||
Reference in New Issue
Block a user