mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
Merge pull request #522 from RWDai/fix/admin-users-server-pagination
Fix admin users server-side pagination
This commit is contained in:
@@ -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> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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#"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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#"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, '未知错误'), '删除用户失败')
|
||||||
|
|||||||
Reference in New Issue
Block a user