mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-03 04:59:46 +08:00
Compare commits
30 Commits
release/0.
...
v0.3.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
954a5d77d3 | ||
|
|
f3130ff517 | ||
|
|
012c99be9e | ||
|
|
c8575c315b | ||
|
|
601d69faeb | ||
|
|
fdb7781a9b | ||
|
|
087578693e | ||
|
|
aceabb63f5 | ||
|
|
8587f72f81 | ||
|
|
1b5a71d478 | ||
|
|
83ad3b09d9 | ||
|
|
72811092b4 | ||
|
|
b67135e2c1 | ||
|
|
f5e16b0b70 | ||
|
|
f8535dd272 | ||
|
|
5cd8681b80 | ||
|
|
4b381c82b5 | ||
|
|
820b064e7f | ||
|
|
70cb6148c6 | ||
|
|
0cb9cb8bc9 | ||
|
|
c2c88d743b | ||
|
|
e8ef6b0b38 | ||
|
|
257459f96a | ||
|
|
027115ab87 | ||
|
|
96cb8134c4 | ||
|
|
b108cd1c90 | ||
|
|
d1ce9cefb8 | ||
|
|
f75e04f091 | ||
|
|
1fc182817e | ||
|
|
3c28b0adeb |
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-*
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
- **Oracle**:基础数据访问与编辑支持。
|
- **Oracle**:基础数据访问与编辑支持。
|
||||||
- **Dameng(达梦)**:基础数据访问与编辑支持。
|
- **Dameng(达梦)**:基础数据访问与编辑支持。
|
||||||
- **Kingbase(人大金仓)**:基础数据访问与编辑支持。
|
- **Kingbase(人大金仓)**:基础数据访问与编辑支持。
|
||||||
|
- **TDengine**:时序数据库连接、库表浏览与 SQL 查询支持。
|
||||||
- **Redis**:Key/Value 浏览、命令执行、视图与编码切换。
|
- **Redis**:Key/Value 浏览、命令执行、视图与编码切换。
|
||||||
- **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。
|
- **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。
|
||||||
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
|
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
|
||||||
|
|||||||
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>
|
||||||
164
docs/HighGo_Optional_Code_Changes.md
Normal file
164
docs/HighGo_Optional_Code_Changes.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# HighGo 可选代码优化建议
|
||||||
|
|
||||||
|
## 一、sslmode 配置优化
|
||||||
|
|
||||||
|
### 当前状态
|
||||||
|
|
||||||
|
**文件**:`internal/db/highgo_impl.go:43`
|
||||||
|
|
||||||
|
**当前代码**:
|
||||||
|
```go
|
||||||
|
q.Set("sslmode", "disable")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 建议修改
|
||||||
|
|
||||||
|
根据瀚高官方文档,sslmode 的默认值应该是 `require`。建议修改为:
|
||||||
|
|
||||||
|
```go
|
||||||
|
q.Set("sslmode", "require")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改原因
|
||||||
|
|
||||||
|
1. **符合官方规范**:瀚高官方文档明确指出默认 sslmode 为 `require`
|
||||||
|
2. **安全性提升**:启用 SSL 加密可以保护数据传输安全
|
||||||
|
3. **生产环境最佳实践**:生产环境应该启用 SSL 连接
|
||||||
|
|
||||||
|
### 是否需要修改?
|
||||||
|
|
||||||
|
**不一定需要修改**,取决于您的实际环境:
|
||||||
|
|
||||||
|
#### 保持 `disable` 的场景:
|
||||||
|
- ✅ 开发/测试环境
|
||||||
|
- ✅ HighGo 服务器未配置 SSL 证书
|
||||||
|
- ✅ 内网环境,不需要加密传输
|
||||||
|
- ✅ 快速测试连接功能
|
||||||
|
|
||||||
|
#### 修改为 `require` 的场景:
|
||||||
|
- ✅ 生产环境
|
||||||
|
- ✅ HighGo 服务器已配置 SSL 证书
|
||||||
|
- ✅ 跨网络连接,需要加密保护
|
||||||
|
- ✅ 符合安全合规要求
|
||||||
|
|
||||||
|
### 如何修改
|
||||||
|
|
||||||
|
如果您决定修改,可以使用以下命令:
|
||||||
|
|
||||||
|
**方式 1:直接修改(固定为 require)**
|
||||||
|
```go
|
||||||
|
// 文件:internal/db/highgo_impl.go 第 43 行
|
||||||
|
q.Set("sslmode", "require")
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式 2:可配置(推荐)**
|
||||||
|
|
||||||
|
如果希望让用户可以选择 sslmode,可以修改为:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 在 getDSN 方法中
|
||||||
|
sslmode := "disable" // 默认值
|
||||||
|
if config.SSLMode != "" {
|
||||||
|
sslmode = config.SSLMode
|
||||||
|
}
|
||||||
|
q.Set("sslmode", sslmode)
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 `internal/connection/connection.go` 的 `ConnectionConfig` 结构体中添加字段:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ConnectionConfig struct {
|
||||||
|
// ... 现有字段
|
||||||
|
SSLMode string `json:"sslMode,omitempty"` // SSL 模式:disable, require, verify-ca, verify-full
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端 UI 也需要相应添加 sslmode 选择控件。
|
||||||
|
|
||||||
|
### 测试建议
|
||||||
|
|
||||||
|
修改后请务必测试:
|
||||||
|
|
||||||
|
1. **SSL 启用测试**:
|
||||||
|
- 连接配置了 SSL 的 HighGo 服务器
|
||||||
|
- 验证连接成功
|
||||||
|
|
||||||
|
2. **SSL 禁用测试**:
|
||||||
|
- 连接未配置 SSL 的 HighGo 服务器
|
||||||
|
- 验证是否会报错(如果设置为 `require` 会报错)
|
||||||
|
|
||||||
|
3. **兼容性测试**:
|
||||||
|
- 测试现有的 HighGo 连接配置是否仍然可用
|
||||||
|
|
||||||
|
## 二、其他可选优化
|
||||||
|
|
||||||
|
### 1. 默认端口提示优化
|
||||||
|
|
||||||
|
**文件**:`frontend/src/components/ConnectionModal.tsx`
|
||||||
|
|
||||||
|
**当前状态**:HighGo 的默认端口已正确设置为 5866
|
||||||
|
|
||||||
|
**建议**:无需修改,已符合官方规范
|
||||||
|
|
||||||
|
### 2. 默认数据库名称
|
||||||
|
|
||||||
|
**文件**:`internal/db/highgo_impl.go:33`
|
||||||
|
|
||||||
|
**当前代码**:
|
||||||
|
```go
|
||||||
|
if dbname == "" {
|
||||||
|
dbname = "highgo" // HighGo default database
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议**:无需修改,已符合官方规范(默认数据库为 `highgo`)
|
||||||
|
|
||||||
|
### 3. 默认用户名
|
||||||
|
|
||||||
|
**当前状态**:未在代码中硬编码默认用户名
|
||||||
|
|
||||||
|
**瀚高官方默认**:`sysdba`
|
||||||
|
|
||||||
|
**建议**:
|
||||||
|
- 可以在前端 UI 的 HighGo 连接表单中,将用户名输入框的 placeholder 设置为 `sysdba`
|
||||||
|
- 但不建议硬编码默认值,让用户自行输入更安全
|
||||||
|
|
||||||
|
## 三、总结
|
||||||
|
|
||||||
|
### 必须修改的项目
|
||||||
|
- ✅ **无**(当前代码已基本符合规范)
|
||||||
|
|
||||||
|
### 建议修改的项目
|
||||||
|
1. **sslmode 配置**(根据实际环境决定)
|
||||||
|
- 开发环境:保持 `disable`
|
||||||
|
- 生产环境:修改为 `require`
|
||||||
|
|
||||||
|
### 可选优化的项目
|
||||||
|
1. 将 sslmode 改为可配置(需要修改前后端)
|
||||||
|
2. 前端 UI 添加 sslmode 选择控件
|
||||||
|
3. 用户名输入框添加 `sysdba` 提示
|
||||||
|
|
||||||
|
## 四、修改优先级
|
||||||
|
|
||||||
|
**优先级 1(高)**:
|
||||||
|
- 集成瀚高 SM3 驱动(参考 `HighGo_SM3_Integration_Guide.md`)
|
||||||
|
|
||||||
|
**优先级 2(中)**:
|
||||||
|
- 根据部署环境调整 sslmode 配置
|
||||||
|
|
||||||
|
**优先级 3(低)**:
|
||||||
|
- 将 sslmode 改为可配置
|
||||||
|
- UI 优化(placeholder 提示等)
|
||||||
|
|
||||||
|
## 五、下一步行动
|
||||||
|
|
||||||
|
建议按以下顺序执行:
|
||||||
|
|
||||||
|
1. **先集成 SM3 驱动**(参考集成指南)
|
||||||
|
2. **测试基本连接功能**(使用 sslmode=disable)
|
||||||
|
3. **如果生产环境需要 SSL**,再修改 sslmode 配置
|
||||||
|
4. **验证所有功能正常**后,考虑可选优化项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**注意**:所有代码修改都应该在集成 SM3 驱动并验证基本功能正常后再进行。
|
||||||
179
docs/HighGo_SM3_Integration_Guide.md
Normal file
179
docs/HighGo_SM3_Integration_Guide.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# HighGo SM3 国密驱动集成指南
|
||||||
|
|
||||||
|
## 一、背景说明
|
||||||
|
|
||||||
|
HighGo(瀚高)数据库需要使用支持 SM3 国密认证的 PostgreSQL 驱动。瀚高官方提供了基于 `lib/pq` 的安全增强版本。
|
||||||
|
|
||||||
|
## 二、集成步骤
|
||||||
|
|
||||||
|
### 步骤 1:下载瀚高 pq 驱动
|
||||||
|
|
||||||
|
1. 访问百度网盘链接:
|
||||||
|
```
|
||||||
|
https://pan.baidu.com/s/1xuz6uJz0utRgKWecXhpOiA?pwd=o0tj
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 下载驱动源码压缩包
|
||||||
|
|
||||||
|
### 步骤 2:放置驱动源码
|
||||||
|
|
||||||
|
1. 在项目根目录创建 vendor 目录(如果不存在):
|
||||||
|
```bash
|
||||||
|
mkdir -p vendor/highgo-pq
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 解压下载的驱动源码到 `vendor/highgo-pq/` 目录
|
||||||
|
|
||||||
|
3. 确保目录结构如下:
|
||||||
|
```
|
||||||
|
GoNavi/
|
||||||
|
├── vendor/
|
||||||
|
│ └── highgo-pq/
|
||||||
|
│ ├── go.mod
|
||||||
|
│ ├── conn.go
|
||||||
|
│ ├── ... (其他 pq 驱动源文件)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:修改 go.mod
|
||||||
|
|
||||||
|
在 `go.mod` 文件末尾添加 replace 指令:
|
||||||
|
|
||||||
|
```go
|
||||||
|
replace github.com/lib/pq => ./vendor/highgo-pq
|
||||||
|
```
|
||||||
|
|
||||||
|
完整示例:
|
||||||
|
```go
|
||||||
|
module GoNavi-Wails
|
||||||
|
|
||||||
|
go 1.24.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
// ... 现有依赖
|
||||||
|
github.com/lib/pq v1.11.1
|
||||||
|
// ... 其他依赖
|
||||||
|
)
|
||||||
|
|
||||||
|
// 在文件末尾添加
|
||||||
|
replace github.com/lib/pq => ./vendor/highgo-pq
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 4:更新 HighGo 连接配置(可选)
|
||||||
|
|
||||||
|
根据瀚高官方文档,建议修改 `internal/db/highgo_impl.go:43` 的 sslmode:
|
||||||
|
|
||||||
|
**当前代码**:
|
||||||
|
```go
|
||||||
|
q.Set("sslmode", "disable")
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议修改为**(瀚高默认):
|
||||||
|
```go
|
||||||
|
q.Set("sslmode", "require")
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ 注意:如果您的 HighGo 服务器未配置 SSL,保持 `disable` 即可。
|
||||||
|
|
||||||
|
### 步骤 5:验证集成
|
||||||
|
|
||||||
|
1. 清理依赖缓存:
|
||||||
|
```bash
|
||||||
|
go clean -modcache
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 重新下载依赖:
|
||||||
|
```bash
|
||||||
|
go mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 编译项目:
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 测试 HighGo 连接:
|
||||||
|
- 启动应用
|
||||||
|
- 创建 HighGo 连接
|
||||||
|
- 测试连接是否成功
|
||||||
|
|
||||||
|
## 三、重要说明
|
||||||
|
|
||||||
|
### ⚠️ 影响范围
|
||||||
|
|
||||||
|
使用 `go.mod replace` 会**全局替换** `github.com/lib/pq` 驱动,这意味着:
|
||||||
|
|
||||||
|
1. **PostgreSQL 连接也会使用瀚高驱动**
|
||||||
|
2. **需要验证瀚高驱动对标准 PostgreSQL 的兼容性**
|
||||||
|
|
||||||
|
### 兼容性验证
|
||||||
|
|
||||||
|
集成后,请务必测试:
|
||||||
|
|
||||||
|
1. ✅ HighGo 数据库连接(SM3 认证)
|
||||||
|
2. ✅ 标准 PostgreSQL 连接(确保仍然可用)
|
||||||
|
|
||||||
|
如果标准 PostgreSQL 连接失败,说明瀚高驱动不完全兼容,需要考虑其他方案。
|
||||||
|
|
||||||
|
### 回滚方案
|
||||||
|
|
||||||
|
如果集成后出现问题,可以快速回滚:
|
||||||
|
|
||||||
|
1. 删除 `go.mod` 中的 replace 指令
|
||||||
|
2. 删除 `vendor/highgo-pq/` 目录
|
||||||
|
3. 运行 `go mod tidy`
|
||||||
|
4. 重新编译
|
||||||
|
|
||||||
|
## 四、瀚高驱动特性
|
||||||
|
|
||||||
|
根据官方文档:
|
||||||
|
|
||||||
|
- **包路径**:`github.com/lib/pq`(与标准版相同)
|
||||||
|
- **驱动名**:`postgres`(与标准版相同)
|
||||||
|
- **SM3 支持**:自动启用国密认证
|
||||||
|
- **默认端口**:5866
|
||||||
|
- **默认数据库**:`highgo`
|
||||||
|
- **默认用户**:`sysdba`
|
||||||
|
- **sslmode 默认**:`require`
|
||||||
|
|
||||||
|
## 五、故障排查
|
||||||
|
|
||||||
|
### 问题 1:编译失败
|
||||||
|
|
||||||
|
**现象**:`go build` 报错找不到 `github.com/lib/pq`
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
1. 检查 `vendor/highgo-pq/` 目录是否存在
|
||||||
|
2. 检查 `go.mod` 中 replace 路径是否正确
|
||||||
|
3. 运行 `go mod download`
|
||||||
|
|
||||||
|
### 问题 2:HighGo 连接失败
|
||||||
|
|
||||||
|
**现象**:连接 HighGo 时报认证错误
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
1. 确认瀚高驱动已正确替换(检查 `go.mod`)
|
||||||
|
2. 确认 HighGo 服务器支持 SM3 认证
|
||||||
|
3. 检查用户名、密码、端口是否正确
|
||||||
|
|
||||||
|
### 问题 3:PostgreSQL 连接失败
|
||||||
|
|
||||||
|
**现象**:集成后标准 PostgreSQL 无法连接
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
1. 这说明瀚高驱动不完全兼容标准 PostgreSQL
|
||||||
|
2. 需要考虑条件编译或其他隔离方案
|
||||||
|
3. 临时回滚:删除 replace 指令
|
||||||
|
|
||||||
|
## 六、后续优化建议
|
||||||
|
|
||||||
|
如果发现瀚高驱动与标准 PostgreSQL 不兼容,可以考虑:
|
||||||
|
|
||||||
|
1. **条件编译**:使用 Go build tags 分别编译两个版本
|
||||||
|
2. **动态驱动注册**:如果瀚高驱动支持自定义驱动名
|
||||||
|
3. **联系瀚高技术支持**:咨询官方兼容性方案
|
||||||
|
|
||||||
|
## 七、参考资料
|
||||||
|
|
||||||
|
- 瀚高官方文档:https://www.highgo.com/document/zh-cn/application/pq%E6%8E%A5%E5%8F%A3.html
|
||||||
|
- 瀚高驱动下载:https://pan.baidu.com/s/1xuz6uJz0utRgKWecXhpOiA?pwd=o0tj
|
||||||
|
- 标准 lib/pq:https://github.com/lib/pq
|
||||||
@@ -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, Modal, Spin } 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, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined } 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, isWindowsPlatform } from './utils/appearance';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const { Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
@@ -17,8 +19,31 @@ 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 = useStore(state => state.darkMode);
|
const themeMode = useStore(state => state.theme);
|
||||||
const toggleDarkMode = useStore(state => state.toggleDarkMode);
|
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 addTab = useStore(state => state.addTab);
|
||||||
const activeContext = useStore(state => state.activeContext);
|
const activeContext = useStore(state => state.activeContext);
|
||||||
const connections = useStore(state => state.connections);
|
const connections = useStore(state => state.connections);
|
||||||
@@ -28,10 +53,32 @@ function App() {
|
|||||||
const updateCheckInFlightRef = React.useRef(false);
|
const updateCheckInFlightRef = React.useRef(false);
|
||||||
const updateDownloadInFlightRef = React.useRef(false);
|
const updateDownloadInFlightRef = React.useRef(false);
|
||||||
const updateDownloadedVersionRef = React.useRef<string | null>(null);
|
const updateDownloadedVersionRef = React.useRef<string | null>(null);
|
||||||
|
const updateDownloadMetaRef = React.useRef<UpdateDownloadResultData | null>(null);
|
||||||
const updateDeferredVersionRef = React.useRef<string | null>(null);
|
const updateDeferredVersionRef = React.useRef<string | null>(null);
|
||||||
|
const updateNotifiedVersionRef = React.useRef<string | null>(null);
|
||||||
|
const updateMutedVersionRef = React.useRef<string | null>(null);
|
||||||
const [isAboutOpen, setIsAboutOpen] = useState(false);
|
const [isAboutOpen, setIsAboutOpen] = useState(false);
|
||||||
const [aboutLoading, setAboutLoading] = 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 [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 = {
|
type UpdateInfo = {
|
||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
@@ -45,10 +92,51 @@ function App() {
|
|||||||
sha256?: string;
|
sha256?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const promptRestartForUpdate = (info: UpdateInfo) => {
|
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({
|
Modal.confirm({
|
||||||
title: '更新已下载',
|
title: '更新已下载',
|
||||||
content: `版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`,
|
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: '立即重启',
|
okText: '立即重启',
|
||||||
cancelText: '稍后',
|
cancelText: '稍后',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
@@ -68,25 +156,49 @@ function App() {
|
|||||||
if (updateDownloadInFlightRef.current) return;
|
if (updateDownloadInFlightRef.current) return;
|
||||||
if (updateDownloadedVersionRef.current === info.latestVersion) {
|
if (updateDownloadedVersionRef.current === info.latestVersion) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
message.info(`更新包已就绪(${info.latestVersion})`);
|
const cachedDownloadPath = updateDownloadMetaRef.current?.downloadPath;
|
||||||
|
message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion})`);
|
||||||
}
|
}
|
||||||
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
|
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
|
||||||
promptRestartForUpdate(info);
|
promptRestartForUpdate(info, updateDownloadMetaRef.current || undefined);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateDownloadInFlightRef.current = true;
|
updateDownloadInFlightRef.current = true;
|
||||||
|
updateDownloadMetaRef.current = null;
|
||||||
const key = 'update-download';
|
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 });
|
message.loading({ content: `正在下载更新 ${info.latestVersion}...`, key, duration: 0 });
|
||||||
const res = await (window as any).go.app.App.DownloadUpdate();
|
const res = await (window as any).go.app.App.DownloadUpdate();
|
||||||
updateDownloadInFlightRef.current = false;
|
updateDownloadInFlightRef.current = false;
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
|
const resultData = (res?.data || {}) as UpdateDownloadResultData;
|
||||||
|
updateDownloadMetaRef.current = resultData;
|
||||||
updateDownloadedVersionRef.current = info.latestVersion;
|
updateDownloadedVersionRef.current = info.latestVersion;
|
||||||
message.success({ content: '更新下载完成', key, duration: 2 });
|
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) {
|
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
|
||||||
promptRestartForUpdate(info);
|
promptRestartForUpdate(info, resultData);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
setUpdateDownloadProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'error',
|
||||||
|
message: res?.message || '未知错误'
|
||||||
|
}));
|
||||||
message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), key, duration: 4 });
|
message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), key, duration: 4 });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -94,23 +206,40 @@ function App() {
|
|||||||
const checkForUpdates = React.useCallback(async (silent: boolean) => {
|
const checkForUpdates = React.useCallback(async (silent: boolean) => {
|
||||||
if (updateCheckInFlightRef.current) return;
|
if (updateCheckInFlightRef.current) return;
|
||||||
updateCheckInFlightRef.current = true;
|
updateCheckInFlightRef.current = true;
|
||||||
|
if (!silent) {
|
||||||
|
setAboutUpdateStatus('正在检查更新...');
|
||||||
|
}
|
||||||
const res = await (window as any).go.app.App.CheckForUpdates();
|
const res = await (window as any).go.app.App.CheckForUpdates();
|
||||||
updateCheckInFlightRef.current = false;
|
updateCheckInFlightRef.current = false;
|
||||||
if (!res?.success) {
|
if (!res?.success) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
message.error('检查更新失败: ' + (res?.message || '未知错误'));
|
message.error('检查更新失败: ' + (res?.message || '未知错误'));
|
||||||
|
setAboutUpdateStatus('检查更新失败: ' + (res?.message || '未知错误'));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const info: UpdateInfo = res.data;
|
const info: UpdateInfo = res.data;
|
||||||
if (!info) return;
|
if (!info) return;
|
||||||
|
setLastUpdateInfo(info);
|
||||||
if (info.hasUpdate) {
|
if (info.hasUpdate) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
message.info(`发现新版本 ${info.latestVersion},开始下载...`);
|
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);
|
||||||
}
|
}
|
||||||
await downloadUpdate(info, silent);
|
|
||||||
} else if (!silent) {
|
} else if (!silent) {
|
||||||
message.success(`当前已是最新版本(${info.currentVersion || '未知'})`);
|
const text = `当前已是最新版本(${info.currentVersion || '未知'})`;
|
||||||
|
message.success(text);
|
||||||
|
setAboutUpdateStatus(text);
|
||||||
|
} else if (silent && isAboutOpen) {
|
||||||
|
const text = `当前已是最新版本(${info.currentVersion || '未知'})`;
|
||||||
|
setAboutUpdateStatus(text);
|
||||||
}
|
}
|
||||||
}, [downloadUpdate]);
|
}, [downloadUpdate]);
|
||||||
|
|
||||||
@@ -207,6 +336,30 @@ function App() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -260,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);
|
||||||
@@ -322,20 +483,23 @@ 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(() => {
|
useEffect(() => {
|
||||||
if (isAboutOpen) {
|
if (isAboutOpen) {
|
||||||
|
if (lastUpdateInfo?.hasUpdate) {
|
||||||
|
setAboutUpdateStatus(`发现新版本 ${lastUpdateInfo.latestVersion}(未下载)`);
|
||||||
|
} else if (lastUpdateInfo) {
|
||||||
|
setAboutUpdateStatus(`当前已是最新版本(${lastUpdateInfo.currentVersion || '未知'})`);
|
||||||
|
} else {
|
||||||
|
setAboutUpdateStatus('未检查');
|
||||||
|
}
|
||||||
loadAboutInfo();
|
loadAboutInfo();
|
||||||
}
|
}
|
||||||
}, [isAboutOpen, loadAboutInfo]);
|
}, [isAboutOpen, lastUpdateInfo, loadAboutInfo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const startupTimer = window.setTimeout(() => {
|
const startupTimer = window.setTimeout(() => {
|
||||||
@@ -350,14 +514,134 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [checkForUpdates]);
|
}, [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', display: 'flex', flexDirection: 'column' }}>
|
<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
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 36,
|
height: 36,
|
||||||
@@ -367,29 +651,33 @@ function App() {
|
|||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
padding: '0 8px',
|
padding: '0 8px',
|
||||||
borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
borderBottom: 'none',
|
||||||
background: darkMode ? '#141414' : '#fff'
|
background: bgMain,
|
||||||
|
backdropFilter: blurFilter,
|
||||||
|
WebkitBackdropFilter: blurFilter,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft">
|
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft">
|
||||||
<Button type="text" icon={<ToolOutlined />} title="工具">工具</Button>
|
<Button type="text" icon={<ToolOutlined />} title="工具">工具</Button>
|
||||||
</Dropdown>
|
</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>
|
<Button type="text" icon={<InfoCircleOutlined />} title="关于" onClick={() => setIsAboutOpen(true)}>关于</Button>
|
||||||
</div>
|
</div>
|
||||||
<Layout style={{ flex: 1, minHeight: 0 }}>
|
<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="新建连接" />
|
||||||
</div>
|
</div>
|
||||||
@@ -400,9 +688,9 @@ function App() {
|
|||||||
</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
|
||||||
@@ -428,8 +716,8 @@ function App() {
|
|||||||
title="拖动调整宽度"
|
title="拖动调整宽度"
|
||||||
/>
|
/>
|
||||||
</Sider>
|
</Sider>
|
||||||
<Content style={{ background: darkMode ? '#141414' : '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, backdropFilter: blurFilter, WebkitBackdropFilter: blurFilter }}>
|
||||||
<TabManager />
|
<TabManager />
|
||||||
</div>
|
</div>
|
||||||
{isLogPanelOpen && (
|
{isLogPanelOpen && (
|
||||||
@@ -455,9 +743,15 @@ function App() {
|
|||||||
open={isAboutOpen}
|
open={isAboutOpen}
|
||||||
onCancel={() => setIsAboutOpen(false)}
|
onCancel={() => setIsAboutOpen(false)}
|
||||||
footer={[
|
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="check" icon={<CloudDownloadOutlined />} onClick={() => checkForUpdates(false)}>检查更新</Button>,
|
||||||
<Button key="close" type="primary" onClick={() => setIsAboutOpen(false)}>关闭</Button>
|
<Button key="close" type="primary" onClick={() => setIsAboutOpen(false)}>关闭</Button>
|
||||||
]}
|
].filter(Boolean)}
|
||||||
>
|
>
|
||||||
{aboutLoading ? (
|
{aboutLoading ? (
|
||||||
<div style={{ padding: '16px 0', textAlign: 'center' }}>
|
<div style={{ padding: '16px 0', textAlign: 'center' }}>
|
||||||
@@ -467,9 +761,10 @@ function App() {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<div>版本:{aboutInfo?.version || '未知'}</div>
|
<div>版本:{aboutInfo?.version || '未知'}</div>
|
||||||
<div>作者:{aboutInfo?.author || '未知'}</div>
|
<div>作者:{aboutInfo?.author || '未知'}</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div>更新状态:{aboutUpdateStatus || '未检查'}</div>
|
||||||
<GithubOutlined />
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
{aboutInfo?.repoUrl ? (
|
<GithubOutlined />
|
||||||
|
{aboutInfo?.repoUrl ? (
|
||||||
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>
|
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>
|
||||||
{aboutInfo.repoUrl}
|
{aboutInfo.repoUrl}
|
||||||
</a>
|
</a>
|
||||||
@@ -494,6 +789,105 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</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>
|
||||||
|
{isWindowsPlatform() ? (
|
||||||
|
<div style={{ fontSize: 12, color: '#888' }}>
|
||||||
|
Windows 使用系统 Acrylic 效果,模糊程度由系统控制
|
||||||
|
</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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
const [useSSH, setUseSSH] = useState(false);
|
const [useSSH, setUseSSH] = useState(false);
|
||||||
const [dbType, setDbType] = useState('mysql');
|
const [dbType, setDbType] = useState('mysql');
|
||||||
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
|
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
|
||||||
|
const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1
|
||||||
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 [redisDbList, setRedisDbList] = useState<number[]>([]); // Redis databases 0-15
|
||||||
@@ -62,6 +63,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
setUseSSH(false);
|
setUseSSH(false);
|
||||||
setDbType('mysql');
|
setDbType('mysql');
|
||||||
|
setActiveGroup(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, initialValues]);
|
}, [open, initialValues]);
|
||||||
@@ -192,9 +194,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
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 'redis': defaultPort = 6379; break;
|
||||||
|
case 'tdengine': defaultPort = 6041; 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;
|
||||||
|
case 'sqlserver': defaultPort = 1433; break;
|
||||||
|
case 'mongodb': defaultPort = 27017; break;
|
||||||
|
case 'highgo': defaultPort = 5866; break;
|
||||||
|
case 'mariadb': defaultPort = 3306; break;
|
||||||
|
case 'vastbase': defaultPort = 5432; break;
|
||||||
default: defaultPort = 3306;
|
default: defaultPort = 3306;
|
||||||
}
|
}
|
||||||
if (type !== 'sqlite' && type !== 'custom') {
|
if (type !== 'sqlite' && type !== 'custom') {
|
||||||
@@ -208,32 +216,78 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
const isCustom = dbType === 'custom';
|
const isCustom = dbType === 'custom';
|
||||||
const isRedis = dbType === 'redis';
|
const isRedis = dbType === 'redis';
|
||||||
|
|
||||||
const dbTypes = [
|
const dbTypeGroups = [
|
||||||
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
|
{ label: '关系型数据库', items: [
|
||||||
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
|
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
|
||||||
{ key: 'redis', name: 'Redis', icon: <CloudOutlined style={{ fontSize: 24, color: '#DC382D' }} /> },
|
{ key: 'mariadb', name: 'MariaDB', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#003545' }} /> },
|
||||||
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
|
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
|
||||||
{ key: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
|
{ key: 'sqlserver', name: 'SQL Server', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#CC2927' }} /> },
|
||||||
{ key: 'dameng', name: 'Dameng (达梦)', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#1890ff' }} /> },
|
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
|
||||||
{ key: 'kingbase', name: 'Kingbase (人大金仓)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#faad14' }} /> },
|
{ key: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
|
||||||
{ key: 'custom', name: 'Custom (自定义)', icon: <AppstoreAddOutlined style={{ fontSize: 24, color: '#595959' }} /> },
|
]},
|
||||||
|
{ label: '国产数据库', items: [
|
||||||
|
{ key: 'dameng', name: 'Dameng (达梦)', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#1890ff' }} /> },
|
||||||
|
{ key: 'kingbase', name: 'Kingbase (人大金仓)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#faad14' }} /> },
|
||||||
|
{ key: 'highgo', name: 'HighGo (瀚高)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#00a854' }} /> },
|
||||||
|
{ key: 'vastbase', name: 'Vastbase (海量)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#1a6dff' }} /> },
|
||||||
|
]},
|
||||||
|
{ label: 'NoSQL', items: [
|
||||||
|
{ key: 'mongodb', name: 'MongoDB', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#47A248' }} /> },
|
||||||
|
{ key: 'redis', name: 'Redis', icon: <CloudOutlined style={{ fontSize: 24, color: '#DC382D' }} /> },
|
||||||
|
]},
|
||||||
|
{ label: '时序数据库', items: [
|
||||||
|
{ key: 'tdengine', name: 'TDengine', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#2F54EB' }} /> },
|
||||||
|
]},
|
||||||
|
{ label: '其他', items: [
|
||||||
|
{ key: 'custom', name: 'Custom (自定义)', icon: <AppstoreAddOutlined style={{ fontSize: 24, color: '#595959' }} /> },
|
||||||
|
]},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const dbTypes = dbTypeGroups.flatMap(g => g.items);
|
||||||
|
|
||||||
const renderStep1 = () => (
|
const renderStep1 = () => (
|
||||||
<Row gutter={[16, 16]}>
|
<div style={{ display: 'flex', height: 360 }}>
|
||||||
{dbTypes.map(item => (
|
{/* 左侧分类导航 */}
|
||||||
<Col span={8} key={item.key}>
|
<div style={{ width: 120, borderRight: '1px solid #f0f0f0', paddingRight: 8, flexShrink: 0 }}>
|
||||||
<Card
|
{dbTypeGroups.map((group, idx) => (
|
||||||
hoverable
|
<div
|
||||||
onClick={() => handleTypeSelect(item.key)}
|
key={group.label}
|
||||||
style={{ textAlign: 'center', cursor: 'pointer' }}
|
onClick={() => setActiveGroup(idx)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 4,
|
||||||
|
background: activeGroup === idx ? '#e6f4ff' : 'transparent',
|
||||||
|
color: activeGroup === idx ? '#1677ff' : undefined,
|
||||||
|
fontWeight: activeGroup === idx ? 500 : 400,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 12 }}>{item.icon}</div>
|
{group.label}
|
||||||
<Text strong>{item.name}</Text>
|
</div>
|
||||||
</Card>
|
))}
|
||||||
</Col>
|
</div>
|
||||||
))}
|
{/* 右侧数据源卡片 */}
|
||||||
</Row>
|
<div style={{ flex: 1, paddingLeft: 16, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
{dbTypeGroups[activeGroup]?.items.map(item => (
|
||||||
|
<Col span={8} key={item.key}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={() => handleTypeSelect(item.key)}
|
||||||
|
style={{ textAlign: 'center', cursor: 'pointer', height: 100 }}
|
||||||
|
styles={{ body: { padding: '16px 8px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' } }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 8 }}>{item.icon}</div>
|
||||||
|
<Text strong style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '100%' }}>{item.name}</Text>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStep2 = () => (
|
const renderStep2 = () => (
|
||||||
@@ -401,15 +455,16 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={getTitle()}
|
title={getTitle()}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={getFooter()}
|
footer={getFooter()}
|
||||||
width={step === 1 ? 700 : 600}
|
width={step === 1 ? 650 : 600}
|
||||||
zIndex={10001}
|
zIndex={10001}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
|
styles={step === 1 ? { body: { padding: '16px 24px', overflow: 'hidden' } } : undefined}
|
||||||
>
|
>
|
||||||
{step === 1 ? renderStep1() : renderStep2()}
|
{step === 1 ? renderStep1() : renderStep2()}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,19 +1,72 @@
|
|||||||
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal } from 'antd';
|
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal } from 'antd';
|
||||||
import type { SortOrder } from 'antd/es/table/interface';
|
import type { SortOrder } from 'antd/es/table/interface';
|
||||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined } from '@ant-design/icons';
|
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App';
|
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import 'react-resizable/css/styles.css';
|
import 'react-resizable/css/styles.css';
|
||||||
import { buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
import { buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||||
|
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform } from '../utils/appearance';
|
||||||
|
|
||||||
|
// --- Error Boundary ---
|
||||||
|
interface DataGridErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DataGridErrorBoundary extends React.Component<
|
||||||
|
{ children: React.ReactNode },
|
||||||
|
DataGridErrorBoundaryState
|
||||||
|
> {
|
||||||
|
constructor(props: { children: React.ReactNode }) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): DataGridErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('DataGrid render error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16, color: '#ff4d4f' }}>
|
||||||
|
<h4>渲染错误</h4>
|
||||||
|
<p>数据表格渲染时发生错误,可能是数据格式问题。</p>
|
||||||
|
<pre style={{ fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{this.state.error?.message}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => this.setState({ hasError: false, error: null })}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 内部行标识字段:避免与真实业务字段(如 `key` 列)冲突。
|
// 内部行标识字段:避免与真实业务字段(如 `key` 列)冲突。
|
||||||
export const GONAVI_ROW_KEY = '__gonavi_row_key__';
|
export const GONAVI_ROW_KEY = '__gonavi_row_key__';
|
||||||
|
|
||||||
// Normalize RFC3339-like datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing.
|
// Normalize RFC3339-like datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing.
|
||||||
|
// Also handle invalid datetime values like '0000-00-00 00:00:00'
|
||||||
const normalizeDateTimeString = (val: string) => {
|
const normalizeDateTimeString = (val: string) => {
|
||||||
|
// 检查是否为无效日期时间(0000-00-00 或类似格式)
|
||||||
|
if (/^0{4}-0{2}-0{2}/.test(val)) {
|
||||||
|
return val; // 保持原样显示,不尝试转换
|
||||||
|
}
|
||||||
|
|
||||||
const match = val.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
|
const match = val.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
|
||||||
if (!match) return val;
|
if (!match) return val;
|
||||||
return `${match[1]} ${match[2]}`;
|
return `${match[1]} ${match[2]}`;
|
||||||
@@ -21,12 +74,23 @@ const normalizeDateTimeString = (val: string) => {
|
|||||||
|
|
||||||
// --- Helper: Format Value ---
|
// --- Helper: Format Value ---
|
||||||
const formatCellValue = (val: any) => {
|
const formatCellValue = (val: any) => {
|
||||||
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
|
try {
|
||||||
if (typeof val === 'object') return JSON.stringify(val);
|
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
|
||||||
if (typeof val === 'string') {
|
if (typeof val === 'object') {
|
||||||
return normalizeDateTimeString(val);
|
try {
|
||||||
|
return JSON.stringify(val);
|
||||||
|
} catch {
|
||||||
|
return '[Object]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
return normalizeDateTimeString(val);
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('formatCellValue error:', e);
|
||||||
|
return '[Error]';
|
||||||
}
|
}
|
||||||
return String(val);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toEditableText = (val: any): string => {
|
const toEditableText = (val: any): string => {
|
||||||
@@ -47,6 +111,46 @@ const toFormText = (val: any): string => {
|
|||||||
|
|
||||||
const INLINE_EDIT_MAX_CHARS = 2000;
|
const INLINE_EDIT_MAX_CHARS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能自增算法:
|
||||||
|
* - 纯数字:+1
|
||||||
|
* - 字符串末尾数字:末尾数字 +1(保持前导零位数)
|
||||||
|
* - 无数字:原值不变
|
||||||
|
*/
|
||||||
|
const smartIncrement = (value: any, step: number = 1): any => {
|
||||||
|
if (value === null || value === undefined) return value;
|
||||||
|
|
||||||
|
// 纯数字类型
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value + step;
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = String(value);
|
||||||
|
|
||||||
|
// 纯数字字符串
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(str)) {
|
||||||
|
const num = parseFloat(str);
|
||||||
|
if (Number.isInteger(num)) {
|
||||||
|
return String(num + step);
|
||||||
|
}
|
||||||
|
return String((num + step).toFixed((str.split('.')[1] || '').length));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字符串末尾数字模式(如 item_1, user001)
|
||||||
|
const match = str.match(/^(.*?)(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
const prefix = match[1];
|
||||||
|
const numStr = match[2];
|
||||||
|
const num = parseInt(numStr, 10) + step;
|
||||||
|
// 保持前导零位数
|
||||||
|
const newNumStr = String(num).padStart(numStr.length, '0');
|
||||||
|
return prefix + newNumStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无法自增,返回原值
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
const shouldOpenModalEditor = (val: any): boolean => {
|
const shouldOpenModalEditor = (val: any): boolean => {
|
||||||
if (val === null || val === undefined) return false;
|
if (val === null || val === undefined) return false;
|
||||||
if (typeof val === 'string') {
|
if (typeof val === 'string') {
|
||||||
@@ -127,6 +231,8 @@ const ResizableTitle = (props: any) => {
|
|||||||
const EditableContext = React.createContext<any>(null);
|
const EditableContext = React.createContext<any>(null);
|
||||||
const CellContextMenuContext = React.createContext<{
|
const CellContextMenuContext = React.createContext<{
|
||||||
showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void;
|
showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void;
|
||||||
|
handleBatchFillToSelected: (record: Item, dataIndex: string) => void;
|
||||||
|
handleDragFillStart: (record: Item, dataIndex: string, cellElement: HTMLElement) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const DataContext = React.createContext<{
|
const DataContext = React.createContext<{
|
||||||
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
||||||
@@ -165,6 +271,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
|||||||
...restProps
|
...restProps
|
||||||
}) => {
|
}) => {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const inputRef = useRef<any>(null);
|
const inputRef = useRef<any>(null);
|
||||||
const cellRef = useRef<HTMLTableCellElement>(null);
|
const cellRef = useRef<HTMLTableCellElement>(null);
|
||||||
const form = useContext(EditableContext);
|
const form = useContext(EditableContext);
|
||||||
@@ -209,6 +316,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
|||||||
const handleContextMenu = (e: React.MouseEvent) => {
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
if (!editable) return;
|
if (!editable) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // 阻止冒泡到行级菜单
|
||||||
if (cellContextMenuContext) {
|
if (cellContextMenuContext) {
|
||||||
cellContextMenuContext.showMenu(e, record, dataIndex, title);
|
cellContextMenuContext.showMenu(e, record, dataIndex, title);
|
||||||
}
|
}
|
||||||
@@ -244,10 +352,37 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="editable-cell-value-wrap"
|
className="editable-cell-value-wrap"
|
||||||
style={{ paddingRight: 24, minHeight: 20 }}
|
style={{ paddingRight: 24, minHeight: 20, position: 'relative' }}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
{/* 填充柄 - 仅在悬停时显示 */}
|
||||||
|
{isHovered && cellContextMenuContext && (
|
||||||
|
<div
|
||||||
|
className="fill-handle"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 2,
|
||||||
|
bottom: 2,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
background: '#1890ff',
|
||||||
|
cursor: 'crosshair',
|
||||||
|
borderRadius: 1,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
title="拖拽向下填充"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (cellRef.current && cellContextMenuContext) {
|
||||||
|
cellContextMenuContext.handleDragFillStart(record, dataIndex, cellRef.current);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -354,8 +489,34 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const connections = useStore(state => state.connections);
|
const connections = useStore(state => state.connections);
|
||||||
const addSqlLog = useStore(state => state.addSqlLog);
|
const addSqlLog = useStore(state => state.addSqlLog);
|
||||||
const darkMode = useStore(state => state.darkMode);
|
const theme = useStore(state => state.theme);
|
||||||
|
const appearance = useStore(state => state.appearance);
|
||||||
|
const darkMode = theme === 'dark';
|
||||||
|
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||||
|
const blur = normalizeBlurForPlatform(appearance.blur);
|
||||||
|
const blurFilter = blurToFilter(blur);
|
||||||
const selectionColumnWidth = 46;
|
const selectionColumnWidth = 46;
|
||||||
|
|
||||||
|
// Background Helper
|
||||||
|
const getBg = (darkHex: string) => {
|
||||||
|
if (!darkMode) return `rgba(255, 255, 255, ${opacity})`;
|
||||||
|
const hex = darkHex.replace('#', '');
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||||
|
};
|
||||||
|
const bgContent = getBg('#1d1d1d');
|
||||||
|
const bgFilter = getBg('#262626');
|
||||||
|
const bgContextMenu = getBg('#1f1f1f');
|
||||||
|
|
||||||
|
// Row Colors with Opacity
|
||||||
|
const getRowBg = (r: number, g: number, b: number) => `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||||
|
const rowAddedBg = darkMode ? getRowBg(22, 43, 22) : getRowBg(246, 255, 237);
|
||||||
|
const rowModBg = darkMode ? getRowBg(22, 34, 56) : getRowBg(230, 247, 255);
|
||||||
|
const rowAddedHover = darkMode ? getRowBg(31, 61, 31) : getRowBg(217, 247, 190);
|
||||||
|
const rowModHover = darkMode ? getRowBg(29, 53, 94) : getRowBg(186, 231, 255);
|
||||||
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [modal, contextHolder] = Modal.useModal();
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
const gridId = useMemo(() => `grid-${uuidv4()}`, []);
|
const gridId = useMemo(() => `grid-${uuidv4()}`, []);
|
||||||
@@ -391,6 +552,34 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const pendingScrollToBottomRef = useRef(false);
|
const pendingScrollToBottomRef = useRef(false);
|
||||||
|
|
||||||
|
// 拖拽填充状态 - 只保留必要的 React 状态
|
||||||
|
const [dragFillActive, setDragFillActive] = useState(false);
|
||||||
|
const dragFillGhostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragFillRafRef = useRef<number | null>(null);
|
||||||
|
// 使用 ref 存储拖拽数据,避免状态更新导致重渲染
|
||||||
|
const dragFillDataRef = useRef<{
|
||||||
|
startRecord: Item | null;
|
||||||
|
dataIndex: string;
|
||||||
|
startRowIndex: number;
|
||||||
|
currentRowIndex: number;
|
||||||
|
startCellRect: DOMRect | null;
|
||||||
|
colIndex: number;
|
||||||
|
// 缓存 DOM 查询结果
|
||||||
|
cachedRows: HTMLElement[];
|
||||||
|
cachedRowKeys: string[];
|
||||||
|
cachedStartEl: HTMLElement | null;
|
||||||
|
}>({
|
||||||
|
startRecord: null,
|
||||||
|
dataIndex: '',
|
||||||
|
startRowIndex: -1,
|
||||||
|
currentRowIndex: -1,
|
||||||
|
startCellRect: null,
|
||||||
|
colIndex: -1,
|
||||||
|
cachedRows: [],
|
||||||
|
cachedRowKeys: [],
|
||||||
|
cachedStartEl: null,
|
||||||
|
});
|
||||||
|
|
||||||
const scrollTableBodyToBottom = useCallback(() => {
|
const scrollTableBodyToBottom = useCallback(() => {
|
||||||
const root = containerRef.current;
|
const root = containerRef.current;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -551,6 +740,270 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
|
|
||||||
const rowKeyStr = useCallback((k: React.Key) => String(k), []);
|
const rowKeyStr = useCallback((k: React.Key) => String(k), []);
|
||||||
|
|
||||||
|
// 批量填充到选中行
|
||||||
|
const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => {
|
||||||
|
const sourceValue = sourceRecord[dataIndex];
|
||||||
|
const selKeys = selectedRowKeysRef.current;
|
||||||
|
|
||||||
|
if (selKeys.length === 0) {
|
||||||
|
message.info('请先选择要填充的行');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceKey = sourceRecord?.[GONAVI_ROW_KEY];
|
||||||
|
// 过滤掉源行本身
|
||||||
|
const targetKeys = selKeys.filter(k => k !== sourceKey);
|
||||||
|
|
||||||
|
if (targetKeys.length === 0) {
|
||||||
|
message.info('没有其他选中的行可以填充');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新
|
||||||
|
let updatedCount = 0;
|
||||||
|
targetKeys.forEach(key => {
|
||||||
|
const keyStr = rowKeyStr(key);
|
||||||
|
const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr);
|
||||||
|
|
||||||
|
if (isAdded) {
|
||||||
|
setAddedRows(prev => prev.map(r => {
|
||||||
|
if (rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) {
|
||||||
|
updatedCount++;
|
||||||
|
return { ...r, [dataIndex]: sourceValue };
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setModifiedRows(prev => {
|
||||||
|
const existing = prev[keyStr] || {};
|
||||||
|
// 获取原始行数据
|
||||||
|
const originalRow = displayDataRef.current.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr);
|
||||||
|
updatedCount++;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[keyStr]: { ...originalRow, ...existing, [dataIndex]: sourceValue }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success(`已填充 ${updatedCount} 行`);
|
||||||
|
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}, [addedRows, rowKeyStr]);
|
||||||
|
|
||||||
|
// 拖拽填充开始
|
||||||
|
const handleDragFillStart = useCallback((record: Item, dataIndex: string, cellElement: HTMLElement) => {
|
||||||
|
const currentData = displayDataRef.current;
|
||||||
|
const rowKey = record?.[GONAVI_ROW_KEY];
|
||||||
|
const rowIndex = currentData.findIndex(r => r?.[GONAVI_ROW_KEY] === rowKey);
|
||||||
|
|
||||||
|
if (rowIndex === -1) return;
|
||||||
|
|
||||||
|
const cellRect = cellElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
// 预先计算列索引
|
||||||
|
let colIndex = -1;
|
||||||
|
const headerRow = containerRef.current?.querySelector('.ant-table-thead tr');
|
||||||
|
if (headerRow) {
|
||||||
|
const headerCells = headerRow.querySelectorAll('th');
|
||||||
|
headerCells.forEach((th, idx) => {
|
||||||
|
const titleSpan = th.querySelector('.ant-table-column-title');
|
||||||
|
const titleText = titleSpan?.textContent?.trim() || th.textContent?.trim();
|
||||||
|
if (titleText === dataIndex) {
|
||||||
|
colIndex = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预先缓存所有行的 DOM 元素和 key
|
||||||
|
const tableBody = containerRef.current?.querySelector('.ant-table-body');
|
||||||
|
const rows = tableBody ? Array.from(tableBody.querySelectorAll('tr[data-row-key]')) as HTMLElement[] : [];
|
||||||
|
const rowKeys = rows.map(r => r.getAttribute('data-row-key') || '');
|
||||||
|
const startKey = String(rowKey);
|
||||||
|
const startEl = rows.find((_, i) => rowKeys[i] === startKey) || null;
|
||||||
|
|
||||||
|
// 存储到 ref
|
||||||
|
dragFillDataRef.current = {
|
||||||
|
startRecord: record,
|
||||||
|
dataIndex,
|
||||||
|
startRowIndex: rowIndex,
|
||||||
|
currentRowIndex: rowIndex,
|
||||||
|
startCellRect: cellRect,
|
||||||
|
colIndex,
|
||||||
|
cachedRows: rows,
|
||||||
|
cachedRowKeys: rowKeys,
|
||||||
|
cachedStartEl: startEl,
|
||||||
|
};
|
||||||
|
|
||||||
|
setDragFillActive(true);
|
||||||
|
document.body.style.cursor = 'crosshair';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 拖拽填充移动(极致优化:最小化 DOM 操作)
|
||||||
|
const handleDragFillMove = useCallback((e: MouseEvent) => {
|
||||||
|
const data = dragFillDataRef.current;
|
||||||
|
if (!data.startRecord) return;
|
||||||
|
|
||||||
|
const ghost = dragFillGhostRef.current;
|
||||||
|
if (!ghost) return;
|
||||||
|
|
||||||
|
const mouseY = e.clientY;
|
||||||
|
const rows = data.cachedRows;
|
||||||
|
const rowKeys = data.cachedRowKeys;
|
||||||
|
const startEl = data.cachedStartEl;
|
||||||
|
|
||||||
|
if (!startEl || rows.length === 0) {
|
||||||
|
ghost.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二分查找优化:找到鼠标所在的行
|
||||||
|
let endEl: HTMLElement = startEl;
|
||||||
|
let endIdx = data.startRowIndex;
|
||||||
|
|
||||||
|
// 使用简单遍历(行数通常不多,二分查找收益有限)
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
|
||||||
|
// 只需要检查行的底部边界
|
||||||
|
if (mouseY >= rect.top) {
|
||||||
|
const currentData = displayDataRef.current;
|
||||||
|
const rowKey = rowKeys[i];
|
||||||
|
const dataIdx = currentData.findIndex(r => String(r?.[GONAVI_ROW_KEY]) === rowKey);
|
||||||
|
|
||||||
|
if (dataIdx > data.startRowIndex) {
|
||||||
|
endEl = row;
|
||||||
|
endIdx = dataIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.currentRowIndex = endIdx;
|
||||||
|
|
||||||
|
// 直接读取位置并更新样式(单次 reflow)
|
||||||
|
const startRect = startEl.getBoundingClientRect();
|
||||||
|
const endRect = endEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
const cells = startEl.querySelectorAll('td');
|
||||||
|
const targetCell = (data.colIndex >= 0 && cells[data.colIndex]) ? cells[data.colIndex] : null;
|
||||||
|
const cellLeft = targetCell ? targetCell.getBoundingClientRect().left : data.startCellRect!.left;
|
||||||
|
const cellWidth = targetCell ? targetCell.getBoundingClientRect().width : data.startCellRect!.width;
|
||||||
|
|
||||||
|
// 批量设置样式(浏览器会合并为一次重绘)
|
||||||
|
ghost.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
display: block;
|
||||||
|
left: ${cellLeft}px;
|
||||||
|
top: ${startRect.top}px;
|
||||||
|
width: ${cellWidth}px;
|
||||||
|
height: ${endRect.bottom - startRect.top}px;
|
||||||
|
border: 2px solid #1890ff;
|
||||||
|
background: rgba(24, 144, 255, 0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9998;
|
||||||
|
`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 拖拽填充结束
|
||||||
|
const handleDragFillEnd = useCallback(() => {
|
||||||
|
// 清理 RAF
|
||||||
|
if (dragFillRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(dragFillRafRef.current);
|
||||||
|
dragFillRafRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = dragFillDataRef.current;
|
||||||
|
|
||||||
|
if (!data.startRecord) {
|
||||||
|
setDragFillActive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startRecord, dataIndex, startRowIndex, currentRowIndex } = data;
|
||||||
|
const sourceValue = startRecord[dataIndex];
|
||||||
|
const currentData = displayDataRef.current;
|
||||||
|
|
||||||
|
// 计算需要填充的行
|
||||||
|
if (currentRowIndex > startRowIndex) {
|
||||||
|
let updatedCount = 0;
|
||||||
|
for (let i = startRowIndex + 1; i <= currentRowIndex && i < currentData.length; i++) {
|
||||||
|
const targetRow = currentData[i];
|
||||||
|
const targetKey = targetRow?.[GONAVI_ROW_KEY];
|
||||||
|
if (targetKey === undefined) continue;
|
||||||
|
|
||||||
|
const keyStr = rowKeyStr(targetKey);
|
||||||
|
const step = i - startRowIndex;
|
||||||
|
const fillValue = smartIncrement(sourceValue, step);
|
||||||
|
|
||||||
|
const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr);
|
||||||
|
|
||||||
|
if (isAdded) {
|
||||||
|
setAddedRows(prev => prev.map(r => {
|
||||||
|
if (rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) {
|
||||||
|
updatedCount++;
|
||||||
|
return { ...r, [dataIndex]: fillValue };
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setModifiedRows(prev => {
|
||||||
|
const existing = prev[keyStr] || {};
|
||||||
|
updatedCount++;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[keyStr]: { ...targetRow, ...existing, [dataIndex]: fillValue }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedCount > 0) {
|
||||||
|
message.success(`已填充 ${updatedCount} 行`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
|
||||||
|
if (dragFillGhostRef.current) {
|
||||||
|
dragFillGhostRef.current.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置 ref
|
||||||
|
dragFillDataRef.current = {
|
||||||
|
startRecord: null,
|
||||||
|
dataIndex: '',
|
||||||
|
startRowIndex: -1,
|
||||||
|
currentRowIndex: -1,
|
||||||
|
startCellRect: null,
|
||||||
|
colIndex: -1,
|
||||||
|
cachedRows: [],
|
||||||
|
cachedRowKeys: [],
|
||||||
|
cachedStartEl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setDragFillActive(false);
|
||||||
|
}, [addedRows, rowKeyStr]);
|
||||||
|
|
||||||
|
// 全局鼠标事件监听(拖拽填充)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragFillActive) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => handleDragFillMove(e);
|
||||||
|
const handleMouseUp = () => handleDragFillEnd();
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [dragFillActive, handleDragFillMove, handleDragFillEnd]);
|
||||||
|
|
||||||
const displayData = useMemo(() => {
|
const displayData = useMemo(() => {
|
||||||
return [...data, ...addedRows].filter(item => {
|
return [...data, ...addedRows].filter(item => {
|
||||||
const k = item?.[GONAVI_ROW_KEY];
|
const k = item?.[GONAVI_ROW_KEY];
|
||||||
@@ -1287,7 +1740,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
const enableVirtual = mergedDisplayData.length >= 200;
|
const enableVirtual = mergedDisplayData.length >= 200;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={gridId} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div className={gridId} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, background: bgContent, backdropFilter: blurFilter, WebkitBackdropFilter: blurFilter }}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
|
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
|
||||||
@@ -1336,7 +1789,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
|
|
||||||
{/* Filter Panel */}
|
{/* Filter Panel */}
|
||||||
{showFilter && (
|
{showFilter && (
|
||||||
<div style={{ padding: '8px', background: '#f5f5f5', borderBottom: '1px solid #eee' }}>
|
<div style={{
|
||||||
|
padding: '8px',
|
||||||
|
margin: '4px 8px 0 8px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: bgFilter,
|
||||||
|
}}>
|
||||||
{filterConditions.map(cond => (
|
{filterConditions.map(cond => (
|
||||||
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start' }}>
|
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start' }}>
|
||||||
<Select
|
<Select
|
||||||
@@ -1427,7 +1885,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
<span>{rowEditorRowKey ? `rowKey: ${rowEditorRowKey}` : ''}</span>
|
<span>{rowEditorRowKey ? `rowKey: ${rowEditorRowKey}` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<Form form={rowEditorForm} layout="vertical">
|
<Form form={rowEditorForm} layout="vertical">
|
||||||
<div style={{ maxHeight: '62vh', overflow: 'auto', paddingRight: 8 }}>
|
<div className="custom-scrollbar" style={{ maxHeight: '62vh', overflow: 'auto', paddingRight: 8 }}>
|
||||||
{columnNames.map((col) => {
|
{columnNames.map((col) => {
|
||||||
const sample = rowEditorDisplayRef.current?.[col] ?? '';
|
const sample = rowEditorDisplayRef.current?.[col] ?? '';
|
||||||
const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined;
|
const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined;
|
||||||
@@ -1494,7 +1952,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
</Modal>
|
</Modal>
|
||||||
<Form component={false} form={form}>
|
<Form component={false} form={form}>
|
||||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
||||||
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu }}>
|
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected, handleDragFillStart }}>
|
||||||
<EditableContext.Provider value={form}>
|
<EditableContext.Provider value={form}>
|
||||||
<Table
|
<Table
|
||||||
components={tableComponents}
|
components={tableComponents}
|
||||||
@@ -1527,19 +1985,22 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
</DataContext.Provider>
|
</DataContext.Provider>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{/* Cell Context Menu */}
|
{/* Cell Context Menu - 使用 Portal 渲染到 body,避免 backdropFilter 影响 fixed 定位 */}
|
||||||
{cellContextMenu.visible && (
|
{cellContextMenu.visible && createPortal(
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: cellContextMenu.x,
|
left: cellContextMenu.x,
|
||||||
top: cellContextMenu.y,
|
top: cellContextMenu.y,
|
||||||
zIndex: 10000,
|
zIndex: 10000,
|
||||||
background: '#fff',
|
background: bgContextMenu,
|
||||||
border: '1px solid #d9d9d9',
|
backdropFilter: blurFilter,
|
||||||
|
WebkitBackdropFilter: blurFilter,
|
||||||
|
border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||||
minWidth: 120,
|
minWidth: 160,
|
||||||
|
color: darkMode ? '#fff' : 'rgba(0, 0, 0, 0.88)'
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -1549,18 +2010,153 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'background 0.2s',
|
transition: 'background 0.2s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.background = '#f5f5f5'}
|
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||||
onClick={handleCellSetNull}
|
onClick={handleCellSetNull}
|
||||||
>
|
>
|
||||||
设置为 NULL
|
设置为 NULL
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: selectedRowKeys.length > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
opacity: selectedRowKeys.length > 0 ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (selectedRowKeys.length > 0) e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedRowKeys.length > 0 && cellContextMenu.record) {
|
||||||
|
handleBatchFillToSelected(cellContextMenu.record, cellContextMenu.dataIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
|
||||||
|
填充到选中行 ({selectedRowKeys.length})
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 1, background: darkMode ? '#303030' : '#f0f0f0', margin: '4px 0' }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record);
|
||||||
|
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制为 INSERT
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
if (cellContextMenu.record) handleCopyJson(cellContextMenu.record);
|
||||||
|
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制为 JSON
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
if (cellContextMenu.record) handleCopyCsv(cellContextMenu.record);
|
||||||
|
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制为 CSV
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
if (cellContextMenu.record) {
|
||||||
|
const records = getTargets(cellContextMenu.record);
|
||||||
|
const lines = records.map((r: any) => {
|
||||||
|
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r;
|
||||||
|
return `| ${Object.values(vals).join(' | ')} |`;
|
||||||
|
});
|
||||||
|
copyToClipboard(lines.join('\n'));
|
||||||
|
}
|
||||||
|
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制为 Markdown
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 1, background: darkMode ? '#303030' : '#f0f0f0', margin: '4px 0' }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
if (cellContextMenu.record) handleExportSelected('csv', cellContextMenu.record);
|
||||||
|
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
导出为 CSV
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
if (cellContextMenu.record) handleExportSelected('xlsx', cellContextMenu.record);
|
||||||
|
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
导出为 Excel
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
if (cellContextMenu.record) handleExportSelected('json', cellContextMenu.record);
|
||||||
|
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
导出为 JSON
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && (
|
{pagination && (
|
||||||
<div style={{ padding: '8px', borderTop: '1px solid #eee', display: 'flex', justifyContent: 'flex-end', background: '#fff' }}>
|
<div style={{ padding: '8px', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<Pagination
|
<Pagination
|
||||||
current={pagination.current}
|
current={pagination.current}
|
||||||
pageSize={pagination.pageSize}
|
pageSize={pagination.pageSize}
|
||||||
@@ -1579,12 +2175,21 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.${gridId} .row-added td { background-color: #f6ffed !important; }
|
.${gridId} .ant-table { background: transparent !important; }
|
||||||
.${gridId} .row-modified td { background-color: #e6f7ff !important; }
|
.${gridId} .ant-table-container { background: transparent !important; border: none !important; }
|
||||||
|
.${gridId} .ant-table-tbody > tr > td { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
|
||||||
|
.${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
|
||||||
|
.${gridId} .ant-table-thead > tr > th::before { display: none !important; }
|
||||||
|
.${gridId} .ant-table-tbody > tr:hover > td { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; }
|
||||||
|
.${gridId} .row-added td { background-color: ${rowAddedBg} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; }
|
||||||
|
.${gridId} .row-modified td { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; }
|
||||||
|
.${gridId} .ant-table-tbody > tr.row-added:hover > td { background-color: ${rowAddedHover} !important; }
|
||||||
|
.${gridId} .ant-table-tbody > tr.row-modified:hover > td { background-color: ${rowModHover} !important; }
|
||||||
|
.${gridId} .fill-handle:hover { background: #0050b3 !important; transform: scale(1.2); }
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* Ghost Resize Line for Columns */}
|
{/* Ghost Resize Line for Columns */}
|
||||||
<div
|
<div
|
||||||
ref={ghostRef}
|
ref={ghostRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -1599,8 +2204,32 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
willChange: 'transform'
|
willChange: 'transform'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* 拖拽填充选区指示器 - 使用 fixed 定位基于视口 */}
|
||||||
|
{dragFillActive && createPortal(
|
||||||
|
<div
|
||||||
|
ref={dragFillGhostRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
border: '2px solid #1890ff',
|
||||||
|
background: 'rgba(24, 144, 255, 0.1)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 9998,
|
||||||
|
display: 'none',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(DataGrid);
|
// 使用 ErrorBoundary 包裹 DataGrid,防止数据渲染错误导致应用崩溃
|
||||||
|
const MemoizedDataGrid = React.memo(DataGrid);
|
||||||
|
|
||||||
|
const DataGridWithErrorBoundary: React.FC<DataGridProps> = (props) => (
|
||||||
|
<DataGridErrorBoundary>
|
||||||
|
<MemoizedDataGrid {...props} />
|
||||||
|
</DataGridErrorBoundary>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DataGridWithErrorBoundary;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ 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[]>([]);
|
||||||
|
const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase();
|
||||||
|
const forceReadOnly = currentConnType === 'tdengine';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPkColumns([]);
|
setPkColumns([]);
|
||||||
@@ -241,6 +243,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
showFilter={showFilter}
|
showFilter={showFilter}
|
||||||
onToggleFilter={handleToggleFilter}
|
onToggleFilter={handleToggleFilter}
|
||||||
onApplyFilter={handleApplyFilter}
|
onApplyFilter={handleApplyFilter}
|
||||||
|
readOnly={forceReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -12,7 +13,24 @@ interface LogPanelProps {
|
|||||||
const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) => {
|
const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) => {
|
||||||
const sqlLogs = useStore(state => state.sqlLogs);
|
const sqlLogs = useStore(state => state.sqlLogs);
|
||||||
const clearSqlLogs = useStore(state => state.clearSqlLogs);
|
const clearSqlLogs = useStore(state => state.clearSqlLogs);
|
||||||
const darkMode = useStore(state => state.darkMode);
|
const theme = useStore(state => state.theme);
|
||||||
|
const appearance = useStore(state => state.appearance);
|
||||||
|
const darkMode = theme === 'dark';
|
||||||
|
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 = [
|
||||||
{
|
{
|
||||||
@@ -53,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',
|
||||||
@@ -77,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' }}>
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
const monacoRef = useRef<any>(null);
|
const monacoRef = useRef<any>(null);
|
||||||
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
|
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
|
||||||
const tablesRef = useRef<string[]>([]); // Store tables for autocomplete
|
const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db)
|
||||||
const allColumnsRef = useRef<{tableName: string, name: string, type: string}[]>([]); // Store all columns
|
const allColumnsRef = useRef<{dbName: string, tableName: string, name: string, type: string}[]>([]); // Store all columns (cross-db)
|
||||||
|
const visibleDbsRef = useRef<string[]>([]); // Store visible databases for cross-db intellisense
|
||||||
|
|
||||||
const connections = useStore(state => state.connections);
|
const connections = useStore(state => state.connections);
|
||||||
const addSqlLog = useStore(state => state.addSqlLog);
|
const addSqlLog = useStore(state => state.addSqlLog);
|
||||||
@@ -52,7 +53,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const connectionsRef = useRef(connections);
|
const connectionsRef = useRef(connections);
|
||||||
const columnsCacheRef = useRef<Record<string, ColumnDefinition[]>>({});
|
const columnsCacheRef = useRef<Record<string, ColumnDefinition[]>>({});
|
||||||
const saveQuery = useStore(state => state.saveQuery);
|
const saveQuery = useStore(state => state.saveQuery);
|
||||||
const darkMode = useStore(state => state.darkMode);
|
const theme = useStore(state => state.theme);
|
||||||
|
const darkMode = theme === 'dark';
|
||||||
const sqlFormatOptions = useStore(state => state.sqlFormatOptions);
|
const sqlFormatOptions = useStore(state => state.sqlFormatOptions);
|
||||||
const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions);
|
const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions);
|
||||||
const queryOptions = useStore(state => state.queryOptions);
|
const queryOptions = useStore(state => state.queryOptions);
|
||||||
@@ -80,9 +82,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const fetchDbs = async () => {
|
const fetchDbs = async () => {
|
||||||
const conn = connections.find(c => c.id === currentConnectionId);
|
const conn = connections.find(c => c.id === currentConnectionId);
|
||||||
if (!conn) return;
|
if (!conn) return;
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
...conn.config,
|
...conn.config,
|
||||||
port: Number(conn.config.port),
|
port: Number(conn.config.port),
|
||||||
password: conn.config.password || "",
|
password: conn.config.password || "",
|
||||||
database: conn.config.database || "",
|
database: conn.config.database || "",
|
||||||
@@ -92,27 +94,41 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
|
|
||||||
const res = await DBGetDatabases(config as any);
|
const res = await DBGetDatabases(config as any);
|
||||||
if (res.success && Array.isArray(res.data)) {
|
if (res.success && Array.isArray(res.data)) {
|
||||||
const dbs = res.data.map((row: any) => row.Database || row.database);
|
let dbs = res.data.map((row: any) => row.Database || row.database);
|
||||||
|
|
||||||
|
// 过滤只显示 includeDatabases 中配置的数据库
|
||||||
|
const includeDbs = conn.includeDatabases;
|
||||||
|
if (includeDbs && includeDbs.length > 0) {
|
||||||
|
dbs = dbs.filter((db: string) => includeDbs.includes(db));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储可见数据库列表用于跨库智能提示
|
||||||
|
visibleDbsRef.current = dbs;
|
||||||
|
|
||||||
setDbList(dbs);
|
setDbList(dbs);
|
||||||
if (!currentDbRef.current) {
|
if (!currentDbRef.current) {
|
||||||
if (conn.config.database) setCurrentDb(conn.config.database);
|
if (conn.config.database && dbs.includes(conn.config.database)) setCurrentDb(conn.config.database);
|
||||||
else if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]);
|
else if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
visibleDbsRef.current = [];
|
||||||
setDbList([]);
|
setDbList([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchDbs();
|
fetchDbs();
|
||||||
}, [currentConnectionId, connections]);
|
}, [currentConnectionId, connections]);
|
||||||
|
|
||||||
// Fetch Metadata for Autocomplete
|
// Fetch Metadata for Autocomplete (Cross-database)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMetadata = async () => {
|
const fetchMetadata = async () => {
|
||||||
const conn = connections.find(c => c.id === currentConnectionId);
|
const conn = connections.find(c => c.id === currentConnectionId);
|
||||||
if (!conn || !currentDb) return;
|
if (!conn) return;
|
||||||
|
|
||||||
const config = {
|
const visibleDbs = visibleDbsRef.current;
|
||||||
...conn.config,
|
if (!visibleDbs || visibleDbs.length === 0) return;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...conn.config,
|
||||||
port: Number(conn.config.port),
|
port: Number(conn.config.port),
|
||||||
password: conn.config.password || "",
|
password: conn.config.password || "",
|
||||||
database: conn.config.database || "",
|
database: conn.config.database || "",
|
||||||
@@ -120,25 +136,39 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||||
};
|
};
|
||||||
|
|
||||||
const resTables = await DBGetTables(config as any, currentDb);
|
// 加载所有可见数据库的表
|
||||||
if (resTables.success && Array.isArray(resTables.data)) {
|
const allTables: {dbName: string, tableName: string}[] = [];
|
||||||
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
|
const allColumns: {dbName: string, tableName: string, name: string, type: string}[] = [];
|
||||||
tablesRef.current = tableNames;
|
|
||||||
} else {
|
|
||||||
tablesRef.current = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.type === 'mysql' || !config.type) {
|
for (const dbName of visibleDbs) {
|
||||||
const resCols = await DBGetAllColumns(config as any, currentDb);
|
// 获取表
|
||||||
|
const resTables = await DBGetTables(config as any, dbName);
|
||||||
|
if (resTables.success && Array.isArray(resTables.data)) {
|
||||||
|
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
|
||||||
|
tableNames.forEach((tableName: string) => {
|
||||||
|
allTables.push({ dbName, tableName });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取列 (所有数据库类型都支持 DBGetAllColumns)
|
||||||
|
const resCols = await DBGetAllColumns(config as any, dbName);
|
||||||
if (resCols.success && Array.isArray(resCols.data)) {
|
if (resCols.success && Array.isArray(resCols.data)) {
|
||||||
allColumnsRef.current = resCols.data;
|
resCols.data.forEach((col: any) => {
|
||||||
} else {
|
allColumns.push({
|
||||||
allColumnsRef.current = [];
|
dbName,
|
||||||
|
tableName: col.tableName,
|
||||||
|
name: col.name,
|
||||||
|
type: col.type
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tablesRef.current = allTables;
|
||||||
|
allColumnsRef.current = allColumns;
|
||||||
};
|
};
|
||||||
fetchMetadata();
|
fetchMetadata();
|
||||||
}, [currentConnectionId, currentDb, connections]);
|
}, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
|
||||||
|
|
||||||
// Handle Resizing
|
// Handle Resizing
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
@@ -241,61 +271,125 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
|
|
||||||
const fullText = model.getValue();
|
const fullText = model.getValue();
|
||||||
|
|
||||||
// 1) alias.field completion: when cursor is after "<alias>.<prefix>"
|
// 获取当前行光标前的内容
|
||||||
const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
|
const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
|
||||||
|
|
||||||
|
// 0) 三段式 db.table.column 格式:当输入 db.table. 时提示列
|
||||||
|
const threePartMatch = linePrefix.match(/([`"]?[\w]+[`"]?)\.([`"]?[\w]+[`"]?)\.(\w*)$/);
|
||||||
|
if (threePartMatch) {
|
||||||
|
const dbPart = stripQuotes(threePartMatch[1]);
|
||||||
|
const tablePart = stripQuotes(threePartMatch[2]);
|
||||||
|
const colPrefix = (threePartMatch[3] || '').toLowerCase();
|
||||||
|
|
||||||
|
// 在 allColumnsRef 中查找匹配的列
|
||||||
|
const cols = allColumnsRef.current.filter(c =>
|
||||||
|
(c.dbName || '').toLowerCase() === dbPart.toLowerCase() &&
|
||||||
|
(c.tableName || '').toLowerCase() === tablePart.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = colPrefix
|
||||||
|
? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix))
|
||||||
|
: cols;
|
||||||
|
|
||||||
|
const suggestions = filtered.map(c => ({
|
||||||
|
label: c.name,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Field,
|
||||||
|
insertText: c.name,
|
||||||
|
detail: `${c.type} (${c.dbName}.${c.tableName})`,
|
||||||
|
range,
|
||||||
|
sortText: '0' + c.name
|
||||||
|
}));
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 两段式 qualifier.xxx 格式
|
||||||
const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_][\w]*[`"]?)\.(\w*)$/);
|
const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_][\w]*[`"]?)\.(\w*)$/);
|
||||||
if (qualifierMatch) {
|
if (qualifierMatch) {
|
||||||
const alias = stripQuotes(qualifierMatch[1]);
|
const qualifier = stripQuotes(qualifierMatch[1]);
|
||||||
const colPrefix = (qualifierMatch[2] || '').toLowerCase();
|
const prefix = (qualifierMatch[2] || '').toLowerCase();
|
||||||
|
|
||||||
|
// 首先检查 qualifier 是否是数据库名(跨库表提示)
|
||||||
|
const visibleDbs = visibleDbsRef.current;
|
||||||
|
if (visibleDbs.some(db => db.toLowerCase() === qualifier.toLowerCase())) {
|
||||||
|
// qualifier 是数据库名,提示该库的表
|
||||||
|
const tables = tablesRef.current.filter(t =>
|
||||||
|
(t.dbName || '').toLowerCase() === qualifier.toLowerCase()
|
||||||
|
);
|
||||||
|
const filtered = prefix
|
||||||
|
? tables.filter(t => (t.tableName || '').toLowerCase().startsWith(prefix))
|
||||||
|
: tables;
|
||||||
|
|
||||||
|
const suggestions = filtered.map(t => ({
|
||||||
|
label: t.tableName,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Class,
|
||||||
|
insertText: t.tableName,
|
||||||
|
detail: `Table (${t.dbName})`,
|
||||||
|
range,
|
||||||
|
sortText: '0' + t.tableName
|
||||||
|
}));
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则检查是否是表别名或表名,提示列
|
||||||
const reserved = new Set([
|
const reserved = new Set([
|
||||||
'where', 'on', 'group', 'order', 'limit', 'having',
|
'where', 'on', 'group', 'order', 'limit', 'having',
|
||||||
'left', 'right', 'inner', 'outer', 'full', 'cross', 'join',
|
'left', 'right', 'inner', 'outer', 'full', 'cross', 'join',
|
||||||
'union', 'except', 'intersect', 'as', 'set', 'values', 'returning',
|
'union', 'except', 'intersect', 'as', 'set', 'values', 'returning',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const aliasMap: Record<string, string> = {};
|
const aliasMap: Record<string, {dbName: string, tableName: string}> = {};
|
||||||
// Capture table and optional alias, support schema.table
|
// Capture table and optional alias, support db.table format
|
||||||
const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?[\w]+[`"]?))?/gi;
|
const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?[\w]+[`"]?))?/gi;
|
||||||
let m;
|
let m;
|
||||||
while ((m = aliasRegex.exec(fullText)) !== null) {
|
while ((m = aliasRegex.exec(fullText)) !== null) {
|
||||||
const tableIdent = normalizeQualifiedName(m[1] || '');
|
const tableIdent = normalizeQualifiedName(m[1] || '');
|
||||||
if (!tableIdent) continue;
|
if (!tableIdent) continue;
|
||||||
|
|
||||||
|
// 解析 db.table 或 table 格式
|
||||||
|
const parts = tableIdent.split('.');
|
||||||
|
let dbName = currentDbRef.current || '';
|
||||||
|
let tableName = tableIdent;
|
||||||
|
if (parts.length === 2) {
|
||||||
|
dbName = parts[0];
|
||||||
|
tableName = parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
const shortTable = getLastPart(tableIdent);
|
const shortTable = getLastPart(tableIdent);
|
||||||
// allow "table." as qualifier too
|
// 用表名作为 qualifier
|
||||||
if (shortTable) aliasMap[shortTable.toLowerCase()] = tableIdent;
|
if (shortTable) aliasMap[shortTable.toLowerCase()] = { dbName, tableName };
|
||||||
|
|
||||||
const a = stripQuotes(m[2] || '').trim();
|
const a = stripQuotes(m[2] || '').trim();
|
||||||
if (!a) continue;
|
if (!a) continue;
|
||||||
const al = a.toLowerCase();
|
const al = a.toLowerCase();
|
||||||
if (reserved.has(al)) continue;
|
if (reserved.has(al)) continue;
|
||||||
aliasMap[al] = tableIdent;
|
aliasMap[al] = { dbName, tableName };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableIdent = aliasMap[alias.toLowerCase()];
|
const tableInfo = aliasMap[qualifier.toLowerCase()];
|
||||||
if (tableIdent) {
|
if (tableInfo) {
|
||||||
const shortTable = getLastPart(tableIdent);
|
|
||||||
|
|
||||||
// Prefer preloaded MySQL all-columns cache
|
// Prefer preloaded MySQL all-columns cache
|
||||||
let cols: { name: string, type?: string, tableName?: string }[] = [];
|
let cols: { name: string, type?: string, tableName?: string, dbName?: string }[] = [];
|
||||||
if (allColumnsRef.current.length > 0) {
|
if (allColumnsRef.current.length > 0) {
|
||||||
cols = allColumnsRef.current
|
cols = allColumnsRef.current
|
||||||
.filter(c => (c.tableName || '').toLowerCase() === (shortTable || '').toLowerCase())
|
.filter(c =>
|
||||||
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName }));
|
(c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() &&
|
||||||
|
(c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase()
|
||||||
|
)
|
||||||
|
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName }));
|
||||||
} else {
|
} else {
|
||||||
const dbCols = await getColumnsByDB(tableIdent);
|
const dbCols = await getColumnsByDB(tableInfo.tableName);
|
||||||
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: shortTable }));
|
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = colPrefix
|
const filtered = prefix
|
||||||
? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix))
|
? cols.filter(c => (c.name || '').toLowerCase().startsWith(prefix))
|
||||||
: cols;
|
: cols;
|
||||||
|
|
||||||
const suggestions = filtered.map(c => ({
|
const suggestions = filtered.map(c => ({
|
||||||
label: c.name,
|
label: c.name,
|
||||||
kind: monaco.languages.CompletionItemKind.Field,
|
kind: monaco.languages.CompletionItemKind.Field,
|
||||||
insertText: c.name,
|
insertText: c.name,
|
||||||
detail: c.type ? `${c.type}${c.tableName ? ` (${c.tableName})` : ''}` : (c.tableName ? `(${c.tableName})` : ''),
|
detail: c.type ? `${c.type} (${c.dbName ? c.dbName + '.' : ''}${c.tableName})` : (c.tableName ? `(${c.tableName})` : ''),
|
||||||
range,
|
range,
|
||||||
sortText: '0' + c.name
|
sortText: '0' + c.name
|
||||||
}));
|
}));
|
||||||
@@ -310,35 +404,72 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
while ((match = tableRegex.exec(fullText)) !== null) {
|
while ((match = tableRegex.exec(fullText)) !== null) {
|
||||||
const t = normalizeQualifiedName(match[1] || '');
|
const t = normalizeQualifiedName(match[1] || '');
|
||||||
if (!t) continue;
|
if (!t) continue;
|
||||||
foundTables.add(getLastPart(t).toLowerCase());
|
// 存储完整标识 db.table 或 table
|
||||||
|
foundTables.add(t.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentDatabase = currentDbRef.current || '';
|
||||||
|
|
||||||
|
// 相关列提示:匹配 SQL 中引用的表(FROM/JOIN 等)
|
||||||
|
// 权重最高,输入 WHERE 条件时优先显示
|
||||||
const relevantColumns = allColumnsRef.current
|
const relevantColumns = allColumnsRef.current
|
||||||
.filter(c => foundTables.has((c.tableName || '').toLowerCase()))
|
.filter(c => {
|
||||||
.map(c => ({
|
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
|
||||||
label: c.name,
|
const shortIdent = (c.tableName || '').toLowerCase();
|
||||||
kind: monaco.languages.CompletionItemKind.Field,
|
return foundTables.has(fullIdent) || foundTables.has(shortIdent);
|
||||||
insertText: c.name,
|
})
|
||||||
detail: `${c.type} (${c.tableName})`,
|
.map(c => {
|
||||||
|
// 当前库的表字段优先级更高
|
||||||
|
const isCurrentDb = (c.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
|
||||||
|
return {
|
||||||
|
label: c.name,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Field,
|
||||||
|
insertText: c.name,
|
||||||
|
detail: `${c.type} (${c.dbName}.${c.tableName})`,
|
||||||
|
range,
|
||||||
|
sortText: isCurrentDb ? '00' + c.name : '01' + c.name // FROM 表字段最优先
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表提示:当前库显示表名,其他库显示 db.table 格式
|
||||||
|
const tableSuggestions = tablesRef.current.map(t => {
|
||||||
|
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
|
||||||
|
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
|
||||||
|
const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Class,
|
||||||
|
insertText,
|
||||||
|
detail: `Table (${t.dbName})`,
|
||||||
range,
|
range,
|
||||||
sortText: '0' + c.name
|
sortText: isCurrentDb ? '10' + t.tableName : '11' + t.tableName // 表次优先
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 数据库提示
|
||||||
|
const dbSuggestions = visibleDbsRef.current.map(db => ({
|
||||||
|
label: db,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Module,
|
||||||
|
insertText: db,
|
||||||
|
detail: 'Database',
|
||||||
|
range,
|
||||||
|
sortText: '20' + db // 数据库最后
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 关键字提示
|
||||||
|
const keywordSuggestions = ['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
|
||||||
|
label: k,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||||
|
insertText: k,
|
||||||
|
range,
|
||||||
|
sortText: '30' + k // 关键字权重最低
|
||||||
|
}));
|
||||||
|
|
||||||
const suggestions = [
|
const suggestions = [
|
||||||
...['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
|
...relevantColumns, // FROM 表的列最优先
|
||||||
label: k,
|
...tableSuggestions, // 表次之
|
||||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
...dbSuggestions, // 数据库
|
||||||
insertText: k,
|
...keywordSuggestions // 关键字最后
|
||||||
range
|
|
||||||
})),
|
|
||||||
...tablesRef.current.map(t => ({
|
|
||||||
label: t,
|
|
||||||
kind: monaco.languages.CompletionItemKind.Class,
|
|
||||||
insertText: t,
|
|
||||||
detail: 'Table',
|
|
||||||
range
|
|
||||||
})),
|
|
||||||
...relevantColumns
|
|
||||||
];
|
];
|
||||||
return { suggestions };
|
return { suggestions };
|
||||||
}
|
}
|
||||||
@@ -788,7 +919,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
|
|
||||||
const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => {
|
const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => {
|
||||||
const normalizedType = (dbType || 'mysql').toLowerCase();
|
const normalizedType = (dbType || 'mysql').toLowerCase();
|
||||||
const supportsLimit = normalizedType === 'mysql' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === '';
|
const supportsLimit = normalizedType === 'mysql' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'tdengine' || normalizedType === '';
|
||||||
if (!supportsLimit) return { sql, applied: false, maxRows };
|
if (!supportsLimit) return { sql, applied: false, maxRows };
|
||||||
if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows };
|
if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows };
|
||||||
|
|
||||||
@@ -866,6 +997,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const nextResultSets: ResultSet[] = [];
|
const nextResultSets: ResultSet[] = [];
|
||||||
const maxRows = Number(queryOptions?.maxRows) || 0;
|
const maxRows = Number(queryOptions?.maxRows) || 0;
|
||||||
const dbType = String((config as any).type || 'mysql');
|
const dbType = String((config as any).type || 'mysql');
|
||||||
|
const normalizedDbType = dbType.toLowerCase();
|
||||||
|
const forceReadOnlyResult = normalizedDbType === 'tdengine';
|
||||||
const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0;
|
const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0;
|
||||||
const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0;
|
const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0;
|
||||||
let anyTruncated = false;
|
let anyTruncated = false;
|
||||||
@@ -922,7 +1055,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
|
const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
|
||||||
if (tableMatch) {
|
if (tableMatch) {
|
||||||
simpleTableName = tableMatch[1];
|
simpleTableName = tableMatch[1];
|
||||||
pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName });
|
if (!forceReadOnlyResult) {
|
||||||
|
pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nextResultSets.push({
|
nextResultSets.push({
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Table, Input, Button, Space, Tag, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
|
import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
|
||||||
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined } from '@ant-design/icons';
|
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined } from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { RedisKeyInfo, RedisValue } from '../types';
|
import { RedisKeyInfo, RedisValue } from '../types';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import type { ColumnType } from 'antd/es/table';
|
import type { DataNode } from 'antd/es/tree';
|
||||||
|
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
|
|
||||||
|
const KEY_GROUP_DELIMITER = ':';
|
||||||
|
const EMPTY_SEGMENT_LABEL = '(empty)';
|
||||||
|
const REDIS_TREE_KEY_TYPE_WIDTH = 92;
|
||||||
|
const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84;
|
||||||
|
const REDIS_TREE_KEY_TTL_WIDTH = 92;
|
||||||
|
const REDIS_TREE_HIDE_TTL_THRESHOLD = 460;
|
||||||
|
|
||||||
interface RedisViewerProps {
|
interface RedisViewerProps {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
redisDB: number;
|
redisDB: number;
|
||||||
@@ -222,86 +229,186 @@ const ResizableDivider: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 可拖拽列头组件 - 纯 DOM 操作实现
|
// 可拖拽列头组件 - 纯 DOM 操作实现
|
||||||
const ResizableTitle: React.FC<any> = (props) => {
|
type RedisKeyTreeLeaf = {
|
||||||
const { onResize, width, children, ...restProps } = props;
|
keyInfo: RedisKeyInfo;
|
||||||
const thRef = useRef<HTMLTableCellElement>(null);
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
// 如果没有 onResize 或 width,说明这列不需要拖拽(如复选框列)
|
type RedisKeyTreeGroup = {
|
||||||
if (!onResize || !width) {
|
name: string;
|
||||||
return <th {...restProps}>{children}</th>;
|
path: string;
|
||||||
}
|
children: Map<string, RedisKeyTreeGroup>;
|
||||||
|
leaves: RedisKeyTreeLeaf[];
|
||||||
|
};
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
type RedisKeyTreeResult = {
|
||||||
e.stopPropagation();
|
treeData: DataNode[];
|
||||||
e.preventDefault();
|
rawKeyByNodeKey: Map<string, string>;
|
||||||
|
leafNodeKeyByRawKey: Map<string, string>;
|
||||||
|
groupKeys: string[];
|
||||||
|
};
|
||||||
|
|
||||||
const startX = e.clientX;
|
const normalizeKeySegment = (segment: string): string => {
|
||||||
const startWidth = width;
|
return segment === '' ? EMPTY_SEGMENT_LABEL : segment;
|
||||||
const th = thRef.current;
|
};
|
||||||
if (!th) return;
|
|
||||||
|
|
||||||
// 找到对应的 colgroup col 元素来同步更新列宽
|
const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => {
|
||||||
const table = th.closest('table');
|
return { name, path, children: new Map(), leaves: [] };
|
||||||
const thIndex = Array.from(th.parentElement?.children || []).indexOf(th);
|
};
|
||||||
const col = table?.querySelector(`colgroup col:nth-child(${thIndex + 1})`) as HTMLElement | null;
|
|
||||||
|
|
||||||
// 创建遮罩层防止文本选择
|
const countGroupLeafNodes = (group: RedisKeyTreeGroup): number => {
|
||||||
const overlay = document.createElement('div');
|
let count = group.leaves.length;
|
||||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;cursor:col-resize;z-index:9999;';
|
group.children.forEach((child) => {
|
||||||
document.body.appendChild(overlay);
|
count += countGroupLeafNodes(child);
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
let currentWidth = startWidth;
|
const buildRedisKeyTree = (
|
||||||
|
keys: RedisKeyInfo[],
|
||||||
|
formatTTL: (ttl: number) => string,
|
||||||
|
getTypeColor: (type: string) => string,
|
||||||
|
showTTL: boolean
|
||||||
|
): RedisKeyTreeResult => {
|
||||||
|
const root = createTreeGroup('__root__', '__root__');
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
keys.forEach((keyInfo) => {
|
||||||
moveEvent.preventDefault();
|
const segments = keyInfo.key.split(KEY_GROUP_DELIMITER);
|
||||||
const delta = moveEvent.clientX - startX;
|
if (segments.length <= 1) {
|
||||||
currentWidth = Math.max(50, startWidth + delta);
|
root.leaves.push({ keyInfo, label: keyInfo.key });
|
||||||
// 直接操作 DOM
|
return;
|
||||||
th.style.width = `${currentWidth}px`;
|
}
|
||||||
if (col) {
|
|
||||||
col.style.width = `${currentWidth}px`;
|
const groupSegments = segments.slice(0, -1);
|
||||||
|
const leafLabel = normalizeKeySegment(segments[segments.length - 1]);
|
||||||
|
let current = root;
|
||||||
|
const pathParts: string[] = [];
|
||||||
|
|
||||||
|
groupSegments.forEach((segment) => {
|
||||||
|
const normalized = normalizeKeySegment(segment);
|
||||||
|
pathParts.push(normalized);
|
||||||
|
const groupPath = pathParts.join(KEY_GROUP_DELIMITER);
|
||||||
|
let child = current.children.get(normalized);
|
||||||
|
if (!child) {
|
||||||
|
child = createTreeGroup(normalized, groupPath);
|
||||||
|
current.children.set(normalized, child);
|
||||||
}
|
}
|
||||||
};
|
current = child;
|
||||||
|
});
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
current.leaves.push({ keyInfo, label: leafLabel });
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
});
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
document.body.removeChild(overlay);
|
|
||||||
// 拖拽结束时更新 React state
|
|
||||||
onResize(null, { size: { width: currentWidth } });
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
const rawKeyByNodeKey = new Map<string, string>();
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
const leafNodeKeyByRawKey = new Map<string, string>();
|
||||||
|
const groupKeys: string[] = [];
|
||||||
|
|
||||||
|
const toTreeNodes = (group: RedisKeyTreeGroup): DataNode[] => {
|
||||||
|
const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const childLeaves = [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key));
|
||||||
|
|
||||||
|
const groupNodes: DataNode[] = childGroups.map((child) => {
|
||||||
|
const groupNodeKey = `group:${child.path}`;
|
||||||
|
groupKeys.push(groupNodeKey);
|
||||||
|
return {
|
||||||
|
key: groupNodeKey,
|
||||||
|
title: (
|
||||||
|
<Space size={6}>
|
||||||
|
<FolderOpenOutlined style={{ color: '#8c8c8c' }} />
|
||||||
|
<span>{child.name}</span>
|
||||||
|
<span style={{ fontSize: 12, color: '#999' }}>({countGroupLeafNodes(child)})</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
selectable: false,
|
||||||
|
disableCheckbox: true,
|
||||||
|
children: toTreeNodes(child),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const leafNodes: DataNode[] = childLeaves.map((leaf) => {
|
||||||
|
const nodeKey = `key:${leaf.keyInfo.key}`;
|
||||||
|
rawKeyByNodeKey.set(nodeKey, leaf.keyInfo.key);
|
||||||
|
leafNodeKeyByRawKey.set(leaf.keyInfo.key, nodeKey);
|
||||||
|
return {
|
||||||
|
key: nodeKey,
|
||||||
|
isLeaf: true,
|
||||||
|
title: (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
minWidth: 0,
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
minWidth: 0,
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyOutlined style={{ color: '#1677ff', flexShrink: 0 }} />
|
||||||
|
<Tooltip title={leaf.keyInfo.key}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{leaf.label}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Tag
|
||||||
|
color={getTypeColor(leaf.keyInfo.type)}
|
||||||
|
style={{
|
||||||
|
marginInlineEnd: 0,
|
||||||
|
width: showTTL ? REDIS_TREE_KEY_TYPE_WIDTH : REDIS_TREE_KEY_TYPE_WIDTH_NARROW,
|
||||||
|
textAlign: 'center',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{leaf.keyInfo.type}
|
||||||
|
</Tag>
|
||||||
|
{showTTL && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: REDIS_TREE_KEY_TTL_WIDTH,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#999',
|
||||||
|
textAlign: 'left',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTTL(leaf.keyInfo.ttl)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...groupNodes, ...leafNodes];
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<th
|
treeData: toTreeNodes(root),
|
||||||
ref={thRef}
|
rawKeyByNodeKey,
|
||||||
{...restProps}
|
leafNodeKeyByRawKey,
|
||||||
style={{
|
groupKeys,
|
||||||
...restProps.style,
|
};
|
||||||
position: 'relative'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: 10,
|
|
||||||
cursor: 'col-resize',
|
|
||||||
zIndex: 1,
|
|
||||||
background: 'transparent'
|
|
||||||
}}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseOver={(e) => { e.currentTarget.style.background = 'rgba(0,0,0,0.06)'; }}
|
|
||||||
onMouseOut={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||||
@@ -317,7 +424,6 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
|||||||
const [keyValue, setKeyValue] = useState<RedisValue | null>(null);
|
const [keyValue, setKeyValue] = useState<RedisValue | null>(null);
|
||||||
const [valueLoading, setValueLoading] = useState(false);
|
const [valueLoading, setValueLoading] = useState(false);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
const [editForm] = Form.useForm();
|
|
||||||
const [newKeyModalOpen, setNewKeyModalOpen] = useState(false);
|
const [newKeyModalOpen, setNewKeyModalOpen] = useState(false);
|
||||||
const [newKeyForm] = Form.useForm();
|
const [newKeyForm] = Form.useForm();
|
||||||
const [ttlModalOpen, setTtlModalOpen] = useState(false);
|
const [ttlModalOpen, setTtlModalOpen] = useState(false);
|
||||||
@@ -341,15 +447,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
|||||||
// 面板宽度状态和 ref - 默认占据 50% 宽度
|
// 面板宽度状态和 ref - 默认占据 50% 宽度
|
||||||
const [leftPanelWidth, setLeftPanelWidth] = useState<number | string>('50%');
|
const [leftPanelWidth, setLeftPanelWidth] = useState<number | string>('50%');
|
||||||
const leftPanelRef = useRef<HTMLDivElement>(null);
|
const leftPanelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showTreeKeyTTL, setShowTreeKeyTTL] = useState(true);
|
||||||
// 列宽状态 - 复选框列约 32px,总宽度需要接近面板宽度
|
const [expandedGroupKeys, setExpandedGroupKeys] = useState<string[]>([]);
|
||||||
// Key 列自适应剩余空间,其他列固定宽度
|
|
||||||
const [columnWidths, setColumnWidths] = useState({
|
|
||||||
key: 220, // Key 名称,需要较宽
|
|
||||||
type: 65, // 类型标签
|
|
||||||
ttl: 80, // TTL 显示
|
|
||||||
action: 50 // 操作按钮
|
|
||||||
});
|
|
||||||
|
|
||||||
const getConfig = useCallback(() => {
|
const getConfig = useCallback(() => {
|
||||||
if (!connection) return null;
|
if (!connection) return null;
|
||||||
@@ -373,7 +472,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
|||||||
if (res.success) {
|
if (res.success) {
|
||||||
const result = res.data;
|
const result = res.data;
|
||||||
if (append) {
|
if (append) {
|
||||||
setKeys(prev => [...prev, ...result.keys]);
|
setKeys(prev => {
|
||||||
|
const keyMap = new Map<string, RedisKeyInfo>();
|
||||||
|
prev.forEach(item => keyMap.set(item.key, item));
|
||||||
|
result.keys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
|
||||||
|
return Array.from(keyMap.values());
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setKeys(result.keys);
|
setKeys(result.keys);
|
||||||
}
|
}
|
||||||
@@ -451,6 +555,11 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteCurrentKey = async () => {
|
||||||
|
if (!selectedKey) return;
|
||||||
|
await handleDeleteKeys([selectedKey]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSetTTL = async () => {
|
const handleSetTTL = async () => {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
if (!config || !selectedKey) return;
|
if (!config || !selectedKey) return;
|
||||||
@@ -529,65 +638,81 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
|||||||
return `${Math.floor(ttl / 86400)}天${Math.floor((ttl % 86400) / 3600)}时`;
|
return `${Math.floor(ttl / 86400)}天${Math.floor((ttl % 86400) / 3600)}时`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理列宽调整 - react-resizable 的 onResize 回调格式
|
useEffect(() => {
|
||||||
const handleColumnResize = (key: string) => (_e: any, { size }: { size: { width: number } }) => {
|
const target = leftPanelRef.current;
|
||||||
setColumnWidths(prev => ({ ...prev, [key]: size.width }));
|
if (!target) return;
|
||||||
|
|
||||||
|
const updateTTLVisibility = (width: number) => {
|
||||||
|
const nextShowTTL = width > REDIS_TREE_HIDE_TTL_THRESHOLD;
|
||||||
|
setShowTreeKeyTTL((prev) => (prev === nextShowTTL ? prev : nextShowTTL));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTTLVisibility(Math.round(target.getBoundingClientRect().width));
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const width = Math.round(entries[0]?.contentRect.width || target.getBoundingClientRect().width);
|
||||||
|
updateTTLVisibility(width);
|
||||||
|
});
|
||||||
|
observer.observe(target);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWindowResize = () => {
|
||||||
|
updateTTLVisibility(Math.round(target.getBoundingClientRect().width));
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleWindowResize);
|
||||||
|
return () => window.removeEventListener('resize', handleWindowResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const keyTree = useMemo(() => {
|
||||||
|
return buildRedisKeyTree(keys, formatTTL, getTypeColor, showTreeKeyTTL);
|
||||||
|
}, [keys, showTreeKeyTTL]);
|
||||||
|
|
||||||
|
const selectedTreeNodeKeys = useMemo(() => {
|
||||||
|
if (!selectedKey) {
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
const nodeKey = keyTree.leafNodeKeyByRawKey.get(selectedKey);
|
||||||
|
return nodeKey ? [nodeKey] : [];
|
||||||
|
}, [selectedKey, keyTree]);
|
||||||
|
|
||||||
|
const checkedTreeNodeKeys = useMemo(() => {
|
||||||
|
return selectedKeys
|
||||||
|
.map(rawKey => keyTree.leafNodeKeyByRawKey.get(rawKey))
|
||||||
|
.filter((nodeKey): nodeKey is string => Boolean(nodeKey));
|
||||||
|
}, [selectedKeys, keyTree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const existingKeySet = new Set(keys.map(item => item.key));
|
||||||
|
setSelectedKeys(prev => prev.filter(rawKey => existingKeySet.has(rawKey)));
|
||||||
|
}, [keys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExpandedGroupKeys((prev) => {
|
||||||
|
const validKeys = prev.filter(nodeKey => keyTree.groupKeys.includes(nodeKey));
|
||||||
|
return validKeys;
|
||||||
|
});
|
||||||
|
}, [keyTree]);
|
||||||
|
|
||||||
|
const handleTreeSelect = (nodeKeys: React.Key[]) => {
|
||||||
|
if (nodeKeys.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawKey = keyTree.rawKeyByNodeKey.get(String(nodeKeys[0]));
|
||||||
|
if (!rawKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadKeyValue(rawKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnType<RedisKeyInfo>[] = [
|
const handleTreeCheck = (checked: React.Key[] | { checked: React.Key[]; halfChecked: React.Key[] }) => {
|
||||||
{
|
const checkedNodeKeys = Array.isArray(checked) ? checked : checked.checked;
|
||||||
title: 'Key',
|
const rawKeys = checkedNodeKeys
|
||||||
dataIndex: 'key',
|
.map(nodeKey => keyTree.rawKeyByNodeKey.get(String(nodeKey)))
|
||||||
key: 'key',
|
.filter((rawKey): rawKey is string => Boolean(rawKey));
|
||||||
width: columnWidths.key,
|
setSelectedKeys(rawKeys);
|
||||||
ellipsis: true,
|
};
|
||||||
onHeaderCell: (column: any) => ({
|
|
||||||
width: column.width,
|
|
||||||
onResize: handleColumnResize('key')
|
|
||||||
}),
|
|
||||||
render: (text: string) => (
|
|
||||||
<Tooltip title={text}>
|
|
||||||
<span style={{ cursor: 'pointer' }} onClick={() => loadKeyValue(text)}>{text}</span>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
dataIndex: 'type',
|
|
||||||
key: 'type',
|
|
||||||
width: columnWidths.type,
|
|
||||||
onHeaderCell: (column: any) => ({
|
|
||||||
width: column.width,
|
|
||||||
onResize: handleColumnResize('type')
|
|
||||||
}),
|
|
||||||
render: (type: string) => <Tag color={getTypeColor(type)}>{type}</Tag>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'TTL',
|
|
||||||
dataIndex: 'ttl',
|
|
||||||
key: 'ttl',
|
|
||||||
width: columnWidths.ttl,
|
|
||||||
onHeaderCell: (column: any) => ({
|
|
||||||
width: column.width,
|
|
||||||
onResize: handleColumnResize('ttl')
|
|
||||||
}),
|
|
||||||
render: (ttl: number) => formatTTL(ttl)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'action',
|
|
||||||
width: columnWidths.action,
|
|
||||||
onHeaderCell: (column: any) => ({
|
|
||||||
width: column.width,
|
|
||||||
onResize: handleColumnResize('action')
|
|
||||||
}),
|
|
||||||
render: (_: any, record: RedisKeyInfo) => (
|
|
||||||
<Popconfirm title="确定删除此 Key?" onConfirm={() => handleDeleteKeys([record.key])}>
|
|
||||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
|
|
||||||
</Popconfirm>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderValueEditor = () => {
|
const renderValueEditor = () => {
|
||||||
if (!keyValue || !selectedKey) {
|
if (!keyValue || !selectedKey) {
|
||||||
@@ -1375,6 +1500,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
|||||||
setTtlModalOpen(true);
|
setTtlModalOpen(true);
|
||||||
}}>设置 TTL</Button>
|
}}>设置 TTL</Button>
|
||||||
<Button size="small" onClick={() => loadKeyValue(selectedKey)} icon={<ReloadOutlined />}>刷新</Button>
|
<Button size="small" onClick={() => loadKeyValue(selectedKey)} icon={<ReloadOutlined />}>刷新</Button>
|
||||||
|
<Popconfirm title={`确定删除 Key "${selectedKey}"?`} onConfirm={handleDeleteCurrentKey}>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />}>删除 Key</Button>
|
||||||
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||||
@@ -1410,36 +1538,35 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
|||||||
<Button size="small" icon={<ReloadOutlined />} onClick={handleRefresh}>刷新</Button>
|
<Button size="small" icon={<ReloadOutlined />} onClick={handleRefresh}>刷新</Button>
|
||||||
<Button size="small" icon={<PlusOutlined />} onClick={() => setNewKeyModalOpen(true)}>新建</Button>
|
<Button size="small" icon={<PlusOutlined />} onClick={() => setNewKeyModalOpen(true)}>新建</Button>
|
||||||
</Space>
|
</Space>
|
||||||
{selectedKeys.length > 0 && (
|
<Popconfirm
|
||||||
<Popconfirm title={`确定删除选中的 ${selectedKeys.length} 个 Key?`} onConfirm={() => handleDeleteKeys(selectedKeys)}>
|
title={`确定删除选中的 ${selectedKeys.length} 个 Key?`}
|
||||||
<Button size="small" danger icon={<DeleteOutlined />}>删除选中</Button>
|
onConfirm={() => handleDeleteKeys(selectedKeys)}
|
||||||
</Popconfirm>
|
disabled={selectedKeys.length === 0}
|
||||||
)}
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} disabled={selectedKeys.length === 0}>
|
||||||
|
删除选中({selectedKeys.length})
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
<Table
|
<Spin spinning={loading} size="small">
|
||||||
dataSource={keys}
|
<Tree
|
||||||
columns={columns}
|
blockNode
|
||||||
rowKey="key"
|
showIcon={false}
|
||||||
size="small"
|
checkable
|
||||||
loading={loading}
|
checkStrictly
|
||||||
pagination={false}
|
selectable
|
||||||
components={{
|
treeData={keyTree.treeData}
|
||||||
header: {
|
selectedKeys={selectedTreeNodeKeys}
|
||||||
cell: ResizableTitle
|
checkedKeys={checkedTreeNodeKeys}
|
||||||
}
|
expandedKeys={expandedGroupKeys}
|
||||||
}}
|
onExpand={(nextExpandedKeys) => setExpandedGroupKeys(nextExpandedKeys as string[])}
|
||||||
rowSelection={{
|
onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)}
|
||||||
selectedRowKeys: selectedKeys,
|
onCheck={(checked) => handleTreeCheck(checked)}
|
||||||
onChange: (keys) => setSelectedKeys(keys as string[])
|
style={{ padding: '8px 6px' }}
|
||||||
}}
|
/>
|
||||||
onRow={(record) => ({
|
</Spin>
|
||||||
onClick: () => loadKeyValue(record.key),
|
|
||||||
style: { cursor: 'pointer', background: selectedKey === record.key ? '#e6f7ff' : undefined }
|
|
||||||
})}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div style={{ padding: 8, textAlign: 'center' }}>
|
<div style={{ padding: 8, textAlign: 'center' }}>
|
||||||
<Button onClick={handleLoadMore} loading={loading}>加载更多</Button>
|
<Button onClick={handleLoadMore} loading={loading}>加载更多</Button>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
|||||||
import {
|
import {
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
TableOutlined,
|
TableOutlined,
|
||||||
|
EyeOutlined,
|
||||||
ConsoleSqlOutlined,
|
ConsoleSqlOutlined,
|
||||||
HddOutlined,
|
HddOutlined,
|
||||||
FolderOpenOutlined,
|
FolderOpenOutlined,
|
||||||
@@ -28,7 +29,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { SavedConnection } from '../types';
|
import { SavedConnection } from '../types';
|
||||||
import { DBGetDatabases, DBGetTables, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase } from '../../wailsjs/go/app/App';
|
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable } from '../../wailsjs/go/app/App';
|
||||||
|
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||||
|
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
|
|
||||||
@@ -39,16 +41,37 @@ interface TreeNode {
|
|||||||
children?: TreeNode[];
|
children?: TreeNode[];
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
dataRef?: any;
|
dataRef?: any;
|
||||||
type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db';
|
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||||
|
|
||||||
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
|
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
|
||||||
const connections = useStore(state => state.connections);
|
const connections = useStore(state => state.connections);
|
||||||
const savedQueries = useStore(state => state.savedQueries);
|
const savedQueries = useStore(state => state.savedQueries);
|
||||||
const addTab = useStore(state => state.addTab);
|
const addTab = useStore(state => state.addTab);
|
||||||
const setActiveContext = useStore(state => state.setActiveContext);
|
const setActiveContext = useStore(state => state.setActiveContext);
|
||||||
const removeConnection = useStore(state => state.removeConnection);
|
const removeConnection = useStore(state => state.removeConnection);
|
||||||
|
const theme = useStore(state => state.theme);
|
||||||
|
const appearance = useStore(state => state.appearance);
|
||||||
|
const tableAccessCount = useStore(state => state.tableAccessCount);
|
||||||
|
const tableSortPreference = useStore(state => state.tableSortPreference);
|
||||||
|
const recordTableAccess = useStore(state => state.recordTableAccess);
|
||||||
|
const setTableSortPreference = useStore(state => state.setTableSortPreference);
|
||||||
|
const darkMode = theme === 'dark';
|
||||||
|
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||||
|
|
||||||
|
// Background Helper (Duplicate logic for now, ideally shared)
|
||||||
|
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('#141414');
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||||
const [autoExpandParent, setAutoExpandParent] = useState(true);
|
const [autoExpandParent, setAutoExpandParent] = useState(true);
|
||||||
@@ -80,6 +103,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false);
|
const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false);
|
||||||
const [createDbForm] = Form.useForm();
|
const [createDbForm] = Form.useForm();
|
||||||
const [targetConnection, setTargetConnection] = useState<any>(null);
|
const [targetConnection, setTargetConnection] = useState<any>(null);
|
||||||
|
const [isRenameDbModalOpen, setIsRenameDbModalOpen] = useState(false);
|
||||||
|
const [renameDbForm] = Form.useForm();
|
||||||
|
const [renameDbTarget, setRenameDbTarget] = useState<any>(null);
|
||||||
|
const [isRenameTableModalOpen, setIsRenameTableModalOpen] = useState(false);
|
||||||
|
const [renameTableForm] = Form.useForm();
|
||||||
|
const [renameTableTarget, setRenameTableTarget] = useState<any>(null);
|
||||||
|
|
||||||
// Batch Operations Modal
|
// Batch Operations Modal
|
||||||
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
|
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
|
||||||
@@ -141,6 +170,199 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SIDEBAR_SCHEMA_DB_TYPES = new Set([
|
||||||
|
'postgres',
|
||||||
|
'kingbase',
|
||||||
|
'highgo',
|
||||||
|
'vastbase',
|
||||||
|
'sqlserver',
|
||||||
|
'oracle',
|
||||||
|
'dameng',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SIDEBAR_SCHEMA_CUSTOM_DRIVERS = new Set([
|
||||||
|
'postgres',
|
||||||
|
'kingbase',
|
||||||
|
'highgo',
|
||||||
|
'vastbase',
|
||||||
|
'sqlserver',
|
||||||
|
'oracle',
|
||||||
|
'dm',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shouldHideSchemaPrefix = (conn: SavedConnection | undefined): boolean => {
|
||||||
|
const dbType = String(conn?.config?.type || '').trim().toLowerCase();
|
||||||
|
if (SIDEBAR_SCHEMA_DB_TYPES.has(dbType)) return true;
|
||||||
|
if (dbType !== 'custom') return false;
|
||||||
|
|
||||||
|
const customDriver = String((conn?.config as any)?.driver || '').trim().toLowerCase();
|
||||||
|
return SIDEBAR_SCHEMA_CUSTOM_DRIVERS.has(customDriver);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSidebarTableDisplayName = (conn: SavedConnection | undefined, tableName: string): string => {
|
||||||
|
const rawName = String(tableName || '').trim();
|
||||||
|
if (!rawName) return rawName;
|
||||||
|
if (!shouldHideSchemaPrefix(conn)) return rawName;
|
||||||
|
const lastDotIndex = rawName.lastIndexOf('.');
|
||||||
|
if (lastDotIndex <= 0 || lastDotIndex >= rawName.length - 1) return rawName;
|
||||||
|
return rawName.substring(lastDotIndex + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMetadataDialect = (conn: SavedConnection | undefined): string => {
|
||||||
|
const type = String(conn?.config?.type || '').trim().toLowerCase();
|
||||||
|
if (type === 'custom') {
|
||||||
|
return String((conn?.config as any)?.driver || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
if (type === 'mariadb') return 'mysql';
|
||||||
|
if (type === 'dameng') return 'dm';
|
||||||
|
return type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
|
||||||
|
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
|
||||||
|
|
||||||
|
const getCaseInsensitiveValue = (row: Record<string, any>, candidateKeys: string[]): string => {
|
||||||
|
const keyMap = new Map<string, any>();
|
||||||
|
Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key]));
|
||||||
|
for (const key of candidateKeys) {
|
||||||
|
const value = keyMap.get(key.toLowerCase());
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (normalized !== '') return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFirstRowValue = (row: Record<string, any>): string => {
|
||||||
|
for (const value of Object.values(row || {})) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (normalized !== '') return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildQualifiedName = (schemaName: string, objectName: string): string => {
|
||||||
|
const schema = String(schemaName || '').trim();
|
||||||
|
const name = String(objectName || '').trim();
|
||||||
|
if (!name) return '';
|
||||||
|
if (!schema) return name;
|
||||||
|
if (name.includes('.')) return name;
|
||||||
|
return `${schema}.${name}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildViewsMetadataQuery = (dialect: string, dbName: string): string => {
|
||||||
|
const safeDbName = escapeSQLLiteral(dbName);
|
||||||
|
switch (dialect) {
|
||||||
|
case 'mysql':
|
||||||
|
if (!safeDbName) return '';
|
||||||
|
return `SELECT TABLE_NAME AS view_name FROM information_schema.views WHERE table_schema = '${safeDbName}' ORDER BY TABLE_NAME`;
|
||||||
|
case 'postgres':
|
||||||
|
case 'kingbase':
|
||||||
|
case 'highgo':
|
||||||
|
case 'vastbase':
|
||||||
|
return `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg_%' ORDER BY schemaname, viewname`;
|
||||||
|
case 'sqlserver': {
|
||||||
|
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
|
||||||
|
return `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name`;
|
||||||
|
}
|
||||||
|
case 'oracle':
|
||||||
|
case 'dm': {
|
||||||
|
if (!safeDbName) {
|
||||||
|
return `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME`;
|
||||||
|
}
|
||||||
|
return `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME`;
|
||||||
|
}
|
||||||
|
case 'sqlite':
|
||||||
|
return `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTriggersMetadataQuery = (dialect: string, dbName: string): string => {
|
||||||
|
const safeDbName = escapeSQLLiteral(dbName);
|
||||||
|
switch (dialect) {
|
||||||
|
case 'mysql':
|
||||||
|
if (!safeDbName) return '';
|
||||||
|
return `SELECT TRIGGER_NAME AS trigger_name, EVENT_OBJECT_TABLE AS table_name, TRIGGER_SCHEMA AS schema_name FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME`;
|
||||||
|
case 'postgres':
|
||||||
|
case 'kingbase':
|
||||||
|
case 'highgo':
|
||||||
|
case 'vastbase':
|
||||||
|
return `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg_%' ORDER BY event_object_schema, event_object_table, trigger_name`;
|
||||||
|
case 'sqlserver': {
|
||||||
|
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
|
||||||
|
return `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name`;
|
||||||
|
}
|
||||||
|
case 'oracle':
|
||||||
|
case 'dm': {
|
||||||
|
if (!safeDbName) {
|
||||||
|
return `SELECT TRIGGER_NAME AS trigger_name, TABLE_NAME AS table_name FROM USER_TRIGGERS ORDER BY TABLE_NAME, TRIGGER_NAME`;
|
||||||
|
}
|
||||||
|
return `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME`;
|
||||||
|
}
|
||||||
|
case 'sqlite':
|
||||||
|
return `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryMetadataRows = async (conn: any, dbName: string, query: string): Promise<Record<string, any>[]> => {
|
||||||
|
if (!query) return [];
|
||||||
|
try {
|
||||||
|
const config = buildRuntimeConfig(conn, dbName);
|
||||||
|
const result = await DBQuery(config as any, dbName, query);
|
||||||
|
if (!result.success || !Array.isArray(result.data)) return [];
|
||||||
|
return result.data as Record<string, any>[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadViews = async (conn: any, dbName: string): Promise<string[]> => {
|
||||||
|
const dialect = getMetadataDialect(conn as SavedConnection);
|
||||||
|
const query = buildViewsMetadataQuery(dialect, dbName);
|
||||||
|
const rows = await queryMetadataRows(conn, dbName, query);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const views: string[] = [];
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'table_schema']);
|
||||||
|
const viewName = getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name']) || getFirstRowValue(row);
|
||||||
|
const fullName = buildQualifiedName(schemaName, viewName);
|
||||||
|
if (!fullName || seen.has(fullName)) return;
|
||||||
|
seen.add(fullName);
|
||||||
|
views.push(fullName);
|
||||||
|
});
|
||||||
|
return views;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDatabaseTriggers = async (conn: any, dbName: string): Promise<Array<{ displayName: string; triggerName: string; tableName: string }>> => {
|
||||||
|
const dialect = getMetadataDialect(conn as SavedConnection);
|
||||||
|
const query = buildTriggersMetadataQuery(dialect, dbName);
|
||||||
|
const rows = await queryMetadataRows(conn, dbName, query);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const triggers: Array<{ displayName: string; triggerName: string; tableName: string }> = [];
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const triggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'name']) || getFirstRowValue(row);
|
||||||
|
if (!triggerName) return;
|
||||||
|
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema']);
|
||||||
|
const tableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name']);
|
||||||
|
const fullTableName = buildQualifiedName(schemaName, tableName);
|
||||||
|
const uniqueKey = `${triggerName}@@${fullTableName}`;
|
||||||
|
if (seen.has(uniqueKey)) return;
|
||||||
|
seen.add(uniqueKey);
|
||||||
|
const displayName = fullTableName ? `${triggerName} (${fullTableName})` : triggerName;
|
||||||
|
triggers.push({ displayName, triggerName, tableName: fullTableName });
|
||||||
|
});
|
||||||
|
return triggers;
|
||||||
|
};
|
||||||
|
|
||||||
const loadDatabases = async (node: any) => {
|
const loadDatabases = async (node: any) => {
|
||||||
const conn = node.dataRef as SavedConnection;
|
const conn = node.dataRef as SavedConnection;
|
||||||
const loadKey = `dbs-${conn.id}`;
|
const loadKey = `dbs-${conn.id}`;
|
||||||
@@ -256,8 +478,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
|
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
|
||||||
const tables = (res.data as any[]).map((row: any) => {
|
const tables = (res.data as any[]).map((row: any) => {
|
||||||
const tableName = Object.values(row)[0] as string;
|
const tableName = Object.values(row)[0] as string;
|
||||||
|
const tableDisplayName = getSidebarTableDisplayName(conn, tableName);
|
||||||
return {
|
return {
|
||||||
title: tableName,
|
title: tableDisplayName,
|
||||||
key: `${conn.id}-${conn.dbName}-${tableName}`,
|
key: `${conn.id}-${conn.dbName}-${tableName}`,
|
||||||
icon: <TableOutlined />,
|
icon: <TableOutlined />,
|
||||||
type: 'table' as const,
|
type: 'table' as const,
|
||||||
@@ -265,8 +488,76 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
isLeaf: false,
|
isLeaf: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...tables]));
|
const [views, triggers] = await Promise.all([
|
||||||
|
loadViews(conn, conn.dbName),
|
||||||
|
loadDatabaseTriggers(conn, conn.dbName),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 获取当前数据库的排序偏好
|
||||||
|
const sortPreferenceKey = `${conn.id}-${conn.dbName}`;
|
||||||
|
const sortBy = tableSortPreference[sortPreferenceKey] || 'name';
|
||||||
|
|
||||||
|
// 根据排序偏好排序表
|
||||||
|
if (sortBy === 'frequency') {
|
||||||
|
// 按使用频率排序(降序)
|
||||||
|
tables.sort((a, b) => {
|
||||||
|
const keyA = `${conn.id}-${conn.dbName}-${a.dataRef.tableName}`;
|
||||||
|
const keyB = `${conn.id}-${conn.dbName}-${b.dataRef.tableName}`;
|
||||||
|
const countA = tableAccessCount[keyA] || 0;
|
||||||
|
const countB = tableAccessCount[keyB] || 0;
|
||||||
|
if (countA !== countB) {
|
||||||
|
return countB - countA; // 降序
|
||||||
|
}
|
||||||
|
// 频率相同时按名称排序
|
||||||
|
return a.title.toLowerCase().localeCompare(b.title.toLowerCase());
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 按名称排序(字母顺序)
|
||||||
|
tables.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort views by name (case-insensitive)
|
||||||
|
views.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||||
|
|
||||||
|
// Sort triggers by display name (case-insensitive)
|
||||||
|
triggers.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||||||
|
|
||||||
|
const viewNodes: TreeNode[] = views.map((viewName) => ({
|
||||||
|
title: getSidebarTableDisplayName(conn, viewName),
|
||||||
|
key: `${conn.id}-${conn.dbName}-view-${viewName}`,
|
||||||
|
icon: <EyeOutlined />,
|
||||||
|
type: 'view',
|
||||||
|
dataRef: { ...conn, viewName, tableName: viewName },
|
||||||
|
isLeaf: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const triggerNodes: TreeNode[] = triggers.map((trigger) => ({
|
||||||
|
title: trigger.displayName,
|
||||||
|
key: `${conn.id}-${conn.dbName}-trigger-${trigger.triggerName}-${trigger.tableName}`,
|
||||||
|
icon: <FunctionOutlined />,
|
||||||
|
type: 'db-trigger',
|
||||||
|
dataRef: { ...conn, triggerName: trigger.triggerName, triggerTableName: trigger.tableName },
|
||||||
|
isLeaf: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const buildObjectGroup = (groupKey: string, groupTitle: string, groupIcon: React.ReactNode, children: TreeNode[]): TreeNode => ({
|
||||||
|
title: `${groupTitle} (${children.length})`,
|
||||||
|
key: `${key}-${groupKey}`,
|
||||||
|
icon: groupIcon,
|
||||||
|
type: 'object-group',
|
||||||
|
isLeaf: children.length === 0,
|
||||||
|
children: children.length > 0 ? children : undefined,
|
||||||
|
dataRef: { ...conn, dbName: conn.dbName, groupKey }
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedNodes: TreeNode[] = [
|
||||||
|
buildObjectGroup('tables', '表', <TableOutlined />, tables),
|
||||||
|
buildObjectGroup('views', '视图', <EyeOutlined />, viewNodes),
|
||||||
|
buildObjectGroup('triggers', '触发器', <FunctionOutlined />, triggerNodes),
|
||||||
|
];
|
||||||
|
|
||||||
|
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes]));
|
||||||
} else {
|
} else {
|
||||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
||||||
message.error({ content: res.message, key: `db-${key}-tables` });
|
message.error({ content: res.message, key: `db-${key}-tables` });
|
||||||
@@ -285,7 +576,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
await loadTables({ key, dataRef });
|
await loadTables({ key, dataRef });
|
||||||
} else if (type === 'table') {
|
} else if (type === 'table') {
|
||||||
// Expand table to show object categories
|
// Expand table to show object categories
|
||||||
const { tableName, dbName, id } = dataRef;
|
|
||||||
const conn = dataRef;
|
const conn = dataRef;
|
||||||
|
|
||||||
const folders: TreeNode[] = [
|
const folders: TreeNode[] = [
|
||||||
@@ -374,6 +664,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
setActiveContext({ connectionId: dataRef.id, dbName: title });
|
setActiveContext({ connectionId: dataRef.id, dbName: title });
|
||||||
} else if (type === 'table') {
|
} else if (type === 'table') {
|
||||||
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||||
|
} else if (type === 'view' || type === 'db-trigger') {
|
||||||
|
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||||
} else if (type === 'saved-query') {
|
} else if (type === 'saved-query') {
|
||||||
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||||||
} else if (type === 'redis-db') {
|
} else if (type === 'redis-db') {
|
||||||
@@ -394,6 +686,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
const onDoubleClick = (e: any, node: any) => {
|
const onDoubleClick = (e: any, node: any) => {
|
||||||
if (node.type === 'table') {
|
if (node.type === 'table') {
|
||||||
const { tableName, dbName, id } = node.dataRef;
|
const { tableName, dbName, id } = node.dataRef;
|
||||||
|
// 记录表访问
|
||||||
|
recordTableAccess(id, dbName, tableName);
|
||||||
addTab({
|
addTab({
|
||||||
id: node.key,
|
id: node.key,
|
||||||
title: tableName,
|
title: tableName,
|
||||||
@@ -403,6 +697,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
tableName,
|
tableName,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
} else if (node.type === 'view') {
|
||||||
|
const { viewName, dbName, id } = node.dataRef;
|
||||||
|
addTab({
|
||||||
|
id: node.key,
|
||||||
|
title: viewName,
|
||||||
|
type: 'table',
|
||||||
|
connectionId: id,
|
||||||
|
dbName,
|
||||||
|
tableName: viewName,
|
||||||
|
});
|
||||||
|
return;
|
||||||
} else if (node.type === 'saved-query') {
|
} else if (node.type === 'saved-query') {
|
||||||
const q = node.dataRef;
|
const q = node.dataRef;
|
||||||
addTab({
|
addTab({
|
||||||
@@ -424,14 +729,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
redisDB: redisDB
|
redisDB: redisDB
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
} else if (node.type === 'db-trigger') {
|
||||||
|
const { triggerName, dbName, id } = node.dataRef;
|
||||||
|
addTab({
|
||||||
|
id: `trigger-${node.key}`,
|
||||||
|
title: `触发器: ${triggerName}`,
|
||||||
|
type: 'trigger',
|
||||||
|
connectionId: id,
|
||||||
|
dbName,
|
||||||
|
triggerName
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = node.key;
|
const key = node.key;
|
||||||
const isExpanded = expandedKeys.includes(key);
|
const isExpanded = expandedKeys.includes(key);
|
||||||
const newExpandedKeys = isExpanded
|
const newExpandedKeys = isExpanded
|
||||||
? expandedKeys.filter(k => k !== key)
|
? expandedKeys.filter(k => k !== key)
|
||||||
: [...expandedKeys, key];
|
: [...expandedKeys, key];
|
||||||
|
|
||||||
setExpandedKeys(newExpandedKeys);
|
setExpandedKeys(newExpandedKeys);
|
||||||
if (!isExpanded) setAutoExpandParent(false);
|
if (!isExpanded) setAutoExpandParent(false);
|
||||||
};
|
};
|
||||||
@@ -645,7 +961,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchExport = async (includeData: boolean) => {
|
const handleBatchExport = async (mode: BatchTableExportMode) => {
|
||||||
const selectedTables = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
const selectedTables = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||||||
if (selectedTables.length === 0) {
|
if (selectedTables.length === 0) {
|
||||||
message.warning('请至少选择一张表');
|
message.warning('请至少选择一张表');
|
||||||
@@ -657,9 +973,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
const { conn, dbName } = batchDbContext;
|
const { conn, dbName } = batchDbContext;
|
||||||
const tableNames = selectedTables.map(t => t.tableName);
|
const tableNames = selectedTables.map(t => t.tableName);
|
||||||
|
|
||||||
const hide = message.loading(includeData ? `正在备份选中表 (${tableNames.length})...` : `正在导出选中表结构 (${tableNames.length})...`, 0);
|
const loadingText = mode === 'backup'
|
||||||
|
? `正在备份选中表 (${tableNames.length})...`
|
||||||
|
: mode === 'dataOnly'
|
||||||
|
? `正在导出选中表数据 (INSERT) (${tableNames.length})...`
|
||||||
|
: `正在导出选中表结构 (${tableNames.length})...`;
|
||||||
|
const hide = message.loading(loadingText, 0);
|
||||||
try {
|
try {
|
||||||
const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, includeData);
|
const app = (window as any).go.app.App;
|
||||||
|
const res = mode === 'dataOnly'
|
||||||
|
? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, tableNames)
|
||||||
|
: await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, mode === 'backup');
|
||||||
hide();
|
hide();
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
message.success('导出成功');
|
message.success('导出成功');
|
||||||
@@ -849,6 +1173,148 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {
|
||||||
|
return {
|
||||||
|
...conn.config,
|
||||||
|
port: Number(conn.config.port),
|
||||||
|
password: conn.config.password || "",
|
||||||
|
database: clearDatabase ? "" : ((overrideDatabase ?? conn.config.database) || ""),
|
||||||
|
useSSH: conn.config.useSSH || false,
|
||||||
|
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConnectionNodeRef = (connRef: any) => {
|
||||||
|
const latestConn = connections.find(c => c.id === connRef.id);
|
||||||
|
return { key: connRef.id, dataRef: latestConn || connRef };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDatabaseNodeRef = (connRef: any, dbName: string) => {
|
||||||
|
const latestConn = connections.find(c => c.id === connRef.id);
|
||||||
|
return {
|
||||||
|
key: `${connRef.id}-${dbName}`,
|
||||||
|
dataRef: { ...(latestConn || connRef), dbName }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractObjectName = (fullName: string) => {
|
||||||
|
const raw = String(fullName || '').trim();
|
||||||
|
const idx = raw.lastIndexOf('.');
|
||||||
|
if (idx >= 0 && idx < raw.length - 1) {
|
||||||
|
return raw.substring(idx + 1);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameDatabase = async () => {
|
||||||
|
if (!renameDbTarget) return;
|
||||||
|
try {
|
||||||
|
const values = await renameDbForm.validateFields();
|
||||||
|
const conn = renameDbTarget.dataRef;
|
||||||
|
const oldDbName = String(conn.dbName || '').trim();
|
||||||
|
const newDbName = String(values.newName || '').trim();
|
||||||
|
if (!oldDbName || !newDbName) {
|
||||||
|
message.error("数据库名称不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldDbName === newDbName) {
|
||||||
|
message.warning("新旧数据库名称相同,无需修改");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||||
|
const res = await RenameDatabase(config as any, oldDbName, newDbName);
|
||||||
|
if (res.success) {
|
||||||
|
message.success("数据库重命名成功");
|
||||||
|
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`)));
|
||||||
|
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`)));
|
||||||
|
await loadDatabases(getConnectionNodeRef(conn));
|
||||||
|
setIsRenameDbModalOpen(false);
|
||||||
|
setRenameDbTarget(null);
|
||||||
|
renameDbForm.resetFields();
|
||||||
|
} else {
|
||||||
|
message.error("重命名失败: " + res.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Validate failed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDatabase = (node: any) => {
|
||||||
|
const conn = node.dataRef;
|
||||||
|
const dbName = String(conn.dbName || '').trim();
|
||||||
|
if (!dbName) return;
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除数据库',
|
||||||
|
content: `确定删除数据库 "${dbName}" 吗?该操作不可恢复。`,
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||||
|
const res = await DropDatabase(config as any, dbName);
|
||||||
|
if (res.success) {
|
||||||
|
message.success("数据库删除成功");
|
||||||
|
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
|
||||||
|
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
|
||||||
|
await loadDatabases(getConnectionNodeRef(conn));
|
||||||
|
} else {
|
||||||
|
message.error("删除失败: " + res.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameTable = async () => {
|
||||||
|
if (!renameTableTarget) return;
|
||||||
|
try {
|
||||||
|
const values = await renameTableForm.validateFields();
|
||||||
|
const conn = renameTableTarget.dataRef;
|
||||||
|
const oldTableName = String(conn.tableName || '').trim();
|
||||||
|
const newTableName = String(values.newName || '').trim();
|
||||||
|
if (!oldTableName || !newTableName) {
|
||||||
|
message.error("表名不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (extractObjectName(oldTableName) === newTableName || oldTableName === newTableName) {
|
||||||
|
message.warning("新旧表名相同,无需修改");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||||
|
const res = await RenameTable(config as any, conn.dbName, oldTableName, newTableName);
|
||||||
|
if (res.success) {
|
||||||
|
message.success("表重命名成功");
|
||||||
|
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||||
|
setIsRenameTableModalOpen(false);
|
||||||
|
setRenameTableTarget(null);
|
||||||
|
renameTableForm.resetFields();
|
||||||
|
} else {
|
||||||
|
message.error("重命名失败: " + res.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Validate failed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTable = (node: any) => {
|
||||||
|
const conn = node.dataRef;
|
||||||
|
const tableName = String(conn.tableName || '').trim();
|
||||||
|
if (!tableName) return;
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除表',
|
||||||
|
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||||
|
const res = await DropTable(config as any, conn.dbName, tableName);
|
||||||
|
if (res.success) {
|
||||||
|
message.success("表删除成功");
|
||||||
|
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||||
|
} else {
|
||||||
|
message.error("删除失败: " + res.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { value } = e.target;
|
const { value } = e.target;
|
||||||
setSearchValue(value);
|
setSearchValue(value);
|
||||||
@@ -881,6 +1347,42 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
const conn = node.dataRef as SavedConnection;
|
const conn = node.dataRef as SavedConnection;
|
||||||
const isRedis = conn?.config?.type === 'redis';
|
const isRedis = conn?.config?.type === 'redis';
|
||||||
|
|
||||||
|
// 表分组节点的右键菜单
|
||||||
|
if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') {
|
||||||
|
const groupData = node.dataRef; // { ...conn, dbName, groupKey }
|
||||||
|
const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`;
|
||||||
|
const currentSort = tableSortPreference[sortPreferenceKey] || 'name';
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'sort-by-name',
|
||||||
|
label: '按名称排序',
|
||||||
|
icon: currentSort === 'name' ? <CheckSquareOutlined /> : null,
|
||||||
|
onClick: () => {
|
||||||
|
setTableSortPreference(groupData.id, groupData.dbName, 'name');
|
||||||
|
const dbNode = {
|
||||||
|
key: `${groupData.id}-${groupData.dbName}`,
|
||||||
|
dataRef: groupData
|
||||||
|
};
|
||||||
|
loadTables(dbNode);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sort-by-frequency',
|
||||||
|
label: '按使用频率排序',
|
||||||
|
icon: currentSort === 'frequency' ? <CheckSquareOutlined /> : null,
|
||||||
|
onClick: () => {
|
||||||
|
setTableSortPreference(groupData.id, groupData.dbName, 'frequency');
|
||||||
|
const dbNode = {
|
||||||
|
key: `${groupData.id}-${groupData.dbName}`,
|
||||||
|
dataRef: groupData
|
||||||
|
};
|
||||||
|
loadTables(dbNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (node.type === 'connection') {
|
if (node.type === 'connection') {
|
||||||
// Redis connection menu
|
// Redis connection menu
|
||||||
if (isRedis) {
|
if (isRedis) {
|
||||||
@@ -1072,6 +1574,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
icon: <TableOutlined />,
|
icon: <TableOutlined />,
|
||||||
onClick: () => openNewTableDesign(node)
|
onClick: () => openNewTableDesign(node)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'rename-db',
|
||||||
|
label: '重命名数据库',
|
||||||
|
icon: <EditOutlined />,
|
||||||
|
onClick: () => {
|
||||||
|
setRenameDbTarget(node);
|
||||||
|
renameDbForm.setFieldsValue({ newName: node.dataRef?.dbName || '' });
|
||||||
|
setIsRenameDbModalOpen(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'drop-db',
|
||||||
|
label: '删除数据库',
|
||||||
|
icon: <DeleteOutlined />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => handleDeleteDatabase(node)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'refresh',
|
key: 'refresh',
|
||||||
label: '刷新',
|
label: '刷新',
|
||||||
@@ -1128,6 +1647,30 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
onClick: () => handleRunSQLFile(node)
|
onClick: () => handleRunSQLFile(node)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
} else if (node.type === 'view') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'open-view',
|
||||||
|
label: '浏览视图数据',
|
||||||
|
icon: <EyeOutlined />,
|
||||||
|
onClick: () => onDoubleClick(null, node)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'new-query',
|
||||||
|
label: '新建查询',
|
||||||
|
icon: <ConsoleSqlOutlined />,
|
||||||
|
onClick: () => {
|
||||||
|
addTab({
|
||||||
|
id: `query-${Date.now()}`,
|
||||||
|
title: `新建查询`,
|
||||||
|
type: 'query',
|
||||||
|
connectionId: node.dataRef.id,
|
||||||
|
dbName: node.dataRef.dbName,
|
||||||
|
query: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
} else if (node.type === 'table') {
|
} else if (node.type === 'table') {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -1164,6 +1707,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
icon: <SaveOutlined />,
|
icon: <SaveOutlined />,
|
||||||
onClick: () => handleExport(node, 'sql')
|
onClick: () => handleExport(node, 'sql')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'rename-table',
|
||||||
|
label: '重命名表',
|
||||||
|
icon: <EditOutlined />,
|
||||||
|
onClick: () => {
|
||||||
|
setRenameTableTarget(node);
|
||||||
|
renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) });
|
||||||
|
setIsRenameTableModalOpen(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'drop-table',
|
||||||
|
label: '删除表',
|
||||||
|
icon: <DeleteOutlined />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => handleDeleteTable(node)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'divider'
|
type: 'divider'
|
||||||
},
|
},
|
||||||
@@ -1189,12 +1749,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
if (connectionStates[node.key] === 'success') status = 'success';
|
if (connectionStates[node.key] === 'success') status = 'success';
|
||||||
else if (connectionStates[node.key] === 'error') status = 'error';
|
else if (connectionStates[node.key] === 'error') status = 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
|
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
|
||||||
<Badge status={status} style={{ marginRight: 8 }} />
|
<Badge status={status} style={{ marginRight: 8 }} />
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return <span title={node.title}>{statusBadge}{node.title}</span>;
|
const displayTitle = String(node.title ?? '');
|
||||||
|
let hoverTitle = displayTitle;
|
||||||
|
if (node.type === 'table' || node.type === 'view') {
|
||||||
|
const rawTableName = String(node?.dataRef?.tableName || node?.dataRef?.viewName || '').trim();
|
||||||
|
const conn = node?.dataRef as SavedConnection | undefined;
|
||||||
|
if (rawTableName && shouldHideSchemaPrefix(conn)) {
|
||||||
|
const lastDotIndex = rawTableName.lastIndexOf('.');
|
||||||
|
if (lastDotIndex > 0 && lastDotIndex < rawTableName.length - 1) {
|
||||||
|
hoverTitle = rawTableName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span title={hoverTitle}>{statusBadge}{displayTitle}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRightClick = ({ event, node }: any) => {
|
const onRightClick = ({ event, node }: any) => {
|
||||||
@@ -1215,7 +1788,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar for batch operations - always visible */}
|
{/* Toolbar for batch operations - always visible */}
|
||||||
<div style={{ padding: '4px 8px', borderBottom: '1px solid #f0f0f0', display: 'flex', gap: 4 }}>
|
<div style={{ padding: '4px 8px', borderBottom: 'none', display: 'flex', gap: 4 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<CheckSquareOutlined />}
|
icon={<CheckSquareOutlined />}
|
||||||
@@ -1279,33 +1852,79 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`重命名数据库${renameDbTarget?.dataRef?.dbName ? ` (${renameDbTarget.dataRef.dbName})` : ''}`}
|
||||||
|
open={isRenameDbModalOpen}
|
||||||
|
onOk={handleRenameDatabase}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsRenameDbModalOpen(false);
|
||||||
|
setRenameDbTarget(null);
|
||||||
|
renameDbForm.resetFields();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={renameDbForm} layout="vertical">
|
||||||
|
<Form.Item name="newName" label="新数据库名称" rules={[{ required: true, message: '请输入新数据库名称' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`重命名表${renameTableTarget?.dataRef?.tableName ? ` (${renameTableTarget.dataRef.tableName})` : ''}`}
|
||||||
|
open={isRenameTableModalOpen}
|
||||||
|
onOk={handleRenameTable}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsRenameTableModalOpen(false);
|
||||||
|
setRenameTableTarget(null);
|
||||||
|
renameTableForm.resetFields();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={renameTableForm} layout="vertical">
|
||||||
|
<Form.Item name="newName" label="新表名" rules={[{ required: true, message: '请输入新表名' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="批量操作表"
|
title="批量操作表"
|
||||||
open={isBatchModalOpen}
|
open={isBatchModalOpen}
|
||||||
onCancel={() => setIsBatchModalOpen(false)}
|
onCancel={() => setIsBatchModalOpen(false)}
|
||||||
width={600}
|
width={680}
|
||||||
footer={[
|
footer={
|
||||||
<Button key="cancel" onClick={() => setIsBatchModalOpen(false)}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
取消
|
<Button key="cancel" onClick={() => setIsBatchModalOpen(false)}>
|
||||||
</Button>,
|
取消
|
||||||
<Button
|
</Button>
|
||||||
key="export-schema"
|
<Space size={8} wrap style={{ marginLeft: 'auto' }}>
|
||||||
icon={<ExportOutlined />}
|
<Button
|
||||||
onClick={() => handleBatchExport(false)}
|
key="export-schema"
|
||||||
disabled={checkedTableKeys.length === 0}
|
icon={<ExportOutlined />}
|
||||||
>
|
onClick={() => handleBatchExport('schema')}
|
||||||
导出表结构 ({checkedTableKeys.length})
|
disabled={checkedTableKeys.length === 0}
|
||||||
</Button>,
|
>
|
||||||
<Button
|
导出结构
|
||||||
key="backup"
|
</Button>
|
||||||
type="primary"
|
<Button
|
||||||
icon={<SaveOutlined />}
|
key="export-data-only"
|
||||||
onClick={() => handleBatchExport(true)}
|
icon={<SaveOutlined />}
|
||||||
disabled={checkedTableKeys.length === 0}
|
onClick={() => handleBatchExport('dataOnly')}
|
||||||
>
|
disabled={checkedTableKeys.length === 0}
|
||||||
备份表 ({checkedTableKeys.length})
|
>
|
||||||
</Button>
|
仅数据(INSERT)
|
||||||
]}
|
</Button>
|
||||||
|
<Button
|
||||||
|
key="backup"
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={() => handleBatchExport('backup')}
|
||||||
|
disabled={checkedTableKeys.length === 0}
|
||||||
|
>
|
||||||
|
备份(结构+数据)
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
@@ -1368,7 +1987,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
</span>
|
</span>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ maxHeight: 400, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
<div style={{ maxHeight: 400, overflow: 'auto', border: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
||||||
<Checkbox.Group
|
<Checkbox.Group
|
||||||
value={checkedTableKeys}
|
value={checkedTableKeys}
|
||||||
onChange={(values) => setCheckedTableKeys(values as string[])}
|
onChange={(values) => setCheckedTableKeys(values as string[])}
|
||||||
@@ -1459,7 +2078,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
</span>
|
</span>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ maxHeight: 400, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
<div style={{ maxHeight: 400, overflow: 'auto', border: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
||||||
<Checkbox.Group
|
<Checkbox.Group
|
||||||
value={checkedDbKeys}
|
value={checkedDbKeys}
|
||||||
onChange={(values) => setCheckedDbKeys(values as string[])}
|
onChange={(values) => setCheckedDbKeys(values as string[])}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import QueryEditor from './QueryEditor';
|
|||||||
import TableDesigner from './TableDesigner';
|
import TableDesigner from './TableDesigner';
|
||||||
import RedisViewer from './RedisViewer';
|
import RedisViewer from './RedisViewer';
|
||||||
import RedisCommandEditor from './RedisCommandEditor';
|
import RedisCommandEditor from './RedisCommandEditor';
|
||||||
|
import TriggerViewer from './TriggerViewer';
|
||||||
|
|
||||||
const TabManager: React.FC = () => {
|
const TabManager: React.FC = () => {
|
||||||
const tabs = useStore(state => state.tabs);
|
const tabs = useStore(state => state.tabs);
|
||||||
@@ -40,6 +41,8 @@ const TabManager: React.FC = () => {
|
|||||||
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||||
} else if (tab.type === 'redis-command') {
|
} else if (tab.type === 'redis-command') {
|
||||||
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||||
|
} else if (tab.type === 'trigger') {
|
||||||
|
content = <TriggerViewer tab={tab} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItems: MenuProps['items'] = [
|
const menuItems: MenuProps['items'] = [
|
||||||
@@ -122,6 +125,9 @@ const TabManager: React.FC = () => {
|
|||||||
.main-tabs .ant-tabs-tabpane-hidden {
|
.main-tabs .ant-tabs-tabpane-hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
.main-tabs .ant-tabs-nav::before {
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<Tabs
|
<Tabs
|
||||||
className="main-tabs"
|
className="main-tabs"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useEffect, useState, useContext, useMemo, useRef } from 'react';
|
import React, { useEffect, useState, useContext, useMemo, useRef } from 'react';
|
||||||
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select } from 'antd';
|
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space } from 'antd';
|
||||||
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined } from '@ant-design/icons';
|
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
|
||||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Resizable } from 'react-resizable';
|
import { Resizable } from 'react-resizable';
|
||||||
|
import Editor, { loader } from '@monaco-editor/react';
|
||||||
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
|
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
||||||
@@ -162,13 +163,47 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const [previewSql, setPreviewSql] = useState<string>('');
|
const [previewSql, setPreviewSql] = useState<string>('');
|
||||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
const [activeKey, setActiveKey] = useState(tab.initialTab || "columns");
|
const [activeKey, setActiveKey] = useState(tab.initialTab || "columns");
|
||||||
|
const [selectedTrigger, setSelectedTrigger] = useState<TriggerDefinition | null>(null);
|
||||||
|
const [isTriggerModalOpen, setIsTriggerModalOpen] = useState(false);
|
||||||
|
const [isTriggerEditModalOpen, setIsTriggerEditModalOpen] = useState(false);
|
||||||
|
const [triggerEditMode, setTriggerEditMode] = useState<'create' | 'edit'>('create');
|
||||||
|
const [triggerEditSql, setTriggerEditSql] = useState<string>('');
|
||||||
|
const [triggerExecuting, setTriggerExecuting] = useState(false);
|
||||||
|
|
||||||
const connections = useStore(state => state.connections);
|
const connections = useStore(state => state.connections);
|
||||||
|
const theme = useStore(state => state.theme);
|
||||||
|
const darkMode = theme === 'dark';
|
||||||
const readOnly = !!tab.readOnly;
|
const readOnly = !!tab.readOnly;
|
||||||
|
|
||||||
const [tableHeight, setTableHeight] = useState(500);
|
const [tableHeight, setTableHeight] = useState(500);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 初始化透明 Monaco Editor 主题
|
||||||
|
useEffect(() => {
|
||||||
|
loader.init().then(monaco => {
|
||||||
|
monaco.editor.defineTheme('transparent-dark', {
|
||||||
|
base: 'vs-dark',
|
||||||
|
inherit: true,
|
||||||
|
rules: [],
|
||||||
|
colors: {
|
||||||
|
'editor.background': '#00000000',
|
||||||
|
'editor.lineHighlightBackground': '#ffffff10',
|
||||||
|
'editorGutter.background': '#00000000',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
monaco.editor.defineTheme('transparent-light', {
|
||||||
|
base: 'vs',
|
||||||
|
inherit: true,
|
||||||
|
rules: [],
|
||||||
|
colors: {
|
||||||
|
'editor.background': '#00000000',
|
||||||
|
'editor.lineHighlightBackground': '#00000010',
|
||||||
|
'editorGutter.background': '#00000000',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const resizeObserver = new ResizeObserver(entries => {
|
const resizeObserver = new ResizeObserver(entries => {
|
||||||
@@ -365,6 +400,215 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [tab]);
|
}, [tab]);
|
||||||
|
|
||||||
|
// --- Trigger Handlers ---
|
||||||
|
|
||||||
|
const getDbType = (): string => {
|
||||||
|
const conn = connections.find(c => c.id === tab.connectionId);
|
||||||
|
const type = String(conn?.config?.type || '').toLowerCase();
|
||||||
|
if (type === 'mariadb') return 'mysql';
|
||||||
|
if (type === 'dameng') return 'dm';
|
||||||
|
return type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateTriggerTemplate = (): string => {
|
||||||
|
const dbType = getDbType();
|
||||||
|
const tblName = tab.tableName || 'table_name';
|
||||||
|
|
||||||
|
switch (dbType) {
|
||||||
|
case 'mysql':
|
||||||
|
return `CREATE TRIGGER trigger_name
|
||||||
|
BEFORE INSERT ON \`${tblName}\`
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
-- 触发器逻辑
|
||||||
|
END;`;
|
||||||
|
case 'postgres':
|
||||||
|
case 'kingbase':
|
||||||
|
case 'highgo':
|
||||||
|
case 'vastbase':
|
||||||
|
return `CREATE OR REPLACE FUNCTION trigger_function_name()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- 触发器逻辑
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_name
|
||||||
|
BEFORE INSERT ON "${tblName}"
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION trigger_function_name();`;
|
||||||
|
case 'sqlserver':
|
||||||
|
return `CREATE TRIGGER trigger_name
|
||||||
|
ON [${tblName}]
|
||||||
|
AFTER INSERT
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
-- 触发器逻辑
|
||||||
|
END;`;
|
||||||
|
case 'oracle':
|
||||||
|
case 'dm':
|
||||||
|
return `CREATE OR REPLACE TRIGGER trigger_name
|
||||||
|
BEFORE INSERT ON "${tblName}"
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
-- 触发器逻辑
|
||||||
|
NULL;
|
||||||
|
END;`;
|
||||||
|
case 'sqlite':
|
||||||
|
return `CREATE TRIGGER trigger_name
|
||||||
|
AFTER INSERT ON "${tblName}"
|
||||||
|
BEGIN
|
||||||
|
-- 触发器逻辑
|
||||||
|
END;`;
|
||||||
|
default:
|
||||||
|
return `-- 请输入 CREATE TRIGGER 语句`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDropTriggerSql = (triggerName: string): string => {
|
||||||
|
const dbType = getDbType();
|
||||||
|
const tblName = tab.tableName || '';
|
||||||
|
|
||||||
|
switch (dbType) {
|
||||||
|
case 'mysql':
|
||||||
|
return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
|
||||||
|
case 'postgres':
|
||||||
|
case 'kingbase':
|
||||||
|
case 'highgo':
|
||||||
|
case 'vastbase':
|
||||||
|
return `DROP TRIGGER IF EXISTS "${triggerName}" ON "${tblName}"`;
|
||||||
|
case 'sqlserver':
|
||||||
|
return `DROP TRIGGER IF EXISTS [${triggerName}]`;
|
||||||
|
case 'oracle':
|
||||||
|
case 'dm':
|
||||||
|
return `DROP TRIGGER "${triggerName}"`;
|
||||||
|
case 'sqlite':
|
||||||
|
return `DROP TRIGGER IF EXISTS "${triggerName}"`;
|
||||||
|
default:
|
||||||
|
return `DROP TRIGGER ${triggerName}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTrigger = () => {
|
||||||
|
setTriggerEditMode('create');
|
||||||
|
setTriggerEditSql(generateTriggerTemplate());
|
||||||
|
setIsTriggerEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditTrigger = () => {
|
||||||
|
if (!selectedTrigger) return;
|
||||||
|
setTriggerEditMode('edit');
|
||||||
|
// 构建完整的 CREATE TRIGGER 语句
|
||||||
|
const dbType = getDbType();
|
||||||
|
const tblName = tab.tableName || '';
|
||||||
|
let createSql = '';
|
||||||
|
|
||||||
|
if (dbType === 'mysql') {
|
||||||
|
createSql = `CREATE TRIGGER \`${selectedTrigger.name}\`
|
||||||
|
${selectedTrigger.timing} ${selectedTrigger.event} ON \`${tblName}\`
|
||||||
|
FOR EACH ROW
|
||||||
|
${selectedTrigger.statement}`;
|
||||||
|
} else {
|
||||||
|
createSql = selectedTrigger.statement || '-- 无法获取完整的触发器定义';
|
||||||
|
}
|
||||||
|
|
||||||
|
setTriggerEditSql(createSql);
|
||||||
|
setIsTriggerEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTrigger = () => {
|
||||||
|
if (!selectedTrigger) return;
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除触发器',
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: `确定要删除触发器 "${selectedTrigger.name}" 吗?此操作不可撤销。`,
|
||||||
|
okText: '删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
const conn = connections.find(c => c.id === tab.connectionId);
|
||||||
|
if (!conn) {
|
||||||
|
message.error('未找到连接');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropSql = buildDropTriggerSql(selectedTrigger.name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await DBQuery(config as any, tab.dbName || '', dropSql);
|
||||||
|
if (res.success) {
|
||||||
|
message.success('触发器删除成功');
|
||||||
|
setSelectedTrigger(null);
|
||||||
|
fetchData(); // 刷新列表
|
||||||
|
} else {
|
||||||
|
message.error('删除失败: ' + res.message);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('删除失败: ' + (e?.message || String(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecuteTriggerSql = async () => {
|
||||||
|
const conn = connections.find(c => c.id === tab.connectionId);
|
||||||
|
if (!conn) {
|
||||||
|
message.error('未找到连接');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
setTriggerExecuting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果是编辑模式,先删除旧触发器
|
||||||
|
if (triggerEditMode === 'edit' && selectedTrigger) {
|
||||||
|
const dropSql = buildDropTriggerSql(selectedTrigger.name);
|
||||||
|
const dropRes = await DBQuery(config as any, tab.dbName || '', dropSql);
|
||||||
|
if (!dropRes.success) {
|
||||||
|
message.error('删除旧触发器失败: ' + dropRes.message);
|
||||||
|
setTriggerExecuting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行创建语句
|
||||||
|
const res = await DBQuery(config as any, tab.dbName || '', triggerEditSql);
|
||||||
|
if (res.success) {
|
||||||
|
message.success(triggerEditMode === 'create' ? '触发器创建成功' : '触发器修改成功');
|
||||||
|
setIsTriggerEditModalOpen(false);
|
||||||
|
setSelectedTrigger(null);
|
||||||
|
fetchData(); // 刷新列表
|
||||||
|
} else {
|
||||||
|
message.error('执行失败: ' + res.message);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('执行失败: ' + (e?.message || String(e)));
|
||||||
|
} finally {
|
||||||
|
setTriggerExecuting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
|
|
||||||
const handleColumnChange = (key: string, field: keyof EditableColumn, value: any) => {
|
const handleColumnChange = (key: string, field: keyof EditableColumn, value: any) => {
|
||||||
@@ -680,19 +924,61 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
key: 'triggers',
|
key: 'triggers',
|
||||||
label: '触发器',
|
label: '触发器',
|
||||||
children: (
|
children: (
|
||||||
<Table
|
<div>
|
||||||
dataSource={triggers}
|
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
|
||||||
columns={[
|
<Button
|
||||||
{ title: '名', dataIndex: 'name', key: 'name' },
|
size="small"
|
||||||
{ title: '时间', dataIndex: 'timing', key: 'timing' },
|
icon={<EyeOutlined />}
|
||||||
{ title: '事件', dataIndex: 'event', key: 'event' },
|
disabled={!selectedTrigger}
|
||||||
{ title: '语句', dataIndex: 'statement', key: 'statement', ellipsis: true },
|
onClick={() => setIsTriggerModalOpen(true)}
|
||||||
]}
|
>
|
||||||
rowKey="name"
|
查看语句
|
||||||
size="small"
|
</Button>
|
||||||
pagination={false}
|
<Button size="small" icon={<PlusOutlined />} onClick={handleCreateTrigger}>新增</Button>
|
||||||
loading={loading}
|
<Button size="small" icon={<EditOutlined />} disabled={!selectedTrigger} onClick={handleEditTrigger}>修改</Button>
|
||||||
/>
|
<Button size="small" icon={<DeleteOutlined />} danger disabled={!selectedTrigger} onClick={handleDeleteTrigger}>删除</Button>
|
||||||
|
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
|
||||||
|
{selectedTrigger ? `已选择: ${selectedTrigger.name}` : '请点击选择触发器'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
dataSource={triggers}
|
||||||
|
columns={[
|
||||||
|
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||||
|
{ title: '时机', dataIndex: 'timing', key: 'timing', width: 100 },
|
||||||
|
{ title: '事件', dataIndex: 'event', key: 'event', width: 100 },
|
||||||
|
]}
|
||||||
|
rowKey="name"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
loading={loading}
|
||||||
|
locale={{ emptyText: <Empty description="该表暂无触发器" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||||
|
rowSelection={{
|
||||||
|
type: 'radio',
|
||||||
|
selectedRowKeys: selectedTrigger ? [selectedTrigger.name] : [],
|
||||||
|
onChange: (_, selectedRows) => setSelectedTrigger(selectedRows[0] || null),
|
||||||
|
onSelect: (record, selected) => {
|
||||||
|
// 点击单选按钮时,如果已选中则取消
|
||||||
|
if (selectedTrigger?.name === record.name) {
|
||||||
|
setSelectedTrigger(null);
|
||||||
|
} else {
|
||||||
|
setSelectedTrigger(record);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => {
|
||||||
|
// 点击已选中的行时取消选择
|
||||||
|
if (selectedTrigger?.name === record.name) {
|
||||||
|
setSelectedTrigger(null);
|
||||||
|
} else {
|
||||||
|
setSelectedTrigger(record);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: { cursor: 'pointer' }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
] : []),
|
] : []),
|
||||||
@@ -701,8 +987,22 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
label: 'DDL',
|
label: 'DDL',
|
||||||
icon: <FileTextOutlined />,
|
icon: <FileTextOutlined />,
|
||||||
children: (
|
children: (
|
||||||
<div style={{ height: 'calc(100vh - 200px)', overflow: 'auto', padding: 10, background: '#f5f5f5', border: '1px solid #eee' }}>
|
<div style={{ height: 'calc(100vh - 200px)', border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
|
||||||
<pre>{ddl}</pre>
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
language="sql"
|
||||||
|
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||||
|
value={ddl}
|
||||||
|
options={{
|
||||||
|
readOnly: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: 'on',
|
||||||
|
automaticLayout: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}] : [])
|
}] : [])
|
||||||
@@ -725,6 +1025,75 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
</div>
|
</div>
|
||||||
<p style={{ marginTop: 10, color: '#faad14' }}>请仔细检查 SQL,执行后不可撤销。</p>
|
<p style={{ marginTop: 10, color: '#faad14' }}>请仔细检查 SQL,执行后不可撤销。</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={selectedTrigger ? `触发器: ${selectedTrigger.name}` : '触发器详情'}
|
||||||
|
open={isTriggerModalOpen}
|
||||||
|
onCancel={() => setIsTriggerModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={700}
|
||||||
|
>
|
||||||
|
{selectedTrigger && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 12, display: 'flex', gap: 24 }}>
|
||||||
|
<span><strong>时机:</strong> {selectedTrigger.timing}</span>
|
||||||
|
<span><strong>事件:</strong> {selectedTrigger.event}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
|
||||||
|
<Editor
|
||||||
|
height="350px"
|
||||||
|
language="sql"
|
||||||
|
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||||
|
value={selectedTrigger.statement}
|
||||||
|
options={{
|
||||||
|
readOnly: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: 'on',
|
||||||
|
automaticLayout: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={triggerEditMode === 'create' ? '新增触发器' : '修改触发器'}
|
||||||
|
open={isTriggerEditModalOpen}
|
||||||
|
onCancel={() => setIsTriggerEditModalOpen(false)}
|
||||||
|
width={800}
|
||||||
|
okText={triggerEditMode === 'create' ? '创建' : '保存'}
|
||||||
|
cancelText="取消"
|
||||||
|
confirmLoading={triggerExecuting}
|
||||||
|
onOk={handleExecuteTriggerSql}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
|
||||||
|
{triggerEditMode === 'edit' && selectedTrigger && (
|
||||||
|
<span>修改触发器时会先删除原触发器,再创建新触发器。</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
|
||||||
|
<Editor
|
||||||
|
height="350px"
|
||||||
|
language="sql"
|
||||||
|
theme={darkMode ? 'vs-dark' : 'light'}
|
||||||
|
value={triggerEditSql}
|
||||||
|
onChange={(val) => setTriggerEditSql(val || '')}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: 'on',
|
||||||
|
automaticLayout: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p style={{ marginTop: 10, color: '#faad14' }}>请仔细检查 SQL 语句,执行后不可撤销。</p>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
240
frontend/src/components/TriggerViewer.tsx
Normal file
240
frontend/src/components/TriggerViewer.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Editor, { loader } from '@monaco-editor/react';
|
||||||
|
import { Spin, Alert } from 'antd';
|
||||||
|
import { TabData } from '../types';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { DBQuery } from '../../wailsjs/go/app/App';
|
||||||
|
|
||||||
|
interface TriggerViewerProps {
|
||||||
|
tab: TabData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [triggerDefinition, setTriggerDefinition] = useState<string>('');
|
||||||
|
|
||||||
|
const connections = useStore(state => state.connections);
|
||||||
|
const theme = useStore(state => state.theme);
|
||||||
|
const darkMode = theme === 'dark';
|
||||||
|
|
||||||
|
// 初始化透明 Monaco Editor 主题
|
||||||
|
useEffect(() => {
|
||||||
|
loader.init().then(monaco => {
|
||||||
|
monaco.editor.defineTheme('transparent-dark', {
|
||||||
|
base: 'vs-dark',
|
||||||
|
inherit: true,
|
||||||
|
rules: [],
|
||||||
|
colors: {
|
||||||
|
'editor.background': '#00000000',
|
||||||
|
'editor.lineHighlightBackground': '#ffffff10',
|
||||||
|
'editorGutter.background': '#00000000',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
monaco.editor.defineTheme('transparent-light', {
|
||||||
|
base: 'vs',
|
||||||
|
inherit: true,
|
||||||
|
rules: [],
|
||||||
|
colors: {
|
||||||
|
'editor.background': '#00000000',
|
||||||
|
'editor.lineHighlightBackground': '#00000010',
|
||||||
|
'editorGutter.background': '#00000000',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
|
||||||
|
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
|
||||||
|
|
||||||
|
const getMetadataDialect = (conn: any): string => {
|
||||||
|
const type = String(conn?.config?.type || '').trim().toLowerCase();
|
||||||
|
if (type === 'custom') {
|
||||||
|
return String(conn?.config?.driver || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
if (type === 'mariadb') return 'mysql';
|
||||||
|
if (type === 'dameng') return 'dm';
|
||||||
|
return type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildShowTriggerQuery = (dialect: string, triggerName: string, dbName: string): string => {
|
||||||
|
const safeTriggerName = escapeSQLLiteral(triggerName);
|
||||||
|
const safeDbName = escapeSQLLiteral(dbName);
|
||||||
|
switch (dialect) {
|
||||||
|
case 'mysql':
|
||||||
|
return `SHOW CREATE TRIGGER \`${triggerName.replace(/`/g, '``')}\``;
|
||||||
|
case 'postgres':
|
||||||
|
case 'kingbase':
|
||||||
|
case 'highgo':
|
||||||
|
case 'vastbase':
|
||||||
|
return `SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition
|
||||||
|
FROM pg_trigger t
|
||||||
|
JOIN pg_class c ON t.tgrelid = c.oid
|
||||||
|
WHERE t.tgname = '${safeTriggerName}'
|
||||||
|
AND NOT t.tgisinternal
|
||||||
|
LIMIT 1`;
|
||||||
|
case 'sqlserver': {
|
||||||
|
return `SELECT OBJECT_DEFINITION(OBJECT_ID('${safeTriggerName.replace(/'/g, "''")}')) AS trigger_definition`;
|
||||||
|
}
|
||||||
|
case 'oracle':
|
||||||
|
case 'dm':
|
||||||
|
if (!safeDbName) {
|
||||||
|
return `SELECT TRIGGER_BODY FROM USER_TRIGGERS WHERE TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`;
|
||||||
|
}
|
||||||
|
return `SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`;
|
||||||
|
case 'sqlite':
|
||||||
|
return `SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`;
|
||||||
|
case 'tdengine':
|
||||||
|
return `-- TDengine 不支持触发器`;
|
||||||
|
case 'mongodb':
|
||||||
|
return `-- MongoDB 不支持触发器`;
|
||||||
|
default:
|
||||||
|
return `-- 暂不支持该数据库类型的触发器定义查看`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractTriggerDefinition = (dialect: string, data: any[]): string => {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return '-- 未找到触发器定义';
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = data[0];
|
||||||
|
|
||||||
|
switch (dialect) {
|
||||||
|
case 'mysql': {
|
||||||
|
// MySQL SHOW CREATE TRIGGER returns: Trigger, sql_mode, SQL Original Statement, ...
|
||||||
|
const keys = Object.keys(row);
|
||||||
|
const sqlKey = keys.find(k => k.toLowerCase().includes('statement') || k.toLowerCase() === 'sql original statement');
|
||||||
|
if (sqlKey) return row[sqlKey];
|
||||||
|
// Fallback: try to find any key containing CREATE TRIGGER
|
||||||
|
for (const key of keys) {
|
||||||
|
const val = String(row[key] || '');
|
||||||
|
if (val.toUpperCase().includes('CREATE TRIGGER')) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(row, null, 2);
|
||||||
|
}
|
||||||
|
case 'postgres':
|
||||||
|
case 'kingbase':
|
||||||
|
case 'highgo':
|
||||||
|
case 'vastbase': {
|
||||||
|
return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || '';
|
||||||
|
}
|
||||||
|
case 'sqlserver': {
|
||||||
|
return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || '';
|
||||||
|
}
|
||||||
|
case 'oracle':
|
||||||
|
case 'dm': {
|
||||||
|
return row.trigger_body || row.TRIGGER_BODY || Object.values(row)[0] || '';
|
||||||
|
}
|
||||||
|
case 'sqlite': {
|
||||||
|
return row.sql || row.SQL || Object.values(row)[0] || '';
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return JSON.stringify(row, null, 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTriggerDefinition = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const conn = connections.find(c => c.id === tab.connectionId);
|
||||||
|
if (!conn) {
|
||||||
|
setError('未找到数据库连接');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerName = tab.triggerName || '';
|
||||||
|
const dbName = tab.dbName || '';
|
||||||
|
|
||||||
|
if (!triggerName) {
|
||||||
|
setError('触发器名称为空');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialect = getMetadataDialect(conn);
|
||||||
|
const query = buildShowTriggerQuery(dialect, triggerName, dbName);
|
||||||
|
|
||||||
|
if (query.startsWith('--')) {
|
||||||
|
setTriggerDefinition(query);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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: '' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await DBQuery(config as any, dbName, query);
|
||||||
|
|
||||||
|
if (result.success && Array.isArray(result.data)) {
|
||||||
|
const definition = extractTriggerDefinition(dialect, result.data);
|
||||||
|
setTriggerDefinition(definition);
|
||||||
|
} else {
|
||||||
|
setError(result.message || '查询触发器定义失败');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError('查询触发器定义失败: ' + (e?.message || String(e)));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTriggerDefinition();
|
||||||
|
}, [tab.connectionId, tab.dbName, tab.triggerName, connections]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||||
|
<Spin tip="加载触发器定义..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<Alert type="error" message="加载失败" description={error} showIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<div style={{ padding: '8px 16px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0' }}>
|
||||||
|
<strong>触发器: </strong>{tab.triggerName}
|
||||||
|
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>数据库: {tab.dbName}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
language="sql"
|
||||||
|
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||||
|
value={triggerDefinition}
|
||||||
|
options={{
|
||||||
|
readOnly: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: 'on',
|
||||||
|
automaticLayout: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TriggerViewer;
|
||||||
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,15 +32,18 @@ interface AppState {
|
|||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
activeContext: { connectionId: string; dbName: string } | null;
|
activeContext: { connectionId: string; dbName: string } | null;
|
||||||
savedQueries: SavedQuery[];
|
savedQueries: SavedQuery[];
|
||||||
darkMode: boolean;
|
theme: 'light' | 'dark';
|
||||||
|
appearance: { opacity: number; blur: number };
|
||||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||||
queryOptions: { maxRows: number };
|
queryOptions: { maxRows: number };
|
||||||
sqlLogs: SqlLog[];
|
sqlLogs: SqlLog[];
|
||||||
|
tableAccessCount: Record<string, number>;
|
||||||
|
tableSortPreference: Record<string, 'name' | 'frequency'>;
|
||||||
|
|
||||||
addConnection: (conn: SavedConnection) => void;
|
addConnection: (conn: SavedConnection) => void;
|
||||||
updateConnection: (conn: SavedConnection) => void;
|
updateConnection: (conn: SavedConnection) => void;
|
||||||
removeConnection: (id: string) => void;
|
removeConnection: (id: string) => void;
|
||||||
|
|
||||||
addTab: (tab: TabData) => void;
|
addTab: (tab: TabData) => void;
|
||||||
closeTab: (id: string) => void;
|
closeTab: (id: string) => void;
|
||||||
closeOtherTabs: (id: string) => void;
|
closeOtherTabs: (id: string) => void;
|
||||||
@@ -40,12 +56,16 @@ interface AppState {
|
|||||||
saveQuery: (query: SavedQuery) => void;
|
saveQuery: (query: SavedQuery) => void;
|
||||||
deleteQuery: (id: string) => void;
|
deleteQuery: (id: string) => void;
|
||||||
|
|
||||||
toggleDarkMode: () => void;
|
setTheme: (theme: 'light' | 'dark') => void;
|
||||||
|
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
||||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||||
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
|
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
|
||||||
|
|
||||||
addSqlLog: (log: SqlLog) => void;
|
addSqlLog: (log: SqlLog) => void;
|
||||||
clearSqlLogs: () => void;
|
clearSqlLogs: () => void;
|
||||||
|
|
||||||
|
recordTableAccess: (connectionId: string, dbName: string, tableName: string) => void;
|
||||||
|
setTableSortPreference: (connectionId: string, dbName: string, sortBy: 'name' | 'frequency') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStore = create<AppState>()(
|
export const useStore = create<AppState>()(
|
||||||
@@ -56,14 +76,17 @@ 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 },
|
queryOptions: { maxRows: 5000 },
|
||||||
sqlLogs: [],
|
sqlLogs: [],
|
||||||
|
tableAccessCount: {},
|
||||||
|
tableSortPreference: {},
|
||||||
|
|
||||||
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
|
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
|
||||||
updateConnection: (conn) => set((state) => ({
|
updateConnection: (conn) => set((state) => ({
|
||||||
connections: state.connections.map(c => c.id === conn.id ? conn : c)
|
connections: state.connections.map(c => c.id === conn.id ? conn : c)
|
||||||
})),
|
})),
|
||||||
removeConnection: (id) => set((state) => ({ connections: state.connections.filter(c => c.id !== id) })),
|
removeConnection: (id) => set((state) => ({ connections: state.connections.filter(c => c.id !== id) })),
|
||||||
|
|
||||||
@@ -125,16 +148,74 @@ export const useStore = create<AppState>()(
|
|||||||
|
|
||||||
deleteQuery: (id) => set((state) => ({ savedQueries: state.savedQueries.filter(q => q.id !== id) })),
|
deleteQuery: (id) => set((state) => ({ savedQueries: state.savedQueries.filter(q => q.id !== id) })),
|
||||||
|
|
||||||
toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),
|
setTheme: (theme) => set({ theme }),
|
||||||
|
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
|
||||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||||
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
|
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
|
||||||
|
|
||||||
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: [] }),
|
||||||
|
|
||||||
|
recordTableAccess: (connectionId, dbName, tableName) => set((state) => {
|
||||||
|
const key = `${connectionId}-${dbName}-${tableName}`;
|
||||||
|
const currentCount = state.tableAccessCount[key] || 0;
|
||||||
|
return {
|
||||||
|
tableAccessCount: {
|
||||||
|
...state.tableAccessCount,
|
||||||
|
[key]: currentCount + 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
setTableSortPreference: (connectionId, dbName, sortBy) => set((state) => {
|
||||||
|
const key = `${connectionId}-${dbName}`;
|
||||||
|
return {
|
||||||
|
tableSortPreference: {
|
||||||
|
...state.tableSortPreference,
|
||||||
|
[key]: sortBy
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'lite-db-storage', // name of the item in the storage (must be unique)
|
name: 'lite-db-storage', // name of the item in the storage (must be unique)
|
||||||
partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, darkMode: state.darkMode, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions }), // Don't persist logs
|
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,
|
||||||
|
tableAccessCount: state.tableAccessCount,
|
||||||
|
tableSortPreference: state.tableSortPreference
|
||||||
|
}), // Don't persist logs
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export interface TriggerDefinition {
|
|||||||
export interface TabData {
|
export interface TabData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command';
|
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger';
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
dbName?: string;
|
dbName?: string;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
@@ -70,6 +70,7 @@ export interface TabData {
|
|||||||
initialTab?: string;
|
initialTab?: string;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
redisDB?: number; // Redis database index for redis tabs
|
redisDB?: number; // Redis database index for redis tabs
|
||||||
|
triggerName?: string; // Trigger name for trigger tabs
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseNode {
|
export interface DatabaseNode {
|
||||||
|
|||||||
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;
|
||||||
|
|
||||||
|
// 平台透明度映射因子:值越大,滑块变化越平滑(1.0 = 线性映射)
|
||||||
|
const MAC_OPACITY_FACTOR = 0.60;
|
||||||
|
const MAC_BLUR_FACTOR = 1.00;
|
||||||
|
const WINDOWS_OPACITY_FACTOR = 0.70;
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -23,6 +23,8 @@ const needsQuote = (ident: string): boolean => {
|
|||||||
if (!ident) return false;
|
if (!ident) return false;
|
||||||
// 如果包含特殊字符(非字母、数字、下划线)则需要引号
|
// 如果包含特殊字符(非字母、数字、下划线)则需要引号
|
||||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(ident)) return true;
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(ident)) return true;
|
||||||
|
// PostgreSQL 会将未加引号的标识符折叠为小写,含大写字母时必须加引号
|
||||||
|
if (/[A-Z]/.test(ident)) return true;
|
||||||
// 常见 SQL 保留字列表(简化版)
|
// 常见 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'];
|
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());
|
return reserved.includes(ident.toLowerCase());
|
||||||
@@ -33,7 +35,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
|
|||||||
if (!raw) return raw;
|
if (!raw) return raw;
|
||||||
const dbTypeLower = (dbType || '').toLowerCase();
|
const dbTypeLower = (dbType || '').toLowerCase();
|
||||||
|
|
||||||
if (dbTypeLower === 'mysql') {
|
if (dbTypeLower === 'mysql' || dbTypeLower === 'tdengine') {
|
||||||
return `\`${raw.replace(/`/g, '``')}\``;
|
return `\`${raw.replace(/`/g, '``')}\``;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,4 +197,3 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
|
|||||||
|
|
||||||
return whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
return whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
18
frontend/wailsjs/go/app/App.d.ts
vendored
18
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -6,6 +6,8 @@ import {redis} from '../models';
|
|||||||
|
|
||||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
export function 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>;
|
||||||
@@ -34,6 +36,12 @@ 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 ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
|
||||||
@@ -42,12 +50,18 @@ export function ExportQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:st
|
|||||||
|
|
||||||
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 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>;
|
||||||
@@ -102,4 +116,8 @@ export function RedisZSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:A
|
|||||||
|
|
||||||
export function RedisZSetRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):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,6 +66,18 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -78,10 +94,18 @@ 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) {
|
export function ExportTablesSQL(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4);
|
return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAppInfo() {
|
||||||
|
return window['go']['app']['App']['GetAppInfo']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ImportConfigFile() {
|
export function ImportConfigFile() {
|
||||||
return window['go']['app']['App']['ImportConfigFile']();
|
return window['go']['app']['App']['ImportConfigFile']();
|
||||||
}
|
}
|
||||||
@@ -90,6 +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);
|
||||||
}
|
}
|
||||||
@@ -198,6 +226,14 @@ export function RedisZSetRemove(arg1, arg2, arg3) {
|
|||||||
return window['go']['app']['App']['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);
|
||||||
}
|
}
|
||||||
|
|||||||
16
go.mod
16
go.mod
@@ -7,9 +7,12 @@ 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/microsoft/go-mssqldb v1.9.6
|
||||||
github.com/redis/go-redis/v9 v9.17.3
|
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/taosdata/driver-go/v3 v3.7.8
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.47.0
|
||||||
modernc.org/sqlite v1.44.3
|
modernc.org/sqlite v1.44.3
|
||||||
)
|
)
|
||||||
@@ -22,10 +25,15 @@ require (
|
|||||||
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
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.6 // indirect
|
||||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
@@ -34,19 +42,27 @@ require (
|
|||||||
github.com/leaanthony/u v1.1.1 // indirect
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/samber/lo v1.49.1 // indirect
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
|||||||
82
go.sum
82
go.sum
@@ -4,6 +4,18 @@ gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3 h1:QjslQNaH5Nuap5i4ni
|
|||||||
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3/go.mod h1:7lH5A1jzCXD9Nl16DzaBUOfDAT8NPrDmZwKu1p5wf94=
|
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3/go.mod h1:7lH5A1jzCXD9Nl16DzaBUOfDAT8NPrDmZwKu1p5wf94=
|
||||||
gitee.com/chunanyong/dm v1.8.22 h1:H7fsrnUIvEA0jlDWew7vwELry1ff+tLMIu2Fk2cIBSg=
|
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/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
@@ -12,6 +24,7 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
|||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
@@ -24,19 +37,37 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1
|
|||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||||
|
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||||
|
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
@@ -61,6 +92,12 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
|||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
|
||||||
|
github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
@@ -78,10 +115,22 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg=
|
github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg=
|
||||||
github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU=
|
github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/taosdata/driver-go/v3 v3.7.8 h1:N2H6HLLZH2ve2ipcoFgG9BJS+yW0XksqNYwEdSmHaJk=
|
||||||
|
github.com/taosdata/driver-go/v3 v3.7.8/go.mod h1:gSxBEPOueMg0rTmMO1Ug6aeD7AwGdDGvUtLrsDTTpYc=
|
||||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
@@ -94,35 +143,68 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
|
|||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
|
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ type cachedDatabase struct {
|
|||||||
|
|
||||||
// App struct
|
// App struct
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
dbCache map[string]cachedDatabase // Cache for DB connections
|
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||||
mu sync.RWMutex // 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
|
||||||
@@ -43,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("应用启动完成")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch strings.ToLower(strings.TrimSpace(config.Type)) {
|
switch strings.ToLower(strings.TrimSpace(config.Type)) {
|
||||||
case "mysql", "postgres", "kingbase":
|
case "mysql", "mariadb", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine":
|
||||||
// 这些类型的 dbName 表示“数据库”,需要写入连接配置以选择目标库。
|
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
|
||||||
runConfig.Database = name
|
runConfig.Database = name
|
||||||
case "dameng":
|
case "dameng":
|
||||||
// 达梦使用 schema 参数,沿用现有行为:dbName 表示 schema。
|
// 达梦使用 schema 参数,沿用现有行为:dbName 表示 schema。
|
||||||
@@ -45,12 +45,14 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch strings.ToLower(strings.TrimSpace(config.Type)) {
|
switch strings.ToLower(strings.TrimSpace(config.Type)) {
|
||||||
case "postgres", "kingbase":
|
case "postgres", "kingbase", "highgo", "vastbase":
|
||||||
// PG/金仓:dbName 在 UI 里是“数据库”,schema 需从 tableName 或使用默认 public。
|
// PG/金仓/瀚高/海量:dbName 在 UI 里是"数据库",schema 需从 tableName 或使用默认 public。
|
||||||
return "public", rawTable
|
return "public", rawTable
|
||||||
|
case "sqlserver":
|
||||||
|
// SQL Server:dbName 表示数据库,schema 默认 dbo
|
||||||
|
return "dbo", rawTable
|
||||||
default:
|
default:
|
||||||
// MySQL:dbName 表示数据库;Oracle/达梦:dbName 表示 schema/owner。
|
// MySQL:dbName 表示数据库;Oracle/达梦:dbName 表示 schema/owner。
|
||||||
return rawDB, rawTable
|
return rawDB, rawTable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResu
|
|||||||
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: "连接成功"}
|
||||||
}
|
}
|
||||||
@@ -31,14 +31,14 @@ func (a *App) TestConnection(config connection.ConnectionConfig) connection.Quer
|
|||||||
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 {
|
||||||
@@ -47,9 +47,14 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
|
|||||||
|
|
||||||
escapedDbName := strings.ReplaceAll(dbName, "`", "``")
|
escapedDbName := strings.ReplaceAll(dbName, "`", "``")
|
||||||
query := fmt.Sprintf("CREATE DATABASE `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", escapedDbName)
|
query := fmt.Sprintf("CREATE DATABASE `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", escapedDbName)
|
||||||
if runConfig.Type == "postgres" {
|
dbType := strings.ToLower(strings.TrimSpace(runConfig.Type))
|
||||||
|
if dbType == "postgres" || dbType == "kingbase" || dbType == "highgo" || dbType == "vastbase" {
|
||||||
escapedDbName = strings.ReplaceAll(dbName, `"`, `""`)
|
escapedDbName = strings.ReplaceAll(dbName, `"`, `""`)
|
||||||
query = fmt.Sprintf("CREATE DATABASE \"%s\"", escapedDbName)
|
query = fmt.Sprintf("CREATE DATABASE \"%s\"", escapedDbName)
|
||||||
|
} else if dbType == "tdengine" {
|
||||||
|
query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName))
|
||||||
|
} else if dbType == "mariadb" {
|
||||||
|
// MariaDB uses same syntax as MySQL
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = dbInst.Exec(query)
|
_, err = dbInst.Exec(query)
|
||||||
@@ -60,6 +65,230 @@ 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", "highgo", "vastbase":
|
||||||
|
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", "mariadb", "postgres", "kingbase", "vastbase", "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", "mariadb":
|
||||||
|
return connection.QueryResult{Success: false, Message: "MySQL/MariaDB 不支持直接重命名数据库,请新建库后迁移数据"}
|
||||||
|
case "postgres", "kingbase", "highgo", "vastbase":
|
||||||
|
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", "mariadb", "tdengine":
|
||||||
|
runConfig = config
|
||||||
|
runConfig.Database = ""
|
||||||
|
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
|
||||||
|
case "postgres", "kingbase", "highgo", "vastbase":
|
||||||
|
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", "mariadb", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
|
||||||
|
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)
|
||||||
|
|
||||||
|
var sql string
|
||||||
|
switch dbType {
|
||||||
|
case "mysql", "mariadb":
|
||||||
|
newQualifiedTable := quoteTableIdentByType(dbType, schemaName, newTableName)
|
||||||
|
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualifiedTable, newQualifiedTable)
|
||||||
|
case "sqlserver":
|
||||||
|
// SQL Server 使用 sp_rename,参数为 'schema.oldname', 'newname'
|
||||||
|
oldFullName := schemaName + "." + pureOldTableName
|
||||||
|
escapedOld := strings.ReplaceAll(oldFullName, "'", "''")
|
||||||
|
escapedNew := strings.ReplaceAll(newTableName, "'", "''")
|
||||||
|
sql = fmt.Sprintf("EXEC sp_rename '%s', '%s'", escapedOld, escapedNew)
|
||||||
|
default:
|
||||||
|
sql = fmt.Sprintf("ALTER TABLE %s RENAME TO %s", oldQualifiedTable, newTableQuoted)
|
||||||
|
}
|
||||||
|
|
||||||
|
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", "mariadb", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine":
|
||||||
|
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)
|
||||||
@@ -156,12 +385,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}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,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 {
|
||||||
@@ -120,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" {
|
||||||
@@ -153,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 {
|
||||||
@@ -195,7 +195,7 @@ 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 {
|
||||||
@@ -219,7 +219,7 @@ 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()}
|
||||||
}
|
}
|
||||||
@@ -238,7 +238,7 @@ dbInst, err := a.getDatabase(runConfig)
|
|||||||
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
|
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true); err != nil {
|
if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true); err != nil {
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
if err := writeSQLFooter(w, runConfig); err != nil {
|
if err := writeSQLFooter(w, runConfig); err != nil {
|
||||||
@@ -249,8 +249,8 @@ dbInst, err := a.getDatabase(runConfig)
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()}
|
||||||
}
|
}
|
||||||
@@ -268,13 +268,27 @@ data, columns, err := dbInst.Query(query)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ExportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeData bool) connection.QueryResult {
|
func (a *App) ExportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeData bool) connection.QueryResult {
|
||||||
|
return a.exportTablesSQL(config, dbName, tableNames, true, includeData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ExportTablesDataSQL(config connection.ConnectionConfig, dbName string, tableNames []string) connection.QueryResult {
|
||||||
|
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)
|
safeDbName := strings.TrimSpace(dbName)
|
||||||
if safeDbName == "" {
|
if safeDbName == "" {
|
||||||
safeDbName = "export"
|
safeDbName = "export"
|
||||||
}
|
}
|
||||||
suffix := "schema"
|
suffix := "schema"
|
||||||
if includeData {
|
if includeSchema && includeData {
|
||||||
suffix = "backup"
|
suffix = "backup"
|
||||||
|
} else if !includeSchema && includeData {
|
||||||
|
suffix = "data"
|
||||||
}
|
}
|
||||||
defaultFilename := fmt.Sprintf("%s_%s_%dtables.sql", safeDbName, suffix, len(tableNames))
|
defaultFilename := fmt.Sprintf("%s_%s_%dtables.sql", safeDbName, suffix, len(tableNames))
|
||||||
if len(tableNames) == 1 && strings.TrimSpace(tableNames[0]) != "" {
|
if len(tableNames) == 1 && strings.TrimSpace(tableNames[0]) != "" {
|
||||||
@@ -323,7 +337,7 @@ func (a *App) ExportTablesSQL(config connection.ConnectionConfig, dbName string,
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
for _, t := range tables {
|
for _, t := range tables {
|
||||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeData); err != nil {
|
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()}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,7 +391,7 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
for _, t := range tables {
|
for _, t := range tables {
|
||||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeData); err != nil {
|
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, true, includeData); err != nil {
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,8 +408,11 @@ func quoteIdentByType(dbType string, ident string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch dbType {
|
switch dbType {
|
||||||
case "mysql":
|
case "mysql", "mariadb", "tdengine":
|
||||||
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
|
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
|
||||||
|
case "sqlserver":
|
||||||
|
escaped := strings.ReplaceAll(ident, "]", "]]")
|
||||||
|
return "[" + escaped + "]"
|
||||||
default:
|
default:
|
||||||
return `"` + strings.ReplaceAll(ident, `"`, `""`) + `"`
|
return `"` + strings.ReplaceAll(ident, `"`, `""`) + `"`
|
||||||
}
|
}
|
||||||
@@ -534,7 +551,7 @@ func formatSQLValue(dbType string, v interface{}) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.ConnectionConfig, dbName, tableName string, includeData bool) error {
|
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)
|
schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName)
|
||||||
|
|
||||||
if _, err := w.WriteString("\n-- ----------------------------\n"); err != nil {
|
if _, err := w.WriteString("\n-- ----------------------------\n"); err != nil {
|
||||||
@@ -547,15 +564,17 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
if includeSchema {
|
||||||
if err != nil {
|
createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
||||||
return err
|
if err != nil {
|
||||||
}
|
return err
|
||||||
if _, err := w.WriteString(ensureSQLTerminator(createSQL)); 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 _, err := w.WriteString("\n\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !includeData {
|
if !includeData {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -22,15 +23,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
updateRepo = "Syngnat/GoNavi"
|
updateRepo = "Syngnat/GoNavi"
|
||||||
updateAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest"
|
updateAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest"
|
||||||
updateChecksumAsset = "SHA256SUMS"
|
updateChecksumAsset = "SHA256SUMS"
|
||||||
|
updateDownloadProgressEvent = "update:download-progress"
|
||||||
)
|
)
|
||||||
|
|
||||||
type updateState struct {
|
type updateState struct {
|
||||||
lastCheck *UpdateInfo
|
lastCheck *UpdateInfo
|
||||||
downloading bool
|
downloading bool
|
||||||
staged *stagedUpdate
|
staged *stagedUpdate
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateInfo struct {
|
type UpdateInfo struct {
|
||||||
@@ -46,27 +48,45 @@ type UpdateInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppInfo struct {
|
type AppInfo struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
RepoURL string `json:"repoUrl,omitempty"`
|
RepoURL string `json:"repoUrl,omitempty"`
|
||||||
IssueURL string `json:"issueUrl,omitempty"`
|
IssueURL string `json:"issueUrl,omitempty"`
|
||||||
ReleaseURL string `json:"releaseUrl,omitempty"`
|
ReleaseURL string `json:"releaseUrl,omitempty"`
|
||||||
BuildTime string `json:"buildTime,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 {
|
type stagedUpdate struct {
|
||||||
Version string
|
Version string
|
||||||
AssetName string
|
AssetName string
|
||||||
FilePath string
|
FilePath string
|
||||||
StagedDir string
|
StagedDir string
|
||||||
|
InstallLogPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type githubRelease struct {
|
type githubRelease struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
Prerelease bool `json:"prerelease"`
|
Prerelease bool `json:"prerelease"`
|
||||||
Assets []githubAsset `json:"assets"`
|
Assets []githubAsset `json:"assets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type githubAsset struct {
|
type githubAsset struct {
|
||||||
@@ -95,12 +115,12 @@ func (a *App) CheckForUpdates() connection.QueryResult {
|
|||||||
|
|
||||||
func (a *App) GetAppInfo() connection.QueryResult {
|
func (a *App) GetAppInfo() connection.QueryResult {
|
||||||
info := AppInfo{
|
info := AppInfo{
|
||||||
Version: getCurrentVersion(),
|
Version: getCurrentVersion(),
|
||||||
Author: getCurrentAuthor(),
|
Author: getCurrentAuthor(),
|
||||||
RepoURL: "https://github.com/" + updateRepo,
|
RepoURL: "https://github.com/" + updateRepo,
|
||||||
IssueURL: "https://github.com/" + updateRepo + "/issues",
|
IssueURL: "https://github.com/" + updateRepo + "/issues",
|
||||||
ReleaseURL: "https://github.com/" + updateRepo + "/releases",
|
ReleaseURL: "https://github.com/" + updateRepo + "/releases",
|
||||||
BuildTime: strings.TrimSpace(AppBuildTime),
|
BuildTime: strings.TrimSpace(AppBuildTime),
|
||||||
}
|
}
|
||||||
return connection.QueryResult{Success: true, Message: "OK", Data: info}
|
return connection.QueryResult{Success: true, Message: "OK", Data: info}
|
||||||
}
|
}
|
||||||
@@ -124,13 +144,15 @@ func (a *App) DownloadUpdate() connection.QueryResult {
|
|||||||
a.updateMu.Unlock()
|
a.updateMu.Unlock()
|
||||||
return connection.QueryResult{Success: false, Message: "未找到可用的更新包"}
|
return connection.QueryResult{Success: false, Message: "未找到可用的更新包"}
|
||||||
}
|
}
|
||||||
if a.updateState.staged != nil && a.updateState.staged.Version == info.LatestVersion {
|
staged := a.updateState.staged
|
||||||
|
if staged != nil && staged.Version == info.LatestVersion {
|
||||||
a.updateMu.Unlock()
|
a.updateMu.Unlock()
|
||||||
return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: info}
|
return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: buildUpdateDownloadResult(*info, staged)}
|
||||||
}
|
}
|
||||||
a.updateState.downloading = true
|
a.updateState.downloading = true
|
||||||
a.updateMu.Unlock()
|
a.updateMu.Unlock()
|
||||||
|
|
||||||
|
a.emitUpdateDownloadProgress("start", 0, info.AssetSize, "")
|
||||||
result := a.downloadAndStageUpdate(*info)
|
result := a.downloadAndStageUpdate(*info)
|
||||||
|
|
||||||
a.updateMu.Lock()
|
a.updateMu.Lock()
|
||||||
@@ -143,6 +165,9 @@ func (a *App) DownloadUpdate() connection.QueryResult {
|
|||||||
func (a *App) InstallUpdateAndRestart() connection.QueryResult {
|
func (a *App) InstallUpdateAndRestart() connection.QueryResult {
|
||||||
a.updateMu.Lock()
|
a.updateMu.Lock()
|
||||||
staged := a.updateState.staged
|
staged := a.updateState.staged
|
||||||
|
if staged != nil && strings.TrimSpace(staged.InstallLogPath) == "" {
|
||||||
|
staged.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
|
||||||
|
}
|
||||||
a.updateMu.Unlock()
|
a.updateMu.Unlock()
|
||||||
if staged == nil {
|
if staged == nil {
|
||||||
return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"}
|
return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"}
|
||||||
@@ -150,49 +175,104 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult {
|
|||||||
|
|
||||||
if err := launchUpdateScript(staged); err != nil {
|
if err := launchUpdateScript(staged); err != nil {
|
||||||
logger.Error(err, "启动更新脚本失败")
|
logger.Error(err, "启动更新脚本失败")
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
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() {
|
go func() {
|
||||||
time.Sleep(300 * time.Millisecond)
|
time.Sleep(300 * time.Millisecond)
|
||||||
wailsRuntime.Quit(a.ctx)
|
wailsRuntime.Quit(a.ctx)
|
||||||
|
// 兜底退出,避免某些平台/窗口状态下 Quit 未真正结束进程,导致更新脚本一直等待。
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return connection.QueryResult{Success: true, Message: "更新已开始安装"}
|
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 {
|
func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
|
||||||
stagedDir, err := os.MkdirTemp("", "gonavi-update-")
|
workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDir())
|
||||||
if err != nil {
|
if workspaceDir == "" {
|
||||||
return connection.QueryResult{Success: false, Message: "创建临时目录失败"}
|
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 := filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, info.LatestVersion))
|
||||||
|
// 清理可能残留的旧目录(上次下载失败后未清理)
|
||||||
|
_ = os.RemoveAll(stagedDir)
|
||||||
|
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", stagedDir)
|
||||||
|
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
|
||||||
|
return connection.QueryResult{Success: false, Message: errMsg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载到 staging 目录,避免覆盖正在运行的可执行文件
|
||||||
assetPath := filepath.Join(stagedDir, info.AssetName)
|
assetPath := filepath.Join(stagedDir, info.AssetName)
|
||||||
actualHash, err := downloadFileWithHash(info.AssetURL, assetPath)
|
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 {
|
if err != nil {
|
||||||
|
_ = os.Remove(assetPath)
|
||||||
_ = os.RemoveAll(stagedDir)
|
_ = os.RemoveAll(stagedDir)
|
||||||
|
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, err.Error())
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.SHA256 == "" {
|
if info.SHA256 == "" {
|
||||||
|
_ = os.Remove(assetPath)
|
||||||
_ = os.RemoveAll(stagedDir)
|
_ = os.RemoveAll(stagedDir)
|
||||||
|
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "缺少更新包校验值(SHA256SUMS)")
|
||||||
return connection.QueryResult{Success: false, Message: "缺少更新包校验值(SHA256SUMS)"}
|
return connection.QueryResult{Success: false, Message: "缺少更新包校验值(SHA256SUMS)"}
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(info.SHA256, actualHash) {
|
if !strings.EqualFold(info.SHA256, actualHash) {
|
||||||
|
_ = os.Remove(assetPath)
|
||||||
_ = os.RemoveAll(stagedDir)
|
_ = os.RemoveAll(stagedDir)
|
||||||
|
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "更新包校验失败,请重试")
|
||||||
return connection.QueryResult{Success: false, Message: "更新包校验失败,请重试"}
|
return connection.QueryResult{Success: false, Message: "更新包校验失败,请重试"}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.updateMu.Lock()
|
staged := &stagedUpdate{
|
||||||
a.updateState.staged = &stagedUpdate{
|
Version: info.LatestVersion,
|
||||||
Version: info.LatestVersion,
|
AssetName: info.AssetName,
|
||||||
AssetName: info.AssetName,
|
FilePath: assetPath,
|
||||||
FilePath: assetPath,
|
StagedDir: stagedDir,
|
||||||
StagedDir: stagedDir,
|
InstallLogPath: buildUpdateInstallLogPath(workspaceDir),
|
||||||
}
|
}
|
||||||
|
a.updateMu.Lock()
|
||||||
|
a.updateState.staged = staged
|
||||||
a.updateMu.Unlock()
|
a.updateMu.Unlock()
|
||||||
|
|
||||||
return connection.QueryResult{Success: true, Message: "更新包下载完成", Data: info}
|
a.emitUpdateDownloadProgress("done", info.AssetSize, info.AssetSize, "")
|
||||||
|
return connection.QueryResult{Success: true, Message: "更新包下载完成", Data: buildUpdateDownloadResult(info, staged)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchLatestUpdateInfo() (UpdateInfo, error) {
|
func fetchLatestUpdateInfo() (UpdateInfo, error) {
|
||||||
@@ -367,7 +447,32 @@ func parseSHA256Sums(content string) map[string]string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadFileWithHash(url, filePath string) (string, error) {
|
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}
|
client := &http.Client{Timeout: 10 * time.Minute}
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -392,14 +497,99 @@ func downloadFileWithHash(url, filePath string) (string, error) {
|
|||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
hasher := sha256.New()
|
hasher := sha256.New()
|
||||||
writer := io.MultiWriter(out, hasher)
|
total := resp.ContentLength
|
||||||
if _, err := io.Copy(writer, resp.Body); err != nil {
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
|
if onProgress != nil {
|
||||||
|
onProgress(progressWriter.written, total)
|
||||||
|
}
|
||||||
|
|
||||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
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 {
|
func launchUpdateScript(staged *stagedUpdate) error {
|
||||||
exePath, err := os.Executable()
|
exePath, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -422,32 +612,41 @@ func launchUpdateScript(staged *stagedUpdate) error {
|
|||||||
|
|
||||||
func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
||||||
scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
|
scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
|
||||||
content := buildWindowsScript(staged.FilePath, targetExe, staged.StagedDir, pid)
|
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 {
|
if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Infof("启动 Windows 更新脚本:target=%s script=%s log=%s", targetExe, scriptPath, logPath)
|
||||||
cmd := exec.Command("cmd", "/C", "start", "", scriptPath)
|
cmd := exec.Command("cmd", "/C", "start", "", scriptPath)
|
||||||
return cmd.Start()
|
return cmd.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
||||||
targetApp := detectMacAppPath(targetExe)
|
targetApp := resolveMacUpdateTarget(targetExe)
|
||||||
if targetApp == "" {
|
|
||||||
targetApp = "/Applications/GoNavi.app"
|
|
||||||
}
|
|
||||||
mountDir := filepath.Join(staged.StagedDir, "mnt")
|
mountDir := filepath.Join(staged.StagedDir, "mnt")
|
||||||
if err := os.MkdirAll(mountDir, 0o755); err != nil {
|
if err := os.MkdirAll(mountDir, 0o755); err != nil {
|
||||||
return err
|
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")
|
scriptPath := filepath.Join(staged.StagedDir, "update.sh")
|
||||||
content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, pid)
|
content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, logPath, pid)
|
||||||
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
|
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("/bin/sh", scriptPath)
|
cmd := exec.Command("/bin/bash", scriptPath)
|
||||||
|
logger.Infof("启动 macOS 更新脚本:target=%s script=%s log=%s", targetApp, scriptPath, logPath)
|
||||||
return cmd.Start()
|
return cmd.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,49 +661,170 @@ func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
|||||||
return cmd.Start()
|
return cmd.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildWindowsScript(source, target, stagedDir string, pid int) string {
|
func buildWindowsScript(source, target, stagedDir, logPath string, pid int) string {
|
||||||
return fmt.Sprintf(`@echo off
|
return fmt.Sprintf(`@echo off
|
||||||
setlocal
|
setlocal EnableExtensions EnableDelayedExpansion
|
||||||
set "SOURCE=%s"
|
set "SOURCE=%s"
|
||||||
set "TARGET=%s"
|
set "TARGET=%s"
|
||||||
set "STAGED=%s"
|
set "STAGED=%s"
|
||||||
|
set "LOG_FILE=%s"
|
||||||
set PID=%d
|
set PID=%d
|
||||||
|
|
||||||
|
call :log updater started
|
||||||
|
if not exist "%%SOURCE%%" (
|
||||||
|
call :log source file not found: %%SOURCE%%
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
:waitloop
|
:waitloop
|
||||||
tasklist /FI "PID eq %%PID%%" | find "%%PID%%" >nul
|
tasklist /FI "PID eq %%PID%%" | find "%%PID%%" >nul
|
||||||
if %%ERRORLEVEL%%==0 (
|
if %%ERRORLEVEL%%==0 (
|
||||||
timeout /t 1 /nobreak >nul
|
timeout /t 1 /nobreak >nul
|
||||||
goto waitloop
|
goto waitloop
|
||||||
)
|
)
|
||||||
move /Y "%%SOURCE%%" "%%TARGET%%" >nul
|
call :log host process exited
|
||||||
start "" "%%TARGET%%"
|
|
||||||
rmdir /S /Q "%%STAGED%%"
|
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
|
exit /b 0
|
||||||
`, source, target, stagedDir, pid)
|
|
||||||
|
:log
|
||||||
|
echo [%%date%% %%time%%] %%*>>"%%LOG_FILE%%"
|
||||||
|
exit /b 0
|
||||||
|
`, source, target, stagedDir, logPath, pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir string, pid int) string {
|
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string {
|
||||||
return fmt.Sprintf(`#!/bin/bash
|
return fmt.Sprintf(`#!/bin/bash
|
||||||
set -e
|
set -euo pipefail
|
||||||
PID=%d
|
PID=%d
|
||||||
DMG="%s"
|
DMG="%s"
|
||||||
TARGET_APP="%s"
|
TARGET_APP="%s"
|
||||||
STAGED="%s"
|
STAGED="%s"
|
||||||
MOUNT_DIR="%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
|
while kill -0 $PID 2>/dev/null; do
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MOUNT_DIR"
|
log "host process exited"
|
||||||
APP_SRC=$(ls "$MOUNT_DIR"/*.app 2>/dev/null | head -n 1)
|
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
|
if [ -z "$APP_SRC" ]; then
|
||||||
hdiutil detach "$MOUNT_DIR" -quiet || true
|
log "no .app found inside dmg"
|
||||||
|
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
rm -rf "$TARGET_APP"
|
|
||||||
cp -R "$APP_SRC" "$TARGET_APP"
|
log "install target: $TARGET_APP"
|
||||||
hdiutil detach "$MOUNT_DIR" -quiet
|
if ! replace_app_direct; then
|
||||||
rm -rf "$MOUNT_DIR" "$DMG" "$STAGED"
|
log "direct replace failed, trying admin replace"
|
||||||
open "$TARGET_APP"
|
run_admin_replace >>"$LOG_FILE" 2>&1
|
||||||
`, pid, dmgPath, targetApp, stagedDir, mountDir)
|
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 {
|
func buildLinuxScript(tarPath, targetExe, stagedDir string, pid int) string {
|
||||||
@@ -543,6 +863,20 @@ func detectMacAppPath(exePath string) string {
|
|||||||
return ""
|
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 {
|
func normalizeVersion(version string) string {
|
||||||
version = strings.TrimSpace(version)
|
version = strings.TrimSpace(version)
|
||||||
version = strings.TrimPrefix(version, "v")
|
version = strings.TrimPrefix(version, "v")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
func sanitizeSQLForPgLike(dbType string, query string) string {
|
func sanitizeSQLForPgLike(dbType string, query string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
||||||
case "postgres", "kingbase":
|
case "postgres", "kingbase", "highgo", "vastbase":
|
||||||
// 有些情况下会出现多层重复引用(例如 """"schema"""" 或 ""schema"""),单次修复不一定收敛。
|
// 有些情况下会出现多层重复引用(例如 """"schema"""" 或 ""schema"""),单次修复不一定收敛。
|
||||||
// 这里做有限次数的迭代,直到输出不再变化。
|
// 这里做有限次数的迭代,直到输出不再变化。
|
||||||
out := query
|
out := query
|
||||||
|
|||||||
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() {}
|
||||||
@@ -40,6 +40,18 @@ func NewDatabase(dbType string) (Database, error) {
|
|||||||
return &DamengDB{}, nil
|
return &DamengDB{}, nil
|
||||||
case "kingbase":
|
case "kingbase":
|
||||||
return &KingbaseDB{}, nil
|
return &KingbaseDB{}, nil
|
||||||
|
case "mongodb":
|
||||||
|
return &MongoDB{}, nil
|
||||||
|
case "sqlserver":
|
||||||
|
return &SqlServerDB{}, nil
|
||||||
|
case "highgo":
|
||||||
|
return &HighGoDB{}, nil
|
||||||
|
case "mariadb":
|
||||||
|
return &MariaDB{}, nil
|
||||||
|
case "vastbase":
|
||||||
|
return &VastbaseDB{}, nil
|
||||||
|
case "tdengine":
|
||||||
|
return &TDengineDB{}, nil
|
||||||
case "custom":
|
case "custom":
|
||||||
return &CustomDB{}, nil
|
return &CustomDB{}, nil
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -95,3 +95,20 @@ func TestKingbaseDSN_QuotesPasswordWithSpaces(t *testing.T) {
|
|||||||
t.Fatalf("dsn 未对包含空格的密码进行引号包裹:%s", dsn)
|
t.Fatalf("dsn 未对包含空格的密码进行引号包裹:%s", dsn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTDengineDSN_UsesWebSocketFormat(t *testing.T) {
|
||||||
|
td := &TDengineDB{}
|
||||||
|
cfg := connection.ConnectionConfig{
|
||||||
|
Type: "tdengine",
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 6041,
|
||||||
|
User: "root",
|
||||||
|
Password: "taosdata",
|
||||||
|
Database: "power",
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := td.getDSN(cfg)
|
||||||
|
if !strings.HasPrefix(dsn, "root:taosdata@ws(127.0.0.1:6041)/power") {
|
||||||
|
t.Fatalf("tdengine dsn 格式不正确:%s", dsn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
628
internal/db/highgo_impl.go
Normal file
628
internal/db/highgo_impl.go
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
|
"GoNavi-Wails/internal/ssh"
|
||||||
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq" // HighGo is PostgreSQL compatible
|
||||||
|
)
|
||||||
|
|
||||||
|
// HighGoDB implements Database interface for HighGo (瀚高) database
|
||||||
|
// HighGo is a PostgreSQL-compatible database, so we reuse PostgreSQL driver
|
||||||
|
type HighGoDB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
pingTimeout time.Duration
|
||||||
|
forwarder *ssh.LocalForwarder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) getDSN(config connection.ConnectionConfig) string {
|
||||||
|
// postgres://user:password@host:port/dbname?sslmode=disable
|
||||||
|
dbname := config.Database
|
||||||
|
if dbname == "" {
|
||||||
|
dbname = "highgo" // HighGo default database
|
||||||
|
}
|
||||||
|
|
||||||
|
u := &url.URL{
|
||||||
|
Scheme: "postgres",
|
||||||
|
Host: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
|
||||||
|
Path: "/" + dbname,
|
||||||
|
}
|
||||||
|
u.User = url.UserPassword(config.User, config.Password)
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("sslmode", "disable")
|
||||||
|
q.Set("connect_timeout", strconv.Itoa(getConnectTimeoutSeconds(config)))
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) Connect(config connection.ConnectionConfig) error {
|
||||||
|
var dsn string
|
||||||
|
|
||||||
|
if config.UseSSH {
|
||||||
|
logger.Infof("HighGo 使用 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)
|
||||||
|
}
|
||||||
|
h.forwarder = forwarder
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
localConfig := config
|
||||||
|
localConfig.Host = host
|
||||||
|
localConfig.Port = port
|
||||||
|
localConfig.UseSSH = false
|
||||||
|
|
||||||
|
dsn = h.getDSN(localConfig)
|
||||||
|
logger.Infof("HighGo 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||||
|
} else {
|
||||||
|
dsn = h.getDSN(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||||
|
}
|
||||||
|
h.conn = db
|
||||||
|
h.pingTimeout = getConnectTimeout(config)
|
||||||
|
|
||||||
|
if err := h.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) Close() error {
|
||||||
|
if h.forwarder != nil {
|
||||||
|
if err := h.forwarder.Close(); err != nil {
|
||||||
|
logger.Warnf("关闭 HighGo SSH 端口转发失败:%v", err)
|
||||||
|
}
|
||||||
|
h.forwarder = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.conn != nil {
|
||||||
|
return h.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) Ping() error {
|
||||||
|
if h.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
timeout := h.pingTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||||
|
defer cancel()
|
||||||
|
return h.conn.PingContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if h.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if h.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.conn.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
if h.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := h.conn.ExecContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) Exec(query string) (int64, error) {
|
||||||
|
if h.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := h.conn.Exec(query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) GetDatabases() ([]string, error) {
|
||||||
|
data, _, err := h.Query("SELECT datname FROM pg_database WHERE datistemplate = false")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var dbs []string
|
||||||
|
for _, row := range data {
|
||||||
|
if val, ok := row["datname"]; ok {
|
||||||
|
dbs = append(dbs, fmt.Sprintf("%v", val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dbs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) GetTables(dbName string) ([]string, error) {
|
||||||
|
query := "SELECT schemaname, tablename FROM pg_catalog.pg_tables WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg_%' ORDER BY schemaname, tablename"
|
||||||
|
data, _, err := h.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tables []string
|
||||||
|
for _, row := range data {
|
||||||
|
schema, okSchema := row["schemaname"]
|
||||||
|
name, okName := row["tablename"]
|
||||||
|
if okSchema && okName {
|
||||||
|
tables = append(tables, fmt.Sprintf("%v.%v", schema, name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if okName {
|
||||||
|
tables = append(tables, fmt.Sprintf("%v", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||||
|
return fmt.Sprintf("-- SHOW CREATE TABLE not fully supported for HighGo in this version.\n-- Table: %s", tableName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HighGoDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
|
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 := h.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 (h *HighGoDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
|
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 := h.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:
|
||||||
|
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 (h *HighGoDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
|
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 := h.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 (h *HighGoDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
|
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 := h.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 (h *HighGoDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
|
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 := h.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 (h *HighGoDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||||
|
if h.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := h.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()
|
||||||
|
}
|
||||||
409
internal/db/mariadb_impl.go
Normal file
409
internal/db/mariadb_impl.go
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
|
"GoNavi-Wails/internal/ssh"
|
||||||
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MariaDB implements Database interface for MariaDB
|
||||||
|
// MariaDB is MySQL-compatible, so we reuse the MySQL driver
|
||||||
|
type MariaDB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
pingTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) getDSN(config connection.ConnectionConfig) string {
|
||||||
|
database := config.Database
|
||||||
|
protocol := "tcp"
|
||||||
|
address := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||||
|
|
||||||
|
if config.UseSSH {
|
||||||
|
netName, err := ssh.RegisterSSHNetwork(config.SSH)
|
||||||
|
if err == nil {
|
||||||
|
protocol = netName
|
||||||
|
address = fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||||
|
} else {
|
||||||
|
logger.Warnf("注册 SSH 网络失败,将尝试直连:地址=%s:%d 用户=%s,原因:%v", config.Host, config.Port, config.User, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := getConnectTimeoutSeconds(config)
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds",
|
||||||
|
config.User, config.Password, protocol, address, database, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) Connect(config connection.ConnectionConfig) error {
|
||||||
|
dsn := m.getDSN(config)
|
||||||
|
db, err := sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||||
|
}
|
||||||
|
m.conn = db
|
||||||
|
m.pingTimeout = getConnectTimeout(config)
|
||||||
|
|
||||||
|
if err := m.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) Close() error {
|
||||||
|
if m.conn != nil {
|
||||||
|
return m.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) Ping() error {
|
||||||
|
if m.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
timeout := m.pingTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||||
|
defer cancel()
|
||||||
|
return m.conn.PingContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) 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 *MariaDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if m.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := m.conn.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) 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 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) Exec(query string) (int64, error) {
|
||||||
|
if m.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := m.conn.Exec(query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) GetDatabases() ([]string, error) {
|
||||||
|
data, _, err := m.Query("SHOW DATABASES")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var dbs []string
|
||||||
|
for _, row := range data {
|
||||||
|
if val, ok := row["Database"]; ok {
|
||||||
|
dbs = append(dbs, fmt.Sprintf("%v", val))
|
||||||
|
} else if val, ok := row["database"]; ok {
|
||||||
|
dbs = append(dbs, fmt.Sprintf("%v", val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dbs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) GetTables(dbName string) ([]string, error) {
|
||||||
|
query := "SHOW TABLES"
|
||||||
|
if dbName != "" {
|
||||||
|
query = fmt.Sprintf("SHOW TABLES FROM `%s`", dbName)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _, err := m.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tables []string
|
||||||
|
for _, row := range data {
|
||||||
|
for _, v := range row {
|
||||||
|
tables = append(tables, fmt.Sprintf("%v", v))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||||
|
query := fmt.Sprintf("SHOW CREATE TABLE `%s`.`%s`", dbName, tableName)
|
||||||
|
if dbName == "" {
|
||||||
|
query = fmt.Sprintf("SHOW CREATE TABLE `%s`", tableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _, err := m.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) > 0 {
|
||||||
|
if val, ok := data[0]["Create Table"]; ok {
|
||||||
|
return fmt.Sprintf("%v", val), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("create statement not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
|
query := fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`.`%s`", dbName, tableName)
|
||||||
|
if dbName == "" {
|
||||||
|
query = fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`", tableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _, err := m.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var columns []connection.ColumnDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
col := connection.ColumnDefinition{
|
||||||
|
Name: fmt.Sprintf("%v", row["Field"]),
|
||||||
|
Type: fmt.Sprintf("%v", row["Type"]),
|
||||||
|
Nullable: fmt.Sprintf("%v", row["Null"]),
|
||||||
|
Key: fmt.Sprintf("%v", row["Key"]),
|
||||||
|
Extra: fmt.Sprintf("%v", row["Extra"]),
|
||||||
|
Comment: fmt.Sprintf("%v", row["Comment"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
if row["Default"] != nil {
|
||||||
|
d := fmt.Sprintf("%v", row["Default"])
|
||||||
|
col.Default = &d
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = append(columns, col)
|
||||||
|
}
|
||||||
|
return columns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
|
query := fmt.Sprintf("SHOW INDEX FROM `%s`.`%s`", dbName, tableName)
|
||||||
|
if dbName == "" {
|
||||||
|
query = fmt.Sprintf("SHOW INDEX FROM `%s`", tableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _, err := m.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexes []connection.IndexDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
nonUnique := 0
|
||||||
|
if val, ok := row["Non_unique"]; ok {
|
||||||
|
if f, ok := val.(float64); ok {
|
||||||
|
nonUnique = int(f)
|
||||||
|
} else if i, ok := val.(int64); ok {
|
||||||
|
nonUnique = int(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := 0
|
||||||
|
if val, ok := row["Seq_in_index"]; ok {
|
||||||
|
if f, ok := val.(float64); ok {
|
||||||
|
seq = int(f)
|
||||||
|
} else if i, ok := val.(int64); ok {
|
||||||
|
seq = int(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := connection.IndexDefinition{
|
||||||
|
Name: fmt.Sprintf("%v", row["Key_name"]),
|
||||||
|
ColumnName: fmt.Sprintf("%v", row["Column_name"]),
|
||||||
|
NonUnique: nonUnique,
|
||||||
|
SeqInIndex: seq,
|
||||||
|
IndexType: fmt.Sprintf("%v", row["Index_type"]),
|
||||||
|
}
|
||||||
|
indexes = append(indexes, idx)
|
||||||
|
}
|
||||||
|
return indexes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
|
query := fmt.Sprintf(`SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
||||||
|
FROM information_schema.KEY_COLUMN_USAGE
|
||||||
|
WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s' AND REFERENCED_TABLE_NAME IS NOT NULL`, dbName, tableName)
|
||||||
|
|
||||||
|
data, _, err := m.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var fks []connection.ForeignKeyDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
fk := connection.ForeignKeyDefinition{
|
||||||
|
Name: fmt.Sprintf("%v", row["CONSTRAINT_NAME"]),
|
||||||
|
ColumnName: fmt.Sprintf("%v", row["COLUMN_NAME"]),
|
||||||
|
RefTableName: fmt.Sprintf("%v", row["REFERENCED_TABLE_NAME"]),
|
||||||
|
RefColumnName: fmt.Sprintf("%v", row["REFERENCED_COLUMN_NAME"]),
|
||||||
|
ConstraintName: fmt.Sprintf("%v", row["CONSTRAINT_NAME"]),
|
||||||
|
}
|
||||||
|
fks = append(fks, fk)
|
||||||
|
}
|
||||||
|
return fks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
|
query := fmt.Sprintf("SHOW TRIGGERS FROM `%s` WHERE `Table` = '%s'", dbName, tableName)
|
||||||
|
data, _, err := m.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"]),
|
||||||
|
Timing: fmt.Sprintf("%v", row["Timing"]),
|
||||||
|
Event: fmt.Sprintf("%v", row["Event"]),
|
||||||
|
Statement: fmt.Sprintf("%v", row["Statement"]),
|
||||||
|
}
|
||||||
|
triggers = append(triggers, trig)
|
||||||
|
}
|
||||||
|
return triggers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||||
|
if m.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := m.conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// 1. Deletes
|
||||||
|
for _, pk := range changes.Deletes {
|
||||||
|
var wheres []string
|
||||||
|
var args []interface{}
|
||||||
|
for k, v := range pk {
|
||||||
|
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||||
|
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||||
|
}
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("DELETE FROM `%s` WHERE %s", tableName, 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` = ?", k))
|
||||||
|
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wheres []string
|
||||||
|
for k, v := range update.Keys {
|
||||||
|
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||||
|
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wheres) == 0 {
|
||||||
|
return fmt.Errorf("update requires keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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, fmt.Sprintf("`%s`", k))
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cols) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)", tableName, 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 (m *MariaDB) 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)
|
||||||
|
if dbName == "" {
|
||||||
|
return nil, fmt.Errorf("database name required for GetAllColumns")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _, err := m.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cols []connection.ColumnDefinitionWithTable
|
||||||
|
for _, row := range data {
|
||||||
|
col := connection.ColumnDefinitionWithTable{
|
||||||
|
TableName: fmt.Sprintf("%v", row["TABLE_NAME"]),
|
||||||
|
Name: fmt.Sprintf("%v", row["COLUMN_NAME"]),
|
||||||
|
Type: fmt.Sprintf("%v", row["COLUMN_TYPE"]),
|
||||||
|
}
|
||||||
|
cols = append(cols, col)
|
||||||
|
}
|
||||||
|
return cols, nil
|
||||||
|
}
|
||||||
407
internal/db/mongodb_impl.go
Normal file
407
internal/db/mongodb_impl.go
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
|
"GoNavi-Wails/internal/ssh"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MongoDB struct {
|
||||||
|
client *mongo.Client
|
||||||
|
database string
|
||||||
|
pingTimeout time.Duration
|
||||||
|
forwarder *ssh.LocalForwarder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
|
||||||
|
// mongodb://user:password@host:port/database?authSource=admin
|
||||||
|
host := config.Host
|
||||||
|
port := config.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 27017
|
||||||
|
}
|
||||||
|
|
||||||
|
uri := fmt.Sprintf("mongodb://%s:%d", host, port)
|
||||||
|
|
||||||
|
if config.User != "" {
|
||||||
|
encodedUser := url.QueryEscape(config.User)
|
||||||
|
if config.Password != "" {
|
||||||
|
encodedPass := url.QueryEscape(config.Password)
|
||||||
|
uri = fmt.Sprintf("mongodb://%s:%s@%s:%d", encodedUser, encodedPass, host, port)
|
||||||
|
} else {
|
||||||
|
uri = fmt.Sprintf("mongodb://%s@%s:%d", encodedUser, host, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add connection options
|
||||||
|
params := []string{}
|
||||||
|
timeout := getConnectTimeoutSeconds(config)
|
||||||
|
params = append(params, fmt.Sprintf("connectTimeoutMS=%d", timeout*1000))
|
||||||
|
params = append(params, fmt.Sprintf("serverSelectionTimeoutMS=%d", timeout*1000))
|
||||||
|
|
||||||
|
// authSource: 优先使用 config.Database,为空时默认 admin
|
||||||
|
authSource := "admin"
|
||||||
|
if config.Database != "" {
|
||||||
|
authSource = config.Database
|
||||||
|
}
|
||||||
|
params = append(params, fmt.Sprintf("authSource=%s", authSource))
|
||||||
|
|
||||||
|
if len(params) > 0 {
|
||||||
|
uri = uri + "/?" + strings.Join(params, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
|
||||||
|
var uri string
|
||||||
|
|
||||||
|
if config.UseSSH {
|
||||||
|
logger.Infof("MongoDB 使用 SSH 连接:地址=%s:%d", config.Host, config.Port)
|
||||||
|
|
||||||
|
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
|
||||||
|
}
|
||||||
|
m.forwarder = forwarder
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
localConfig := config
|
||||||
|
localConfig.Host = host
|
||||||
|
localConfig.Port = port
|
||||||
|
localConfig.UseSSH = false
|
||||||
|
|
||||||
|
uri = m.getURI(localConfig)
|
||||||
|
logger.Infof("MongoDB 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||||
|
} else {
|
||||||
|
uri = m.getURI(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.pingTimeout = getConnectTimeout(config)
|
||||||
|
m.database = config.Database
|
||||||
|
if m.database == "" {
|
||||||
|
m.database = "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
clientOpts := options.Client().ApplyURI(uri)
|
||||||
|
client, err := mongo.Connect(clientOpts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("MongoDB 连接失败:%w", err)
|
||||||
|
}
|
||||||
|
m.client = client
|
||||||
|
|
||||||
|
if err := m.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("MongoDB 连接验证失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoDB) Close() error {
|
||||||
|
if m.forwarder != nil {
|
||||||
|
if err := m.forwarder.Close(); err != nil {
|
||||||
|
logger.Warnf("关闭 MongoDB SSH 端口转发失败:%v", err)
|
||||||
|
}
|
||||||
|
m.forwarder = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.client != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return m.client.Disconnect(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoDB) Ping() error {
|
||||||
|
if m.client == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
timeout := m.pingTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
return m.client.Ping(ctx, readpref.Primary())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query executes a MongoDB command and returns results
|
||||||
|
// Supports JSON format commands like: {"find": "collection", "filter": {}}
|
||||||
|
func (m *MongoDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return m.queryWithContext(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryContext executes a MongoDB command with the given context for timeout control
|
||||||
|
func (m *MongoDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
return m.queryWithContext(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if m.client == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
if query == "" {
|
||||||
|
return nil, nil, fmt.Errorf("empty query")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON command
|
||||||
|
var cmd bson.D
|
||||||
|
if err := bson.UnmarshalExtJSON([]byte(query), true, &cmd); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("invalid JSON command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := m.client.Database(m.database)
|
||||||
|
var result bson.M
|
||||||
|
if err := db.RunCommand(ctx, cmd).Decode(&result); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert result to standard format
|
||||||
|
data := []map[string]interface{}{{"result": result}}
|
||||||
|
columns := []string{"result"}
|
||||||
|
|
||||||
|
// If result contains cursor with documents, extract them
|
||||||
|
if cursor, ok := result["cursor"].(bson.M); ok {
|
||||||
|
if batch, ok := cursor["firstBatch"].(bson.A); ok {
|
||||||
|
data = make([]map[string]interface{}, 0, len(batch))
|
||||||
|
columnSet := make(map[string]bool)
|
||||||
|
for _, doc := range batch {
|
||||||
|
if docMap, ok := doc.(bson.M); ok {
|
||||||
|
row := make(map[string]interface{})
|
||||||
|
for k, v := range docMap {
|
||||||
|
row[k] = v
|
||||||
|
columnSet[k] = true
|
||||||
|
}
|
||||||
|
data = append(data, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
columns = make([]string, 0, len(columnSet))
|
||||||
|
for k := range columnSet {
|
||||||
|
columns = append(columns, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, columns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoDB) Exec(query string) (int64, error) {
|
||||||
|
_, _, err := m.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecContext executes a MongoDB command with the given context for timeout control
|
||||||
|
func (m *MongoDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
_, _, err := m.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoDB) GetDatabases() ([]string, error) {
|
||||||
|
if m.client == nil {
|
||||||
|
return nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dbs, err := m.client.ListDatabaseNames(ctx, bson.M{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dbs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoDB) GetTables(dbName string) ([]string, error) {
|
||||||
|
if m.client == nil {
|
||||||
|
return nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
targetDB := dbName
|
||||||
|
if targetDB == "" {
|
||||||
|
targetDB = m.database
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
collections, err := m.client.Database(targetDB).ListCollectionNames(ctx, bson.M{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return collections, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||||
|
return fmt.Sprintf("// MongoDB collection: %s.%s\n// MongoDB is schemaless - no CREATE statement available", dbName, tableName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetColumns returns empty for MongoDB (schemaless)
|
||||||
|
func (m *MongoDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
|
// MongoDB is schemaless, return empty
|
||||||
|
return []connection.ColumnDefinition{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllColumns returns empty for MongoDB (schemaless)
|
||||||
|
func (m *MongoDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
|
return []connection.ColumnDefinitionWithTable{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIndexes returns indexes for a MongoDB collection
|
||||||
|
func (m *MongoDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
|
if m.client == nil {
|
||||||
|
return nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
targetDB := dbName
|
||||||
|
if targetDB == "" {
|
||||||
|
targetDB = m.database
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
collection := m.client.Database(targetDB).Collection(tableName)
|
||||||
|
cursor, err := collection.Indexes().List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var indexes []connection.IndexDefinition
|
||||||
|
for cursor.Next(ctx) {
|
||||||
|
var idx bson.M
|
||||||
|
if err := cursor.Decode(&idx); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := fmt.Sprintf("%v", idx["name"])
|
||||||
|
unique := false
|
||||||
|
if u, ok := idx["unique"].(bool); ok {
|
||||||
|
unique = u
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key fields
|
||||||
|
if key, ok := idx["key"].(bson.M); ok {
|
||||||
|
seq := 1
|
||||||
|
for field := range key {
|
||||||
|
nonUnique := 1
|
||||||
|
if unique {
|
||||||
|
nonUnique = 0
|
||||||
|
}
|
||||||
|
indexes = append(indexes, connection.IndexDefinition{
|
||||||
|
Name: name,
|
||||||
|
ColumnName: field,
|
||||||
|
NonUnique: nonUnique,
|
||||||
|
SeqInIndex: seq,
|
||||||
|
IndexType: "BTREE",
|
||||||
|
})
|
||||||
|
seq++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
|
// MongoDB doesn't have foreign keys
|
||||||
|
return []connection.ForeignKeyDefinition{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MongoDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
|
// MongoDB doesn't have triggers in the traditional sense
|
||||||
|
return []connection.TriggerDefinition{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyChanges implements batch changes for MongoDB
|
||||||
|
func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||||
|
if m.client == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
collection := m.client.Database(m.database).Collection(tableName)
|
||||||
|
|
||||||
|
// Process deletes
|
||||||
|
for _, pk := range changes.Deletes {
|
||||||
|
filter := bson.M{}
|
||||||
|
for k, v := range pk {
|
||||||
|
filter[k] = v
|
||||||
|
}
|
||||||
|
if len(filter) > 0 {
|
||||||
|
if _, err := collection.DeleteOne(ctx, filter); err != nil {
|
||||||
|
return fmt.Errorf("delete error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process updates
|
||||||
|
for _, update := range changes.Updates {
|
||||||
|
filter := bson.M{}
|
||||||
|
for k, v := range update.Keys {
|
||||||
|
filter[k] = v
|
||||||
|
}
|
||||||
|
if len(filter) == 0 {
|
||||||
|
return fmt.Errorf("update requires keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDoc := bson.M{"$set": bson.M{}}
|
||||||
|
for k, v := range update.Values {
|
||||||
|
updateDoc["$set"].(bson.M)[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := collection.UpdateOne(ctx, filter, updateDoc); err != nil {
|
||||||
|
return fmt.Errorf("update error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process inserts
|
||||||
|
for _, row := range changes.Inserts {
|
||||||
|
doc := bson.M{}
|
||||||
|
for k, v := range row {
|
||||||
|
doc[k] = v
|
||||||
|
}
|
||||||
|
if len(doc) > 0 {
|
||||||
|
if _, err := collection.InsertOne(ctx, doc); err != nil {
|
||||||
|
return fmt.Errorf("insert error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
635
internal/db/sqlserver_impl.go
Normal file
635
internal/db/sqlserver_impl.go
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
|
"GoNavi-Wails/internal/ssh"
|
||||||
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
|
_ "github.com/microsoft/go-mssqldb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SqlServerDB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
pingTimeout time.Duration
|
||||||
|
forwarder *ssh.LocalForwarder
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoteBracket escapes ] in identifiers for safe use in SQL Server [bracket] notation
|
||||||
|
func quoteBracket(name string) string {
|
||||||
|
return strings.ReplaceAll(name, "]", "]]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) getDSN(config connection.ConnectionConfig) string {
|
||||||
|
// sqlserver://user:password@host:port?database=dbname
|
||||||
|
dbname := config.Database
|
||||||
|
if dbname == "" {
|
||||||
|
dbname = "master"
|
||||||
|
}
|
||||||
|
|
||||||
|
u := &url.URL{
|
||||||
|
Scheme: "sqlserver",
|
||||||
|
Host: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
|
||||||
|
}
|
||||||
|
u.User = url.UserPassword(config.User, config.Password)
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("database", dbname)
|
||||||
|
q.Set("connection timeout", strconv.Itoa(getConnectTimeoutSeconds(config)))
|
||||||
|
q.Set("encrypt", "disable")
|
||||||
|
q.Set("TrustServerCertificate", "true")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) Connect(config connection.ConnectionConfig) error {
|
||||||
|
var dsn string
|
||||||
|
|
||||||
|
if config.UseSSH {
|
||||||
|
logger.Infof("SQL Server 使用 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)
|
||||||
|
}
|
||||||
|
s.forwarder = forwarder
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
localConfig := config
|
||||||
|
localConfig.Host = host
|
||||||
|
localConfig.Port = port
|
||||||
|
localConfig.UseSSH = false
|
||||||
|
|
||||||
|
dsn = s.getDSN(localConfig)
|
||||||
|
logger.Infof("SQL Server 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||||
|
} else {
|
||||||
|
dsn = s.getDSN(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlserver", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||||
|
}
|
||||||
|
s.conn = db
|
||||||
|
s.pingTimeout = getConnectTimeout(config)
|
||||||
|
|
||||||
|
if err := s.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) Close() error {
|
||||||
|
if s.forwarder != nil {
|
||||||
|
if err := s.forwarder.Close(); err != nil {
|
||||||
|
logger.Warnf("关闭 SQL Server SSH 端口转发失败:%v", err)
|
||||||
|
}
|
||||||
|
s.forwarder = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.conn != nil {
|
||||||
|
return s.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) Ping() error {
|
||||||
|
if s.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
timeout := s.pingTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||||
|
defer cancel()
|
||||||
|
return s.conn.PingContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) 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 *SqlServerDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if s.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.conn.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) 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 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) Exec(query string) (int64, error) {
|
||||||
|
if s.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := s.conn.Exec(query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) GetDatabases() ([]string, error) {
|
||||||
|
query := "SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' ORDER BY name"
|
||||||
|
data, _, err := s.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var dbs []string
|
||||||
|
for _, row := range data {
|
||||||
|
if val, ok := row["name"]; ok {
|
||||||
|
dbs = append(dbs, fmt.Sprintf("%v", val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dbs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) GetTables(dbName string) ([]string, error) {
|
||||||
|
// SQL Server uses schema.table format, default schema is dbo
|
||||||
|
safeDB := quoteBracket(dbName)
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT s.name AS schema_name, t.name AS table_name
|
||||||
|
FROM [%s].sys.tables t
|
||||||
|
JOIN [%s].sys.schemas s ON t.schema_id = s.schema_id
|
||||||
|
WHERE t.type = 'U'
|
||||||
|
ORDER BY s.name, t.name`, safeDB, safeDB)
|
||||||
|
|
||||||
|
data, _, err := s.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tables []string
|
||||||
|
for _, row := range data {
|
||||||
|
schema, okSchema := row["schema_name"]
|
||||||
|
name, okName := row["table_name"]
|
||||||
|
if okSchema && okName {
|
||||||
|
tables = append(tables, fmt.Sprintf("%v.%v", schema, name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if okName {
|
||||||
|
tables = append(tables, fmt.Sprintf("%v", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||||
|
return fmt.Sprintf("-- SHOW CREATE TABLE not supported for SQL Server in this version.\n-- Table: %s.%s", dbName, tableName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
|
schema := "dbo"
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
schema = strings.TrimSpace(parts[0])
|
||||||
|
table = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||||
|
safeDB := quoteBracket(dbName)
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
c.name AS column_name,
|
||||||
|
t.name + CASE
|
||||||
|
WHEN t.name IN ('varchar', 'nvarchar', 'char', 'nchar') THEN '(' + CASE WHEN c.max_length = -1 THEN 'MAX' ELSE CAST(CASE WHEN t.name IN ('nvarchar', 'nchar') THEN c.max_length / 2 ELSE c.max_length END AS VARCHAR) END + ')'
|
||||||
|
WHEN t.name IN ('decimal', 'numeric') THEN '(' + CAST(c.precision AS VARCHAR) + ',' + CAST(c.scale AS VARCHAR) + ')'
|
||||||
|
ELSE ''
|
||||||
|
END AS data_type,
|
||||||
|
CASE WHEN c.is_nullable = 1 THEN 'YES' ELSE 'NO' END AS is_nullable,
|
||||||
|
dc.definition AS column_default,
|
||||||
|
ep.value AS comment,
|
||||||
|
CASE WHEN pk.column_id IS NOT NULL THEN 'PRI' ELSE '' END AS column_key,
|
||||||
|
CASE WHEN c.is_identity = 1 THEN 'auto_increment' ELSE '' END AS extra
|
||||||
|
FROM [%s].sys.columns c
|
||||||
|
JOIN [%s].sys.types t ON c.user_type_id = t.user_type_id
|
||||||
|
JOIN [%s].sys.tables tb ON c.object_id = tb.object_id
|
||||||
|
JOIN [%s].sys.schemas s ON tb.schema_id = s.schema_id
|
||||||
|
LEFT JOIN [%s].sys.default_constraints dc ON c.default_object_id = dc.object_id
|
||||||
|
LEFT JOIN [%s].sys.extended_properties ep ON ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = 'MS_Description'
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT ic.object_id, ic.column_id
|
||||||
|
FROM [%s].sys.index_columns ic
|
||||||
|
JOIN [%s].sys.indexes i ON ic.object_id = i.object_id AND ic.index_id = i.index_id
|
||||||
|
WHERE i.is_primary_key = 1
|
||||||
|
) pk ON pk.object_id = c.object_id AND pk.column_id = c.column_id
|
||||||
|
WHERE s.name = '%s' AND tb.name = '%s'
|
||||||
|
ORDER BY c.column_id`,
|
||||||
|
safeDB, safeDB, safeDB, safeDB, safeDB, safeDB, safeDB, safeDB,
|
||||||
|
esc(schema), esc(table))
|
||||||
|
|
||||||
|
data, _, err := s.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: fmt.Sprintf("%v", row["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
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = append(columns, col)
|
||||||
|
}
|
||||||
|
return columns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
|
safeDB := quoteBracket(dbName)
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT s.name AS schema_name, t.name AS table_name, c.name AS column_name, tp.name AS data_type
|
||||||
|
FROM [%s].sys.columns c
|
||||||
|
JOIN [%s].sys.tables t ON c.object_id = t.object_id
|
||||||
|
JOIN [%s].sys.schemas s ON t.schema_id = s.schema_id
|
||||||
|
JOIN [%s].sys.types tp ON c.user_type_id = tp.user_type_id
|
||||||
|
WHERE t.type = 'U'
|
||||||
|
ORDER BY s.name, t.name, c.column_id`, safeDB, safeDB, safeDB, safeDB)
|
||||||
|
|
||||||
|
data, _, err := s.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cols []connection.ColumnDefinitionWithTable
|
||||||
|
for _, row := range data {
|
||||||
|
schema := fmt.Sprintf("%v", row["schema_name"])
|
||||||
|
table := fmt.Sprintf("%v", row["table_name"])
|
||||||
|
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 (s *SqlServerDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
|
schema := "dbo"
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
schema = strings.TrimSpace(parts[0])
|
||||||
|
table = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||||
|
safeDB := quoteBracket(dbName)
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
i.name AS index_name,
|
||||||
|
c.name AS column_name,
|
||||||
|
i.is_unique,
|
||||||
|
ic.key_ordinal AS seq_in_index,
|
||||||
|
i.type_desc AS index_type
|
||||||
|
FROM [%s].sys.indexes i
|
||||||
|
JOIN [%s].sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
||||||
|
JOIN [%s].sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
|
||||||
|
JOIN [%s].sys.tables t ON i.object_id = t.object_id
|
||||||
|
JOIN [%s].sys.schemas s ON t.schema_id = s.schema_id
|
||||||
|
WHERE s.name = '%s' AND t.name = '%s' AND i.name IS NOT NULL
|
||||||
|
ORDER BY i.name, ic.key_ordinal`,
|
||||||
|
safeDB, safeDB, safeDB, safeDB, safeDB, esc(schema), esc(table))
|
||||||
|
|
||||||
|
data, _, err := s.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexes []connection.IndexDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
isUnique := false
|
||||||
|
if v, ok := row["is_unique"]; ok && v != nil {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case bool:
|
||||||
|
isUnique = val
|
||||||
|
case int64:
|
||||||
|
isUnique = val == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonUnique := 1
|
||||||
|
if isUnique {
|
||||||
|
nonUnique = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := 0
|
||||||
|
if v, ok := row["seq_in_index"]; ok && v != nil {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case int:
|
||||||
|
seq = val
|
||||||
|
case int64:
|
||||||
|
seq = int(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexType := "NONCLUSTERED"
|
||||||
|
if v, ok := row["index_type"]; ok && v != nil {
|
||||||
|
indexType = strings.ToUpper(fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (s *SqlServerDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
|
schema := "dbo"
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
schema = strings.TrimSpace(parts[0])
|
||||||
|
table = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||||
|
safeDB := quoteBracket(dbName)
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
fk.name AS constraint_name,
|
||||||
|
c.name AS column_name,
|
||||||
|
rs.name AS foreign_schema,
|
||||||
|
rt.name AS foreign_table,
|
||||||
|
rc.name AS foreign_column
|
||||||
|
FROM [%s].sys.foreign_keys fk
|
||||||
|
JOIN [%s].sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id
|
||||||
|
JOIN [%s].sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id
|
||||||
|
JOIN [%s].sys.tables t ON fk.parent_object_id = t.object_id
|
||||||
|
JOIN [%s].sys.schemas s ON t.schema_id = s.schema_id
|
||||||
|
JOIN [%s].sys.tables rt ON fk.referenced_object_id = rt.object_id
|
||||||
|
JOIN [%s].sys.schemas rs ON rt.schema_id = rs.schema_id
|
||||||
|
JOIN [%s].sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id
|
||||||
|
WHERE s.name = '%s' AND t.name = '%s'
|
||||||
|
ORDER BY fk.name`,
|
||||||
|
safeDB, safeDB, safeDB, safeDB, safeDB, safeDB, safeDB, safeDB, esc(schema), esc(table))
|
||||||
|
|
||||||
|
data, _, err := s.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var fks []connection.ForeignKeyDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
refSchema := fmt.Sprintf("%v", row["foreign_schema"])
|
||||||
|
refTable := fmt.Sprintf("%v", row["foreign_table"])
|
||||||
|
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"]),
|
||||||
|
ConstraintName: fmt.Sprintf("%v", row["constraint_name"]),
|
||||||
|
}
|
||||||
|
fks = append(fks, fk)
|
||||||
|
}
|
||||||
|
return fks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
|
schema := "dbo"
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
schema = strings.TrimSpace(parts[0])
|
||||||
|
table = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if table == "" {
|
||||||
|
return nil, fmt.Errorf("table name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||||
|
safeDB := quoteBracket(dbName)
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
tr.name AS trigger_name,
|
||||||
|
CASE WHEN tr.is_instead_of_trigger = 1 THEN 'INSTEAD OF' ELSE 'AFTER' END AS timing,
|
||||||
|
STUFF((
|
||||||
|
SELECT ', ' + te.type_desc
|
||||||
|
FROM [%s].sys.trigger_events te
|
||||||
|
WHERE te.object_id = tr.object_id
|
||||||
|
FOR XML PATH('')
|
||||||
|
), 1, 2, '') AS event,
|
||||||
|
OBJECT_DEFINITION(tr.object_id) AS statement
|
||||||
|
FROM [%s].sys.triggers tr
|
||||||
|
JOIN [%s].sys.tables t ON tr.parent_id = t.object_id
|
||||||
|
JOIN [%s].sys.schemas s ON t.schema_id = s.schema_id
|
||||||
|
WHERE s.name = '%s' AND t.name = '%s'
|
||||||
|
ORDER BY tr.name`,
|
||||||
|
safeDB, safeDB, safeDB, safeDB, esc(schema), esc(table))
|
||||||
|
|
||||||
|
data, _, err := s.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["timing"]),
|
||||||
|
Event: fmt.Sprintf("%v", row["event"]),
|
||||||
|
Statement: "",
|
||||||
|
}
|
||||||
|
if v, ok := row["statement"]; ok && v != nil {
|
||||||
|
trig.Statement = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
triggers = append(triggers, trig)
|
||||||
|
}
|
||||||
|
return triggers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SqlServerDB) 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 := "dbo"
|
||||||
|
table := strings.TrimSpace(tableName)
|
||||||
|
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||||
|
schema = strings.TrimSpace(parts[0])
|
||||||
|
table = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedTable := fmt.Sprintf("%s.%s", quoteIdent(schema), 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 = @p%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, sql.Named(fmt.Sprintf("p%d", idx), 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 = @p%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, sql.Named(fmt.Sprintf("p%d", idx), v))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wheres []string
|
||||||
|
for k, v := range update.Keys {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = @p%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, sql.Named(fmt.Sprintf("p%d", idx), 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("@p%d", idx))
|
||||||
|
args = append(args, sql.Named(fmt.Sprintf("p%d", idx), 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()
|
||||||
|
}
|
||||||
398
internal/db/tdengine_impl.go
Normal file
398
internal/db/tdengine_impl.go
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
|
"GoNavi-Wails/internal/ssh"
|
||||||
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
|
_ "github.com/taosdata/driver-go/v3/taosWS"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TDengineDB implements Database interface for TDengine.
|
||||||
|
// Uses taosWS driver via WebSocket (通常通过 taosAdapter 提供服务)。
|
||||||
|
type TDengineDB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
pingTimeout time.Duration
|
||||||
|
forwarder *ssh.LocalForwarder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) getDSN(config connection.ConnectionConfig) string {
|
||||||
|
user := strings.TrimSpace(config.User)
|
||||||
|
if user == "" {
|
||||||
|
user = "root"
|
||||||
|
}
|
||||||
|
|
||||||
|
pass := config.Password
|
||||||
|
dbName := strings.TrimSpace(config.Database)
|
||||||
|
path := "/"
|
||||||
|
if dbName != "" {
|
||||||
|
path = "/" + dbName
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s:%s@ws(%s)%s", user, pass, net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) Connect(config connection.ConnectionConfig) error {
|
||||||
|
var dsn string
|
||||||
|
|
||||||
|
if config.UseSSH {
|
||||||
|
logger.Infof("TDengine 使用 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)
|
||||||
|
}
|
||||||
|
t.forwarder = forwarder
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
localConfig := config
|
||||||
|
localConfig.Host = host
|
||||||
|
localConfig.Port = port
|
||||||
|
localConfig.UseSSH = false
|
||||||
|
dsn = t.getDSN(localConfig)
|
||||||
|
logger.Infof("TDengine 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||||
|
} else {
|
||||||
|
dsn = t.getDSN(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("taosWS", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||||
|
}
|
||||||
|
t.conn = db
|
||||||
|
t.pingTimeout = getConnectTimeout(config)
|
||||||
|
|
||||||
|
if err := t.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) Close() error {
|
||||||
|
if t.forwarder != nil {
|
||||||
|
if err := t.forwarder.Close(); err != nil {
|
||||||
|
logger.Warnf("关闭 TDengine SSH 端口转发失败:%v", err)
|
||||||
|
}
|
||||||
|
t.forwarder = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.conn != nil {
|
||||||
|
return t.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) Ping() error {
|
||||||
|
if t.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
timeout := t.pingTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||||
|
defer cancel()
|
||||||
|
return t.conn.PingContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if t.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := t.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if t.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := t.conn.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
if t.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := t.conn.ExecContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) Exec(query string) (int64, error) {
|
||||||
|
if t.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := t.conn.Exec(query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) GetDatabases() ([]string, error) {
|
||||||
|
data, _, err := t.Query("SHOW DATABASES")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbs []string
|
||||||
|
for _, row := range data {
|
||||||
|
if val, ok := getValueFromRow(row, "name", "database", "Database", "db_name"); ok {
|
||||||
|
dbs = append(dbs, fmt.Sprintf("%v", val))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, val := range row {
|
||||||
|
dbs = append(dbs, fmt.Sprintf("%v", val))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dbs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) GetTables(dbName string) ([]string, error) {
|
||||||
|
queries := make([]string, 0, 2)
|
||||||
|
if strings.TrimSpace(dbName) != "" {
|
||||||
|
queries = append(queries, fmt.Sprintf("SHOW TABLES FROM `%s`", escapeBacktickIdent(dbName)))
|
||||||
|
}
|
||||||
|
queries = append(queries, "SHOW TABLES")
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, query := range queries {
|
||||||
|
data, _, err := t.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var tables []string
|
||||||
|
for _, row := range data {
|
||||||
|
if val, ok := getValueFromRow(row, "table_name", "tablename", "name", "Table", "table"); ok {
|
||||||
|
tables = append(tables, fmt.Sprintf("%v", val))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, val := range row {
|
||||||
|
tables = append(tables, fmt.Sprintf("%v", val))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||||
|
qualified := quoteTDengineTable(dbName, tableName)
|
||||||
|
queries := []string{
|
||||||
|
fmt.Sprintf("SHOW CREATE TABLE %s", qualified),
|
||||||
|
fmt.Sprintf("SHOW CREATE STABLE %s", qualified),
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, query := range queries {
|
||||||
|
data, _, err := t.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
row := data[0]
|
||||||
|
if val, ok := getValueFromRow(row, "Create Table", "create table", "Create Stable", "create stable", "SQL", "sql"); ok {
|
||||||
|
return fmt.Sprintf("%v", val), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
longest := ""
|
||||||
|
for _, val := range row {
|
||||||
|
text := fmt.Sprintf("%v", val)
|
||||||
|
if strings.Contains(strings.ToUpper(text), "CREATE ") && len(text) > len(longest) {
|
||||||
|
longest = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if longest != "" {
|
||||||
|
return longest, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", lastErr
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("create statement not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
|
query := fmt.Sprintf("DESCRIBE %s", quoteTDengineTable(dbName, tableName))
|
||||||
|
data, _, err := t.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
columns := make([]connection.ColumnDefinition, 0, len(data))
|
||||||
|
for _, row := range data {
|
||||||
|
name, _ := getValueFromRow(row, "Field", "field", "col_name", "column_name", "name")
|
||||||
|
colType, _ := getValueFromRow(row, "Type", "type", "data_type")
|
||||||
|
note, _ := getValueFromRow(row, "Note", "note", "Extra", "extra")
|
||||||
|
nullable, okNull := getValueFromRow(row, "Null", "null", "nullable")
|
||||||
|
comment, _ := getValueFromRow(row, "Comment", "comment")
|
||||||
|
defaultVal, hasDefault := getValueFromRow(row, "Default", "default")
|
||||||
|
|
||||||
|
col := connection.ColumnDefinition{
|
||||||
|
Name: fmt.Sprintf("%v", name),
|
||||||
|
Type: fmt.Sprintf("%v", colType),
|
||||||
|
Nullable: "YES",
|
||||||
|
Key: "",
|
||||||
|
Extra: fmt.Sprintf("%v", note),
|
||||||
|
Comment: fmt.Sprintf("%v", comment),
|
||||||
|
}
|
||||||
|
|
||||||
|
if okNull {
|
||||||
|
col.Nullable = strings.ToUpper(fmt.Sprintf("%v", nullable))
|
||||||
|
}
|
||||||
|
|
||||||
|
noteUpper := strings.ToUpper(fmt.Sprintf("%v", note))
|
||||||
|
if strings.Contains(noteUpper, "TAG") {
|
||||||
|
col.Key = "TAG"
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasDefault && defaultVal != nil {
|
||||||
|
def := fmt.Sprintf("%v", defaultVal)
|
||||||
|
if def != "<nil>" {
|
||||||
|
col.Default = &def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = append(columns, col)
|
||||||
|
}
|
||||||
|
return columns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
|
if strings.TrimSpace(dbName) == "" {
|
||||||
|
return nil, fmt.Errorf("database name required for GetAllColumns")
|
||||||
|
}
|
||||||
|
|
||||||
|
tables, err := t.GetTables(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := make([]connection.ColumnDefinitionWithTable, 0)
|
||||||
|
for _, table := range tables {
|
||||||
|
tableCols, err := t.GetColumns(dbName, table)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, col := range tableCols {
|
||||||
|
cols = append(cols, connection.ColumnDefinitionWithTable{
|
||||||
|
TableName: table,
|
||||||
|
Name: col.Name,
|
||||||
|
Type: col.Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
|
return []connection.IndexDefinition{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
|
return []connection.ForeignKeyDefinition{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TDengineDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
|
return []connection.TriggerDefinition{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getValueFromRow(row map[string]interface{}, keys ...string) (interface{}, bool) {
|
||||||
|
if len(row) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if val, ok := row[key]; ok {
|
||||||
|
return val, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for existingKey, val := range row {
|
||||||
|
for _, key := range keys {
|
||||||
|
if strings.EqualFold(existingKey, key) {
|
||||||
|
return val, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeBacktickIdent(ident string) string {
|
||||||
|
return strings.ReplaceAll(strings.TrimSpace(ident), "`", "``")
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteTDengineTable(dbName, tableName string) string {
|
||||||
|
t := escapeBacktickIdent(tableName)
|
||||||
|
if t == "" {
|
||||||
|
return "``"
|
||||||
|
}
|
||||||
|
if strings.Contains(t, ".") {
|
||||||
|
parts := strings.Split(t, ".")
|
||||||
|
quoted := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
quoted = append(quoted, fmt.Sprintf("`%s`", escapeBacktickIdent(part)))
|
||||||
|
}
|
||||||
|
if len(quoted) > 0 {
|
||||||
|
return strings.Join(quoted, ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db := escapeBacktickIdent(dbName)
|
||||||
|
if db == "" {
|
||||||
|
return fmt.Sprintf("`%s`", t)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("`%s`.`%s`", db, t)
|
||||||
|
}
|
||||||
627
internal/db/vastbase_impl.go
Normal file
627
internal/db/vastbase_impl.go
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
|
"GoNavi-Wails/internal/ssh"
|
||||||
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq" // Vastbase is PostgreSQL compatible
|
||||||
|
)
|
||||||
|
|
||||||
|
// VastbaseDB implements Database interface for Vastbase (海量) database
|
||||||
|
// Vastbase is a PostgreSQL-compatible database, so we reuse PostgreSQL driver
|
||||||
|
type VastbaseDB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
pingTimeout time.Duration
|
||||||
|
forwarder *ssh.LocalForwarder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) getDSN(config connection.ConnectionConfig) string {
|
||||||
|
dbname := config.Database
|
||||||
|
if dbname == "" {
|
||||||
|
dbname = "vastbase" // Vastbase default database
|
||||||
|
}
|
||||||
|
|
||||||
|
u := &url.URL{
|
||||||
|
Scheme: "postgres",
|
||||||
|
Host: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
|
||||||
|
Path: "/" + dbname,
|
||||||
|
}
|
||||||
|
u.User = url.UserPassword(config.User, config.Password)
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("sslmode", "disable")
|
||||||
|
q.Set("connect_timeout", strconv.Itoa(getConnectTimeoutSeconds(config)))
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) Connect(config connection.ConnectionConfig) error {
|
||||||
|
var dsn string
|
||||||
|
|
||||||
|
if config.UseSSH {
|
||||||
|
logger.Infof("Vastbase 使用 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)
|
||||||
|
}
|
||||||
|
v.forwarder = forwarder
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
localConfig := config
|
||||||
|
localConfig.Host = host
|
||||||
|
localConfig.Port = port
|
||||||
|
localConfig.UseSSH = false
|
||||||
|
|
||||||
|
dsn = v.getDSN(localConfig)
|
||||||
|
logger.Infof("Vastbase 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||||
|
} else {
|
||||||
|
dsn = v.getDSN(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||||
|
}
|
||||||
|
v.conn = db
|
||||||
|
v.pingTimeout = getConnectTimeout(config)
|
||||||
|
|
||||||
|
if err := v.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) Close() error {
|
||||||
|
if v.forwarder != nil {
|
||||||
|
if err := v.forwarder.Close(); err != nil {
|
||||||
|
logger.Warnf("关闭 Vastbase SSH 端口转发失败:%v", err)
|
||||||
|
}
|
||||||
|
v.forwarder = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.conn != nil {
|
||||||
|
return v.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) Ping() error {
|
||||||
|
if v.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
timeout := v.pingTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||||
|
defer cancel()
|
||||||
|
return v.conn.PingContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if v.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := v.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||||
|
if v.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := v.conn.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||||
|
if v.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := v.conn.ExecContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) Exec(query string) (int64, error) {
|
||||||
|
if v.conn == nil {
|
||||||
|
return 0, fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
res, err := v.conn.Exec(query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) GetDatabases() ([]string, error) {
|
||||||
|
data, _, err := v.Query("SELECT datname FROM pg_database WHERE datistemplate = false")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var dbs []string
|
||||||
|
for _, row := range data {
|
||||||
|
if val, ok := row["datname"]; ok {
|
||||||
|
dbs = append(dbs, fmt.Sprintf("%v", val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dbs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) GetTables(dbName string) ([]string, error) {
|
||||||
|
query := "SELECT schemaname, tablename FROM pg_catalog.pg_tables WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg_%' ORDER BY schemaname, tablename"
|
||||||
|
data, _, err := v.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tables []string
|
||||||
|
for _, row := range data {
|
||||||
|
schema, okSchema := row["schemaname"]
|
||||||
|
name, okName := row["tablename"]
|
||||||
|
if okSchema && okName {
|
||||||
|
tables = append(tables, fmt.Sprintf("%v.%v", schema, name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if okName {
|
||||||
|
tables = append(tables, fmt.Sprintf("%v", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||||
|
return fmt.Sprintf("-- SHOW CREATE TABLE not fully supported for Vastbase in this version.\n-- Table: %s", tableName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
|
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 := v.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 val, ok := row["comment"]; ok && val != nil {
|
||||||
|
col.Comment = fmt.Sprintf("%v", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := row["column_default"]; ok && val != nil {
|
||||||
|
def := fmt.Sprintf("%v", val)
|
||||||
|
col.Default = &def
|
||||||
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") {
|
||||||
|
col.Extra = "auto_increment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = append(columns, col)
|
||||||
|
}
|
||||||
|
return columns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VastbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
|
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 := v.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parseBool := func(val interface{}) bool {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case bool:
|
||||||
|
return v
|
||||||
|
case string:
|
||||||
|
s := strings.ToLower(strings.TrimSpace(v))
|
||||||
|
return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes"
|
||||||
|
default:
|
||||||
|
s := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", val)))
|
||||||
|
return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseInt := func(val interface{}) int {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
case int64:
|
||||||
|
return int(v)
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case string:
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", val)), "%d", &n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexes []connection.IndexDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
isUnique := false
|
||||||
|
if val, ok := row["is_unique"]; ok && val != nil {
|
||||||
|
isUnique = parseBool(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonUnique := 1
|
||||||
|
if isUnique {
|
||||||
|
nonUnique = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := 0
|
||||||
|
if val, ok := row["seq_in_index"]; ok && val != nil {
|
||||||
|
seq = parseInt(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
indexType := ""
|
||||||
|
if val, ok := row["index_type"]; ok && val != nil {
|
||||||
|
indexType = strings.ToUpper(fmt.Sprintf("%v", val))
|
||||||
|
}
|
||||||
|
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 (v *VastbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
|
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 := v.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var fks []connection.ForeignKeyDefinition
|
||||||
|
for _, row := range data {
|
||||||
|
refSchema := ""
|
||||||
|
if val, ok := row["foreign_table_schema"]; ok && val != nil {
|
||||||
|
refSchema = fmt.Sprintf("%v", val)
|
||||||
|
}
|
||||||
|
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 (v *VastbaseDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
|
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 := v.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 (v *VastbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
|
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 := v.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 (v *VastbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||||
|
if v.conn == nil {
|
||||||
|
return fmt.Errorf("connection not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := v.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, val := range pk {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, val)
|
||||||
|
}
|
||||||
|
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, val := range update.Values {
|
||||||
|
idx++
|
||||||
|
sets = append(sets, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wheres []string
|
||||||
|
for k, val := range update.Keys {
|
||||||
|
idx++
|
||||||
|
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||||
|
args = append(args, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, val := range row {
|
||||||
|
idx++
|
||||||
|
cols = append(cols, quoteIdent(k))
|
||||||
|
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
|
||||||
|
args = append(args, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -22,8 +22,11 @@ func quoteIdentByType(dbType string, ident string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch dbType {
|
switch dbType {
|
||||||
case "mysql":
|
case "mysql", "mariadb":
|
||||||
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
|
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
|
||||||
|
case "sqlserver":
|
||||||
|
escaped := strings.ReplaceAll(ident, "]", "]]")
|
||||||
|
return "[" + escaped + "]"
|
||||||
default:
|
default:
|
||||||
return `"` + strings.ReplaceAll(ident, `"`, `""`) + `"`
|
return `"` + strings.ReplaceAll(ident, `"`, `""`) + `"`
|
||||||
}
|
}
|
||||||
@@ -71,7 +74,7 @@ func normalizeSchemaAndTable(dbType string, dbName string, tableName string) (st
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
||||||
case "postgres", "kingbase":
|
case "postgres", "kingbase", "vastbase":
|
||||||
return "public", rawTable
|
return "public", rawTable
|
||||||
default:
|
default:
|
||||||
return rawDB, rawTable
|
return rawDB, rawTable
|
||||||
@@ -88,7 +91,7 @@ func qualifiedNameForQuery(dbType string, schema string, table string, original
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
||||||
case "postgres", "kingbase":
|
case "postgres", "kingbase", "vastbase":
|
||||||
s := strings.TrimSpace(schema)
|
s := strings.TrimSpace(schema)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
s = "public"
|
s = "public"
|
||||||
@@ -97,7 +100,7 @@ func qualifiedNameForQuery(dbType string, schema string, table string, original
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
return s + "." + table
|
return s + "." + table
|
||||||
case "mysql":
|
case "mysql", "mariadb":
|
||||||
s := strings.TrimSpace(schema)
|
s := strings.TrimSpace(schema)
|
||||||
if s == "" || table == "" {
|
if s == "" || table == "" {
|
||||||
return table
|
return table
|
||||||
|
|||||||
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