feat: 新增通用导入中心
This commit is contained in:
616
apps/admin/src/components/ImportWizard/ImportWizardModal.tsx
Normal file
616
apps/admin/src/components/ImportWizard/ImportWizardModal.tsx
Normal file
@@ -0,0 +1,616 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
DownloadOutlined,
|
||||
InboxOutlined,
|
||||
ReloadOutlined,
|
||||
StepForwardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Flex,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Steps,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import {
|
||||
commitImportStep,
|
||||
createImportRun,
|
||||
getImportRun,
|
||||
importErrorReportUrl,
|
||||
previewImportStep,
|
||||
} from '../../api/imports';
|
||||
import {
|
||||
STEP_FIELDS,
|
||||
type ImportPreviewResult,
|
||||
type ImportReceipt,
|
||||
type ImportRunDetail,
|
||||
type ImportStepKey,
|
||||
} from './types';
|
||||
|
||||
interface ImportWizardModalProps {
|
||||
open: boolean;
|
||||
/** AI 对话生成的导入任务;为空时向导从上传文件开始。 */
|
||||
runId?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type RowAction = 'create' | 'update' | 'skip';
|
||||
|
||||
const ACTION_META: Record<RowAction, { label: string; color: string }> = {
|
||||
create: { label: '新建', color: 'blue' },
|
||||
update: { label: '更新', color: 'orange' },
|
||||
skip: { label: '跳过', color: 'default' },
|
||||
};
|
||||
|
||||
function guessMapping(stepKey: ImportStepKey, headers: string[]): Record<string, string> {
|
||||
const mapping: Record<string, string> = {};
|
||||
for (const field of STEP_FIELDS[stepKey]) {
|
||||
const hit = headers.find((header) => {
|
||||
const normalizedHeader = header.replace(/[\s()()]/g, '').toLowerCase();
|
||||
const normalizedLabel = field.label.replace(/[\s()()]/g, '').toLowerCase();
|
||||
return (
|
||||
normalizedHeader === normalizedLabel ||
|
||||
normalizedHeader.includes(normalizedLabel) ||
|
||||
normalizedLabel.includes(normalizedHeader)
|
||||
);
|
||||
});
|
||||
if (hit) mapping[field.key] = hit;
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function keyInfo(stepKey: ImportStepKey, row: ImportPreviewResult['rows'][number]): string {
|
||||
const fields = row.fields;
|
||||
if (stepKey === 'students') {
|
||||
return [fields.name, fields.studentNo, fields.phone].filter(Boolean).join(' / ');
|
||||
}
|
||||
if (stepKey === 'rooms') {
|
||||
return [fields.roomNumber, fields.building, fields.floor].filter(Boolean).join(' / ');
|
||||
}
|
||||
if (stepKey === 'checkins') {
|
||||
return [fields.name, fields.studentNo, fields.phone, fields.roomNumber, fields.checkInDate]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
}
|
||||
return [fields.studentNo, fields.phone, fields.oldRoom, fields.newRoom, fields.transferDate]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
}
|
||||
|
||||
async function downloadErrorReport(runId: string, stepKey?: ImportStepKey): Promise<void> {
|
||||
const token = useUserStore.getState().token;
|
||||
const response = await fetch(importErrorReportUrl(runId, stepKey), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
if (!response.ok) throw new Error('错误报告下载失败');
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `导入错误报告-${runId.slice(0, 8)}.csv`;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
||||
open,
|
||||
runId: initialRunId,
|
||||
onClose,
|
||||
}) => {
|
||||
const [run, setRun] = useState<ImportRunDetail | null>(null);
|
||||
const [loadingRun, setLoadingRun] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [activeStepKey, setActiveStepKey] = useState<ImportStepKey | null>(null);
|
||||
const [sheetSelection, setSheetSelection] = useState<Record<string, string[]>>({});
|
||||
const [mappingDraft, setMappingDraft] = useState<Record<string, Record<string, string>>>({});
|
||||
const [previewByStep, setPreviewByStep] = useState<Record<string, ImportPreviewResult>>({});
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [onlyErrors, setOnlyErrors] = useState(false);
|
||||
const [rowActions, setRowActions] = useState<Record<number, RowAction>>({});
|
||||
const [commitLoading, setCommitLoading] = useState(false);
|
||||
const [receipt, setReceipt] = useState<ImportReceipt | null>(null);
|
||||
const [reportDownloading, setReportDownloading] = useState(false);
|
||||
const requestSeq = useRef(0);
|
||||
|
||||
const loadRun = useCallback(async (runId: string) => {
|
||||
const seq = ++requestSeq.current;
|
||||
setLoadingRun(true);
|
||||
try {
|
||||
const detail = await getImportRun(runId);
|
||||
if (seq !== requestSeq.current) return;
|
||||
setRun(detail);
|
||||
const selections: Record<string, string[]> = {};
|
||||
const mappings: Record<string, Record<string, string>> = {};
|
||||
const sheetHeaders = new Map(detail.sheets.map((sheet) => [sheet.name, sheet.headers]));
|
||||
for (const step of detail.steps) {
|
||||
selections[step.stepKey] = step.sheets;
|
||||
const mapped =
|
||||
step.mapping && Object.keys(step.mapping).length > 0
|
||||
? step.mapping
|
||||
: guessMapping(step.stepKey, sheetHeaders.get(step.sheets[0] ?? '') ?? []);
|
||||
mappings[step.stepKey] = mapped;
|
||||
}
|
||||
setSheetSelection(selections);
|
||||
setMappingDraft(mappings);
|
||||
setPreviewByStep({});
|
||||
setRowActions({});
|
||||
setReceipt(null);
|
||||
const firstActive =
|
||||
detail.steps.find((step) => step.status !== 'skipped' && step.status !== 'committed') ??
|
||||
detail.steps.find((step) => step.status !== 'skipped');
|
||||
setActiveStepKey(firstActive?.stepKey ?? detail.currentStepKey);
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq.current) return;
|
||||
message.error(error instanceof Error ? error.message : '导入任务加载失败');
|
||||
setRun(null);
|
||||
} finally {
|
||||
if (seq === requestSeq.current) setLoadingRun(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !initialRunId) return;
|
||||
void loadRun(initialRunId);
|
||||
}, [open, initialRunId, loadRun]);
|
||||
|
||||
const activeStep = useMemo(
|
||||
() => run?.steps.find((step) => step.stepKey === activeStepKey) ?? null,
|
||||
[run, activeStepKey],
|
||||
);
|
||||
const preview = activeStepKey ? (previewByStep[activeStepKey] ?? null) : null;
|
||||
|
||||
const sheetOptions = useMemo(() => (run?.sheets ?? []).map((sheet) => sheet.name), [run]);
|
||||
const headerOptions = useMemo(() => {
|
||||
if (!run || !activeStepKey) return [];
|
||||
const names = sheetSelection[activeStepKey] ?? [];
|
||||
const headers = new Set<string>();
|
||||
for (const sheet of run.sheets) {
|
||||
if (names.includes(sheet.name)) sheet.headers.forEach((header) => headers.add(header));
|
||||
}
|
||||
return [...headers];
|
||||
}, [run, activeStepKey, sheetSelection]);
|
||||
|
||||
const handleUpload: UploadProps['customRequest'] = async (options) => {
|
||||
const file = options.file as File;
|
||||
setUploading(true);
|
||||
try {
|
||||
const detail = await createImportRun(file, { source: 'manual' });
|
||||
await loadRun(detail.id);
|
||||
message.success(`已识别 ${detail.sheets.length} 个工作表`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '文件上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!run || !activeStepKey || !activeStep) return;
|
||||
const mapping = mappingDraft[activeStepKey] ?? {};
|
||||
const required = STEP_FIELDS[activeStepKey]
|
||||
.filter((field) => field.required)
|
||||
.map((field) => field.key);
|
||||
const missing = required.filter((key) => !mapping[key]);
|
||||
if (missing.length > 0) {
|
||||
message.warning('请先完成必填列的映射');
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const result = await previewImportStep(run.id, activeStepKey, {
|
||||
sheets: sheetSelection[activeStepKey] ?? [],
|
||||
mapping,
|
||||
});
|
||||
setPreviewByStep((prev) => ({ ...prev, [activeStepKey]: result }));
|
||||
setRowActions({});
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '预览失败');
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!run || !activeStepKey || !preview) return;
|
||||
setCommitLoading(true);
|
||||
try {
|
||||
const decisions = preview.rows
|
||||
.filter((row) => row.status === 'valid')
|
||||
.map((row) => ({ rowId: row.id, action: rowActions[row.id] ?? row.action ?? 'create' }));
|
||||
const result = await commitImportStep(run.id, activeStepKey, decisions);
|
||||
setReceipt(result);
|
||||
const refreshed = await getImportRun(run.id);
|
||||
setRun(refreshed);
|
||||
setPreviewByStep({});
|
||||
setRowActions({});
|
||||
if (result.nextStepKey) {
|
||||
setActiveStepKey(result.nextStepKey);
|
||||
const nextStep = refreshed.steps.find((step) => step.stepKey === result.nextStepKey);
|
||||
setMappingDraft((prev) => ({
|
||||
...prev,
|
||||
[result.nextStepKey as string]: nextStep?.mapping ?? {},
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '提交失败');
|
||||
} finally {
|
||||
setCommitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReupload = async (file: File) => {
|
||||
if (!run || !activeStepKey) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const detail = await createImportRun(file, {
|
||||
source: 'manual',
|
||||
stages: [{ stepKey: activeStepKey, sheet: sheetSelection[activeStepKey]?.[0] }],
|
||||
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
|
||||
});
|
||||
await loadRun(detail.id);
|
||||
message.success('已重新上传,并保留原列映射');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '重新上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewRows = useMemo(() => {
|
||||
if (!preview) return [];
|
||||
return onlyErrors ? preview.rows.filter((row) => row.status === 'error') : preview.rows;
|
||||
}, [preview, onlyErrors]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
if (!activeStepKey) return [];
|
||||
return [
|
||||
{ title: '行号', dataIndex: 'rowNumber', width: 70 },
|
||||
{ title: '工作表', dataIndex: 'sheetName', width: 120, ellipsis: true },
|
||||
{
|
||||
title: '数据',
|
||||
key: 'data',
|
||||
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
|
||||
keyInfo(activeStepKey, row),
|
||||
},
|
||||
{
|
||||
title: '判定',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
render: (_: unknown, row: ImportPreviewResult['rows'][number]) => {
|
||||
const action = rowActions[row.id] ?? row.action;
|
||||
return action ? (
|
||||
<Tag color={ACTION_META[action].color}>{ACTION_META[action].label}</Tag>
|
||||
) : (
|
||||
'-'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
|
||||
row.status === 'error' ? (
|
||||
<Tag color="red" icon={<CloseCircleOutlined />}>
|
||||
错误
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="green" icon={<CheckCircleOutlined />}>
|
||||
有效
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '错误信息',
|
||||
key: 'errors',
|
||||
ellipsis: true,
|
||||
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
|
||||
row.errors.length > 0 ? (
|
||||
<Tooltip title={row.errors.join(';')}>
|
||||
<Typography.Text type="danger" style={{ maxWidth: 320 }}>
|
||||
{row.errors.join(';')}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: '处理方式',
|
||||
key: 'decision',
|
||||
width: 120,
|
||||
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
|
||||
row.status === 'valid' ? (
|
||||
<Select
|
||||
size="small"
|
||||
value={rowActions[row.id] ?? row.action ?? 'create'}
|
||||
options={
|
||||
row.action === 'update'
|
||||
? [
|
||||
{ value: 'update', label: '更新' },
|
||||
{ value: 'skip', label: '跳过' },
|
||||
]
|
||||
: [
|
||||
{ value: 'create', label: '新建' },
|
||||
{ value: 'skip', label: '跳过' },
|
||||
]
|
||||
}
|
||||
onChange={(value: RowAction) =>
|
||||
setRowActions((prev) => ({ ...prev, [row.id]: value }))
|
||||
}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
}, [activeStepKey, rowActions]);
|
||||
|
||||
const stageItems = useMemo(
|
||||
() =>
|
||||
(run?.steps ?? [])
|
||||
.filter((step) => step.status !== 'skipped')
|
||||
.map((step) => ({
|
||||
key: step.stepKey,
|
||||
title: step.label,
|
||||
status:
|
||||
step.status === 'committed'
|
||||
? ('finish' as const)
|
||||
: step.stepKey === activeStepKey
|
||||
? ('process' as const)
|
||||
: ('wait' as const),
|
||||
})),
|
||||
[run, activeStepKey],
|
||||
);
|
||||
|
||||
const allCommitted = run?.status === 'committed';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={980}
|
||||
title="Excel 批量导入向导"
|
||||
destroyOnHidden={false}
|
||||
>
|
||||
{!run && !loadingRun ? (
|
||||
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="上传 Excel 后,系统会自动识别工作表并按业务依赖分阶段(学生/宿舍 → 入住/换宿)。每一阶段都需要先预览、再确认,确认后才会写入数据库。"
|
||||
/>
|
||||
<Upload.Dragger
|
||||
accept=".xlsx,.csv"
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
disabled={uploading}
|
||||
customRequest={handleUpload}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽 .xlsx / .csv 文件到此区域</p>
|
||||
<p className="ant-upload-hint">单文件不超过 10MB;.xls 请先另存为 .xlsx</p>
|
||||
</Upload.Dragger>
|
||||
</Space>
|
||||
) : (
|
||||
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Flex justify="space-between" align="center" wrap gap={8}>
|
||||
<Space wrap>
|
||||
<Typography.Text strong>{run?.fileName}</Typography.Text>
|
||||
<Tag color={allCommitted ? 'success' : 'processing'}>
|
||||
{allCommitted ? '已完成' : '待处理'}
|
||||
</Tag>
|
||||
<Tag>当前阶段:{activeStep?.label ?? '—'}</Tag>
|
||||
</Space>
|
||||
<Space>
|
||||
{preview && preview.summary.error > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={reportDownloading}
|
||||
onClick={() => {
|
||||
setReportDownloading(true);
|
||||
void downloadErrorReport(run?.id ?? '', activeStepKey ?? undefined)
|
||||
.then(() => message.success('错误报告已下载'))
|
||||
.catch((error: unknown) =>
|
||||
message.error(error instanceof Error ? error.message : '下载失败'),
|
||||
)
|
||||
.finally(() => setReportDownloading(false));
|
||||
}}
|
||||
>
|
||||
下载错误报告
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<Steps
|
||||
size="small"
|
||||
items={stageItems}
|
||||
onChange={(index) => {
|
||||
const step = (run?.steps ?? []).filter((s) => s.status !== 'skipped')[index];
|
||||
if (step) setActiveStepKey(step.stepKey);
|
||||
}}
|
||||
/>
|
||||
|
||||
{loadingRun ? (
|
||||
<Flex justify="center" style={{ padding: 32 }}>
|
||||
<Spin description="正在加载导入任务..." />
|
||||
</Flex>
|
||||
) : allCommitted ? (
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert type="success" showIcon message="全部阶段已提交完成" />
|
||||
<Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
column={2}
|
||||
items={(run?.steps ?? [])
|
||||
.filter((step) => step.status !== 'skipped')
|
||||
.map((step) => ({
|
||||
key: step.stepKey,
|
||||
label: step.label,
|
||||
children: step.summary
|
||||
? `新建 ${step.summary.create} / 更新 ${step.summary.update} / 跳过 ${step.summary.skip} / 失败 ${step.summary.error}`
|
||||
: '—',
|
||||
}))}
|
||||
/>
|
||||
<Button type="primary" onClick={onClose}>
|
||||
完成
|
||||
</Button>
|
||||
</Space>
|
||||
) : activeStep ? (
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
{receipt && (
|
||||
<Alert
|
||||
type={receipt.status === 'committed' ? 'success' : 'warning'}
|
||||
showIcon
|
||||
title={receipt.message}
|
||||
closable
|
||||
onClose={() => setReceipt(null)}
|
||||
/>
|
||||
)}
|
||||
{activeStep.status === 'committed' ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
title={`「${activeStep.label}」已提交`}
|
||||
description={
|
||||
activeStep.summary
|
||||
? `新建 ${activeStep.summary.create} / 更新 ${activeStep.summary.update} / 跳过 ${activeStep.summary.skip} / 失败 ${activeStep.summary.error}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : preview ? (
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Flex wrap gap={12} align="center">
|
||||
<Space size={4}>
|
||||
<Tag color="blue">共 {preview.summary.total} 行</Tag>
|
||||
<Tag color="green">有效 {preview.summary.valid}</Tag>
|
||||
<Tag color="red">错误 {preview.summary.error}</Tag>
|
||||
<Tag color="blue">新建 {preview.summary.create}</Tag>
|
||||
<Tag color="orange">更新 {preview.summary.update}</Tag>
|
||||
</Space>
|
||||
<Checkbox
|
||||
checked={onlyErrors}
|
||||
onChange={(e) => setOnlyErrors(e.target.checked)}
|
||||
>
|
||||
只看错误行
|
||||
</Checkbox>
|
||||
</Flex>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={previewRows}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
<Flex justify="end" gap={8}>
|
||||
<Upload
|
||||
accept=".xlsx,.csv"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
void handleReupload(file);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button icon={<ReloadOutlined />} loading={uploading}>
|
||||
重新上传并保留映射
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<StepForwardOutlined />}
|
||||
loading={commitLoading}
|
||||
onClick={() => void handleCommit()}
|
||||
>
|
||||
确认提交本阶段
|
||||
</Button>
|
||||
</Flex>
|
||||
</Space>
|
||||
) : (
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
title={`配置「${activeStep.label}」阶段`}
|
||||
description="选择该阶段使用的工作表,并确认列映射;系统会按“学号/手机号/宿舍号”自动区分新建或更新。"
|
||||
/>
|
||||
<Flex align="center" gap={8}>
|
||||
<Typography.Text style={{ width: 120 }}>工作表</Typography.Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ minWidth: 320, flex: 1 }}
|
||||
placeholder="选择该阶段的工作表"
|
||||
value={sheetSelection[activeStepKey ?? ''] ?? []}
|
||||
options={sheetOptions.map((name) => ({ value: name, label: name }))}
|
||||
onChange={(values: string[]) =>
|
||||
setSheetSelection((prev) => ({
|
||||
...prev,
|
||||
[activeStepKey ?? '']: values,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
{STEP_FIELDS[activeStep.stepKey].map((field) => (
|
||||
<Flex key={field.key} align="center" gap={8}>
|
||||
<Typography.Text style={{ width: 120 }}>
|
||||
{field.label}
|
||||
{field.required ? <span style={{ color: '#ff4d4f' }}> *</span> : null}
|
||||
{field.identity ? <Tag style={{ marginLeft: 4 }}>匹配键</Tag> : null}
|
||||
</Typography.Text>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ minWidth: 320, flex: 1 }}
|
||||
placeholder="选择对应列(留空则自动识别)"
|
||||
value={mappingDraft[activeStepKey ?? '']?.[field.key]}
|
||||
options={headerOptions.map((header) => ({ value: header, label: header }))}
|
||||
onChange={(value?: string) =>
|
||||
setMappingDraft((prev) => {
|
||||
const current = { ...prev[activeStepKey ?? ''] };
|
||||
if (value) current[field.key] = value;
|
||||
else delete current[field.key];
|
||||
return { ...prev, [activeStepKey ?? '']: current };
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
))}
|
||||
<Flex justify="end">
|
||||
<Button
|
||||
type="primary"
|
||||
loading={previewLoading}
|
||||
onClick={() => void handlePreview()}
|
||||
>
|
||||
开始校验预览
|
||||
</Button>
|
||||
</Flex>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
) : (
|
||||
<Alert type="warning" showIcon message="当前没有可处理的阶段" />
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user