Private
Public Access
1
0

新增:数据库可见性过滤与连接管理增强

功能:
- 支持配置 MySQL/MongoDB 可见数据库列表
- 连接删除时自动清理关联数据并关闭连接池
- 新增加载数据库列表 API
- 数据库错误提示优化

改进:
- 代码简化:消除重复的表单验证和密码处理逻辑
- ResultPanel 表格高度计算重构
- 删除调试日志和临时文件

后端:
- 新增 VisibleDatabases 字段到连接模型
- DeleteConnection 使用事务确保数据一致性
- LoadAllDatabases 支持 MySQL/MongoDB 数据库列表加载
This commit is contained in:
2026-02-13 00:38:25 +08:00
parent 0229cab550
commit d62b9ca7bd
15 changed files with 993 additions and 386 deletions

31
app.go
View File

@@ -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
View 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工具退出")
}

View File

@@ -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,
)
}

View File

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

View File

@@ -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
View 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

View File

@@ -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'

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

View File

@@ -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>

View File

@@ -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)
}
}
// 刷新特定节点

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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>>;

View File

@@ -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);
}

View File

@@ -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 {