Files
MyGoNavi/frontend/src/App.tsx
Syngnat 89c81823bc Release/0.5.4 (#199)
* 🐛 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 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
2026-03-07 17:15:30 +08:00

2073 lines
97 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo } 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, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons';
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetSize, WindowToggleMaximise } from '../wailsjs/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
import DataSyncModal from './components/DataSyncModal';
import DriverManagerModal from './components/DriverManagerModal';
import LogPanel from './components/LogPanel';
import { useStore } from './store';
import { SavedConnection } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import {
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
ShortcutAction,
eventToShortcut,
getShortcutDisplay,
hasModifierKey,
isEditableElement,
isShortcutMatch,
normalizeShortcutCombo,
} from './utils/shortcuts';
import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/App';
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);
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
const [isDriverModalOpen, setIsDriverModalOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
const themeMode = useStore(state => state.theme);
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 shortcutOptions = useStore(state => state.shortcutOptions);
const updateShortcut = useStore(state => state.updateShortcut);
const resetShortcutOptions = useStore(state => state.resetShortcutOptions);
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 titleBarButtonWidth = Math.max(40, Math.round(46 * effectiveUiScale));
const floatingLogButtonHeight = Math.max(30, Math.round(34 * effectiveUiScale));
const resolvedAppearance = resolveAppearanceValues(appearance);
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const effectiveBlur = normalizeBlurForPlatform(resolvedAppearance.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);
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
// 避免 GPU 持续计算窗口背后的模糊合成
useEffect(() => {
void SetWindowTranslucency(resolvedAppearance.opacity, resolvedAppearance.blur).catch(() => undefined);
}, [resolvedAppearance.blur, resolvedAppearance.opacity]);
useEffect(() => {
let cancelled = false;
Environment()
.then((env) => {
if (cancelled) return;
const platform = String(env?.platform || '').toLowerCase();
setRuntimePlatform(platform);
setIsLinuxRuntime(platform === 'linux');
})
.catch(() => {
if (cancelled) return;
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;
};
}, []);
useEffect(() => {
if (isStoreHydrated) {
return;
}
const unsubscribe = useStore.persist.onFinishHydration(() => {
setIsStoreHydrated(true);
});
return () => {
unsubscribe();
};
}, [isStoreHydrated]);
useEffect(() => {
if (!isStoreHydrated) {
return;
}
const host = String(globalProxy.host || '').trim();
const port = Number(globalProxy.port);
const portValid = Number.isFinite(port) && port > 0 && port <= 65535;
const invalidWhenEnabled = globalProxy.enabled && (!host || !portValid);
if (invalidWhenEnabled) {
if (!globalProxyInvalidHintShownRef.current) {
void message.warning({
content: '全局代理已开启,但地址或端口无效,当前按未启用处理',
key: 'global-proxy-invalid',
});
globalProxyInvalidHintShownRef.current = true;
}
} else {
globalProxyInvalidHintShownRef.current = false;
void message.destroy('global-proxy-invalid');
}
const enabledForBackend = globalProxy.enabled && !invalidWhenEnabled;
let cancelled = false;
ConfigureGlobalProxy(enabledForBackend, {
type: globalProxy.type,
host,
port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080),
user: String(globalProxy.user || '').trim(),
password: globalProxy.password || '',
})
.then((res) => {
if (cancelled || res?.success) {
return;
}
void message.error({
content: '全局代理配置失败: ' + (res?.message || '未知错误'),
key: 'global-proxy-sync-error',
});
})
.catch((err) => {
if (cancelled) {
return;
}
const errMsg = err instanceof Error ? err.message : String(err || '未知错误');
void message.error({
content: '全局代理配置失败: ' + errMsg,
key: 'global-proxy-sync-error',
});
});
return () => {
cancelled = true;
};
}, [
isStoreHydrated,
globalProxy.enabled,
globalProxy.type,
globalProxy.host,
globalProxy.port,
globalProxy.user,
globalProxy.password,
]);
useEffect(() => {
let cancelled = false;
let startupWindowTimer: number | null = null;
const maxApplyAttempts = 6;
const applyRetryDelayMs = 400;
const settleDelayMs = 160;
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
try {
if (await WindowIsFullscreen()) {
return true;
}
} catch (_) {
// ignore
}
try {
if (await WindowIsMaximised()) {
return true;
}
} catch (_) {
// ignore
}
return false;
};
const applyStartupWindowPreference = (attempt: number) => {
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
startupWindowTimer = window.setTimeout(() => {
if (cancelled) {
return;
}
if (!useStore.getState().startupFullscreen) {
return;
}
void Promise.resolve()
.then(async () => {
if (await checkStartupPreferenceApplied()) {
return;
}
// 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。
await WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
if (attempt < maxApplyAttempts) {
applyStartupWindowPreference(attempt + 1);
}
});
}, applyRetryDelayMs);
};
if (useStore.persist.hasHydrated()) {
applyStartupWindowPreference(1);
}
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
if (cancelled) {
return;
}
applyStartupWindowPreference(1);
});
return () => {
cancelled = true;
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
unsubscribeHydration();
};
}, []);
useEffect(() => {
if (!isWindowsPlatform()) {
return;
}
let cancelled = false;
let inFlight = false;
let lastRatio = Number(window.devicePixelRatio) || 1;
let lastFixAt = 0;
let activationTimer: number | null = null;
const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
const fixWindowScaleIfNeeded = async () => {
if (cancelled || inFlight) return;
const now = Date.now();
if (now - lastFixAt < 700) return;
inFlight = true;
try {
const [isFullscreen, isMaximised] = await Promise.all([
WindowIsFullscreen().catch(() => false),
WindowIsMaximised().catch(() => false),
]);
// 避免在全屏/最大化状态下强制改尺寸;这两种状态通常能自行保持 DPI 同步。
if (isFullscreen || isMaximised) {
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
return;
}
const size = await WindowGetSize().catch(() => null);
const width = Math.trunc(Number(size?.w || 0));
const height = Math.trunc(Number(size?.h || 0));
if (width <= 0 || height <= 0) {
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
return;
}
const nudgedWidth = width > 480 ? width - 1 : width + 1;
WindowSetSize(nudgedWidth, height);
await wait(28);
WindowSetSize(width, height);
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
} finally {
inFlight = false;
}
};
const checkDevicePixelRatio = () => {
if (cancelled) return;
const currentRatio = Number(window.devicePixelRatio) || 1;
if (Math.abs(currentRatio - lastRatio) < 0.02) {
return;
}
lastRatio = currentRatio;
void fixWindowScaleIfNeeded();
};
const scheduleActivationFix = () => {
if (cancelled) return;
if (activationTimer !== null) {
window.clearTimeout(activationTimer);
}
activationTimer = window.setTimeout(() => {
activationTimer = null;
if (cancelled) return;
void fixWindowScaleIfNeeded();
}, 80);
};
const handleWindowFocus = () => {
if (cancelled) return;
checkDevicePixelRatio();
scheduleActivationFix();
};
const handleVisibilityChange = () => {
if (cancelled) return;
if (document.visibilityState !== 'visible') {
return;
}
checkDevicePixelRatio();
scheduleActivationFix();
};
const handlePageShow = () => {
if (cancelled) return;
checkDevicePixelRatio();
scheduleActivationFix();
};
const pollTimer = window.setInterval(checkDevicePixelRatio, 900);
window.addEventListener('resize', checkDevicePixelRatio);
window.addEventListener('focus', handleWindowFocus);
window.addEventListener('pageshow', handlePageShow);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
cancelled = true;
if (activationTimer !== null) {
window.clearTimeout(activationTimer);
}
window.clearInterval(pollTimer);
window.removeEventListener('resize', checkDevicePixelRatio);
window.removeEventListener('focus', handleWindowFocus);
window.removeEventListener('pageshow', handlePageShow);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
// Background Helper
const getBg = (darkHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
// Parse hex to rgb
const hex = darkHex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${effectiveOpacity})`;
};
// Specific colors
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 isOpaqueUtilityMode = resolvedAppearance.opacity >= 0.999 && resolvedAppearance.blur <= 0;
const utilityButtonBgAlpha = darkMode
? Math.max(0.28, Math.min(0.76, effectiveOpacity * 0.72))
: Math.max(0.52, Math.min(0.92, effectiveOpacity * 0.9));
const utilityButtonBgColor = isOpaqueUtilityMode
? 'transparent'
: (darkMode
? `rgba(20, 26, 38, ${utilityButtonBgAlpha})`
: `rgba(255, 255, 255, ${utilityButtonBgAlpha})`);
const utilityButtonBorderColor = isOpaqueUtilityMode
? (darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.10)')
: (darkMode
? `rgba(255,255,255,${Math.max(0.08, Math.min(0.18, effectiveOpacity * 0.16))})`
: `rgba(16,24,40,${Math.max(0.06, Math.min(0.14, effectiveOpacity * 0.12))})`);
const utilityButtonShadow = isOpaqueUtilityMode
? 'none'
: (darkMode
? `0 8px 18px rgba(0,0,0,${Math.max(0.10, Math.min(0.22, effectiveOpacity * 0.24))})`
: `0 8px 18px rgba(15,23,42,${Math.max(0.04, Math.min(0.12, effectiveOpacity * 0.12))})`);
const utilityButtonStyle = useMemo(() => ({
height: Math.max(30, Math.round(32 * effectiveUiScale)),
width: '100%',
paddingInline: Math.max(10, Math.round(12 * effectiveUiScale)),
borderRadius: 10,
border: `1px solid ${utilityButtonBorderColor}`,
background: utilityButtonBgColor,
color: darkMode ? 'rgba(255,255,255,0.94)' : '#162033',
boxShadow: utilityButtonShadow,
backdropFilter: isOpaqueUtilityMode ? 'none' : blurFilter,
WebkitBackdropFilter: isOpaqueUtilityMode ? 'none' : blurFilter,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
}), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]);
const utilityDropdownShellStyle = useMemo(() => ({
borderRadius: 14,
padding: 6,
background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.32)' : '0 16px 36px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(16px)' : 'none',
overflow: 'hidden',
}), [darkMode]);
const sidebarQuickActionBaseStyle = useMemo(() => ({
height: Math.max(34, Math.round(36 * effectiveUiScale)),
borderRadius: 12,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingInline: Math.max(12, Math.round(14 * effectiveUiScale)),
fontWeight: 700,
boxShadow: darkMode ? '0 8px 18px rgba(0,0,0,0.16)' : '0 8px 16px rgba(15,23,42,0.08)',
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
minWidth: 0,
whiteSpace: 'nowrap',
}), [blurFilter, darkMode, effectiveUiScale]);
const sidebarQueryActionStyle = useMemo(() => ({
...sidebarQuickActionBaseStyle,
flex: '1 1 0',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.10)'}`,
background: darkMode ? `rgba(255,255,255,0.05)` : 'rgba(255,255,255,0.88)',
color: darkMode ? 'rgba(255,255,255,0.92)' : '#162033',
}), [darkMode, sidebarQuickActionBaseStyle]);
const sidebarCreateConnectionActionStyle = useMemo(() => ({
...sidebarQuickActionBaseStyle,
flex: '1 1 0',
border: 'none',
background: 'linear-gradient(135deg, rgba(255,214,102,0.96) 0%, rgba(240,183,39,0.92) 100%)',
color: '#2a1f00',
}), [sidebarQuickActionBaseStyle]);
const utilityMenuTheme = useMemo(() => ({
components: {
Menu: {
popupBg: 'transparent',
darkPopupBg: 'transparent',
itemBg: 'transparent',
darkItemBg: 'transparent',
subMenuItemBg: 'transparent',
itemColor: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
itemHoverColor: darkMode ? '#fff7d6' : '#0f172a',
itemHoverBg: darkMode ? 'rgba(255,214,102,0.10)' : 'rgba(24,144,255,0.08)',
itemSelectedColor: darkMode ? '#ffd666' : '#1677ff',
itemSelectedBg: darkMode ? 'rgba(255,214,102,0.14)' : 'rgba(24,144,255,0.12)',
itemBorderRadius: 10,
itemMarginBlock: 4,
itemMarginInline: 0,
itemPaddingInline: 12,
itemHeight: 40,
groupTitleColor: darkMode ? 'rgba(255,255,255,0.48)' : 'rgba(16,24,40,0.48)',
},
},
}), [darkMode]);
const renderUtilityDropdown = (menu: React.ReactNode) => (
<ConfigProvider theme={utilityMenuTheme}>
<div style={{ ...utilityDropdownShellStyle, minWidth: 220 }}>
{menu}
</div>
</ConfigProvider>
);
const utilityModalShellStyle = useMemo(() => ({
background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.32)' : '0 18px 42px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
const utilityPanelStyle = useMemo(() => ({
padding: 16,
borderRadius: 14,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
}), [darkMode]);
const utilityMutedTextStyle = useMemo(() => ({
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
fontSize: 12,
lineHeight: 1.6,
}), [darkMode]);
const renderUtilityModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
</div>
</div>
);
const sidebarHorizontalPadding = 10;
const addTab = useStore(state => state.addTab);
const activeContext = useStore(state => state.activeContext);
const connections = useStore(state => state.connections);
const addConnection = useStore(state => state.addConnection);
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
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 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; communityUrl?: string } | null>(null);
const [aboutUpdateStatus, setAboutUpdateStatus] = useState<string>('');
const [lastUpdateInfo, setLastUpdateInfo] = useState<UpdateInfo | null>(null);
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<{
open: boolean;
version: string;
status: 'idle' | 'start' | 'downloading' | 'done' | 'error';
percent: number;
downloaded: number;
total: number;
message: string;
}>({
open: false,
version: '',
status: 'idle',
percent: 0,
downloaded: 0,
total: 0,
message: ''
});
type UpdateInfo = {
hasUpdate: boolean;
currentVersion: string;
latestVersion: string;
releaseName?: string;
releaseNotesUrl?: string;
assetName?: string;
assetUrl?: string;
assetSize?: number;
sha256?: string;
downloaded?: boolean;
downloadPath?: string;
};
type UpdateDownloadProgressEvent = {
status?: 'start' | 'downloading' | 'done' | 'error';
percent?: number;
downloaded?: number;
total?: number;
message?: string;
};
type UpdateDownloadResultData = {
info?: UpdateInfo;
downloadPath?: string;
installLogPath?: string;
installTarget?: string;
platform?: string;
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'];
let value = bytes;
let idx = 0;
while (value >= 1024 && idx < units.length - 1) {
value /= 1024;
idx++;
}
return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
};
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;
void message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion}`);
showUpdateDownloadProgress();
}
return;
}
updateDownloadInFlightRef.current = true;
updateDownloadMetaRef.current = null;
setUpdateDownloadProgress({
open: true,
version: info.latestVersion,
status: 'start',
percent: 0,
downloaded: 0,
total: info.assetSize || 0,
message: ''
});
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 => {
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) {
void message.success({ content: `更新下载完成,更新包路径:${resultData.downloadPath}`, duration: 5 });
} else {
void message.success({ content: '更新下载完成', duration: 2 });
}
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,请点击“下载进度”后安装)`);
} else {
setUpdateDownloadProgress(prev => ({
...prev,
status: 'error',
message: res?.message || '未知错误'
}));
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;
if (!silent) {
setAboutUpdateStatus('正在检查更新...');
}
const res = await (window as any).go.app.App.CheckForUpdates();
updateCheckInFlightRef.current = false;
if (!res?.success) {
if (!silent) {
void message.error('检查更新失败: ' + (res?.message || '未知错误'));
setAboutUpdateStatus('检查更新失败: ' + (res?.message || '未知错误'));
}
return;
}
const info: UpdateInfo = res.data;
if (!info) return;
const aboutOpen = isAboutOpenRef.current;
if (info.hasUpdate) {
const localDownloaded = updateDownloadedVersionRef.current === info.latestVersion;
const hasDownloaded = Boolean(info.downloaded) || localDownloaded;
if (hasDownloaded) {
const downloadPath = info.downloadPath || updateDownloadMetaRef.current?.downloadPath || '';
updateDownloadedVersionRef.current = info.latestVersion;
updateDownloadMetaRef.current = {
...(updateDownloadMetaRef.current || {}),
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}(未下载)`;
if (!silent) {
void message.info(`发现新版本 ${info.latestVersion}`);
setAboutUpdateStatus(statusText);
}
if (silent && aboutOpen) {
setAboutUpdateStatus(statusText);
}
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 || '未知'}`;
void message.success(text);
setAboutUpdateStatus(text);
} 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);
}
}, []);
const loadAboutInfo = React.useCallback(async () => {
setAboutLoading(true);
const res = await (window as any).go.app.App.GetAppInfo();
if (res?.success) {
setAboutInfo(res.data);
} else {
void message.error('获取应用信息失败: ' + (res?.message || '未知错误'));
}
setAboutLoading(false);
}, []);
const handleNewQuery = () => {
let connId = activeContext?.connectionId || '';
let db = activeContext?.dbName || '';
// Priority: Active Tab Context > Sidebar Selection
if (activeTabId) {
const currentTab = tabs.find(t => t.id === activeTabId);
if (currentTab && currentTab.connectionId) {
connId = currentTab.connectionId;
db = currentTab.dbName || '';
}
}
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: connId,
dbName: db,
query: ''
});
};
const handleImportConnections = async () => {
const res = await (window as any).go.app.App.ImportConfigFile();
if (res.success) {
try {
const imported = JSON.parse(res.data);
if (Array.isArray(imported)) {
let count = 0;
imported.forEach((conn: any) => {
if (!connections.some(c => c.id === conn.id)) {
addConnection(conn);
count++;
}
});
void message.success(`成功导入 ${count} 个连接`);
} else {
void message.error("文件格式错误:需要 JSON 数组");
}
} catch (e) {
void message.error("解析 JSON 失败");
}
} else if (res.message !== "Cancelled") {
void message.error("导入失败: " + res.message);
}
};
const handleExportConnections = async () => {
if (connections.length === 0) {
void message.warning("没有连接可导出");
return;
}
const res = await (window as any).go.app.App.ExportData(connections, ['id','name','config','includeDatabases','includeRedisDatabases'], "connections", "json");
if (res.success) {
void message.success("导出成功");
} else if (res.message !== "Cancelled") {
void message.error("导出失败: " + res.message);
}
};
const toolsMenu: MenuProps['items'] = [
{
key: 'import',
label: '导入连接配置',
icon: <UploadOutlined />,
onClick: handleImportConnections
},
{
key: 'export',
label: '导出连接配置',
icon: <DownloadOutlined />,
onClick: handleExportConnections
},
{
key: 'sync',
label: '数据同步',
icon: <UploadOutlined rotate={90} />,
onClick: () => setIsSyncModalOpen(true)
},
{
key: 'drivers',
label: '驱动管理',
icon: <SettingOutlined />,
onClick: () => setIsDriverModalOpen(true)
},
{ type: 'divider' },
{
key: 'shortcut-settings',
label: '快捷键管理',
icon: <LinkOutlined />,
onClick: () => setIsShortcutModalOpen(true)
}
];
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme');
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
const LOG_PANEL_TOOLBAR_HEIGHT = 32;
const LOG_PANEL_SINGLE_ROW_HEIGHT = 39;
const LOG_PANEL_MIN_VISIBLE_ROWS = 1;
const LOG_PANEL_MIN_HEIGHT = LOG_PANEL_TOOLBAR_HEIGHT + (LOG_PANEL_SINGLE_ROW_HEIGHT * LOG_PANEL_MIN_VISIBLE_ROWS);
const LOG_PANEL_MAX_HEIGHT = 800;
const [logPanelHeight, setLogPanelHeight] = useState(Math.max(200, LOG_PANEL_MIN_HEIGHT));
const [isLogPanelOpen, setIsLogPanelOpen] = useState(false);
const logResizeRef = React.useRef<{ startY: number, startHeight: number } | null>(null);
const logGhostRef = React.useRef<HTMLDivElement>(null);
const handleLogResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
logResizeRef.current = { startY: e.clientY, startHeight: logPanelHeight };
if (logGhostRef.current) {
logGhostRef.current.style.top = `${e.clientY}px`;
logGhostRef.current.style.display = 'block';
}
document.addEventListener('mousemove', handleLogResizeMove);
document.addEventListener('mouseup', handleLogResizeUp);
};
const handleLogResizeMove = (e: MouseEvent) => {
if (!logResizeRef.current) return;
// Just update ghost line, no state update
if (logGhostRef.current) {
logGhostRef.current.style.top = `${e.clientY}px`;
}
};
const handleLogResizeUp = (e: MouseEvent) => {
if (logResizeRef.current) {
const delta = logResizeRef.current.startY - e.clientY;
const newHeight = Math.max(
LOG_PANEL_MIN_HEIGHT,
Math.min(LOG_PANEL_MAX_HEIGHT, logResizeRef.current.startHeight + delta)
);
setLogPanelHeight(newHeight);
}
if (logGhostRef.current) {
logGhostRef.current.style.display = 'none';
}
logResizeRef.current = null;
document.removeEventListener('mousemove', handleLogResizeMove);
document.removeEventListener('mouseup', handleLogResizeUp);
};
const handleEditConnection = (conn: SavedConnection) => {
setEditingConnection(conn);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingConnection(null);
};
const handleOpenDriverManagerFromConnection = () => {
setIsModalOpen(false);
setEditingConnection(null);
setIsDriverModalOpen(true);
};
const handleTitleBarDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-no-titlebar-toggle="true"]')) {
return;
}
WindowToggleMaximise();
};
// Sidebar Resizing
const [sidebarWidth, setSidebarWidth] = useState(330);
const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null);
const rafRef = React.useRef<number | null>(null);
const ghostRef = React.useRef<HTMLDivElement>(null);
const latestMouseX = React.useRef<number>(0); // Store latest mouse position
const handleSidebarMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
if (ghostRef.current) {
ghostRef.current.style.left = `${sidebarWidth}px`;
ghostRef.current.style.display = 'block';
}
sidebarDragRef.current = { startX: e.clientX, startWidth: sidebarWidth };
latestMouseX.current = e.clientX; // Init
document.addEventListener('mousemove', handleSidebarMouseMove);
document.addEventListener('mouseup', handleSidebarMouseUp);
};
const handleSidebarMouseMove = (e: MouseEvent) => {
if (!sidebarDragRef.current) return;
latestMouseX.current = e.clientX; // Always update latest pos
if (rafRef.current) return; // Schedule once per frame
rafRef.current = requestAnimationFrame(() => {
if (!sidebarDragRef.current || !ghostRef.current) return;
// Use latestMouseX.current instead of stale closure 'e.clientX'
const delta = latestMouseX.current - sidebarDragRef.current.startX;
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
ghostRef.current.style.left = `${newWidth}px`;
rafRef.current = null;
});
};
const handleSidebarMouseUp = (e: MouseEvent) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (sidebarDragRef.current) {
// Use latest position for final commit too
const delta = e.clientX - sidebarDragRef.current.startX;
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
setSidebarWidth(newWidth);
}
if (ghostRef.current) {
ghostRef.current.style.display = 'none';
}
sidebarDragRef.current = null;
document.removeEventListener('mousemove', handleSidebarMouseMove);
document.removeEventListener('mouseup', handleSidebarMouseUp);
};
useEffect(() => {
document.body.style.backgroundColor = 'transparent';
document.body.style.color = darkMode ? '#ffffff' : '#000000';
document.body.setAttribute('data-theme', darkMode ? 'dark' : 'light');
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) {
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('未检查');
}
void loadAboutInfo();
}
}, [isAboutOpen, lastUpdateInfo, loadAboutInfo]);
useEffect(() => {
const startupTimer = window.setTimeout(() => {
void checkForUpdates(true);
}, 2000);
const interval = window.setInterval(() => {
void checkForUpdates(true);
}, 30 * 60 * 1000);
return () => {
window.clearTimeout(startupTimer);
window.clearInterval(interval);
};
}, [checkForUpdates]);
useEffect(() => {
const offDownloadProgress = EventsOn('update:download-progress', (event: UpdateDownloadProgressEvent) => {
if (!event) return;
const status = event.status || 'downloading';
const nextStatus: 'idle' | 'start' | 'downloading' | 'done' | 'error' =
status === 'start' || status === 'downloading' || status === 'done' || status === 'error'
? status
: 'downloading';
const downloaded = typeof event.downloaded === 'number' ? event.downloaded : 0;
const total = typeof event.total === 'number' ? event.total : 0;
const percentRaw = typeof event.percent === 'number'
? event.percent
: (total > 0 ? (downloaded / total) * 100 : 0);
const percent = Math.max(0, Math.min(100, percentRaw));
setUpdateDownloadProgress(prev => ({
open: prev.open,
version: prev.version,
status: nextStatus,
percent,
downloaded,
total,
message: String(event.message || '')
}));
});
return () => {
offDownloadProgress();
};
}, []);
useEffect(() => {
const handleOpenShortcutSettingsEvent = () => {
setIsShortcutModalOpen(true);
};
window.addEventListener('gonavi:open-shortcut-settings', handleOpenShortcutSettingsEvent as EventListener);
return () => {
window.removeEventListener('gonavi:open-shortcut-settings', handleOpenShortcutSettingsEvent as EventListener);
};
}, []);
useEffect(() => {
const handleGlobalShortcut = (event: KeyboardEvent) => {
const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => {
const binding = shortcutOptions[action];
if (!binding?.enabled) {
return false;
}
if (isEditableElement(event.target) && !SHORTCUT_ACTION_META[action].allowInEditable) {
return false;
}
return isShortcutMatch(event, binding.combo);
});
if (!matchedAction) {
return;
}
event.preventDefault();
event.stopPropagation();
switch (matchedAction) {
case 'runQuery':
window.dispatchEvent(new CustomEvent('gonavi:run-active-query'));
break;
case 'focusSidebarSearch':
window.dispatchEvent(new CustomEvent('gonavi:focus-sidebar-search'));
break;
case 'newQueryTab':
handleNewQuery();
break;
case 'toggleLogPanel':
setIsLogPanelOpen((prev) => !prev);
break;
case 'toggleTheme':
setTheme(themeMode === 'dark' ? 'light' : 'dark');
break;
case 'openShortcutManager':
setIsShortcutModalOpen(true);
break;
}
};
window.addEventListener('keydown', handleGlobalShortcut);
return () => {
window.removeEventListener('keydown', handleGlobalShortcut);
};
}, [handleNewQuery, shortcutOptions, themeMode, setTheme]);
useEffect(() => {
if (!capturingShortcutAction) {
return;
}
const handleShortcutCapture = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.key === 'Escape') {
setCapturingShortcutAction(null);
return;
}
const combo = eventToShortcut(event);
if (!combo) {
return;
}
if (!hasModifierKey(combo)) {
void message.warning('快捷键至少包含 Ctrl / Alt / Shift / Meta 之一');
return;
}
const normalizedCombo = normalizeShortcutCombo(combo);
const conflictAction = SHORTCUT_ACTION_ORDER.find((action) => {
if (action === capturingShortcutAction) {
return false;
}
const binding = shortcutOptions[action];
if (!binding?.enabled) {
return false;
}
return normalizeShortcutCombo(binding.combo) === normalizedCombo;
});
if (conflictAction) {
void message.warning(`与「${SHORTCUT_ACTION_META[conflictAction].label}」冲突,请换一个快捷键`);
return;
}
updateShortcut(capturingShortcutAction, { combo: normalizedCombo, enabled: true });
setCapturingShortcutAction(null);
};
window.addEventListener('keydown', handleShortcutCapture, true);
return () => {
window.removeEventListener('keydown', handleShortcutCapture, true);
};
}, [capturingShortcutAction, shortcutOptions, updateShortcut]);
const linuxResizeHandleStyleBase = {
position: 'fixed',
zIndex: 12000,
background: 'transparent',
WebkitAppRegion: 'drag',
'--wails-draggable': 'drag',
userSelect: 'none'
} 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})`
: `rgba(255, 255, 255, ${effectiveOpacity})`,
colorBgElevated: darkMode
? '#1f1f1f'
: '#ffffff',
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: {
bodyBg: 'transparent',
headerBg: 'transparent',
siderBg: 'transparent',
triggerBg: 'transparent'
},
Table: {
headerBg: 'transparent',
rowHoverBg: darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)',
},
Tabs: {
cardBg: 'transparent',
itemActiveColor: darkMode ? '#ffd666' : '#1890ff',
itemHoverColor: darkMode ? '#ffe58f' : '#40a9ff',
itemSelectedColor: darkMode ? '#ffd666' : '#1677ff',
inkBarColor: darkMode ? '#ffd666' : '#1677ff',
}
}
}}
>
<Layout style={{
height: '100vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
borderRadius: showLinuxResizeHandles ? 0 : windowCornerRadius,
clipPath: showLinuxResizeHandles ? 'none' : `inset(0 round ${windowCornerRadius}px)`,
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
}}>
{/* Custom Title Bar */}
<div
onDoubleClick={handleTitleBarDoubleClick}
style={{
height: titleBarHeight,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: bgMain,
borderBottom: 'none',
userSelect: 'none',
WebkitAppRegion: 'drag', // Wails drag region
'--wails-draggable': 'drag',
paddingLeft: Math.max(12, Math.round(16 * effectiveUiScale)),
fontSize: tokenFontSize
} as any}
>
<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>
<div
data-no-titlebar-toggle="true"
onDoubleClick={(e) => e.stopPropagation()}
style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}
>
<Button
type="text"
icon={<MinusOutlined />}
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={WindowMinimise}
/>
<Button
type="text"
icon={<BorderOutlined />}
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: titleBarButtonWidth }}
onClick={Quit}
/>
</div>
</div>
<Layout style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
<Sider
width={sidebarWidth}
style={{
borderRight: '1px solid rgba(128,128,128,0.2)',
position: 'relative',
background: bgMain
}}
>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft" dropdownRender={renderUtilityDropdown}>
<Button type="text" icon={<ToolOutlined />} title="工具" style={utilityButtonStyle}></Button>
</Dropdown>
<Button type="text" icon={<GlobalOutlined />} title="代理" style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)}></Button>
<Button type="text" icon={<SkinOutlined />} title="主题" style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)}></Button>
<Button type="text" icon={<InfoCircleOutlined />} title="关于" style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)}></Button>
</div>
</div>
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 8, width: '100%' }}>
<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" style={sidebarQueryActionStyle}>
</Button>
<Button icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" style={sidebarCreateConnectionActionStyle}>
</Button>
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58 }}>
<Sidebar onEditConnection={handleEditConnection} />
</div>
{/* 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)}
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>
</div>
</div>
{/* Sidebar Resize Handle */}
<div
onMouseDown={handleSidebarMouseDown}
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '5px',
cursor: 'col-resize',
zIndex: 100,
// background: 'transparent' // transparent usually, visible on hover if desired
}}
title="拖动调整宽度"
/>
</Sider>
<Content style={{ background: isLogPanelOpen ? bgContent : 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? windowCornerRadius : 0, clipPath: isLogPanelOpen ? `inset(0 round ${windowCornerRadius}px)` : 'none' }}>
<TabManager />
</div>
{isLogPanelOpen && (
<LogPanel
height={logPanelHeight}
onClose={() => setIsLogPanelOpen(false)}
onResizeStart={handleLogResizeStart}
/>
)}
</Content>
</Layout>
<ConnectionModal
open={isModalOpen}
onClose={handleCloseModal}
initialValues={editingConnection}
onOpenDriverManager={handleOpenDriverManagerFromConnection}
/>
<DataSyncModal
open={isSyncModalOpen}
onClose={() => setIsSyncModalOpen(false)}
/>
<DriverManagerModal
open={isDriverModalOpen}
onClose={() => setIsDriverModalOpen(false)}
onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)}
/>
<Modal
title={renderUtilityModalTitle(<InfoCircleOutlined />, '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')}
open={isAboutOpen}
onCancel={() => setIsAboutOpen(false)}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
footer={[
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 ? (
<Button key="mute" onClick={() => { updateMutedVersionRef.current = lastUpdateInfo.latestVersion; setIsAboutOpen(false); }}></Button>
) : null,
<Button key="check" icon={<CloudDownloadOutlined />} onClick={() => checkForUpdates(false)}></Button>,
<Button key="close" type="primary" onClick={() => setIsAboutOpen(false)}></Button>
].filter(Boolean)}
>
{aboutLoading ? (
<div style={{ padding: '16px 0', textAlign: 'center' }}>
<Spin />
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={utilityPanelStyle}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }}>
<div>
<div style={{ marginBottom: 6, fontWeight: 600 }}></div>
<div style={utilityMutedTextStyle}>{aboutInfo?.version || '未知'}</div>
</div>
<div>
<div style={{ marginBottom: 6, fontWeight: 600 }}></div>
<div style={utilityMutedTextStyle}>{aboutInfo?.author || '未知'}</div>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ marginBottom: 6, fontWeight: 600 }}></div>
<div style={utilityMutedTextStyle}>{aboutUpdateStatus || '未检查'}</div>
</div>
{aboutInfo?.communityUrl ? (
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ marginBottom: 6, fontWeight: 600 }}></div>
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.communityUrl) BrowserOpenURL(aboutInfo.communityUrl); }} href={aboutInfo.communityUrl}>AI全书</a>
</div>
) : null}
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}></div>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<GithubOutlined />
{aboutInfo?.repoUrl ? (
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.repoUrl) BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>{aboutInfo.repoUrl}</a>
) : '未知'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<BugOutlined />
{aboutInfo?.issueUrl ? (
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.issueUrl) BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}>{aboutInfo.issueUrl}</a>
) : '未知'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CloudDownloadOutlined />
{aboutInfo?.releaseUrl ? (
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.releaseUrl) BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}>{aboutInfo.releaseUrl}</a>
) : '未知'}
</div>
</div>
</div>
</div>
)}
</Modal>
<Modal
title={renderUtilityModalTitle(
themeModalSection === 'theme' ? <SkinOutlined /> : <BgColorsOutlined />,
themeModalSection === 'theme' ? '主题设置' : '外观设置',
themeModalSection === 'theme'
? '切换亮暗主题,保持整体视觉风格统一。'
: '统一调整缩放、字体、透明度与模糊效果。'
)}
open={isThemeModalOpen}
onCancel={() => { setIsThemeModalOpen(false); setThemeModalSection('theme'); }}
footer={null}
width={820}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8, height: 620, overflow: 'hidden' }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
>
<div style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0', height: '100%', minHeight: 0, overflow: 'hidden', alignItems: 'stretch' }}>
<div style={{ ...utilityPanelStyle, padding: 12, height: 'fit-content' }}>
<div style={{ marginBottom: 12, fontWeight: 600 }}></div>
<div style={{ display: 'grid', gap: 10 }}>
{[
{ key: 'theme', title: '主题模式', description: '亮色与暗色切换', icon: <SkinOutlined /> },
{ key: 'appearance', title: '外观参数', description: '缩放、字体与透明度', icon: <BgColorsOutlined /> },
].map((item) => {
const active = themeModalSection === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setThemeModalSection(item.key as 'theme' | 'appearance')}
style={{
textAlign: 'left',
padding: '12px 12px',
borderRadius: 12,
border: `1px solid ${active
? (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)')
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
background: active
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)')
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span>{item.icon}</span>
<span style={{ fontWeight: 700 }}>{item.title}</span>
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : utilityMutedTextStyle.color }}>
{item.description}
</div>
</button>
);
})}
</div>
</div>
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
{themeModalSection === 'theme' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }}>
{[
{ key: 'light', label: '亮色主题', description: '适合明亮环境,层次更轻。' },
{ key: 'dark', label: '暗色主题', description: '适合低光环境,视觉更沉稳。' },
].map((item) => {
const active = themeMode === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setTheme(item.key as 'light' | 'dark')}
style={{
textAlign: 'left',
padding: '14px 14px',
borderRadius: 14,
border: `1px solid ${active
? (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)')
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
background: active
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)')
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{item.label}</span>
{active ? <CheckOutlined style={{ color: darkMode ? '#ffd666' : '#1677ff' }} /> : null}
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : utilityMutedTextStyle.color }}>
{item.description}
</div>
</button>
);
})}
</div>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={utilityPanelStyle}>
<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: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 4 }}>
* 85%-95%
</div>
</div>
<div style={utilityPanelStyle}>
<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 style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 500 }}></div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div>
<div style={{ fontWeight: 500 }}></div>
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}></div>
</div>
<Switch checked={appearance.enabled !== false} onChange={(checked) => setAppearance({ enabled: checked })} />
</div>
<div style={{ display: 'grid', gap: 14, opacity: appearance.enabled !== false ? 1 : 0.6 }}>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}> (Opacity)</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={0.1}
max={1.0}
step={0.05}
disabled={appearance.enabled === false}
value={appearance.opacity ?? 1.0}
onChange={(v) => setAppearance({ opacity: v })}
style={{ flex: 1 }}
/>
<span style={{ width: 40 }}>{Math.round((appearance.opacity ?? 1.0) * 100)}%</span>
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}> (Blur)</div>
{isWindowsPlatform() ? (
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
Windows 使 Acrylic
</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={0}
max={20}
disabled={appearance.enabled === false}
value={appearance.blur ?? 0}
onChange={(v) => setAppearance({ blur: v })}
style={{ flex: 1 }}
/>
<span style={{ width: 40 }}>{appearance.blur}px</span>
</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 4 }}>
*
</div>
</>
)}
</div>
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span></span>
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 4 }}>
*
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, paddingTop: 8, paddingBottom: 12 }}>
<Button
onClick={() => {
setUiScale(DEFAULT_UI_SCALE);
setFontSize(DEFAULT_FONT_SIZE);
setAppearance({ enabled: true, opacity: 1.0, blur: 0 });
}}
>
</Button>
</div>
</div>
)}
</div>
</div>
</Modal>
<Modal
title={renderUtilityModalTitle(<LinkOutlined />, '快捷键管理', '统一查看、录制与启停常用快捷键,保持操作习惯一致。')}
open={isShortcutModalOpen}
onCancel={() => {
setIsShortcutModalOpen(false);
setCapturingShortcutAction(null);
}}
width={760}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
footer={[
<Button
key="reset"
onClick={() => {
resetShortcutOptions();
setCapturingShortcutAction(null);
void message.success('已恢复默认快捷键');
}}
>
</Button>,
<Button
key="close"
type="primary"
onClick={() => {
setIsShortcutModalOpen(false);
setCapturingShortcutAction(null);
}}
>
</Button>,
]}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
<div style={utilityPanelStyle}>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
Esc Ctrl/Alt/Shift/Meta
</div>
</div>
{SHORTCUT_ACTION_ORDER.map((action) => {
const meta = SHORTCUT_ACTION_META[action];
const binding = shortcutOptions[action] ?? { combo: '', enabled: false };
const isCapturing = capturingShortcutAction === action;
return (
<div
key={action}
style={{
...utilityPanelStyle,
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 12,
alignItems: 'center',
padding: '10px 12px',
}}
>
<div>
<div style={{ fontWeight: 500 }}>{meta.label}</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{meta.description}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Input
readOnly
value={isCapturing ? '请按下快捷键...' : getShortcutDisplay(binding.combo)}
style={{ width: 180, fontFamily: 'Consolas, Menlo, Monaco, monospace' }}
/>
<Button
size="small"
onClick={() => setCapturingShortcutAction((prev) => (prev === action ? null : action))}
>
{isCapturing ? '取消' : '录制'}
</Button>
<Switch
checked={binding.enabled}
onChange={(checked) => updateShortcut(action, { enabled: checked })}
/>
</div>
</div>
);
})}
</div>
</Modal>
<Modal
title={renderUtilityModalTitle(<GlobalOutlined />, '全局代理设置', '统一配置更新检查、驱动管理与未单独指定代理的连接网络出口。')}
open={isProxyModalOpen}
onCancel={() => setIsProxyModalOpen(false)}
footer={null}
width={520}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span></span>
<Switch checked={globalProxy.enabled} onChange={(checked) => setGlobalProxy({ enabled: checked })} />
</div>
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, opacity: globalProxy.enabled ? 1 : 0.7 }}>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}></div>
<Select
value={globalProxy.type}
disabled={!globalProxy.enabled}
options={[
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'http', label: 'HTTP' },
]}
onChange={(value) => setGlobalProxy({ type: value as 'socks5' | 'http' })}
/>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}></div>
<InputNumber
min={1}
max={65535}
style={{ width: '100%' }}
value={globalProxy.port}
disabled={!globalProxy.enabled}
onChange={(value) => setGlobalProxy({
port: typeof value === 'number' ? value : (globalProxy.type === 'http' ? 8080 : 1080),
})}
/>
</div>
<div style={{ gridColumn: '1 / span 2' }}>
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}></div>
<Input
placeholder="例如127.0.0.1"
value={globalProxy.host}
disabled={!globalProxy.enabled}
onChange={(e) => setGlobalProxy({ host: e.target.value })}
/>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}></div>
<Input
placeholder="proxy-user"
value={globalProxy.user}
disabled={!globalProxy.enabled}
onChange={(e) => setGlobalProxy({ user: e.target.value })}
/>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}></div>
<Input.Password
placeholder="proxy-password"
value={globalProxy.password}
disabled={!globalProxy.enabled}
onChange={(e) => setGlobalProxy({ password: e.target.value })}
/>
</div>
</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 6 }}>
*
</div>
</div>
</div>
</Modal>
<Modal
title={updateDownloadProgress.version ? `下载更新 ${updateDownloadProgress.version}` : '下载更新'}
open={updateDownloadProgress.open}
closable
maskClosable
keyboard
onCancel={hideUpdateDownloadProgress}
footer={updateDownloadProgress.status === 'start' || updateDownloadProgress.status === 'downloading' ? [
<Button
key="background"
onClick={hideUpdateDownloadProgress}
>
</Button>
] : (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
percent={Math.round(updateDownloadProgress.percent)}
status={updateDownloadProgress.status === 'error' ? 'exception' : (updateDownloadProgress.status === 'done' ? 'success' : 'active')}
/>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
{`${formatBytes(updateDownloadProgress.downloaded)} / ${formatBytes(updateDownloadProgress.total)}`}
</div>
{updateDownloadProgress.message ? (
<div style={{ fontSize: 12, color: '#ff4d4f' }}>{updateDownloadProgress.message}</div>
) : null}
</div>
</Modal>
{showLinuxResizeHandles && (
<>
{/* Linux Mint 下 frameless 仅局部可缩放:补四边四角命中层 */}
<div style={{ ...linuxResizeHandleStyleBase, top: 0, left: 14, right: 14, height: 6, cursor: 'ns-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, left: 14, right: 14, height: 6, cursor: 'ns-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 14, bottom: 14, left: 0, width: 6, cursor: 'ew-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 14, bottom: 14, right: 0, width: 6, cursor: 'ew-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 0, left: 0, width: 14, height: 14, cursor: 'nwse-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 0, right: 0, width: 14, height: 14, cursor: 'nesw-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, left: 0, width: 14, height: 14, cursor: 'nesw-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, right: 0, width: 14, height: 14, cursor: 'nwse-resize' }} />
</>
)}
{/* Ghost Resize Line for Sidebar */}
<div
ref={ghostRef}
style={{
position: 'fixed',
top: 0,
bottom: 0,
left: 0,
width: '4px',
background: resizeGuideColor,
zIndex: 9999,
pointerEvents: 'none',
display: 'none'
}}
/>
{/* Ghost Resize Line for Log Panel */}
<div
ref={logGhostRef}
style={{
position: 'fixed',
left: sidebarWidth, // Start from sidebar edge
right: 0,
height: '4px',
background: resizeGuideColor,
zIndex: 9999,
pointerEvents: 'none',
display: 'none',
cursor: 'row-resize'
}}
/>
</Layout>
</ConfigProvider>
);
}
export default App;