diff --git a/apps/web/src/api/pluginData.ts b/apps/web/src/api/pluginData.ts index 1b66b82..4227f7e 100644 --- a/apps/web/src/api/pluginData.ts +++ b/apps/web/src/api/pluginData.ts @@ -209,3 +209,54 @@ export async function getPluginEntityRegistry(): Promise { ); return data.data; } + +// ─── 数据导入导出 API ────────────────────────────────────────────────── + +export interface ExportOptions { + filter?: Record; + search?: string; + sort_by?: string; + sort_order?: 'asc' | 'desc'; + format?: 'csv' | 'json'; +} + +export async function exportPluginData( + pluginId: string, + entity: string, + options?: ExportOptions, +): Promise[]> { + const params: Record = {}; + if (options?.filter) params.filter = JSON.stringify(options.filter); + if (options?.search) params.search = options.search; + if (options?.sort_by) params.sort_by = options.sort_by; + if (options?.sort_order) params.sort_order = options.sort_order; + + const { data } = await client.get<{ success: boolean; data: Record[] }>( + `/plugins/${pluginId}/${entity}/export`, + { params }, + ); + return data.data; +} + +export interface ImportRowError { + row: number; + errors: string[]; +} + +export interface ImportResult { + success_count: number; + error_count: number; + errors: ImportRowError[]; +} + +export async function importPluginData( + pluginId: string, + entity: string, + rows: Record[], +): Promise { + const { data } = await client.post<{ success: boolean; data: ImportResult }>( + `/plugins/${pluginId}/${entity}/import`, + { rows }, + ); + return data.data; +} diff --git a/apps/web/src/components/PluginSettingsForm.tsx b/apps/web/src/components/PluginSettingsForm.tsx index fed9fa6..1ed8477 100644 --- a/apps/web/src/components/PluginSettingsForm.tsx +++ b/apps/web/src/components/PluginSettingsForm.tsx @@ -92,7 +92,7 @@ const PluginSettingsForm: React.FC = ({ ); - const rules: Array<{ required: boolean; message?: string; type?: string }> = []; + const rules: Array<{ required: boolean; message?: string; type?: 'string' | 'number' | 'boolean' | 'url' | 'email' }> = []; if (field.required) { rules.push({ required: true, message: `请输入${field.display_name}` }); } @@ -124,7 +124,7 @@ const PluginSettingsForm: React.FC = ({ {groupEntries.map(([group, groupFields], gi) => ( {group ? ( - + {group} ) : null} diff --git a/apps/web/src/pages/PluginCRUDPage.tsx b/apps/web/src/pages/PluginCRUDPage.tsx index 1520585..d988075 100644 --- a/apps/web/src/pages/PluginCRUDPage.tsx +++ b/apps/web/src/pages/PluginCRUDPage.tsx @@ -18,6 +18,8 @@ import { Descriptions, Segmented, Timeline, + Upload, + Alert, } from 'antd'; import { PlusOutlined, @@ -25,6 +27,8 @@ import { DeleteOutlined, ReloadOutlined, EyeOutlined, + DownloadOutlined, + UploadOutlined, } from '@ant-design/icons'; import { listPluginData, @@ -33,7 +37,10 @@ import { deletePluginData, batchPluginData, resolveRefLabels, + exportPluginData, + importPluginData, type PluginDataListOptions, + type ImportResult, } from '../api/pluginData'; import EntitySelect from '../components/EntitySelect'; import { @@ -105,6 +112,13 @@ export default function PluginCRUDPage({ const [allEntities, setAllEntities] = useState([]); const [allPages, setAllPages] = useState([]); + // 导入导出 + const [entityDef, setEntityDef] = useState(null); + const [importModalOpen, setImportModalOpen] = useState(false); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + const [exporting, setExporting] = useState(false); + // 从 fields 中提取 filterable 字段 const filterableFields = fields.filter((f) => f.filterable); @@ -137,6 +151,7 @@ export default function PluginCRUDPage({ if (entity) { setFields(entity.fields); setDisplayName(entity.display_name || entityName || ''); + setEntityDef(entity); } const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui; if (ui?.pages) { @@ -560,6 +575,45 @@ export default function PluginCRUDPage({ + {entityDef?.exportable && ( + + )} + {entityDef?.importable && ( + + )} )} @@ -710,6 +764,80 @@ export default function PluginCRUDPage({ {/* 详情 Drawer */} {renderDetailDrawer()} + + {/* 导入弹窗 */} + { + setImportModalOpen(false); + setImportResult(null); + }} + footer={importResult ? ( + + ) : null} + destroyOnClose + > + {importResult ? ( +
+ 0 ? 'warning' : 'success'} + message={`导入完成:成功 ${importResult.success_count} 条,失败 ${importResult.error_count} 条`} + style={{ marginBottom: 16 }} + /> + {importResult.errors.length > 0 && ( +
+

错误详情

+ {importResult.errors.map((err, i) => ( + + ))} +
+ )} +
+ ) : ( + { + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const text = e.target?.result as string; + const rows = JSON.parse(text); + if (!Array.isArray(rows)) { + message.error('文件格式错误:需要 JSON 数组'); + return; + } + setImporting(true); + const result = await importPluginData(pluginId, entityName, rows); + setImportResult(result); + if (result.success_count > 0) fetchData(); + } catch { + message.error('文件解析失败,请确认格式为 JSON 数组'); + } + setImporting(false); + }; + reader.readAsText(file); + return false; + }} + showUploadList={false} + disabled={importing} + > +

