mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-06 06:29:35 +08:00
Compare commits
66 Commits
feature/da
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b67135e2c1 | ||
|
|
f5e16b0b70 | ||
|
|
f8535dd272 | ||
|
|
5cd8681b80 | ||
|
|
4b381c82b5 | ||
|
|
820b064e7f | ||
|
|
70cb6148c6 | ||
|
|
0cb9cb8bc9 | ||
|
|
c2c88d743b | ||
|
|
e8ef6b0b38 | ||
|
|
257459f96a | ||
|
|
027115ab87 | ||
|
|
96cb8134c4 | ||
|
|
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 | ||
|
|
6674ad69e1 | ||
|
|
37d35684f1 | ||
|
|
71e5de0cdc | ||
|
|
d8656c6c9c | ||
|
|
443b487a02 | ||
|
|
bac57ebdf0 | ||
|
|
213a33e4f3 | ||
|
|
a00f87582d | ||
|
|
f129623000 | ||
|
|
8dbc97e466 | ||
|
|
4a0db185c0 | ||
|
|
5793f63ac8 | ||
|
|
8aabc67634 | ||
|
|
34c494ce51 | ||
|
|
178de02783 | ||
|
|
94e5b8d2c6 | ||
|
|
89e2247c05 | ||
|
|
b2ede61b79 | ||
|
|
db381ae9d1 | ||
|
|
f946cfd647 | ||
|
|
791425a5a8 | ||
|
|
d7acfd1af9 | ||
|
|
88952e87c1 | ||
|
|
c981a65834 | ||
|
|
b9d9ab5464 | ||
|
|
6b503480cf |
58
.github/ISSUE_TEMPLATE/01-bug_report.yml
vendored
Normal file
58
.github/ISSUE_TEMPLATE/01-bug_report.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
name: 问题反馈
|
||||||
|
description: 软件问题反馈
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: searched
|
||||||
|
attributes:
|
||||||
|
label: 已经搜索过 Issues,未发现重复问题*
|
||||||
|
options:
|
||||||
|
- label: 我已经搜索过 Issues,没有发现重复问题
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: system
|
||||||
|
attributes:
|
||||||
|
label: 操作系统及版本
|
||||||
|
placeholder: Windows 10 22H2 / macOS Mojave / Linux
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: 软件安装版本
|
||||||
|
placeholder: v0.2.3
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: 问题简述及复现流程
|
||||||
|
description: 请详细描述你遇到的问题,并提供复现步骤
|
||||||
|
placeholder: |
|
||||||
|
1. 打开软件
|
||||||
|
2. 点击 xxx
|
||||||
|
3. 预期结果是 ...
|
||||||
|
4. 实际结果是 ...
|
||||||
|
5. 截图 ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: 其他补充
|
||||||
|
description: 如果你有额外信息,请在此填写
|
||||||
|
placeholder: 可选
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: pr
|
||||||
|
attributes:
|
||||||
|
label: 是否愿意提交 PR 修复当前 Issue
|
||||||
|
options:
|
||||||
|
- label: 我愿意尝试提交 PR
|
||||||
37
.github/ISSUE_TEMPLATE/02-feature_request.yml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/02-feature_request.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: 功能建议
|
||||||
|
description: 添加全新功能或改进现有功能
|
||||||
|
title: "[Enhancement] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: searched
|
||||||
|
attributes:
|
||||||
|
label: 已经搜索过 Issues,未发现重复问题*
|
||||||
|
options:
|
||||||
|
- label: 我已经搜索过 Issues,没有发现重复问题
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: feature
|
||||||
|
attributes:
|
||||||
|
label: 功能描述
|
||||||
|
description: 请详细描述你希望添加或改进的功能
|
||||||
|
placeholder: 请描述你想要的功能
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: 其他补充
|
||||||
|
description: 如果你有额外信息,请在此填写
|
||||||
|
placeholder: 可选
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: pr
|
||||||
|
attributes:
|
||||||
|
label: 是否愿意提交 PR 实现当前 Issue
|
||||||
|
options:
|
||||||
|
- label: 我愿意尝试提交 PR
|
||||||
30
.github/ISSUE_TEMPLATE/03-generic.yml
vendored
Normal file
30
.github/ISSUE_TEMPLATE/03-generic.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: 其他反馈
|
||||||
|
description: 其他类型反馈、建议或讨论
|
||||||
|
title: "[Question] "
|
||||||
|
labels: ["question"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: searched
|
||||||
|
attributes:
|
||||||
|
label: 已经搜索过 Issues,未发现重复问题*
|
||||||
|
options:
|
||||||
|
- label: 我已经搜索过 Issues,没有发现重复问题
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: content
|
||||||
|
attributes:
|
||||||
|
label: 内容
|
||||||
|
description: 请填写你的反馈、建议或讨论内容
|
||||||
|
placeholder: 请描述你的问题或想法
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: 其他补充
|
||||||
|
description: 如果你有额外信息,请在此填写
|
||||||
|
placeholder: 可选
|
||||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
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 |
68
build/darwin/Info.dev.plist
Normal file
68
build/darwin/Info.dev.plist
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>{{.Info.ProductName}}</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>{{.OutputFilename}}</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.wails.{{.Name}}.dev</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>{{.Info.Comments}}</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>iconfile</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.13.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>{{.Info.Copyright}}</string>
|
||||||
|
{{if .Info.FileAssociations}}
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.FileAssociations}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeExtensions</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Ext}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>{{.Name}}</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
<key>CFBundleTypeIconFile</key>
|
||||||
|
<string>{{.IconName}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
{{if .Info.Protocols}}
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.Protocols}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.wails.{{.Scheme}}</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Scheme}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
63
build/darwin/Info.plist
Normal file
63
build/darwin/Info.plist
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>{{.Info.ProductName}}</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>{{.OutputFilename}}</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.wails.{{.Name}}</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>{{.Info.Comments}}</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>iconfile</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.13.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>{{.Info.Copyright}}</string>
|
||||||
|
{{if .Info.FileAssociations}}
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.FileAssociations}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeExtensions</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Ext}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>{{.Name}}</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
<key>CFBundleTypeIconFile</key>
|
||||||
|
<string>{{.IconName}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
{{if .Info.Protocols}}
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.Protocols}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.wails.{{.Scheme}}</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Scheme}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
build/darwin/icon.icns
Normal file
BIN
build/darwin/icon.icns
Normal file
Binary file not shown.
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 @@
|
|||||||
d0f9366af59a6367ad3c7e2d4185ead4
|
5b8157374dae5f9340e31b2d0bd2c00e
|
||||||
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,11 @@ 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 */
|
||||||
|
}
|
||||||
|
|
||||||
|
body, #root {
|
||||||
|
border-radius: 14px; /* Slightly rounded app window corners */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 侧边栏 Tree 样式优化 */
|
/* 侧边栏 Tree 样式优化 */
|
||||||
@@ -30,4 +35,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,8 @@
|
|||||||
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, Progress } 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 { EventsOn } from '../wailsjs/runtime/runtime';
|
||||||
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';
|
||||||
@@ -9,6 +10,7 @@ import DataSyncModal from './components/DataSyncModal';
|
|||||||
import LogPanel from './components/LogPanel';
|
import LogPanel from './components/LogPanel';
|
||||||
import { useStore } from './store';
|
import { useStore } from './store';
|
||||||
import { SavedConnection } from './types';
|
import { SavedConnection } from './types';
|
||||||
|
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform } from './utils/appearance';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const { Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
@@ -17,7 +19,240 @@ 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';
|
||||||
|
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||||
|
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
|
||||||
|
const blurFilter = blurToFilter(effectiveBlur);
|
||||||
|
const windowCornerRadius = 14;
|
||||||
|
|
||||||
|
// Background Helper
|
||||||
|
const getBg = (darkHex: string, lightHex: string) => {
|
||||||
|
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
|
||||||
|
|
||||||
|
// Parse hex to rgb
|
||||||
|
const hex = darkHex.replace('#', '');
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${effectiveOpacity})`;
|
||||||
|
};
|
||||||
|
// Specific colors
|
||||||
|
const bgMain = getBg('#141414', '#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 updateDownloadMetaRef = React.useRef<UpdateDownloadResultData | null>(null);
|
||||||
|
const updateDeferredVersionRef = React.useRef<string | null>(null);
|
||||||
|
const updateNotifiedVersionRef = React.useRef<string | null>(null);
|
||||||
|
const updateMutedVersionRef = React.useRef<string | null>(null);
|
||||||
|
const [isAboutOpen, setIsAboutOpen] = useState(false);
|
||||||
|
const [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);
|
||||||
|
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
version: string;
|
||||||
|
status: 'idle' | 'start' | 'downloading' | 'done' | 'error';
|
||||||
|
percent: number;
|
||||||
|
downloaded: number;
|
||||||
|
total: number;
|
||||||
|
message: string;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
version: '',
|
||||||
|
status: 'idle',
|
||||||
|
percent: 0,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 0,
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
type UpdateInfo = {
|
||||||
|
hasUpdate: boolean;
|
||||||
|
currentVersion: string;
|
||||||
|
latestVersion: string;
|
||||||
|
releaseName?: string;
|
||||||
|
releaseNotesUrl?: string;
|
||||||
|
assetName?: string;
|
||||||
|
assetUrl?: string;
|
||||||
|
assetSize?: number;
|
||||||
|
sha256?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateDownloadProgressEvent = {
|
||||||
|
status?: 'start' | 'downloading' | 'done' | 'error';
|
||||||
|
percent?: number;
|
||||||
|
downloaded?: number;
|
||||||
|
total?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateDownloadResultData = {
|
||||||
|
info?: UpdateInfo;
|
||||||
|
downloadPath?: string;
|
||||||
|
installLogPath?: string;
|
||||||
|
installTarget?: string;
|
||||||
|
platform?: string;
|
||||||
|
autoRelaunch?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBytes = (bytes?: number) => {
|
||||||
|
if (!bytes || bytes <= 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let value = bytes;
|
||||||
|
let idx = 0;
|
||||||
|
while (value >= 1024 && idx < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptRestartForUpdate = (info: UpdateInfo, resultData?: UpdateDownloadResultData) => {
|
||||||
|
const downloadPathHint = resultData?.downloadPath
|
||||||
|
? `更新包路径:${resultData.downloadPath}`
|
||||||
|
: '';
|
||||||
|
const installLogHint = resultData?.installLogPath
|
||||||
|
? `安装日志:${resultData.installLogPath}`
|
||||||
|
: '';
|
||||||
|
Modal.confirm({
|
||||||
|
title: '更新已下载',
|
||||||
|
content: (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, userSelect: 'text' }}>
|
||||||
|
<div>{`版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`}</div>
|
||||||
|
{downloadPathHint ? <div style={{ fontSize: 12, color: '#8c8c8c' }}>{downloadPathHint}</div> : null}
|
||||||
|
{installLogHint ? <div style={{ fontSize: 12, color: '#8c8c8c' }}>{installLogHint}</div> : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
okText: '立即重启',
|
||||||
|
cancelText: '稍后',
|
||||||
|
onOk: async () => {
|
||||||
|
updateDeferredVersionRef.current = null;
|
||||||
|
const res = await (window as any).go.app.App.InstallUpdateAndRestart();
|
||||||
|
if (!res?.success) {
|
||||||
|
message.error('更新安装失败: ' + (res?.message || '未知错误'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
updateDeferredVersionRef.current = info.latestVersion;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadUpdate = React.useCallback(async (info: UpdateInfo, silent: boolean) => {
|
||||||
|
if (updateDownloadInFlightRef.current) return;
|
||||||
|
if (updateDownloadedVersionRef.current === info.latestVersion) {
|
||||||
|
if (!silent) {
|
||||||
|
const cachedDownloadPath = updateDownloadMetaRef.current?.downloadPath;
|
||||||
|
message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion})`);
|
||||||
|
}
|
||||||
|
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
|
||||||
|
promptRestartForUpdate(info, updateDownloadMetaRef.current || undefined);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateDownloadInFlightRef.current = true;
|
||||||
|
updateDownloadMetaRef.current = null;
|
||||||
|
const key = 'update-download';
|
||||||
|
setUpdateDownloadProgress({
|
||||||
|
open: true,
|
||||||
|
version: info.latestVersion,
|
||||||
|
status: 'start',
|
||||||
|
percent: 0,
|
||||||
|
downloaded: 0,
|
||||||
|
total: info.assetSize || 0,
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
message.loading({ content: `正在下载更新 ${info.latestVersion}...`, key, duration: 0 });
|
||||||
|
const res = await (window as any).go.app.App.DownloadUpdate();
|
||||||
|
updateDownloadInFlightRef.current = false;
|
||||||
|
if (res?.success) {
|
||||||
|
const resultData = (res?.data || {}) as UpdateDownloadResultData;
|
||||||
|
updateDownloadMetaRef.current = resultData;
|
||||||
|
updateDownloadedVersionRef.current = info.latestVersion;
|
||||||
|
setUpdateDownloadProgress(prev => ({ ...prev, status: 'done', percent: 100, open: false }));
|
||||||
|
if (resultData?.downloadPath) {
|
||||||
|
message.success({ content: `更新下载完成,更新包路径:${resultData.downloadPath}`, key, duration: 5 });
|
||||||
|
} else {
|
||||||
|
message.success({ content: '更新下载完成', key, duration: 2 });
|
||||||
|
}
|
||||||
|
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,待重启安装)`);
|
||||||
|
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
|
||||||
|
promptRestartForUpdate(info, resultData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUpdateDownloadProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'error',
|
||||||
|
message: res?.message || '未知错误'
|
||||||
|
}));
|
||||||
|
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 +272,8 @@ function App() {
|
|||||||
title: '新建查询',
|
title: '新建查询',
|
||||||
type: 'query',
|
type: 'query',
|
||||||
connectionId: connId,
|
connectionId: connId,
|
||||||
dbName: db
|
dbName: db,
|
||||||
|
query: ''
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,13 +315,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 +327,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);
|
||||||
@@ -152,6 +413,14 @@ function App() {
|
|||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingConnection(null);
|
setEditingConnection(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTitleBarDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('[data-no-titlebar-toggle="true"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(window as any).runtime.WindowToggleMaximise();
|
||||||
|
};
|
||||||
|
|
||||||
// Sidebar Resizing
|
// Sidebar Resizing
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(300);
|
const [sidebarWidth, setSidebarWidth] = useState(300);
|
||||||
@@ -214,52 +483,214 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (darkMode) {
|
document.body.style.backgroundColor = 'transparent';
|
||||||
document.body.style.backgroundColor = '#141414';
|
document.body.style.color = darkMode ? '#ffffff' : '#000000';
|
||||||
document.body.style.color = '#ffffff';
|
document.body.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||||
} else {
|
|
||||||
document.body.style.backgroundColor = '#ffffff';
|
|
||||||
document.body.style.color = '#000000';
|
|
||||||
}
|
|
||||||
}, [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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const offDownloadProgress = EventsOn('update:download-progress', (event: UpdateDownloadProgressEvent) => {
|
||||||
|
if (!event) return;
|
||||||
|
const status = event.status || 'downloading';
|
||||||
|
const nextStatus: 'idle' | 'start' | 'downloading' | 'done' | 'error' =
|
||||||
|
status === 'start' || status === 'downloading' || status === 'done' || status === 'error'
|
||||||
|
? status
|
||||||
|
: 'downloading';
|
||||||
|
const downloaded = typeof event.downloaded === 'number' ? event.downloaded : 0;
|
||||||
|
const total = typeof event.total === 'number' ? event.total : 0;
|
||||||
|
const percentRaw = typeof event.percent === 'number'
|
||||||
|
? event.percent
|
||||||
|
: (total > 0 ? (downloaded / total) * 100 : 0);
|
||||||
|
const percent = Math.max(0, Math.min(100, percentRaw));
|
||||||
|
setUpdateDownloadProgress(prev => ({
|
||||||
|
open: nextStatus === 'start' || nextStatus === 'downloading' || nextStatus === 'error',
|
||||||
|
version: prev.version,
|
||||||
|
status: nextStatus,
|
||||||
|
percent,
|
||||||
|
downloaded,
|
||||||
|
total,
|
||||||
|
message: String(event.message || '')
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
offDownloadProgress();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
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, ${effectiveOpacity})`
|
||||||
|
: `rgba(255, 255, 255, ${effectiveOpacity})`,
|
||||||
|
colorBgElevated: darkMode
|
||||||
|
? '#1f1f1f'
|
||||||
|
: '#ffffff',
|
||||||
|
colorFillAlter: darkMode
|
||||||
|
? `rgba(38, 38, 38, ${effectiveOpacity})`
|
||||||
|
: `rgba(250, 250, 250, ${effectiveOpacity})`,
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
borderRadius: windowCornerRadius,
|
||||||
|
clipPath: `inset(0 round ${windowCornerRadius}px)`,
|
||||||
|
backdropFilter: blurFilter,
|
||||||
|
WebkitBackdropFilter: blurFilter,
|
||||||
|
}}>
|
||||||
|
{/* Custom Title Bar */}
|
||||||
|
<div
|
||||||
|
onDoubleClick={handleTitleBarDoubleClick}
|
||||||
|
style={{
|
||||||
|
height: 32,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
background: bgMain,
|
||||||
|
backdropFilter: blurFilter,
|
||||||
|
WebkitBackdropFilter: blurFilter,
|
||||||
|
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
|
||||||
|
data-no-titlebar-toggle="true"
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<MinusOutlined />}
|
||||||
|
style={{ height: '100%', borderRadius: 0, width: 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: blurFilter,
|
||||||
|
WebkitBackdropFilter: blurFilter,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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: '1px solid rgba(128,128,128,0.2)',
|
||||||
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,18 +716,19 @@ 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, overflow: 'hidden' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, backdropFilter: blurFilter, WebkitBackdropFilter: blurFilter }}>
|
||||||
<TabManager />
|
<TabManager />
|
||||||
</div>
|
</div>
|
||||||
{isLogPanelOpen && (
|
{isLogPanelOpen && (
|
||||||
<LogPanel
|
<LogPanel
|
||||||
height={logPanelHeight}
|
height={logPanelHeight}
|
||||||
onClose={() => setIsLogPanelOpen(false)}
|
onClose={() => setIsLogPanelOpen(false)}
|
||||||
onResizeStart={handleLogResizeStart}
|
onResizeStart={handleLogResizeStart}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Content>
|
</Content>
|
||||||
|
</Layout>
|
||||||
<ConnectionModal
|
<ConnectionModal
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
@@ -306,6 +738,148 @@ 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 ?? 1.0}
|
||||||
|
onChange={(v) => setAppearance({ opacity: v })}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<span style={{ width: 40 }}>{Math.round((appearance.opacity ?? 1.0) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8, fontWeight: 500 }}>高斯模糊 (Blur)</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={updateDownloadProgress.version ? `下载更新 ${updateDownloadProgress.version}` : '下载更新'}
|
||||||
|
open={updateDownloadProgress.open}
|
||||||
|
closable={updateDownloadProgress.status === 'error'}
|
||||||
|
maskClosable={false}
|
||||||
|
keyboard={updateDownloadProgress.status === 'error'}
|
||||||
|
onCancel={() => {
|
||||||
|
if (updateDownloadProgress.status === 'error') {
|
||||||
|
setUpdateDownloadProgress({
|
||||||
|
open: false,
|
||||||
|
version: '',
|
||||||
|
status: 'idle',
|
||||||
|
percent: 0,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 0,
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
footer={updateDownloadProgress.status === 'error' ? [
|
||||||
|
<Button
|
||||||
|
key="close"
|
||||||
|
onClick={() => setUpdateDownloadProgress({
|
||||||
|
open: false,
|
||||||
|
version: '',
|
||||||
|
status: 'idle',
|
||||||
|
percent: 0,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 0,
|
||||||
|
message: ''
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
] : null}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round(updateDownloadProgress.percent)}
|
||||||
|
status={updateDownloadProgress.status === 'error' ? 'exception' : (updateDownloadProgress.status === 'done' ? 'success' : 'active')}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||||
|
{`${formatBytes(updateDownloadProgress.downloaded)} / ${formatBytes(updateDownloadProgress.total)}`}
|
||||||
|
</div>
|
||||||
|
{updateDownloadProgress.message ? (
|
||||||
|
<div style={{ fontSize: 12, color: '#ff4d4f' }}>{updateDownloadProgress.message}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Ghost Resize Line for Sidebar */}
|
{/* Ghost Resize Line for Sidebar */}
|
||||||
<div
|
<div
|
||||||
@@ -343,4 +917,4 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -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>)}
|
||||||
@@ -264,8 +324,8 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
{useSSH && (
|
{useSSH && (
|
||||||
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 6, marginTop: 12 }}>
|
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 6, marginTop: 12 }}>
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
<Form.Item name="sshHost" label="SSH 主机" rules={[{ required: useSSH, message: '请输入SSH主机' }]} style={{ flex: 1 }}>
|
<Form.Item name="sshHost" label="SSH 主机 (域名或IP)" rules={[{ required: useSSH, message: '请输入SSH主机' }]} style={{ flex: 1 }}>
|
||||||
<Input placeholder="ssh.example.com" />
|
<Input placeholder="例如: ssh.example.com 或 192.168.1.100" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="sshPort" label="端口" rules={[{ required: useSSH, message: '请输入SSH端口' }]} style={{ width: 100 }}>
|
<Form.Item name="sshPort" label="端口" rules={[{ required: useSSH, message: '请输入SSH端口' }]} style={{ width: 100 }}>
|
||||||
<InputNumber style={{ width: '100%' }} />
|
<InputNumber style={{ width: '100%' }} />
|
||||||
@@ -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>
|
||||||
];
|
];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,16 +4,20 @@ import { TabData, ColumnDefinition } from '../types';
|
|||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||||
|
import { buildWhereSQL, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||||
|
|
||||||
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
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>('');
|
||||||
|
const pkSeqRef = useRef(0);
|
||||||
|
const pkKeyRef = useRef<string>('');
|
||||||
|
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
current: 1,
|
current: 1,
|
||||||
@@ -27,6 +31,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const [showFilter, setShowFilter] = useState(false);
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
const [filterConditions, setFilterConditions] = useState<any[]>([]);
|
const [filterConditions, setFilterConditions] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPkColumns([]);
|
||||||
|
pkKeyRef.current = '';
|
||||||
|
countKeyRef.current = '';
|
||||||
|
setPagination(prev => ({ ...prev, current: 1, total: 0, totalKnown: false }));
|
||||||
|
}, [tab.connectionId, tab.dbName, tab.tableName]);
|
||||||
|
|
||||||
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize) => {
|
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize) => {
|
||||||
const seq = ++fetchSeqRef.current;
|
const seq = ++fetchSeqRef.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -46,40 +57,18 @@ const DataViewer: 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 quoteIdentPart = (ident: string) => {
|
const dbType = config.type || '';
|
||||||
if (!ident) return ident;
|
|
||||||
if (config.type === 'mysql') return `\`${ident.replace(/`/g, '``')}\``;
|
|
||||||
return `"${ident.replace(/"/g, '""')}"`;
|
|
||||||
};
|
|
||||||
const quoteQualifiedIdent = (ident: string) => {
|
|
||||||
const raw = (ident || '').trim();
|
|
||||||
if (!raw) return raw;
|
|
||||||
const parts = raw.split('.').filter(Boolean);
|
|
||||||
if (parts.length <= 1) return quoteIdentPart(raw);
|
|
||||||
return parts.map(quoteIdentPart).join('.');
|
|
||||||
};
|
|
||||||
const escapeLiteral = (val: string) => val.replace(/'/g, "''");
|
|
||||||
|
|
||||||
const dbName = tab.dbName || '';
|
const dbName = tab.dbName || '';
|
||||||
const tableName = tab.tableName || '';
|
const tableName = tab.tableName || '';
|
||||||
|
|
||||||
const whereParts: string[] = [];
|
const whereSQL = buildWhereSQL(dbType, filterConditions);
|
||||||
filterConditions.forEach(cond => {
|
|
||||||
if (cond.column && cond.value) {
|
|
||||||
if (cond.op === 'LIKE') {
|
|
||||||
whereParts.push(`${quoteIdentPart(cond.column)} LIKE '%${escapeLiteral(cond.value)}%'`);
|
|
||||||
} else {
|
|
||||||
whereParts.push(`${quoteIdentPart(cond.column)} ${cond.op} '${escapeLiteral(cond.value)}'`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const whereSQL = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : "";
|
|
||||||
|
|
||||||
const countSql = `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(tableName)} ${whereSQL}`;
|
const countSql = `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||||
|
|
||||||
let sql = `SELECT * FROM ${quoteQualifiedIdent(tableName)} ${whereSQL}`;
|
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||||
if (sortInfo && sortInfo.order) {
|
if (sortInfo && sortInfo.order) {
|
||||||
sql += ` ORDER BY ${quoteIdentPart(sortInfo.columnKey)} ${sortInfo.order === 'ascend' ? 'ASC' : 'DESC'}`;
|
sql += ` ORDER BY ${quoteIdentPart(dbType, sortInfo.columnKey)} ${sortInfo.order === 'ascend' ? 'ASC' : 'DESC'}`;
|
||||||
}
|
}
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
|
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
|
||||||
@@ -89,11 +78,6 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
try {
|
try {
|
||||||
const pData = DBQuery(config as any, dbName, sql);
|
const pData = DBQuery(config as any, dbName, sql);
|
||||||
|
|
||||||
let pCols: Promise<any> | null = null;
|
|
||||||
if (pkColumns.length === 0) {
|
|
||||||
pCols = DBGetColumns(config as any, dbName, tableName);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resData = await pData;
|
const resData = await pData;
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
@@ -109,11 +93,23 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
dbName
|
dbName
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pCols) {
|
if (pkColumns.length === 0) {
|
||||||
const resCols = await pCols;
|
const pkKey = `${tab.connectionId}|${dbName}|${tableName}`;
|
||||||
if (resCols.success) {
|
if (pkKeyRef.current !== pkKey) {
|
||||||
const pks = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
|
pkKeyRef.current = pkKey;
|
||||||
setPkColumns(pks);
|
const pkSeq = ++pkSeqRef.current;
|
||||||
|
DBGetColumns(config as any, dbName, tableName)
|
||||||
|
.then((resCols: any) => {
|
||||||
|
if (pkSeqRef.current !== pkSeq) return;
|
||||||
|
if (pkKeyRef.current !== pkKey) return;
|
||||||
|
if (!resCols?.success) return;
|
||||||
|
const pks = (resCols.data as ColumnDefinition[]).filter((c: any) => c.key === 'PRI').map((c: any) => c.name);
|
||||||
|
setPkColumns(pks);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (pkSeqRef.current !== pkSeq) return;
|
||||||
|
if (pkKeyRef.current !== pkKey) return;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,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;
|
||||||
|
|
||||||
@@ -214,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 }), []);
|
||||||
@@ -227,7 +225,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', width: '100%', overflow: 'hidden' }}>
|
<div style={{ flex: '1 1 auto', minHeight: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
data={data}
|
data={data}
|
||||||
columnNames={columnNames}
|
columnNames={columnNames}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useRef, useEffect } from 'react';
|
|||||||
import { Table, Tag, Button, Tooltip } from 'antd';
|
import { Table, Tag, Button, Tooltip } from 'antd';
|
||||||
import { ClearOutlined, CloseOutlined, CaretRightOutlined, BugOutlined } from '@ant-design/icons';
|
import { ClearOutlined, CloseOutlined, CaretRightOutlined, BugOutlined } from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
|
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform } from '../utils/appearance';
|
||||||
|
|
||||||
interface LogPanelProps {
|
interface LogPanelProps {
|
||||||
height: number;
|
height: number;
|
||||||
@@ -10,7 +11,26 @@ 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';
|
||||||
|
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||||
|
const blur = normalizeBlurForPlatform(appearance.blur);
|
||||||
|
|
||||||
|
// 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 bgMain = getBg('#1f1f1f');
|
||||||
|
const bgToolbar = getBg('#2a2a2a');
|
||||||
|
const blurFilter = blurToFilter(blur);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
@@ -51,8 +71,10 @@ 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: blurFilter,
|
||||||
|
WebkitBackdropFilter: blurFilter,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -75,11 +97,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 +132,4 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LogPanel;
|
export default LogPanel;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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;
|
||||||
1633
frontend/src/components/RedisViewer.tsx
Normal file
1633
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
@@ -1,12 +1,22 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Tabs, Button } from 'antd';
|
import { Tabs, Dropdown } from 'antd';
|
||||||
|
import type { MenuProps } from 'antd';
|
||||||
import { useStore } from '../store';
|
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 } = 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);
|
||||||
@@ -18,7 +28,7 @@ const TabManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = useMemo(() => tabs.map(tab => {
|
const items = useMemo(() => tabs.map((tab, index) => {
|
||||||
let content;
|
let content;
|
||||||
if (tab.type === 'query') {
|
if (tab.type === 'query') {
|
||||||
content = <QueryEditor tab={tab} />;
|
content = <QueryEditor tab={tab} />;
|
||||||
@@ -26,28 +36,103 @@ 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'] = [
|
||||||
|
{
|
||||||
|
key: 'close-other',
|
||||||
|
label: '关闭其他页',
|
||||||
|
disabled: tabs.length <= 1,
|
||||||
|
onClick: () => closeOtherTabs(tab.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'close-left',
|
||||||
|
label: '关闭左侧',
|
||||||
|
disabled: index === 0,
|
||||||
|
onClick: () => closeTabsToLeft(tab.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'close-right',
|
||||||
|
label: '关闭右侧',
|
||||||
|
disabled: index === tabs.length - 1,
|
||||||
|
onClick: () => closeTabsToRight(tab.id),
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
key: 'close-all',
|
||||||
|
label: '关闭所有',
|
||||||
|
disabled: tabs.length === 0,
|
||||||
|
onClick: () => closeAllTabs(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: tab.title,
|
label: (
|
||||||
|
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||||
|
<span onContextMenu={(e) => e.preventDefault()}>{tab.title}</span>
|
||||||
|
</Dropdown>
|
||||||
|
),
|
||||||
key: tab.id,
|
key: tab.id,
|
||||||
children: content,
|
children: content,
|
||||||
};
|
};
|
||||||
}), [tabs]);
|
}), [tabs, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
.ant-tabs-content { height: 100%; }
|
.main-tabs {
|
||||||
.ant-tabs-tabpane { height: 100%; }
|
height: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.main-tabs .ant-tabs-nav {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.main-tabs .ant-tabs-content-holder {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.main-tabs .ant-tabs-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.main-tabs .ant-tabs-tabpane {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.main-tabs .ant-tabs-tabpane > div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.main-tabs .ant-tabs-tabpane-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.main-tabs .ant-tabs-nav::before {
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
className="main-tabs"
|
||||||
type="editable-card"
|
type="editable-card"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
activeKey={activeTabId || undefined}
|
activeKey={activeTabId || undefined}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
items={items}
|
items={items}
|
||||||
style={{ height: '100%' }}
|
|
||||||
hideAdd
|
hideAdd
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -550,7 +550,6 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
<div ref={containerRef} className="table-designer-wrapper" style={{ height: '100%', overflow: 'hidden', position: 'relative' }}>
|
<div ref={containerRef} className="table-designer-wrapper" style={{ height: '100%', overflow: 'hidden', position: 'relative' }}>
|
||||||
<style>{`
|
<style>{`
|
||||||
.table-designer-wrapper .ant-table-body {
|
.table-designer-wrapper .ant-table-body {
|
||||||
height: ${tableHeight}px !important;
|
|
||||||
max-height: ${tableHeight}px !important;
|
max-height: ${tableHeight}px !important;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import { create } from 'zustand';
|
|||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { SavedConnection, TabData, SavedQuery } from './types';
|
import { SavedConnection, TabData, SavedQuery } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
|
||||||
|
const LEGACY_DEFAULT_OPACITY = 0.95;
|
||||||
|
const OPACITY_EPSILON = 1e-6;
|
||||||
|
|
||||||
|
const isLegacyDefaultAppearance = (appearance: Partial<{ opacity: number; blur: number }> | undefined): boolean => {
|
||||||
|
if (!appearance) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const opacity = typeof appearance.opacity === 'number' ? appearance.opacity : LEGACY_DEFAULT_OPACITY;
|
||||||
|
const blur = typeof appearance.blur === 'number' ? appearance.blur : 0;
|
||||||
|
return Math.abs(opacity - LEGACY_DEFAULT_OPACITY) < OPACITY_EPSILON && blur === 0;
|
||||||
|
};
|
||||||
|
|
||||||
export interface SqlLog {
|
export interface SqlLog {
|
||||||
id: string;
|
id: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -19,8 +32,10 @@ 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 };
|
||||||
sqlLogs: SqlLog[];
|
sqlLogs: SqlLog[];
|
||||||
|
|
||||||
addConnection: (conn: SavedConnection) => void;
|
addConnection: (conn: SavedConnection) => void;
|
||||||
@@ -29,14 +44,20 @@ interface AppState {
|
|||||||
|
|
||||||
addTab: (tab: TabData) => void;
|
addTab: (tab: TabData) => void;
|
||||||
closeTab: (id: string) => void;
|
closeTab: (id: string) => void;
|
||||||
|
closeOtherTabs: (id: string) => void;
|
||||||
|
closeTabsToLeft: (id: string) => void;
|
||||||
|
closeTabsToRight: (id: string) => void;
|
||||||
|
closeAllTabs: () => void;
|
||||||
setActiveTab: (id: string) => void;
|
setActiveTab: (id: string) => void;
|
||||||
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
|
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
addSqlLog: (log: SqlLog) => void;
|
addSqlLog: (log: SqlLog) => void;
|
||||||
clearSqlLogs: () => void;
|
clearSqlLogs: () => void;
|
||||||
@@ -50,8 +71,10 @@ export const useStore = create<AppState>()(
|
|||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
activeContext: null,
|
activeContext: null,
|
||||||
savedQueries: [],
|
savedQueries: [],
|
||||||
darkMode: false,
|
theme: 'light',
|
||||||
|
appearance: { ...DEFAULT_APPEARANCE },
|
||||||
sqlFormatOptions: { keywordCase: 'upper' },
|
sqlFormatOptions: { keywordCase: 'upper' },
|
||||||
|
queryOptions: { maxRows: 5000 },
|
||||||
sqlLogs: [],
|
sqlLogs: [],
|
||||||
|
|
||||||
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
|
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
|
||||||
@@ -79,6 +102,30 @@ export const useStore = create<AppState>()(
|
|||||||
}
|
}
|
||||||
return { tabs: newTabs, activeTabId: newActiveId };
|
return { tabs: newTabs, activeTabId: newActiveId };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
closeOtherTabs: (id) => set((state) => {
|
||||||
|
const keep = state.tabs.find(t => t.id === id);
|
||||||
|
if (!keep) return state;
|
||||||
|
return { tabs: [keep], activeTabId: id };
|
||||||
|
}),
|
||||||
|
|
||||||
|
closeTabsToLeft: (id) => set((state) => {
|
||||||
|
const index = state.tabs.findIndex(t => t.id === id);
|
||||||
|
if (index === -1) return state;
|
||||||
|
const newTabs = state.tabs.slice(index);
|
||||||
|
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
|
||||||
|
return { tabs: newTabs, activeTabId: activeStillExists ? state.activeTabId : id };
|
||||||
|
}),
|
||||||
|
|
||||||
|
closeTabsToRight: (id) => set((state) => {
|
||||||
|
const index = state.tabs.findIndex(t => t.id === id);
|
||||||
|
if (index === -1) return state;
|
||||||
|
const newTabs = state.tabs.slice(0, index + 1);
|
||||||
|
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
|
||||||
|
return { tabs: newTabs, activeTabId: activeStillExists ? state.activeTabId : id };
|
||||||
|
}),
|
||||||
|
|
||||||
|
closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })),
|
||||||
|
|
||||||
setActiveTab: (id) => set({ activeTabId: id }),
|
setActiveTab: (id) => set({ activeTabId: id }),
|
||||||
setActiveContext: (context) => set({ activeContext: context }),
|
setActiveContext: (context) => set({ activeContext: context }),
|
||||||
@@ -94,15 +141,44 @@ 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 } })),
|
||||||
|
|
||||||
addSqlLog: (log) => set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
|
addSqlLog: (log) => set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
|
||||||
clearSqlLogs: () => set({ sqlLogs: [] }),
|
clearSqlLogs: () => set({ sqlLogs: [] }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
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 }), // Don't persist logs
|
version: 2,
|
||||||
|
migrate: (persistedState: unknown, version: number) => {
|
||||||
|
if (!persistedState || typeof persistedState !== 'object') {
|
||||||
|
return persistedState as AppState;
|
||||||
|
}
|
||||||
|
const state = persistedState as Partial<AppState>;
|
||||||
|
const nextState: Partial<AppState> = { ...state };
|
||||||
|
const appearance = state.appearance;
|
||||||
|
|
||||||
|
if (!appearance || typeof appearance !== 'object') {
|
||||||
|
nextState.appearance = { ...DEFAULT_APPEARANCE };
|
||||||
|
return nextState as AppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAppearance = {
|
||||||
|
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
|
||||||
|
blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
|
||||||
|
nextState.appearance = { ...DEFAULT_APPEARANCE };
|
||||||
|
} else {
|
||||||
|
nextState.appearance = nextAppearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState as AppState;
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
66
frontend/src/utils/appearance.ts
Normal file
66
frontend/src/utils/appearance.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const DEFAULT_OPACITY = 1.0;
|
||||||
|
const MIN_OPACITY = 0.1;
|
||||||
|
const MAX_OPACITY = 1.0;
|
||||||
|
|
||||||
|
// macOS 端进一步增强通透感:同滑块值下更低等效不透明度、降低过重模糊。
|
||||||
|
const MAC_OPACITY_FACTOR = 0.20;
|
||||||
|
const MAC_BLUR_FACTOR = 1.00;
|
||||||
|
const WINDOWS_OPACITY_FACTOR = 0.20;
|
||||||
|
const WINDOWS_BLUR_FACTOR = 1.00;
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
export const isMacLikePlatform = (): boolean => {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const platform = navigator.platform || '';
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
return /(Mac|iPhone|iPad|iPod)/i.test(`${platform} ${ua}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isWindowsPlatform = (): boolean => {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const platform = navigator.platform || '';
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
return /(Win|Windows)/i.test(`${platform} ${ua}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlatformFactors = () => {
|
||||||
|
if (isMacLikePlatform()) {
|
||||||
|
return { opacity: MAC_OPACITY_FACTOR, blur: MAC_BLUR_FACTOR };
|
||||||
|
}
|
||||||
|
if (isWindowsPlatform()) {
|
||||||
|
return { opacity: WINDOWS_OPACITY_FACTOR, blur: WINDOWS_BLUR_FACTOR };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeOpacityForPlatform = (opacity: number | undefined): number => {
|
||||||
|
const raw = clamp(opacity ?? DEFAULT_OPACITY, MIN_OPACITY, MAX_OPACITY);
|
||||||
|
// 用户显式拉到 100%% 时,必须保持完全不透明,不能再被平台映射压低。
|
||||||
|
if (raw >= MAX_OPACITY - 1e-6) {
|
||||||
|
return MAX_OPACITY;
|
||||||
|
}
|
||||||
|
const factors = getPlatformFactors();
|
||||||
|
if (!factors) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clamp(MIN_OPACITY + (raw - MIN_OPACITY) * factors.opacity, MIN_OPACITY, MAX_OPACITY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeBlurForPlatform = (blur: number | undefined): number => {
|
||||||
|
const raw = Math.max(0, blur ?? 0);
|
||||||
|
const factors = getPlatformFactors();
|
||||||
|
if (!factors) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return Math.round(raw * factors.blur);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const blurToFilter = (blur: number): string | undefined => {
|
||||||
|
return blur > 0 ? `blur(${blur}px)` : undefined;
|
||||||
|
};
|
||||||
200
frontend/src/utils/sql.ts
Normal file
200
frontend/src/utils/sql.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
export type FilterCondition = {
|
||||||
|
id?: number;
|
||||||
|
column?: string;
|
||||||
|
op?: string;
|
||||||
|
value?: string;
|
||||||
|
value2?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeIdentPart = (ident: string) => {
|
||||||
|
let raw = (ident || '').trim();
|
||||||
|
if (!raw) return raw;
|
||||||
|
const first = raw[0];
|
||||||
|
const last = raw[raw.length - 1];
|
||||||
|
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
|
||||||
|
raw = raw.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
raw = raw.replace(/["`]/g, '').trim();
|
||||||
|
return raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查标识符是否需要引号(包含特殊字符或是保留字)
|
||||||
|
const needsQuote = (ident: string): boolean => {
|
||||||
|
if (!ident) return false;
|
||||||
|
// 如果包含特殊字符(非字母、数字、下划线)则需要引号
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(ident)) return true;
|
||||||
|
// PostgreSQL 会将未加引号的标识符折叠为小写,含大写字母时必须加引号
|
||||||
|
if (/[A-Z]/.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) => {
|
||||||
|
const raw = normalizeIdentPart(ident);
|
||||||
|
if (!raw) return raw;
|
||||||
|
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, '""')}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const quoteQualifiedIdent = (dbType: string, ident: string) => {
|
||||||
|
const raw = (ident || '').trim();
|
||||||
|
if (!raw) return raw;
|
||||||
|
const parts = raw.split('.').map(normalizeIdentPart).filter(Boolean);
|
||||||
|
if (parts.length <= 1) return quoteIdentPart(dbType, raw);
|
||||||
|
return parts.map(p => quoteIdentPart(dbType, p)).join('.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const escapeLiteral = (val: string) => (val || '').replace(/'/g, "''");
|
||||||
|
|
||||||
|
export const parseListValues = (val: string) => {
|
||||||
|
const raw = (val || '').trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
return raw
|
||||||
|
.split(/[\n,,]+/)
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) => {
|
||||||
|
const whereParts: string[] = [];
|
||||||
|
|
||||||
|
(conditions || []).forEach((cond) => {
|
||||||
|
const op = (cond?.op || '').trim();
|
||||||
|
const column = (cond?.column || '').trim();
|
||||||
|
const value = (cond?.value ?? '').toString();
|
||||||
|
const value2 = (cond?.value2 ?? '').toString();
|
||||||
|
|
||||||
|
if (op === 'CUSTOM') {
|
||||||
|
const expr = value.trim();
|
||||||
|
if (expr) whereParts.push(`(${expr})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
const col = quoteIdentPart(dbType, column);
|
||||||
|
|
||||||
|
switch (op) {
|
||||||
|
case 'IS_NULL':
|
||||||
|
whereParts.push(`${col} IS NULL`);
|
||||||
|
return;
|
||||||
|
case 'IS_NOT_NULL':
|
||||||
|
whereParts.push(`${col} IS NOT NULL`);
|
||||||
|
return;
|
||||||
|
case 'IS_EMPTY':
|
||||||
|
// 兼容:空值通常理解为 NULL 或空字符串
|
||||||
|
whereParts.push(`(${col} IS NULL OR ${col} = '')`);
|
||||||
|
return;
|
||||||
|
case 'IS_NOT_EMPTY':
|
||||||
|
whereParts.push(`(${col} IS NOT NULL AND ${col} <> '')`);
|
||||||
|
return;
|
||||||
|
case 'BETWEEN': {
|
||||||
|
const v1 = value.trim();
|
||||||
|
const v2 = value2.trim();
|
||||||
|
if (!v1 || !v2) return;
|
||||||
|
whereParts.push(`${col} BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'NOT_BETWEEN': {
|
||||||
|
const v1 = value.trim();
|
||||||
|
const v2 = value2.trim();
|
||||||
|
if (!v1 || !v2) return;
|
||||||
|
whereParts.push(`${col} NOT BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'IN': {
|
||||||
|
const items = parseListValues(value);
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const list = items.map(v => `'${escapeLiteral(v)}'`).join(', ');
|
||||||
|
whereParts.push(`${col} IN (${list})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'NOT_IN': {
|
||||||
|
const items = parseListValues(value);
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const list = items.map(v => `'${escapeLiteral(v)}'`).join(', ');
|
||||||
|
whereParts.push(`${col} NOT IN (${list})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'CONTAINS': {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
whereParts.push(`${col} LIKE '%${escapeLiteral(v)}%'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'NOT_CONTAINS': {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
whereParts.push(`${col} NOT LIKE '%${escapeLiteral(v)}%'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'STARTS_WITH': {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
whereParts.push(`${col} LIKE '${escapeLiteral(v)}%'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'NOT_STARTS_WITH': {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
whereParts.push(`${col} NOT LIKE '${escapeLiteral(v)}%'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'ENDS_WITH': {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
whereParts.push(`${col} LIKE '%${escapeLiteral(v)}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'NOT_ENDS_WITH': {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
whereParts.push(`${col} NOT LIKE '%${escapeLiteral(v)}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case '=':
|
||||||
|
case '!=':
|
||||||
|
case '<':
|
||||||
|
case '<=':
|
||||||
|
case '>':
|
||||||
|
case '>=': {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
whereParts.push(`${col} ${op} '${escapeLiteral(v)}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// 兼容旧值:LIKE
|
||||||
|
if (op.toUpperCase() === 'LIKE') {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
whereParts.push(`${col} LIKE '%${escapeLiteral(v)}%'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
whereParts.push(`${col} ${op} '${escapeLiteral(v)}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
67
frontend/wailsjs/go/app/App.d.ts
vendored
67
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,14 +36,32 @@ 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 DropDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):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 ExportQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function ExportTablesDataSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):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>;
|
||||||
@@ -53,4 +74,50 @@ 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 RenameDatabase(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function RenameTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4: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,14 +66,46 @@ 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 DropDatabase(arg1, arg2) {
|
||||||
|
return window['go']['app']['App']['DropDatabase'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropTable(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['DropTable'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportDatabaseSQL(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['ExportDatabaseSQL'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportQuery(arg1, arg2, arg3, arg4, arg5) {
|
||||||
|
return window['go']['app']['App']['ExportQuery'](arg1, arg2, arg3, arg4, arg5);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExportTable(arg1, arg2, arg3, arg4) {
|
export function ExportTable(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4);
|
return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportTablesDataSQL(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['ExportTablesDataSQL'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportTablesSQL(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetAppInfo() {
|
||||||
|
return window['go']['app']['App']['GetAppInfo']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ImportConfigFile() {
|
export function ImportConfigFile() {
|
||||||
return window['go']['app']['App']['ImportConfigFile']();
|
return window['go']['app']['App']['ImportConfigFile']();
|
||||||
}
|
}
|
||||||
@@ -78,6 +114,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);
|
||||||
}
|
}
|
||||||
@@ -102,6 +142,98 @@ 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 RenameDatabase(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['RenameDatabase'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenameTable(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['app']['App']['RenameTable'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +45,7 @@ func NewApp() *App {
|
|||||||
func (a *App) Startup(ctx context.Context) {
|
func (a *App) Startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
logger.Init()
|
logger.Init()
|
||||||
|
applyMacWindowTranslucencyFix()
|
||||||
logger.Infof("应用启动完成")
|
logger.Infof("应用启动完成")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +55,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,35 +147,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]
|
||||||
}
|
}
|
||||||
if config.UseSSH && config.Type != "mysql" {
|
|
||||||
logger.Warnf("当前仅 MySQL 支持内置 SSH 直连,其他类型请使用本地端口转发:%s", formatConnSummary(config))
|
|
||||||
}
|
|
||||||
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 {
|
||||||
@@ -176,7 +217,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,44 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
"GoNavi-Wails/internal/logger"
|
"GoNavi-Wails/internal/logger"
|
||||||
|
"GoNavi-Wails/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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()}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("DBConnect 连接成功:%s", formatConnSummary(config))
|
logger.Infof("DBConnect 连接成功:%s", formatConnSummary(config))
|
||||||
return connection.QueryResult{Success: true, Message: "连接成功"}
|
return connection.QueryResult{Success: true, Message: "连接成功"}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("TestConnection 连接测试成功:%s", formatConnSummary(config))
|
logger.Infof("TestConnection 连接测试成功:%s", formatConnSummary(config))
|
||||||
return connection.QueryResult{Success: true, Message: "连接成功"}
|
return connection.QueryResult{Success: true, Message: "连接成功"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||||||
runConfig := config
|
runConfig := config
|
||||||
runConfig.Database = ""
|
runConfig.Database = ""
|
||||||
|
|
||||||
dbInst, err := a.getDatabase(runConfig)
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,6 +60,221 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
|
|||||||
return connection.QueryResult{Success: true, Message: "Database created successfully"}
|
return connection.QueryResult{Success: true, Message: "Database created successfully"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveDDLDBType(config connection.ConnectionConfig) string {
|
||||||
|
dbType := strings.ToLower(strings.TrimSpace(config.Type))
|
||||||
|
if dbType != "custom" {
|
||||||
|
return dbType
|
||||||
|
}
|
||||||
|
|
||||||
|
driver := strings.ToLower(strings.TrimSpace(config.Driver))
|
||||||
|
switch driver {
|
||||||
|
case "postgresql":
|
||||||
|
return "postgres"
|
||||||
|
case "dm":
|
||||||
|
return "dameng"
|
||||||
|
case "sqlite3":
|
||||||
|
return "sqlite"
|
||||||
|
default:
|
||||||
|
return driver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSchemaAndTableByType(dbType string, dbName string, tableName string) (string, string) {
|
||||||
|
rawTable := strings.TrimSpace(tableName)
|
||||||
|
rawDB := strings.TrimSpace(dbName)
|
||||||
|
if rawTable == "" {
|
||||||
|
return rawDB, rawTable
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts := strings.SplitN(rawTable, ".", 2); len(parts) == 2 {
|
||||||
|
schema := strings.TrimSpace(parts[0])
|
||||||
|
table := strings.TrimSpace(parts[1])
|
||||||
|
if schema != "" && table != "" {
|
||||||
|
return schema, table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch dbType {
|
||||||
|
case "postgres", "kingbase":
|
||||||
|
return "public", rawTable
|
||||||
|
default:
|
||||||
|
return rawDB, rawTable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteTableIdentByType(dbType string, schema string, table string) string {
|
||||||
|
s := strings.TrimSpace(schema)
|
||||||
|
t := strings.TrimSpace(table)
|
||||||
|
if s == "" {
|
||||||
|
return quoteIdentByType(dbType, t)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s.%s", quoteIdentByType(dbType, s), quoteIdentByType(dbType, t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRunConfigForDDL(config connection.ConnectionConfig, dbType string, dbName string) connection.ConnectionConfig {
|
||||||
|
runConfig := normalizeRunConfig(config, dbName)
|
||||||
|
if strings.EqualFold(strings.TrimSpace(config.Type), "custom") {
|
||||||
|
// custom 连接的 dbName 语义依赖 driver,尽量在常见驱动上对齐内置类型行为。
|
||||||
|
switch dbType {
|
||||||
|
case "mysql", "postgres", "kingbase", "dameng":
|
||||||
|
if strings.TrimSpace(dbName) != "" {
|
||||||
|
runConfig.Database = strings.TrimSpace(dbName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string, newName string) connection.QueryResult {
|
||||||
|
oldName = strings.TrimSpace(oldName)
|
||||||
|
newName = strings.TrimSpace(newName)
|
||||||
|
if oldName == "" || newName == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "数据库名称不能为空"}
|
||||||
|
}
|
||||||
|
if strings.EqualFold(oldName, newName) {
|
||||||
|
return connection.QueryResult{Success: false, Message: "新旧数据库名称不能相同"}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbType := resolveDDLDBType(config)
|
||||||
|
switch dbType {
|
||||||
|
case "mysql":
|
||||||
|
return connection.QueryResult{Success: false, Message: "MySQL 不支持直接重命名数据库,请新建库后迁移数据"}
|
||||||
|
case "postgres", "kingbase":
|
||||||
|
if strings.EqualFold(strings.TrimSpace(config.Database), oldName) {
|
||||||
|
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"}
|
||||||
|
}
|
||||||
|
runConfig := config
|
||||||
|
if strings.TrimSpace(runConfig.Database) == "" {
|
||||||
|
runConfig.Database = "postgres"
|
||||||
|
}
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
sql := fmt.Sprintf("ALTER DATABASE %s RENAME TO %s", quoteIdentByType(dbType, oldName), quoteIdentByType(dbType, newName))
|
||||||
|
if _, err := dbInst.Exec(sql); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
return connection.QueryResult{Success: true, Message: "数据库重命名成功"}
|
||||||
|
default:
|
||||||
|
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名数据库", dbType)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||||||
|
dbName = strings.TrimSpace(dbName)
|
||||||
|
if dbName == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "数据库名称不能为空"}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbType := resolveDDLDBType(config)
|
||||||
|
var (
|
||||||
|
runConfig connection.ConnectionConfig
|
||||||
|
sql string
|
||||||
|
)
|
||||||
|
switch dbType {
|
||||||
|
case "mysql":
|
||||||
|
runConfig = config
|
||||||
|
runConfig.Database = ""
|
||||||
|
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
|
||||||
|
case "postgres", "kingbase":
|
||||||
|
if strings.EqualFold(strings.TrimSpace(config.Database), dbName) {
|
||||||
|
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再删除"}
|
||||||
|
}
|
||||||
|
runConfig = config
|
||||||
|
if strings.TrimSpace(runConfig.Database) == "" {
|
||||||
|
runConfig.Database = "postgres"
|
||||||
|
}
|
||||||
|
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
|
||||||
|
default:
|
||||||
|
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除数据库", dbType)}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
if _, err := dbInst.Exec(sql); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
return connection.QueryResult{Success: true, Message: "数据库删除成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, oldTableName string, newTableName string) connection.QueryResult {
|
||||||
|
oldTableName = strings.TrimSpace(oldTableName)
|
||||||
|
newTableName = strings.TrimSpace(newTableName)
|
||||||
|
if oldTableName == "" || newTableName == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "表名不能为空"}
|
||||||
|
}
|
||||||
|
if strings.EqualFold(oldTableName, newTableName) {
|
||||||
|
return connection.QueryResult{Success: false, Message: "新旧表名不能相同"}
|
||||||
|
}
|
||||||
|
if strings.Contains(newTableName, ".") {
|
||||||
|
return connection.QueryResult{Success: false, Message: "新表名不能包含 schema 或数据库前缀"}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbType := resolveDDLDBType(config)
|
||||||
|
switch dbType {
|
||||||
|
case "mysql", "postgres", "kingbase", "sqlite", "oracle", "dameng":
|
||||||
|
default:
|
||||||
|
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名表", dbType)}
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaName, pureOldTableName := normalizeSchemaAndTableByType(dbType, dbName, oldTableName)
|
||||||
|
if pureOldTableName == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "旧表名不能为空"}
|
||||||
|
}
|
||||||
|
oldQualifiedTable := quoteTableIdentByType(dbType, schemaName, pureOldTableName)
|
||||||
|
newTableQuoted := quoteIdentByType(dbType, newTableName)
|
||||||
|
|
||||||
|
sql := fmt.Sprintf("ALTER TABLE %s RENAME TO %s", oldQualifiedTable, newTableQuoted)
|
||||||
|
if dbType == "mysql" {
|
||||||
|
newQualifiedTable := quoteTableIdentByType(dbType, schemaName, newTableName)
|
||||||
|
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualifiedTable, newQualifiedTable)
|
||||||
|
}
|
||||||
|
|
||||||
|
runConfig := buildRunConfigForDDL(config, dbType, dbName)
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
if _, err := dbInst.Exec(sql); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
return connection.QueryResult{Success: true, Message: "表重命名成功"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DropTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||||
|
tableName = strings.TrimSpace(tableName)
|
||||||
|
if tableName == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "表名不能为空"}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbType := resolveDDLDBType(config)
|
||||||
|
switch dbType {
|
||||||
|
case "mysql", "postgres", "kingbase", "sqlite", "oracle", "dameng":
|
||||||
|
default:
|
||||||
|
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除表", dbType)}
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaName, pureTableName := normalizeSchemaAndTableByType(dbType, dbName, tableName)
|
||||||
|
if pureTableName == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "表名不能为空"}
|
||||||
|
}
|
||||||
|
qualifiedTable := quoteTableIdentByType(dbType, schemaName, pureTableName)
|
||||||
|
sql := fmt.Sprintf("DROP TABLE %s", qualifiedTable)
|
||||||
|
|
||||||
|
runConfig := buildRunConfigForDDL(config, dbType, dbName)
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
if _, err := dbInst.Exec(sql); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
return connection.QueryResult{Success: true, Message: "表删除成功"}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) MySQLConnect(config connection.ConnectionConfig) connection.QueryResult {
|
func (a *App) MySQLConnect(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
config.Type = "mysql"
|
config.Type = "mysql"
|
||||||
return a.DBConnect(config)
|
return a.DBConnect(config)
|
||||||
@@ -91,16 +309,39 @@ func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query s
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query = sanitizeSQLForPgLike(runConfig.Type, query)
|
||||||
|
timeoutSeconds := runConfig.Timeout
|
||||||
|
if timeoutSeconds <= 0 {
|
||||||
|
timeoutSeconds = 30
|
||||||
|
}
|
||||||
|
ctx, cancel := utils.ContextWithTimeout(time.Duration(timeoutSeconds) * time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
lowerQuery := strings.TrimSpace(strings.ToLower(query))
|
lowerQuery := strings.TrimSpace(strings.ToLower(query))
|
||||||
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain") {
|
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain") {
|
||||||
data, columns, err := dbInst.Query(query)
|
var data []map[string]interface{}
|
||||||
|
var columns []string
|
||||||
|
if q, ok := dbInst.(interface {
|
||||||
|
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
|
||||||
|
}); ok {
|
||||||
|
data, columns, err = q.QueryContext(ctx, query)
|
||||||
|
} else {
|
||||||
|
data, columns, err = dbInst.Query(query)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err, "DBQuery 查询失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
logger.Error(err, "DBQuery 查询失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
return connection.QueryResult{Success: true, Data: data, Fields: columns}
|
return connection.QueryResult{Success: true, Data: data, Fields: columns}
|
||||||
} else {
|
} else {
|
||||||
affected, err := dbInst.Exec(query)
|
var affected int64
|
||||||
|
if e, ok := dbInst.(interface {
|
||||||
|
ExecContext(context.Context, string) (int64, error)
|
||||||
|
}); ok {
|
||||||
|
affected, err = e.ExecContext(ctx, query)
|
||||||
|
} else {
|
||||||
|
affected, err = dbInst.Exec(query)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err, "DBQuery 执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
logger.Error(err, "DBQuery 执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
@@ -130,12 +371,12 @@ func (a *App) DBGetDatabases(config connection.ConnectionConfig) connection.Quer
|
|||||||
logger.Error(err, "DBGetDatabases 获取数据库列表失败:%s", formatConnSummary(config))
|
logger.Error(err, "DBGetDatabases 获取数据库列表失败:%s", formatConnSummary(config))
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
var resData []map[string]string
|
var resData []map[string]string
|
||||||
for _, name := range dbs {
|
for _, name := range dbs {
|
||||||
resData = append(resData, map[string]string{"Database": name})
|
resData = append(resData, map[string]string{"Database": name})
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.QueryResult{Success: true, Data: resData}
|
return connection.QueryResult{Success: true, Data: resData}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
"GoNavi-Wails/internal/db"
|
"GoNavi-Wails/internal/db"
|
||||||
@@ -97,8 +102,8 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
var rows []map[string]interface{ }
|
var rows []map[string]interface{}
|
||||||
|
|
||||||
if strings.HasSuffix(strings.ToLower(selection), ".json") {
|
if strings.HasSuffix(strings.ToLower(selection), ".json") {
|
||||||
decoder := json.NewDecoder(f)
|
decoder := json.NewDecoder(f)
|
||||||
if err := decoder.Decode(&rows); err != nil {
|
if err := decoder.Decode(&rows); err != nil {
|
||||||
@@ -115,7 +120,7 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s
|
|||||||
}
|
}
|
||||||
headers := records[0]
|
headers := records[0]
|
||||||
for _, record := range records[1:] {
|
for _, record := range records[1:] {
|
||||||
row := make(map[string]interface{ })
|
row := make(map[string]interface{})
|
||||||
for i, val := range record {
|
for i, val := range record {
|
||||||
if i < len(headers) {
|
if i < len(headers) {
|
||||||
if val == "NULL" {
|
if val == "NULL" {
|
||||||
@@ -148,7 +153,7 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s
|
|||||||
for k := range firstRow {
|
for k := range firstRow {
|
||||||
cols = append(cols, k)
|
cols = append(cols, k)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
var values []string
|
var values []string
|
||||||
for _, col := range cols {
|
for _, col := range cols {
|
||||||
@@ -190,16 +195,16 @@ 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()}
|
||||||
}
|
}
|
||||||
|
|
||||||
if applier, ok := dbInst.(db.BatchApplier); ok {
|
if applier, ok := dbInst.(db.BatchApplier); ok {
|
||||||
err := applier.ApplyChanges(tableName, changes)
|
err := applier.ApplyChanges(tableName, changes)
|
||||||
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 {
|
||||||
@@ -213,15 +218,39 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
|
|||||||
}
|
}
|
||||||
|
|
||||||
runConfig := normalizeRunConfig(config, dbName)
|
runConfig := normalizeRunConfig(config, dbName)
|
||||||
|
|
||||||
dbInst, err := a.getDatabase(runConfig)
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
format = strings.ToLower(format)
|
||||||
|
if format == "sql" {
|
||||||
|
f, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
w := bufio.NewWriterSize(f, 1024*1024)
|
||||||
|
defer w.Flush()
|
||||||
|
|
||||||
|
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
if err := writeSQLFooter(w, runConfig); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||||
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(runConfig.Type, tableName))
|
query := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(runConfig.Type, tableName))
|
||||||
|
|
||||||
data, columns, err := dbInst.Query(query)
|
data, columns, err := dbInst.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
@@ -231,71 +260,143 @@ data, columns, err := dbInst.Query(query)
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
if err := writeRowsToFile(f, data, columns, format); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
format = strings.ToLower(format)
|
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||||
var csvWriter *csv.Writer
|
}
|
||||||
var jsonEncoder *json.Encoder
|
|
||||||
var isJsonFirstRow = true
|
|
||||||
|
|
||||||
switch format {
|
func (a *App) ExportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeData bool) connection.QueryResult {
|
||||||
case "csv", "xlsx":
|
return a.exportTablesSQL(config, dbName, tableNames, true, includeData)
|
||||||
f.Write([]byte{0xEF, 0xBB, 0xBF})
|
}
|
||||||
csvWriter = csv.NewWriter(f)
|
|
||||||
defer csvWriter.Flush()
|
func (a *App) ExportTablesDataSQL(config connection.ConnectionConfig, dbName string, tableNames []string) connection.QueryResult {
|
||||||
if err := csvWriter.Write(columns); err != nil {
|
return a.exportTablesSQL(config, dbName, tableNames, false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeSchema bool, includeData bool) connection.QueryResult {
|
||||||
|
if !includeSchema && !includeData {
|
||||||
|
return connection.QueryResult{Success: false, Message: "invalid export mode"}
|
||||||
|
}
|
||||||
|
|
||||||
|
safeDbName := strings.TrimSpace(dbName)
|
||||||
|
if safeDbName == "" {
|
||||||
|
safeDbName = "export"
|
||||||
|
}
|
||||||
|
suffix := "schema"
|
||||||
|
if includeSchema && includeData {
|
||||||
|
suffix = "backup"
|
||||||
|
} else if !includeSchema && includeData {
|
||||||
|
suffix = "data"
|
||||||
|
}
|
||||||
|
defaultFilename := fmt.Sprintf("%s_%s_%dtables.sql", safeDbName, suffix, len(tableNames))
|
||||||
|
if len(tableNames) == 1 && strings.TrimSpace(tableNames[0]) != "" {
|
||||||
|
defaultFilename = fmt.Sprintf("%s_%s.sql", strings.TrimSpace(tableNames[0]), suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||||
|
Title: "Export Tables (SQL)",
|
||||||
|
DefaultFilename: defaultFilename,
|
||||||
|
})
|
||||||
|
if err != nil || filename == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||||
|
}
|
||||||
|
|
||||||
|
runConfig := normalizeRunConfig(config, dbName)
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := make([]string, 0, len(tableNames))
|
||||||
|
seen := make(map[string]struct{}, len(tableNames))
|
||||||
|
for _, t := range tableNames {
|
||||||
|
t = strings.TrimSpace(t)
|
||||||
|
if t == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[t]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[t] = struct{}{}
|
||||||
|
tables = append(tables, t)
|
||||||
|
}
|
||||||
|
sort.Strings(tables)
|
||||||
|
|
||||||
|
f, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
w := bufio.NewWriterSize(f, 1024*1024)
|
||||||
|
defer w.Flush()
|
||||||
|
|
||||||
|
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
for _, t := range tables {
|
||||||
|
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeSchema, includeData); err != nil {
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
case "json":
|
}
|
||||||
f.WriteString("[\n")
|
if err := writeSQLFooter(w, runConfig); err != nil {
|
||||||
jsonEncoder = json.NewEncoder(f)
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
jsonEncoder.SetIndent(" ", " ")
|
|
||||||
case "md":
|
|
||||||
fmt.Fprintf(f, "| %s |\n", strings.Join(columns, " | "))
|
|
||||||
seps := make([]string, len(columns))
|
|
||||||
for i := range seps {
|
|
||||||
seps[i] = "---"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(f, "| %s |\n", strings.Join(seps, " | "))
|
|
||||||
default:
|
|
||||||
return connection.QueryResult{Success: false, Message: "Unsupported format: " + format}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rowMap := range data {
|
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||||
record := make([]string, len(columns))
|
}
|
||||||
for i, col := range columns {
|
|
||||||
val := rowMap[col]
|
|
||||||
if val == nil {
|
|
||||||
record[i] = "NULL"
|
|
||||||
} else {
|
|
||||||
s := fmt.Sprintf("%v", val)
|
|
||||||
if format == "md" {
|
|
||||||
s = strings.ReplaceAll(s, "|", "\\|")
|
|
||||||
s = strings.ReplaceAll(s, "\n", "<br>")
|
|
||||||
}
|
|
||||||
record[i] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch format {
|
func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName string, includeData bool) connection.QueryResult {
|
||||||
case "csv", "xlsx":
|
safeDbName := strings.TrimSpace(dbName)
|
||||||
if err := csvWriter.Write(record); err != nil {
|
if safeDbName == "" {
|
||||||
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
return connection.QueryResult{Success: false, Message: "dbName required"}
|
||||||
}
|
}
|
||||||
case "json":
|
suffix := "schema"
|
||||||
if !isJsonFirstRow {
|
if includeData {
|
||||||
f.WriteString(",\n")
|
suffix = "backup"
|
||||||
}
|
|
||||||
if err := jsonEncoder.Encode(rowMap); err != nil {
|
|
||||||
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
|
||||||
}
|
|
||||||
isJsonFirstRow = false
|
|
||||||
case "md":
|
|
||||||
fmt.Fprintf(f, "| %s |\n", strings.Join(record, " | "))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if format == "json" {
|
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||||
f.WriteString("\n]")
|
Title: fmt.Sprintf("Export %s (SQL)", safeDbName),
|
||||||
|
DefaultFilename: fmt.Sprintf("%s_%s.sql", safeDbName, suffix),
|
||||||
|
})
|
||||||
|
if err != nil || filename == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||||
|
}
|
||||||
|
|
||||||
|
runConfig := normalizeRunConfig(config, dbName)
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
tables, err := dbInst.GetTables(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
sort.Strings(tables)
|
||||||
|
|
||||||
|
f, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
w := bufio.NewWriterSize(f, 1024*1024)
|
||||||
|
defer w.Flush()
|
||||||
|
|
||||||
|
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
for _, t := range tables {
|
||||||
|
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, true, includeData); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeSQLFooter(w, runConfig); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.QueryResult{Success: true, Message: "Export successful"}
|
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||||
@@ -340,6 +441,175 @@ func quoteQualifiedIdentByType(dbType string, ident string) string {
|
|||||||
return strings.Join(quotedParts, ".")
|
return strings.Join(quotedParts, ".")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeSQLHeader(w *bufio.Writer, config connection.ConnectionConfig, dbName string) error {
|
||||||
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
if _, err := w.WriteString(fmt.Sprintf("-- GoNavi SQL Export\n-- Time: %s\n", now)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(dbName) != "" {
|
||||||
|
if _, err := w.WriteString(fmt.Sprintf("-- Database: %s\n\n", dbName)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(strings.TrimSpace(config.Type)) == "mysql" && strings.TrimSpace(dbName) != "" {
|
||||||
|
if _, err := w.WriteString(fmt.Sprintf("USE %s;\n\n", quoteIdentByType("mysql", dbName))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.WriteString("SET FOREIGN_KEY_CHECKS=0;\n\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSQLFooter(w *bufio.Writer, config connection.ConnectionConfig) error {
|
||||||
|
if strings.ToLower(strings.TrimSpace(config.Type)) == "mysql" {
|
||||||
|
if _, err := w.WriteString("\nSET FOREIGN_KEY_CHECKS=1;\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func qualifyTable(schemaName, tableName string) string {
|
||||||
|
schemaName = strings.TrimSpace(schemaName)
|
||||||
|
tableName = strings.TrimSpace(tableName)
|
||||||
|
if schemaName == "" {
|
||||||
|
return tableName
|
||||||
|
}
|
||||||
|
return schemaName + "." + tableName
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSQLTerminator(sql string) string {
|
||||||
|
trimmed := strings.TrimSpace(sql)
|
||||||
|
if trimmed == "" {
|
||||||
|
return sql
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(trimmed, ";") {
|
||||||
|
return sql
|
||||||
|
}
|
||||||
|
return sql + ";"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMySQLHexLiteral(s string) bool {
|
||||||
|
if len(s) < 3 || !(strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 2; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSQLValue(dbType string, v interface{}) string {
|
||||||
|
if v == nil {
|
||||||
|
return "NULL"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := v.(type) {
|
||||||
|
case bool:
|
||||||
|
if val {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(val)
|
||||||
|
case int8, int16, int32, int64:
|
||||||
|
return fmt.Sprintf("%d", val)
|
||||||
|
case uint, uint8, uint16, uint32, uint64:
|
||||||
|
return fmt.Sprintf("%d", val)
|
||||||
|
case float32:
|
||||||
|
f := float64(val)
|
||||||
|
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||||
|
return "NULL"
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(f, 'f', -1, 32)
|
||||||
|
case float64:
|
||||||
|
if math.IsNaN(val) || math.IsInf(val, 0) {
|
||||||
|
return "NULL"
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(val, 'f', -1, 64)
|
||||||
|
case time.Time:
|
||||||
|
return "'" + val.Format("2006-01-02 15:04:05") + "'"
|
||||||
|
case string:
|
||||||
|
if strings.ToLower(strings.TrimSpace(dbType)) == "mysql" && isMySQLHexLiteral(val) {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
escaped := strings.ReplaceAll(val, "'", "''")
|
||||||
|
return "'" + escaped + "'"
|
||||||
|
default:
|
||||||
|
escaped := strings.ReplaceAll(fmt.Sprintf("%v", v), "'", "''")
|
||||||
|
return "'" + escaped + "'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.ConnectionConfig, dbName, tableName string, includeSchema bool, includeData bool) error {
|
||||||
|
schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName)
|
||||||
|
|
||||||
|
if _, err := w.WriteString("\n-- ----------------------------\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.WriteString(fmt.Sprintf("-- Table: %s\n", qualifyTable(schemaName, pureTableName))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.WriteString("-- ----------------------------\n\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeSchema {
|
||||||
|
createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.WriteString(ensureSQLTerminator(createSQL)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.WriteString("\n\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !includeData {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
qualified := qualifyTable(schemaName, pureTableName)
|
||||||
|
selectSQL := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.Type, qualified))
|
||||||
|
data, columns, err := dbInst.Query(selectSQL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
if _, err := w.WriteString("-- (0 rows)\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
quotedCols := make([]string, 0, len(columns))
|
||||||
|
for _, c := range columns {
|
||||||
|
quotedCols = append(quotedCols, quoteIdentByType(config.Type, c))
|
||||||
|
}
|
||||||
|
quotedTable := quoteQualifiedIdentByType(config.Type, qualified)
|
||||||
|
|
||||||
|
for _, row := range data {
|
||||||
|
values := make([]string, 0, len(columns))
|
||||||
|
for _, c := range columns {
|
||||||
|
values = append(values, formatSQLValue(config.Type, row[c]))
|
||||||
|
}
|
||||||
|
if _, err := w.WriteString(fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);\n", quotedTable, strings.Join(quotedCols, ", "), strings.Join(values, ", "))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ExportData exports provided data to a file
|
// ExportData exports provided data to a file
|
||||||
func (a *App) ExportData(data []map[string]interface{}, columns []string, defaultName string, format string) connection.QueryResult {
|
func (a *App) ExportData(data []map[string]interface{}, columns []string, defaultName string, format string) connection.QueryResult {
|
||||||
if defaultName == "" {
|
if defaultName == "" {
|
||||||
@@ -359,33 +629,101 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
if err := writeRowsToFile(f, data, columns, format); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportQuery exports by executing the provided SELECT query on backend side.
|
||||||
|
// This avoids frontend IPC payload limits when exporting very large/long-text columns (e.g. base64).
|
||||||
|
func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, query string, defaultName string, format string) connection.QueryResult {
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
if query == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "query required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if defaultName == "" {
|
||||||
|
defaultName = "export"
|
||||||
|
}
|
||||||
|
|
||||||
|
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||||
|
Title: "Export Query Result",
|
||||||
|
DefaultFilename: fmt.Sprintf("%s.%s", defaultName, strings.ToLower(format)),
|
||||||
|
})
|
||||||
|
if err != nil || filename == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||||
|
}
|
||||||
|
|
||||||
|
runConfig := normalizeRunConfig(config, dbName)
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
query = sanitizeSQLForPgLike(runConfig.Type, query)
|
||||||
|
lowerQuery := strings.ToLower(strings.TrimSpace(query))
|
||||||
|
if !(strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "with")) {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Only SELECT/WITH queries are supported"}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, columns, err := dbInst.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if err := writeRowsToFile(f, data, columns, format); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string, format string) error {
|
||||||
|
format = strings.ToLower(strings.TrimSpace(format))
|
||||||
|
if f == nil {
|
||||||
|
return fmt.Errorf("file required")
|
||||||
|
}
|
||||||
|
|
||||||
format = strings.ToLower(format)
|
|
||||||
var csvWriter *csv.Writer
|
var csvWriter *csv.Writer
|
||||||
var jsonEncoder *json.Encoder
|
var jsonEncoder *json.Encoder
|
||||||
var isJsonFirstRow = true
|
isJsonFirstRow := true
|
||||||
|
|
||||||
switch format {
|
switch format {
|
||||||
case "csv", "xlsx":
|
case "csv", "xlsx":
|
||||||
f.Write([]byte{0xEF, 0xBB, 0xBF})
|
if _, err := f.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
csvWriter = csv.NewWriter(f)
|
csvWriter = csv.NewWriter(f)
|
||||||
defer csvWriter.Flush()
|
|
||||||
if err := csvWriter.Write(columns); err != nil {
|
if err := csvWriter.Write(columns); err != nil {
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return err
|
||||||
}
|
}
|
||||||
case "json":
|
case "json":
|
||||||
f.WriteString("[\n")
|
if _, err := f.WriteString("[\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
jsonEncoder = json.NewEncoder(f)
|
jsonEncoder = json.NewEncoder(f)
|
||||||
jsonEncoder.SetIndent(" ", " ")
|
jsonEncoder.SetIndent(" ", " ")
|
||||||
case "md":
|
case "md":
|
||||||
fmt.Fprintf(f, "| %s |\n", strings.Join(columns, " | "))
|
if _, err := fmt.Fprintf(f, "| %s |\n", strings.Join(columns, " | ")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
seps := make([]string, len(columns))
|
seps := make([]string, len(columns))
|
||||||
for i := range seps {
|
for i := range seps {
|
||||||
seps[i] = "---"
|
seps[i] = "---"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(f, "| %s |\n", strings.Join(seps, " | "))
|
if _, err := fmt.Fprintf(f, "| %s |\n", strings.Join(seps, " | ")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return connection.QueryResult{Success: false, Message: "Unsupported format: " + format}
|
return fmt.Errorf("unsupported format: %s", format)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rowMap := range data {
|
for _, rowMap := range data {
|
||||||
@@ -394,37 +732,51 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
|
|||||||
val := rowMap[col]
|
val := rowMap[col]
|
||||||
if val == nil {
|
if val == nil {
|
||||||
record[i] = "NULL"
|
record[i] = "NULL"
|
||||||
} else {
|
continue
|
||||||
s := fmt.Sprintf("%v", val)
|
|
||||||
if format == "md" {
|
|
||||||
s = strings.ReplaceAll(s, "|", "\\|")
|
|
||||||
s = strings.ReplaceAll(s, "\n", "<br>")
|
|
||||||
}
|
|
||||||
record[i] = s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s := fmt.Sprintf("%v", val)
|
||||||
|
if format == "md" {
|
||||||
|
s = strings.ReplaceAll(s, "|", "\\|")
|
||||||
|
s = strings.ReplaceAll(s, "\n", "<br>")
|
||||||
|
}
|
||||||
|
record[i] = s
|
||||||
}
|
}
|
||||||
|
|
||||||
switch format {
|
switch format {
|
||||||
case "csv", "xlsx":
|
case "csv", "xlsx":
|
||||||
if err := csvWriter.Write(record); err != nil {
|
if err := csvWriter.Write(record); err != nil {
|
||||||
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
return err
|
||||||
}
|
}
|
||||||
case "json":
|
case "json":
|
||||||
if !isJsonFirstRow {
|
if !isJsonFirstRow {
|
||||||
f.WriteString(",\n")
|
if _, err := f.WriteString(",\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := jsonEncoder.Encode(rowMap); err != nil {
|
if err := jsonEncoder.Encode(rowMap); err != nil {
|
||||||
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
return err
|
||||||
}
|
}
|
||||||
isJsonFirstRow = false
|
isJsonFirstRow = false
|
||||||
case "md":
|
case "md":
|
||||||
fmt.Fprintf(f, "| %s |\n", strings.Join(record, " | "))
|
if _, err := fmt.Fprintf(f, "| %s |\n", strings.Join(record, " | ")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if format == "csv" || format == "xlsx" {
|
||||||
|
csvWriter.Flush()
|
||||||
|
if err := csvWriter.Error(); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if format == "json" {
|
if format == "json" {
|
||||||
f.WriteString("\n]")
|
if _, err := f.WriteString("\n]"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.QueryResult{Success: true, Message: "Export successful"}
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
936
internal/app/methods_update.go
Normal file
936
internal/app/methods_update.go
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"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"
|
||||||
|
updateDownloadProgressEvent = "update:download-progress"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 updateDownloadResult struct {
|
||||||
|
Info UpdateInfo `json:"info"`
|
||||||
|
DownloadPath string `json:"downloadPath,omitempty"`
|
||||||
|
InstallLogPath string `json:"installLogPath,omitempty"`
|
||||||
|
InstallTarget string `json:"installTarget,omitempty"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
AutoRelaunch bool `json:"autoRelaunch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateDownloadProgressPayload struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Percent float64 `json:"percent"`
|
||||||
|
Downloaded int64 `json:"downloaded"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type stagedUpdate struct {
|
||||||
|
Version string
|
||||||
|
AssetName string
|
||||||
|
FilePath string
|
||||||
|
StagedDir string
|
||||||
|
InstallLogPath 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: "未找到可用的更新包"}
|
||||||
|
}
|
||||||
|
staged := a.updateState.staged
|
||||||
|
if staged != nil && staged.Version == info.LatestVersion {
|
||||||
|
a.updateMu.Unlock()
|
||||||
|
return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: buildUpdateDownloadResult(*info, staged)}
|
||||||
|
}
|
||||||
|
a.updateState.downloading = true
|
||||||
|
a.updateMu.Unlock()
|
||||||
|
|
||||||
|
a.emitUpdateDownloadProgress("start", 0, info.AssetSize, "")
|
||||||
|
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
|
||||||
|
if staged != nil && strings.TrimSpace(staged.InstallLogPath) == "" {
|
||||||
|
staged.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
|
||||||
|
}
|
||||||
|
a.updateMu.Unlock()
|
||||||
|
if staged == nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := launchUpdateScript(staged); err != nil {
|
||||||
|
logger.Error(err, "启动更新脚本失败")
|
||||||
|
msg := err.Error()
|
||||||
|
if staged.InstallLogPath != "" {
|
||||||
|
msg = fmt.Sprintf("%s(更新日志:%s)", msg, staged.InstallLogPath)
|
||||||
|
}
|
||||||
|
return connection.QueryResult{
|
||||||
|
Success: false,
|
||||||
|
Message: msg,
|
||||||
|
Data: map[string]any{
|
||||||
|
"logPath": staged.InstallLogPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
wailsRuntime.Quit(a.ctx)
|
||||||
|
// 兜底退出,避免某些平台/窗口状态下 Quit 未真正结束进程,导致更新脚本一直等待。
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
msg := "更新已开始安装"
|
||||||
|
if staged.InstallLogPath != "" {
|
||||||
|
msg = fmt.Sprintf("更新已开始安装,日志路径:%s", staged.InstallLogPath)
|
||||||
|
}
|
||||||
|
return connection.QueryResult{
|
||||||
|
Success: true,
|
||||||
|
Message: msg,
|
||||||
|
Data: map[string]any{
|
||||||
|
"logPath": staged.InstallLogPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
|
||||||
|
workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDir())
|
||||||
|
if workspaceDir == "" {
|
||||||
|
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "无法确定当前应用目录")
|
||||||
|
return connection.QueryResult{Success: false, Message: "无法确定当前应用目录,无法下载更新"}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(workspaceDir, 0o755); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("无法访问应用目录:%s", workspaceDir)
|
||||||
|
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
|
||||||
|
return connection.QueryResult{Success: false, Message: errMsg}
|
||||||
|
}
|
||||||
|
|
||||||
|
stagedDir, err := os.MkdirTemp(workspaceDir, ".gonavi-update-work-")
|
||||||
|
if err != nil {
|
||||||
|
errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", workspaceDir)
|
||||||
|
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
|
||||||
|
return connection.QueryResult{Success: false, Message: errMsg}
|
||||||
|
}
|
||||||
|
|
||||||
|
assetPath := filepath.Join(workspaceDir, info.AssetName)
|
||||||
|
actualHash, err := downloadFileWithHash(info.AssetURL, assetPath, func(downloaded, total int64) {
|
||||||
|
reportTotal := total
|
||||||
|
if reportTotal <= 0 {
|
||||||
|
reportTotal = info.AssetSize
|
||||||
|
}
|
||||||
|
a.emitUpdateDownloadProgress("downloading", downloaded, reportTotal, "")
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(assetPath)
|
||||||
|
_ = os.RemoveAll(stagedDir)
|
||||||
|
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, err.Error())
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.SHA256 == "" {
|
||||||
|
_ = os.Remove(assetPath)
|
||||||
|
_ = os.RemoveAll(stagedDir)
|
||||||
|
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "缺少更新包校验值(SHA256SUMS)")
|
||||||
|
return connection.QueryResult{Success: false, Message: "缺少更新包校验值(SHA256SUMS)"}
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(info.SHA256, actualHash) {
|
||||||
|
_ = os.Remove(assetPath)
|
||||||
|
_ = os.RemoveAll(stagedDir)
|
||||||
|
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "更新包校验失败,请重试")
|
||||||
|
return connection.QueryResult{Success: false, Message: "更新包校验失败,请重试"}
|
||||||
|
}
|
||||||
|
|
||||||
|
staged := &stagedUpdate{
|
||||||
|
Version: info.LatestVersion,
|
||||||
|
AssetName: info.AssetName,
|
||||||
|
FilePath: assetPath,
|
||||||
|
StagedDir: stagedDir,
|
||||||
|
InstallLogPath: buildUpdateInstallLogPath(workspaceDir),
|
||||||
|
}
|
||||||
|
a.updateMu.Lock()
|
||||||
|
a.updateState.staged = staged
|
||||||
|
a.updateMu.Unlock()
|
||||||
|
|
||||||
|
a.emitUpdateDownloadProgress("done", info.AssetSize, info.AssetSize, "")
|
||||||
|
return connection.QueryResult{Success: true, Message: "更新包下载完成", Data: buildUpdateDownloadResult(info, staged)}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type downloadProgressWriter struct {
|
||||||
|
total int64
|
||||||
|
written int64
|
||||||
|
lastEmit time.Time
|
||||||
|
emitEvery time.Duration
|
||||||
|
onProgress func(downloaded, total int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *downloadProgressWriter) Write(p []byte) (int, error) {
|
||||||
|
n := len(p)
|
||||||
|
if n == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
w.written += int64(n)
|
||||||
|
if w.onProgress == nil {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if w.lastEmit.IsZero() || now.Sub(w.lastEmit) >= w.emitEvery || (w.total > 0 && w.written >= w.total) {
|
||||||
|
w.lastEmit = now
|
||||||
|
w.onProgress(w.written, w.total)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFileWithHash(url, filePath string, onProgress func(downloaded, total int64)) (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()
|
||||||
|
total := resp.ContentLength
|
||||||
|
progressWriter := &downloadProgressWriter{
|
||||||
|
total: total,
|
||||||
|
emitEvery: 120 * time.Millisecond,
|
||||||
|
onProgress: onProgress,
|
||||||
|
}
|
||||||
|
writers := []io.Writer{out, hasher, progressWriter}
|
||||||
|
if onProgress != nil {
|
||||||
|
onProgress(0, total)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(io.MultiWriter(writers...), resp.Body); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if onProgress != nil {
|
||||||
|
onProgress(progressWriter.written, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUpdateDownloadResult(info UpdateInfo, staged *stagedUpdate) updateDownloadResult {
|
||||||
|
result := updateDownloadResult{
|
||||||
|
Info: info,
|
||||||
|
Platform: stdRuntime.GOOS,
|
||||||
|
InstallTarget: resolveUpdateInstallTarget(),
|
||||||
|
AutoRelaunch: true,
|
||||||
|
}
|
||||||
|
if staged != nil {
|
||||||
|
result.DownloadPath = staged.FilePath
|
||||||
|
result.InstallLogPath = staged.InstallLogPath
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUpdateInstallLogPath(baseDir string) string {
|
||||||
|
platform := stdRuntime.GOOS
|
||||||
|
if platform == "darwin" {
|
||||||
|
platform = "macos"
|
||||||
|
}
|
||||||
|
logDir := strings.TrimSpace(baseDir)
|
||||||
|
if logDir == "" {
|
||||||
|
logDir = os.TempDir()
|
||||||
|
}
|
||||||
|
return filepath.Join(logDir, fmt.Sprintf("gonavi-update-%s-%d.log", platform, time.Now().UnixNano()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveUpdateWorkspaceDir() string {
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
exePath, _ = filepath.EvalSymlinks(exePath)
|
||||||
|
if stdRuntime.GOOS == "darwin" {
|
||||||
|
appPath := detectMacAppPath(exePath)
|
||||||
|
if appPath != "" {
|
||||||
|
return filepath.Dir(appPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Dir(exePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveUpdateInstallTarget() string {
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
exePath, _ = filepath.EvalSymlinks(exePath)
|
||||||
|
if stdRuntime.GOOS == "darwin" {
|
||||||
|
return resolveMacUpdateTarget(exePath)
|
||||||
|
}
|
||||||
|
return exePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) emitUpdateDownloadProgress(status string, downloaded, total int64, message string) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := updateDownloadProgressPayload{
|
||||||
|
Status: status,
|
||||||
|
Percent: 0,
|
||||||
|
Downloaded: downloaded,
|
||||||
|
Total: total,
|
||||||
|
Message: strings.TrimSpace(message),
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
payload.Percent = math.Min(100, (float64(downloaded)/float64(total))*100)
|
||||||
|
}
|
||||||
|
if status == "done" && payload.Percent < 100 {
|
||||||
|
payload.Percent = 100
|
||||||
|
}
|
||||||
|
wailsRuntime.EventsEmit(a.ctx, updateDownloadProgressEvent, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
logPath := strings.TrimSpace(staged.InstallLogPath)
|
||||||
|
if logPath == "" {
|
||||||
|
logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
|
||||||
|
staged.InstallLogPath = logPath
|
||||||
|
}
|
||||||
|
content := buildWindowsScript(staged.FilePath, targetExe, staged.StagedDir, logPath, pid)
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("启动 Windows 更新脚本:target=%s script=%s log=%s", targetExe, scriptPath, logPath)
|
||||||
|
cmd := exec.Command("cmd", "/C", "start", "", scriptPath)
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
||||||
|
targetApp := resolveMacUpdateTarget(targetExe)
|
||||||
|
mountDir := filepath.Join(staged.StagedDir, "mnt")
|
||||||
|
if err := os.MkdirAll(mountDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logPath := strings.TrimSpace(staged.InstallLogPath)
|
||||||
|
if logPath == "" {
|
||||||
|
logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
|
||||||
|
staged.InstallLogPath = logPath
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(staged.StagedDir, "update.sh")
|
||||||
|
content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, logPath, pid)
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("/bin/bash", scriptPath)
|
||||||
|
logger.Infof("启动 macOS 更新脚本:target=%s script=%s log=%s", targetApp, scriptPath, logPath)
|
||||||
|
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, logPath string, pid int) string {
|
||||||
|
return fmt.Sprintf(`@echo off
|
||||||
|
setlocal EnableExtensions EnableDelayedExpansion
|
||||||
|
set "SOURCE=%s"
|
||||||
|
set "TARGET=%s"
|
||||||
|
set "STAGED=%s"
|
||||||
|
set "LOG_FILE=%s"
|
||||||
|
set PID=%d
|
||||||
|
|
||||||
|
call :log updater started
|
||||||
|
if not exist "%%SOURCE%%" (
|
||||||
|
call :log source file not found: %%SOURCE%%
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:waitloop
|
||||||
|
tasklist /FI "PID eq %%PID%%" | find "%%PID%%" >nul
|
||||||
|
if %%ERRORLEVEL%%==0 (
|
||||||
|
timeout /t 1 /nobreak >nul
|
||||||
|
goto waitloop
|
||||||
|
)
|
||||||
|
call :log host process exited
|
||||||
|
|
||||||
|
set /a RETRY=0
|
||||||
|
:move_retry
|
||||||
|
move /Y "%%SOURCE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||||
|
if %%ERRORLEVEL%%==0 goto move_done
|
||||||
|
|
||||||
|
copy /Y "%%SOURCE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||||
|
if %%ERRORLEVEL%%==0 goto move_done
|
||||||
|
|
||||||
|
set /a RETRY+=1
|
||||||
|
if !RETRY! LSS 20 (
|
||||||
|
timeout /t 1 /nobreak >nul
|
||||||
|
goto move_retry
|
||||||
|
)
|
||||||
|
|
||||||
|
call :log replace failed after retries (portable mode, no elevation): check directory write permission or file lock
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:move_done
|
||||||
|
start "" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||||
|
if %%ERRORLEVEL%% NEQ 0 (
|
||||||
|
call :log cmd start failed, trying powershell Start-Process
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%%TARGET%%'" >> "%%LOG_FILE%%" 2>&1
|
||||||
|
if %%ERRORLEVEL%% NEQ 0 (
|
||||||
|
call :log relaunch failed
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rmdir /S /Q "%%STAGED%%" >> "%%LOG_FILE%%" 2>&1
|
||||||
|
call :log update finished
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:log
|
||||||
|
echo [%%date%% %%time%%] %%*>>"%%LOG_FILE%%"
|
||||||
|
exit /b 0
|
||||||
|
`, source, target, stagedDir, logPath, pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string {
|
||||||
|
return fmt.Sprintf(`#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
PID=%d
|
||||||
|
DMG="%s"
|
||||||
|
TARGET_APP="%s"
|
||||||
|
STAGED="%s"
|
||||||
|
MOUNT_DIR="%s"
|
||||||
|
LOG_FILE="%s"
|
||||||
|
TMP_APP="${TARGET_APP}.new"
|
||||||
|
BACKUP_APP="${TARGET_APP}.backup"
|
||||||
|
APP_BIN_NAME=$(basename "$TARGET_APP" .app)
|
||||||
|
APP_BIN_REL="Contents/MacOS/$APP_BIN_NAME"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%%Y-%%m-%%d %%H:%%M:%%S')] $*" >> "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_admin_replace() {
|
||||||
|
/usr/bin/osascript <<'APPLESCRIPT' "$APP_SRC" "$TARGET_APP" "$TMP_APP" "$BACKUP_APP" "$APP_BIN_REL" "$LOG_FILE"
|
||||||
|
on run argv
|
||||||
|
set srcPath to item 1 of argv
|
||||||
|
set dstPath to item 2 of argv
|
||||||
|
set tmpPath to item 3 of argv
|
||||||
|
set bakPath to item 4 of argv
|
||||||
|
set binRel to item 5 of argv
|
||||||
|
set logPath to item 6 of argv
|
||||||
|
set cmd to "set -eu; " & ¬
|
||||||
|
"rm -rf " & quoted form of tmpPath & " " & quoted form of bakPath & "; " & ¬
|
||||||
|
"/usr/bin/ditto " & quoted form of srcPath & " " & quoted form of tmpPath & "; " & ¬
|
||||||
|
"if [ ! -x " & quoted form of (tmpPath & "/" & binRel) & " ]; then echo 'tmp app binary missing' >> " & quoted form of logPath & "; exit 1; fi; " & ¬
|
||||||
|
"xattr -rd com.apple.quarantine " & quoted form of tmpPath & " >> " & quoted form of logPath & " 2>&1 || true; " & ¬
|
||||||
|
"if [ -d " & quoted form of dstPath & " ]; then mv " & quoted form of dstPath & " " & quoted form of bakPath & "; fi; " & ¬
|
||||||
|
"mv " & quoted form of tmpPath & " " & quoted form of dstPath & "; " & ¬
|
||||||
|
"rm -rf " & quoted form of bakPath & "; " & ¬
|
||||||
|
"xattr -rd com.apple.quarantine " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1 || true"
|
||||||
|
do shell script cmd with administrator privileges
|
||||||
|
end run
|
||||||
|
APPLESCRIPT
|
||||||
|
}
|
||||||
|
|
||||||
|
replace_app_direct() {
|
||||||
|
rm -rf "$TMP_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true
|
||||||
|
/usr/bin/ditto "$APP_SRC" "$TMP_APP" >>"$LOG_FILE" 2>&1
|
||||||
|
if [ ! -x "$TMP_APP/$APP_BIN_REL" ]; then
|
||||||
|
log "tmp app binary missing: $TMP_APP/$APP_BIN_REL"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
xattr -rd com.apple.quarantine "$TMP_APP" >>"$LOG_FILE" 2>&1 || true
|
||||||
|
if [ -d "$TARGET_APP" ]; then
|
||||||
|
mv "$TARGET_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1
|
||||||
|
fi
|
||||||
|
if ! mv "$TMP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
|
||||||
|
log "move new app failed, trying rollback"
|
||||||
|
rm -rf "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
|
||||||
|
if [ -d "$BACKUP_APP" ]; then
|
||||||
|
mv "$BACKUP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -rf "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true
|
||||||
|
xattr -rd com.apple.quarantine "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
relaunch_app() {
|
||||||
|
if /usr/bin/open -n "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "open -n failed, trying binary launch"
|
||||||
|
"$TARGET_APP/$APP_BIN_REL" >>"$LOG_FILE" 2>&1 &
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
log "updater started"
|
||||||
|
while kill -0 $PID 2>/dev/null; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
log "host process exited"
|
||||||
|
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MOUNT_DIR" >>"$LOG_FILE" 2>&1
|
||||||
|
APP_SRC=$(ls "$MOUNT_DIR"/*.app 2>/dev/null | head -n 1 || true)
|
||||||
|
if [ -z "$APP_SRC" ]; then
|
||||||
|
log "no .app found inside dmg"
|
||||||
|
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "install target: $TARGET_APP"
|
||||||
|
if ! replace_app_direct; then
|
||||||
|
log "direct replace failed, trying admin replace"
|
||||||
|
run_admin_replace >>"$LOG_FILE" 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$TARGET_APP/$APP_BIN_REL" ]; then
|
||||||
|
log "target app binary missing after replace: $TARGET_APP/$APP_BIN_REL"
|
||||||
|
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
|
||||||
|
rm -rf "$MOUNT_DIR" "$DMG" "$STAGED" >>"$LOG_FILE" 2>&1 || true
|
||||||
|
relaunch_app
|
||||||
|
log "relaunch requested"
|
||||||
|
`, pid, dmgPath, targetApp, stagedDir, mountDir, logPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 resolveMacUpdateTarget(exePath string) string {
|
||||||
|
targetApp := detectMacAppPath(exePath)
|
||||||
|
if targetApp == "" {
|
||||||
|
return "/Applications/GoNavi.app"
|
||||||
|
}
|
||||||
|
targetApp = filepath.Clean(targetApp)
|
||||||
|
// Gatekeeper App Translocation 路径不可用于稳定覆盖更新,统一回退到 /Applications。
|
||||||
|
if strings.Contains(targetApp, string(filepath.Separator)+"AppTranslocation"+string(filepath.Separator)) {
|
||||||
|
logger.Warnf("检测到 AppTranslocation 运行路径,更新目标回退至 /Applications/GoNavi.app:%s", targetApp)
|
||||||
|
return "/Applications/GoNavi.app"
|
||||||
|
}
|
||||||
|
return targetApp
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
236
internal/app/sql_sanitize.go
Normal file
236
internal/app/sql_sanitize.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sanitizeSQLForPgLike(dbType string, query string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
||||||
|
case "postgres", "kingbase":
|
||||||
|
// 有些情况下会出现多层重复引用(例如 """"schema"""" 或 ""schema"""),单次修复不一定收敛。
|
||||||
|
// 这里做有限次数的迭代,直到输出不再变化。
|
||||||
|
out := query
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
fixed := fixBrokenDoubleDoubleQuotedIdent(out)
|
||||||
|
if fixed == out {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = fixed
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixBrokenDoubleDoubleQuotedIdent fixes accidental identifiers like:
|
||||||
|
//
|
||||||
|
// SELECT * FROM ""schema"".""table""
|
||||||
|
//
|
||||||
|
// which can be produced when a quoted identifier gets wrapped by quotes again.
|
||||||
|
//
|
||||||
|
// It is intentionally conservative:
|
||||||
|
// - only runs outside strings/comments/dollar-quoted blocks
|
||||||
|
// - does not touch valid escaped-quote sequences inside quoted identifiers (e.g. "a""b")
|
||||||
|
func fixBrokenDoubleDoubleQuotedIdent(query string) string {
|
||||||
|
if !strings.Contains(query, `""`) {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(query))
|
||||||
|
|
||||||
|
inSingle := false
|
||||||
|
inDoubleIdent := false
|
||||||
|
inLineComment := false
|
||||||
|
inBlockComment := false
|
||||||
|
dollarTag := ""
|
||||||
|
|
||||||
|
for i := 0; i < len(query); i++ {
|
||||||
|
ch := query[i]
|
||||||
|
next := byte(0)
|
||||||
|
if i+1 < len(query) {
|
||||||
|
next = query[i+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if inLineComment {
|
||||||
|
b.WriteByte(ch)
|
||||||
|
if ch == '\n' {
|
||||||
|
inLineComment = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inBlockComment {
|
||||||
|
b.WriteByte(ch)
|
||||||
|
if ch == '*' && next == '/' {
|
||||||
|
b.WriteByte('/')
|
||||||
|
i++
|
||||||
|
inBlockComment = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dollarTag != "" {
|
||||||
|
if strings.HasPrefix(query[i:], dollarTag) {
|
||||||
|
b.WriteString(dollarTag)
|
||||||
|
i += len(dollarTag) - 1
|
||||||
|
dollarTag = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte(ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inSingle {
|
||||||
|
b.WriteByte(ch)
|
||||||
|
if ch == '\'' {
|
||||||
|
// escaped single quote
|
||||||
|
if next == '\'' {
|
||||||
|
b.WriteByte('\'')
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inSingle = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inDoubleIdent {
|
||||||
|
b.WriteByte(ch)
|
||||||
|
if ch == '"' {
|
||||||
|
// escaped quote inside identifier
|
||||||
|
if next == '"' {
|
||||||
|
b.WriteByte('"')
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inDoubleIdent = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Outside of all string/comment blocks ---
|
||||||
|
if ch == '-' && next == '-' {
|
||||||
|
b.WriteByte(ch)
|
||||||
|
b.WriteByte('-')
|
||||||
|
i++
|
||||||
|
inLineComment = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '/' && next == '*' {
|
||||||
|
b.WriteByte(ch)
|
||||||
|
b.WriteByte('*')
|
||||||
|
i++
|
||||||
|
inBlockComment = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '\'' {
|
||||||
|
b.WriteByte(ch)
|
||||||
|
inSingle = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '$' {
|
||||||
|
if tag := parseDollarTag(query[i:]); tag != "" {
|
||||||
|
b.WriteString(tag)
|
||||||
|
i += len(tag) - 1
|
||||||
|
dollarTag = tag
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '"' {
|
||||||
|
// Fix: ""ident"" -> "ident" (only when it looks like a plain identifier)
|
||||||
|
// Also handle variants like ""ident""" / """"ident"""" (extra quotes at either side).
|
||||||
|
if next == '"' {
|
||||||
|
if replacement, advance, ok := tryFixDoubleDoubleQuotedIdent(query, i); ok {
|
||||||
|
b.WriteString(replacement)
|
||||||
|
i = advance - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteByte(ch)
|
||||||
|
inDoubleIdent = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteByte(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryFixDoubleDoubleQuotedIdent(query string, start int) (replacement string, advance int, ok bool) {
|
||||||
|
// start points at the first quote of a broken identifier, usually like:
|
||||||
|
// ""ident"" / ""ident""" / """"ident""""
|
||||||
|
if start < 0 || start+1 >= len(query) {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
if query[start] != '"' || query[start+1] != '"' {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
if start > 0 && query[start-1] == '"' {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
runLen := 0
|
||||||
|
for start+runLen < len(query) && query[start+runLen] == '"' {
|
||||||
|
runLen++
|
||||||
|
}
|
||||||
|
if runLen < 2 || runLen%2 == 1 {
|
||||||
|
// Odd run (e.g. """...) can be a valid quoted identifier with escaped quotes.
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStart := start + runLen
|
||||||
|
j := contentStart
|
||||||
|
for j < len(query) {
|
||||||
|
if query[j] == '"' {
|
||||||
|
endRunLen := 0
|
||||||
|
for j+endRunLen < len(query) && query[j+endRunLen] == '"' {
|
||||||
|
endRunLen++
|
||||||
|
}
|
||||||
|
if endRunLen >= 2 {
|
||||||
|
content := strings.TrimSpace(query[contentStart:j])
|
||||||
|
if looksLikeIdentifierContent(content) {
|
||||||
|
return `"` + content + `"`, j + endRunLen, true
|
||||||
|
}
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fast abort: identifier-like content should not span lines.
|
||||||
|
if query[j] == '\n' || query[j] == '\r' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeIdentifierContent(s string) bool {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '_' || r == '$' || r == '-' || unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDollarTag(s string) string {
|
||||||
|
// Match: $tag$ where tag is [A-Za-z0-9_]* (can be empty => $$)
|
||||||
|
if len(s) < 2 || s[0] != '$' {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := 1; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if c == '$' {
|
||||||
|
return s[:i+1]
|
||||||
|
}
|
||||||
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
55
internal/app/sql_sanitize_test.go
Normal file
55
internal/app/sql_sanitize_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSanitizeSQLForPgLike_FixesBrokenDoubleDoubleQuotes(t *testing.T) {
|
||||||
|
in := `SELECT * FROM ""ldf_server"".""t_user"" LIMIT 1`
|
||||||
|
out := sanitizeSQLForPgLike("kingbase", in)
|
||||||
|
want := `SELECT * FROM "ldf_server"."t_user" LIMIT 1`
|
||||||
|
if out != want {
|
||||||
|
t.Fatalf("unexpected sanitize output:\nIN: %s\nOUT: %s\nWANT: %s", in, out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeSQLForPgLike_FixesBrokenDoubleDoubleQuotes_WithExtraQuotes(t *testing.T) {
|
||||||
|
in := `SELECT * FROM ""ldf_server""".""t_user"" LIMIT 1`
|
||||||
|
out := sanitizeSQLForPgLike("kingbase", in)
|
||||||
|
want := `SELECT * FROM "ldf_server"."t_user" LIMIT 1`
|
||||||
|
if out != want {
|
||||||
|
t.Fatalf("unexpected sanitize output:\nIN: %s\nOUT: %s\nWANT: %s", in, out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeSQLForPgLike_FixesBrokenDoubleDoubleQuotes_WithQuadQuotes(t *testing.T) {
|
||||||
|
in := `SELECT * FROM """"ldf_server"""".""t_user"" LIMIT 1`
|
||||||
|
out := sanitizeSQLForPgLike("kingbase", in)
|
||||||
|
want := `SELECT * FROM "ldf_server"."t_user" LIMIT 1`
|
||||||
|
if out != want {
|
||||||
|
t.Fatalf("unexpected sanitize output:\nIN: %s\nOUT: %s\nWANT: %s", in, out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeSQLForPgLike_DoesNotTouchEscapedQuotesInsideIdentifier(t *testing.T) {
|
||||||
|
in := `SELECT "a""b" FROM "t""x"`
|
||||||
|
out := sanitizeSQLForPgLike("postgres", in)
|
||||||
|
if out != in {
|
||||||
|
t.Fatalf("should keep valid escaped quotes inside identifier:\nIN: %s\nOUT: %s", in, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeSQLForPgLike_DoesNotTouchDollarQuotedStrings(t *testing.T) {
|
||||||
|
in := "SELECT $$\"\"ldf_server\"\"$$, \"\"ldf_server\"\""
|
||||||
|
out := sanitizeSQLForPgLike("postgres", in)
|
||||||
|
want := "SELECT $$\"\"ldf_server\"\"$$, \"ldf_server\""
|
||||||
|
if out != want {
|
||||||
|
t.Fatalf("unexpected sanitize output for dollar quoted string:\nIN: %s\nOUT: %s\nWANT: %s", in, out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeSQLForPgLike_DoesNotModifyOtherDBTypes(t *testing.T) {
|
||||||
|
in := `SELECT * FROM ""ldf_server""`
|
||||||
|
out := sanitizeSQLForPgLike("mysql", in)
|
||||||
|
if out != in {
|
||||||
|
t.Fatalf("non-PG-like db should not be sanitized:\nIN: %s\nOUT: %s", in, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
70
internal/app/window_translucency_darwin.go
Normal file
70
internal/app/window_translucency_darwin.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -x objective-c -fblocks
|
||||||
|
#cgo LDFLAGS: -framework Cocoa
|
||||||
|
#import <Cocoa/Cocoa.h>
|
||||||
|
#import <dispatch/dispatch.h>
|
||||||
|
|
||||||
|
static void gonaviTuneWindowTranslucency(NSWindow *window) {
|
||||||
|
if (window == nil) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CGFloat cornerRadius = 14.0;
|
||||||
|
|
||||||
|
[window setOpaque:NO];
|
||||||
|
[window setBackgroundColor:[NSColor clearColor]];
|
||||||
|
[window setHasShadow:YES];
|
||||||
|
[window setMovableByWindowBackground:YES];
|
||||||
|
|
||||||
|
NSView *contentView = [window contentView];
|
||||||
|
if (contentView == nil) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[contentView setWantsLayer:YES];
|
||||||
|
[[contentView layer] setBackgroundColor:[[NSColor clearColor] CGColor]];
|
||||||
|
[[contentView layer] setCornerRadius:cornerRadius];
|
||||||
|
[[contentView layer] setMasksToBounds:YES];
|
||||||
|
|
||||||
|
NSVisualEffectView *effectView = nil;
|
||||||
|
for (NSView *subview in [contentView subviews]) {
|
||||||
|
if ([subview isKindOfClass:[NSVisualEffectView class]]) {
|
||||||
|
effectView = (NSVisualEffectView *)subview;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectView == nil) {
|
||||||
|
effectView = [[NSVisualEffectView alloc] initWithFrame:[contentView bounds]];
|
||||||
|
[effectView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable];
|
||||||
|
[contentView addSubview:effectView positioned:NSWindowBelow relativeTo:nil];
|
||||||
|
[effectView release];
|
||||||
|
}
|
||||||
|
|
||||||
|
[effectView setMaterial:NSVisualEffectMaterialHUDWindow];
|
||||||
|
[effectView setBlendingMode:NSVisualEffectBlendingModeBehindWindow];
|
||||||
|
[effectView setState:NSVisualEffectStateActive];
|
||||||
|
[effectView setAlphaValue:0.72];
|
||||||
|
[effectView setWantsLayer:YES];
|
||||||
|
[[effectView layer] setCornerRadius:cornerRadius];
|
||||||
|
[[effectView layer] setMasksToBounds:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
static void gonaviApplyWindowTranslucencyFix() {
|
||||||
|
for (int i = 0; i < 24; i++) {
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(i * 250 * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{
|
||||||
|
for (NSWindow *window in [NSApp windows]) {
|
||||||
|
gonaviTuneWindowTranslucency(window);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
func applyMacWindowTranslucencyFix() {
|
||||||
|
C.gonaviApplyWindowTranslucencyFix()
|
||||||
|
}
|
||||||
5
internal/app/window_translucency_stub.go
Normal file
5
internal/app/window_translucency_stub.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
func applyMacWindowTranslucencyFix() {}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -57,6 +58,20 @@ func (c *CustomDB) Ping() error {
|
|||||||
return c.conn.PingContext(ctx)
|
return c.conn.PingContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *CustomDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if c.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := c.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CustomDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
func (c *CustomDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
if c.conn == nil {
|
if c.conn == nil {
|
||||||
return nil, nil, fmt.Errorf("connection not open")
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
@@ -67,33 +82,18 @@ func (c *CustomDB) Query(query string) ([]map[string]interface{}, []string, erro
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
columns, err := rows.Columns()
|
func (c *CustomDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
if c.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := c.conn.ExecContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
var resultData []map[string]interface{}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
values := make([]interface{}, len(columns))
|
|
||||||
valuePtrs := make([]interface{}, len(columns))
|
|
||||||
for i := range columns {
|
|
||||||
valuePtrs[i] = &values[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Scan(valuePtrs...); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := make(map[string]interface{})
|
|
||||||
for i, col := range columns {
|
|
||||||
entry[col] = normalizeQueryValue(values[i])
|
|
||||||
}
|
|
||||||
resultData = append(resultData, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultData, columns, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CustomDB) Exec(query string) (int64, error) {
|
func (c *CustomDB) Exec(query string) (int64, error) {
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
"GoNavi-Wails/internal/ssh"
|
"GoNavi-Wails/internal/ssh"
|
||||||
"GoNavi-Wails/internal/utils"
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ import (
|
|||||||
type DamengDB struct {
|
type DamengDB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
pingTimeout time.Duration
|
pingTimeout time.Duration
|
||||||
|
forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DamengDB) getDSN(config connection.ConnectionConfig) string {
|
func (d *DamengDB) getDSN(config connection.ConnectionConfig) string {
|
||||||
@@ -26,16 +29,6 @@ func (d *DamengDB) getDSN(config connection.ConnectionConfig) string {
|
|||||||
// or dm://user:password@host:port
|
// or dm://user:password@host:port
|
||||||
|
|
||||||
address := net.JoinHostPort(config.Host, strconv.Itoa(config.Port))
|
address := net.JoinHostPort(config.Host, strconv.Itoa(config.Port))
|
||||||
if config.UseSSH {
|
|
||||||
// SSH logic similar to others, assumes port forwarding
|
|
||||||
_, err := ssh.RegisterSSHNetwork(config.SSH)
|
|
||||||
if err == nil {
|
|
||||||
// DM driver likely uses standard net.Dial, so we might need a local listener
|
|
||||||
// or assume port forwarding is handled externally or implicitly via "tcp" override if driver allows.
|
|
||||||
// Similar to Oracle, we skip complex custom dialer injection for now.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
escapedPassword := url.PathEscape(config.Password)
|
escapedPassword := url.PathEscape(config.Password)
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
if config.Database != "" {
|
if config.Database != "" {
|
||||||
@@ -55,7 +48,42 @@ func (d *DamengDB) getDSN(config connection.ConnectionConfig) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DamengDB) Connect(config connection.ConnectionConfig) error {
|
func (d *DamengDB) Connect(config connection.ConnectionConfig) error {
|
||||||
dsn := d.getDSN(config)
|
var dsn string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if config.UseSSH {
|
||||||
|
// Create SSH tunnel with local port forwarding
|
||||||
|
logger.Infof("达梦数据库使用 SSH 连接:地址=%s:%d 用户=%s", config.Host, config.Port, config.User)
|
||||||
|
|
||||||
|
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
|
||||||
|
}
|
||||||
|
d.forwarder = forwarder
|
||||||
|
|
||||||
|
// Parse local address
|
||||||
|
host, portStr, err := net.SplitHostPort(forwarder.LocalAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析本地转发地址失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析本地端口失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a modified config pointing to local forwarder
|
||||||
|
localConfig := config
|
||||||
|
localConfig.Host = host
|
||||||
|
localConfig.Port = port
|
||||||
|
localConfig.UseSSH = false
|
||||||
|
|
||||||
|
dsn = d.getDSN(localConfig)
|
||||||
|
logger.Infof("达梦数据库通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||||
|
} else {
|
||||||
|
dsn = d.getDSN(config)
|
||||||
|
}
|
||||||
|
|
||||||
db, err := sql.Open("dm", dsn)
|
db, err := sql.Open("dm", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||||
@@ -69,6 +97,15 @@ func (d *DamengDB) Connect(config connection.ConnectionConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DamengDB) Close() error {
|
func (d *DamengDB) Close() error {
|
||||||
|
// Close SSH forwarder first if exists
|
||||||
|
if d.forwarder != nil {
|
||||||
|
if err := d.forwarder.Close(); err != nil {
|
||||||
|
logger.Warnf("关闭达梦数据库 SSH 端口转发失败:%v", err)
|
||||||
|
}
|
||||||
|
d.forwarder = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then close database connection
|
||||||
if d.conn != nil {
|
if d.conn != nil {
|
||||||
return d.conn.Close()
|
return d.conn.Close()
|
||||||
}
|
}
|
||||||
@@ -88,6 +125,20 @@ func (d *DamengDB) Ping() error {
|
|||||||
return d.conn.PingContext(ctx)
|
return d.conn.PingContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DamengDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if d.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := d.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DamengDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
func (d *DamengDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
if d.conn == nil {
|
if d.conn == nil {
|
||||||
return nil, nil, fmt.Errorf("connection not open")
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
@@ -98,33 +149,18 @@ func (d *DamengDB) Query(query string) ([]map[string]interface{}, []string, erro
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
columns, err := rows.Columns()
|
func (d *DamengDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
if d.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := d.conn.ExecContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
var resultData []map[string]interface{}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
values := make([]interface{}, len(columns))
|
|
||||||
valuePtrs := make([]interface{}, len(columns))
|
|
||||||
for i := range columns {
|
|
||||||
valuePtrs[i] = &values[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Scan(valuePtrs...); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := make(map[string]interface{})
|
|
||||||
for i, col := range columns {
|
|
||||||
entry[col] = normalizeQueryValue(values[i])
|
|
||||||
}
|
|
||||||
resultData = append(resultData, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultData, columns, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DamengDB) Exec(query string) (int64, error) {
|
func (d *DamengDB) Exec(query string) (int64, error) {
|
||||||
@@ -337,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) {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
"GoNavi-Wails/internal/ssh"
|
"GoNavi-Wails/internal/ssh"
|
||||||
"GoNavi-Wails/internal/utils"
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
@@ -16,6 +20,7 @@ import (
|
|||||||
type KingbaseDB struct {
|
type KingbaseDB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
pingTimeout time.Duration
|
pingTimeout time.Duration
|
||||||
|
forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder
|
||||||
}
|
}
|
||||||
|
|
||||||
func quoteConnValue(v string) string {
|
func quoteConnValue(v string) string {
|
||||||
@@ -57,20 +62,6 @@ func (k *KingbaseDB) getDSN(config connection.ConnectionConfig) string {
|
|||||||
address := config.Host
|
address := config.Host
|
||||||
port := config.Port
|
port := config.Port
|
||||||
|
|
||||||
if config.UseSSH {
|
|
||||||
netName, err := ssh.RegisterSSHNetwork(config.SSH)
|
|
||||||
if err == nil {
|
|
||||||
// Kingbase/Postgres lib/pq allows custom dialer via "host" if using unix socket,
|
|
||||||
// but for custom network it's harder.
|
|
||||||
// Ideally we use a local forwarder.
|
|
||||||
// For now, we assume standard TCP or handle SSH externally.
|
|
||||||
// If we implement the net.Dial override for "kingbase" driver (which might use lib/pq internally),
|
|
||||||
// we might need to check if it supports "cloudsql" style or similar custom dialers.
|
|
||||||
// Similar to others, skipping SSH deep integration here for now.
|
|
||||||
_ = netName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct DSN
|
// Construct DSN
|
||||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable connect_timeout=%d",
|
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable connect_timeout=%d",
|
||||||
quoteConnValue(address),
|
quoteConnValue(address),
|
||||||
@@ -85,7 +76,42 @@ func (k *KingbaseDB) getDSN(config connection.ConnectionConfig) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *KingbaseDB) Connect(config connection.ConnectionConfig) error {
|
func (k *KingbaseDB) Connect(config connection.ConnectionConfig) error {
|
||||||
dsn := k.getDSN(config)
|
var dsn string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if config.UseSSH {
|
||||||
|
// Create SSH tunnel with local port forwarding
|
||||||
|
logger.Infof("人大金仓使用 SSH 连接:地址=%s:%d 用户=%s", config.Host, config.Port, config.User)
|
||||||
|
|
||||||
|
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
|
||||||
|
}
|
||||||
|
k.forwarder = forwarder
|
||||||
|
|
||||||
|
// Parse local address
|
||||||
|
host, portStr, err := net.SplitHostPort(forwarder.LocalAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析本地转发地址失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析本地端口失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a modified config pointing to local forwarder
|
||||||
|
localConfig := config
|
||||||
|
localConfig.Host = host
|
||||||
|
localConfig.Port = port
|
||||||
|
localConfig.UseSSH = false
|
||||||
|
|
||||||
|
dsn = k.getDSN(localConfig)
|
||||||
|
logger.Infof("人大金仓通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||||
|
} else {
|
||||||
|
dsn = k.getDSN(config)
|
||||||
|
}
|
||||||
|
|
||||||
// Open using "kingbase" driver
|
// Open using "kingbase" driver
|
||||||
db, err := sql.Open("kingbase", dsn)
|
db, err := sql.Open("kingbase", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,6 +126,15 @@ func (k *KingbaseDB) Connect(config connection.ConnectionConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *KingbaseDB) Close() error {
|
func (k *KingbaseDB) Close() error {
|
||||||
|
// Close SSH forwarder first if exists
|
||||||
|
if k.forwarder != nil {
|
||||||
|
if err := k.forwarder.Close(); err != nil {
|
||||||
|
logger.Warnf("关闭人大金仓 SSH 端口转发失败:%v", err)
|
||||||
|
}
|
||||||
|
k.forwarder = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then close database connection
|
||||||
if k.conn != nil {
|
if k.conn != nil {
|
||||||
return k.conn.Close()
|
return k.conn.Close()
|
||||||
}
|
}
|
||||||
@@ -119,6 +154,20 @@ func (k *KingbaseDB) Ping() error {
|
|||||||
return k.conn.PingContext(ctx)
|
return k.conn.PingContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (k *KingbaseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if k.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := k.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
func (k *KingbaseDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
func (k *KingbaseDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
if k.conn == nil {
|
if k.conn == nil {
|
||||||
return nil, nil, fmt.Errorf("connection not open")
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
@@ -129,33 +178,18 @@ func (k *KingbaseDB) Query(query string) ([]map[string]interface{}, []string, er
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
columns, err := rows.Columns()
|
func (k *KingbaseDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
if k.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := k.conn.ExecContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
var resultData []map[string]interface{}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
values := make([]interface{}, len(columns))
|
|
||||||
valuePtrs := make([]interface{}, len(columns))
|
|
||||||
for i := range columns {
|
|
||||||
valuePtrs[i] = &values[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Scan(valuePtrs...); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := make(map[string]interface{})
|
|
||||||
for i, col := range columns {
|
|
||||||
entry[col] = normalizeQueryValue(values[i])
|
|
||||||
}
|
|
||||||
resultData = append(resultData, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultData, columns, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KingbaseDB) Exec(query string) (int64, error) {
|
func (k *KingbaseDB) Exec(query string) (int64, error) {
|
||||||
@@ -223,15 +257,84 @@ func (k *KingbaseDB) GetCreateStatement(dbName, tableName string) (string, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
schema := "public"
|
// 解析 schema.table 格式
|
||||||
if dbName != "" {
|
schema := strings.TrimSpace(dbName)
|
||||||
schema = dbName
|
table := strings.TrimSpace(tableName)
|
||||||
|
|
||||||
|
// 如果 tableName 包含 schema (格式: schema.table)
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
parsedSchema := strings.TrimSpace(parts[0])
|
||||||
|
parsedTable := strings.TrimSpace(parts[1])
|
||||||
|
if parsedSchema != "" && parsedTable != "" {
|
||||||
|
schema = parsedSchema
|
||||||
|
table = parsedTable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default
|
// 如果仍然没有 schema,使用 current_schema()
|
||||||
FROM information_schema.columns
|
// 这样可以自动匹配当前连接的 search_path
|
||||||
WHERE table_schema = '%s' AND table_name = '%s'
|
if schema == "" {
|
||||||
ORDER BY ordinal_position`, schema, tableName)
|
return k.getColumnsWithCurrentSchema(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转义函数:处理单引号,移除双引号
|
||||||
|
esc := func(s string) string {
|
||||||
|
// 移除前后的双引号(如果存在)
|
||||||
|
s = strings.Trim(s, "\"")
|
||||||
|
// 转义单引号
|
||||||
|
return strings.ReplaceAll(s, "'", "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = '%s' AND table_name = '%s'
|
||||||
|
ORDER BY ordinal_position`, esc(schema), esc(table))
|
||||||
|
|
||||||
|
data, _, err := k.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var columns []connection.ColumnDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
col := connection.ColumnDefinition{
|
||||||
|
Name: fmt.Sprintf("%v", row["column_name"]),
|
||||||
|
Type: fmt.Sprintf("%v", row["data_type"]),
|
||||||
|
Nullable: fmt.Sprintf("%v", row["is_nullable"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
if row["column_default"] != nil {
|
||||||
|
def := fmt.Sprintf("%v", row["column_default"])
|
||||||
|
col.Default = &def
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = append(columns, col)
|
||||||
|
}
|
||||||
|
return columns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getColumnsWithCurrentSchema 使用 current_schema() 查询当前schema的表
|
||||||
|
func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转义函数
|
||||||
|
esc := func(s string) string {
|
||||||
|
s = strings.Trim(s, "\"")
|
||||||
|
return strings.ReplaceAll(s, "'", "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 current_schema() 获取当前schema
|
||||||
|
query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema() AND table_name = '%s'
|
||||||
|
ORDER BY ordinal_position`, esc(table))
|
||||||
|
|
||||||
data, _, err := k.Query(query)
|
data, _, err := k.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -257,32 +360,76 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *KingbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
func (k *KingbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
// Postgres/Kingbase index query
|
// 解析 schema.table 格式
|
||||||
query := fmt.Sprintf(`
|
schema := strings.TrimSpace(dbName)
|
||||||
SELECT
|
table := strings.TrimSpace(tableName)
|
||||||
i.relname as index_name,
|
|
||||||
a.attname as column_name,
|
|
||||||
ix.indisunique as is_unique
|
|
||||||
FROM
|
|
||||||
pg_class t,
|
|
||||||
pg_class i,
|
|
||||||
pg_index ix,
|
|
||||||
pg_attribute a,
|
|
||||||
pg_namespace n
|
|
||||||
WHERE
|
|
||||||
t.oid = ix.indrelid
|
|
||||||
AND i.oid = ix.indexrelid
|
|
||||||
AND a.attrelid = t.oid
|
|
||||||
AND a.attnum = ANY(ix.indkey)
|
|
||||||
AND t.relkind = 'r'
|
|
||||||
AND t.relname = '%s'
|
|
||||||
AND n.oid = t.relnamespace
|
|
||||||
AND n.nspname = '%s'
|
|
||||||
`, tableName, "public") // Default to public if dbName (schema) not clear.
|
|
||||||
|
|
||||||
if dbName != "" {
|
// 如果 tableName 包含 schema (格式: schema.table)
|
||||||
// Update query to use dbName as schema
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
query = strings.Replace(query, "'public'", fmt.Sprintf("'%s'", dbName), 1)
|
parsedSchema := strings.TrimSpace(parts[0])
|
||||||
|
parsedTable := strings.TrimSpace(parts[1])
|
||||||
|
if parsedSchema != "" && parsedTable != "" {
|
||||||
|
schema = parsedSchema
|
||||||
|
table = parsedTable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转义函数:处理单引号,移除双引号
|
||||||
|
esc := func(s string) string {
|
||||||
|
s = strings.Trim(s, "\"")
|
||||||
|
return strings.ReplaceAll(s, "'", "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建查询:如果没有指定schema,使用current_schema()
|
||||||
|
var query string
|
||||||
|
if schema != "" {
|
||||||
|
query = fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
i.relname as index_name,
|
||||||
|
a.attname as column_name,
|
||||||
|
ix.indisunique as is_unique
|
||||||
|
FROM
|
||||||
|
pg_class t,
|
||||||
|
pg_class i,
|
||||||
|
pg_index ix,
|
||||||
|
pg_attribute a,
|
||||||
|
pg_namespace n
|
||||||
|
WHERE
|
||||||
|
t.oid = ix.indrelid
|
||||||
|
AND i.oid = ix.indexrelid
|
||||||
|
AND a.attrelid = t.oid
|
||||||
|
AND a.attnum = ANY(ix.indkey)
|
||||||
|
AND t.relkind = 'r'
|
||||||
|
AND t.relname = '%s'
|
||||||
|
AND n.oid = t.relnamespace
|
||||||
|
AND n.nspname = '%s'
|
||||||
|
`, esc(table), esc(schema))
|
||||||
|
} else {
|
||||||
|
query = fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
i.relname as index_name,
|
||||||
|
a.attname as column_name,
|
||||||
|
ix.indisunique as is_unique
|
||||||
|
FROM
|
||||||
|
pg_class t,
|
||||||
|
pg_class i,
|
||||||
|
pg_index ix,
|
||||||
|
pg_attribute a,
|
||||||
|
pg_namespace n
|
||||||
|
WHERE
|
||||||
|
t.oid = ix.indrelid
|
||||||
|
AND i.oid = ix.indexrelid
|
||||||
|
AND a.attrelid = t.oid
|
||||||
|
AND a.attnum = ANY(ix.indkey)
|
||||||
|
AND t.relkind = 'r'
|
||||||
|
AND t.relname = '%s'
|
||||||
|
AND n.oid = t.relnamespace
|
||||||
|
AND n.nspname = current_schema()
|
||||||
|
`, esc(table))
|
||||||
}
|
}
|
||||||
|
|
||||||
data, _, err := k.Query(query)
|
data, _, err := k.Query(query)
|
||||||
@@ -311,27 +458,67 @@ func (k *KingbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDef
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *KingbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
func (k *KingbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
schema := "public"
|
// 解析 schema.table 格式
|
||||||
if dbName != "" {
|
schema := strings.TrimSpace(dbName)
|
||||||
schema = dbName
|
table := strings.TrimSpace(tableName)
|
||||||
|
|
||||||
|
// 如果 tableName 包含 schema (格式: schema.table)
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
parsedSchema := strings.TrimSpace(parts[0])
|
||||||
|
parsedTable := strings.TrimSpace(parts[1])
|
||||||
|
if parsedSchema != "" && parsedTable != "" {
|
||||||
|
schema = parsedSchema
|
||||||
|
table = parsedTable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
if table == "" {
|
||||||
SELECT
|
return nil, fmt.Errorf("table name required")
|
||||||
tc.constraint_name,
|
}
|
||||||
kcu.column_name,
|
|
||||||
ccu.table_name AS foreign_table_name,
|
// 转义函数:处理单引号,移除双引号
|
||||||
ccu.column_name AS foreign_column_name
|
esc := func(s string) string {
|
||||||
FROM
|
s = strings.Trim(s, "\"")
|
||||||
information_schema.table_constraints AS tc
|
return strings.ReplaceAll(s, "'", "''")
|
||||||
JOIN information_schema.key_column_usage AS kcu
|
}
|
||||||
ON tc.constraint_name = kcu.constraint_name
|
|
||||||
AND tc.table_schema = kcu.table_schema
|
// 构建查询:如果没有指定schema,使用current_schema()
|
||||||
JOIN information_schema.constraint_column_usage AS ccu
|
var query string
|
||||||
ON ccu.constraint_name = tc.constraint_name
|
if schema != "" {
|
||||||
AND ccu.table_schema = tc.table_schema
|
query = fmt.Sprintf(`
|
||||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name='%s' AND tc.table_schema='%s'`,
|
SELECT
|
||||||
tableName, schema)
|
tc.constraint_name,
|
||||||
|
kcu.column_name,
|
||||||
|
ccu.table_name AS foreign_table_name,
|
||||||
|
ccu.column_name AS foreign_column_name
|
||||||
|
FROM
|
||||||
|
information_schema.table_constraints AS tc
|
||||||
|
JOIN information_schema.key_column_usage AS kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
AND tc.table_schema = kcu.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage AS ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name='%s' AND tc.table_schema='%s'`,
|
||||||
|
esc(table), esc(schema))
|
||||||
|
} else {
|
||||||
|
query = fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
tc.constraint_name,
|
||||||
|
kcu.column_name,
|
||||||
|
ccu.table_name AS foreign_table_name,
|
||||||
|
ccu.column_name AS foreign_column_name
|
||||||
|
FROM
|
||||||
|
information_schema.table_constraints AS tc
|
||||||
|
JOIN information_schema.key_column_usage AS kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
AND tc.table_schema = kcu.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage AS ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name='%s' AND tc.table_schema=current_schema()`,
|
||||||
|
esc(table))
|
||||||
|
}
|
||||||
|
|
||||||
data, _, err := k.Query(query)
|
data, _, err := k.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -353,9 +540,43 @@ func (k *KingbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.Fore
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *KingbaseDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
func (k *KingbaseDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
query := fmt.Sprintf(`SELECT trigger_name, action_timing, event_manipulation
|
// 解析 schema.table 格式
|
||||||
FROM information_schema.triggers
|
schema := strings.TrimSpace(dbName)
|
||||||
WHERE event_object_table = '%s'`, tableName)
|
table := strings.TrimSpace(tableName)
|
||||||
|
|
||||||
|
// 如果 tableName 包含 schema (格式: schema.table)
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
parsedSchema := strings.TrimSpace(parts[0])
|
||||||
|
parsedTable := strings.TrimSpace(parts[1])
|
||||||
|
if parsedSchema != "" && parsedTable != "" {
|
||||||
|
schema = parsedSchema
|
||||||
|
table = parsedTable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转义函数:处理单引号,移除双引号
|
||||||
|
esc := func(s string) string {
|
||||||
|
s = strings.Trim(s, "\"")
|
||||||
|
return strings.ReplaceAll(s, "'", "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建查询:如果指定了schema,也加上schema条件
|
||||||
|
var query string
|
||||||
|
if schema != "" {
|
||||||
|
query = fmt.Sprintf(`SELECT trigger_name, action_timing, event_manipulation
|
||||||
|
FROM information_schema.triggers
|
||||||
|
WHERE event_object_table = '%s' AND event_object_schema = '%s'`,
|
||||||
|
esc(table), esc(schema))
|
||||||
|
} else {
|
||||||
|
query = fmt.Sprintf(`SELECT trigger_name, action_timing, event_manipulation
|
||||||
|
FROM information_schema.triggers
|
||||||
|
WHERE event_object_table = '%s' AND event_object_schema = current_schema()`,
|
||||||
|
esc(table))
|
||||||
|
}
|
||||||
|
|
||||||
data, _, err := k.Query(query)
|
data, _, err := k.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -376,18 +597,127 @@ 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) {
|
||||||
schema := "public"
|
// dbName 在本项目语义里是“数据库”,schema 由 table_schema 决定;这里返回全部用户 schema 的列用于查询提示。
|
||||||
if dbName != "" {
|
query := `
|
||||||
schema = dbName
|
SELECT table_schema, table_name, column_name, data_type
|
||||||
}
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
query := fmt.Sprintf(`SELECT table_name, column_name, data_type
|
AND table_schema NOT LIKE 'pg_%'
|
||||||
FROM information_schema.columns
|
ORDER BY table_schema, table_name, ordinal_position`
|
||||||
WHERE table_schema = '%s'`, schema)
|
|
||||||
|
|
||||||
data, _, err := k.Query(query)
|
data, _, err := k.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -396,8 +726,14 @@ func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinition
|
|||||||
|
|
||||||
var cols []connection.ColumnDefinitionWithTable
|
var cols []connection.ColumnDefinitionWithTable
|
||||||
for _, row := range data {
|
for _, row := range data {
|
||||||
|
schema := fmt.Sprintf("%v", row["table_schema"])
|
||||||
|
table := fmt.Sprintf("%v", row["table_name"])
|
||||||
|
tableName := table
|
||||||
|
if strings.TrimSpace(schema) != "" {
|
||||||
|
tableName = fmt.Sprintf("%s.%s", schema, table)
|
||||||
|
}
|
||||||
col := connection.ColumnDefinitionWithTable{
|
col := connection.ColumnDefinitionWithTable{
|
||||||
TableName: fmt.Sprintf("%v", row["table_name"]),
|
TableName: tableName,
|
||||||
Name: fmt.Sprintf("%v", row["column_name"]),
|
Name: fmt.Sprintf("%v", row["column_name"]),
|
||||||
Type: fmt.Sprintf("%v", row["data_type"]),
|
Type: fmt.Sprintf("%v", row["data_type"]),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -76,6 +77,20 @@ func (m *MySQLDB) Ping() error {
|
|||||||
return m.conn.PingContext(ctx)
|
return m.conn.PingContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MySQLDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if m.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := m.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
func (m *MySQLDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
if m.conn == nil {
|
if m.conn == nil {
|
||||||
return nil, nil, fmt.Errorf("connection not open")
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
@@ -86,33 +101,18 @@ func (m *MySQLDB) Query(query string) ([]map[string]interface{}, []string, error
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
columns, err := rows.Columns()
|
func (m *MySQLDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
if m.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := m.conn.ExecContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
var resultData []map[string]interface{}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
values := make([]interface{}, len(columns))
|
|
||||||
valuePtrs := make([]interface{}, len(columns))
|
|
||||||
for i := range columns {
|
|
||||||
valuePtrs[i] = &values[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Scan(valuePtrs...); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := make(map[string]interface{})
|
|
||||||
for i, col := range columns {
|
|
||||||
entry[col] = normalizeQueryValue(values[i])
|
|
||||||
}
|
|
||||||
resultData = append(resultData, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultData, columns, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) Exec(query string) (int64, error) {
|
func (m *MySQLDB) Exec(query string) (int64, 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 == "" {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
"GoNavi-Wails/internal/ssh"
|
"GoNavi-Wails/internal/ssh"
|
||||||
"GoNavi-Wails/internal/utils"
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ import (
|
|||||||
type OracleDB struct {
|
type OracleDB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
pingTimeout time.Duration
|
pingTimeout time.Duration
|
||||||
|
forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OracleDB) getDSN(config connection.ConnectionConfig) string {
|
func (o *OracleDB) getDSN(config connection.ConnectionConfig) string {
|
||||||
@@ -28,28 +31,6 @@ func (o *OracleDB) getDSN(config connection.ConnectionConfig) string {
|
|||||||
database = config.User // Default to user service/schema if empty?
|
database = config.User // Default to user service/schema if empty?
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.UseSSH {
|
|
||||||
_, err := ssh.RegisterSSHNetwork(config.SSH)
|
|
||||||
if err == nil {
|
|
||||||
// Oracle driver might not support custom dialer via DSN easily without extra config
|
|
||||||
// But go-ora v2 supports some advanced options.
|
|
||||||
// For simplicity, we assume standard TCP or we might need a workaround for SSH.
|
|
||||||
// go-ora v2 is pure Go, so we can potentially use a custom dialer if we manually open.
|
|
||||||
// But for now, let's just use the address.
|
|
||||||
// SSH tunneling via net.Dialer override is complex in sql.Open("oracle", ...).
|
|
||||||
// We might need to forward a local port if using SSH.
|
|
||||||
// Since ssh.RegisterSSHNetwork creates a custom network "ssh-via-...",
|
|
||||||
// we need to see if go-ora supports custom networks.
|
|
||||||
// Checking go-ora docs (simulated): It supports "unix" and "tcp".
|
|
||||||
// We might need to map the custom network to a local proxy.
|
|
||||||
// For now, we will assume direct connection or handle SSH separately later.
|
|
||||||
// We'll leave the protocol implementation as is in MySQL for now, hoping go-ora uses standard net.Dial.
|
|
||||||
// Note: go-ora connection string: oracle://user:pass@host:port/service
|
|
||||||
// It parses host/port. It doesn't easily take a custom "network" parameter in URL.
|
|
||||||
// We will proceed with standard TCP string.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
u := &url.URL{
|
u := &url.URL{
|
||||||
Scheme: "oracle",
|
Scheme: "oracle",
|
||||||
Host: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
|
Host: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
|
||||||
@@ -61,7 +42,42 @@ func (o *OracleDB) getDSN(config connection.ConnectionConfig) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (o *OracleDB) Connect(config connection.ConnectionConfig) error {
|
func (o *OracleDB) Connect(config connection.ConnectionConfig) error {
|
||||||
dsn := o.getDSN(config)
|
var dsn string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if config.UseSSH {
|
||||||
|
// Create SSH tunnel with local port forwarding
|
||||||
|
logger.Infof("Oracle 使用 SSH 连接:地址=%s:%d 用户=%s", config.Host, config.Port, config.User)
|
||||||
|
|
||||||
|
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
|
||||||
|
}
|
||||||
|
o.forwarder = forwarder
|
||||||
|
|
||||||
|
// Parse local address
|
||||||
|
host, portStr, err := net.SplitHostPort(forwarder.LocalAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析本地转发地址失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析本地端口失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a modified config pointing to local forwarder
|
||||||
|
localConfig := config
|
||||||
|
localConfig.Host = host
|
||||||
|
localConfig.Port = port
|
||||||
|
localConfig.UseSSH = false
|
||||||
|
|
||||||
|
dsn = o.getDSN(localConfig)
|
||||||
|
logger.Infof("Oracle 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||||
|
} else {
|
||||||
|
dsn = o.getDSN(config)
|
||||||
|
}
|
||||||
|
|
||||||
db, err := sql.Open("oracle", dsn)
|
db, err := sql.Open("oracle", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||||
@@ -75,6 +91,15 @@ func (o *OracleDB) Connect(config connection.ConnectionConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (o *OracleDB) Close() error {
|
func (o *OracleDB) Close() error {
|
||||||
|
// Close SSH forwarder first if exists
|
||||||
|
if o.forwarder != nil {
|
||||||
|
if err := o.forwarder.Close(); err != nil {
|
||||||
|
logger.Warnf("关闭 Oracle SSH 端口转发失败:%v", err)
|
||||||
|
}
|
||||||
|
o.forwarder = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then close database connection
|
||||||
if o.conn != nil {
|
if o.conn != nil {
|
||||||
return o.conn.Close()
|
return o.conn.Close()
|
||||||
}
|
}
|
||||||
@@ -94,6 +119,20 @@ func (o *OracleDB) Ping() error {
|
|||||||
return o.conn.PingContext(ctx)
|
return o.conn.PingContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *OracleDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if o.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := o.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OracleDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
func (o *OracleDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
if o.conn == nil {
|
if o.conn == nil {
|
||||||
return nil, nil, fmt.Errorf("connection not open")
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
@@ -104,33 +143,18 @@ func (o *OracleDB) Query(query string) ([]map[string]interface{}, []string, erro
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
columns, err := rows.Columns()
|
func (o *OracleDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
if o.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := o.conn.ExecContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
var resultData []map[string]interface{}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
values := make([]interface{}, len(columns))
|
|
||||||
valuePtrs := make([]interface{}, len(columns))
|
|
||||||
for i := range columns {
|
|
||||||
valuePtrs[i] = &values[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Scan(valuePtrs...); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := make(map[string]interface{})
|
|
||||||
for i, col := range columns {
|
|
||||||
entry[col] = normalizeQueryValue(values[i])
|
|
||||||
}
|
|
||||||
resultData = append(resultData, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultData, columns, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OracleDB) Exec(query string) (int64, error) {
|
func (o *OracleDB) Exec(query string) (int64, error) {
|
||||||
@@ -339,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) {
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
|
"GoNavi-Wails/internal/ssh"
|
||||||
"GoNavi-Wails/internal/utils"
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
type PostgresDB struct {
|
type PostgresDB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
pingTimeout time.Duration
|
pingTimeout time.Duration
|
||||||
|
forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
|
func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
|
||||||
// postgres://user:password@host:port/dbname?sslmode=disable
|
// postgres://user:password@host:port/dbname?sslmode=disable
|
||||||
dbname := config.Database
|
dbname := config.Database
|
||||||
@@ -41,7 +48,42 @@ func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
||||||
dsn := p.getDSN(config)
|
var dsn string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if config.UseSSH {
|
||||||
|
// Create SSH tunnel with local port forwarding
|
||||||
|
logger.Infof("PostgreSQL 使用 SSH 连接:地址=%s:%d 用户=%s", config.Host, config.Port, config.User)
|
||||||
|
|
||||||
|
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
|
||||||
|
}
|
||||||
|
p.forwarder = forwarder
|
||||||
|
|
||||||
|
// Parse local address
|
||||||
|
host, portStr, err := net.SplitHostPort(forwarder.LocalAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析本地转发地址失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析本地端口失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a modified config pointing to local forwarder
|
||||||
|
localConfig := config
|
||||||
|
localConfig.Host = host
|
||||||
|
localConfig.Port = port
|
||||||
|
localConfig.UseSSH = false // Disable SSH flag for DSN generation
|
||||||
|
|
||||||
|
dsn = p.getDSN(localConfig)
|
||||||
|
logger.Infof("PostgreSQL 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||||
|
} else {
|
||||||
|
dsn = p.getDSN(config)
|
||||||
|
}
|
||||||
|
|
||||||
db, err := sql.Open("postgres", dsn)
|
db, err := sql.Open("postgres", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||||
@@ -56,7 +98,17 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (p *PostgresDB) Close() error {
|
func (p *PostgresDB) Close() error {
|
||||||
|
// Close SSH forwarder first if exists
|
||||||
|
if p.forwarder != nil {
|
||||||
|
if err := p.forwarder.Close(); err != nil {
|
||||||
|
logger.Warnf("关闭 PostgreSQL SSH 端口转发失败:%v", err)
|
||||||
|
}
|
||||||
|
p.forwarder = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then close database connection
|
||||||
if p.conn != nil {
|
if p.conn != nil {
|
||||||
return p.conn.Close()
|
return p.conn.Close()
|
||||||
}
|
}
|
||||||
@@ -76,6 +128,20 @@ func (p *PostgresDB) Ping() error {
|
|||||||
return p.conn.PingContext(ctx)
|
return p.conn.PingContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *PostgresDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if p.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := p.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
func (p *PostgresDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
if p.conn == nil {
|
if p.conn == nil {
|
||||||
return nil, nil, fmt.Errorf("connection not open")
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
@@ -86,33 +152,18 @@ func (p *PostgresDB) Query(query string) ([]map[string]interface{}, []string, er
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
columns, err := rows.Columns()
|
func (p *PostgresDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
if p.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := p.conn.ExecContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
var resultData []map[string]interface{}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
values := make([]interface{}, len(columns))
|
|
||||||
valuePtrs := make([]interface{}, len(columns))
|
|
||||||
for i := range columns {
|
|
||||||
valuePtrs[i] = &values[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Scan(valuePtrs...); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := make(map[string]interface{})
|
|
||||||
for i, col := range columns {
|
|
||||||
entry[col] = normalizeQueryValue(values[i])
|
|
||||||
}
|
|
||||||
resultData = append(resultData, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultData, columns, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) Exec(query string) (int64, error) {
|
func (p *PostgresDB) Exec(query string) (int64, error) {
|
||||||
@@ -167,21 +218,420 @@ func (p *PostgresDB) GetCreateStatement(dbName, tableName string) (string, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
func (p *PostgresDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
return []connection.ColumnDefinition{}, nil
|
schema := strings.TrimSpace(dbName)
|
||||||
|
if schema == "" {
|
||||||
|
schema = "public"
|
||||||
|
}
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
a.attname AS column_name,
|
||||||
|
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
|
||||||
|
CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
|
||||||
|
pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
|
||||||
|
col_description(a.attrelid, a.attnum) AS comment,
|
||||||
|
CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.oid
|
||||||
|
LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT i.indrelid, a3.attname
|
||||||
|
FROM pg_index i
|
||||||
|
JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey)
|
||||||
|
WHERE i.indisprimary
|
||||||
|
) pk ON pk.indrelid = c.oid AND pk.attname = a.attname
|
||||||
|
WHERE c.relkind IN ('r', 'p')
|
||||||
|
AND n.nspname = '%s'
|
||||||
|
AND c.relname = '%s'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped
|
||||||
|
ORDER BY a.attnum`, esc(schema), esc(table))
|
||||||
|
|
||||||
|
data, _, err := p.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var columns []connection.ColumnDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
col := connection.ColumnDefinition{
|
||||||
|
Name: fmt.Sprintf("%v", row["column_name"]),
|
||||||
|
Type: fmt.Sprintf("%v", row["data_type"]),
|
||||||
|
Nullable: fmt.Sprintf("%v", row["is_nullable"]),
|
||||||
|
Key: fmt.Sprintf("%v", row["column_key"]),
|
||||||
|
Extra: "",
|
||||||
|
Comment: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := row["comment"]; ok && v != nil {
|
||||||
|
col.Comment = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := row["column_default"]; ok && v != nil {
|
||||||
|
def := fmt.Sprintf("%v", v)
|
||||||
|
col.Default = &def
|
||||||
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") {
|
||||||
|
col.Extra = "auto_increment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = append(columns, col)
|
||||||
|
}
|
||||||
|
return columns, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
return []connection.IndexDefinition{}, nil
|
schema := strings.TrimSpace(dbName)
|
||||||
|
if schema == "" {
|
||||||
|
schema = "public"
|
||||||
|
}
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
i.relname AS index_name,
|
||||||
|
a.attname AS column_name,
|
||||||
|
ix.indisunique AS is_unique,
|
||||||
|
x.ordinality AS seq_in_index,
|
||||||
|
am.amname AS index_type
|
||||||
|
FROM pg_class t
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
JOIN pg_index ix ON t.oid = ix.indrelid
|
||||||
|
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||||
|
JOIN pg_am am ON i.relam = am.oid
|
||||||
|
JOIN unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinality) ON TRUE
|
||||||
|
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
|
||||||
|
WHERE t.relkind IN ('r', 'p')
|
||||||
|
AND t.relname = '%s'
|
||||||
|
AND n.nspname = '%s'
|
||||||
|
ORDER BY i.relname, x.ordinality`, esc(table), esc(schema))
|
||||||
|
|
||||||
|
data, _, err := p.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parseBool := func(v interface{}) bool {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return val
|
||||||
|
case string:
|
||||||
|
s := strings.ToLower(strings.TrimSpace(val))
|
||||||
|
return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes"
|
||||||
|
default:
|
||||||
|
s := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
|
||||||
|
return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseInt := func(v interface{}) int {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case int:
|
||||||
|
return val
|
||||||
|
case int64:
|
||||||
|
return int(val)
|
||||||
|
case float64:
|
||||||
|
return int(val)
|
||||||
|
case string:
|
||||||
|
// best effort
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexes []connection.IndexDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
isUnique := false
|
||||||
|
if v, ok := row["is_unique"]; ok && v != nil {
|
||||||
|
isUnique = parseBool(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonUnique := 1
|
||||||
|
if isUnique {
|
||||||
|
nonUnique = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := 0
|
||||||
|
if v, ok := row["seq_in_index"]; ok && v != nil {
|
||||||
|
seq = parseInt(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
indexType := ""
|
||||||
|
if v, ok := row["index_type"]; ok && v != nil {
|
||||||
|
indexType = strings.ToUpper(fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
if indexType == "" {
|
||||||
|
indexType = "BTREE"
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := connection.IndexDefinition{
|
||||||
|
Name: fmt.Sprintf("%v", row["index_name"]),
|
||||||
|
ColumnName: fmt.Sprintf("%v", row["column_name"]),
|
||||||
|
NonUnique: nonUnique,
|
||||||
|
SeqInIndex: seq,
|
||||||
|
IndexType: indexType,
|
||||||
|
}
|
||||||
|
indexes = append(indexes, idx)
|
||||||
|
}
|
||||||
|
return indexes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
return []connection.ForeignKeyDefinition{}, nil
|
schema := strings.TrimSpace(dbName)
|
||||||
|
if schema == "" {
|
||||||
|
schema = "public"
|
||||||
|
}
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
tc.constraint_name AS constraint_name,
|
||||||
|
kcu.column_name AS column_name,
|
||||||
|
ccu.table_schema AS foreign_table_schema,
|
||||||
|
ccu.table_name AS foreign_table_name,
|
||||||
|
ccu.column_name AS foreign_column_name
|
||||||
|
FROM information_schema.table_constraints AS tc
|
||||||
|
JOIN information_schema.key_column_usage AS kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
AND tc.table_schema = kcu.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage AS ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.table_name = '%s'
|
||||||
|
AND tc.table_schema = '%s'
|
||||||
|
ORDER BY tc.constraint_name, kcu.ordinal_position`, esc(table), esc(schema))
|
||||||
|
|
||||||
|
data, _, err := p.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var fks []connection.ForeignKeyDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
refSchema := ""
|
||||||
|
if v, ok := row["foreign_table_schema"]; ok && v != nil {
|
||||||
|
refSchema = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
refTable := fmt.Sprintf("%v", row["foreign_table_name"])
|
||||||
|
refTableName := refTable
|
||||||
|
if strings.TrimSpace(refSchema) != "" {
|
||||||
|
refTableName = fmt.Sprintf("%s.%s", refSchema, refTable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fk := connection.ForeignKeyDefinition{
|
||||||
|
Name: fmt.Sprintf("%v", row["constraint_name"]),
|
||||||
|
ColumnName: fmt.Sprintf("%v", row["column_name"]),
|
||||||
|
RefTableName: refTableName,
|
||||||
|
RefColumnName: fmt.Sprintf("%v", row["foreign_column_name"]),
|
||||||
|
ConstraintName: fmt.Sprintf("%v", row["constraint_name"]),
|
||||||
|
}
|
||||||
|
fks = append(fks, fk)
|
||||||
|
}
|
||||||
|
return fks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
func (p *PostgresDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
return []connection.TriggerDefinition{}, nil
|
schema := strings.TrimSpace(dbName)
|
||||||
|
if schema == "" {
|
||||||
|
schema = "public"
|
||||||
|
}
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT trigger_name, action_timing, event_manipulation, action_statement
|
||||||
|
FROM information_schema.triggers
|
||||||
|
WHERE event_object_table = '%s'
|
||||||
|
AND event_object_schema = '%s'
|
||||||
|
ORDER BY trigger_name, event_manipulation`, esc(table), esc(schema))
|
||||||
|
|
||||||
|
data, _, err := p.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var triggers []connection.TriggerDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
trig := connection.TriggerDefinition{
|
||||||
|
Name: fmt.Sprintf("%v", row["trigger_name"]),
|
||||||
|
Timing: fmt.Sprintf("%v", row["action_timing"]),
|
||||||
|
Event: fmt.Sprintf("%v", row["event_manipulation"]),
|
||||||
|
Statement: fmt.Sprintf("%v", row["action_statement"]),
|
||||||
|
}
|
||||||
|
triggers = append(triggers, trig)
|
||||||
|
}
|
||||||
|
return triggers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
func (p *PostgresDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
return []connection.ColumnDefinitionWithTable{}, nil
|
query := `
|
||||||
|
SELECT table_schema, table_name, column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
AND table_schema NOT LIKE 'pg_%'
|
||||||
|
ORDER BY table_schema, table_name, ordinal_position`
|
||||||
|
|
||||||
|
data, _, err := p.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cols []connection.ColumnDefinitionWithTable
|
||||||
|
for _, row := range data {
|
||||||
|
schema := fmt.Sprintf("%v", row["table_schema"])
|
||||||
|
table := fmt.Sprintf("%v", row["table_name"])
|
||||||
|
tableName := table
|
||||||
|
if strings.TrimSpace(schema) != "" {
|
||||||
|
tableName = fmt.Sprintf("%s.%s", schema, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
col := connection.ColumnDefinitionWithTable{
|
||||||
|
TableName: tableName,
|
||||||
|
Name: fmt.Sprintf("%v", row["column_name"]),
|
||||||
|
Type: fmt.Sprintf("%v", row["data_type"]),
|
||||||
|
}
|
||||||
|
cols = append(cols, col)
|
||||||
|
}
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
@@ -9,13 +11,17 @@ import (
|
|||||||
// normalizeQueryValue normalizes driver-returned values for UI/JSON transport.
|
// normalizeQueryValue normalizes driver-returned values for UI/JSON transport.
|
||||||
// 当前主要处理 []byte:如果是可读文本则转为 string,否则转为十六进制字符串,避免前端出现“空白值”。
|
// 当前主要处理 []byte:如果是可读文本则转为 string,否则转为十六进制字符串,避免前端出现“空白值”。
|
||||||
func normalizeQueryValue(v interface{}) interface{} {
|
func normalizeQueryValue(v interface{}) interface{} {
|
||||||
|
return normalizeQueryValueWithDBType(v, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeQueryValueWithDBType(v interface{}, databaseTypeName string) interface{} {
|
||||||
if b, ok := v.([]byte); ok {
|
if b, ok := v.([]byte); ok {
|
||||||
return bytesToReadableString(b)
|
return bytesToDisplayValue(b, databaseTypeName)
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func bytesToReadableString(b []byte) interface{} {
|
func bytesToDisplayValue(b []byte, databaseTypeName string) interface{} {
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -23,6 +29,18 @@ func bytesToReadableString(b []byte) interface{} {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dbType := strings.ToUpper(strings.TrimSpace(databaseTypeName))
|
||||||
|
if isBitLikeDBType(dbType) {
|
||||||
|
if u, ok := bytesToUint64(b); ok {
|
||||||
|
// JS number precision is limited; keep large bitmasks as string.
|
||||||
|
const maxSafeInteger = 9007199254740991 // 2^53 - 1
|
||||||
|
if u <= maxSafeInteger {
|
||||||
|
return int64(u)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if utf8.Valid(b) {
|
if utf8.Valid(b) {
|
||||||
s := string(b)
|
s := string(b)
|
||||||
if isMostlyPrintable(s) {
|
if isMostlyPrintable(s) {
|
||||||
@@ -30,9 +48,47 @@ func bytesToReadableString(b []byte) interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: some drivers return BIT(1) as []byte{0} / []byte{1} without type info.
|
||||||
|
if dbType == "" && len(b) == 1 && (b[0] == 0 || b[0] == 1) {
|
||||||
|
return int64(b[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytesToReadableString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesToReadableString(b []byte) interface{} {
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(b) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return "0x" + hex.EncodeToString(b)
|
return "0x" + hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isBitLikeDBType(typeName string) bool {
|
||||||
|
if typeName == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch typeName {
|
||||||
|
case "BIT", "VARBIT":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(typeName, "BIT")
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesToUint64(b []byte) (uint64, bool) {
|
||||||
|
if len(b) == 0 || len(b) > 8 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var u uint64
|
||||||
|
for _, v := range b {
|
||||||
|
u = (u << 8) | uint64(v)
|
||||||
|
}
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
|
||||||
func isMostlyPrintable(s string) bool {
|
func isMostlyPrintable(s string) bool {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return true
|
return true
|
||||||
|
|||||||
44
internal/db/query_value_test.go
Normal file
44
internal/db/query_value_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeQueryValueWithDBType_BitBytes(t *testing.T) {
|
||||||
|
v := normalizeQueryValueWithDBType([]byte{0x00}, "BIT")
|
||||||
|
if v != int64(0) {
|
||||||
|
t.Fatalf("BIT 0x00 期望为 0,实际=%v(%T)", v, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
v = normalizeQueryValueWithDBType([]byte{0x01}, "bit")
|
||||||
|
if v != int64(1) {
|
||||||
|
t.Fatalf("BIT 0x01 期望为 1,实际=%v(%T)", v, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
v = normalizeQueryValueWithDBType([]byte{0x01, 0x02}, "BIT VARYING")
|
||||||
|
if v != int64(258) {
|
||||||
|
t.Fatalf("BIT 0x0102 期望为 258,实际=%v(%T)", v, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeQueryValueWithDBType_BitLargeAsString(t *testing.T) {
|
||||||
|
v := normalizeQueryValueWithDBType([]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, "BIT")
|
||||||
|
if s, ok := v.(string); !ok || s != "18446744073709551615" {
|
||||||
|
t.Fatalf("BIT 0xffffffffffffffff 期望为 string(18446744073709551615),实际=%v(%T)", v, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeQueryValueWithDBType_ByteFallbacks(t *testing.T) {
|
||||||
|
v := normalizeQueryValueWithDBType([]byte("abc"), "")
|
||||||
|
if v != "abc" {
|
||||||
|
t.Fatalf("文本 []byte 期望返回 string,实际=%v(%T)", v, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
v = normalizeQueryValueWithDBType([]byte{0x00}, "")
|
||||||
|
if v != int64(0) {
|
||||||
|
t.Fatalf("未知类型 0x00 期望返回 0,实际=%v(%T)", v, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
v = normalizeQueryValueWithDBType([]byte{0xff}, "")
|
||||||
|
if v != "0xff" {
|
||||||
|
t.Fatalf("未知类型 0xff 期望返回 0xff,实际=%v(%T)", v, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
46
internal/db/scan_rows.go
Normal file
46
internal/db/scan_rows.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func scanRows(rows *sql.Rows) ([]map[string]interface{}, []string, error) {
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
colTypes, err := rows.ColumnTypes()
|
||||||
|
if err != nil || len(colTypes) != len(columns) {
|
||||||
|
colTypes = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resultData := make([]map[string]interface{}, 0)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
values := make([]interface{}, len(columns))
|
||||||
|
valuePtrs := make([]interface{}, len(columns))
|
||||||
|
for i := range columns {
|
||||||
|
valuePtrs[i] = &values[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Scan(valuePtrs...); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := make(map[string]interface{}, len(columns))
|
||||||
|
for i, col := range columns {
|
||||||
|
dbTypeName := ""
|
||||||
|
if colTypes != nil && i < len(colTypes) && colTypes[i] != nil {
|
||||||
|
dbTypeName = colTypes[i].DatabaseTypeName()
|
||||||
|
}
|
||||||
|
entry[col] = normalizeQueryValueWithDBType(values[i], dbTypeName)
|
||||||
|
}
|
||||||
|
resultData = append(resultData, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return resultData, columns, err
|
||||||
|
}
|
||||||
|
return resultData, columns, nil
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
@@ -52,6 +54,20 @@ func (s *SQLiteDB) Ping() error {
|
|||||||
return s.conn.PingContext(ctx)
|
return s.conn.PingContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if s.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
func (s *SQLiteDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
if s.conn == nil {
|
if s.conn == nil {
|
||||||
return nil, nil, fmt.Errorf("connection not open")
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
@@ -62,33 +78,18 @@ func (s *SQLiteDB) Query(query string) ([]map[string]interface{}, []string, erro
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
columns, err := rows.Columns()
|
func (s *SQLiteDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
if s.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := s.conn.ExecContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
var resultData []map[string]interface{}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
values := make([]interface{}, len(columns))
|
|
||||||
valuePtrs := make([]interface{}, len(columns))
|
|
||||||
for i := range columns {
|
|
||||||
valuePtrs[i] = &values[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Scan(valuePtrs...); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := make(map[string]interface{})
|
|
||||||
for i, col := range columns {
|
|
||||||
entry[col] = normalizeQueryValue(values[i])
|
|
||||||
}
|
|
||||||
resultData = append(resultData, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultData, columns, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) Exec(query string) (int64, error) {
|
func (s *SQLiteDB) Exec(query string) (int64, error) {
|
||||||
@@ -137,21 +138,443 @@ func (s *SQLiteDB) GetCreateStatement(dbName, tableName string) (string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
return []connection.ColumnDefinition{}, nil
|
table := strings.TrimSpace(tableName)
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
|
||||||
|
|
||||||
|
// cid, name, type, notnull, dflt_value, pk
|
||||||
|
data, _, err := s.Query(fmt.Sprintf("PRAGMA table_info('%s')", esc(table)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parseInt := func(v interface{}) int {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case int:
|
||||||
|
return val
|
||||||
|
case int64:
|
||||||
|
return int(val)
|
||||||
|
case float64:
|
||||||
|
return int(val)
|
||||||
|
case string:
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStr := func(row map[string]interface{}, key string) string {
|
||||||
|
if v, ok := row[key]; ok && v != nil {
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
if v, ok := row[strings.ToUpper(key)]; ok && v != nil {
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var columns []connection.ColumnDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
notnull := 0
|
||||||
|
if v, ok := row["notnull"]; ok && v != nil {
|
||||||
|
notnull = parseInt(v)
|
||||||
|
} else if v, ok := row["NOTNULL"]; ok && v != nil {
|
||||||
|
notnull = parseInt(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pk := 0
|
||||||
|
if v, ok := row["pk"]; ok && v != nil {
|
||||||
|
pk = parseInt(v)
|
||||||
|
} else if v, ok := row["PK"]; ok && v != nil {
|
||||||
|
pk = parseInt(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
nullable := "YES"
|
||||||
|
if notnull == 1 {
|
||||||
|
nullable = "NO"
|
||||||
|
}
|
||||||
|
|
||||||
|
key := ""
|
||||||
|
if pk == 1 {
|
||||||
|
key = "PRI"
|
||||||
|
}
|
||||||
|
|
||||||
|
col := connection.ColumnDefinition{
|
||||||
|
Name: getStr(row, "name"),
|
||||||
|
Type: getStr(row, "type"),
|
||||||
|
Nullable: nullable,
|
||||||
|
Key: key,
|
||||||
|
Extra: "",
|
||||||
|
Comment: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := row["dflt_value"]; ok && v != nil {
|
||||||
|
def := fmt.Sprintf("%v", v)
|
||||||
|
col.Default = &def
|
||||||
|
} else if v, ok := row["DFLT_VALUE"]; ok && v != nil {
|
||||||
|
def := fmt.Sprintf("%v", v)
|
||||||
|
col.Default = &def
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = append(columns, col)
|
||||||
|
}
|
||||||
|
return columns, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
return []connection.IndexDefinition{}, nil
|
table := strings.TrimSpace(tableName)
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
|
||||||
|
parseInt := func(v interface{}) int {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case int:
|
||||||
|
return val
|
||||||
|
case int64:
|
||||||
|
return int(val)
|
||||||
|
case float64:
|
||||||
|
return int(val)
|
||||||
|
case string:
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _, err := s.Query(fmt.Sprintf("PRAGMA index_list('%s')", esc(table)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexes []connection.IndexDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
indexName := ""
|
||||||
|
if v, ok := row["name"]; ok && v != nil {
|
||||||
|
indexName = fmt.Sprintf("%v", v)
|
||||||
|
} else if v, ok := row["NAME"]; ok && v != nil {
|
||||||
|
indexName = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(indexName) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unique := 0
|
||||||
|
if v, ok := row["unique"]; ok && v != nil {
|
||||||
|
unique = parseInt(v)
|
||||||
|
} else if v, ok := row["UNIQUE"]; ok && v != nil {
|
||||||
|
unique = parseInt(v)
|
||||||
|
}
|
||||||
|
nonUnique := 1
|
||||||
|
if unique == 1 {
|
||||||
|
nonUnique = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cols, _, err := s.Query(fmt.Sprintf("PRAGMA index_info('%s')", esc(indexName)))
|
||||||
|
if err != nil {
|
||||||
|
// skip broken index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cols {
|
||||||
|
colName := ""
|
||||||
|
if v, ok := c["name"]; ok && v != nil {
|
||||||
|
colName = fmt.Sprintf("%v", v)
|
||||||
|
} else if v, ok := c["NAME"]; ok && v != nil {
|
||||||
|
colName = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(colName) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := 0
|
||||||
|
if v, ok := c["seqno"]; ok && v != nil {
|
||||||
|
seq = parseInt(v) + 1
|
||||||
|
} else if v, ok := c["SEQNO"]; ok && v != nil {
|
||||||
|
seq = parseInt(v) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
indexes = append(indexes, connection.IndexDefinition{
|
||||||
|
Name: indexName,
|
||||||
|
ColumnName: colName,
|
||||||
|
NonUnique: nonUnique,
|
||||||
|
SeqInIndex: seq,
|
||||||
|
IndexType: "BTREE",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
return []connection.ForeignKeyDefinition{}, nil
|
table := strings.TrimSpace(tableName)
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
|
||||||
|
|
||||||
|
data, _, err := s.Query(fmt.Sprintf("PRAGMA foreign_key_list('%s')", esc(table)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parseInt := func(v interface{}) int {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case int:
|
||||||
|
return val
|
||||||
|
case int64:
|
||||||
|
return int(val)
|
||||||
|
case float64:
|
||||||
|
return int(val)
|
||||||
|
case string:
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fks []connection.ForeignKeyDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
id := 0
|
||||||
|
if v, ok := row["id"]; ok && v != nil {
|
||||||
|
id = parseInt(v)
|
||||||
|
} else if v, ok := row["ID"]; ok && v != nil {
|
||||||
|
id = parseInt(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
refTable := ""
|
||||||
|
if v, ok := row["table"]; ok && v != nil {
|
||||||
|
refTable = fmt.Sprintf("%v", v)
|
||||||
|
} else if v, ok := row["TABLE"]; ok && v != nil {
|
||||||
|
refTable = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fromCol := ""
|
||||||
|
if v, ok := row["from"]; ok && v != nil {
|
||||||
|
fromCol = fmt.Sprintf("%v", v)
|
||||||
|
} else if v, ok := row["FROM"]; ok && v != nil {
|
||||||
|
fromCol = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
toCol := ""
|
||||||
|
if v, ok := row["to"]; ok && v != nil {
|
||||||
|
toCol = fmt.Sprintf("%v", v)
|
||||||
|
} else if v, ok := row["TO"]; ok && v != nil {
|
||||||
|
toCol = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := fmt.Sprintf("fk_%s_%d", table, id)
|
||||||
|
fks = append(fks, connection.ForeignKeyDefinition{
|
||||||
|
Name: name,
|
||||||
|
ColumnName: fromCol,
|
||||||
|
RefTableName: refTable,
|
||||||
|
RefColumnName: toCol,
|
||||||
|
ConstraintName: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
return []connection.TriggerDefinition{}, nil
|
table := strings.TrimSpace(tableName)
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
|
||||||
|
|
||||||
|
data, _, err := s.Query(fmt.Sprintf("SELECT name AS trigger_name, sql AS statement FROM sqlite_master WHERE type='trigger' AND tbl_name='%s' ORDER BY name", esc(table)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var triggers []connection.TriggerDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
name := fmt.Sprintf("%v", row["trigger_name"])
|
||||||
|
stmt := ""
|
||||||
|
if v, ok := row["statement"]; ok && v != nil {
|
||||||
|
stmt = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
upper := strings.ToUpper(stmt)
|
||||||
|
timing := ""
|
||||||
|
switch {
|
||||||
|
case strings.Contains(upper, " BEFORE "):
|
||||||
|
timing = "BEFORE"
|
||||||
|
case strings.Contains(upper, " AFTER "):
|
||||||
|
timing = "AFTER"
|
||||||
|
case strings.Contains(upper, " INSTEAD OF "):
|
||||||
|
timing = "INSTEAD OF"
|
||||||
|
}
|
||||||
|
|
||||||
|
event := ""
|
||||||
|
switch {
|
||||||
|
case strings.Contains(upper, " INSERT "):
|
||||||
|
event = "INSERT"
|
||||||
|
case strings.Contains(upper, " UPDATE "):
|
||||||
|
event = "UPDATE"
|
||||||
|
case strings.Contains(upper, " DELETE "):
|
||||||
|
event = "DELETE"
|
||||||
|
}
|
||||||
|
|
||||||
|
triggers = append(triggers, connection.TriggerDefinition{
|
||||||
|
Name: name,
|
||||||
|
Timing: timing,
|
||||||
|
Event: event,
|
||||||
|
Statement: stmt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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) {
|
||||||
return []connection.ColumnDefinitionWithTable{}, nil
|
tables, err := s.GetTables(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cols []connection.ColumnDefinitionWithTable
|
||||||
|
for _, table := range tables {
|
||||||
|
// Skip internal tables
|
||||||
|
if strings.HasPrefix(strings.ToLower(table), "sqlite_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
columns, err := s.GetColumns("", table)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, col := range columns {
|
||||||
|
cols = append(cols, connection.ColumnDefinitionWithTable{
|
||||||
|
TableName: table,
|
||||||
|
Name: col.Name,
|
||||||
|
Type: col.Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols, 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()
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ package ssh
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
@@ -110,3 +112,264 @@ func RegisterSSHNetwork(sshConfig connection.SSHConfig) (string, error) {
|
|||||||
|
|
||||||
return netName, nil
|
return netName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sshClientCache stores SSH clients to avoid creating multiple connections
|
||||||
|
var (
|
||||||
|
sshClientCache = make(map[string]*ssh.Client)
|
||||||
|
sshClientCacheMu sync.RWMutex
|
||||||
|
localForwarders = make(map[string]*LocalForwarder)
|
||||||
|
forwarderMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// LocalForwarder represents a local port forwarder through SSH
|
||||||
|
type LocalForwarder struct {
|
||||||
|
LocalAddr string
|
||||||
|
RemoteAddr string
|
||||||
|
SSHClient *ssh.Client
|
||||||
|
listener net.Listener
|
||||||
|
closeChan chan struct{}
|
||||||
|
closeOnce sync.Once // 防止重复关闭
|
||||||
|
closed bool // 关闭状态标记
|
||||||
|
closedMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocalForwarder creates a new local port forwarder
|
||||||
|
// It listens on a random local port and forwards all connections through SSH tunnel
|
||||||
|
func NewLocalForwarder(sshConfig connection.SSHConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
|
||||||
|
client, err := GetOrCreateSSHClient(sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("建立 SSH 连接失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen on localhost with a random port
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建本地监听器失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localAddr := listener.Addr().String()
|
||||||
|
remoteAddr := fmt.Sprintf("%s:%d", remoteHost, remotePort)
|
||||||
|
|
||||||
|
forwarder := &LocalForwarder{
|
||||||
|
LocalAddr: localAddr,
|
||||||
|
RemoteAddr: remoteAddr,
|
||||||
|
SSHClient: client,
|
||||||
|
listener: listener,
|
||||||
|
closeChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start forwarding in background
|
||||||
|
go forwarder.forward()
|
||||||
|
|
||||||
|
logger.Infof("已创建 SSH 端口转发:本地 %s -> 远程 %s", localAddr, remoteAddr)
|
||||||
|
return forwarder, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forward handles the port forwarding
|
||||||
|
func (f *LocalForwarder) forward() {
|
||||||
|
for {
|
||||||
|
localConn, err := f.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
// Check if we're shutting down
|
||||||
|
select {
|
||||||
|
case <-f.closeChan:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
logger.Warnf("接受本地连接失败:%v", err)
|
||||||
|
// listener可能已关闭,退出循环
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go f.handleConnection(localConn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConnection handles a single connection
|
||||||
|
func (f *LocalForwarder) handleConnection(localConn net.Conn) {
|
||||||
|
defer localConn.Close()
|
||||||
|
|
||||||
|
// Connect to remote through SSH with timeout
|
||||||
|
remoteConn, err := f.SSHClient.Dial("tcp", f.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("通过 SSH 连接到远程 %s 失败:%v", f.RemoteAddr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer remoteConn.Close()
|
||||||
|
|
||||||
|
// Bidirectional copy with error channel
|
||||||
|
errc := make(chan error, 2)
|
||||||
|
|
||||||
|
// Copy from local to remote
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(remoteConn, localConn)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("本地->远程数据复制错误:%v", err)
|
||||||
|
}
|
||||||
|
errc <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Copy from remote to local
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(localConn, remoteConn)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("远程->本地数据复制错误:%v", err)
|
||||||
|
}
|
||||||
|
errc <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for BOTH goroutines to complete
|
||||||
|
<-errc
|
||||||
|
<-errc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the forwarder (thread-safe, can be called multiple times)
|
||||||
|
func (f *LocalForwarder) Close() error {
|
||||||
|
var err error
|
||||||
|
f.closeOnce.Do(func() {
|
||||||
|
f.closedMu.Lock()
|
||||||
|
f.closed = true
|
||||||
|
f.closedMu.Unlock()
|
||||||
|
|
||||||
|
close(f.closeChan)
|
||||||
|
err = f.listener.Close()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("关闭端口转发监听器失败:%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsClosed returns whether the forwarder is closed
|
||||||
|
func (f *LocalForwarder) IsClosed() bool {
|
||||||
|
f.closedMu.RLock()
|
||||||
|
defer f.closedMu.RUnlock()
|
||||||
|
return f.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateLocalForwarder returns a cached forwarder or creates a new one
|
||||||
|
func GetOrCreateLocalForwarder(sshConfig connection.SSHConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
|
||||||
|
key := fmt.Sprintf("%s:%d:%s->%s:%d",
|
||||||
|
sshConfig.Host, sshConfig.Port, sshConfig.User,
|
||||||
|
remoteHost, remotePort)
|
||||||
|
|
||||||
|
forwarderMu.RLock()
|
||||||
|
forwarder, exists := localForwarders[key]
|
||||||
|
forwarderMu.RUnlock()
|
||||||
|
|
||||||
|
// Check if exists and is still valid
|
||||||
|
if exists && forwarder != nil && !forwarder.IsClosed() {
|
||||||
|
logger.Infof("复用已有端口转发:%s", key)
|
||||||
|
return forwarder, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale forwarder from cache
|
||||||
|
if exists {
|
||||||
|
forwarderMu.Lock()
|
||||||
|
delete(localForwarders, key)
|
||||||
|
forwarderMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
forwarder, err := NewLocalForwarder(sshConfig, remoteHost, remotePort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
forwarderMu.Lock()
|
||||||
|
localForwarders[key] = forwarder
|
||||||
|
forwarderMu.Unlock()
|
||||||
|
|
||||||
|
return forwarder, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseAllForwarders closes all local forwarders
|
||||||
|
func CloseAllForwarders() {
|
||||||
|
forwarderMu.Lock()
|
||||||
|
defer forwarderMu.Unlock()
|
||||||
|
|
||||||
|
for key, forwarder := range localForwarders {
|
||||||
|
if forwarder != nil {
|
||||||
|
_ = forwarder.Close()
|
||||||
|
logger.Infof("已关闭端口转发:%s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localForwarders = make(map[string]*LocalForwarder)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// getSSHClientCacheKey generates a unique cache key for SSH config
|
||||||
|
func getSSHClientCacheKey(config connection.SSHConfig) string {
|
||||||
|
return fmt.Sprintf("%s:%d:%s", config.Host, config.Port, config.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateSSHClient returns a cached SSH client or creates a new one
|
||||||
|
func GetOrCreateSSHClient(config connection.SSHConfig) (*ssh.Client, error) {
|
||||||
|
key := getSSHClientCacheKey(config)
|
||||||
|
|
||||||
|
sshClientCacheMu.RLock()
|
||||||
|
client, exists := sshClientCache[key]
|
||||||
|
sshClientCacheMu.RUnlock()
|
||||||
|
|
||||||
|
if exists && client != nil {
|
||||||
|
// Test if connection is still alive by creating a test session
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err == nil {
|
||||||
|
session.Close()
|
||||||
|
logger.Infof("复用已有 SSH 连接:%s", key)
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
// Connection is dead, remove from cache
|
||||||
|
logger.Warnf("SSH 连接已断开,重新建立:%s (错误: %v)", key, err)
|
||||||
|
sshClientCacheMu.Lock()
|
||||||
|
delete(sshClientCache, key)
|
||||||
|
sshClientCacheMu.Unlock()
|
||||||
|
// Try to close the dead client
|
||||||
|
_ = client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new SSH client
|
||||||
|
client, err := connectSSH(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the client
|
||||||
|
sshClientCacheMu.Lock()
|
||||||
|
sshClientCache[key] = client
|
||||||
|
sshClientCacheMu.Unlock()
|
||||||
|
|
||||||
|
logger.Infof("已缓存 SSH 连接:%s", key)
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialThroughSSH creates a connection through SSH tunnel
|
||||||
|
// This is a generic dialer that can be used by any database driver
|
||||||
|
func DialThroughSSH(config connection.SSHConfig, network, address string) (net.Conn, error) {
|
||||||
|
client, err := GetOrCreateSSHClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("建立 SSH 连接失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := client.Dial(network, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("通过 SSH 隧道连接到 %s 失败:%w", address, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("已通过 SSH 隧道连接到:%s", address)
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseAllSSHClients closes all cached SSH clients
|
||||||
|
func CloseAllSSHClients() {
|
||||||
|
sshClientCacheMu.Lock()
|
||||||
|
defer sshClientCacheMu.Unlock()
|
||||||
|
|
||||||
|
for key, client := range sshClientCache {
|
||||||
|
if client != nil {
|
||||||
|
_ = client.Close()
|
||||||
|
logger.Infof("已关闭 SSH 连接:%s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sshClientCache = make(map[string]*ssh.Client)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
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 |
22
main.go
22
main.go
@@ -9,6 +9,8 @@ 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/mac"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:frontend/dist
|
//go:embed all:frontend/dist
|
||||||
@@ -20,18 +22,30 @@ 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,
|
||||||
|
},
|
||||||
|
Mac: &mac.Options{
|
||||||
|
WebviewIsTransparent: true,
|
||||||
|
WindowIsTranslucent: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user