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:
Syngnat
2026-06-16 12:15:16 +08:00
parent 938bc53966
commit f41a15c7b8
15 changed files with 707 additions and 471 deletions

View File

@@ -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 && (

View File

@@ -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', () => {

View File

@@ -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 MySQLOracle OracleGoNavi OB MySQL wire OBClient capability Navicat OBProxy Oracle listener TNS
MySQL MySQLOracle OracleOracle 使 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 入口例如 ORCLPDB1OBClient/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";

View 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);
});
});

View File

@@ -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' }}>

View File

@@ -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}>

View File

@@ -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,

View 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,
};
}
};