+ {importing ? '导入中...' : '点击或拖拽 JSON 文件到此处'} +

+

支持 JSON 数组格式,单次上限 1000 行

+
+ )} +
); } diff --git a/apps/web/src/pages/PluginMarket.tsx b/apps/web/src/pages/PluginMarket.tsx index 0cd135a..f4b60f1 100644 --- a/apps/web/src/pages/PluginMarket.tsx +++ b/apps/web/src/pages/PluginMarket.tsx @@ -10,7 +10,6 @@ import { Typography, Modal, Rate, - List, message, Empty, Tooltip, @@ -21,7 +20,7 @@ import { AppstoreOutlined, StarOutlined, } from '@ant-design/icons'; -import { listPlugins, installPlugin } from '../api/plugins'; +import { listPlugins } from '../api/plugins'; const { Title, Text, Paragraph } = Typography; @@ -108,7 +107,7 @@ export default function PluginMarket() { return matchSearch && matchCategory; }); - const categories = Array.from(new Set(plugins.map((p) => p.category).filter(Boolean))); + const categories = Array.from(new Set(plugins.map((p) => p.category).filter((c): c is string => Boolean(c)))); const showDetail = (plugin: MarketPlugin) => { setSelectedPlugin(plugin); diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs index a245346..241e267 100644 --- a/crates/erp-plugin/src/data_dto.rs +++ b/crates/erp-plugin/src/data_dto.rs @@ -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, + /// 搜索关键词 + pub search: Option, + /// 排序字段 + pub sort_by: Option, + /// "asc" or "desc" + pub sort_order: Option, + /// 导出格式: "csv" (默认) | "json" + pub format: Option, +} + +/// 数据导入请求 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ImportReq { + /// 导入数据行列表,每行是一个 JSON 对象 + pub rows: Vec, +} + +/// 数据导入结果 +#[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, +} + +/// 单行导入错误 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ImportRowError { + /// 行号(0-based) + pub row: usize, + /// 错误消息列表 + pub errors: Vec, +} diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs index 786654d..0425c63 100644 --- a/crates/erp-plugin/src/data_service.rs +++ b/crates/erp-plugin/src/data_service.rs @@ -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, + search: Option, + sort_by: Option, + sort_order: Option, + cache: &moka::sync::Cache, + scope: Option, + ) -> AppResult> { + 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, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AppResult { + 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 = 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, diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs index 866436a..0b38854 100644 --- a/crates/erp-plugin/src/handler/data_handler.rs +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Query(params): Query, +) -> Result>>, AppError> +where + PluginState: FromRef, + 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 = 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), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// POST /api/v1/plugins/{plugin_id}/{entity}/import — 导入数据 +pub async fn import_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + 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))) +} diff --git a/crates/erp-plugin/src/module.rs b/crates/erp-plugin/src/module.rs index aaaea26..7f86dbf 100644 --- a/crates/erp-plugin/src/module.rs +++ b/crates/erp-plugin/src/module.rs @@ -113,6 +113,15 @@ impl PluginModule { .route( "/plugins/{plugin_id}/{entity}/resolve-labels", post(crate::handler::data_handler::resolve_ref_labels::), + ) + // 数据导入导出 + .route( + "/plugins/{plugin_id}/{entity}/export", + get(crate::handler::data_handler::export_plugin_data::), + ) + .route( + "/plugins/{plugin_id}/{entity}/import", + post(crate::handler::data_handler::import_plugin_data::), ); // 实体注册表路由