Merge pull request #522 from RWDai/fix/admin-users-server-pagination

Fix admin users server-side pagination
This commit is contained in:
fawney19
2026-05-20 18:03:11 +08:00
committed by GitHub
14 changed files with 427 additions and 132 deletions

View File

@@ -1512,6 +1512,16 @@ impl GatewayDataState {
} }
} }
pub(crate) async fn count_export_users(
&self,
query: &aether_data::repository::users::UserExportListQuery,
) -> Result<u64, DataLayerError> {
match &self.user_reader {
Some(repository) => repository.count_export_users(query).await,
None => Ok(0),
}
}
pub(crate) async fn summarize_export_users( pub(crate) async fn summarize_export_users(
&self, &self,
) -> Result<aether_data::repository::users::UserExportSummary, DataLayerError> { ) -> Result<aether_data::repository::users::UserExportSummary, DataLayerError> {

View File

@@ -42,6 +42,13 @@ impl<'a> AdminAppState<'a> {
self.app.list_export_users_page(query).await self.app.list_export_users_page(query).await
} }
pub(crate) async fn count_export_users(
&self,
query: &aether_data::repository::users::UserExportListQuery,
) -> Result<u64, GatewayError> {
self.app.count_export_users(query).await
}
pub(crate) async fn find_export_user_by_id( pub(crate) async fn find_export_user_by_id(
&self, &self,
user_id: &str, user_id: &str,

View File

@@ -37,16 +37,20 @@ pub(in super::super) async fn build_admin_list_users_response(
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
let paged_rows = state let query = aether_data::repository::users::UserExportListQuery {
.list_export_users_page(&aether_data::repository::users::UserExportListQuery { skip,
skip, limit,
limit, role: role.clone(),
role: role.clone(), is_active,
is_active, search,
search, group_id,
group_id, };
}) let (paged_rows_result, total_result) = tokio::join!(
.await?; state.list_export_users_page(&query),
state.count_export_users(&query),
);
let paged_rows = paged_rows_result?;
let total = total_result?;
let user_ids = paged_rows let user_ids = paged_rows
.iter() .iter()
.map(|row| row.id.clone()) .map(|row| row.id.clone())
@@ -116,7 +120,15 @@ pub(in super::super) async fn build_admin_list_users_response(
)); ));
} }
Ok(Json(payload).into_response()) let has_more = (skip as u64).saturating_add(payload.len() as u64) < total;
Ok(Json(json!({
"items": payload,
"total": total,
"skip": skip,
"limit": limit,
"has_more": has_more,
}))
.into_response())
} }
pub(in super::super) async fn build_admin_get_user_response( pub(in super::super) async fn build_admin_get_user_response(

View File

@@ -169,6 +169,16 @@ impl AppState {
.map_err(|err| GatewayError::Internal(err.to_string())) .map_err(|err| GatewayError::Internal(err.to_string()))
} }
pub(crate) async fn count_export_users(
&self,
query: &aether_data::repository::users::UserExportListQuery,
) -> Result<u64, GatewayError> {
self.data
.count_export_users(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_export_user_by_id( pub(crate) async fn find_export_user_by_id(
&self, &self,
user_id: &str, user_id: &str,

View File

@@ -300,7 +300,11 @@ async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal()
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse"); let payload: serde_json::Value = response.json().await.expect("json body should parse");
let items = payload.as_array().expect("list payload should be array"); assert_eq!(payload["total"], 1);
assert_eq!(payload["skip"], 0);
assert_eq!(payload["limit"], 20);
assert_eq!(payload["has_more"], false);
let items = payload["items"].as_array().expect("items should be array");
assert_eq!(items.len(), 1); assert_eq!(items.len(), 1);
assert_eq!(items[0]["id"], "user-1"); assert_eq!(items[0]["id"], "user-1");
assert_eq!(items[0]["email"], "alice@example.com"); assert_eq!(items[0]["email"], "alice@example.com");
@@ -332,9 +336,10 @@ async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal()
.json() .json()
.await .await
.expect("search json body should parse"); .expect("search json body should parse");
let search_items = search_payload assert_eq!(search_payload["total"], 1);
let search_items = search_payload["items"]
.as_array() .as_array()
.expect("search list payload should be array"); .expect("search items should be array");
assert_eq!(search_items.len(), 1); assert_eq!(search_items.len(), 1);
assert_eq!(search_items[0]["id"], "user-3"); assert_eq!(search_items[0]["id"], "user-3");
assert_eq!(search_items[0]["email"], "carol@example.com"); assert_eq!(search_items[0]["email"], "carol@example.com");
@@ -355,9 +360,10 @@ async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal()
.json() .json()
.await .await
.expect("id search json body should parse"); .expect("id search json body should parse");
let id_search_items = id_search_payload assert_eq!(id_search_payload["total"], 1);
let id_search_items = id_search_payload["items"]
.as_array() .as_array()
.expect("id search list payload should be array"); .expect("id search items should be array");
assert_eq!(id_search_items.len(), 1); assert_eq!(id_search_items.len(), 1);
assert_eq!(id_search_items[0]["id"], "user-3"); assert_eq!(id_search_items[0]["id"], "user-3");
@@ -377,9 +383,11 @@ async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal()
.json() .json()
.await .await
.expect("limited search json body should parse"); .expect("limited search json body should parse");
let limited_search_items = limited_search_payload assert_eq!(limited_search_payload["total"], 3);
assert_eq!(limited_search_payload["has_more"], true);
let limited_search_items = limited_search_payload["items"]
.as_array() .as_array()
.expect("limited search list payload should be array"); .expect("limited search items should be array");
assert_eq!(limited_search_items.len(), 2); assert_eq!(limited_search_items.len(), 2);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -1057,7 +1065,8 @@ async fn gateway_handles_admin_users_root_locally_with_bearer_admin_session() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse"); let payload: serde_json::Value = response.json().await.expect("json body should parse");
let items = payload.as_array().expect("list payload should be array"); assert_eq!(payload["total"], 1);
let items = payload["items"].as_array().expect("items should be array");
assert_eq!(items.len(), 1); assert_eq!(items.len(), 1);
assert_eq!(items[0]["id"], "user-1"); assert_eq!(items[0]["id"], "user-1");
assert_eq!(items[0]["email"], "alice@example.com"); assert_eq!(items[0]["email"], "alice@example.com");

View File

@@ -360,6 +360,58 @@ fn memory_group_members(
.collect() .collect()
} }
fn filter_memory_export_rows(
repository: &InMemoryUserReadRepository,
query: &UserExportListQuery,
) -> Vec<StoredUserExportRow> {
let mut rows = repository
.export_rows
.read()
.expect("user repository lock")
.clone();
if let Some(role) = query.role.as_deref() {
rows.retain(|row| row.role.eq_ignore_ascii_case(role));
}
if let Some(is_active) = query.is_active {
rows.retain(|row| row.is_active == is_active);
}
if let Some(group_id) = query
.group_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let member_ids = repository
.group_members
.read()
.expect("user repository lock")
.keys()
.filter(|(candidate_group_id, _)| candidate_group_id == group_id)
.map(|(_, user_id)| user_id.clone())
.collect::<std::collections::BTreeSet<_>>();
rows.retain(|row| member_ids.contains(&row.id));
}
if let Some(search) = query
.search
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let search = search.to_ascii_lowercase();
rows.retain(|row| {
row.id.to_ascii_lowercase().contains(&search)
|| row.username.to_ascii_lowercase().contains(&search)
|| row
.email
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase()
.contains(&search)
});
}
rows
}
fn memory_export_row_from_auth_user( fn memory_export_row_from_auth_user(
repository: &InMemoryUserReadRepository, repository: &InMemoryUserReadRepository,
user: &StoredUserAuthRecord, user: &StoredUserAuthRecord,
@@ -477,59 +529,17 @@ impl UserReadRepository for InMemoryUserReadRepository {
&self, &self,
query: &UserExportListQuery, query: &UserExportListQuery,
) -> Result<Vec<StoredUserExportRow>, DataLayerError> { ) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
let mut rows = self Ok(filter_memory_export_rows(self, query)
.export_rows
.read()
.expect("user repository lock")
.clone();
if let Some(role) = query.role.as_deref() {
rows.retain(|row| row.role.eq_ignore_ascii_case(role));
}
if let Some(is_active) = query.is_active {
rows.retain(|row| row.is_active == is_active);
}
if let Some(group_id) = query
.group_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let member_ids = self
.group_members
.read()
.expect("user repository lock")
.keys()
.filter_map(|(candidate_group_id, user_id)| {
(candidate_group_id == group_id).then(|| user_id.clone())
})
.collect::<std::collections::BTreeSet<_>>();
rows.retain(|row| member_ids.contains(&row.id));
}
if let Some(search) = query
.search
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let search = search.to_ascii_lowercase();
rows.retain(|row| {
row.id.to_ascii_lowercase().contains(&search)
|| row.username.to_ascii_lowercase().contains(&search)
|| row
.email
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase()
.contains(&search)
});
}
Ok(rows
.into_iter() .into_iter()
.skip(query.skip) .skip(query.skip)
.take(query.limit) .take(query.limit)
.collect()) .collect())
} }
async fn count_export_users(&self, query: &UserExportListQuery) -> Result<u64, DataLayerError> {
Ok(filter_memory_export_rows(self, query).len() as u64)
}
async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> { async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> {
let rows = self.export_rows.read().expect("user repository lock"); let rows = self.export_rows.read().expect("user repository lock");
Ok(UserExportSummary { Ok(UserExportSummary {

View File

@@ -325,6 +325,48 @@ impl UserReadRepository for MysqlUserReadRepository {
self.fetch_export_rows(builder).await self.fetch_export_rows(builder).await
} }
async fn count_export_users(&self, query: &UserExportListQuery) -> Result<u64, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new("SELECT COUNT(*) AS total FROM users");
builder.push(" WHERE is_deleted = 0");
if let Some(role) = query.role.as_deref() {
builder
.push(" AND LOWER(role) = ")
.push_bind(role.trim().to_ascii_lowercase());
}
if let Some(is_active) = query.is_active {
builder.push(" AND is_active = ").push_bind(is_active);
}
if let Some(group_id) = query
.group_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
builder.push(" AND id IN (SELECT user_id FROM user_group_members WHERE group_id = ");
builder.push_bind(group_id);
builder.push(")");
}
if let Some(search) = query
.search
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let pattern = format!("%{}%", search.to_ascii_lowercase());
builder
.push(" AND (LOWER(id) LIKE ")
.push_bind(pattern.clone())
.push(" OR LOWER(username) LIKE ")
.push_bind(pattern.clone())
.push(" OR LOWER(COALESCE(email, '')) LIKE ")
.push_bind(pattern)
.push(")");
}
let row = builder.build().fetch_one(&self.pool).await.map_sql_err()?;
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
}
async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> { async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> {
let row = sqlx::query( let row = sqlx::query(
r#" r#"

View File

@@ -1015,6 +1015,57 @@ WHERE user_group_members.user_id IN (
collect_query_rows(query.fetch(&self.pool), map_user_export_row).await collect_query_rows(query.fetch(&self.pool), map_user_export_row).await
} }
pub async fn count_export_users(
&self,
query: &UserExportListQuery,
) -> Result<u64, DataLayerError> {
let mut builder =
QueryBuilder::<Postgres>::new("SELECT COUNT(*)::BIGINT AS total FROM users");
builder.push(" WHERE is_deleted IS FALSE");
if let Some(role) = query.role.as_deref() {
builder
.push(" AND LOWER(role::text) = ")
.push_bind(role.trim().to_ascii_lowercase());
}
if let Some(is_active) = query.is_active {
builder.push(" AND is_active = ").push_bind(is_active);
}
if let Some(group_id) = query
.group_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
builder.push(" AND id IN (SELECT user_id FROM user_group_members WHERE group_id = ");
builder.push_bind(group_id);
builder.push(")");
}
if let Some(search) = query
.search
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let pattern = format!("%{}%", search.to_ascii_lowercase());
builder
.push(" AND (LOWER(id) LIKE ")
.push_bind(pattern.clone())
.push(" OR LOWER(username) LIKE ")
.push_bind(pattern.clone())
.push(" OR LOWER(COALESCE(email, '')) LIKE ")
.push_bind(pattern)
.push(")");
}
let row = builder
.build()
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
Ok(row.try_get::<i64, _>("total").map_postgres_err()?.max(0) as u64)
}
pub async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> { pub async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> {
let row = sqlx::query(SUMMARIZE_EXPORT_USERS_SQL) let row = sqlx::query(SUMMARIZE_EXPORT_USERS_SQL)
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -2306,6 +2357,10 @@ impl UserReadRepository for SqlxUserReadRepository {
self.list_export_users_page(query).await self.list_export_users_page(query).await
} }
async fn count_export_users(&self, query: &UserExportListQuery) -> Result<u64, DataLayerError> {
self.count_export_users(query).await
}
async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> { async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> {
self.summarize_export_users().await self.summarize_export_users().await
} }

View File

@@ -325,6 +325,48 @@ impl UserReadRepository for SqliteUserReadRepository {
self.fetch_export_rows(builder).await self.fetch_export_rows(builder).await
} }
async fn count_export_users(&self, query: &UserExportListQuery) -> Result<u64, DataLayerError> {
let mut builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) AS total FROM users");
builder.push(" WHERE is_deleted = 0");
if let Some(role) = query.role.as_deref() {
builder
.push(" AND LOWER(role) = ")
.push_bind(role.trim().to_ascii_lowercase());
}
if let Some(is_active) = query.is_active {
builder.push(" AND is_active = ").push_bind(is_active);
}
if let Some(group_id) = query
.group_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
builder.push(" AND id IN (SELECT user_id FROM user_group_members WHERE group_id = ");
builder.push_bind(group_id);
builder.push(")");
}
if let Some(search) = query
.search
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let pattern = format!("%{}%", search.to_ascii_lowercase());
builder
.push(" AND (LOWER(id) LIKE ")
.push_bind(pattern.clone())
.push(" OR LOWER(username) LIKE ")
.push_bind(pattern.clone())
.push(" OR LOWER(COALESCE(email, '')) LIKE ")
.push_bind(pattern)
.push(")");
}
let row = builder.build().fetch_one(&self.pool).await.map_sql_err()?;
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
}
async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> { async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> {
let row = sqlx::query( let row = sqlx::query(
r#" r#"

View File

@@ -669,6 +669,11 @@ pub trait UserReadRepository: Send + Sync {
query: &UserExportListQuery, query: &UserExportListQuery,
) -> Result<Vec<StoredUserExportRow>, crate::DataLayerError>; ) -> Result<Vec<StoredUserExportRow>, crate::DataLayerError>;
async fn count_export_users(
&self,
query: &UserExportListQuery,
) -> Result<u64, crate::DataLayerError>;
async fn summarize_export_users(&self) -> Result<UserExportSummary, crate::DataLayerError>; async fn summarize_export_users(&self) -> Result<UserExportSummary, crate::DataLayerError>;
async fn find_export_user_by_id( async fn find_export_user_by_id(

View File

@@ -3,7 +3,7 @@ import { cachedRequest } from '@/utils/cache'
import type { UserSession as SessionRecord } from '@/types/session' import type { UserSession as SessionRecord } from '@/types/session'
import type { BillingPlan, UserPlanEntitlement } from './billing' 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 ListPolicyMode = 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
export type RateLimitPolicyMode = 'inherit' | 'system' | 'custom' export type RateLimitPolicyMode = 'inherit' | 'system' | 'custom'
export type FeatureSettings = Record<string, unknown> export type FeatureSettings = Record<string, unknown>
@@ -264,10 +264,32 @@ export interface GetAllUsersOptions {
skip?: number skip?: number
limit?: number limit?: number
cacheTtlMs?: 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 = { export const usersApi = {
async getAllUsers(options: GetAllUsersOptions = {}): Promise<User[]> { async getAllUsersPage(options: GetAllUsersOptions = {}): Promise<AdminUsersListResponse> {
const cacheTtlMs = options.cacheTtlMs ?? 0 const cacheTtlMs = options.cacheTtlMs ?? 0
const params: Record<string, string | number> = {} const params: Record<string, string | number> = {}
const search = options.search?.trim() const search = options.search?.trim()
@@ -289,20 +311,26 @@ export const usersApi = {
options.group_id ?? '', options.group_id ?? '',
options.skip ?? '', options.skip ?? '',
options.limit ?? '', options.limit ?? '',
options.cacheKeySuffix ?? '',
].join(':') ].join(':')
return cachedRequest( return cachedRequest(
cacheKey, cacheKey,
async () => { 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, params: Object.keys(params).length > 0 ? params : undefined,
}) })
return response.data return normalizeAdminUsersListResponse(response.data)
}, },
cacheTtlMs, cacheTtlMs,
) )
}, },
async getAllUsers(options: GetAllUsersOptions = {}): Promise<User[]> {
const response = await this.getAllUsersPage(options)
return response.items
},
async getUser(userId: string): Promise<User> { async getUser(userId: string): Promise<User> {
const response = await apiClient.get<User>(`/api/admin/users/${userId}`) const response = await apiClient.get<User>(`/api/admin/users/${userId}`)
return response.data return response.data

View File

@@ -274,7 +274,7 @@ import type {
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
users: User[] usersVersion: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -295,8 +295,12 @@ const {
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const groups = ref<UserGroup[]>([]) const groups = ref<UserGroup[]>([])
const dialogUsers = ref<User[]>([])
const editingGroupId = ref<string | null>(null) const editingGroupId = ref<string | null>(null)
const memberUserIds = ref<string[]>([]) const memberUserIds = ref<string[]>([])
const USER_OPTIONS_CACHE_TTL_MS = 30 * 1000
let dialogUsersLoadedAt = 0
let dialogUsersLoadedVersion = -1
const form = ref({ const form = ref({
name: '', name: '',
@@ -311,7 +315,7 @@ const form = ref({
}) })
const selectedGroup = computed(() => groups.value.find((group) => group.id === editingGroupId.value) ?? null) 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})` : ''}`, label: `${user.username}${user.email ? ` (${user.email})` : ''}`,
value: user.id, value: user.id,
}))) })))
@@ -334,8 +338,11 @@ function handleDialogUpdate(value: boolean): void {
async function loadDialogData(): Promise<void> { async function loadDialogData(): Promise<void> {
loading.value = true loading.value = true
try { try {
const response = await usersStore.listUserGroups() const [groupsResponse] = await Promise.all([
groups.value = response.items usersStore.listUserGroups(),
ensureDialogUsers(),
])
groups.value = groupsResponse.items
if (editingGroupId.value && !groups.value.some((group) => group.id === editingGroupId.value)) { if (editingGroupId.value && !groups.value.some((group) => group.id === editingGroupId.value)) {
editingGroupId.value = null 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> { async function selectGroup(groupId: string): Promise<void> {
const group = groups.value.find((item) => item.id === groupId) const group = groups.value.find((item) => item.id === groupId)
if (!group) return if (!group) return

View File

@@ -3,6 +3,7 @@ import { ref } from 'vue'
import { import {
usersApi, usersApi,
type User, type User,
type GetAllUsersOptions,
type CreateUserRequest, type CreateUserRequest,
type UpdateUserRequest, type UpdateUserRequest,
type ApiKey, type ApiKey,
@@ -12,6 +13,7 @@ import {
type ResolveUserBatchSelectionResponse, type ResolveUserBatchSelectionResponse,
type UserBatchActionRequest, type UserBatchActionRequest,
type UserBatchActionResponse, type UserBatchActionResponse,
type UserRole,
type UserGroup, type UserGroup,
type UserGroupMember, type UserGroupMember,
type UpsertUserGroupRequest, type UpsertUserGroupRequest,
@@ -24,25 +26,61 @@ import { parseApiError } from '@/utils/errorParser'
export const useUsersStore = defineStore('users', () => { export const useUsersStore = defineStore('users', () => {
const users = ref<User[]>([]) 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 loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
let fetchUsersRequestId = 0
async function fetchUsers(options: { async function fetchUsers(options: {
cacheTtlMs?: number cacheTtlMs?: number
search?: string search?: string
role?: 'admin' | 'user' role?: UserRole
is_active?: boolean is_active?: boolean
group_id?: string group_id?: string
skip?: number
limit?: number
} = {}) { } = {}) {
const requestId = ++fetchUsersRequestId
loading.value = true loading.value = true
error.value = null error.value = null
try { 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) { } catch (err: unknown) {
if (requestId !== fetchUsersRequestId) return
error.value = parseApiError(err, '获取用户列表失败') error.value = parseApiError(err, '获取用户列表失败')
} finally { } 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 { return {
users, users,
total,
skip,
limit,
hasMore,
loading, loading,
error, error,
fetchUsers, fetchUsers,
listAllUsers,
createUser, createUser,
updateUser, updateUser,
deleteUser, deleteUser,

View File

@@ -243,12 +243,12 @@
<Checkbox <Checkbox
:checked="isAllFilteredSelected" :checked="isAllFilteredSelected"
:indeterminate="isPartiallyFilteredSelected" :indeterminate="isPartiallyFilteredSelected"
:disabled="filteredUsers.length === 0 || usersStore.loading" :disabled="filteredUserCount === 0 || usersStore.loading"
@update:checked="toggleSelectFiltered" @update:checked="toggleSelectFiltered"
/> />
<span>全选筛选结果</span> <span>全选筛选结果</span>
</label> </label>
<span>匹配 {{ filteredUsers.length }} 当前页 {{ paginatedUsers.length }} 已选 {{ selectedCount }} </span> <span>匹配 {{ filteredUserCount }} 当前页 {{ paginatedUsers.length }} 已选 {{ selectedCount }} </span>
</div> </div>
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<Button <Button
@@ -576,10 +576,10 @@
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<p class="text-sm font-medium text-foreground"> <p class="text-sm font-medium text-foreground">
{{ searchQuery || filterRole !== 'all' || filterStatus !== 'all' ? '未找到匹配的用户' : '暂无用户' }} {{ searchQuery || filterRole !== 'all' || filterStatus !== 'all' || filterGroup !== 'all' ? '未找到匹配的用户' : '暂无用户' }}
</p> </p>
<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" class="mt-1 text-xs text-muted-foreground"
> >
尝试调整筛选条件 尝试调整筛选条件
@@ -812,11 +812,11 @@
<!-- 分页控件 --> <!-- 分页控件 -->
<Pagination <Pagination
:current="currentPage" :current="currentPage"
:total="filteredUsers.length" :total="filteredUserCount"
:page-size="pageSize" :page-size="pageSize"
cache-key="users-page-size" cache-key="users-page-size"
@update:current="currentPage = $event" @update:current="handlePageChange"
@update:page-size="pageSize = $event" @update:page-size="handlePageSizeChange"
/> />
</Card> </Card>
@@ -843,7 +843,7 @@
<UserGroupsDialog <UserGroupsDialog
:open="showUserGroupsDialog" :open="showUserGroupsDialog"
:users="usersStore.users" :users-version="userOptionsVersion"
@close="showUserGroupsDialog = false" @close="showUserGroupsDialog = false"
@changed="handleUserGroupsChanged" @changed="handleUserGroupsChanged"
/> />
@@ -1592,10 +1592,11 @@ const showWalletActionDialogState = ref(false)
const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null) const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
const showUserBatchDialog = ref(false) const showUserBatchDialog = ref(false)
const showUserGroupsDialog = ref(false) const showUserGroupsDialog = ref(false)
const userOptionsVersion = ref(0)
const searchQuery = ref('') const searchQuery = ref('')
const filterRole = ref('all') const filterRole = ref<'all' | User['role']>('all')
const filterStatus = ref('all') const filterStatus = ref<'all' | 'active' | 'inactive'>('all')
const filterGroup = ref('all') const filterGroup = ref('all')
const userGroups = ref<UserGroup[]>([]) const userGroups = ref<UserGroup[]>([])
const userRoleFilterOptions = [ const userRoleFilterOptions = [
@@ -1616,50 +1617,11 @@ const USERS_PAGE_CACHE_TTL_MS = 10 * 1000
const USER_WALLETS_CACHE_TTL_MS = 10 * 1000 const USER_WALLETS_CACHE_TTL_MS = 10 * 1000
let userWalletsRequestId = 0 let userWalletsRequestId = 0
const filteredUsers = computed(() => { const filteredUsers = computed(() => usersStore.users)
let filtered = [...usersStore.users]
// 先排序:管理员优先,然后按创建时间倒序 const paginatedUsers = computed(() => filteredUsers.value)
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()
})
// 搜索(支持空格分隔的多关键词 AND 搜索) const filteredUserCount = computed(() => usersStore.total)
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 { const {
selectedIds, selectedIds,
selectAllFiltered, selectAllFiltered,
@@ -1700,6 +1662,7 @@ const grantableBillingPlans = computed(() =>
watch([searchQuery, filterRole, filterStatus, filterGroup], () => { watch([searchQuery, filterRole, filterStatus, filterGroup], () => {
currentPage.value = 1 currentPage.value = 1
resetBatchSelection() resetBatchSelection()
void refreshUsers()
}) })
watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true }) watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true })
@@ -1720,8 +1683,17 @@ onMounted(() => {
async function refreshUsers(options: { preferCache?: boolean } = {}) { async function refreshUsers(options: { preferCache?: boolean } = {}) {
const cacheTtlMs = options.preferCache ? USERS_PAGE_CACHE_TTL_MS : 0 const cacheTtlMs = options.preferCache ? USERS_PAGE_CACHE_TTL_MS : 0
const search = searchQuery.value.trim()
await Promise.all([ 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(), loadUserGroups(),
]) ])
void loadUserWallets({ 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> { async function loadUserGroups(): Promise<void> {
try { try {
const response = await usersStore.listUserGroups() const response = await usersStore.listUserGroups()
@@ -1755,6 +1739,10 @@ async function handleUserBatchCompleted(_result: UserBatchActionResponse): Promi
resetBatchSelection(true) resetBatchSelection(true)
} }
function invalidateUserOptions(): void {
userOptionsVersion.value += 1
}
function formatDate(dateString: string) { function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('zh-CN') return new Date(dateString).toLocaleDateString('zh-CN')
} }
@@ -1932,6 +1920,8 @@ async function toggleUserStatus(user: User) {
try { try {
await usersStore.updateUser(user.id, { is_active: !user.is_active }) await usersStore.updateUser(user.id, { is_active: !user.is_active })
invalidateUserOptions()
await refreshUsers()
success(`用户已${action}`) success(`用户已${action}`)
} catch (err: unknown) { } catch (err: unknown) {
error(parseApiError(err, '未知错误'), `${action}用户失败`) error(parseApiError(err, '未知错误'), `${action}用户失败`)
@@ -1982,7 +1972,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
updateData.password = data.password updateData.password = data.password
} }
await usersStore.updateUser(data.id, updateData) await usersStore.updateUser(data.id, updateData)
await loadUserWallets() invalidateUserOptions()
success('用户信息已更新') success('用户信息已更新')
} else { } else {
// 创建用户 // 创建用户
@@ -2000,10 +1990,11 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
if (data.is_active === false && newUser) { if (data.is_active === false && newUser) {
await usersStore.updateUser(newUser.id, { is_active: false }) await usersStore.updateUser(newUser.id, { is_active: false })
} }
await loadUserWallets() invalidateUserOptions()
success('用户创建成功') success('用户创建成功')
} }
closeUserFormDialog() closeUserFormDialog()
await refreshUsers()
} catch (err: unknown) { } catch (err: unknown) {
const title = data.id ? '更新用户失败' : '创建用户失败' const title = data.id ? '更新用户失败' : '创建用户失败'
error(parseApiError(err, '未知错误'), title) error(parseApiError(err, '未知错误'), title)
@@ -2313,6 +2304,11 @@ async function deleteUser(user: User) {
try { try {
await usersStore.deleteUser(user.id) await usersStore.deleteUser(user.id)
invalidateUserOptions()
if (usersStore.users.length === 0 && currentPage.value > 1) {
currentPage.value -= 1
}
await refreshUsers()
success('用户已删除') success('用户已删除')
} catch (err: unknown) { } catch (err: unknown) {
error(parseApiError(err, '未知错误'), '删除用户失败') error(parseApiError(err, '未知错误'), '删除用户失败')