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

@@ -209,3 +209,54 @@ export async function getPluginEntityRegistry(): Promise<PublicEntity[]> {
);
return data.data;
}
// ─── 数据导入导出 API ──────────────────────────────────────────────────
export interface ExportOptions {
filter?: Record<string, string>;
search?: string;
sort_by?: string;
sort_order?: 'asc' | 'desc';
format?: 'csv' | 'json';
}
export async function exportPluginData(
pluginId: string,
entity: string,
options?: ExportOptions,
): Promise<Record<string, unknown>[]> {
const params: Record<string, string> = {};
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<string, unknown>[] }>(
`/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<string, unknown>[],
): Promise<ImportResult> {
const { data } = await client.post<{ success: boolean; data: ImportResult }>(
`/plugins/${pluginId}/${entity}/import`,
{ rows },
);
return data.data;
}

View File

@@ -92,7 +92,7 @@ const PluginSettingsForm: React.FC<PluginSettingsFormProps> = ({
</span>
);
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<PluginSettingsFormProps> = ({
{groupEntries.map(([group, groupFields], gi) => (
<React.Fragment key={group || `__default_${gi}`}>
{group ? (
<Divider orientation="left" orientationMargin={0}>
<Divider type="horizontal" orientationMargin={0} plain>
<Text strong>{group}</Text>
</Divider>
) : null}

View File

@@ -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<PluginEntitySchema[]>([]);
const [allPages, setAllPages] = useState<PluginPageSchema[]>([]);
// 导入导出
const [entityDef, setEntityDef] = useState<PluginEntitySchema | null>(null);
const [importModalOpen, setImportModalOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(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({
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
</Button>
{entityDef?.exportable && (
<Button
icon={<DownloadOutlined />}
loading={exporting}
onClick={async () => {
setExporting(true);
try {
const rows = await exportPluginData(pluginId, entityName, {
sort_by: sortBy,
sort_order: sortOrder,
});
const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${entityName}_export_${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
message.success(`导出 ${rows.length} 条记录`);
} catch {
message.error('导出失败');
}
setExporting(false);
}}
>
</Button>
)}
{entityDef?.importable && (
<Button
icon={<UploadOutlined />}
onClick={() => {
setImportResult(null);
setImportModalOpen(true);
}}
>
</Button>
)}
</Space>
</div>
)}
@@ -710,6 +764,80 @@ export default function PluginCRUDPage({
{/* 详情 Drawer */}
{renderDetailDrawer()}
{/* 导入弹窗 */}
<Modal
title="导入数据"
open={importModalOpen}
onCancel={() => {
setImportModalOpen(false);
setImportResult(null);
}}
footer={importResult ? (
<Button onClick={() => { setImportModalOpen(false); setImportResult(null); }}>
</Button>
) : null}
destroyOnClose
>
{importResult ? (
<div>
<Alert
type={importResult.error_count > 0 ? 'warning' : 'success'}
message={`导入完成:成功 ${importResult.success_count} 条,失败 ${importResult.error_count}`}
style={{ marginBottom: 16 }}
/>
{importResult.errors.length > 0 && (
<div>
<h4></h4>
{importResult.errors.map((err, i) => (
<Alert
key={i}
type="error"
message={`${err.row + 1}`}
description={err.errors.join('; ')}
style={{ marginBottom: 8 }}
/>
))}
</div>
)}
</div>
) : (
<Upload.Dragger
accept=".json"
maxCount={1}
beforeUpload={(file) => {
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}
>
<p style={{ fontSize: 16, padding: '24px 0' }}>
{importing ? '导入中...' : '点击或拖拽 JSON 文件到此处'}
</p>
<p style={{ color: '#999' }}> JSON 1000 </p>
</Upload.Dragger>
)}
</Modal>
</div>
);
}

View File

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

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>),
);
// 实体注册表路由