mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat(admin): unify system data management and aggregate import/export
This commit is contained in:
@@ -63,6 +63,14 @@ pub(super) fn classify_admin_system_family_route(
|
|||||||
"admin:system",
|
"admin:system",
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
|
} else if method == http::Method::GET && normalized_path == "/api/admin/system/data/export" {
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"system_manage",
|
||||||
|
"data_export",
|
||||||
|
"admin:system",
|
||||||
|
false,
|
||||||
|
))
|
||||||
} else if method == http::Method::POST && normalized_path == "/api/admin/system/config/import" {
|
} else if method == http::Method::POST && normalized_path == "/api/admin/system/config/import" {
|
||||||
Some(classified(
|
Some(classified(
|
||||||
"admin_proxy",
|
"admin_proxy",
|
||||||
@@ -79,6 +87,14 @@ pub(super) fn classify_admin_system_family_route(
|
|||||||
"admin:system",
|
"admin:system",
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
|
} else if method == http::Method::POST && normalized_path == "/api/admin/system/data/import" {
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"system_manage",
|
||||||
|
"data_import",
|
||||||
|
"admin:system",
|
||||||
|
false,
|
||||||
|
))
|
||||||
} else if method == http::Method::POST && normalized_path == "/api/admin/system/smtp/test" {
|
} else if method == http::Method::POST && normalized_path == "/api/admin/system/smtp/test" {
|
||||||
Some(classified(
|
Some(classified(
|
||||||
"admin_proxy",
|
"admin_proxy",
|
||||||
|
|||||||
@@ -156,12 +156,32 @@ fn classifies_admin_system_users_export_as_admin_proxy_route() {
|
|||||||
assert!(!decision.is_execution_runtime_candidate());
|
assert!(!decision.is_execution_runtime_candidate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_admin_system_data_export_as_admin_proxy_route() {
|
||||||
|
let headers = headers(&[]);
|
||||||
|
let uri: Uri = "/api/admin/system/data/export"
|
||||||
|
.parse()
|
||||||
|
.expect("uri should parse");
|
||||||
|
let decision =
|
||||||
|
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
|
||||||
|
|
||||||
|
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
|
||||||
|
assert_eq!(decision.route_family.as_deref(), Some("system_manage"));
|
||||||
|
assert_eq!(decision.route_kind.as_deref(), Some("data_export"));
|
||||||
|
assert_eq!(
|
||||||
|
decision.auth_endpoint_signature.as_deref(),
|
||||||
|
Some("admin:system")
|
||||||
|
);
|
||||||
|
assert!(!decision.is_execution_runtime_candidate());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_admin_system_maintenance_write_routes_as_admin_proxy_route() {
|
fn classifies_admin_system_maintenance_write_routes_as_admin_proxy_route() {
|
||||||
let headers = headers(&[]);
|
let headers = headers(&[]);
|
||||||
let cases = [
|
let cases = [
|
||||||
("/api/admin/system/config/import", "config_import"),
|
("/api/admin/system/config/import", "config_import"),
|
||||||
("/api/admin/system/users/import", "users_import"),
|
("/api/admin/system/users/import", "users_import"),
|
||||||
|
("/api/admin/system/data/import", "data_import"),
|
||||||
("/api/admin/system/smtp/test", "smtp_test"),
|
("/api/admin/system/smtp/test", "smtp_test"),
|
||||||
("/api/admin/system/cleanup", "cleanup"),
|
("/api/admin/system/cleanup", "cleanup"),
|
||||||
("/api/admin/system/purge/config", "purge_config"),
|
("/api/admin/system/purge/config", "purge_config"),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::ADMIN_SYSTEM_DATA_EXPORT_VERSION;
|
||||||
use crate::handlers::admin::request::AdminAppState;
|
use crate::handlers::admin::request::AdminAppState;
|
||||||
use crate::handlers::admin::system::shared::configs::is_sensitive_admin_system_config_key;
|
use crate::handlers::admin::system::shared::configs::is_sensitive_admin_system_config_key;
|
||||||
use crate::handlers::admin::system::shared::export::{
|
use crate::handlers::admin::system::shared::export::{
|
||||||
@@ -308,6 +309,20 @@ impl<'a> AdminAppState<'a> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn build_admin_system_data_export_payload(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value, GatewayError> {
|
||||||
|
let config_data = self.build_admin_system_config_export_payload().await?;
|
||||||
|
let user_data = self.build_admin_system_users_export_payload().await?;
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"version": ADMIN_SYSTEM_DATA_EXPORT_VERSION,
|
||||||
|
"exported_at": Utc::now().to_rfc3339(),
|
||||||
|
"config_data": config_data,
|
||||||
|
"user_data": user_data,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
fn build_admin_system_users_export_api_key_payload(
|
fn build_admin_system_users_export_api_key_payload(
|
||||||
&self,
|
&self,
|
||||||
key: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
|
key: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use super::AdminAppState;
|
use super::{
|
||||||
|
AdminAppState, ADMIN_SYSTEM_DATA_EXPORT_VERSION, ADMIN_SYSTEM_DATA_IMPORT_MAX_SIZE_BYTES,
|
||||||
|
};
|
||||||
use crate::api::ai::admin_endpoint_signature_parts;
|
use crate::api::ai::admin_endpoint_signature_parts;
|
||||||
use crate::handlers::admin::provider::endpoints_admin::payloads::AdminProviderEndpointUpdatePatch;
|
use crate::handlers::admin::provider::endpoints_admin::payloads::AdminProviderEndpointUpdatePatch;
|
||||||
use crate::handlers::admin::provider::shared::payloads::{
|
use crate::handlers::admin::provider::shared::payloads::{
|
||||||
@@ -56,6 +58,26 @@ fn invalid_request(detail: impl Into<String>) -> (http::StatusCode, Value) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_admin_system_data_import_part_body(
|
||||||
|
root: &Map<String, Value>,
|
||||||
|
field_name: &str,
|
||||||
|
merge_mode: AdminImportMergeMode,
|
||||||
|
) -> Result<Bytes, (http::StatusCode, Value)> {
|
||||||
|
let mut part = match root.get(field_name) {
|
||||||
|
Some(Value::Object(map)) => map.clone(),
|
||||||
|
Some(_) => return Err(invalid_request(format!("{field_name} 必须是对象"))),
|
||||||
|
None => return Err(invalid_request(format!("{field_name} 为必填字段"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let merge_mode_value = serde_json::to_value(merge_mode)
|
||||||
|
.map_err(|err| invalid_request(format!("merge_mode 序列化失败: {err}")))?;
|
||||||
|
part.insert("merge_mode".to_string(), merge_mode_value);
|
||||||
|
|
||||||
|
serde_json::to_vec(&Value::Object(part))
|
||||||
|
.map(Bytes::from)
|
||||||
|
.map_err(|err| invalid_request(format!("{field_name} 序列化失败: {err}")))
|
||||||
|
}
|
||||||
|
|
||||||
fn trim_required(value: &str, field_name: &str) -> Result<String, String> {
|
fn trim_required(value: &str, field_name: &str) -> Result<String, String> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -893,6 +915,91 @@ fn normalize_imported_wallet_target(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> AdminAppState<'a> {
|
impl<'a> AdminAppState<'a> {
|
||||||
|
pub(crate) async fn import_admin_system_data(
|
||||||
|
&self,
|
||||||
|
request_body: &Bytes,
|
||||||
|
operator_id: Option<&str>,
|
||||||
|
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
|
||||||
|
if !self.has_global_model_data_reader()
|
||||||
|
|| !self.has_global_model_data_writer()
|
||||||
|
|| !self.has_provider_catalog_data_reader()
|
||||||
|
|| !self.has_provider_catalog_data_writer()
|
||||||
|
|| !self.has_auth_user_write_capability()
|
||||||
|
|| !self.has_auth_wallet_write_capability()
|
||||||
|
|| !self.has_auth_api_key_writer()
|
||||||
|
{
|
||||||
|
return Ok(Err((
|
||||||
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
json!({ "detail": "Admin system data unavailable" }),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request_body.len() > ADMIN_SYSTEM_DATA_IMPORT_MAX_SIZE_BYTES {
|
||||||
|
return Ok(Err(invalid_request("请求体大小不能超过 20MB")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = match serde_json::from_slice::<Value>(request_body) {
|
||||||
|
Ok(Value::Object(map)) => map,
|
||||||
|
_ => return Ok(Err(invalid_request("请求数据验证失败"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let version = root
|
||||||
|
.get("version")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| invalid_request("version 为必填字段"));
|
||||||
|
let version = match version {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => return Ok(Err(err)),
|
||||||
|
};
|
||||||
|
if version != ADMIN_SYSTEM_DATA_EXPORT_VERSION {
|
||||||
|
return Ok(Err(invalid_request(format!(
|
||||||
|
"不支持的聚合数据版本: {version},支持的版本: {ADMIN_SYSTEM_DATA_EXPORT_VERSION}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let merge_mode = match serde_json::from_value::<AdminImportMergeMode>(
|
||||||
|
root.get("merge_mode").cloned().unwrap_or(Value::Null),
|
||||||
|
) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(Err(invalid_request(
|
||||||
|
"merge_mode 仅支持 skip / overwrite / error",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_body =
|
||||||
|
match build_admin_system_data_import_part_body(&root, "config_data", merge_mode) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => return Ok(Err(err)),
|
||||||
|
};
|
||||||
|
let users_body =
|
||||||
|
match build_admin_system_data_import_part_body(&root, "user_data", merge_mode) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => return Ok(Err(err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_result = match self.import_admin_system_config(&config_body).await? {
|
||||||
|
Ok(payload) => payload,
|
||||||
|
Err(err) => return Ok(Err(err)),
|
||||||
|
};
|
||||||
|
let users_result = match self
|
||||||
|
.import_admin_system_users(&users_body, operator_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Ok(payload) => payload,
|
||||||
|
Err(err) => return Ok(Err(err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Ok(json!({
|
||||||
|
"message": "聚合数据导入成功",
|
||||||
|
"config": config_result,
|
||||||
|
"users": users_result,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn import_admin_system_config(
|
pub(crate) async fn import_admin_system_config(
|
||||||
&self,
|
&self,
|
||||||
request_body: &Bytes,
|
request_body: &Bytes,
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ mod modules;
|
|||||||
mod proxy_nodes;
|
mod proxy_nodes;
|
||||||
mod templates;
|
mod templates;
|
||||||
|
|
||||||
|
const ADMIN_SYSTEM_DATA_EXPORT_VERSION: &str = "1.0";
|
||||||
|
const ADMIN_SYSTEM_DATA_IMPORT_MAX_SIZE_BYTES: usize = 20 * 1024 * 1024;
|
||||||
|
|
||||||
impl<'a> AdminAppState<'a> {
|
impl<'a> AdminAppState<'a> {
|
||||||
pub(crate) async fn upsert_system_config_json_value(
|
pub(crate) async fn upsert_system_config_json_value(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -180,6 +180,55 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decision.route_kind.as_deref() == Some("data_export")
|
||||||
|
&& request_method == http::Method::GET
|
||||||
|
&& request_path == "/api/admin/system/data/export"
|
||||||
|
{
|
||||||
|
return Ok(Some(attach_admin_audit_response(
|
||||||
|
Json(state.build_admin_system_data_export_payload().await?).into_response(),
|
||||||
|
"admin_system_data_exported",
|
||||||
|
"export_system_data",
|
||||||
|
"system_data_export",
|
||||||
|
"global",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if decision.route_kind.as_deref() == Some("data_import")
|
||||||
|
&& request_method == http::Method::POST
|
||||||
|
&& request_path == "/api/admin/system/data/import"
|
||||||
|
{
|
||||||
|
let Some(request_body) = request_body else {
|
||||||
|
return Ok(Some(
|
||||||
|
(
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({ "detail": "请求数据验证失败" })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
return Ok(Some(
|
||||||
|
match state
|
||||||
|
.import_admin_system_data(
|
||||||
|
request_body,
|
||||||
|
decision
|
||||||
|
.admin_principal
|
||||||
|
.as_ref()
|
||||||
|
.map(|principal| principal.user_id.as_str()),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Ok(payload) => attach_admin_audit_response(
|
||||||
|
Json(payload).into_response(),
|
||||||
|
"admin_system_data_imported",
|
||||||
|
"import_system_data",
|
||||||
|
"system_data_import",
|
||||||
|
"global",
|
||||||
|
),
|
||||||
|
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if decision.route_kind.as_deref() == Some("smtp_test")
|
if decision.route_kind.as_deref() == Some("smtp_test")
|
||||||
&& request_method == http::Method::POST
|
&& request_method == http::Method::POST
|
||||||
&& request_path == "/api/admin/system/smtp/test"
|
&& request_path == "/api/admin/system/smtp/test"
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
|
|||||||
| (Some("provider_oauth_manage"), http::Method::POST, Some("device_poll"))
|
| (Some("provider_oauth_manage"), http::Method::POST, Some("device_poll"))
|
||||||
| (Some("system_manage"), http::Method::POST, Some("config_import"))
|
| (Some("system_manage"), http::Method::POST, Some("config_import"))
|
||||||
| (Some("system_manage"), http::Method::POST, Some("users_import"))
|
| (Some("system_manage"), http::Method::POST, Some("users_import"))
|
||||||
|
| (Some("system_manage"), http::Method::POST, Some("data_import"))
|
||||||
| (Some("system_manage"), http::Method::PUT, Some("settings_set"))
|
| (Some("system_manage"), http::Method::PUT, Some("settings_set"))
|
||||||
| (Some("system_manage"), http::Method::PUT, Some("config_set"))
|
| (Some("system_manage"), http::Method::PUT, Some("config_set"))
|
||||||
| (Some("system_manage"), http::Method::PUT, Some("email_template_set"))
|
| (Some("system_manage"), http::Method::PUT, Some("email_template_set"))
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ fn admin_system_owns_system_route_helpers() {
|
|||||||
"build_admin_api_formats_payload",
|
"build_admin_api_formats_payload",
|
||||||
"build_admin_system_config_export_payload",
|
"build_admin_system_config_export_payload",
|
||||||
"build_admin_system_users_export_payload",
|
"build_admin_system_users_export_payload",
|
||||||
|
"build_admin_system_data_export_payload",
|
||||||
"build_admin_system_configs_payload",
|
"build_admin_system_configs_payload",
|
||||||
"build_admin_system_config_detail_payload",
|
"build_admin_system_config_detail_payload",
|
||||||
"apply_admin_system_config_update",
|
"apply_admin_system_config_update",
|
||||||
@@ -287,6 +288,7 @@ fn admin_system_owns_system_route_helpers() {
|
|||||||
"build_admin_api_formats_payload",
|
"build_admin_api_formats_payload",
|
||||||
"build_admin_system_config_export_payload",
|
"build_admin_system_config_export_payload",
|
||||||
"build_admin_system_users_export_payload",
|
"build_admin_system_users_export_payload",
|
||||||
|
"build_admin_system_data_export_payload",
|
||||||
"build_admin_system_configs_payload",
|
"build_admin_system_configs_payload",
|
||||||
"build_admin_system_config_detail_payload",
|
"build_admin_system_config_detail_payload",
|
||||||
"apply_admin_system_config_update",
|
"apply_admin_system_config_update",
|
||||||
@@ -315,6 +317,7 @@ fn admin_system_owns_system_route_helpers() {
|
|||||||
"pub(crate) async fn build_admin_system_settings_payload",
|
"pub(crate) async fn build_admin_system_settings_payload",
|
||||||
"pub(crate) async fn build_admin_system_config_export_payload",
|
"pub(crate) async fn build_admin_system_config_export_payload",
|
||||||
"pub(crate) async fn build_admin_system_users_export_payload",
|
"pub(crate) async fn build_admin_system_users_export_payload",
|
||||||
|
"pub(crate) async fn build_admin_system_data_export_payload",
|
||||||
"pub(crate) fn build_admin_system_configs_payload",
|
"pub(crate) fn build_admin_system_configs_payload",
|
||||||
"pub(crate) async fn build_admin_system_config_detail_payload",
|
"pub(crate) async fn build_admin_system_config_detail_payload",
|
||||||
"pub(crate) async fn apply_admin_system_config_update",
|
"pub(crate) async fn apply_admin_system_config_update",
|
||||||
@@ -447,6 +450,7 @@ fn admin_system_owns_system_route_helpers() {
|
|||||||
"pub(crate) async fn reset_admin_email_template",
|
"pub(crate) async fn reset_admin_email_template",
|
||||||
"pub(crate) async fn build_admin_system_config_export_payload",
|
"pub(crate) async fn build_admin_system_config_export_payload",
|
||||||
"pub(crate) async fn build_admin_system_users_export_payload",
|
"pub(crate) async fn build_admin_system_users_export_payload",
|
||||||
|
"pub(crate) async fn build_admin_system_data_export_payload",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
request_system.contains(pattern),
|
request_system.contains(pattern),
|
||||||
@@ -511,6 +515,7 @@ fn admin_system_shared_configs_split_export_owners() {
|
|||||||
for pattern in [
|
for pattern in [
|
||||||
"pub(crate) async fn build_admin_system_config_export_payload(",
|
"pub(crate) async fn build_admin_system_config_export_payload(",
|
||||||
"pub(crate) async fn build_admin_system_users_export_payload(",
|
"pub(crate) async fn build_admin_system_users_export_payload(",
|
||||||
|
"pub(crate) async fn build_admin_system_data_export_payload(",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
request_system.contains(pattern),
|
request_system.contains(pattern),
|
||||||
|
|||||||
@@ -839,6 +839,7 @@ async fn gateway_handles_admin_system_unavailable_write_routes_locally_with_trus
|
|||||||
let unavailable_paths = [
|
let unavailable_paths = [
|
||||||
"/api/admin/system/config/import",
|
"/api/admin/system/config/import",
|
||||||
"/api/admin/system/users/import",
|
"/api/admin/system/users/import",
|
||||||
|
"/api/admin/system/data/import",
|
||||||
];
|
];
|
||||||
let local_paths = [
|
let local_paths = [
|
||||||
"/api/admin/system/cleanup",
|
"/api/admin/system/cleanup",
|
||||||
@@ -862,7 +863,11 @@ async fn gateway_handles_admin_system_unavailable_write_routes_locally_with_trus
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"path={path}"
|
||||||
|
);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["detail"], DETAIL, "path={path}");
|
assert_eq!(payload["detail"], DETAIL, "path={path}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ export interface UsersExportData {
|
|||||||
standalone_keys?: StandaloneKeyExport[]
|
standalone_keys?: StandaloneKeyExport[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AggregateExportData {
|
||||||
|
version: string
|
||||||
|
exported_at: string
|
||||||
|
config_data: ConfigExportData
|
||||||
|
user_data: UsersExportData
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserGroupExport {
|
export interface UserGroupExport {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
@@ -421,6 +428,16 @@ export interface UsersImportResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AggregateImportRequest extends AggregateExportData {
|
||||||
|
merge_mode: 'skip' | 'overwrite' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggregateImportResponse {
|
||||||
|
message: string
|
||||||
|
config: ConfigImportResponse
|
||||||
|
users: UsersImportResponse
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigImportResponse {
|
export interface ConfigImportResponse {
|
||||||
message: string
|
message: string
|
||||||
stats: {
|
stats: {
|
||||||
@@ -837,6 +854,21 @@ export const adminApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 导出聚合数据(配置数据 + 用户数据)
|
||||||
|
async exportAggregateData(): Promise<AggregateExportData> {
|
||||||
|
const response = await apiClient.get<AggregateExportData>('/api/admin/system/data/export')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 导入聚合数据(配置数据 + 用户数据)
|
||||||
|
async importAggregateData(data: AggregateImportRequest): Promise<AggregateImportResponse> {
|
||||||
|
const response = await apiClient.post<AggregateImportResponse>(
|
||||||
|
'/api/admin/system/data/import',
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// 查询 Provider 可用模型(从上游 API 获取)
|
// 查询 Provider 可用模型(从上游 API 获取)
|
||||||
async queryProviderModels(providerId: string, apiKeyId?: string, forceRefresh = false): Promise<ProviderModelsQueryResponse> {
|
async queryProviderModels(providerId: string, apiKeyId?: string, forceRefresh = false): Promise<ProviderModelsQueryResponse> {
|
||||||
const response = await apiClient.post<ProviderModelsQueryResponse>(
|
const response = await apiClient.post<ProviderModelsQueryResponse>(
|
||||||
|
|||||||
@@ -21,26 +21,18 @@
|
|||||||
@update:site-subtitle="systemConfig.site_subtitle = $event"
|
@update:site-subtitle="systemConfig.site_subtitle = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 配置导出/导入 -->
|
|
||||||
<ConfigManagementSection
|
|
||||||
id="section-config-mgmt"
|
|
||||||
:export-loading="exportLoading"
|
|
||||||
:import-loading="importLoading"
|
|
||||||
@export="handleExportConfig"
|
|
||||||
@file-select="handleConfigFileSelect"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 用户数据导出/导入 -->
|
|
||||||
<UserDataSection
|
|
||||||
id="section-user-data"
|
|
||||||
:export-loading="exportUsersLoading"
|
|
||||||
:import-loading="importUsersLoading"
|
|
||||||
@export="handleExportUsers"
|
|
||||||
@file-select="handleUsersFileSelect"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 数据管理 -->
|
<!-- 数据管理 -->
|
||||||
<DataManagementSection id="section-data-mgmt" />
|
<DataManagementSection
|
||||||
|
id="section-data-mgmt"
|
||||||
|
:config-export-loading="exportLoading"
|
||||||
|
:config-import-loading="importLoading"
|
||||||
|
:users-export-loading="exportUsersLoading"
|
||||||
|
:users-import-loading="importUsersLoading"
|
||||||
|
:aggregate-export-loading="exportAggregateLoading"
|
||||||
|
:aggregate-import-loading="importAggregateLoading"
|
||||||
|
@export="handleDataExport"
|
||||||
|
@file-select="handleDataFileSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 网络代理 -->
|
<!-- 网络代理 -->
|
||||||
<ProxyConfigSection
|
<ProxyConfigSection
|
||||||
@@ -210,6 +202,22 @@
|
|||||||
@update:users-merge-mode="usersMergeMode = $event"
|
@update:users-merge-mode="usersMergeMode = $event"
|
||||||
@update:users-merge-mode-select-open="usersMergeModeSelectOpen = $event"
|
@update:users-merge-mode-select-open="usersMergeModeSelectOpen = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 聚合数据导入对话框 -->
|
||||||
|
<AggregateImportDialog
|
||||||
|
:aggregate-import-dialog-open="aggregateImportDialogOpen"
|
||||||
|
:aggregate-import-result-dialog-open="aggregateImportResultDialogOpen"
|
||||||
|
:aggregate-import-preview="aggregateImportPreview"
|
||||||
|
:aggregate-import-result="aggregateImportResult"
|
||||||
|
:aggregate-merge-mode="aggregateMergeMode"
|
||||||
|
:aggregate-merge-mode-select-open="aggregateMergeModeSelectOpen"
|
||||||
|
:import-aggregate-loading="importAggregateLoading"
|
||||||
|
@confirm="confirmImportAggregate"
|
||||||
|
@update:aggregate-import-dialog-open="aggregateImportDialogOpen = $event"
|
||||||
|
@update:aggregate-import-result-dialog-open="aggregateImportResultDialogOpen = $event"
|
||||||
|
@update:aggregate-merge-mode="aggregateMergeMode = $event"
|
||||||
|
@update:aggregate-merge-mode-select-open="aggregateMergeModeSelectOpen = $event"
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -225,8 +233,6 @@ import { useScheduledTasks } from './system-settings/composables/useScheduledTas
|
|||||||
|
|
||||||
// Section components
|
// Section components
|
||||||
import SiteInfoSection from './system-settings/SiteInfoSection.vue'
|
import SiteInfoSection from './system-settings/SiteInfoSection.vue'
|
||||||
import ConfigManagementSection from './system-settings/ConfigManagementSection.vue'
|
|
||||||
import UserDataSection from './system-settings/UserDataSection.vue'
|
|
||||||
import DataManagementSection from './system-settings/DataManagementSection.vue'
|
import DataManagementSection from './system-settings/DataManagementSection.vue'
|
||||||
import ProxyConfigSection from './system-settings/ProxyConfigSection.vue'
|
import ProxyConfigSection from './system-settings/ProxyConfigSection.vue'
|
||||||
import BasicConfigSection from './system-settings/BasicConfigSection.vue'
|
import BasicConfigSection from './system-settings/BasicConfigSection.vue'
|
||||||
@@ -238,14 +244,13 @@ import SystemInfoSection from './system-settings/SystemInfoSection.vue'
|
|||||||
// Dialog components
|
// Dialog components
|
||||||
import ConfigImportDialog from './system-settings/ConfigImportDialog.vue'
|
import ConfigImportDialog from './system-settings/ConfigImportDialog.vue'
|
||||||
import UsersImportDialog from './system-settings/UsersImportDialog.vue'
|
import UsersImportDialog from './system-settings/UsersImportDialog.vue'
|
||||||
|
import AggregateImportDialog from './system-settings/AggregateImportDialog.vue'
|
||||||
|
|
||||||
const proxyNodesStore = useProxyNodesStore()
|
const proxyNodesStore = useProxyNodesStore()
|
||||||
|
|
||||||
// TOC 目录导航
|
// TOC 目录导航
|
||||||
const tocItems = [
|
const tocItems = [
|
||||||
{ id: 'section-site-info', label: '站点信息' },
|
{ id: 'section-site-info', label: '站点信息' },
|
||||||
{ id: 'section-config-mgmt', label: '配置管理' },
|
|
||||||
{ id: 'section-user-data', label: '用户数据管理' },
|
|
||||||
{ id: 'section-data-mgmt', label: '数据管理' },
|
{ id: 'section-data-mgmt', label: '数据管理' },
|
||||||
{ id: 'section-proxy', label: '网络代理' },
|
{ id: 'section-proxy', label: '网络代理' },
|
||||||
{ id: 'section-basic', label: '基础配置' },
|
{ id: 'section-basic', label: '基础配置' },
|
||||||
@@ -331,7 +336,7 @@ const {
|
|||||||
handleAutoCleanupToggle,
|
handleAutoCleanupToggle,
|
||||||
} = useSystemConfig()
|
} = useSystemConfig()
|
||||||
|
|
||||||
// Config export/import composable
|
// 数据导出/导入 composable
|
||||||
const {
|
const {
|
||||||
exportLoading,
|
exportLoading,
|
||||||
importLoading,
|
importLoading,
|
||||||
@@ -355,8 +360,41 @@ const {
|
|||||||
handleExportUsers,
|
handleExportUsers,
|
||||||
handleUsersFileSelect,
|
handleUsersFileSelect,
|
||||||
confirmImportUsers,
|
confirmImportUsers,
|
||||||
|
exportAggregateLoading,
|
||||||
|
importAggregateLoading,
|
||||||
|
aggregateImportDialogOpen,
|
||||||
|
aggregateImportResultDialogOpen,
|
||||||
|
aggregateImportPreview,
|
||||||
|
aggregateImportResult,
|
||||||
|
aggregateMergeMode,
|
||||||
|
aggregateMergeModeSelectOpen,
|
||||||
|
handleExportAggregate,
|
||||||
|
handleAggregateFileSelect,
|
||||||
|
confirmImportAggregate,
|
||||||
} = useConfigExportImport(systemConfig)
|
} = useConfigExportImport(systemConfig)
|
||||||
|
|
||||||
|
type DataManagementKind = 'config' | 'users' | 'aggregate'
|
||||||
|
|
||||||
|
function handleDataExport(kind: DataManagementKind) {
|
||||||
|
if (kind === 'config') {
|
||||||
|
handleExportConfig()
|
||||||
|
} else if (kind === 'users') {
|
||||||
|
handleExportUsers()
|
||||||
|
} else {
|
||||||
|
handleExportAggregate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDataFileSelect(kind: DataManagementKind, event: Event) {
|
||||||
|
if (kind === 'config') {
|
||||||
|
handleConfigFileSelect(event)
|
||||||
|
} else if (kind === 'users') {
|
||||||
|
handleUsersFileSelect(event)
|
||||||
|
} else {
|
||||||
|
handleAggregateFileSelect(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Scheduled tasks composable
|
// Scheduled tasks composable
|
||||||
const {
|
const {
|
||||||
scheduledTasks,
|
scheduledTasks,
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 聚合数据导入对话框 -->
|
||||||
|
<Dialog
|
||||||
|
:open="aggregateImportDialogOpen"
|
||||||
|
title="导入聚合数据"
|
||||||
|
description="选择冲突处理模式并确认导入"
|
||||||
|
@update:open="$emit('update:aggregateImportDialogOpen', $event)"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-if="aggregateImportPreview"
|
||||||
|
class="text-sm"
|
||||||
|
>
|
||||||
|
<p class="font-medium mb-2">
|
||||||
|
聚合数据预览
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-foreground mb-1">
|
||||||
|
配置数据
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li>全局模型: {{ aggregateImportPreview.config_data.global_models?.length || 0 }} 个</li>
|
||||||
|
<li>提供商: {{ aggregateImportPreview.config_data.providers?.length || 0 }} 个</li>
|
||||||
|
<li>
|
||||||
|
API Keys: {{ aggregateImportPreview.config_data.providers?.reduce((sum: number, p: { api_keys?: unknown[] }) => sum + (p.api_keys?.length || 0), 0) }} 个
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-foreground mb-1">
|
||||||
|
用户数据
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li v-if="aggregateImportPreview.user_data.user_groups?.length">
|
||||||
|
用户组: {{ aggregateImportPreview.user_data.user_groups.length }} 个
|
||||||
|
</li>
|
||||||
|
<li>用户: {{ aggregateImportPreview.user_data.users?.length || 0 }} 个</li>
|
||||||
|
<li>
|
||||||
|
API Keys: {{ aggregateImportPreview.user_data.users?.reduce((sum: number, u: { api_keys?: unknown[] }) => sum + (u.api_keys?.length || 0), 0) }} 个
|
||||||
|
</li>
|
||||||
|
<li v-if="aggregateImportPreview.user_data.standalone_keys?.length">
|
||||||
|
独立余额 Keys: {{ aggregateImportPreview.user_data.standalone_keys.length }} 个
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
|
||||||
|
<Select
|
||||||
|
:model-value="aggregateMergeMode"
|
||||||
|
:open="aggregateMergeModeSelectOpen"
|
||||||
|
@update:model-value="$emit('update:aggregateMergeMode', $event)"
|
||||||
|
@update:open="$emit('update:aggregateMergeModeSelectOpen', $event)"
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="skip">
|
||||||
|
跳过 - 保留现有数据
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="overwrite">
|
||||||
|
覆盖 - 用导入数据替换
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="error">
|
||||||
|
报错 - 遇到冲突时中止
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
<template v-if="aggregateMergeMode === 'skip'">
|
||||||
|
已存在的数据将被保留,仅导入新数据
|
||||||
|
</template>
|
||||||
|
<template v-else-if="aggregateMergeMode === 'overwrite'">
|
||||||
|
已存在的数据将被导入内容覆盖
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
如果发现任何冲突,导入将中止
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
注意:聚合数据会先导入配置数据,再导入用户数据;用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
@click="$emit('update:aggregateImportDialogOpen', false); $emit('update:aggregateMergeModeSelectOpen', false)"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:disabled="importAggregateLoading"
|
||||||
|
@click="$emit('confirm')"
|
||||||
|
>
|
||||||
|
{{ importAggregateLoading ? '导入中...' : '确认导入' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 聚合数据导入结果对话框 -->
|
||||||
|
<Dialog
|
||||||
|
:open="aggregateImportResultDialogOpen"
|
||||||
|
title="聚合数据导入完成"
|
||||||
|
@update:open="$emit('update:aggregateImportResultDialogOpen', $event)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="aggregateImportResult"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">
|
||||||
|
配置数据
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
全局模型创建 {{ aggregateImportResult.config.stats.global_models.created }},
|
||||||
|
提供商创建 {{ aggregateImportResult.config.stats.providers.created }},
|
||||||
|
API Keys 创建 {{ aggregateImportResult.config.stats.keys.created }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">
|
||||||
|
用户数据
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
用户创建 {{ aggregateImportResult.users.stats.users.created }},
|
||||||
|
API Keys 创建 {{ aggregateImportResult.users.stats.api_keys.created }},
|
||||||
|
跳过 {{ aggregateImportResult.users.stats.users.skipped }} 个用户
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="warningMessages.length > 0"
|
||||||
|
class="p-3 bg-destructive/10 rounded-lg"
|
||||||
|
>
|
||||||
|
<p class="font-medium text-destructive mb-2">
|
||||||
|
警告信息
|
||||||
|
</p>
|
||||||
|
<ul class="text-sm text-destructive space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(message, index) in warningMessages"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button @click="$emit('update:aggregateImportResultDialogOpen', false)">
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import Label from '@/components/ui/label.vue'
|
||||||
|
import Select from '@/components/ui/select.vue'
|
||||||
|
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||||
|
import SelectValue from '@/components/ui/select-value.vue'
|
||||||
|
import SelectContent from '@/components/ui/select-content.vue'
|
||||||
|
import SelectItem from '@/components/ui/select-item.vue'
|
||||||
|
import { Dialog } from '@/components/ui'
|
||||||
|
import type { AggregateExportData, AggregateImportResponse } from '@/api/admin'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
aggregateImportDialogOpen: boolean
|
||||||
|
aggregateImportResultDialogOpen: boolean
|
||||||
|
aggregateImportPreview: AggregateExportData | null
|
||||||
|
aggregateImportResult: AggregateImportResponse | null
|
||||||
|
aggregateMergeMode: 'skip' | 'overwrite' | 'error'
|
||||||
|
aggregateMergeModeSelectOpen: boolean
|
||||||
|
importAggregateLoading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
confirm: []
|
||||||
|
'update:aggregateImportDialogOpen': [value: boolean]
|
||||||
|
'update:aggregateImportResultDialogOpen': [value: boolean]
|
||||||
|
'update:aggregateMergeMode': [value: 'skip' | 'overwrite' | 'error']
|
||||||
|
'update:aggregateMergeModeSelectOpen': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const warningMessages = computed(() => {
|
||||||
|
if (!props.aggregateImportResult) return []
|
||||||
|
const configErrors = props.aggregateImportResult.config.stats.errors.map((message) => `配置数据: ${message}`)
|
||||||
|
const userErrors = props.aggregateImportResult.users.stats.errors.map((message) => `用户数据: ${message}`)
|
||||||
|
return [...configErrors, ...userErrors]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<template>
|
|
||||||
<CardSection
|
|
||||||
title="配置管理"
|
|
||||||
description="导出或导入提供商和模型配置,便于备份或迁移"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap gap-4">
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<p class="text-sm text-muted-foreground mb-3">
|
|
||||||
导出当前所有提供商、端点、API Key 和模型配置到 JSON 文件
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
:disabled="exportLoading"
|
|
||||||
@click="$emit('export')"
|
|
||||||
>
|
|
||||||
<Download class="w-4 h-4 mr-2" />
|
|
||||||
{{ exportLoading ? '导出中...' : '导出配置' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<p class="text-sm text-muted-foreground mb-3">
|
|
||||||
从 JSON 文件导入配置,支持跳过、覆盖或报错三种冲突处理模式
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
ref="configFileInput"
|
|
||||||
type="file"
|
|
||||||
accept=".json"
|
|
||||||
class="hidden"
|
|
||||||
@change="$emit('fileSelect', $event)"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
:disabled="importLoading"
|
|
||||||
@click="triggerFileSelect"
|
|
||||||
>
|
|
||||||
<Upload class="w-4 h-4 mr-2" />
|
|
||||||
{{ importLoading ? '导入中...' : '导入配置' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardSection>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Download, Upload } from 'lucide-vue-next'
|
|
||||||
import Button from '@/components/ui/button.vue'
|
|
||||||
import { CardSection } from '@/components/layout'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
exportLoading: boolean
|
|
||||||
importLoading: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
export: []
|
|
||||||
fileSelect: [event: Event]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const configFileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
|
|
||||||
function triggerFileSelect() {
|
|
||||||
configFileInput.value?.click()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,49 +1,160 @@
|
|||||||
<template>
|
<template>
|
||||||
<CardSection
|
<CardSection
|
||||||
title="数据管理"
|
title="数据管理"
|
||||||
description="清空系统数据,操作不可逆,请谨慎使用"
|
description="导出、导入或清空系统数据"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="space-y-6">
|
||||||
<div
|
<div>
|
||||||
v-for="item in purgeItems"
|
<div class="flex items-center gap-2 mb-3">
|
||||||
:key="item.key"
|
<Database class="w-4 h-4 text-muted-foreground" />
|
||||||
class="flex flex-col gap-2 p-4 rounded-lg border border-border"
|
<h4 class="text-sm font-medium">
|
||||||
>
|
导出 / 导入
|
||||||
<div class="flex items-center gap-2">
|
</h4>
|
||||||
<component
|
|
||||||
:is="item.icon"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium">{{ item.title }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground flex-1">
|
|
||||||
{{ item.description }}
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
</p>
|
<div
|
||||||
<Button
|
v-for="item in dataItems"
|
||||||
variant="destructive"
|
:key="item.key"
|
||||||
size="sm"
|
class="flex flex-col gap-3 p-4 rounded-lg border border-border"
|
||||||
class="w-full mt-1"
|
>
|
||||||
:disabled="loadingKey === item.key"
|
<div class="flex items-center gap-2">
|
||||||
@click="handlePurge(item)"
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium">{{ item.title }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground flex-1">
|
||||||
|
{{ item.description }}
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="item.exportLoading"
|
||||||
|
@click="$emit('export', item.key)"
|
||||||
|
>
|
||||||
|
<Download class="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
{{ item.exportLoading ? '导出中...' : item.exportLabel }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="item.importLoading"
|
||||||
|
@click="triggerDataFileSelect(item.key)"
|
||||||
|
>
|
||||||
|
<Upload class="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
{{ item.importLoading ? '导入中...' : item.importLabel }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="configFileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
class="hidden"
|
||||||
|
@change="$emit('fileSelect', 'config', $event)"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5 mr-1.5" />
|
<input
|
||||||
{{ loadingKey === item.key ? '清空中...' : item.buttonText }}
|
ref="usersFileInput"
|
||||||
</Button>
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
class="hidden"
|
||||||
|
@change="$emit('fileSelect', 'users', $event)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="aggregateFileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
class="hidden"
|
||||||
|
@change="$emit('fileSelect', 'aggregate', $event)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<Trash2 class="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h4 class="text-sm font-medium">
|
||||||
|
清空数据
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="item in purgeItems"
|
||||||
|
:key="item.key"
|
||||||
|
class="flex flex-col gap-2 p-4 rounded-lg border border-border"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium">{{ item.title }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground flex-1">
|
||||||
|
{{ item.description }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
class="w-full mt-1"
|
||||||
|
:disabled="loadingKey === item.key"
|
||||||
|
@click="handlePurge(item)"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
{{ loadingKey === item.key ? '清空中...' : item.buttonText }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, markRaw, type Component } from 'vue'
|
import { computed, ref, markRaw, type Component } from 'vue'
|
||||||
import { Trash2, Settings, Users, BarChart3, Shield, FileText, PieChart } from 'lucide-vue-next'
|
import {
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
Database,
|
||||||
|
Layers3,
|
||||||
|
Trash2,
|
||||||
|
BarChart3,
|
||||||
|
Shield,
|
||||||
|
FileText,
|
||||||
|
PieChart,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import { Separator } from '@/components/ui'
|
||||||
import { CardSection } from '@/components/layout'
|
import { CardSection } from '@/components/layout'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
|
|
||||||
|
type DataItemKey = 'config' | 'users' | 'aggregate'
|
||||||
|
|
||||||
|
interface DataItem {
|
||||||
|
key: DataItemKey
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
exportLabel: string
|
||||||
|
importLabel: string
|
||||||
|
icon: Component
|
||||||
|
exportLoading: boolean
|
||||||
|
importLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface PurgeItem {
|
interface PurgeItem {
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
@@ -54,9 +165,59 @@ interface PurgeItem {
|
|||||||
action: () => Promise<{ message: string }>
|
action: () => Promise<{ message: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
configExportLoading: boolean
|
||||||
|
configImportLoading: boolean
|
||||||
|
usersExportLoading: boolean
|
||||||
|
usersImportLoading: boolean
|
||||||
|
aggregateExportLoading: boolean
|
||||||
|
aggregateImportLoading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
export: [key: DataItemKey]
|
||||||
|
fileSelect: [key: DataItemKey, event: Event]
|
||||||
|
}>()
|
||||||
|
|
||||||
const { success, error } = useToast()
|
const { success, error } = useToast()
|
||||||
const { confirmDanger } = useConfirm()
|
const { confirmDanger } = useConfirm()
|
||||||
const loadingKey = ref<string | null>(null)
|
const loadingKey = ref<string | null>(null)
|
||||||
|
const configFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const usersFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const aggregateFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const dataItems = computed<DataItem[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'config',
|
||||||
|
title: '配置数据',
|
||||||
|
description: '提供商、端点、API Key、模型与系统配置',
|
||||||
|
exportLabel: '导出配置',
|
||||||
|
importLabel: '导入配置',
|
||||||
|
icon: markRaw(Settings),
|
||||||
|
exportLoading: props.configExportLoading,
|
||||||
|
importLoading: props.configImportLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users',
|
||||||
|
title: '用户数据',
|
||||||
|
description: '普通用户、用户组、API Keys 与钱包快照(不含管理员)',
|
||||||
|
exportLabel: '导出用户',
|
||||||
|
importLabel: '导入用户',
|
||||||
|
icon: markRaw(Users),
|
||||||
|
exportLoading: props.usersExportLoading,
|
||||||
|
importLoading: props.usersImportLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'aggregate',
|
||||||
|
title: '聚合数据',
|
||||||
|
description: '配置数据和用户数据的一体化备份文件',
|
||||||
|
exportLabel: '导出聚合',
|
||||||
|
importLabel: '导入聚合',
|
||||||
|
icon: markRaw(Layers3),
|
||||||
|
exportLoading: props.aggregateExportLoading,
|
||||||
|
importLoading: props.aggregateImportLoading,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
const purgeItems: PurgeItem[] = [
|
const purgeItems: PurgeItem[] = [
|
||||||
{
|
{
|
||||||
@@ -115,6 +276,16 @@ const purgeItems: PurgeItem[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function triggerDataFileSelect(key: DataItemKey) {
|
||||||
|
if (key === 'config') {
|
||||||
|
configFileInput.value?.click()
|
||||||
|
} else if (key === 'users') {
|
||||||
|
usersFileInput.value?.click()
|
||||||
|
} else {
|
||||||
|
aggregateFileInput.value?.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePurge(item: PurgeItem) {
|
async function handlePurge(item: PurgeItem) {
|
||||||
const confirmed = await confirmDanger(item.confirmMessage, item.title)
|
const confirmed = await confirmDanger(item.confirmMessage, item.title)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
<template>
|
|
||||||
<CardSection
|
|
||||||
title="用户数据管理"
|
|
||||||
description="导出或导入用户及其 API Keys 数据(不含管理员)"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap gap-4">
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<p class="text-sm text-muted-foreground mb-3">
|
|
||||||
导出所有普通用户及其 API Keys 到 JSON 文件
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
:disabled="exportLoading"
|
|
||||||
@click="$emit('export')"
|
|
||||||
>
|
|
||||||
<Download class="w-4 h-4 mr-2" />
|
|
||||||
{{ exportLoading ? '导出中...' : '导出用户数据' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<p class="text-sm text-muted-foreground mb-3">
|
|
||||||
从 JSON 文件导入用户数据(需相同 ENCRYPTION_KEY)
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
ref="usersFileInput"
|
|
||||||
type="file"
|
|
||||||
accept=".json"
|
|
||||||
class="hidden"
|
|
||||||
@change="$emit('fileSelect', $event)"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
:disabled="importLoading"
|
|
||||||
@click="triggerFileSelect"
|
|
||||||
>
|
|
||||||
<Upload class="w-4 h-4 mr-2" />
|
|
||||||
{{ importLoading ? '导入中...' : '导入用户数据' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardSection>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Download, Upload } from 'lucide-vue-next'
|
|
||||||
import Button from '@/components/ui/button.vue'
|
|
||||||
import { CardSection } from '@/components/layout'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
exportLoading: boolean
|
|
||||||
importLoading: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
export: []
|
|
||||||
fileSelect: [event: Event]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const usersFileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
|
|
||||||
function triggerFileSelect() {
|
|
||||||
usersFileInput.value?.click()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -2,6 +2,8 @@ import { ref } from 'vue'
|
|||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import {
|
import {
|
||||||
adminApi,
|
adminApi,
|
||||||
|
type AggregateExportData,
|
||||||
|
type AggregateImportResponse,
|
||||||
type ConfigExportData,
|
type ConfigExportData,
|
||||||
type ConfigImportResponse,
|
type ConfigImportResponse,
|
||||||
type UsersExportData,
|
type UsersExportData,
|
||||||
@@ -11,8 +13,9 @@ import { parseApiError } from '@/utils/errorParser'
|
|||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import type { SystemConfig } from './useSystemConfig'
|
import type { SystemConfig } from './useSystemConfig'
|
||||||
|
|
||||||
// 文件大小限制 (10MB)
|
// 文件大小限制:聚合数据包含配置和用户数据,允许更大的备份文件。
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||||
|
const MAX_AGGREGATE_FILE_SIZE = 20 * 1024 * 1024
|
||||||
|
|
||||||
type JsonObject = Record<string, unknown>
|
type JsonObject = Record<string, unknown>
|
||||||
|
|
||||||
@@ -41,6 +44,23 @@ function looksLikeUsersExport(value: JsonObject): boolean {
|
|||||||
|| hasArrayField(value, 'user_groups')
|
|| hasArrayField(value, 'user_groups')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeAggregateExport(value: JsonObject): boolean {
|
||||||
|
return asJsonObject(value.config_data) != null
|
||||||
|
&& asJsonObject(value.user_data) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadJson(data: unknown, filename: string) {
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||||
const { success, error } = useToast()
|
const { success, error } = useToast()
|
||||||
|
|
||||||
@@ -66,20 +86,25 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
|||||||
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||||
const usersMergeModeSelectOpen = ref(false)
|
const usersMergeModeSelectOpen = ref(false)
|
||||||
|
|
||||||
|
// 聚合数据导出/导入相关
|
||||||
|
const exportAggregateLoading = ref(false)
|
||||||
|
const importAggregateLoading = ref(false)
|
||||||
|
const aggregateImportDialogOpen = ref(false)
|
||||||
|
const aggregateImportResultDialogOpen = ref(false)
|
||||||
|
const aggregateImportPreview = ref<AggregateExportData | null>(null)
|
||||||
|
const aggregateImportResult = ref<AggregateImportResponse | null>(null)
|
||||||
|
const aggregateMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||||
|
const aggregateMergeModeSelectOpen = ref(false)
|
||||||
|
|
||||||
// 导出配置
|
// 导出配置
|
||||||
async function handleExportConfig() {
|
async function handleExportConfig() {
|
||||||
exportLoading.value = true
|
exportLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await adminApi.exportConfig()
|
const data = await adminApi.exportConfig()
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
downloadJson(
|
||||||
const url = URL.createObjectURL(blob)
|
data,
|
||||||
const a = document.createElement('a')
|
`${systemConfig.value.site_name.toLowerCase()}-config-${new Date().toISOString().slice(0, 10)}.json`,
|
||||||
a.href = url
|
)
|
||||||
a.download = `${systemConfig.value.site_name.toLowerCase()}-config-${new Date().toISOString().slice(0, 10)}.json`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
success('配置已导出')
|
success('配置已导出')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error('导出配置失败')
|
error('导出配置失败')
|
||||||
@@ -173,15 +198,10 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
|||||||
exportUsersLoading.value = true
|
exportUsersLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await adminApi.exportUsers()
|
const data = await adminApi.exportUsers()
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
downloadJson(
|
||||||
const url = URL.createObjectURL(blob)
|
data,
|
||||||
const a = document.createElement('a')
|
`${systemConfig.value.site_name.toLowerCase()}-users-${new Date().toISOString().slice(0, 10)}.json`,
|
||||||
a.href = url
|
)
|
||||||
a.download = `${systemConfig.value.site_name.toLowerCase()}-users-${new Date().toISOString().slice(0, 10)}.json`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
success('用户数据已导出')
|
success('用户数据已导出')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error('导出用户数据失败')
|
error('导出用户数据失败')
|
||||||
@@ -280,6 +300,110 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导出聚合数据
|
||||||
|
async function handleExportAggregate() {
|
||||||
|
exportAggregateLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await adminApi.exportAggregateData()
|
||||||
|
downloadJson(
|
||||||
|
data,
|
||||||
|
`${systemConfig.value.site_name.toLowerCase()}-data-${new Date().toISOString().slice(0, 10)}.json`,
|
||||||
|
)
|
||||||
|
success('聚合数据已导出')
|
||||||
|
} catch (err) {
|
||||||
|
error('导出聚合数据失败')
|
||||||
|
log.error('导出聚合数据失败:', err)
|
||||||
|
} finally {
|
||||||
|
exportAggregateLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理聚合数据文件选择
|
||||||
|
function handleAggregateFileSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (file.size > MAX_AGGREGATE_FILE_SIZE) {
|
||||||
|
error('文件大小不能超过 20MB')
|
||||||
|
input.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const content = e.target?.result as string
|
||||||
|
const root = asJsonObject(JSON.parse(content))
|
||||||
|
if (!root) {
|
||||||
|
error('无效的聚合数据文件:JSON 顶层必须是对象')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!looksLikeAggregateExport(root)) {
|
||||||
|
if (looksLikeConfigExport(root)) {
|
||||||
|
error('这是配置导出文件,请使用“导入配置数据”')
|
||||||
|
} else if (looksLikeUsersExport(root)) {
|
||||||
|
error('这是用户数据导出文件,请使用“导入用户数据”')
|
||||||
|
} else {
|
||||||
|
error('无效的聚合数据文件:未找到配置数据和用户数据')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!root.version) {
|
||||||
|
error('无效的聚合数据文件:缺少版本信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const configData = asJsonObject(root.config_data)
|
||||||
|
const userData = asJsonObject(root.user_data)
|
||||||
|
if (!configData || !looksLikeConfigExport(configData)) {
|
||||||
|
error('无效的聚合数据文件:config_data 格式不正确')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!userData || !looksLikeUsersExport(userData)) {
|
||||||
|
error('无效的聚合数据文件:user_data 格式不正确')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = root as unknown as AggregateExportData
|
||||||
|
aggregateImportPreview.value = data
|
||||||
|
aggregateMergeMode.value = 'skip'
|
||||||
|
aggregateImportDialogOpen.value = true
|
||||||
|
} catch (err) {
|
||||||
|
error('解析聚合数据文件失败,请确保是有效的 JSON 文件')
|
||||||
|
log.error('解析聚合数据文件失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认导入聚合数据
|
||||||
|
async function confirmImportAggregate() {
|
||||||
|
if (!aggregateImportPreview.value) return
|
||||||
|
|
||||||
|
importAggregateLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await adminApi.importAggregateData({
|
||||||
|
...aggregateImportPreview.value,
|
||||||
|
merge_mode: aggregateMergeMode.value,
|
||||||
|
})
|
||||||
|
aggregateImportResult.value = result
|
||||||
|
aggregateImportDialogOpen.value = false
|
||||||
|
aggregateMergeModeSelectOpen.value = false
|
||||||
|
aggregateImportResultDialogOpen.value = true
|
||||||
|
success('聚合数据导入成功')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error(parseApiError(err, '导入聚合数据失败'))
|
||||||
|
log.error('导入聚合数据失败:', err)
|
||||||
|
} finally {
|
||||||
|
importAggregateLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 配置导出/导入
|
// 配置导出/导入
|
||||||
exportLoading,
|
exportLoading,
|
||||||
@@ -309,5 +433,17 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
|||||||
triggerUsersFileSelect,
|
triggerUsersFileSelect,
|
||||||
handleUsersFileSelect,
|
handleUsersFileSelect,
|
||||||
confirmImportUsers,
|
confirmImportUsers,
|
||||||
|
// 聚合数据导出/导入
|
||||||
|
exportAggregateLoading,
|
||||||
|
importAggregateLoading,
|
||||||
|
aggregateImportDialogOpen,
|
||||||
|
aggregateImportResultDialogOpen,
|
||||||
|
aggregateImportPreview,
|
||||||
|
aggregateImportResult,
|
||||||
|
aggregateMergeMode,
|
||||||
|
aggregateMergeModeSelectOpen,
|
||||||
|
handleExportAggregate,
|
||||||
|
handleAggregateFileSelect,
|
||||||
|
confirmImportAggregate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user