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;
|
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>
|
</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) {
|
if (field.required) {
|
||||||
rules.push({ required: true, message: `请输入${field.display_name}` });
|
rules.push({ required: true, message: `请输入${field.display_name}` });
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ const PluginSettingsForm: React.FC<PluginSettingsFormProps> = ({
|
|||||||
{groupEntries.map(([group, groupFields], gi) => (
|
{groupEntries.map(([group, groupFields], gi) => (
|
||||||
<React.Fragment key={group || `__default_${gi}`}>
|
<React.Fragment key={group || `__default_${gi}`}>
|
||||||
{group ? (
|
{group ? (
|
||||||
<Divider orientation="left" orientationMargin={0}>
|
<Divider type="horizontal" orientationMargin={0} plain>
|
||||||
<Text strong>{group}</Text>
|
<Text strong>{group}</Text>
|
||||||
</Divider>
|
</Divider>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
Descriptions,
|
Descriptions,
|
||||||
Segmented,
|
Segmented,
|
||||||
Timeline,
|
Timeline,
|
||||||
|
Upload,
|
||||||
|
Alert,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -25,6 +27,8 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
UploadOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
listPluginData,
|
listPluginData,
|
||||||
@@ -33,7 +37,10 @@ import {
|
|||||||
deletePluginData,
|
deletePluginData,
|
||||||
batchPluginData,
|
batchPluginData,
|
||||||
resolveRefLabels,
|
resolveRefLabels,
|
||||||
|
exportPluginData,
|
||||||
|
importPluginData,
|
||||||
type PluginDataListOptions,
|
type PluginDataListOptions,
|
||||||
|
type ImportResult,
|
||||||
} from '../api/pluginData';
|
} from '../api/pluginData';
|
||||||
import EntitySelect from '../components/EntitySelect';
|
import EntitySelect from '../components/EntitySelect';
|
||||||
import {
|
import {
|
||||||
@@ -105,6 +112,13 @@ export default function PluginCRUDPage({
|
|||||||
const [allEntities, setAllEntities] = useState<PluginEntitySchema[]>([]);
|
const [allEntities, setAllEntities] = useState<PluginEntitySchema[]>([]);
|
||||||
const [allPages, setAllPages] = useState<PluginPageSchema[]>([]);
|
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 字段
|
// 从 fields 中提取 filterable 字段
|
||||||
const filterableFields = fields.filter((f) => f.filterable);
|
const filterableFields = fields.filter((f) => f.filterable);
|
||||||
|
|
||||||
@@ -137,6 +151,7 @@ export default function PluginCRUDPage({
|
|||||||
if (entity) {
|
if (entity) {
|
||||||
setFields(entity.fields);
|
setFields(entity.fields);
|
||||||
setDisplayName(entity.display_name || entityName || '');
|
setDisplayName(entity.display_name || entityName || '');
|
||||||
|
setEntityDef(entity);
|
||||||
}
|
}
|
||||||
const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui;
|
const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui;
|
||||||
if (ui?.pages) {
|
if (ui?.pages) {
|
||||||
@@ -560,6 +575,45 @@ export default function PluginCRUDPage({
|
|||||||
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
|
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</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>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -710,6 +764,80 @@ export default function PluginCRUDPage({
|
|||||||
|
|
||||||
{/* 详情 Drawer */}
|
{/* 详情 Drawer */}
|
||||||
{renderDetailDrawer()}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Modal,
|
Modal,
|
||||||
Rate,
|
Rate,
|
||||||
List,
|
|
||||||
message,
|
message,
|
||||||
Empty,
|
Empty,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -21,7 +20,7 @@ import {
|
|||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
StarOutlined,
|
StarOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { listPlugins, installPlugin } from '../api/plugins';
|
import { listPlugins } from '../api/plugins';
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
@@ -108,7 +107,7 @@ export default function PluginMarket() {
|
|||||||
return matchSearch && matchCategory;
|
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) => {
|
const showDetail = (plugin: MarketPlugin) => {
|
||||||
setSelectedPlugin(plugin);
|
setSelectedPlugin(plugin);
|
||||||
|
|||||||
@@ -164,3 +164,48 @@ pub struct PublicEntityResp {
|
|||||||
pub entity_name: String,
|
pub entity_name: String,
|
||||||
pub display_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(())
|
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
|
/// 批量操作 — batch_delete / batch_update
|
||||||
pub async fn batch(
|
pub async fn batch(
|
||||||
plugin_id: Uuid,
|
plugin_id: Uuid,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
|||||||
|
|
||||||
use crate::data_dto::{
|
use crate::data_dto::{
|
||||||
AggregateItem, AggregateMultiReq, AggregateMultiRow, AggregateQueryParams, BatchActionReq,
|
AggregateItem, AggregateMultiReq, AggregateMultiRow, AggregateQueryParams, BatchActionReq,
|
||||||
CountQueryParams, CreatePluginDataReq, PatchPluginDataReq, PluginDataListParams,
|
CountQueryParams, CreatePluginDataReq, ExportParams, ImportReq, ImportResult,
|
||||||
|
PatchPluginDataReq, PluginDataListParams,
|
||||||
PluginDataResp, PublicEntityResp, ResolveLabelsReq, ResolveLabelsResp,
|
PluginDataResp, PublicEntityResp, ResolveLabelsReq, ResolveLabelsResp,
|
||||||
TimeseriesItem, TimeseriesParams, UpdatePluginDataReq,
|
TimeseriesItem, TimeseriesParams, UpdatePluginDataReq,
|
||||||
};
|
};
|
||||||
@@ -780,3 +781,95 @@ where
|
|||||||
|
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
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(
|
.route(
|
||||||
"/plugins/{plugin_id}/{entity}/resolve-labels",
|
"/plugins/{plugin_id}/{entity}/resolve-labels",
|
||||||
post(crate::handler::data_handler::resolve_ref_labels::<S>),
|
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