mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Merge remote-tracking branch 'origin/pr/394' into aether-rust-pioneer
This commit is contained in:
@@ -490,6 +490,32 @@ pub(super) fn classify_admin_operations_family_route(
|
||||
"admin:users",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/users/resolve-selection" | "/api/admin/users/resolve-selection/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"users_manage",
|
||||
"resolve_user_selection",
|
||||
"admin:users",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/users/batch-action" | "/api/admin/users/batch-action/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"users_manage",
|
||||
"batch_action_users",
|
||||
"admin:users",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path.starts_with("/api/admin/users/")
|
||||
&& normalized_path.ends_with("/sessions")
|
||||
|
||||
@@ -36,6 +36,38 @@ fn classifies_admin_users_create_as_admin_proxy_route() {
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_user_batch_routes_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
let resolve_uri: Uri = "/api/admin/users/resolve-selection"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let resolve = classify_control_route(&http::Method::POST, &resolve_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(resolve.route_family.as_deref(), Some("users_manage"));
|
||||
assert_eq!(
|
||||
resolve.route_kind.as_deref(),
|
||||
Some("resolve_user_selection")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:users")
|
||||
);
|
||||
|
||||
let batch_uri: Uri = "/api/admin/users/batch-action"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let batch = classify_control_route(&http::Method::POST, &batch_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(batch.route_family.as_deref(), Some("users_manage"));
|
||||
assert_eq!(batch.route_kind.as_deref(), Some("batch_action_users"));
|
||||
assert_eq!(
|
||||
batch.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:users")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_user_detail_routes_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
@@ -399,6 +399,7 @@ impl GatewayDataState {
|
||||
allowed_api_formats: Option<Vec<String>>,
|
||||
allowed_models_present: bool,
|
||||
allowed_models: Option<Vec<String>>,
|
||||
rate_limit_present: bool,
|
||||
rate_limit: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
@@ -415,6 +416,7 @@ impl GatewayDataState {
|
||||
allowed_api_formats,
|
||||
allowed_models_present,
|
||||
allowed_models,
|
||||
rate_limit_present,
|
||||
rate_limit,
|
||||
is_active,
|
||||
)
|
||||
|
||||
@@ -1847,6 +1847,7 @@ impl<'a> AdminAppState<'a> {
|
||||
allowed_api_formats.clone(),
|
||||
user.contains_key("allowed_models"),
|
||||
allowed_models.clone(),
|
||||
user.contains_key("rate_limit"),
|
||||
rate_limit,
|
||||
Some(is_active),
|
||||
)
|
||||
|
||||
@@ -166,6 +166,7 @@ impl<'a> AdminAppState<'a> {
|
||||
allowed_api_formats: Option<Vec<String>>,
|
||||
allowed_models_present: bool,
|
||||
allowed_models: Option<Vec<String>>,
|
||||
rate_limit_present: bool,
|
||||
rate_limit: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
|
||||
@@ -179,6 +180,7 @@ impl<'a> AdminAppState<'a> {
|
||||
allowed_api_formats,
|
||||
allowed_models_present,
|
||||
allowed_models,
|
||||
rate_limit_present,
|
||||
rate_limit,
|
||||
is_active,
|
||||
)
|
||||
@@ -581,4 +583,10 @@ impl<'a> AdminAppState<'a> {
|
||||
) -> Result<Vec<aether_data::repository::users::StoredUserExportRow>, GatewayError> {
|
||||
self.app.list_non_admin_export_users().await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_export_users(
|
||||
&self,
|
||||
) -> Result<Vec<aether_data::repository::users::StoredUserExportRow>, GatewayError> {
|
||||
self.app.list_export_users().await
|
||||
}
|
||||
}
|
||||
|
||||
648
apps/aether-gateway/src/handlers/admin/users/batch.rs
Normal file
648
apps/aether-gateway/src/handlers/admin/users/batch.rs
Normal file
@@ -0,0 +1,648 @@
|
||||
use super::{
|
||||
build_admin_users_bad_request_response, build_admin_users_read_only_response,
|
||||
normalize_admin_user_api_formats, normalize_admin_user_role, normalize_admin_user_string_list,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||
use crate::GatewayError;
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Deserialize)]
|
||||
struct AdminUserSelectionFilters {
|
||||
#[serde(default)]
|
||||
search: Option<String>,
|
||||
#[serde(default)]
|
||||
role: Option<String>,
|
||||
#[serde(default)]
|
||||
is_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct AdminUserSelectionRequest {
|
||||
user_ids: Vec<String>,
|
||||
filters: Option<AdminUserSelectionFilters>,
|
||||
filters_scope_present: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AdminUserBatchActionRequest {
|
||||
selection: AdminUserSelectionRequest,
|
||||
action: String,
|
||||
payload: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct RawAdminUserBatchActionRequest {
|
||||
selection: Value,
|
||||
action: String,
|
||||
#[serde(default)]
|
||||
payload: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct NormalizedAdminUserSelectionFilters {
|
||||
search: Option<String>,
|
||||
role: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct AdminUserSelectionItem {
|
||||
user_id: String,
|
||||
username: String,
|
||||
email: Option<String>,
|
||||
role: String,
|
||||
is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ResolvedAdminUserSelection {
|
||||
items: Vec<AdminUserSelectionItem>,
|
||||
missing_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct AdminUserBatchMutation {
|
||||
role: Option<String>,
|
||||
allowed_providers_present: bool,
|
||||
allowed_providers: Option<Vec<String>>,
|
||||
allowed_api_formats_present: bool,
|
||||
allowed_api_formats: Option<Vec<String>>,
|
||||
allowed_models_present: bool,
|
||||
allowed_models: Option<Vec<String>>,
|
||||
rate_limit_present: bool,
|
||||
rate_limit: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
unlimited: Option<bool>,
|
||||
modified_fields: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl AdminUserBatchMutation {
|
||||
fn has_auth_user_fields(&self) -> bool {
|
||||
self.role.is_some()
|
||||
|| self.allowed_providers_present
|
||||
|| self.allowed_api_formats_present
|
||||
|| self.allowed_models_present
|
||||
|| self.rate_limit_present
|
||||
|| self.is_active.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
pub(in super::super) async fn build_admin_resolve_user_selection_response(
|
||||
state: &AdminAppState<'_>,
|
||||
_request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let selection = match parse_resolve_selection_request(request_body) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
|
||||
};
|
||||
let resolved = match resolve_admin_user_selection(state, selection).await {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"total": resolved.items.len(),
|
||||
"items": resolved.items,
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub(in super::super) async fn build_admin_user_batch_action_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let request = match parse_batch_action_request(request_body) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
|
||||
};
|
||||
let mutation = match parse_batch_mutation(&request.action, request.payload) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
|
||||
};
|
||||
if mutation.has_auth_user_fields() && !state.has_auth_user_write_capability() {
|
||||
return Ok(build_admin_users_read_only_response(
|
||||
"当前为只读模式,无法批量更新用户",
|
||||
));
|
||||
}
|
||||
if mutation.unlimited.is_some() && !state.has_auth_wallet_write_capability() {
|
||||
return Ok(build_admin_users_read_only_response(
|
||||
"当前为只读模式,无法批量更新用户钱包",
|
||||
));
|
||||
}
|
||||
let resolved = match resolve_admin_user_selection(state, request.selection).await {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
|
||||
};
|
||||
let active_admin_demotions = count_active_admin_demotions(&mutation, &resolved.items);
|
||||
let active_admin_count = if active_admin_demotions > 0 {
|
||||
state.count_active_admin_users().await?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let current_admin_user_id = request_context
|
||||
.decision()
|
||||
.and_then(|decision| decision.admin_principal.as_ref())
|
||||
.map(|principal| principal.user_id.as_str());
|
||||
|
||||
let mut success = 0usize;
|
||||
let mut failures = resolved
|
||||
.missing_user_ids
|
||||
.iter()
|
||||
.map(|user_id| json!({ "user_id": user_id, "reason": "用户不存在或已删除" }))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for item in &resolved.items {
|
||||
if state.find_user_auth_by_id(&item.user_id).await?.is_none() {
|
||||
failures.push(json!({
|
||||
"user_id": item.user_id,
|
||||
"reason": "用户不存在或已删除",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(reason) = batch_role_demotion_failure_reason(
|
||||
&mutation,
|
||||
item,
|
||||
active_admin_count,
|
||||
active_admin_demotions,
|
||||
current_admin_user_id,
|
||||
) {
|
||||
failures.push(json!({
|
||||
"user_id": item.user_id,
|
||||
"reason": reason,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(unlimited) = mutation.unlimited {
|
||||
if !apply_batch_user_wallet_limit_mode(state, &item.user_id, unlimited).await? {
|
||||
failures.push(json!({
|
||||
"user_id": item.user_id,
|
||||
"reason": "用户钱包不可用",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if mutation.has_auth_user_fields()
|
||||
&& state
|
||||
.update_local_auth_user_admin_fields(
|
||||
&item.user_id,
|
||||
mutation.role.clone(),
|
||||
mutation.allowed_providers_present,
|
||||
mutation.allowed_providers.clone(),
|
||||
mutation.allowed_api_formats_present,
|
||||
mutation.allowed_api_formats.clone(),
|
||||
mutation.allowed_models_present,
|
||||
mutation.allowed_models.clone(),
|
||||
mutation.rate_limit_present,
|
||||
mutation.rate_limit,
|
||||
mutation.is_active,
|
||||
)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
failures.push(json!({
|
||||
"user_id": item.user_id,
|
||||
"reason": "用户不存在或已删除",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
success += 1;
|
||||
}
|
||||
|
||||
let failed = failures.len();
|
||||
let total = success + failed;
|
||||
let response = Json(json!({
|
||||
"total": total,
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"failures": failures,
|
||||
"action": request.action.trim().to_ascii_lowercase(),
|
||||
"modified_fields": mutation.modified_fields,
|
||||
}))
|
||||
.into_response();
|
||||
|
||||
Ok(attach_admin_audit_response(
|
||||
response,
|
||||
"admin_users_batch_action_executed",
|
||||
"batch_update_users",
|
||||
"user_batch",
|
||||
"users",
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_resolve_selection_request(
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<AdminUserSelectionRequest, String> {
|
||||
match request_body {
|
||||
None => Ok(AdminUserSelectionRequest::default()),
|
||||
Some(body) if body.is_empty() => Ok(AdminUserSelectionRequest::default()),
|
||||
Some(body) => {
|
||||
let value = serde_json::from_slice::<Value>(body)
|
||||
.map_err(|_| "Invalid JSON request body".to_string())?;
|
||||
parse_selection_request_value(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_batch_action_request(
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<AdminUserBatchActionRequest, String> {
|
||||
match request_body {
|
||||
Some(body) if !body.is_empty() => {
|
||||
let raw = serde_json::from_slice::<RawAdminUserBatchActionRequest>(body)
|
||||
.map_err(|_| "Invalid JSON request body".to_string())?;
|
||||
Ok(AdminUserBatchActionRequest {
|
||||
selection: parse_selection_request_value(raw.selection)?,
|
||||
action: raw.action,
|
||||
payload: raw.payload,
|
||||
})
|
||||
}
|
||||
_ => Err("Invalid JSON request body".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_selection_request_value(value: Value) -> Result<AdminUserSelectionRequest, String> {
|
||||
let Value::Object(map) = value else {
|
||||
return Err("selection 必须是对象".to_string());
|
||||
};
|
||||
|
||||
let user_ids = match map.get("user_ids") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(value) => serde_json::from_value::<Vec<String>>(value.clone())
|
||||
.map_err(|_| "user_ids 必须是字符串数组".to_string())?,
|
||||
};
|
||||
|
||||
let (filters_scope_present, filters) = match map.get("filters") {
|
||||
Some(Value::Object(_)) => {
|
||||
let filters = serde_json::from_value::<AdminUserSelectionFilters>(
|
||||
map.get("filters").cloned().unwrap_or(Value::Null),
|
||||
)
|
||||
.map_err(|_| "filters 参数不合法".to_string())?;
|
||||
(true, Some(filters))
|
||||
}
|
||||
None | Some(Value::Null) => (false, None),
|
||||
Some(_) => return Err("filters 必须是对象".to_string()),
|
||||
};
|
||||
|
||||
Ok(AdminUserSelectionRequest {
|
||||
user_ids,
|
||||
filters,
|
||||
filters_scope_present,
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_admin_user_selection(
|
||||
state: &AdminAppState<'_>,
|
||||
selection: AdminUserSelectionRequest,
|
||||
) -> Result<ResolvedAdminUserSelection, String> {
|
||||
let filters = normalize_selection_filters(selection.filters)?;
|
||||
let explicit_user_ids = normalize_user_ids(selection.user_ids);
|
||||
if explicit_user_ids.is_empty() && !selection.filters_scope_present {
|
||||
return Err("至少需要选择一个用户或明确提供筛选条件".to_string());
|
||||
}
|
||||
let should_resolve_filters = selection.filters_scope_present;
|
||||
let mut items_by_id = BTreeMap::new();
|
||||
let mut missing_user_ids = Vec::new();
|
||||
|
||||
if !explicit_user_ids.is_empty() {
|
||||
let users = state
|
||||
.resolve_auth_user_summaries_by_ids(&explicit_user_ids)
|
||||
.await
|
||||
.map_err(|_| "用户数据不可用".to_string())?;
|
||||
for user_id in explicit_user_ids {
|
||||
match users.get(&user_id).filter(|user| !user.is_deleted) {
|
||||
Some(user) => {
|
||||
items_by_id.insert(
|
||||
user.id.clone(),
|
||||
AdminUserSelectionItem {
|
||||
user_id: user.id.clone(),
|
||||
username: user.username.clone(),
|
||||
email: user.email.clone(),
|
||||
role: user.role.clone(),
|
||||
is_active: user.is_active,
|
||||
},
|
||||
);
|
||||
}
|
||||
None => missing_user_ids.push(user_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_resolve_filters {
|
||||
let users = state
|
||||
.list_export_users()
|
||||
.await
|
||||
.map_err(|_| "用户数据不可用".to_string())?;
|
||||
for user in users
|
||||
.into_iter()
|
||||
.filter(|user| admin_user_matches_filters(user, filters.as_ref()))
|
||||
{
|
||||
items_by_id.insert(
|
||||
user.id.clone(),
|
||||
AdminUserSelectionItem {
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut items = items_by_id.into_values().collect::<Vec<_>>();
|
||||
items.sort_by(|left, right| {
|
||||
left.username
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&right.username.to_ascii_lowercase())
|
||||
.then_with(|| left.user_id.cmp(&right.user_id))
|
||||
});
|
||||
|
||||
Ok(ResolvedAdminUserSelection {
|
||||
items,
|
||||
missing_user_ids,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_selection_filters(
|
||||
filters: Option<AdminUserSelectionFilters>,
|
||||
) -> Result<Option<NormalizedAdminUserSelectionFilters>, String> {
|
||||
let Some(filters) = filters else {
|
||||
return Ok(None);
|
||||
};
|
||||
let search = filters
|
||||
.search
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let role = match filters
|
||||
.role
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.filter(|value| !value.is_empty() && value != "all")
|
||||
{
|
||||
Some(role) if matches!(role.as_str(), "user" | "admin") => Some(role),
|
||||
Some(_) => return Err("role 参数不合法".to_string()),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Some(NormalizedAdminUserSelectionFilters {
|
||||
search,
|
||||
role,
|
||||
is_active: filters.is_active,
|
||||
}))
|
||||
}
|
||||
|
||||
fn admin_user_matches_filters(
|
||||
user: &aether_data::repository::users::StoredUserExportRow,
|
||||
filters: Option<&NormalizedAdminUserSelectionFilters>,
|
||||
) -> bool {
|
||||
let Some(filters) = filters else {
|
||||
return true;
|
||||
};
|
||||
if filters
|
||||
.role
|
||||
.as_deref()
|
||||
.is_some_and(|role| !user.role.eq_ignore_ascii_case(role))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if filters
|
||||
.is_active
|
||||
.is_some_and(|is_active| user.is_active != is_active)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(search) = filters.search.as_deref() {
|
||||
let searchable_text = format!(
|
||||
"{} {}",
|
||||
user.username,
|
||||
user.email.as_deref().unwrap_or_default()
|
||||
)
|
||||
.to_ascii_lowercase();
|
||||
let keywords = search
|
||||
.to_ascii_lowercase()
|
||||
.split_whitespace()
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
if !keywords
|
||||
.iter()
|
||||
.all(|keyword| searchable_text.contains(keyword))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn normalize_user_ids(user_ids: Vec<String>) -> Vec<String> {
|
||||
user_ids
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_batch_mutation(
|
||||
action: &str,
|
||||
payload: Option<Value>,
|
||||
) -> Result<AdminUserBatchMutation, String> {
|
||||
match action.trim().to_ascii_lowercase().as_str() {
|
||||
"enable" => Ok(AdminUserBatchMutation {
|
||||
is_active: Some(true),
|
||||
modified_fields: vec!["is_active"],
|
||||
..AdminUserBatchMutation::default()
|
||||
}),
|
||||
"disable" => Ok(AdminUserBatchMutation {
|
||||
is_active: Some(false),
|
||||
modified_fields: vec!["is_active"],
|
||||
..AdminUserBatchMutation::default()
|
||||
}),
|
||||
"update_access_control" => parse_access_control_mutation(payload),
|
||||
"update_role" => parse_role_mutation(payload),
|
||||
_ => Err("不支持的批量操作".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_role_mutation(payload: Option<Value>) -> Result<AdminUserBatchMutation, String> {
|
||||
let Some(Value::Object(payload)) = payload else {
|
||||
return Err("payload 必须是对象".to_string());
|
||||
};
|
||||
let Some(value) = payload.get("role") else {
|
||||
return Err("role 参数不能为空".to_string());
|
||||
};
|
||||
let Some(role) = value.as_str() else {
|
||||
return Err("role 参数不合法".to_string());
|
||||
};
|
||||
let role = role.trim();
|
||||
if role.is_empty() {
|
||||
return Err("role 参数不能为空".to_string());
|
||||
}
|
||||
Ok(AdminUserBatchMutation {
|
||||
role: Some(normalize_admin_user_role(Some(role))?),
|
||||
modified_fields: vec!["role"],
|
||||
..AdminUserBatchMutation::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_access_control_mutation(payload: Option<Value>) -> Result<AdminUserBatchMutation, String> {
|
||||
let Some(Value::Object(payload)) = payload else {
|
||||
return Err("payload 必须是对象".to_string());
|
||||
};
|
||||
let mut mutation = AdminUserBatchMutation::default();
|
||||
|
||||
if let Some(value) = payload.get("allowed_providers") {
|
||||
mutation.allowed_providers_present = true;
|
||||
mutation.allowed_providers = parse_optional_string_list(value, "allowed_providers")?;
|
||||
mutation.modified_fields.push("allowed_providers");
|
||||
}
|
||||
if let Some(value) = payload.get("allowed_api_formats") {
|
||||
mutation.allowed_api_formats_present = true;
|
||||
mutation.allowed_api_formats = parse_optional_api_formats(value)?;
|
||||
mutation.modified_fields.push("allowed_api_formats");
|
||||
}
|
||||
if let Some(value) = payload.get("allowed_models") {
|
||||
mutation.allowed_models_present = true;
|
||||
mutation.allowed_models = parse_optional_string_list(value, "allowed_models")?;
|
||||
mutation.modified_fields.push("allowed_models");
|
||||
}
|
||||
if let Some(value) = payload.get("rate_limit") {
|
||||
mutation.rate_limit_present = true;
|
||||
mutation.rate_limit = parse_optional_rate_limit(value)?;
|
||||
mutation.modified_fields.push("rate_limit");
|
||||
}
|
||||
if let Some(value) = payload.get("unlimited") {
|
||||
mutation.unlimited = Some(parse_unlimited(value)?);
|
||||
mutation.modified_fields.push("unlimited");
|
||||
}
|
||||
|
||||
if mutation.modified_fields.is_empty() {
|
||||
return Err("至少需要选择一个要修改的访问控制字段".to_string());
|
||||
}
|
||||
|
||||
Ok(mutation)
|
||||
}
|
||||
|
||||
fn parse_optional_string_list(
|
||||
value: &Value,
|
||||
field_name: &str,
|
||||
) -> Result<Option<Vec<String>>, String> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let values = serde_json::from_value::<Vec<String>>(value.clone())
|
||||
.map_err(|_| format!("{field_name} 必须是字符串数组或 null"))?;
|
||||
normalize_admin_user_string_list(Some(values), field_name)
|
||||
}
|
||||
|
||||
fn parse_optional_api_formats(value: &Value) -> Result<Option<Vec<String>>, String> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let values = serde_json::from_value::<Vec<String>>(value.clone())
|
||||
.map_err(|_| "allowed_api_formats 必须是字符串数组或 null".to_string())?;
|
||||
normalize_admin_user_api_formats(Some(values))
|
||||
}
|
||||
|
||||
fn parse_optional_rate_limit(value: &Value) -> Result<Option<i32>, String> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let rate_limit = serde_json::from_value::<i32>(value.clone())
|
||||
.map_err(|_| "rate_limit 必须是整数或 null".to_string())?;
|
||||
if rate_limit < 0 {
|
||||
return Err("rate_limit 必须大于等于 0".to_string());
|
||||
}
|
||||
Ok(Some(rate_limit))
|
||||
}
|
||||
|
||||
fn parse_unlimited(value: &Value) -> Result<bool, String> {
|
||||
serde_json::from_value::<bool>(value.clone()).map_err(|_| "unlimited 必须是布尔值".to_string())
|
||||
}
|
||||
|
||||
fn count_active_admin_demotions(
|
||||
mutation: &AdminUserBatchMutation,
|
||||
items: &[AdminUserSelectionItem],
|
||||
) -> usize {
|
||||
if mutation.role.as_deref() != Some("user") {
|
||||
return 0;
|
||||
}
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| item.is_active && item.role.eq_ignore_ascii_case("admin"))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn batch_role_demotion_failure_reason(
|
||||
mutation: &AdminUserBatchMutation,
|
||||
item: &AdminUserSelectionItem,
|
||||
active_admin_count: u64,
|
||||
active_admin_demotions: usize,
|
||||
current_admin_user_id: Option<&str>,
|
||||
) -> Option<&'static str> {
|
||||
if mutation.role.as_deref() != Some("user")
|
||||
|| !item.is_active
|
||||
|| !item.role.eq_ignore_ascii_case("admin")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if current_admin_user_id.is_some_and(|user_id| user_id == item.user_id) {
|
||||
return Some("不能降级当前管理员账户");
|
||||
}
|
||||
if active_admin_count <= active_admin_demotions as u64 {
|
||||
return Some("不能降级最后一个管理员账户");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn apply_batch_user_wallet_limit_mode(
|
||||
state: &AdminAppState<'_>,
|
||||
user_id: &str,
|
||||
unlimited: bool,
|
||||
) -> Result<bool, GatewayError> {
|
||||
let desired_limit_mode = if unlimited { "unlimited" } else { "finite" };
|
||||
match state
|
||||
.find_wallet(aether_data::repository::wallet::WalletLookupKey::UserId(
|
||||
user_id,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
Some(wallet) => {
|
||||
if wallet.limit_mode.eq_ignore_ascii_case(desired_limit_mode) {
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(state
|
||||
.update_auth_user_wallet_limit_mode(user_id, desired_limit_mode)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
None => Ok(state
|
||||
.initialize_auth_user_wallet(user_id, 0.0, unlimited)
|
||||
.await?
|
||||
.is_some()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_admin_user_batch_bad_request_response(detail: String) -> Response<Body> {
|
||||
if detail.as_str() == "缺少 user_id" {
|
||||
return build_admin_users_bad_request_response("缺少 user_id");
|
||||
}
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -184,7 +184,7 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
|| field_presence.contains("allowed_providers")
|
||||
|| field_presence.contains("allowed_api_formats")
|
||||
|| field_presence.contains("allowed_models")
|
||||
|| payload.rate_limit.is_some()
|
||||
|| field_presence.contains("rate_limit")
|
||||
|| payload.is_active.is_some();
|
||||
if needs_auth_user_write && !state.has_auth_user_write_capability() {
|
||||
return Ok(build_admin_users_read_only_response(
|
||||
@@ -247,7 +247,7 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
|| field_presence.contains("allowed_providers")
|
||||
|| field_presence.contains("allowed_api_formats")
|
||||
|| field_presence.contains("allowed_models")
|
||||
|| payload.rate_limit.is_some()
|
||||
|| field_presence.contains("rate_limit")
|
||||
|| payload.is_active.is_some()
|
||||
{
|
||||
if state
|
||||
@@ -260,6 +260,7 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
allowed_api_formats,
|
||||
field_presence.contains("allowed_models"),
|
||||
allowed_models,
|
||||
field_presence.contains("rate_limit"),
|
||||
payload.rate_limit,
|
||||
payload.is_active,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::handlers::admin::request::{AdminRouteRequest, AdminRouteResult};
|
||||
const ADMIN_USERS_DATA_UNAVAILABLE_DETAIL: &str = "Admin user management data unavailable";
|
||||
|
||||
mod api_keys;
|
||||
mod batch;
|
||||
mod lifecycle;
|
||||
mod route_seam;
|
||||
mod routes;
|
||||
@@ -19,6 +20,9 @@ pub(crate) use self::api_keys::{
|
||||
generate_admin_user_api_key_plaintext, hash_admin_user_api_key, masked_user_api_key_display,
|
||||
normalize_admin_optional_api_key_name,
|
||||
};
|
||||
use self::batch::{
|
||||
build_admin_resolve_user_selection_response, build_admin_user_batch_action_response,
|
||||
};
|
||||
use self::lifecycle::{
|
||||
build_admin_create_user_response, build_admin_delete_user_response,
|
||||
build_admin_get_user_response, build_admin_list_users_response,
|
||||
|
||||
@@ -4,8 +4,9 @@ use super::{
|
||||
build_admin_delete_user_session_response, build_admin_delete_user_sessions_response,
|
||||
build_admin_get_user_response, build_admin_list_user_api_keys_response,
|
||||
build_admin_list_user_sessions_response, build_admin_list_users_response,
|
||||
build_admin_reveal_user_api_key_response, build_admin_toggle_user_api_key_lock_response,
|
||||
build_admin_update_user_api_key_response, build_admin_update_user_response,
|
||||
build_admin_resolve_user_selection_response, build_admin_reveal_user_api_key_response,
|
||||
build_admin_toggle_user_api_key_lock_response, build_admin_update_user_api_key_response,
|
||||
build_admin_update_user_response, build_admin_user_batch_action_response,
|
||||
build_admin_users_data_unavailable_response,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
@@ -18,6 +19,14 @@ fn is_admin_users_route(request_context: &AdminRequestContext<'_>) -> bool {
|
||||
&& matches!(path, "/api/admin/users" | "/api/admin/users/"))
|
||||
|| (request_context.method() == http::Method::POST
|
||||
&& matches!(path, "/api/admin/users" | "/api/admin/users/"))
|
||||
|| (request_context.method() == http::Method::POST
|
||||
&& matches!(
|
||||
path,
|
||||
"/api/admin/users/resolve-selection"
|
||||
| "/api/admin/users/resolve-selection/"
|
||||
| "/api/admin/users/batch-action"
|
||||
| "/api/admin/users/batch-action/"
|
||||
))
|
||||
|| ((request_context.method() == http::Method::GET
|
||||
|| request_context.method() == http::Method::PUT
|
||||
|| request_context.method() == http::Method::DELETE)
|
||||
@@ -81,6 +90,13 @@ pub(super) async fn maybe_build_local_admin_users_routes_response(
|
||||
Some("list_users") => Ok(Some(
|
||||
build_admin_list_users_response(state, request_context).await?,
|
||||
)),
|
||||
Some("resolve_user_selection") => Ok(Some(
|
||||
build_admin_resolve_user_selection_response(state, request_context, request_body)
|
||||
.await?,
|
||||
)),
|
||||
Some("batch_action_users") => Ok(Some(
|
||||
build_admin_user_batch_action_response(state, request_context, request_body).await?,
|
||||
)),
|
||||
Some("get_user") => Ok(Some(
|
||||
build_admin_get_user_response(state, request_context).await?,
|
||||
)),
|
||||
|
||||
@@ -320,6 +320,8 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
|
||||
| (Some("security_manage"), http::Method::POST, Some("blacklist_add"))
|
||||
| (Some("security_manage"), http::Method::POST, Some("whitelist_add"))
|
||||
| (Some("users_manage"), http::Method::POST, Some("create_user"))
|
||||
| (Some("users_manage"), http::Method::POST, Some("resolve_user_selection"))
|
||||
| (Some("users_manage"), http::Method::POST, Some("batch_action_users"))
|
||||
| (Some("users_manage"), http::Method::PUT, Some("update_user"))
|
||||
| (Some("users_manage"), http::Method::POST, Some("create_user_api_key"))
|
||||
| (Some("users_manage"), http::Method::PUT, Some("update_user_api_key"))
|
||||
|
||||
@@ -371,6 +371,7 @@ impl AppState {
|
||||
allowed_api_formats: Option<Vec<String>>,
|
||||
allowed_models_present: bool,
|
||||
allowed_models: Option<Vec<String>>,
|
||||
rate_limit_present: bool,
|
||||
rate_limit: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
|
||||
@@ -395,7 +396,7 @@ impl AppState {
|
||||
if let Some(is_active) = is_active {
|
||||
user.is_active = is_active;
|
||||
}
|
||||
let _ = rate_limit;
|
||||
let _ = (rate_limit_present, rate_limit);
|
||||
return Ok(Some(user.clone()));
|
||||
}
|
||||
|
||||
@@ -409,6 +410,7 @@ impl AppState {
|
||||
allowed_api_formats,
|
||||
allowed_models_present,
|
||||
allowed_models,
|
||||
rate_limit_present,
|
||||
rate_limit,
|
||||
is_active,
|
||||
)
|
||||
|
||||
@@ -361,6 +361,327 @@ async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal()
|
||||
create_gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_resolves_admin_user_batch_selection_locally() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().fallback(any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}
|
||||
}));
|
||||
|
||||
let user_repository = Arc::new(
|
||||
InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
sample_admin_user("user-1"),
|
||||
sample_admin_user_with_role("user-2", "admin", "root@example.com", "root"),
|
||||
sample_admin_user_with_role("user-3", "user", "carol@example.com", "carol"),
|
||||
])
|
||||
.with_export_users(vec![
|
||||
sample_admin_export_user("user-1"),
|
||||
sample_admin_export_user_with("admin", true, "user-2", "root@example.com", "root"),
|
||||
sample_admin_export_user_with("user", false, "user-3", "carol@example.com", "carol"),
|
||||
]),
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
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 response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/users/resolve-selection"))
|
||||
.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")
|
||||
.json(&json!({
|
||||
"filters": {
|
||||
"search": "ali",
|
||||
"role": "user",
|
||||
"is_active": true
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["total"], 1);
|
||||
let items = payload["items"].as_array().expect("items should be array");
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["user_id"], "user-1");
|
||||
assert_eq!(items[0]["username"], "alice");
|
||||
assert_eq!(items[0]["email"], "alice@example.com");
|
||||
assert_eq!(items[0]["role"], "user");
|
||||
assert_eq!(items[0]["is_active"], true);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
let all_filtered_response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/users/resolve-selection"))
|
||||
.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")
|
||||
.json(&json!({ "filters": {} }))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(all_filtered_response.status(), StatusCode::OK);
|
||||
let all_filtered_payload: serde_json::Value = all_filtered_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(all_filtered_payload["total"], 3);
|
||||
|
||||
let empty_selection_response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/users/resolve-selection"))
|
||||
.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")
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(empty_selection_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_user_batch_actions_locally() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().fallback(any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}
|
||||
}));
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_auth_users_for_tests([sample_admin_user("user-1")])
|
||||
.with_auth_wallets_for_tests([sample_admin_wallet("user-1", "unlimited")]),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let disable_response = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.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")
|
||||
.json(&json!({
|
||||
"selection": {
|
||||
"user_ids": ["user-1", "user-1", "missing-user"]
|
||||
},
|
||||
"action": "disable"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(disable_response.status(), StatusCode::OK);
|
||||
let disable_payload: serde_json::Value = disable_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(disable_payload["total"], 2);
|
||||
assert_eq!(disable_payload["success"], 1);
|
||||
assert_eq!(disable_payload["failed"], 1);
|
||||
assert_eq!(disable_payload["failures"][0]["user_id"], "missing-user");
|
||||
|
||||
let empty_selection_response = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.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")
|
||||
.json(&json!({
|
||||
"selection": {},
|
||||
"action": "disable"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(empty_selection_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let access_response = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.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")
|
||||
.json(&json!({
|
||||
"selection": {
|
||||
"user_ids": ["user-1"]
|
||||
},
|
||||
"action": "update_access_control",
|
||||
"payload": {
|
||||
"allowed_providers": null,
|
||||
"allowed_api_formats": ["OPENAI:RESPONSES"],
|
||||
"allowed_models": [],
|
||||
"rate_limit": 0,
|
||||
"unlimited": false
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(access_response.status(), StatusCode::OK);
|
||||
let access_payload: serde_json::Value = access_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(access_payload["total"], 1);
|
||||
assert_eq!(access_payload["success"], 1);
|
||||
assert_eq!(access_payload["failed"], 0);
|
||||
assert_eq!(
|
||||
access_payload["modified_fields"],
|
||||
json!([
|
||||
"allowed_providers",
|
||||
"allowed_api_formats",
|
||||
"allowed_models",
|
||||
"rate_limit",
|
||||
"unlimited"
|
||||
])
|
||||
);
|
||||
|
||||
let role_response = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.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")
|
||||
.json(&json!({
|
||||
"selection": {
|
||||
"user_ids": ["user-1"]
|
||||
},
|
||||
"action": "update_role",
|
||||
"payload": {
|
||||
"role": "admin"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(role_response.status(), StatusCode::OK);
|
||||
let role_payload: serde_json::Value =
|
||||
role_response.json().await.expect("json body should parse");
|
||||
assert_eq!(role_payload["total"], 1);
|
||||
assert_eq!(role_payload["success"], 1);
|
||||
assert_eq!(role_payload["modified_fields"], json!(["role"]));
|
||||
|
||||
let blank_role_response = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.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")
|
||||
.json(&json!({
|
||||
"selection": {
|
||||
"user_ids": ["user-1"]
|
||||
},
|
||||
"action": "update_role",
|
||||
"payload": {
|
||||
"role": ""
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(blank_role_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let enable_admin_response = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.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")
|
||||
.json(&json!({
|
||||
"selection": {
|
||||
"user_ids": ["user-1"]
|
||||
},
|
||||
"action": "enable"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(enable_admin_response.status(), StatusCode::OK);
|
||||
|
||||
let last_admin_demotion_response = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.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")
|
||||
.json(&json!({
|
||||
"selection": {
|
||||
"user_ids": ["user-1"]
|
||||
},
|
||||
"action": "update_role",
|
||||
"payload": {
|
||||
"role": "user"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(last_admin_demotion_response.status(), StatusCode::OK);
|
||||
let last_admin_demotion_payload: serde_json::Value = last_admin_demotion_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(last_admin_demotion_payload["success"], 0);
|
||||
assert_eq!(last_admin_demotion_payload["failed"], 1);
|
||||
assert_eq!(
|
||||
last_admin_demotion_payload["failures"][0]["reason"],
|
||||
"不能降级最后一个管理员账户"
|
||||
);
|
||||
|
||||
let detail_response = client
|
||||
.get(format!("{gateway_url}/api/admin/users/user-1"))
|
||||
.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!(detail_response.status(), StatusCode::OK);
|
||||
let detail_payload: serde_json::Value = detail_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(detail_payload["role"], "admin");
|
||||
assert_eq!(detail_payload["is_active"], true);
|
||||
assert_eq!(detail_payload["unlimited"], false);
|
||||
assert_eq!(detail_payload["allowed_providers"], serde_json::Value::Null);
|
||||
assert_eq!(
|
||||
detail_payload["allowed_api_formats"],
|
||||
json!(["openai:responses"])
|
||||
);
|
||||
assert_eq!(detail_payload["allowed_models"], json!([]));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_users_root_locally_with_bearer_admin_session() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
@@ -882,6 +882,7 @@ impl UserReadRepository for InMemoryUserReadRepository {
|
||||
allowed_api_formats: Option<Vec<String>>,
|
||||
allowed_models_present: bool,
|
||||
allowed_models: Option<Vec<String>>,
|
||||
rate_limit_present: bool,
|
||||
rate_limit: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
@@ -931,8 +932,8 @@ impl UserReadRepository for InMemoryUserReadRepository {
|
||||
row.allowed_providers = updated.allowed_providers.clone();
|
||||
row.allowed_api_formats = updated.allowed_api_formats.clone();
|
||||
row.allowed_models = updated.allowed_models.clone();
|
||||
if let Some(rate_limit) = rate_limit {
|
||||
row.rate_limit = Some(rate_limit);
|
||||
if rate_limit_present {
|
||||
row.rate_limit = rate_limit;
|
||||
}
|
||||
row.is_active = updated.is_active;
|
||||
}
|
||||
@@ -1518,7 +1519,24 @@ mod tests {
|
||||
None,
|
||||
)
|
||||
.expect("auth user should build");
|
||||
let repository = InMemoryUserReadRepository::seed_auth_users(vec![user]);
|
||||
let export_user = StoredUserExportRow::new(
|
||||
"user-1".to_string(),
|
||||
Some("alice@example.com".to_string()),
|
||||
true,
|
||||
"alice".to_string(),
|
||||
Some("old-hash".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(10),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("export user should build");
|
||||
let repository = InMemoryUserReadRepository::seed_auth_users(vec![user])
|
||||
.with_export_users([export_user]);
|
||||
|
||||
let updated = repository
|
||||
.update_local_auth_user_profile(
|
||||
@@ -1566,6 +1584,7 @@ mod tests {
|
||||
None,
|
||||
true,
|
||||
Some(vec!["gpt-4.1".to_string()]),
|
||||
true,
|
||||
Some(50),
|
||||
Some(false),
|
||||
)
|
||||
@@ -1583,6 +1602,31 @@ mod tests {
|
||||
Some(vec!["gpt-4.1".to_string()])
|
||||
);
|
||||
assert!(!admin_updated.is_active);
|
||||
assert_eq!(
|
||||
repository
|
||||
.find_export_user_by_id("user-1")
|
||||
.await
|
||||
.expect("export lookup should succeed")
|
||||
.expect("export row should exist")
|
||||
.rate_limit,
|
||||
Some(50)
|
||||
);
|
||||
repository
|
||||
.update_local_auth_user_admin_fields(
|
||||
"user-1", None, false, None, false, None, false, None, true, None, None,
|
||||
)
|
||||
.await
|
||||
.expect("rate limit clear should succeed")
|
||||
.expect("rate limit clear should return user");
|
||||
assert_eq!(
|
||||
repository
|
||||
.find_export_user_by_id("user-1")
|
||||
.await
|
||||
.expect("export lookup should succeed")
|
||||
.expect("export row should exist")
|
||||
.rate_limit,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
repository
|
||||
.update_user_model_capability_settings(
|
||||
|
||||
@@ -655,6 +655,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
allowed_api_formats: Option<Vec<String>>,
|
||||
allowed_models_present: bool,
|
||||
allowed_models: Option<Vec<String>>,
|
||||
rate_limit_present: bool,
|
||||
rate_limit: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
@@ -688,7 +689,7 @@ WHERE id = ?
|
||||
allowed_models,
|
||||
"users.allowed_models",
|
||||
)?)
|
||||
.bind(rate_limit.is_some())
|
||||
.bind(rate_limit_present)
|
||||
.bind(rate_limit)
|
||||
.bind(is_active.is_some())
|
||||
.bind(is_active)
|
||||
|
||||
@@ -1107,6 +1107,7 @@ WHERE id = $1
|
||||
allowed_api_formats: Option<Vec<String>>,
|
||||
allowed_models_present: bool,
|
||||
allowed_models: Option<Vec<String>>,
|
||||
rate_limit_present: bool,
|
||||
rate_limit: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
@@ -1130,7 +1131,7 @@ SET role = CASE
|
||||
ELSE allowed_models
|
||||
END,
|
||||
rate_limit = CASE
|
||||
WHEN $10::BOOLEAN AND $11 IS NOT NULL THEN $11
|
||||
WHEN $10::BOOLEAN THEN $11
|
||||
ELSE rate_limit
|
||||
END,
|
||||
is_active = CASE
|
||||
@@ -1150,7 +1151,7 @@ WHERE id = $1
|
||||
.bind(allowed_api_formats.map(serde_json::Value::from))
|
||||
.bind(allowed_models_present)
|
||||
.bind(allowed_models.map(serde_json::Value::from))
|
||||
.bind(rate_limit.is_some())
|
||||
.bind(rate_limit_present)
|
||||
.bind(rate_limit)
|
||||
.bind(is_active.is_some())
|
||||
.bind(is_active)
|
||||
@@ -1882,6 +1883,7 @@ impl UserReadRepository for SqlxUserReadRepository {
|
||||
allowed_api_formats: Option<Vec<String>>,
|
||||
allowed_models_present: bool,
|
||||
allowed_models: Option<Vec<String>>,
|
||||
rate_limit_present: bool,
|
||||
rate_limit: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
@@ -1894,6 +1896,7 @@ impl UserReadRepository for SqlxUserReadRepository {
|
||||
allowed_api_formats,
|
||||
allowed_models_present,
|
||||
allowed_models,
|
||||
rate_limit_present,
|
||||
rate_limit,
|
||||
is_active,
|
||||
)
|
||||
|
||||
@@ -655,6 +655,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
allowed_api_formats: Option<Vec<String>>,
|
||||
allowed_models_present: bool,
|
||||
allowed_models: Option<Vec<String>>,
|
||||
rate_limit_present: bool,
|
||||
rate_limit: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
@@ -688,7 +689,7 @@ WHERE id = ?
|
||||
allowed_models,
|
||||
"users.allowed_models",
|
||||
)?)
|
||||
.bind(rate_limit.is_some())
|
||||
.bind(rate_limit_present)
|
||||
.bind(rate_limit)
|
||||
.bind(is_active.is_some())
|
||||
.bind(is_active)
|
||||
@@ -1638,6 +1639,7 @@ INSERT INTO users (
|
||||
Some(vec!["responses".to_string()]),
|
||||
true,
|
||||
Some(vec!["gpt-4.1-mini".to_string()]),
|
||||
true,
|
||||
Some(5),
|
||||
Some(false),
|
||||
)
|
||||
|
||||
@@ -596,6 +596,7 @@ pub trait UserReadRepository: Send + Sync {
|
||||
allowed_api_formats: Option<Vec<String>>,
|
||||
allowed_models_present: bool,
|
||||
allowed_models: Option<Vec<String>>,
|
||||
rate_limit_present: bool,
|
||||
rate_limit: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
|
||||
|
||||
@@ -2,11 +2,13 @@ import apiClient from './client'
|
||||
import { cachedRequest } from '@/utils/cache'
|
||||
import type { UserSession as SessionRecord } from '@/types/session'
|
||||
|
||||
export type UserRole = 'admin' | 'user'
|
||||
|
||||
export interface User {
|
||||
id: string // UUID
|
||||
username: string
|
||||
email: string
|
||||
role: 'admin' | 'user'
|
||||
role: UserRole
|
||||
is_active: boolean
|
||||
unlimited: boolean
|
||||
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
|
||||
@@ -24,7 +26,7 @@ export interface CreateUserRequest {
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
role?: 'admin' | 'user'
|
||||
role?: UserRole
|
||||
initial_gift_usd?: number | null
|
||||
unlimited?: boolean
|
||||
allowed_providers?: string[] | null
|
||||
@@ -36,7 +38,7 @@ export interface CreateUserRequest {
|
||||
export interface UpdateUserRequest {
|
||||
email?: string
|
||||
is_active?: boolean
|
||||
role?: 'admin' | 'user'
|
||||
role?: UserRole
|
||||
unlimited?: boolean
|
||||
password?: string
|
||||
allowed_providers?: string[] | null
|
||||
@@ -45,6 +47,83 @@ export interface UpdateUserRequest {
|
||||
rate_limit?: number | null
|
||||
}
|
||||
|
||||
export interface UserBatchSelectionFilters {
|
||||
search?: string
|
||||
role?: UserRole
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface UserBatchSelection {
|
||||
user_ids?: string[]
|
||||
filters?: UserBatchSelectionFilters | null
|
||||
}
|
||||
|
||||
export interface UserBatchSelectionItem {
|
||||
user_id: string
|
||||
username: string
|
||||
email?: string | null
|
||||
role: UserRole
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ResolveUserBatchSelectionResponse {
|
||||
total: number
|
||||
items: UserBatchSelectionItem[]
|
||||
}
|
||||
|
||||
export interface UserBatchAccessControlPayload {
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number | null
|
||||
unlimited?: boolean
|
||||
}
|
||||
|
||||
export interface UserBatchRolePayload {
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role'
|
||||
|
||||
export type UserBatchActionPayload = UserBatchAccessControlPayload | UserBatchRolePayload
|
||||
|
||||
export interface UserBatchToggleActionRequest {
|
||||
selection: UserBatchSelection
|
||||
action: 'enable' | 'disable'
|
||||
payload?: null
|
||||
}
|
||||
|
||||
export interface UserBatchAccessControlActionRequest {
|
||||
selection: UserBatchSelection
|
||||
action: 'update_access_control'
|
||||
payload: UserBatchAccessControlPayload
|
||||
}
|
||||
|
||||
export interface UserBatchRoleActionRequest {
|
||||
selection: UserBatchSelection
|
||||
action: 'update_role'
|
||||
payload: UserBatchRolePayload
|
||||
}
|
||||
|
||||
export type UserBatchActionRequest =
|
||||
| UserBatchToggleActionRequest
|
||||
| UserBatchAccessControlActionRequest
|
||||
| UserBatchRoleActionRequest
|
||||
|
||||
export interface UserBatchActionFailure {
|
||||
user_id: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface UserBatchActionResponse {
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
failures: UserBatchActionFailure[]
|
||||
action?: string
|
||||
modified_fields?: string[]
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string // UUID
|
||||
key?: string // 完整的 key,只在创建时返回
|
||||
@@ -98,6 +177,24 @@ export const usersApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async resolveBatchSelection(
|
||||
selection: UserBatchSelection
|
||||
): Promise<ResolveUserBatchSelectionResponse> {
|
||||
const response = await apiClient.post<ResolveUserBatchSelectionResponse>(
|
||||
'/api/admin/users/resolve-selection',
|
||||
selection
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async batchAction(request: UserBatchActionRequest): Promise<UserBatchActionResponse> {
|
||||
const response = await apiClient.post<UserBatchActionResponse>(
|
||||
'/api/admin/users/batch-action',
|
||||
request
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/admin/users/${userId}`)
|
||||
},
|
||||
@@ -113,12 +210,12 @@ export const usersApi = {
|
||||
},
|
||||
|
||||
async revokeUserSession(userId: string, sessionId: string): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete(`/api/admin/users/${userId}/sessions/${sessionId}`)
|
||||
const response = await apiClient.delete<{ message: string }>(`/api/admin/users/${userId}/sessions/${sessionId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async revokeAllUserSessions(userId: string): Promise<{ message: string; revoked_count: number }> {
|
||||
const response = await apiClient.delete(`/api/admin/users/${userId}/sessions`)
|
||||
const response = await apiClient.delete<{ message: string; revoked_count: number }>(`/api/admin/users/${userId}/sessions`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -157,7 +254,7 @@ export const usersApi = {
|
||||
},
|
||||
// 管理员统计
|
||||
async getUsageStats(): Promise<Record<string, unknown>> {
|
||||
const response = await apiClient.get('/api/admin/usage/stats')
|
||||
const response = await apiClient.get<Record<string, unknown>>('/api/admin/usage/stats')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
89
frontend/src/composables/useBatchSelection.ts
Normal file
89
frontend/src/composables/useBatchSelection.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
|
||||
export function useBatchSelection<TItem>(options: {
|
||||
pageItems: Ref<TItem[]>
|
||||
filteredTotal: Ref<number>
|
||||
getItemId: (item: TItem) => string
|
||||
}) {
|
||||
const selectedIds = ref<string[]>([])
|
||||
const selectAllFiltered = ref(false)
|
||||
const knownItemsById = ref<Record<string, TItem>>({})
|
||||
|
||||
const selectedIdSet = computed(() => new Set(selectedIds.value))
|
||||
const selectedCount = computed(() => (
|
||||
selectAllFiltered.value ? options.filteredTotal.value : selectedIds.value.length
|
||||
))
|
||||
const isAllFilteredSelected = computed(() => (
|
||||
selectAllFiltered.value && options.filteredTotal.value > 0
|
||||
))
|
||||
const isPartiallyFilteredSelected = computed(() => (
|
||||
!selectAllFiltered.value && selectedIds.value.length > 0
|
||||
))
|
||||
const isCurrentPageFullySelected = computed(() => {
|
||||
const pageIds = options.pageItems.value.map(options.getItemId)
|
||||
return pageIds.length > 0 && pageIds.every((id) => selectedIdSet.value.has(id))
|
||||
})
|
||||
const canClearSelection = computed(() => selectAllFiltered.value || selectedIds.value.length > 0)
|
||||
|
||||
function rememberItems(items: TItem[]): void {
|
||||
if (items.length === 0) return
|
||||
const next = { ...knownItemsById.value }
|
||||
for (const item of items) {
|
||||
next[options.getItemId(item)] = item
|
||||
}
|
||||
knownItemsById.value = next
|
||||
}
|
||||
|
||||
function resetSelection(clearKnown = false): void {
|
||||
selectAllFiltered.value = false
|
||||
selectedIds.value = []
|
||||
if (clearKnown) knownItemsById.value = {}
|
||||
}
|
||||
|
||||
function toggleOne(id: string, checked: boolean): void {
|
||||
if (selectAllFiltered.value) return
|
||||
const set = new Set(selectedIds.value)
|
||||
if (checked) set.add(id)
|
||||
else set.delete(id)
|
||||
selectedIds.value = [...set]
|
||||
}
|
||||
|
||||
function toggleSelectFiltered(checked: boolean | 'indeterminate'): void {
|
||||
selectAllFiltered.value = checked === true
|
||||
if (selectAllFiltered.value) selectedIds.value = []
|
||||
}
|
||||
|
||||
function toggleSelectCurrentPage(): void {
|
||||
if (selectAllFiltered.value || options.pageItems.value.length === 0) return
|
||||
const set = new Set(selectedIds.value)
|
||||
const pageIds = options.pageItems.value.map(options.getItemId)
|
||||
const shouldUnselect = pageIds.every((id) => set.has(id))
|
||||
for (const id of pageIds) {
|
||||
if (shouldUnselect) set.delete(id)
|
||||
else set.add(id)
|
||||
}
|
||||
selectedIds.value = [...set]
|
||||
}
|
||||
|
||||
function clearSelection(): void {
|
||||
resetSelection()
|
||||
}
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
selectAllFiltered,
|
||||
knownItemsById,
|
||||
selectedIdSet,
|
||||
selectedCount,
|
||||
isAllFilteredSelected,
|
||||
isPartiallyFilteredSelected,
|
||||
isCurrentPageFullySelected,
|
||||
canClearSelection,
|
||||
rememberItems,
|
||||
resetSelection,
|
||||
toggleOne,
|
||||
toggleSelectFiltered,
|
||||
toggleSelectCurrentPage,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,7 @@ import {
|
||||
getOAuthStatusTitle,
|
||||
} from '@/utils/providerKeyStatus'
|
||||
import { getQuotaDisplayText } from '@/utils/providerKeyQuota'
|
||||
import { runChunkedBatchAction } from '@/utils/batchAction'
|
||||
|
||||
type QuickSelectorValue =
|
||||
| 'banned'
|
||||
@@ -839,24 +840,20 @@ async function executeAction(actionOverride?: BatchActionValue): Promise<void> {
|
||||
if (selectedAction.value === 'refresh_quota') {
|
||||
const targetIds = selectedKeys.map((key) => key.key_id)
|
||||
const BATCH_SIZE = 20
|
||||
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
|
||||
|
||||
for (let i = 0; i < targetIds.length; i += BATCH_SIZE) {
|
||||
const batchIndex = Math.floor(i / BATCH_SIZE) + 1
|
||||
const batch = targetIds.slice(i, i + BATCH_SIZE)
|
||||
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
|
||||
|
||||
try {
|
||||
const result = await refreshProviderQuota(props.providerId, batch)
|
||||
successCount += Number(result.success || 0)
|
||||
failedCount += Number(result.failed || 0)
|
||||
skippedCount += Math.max(0, batch.length - Number(result.total || 0))
|
||||
} catch {
|
||||
failedCount += batch.length
|
||||
}
|
||||
|
||||
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
||||
}
|
||||
const counts = await runChunkedBatchAction({
|
||||
items: targetIds,
|
||||
chunkSize: BATCH_SIZE,
|
||||
runChunk: (batch) => refreshProviderQuota(props.providerId, batch),
|
||||
onChunkStart: ({ batchIndex, totalBatches }) => {
|
||||
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
|
||||
},
|
||||
onChunkDone: ({ processed }) => {
|
||||
progressDone.value = processed
|
||||
},
|
||||
})
|
||||
successCount += counts.success
|
||||
failedCount += counts.failed
|
||||
skippedCount += counts.skipped
|
||||
} else if (selectedAction.value === 'export') {
|
||||
const exportableKeys = selectedKeys.filter((key) => canExportOAuthCredential(key))
|
||||
const exportedEntries: Array<Record<string, unknown> | null> = Array.from({ length: exportableKeys.length }, () => null)
|
||||
|
||||
568
frontend/src/features/users/components/UserBatchActionDialog.vue
Normal file
568
frontend/src/features/users/components/UserBatchActionDialog.vue
Normal file
@@ -0,0 +1,568 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
title="用户批量操作"
|
||||
description="按当前选择批量调整用户状态、角色、访问控制和额度"
|
||||
size="2xl"
|
||||
persistent
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-2xl border border-primary/15 bg-gradient-to-br from-primary/10 via-background to-muted/40 p-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<UsersRound class="h-4 w-4 text-primary" />
|
||||
<span>影响用户:{{ impactCount }} 个</span>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
{{ selectAllFiltered ? '目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。' : '目标为当前已勾选的用户,重复 ID 会自动去重。' }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" class="shrink-0">
|
||||
{{ selectAllFiltered ? '全选筛选结果' : '手动选择' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="previewLoading"
|
||||
class="mt-3 rounded-xl border border-border/60 bg-background/65 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
正在解析影响范围...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="previewItems.length > 0"
|
||||
class="mt-3 flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<Badge
|
||||
v-for="item in previewItems"
|
||||
:key="item.user_id"
|
||||
variant="outline"
|
||||
class="bg-background/70 text-[11px]"
|
||||
>
|
||||
{{ item.username }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="impactCount > previewItems.length"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
等 {{ impactCount }} 个用户
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">选择批量动作</Label>
|
||||
<span class="text-[11px] text-muted-foreground">只会提交当前动作对应的字段</span>
|
||||
</div>
|
||||
<div class="grid gap-2 md:grid-cols-4">
|
||||
<button
|
||||
v-for="action in actionOptions"
|
||||
:key="action.value"
|
||||
type="button"
|
||||
:class="actionCardClass(action.value)"
|
||||
@click="selectedAction = action.value"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<span :class="actionIconClass(action.value)">
|
||||
<component :is="action.icon" class="h-4 w-4" />
|
||||
</span>
|
||||
<span class="font-medium text-foreground">{{ action.label }}</span>
|
||||
</span>
|
||||
<span class="mt-1 block text-[11px] leading-relaxed text-muted-foreground">
|
||||
{{ action.description }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedAction === 'update_role'"
|
||||
class="space-y-4 rounded-2xl border bg-background p-4 shadow-sm"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<UserCog class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<h4 class="text-sm font-semibold text-foreground">批量修改用户角色</h4>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
将所选用户统一调整为同一个角色。管理员角色拥有后台管理权限,请确认选择范围。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-xl border border-border/70 bg-muted/25 p-3 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">目标角色</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">对所有目标用户生效</p>
|
||||
</div>
|
||||
<Select v-model="targetRole">
|
||||
<SelectTrigger class="h-10 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
<SelectItem value="admin">管理员</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-amber-200/70 bg-amber-50/70 px-3 py-2.5 text-xs leading-relaxed text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{{ targetRole === 'admin' ? '提示:设置为管理员会授予用户后台管理能力。' : '提示:设置为普通用户会移除目标用户的管理员权限。' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedAction === 'update_access_control'"
|
||||
class="space-y-4 rounded-2xl border bg-background p-4 shadow-sm"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<h4 class="text-sm font-semibold text-foreground">批量设置访问控制与额度</h4>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
每个字段都可独立选择“不修改 / 不限制 / 指定列表”。指定列表为空表示全部禁用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div class="grid gap-3 lg:grid-cols-[9rem_minmax(0,1fr)] lg:items-start">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">允许的提供商</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">控制可使用的供应商</p>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||
<Select v-model="providerMode">
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">不修改</SelectItem>
|
||||
<SelectItem value="unrestricted">不限制</SelectItem>
|
||||
<SelectItem value="specific">指定列表</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<MultiSelect
|
||||
v-model="allowedProviders"
|
||||
:options="providerOptions"
|
||||
:disabled="providerMode !== 'specific'"
|
||||
:search-threshold="0"
|
||||
placeholder="未选择时表示全部禁用"
|
||||
empty-text="暂无可用提供商"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div class="grid gap-3 lg:grid-cols-[9rem_minmax(0,1fr)] lg:items-start">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">允许的端点</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">控制 API 格式入口</p>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||
<Select v-model="apiFormatMode">
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">不修改</SelectItem>
|
||||
<SelectItem value="unrestricted">不限制</SelectItem>
|
||||
<SelectItem value="specific">指定列表</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<MultiSelect
|
||||
v-model="allowedApiFormats"
|
||||
:options="apiFormatOptions"
|
||||
:disabled="apiFormatMode !== 'specific'"
|
||||
:search-threshold="0"
|
||||
placeholder="未选择时表示全部禁用"
|
||||
empty-text="暂无可用端点"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div class="grid gap-3 lg:grid-cols-[9rem_minmax(0,1fr)] lg:items-start">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">允许的模型</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">控制模型白名单</p>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||
<Select v-model="modelMode">
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">不修改</SelectItem>
|
||||
<SelectItem value="unrestricted">不限制</SelectItem>
|
||||
<SelectItem value="specific">指定列表</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<MultiSelect
|
||||
v-model="allowedModels"
|
||||
:options="modelOptions"
|
||||
:disabled="modelMode !== 'specific'"
|
||||
:search-threshold="0"
|
||||
placeholder="未选择时表示全部禁用"
|
||||
empty-text="暂无可用模型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">速率限制</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">请求/分钟,0 表示不限速</p>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)] md:grid-cols-1 xl:grid-cols-[9rem_minmax(0,1fr)]">
|
||||
<Select v-model="rateLimitMode">
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">不修改</SelectItem>
|
||||
<SelectItem value="inherit">跟随默认</SelectItem>
|
||||
<SelectItem value="custom">指定数值</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
:model-value="rateLimit ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
class="h-9"
|
||||
:disabled="rateLimitMode !== 'custom'"
|
||||
placeholder="0 = 不限速"
|
||||
@update:model-value="(value) => rateLimit = parseNumberInput(value, { min: 0, max: 10000 })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">额度</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">与单用户编辑保持一致</p>
|
||||
</div>
|
||||
<Select v-model="quotaMode">
|
||||
<SelectTrigger class="h-9 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">不修改</SelectItem>
|
||||
<SelectItem value="wallet">按钱包余额限制</SelectItem>
|
||||
<SelectItem value="unlimited">无限额度</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="lastResult"
|
||||
class="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
成功 {{ lastResult.success }} 个,失败 {{ lastResult.failed }} 个
|
||||
<span v-if="lastResult.failures.length > 0">
|
||||
:{{ lastResult.failures.slice(0, 3).map((item) => `${item.user_id} ${item.reason}`).join(';') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="executing"
|
||||
@click="emit('close')"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!canExecute"
|
||||
@click="executeBatchAction"
|
||||
>
|
||||
{{ executing ? '执行中...' : executeButtonLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, type Component } from 'vue'
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
ShieldCheck,
|
||||
UserCog,
|
||||
UsersRound,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog,
|
||||
Button,
|
||||
Badge,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserAccessControlOptions } from '@/features/users/composables/useUserAccessControlOptions'
|
||||
import type {
|
||||
UserBatchAccessControlPayload,
|
||||
UserBatchAction,
|
||||
UserBatchActionRequest,
|
||||
UserBatchActionResponse,
|
||||
UserBatchRolePayload,
|
||||
UserBatchSelection,
|
||||
UserBatchSelectionFilters,
|
||||
UserBatchSelectionItem,
|
||||
UserRole,
|
||||
} from '@/api/users'
|
||||
|
||||
type AccessFieldMode = 'skip' | 'unrestricted' | 'specific'
|
||||
type RateLimitMode = 'skip' | 'inherit' | 'custom'
|
||||
type QuotaMode = 'skip' | 'wallet' | 'unlimited'
|
||||
|
||||
interface ActionOption {
|
||||
value: UserBatchAction
|
||||
label: string
|
||||
description: string
|
||||
icon: Component
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
selectedIds: string[]
|
||||
selectAllFiltered: boolean
|
||||
selectedCount: number
|
||||
filters: UserBatchSelectionFilters
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
completed: [result: UserBatchActionResponse]
|
||||
}>()
|
||||
|
||||
const usersStore = useUsersStore()
|
||||
const { success, warning, error } = useToast()
|
||||
const {
|
||||
providerOptions,
|
||||
apiFormatOptions,
|
||||
modelOptions,
|
||||
loadAccessControlOptions,
|
||||
} = useUserAccessControlOptions()
|
||||
|
||||
const actionOptions: ActionOption[] = [
|
||||
{
|
||||
value: 'enable',
|
||||
label: '启用',
|
||||
description: '恢复用户登录与调用',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
value: 'disable',
|
||||
label: '禁用',
|
||||
description: '暂停用户访问权限',
|
||||
icon: Ban,
|
||||
},
|
||||
{
|
||||
value: 'update_access_control',
|
||||
label: '访问控制',
|
||||
description: '提供商、端点、模型、限速和额度',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
value: 'update_role',
|
||||
label: '修改角色',
|
||||
description: '批量设为普通用户或管理员',
|
||||
icon: UserCog,
|
||||
},
|
||||
]
|
||||
|
||||
const selectedAction = ref<UserBatchAction>('enable')
|
||||
const targetRole = ref<UserRole>('user')
|
||||
const providerMode = ref<AccessFieldMode>('skip')
|
||||
const apiFormatMode = ref<AccessFieldMode>('skip')
|
||||
const modelMode = ref<AccessFieldMode>('skip')
|
||||
const rateLimitMode = ref<RateLimitMode>('skip')
|
||||
const quotaMode = ref<QuotaMode>('skip')
|
||||
const allowedProviders = ref<string[]>([])
|
||||
const allowedApiFormats = ref<string[]>([])
|
||||
const allowedModels = ref<string[]>([])
|
||||
const rateLimit = ref<number | undefined>(undefined)
|
||||
const previewLoading = ref(false)
|
||||
const previewItems = ref<UserBatchSelectionItem[]>([])
|
||||
const resolvedTotal = ref<number | null>(null)
|
||||
const executing = ref(false)
|
||||
const lastResult = ref<UserBatchActionResponse | null>(null)
|
||||
|
||||
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
|
||||
const canExecute = computed(() => props.selectedCount > 0 && !previewLoading.value && !executing.value)
|
||||
const selectedActionLabel = computed(() => (
|
||||
actionOptions.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
|
||||
))
|
||||
const executeButtonLabel = computed(() => `确认${selectedActionLabel.value}(${impactCount.value})`)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
resetLocalState()
|
||||
void loadAccessControlOptions().catch((err) => {
|
||||
error(parseApiError(err, '加载访问控制选项失败'))
|
||||
})
|
||||
void resolvePreview()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.selectedIds, props.selectAllFiltered, props.selectedCount, props.filters] as const,
|
||||
() => {
|
||||
if (props.open) void resolvePreview()
|
||||
},
|
||||
)
|
||||
|
||||
function handleDialogUpdate(value: boolean): void {
|
||||
if (!value) emit('close')
|
||||
}
|
||||
|
||||
function resetLocalState(): void {
|
||||
selectedAction.value = 'enable'
|
||||
targetRole.value = 'user'
|
||||
providerMode.value = 'skip'
|
||||
apiFormatMode.value = 'skip'
|
||||
modelMode.value = 'skip'
|
||||
rateLimitMode.value = 'skip'
|
||||
quotaMode.value = 'skip'
|
||||
allowedProviders.value = []
|
||||
allowedApiFormats.value = []
|
||||
allowedModels.value = []
|
||||
rateLimit.value = undefined
|
||||
lastResult.value = null
|
||||
}
|
||||
|
||||
function actionCardClass(action: UserBatchAction): string {
|
||||
return cn(
|
||||
'rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 hover:border-primary/35 hover:bg-primary/5 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/30',
|
||||
selectedAction.value === action
|
||||
? 'border-primary/60 bg-primary/10 shadow-sm ring-1 ring-primary/20'
|
||||
: 'border-border/70 bg-background',
|
||||
)
|
||||
}
|
||||
|
||||
function actionIconClass(action: UserBatchAction): string {
|
||||
return cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-lg transition-colors',
|
||||
selectedAction.value === action
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)
|
||||
}
|
||||
|
||||
function buildSelection(): UserBatchSelection {
|
||||
if (props.selectAllFiltered) {
|
||||
return { filters: props.filters }
|
||||
}
|
||||
return { user_ids: [...props.selectedIds] }
|
||||
}
|
||||
|
||||
async function resolvePreview(): Promise<void> {
|
||||
if (props.selectedCount === 0) {
|
||||
resolvedTotal.value = 0
|
||||
previewItems.value = []
|
||||
return
|
||||
}
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const result = await usersStore.resolveBatchSelection(buildSelection())
|
||||
resolvedTotal.value = result.total
|
||||
previewItems.value = result.items.slice(0, 6)
|
||||
} catch (err) {
|
||||
resolvedTotal.value = props.selectedCount
|
||||
previewItems.value = []
|
||||
error(parseApiError(err, '解析用户选择失败'))
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildAccessControlPayload(): UserBatchAccessControlPayload | null {
|
||||
const payload: UserBatchAccessControlPayload = {}
|
||||
if (providerMode.value === 'unrestricted') payload.allowed_providers = null
|
||||
if (providerMode.value === 'specific') payload.allowed_providers = [...allowedProviders.value]
|
||||
if (apiFormatMode.value === 'unrestricted') payload.allowed_api_formats = null
|
||||
if (apiFormatMode.value === 'specific') payload.allowed_api_formats = [...allowedApiFormats.value]
|
||||
if (modelMode.value === 'unrestricted') payload.allowed_models = null
|
||||
if (modelMode.value === 'specific') payload.allowed_models = [...allowedModels.value]
|
||||
if (rateLimitMode.value === 'inherit') payload.rate_limit = null
|
||||
if (rateLimitMode.value === 'custom' && rateLimit.value != null) payload.rate_limit = rateLimit.value
|
||||
if (quotaMode.value === 'wallet') payload.unlimited = false
|
||||
if (quotaMode.value === 'unlimited') payload.unlimited = true
|
||||
return Object.keys(payload).length > 0 ? payload : null
|
||||
}
|
||||
|
||||
function buildRolePayload(): UserBatchRolePayload {
|
||||
return { role: targetRole.value }
|
||||
}
|
||||
|
||||
async function executeBatchAction(): Promise<void> {
|
||||
if (!canExecute.value) return
|
||||
if (selectedAction.value === 'update_access_control' && rateLimitMode.value === 'custom' && rateLimit.value == null) {
|
||||
warning('请输入速率限制数值,0 表示不限速')
|
||||
return
|
||||
}
|
||||
const selection = buildSelection()
|
||||
let request: UserBatchActionRequest
|
||||
if (selectedAction.value === 'update_access_control') {
|
||||
const payload = buildAccessControlPayload()
|
||||
if (payload === null) {
|
||||
warning('请至少选择一个要修改的访问控制或额度字段')
|
||||
return
|
||||
}
|
||||
request = { selection, action: 'update_access_control', payload }
|
||||
} else if (selectedAction.value === 'update_role') {
|
||||
request = { selection, action: 'update_role', payload: buildRolePayload() }
|
||||
} else {
|
||||
request = { selection, action: selectedAction.value }
|
||||
}
|
||||
|
||||
executing.value = true
|
||||
try {
|
||||
const result = await usersStore.batchAction(request)
|
||||
lastResult.value = result
|
||||
const message = `批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`
|
||||
if (result.failed > 0) {
|
||||
warning(message)
|
||||
} else {
|
||||
success(message)
|
||||
}
|
||||
emit('completed', result)
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '批量操作失败'))
|
||||
} finally {
|
||||
executing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -355,11 +355,10 @@ import {
|
||||
import { UserPlus, SquarePen } from 'lucide-vue-next'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getGlobalModels } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { useUserAccessControlOptions } from '@/features/users/composables/useUserAccessControlOptions'
|
||||
import {
|
||||
getPasswordPolicyHint,
|
||||
getPasswordPolicyPlaceholder,
|
||||
@@ -367,10 +366,6 @@ import {
|
||||
validatePasswordByPolicy,
|
||||
type PasswordPolicyLevel,
|
||||
} from '@/utils/passwordPolicy'
|
||||
import type {
|
||||
ProviderWithEndpointsSummary,
|
||||
GlobalModelResponse,
|
||||
} from '@/api/endpoints/types'
|
||||
|
||||
export interface UserFormData {
|
||||
id?: string
|
||||
@@ -401,29 +396,12 @@ const saving = ref(false)
|
||||
const formNonce = ref(createFieldNonce())
|
||||
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
|
||||
|
||||
// 选项数据
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
||||
|
||||
const providerOptions = computed(() =>
|
||||
providers.value.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.name,
|
||||
})),
|
||||
)
|
||||
const apiFormatOptions = computed(() =>
|
||||
apiFormats.value.map((format) => ({
|
||||
value: format.value,
|
||||
label: format.label,
|
||||
})),
|
||||
)
|
||||
const modelOptions = computed(() =>
|
||||
globalModels.value.map((model) => ({
|
||||
value: model.name,
|
||||
label: model.name,
|
||||
})),
|
||||
)
|
||||
const {
|
||||
providerOptions,
|
||||
apiFormatOptions,
|
||||
modelOptions,
|
||||
loadAccessControlOptions: loadAccessControlOptionLists,
|
||||
} = useUserAccessControlOptions()
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
@@ -547,15 +525,10 @@ const isFormValid = computed(() => {
|
||||
// 加载访问控制选项
|
||||
async function loadAccessControlOptions(): Promise<void> {
|
||||
try {
|
||||
const [providersResponse, modelsData, formatsData, passwordPolicyResponse] = await Promise.all([
|
||||
getProvidersSummary({ page_size: 9999 }),
|
||||
getGlobalModels({ limit: 1000, is_active: true }),
|
||||
adminApi.getApiFormats(),
|
||||
const [, passwordPolicyResponse] = await Promise.all([
|
||||
loadAccessControlOptionLists(),
|
||||
adminApi.getSystemConfig('password_policy_level').catch(() => ({ value: 'weak' })),
|
||||
])
|
||||
providers.value = providersResponse.items
|
||||
globalModels.value = modelsData.models || []
|
||||
apiFormats.value = formatsData.formats || []
|
||||
passwordPolicyLevel.value = normalizePasswordPolicyLevel(passwordPolicyResponse.value)
|
||||
} catch (err) {
|
||||
log.error('加载访问限制选项失败:', err)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getGlobalModels } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints/types'
|
||||
import type { GlobalModelResponse } from '@/api/global-models'
|
||||
|
||||
export function useUserAccessControlOptions() {
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
||||
|
||||
const providerOptions = computed(() =>
|
||||
providers.value.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.name,
|
||||
})),
|
||||
)
|
||||
const apiFormatOptions = computed(() =>
|
||||
apiFormats.value.map((format) => ({
|
||||
value: format.value,
|
||||
label: format.label,
|
||||
})),
|
||||
)
|
||||
const modelOptions = computed(() =>
|
||||
globalModels.value.map((model) => ({
|
||||
value: model.name,
|
||||
label: model.name,
|
||||
})),
|
||||
)
|
||||
|
||||
async function loadAccessControlOptions(): Promise<void> {
|
||||
const [providersResponse, modelsData, formatsData] = await Promise.all([
|
||||
getProvidersSummary({ page_size: 9999 }),
|
||||
getGlobalModels({ limit: 1000, is_active: true }),
|
||||
adminApi.getApiFormats(),
|
||||
])
|
||||
providers.value = providersResponse.items
|
||||
globalModels.value = modelsData.models || []
|
||||
apiFormats.value = formatsData.formats || []
|
||||
}
|
||||
|
||||
return {
|
||||
providers,
|
||||
globalModels,
|
||||
apiFormats,
|
||||
providerOptions,
|
||||
apiFormatOptions,
|
||||
modelOptions,
|
||||
loadAccessControlOptions,
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
type ApiKey,
|
||||
type UpsertUserApiKeyRequest,
|
||||
type UserSession,
|
||||
type UserBatchSelection,
|
||||
type ResolveUserBatchSelectionResponse,
|
||||
type UserBatchActionRequest,
|
||||
type UserBatchActionResponse,
|
||||
} from '@/api/users'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
@@ -83,6 +87,30 @@ export const useUsersStore = defineStore('users', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBatchSelection(
|
||||
selection: UserBatchSelection
|
||||
): Promise<ResolveUserBatchSelectionResponse> {
|
||||
try {
|
||||
return await usersApi.resolveBatchSelection(selection)
|
||||
} catch (err: unknown) {
|
||||
error.value = parseApiError(err, '解析用户选择失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function batchAction(request: UserBatchActionRequest): Promise<UserBatchActionResponse> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await usersApi.batchAction(request)
|
||||
} catch (err: unknown) {
|
||||
error.value = parseApiError(err, '批量操作用户失败')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserApiKeys(userId: string): Promise<ApiKey[]> {
|
||||
try {
|
||||
return await usersApi.getUserApiKeys(userId)
|
||||
@@ -169,6 +197,8 @@ export const useUsersStore = defineStore('users', () => {
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
resolveBatchSelection,
|
||||
batchAction,
|
||||
getUserApiKeys,
|
||||
createApiKey,
|
||||
updateApiKey,
|
||||
|
||||
24
frontend/src/utils/__tests__/batchAction.spec.ts
Normal file
24
frontend/src/utils/__tests__/batchAction.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runChunkedBatchAction } from '../batchAction'
|
||||
|
||||
describe('runChunkedBatchAction', () => {
|
||||
it('counts unreported items as skipped when chunk total is omitted', async () => {
|
||||
const counts = await runChunkedBatchAction({
|
||||
items: ['a', 'b', 'c'],
|
||||
chunkSize: 3,
|
||||
runChunk: async () => ({ success: 1, failed: 1 }),
|
||||
})
|
||||
|
||||
expect(counts).toEqual({ success: 1, failed: 1, skipped: 1 })
|
||||
})
|
||||
|
||||
it('keeps legacy total-based skipped fallback when chunk total is reported', async () => {
|
||||
const counts = await runChunkedBatchAction({
|
||||
items: ['a', 'b', 'c'],
|
||||
chunkSize: 3,
|
||||
runChunk: async () => ({ total: 2, success: 1, failed: 0 }),
|
||||
})
|
||||
|
||||
expect(counts).toEqual({ success: 1, failed: 0, skipped: 1 })
|
||||
})
|
||||
})
|
||||
61
frontend/src/utils/batchAction.ts
Normal file
61
frontend/src/utils/batchAction.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export interface BatchChunkCounts {
|
||||
total?: number
|
||||
success?: number
|
||||
failed?: number
|
||||
skipped?: number
|
||||
}
|
||||
|
||||
export interface BatchChunkProgress<TItem> {
|
||||
batch: TItem[]
|
||||
batchIndex: number
|
||||
totalBatches: number
|
||||
processed: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface BatchActionCounts {
|
||||
success: number
|
||||
failed: number
|
||||
skipped: number
|
||||
}
|
||||
|
||||
export async function runChunkedBatchAction<TItem>(options: {
|
||||
items: TItem[]
|
||||
chunkSize: number
|
||||
runChunk: (batch: TItem[], context: BatchChunkProgress<TItem>) => Promise<BatchChunkCounts>
|
||||
onChunkStart?: (context: BatchChunkProgress<TItem>) => void
|
||||
onChunkDone?: (context: BatchChunkProgress<TItem>, counts: BatchChunkCounts) => void
|
||||
}): Promise<BatchActionCounts> {
|
||||
const chunkSize = Math.max(1, options.chunkSize)
|
||||
const totalBatches = Math.ceil(options.items.length / chunkSize)
|
||||
const counts: BatchActionCounts = { success: 0, failed: 0, skipped: 0 }
|
||||
|
||||
for (let offset = 0; offset < options.items.length; offset += chunkSize) {
|
||||
const batch = options.items.slice(offset, offset + chunkSize)
|
||||
const context: BatchChunkProgress<TItem> = {
|
||||
batch,
|
||||
batchIndex: Math.floor(offset / chunkSize) + 1,
|
||||
totalBatches,
|
||||
processed: Math.min(offset + batch.length, options.items.length),
|
||||
total: options.items.length,
|
||||
}
|
||||
options.onChunkStart?.(context)
|
||||
try {
|
||||
const result = await options.runChunk(batch, context)
|
||||
const success = Number(result.success ?? 0)
|
||||
const failed = Number(result.failed ?? 0)
|
||||
const skipped = result.skipped == null
|
||||
? Math.max(0, batch.length - Number(result.total ?? success + failed))
|
||||
: Number(result.skipped)
|
||||
counts.success += success
|
||||
counts.failed += failed
|
||||
counts.skipped += skipped
|
||||
options.onChunkDone?.(context, result)
|
||||
} catch {
|
||||
counts.failed += batch.length
|
||||
options.onChunkDone?.(context, { total: batch.length, failed: batch.length })
|
||||
}
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
@@ -172,11 +172,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 border-b border-border/60 bg-muted/20 px-4 py-2.5 text-xs sm:flex-row sm:items-center sm:justify-between sm:px-6 xl:px-4">
|
||||
<div class="flex flex-wrap items-center gap-2 text-muted-foreground">
|
||||
<label class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="isAllFilteredSelected"
|
||||
:indeterminate="isPartiallyFilteredSelected"
|
||||
:disabled="filteredUsers.length === 0 || usersStore.loading"
|
||||
@update:checked="toggleSelectFiltered"
|
||||
/>
|
||||
<span>全选筛选结果</span>
|
||||
</label>
|
||||
<span>匹配 {{ filteredUsers.length }} 个,当前页 {{ paginatedUsers.length }} 个,已选 {{ selectedCount }} 个</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="paginatedUsers.length === 0 || selectAllFiltered || usersStore.loading"
|
||||
@click="toggleSelectCurrentPage"
|
||||
>
|
||||
{{ isCurrentPageFullySelected ? '取消本页全选' : '本页全选' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="!canClearSelection || usersStore.loading"
|
||||
@click="clearSelection"
|
||||
>
|
||||
清空选择
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-7 px-3 text-[11px]"
|
||||
:disabled="selectedCount === 0 || usersStore.loading"
|
||||
@click="openUserBatchDialog"
|
||||
>
|
||||
批量操作
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
<div class="hidden xl:block overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[44px] h-12 px-4">
|
||||
<Checkbox
|
||||
:checked="isCurrentPageFullySelected || isAllFilteredSelected"
|
||||
:indeterminate="isPartiallyFilteredSelected && !isCurrentPageFullySelected"
|
||||
:disabled="paginatedUsers.length === 0 || selectAllFiltered || usersStore.loading"
|
||||
@update:checked="toggleSelectCurrentPage"
|
||||
/>
|
||||
</TableHead>
|
||||
<SortableTableHead
|
||||
class="w-[260px] h-12 font-semibold"
|
||||
column-key="role"
|
||||
@@ -231,6 +282,13 @@
|
||||
:key="user.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell class="w-[44px] px-4 py-4">
|
||||
<Checkbox
|
||||
:checked="selectAllFiltered || selectedIdSet.has(user.id)"
|
||||
:disabled="selectAllFiltered || usersStore.loading"
|
||||
@update:checked="(checked) => toggleOne(user.id, checked === true)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md">
|
||||
@@ -440,6 +498,12 @@
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Checkbox
|
||||
class="mt-2 shrink-0"
|
||||
:checked="selectAllFiltered || selectedIdSet.has(user.id)"
|
||||
:disabled="selectAllFiltered || usersStore.loading"
|
||||
@update:checked="(checked) => toggleOne(user.id, checked === true)"
|
||||
/>
|
||||
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md flex-shrink-0">
|
||||
<AvatarFallback class="bg-primary text-sm font-bold text-white">
|
||||
{{ user.username.charAt(0).toUpperCase() }}
|
||||
@@ -637,6 +701,16 @@
|
||||
@submit="handleUserFormSubmit"
|
||||
/>
|
||||
|
||||
<UserBatchActionDialog
|
||||
:open="showUserBatchDialog"
|
||||
:selected-ids="selectedIds"
|
||||
:select-all-filtered="selectAllFiltered"
|
||||
:selected-count="selectedCount"
|
||||
:filters="batchSelectionFilters"
|
||||
@close="showUserBatchDialog = false"
|
||||
@completed="handleUserBatchCompleted"
|
||||
/>
|
||||
|
||||
<!-- API Keys 管理对话框 -->
|
||||
<Dialog
|
||||
v-model="showApiKeysDialog"
|
||||
@@ -1060,7 +1134,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import type { User, ApiKey, UserSession } from '@/api/users'
|
||||
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters } from '@/api/users'
|
||||
import { formatSessionMeta } from '@/types/session'
|
||||
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -1093,7 +1167,8 @@ import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
Pagination,
|
||||
RefreshButton
|
||||
RefreshButton,
|
||||
Checkbox
|
||||
} from '@/components/ui'
|
||||
|
||||
import {
|
||||
@@ -1114,11 +1189,13 @@ import {
|
||||
|
||||
// 功能组件
|
||||
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
|
||||
import UserBatchActionDialog from '@/features/users/components/UserBatchActionDialog.vue'
|
||||
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { log } from '@/utils/logger'
|
||||
import { useBatchSelection } from '@/composables/useBatchSelection'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
@@ -1155,6 +1232,7 @@ const userWalletMap = ref<Record<string, AdminWallet>>({})
|
||||
|
||||
const showWalletActionDialogState = ref(false)
|
||||
const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
|
||||
const showUserBatchDialog = ref(false)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterRole = ref('all')
|
||||
@@ -1215,11 +1293,46 @@ const paginatedUsers = computed(() => {
|
||||
return filteredUsers.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const filteredUserCount = computed(() => filteredUsers.value.length)
|
||||
const {
|
||||
selectedIds,
|
||||
selectAllFiltered,
|
||||
selectedIdSet,
|
||||
selectedCount,
|
||||
isAllFilteredSelected,
|
||||
isPartiallyFilteredSelected,
|
||||
isCurrentPageFullySelected,
|
||||
canClearSelection,
|
||||
rememberItems: rememberBatchPageUsers,
|
||||
resetSelection: resetBatchSelection,
|
||||
toggleOne,
|
||||
toggleSelectFiltered,
|
||||
toggleSelectCurrentPage,
|
||||
clearSelection,
|
||||
} = useBatchSelection<User>({
|
||||
pageItems: paginatedUsers,
|
||||
filteredTotal: filteredUserCount,
|
||||
getItemId: (user) => user.id,
|
||||
})
|
||||
|
||||
const batchSelectionFilters = computed<UserBatchSelectionFilters>(() => {
|
||||
const filters: UserBatchSelectionFilters = {}
|
||||
const search = searchQuery.value.trim()
|
||||
if (search) filters.search = search
|
||||
if (filterRole.value === 'admin' || filterRole.value === 'user') filters.role = filterRole.value
|
||||
if (filterStatus.value === 'active') filters.is_active = true
|
||||
if (filterStatus.value === 'inactive') filters.is_active = false
|
||||
return filters
|
||||
})
|
||||
|
||||
// Watch filter changes and reset to first page
|
||||
watch([searchQuery, filterRole, filterStatus], () => {
|
||||
currentPage.value = 1
|
||||
resetBatchSelection()
|
||||
})
|
||||
|
||||
watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
void refreshUsers({ preferCache: true })
|
||||
})
|
||||
@@ -1232,6 +1345,16 @@ async function refreshUsers(options: { preferCache?: boolean } = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
function openUserBatchDialog(): void {
|
||||
if (selectedCount.value === 0) return
|
||||
showUserBatchDialog.value = true
|
||||
}
|
||||
|
||||
async function handleUserBatchCompleted(_result: UserBatchActionResponse): Promise<void> {
|
||||
await refreshUsers()
|
||||
resetBatchSelection(true)
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user