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