Compare commits

...

19 Commits

Author SHA1 Message Date
Syngnat
538e4a1506 Merge pull request #70 from bengbengbalabalabeng/feat-issues-55
ci: add publish-to-winget action
2026-02-05 08:41:48 +08:00
Syngnat
934581c796 chore(ci): 调整 WinGet 发布配置
## 修改内容
- 修正 WinGet workflow 中 installers-regex,使其匹配实际 Release 产物名称

## 修改原因
- 原匹配规则无法匹配 GoNavi-windows-amd64.exe / GoNavi-windows-arm64.exe
- 避免 WinGet 发布流程找不到安装包导致失败

## 影响范围
- CI / WinGet 发布流程
2026-02-05 08:41:18 +08:00
baicaixiaozhan
1486b98d27 ci: add publish-to-winget action 2026-02-04 20:02:43 +08:00
Syngnat
6cda430f03 🔧 chore(ci/build): 移除Linux ARM64构建支持以简化发布流程
- 从构建矩阵中移除linux/arm64平台
  - 移除ARM64交叉编译工具链安装逻辑
  - 简化Linux依赖安装流程,移除条件判断
  - 保留macOS和Windows的ARM64支持(原生构建)
  - 当前支持平台:macOS(AMD64/ARM64)、Windows(AMD64/ARM64)、Linux(AMD64)
  - 技术原因:Wails CGO交叉编译在x86_64 runner上存在头文件冲突问题
2026-02-04 17:50:13 +08:00
Syngnat
f56c3d5f6e 🐛 fix(workflows): 移除了 dpkg --add-architecture arm64,这会导致 apt 尝试从不存在的 ARM64 仓库获取包 2026-02-04 17:43:31 +08:00
Syngnat
74c9143c95 🐛 fix(workflows): 添加 wget 重试机制(3次重试,超时控制) 2026-02-04 17:36:59 +08:00
Syngnat
0e4a833ffa 🐛 fix(workflows): 修复artifact_name 冲突 2026-02-04 17:30:26 +08:00
Syngnat
37ad9885b7 Merge pull request #69 from Syngnat/release/0.3.0
🐛 fix(workflows): 修复actions语法错误
2026-02-04 17:19:46 +08:00
Syngnat
5cef9a4032 Merge pull request #68 from Syngnat/dev
🐛 fix(workflows): 修复actions语法错误
2026-02-04 17:18:54 +08:00
Syngnat
f49767c38b 🐛 fix(workflows): 修复actions语法错误 2026-02-04 17:17:02 +08:00
Syngnat
7e8699ba02 Merge pull request #67 from Syngnat/release/0.3.0
 feat(redis): 新增Redis数据源完整支持
2026-02-04 17:05:11 +08:00
Syngnat
5f0ce5ed7a Merge pull request #66 from Syngnat/feature/support-redis-20260204-ygf
 feat(redis): 新增Redis数据源完整支持
2026-02-04 17:03:40 +08:00
Syngnat
49c7620bdd 🐛 fix(redis/kingbase): Redis数据库选择优化与金仓标识符引号修复
- Redis配置优化:移除固定数据库输入框,改为测试连接后多选数据库
  - 数据库筛选:支持选择显示的Redis数据库(0-15),留空显示全部
  - 类型扩展:SavedConnection新增includeRedisDatabases字段存储用户选择
  - 侧边栏过滤:根据配置过滤显示的Redis数据库列表
  - 金仓修复:KingBase/PostgreSQL标识符仅在必要时加双引号
  - 保留字检测:新增needsQuote函数识别特殊字符和SQL保留字
2026-02-04 17:00:51 +08:00
Syngnat
80fa7a1acd feat(redis): 新增Redis数据源完整支持
- 后端实现:新增Redis客户端接口与go-redis实现,支持SSH隧道连接
  - API方法:新增21个Redis操作API(连接/Key/Value/命令执行等)
  - 连接配置:ConnectionModal支持Redis类型,自动识别端口与认证方式
  - 数据浏览:RedisViewer组件支持Key列表展示、类型识别与分页加载
  - 值编辑器:支持String/Hash/List/Set/ZSet五种数据类型的查看与编辑
  - 二进制处理:自动检测二进制数据并以十六进制格式展示
  - 命令终端:RedisCommandEditor支持多行命令执行与结果展示
  - 交互优化:JSON语法高亮编辑、一键复制值、面板宽度可调整
