新增:数据库可见性过滤与连接管理增强
功能: - 支持配置 MySQL/MongoDB 可见数据库列表 - 连接删除时自动清理关联数据并关闭连接池 - 新增加载数据库列表 API - 数据库错误提示优化 改进: - 代码简化:消除重复的表单验证和密码处理逻辑 - ResultPanel 表格高度计算重构 - 删除调试日志和临时文件 后端: - 新增 VisibleDatabases 字段到连接模型 - DeleteConnection 使用事务确保数据一致性 - LoadAllDatabases 支持 MySQL/MongoDB 数据库列表加载
This commit is contained in:
31
app.go
31
app.go
@@ -6,8 +6,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
stdruntime "runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/api"
|
||||
@@ -23,15 +23,15 @@ import (
|
||||
|
||||
// App 应用结构体
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
db *database.DB
|
||||
connectionAPI *api.ConnectionAPI
|
||||
sqlAPI *api.SqlAPI
|
||||
tabAPI *api.TabAPI
|
||||
updateAPI *api.UpdateAPI
|
||||
configAPI *api.ConfigAPI
|
||||
fileServer *http.Server
|
||||
filesystem *filesystem.FileSystemService
|
||||
ctx context.Context
|
||||
db *database.DB
|
||||
connectionAPI *api.ConnectionAPI
|
||||
sqlAPI *api.SqlAPI
|
||||
tabAPI *api.TabAPI
|
||||
updateAPI *api.UpdateAPI
|
||||
configAPI *api.ConfigAPI
|
||||
fileServer *http.Server
|
||||
filesystem *filesystem.FileSystemService
|
||||
}
|
||||
|
||||
// NewApp 创建新的应用实例
|
||||
@@ -362,9 +362,9 @@ func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
|
||||
if err != nil {
|
||||
// 目标文件不存在或无法访问
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"targetPath": targetPath,
|
||||
"targetExists": false,
|
||||
"success": true,
|
||||
"targetPath": targetPath,
|
||||
"targetExists": false,
|
||||
"targetAccessible": false,
|
||||
}, nil
|
||||
}
|
||||
@@ -434,6 +434,11 @@ func (a *App) TestDbConnectionWithParams(req api.TestConnectionRequest) error {
|
||||
return a.connectionAPI.TestDbConnectionWithParams(req)
|
||||
}
|
||||
|
||||
// LoadAllDatabases 加载全部数据库列表
|
||||
func (a *App) LoadAllDatabases(req api.LoadAllDatabasesRequest) ([]string, error) {
|
||||
return a.connectionAPI.LoadAllDatabases(req)
|
||||
}
|
||||
|
||||
// ExecuteSQL 执行 SQL 语句
|
||||
// 注意:SQL 语句应该已经包含分页信息(LIMIT 和 OFFSET),由客户端添加
|
||||
func (a *App) ExecuteSQL(connectionId uint, sqlStr string, database string) (map[string]interface{}, error) {
|
||||
|
||||
73
cmd/debug_db/main.go
Normal file
73
cmd/debug_db/main.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"u-desk/internal/storage"
|
||||
"u-desk/internal/storage/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 初始化数据库
|
||||
db, err := storage.Init()
|
||||
if err != nil {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("=== 数据库连接配置调试工具 ===")
|
||||
fmt.Println()
|
||||
|
||||
// 列出所有连接
|
||||
var connections []models.DbConnection
|
||||
result := db.Order("id").Find(&connections)
|
||||
if result.Error != nil {
|
||||
log.Fatalf("查询失败: %v", result.Error)
|
||||
}
|
||||
|
||||
fmt.Printf("当前有 %d 个连接配置:\n", len(connections))
|
||||
fmt.Println()
|
||||
|
||||
for _, conn := range connections {
|
||||
fmt.Printf("ID: %d\n", conn.ID)
|
||||
fmt.Printf(" 名称: %s\n", conn.Name)
|
||||
fmt.Printf(" 类型: %s\n", conn.Type)
|
||||
fmt.Printf(" 主机: %s:%d\n", conn.Host, conn.Port)
|
||||
fmt.Printf(" 用户名: %s\n", conn.Username)
|
||||
fmt.Printf(" 创建时间: %s\n", conn.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// 询问用户操作
|
||||
var choice int
|
||||
fmt.Print("请选择操作:\n")
|
||||
fmt.Print("1. 删除指定 ID 的连接\n")
|
||||
fmt.Print("2. 列出连接详情\n")
|
||||
fmt.Print("0. 退出\n")
|
||||
fmt.Print("请输入: ")
|
||||
fmt.Scanln(&choice)
|
||||
|
||||
if choice == 1 {
|
||||
var id uint
|
||||
fmt.Print("请输入要删除的连接 ID: ")
|
||||
fmt.Scanln(&id)
|
||||
|
||||
// 确认
|
||||
var confirm string
|
||||
fmt.Printf("确认删除 ID=%d 的连接吗?(y/N): ", id)
|
||||
fmt.Scanln(&confirm)
|
||||
|
||||
if confirm == "y" || confirm == "Y" {
|
||||
result := db.Delete(&models.DbConnection{}, id)
|
||||
if result.Error != nil {
|
||||
log.Printf("删除失败: %v", result.Error)
|
||||
} else {
|
||||
fmt.Printf("删除成功!影响行数: %d\n", result.RowsAffected)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("已取消删除")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n工具退出")
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"u-desk/internal/service"
|
||||
"u-desk/internal/storage"
|
||||
"u-desk/internal/storage/models"
|
||||
)
|
||||
|
||||
// ConnectionAPI 连接管理API
|
||||
type ConnectionAPI struct {
|
||||
connService *service.ConnectionService
|
||||
connService *storage.ConnectionService
|
||||
}
|
||||
|
||||
// NewConnectionAPI 创建连接管理API
|
||||
func NewConnectionAPI() (*ConnectionAPI, error) {
|
||||
connService, err := service.NewConnectionService()
|
||||
connService, err := storage.NewConnectionService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -21,29 +21,31 @@ func NewConnectionAPI() (*ConnectionAPI, error) {
|
||||
|
||||
// SaveConnectionRequest 保存连接请求结构体
|
||||
type SaveConnectionRequest struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
Options string `json:"options"`
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
Options string `json:"options"`
|
||||
VisibleDatabases string `json:"visible_databases"`
|
||||
}
|
||||
|
||||
// SaveDbConnection 保存数据库连接配置
|
||||
func (api *ConnectionAPI) SaveDbConnection(req SaveConnectionRequest) error {
|
||||
conn := &models.DbConnection{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Host: req.Host,
|
||||
Port: req.Port,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Database: req.Database,
|
||||
Options: req.Options,
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Host: req.Host,
|
||||
Port: req.Port,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Database: req.Database,
|
||||
Options: req.Options,
|
||||
VisibleDatabases: req.VisibleDatabases,
|
||||
}
|
||||
return api.connService.SaveConnection(conn)
|
||||
}
|
||||
@@ -59,16 +61,17 @@ func (api *ConnectionAPI) ListDbConnections() ([]map[string]interface{}, error)
|
||||
timeFormat := "2006-01-02 15:04:05"
|
||||
for i, conn := range connections {
|
||||
result[i] = map[string]interface{}{
|
||||
"id": conn.ID,
|
||||
"name": conn.Name,
|
||||
"type": conn.Type,
|
||||
"host": conn.Host,
|
||||
"port": conn.Port,
|
||||
"username": conn.Username,
|
||||
"database": conn.Database,
|
||||
"options": conn.Options,
|
||||
"created_at": conn.CreatedAt.Format(timeFormat),
|
||||
"updated_at": conn.UpdatedAt.Format(timeFormat),
|
||||
"id": conn.ID,
|
||||
"name": conn.Name,
|
||||
"type": conn.Type,
|
||||
"host": conn.Host,
|
||||
"port": conn.Port,
|
||||
"username": conn.Username,
|
||||
"database": conn.Database,
|
||||
"options": conn.Options,
|
||||
"visible_databases": conn.VisibleDatabases,
|
||||
"created_at": conn.CreatedAt.Format(timeFormat),
|
||||
"updated_at": conn.UpdatedAt.Format(timeFormat),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
@@ -79,7 +82,11 @@ func (api *ConnectionAPI) DeleteDbConnection(id uint) error {
|
||||
}
|
||||
|
||||
func (api *ConnectionAPI) TestDbConnection(id uint) error {
|
||||
return api.connService.TestConnection(id)
|
||||
conn, err := api.connService.GetConnection(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return api.connService.TestConnection(conn)
|
||||
}
|
||||
|
||||
// TestConnectionRequest 测试连接请求结构体(不保存数据)
|
||||
@@ -107,3 +114,29 @@ func (api *ConnectionAPI) TestDbConnectionWithParams(req TestConnectionRequest)
|
||||
req.ID,
|
||||
)
|
||||
}
|
||||
|
||||
// LoadAllDatabasesRequest 加载全部数据库请求结构体
|
||||
type LoadAllDatabasesRequest struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
Options string `json:"options"`
|
||||
}
|
||||
|
||||
// LoadAllDatabases 加载全部数据库列表
|
||||
func (api *ConnectionAPI) LoadAllDatabases(req LoadAllDatabasesRequest) ([]string, error) {
|
||||
return api.connService.LoadAllDatabases(
|
||||
req.Type,
|
||||
req.Host,
|
||||
req.Port,
|
||||
req.Username,
|
||||
req.Password,
|
||||
req.Database,
|
||||
req.Options,
|
||||
req.ID,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"u-desk/internal/crypto"
|
||||
@@ -55,13 +56,14 @@ func (s *ConnectionService) SaveConnection(conn *models.DbConnection) error {
|
||||
if conn.ID > 0 {
|
||||
// 更新模式
|
||||
updateData := map[string]interface{}{
|
||||
"name": conn.Name,
|
||||
"type": conn.Type,
|
||||
"host": conn.Host,
|
||||
"port": conn.Port,
|
||||
"username": conn.Username,
|
||||
"database": conn.Database,
|
||||
"options": conn.Options,
|
||||
"name": conn.Name,
|
||||
"type": conn.Type,
|
||||
"host": conn.Host,
|
||||
"port": conn.Port,
|
||||
"username": conn.Username,
|
||||
"database": conn.Database,
|
||||
"options": conn.Options,
|
||||
"visible_databases": conn.VisibleDatabases,
|
||||
}
|
||||
|
||||
// 如果提供了新密码,加密后更新
|
||||
@@ -111,7 +113,26 @@ func (s *ConnectionService) GetConnection(id uint) (*models.DbConnection, error)
|
||||
|
||||
// DeleteConnection 删除连接配置
|
||||
func (s *ConnectionService) DeleteConnection(id uint) error {
|
||||
return s.db.Delete(&models.DbConnection{}, id).Error
|
||||
var conn models.DbConnection
|
||||
if err := s.db.First(&conn, id).Error; err != nil {
|
||||
return nil // 连接不存在视为成功
|
||||
}
|
||||
|
||||
// 使用事务删除
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 清理关联数据
|
||||
tx.Where("connection_id = ?", id).Delete(&models.SqlResultHistory{})
|
||||
tx.Where("connection_id = ?", id).Delete(&models.SqlTab{})
|
||||
|
||||
// 删除连接
|
||||
if err := tx.Delete(&conn).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 关闭连接池
|
||||
dbclient.GetPool().CloseConnection(id, conn.Type)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// TestConnection 测试连接(需要根据类型调用不同的测试方法)
|
||||
@@ -163,3 +184,127 @@ func testRedisConnection(host string, port int, password string) error {
|
||||
func testMongoConnection(host string, port int, username, password, database, authSource, authMechanism string) error {
|
||||
return dbclient.TestMongoConnectionWithOptions(host, port, username, password, database, authSource, authMechanism)
|
||||
}
|
||||
|
||||
// TestConnectionWithParams 使用参数测试连接(不保存数据)
|
||||
func (s *ConnectionService) TestConnectionWithParams(dbType, host string, port int, username, password, database, options string, id uint) error {
|
||||
// 如果是编辑模式且有ID,获取已保存的密码
|
||||
if id > 0 && password == "" {
|
||||
conn, err := s.GetConnection(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取连接信息失败: %v", err)
|
||||
}
|
||||
decryptPassword, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
password = decryptPassword
|
||||
}
|
||||
|
||||
// 根据类型测试连接
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
return testMySQLConnection(host, port, username, password, database)
|
||||
case "redis":
|
||||
return testRedisConnection(host, port, password)
|
||||
case "mongo":
|
||||
// 解析 Options 获取 MongoDB 连接参数
|
||||
authSource := ""
|
||||
authMechanism := ""
|
||||
if options != "" {
|
||||
var opts map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(options), &opts); err == nil {
|
||||
if as, ok := opts["authSource"].(string); ok && as != "" {
|
||||
authSource = as
|
||||
}
|
||||
if am, ok := opts["authMechanism"].(string); ok && am != "" {
|
||||
authMechanism = am
|
||||
}
|
||||
}
|
||||
}
|
||||
return testMongoConnection(host, port, username, password, database, authSource, authMechanism)
|
||||
default:
|
||||
return fmt.Errorf("不支持的数据库类型: %s", dbType)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAllDatabases 加载全部数据库列表
|
||||
func (s *ConnectionService) LoadAllDatabases(dbType, host string, port int, username, password, database, options string, id uint) ([]string, error) {
|
||||
// 如果是编辑模式且有ID,获取已保存的密码
|
||||
if id > 0 && password == "" {
|
||||
conn, err := s.GetConnection(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接信息失败: %v", err)
|
||||
}
|
||||
decryptPassword, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
password = decryptPassword
|
||||
}
|
||||
|
||||
// 根据类型加载数据库列表
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
return loadMySQLDatabases(host, port, username, password, database)
|
||||
case "mongo":
|
||||
return loadMongoDatabases(host, port, username, password, database, options)
|
||||
case "redis":
|
||||
// Redis 没有数据库概念,返回空列表
|
||||
return []string{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", dbType)
|
||||
}
|
||||
}
|
||||
|
||||
// loadMySQLDatabases 加载 MySQL 数据库列表
|
||||
func loadMySQLDatabases(host string, port int, username, password, defaultDatabase string) ([]string, error) {
|
||||
config := &dbclient.MySQLConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: defaultDatabase,
|
||||
}
|
||||
client, err := dbclient.NewMySQLClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
return client.ListDatabases(context.Background())
|
||||
}
|
||||
|
||||
// loadMongoDatabases 加载 MongoDB 数据库列表
|
||||
func loadMongoDatabases(host string, port int, username, password, defaultDatabase, options string) ([]string, error) {
|
||||
// 解析 Options 获取 MongoDB 连接参数
|
||||
authSource := ""
|
||||
authMechanism := ""
|
||||
if options != "" {
|
||||
var opts map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(options), &opts); err == nil {
|
||||
if as, ok := opts["authSource"].(string); ok && as != "" {
|
||||
authSource = as
|
||||
}
|
||||
if am, ok := opts["authMechanism"].(string); ok && am != "" {
|
||||
authMechanism = am
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mongoConfig := &dbclient.MongoConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: defaultDatabase,
|
||||
AuthSource: authSource,
|
||||
AuthMechanism: authMechanism,
|
||||
}
|
||||
client, err := dbclient.NewMongoClient(mongoConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
return client.ListDatabases(context.Background())
|
||||
}
|
||||
|
||||
@@ -6,17 +6,18 @@ import (
|
||||
|
||||
// DbConnection 数据库连接配置
|
||||
type DbConnection struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"type:varchar(100);not null" json:"name"` // 连接名称
|
||||
Type string `gorm:"type:varchar(20);not null" json:"type"` // 数据库类型: mysql/redis/mongo
|
||||
Host string `gorm:"type:varchar(255);not null" json:"host"` // 主机地址
|
||||
Port int `gorm:"not null" json:"port"` // 端口
|
||||
Username string `gorm:"type:varchar(100)" json:"username"` // 用户名
|
||||
Password string `gorm:"type:varchar(500)" json:"-"` // 密码(加密存储,不返回)
|
||||
Database string `gorm:"type:varchar(100)" json:"database"` // 数据库名(MySQL/MongoDB)
|
||||
Options string `gorm:"type:text" json:"options"` // 额外选项(JSON格式)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"type:varchar(100);not null" json:"name"` // 连接名称
|
||||
Type string `gorm:"type:varchar(20);not null" json:"type"` // 数据库类型: mysql/redis/mongo
|
||||
Host string `gorm:"type:varchar(255);not null" json:"host"` // 主机地址
|
||||
Port int `gorm:"not null" json:"port"` // 端口
|
||||
Username string `gorm:"type:varchar(100)" json:"username"` // 用户名
|
||||
Password string `gorm:"type:varchar(500)" json:"-"` // 密码(加密存储,不返回)
|
||||
Database string `gorm:"type:varchar(100)" json:"database"` // 数据库名(MySQL/MongoDB)
|
||||
Options string `gorm:"type:text" json:"options"` // 额外选项(JSON格式)
|
||||
VisibleDatabases string `gorm:"type:text" json:"visible_databases"` // 可见数据库列表(JSON数组,为空则全部可见)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
31
web/.gitignore
vendored
Normal file
31
web/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
|
||||
# 构建产物
|
||||
dist/
|
||||
build/
|
||||
|
||||
# 自动生成的类型声明文件
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
|
||||
# 缓存
|
||||
*.log
|
||||
*.cache
|
||||
.vite/
|
||||
|
||||
# 编辑器
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 环境变量
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -72,17 +72,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { IconSettings } from '@arco-design/web-vue/es/icon'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||
import {IconSettings} from '@arco-design/web-vue/es/icon'
|
||||
import DbCli from './views/db-cli/index.vue'
|
||||
import ThemeToggle from './components/ThemeToggle.vue'
|
||||
import FileSystem from './components/FileSystem/index.vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
import UpdateNotification from './components/UpdateNotification.vue'
|
||||
import { useUpdateStore } from './stores/update'
|
||||
import { useConfigStore } from './stores/config'
|
||||
import { preloadCommonLanguages } from './utils/codeMirrorLoader'
|
||||
import {useUpdateStore} from './stores/update'
|
||||
import {useConfigStore} from './stores/config'
|
||||
import {preloadCommonLanguages} from './utils/codeMirrorLoader'
|
||||
|
||||
// 存储键
|
||||
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
||||
|
||||
114
web/src/utils/database-error.ts
Normal file
114
web/src/utils/database-error.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 数据库错误提示工具
|
||||
* 将技术性错误信息转换为用户友好的提示
|
||||
*/
|
||||
|
||||
/**
|
||||
* 解析数据库连接错误,返回友好的提示信息
|
||||
*/
|
||||
export function getFriendlyDatabaseError(error: Error | string | unknown): string {
|
||||
const errorMsg = typeof error === 'string' ? error : error instanceof Error ? error.message : String(error)
|
||||
|
||||
// MySQL 错误码处理
|
||||
if (errorMsg.includes('Error 1045') || errorMsg.includes('28000')) {
|
||||
return '用户名或密码错误,请检查连接配置'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 2002') || errorMsg.includes('Error 2003')) {
|
||||
return '无法连接到数据库服务器,请检查主机地址和端口是否正确'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 2003')) {
|
||||
return '无法连接到 MySQL 服务器,请检查:\n1. 主机地址和端口是否正确\n2. MySQL 服务是否已启动\n3. 防火墙是否允许连接'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 1045') || errorMsg.includes('Access denied')) {
|
||||
return '认证失败,请检查:\n1. 用户名是否正确\n2. 密码是否正确\n3. 用户是否有访问权限'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 1130') || errorMsg.includes('host is not allowed')) {
|
||||
return '当前 IP 不被允许连接,请检查 MySQL 用户的主机访问权限配置'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 10061') || errorMsg.includes('Connection refused')) {
|
||||
return '连接被拒绝,请检查:\n1. 端口是否正确\n2. 数据库服务是否已启动'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 10060') || errorMsg.includes('timeout')) {
|
||||
return '连接超时,请检查:\n1. 网络连接是否正常\n2. 主机地址是否正确\n3. 防火墙设置'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('No connection') || errorMsg.includes('closed')) {
|
||||
return '数据库连接已断开,请尝试重新连接'
|
||||
}
|
||||
|
||||
// MongoDB 错误处理
|
||||
if (errorMsg.includes('Authentication failed')) {
|
||||
return 'MongoDB 认证失败,请检查用户名和密码'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('connection refused') || errorMsg.includes('ECONNREFUSED')) {
|
||||
return '无法连接到 MongoDB 服务器,请检查服务是否已启动'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('ENOTFOUND') || errorMsg.includes('no such host')) {
|
||||
return '主机地址无法解析,请检查主机名是否正确'
|
||||
}
|
||||
|
||||
// Redis 错误处理
|
||||
if (errorMsg.includes('NOAUTH')) {
|
||||
return 'Redis 需要密码认证'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('WRONGPASS')) {
|
||||
return 'Redis 密码错误'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('connection')) {
|
||||
return '无法连接到 Redis 服务器,请检查:\n1. 主机地址和端口是否正确\n2. Redis 服务是否已启动'
|
||||
}
|
||||
|
||||
// 网络相关
|
||||
if (errorMsg.includes('network') || errorMsg.includes('ENETUNREACH')) {
|
||||
return '网络连接失败,请检查网络设置'
|
||||
}
|
||||
|
||||
// 通用错误
|
||||
if (errorMsg.includes('获取 MySQL 客户端失败')) {
|
||||
return '数据库连接初始化失败,请检查连接配置'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('连接.*失败')) {
|
||||
return '数据库连接失败,请检查连接配置'
|
||||
}
|
||||
|
||||
// 返回原始错误信息(去除技术性前缀)
|
||||
const friendly = errorMsg
|
||||
.replace(/^获取 MySQL 客户端失败:\s*/, '')
|
||||
.replace(/^连接 MySQL 失败:\s*/, '')
|
||||
.replace(/^连接 MongoDB 失败:\s*/, '')
|
||||
.replace(/^连接 Redis 失败:\s*/, '')
|
||||
.replace(/^Error \d+:\s*/, '')
|
||||
|
||||
return friendly || '未知错误,请检查连接配置'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接失败的友好提示
|
||||
*/
|
||||
export function getConnectionFailedTip(error: Error | string | unknown, dbType: string = 'mysql'): string {
|
||||
const friendlyError = getFriendlyDatabaseError(error)
|
||||
const dbTypeName = dbType === 'mysql' ? 'MySQL' : dbType === 'mongo' ? 'MongoDB' : 'Redis'
|
||||
|
||||
return `${dbTypeName} 连接失败:${friendlyError}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取加载数据失败的友好提示
|
||||
*/
|
||||
export function getLoadFailedTip(error: Error | string | unknown, loadType: 'databases' | 'tables' | 'keys'): string {
|
||||
const friendlyError = getFriendlyDatabaseError(error)
|
||||
const typeText = loadType === 'databases' ? '数据库列表' : loadType === 'tables' ? '表列表' : '键列表'
|
||||
|
||||
return `加载${typeText}失败:${friendlyError}`
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
title="数据库连接配置"
|
||||
width="560px"
|
||||
width="600px"
|
||||
:body-style="{ padding: '16px 20px' }"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
@@ -65,6 +65,67 @@
|
||||
:max-length="100"
|
||||
size="small"/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 数据库过滤选项(仅 MySQL 和 MongoDB) -->
|
||||
<template v-if="form.type === 'mysql' || form.type === 'mongo'">
|
||||
<a-form-item label="可见数据库" field="visibleDatabases">
|
||||
<div class="database-list-container">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="list-toolbar">
|
||||
<a-button
|
||||
type="outline"
|
||||
size="small"
|
||||
@click="loadAllDatabases"
|
||||
:loading="loadingDatabases"
|
||||
:disabled="!canLoadDatabases"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-refresh />
|
||||
</template>
|
||||
{{ allDatabases.length > 0 ? '重新加载' : '加载数据库列表' }}
|
||||
</a-button>
|
||||
|
||||
<template v-if="allDatabases.length > 0">
|
||||
<div class="toolbar-stats">
|
||||
已选 {{ selectedDatabases.length }} / {{ allDatabases.length }}
|
||||
</div>
|
||||
<a-button size="small" @click="handleSelectAll(true)">全选</a-button>
|
||||
<a-button size="small" @click="handleInvertSelection">反选</a-button>
|
||||
<a-button size="small" @click="handleSelectAll(false)">清空</a-button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!loadingDatabases && allDatabases.length === 0" class="list-empty">
|
||||
<icon-storage />
|
||||
<span>点击上方按钮加载数据库列表</span>
|
||||
</div>
|
||||
|
||||
<!-- 数据库列表(复选框) -->
|
||||
<div v-else class="database-checkbox-list">
|
||||
<div
|
||||
v-for="db in allDatabases"
|
||||
:key="db"
|
||||
class="database-checkbox-item"
|
||||
>
|
||||
<a-checkbox
|
||||
:model-value="selectedDatabases.includes(db)"
|
||||
:disabled="loadingDatabases"
|
||||
@change="(checked: boolean) => toggleDatabase(db, checked)"
|
||||
>
|
||||
{{ db }}
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div v-if="allDatabases.length > 0" class="list-tip">
|
||||
<icon-info-circle />
|
||||
未选择任何数据库时,将展示所有数据库
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- MongoDB 专用选项 -->
|
||||
<template v-if="form.type === 'mongo'">
|
||||
@@ -87,12 +148,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {reactive, ref, watch} from 'vue'
|
||||
import {reactive, ref, watch, computed, nextTick} from 'vue'
|
||||
import {Message} from '@arco-design/web-vue'
|
||||
import {
|
||||
IconCheckCircle,
|
||||
IconClose,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconStorage
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
ListDbConnections,
|
||||
SaveDbConnection
|
||||
} from '../../../wailsjs/wailsjs/go/main/App'
|
||||
import { getConnectionFailedTip, getLoadFailedTip } from '@/utils/database-error'
|
||||
|
||||
// 使用 defineModel 简化 v-model:visible 双向绑定(Vue 3.5+)
|
||||
const visible = defineModel('visible', { type: Boolean, default: false })
|
||||
@@ -113,6 +182,16 @@ const errorMessage = ref('')
|
||||
// 是否修改密码(编辑模式下)
|
||||
const isPasswordChanged = ref(false)
|
||||
|
||||
// 数据库过滤相关
|
||||
const loadingDatabases = ref(false)
|
||||
const allDatabases = ref<string[]>([])
|
||||
const selectedDatabases = ref<string[]>([])
|
||||
|
||||
// 是否可以加载数据库列表
|
||||
const canLoadDatabases = computed(() =>
|
||||
!!(form.host && form.port && form.username && (form.password || props.connectionId))
|
||||
)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
type: 'mysql',
|
||||
@@ -121,7 +200,8 @@ const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
database: '',
|
||||
options: ''
|
||||
options: '',
|
||||
visibleDatabases: ''
|
||||
})
|
||||
|
||||
// 选项表单(用于表单输入)
|
||||
@@ -213,17 +293,9 @@ const rules = {
|
||||
|
||||
// 获取密码输入框的占位符
|
||||
const getPasswordPlaceholder = () => {
|
||||
if (props.connectionId) {
|
||||
return '请输入新密码'
|
||||
}
|
||||
switch (form.type) {
|
||||
case 'redis':
|
||||
return '可选,留空则无密码连接'
|
||||
case 'mongo':
|
||||
return '可选,留空则无认证连接'
|
||||
default:
|
||||
return '请输入密码'
|
||||
}
|
||||
if (props.connectionId) return '请输入新密码'
|
||||
const placeholders = { redis: '可选,留空则无密码连接', mongo: '可选,留空则无认证连接' }
|
||||
return placeholders[form.type] || '请输入密码'
|
||||
}
|
||||
|
||||
// 监听类型变化,设置默认端口、主机和用户名
|
||||
@@ -271,6 +343,7 @@ const handleTypeChange = (type) => {
|
||||
const loadConnection = async () => {
|
||||
if (!props.connectionId) {
|
||||
resetForm()
|
||||
// 新建模式:不自动加载,等用户手动点击
|
||||
return
|
||||
}
|
||||
|
||||
@@ -293,9 +366,28 @@ const loadConnection = async () => {
|
||||
parseOptionsToForm(conn.options || '')
|
||||
// 然后设置 form.options(这样不会触发 watch)
|
||||
form.options = conn.options || ''
|
||||
// 设置可见数据库
|
||||
form.visibleDatabases = conn.visible_databases || ''
|
||||
// 编辑模式下,默认不修改密码
|
||||
form.password = ''
|
||||
isPasswordChanged.value = false
|
||||
|
||||
// 恢复数据库选择
|
||||
if (conn.visible_databases) {
|
||||
try {
|
||||
selectedDatabases.value = JSON.parse(conn.visible_databases)
|
||||
} catch (error) {
|
||||
console.warn('解析可见数据库列表失败:', error)
|
||||
selectedDatabases.value = []
|
||||
}
|
||||
} else {
|
||||
selectedDatabases.value = []
|
||||
}
|
||||
|
||||
// 编辑模式:自动加载数据库列表
|
||||
nextTick(() => {
|
||||
loadAllDatabases()
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载连接详情失败:', error)
|
||||
@@ -304,6 +396,28 @@ const loadConnection = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取要使用的密码(编辑模式下未修改密码时为空)
|
||||
const getPasswordToUse = () =>
|
||||
(props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
|
||||
|
||||
// 表单验证(返回 true 表示验证通过)
|
||||
const validateForm = async () => {
|
||||
if (!formRef.value) {
|
||||
console.error('formRef 未初始化')
|
||||
return false
|
||||
}
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
return true
|
||||
} catch (error) {
|
||||
const errorMsg = error?.fields?.[Object.keys(error.fields)[0]]?.[0]?.message || '请检查表单填写是否正确'
|
||||
errorMessage.value = errorMsg
|
||||
Message.warning(errorMsg)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
form.name = ''
|
||||
@@ -314,67 +428,44 @@ const resetForm = () => {
|
||||
form.password = ''
|
||||
form.database = ''
|
||||
form.options = ''
|
||||
form.visibleDatabases = ''
|
||||
optionsForm.authSource = ''
|
||||
isPasswordChanged.value = false
|
||||
loadingDatabases.value = false
|
||||
allDatabases.value = []
|
||||
selectedDatabases.value = []
|
||||
}
|
||||
|
||||
// 测试连接(不保存数据)
|
||||
const handleTest = async () => {
|
||||
if (!formRef.value) {
|
||||
console.error('formRef 未初始化')
|
||||
return
|
||||
}
|
||||
|
||||
// 清除之前的错误信息
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
// 验证通过,继续执行
|
||||
} catch (error) {
|
||||
// 表单验证失败
|
||||
const errorFields = error?.fields || {}
|
||||
const firstError = Object.values(errorFields)[0]
|
||||
const errorMsg = firstError?.[0]?.message || '请检查表单填写是否正确'
|
||||
errorMessage.value = errorMsg
|
||||
Message.warning(errorMsg)
|
||||
return
|
||||
}
|
||||
if (!(await validateForm())) return
|
||||
|
||||
// 检查 Go 后端是否可用
|
||||
if (!(window as any).go?.main?.App) {
|
||||
errorMessage.value = 'Go 后端未就绪,请确保应用已启动'
|
||||
Message.error('Go 后端未就绪,请确保应用已启动')
|
||||
const msg = 'Go 后端未就绪,请确保应用已启动'
|
||||
errorMessage.value = msg
|
||||
Message.error(msg)
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
// 编辑模式下,如果未修改密码,传递空字符串(后端会获取已保存的密码)
|
||||
const passwordToTest = (props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
|
||||
|
||||
// 合并选项为 JSON
|
||||
const optionsJson = mergeOptionsToJson()
|
||||
|
||||
// 直接测试连接,不保存数据
|
||||
await (window as any).go.main.App.TestDbConnectionWithParams({
|
||||
id: props.connectionId || 0, // 编辑模式下传递ID,用于获取已保存的密码
|
||||
id: props.connectionId || 0,
|
||||
type: form.type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username || '',
|
||||
password: passwordToTest,
|
||||
password: getPasswordToUse(),
|
||||
database: form.database || '',
|
||||
options: optionsJson
|
||||
options: mergeOptionsToJson()
|
||||
})
|
||||
|
||||
Message.success('连接测试成功')
|
||||
errorMessage.value = ''
|
||||
} catch (error) {
|
||||
console.error('连接测试失败:', error)
|
||||
const errorMsg = error.message || error.toString() || '未知错误'
|
||||
errorMessage.value = '连接测试失败: ' + errorMsg
|
||||
Message.error('连接测试失败: ' + errorMsg)
|
||||
const friendlyMsg = getConnectionFailedTip(error, form.type)
|
||||
errorMessage.value = friendlyMsg
|
||||
Message.error(friendlyMsg)
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
@@ -382,42 +473,18 @@ const handleTest = async () => {
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
console.error('formRef 未初始化')
|
||||
return
|
||||
}
|
||||
|
||||
// 清除之前的错误信息
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
// 验证通过,继续执行
|
||||
} catch (error) {
|
||||
// 表单验证失败
|
||||
const errorFields = error?.fields || {}
|
||||
const firstError = Object.values(errorFields)[0]
|
||||
const errorMsg = firstError?.[0]?.message || '请检查表单填写是否正确'
|
||||
errorMessage.value = errorMsg
|
||||
Message.warning(errorMsg)
|
||||
return
|
||||
}
|
||||
if (!(await validateForm())) return
|
||||
|
||||
// 检查 Go 后端是否可用
|
||||
if (!(window as any).go?.main?.App) {
|
||||
errorMessage.value = 'Go 后端未就绪,请确保应用已启动'
|
||||
Message.error('Go 后端未就绪,请确保应用已启动')
|
||||
const msg = 'Go 后端未就绪,请确保应用已启动'
|
||||
errorMessage.value = msg
|
||||
Message.error(msg)
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// 编辑模式下,如果未修改密码,传递空字符串(后端会保留原密码)
|
||||
const passwordToSave = (props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
|
||||
|
||||
// 合并选项为 JSON
|
||||
const optionsJson = mergeOptionsToJson()
|
||||
|
||||
await (window as any).go.main.App.SaveDbConnection({
|
||||
id: props.connectionId || 0,
|
||||
name: form.name,
|
||||
@@ -425,18 +492,17 @@ const handleSubmit = async () => {
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username || '',
|
||||
password: passwordToSave,
|
||||
password: getPasswordToUse(),
|
||||
database: form.database || '',
|
||||
options: optionsJson
|
||||
options: mergeOptionsToJson(),
|
||||
visible_databases: form.visibleDatabases || ''
|
||||
})
|
||||
|
||||
Message.success(props.connectionId ? '更新成功' : '保存成功')
|
||||
errorMessage.value = ''
|
||||
emit('success')
|
||||
visible.value = false
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
const errorMsg = error.message || error.toString() || '未知错误'
|
||||
const errorMsg = error?.message || error?.toString() || '未知错误'
|
||||
errorMessage.value = '保存失败: ' + errorMsg
|
||||
Message.error('保存失败: ' + errorMsg)
|
||||
} finally {
|
||||
@@ -470,6 +536,83 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 加载全部数据库列表
|
||||
const loadAllDatabases = async () => {
|
||||
if (!canLoadDatabases.value) {
|
||||
Message.warning('请先填写连接信息')
|
||||
return
|
||||
}
|
||||
|
||||
loadingDatabases.value = true
|
||||
try {
|
||||
const databases = await (window as any).go.main.App.LoadAllDatabases({
|
||||
id: props.connectionId || 0,
|
||||
type: form.type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username || '',
|
||||
password: getPasswordToUse(),
|
||||
database: form.database || '',
|
||||
options: mergeOptionsToJson()
|
||||
})
|
||||
|
||||
allDatabases.value = databases || []
|
||||
|
||||
// 从已保存的 visibleDatabases 中恢复选择
|
||||
if (form.visibleDatabases) {
|
||||
try {
|
||||
selectedDatabases.value = JSON.parse(form.visibleDatabases)
|
||||
.filter((db: string) => databases.includes(db))
|
||||
} catch (error) {
|
||||
console.warn('解析可见数据库列表失败:', error)
|
||||
selectedDatabases.value = []
|
||||
}
|
||||
} else {
|
||||
selectedDatabases.value = []
|
||||
}
|
||||
|
||||
Message.success(`成功加载 ${databases.length} 个数据库`)
|
||||
} catch (error) {
|
||||
Message.error(getLoadFailedTip(error, 'databases'))
|
||||
} finally {
|
||||
loadingDatabases.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换单个数据库选择
|
||||
const toggleDatabase = (dbName: string, checked: boolean) => {
|
||||
const list = selectedDatabases.value
|
||||
if (checked && !list.includes(dbName)) {
|
||||
list.push(dbName)
|
||||
} else if (!checked) {
|
||||
selectedDatabases.value = list.filter(db => db !== dbName)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/全不选
|
||||
const handleSelectAll = (selectAll: boolean) => {
|
||||
if (selectAll) {
|
||||
selectedDatabases.value = [...allDatabases.value]
|
||||
} else {
|
||||
selectedDatabases.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 反选
|
||||
const handleInvertSelection = () => {
|
||||
const selectedSet = new Set(selectedDatabases.value)
|
||||
selectedDatabases.value = allDatabases.value.filter(db => !selectedSet.has(db))
|
||||
}
|
||||
|
||||
// 监听数据库选择变化,同步到表单
|
||||
watch(selectedDatabases, (newVal) => {
|
||||
if (newVal.length === 0) {
|
||||
form.visibleDatabases = ''
|
||||
} else {
|
||||
form.visibleDatabases = JSON.stringify(newVal)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (val) => {
|
||||
if (val) {
|
||||
@@ -507,5 +650,81 @@ watch(visible, (val) => {
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.database-list-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 顶部工具栏 */
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-stats {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.list-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 40px 20px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-empty svg {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
/* 复选框列表 */
|
||||
.database-checkbox-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background: var(--color-fill-2);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.database-checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.database-checkbox-item :deep(.arco-checkbox) {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 底部提示 */
|
||||
.list-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.list-tip svg {
|
||||
color: var(--color-text-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {onMounted, onUnmounted, ref, watch, nextTick, h, computed} from 'vue'
|
||||
import {Message} from '@arco-design/web-vue'
|
||||
import {Message, Modal} from '@arco-design/web-vue'
|
||||
import {
|
||||
IconDelete,
|
||||
IconEdit,
|
||||
@@ -141,6 +141,7 @@ import type { MenuItem } from './ContextMenu.vue'
|
||||
import { STORAGE_KEYS } from '../constants/storage'
|
||||
import { listConnections, getDatabases, getTables, deleteConnection } from '@/api'
|
||||
import type { Connection } from '@/api'
|
||||
import { getLoadFailedTip } from '@/utils/database-error'
|
||||
|
||||
// 连接类型定义(使用 API 层的类型)
|
||||
type DbConnection = Connection
|
||||
@@ -247,11 +248,9 @@ const loadConnections = async () => {
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
|
||||
if (!window.go?.main?.App?.ListDbConnections) {
|
||||
retryTimer = setTimeout(() => {
|
||||
loadConnections()
|
||||
}, 500)
|
||||
retryTimer = setTimeout(() => loadConnections(), 500)
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
@@ -259,7 +258,6 @@ const loadConnections = async () => {
|
||||
try {
|
||||
const result = await listConnections()
|
||||
connections.value = Array.isArray(result) ? result : []
|
||||
// 不等待 buildTreeData,让它异步执行,先显示连接列表
|
||||
buildTreeData()
|
||||
} catch (error: any) {
|
||||
Message.error('加载连接列表失败: ' + (error.message || error))
|
||||
@@ -306,17 +304,35 @@ const restoreExpandedState = async () => {
|
||||
try {
|
||||
const savedExpandedKeys = loadExpandedKeys()
|
||||
if (savedExpandedKeys.length === 0) return
|
||||
|
||||
|
||||
// 过滤出有效的展开键(排除已删除连接的相关键)
|
||||
const validExpandedKeys = savedExpandedKeys.filter(key => {
|
||||
if (key.startsWith('conn-')) {
|
||||
const connId = Number(key.replace('conn-', ''))
|
||||
return connections.value.some(c => c.id === connId)
|
||||
}
|
||||
if (key.startsWith('db-')) {
|
||||
const connId = Number(key.split('-')[1])
|
||||
return connections.value.some(c => c.id === connId)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 如果过滤后有变化,更新保存的展开状态
|
||||
if (validExpandedKeys.length !== savedExpandedKeys.length) {
|
||||
saveToStorage(STORAGE_KEYS.TREE_EXPANDED_KEYS, validExpandedKeys)
|
||||
}
|
||||
|
||||
// 恢复展开状态
|
||||
expandedKeys.value = savedExpandedKeys
|
||||
|
||||
expandedKeys.value = validExpandedKeys
|
||||
|
||||
// 等待 DOM 更新
|
||||
await nextTick()
|
||||
|
||||
|
||||
// 收集需要加载的节点
|
||||
const loadTasks: Promise<void>[] = []
|
||||
|
||||
for (const key of savedExpandedKeys) {
|
||||
|
||||
for (const key of validExpandedKeys) {
|
||||
const node = findNodeByKey(treeData.value, key)
|
||||
if (node && !node.children) {
|
||||
// 节点存在但还没有子节点,需要加载
|
||||
@@ -751,7 +767,10 @@ const withLoadingNode = async (nodeKey: string, loader: () => Promise<void>) =>
|
||||
// 强制触发响应式更新
|
||||
refreshTreeData()
|
||||
} catch (error) {
|
||||
Message.error('加载失败: ' + (error.message || error))
|
||||
// 判断加载类型
|
||||
const loadType = nodeKey.startsWith('conn-') ? 'databases' :
|
||||
nodeKey.startsWith('db-') ? 'tables' : 'keys'
|
||||
Message.error(getLoadFailedTip(error, loadType))
|
||||
} finally {
|
||||
loadingNodes.value.delete(nodeKey)
|
||||
}
|
||||
@@ -769,8 +788,23 @@ const loadDatabases = async (connectionNode) => {
|
||||
const databases = await getDatabases(connectionNode.connectionId)
|
||||
if (!Array.isArray(databases)) return
|
||||
|
||||
// 获取连接配置,检查是否有可见数据库过滤
|
||||
const conn = connections.value.find(c => c.id === connectionNode.connectionId)
|
||||
let filteredDatabases = databases
|
||||
|
||||
if (conn?.visible_databases) {
|
||||
try {
|
||||
const visibleDbs = JSON.parse(conn.visible_databases)
|
||||
if (Array.isArray(visibleDbs) && visibleDbs.length > 0) {
|
||||
filteredDatabases = databases.filter(db => visibleDbs.includes(db))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('解析可见数据库列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据数据库类型设置节点标题
|
||||
connectionNode.children = databases.map(db => ({
|
||||
connectionNode.children = filteredDatabases.map(db => ({
|
||||
key: `db-${connectionNode.connectionId}-${db}`,
|
||||
title: connectionNode.dbType === 'redis' ? `DB ${db}` : db,
|
||||
type: 'database',
|
||||
@@ -867,25 +901,20 @@ const handleEditConnection = (nodeData) => {
|
||||
|
||||
// 删除连接
|
||||
const handleDeleteConnection = async (nodeData) => {
|
||||
if (!nodeData || !nodeData.connectionId) return
|
||||
const connId = nodeData?.connectionId || Number(nodeData?.key?.replace('conn-', ''))
|
||||
if (!connId) {
|
||||
Message.error('无法获取连接 ID')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!window.go?.main?.App?.DeleteDbConnection) {
|
||||
Message.error('Go 后端未就绪')
|
||||
return
|
||||
}
|
||||
|
||||
await deleteConnection(nodeData.connectionId)
|
||||
await deleteConnection(connId)
|
||||
Message.success('删除成功')
|
||||
|
||||
emit('connection-delete', {
|
||||
connectionId: nodeData.connectionId
|
||||
})
|
||||
|
||||
// 重新加载连接列表
|
||||
await loadConnections()
|
||||
emit('connection-delete', { connectionId: connId })
|
||||
} catch (error) {
|
||||
Message.error('删除失败: ' + (error.message || error))
|
||||
Message.error('删除失败: ' + (error?.message || error))
|
||||
await loadConnections()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -946,7 +975,22 @@ const handleTreeContextMenu = (event: MouseEvent) => {
|
||||
|
||||
// 处理菜单项点击
|
||||
const handleMenuItemClick = (item: MenuItem) => {
|
||||
contextMenu.handleMenuItemClick(item, emit as any)
|
||||
if (item.key === 'delete') {
|
||||
// 右键菜单的删除功能:显示确认对话框
|
||||
const nodeData = contextMenu.currentNodeData.value
|
||||
if (nodeData) {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除连接 "${nodeData.title || nodeData.name}" 吗?`,
|
||||
okText: '删除',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { status: 'danger' },
|
||||
onOk: () => handleDeleteConnection(nodeData)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
contextMenu.handleMenuItemClick(item, emit as any)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新特定节点
|
||||
|
||||
@@ -29,23 +29,26 @@
|
||||
</a-radio-group>
|
||||
</a-space>
|
||||
</div>
|
||||
<div v-if="viewMode === 'table' && data.length > 0" class="result-table-container" ref="resultTableContainerRef">
|
||||
<a-table
|
||||
:columns="tableColumns"
|
||||
:data="pagedData"
|
||||
:pagination="false"
|
||||
:loading="loading"
|
||||
size="mini"
|
||||
:scroll="{ x: 'max-content', y: tableScrollHeight }"
|
||||
:bordered="true"
|
||||
class="result-table"
|
||||
column-resizable
|
||||
/>
|
||||
<!-- 表格视图 -->
|
||||
<template v-if="viewMode === 'table'">
|
||||
<div v-if="data.length > 0" class="result-table-container" ref="resultTableContainerRef">
|
||||
<a-table
|
||||
:columns="tableColumns"
|
||||
:data="pagedData"
|
||||
:pagination="false"
|
||||
:loading="loading"
|
||||
size="mini"
|
||||
:bordered="false"
|
||||
class="result-table"
|
||||
column-resizable
|
||||
:scroll="{ y: resultTableScrollHeight, x: 'max-content' }"
|
||||
/>
|
||||
</div>
|
||||
<!-- 自定义分页控制 -->
|
||||
<div class="custom-pagination">
|
||||
<div class="custom-pagination" v-if="data.length > 0">
|
||||
<a-space>
|
||||
<a-button
|
||||
size="small"
|
||||
<a-button
|
||||
size="small"
|
||||
:disabled="currentPage <= 1 || loading"
|
||||
@click="handlePageChange(currentPage - 1)"
|
||||
>
|
||||
@@ -55,8 +58,8 @@
|
||||
第 {{ currentPage }} 页,{{ pagedData.length }} 条
|
||||
<span v-if="!canGoNext && !loading" style="color: var(--color-text-4);">(已到最后一页)</span>
|
||||
</span>
|
||||
<a-button
|
||||
size="small"
|
||||
<a-button
|
||||
size="small"
|
||||
:disabled="!canGoNext || loading"
|
||||
@click="handlePageChange(currentPage + 1)"
|
||||
>
|
||||
@@ -64,10 +67,11 @@
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewMode === 'table' && data.length === 0" class="result-empty-table">
|
||||
<a-empty description="查询结果为空" :image="false"/>
|
||||
</div>
|
||||
<div v-else class="result-empty-table">
|
||||
<a-empty description="查询结果为空" :image="false"/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- JSON 视图 -->
|
||||
<div v-else-if="viewMode === 'json'" class="result-json-container">
|
||||
<pre class="result-json" v-html="lazyHighlightedJSON"></pre>
|
||||
</div>
|
||||
@@ -677,30 +681,36 @@ const minTableHeight = computed(() => {
|
||||
return props.editorVisible ? MIN_TABLE_HEIGHT : Math.max(120, MIN_TABLE_HEIGHT * 0.6)
|
||||
})
|
||||
|
||||
// 统一的当前tab高度更新函数
|
||||
const updateCurrentTabHeight = () => {
|
||||
if (resultTab.value === 'result') {
|
||||
updateResultTableScrollHeight()
|
||||
} else if (resultTab.value === 'structure') {
|
||||
updateTableScrollHeight()
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖的高度更新(300ms 防抖,确保 DOM 稳定后再计算)
|
||||
let heightUpdateTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const debouncedHeightUpdate = () => {
|
||||
if (heightUpdateTimer) clearTimeout(heightUpdateTimer)
|
||||
heightUpdateTimer = setTimeout(() => {
|
||||
updateCurrentTabHeight()
|
||||
heightUpdateTimer = null
|
||||
}, 300)
|
||||
}
|
||||
// 结果表格滚动高度
|
||||
const resultTableScrollHeight = ref(400)
|
||||
// 触发高度重新计算
|
||||
const tableHeightTrigger = ref(0)
|
||||
|
||||
// 延迟更新函数(用于特定场景)
|
||||
const delayedUpdate = (callback: () => void, delay: number = 100) => {
|
||||
nextTick(() => setTimeout(callback, delay))
|
||||
}
|
||||
|
||||
// 更新结果表格滚动高度
|
||||
const updateResultTableScrollHeight = () => {
|
||||
const tableContainer = document.querySelector('.result-table-container') as HTMLElement
|
||||
if (!tableContainer) {
|
||||
delayedUpdate(updateResultTableScrollHeight, 50)
|
||||
return
|
||||
}
|
||||
|
||||
const containerHeight = tableContainer.offsetHeight
|
||||
|
||||
// 获取表头高度
|
||||
const tableHeaderElement = tableContainer.querySelector('.arco-table-header') as HTMLElement
|
||||
const tableHeaderHeight = tableHeaderElement ? tableHeaderElement.offsetHeight : 0
|
||||
|
||||
// 计算表格 body 可用高度(容器高度 - 表头 - 少量边距)
|
||||
const availableHeight = containerHeight - tableHeaderHeight - 4
|
||||
|
||||
resultTableScrollHeight.value = Math.max(MIN_TABLE_HEIGHT, availableHeight)
|
||||
}
|
||||
|
||||
// 从localStorage加载tab状态
|
||||
const loadResultTab = (): string => {
|
||||
try {
|
||||
@@ -715,40 +725,32 @@ const resultTab = ref(loadResultTab())
|
||||
const tableContainerRef = ref<HTMLElement | null>(null)
|
||||
const resultTableContainerRef = ref<HTMLElement | null>(null)
|
||||
const tableScrollHeight = ref(400)
|
||||
const resultTableScrollHeight = ref(400)
|
||||
const mysqlCreateRef = ref<InstanceType<typeof MySQLCreate> | null>(null)
|
||||
|
||||
// 计算结果表格滚动高度 - 改进版本
|
||||
const updateResultTableHeight = () => {
|
||||
// 使用 setTimeout 确保 DOM 渲染完成
|
||||
setTimeout(() => {
|
||||
if (!resultTableContainerRef.value) return
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (resultTab.value === 'history' && !loadedTabs.value.has('history')) {
|
||||
loadedTabs.value.add('history')
|
||||
handleSearchHistory()
|
||||
}
|
||||
// 初始化表格高度
|
||||
tableHeightTrigger.value++
|
||||
})
|
||||
|
||||
const container = resultTableContainerRef.value
|
||||
// 监听窗口大小变化
|
||||
windowResizeHandler = () => {
|
||||
tableHeightTrigger.value++
|
||||
}
|
||||
window.addEventListener('resize', windowResizeHandler)
|
||||
})
|
||||
|
||||
// 获取容器的实际高度
|
||||
const containerHeight = container.offsetHeight
|
||||
|
||||
// 获取分页栏高度
|
||||
const paginationEl = container.querySelector('.custom-pagination') as HTMLElement
|
||||
const paginationHeight = paginationEl ? paginationEl.offsetHeight : 40
|
||||
|
||||
// 获取表头实际高度(需要等待表格渲染)
|
||||
const tableHeaderEl = container.querySelector('.arco-table-header') as HTMLElement
|
||||
const tableHeaderHeight = tableHeaderEl ? tableHeaderEl.offsetHeight : 40
|
||||
|
||||
// 计算可用的 tbody 高度(scroll.y 控制 tbody 的高度)
|
||||
// 容器高度 - 分页高度 - 表头高度 - 边距(8px)
|
||||
const availableHeight = containerHeight - paginationHeight - tableHeaderHeight - 8
|
||||
|
||||
// 设置合理的边界,防止溢出或过小
|
||||
// 最小值确保表格可用,最大值防止超出容器
|
||||
const minHeight = 100
|
||||
const maxHeight = availableHeight > 0 ? availableHeight : 400
|
||||
|
||||
tableScrollHeight.value = Math.max(minHeight, maxHeight)
|
||||
}, 150)
|
||||
}
|
||||
onUnmounted(() => {
|
||||
// 清理 resize 监听器
|
||||
if (windowResizeHandler) {
|
||||
window.removeEventListener('resize', windowResizeHandler)
|
||||
windowResizeHandler = null
|
||||
}
|
||||
})
|
||||
|
||||
// 视图模式切换(表格/JSON)
|
||||
const viewMode = ref<'table' | 'json'>('table')
|
||||
@@ -858,41 +860,6 @@ const isContainerReady = (container: HTMLElement | null): boolean => {
|
||||
return container ? container.offsetHeight >= 10 : false
|
||||
}
|
||||
|
||||
// 更新结果表格高度(一次性计算,不递归)
|
||||
const updateResultTableScrollHeight = () => {
|
||||
if (!resultTableContainerRef.value) {
|
||||
resultTableScrollHeight.value = minTableHeight.value
|
||||
return
|
||||
}
|
||||
|
||||
const wrapper = resultTableContainerRef.value.closest('.result-data-wrapper') as HTMLElement
|
||||
if (!wrapper || wrapper.offsetHeight < 10) {
|
||||
resultTableScrollHeight.value = minTableHeight.value
|
||||
return
|
||||
}
|
||||
|
||||
const wrapperHeight = wrapper.offsetHeight
|
||||
|
||||
// 获取统计栏高度(包括 margin)
|
||||
const statsElement = wrapper.querySelector('.result-stats') as HTMLElement
|
||||
const statsHeight = statsElement ? statsElement.offsetHeight + 8 : 0 // 8px for margin-bottom
|
||||
|
||||
// 获取表头高度(在渲染后才能获取)
|
||||
const headerElement = resultTableContainerRef.value.querySelector('.arco-table-header') as HTMLElement
|
||||
const headerHeight = headerElement ? headerElement.offsetHeight : 0
|
||||
|
||||
// 获取分页栏高度
|
||||
const paginationElement = wrapper.querySelector('.custom-pagination') as HTMLElement
|
||||
const paginationHeight = paginationElement ? paginationElement.offsetHeight : 0
|
||||
|
||||
// 计算表格 body 可用高度
|
||||
// 总高度 - 统计栏 - 表头 - 分页栏 - 少量边距
|
||||
const availableHeight = wrapperHeight - statsHeight - headerHeight - paginationHeight - 16
|
||||
|
||||
// 设置表格滚动高度(确保不小于最小高度)
|
||||
resultTableScrollHeight.value = Math.max(minTableHeight.value, availableHeight)
|
||||
}
|
||||
|
||||
// 更新结构表格高度
|
||||
const updateTableScrollHeight = () => {
|
||||
if (!tableContainerRef.value) return
|
||||
@@ -935,46 +902,44 @@ const switchToResultTab = () => {
|
||||
resultTab.value = 'result'
|
||||
}
|
||||
|
||||
// 监听标签切换,自动更新对应表格高度并保存状态
|
||||
watch(() => resultTab.value, (newTab, oldTab) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.RESULT_TAB, newTab)
|
||||
} catch (e) {
|
||||
// 静默失败
|
||||
}
|
||||
emit('tab-change', newTab, oldTab)
|
||||
// 监听标签切换
|
||||
watch(() => resultTab.value, (newTab) => {
|
||||
localStorage.setItem(STORAGE_KEYS.RESULT_TAB, newTab)
|
||||
emit('tab-change', newTab, resultTab.value)
|
||||
|
||||
// 切换到结果 tab 时更新表格高度
|
||||
// 切换到结果 tab 时,更新表格高度
|
||||
if (newTab === 'result') {
|
||||
nextTick(updateResultTableHeight)
|
||||
tableHeightTrigger.value++
|
||||
}
|
||||
|
||||
nextTick(debouncedHeightUpdate)
|
||||
})
|
||||
|
||||
// 监听编辑器可见性变化,更新当前tab高度
|
||||
// 监听编辑器可见性
|
||||
watch(() => props.editorVisible, () => {
|
||||
updateResultTableHeight()
|
||||
debouncedHeightUpdate()
|
||||
if (resultTab.value === 'result') {
|
||||
tableHeightTrigger.value++
|
||||
}
|
||||
})
|
||||
|
||||
// 监听数据变化,更新表格高度
|
||||
// 监听数据变化
|
||||
watch(() => props.data, () => {
|
||||
nextTick(updateResultTableHeight)
|
||||
if (resultTab.value === 'result') {
|
||||
tableHeightTrigger.value++
|
||||
}
|
||||
})
|
||||
|
||||
// 监听数据结构变化,自动更新高度
|
||||
// 监听结构数据变化
|
||||
watch(() => props.structureData, () => {
|
||||
if (resultTab.value === 'structure' && props.structureData) {
|
||||
tableHeightTrigger.value++
|
||||
debouncedHeightUpdate()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听编辑模式切换,更新表格高度
|
||||
watch(() => props.editMode, () => {
|
||||
if (resultTab.value === 'structure') {
|
||||
debouncedHeightUpdate()
|
||||
// 监听触发器,更新表格高度
|
||||
watch(tableHeightTrigger, () => {
|
||||
if (resultTab.value === 'result') {
|
||||
delayedUpdate(updateResultTableScrollHeight, 50)
|
||||
} else if (resultTab.value === 'structure') {
|
||||
delayedUpdate(updateTableScrollHeight, 50)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -990,37 +955,6 @@ watch(() => [props.editMode, props.structureData?.columns], ([editMode, columns]
|
||||
// 窗口 resize 监听
|
||||
let windowResizeHandler: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
// 如果初始 tab 是 history,加载数据
|
||||
if (resultTab.value === 'history' && !loadedTabs.value.has('history')) {
|
||||
loadedTabs.value.add('history')
|
||||
handleSearchHistory()
|
||||
}
|
||||
|
||||
// 初始化高度
|
||||
updateCurrentTabHeight()
|
||||
|
||||
// 如果当前是结果 tab,更新表格高度
|
||||
if (resultTab.value === 'result') {
|
||||
updateResultTableHeight()
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
windowResizeHandler = createDebounceFn(() => {
|
||||
updateResultTableHeight()
|
||||
debouncedHeightUpdate()
|
||||
}, 100)
|
||||
window.addEventListener('resize', windowResizeHandler)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (windowResizeHandler) {
|
||||
window.removeEventListener('resize', windowResizeHandler)
|
||||
}
|
||||
})
|
||||
|
||||
// 历史记录相关
|
||||
const historyComposable = useResultHistory()
|
||||
const historyLoading = computed(() => historyComposable.loading.value)
|
||||
@@ -1695,23 +1629,7 @@ const mongoSampleTableData = computed(() => {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 编辑器隐藏时的优化样式 */
|
||||
|
||||
.result-panel-wrapper.editor-hidden .result-content,
|
||||
.result-panel-wrapper.editor-hidden .messages-content,
|
||||
.result-panel-wrapper.editor-hidden .structure-content,
|
||||
.result-panel-wrapper.editor-hidden .history-content,
|
||||
.result-panel-wrapper.editor-hidden .create-content {
|
||||
padding: var(--spacing-sm, 8px);
|
||||
}
|
||||
|
||||
.result-panel-wrapper.editor-hidden .result-stats {
|
||||
margin-bottom: var(--spacing-xs, 4px);
|
||||
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
|
||||
}
|
||||
|
||||
|
||||
/* Tabs容器 - 只设置外层容器,完全交给 Arco 控制内部 */
|
||||
/* Tabs容器 */
|
||||
.result-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1744,7 +1662,6 @@ const mongoSampleTableData = computed(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* 结果Tab内容 */
|
||||
.result-content {
|
||||
flex: 1;
|
||||
@@ -1768,6 +1685,8 @@ const mongoSampleTableData = computed(() => {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.result-stats {
|
||||
@@ -1782,44 +1701,30 @@ const mongoSampleTableData = computed(() => {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
/* 表格容器 - 使用 Arco Table 自带滚动 */
|
||||
.result-table-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: var(--border-radius-small, 2px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.arco-table) {
|
||||
/* 使用 scroll.y 后,表格会变成固定高度 */
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.arco-table-container) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.arco-table-content) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.arco-table-body) {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
.custom-pagination {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-sm, 8px);
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--color-bg-2);
|
||||
margin-top: 4px;
|
||||
border-radius: var(--border-radius-small, 2px);
|
||||
}
|
||||
|
||||
/* 查询结果表格紧凑样式 */
|
||||
/* 表格样式 */
|
||||
.result-table-container :deep(.result-table) {
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
}
|
||||
@@ -1830,6 +1735,10 @@ const mongoSampleTableData = computed(() => {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-bg-2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.result-table .arco-table-td) {
|
||||
|
||||
@@ -153,6 +153,7 @@ import { useCreateState } from './composables/useCreateState'
|
||||
import { createResizeHandler } from './utils/resize'
|
||||
import { STORAGE_KEYS } from './constants/storage'
|
||||
import { executeQuery } from '@/api'
|
||||
import { getFriendlyDatabaseError } from '@/utils/database-error'
|
||||
|
||||
// 类型声明
|
||||
declare global {
|
||||
@@ -423,8 +424,7 @@ const handleConnectionTest = async (data: ConnectionTestEvent) => {
|
||||
await window.go?.main?.App?.TestDbConnection?.(data.connectionId)
|
||||
Message.success('连接测试成功')
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Message.error('连接测试失败: ' + errorMessage)
|
||||
Message.error(getFriendlyDatabaseError(error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,7 +952,7 @@ onUnmounted(() => {
|
||||
/* 结果区域 - 使用 Arco Layout Content */
|
||||
.result-area {
|
||||
flex: 1;
|
||||
min-height: 150px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
|
||||
4
web/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
4
web/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
@@ -1,8 +1,8 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {filesystem} from '../models';
|
||||
import {main} from '../models';
|
||||
import {api} from '../models';
|
||||
import {main} from '../models';
|
||||
|
||||
export function CheckUpdate():Promise<Record<string, any>>;
|
||||
|
||||
@@ -86,6 +86,8 @@ export function ListSqlTabs():Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function ListZipContents(arg1:string):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function LoadAllDatabases(arg1:api.LoadAllDatabasesRequest):Promise<Array<string>>;
|
||||
|
||||
export function OpenPath(arg1:string):Promise<void>;
|
||||
|
||||
export function PreviewTableStructure(arg1:number,arg2:string,arg3:string,arg4:Record<string, any>):Promise<Array<string>>;
|
||||
|
||||
@@ -166,6 +166,10 @@ export function ListZipContents(arg1) {
|
||||
return window['go']['main']['App']['ListZipContents'](arg1);
|
||||
}
|
||||
|
||||
export function LoadAllDatabases(arg1) {
|
||||
return window['go']['main']['App']['LoadAllDatabases'](arg1);
|
||||
}
|
||||
|
||||
export function OpenPath(arg1) {
|
||||
return window['go']['main']['App']['OpenPath'](arg1);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,32 @@ export namespace api {
|
||||
this.enabled = source["enabled"];
|
||||
}
|
||||
}
|
||||
export class LoadAllDatabasesRequest {
|
||||
id: number;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
database: string;
|
||||
options: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new LoadAllDatabasesRequest(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.username = source["username"];
|
||||
this.password = source["password"];
|
||||
this.database = source["database"];
|
||||
this.options = source["options"];
|
||||
}
|
||||
}
|
||||
export class SaveConnectionRequest {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -28,6 +54,7 @@ export namespace api {
|
||||
password: string;
|
||||
database: string;
|
||||
options: string;
|
||||
visible_databases: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SaveConnectionRequest(source);
|
||||
@@ -44,6 +71,7 @@ export namespace api {
|
||||
this.password = source["password"];
|
||||
this.database = source["database"];
|
||||
this.options = source["options"];
|
||||
this.visible_databases = source["visible_databases"];
|
||||
}
|
||||
}
|
||||
export class TestConnectionRequest {
|
||||
|
||||
Reference in New Issue
Block a user