mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-21 16:23:29 +08:00
Compare commits
27 Commits
release/0.
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b108cd1c90 | ||
|
|
d1ce9cefb8 | ||
|
|
f75e04f091 | ||
|
|
1fc182817e | ||
|
|
3c28b0adeb | ||
|
|
ec4b3d9018 | ||
|
|
8654485cfe | ||
|
|
9beb73ea40 | ||
|
|
3b19a33d4b | ||
|
|
13ba78103c | ||
|
|
538e4a1506 | ||
|
|
934581c796 | ||
|
|
1486b98d27 | ||
|
|
6cda430f03 | ||
|
|
f56c3d5f6e | ||
|
|
74c9143c95 | ||
|
|
0e4a833ffa | ||
|
|
37ad9885b7 | ||
|
|
5cef9a4032 | ||
|
|
f49767c38b | ||
|
|
7e8699ba02 | ||
|
|
5f0ce5ed7a | ||
|
|
49c7620bdd | ||
|
|
80fa7a1acd | ||
|
|
68770a42e2 | ||
|
|
06aebf716e | ||
|
|
f551b19f40 |
22
.github/workflows/release-winget.yml
vendored
Normal file
22
.github/workflows/release-winget.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: Publish to WinGet
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
required: true
|
||||||
|
description: 'Tag of release you want to publish'
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: vedantmgoyal9/winget-releaser@v2
|
||||||
|
with:
|
||||||
|
identifier: Syngnat.GoNavi
|
||||||
|
installers-regex: 'GoNavi-windows-(amd64|arm64)\.exe$'
|
||||||
|
release-tag: ${{ inputs.release_tag || github.ref_name }}
|
||||||
|
token: ${{ secrets.WINGET_TOKEN }}
|
||||||
127
.github/workflows/release.yml
vendored
127
.github/workflows/release.yml
vendored
@@ -29,6 +29,13 @@ jobs:
|
|||||||
platform: windows/amd64
|
platform: windows/amd64
|
||||||
artifact_name: GoNavi-windows-amd64
|
artifact_name: GoNavi-windows-amd64
|
||||||
asset_ext: .exe
|
asset_ext: .exe
|
||||||
|
- os: windows-latest
|
||||||
|
platform: windows/arm64
|
||||||
|
artifact_name: GoNavi-windows-arm64
|
||||||
|
asset_ext: .exe
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
platform: linux/amd64
|
||||||
|
artifact_name: GoNavi-linux-amd64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -45,13 +52,43 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
|
||||||
|
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
|
||||||
|
- name: Install Linux Dependencies
|
||||||
|
if: contains(matrix.platform, 'linux')
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libfuse2
|
||||||
|
|
||||||
|
# Download linuxdeploy tools for AppImage packaging
|
||||||
|
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||||
|
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
|
||||||
|
|
||||||
|
echo "📥 下载 linuxdeploy..."
|
||||||
|
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
|
||||||
|
-O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
|
||||||
|
echo "⚠️ linuxdeploy 下载失败,AppImage 打包将跳过"
|
||||||
|
touch /tmp/skip-appimage
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "📥 下载 linuxdeploy-plugin-gtk..."
|
||||||
|
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
|
||||||
|
-O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
|
||||||
|
echo "⚠️ linuxdeploy-plugin-gtk 下载失败,AppImage 打包将跳过"
|
||||||
|
touch /tmp/skip-appimage
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f /tmp/skip-appimage ]; then
|
||||||
|
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
|
||||||
|
echo "✅ AppImage 工具准备完成"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Install Wails
|
- name: Install Wails
|
||||||
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
|
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
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
|
# macOS Packaging
|
||||||
- name: Package macOS DMG
|
- name: Package macOS DMG
|
||||||
@@ -107,12 +144,93 @@ jobs:
|
|||||||
echo "📦 正在移动 $FINAL_EXE 到根目录..."
|
echo "📦 正在移动 $FINAL_EXE 到根目录..."
|
||||||
mv "$FINAL_EXE" "../../$FINAL_EXE"
|
mv "$FINAL_EXE" "../../$FINAL_EXE"
|
||||||
|
|
||||||
|
# Linux Packaging (tar.gz and AppImage)
|
||||||
|
- name: Package Linux
|
||||||
|
if: contains(matrix.platform, 'linux')
|
||||||
|
run: |
|
||||||
|
cd build/bin
|
||||||
|
TARGET="${{ matrix.artifact_name }}"
|
||||||
|
|
||||||
|
if [ ! -f "$TARGET" ]; then
|
||||||
|
echo "❌ 未找到构建产物 '$TARGET'!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$TARGET"
|
||||||
|
|
||||||
|
# 1. Create tar.gz
|
||||||
|
echo "📦 正在打包 $TARGET.tar.gz..."
|
||||||
|
tar -czvf "$TARGET.tar.gz" "$TARGET"
|
||||||
|
mv "$TARGET.tar.gz" ../../
|
||||||
|
|
||||||
|
# 2. Create AppImage (skip for ARM64 or if tools unavailable)
|
||||||
|
if [ -f /tmp/skip-appimage ]; then
|
||||||
|
echo "⚠️ 跳过 AppImage 打包"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 正在生成 AppImage..."
|
||||||
|
|
||||||
|
# Create AppDir structure
|
||||||
|
mkdir -p AppDir/usr/bin
|
||||||
|
mkdir -p AppDir/usr/share/applications
|
||||||
|
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
|
||||||
|
|
||||||
|
cp "$TARGET" AppDir/usr/bin/gonavi
|
||||||
|
|
||||||
|
# Create desktop file
|
||||||
|
printf '%s\n' \
|
||||||
|
'[Desktop Entry]' \
|
||||||
|
'Name=GoNavi' \
|
||||||
|
'Exec=gonavi' \
|
||||||
|
'Icon=gonavi' \
|
||||||
|
'Type=Application' \
|
||||||
|
'Categories=Development;Database;' \
|
||||||
|
'Comment=Database Management Tool' \
|
||||||
|
> AppDir/usr/share/applications/gonavi.desktop
|
||||||
|
|
||||||
|
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
|
||||||
|
|
||||||
|
# Create a simple icon (or use existing if available)
|
||||||
|
if [ -f "../../build/appicon.png" ]; then
|
||||||
|
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||||
|
cp "../../build/appicon.png" AppDir/gonavi.png
|
||||||
|
else
|
||||||
|
# Create a placeholder icon
|
||||||
|
convert -size 256x256 xc:#336791 -fill white -gravity center -pointsize 48 -annotate 0 "GoNavi" AppDir/gonavi.png || \
|
||||||
|
wget -q "https://via.placeholder.com/256/336791/FFFFFF?text=GoNavi" -O AppDir/gonavi.png || \
|
||||||
|
touch AppDir/gonavi.png
|
||||||
|
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build AppImage
|
||||||
|
export DEPLOY_GTK_VERSION=3
|
||||||
|
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || {
|
||||||
|
echo "⚠️ AppImage 生成失败,但 tar.gz 已成功生成"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rename output
|
||||||
|
mv GoNavi*.AppImage "$TARGET.AppImage" 2>/dev/null || {
|
||||||
|
echo "⚠️ AppImage 重命名失败"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -f "$TARGET.AppImage" ]; then
|
||||||
|
mv "$TARGET.AppImage" ../../
|
||||||
|
echo "✅ AppImage 生成成功"
|
||||||
|
fi
|
||||||
|
|
||||||
# Upload to Actions Artifacts (Temporary Storage)
|
# Upload to Actions Artifacts (Temporary Storage)
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: build-artifacts-${{ strategy.job-index }} # Unique name per job
|
name: build-artifacts-${{ strategy.job-index }} # Unique name per job
|
||||||
path: GoNavi-*${{ matrix.asset_ext }}
|
path: |
|
||||||
|
GoNavi-*.dmg
|
||||||
|
GoNavi-*.exe
|
||||||
|
GoNavi-*.tar.gz
|
||||||
|
GoNavi-*.AppImage
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
# Phase 2: Collect all artifacts and Publish Release (Single Job)
|
# Phase 2: Collect all artifacts and Publish Release (Single Job)
|
||||||
@@ -131,6 +249,11 @@ jobs:
|
|||||||
- name: List Assets
|
- name: List Assets
|
||||||
run: ls -R release-assets
|
run: ls -R release-assets
|
||||||
|
|
||||||
|
- name: Generate SHA256SUMS
|
||||||
|
run: |
|
||||||
|
cd release-assets
|
||||||
|
sha256sum * > SHA256SUMS
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,7 +6,7 @@
|
|||||||
frontend/release/
|
frontend/release/
|
||||||
**/release/
|
**/release/
|
||||||
**/dist/
|
**/dist/
|
||||||
**/build/
|
build/bin/
|
||||||
|
|
||||||
# wails / node artifacts (按需)
|
# wails / node artifacts (按需)
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -17,3 +17,5 @@ dist/
|
|||||||
GoNavi-Wails
|
GoNavi-Wails
|
||||||
GoNavi-Wails.exe
|
GoNavi-Wails.exe
|
||||||
.ace-tool/
|
.ace-tool/
|
||||||
|
.claude/
|
||||||
|
tmpclaude-*
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -31,16 +31,44 @@
|
|||||||
- **虚拟滚动**:轻松处理海量数据展示,拒绝卡顿。
|
- **虚拟滚动**:轻松处理海量数据展示,拒绝卡顿。
|
||||||
|
|
||||||
### 🔌 多数据库支持
|
### 🔌 多数据库支持
|
||||||
- **MySQL**:完整的支持,包括表结构设计、索引管理、外键管理等。
|
- **MySQL**:完整支持,涵盖数据编辑、结构管理与导入导出。
|
||||||
- **PostgreSQL**:基础支持(持续完善中)。
|
- **PostgreSQL**:数据查看与编辑支持,事务提交能力持续完善。
|
||||||
- **SQLite**:本地文件数据库支持。
|
- **SQLite**:本地文件数据库支持。
|
||||||
|
- **Oracle**:基础数据访问与编辑支持。
|
||||||
|
- **Dameng(达梦)**:基础数据访问与编辑支持。
|
||||||
|
- **Kingbase(人大金仓)**:基础数据访问与编辑支持。
|
||||||
|
- **Redis**:Key/Value 浏览、命令执行、视图与编码切换。
|
||||||
|
- **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。
|
||||||
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
|
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
|
||||||
|
|
||||||
### 📊 强大的数据管理 (DataGrid)
|
### 📊 强大的数据管理 (DataGrid)
|
||||||
- **所见即所得编辑**:直接在表格中双击单元格修改数据。
|
- **所见即所得编辑**:直接在表格中双击单元格修改数据。
|
||||||
- **事务操作**:支持批量新增、修改、删除,一键提交或回滚事务。
|
- **批量事务操作**:支持批量新增、修改、删除,一键提交或回滚事务。
|
||||||
|
- **大字段编辑**:双击大字段自动打开弹窗编辑器,避免卡顿。
|
||||||
|
- **右键上下文菜单**:快速设置 NULL、复制/导出等操作。
|
||||||
- **智能上下文**:自动识别单表查询,解锁编辑功能;复杂查询自动切换为只读模式。
|
- **智能上下文**:自动识别单表查询,解锁编辑功能;复杂查询自动切换为只读模式。
|
||||||
- **数据导出**:支持导出为 CSV, Excel (XLSX), JSON, Markdown 等格式。
|
- **批量导出/备份**:支持表与数据库的批量导出/备份。
|
||||||
|
- **数据导出**:支持 CSV、Excel (XLSX)、JSON、Markdown 等格式。
|
||||||
|
|
||||||
|
### 🧰 批量导出/备份
|
||||||
|
- **数据库批量导出**:支持结构导出与结构+数据备份。
|
||||||
|
- **表批量导出**:支持多表一键导出/备份。
|
||||||
|
- **智能上下文检测**:自动判断目标范围,避免误操作。
|
||||||
|
|
||||||
|
### 🧩 Redis 视图与编码
|
||||||
|
- **视图模式切换**:自动/原始文本/UTF-8/十六进制多模式显示。
|
||||||
|
- **智能解码**:针对二进制值进行 UTF-8 质量判定与中文字符识别。
|
||||||
|
- **命令执行**:内置命令面板快速操作。
|
||||||
|
|
||||||
|
### 🔄 数据同步与导入导出
|
||||||
|
- **连接配置导入/导出**:支持配置 JSON 导入导出,便于团队共享。
|
||||||
|
- **数据同步**:内置数据同步面板,支持跨库同步任务配置。
|
||||||
|
|
||||||
|
### 🆙 在线更新
|
||||||
|
- **自动更新**:启动/定时/手动检查更新,自动下载并提示重启完成更新。
|
||||||
|
|
||||||
|
### 🧾 可观测性
|
||||||
|
- **SQL 执行日志**:实时查看 SQL 与执行耗时,便于排障与优化。
|
||||||
|
|
||||||
### 📝 智能 SQL 编辑器
|
### 📝 智能 SQL 编辑器
|
||||||
- **Monaco Editor 内核**:集成 VS Code 同款编辑器,体验极佳。
|
- **Monaco Editor 内核**:集成 VS Code 同款编辑器,体验极佳。
|
||||||
|
|||||||
141
build-release.sh
141
build-release.sh
@@ -12,6 +12,7 @@ if [ -z "$VERSION" ]; then
|
|||||||
VERSION="0.0.0"
|
VERSION="0.0.0"
|
||||||
fi
|
fi
|
||||||
echo "ℹ️ 检测到版本号: $VERSION"
|
echo "ℹ️ 检测到版本号: $VERSION"
|
||||||
|
LDFLAGS="-X GoNavi-Wails/internal/app.AppVersion=$VERSION"
|
||||||
|
|
||||||
# 颜色配置
|
# 颜色配置
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
@@ -27,7 +28,7 @@ mkdir -p $DIST_DIR
|
|||||||
|
|
||||||
# --- macOS ARM64 构建 ---
|
# --- macOS ARM64 构建 ---
|
||||||
echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}"
|
echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}"
|
||||||
wails build -platform darwin/arm64 -clean
|
wails build -platform darwin/arm64 -clean -ldflags "$LDFLAGS"
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
||||||
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app"
|
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app"
|
||||||
@@ -81,7 +82,7 @@ fi
|
|||||||
|
|
||||||
# --- macOS AMD64 构建 ---
|
# --- macOS AMD64 构建 ---
|
||||||
echo -e "${GREEN}🍎 正在构建 macOS (amd64)...${NC}"
|
echo -e "${GREEN}🍎 正在构建 macOS (amd64)...${NC}"
|
||||||
wails build -platform darwin/amd64 -clean
|
wails build -platform darwin/amd64 -clean -ldflags "$LDFLAGS"
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
||||||
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-amd64.app"
|
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-amd64.app"
|
||||||
@@ -131,19 +132,147 @@ fi
|
|||||||
# --- Windows AMD64 构建 ---
|
# --- Windows AMD64 构建 ---
|
||||||
echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}"
|
echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}"
|
||||||
if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
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
|
if [ $? -eq 0 ]; then
|
||||||
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
|
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
|
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||||
else
|
else
|
||||||
echo -e "${RED} ❌ Windows 构建失败。${NC}"
|
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW} ⚠️ 未找到 MinGW 工具 (x86_64-w64-mingw32-gcc),跳过 Windows 构建。${NC}"
|
echo -e "${YELLOW} ⚠️ 未找到 MinGW 工具 (x86_64-w64-mingw32-gcc),跳过 Windows amd64 构建。${NC}"
|
||||||
|
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 -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"
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW} ⚠️ 未找到 MinGW ARM64 工具 (aarch64-w64-mingw32-gcc),跳过 Windows arm64 构建。${NC}"
|
||||||
|
echo " 安装命令: brew install mingw-w64 (需要支持 ARM64 的版本)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Linux AMD64 构建 ---
|
||||||
|
echo -e "${GREEN}🐧 正在构建 Linux (amd64)...${NC}"
|
||||||
|
# 检测当前系统
|
||||||
|
CURRENT_OS=$(uname -s)
|
||||||
|
CURRENT_ARCH=$(uname -m)
|
||||||
|
|
||||||
|
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
|
||||||
|
# 本机 Linux amd64,直接构建
|
||||||
|
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"
|
||||||
|
# 打包为 tar.gz
|
||||||
|
cd "$DIST_DIR"
|
||||||
|
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
|
||||||
|
rm "${APP_NAME}-${VERSION}-linux-amd64"
|
||||||
|
cd ..
|
||||||
|
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ Linux amd64 构建失败。${NC}"
|
||||||
|
fi
|
||||||
|
elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
|
||||||
|
# macOS 或其他系统,尝试交叉编译
|
||||||
|
export CC=x86_64-linux-gnu-gcc
|
||||||
|
export CXX=x86_64-linux-gnu-g++
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
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"
|
||||||
|
cd "$DIST_DIR"
|
||||||
|
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
|
||||||
|
rm "${APP_NAME}-${VERSION}-linux-amd64"
|
||||||
|
cd ..
|
||||||
|
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ Linux amd64 交叉编译失败。${NC}"
|
||||||
|
fi
|
||||||
|
unset CC CXX CGO_ENABLED
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW} ⚠️ 非 Linux 系统且未找到交叉编译工具,跳过 Linux amd64 构建。${NC}"
|
||||||
|
echo " 在 Linux 上运行此脚本可直接构建,或安装交叉编译工具链。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Linux ARM64 构建 ---
|
||||||
|
echo -e "${GREEN}🐧 正在构建 Linux (arm64)...${NC}"
|
||||||
|
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
|
||||||
|
# 本机 Linux arm64,直接构建
|
||||||
|
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"
|
||||||
|
cd "$DIST_DIR"
|
||||||
|
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
|
||||||
|
rm "${APP_NAME}-${VERSION}-linux-arm64"
|
||||||
|
cd ..
|
||||||
|
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ Linux arm64 构建失败。${NC}"
|
||||||
|
fi
|
||||||
|
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 -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"
|
||||||
|
cd "$DIST_DIR"
|
||||||
|
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
|
||||||
|
rm "${APP_NAME}-${VERSION}-linux-arm64"
|
||||||
|
cd ..
|
||||||
|
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ Linux arm64 交叉编译失败。${NC}"
|
||||||
|
fi
|
||||||
|
unset CC CXX CGO_ENABLED
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW} ⚠️ 非 Linux ARM64 系统且未找到交叉编译工具,跳过 Linux arm64 构建。${NC}"
|
||||||
|
echo " 安装命令 (Ubuntu): sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu"
|
||||||
|
echo " 安装命令 (macOS): brew install aarch64-linux-gnu-gcc (需要第三方 tap)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 清理中间构建目录
|
# 清理中间构建目录
|
||||||
rm -rf "build/bin"
|
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}"
|
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
|
||||||
ls -1 "$DIST_DIR"
|
ls -lh "$DIST_DIR"
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}📋 支持的平台:${NC}"
|
||||||
|
echo " • macOS (Intel/Apple Silicon): .dmg"
|
||||||
|
echo " • Windows (x64/ARM64): .exe"
|
||||||
|
echo " • Linux (x64/ARM64): .tar.gz"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}💡 提示:Linux AppImage 包请使用 GitHub Actions CI/CD 构建。${NC}"
|
||||||
|
|||||||
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>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>GoNavi</title>
|
<title>GoNavi</title>
|
||||||
</head>
|
</head>
|
||||||
@@ -10,4 +10,4 @@
|
|||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
5b8157374dae5f9340e31b2d0bd2c00e
|
d0f9366af59a6367ad3c7e2d4185ead4
|
||||||
52
frontend/public/logo.svg
Normal file
52
frontend/public/logo.svg
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<!-- Background: Soft Light Grey -->
|
||||||
|
<linearGradient id="bgSoft" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#f5f7fa;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#c3cfe2;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Hexagon: Solid Tech Pink -->
|
||||||
|
<linearGradient id="solidPink" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#FF5F6D;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#FFC371;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- N: Solid Tech Blue/Cyan -->
|
||||||
|
<linearGradient id="solidCyan" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#00c6ff;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#0072ff;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id="hardShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
|
||||||
|
<feOffset dx="4" dy="4" result="offsetblur"/>
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope="0.2"/>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<rect x="32" y="32" width="448" height="448" rx="100" fill="url(#bgSoft)" />
|
||||||
|
|
||||||
|
<!-- Main Content Centered -->
|
||||||
|
<g transform="translate(106, 106) scale(0.6)" filter="url(#hardShadow)">
|
||||||
|
|
||||||
|
<!-- Hex G -->
|
||||||
|
<path d="M 250 0 L 466 125 L 466 375 L 250 500 L 34 375 L 34 125 Z"
|
||||||
|
fill="none" stroke="url(#solidPink)" stroke-width="45" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- G Crossbar -->
|
||||||
|
<path d="M 466 300 L 330 300" stroke="url(#solidPink)" stroke-width="45" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Inner N -->
|
||||||
|
<path d="M 160 350 L 160 150 L 340 350 L 340 150"
|
||||||
|
fill="none" stroke="url(#solidCyan)" stroke-width="50" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -3,6 +3,7 @@ html, body, #root {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden; /* Disable global scrollbar */
|
overflow: hidden; /* Disable global scrollbar */
|
||||||
|
background-color: transparent !important; /* CRITICAL: Allow Wails window transparency */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 侧边栏 Tree 样式优化 */
|
/* 侧边栏 Tree 样式优化 */
|
||||||
@@ -30,4 +31,40 @@ html, body, #root {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding-right: 8px;
|
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 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 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 Sidebar from './components/Sidebar';
|
||||||
import TabManager from './components/TabManager';
|
import TabManager from './components/TabManager';
|
||||||
import ConnectionModal from './components/ConnectionModal';
|
import ConnectionModal from './components/ConnectionModal';
|
||||||
@@ -17,7 +17,153 @@ function App() {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
|
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
|
||||||
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
|
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
|
||||||
const { darkMode, toggleDarkMode, addTab, activeContext, connections, addConnection, tabs, activeTabId } = useStore();
|
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 = () => {
|
const handleNewQuery = () => {
|
||||||
let connId = activeContext?.connectionId || '';
|
let connId = activeContext?.connectionId || '';
|
||||||
@@ -37,7 +183,8 @@ function App() {
|
|||||||
title: '新建查询',
|
title: '新建查询',
|
||||||
type: 'query',
|
type: 'query',
|
||||||
connectionId: connId,
|
connectionId: connId,
|
||||||
dbName: db
|
dbName: db,
|
||||||
|
query: ''
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,13 +226,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsMenu: MenuProps['items'] = [
|
const toolsMenu: MenuProps['items'] = [
|
||||||
{
|
|
||||||
key: 'sync',
|
|
||||||
label: '数据同步',
|
|
||||||
icon: <UploadOutlined rotate={90} />,
|
|
||||||
onClick: () => setIsSyncModalOpen(true)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'import',
|
key: 'import',
|
||||||
label: '导入连接配置',
|
label: '导入连接配置',
|
||||||
@@ -97,9 +238,40 @@ function App() {
|
|||||||
label: '导出连接配置',
|
label: '导出连接配置',
|
||||||
icon: <DownloadOutlined />,
|
icon: <DownloadOutlined />,
|
||||||
onClick: handleExportConnections
|
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
|
// Log Panel
|
||||||
const [logPanelHeight, setLogPanelHeight] = useState(200);
|
const [logPanelHeight, setLogPanelHeight] = useState(200);
|
||||||
const [isLogPanelOpen, setIsLogPanelOpen] = useState(false);
|
const [isLogPanelOpen, setIsLogPanelOpen] = useState(false);
|
||||||
@@ -223,43 +395,170 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [darkMode]);
|
}, [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 (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
locale={zhCN}
|
locale={zhCN}
|
||||||
theme={{
|
theme={{
|
||||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
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
|
<Sider
|
||||||
theme={darkMode ? "dark" : "light"}
|
|
||||||
width={sidebarWidth}
|
width={sidebarWidth}
|
||||||
style={{
|
style={{
|
||||||
borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
borderRight: 'none',
|
||||||
position: 'relative'
|
position: 'relative',
|
||||||
|
background: bgMain
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<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 }}>
|
<div style={{ padding: '10px', borderBottom: 'none', display: 'flex', justifyContent: 'flex-end', alignItems: 'center', flexShrink: 0 }}>
|
||||||
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
|
|
||||||
<div>
|
<div>
|
||||||
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
|
|
||||||
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" />
|
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" />
|
||||||
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} 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>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<Sidebar onEditConnection={handleEditConnection} />
|
<Sidebar onEditConnection={handleEditConnection} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar Footer for Log Toggle */}
|
{/* 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
|
<Button
|
||||||
type={isLogPanelOpen ? "primary" : "text"}
|
type={isLogPanelOpen ? "primary" : "text"}
|
||||||
icon={<BugOutlined />}
|
icon={<BugOutlined />}
|
||||||
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
|
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
|
||||||
block
|
block
|
||||||
@@ -285,8 +584,8 @@ function App() {
|
|||||||
title="拖动调整宽度"
|
title="拖动调整宽度"
|
||||||
/>
|
/>
|
||||||
</Sider>
|
</Sider>
|
||||||
<Content style={{ background: darkMode ? '#141414' : '#fff', 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' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, backdropFilter: `blur(${appearance.blur ?? 0}px)` }}>
|
||||||
<TabManager />
|
<TabManager />
|
||||||
</div>
|
</div>
|
||||||
{isLogPanelOpen && (
|
{isLogPanelOpen && (
|
||||||
@@ -297,6 +596,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Content>
|
</Content>
|
||||||
|
</Layout>
|
||||||
<ConnectionModal
|
<ConnectionModal
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
@@ -306,6 +606,98 @@ function App() {
|
|||||||
open={isSyncModalOpen}
|
open={isSyncModalOpen}
|
||||||
onClose={() => setIsSyncModalOpen(false)}
|
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 */}
|
{/* Ghost Resize Line for Sidebar */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse } from 'antd';
|
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse } from 'antd';
|
||||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined } from '@ant-design/icons';
|
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined } from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { DBConnect, DBGetDatabases, TestConnection } from '../../wailsjs/go/app/App';
|
import { DBGetDatabases, TestConnection, RedisConnect } from '../../wailsjs/go/app/App';
|
||||||
import { SavedConnection } from '../types';
|
import { SavedConnection } from '../types';
|
||||||
|
|
||||||
const { Meta } = Card;
|
const { Meta } = Card;
|
||||||
@@ -16,6 +16,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
|
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
|
||||||
const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||||
const [dbList, setDbList] = useState<string[]>([]);
|
const [dbList, setDbList] = useState<string[]>([]);
|
||||||
|
const [redisDbList, setRedisDbList] = useState<number[]>([]); // Redis databases 0-15
|
||||||
|
const testInFlightRef = useRef(false);
|
||||||
|
const testTimerRef = useRef<number | null>(null);
|
||||||
const addConnection = useStore((state) => state.addConnection);
|
const addConnection = useStore((state) => state.addConnection);
|
||||||
const updateConnection = useStore((state) => state.updateConnection);
|
const updateConnection = useStore((state) => state.updateConnection);
|
||||||
|
|
||||||
@@ -23,6 +26,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
if (open) {
|
if (open) {
|
||||||
setTestResult(null); // Reset test result
|
setTestResult(null); // Reset test result
|
||||||
setDbList([]);
|
setDbList([]);
|
||||||
|
setRedisDbList([]);
|
||||||
if (initialValues) {
|
if (initialValues) {
|
||||||
// Edit mode: Go directly to step 2
|
// Edit mode: Go directly to step 2
|
||||||
setStep(2);
|
setStep(2);
|
||||||
@@ -35,6 +39,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
password: initialValues.config.password,
|
password: initialValues.config.password,
|
||||||
database: initialValues.config.database,
|
database: initialValues.config.database,
|
||||||
includeDatabases: initialValues.includeDatabases,
|
includeDatabases: initialValues.includeDatabases,
|
||||||
|
includeRedisDatabases: initialValues.includeRedisDatabases,
|
||||||
useSSH: initialValues.config.useSSH,
|
useSSH: initialValues.config.useSSH,
|
||||||
sshHost: initialValues.config.ssh?.host,
|
sshHost: initialValues.config.ssh?.host,
|
||||||
sshPort: initialValues.config.ssh?.port,
|
sshPort: initialValues.config.ssh?.port,
|
||||||
@@ -47,6 +52,10 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
});
|
});
|
||||||
setUseSSH(initialValues.config.useSSH || false);
|
setUseSSH(initialValues.config.useSSH || false);
|
||||||
setDbType(initialValues.config.type);
|
setDbType(initialValues.config.type);
|
||||||
|
// 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表
|
||||||
|
if (initialValues.config.type === 'redis') {
|
||||||
|
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create mode: Start at step 1
|
// Create mode: Start at step 1
|
||||||
setStep(1);
|
setStep(1);
|
||||||
@@ -57,64 +66,94 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
}
|
}
|
||||||
}, [open, initialValues]);
|
}, [open, initialValues]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (testTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(testTimerRef.current);
|
||||||
|
testTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleOk = async () => {
|
const handleOk = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const config = await buildConfig(values);
|
|
||||||
|
|
||||||
const res = await DBConnect(config as any);
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
if (res.success) {
|
|
||||||
const newConn = {
|
|
||||||
id: initialValues ? initialValues.id : Date.now().toString(),
|
|
||||||
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : values.host),
|
|
||||||
config: config,
|
|
||||||
includeDatabases: values.includeDatabases
|
|
||||||
};
|
|
||||||
|
|
||||||
if (initialValues) {
|
const config = await buildConfig(values);
|
||||||
updateConnection(newConn);
|
|
||||||
message.success('连接已更新!');
|
const isRedisType = values.type === 'redis';
|
||||||
} else {
|
const newConn = {
|
||||||
addConnection(newConn);
|
id: initialValues ? initialValues.id : Date.now().toString(),
|
||||||
message.success('连接已保存!');
|
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${values.host}` : values.host)),
|
||||||
}
|
config: config,
|
||||||
|
includeDatabases: values.includeDatabases,
|
||||||
form.resetFields();
|
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined
|
||||||
setUseSSH(false);
|
};
|
||||||
setDbType('mysql');
|
|
||||||
setStep(1);
|
if (initialValues) {
|
||||||
onClose();
|
updateConnection(newConn);
|
||||||
|
message.success('配置已更新(未连接)');
|
||||||
} else {
|
} else {
|
||||||
message.error('连接失败: ' + res.message);
|
addConnection(newConn);
|
||||||
|
message.success('配置已保存(未连接)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
form.resetFields();
|
||||||
|
setUseSSH(false);
|
||||||
|
setDbType('mysql');
|
||||||
|
setStep(1);
|
||||||
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestTest = () => {
|
||||||
|
if (loading) return;
|
||||||
|
if (testTimerRef.current !== null) return;
|
||||||
|
testTimerRef.current = window.setTimeout(() => {
|
||||||
|
testTimerRef.current = null;
|
||||||
|
handleTest();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
|
if (testInFlightRef.current) return;
|
||||||
|
testInFlightRef.current = true;
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
const config = await buildConfig(values);
|
const config = await buildConfig(values);
|
||||||
const res = await TestConnection(config as any);
|
|
||||||
setLoading(false);
|
// Use different API for Redis
|
||||||
|
const isRedisType = values.type === 'redis';
|
||||||
|
const res = isRedisType
|
||||||
|
? await RedisConnect(config as any)
|
||||||
|
: await TestConnection(config as any);
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setTestResult({ type: 'success', message: res.message });
|
setTestResult({ type: 'success', message: res.message });
|
||||||
const dbRes = await DBGetDatabases(config as any);
|
if (isRedisType) {
|
||||||
if (dbRes.success) {
|
// Redis: generate database list 0-15
|
||||||
const dbs = (dbRes.data as any[]).map((row: any) => row.Database || row.database);
|
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
|
||||||
setDbList(dbs);
|
} else {
|
||||||
|
// Other databases: fetch database list
|
||||||
|
const dbRes = await DBGetDatabases(config as any);
|
||||||
|
if (dbRes.success) {
|
||||||
|
const dbs = (dbRes.data as any[]).map((row: any) => row.Database || row.database);
|
||||||
|
setDbList(dbs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setTestResult({ type: 'error', message: "测试失败: " + res.message });
|
setTestResult({ type: 'error', message: "测试失败: " + res.message });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
testInFlightRef.current = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -128,7 +167,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
keyPath: values.sshKeyPath || ""
|
keyPath: values.sshKeyPath || ""
|
||||||
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
|
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: values.type,
|
type: values.type,
|
||||||
host: values.host || "",
|
host: values.host || "",
|
||||||
port: Number(values.port || 0),
|
port: Number(values.port || 0),
|
||||||
@@ -146,12 +185,13 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
const handleTypeSelect = (type: string) => {
|
const handleTypeSelect = (type: string) => {
|
||||||
setDbType(type);
|
setDbType(type);
|
||||||
form.setFieldsValue({ type: type });
|
form.setFieldsValue({ type: type });
|
||||||
|
|
||||||
// Auto-fill default port
|
// Auto-fill default port
|
||||||
let defaultPort = 3306;
|
let defaultPort = 3306;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'mysql': defaultPort = 3306; break;
|
case 'mysql': defaultPort = 3306; break;
|
||||||
case 'postgres': defaultPort = 5432; break;
|
case 'postgres': defaultPort = 5432; break;
|
||||||
|
case 'redis': defaultPort = 6379; break;
|
||||||
case 'oracle': defaultPort = 1521; break;
|
case 'oracle': defaultPort = 1521; break;
|
||||||
case 'dameng': defaultPort = 5236; break;
|
case 'dameng': defaultPort = 5236; break;
|
||||||
case 'kingbase': defaultPort = 54321; break;
|
case 'kingbase': defaultPort = 54321; break;
|
||||||
@@ -166,10 +206,12 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
|
|
||||||
const isSqlite = dbType === 'sqlite';
|
const isSqlite = dbType === 'sqlite';
|
||||||
const isCustom = dbType === 'custom';
|
const isCustom = dbType === 'custom';
|
||||||
|
const isRedis = dbType === 'redis';
|
||||||
|
|
||||||
const dbTypes = [
|
const dbTypes = [
|
||||||
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
|
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
|
||||||
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
|
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
|
||||||
|
{ key: 'redis', name: 'Redis', icon: <CloudOutlined style={{ fontSize: 24, color: '#DC382D' }} /> },
|
||||||
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
|
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
|
||||||
{ key: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
|
{ key: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
|
||||||
{ key: 'dameng', name: 'Dameng (达梦)', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#1890ff' }} /> },
|
{ key: 'dameng', name: 'Dameng (达梦)', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#1890ff' }} /> },
|
||||||
@@ -226,7 +268,10 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
<Form.Item name="host" label={isSqlite ? "文件路径 (绝对路径)" : "主机地址 (Host)"} rules={[{ required: true, message: '请输入地址/路径' }]} style={{ flex: 1 }}>
|
<Form.Item name="host" label={isSqlite ? "文件路径 (绝对路径)" : "主机地址 (Host)"} rules={[{ required: true, message: '请输入地址/路径' }]} style={{ flex: 1 }}>
|
||||||
<Input placeholder={isSqlite ? "/path/to/db.sqlite" : "localhost"} />
|
<Input
|
||||||
|
placeholder={isSqlite ? "/path/to/db.sqlite" : "localhost"}
|
||||||
|
onDoubleClick={requestTest}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{!isSqlite && (
|
{!isSqlite && (
|
||||||
<Form.Item name="port" label="端口 (Port)" rules={[{ required: true, message: '请输入端口号' }]} style={{ width: 100 }}>
|
<Form.Item name="port" label="端口 (Port)" rules={[{ required: true, message: '请输入端口号' }]} style={{ width: 100 }}>
|
||||||
@@ -235,7 +280,22 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isSqlite && (
|
{/* Redis specific: password only, no username */}
|
||||||
|
{isRedis && (
|
||||||
|
<>
|
||||||
|
<Form.Item name="password" label="密码 (可选)">
|
||||||
|
<Input.Password placeholder="Redis 密码(如果设置了 requirepass)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="includeRedisDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
|
||||||
|
<Select mode="multiple" placeholder="选择显示的数据库 (0-15)" allowClear>
|
||||||
|
{redisDbList.map(db => <Select.Option key={db} value={db}>db{db}</Select.Option>)}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non-Redis, non-SQLite: username and password */}
|
||||||
|
{!isSqlite && !isRedis && (
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
<Form.Item name="user" label="用户名" rules={[{ required: true, message: '请输入用户名' }]} style={{ flex: 1 }}>
|
<Form.Item name="user" label="用户名" rules={[{ required: true, message: '请输入用户名' }]} style={{ flex: 1 }}>
|
||||||
<Input />
|
<Input />
|
||||||
@@ -245,8 +305,8 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSqlite && (
|
{!isSqlite && !isRedis && (
|
||||||
<Form.Item name="includeDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
|
<Form.Item name="includeDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
|
||||||
<Select mode="multiple" placeholder="选择显示的数据库" allowClear>
|
<Select mode="multiple" placeholder="选择显示的数据库" allowClear>
|
||||||
{dbList.map(db => <Select.Option key={db} value={db}>{db}</Select.Option>)}
|
{dbList.map(db => <Select.Option key={db} value={db}>{db}</Select.Option>)}
|
||||||
@@ -328,7 +388,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
!initialValues && <Button key="back" onClick={() => setStep(1)} style={{ float: 'left' }}>上一步</Button>,
|
!initialValues && <Button key="back" onClick={() => setStep(1)} style={{ float: 'left' }}>上一步</Button>,
|
||||||
<Button key="test" loading={loading} onClick={handleTest}>测试连接</Button>,
|
<Button key="test" loading={loading} onClick={requestTest}>测试连接</Button>,
|
||||||
<Button key="cancel" onClick={onClose}>取消</Button>,
|
<Button key="cancel" onClick={onClose}>取消</Button>,
|
||||||
<Button key="submit" type="primary" loading={loading} onClick={handleOk}>保存</Button>
|
<Button key="submit" type="primary" loading={loading} onClick={handleOk}>保存</Button>
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
|
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 { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal } from 'antd';
|
||||||
import type { SortOrder } from 'antd/es/table/interface';
|
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';
|
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
@@ -45,6 +46,35 @@ const toFormText = (val: any): string => {
|
|||||||
return toEditableText(val);
|
return toEditableText(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const INLINE_EDIT_MAX_CHARS = 2000;
|
||||||
|
|
||||||
|
const shouldOpenModalEditor = (val: any): boolean => {
|
||||||
|
if (val === null || val === undefined) return false;
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
return val.length > INLINE_EDIT_MAX_CHARS || val.includes('\n');
|
||||||
|
}
|
||||||
|
if (typeof val === 'object') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellFieldName = (record: Item, dataIndex: string) => {
|
||||||
|
const rowKey = record?.[GONAVI_ROW_KEY];
|
||||||
|
if (rowKey === undefined || rowKey === null) return dataIndex;
|
||||||
|
return [String(rowKey), dataIndex];
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCellFieldValue = (form: any, fieldName: string | (string | number)[], value: any) => {
|
||||||
|
if (!form) return;
|
||||||
|
if (Array.isArray(fieldName)) {
|
||||||
|
const [rowKey, colKey] = fieldName;
|
||||||
|
form.setFieldsValue({ [rowKey]: { [colKey]: value } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.setFieldsValue({ [fieldName]: value });
|
||||||
|
};
|
||||||
|
|
||||||
const looksLikeJsonText = (text: string): boolean => {
|
const looksLikeJsonText = (text: string): boolean => {
|
||||||
const raw = (text || '').trim();
|
const raw = (text || '').trim();
|
||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
@@ -96,6 +126,9 @@ const ResizableTitle = (props: any) => {
|
|||||||
|
|
||||||
// --- Contexts ---
|
// --- Contexts ---
|
||||||
const EditableContext = React.createContext<any>(null);
|
const EditableContext = React.createContext<any>(null);
|
||||||
|
const CellContextMenuContext = React.createContext<{
|
||||||
|
showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void;
|
||||||
|
} | null>(null);
|
||||||
const DataContext = React.createContext<{
|
const DataContext = React.createContext<{
|
||||||
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
||||||
displayDataRef: React.MutableRefObject<any[]>;
|
displayDataRef: React.MutableRefObject<any[]>;
|
||||||
@@ -134,7 +167,9 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
|||||||
}) => {
|
}) => {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const inputRef = useRef<any>(null);
|
const inputRef = useRef<any>(null);
|
||||||
|
const cellRef = useRef<HTMLTableCellElement>(null);
|
||||||
const form = useContext(EditableContext);
|
const form = useContext(EditableContext);
|
||||||
|
const cellContextMenuContext = useContext(CellContextMenuContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@@ -146,29 +181,74 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
|||||||
setEditing(!editing);
|
setEditing(!editing);
|
||||||
const raw = record[dataIndex];
|
const raw = record[dataIndex];
|
||||||
const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw;
|
const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw;
|
||||||
form.setFieldsValue({ [dataIndex]: initialValue });
|
const fieldName = getCellFieldName(record, dataIndex);
|
||||||
|
setCellFieldValue(form, fieldName, initialValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
const values = await form.validateFields([dataIndex]);
|
const fieldName = getCellFieldName(record, dataIndex);
|
||||||
|
await form.validateFields([fieldName]);
|
||||||
|
const nextValue = form.getFieldValue(fieldName);
|
||||||
|
const prevText = toFormText(record?.[dataIndex]);
|
||||||
|
const nextText = toFormText(nextValue);
|
||||||
toggleEdit();
|
toggleEdit();
|
||||||
handleSave({ ...record, ...values });
|
// 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。
|
||||||
|
if (nextText !== prevText) {
|
||||||
|
handleSave({ ...record, [dataIndex]: nextValue });
|
||||||
|
}
|
||||||
|
// 保存后移除焦点
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.blur();
|
||||||
|
}
|
||||||
} catch (errInfo) {
|
} catch (errInfo) {
|
||||||
console.log('Save failed:', errInfo);
|
console.log('Save failed:', errInfo);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
|
if (!editable) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // 阻止冒泡到行级菜单
|
||||||
|
if (cellContextMenuContext) {
|
||||||
|
cellContextMenuContext.showMenu(e, record, dataIndex, title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let childNode = children;
|
let childNode = children;
|
||||||
|
|
||||||
if (editable) {
|
if (editable) {
|
||||||
childNode = editing ? (
|
childNode = editing ? (
|
||||||
<Form.Item style={{ margin: 0 }} name={dataIndex}>
|
<Form.Item style={{ margin: 0 }} name={getCellFieldName(record, dataIndex)}>
|
||||||
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
onPressEnter={save}
|
||||||
|
onBlur={save}
|
||||||
|
onFocus={(e) => {
|
||||||
|
// Enter 编辑态时直接全选,便于快速替换;同时避免双击在 input 内冒泡导致关闭编辑态。
|
||||||
|
try {
|
||||||
|
(e.target as HTMLInputElement)?.select?.();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
(e.target as HTMLInputElement)?.select?.();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : (
|
) : (
|
||||||
<div className="editable-cell-value-wrap" style={{ paddingRight: 24, minHeight: 20 }}>
|
<div
|
||||||
|
className="editable-cell-value-wrap"
|
||||||
|
style={{ paddingRight: 24, minHeight: 20 }}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -176,19 +256,20 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
|||||||
|
|
||||||
const handleDoubleClick = () => {
|
const handleDoubleClick = () => {
|
||||||
if (!editable) return;
|
if (!editable) return;
|
||||||
|
// 已在编辑态时再次双击不应退出编辑;双击应支持在 Input 内进行全选。
|
||||||
|
if (editing) return;
|
||||||
|
const raw = record?.[dataIndex];
|
||||||
|
if (focusCell && shouldOpenModalEditor(raw)) {
|
||||||
|
focusCell(record, dataIndex, title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
toggleEdit();
|
toggleEdit();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
|
||||||
restProps?.onClick?.(e);
|
|
||||||
if (!editable) return;
|
|
||||||
if (typeof focusCell === 'function') focusCell(record, dataIndex, title);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
{...restProps}
|
{...restProps}
|
||||||
onClick={editable ? handleClick : restProps?.onClick}
|
ref={cellRef}
|
||||||
onDoubleClick={editable ? handleDoubleClick : restProps?.onDoubleClick}
|
onDoubleClick={editable ? handleDoubleClick : restProps?.onDoubleClick}
|
||||||
>
|
>
|
||||||
{childNode}
|
{childNode}
|
||||||
@@ -273,10 +354,35 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
|
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
|
||||||
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
|
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
|
||||||
}) => {
|
}) => {
|
||||||
const { connections } = useStore();
|
const connections = useStore(state => state.connections);
|
||||||
const addSqlLog = useStore(state => state.addSqlLog);
|
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;
|
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 [form] = Form.useForm();
|
||||||
const [modal, contextHolder] = Modal.useModal();
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
const gridId = useMemo(() => `grid-${uuidv4()}`, []);
|
const gridId = useMemo(() => `grid-${uuidv4()}`, []);
|
||||||
@@ -285,14 +391,77 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
const [cellEditorIsJson, setCellEditorIsJson] = useState(false);
|
const [cellEditorIsJson, setCellEditorIsJson] = useState(false);
|
||||||
const [cellEditorMeta, setCellEditorMeta] = useState<{ record: Item; dataIndex: string; title: string } | null>(null);
|
const [cellEditorMeta, setCellEditorMeta] = useState<{ record: Item; dataIndex: string; title: string } | null>(null);
|
||||||
const cellEditorApplyRef = useRef<((val: string) => void) | null>(null);
|
const cellEditorApplyRef = useRef<((val: string) => void) | null>(null);
|
||||||
const [activeCell, setActiveCell] = useState<{ rowKey: string; dataIndex: string; title: string } | null>(null);
|
|
||||||
const [rowEditorOpen, setRowEditorOpen] = useState(false);
|
const [rowEditorOpen, setRowEditorOpen] = useState(false);
|
||||||
const [rowEditorRowKey, setRowEditorRowKey] = useState<string>('');
|
const [rowEditorRowKey, setRowEditorRowKey] = useState<string>('');
|
||||||
const rowEditorBaseRef = useRef<Record<string, string>>({});
|
const rowEditorBaseRef = useRef<Record<string, string>>({});
|
||||||
const rowEditorDisplayRef = useRef<Record<string, string>>({});
|
const rowEditorDisplayRef = useRef<Record<string, string>>({});
|
||||||
const rowEditorNullColsRef = useRef<Set<string>>(new Set());
|
const rowEditorNullColsRef = useRef<Set<string>>(new Set());
|
||||||
const [rowEditorForm] = Form.useForm();
|
const [rowEditorForm] = Form.useForm();
|
||||||
|
|
||||||
|
// Cell Context Menu State
|
||||||
|
const [cellContextMenu, setCellContextMenu] = useState<{
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
record: Item | null;
|
||||||
|
dataIndex: string;
|
||||||
|
title: string;
|
||||||
|
}>({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
record: null,
|
||||||
|
dataIndex: '',
|
||||||
|
title: '',
|
||||||
|
});
|
||||||
|
const [cellSetValueInput, setCellSetValueInput] = useState('');
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pendingScrollToBottomRef = useRef(false);
|
||||||
|
|
||||||
|
const scrollTableBodyToBottom = useCallback(() => {
|
||||||
|
const root = containerRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const body = root.querySelector('.ant-table-body') as HTMLElement | null;
|
||||||
|
if (!body) return;
|
||||||
|
body.scrollTop = body.scrollHeight;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close cell context menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (cellContextMenu.visible) {
|
||||||
|
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}
|
||||||
|
// Remove focus from any focused cell when clicking outside the table
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const tableContainer = containerRef.current;
|
||||||
|
if (tableContainer && !tableContainer.contains(target)) {
|
||||||
|
// Remove focus from any input elements in the table
|
||||||
|
const focusedElement = document.activeElement as HTMLElement;
|
||||||
|
if (focusedElement && focusedElement.tagName === 'INPUT' && tableContainer.contains(focusedElement)) {
|
||||||
|
focusedElement.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
}, [cellContextMenu.visible]);
|
||||||
|
|
||||||
|
const showCellContextMenu = useCallback((e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const titleText = typeof title === 'string' ? title : (typeof title === 'number' ? String(title) : String(dataIndex));
|
||||||
|
setCellContextMenu({
|
||||||
|
visible: true,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
record,
|
||||||
|
dataIndex,
|
||||||
|
title: titleText,
|
||||||
|
});
|
||||||
|
setCellSetValueInput(toFormText(record[dataIndex]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Helper to export specific data
|
// Helper to export specific data
|
||||||
const exportData = async (rows: any[], format: string) => {
|
const exportData = async (rows: any[], format: string) => {
|
||||||
const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0);
|
const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0);
|
||||||
@@ -327,10 +496,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
setCellEditorOpen(true);
|
setCellEditorOpen(true);
|
||||||
cellEditorApplyRef.current = typeof onApplyValue === 'function' ? onApplyValue : null;
|
cellEditorApplyRef.current = typeof onApplyValue === 'function' ? onApplyValue : null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Dynamic Height
|
// Dynamic Height
|
||||||
const [tableHeight, setTableHeight] = useState(500);
|
const [tableHeight, setTableHeight] = useState(500);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
@@ -382,13 +550,22 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
|
|
||||||
useEffect(() => { selectedRowKeysRef.current = selectedRowKeys; }, [selectedRowKeys]);
|
useEffect(() => { selectedRowKeysRef.current = selectedRowKeys; }, [selectedRowKeys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingScrollToBottomRef.current) return;
|
||||||
|
pendingScrollToBottomRef.current = false;
|
||||||
|
// 等待 Table 渲染出新增行后再滚动到底部(virtual 模式也适用)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollTableBodyToBottom();
|
||||||
|
requestAnimationFrame(() => scrollTableBodyToBottom());
|
||||||
|
});
|
||||||
|
}, [addedRows.length, scrollTableBodyToBottom]);
|
||||||
|
|
||||||
// Reset local state when data source likely changes (e.g. tableName change)
|
// Reset local state when data source likely changes (e.g. tableName change)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAddedRows([]);
|
setAddedRows([]);
|
||||||
setModifiedRows({});
|
setModifiedRows({});
|
||||||
setDeletedRowKeys(new Set());
|
setDeletedRowKeys(new Set());
|
||||||
setSelectedRowKeys([]);
|
setSelectedRowKeys([]);
|
||||||
setActiveCell(null);
|
|
||||||
setRowEditorOpen(false);
|
setRowEditorOpen(false);
|
||||||
setRowEditorRowKey('');
|
setRowEditorRowKey('');
|
||||||
rowEditorBaseRef.current = {};
|
rowEditorBaseRef.current = {};
|
||||||
@@ -550,6 +727,18 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
}
|
}
|
||||||
}, [addedRows]);
|
}, [addedRows]);
|
||||||
|
|
||||||
|
const handleCellSetNull = useCallback(() => {
|
||||||
|
if (!cellContextMenu.record) return;
|
||||||
|
handleCellSave({ ...cellContextMenu.record, [cellContextMenu.dataIndex]: null });
|
||||||
|
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}, [cellContextMenu, handleCellSave]);
|
||||||
|
|
||||||
|
const handleCellSetValue = useCallback(() => {
|
||||||
|
if (!cellContextMenu.record) return;
|
||||||
|
handleCellSave({ ...cellContextMenu.record, [cellContextMenu.dataIndex]: cellSetValueInput });
|
||||||
|
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}, [cellContextMenu, cellSetValueInput, handleCellSave]);
|
||||||
|
|
||||||
const handleCellEditorSave = useCallback(() => {
|
const handleCellEditorSave = useCallback(() => {
|
||||||
if (!cellEditorMeta) return;
|
if (!cellEditorMeta) return;
|
||||||
const apply = cellEditorApplyRef.current;
|
const apply = cellEditorApplyRef.current;
|
||||||
@@ -586,13 +775,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
});
|
});
|
||||||
}, [displayData, modifiedRows]);
|
}, [displayData, modifiedRows]);
|
||||||
|
|
||||||
const focusCell = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => {
|
|
||||||
const k = record?.[GONAVI_ROW_KEY];
|
|
||||||
if (k === undefined) return;
|
|
||||||
const titleText = typeof title === 'string' ? title : (typeof title === 'number' ? String(title) : String(dataIndex));
|
|
||||||
setActiveCell({ rowKey: rowKeyStr(k), dataIndex, title: titleText });
|
|
||||||
}, [rowKeyStr]);
|
|
||||||
|
|
||||||
const closeRowEditor = useCallback(() => {
|
const closeRowEditor = useCallback(() => {
|
||||||
setRowEditorOpen(false);
|
setRowEditorOpen(false);
|
||||||
setRowEditorRowKey('');
|
setRowEditorRowKey('');
|
||||||
@@ -610,9 +792,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keyStr =
|
const keyStr =
|
||||||
selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : activeCell?.rowKey;
|
selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : undefined;
|
||||||
if (!keyStr) {
|
if (!keyStr) {
|
||||||
message.info('请先选择一行(勾选一行或点击任意单元格)');
|
message.info('请先选择一行(勾选复选框)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,7 +828,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
rowEditorForm.setFieldsValue(displayMap);
|
rowEditorForm.setFieldsValue(displayMap);
|
||||||
setRowEditorRowKey(keyStr);
|
setRowEditorRowKey(keyStr);
|
||||||
setRowEditorOpen(true);
|
setRowEditorOpen(true);
|
||||||
}, [readOnly, tableName, selectedRowKeys, activeCell, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]);
|
}, [readOnly, tableName, selectedRowKeys, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]);
|
||||||
|
|
||||||
const openRowEditorFieldEditor = useCallback((dataIndex: string) => {
|
const openRowEditorFieldEditor = useCallback((dataIndex: string) => {
|
||||||
if (!dataIndex) return;
|
if (!dataIndex) return;
|
||||||
@@ -695,12 +877,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
title: key,
|
title: key,
|
||||||
dataIndex: key,
|
dataIndex: key,
|
||||||
key: key,
|
key: key,
|
||||||
ellipsis: true,
|
// 不使用 ellipsis,避免 Ant Design 的 Tooltip 展开行为
|
||||||
width: columnWidths[key] || 200,
|
width: columnWidths[key] || 200,
|
||||||
sorter: !!onSort,
|
sorter: !!onSort,
|
||||||
sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined,
|
sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined,
|
||||||
editable: !readOnly && !!tableName, // Only editable if table name known
|
editable: !readOnly && !!tableName, // Only editable if table name known
|
||||||
render: (text: any) => formatCellValue(text),
|
render: (text: any) => (
|
||||||
|
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{formatCellValue(text)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
onHeaderCell: (column: any) => ({
|
onHeaderCell: (column: any) => ({
|
||||||
width: column.width,
|
width: column.width,
|
||||||
onResizeStart: handleResizeStart(key), // Only need start
|
onResizeStart: handleResizeStart(key), // Only need start
|
||||||
@@ -718,18 +904,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
dataIndex: col.dataIndex,
|
dataIndex: col.dataIndex,
|
||||||
title: col.title,
|
title: col.title,
|
||||||
handleSave: handleCellSave,
|
handleSave: handleCellSave,
|
||||||
focusCell,
|
focusCell: openCellEditor,
|
||||||
className: (activeCell && rowKeyStr(record?.[GONAVI_ROW_KEY]) === activeCell.rowKey && col.dataIndex === activeCell.dataIndex)
|
|
||||||
? 'gonavi-active-cell'
|
|
||||||
: undefined,
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}), [columns, handleCellSave, openCellEditor, focusCell, activeCell, rowKeyStr]);
|
}), [columns, handleCellSave, openCellEditor]);
|
||||||
|
|
||||||
const handleAddRow = () => {
|
const handleAddRow = () => {
|
||||||
const newKey = `new-${Date.now()}`;
|
const newKey = `new-${Date.now()}`;
|
||||||
const newRow: any = { [GONAVI_ROW_KEY]: newKey };
|
const newRow: any = { [GONAVI_ROW_KEY]: newKey };
|
||||||
columnNames.forEach(col => newRow[col] = '');
|
columnNames.forEach(col => newRow[col] = '');
|
||||||
|
pendingScrollToBottomRef.current = true;
|
||||||
setAddedRows(prev => [...prev, newRow]);
|
setAddedRows(prev => [...prev, newRow]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -770,9 +954,24 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
const pkData: any = {};
|
const pkData: any = {};
|
||||||
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
||||||
else { const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = originalRow; Object.assign(pkData, rest); }
|
else { const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = originalRow; Object.assign(pkData, rest); }
|
||||||
|
|
||||||
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = newRow;
|
const hasRowKey = Object.prototype.hasOwnProperty.call(newRow as any, GONAVI_ROW_KEY);
|
||||||
updates.push({ keys: pkData, values: vals });
|
let values: any = {};
|
||||||
|
|
||||||
|
if (!hasRowKey) {
|
||||||
|
values = { ...(newRow as any) };
|
||||||
|
} else {
|
||||||
|
columnNames.forEach((col) => {
|
||||||
|
const nextVal = (newRow as any)?.[col];
|
||||||
|
const prevVal = (originalRow as any)?.[col];
|
||||||
|
const nextStr = toFormText(nextVal);
|
||||||
|
const prevStr = toFormText(prevVal);
|
||||||
|
if (nextStr !== prevStr) values[col] = nextVal;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(values).length === 0) return;
|
||||||
|
updates.push({ keys: pkData, values });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
||||||
@@ -809,7 +1008,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
message: res.message,
|
message: res.message,
|
||||||
dbName
|
dbName
|
||||||
});
|
});
|
||||||
message.success("Changes committed successfully!");
|
message.success("事务提交成功");
|
||||||
setAddedRows([]);
|
setAddedRows([]);
|
||||||
setModifiedRows({});
|
setModifiedRows({});
|
||||||
setDeletedRowKeys(new Set());
|
setDeletedRowKeys(new Set());
|
||||||
@@ -824,7 +1023,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
message: res.message,
|
message: res.message,
|
||||||
dbName
|
dbName
|
||||||
});
|
});
|
||||||
message.error("Commit failed: " + res.message);
|
message.error("提交失败: " + res.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1115,15 +1314,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
const enableVirtual = mergedDisplayData.length >= 200;
|
const enableVirtual = mergedDisplayData.length >= 200;
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Toolbar */}
|
||||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
{onReload && <Button icon={<ReloadOutlined />} onClick={() => {
|
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
|
||||||
setAddedRows([]);
|
setAddedRows([]);
|
||||||
setModifiedRows({});
|
setModifiedRows({});
|
||||||
setDeletedRowKeys(new Set());
|
setDeletedRowKeys(new Set());
|
||||||
setSelectedRowKeys([]);
|
setSelectedRowKeys([]);
|
||||||
setActiveCell(null);
|
|
||||||
onReload();
|
onReload();
|
||||||
}}>刷新</Button>}
|
}}>刷新</Button>}
|
||||||
{tableName && <Button icon={<ImportOutlined />} onClick={handleImport}>导入</Button>}
|
{tableName && <Button icon={<ImportOutlined />} onClick={handleImport}>导入</Button>}
|
||||||
@@ -1135,7 +1333,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
<Button icon={<PlusOutlined />} onClick={handleAddRow}>添加行</Button>
|
<Button icon={<PlusOutlined />} onClick={handleAddRow}>添加行</Button>
|
||||||
<Button
|
<Button
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
disabled={selectedRowKeys.length > 1 || (selectedRowKeys.length !== 1 && !activeCell)}
|
disabled={selectedRowKeys.length !== 1}
|
||||||
onClick={openRowEditor}
|
onClick={openRowEditor}
|
||||||
>
|
>
|
||||||
编辑行
|
编辑行
|
||||||
@@ -1165,7 +1363,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
|
|
||||||
{/* Filter Panel */}
|
{/* Filter Panel */}
|
||||||
{showFilter && (
|
{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 => (
|
{filterConditions.map(cond => (
|
||||||
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start' }}>
|
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start' }}>
|
||||||
<Select
|
<Select
|
||||||
@@ -1244,7 +1447,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
open={rowEditorOpen}
|
open={rowEditorOpen}
|
||||||
onCancel={closeRowEditor}
|
onCancel={closeRowEditor}
|
||||||
width={980}
|
width={980}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="cancel" onClick={closeRowEditor}>取消</Button>,
|
<Button key="cancel" onClick={closeRowEditor}>取消</Button>,
|
||||||
@@ -1256,7 +1459,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
<span>{rowEditorRowKey ? `rowKey: ${rowEditorRowKey}` : ''}</span>
|
<span>{rowEditorRowKey ? `rowKey: ${rowEditorRowKey}` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<Form form={rowEditorForm} layout="vertical">
|
<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) => {
|
{columnNames.map((col) => {
|
||||||
const sample = rowEditorDisplayRef.current?.[col] ?? '';
|
const sample = rowEditorDisplayRef.current?.[col] ?? '';
|
||||||
const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined;
|
const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined;
|
||||||
@@ -1290,7 +1493,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
open={cellEditorOpen}
|
open={cellEditorOpen}
|
||||||
onCancel={closeCellEditor}
|
onCancel={closeCellEditor}
|
||||||
width={960}
|
width={960}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="format" onClick={handleFormatJsonInEditor} disabled={!cellEditorIsJson}>
|
<Button key="format" onClick={handleFormatJsonInEditor} disabled={!cellEditorIsJson}>
|
||||||
@@ -1323,40 +1526,190 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
</Modal>
|
</Modal>
|
||||||
<Form component={false} form={form}>
|
<Form component={false} form={form}>
|
||||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
||||||
<EditableContext.Provider value={form}>
|
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu }}>
|
||||||
<Table
|
<EditableContext.Provider value={form}>
|
||||||
components={tableComponents}
|
<Table
|
||||||
dataSource={mergedDisplayData}
|
components={tableComponents}
|
||||||
columns={mergedColumns}
|
dataSource={mergedDisplayData}
|
||||||
size="small"
|
columns={mergedColumns}
|
||||||
tableLayout="fixed"
|
size="small"
|
||||||
scroll={{ x: Math.max(totalWidth, 1000), y: tableHeight }}
|
tableLayout="fixed"
|
||||||
virtual={enableVirtual}
|
scroll={{ x: Math.max(totalWidth, 1000), y: tableHeight }}
|
||||||
loading={loading}
|
virtual={enableVirtual}
|
||||||
rowKey={GONAVI_ROW_KEY}
|
loading={loading}
|
||||||
pagination={false}
|
rowKey={GONAVI_ROW_KEY}
|
||||||
onChange={handleTableChange}
|
pagination={false}
|
||||||
bordered
|
onChange={handleTableChange}
|
||||||
rowSelection={{
|
bordered
|
||||||
selectedRowKeys,
|
rowSelection={{
|
||||||
onChange: setSelectedRowKeys,
|
selectedRowKeys,
|
||||||
columnWidth: selectionColumnWidth,
|
onChange: setSelectedRowKeys,
|
||||||
}}
|
columnWidth: selectionColumnWidth,
|
||||||
rowClassName={(record) => {
|
}}
|
||||||
const k = record?.[GONAVI_ROW_KEY];
|
rowClassName={(record) => {
|
||||||
if (k !== undefined && addedRows.some(r => r?.[GONAVI_ROW_KEY] === k)) return 'row-added';
|
const k = record?.[GONAVI_ROW_KEY];
|
||||||
if (k !== undefined && (modifiedRows[rowKeyStr(k)] || deletedRowKeys.has(rowKeyStr(k)))) return 'row-modified'; // deleted won't show
|
if (k !== undefined && addedRows.some(r => r?.[GONAVI_ROW_KEY] === k)) return 'row-added';
|
||||||
return '';
|
if (k !== undefined && (modifiedRows[rowKeyStr(k)] || deletedRowKeys.has(rowKeyStr(k)))) return 'row-modified'; // deleted won't show
|
||||||
}}
|
return '';
|
||||||
onRow={(record) => ({ record } as any)}
|
}}
|
||||||
/>
|
onRow={(record) => ({ record } as any)}
|
||||||
</EditableContext.Provider>
|
/>
|
||||||
|
</EditableContext.Provider>
|
||||||
|
</CellContextMenuContext.Provider>
|
||||||
</DataContext.Provider>
|
</DataContext.Provider>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
{/* Cell Context Menu - 使用 Portal 渲染到 body,避免 backdropFilter 影响 fixed 定位 */}
|
||||||
|
{cellContextMenu.visible && createPortal(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: cellContextMenu.x,
|
||||||
|
top: cellContextMenu.y,
|
||||||
|
zIndex: 10000,
|
||||||
|
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: 160,
|
||||||
|
color: darkMode ? '#fff' : 'rgba(0, 0, 0, 0.88)'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<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={handleCellSetNull}
|
||||||
|
>
|
||||||
|
设置为 NULL
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
{pagination && (
|
{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
|
<Pagination
|
||||||
current={pagination.current}
|
current={pagination.current}
|
||||||
pageSize={pagination.pageSize}
|
pageSize={pagination.pageSize}
|
||||||
@@ -1375,12 +1728,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.${gridId} .row-added td { background-color: #f6ffed !important; }
|
.${gridId} .ant-table { background: transparent !important; }
|
||||||
.${gridId} .row-modified td { background-color: #e6f7ff !important; }
|
.${gridId} .ant-table-container { background: transparent !important; border: none !important; }
|
||||||
.${gridId} td.gonavi-active-cell {
|
.${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; }
|
||||||
outline: 2px solid #1677ff;
|
.${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; }
|
||||||
outline-offset: -2px;
|
.${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>
|
`}</style>
|
||||||
|
|
||||||
{/* Ghost Resize Line for Columns */}
|
{/* Ghost Resize Line for Columns */}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||||
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { connections, addSqlLog } = useStore();
|
const connections = useStore(state => state.connections);
|
||||||
|
const addSqlLog = useStore(state => state.addSqlLog);
|
||||||
const fetchSeqRef = useRef(0);
|
const fetchSeqRef = useRef(0);
|
||||||
const countSeqRef = useRef(0);
|
const countSeqRef = useRef(0);
|
||||||
const countKeyRef = useRef<string>('');
|
const countKeyRef = useRef<string>('');
|
||||||
@@ -149,8 +150,11 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
countKeyRef.current = countKey;
|
countKeyRef.current = countKey;
|
||||||
const countSeq = ++countSeqRef.current;
|
const countSeq = ++countSeqRef.current;
|
||||||
const countStart = Date.now();
|
const countStart = Date.now();
|
||||||
|
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
|
||||||
|
// 这里为统计请求设置更短的超时,避免“后台统计”长期占用资源。
|
||||||
|
const countConfig: any = { ...(config as any), timeout: 5 };
|
||||||
|
|
||||||
DBQuery(config as any, dbName, countSql)
|
DBQuery(countConfig, dbName, countSql)
|
||||||
.then((resCount: any) => {
|
.then((resCount: any) => {
|
||||||
const countDuration = Date.now() - countStart;
|
const countDuration = Date.now() - countStart;
|
||||||
|
|
||||||
@@ -209,7 +213,6 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
|
|
||||||
// Handlers memoized
|
// Handlers memoized
|
||||||
const handleReload = useCallback(() => {
|
const handleReload = useCallback(() => {
|
||||||
countKeyRef.current = '';
|
|
||||||
fetchData(pagination.current, pagination.pageSize);
|
fetchData(pagination.current, pagination.pageSize);
|
||||||
}, [fetchData, pagination.current, pagination.pageSize]);
|
}, [fetchData, pagination.current, pagination.pageSize]);
|
||||||
const handleSort = useCallback((field: string, order: string) => setSortInfo({ columnKey: field, order }), []);
|
const handleSort = useCallback((field: string, order: string) => setSortInfo({ columnKey: field, order }), []);
|
||||||
|
|||||||
@@ -10,7 +10,23 @@ interface LogPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) => {
|
const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) => {
|
||||||
const { sqlLogs, clearSqlLogs, darkMode } = useStore();
|
const sqlLogs = useStore(state => state.sqlLogs);
|
||||||
|
const clearSqlLogs = useStore(state => state.clearSqlLogs);
|
||||||
|
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 = [
|
const columns = [
|
||||||
{
|
{
|
||||||
@@ -51,8 +67,9 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
|||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
height,
|
height,
|
||||||
borderTop: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
|
borderTop: 'none',
|
||||||
background: darkMode ? '#1f1f1f' : '#fff',
|
background: bgMain,
|
||||||
|
backdropFilter: `blur(${appearance.blur ?? 0}px)`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -75,11 +92,10 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
|||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
borderBottom: 'none',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
background: darkMode ? '#2a2a2a' : '#fafafa',
|
|
||||||
height: 32
|
height: 32
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 'bold', fontSize: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 'bold', fontSize: '12px' }}>
|
||||||
@@ -111,4 +127,4 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LogPanel;
|
export default LogPanel;
|
||||||
|
|||||||
@@ -42,16 +42,19 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
const monacoRef = useRef<any>(null);
|
const monacoRef = useRef<any>(null);
|
||||||
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
|
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
|
||||||
const tablesRef = useRef<string[]>([]); // Store tables for autocomplete
|
const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db)
|
||||||
const allColumnsRef = useRef<{tableName: string, name: string, type: string}[]>([]); // Store all columns
|
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, addSqlLog } = useStore();
|
const connections = useStore(state => state.connections);
|
||||||
|
const addSqlLog = useStore(state => state.addSqlLog);
|
||||||
const currentConnectionIdRef = useRef(currentConnectionId);
|
const currentConnectionIdRef = useRef(currentConnectionId);
|
||||||
const currentDbRef = useRef(currentDb);
|
const currentDbRef = useRef(currentDb);
|
||||||
const connectionsRef = useRef(connections);
|
const connectionsRef = useRef(connections);
|
||||||
const columnsCacheRef = useRef<Record<string, ColumnDefinition[]>>({});
|
const columnsCacheRef = useRef<Record<string, ColumnDefinition[]>>({});
|
||||||
const saveQuery = useStore(state => state.saveQuery);
|
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 sqlFormatOptions = useStore(state => state.sqlFormatOptions);
|
||||||
const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions);
|
const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions);
|
||||||
const queryOptions = useStore(state => state.queryOptions);
|
const queryOptions = useStore(state => state.queryOptions);
|
||||||
@@ -79,9 +82,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const fetchDbs = async () => {
|
const fetchDbs = async () => {
|
||||||
const conn = connections.find(c => c.id === currentConnectionId);
|
const conn = connections.find(c => c.id === currentConnectionId);
|
||||||
if (!conn) return;
|
if (!conn) return;
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
...conn.config,
|
...conn.config,
|
||||||
port: Number(conn.config.port),
|
port: Number(conn.config.port),
|
||||||
password: conn.config.password || "",
|
password: conn.config.password || "",
|
||||||
database: conn.config.database || "",
|
database: conn.config.database || "",
|
||||||
@@ -91,27 +94,41 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
|
|
||||||
const res = await DBGetDatabases(config as any);
|
const res = await DBGetDatabases(config as any);
|
||||||
if (res.success && Array.isArray(res.data)) {
|
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);
|
setDbList(dbs);
|
||||||
if (!currentDbRef.current) {
|
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 if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
visibleDbsRef.current = [];
|
||||||
setDbList([]);
|
setDbList([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchDbs();
|
fetchDbs();
|
||||||
}, [currentConnectionId, connections]);
|
}, [currentConnectionId, connections]);
|
||||||
|
|
||||||
// Fetch Metadata for Autocomplete
|
// Fetch Metadata for Autocomplete (Cross-database)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMetadata = async () => {
|
const fetchMetadata = async () => {
|
||||||
const conn = connections.find(c => c.id === currentConnectionId);
|
const conn = connections.find(c => c.id === currentConnectionId);
|
||||||
if (!conn || !currentDb) return;
|
if (!conn) return;
|
||||||
|
|
||||||
const config = {
|
const visibleDbs = visibleDbsRef.current;
|
||||||
...conn.config,
|
if (!visibleDbs || visibleDbs.length === 0) return;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...conn.config,
|
||||||
port: Number(conn.config.port),
|
port: Number(conn.config.port),
|
||||||
password: conn.config.password || "",
|
password: conn.config.password || "",
|
||||||
database: conn.config.database || "",
|
database: conn.config.database || "",
|
||||||
@@ -119,25 +136,39 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
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 allTables: {dbName: string, tableName: string}[] = [];
|
||||||
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
|
const allColumns: {dbName: string, tableName: string, name: string, type: string}[] = [];
|
||||||
tablesRef.current = tableNames;
|
|
||||||
} else {
|
|
||||||
tablesRef.current = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.type === 'mysql' || !config.type) {
|
for (const dbName of visibleDbs) {
|
||||||
const resCols = await DBGetAllColumns(config as any, currentDb);
|
// 获取表
|
||||||
|
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)) {
|
if (resCols.success && Array.isArray(resCols.data)) {
|
||||||
allColumnsRef.current = resCols.data;
|
resCols.data.forEach((col: any) => {
|
||||||
} else {
|
allColumns.push({
|
||||||
allColumnsRef.current = [];
|
dbName,
|
||||||
|
tableName: col.tableName,
|
||||||
|
name: col.name,
|
||||||
|
type: col.type
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tablesRef.current = allTables;
|
||||||
|
allColumnsRef.current = allColumns;
|
||||||
};
|
};
|
||||||
fetchMetadata();
|
fetchMetadata();
|
||||||
}, [currentConnectionId, currentDb, connections]);
|
}, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
|
||||||
|
|
||||||
// Handle Resizing
|
// Handle Resizing
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
@@ -240,61 +271,125 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
|
|
||||||
const fullText = model.getValue();
|
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);
|
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*)$/);
|
const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_][\w]*[`"]?)\.(\w*)$/);
|
||||||
if (qualifierMatch) {
|
if (qualifierMatch) {
|
||||||
const alias = stripQuotes(qualifierMatch[1]);
|
const qualifier = stripQuotes(qualifierMatch[1]);
|
||||||
const colPrefix = (qualifierMatch[2] || '').toLowerCase();
|
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([
|
const reserved = new Set([
|
||||||
'where', 'on', 'group', 'order', 'limit', 'having',
|
'where', 'on', 'group', 'order', 'limit', 'having',
|
||||||
'left', 'right', 'inner', 'outer', 'full', 'cross', 'join',
|
'left', 'right', 'inner', 'outer', 'full', 'cross', 'join',
|
||||||
'union', 'except', 'intersect', 'as', 'set', 'values', 'returning',
|
'union', 'except', 'intersect', 'as', 'set', 'values', 'returning',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const aliasMap: Record<string, string> = {};
|
const aliasMap: Record<string, {dbName: string, tableName: string}> = {};
|
||||||
// Capture table and optional alias, support schema.table
|
// 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;
|
const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?[\w]+[`"]?))?/gi;
|
||||||
let m;
|
let m;
|
||||||
while ((m = aliasRegex.exec(fullText)) !== null) {
|
while ((m = aliasRegex.exec(fullText)) !== null) {
|
||||||
const tableIdent = normalizeQualifiedName(m[1] || '');
|
const tableIdent = normalizeQualifiedName(m[1] || '');
|
||||||
if (!tableIdent) continue;
|
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);
|
const shortTable = getLastPart(tableIdent);
|
||||||
// allow "table." as qualifier too
|
// 用表名作为 qualifier
|
||||||
if (shortTable) aliasMap[shortTable.toLowerCase()] = tableIdent;
|
if (shortTable) aliasMap[shortTable.toLowerCase()] = { dbName, tableName };
|
||||||
|
|
||||||
const a = stripQuotes(m[2] || '').trim();
|
const a = stripQuotes(m[2] || '').trim();
|
||||||
if (!a) continue;
|
if (!a) continue;
|
||||||
const al = a.toLowerCase();
|
const al = a.toLowerCase();
|
||||||
if (reserved.has(al)) continue;
|
if (reserved.has(al)) continue;
|
||||||
aliasMap[al] = tableIdent;
|
aliasMap[al] = { dbName, tableName };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableIdent = aliasMap[alias.toLowerCase()];
|
const tableInfo = aliasMap[qualifier.toLowerCase()];
|
||||||
if (tableIdent) {
|
if (tableInfo) {
|
||||||
const shortTable = getLastPart(tableIdent);
|
|
||||||
|
|
||||||
// Prefer preloaded MySQL all-columns cache
|
// 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) {
|
if (allColumnsRef.current.length > 0) {
|
||||||
cols = allColumnsRef.current
|
cols = allColumnsRef.current
|
||||||
.filter(c => (c.tableName || '').toLowerCase() === (shortTable || '').toLowerCase())
|
.filter(c =>
|
||||||
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName }));
|
(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 {
|
} else {
|
||||||
const dbCols = await getColumnsByDB(tableIdent);
|
const dbCols = await getColumnsByDB(tableInfo.tableName);
|
||||||
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: shortTable }));
|
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = colPrefix
|
const filtered = prefix
|
||||||
? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix))
|
? cols.filter(c => (c.name || '').toLowerCase().startsWith(prefix))
|
||||||
: cols;
|
: cols;
|
||||||
|
|
||||||
const suggestions = filtered.map(c => ({
|
const suggestions = filtered.map(c => ({
|
||||||
label: c.name,
|
label: c.name,
|
||||||
kind: monaco.languages.CompletionItemKind.Field,
|
kind: monaco.languages.CompletionItemKind.Field,
|
||||||
insertText: c.name,
|
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,
|
range,
|
||||||
sortText: '0' + c.name
|
sortText: '0' + c.name
|
||||||
}));
|
}));
|
||||||
@@ -309,35 +404,72 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
while ((match = tableRegex.exec(fullText)) !== null) {
|
while ((match = tableRegex.exec(fullText)) !== null) {
|
||||||
const t = normalizeQualifiedName(match[1] || '');
|
const t = normalizeQualifiedName(match[1] || '');
|
||||||
if (!t) continue;
|
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
|
const relevantColumns = allColumnsRef.current
|
||||||
.filter(c => foundTables.has((c.tableName || '').toLowerCase()))
|
.filter(c => {
|
||||||
.map(c => ({
|
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
|
||||||
label: c.name,
|
const shortIdent = (c.tableName || '').toLowerCase();
|
||||||
kind: monaco.languages.CompletionItemKind.Field,
|
return foundTables.has(fullIdent) || foundTables.has(shortIdent);
|
||||||
insertText: c.name,
|
})
|
||||||
detail: `${c.type} (${c.tableName})`,
|
.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,
|
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 = [
|
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 => ({
|
...relevantColumns, // FROM 表的列最优先
|
||||||
label: k,
|
...tableSuggestions, // 表次之
|
||||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
...dbSuggestions, // 数据库
|
||||||
insertText: k,
|
...keywordSuggestions // 关键字最后
|
||||||
range
|
|
||||||
})),
|
|
||||||
...tablesRef.current.map(t => ({
|
|
||||||
label: t,
|
|
||||||
kind: monaco.languages.CompletionItemKind.Class,
|
|
||||||
insertText: t,
|
|
||||||
detail: 'Table',
|
|
||||||
range
|
|
||||||
})),
|
|
||||||
...relevantColumns
|
|
||||||
];
|
];
|
||||||
return { suggestions };
|
return { suggestions };
|
||||||
}
|
}
|
||||||
|
|||||||
205
frontend/src/components/RedisCommandEditor.tsx
Normal file
205
frontend/src/components/RedisCommandEditor.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
|
import { Button, Space, message } from 'antd';
|
||||||
|
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import Editor, { OnMount } from '@monaco-editor/react';
|
||||||
|
|
||||||
|
interface RedisCommandEditorProps {
|
||||||
|
connectionId: string;
|
||||||
|
redisDB: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandResult {
|
||||||
|
command: string;
|
||||||
|
result: any;
|
||||||
|
error?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
|
||||||
|
const { connections } = useStore();
|
||||||
|
const connection = connections.find(c => c.id === connectionId);
|
||||||
|
|
||||||
|
const [command, setCommand] = useState('');
|
||||||
|
const [results, setResults] = useState<CommandResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const editorRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const getConfig = useCallback(() => {
|
||||||
|
if (!connection) return null;
|
||||||
|
return {
|
||||||
|
...connection.config,
|
||||||
|
port: Number(connection.config.port),
|
||||||
|
password: connection.config.password || "",
|
||||||
|
useSSH: connection.config.useSSH || false,
|
||||||
|
ssh: connection.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
|
||||||
|
redisDB: redisDB
|
||||||
|
};
|
||||||
|
}, [connection, redisDB]);
|
||||||
|
|
||||||
|
const handleEditorMount: OnMount = (editor) => {
|
||||||
|
editorRef.current = editor;
|
||||||
|
// Add keyboard shortcut for execute
|
||||||
|
editor.addCommand(
|
||||||
|
// Ctrl/Cmd + Enter
|
||||||
|
2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter
|
||||||
|
() => handleExecute()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecute = async () => {
|
||||||
|
const config = getConfig();
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const cmdToExecute = command.trim();
|
||||||
|
if (!cmdToExecute) {
|
||||||
|
message.warning('请输入命令');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support multiple commands separated by newlines
|
||||||
|
const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#'));
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const newResults: CommandResult[] = [];
|
||||||
|
|
||||||
|
for (const cmd of commands) {
|
||||||
|
const trimmedCmd = cmd.trim();
|
||||||
|
if (!trimmedCmd) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd);
|
||||||
|
newResults.push({
|
||||||
|
command: trimmedCmd,
|
||||||
|
result: res.success ? res.data : null,
|
||||||
|
error: res.success ? undefined : res.message,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
newResults.push({
|
||||||
|
command: trimmedCmd,
|
||||||
|
result: null,
|
||||||
|
error: e?.message || String(e),
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setResults(prev => [...newResults, ...prev]);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatResult = (result: any): string => {
|
||||||
|
if (result === null || result === undefined) {
|
||||||
|
return '(nil)';
|
||||||
|
}
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
return `"${result}"`;
|
||||||
|
}
|
||||||
|
if (typeof result === 'number') {
|
||||||
|
return `(integer) ${result}`;
|
||||||
|
}
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
if (result.length === 0) {
|
||||||
|
return '(empty array)';
|
||||||
|
}
|
||||||
|
return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n');
|
||||||
|
}
|
||||||
|
if (typeof result === 'object') {
|
||||||
|
return JSON.stringify(result, null, 2);
|
||||||
|
}
|
||||||
|
return String(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return <div style={{ padding: 20 }}>连接不存在</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{/* Command Input */}
|
||||||
|
<div style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||||
|
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Space>
|
||||||
|
<span style={{ fontWeight: 500 }}>Redis 命令</span>
|
||||||
|
<span style={{ color: '#999', fontSize: 12 }}>db{redisDB}</span>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={handleExecute}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
执行 (Ctrl+Enter)
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ClearOutlined />} onClick={handleClear}>清空结果</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Editor
|
||||||
|
height="150px"
|
||||||
|
defaultLanguage="plaintext"
|
||||||
|
value={command}
|
||||||
|
onChange={(value) => setCommand(value || '')}
|
||||||
|
onMount={handleEditorMount}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
lineNumbers: 'on',
|
||||||
|
fontSize: 14,
|
||||||
|
wordWrap: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
tabSize: 2
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace' }}>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>
|
||||||
|
输入 Redis 命令并按 Ctrl+Enter 执行
|
||||||
|
<br />
|
||||||
|
<span style={{ fontSize: 12 }}>支持多行命令,每行一个命令</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
results.map((item, index) => (
|
||||||
|
<div key={item.timestamp + index} style={{ padding: '8px 12px', borderBottom: '1px solid #333' }}>
|
||||||
|
<div style={{ color: '#569cd6', marginBottom: 4 }}>
|
||||||
|
> {item.command}
|
||||||
|
</div>
|
||||||
|
{item.error ? (
|
||||||
|
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
|
||||||
|
(error) {item.error}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#ce9178', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{formatResult(item.result)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Common Commands Help */}
|
||||||
|
<div style={{ padding: '8px 12px', borderTop: '1px solid #f0f0f0', background: '#fafafa', fontSize: 12, color: '#666' }}>
|
||||||
|
常用命令:
|
||||||
|
<span style={{ marginLeft: 8 }}>
|
||||||
|
<code>KEYS *</code> |
|
||||||
|
<code style={{ marginLeft: 8 }}>GET key</code> |
|
||||||
|
<code style={{ marginLeft: 8 }}>SET key value</code> |
|
||||||
|
<code style={{ marginLeft: 8 }}>HGETALL key</code> |
|
||||||
|
<code style={{ marginLeft: 8 }}>INFO</code> |
|
||||||
|
<code style={{ marginLeft: 8 }}>DBSIZE</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RedisCommandEditor;
|
||||||
1557
frontend/src/components/RedisViewer.tsx
Normal file
1557
frontend/src/components/RedisViewer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,18 @@ import { useStore } from '../store';
|
|||||||
import DataViewer from './DataViewer';
|
import DataViewer from './DataViewer';
|
||||||
import QueryEditor from './QueryEditor';
|
import QueryEditor from './QueryEditor';
|
||||||
import TableDesigner from './TableDesigner';
|
import TableDesigner from './TableDesigner';
|
||||||
|
import RedisViewer from './RedisViewer';
|
||||||
|
import RedisCommandEditor from './RedisCommandEditor';
|
||||||
|
|
||||||
const TabManager: React.FC = () => {
|
const TabManager: React.FC = () => {
|
||||||
const { tabs, activeTabId, setActiveTab, closeTab, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs } = useStore();
|
const tabs = useStore(state => state.tabs);
|
||||||
|
const activeTabId = useStore(state => state.activeTabId);
|
||||||
|
const setActiveTab = useStore(state => state.setActiveTab);
|
||||||
|
const closeTab = useStore(state => state.closeTab);
|
||||||
|
const closeOtherTabs = useStore(state => state.closeOtherTabs);
|
||||||
|
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
|
||||||
|
const closeTabsToRight = useStore(state => state.closeTabsToRight);
|
||||||
|
const closeAllTabs = useStore(state => state.closeAllTabs);
|
||||||
|
|
||||||
const onChange = (newActiveKey: string) => {
|
const onChange = (newActiveKey: string) => {
|
||||||
setActiveTab(newActiveKey);
|
setActiveTab(newActiveKey);
|
||||||
@@ -27,6 +36,10 @@ const TabManager: React.FC = () => {
|
|||||||
content = <DataViewer tab={tab} />;
|
content = <DataViewer tab={tab} />;
|
||||||
} else if (tab.type === 'design') {
|
} else if (tab.type === 'design') {
|
||||||
content = <TableDesigner tab={tab} />;
|
content = <TableDesigner tab={tab} />;
|
||||||
|
} else if (tab.type === 'redis-keys') {
|
||||||
|
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||||
|
} else if (tab.type === 'redis-command') {
|
||||||
|
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItems: MenuProps['items'] = [
|
const menuItems: MenuProps['items'] = [
|
||||||
@@ -109,6 +122,9 @@ const TabManager: React.FC = () => {
|
|||||||
.main-tabs .ant-tabs-tabpane-hidden {
|
.main-tabs .ant-tabs-tabpane-hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
.main-tabs .ant-tabs-nav::before {
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<Tabs
|
<Tabs
|
||||||
className="main-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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
go: any;
|
||||||
|
runtime: {
|
||||||
|
WindowMinimise: () => void;
|
||||||
|
WindowToggleMaximise: () => void;
|
||||||
|
Quit: () => void;
|
||||||
|
BrowserOpenURL: (url: string) => void;
|
||||||
|
};
|
||||||
ipcRenderer: {
|
ipcRenderer: {
|
||||||
send: (channel: string, ...args: any[]) => void;
|
send: (channel: string, ...args: any[]) => void;
|
||||||
on: (channel: string, listener: (event: any, ...args: any[]) => void) => void;
|
on: (channel: string, listener: (event: any, ...args: any[]) => void) => void;
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ interface AppState {
|
|||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
activeContext: { connectionId: string; dbName: string } | null;
|
activeContext: { connectionId: string; dbName: string } | null;
|
||||||
savedQueries: SavedQuery[];
|
savedQueries: SavedQuery[];
|
||||||
darkMode: boolean;
|
theme: 'light' | 'dark';
|
||||||
|
appearance: { opacity: number; blur: number };
|
||||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||||
queryOptions: { maxRows: number };
|
queryOptions: { maxRows: number };
|
||||||
sqlLogs: SqlLog[];
|
sqlLogs: SqlLog[];
|
||||||
@@ -40,7 +41,8 @@ interface AppState {
|
|||||||
saveQuery: (query: SavedQuery) => void;
|
saveQuery: (query: SavedQuery) => void;
|
||||||
deleteQuery: (id: string) => 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;
|
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||||
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
|
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
|
||||||
|
|
||||||
@@ -56,7 +58,8 @@ export const useStore = create<AppState>()(
|
|||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
activeContext: null,
|
activeContext: null,
|
||||||
savedQueries: [],
|
savedQueries: [],
|
||||||
darkMode: false,
|
theme: 'light',
|
||||||
|
appearance: { opacity: 0.95, blur: 0 },
|
||||||
sqlFormatOptions: { keywordCase: 'upper' },
|
sqlFormatOptions: { keywordCase: 'upper' },
|
||||||
queryOptions: { maxRows: 5000 },
|
queryOptions: { maxRows: 5000 },
|
||||||
sqlLogs: [],
|
sqlLogs: [],
|
||||||
@@ -125,7 +128,8 @@ export const useStore = create<AppState>()(
|
|||||||
|
|
||||||
deleteQuery: (id) => set((state) => ({ savedQueries: state.savedQueries.filter(q => q.id !== id) })),
|
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 }),
|
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||||
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...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)
|
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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface ConnectionConfig {
|
|||||||
database?: string;
|
database?: string;
|
||||||
useSSH?: boolean;
|
useSSH?: boolean;
|
||||||
ssh?: SSHConfig;
|
ssh?: SSHConfig;
|
||||||
|
redisDB?: number; // Redis database index (0-15)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedConnection {
|
export interface SavedConnection {
|
||||||
@@ -22,6 +23,7 @@ export interface SavedConnection {
|
|||||||
name: string;
|
name: string;
|
||||||
config: ConnectionConfig;
|
config: ConnectionConfig;
|
||||||
includeDatabases?: string[];
|
includeDatabases?: string[];
|
||||||
|
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ColumnDefinition {
|
export interface ColumnDefinition {
|
||||||
@@ -60,13 +62,14 @@ export interface TriggerDefinition {
|
|||||||
export interface TabData {
|
export interface TabData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
type: 'query' | 'table' | 'design';
|
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command';
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
dbName?: string;
|
dbName?: string;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
initialTab?: string;
|
initialTab?: string;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
redisDB?: number; // Redis database index for redis tabs
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseNode {
|
export interface DatabaseNode {
|
||||||
@@ -85,3 +88,32 @@ export interface SavedQuery {
|
|||||||
dbName: string;
|
dbName: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redis types
|
||||||
|
export interface RedisKeyInfo {
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisScanResult {
|
||||||
|
keys: RedisKeyInfo[];
|
||||||
|
cursor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisValue {
|
||||||
|
type: 'string' | 'hash' | 'list' | 'set' | 'zset';
|
||||||
|
ttl: number;
|
||||||
|
value: any;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisDBInfo {
|
||||||
|
index: number;
|
||||||
|
keys: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZSetMember {
|
||||||
|
member: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,10 +18,35 @@ const normalizeIdentPart = (ident: string) => {
|
|||||||
return raw;
|
return raw;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 检查标识符是否需要引号(包含特殊字符或是保留字)
|
||||||
|
const needsQuote = (ident: string): boolean => {
|
||||||
|
if (!ident) return false;
|
||||||
|
// 如果包含特殊字符(非字母、数字、下划线)则需要引号
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(ident)) return true;
|
||||||
|
// 常见 SQL 保留字列表(简化版)
|
||||||
|
const reserved = ['select', 'from', 'where', 'table', 'index', 'user', 'order', 'group', 'by', 'limit', 'offset', 'and', 'or', 'not', 'null', 'true', 'false', 'key', 'primary', 'foreign', 'references', 'default', 'constraint', 'create', 'drop', 'alter', 'insert', 'update', 'delete', 'set', 'values', 'into', 'join', 'left', 'right', 'inner', 'outer', 'on', 'as', 'is', 'in', 'like', 'between', 'case', 'when', 'then', 'else', 'end', 'having', 'distinct', 'all', 'any', 'exists', 'union', 'except', 'intersect'];
|
||||||
|
return reserved.includes(ident.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
export const quoteIdentPart = (dbType: string, ident: string) => {
|
export const quoteIdentPart = (dbType: string, ident: string) => {
|
||||||
const raw = normalizeIdentPart(ident);
|
const raw = normalizeIdentPart(ident);
|
||||||
if (!raw) return raw;
|
if (!raw) return raw;
|
||||||
if ((dbType || '').toLowerCase() === 'mysql') return `\`${raw.replace(/`/g, '``')}\``;
|
const dbTypeLower = (dbType || '').toLowerCase();
|
||||||
|
|
||||||
|
if (dbTypeLower === 'mysql') {
|
||||||
|
return `\`${raw.replace(/`/g, '``')}\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于 KingBase/PostgreSQL,只在必要时加引号
|
||||||
|
if (dbTypeLower === 'kingbase' || dbTypeLower === 'postgres') {
|
||||||
|
if (needsQuote(raw)) {
|
||||||
|
return `"${raw.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
// 不加引号,保持原样(数据库会自动转小写处理)
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库默认加双引号
|
||||||
return `"${raw.replace(/"/g, '""')}"`;
|
return `"${raw.replace(/"/g, '""')}"`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
51
frontend/wailsjs/go/app/App.d.ts
vendored
51
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -2,9 +2,12 @@
|
|||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
import {connection} from '../models';
|
import {connection} from '../models';
|
||||||
import {sync} from '../models';
|
import {sync} from '../models';
|
||||||
|
import {redis} from '../models';
|
||||||
|
|
||||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
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 CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
export function DBConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
export function DBConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
@@ -33,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 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 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>;
|
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
|
||||||
@@ -43,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 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 ImportConfigFile():Promise<connection.QueryResult>;
|
||||||
|
|
||||||
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):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 MySQLConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
export function MySQLGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
export function MySQLGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
@@ -59,4 +68,46 @@ export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:strin
|
|||||||
|
|
||||||
export function OpenSQLFile():Promise<connection.QueryResult>;
|
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisDeleteKeys(arg1:connection.ConnectionConfig,arg2:Array<string>):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisExecuteCommand(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisFlushDB(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisGetServerInfo(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisRenameKey(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:number):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisSelectDB(arg1:connection.ConnectionConfig,arg2:number):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisSetHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisSetRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisSetString(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:number):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisSetTTL(arg1:connection.ConnectionConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisTestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisZSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<redis.ZSetMember>):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RedisZSetRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
export function TestConnection(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);
|
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CheckForUpdates() {
|
||||||
|
return window['go']['app']['App']['CheckForUpdates']();
|
||||||
|
}
|
||||||
|
|
||||||
export function CreateDatabase(arg1, arg2) {
|
export function CreateDatabase(arg1, arg2) {
|
||||||
return window['go']['app']['App']['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);
|
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) {
|
export function ExportData(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['app']['App']['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);
|
return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAppInfo() {
|
||||||
|
return window['go']['app']['App']['GetAppInfo']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ImportConfigFile() {
|
export function ImportConfigFile() {
|
||||||
return window['go']['app']['App']['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);
|
return window['go']['app']['App']['ImportData'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InstallUpdateAndRestart() {
|
||||||
|
return window['go']['app']['App']['InstallUpdateAndRestart']();
|
||||||
|
}
|
||||||
|
|
||||||
export function MySQLConnect(arg1) {
|
export function MySQLConnect(arg1) {
|
||||||
return window['go']['app']['App']['MySQLConnect'](arg1);
|
return window['go']['app']['App']['MySQLConnect'](arg1);
|
||||||
}
|
}
|
||||||
@@ -114,6 +130,90 @@ export function OpenSQLFile() {
|
|||||||
return window['go']['app']['App']['OpenSQLFile']();
|
return window['go']['app']['App']['OpenSQLFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RedisConnect(arg1) {
|
||||||
|
return window['go']['app']['App']['RedisConnect'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisDeleteHashField(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['RedisDeleteHashField'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisDeleteKeys(arg1, arg2) {
|
||||||
|
return window['go']['app']['App']['RedisDeleteKeys'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisExecuteCommand(arg1, arg2) {
|
||||||
|
return window['go']['app']['App']['RedisExecuteCommand'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisFlushDB(arg1) {
|
||||||
|
return window['go']['app']['App']['RedisFlushDB'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisGetDatabases(arg1) {
|
||||||
|
return window['go']['app']['App']['RedisGetDatabases'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisGetServerInfo(arg1) {
|
||||||
|
return window['go']['app']['App']['RedisGetServerInfo'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisGetValue(arg1, arg2) {
|
||||||
|
return window['go']['app']['App']['RedisGetValue'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisListPush(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['RedisListPush'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisListSet(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['app']['App']['RedisListSet'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisRenameKey(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['RedisRenameKey'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisScanKeys(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['app']['App']['RedisScanKeys'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisSelectDB(arg1, arg2) {
|
||||||
|
return window['go']['app']['App']['RedisSelectDB'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisSetAdd(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['RedisSetAdd'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisSetHashField(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['app']['App']['RedisSetHashField'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisSetRemove(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['RedisSetRemove'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisSetString(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['app']['App']['RedisSetString'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisSetTTL(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['RedisSetTTL'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisTestConnection(arg1) {
|
||||||
|
return window['go']['app']['App']['RedisTestConnection'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisZSetAdd(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['RedisZSetAdd'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedisZSetRemove(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['RedisZSetRemove'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestConnection(arg1) {
|
export function TestConnection(arg1) {
|
||||||
return window['go']['app']['App']['TestConnection'](arg1);
|
return window['go']['app']['App']['TestConnection'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export namespace connection {
|
|||||||
driver?: string;
|
driver?: string;
|
||||||
dsn?: string;
|
dsn?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
redisDB?: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ConnectionConfig(source);
|
return new ConnectionConfig(source);
|
||||||
@@ -98,6 +99,7 @@ export namespace connection {
|
|||||||
this.driver = source["driver"];
|
this.driver = source["driver"];
|
||||||
this.dsn = source["dsn"];
|
this.dsn = source["dsn"];
|
||||||
this.timeout = source["timeout"];
|
this.timeout = source["timeout"];
|
||||||
|
this.redisDB = source["redisDB"];
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -140,6 +142,25 @@ export namespace connection {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace redis {
|
||||||
|
|
||||||
|
export class ZSetMember {
|
||||||
|
member: string;
|
||||||
|
score: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ZSetMember(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.member = source["member"];
|
||||||
|
this.score = source["score"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace sync {
|
export namespace sync {
|
||||||
|
|
||||||
export class TableOptions {
|
export class TableOptions {
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -7,6 +7,7 @@ require (
|
|||||||
gitee.com/chunanyong/dm v1.8.22
|
gitee.com/chunanyong/dm v1.8.22
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
github.com/lib/pq v1.11.1
|
github.com/lib/pq v1.11.1
|
||||||
|
github.com/redis/go-redis/v9 v9.17.3
|
||||||
github.com/sijms/go-ora/v2 v2.9.0
|
github.com/sijms/go-ora/v2 v2.9.0
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.47.0
|
||||||
@@ -16,6 +17,8 @@ require (
|
|||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
|||||||
10
go.sum
10
go.sum
@@ -6,8 +6,16 @@ gitee.com/chunanyong/dm v1.8.22 h1:H7fsrnUIvEA0jlDWew7vwELry1ff+tLMIu2Fk2cIBSg=
|
|||||||
gitee.com/chunanyong/dm v1.8.22/go.mod h1:EPRJnuPFgbyOFgJ0TRYCTGzhq+ZT4wdyaj/GW/LLcNg=
|
gitee.com/chunanyong/dm v1.8.22/go.mod h1:EPRJnuPFgbyOFgJ0TRYCTGzhq+ZT4wdyaj/GW/LLcNg=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
@@ -61,6 +69,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
|||||||
@@ -10,23 +10,33 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
"GoNavi-Wails/internal/db"
|
"GoNavi-Wails/internal/db"
|
||||||
"GoNavi-Wails/internal/logger"
|
"GoNavi-Wails/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const dbCachePingInterval = 30 * time.Second
|
||||||
|
|
||||||
|
type cachedDatabase struct {
|
||||||
|
inst db.Database
|
||||||
|
lastPing time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// App struct
|
// App struct
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
dbCache map[string]db.Database // Cache for DB connections
|
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||||
mu sync.Mutex // Mutex for cache access
|
mu sync.RWMutex // Mutex for cache access
|
||||||
|
updateMu sync.Mutex
|
||||||
|
updateState updateState
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates a new App application struct
|
// NewApp creates a new App application struct
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
return &App{
|
return &App{
|
||||||
dbCache: make(map[string]db.Database),
|
dbCache: make(map[string]cachedDatabase),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +54,12 @@ func (a *App) Shutdown(ctx context.Context) {
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
for _, dbInst := range a.dbCache {
|
for _, dbInst := range a.dbCache {
|
||||||
if err := dbInst.Close(); err != nil {
|
if err := dbInst.inst.Close(); err != nil {
|
||||||
logger.Error(err, "关闭数据库连接失败")
|
logger.Error(err, "关闭数据库连接失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Close all Redis connections
|
||||||
|
CloseAllRedisClients()
|
||||||
logger.Infof("资源释放完成,应用已关闭")
|
logger.Infof("资源释放完成,应用已关闭")
|
||||||
logger.Close()
|
logger.Close()
|
||||||
}
|
}
|
||||||
@@ -134,32 +146,63 @@ func formatConnSummary(config connection.ConnectionConfig) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) getDatabaseForcePing(config connection.ConnectionConfig) (db.Database, error) {
|
||||||
|
return a.getDatabaseWithPing(config, true)
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: Get or create a database connection
|
// Helper: Get or create a database connection
|
||||||
func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, error) {
|
func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, error) {
|
||||||
|
return a.getDatabaseWithPing(config, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) {
|
||||||
key := getCacheKey(config)
|
key := getCacheKey(config)
|
||||||
shortKey := key
|
shortKey := key
|
||||||
if len(shortKey) > 12 {
|
if len(shortKey) > 12 {
|
||||||
shortKey = shortKey[:12]
|
shortKey = shortKey[:12]
|
||||||
}
|
}
|
||||||
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.RLock()
|
||||||
defer a.mu.Unlock()
|
entry, ok := a.dbCache[key]
|
||||||
|
a.mu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
needPing := forcePing
|
||||||
|
if !needPing {
|
||||||
|
lastPing := entry.lastPing
|
||||||
|
if lastPing.IsZero() || time.Since(lastPing) >= dbCachePingInterval {
|
||||||
|
needPing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if dbInst, ok := a.dbCache[key]; ok {
|
if !needPing {
|
||||||
logger.Infof("命中连接缓存,开始检测可用性:缓存Key=%s", shortKey)
|
return entry.inst, nil
|
||||||
if err := dbInst.Ping(); err == nil {
|
}
|
||||||
logger.Infof("缓存连接可用:缓存Key=%s", shortKey)
|
|
||||||
return dbInst, nil
|
if err := entry.inst.Ping(); err == nil {
|
||||||
|
// Update lastPing (best effort)
|
||||||
|
a.mu.Lock()
|
||||||
|
if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst {
|
||||||
|
cur.lastPing = time.Now()
|
||||||
|
a.dbCache[key] = cur
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
return entry.inst, nil
|
||||||
} else {
|
} else {
|
||||||
logger.Error(err, "缓存连接不可用,准备重建:缓存Key=%s", shortKey)
|
logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||||
}
|
}
|
||||||
if err := dbInst.Close(); err != nil {
|
|
||||||
logger.Error(err, "关闭失效缓存连接失败:缓存Key=%s", shortKey)
|
// Ping failed: remove cached instance (best effort)
|
||||||
|
a.mu.Lock()
|
||||||
|
if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst {
|
||||||
|
if err := cur.inst.Close(); err != nil {
|
||||||
|
logger.Error(err, "关闭失效缓存连接失败:缓存Key=%s", shortKey)
|
||||||
|
}
|
||||||
|
delete(a.dbCache, key)
|
||||||
}
|
}
|
||||||
delete(a.dbCache, key)
|
a.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||||
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", config.Type, shortKey)
|
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", config.Type, shortKey)
|
||||||
dbInst, err := db.NewDatabase(config.Type)
|
dbInst, err := db.NewDatabase(config.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -173,7 +216,18 @@ func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, erro
|
|||||||
return nil, wrapped
|
return nil, wrapped
|
||||||
}
|
}
|
||||||
|
|
||||||
a.dbCache[key] = dbInst
|
now := time.Now()
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
if existing, exists := a.dbCache[key]; exists && existing.inst != nil {
|
||||||
|
a.mu.Unlock()
|
||||||
|
// Prefer existing cached connection to avoid cache racing duplicates.
|
||||||
|
_ = dbInst.Close()
|
||||||
|
return existing.inst, nil
|
||||||
|
}
|
||||||
|
a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now}
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||||
return dbInst, nil
|
return dbInst, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
// Generic DB Methods
|
// Generic DB Methods
|
||||||
|
|
||||||
func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResult {
|
func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
// getDatabase checks cache and Pings. If valid, reuses. If not, connects.
|
// 连接测试需要强制 ping,避免缓存命中但连接已失效时误判成功。
|
||||||
_, err := a.getDatabase(config)
|
_, err := a.getDatabaseForcePing(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err, "DBConnect 连接失败:%s", formatConnSummary(config))
|
logger.Error(err, "DBConnect 连接失败:%s", formatConnSummary(config))
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
@@ -26,7 +26,7 @@ func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) TestConnection(config connection.ConnectionConfig) connection.QueryResult {
|
func (a *App) TestConnection(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
_, err := a.getDatabase(config)
|
_, err := a.getDatabaseForcePing(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err, "TestConnection 连接测试失败:%s", formatConnSummary(config))
|
logger.Error(err, "TestConnection 连接测试失败:%s", formatConnSummary(config))
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
|||||||
@@ -201,10 +201,10 @@ func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
return connection.QueryResult{Success: true, Message: "Changes applied successfully"}
|
return connection.QueryResult{Success: true, Message: "事务提交成功"}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.QueryResult{Success: false, Message: "Batch updates not supported for this database type"}
|
return connection.QueryResult{Success: false, Message: "当前数据库类型不支持批量提交"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tableName string, format string) connection.QueryResult {
|
func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tableName string, format string) connection.QueryResult {
|
||||||
|
|||||||
481
internal/app/methods_redis.go
Normal file
481
internal/app/methods_redis.go
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
|
"GoNavi-Wails/internal/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Redis client cache
|
||||||
|
var (
|
||||||
|
redisCache = make(map[string]redis.RedisClient)
|
||||||
|
redisCacheMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// getRedisClient gets or creates a Redis client from cache
|
||||||
|
func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisClient, error) {
|
||||||
|
key := getRedisClientCacheKey(config)
|
||||||
|
shortKey := key
|
||||||
|
if len(shortKey) > 12 {
|
||||||
|
shortKey = shortKey[:12]
|
||||||
|
}
|
||||||
|
logger.Infof("获取 Redis 连接:%s 缓存Key=%s", formatRedisConnSummary(config), shortKey)
|
||||||
|
|
||||||
|
redisCacheMu.Lock()
|
||||||
|
defer redisCacheMu.Unlock()
|
||||||
|
|
||||||
|
if client, ok := redisCache[key]; ok {
|
||||||
|
logger.Infof("命中 Redis 连接缓存,开始检测可用性:缓存Key=%s", shortKey)
|
||||||
|
if err := client.Ping(); err == nil {
|
||||||
|
logger.Infof("缓存 Redis 连接可用:缓存Key=%s", shortKey)
|
||||||
|
return client, nil
|
||||||
|
} else {
|
||||||
|
logger.Error(err, "缓存 Redis 连接不可用,准备重建:缓存Key=%s", shortKey)
|
||||||
|
}
|
||||||
|
client.Close()
|
||||||
|
delete(redisCache, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("创建 Redis 客户端实例:缓存Key=%s", shortKey)
|
||||||
|
client := redis.NewRedisClient()
|
||||||
|
if err := client.Connect(config); err != nil {
|
||||||
|
logger.Error(err, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(config), shortKey)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
redisCache[key] = client
|
||||||
|
logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(config), shortKey)
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRedisClientCacheKey(config connection.ConnectionConfig) string {
|
||||||
|
if !config.UseSSH {
|
||||||
|
config.SSH = connection.SSHConfig{}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(config)
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRedisConnSummary(config connection.ConnectionConfig) string {
|
||||||
|
timeoutSeconds := config.Timeout
|
||||||
|
if timeoutSeconds <= 0 {
|
||||||
|
timeoutSeconds = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("类型=redis 地址=")
|
||||||
|
b.WriteString(config.Host)
|
||||||
|
b.WriteString(":")
|
||||||
|
b.WriteString(string(rune(config.Port + '0')))
|
||||||
|
b.WriteString(" DB=")
|
||||||
|
b.WriteString(string(rune(config.RedisDB + '0')))
|
||||||
|
|
||||||
|
if config.UseSSH {
|
||||||
|
b.WriteString(" SSH=")
|
||||||
|
b.WriteString(config.SSH.Host)
|
||||||
|
b.WriteString(":")
|
||||||
|
b.WriteString(string(rune(config.SSH.Port + '0')))
|
||||||
|
b.WriteString(" 用户=")
|
||||||
|
b.WriteString(config.SSH.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisConnect tests a Redis connection
|
||||||
|
func (a *App) RedisConnect(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
_, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err, "RedisConnect 连接失败:%s", formatRedisConnSummary(config))
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
logger.Infof("RedisConnect 连接成功:%s", formatRedisConnSummary(config))
|
||||||
|
return connection.QueryResult{Success: true, Message: "连接成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisTestConnection tests a Redis connection (alias for RedisConnect)
|
||||||
|
func (a *App) RedisTestConnection(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
|
return a.RedisConnect(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisScanKeys scans keys matching a pattern
|
||||||
|
func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string, cursor uint64, count int64) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.ScanKeys(pattern, cursor, count)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err, "RedisScanKeys 扫描失败:pattern=%s", pattern)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: result}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisGetValue gets the value of a key
|
||||||
|
func (a *App) RedisGetValue(config connection.ConnectionConfig, key string) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := client.GetValue(key)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err, "RedisGetValue 获取失败:key=%s", key)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisSetString sets a string value
|
||||||
|
func (a *App) RedisSetString(config connection.ConnectionConfig, key, value string, ttl int64) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.SetString(key, value, ttl); err != nil {
|
||||||
|
logger.Error(err, "RedisSetString 设置失败:key=%s", key)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "设置成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisSetHashField sets a field in a hash
|
||||||
|
func (a *App) RedisSetHashField(config connection.ConnectionConfig, key, field, value string) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.SetHashField(key, field, value); err != nil {
|
||||||
|
logger.Error(err, "RedisSetHashField 设置失败:key=%s field=%s", key, field)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "设置成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisDeleteKeys deletes one or more keys
|
||||||
|
func (a *App) RedisDeleteKeys(config connection.ConnectionConfig, keys []string) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := client.DeleteKeys(keys)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err, "RedisDeleteKeys 删除失败:keys=%v", keys)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: map[string]int64{"deleted": deleted}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisSetTTL sets the TTL of a key
|
||||||
|
func (a *App) RedisSetTTL(config connection.ConnectionConfig, key string, ttl int64) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.SetTTL(key, ttl); err != nil {
|
||||||
|
logger.Error(err, "RedisSetTTL 设置失败:key=%s ttl=%d", key, ttl)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "设置成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisExecuteCommand executes a raw Redis command
|
||||||
|
func (a *App) RedisExecuteCommand(config connection.ConnectionConfig, command string) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command string into args
|
||||||
|
args := parseRedisCommand(command)
|
||||||
|
if len(args) == 0 {
|
||||||
|
return connection.QueryResult{Success: false, Message: "命令不能为空"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.ExecuteCommand(args)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err, "RedisExecuteCommand 执行失败:command=%s", command)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: result}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRedisCommand parses a Redis command string into arguments
|
||||||
|
func parseRedisCommand(command string) []string {
|
||||||
|
command = strings.TrimSpace(command)
|
||||||
|
if command == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var args []string
|
||||||
|
var current strings.Builder
|
||||||
|
inQuote := false
|
||||||
|
quoteChar := rune(0)
|
||||||
|
|
||||||
|
for _, ch := range command {
|
||||||
|
if inQuote {
|
||||||
|
if ch == quoteChar {
|
||||||
|
inQuote = false
|
||||||
|
args = append(args, current.String())
|
||||||
|
current.Reset()
|
||||||
|
} else {
|
||||||
|
current.WriteRune(ch)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ch == '"' || ch == '\'' {
|
||||||
|
inQuote = true
|
||||||
|
quoteChar = ch
|
||||||
|
} else if ch == ' ' || ch == '\t' {
|
||||||
|
if current.Len() > 0 {
|
||||||
|
args = append(args, current.String())
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.WriteRune(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.Len() > 0 {
|
||||||
|
args = append(args, current.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisGetServerInfo returns server information
|
||||||
|
func (a *App) RedisGetServerInfo(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := client.GetServerInfo()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err, "RedisGetServerInfo 获取失败")
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: info}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisGetDatabases returns information about all databases
|
||||||
|
func (a *App) RedisGetDatabases(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbs, err := client.GetDatabases()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err, "RedisGetDatabases 获取失败")
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: dbs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisSelectDB selects a database
|
||||||
|
func (a *App) RedisSelectDB(config connection.ConnectionConfig, dbIndex int) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
config.RedisDB = dbIndex
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.SelectDB(dbIndex); err != nil {
|
||||||
|
logger.Error(err, "RedisSelectDB 切换失败:db=%d", dbIndex)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "切换成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisRenameKey renames a key
|
||||||
|
func (a *App) RedisRenameKey(config connection.ConnectionConfig, oldKey, newKey string) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.RenameKey(oldKey, newKey); err != nil {
|
||||||
|
logger.Error(err, "RedisRenameKey 重命名失败:%s -> %s", oldKey, newKey)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "重命名成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisDeleteHashField deletes fields from a hash
|
||||||
|
func (a *App) RedisDeleteHashField(config connection.ConnectionConfig, key string, fields []string) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.DeleteHashField(key, fields...); err != nil {
|
||||||
|
logger.Error(err, "RedisDeleteHashField 删除失败:key=%s fields=%v", key, fields)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "删除成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisListPush pushes values to a list
|
||||||
|
func (a *App) RedisListPush(config connection.ConnectionConfig, key string, values []string) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.ListPush(key, values...); err != nil {
|
||||||
|
logger.Error(err, "RedisListPush 添加失败:key=%s", key)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "添加成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisListSet sets a value at an index in a list
|
||||||
|
func (a *App) RedisListSet(config connection.ConnectionConfig, key string, index int64, value string) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.ListSet(key, index, value); err != nil {
|
||||||
|
logger.Error(err, "RedisListSet 设置失败:key=%s index=%d", key, index)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "设置成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisSetAdd adds members to a set
|
||||||
|
func (a *App) RedisSetAdd(config connection.ConnectionConfig, key string, members []string) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.SetAdd(key, members...); err != nil {
|
||||||
|
logger.Error(err, "RedisSetAdd 添加失败:key=%s", key)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "添加成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisSetRemove removes members from a set
|
||||||
|
func (a *App) RedisSetRemove(config connection.ConnectionConfig, key string, members []string) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.SetRemove(key, members...); err != nil {
|
||||||
|
logger.Error(err, "RedisSetRemove 删除失败:key=%s", key)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "删除成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisZSetAdd adds members to a sorted set
|
||||||
|
func (a *App) RedisZSetAdd(config connection.ConnectionConfig, key string, members []redis.ZSetMember) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.ZSetAdd(key, members...); err != nil {
|
||||||
|
logger.Error(err, "RedisZSetAdd 添加失败:key=%s", key)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "添加成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisZSetRemove removes members from a sorted set
|
||||||
|
func (a *App) RedisZSetRemove(config connection.ConnectionConfig, key string, members []string) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.ZSetRemove(key, members...); err != nil {
|
||||||
|
logger.Error(err, "RedisZSetRemove 删除失败:key=%s", key)
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "删除成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisFlushDB flushes the current database
|
||||||
|
func (a *App) RedisFlushDB(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
|
config.Type = "redis"
|
||||||
|
client, err := a.getRedisClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.FlushDB(); err != nil {
|
||||||
|
logger.Error(err, "RedisFlushDB 清空失败")
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "清空成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseAllRedisClients closes all cached Redis clients (called on shutdown)
|
||||||
|
func CloseAllRedisClients() {
|
||||||
|
redisCacheMu.Lock()
|
||||||
|
defer redisCacheMu.Unlock()
|
||||||
|
|
||||||
|
for key, client := range redisCache {
|
||||||
|
if client != nil {
|
||||||
|
client.Close()
|
||||||
|
logger.Infof("已关闭 Redis 连接:%s", key[:12])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redisCache = make(map[string]redis.RedisClient)
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
@@ -19,9 +19,10 @@ type ConnectionConfig struct {
|
|||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
UseSSH bool `json:"useSSH"`
|
UseSSH bool `json:"useSSH"`
|
||||||
SSH SSHConfig `json:"ssh"`
|
SSH SSHConfig `json:"ssh"`
|
||||||
Driver string `json:"driver,omitempty"` // For custom connection
|
Driver string `json:"driver,omitempty"` // For custom connection
|
||||||
DSN string `json:"dsn,omitempty"` // For custom connection
|
DSN string `json:"dsn,omitempty"` // For custom connection
|
||||||
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
||||||
|
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryResult is the standard response format for Wails methods
|
// QueryResult is the standard response format for Wails methods
|
||||||
|
|||||||
@@ -248,7 +248,141 @@ func (c *CustomDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||||
return fmt.Errorf("read-only mode for custom")
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := c.conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
driver := strings.ToLower(strings.TrimSpace(c.driver))
|
||||||
|
isMySQL := strings.Contains(driver, "mysql")
|
||||||
|
isPostgres := strings.Contains(driver, "postgres") || strings.Contains(driver, "kingbase") || strings.Contains(driver, "pg")
|
||||||
|
isOracle := strings.Contains(driver, "oracle") || strings.Contains(driver, "ora") || strings.Contains(driver, "dm") || strings.Contains(driver, "dameng")
|
||||||
|
|
||||||
|
quoteIdent := func(name string) string {
|
||||||
|
n := strings.TrimSpace(name)
|
||||||
|
if isMySQL {
|
||||||
|
n = strings.Trim(n, "`")
|
||||||
|
n = strings.ReplaceAll(n, "`", "``")
|
||||||
|
if n == "" {
|
||||||
|
return "``"
|
||||||
|
}
|
||||||
|
return "`" + n + "`"
|
||||||
|
}
|
||||||
|
n = strings.Trim(n, "\"")
|
||||||
|
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||||
|
if n == "" {
|
||||||
|
return "\"\""
|
||||||
|
}
|
||||||
|
return `"` + n + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholder := func(idx int) string {
|
||||||
|
if isPostgres {
|
||||||
|
return fmt.Sprintf("$%d", idx)
|
||||||
|
}
|
||||||
|
if isOracle {
|
||||||
|
return fmt.Sprintf(":%d", idx)
|
||||||
|
}
|
||||||
|
// MySQL / SQLite / default
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := ""
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
schema = strings.TrimSpace(parts[0])
|
||||||
|
table = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedTable := ""
|
||||||
|
if schema != "" {
|
||||||
|
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||||
|
} else {
|
||||||
|
qualifiedTable = quoteIdent(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Deletes
|
||||||
|
for _, pk := range changes.Deletes {
|
||||||
|
var wheres []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
for k, v := range pk {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = %s", quoteIdent(k), placeholder(idx)))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("delete error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Updates
|
||||||
|
for _, update := range changes.Updates {
|
||||||
|
var sets []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
|
||||||
|
for k, v := range update.Values {
|
||||||
|
idx++
|
||||||
|
sets = append(sets, fmt.Sprintf("%s = %s", quoteIdent(k), placeholder(idx)))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wheres []string
|
||||||
|
for k, v := range update.Keys {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = %s", quoteIdent(k), placeholder(idx)))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
return fmt.Errorf("update requires keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("update error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Inserts
|
||||||
|
for _, row := range changes.Inserts {
|
||||||
|
var cols []string
|
||||||
|
var placeholders []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
|
||||||
|
for k, v := range row {
|
||||||
|
idx++
|
||||||
|
cols = append(cols, quoteIdent(k))
|
||||||
|
placeholders = append(placeholders, placeholder(idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cols) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("insert error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CustomDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
func (c *CustomDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
|
|||||||
@@ -373,7 +373,117 @@ func (d *DamengDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||||
return fmt.Errorf("read-only mode implemented for Dameng so far")
|
if d.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := d.conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
quoteIdent := func(name string) string {
|
||||||
|
n := strings.TrimSpace(name)
|
||||||
|
n = strings.Trim(n, "\"")
|
||||||
|
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||||
|
if n == "" {
|
||||||
|
return "\"\""
|
||||||
|
}
|
||||||
|
return `"` + n + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := ""
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
schema = strings.TrimSpace(parts[0])
|
||||||
|
table = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedTable := ""
|
||||||
|
if schema != "" {
|
||||||
|
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||||
|
} else {
|
||||||
|
qualifiedTable = quoteIdent(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Deletes
|
||||||
|
for _, pk := range changes.Deletes {
|
||||||
|
var wheres []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
for k, v := range pk {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("delete error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Updates
|
||||||
|
for _, update := range changes.Updates {
|
||||||
|
var sets []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
|
||||||
|
for k, v := range update.Values {
|
||||||
|
idx++
|
||||||
|
sets = append(sets, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wheres []string
|
||||||
|
for k, v := range update.Keys {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
return fmt.Errorf("update requires keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("update error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Inserts
|
||||||
|
for _, row := range changes.Inserts {
|
||||||
|
var cols []string
|
||||||
|
var placeholders []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
|
||||||
|
for k, v := range row {
|
||||||
|
idx++
|
||||||
|
cols = append(cols, quoteIdent(k))
|
||||||
|
placeholders = append(placeholders, fmt.Sprintf(":%d", idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cols) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("insert error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DamengDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
func (d *DamengDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
|
|||||||
@@ -597,7 +597,117 @@ func (k *KingbaseDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||||
return fmt.Errorf("read-only mode implemented for Kingbase so far")
|
if k.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := k.conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
quoteIdent := func(name string) string {
|
||||||
|
n := strings.TrimSpace(name)
|
||||||
|
n = strings.Trim(n, "\"")
|
||||||
|
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||||
|
if n == "" {
|
||||||
|
return "\"\""
|
||||||
|
}
|
||||||
|
return `"` + n + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := ""
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
schema = strings.TrimSpace(parts[0])
|
||||||
|
table = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedTable := ""
|
||||||
|
if schema != "" {
|
||||||
|
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||||
|
} else {
|
||||||
|
qualifiedTable = quoteIdent(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Deletes
|
||||||
|
for _, pk := range changes.Deletes {
|
||||||
|
var wheres []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
for k, v := range pk {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("delete error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Updates
|
||||||
|
for _, update := range changes.Updates {
|
||||||
|
var sets []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
|
||||||
|
for k, v := range update.Values {
|
||||||
|
idx++
|
||||||
|
sets = append(sets, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wheres []string
|
||||||
|
for k, v := range update.Keys {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
return fmt.Errorf("update requires keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("update error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Inserts
|
||||||
|
for _, row := range changes.Inserts {
|
||||||
|
var cols []string
|
||||||
|
var placeholders []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
|
||||||
|
for k, v := range row {
|
||||||
|
idx++
|
||||||
|
cols = append(cols, quoteIdent(k))
|
||||||
|
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cols) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("insert error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
|
|||||||
@@ -318,15 +318,19 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
|||||||
var args []interface{}
|
var args []interface{}
|
||||||
for k, v := range pk {
|
for k, v := range pk {
|
||||||
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||||
args = append(args, v)
|
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||||
}
|
}
|
||||||
if len(wheres) == 0 {
|
if len(wheres) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
query := fmt.Sprintf("DELETE FROM `%s` WHERE %s", tableName, strings.Join(wheres, " AND "))
|
query := fmt.Sprintf("DELETE FROM `%s` WHERE %s", tableName, strings.Join(wheres, " AND "))
|
||||||
if _, err := tx.Exec(query, args...); err != nil {
|
res, err := tx.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("delete error: %v", err)
|
return fmt.Errorf("delete error: %v", err)
|
||||||
}
|
}
|
||||||
|
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||||
|
return fmt.Errorf("删除未生效:未匹配到任何行")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Updates
|
// 2. Updates
|
||||||
@@ -336,7 +340,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
|||||||
|
|
||||||
for k, v := range update.Values {
|
for k, v := range update.Values {
|
||||||
sets = append(sets, fmt.Sprintf("`%s` = ?", k))
|
sets = append(sets, fmt.Sprintf("`%s` = ?", k))
|
||||||
args = append(args, v)
|
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sets) == 0 {
|
if len(sets) == 0 {
|
||||||
@@ -346,7 +350,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
|||||||
var wheres []string
|
var wheres []string
|
||||||
for k, v := range update.Keys {
|
for k, v := range update.Keys {
|
||||||
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||||
args = append(args, v)
|
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(wheres) == 0 {
|
if len(wheres) == 0 {
|
||||||
@@ -354,9 +358,13 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf("UPDATE `%s` SET %s WHERE %s", tableName, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
query := fmt.Sprintf("UPDATE `%s` SET %s WHERE %s", tableName, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||||
if _, err := tx.Exec(query, args...); err != nil {
|
res, err := tx.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("update error: %v", err)
|
return fmt.Errorf("update error: %v", err)
|
||||||
}
|
}
|
||||||
|
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||||
|
return fmt.Errorf("更新未生效:未匹配到任何行")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Inserts
|
// 3. Inserts
|
||||||
@@ -368,7 +376,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
|||||||
for k, v := range row {
|
for k, v := range row {
|
||||||
cols = append(cols, fmt.Sprintf("`%s`", k))
|
cols = append(cols, fmt.Sprintf("`%s`", k))
|
||||||
placeholders = append(placeholders, "?")
|
placeholders = append(placeholders, "?")
|
||||||
args = append(args, v)
|
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cols) == 0 {
|
if len(cols) == 0 {
|
||||||
@@ -376,14 +384,93 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)", tableName, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)", tableName, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||||
if _, err := tx.Exec(query, args...); err != nil {
|
res, err := tx.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("insert error: %v", err)
|
return fmt.Errorf("insert error: %v", err)
|
||||||
}
|
}
|
||||||
|
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||||
|
return fmt.Errorf("插入未生效:未影响任何行")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeMySQLDateTimeValue(value interface{}) interface{} {
|
||||||
|
text, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(text)
|
||||||
|
if raw == "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned := strings.ReplaceAll(raw, "+ ", "+")
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, "- ", "-")
|
||||||
|
|
||||||
|
if len(cleaned) >= 19 && cleaned[10] == 'T' {
|
||||||
|
if strings.HasSuffix(cleaned, "Z") || hasTimezoneOffset(cleaned) {
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, cleaned); err == nil {
|
||||||
|
return formatMySQLDateTime(t)
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, cleaned); err == nil {
|
||||||
|
return formatMySQLDateTime(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Replace(cleaned, "T", " ", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(cleaned, " ") && (strings.HasSuffix(cleaned, "Z") || hasTimezoneOffset(cleaned)) {
|
||||||
|
candidate := strings.Replace(cleaned, " ", "T", 1)
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, candidate); err == nil {
|
||||||
|
return formatMySQLDateTime(t)
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, candidate); err == nil {
|
||||||
|
return formatMySQLDateTime(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTimezoneOffset(text string) bool {
|
||||||
|
pos := strings.LastIndexAny(text, "+-")
|
||||||
|
if pos < 0 || pos < 10 || pos+1 >= len(text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
offset := text[pos+1:]
|
||||||
|
if len(offset) == 5 && offset[2] == ':' {
|
||||||
|
return isAllDigits(offset[:2]) && isAllDigits(offset[3:])
|
||||||
|
}
|
||||||
|
if len(offset) == 4 {
|
||||||
|
return isAllDigits(offset)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllDigits(text string) bool {
|
||||||
|
if text == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range text {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMySQLDateTime(t time.Time) string {
|
||||||
|
base := t.Format("2006-01-02 15:04:05")
|
||||||
|
nanos := t.Nanosecond()
|
||||||
|
if nanos == 0 {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
micro := nanos / 1000
|
||||||
|
return fmt.Sprintf("%s.%06d", base, micro)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
func (m *MySQLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName)
|
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName)
|
||||||
if dbName == "" {
|
if dbName == "" {
|
||||||
|
|||||||
@@ -363,8 +363,117 @@ func (o *OracleDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||||
// TODO: Implement batch application for Oracle using correct syntax
|
if o.conn == nil {
|
||||||
return fmt.Errorf("read-only mode implemented for Oracle so far")
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := o.conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
quoteIdent := func(name string) string {
|
||||||
|
n := strings.TrimSpace(name)
|
||||||
|
n = strings.Trim(n, "\"")
|
||||||
|
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||||
|
if n == "" {
|
||||||
|
return "\"\""
|
||||||
|
}
|
||||||
|
return `"` + n + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := ""
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
schema = strings.TrimSpace(parts[0])
|
||||||
|
table = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedTable := ""
|
||||||
|
if schema != "" {
|
||||||
|
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||||
|
} else {
|
||||||
|
qualifiedTable = quoteIdent(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Deletes
|
||||||
|
for _, pk := range changes.Deletes {
|
||||||
|
var wheres []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
for k, v := range pk {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("delete error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Updates
|
||||||
|
for _, update := range changes.Updates {
|
||||||
|
var sets []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
|
||||||
|
for k, v := range update.Values {
|
||||||
|
idx++
|
||||||
|
sets = append(sets, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wheres []string
|
||||||
|
for k, v := range update.Keys {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
return fmt.Errorf("update requires keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("update error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Inserts
|
||||||
|
for _, row := range changes.Inserts {
|
||||||
|
var cols []string
|
||||||
|
var placeholders []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
|
||||||
|
for k, v := range row {
|
||||||
|
idx++
|
||||||
|
cols = append(cols, quoteIdent(k))
|
||||||
|
placeholders = append(placeholders, fmt.Sprintf(":%d", idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cols) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("insert error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OracleDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
func (o *OracleDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
|
|||||||
@@ -521,3 +521,117 @@ ORDER BY table_schema, table_name, ordinal_position`
|
|||||||
}
|
}
|
||||||
return cols, nil
|
return cols, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||||
|
if p.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := p.conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
quoteIdent := func(name string) string {
|
||||||
|
n := strings.TrimSpace(name)
|
||||||
|
n = strings.Trim(n, "\"")
|
||||||
|
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||||
|
if n == "" {
|
||||||
|
return "\"\""
|
||||||
|
}
|
||||||
|
return `"` + n + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := ""
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
schema = strings.TrimSpace(parts[0])
|
||||||
|
table = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedTable := ""
|
||||||
|
if schema != "" {
|
||||||
|
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||||
|
} else {
|
||||||
|
qualifiedTable = quoteIdent(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Deletes
|
||||||
|
for _, pk := range changes.Deletes {
|
||||||
|
var wheres []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
for k, v := range pk {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("delete error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Updates
|
||||||
|
for _, update := range changes.Updates {
|
||||||
|
var sets []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
|
||||||
|
for k, v := range update.Values {
|
||||||
|
idx++
|
||||||
|
sets = append(sets, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wheres []string
|
||||||
|
for k, v := range update.Keys {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
return fmt.Errorf("update requires keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("update error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Inserts
|
||||||
|
for _, row := range changes.Inserts {
|
||||||
|
var cols []string
|
||||||
|
var placeholders []string
|
||||||
|
var args []interface{}
|
||||||
|
idx := 0
|
||||||
|
|
||||||
|
for k, v := range row {
|
||||||
|
idx++
|
||||||
|
cols = append(cols, quoteIdent(k))
|
||||||
|
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cols) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("insert error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|||||||
@@ -445,6 +445,113 @@ func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
|||||||
return triggers, nil
|
return triggers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||||
|
if s.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
quoteIdent := func(name string) string {
|
||||||
|
n := strings.TrimSpace(name)
|
||||||
|
n = strings.Trim(n, "\"")
|
||||||
|
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||||
|
if n == "" {
|
||||||
|
return "\"\""
|
||||||
|
}
|
||||||
|
return `"` + n + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := ""
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
schema = strings.TrimSpace(parts[0])
|
||||||
|
table = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedTable := ""
|
||||||
|
if schema != "" {
|
||||||
|
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||||
|
} else {
|
||||||
|
qualifiedTable = quoteIdent(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Deletes
|
||||||
|
for _, pk := range changes.Deletes {
|
||||||
|
var wheres []string
|
||||||
|
var args []interface{}
|
||||||
|
for k, v := range pk {
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = ?", quoteIdent(k)))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("delete error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Updates
|
||||||
|
for _, update := range changes.Updates {
|
||||||
|
var sets []string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
for k, v := range update.Values {
|
||||||
|
sets = append(sets, fmt.Sprintf("%s = ?", quoteIdent(k)))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wheres []string
|
||||||
|
for k, v := range update.Keys {
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = ?", quoteIdent(k)))
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
return fmt.Errorf("update requires keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("update error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Inserts
|
||||||
|
for _, row := range changes.Inserts {
|
||||||
|
var cols []string
|
||||||
|
var placeholders []string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
for k, v := range row {
|
||||||
|
cols = append(cols, quoteIdent(k))
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cols) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||||
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
|
return fmt.Errorf("insert error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
func (s *SQLiteDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
tables, err := s.GetTables(dbName)
|
tables, err := s.GetTables(dbName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
90
internal/redis/redis.go
Normal file
90
internal/redis/redis.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package redis
|
||||||
|
|
||||||
|
import "GoNavi-Wails/internal/connection"
|
||||||
|
|
||||||
|
// RedisValue represents a Redis value with its type and metadata
|
||||||
|
type RedisValue struct {
|
||||||
|
Type string `json:"type"` // string, hash, list, set, zset
|
||||||
|
TTL int64 `json:"ttl"` // TTL in seconds, -1 means no expiry, -2 means key doesn't exist
|
||||||
|
Value interface{} `json:"value"` // The actual value
|
||||||
|
Length int64 `json:"length"` // Length/size of the value
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisDBInfo represents information about a Redis database
|
||||||
|
type RedisDBInfo struct {
|
||||||
|
Index int `json:"index"` // Database index (0-15)
|
||||||
|
Keys int64 `json:"keys"` // Number of keys in this database
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisKeyInfo represents information about a Redis key
|
||||||
|
type RedisKeyInfo struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TTL int64 `json:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisScanResult represents the result of a SCAN operation
|
||||||
|
type RedisScanResult struct {
|
||||||
|
Keys []RedisKeyInfo `json:"keys"`
|
||||||
|
Cursor uint64 `json:"cursor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisClient defines the interface for Redis operations
|
||||||
|
type RedisClient interface {
|
||||||
|
// Connection management
|
||||||
|
Connect(config connection.ConnectionConfig) error
|
||||||
|
Close() error
|
||||||
|
Ping() error
|
||||||
|
|
||||||
|
// Key operations
|
||||||
|
ScanKeys(pattern string, cursor uint64, count int64) (*RedisScanResult, error)
|
||||||
|
GetKeyType(key string) (string, error)
|
||||||
|
GetTTL(key string) (int64, error)
|
||||||
|
SetTTL(key string, ttl int64) error
|
||||||
|
DeleteKeys(keys []string) (int64, error)
|
||||||
|
RenameKey(oldKey, newKey string) error
|
||||||
|
KeyExists(key string) (bool, error)
|
||||||
|
|
||||||
|
// Value operations
|
||||||
|
GetValue(key string) (*RedisValue, error)
|
||||||
|
|
||||||
|
// String operations
|
||||||
|
GetString(key string) (string, error)
|
||||||
|
SetString(key, value string, ttl int64) error
|
||||||
|
|
||||||
|
// Hash operations
|
||||||
|
GetHash(key string) (map[string]string, error)
|
||||||
|
SetHashField(key, field, value string) error
|
||||||
|
DeleteHashField(key string, fields ...string) error
|
||||||
|
|
||||||
|
// List operations
|
||||||
|
GetList(key string, start, stop int64) ([]string, error)
|
||||||
|
ListPush(key string, values ...string) error
|
||||||
|
ListSet(key string, index int64, value string) error
|
||||||
|
|
||||||
|
// Set operations
|
||||||
|
GetSet(key string) ([]string, error)
|
||||||
|
SetAdd(key string, members ...string) error
|
||||||
|
SetRemove(key string, members ...string) error
|
||||||
|
|
||||||
|
// Sorted Set operations
|
||||||
|
GetZSet(key string, start, stop int64) ([]ZSetMember, error)
|
||||||
|
ZSetAdd(key string, members ...ZSetMember) error
|
||||||
|
ZSetRemove(key string, members ...string) error
|
||||||
|
|
||||||
|
// Command execution
|
||||||
|
ExecuteCommand(args []string) (interface{}, error)
|
||||||
|
|
||||||
|
// Server information
|
||||||
|
GetServerInfo() (map[string]string, error)
|
||||||
|
GetDatabases() ([]RedisDBInfo, error)
|
||||||
|
SelectDB(index int) error
|
||||||
|
GetCurrentDB() int
|
||||||
|
FlushDB() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZSetMember represents a member in a sorted set
|
||||||
|
type ZSetMember struct {
|
||||||
|
Member string `json:"member"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
711
internal/redis/redis_impl.go
Normal file
711
internal/redis/redis_impl.go
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
package redis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
|
"GoNavi-Wails/internal/ssh"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedisClientImpl implements RedisClient using go-redis
|
||||||
|
type RedisClientImpl struct {
|
||||||
|
client *redis.Client
|
||||||
|
config connection.ConnectionConfig
|
||||||
|
currentDB int
|
||||||
|
forwarder *ssh.LocalForwarder
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRedisClient creates a new Redis client instance
|
||||||
|
func NewRedisClient() RedisClient {
|
||||||
|
return &RedisClientImpl{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect establishes a connection to Redis
|
||||||
|
func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error {
|
||||||
|
r.config = config
|
||||||
|
r.currentDB = config.RedisDB
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||||
|
|
||||||
|
// Handle SSH tunnel if enabled
|
||||||
|
if config.UseSSH {
|
||||||
|
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建 SSH 隧道失败: %w", err)
|
||||||
|
}
|
||||||
|
r.forwarder = forwarder
|
||||||
|
addr = forwarder.LocalAddr
|
||||||
|
logger.Infof("Redis 通过 SSH 隧道连接: %s -> %s:%d", addr, config.Host, config.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &redis.Options{
|
||||||
|
Addr: addr,
|
||||||
|
Password: config.Password,
|
||||||
|
DB: config.RedisDB,
|
||||||
|
DialTimeout: time.Duration(config.Timeout) * time.Second,
|
||||||
|
ReadTimeout: time.Duration(config.Timeout) * time.Second,
|
||||||
|
WriteTimeout: time.Duration(config.Timeout) * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.DialTimeout == 0 {
|
||||||
|
opts.DialTimeout = 30 * time.Second
|
||||||
|
opts.ReadTimeout = 30 * time.Second
|
||||||
|
opts.WriteTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
r.client = redis.NewClient(opts)
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opts.DialTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := r.client.Ping(ctx).Err(); err != nil {
|
||||||
|
r.client.Close()
|
||||||
|
r.client = nil
|
||||||
|
return fmt.Errorf("Redis 连接失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("Redis 连接成功: %s DB=%d", addr, config.RedisDB)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the Redis connection
|
||||||
|
func (r *RedisClientImpl) Close() error {
|
||||||
|
if r.client != nil {
|
||||||
|
err := r.client.Close()
|
||||||
|
r.client = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping tests the connection
|
||||||
|
func (r *RedisClientImpl) Ping() error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.Ping(ctx).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanKeys scans keys matching a pattern
|
||||||
|
func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (*RedisScanResult, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return nil, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if pattern == "" {
|
||||||
|
pattern = "*"
|
||||||
|
}
|
||||||
|
if count <= 0 {
|
||||||
|
count = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, count).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &RedisScanResult{
|
||||||
|
Keys: make([]RedisKeyInfo, 0, len(keys)),
|
||||||
|
Cursor: nextCursor,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get type and TTL for each key
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
typeResults := make([]*redis.StatusCmd, len(keys))
|
||||||
|
ttlResults := make([]*redis.DurationCmd, len(keys))
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
typeResults[i] = pipe.Type(ctx, key)
|
||||||
|
ttlResults[i] = pipe.TTL(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = pipe.Exec(ctx)
|
||||||
|
if err != nil && err != redis.Nil {
|
||||||
|
// Fallback: get info one by one
|
||||||
|
for _, key := range keys {
|
||||||
|
keyType, _ := r.GetKeyType(key)
|
||||||
|
ttl, _ := r.GetTTL(key)
|
||||||
|
result.Keys = append(result.Keys, RedisKeyInfo{
|
||||||
|
Key: key,
|
||||||
|
Type: keyType,
|
||||||
|
TTL: ttl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
keyType := typeResults[i].Val()
|
||||||
|
ttl := int64(ttlResults[i].Val().Seconds())
|
||||||
|
if ttlResults[i].Val() == -1 {
|
||||||
|
ttl = -1
|
||||||
|
} else if ttlResults[i].Val() == -2 {
|
||||||
|
ttl = -2
|
||||||
|
}
|
||||||
|
result.Keys = append(result.Keys, RedisKeyInfo{
|
||||||
|
Key: key,
|
||||||
|
Type: keyType,
|
||||||
|
TTL: ttl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKeyType returns the type of a key
|
||||||
|
func (r *RedisClientImpl) GetKeyType(key string) (string, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return "", fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.Type(ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTTL returns the TTL of a key in seconds
|
||||||
|
func (r *RedisClientImpl) GetTTL(key string) (int64, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return 0, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ttl, err := r.client.TTL(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ttl == -1 {
|
||||||
|
return -1, nil // No expiry
|
||||||
|
} else if ttl == -2 {
|
||||||
|
return -2, nil // Key doesn't exist
|
||||||
|
}
|
||||||
|
return int64(ttl.Seconds()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTTL sets the TTL of a key
|
||||||
|
func (r *RedisClientImpl) SetTTL(key string, ttl int64) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if ttl < 0 {
|
||||||
|
// Remove expiry
|
||||||
|
return r.client.Persist(ctx, key).Err()
|
||||||
|
}
|
||||||
|
return r.client.Expire(ctx, key, time.Duration(ttl)*time.Second).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteKeys deletes one or more keys
|
||||||
|
func (r *RedisClientImpl) DeleteKeys(keys []string) (int64, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return 0, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.Del(ctx, keys...).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameKey renames a key
|
||||||
|
func (r *RedisClientImpl) RenameKey(oldKey, newKey string) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.Rename(ctx, oldKey, newKey).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyExists checks if a key exists
|
||||||
|
func (r *RedisClientImpl) KeyExists(key string) (bool, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return false, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
n, err := r.client.Exists(ctx, key).Result()
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValue gets the value of a key with automatic type detection
|
||||||
|
func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return nil, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
|
||||||
|
keyType, err := r.GetKeyType(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl, _ := r.GetTTL(key)
|
||||||
|
|
||||||
|
result := &RedisValue{
|
||||||
|
Type: keyType,
|
||||||
|
TTL: ttl,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
switch keyType {
|
||||||
|
case "string":
|
||||||
|
val, err := r.client.Get(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Value = val
|
||||||
|
result.Length = int64(len(val))
|
||||||
|
|
||||||
|
case "hash":
|
||||||
|
val, err := r.client.HGetAll(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Value = val
|
||||||
|
result.Length = int64(len(val))
|
||||||
|
|
||||||
|
case "list":
|
||||||
|
length, err := r.client.LLen(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Get first 1000 items
|
||||||
|
limit := int64(1000)
|
||||||
|
if length < limit {
|
||||||
|
limit = length
|
||||||
|
}
|
||||||
|
val, err := r.client.LRange(ctx, key, 0, limit-1).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Value = val
|
||||||
|
result.Length = length
|
||||||
|
|
||||||
|
case "set":
|
||||||
|
length, err := r.client.SCard(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Get members using SMembers (limited by Redis server)
|
||||||
|
members, err := r.client.SMembers(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Value = members
|
||||||
|
result.Length = length
|
||||||
|
|
||||||
|
case "zset":
|
||||||
|
length, err := r.client.ZCard(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Get first 1000 members with scores
|
||||||
|
limit := int64(1000)
|
||||||
|
if length < limit {
|
||||||
|
limit = length
|
||||||
|
}
|
||||||
|
val, err := r.client.ZRangeWithScores(ctx, key, 0, limit-1).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
members := make([]ZSetMember, len(val))
|
||||||
|
for i, z := range val {
|
||||||
|
members[i] = ZSetMember{
|
||||||
|
Member: z.Member.(string),
|
||||||
|
Score: z.Score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Value = members
|
||||||
|
result.Length = length
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("不支持的 Redis 数据类型: %s", keyType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetString gets a string value
|
||||||
|
func (r *RedisClientImpl) GetString(key string) (string, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return "", fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.Get(ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetString sets a string value with optional TTL
|
||||||
|
func (r *RedisClientImpl) SetString(key, value string, ttl int64) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var expiration time.Duration
|
||||||
|
if ttl > 0 {
|
||||||
|
expiration = time.Duration(ttl) * time.Second
|
||||||
|
}
|
||||||
|
return r.client.Set(ctx, key, value, expiration).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHash gets all fields of a hash
|
||||||
|
func (r *RedisClientImpl) GetHash(key string) (map[string]string, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return nil, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.HGetAll(ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHashField sets a field in a hash
|
||||||
|
func (r *RedisClientImpl) SetHashField(key, field, value string) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.HSet(ctx, key, field, value).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteHashField deletes fields from a hash
|
||||||
|
func (r *RedisClientImpl) DeleteHashField(key string, fields ...string) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.HDel(ctx, key, fields...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetList gets a range of elements from a list
|
||||||
|
func (r *RedisClientImpl) GetList(key string, start, stop int64) ([]string, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return nil, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.LRange(ctx, key, start, stop).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPush pushes values to the end of a list
|
||||||
|
func (r *RedisClientImpl) ListPush(key string, values ...string) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
args := make([]interface{}, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
args[i] = v
|
||||||
|
}
|
||||||
|
return r.client.RPush(ctx, key, args...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSet sets the value at an index in a list
|
||||||
|
func (r *RedisClientImpl) ListSet(key string, index int64, value string) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.LSet(ctx, key, index, value).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSet gets all members of a set
|
||||||
|
func (r *RedisClientImpl) GetSet(key string) ([]string, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return nil, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.SMembers(ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAdd adds members to a set
|
||||||
|
func (r *RedisClientImpl) SetAdd(key string, members ...string) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
args := make([]interface{}, len(members))
|
||||||
|
for i, m := range members {
|
||||||
|
args[i] = m
|
||||||
|
}
|
||||||
|
return r.client.SAdd(ctx, key, args...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRemove removes members from a set
|
||||||
|
func (r *RedisClientImpl) SetRemove(key string, members ...string) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
args := make([]interface{}, len(members))
|
||||||
|
for i, m := range members {
|
||||||
|
args[i] = m
|
||||||
|
}
|
||||||
|
return r.client.SRem(ctx, key, args...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetZSet gets members with scores from a sorted set
|
||||||
|
func (r *RedisClientImpl) GetZSet(key string, start, stop int64) ([]ZSetMember, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return nil, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
val, err := r.client.ZRangeWithScores(ctx, key, start, stop).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
members := make([]ZSetMember, len(val))
|
||||||
|
for i, z := range val {
|
||||||
|
members[i] = ZSetMember{
|
||||||
|
Member: z.Member.(string),
|
||||||
|
Score: z.Score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return members, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZSetAdd adds members to a sorted set
|
||||||
|
func (r *RedisClientImpl) ZSetAdd(key string, members ...ZSetMember) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
zMembers := make([]redis.Z, len(members))
|
||||||
|
for i, m := range members {
|
||||||
|
zMembers[i] = redis.Z{
|
||||||
|
Score: m.Score,
|
||||||
|
Member: m.Member,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.client.ZAdd(ctx, key, zMembers...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZSetRemove removes members from a sorted set
|
||||||
|
func (r *RedisClientImpl) ZSetRemove(key string, members ...string) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
args := make([]interface{}, len(members))
|
||||||
|
for i, m := range members {
|
||||||
|
args[i] = m
|
||||||
|
}
|
||||||
|
return r.client.ZRem(ctx, key, args...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteCommand executes a raw Redis command
|
||||||
|
func (r *RedisClientImpl) ExecuteCommand(args []string) (interface{}, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return nil, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil, fmt.Errorf("命令不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Convert to []interface{}
|
||||||
|
cmdArgs := make([]interface{}, len(args))
|
||||||
|
for i, arg := range args {
|
||||||
|
cmdArgs[i] = arg
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.client.Do(ctx, cmdArgs...).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatCommandResult(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCommandResult formats the command result for display
|
||||||
|
func formatCommandResult(result interface{}) interface{} {
|
||||||
|
switch v := result.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
formatted := make([]interface{}, len(v))
|
||||||
|
for i, item := range v {
|
||||||
|
formatted[i] = formatCommandResult(item)
|
||||||
|
}
|
||||||
|
return formatted
|
||||||
|
case []byte:
|
||||||
|
return string(v)
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServerInfo returns server information
|
||||||
|
func (r *RedisClientImpl) GetServerInfo() (map[string]string, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return nil, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
info, err := r.client.Info(ctx).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]string)
|
||||||
|
lines := strings.Split(info, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
result[parts[0]] = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDatabases returns information about all databases
|
||||||
|
func (r *RedisClientImpl) GetDatabases() ([]RedisDBInfo, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return nil, fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Get keyspace info
|
||||||
|
info, err := r.client.Info(ctx, "keyspace").Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse keyspace info
|
||||||
|
dbMap := make(map[int]int64)
|
||||||
|
lines := strings.Split(info, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "db") {
|
||||||
|
// Format: db0:keys=123,expires=0,avg_ttl=0
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dbIndex, err := strconv.Atoi(strings.TrimPrefix(parts[0], "db"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Parse keys count
|
||||||
|
kvPairs := strings.Split(parts[1], ",")
|
||||||
|
for _, kv := range kvPairs {
|
||||||
|
if strings.HasPrefix(kv, "keys=") {
|
||||||
|
keys, _ := strconv.ParseInt(strings.TrimPrefix(kv, "keys="), 10, 64)
|
||||||
|
dbMap[dbIndex] = keys
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return all 16 databases (0-15)
|
||||||
|
result := make([]RedisDBInfo, 16)
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
result[i] = RedisDBInfo{
|
||||||
|
Index: i,
|
||||||
|
Keys: dbMap[i], // Will be 0 if not in map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectDB selects a database
|
||||||
|
func (r *RedisClientImpl) SelectDB(index int) error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
if index < 0 || index > 15 {
|
||||||
|
return fmt.Errorf("数据库索引必须在 0-15 之间")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new client with different DB
|
||||||
|
addr := fmt.Sprintf("%s:%d", r.config.Host, r.config.Port)
|
||||||
|
if r.forwarder != nil {
|
||||||
|
addr = r.forwarder.LocalAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &redis.Options{
|
||||||
|
Addr: addr,
|
||||||
|
Password: r.config.Password,
|
||||||
|
DB: index,
|
||||||
|
DialTimeout: time.Duration(r.config.Timeout) * time.Second,
|
||||||
|
ReadTimeout: time.Duration(r.config.Timeout) * time.Second,
|
||||||
|
WriteTimeout: time.Duration(r.config.Timeout) * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.DialTimeout == 0 {
|
||||||
|
opts.DialTimeout = 30 * time.Second
|
||||||
|
opts.ReadTimeout = 30 * time.Second
|
||||||
|
opts.WriteTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
newClient := redis.NewClient(opts)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opts.DialTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := newClient.Ping(ctx).Err(); err != nil {
|
||||||
|
newClient.Close()
|
||||||
|
return fmt.Errorf("切换数据库失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close old client and replace
|
||||||
|
r.client.Close()
|
||||||
|
r.client = newClient
|
||||||
|
r.currentDB = index
|
||||||
|
|
||||||
|
logger.Infof("Redis 切换到数据库: db%d", index)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentDB returns the current database index
|
||||||
|
func (r *RedisClientImpl) GetCurrentDB() int {
|
||||||
|
return r.currentDB
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushDB flushes the current database
|
||||||
|
func (r *RedisClientImpl) FlushDB() error {
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("Redis 客户端未连接")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return r.client.FlushDB(ctx).Err()
|
||||||
|
}
|
||||||
52
logo.svg
Normal file
52
logo.svg
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<!-- Background: Soft Light Grey -->
|
||||||
|
<linearGradient id="bgSoft" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#f5f7fa;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#c3cfe2;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Hexagon: Solid Tech Pink -->
|
||||||
|
<linearGradient id="solidPink" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#FF5F6D;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#FFC371;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- N: Solid Tech Blue/Cyan -->
|
||||||
|
<linearGradient id="solidCyan" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#00c6ff;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#0072ff;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id="hardShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
|
||||||
|
<feOffset dx="4" dy="4" result="offsetblur"/>
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope="0.2"/>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<rect x="32" y="32" width="448" height="448" rx="100" fill="url(#bgSoft)" />
|
||||||
|
|
||||||
|
<!-- Main Content Centered -->
|
||||||
|
<g transform="translate(106, 106) scale(0.6)" filter="url(#hardShadow)">
|
||||||
|
|
||||||
|
<!-- Hex G -->
|
||||||
|
<path d="M 250 0 L 466 125 L 466 375 L 250 500 L 34 375 L 34 125 Z"
|
||||||
|
fill="none" stroke="url(#solidPink)" stroke-width="45" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- G Crossbar -->
|
||||||
|
<path d="M 466 300 L 330 300" stroke="url(#solidPink)" stroke-width="45" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Inner N -->
|
||||||
|
<path d="M 160 350 L 160 150 L 340 350 L 340 150"
|
||||||
|
fill="none" stroke="url(#solidCyan)" stroke-width="50" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
17
main.go
17
main.go
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:frontend/dist
|
//go:embed all:frontend/dist
|
||||||
@@ -20,18 +21,26 @@ func main() {
|
|||||||
|
|
||||||
// Create application with options
|
// Create application with options
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
Title: "GoNavi",
|
Title: "GoNavi",
|
||||||
Width: 1024,
|
Width: 1024,
|
||||||
Height: 768,
|
Height: 768,
|
||||||
|
Frameless: true,
|
||||||
AssetServer: &assetserver.Options{
|
AssetServer: &assetserver.Options{
|
||||||
Assets: assets,
|
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,
|
OnStartup: application.Startup,
|
||||||
OnShutdown: application.Shutdown,
|
OnShutdown: application.Shutdown,
|
||||||
Bind: []interface{}{
|
Bind: []interface{}{
|
||||||
application,
|
application,
|
||||||
},
|
},
|
||||||
|
Windows: &windows.Options{
|
||||||
|
WebviewIsTransparent: true,
|
||||||
|
WindowIsTranslucent: true,
|
||||||
|
BackdropType: windows.Acrylic,
|
||||||
|
DisableWindowIcon: false,
|
||||||
|
DisableFramelessWindowDecorations: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user