Files
gongxue-base/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx

617 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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