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

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