From caf450d4f0dd64ad589efb0007803bd29ef3a2e9 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 22 Jul 2026 22:36:19 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(import):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=AF=BC=E5=85=A5=E8=BF=9B=E5=BA=A6=E4=B8=8E=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=81=9C=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正 SQL 文件执行进度提前完成及终态竞态 - 支持停止导入并保留已提交的部分统计 - 完善取消审计、多语言提示与回归测试 Refs #677 Refs #678 --- .../components/ImportPreviewModal.test.tsx | 159 ++++++++++++++++++ .../src/components/ImportPreviewModal.tsx | 70 +++++++- .../components/SQLFileExecutionWorkbench.tsx | 5 - .../useSQLFileExecutionRunner.test.tsx | 144 ++++++++++++++++ .../components/useSQLFileExecutionRunner.ts | 25 ++- internal/app/app.go | 9 +- internal/app/import_pipeline.go | 50 +++++- internal/app/methods_file.go | 120 +++++++++++-- internal/app/methods_file_i18n_test.go | 2 + internal/app/methods_file_import_test.go | 128 ++++++++++++++ .../app/methods_file_sql_execution_test.go | 23 +++ internal/app/methods_sql_audit.go | 5 + internal/app/methods_sql_audit_test.go | 11 ++ shared/i18n/de-DE.json | 7 + shared/i18n/en-US.json | 7 + shared/i18n/ja-JP.json | 7 + shared/i18n/ru-RU.json | 7 + shared/i18n/zh-CN.json | 7 + shared/i18n/zh-TW.json | 7 + 19 files changed, 757 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/ImportPreviewModal.test.tsx b/frontend/src/components/ImportPreviewModal.test.tsx index 7d6e45ca..16564ec4 100644 --- a/frontend/src/components/ImportPreviewModal.test.tsx +++ b/frontend/src/components/ImportPreviewModal.test.tsx @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ previewImportFile: vi.fn(), dbGetColumns: vi.fn(), importDataWithProgressOptions: vi.fn(), + cancelQuery: vi.fn(), progressHandler: null as ((data: any) => void) | null, eventsOn: vi.fn((_event: string, handler: (data: any) => void) => { mocks.progressHandler = handler; @@ -47,6 +48,7 @@ vi.mock("../../wailsjs/go/app/App", () => ({ PreviewImportFile: mocks.previewImportFile, DBGetColumns: mocks.dbGetColumns, ImportDataWithProgressOptions: mocks.importDataWithProgressOptions, + CancelQuery: mocks.cancelQuery, })); vi.mock("../../wailsjs/runtime/runtime", () => ({ @@ -144,6 +146,7 @@ vi.mock("@ant-design/icons", async () => { return { CheckCircleOutlined: Icon, CloseCircleOutlined: Icon, + StopOutlined: Icon, }; }); @@ -220,6 +223,8 @@ describe("ImportPreviewModal i18n", () => { ], }); mocks.importDataWithProgressOptions.mockReset(); + mocks.cancelQuery.mockReset(); + mocks.cancelQuery.mockResolvedValue({ success: true }); mocks.progressHandler = null; mocks.eventsOn.mockClear(); mocks.eventsOff.mockClear(); @@ -496,6 +501,160 @@ describe("ImportPreviewModal i18n", () => { expect(textContent(renderer.toJSON())).toContain("Row 12: duplicate key"); }); + it("stops an active import by its job id and preserves the partial result", async () => { + let resolveImport!: (value: any) => void; + mocks.importDataWithProgressOptions.mockImplementation( + () => new Promise((resolve) => { + resolveImport = resolve; + }), + ); + const renderer = await renderImportPreview(); + const startButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Start import"); + + await act(async () => { + startButton?.props.onClick(); + await Promise.resolve(); + }); + + const importJobId = mocks.importDataWithProgressOptions.mock.calls[0][4].jobId; + const stopButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Stop import"); + expect(stopButton).toBeDefined(); + + await act(async () => { + stopButton?.props.onClick(); + stopButton?.props.onClick(); + await Promise.resolve(); + }); + expect(mocks.cancelQuery).toHaveBeenCalledTimes(1); + expect(mocks.cancelQuery).toHaveBeenCalledWith(importJobId); + + await act(async () => { + resolveImport({ + success: false, + message: "Import stopped", + data: { + success: 10, + failed: 2, + total: 12, + errorLogs: ["Row 11: duplicate key", "Row 12: duplicate key"], + cancelled: true, + }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + const renderedText = textContent(renderer.toJSON()); + expect(renderedText).toContain("Import stopped"); + expect(renderedText).toContain("Successfully imported 10 rows"); + expect(renderedText).toContain("Failed 2 rows"); + }); + + it("ignores a late stop failure after the import already completed", async () => { + let resolveImport!: (value: any) => void; + let resolveCancel!: (value: any) => void; + mocks.importDataWithProgressOptions.mockImplementation( + () => new Promise((resolve) => { + resolveImport = resolve; + }), + ); + mocks.cancelQuery.mockImplementation( + () => new Promise((resolve) => { + resolveCancel = resolve; + }), + ); + const renderer = await renderImportPreview(); + const startButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Start import"); + + await act(async () => { + startButton?.props.onClick(); + await Promise.resolve(); + }); + const stopButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Stop import"); + await act(async () => { + stopButton?.props.onClick(); + await Promise.resolve(); + }); + + await act(async () => { + resolveImport({ + success: true, + data: { success: 12, failed: 0, total: 12, errorLogs: [] }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + resolveCancel({ success: false, message: "No running query" }); + await Promise.resolve(); + }); + + const renderedText = textContent(renderer.toJSON()); + expect(renderedText).toContain("Import completed"); + expect(renderedText).not.toContain("No running query"); + }); + + it("clears an earlier stop failure when stop is retried", async () => { + let resolveImport!: (value: any) => void; + mocks.importDataWithProgressOptions.mockImplementation( + () => new Promise((resolve) => { + resolveImport = resolve; + }), + ); + mocks.cancelQuery.mockResolvedValue({ success: false, message: "No running query" }); + const renderer = await renderImportPreview(); + const startButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Start import"); + + await act(async () => { + startButton?.props.onClick(); + await Promise.resolve(); + }); + const stopButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Stop import"); + await act(async () => { + stopButton?.props.onClick(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(textContent(renderer.toJSON())).toContain("No running query"); + + mocks.cancelQuery.mockResolvedValue({ success: true }); + const retryStopButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Stop import"); + await act(async () => { + retryStopButton?.props.onClick(); + await Promise.resolve(); + }); + expect(mocks.cancelQuery).toHaveBeenCalledTimes(2); + expect(textContent(renderer.toJSON())).not.toContain("No running query"); + + await act(async () => { + resolveImport({ + success: false, + message: "Import stopped", + data: { success: 10, failed: 2, total: 12, errorLogs: [], cancelled: true }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + const renderedText = textContent(renderer.toJSON()); + expect(renderedText).toContain("Import stopped"); + expect(renderedText).not.toContain("No running query"); + }); + it("preserves an RPC failure when connection state changes during import", async () => { let resolveImport!: (value: any) => void; mocks.importDataWithProgressOptions.mockImplementation( diff --git a/frontend/src/components/ImportPreviewModal.tsx b/frontend/src/components/ImportPreviewModal.tsx index f75f7215..2baa4795 100644 --- a/frontend/src/components/ImportPreviewModal.tsx +++ b/frontend/src/components/ImportPreviewModal.tsx @@ -1,8 +1,9 @@ import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useRef } from "react"; import { Table, Alert, Progress, Button, Space, Select } from 'antd'; -import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"; +import { CheckCircleOutlined, CloseCircleOutlined, StopOutlined } from "@ant-design/icons"; import { + CancelQuery, DBGetColumns, PreviewImportFile, ImportDataWithProgressOptions, @@ -69,11 +70,13 @@ const ImportPreviewModal: React.FC = ({ const [columnMappings, setColumnMappings] = useState>({}); const [error, setError] = useState(null); const [importing, setImporting] = useState(false); + const [stopping, setStopping] = useState(false); const [progress, setProgress] = useState(null); const [importResult, setImportResult] = useState(null); const previewRequestRef = useRef(0); const importRequestRef = useRef(0); const importingRef = useRef(false); + const stoppingRef = useRef(false); const activeImportJobIdRef = useRef(""); const previewConnectionConfigRef = useRef(null); const secondaryTextColor = darkMode ? "rgba(255,255,255,0.65)" : "rgba(0,0,0,0.45)"; @@ -131,9 +134,11 @@ const ImportPreviewModal: React.FC = ({ const loadPreview = async (requestId: number) => { importRequestRef.current += 1; importingRef.current = false; + stoppingRef.current = false; activeImportJobIdRef.current = ""; previewConnectionConfigRef.current = null; setImporting(false); + setStopping(false); setLoading(true); setError(null); setPreviewData(null); @@ -240,8 +245,11 @@ const ImportPreviewModal: React.FC = ({ const importJobId = createImportJobId(); importRequestRef.current = importRequestId; importingRef.current = true; + stoppingRef.current = false; activeImportJobIdRef.current = importJobId; setImporting(true); + setStopping(false); + setError(null); setProgress({ current: 0, total: previewData.totalRows, @@ -269,7 +277,10 @@ const ImportPreviewModal: React.FC = ({ ); if (importRequestRef.current !== importRequestId) return; - if (res.success && res.data) { + setError(null); + if (res.data?.cancelled) { + setImportResult(res.data); + } else if (res.success && res.data) { setImportResult(res.data); if (res.data.failed === 0) { onSuccess(); @@ -287,12 +298,43 @@ const ImportPreviewModal: React.FC = ({ } finally { if (importRequestRef.current === importRequestId) { importingRef.current = false; + stoppingRef.current = false; activeImportJobIdRef.current = ""; setImporting(false); + setStopping(false); } } }; + const handleStopImport = async () => { + const importJobId = activeImportJobIdRef.current; + if (!importJobId || stoppingRef.current) return; + + stoppingRef.current = true; + setStopping(true); + setError(null); + try { + const res = await CancelQuery(importJobId); + if (!importingRef.current || activeImportJobIdRef.current !== importJobId) { + return; + } + if (!res.success) { + stoppingRef.current = false; + setStopping(false); + setError(res.message || t("import_preview.error.stop_failed")); + } + } catch (e: any) { + if (!importingRef.current || activeImportJobIdRef.current !== importJobId) { + return; + } + stoppingRef.current = false; + setStopping(false); + setError(t("import_preview.error.stop_failed_detail", { + detail: String(e?.message || e), + })); + } + }; + const columns = previewData?.columns.map((col) => ({ title: col, @@ -311,7 +353,19 @@ const ImportPreviewModal: React.FC = ({ - ) : importing ? null : ( + ) : importing ? ( + + + + ) : (