2026-02-04 16:45:51 +08:00
Syngnat
68770a42e2 Merge pull request #65 from Syngnat/feature/support-linux-windosw-arm-amd-20260204-ygf
 feat(ci/build): 新增Linux和Windows ARM64多平台构建支持
2026-02-04 15:15:18 +08:00
Syngnat
06aebf716e feat(ci/build): 新增Linux和Windows ARM64多平台构建支持
- CI矩阵扩展:新增Linux amd64/arm64和Windows arm64构建任务
  - AppImage支持:Linux平台生成通用AppImage包,兼容所有主流发行版
  - 依赖安装:自动安装GTK3/WebKit2GTK及ARM64交叉编译工具链
  - 本地构建:build-release.sh支持Linux/Windows多架构本地构建
  - 交叉编译:macOS/Linux可交叉编译其他平台,自动检测工具链
  - 打包优化:Linux输出tar.gz和AppImage两种格式
2026-02-04 15:02:42 +08:00
Syngnat
f551b19f40 Merge pull request #64 from Syngnat/release/0.2.6
♻️ refactor(database/ssh): SSH隧道架构重构与多数据源适配
2026-02-04 14:41:43 +08:00
Syngnat
6674ad69e1 Merge pull request #63 from Syngnat/dev
♻️ refactor(database/ssh): SSH隧道架构重构与多数据源适配
2026-02-04 14:40:34 +08:00
Syngnat
37d35684f1 Merge pull request #62 from Syngnat/feature/table-and-database-export-20260203-ygf
♻️ refactor(database/ssh): SSH隧道架构重构与多数据源适配
2026-02-04 14:37:11 +08:00
20 changed files with 3490 additions and 42 deletions

22
.github/workflows/release-winget.yml vendored Normal file
View 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 }}

View File

@@ -29,6 +29,13 @@ jobs:
platform: windows/amd64
artifact_name: GoNavi-windows-amd64
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:
- name: Checkout code
@@ -45,6 +52,36 @@ jobs:
with:
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
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
@@ -107,12 +144,93 @@ jobs:
echo "📦 正在移动 $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)
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
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
# Phase 2: Collect all artifacts and Publish Release (Single Job)

View File

