Files
gongxue-base/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx
wangziqi 8dc72d6e1b fix(admin): 编辑消息/切换会话保护、行内编辑撤销与上传进度反馈
- 编辑旧消息会删除其后全部消息时,先弹确认告知影响范围
- 有待发送附件时切换会话,先确认再丢弃(避免静默删除已上传文件)
- EditableCell 保存成功后提供 6 秒内"撤销"入口(把旧值重新保存),
  重新编辑时自动清除
- 导入向导/重新上传显示上传进度条与百分比,不再"假死"
- AI 附件上传透传进度给附件列表
- 租赁合同上传显示按钮 loading 与上传百分比
2026-08-07 17:38:21 +08:00

641 lines
24 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,
Progress,
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 [uploadPercent, setUploadPercent] = useState(0);
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);
setUploadPercent(0);
try {
const detail = await createImportRun(file, {
source: 'manual',
onProgress: (percent) => setUploadPercent(percent),
});
await loadRun(detail.id);
message.success(`已识别 ${detail.sheets.length} 个工作表`);
} catch (error) {
message.error(error instanceof Error ? error.message : '文件上传失败');
} finally {
setUploading(false);
setUploadPercent(0);
}
};
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);
setUploadPercent(0);
try {
const detail = await createImportRun(file, {
source: 'manual',
stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }],
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
onProgress: (percent) => setUploadPercent(percent),
});
await loadRun(detail.id);
message.success('已重新上传,并保留原列映射');
} catch (error) {
message.error(error instanceof Error ? error.message : '重新上传失败');
} finally {
setUploading(false);
setUploadPercent(0);
}
};
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
title="上传 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>
{uploading ? (
<Flex vertical gap={4} style={{ marginTop: 8 }}>
<Progress
percent={uploadPercent}
size="small"
status={uploadPercent > 0 && uploadPercent < 100 ? 'active' : 'normal'}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, textAlign: 'center' }}>
{uploadPercent > 0 && uploadPercent < 100
? `正在上传 ${uploadPercent}%...`
: '正在上传并解析文件...'}
</Typography.Text>
</Flex>
) : null}
</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 title="全部阶段已提交完成" />
<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 title="当前没有可处理的阶段" />
)}
</Space>
)}
</Modal>
);
};