️ perf(import): 重构导入链路并支持流式批量写入

- 后端新增流式导入流水线,避免预览和导入阶段整文件驻留内存\n- 导入执行优先复用 BatchApplier 按批提交,并在批量失败时回退单行定位错误\n- 导入进度事件兼容未预扫总行数场景,沿用预览总数稳定展示进度\n- 补充导入预览、批量回退和前端进度展示的最小回归测试
This commit is contained in:
Syngnat
2026-06-17 17:26:57 +08:00
parent 4e31d47936
commit e67285fde1
5 changed files with 1113 additions and 398 deletions

View File

@@ -9,7 +9,11 @@ import ImportPreviewModal from "./ImportPreviewModal";
const mocks = vi.hoisted(() => ({
previewImportFile: vi.fn(),
importDataWithProgress: vi.fn(),
eventsOn: vi.fn(() => vi.fn()),
progressHandler: null as ((data: any) => void) | null,
eventsOn: vi.fn((_event: string, handler: (data: any) => void) => {
mocks.progressHandler = handler;
return vi.fn();
}),
eventsOff: vi.fn(),
storeState: {
connections: [
@@ -29,7 +33,8 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("../store", () => ({
useStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState),
useStore: (selector: (state: typeof mocks.storeState) => unknown) =>
selector(mocks.storeState),
}));
vi.mock("../i18n/runtime", () => ({
@@ -59,12 +64,25 @@ vi.mock("antd", async () => {
footer?: React.ReactNode;
open?: boolean;
title?: React.ReactNode;
}) => (open ? React.createElement("section", null, title, children, footer) : null);
const Table = ({ columns, dataSource }: { columns?: any[]; dataSource?: any[] }) =>
}) =>
open ? React.createElement("section", null, title, children, footer) : null;
const Table = ({
columns,
dataSource,
}: {
columns?: any[];
dataSource?: any[];
}) =>
React.createElement(
"div",
null,
columns?.map((column) => React.createElement("span", { key: column.key || column.dataIndex }, column.title)),
columns?.map((column) =>
React.createElement(
"span",
{ key: column.key || column.dataIndex },
column.title,
),
),
dataSource?.map((row, index) =>
React.createElement(
"div",
@@ -78,12 +96,24 @@ vi.mock("antd", async () => {
return {
Modal,
Table,
Alert: ({ message, description }: { message?: React.ReactNode; description?: React.ReactNode }) =>
React.createElement("div", null, message, description),
Progress: ({ percent }: { percent: number }) => React.createElement("div", null, `${percent}%`),
Button: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) =>
React.createElement("button", { onClick }, children),
Space: ({ children }: { children?: React.ReactNode }) => React.createElement("div", null, children),
Alert: ({
message,
description,
}: {
message?: React.ReactNode;
description?: React.ReactNode;
}) => React.createElement("div", null, message, description),
Progress: ({ percent }: { percent: number }) =>
React.createElement("div", null, `${percent}%`),
Button: ({
children,
onClick,
}: {
children?: React.ReactNode;
onClick?: () => void;
}) => React.createElement("button", { onClick }, children),
Space: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
};
});
@@ -99,7 +129,8 @@ vi.mock("@ant-design/icons", async () => {
const textContent = (node: any): string => {
if (node === null || node === undefined) return "";
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map((item) => textContent(item)).join("");
if (Array.isArray(node))
return node.map((item) => textContent(item)).join("");
return textContent(node.children || []);
};
@@ -146,7 +177,9 @@ describe("ImportPreviewModal i18n", () => {
expect(renderedText).toContain("Import data preview");
expect(renderedText).toContain("12 rows and 2 fields");
expect(renderedText).toContain("The first 5 rows are shown below. Start the import after confirming the data.");
expect(renderedText).toContain(
"The first 5 rows are shown below. Start the import after confirming the data.",
);
expect(renderedText).toContain("Field list:");
expect(renderedText).toContain("Data preview (first 5 rows):");
expect(renderedText).toContain("Cancel");
@@ -156,7 +189,10 @@ describe("ImportPreviewModal i18n", () => {
});
it("does not keep migrated Chinese UI literals in ImportPreviewModal source", () => {
const source = readFileSync(new URL("./ImportPreviewModal.tsx", import.meta.url), "utf8");
const source = readFileSync(
new URL("./ImportPreviewModal.tsx", import.meta.url),
"utf8",
);
expect(source).not.toContain("导入数据预览");
expect(source).not.toContain("开始导入");
@@ -166,4 +202,49 @@ describe("ImportPreviewModal i18n", () => {
expect(source).not.toContain("正在导入数据...");
expect(source).not.toContain("错误日志:");
});
it("keeps preview total when progress events omit total rows", async () => {
let resolveImport!: (value: any) => void;
mocks.importDataWithProgress.mockImplementation(
() =>
new Promise((resolve) => {
resolveImport = resolve;
}),
);
const renderer = await renderImportPreview();
const button = renderer.root
.findAllByType("button")
.find((node) => textContent(node.props.children) === "Start import");
expect(button).toBeDefined();
await act(async () => {
button?.props.onClick();
await Promise.resolve();
});
expect(mocks.progressHandler).toBeTypeOf("function");
await act(async () => {
mocks.progressHandler?.({
current: 3,
total: 0,
success: 3,
errors: 0,
totalRowsKnown: false,
});
await Promise.resolve();
});
expect(textContent(renderer.toJSON())).toContain("Processed 3 / 12 rows");
expect(textContent(renderer.toJSON())).toContain("25%");
await act(async () => {
resolveImport({
success: true,
data: { success: 3, failed: 0, total: 12, errorLogs: [] },
});
await Promise.resolve();
});
});
});