feat(plugin): P2-4 数据导入导出 — 后端 export/import API + 前端 UI + TS 修复
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- data_service: export 方法查询匹配行(上限10000),import 方法逐行校验+插入
- data_handler: export_plugin_data / import_plugin_data 处理函数
- module: 注册 GET /export + POST /import 路由
- pluginData.ts: exportPluginData / importPluginData API 函数
- PluginCRUDPage: 根据 entity importable/exportable 标志显示导出/导入按钮
- PluginMarket: 修复 TS 错误 (unused imports, type narrowing)
- PluginSettingsForm: 修复 TS 错误 (Rule type, Divider orientation)
This commit is contained in:
iven
2026-04-19 13:28:12 +08:00
parent e429448c42
commit 120f3fe867
8 changed files with 464 additions and 6 deletions

View File

@@ -164,3 +164,48 @@ pub struct PublicEntityResp {
pub entity_name: String,
pub display_name: String,
}
// ─── 导入导出 DTO ──────────────────────────────────────────────────
/// 数据导出参数
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
pub struct ExportParams {
/// JSON 格式过滤: {"field":"value"}
pub filter: Option<String>,
/// 搜索关键词
pub search: Option<String>,
/// 排序字段
pub sort_by: Option<String>,
/// "asc" or "desc"
pub sort_order: Option<String>,
/// 导出格式: "csv" (默认) | "json"
pub format: Option<String>,
}
/// 数据导入请求
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ImportReq {
/// 导入数据行列表,每行是一个 JSON 对象
pub rows: Vec<serde_json::Value>,
}
/// 数据导入结果
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ImportResult {
/// 成功导入行数
pub success_count: usize,
/// 失败行数
pub error_count: usize,
/// 每行错误详情: [{ row: 0, errors: ["字段 xxx 必填"] }]
#[serde(default)]
pub errors: Vec<ImportRowError>,
}
/// 单行导入错误
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ImportRowError {
/// 行号0-based
pub row: usize,
/// 错误消息列表
pub errors: Vec<String>,
}

View File