@@ -136,14 +136,121 @@ if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
else
echo -e "${RED} ❌ Windows 构建失败。${NC}"
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
fi
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
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
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
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
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
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
# 清理中间构建目录
rm -rf "build/bin"
echo ""
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}"

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
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 { DBConnect, DBGetDatabases, TestConnection } from '../../wailsjs/go/app/App';
import { DBConnect, DBGetDatabases, TestConnection, RedisConnect } from '../../wailsjs/go/app/App';
import { SavedConnection } from '../types';
const { Meta } = Card;
@@ -16,6 +16,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null);
const [dbList, setDbList] = useState<string[]>([]);
const [redisDbList, setRedisDbList] = useState<number[]>([]); // Redis databases 0-15
const addConnection = useStore((state) => state.addConnection);
const updateConnection = useStore((state) => state.updateConnection);
@@ -23,6 +24,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
if (open) {
setTestResult(null); // Reset test result
setDbList([]);
setRedisDbList([]);
if (initialValues) {
// Edit mode: Go directly to step 2
setStep(2);
@@ -35,6 +37,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
password: initialValues.config.password,
database: initialValues.config.database,
includeDatabases: initialValues.includeDatabases,
includeRedisDatabases: initialValues.includeRedisDatabases,
useSSH: initialValues.config.useSSH,
sshHost: initialValues.config.ssh?.host,
sshPort: initialValues.config.ssh?.port,
@@ -47,6 +50,10 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
});
setUseSSH(initialValues.config.useSSH || false);
setDbType(initialValues.config.type);
// 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表
if (initialValues.config.type === 'redis') {
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
}
} else {
// Create mode: Start at step 1
setStep(1);
@@ -61,18 +68,24 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
try {
const values = await form.validateFields();
setLoading(true);
const config = await buildConfig(values);
const res = await DBConnect(config as any);
// Use different API for Redis
const isRedisType = values.type === 'redis';
const res = isRedisType
? await RedisConnect(config as any)
: 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),
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${values.host}` : values.host)),
config: config,
includeDatabases: values.includeDatabases
includeDatabases: values.includeDatabases,
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined
};
if (initialValues) {
@@ -82,7 +95,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
addConnection(newConn);
message.success('连接已保存!');
}
form.resetFields();
setUseSSH(false);
setDbType('mysql');
@@ -102,14 +115,26 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
setLoading(true);
setTestResult(null);
const config = await buildConfig(values);
const res = await TestConnection(config as any);
// Use different API for Redis
const isRedisType = values.type === 'redis';
const res = isRedisType
? await RedisConnect(config as any)
: await TestConnection(config as any);
setLoading(false);
if (res.success) {
setTestResult({ type: 'success', message: res.message });
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);
if (isRedisType) {
// Redis: generate database list 0-15
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
} 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 {
setTestResult({ type: 'error', message: "测试失败: " + res.message });
@@ -128,7 +153,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
keyPath: values.sshKeyPath || ""
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
return {
return {
type: values.type,
host: values.host || "",
port: Number(values.port || 0),
@@ -146,12 +171,13 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
const handleTypeSelect = (type: string) => {
setDbType(type);
form.setFieldsValue({ type: type });
// Auto-fill default port
let defaultPort = 3306;
switch (type) {
case 'mysql': defaultPort = 3306; break;
case 'postgres': defaultPort = 5432; break;
case 'redis': defaultPort = 6379; break;
case 'oracle': defaultPort = 1521; break;
case 'dameng': defaultPort = 5236; break;
case 'kingbase': defaultPort = 54321; break;
@@ -166,10 +192,12 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
const isSqlite = dbType === 'sqlite';
const isCustom = dbType === 'custom';
const isRedis = dbType === 'redis';
const dbTypes = [
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
{ 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: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
{ key: 'dameng', name: 'Dameng (达梦)', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#1890ff' }} /> },
@@ -235,7 +263,22 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
)}
</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 }}>
<Form.Item name="user" label="用户名" rules={[{ required: true, message: '请输入用户名' }]} style={{ flex: 1 }}>
<Input />
@@ -245,8 +288,8 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
</Form.Item>
</div>
)}
{!isSqlite && (
{!isSqlite && !isRedis && (
<Form.Item name="includeDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
<Select mode="multiple" placeholder="选择显示的数据库" allowClear>
{dbList.map(db => <Select.Option key={db} value={db}>{db}</Select.Option>)}

View 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 }}>
&gt; {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;

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
import React, { useEffect, useState, useMemo, useRef } from 'react';
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge } from 'antd';
import {
DatabaseOutlined,
TableOutlined,
ConsoleSqlOutlined,
HddOutlined,
FolderOpenOutlined,
DatabaseOutlined,
TableOutlined,
ConsoleSqlOutlined,
HddOutlined,
FolderOpenOutlined,
FileTextOutlined,
CopyOutlined,
ExportOutlined,
@@ -22,7 +22,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge }
PlusOutlined,
ReloadOutlined,
DeleteOutlined,
DisconnectOutlined
DisconnectOutlined,
CloudOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { SavedConnection } from '../types';
@@ -37,7 +38,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers';
type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db';
}
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
@@ -99,7 +100,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setTreeData(connections.map(conn => ({
title: conn.name,
key: conn.id,
icon: <HddOutlined />,
icon: conn.config.type === 'redis' ? <CloudOutlined style={{ color: '#DC382D' }} /> : <HddOutlined />,
type: 'connection',
dataRef: conn,
isLeaf: false,
@@ -120,14 +121,46 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const loadDatabases = async (node: any) => {
const conn = node.dataRef as SavedConnection;
const config = {
...conn.config,
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
// Handle Redis connections differently
if (conn.config.type === 'redis') {
try {
const res = await (window as any).go.app.App.RedisGetDatabases(config);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
let dbs = (res.data as any[]).map((db: any) => ({
title: `db${db.index}${db.keys > 0 ? ` (${db.keys})` : ''}`,
key: `${conn.id}-db${db.index}`,
icon: <DatabaseOutlined style={{ color: '#DC382D' }} />,
type: 'redis-db' as const,
dataRef: { ...conn, redisDB: db.index },
isLeaf: true,
dbIndex: db.index,
}));
// Filter Redis databases if configured
if (conn.includeRedisDatabases && conn.includeRedisDatabases.length > 0) {
dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex));
}
setTreeData(origin => updateTreeData(origin, node.key, dbs));
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error(res.message);
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error('连接失败: ' + (e?.message || String(e)));
}
return;
}
const res = await DBGetDatabases(config as any);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
@@ -293,9 +326,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return;
}
if (!info.selected) return;
const { type, dataRef, key, title } = info.node;
// Update active context
if (type === 'connection') {
setActiveContext({ connectionId: key, dbName: '' });
@@ -305,6 +338,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
} else if (type === 'redis-db') {
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
}
if (type === 'folder-columns') openDesign(info.node, 'columns', true);
@@ -341,6 +376,16 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
query: q.sql
});
return;
} else if (node.type === 'redis-db') {
const { id, redisDB } = node.dataRef;
addTab({
id: `redis-keys-${id}-db${redisDB}`,
title: `db${redisDB}`,
type: 'redis-keys',
connectionId: id,
redisDB: redisDB
});
return;
}
const key = node.key;
@@ -519,7 +564,80 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}, [searchValue, treeData]);
const getNodeMenuItems = (node: any): MenuProps['items'] => {
const conn = node.dataRef as SavedConnection;
const isRedis = conn?.config?.type === 'redis';
if (node.type === 'connection') {
// Redis connection menu
if (isRedis) {
return [
{
key: 'refresh',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => loadDatabases(node)
},
{ type: 'divider' },
{
key: 'new-command',
label: '新建命令窗口',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `redis-cmd-${node.key}-${Date.now()}`,
title: `命令 - ${node.title}`,
type: 'redis-command',
connectionId: node.key,
redisDB: 0
});
}
},
{ type: 'divider' },
{
key: 'edit',
label: '编辑连接',
icon: <EditOutlined />,
onClick: () => {
if (onEditConnection) onEditConnection(node.dataRef);
}
},
{
key: 'disconnect',
label: '断开连接',
icon: <DisconnectOutlined />,
onClick: () => {
setConnectionStates(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => {
if (k === node.key || k.startsWith(`${node.key}-`)) {
delete next[k];
}
});
return next;
});
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setTreeData(origin => updateTreeData(origin, node.key, undefined));
message.success("已断开连接");
}
},
{
key: 'delete',
label: '删除连接',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除连接 "${node.title}" 吗?`,
onOk: () => removeConnection(node.key)
});
}
}
];
}
// Regular database connection menu
return [
{
key: 'new-db',
@@ -537,9 +655,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onClick: () => loadDatabases(node)
},
{ type: 'divider' },
{
key: 'new-query',
label: '新建查询',
{
key: 'new-query',
label: '新建查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
@@ -598,6 +716,39 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
}
];
} else if (node.type === 'redis-db') {
// Redis database menu
const { id, redisDB } = node.dataRef;
return [
{
key: 'open-keys',
label: '浏览 Key',
icon: <KeyOutlined />,
onClick: () => {
addTab({
id: `redis-keys-${id}-db${redisDB}`,
title: `db${redisDB}`,
type: 'redis-keys',
connectionId: id,
redisDB: redisDB
});
}
},
{
key: 'new-command',
label: '新建命令窗口',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `redis-cmd-${id}-db${redisDB}-${Date.now()}`,
title: `命令 - db${redisDB}`,
type: 'redis-command',
connectionId: id,
redisDB: redisDB
});
}
}
];
} else if (node.type === 'database') {
return [
{

View File

@@ -5,6 +5,8 @@ import { useStore } from '../store';
import DataViewer from './DataViewer';
import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
const TabManager: React.FC = () => {
const { tabs, activeTabId, setActiveTab, closeTab, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs } = useStore();
@@ -27,6 +29,10 @@ const TabManager: React.FC = () => {
content = <DataViewer tab={tab} />;
} else if (tab.type === 'design') {
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'] = [

View File

@@ -15,6 +15,7 @@ export interface ConnectionConfig {
database?: string;
useSSH?: boolean;
ssh?: SSHConfig;
redisDB?: number; // Redis database index (0-15)
}
export interface SavedConnection {
@@ -22,6 +23,7 @@ export interface SavedConnection {
name: string;
config: ConnectionConfig;
includeDatabases?: string[];
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
}
export interface ColumnDefinition {
@@ -60,13 +62,14 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design';
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command';
connectionId: string;
dbName?: string;
tableName?: string;
query?: string;
initialTab?: string;
readOnly?: boolean;
redisDB?: number; // Redis database index for redis tabs
}
export interface DatabaseNode {
@@ -85,3 +88,32 @@ export interface SavedQuery {
dbName: string;
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;
}

View File

@@ -18,10 +18,35 @@ const normalizeIdentPart = (ident: string) => {
return raw;
};
// 检查标识符是否需要引号(包含特殊字符或是保留字)
const needsQuote = (ident: string): boolean => {
if (!ident) return false;
// 如果包含特殊字符(非字母、数字、下划线)则需要引号
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(ident)) return true;
// 常见 SQL 保留字列表(简化版)
const reserved = ['select', 'from', 'where', 'table', 'index', 'user', 'order', 'group', 'by', 'limit', 'offset', 'and', 'or', 'not', 'null', 'true', 'false', 'key', 'primary', 'foreign', 'references', 'default', 'constraint', 'create', 'drop', 'alter', 'insert', 'update', 'delete', 'set', 'values', 'into', 'join', 'left', 'right', 'inner', 'outer', 'on', 'as', 'is', 'in', 'like', 'between', 'case', 'when', 'then', 'else', 'end', 'having', 'distinct', 'all', 'any', 'exists', 'union', 'except', 'intersect'];
return reserved.includes(ident.toLowerCase());
};
export const quoteIdentPart = (dbType: string, ident: string) => {
const raw = normalizeIdentPart(ident);
if (!raw) return raw;
if ((dbType || '').toLowerCase() === 'mysql') return `\`${raw.replace(/`/g, '``')}\``;
const dbTypeLower = (dbType || '').toLowerCase();
if (dbTypeLower === 'mysql') {
return `\`${raw.replace(/`/g, '``')}\``;
}
// 对于 KingBase/PostgreSQL只在必要时加引号
if (dbTypeLower === 'kingbase' || dbTypeLower === 'postgres') {
if (needsQuote(raw)) {
return `"${raw.replace(/"/g, '""')}"`;
}
// 不加引号,保持原样(数据库会自动转小写处理)
return raw;
}
// 其他数据库默认加双引号
return `"${raw.replace(/"/g, '""')}"`;
};

View File

@@ -2,6 +2,7 @@
// This file is automatically generated. DO NOT EDIT
import {connection} 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>;
@@ -59,4 +60,46 @@ export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:strin
export function OpenSQLFile():Promise<connection.QueryResult>;
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisDeleteKeys(arg1:connection.ConnectionConfig,arg2:Array<string>):Promise<connection.QueryResult>;
export function RedisExecuteCommand(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function RedisFlushDB(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisGetServerInfo(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise<connection.QueryResult>;
export function RedisRenameKey(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:number):Promise<connection.QueryResult>;
export function RedisSelectDB(arg1:connection.ConnectionConfig,arg2:number):Promise<connection.QueryResult>;
export function RedisSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisSetHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function RedisSetRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisSetString(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:number):Promise<connection.QueryResult>;
export function RedisSetTTL(arg1:connection.ConnectionConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
export function RedisTestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisZSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<redis.ZSetMember>):Promise<connection.QueryResult>;
export function RedisZSetRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;

View File

@@ -114,6 +114,90 @@ export function OpenSQLFile() {
return window['go']['app']['App']['OpenSQLFile']();
}
export function RedisConnect(arg1) {
return window['go']['app']['App']['RedisConnect'](arg1);
}
export function RedisDeleteHashField(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisDeleteHashField'](arg1, arg2, arg3);
}
export function RedisDeleteKeys(arg1, arg2) {
return window['go']['app']['App']['RedisDeleteKeys'](arg1, arg2);
}
export function RedisExecuteCommand(arg1, arg2) {
return window['go']['app']['App']['RedisExecuteCommand'](arg1, arg2);
}
export function RedisFlushDB(arg1) {
return window['go']['app']['App']['RedisFlushDB'](arg1);
}
export function RedisGetDatabases(arg1) {
return window['go']['app']['App']['RedisGetDatabases'](arg1);
}
export function RedisGetServerInfo(arg1) {
return window['go']['app']['App']['RedisGetServerInfo'](arg1);
}
export function RedisGetValue(arg1, arg2) {
return window['go']['app']['App']['RedisGetValue'](arg1, arg2);
}
export function RedisListPush(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisListPush'](arg1, arg2, arg3);
}
export function RedisListSet(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisListSet'](arg1, arg2, arg3, arg4);
}
export function RedisRenameKey(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisRenameKey'](arg1, arg2, arg3);
}
export function RedisScanKeys(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisScanKeys'](arg1, arg2, arg3, arg4);
}
export function RedisSelectDB(arg1, arg2) {
return window['go']['app']['App']['RedisSelectDB'](arg1, arg2);
}
export function RedisSetAdd(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisSetAdd'](arg1, arg2, arg3);
}
export function RedisSetHashField(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisSetHashField'](arg1, arg2, arg3, arg4);
}
export function RedisSetRemove(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisSetRemove'](arg1, arg2, arg3);
}
export function RedisSetString(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisSetString'](arg1, arg2, arg3, arg4);
}
export function RedisSetTTL(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisSetTTL'](arg1, arg2, arg3);
}
export function RedisTestConnection(arg1) {
return window['go']['app']['App']['RedisTestConnection'](arg1);
}
export function RedisZSetAdd(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisZSetAdd'](arg1, arg2, arg3);
}
export function RedisZSetRemove(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisZSetRemove'](arg1, arg2, arg3);
}
export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}

View File

@@ -80,6 +80,7 @@ export namespace connection {
driver?: string;
dsn?: string;
timeout?: number;
redisDB?: number;
static createFrom(source: any = {}) {
return new ConnectionConfig(source);
@@ -98,6 +99,7 @@ export namespace connection {
this.driver = source["driver"];
this.dsn = source["dsn"];
this.timeout = source["timeout"];
this.redisDB = source["redisDB"];
}
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 class TableOptions {

3
go.mod
View File

@@ -7,6 +7,7 @@ require (
gitee.com/chunanyong/dm v1.8.22
github.com/go-sql-driver/mysql v1.9.3
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/wailsapp/wails/v2 v2.11.0
golang.org/x/crypto v0.47.0
@@ -16,6 +17,8 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // 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/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect

10
go.sum
View File

@@ -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=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
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/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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=

View File

@@ -48,6 +48,8 @@ func (a *App) Shutdown(ctx context.Context) {
logger.Error(err, "关闭数据库连接失败")
}
}
// Close all Redis connections
CloseAllRedisClients()
logger.Infof("资源释放完成,应用已关闭")
logger.Close()
}

View 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)
}

View File

@@ -19,9 +19,10 @@ type ConnectionConfig struct {
Database string `json:"database"`
UseSSH bool `json:"useSSH"`
SSH SSHConfig `json:"ssh"`
Driver string `json:"driver,omitempty"` // For custom connection
DSN string `json:"dsn,omitempty"` // For custom connection
Driver string `json:"driver,omitempty"` // For custom connection
DSN string `json:"dsn,omitempty"` // For custom connection
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

90
internal/redis/redis.go Normal file
View 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"`
}

View 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()
}