Release/0.5.1 (#149)

* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
This commit is contained in:
Syngnat
2026-03-03 14:35:17 +08:00
committed by GitHub
parent a54b8906a3
commit 494484eb92
40 changed files with 4306 additions and 654 deletions

View File

@@ -57,6 +57,29 @@ body[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
background: #666;
}
/* Scrollbar styling for light mode (transparent-friendly) */
body[data-theme='light'] ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
body[data-theme='light'] ::-webkit-scrollbar-track {
background: transparent;
}
body[data-theme='light'] ::-webkit-scrollbar-corner {
background: transparent;
}
body[data-theme='light'] ::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
body[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.30);
border: 2px solid transparent;
background-clip: content-box;
}
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
body {
transition: color 0.3s;
@@ -67,6 +90,51 @@ body[data-theme='dark'] {
在透明窗口环境下会显著加剧 GPU 负载 */
}
/* 暗色 + 透明:提升选中/焦点可读性,避免默认蓝色在半透明背景下发灰 */
body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected,
body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover {
background: rgba(246, 196, 83, 0.24) !important;
color: rgba(255, 236, 179, 0.98) !important;
}
body[data-theme='dark'] .ant-checkbox-checked .ant-checkbox-inner {
background-color: #f6c453 !important;
border-color: #f6c453 !important;
}
body[data-theme='dark'] .ant-checkbox-indeterminate .ant-checkbox-inner::after {
background-color: #f6c453 !important;
}
body[data-theme='dark'] .ant-checkbox:hover .ant-checkbox-inner,
body[data-theme='dark'] .ant-checkbox-wrapper:hover .ant-checkbox-inner {
border-color: #f6c453 !important;
}
body[data-theme='dark'] .ant-radio-checked .ant-radio-inner {
border-color: #f6c453 !important;
background-color: #f6c453 !important;
}
body[data-theme='dark'] .ant-radio-wrapper:hover .ant-radio-inner,
body[data-theme='dark'] .ant-radio:hover .ant-radio-inner {
border-color: #f6c453 !important;
}
body[data-theme='dark'] .ant-switch.ant-switch-checked {
background: #d8a93b !important;
}
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected > td,
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected > .ant-table-cell {
background: rgba(246, 196, 83, 0.18) !important;
}
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected:hover > td,
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:hover > .ant-table-cell {
background: rgba(246, 196, 83, 0.26) !important;
}
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */
.connection-modal-wrap {
overflow: hidden !important;

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
import { Environment, EventsOn, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise } from '../wailsjs/runtime/runtime';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowToggleMaximise } from '../wailsjs/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
@@ -16,6 +16,25 @@ import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/A
import './App.css';
const { Sider, Content } = Layout;
const MIN_UI_SCALE = 0.8;
const MAX_UI_SCALE = 1.25;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 20;
const DEFAULT_UI_SCALE = 1.0;
const DEFAULT_FONT_SIZE = 14;
const detectNavigatorPlatform = (): string => {
if (typeof navigator === 'undefined') {
return '';
}
const uaDataPlatform = (navigator as Navigator & {
userAgentData?: { platform?: string };
}).userAgentData?.platform;
if (uaDataPlatform) {
return uaDataPlatform;
}
return navigator.userAgent || '';
};
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -26,15 +45,33 @@ function App() {
const setTheme = useStore(state => state.setTheme);
const appearance = useStore(state => state.appearance);
const setAppearance = useStore(state => state.setAppearance);
const uiScale = useStore(state => state.uiScale);
const setUiScale = useStore(state => state.setUiScale);
const fontSize = useStore(state => state.fontSize);
const setFontSize = useStore(state => state.setFontSize);
const startupFullscreen = useStore(state => state.startupFullscreen);
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
const globalProxy = useStore(state => state.globalProxy);
const setGlobalProxy = useStore(state => state.setGlobalProxy);
const darkMode = themeMode === 'dark';
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 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));
const tokenControlHeightSM = Math.max(20, Math.round(24 * effectiveUiScale));
const tokenControlHeightLG = Math.max(30, Math.round(40 * effectiveUiScale));
const appComponentSize: 'small' | 'middle' | 'large' = effectiveUiScale <= 0.92 ? 'small' : (effectiveUiScale >= 1.12 ? 'large' : 'middle');
const titleBarHeight = Math.max(28, Math.round(32 * effectiveUiScale));
const toolbarHeight = Math.max(32, Math.round(36 * effectiveUiScale));
const titleBarButtonWidth = Math.max(40, Math.round(46 * effectiveUiScale));
const floatingLogButtonHeight = Math.max(30, Math.round(34 * effectiveUiScale));
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
const blurFilter = blurToFilter(effectiveBlur);
const windowCornerRadius = 14;
const [runtimePlatform, setRuntimePlatform] = useState('');
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
const globalProxyInvalidHintShownRef = React.useRef(false);
@@ -42,7 +79,7 @@ function App() {
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
// 避免 GPU 持续计算窗口背后的模糊合成
useEffect(() => {
SetWindowTranslucency(appearance.opacity, appearance.blur).catch(() => {});
void SetWindowTranslucency(appearance.opacity, appearance.blur).catch(() => undefined);
}, [appearance.opacity, appearance.blur]);
useEffect(() => {
@@ -50,12 +87,18 @@ function App() {
Environment()
.then((env) => {
if (cancelled) return;
setIsLinuxRuntime((env?.platform || '').toLowerCase() === 'linux');
const platform = String(env?.platform || '').toLowerCase();
setRuntimePlatform(platform);
setIsLinuxRuntime(platform === 'linux');
})
.catch(() => {
if (cancelled) return;
const platform = typeof navigator !== 'undefined' ? navigator.platform : '';
setIsLinuxRuntime(/linux/i.test(platform));
const platform = detectNavigatorPlatform();
const normalized = /linux/i.test(platform)
? 'linux'
: (/mac/i.test(platform) ? 'darwin' : (/win/i.test(platform) ? 'windows' : ''));
setRuntimePlatform(normalized);
setIsLinuxRuntime(normalized === 'linux');
});
return () => {
cancelled = true;
@@ -86,7 +129,7 @@ function App() {
if (invalidWhenEnabled) {
if (!globalProxyInvalidHintShownRef.current) {
message.warning({
void message.warning({
content: '全局代理已开启,但地址或端口无效,当前按未启用处理',
key: 'global-proxy-invalid',
});
@@ -94,7 +137,7 @@ function App() {
}
} else {
globalProxyInvalidHintShownRef.current = false;
message.destroy('global-proxy-invalid');
void message.destroy('global-proxy-invalid');
}
const enabledForBackend = globalProxy.enabled && !invalidWhenEnabled;
@@ -110,7 +153,7 @@ function App() {
if (cancelled || res?.success) {
return;
}
message.error({
void message.error({
content: '全局代理配置失败: ' + (res?.message || '未知错误'),
key: 'global-proxy-sync-error',
});
@@ -120,7 +163,7 @@ function App() {
return;
}
const errMsg = err instanceof Error ? err.message : String(err || '未知错误');
message.error({
void message.error({
content: '全局代理配置失败: ' + errMsg,
key: 'global-proxy-sync-error',
});
@@ -175,18 +218,18 @@ function App() {
if (!useStore.getState().startupFullscreen) {
return;
}
Promise.resolve()
void Promise.resolve()
.then(async () => {
if (await checkStartupPreferenceApplied()) {
return;
}
// 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。
WindowFullscreen();
await WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
WindowMaximise();
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
@@ -195,7 +238,7 @@ function App() {
applyStartupWindowPreference(attempt + 1);
}
});
}, 300);
}, applyRetryDelayMs);
};
if (useStore.persist.hasHydrated()) {
@@ -218,7 +261,7 @@ function App() {
}, []);
// Background Helper
const getBg = (darkHex: string, lightHex: string) => {
const getBg = (darkHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
// Parse hex to rgb
@@ -229,8 +272,16 @@ function App() {
return `rgba(${r}, ${g}, ${b}, ${effectiveOpacity})`;
};
// Specific colors
const bgMain = getBg('#141414', '#ffffff');
const bgContent = getBg('#1d1d1d', '#ffffff');
const bgMain = getBg('#141414');
const bgContent = getBg('#1d1d1d');
const floatingLogButtonBorderColor = darkMode ? 'rgba(255,255,255,0.20)' : 'rgba(0,0,0,0.16)';
const floatingLogButtonTextColor = darkMode ? 'rgba(255,255,255,0.92)' : 'rgba(0,0,0,0.82)';
const floatingLogButtonBgColor = darkMode
? `rgba(34, 34, 34, ${Math.max(effectiveOpacity, 0.82)})`
: `rgba(255, 255, 255, ${Math.max(effectiveOpacity, 0.9)})`;
const floatingLogButtonShadow = darkMode
? '0 8px 22px rgba(0,0,0,0.38)'
: '0 8px 20px rgba(0,0,0,0.16)';
const addTab = useStore(state => state.addTab);
const activeContext = useStore(state => state.activeContext);
@@ -241,11 +292,12 @@ function App() {
const updateCheckInFlightRef = React.useRef(false);
const updateDownloadInFlightRef = React.useRef(false);
const updateDownloadedVersionRef = React.useRef<string | null>(null);
const updateInstallTriggeredVersionRef = React.useRef<string | null>(null);
const updateDownloadMetaRef = React.useRef<UpdateDownloadResultData | null>(null);
const updateDeferredVersionRef = React.useRef<string | null>(null);
const updateNotifiedVersionRef = React.useRef<string | null>(null);
const updateMutedVersionRef = React.useRef<string | null>(null);
const [isAboutOpen, setIsAboutOpen] = useState(false);
const isAboutOpenRef = React.useRef(false);
const [aboutLoading, setAboutLoading] = useState(false);
const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string } | null>(null);
const [aboutUpdateStatus, setAboutUpdateStatus] = useState<string>('');
@@ -299,6 +351,9 @@ function App() {
autoRelaunch?: boolean;
};
const isMacRuntime = runtimePlatform === 'darwin'
|| (runtimePlatform === '' && /mac/i.test(detectNavigatorPlatform()));
const formatBytes = (bytes?: number) => {
if (!bytes || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
@@ -311,52 +366,18 @@ function App() {
return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
};
const promptRestartForUpdate = (info: UpdateInfo, resultData?: UpdateDownloadResultData) => {
const downloadPathHint = resultData?.downloadPath
? `更新包路径:${resultData.downloadPath}`
: '';
const installLogHint = resultData?.installLogPath
? `安装日志:${resultData.installLogPath}`
: '';
Modal.confirm({
title: '更新已下载',
content: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, userSelect: 'text' }}>
<div>{`版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`}</div>
{downloadPathHint ? <div style={{ fontSize: 12, color: '#8c8c8c' }}>{downloadPathHint}</div> : null}
{installLogHint ? <div style={{ fontSize: 12, color: '#8c8c8c' }}>{installLogHint}</div> : null}
</div>
),
okText: '立即重启',
cancelText: '稍后',
onOk: async () => {
updateDeferredVersionRef.current = null;
const res = await (window as any).go.app.App.InstallUpdateAndRestart();
if (!res?.success) {
message.error('更新安装失败: ' + (res?.message || '未知错误'));
}
},
onCancel: () => {
updateDeferredVersionRef.current = info.latestVersion;
}
});
};
const downloadUpdate = React.useCallback(async (info: UpdateInfo, silent: boolean) => {
if (updateDownloadInFlightRef.current) return;
if (updateDownloadedVersionRef.current === info.latestVersion) {
if (!silent) {
const cachedDownloadPath = updateDownloadMetaRef.current?.downloadPath;
message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion}`);
}
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
promptRestartForUpdate(info, updateDownloadMetaRef.current || undefined);
void message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion}`);
showUpdateDownloadProgress();
}
return;
}
updateDownloadInFlightRef.current = true;
updateDownloadMetaRef.current = null;
const key = 'update-download';
setUpdateDownloadProgress({
open: true,
version: info.latestVersion,
@@ -366,33 +387,94 @@ function App() {
total: info.assetSize || 0,
message: ''
});
message.loading({ content: `正在下载更新 ${info.latestVersion}...`, key, duration: 0 });
const res = await (window as any).go.app.App.DownloadUpdate();
updateDownloadInFlightRef.current = false;
if (res?.success) {
const resultData = (res?.data || {}) as UpdateDownloadResultData;
updateDownloadMetaRef.current = resultData;
updateDownloadedVersionRef.current = info.latestVersion;
setUpdateDownloadProgress(prev => ({ ...prev, status: 'done', percent: 100, open: false }));
setUpdateDownloadProgress(prev => {
const total = prev.total > 0 ? prev.total : (info.assetSize || 0);
return { ...prev, status: 'done', percent: 100, downloaded: total, total, message: '', open: false };
});
setLastUpdateInfo((prev) => {
if (!prev || prev.latestVersion !== info.latestVersion) {
return {
...info,
downloaded: true,
downloadPath: resultData?.downloadPath || info.downloadPath,
};
}
return {
...prev,
downloaded: true,
downloadPath: resultData?.downloadPath || prev.downloadPath || info.downloadPath,
};
});
if (resultData?.downloadPath) {
message.success({ content: `更新下载完成,更新包路径:${resultData.downloadPath}`, key, duration: 5 });
void message.success({ content: `更新下载完成,更新包路径:${resultData.downloadPath}`, duration: 5 });
} else {
message.success({ content: '更新下载完成', key, duration: 2 });
}
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,待重启安装)`);
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
promptRestartForUpdate(info, resultData);
void message.success({ content: '更新下载完成', duration: 2 });
}
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,请点击“下载进度”后安装)`);
} else {
setUpdateDownloadProgress(prev => ({
...prev,
status: 'error',
message: res?.message || '未知错误'
}));
message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), key, duration: 4 });
void message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), duration: 4 });
}
}, []);
const showUpdateDownloadProgress = React.useCallback(() => {
setUpdateDownloadProgress((prev) => {
if (prev.status === 'idle') return prev;
return { ...prev, open: true };
});
}, []);
const hideUpdateDownloadProgress = React.useCallback(() => {
setUpdateDownloadProgress((prev) => ({ ...prev, open: false }));
}, []);
const isLatestUpdateDownloaded = Boolean(lastUpdateInfo?.hasUpdate) && (
Boolean(lastUpdateInfo?.downloaded)
|| (Boolean(lastUpdateInfo?.latestVersion) && updateDownloadedVersionRef.current === lastUpdateInfo?.latestVersion)
);
const isBackgroundProgressForLatestUpdate = Boolean(lastUpdateInfo?.hasUpdate)
&& Boolean(lastUpdateInfo?.latestVersion)
&& updateDownloadProgress.version === lastUpdateInfo?.latestVersion
&& (updateDownloadProgress.status === 'start'
|| updateDownloadProgress.status === 'downloading'
|| updateDownloadProgress.status === 'error');
const canShowProgressEntry = (isLatestUpdateDownloaded || isBackgroundProgressForLatestUpdate)
&& updateInstallTriggeredVersionRef.current !== (lastUpdateInfo?.latestVersion || null);
const handleInstallFromProgress = React.useCallback(async () => {
if (updateDownloadProgress.status !== 'done') {
return;
}
if (isMacRuntime) {
const res = await (window as any).go.app.App.OpenDownloadedUpdateDirectory();
if (!res?.success) {
void message.error('打开安装目录失败: ' + (res?.message || '未知错误'));
return;
}
updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null;
hideUpdateDownloadProgress();
void message.success(res?.message || '已打开安装目录,请手动完成替换');
return;
}
const res = await (window as any).go.app.App.InstallUpdateAndRestart();
if (!res?.success) {
void message.error('更新安装失败: ' + (res?.message || '未知错误'));
return;
}
updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null;
hideUpdateDownloadProgress();
}, [hideUpdateDownloadProgress, isMacRuntime, lastUpdateInfo?.latestVersion, updateDownloadProgress.status, updateDownloadProgress.version]);
const checkForUpdates = React.useCallback(async (silent: boolean) => {
if (updateCheckInFlightRef.current) return;
updateCheckInFlightRef.current = true;
@@ -403,14 +485,14 @@ function App() {
updateCheckInFlightRef.current = false;
if (!res?.success) {
if (!silent) {
message.error('检查更新失败: ' + (res?.message || '未知错误'));
void message.error('检查更新失败: ' + (res?.message || '未知错误'));
setAboutUpdateStatus('检查更新失败: ' + (res?.message || '未知错误'));
}
return;
}
const info: UpdateInfo = res.data;
if (!info) return;
setLastUpdateInfo(info);
const aboutOpen = isAboutOpenRef.current;
if (info.hasUpdate) {
const localDownloaded = updateDownloadedVersionRef.current === info.latestVersion;
const hasDownloaded = Boolean(info.downloaded) || localDownloaded;
@@ -422,34 +504,103 @@ function App() {
info,
downloadPath: downloadPath || undefined,
};
setUpdateDownloadProgress((prev) => {
if (prev.status === 'start' || prev.status === 'downloading') {
return prev;
}
const total = info.assetSize || prev.total || 0;
return {
...prev,
open: prev.open && prev.version === info.latestVersion,
version: info.latestVersion,
status: 'done',
percent: 100,
downloaded: total,
total,
message: '',
};
});
setLastUpdateInfo({
...info,
downloaded: true,
downloadPath: downloadPath || undefined,
});
} else {
if (updateDownloadedVersionRef.current !== info.latestVersion) {
updateDownloadMetaRef.current = null;
}
setUpdateDownloadProgress((prev) => {
if (prev.status === 'start' || prev.status === 'downloading') {
return prev;
}
return {
...prev,
open: false,
version: info.latestVersion,
status: 'idle',
percent: 0,
downloaded: 0,
total: info.assetSize || 0,
message: '',
};
});
setLastUpdateInfo(info);
}
const statusText = hasDownloaded
? `发现新版本 ${info.latestVersion}(已下载,待重启安装)`
? `发现新版本 ${info.latestVersion}(已下载,请点击“下载进度”后安装)`
: `发现新版本 ${info.latestVersion}(未下载)`;
if (!silent) {
message.info(`发现新版本 ${info.latestVersion}`);
void message.info(`发现新版本 ${info.latestVersion}`);
setAboutUpdateStatus(statusText);
}
if (silent && isAboutOpen) {
if (silent && aboutOpen) {
setAboutUpdateStatus(statusText);
}
if (silent && !isAboutOpen && updateMutedVersionRef.current !== info.latestVersion && updateNotifiedVersionRef.current !== info.latestVersion) {
if (silent && !aboutOpen && updateMutedVersionRef.current !== info.latestVersion && updateNotifiedVersionRef.current !== info.latestVersion) {
updateNotifiedVersionRef.current = info.latestVersion;
setIsAboutOpen(true);
}
} else if (!silent) {
setUpdateDownloadProgress((prev) => {
if (prev.status === 'start' || prev.status === 'downloading') {
return prev;
}
return {
open: false,
version: '',
status: 'idle',
percent: 0,
downloaded: 0,
total: 0,
message: '',
};
});
setLastUpdateInfo(info);
const text = `当前已是最新版本(${info.currentVersion || '未知'}`;
message.success(text);
void message.success(text);
setAboutUpdateStatus(text);
} else if (silent && isAboutOpen) {
} else if (silent && aboutOpen) {
setUpdateDownloadProgress((prev) => {
if (prev.status === 'start' || prev.status === 'downloading') {
return prev;
}
return {
open: false,
version: '',
status: 'idle',
percent: 0,
downloaded: 0,
total: 0,
message: '',
};
});
setLastUpdateInfo(info);
const text = `当前已是最新版本(${info.currentVersion || '未知'}`;
setAboutUpdateStatus(text);
} else {
setLastUpdateInfo(info);
}
}, [downloadUpdate]);
}, []);
const loadAboutInfo = React.useCallback(async () => {
setAboutLoading(true);
@@ -457,7 +608,7 @@ function App() {
if (res?.success) {
setAboutInfo(res.data);
} else {
message.error('获取应用信息失败: ' + (res?.message || '未知错误'));
void message.error('获取应用信息失败: ' + (res?.message || '未知错误'));
}
setAboutLoading(false);
}, []);
@@ -498,28 +649,28 @@ function App() {
count++;
}
});
message.success(`成功导入 ${count} 个连接`);
void message.success(`成功导入 ${count} 个连接`);
} else {
message.error("文件格式错误:需要 JSON 数组");
void message.error("文件格式错误:需要 JSON 数组");
}
} catch (e) {
message.error("解析 JSON 失败");
void message.error("解析 JSON 失败");
}
} else if (res.message !== "Cancelled") {
message.error("导入失败: " + res.message);
void message.error("导入失败: " + res.message);
}
};
const handleExportConnections = async () => {
if (connections.length === 0) {
message.warning("没有连接可导出");
void message.warning("没有连接可导出");
return;
}
const res = await (window as any).go.app.App.ExportData(connections, [], "connections", "json");
if (res.success) {
message.success("导出成功");
void message.success("导出成功");
} else if (res.message !== "Cancelled") {
message.error("导出失败: " + res.message);
void message.error("导出失败: " + res.message);
}
};
@@ -648,7 +799,7 @@ function App() {
if (target?.closest('[data-no-titlebar-toggle="true"]')) {
return;
}
(window as any).runtime.WindowToggleMaximise();
WindowToggleMaximise();
};
// Sidebar Resizing
@@ -715,27 +866,39 @@ function App() {
document.body.style.backgroundColor = 'transparent';
document.body.style.color = darkMode ? '#ffffff' : '#000000';
document.body.setAttribute('data-theme', darkMode ? 'dark' : 'light');
}, [darkMode]);
document.body.style.fontSize = `${effectiveFontSize}px`;
document.documentElement.style.setProperty('--gonavi-font-size', `${effectiveFontSize}px`);
}, [darkMode, effectiveFontSize]);
useEffect(() => {
isAboutOpenRef.current = isAboutOpen;
}, [isAboutOpen]);
useEffect(() => {
if (isAboutOpen) {
if (lastUpdateInfo?.hasUpdate) {
setAboutUpdateStatus(`发现新版本 ${lastUpdateInfo.latestVersion}(未下载)`);
const localDownloaded = updateDownloadedVersionRef.current === lastUpdateInfo.latestVersion;
const hasDownloaded = Boolean(lastUpdateInfo.downloaded) || localDownloaded;
setAboutUpdateStatus(
hasDownloaded
? `发现新版本 ${lastUpdateInfo.latestVersion}(已下载,请点击“下载进度”后安装)`
: `发现新版本 ${lastUpdateInfo.latestVersion}(未下载)`
);
} else if (lastUpdateInfo) {
setAboutUpdateStatus(`当前已是最新版本(${lastUpdateInfo.currentVersion || '未知'}`);
} else {
setAboutUpdateStatus('未检查');
}
loadAboutInfo();
void loadAboutInfo();
}
}, [isAboutOpen, lastUpdateInfo, loadAboutInfo]);
useEffect(() => {
const startupTimer = window.setTimeout(() => {
checkForUpdates(true);
void checkForUpdates(true);
}, 2000);
const interval = window.setInterval(() => {
checkForUpdates(true);
void checkForUpdates(true);
}, 30 * 60 * 1000);
return () => {
window.clearTimeout(startupTimer);
@@ -758,7 +921,7 @@ function App() {
: (total > 0 ? (downloaded / total) * 100 : 0);
const percent = Math.max(0, Math.min(100, percentRaw));
setUpdateDownloadProgress(prev => ({
open: nextStatus === 'start' || nextStatus === 'downloading' || nextStatus === 'error',
open: prev.open,
version: prev.version,
status: nextStatus,
percent,
@@ -782,13 +945,21 @@ function App() {
} as any;
const showLinuxResizeHandles = isLinuxRuntime;
const resizeGuideColor = darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)';
return (
<ConfigProvider
locale={zhCN}
componentSize={appComponentSize}
theme={{
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
fontSize: tokenFontSize,
fontSizeSM: tokenFontSizeSM,
fontSizeLG: tokenFontSizeLG,
controlHeight: tokenControlHeight,
controlHeightSM: tokenControlHeightSM,
controlHeightLG: tokenControlHeightLG,
colorBgLayout: 'transparent',
colorBgContainer: darkMode
? `rgba(29, 29, 29, ${effectiveOpacity})`
@@ -799,6 +970,20 @@ function App() {
colorFillAlter: darkMode
? `rgba(38, 38, 38, ${effectiveOpacity})`
: `rgba(250, 250, 250, ${effectiveOpacity})`,
colorPrimary: darkMode ? '#f6c453' : '#1677ff',
colorPrimaryHover: darkMode ? '#ffd666' : '#4096ff',
colorPrimaryActive: darkMode ? '#d8a93b' : '#0958d9',
colorInfo: darkMode ? '#f6c453' : '#1677ff',
colorLink: darkMode ? '#ffd666' : '#1677ff',
colorLinkHover: darkMode ? '#ffe58f' : '#4096ff',
colorLinkActive: darkMode ? '#d8a93b' : '#0958d9',
colorPrimaryBg: darkMode ? 'rgba(246, 196, 83, 0.22)' : '#e6f4ff',
colorPrimaryBgHover: darkMode ? 'rgba(246, 196, 83, 0.30)' : '#bae0ff',
colorPrimaryBorder: darkMode ? 'rgba(246, 196, 83, 0.45)' : '#91caff',
colorPrimaryBorderHover: darkMode ? 'rgba(246, 196, 83, 0.60)' : '#69b1ff',
controlItemBgActive: darkMode ? 'rgba(246, 196, 83, 0.20)' : 'rgba(22, 119, 255, 0.12)',
controlItemBgActiveHover: darkMode ? 'rgba(246, 196, 83, 0.28)' : 'rgba(22, 119, 255, 0.18)',
controlOutline: darkMode ? 'rgba(246, 196, 83, 0.50)' : 'rgba(5, 145, 255, 0.24)',
},
components: {
Layout: {
@@ -815,7 +1000,10 @@ function App() {
},
Tabs: {
cardBg: 'transparent',
itemActiveColor: darkMode ? '#177ddc' : '#1890ff',
itemActiveColor: darkMode ? '#ffd666' : '#1890ff',
itemHoverColor: darkMode ? '#ffe58f' : '#40a9ff',
itemSelectedColor: darkMode ? '#ffd666' : '#1677ff',
inkBarColor: darkMode ? '#ffd666' : '#1677ff',
}
}
}}
@@ -835,7 +1023,7 @@ function App() {
<div
onDoubleClick={handleTitleBarDoubleClick}
style={{
height: 32,
height: titleBarHeight,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
@@ -845,10 +1033,11 @@ function App() {
userSelect: 'none',
WebkitAppRegion: 'drag', // Wails drag region
'--wails-draggable': 'drag',
paddingLeft: 16
paddingLeft: Math.max(12, Math.round(16 * effectiveUiScale)),
fontSize: tokenFontSize
} as any}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: Math.max(6, Math.round(8 * effectiveUiScale)), fontWeight: 600 }}>
{/* Logo can be added here if available */}
GoNavi
</div>
@@ -860,35 +1049,35 @@ function App() {
<Button
type="text"
icon={<MinusOutlined />}
style={{ height: '100%', borderRadius: 0, width: 46 }}
onClick={() => (window as any).runtime.WindowMinimise()}
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={WindowMinimise}
/>
<Button
type="text"
icon={<BorderOutlined />}
style={{ height: '100%', borderRadius: 0, width: 46 }}
onClick={() => (window as any).runtime.WindowToggleMaximise()}
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={WindowToggleMaximise}
/>
<Button
type="text"
icon={<CloseOutlined />}
danger
className="titlebar-close-btn"
style={{ height: '100%', borderRadius: 0, width: 46 }}
onClick={() => (window as any).runtime.Quit()}
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={Quit}
/>
</div>
</div>
<div
style={{
height: 36,
height: toolbarHeight,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 4,
padding: '0 8px',
gap: Math.max(2, Math.round(4 * effectiveUiScale)),
padding: `0 ${Math.max(6, Math.round(8 * effectiveUiScale))}px`,
borderBottom: 'none',
background: bgMain,
}}
@@ -920,17 +1109,42 @@ function App() {
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58 }}>
<Sidebar onEditConnection={handleEditConnection} />
</div>
{/* Sidebar Footer for Log Toggle */}
<div style={{ padding: '8px', borderTop: 'none', display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
<Button
type={isLogPanelOpen ? "primary" : "text"}
icon={<BugOutlined />}
{/* Floating SQL Log Toggle */}
<div
style={{
position: 'absolute',
left: 10,
right: 14,
bottom: 10,
zIndex: 20,
pointerEvents: 'none'
}}
>
<Button
type={isLogPanelOpen ? "primary" : "text"}
icon={<BugOutlined />}
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
block
style={isLogPanelOpen ? {
width: '100%',
height: floatingLogButtonHeight,
borderRadius: 999,
boxShadow: floatingLogButtonShadow,
pointerEvents: 'auto'
} : {
width: '100%',
height: floatingLogButtonHeight,
borderRadius: 999,
border: `1px solid ${floatingLogButtonBorderColor}`,
color: floatingLogButtonTextColor,
background: floatingLogButtonBgColor,
boxShadow: floatingLogButtonShadow,
backdropFilter: blurFilter,
pointerEvents: 'auto'
}}
>
SQL
</Button>
@@ -979,13 +1193,17 @@ function App() {
<DriverManagerModal
open={isDriverModalOpen}
onClose={() => setIsDriverModalOpen(false)}
onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)}
/>
<Modal
title="关于 GoNavi"
open={isAboutOpen}
onCancel={() => setIsAboutOpen(false)}
footer={[
lastUpdateInfo?.hasUpdate ? (
canShowProgressEntry ? (
<Button key="progress" icon={<DownloadOutlined />} onClick={showUpdateDownloadProgress}></Button>
) : null,
lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded ? (
<Button key="download" icon={<DownloadOutlined />} onClick={() => downloadUpdate(lastUpdateInfo, false)}></Button>
) : null,
lastUpdateInfo?.hasUpdate ? (
@@ -1007,7 +1225,7 @@ function App() {
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<GithubOutlined />
{aboutInfo?.repoUrl ? (
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.repoUrl) BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>
{aboutInfo.repoUrl}
</a>
) : '未知'}
@@ -1015,7 +1233,7 @@ function App() {
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<BugOutlined />
{aboutInfo?.issueUrl ? (
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}>
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.issueUrl) BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}>
{aboutInfo.issueUrl}
</a>
) : '未知'}
@@ -1023,7 +1241,7 @@ function App() {
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<CloudDownloadOutlined />
{aboutInfo?.releaseUrl ? (
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}>
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.releaseUrl) BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}>
{aboutInfo.releaseUrl}
</a>
) : '未知'}
@@ -1040,6 +1258,37 @@ function App() {
width={460}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: '12px 0' }}>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}> (UI Scale)</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={MIN_UI_SCALE}
max={MAX_UI_SCALE}
step={0.05}
value={effectiveUiScale}
onChange={(v) => setUiScale(Number(v))}
style={{ flex: 1 }}
/>
<span style={{ width: 56 }}>{Math.round(effectiveUiScale * 100)}%</span>
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
* 85%-95%
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}> (Font Size)</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step={1}
value={effectiveFontSize}
onChange={(v) => setFontSize(Number(v))}
style={{ flex: 1 }}
/>
<span style={{ width: 56 }}>{effectiveFontSize}px</span>
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}> (Opacity)</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
@@ -1088,6 +1337,17 @@ function App() {
*
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
onClick={() => {
setUiScale(DEFAULT_UI_SCALE);
setFontSize(DEFAULT_FONT_SIZE);
setAppearance({ opacity: 1.0, blur: 0 });
}}
>
</Button>
</div>
</div>
</Modal>
@@ -1169,38 +1429,25 @@ function App() {
<Modal
title={updateDownloadProgress.version ? `下载更新 ${updateDownloadProgress.version}` : '下载更新'}
open={updateDownloadProgress.open}
closable={updateDownloadProgress.status === 'error'}
maskClosable={false}
keyboard={updateDownloadProgress.status === 'error'}
onCancel={() => {
if (updateDownloadProgress.status === 'error') {
setUpdateDownloadProgress({
open: false,
version: '',
status: 'idle',
percent: 0,
downloaded: 0,
total: 0,
message: ''
});
}
}}
footer={updateDownloadProgress.status === 'error' ? [
closable
maskClosable
keyboard
onCancel={hideUpdateDownloadProgress}
footer={updateDownloadProgress.status === 'start' || updateDownloadProgress.status === 'downloading' ? [
<Button
key="close"
onClick={() => setUpdateDownloadProgress({
open: false,
version: '',
status: 'idle',
percent: 0,
downloaded: 0,
total: 0,
message: ''
})}
key="background"
onClick={hideUpdateDownloadProgress}
>
</Button>
] : null}
] : (updateDownloadProgress.status === 'done' ? [
<Button key="close" onClick={hideUpdateDownloadProgress}></Button>,
<Button key="install" type="primary" onClick={handleInstallFromProgress}>
{isMacRuntime ? '打开安装目录' : '安装更新'}
</Button>
] : (updateDownloadProgress.status === 'error' ? [
<Button key="close" onClick={hideUpdateDownloadProgress}></Button>
] : null))}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Progress
@@ -1240,7 +1487,7 @@ function App() {
bottom: 0,
left: 0,
width: '4px',
background: 'rgba(24, 144, 255, 0.5)',
background: resizeGuideColor,
zIndex: 9999,
pointerEvents: 'none',
display: 'none'
@@ -1255,7 +1502,7 @@ function App() {
left: sidebarWidth, // Start from sidebar edge
right: 0,
height: '4px',
background: 'rgba(24, 144, 255, 0.5)',
background: resizeGuideColor,
zIndex: 9999,
pointerEvents: 'none',
display: 'none',

View File

@@ -3,7 +3,7 @@ import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Se
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
import { useStore } from '../store';
import { normalizeOpacityForPlatform } from '../utils/appearance';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
const { Meta } = Card;
@@ -41,6 +41,19 @@ const getDefaultPortByType = (type: string) => {
}
};
const singleHostUriSchemesByType: Record<string, string[]> = {
postgres: ['postgresql', 'postgres'],
clickhouse: ['clickhouse'],
oracle: ['oracle'],
sqlserver: ['sqlserver'],
redis: ['redis'],
tdengine: ['tdengine'],
dameng: ['dameng', 'dm'],
kingbase: ['kingbase'],
highgo: ['highgo'],
vastbase: ['vastbase'],
};
const isFileDatabaseType = (type: string) => type === 'sqlite' || type === 'duckdb';
type DriverStatusSnapshot = {
@@ -80,6 +93,7 @@ const ConnectionModal: React.FC<{
const [typeSelectWarning, setTypeSelectWarning] = useState<{ driverName: string; reason: string } | null>(null);
const [driverStatusMap, setDriverStatusMap] = useState<Record<string, DriverStatusSnapshot>>({});
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
const [selectingDbFile, setSelectingDbFile] = useState(false);
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
const testInFlightRef = useRef(false);
const testTimerRef = useRef<number | null>(null);
@@ -92,6 +106,7 @@ const ConnectionModal: React.FC<{
const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single';
const mongoTopology = Form.useWatch('mongoTopology', form) || 'single';
const mongoSrv = Form.useWatch('mongoSrv', form) || false;
const redisTopology = Form.useWatch('redisTopology', form) || 'single';
const getSectionBg = (darkHex: string) => {
if (!darkMode) {
@@ -105,6 +120,8 @@ const ConnectionModal: React.FC<{
};
const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT;
const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff';
const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff';
const tunnelSectionStyle: React.CSSProperties = {
padding: '12px',
@@ -343,6 +360,41 @@ const ConnectionModal: React.FC<{
};
};
const parseSingleHostUri = (
uriText: string,
expectedSchemes: string[],
defaultPort: number,
): { host: string; port: number; username: string; password: string; database: string } | null => {
let parsed: ReturnType<typeof parseMultiHostUri> | null = null;
for (const scheme of expectedSchemes) {
parsed = parseMultiHostUri(uriText, scheme);
if (parsed) {
break;
}
}
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, defaultPort);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(hostList[0] || `localhost:${defaultPort}`, defaultPort);
return {
host: primary?.host || 'localhost',
port: primary?.port || defaultPort,
username: parsed.username,
password: parsed.password,
database: parsed.database || '',
};
};
const parseUriToValues = (uriText: string, type: string): Record<string, any> | null => {
const trimmedUri = String(uriText || '').trim();
if (!trimmedUri) {
@@ -398,6 +450,35 @@ const ConnectionModal: React.FC<{
return { host: normalizeFileDbPath(safeDecode(rawPath)) };
}
if (type === 'redis') {
const parsed = parseMultiHostUri(trimmedUri, 'redis');
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, 6379);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(hostList[0] || 'localhost:6379', 6379);
const topologyParam = String(parsed.params.get('topology') || '').toLowerCase();
const dbText = String(parsed.database || '').trim().replace(/^\//, '');
const dbIndex = Number(dbText);
return {
host: primary?.host || 'localhost',
port: primary?.port || 6379,
password: parsed.password || '',
redisTopology: hostList.length > 1 || topologyParam === 'cluster' ? 'cluster' : 'single',
redisHosts: hostList.slice(1),
redisDB: Number.isFinite(dbIndex) && dbIndex >= 0 && dbIndex <= 15 ? Math.trunc(dbIndex) : 0,
};
}
if (type === 'mongodb') {
const parsed = parseMultiHostUri(trimmedUri, 'mongodb') || parseMultiHostUri(trimmedUri, 'mongodb+srv');
if (!parsed) {
@@ -440,28 +521,22 @@ const ConnectionModal: React.FC<{
};
}
if (type === 'clickhouse') {
const parsed = parseMultiHostUri(trimmedUri, 'clickhouse');
const singleHostSchemes = singleHostUriSchemesByType[type];
if (singleHostSchemes && singleHostSchemes.length > 0) {
const parsed = parseSingleHostUri(trimmedUri, singleHostSchemes, getDefaultPortByType(type));
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
if (type === 'oracle' && !String(parsed.database || '').trim()) {
// Oracle 需要显式 service name避免 URI 解析后放过必填校验。
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, 9000);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(hostList[0] || 'localhost:9000', 9000);
return {
host: primary?.host || 'localhost',
port: primary?.port || 9000,
host: parsed.host,
port: parsed.port,
user: parsed.username,
password: parsed.password,
database: parsed.database || '',
database: parsed.database,
};
}
@@ -502,6 +577,12 @@ const ConnectionModal: React.FC<{
if (dbType === 'clickhouse') {
return 'clickhouse://default:pass@127.0.0.1:9000/default';
}
if (dbType === 'redis') {
return 'redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster';
}
if (dbType === 'oracle') {
return 'oracle://user:pass@127.0.0.1:1521/ORCLPDB1';
}
return '例如: postgres://user:pass@127.0.0.1:5432/db_name';
};
@@ -537,6 +618,26 @@ const ConnectionModal: React.FC<{
return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
}
if (type === 'redis') {
const primary = toAddress(host, port, 6379);
const clusterHosts = values.redisTopology === 'cluster'
? normalizeAddressList(values.redisHosts, 6379)
: [];
const hosts = normalizeAddressList([primary, ...clusterHosts], 6379);
const params = new URLSearchParams();
if (hosts.length > 1 || values.redisTopology === 'cluster') {
params.set('topology', 'cluster');
}
const redisPassword = String(values.password || '');
const redisAuth = redisPassword ? `:${encodeURIComponent(redisPassword)}@` : '';
const redisDB = Number.isFinite(Number(values.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(values.redisDB))))
: 0;
const dbPath = `/${redisDB}`;
const query = params.toString();
return `redis://${redisAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
}
if (isFileDatabaseType(type)) {
const pathText = normalizeFileDbPath(String(values.host || '').trim());
if (!pathText) {
@@ -665,6 +766,30 @@ const ConnectionModal: React.FC<{
}
};
const handleSelectDatabaseFile = async () => {
if (selectingDbFile) {
return;
}
try {
setSelectingDbFile(true);
const currentPath = String(form.getFieldValue('host') || '').trim();
const res = await SelectDatabaseFile(currentPath, dbType);
if (res?.success) {
const data = res.data || {};
const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim();
if (selectedPath) {
form.setFieldValue('host', normalizeFileDbPath(selectedPath));
}
} else if (res?.message !== 'Cancelled') {
message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`);
}
} catch (e: any) {
message.error(`选择数据库文件失败: ${e?.message || String(e)}`);
} finally {
setSelectingDbFile(false);
}
};
useEffect(() => {
if (open) {
setTestResult(null); // Reset test result
@@ -698,8 +823,10 @@ const ConnectionModal: React.FC<{
: (primaryAddress?.port || Number(config.port || defaultPort));
const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : [];
const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : [];
const redisHosts = configType === 'redis' ? normalizedHosts.slice(1) : [];
const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0;
const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet;
const redisIsCluster = String(config.topology || '').toLowerCase() === 'cluster' || redisHosts.length > 0;
form.setFieldsValue({
type: configType,
name: initialValues.name,
@@ -732,12 +859,15 @@ const ConnectionModal: React.FC<{
mysqlReplicaPassword: config.mysqlReplicaPassword || '',
mongoTopology: mongoIsReplica ? 'replica' : 'single',
mongoHosts: mongoHosts,
redisTopology: redisIsCluster ? 'cluster' : 'single',
redisHosts: redisHosts,
mongoSrv: !!config.mongoSrv,
mongoReplicaSet: config.replicaSet || '',
mongoAuthSource: config.authSource || '',
mongoReadPreference: config.readPreference || 'primary',
mongoAuthMechanism: config.mongoAuthMechanism || '',
savePassword: config.savePassword !== false,
redisDB: Number.isFinite(Number(config.redisDB)) ? Number(config.redisDB) : 0,
mongoReplicaUser: config.mongoReplicaUser || '',
mongoReplicaPassword: config.mongoReplicaPassword || ''
});
@@ -852,7 +982,6 @@ const ConnectionModal: React.FC<{
if (res.success) {
setTestResult({ type: 'success', message: res.message });
if (isRedisType) {
// Redis: generate database list 0-15
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
} else {
// Other databases: fetch database list
@@ -961,7 +1090,7 @@ const ConnectionModal: React.FC<{
}
let hosts: string[] = [];
let topology: 'single' | 'replica' | undefined;
let topology: 'single' | 'replica' | 'cluster' | undefined;
let replicaSet = '';
let authSource = '';
let readPreference = '';
@@ -1015,6 +1144,22 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism = String(mergedValues.mongoAuthMechanism || '').trim().toUpperCase();
}
if (type === 'redis') {
const clusterNodes = mergedValues.redisTopology === 'cluster'
? normalizeAddressList(mergedValues.redisHosts, defaultPort)
: [];
const allHosts = normalizeAddressList([`${primaryHost}:${primaryPort}`, ...clusterNodes], defaultPort);
if (mergedValues.redisTopology === 'cluster' || allHosts.length > 1) {
hosts = allHosts;
topology = 'cluster';
} else {
topology = 'single';
}
mergedValues.redisDB = Number.isFinite(Number(mergedValues.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
: 0;
}
const sshConfig = mergedValues.useSSH ? {
host: mergedValues.sshHost,
port: Number(mergedValues.sshPort),
@@ -1056,6 +1201,9 @@ const ConnectionModal: React.FC<{
driver: mergedValues.driver,
dsn: mergedValues.dsn,
timeout: Number(mergedValues.timeout || 30),
redisDB: Number.isFinite(Number(mergedValues.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
: 0,
uri: String(mergedValues.uri || '').trim(),
hosts: hosts,
topology: topology,
@@ -1106,6 +1254,7 @@ const ConnectionModal: React.FC<{
proxyUser: '',
proxyPassword: '',
mysqlTopology: 'single',
redisTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
@@ -1114,11 +1263,13 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
redisHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
redisDB: 0,
});
} else if (type !== 'custom') {
const defaultUser = type === 'clickhouse' ? 'default' : 'root';
@@ -1127,6 +1278,7 @@ const ConnectionModal: React.FC<{
database: '',
port: defaultPort,
mysqlTopology: 'single',
redisTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
@@ -1135,11 +1287,13 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
redisHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
redisDB: 0,
});
}
@@ -1223,8 +1377,8 @@ const ConnectionModal: React.FC<{
cursor: 'pointer',
borderRadius: 6,
marginBottom: 4,
background: activeGroup === idx ? '#e6f4ff' : 'transparent',
color: activeGroup === idx ? '#1677ff' : undefined,
background: activeGroup === idx ? step1SidebarActiveBg : 'transparent',
color: activeGroup === idx ? step1SidebarActiveColor : undefined,
fontWeight: activeGroup === idx ? 500 : 400,
transition: 'all 0.2s',
fontSize: 13,
@@ -1274,17 +1428,20 @@ const ConnectionModal: React.FC<{
timeout: 30,
uri: '',
mysqlTopology: 'single',
redisTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
redisHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
redisDB: 0,
}}
onValuesChange={(changed) => {
if (testResult) {
@@ -1312,6 +1469,17 @@ const ConnectionModal: React.FC<{
}
// Type change handled by step 1, but keep sync if select changes (hidden now)
if (changed.type !== undefined) setDbType(changed.type);
if (changed.redisTopology !== undefined) {
const supportedDbs = Array.from({ length: 16 }, (_, i) => i);
setRedisDbList(supportedDbs);
const selectedDbsRaw = form.getFieldValue('includeRedisDatabases');
const selectedDbs = Array.isArray(selectedDbsRaw) ? selectedDbsRaw.map((entry: any) => Number(entry)) : [];
const validDbs = selectedDbs
.filter((entry: number) => Number.isFinite(entry))
.map((entry: number) => Math.trunc(entry))
.filter((entry: number) => supportedDbs.includes(entry));
form.setFieldValue('includeRedisDatabases', validDbs.length > 0 ? validDbs : undefined);
}
if (
changed.type !== undefined
|| changed.host !== undefined
@@ -1392,6 +1560,13 @@ const ConnectionModal: React.FC<{
onDoubleClick={requestTest}
/>
</Form.Item>
{isFileDb && (
<Form.Item label=" " style={{ width: 120 }}>
<Button style={{ width: '100%' }} onClick={handleSelectDatabaseFile} loading={selectingDbFile}>
...
</Button>
</Form.Item>
)}
{!isFileDb && (
<Form.Item
name="port"
@@ -1414,6 +1589,17 @@ const ConnectionModal: React.FC<{
</Form.Item>
)}
{dbType === 'oracle' && (
<Form.Item
name="database"
label="服务名 (Service Name)"
rules={[createUriAwareRequiredRule('请输入 Oracle 服务名(例如 ORCLPDB1')]}
help="请填写监听器注册的 SERVICE_NAME不是用户名。例如ORCLPDB1"
>
<Input placeholder="例如ORCLPDB1" />
</Form.Item>
)}
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && (
<>
<Form.Item name="mysqlTopology" label="连接模式">
@@ -1567,11 +1753,36 @@ const ConnectionModal: React.FC<{
{/* Redis specific: password only, no username */}
{isRedis && (
<>
<Form.Item name="redisTopology" label="连接模式">
<Select
options={[
{ value: 'single', label: '单机模式' },
{ value: 'cluster', label: '集群模式Redis Cluster' },
]}
/>
</Form.Item>
{redisTopology === 'cluster' && (
<Form.Item
name="redisHosts"
label="集群附加节点地址"
help="主节点使用上方主机地址这里填写其他种子节点格式host:port"
>
<Select mode="tags" placeholder="例如10.10.0.12:6379、10.10.0.13:6379" tokenSeparators={[',', ';', ' ']} />
</Form.Item>
)}
<Form.Item name="password" label="密码 (可选)">
<Input.Password placeholder="Redis 密码(如果设置了 requirepass" />
</Form.Item>
<Form.Item name="includeRedisDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
<Select mode="multiple" placeholder="选择显示的数据库 (0-15)" allowClear>
<Form.Item
name="includeRedisDatabases"
label="显示数据库 (留空显示全部)"
help="连接测试成功后可选择"
>
<Select
mode="multiple"
placeholder="选择显示的数据库 (0-15)"
allowClear
>
{redisDbList.map(db => <Select.Option key={db} value={db}>db{db}</Select.Option>)}
</Select>
</Form.Item>

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,10 @@ import React, { useEffect, useState, useCallback, useRef } from 'react';
import { message } from 'antd';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetColumns, DBQueryIsolated } from '../../wailsjs/go/app/App';
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildOrderBySQL, buildWhereSQL, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { buildOrderBySQL, buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
type ViewerPaginationState = {
current: number;
@@ -16,18 +17,33 @@ type ViewerPaginationState = {
totalCountCancelled: boolean;
};
const JS_MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
const isIntegerText = (text: string): boolean => /^[+-]?\d+$/.test(text);
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
if (typeof value === 'number') {
return Number.isFinite(value) && value >= 0 ? value : null;
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
}
if (typeof value === 'bigint') {
return value >= 0n ? Number(value) : null;
return value >= 0n && value <= JS_MAX_SAFE_INTEGER_BIGINT ? Number(value) : null;
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) return null;
if (isIntegerText(text)) {
try {
const parsedBigInt = BigInt(text);
if (parsedBigInt < 0n || parsedBigInt > JS_MAX_SAFE_INTEGER_BIGINT) {
return null;
}
return Number(parsedBigInt);
} catch {
return null;
}
}
const parsed = Number(text);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null;
}
return null;
};
@@ -108,6 +124,33 @@ const resolveDuckDBSchemaAndTable = (dbName: string, tableName: string) => {
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");
const isDuckDBUnsupportedTypeError = (msg: string): boolean => /unsupported\s*type:\s*duckdb\./i.test(String(msg || ''));
const isDuckDBComplexColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
return raw.includes('map') || raw.includes('struct') || raw.includes('union') || raw.includes('array') || raw.includes('list');
};
const reverseOrderBySQL = (orderBySQL: string): string => {
const raw = String(orderBySQL || '').trim();
if (!raw) return '';
const body = raw.replace(/^order\s+by\s+/i, '').trim();
if (!body) return '';
const parts = body
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
if (/\s+asc$/i.test(part)) return part.replace(/\s+asc$/i, ' DESC');
if (/\s+desc$/i.test(part)) return part.replace(/\s+desc$/i, ' ASC');
return `${part} DESC`;
});
if (parts.length === 0) return '';
return ` ORDER BY ${parts.join(', ')}`;
};
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [data, setData] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
@@ -144,12 +187,11 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [showFilter, setShowFilter] = useState(false);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>([]);
const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase();
const forceReadOnly = currentConnType === 'tdengine' || currentConnType === 'clickhouse';
const runIsolatedQuery = useCallback(async (queryConfig: any, dbName: string, sql: string) => {
return DBQueryIsolated(queryConfig as any, dbName, sql);
}, []);
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
const currentConnType = currentConnCaps.type;
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
useEffect(() => {
setPkColumns([]);
@@ -157,6 +199,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
countKeyRef.current = '';
duckdbApproxKeyRef.current = '';
manualCountKeyRef.current = '';
duckdbSafeSelectCacheRef.current = {};
latestConfigRef.current = null;
latestDbTypeRef.current = '';
latestDbNameRef.current = '';
@@ -194,7 +237,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const countConfig: any = { ...(config as any), timeout: 120 };
try {
const resCount = await runIsolatedQuery(countConfig, dbName, countSql);
const resCount = await DBQuery(countConfig as any, dbName, countSql);
const countDuration = Date.now() - countStart;
addSqlLog({
id: `log-${Date.now()}-duckdb-manual-count`,
@@ -240,7 +283,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error(`统计总数失败: ${String(e?.message || e)}`);
}
}, [addSqlLog, runIsolatedQuery]);
}, [addSqlLog]);
const handleDuckDBCancelManualCount = useCallback(() => {
manualCountSeqRef.current++;
@@ -277,35 +320,112 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const countSql = `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
const offset = (page - 1) * size;
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns);
let sql = `${baseSql}${orderBySQL}`;
const totalRows = Number(pagination.total);
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
const totalKnown = pagination.totalKnown && hasFiniteTotal;
const totalPages = hasFiniteTotal ? Math.max(1, Math.ceil(totalRows / size)) : 0;
const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
const offset = (currentPage - 1) * size;
const isClickHouse = dbTypeLower === 'clickhouse';
const reverseOrderSQL = isClickHouse ? reverseOrderBySQL(orderBySQL) : '';
let useClickHouseReversePagination = false;
let clickHouseReverseLimit = 0;
let clickHouseReverseHasMore = false;
// ClickHouse 深分页在超大 OFFSET 下容易超时。对于总数已知且存在 ORDER BY 的场景,
// 当“尾部偏移”小于“头部偏移”时,改为反向 ORDER BY + 小 OFFSET并在前端翻转结果。
if (isClickHouse && totalKnown && offset > 0 && reverseOrderSQL) {
const pageRowCount = Math.max(0, Math.min(size, totalRows - offset));
if (pageRowCount > 0) {
const tailOffset = Math.max(0, totalRows - (offset + pageRowCount));
if (tailOffset < offset) {
sql = `${baseSql}${reverseOrderSQL} LIMIT ${pageRowCount} OFFSET ${tailOffset}`;
useClickHouseReversePagination = true;
clickHouseReverseLimit = pageRowCount;
clickHouseReverseHasMore = currentPage < totalPages;
}
}
}
if (!useClickHouseReversePagination) {
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
}
const requestStartTime = Date.now();
let executedSql = sql;
try {
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
const startTime = Date.now();
const result = await DBQuery(config as any, dbName, querySql);
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
sql: querySql,
status: result.success ? 'success' : 'error',
duration: Date.now() - startTime,
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
dbName
});
return result;
try {
const result = await DBQuery(config as any, dbName, querySql);
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
sql: querySql,
status: result.success ? 'success' : 'error',
duration: Date.now() - startTime,
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
dbName
});
return result;
} catch (e: any) {
const errMessage = String(e?.message || e || 'query failed');
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
sql: querySql,
status: 'error',
duration: Date.now() - startTime,
message: `${attemptLabel}: ${errMessage}`,
dbName
});
return { success: false, message: errMessage, data: [], fields: [] };
}
};
const hasSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
let resData = await executeDataQuery(sql, '主查询');
if (!resData.success && dbTypeLower === 'duckdb' && isDuckDBUnsupportedTypeError(String(resData.message || ''))) {
const cacheKey = `${tab.connectionId}|${dbName}|${tableName}`;
let safeSelect = duckdbSafeSelectCacheRef.current[cacheKey] || '';
if (!safeSelect) {
try {
const resCols = await DBGetColumns(config as any, dbName, tableName);
if (resCols?.success && Array.isArray(resCols.data)) {
const columnDefs = resCols.data as ColumnDefinition[];
const selectParts = columnDefs.map((col) => {
const colName = String(col?.name || '').trim();
if (!colName) return '';
const quotedCol = quoteIdentPart(dbType, colName);
if (isDuckDBComplexColumnType(col?.type)) {
return `CAST(${quotedCol} AS VARCHAR) AS ${quotedCol}`;
}
return quotedCol;
}).filter(Boolean);
if (selectParts.length > 0) {
safeSelect = selectParts.join(', ');
duckdbSafeSelectCacheRef.current[cacheKey] = safeSelect;
}
}
} catch {
// ignore and keep original error path
}
}
if (safeSelect) {
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
fallbackSql += buildOrderBySQL(dbType, sortInfo, pkColumns);
fallbackSql += ` LIMIT ${size + 1} OFFSET ${offset}`;
executedSql = fallbackSql;
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
}
}
if (!resData.success && isMySQLFamily && hasSort && isSortMemoryErr(resData.message)) {
const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024);
if (retrySql32MB !== sql) {
@@ -348,7 +468,12 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
let resultData = resData.data as any[];
if (!Array.isArray(resultData)) resultData = [];
const hasMore = resultData.length > size;
if (useClickHouseReversePagination) {
// 反向查询后恢复为原排序方向,保证用户看到的仍是“最后一页正序数据”。
resultData = resultData.slice(0, clickHouseReverseLimit).reverse();
}
const hasMore = useClickHouseReversePagination ? clickHouseReverseHasMore : resultData.length > size;
if (hasMore) resultData = resultData.slice(0, size);
let fieldNames = resData.fields || [];
@@ -363,7 +488,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
setData(resultData);
const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`;
const derivedTotalKnown = !hasMore;
const derivedTotal = derivedTotalKnown ? offset + resultData.length : page * size + 1;
const derivedTotal = derivedTotalKnown ? offset + resultData.length : currentPage * size + 1;
const isDuckDB = dbTypeLower === 'duckdb';
const minExpectedTotal = hasMore ? offset + resultData.length + 1 : offset + resultData.length;
if (derivedTotalKnown) countKeyRef.current = countKey;
@@ -377,7 +502,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
if (derivedTotalKnown) {
return {
...prev,
current: page,
current: currentPage,
pageSize: size,
total: derivedTotal,
totalKnown: true,
@@ -388,19 +513,19 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
if (prev.totalKnown && countKeyRef.current === countKey) {
if (!isDuckDB) {
return { ...prev, current: page, pageSize: size };
return { ...prev, current: currentPage, pageSize: size };
}
// 当当前页存在“下一页”信号时,已知总数至少应大于当前页末尾。
// 若旧总数不满足该条件(例如历史统计值为 0降级为未知总数并回退到 derivedTotal。
if (Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
return { ...prev, current: page, pageSize: size };
return { ...prev, current: currentPage, pageSize: size };
}
}
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
return {
...prev,
current: page,
current: currentPage,
pageSize: size,
totalKnown: false,
totalApprox: true,
@@ -410,7 +535,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
return {
...prev,
current: page,
current: currentPage,
pageSize: size,
total: derivedTotal,
totalKnown: false,
@@ -450,11 +575,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
if (!resCount.success) return;
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
let total: number | null = null;
const parsed = Number(resCount.data[0]?.['total']);
if (Number.isFinite(parsed) && parsed >= 0) {
total = parsed;
}
const total = parseTotalFromCountRow(resCount.data[0]);
if (total === null) return;
setPagination(prev => ({
@@ -489,7 +610,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
(async () => {
for (const approxSql of approxSqlCandidates) {
try {
const approxRes = await runIsolatedQuery(approxConfig, dbName, approxSql);
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (countKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
@@ -534,7 +655,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, pkColumns, runIsolatedQuery]);
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown]);
// 依赖 pkColumns在无手动排序时可回退到主键稳定排序。
// 主键信息只会在首次加载后更新一次,避免循环查询。
@@ -566,6 +687,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
columnNames={columnNames}
loading={loading}
tableName={tab.tableName}
exportScope="table"
dbName={tab.dbName}
connectionId={tab.connectionId}
pkColumns={pkColumns}

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, ReloadOutlined } from '@ant-design/icons';
import { Alert, Button, Collapse, Input, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, InfoCircleFilled, ReloadOutlined } from '@ant-design/icons';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { useStore } from '../store';
import { normalizeOpacityForPlatform } from '../utils/appearance';
@@ -63,6 +63,9 @@ type DriverNetworkProbe = {
reachable: boolean;
httpStatus?: number;
latencyMs?: number;
tcpLatencyMs?: number;
httpLatencyMs?: number;
method?: string;
error?: string;
};
@@ -71,12 +74,22 @@ type DriverNetworkStatus = {
summary: string;
recommendedProxy: boolean;
proxyConfigured: boolean;
downloadChainReachable?: boolean;
downloadRequiredHosts?: string[];
proxyEnv?: Record<string, string>;
checks: DriverNetworkProbe[];
checkedAt?: string;
logPath?: string;
};
const parseOptionalLatency = (value: unknown): number | undefined => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return undefined;
return parsed;
};
const sharedInfoAlertIcon = <InfoCircleFilled style={{ fontSize: 24 }} />;
type DriverVersionOption = {
version: string;
downloadUrl: string;
@@ -90,6 +103,14 @@ type DriverVersionOption = {
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
const DRIVER_TABLE_SCROLL_X = 1450;
const DRIVER_STATUS_CACHE_TTL_MS = 60 * 1000;
const DRIVER_NETWORK_CACHE_TTL_MS = 5 * 60 * 1000;
const normalizeDriverSearchText = (value: string) => String(value || '').trim().toLowerCase();
let driverStatusSnapshotCache: { rows: DriverStatusRow[]; downloadDir: string; cachedAt: number } | null = null;
let driverNetworkSnapshotCache: { status: DriverNetworkStatus; cachedAt: number } | null = null;
const isFreshCache = (cachedAt: number, ttlMs: number): boolean => Date.now() - cachedAt <= ttlMs;
const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
type SelectOption = { value: string; label: string };
@@ -137,7 +158,11 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
return grouped;
};
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenGlobalProxySettings?: () => void }> = ({
open,
onClose,
onOpenGlobalProxySettings,
}) => {
const theme = useStore((state) => state.theme);
const appearance = useStore((state) => state.appearance);
const darkMode = theme === 'dark';
@@ -151,6 +176,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
const [downloadDir, setDownloadDir] = useState('');
const [networkChecking, setNetworkChecking] = useState(false);
const [networkStatus, setNetworkStatus] = useState<DriverNetworkStatus | null>(null);
const [searchKeyword, setSearchKeyword] = useState('');
const [rows, setRows] = useState<DriverStatusRow[]>([]);
const [actionState, setActionState] = useState<{ driverType: string; kind: DriverActionKind }>({ driverType: '', kind: '' });
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
@@ -164,6 +190,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
const [horizontalScrollWidth, setHorizontalScrollWidth] = useState(DRIVER_TABLE_SCROLL_X);
const downloadDirRef = useRef(downloadDir);
useEffect(() => {
downloadDirRef.current = downloadDir;
}, [downloadDir]);
const appendOperationLog = useCallback((
driverType: string,
@@ -281,10 +312,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
horizontalSyncSourceRef.current = '';
}, []);
const refreshStatus = useCallback(async (toastOnError = true) => {
setLoading(true);
const refreshStatus = useCallback(async (
toastOnError = true,
options?: { showLoading?: boolean },
) => {
const showLoading = options?.showLoading ?? true;
if (showLoading) {
setLoading(true);
}
try {
const res = await GetDriverStatusList(downloadDir, '');
const res = await GetDriverStatusList(downloadDirRef.current, '');
if (!res?.success) {
if (toastOnError) {
message.error(res?.message || '拉取驱动状态失败');
@@ -296,6 +333,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
const resolvedDir = String(data.downloadDir || '').trim();
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
const effectiveDownloadDir = resolvedDir || downloadDirRef.current;
if (resolvedDir) {
setDownloadDir(resolvedDir);
}
@@ -318,17 +356,30 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
message: String(item.message || '').trim() || undefined,
}));
setRows(nextRows);
driverStatusSnapshotCache = {
rows: nextRows,
downloadDir: effectiveDownloadDir,
cachedAt: Date.now(),
};
} catch (err: any) {
if (toastOnError) {
message.error(`拉取驱动状态失败:${err?.message || String(err)}`);
}
} finally {
setLoading(false);
if (showLoading) {
setLoading(false);
}
}
}, [downloadDir]);
}, []);
const checkNetworkStatus = useCallback(async (toastOnError = false) => {
setNetworkChecking(true);
const checkNetworkStatus = useCallback(async (
toastOnError = false,
options?: { showLoading?: boolean },
) => {
const showLoading = options?.showLoading ?? true;
if (showLoading) {
setNetworkChecking(true);
}
try {
const res = await CheckDriverNetworkStatus();
if (!res?.success) {
@@ -343,26 +394,40 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
name: String(item.name || '').trim(),
url: String(item.url || '').trim(),
reachable: !!item.reachable,
httpStatus: Number(item.httpStatus || 0) || undefined,
latencyMs: Number(item.latencyMs || 0) || undefined,
httpStatus: parseOptionalLatency(item.httpStatus),
latencyMs: parseOptionalLatency(item.latencyMs),
tcpLatencyMs: parseOptionalLatency(item.tcpLatencyMs),
httpLatencyMs: parseOptionalLatency(item.httpLatencyMs),
method: String(item.method || '').trim().toUpperCase() || undefined,
error: String(item.error || '').trim() || undefined,
}));
setNetworkStatus({
const nextStatus: DriverNetworkStatus = {
reachable: !!data.reachable,
summary: String(data.summary || '').trim() || '驱动网络检测已完成',
recommendedProxy: !!data.recommendedProxy,
proxyConfigured: !!data.proxyConfigured,
downloadChainReachable: typeof data.downloadChainReachable === 'boolean' ? data.downloadChainReachable : undefined,
downloadRequiredHosts: Array.isArray(data.downloadRequiredHosts)
? data.downloadRequiredHosts.map((item: unknown) => String(item || '').trim()).filter(Boolean)
: undefined,
proxyEnv: (data.proxyEnv || {}) as Record<string, string>,
checkedAt: String(data.checkedAt || '').trim() || undefined,
checks: normalizedChecks,
logPath: String(data.logPath || '').trim() || undefined,
});
};
setNetworkStatus(nextStatus);
driverNetworkSnapshotCache = {
status: nextStatus,
cachedAt: Date.now(),
};
} catch (err: any) {
if (toastOnError) {
message.error(`驱动网络检测失败:${err?.message || String(err)}`);
}
} finally {
setNetworkChecking(false);
if (showLoading) {
setNetworkChecking(false);
}
}
}, []);
@@ -521,8 +586,29 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
tableScrollTargetsRef.current = [];
return;
}
refreshStatus(false);
checkNetworkStatus(false);
const cachedStatus = driverStatusSnapshotCache;
const hasCachedStatus = !!cachedStatus;
if (cachedStatus) {
setRows(cachedStatus.rows);
if (cachedStatus.downloadDir) {
setDownloadDir(cachedStatus.downloadDir);
}
}
const shouldRefreshStatus = !cachedStatus || !isFreshCache(cachedStatus.cachedAt, DRIVER_STATUS_CACHE_TTL_MS);
if (shouldRefreshStatus) {
void refreshStatus(false, { showLoading: !hasCachedStatus });
}
const cachedNetwork = driverNetworkSnapshotCache;
const hasCachedNetwork = !!cachedNetwork;
if (cachedNetwork) {
setNetworkStatus(cachedNetwork.status);
}
const shouldRefreshNetwork = !cachedNetwork || !isFreshCache(cachedNetwork.cachedAt, DRIVER_NETWORK_CACHE_TTL_MS);
if (shouldRefreshNetwork) {
void checkNetworkStatus(false, { showLoading: !hasCachedNetwork });
}
}, [checkNetworkStatus, open, refreshStatus]);
useEffect(() => {
@@ -1075,10 +1161,47 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
}
return rows.find((item) => item.type === logDriverType);
}, [logDriverType, rows]);
const normalizedSearchKeyword = useMemo(() => normalizeDriverSearchText(searchKeyword), [searchKeyword]);
const filteredRows = useMemo(() => {
if (!normalizedSearchKeyword) {
return rows;
}
return rows.filter((row) => {
const searchableParts = [
row.name,
row.type,
row.pinnedVersion,
row.installedVersion,
row.message,
row.builtIn ? '内置' : '外置',
row.connectable ? '已启用' : row.packageInstalled ? '已安装' : '未启用',
];
const searchableText = normalizeDriverSearchText(searchableParts.filter(Boolean).join(' '));
return searchableText.includes(normalizedSearchKeyword);
});
}, [normalizedSearchKeyword, rows]);
const filterSummaryText = useMemo(() => {
if (normalizedSearchKeyword) {
return `匹配 ${filteredRows.length} / ${rows.length}`;
}
return `${rows.length} 个驱动`;
}, [filteredRows.length, normalizedSearchKeyword, rows.length]);
const activeDriverLogs = operationLogMap[logDriverType] || [];
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {});
const downloadRequiredHosts = (networkStatus?.downloadRequiredHosts || []).filter(Boolean);
const showDownloadChainAlert = networkStatus?.downloadChainReachable === false;
const networkUnreachable = networkStatus?.reachable === false;
const downloadRequiredHostText = (downloadRequiredHosts.length > 0
? downloadRequiredHosts
: ['github.com', 'api.github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com', 'raw.githubusercontent.com']).join('、');
const githubConnectivityProbe = networkStatus?.checks.find((item) => item.name === 'GitHub API')
|| networkStatus?.checks.find((item) => item.name === 'GitHub 驱动发布')
|| null;
const githubConnectivityLatencyMs = githubConnectivityProbe
? (githubConnectivityProbe.httpLatencyMs ?? githubConnectivityProbe.latencyMs ?? githubConnectivityProbe.tcpLatencyMs)
: undefined;
const logBlockBackground = darkMode
? `rgba(28, 28, 28, ${Math.max(opacity, 0.82)})`
: `rgba(255, 255, 255, ${Math.max(opacity, 0.92)})`;
@@ -1129,15 +1252,43 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Text type="secondary"> MySQL / Redis / Oracle / PostgreSQL </Text>
{networkStatus ? (
<Alert
type={networkStatus.reachable ? 'success' : 'warning'}
showIcon
message={networkStatus.summary}
description={(
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary">
GitHub Go HTTP/HTTPS/SOCKS5
</Text>
networkUnreachable ? (
<Alert
type="error"
showIcon
message={showDownloadChainAlert ? '重要提醒:驱动下载链路域名不可达' : '重要提醒:驱动下载网络不可达'}
description={(
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{showDownloadChainAlert ? (
<>
<Text>
访 GitHub
GoNavi
</Text>
{onOpenGlobalProxySettings ? (
<Button size="small" onClick={onOpenGlobalProxySettings}></Button>
) : null}
<Text>
{downloadRequiredHostText} TUN
</Text>
</>
) : (
<Text>{networkStatus.summary}</Text>
)}
{proxyEnvEntries.length > 0 ? (
<Text type="secondary">
{proxyEnvEntries.map(([key]) => key).join('、')}
</Text>
) : null}
</Space>
)}
/>
) : (
<Alert
type="success"
showIcon
message={networkStatus.summary}
description={(
<Collapse
size="small"
items={[
@@ -1146,11 +1297,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
label: '查看网络检测明细',
children: (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{networkStatus.checks.map((item) => (
<Text key={`${item.name}-${item.url}`} type={item.reachable ? 'secondary' : 'danger'}>
{item.name}{item.reachable ? '可达' : '不可达'}{item.httpStatus ? `HTTP ${item.httpStatus}` : ''}{item.latencyMs ? `${item.latencyMs}ms` : ''}{item.error ? `${item.error}` : ''}
</Text>
))}
<Text type="secondary">
GitHub {githubConnectivityProbe ? (githubConnectivityProbe.reachable ? '可达' : '不可达') : '暂无结果'}
{githubConnectivityLatencyMs !== undefined ? `${githubConnectivityLatencyMs}ms` : ''}
{githubConnectivityProbe?.error ? `${githubConnectivityProbe.error}` : ''}
</Text>
{proxyEnvEntries.length > 0 ? (
<Text type="secondary">
{proxyEnvEntries.map(([key]) => key).join('、')}
@@ -1163,34 +1314,58 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
},
]}
/>
</Space>
)}
/>
)}
/>
)
) : (
<Alert type="info" showIcon message={networkChecking ? '正在检测驱动下载网络...' : '尚未完成网络检测'} />
<Alert
type="info"
showIcon
icon={sharedInfoAlertIcon}
message={networkChecking ? '正在检测驱动下载网络...' : '尚未完成网络检测'}
/>
)}
<Alert
type="info"
showIcon
icon={sharedInfoAlertIcon}
message="驱动目录与复用说明"
description={(
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary">/ `mariadb-driver-agent``mariadb-driver-agent.exe``GoNavi-DriverAgents.zip`使</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
</Paragraph>
) : null}
</Space>
<Collapse
size="small"
items={[
{
key: 'driver-directory',
label: '查看驱动目录与复用说明',
children: (
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary">/ `mariadb-driver-agent``mariadb-driver-agent.exe``GoNavi-DriverAgents.zip`使</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
</Paragraph>
) : null}
</Space>
),
},
]}
/>
)}
/>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<div style={{ width: '100%', display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Input.Search
allowClear
placeholder="搜索驱动名称/类型(如 DuckDB、clickhouse"
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
style={{ minWidth: 300, flex: '1 1 360px' }}
/>
<Space size={8}>
<Text type="secondary"></Text>
<Switch
@@ -1198,15 +1373,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
onChange={(checked) => setForceOverwriteInstalled(checked)}
disabled={batchDirectoryImporting}
/>
<Button
icon={<FolderOpenOutlined />}
loading={batchDirectoryImporting}
onClick={() => void installDriversFromDirectory()}
>
</Button>
</Space>
<Button
icon={<FolderOpenOutlined />}
loading={batchDirectoryImporting}
onClick={() => void installDriversFromDirectory()}
>
</Button>
</Space>
</div>
<Text type="secondary">{filterSummaryText}</Text>
<div
ref={tableContainerRef}
@@ -1217,11 +1393,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
rowKey="type"
loading={loading}
columns={columns as any}
dataSource={rows}
dataSource={filteredRows}
pagination={false}
size="middle"
sticky={false}
scroll={{ x: DRIVER_TABLE_SCROLL_X }}
locale={{
emptyText: normalizedSearchKeyword
? `未找到匹配“${String(searchKeyword || '').trim()}”的驱动`
: '暂无驱动数据',
}}
/>
</div>
</Space>

View File

@@ -27,8 +27,9 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
const bgMain = getBg('#1f1f1f');
const bgToolbar = getBg('#2a2a2a');
const bgMain = getBg('#1d1d1d');
const panelDividerColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
const panelMutedTextColor = darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(0,0,0,0.58)';
const logScrollbarThumb = darkMode ? 'rgba(255, 255, 255, 0.34)' : 'rgba(0, 0, 0, 0.26)';
const logScrollbarThumbHover = darkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.36)';
@@ -37,7 +38,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
title: 'Time',
dataIndex: 'timestamp',
width: 80,
render: (ts: number) => <span style={{ color: '#888', fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
render: (ts: number) => <span style={{ color: panelMutedTextColor, fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
},
{
title: 'Status',
@@ -62,7 +63,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.2' }}>
<div style={{ color: darkMode ? '#a6e22e' : '#005cc5' }}>{text}</div>
{record.message && <div style={{ color: '#ff4d4f', marginTop: 2 }}>{record.message}</div>}
{record.affectedRows !== undefined && <div style={{ color: '#888', marginTop: 1 }}>Affected: {record.affectedRows}</div>}
{record.affectedRows !== undefined && <div style={{ color: panelMutedTextColor, marginTop: 1 }}>Affected: {record.affectedRows}</div>}
</div>
)
}
@@ -71,7 +72,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
return (
<div style={{
height,
borderTop: 'none',
borderTop: `1px solid ${panelDividerColor}`,
background: bgMain,
display: 'flex',
flexDirection: 'column',
@@ -95,7 +96,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
{/* Toolbar */}
<div style={{
padding: '4px 8px',
borderBottom: 'none',
borderBottom: `1px solid ${panelDividerColor}`,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined } from '@ant-design/icons';
@@ -7,6 +7,7 @@ import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
@@ -14,6 +15,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
type ResultSet = {
key: string;
sql: string;
exportSql?: string;
rows: any[];
columns: string[];
tableName?: string;
@@ -47,6 +49,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const visibleDbsRef = useRef<string[]>([]); // Store visible databases for cross-db intellisense
const connections = useStore(state => state.connections);
const queryCapableConnections = useMemo(
() => connections.filter(c => getDataSourceCapabilities(c.config).supportsQueryEditor),
[connections]
);
const addSqlLog = useStore(state => state.addSqlLog);
const currentConnectionIdRef = useRef(currentConnectionId);
const currentDbRef = useRef(currentDb);
@@ -64,6 +70,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
currentConnectionIdRef.current = currentConnectionId;
}, [currentConnectionId]);
useEffect(() => {
if (!queryCapableConnections.some(c => c.id === currentConnectionId)) {
const fallback = queryCapableConnections[0]?.id || '';
if (fallback && fallback !== currentConnectionId) {
setCurrentConnectionId(fallback);
setCurrentDb('');
}
}
}, [queryCapableConnections, currentConnectionId]);
useEffect(() => {
currentDbRef.current = currentDb;
}, [currentDb]);
@@ -977,6 +993,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
if (runSeqRef.current === runSeq) setLoading(false);
return;
}
const connCaps = getDataSourceCapabilities(conn.config);
if (!connCaps.supportsQueryEditor) {
message.error("当前数据源不支持 SQL 查询编辑器,请使用对应专用页面。");
if (runSeqRef.current === runSeq) setLoading(false);
return;
}
const config = {
...conn.config,
@@ -1000,8 +1022,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
const dbType = String((config as any).type || 'mysql');
const normalizedDbType = dbType.toLowerCase();
const forceReadOnlyResult = normalizedDbType === 'tdengine' || normalizedDbType === 'clickhouse';
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;
const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0;
const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0;
let anyTruncated = false;
@@ -1066,6 +1087,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: limited.applied ? applyAutoLimit(rawStatement, dbType, Math.max(1, Number(maxRows) || 1)).sql : rawStatement,
rows,
columns: cols,
tableName: simpleTableName,
@@ -1082,6 +1104,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: rawStatement,
rows: [row],
columns: ['affectedRows'],
pkColumns: [],
@@ -1223,7 +1246,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
setCurrentConnectionId(val);
setCurrentDb('');
}}
options={connections.map(c => ({ label: c.name, value: c.id }))}
options={queryCapableConnections.map(c => ({ label: c.name, value: c.id }))}
showSearch
/>
<Select
@@ -1333,6 +1356,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
columnNames={rs.columns}
loading={loading}
tableName={rs.tableName}
exportScope="queryResult"
resultSql={rs.exportSql || rs.sql}
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={rs.pkColumns}

View File

@@ -5,6 +5,7 @@ import { useStore } from '../store';
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
import Editor from '@monaco-editor/react';
import type { DataNode } from 'antd/es/tree';
import { normalizeOpacityForPlatform } from '../utils/appearance';
const { Search } = Input;
@@ -394,8 +395,21 @@ const buildRedisKeyTree = (
};
const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const { connections } = useStore();
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const opacity = normalizeOpacityForPlatform(appearance.opacity);
const connection = connections.find(c => c.id === connectionId);
const keyAccentColor = darkMode ? '#ffd666' : '#1677ff';
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
const valueToolbarBg = darkMode
? `rgba(38, 38, 38, ${opacity})`
: `rgba(245, 245, 245, ${opacity})`;
const valueToolbarBorder = darkMode
? `1px solid rgba(255, 255, 255, ${Math.max(0.12, Math.min(0.24, opacity * 0.22))})`
: `1px solid rgba(0, 0, 0, ${Math.max(0.08, Math.min(0.2, opacity * 0.12))})`;
const valueToolbarText = darkMode ? 'rgba(255, 255, 255, 0.78)' : '#666';
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
const [loading, setLoading] = useState(false);
@@ -805,7 +819,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
overflow: 'hidden',
}}
>
<KeyOutlined style={{ color: '#1677ff', flexShrink: 0 }} />
<KeyOutlined style={{ color: keyAccentColor, flexShrink: 0 }} />
<Tooltip title={rawKey}>
<span
style={{
@@ -901,13 +915,13 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
padding: '4px 8px',
background: '#f5f5f5',
borderBottom: '1px solid #d9d9d9',
background: valueToolbarBg,
borderBottom: valueToolbarBorder,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span style={{ fontSize: 12, color: '#666' }}>
<span style={{ fontSize: 12, color: valueToolbarText }}>
{encoding && `编码: ${encoding}`}
</span>
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
@@ -920,6 +934,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<Editor
height="calc(100% - 72px)"
language={isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={displayValue}
options={{
readOnly: true,
@@ -1069,7 +1084,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
@@ -1248,7 +1263,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
@@ -1403,7 +1418,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
@@ -1557,7 +1572,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
@@ -1771,7 +1786,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 720 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
@@ -1964,6 +1979,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<Editor
height="450px"
language={tryFormatJson(editValue).isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={editValue}
onChange={(value) => setEditValue(value || '')}
options={{
@@ -2028,6 +2044,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<Editor
height="450px"
language={jsonEditConfig?.isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
defaultValue={jsonEditConfig?.value || ''}
onChange={(value) => { jsonEditValueRef.current = value || ''; }}
onMount={(editor) => { jsonEditValueRef.current = jsonEditConfig?.value || ''; }}

View File

@@ -1,6 +1,11 @@
import React, { useMemo } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { Tabs, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import type { MenuProps, TabsProps } from 'antd';
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
import { useStore } from '../store';
import DataViewer from './DataViewer';
import QueryEditor from './QueryEditor';
@@ -29,9 +34,58 @@ const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined):
return `[${prefix}] ${tab.title}`;
};
type SortableTabLabelProps = {
displayTitle: string;
menuItems: MenuProps['items'];
};
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
displayTitle,
menuItems,
}) => {
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span
className="tab-dnd-label"
onContextMenu={(e) => e.preventDefault()}
title="拖拽调整标签顺序"
>
{displayTitle}
</span>
</Dropdown>
);
};
type DraggableTabNodeProps = {
node: React.ReactElement;
};
const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
const tabId = String(node.key || '').trim();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
const style: React.CSSProperties = {
...(node.props.style || {}),
transform: CSS.Transform.toString(transform),
transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)',
opacity: isDragging ? 0.88 : 1,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
zIndex: isDragging ? 2 : node.props.style?.zIndex,
};
return React.cloneElement(node, {
ref: setNodeRef,
style,
...attributes,
...listeners,
className: `${node.props.className || ''} tab-dnd-node${isDragging ? ' is-dragging' : ''}`,
});
};
const TabManager: React.FC = () => {
const tabs = useStore(state => state.tabs);
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const closeTab = useStore(state => state.closeTab);
@@ -39,6 +93,15 @@ const TabManager: React.FC = () => {
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
const closeTabsToRight = useStore(state => state.closeTabsToRight);
const closeAllTabs = useStore(state => state.closeAllTabs);
const moveTab = useStore(state => state.moveTab);
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const suppressClickUntilRef = useRef<number>(0);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const onChange = (newActiveKey: string) => {
setActiveTab(newActiveKey);
@@ -50,11 +113,43 @@ const TabManager: React.FC = () => {
}
};
const handleDragStart = (event: DragStartEvent) => {
const sourceId = String(event.active.id || '').trim();
setDraggingTabId(sourceId || null);
};
const handleDragEnd = (event: DragEndEvent) => {
const sourceId = String(event.active.id || '').trim();
const targetId = String(event.over?.id || '').trim();
setDraggingTabId(null);
if (!sourceId || !targetId || sourceId === targetId) {
return;
}
suppressClickUntilRef.current = Date.now() + 120;
moveTab(sourceId, targetId);
};
const handleDragCancel = () => {
setDraggingTabId(null);
};
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
<DefaultTabBar {...tabBarProps}>
{(node) => <DraggableTabNode key={node.key} node={node} />}
</DefaultTabBar>
);
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
const keepMountedWhenInactive = tab.type === 'query' || tab.type === 'redis-command';
const shouldRenderContent = activeTabId === tab.id || keepMountedWhenInactive;
let content;
if (tab.type === 'query') {
if (!shouldRenderContent) {
content = null;
} else if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
} else if (tab.type === 'table') {
content = <DataViewer tab={tab} />;
@@ -100,14 +195,15 @@ const TabManager: React.FC = () => {
return {
label: (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span onContextMenu={(e) => e.preventDefault()}>{displayTitle}</span>
</Dropdown>
<SortableTabLabel
displayTitle={displayTitle}
menuItems={menuItems}
/>
),
key: tab.id,
children: content,
};
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>
@@ -156,18 +252,63 @@ const TabManager: React.FC = () => {
display: none !important;
}
.main-tabs .ant-tabs-nav::before {
border-bottom: none !important;
border-bottom: 1px solid ${tabsNavBorderColor} !important;
}
.main-tabs .ant-tabs-tab {
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms ease;
}
.main-tabs .tab-dnd-label {
user-select: none;
-webkit-user-select: none;
display: inline-flex;
align-items: center;
max-width: 100%;
}
.main-tabs .tab-dnd-node.is-dragging,
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
cursor: grabbing !important;
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab-btn:focus-visible {
outline: none !important;
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(255, 214, 102, 0.72);
background: rgba(255, 214, 102, 0.16);
}
body[data-theme='light'] .main-tabs .ant-tabs-tab-btn:focus-visible {
outline: none !important;
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
background: rgba(9, 109, 217, 0.08);
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(255, 214, 102, 0.12) !important;
border-color: rgba(255, 214, 102, 0.4) !important;
}
`}</style>
<Tabs
className="main-tabs"
type="editable-card"
onChange={onChange}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
hideAdd
/>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
<Tabs
className="main-tabs"
type="editable-card"
onChange={(newActiveKey) => {
if (Date.now() < suppressClickUntilRef.current) return;
onChange(newActiveKey);
}}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
hideAdd
renderTabBar={renderTabBar}
/>
</SortableContext>
</DndContext>
</>
);
};

View File

@@ -259,6 +259,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
const resizeGuideColor = darkMode ? '#f6c453' : '#1890ff';
const readOnly = !!tab.readOnly;
const [tableHeight, setTableHeight] = useState(500);
@@ -1973,7 +1974,7 @@ END;`;
bottom: 0,
left: 0,
width: '2px',
background: '#1890ff',
background: resizeGuideColor,
zIndex: 9999,
display: 'none',
pointerEvents: 'none',

View File

@@ -3,6 +3,12 @@ import { persist } from 'zustand/middleware';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery } from './types';
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
const DEFAULT_UI_SCALE = 1.0;
const MIN_UI_SCALE = 0.8;
const MAX_UI_SCALE = 1.25;
const DEFAULT_FONT_SIZE = 14;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 20;
const DEFAULT_STARTUP_FULLSCREEN = false;
const LEGACY_DEFAULT_OPACITY = 0.95;
const OPACITY_EPSILON = 1e-6;
@@ -107,6 +113,13 @@ const normalizeIntegerInRange = (value: unknown, fallbackValue: number, min: num
return normalized;
};
const normalizeFloatInRange = (value: unknown, fallbackValue: number, min: number, max: number): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallbackValue;
if (parsed < min || parsed > max) return fallbackValue;
return parsed;
};
const isValidHostEntry = (entry: string): boolean => {
if (!entry) return false;
if (entry.length > MAX_HOST_ENTRY_LENGTH) return false;
@@ -199,7 +212,7 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
proxy,
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
hosts: sanitizeAddressList(raw.hosts),
topology: raw.topology === 'replica' ? 'replica' : 'single',
topology: raw.topology === 'replica' ? 'replica' : (raw.topology === 'cluster' ? 'cluster' : 'single'),
mysqlReplicaUser: toTrimmedString(raw.mysqlReplicaUser),
mysqlReplicaPassword: savePassword ? toTrimmedString(raw.mysqlReplicaPassword) : '',
replicaSet: toTrimmedString(raw.replicaSet),
@@ -318,6 +331,8 @@ interface AppState {
savedQueries: SavedQuery[];
theme: 'light' | 'dark';
appearance: { opacity: number; blur: number };
uiScale: number;
fontSize: number;
startupFullscreen: boolean;
globalProxy: GlobalProxyConfig;
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
@@ -337,6 +352,7 @@ interface AppState {
closeTabsToRight: (id: string) => void;
closeTabsByConnection: (connectionId: string) => void;
closeTabsByDatabase: (connectionId: string, dbName: string) => void;
moveTab: (sourceId: string, targetId: string) => void;
closeAllTabs: () => void;
setActiveTab: (id: string) => void;
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
@@ -346,6 +362,8 @@ interface AppState {
setTheme: (theme: 'light' | 'dark') => void;
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
setUiScale: (scale: number) => void;
setFontSize: (size: number) => void;
setStartupFullscreen: (enabled: boolean) => void;
setGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
@@ -440,6 +458,14 @@ const sanitizeStartupFullscreen = (value: unknown): boolean => {
return value === true;
};
const sanitizeUiScale = (value: unknown): number => {
return normalizeFloatInRange(value, DEFAULT_UI_SCALE, MIN_UI_SCALE, MAX_UI_SCALE);
};
const sanitizeFontSize = (value: unknown): number => {
return normalizeIntegerInRange(value, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE);
};
const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase();
@@ -476,6 +502,8 @@ export const useStore = create<AppState>()(
savedQueries: [],
theme: 'light',
appearance: { ...DEFAULT_APPEARANCE },
uiScale: DEFAULT_UI_SCALE,
fontSize: DEFAULT_FONT_SIZE,
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
globalProxy: { ...DEFAULT_GLOBAL_PROXY },
sqlFormatOptions: { keywordCase: 'upper' },
@@ -571,6 +599,23 @@ export const useStore = create<AppState>()(
};
}),
moveTab: (sourceId, targetId) => set((state) => {
const fromId = String(sourceId || '').trim();
const toId = String(targetId || '').trim();
if (!fromId || !toId || fromId === toId) {
return state;
}
const fromIndex = state.tabs.findIndex((tab) => tab.id === fromId);
const toIndex = state.tabs.findIndex((tab) => tab.id === toId);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
return state;
}
const nextTabs = [...state.tabs];
const [movingTab] = nextTabs.splice(fromIndex, 1);
nextTabs.splice(toIndex, 0, movingTab);
return { tabs: nextTabs };
}),
closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })),
setActiveTab: (id) => set({ activeTabId: id }),
@@ -589,6 +634,8 @@ export const useStore = create<AppState>()(
setTheme: (theme) => set({ theme }),
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
setUiScale: (scale) => set({ uiScale: sanitizeUiScale(scale) }),
setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }),
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
@@ -628,6 +675,8 @@ export const useStore = create<AppState>()(
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
nextState.theme = sanitizeTheme(state.theme);
nextState.appearance = sanitizeAppearance(state.appearance, version);
nextState.uiScale = sanitizeUiScale(state.uiScale);
nextState.fontSize = sanitizeFontSize(state.fontSize);
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
@@ -645,6 +694,8 @@ export const useStore = create<AppState>()(
savedQueries: sanitizeSavedQueries(state.savedQueries),
theme: sanitizeTheme(state.theme),
appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION),
uiScale: sanitizeUiScale(state.uiScale),
fontSize: sanitizeFontSize(state.fontSize),
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
globalProxy: sanitizeGlobalProxy(state.globalProxy),
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
@@ -658,6 +709,8 @@ export const useStore = create<AppState>()(
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,
uiScale: state.uiScale,
fontSize: state.fontSize,
startupFullscreen: state.startupFullscreen,
globalProxy: state.globalProxy,
sqlFormatOptions: state.sqlFormatOptions,

View File

@@ -32,7 +32,7 @@ export interface ConnectionConfig {
redisDB?: number; // Redis database index (0-15)
uri?: string; // Connection URI for copy/paste
hosts?: string[]; // Multi-host addresses: host:port
topology?: 'single' | 'replica';
topology?: 'single' | 'replica' | 'cluster';
mysqlReplicaUser?: string;
mysqlReplicaPassword?: string;
replicaSet?: string;

View File

@@ -0,0 +1,86 @@
import type { ConnectionConfig } from '../types';
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver'> | null | undefined;
const normalizeDataSourceToken = (raw: string): string => {
const normalized = String(raw || '').trim().toLowerCase();
switch (normalized) {
case 'doris':
return 'diros';
case 'postgresql':
return 'postgres';
case 'dm':
return 'dameng';
default:
return normalized;
}
};
export const resolveDataSourceType = (config: ConnectionLike): string => {
if (!config) return '';
const type = normalizeDataSourceToken(String(config.type || ''));
if (type === 'custom') {
const driver = normalizeDataSourceToken(String(config.driver || ''));
return driver || 'custom';
}
return type;
};
const SQL_QUERY_EXPORT_TYPES = new Set([
'mysql',
'mariadb',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'sqlserver',
'sqlite',
'duckdb',
'oracle',
'dameng',
'tdengine',
'clickhouse',
]);
const COPY_INSERT_TYPES = new Set([
'mysql',
'mariadb',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'sqlserver',
'sqlite',
'duckdb',
'oracle',
'dameng',
'tdengine',
'clickhouse',
]);
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
export type DataSourceCapabilities = {
type: string;
supportsQueryEditor: boolean;
supportsSqlQueryExport: boolean;
supportsCopyInsert: boolean;
forceReadOnlyQueryResult: boolean;
};
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
const type = resolveDataSourceType(config);
return {
type,
supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type),
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
};
};