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);
|
||||
|
||||
Reference in New Issue
Block a user