Merge branch 'dev' into feature/20260602_connection_driver_i18n

This commit is contained in:
tianqijiuyun-latiao
2026-06-23 23:34:16 +08:00
39 changed files with 3045 additions and 1840 deletions

View File

@@ -472,6 +472,69 @@ describe("ConnectionModal i18n", () => {
},
);
it.each([
{
language: "zh-CN" as const,
sourceLabel: "MySQL",
expectations: [
"生产连接保护",
"按需勾选限制项",
"限制数据编辑",
"限制结构编辑",
"限制脚本执行",
"限制数据导入",
"当前策略",
],
},
{
language: "en-US" as const,
sourceLabel: "MySQL",
expectations: [
"Production guard",
"Select only the restrictions you need",
"Restrict data edits",
"Restrict structure edits",
"Restrict script execution",
"Restrict data import",
"Current policy",
],
},
])(
"renders a detailed production-guard card in $language",
async ({ language, sourceLabel, expectations }) => {
setCurrentLanguage(language);
storeState.languagePreference = language;
mockFormValues = {
type: "mysql",
restrictDataEdit: true,
restrictStructureEdit: true,
restrictScriptExecution: false,
restrictDataImport: false,
useSSL: true,
sslMode: "preferred",
timeout: 30,
};
const { default: ConnectionModal } = await import("./ConnectionModal");
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<ConnectionModal open onClose={vi.fn()} />);
});
await act(async () => {
findClickableCard(renderer!, sourceLabel).props.onClick();
});
const pageText = textContent(renderer!.toJSON());
expectations.forEach((expected) => {
expect(pageText).toContain(expected);
});
expect(pageText).not.toContain("connection.modal.section.undefined.title");
expect(pageText).not.toContain("connection.modal.section.undefined.description");
},
);
it("renders English topology and authentication copy for legacy mysql, mongodb, and redis sections", async () => {
storeState.appearance.uiVersion = "legacy";
setCurrentLanguage("en-US");

View File

@@ -62,6 +62,7 @@ import {
import { getCustomConnectionDsnValidationMessage } from "../utils/customConnectionDsn";
import { mergeParsedUriValuesForForm } from "../utils/connectionUriMerge";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { resolveConnectionProtectionConfig } from "../utils/connectionReadOnly";
import { getCustomConnectionDriverHelp } from "../utils/driverImportGuidance";
import { isBackendCancelledResult } from "../utils/connectionExport";
import {
@@ -1379,6 +1380,7 @@ const ConnectionModal: React.FC<{
: Number(config.timeout || 30);
const hasHttpTunnel = !!config.useHttpTunnel;
const hasProxy = !hasHttpTunnel && !!config.useProxy;
const protection = resolveConnectionProtectionConfig(config);
form.setFieldsValue({
type: configType,
name: initialValues.name,
@@ -1387,7 +1389,11 @@ const ConnectionModal: React.FC<{
user: config.user,
password: config.password,
database: config.database,
readOnly: config.readOnly === true,
restrictDataEdit: protection.restrictDataEdit === true,
restrictStructureEdit: protection.restrictStructureEdit === true,
restrictScriptExecution:
protection.restrictScriptExecution === true,
restrictDataImport: protection.restrictDataImport === true,
uri: config.uri || "",
connectionParams:
config.connectionParams ||

View File

@@ -32,6 +32,7 @@ import { v4 as generateUuid } from 'uuid';
import 'react-resizable/css/styles.css';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { isConnectionDataImportRestricted } from '../utils/connectionReadOnly';
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol';
@@ -158,7 +159,7 @@ import { useDataGridColumnResize } from './useDataGridColumnResize';
import { useDataGridPreviewPanel } from './useDataGridPreviewPanel';
import { buildTableExportTab } from '../utils/tableExportTab';
import { buildDataGridCssText } from './dataGridStyles';
import { formatMongoEditableValue, parseMongoEditedValue } from '../utils/mongodb';
import { formatMongoEditableValue, normalizeMongoDocumentForEditing, parseMongoEditedValue } from '../utils/mongodb';
// --- Error Boundary ---
import {
@@ -533,25 +534,27 @@ const DataGrid: React.FC<DataGridProps> = ({
const prefersManualTotalCount = dataSourceCaps.preferManualTotalCount;
const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount;
const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages;
const designerReadOnly = dataSourceCaps.forceReadOnlyStructureDesigner;
const importRestricted = isConnectionDataImportRestricted(currentConnConfig);
const dbType = dataSourceCaps.type;
const isMongoDBConnection = dbType === 'mongodb';
const isDuckDBConnection = dataSourceCaps.type === 'duckdb';
const supportsCopyInsert = dataSourceCaps.supportsCopyInsert;
const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport;
const isQueryResultExport = exportScope === 'queryResult';
const canImport = exportScope === 'table' && !!tableName && !readOnly;
const canImport = exportScope === 'table' && !!tableName && !importRestricted;
const canExport = !!connectionId && (isQueryResultExport || !!tableName);
const canViewDdl = exportScope === 'table' && !!connectionId && !!tableName;
const canOpenObjectDesigner = exportScope === 'table' && objectType === 'table' && !!connectionId && !!tableName;
const filteredExportSql = useMemo(() => String(exportSqlWithFilter || '').trim(), [exportSqlWithFilter]);
const hasFilteredExportSql = exportScope === 'table' && filteredExportSql.length > 0;
const mongoAwareEditableText = useCallback((value: any): string => (
isMongoDBConnection ? formatMongoEditableValue(value) : toEditableText(value)
const mongoAwareEditableText = useCallback((value: any, columnName?: string): string => (
isMongoDBConnection ? formatMongoEditableValue(value, columnName) : toEditableText(value)
), [isMongoDBConnection]);
const mongoAwareFormText = useCallback((value: any): string => (
isMongoDBConnection ? formatMongoEditableValue(value) : toFormText(value)
const mongoAwareFormText = useCallback((value: any, columnName?: string): string => (
isMongoDBConnection ? formatMongoEditableValue(value, columnName) : toFormText(value)
), [isMongoDBConnection]);
const normalizeMongoEditedCellValue = useCallback((columnName: string, value: any, currentValue?: any) => (
@@ -1494,9 +1497,15 @@ const DataGrid: React.FC<DataGridProps> = ({
updateCellSelection,
});
const baseData = useMemo(() => (
isMongoDBConnection
? data.map((row) => normalizeMongoDocumentForEditing(row))
: data
), [data, isMongoDBConnection]);
const displayData = useMemo(() => {
return [...data, ...addedRows];
}, [data, addedRows]);
return [...baseData, ...addedRows];
}, [baseData, addedRows]);
useEffect(() => { displayDataRef.current = displayData; }, [displayData]);
@@ -1610,7 +1619,7 @@ const DataGrid: React.FC<DataGridProps> = ({
}
if (deletedRowKeys.has(keyStr)) return;
// 查找原始行数据,对比是否真正有值变更
const originalRow = data.find(r => r?.[GONAVI_ROW_KEY] === rowKey);
const originalRow = baseData.find(r => r?.[GONAVI_ROW_KEY] === rowKey);
if (originalRow) {
const currentRow = modifiedRows[keyStr] ? { ...originalRow, ...modifiedRows[keyStr] } : originalRow;
const normalizedRow = normalizeMongoEditedRow(row, currentRow);
@@ -1651,7 +1660,7 @@ const DataGrid: React.FC<DataGridProps> = ({
});
setModifiedRows(prev => ({ ...prev, [keyStr]: normalizedRow }));
}
}, [addedRows, data, rowKeyStr, deletedRowKeys, effectiveEditLocator, modifiedRows, normalizeMongoEditedRow]);
}, [addedRows, baseData, rowKeyStr, deletedRowKeys, effectiveEditLocator, modifiedRows, normalizeMongoEditedRow]);
const handleDataPanelSave = useCallback(() => {
if (!focusedCellInfo) return;
@@ -1710,7 +1719,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return;
}
const originalRow = data.find((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === keyStr);
const originalRow = baseData.find((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === keyStr);
if (!originalRow) {
void message.error(translateDataGrid('data_grid.message.undo_cell_original_missing'));
setCellContextMenu(prev => ({ ...prev, visible: false }));
@@ -1720,7 +1729,7 @@ const DataGrid: React.FC<DataGridProps> = ({
handleCellSave({ ...record, [dataIndex]: originalRow[dataIndex] });
setCellContextMenu(prev => ({ ...prev, visible: false }));
void message.success(translateDataGrid('data_grid.message.undo_cell_success'));
}, [addedRowKeySet, cellContextMenu.dataIndex, cellContextMenu.record, data, handleCellSave, modifiedColumns, rowKeyStr, translateDataGrid]);
}, [addedRowKeySet, baseData, cellContextMenu.dataIndex, cellContextMenu.record, handleCellSave, modifiedColumns, rowKeyStr, translateDataGrid]);
const handleCellEditorSave = useCallback(() => {
if (!cellEditorMeta) return;
@@ -1770,7 +1779,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setCellFieldValue(form, fieldName, parseToDayjs(raw, pickerType));
} else {
const initialValue = isMongoDBConnection
? mongoAwareEditableText(raw)
? mongoAwareEditableText(raw, dataIndex)
: (typeof raw === 'string' ? normalizeDateTimeString(raw) : raw);
setCellFieldValue(form, fieldName, initialValue);
}
@@ -2043,7 +2052,7 @@ const DataGrid: React.FC<DataGridProps> = ({
}
const baseRow =
data.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) ||
baseData.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) ||
addedRows.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) ||
displayRow;
@@ -2056,7 +2065,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const baseVal = (baseRow as any)?.[col];
const displayVal = (displayRow as any)?.[col];
baseRawMap[col] = baseVal;
displayMap[col] = mongoAwareFormText(displayVal);
displayMap[col] = mongoAwareFormText(displayVal, col);
// 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用
const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()];
const rowPickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig);
@@ -2064,7 +2073,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const dVal = parseToDayjs(displayVal, rowPickerType);
formMap[col] = dVal;
} else {
formMap[col] = displayVal === null || displayVal === undefined ? undefined : mongoAwareFormText(displayVal);
formMap[col] = displayVal === null || displayVal === undefined ? undefined : mongoAwareFormText(displayVal, col);
}
if (baseVal === null || baseVal === undefined) nullCols.add(col);
});
@@ -2076,7 +2085,7 @@ const DataGrid: React.FC<DataGridProps> = ({
nullCols,
formValues: formMap,
});
}, [addedRows, canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, data, dbType, mergedDisplayData, mongoAwareFormText, openRowEditor, rowKeyStr, translateDataGrid, visibleColumnNames]);
}, [addedRows, baseData, canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, mergedDisplayData, mongoAwareFormText, openRowEditor, rowKeyStr, translateDataGrid, visibleColumnNames]);
const openCurrentViewRowEditor = useCallback(() => {
if (!canModifyData) return;
@@ -2140,7 +2149,7 @@ const DataGrid: React.FC<DataGridProps> = ({
});
const originalMap = new Map<string, any>();
data.forEach((r) => {
baseData.forEach((r) => {
const key = r?.[GONAVI_ROW_KEY];
if (key === undefined) return;
originalMap.set(rowKeyStr(key), r);
@@ -2213,7 +2222,7 @@ const DataGrid: React.FC<DataGridProps> = ({
closeJsonEditor();
void message.success(translateDataGrid('data_grid.message.json_applied'));
}, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, data, visibleColumnNames, effectiveEditLocator, closeJsonEditor, translateDataGrid]);
}, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, baseData, visibleColumnNames, effectiveEditLocator, closeJsonEditor, translateDataGrid]);
const openRowEditorFieldEditor = useCallback((dataIndex: string) => {
if (!dataIndex) return;
@@ -2701,7 +2710,7 @@ const DataGrid: React.FC<DataGridProps> = ({
addedRows,
modifiedRows,
deletedRowKeys,
data,
data: baseData,
editLocator: effectiveEditLocator,
visibleColumnNames,
rowKeyToString: rowKeyStr,
@@ -2748,7 +2757,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const rawErrorMessage = e?.message || String(e);
void message.error(translateDataGrid('data_grid.message.preview_sql_failed_detail', { detail: rawErrorMessage }));
}
}, [addedRows, modifiedRows, deletedRowKeys, data, effectiveEditLocator,
}, [addedRows, modifiedRows, deletedRowKeys, baseData, effectiveEditLocator,
visibleColumnNames, rowKeyStr, normalizeCommitCellValue, shouldCommitColumn,
connectionId, tableName, connections, rowLocatorMessages, translateDataGrid]);
@@ -2761,7 +2770,7 @@ const DataGrid: React.FC<DataGridProps> = ({
addedRows,
modifiedRows,
deletedRowKeys,
data,
data: baseData,
editLocator: effectiveEditLocator,
visibleColumnNames,
rowKeyToString: rowKeyStr,
@@ -2845,7 +2854,7 @@ const DataGrid: React.FC<DataGridProps> = ({
addedRows,
modifiedRows,
deletedRowKeys,
data,
baseData,
effectiveEditLocator,
visibleColumnNames,
rowKeyStr,
@@ -4180,6 +4189,7 @@ const DataGrid: React.FC<DataGridProps> = ({
copyRowsForPaste,
copyToClipboard,
currentConnConfig,
designerReadOnly,
currentTextRow,
darkMode,
dataContextValue,

View File

@@ -100,6 +100,7 @@ const DataGridShell: React.FC<DataGridShellProps> = (props) => {
copyRowsForPaste,
copyToClipboard,
currentConnConfig,
designerReadOnly,
currentTextRow,
darkMode,
dataContextValue,
@@ -734,7 +735,7 @@ const renderDataTableView = () => (
dbName,
tableName,
initialTab: 'columns',
readOnly,
readOnly: designerReadOnly,
objectType: 'table',
}}
/>

View File

@@ -3129,6 +3129,48 @@ describe('QueryEditor external SQL save', () => {
expect(tableSuggestion.detail).not.toContain('Table (analytics)');
});
it('deduplicates Oracle-style database qualified table completion labels when schema matches the qualifier', async () => {
storeState.languagePreference = 'zh-CN';
setCurrentLanguage('zh-CN');
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
editorState.value = 'select * from sbdev.AA';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({
success: true,
data: [{ Database: 'ORCLPDB1' }, { Database: 'sbdev' }],
});
backendApp.DBGetTables.mockImplementation(async (_config: any, dbName: string) => {
if (String(dbName || '').toLowerCase() === 'sbdev') {
return { success: true, data: [{ Table: 'SBDEV.AAA3_NJ' }] };
}
return { success: true, data: [] };
});
backendApp.DBGetAllColumns.mockResolvedValue({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'ORCLPDB1' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const completionProvider = editorState.providers[0];
expect(completionProvider).toBeTruthy();
const completionItems = await completionProvider.provideCompletionItems(
editorState.editor.getModel(),
{ lineNumber: 1, column: editorState.value.length + 1 },
);
const tableSuggestion = completionItems?.suggestions?.find((item: any) => item?.label === 'AAA3_NJ');
expect(tableSuggestion).toBeTruthy();
expect(tableSuggestion.insertText).toBe('AAA3_NJ');
expect(tableSuggestion.detail).toContain('表 (sbdev)');
expect(completionItems?.suggestions?.some((item: any) => item?.label === 'sbdev.SBDEV.AAA3_NJ')).toBe(false);
});
it('localizes schema-qualified table completion detail in zh-CN while preserving the raw database and schema names', async () => {
storeState.languagePreference = 'zh-CN';
setCurrentLanguage('zh-CN');
@@ -4307,6 +4349,47 @@ describe('QueryEditor external SQL save', () => {
expect(messageApi.success).toHaveBeenCalledWith('查询已保存。');
});
it('allows Ctrl/Cmd+S to save external SQL files from document-level targets', async () => {
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
editorState.hasTextFocus = false;
await act(async () => {
create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 6;';
const isMacRuntime = /(Mac|iPhone|iPad|iPod)/i.test(`${navigator.platform || ''} ${navigator.userAgent || ''}`);
const event = {
ctrlKey: !isMacRuntime,
metaKey: isMacRuntime,
altKey: false,
shiftKey: false,
key: 's',
target: document.body,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
await act(async () => {
windowListeners.keydown?.forEach((listener) => listener(event));
});
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 6;');
expect(messageApi.success).toHaveBeenCalledWith(expect.stringContaining('SQL 文件已保存'));
});
it('does not create saved queries when external SQL file writes fail', async () => {
let renderer!: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';

View File

@@ -191,6 +191,7 @@ let sharedMaterializedViewsData: CompletionViewMeta[] = [];
let sharedTriggersData: CompletionTriggerMeta[] = [];
let sharedRoutinesData: CompletionRoutineMeta[] = [];
let sharedColumnsCacheData: Record<string, any[]> = {};
const QUERY_EDITOR_LAZY_VISIBLE_DB_COMPLETION_LIMIT = 10;
const sharedLazyTablesCache: Record<string, CompletionTableMeta[] | undefined> = {};
const sharedLazyTablesInFlight: Record<string, Promise<CompletionTableMeta[]> | undefined> = {};
const createEmptySqlCompletionResult = () => ({ suggestions: [] as any[] });
@@ -204,6 +205,7 @@ const clearRecord = (record: Record<string, unknown>) => {
const resetSharedQueryEditorMetadata = () => {
sharedTablesData = [];
sharedAllColumnsData = [];
sharedVisibleDbs = [];
sharedViewsData = [];
sharedMaterializedViewsData = [];
sharedTriggersData = [];
@@ -1943,6 +1945,26 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const stripQuotes = stripCompletionIdentifierQuotes;
const normalizeQualifiedName = normalizeCompletionQualifiedName;
const splitSchemaAndTable = splitCompletionSchemaAndTable;
const buildDbQualifiedTableSuggestionMeta = (dbName: string, tableName: string) => {
const rawDbName = String(dbName || '').trim();
const rawTableName = String(tableName || '').trim();
const parsed = splitSchemaAndTable(rawTableName);
const schemaMatchesDb = !!parsed.schema
&& !!parsed.table
&& parsed.schema.toLowerCase() === rawDbName.toLowerCase();
const displayName = schemaMatchesDb ? parsed.table : rawTableName;
const insertText = schemaMatchesDb
? quoteCompletionPart(parsed.table)
: quoteCompletionPath(rawTableName);
const dbQualifiedLabel = rawDbName
? `${rawDbName}.${displayName || rawTableName}`
: (displayName || rawTableName);
return {
displayName: displayName || rawTableName,
insertText,
dbQualifiedLabel,
};
};
const buildConnConfig = () => {
const connId = sharedCurrentConnectionId;
@@ -2132,18 +2154,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
}
const filtered = prefix
? tables.filter(t => (t.tableName || '').toLowerCase().startsWith(prefix))
? tables.filter(t => {
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || qualifier, t.tableName || '');
return String(suggestionMeta.displayName || '').toLowerCase().startsWith(prefix)
|| String(t.tableName || '').toLowerCase().startsWith(prefix);
})
: tables;
const suggestions = filtered.map(t => ({
label: t.tableName,
const suggestions = filtered.map(t => {
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || qualifier, t.tableName || '');
return {
label: suggestionMeta.displayName,
kind: monaco.languages.CompletionItemKind.Class,
insertText: quoteCompletionPath(t.tableName),
insertText: suggestionMeta.insertText,
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName})`, t.comment),
documentation: buildCompletionDocumentation(t.comment),
range,
sortText: '0' + t.tableName
}));
sortText: '0' + suggestionMeta.displayName
};
});
return { suggestions };
}
@@ -2232,7 +2261,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (normalized.some((candidate) => candidate.includes(wordPrefix))) return '1';
return '9';
};
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim());
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix);
const shouldBoostKeywords = !expectsTableName
&& wordPrefix.length > 0
&& dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
@@ -2261,6 +2290,38 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
}
}
if (expectsTableName && sharedVisibleDbs.length > 1) {
const loadedDbKeys = new Set(
completionTables
.map((table) => String(table.dbName || '').toLowerCase())
.filter(Boolean),
);
const missingVisibleDbs = sharedVisibleDbs.filter((dbName) => {
const normalizedDbName = String(dbName || '').trim();
const dbKey = normalizedDbName.toLowerCase();
return normalizedDbName
&& dbKey !== currentDatabase.toLowerCase()
&& !loadedDbKeys.has(dbKey);
});
if (
missingVisibleDbs.length > 0
&& missingVisibleDbs.length <= QUERY_EDITOR_LAZY_VISIBLE_DB_COMPLETION_LIMIT
) {
const lazyTableGroups = await Promise.all(
missingVisibleDbs.map((dbName) => getLazyTablesByDB(dbName)),
);
if (isSqlCompletionRequestCancelled(token)) {
return createEmptySqlCompletionResult();
}
const seenTableKeys = new Set<string>();
completionTables = [...completionTables, ...lazyTableGroups.flat()].filter((table) => {
const key = `${String(table.dbName || '').toLowerCase()}.${String(table.tableName || '').toLowerCase()}`;
if (seenTableKeys.has(key)) return false;
seenTableKeys.add(key);
return true;
});
}
}
const referencedColumns: CompletionColumnMeta[] = [];
if (!expectsTableName) {
@@ -2327,9 +2388,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = parsed.table || t.tableName || '';
if (!isCurrentDb) {
// 跨库:用 db.table 格式匹配
return includesWordPrefix(`${t.dbName}.${t.tableName}`)
if (!isCurrentDb) {
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || '', t.tableName || '');
const label = suggestionMeta.dbQualifiedLabel;
// 跨库:用 db.table 格式匹配
return includesWordPrefix(label)
|| includesWordPrefix(t.tableName || '')
|| includesWordPrefix(pureTable);
}
@@ -2341,7 +2404,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = parsed.table || t.tableName || '';
if (!isCurrentDb) {
const label = `${t.dbName}.${t.tableName}`;
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || '', t.tableName || '');
const label = suggestionMeta.dbQualifiedLabel;
return {
label,
kind: monaco.languages.CompletionItemKind.Class,
@@ -2349,7 +2413,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName})`, t.comment),
documentation: buildCompletionDocumentation(t.comment),
range,
sortText: sortGroups.tableOther + getPrefixMatchRank(`${t.dbName}.${t.tableName}`, t.tableName || '', pureTable) + t.tableName,
sortText: sortGroups.tableOther + getPrefixMatchRank(label, t.tableName || '', pureTable) + label,
};
}
// 当前库:检查是否有跨 schema 同名表
@@ -4117,7 +4181,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const targetNode = resolveEventTargetNode(event.target);
const editorHasFocus = !!editor?.hasTextFocus?.();
const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode));
if (!editorHasFocus && !inQueryEditor) {
if (!editorHasFocus && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) {
return;
}

View File

@@ -119,6 +119,7 @@ import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import FindInDatabaseModal from './FindInDatabaseModal';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { isConnectionStructureEditRestricted } from '../utils/connectionReadOnly';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import {
resolveSidebarRuntimeDatabase,
@@ -1412,7 +1413,10 @@ const Sidebar: React.FC<{
const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => {
const { tableName, dbName, id } = node.dataRef;
const forceReadOnly = readOnly || isStructureOnlyDbType(id);
const conn = connections.find(c => c.id === id);
const forceReadOnly = readOnly
|| isStructureOnlyDbType(id)
|| isConnectionStructureEditRestricted(conn?.config);
addTab({
id: `design-${id}-${dbName}-${tableName}`,
title: forceReadOnly
@@ -1429,7 +1433,8 @@ const Sidebar: React.FC<{
const openNewTableDesign = (node: any) => {
const { dbName, id } = node.dataRef;
if (isStructureOnlyDbType(id)) {
const conn = connections.find(c => c.id === id);
if (isStructureOnlyDbType(id) || isConnectionStructureEditRestricted(conn?.config)) {
message.warning(t('sidebar.message.visual_new_table_unsupported'));
return;
}

View File

@@ -26,6 +26,7 @@ import { isMacLikePlatform } from '../utils/appearance';
import { getShortcutPlatform } from '../utils/shortcuts';
import { t } from '../i18n';
import { buildTableExportTab } from '../utils/tableExportTab';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu';
import { useExportProgressDialog } from './ExportProgressModal';
@@ -279,7 +280,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
[connection?.config?.driver, connection?.config?.oceanBaseProtocol, connection?.config?.type]
);
const schemaName = String((tab as any).schemaName || '').trim();
const supportsDesignWrite = metadataDialect !== 'iotdb';
const supportsDesignWrite = !getDataSourceCapabilities(connection?.config).forceReadOnlyStructureDesigner;
const autoFetchVisible = useAutoFetchVisibility();
const loadData = useCallback(async () => {

View File

@@ -37,7 +37,9 @@ import {
import ConnectionModalMongoSections from "../ConnectionModalMongoSections";
import ConnectionModalRedisSections from "../ConnectionModalRedisSections";
import { t } from "../../i18n";
import { supportsConnectionReadOnlyMode } from "../../utils/connectionReadOnly";
import {
supportsConnectionReadOnlyMode,
} from "../../utils/connectionReadOnly";
import {
getConnectionConfigLayoutKindLabel,
getStoredSecretPlaceholder,
@@ -198,6 +200,19 @@ const renderStep2 = () => {
driver: form.getFieldValue("driver"),
oceanBaseProtocol,
});
const restrictDataEdit = Form.useWatch("restrictDataEdit", form) === true;
const restrictStructureEdit =
Form.useWatch("restrictStructureEdit", form) === true;
const restrictScriptExecution =
Form.useWatch("restrictScriptExecution", form) === true;
const restrictDataImport =
Form.useWatch("restrictDataImport", form) === true;
const connectionProtectionEnabledCount = [
restrictDataEdit,
restrictStructureEdit,
restrictScriptExecution,
restrictDataImport,
].filter(Boolean).length;
const baseInfoSection = (
<div style={modalInnerSectionStyle}>
<div
@@ -1349,20 +1364,252 @@ const renderStep2 = () => {
renderConfigSectionCard({
sectionKey: "readOnly",
icon: <SafetyCertificateOutlined />,
children: (
<Form.Item
name="readOnly"
label={t("connection.modal.field.readOnly.label")}
help={t("connection.modal.field.readOnly.help")}
valuePropName="checked"
style={{ marginBottom: 0 }}
badge: (
<Tag
color={
connectionProtectionEnabledCount > 0 ? "red" : "default"
}
>
<Checkbox
onChange={() => clearConnectionTestResultForChoice()}
{connectionProtectionEnabledCount > 0
? t(
"connection.modal.field.readOnly.status.enabledCount",
{
count: connectionProtectionEnabledCount,
},
)
: t("connection.modal.field.readOnly.status.disabled")}
</Tag>
),
children: (
<div style={{ display: "grid", gap: 14 }}>
<div
style={{
padding: 16,
borderRadius: 16,
border: connectionProtectionEnabledCount > 0
? darkMode
? "1px solid rgba(255,120,117,0.34)"
: "1px solid rgba(245,34,45,0.18)"
: darkMode
? "1px solid rgba(255,214,102,0.24)"
: "1px solid rgba(250,173,20,0.18)",
background: connectionProtectionEnabledCount > 0
? darkMode
? "linear-gradient(180deg, rgba(255,120,117,0.12) 0%, rgba(255,120,117,0.05) 100%)"
: "linear-gradient(180deg, rgba(255,245,245,0.96) 0%, rgba(255,240,240,0.92) 100%)"
: darkMode
? "linear-gradient(180deg, rgba(255,214,102,0.10) 0%, rgba(255,214,102,0.04) 100%)"
: "linear-gradient(180deg, rgba(255,251,230,0.98) 0%, rgba(255,247,214,0.94) 100%)",
boxShadow: darkMode
? "inset 0 1px 0 rgba(255,255,255,0.04)"
: "inset 0 1px 0 rgba(255,255,255,0.92)",
}}
>
{t("connection.modal.field.readOnly.checkbox")}
</Checkbox>
</Form.Item>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) auto",
gap: 16,
alignItems: "start",
}}
>
<div>
<div
style={{
fontSize: 15,
fontWeight: 700,
color: darkMode ? "#f5f7ff" : "#162033",
}}
>
{t("connection.modal.field.readOnly.label")}
</div>
<div style={{ ...modalMutedTextStyle, marginTop: 6 }}>
{t("connection.modal.field.readOnly.help")}
</div>
</div>
<Text
style={{
fontSize: 12,
color: darkMode ? "#ffd591" : "#ad4e00",
whiteSpace: "nowrap",
}}
>
{t("connection.modal.field.readOnly.compatibility")}
</Text>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
{[
{
field: "restrictDataEdit",
checked: restrictDataEdit,
label: t(
"connection.modal.field.readOnly.option.dataEdit.label",
),
help: t(
"connection.modal.field.readOnly.option.dataEdit.help",
),
},
{
field: "restrictStructureEdit",
checked: restrictStructureEdit,
label: t(
"connection.modal.field.readOnly.option.structureEdit.label",
),
help: t(
"connection.modal.field.readOnly.option.structureEdit.help",
),
},
{
field: "restrictScriptExecution",
checked: restrictScriptExecution,
label: t(
"connection.modal.field.readOnly.option.scriptExecution.label",
),
help: t(
"connection.modal.field.readOnly.option.scriptExecution.help",
),
},
{
field: "restrictDataImport",
checked: restrictDataImport,
label: t(
"connection.modal.field.readOnly.option.dataImport.label",
),
help: t(
"connection.modal.field.readOnly.option.dataImport.help",
),
},
].map((item) => (
<div
key={item.field}
onClick={() =>
setChoiceFieldValue(item.field, !item.checked)
}
style={{
padding: 14,
borderRadius: 14,
border: item.checked
? darkMode
? "1px solid rgba(255,120,117,0.22)"
: "1px solid rgba(245,34,45,0.14)"
: darkMode
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(5,5,5,0.08)",
background: item.checked
? darkMode
? "rgba(255,120,117,0.08)"
: "rgba(255,241,240,0.92)"
: darkMode
? "rgba(255,255,255,0.02)"
: "rgba(255,255,255,0.9)",
cursor: "pointer",
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "auto minmax(0, 1fr)",
gap: 12,
alignItems: "start",
}}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
paddingTop: 2,
}}
onClick={(event) => event.stopPropagation()}
>
<Form.Item
name={item.field}
valuePropName="checked"
noStyle
>
<Checkbox
onChange={() =>
clearConnectionTestResultForChoice()
}
style={{
marginInlineStart: 0,
}}
/>
</Form.Item>
</div>
<div>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: darkMode ? "#f5f7ff" : "#162033",
}}
>
{item.label}
</div>
<div
style={{
...modalMutedTextStyle,
marginTop: 6,
whiteSpace: "normal",
}}
>
{item.help}
</div>
</div>
</div>
</div>
))}
</div>
<div
style={{
padding: 14,
borderRadius: 14,
border: darkMode
? "1px solid rgba(82,196,26,0.22)"
: "1px solid rgba(82,196,26,0.18)",
background: darkMode
? "rgba(82,196,26,0.08)"
: "rgba(246,255,237,0.92)",
}}
>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: darkMode ? "#f5f7ff" : "#162033",
}}
>
{t("connection.modal.field.readOnly.summary.title")}
</div>
<div style={{ ...modalMutedTextStyle, marginTop: 6 }}>
{connectionProtectionEnabledCount > 0
? t(
"connection.modal.field.readOnly.summary.selected",
{ count: connectionProtectionEnabledCount },
)
: t(
"connection.modal.field.readOnly.summary.empty",
)}
</div>
</div>
<div
style={{
...modalMutedTextStyle,
fontSize: 12,
lineHeight: 1.7,
padding: "0 4px",
}}
>
{t("connection.modal.field.readOnly.tip")}
</div>
</div>
),
})}
@@ -2333,7 +2580,10 @@ const renderStep2 = () => {
keepAliveIntervalMinutes: 240,
uri: "",
connectionParams: "",
readOnly: false,
restrictDataEdit: false,
restrictStructureEdit: false,
restrictScriptExecution: false,
restrictDataImport: false,
oceanBaseProtocol: "mysql",
mysqlTopology: "single",
rocketmqTopology: "single",

View File

@@ -1,5 +1,9 @@
import type { ConnectionConfig, SavedConnection } from "../../types";
import { supportsConnectionReadOnlyMode } from "../../utils/connectionReadOnly";
import {
deriveLegacyConnectionReadOnlyFlag,
normalizeConnectionProtectionConfig,
supportsConnectionReadOnlyMode,
} from "../../utils/connectionReadOnly";
import { resolveConnectionSecretDraft } from "../../utils/connectionSecretDraft";
import {
getConnectionTypeDefaultPort as getDefaultPortByType,
@@ -797,6 +801,19 @@ export const buildConnectionConfig = async ({
)
: normalizeConnectionParamsText(mergedValues.connectionParams)
: "";
const supportsProductionGuard = supportsConnectionReadOnlyMode({
type,
driver: mergedValues.driver,
oceanBaseProtocol: selectedOceanBaseProtocol,
});
const protection = supportsProductionGuard
? normalizeConnectionProtectionConfig({
restrictDataEdit: mergedValues.restrictDataEdit === true,
restrictStructureEdit: mergedValues.restrictStructureEdit === true,
restrictScriptExecution: mergedValues.restrictScriptExecution === true,
restrictDataImport: mergedValues.restrictDataImport === true,
})
: undefined;
return {
type: mergedValues.type,
@@ -806,12 +823,10 @@ export const buildConnectionConfig = async ({
password: keepPassword ? mergedValues.password || "" : "",
savePassword: savePassword,
database: mergedValues.database || "",
readOnly:
supportsConnectionReadOnlyMode({
type,
driver: mergedValues.driver,
oceanBaseProtocol: selectedOceanBaseProtocol,
}) && mergedValues.readOnly === true,
readOnly: protection
? deriveLegacyConnectionReadOnlyFlag(protection)
: false,
protection,
useSSL: effectiveUseSSL,
sslMode: effectiveUseSSL ? sslMode : "disable",
sslCAPath: sslCAPath,

View File

@@ -18,7 +18,7 @@ interface OpenRowEditorParams {
}
interface UseDataGridModalEditorsParams {
toEditableText: (value: any) => string;
toEditableText: (value: any, columnName?: string) => string;
looksLikeJsonText: (text: string) => boolean;
}
@@ -100,7 +100,7 @@ export const useDataGridModalEditors = ({
) => {
if (!record || !dataIndex) return;
const raw = record?.[dataIndex];
const text = toEditableText(raw);
const text = toEditableText(raw, dataIndex);
const isJson = looksLikeJsonText(text);
const titleText = typeof title === 'string'
? title

View File

@@ -9,7 +9,7 @@ export interface DataGridFocusedCellInfo {
}
interface UseDataGridPreviewPanelParams {
toEditableText: (value: any) => string;
toEditableText: (value: any, columnName?: string) => string;
looksLikeJsonText: (text: string) => boolean;
normalizeDateTimeString: (value: string) => string;
}
@@ -44,8 +44,8 @@ export const useDataGridPreviewPanel = ({
const updateFocusedCell = React.useCallback((record: GridRecord, dataIndex: string) => {
if (!record || !dataIndex) return;
const raw = record?.[dataIndex];
let text = toEditableText(raw);
if (typeof raw === 'string') {
let text = toEditableText(raw, dataIndex);
if (typeof raw === 'string' && text === raw) {
text = normalizeDateTimeString(raw);
}
const isJson = looksLikeJsonText(text);