重构: 死代码清理 + 拷贝优化 + 滚动条修复
This commit is contained in:
104
app.go
104
app.go
@@ -6,16 +6,13 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
stdruntime "runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"u-desk/internal/api"
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/database"
|
||||
"u-desk/internal/filesystem"
|
||||
"u-desk/internal/service"
|
||||
"u-desk/internal/storage"
|
||||
@@ -24,13 +21,9 @@ import (
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// PDF 有序列表正则(包级变量,避免循环内重复编译)
|
||||
var orderedListRe = regexp.MustCompile(`^\d+\.\s+`)
|
||||
|
||||
// App 应用结构体
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
db *database.DB
|
||||
connectionAPI *api.ConnectionAPI
|
||||
sqlAPI *api.SqlAPI
|
||||
tabAPI *api.TabAPI
|
||||
@@ -229,36 +222,6 @@ func (a *App) Shutdown(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// QueryUsers 查询用户列表
|
||||
func (a *App) QueryUsers(keyword string, status int, role int, organid int, page int, pageSize int, sortField string, sortOrder string) (map[string]interface{}, error) {
|
||||
db, err := a.getDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db.QueryUsers(keyword, status, role, organid, page, pageSize, sortField, sortOrder)
|
||||
}
|
||||
|
||||
// getDB 获取数据库连接(延迟加载,按需初始化)
|
||||
func (a *App) getDB() (*database.DB, error) {
|
||||
if a.db != nil {
|
||||
return a.db, nil
|
||||
}
|
||||
|
||||
// 首次调用时才连接数据库
|
||||
db, err := database.Init()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("数据库连接失败: %v", err)
|
||||
}
|
||||
|
||||
a.db = db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Greet 测试方法
|
||||
func (a *App) Greet(name string) string {
|
||||
return "Hello " + name + ", It's show time!"
|
||||
}
|
||||
|
||||
// GetSystemInfo 获取系统信息
|
||||
func (a *App) GetSystemInfo() (map[string]interface{}, error) {
|
||||
return system.GetSystemInfo()
|
||||
@@ -992,70 +955,3 @@ func (a *App) SelectPDFSaveDirectory() (string, error) {
|
||||
return a.pdfAPI.SelectDirectory()
|
||||
}
|
||||
|
||||
// ExportMarkdownToPDF 使用gofpdf导出Markdown为PDF
|
||||
func (a *App) ExportMarkdownToPDF(markdownContent string) (string, error) {
|
||||
// 1. 弹出保存对话框
|
||||
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
Title: "保存 PDF",
|
||||
DefaultFilename: "document.pdf",
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "PDF 文件", Pattern: "*.pdf"},
|
||||
},
|
||||
})
|
||||
if err != nil || savePath == "" {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 2. 创建PDF
|
||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||
pdf.AddPage()
|
||||
pdf.SetAutoPageBreak(true, 15)
|
||||
|
||||
// 3. 解析Markdown并写入PDF
|
||||
lines := strings.Split(markdownContent, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "# ") {
|
||||
// H1 标题
|
||||
pdf.SetFont("Arial", "B", 24)
|
||||
pdf.Cell(40, 10, strings.TrimPrefix(line, "# "))
|
||||
pdf.Ln(12)
|
||||
} else if strings.HasPrefix(line, "## ") {
|
||||
// H2 标题
|
||||
pdf.SetFont("Arial", "B", 18)
|
||||
pdf.Cell(40, 10, strings.TrimPrefix(line, "## "))
|
||||
pdf.Ln(10)
|
||||
} else if strings.HasPrefix(line, "### ") {
|
||||
// H3 标题
|
||||
pdf.SetFont("Arial", "B", 14)
|
||||
pdf.Cell(40, 10, strings.TrimPrefix(line, "### "))
|
||||
pdf.Ln(8)
|
||||
} else if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
|
||||
// 无序列表
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.Cell(10, 7, "•")
|
||||
pdf.Cell(0, 7, strings.TrimPrefix(line, "- "))
|
||||
pdf.Ln(7)
|
||||
} else if orderedListRe.MatchString(line) {
|
||||
// 有序列表
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.Cell(10, 7, strings.TrimSpace(strings.SplitN(line, ".", 2)[0]) + ".")
|
||||
pdf.Cell(0, 7, strings.TrimSpace(strings.SplitN(line, ".", 2)[1]))
|
||||
pdf.Ln(7)
|
||||
} else if line == "" {
|
||||
// 空行
|
||||
pdf.Ln(7)
|
||||
} else {
|
||||
// 普通文本
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.MultiCell(190, 7, line, "", "", false)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 保存文件
|
||||
err = pdf.OutputFileAndClose(savePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("保存PDF文件失败: %v", err)
|
||||
}
|
||||
|
||||
return savePath, nil
|
||||
}
|
||||
|
||||
1
go.mod
1
go.mod
@@ -7,7 +7,6 @@ require (
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/redis/go-redis/v9 v9.17.3
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
|
||||
10
go.sum
10
go.sum
@@ -4,7 +4,6 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -17,7 +16,6 @@ github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZ
|
||||
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
@@ -59,9 +57,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
||||
@@ -93,10 +88,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -110,7 +103,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
@@ -119,7 +111,6 @@ github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI
|
||||
github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
|
||||
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
@@ -159,7 +150,6 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
|
||||
@@ -12,6 +12,3 @@ const (
|
||||
|
||||
// DefaultVisibleTabs 默认可见的 Tabs
|
||||
var DefaultVisibleTabs = []string{TabDatabase, TabFileSystem, TabDevice}
|
||||
|
||||
// DefaultTab 默认打开的 Tab
|
||||
const DefaultTab = TabDatabase
|
||||
|
||||
@@ -61,12 +61,3 @@ func IsWindows() bool {
|
||||
return runtime.GOOS == "windows"
|
||||
}
|
||||
|
||||
// IsMac 判断是否为Mac系统
|
||||
func IsMac() bool {
|
||||
return runtime.GOOS == "darwin"
|
||||
}
|
||||
|
||||
// IsLinux 判断是否为Linux系统
|
||||
func IsLinux() bool {
|
||||
return runtime.GOOS == "linux"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -303,52 +302,6 @@ func getContentType(ext string) string {
|
||||
return defaultFileTypeManager.GetMIMEType(ext)
|
||||
}
|
||||
|
||||
// ReadFileAsBase64 读取文件并返回 base64 编码的字符串
|
||||
// 用于读取从 ZIP 提取的临时图片文件
|
||||
func ReadFileAsBase64(filePath string) (string, error) {
|
||||
log.Printf("[ReadFileAsBase64] 读取文件: %s", filePath)
|
||||
|
||||
if !isSafePath(filePath) {
|
||||
return "", fmt.Errorf("路径不安全")
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("文件不存在: %s", filePath)
|
||||
}
|
||||
return "", fmt.Errorf("无法访问文件: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[ReadFileAsBase64] 文件大小: %d bytes", fileInfo.Size())
|
||||
|
||||
// 读取文件
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 编码为 base64
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
log.Printf("[ReadFileAsBase64] 编码成功: 原始=%d, base64=%d", len(data), len(encoded))
|
||||
|
||||
// 获取文件扩展名并确定 MIME 类型
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
mimeType := getContentType(ext)
|
||||
|
||||
// 返回 data URI 格式: data:image/png;base64,iVBORw0KG...
|
||||
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil
|
||||
}
|
||||
|
||||
// HandleLocalFile 处理 /localfs/ 路由的 HTTP 请求
|
||||
// 前端可以请求 http://localhost:18765/localfs/C:/path/to/image.jpg
|
||||
// 注意:此函数与 ServeHTTP 功能重复,建议统一使用 ServeHTTP
|
||||
func HandleLocalFile(w http.ResponseWriter, r *http.Request) {
|
||||
handler := NewLocalFileHandler()
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// isAllowedFileType 检查文件类型是否在白名单中
|
||||
func isAllowedFileType(ext string) bool {
|
||||
return defaultFileTypeManager.IsAllowed(ext)
|
||||
|
||||
@@ -220,37 +220,6 @@ func (a *AuditLogger) Close() error {
|
||||
return a.logFile.Close()
|
||||
}
|
||||
|
||||
// RotateLog 日志轮转(每天创建新文件)
|
||||
func (a *AuditLogger) RotateLog() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
// 刷新缓冲区
|
||||
if err := a.flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 关闭当前文件
|
||||
if err := a.logFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 生成新的日志文件名
|
||||
timestamp := time.Now().Format("2006-01-02")
|
||||
logPath := filepath.Join(filepath.Dir(a.logPath), fmt.Sprintf("audit_%s.log", timestamp))
|
||||
|
||||
// 打开新文件
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.logFile = logFile
|
||||
a.logPath = logPath
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRecentLogs 获取最近的审计日志
|
||||
func GetRecentLogs(logDir string, limit int) ([]AuditLogEntry, error) {
|
||||
// 读取今天的日志文件
|
||||
@@ -309,22 +278,8 @@ func parseLines(text string) []string {
|
||||
var globalAuditLogger *AuditLogger
|
||||
var auditLoggerOnce sync.Once
|
||||
|
||||
// InitAuditLogger 初始化全局审计日志记录器
|
||||
func InitAuditLogger(logDir string) error {
|
||||
var err error
|
||||
globalAuditLogger, err = NewAuditLogger(logDir)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAuditLogger 获取全局审计日志记录器
|
||||
func GetAuditLogger() *AuditLogger {
|
||||
return globalAuditLogger
|
||||
}
|
||||
|
||||
// CloseAuditLogger 关闭全局审计日志记录器
|
||||
func CloseAuditLogger() error {
|
||||
if globalAuditLogger != nil {
|
||||
return globalAuditLogger.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,22 +13,13 @@ const (
|
||||
|
||||
// HTTP 文件服务大小限制
|
||||
MaxHTTPFileSize = 500 * 1024 * 1024 // 500MB - HTTP 访问文件最大大小
|
||||
|
||||
// 删除操作限制
|
||||
MaxDeleteSizeGB = 1 * 1024 * 1024 * 1024 // 1GB - 单个文件删除大小限制
|
||||
MaxDeleteDirSizeGB = 1 * 1024 * 1024 * 1024 // 1GB - 目录删除大小限制
|
||||
)
|
||||
|
||||
// 时间相关常量
|
||||
const (
|
||||
// 审计日志
|
||||
AuditFlushInterval = 5 * time.Second // 审计日志刷新间隔
|
||||
AuditLogBufferSize = 100 // 审计日志缓冲区大小
|
||||
|
||||
// 回收站
|
||||
RecycleBinRetentionDays = 30 // 回收站文件保留天数(天)
|
||||
RecycleBinRetentionPeriod = 30 * 24 * time.Hour // 回收站文件保留期
|
||||
|
||||
// 临时文件
|
||||
TempFileCleanupAge = 24 * time.Hour // 临时文件清理周期
|
||||
TempFileDir = "u-desk-zip" // 临时文件目录名
|
||||
@@ -36,7 +27,6 @@ const (
|
||||
|
||||
// 数量限制常量
|
||||
const (
|
||||
MaxDirectoryDepth = 15 // 最大目录深度
|
||||
MaxFileCount = 1000 // 最大文件数量(目录)
|
||||
)
|
||||
|
||||
@@ -48,15 +38,9 @@ const (
|
||||
|
||||
// 随机字符串相关常量
|
||||
const (
|
||||
RandomStringCharset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
RandomStringDefaultLength = 6 // 回收站文件名随机后缀长度
|
||||
)
|
||||
|
||||
// 文件路径相关常量
|
||||
const (
|
||||
WindowsDriveLength = 2 // Windows 盘符长度 (C:)
|
||||
)
|
||||
|
||||
// 路径遍历检测字符串
|
||||
const (
|
||||
PathTraversalPattern = ".." // 路径遍历特征字符串
|
||||
@@ -69,17 +53,5 @@ const (
|
||||
FileTypeAudio = "audio"
|
||||
FileTypeDocument = "document"
|
||||
FileTypeText = "text"
|
||||
FileTypeArchive = "archive"
|
||||
FileTypeApplication = "application"
|
||||
)
|
||||
|
||||
// 安全相关常量
|
||||
const (
|
||||
// ZIP 安全
|
||||
MinValidZipSize = 22 // ZIP 文件最小有效大小(文件头)
|
||||
ZipFileHeaderSignature = 0x504B // "PK" - ZIP 文件头签名
|
||||
|
||||
// 文件锁
|
||||
LockCheckMaxRetries = 3 // 文件锁检查最大重试次数
|
||||
LockCheckRetryInterval = 100 * time.Millisecond // 文件锁检查重试间隔
|
||||
)
|
||||
|
||||
@@ -6,130 +6,6 @@ import (
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// ErrorCode 错误码类型
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
// 通用错误
|
||||
ErrCodeGeneral ErrorCode = "GENERAL_ERROR"
|
||||
ErrCodeInvalid ErrorCode = "INVALID_ARGUMENT"
|
||||
ErrCodeNotFound ErrorCode = "NOT_FOUND"
|
||||
ErrCodePermission ErrorCode = "PERMISSION_DENIED"
|
||||
ErrCodeIO ErrorCode = "IO_ERROR"
|
||||
|
||||
// 路径相关错误
|
||||
ErrCodePathTraversal ErrorCode = "PATH_TRAVERSAL"
|
||||
ErrCodeInvalidPath ErrorCode = "INVALID_PATH"
|
||||
ErrCodeSensitivePath ErrorCode = "SENSITIVE_PATH"
|
||||
|
||||
// 文件操作错误
|
||||
ErrCodeFileNotFound ErrorCode = "FILE_NOT_FOUND"
|
||||
ErrCodeFileExists ErrorCode = "FILE_EXISTS"
|
||||
ErrCodeDirectoryNotEmpty ErrorCode = "DIRECTORY_NOT_EMPTY"
|
||||
|
||||
// 安全相关错误
|
||||
ErrCodeSecurityViolation ErrorCode = "SECURITY_VIOLATION"
|
||||
ErrCodeSizeLimit ErrorCode = "SIZE_LIMIT_EXCEEDED"
|
||||
ErrCodeFileLocked ErrorCode = "FILE_LOCKED"
|
||||
|
||||
// ZIP相关错误
|
||||
ErrCodeZipInvalid ErrorCode = "ZIP_INVALID"
|
||||
ErrCodeZipBomb ErrorCode = "ZIP_BOMB"
|
||||
ErrCodeZipExtract ErrorCode = "ZIP_EXTRACT_FAILED"
|
||||
)
|
||||
|
||||
// FileError 文件系统专用错误类型
|
||||
// 包含详细的错误上下文信息,便于调试和用户提示
|
||||
type FileNotFoundError struct {
|
||||
Path string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *FileNotFoundError) Error() string {
|
||||
return fmt.Sprintf("文件不存在: %s", e.Path)
|
||||
}
|
||||
|
||||
func (e *FileNotFoundError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// PathValidationError 路径验证错误
|
||||
type PathValidationError struct {
|
||||
Path string
|
||||
Reason string
|
||||
IsSensitive bool
|
||||
}
|
||||
|
||||
func (e *PathValidationError) Error() string {
|
||||
return fmt.Sprintf("路径验证失败: %s - %s", e.Path, e.Reason)
|
||||
}
|
||||
|
||||
// SecurityViolationError 安全违规错误
|
||||
type SecurityViolationError struct {
|
||||
Path string
|
||||
Violation string
|
||||
Suggestion string
|
||||
}
|
||||
|
||||
func (e *SecurityViolationError) Error() string {
|
||||
msg := fmt.Sprintf("安全违规: %s - %s", e.Path, e.Violation)
|
||||
if e.Suggestion != "" {
|
||||
msg += fmt.Sprintf("\n建议: %s", e.Suggestion)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// SizeLimitError 大小限制错误
|
||||
type SizeLimitError struct {
|
||||
Path string
|
||||
ActualSize int64
|
||||
MaxSize int64
|
||||
SizeType string // "file" or "directory"
|
||||
}
|
||||
|
||||
func (e *SizeLimitError) Error() string {
|
||||
return fmt.Sprintf("%s大小超限: %s (实际: %.2f GB, 限制: %.2f GB)",
|
||||
e.SizeType, e.Path,
|
||||
float64(e.ActualSize)/(1024*1024*1024),
|
||||
float64(e.MaxSize)/(1024*1024*1024),
|
||||
)
|
||||
}
|
||||
|
||||
// FileLockedError 文件锁定错误
|
||||
type FileLockedError struct {
|
||||
Path string
|
||||
ProcessInfo string
|
||||
}
|
||||
|
||||
func (e *FileLockedError) Error() string {
|
||||
msg := fmt.Sprintf("文件被占用: %s", e.Path)
|
||||
if e.ProcessInfo != "" {
|
||||
msg += fmt.Sprintf("\n占用程序: %s", e.ProcessInfo)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// WrapError 错误包装函数
|
||||
// 添加上下文信息到错误中
|
||||
func WrapError(operation string, path string, err error) error {
|
||||
return fmt.Errorf("%s 失败: %s - %w", operation, path, err)
|
||||
}
|
||||
|
||||
// WrapErrorf 格式化错误包装
|
||||
func WrapErrorf(format string, args ...interface{}) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// GetStackTrace 获取堆栈跟踪(用于调试)
|
||||
func GetStackTrace(skip int) string {
|
||||
buf := make([]byte, 4096)
|
||||
n := runtime.Stack(buf, false)
|
||||
if n > 0 {
|
||||
return string(buf[:n])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DeleteRestrictionWarning 删除限制警告
|
||||
// 用于在删除受限文件时提供详细的警告信息
|
||||
type DeleteRestrictionWarning struct {
|
||||
@@ -141,3 +17,13 @@ type DeleteRestrictionWarning struct {
|
||||
func (w *DeleteRestrictionWarning) Error() string {
|
||||
return fmt.Sprintf("删除限制警告: %s\n%s", w.Path, w.Details)
|
||||
}
|
||||
|
||||
// GetStackTrace 获取堆栈跟踪(用于调试)
|
||||
func GetStackTrace(skip int) string {
|
||||
buf := make([]byte, 4096)
|
||||
n := runtime.Stack(buf, false)
|
||||
if n > 0 {
|
||||
return string(buf[:n])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -4,14 +4,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Windows API 锁相关函数和常量
|
||||
var (
|
||||
modkernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procGetLastError = modkernel32.NewProc("GetLastError")
|
||||
procGetProcessId = modkernel32.NewProc("GetProcessId")
|
||||
)
|
||||
|
||||
// FileLockChecker 文件锁检查器
|
||||
@@ -102,37 +94,6 @@ func (c *FileLockChecker) getProcessInfo(path string) (string, error) {
|
||||
return "文件正被其他程序使用", nil
|
||||
}
|
||||
|
||||
// CheckFileWithRetry 带重试的文件锁检查
|
||||
func (c *FileLockChecker) CheckFileWithRetry(path string, maxRetries int, retryInterval time.Duration) error {
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
locked, processInfo, err := c.IsFileLocked(path)
|
||||
if err != nil && !locked {
|
||||
// 非锁相关的错误,直接返回
|
||||
return err
|
||||
}
|
||||
|
||||
if !locked {
|
||||
// 文件未被锁定,可以操作
|
||||
return nil
|
||||
}
|
||||
|
||||
// 文件被锁定
|
||||
if i < maxRetries-1 {
|
||||
// 还有重试机会,等待后重试
|
||||
time.Sleep(retryInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
// 最后一次重试失败,返回错误
|
||||
if processInfo != "" {
|
||||
return fmt.Errorf("文件被占用: %s", processInfo)
|
||||
}
|
||||
return fmt.Errorf("文件被其他程序占用,请关闭相关程序后重试")
|
||||
}
|
||||
|
||||
return fmt.Errorf("文件检查超时")
|
||||
}
|
||||
|
||||
// SafeDeleteWithLockCheck 带锁检查的安全删除
|
||||
func (c *FileLockChecker) SafeDeleteWithLockCheck(path string) error {
|
||||
// 检查文件是否被锁定
|
||||
@@ -158,20 +119,6 @@ const (
|
||||
ERROR_SHARING_VIOLATION = 32 // syscall.Errno(32)
|
||||
)
|
||||
|
||||
// BY_HANDLE_FILE_INFORMATION 文件信息结构体
|
||||
type BY_HANDLE_FILE_INFORMATION struct {
|
||||
FileAttributes uint32
|
||||
CreationTime syscall.Filetime
|
||||
LastAccessTime syscall.Filetime
|
||||
LastWriteTime syscall.Filetime
|
||||
VolumeSerialNumber uint32
|
||||
FileSizeHigh uint32
|
||||
FileSizeLow uint32
|
||||
NumberOfLinks uint32
|
||||
FileIndexHigh uint32
|
||||
FileIndexLow uint32
|
||||
}
|
||||
|
||||
// contains 检查字符串是否包含子串(不区分大小写)
|
||||
func contains(str, substr string) bool {
|
||||
return len(str) >= len(substr) && (str == substr || len(substr) == 0 ||
|
||||
@@ -203,18 +150,3 @@ func containsIgnoreCase(str, substr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 全局文件锁检查器
|
||||
var globalLockChecker *FileLockChecker
|
||||
|
||||
// InitFileLockChecker 初始化全局文件锁检查器
|
||||
func InitFileLockChecker() {
|
||||
globalLockChecker = NewFileLockChecker()
|
||||
}
|
||||
|
||||
// GetFileLockChecker 获取全局文件锁检查器
|
||||
func GetFileLockChecker() *FileLockChecker {
|
||||
if globalLockChecker == nil {
|
||||
globalLockChecker = NewFileLockChecker()
|
||||
}
|
||||
return globalLockChecker
|
||||
}
|
||||
|
||||
@@ -70,11 +70,6 @@ func (l *Logger) Info(format string, args ...interface{}) {
|
||||
l.log(LogLevelInfo, "INFO", format, args...)
|
||||
}
|
||||
|
||||
// Warn 记录警告日志
|
||||
func (l *Logger) Warn(format string, args ...interface{}) {
|
||||
l.log(LogLevelWarn, "WARN", format, args...)
|
||||
}
|
||||
|
||||
// Error 记录错误日志
|
||||
func (l *Logger) Error(format string, args ...interface{}) {
|
||||
l.log(LogLevelError, "ERROR", format, args...)
|
||||
@@ -141,37 +136,10 @@ func LogError(operation string, path string, err error) {
|
||||
|
||||
var (
|
||||
globalLogger *Logger
|
||||
loggerOnce sync.Once
|
||||
)
|
||||
|
||||
// InitLogger 初始化全局日志记录器
|
||||
func InitLogger(logDir string, minLevel LogLevel) error {
|
||||
var initErr error
|
||||
loggerOnce.Do(func() {
|
||||
timestamp := time.Now().Format("2006-01-02")
|
||||
logPath := filepath.Join(logDir, fmt.Sprintf("filesystem_%s.log", timestamp))
|
||||
|
||||
logger, err := NewLogger(logPath, minLevel)
|
||||
if err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
|
||||
globalLogger = logger
|
||||
log.Printf("[日志系统] 已启动,日志文件: %s", logPath)
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
// GetGlobalLogger 获取全局日志记录器
|
||||
func GetGlobalLogger() *Logger {
|
||||
return globalLogger
|
||||
}
|
||||
|
||||
// CloseLogger 关闭全局日志记录器
|
||||
func CloseLogger() error {
|
||||
if globalLogger != nil {
|
||||
return globalLogger.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -376,16 +376,6 @@ func generateRandomString(length int) string {
|
||||
// 全局回收站实例
|
||||
var globalRecycleBin *RecycleBin
|
||||
|
||||
// InitRecycleBin 初始化全局回收站
|
||||
func InitRecycleBin(binPath string) error {
|
||||
bin, err := NewRecycleBin(binPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
globalRecycleBin = bin
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRecycleBin 获取全局回收站实例
|
||||
func GetRecycleBin() *RecycleBin {
|
||||
return globalRecycleBin
|
||||
|
||||
@@ -119,11 +119,6 @@ func (s *FileSystemService) initRecycleBin() error {
|
||||
|
||||
// ========== 核心文件操作 ==========
|
||||
|
||||
// Read 读取文件内容(实现 FileService 接口)
|
||||
func (s *FileSystemService) Read(path string) (string, error) {
|
||||
return s.ReadFile(path)
|
||||
}
|
||||
|
||||
// ReadFile 读取文件内容(限制最大 10MB)
|
||||
func (s *FileSystemService) ReadFile(path string) (string, error) {
|
||||
// 路径验证
|
||||
@@ -151,10 +146,6 @@ func (s *FileSystemService) ReadFile(path string) (string, error) {
|
||||
}
|
||||
|
||||
// Write 写入文件内容(实现 FileService 接口)
|
||||
func (s *FileSystemService) Write(path, content string) error {
|
||||
return s.WriteFile(path, content)
|
||||
}
|
||||
|
||||
// writeFile 内部写入实现(路径验证+大小检查+写入+日志)
|
||||
func (s *FileSystemService) writeFileWithLog(path string, data []byte) error {
|
||||
if err := s.validatePath(path); err != nil {
|
||||
@@ -192,31 +183,6 @@ func (s *FileSystemService) SaveBase64File(path, base64Content string) error {
|
||||
return s.writeFileWithLog(path, data)
|
||||
}
|
||||
|
||||
// List 列出目录内容(实现 FileService 接口)
|
||||
func (s *FileSystemService) List(path string) ([]map[string]interface{}, error) {
|
||||
return s.ListDir(path)
|
||||
}
|
||||
|
||||
// Open 打开文件(实现 FileService 接口)
|
||||
func (s *FileSystemService) Open(path string) error {
|
||||
// 使用系统默认程序打开文件
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", "", path)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", path)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", path)
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// Delete 删除文件或目录(实现 FileService 接口)
|
||||
func (s *FileSystemService) Delete(path string) (*FileOperationResult, error) {
|
||||
return s.DeletePathWithContext(context.Background(), path)
|
||||
}
|
||||
|
||||
// DeletePath 删除文件或目录
|
||||
func (s *FileSystemService) DeletePath(path string) (*FileOperationResult, error) {
|
||||
return s.DeletePathWithContext(context.Background(), path)
|
||||
@@ -430,11 +396,6 @@ func (s *FileSystemService) CreateFile(path string) (*FileOperationResult, error
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetInfo 获取文件信息(实现 FileService 接口)
|
||||
func (s *FileSystemService) GetInfo(path string) (map[string]interface{}, error) {
|
||||
return s.GetFileInfo(path)
|
||||
}
|
||||
|
||||
// GetFileInfo 获取文件信息
|
||||
func (s *FileSystemService) GetFileInfo(path string) (map[string]interface{}, error) {
|
||||
if err := s.validatePath(path); err != nil {
|
||||
@@ -519,31 +480,16 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
|
||||
|
||||
// ========== ZIP操作接口 ==========
|
||||
|
||||
// ListZip 列出ZIP文件内容
|
||||
func (s *FileSystemService) ListZip(zipPath string) ([]map[string]interface{}, error) {
|
||||
return ListZipContents(zipPath)
|
||||
}
|
||||
|
||||
// ListZipContents 列出ZIP文件内容(别名,保持向后兼容)
|
||||
func (s *FileSystemService) ListZipContents(zipPath string) ([]map[string]interface{}, error) {
|
||||
return ListZipContents(zipPath)
|
||||
}
|
||||
|
||||
// ExtractZipFile 从ZIP提取文件内容
|
||||
func (s *FileSystemService) ExtractZipFile(zipPath, filePath string) (string, error) {
|
||||
return ExtractFileFromZip(zipPath, filePath)
|
||||
}
|
||||
|
||||
// ExtractFileFromZip 从ZIP提取文件内容(别名,保持向后兼容)
|
||||
func (s *FileSystemService) ExtractFileFromZip(zipPath, filePath string) (string, error) {
|
||||
return ExtractFileFromZip(zipPath, filePath)
|
||||
}
|
||||
|
||||
// ExtractZipFileToTemp 从ZIP提取文件到临时目录
|
||||
func (s *FileSystemService) ExtractZipFileToTemp(zipPath, filePath string) (string, error) {
|
||||
return ExtractFileFromZipToTemp(zipPath, filePath)
|
||||
}
|
||||
|
||||
// ExtractFileFromZipToTemp 从ZIP提取文件到临时目录(别名,保持向后兼容)
|
||||
func (s *FileSystemService) ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
|
||||
return ExtractFileFromZipToTemp(zipPath, filePath)
|
||||
@@ -564,7 +510,9 @@ func getCurrentTimestamp() time.Time {
|
||||
// isInRecycleBin 检查路径是否在回收站中
|
||||
func isInRecycleBin(path string) bool {
|
||||
recycleBinPath := filepath.Join(common.GetUserDataDir(), "recycle_bin")
|
||||
return filepath.HasPrefix(filepath.Clean(path), filepath.Clean(recycleBinPath))
|
||||
cleanPath := filepath.Clean(path)
|
||||
cleanBinPath := filepath.Clean(recycleBinPath)
|
||||
return len(cleanPath) >= len(cleanBinPath) && cleanPath[:len(cleanBinPath)] == cleanBinPath
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
@@ -787,16 +735,3 @@ func GetGlobalService() (*FileSystemService, error) {
|
||||
return globalService, initErr
|
||||
}
|
||||
|
||||
// InitGlobalFileSystem 初始化全局文件系统(兼容旧代码)
|
||||
func InitGlobalFileSystem() error {
|
||||
_, err := GetGlobalService()
|
||||
return err
|
||||
}
|
||||
|
||||
// CloseGlobalFileSystem 关闭全局文件系统
|
||||
func CloseGlobalFileSystem(ctx context.Context) error {
|
||||
if globalService != nil {
|
||||
return globalService.Close(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -346,46 +346,4 @@ func GetZipFileInfo(zipPath, filePath string) (map[string]interface{}, error) {
|
||||
return result.(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
// validateZipFileBasic 验证ZIP文件的基本信息(提取自ListZipContents)
|
||||
func validateZipFileBasic(zipPath string) error {
|
||||
if err := validateZipPath(zipPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法访问文件: %v", err)
|
||||
}
|
||||
|
||||
if fileInfo.Size() < MinValidZipSize {
|
||||
return fmt.Errorf("文件太小 (%d bytes)", fileInfo.Size())
|
||||
}
|
||||
|
||||
if fileInfo.Size() > MaxZipSize {
|
||||
return fmt.Errorf("ZIP文件过大 (%d bytes)", fileInfo.Size())
|
||||
}
|
||||
|
||||
return checkZipFileHeader(zipPath)
|
||||
}
|
||||
|
||||
// checkZipFileHeader 检查ZIP文件头签名
|
||||
func checkZipFileHeader(zipPath string) error {
|
||||
file, err := os.Open(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法打开文件: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
header := make([]byte, 4)
|
||||
n, err := file.Read(header)
|
||||
if err != nil || n != 4 {
|
||||
return fmt.Errorf("无法读取文件头")
|
||||
}
|
||||
|
||||
if header[0] != 0x50 || header[1] != 0x4B {
|
||||
return fmt.Errorf("不是有效的 ZIP 文件")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -65,25 +65,6 @@ func isMatchFile(file *zip.File, targetPath string) bool {
|
||||
filepath.Clean(file.Name) == filepath.Clean(targetPath)
|
||||
}
|
||||
|
||||
// openZipFileInReader 在ZIP reader中打开指定文件
|
||||
// 用于读取文件内容的辅助函数
|
||||
func openZipFileInReader(reader *zip.ReadCloser, filePath string) (io.ReadCloser, *zip.File, error) {
|
||||
for _, file := range reader.File {
|
||||
if isMatchFile(file, filePath) {
|
||||
if file.Mode().IsDir() {
|
||||
return nil, nil, fmt.Errorf("不能读取目录")
|
||||
}
|
||||
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("打开 zip 中的文件失败: %v", err)
|
||||
}
|
||||
return rc, file, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("文件在 zip 中不存在: %s", filePath)
|
||||
}
|
||||
|
||||
// readAllFromFile 从文件读取所有内容
|
||||
// 辅助函数,避免重复的 io.ReadAll 调用
|
||||
func readAllFromFile(rc io.ReadCloser) ([]byte, error) {
|
||||
|
||||
@@ -29,8 +29,3 @@ func (s *TabService) SaveTabs(tabs []models.SqlTab) error {
|
||||
func (s *TabService) ListTabs() ([]models.SqlTab, error) {
|
||||
return s.repo.FindAll()
|
||||
}
|
||||
|
||||
// DeleteTab 删除标签页
|
||||
func (s *TabService) DeleteTab(id uint) error {
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
|
||||
@@ -102,22 +102,6 @@ func SaveUpdateConfig(config *UpdateConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShouldCheckUpdate 判断是否应该检查更新
|
||||
func (c *UpdateConfig) ShouldCheckUpdate() bool {
|
||||
if !c.AutoCheckEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果从未检查过,应该检查
|
||||
if c.LastCheckTime.IsZero() {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查是否超过间隔分钟数
|
||||
minutesSinceLastCheck := time.Since(c.LastCheckTime).Minutes()
|
||||
return minutesSinceLastCheck >= float64(c.CheckIntervalMinutes)
|
||||
}
|
||||
|
||||
// UpdateLastCheckTime 更新最后检查时间
|
||||
func (c *UpdateConfig) UpdateLastCheckTime() error {
|
||||
c.LastCheckTime = time.Now()
|
||||
|
||||
@@ -64,11 +64,6 @@ func ParseVersion(versionStr string) (*Version, error) {
|
||||
return &Version{Major: major, Minor: minor, Patch: patch}, nil
|
||||
}
|
||||
|
||||
// String 返回版本号字符串(格式:v1.0.0)
|
||||
func (v *Version) String() string {
|
||||
return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||
}
|
||||
|
||||
// Compare 比较版本号
|
||||
// 返回值:-1 表示当前版本小于目标版本,0 表示相等,1 表示大于
|
||||
func (v *Version) Compare(other *Version) int {
|
||||
@@ -100,11 +95,6 @@ func (v *Version) IsNewerThan(other *Version) bool {
|
||||
return v.Compare(other) > 0
|
||||
}
|
||||
|
||||
// IsOlderThan 判断是否比目标版本旧
|
||||
func (v *Version) IsOlderThan(other *Version) bool {
|
||||
return v.Compare(other) < 0
|
||||
}
|
||||
|
||||
// ==================== 版本号获取 ====================
|
||||
|
||||
// GetCurrentVersion 获取当前版本号(带缓存)
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"u-desk/internal/crypto"
|
||||
"u-desk/internal/dbclient"
|
||||
"u-desk/internal/storage/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ConnectionService 连接管理服务
|
||||
type ConnectionService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewConnectionService 创建连接服务
|
||||
func NewConnectionService() (*ConnectionService, error) {
|
||||
db := GetDB()
|
||||
if db == nil {
|
||||
// 尝试重新初始化
|
||||
var err error
|
||||
db, err = Init()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("数据库初始化失败: %v", err)
|
||||
}
|
||||
}
|
||||
return &ConnectionService{db: db}, nil
|
||||
}
|
||||
|
||||
// SaveConnection 保存连接配置
|
||||
func (s *ConnectionService) SaveConnection(conn *models.DbConnection) error {
|
||||
if conn.Name == "" {
|
||||
return fmt.Errorf("连接名称不能为空")
|
||||
}
|
||||
if conn.Type == "" {
|
||||
return fmt.Errorf("数据库类型不能为空")
|
||||
}
|
||||
if conn.Host == "" {
|
||||
return fmt.Errorf("主机地址不能为空")
|
||||
}
|
||||
|
||||
// 检查名称是否重复(排除当前记录)
|
||||
var count int64
|
||||
query := s.db.Model(&models.DbConnection{}).Where("name = ?", conn.Name)
|
||||
if conn.ID > 0 {
|
||||
query = query.Where("id != ?", conn.ID)
|
||||
}
|
||||
query.Count(&count)
|
||||
if count > 0 {
|
||||
return fmt.Errorf("连接名称已存在")
|
||||
}
|
||||
|
||||
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,
|
||||
"visible_databases": conn.VisibleDatabases,
|
||||
}
|
||||
|
||||
// 如果提供了新密码,加密后更新
|
||||
if conn.Password != "" {
|
||||
encrypted, err := crypto.EncryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败: %v", err)
|
||||
}
|
||||
updateData["password"] = encrypted
|
||||
}
|
||||
// 如果密码为空,不更新密码字段(保留原密码)
|
||||
|
||||
return s.db.Model(&models.DbConnection{}).Where("id = ?", conn.ID).Updates(updateData).Error
|
||||
}
|
||||
|
||||
// 新增模式 - 必须提供密码
|
||||
if conn.Password == "" {
|
||||
return fmt.Errorf("新增连接时密码不能为空")
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
encrypted, err := crypto.EncryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败: %v", err)
|
||||
}
|
||||
conn.Password = encrypted
|
||||
|
||||
return s.db.Create(conn).Error
|
||||
}
|
||||
|
||||
// ListConnections 获取连接列表
|
||||
func (s *ConnectionService) ListConnections() ([]models.DbConnection, error) {
|
||||
var connections []models.DbConnection
|
||||
err := s.db.Order("created_at DESC").Find(&connections).Error
|
||||
return connections, err
|
||||
}
|
||||
|
||||
// GetConnection 获取连接详情
|
||||
func (s *ConnectionService) GetConnection(id uint) (*models.DbConnection, error) {
|
||||
var conn models.DbConnection
|
||||
err := s.db.First(&conn, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &conn, nil
|
||||
}
|
||||
|
||||
// DeleteConnection 删除连接配置
|
||||
func (s *ConnectionService) DeleteConnection(id uint) 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
|
||||
})
|
||||
}
|
||||
|
||||
// resolvePassword 解析密码(编辑模式下从已保存连接中获取)
|
||||
func (s *ConnectionService) resolvePassword(id uint, password string) (string, error) {
|
||||
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)
|
||||
}
|
||||
return decryptPassword, nil
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// parseMongoOptions 解析 MongoDB 连接选项
|
||||
func parseMongoOptions(options string) (authSource, authMechanism string) {
|
||||
if options == "" {
|
||||
return "", ""
|
||||
}
|
||||
var opts map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(options), &opts); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
authSource, _ = opts["authSource"].(string)
|
||||
authMechanism, _ = opts["authMechanism"].(string)
|
||||
return authSource, authMechanism
|
||||
}
|
||||
|
||||
// TestConnection 测试连接(需要根据类型调用不同的测试方法)
|
||||
func (s *ConnectionService) TestConnection(conn *models.DbConnection) error {
|
||||
password, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
|
||||
authSource, authMechanism := parseMongoOptions(conn.Options)
|
||||
|
||||
return s.testConnectionByType(conn.Type, conn.Host, conn.Port, conn.Username, password, conn.Database, authSource, authMechanism)
|
||||
}
|
||||
|
||||
// testConnectionByType 根据类型调用对应的测试方法
|
||||
func (s *ConnectionService) testConnectionByType(dbType, host string, port int, username, password, database, authSource, authMechanism string) error {
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
return testMySQLConnection(host, port, username, password, database)
|
||||
case "redis":
|
||||
return testRedisConnection(host, port, password)
|
||||
case "mongo":
|
||||
return testMongoConnection(host, port, username, password, database, authSource, authMechanism)
|
||||
default:
|
||||
return fmt.Errorf("不支持的数据库类型: %s", dbType)
|
||||
}
|
||||
}
|
||||
|
||||
// testMySQLConnection 测试 MySQL 连接
|
||||
func testMySQLConnection(host string, port int, username, password, database string) error {
|
||||
return dbclient.TestMySQLConnection(host, port, username, password, database)
|
||||
}
|
||||
|
||||
// testRedisConnection 测试 Redis 连接
|
||||
func testRedisConnection(host string, port int, password string) error {
|
||||
return dbclient.TestRedisConnection(host, port, password)
|
||||
}
|
||||
|
||||
// testMongoConnection 测试 MongoDB 连接
|
||||
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 {
|
||||
password, err := s.resolvePassword(id, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authSource, authMechanism := parseMongoOptions(options)
|
||||
return s.testConnectionByType(dbType, host, port, username, password, database, authSource, authMechanism)
|
||||
}
|
||||
|
||||
// LoadAllDatabases 加载全部数据库列表
|
||||
func (s *ConnectionService) LoadAllDatabases(dbType, host string, port int, username, password, database, options string, id uint) ([]string, error) {
|
||||
password, err := s.resolvePassword(id, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authSource, authMechanism := parseMongoOptions(options)
|
||||
|
||||
// 根据类型加载数据库列表
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
return loadMySQLDatabases(host, port, username, password, database)
|
||||
case "mongo":
|
||||
return loadMongoDatabasesWithOptions(host, port, username, password, database, authSource, authMechanism)
|
||||
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())
|
||||
}
|
||||
|
||||
// loadMongoDatabasesWithOptions 加载 MongoDB 数据库列表(使用解析后的选项)
|
||||
func loadMongoDatabasesWithOptions(host string, port int, username, password, defaultDatabase, authSource, authMechanism string) ([]string, error) {
|
||||
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())
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SqlFile SQL 文件记录
|
||||
type SqlFile struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"type:varchar(200);not null" json:"name"` // 文件名
|
||||
Path string `gorm:"type:varchar(500);not null;uniqueIndex" json:"path"` // 文件路径
|
||||
Content string `gorm:"type:text" json:"content"` // 文件内容
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SqlFile) TableName() string {
|
||||
return "sql_file"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Version 版本信息
|
||||
type Version struct {
|
||||
ID int `gorm:"primaryKey" json:"id"` // 主键ID
|
||||
Version string `gorm:"type:varchar(20);not null;uniqueIndex" json:"version"` // 版本号(语义化版本,如1.0.0)
|
||||
DownloadURL string `gorm:"type:varchar(500)" json:"download_url"` // 下载地址(更新包下载URL)
|
||||
Changelog string `gorm:"type:text" json:"changelog"` // 更新日志(Markdown格式)
|
||||
ForceUpdate int `gorm:"type:tinyint;not null;default:0" json:"force_update"` // 是否强制更新(1:是 0:否)
|
||||
ReleaseDate *time.Time `gorm:"type:date" json:"release_date"` // 发布日期
|
||||
CreatedAt time.Time `gorm:"autoCreateTime:false" json:"created_at"` // 创建时间(由程序设置)
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime:false" json:"updated_at"` // 更新时间(由程序设置)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Version) TableName() string {
|
||||
return "sys_version"
|
||||
}
|
||||
@@ -5,17 +5,13 @@ import (
|
||||
"u-desk/internal/storage"
|
||||
"u-desk/internal/storage/models"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ResultRepository interface {
|
||||
Save(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (*models.SqlResultHistory, error)
|
||||
FindByID(id uint) (*models.SqlResultHistory, error)
|
||||
FindByConnection(connectionID uint, limit int) ([]models.SqlResultHistory, error)
|
||||
Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error)
|
||||
Delete(id uint) error
|
||||
DeleteByConnection(connectionID uint) error
|
||||
DeleteOld(keepDays int) error
|
||||
}
|
||||
|
||||
type resultRepository struct {
|
||||
@@ -61,15 +57,6 @@ func (r *resultRepository) FindByID(id uint) (*models.SqlResultHistory, error) {
|
||||
return &history, err
|
||||
}
|
||||
|
||||
func (r *resultRepository) FindByConnection(connectionID uint, limit int) ([]models.SqlResultHistory, error) {
|
||||
var histories []models.SqlResultHistory
|
||||
query := r.db.Where("connection_id = ?", connectionID).Order("created_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
return histories, query.Find(&histories).Error
|
||||
}
|
||||
|
||||
func (r *resultRepository) Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error) {
|
||||
query := r.db.Model(&models.SqlResultHistory{})
|
||||
|
||||
@@ -101,10 +88,3 @@ func (r *resultRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.SqlResultHistory{}, id).Error
|
||||
}
|
||||
|
||||
func (r *resultRepository) DeleteByConnection(connectionID uint) error {
|
||||
return r.db.Where("connection_id = ?", connectionID).Delete(&models.SqlResultHistory{}).Error
|
||||
}
|
||||
|
||||
func (r *resultRepository) DeleteOld(keepDays int) error {
|
||||
return r.db.Where("created_at < ?", time.Now().AddDate(0, 0, -keepDays)).Delete(&models.SqlResultHistory{}).Error
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ type TabRepository interface {
|
||||
SaveAll(tabs []models.SqlTab) error
|
||||
FindAll() ([]models.SqlTab, error)
|
||||
Delete(id uint) error
|
||||
DeleteAll() error
|
||||
}
|
||||
|
||||
type tabRepository struct {
|
||||
@@ -50,6 +49,3 @@ func (r *tabRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.SqlTab{}, id).Error
|
||||
}
|
||||
|
||||
func (r *tabRepository) DeleteAll() error {
|
||||
return r.db.Where("1=1").Delete(&models.SqlTab{}).Error
|
||||
}
|
||||
|
||||
@@ -29,16 +29,22 @@
|
||||
{{ config.currentFileName }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<icon-copy
|
||||
class="copy-icon"
|
||||
title="复制路径"
|
||||
@click="handleCopyPath"
|
||||
/>
|
||||
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="left">
|
||||
<a-button
|
||||
size="mini"
|
||||
type="text"
|
||||
:status="copied ? 'success' : 'normal'"
|
||||
@click="handleCopyPath"
|
||||
>
|
||||
<icon-copy v-if="!copied" />
|
||||
<icon-check v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<div class="editor-content thin-dark-scrollbar">
|
||||
<!-- 二进制文件提示 -->
|
||||
<div v-if="config.isBinaryFile" class="binary-file-message">
|
||||
<pre>{{ config.fileContent }}</pre>
|
||||
@@ -271,7 +277,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 预览模式 -->
|
||||
<div v-if="!config.isEditMode" ref="markdownPreviewRef" class="markdown-preview-content markdown-content" v-html="config.rendered"></div>
|
||||
<div v-if="!config.isEditMode" ref="markdownPreviewRef" class="markdown-preview-content markdown-content thin-dark-scrollbar" v-html="config.rendered"></div>
|
||||
|
||||
<!-- 编辑模式 -->
|
||||
<div v-else class="markdown-edit-wrapper">
|
||||
@@ -346,8 +352,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon'
|
||||
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy, IconCheck, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon'
|
||||
import { getFileName, escapeHtml } from '@/utils/fileUtils'
|
||||
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||
import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -730,23 +737,11 @@ const getPreviewButtonTooltip = () => {
|
||||
return '切换到预览'
|
||||
}
|
||||
|
||||
// 复制文件路径
|
||||
const handleCopyPath = () => {
|
||||
const path = props.config.currentFileFullPath
|
||||
if (!path) return
|
||||
// 复制文件路径(带状态反馈)
|
||||
const { copied, copy: copyPath, cleanup: copyCleanup } = useClipboardCopy()
|
||||
|
||||
navigator.clipboard.writeText(path).then(() => {
|
||||
Message.success('路径已复制')
|
||||
}).catch(() => {
|
||||
// 降级方案
|
||||
const input = document.createElement('input')
|
||||
input.value = path
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
Message.success('路径已复制')
|
||||
})
|
||||
const handleCopyPath = async () => {
|
||||
await copyPath(props.config.currentFileFullPath)
|
||||
}
|
||||
|
||||
// 处理 Markdown 预览中的本地文件链接点击
|
||||
@@ -810,6 +805,7 @@ onUnmounted(() => {
|
||||
window.removeEventListener('message', handleHtmlIframeMessage)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
copyCleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
:scroll="{ y: 'auto' }"
|
||||
class="file-table"
|
||||
@row-click="handleRowClick"
|
||||
@row-dblclick="handleRowDoubleClick"
|
||||
@row-contextmenu="handleRowContextMenu"
|
||||
/>
|
||||
|
||||
|
||||
@@ -32,10 +32,17 @@
|
||||
@navigate="handleGoToPath"
|
||||
@openFile="handleOpenFile"
|
||||
/>
|
||||
<a-tooltip content="复制路径" position="top">
|
||||
<div class="copy-icon-wrapper" @click="handleCopyPath">
|
||||
<icon-copy />
|
||||
</div>
|
||||
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="top">
|
||||
<a-button
|
||||
size="mini"
|
||||
type="text"
|
||||
:status="copied ? 'success' : 'normal'"
|
||||
class="toolbar-copy-btn"
|
||||
@click="handleCopyPath"
|
||||
>
|
||||
<icon-copy v-if="!copied" />
|
||||
<icon-check v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,9 +116,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy, IconCheck } from '@arco-design/web-vue/es/icon'
|
||||
import type { ToolbarConfig } from '@/types/file-system'
|
||||
import PathBreadcrumb from './PathBreadcrumb.vue'
|
||||
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
@@ -129,7 +137,6 @@ interface Emits {
|
||||
(e: 'goToPath', path: string): void
|
||||
(e: 'openFile', path: string): void
|
||||
(e: 'navigateToZipDirectory', path: string): void
|
||||
(e: 'showMessage', message: string, type: 'success' | 'error' | 'warning' | 'info'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -163,22 +170,10 @@ const handleToggleSidebar = () => {
|
||||
emit('update:showSidebar', !props.config.showSidebar)
|
||||
}
|
||||
|
||||
const handleCopyPath = async () => {
|
||||
const path = props.config.filePath
|
||||
if (!path) return
|
||||
const { copied, copy: copyPath } = useClipboardCopy()
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(path)
|
||||
emit('showMessage', '路径已复制', 'success')
|
||||
} catch {
|
||||
const input = document.createElement('input')
|
||||
input.value = path
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
emit('showMessage', '路径已复制', 'success')
|
||||
}
|
||||
const handleCopyPath = async () => {
|
||||
await copyPath(props.config.filePath)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -233,22 +228,8 @@ const handleCopyPath = async () => {
|
||||
border-color: var(--color-border-2);
|
||||
}
|
||||
|
||||
.copy-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy-icon-wrapper:hover {
|
||||
color: rgb(var(--primary-6));
|
||||
background: var(--color-fill-2);
|
||||
.toolbar-copy-btn {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.zip-breadcrumb {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
/**
|
||||
* 拷贝路径 composable(3-tier fallback: Wails native → clipboard API → execCommand)
|
||||
*/
|
||||
export function useClipboardCopy() {
|
||||
const copied = ref(false)
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const copy = async (path: string) => {
|
||||
if (!path || copied.value) return
|
||||
|
||||
try {
|
||||
if (window.runtime?.ClipboardSetText) {
|
||||
await window.runtime.ClipboardSetText(path)
|
||||
} else {
|
||||
await navigator.clipboard.writeText(path)
|
||||
}
|
||||
copied.value = true
|
||||
} catch {
|
||||
try {
|
||||
const input = document.createElement('input')
|
||||
input.style.position = 'fixed'
|
||||
input.style.opacity = '0'
|
||||
input.value = path
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
copied.value = true
|
||||
} catch {
|
||||
Message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (copyTimer) clearTimeout(copyTimer)
|
||||
copyTimer = setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (copyTimer) { clearTimeout(copyTimer); copyTimer = null }
|
||||
}
|
||||
|
||||
return { copied, copy, cleanup }
|
||||
}
|
||||
@@ -154,7 +154,6 @@ export default {
|
||||
const isFullscreen = ref(false)
|
||||
const isEditorExpanded = ref(false)
|
||||
const isPreviewExpanded = ref(false)
|
||||
const showPreview = ref(true)
|
||||
const editorWidthPercent = ref(50)
|
||||
const editorContentRef = ref(null)
|
||||
const previewRef = ref<HTMLElement | null>(null)
|
||||
@@ -231,13 +230,10 @@ export default {
|
||||
|
||||
// 切换功能
|
||||
const togglePreview = () => {
|
||||
showPreview.value = !showPreview.value
|
||||
if (showPreview.value) {
|
||||
// 恢复预览时重新调整大小
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
}
|
||||
// 预览面板始终显示,保留快捷键兼容性
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
@@ -323,16 +319,6 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
// 导出方法
|
||||
const getMarkdownContent = () => {
|
||||
return markdownContent.value
|
||||
}
|
||||
|
||||
const setMarkdownContent = (content) => {
|
||||
markdownContent.value = content
|
||||
hasChanges.value = content !== lastSavedContent.value
|
||||
}
|
||||
|
||||
return {
|
||||
markdownContent,
|
||||
textarea,
|
||||
@@ -343,13 +329,10 @@ export default {
|
||||
isFullscreen,
|
||||
isEditorExpanded,
|
||||
isPreviewExpanded,
|
||||
showPreview,
|
||||
handleInput,
|
||||
handleKeydown,
|
||||
saveContent,
|
||||
onExportComplete,
|
||||
getMarkdownContent,
|
||||
setMarkdownContent,
|
||||
togglePreview,
|
||||
toggleFullscreen,
|
||||
toggleEditorExpand,
|
||||
|
||||
@@ -16,7 +16,6 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const isDark = computed(() => theme.value === 'dark')
|
||||
const isLight = computed(() => theme.value === 'light')
|
||||
const tooltipText = computed(() =>
|
||||
isDark.value ? '切换到亮色主题' : '切换到夜间主题'
|
||||
)
|
||||
@@ -53,20 +52,6 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
applyTheme(newTheme)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置为亮色主题
|
||||
*/
|
||||
const setLightTheme = () => {
|
||||
applyTheme('light')
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置为暗色主题
|
||||
*/
|
||||
const setDarkTheme = () => {
|
||||
applyTheme('dark')
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化主题(应用启动时调用)
|
||||
*/
|
||||
@@ -96,16 +81,6 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
systemThemeListener = () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理系统主题监听器
|
||||
*/
|
||||
const removeSystemThemeListener = () => {
|
||||
if (systemThemeListener) {
|
||||
systemThemeListener()
|
||||
systemThemeListener = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 返回 ====================
|
||||
return {
|
||||
// 状态
|
||||
@@ -113,14 +88,10 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
|
||||
// 计算属性
|
||||
isDark,
|
||||
isLight,
|
||||
tooltipText,
|
||||
|
||||
// 方法
|
||||
toggleTheme,
|
||||
setLightTheme,
|
||||
setDarkTheme,
|
||||
initTheme,
|
||||
removeSystemThemeListener
|
||||
initTheme
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { formatBytes as formatFileSize } from '@/utils/fileUtils'
|
||||
|
||||
/**
|
||||
* 更新管理 Store
|
||||
@@ -38,14 +39,6 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (!bytes || bytes < 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
return formatFileSize(bytesPerSecond) + '/s'
|
||||
}
|
||||
@@ -211,6 +204,8 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
downloadProgress.value = 100
|
||||
downloadStatus.value = 'success'
|
||||
|
||||
const fileSize = (data.file_size as number) || 0
|
||||
|
||||
// 系统通知:下载完成
|
||||
try {
|
||||
window.runtime?.SendNotification?.({
|
||||
@@ -218,7 +213,6 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
body: `更新包下载完成 (${formatFileSize(fileSize)}),正在安装...`
|
||||
})
|
||||
} catch { /* 通知不可用时忽略 */ }
|
||||
const fileSize = (data.file_size as number) || 0
|
||||
progressInfo.value = {
|
||||
speed: 0,
|
||||
downloaded: fileSize,
|
||||
@@ -263,13 +257,6 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
window.runtime.EventsOff('download-complete')
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭更新提示
|
||||
*/
|
||||
const closeUpdateNotification = () => {
|
||||
showUpdate.value = false
|
||||
}
|
||||
|
||||
// ==================== 返回 ====================
|
||||
return {
|
||||
// 状态
|
||||
@@ -288,7 +275,6 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
installUpdate,
|
||||
setupEventListeners,
|
||||
removeEventListeners,
|
||||
closeUpdateNotification,
|
||||
formatFileSize,
|
||||
formatSpeed
|
||||
}
|
||||
|
||||
@@ -50,6 +50,29 @@ body {
|
||||
scrollbar-color: var(--color-border-2, rgba(0, 0, 0, 0.1)) transparent;
|
||||
}
|
||||
|
||||
/* 暗色细滚动条(用于编辑器/预览区) */
|
||||
.thin-dark-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border-3) transparent;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-2);
|
||||
}
|
||||
|
||||
/* Highlight.js CSS */
|
||||
.hljs {
|
||||
display: block;
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
*/
|
||||
|
||||
// Core
|
||||
export { EditorView, lineNumbers, highlightActiveLineGutter, keymap, drawSelection, dropCursor } from '@codemirror/view'
|
||||
export { EditorState, Compartment, Facet, StateEffect, StateField } from '@codemirror/state'
|
||||
export { EditorView, lineNumbers, highlightActiveLineGutter, keymap } from '@codemirror/view'
|
||||
export { EditorState, Compartment } from '@codemirror/state'
|
||||
export { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting, StreamLanguage } from '@codemirror/language'
|
||||
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
export { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
// 语言包通过 codeMirrorLoader 动态导入,避免全量打包
|
||||
|
||||
@@ -61,8 +61,6 @@ export const FILE_EXTENSIONS = {
|
||||
// 视频文件
|
||||
VIDEO_BROWSER: ['mp4', 'webm', 'ogg', 'mov', 'm4v'], // 浏览器原生支持
|
||||
VIDEO_EXTERNAL: ['avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'mts'], // 需要外部播放器(注意:不用 'ts' 避免 TypeScript 冲突)
|
||||
VIDEO: ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'mts'], // 所有视频
|
||||
|
||||
// 音频文件
|
||||
AUDIO: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'opus', 'webm'],
|
||||
|
||||
@@ -96,9 +94,6 @@ export const FILE_EXTENSIONS = {
|
||||
// 纯文本文件
|
||||
TEXT: ['txt', 'text', 'log', 'md', 'markdown', 'rst', 'adoc', 'tex', 'msg', 'csv', 'tsv'],
|
||||
|
||||
// 标记语言文件(用于特殊预览)
|
||||
MARKUP: ['html', 'htm', 'md', 'markdown'],
|
||||
|
||||
// 数据库文件
|
||||
DATABASE: ['db', 'sqlite', 'mdb', 'accdb'],
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FILE_ICON_MAP, FILE_ICONS, BYTE_UNITS, FILE_SIZE_FORMAT } from './const
|
||||
* 路径分隔符正则(匹配 Windows 和 Unix 风格)
|
||||
* @type {RegExp}
|
||||
*/
|
||||
export const PATH_SEPARATOR_REGEX = /[/\\]/
|
||||
const PATH_SEPARATOR_REGEX = /[/\\]/
|
||||
|
||||
/**
|
||||
* 规范化路径分隔符(统一为正斜杠)
|
||||
@@ -93,26 +93,6 @@ export function getFileName(path) {
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
/**
|
||||
* 分割路径为多个部分
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string[]} 路径数组
|
||||
*/
|
||||
export const splitPath = (path) => {
|
||||
if (!path) return []
|
||||
return path.split(PATH_SEPARATOR_REGEX)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件名(不含扩展名)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 文件名(不含扩展名)
|
||||
*/
|
||||
export const getFileNameWithoutExt = (path) => {
|
||||
const fileName = getFileName(path)
|
||||
const lastDot = fileName.lastIndexOf('.')
|
||||
return lastDot > 0 ? fileName.substring(0, lastDot) : fileName
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件信息获取对应的图标
|
||||
@@ -177,89 +157,6 @@ export function normalizeFilePath(path, encode = false) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件类型的友好名称
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 文件类型名称
|
||||
*
|
||||
* @example
|
||||
* getFileTypeName('image.png') // "PNG图片"
|
||||
* getFileTypeName('document.pdf') // "PDF文档"
|
||||
* getFileTypeName('unknown.xyz') // "XYZ文件"
|
||||
*/
|
||||
export function getFileTypeName(path) {
|
||||
const ext = getExt(path)
|
||||
const extUpper = ext.toUpperCase()
|
||||
|
||||
// 图片
|
||||
if (['JPG', 'JPEG', 'PNG', 'GIF', 'BMP', 'SVG', 'WEBP'].includes(extUpper)) {
|
||||
return `${extUpper}图片`
|
||||
}
|
||||
|
||||
// 视频
|
||||
if (['MP4', 'WEBM', 'AVI', 'MKV'].includes(extUpper)) {
|
||||
return `${extUpper}视频`
|
||||
}
|
||||
|
||||
// 音频
|
||||
if (['MP3', 'WAV', 'FLAC', 'AAC'].includes(extUpper)) {
|
||||
return `${extUpper}音频`
|
||||
}
|
||||
|
||||
// PDF
|
||||
if (extUpper === 'PDF') {
|
||||
return 'PDF文档'
|
||||
}
|
||||
|
||||
// 文档
|
||||
if (['DOC', 'DOCX', 'XLS', 'XLSX', 'PPT', 'PPTX'].includes(extUpper)) {
|
||||
return `${extUpper}文档`
|
||||
}
|
||||
|
||||
// 代码
|
||||
if (['JS', 'TS', 'PY', 'JAVA', 'GO', 'RS', 'CPP'].includes(extUpper)) {
|
||||
return `${extUpper}代码`
|
||||
}
|
||||
|
||||
// 默认返回扩展名
|
||||
return ext ? `${extUpper}文件` : '文件'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否为绝对路径
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean} 是否为绝对路径
|
||||
*
|
||||
* @example
|
||||
* isAbsolutePath('C:\\Users') // true (Windows)
|
||||
* isAbsolutePath('/home/user') // true (Unix)
|
||||
* isAbsolutePath('folder/file') // false
|
||||
*/
|
||||
export function isAbsolutePath(path) {
|
||||
if (!path) return false
|
||||
|
||||
// Windows路径:盘符开头
|
||||
if (/^[A-Za-z]:\\/.test(path)) return true
|
||||
|
||||
// Unix路径:以 / 开头
|
||||
if (path.startsWith('/')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接路径片段
|
||||
* @param {...string} parts - 路径片段
|
||||
* @returns {string} 拼接后的路径
|
||||
*
|
||||
* @example
|
||||
* joinPaths('/home', 'user', 'docs') // "/home/user/docs"
|
||||
* joinPaths('C:\\Users', 'user') // "C:\\Users\\user"
|
||||
*/
|
||||
export function joinPaths(...parts) {
|
||||
return parts.join('/').replace(/\/+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路径使用的分隔符(Windows 反斜杠或 Unix 正斜杠)
|
||||
* @param {string} path - 文件路径
|
||||
@@ -301,24 +198,6 @@ export function getParentPath(path) {
|
||||
return parentPath || '/'
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文件名,移除非法字符
|
||||
* @param {string} filename - 原始文件名
|
||||
* @param {string} [replacement='_'] - 替换字符
|
||||
* @returns {string} 清理后的文件名
|
||||
*
|
||||
* @example
|
||||
* sanitizeFileName('file/name.txt') // "file_name.txt"
|
||||
* sanitizeFileName('file:name.txt', '-') // "file-name.txt"
|
||||
*/
|
||||
export function sanitizeFileName(filename, replacement = '_') {
|
||||
if (!filename) return ''
|
||||
|
||||
// Windows不允许的字符: < > : " / \ | ? *
|
||||
const illegalChars = /[<>:"/\\|?*]/g
|
||||
|
||||
return filename.replace(illegalChars, replacement)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表排序:文件夹优先,支持多字段排序
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
:block-node="true"
|
||||
:default-expand-all="false"
|
||||
:show-line="false"
|
||||
:load-more="handleLoadMore"
|
||||
:selected-keys="selectedKeys"
|
||||
:expanded-keys="expandedKeys"
|
||||
@select="handleTreeSelect"
|
||||
@@ -586,7 +585,7 @@ const filteredTreeData = computed(() => {
|
||||
})
|
||||
|
||||
// 搜索历史管理
|
||||
const MAX_HISTORY = 100 // 最多保存10条历史
|
||||
const MAX_HISTORY = 100 // 最多保存100条历史
|
||||
|
||||
const saveSearchHistory = (text: string) => {
|
||||
if (!text.trim()) return
|
||||
@@ -694,22 +693,29 @@ const handleTreeSelect = (keys, info) => {
|
||||
|
||||
// 触发节点选择相关事件
|
||||
emitNodeSelectEvents(nodeData, conn)
|
||||
|
||||
// 选中连接/数据库时自动展开加载子节点(仅在未加载时触发)
|
||||
if (nodeData.type === 'connection' || nodeData.type === 'database') {
|
||||
if (!expandedKeys.value.includes(key)) {
|
||||
expandedKeys.value = [...expandedKeys.value, key]
|
||||
saveToStorage(STORAGE_KEYS.TREE_EXPANDED_KEYS, expandedKeys.value)
|
||||
}
|
||||
// 仅在子节点未加载时才触发展开加载,避免快速切换时的重复请求
|
||||
if (!nodeData.children || nodeData.children.length === 0) {
|
||||
handleNodeExpand(nodeData, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 树节点展开
|
||||
const handleTreeExpand = (keys: string[], info) => {
|
||||
// Arco Design Tree 的 expand 事件参数格式
|
||||
// keys: 展开的节点 key 数组
|
||||
// info: { expanded: boolean, node: TreeNodeData, e: Event }
|
||||
|
||||
// 更新展开状态
|
||||
expandedKeys.value = keys
|
||||
|
||||
|
||||
// 保存到localStorage
|
||||
saveToStorage(STORAGE_KEYS.TREE_EXPANDED_KEYS, keys)
|
||||
|
||||
|
||||
if (!info || !info.node) {
|
||||
// 如果没有 info.node,尝试从 keys 中获取
|
||||
if (keys && keys.length > 0) {
|
||||
const lastExpandedKey = keys[keys.length - 1]
|
||||
const nodeData = findNodeByKey(treeData.value, lastExpandedKey)
|
||||
@@ -720,10 +726,10 @@ const handleTreeExpand = (keys: string[], info) => {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const nodeData = info.node
|
||||
const isExpanded = info.expanded !== false // 默认为展开
|
||||
|
||||
const isExpanded = info.expanded !== false
|
||||
|
||||
if (isExpanded) {
|
||||
handleNodeExpand(nodeData, true)
|
||||
}
|
||||
@@ -732,14 +738,12 @@ const handleTreeExpand = (keys: string[], info) => {
|
||||
// 处理节点展开逻辑
|
||||
const handleNodeExpand = (nodeData, isExpanded) => {
|
||||
if (!isExpanded) return
|
||||
|
||||
|
||||
if (nodeData.type === 'connection') {
|
||||
// 连接节点:加载数据库列表
|
||||
if (!nodeData.children || nodeData.children.length === 0) {
|
||||
loadDatabases(nodeData)
|
||||
}
|
||||
} else if (nodeData.type === 'database') {
|
||||
// 数据库节点:加载表列表
|
||||
if (!nodeData.children || nodeData.children.length === 0) {
|
||||
loadTables(nodeData)
|
||||
}
|
||||
@@ -759,10 +763,22 @@ const handleLoadMore = (nodeData) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 通用加载节点数据的错误处理包装器
|
||||
// 通用加载节点数据的错误处理包装器(带超时保护)
|
||||
const NODE_LOAD_TIMEOUT = 15000 // 15秒超时
|
||||
|
||||
const withLoadingNode = async (nodeKey: string, loader: () => Promise<void>) => {
|
||||
if (loadingNodes.value.has(nodeKey)) return
|
||||
loadingNodes.value.add(nodeKey)
|
||||
|
||||
// 超时保护:防止无限转圈
|
||||
const timer = setTimeout(() => {
|
||||
if (loadingNodes.value.has(nodeKey)) {
|
||||
loadingNodes.value.delete(nodeKey)
|
||||
refreshTreeData()
|
||||
console.warn(`[ConnectionTree] 节点 ${nodeKey} 加载超时 (${NODE_LOAD_TIMEOUT}ms)`)
|
||||
}
|
||||
}, NODE_LOAD_TIMEOUT)
|
||||
|
||||
try {
|
||||
await loader()
|
||||
// 强制触发响应式更新
|
||||
@@ -772,7 +788,9 @@ const withLoadingNode = async (nodeKey: string, loader: () => Promise<void>) =>
|
||||
const loadType = nodeKey.startsWith('conn-') ? 'databases' :
|
||||
nodeKey.startsWith('db-') ? 'tables' : 'keys'
|
||||
Message.error(getLoadFailedTip(error, loadType))
|
||||
console.error(`[ConnectionTree] 节点 ${nodeKey} 加载失败:`, error)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
loadingNodes.value.delete(nodeKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import {Message} from '@arco-design/web-vue'
|
||||
import {
|
||||
EditorView, keymap, lineNumbers,
|
||||
EditorState,
|
||||
sql, javascript,
|
||||
defaultKeymap, history, historyKeymap,
|
||||
defaultHighlightStyle, syntaxHighlighting
|
||||
} from '@/utils/codemirrorExports'
|
||||
@@ -55,22 +54,22 @@ const tabPersistence = useTabPersistence()
|
||||
// 数据库类型配置
|
||||
const DB_CONFIG = {
|
||||
mysql: {
|
||||
language: () => sql(),
|
||||
language: async () => (await import('@codemirror/lang-sql')).sql(),
|
||||
defaultContent: 'select 1;',
|
||||
executeText: '执行'
|
||||
},
|
||||
redis: {
|
||||
language: () => javascript({ jsx: false, typescript: false }),
|
||||
language: async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: false, typescript: false }),
|
||||
defaultContent: 'GET key\nSET key value\nHGET hash field',
|
||||
executeText: '执行命令'
|
||||
},
|
||||
mongo: {
|
||||
language: () => javascript({ jsx: false, typescript: false }),
|
||||
language: async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: false, typescript: false }),
|
||||
defaultContent: 'db.collection.find({})\n// 示例:db.users.find({name: "John"})',
|
||||
executeText: '执行查询'
|
||||
},
|
||||
mongodb: {
|
||||
language: () => javascript({ jsx: false, typescript: false }),
|
||||
language: async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: false, typescript: false }),
|
||||
defaultContent: 'db.collection.find({})\n// 示例:db.users.find({name: "John"})',
|
||||
executeText: '执行查询'
|
||||
}
|
||||
@@ -79,7 +78,7 @@ const DB_CONFIG = {
|
||||
// ==================== 工具函数 ====================
|
||||
const getDbType = () => props.currentConnection?.type?.toLowerCase() || 'mysql'
|
||||
const getDbConfig = (dbType = null) => DB_CONFIG[dbType || getDbType()] || DB_CONFIG.mysql
|
||||
const getLanguageMode = (dbType = null) => getDbConfig(dbType).language()
|
||||
const getLanguageMode = async (dbType = null) => getDbConfig(dbType).language()
|
||||
const getDefaultContent = (dbType = null) => getDbConfig(dbType).defaultContent
|
||||
const getExecuteButtonText = () => getDbConfig().executeText
|
||||
|
||||
@@ -91,9 +90,9 @@ let saveTimer = null
|
||||
const lastExecutionTime = ref(null)
|
||||
|
||||
// 创建编辑器扩展
|
||||
const createEditorExtensions = () => {
|
||||
const createEditorExtensions = async () => {
|
||||
const dbType = getDbType()
|
||||
const languageMode = getLanguageMode(dbType)
|
||||
const languageMode = await getLanguageMode(dbType)
|
||||
|
||||
return [
|
||||
EditorState.lineSeparator.of('\n'),
|
||||
@@ -202,7 +201,7 @@ const initEditor = async () => {
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: initialContent,
|
||||
extensions: createEditorExtensions()
|
||||
extensions: await createEditorExtensions()
|
||||
})
|
||||
|
||||
editorView = new EditorView({
|
||||
|
||||
@@ -20,7 +20,6 @@ import { IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
EditorView, lineNumbers,
|
||||
EditorState,
|
||||
sql,
|
||||
defaultHighlightStyle, syntaxHighlighting
|
||||
} from '@/utils/codemirrorExports'
|
||||
|
||||
@@ -68,6 +67,8 @@ const initEditor = async () => {
|
||||
// 检测是否为暗色主题
|
||||
const isDark = document.body.hasAttribute('arco-theme')
|
||||
|
||||
const { sql } = await import('@codemirror/lang-sql')
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: sqlText,
|
||||
extensions: [
|
||||
|
||||
@@ -264,7 +264,11 @@ const sqlPreviewInfo = ref<{
|
||||
// 编辑器/结果区域高度调整
|
||||
const loadEditorAreaHeight = (): number => {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.EDITOR_AREA_HEIGHT)
|
||||
return saved ? Number(saved) : 50
|
||||
if (saved) {
|
||||
const val = Number(saved)
|
||||
if (Number.isFinite(val) && val > 0 && val <= 100) return val
|
||||
}
|
||||
return 50
|
||||
}
|
||||
const editorAreaHeight = ref(loadEditorAreaHeight())
|
||||
const editorAreaPixelHeight = ref<number | null>(null)
|
||||
@@ -320,7 +324,7 @@ const handleEditorResultDividerMouseDown = (e: MouseEvent) => {
|
||||
|
||||
if (!(mainLayoutEl instanceof HTMLElement)) return
|
||||
|
||||
const resizeHandler = createResizeHandler(mainLayoutEl, () => editorAreaHeight.value, {
|
||||
const resizeHandler = createResizeHandler(() => mainLayoutEl, () => editorAreaHeight.value, {
|
||||
minPercent: 20,
|
||||
maxPercent: 80,
|
||||
minPixels: 150,
|
||||
|
||||
@@ -1,35 +1,2 @@
|
||||
export interface ResizeOptions {
|
||||
minPercent?: number
|
||||
maxPercent?: number
|
||||
minPixels?: number
|
||||
onResize?: (percentage: number) => void
|
||||
}
|
||||
|
||||
export function createResizeHandler(
|
||||
container: HTMLElement | null,
|
||||
getInitialPercentage: () => number,
|
||||
options: ResizeOptions = {}
|
||||
): (e: MouseEvent) => void {
|
||||
const { minPercent = 20, maxPercent = 80, minPixels = 150, onResize } = options
|
||||
|
||||
return (e: MouseEvent) => {
|
||||
if (!container) return
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
const percentage = ((moveEvent.clientY - rect.top) / rect.height) * 100
|
||||
const minPercentFromPixels = (minPixels / rect.height) * 100
|
||||
const clamped = Math.max(Math.max(minPercent, minPercentFromPixels), Math.min(maxPercent, percentage))
|
||||
onResize?.(clamped)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
// 保留向后兼容,内部使用通用工具
|
||||
export { createResizeHandler, type ResizeOptions } from '../../../utils/resize'
|
||||
|
||||
Reference in New Issue
Block a user