feat(admin): unify system data management and aggregate import/export

This commit is contained in:
fawney19
2026-05-16 02:09:01 +08:00
parent 48da062251
commit f59cf1090d
16 changed files with 872 additions and 206 deletions

View File

@@ -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",

View File

@@ -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"),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"

View File

@@ -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"))

View File

@@ -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),

View File

@@ -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}");
} }

View File

@@ -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>(

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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,
} }
} }