mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-06 14:39:35 +08:00
🔧 chore(dev): 合并 open issue backlog 修复分支
- 合并已按 issue 拆分提交的 backlog 修复与 SQL 结果集同步能力 - 解决 DataGrid、Sidebar 以及 legacy WebKit 存储迁移测试的合并冲突 - 保留 dev 分支当前结构并移除已废弃的 issue backlog 跟踪文档
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
isShortcutMatch,
|
||||
normalizeShortcutCombo,
|
||||
} from './utils/shortcuts';
|
||||
import { resolveTitleBarToggleIconKey, shouldToggleMaximisedWindowForScaleFix } from './utils/windowStateUi';
|
||||
import {
|
||||
SIDEBAR_UTILITY_ITEM_KEYS,
|
||||
resolveAIEntryPlacement,
|
||||
@@ -168,6 +169,9 @@ function App() {
|
||||
const effectiveUiScale = Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Number(uiScale) || DEFAULT_UI_SCALE));
|
||||
const effectiveFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(Number(fontSize) || DEFAULT_FONT_SIZE)));
|
||||
const tokenFontSize = Math.round(effectiveFontSize * effectiveUiScale);
|
||||
const titleBarToggleIconKey = resolveTitleBarToggleIconKey(
|
||||
windowState === 'fullscreen' ? 'fullscreen' : (windowState === 'maximized' ? 'maximized' : 'normal')
|
||||
);
|
||||
const tokenFontSizeSM = Math.max(10, Math.round(tokenFontSize * 0.86));
|
||||
const tokenFontSizeLG = Math.max(tokenFontSize + 1, Math.round(tokenFontSize * 1.14));
|
||||
const tokenControlHeight = Math.max(24, Math.round(32 * effectiveUiScale));
|
||||
@@ -639,7 +643,7 @@ function App() {
|
||||
});
|
||||
|
||||
if (isMaximised) {
|
||||
if (reason !== 'ratio-change' && !hasViewportScaleDrift) {
|
||||
if (!shouldToggleMaximisedWindowForScaleFix(reason, hasViewportScaleDrift)) {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
lastFixAt = Date.now();
|
||||
return;
|
||||
@@ -2167,19 +2171,34 @@ function App() {
|
||||
}, [securityUpdateRepairSource]);
|
||||
|
||||
const handleTitleBarWindowToggle = async () => {
|
||||
const syncWindowStateFromRuntime = async () => {
|
||||
try {
|
||||
const [isFullscreen, isMaximised] = await Promise.all([
|
||||
WindowIsFullscreen().catch(() => false),
|
||||
WindowIsMaximised().catch(() => false),
|
||||
]);
|
||||
useStore.getState().setWindowState(isFullscreen ? 'fullscreen' : (isMaximised ? 'maximized' : 'normal'));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:before');
|
||||
if (await WindowIsFullscreen()) {
|
||||
await WindowUnfullscreen();
|
||||
await syncWindowStateFromRuntime();
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-unfullscreen');
|
||||
return;
|
||||
}
|
||||
if (useNativeMacWindowControls && isMacRuntime) {
|
||||
await WindowFullscreen();
|
||||
await syncWindowStateFromRuntime();
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-fullscreen');
|
||||
return;
|
||||
}
|
||||
await WindowToggleMaximise();
|
||||
await syncWindowStateFromRuntime();
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-toggle-maximise');
|
||||
} catch (_) {
|
||||
// ignore
|
||||
@@ -2583,7 +2602,7 @@ function App() {
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BorderOutlined />}
|
||||
icon={titleBarToggleIconKey === 'restore' ? <SwitcherOutlined /> : <BorderOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={() => { void handleTitleBarWindowToggle(); }}
|
||||
/>
|
||||
|
||||
@@ -51,6 +51,15 @@ import {
|
||||
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
|
||||
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
|
||||
import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import {
|
||||
TEMPORAL_FORMATS,
|
||||
formatFromDayjs,
|
||||
getTemporalPickerType,
|
||||
isTemporalColumnType,
|
||||
parseToDayjs,
|
||||
resolveTemporalEditorSaveValue,
|
||||
type TemporalPickerType,
|
||||
} from './dataGridTemporal';
|
||||
|
||||
// --- Error Boundary ---
|
||||
interface DataGridErrorBoundaryState {
|
||||
@@ -167,51 +176,6 @@ const normalizeDateTimeString = (val: string) => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const isTemporalColumnType = (columnType?: string): boolean => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return false;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
return base === 'date' || base === 'time' || base === 'year';
|
||||
};
|
||||
|
||||
// 根据列类型返回 DatePicker 的 picker 模式
|
||||
type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
|
||||
const getTemporalPickerType = (columnType?: string): TemporalPickerType => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp')) return 'datetime';
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
if (base === 'date') return 'date';
|
||||
if (base === 'time') return 'time';
|
||||
if (base === 'year') return 'year';
|
||||
return null;
|
||||
};
|
||||
|
||||
const TEMPORAL_FORMATS: Record<string, string> = {
|
||||
datetime: 'YYYY-MM-DD HH:mm:ss',
|
||||
date: 'YYYY-MM-DD',
|
||||
time: 'HH:mm:ss',
|
||||
year: 'YYYY',
|
||||
};
|
||||
|
||||
// 将字符串值转为 dayjs 对象(用于 DatePicker),无效值返回 null
|
||||
const parseToDayjs = (val: any, pickerType: TemporalPickerType): dayjs.Dayjs | null => {
|
||||
if (val === null || val === undefined || val === '') return null;
|
||||
const str = String(val).trim();
|
||||
if (!str || /^0{4}-0{2}-0{2}/.test(str)) return null; // 无效日期
|
||||
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
|
||||
const d = dayjs(str, fmt);
|
||||
return d.isValid() ? d : dayjs(str).isValid() ? dayjs(str) : null;
|
||||
};
|
||||
|
||||
// 将 dayjs 对象格式化为对应格式字符串
|
||||
const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => {
|
||||
if (!val || !val.isValid()) return '';
|
||||
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
|
||||
return val.format(fmt);
|
||||
};
|
||||
|
||||
// --- Helper: Format Value ---
|
||||
const formatCellValue = (val: any) => {
|
||||
try {
|
||||
@@ -640,17 +604,14 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
setEditing(!editing);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const save = async (pickerValue?: dayjs.Dayjs | null) => {
|
||||
try {
|
||||
if (!form || !editing) return;
|
||||
const fieldName = getCellFieldName(record, dataIndex);
|
||||
await form.validateFields([fieldName]);
|
||||
let nextValue = form.getFieldValue(fieldName);
|
||||
// 日期时间类型: 将 dayjs 对象转回格式化字符串
|
||||
if (isDateTimeField && nextValue && dayjs.isDayjs(nextValue)) {
|
||||
nextValue = formatFromDayjs(nextValue as dayjs.Dayjs, pickerType);
|
||||
} else if (isDateTimeField && !nextValue) {
|
||||
nextValue = null;
|
||||
if (isDateTimeField) {
|
||||
nextValue = resolveTemporalEditorSaveValue(nextValue, pickerValue, pickerType);
|
||||
}
|
||||
toggleEdit();
|
||||
// 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。
|
||||
@@ -689,9 +650,9 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
ref={inputRef}
|
||||
style={{ width: '100%' }}
|
||||
format={TEMPORAL_FORMATS[pickerType]}
|
||||
onChange={() => setTimeout(save, 0)}
|
||||
onChange={(value) => setTimeout(() => { void save(value); }, 0)}
|
||||
onOpenChange={lockTableScroll}
|
||||
onBlur={() => setTimeout(save, 0)}
|
||||
onBlur={() => setTimeout(() => { void save(); }, 0)}
|
||||
needConfirm={false}
|
||||
/>
|
||||
) : pickerType === 'datetime' ? (
|
||||
@@ -712,7 +673,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
}}
|
||||
>此刻</a>
|
||||
)}
|
||||
onOk={() => setTimeout(save, 0)}
|
||||
onOk={(value) => setTimeout(() => { void save((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)}
|
||||
onOpenChange={(open) => {
|
||||
pickerOpenRef.current = open;
|
||||
lockTableScroll(open);
|
||||
@@ -732,17 +693,17 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
style={{ width: '100%' }}
|
||||
format={TEMPORAL_FORMATS[pickerType]}
|
||||
picker={pickerType as any}
|
||||
onChange={() => setTimeout(save, 0)}
|
||||
onChange={(value) => setTimeout(() => { void save(value); }, 0)}
|
||||
onOpenChange={lockTableScroll}
|
||||
onBlur={() => setTimeout(save, 0)}
|
||||
onBlur={() => setTimeout(() => { void save(); }, 0)}
|
||||
needConfirm={false}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
onPressEnter={save}
|
||||
onBlur={save}
|
||||
onPressEnter={() => { void save(); }}
|
||||
onBlur={() => { void save(); }}
|
||||
onFocus={(e) => {
|
||||
try {
|
||||
(e.target as HTMLInputElement)?.select?.();
|
||||
|
||||
@@ -8,10 +8,12 @@ import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
|
||||
import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
type SyncLogEvent = { jobId: string; level?: string; message?: string; ts?: number };
|
||||
type SyncProgressEvent = { jobId: string; percent?: number; current?: number; total?: number; table?: string; stage?: string };
|
||||
@@ -24,6 +26,7 @@ type TableDiffSummary = {
|
||||
updates?: number;
|
||||
deletes?: number;
|
||||
same?: number;
|
||||
schemaDiffCount?: number;
|
||||
message?: string;
|
||||
targetTableExists?: boolean;
|
||||
plannedAction?: string;
|
||||
@@ -123,6 +126,15 @@ const buildSqlPreview = (
|
||||
? previewData.columnTypes as Record<string, string>
|
||||
: {};
|
||||
const statements: string[] = [];
|
||||
const schemaStatements = Array.isArray(previewData.schemaStatements)
|
||||
? previewData.schemaStatements
|
||||
.map((item: any) => String(item || '').trim())
|
||||
.filter((item: string) => item.length > 0)
|
||||
: [];
|
||||
|
||||
schemaStatements.forEach((statement: string) => {
|
||||
statements.push(statement.endsWith(';') ? statement : `${statement};`);
|
||||
});
|
||||
|
||||
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
|
||||
const updateRows = Array.isArray(previewData.updates) ? previewData.updates : [];
|
||||
@@ -204,6 +216,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
// Step 2: Tables
|
||||
const [allTables, setAllTables] = useState<string[]>([]);
|
||||
const [selectedTables, setSelectedTables] = useState<string[]>([]);
|
||||
const [sourceDatasetMode, setSourceDatasetMode] = useState<SourceDatasetMode>('table');
|
||||
const [sourceQuery, setSourceQuery] = useState<string>('');
|
||||
|
||||
// Options
|
||||
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
|
||||
@@ -284,7 +298,10 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setTargetConnId('');
|
||||
setSourceDb('');
|
||||
setTargetDb('');
|
||||
setAllTables([]);
|
||||
setSelectedTables([]);
|
||||
setSourceDatasetMode('table');
|
||||
setSourceQuery('');
|
||||
setWorkflowType('sync');
|
||||
setSyncContent('data');
|
||||
setSyncMode('insert_update');
|
||||
@@ -332,6 +349,28 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
}
|
||||
}, [workflowType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceDatasetMode !== 'query') return;
|
||||
if (workflowType !== 'sync') {
|
||||
setWorkflowType('sync');
|
||||
}
|
||||
if (syncContent !== 'data') {
|
||||
setSyncContent('data');
|
||||
}
|
||||
if (targetTableStrategy !== 'existing_only') {
|
||||
setTargetTableStrategy('existing_only');
|
||||
}
|
||||
if (createIndexes) {
|
||||
setCreateIndexes(false);
|
||||
}
|
||||
if (autoAddColumns) {
|
||||
setAutoAddColumns(false);
|
||||
}
|
||||
if (selectedTables.length > 1) {
|
||||
setSelectedTables(selectedTables.slice(0, 1));
|
||||
}
|
||||
}, [sourceDatasetMode, workflowType, syncContent, targetTableStrategy, createIndexes, autoAddColumns, selectedTables]);
|
||||
|
||||
const handleSourceConnChange = async (connId: string) => {
|
||||
setSourceConnId(connId);
|
||||
setSourceDb('');
|
||||
@@ -377,10 +416,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const conn = connections.find(c => c.id === sourceConnId);
|
||||
const connId = isSourceQueryMode ? targetConnId : sourceConnId;
|
||||
const dbName = isSourceQueryMode ? targetDb : sourceDb;
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
const config = normalizeConnConfig(conn, sourceDb);
|
||||
const res = await DBGetTables(config as any, sourceDb);
|
||||
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 : [];
|
||||
@@ -388,6 +429,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
.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) {
|
||||
return existing.slice(0, 1);
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
@@ -405,7 +453,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
};
|
||||
|
||||
const analyzeDiff = async () => {
|
||||
if (selectedTables.length === 0) return;
|
||||
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
|
||||
if (selectionError) return message.error(selectionError);
|
||||
if (!sourceConnId || !targetConnId) return message.error("Select connections first");
|
||||
if (!sourceDb || !targetDb) return message.error("Select databases first");
|
||||
|
||||
@@ -422,18 +471,20 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
autoScrollRef.current = true;
|
||||
setSyncProgress({ percent: 0, current: 0, total: selectedTables.length, table: '', stage: '差异分析' });
|
||||
|
||||
const config = {
|
||||
const config = buildDataSyncRequest({
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
tables: selectedTables,
|
||||
content: syncContent,
|
||||
mode: "insert_update",
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
mongoCollectionName,
|
||||
jobId,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await DataSyncAnalyze(config as any);
|
||||
@@ -475,17 +526,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setPreviewLoading(true);
|
||||
setPreviewData(null);
|
||||
|
||||
const config = {
|
||||
const config = buildDataSyncRequest({
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
tables: selectedTables,
|
||||
content: "data",
|
||||
mode: "insert_update",
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
};
|
||||
mongoCollectionName,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await DataSyncPreview(config as any, table, 200);
|
||||
@@ -502,6 +555,11 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
};
|
||||
|
||||
const runSync = async () => {
|
||||
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
|
||||
if (selectionError) {
|
||||
message.error(selectionError);
|
||||
return;
|
||||
}
|
||||
if (syncContent !== 'schema' && diffTables.length === 0) {
|
||||
message.error("请先对比差异,再开始同步");
|
||||
return;
|
||||
@@ -540,19 +598,21 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
stage: '准备开始',
|
||||
});
|
||||
|
||||
const config = {
|
||||
const config = buildDataSyncRequest({
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
tables: selectedTables,
|
||||
content: syncContent,
|
||||
mode: syncMode,
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode,
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
mongoCollectionName,
|
||||
tableOptions,
|
||||
jobId,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await DataSync(config as any);
|
||||
@@ -596,6 +656,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false };
|
||||
return buildSqlPreview(previewData, previewTable, targetType, ops);
|
||||
}, [previewData, previewTable, targetConnId, connections, tableOptions]);
|
||||
const previewHasSchemaStatements = useMemo(
|
||||
() => Array.isArray(previewData?.schemaStatements) && previewData.schemaStatements.length > 0,
|
||||
[previewData],
|
||||
);
|
||||
const previewSchemaWarnings = useMemo(
|
||||
() => Array.isArray(previewData?.schemaWarnings) ? previewData.schemaWarnings as string[] : [],
|
||||
[previewData],
|
||||
);
|
||||
const previewHasDataDiff = useMemo(
|
||||
() => Number(previewData?.totalInserts || 0) + Number(previewData?.totalUpdates || 0) + Number(previewData?.totalDeletes || 0) > 0,
|
||||
[previewData],
|
||||
);
|
||||
|
||||
const analysisWarnings = useMemo(() => {
|
||||
const items: string[] = [];
|
||||
@@ -606,6 +678,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
return Array.from(new Set(items));
|
||||
}, [diffTables]);
|
||||
|
||||
const isSourceQueryMode = sourceDatasetMode === 'query';
|
||||
const isMigrationWorkflow = workflowType === 'migration';
|
||||
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
|
||||
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
|
||||
@@ -838,7 +911,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Form.Item label="功能类型">
|
||||
<Select value={workflowType} onChange={setWorkflowType}>
|
||||
<Option value="sync">数据同步(基于已有目标表做差异同步)</Option>
|
||||
<Option value="migration">跨库迁移(可自动建表后导入)</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>
|
||||
<Alert
|
||||
@@ -849,11 +928,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
|
||||
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
|
||||
/>
|
||||
{isSourceQueryMode && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="SQL 结果集同步当前只支持:源端自定义 SQL -> 单个已存在目标表;查询结果需包含目标表主键列。"
|
||||
/>
|
||||
)}
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
|
||||
<Select value={syncContent} onChange={setSyncContent}>
|
||||
<Option value="data">仅同步数据</Option>
|
||||
<Option value="schema">仅同步结构</Option>
|
||||
<Option value="both">同步结构 + 数据</Option>
|
||||
<Option value="schema" disabled={isSourceQueryMode}>仅同步结构</Option>
|
||||
<Option value="both" disabled={isSourceQueryMode}>同步结构 + 数据</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
|
||||
@@ -864,7 +951,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!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>
|
||||
@@ -887,12 +974,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)}>
|
||||
自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase)
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
|
||||
自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase;SQL 结果集模式暂不支持)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
|
||||
自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
@@ -928,21 +1015,56 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{currentStep === 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={quietPanelStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
|
||||
显示相同表
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Transfer
|
||||
dataSource={allTables.map(t => ({ key: t, title: t }))}
|
||||
titles={['源表', '已选表']}
|
||||
targetKeys={selectedTables}
|
||||
onChange={(keys) => setSelectedTables(keys as string[])}
|
||||
render={item => item.title}
|
||||
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
|
||||
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
|
||||
/>
|
||||
{!isSourceQueryMode && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
|
||||
显示相同表
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Transfer
|
||||
dataSource={allTables.map(t => ({ key: t, title: t }))}
|
||||
titles={['源表', '已选表']}
|
||||
targetKeys={selectedTables}
|
||||
onChange={(keys) => setSelectedTables(keys as string[])}
|
||||
render={item => item.title}
|
||||
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
|
||||
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isSourceQueryMode && (
|
||||
<Form layout="vertical">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="请输入源查询 SQL,并选择一个目标表。差异分析会直接基于该结果集与目标表对比。"
|
||||
/>
|
||||
<Form.Item label="源查询 SQL">
|
||||
<TextArea
|
||||
value={sourceQuery}
|
||||
onChange={(e) => setSourceQuery(e.target.value)}
|
||||
rows={8}
|
||||
placeholder="例如:SELECT id, name, email FROM users WHERE status = 'active'"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="目标表">
|
||||
<Select
|
||||
value={selectedTables[0]}
|
||||
onChange={(value) => setSelectedTables(value ? [value] : [])}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="请选择一个目标表"
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{allTables.map((table) => <Option key={table} value={table}>{table}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{diffTables.length > 0 && (
|
||||
@@ -1061,8 +1183,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
render: (_: any, r: any) => {
|
||||
const can = !!r.canSync;
|
||||
const hasDiff = Number(r.inserts || 0) + Number(r.updates || 0) + Number(r.deletes || 0) > 0;
|
||||
const hasSchemaDiff = Number(r.schemaDiffCount || 0) > 0;
|
||||
return (
|
||||
<Button size="small" disabled={!can || !hasDiff || analyzing} onClick={() => openPreview(r.table)}>
|
||||
<Button size="small" disabled={!can || !(hasDiff || hasSchemaDiff) || analyzing} onClick={() => openPreview(r.table)}>
|
||||
查看
|
||||
</Button>
|
||||
);
|
||||
@@ -1134,14 +1257,14 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{currentStep === 1 && (
|
||||
<>
|
||||
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}>上一步</Button>
|
||||
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing} style={{ marginRight: 8 }}>
|
||||
<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 || (syncContent !== 'schema' && diffTables.length === 0)}
|
||||
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
|
||||
>
|
||||
开始同步
|
||||
</Button>
|
||||
@@ -1169,12 +1292,59 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`}
|
||||
message={
|
||||
previewHasDataDiff
|
||||
? `插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`
|
||||
: (previewData.schemaSummary || `检测到 ${previewSql.statementCount} 条结构变更语句`)
|
||||
}
|
||||
/>
|
||||
{previewSchemaWarnings.length > 0 && (
|
||||
<Alert
|
||||
style={{ marginTop: 12 }}
|
||||
type="warning"
|
||||
showIcon
|
||||
message="结构预览包含风险或降级项"
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{previewSchemaWarnings.slice(0, 8).map((item) => <li key={item}>{item}</li>)}
|
||||
{previewSchemaWarnings.length > 8 && <li>还有 {previewSchemaWarnings.length - 8} 项未展开</li>}
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
...(previewHasSchemaStatements ? [{
|
||||
key: 'schema',
|
||||
label: `结构(${Array.isArray(previewData.schemaStatements) ? previewData.schemaStatements.length : 0})`,
|
||||
children: (
|
||||
<div>
|
||||
<Text type="secondary">
|
||||
{previewData.schemaSummary || '以下为本次结构同步计划执行的语句。'}
|
||||
</Text>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 8,
|
||||
marginBottom: 0,
|
||||
padding: 10,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
background: '#fafafa',
|
||||
maxHeight: 420,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{Array.isArray(previewData.schemaStatements) && previewData.schemaStatements.length > 0
|
||||
? previewData.schemaStatements.join('\n')
|
||||
: '-- 当前表结构无可执行变更'}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}] : []),
|
||||
...(previewHasDataDiff ? [{
|
||||
key: 'insert',
|
||||
label: `插入(${previewData.totalInserts || 0})`,
|
||||
children: (
|
||||
@@ -1274,7 +1444,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
key: 'sql',
|
||||
label: `SQL(${previewSql.statementCount})`,
|
||||
@@ -1283,10 +1453,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||
message={
|
||||
previewHasDataDiff
|
||||
? "SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||
: "SQL 预览展示将执行的结构变更语句,用于审核确认。"
|
||||
}
|
||||
/>
|
||||
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">共 {previewSql.statementCount} 条语句(预览数据最多 200 条/类型)</Text>
|
||||
<Text type="secondary">
|
||||
{previewHasDataDiff
|
||||
? `共 ${previewSql.statementCount} 条语句(预览数据最多 200 条/类型)`
|
||||
: `共 ${previewSql.statementCount} 条结构变更语句`}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!previewSql.sqlText}
|
||||
@@ -1315,7 +1493,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
|
||||
{previewSql.sqlText || (previewHasDataDiff ? '-- 当前勾选范围下无 SQL 可预览' : '-- 当前表结构无可执行变更')}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1228,7 +1228,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, field);
|
||||
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, [field]);
|
||||
if (res.success) {
|
||||
message.success('删除成功');
|
||||
loadKeyValue(selectedKey);
|
||||
|
||||
@@ -48,6 +48,7 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
|
||||
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
|
||||
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -3556,7 +3557,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
const tableName = String(node.dataRef?.tableName || '').trim();
|
||||
const queryTemplate = tableName ? `SELECT * FROM ${tableName};` : 'SELECT * FROM ';
|
||||
const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName);
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `新建查询`,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
|
||||
|
||||
interface TableOverviewProps {
|
||||
tab: TabData;
|
||||
@@ -167,6 +168,10 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
|
||||
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
|
||||
const metadataDialect = useMemo(
|
||||
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver),
|
||||
[connection?.config?.driver, connection?.config?.type]
|
||||
);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -181,11 +186,10 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
useSSH: connection.config.useSSH || false,
|
||||
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
|
||||
};
|
||||
const dialect = getMetadataDialect(connection.config.type, connection.config.driver);
|
||||
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
|
||||
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', (tab as any).schemaName);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setTables(parseTableStats(dialect, res.data));
|
||||
setTables(parseTableStats(metadataDialect, res.data));
|
||||
} else {
|
||||
message.error('获取表信息失败: ' + (res.message || '未知错误'));
|
||||
}
|
||||
@@ -194,7 +198,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connection, tab.dbName]);
|
||||
}, [connection, metadataDialect, tab.dbName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
@@ -487,7 +491,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: `SELECT * FROM ${t.name};`,
|
||||
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
@@ -573,7 +577,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: `SELECT * FROM ${t.name};`,
|
||||
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { AIChatMessage, AIToolCall } from '../../types';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import { normalizeAiMarkdown } from '../../utils/aiMarkdown';
|
||||
|
||||
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
|
||||
const remarkPlugins = [remarkGfm];
|
||||
@@ -27,6 +28,7 @@ const MemoizedMarkdown = React.memo(({
|
||||
activeConnectionId?: string;
|
||||
activeDbName?: string;
|
||||
}) => {
|
||||
const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]);
|
||||
// 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo
|
||||
const components = React.useMemo(() => ({
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
@@ -46,7 +48,7 @@ const MemoizedMarkdown = React.memo(({
|
||||
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
|
||||
{content}
|
||||
{normalizedContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
|
||||
10
frontend/src/components/dataGridTemporal.test.ts
Normal file
10
frontend/src/components/dataGridTemporal.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveTemporalEditorSaveValue } from './dataGridTemporal';
|
||||
|
||||
describe('dataGridTemporal helpers', () => {
|
||||
it('prefers the picker selected date when form store has not caught up yet', () => {
|
||||
expect(resolveTemporalEditorSaveValue(undefined, dayjs('2026-04-12'), 'date')).toBe('2026-04-12');
|
||||
});
|
||||
});
|
||||
59
frontend/src/components/dataGridTemporal.ts
Normal file
59
frontend/src/components/dataGridTemporal.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
|
||||
|
||||
export const TEMPORAL_FORMATS: Record<string, string> = {
|
||||
datetime: 'YYYY-MM-DD HH:mm:ss',
|
||||
date: 'YYYY-MM-DD',
|
||||
time: 'HH:mm:ss',
|
||||
year: 'YYYY',
|
||||
};
|
||||
|
||||
export const isTemporalColumnType = (columnType?: string): boolean => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return false;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
return base === 'date' || base === 'time' || base === 'year';
|
||||
};
|
||||
|
||||
export const getTemporalPickerType = (columnType?: string): TemporalPickerType => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp')) return 'datetime';
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
if (base === 'date') return 'date';
|
||||
if (base === 'time') return 'time';
|
||||
if (base === 'year') return 'year';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseToDayjs = (val: any, pickerType: TemporalPickerType): dayjs.Dayjs | null => {
|
||||
if (val === null || val === undefined || val === '') return null;
|
||||
const str = String(val).trim();
|
||||
if (!str || /^0{4}-0{2}-0{2}/.test(str)) return null;
|
||||
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
|
||||
const d = dayjs(str, fmt);
|
||||
return d.isValid() ? d : dayjs(str).isValid() ? dayjs(str) : null;
|
||||
};
|
||||
|
||||
export const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => {
|
||||
if (!val || !val.isValid()) return '';
|
||||
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
|
||||
return val.format(fmt);
|
||||
};
|
||||
|
||||
export const resolveTemporalEditorSaveValue = (
|
||||
formValue: any,
|
||||
pickerValue: dayjs.Dayjs | null | undefined,
|
||||
pickerType: TemporalPickerType,
|
||||
): string | null | any => {
|
||||
const value = pickerValue !== undefined ? pickerValue : formValue;
|
||||
if (value && dayjs.isDayjs(value)) {
|
||||
return formatFromDayjs(value as dayjs.Dayjs, pickerType);
|
||||
}
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
67
frontend/src/components/dataSyncRequest.test.ts
Normal file
67
frontend/src/components/dataSyncRequest.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDataSyncRequest, validateDataSyncSelection } from './dataSyncRequest';
|
||||
|
||||
describe('validateDataSyncSelection', () => {
|
||||
it('requires source query and single target table in query mode', () => {
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: [],
|
||||
sourceQuery: '',
|
||||
syncContent: 'data',
|
||||
})).toBe('请输入源查询 SQL');
|
||||
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: [],
|
||||
sourceQuery: 'select 1',
|
||||
syncContent: 'data',
|
||||
})).toBe('SQL 结果集同步需要选择一个目标表');
|
||||
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: ['users', 'orders'],
|
||||
sourceQuery: 'select 1',
|
||||
syncContent: 'data',
|
||||
})).toBe('SQL 结果集同步需要选择一个目标表');
|
||||
});
|
||||
|
||||
it('forces data-only in query mode', () => {
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: ['users'],
|
||||
sourceQuery: 'select 1',
|
||||
syncContent: 'both',
|
||||
})).toBe('SQL 结果集同步仅支持仅同步数据');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDataSyncRequest', () => {
|
||||
it('normalizes query mode payload for backend', () => {
|
||||
const payload = buildDataSyncRequest({
|
||||
sourceConfig: { type: 'mysql' },
|
||||
targetConfig: { type: 'mysql' },
|
||||
selectedTables: ['users'],
|
||||
sourceDatasetMode: 'query',
|
||||
sourceQuery: ' SELECT id, name FROM active_users ',
|
||||
syncContent: 'both',
|
||||
syncMode: 'insert_update',
|
||||
autoAddColumns: true,
|
||||
targetTableStrategy: 'smart',
|
||||
createIndexes: true,
|
||||
mongoCollectionName: ' ',
|
||||
jobId: 'job-1',
|
||||
tableOptions: { users: { insert: true, update: true, delete: false } },
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
tables: ['users'],
|
||||
sourceQuery: 'SELECT id, name FROM active_users',
|
||||
content: 'data',
|
||||
mode: 'insert_update',
|
||||
autoAddColumns: false,
|
||||
targetTableStrategy: 'existing_only',
|
||||
createIndexes: false,
|
||||
jobId: 'job-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
85
frontend/src/components/dataSyncRequest.ts
Normal file
85
frontend/src/components/dataSyncRequest.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export type SourceDatasetMode = 'table' | 'query';
|
||||
|
||||
type SyncContent = 'data' | 'schema' | 'both';
|
||||
type TargetTableStrategy = 'existing_only' | 'auto_create_if_missing' | 'smart';
|
||||
|
||||
type BuildDataSyncRequestParams = {
|
||||
sourceConfig: any;
|
||||
targetConfig: any;
|
||||
selectedTables: string[];
|
||||
sourceDatasetMode: SourceDatasetMode;
|
||||
sourceQuery: string;
|
||||
syncContent: SyncContent;
|
||||
syncMode: string;
|
||||
autoAddColumns: boolean;
|
||||
targetTableStrategy: TargetTableStrategy;
|
||||
createIndexes: boolean;
|
||||
mongoCollectionName: string;
|
||||
jobId?: string;
|
||||
tableOptions?: Record<string, any>;
|
||||
};
|
||||
|
||||
type ValidateDataSyncSelectionParams = {
|
||||
sourceDatasetMode: SourceDatasetMode;
|
||||
selectedTables: string[];
|
||||
sourceQuery: string;
|
||||
syncContent: SyncContent;
|
||||
};
|
||||
|
||||
export const validateDataSyncSelection = ({
|
||||
sourceDatasetMode,
|
||||
selectedTables,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
}: ValidateDataSyncSelectionParams): string | null => {
|
||||
if (sourceDatasetMode === 'query') {
|
||||
if (!String(sourceQuery || '').trim()) {
|
||||
return '请输入源查询 SQL';
|
||||
}
|
||||
if (selectedTables.length !== 1) {
|
||||
return 'SQL 结果集同步需要选择一个目标表';
|
||||
}
|
||||
if (syncContent !== 'data') {
|
||||
return 'SQL 结果集同步仅支持仅同步数据';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedTables.length === 0) {
|
||||
return '请选择至少一张表';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildDataSyncRequest = ({
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode,
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName,
|
||||
jobId,
|
||||
tableOptions,
|
||||
}: BuildDataSyncRequestParams) => {
|
||||
const isQueryMode = sourceDatasetMode === 'query';
|
||||
|
||||
return {
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
tables: selectedTables,
|
||||
sourceQuery: isQueryMode ? String(sourceQuery || '').trim() : undefined,
|
||||
content: isQueryMode ? 'data' : syncContent,
|
||||
mode: syncMode,
|
||||
autoAddColumns: isQueryMode ? false : autoAddColumns,
|
||||
targetTableStrategy: isQueryMode ? 'existing_only' : targetTableStrategy,
|
||||
createIndexes: isQueryMode ? false : createIndexes,
|
||||
mongoCollectionName: String(mongoCollectionName || '').trim(),
|
||||
...(jobId ? { jobId } : {}),
|
||||
...(tableOptions ? { tableOptions } : {}),
|
||||
};
|
||||
};
|
||||
@@ -51,4 +51,16 @@ describe('tableDesignerSchemaSql', () => {
|
||||
expect(sql).not.toContain('AFTER');
|
||||
expect(sql).not.toContain(' FIRST');
|
||||
});
|
||||
|
||||
it('uses mysql change column syntax when renaming a column', () => {
|
||||
const sql = buildAlterTablePreviewSql(buildInput({
|
||||
dbType: 'mysql',
|
||||
originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'varchar(64)', nullable: 'YES' })],
|
||||
columns: [baseColumn({ _key: 'name', name: 'display_name', type: 'varchar(64)', nullable: 'YES' })],
|
||||
}));
|
||||
|
||||
expect(sql).toContain('CHANGE COLUMN `name` `display_name` varchar(64) NULL');
|
||||
expect(sql).toContain('FIRST');
|
||||
expect(sql).not.toContain('MODIFY COLUMN `display_name`');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,14 +140,21 @@ const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string =
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
curr.name !== orig.name ||
|
||||
const definitionChanged =
|
||||
curr.type !== orig.type ||
|
||||
curr.nullable !== orig.nullable ||
|
||||
curr.default !== orig.default ||
|
||||
(curr.comment || '') !== (orig.comment || '') ||
|
||||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
|
||||
) {
|
||||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement);
|
||||
|
||||
if (curr.name !== orig.name) {
|
||||
alters.push(
|
||||
`CHANGE COLUMN ${quoteIdentifierPart(orig.name, 'mysql')} ${colDef} ${positionSql}`.trim(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (definitionChanged) {
|
||||
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim());
|
||||
}
|
||||
});
|
||||
|
||||
11
frontend/src/utils/aiMarkdown.test.ts
Normal file
11
frontend/src/utils/aiMarkdown.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeAiMarkdown } from './aiMarkdown';
|
||||
|
||||
describe('normalizeAiMarkdown', () => {
|
||||
it('inserts a missing newline after the fenced code language marker', () => {
|
||||
expect(normalizeAiMarkdown('```sqlSELECT COUNT(*) AS order_count\nFROM customer_order;\n```')).toBe(
|
||||
'```sql\nSELECT COUNT(*) AS order_count\nFROM customer_order;\n```',
|
||||
);
|
||||
});
|
||||
});
|
||||
13
frontend/src/utils/aiMarkdown.ts
Normal file
13
frontend/src/utils/aiMarkdown.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const normalizeAiMarkdown = (content: string): string => {
|
||||
let text = String(content || '').replace(/\r\n/g, '\n');
|
||||
const knownFenceLanguages = [
|
||||
'sql', 'mermaid', 'json', 'javascript', 'typescript', 'ts', 'js', 'tsx', 'jsx',
|
||||
'bash', 'sh', 'shell', 'python', 'py', 'go', 'java', 'yaml', 'yml', 'html', 'css',
|
||||
'xml', 'markdown', 'md', 'text', 'plaintext', 'vue', 'php', 'ruby', 'rust', 'toml',
|
||||
'ini', 'diff',
|
||||
];
|
||||
const fencePattern = new RegExp(`(^|\\n)\`\`\`(${knownFenceLanguages.join('|')})([^\\n])`, 'gi');
|
||||
text = text.replace(fencePattern, '$1```$2\n$3');
|
||||
text = text.replace(/([^\n])```(?=\n|$)/g, '$1\n```');
|
||||
return text;
|
||||
};
|
||||
9
frontend/src/utils/objectQueryTemplates.test.ts
Normal file
9
frontend/src/utils/objectQueryTemplates.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTableSelectQuery } from './objectQueryTemplates';
|
||||
|
||||
describe('buildTableSelectQuery', () => {
|
||||
it('quotes uppercase postgres table names in new query templates', () => {
|
||||
expect(buildTableSelectQuery('postgres', 'public.MyTable')).toBe('SELECT * FROM public."MyTable";');
|
||||
});
|
||||
});
|
||||
9
frontend/src/utils/objectQueryTemplates.ts
Normal file
9
frontend/src/utils/objectQueryTemplates.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { quoteQualifiedIdent } from './sql';
|
||||
|
||||
export const buildTableSelectQuery = (dbType: string, tableName: string): string => {
|
||||
const normalizedTableName = String(tableName || '').trim();
|
||||
if (!normalizedTableName) {
|
||||
return 'SELECT * FROM ';
|
||||
}
|
||||
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)};`;
|
||||
};
|
||||
13
frontend/src/utils/windowStateUi.test.ts
Normal file
13
frontend/src/utils/windowStateUi.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveTitleBarToggleIconKey, shouldToggleMaximisedWindowForScaleFix } from './windowStateUi';
|
||||
|
||||
describe('windowStateUi', () => {
|
||||
it('does not re-toggle a maximized window on activation when focus returns', () => {
|
||||
expect(shouldToggleMaximisedWindowForScaleFix('activation', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('switches the titlebar toggle icon to restore when the window is maximized', () => {
|
||||
expect(resolveTitleBarToggleIconKey('maximized')).toBe('restore');
|
||||
});
|
||||
});
|
||||
11
frontend/src/utils/windowStateUi.ts
Normal file
11
frontend/src/utils/windowStateUi.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type WindowVisualState = 'normal' | 'maximized' | 'fullscreen';
|
||||
export type WindowScaleFixReason = 'activation' | 'ratio-change';
|
||||
export type TitleBarToggleIconKey = 'maximize' | 'restore';
|
||||
|
||||
export const shouldToggleMaximisedWindowForScaleFix = (
|
||||
reason: WindowScaleFixReason,
|
||||
hasViewportScaleDrift: boolean,
|
||||
): boolean => reason === 'ratio-change' && hasViewportScaleDrift;
|
||||
|
||||
export const resolveTitleBarToggleIconKey = (windowState: WindowVisualState): TitleBarToggleIconKey =>
|
||||
windowState === 'maximized' ? 'restore' : 'maximize';
|
||||
Reference in New Issue
Block a user