feat(plugin): P2-4 数据导入导出 — 后端 export/import API + 前端 UI + TS 修复
- 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:
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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>),
|
||||
);
|
||||
|
||||
// 实体注册表路由
|
||||
|
||||
Reference in New Issue
Block a user