mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-08 03:21:32 +08:00
✨ feat(data-sync/oceanbase): 拆分比对入口并修复 OceanBase Oracle 连接
- 数据同步:新增表结构比对、数据比对两个独立工具入口 - 比对模式:为 DataSyncModal 增加只读入口展示与模式化文案 - OceanBase:Oracle 租户改用 OB Oracle 专用 MySQL-wire 连接路径 - 连接表单:允许 OceanBase Oracle Service Name 留空,仅 TNS 场景需要填写 - 驱动提示:revision 不匹配提示收敛到驱动管理,不再在普通数据源入口弹出 - 测试覆盖:补充数据比对入口、OceanBase Oracle、driver-agent 提示边界测试
This commit is contained in:
@@ -9,6 +9,7 @@ import ConnectionModal from './components/ConnectionModal';
|
||||
import SnippetSettingsModal from './components/SnippetSettingsModal';
|
||||
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
|
||||
import DataSyncModal from './components/DataSyncModal';
|
||||
import { type DataSyncEntryMode } from './components/dataSyncEntryMode';
|
||||
import DriverManagerModal from './components/DriverManagerModal';
|
||||
import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';
|
||||
import LogPanel from './components/LogPanel';
|
||||
@@ -208,6 +209,7 @@ function App() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false);
|
||||
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
|
||||
const [syncModalEntryMode, setSyncModalEntryMode] = useState<DataSyncEntryMode>('sync');
|
||||
const [isDriverModalOpen, setIsDriverModalOpen] = useState(false);
|
||||
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
|
||||
const connectionModalWarmupDoneRef = useRef(false);
|
||||
@@ -3759,13 +3761,36 @@ function App() {
|
||||
void handleExportConnections();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'schema-compare',
|
||||
icon: <AppstoreOutlined />,
|
||||
title: '表结构比对',
|
||||
description: '对比源表与目标表结构差异,只预览不执行。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
setSyncModalEntryMode('schemaCompare');
|
||||
setIsSyncModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'data-compare',
|
||||
icon: <SwitcherOutlined />,
|
||||
title: '数据比对',
|
||||
description: '按主键分析新增、更新、删除和相同行。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
setSyncModalEntryMode('dataCompare');
|
||||
setIsSyncModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sync',
|
||||
icon: <UploadOutlined rotate={90} />,
|
||||
title: '数据同步',
|
||||
description: '进入跨源同步工作流。',
|
||||
description: '进入可执行写入的跨源同步工作流。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
setSyncModalEntryMode('sync');
|
||||
setIsSyncModalOpen(true);
|
||||
},
|
||||
},
|
||||
@@ -3976,6 +4001,7 @@ function App() {
|
||||
<DataSyncModal
|
||||
open={isSyncModalOpen}
|
||||
onClose={() => setIsSyncModalOpen(false)}
|
||||
entryMode={syncModalEntryMode}
|
||||
/>
|
||||
)}
|
||||
{isDriverModalOpen && (
|
||||
|
||||
@@ -163,6 +163,16 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain('type === "goldendb" ? "goldendb" : "mysql"');
|
||||
expect(source).toContain('? "goldendb"');
|
||||
});
|
||||
|
||||
it('keeps OceanBase Oracle service name optional for OBClient/MySQL-wire connections', () => {
|
||||
expect(source).toContain('OceanBase Oracle 服务名 (Service Name,可选)');
|
||||
expect(source).toContain('isOceanBaseOracle\n ? []');
|
||||
expect(source).toContain('连接 OBClient/OBServer MySQL-wire 入口时可留空');
|
||||
expect(source).toContain('只有连接 OBProxy Oracle listener/TNS 入口时才需要填写 SERVICE_NAME');
|
||||
expect(source).toContain('createUriAwareRequiredRule("请输入 Oracle 服务名(例如 ORCLPDB1)")');
|
||||
expect(source).not.toContain('请输入 OceanBase Oracle 服务名');
|
||||
expect(source).not.toContain('Oracle 租户必须填写监听器注册的 SERVICE_NAME');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionModal Redis Sentinel configuration', () => {
|
||||
|
||||
@@ -2075,7 +2075,7 @@ const ConnectionModal: React.FC<{
|
||||
const scheme =
|
||||
dbType === "diros" ? "doris" : dbType === "starrocks" ? "starrocks" : dbType === "oceanbase" ? "oceanbase" : dbType === "goldendb" ? "goldendb" : "mysql";
|
||||
if (dbType === "oceanbase") {
|
||||
return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}/SERVICE_NAME?protocol=oracle`;
|
||||
return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}?protocol=oracle`;
|
||||
}
|
||||
return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
|
||||
}
|
||||
@@ -4227,14 +4227,6 @@ const ConnectionModal: React.FC<{
|
||||
? currentDriverSnapshot.message ||
|
||||
`${currentDriverSnapshot.name || dbType} 驱动未安装启用`
|
||||
: "";
|
||||
const currentDriverUpdateReason =
|
||||
hasCurrentDriverType &&
|
||||
currentDriverSnapshot?.connectable &&
|
||||
currentDriverSnapshot.needsUpdate
|
||||
? currentDriverSnapshot.message ||
|
||||
currentDriverSnapshot.updateReason ||
|
||||
`${currentDriverSnapshot.name || dbType} 驱动代理需要重装后才能应用当前版本的驱动侧更新`
|
||||
: "";
|
||||
const driverStatusChecking =
|
||||
hasCurrentDriverType && !driverStatusLoaded && step === 2;
|
||||
|
||||
@@ -5246,9 +5238,9 @@ const ConnectionModal: React.FC<{
|
||||
label="OceanBase 协议"
|
||||
help={
|
||||
<span>
|
||||
MySQL 租户选择 MySQL;Oracle 租户选择 Oracle。GoNavi 会根据端口自动选择:OB MySQL wire 端口走 OBClient capability 注入(与 Navicat 相同路径),OBProxy Oracle listener 端口走标准 TNS。
|
||||
MySQL 租户选择 MySQL;Oracle 租户选择 Oracle。Oracle 协议会优先使用 OBClient/OBServer MySQL-wire 入口;只有连接 OBProxy Oracle listener/TNS 入口时才需要 Service Name。
|
||||
<br />
|
||||
如果 Oracle 租户连接报「Error 1235」或 OBClient 握手失败,可在「连接参数」字段通过 <code>connectionAttributes=key1:value1,key2:value2</code> 覆盖 GoNavi 默认注入的 OBClient capability。
|
||||
如果 MySQL 协议连接 Oracle 租户报「Error 1235」,请切换为 Oracle 协议;通常不需要手工配置 connectionAttributes。
|
||||
</span>
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
@@ -5356,24 +5348,22 @@ const ConnectionModal: React.FC<{
|
||||
children: (
|
||||
<Form.Item
|
||||
name="database"
|
||||
label={isOceanBaseOracle ? "OceanBase Oracle 服务名 (Service Name)" : "服务名 (Service Name)"}
|
||||
rules={[
|
||||
createUriAwareRequiredRule(
|
||||
isOceanBaseOracle
|
||||
? "请输入 OceanBase Oracle 服务名"
|
||||
: "请输入 Oracle 服务名(例如 ORCLPDB1)",
|
||||
),
|
||||
]}
|
||||
label={isOceanBaseOracle ? "OceanBase Oracle 服务名 (Service Name,可选)" : "服务名 (Service Name)"}
|
||||
rules={
|
||||
isOceanBaseOracle
|
||||
? []
|
||||
: [createUriAwareRequiredRule("请输入 Oracle 服务名(例如 ORCLPDB1)")]
|
||||
}
|
||||
help={
|
||||
isOceanBaseOracle
|
||||
? "Oracle 租户必须填写监听器注册的 SERVICE_NAME;用户名仍按 OceanBase 租户格式填写。"
|
||||
? "连接 OBClient/OBServer MySQL-wire 入口时可留空;只有连接 OBProxy Oracle listener/TNS 入口时才需要填写 SERVICE_NAME。"
|
||||
: "请填写监听器注册的 SERVICE_NAME(不是用户名)。例如:ORCLPDB1"
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="例如:ORCLPDB1"
|
||||
placeholder={isOceanBaseOracle ? "TNS 入口例如 ORCLPDB1;OBClient/MySQL-wire 可留空" : "例如:ORCLPDB1"}
|
||||
/>
|
||||
</Form.Item>
|
||||
),
|
||||
@@ -6778,26 +6768,6 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentDriverUpdateReason && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
style={{ marginBottom: 12 }}
|
||||
message="当前数据源驱动代理建议重装"
|
||||
description={
|
||||
<Space size={8}>
|
||||
<span>{currentDriverUpdateReason}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => onOpenDriverManager?.()}
|
||||
>
|
||||
去驱动管理重装
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
const sectionItems: Array<{
|
||||
key: "basic" | "network" | "appearance";
|
||||
|
||||
32
frontend/src/components/DataSyncModal.entry-mode.test.ts
Normal file
32
frontend/src/components/DataSyncModal.entry-mode.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveDataSyncEntryModePresentation } from './dataSyncEntryMode';
|
||||
|
||||
describe('resolveDataSyncEntryModePresentation', () => {
|
||||
it('marks schema compare as a read-only independent entry', () => {
|
||||
const presentation = resolveDataSyncEntryModePresentation('schemaCompare');
|
||||
|
||||
expect(presentation.title).toBe('表结构比对');
|
||||
expect(presentation.analyzeButtonText).toBe('开始比对');
|
||||
expect(presentation.badgeText).toBe('结构比对');
|
||||
expect(presentation.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('marks data compare as a read-only independent entry', () => {
|
||||
const presentation = resolveDataSyncEntryModePresentation('dataCompare');
|
||||
|
||||
expect(presentation.title).toBe('数据比对');
|
||||
expect(presentation.tableSelectLabel).toContain('比对数据');
|
||||
expect(presentation.badgeText).toBe('数据比对');
|
||||
expect(presentation.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the original sync entry writable', () => {
|
||||
const presentation = resolveDataSyncEntryModePresentation('sync');
|
||||
|
||||
expect(presentation.title).toBe('数据同步工作台');
|
||||
expect(presentation.analyzeButtonText).toBe('对比差异');
|
||||
expect(presentation.badgeText).toBe('同步模式');
|
||||
expect(presentation.readOnly).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
|
||||
import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest';
|
||||
import { resolveDataSyncEntryModePresentation, type DataSyncEntryMode } from './dataSyncEntryMode';
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
const { Option } = Select;
|
||||
@@ -188,7 +189,11 @@ const buildSqlPreview = (
|
||||
};
|
||||
};
|
||||
|
||||
const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: DataSyncEntryMode }> = ({ open, onClose, entryMode = 'sync' }) => {
|
||||
const entryPresentation = resolveDataSyncEntryModePresentation(entryMode);
|
||||
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);
|
||||
@@ -217,7 +222,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
|
||||
// Options
|
||||
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
|
||||
const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>('data');
|
||||
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');
|
||||
@@ -276,8 +281,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
});
|
||||
|
||||
return () => {
|
||||
offLog();
|
||||
offProgress();
|
||||
if (typeof offLog === 'function') offLog();
|
||||
if (typeof offProgress === 'function') offProgress();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -299,7 +304,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setSourceDatasetMode('table');
|
||||
setSourceQuery('');
|
||||
setWorkflowType('sync');
|
||||
setSyncContent('data');
|
||||
setSyncContent(isSchemaCompareEntry ? 'schema' : 'data');
|
||||
setSyncMode('insert_update');
|
||||
setAutoAddColumns(true);
|
||||
setTargetTableStrategy('existing_only');
|
||||
@@ -319,9 +324,48 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
jobIdRef.current = '';
|
||||
autoScrollRef.current = true;
|
||||
}
|
||||
}, [open]);
|
||||
}, [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');
|
||||
@@ -343,7 +387,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setCreateIndexes(false);
|
||||
}
|
||||
}
|
||||
}, [workflowType]);
|
||||
}, [isSchemaCompareEntry, isDataCompareEntry, workflowType, sourceDatasetMode, syncContent, syncMode, targetTableStrategy, createIndexes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceDatasetMode !== 'query') return;
|
||||
@@ -371,38 +415,38 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
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) { message.error("Failed to fetch source databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
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) { message.error("Failed to fetch source databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetConnChange = async (connId: string) => {
|
||||
setTargetConnId(connId);
|
||||
setTargetDb('');
|
||||
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) { message.error("Failed to fetch target databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
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) { message.error("Failed to fetch target databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nextToTables = async () => {
|
||||
@@ -416,15 +460,15 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
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() !== '');
|
||||
setAllTables(tables as string[]);
|
||||
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() !== '');
|
||||
setAllTables(tables as string[]);
|
||||
setSelectedTables(prev => {
|
||||
const existing = prev.filter((name) => tables.includes(name));
|
||||
if (isSourceQueryMode) {
|
||||
@@ -432,8 +476,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
}
|
||||
@@ -681,7 +725,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
}, [diffTables]);
|
||||
|
||||
const isSourceQueryMode = sourceDatasetMode === 'query';
|
||||
const isMigrationWorkflow = workflowType === 'migration';
|
||||
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 sourceType = String(sourceConn?.config?.type || '').toLowerCase();
|
||||
@@ -797,7 +841,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={renderModalTitle(isMigrationWorkflow ? '跨库迁移工作台' : '数据同步工作台', isMigrationWorkflow ? '按源库 → 目标库完成建表、导入与风险预检。' : '按已有目标表完成差异对比、同步执行与结果确认。')}
|
||||
title={renderModalTitle(isMigrationWorkflow ? '跨库迁移工作台' : entryPresentation.title, isMigrationWorkflow ? '按源库 → 目标库完成建表、导入与风险预检。' : entryPresentation.description)}
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
if (syncing) {
|
||||
@@ -830,15 +874,15 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<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 ? '跨数据源迁移' : '数据同步'}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{isMigrationWorkflow ? '跨数据源迁移' : entryPresentation.heroTitle}</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
|
||||
? '适合把源表迁移到另一套数据库,可按策略自动建表、导入数据并补建可兼容索引。'
|
||||
: '适合目标表已存在的场景,先做差异分析,再按勾选执行插入、更新或删除。'}
|
||||
: entryPresentation.heroDescription}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<span style={badgeStyle}>{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />} {isMigrationWorkflow ? '迁移模式' : '同步模式'}</span>
|
||||
<span style={badgeStyle}>{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />} {isMigrationWorkflow ? '迁移模式' : entryPresentation.badgeText}</span>
|
||||
<span style={badgeStyle}><DatabaseOutlined /> {sourceConnId ? '已选源连接' : '待选源连接'}</span>
|
||||
<span style={badgeStyle}><TableOutlined /> {selectedTables.length || 0} 张表</span>
|
||||
</div>
|
||||
@@ -847,7 +891,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Steps current={currentStep} style={{ marginBottom: 24 }}>
|
||||
<Step title="配置源与目标" />
|
||||
<Step title="选择表" />
|
||||
<Step title="执行结果" />
|
||||
<Step title={entryPresentation.resultTitle} />
|
||||
</Steps>
|
||||
</div>
|
||||
|
||||
@@ -900,35 +944,45 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={isMigrationWorkflow ? '迁移选项' : '同步选项'}
|
||||
title={isMigrationWorkflow ? '迁移选项' : entryPresentation.optionTitle}
|
||||
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
|
||||
? '当前入口只做差异分析和预览,不会执行同步、建表、补字段或删除数据。'
|
||||
: '先明确当前要做的是“已有目标表同步”还是“跨库迁移”,页面会按功能类型自动给出更安全的默认策略。'}
|
||||
</Text>
|
||||
</div>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="功能类型">
|
||||
<Select value={workflowType} onChange={setWorkflowType}>
|
||||
<Option value="sync">数据同步(基于已有目标表做差异同步)</Option>
|
||||
<Option value="migration" disabled={isSourceQueryMode}>跨库迁移(可自动建表后导入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="源数据方式">
|
||||
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
|
||||
<Option value="table">按表同步</Option>
|
||||
<Option value="query">按 SQL 结果集同步</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{!isCompareEntry && (
|
||||
<Form.Item label="功能类型">
|
||||
<Select value={workflowType} onChange={setWorkflowType}>
|
||||
<Option value="sync">数据同步(基于已有目标表做差异同步)</Option>
|
||||
<Option value="migration" disabled={isSourceQueryMode}>跨库迁移(可自动建表后导入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isSchemaCompareEntry && (
|
||||
<Form.Item label="源数据方式">
|
||||
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
|
||||
<Option value="table">{isCompareEntry ? '按表比对' : '按表同步'}</Option>
|
||||
<Option value="query">{isCompareEntry ? '按 SQL 结果集比对' : '按 SQL 结果集同步'}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Alert
|
||||
type={isMigrationWorkflow ? 'info' : 'success'}
|
||||
type={isMigrationWorkflow || isCompareEntry ? 'info' : 'success'}
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={isMigrationWorkflow
|
||||
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
|
||||
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
|
||||
: isSchemaCompareEntry
|
||||
? '当前为“表结构比对”入口:固定只分析结构差异和生成可审阅 SQL,不执行变更。'
|
||||
: isDataCompareEntry
|
||||
? '当前为“数据比对”入口:固定按主键分析行级差异,不执行写入。'
|
||||
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
|
||||
/>
|
||||
{isSourceQueryMode && (
|
||||
<Alert
|
||||
@@ -938,27 +992,33 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
message="SQL 结果集同步当前只支持:源端自定义 SQL -> 单个已存在目标表;查询结果需包含目标表主键列。"
|
||||
/>
|
||||
)}
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
|
||||
<Select value={syncContent} onChange={setSyncContent}>
|
||||
<Option value="data">仅同步数据</Option>
|
||||
<Option value="schema" disabled={isSourceQueryMode}>仅同步结构</Option>
|
||||
<Option value="both" disabled={isSourceQueryMode}>同步结构 + 数据</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
|
||||
<Select value={syncMode} onChange={setSyncMode} disabled={syncContent === 'schema'}>
|
||||
<Option value="insert_update">增量同步(对比差异,按插入/更新/删除勾选执行)</Option>
|
||||
<Option value="insert_only">仅插入(不对比目标;无主键表将跳过)</Option>
|
||||
<Option value="full_overwrite">全量覆盖(清空目标表后插入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
|
||||
<Option value="existing_only">仅使用已有目标表</Option>
|
||||
<Option value="auto_create_if_missing">目标表不存在时自动建表后导入</Option>
|
||||
<Option value="smart">智能模式(存在则直接导入,不存在则自动建表)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{!isCompareEntry && (
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
|
||||
<Select value={syncContent} onChange={setSyncContent}>
|
||||
<Option value="data">仅同步数据</Option>
|
||||
<Option value="schema" disabled={isSourceQueryMode}>仅同步结构</Option>
|
||||
<Option value="both" disabled={isSourceQueryMode}>同步结构 + 数据</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isCompareEntry && (
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
|
||||
<Select value={syncMode} onChange={setSyncMode} disabled={syncContent === 'schema'}>
|
||||
<Option value="insert_update">增量同步(对比差异,按插入/更新/删除勾选执行)</Option>
|
||||
<Option value="insert_only">仅插入(不对比目标;无主键表将跳过)</Option>
|
||||
<Option value="full_overwrite">全量覆盖(清空目标表后插入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isCompareEntry && (
|
||||
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
|
||||
<Option value="existing_only">仅使用已有目标表</Option>
|
||||
<Option value="auto_create_if_missing">目标表不存在时自动建表后导入</Option>
|
||||
<Option value="smart">智能模式(存在则直接导入,不存在则自动建表)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{isRedisMongoKeyspaceMigration && (
|
||||
<Form.Item
|
||||
label="Mongo 集合名(可选)"
|
||||
@@ -975,16 +1035,22 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
|
||||
自动补齐目标表缺失字段(按源/目标数据源选择可兼容规划器;SQL 结果集模式暂不支持)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
|
||||
自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
{(!isCompareEntry || isSchemaCompareEntry) && (
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
|
||||
{isSchemaCompareEntry
|
||||
? '生成目标表缺失字段的兼容变更 SQL(仅预览,不执行)'
|
||||
: '自动补齐目标表缺失字段(按源/目标数据源选择可兼容规划器;SQL 结果集模式暂不支持)'}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isCompareEntry && (
|
||||
<Form.Item>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
|
||||
自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
{isMigrationWorkflow && targetTableStrategy !== 'existing_only' && (
|
||||
<Alert
|
||||
type="info"
|
||||
@@ -993,7 +1059,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{!isMigrationWorkflow && (
|
||||
{!isCompareEntry && !isMigrationWorkflow && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
@@ -1020,7 +1086,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{!isSourceQueryMode && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<Text type="secondary">{entryPresentation.tableSelectLabel}</Text>
|
||||
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
|
||||
显示相同表
|
||||
</Checkbox>
|
||||
@@ -1204,48 +1270,50 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{currentStep === 2 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={quietPanelStyle}>
|
||||
<Alert
|
||||
message={syncing ? "正在同步" : (syncResult?.success ? "同步完成" : "同步失败")}
|
||||
description={
|
||||
syncing
|
||||
? `当前阶段:${syncProgress.stage || '执行中'}${syncProgress.table ? `,表:${syncProgress.table}` : ''}`
|
||||
: (syncResult?.message || `成功同步 ${syncResult?.tablesSynced || 0} 张表. 插入: ${syncResult?.rowsInserted || 0}, 更新: ${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}`}
|
||||
<Alert
|
||||
message={syncing ? (isCompareEntry ? '正在比对' : '正在同步') : (syncResult?.success ? (isCompareEntry ? '比对完成' : '同步完成') : (isCompareEntry ? '比对失败' : '同步失败'))}
|
||||
description={
|
||||
syncing
|
||||
? `当前阶段:${syncProgress.stage || '执行中'}${syncProgress.table ? `,表:${syncProgress.table}` : ''}`
|
||||
: (syncResult?.message || (isCompareEntry
|
||||
? `成功比对 ${diffTables.length || syncResult?.tablesSynced || 0} 张表。`
|
||||
: `成功同步 ${syncResult?.tablesSynced || 0} 张表. 插入: ${syncResult?.rowsInserted || 0}, 更新: ${syncResult?.rowsUpdated || 0}`))
|
||||
}
|
||||
type={syncing ? "info" : (syncResult?.success ? "success" : "error")}
|
||||
showIcon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 }}>执行日志</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>
|
||||
<Divider orientation="left" style={{ marginTop: 0 }}>{isCompareEntry ? '分析日志' : '执行日志'}</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>
|
||||
)}
|
||||
@@ -1256,25 +1324,32 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{currentStep === 0 && (
|
||||
<Button type="primary" onClick={nextToTables} loading={loading}>下一步</Button>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<>
|
||||
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}>上一步</Button>
|
||||
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
|
||||
对比差异
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={runSync}
|
||||
loading={loading}
|
||||
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
|
||||
>
|
||||
开始同步
|
||||
</Button>
|
||||
{currentStep === 1 && (
|
||||
<>
|
||||
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}>上一步</Button>
|
||||
<Button onClick={analyzeDiff} loading={loading} disabled={selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
|
||||
{entryPresentation.analyzeButtonText}
|
||||
</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)}
|
||||
>
|
||||
开始同步
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<>
|
||||
<Button disabled={syncing} onClick={() => setCurrentStep(1)} style={{ marginRight: 8 }}>继续同步</Button>
|
||||
<Button disabled={syncing} onClick={() => setCurrentStep(1)} style={{ marginRight: 8 }}>{isCompareEntry ? '返回比对' : '继续同步'}</Button>
|
||||
<Button type="primary" disabled={syncing} onClick={onClose}>关闭</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -1351,7 +1426,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
label: `插入(${previewData.totalInserts || 0})`,
|
||||
children: (
|
||||
<div>
|
||||
<Text type="secondary">未勾选任何行表示“同步全部插入差异”;如不想执行插入请在对比结果中取消勾选“插入”。</Text>
|
||||
<Text type="secondary">{isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : '未勾选任何行表示“同步全部插入差异”;如不想执行插入请在对比结果中取消勾选“插入”。'}</Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
@@ -1376,7 +1451,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
label: `更新(${previewData.totalUpdates || 0})`,
|
||||
children: (
|
||||
<div>
|
||||
<Text type="secondary">未勾选任何行表示“同步全部更新差异”;如不想执行更新请在对比结果中取消勾选“更新”。</Text>
|
||||
<Text type="secondary">{isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : '未勾选任何行表示“同步全部更新差异”;如不想执行更新请在对比结果中取消勾选“更新”。'}</Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
@@ -1427,7 +1502,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
children: (
|
||||
<div>
|
||||
<Alert type="warning" showIcon message="删除默认不勾选。请确认业务允许后再开启删除操作。" />
|
||||
<Text type="secondary">未勾选任何行表示“同步全部删除差异”;如不想执行删除请在对比结果中取消勾选“删除”。</Text>
|
||||
<Text type="secondary">{isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : '未勾选任何行表示“同步全部删除差异”;如不想执行删除请在对比结果中取消勾选“删除”。'}</Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
@@ -1457,8 +1532,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
showIcon
|
||||
message={
|
||||
previewHasDataDiff
|
||||
? "SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||
: "SQL 预览展示将执行的结构变更语句,用于审核确认。"
|
||||
? (isCompareEntry ? 'SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,仅用于审核差异。' : 'SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。')
|
||||
: (isCompareEntry ? 'SQL 预览展示结构差异建议语句,仅用于审核差异。' : 'SQL 预览展示将执行的结构变更语句,用于审核确认。')
|
||||
}
|
||||
/>
|
||||
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import React from 'react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DriverManagerModal from './DriverManagerModal';
|
||||
|
||||
const connectionModalSource = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8');
|
||||
const driverManagerModalSource = readFileSync(new URL('./DriverManagerModal.tsx', import.meta.url), 'utf8');
|
||||
const sidebarSource = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
theme: 'light',
|
||||
appearance: {
|
||||
@@ -57,6 +62,20 @@ vi.mock('@ant-design/icons', () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('driver-agent update prompt placement', () => {
|
||||
it('keeps revision mismatch prompts inside driver manager only', () => {
|
||||
expect(driverManagerModalSource).toContain('需要重装');
|
||||
expect(driverManagerModalSource).toContain('row.needsUpdate');
|
||||
|
||||
expect(connectionModalSource).not.toContain('当前数据源驱动代理建议重装');
|
||||
expect(connectionModalSource).not.toContain('去驱动管理重装');
|
||||
|
||||
expect(sidebarSource).not.toContain('warnIfConnectionDriverAgentNeedsUpdate');
|
||||
expect(sidebarSource).not.toContain('driver-agent-update-');
|
||||
expect(sidebarSource).not.toContain('驱动代理需要重装:');
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock('antd', () => {
|
||||
const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
|
||||
<button type="button" disabled={disabled || loading} onClick={onClick} {...rest}>
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
|
||||
import { getDbIcon } from './DatabaseIcons';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities } from '../../wailsjs/go/app/App';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
@@ -91,7 +91,6 @@ import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
|
||||
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
|
||||
import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
|
||||
import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag';
|
||||
import type { DriverStatusSnapshot } from '../utils/connectionDriverType';
|
||||
import JVMModeBadge from './jvm/JVMModeBadge';
|
||||
import MessagePublishModal from './MessagePublishModal';
|
||||
import {
|
||||
@@ -104,7 +103,6 @@ import {
|
||||
isExternalSQLDirectoryModalMode,
|
||||
isPostgresSchemaDialect,
|
||||
isV2SidebarObjectNode,
|
||||
normalizeDriverType,
|
||||
normalizeMySQLViewDDLForEditing,
|
||||
resolveSavedConnectionDriverType,
|
||||
resolveSidebarContextMenuPosition,
|
||||
@@ -247,8 +245,6 @@ interface BatchObjectItem {
|
||||
dataRef: any;
|
||||
}
|
||||
|
||||
const DRIVER_STATUS_CACHE_TTL_MS = 30_000;
|
||||
|
||||
const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
|
||||
smart: <ThunderboltOutlined />,
|
||||
object: <TableOutlined />,
|
||||
@@ -525,8 +521,6 @@ const Sidebar: React.FC<{
|
||||
selectedNodes: [],
|
||||
activeContext: null,
|
||||
});
|
||||
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
|
||||
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
|
||||
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
|
||||
const [contextMenu, setContextMenu] = useState<SidebarContextMenuState | null>(null);
|
||||
const contextMenuPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -1825,67 +1819,8 @@ const Sidebar: React.FC<{
|
||||
return { schemas, supported: hasSuccessfulQuery };
|
||||
};
|
||||
|
||||
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
|
||||
const cached = driverStatusCacheRef.current;
|
||||
if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) {
|
||||
return cached.items;
|
||||
}
|
||||
const result: Record<string, DriverStatusSnapshot> = {};
|
||||
const res = await GetDriverStatusList('', '');
|
||||
if (!res?.success) {
|
||||
return result;
|
||||
}
|
||||
const data = (res.data || {}) as any;
|
||||
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
|
||||
drivers.forEach((item: any) => {
|
||||
const type = normalizeDriverType(String(item.type || '').trim());
|
||||
if (!type) return;
|
||||
result[type] = {
|
||||
type,
|
||||
name: String(item.name || item.type || type).trim(),
|
||||
connectable: !!item.connectable,
|
||||
expectedRevision: String(item.expectedRevision || '').trim() || undefined,
|
||||
needsUpdate: !!item.needsUpdate,
|
||||
updateReason: String(item.updateReason || '').trim() || undefined,
|
||||
message: String(item.message || '').trim() || undefined,
|
||||
};
|
||||
});
|
||||
driverStatusCacheRef.current = { fetchedAt: Date.now(), items: result };
|
||||
return result;
|
||||
};
|
||||
|
||||
const warnIfConnectionDriverAgentNeedsUpdate = async (conn: SavedConnection) => {
|
||||
try {
|
||||
const driverType = resolveSavedConnectionDriverType(conn);
|
||||
if (!driverType || driverType === 'custom') {
|
||||
return;
|
||||
}
|
||||
const statusMap = await fetchDriverStatusMap();
|
||||
const status = statusMap[driverType];
|
||||
if (!status?.connectable || !status.needsUpdate) {
|
||||
return;
|
||||
}
|
||||
const revisionKey = status.expectedRevision || status.updateReason || status.message || 'unknown';
|
||||
const warningKey = `${conn.id}:${driverType}:${revisionKey}`;
|
||||
if (driverUpdateWarningKeysRef.current.has(warningKey)) {
|
||||
return;
|
||||
}
|
||||
driverUpdateWarningKeysRef.current.add(warningKey);
|
||||
const driverName = status.name || driverType;
|
||||
const reason = status.message || status.updateReason || `${driverName} driver-agent 与当前 GoNavi 版本要求不一致`;
|
||||
message.warning({
|
||||
content: `${driverName} 驱动代理需要重装:${reason}`,
|
||||
key: `driver-agent-update-${conn.id}`,
|
||||
duration: 10,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('检查驱动代理更新状态失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDatabases = async (node: any) => {
|
||||
const conn = node.dataRef as SavedConnection;
|
||||
void warnIfConnectionDriverAgentNeedsUpdate(conn);
|
||||
const loadKey = `dbs-${conn.id}`;
|
||||
if (loadingNodesRef.current.has(loadKey)) return;
|
||||
loadingNodesRef.current.add(loadKey);
|
||||
@@ -3161,7 +3096,6 @@ const Sidebar: React.FC<{
|
||||
};
|
||||
|
||||
const loadDatabasesForBatch = async (conn: SavedConnection) => {
|
||||
void warnIfConnectionDriverAgentNeedsUpdate(conn);
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
@@ -3483,7 +3417,6 @@ const Sidebar: React.FC<{
|
||||
|
||||
const loadDatabasesForDbBatch = async (conn: SavedConnection) => {
|
||||
setBatchConnContext(conn);
|
||||
void warnIfConnectionDriverAgentNeedsUpdate(conn);
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
|
||||
62
frontend/src/components/dataSyncEntryMode.ts
Normal file
62
frontend/src/components/dataSyncEntryMode.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type DataSyncEntryMode = 'sync' | 'schemaCompare' | 'dataCompare';
|
||||
|
||||
export type DataSyncEntryModePresentation = {
|
||||
title: string;
|
||||
description: string;
|
||||
heroTitle: string;
|
||||
heroDescription: string;
|
||||
optionTitle: string;
|
||||
tableSelectLabel: string;
|
||||
analyzeButtonText: string;
|
||||
closeButtonText: string;
|
||||
badgeText: string;
|
||||
resultTitle: string;
|
||||
readOnly: boolean;
|
||||
};
|
||||
|
||||
export const resolveDataSyncEntryModePresentation = (entryMode: DataSyncEntryMode): DataSyncEntryModePresentation => {
|
||||
switch (entryMode) {
|
||||
case 'schemaCompare':
|
||||
return {
|
||||
title: '表结构比对',
|
||||
description: '按源表与目标表生成结构差异、兼容风险和可审阅 SQL。',
|
||||
heroTitle: '表结构比对',
|
||||
heroDescription: '适合发布前核对两端表结构差异,只做分析与预览,不执行结构变更。',
|
||||
optionTitle: '比对选项',
|
||||
tableSelectLabel: '请选择需要比对结构的表:',
|
||||
analyzeButtonText: '开始比对',
|
||||
closeButtonText: '关闭',
|
||||
badgeText: '结构比对',
|
||||
resultTitle: '比对结果',
|
||||
readOnly: true,
|
||||
};
|
||||
case 'dataCompare':
|
||||
return {
|
||||
title: '数据比对',
|
||||
description: '按主键对比源表与目标表的数据差异,查看新增、更新和删除明细。',
|
||||
heroTitle: '数据比对',
|
||||
heroDescription: '适合核对两端数据一致性,只做差异分析与行级预览,不执行写入。',
|
||||
optionTitle: '比对选项',
|
||||
tableSelectLabel: '请选择需要比对数据的表:',
|
||||
analyzeButtonText: '开始比对',
|
||||
closeButtonText: '关闭',
|
||||
badgeText: '数据比对',
|
||||
resultTitle: '比对结果',
|
||||
readOnly: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: '数据同步工作台',
|
||||
description: '按已有目标表完成差异对比、同步执行与结果确认。',
|
||||
heroTitle: '数据同步',
|
||||
heroDescription: '适合目标表已存在的场景,先做差异分析,再按勾选执行插入、更新或删除。',
|
||||
optionTitle: '同步选项',
|
||||
tableSelectLabel: '请选择需要同步的表:',
|
||||
analyzeButtonText: '对比差异',
|
||||
closeButtonText: '关闭',
|
||||
badgeText: '同步模式',
|
||||
resultTitle: '执行结果',
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user