mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-22 21:27:11 +08:00
- 工具中心详情页统一承载入口标题和说明 - 数据同步、表结构比对和数据比对嵌入时隐藏内部 hero - 数据目录、快捷键、连接包和安全更新嵌入时隐藏内部 Modal 标题 - 补充工具中心全 pane 标题归属回归断言
2890 lines
101 KiB
TypeScript
2890 lines
101 KiB
TypeScript
import Modal from "./common/ResizableDraggableModal";
|
|
import React, { useState, useEffect, useMemo, useRef } from "react";
|
|
import {
|
|
Form,
|
|
Select,
|
|
Input,
|
|
Button,
|
|
message,
|
|
Steps,
|
|
Transfer,
|
|
Card,
|
|
Alert,
|
|
Divider,
|
|
Typography,
|
|
Progress,
|
|
Checkbox,
|
|
Table,
|
|
Drawer,
|
|
Tabs,
|
|
theme as antdTheme,
|
|
} from "antd";
|
|
import {
|
|
DatabaseOutlined,
|
|
RocketOutlined,
|
|
SwapOutlined,
|
|
TableOutlined,
|
|
} from "@ant-design/icons";
|
|
import { useStore } from "../store";
|
|
import {
|
|
DBGetDatabases,
|
|
DBGetTables,
|
|
DataSync,
|
|
DataSyncAnalyze,
|
|
DataSyncPreview,
|
|
} from "../../wailsjs/go/app/App";
|
|
import { SavedConnection } from "../types";
|
|
import { EventsOn } from "../../wailsjs/runtime/runtime";
|
|
import {
|
|
isMacLikePlatform,
|
|
normalizeOpacityForPlatform,
|
|
resolveAppearanceValues,
|
|
resolveTextInputSafeBackdropFilter,
|
|
} from "../utils/appearance";
|
|
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
|
import {
|
|
isPostgresSchemaDialect,
|
|
supportsIndependentSchemaSelection,
|
|
} from "../utils/connectionDriverType";
|
|
import { resolveSqlDialect } from "../utils/sqlDialect";
|
|
import { quoteIdentPart, quoteQualifiedIdent } from "../utils/sql";
|
|
import {
|
|
formatLocalDateTimeLiteral,
|
|
normalizeTemporalLiteralText,
|
|
} from "./dataGridCopyInsert";
|
|
import {
|
|
buildDataSyncRequest,
|
|
type SourceDatasetMode,
|
|
validateDataSyncSelection,
|
|
} from "./dataSyncRequest";
|
|
import { t } from "../i18n";
|
|
import { useOptionalI18n } from "../i18n/provider";
|
|
import {
|
|
resolveDataSyncEntryModePresentation,
|
|
type DataSyncEntryMode,
|
|
} from "./dataSyncEntryMode";
|
|
import { loadSchemas } from "./sidebar/sidebarMetadataLoaders";
|
|
const { Title, Text } = Typography;
|
|
const { Step } = Steps;
|
|
const { Option } = Select;
|
|
const { TextArea } = Input;
|
|
|
|
type SyncLogEvent = {
|
|
jobId: string;
|
|
level?: string;
|
|
message?: string;
|
|
ts?: number;
|
|
};
|
|
type SyncProgressEvent = {
|
|
jobId: string;
|
|
percent?: number;
|
|
current?: number;
|
|
total?: number;
|
|
table?: string;
|
|
stage?: string;
|
|
};
|
|
type SyncLogItem = { level: string; message: string; ts?: number };
|
|
type TableDiffSummary = {
|
|
table: string;
|
|
pkColumn?: string;
|
|
canSync?: boolean;
|
|
inserts?: number;
|
|
updates?: number;
|
|
deletes?: number;
|
|
same?: number;
|
|
schemaDiffCount?: number;
|
|
message?: string;
|
|
targetTableExists?: boolean;
|
|
plannedAction?: string;
|
|
warnings?: string[];
|
|
unsupportedObjects?: string[];
|
|
indexesToCreate?: number;
|
|
indexesSkipped?: number;
|
|
};
|
|
type TableOps = {
|
|
insert: boolean;
|
|
update: boolean;
|
|
delete: boolean;
|
|
selectedInsertPks?: string[];
|
|
selectedUpdatePks?: string[];
|
|
selectedDeletePks?: string[];
|
|
};
|
|
|
|
type WorkflowType = "sync" | "migration";
|
|
|
|
const quoteSqlIdent = (dbType: string, ident: string): string => {
|
|
return quoteIdentPart(dbType, String(ident || "").trim());
|
|
};
|
|
|
|
const quoteSqlTable = (dbType: string, tableName: string): string => {
|
|
return quoteQualifiedIdent(dbType, String(tableName || "").trim());
|
|
};
|
|
|
|
const toSqlLiteral = (value: any, dbType: string): string => {
|
|
if (value === null || value === undefined) return "NULL";
|
|
if (typeof value === "number")
|
|
return Number.isFinite(value) ? String(value) : "NULL";
|
|
if (typeof value === "bigint") return value.toString();
|
|
if (typeof value === "boolean") {
|
|
const t = String(dbType || "").toLowerCase();
|
|
if (t === "sqlserver") return value ? "1" : "0";
|
|
return value ? "TRUE" : "FALSE";
|
|
}
|
|
if (value instanceof Date) {
|
|
return `'${formatLocalDateTimeLiteral(value).replace(/'/g, "''")}'`;
|
|
}
|
|
if (typeof value === "string") {
|
|
return `'${value.replace(/'/g, "''")}'`;
|
|
}
|
|
if (typeof value === "object") {
|
|
try {
|
|
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
|
|
} catch {
|
|
return `'${String(value).replace(/'/g, "''")}'`;
|
|
}
|
|
}
|
|
return `'${String(value).replace(/'/g, "''")}'`;
|
|
};
|
|
|
|
const toTypedSqlLiteral = (
|
|
value: any,
|
|
dbType: string,
|
|
columnType?: string,
|
|
): string => {
|
|
if (typeof value === "string") {
|
|
const normalized = normalizeTemporalLiteralText(value, columnType, false);
|
|
return toSqlLiteral(normalized, dbType);
|
|
}
|
|
if (value instanceof Date) {
|
|
const normalized = String(columnType || "").trim()
|
|
? formatLocalDateTimeLiteral(value)
|
|
: value.toISOString();
|
|
return toSqlLiteral(normalized, dbType);
|
|
}
|
|
return toSqlLiteral(value, dbType);
|
|
};
|
|
|
|
const resolveRedisDbIndex = (raw?: string): number => {
|
|
const value = Number(String(raw || "").trim());
|
|
return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0;
|
|
};
|
|
|
|
const normalizeSchemaName = (raw?: string): string =>
|
|
String(raw || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
|
|
const resolveSchemaFromQualifiedTableName = (tableName: string): string => {
|
|
const parts = String(tableName || "")
|
|
.trim()
|
|
.split(".")
|
|
.map((part) => part.trim())
|
|
.filter((part) => part !== "");
|
|
if (parts.length < 2) return "";
|
|
return parts.length >= 3 ? parts[parts.length - 2] : parts[0];
|
|
};
|
|
|
|
const filterTablesBySchema = (
|
|
tables: string[],
|
|
schemaName: string,
|
|
): string[] => {
|
|
const normalizedSchema = normalizeSchemaName(schemaName);
|
|
if (!normalizedSchema) return tables;
|
|
|
|
const filtered = tables.filter((tableName) => {
|
|
const rawTableName = String(tableName || "").trim();
|
|
if (!rawTableName) return false;
|
|
const tableSchema = resolveSchemaFromQualifiedTableName(rawTableName);
|
|
if (!tableSchema) return true;
|
|
return normalizeSchemaName(tableSchema) === normalizedSchema;
|
|
});
|
|
|
|
return filtered.length > 0 ? filtered : tables;
|
|
};
|
|
|
|
const resolvePreferredTargetSchema = (
|
|
dialect: string,
|
|
schemaNames: string[],
|
|
): string => {
|
|
const preferred = isPostgresSchemaDialect(dialect)
|
|
? "public"
|
|
: dialect === "sqlserver"
|
|
? "dbo"
|
|
: dialect === "duckdb"
|
|
? "main"
|
|
: "";
|
|
if (preferred) {
|
|
const matched = schemaNames.find(
|
|
(item) => item.toLowerCase() === preferred,
|
|
);
|
|
if (matched) return matched;
|
|
}
|
|
if (schemaNames.length === 1) return schemaNames[0];
|
|
return "";
|
|
};
|
|
|
|
const isServiceNameBackedSyncConnection = (conn?: SavedConnection): boolean => {
|
|
const type = String(conn?.config?.type || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
if (type === "oracle") return true;
|
|
if (type !== "oceanbase") return false;
|
|
const explicitProtocol = String(
|
|
(conn?.config as any)?.oceanBaseProtocol || "",
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
if (explicitProtocol === "oracle") return true;
|
|
const params = new URLSearchParams(
|
|
String(conn?.config?.connectionParams || ""),
|
|
);
|
|
const protocol = String(
|
|
params.get("protocol") || params.get("tenantMode") || "",
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
return protocol === "oracle";
|
|
};
|
|
|
|
const buildSqlPreview = (
|
|
previewData: any,
|
|
tableName: string,
|
|
dbType: string,
|
|
ops?: TableOps,
|
|
): { sqlText: string; statementCount: number } => {
|
|
if (!previewData || !tableName) return { sqlText: "", statementCount: 0 };
|
|
const tableExpr = quoteSqlTable(dbType, tableName);
|
|
const pkCol = String(previewData.pkColumn || "id");
|
|
const columnTypesByLowerName =
|
|
previewData?.columnTypes && typeof previewData.columnTypes === "object"
|
|
? (previewData.columnTypes as Record<string, string>)
|
|
: {};
|
|
const statements: string[] = [];
|
|
const schemaStatements = Array.isArray(previewData.schemaStatements)
|
|
? previewData.schemaStatements
|
|
.map((item: any) => String(item || "").trim())
|
|
.filter((item: string) => item.length > 0)
|
|
: [];
|
|
|
|
schemaStatements.forEach((statement: string) => {
|
|
statements.push(statement.endsWith(";") ? statement : `${statement};`);
|
|
});
|
|
|
|
const insertRows = Array.isArray(previewData.inserts)
|
|
? previewData.inserts
|
|
: [];
|
|
const updateRows = Array.isArray(previewData.updates)
|
|
? previewData.updates
|
|
: [];
|
|
const deleteRows = Array.isArray(previewData.deletes)
|
|
? previewData.deletes
|
|
: [];
|
|
|
|
const selectedInsert = new Set(
|
|
(ops?.selectedInsertPks || []).map((v) => String(v)),
|
|
);
|
|
const selectedUpdate = new Set(
|
|
(ops?.selectedUpdatePks || []).map((v) => String(v)),
|
|
);
|
|
const selectedDelete = new Set(
|
|
(ops?.selectedDeletePks || []).map((v) => String(v)),
|
|
);
|
|
|
|
if (ops?.insert !== false) {
|
|
insertRows.forEach((rowWrap: any) => {
|
|
const pk = String(rowWrap?.pk ?? "");
|
|
if (selectedInsert.size > 0 && !selectedInsert.has(pk)) return;
|
|
const row = rowWrap?.row || {};
|
|
const columns = Object.keys(row);
|
|
if (columns.length === 0) return;
|
|
const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(", ");
|
|
const valExpr = columns
|
|
.map((c) =>
|
|
toTypedSqlLiteral(
|
|
row[c],
|
|
dbType,
|
|
columnTypesByLowerName[String(c).toLowerCase()],
|
|
),
|
|
)
|
|
.join(", ");
|
|
statements.push(
|
|
`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`,
|
|
);
|
|
});
|
|
}
|
|
|
|
if (ops?.update !== false) {
|
|
updateRows.forEach((rowWrap: any) => {
|
|
const pk = String(rowWrap?.pk ?? "");
|
|
if (selectedUpdate.size > 0 && !selectedUpdate.has(pk)) return;
|
|
const source = rowWrap?.source || {};
|
|
const changedColumns = Array.isArray(rowWrap?.changedColumns)
|
|
? rowWrap.changedColumns
|
|
: Object.keys(source).filter((k) => k !== pkCol);
|
|
const setCols = changedColumns.filter((c: string) => String(c) !== pkCol);
|
|
if (setCols.length === 0) return;
|
|
const setExpr = setCols
|
|
.map(
|
|
(c: string) =>
|
|
`${quoteSqlIdent(dbType, c)} = ${toTypedSqlLiteral(source[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])}`,
|
|
)
|
|
.join(", ");
|
|
statements.push(
|
|
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
|
|
);
|
|
});
|
|
}
|
|
|
|
if (ops?.delete) {
|
|
deleteRows.forEach((rowWrap: any) => {
|
|
const pk = String(rowWrap?.pk ?? "");
|
|
if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return;
|
|
statements.push(
|
|
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
|
|
);
|
|
});
|
|
}
|
|
|
|
return {
|
|
sqlText: statements.join("\n"),
|
|
statementCount: statements.length,
|
|
};
|
|
};
|
|
|
|
const DataSyncModal: React.FC<{
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onBack?: () => void;
|
|
entryMode?: DataSyncEntryMode;
|
|
embedded?: boolean;
|
|
}> = ({ open, onClose, onBack, entryMode = "sync", embedded = false }) => {
|
|
const i18n = useOptionalI18n();
|
|
const i18nLanguage = i18n?.language;
|
|
const tr = (key: string, params?: Parameters<typeof t>[1]) =>
|
|
t(key, params, i18nLanguage);
|
|
const entryPresentation = resolveDataSyncEntryModePresentation(entryMode, tr);
|
|
const isSchemaCompareEntry = entryMode === "schemaCompare";
|
|
const isDataCompareEntry = entryMode === "dataCompare";
|
|
const isCompareEntry = entryPresentation.readOnly;
|
|
const connections = useStore((state) => state.connections);
|
|
const themeMode = useStore((state) => state.theme);
|
|
const appearance = useStore((state) => state.appearance);
|
|
const [currentStep, setCurrentStep] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const { token } = antdTheme.useToken();
|
|
const darkMode = themeMode === "dark";
|
|
const resolvedAppearance = resolveAppearanceValues(appearance);
|
|
const effectiveOpacity = normalizeOpacityForPlatform(
|
|
resolvedAppearance.opacity,
|
|
);
|
|
const disableLocalBackdropFilter = isMacLikePlatform();
|
|
|
|
// Step 1: Config
|
|
const [sourceConnId, setSourceConnId] = useState<string>("");
|
|
const [targetConnId, setTargetConnId] = useState<string>("");
|
|
const [sourceDb, setSourceDb] = useState<string>("");
|
|
const [targetDb, setTargetDb] = useState<string>("");
|
|
|
|
const [sourceDbs, setSourceDbs] = useState<string[]>([]);
|
|
const [targetDbs, setTargetDbs] = useState<string[]>([]);
|
|
const [targetSchemas, setTargetSchemas] = useState<string[]>([]);
|
|
const [targetSchema, setTargetSchema] = useState<string>("");
|
|
const [targetSchemaLoading, setTargetSchemaLoading] =
|
|
useState<boolean>(false);
|
|
|
|
// Step 2: Tables
|
|
const [allTables, setAllTables] = useState<string[]>([]);
|
|
const [selectedTables, setSelectedTables] = useState<string[]>([]);
|
|
const [sourceDatasetMode, setSourceDatasetMode] =
|
|
useState<SourceDatasetMode>("table");
|
|
const [sourceQuery, setSourceQuery] = useState<string>("");
|
|
|
|
// Options
|
|
const [workflowType, setWorkflowType] = useState<WorkflowType>("sync");
|
|
const [syncContent, setSyncContent] = useState<"data" | "schema" | "both">(
|
|
isSchemaCompareEntry ? "schema" : "data",
|
|
);
|
|
const [syncMode, setSyncMode] = useState<string>("insert_update");
|
|
const [autoAddColumns, setAutoAddColumns] = useState<boolean>(true);
|
|
const [targetTableStrategy, setTargetTableStrategy] = useState<
|
|
"existing_only" | "auto_create_if_missing" | "smart"
|
|
>("existing_only");
|
|
const [createIndexes, setCreateIndexes] = useState<boolean>(false);
|
|
const [mongoCollectionName, setMongoCollectionName] = useState<string>("");
|
|
const [showSameTables, setShowSameTables] = useState<boolean>(false);
|
|
const [analyzing, setAnalyzing] = useState<boolean>(false);
|
|
const [diffTables, setDiffTables] = useState<TableDiffSummary[]>([]);
|
|
const [tableOptions, setTableOptions] = useState<Record<string, TableOps>>(
|
|
{},
|
|
);
|
|
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
const [previewTable, setPreviewTable] = useState<string>("");
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
const [previewData, setPreviewData] = useState<any>(null);
|
|
|
|
// Step 3: Result
|
|
const [syncResult, setSyncResult] = useState<any>(null);
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [syncLogs, setSyncLogs] = useState<SyncLogItem[]>([]);
|
|
const [syncProgress, setSyncProgress] = useState<{
|
|
percent: number;
|
|
current: number;
|
|
total: number;
|
|
table: string;
|
|
stage: string;
|
|
}>({
|
|
percent: 0,
|
|
current: 0,
|
|
total: 0,
|
|
table: "",
|
|
stage: "",
|
|
});
|
|
const jobIdRef = useRef<string>("");
|
|
const logBoxRef = useRef<HTMLDivElement>(null);
|
|
const autoScrollRef = useRef(true);
|
|
|
|
const normalizeConnConfig = (conn: SavedConnection, database?: string) =>
|
|
buildRpcConnectionConfig(conn.config, {
|
|
database:
|
|
typeof database === "string" && !isServiceNameBackedSyncConnection(conn)
|
|
? database
|
|
: conn.config.database || "",
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const offLog = EventsOn("sync:log", (event: SyncLogEvent) => {
|
|
if (!event || event.jobId !== jobIdRef.current) return;
|
|
const msg = String(event.message || "").trim();
|
|
if (!msg) return;
|
|
setSyncLogs((prev) => [
|
|
...prev,
|
|
{ level: String(event.level || "info"), message: msg, ts: event.ts },
|
|
]);
|
|
});
|
|
|
|
const offProgress = EventsOn(
|
|
"sync:progress",
|
|
(event: SyncProgressEvent) => {
|
|
if (!event || event.jobId !== jobIdRef.current) return;
|
|
setSyncProgress((prev) => ({
|
|
percent:
|
|
typeof event.percent === "number" ? event.percent : prev.percent,
|
|
current:
|
|
typeof event.current === "number" ? event.current : prev.current,
|
|
total: typeof event.total === "number" ? event.total : prev.total,
|
|
table: typeof event.table === "string" ? event.table : prev.table,
|
|
stage: typeof event.stage === "string" ? event.stage : prev.stage,
|
|
}));
|
|
},
|
|
);
|
|
|
|
return () => {
|
|
if (typeof offLog === "function") offLog();
|
|
if (typeof offProgress === "function") offProgress();
|
|
};
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!logBoxRef.current) return;
|
|
if (!autoScrollRef.current) return;
|
|
logBoxRef.current.scrollTop = logBoxRef.current.scrollHeight;
|
|
}, [syncLogs]);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setCurrentStep(0);
|
|
setSourceConnId("");
|
|
setTargetConnId("");
|
|
setSourceDb("");
|
|
setTargetDb("");
|
|
setTargetSchema("");
|
|
setTargetSchemas([]);
|
|
setTargetSchemaLoading(false);
|
|
setAllTables([]);
|
|
setSelectedTables([]);
|
|
setSourceDatasetMode("table");
|
|
setSourceQuery("");
|
|
setWorkflowType("sync");
|
|
setSyncContent(isSchemaCompareEntry ? "schema" : "data");
|
|
setSyncMode("insert_update");
|
|
setAutoAddColumns(true);
|
|
setTargetTableStrategy("existing_only");
|
|
setCreateIndexes(false);
|
|
setShowSameTables(false);
|
|
setAnalyzing(false);
|
|
setDiffTables([]);
|
|
setTableOptions({});
|
|
setPreviewOpen(false);
|
|
setPreviewTable("");
|
|
setPreviewLoading(false);
|
|
setPreviewData(null);
|
|
setSyncResult(null);
|
|
setSyncing(false);
|
|
setSyncLogs([]);
|
|
setSyncProgress({
|
|
percent: 0,
|
|
current: 0,
|
|
total: 0,
|
|
table: "",
|
|
stage: "",
|
|
});
|
|
jobIdRef.current = "";
|
|
autoScrollRef.current = true;
|
|
}
|
|
}, [open, isSchemaCompareEntry]);
|
|
|
|
useEffect(() => {
|
|
if (isSchemaCompareEntry) {
|
|
if (workflowType !== "sync") {
|
|
setWorkflowType("sync");
|
|
}
|
|
if (sourceDatasetMode !== "table") {
|
|
setSourceDatasetMode("table");
|
|
}
|
|
if (syncContent !== "schema") {
|
|
setSyncContent("schema");
|
|
}
|
|
if (syncMode !== "insert_update") {
|
|
setSyncMode("insert_update");
|
|
}
|
|
if (targetTableStrategy !== "existing_only") {
|
|
setTargetTableStrategy("existing_only");
|
|
}
|
|
if (createIndexes) {
|
|
setCreateIndexes(false);
|
|
}
|
|
return;
|
|
}
|
|
if (isDataCompareEntry) {
|
|
if (workflowType !== "sync") {
|
|
setWorkflowType("sync");
|
|
}
|
|
if (syncContent !== "data") {
|
|
setSyncContent("data");
|
|
}
|
|
if (syncMode !== "insert_update") {
|
|
setSyncMode("insert_update");
|
|
}
|
|
if (targetTableStrategy !== "existing_only") {
|
|
setTargetTableStrategy("existing_only");
|
|
}
|
|
if (createIndexes) {
|
|
setCreateIndexes(false);
|
|
}
|
|
return;
|
|
}
|
|
if (workflowType === "migration") {
|
|
if (syncMode === "insert_update") {
|
|
setSyncMode("insert_only");
|
|
}
|
|
if (syncContent === "schema") {
|
|
setSyncContent("both");
|
|
}
|
|
if (targetTableStrategy === "existing_only") {
|
|
setTargetTableStrategy("smart");
|
|
}
|
|
if (!createIndexes) {
|
|
setCreateIndexes(true);
|
|
}
|
|
} else {
|
|
if (targetTableStrategy !== "existing_only") {
|
|
setTargetTableStrategy("existing_only");
|
|
}
|
|
if (createIndexes) {
|
|
setCreateIndexes(false);
|
|
}
|
|
}
|
|
}, [
|
|
isSchemaCompareEntry,
|
|
isDataCompareEntry,
|
|
workflowType,
|
|
sourceDatasetMode,
|
|
syncContent,
|
|
syncMode,
|
|
targetTableStrategy,
|
|
createIndexes,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (sourceDatasetMode !== "query") return;
|
|
if (workflowType !== "sync") {
|
|
setWorkflowType("sync");
|
|
}
|
|
if (syncContent !== "data") {
|
|
setSyncContent("data");
|
|
}
|
|
if (targetTableStrategy !== "existing_only") {
|
|
setTargetTableStrategy("existing_only");
|
|
}
|
|
if (createIndexes) {
|
|
setCreateIndexes(false);
|
|
}
|
|
if (autoAddColumns) {
|
|
setAutoAddColumns(false);
|
|
}
|
|
if (selectedTables.length > 1) {
|
|
setSelectedTables(selectedTables.slice(0, 1));
|
|
}
|
|
}, [
|
|
sourceDatasetMode,
|
|
workflowType,
|
|
syncContent,
|
|
targetTableStrategy,
|
|
createIndexes,
|
|
autoAddColumns,
|
|
selectedTables,
|
|
]);
|
|
|
|
const handleSourceConnChange = async (connId: string) => {
|
|
setSourceConnId(connId);
|
|
setSourceDb("");
|
|
const conn = connections.find((c) => c.id === connId);
|
|
if (conn) {
|
|
setLoading(true);
|
|
try {
|
|
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
|
if (res.success) {
|
|
const dbRows = Array.isArray(res.data) ? res.data : [];
|
|
setSourceDbs(
|
|
dbRows
|
|
.map((r: any) => r?.Database || r?.database || r?.username)
|
|
.filter(
|
|
(name: any) => typeof name === "string" && name.trim() !== "",
|
|
),
|
|
);
|
|
}
|
|
} catch (e: any) {
|
|
message.error(
|
|
tr("data_sync.message.fetch_source_databases_failed_detail", {
|
|
detail: e?.message || String(e),
|
|
}),
|
|
);
|
|
}
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleTargetConnChange = async (connId: string) => {
|
|
setTargetConnId(connId);
|
|
setTargetDb("");
|
|
setTargetSchema("");
|
|
setTargetSchemas([]);
|
|
setTargetSchemaLoading(false);
|
|
const conn = connections.find((c) => c.id === connId);
|
|
if (conn) {
|
|
setLoading(true);
|
|
try {
|
|
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
|
if (res.success) {
|
|
const dbRows = Array.isArray(res.data) ? res.data : [];
|
|
setTargetDbs(
|
|
dbRows
|
|
.map((r: any) => r?.Database || r?.database || r?.username)
|
|
.filter(
|
|
(name: any) => typeof name === "string" && name.trim() !== "",
|
|
),
|
|
);
|
|
}
|
|
} catch (e: any) {
|
|
message.error(
|
|
tr("data_sync.message.fetch_target_databases_failed_detail", {
|
|
detail: e?.message || String(e),
|
|
}),
|
|
);
|
|
}
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const ensureTargetSchemaSelected = (): boolean => {
|
|
if (targetSupportsSchemaSelection && !String(targetSchema || "").trim()) {
|
|
message.error(tr("data_sync.message.select_target_schema"));
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const nextToTables = async () => {
|
|
if (!sourceConnId || !targetConnId) return message.error(tr('data_sync.message.select_connections_first'));
|
|
if (!sourceDb) return message.error(tr('data_sync.message.select_source_database'));
|
|
if (!targetDb) return message.error(tr('data_sync.message.select_target_database'));
|
|
if (!ensureTargetSchemaSelected()) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const connId = isSourceQueryMode ? targetConnId : sourceConnId;
|
|
const dbName = isSourceQueryMode ? targetDb : sourceDb;
|
|
const conn = connections.find((c) => c.id === connId);
|
|
if (conn) {
|
|
const config = normalizeConnConfig(conn, dbName);
|
|
const res = await DBGetTables(config as any, dbName);
|
|
if (res.success) {
|
|
// DBGetTables returns [{Table: "name"}, ...]
|
|
const tableRows = Array.isArray(res.data) ? res.data : [];
|
|
const tables = tableRows
|
|
.map(
|
|
(row: any) =>
|
|
row?.Table ||
|
|
row?.table ||
|
|
row?.TABLE_NAME ||
|
|
Object.values(row || {})[0],
|
|
)
|
|
.filter(
|
|
(name: any) => typeof name === "string" && name.trim() !== "",
|
|
);
|
|
const nextTables = (
|
|
isSourceQueryMode && targetSupportsSchemaSelection && targetSchema
|
|
? filterTablesBySchema(tables as string[], targetSchema)
|
|
: tables
|
|
) as string[];
|
|
setAllTables(nextTables);
|
|
setSelectedTables((prev) => {
|
|
const existing = prev.filter((name) => nextTables.includes(name));
|
|
if (isSourceQueryMode) {
|
|
return existing.slice(0, 1);
|
|
}
|
|
return existing;
|
|
});
|
|
setCurrentStep(1);
|
|
} else {
|
|
message.error(
|
|
res.message
|
|
? tr("data_sync.message.fetch_tables_failed_detail", {
|
|
detail: res.message,
|
|
})
|
|
: tr("data_sync.message.fetch_tables_failed"),
|
|
);
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
message.error(
|
|
tr("data_sync.message.fetch_tables_failed_detail", {
|
|
detail: e?.message || String(e),
|
|
}),
|
|
);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
const updateTableOption = (
|
|
table: string,
|
|
key: keyof TableOps,
|
|
value: any,
|
|
) => {
|
|
setTableOptions((prev) => ({
|
|
...prev,
|
|
[table]: {
|
|
...(prev[table] || { insert: true, update: true, delete: false }),
|
|
[key]: value,
|
|
},
|
|
}));
|
|
};
|
|
|
|
const analyzeDiff = async () => {
|
|
const selectionError = validateDataSyncSelection({
|
|
sourceDatasetMode,
|
|
selectedTables,
|
|
sourceQuery,
|
|
syncContent,
|
|
});
|
|
if (selectionError) return message.error(tr(selectionError));
|
|
if (!sourceConnId || !targetConnId)
|
|
return message.error(tr("data_sync.message.select_connections_first"));
|
|
if (!sourceDb || !targetDb)
|
|
return message.error(tr("data_sync.message.select_databases_first"));
|
|
if (!ensureTargetSchemaSelected()) return;
|
|
|
|
setLoading(true);
|
|
setAnalyzing(true);
|
|
setDiffTables([]);
|
|
setTableOptions({});
|
|
setSyncLogs([]);
|
|
|
|
const sConn = connections.find((c) => c.id === sourceConnId)!;
|
|
const tConn = connections.find((c) => c.id === targetConnId)!;
|
|
const jobId = `analyze-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
jobIdRef.current = jobId;
|
|
autoScrollRef.current = true;
|
|
setSyncProgress({
|
|
percent: 0,
|
|
current: 0,
|
|
total: selectedTables.length,
|
|
table: "",
|
|
stage: tr("data_sync.progress.stage.analyzing_diff"),
|
|
});
|
|
|
|
const config = buildDataSyncRequest({
|
|
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
|
targetConfig: normalizeConnConfig(tConn, targetDb),
|
|
sourceDatabase: sourceDb,
|
|
targetDatabase: targetDb,
|
|
targetSchema,
|
|
selectedTables,
|
|
sourceDatasetMode,
|
|
sourceQuery,
|
|
syncContent,
|
|
syncMode: "insert_update",
|
|
autoAddColumns,
|
|
targetTableStrategy,
|
|
createIndexes,
|
|
mongoCollectionName,
|
|
jobId,
|
|
});
|
|
|
|
try {
|
|
const res = await DataSyncAnalyze(config as any);
|
|
if (res.success) {
|
|
const tables = ((res.data as any)?.tables || []) as TableDiffSummary[];
|
|
setDiffTables(tables);
|
|
const init: Record<string, TableOps> = {};
|
|
tables.forEach((t) => {
|
|
const can = !!t.canSync;
|
|
init[t.table] = {
|
|
insert: can,
|
|
update: can,
|
|
delete: false,
|
|
selectedInsertPks: [],
|
|
selectedUpdatePks: [],
|
|
selectedDeletePks: [],
|
|
};
|
|
});
|
|
setTableOptions(init);
|
|
message.success(tr("data_sync.message.analysis_complete"));
|
|
} else {
|
|
message.error(
|
|
res.message
|
|
? tr("data_sync.message.analysis_failed_detail", {
|
|
detail: res.message,
|
|
})
|
|
: tr("data_sync.message.analysis_failed"),
|
|
);
|
|
}
|
|
} catch (e: any) {
|
|
message.error(
|
|
tr("data_sync.message.analysis_failed_detail", {
|
|
detail: e?.message || String(e),
|
|
}),
|
|
);
|
|
}
|
|
|
|
setLoading(false);
|
|
setAnalyzing(false);
|
|
};
|
|
|
|
const openPreview = async (table: string) => {
|
|
if (!table) return;
|
|
if (!ensureTargetSchemaSelected()) return;
|
|
const sConn = connections.find((c) => c.id === sourceConnId)!;
|
|
const tConn = connections.find((c) => c.id === targetConnId)!;
|
|
|
|
setPreviewOpen(true);
|
|
setPreviewTable(table);
|
|
setPreviewLoading(true);
|
|
setPreviewData(null);
|
|
|
|
const config = buildDataSyncRequest({
|
|
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
|
targetConfig: normalizeConnConfig(tConn, targetDb),
|
|
sourceDatabase: sourceDb,
|
|
targetDatabase: targetDb,
|
|
targetSchema,
|
|
selectedTables,
|
|
sourceDatasetMode,
|
|
sourceQuery,
|
|
syncContent,
|
|
syncMode: "insert_update",
|
|
autoAddColumns,
|
|
targetTableStrategy,
|
|
createIndexes,
|
|
mongoCollectionName,
|
|
});
|
|
|
|
try {
|
|
const res = await DataSyncPreview(config as any, table, 200);
|
|
if (res.success) {
|
|
setPreviewData(res.data);
|
|
} else {
|
|
message.error(
|
|
res.message
|
|
? tr("data_sync.message.preview_load_failed_detail", {
|
|
detail: res.message,
|
|
})
|
|
: tr("data_sync.message.preview_load_failed"),
|
|
);
|
|
}
|
|
} catch (e: any) {
|
|
message.error(
|
|
tr("data_sync.message.preview_load_failed_detail", {
|
|
detail: e?.message || String(e),
|
|
}),
|
|
);
|
|
}
|
|
|
|
setPreviewLoading(false);
|
|
};
|
|
|
|
const runSync = async () => {
|
|
const selectionError = validateDataSyncSelection({
|
|
sourceDatasetMode,
|
|
selectedTables,
|
|
sourceQuery,
|
|
syncContent,
|
|
});
|
|
if (selectionError) {
|
|
message.error(tr(selectionError));
|
|
return;
|
|
}
|
|
if (!ensureTargetSchemaSelected()) return;
|
|
if (syncContent !== "schema" && diffTables.length === 0) {
|
|
message.error(tr("data_sync.message.analyze_before_sync"));
|
|
return;
|
|
}
|
|
if (syncContent !== "schema" && syncMode === "full_overwrite") {
|
|
const ok = await new Promise<boolean>((resolve) => {
|
|
Modal.confirm({
|
|
title: tr("data_sync.modal.full_overwrite_title"),
|
|
content: tr("data_sync.modal.full_overwrite_content"),
|
|
okText: tr("data_sync.modal.full_overwrite_ok"),
|
|
cancelText: tr("common.cancel"),
|
|
onOk: () => resolve(true),
|
|
onCancel: () => resolve(false),
|
|
});
|
|
});
|
|
if (!ok) return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setSyncing(true);
|
|
setCurrentStep(2);
|
|
setSyncResult(null);
|
|
setSyncLogs([]);
|
|
|
|
const sConn = connections.find((c) => c.id === sourceConnId)!;
|
|
const tConn = connections.find((c) => c.id === targetConnId)!;
|
|
|
|
const jobId = `sync-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
jobIdRef.current = jobId;
|
|
autoScrollRef.current = true;
|
|
setSyncProgress({
|
|
percent: 0,
|
|
current: 0,
|
|
total: selectedTables.length,
|
|
table: "",
|
|
stage: tr("data_sync.progress.stage.preparing"),
|
|
});
|
|
|
|
const config = buildDataSyncRequest({
|
|
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
|
targetConfig: normalizeConnConfig(tConn, targetDb),
|
|
sourceDatabase: sourceDb,
|
|
targetDatabase: targetDb,
|
|
targetSchema,
|
|
selectedTables,
|
|
sourceDatasetMode,
|
|
sourceQuery,
|
|
syncContent,
|
|
syncMode,
|
|
autoAddColumns,
|
|
targetTableStrategy,
|
|
createIndexes,
|
|
mongoCollectionName,
|
|
tableOptions,
|
|
jobId,
|
|
});
|
|
|
|
try {
|
|
const res = await DataSync(config as any);
|
|
setSyncResult(res);
|
|
if (Array.isArray(res?.logs) && res.logs.length > 0) {
|
|
setSyncLogs((prev) => {
|
|
if (prev.length > 0) return prev;
|
|
return (res.logs as string[]).map((log) => {
|
|
const msg = String(log || "").trim();
|
|
if (msg.includes("致命错误") || msg.includes("失败"))
|
|
return { level: "error", message: msg }; // i18n-scan: allow-raw backend log severity markers
|
|
if (msg.includes("跳过") || msg.includes("警告"))
|
|
return { level: "warn", message: msg }; // i18n-scan: allow-raw backend log severity markers
|
|
return { level: "info", message: msg };
|
|
});
|
|
});
|
|
}
|
|
} catch (e: any) {
|
|
message.error(
|
|
tr("data_sync.message.sync_execution_failed_detail", {
|
|
detail: e?.message || String(e),
|
|
}),
|
|
);
|
|
setSyncResult({
|
|
success: false,
|
|
message: tr("data_sync.message.sync_execution_failed"),
|
|
logs: [],
|
|
});
|
|
}
|
|
setLoading(false);
|
|
setSyncing(false);
|
|
};
|
|
|
|
const renderSyncLogItem = (item: SyncLogItem) => {
|
|
const level = String(item.level || "info").toLowerCase();
|
|
const color =
|
|
level === "error" ? "#ff4d4f" : level === "warn" ? "#faad14" : "#595959";
|
|
const label =
|
|
level === "error"
|
|
? tr("data_sync.log.level.error")
|
|
: level === "warn"
|
|
? tr("data_sync.log.level.warn")
|
|
: tr("data_sync.log.level.info");
|
|
const timeText =
|
|
typeof item.ts === "number"
|
|
? new Date(item.ts).toLocaleTimeString(i18nLanguage || undefined, {
|
|
hour12: false,
|
|
})
|
|
: "";
|
|
return (
|
|
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
|
|
<span style={{ color, flex: "0 0 auto" }}>● {label}</span>
|
|
{timeText && (
|
|
<span style={{ color: "#8c8c8c", flex: "0 0 auto" }}>{timeText}</span>
|
|
)}
|
|
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
|
{item.message}
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const previewSql = useMemo(() => {
|
|
if (!previewData || !previewTable)
|
|
return { sqlText: "", statementCount: 0 };
|
|
const targetType = String(
|
|
connections.find((c) => c.id === targetConnId)?.config?.type || "",
|
|
);
|
|
const ops = tableOptions[previewTable] || {
|
|
insert: true,
|
|
update: true,
|
|
delete: false,
|
|
};
|
|
return buildSqlPreview(previewData, previewTable, targetType, ops);
|
|
}, [previewData, previewTable, targetConnId, connections, tableOptions]);
|
|
const previewHasSchemaStatements = useMemo(
|
|
() =>
|
|
Array.isArray(previewData?.schemaStatements) &&
|
|
previewData.schemaStatements.length > 0,
|
|
[previewData],
|
|
);
|
|
const previewSchemaWarnings = useMemo(
|
|
() =>
|
|
Array.isArray(previewData?.schemaWarnings)
|
|
? (previewData.schemaWarnings as string[])
|
|
: [],
|
|
[previewData],
|
|
);
|
|
const previewHasDataDiff = useMemo(
|
|
() =>
|
|
Number(previewData?.totalInserts || 0) +
|
|
Number(previewData?.totalUpdates || 0) +
|
|
Number(previewData?.totalDeletes || 0) >
|
|
0,
|
|
[previewData],
|
|
);
|
|
|
|
const analysisWarnings = useMemo(() => {
|
|
const items: string[] = [];
|
|
diffTables.forEach((table) => {
|
|
(table.warnings || []).forEach((warning) =>
|
|
items.push(`${table.table}: ${warning}`),
|
|
);
|
|
(table.unsupportedObjects || []).forEach((warning) =>
|
|
items.push(`${table.table}: ${warning}`),
|
|
);
|
|
});
|
|
return Array.from(new Set(items));
|
|
}, [diffTables]);
|
|
|
|
const isSourceQueryMode = sourceDatasetMode === "query";
|
|
const isMigrationWorkflow = !isCompareEntry && workflowType === "migration";
|
|
const sourceConn = useMemo(
|
|
() => connections.find((c) => c.id === sourceConnId),
|
|
[connections, sourceConnId],
|
|
);
|
|
const targetConn = useMemo(
|
|
() => connections.find((c) => c.id === targetConnId),
|
|
[connections, targetConnId],
|
|
);
|
|
const targetDialect = useMemo(
|
|
() =>
|
|
resolveSqlDialect(
|
|
targetConn?.config?.type || "",
|
|
targetConn?.config?.driver || "",
|
|
{ oceanBaseProtocol: targetConn?.config?.oceanBaseProtocol },
|
|
),
|
|
[targetConn],
|
|
);
|
|
const targetSupportsSchemaSelection = useMemo(
|
|
() => supportsIndependentSchemaSelection(targetDialect),
|
|
[targetDialect],
|
|
);
|
|
const sourceType = String(sourceConn?.config?.type || "").toLowerCase();
|
|
const targetType = String(targetConn?.config?.type || "").toLowerCase();
|
|
const isRedisMongoKeyspaceMigration =
|
|
isMigrationWorkflow &&
|
|
((sourceType === "redis" && targetType === "mongodb") ||
|
|
(sourceType === "mongodb" && targetType === "redis"));
|
|
const defaultMongoCollectionName = useMemo(() => {
|
|
if (sourceType === "redis" && targetType === "mongodb") {
|
|
return `redis_db_${resolveRedisDbIndex(sourceDb || sourceConn?.config?.database)}_keys`;
|
|
}
|
|
if (sourceType === "mongodb" && targetType === "redis") {
|
|
return (
|
|
selectedTables[0] ||
|
|
`redis_db_${resolveRedisDbIndex(targetDb || targetConn?.config?.database)}_keys`
|
|
);
|
|
}
|
|
return "";
|
|
}, [
|
|
sourceType,
|
|
targetType,
|
|
sourceDb,
|
|
targetDb,
|
|
sourceConn,
|
|
targetConn,
|
|
selectedTables,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!targetConn || !targetDb || !targetSupportsSchemaSelection) {
|
|
setTargetSchemas([]);
|
|
setTargetSchema("");
|
|
setTargetSchemaLoading(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setTargetSchema("");
|
|
setTargetSchemas([]);
|
|
setTargetSchemaLoading(true);
|
|
|
|
(async () => {
|
|
try {
|
|
const result = await loadSchemas(targetConn, targetDb);
|
|
if (cancelled) return;
|
|
const normalizedSchemas = Array.from(
|
|
new Map(
|
|
(Array.isArray(result.schemas) ? result.schemas : [])
|
|
.map((item) => String(item || "").trim())
|
|
.filter((item) => item !== "")
|
|
.map((item) => [item.toLowerCase(), item] as const),
|
|
).values(),
|
|
);
|
|
setTargetSchemas(normalizedSchemas);
|
|
setTargetSchema(
|
|
resolvePreferredTargetSchema(targetDialect, normalizedSchemas),
|
|
);
|
|
} catch (e: any) {
|
|
if (cancelled) return;
|
|
setTargetSchemas([]);
|
|
setTargetSchema("");
|
|
message.error(
|
|
t(
|
|
"data_sync.message.fetch_target_schemas_failed_detail",
|
|
{ detail: e?.message || String(e) },
|
|
i18nLanguage,
|
|
),
|
|
);
|
|
} finally {
|
|
if (!cancelled) {
|
|
setTargetSchemaLoading(false);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [targetConn, targetDb, targetDialect, targetSupportsSchemaSelection]);
|
|
|
|
const modalPanelStyle = useMemo(
|
|
() => ({
|
|
background: darkMode
|
|
? "linear-gradient(180deg, rgba(16,22,34,0.96) 0%, rgba(10,14,24,0.98) 100%)"
|
|
: "linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)",
|
|
border: darkMode
|
|
? "1px solid rgba(255,255,255,0.08)"
|
|
: "1px solid rgba(16,24,40,0.08)",
|
|
boxShadow: darkMode
|
|
? "0 24px 56px rgba(0,0,0,0.36)"
|
|
: "0 18px 44px rgba(15,23,42,0.14)",
|
|
backdropFilter: resolveTextInputSafeBackdropFilter(
|
|
darkMode ? "blur(18px)" : "none",
|
|
disableLocalBackdropFilter,
|
|
),
|
|
}),
|
|
[darkMode, disableLocalBackdropFilter],
|
|
);
|
|
|
|
const shellCardStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
borderRadius: 18,
|
|
border: darkMode
|
|
? "1px solid rgba(255,255,255,0.08)"
|
|
: "1px solid rgba(15,23,42,0.08)",
|
|
background: darkMode
|
|
? "rgba(255,255,255,0.03)"
|
|
: `rgba(255,255,255,${Math.max(effectiveOpacity, 0.88)})`,
|
|
boxShadow: darkMode
|
|
? "0 12px 32px rgba(0,0,0,0.22)"
|
|
: "0 10px 24px rgba(15,23,42,0.08)",
|
|
overflow: "hidden",
|
|
}),
|
|
[darkMode, effectiveOpacity],
|
|
);
|
|
|
|
const heroPanelStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
padding: 18,
|
|
borderRadius: 18,
|
|
border: darkMode
|
|
? "1px solid rgba(255,214,102,0.12)"
|
|
: "1px solid rgba(24,144,255,0.12)",
|
|
background: darkMode
|
|
? "linear-gradient(135deg, rgba(255,214,102,0.10) 0%, rgba(255,255,255,0.03) 100%)"
|
|
: "linear-gradient(135deg, rgba(24,144,255,0.10) 0%, rgba(255,255,255,0.95) 100%)",
|
|
marginBottom: 18,
|
|
}),
|
|
[darkMode],
|
|
);
|
|
|
|
const badgeStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "6px 10px",
|
|
borderRadius: 999,
|
|
border: darkMode
|
|
? "1px solid rgba(255,255,255,0.10)"
|
|
: "1px solid rgba(15,23,42,0.08)",
|
|
background: darkMode
|
|
? "rgba(255,255,255,0.04)"
|
|
: "rgba(255,255,255,0.86)",
|
|
color: darkMode ? "rgba(255,255,255,0.88)" : "#334155",
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
}),
|
|
[darkMode],
|
|
);
|
|
|
|
const quietPanelStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
padding: 14,
|
|
borderRadius: 16,
|
|
border: darkMode
|
|
? "1px solid rgba(255,255,255,0.08)"
|
|
: "1px solid rgba(15,23,42,0.08)",
|
|
background: darkMode
|
|
? "rgba(255,255,255,0.025)"
|
|
: "rgba(248,250,252,0.92)",
|
|
}),
|
|
[darkMode],
|
|
);
|
|
|
|
const modalWorkspaceStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
height: "100%",
|
|
minHeight: 0,
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const modalScrollableContentStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
flex: 1,
|
|
minHeight: 0,
|
|
overflowY: "auto",
|
|
overflowX: "hidden",
|
|
paddingRight: 4,
|
|
overscrollBehavior: "contain",
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const modalFooterBarStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
marginTop: 18,
|
|
display: "flex",
|
|
justifyContent: "flex-end",
|
|
gap: 8,
|
|
paddingTop: 12,
|
|
borderTop: darkMode
|
|
? "1px solid rgba(255,255,255,0.06)"
|
|
: "1px solid rgba(15,23,42,0.06)",
|
|
flex: "0 0 auto",
|
|
}),
|
|
[darkMode],
|
|
);
|
|
|
|
const renderModalTitle = (title: string, description: string) => (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
gap: 12,
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: 14,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
background: darkMode
|
|
? "rgba(255,214,102,0.12)"
|
|
: "rgba(24,144,255,0.10)",
|
|
color: darkMode ? "#ffd666" : token.colorPrimary,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />}
|
|
</div>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: 16,
|
|
fontWeight: 700,
|
|
color: darkMode ? "#f8fafc" : "#0f172a",
|
|
}}
|
|
>
|
|
{title}
|
|
</div>
|
|
<div
|
|
style={{
|
|
marginTop: 4,
|
|
fontSize: 12,
|
|
lineHeight: 1.6,
|
|
color: darkMode ? "rgba(255,255,255,0.56)" : "rgba(15,23,42,0.58)",
|
|
}}
|
|
>
|
|
{description}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const handleReturnToPrevious = () => {
|
|
if (syncing) {
|
|
message.warning(tr("data_sync.message.close_blocked_running"));
|
|
return;
|
|
}
|
|
onBack?.();
|
|
};
|
|
|
|
const dataSyncContent = (
|
|
<div style={modalWorkspaceStyle}>
|
|
<div style={{ flex: "0 0 auto" }}>
|
|
{!embedded && (
|
|
<div style={heroPanelStyle}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
gap: 12,
|
|
alignItems: "flex-start",
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: 18,
|
|
fontWeight: 700,
|
|
color: darkMode ? "#f8fafc" : "#0f172a",
|
|
}}
|
|
>
|
|
{isMigrationWorkflow
|
|
? tr("data_sync.title.migration")
|
|
: isCompareEntry
|
|
? entryPresentation.heroTitle
|
|
: tr("data_sync.title.sync")}
|
|
</div>
|
|
<div
|
|
style={{
|
|
marginTop: 6,
|
|
fontSize: 13,
|
|
lineHeight: 1.7,
|
|
color: darkMode
|
|
? "rgba(255,255,255,0.62)"
|
|
: "rgba(15,23,42,0.62)",
|
|
}}
|
|
>
|
|
{isMigrationWorkflow
|
|
? tr("data_sync.title.migration_description")
|
|
: isCompareEntry
|
|
? entryPresentation.heroDescription
|
|
: tr("data_sync.title.sync_description")}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
|
<span style={badgeStyle}>
|
|
{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />}{" "}
|
|
{isMigrationWorkflow
|
|
? tr("data_sync.badge.migration_mode")
|
|
: isCompareEntry
|
|
? entryPresentation.badgeText
|
|
: tr("data_sync.badge.sync_mode")}
|
|
</span>
|
|
<span style={badgeStyle}>
|
|
<DatabaseOutlined />{" "}
|
|
{sourceConnId
|
|
? tr("data_sync.badge.source_selected")
|
|
: tr("data_sync.badge.source_pending")}
|
|
</span>
|
|
<span style={badgeStyle}>
|
|
<TableOutlined />{" "}
|
|
{tr("data_sync.badge.table_count", {
|
|
count: selectedTables.length || 0,
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Steps current={currentStep} style={{ marginBottom: 24 }}>
|
|
<Step title={tr("data_sync.step.configure")} />
|
|
<Step title={tr("data_sync.step.select_tables")} />
|
|
<Step
|
|
title={
|
|
isCompareEntry
|
|
? entryPresentation.resultTitle
|
|
: tr("data_sync.step.result")
|
|
}
|
|
/>
|
|
</Steps>
|
|
</div>
|
|
|
|
<div style={modalScrollableContentStyle}>
|
|
{/* STEP 1: CONFIG */}
|
|
{currentStep === 0 && (
|
|
<div>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "minmax(0, 1fr) 44px minmax(0, 1fr)",
|
|
gap: 18,
|
|
alignItems: "stretch",
|
|
}}
|
|
>
|
|
<Card
|
|
title={tr("data_sync.title.source_database")}
|
|
style={shellCardStyle}
|
|
styles={{
|
|
header: {
|
|
borderBottom: darkMode
|
|
? "1px solid rgba(255,255,255,0.08)"
|
|
: "1px solid rgba(15,23,42,0.06)",
|
|
fontWeight: 700,
|
|
},
|
|
body: { padding: 18 },
|
|
}}
|
|
>
|
|
<Form layout="vertical">
|
|
<Form.Item label={tr("data_sync.field.connection")}>
|
|
<Select
|
|
value={sourceConnId}
|
|
onChange={handleSourceConnChange}
|
|
>
|
|
{connections.map((c) => (
|
|
<Option key={c.id} value={c.id}>
|
|
{c.name} ({c.config.type})
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
<Form.Item label={tr("data_sync.field.database")}>
|
|
<Select value={sourceDb} onChange={setSourceDb} showSearch>
|
|
{sourceDbs.map((d) => (
|
|
<Option key={d} value={d}>
|
|
{d}
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
</Form>
|
|
</Card>
|
|
<div style={{ display: "grid", placeItems: "center" }}>
|
|
<div
|
|
style={{
|
|
...badgeStyle,
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 14,
|
|
justifyContent: "center",
|
|
padding: 0,
|
|
}}
|
|
>
|
|
<SwapOutlined />
|
|
</div>
|
|
</div>
|
|
<Card
|
|
title={tr("data_sync.title.target_database")}
|
|
style={shellCardStyle}
|
|
styles={{
|
|
header: {
|
|
borderBottom: darkMode
|
|
? "1px solid rgba(255,255,255,0.08)"
|
|
: "1px solid rgba(15,23,42,0.06)",
|
|
fontWeight: 700,
|
|
},
|
|
body: { padding: 18 },
|
|
}}
|
|
>
|
|
<Form layout="vertical">
|
|
<Form.Item label={tr("data_sync.field.connection")}>
|
|
<Select
|
|
value={targetConnId}
|
|
onChange={handleTargetConnChange}
|
|
>
|
|
{connections.map((c) => (
|
|
<Option key={c.id} value={c.id}>
|
|
{c.name} ({c.config.type})
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
<Form.Item label={tr("data_sync.field.database")}>
|
|
<Select value={targetDb} onChange={setTargetDb} showSearch>
|
|
{targetDbs.map((d) => (
|
|
<Option key={d} value={d}>
|
|
{d}
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
{targetSupportsSchemaSelection && (
|
|
<Form.Item label={tr("data_sync.field.schema")}>
|
|
<Select
|
|
value={targetSchema || undefined}
|
|
onChange={(value) =>
|
|
setTargetSchema(String(value || ""))
|
|
}
|
|
showSearch
|
|
allowClear
|
|
loading={targetSchemaLoading}
|
|
disabled={!targetDb}
|
|
>
|
|
{targetSchemas.map((schemaName) => (
|
|
<Option key={schemaName} value={schemaName}>
|
|
{schemaName}
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
)}
|
|
</Form>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card
|
|
title={
|
|
isMigrationWorkflow
|
|
? tr("data_sync.title.migration_options")
|
|
: isCompareEntry
|
|
? entryPresentation.optionTitle
|
|
: tr("data_sync.title.sync_options")
|
|
}
|
|
style={{ ...shellCardStyle, marginTop: 18 }}
|
|
styles={{
|
|
header: {
|
|
borderBottom: darkMode
|
|
? "1px solid rgba(255,255,255,0.08)"
|
|
: "1px solid rgba(15,23,42,0.06)",
|
|
fontWeight: 700,
|
|
},
|
|
body: { padding: 18 },
|
|
}}
|
|
>
|
|
<div style={{ ...quietPanelStyle, marginBottom: 14 }}>
|
|
<Text
|
|
style={{
|
|
color: darkMode
|
|
? "rgba(255,255,255,0.72)"
|
|
: "rgba(15,23,42,0.68)",
|
|
lineHeight: 1.7,
|
|
}}
|
|
>
|
|
{isCompareEntry
|
|
? tr("data_sync.compare_entry.workflow_help")
|
|
: tr("data_sync.help.workflow_type")}
|
|
</Text>
|
|
</div>
|
|
<Form layout="vertical">
|
|
{!isCompareEntry && (
|
|
<Form.Item label={tr("data_sync.field.workflow_type")}>
|
|
<Select value={workflowType} onChange={setWorkflowType}>
|
|
<Option value="sync">
|
|
{tr("data_sync.option.workflow.sync")}
|
|
</Option>
|
|
<Option value="migration" disabled={isSourceQueryMode}>
|
|
{tr("data_sync.option.workflow.migration")}
|
|
</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
)}
|
|
{!isSchemaCompareEntry && (
|
|
<Form.Item label={tr("data_sync.field.source_dataset_mode")}>
|
|
<Select
|
|
value={sourceDatasetMode}
|
|
onChange={setSourceDatasetMode}
|
|
>
|
|
<Option value="table">
|
|
{isCompareEntry
|
|
? tr(
|
|
"data_sync.compare_entry.option.source_dataset.table",
|
|
)
|
|
: tr("data_sync.option.source_dataset.table")}
|
|
</Option>
|
|
<Option value="query">
|
|
{isCompareEntry
|
|
? tr(
|
|
"data_sync.compare_entry.option.source_dataset.query",
|
|
)
|
|
: tr("data_sync.option.source_dataset.query")}
|
|
</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
)}
|
|
<Alert
|
|
type={
|
|
isMigrationWorkflow || isCompareEntry ? "info" : "success"
|
|
}
|
|
showIcon
|
|
style={{ marginBottom: 12 }}
|
|
message={
|
|
isMigrationWorkflow
|
|
? tr("data_sync.alert.migration_mode")
|
|
: isSchemaCompareEntry
|
|
? tr("data_sync.compare_entry.alert.schema")
|
|
: isDataCompareEntry
|
|
? tr("data_sync.compare_entry.alert.data")
|
|
: tr("data_sync.alert.sync_mode")
|
|
}
|
|
/>
|
|
{isSourceQueryMode && (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 12 }}
|
|
message={tr("data_sync.alert.query_mode")}
|
|
/>
|
|
)}
|
|
{!isCompareEntry && (
|
|
<Form.Item
|
|
label={
|
|
isMigrationWorkflow
|
|
? tr("data_sync.field.migration_content")
|
|
: tr("data_sync.field.sync_content")
|
|
}
|
|
>
|
|
<Select value={syncContent} onChange={setSyncContent}>
|
|
<Option value="data">
|
|
{tr("data_sync.option.content.data")}
|
|
</Option>
|
|
<Option value="schema" disabled={isSourceQueryMode}>
|
|
{tr("data_sync.option.content.schema")}
|
|
</Option>
|
|
<Option value="both" disabled={isSourceQueryMode}>
|
|
{tr("data_sync.option.content.both")}
|
|
</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
)}
|
|
{!isCompareEntry && (
|
|
<Form.Item
|
|
label={
|
|
isMigrationWorkflow
|
|
? tr("data_sync.field.migration_mode")
|
|
: tr("data_sync.field.sync_mode")
|
|
}
|
|
>
|
|
<Select
|
|
value={syncMode}
|
|
onChange={setSyncMode}
|
|
disabled={syncContent === "schema"}
|
|
>
|
|
<Option value="insert_update">
|
|
{tr("data_sync.option.sync_mode.insert_update")}
|
|
</Option>
|
|
<Option value="insert_only">
|
|
{tr("data_sync.option.sync_mode.insert_only")}
|
|
</Option>
|
|
<Option value="full_overwrite">
|
|
{tr("data_sync.option.sync_mode.full_overwrite")}
|
|
</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
)}
|
|
{!isCompareEntry && (
|
|
<Form.Item
|
|
label={
|
|
isMigrationWorkflow
|
|
? tr("data_sync.field.target_table_strategy")
|
|
: tr("data_sync.field.target_table_requirement")
|
|
}
|
|
>
|
|
<Select
|
|
value={targetTableStrategy}
|
|
onChange={setTargetTableStrategy}
|
|
disabled={!isMigrationWorkflow || isSourceQueryMode}
|
|
>
|
|
<Option value="existing_only">
|
|
{tr("data_sync.option.target_strategy.existing_only")}
|
|
</Option>
|
|
<Option value="auto_create_if_missing">
|
|
{tr(
|
|
"data_sync.option.target_strategy.auto_create_if_missing",
|
|
)}
|
|
</Option>
|
|
<Option value="smart">
|
|
{tr("data_sync.option.target_strategy.smart")}
|
|
</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
)}
|
|
{isRedisMongoKeyspaceMigration && (
|
|
<Form.Item
|
|
label={tr("data_sync.field.mongo_collection_name")}
|
|
extra={
|
|
sourceType === "redis"
|
|
? tr("data_sync.help.mongo_collection_redis_to_mongo")
|
|
: tr("data_sync.help.mongo_collection_mongo_to_redis")
|
|
}
|
|
>
|
|
<Input
|
|
value={mongoCollectionName}
|
|
onChange={(e) => setMongoCollectionName(e.target.value)}
|
|
placeholder={
|
|
defaultMongoCollectionName ||
|
|
tr("data_sync.placeholder.mongo_collection_name")
|
|
}
|
|
allowClear
|
|
maxLength={128}
|
|
/>
|
|
</Form.Item>
|
|
)}
|
|
{(!isCompareEntry || isSchemaCompareEntry) && (
|
|
<Form.Item>
|
|
<Checkbox
|
|
checked={autoAddColumns}
|
|
onChange={(e) => setAutoAddColumns(e.target.checked)}
|
|
disabled={isSourceQueryMode}
|
|
>
|
|
{isSchemaCompareEntry
|
|
? tr("data_sync.compare_entry.option.auto_add_columns")
|
|
: tr("data_sync.option.auto_add_columns")}
|
|
</Checkbox>
|
|
</Form.Item>
|
|
)}
|
|
{!isCompareEntry && (
|
|
<Form.Item>
|
|
<Checkbox
|
|
checked={createIndexes}
|
|
onChange={(e) => setCreateIndexes(e.target.checked)}
|
|
disabled={
|
|
!isMigrationWorkflow ||
|
|
targetTableStrategy === "existing_only" ||
|
|
isSourceQueryMode
|
|
}
|
|
>
|
|
{tr("data_sync.option.create_indexes")}
|
|
</Checkbox>
|
|
</Form.Item>
|
|
)}
|
|
{isMigrationWorkflow &&
|
|
targetTableStrategy !== "existing_only" && (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
message={tr("data_sync.alert.auto_create_planner_scope")}
|
|
style={{ marginBottom: 12 }}
|
|
/>
|
|
)}
|
|
{!isCompareEntry && !isMigrationWorkflow && (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
message={tr("data_sync.alert.existing_target_only")}
|
|
style={{ marginBottom: 12 }}
|
|
/>
|
|
)}
|
|
{syncContent !== "schema" && syncMode === "full_overwrite" && (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message={tr("data_sync.alert.full_overwrite")}
|
|
/>
|
|
)}
|
|
</Form>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* STEP 2: TABLES */}
|
|
{currentStep === 1 && (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
|
<div style={quietPanelStyle}>
|
|
{!isSourceQueryMode && (
|
|
<>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: 10,
|
|
}}
|
|
>
|
|
<Text type="secondary">
|
|
{isCompareEntry
|
|
? entryPresentation.tableSelectLabel
|
|
: tr("data_sync.help.select_tables")}
|
|
</Text>
|
|
<Checkbox
|
|
checked={showSameTables}
|
|
onChange={(e) => setShowSameTables(e.target.checked)}
|
|
>
|
|
{tr("data_sync.option.show_same_tables")}
|
|
</Checkbox>
|
|
</div>
|
|
<Transfer
|
|
dataSource={allTables.map((t) => ({ key: t, title: t }))}
|
|
titles={[
|
|
tr("data_sync.transfer.source_tables"),
|
|
tr("data_sync.transfer.selected_tables"),
|
|
]}
|
|
targetKeys={selectedTables}
|
|
onChange={(keys) => setSelectedTables(keys as string[])}
|
|
render={(item) => item.title}
|
|
listStyle={{
|
|
width: 390,
|
|
height: 320,
|
|
marginTop: 0,
|
|
borderRadius: 14,
|
|
overflow: "hidden",
|
|
}}
|
|
locale={{
|
|
itemUnit: tr("data_sync.transfer.item_unit"),
|
|
itemsUnit: tr("data_sync.transfer.items_unit"),
|
|
searchPlaceholder: tr(
|
|
"data_sync.transfer.search_placeholder",
|
|
),
|
|
notFoundContent: tr("data_sync.transfer.empty"),
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
{isSourceQueryMode && (
|
|
<Form layout="vertical">
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 12 }}
|
|
message={tr("data_sync.help.source_query_mode")}
|
|
/>
|
|
<Form.Item label={tr("data_sync.field.source_query_sql")}>
|
|
<TextArea
|
|
value={sourceQuery}
|
|
onChange={(e) => setSourceQuery(e.target.value)}
|
|
rows={8}
|
|
placeholder={tr("data_sync.placeholder.source_query_sql")}
|
|
spellCheck={false}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item label={tr("data_sync.field.target_table")}>
|
|
<Select
|
|
value={selectedTables[0]}
|
|
onChange={(value) =>
|
|
setSelectedTables(value ? [value] : [])
|
|
}
|
|
showSearch
|
|
allowClear
|
|
placeholder={tr("data_sync.placeholder.target_table")}
|
|
optionFilterProp="children"
|
|
>
|
|
{allTables.map((table) => (
|
|
<Option key={table} value={table}>
|
|
{table}
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
</Form>
|
|
)}
|
|
</div>
|
|
|
|
{diffTables.length > 0 && (
|
|
<div style={quietPanelStyle}>
|
|
<Divider orientation="left" style={{ marginTop: 0 }}>
|
|
{tr("data_sync.title.compare_result")}
|
|
</Divider>
|
|
{analysisWarnings.length > 0 && (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message={tr("data_sync.message.precheck_warnings")}
|
|
description={
|
|
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
|
{analysisWarnings.slice(0, 8).map((item) => (
|
|
<li key={item}>{item}</li>
|
|
))}
|
|
{analysisWarnings.length > 8 && (
|
|
<li>
|
|
{tr("data_sync.message.more_items_collapsed", {
|
|
count: analysisWarnings.length - 8,
|
|
})}
|
|
</li>
|
|
)}
|
|
</ul>
|
|
}
|
|
style={{ marginBottom: 12 }}
|
|
/>
|
|
)}
|
|
<Table
|
|
size="small"
|
|
pagination={false}
|
|
rowKey={(r: any) => r.table}
|
|
dataSource={diffTables.filter((t) => {
|
|
const ins = Number(t.inserts || 0);
|
|
const upd = Number(t.updates || 0);
|
|
const del = Number(t.deletes || 0);
|
|
const same = Number(t.same || 0);
|
|
const msg = String(t.message || "").trim();
|
|
const can = !!t.canSync;
|
|
const warns = Array.isArray(t.warnings)
|
|
? t.warnings.length
|
|
: 0;
|
|
const unsupported = Array.isArray(t.unsupportedObjects)
|
|
? t.unsupportedObjects.length
|
|
: 0;
|
|
if (showSameTables) return true;
|
|
if (!can) return true;
|
|
if (msg || warns > 0 || unsupported > 0) return true;
|
|
return ins > 0 || upd > 0 || del > 0 || same === 0;
|
|
})}
|
|
columns={[
|
|
{
|
|
title: tr("data_sync.table.table_name"),
|
|
dataIndex: "table",
|
|
key: "table",
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: tr("data_sync.table.target_table"),
|
|
key: "targetTableExists",
|
|
width: 90,
|
|
render: (_: any, r: any) =>
|
|
r.targetTableExists
|
|
? tr("data_sync.table.target_exists")
|
|
: tr("data_sync.table.target_missing"),
|
|
},
|
|
{
|
|
title: tr("data_sync.table.plan"),
|
|
dataIndex: "plannedAction",
|
|
key: "plannedAction",
|
|
width: 220,
|
|
ellipsis: true,
|
|
render: (v: any) => String(v || ""),
|
|
},
|
|
{
|
|
title: tr("data_sync.table.insert"),
|
|
key: "inserts",
|
|
width: 90,
|
|
render: (_: any, r: any) => {
|
|
const ops = tableOptions[r.table] || {
|
|
insert: true,
|
|
update: true,
|
|
delete: false,
|
|
};
|
|
const disabled =
|
|
!r.canSync ||
|
|
analyzing ||
|
|
Number(r.inserts || 0) === 0;
|
|
return (
|
|
<Checkbox
|
|
checked={!!ops.insert}
|
|
disabled={disabled}
|
|
onChange={(e) =>
|
|
updateTableOption(
|
|
r.table,
|
|
"insert",
|
|
e.target.checked,
|
|
)
|
|
}
|
|
>
|
|
{Number(r.inserts || 0)}
|
|
</Checkbox>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: tr("data_sync.table.update"),
|
|
key: "updates",
|
|
width: 90,
|
|
render: (_: any, r: any) => {
|
|
const ops = tableOptions[r.table] || {
|
|
insert: true,
|
|
update: true,
|
|
delete: false,
|
|
};
|
|
const disabled =
|
|
!r.canSync ||
|
|
analyzing ||
|
|
Number(r.updates || 0) === 0;
|
|
return (
|
|
<Checkbox
|
|
checked={!!ops.update}
|
|
disabled={disabled}
|
|
onChange={(e) =>
|
|
updateTableOption(
|
|
r.table,
|
|
"update",
|
|
e.target.checked,
|
|
)
|
|
}
|
|
>
|
|
{Number(r.updates || 0)}
|
|
</Checkbox>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: tr("data_sync.table.delete"),
|
|
key: "deletes",
|
|
width: 90,
|
|
render: (_: any, r: any) => {
|
|
const ops = tableOptions[r.table] || {
|
|
insert: true,
|
|
update: true,
|
|
delete: false,
|
|
};
|
|
const disabled =
|
|
!r.canSync ||
|
|
analyzing ||
|
|
Number(r.deletes || 0) === 0;
|
|
return (
|
|
<Checkbox
|
|
checked={!!ops.delete}
|
|
disabled={disabled}
|
|
onChange={(e) =>
|
|
updateTableOption(
|
|
r.table,
|
|
"delete",
|
|
e.target.checked,
|
|
)
|
|
}
|
|
>
|
|
{Number(r.deletes || 0)}
|
|
</Checkbox>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: tr("data_sync.table.same"),
|
|
dataIndex: "same",
|
|
key: "same",
|
|
width: 70,
|
|
render: (v: any) => Number(v || 0),
|
|
},
|
|
{
|
|
title: tr("data_sync.table.risk"),
|
|
key: "warnings",
|
|
width: 220,
|
|
render: (_: any, r: any) => {
|
|
const warns = [
|
|
...(Array.isArray(r.warnings) ? r.warnings : []),
|
|
...(Array.isArray(r.unsupportedObjects)
|
|
? r.unsupportedObjects
|
|
: []),
|
|
];
|
|
if (warns.length === 0) return "-";
|
|
return (
|
|
<div
|
|
style={{
|
|
color: "#d48806",
|
|
fontSize: 12,
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
{warns.slice(0, 2).map((item: string) => (
|
|
<div key={item}>{item}</div>
|
|
))}
|
|
{warns.length > 2 && (
|
|
<div>
|
|
{tr("data_sync.message.more_items_collapsed", {
|
|
count: warns.length - 2,
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: tr("data_sync.table.preview"),
|
|
key: "preview",
|
|
width: 80,
|
|
render: (_: any, r: any) => {
|
|
const can = !!r.canSync;
|
|
const hasDiff =
|
|
Number(r.inserts || 0) +
|
|
Number(r.updates || 0) +
|
|
Number(r.deletes || 0) >
|
|
0;
|
|
const hasSchemaDiff =
|
|
Number(r.schemaDiffCount || 0) > 0;
|
|
return (
|
|
<Button
|
|
size="small"
|
|
disabled={
|
|
!can || !(hasDiff || hasSchemaDiff) || analyzing
|
|
}
|
|
onClick={() => openPreview(r.table)}
|
|
>
|
|
{tr("data_sync.action.view")}
|
|
</Button>
|
|
);
|
|
},
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* STEP 3: RESULT */}
|
|
{currentStep === 2 && (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
|
<div style={quietPanelStyle}>
|
|
<Alert
|
|
message={
|
|
syncing
|
|
? isCompareEntry
|
|
? tr("data_sync.compare_entry.result.running")
|
|
: tr("data_sync.result.running")
|
|
: syncResult?.success
|
|
? isCompareEntry
|
|
? tr("data_sync.compare_entry.result.completed")
|
|
: tr("data_sync.result.completed")
|
|
: isCompareEntry
|
|
? tr("data_sync.compare_entry.result.failed")
|
|
: tr("data_sync.result.failed")
|
|
}
|
|
description={
|
|
syncing
|
|
? isCompareEntry
|
|
? tr(
|
|
"data_sync.compare_entry.result.running_description",
|
|
{
|
|
stage:
|
|
syncProgress.stage ||
|
|
tr(
|
|
"data_sync.compare_entry.result.stage_fallback",
|
|
),
|
|
table: syncProgress.table
|
|
? tr(
|
|
"data_sync.compare_entry.result.table_suffix",
|
|
{ table: syncProgress.table },
|
|
)
|
|
: "",
|
|
},
|
|
)
|
|
: tr("data_sync.result.running_description", {
|
|
stage:
|
|
syncProgress.stage ||
|
|
tr("data_sync.progress.stage.executing"),
|
|
table: syncProgress.table
|
|
? tr("data_sync.result.table_suffix", {
|
|
table: syncProgress.table,
|
|
})
|
|
: "",
|
|
})
|
|
: syncResult?.message ||
|
|
(isCompareEntry
|
|
? tr("data_sync.compare_entry.result.success_summary", {
|
|
tables:
|
|
diffTables.length ||
|
|
syncResult?.tablesSynced ||
|
|
0,
|
|
})
|
|
: tr("data_sync.result.success_summary", {
|
|
tables: syncResult?.tablesSynced || 0,
|
|
inserted: syncResult?.rowsInserted || 0,
|
|
updated: syncResult?.rowsUpdated || 0,
|
|
}))
|
|
}
|
|
type={
|
|
syncing ? "info" : syncResult?.success ? "success" : "error"
|
|
}
|
|
showIcon
|
|
/>
|
|
|
|
<div style={{ marginTop: 14 }}>
|
|
<Progress
|
|
percent={syncProgress.percent}
|
|
status={
|
|
syncing
|
|
? "active"
|
|
: syncResult?.success
|
|
? "success"
|
|
: "exception"
|
|
}
|
|
format={() => `${syncProgress.current}/${syncProgress.total}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div style={quietPanelStyle}>
|
|
<Divider orientation="left" style={{ marginTop: 0 }}>
|
|
{isCompareEntry
|
|
? tr("data_sync.compare_entry.title.analysis_log")
|
|
: tr("data_sync.title.execution_log")}
|
|
</Divider>
|
|
<div
|
|
ref={logBoxRef}
|
|
onScroll={() => {
|
|
const el = logBoxRef.current;
|
|
if (!el) return;
|
|
const nearBottom =
|
|
el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
|
autoScrollRef.current = nearBottom;
|
|
}}
|
|
style={{
|
|
background: darkMode
|
|
? "rgba(255,255,255,0.03)"
|
|
: "rgba(248,250,252,0.92)",
|
|
border: darkMode
|
|
? "1px solid rgba(255,255,255,0.08)"
|
|
: "1px solid rgba(15,23,42,0.06)",
|
|
borderRadius: 14,
|
|
padding: 12,
|
|
height: 300,
|
|
overflowY: "auto",
|
|
fontFamily: "var(--gn-font-mono)",
|
|
}}
|
|
>
|
|
{syncLogs.map((item, i: number) => (
|
|
<div key={i}>{renderSyncLogItem(item)}</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={modalFooterBarStyle}>
|
|
{currentStep === 0 && (
|
|
<Button type="primary" onClick={nextToTables} loading={loading}>
|
|
{tr("data_sync.action.next")}
|
|
</Button>
|
|
)}
|
|
{currentStep === 1 && (
|
|
<>
|
|
<Button
|
|
onClick={() => setCurrentStep(0)}
|
|
style={{ marginRight: 8 }}
|
|
>
|
|
{tr("data_sync.action.previous")}
|
|
</Button>
|
|
<Button
|
|
onClick={analyzeDiff}
|
|
loading={loading}
|
|
disabled={
|
|
(isCompareEntry ? false : syncContent === "schema") ||
|
|
selectedTables.length === 0 ||
|
|
analyzing ||
|
|
(isSourceQueryMode && !sourceQuery.trim())
|
|
}
|
|
style={{ marginRight: 8 }}
|
|
>
|
|
{isCompareEntry
|
|
? entryPresentation.analyzeButtonText
|
|
: tr("data_sync.action.analyze_diff")}
|
|
</Button>
|
|
{isCompareEntry && (
|
|
<Button onClick={onClose}>
|
|
{entryPresentation.closeButtonText}
|
|
</Button>
|
|
)}
|
|
{!isCompareEntry && (
|
|
<Button
|
|
type="primary"
|
|
onClick={runSync}
|
|
loading={loading}
|
|
disabled={
|
|
selectedTables.length === 0 ||
|
|
(isSourceQueryMode && !sourceQuery.trim()) ||
|
|
(syncContent !== "schema" && diffTables.length === 0)
|
|
}
|
|
>
|
|
{tr("data_sync.action.start_sync")}
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
{currentStep === 2 && (
|
|
<>
|
|
<Button
|
|
disabled={syncing}
|
|
onClick={() => setCurrentStep(1)}
|
|
style={{ marginRight: 8 }}
|
|
>
|
|
{isCompareEntry
|
|
? tr("data_sync.compare_entry.action.return_to_compare")
|
|
: tr("data_sync.action.continue_sync")}
|
|
</Button>
|
|
<Button type="primary" disabled={syncing} onClick={onClose}>
|
|
{isCompareEntry
|
|
? entryPresentation.closeButtonText
|
|
: tr("data_sync.action.close")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{onBack ? (
|
|
<Button onClick={handleReturnToPrevious}>
|
|
{tr("common.back_to_previous")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{embedded ? (
|
|
dataSyncContent
|
|
) : (
|
|
<Modal
|
|
title={renderModalTitle(
|
|
isMigrationWorkflow
|
|
? tr("data_sync.title.migration_workbench")
|
|
: isCompareEntry
|
|
? entryPresentation.title
|
|
: tr("data_sync.title.sync_workbench"),
|
|
isMigrationWorkflow
|
|
? tr("data_sync.title.migration_description")
|
|
: isCompareEntry
|
|
? entryPresentation.description
|
|
: tr("data_sync.title.sync_description"),
|
|
)}
|
|
open={open}
|
|
onCancel={() => {
|
|
if (syncing) {
|
|
message.warning(tr("data_sync.message.close_blocked_running"));
|
|
return;
|
|
}
|
|
onClose();
|
|
}}
|
|
width={920}
|
|
footer={null}
|
|
destroyOnHidden
|
|
closable={!syncing}
|
|
maskClosable={!syncing}
|
|
styles={{
|
|
content: modalPanelStyle,
|
|
header: {
|
|
background: "transparent",
|
|
borderBottom: "none",
|
|
paddingBottom: 10,
|
|
},
|
|
body: {
|
|
paddingTop: 8,
|
|
height: 760,
|
|
maxHeight: "calc(100vh - 120px)",
|
|
overflow: "hidden",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
},
|
|
footer: {
|
|
background: "transparent",
|
|
borderTop: "none",
|
|
paddingTop: 12,
|
|
},
|
|
}}
|
|
>
|
|
{dataSyncContent}
|
|
</Modal>
|
|
)}
|
|
<Drawer
|
|
title={tr("data_sync.preview.title", { table: previewTable })}
|
|
styles={{
|
|
body: { background: darkMode ? "rgba(9,13,20,0.98)" : "#f8fafc" },
|
|
}}
|
|
open={previewOpen}
|
|
onClose={() => {
|
|
setPreviewOpen(false);
|
|
setPreviewTable("");
|
|
setPreviewData(null);
|
|
}}
|
|
width={900}
|
|
>
|
|
{previewLoading && (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
message={tr("data_sync.preview.loading")}
|
|
/>
|
|
)}
|
|
{!previewLoading && previewData && (
|
|
<div>
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
message={
|
|
previewHasDataDiff
|
|
? tr("data_sync.preview.data_summary", {
|
|
inserts: previewData.totalInserts || 0,
|
|
updates: previewData.totalUpdates || 0,
|
|
deletes: previewData.totalDeletes || 0,
|
|
})
|
|
: previewData.schemaSummary ||
|
|
tr("data_sync.preview.schema_statement_count", {
|
|
count: previewSql.statementCount,
|
|
})
|
|
}
|
|
/>
|
|
{previewSchemaWarnings.length > 0 && (
|
|
<Alert
|
|
style={{ marginTop: 12 }}
|
|
type="warning"
|
|
showIcon
|
|
message={tr("data_sync.preview.schema_warning_title")}
|
|
description={
|
|
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
|
{previewSchemaWarnings.slice(0, 8).map((item) => (
|
|
<li key={item}>{item}</li>
|
|
))}
|
|
{previewSchemaWarnings.length > 8 && (
|
|
<li>
|
|
{tr("data_sync.message.more_items_collapsed", {
|
|
count: previewSchemaWarnings.length - 8,
|
|
})}
|
|
</li>
|
|
)}
|
|
</ul>
|
|
}
|
|
/>
|
|
)}
|
|
<Divider />
|
|
<Tabs
|
|
items={[
|
|
...(previewHasSchemaStatements
|
|
? [
|
|
{
|
|
key: "schema",
|
|
label: tr("data_sync.preview.tab.schema", {
|
|
count: Array.isArray(previewData.schemaStatements)
|
|
? previewData.schemaStatements.length
|
|
: 0,
|
|
}),
|
|
children: (
|
|
<div>
|
|
<Text type="secondary">
|
|
{previewData.schemaSummary ||
|
|
tr("data_sync.preview.schema_plan_help")}
|
|
</Text>
|
|
<pre
|
|
style={{
|
|
marginTop: 8,
|
|
marginBottom: 0,
|
|
padding: 10,
|
|
border: "1px solid #f0f0f0",
|
|
borderRadius: 6,
|
|
background: "#fafafa",
|
|
maxHeight: 420,
|
|
overflow: "auto",
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-word",
|
|
}}
|
|
>
|
|
{Array.isArray(previewData.schemaStatements) &&
|
|
previewData.schemaStatements.length > 0
|
|
? previewData.schemaStatements.join("\n")
|
|
: tr("data_sync.preview.sql.no_schema_changes")}
|
|
</pre>
|
|
</div>
|
|
),
|
|
},
|
|
]
|
|
: []),
|
|
...(previewHasDataDiff
|
|
? [
|
|
{
|
|
key: "insert",
|
|
label: tr("data_sync.preview.tab.insert", {
|
|
count: previewData.totalInserts || 0,
|
|
}),
|
|
children: (
|
|
<div>
|
|
<Text type="secondary">
|
|
{isCompareEntry
|
|
? tr(
|
|
"data_sync.compare_entry.preview.selection_hint",
|
|
)
|
|
: tr("data_sync.preview.selection_hint.insert")}
|
|
</Text>
|
|
<Table
|
|
size="small"
|
|
style={{ marginTop: 8 }}
|
|
rowKey={(r: any) => r.pk}
|
|
dataSource={(previewData.inserts || []).map(
|
|
(r: any) => ({ ...r, key: r.pk }),
|
|
)}
|
|
pagination={false}
|
|
rowSelection={{
|
|
selectedRowKeys: (tableOptions[previewTable]
|
|
?.selectedInsertPks || []) as any,
|
|
onChange: (keys) =>
|
|
updateTableOption(
|
|
previewTable,
|
|
"selectedInsertPks",
|
|
keys as string[],
|
|
),
|
|
getCheckboxProps: () => ({
|
|
disabled: !tableOptions[previewTable]?.insert,
|
|
}),
|
|
}}
|
|
columns={[
|
|
{
|
|
title:
|
|
previewData.pkColumn ||
|
|
tr("data_sync.preview.column.primary_key"),
|
|
dataIndex: "pk",
|
|
key: "pk",
|
|
width: 200,
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: tr("data_sync.preview.column.data"),
|
|
dataIndex: "row",
|
|
key: "row",
|
|
render: (v: any) => (
|
|
<pre
|
|
style={{
|
|
margin: 0,
|
|
maxHeight: 140,
|
|
overflow: "auto",
|
|
}}
|
|
>
|
|
{JSON.stringify(v, null, 2)}
|
|
</pre>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "update",
|
|
label: tr("data_sync.preview.tab.update", {
|
|
count: previewData.totalUpdates || 0,
|
|
}),
|
|
children: (
|
|
<div>
|
|
<Text type="secondary">
|
|
{isCompareEntry
|
|
? tr(
|
|
"data_sync.compare_entry.preview.selection_hint",
|
|
)
|
|
: tr("data_sync.preview.selection_hint.update")}
|
|
</Text>
|
|
<Table
|
|
size="small"
|
|
style={{ marginTop: 8 }}
|
|
rowKey={(r: any) => r.pk}
|
|
dataSource={(previewData.updates || []).map(
|
|
(r: any) => ({ ...r, key: r.pk }),
|
|
)}
|
|
pagination={false}
|
|
rowSelection={{
|
|
selectedRowKeys: (tableOptions[previewTable]
|
|
?.selectedUpdatePks || []) as any,
|
|
onChange: (keys) =>
|
|
updateTableOption(
|
|
previewTable,
|
|
"selectedUpdatePks",
|
|
keys as string[],
|
|
),
|
|
getCheckboxProps: () => ({
|
|
disabled: !tableOptions[previewTable]?.update,
|
|
}),
|
|
}}
|
|
columns={[
|
|
{
|
|
title:
|
|
previewData.pkColumn ||
|
|
tr("data_sync.preview.column.primary_key"),
|
|
dataIndex: "pk",
|
|
key: "pk",
|
|
width: 200,
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: tr(
|
|
"data_sync.preview.column.changed_columns",
|
|
),
|
|
dataIndex: "changedColumns",
|
|
key: "changedColumns",
|
|
render: (v: any) =>
|
|
Array.isArray(v) ? v.join(", ") : "",
|
|
},
|
|
{
|
|
title: tr("data_sync.preview.column.detail"),
|
|
key: "detail",
|
|
width: 80,
|
|
render: (_: any, r: any) => (
|
|
<Button
|
|
size="small"
|
|
onClick={() => {
|
|
Modal.info({
|
|
title: tr(
|
|
"data_sync.preview.update_detail_title",
|
|
{ table: previewTable, pk: r.pk },
|
|
),
|
|
width: 900,
|
|
content: (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<div style={{ flex: 1 }}>
|
|
<Title level={5}>
|
|
{tr(
|
|
"data_sync.preview.side.source",
|
|
)}
|
|
</Title>
|
|
<pre
|
|
style={{
|
|
maxHeight: 360,
|
|
overflow: "auto",
|
|
background: "#f5f5f5",
|
|
padding: 8,
|
|
}}
|
|
>
|
|
{JSON.stringify(
|
|
r.source,
|
|
null,
|
|
2,
|
|
)}
|
|
</pre>
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<Title level={5}>
|
|
{tr(
|
|
"data_sync.preview.side.target",
|
|
)}
|
|
</Title>
|
|
<pre
|
|
style={{
|
|
maxHeight: 360,
|
|
overflow: "auto",
|
|
background: "#f5f5f5",
|
|
padding: 8,
|
|
}}
|
|
>
|
|
{JSON.stringify(
|
|
r.target,
|
|
null,
|
|
2,
|
|
)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
),
|
|
});
|
|
}}
|
|
>
|
|
{tr("data_sync.action.view")}
|
|
</Button>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "delete",
|
|
label: tr("data_sync.preview.tab.delete", {
|
|
count: previewData.totalDeletes || 0,
|
|
}),
|
|
children: (
|
|
<div>
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message={tr("data_sync.preview.delete_warning")}
|
|
/>
|
|
<Text type="secondary">
|
|
{isCompareEntry
|
|
? tr(
|
|
"data_sync.compare_entry.preview.selection_hint",
|
|
)
|
|
: tr("data_sync.preview.selection_hint.delete")}
|
|
</Text>
|
|
<Table
|
|
size="small"
|
|
style={{ marginTop: 8 }}
|
|
rowKey={(r: any) => r.pk}
|
|
dataSource={(previewData.deletes || []).map(
|
|
(r: any) => ({ ...r, key: r.pk }),
|
|
)}
|
|
pagination={false}
|
|
rowSelection={{
|
|
selectedRowKeys: (tableOptions[previewTable]
|
|
?.selectedDeletePks || []) as any,
|
|
onChange: (keys) =>
|
|
updateTableOption(
|
|
previewTable,
|
|
"selectedDeletePks",
|
|
keys as string[],
|
|
),
|
|
getCheckboxProps: () => ({
|
|
disabled: !tableOptions[previewTable]?.delete,
|
|
}),
|
|
}}
|
|
columns={[
|
|
{
|
|
title:
|
|
previewData.pkColumn ||
|
|
tr("data_sync.preview.column.primary_key"),
|
|
dataIndex: "pk",
|
|
key: "pk",
|
|
width: 200,
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: tr("data_sync.preview.column.data"),
|
|
dataIndex: "row",
|
|
key: "row",
|
|
render: (v: any) => (
|
|
<pre
|
|
style={{
|
|
margin: 0,
|
|
maxHeight: 140,
|
|
overflow: "auto",
|
|
}}
|
|
>
|
|
{JSON.stringify(v, null, 2)}
|
|
</pre>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
key: "sql",
|
|
label: tr("data_sync.preview.tab.sql", {
|
|
count: previewSql.statementCount,
|
|
}),
|
|
children: (
|
|
<div>
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
message={
|
|
previewHasDataDiff
|
|
? isCompareEntry
|
|
? tr(
|
|
"data_sync.compare_entry.preview.sql.data_help",
|
|
)
|
|
: tr("data_sync.preview.sql.data_help")
|
|
: isCompareEntry
|
|
? tr(
|
|
"data_sync.compare_entry.preview.sql.schema_help",
|
|
)
|
|
: tr("data_sync.preview.sql.schema_help")
|
|
}
|
|
/>
|
|
<div
|
|
style={{
|
|
marginTop: 8,
|
|
marginBottom: 8,
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Text type="secondary">
|
|
{previewHasDataDiff
|
|
? tr("data_sync.preview.sql.statement_count", {
|
|
count: previewSql.statementCount,
|
|
})
|
|
: tr(
|
|
"data_sync.preview.sql.schema_statement_count",
|
|
{ count: previewSql.statementCount },
|
|
)}
|
|
</Text>
|
|
<Button
|
|
size="small"
|
|
disabled={!previewSql.sqlText}
|
|
onClick={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(
|
|
previewSql.sqlText || "",
|
|
);
|
|
message.success(
|
|
tr("data_sync.preview.message.sql_copied"),
|
|
);
|
|
} catch {
|
|
message.error(
|
|
tr("data_sync.preview.message.copy_failed"),
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
{tr("data_sync.preview.action.copy_sql")}
|
|
</Button>
|
|
</div>
|
|
<pre
|
|
style={{
|
|
margin: 0,
|
|
padding: 10,
|
|
border: "1px solid #f0f0f0",
|
|
borderRadius: 6,
|
|
background: "#fafafa",
|
|
maxHeight: 420,
|
|
overflow: "auto",
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-word",
|
|
}}
|
|
>
|
|
{previewSql.sqlText ||
|
|
(previewHasDataDiff
|
|
? tr("data_sync.preview.sql.no_data_sql")
|
|
: tr("data_sync.preview.sql.no_schema_changes"))}
|
|
</pre>
|
|
</div>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Drawer>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default DataSyncModal;
|