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