@@ -505,6 +505,139 @@ impl PluginDataService {
Ok(())
}
/// 导出数据(不分页,复用 list 的过滤逻辑)
pub async fn export(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
filter: Option<serde_json::Value>,
search: Option<String>,
sort_by: Option<String>,
sort_order: Option<String>,
cache: &moka::sync::Cache<String, EntityInfo>,
scope: Option<DataScopeParams>,
) -> AppResult<Vec<serde_json::Value>> {
let info =
resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?;
// 搜索字段
let entity_fields = info.fields()?;
let search_tuple = {
let searchable: Vec<&str> = entity_fields
.iter()
.filter(|f| f.searchable == Some(true))
.map(|f| f.name.as_str())
.collect();
match (searchable.is_empty(), &search) {
(false, Some(kw)) => Some((searchable.join(","), kw.clone())),
_ => None,
}
};
// 查询所有匹配行(上限 10000
let (sql, mut values) = DynamicTableManager::build_filtered_query_sql_ex(
&info.table_name,
tenant_id,
10000,
0,
filter,
search_tuple,
sort_by,
sort_order,
&info.generated_fields,
)
.map_err(|e| AppError::Validation(e))?;
// 注入数据权限
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
let sql = merge_scope_condition(sql, &scope_condition);
values.extend(scope_condition.1);
#[derive(FromQueryResult)]
struct DataRow { data: serde_json::Value }
let rows = DataRow::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.all(db)
.await?;
Ok(rows.into_iter().map(|r| r.data).collect())
}
/// 批量导入数据(逐行校验 + 逐行插入)
pub async fn import(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
operator_id: Uuid,
rows: Vec<serde_json::Value>,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AppResult<crate::data_dto::ImportResult> {
use crate::data_dto::{ImportResult, ImportRowError};
if rows.len() > 1000 {
return Err(AppError::Validation("单次导入上限 1000 行".to_string()));
}
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let fields = info.fields()?;
let mut success_count = 0usize;
let mut row_errors: Vec<ImportRowError> = Vec::new();
for (i, row_data) in rows.iter().enumerate() {
if let Err(e) = validate_data(row_data, &fields) {
row_errors.push(ImportRowError { row: i, errors: vec![e.to_string()] });
continue;
}
if let Err(e) = validate_ref_entities(row_data, &fields, entity_name, plugin_id, tenant_id, db, true, None).await {
row_errors.push(ImportRowError { row: i, errors: vec![e.to_string()] });
continue;
}
let (sql, values) =
DynamicTableManager::build_insert_sql(&info.table_name, tenant_id, operator_id, row_data);
let result = db.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
)).await;
match result {
Ok(_) => success_count += 1,
Err(e) => {
row_errors.push(ImportRowError { row: i, errors: vec![format!("写入失败: {}", e)] });
}
}
}
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "plugin.data.import", entity_name),
db,
)
.await;
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await {
emit_trigger_events(
&triggers, "create", entity_name,
&format!("batch_import:{}", success_count),
tenant_id, None, event_bus, db,
).await;
}
Ok(ImportResult {
success_count,
error_count: row_errors.len(),
errors: row_errors,
})
}
/// 批量操作 — batch_delete / batch_update
pub async fn batch(
plugin_id: Uuid,

View File

@@ -9,7 +9,8 @@ use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::data_dto::{
AggregateItem, AggregateMultiReq, AggregateMultiRow, AggregateQueryParams, BatchActionReq,
CountQueryParams, CreatePluginDataReq, PatchPluginDataReq, PluginDataListParams,
CountQueryParams, CreatePluginDataReq, ExportParams, ImportReq, ImportResult,
PatchPluginDataReq, PluginDataListParams,
PluginDataResp, PublicEntityResp, ResolveLabelsReq, ResolveLabelsResp,
TimeseriesItem, TimeseriesParams, UpdatePluginDataReq,
};
@@ -780,3 +781,95 @@ where
Ok(Json(ApiResponse::ok(result)))
}
// ─── 数据导入导出 ──────────────────────────────────────────────────────
#[utoipa::path(
get,
path = "/api/v1/plugins/{plugin_id}/{entity}/export",
params(ExportParams),
responses(
(status = 200, description = "导出成功"),
),
security(("bearer_auth" = [])),
tag = "插件数据"
)]
/// GET /api/v1/plugins/{plugin_id}/{entity}/export — 导出数据
pub async fn export_plugin_data<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity)): Path<(Uuid, String)>,
Query(params): Query<ExportParams>,
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
require_permission(&ctx, &fine_perm)?;
let scope = resolve_data_scope(
&ctx, &manifest_id, &entity, &fine_perm, &state.db,
).await?;
let filter: Option<serde_json::Value> = params
.filter
.as_ref()
.and_then(|f| serde_json::from_str(f).ok());
let rows = PluginDataService::export(
plugin_id,
&entity,
ctx.tenant_id,
&state.db,
filter,
params.search,
params.sort_by,
params.sort_order,
&state.entity_cache,
scope,
)
.await?;
Ok(Json(ApiResponse::ok(rows)))
}
#[utoipa::path(
post,
path = "/api/v1/plugins/{plugin_id}/{entity}/import",
request_body = ImportReq,
responses(
(status = 200, description = "导入完成", body = ApiResponse<ImportResult>),
),
security(("bearer_auth" = [])),
tag = "插件数据"
)]
/// POST /api/v1/plugins/{plugin_id}/{entity}/import — 导入数据
pub async fn import_plugin_data<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity)): Path<(Uuid, String)>,
Json(req): Json<ImportReq>,
) -> Result<Json<ApiResponse<ImportResult>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "create");
require_permission(&ctx, &fine_perm)?;
let result = PluginDataService::import(
plugin_id,
&entity,
ctx.tenant_id,
ctx.user_id,
req.rows,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -113,6 +113,15 @@ impl PluginModule {
.route(
"/plugins/{plugin_id}/{entity}/resolve-labels",
post(crate::handler::data_handler::resolve_ref_labels::<S>),
)
// 数据导入导出
.route(
"/plugins/{plugin_id}/{entity}/export",
get(crate::handler::data_handler::export_plugin_data::<S>),
)
.route(
"/plugins/{plugin_id}/{entity}/import",
post(crate::handler::data_handler::import_plugin_data::<S>),
);
// 实体注册表路由