mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 11:49:40 +08:00
Compare commits
9 Commits
feature/su
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b108cd1c90 | ||
|
|
d1ce9cefb8 | ||
|
|
f75e04f091 | ||
|
|
1fc182817e | ||
|
|
3c28b0adeb | ||
|
|
ec4b3d9018 | ||
|
|
8654485cfe | ||
|
|
9beb73ea40 | ||
|
|
3b19a33d4b |
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -88,7 +88,7 @@ jobs:
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.artifact_name }}
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.artifact_name }} -ldflags "-X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
|
||||
# macOS Packaging
|
||||
- name: Package macOS DMG
|
||||
@@ -249,6 +249,11 @@ jobs:
|
||||
- name: List Assets
|
||||
run: ls -R release-assets
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
cd release-assets
|
||||
sha256sum * > SHA256SUMS
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,7 +6,7 @@
|
||||
frontend/release/
|
||||
**/release/
|
||||
**/dist/
|
||||
**/build/
|
||||
build/bin/
|
||||
|
||||
# wails / node artifacts (按需)
|
||||
node_modules/
|
||||
@@ -17,3 +17,5 @@ dist/
|
||||
GoNavi-Wails
|
||||
GoNavi-Wails.exe
|
||||
.ace-tool/
|
||||
.claude/
|
||||
tmpclaude-*
|
||||
|
||||
36
README.md
36
README.md
@@ -31,16 +31,44 @@
|
||||
- **虚拟滚动**:轻松处理海量数据展示,拒绝卡顿。
|
||||
|
||||
### 🔌 多数据库支持
|
||||
- **MySQL**:完整的支持,包括表结构设计、索引管理、外键管理等。
|
||||
- **PostgreSQL**:基础支持(持续完善中)。
|
||||
- **MySQL**:完整支持,涵盖数据编辑、结构管理与导入导出。
|
||||
- **PostgreSQL**:数据查看与编辑支持,事务提交能力持续完善。
|
||||
- **SQLite**:本地文件数据库支持。
|
||||
- **Oracle**:基础数据访问与编辑支持。
|
||||
- **Dameng(达梦)**:基础数据访问与编辑支持。
|
||||
- **Kingbase(人大金仓)**:基础数据访问与编辑支持。
|
||||
- **Redis**:Key/Value 浏览、命令执行、视图与编码切换。
|
||||
- **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。
|
||||
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
|
||||
|
||||
### 📊 强大的数据管理 (DataGrid)
|
||||
- **所见即所得编辑**:直接在表格中双击单元格修改数据。
|
||||
- **事务操作**:支持批量新增、修改、删除,一键提交或回滚事务。
|
||||
- **批量事务操作**:支持批量新增、修改、删除,一键提交或回滚事务。
|
||||
- **大字段编辑**:双击大字段自动打开弹窗编辑器,避免卡顿。
|
||||
- **右键上下文菜单**:快速设置 NULL、复制/导出等操作。
|
||||
- **智能上下文**:自动识别单表查询,解锁编辑功能;复杂查询自动切换为只读模式。
|
||||
- **数据导出**:支持导出为 CSV, Excel (XLSX), JSON, Markdown 等格式。
|
||||
- **批量导出/备份**:支持表与数据库的批量导出/备份。
|
||||
- **数据导出**:支持 CSV、Excel (XLSX)、JSON、Markdown 等格式。
|
||||
|
||||
### 🧰 批量导出/备份
|
||||
- **数据库批量导出**:支持结构导出与结构+数据备份。
|
||||
- **表批量导出**:支持多表一键导出/备份。
|
||||
- **智能上下文检测**:自动判断目标范围,避免误操作。
|
||||
|
||||
### 🧩 Redis 视图与编码
|
||||
- **视图模式切换**:自动/原始文本/UTF-8/十六进制多模式显示。
|
||||
- **智能解码**:针对二进制值进行 UTF-8 质量判定与中文字符识别。
|
||||
- **命令执行**:内置命令面板快速操作。
|
||||
|
||||
### 🔄 数据同步与导入导出
|
||||
- **连接配置导入/导出**:支持配置 JSON 导入导出,便于团队共享。
|
||||
- **数据同步**:内置数据同步面板,支持跨库同步任务配置。
|
||||
|
||||
### 🆙 在线更新
|
||||
- **自动更新**:启动/定时/手动检查更新,自动下载并提示重启完成更新。
|
||||
|
||||
### 🧾 可观测性
|
||||
- **SQL 执行日志**:实时查看 SQL 与执行耗时,便于排障与优化。
|
||||
|
||||
### 📝 智能 SQL 编辑器
|
||||
- **Monaco Editor 内核**:集成 VS Code 同款编辑器,体验极佳。
|
||||
|
||||
@@ -12,6 +12,7 @@ if [ -z "$VERSION" ]; then
|
||||
VERSION="0.0.0"
|
||||
fi
|
||||
echo "ℹ️ 检测到版本号: $VERSION"
|
||||
LDFLAGS="-X GoNavi-Wails/internal/app.AppVersion=$VERSION"
|
||||
|
||||
# 颜色配置
|
||||
GREEN='\033[0;32m'
|
||||
@@ -27,7 +28,7 @@ mkdir -p $DIST_DIR
|
||||
|
||||
# --- macOS ARM64 构建 ---
|
||||
echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}"
|
||||
wails build -platform darwin/arm64 -clean
|
||||
wails build -platform darwin/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
||||
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app"
|
||||
@@ -81,7 +82,7 @@ fi
|
||||
|
||||
# --- macOS AMD64 构建 ---
|
||||
echo -e "${GREEN}🍎 正在构建 macOS (amd64)...${NC}"
|
||||
wails build -platform darwin/amd64 -clean
|
||||
wails build -platform darwin/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
||||
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-amd64.app"
|
||||
@@ -131,7 +132,7 @@ fi
|
||||
# --- Windows AMD64 构建 ---
|
||||
echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}"
|
||||
if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
||||
wails build -platform windows/amd64 -clean
|
||||
wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||
@@ -145,7 +146,7 @@ fi
|
||||
# --- Windows ARM64 构建 ---
|
||||
echo -e "${GREEN}🪟 正在构建 Windows (arm64)...${NC}"
|
||||
if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
|
||||
wails build -platform windows/arm64 -clean
|
||||
wails build -platform windows/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
|
||||
@@ -165,7 +166,7 @@ CURRENT_ARCH=$(uname -m)
|
||||
|
||||
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
|
||||
# 本机 Linux amd64,直接构建
|
||||
wails build -platform linux/amd64 -clean
|
||||
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||
@@ -183,7 +184,7 @@ elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
|
||||
export CC=x86_64-linux-gnu-gcc
|
||||
export CXX=x86_64-linux-gnu-g++
|
||||
export CGO_ENABLED=1
|
||||
wails build -platform linux/amd64 -clean
|
||||
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||
@@ -205,7 +206,7 @@ fi
|
||||
echo -e "${GREEN}🐧 正在构建 Linux (arm64)...${NC}"
|
||||
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
|
||||
# 本机 Linux arm64,直接构建
|
||||
wails build -platform linux/arm64 -clean
|
||||
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||
@@ -222,7 +223,7 @@ elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
|
||||
export CC=aarch64-linux-gnu-gcc
|
||||
export CXX=aarch64-linux-gnu-g++
|
||||
export CGO_ENABLED=1
|
||||
wails build -platform linux/arm64 -clean
|
||||
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||
chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||
@@ -244,6 +245,27 @@ fi
|
||||
# 清理中间构建目录
|
||||
rm -rf "build/bin"
|
||||
|
||||
echo -e "${GREEN}🔐 生成 SHA256SUMS...${NC}"
|
||||
if command -v sha256sum &> /dev/null; then
|
||||
cd "$DIST_DIR"
|
||||
: > SHA256SUMS
|
||||
for f in *; do
|
||||
[ -f "$f" ] || continue
|
||||
sha256sum "$f" >> SHA256SUMS
|
||||
done
|
||||
cd ..
|
||||
elif command -v shasum &> /dev/null; then
|
||||
cd "$DIST_DIR"
|
||||
: > SHA256SUMS
|
||||
for f in *; do
|
||||
[ -f "$f" ] || continue
|
||||
shasum -a 256 "$f" >> SHA256SUMS
|
||||
done
|
||||
cd ..
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 sha256sum/shasum,跳过校验文件生成。${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
|
||||
ls -lh "$DIST_DIR"
|
||||
|
||||
BIN
build/appicon.png
Normal file
BIN
build/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
BIN
build/windows/icon.ico
Normal file
BIN
build/windows/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
15
build/windows/info.json
Normal file
15
build/windows/info.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
build/windows/wails.exe.manifest
Normal file
15
build/windows/wails.exe.manifest
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
@@ -1 +1 @@
|
||||
5b8157374dae5f9340e31b2d0bd2c00e
|
||||
d0f9366af59a6367ad3c7e2d4185ead4
|
||||
@@ -3,6 +3,7 @@ html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden; /* Disable global scrollbar */
|
||||
background-color: transparent !important; /* CRITICAL: Allow Wails window transparency */
|
||||
}
|
||||
|
||||
/* 侧边栏 Tree 样式优化 */
|
||||
@@ -30,4 +31,40 @@ html, body, #root {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dark mode */
|
||||
body[data-theme='dark'] ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
body[data-theme='dark'] ::-webkit-scrollbar-track {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
body[data-theme='dark'] ::-webkit-scrollbar-corner {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
body[data-theme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #1f1f1f;
|
||||
}
|
||||
body[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
|
||||
body {
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] {
|
||||
/* Improve contrast on transparent backgrounds */
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Custom Title Bar Close Button Hover */
|
||||
.titlebar-close-btn:hover {
|
||||
background-color: #ff4d4f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message } from 'antd';
|
||||
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Popover } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, BugOutlined, SettingOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
@@ -17,14 +17,153 @@ function App() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
|
||||
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
|
||||
const darkMode = useStore(state => state.darkMode);
|
||||
const toggleDarkMode = useStore(state => state.toggleDarkMode);
|
||||
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 darkMode = themeMode === 'dark';
|
||||
|
||||
// Background Helper
|
||||
const getBg = (darkHex: string, lightHex: string) => {
|
||||
if (!darkMode) return `rgba(255, 255, 255, ${appearance.opacity ?? 0.95})`; // 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}, ${appearance.opacity ?? 0.95})`;
|
||||
};
|
||||
// Specific colors
|
||||
const bgMain = getBg('#141414', '#ffffff');
|
||||
const bgContent = getBg('#1d1d1d', '#ffffff');
|
||||
|
||||
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 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 [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>('');
|
||||
const [lastUpdateInfo, setLastUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
|
||||
type UpdateInfo = {
|
||||
hasUpdate: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
releaseName?: string;
|
||||
releaseNotesUrl?: string;
|
||||
assetName?: string;
|
||||
assetUrl?: string;
|
||||
assetSize?: number;
|
||||
sha256?: string;
|
||||
};
|
||||
|
||||
const promptRestartForUpdate = (info: UpdateInfo) => {
|
||||
Modal.confirm({
|
||||
title: '更新已下载',
|
||||
content: `版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`,
|
||||
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) {
|
||||
message.info(`更新包已就绪(${info.latestVersion})`);
|
||||
}
|
||||
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
|
||||
promptRestartForUpdate(info);
|
||||
}
|
||||
return;
|
||||
}
|
||||
updateDownloadInFlightRef.current = true;
|
||||
const key = 'update-download';
|
||||
message.loading({ content: `正在下载更新 ${info.latestVersion}...`, key, duration: 0 });
|
||||
const res = await (window as any).go.app.App.DownloadUpdate();
|
||||
updateDownloadInFlightRef.current = false;
|
||||
if (res?.success) {
|
||||
updateDownloadedVersionRef.current = info.latestVersion;
|
||||
message.success({ content: '更新下载完成', key, duration: 2 });
|
||||
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
|
||||
promptRestartForUpdate(info);
|
||||
}
|
||||
} else {
|
||||
message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), key, duration: 4 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
message.error('检查更新失败: ' + (res?.message || '未知错误'));
|
||||
setAboutUpdateStatus('检查更新失败: ' + (res?.message || '未知错误'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const info: UpdateInfo = res.data;
|
||||
if (!info) return;
|
||||
setLastUpdateInfo(info);
|
||||
if (info.hasUpdate) {
|
||||
if (!silent) {
|
||||
message.info(`发现新版本 ${info.latestVersion}`);
|
||||
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(未下载)`);
|
||||
}
|
||||
if (silent && isAboutOpen) {
|
||||
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(未下载)`);
|
||||
}
|
||||
if (silent && !isAboutOpen && updateMutedVersionRef.current !== info.latestVersion && updateNotifiedVersionRef.current !== info.latestVersion) {
|
||||
updateNotifiedVersionRef.current = info.latestVersion;
|
||||
setIsAboutOpen(true);
|
||||
}
|
||||
} else if (!silent) {
|
||||
const text = `当前已是最新版本(${info.currentVersion || '未知'})`;
|
||||
message.success(text);
|
||||
setAboutUpdateStatus(text);
|
||||
} else if (silent && isAboutOpen) {
|
||||
const text = `当前已是最新版本(${info.currentVersion || '未知'})`;
|
||||
setAboutUpdateStatus(text);
|
||||
}
|
||||
}, [downloadUpdate]);
|
||||
|
||||
const loadAboutInfo = React.useCallback(async () => {
|
||||
setAboutLoading(true);
|
||||
const res = await (window as any).go.app.App.GetAppInfo();
|
||||
if (res?.success) {
|
||||
setAboutInfo(res.data);
|
||||
} else {
|
||||
message.error('获取应用信息失败: ' + (res?.message || '未知错误'));
|
||||
}
|
||||
setAboutLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleNewQuery = () => {
|
||||
let connId = activeContext?.connectionId || '';
|
||||
@@ -44,7 +183,8 @@ function App() {
|
||||
title: '新建查询',
|
||||
type: 'query',
|
||||
connectionId: connId,
|
||||
dbName: db
|
||||
dbName: db,
|
||||
query: ''
|
||||
});
|
||||
};
|
||||
|
||||
@@ -86,13 +226,7 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const settingsMenu: MenuProps['items'] = [
|
||||
{
|
||||
key: 'sync',
|
||||
label: '数据同步',
|
||||
icon: <UploadOutlined rotate={90} />,
|
||||
onClick: () => setIsSyncModalOpen(true)
|
||||
},
|
||||
const toolsMenu: MenuProps['items'] = [
|
||||
{
|
||||
key: 'import',
|
||||
label: '导入连接配置',
|
||||
@@ -104,9 +238,40 @@ function App() {
|
||||
label: '导出连接配置',
|
||||
icon: <DownloadOutlined />,
|
||||
onClick: handleExportConnections
|
||||
},
|
||||
{
|
||||
key: 'sync',
|
||||
label: '数据同步',
|
||||
icon: <UploadOutlined rotate={90} />,
|
||||
onClick: () => setIsSyncModalOpen(true)
|
||||
}
|
||||
];
|
||||
|
||||
const themeMenu: MenuProps['items'] = [
|
||||
{
|
||||
key: 'light',
|
||||
label: '亮色主题',
|
||||
icon: themeMode === 'light' ? <CheckOutlined /> : undefined,
|
||||
onClick: () => setTheme('light')
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
label: '暗色主题',
|
||||
icon: themeMode === 'dark' ? <CheckOutlined /> : undefined,
|
||||
onClick: () => setTheme('dark')
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'settings',
|
||||
label: '外观设置...',
|
||||
icon: <SettingOutlined />,
|
||||
onClick: () => setIsAppearanceModalOpen(true)
|
||||
}
|
||||
];
|
||||
|
||||
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
|
||||
|
||||
|
||||
// Log Panel
|
||||
const [logPanelHeight, setLogPanelHeight] = useState(200);
|
||||
const [isLogPanelOpen, setIsLogPanelOpen] = useState(false);
|
||||
@@ -230,43 +395,170 @@ function App() {
|
||||
}
|
||||
}, [darkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAboutOpen) {
|
||||
if (lastUpdateInfo?.hasUpdate) {
|
||||
setAboutUpdateStatus(`发现新版本 ${lastUpdateInfo.latestVersion}(未下载)`);
|
||||
} else if (lastUpdateInfo) {
|
||||
setAboutUpdateStatus(`当前已是最新版本(${lastUpdateInfo.currentVersion || '未知'})`);
|
||||
} else {
|
||||
setAboutUpdateStatus('未检查');
|
||||
}
|
||||
loadAboutInfo();
|
||||
}
|
||||
}, [isAboutOpen, lastUpdateInfo, loadAboutInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const startupTimer = window.setTimeout(() => {
|
||||
checkForUpdates(true);
|
||||
}, 2000);
|
||||
const interval = window.setInterval(() => {
|
||||
checkForUpdates(true);
|
||||
}, 30 * 60 * 1000);
|
||||
return () => {
|
||||
window.clearTimeout(startupTimer);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [checkForUpdates]);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorBgLayout: 'transparent',
|
||||
colorBgContainer: darkMode
|
||||
? `rgba(29, 29, 29, ${appearance.opacity ?? 0.95})`
|
||||
: `rgba(255, 255, 255, ${appearance.opacity ?? 0.95})`,
|
||||
colorBgElevated: darkMode
|
||||
? '#1f1f1f'
|
||||
: '#ffffff',
|
||||
colorFillAlter: darkMode
|
||||
? `rgba(38, 38, 38, ${appearance.opacity ?? 0.95})`
|
||||
: `rgba(250, 250, 250, ${appearance.opacity ?? 0.95})`,
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
colorBgBody: 'transparent',
|
||||
colorBgHeader: 'transparent',
|
||||
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 ? '#177ddc' : '#1890ff',
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||
<Layout style={{
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
backdropFilter: `blur(${appearance.blur ?? 0}px)`
|
||||
}}>
|
||||
{/* Custom Title Bar */}
|
||||
<div
|
||||
style={{
|
||||
height: 32,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: bgMain,
|
||||
backdropFilter: `blur(${appearance.blur ?? 0}px)`,
|
||||
borderBottom: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitAppRegion: 'drag', // Wails drag region
|
||||
'--wails-draggable': 'drag',
|
||||
paddingLeft: 16
|
||||
} as any}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600 }}>
|
||||
{/* Logo can be added here if available */}
|
||||
GoNavi
|
||||
</div>
|
||||
<div 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: 46 }}
|
||||
onClick={() => (window as any).runtime.WindowMinimise()}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BorderOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: 46 }}
|
||||
onClick={() => (window as any).runtime.WindowToggleMaximise()}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
danger
|
||||
className="titlebar-close-btn"
|
||||
style={{ height: '100%', borderRadius: 0, width: 46 }}
|
||||
onClick={() => (window as any).runtime.Quit()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: 36,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 4,
|
||||
padding: '0 8px',
|
||||
borderBottom: 'none',
|
||||
background: bgMain,
|
||||
backdropFilter: `blur(${appearance.blur ?? 0}px)`
|
||||
}}
|
||||
>
|
||||
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft">
|
||||
<Button type="text" icon={<ToolOutlined />} title="工具">工具</Button>
|
||||
</Dropdown>
|
||||
<Dropdown menu={{ items: themeMenu }} placement="bottomLeft">
|
||||
<Button type="text" icon={<SkinOutlined />} title="主题">主题</Button>
|
||||
</Dropdown>
|
||||
<Button type="text" icon={<InfoCircleOutlined />} title="关于" onClick={() => setIsAboutOpen(true)}>关于</Button>
|
||||
</div>
|
||||
<Layout style={{ flex: 1, minHeight: 0 }}>
|
||||
<Sider
|
||||
theme={darkMode ? "dark" : "light"}
|
||||
width={sidebarWidth}
|
||||
style={{
|
||||
borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||
position: 'relative'
|
||||
borderRight: 'none',
|
||||
position: 'relative',
|
||||
background: bgMain
|
||||
}}
|
||||
>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '10px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
|
||||
<div style={{ padding: '10px', borderBottom: 'none', display: 'flex', justifyContent: 'flex-end', alignItems: 'center', flexShrink: 0 }}>
|
||||
|
||||
<div>
|
||||
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
|
||||
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" />
|
||||
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
|
||||
<Dropdown menu={{ items: settingsMenu }} placement="bottomRight">
|
||||
<Button type="text" icon={<SettingOutlined />} title="更多设置" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Sidebar onEditConnection={handleEditConnection} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer for Log Toggle */}
|
||||
<div style={{ padding: '8px', borderTop: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div style={{ padding: '8px', borderTop: 'none', display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Button
|
||||
type={isLogPanelOpen ? "primary" : "text"}
|
||||
type={isLogPanelOpen ? "primary" : "text"}
|
||||
icon={<BugOutlined />}
|
||||
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
|
||||
block
|
||||
@@ -292,8 +584,8 @@ function App() {
|
||||
title="拖动调整宽度"
|
||||
/>
|
||||
</Sider>
|
||||
<Content style={{ background: darkMode ? '#141414' : '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, backdropFilter: `blur(${appearance.blur ?? 0}px)` }}>
|
||||
<TabManager />
|
||||
</div>
|
||||
{isLogPanelOpen && (
|
||||
@@ -304,6 +596,7 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
<ConnectionModal
|
||||
open={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
@@ -313,6 +606,98 @@ function App() {
|
||||
open={isSyncModalOpen}
|
||||
onClose={() => setIsSyncModalOpen(false)}
|
||||
/>
|
||||
<Modal
|
||||
title="关于 GoNavi"
|
||||
open={isAboutOpen}
|
||||
onCancel={() => setIsAboutOpen(false)}
|
||||
footer={[
|
||||
lastUpdateInfo?.hasUpdate ? (
|
||||
<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: 8 }}>
|
||||
<div>版本:{aboutInfo?.version || '未知'}</div>
|
||||
<div>作者:{aboutInfo?.author || '未知'}</div>
|
||||
<div>更新状态:{aboutUpdateStatus || '未检查'}</div>
|
||||
<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}>
|
||||
{aboutInfo.repoUrl}
|
||||
</a>
|
||||
) : '未知'}
|
||||
</div>
|
||||
<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}>
|
||||
{aboutInfo.issueUrl}
|
||||
</a>
|
||||
) : '未知'}
|
||||
</div>
|
||||
<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}>
|
||||
{aboutInfo.releaseUrl}
|
||||
</a>
|
||||
) : '未知'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="外观设置"
|
||||
open={isAppearanceModalOpen}
|
||||
onCancel={() => setIsAppearanceModalOpen(false)}
|
||||
footer={null}
|
||||
width={400}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: '12px 0' }}>
|
||||
<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}
|
||||
value={appearance.opacity ?? 0.95}
|
||||
onChange={(v) => setAppearance({ opacity: v })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 40 }}>{Math.round((appearance.opacity ?? 0.95) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>高斯模糊 (Blur)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
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: '#888', marginTop: 4 }}>
|
||||
* 仅控制应用内覆盖层的模糊效果
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Ghost Resize Line for Sidebar */}
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal } from 'antd';
|
||||
import type { SortOrder } from 'antd/es/table/interface';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined } from '@ant-design/icons';
|
||||
@@ -209,6 +210,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
if (!editable) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // 阻止冒泡到行级菜单
|
||||
if (cellContextMenuContext) {
|
||||
cellContextMenuContext.showMenu(e, record, dataIndex, title);
|
||||
}
|
||||
@@ -354,8 +356,33 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const darkMode = useStore(state => state.darkMode);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
const opacity = appearance.opacity ?? 0.95;
|
||||
const selectionColumnWidth = 46;
|
||||
|
||||
// Background Helper
|
||||
const getBg = (darkHex: string) => {
|
||||
if (!darkMode) return `rgba(255, 255, 255, ${opacity})`;
|
||||
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}, ${opacity})`;
|
||||
};
|
||||
const blur = appearance.blur ?? 0;
|
||||
const bgContent = getBg('#1d1d1d');
|
||||
const bgFilter = getBg('#262626');
|
||||
const bgContextMenu = getBg('#1f1f1f');
|
||||
|
||||
// Row Colors with Opacity
|
||||
const getRowBg = (r: number, g: number, b: number) => `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
const rowAddedBg = darkMode ? getRowBg(22, 43, 22) : getRowBg(246, 255, 237);
|
||||
const rowModBg = darkMode ? getRowBg(22, 34, 56) : getRowBg(230, 247, 255);
|
||||
const rowAddedHover = darkMode ? getRowBg(31, 61, 31) : getRowBg(217, 247, 190);
|
||||
const rowModHover = darkMode ? getRowBg(29, 53, 94) : getRowBg(186, 231, 255);
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const gridId = useMemo(() => `grid-${uuidv4()}`, []);
|
||||
@@ -1287,7 +1314,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const enableVirtual = mergedDisplayData.length >= 200;
|
||||
|
||||
return (
|
||||
<div className={gridId} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div className={gridId} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, background: bgContent, backdropFilter: blur > 0 ? `blur(${blur}px)` : undefined }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
|
||||
@@ -1336,7 +1363,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
{/* Filter Panel */}
|
||||
{showFilter && (
|
||||
<div style={{ padding: '8px', background: '#f5f5f5', borderBottom: '1px solid #eee' }}>
|
||||
<div style={{
|
||||
padding: '8px',
|
||||
margin: '4px 8px 0 8px',
|
||||
borderRadius: '8px',
|
||||
background: bgFilter,
|
||||
}}>
|
||||
{filterConditions.map(cond => (
|
||||
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start' }}>
|
||||
<Select
|
||||
@@ -1427,7 +1459,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<span>{rowEditorRowKey ? `rowKey: ${rowEditorRowKey}` : ''}</span>
|
||||
</div>
|
||||
<Form form={rowEditorForm} layout="vertical">
|
||||
<div style={{ maxHeight: '62vh', overflow: 'auto', paddingRight: 8 }}>
|
||||
<div className="custom-scrollbar" style={{ maxHeight: '62vh', overflow: 'auto', paddingRight: 8 }}>
|
||||
{columnNames.map((col) => {
|
||||
const sample = rowEditorDisplayRef.current?.[col] ?? '';
|
||||
const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined;
|
||||
@@ -1527,19 +1559,21 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</DataContext.Provider>
|
||||
</Form>
|
||||
|
||||
{/* Cell Context Menu */}
|
||||
{cellContextMenu.visible && (
|
||||
{/* Cell Context Menu - 使用 Portal 渲染到 body,避免 backdropFilter 影响 fixed 定位 */}
|
||||
{cellContextMenu.visible && createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: cellContextMenu.x,
|
||||
top: cellContextMenu.y,
|
||||
zIndex: 10000,
|
||||
background: '#fff',
|
||||
border: '1px solid #d9d9d9',
|
||||
background: bgContextMenu,
|
||||
backdropFilter: blur > 0 ? `blur(${blur}px)` : undefined,
|
||||
border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
minWidth: 120,
|
||||
minWidth: 160,
|
||||
color: darkMode ? '#fff' : 'rgba(0, 0, 0, 0.88)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -1549,18 +1583,133 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = '#f5f5f5'}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={handleCellSetNull}
|
||||
>
|
||||
设置为 NULL
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: 1, background: darkMode ? '#303030' : '#f0f0f0', margin: '4px 0' }} />
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record);
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
>
|
||||
复制为 INSERT
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (cellContextMenu.record) handleCopyJson(cellContextMenu.record);
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
>
|
||||
复制为 JSON
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (cellContextMenu.record) handleCopyCsv(cellContextMenu.record);
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
>
|
||||
复制为 CSV
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (cellContextMenu.record) {
|
||||
const records = getTargets(cellContextMenu.record);
|
||||
const lines = records.map((r: any) => {
|
||||
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r;
|
||||
return `| ${Object.values(vals).join(' | ')} |`;
|
||||
});
|
||||
copyToClipboard(lines.join('\n'));
|
||||
}
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
>
|
||||
复制为 Markdown
|
||||
</div>
|
||||
<div style={{ height: 1, background: darkMode ? '#303030' : '#f0f0f0', margin: '4px 0' }} />
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (cellContextMenu.record) handleExportSelected('csv', cellContextMenu.record);
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
>
|
||||
导出为 CSV
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (cellContextMenu.record) handleExportSelected('xlsx', cellContextMenu.record);
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
>
|
||||
导出为 Excel
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (cellContextMenu.record) handleExportSelected('json', cellContextMenu.record);
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
>
|
||||
导出为 JSON
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div style={{ padding: '8px', borderTop: '1px solid #eee', display: 'flex', justifyContent: 'flex-end', background: '#fff' }}>
|
||||
<div style={{ padding: '8px', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
@@ -1579,8 +1728,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.${gridId} .row-added td { background-color: #f6ffed !important; }
|
||||
.${gridId} .row-modified td { background-color: #e6f7ff !important; }
|
||||
.${gridId} .ant-table { background: transparent !important; }
|
||||
.${gridId} .ant-table-container { background: transparent !important; border: none !important; }
|
||||
.${gridId} .ant-table-tbody > tr > td { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
|
||||
.${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
|
||||
.${gridId} .ant-table-thead > tr > th::before { display: none !important; }
|
||||
.${gridId} .ant-table-tbody > tr:hover > td { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; }
|
||||
.${gridId} .row-added td { background-color: ${rowAddedBg} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; }
|
||||
.${gridId} .row-modified td { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; }
|
||||
.${gridId} .ant-table-tbody > tr.row-added:hover > td { background-color: ${rowAddedHover} !important; }
|
||||
.${gridId} .ant-table-tbody > tr.row-modified:hover > td { background-color: ${rowModHover} !important; }
|
||||
`}</style>
|
||||
|
||||
{/* Ghost Resize Line for Columns */}
|
||||
|
||||
@@ -12,7 +12,21 @@ interface LogPanelProps {
|
||||
const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) => {
|
||||
const sqlLogs = useStore(state => state.sqlLogs);
|
||||
const clearSqlLogs = useStore(state => state.clearSqlLogs);
|
||||
const darkMode = useStore(state => state.darkMode);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
|
||||
// Background Helper
|
||||
const getBg = (darkHex: string) => {
|
||||
if (!darkMode) return `rgba(255, 255, 255, ${appearance.opacity ?? 0.95})`;
|
||||
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}, ${appearance.opacity ?? 0.95})`;
|
||||
};
|
||||
const bgMain = getBg('#1f1f1f');
|
||||
const bgToolbar = getBg('#2a2a2a');
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -53,8 +67,9 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
return (
|
||||
<div style={{
|
||||
height,
|
||||
borderTop: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
|
||||
background: darkMode ? '#1f1f1f' : '#fff',
|
||||
borderTop: 'none',
|
||||
background: bgMain,
|
||||
backdropFilter: `blur(${appearance.blur ?? 0}px)`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
@@ -77,11 +92,10 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
padding: '4px 8px',
|
||||
borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||
borderBottom: 'none',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: darkMode ? '#2a2a2a' : '#fafafa',
|
||||
height: 32
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 'bold', fontSize: '12px' }}>
|
||||
|
||||
@@ -42,8 +42,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const editorRef = useRef<any>(null);
|
||||
const monacoRef = useRef<any>(null);
|
||||
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
|
||||
const tablesRef = useRef<string[]>([]); // Store tables for autocomplete
|
||||
const allColumnsRef = useRef<{tableName: string, name: string, type: string}[]>([]); // Store all columns
|
||||
const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db)
|
||||
const allColumnsRef = useRef<{dbName: string, tableName: string, name: string, type: string}[]>([]); // Store all columns (cross-db)
|
||||
const visibleDbsRef = useRef<string[]>([]); // Store visible databases for cross-db intellisense
|
||||
|
||||
const connections = useStore(state => state.connections);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
@@ -52,7 +53,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const connectionsRef = useRef(connections);
|
||||
const columnsCacheRef = useRef<Record<string, ColumnDefinition[]>>({});
|
||||
const saveQuery = useStore(state => state.saveQuery);
|
||||
const darkMode = useStore(state => state.darkMode);
|
||||
const theme = useStore(state => state.theme);
|
||||
const darkMode = theme === 'dark';
|
||||
const sqlFormatOptions = useStore(state => state.sqlFormatOptions);
|
||||
const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions);
|
||||
const queryOptions = useStore(state => state.queryOptions);
|
||||
@@ -80,9 +82,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const fetchDbs = async () => {
|
||||
const conn = connections.find(c => c.id === currentConnectionId);
|
||||
if (!conn) return;
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: conn.config.database || "",
|
||||
@@ -92,27 +94,41 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const res = await DBGetDatabases(config as any);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
const dbs = res.data.map((row: any) => row.Database || row.database);
|
||||
let dbs = res.data.map((row: any) => row.Database || row.database);
|
||||
|
||||
// 过滤只显示 includeDatabases 中配置的数据库
|
||||
const includeDbs = conn.includeDatabases;
|
||||
if (includeDbs && includeDbs.length > 0) {
|
||||
dbs = dbs.filter((db: string) => includeDbs.includes(db));
|
||||
}
|
||||
|
||||
// 存储可见数据库列表用于跨库智能提示
|
||||
visibleDbsRef.current = dbs;
|
||||
|
||||
setDbList(dbs);
|
||||
if (!currentDbRef.current) {
|
||||
if (conn.config.database) setCurrentDb(conn.config.database);
|
||||
if (conn.config.database && dbs.includes(conn.config.database)) setCurrentDb(conn.config.database);
|
||||
else if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]);
|
||||
}
|
||||
} else {
|
||||
visibleDbsRef.current = [];
|
||||
setDbList([]);
|
||||
}
|
||||
};
|
||||
fetchDbs();
|
||||
}, [currentConnectionId, connections]);
|
||||
|
||||
// Fetch Metadata for Autocomplete
|
||||
// Fetch Metadata for Autocomplete (Cross-database)
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
const conn = connections.find(c => c.id === currentConnectionId);
|
||||
if (!conn || !currentDb) return;
|
||||
if (!conn) return;
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
const visibleDbs = visibleDbsRef.current;
|
||||
if (!visibleDbs || visibleDbs.length === 0) return;
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: conn.config.database || "",
|
||||
@@ -120,25 +136,39 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const resTables = await DBGetTables(config as any, currentDb);
|
||||
if (resTables.success && Array.isArray(resTables.data)) {
|
||||
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
|
||||
tablesRef.current = tableNames;
|
||||
} else {
|
||||
tablesRef.current = [];
|
||||
}
|
||||
// 加载所有可见数据库的表
|
||||
const allTables: {dbName: string, tableName: string}[] = [];
|
||||
const allColumns: {dbName: string, tableName: string, name: string, type: string}[] = [];
|
||||
|
||||
if (config.type === 'mysql' || !config.type) {
|
||||
const resCols = await DBGetAllColumns(config as any, currentDb);
|
||||
for (const dbName of visibleDbs) {
|
||||
// 获取表
|
||||
const resTables = await DBGetTables(config as any, dbName);
|
||||
if (resTables.success && Array.isArray(resTables.data)) {
|
||||
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
|
||||
tableNames.forEach((tableName: string) => {
|
||||
allTables.push({ dbName, tableName });
|
||||
});
|
||||
}
|
||||
|
||||
// 获取列 (所有数据库类型都支持 DBGetAllColumns)
|
||||
const resCols = await DBGetAllColumns(config as any, dbName);
|
||||
if (resCols.success && Array.isArray(resCols.data)) {
|
||||
allColumnsRef.current = resCols.data;
|
||||
} else {
|
||||
allColumnsRef.current = [];
|
||||
resCols.data.forEach((col: any) => {
|
||||
allColumns.push({
|
||||
dbName,
|
||||
tableName: col.tableName,
|
||||
name: col.name,
|
||||
type: col.type
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tablesRef.current = allTables;
|
||||
allColumnsRef.current = allColumns;
|
||||
};
|
||||
fetchMetadata();
|
||||
}, [currentConnectionId, currentDb, connections]);
|
||||
}, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
|
||||
|
||||
// Handle Resizing
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
@@ -241,61 +271,125 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const fullText = model.getValue();
|
||||
|
||||
// 1) alias.field completion: when cursor is after "<alias>.<prefix>"
|
||||
// 获取当前行光标前的内容
|
||||
const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
|
||||
|
||||
// 0) 三段式 db.table.column 格式:当输入 db.table. 时提示列
|
||||
const threePartMatch = linePrefix.match(/([`"]?[\w]+[`"]?)\.([`"]?[\w]+[`"]?)\.(\w*)$/);
|
||||
if (threePartMatch) {
|
||||
const dbPart = stripQuotes(threePartMatch[1]);
|
||||
const tablePart = stripQuotes(threePartMatch[2]);
|
||||
const colPrefix = (threePartMatch[3] || '').toLowerCase();
|
||||
|
||||
// 在 allColumnsRef 中查找匹配的列
|
||||
const cols = allColumnsRef.current.filter(c =>
|
||||
(c.dbName || '').toLowerCase() === dbPart.toLowerCase() &&
|
||||
(c.tableName || '').toLowerCase() === tablePart.toLowerCase()
|
||||
);
|
||||
|
||||
const filtered = colPrefix
|
||||
? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix))
|
||||
: cols;
|
||||
|
||||
const suggestions = filtered.map(c => ({
|
||||
label: c.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: c.name,
|
||||
detail: `${c.type} (${c.dbName}.${c.tableName})`,
|
||||
range,
|
||||
sortText: '0' + c.name
|
||||
}));
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
// 1) 两段式 qualifier.xxx 格式
|
||||
const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_][\w]*[`"]?)\.(\w*)$/);
|
||||
if (qualifierMatch) {
|
||||
const alias = stripQuotes(qualifierMatch[1]);
|
||||
const colPrefix = (qualifierMatch[2] || '').toLowerCase();
|
||||
const qualifier = stripQuotes(qualifierMatch[1]);
|
||||
const prefix = (qualifierMatch[2] || '').toLowerCase();
|
||||
|
||||
// 首先检查 qualifier 是否是数据库名(跨库表提示)
|
||||
const visibleDbs = visibleDbsRef.current;
|
||||
if (visibleDbs.some(db => db.toLowerCase() === qualifier.toLowerCase())) {
|
||||
// qualifier 是数据库名,提示该库的表
|
||||
const tables = tablesRef.current.filter(t =>
|
||||
(t.dbName || '').toLowerCase() === qualifier.toLowerCase()
|
||||
);
|
||||
const filtered = prefix
|
||||
? tables.filter(t => (t.tableName || '').toLowerCase().startsWith(prefix))
|
||||
: tables;
|
||||
|
||||
const suggestions = filtered.map(t => ({
|
||||
label: t.tableName,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: t.tableName,
|
||||
detail: `Table (${t.dbName})`,
|
||||
range,
|
||||
sortText: '0' + t.tableName
|
||||
}));
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
// 否则检查是否是表别名或表名,提示列
|
||||
const reserved = new Set([
|
||||
'where', 'on', 'group', 'order', 'limit', 'having',
|
||||
'left', 'right', 'inner', 'outer', 'full', 'cross', 'join',
|
||||
'union', 'except', 'intersect', 'as', 'set', 'values', 'returning',
|
||||
]);
|
||||
|
||||
const aliasMap: Record<string, string> = {};
|
||||
// Capture table and optional alias, support schema.table
|
||||
const aliasMap: Record<string, {dbName: string, tableName: string}> = {};
|
||||
// Capture table and optional alias, support db.table format
|
||||
const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?[\w]+[`"]?))?/gi;
|
||||
let m;
|
||||
while ((m = aliasRegex.exec(fullText)) !== null) {
|
||||
const tableIdent = normalizeQualifiedName(m[1] || '');
|
||||
if (!tableIdent) continue;
|
||||
|
||||
// 解析 db.table 或 table 格式
|
||||
const parts = tableIdent.split('.');
|
||||
let dbName = currentDbRef.current || '';
|
||||
let tableName = tableIdent;
|
||||
if (parts.length === 2) {
|
||||
dbName = parts[0];
|
||||
tableName = parts[1];
|
||||
}
|
||||
|
||||
const shortTable = getLastPart(tableIdent);
|
||||
// allow "table." as qualifier too
|
||||
if (shortTable) aliasMap[shortTable.toLowerCase()] = tableIdent;
|
||||
// 用表名作为 qualifier
|
||||
if (shortTable) aliasMap[shortTable.toLowerCase()] = { dbName, tableName };
|
||||
|
||||
const a = stripQuotes(m[2] || '').trim();
|
||||
if (!a) continue;
|
||||
const al = a.toLowerCase();
|
||||
if (reserved.has(al)) continue;
|
||||
aliasMap[al] = tableIdent;
|
||||
aliasMap[al] = { dbName, tableName };
|
||||
}
|
||||
|
||||
const tableIdent = aliasMap[alias.toLowerCase()];
|
||||
if (tableIdent) {
|
||||
const shortTable = getLastPart(tableIdent);
|
||||
|
||||
const tableInfo = aliasMap[qualifier.toLowerCase()];
|
||||
if (tableInfo) {
|
||||
// Prefer preloaded MySQL all-columns cache
|
||||
let cols: { name: string, type?: string, tableName?: string }[] = [];
|
||||
let cols: { name: string, type?: string, tableName?: string, dbName?: string }[] = [];
|
||||
if (allColumnsRef.current.length > 0) {
|
||||
cols = allColumnsRef.current
|
||||
.filter(c => (c.tableName || '').toLowerCase() === (shortTable || '').toLowerCase())
|
||||
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName }));
|
||||
.filter(c =>
|
||||
(c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() &&
|
||||
(c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase()
|
||||
)
|
||||
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName }));
|
||||
} else {
|
||||
const dbCols = await getColumnsByDB(tableIdent);
|
||||
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: shortTable }));
|
||||
const dbCols = await getColumnsByDB(tableInfo.tableName);
|
||||
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName }));
|
||||
}
|
||||
|
||||
const filtered = colPrefix
|
||||
? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix))
|
||||
const filtered = prefix
|
||||
? cols.filter(c => (c.name || '').toLowerCase().startsWith(prefix))
|
||||
: cols;
|
||||
|
||||
const suggestions = filtered.map(c => ({
|
||||
label: c.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: c.name,
|
||||
detail: c.type ? `${c.type}${c.tableName ? ` (${c.tableName})` : ''}` : (c.tableName ? `(${c.tableName})` : ''),
|
||||
detail: c.type ? `${c.type} (${c.dbName ? c.dbName + '.' : ''}${c.tableName})` : (c.tableName ? `(${c.tableName})` : ''),
|
||||
range,
|
||||
sortText: '0' + c.name
|
||||
}));
|
||||
@@ -310,35 +404,72 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
while ((match = tableRegex.exec(fullText)) !== null) {
|
||||
const t = normalizeQualifiedName(match[1] || '');
|
||||
if (!t) continue;
|
||||
foundTables.add(getLastPart(t).toLowerCase());
|
||||
// 存储完整标识 db.table 或 table
|
||||
foundTables.add(t.toLowerCase());
|
||||
}
|
||||
|
||||
const currentDatabase = currentDbRef.current || '';
|
||||
|
||||
// 相关列提示:匹配 SQL 中引用的表(FROM/JOIN 等)
|
||||
// 权重最高,输入 WHERE 条件时优先显示
|
||||
const relevantColumns = allColumnsRef.current
|
||||
.filter(c => foundTables.has((c.tableName || '').toLowerCase()))
|
||||
.map(c => ({
|
||||
label: c.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: c.name,
|
||||
detail: `${c.type} (${c.tableName})`,
|
||||
.filter(c => {
|
||||
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
|
||||
const shortIdent = (c.tableName || '').toLowerCase();
|
||||
return foundTables.has(fullIdent) || foundTables.has(shortIdent);
|
||||
})
|
||||
.map(c => {
|
||||
// 当前库的表字段优先级更高
|
||||
const isCurrentDb = (c.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
|
||||
return {
|
||||
label: c.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: c.name,
|
||||
detail: `${c.type} (${c.dbName}.${c.tableName})`,
|
||||
range,
|
||||
sortText: isCurrentDb ? '00' + c.name : '01' + c.name // FROM 表字段最优先
|
||||
};
|
||||
});
|
||||
|
||||
// 表提示:当前库显示表名,其他库显示 db.table 格式
|
||||
const tableSuggestions = tablesRef.current.map(t => {
|
||||
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
|
||||
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
|
||||
const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
|
||||
return {
|
||||
label,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText,
|
||||
detail: `Table (${t.dbName})`,
|
||||
range,
|
||||
sortText: '0' + c.name
|
||||
}));
|
||||
sortText: isCurrentDb ? '10' + t.tableName : '11' + t.tableName // 表次优先
|
||||
};
|
||||
});
|
||||
|
||||
// 数据库提示
|
||||
const dbSuggestions = visibleDbsRef.current.map(db => ({
|
||||
label: db,
|
||||
kind: monaco.languages.CompletionItemKind.Module,
|
||||
insertText: db,
|
||||
detail: 'Database',
|
||||
range,
|
||||
sortText: '20' + db // 数据库最后
|
||||
}));
|
||||
|
||||
// 关键字提示
|
||||
const keywordSuggestions = ['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
|
||||
label: k,
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: k,
|
||||
range,
|
||||
sortText: '30' + k // 关键字权重最低
|
||||
}));
|
||||
|
||||
const suggestions = [
|
||||
...['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
|
||||
label: k,
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: k,
|
||||
range
|
||||
})),
|
||||
...tablesRef.current.map(t => ({
|
||||
label: t,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: t,
|
||||
detail: 'Table',
|
||||
range
|
||||
})),
|
||||
...relevantColumns
|
||||
...relevantColumns, // FROM 表的列最优先
|
||||
...tableSuggestions, // 表次之
|
||||
...dbSuggestions, // 数据库
|
||||
...keywordSuggestions // 关键字最后
|
||||
];
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
@@ -48,7 +48,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
const removeConnection = useStore(state => state.removeConnection);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||
|
||||
// Background Helper (Duplicate logic for now, ideally shared)
|
||||
const getBg = (darkHex: string) => {
|
||||
if (!darkMode) return `rgba(255, 255, 255, ${appearance.opacity ?? 0.95})`;
|
||||
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}, ${appearance.opacity ?? 0.95})`;
|
||||
};
|
||||
const bgMain = getBg('#141414');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
const [autoExpandParent, setAutoExpandParent] = useState(true);
|
||||
@@ -979,7 +993,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
title: `新建查询`,
|
||||
type: 'query',
|
||||
connectionId: node.key,
|
||||
dbName: undefined
|
||||
dbName: undefined,
|
||||
query: ''
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1115,7 +1130,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
title: `新建查询 (${node.title})`,
|
||||
type: 'query',
|
||||
connectionId: node.dataRef.id,
|
||||
dbName: node.title
|
||||
dbName: node.title,
|
||||
query: ''
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1138,7 +1154,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
title: `新建查询`,
|
||||
type: 'query',
|
||||
connectionId: node.dataRef.id,
|
||||
dbName: node.dataRef.dbName
|
||||
dbName: node.dataRef.dbName,
|
||||
query: ''
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1212,7 +1229,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</div>
|
||||
|
||||
{/* Toolbar for batch operations - always visible */}
|
||||
<div style={{ padding: '4px 8px', borderBottom: '1px solid #f0f0f0', display: 'flex', gap: 4 }}>
|
||||
<div style={{ padding: '4px 8px', borderBottom: 'none', display: 'flex', gap: 4 }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CheckSquareOutlined />}
|
||||
@@ -1365,7 +1382,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ maxHeight: 400, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
||||
<div style={{ maxHeight: 400, overflow: 'auto', border: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
||||
<Checkbox.Group
|
||||
value={checkedTableKeys}
|
||||
onChange={(values) => setCheckedTableKeys(values as string[])}
|
||||
@@ -1456,7 +1473,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ maxHeight: 400, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
||||
<div style={{ maxHeight: 400, overflow: 'auto', border: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
||||
<Checkbox.Group
|
||||
value={checkedDbKeys}
|
||||
onChange={(values) => setCheckedDbKeys(values as string[])}
|
||||
|
||||
@@ -122,6 +122,9 @@ const TabManager: React.FC = () => {
|
||||
.main-tabs .ant-tabs-tabpane-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.main-tabs .ant-tabs-nav::before {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
`}</style>
|
||||
<Tabs
|
||||
className="main-tabs"
|
||||
|
||||
7
frontend/src/global.d.ts
vendored
7
frontend/src/global.d.ts
vendored
@@ -2,6 +2,13 @@ export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
go: any;
|
||||
runtime: {
|
||||
WindowMinimise: () => void;
|
||||
WindowToggleMaximise: () => void;
|
||||
Quit: () => void;
|
||||
BrowserOpenURL: (url: string) => void;
|
||||
};
|
||||
ipcRenderer: {
|
||||
send: (channel: string, ...args: any[]) => void;
|
||||
on: (channel: string, listener: (event: any, ...args: any[]) => void) => void;
|
||||
|
||||
@@ -19,7 +19,8 @@ interface AppState {
|
||||
activeTabId: string | null;
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
darkMode: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { opacity: number; blur: number };
|
||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||
queryOptions: { maxRows: number };
|
||||
sqlLogs: SqlLog[];
|
||||
@@ -40,7 +41,8 @@ interface AppState {
|
||||
saveQuery: (query: SavedQuery) => void;
|
||||
deleteQuery: (id: string) => void;
|
||||
|
||||
toggleDarkMode: () => void;
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
|
||||
|
||||
@@ -56,7 +58,8 @@ export const useStore = create<AppState>()(
|
||||
activeTabId: null,
|
||||
activeContext: null,
|
||||
savedQueries: [],
|
||||
darkMode: false,
|
||||
theme: 'light',
|
||||
appearance: { opacity: 0.95, blur: 0 },
|
||||
sqlFormatOptions: { keywordCase: 'upper' },
|
||||
queryOptions: { maxRows: 5000 },
|
||||
sqlLogs: [],
|
||||
@@ -125,7 +128,8 @@ export const useStore = create<AppState>()(
|
||||
|
||||
deleteQuery: (id) => set((state) => ({ savedQueries: state.savedQueries.filter(q => q.id !== id) })),
|
||||
|
||||
toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
|
||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
|
||||
|
||||
@@ -134,7 +138,7 @@ export const useStore = create<AppState>()(
|
||||
}),
|
||||
{
|
||||
name: 'lite-db-storage', // name of the item in the storage (must be unique)
|
||||
partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, darkMode: state.darkMode, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions }), // Don't persist logs
|
||||
partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions }), // Don't persist logs
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
8
frontend/wailsjs/go/app/App.d.ts
vendored
8
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -6,6 +6,8 @@ import {redis} from '../models';
|
||||
|
||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
|
||||
export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
@@ -34,6 +36,8 @@ export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise<connection.QueryRe
|
||||
|
||||
export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function DownloadUpdate():Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
|
||||
@@ -44,10 +48,14 @@ export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:st
|
||||
|
||||
export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>,arg4:boolean):Promise<connection.QueryResult>;
|
||||
|
||||
export function GetAppInfo():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportConfigFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
|
||||
|
||||
export function MySQLConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function MySQLGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -6,6 +6,10 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function CheckForUpdates() {
|
||||
return window['go']['app']['App']['CheckForUpdates']();
|
||||
}
|
||||
|
||||
export function CreateDatabase(arg1, arg2) {
|
||||
return window['go']['app']['App']['CreateDatabase'](arg1, arg2);
|
||||
}
|
||||
@@ -62,6 +66,10 @@ export function DataSyncPreview(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DownloadUpdate() {
|
||||
return window['go']['app']['App']['DownloadUpdate']();
|
||||
}
|
||||
|
||||
export function ExportData(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -82,6 +90,10 @@ export function ExportTablesSQL(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function GetAppInfo() {
|
||||
return window['go']['app']['App']['GetAppInfo']();
|
||||
}
|
||||
|
||||
export function ImportConfigFile() {
|
||||
return window['go']['app']['App']['ImportConfigFile']();
|
||||
}
|
||||
@@ -90,6 +102,10 @@ export function ImportData(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['ImportData'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function InstallUpdateAndRestart() {
|
||||
return window['go']['app']['App']['InstallUpdateAndRestart']();
|
||||
}
|
||||
|
||||
export function MySQLConnect(arg1) {
|
||||
return window['go']['app']['App']['MySQLConnect'](arg1);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ type App struct {
|
||||
ctx context.Context
|
||||
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||
mu sync.RWMutex // Mutex for cache access
|
||||
updateMu sync.Mutex
|
||||
updateState updateState
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
|
||||
606
internal/app/methods_update.go
Normal file
606
internal/app/methods_update.go
Normal file
@@ -0,0 +1,606 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
stdRuntime "runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
updateRepo = "Syngnat/GoNavi"
|
||||
updateAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest"
|
||||
updateChecksumAsset = "SHA256SUMS"
|
||||
)
|
||||
|
||||
type updateState struct {
|
||||
lastCheck *UpdateInfo
|
||||
downloading bool
|
||||
staged *stagedUpdate
|
||||
}
|
||||
|
||||
type UpdateInfo struct {
|
||||
HasUpdate bool `json:"hasUpdate"`
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
ReleaseName string `json:"releaseName"`
|
||||
ReleaseNotesURL string `json:"releaseNotesUrl"`
|
||||
AssetName string `json:"assetName"`
|
||||
AssetURL string `json:"assetUrl"`
|
||||
AssetSize int64 `json:"assetSize"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type AppInfo struct {
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
RepoURL string `json:"repoUrl,omitempty"`
|
||||
IssueURL string `json:"issueUrl,omitempty"`
|
||||
ReleaseURL string `json:"releaseUrl,omitempty"`
|
||||
BuildTime string `json:"buildTime,omitempty"`
|
||||
}
|
||||
|
||||
type stagedUpdate struct {
|
||||
Version string
|
||||
AssetName string
|
||||
FilePath string
|
||||
StagedDir string
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Assets []githubAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
func (a *App) CheckForUpdates() connection.QueryResult {
|
||||
info, err := fetchLatestUpdateInfo()
|
||||
if err != nil {
|
||||
logger.Error(err, "检查更新失败")
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
a.updateMu.Lock()
|
||||
a.updateState.lastCheck = &info
|
||||
a.updateMu.Unlock()
|
||||
|
||||
msg := "已是最新版本"
|
||||
if info.HasUpdate {
|
||||
msg = fmt.Sprintf("发现新版本:%s", info.LatestVersion)
|
||||
}
|
||||
return connection.QueryResult{Success: true, Message: msg, Data: info}
|
||||
}
|
||||
|
||||
func (a *App) GetAppInfo() connection.QueryResult {
|
||||
info := AppInfo{
|
||||
Version: getCurrentVersion(),
|
||||
Author: getCurrentAuthor(),
|
||||
RepoURL: "https://github.com/" + updateRepo,
|
||||
IssueURL: "https://github.com/" + updateRepo + "/issues",
|
||||
ReleaseURL: "https://github.com/" + updateRepo + "/releases",
|
||||
BuildTime: strings.TrimSpace(AppBuildTime),
|
||||
}
|
||||
return connection.QueryResult{Success: true, Message: "OK", Data: info}
|
||||
}
|
||||
|
||||
func (a *App) DownloadUpdate() connection.QueryResult {
|
||||
a.updateMu.Lock()
|
||||
if a.updateState.downloading {
|
||||
a.updateMu.Unlock()
|
||||
return connection.QueryResult{Success: false, Message: "更新包正在下载中,请稍后重试"}
|
||||
}
|
||||
info := a.updateState.lastCheck
|
||||
if info == nil {
|
||||
a.updateMu.Unlock()
|
||||
return connection.QueryResult{Success: false, Message: "请先检查更新"}
|
||||
}
|
||||
if !info.HasUpdate {
|
||||
a.updateMu.Unlock()
|
||||
return connection.QueryResult{Success: false, Message: "当前已是最新版本"}
|
||||
}
|
||||
if info.AssetURL == "" || info.AssetName == "" {
|
||||
a.updateMu.Unlock()
|
||||
return connection.QueryResult{Success: false, Message: "未找到可用的更新包"}
|
||||
}
|
||||
if a.updateState.staged != nil && a.updateState.staged.Version == info.LatestVersion {
|
||||
a.updateMu.Unlock()
|
||||
return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: info}
|
||||
}
|
||||
a.updateState.downloading = true
|
||||
a.updateMu.Unlock()
|
||||
|
||||
result := a.downloadAndStageUpdate(*info)
|
||||
|
||||
a.updateMu.Lock()
|
||||
a.updateState.downloading = false
|
||||
a.updateMu.Unlock()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *App) InstallUpdateAndRestart() connection.QueryResult {
|
||||
a.updateMu.Lock()
|
||||
staged := a.updateState.staged
|
||||
a.updateMu.Unlock()
|
||||
if staged == nil {
|
||||
return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"}
|
||||
}
|
||||
|
||||
if err := launchUpdateScript(staged); err != nil {
|
||||
logger.Error(err, "启动更新脚本失败")
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
wailsRuntime.Quit(a.ctx)
|
||||
}()
|
||||
|
||||
return connection.QueryResult{Success: true, Message: "更新已开始安装"}
|
||||
}
|
||||
|
||||
func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
|
||||
stagedDir, err := os.MkdirTemp("", "gonavi-update-")
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: "创建临时目录失败"}
|
||||
}
|
||||
|
||||
assetPath := filepath.Join(stagedDir, info.AssetName)
|
||||
actualHash, err := downloadFileWithHash(info.AssetURL, assetPath)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(stagedDir)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
if info.SHA256 == "" {
|
||||
_ = os.RemoveAll(stagedDir)
|
||||
return connection.QueryResult{Success: false, Message: "缺少更新包校验值(SHA256SUMS)"}
|
||||
}
|
||||
if !strings.EqualFold(info.SHA256, actualHash) {
|
||||
_ = os.RemoveAll(stagedDir)
|
||||
return connection.QueryResult{Success: false, Message: "更新包校验失败,请重试"}
|
||||
}
|
||||
|
||||
a.updateMu.Lock()
|
||||
a.updateState.staged = &stagedUpdate{
|
||||
Version: info.LatestVersion,
|
||||
AssetName: info.AssetName,
|
||||
FilePath: assetPath,
|
||||
StagedDir: stagedDir,
|
||||
}
|
||||
a.updateMu.Unlock()
|
||||
|
||||
return connection.QueryResult{Success: true, Message: "更新包下载完成", Data: info}
|
||||
}
|
||||
|
||||
func fetchLatestUpdateInfo() (UpdateInfo, error) {
|
||||
release, err := fetchLatestRelease()
|
||||
if err != nil {
|
||||
return UpdateInfo{}, err
|
||||
}
|
||||
|
||||
currentVersion := getCurrentVersion()
|
||||
latestVersion := normalizeVersion(release.TagName)
|
||||
if latestVersion == "" {
|
||||
return UpdateInfo{}, errors.New("无法解析最新版本号")
|
||||
}
|
||||
|
||||
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH)
|
||||
if err != nil {
|
||||
return UpdateInfo{}, err
|
||||
}
|
||||
asset, err := findReleaseAsset(release.Assets, assetName)
|
||||
if err != nil {
|
||||
return UpdateInfo{}, err
|
||||
}
|
||||
|
||||
hashMap, err := fetchReleaseSHA256(release.Assets)
|
||||
if err != nil {
|
||||
return UpdateInfo{}, err
|
||||
}
|
||||
sha256Value := strings.TrimSpace(hashMap[assetName])
|
||||
if sha256Value == "" {
|
||||
return UpdateInfo{}, errors.New("SHA256SUMS 未包含当前平台更新包")
|
||||
}
|
||||
|
||||
hasUpdate := compareVersion(currentVersion, latestVersion) < 0
|
||||
|
||||
return UpdateInfo{
|
||||
HasUpdate: hasUpdate,
|
||||
CurrentVersion: currentVersion,
|
||||
LatestVersion: latestVersion,
|
||||
ReleaseName: release.Name,
|
||||
ReleaseNotesURL: release.HTMLURL,
|
||||
AssetName: asset.Name,
|
||||
AssetURL: asset.BrowserDownloadURL,
|
||||
AssetSize: asset.Size,
|
||||
SHA256: sha256Value,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getCurrentAuthor() string {
|
||||
if env := strings.TrimSpace(os.Getenv("GONAVI_AUTHOR")); env != "" {
|
||||
return env
|
||||
}
|
||||
parts := strings.Split(updateRepo, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchLatestRelease() (*githubRelease, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, updateAPIURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "GoNavi-Updater")
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("检查更新失败:HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func expectedAssetName(goos, goarch string) (string, error) {
|
||||
switch goos {
|
||||
case "windows":
|
||||
if goarch == "amd64" {
|
||||
return "GoNavi-windows-amd64.exe", nil
|
||||
}
|
||||
if goarch == "arm64" {
|
||||
return "GoNavi-windows-arm64.exe", nil
|
||||
}
|
||||
case "darwin":
|
||||
if goarch == "amd64" {
|
||||
return "GoNavi-mac-amd64.dmg", nil
|
||||
}
|
||||
if goarch == "arm64" {
|
||||
return "GoNavi-mac-arm64.dmg", nil
|
||||
}
|
||||
case "linux":
|
||||
if goarch == "amd64" {
|
||||
return "GoNavi-linux-amd64.tar.gz", nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("当前平台暂不支持在线更新:%s/%s", goos, goarch)
|
||||
}
|
||||
|
||||
func findReleaseAsset(assets []githubAsset, name string) (*githubAsset, error) {
|
||||
for _, asset := range assets {
|
||||
if asset.Name == name {
|
||||
return &asset, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("未找到更新包:%s", name)
|
||||
}
|
||||
|
||||
func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) {
|
||||
var checksumURL string
|
||||
for _, asset := range assets {
|
||||
if strings.EqualFold(asset.Name, updateChecksumAsset) || strings.Contains(strings.ToLower(asset.Name), "sha256sums") {
|
||||
checksumURL = asset.BrowserDownloadURL
|
||||
break
|
||||
}
|
||||
}
|
||||
if checksumURL == "" {
|
||||
return nil, errors.New("Release 未提供 SHA256SUMS")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, checksumURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "GoNavi-Updater")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("下载 SHA256SUMS 失败:HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parseSHA256Sums(string(body)), nil
|
||||
}
|
||||
|
||||
func parseSHA256Sums(content string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
hash := fields[0]
|
||||
name := fields[len(fields)-1]
|
||||
name = strings.TrimPrefix(name, "*")
|
||||
name = strings.TrimPrefix(name, "./")
|
||||
result[name] = hash
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func downloadFileWithHash(url, filePath string) (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "GoNavi-Updater")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("下载更新包失败:HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
writer := io.MultiWriter(out, hasher)
|
||||
if _, err := io.Copy(writer, resp.Body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func launchUpdateScript(staged *stagedUpdate) error {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exePath, _ = filepath.EvalSymlinks(exePath)
|
||||
pid := os.Getpid()
|
||||
|
||||
switch stdRuntime.GOOS {
|
||||
case "windows":
|
||||
return launchWindowsUpdate(staged, exePath, pid)
|
||||
case "darwin":
|
||||
return launchMacUpdate(staged, exePath, pid)
|
||||
case "linux":
|
||||
return launchLinuxUpdate(staged, exePath, pid)
|
||||
default:
|
||||
return fmt.Errorf("当前平台暂不支持更新安装:%s", stdRuntime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
||||
scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
|
||||
content := buildWindowsScript(staged.FilePath, targetExe, staged.StagedDir, pid)
|
||||
if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("cmd", "/C", "start", "", scriptPath)
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
||||
targetApp := detectMacAppPath(targetExe)
|
||||
if targetApp == "" {
|
||||
targetApp = "/Applications/GoNavi.app"
|
||||
}
|
||||
mountDir := filepath.Join(staged.StagedDir, "mnt")
|
||||
if err := os.MkdirAll(mountDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scriptPath := filepath.Join(staged.StagedDir, "update.sh")
|
||||
content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, pid)
|
||||
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("/bin/sh", scriptPath)
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
||||
scriptPath := filepath.Join(staged.StagedDir, "update.sh")
|
||||
content := buildLinuxScript(staged.FilePath, targetExe, staged.StagedDir, pid)
|
||||
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("/bin/sh", scriptPath)
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func buildWindowsScript(source, target, stagedDir string, pid int) string {
|
||||
return fmt.Sprintf(`@echo off
|
||||
setlocal
|
||||
set "SOURCE=%s"
|
||||
set "TARGET=%s"
|
||||
set "STAGED=%s"
|
||||
set PID=%d
|
||||
:waitloop
|
||||
tasklist /FI "PID eq %%PID%%" | find "%%PID%%" >nul
|
||||
if %%ERRORLEVEL%%==0 (
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto waitloop
|
||||
)
|
||||
move /Y "%%SOURCE%%" "%%TARGET%%" >nul
|
||||
start "" "%%TARGET%%"
|
||||
rmdir /S /Q "%%STAGED%%"
|
||||
exit /b 0
|
||||
`, source, target, stagedDir, pid)
|
||||
}
|
||||
|
||||
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir string, pid int) string {
|
||||
return fmt.Sprintf(`#!/bin/bash
|
||||
set -e
|
||||
PID=%d
|
||||
DMG="%s"
|
||||
TARGET_APP="%s"
|
||||
STAGED="%s"
|
||||
MOUNT_DIR="%s"
|
||||
while kill -0 $PID 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MOUNT_DIR"
|
||||
APP_SRC=$(ls "$MOUNT_DIR"/*.app 2>/dev/null | head -n 1)
|
||||
if [ -z "$APP_SRC" ]; then
|
||||
hdiutil detach "$MOUNT_DIR" -quiet || true
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$TARGET_APP"
|
||||
cp -R "$APP_SRC" "$TARGET_APP"
|
||||
hdiutil detach "$MOUNT_DIR" -quiet
|
||||
rm -rf "$MOUNT_DIR" "$DMG" "$STAGED"
|
||||
open "$TARGET_APP"
|
||||
`, pid, dmgPath, targetApp, stagedDir, mountDir)
|
||||
}
|
||||
|
||||
func buildLinuxScript(tarPath, targetExe, stagedDir string, pid int) string {
|
||||
return fmt.Sprintf(`#!/bin/bash
|
||||
set -e
|
||||
PID=%d
|
||||
ARCHIVE="%s"
|
||||
TARGET="%s"
|
||||
STAGED="%s"
|
||||
while kill -0 $PID 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
TMPDIR=$(mktemp -d)
|
||||
tar -xzf "$ARCHIVE" -C "$TMPDIR"
|
||||
NEWBIN="$TMPDIR/GoNavi"
|
||||
if [ ! -f "$NEWBIN" ]; then
|
||||
NEWBIN=$(find "$TMPDIR" -type f -name "GoNavi" | head -n 1)
|
||||
fi
|
||||
if [ -z "$NEWBIN" ] || [ ! -f "$NEWBIN" ]; then
|
||||
exit 1
|
||||
fi
|
||||
cp -f "$NEWBIN" "$TARGET"
|
||||
chmod +x "$TARGET"
|
||||
rm -rf "$TMPDIR" "$ARCHIVE" "$STAGED"
|
||||
"$TARGET" &
|
||||
`, pid, tarPath, targetExe, stagedDir)
|
||||
}
|
||||
|
||||
func detectMacAppPath(exePath string) string {
|
||||
parts := strings.Split(exePath, string(filepath.Separator))
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
if strings.HasSuffix(parts[i], ".app") {
|
||||
return filepath.Join(parts[:i+1]...)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeVersion(version string) string {
|
||||
version = strings.TrimSpace(version)
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
return version
|
||||
}
|
||||
|
||||
func compareVersion(current, latest string) int {
|
||||
current = normalizeVersion(current)
|
||||
latest = normalizeVersion(latest)
|
||||
if current == "" {
|
||||
return -1
|
||||
}
|
||||
if current == latest {
|
||||
return 0
|
||||
}
|
||||
|
||||
curParts := splitVersionParts(current)
|
||||
latParts := splitVersionParts(latest)
|
||||
max := len(curParts)
|
||||
if len(latParts) > max {
|
||||
max = len(latParts)
|
||||
}
|
||||
for i := 0; i < max; i++ {
|
||||
cur := 0
|
||||
lat := 0
|
||||
if i < len(curParts) {
|
||||
cur = curParts[i]
|
||||
}
|
||||
if i < len(latParts) {
|
||||
lat = latParts[i]
|
||||
}
|
||||
if cur < lat {
|
||||
return -1
|
||||
}
|
||||
if cur > lat {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func splitVersionParts(version string) []int {
|
||||
parts := strings.Split(version, ".")
|
||||
result := make([]int, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
result = append(result, 0)
|
||||
continue
|
||||
}
|
||||
num := 0
|
||||
for _, ch := range part {
|
||||
if ch < '0' || ch > '9' {
|
||||
break
|
||||
}
|
||||
num = num*10 + int(ch-'0')
|
||||
}
|
||||
result = append(result, num)
|
||||
}
|
||||
return result
|
||||
}
|
||||
53
internal/app/version.go
Normal file
53
internal/app/version.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var AppVersion = "0.0.0"
|
||||
var AppBuildTime = ""
|
||||
|
||||
func getCurrentVersion() string {
|
||||
version := strings.TrimSpace(AppVersion)
|
||||
if version == "" || version == "0.0.0" {
|
||||
if env := strings.TrimSpace(os.Getenv("GONAVI_VERSION")); env != "" {
|
||||
version = env
|
||||
} else if pkgVersion, err := readPackageVersion(); err == nil && pkgVersion != "" {
|
||||
version = pkgVersion
|
||||
}
|
||||
}
|
||||
return normalizeVersion(version)
|
||||
}
|
||||
|
||||
func readPackageVersion() (string, error) {
|
||||
paths := []string{
|
||||
filepath.Join("frontend", "package.json"),
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
base := filepath.Dir(exe)
|
||||
paths = append(paths, filepath.Join(base, "frontend", "package.json"))
|
||||
paths = append(paths, filepath.Join(base, "..", "frontend", "package.json"))
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var payload struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(payload.Version) != "" {
|
||||
return strings.TrimSpace(payload.Version), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
17
main.go
17
main.go
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
@@ -20,18 +21,26 @@ func main() {
|
||||
|
||||
// Create application with options
|
||||
err := wails.Run(&options.App{
|
||||
Title: "GoNavi",
|
||||
Width: 1024,
|
||||
Height: 768,
|
||||
Title: "GoNavi",
|
||||
Width: 1024,
|
||||
Height: 768,
|
||||
Frameless: true,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0},
|
||||
OnStartup: application.Startup,
|
||||
OnShutdown: application.Shutdown,
|
||||
Bind: []interface{}{
|
||||
application,
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
WebviewIsTransparent: true,
|
||||
WindowIsTranslucent: true,
|
||||
BackdropType: windows.Acrylic,
|
||||
DisableWindowIcon: false,
|
||||
DisableFramelessWindowDecorations: false,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user