mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 20:50:20 +08:00
feat(admin-users): 支持按创建时间排序
This commit is contained in:
@@ -417,6 +417,7 @@ async fn resolve_admin_user_selection(
|
||||
group_id: filters
|
||||
.as_ref()
|
||||
.and_then(|filters| filters.group_id.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "用户数据不可用".to_string())?
|
||||
|
||||
@@ -37,6 +37,13 @@ pub(in super::super) async fn build_admin_list_users_response(
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let sort_by = query_param_value(request_context.query_string(), "sort_by")
|
||||
.and_then(|value| aether_data::repository::users::UserExportSortBy::parse(&value))
|
||||
.unwrap_or_default();
|
||||
let sort_order = query_param_value(request_context.query_string(), "sort_order")
|
||||
.and_then(|value| aether_data::repository::users::UserExportSortOrder::parse(&value))
|
||||
.unwrap_or_default();
|
||||
|
||||
let query = aether_data::repository::users::UserExportListQuery {
|
||||
skip,
|
||||
limit,
|
||||
@@ -44,6 +51,8 @@ pub(in super::super) async fn build_admin_list_users_response(
|
||||
is_active,
|
||||
search,
|
||||
group_id,
|
||||
sort_by,
|
||||
sort_order,
|
||||
};
|
||||
let (paged_rows_result, total_result) = tokio::join!(
|
||||
state.list_export_users_page(&query),
|
||||
|
||||
@@ -15,7 +15,7 @@ use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
||||
use axum::body::Body;
|
||||
use axum::routing::{any, delete, get, patch, post, put};
|
||||
use axum::{extract::Request, Router};
|
||||
use chrono::Utc;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -37,6 +37,16 @@ fn sample_admin_user_with_role(
|
||||
role: &str,
|
||||
email: &str,
|
||||
username: &str,
|
||||
) -> StoredUserAuthRecord {
|
||||
sample_admin_user_with_role_and_created_at(user_id, role, email, username, Utc::now())
|
||||
}
|
||||
|
||||
fn sample_admin_user_with_role_and_created_at(
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
email: &str,
|
||||
username: &str,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
) -> StoredUserAuthRecord {
|
||||
StoredUserAuthRecord::new(
|
||||
user_id.to_string(),
|
||||
@@ -51,7 +61,7 @@ fn sample_admin_user_with_role(
|
||||
Some(json!(["gpt-4.1"])),
|
||||
true,
|
||||
false,
|
||||
Some(Utc::now()),
|
||||
Some(created_at),
|
||||
Some(Utc::now()),
|
||||
)
|
||||
.expect("user should build")
|
||||
@@ -206,6 +216,113 @@ fn sample_admin_api_key_snapshot(user_id: &str, api_key_id: &str) -> StoredAuthA
|
||||
.expect("api key snapshot should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_sorts_admin_users_by_created_at() {
|
||||
let oldest = Utc
|
||||
.with_ymd_and_hms(2026, 1, 10, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid timestamp");
|
||||
let middle = Utc
|
||||
.with_ymd_and_hms(2026, 2, 10, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid timestamp");
|
||||
let newest = Utc
|
||||
.with_ymd_and_hms(2026, 3, 10, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid timestamp");
|
||||
|
||||
let user_repository = Arc::new(
|
||||
InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
sample_admin_user_with_role_and_created_at(
|
||||
"user-old",
|
||||
"user",
|
||||
"old@example.com",
|
||||
"old",
|
||||
oldest,
|
||||
),
|
||||
sample_admin_user_with_role_and_created_at(
|
||||
"user-middle",
|
||||
"user",
|
||||
"middle@example.com",
|
||||
"middle",
|
||||
middle,
|
||||
),
|
||||
sample_admin_user_with_role_and_created_at(
|
||||
"user-new",
|
||||
"user",
|
||||
"new@example.com",
|
||||
"new",
|
||||
newest,
|
||||
),
|
||||
])
|
||||
.with_export_users(vec![
|
||||
sample_admin_export_user_with("user", true, "user-old", "old@example.com", "old"),
|
||||
sample_admin_export_user_with(
|
||||
"user",
|
||||
true,
|
||||
"user-middle",
|
||||
"middle@example.com",
|
||||
"middle",
|
||||
),
|
||||
sample_admin_export_user_with("user", true, "user-new", "new@example.com", "new"),
|
||||
]),
|
||||
);
|
||||
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_user_reader_for_tests(
|
||||
user_repository,
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let desc_response = client
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/users?skip=0&limit=10&sort_by=created_at&sort_order=desc"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(desc_response.status(), StatusCode::OK);
|
||||
let desc_payload: serde_json::Value = desc_response.json().await.expect("json should parse");
|
||||
let desc_ids = desc_payload["items"]
|
||||
.as_array()
|
||||
.expect("items should be array")
|
||||
.iter()
|
||||
.map(|item| item["id"].as_str().expect("id should be string"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(desc_ids, vec!["user-new", "user-middle", "user-old"]);
|
||||
|
||||
let asc_response = client
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/users?skip=0&limit=10&sort_by=created_at&sort_order=asc"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(asc_response.status(), StatusCode::OK);
|
||||
let asc_payload: serde_json::Value = asc_response.json().await.expect("json should parse");
|
||||
let asc_ids = asc_payload["items"]
|
||||
.as_array()
|
||||
.expect("items should be array")
|
||||
.iter()
|
||||
.map(|item| item["id"].as_str().expect("id should be string"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(asc_ids, vec!["user-old", "user-middle", "user-new"]);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
@@ -7,8 +7,8 @@ use super::types::{
|
||||
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
|
||||
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
|
||||
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
|
||||
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
|
||||
UserReadRepository,
|
||||
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
|
||||
UserExportSummary, UserReadRepository,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
|
||||
@@ -409,6 +409,34 @@ fn filter_memory_export_rows(
|
||||
.contains(&search)
|
||||
});
|
||||
}
|
||||
match query.sort_by {
|
||||
UserExportSortBy::CreatedAt => {
|
||||
let created_at_by_id = repository
|
||||
.auth_by_id
|
||||
.read()
|
||||
.expect("user repository lock")
|
||||
.iter()
|
||||
.filter_map(|(user_id, user)| {
|
||||
user.created_at
|
||||
.map(|created_at| (user_id.clone(), created_at.timestamp_millis()))
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
rows.sort_by(|left, right| {
|
||||
let primary = created_at_by_id
|
||||
.get(&left.id)
|
||||
.cmp(&created_at_by_id.get(&right.id));
|
||||
let ordered = if query.sort_order.is_desc() {
|
||||
primary.reverse()
|
||||
} else {
|
||||
primary
|
||||
};
|
||||
ordered.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
}
|
||||
UserExportSortBy::Id => {
|
||||
rows.sort_by(|left, right| left.id.cmp(&right.id));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
@@ -2682,6 +2710,7 @@ mod tests {
|
||||
is_active: Some(true),
|
||||
search: None,
|
||||
group_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("paged export should succeed");
|
||||
|
||||
@@ -12,5 +12,6 @@ pub use types::{
|
||||
normalize_user_group_name, StoredUserAuthRecord, StoredUserExportRow, StoredUserGroup,
|
||||
StoredUserGroupMember, StoredUserGroupMembership, StoredUserOAuthLinkSummary,
|
||||
StoredUserPreferenceRecord, StoredUserSessionRecord, StoredUserSummary, UpsertUserGroupRecord,
|
||||
UserExportListQuery, UserExportSummary, UserReadRepository,
|
||||
UserExportListQuery, UserExportSortBy, UserExportSortOrder, UserExportSummary,
|
||||
UserReadRepository,
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@ use super::types::{
|
||||
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
|
||||
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
|
||||
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
|
||||
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
|
||||
UserReadRepository,
|
||||
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
|
||||
UserExportSummary, UserReadRepository,
|
||||
};
|
||||
use crate::driver::mysql::MysqlPool;
|
||||
use crate::error::SqlResultExt;
|
||||
@@ -313,8 +313,24 @@ impl UserReadRepository for MysqlUserReadRepository {
|
||||
.push_bind(pattern)
|
||||
.push(")");
|
||||
}
|
||||
match query.sort_by {
|
||||
UserExportSortBy::CreatedAt => {
|
||||
builder
|
||||
.push(" ORDER BY created_at ")
|
||||
.push(if query.sort_order.is_desc() {
|
||||
"DESC"
|
||||
} else {
|
||||
"ASC"
|
||||
})
|
||||
.push(", id ASC");
|
||||
}
|
||||
UserExportSortBy::Id => {
|
||||
builder.push(" ORDER BY id ASC");
|
||||
}
|
||||
}
|
||||
|
||||
builder
|
||||
.push(" ORDER BY id ASC LIMIT ")
|
||||
.push(" LIMIT ")
|
||||
.push_bind(i64::try_from(query.limit).map_err(|_| {
|
||||
DataLayerError::InvalidInput(format!("invalid user export limit: {}", query.limit))
|
||||
})?)
|
||||
|
||||
@@ -6,8 +6,8 @@ use super::types::{
|
||||
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
|
||||
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
|
||||
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
|
||||
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
|
||||
UserReadRepository,
|
||||
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
|
||||
UserExportSummary, UserReadRepository,
|
||||
};
|
||||
use crate::{error::SqlxResultExt, DataLayerError};
|
||||
|
||||
@@ -1001,8 +1001,24 @@ WHERE user_group_members.user_id IN (
|
||||
.push(")");
|
||||
}
|
||||
|
||||
match query.sort_by {
|
||||
UserExportSortBy::CreatedAt => {
|
||||
builder
|
||||
.push(" ORDER BY created_at ")
|
||||
.push(if query.sort_order.is_desc() {
|
||||
"DESC"
|
||||
} else {
|
||||
"ASC"
|
||||
})
|
||||
.push(", id ASC");
|
||||
}
|
||||
UserExportSortBy::Id => {
|
||||
builder.push(" ORDER BY id ASC");
|
||||
}
|
||||
}
|
||||
|
||||
builder
|
||||
.push(" ORDER BY id ASC OFFSET ")
|
||||
.push(" OFFSET ")
|
||||
.push_bind(i64::try_from(query.skip).map_err(|_| {
|
||||
DataLayerError::InvalidInput(format!("invalid user export skip: {}", query.skip))
|
||||
})?)
|
||||
|
||||
@@ -6,8 +6,8 @@ use super::types::{
|
||||
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
|
||||
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
|
||||
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
|
||||
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
|
||||
UserReadRepository,
|
||||
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
|
||||
UserExportSummary, UserReadRepository,
|
||||
};
|
||||
use crate::driver::sqlite::SqlitePool;
|
||||
use crate::error::SqlResultExt;
|
||||
@@ -313,8 +313,24 @@ impl UserReadRepository for SqliteUserReadRepository {
|
||||
.push_bind(pattern)
|
||||
.push(")");
|
||||
}
|
||||
match query.sort_by {
|
||||
UserExportSortBy::CreatedAt => {
|
||||
builder
|
||||
.push(" ORDER BY created_at ")
|
||||
.push(if query.sort_order.is_desc() {
|
||||
"DESC"
|
||||
} else {
|
||||
"ASC"
|
||||
})
|
||||
.push(", id ASC");
|
||||
}
|
||||
UserExportSortBy::Id => {
|
||||
builder.push(" ORDER BY id ASC");
|
||||
}
|
||||
}
|
||||
|
||||
builder
|
||||
.push(" ORDER BY id ASC LIMIT ")
|
||||
.push(" LIMIT ")
|
||||
.push_bind(i64::try_from(query.limit).map_err(|_| {
|
||||
DataLayerError::InvalidInput(format!("invalid user export limit: {}", query.limit))
|
||||
})?)
|
||||
@@ -2199,6 +2215,7 @@ INSERT INTO users (
|
||||
is_active: Some(true),
|
||||
search: None,
|
||||
group_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("export page should load");
|
||||
|
||||
@@ -642,6 +642,46 @@ pub struct UserExportListQuery {
|
||||
pub is_active: Option<bool>,
|
||||
pub search: Option<String>,
|
||||
pub group_id: Option<String>,
|
||||
pub sort_by: UserExportSortBy,
|
||||
pub sort_order: UserExportSortOrder,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum UserExportSortBy {
|
||||
#[default]
|
||||
Id,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
impl UserExportSortBy {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"created_at" => Some(Self::CreatedAt),
|
||||
"id" => Some(Self::Id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum UserExportSortOrder {
|
||||
#[default]
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
impl UserExportSortOrder {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"asc" => Some(Self::Asc),
|
||||
"desc" => Some(Self::Desc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_desc(self) -> bool {
|
||||
matches!(self, Self::Desc)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getMock, cachedRequestMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
cachedRequestMock: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: {
|
||||
get: getMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/cache', () => ({
|
||||
cachedRequest: cachedRequestMock,
|
||||
}))
|
||||
|
||||
import { usersApi } from '@/api/users'
|
||||
|
||||
describe('usersApi admin list query', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
cachedRequestMock.mockClear()
|
||||
getMock.mockResolvedValue({
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
skip: 0,
|
||||
limit: 20,
|
||||
has_more: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('passes creation-time sort parameters to the admin users endpoint', async () => {
|
||||
await usersApi.getAllUsersPage({
|
||||
skip: 20,
|
||||
limit: 10,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc',
|
||||
})
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/api/admin/users', {
|
||||
params: {
|
||||
skip: 20,
|
||||
limit: 10,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,8 @@ import type { BillingPlan, UserPlanEntitlement } from './billing'
|
||||
export type UserRole = 'admin' | 'audit_admin' | 'user'
|
||||
export type ListPolicyMode = 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
|
||||
export type RateLimitPolicyMode = 'inherit' | 'system' | 'custom'
|
||||
export type AdminUserSortBy = 'created_at'
|
||||
export type AdminUserSortOrder = 'asc' | 'desc'
|
||||
export type FeatureSettings = Record<string, unknown>
|
||||
|
||||
export interface UserGroupSummary {
|
||||
@@ -261,6 +263,8 @@ export interface GetAllUsersOptions {
|
||||
role?: UserRole
|
||||
is_active?: boolean
|
||||
group_id?: string
|
||||
sort_by?: AdminUserSortBy
|
||||
sort_order?: AdminUserSortOrder
|
||||
skip?: number
|
||||
limit?: number
|
||||
cacheTtlMs?: number
|
||||
@@ -298,6 +302,8 @@ export const usersApi = {
|
||||
if (options.role) params.role = options.role
|
||||
if (options.is_active !== undefined) params.is_active = options.is_active ? 'true' : 'false'
|
||||
if (options.group_id) params.group_id = options.group_id
|
||||
if (options.sort_by) params.sort_by = options.sort_by
|
||||
if (options.sort_order) params.sort_order = options.sort_order
|
||||
if (options.skip !== undefined) params.skip = options.skip
|
||||
if (options.limit !== undefined) params.limit = options.limit
|
||||
|
||||
@@ -309,6 +315,8 @@ export const usersApi = {
|
||||
options.role ?? '',
|
||||
options.is_active ?? '',
|
||||
options.group_id ?? '',
|
||||
options.sort_by ?? '',
|
||||
options.sort_order ?? '',
|
||||
options.skip ?? '',
|
||||
options.limit ?? '',
|
||||
options.cacheKeySuffix ?? '',
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type UserBatchActionRequest,
|
||||
type UserBatchActionResponse,
|
||||
type UserRole,
|
||||
type AdminUserSortBy,
|
||||
type AdminUserSortOrder,
|
||||
type UserGroup,
|
||||
type UserGroupMember,
|
||||
type UpsertUserGroupRequest,
|
||||
@@ -40,6 +42,8 @@ export const useUsersStore = defineStore('users', () => {
|
||||
role?: UserRole
|
||||
is_active?: boolean
|
||||
group_id?: string
|
||||
sort_by?: AdminUserSortBy
|
||||
sort_order?: AdminUserSortOrder
|
||||
skip?: number
|
||||
limit?: number
|
||||
} = {}) {
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- 筛选器 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="relative min-w-40 flex-1">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="users-search-mobile"
|
||||
@@ -110,6 +110,22 @@
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-model="sortOption"
|
||||
>
|
||||
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="排序" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in userSortOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -201,6 +217,23 @@
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<Select v-model="sortOption">
|
||||
<SelectTrigger class="w-40 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="排序" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in userSortOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
@@ -317,9 +350,17 @@
|
||||
<TableHead class="w-[170px] h-12 font-semibold">
|
||||
统计/限速
|
||||
</TableHead>
|
||||
<TableHead class="w-[110px] h-12 font-semibold">
|
||||
<SortableTableHead
|
||||
class="w-[110px] h-12 font-semibold"
|
||||
column-key="created_at"
|
||||
:active-key="sortBy"
|
||||
:direction="sortOrder"
|
||||
default-direction="desc"
|
||||
title="按创建时间排序"
|
||||
@sort="handleTableSort"
|
||||
>
|
||||
创建时间
|
||||
</TableHead>
|
||||
</SortableTableHead>
|
||||
<SortableTableHead
|
||||
class="w-[180px] h-12 font-semibold"
|
||||
column-key="status"
|
||||
@@ -1470,7 +1511,18 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usersApi, type User, type ApiKey, type UserSession, type UserBatchActionResponse, type UserBatchSelectionFilters, type UserGroup, type AdminUserPlanEntitlement } from '@/api/users'
|
||||
import {
|
||||
usersApi,
|
||||
type User,
|
||||
type ApiKey,
|
||||
type UserSession,
|
||||
type UserBatchActionResponse,
|
||||
type UserBatchSelectionFilters,
|
||||
type UserGroup,
|
||||
type AdminUserPlanEntitlement,
|
||||
type AdminUserSortBy,
|
||||
type AdminUserSortOrder,
|
||||
} from '@/api/users'
|
||||
import { formatSessionMeta } from '@/types/session'
|
||||
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
||||
import { adminBillingPlansApi, type BillingEntitlement, type BillingPlan } from '@/api/billing'
|
||||
@@ -1598,6 +1650,7 @@ const searchQuery = ref('')
|
||||
const filterRole = ref<'all' | User['role']>('all')
|
||||
const filterStatus = ref<'all' | 'active' | 'inactive'>('all')
|
||||
const filterGroup = ref('all')
|
||||
const sortOption = ref<'default' | 'created_at_desc' | 'created_at_asc'>('default')
|
||||
const userGroups = ref<UserGroup[]>([])
|
||||
const userRoleFilterOptions = [
|
||||
{ value: 'all', label: '全部角色' },
|
||||
@@ -1610,6 +1663,17 @@ const userStatusFilterOptions = [
|
||||
{ value: 'active', label: '活跃' },
|
||||
{ value: 'inactive', label: '禁用' },
|
||||
]
|
||||
const userSortOptions = [
|
||||
{ value: 'default', label: '默认排序' },
|
||||
{ value: 'created_at_desc', label: '创建时间 新到旧' },
|
||||
{ value: 'created_at_asc', label: '创建时间 旧到新' },
|
||||
]
|
||||
const sortBy = computed<AdminUserSortBy | null>(() =>
|
||||
sortOption.value === 'default' ? null : 'created_at'
|
||||
)
|
||||
const sortOrder = computed<AdminUserSortOrder>(() =>
|
||||
sortOption.value === 'created_at_asc' ? 'asc' : 'desc'
|
||||
)
|
||||
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
@@ -1659,7 +1723,7 @@ const grantableBillingPlans = computed(() =>
|
||||
)
|
||||
|
||||
// Watch filter changes and reset to first page
|
||||
watch([searchQuery, filterRole, filterStatus, filterGroup], () => {
|
||||
watch([searchQuery, filterRole, filterStatus, filterGroup, sortOption], () => {
|
||||
currentPage.value = 1
|
||||
resetBatchSelection()
|
||||
void refreshUsers()
|
||||
@@ -1691,6 +1755,8 @@ async function refreshUsers(options: { preferCache?: boolean } = {}) {
|
||||
role: filterRole.value === 'all' ? undefined : filterRole.value,
|
||||
is_active: filterStatus.value === 'all' ? undefined : filterStatus.value === 'active',
|
||||
group_id: filterGroup.value === 'all' ? undefined : filterGroup.value,
|
||||
sort_by: sortBy.value ?? undefined,
|
||||
sort_order: sortBy.value ? sortOrder.value : undefined,
|
||||
skip: (currentPage.value - 1) * pageSize.value,
|
||||
limit: pageSize.value,
|
||||
}),
|
||||
@@ -1701,6 +1767,11 @@ async function refreshUsers(options: { preferCache?: boolean } = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
function handleTableSort(payload: { key: string, direction: AdminUserSortOrder }): void {
|
||||
if (payload.key !== 'created_at') return
|
||||
sortOption.value = payload.direction === 'asc' ? 'created_at_asc' : 'created_at_desc'
|
||||
}
|
||||
|
||||
function handlePageChange(page: number): void {
|
||||
currentPage.value = page
|
||||
void refreshUsers({ preferCache: true })
|
||||
|
||||
Reference in New Issue
Block a user