优化:工具栏高度对齐+面板统一+远程连接架构+自动恢复预览
- 工具栏:面包屑与右侧组件像素级等高(:deep 34px)、合并重复search handler、统一分隔符样式、删除死代码 - 面板对齐:三面板header统一padding/font-size、文件列表分页固定底部(自定义紧凑)、表头默认隐藏、滚动条统一样式 - 预览区:始终显示空白预览面板、重启自动恢复上次打开文件 - 收藏夹:简化计数显示(共N项) - 远程连接:ConnectionIndicator自适应UI(无远程显示mini云图标)、ConnectionDialog支持编辑配置、transport抽象层(本地Wails/远程HTTP双模式)、agent后端模块
This commit is contained in:
BIN
cmd/agent/clipboard_20260429_195256.png
Normal file
BIN
cmd/agent/clipboard_20260429_195256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
105
cmd/agent/main.go
Normal file
105
cmd/agent/main.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/agent/config"
|
||||||
|
agentmw "u-desk/internal/agent/middleware"
|
||||||
|
"u-desk/internal/agent/handler"
|
||||||
|
"u-desk/internal/filesystem"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load("configs/agent.yaml")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[FATAL] 加载配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fsConfig := filesystem.DefaultConfig()
|
||||||
|
fsSvc, err := filesystem.NewFileSystemService(fsConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[FATAL] 初始化文件服务失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
e.HideBanner = true
|
||||||
|
e.HidePort = true
|
||||||
|
|
||||||
|
e.Use(middleware.Recover())
|
||||||
|
e.Use(middleware.Logger())
|
||||||
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
|
AllowOrigins: cfg.CORS.AllowedOrigins,
|
||||||
|
AllowMethods: []string{echo.GET, echo.PUT, echo.POST, echo.DELETE, echo.PATCH, echo.OPTIONS},
|
||||||
|
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAuthorization, echo.HeaderAccept},
|
||||||
|
}))
|
||||||
|
if cfg.Auth.Token != "" {
|
||||||
|
e.Use(agentmw.Auth(cfg.Auth.Token))
|
||||||
|
}
|
||||||
|
|
||||||
|
h := handler.New(fsSvc, cfg)
|
||||||
|
|
||||||
|
api := e.Group("/api/v1")
|
||||||
|
{
|
||||||
|
api.GET("/ping", h.Ping)
|
||||||
|
api.GET("/info", h.Info)
|
||||||
|
|
||||||
|
// 文件操作 — 所有通过 ?path= 参数传递路径
|
||||||
|
api.GET("/fs", h.ListOrStat) // ?path=xxx [&action=stat]
|
||||||
|
api.GET("/fs/read", h.ReadFile) // ?path=xxx
|
||||||
|
api.PUT("/fs/write", h.WriteFile) // ?path=xxx & body={content}
|
||||||
|
api.POST("/fs/create", h.Create) // ?path=xxx & body={type,name}
|
||||||
|
api.DELETE("/fs/delete", h.Delete) // ?path=xxx
|
||||||
|
api.PATCH("/fs/rename", h.Rename) // ?path=xxx & body={new_path}
|
||||||
|
api.POST("/fs/upload", h.Upload) // ?path=xxx & body={content}
|
||||||
|
api.GET("/fs/detect", h.DetectType) // ?path=xxx
|
||||||
|
|
||||||
|
sys := api.Group("/system")
|
||||||
|
{
|
||||||
|
sys.GET("/common-paths", h.CommonPaths)
|
||||||
|
sys.GET("/drives", h.Drives)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := api.Group("/proxy")
|
||||||
|
{
|
||||||
|
proxy.GET("/localfs/*", h.FileServerProxy)
|
||||||
|
proxy.GET("/html-preview", h.HTMLPreviewProxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||||
|
go func() {
|
||||||
|
log.Printf("[INFO] u-fs-agent 启动于 %s", addr)
|
||||||
|
if err := e.Start(addr); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("[FATAL] HTTP 服务器错误: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if _, err := filesystem.StartLocalFileServer(); err != nil {
|
||||||
|
log.Printf("[WARN] 文件服务器启动失败(媒体预览不可用): %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
log.Println("[INFO] 正在关闭...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
filesystem.ShutdownLocalFileServer()
|
||||||
|
e.Shutdown(ctx)
|
||||||
|
fsSvc.Close(ctx)
|
||||||
|
log.Println("[INFO] 已关闭")
|
||||||
|
}
|
||||||
29
configs/agent.yaml
Normal file
29
configs/agent.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# u-fs-agent 配置文件
|
||||||
|
# 部署到远端服务器后修改此文件
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 9876 # 监听端口
|
||||||
|
host: "0.0.0.0" # 监听地址
|
||||||
|
|
||||||
|
auth:
|
||||||
|
token: "" # API Token(留空则不验证,生产环境必须设置)
|
||||||
|
# 生成随机 token: openssl rand -hex 32
|
||||||
|
|
||||||
|
cors:
|
||||||
|
allowed_origins:
|
||||||
|
- "*" # 开发模式允许所有来源
|
||||||
|
# 生产环境建议限定:
|
||||||
|
# - "http://localhost:5173"
|
||||||
|
# - "http://localhost:5174"
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: "info" # debug / info / warn / error
|
||||||
|
format: "json" # json / text
|
||||||
|
|
||||||
|
file_server:
|
||||||
|
port: 8073 # 内置文件服务器端口(用于媒体预览代理)
|
||||||
|
max_file_size: 524288000 # 最大文件大小 500MB
|
||||||
|
|
||||||
|
security:
|
||||||
|
allow_symlinks: false # 是否允许符号链接
|
||||||
|
check_system_paths: true # 检查系统关键目录
|
||||||
4
go.mod
4
go.mod
@@ -6,10 +6,12 @@ require (
|
|||||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
|
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
|
||||||
github.com/chromedp/chromedp v0.14.2
|
github.com/chromedp/chromedp v0.14.2
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/labstack/echo/v4 v4.15.0
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/wailsapp/wails/v2 v2.12.0
|
github.com/wailsapp/wails/v2 v2.12.0
|
||||||
github.com/yuin/goldmark v1.8.2
|
github.com/yuin/goldmark v1.8.2
|
||||||
golang.org/x/sys v0.40.0
|
golang.org/x/sys v0.40.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +32,6 @@ require (
|
|||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/labstack/echo/v4 v4.15.0 // indirect
|
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
@@ -59,6 +60,7 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
golang.org/x/time v0.14.0 // indirect
|
||||||
modernc.org/libc v1.67.7 // indirect
|
modernc.org/libc v1.67.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -141,9 +141,13 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
|
|||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
|||||||
108
internal/agent/config/config.go
Normal file
108
internal/agent/config/config.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `yaml:"server"`
|
||||||
|
Auth AuthConfig `yaml:"auth"`
|
||||||
|
CORS CORSConfig `yaml:"cors"`
|
||||||
|
Log LogConfig `yaml:"log"`
|
||||||
|
FileServer FileServerConfig `yaml:"file_server"`
|
||||||
|
Security SecurityConfig `yaml:"security"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CORSConfig struct {
|
||||||
|
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
Level string `yaml:"level"`
|
||||||
|
Format string `yaml:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileServerConfig struct {
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
MaxFileSize int64 `yaml:"max_file_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SecurityConfig struct {
|
||||||
|
AllowSymlinks bool `yaml:"allow_symlinks"`
|
||||||
|
CheckSystemPaths bool `yaml:"check_system_paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileServerAddr 返回文件服务器的完整地址
|
||||||
|
func (c *Config) FileServerAddr() string {
|
||||||
|
return fmt.Sprintf("http://localhost:%d", c.FileServer.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
// 配置文件不存在时使用默认值
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return Default(), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Default()
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理 origins 中的空格并去重
|
||||||
|
seen := make(map[string]bool, len(cfg.CORS.AllowedOrigins))
|
||||||
|
uniques := cfg.CORS.AllowedOrigins[:0]
|
||||||
|
for _, origin := range cfg.CORS.AllowedOrigins {
|
||||||
|
o := strings.TrimSpace(origin)
|
||||||
|
if o != "" && !seen[o] {
|
||||||
|
seen[o] = true
|
||||||
|
uniques = append(uniques, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.CORS.AllowedOrigins = uniques
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Default() *Config {
|
||||||
|
return &Config{
|
||||||
|
Server: ServerConfig{
|
||||||
|
Port: 9876,
|
||||||
|
Host: "0.0.0.0",
|
||||||
|
},
|
||||||
|
Auth: AuthConfig{
|
||||||
|
Token: "",
|
||||||
|
},
|
||||||
|
CORS: CORSConfig{
|
||||||
|
AllowedOrigins: []string{"*"},
|
||||||
|
},
|
||||||
|
Log: LogConfig{
|
||||||
|
Level: "info",
|
||||||
|
Format: "json",
|
||||||
|
},
|
||||||
|
FileServer: FileServerConfig{
|
||||||
|
Port: 8073,
|
||||||
|
MaxFileSize: 500 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
Security: SecurityConfig{
|
||||||
|
AllowSymlinks: false,
|
||||||
|
CheckSystemPaths: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
176
internal/agent/handler/file_handler.go
Normal file
176
internal/agent/handler/file_handler.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"u-desk/internal/agent/model"
|
||||||
|
"u-desk/internal/filesystem"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type writeFileReq struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createReq struct {
|
||||||
|
Type string `json:"type"` // "file" or "dir"
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renameReq struct {
|
||||||
|
NewPath string `json:"new_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadReq struct {
|
||||||
|
Content string `json:"content"` // base64 编码内容
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrStat 列出目录或获取文件信息(?get=stat 时返回单文件信息)
|
||||||
|
func (h *Handler) ListOrStat(c echo.Context) error {
|
||||||
|
path := getPath(c)
|
||||||
|
action := c.QueryParam("get")
|
||||||
|
|
||||||
|
if action == "stat" {
|
||||||
|
info, err := h.fsSvc.GetFileInfo(path)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusOK, model.NotFound(err.Error()))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, model.OK(info))
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := h.fsSvc.ListDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||||
|
}
|
||||||
|
// 限制返回数量,避免大目录导致前端卡顿
|
||||||
|
limit := c.QueryParam("limit")
|
||||||
|
if limit != "" {
|
||||||
|
n := 0
|
||||||
|
for i, f := range files {
|
||||||
|
if n >= 500 { // 硬限制 500 条
|
||||||
|
break
|
||||||
|
}
|
||||||
|
files[i] = f
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
files = files[:n]
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, model.OK(files))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile 读取文件文本内容
|
||||||
|
func (h *Handler) ReadFile(c echo.Context) error {
|
||||||
|
path := getPath(c)
|
||||||
|
content, err := h.fsSvc.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, model.OK(map[string]string{
|
||||||
|
"content": content,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile 写入文件文本内容
|
||||||
|
func (h *Handler) WriteFile(c echo.Context) error {
|
||||||
|
path := getPath(c)
|
||||||
|
var req writeFileReq
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||||
|
}
|
||||||
|
if err := h.fsSvc.WriteFile(path, req.Content); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, model.NoContent())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建文件或目录
|
||||||
|
func (h *Handler) Create(c echo.Context) error {
|
||||||
|
parentPath := getPath(c)
|
||||||
|
var req createReq
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, model.BadRequest("名称不能为空"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result *filesystem.FileOperationResult
|
||||||
|
var err error
|
||||||
|
|
||||||
|
fullPath := filepath.Join(parentPath, req.Name)
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case "dir":
|
||||||
|
result, err = h.fsSvc.CreateDir(fullPath)
|
||||||
|
default:
|
||||||
|
result, err = h.fsSvc.CreateFile(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, model.OK(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除文件或目录
|
||||||
|
func (h *Handler) Delete(c echo.Context) error {
|
||||||
|
path := getPath(c)
|
||||||
|
result, err := h.fsSvc.DeletePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, model.OK(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename 重命名文件或目录
|
||||||
|
func (h *Handler) Rename(c echo.Context) error {
|
||||||
|
oldPath := getPath(c)
|
||||||
|
var req renameReq
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||||
|
}
|
||||||
|
req.NewPath = strings.TrimSpace(req.NewPath)
|
||||||
|
if req.NewPath == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, model.BadRequest("新路径不能为空"))
|
||||||
|
}
|
||||||
|
cleanNew := filepath.Clean(req.NewPath)
|
||||||
|
if strings.Contains(cleanNew, "..") {
|
||||||
|
return c.JSON(http.StatusBadRequest, model.BadRequest("新路径不允许包含 .."))
|
||||||
|
}
|
||||||
|
result, err := h.fsSvc.RenamePath(oldPath, cleanNew)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, model.OK(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload 上传 Base64 编码的二进制文件
|
||||||
|
func (h *Handler) Upload(c echo.Context) error {
|
||||||
|
path := getPath(c)
|
||||||
|
var req uploadReq
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||||
|
}
|
||||||
|
if req.Content == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, model.BadRequest("内容不能为空"))
|
||||||
|
}
|
||||||
|
if err := h.fsSvc.SaveBase64File(path, req.Content); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, model.NoContent())
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectType 通过文件内容检测类型
|
||||||
|
func (h *Handler) DetectType(c echo.Context) error {
|
||||||
|
path := getPath(c)
|
||||||
|
info, err := h.fsSvc.DetectFileTypeByContent(path)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, model.OK(info))
|
||||||
|
}
|
||||||
37
internal/agent/handler/handler.go
Normal file
37
internal/agent/handler/handler.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"u-desk/internal/agent/config"
|
||||||
|
"u-desk/internal/filesystem"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
fsSvc *filesystem.FileSystemService
|
||||||
|
cfg *config.Config
|
||||||
|
fileProxy *httputil.ReverseProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(fsSvc *filesystem.FileSystemService, cfg *config.Config) *Handler {
|
||||||
|
fileTarget, _ := url.Parse(cfg.FileServerAddr() + "/localfs/")
|
||||||
|
return &Handler{
|
||||||
|
fsSvc: fsSvc,
|
||||||
|
cfg: cfg,
|
||||||
|
fileProxy: httputil.NewSingleHostReverseProxy(fileTarget),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPath 从 query 参数提取并规范化文件路径
|
||||||
|
func getPath(c echo.Context) string {
|
||||||
|
raw := c.QueryParam("path")
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// URL 已被 Echo 自动 decode,只需转换路径分隔符
|
||||||
|
return filepath.FromSlash(raw)
|
||||||
|
}
|
||||||
64
internal/agent/handler/server_handler.go
Normal file
64
internal/agent/handler/server_handler.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileServerProxy 反向代理到内置文件服务器(用于媒体预览)
|
||||||
|
func (h *Handler) FileServerProxy(c echo.Context) error {
|
||||||
|
rawPath := c.Param("*")
|
||||||
|
if rawPath == "" {
|
||||||
|
return c.String(http.StatusBadRequest, "缺少文件路径")
|
||||||
|
}
|
||||||
|
|
||||||
|
clean := filepath.Clean(rawPath)
|
||||||
|
if strings.Contains(clean, "..") {
|
||||||
|
return c.String(http.StatusForbidden, "路径不允许包含 ..")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止多重 /localfs/ 前缀(循环去除所有)
|
||||||
|
targetPath := filepath.ToSlash(clean)
|
||||||
|
for strings.HasPrefix(targetPath, "localfs/") || strings.HasPrefix(targetPath, "localfs\\") {
|
||||||
|
targetPath = strings.TrimPrefix(targetPath, "localfs/")
|
||||||
|
targetPath = strings.TrimPrefix(targetPath, "localfs\\")
|
||||||
|
}
|
||||||
|
c.Request().URL.Path = "/localfs/" + targetPath
|
||||||
|
h.fileProxy.ServeHTTP(c.Response(), c.Request())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMLPreviewProxy 代理 HTML 预览请求(直连内部服务器,避免 ReverseProxy 路径拼接问题)
|
||||||
|
func (h *Handler) HTMLPreviewProxy(c echo.Context) error {
|
||||||
|
rawPath := c.QueryParam("path")
|
||||||
|
if rawPath == "" {
|
||||||
|
return c.String(http.StatusBadRequest, "缺少 path 参数")
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(rawPath)
|
||||||
|
if strings.Contains(clean, "..") {
|
||||||
|
return c.String(http.StatusForbidden, "路径不允许包含 ..")
|
||||||
|
}
|
||||||
|
theme := c.QueryParam("theme")
|
||||||
|
|
||||||
|
targetURL := fmt.Sprintf("http://localhost:8073/localfs/html-preview?path=%s&theme=%s",
|
||||||
|
url.QueryEscape(clean), url.QueryEscape(theme))
|
||||||
|
|
||||||
|
resp, err := http.Get(targetURL)
|
||||||
|
if err != nil {
|
||||||
|
return c.String(http.StatusBadGateway, "内部服务器不可用")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
c.Response().Header()[k] = v
|
||||||
|
}
|
||||||
|
c.Response().WriteHeader(resp.StatusCode)
|
||||||
|
io.Copy(c.Response(), resp.Body)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
113
internal/agent/handler/system_handler.go
Normal file
113
internal/agent/handler/system_handler.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"u-desk/internal/agent/model"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ping 健康检查
|
||||||
|
func (h *Handler) Ping(c echo.Context) error {
|
||||||
|
return c.JSON(http.StatusOK, model.OK(map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info 返回 Agent 信息
|
||||||
|
func (h *Handler) Info(c echo.Context) error {
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
return c.JSON(http.StatusOK, model.OK(map[string]interface{}{
|
||||||
|
"version": "0.1.0",
|
||||||
|
"os": runtime.GOOS,
|
||||||
|
"arch": runtime.GOARCH,
|
||||||
|
"hostname": hostname,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommonPaths 返回常用系统路径
|
||||||
|
func (h *Handler) CommonPaths(c echo.Context) error {
|
||||||
|
paths := map[string]string{}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home != "" {
|
||||||
|
paths["home"] = home
|
||||||
|
paths["desktop"] = home + "/Desktop"
|
||||||
|
paths["documents"] = home + "/Documents"
|
||||||
|
paths["downloads"] = home + "/Downloads"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据平台添加盘符/根路径
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
|
||||||
|
_, err := os.Stat(string(drive) + ":\\")
|
||||||
|
if err == nil {
|
||||||
|
paths["drive_"+strings.ToLower(string(drive))] = string(drive) + ":\\"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
paths["root"] = "/"
|
||||||
|
_, err := os.Stat("/home")
|
||||||
|
if err == nil {
|
||||||
|
paths["users"] = "/home"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, model.OK(paths))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drives 返回可用磁盘列表
|
||||||
|
func (h *Handler) Drives(c echo.Context) error {
|
||||||
|
type DriveInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
FsType string `json:"fs_type,omitempty"`
|
||||||
|
Total uint64 `json:"total"`
|
||||||
|
Free uint64 `json:"free"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var drives []DriveInfo
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
|
||||||
|
drivePath := string(drive) + ":\\"
|
||||||
|
if _, err := os.Stat(drivePath); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
drives = append(drives, DriveInfo{
|
||||||
|
Name: strings.ToLower(string(drive)),
|
||||||
|
Path: drivePath,
|
||||||
|
Total: 0,
|
||||||
|
Free: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts, err := os.ReadDir("/")
|
||||||
|
if err == nil {
|
||||||
|
for _, p := range parts {
|
||||||
|
name := p.Name()
|
||||||
|
if len(name) == 2 && name[0] != '.' && name[1] != '.' && p.IsDir() {
|
||||||
|
// 可能是挂载点
|
||||||
|
fullPath := "/" + name
|
||||||
|
if stat, err := os.Stat(fullPath); err == nil && stat.IsDir() {
|
||||||
|
drives = append(drives, DriveInfo{
|
||||||
|
Name: name,
|
||||||
|
Path: fullPath,
|
||||||
|
})
|
||||||
|
_ = stat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 至少返回根目录
|
||||||
|
if len(drives) == 0 {
|
||||||
|
drives = append(drives, DriveInfo{Name: "/", Path: "/"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, model.OK(drives))
|
||||||
|
}
|
||||||
61
internal/agent/middleware/auth.go
Normal file
61
internal/agent/middleware/auth.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cookieName = "fs_token"
|
||||||
|
|
||||||
|
func Auth(token string) echo.MiddlewareFunc {
|
||||||
|
if token == "" {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
// 1. Authorization header(API 调用,首选)
|
||||||
|
auth := c.Request().Header.Get("Authorization")
|
||||||
|
if len(auth) >= 7 && strings.HasPrefix(auth, "Bearer ") &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(auth[7:]), []byte(token)) == 1 {
|
||||||
|
setAuthCookie(c, token)
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
// 2. Cookie(<img>/<video> 等浏览器自动携带)
|
||||||
|
if ck, err := c.Cookie(cookieName); err == nil &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(ck.Value), []byte(token)) == 1 {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
// 3. 查询参数(兼容旧版,可后续移除)
|
||||||
|
if qt := c.QueryParam("token"); qt != "" &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(qt), []byte(token)) == 1 {
|
||||||
|
setAuthCookie(c, token)
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||||
|
"error": "unauthorized",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setAuthCookie 首次认证成功后设置 Cookie(供 <img> 等浏览器请求自动携带)
|
||||||
|
func setAuthCookie(c echo.Context, token string) {
|
||||||
|
c.SetCookie(&http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(24 * time.Hour / time.Second),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: c.Request().TLS != nil,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
41
internal/agent/model/response.go
Normal file
41
internal/agent/model/response.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func OK(data interface{}) Response {
|
||||||
|
return Response{Code: http.StatusOK, Data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Created(data interface{}) Response {
|
||||||
|
return Response{Code: http.StatusCreated, Data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NoContent() Response {
|
||||||
|
return Response{Code: http.StatusNoContent}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BadRequest(msg string) Response {
|
||||||
|
return Response{Code: http.StatusBadRequest, Message: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Unauthorized(msg string) Response {
|
||||||
|
return Response{Code: http.StatusUnauthorized, Message: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Forbidden(msg string) Response {
|
||||||
|
return Response{Code: http.StatusForbidden, Message: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NotFound(msg string) Response {
|
||||||
|
return Response{Code: http.StatusNotFound, Message: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func InternalError(msg string) Response {
|
||||||
|
return Response{Code: http.StatusInternalServerError, Message: msg}
|
||||||
|
}
|
||||||
@@ -68,9 +68,22 @@ func validateFilePath(rawPath string, logPrefix string) (string, error) {
|
|||||||
return "", ErrPathTraversal
|
return "", ErrPathTraversal
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
|
// 去除代理引入的 /localfs/ 前缀(可能有多层)
|
||||||
|
clean := decodedPath
|
||||||
|
for strings.HasPrefix(clean, "/localfs/") || strings.HasPrefix(clean, "localfs/") {
|
||||||
|
clean = strings.TrimPrefix(clean, "/localfs/")
|
||||||
|
clean = strings.TrimPrefix(clean, "localfs/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平台适配:Windows 用反斜杠,Linux/macOS 保持正斜杠
|
||||||
|
filePath := filepath.FromSlash(clean)
|
||||||
filePath = filepath.Clean(filePath)
|
filePath = filepath.Clean(filePath)
|
||||||
|
|
||||||
|
// 确保绝对路径(Linux 以 / 开头,Windows 以盘符开头)
|
||||||
|
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' {
|
||||||
|
filePath = "/" + filePath
|
||||||
|
}
|
||||||
|
|
||||||
if !isSafePath(filePath) {
|
if !isSafePath(filePath) {
|
||||||
return "", ErrPathUnsafe
|
return "", ErrPathUnsafe
|
||||||
}
|
}
|
||||||
@@ -153,8 +166,20 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path)
|
log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path)
|
||||||
|
|
||||||
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
|
// 从 URL 路径获取文件路径(移除所有 /localfs/ 前缀,兼容代理双重前缀)
|
||||||
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
|
pathPart := r.URL.Path
|
||||||
|
for strings.HasPrefix(pathPart, "/localfs/") {
|
||||||
|
pathPart = strings.TrimPrefix(pathPart, "/localfs/")
|
||||||
|
}
|
||||||
|
if pathPart == "" || pathPart == r.URL.Path {
|
||||||
|
log.Printf("[LocalFileHandler] 路径前缀无效: original=%s", r.URL.Path)
|
||||||
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 仅对非绝对路径添加前导 /(Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /)
|
||||||
|
if !strings.HasPrefix(pathPart, "/") && !regexp.MustCompile(`^[A-Za-z]:`).MatchString(pathPart) {
|
||||||
|
pathPart = "/" + pathPart
|
||||||
|
}
|
||||||
|
|
||||||
if pathPart == "" || pathPart == r.URL.Path {
|
if pathPart == "" || pathPart == r.URL.Path {
|
||||||
log.Printf("[LocalFileHandler] 路径前缀无效")
|
log.Printf("[LocalFileHandler] 路径前缀无效")
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
package filesystem
|
package filesystem
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
19
internal/filesystem/file_lock_linux.go
Normal file
19
internal/filesystem/file_lock_linux.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build !windows
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package filesystem
|
||||||
|
|
||||||
|
// FileLockChecker 文件锁检查器(Linux 空实现)
|
||||||
|
type FileLockChecker struct{}
|
||||||
|
|
||||||
|
func NewFileLockChecker() *FileLockChecker {
|
||||||
|
return &FileLockChecker{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FileLockChecker) IsFileLocked(_path string) (bool, string, error) {
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FileLockChecker) SafeDeleteWithLockCheck(_path string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
199
web/src/api/connection-manager.ts
Normal file
199
web/src/api/connection-manager.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* 连接管理器 — 管理本地/远程传输层切换
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FsTransport } from './transport'
|
||||||
|
import { WailsTransport } from './wails-transport'
|
||||||
|
import { HttpTransport } from './http-transport'
|
||||||
|
|
||||||
|
export type ConnectionType = 'local' | 'remote'
|
||||||
|
|
||||||
|
export interface ConnectionProfile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
token: string
|
||||||
|
type: ConnectionType
|
||||||
|
lastConnected?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||||
|
|
||||||
|
const PROFILES_KEY = 'fs_connection_profiles'
|
||||||
|
const ACTIVE_KEY = 'fs_active_connection'
|
||||||
|
|
||||||
|
class ConnectionManagerImpl {
|
||||||
|
private _transport: FsTransport | null = null
|
||||||
|
private _profiles: ConnectionProfile[] = []
|
||||||
|
private _activeId: string | null = null
|
||||||
|
private _state: ConnectionState = 'disconnected'
|
||||||
|
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
|
||||||
|
private _connectSeq = 0
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.loadProfiles()
|
||||||
|
this.initDefaultLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
private initDefaultLocal() {
|
||||||
|
const localProfile: ConnectionProfile = {
|
||||||
|
id: 'local-default',
|
||||||
|
name: '本地',
|
||||||
|
host: '',
|
||||||
|
port: 0,
|
||||||
|
token: '',
|
||||||
|
type: 'local',
|
||||||
|
}
|
||||||
|
if (!this._profiles.find(p => p.id === localProfile.id)) {
|
||||||
|
this._profiles.unshift(localProfile)
|
||||||
|
}
|
||||||
|
// 默认连接本地
|
||||||
|
if (!this._activeId) {
|
||||||
|
this._activeId = localProfile.id
|
||||||
|
}
|
||||||
|
this.applyActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadProfiles() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PROFILES_KEY)
|
||||||
|
if (raw) this._profiles = JSON.parse(raw)
|
||||||
|
this._activeId = localStorage.getItem(ACTIVE_KEY)
|
||||||
|
} catch { /* 首次使用 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveProfiles() {
|
||||||
|
localStorage.setItem(PROFILES_KEY, JSON.stringify(this._profiles))
|
||||||
|
if (this._activeId) {
|
||||||
|
localStorage.setItem(ACTIVE_KEY, this._activeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(state: ConnectionState) {
|
||||||
|
this._state = state
|
||||||
|
this.notifyChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyChange() {
|
||||||
|
this._stateChangeCallbacks.forEach(cb => cb(this._state))
|
||||||
|
}
|
||||||
|
|
||||||
|
onStateChange(cb: (state: ConnectionState) => void) {
|
||||||
|
this._stateChangeCallbacks.push(cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
get state(): ConnectionState {
|
||||||
|
return this._state
|
||||||
|
}
|
||||||
|
|
||||||
|
get profiles(): ConnectionProfile[] {
|
||||||
|
return [...this._profiles]
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeProfile(): ConnectionProfile | null {
|
||||||
|
return this._profiles.find(p => p.id === this._activeId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
getTransport(): FsTransport {
|
||||||
|
if (!this._transport) {
|
||||||
|
this.applyActive()
|
||||||
|
}
|
||||||
|
return this._transport!
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileServerBaseURL(): string {
|
||||||
|
if (this._transport instanceof HttpTransport) {
|
||||||
|
const profile = this.activeProfile
|
||||||
|
if (!profile) return ''
|
||||||
|
const scheme = profile.port === 443 ? 'https' : 'http'
|
||||||
|
const port = (profile.port === 80 || profile.port === 443) ? '' : `:${profile.port}`
|
||||||
|
return `${scheme}://${profile.host}${port}`
|
||||||
|
}
|
||||||
|
// Wails 模式返回空字符串,让 useFilePreview 走原有逻辑
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
isRemote(): boolean {
|
||||||
|
return this.activeProfile?.type === 'remote'
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(profileId: string): void {
|
||||||
|
const profile = this._profiles.find(p => p.id === profileId)
|
||||||
|
if (!profile) return
|
||||||
|
|
||||||
|
this._activeId = profileId
|
||||||
|
this.saveProfiles()
|
||||||
|
this.applyActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this._activeId = 'local-default'
|
||||||
|
this.saveProfiles()
|
||||||
|
this.applyActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
addProfile(profile: Omit<ConnectionProfile, 'id'>): ConnectionProfile {
|
||||||
|
const newProfile: ConnectionProfile = {
|
||||||
|
...profile,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
}
|
||||||
|
this._profiles.push(newProfile)
|
||||||
|
this.saveProfiles()
|
||||||
|
this.notifyChange()
|
||||||
|
return newProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProfile(id: string, updates: Partial<ConnectionProfile>): void {
|
||||||
|
const idx = this._profiles.findIndex(p => p.id === id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
this._profiles[idx] = { ...this._profiles[idx], ...updates }
|
||||||
|
this.saveProfiles()
|
||||||
|
// 仅当连接参数变化时重新应用(避免 lastConnected 等元数据更新触发死循环)
|
||||||
|
const REAPPLY_KEYS = ['host', 'port', 'token'] as const
|
||||||
|
const needsReapply = REAPPLY_KEYS.some(k => k in updates)
|
||||||
|
if (needsReapply && id === this._activeId) {
|
||||||
|
this.applyActive()
|
||||||
|
}
|
||||||
|
this.notifyChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeProfile(id: string): void {
|
||||||
|
if (id === 'local-default') return // 不允许删除本地配置
|
||||||
|
this._profiles = this._profiles.filter(p => p.id !== id)
|
||||||
|
if (this._activeId === id) {
|
||||||
|
this._activeId = 'local-default'
|
||||||
|
}
|
||||||
|
this.saveProfiles()
|
||||||
|
this.applyActive()
|
||||||
|
this.notifyChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyActive() {
|
||||||
|
const profile = this.activeProfile
|
||||||
|
const seq = ++this._connectSeq
|
||||||
|
if (!profile || profile.type === 'local') {
|
||||||
|
this._transport = new WailsTransport()
|
||||||
|
this.setState('connected')
|
||||||
|
} else {
|
||||||
|
this.setState('connecting')
|
||||||
|
try {
|
||||||
|
this._transport = new HttpTransport(profile.host, profile.port, profile.token)
|
||||||
|
// 快速连通性检查(用轻量 ping 代替 getCommonPaths)
|
||||||
|
this._transport.getFileInfo('/').then(() => {
|
||||||
|
if (seq !== this._connectSeq) return // 已被后续连接覆盖
|
||||||
|
this.setState('connected')
|
||||||
|
this.updateProfile(profile.id!, { lastConnected: Date.now() })
|
||||||
|
}).catch(() => {
|
||||||
|
if (seq !== this._connectSeq) return
|
||||||
|
this.setState('error')
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
this.setState('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const connectionManager = new ConnectionManagerImpl()
|
||||||
136
web/src/api/http-transport.ts
Normal file
136
web/src/api/http-transport.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Http Transport — 远程文件操作(通过 u-fs-agent REST API)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
|
||||||
|
|
||||||
|
const CONTENT_TYPE = 'application/json'
|
||||||
|
|
||||||
|
export class HttpTransport implements FsTransport {
|
||||||
|
private baseUrl: string
|
||||||
|
private token: string
|
||||||
|
|
||||||
|
constructor(host: string, port: number, token: string) {
|
||||||
|
const scheme = port === 443 ? 'https' : 'http'
|
||||||
|
this.baseUrl = `${scheme}://${host}${port === 80 || port === 443 ? '' : ':' + port}`
|
||||||
|
this.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
private headers(): HeadersInit {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': CONTENT_TYPE }
|
||||||
|
if (this.token) h['Authorization'] = `Bearer ${this.token}`
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(method: string, path: string, params?: Record<string, string>, body?: any): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}${path}`
|
||||||
|
const searchParams = params ? '?' + new URLSearchParams(params).toString() : ''
|
||||||
|
const opts: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: this.headers(),
|
||||||
|
}
|
||||||
|
if (body !== undefined) opts.body = JSON.stringify(body)
|
||||||
|
|
||||||
|
const res = await fetch(url + searchParams, opts)
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data.code >= 400) {
|
||||||
|
throw new Error(data.message || `请求失败 (code=${data.code})`)
|
||||||
|
}
|
||||||
|
return data.data ?? data
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDir(path: string): Promise<FileItem[]> {
|
||||||
|
return this.request<FileItem[]>('GET', '/api/v1/fs', { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileInfo(path: string): Promise<Record<string, any>> {
|
||||||
|
return this.request<Record<string, any>>('GET', '/api/v1/fs', { path, get: 'stat' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(path: string): Promise<string> {
|
||||||
|
const data = await this.request<{ content: string }>('GET', '/api/v1/fs/read', { path })
|
||||||
|
return data.content
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, content: string): Promise<void> {
|
||||||
|
await this.request('PUT', '/api/v1/fs/write', { path }, { content })
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBase64File(path: string, content: string): Promise<void> {
|
||||||
|
if (!content) throw new Error('无效的 base64 内容')
|
||||||
|
await this.request('POST', '/api/v1/fs/upload', { path }, { content })
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||||
|
return this.request<FileOperationResult>('POST', '/api/v1/fs/create', { path: dirPath }, { type: 'file', name: filename })
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||||
|
return this.request<FileOperationResult>('POST', '/api/v1/fs/create', { path: parentPath }, { type: 'dir', name: dirname })
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePath(path: string): Promise<FileOperationResult> {
|
||||||
|
return this.request<FileOperationResult>('DELETE', '/api/v1/fs/delete', { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
|
||||||
|
return this.request<FileOperationResult>('PATCH', '/api/v1/fs/rename', { path: oldPath }, { new_path: newPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
async listZipContents(zipPath: string): Promise<FileItem[]> {
|
||||||
|
// Wave 3 实现
|
||||||
|
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
|
||||||
|
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractFileFromZipToTemp(_zipPath: string, _filePath: string): Promise<string> {
|
||||||
|
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getZipFileInfo(_zipPath: string, _filePath: string): Promise<FileItem> {
|
||||||
|
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async openPath(_path: string): Promise<void> {
|
||||||
|
throw new Error('远程模式不支持打开本地路径')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileServerURL(): Promise<string> {
|
||||||
|
return `${this.baseUrl}/api/v1/proxy/localfs`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 远程模式预览用的认证 token(拼接到 URL query) */
|
||||||
|
getPreviewToken(): string {
|
||||||
|
return this.token
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveShortcut(_lnkPath: string): Promise<any> {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectFileTypeByContent(path: string): Promise<DetectTypeResult> {
|
||||||
|
return this.request<DetectTypeResult>('GET', '/api/v1/fs/detect', { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommonPaths(): Promise<Record<string, string>> {
|
||||||
|
return this.request<Record<string, string>>('GET', '/api/v1/system/common-paths')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecycleBinEntries(): Promise<any[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreFromRecycleBin(_path: string): Promise<void> {}
|
||||||
|
|
||||||
|
async deletePermanently(_path: string): Promise<void> {}
|
||||||
|
|
||||||
|
async emptyRecycleBin(): Promise<void> {}
|
||||||
|
}
|
||||||
@@ -1,306 +1,110 @@
|
|||||||
/**
|
/**
|
||||||
* 系统信息相关 API
|
* 系统信息相关 API — 委托给 Transport 层
|
||||||
|
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SystemInfo, CPU, Memory, Disk, File } from './types'
|
import type { File } from './types'
|
||||||
import { debugError } from '@/utils/debugLog'
|
import { connectionManager } from './connection-manager'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转换后端文件数据格式(蛇形 → 驼峰)
|
* 转换后端文件数据格式(蛇形 → 驼峰)
|
||||||
* 后端返回 is_dir,前端使用 isDir
|
|
||||||
*/
|
*/
|
||||||
function transformFile(file: any): File {
|
function transformFile(file: any): File {
|
||||||
return {
|
return { ...file, isDir: file.is_dir, modified_time: file.mod_time }
|
||||||
...file,
|
|
||||||
isDir: file.is_dir,
|
|
||||||
modified_time: file.mod_time
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量转换文件列表
|
|
||||||
*/
|
|
||||||
function transformFileList(files: any[]): File[] {
|
function transformFileList(files: any[]): File[] {
|
||||||
return files.map(transformFile)
|
return files.map(transformFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const t = () => connectionManager.getTransport()
|
||||||
* 获取系统信息
|
|
||||||
*/
|
export async function getSystemInfo() { return t().getFileInfo('/') }
|
||||||
export async function getSystemInfo(): Promise<SystemInfo> {
|
|
||||||
if (!window.go?.main?.App?.GetSystemInfo) {
|
export async function getCPUInfo() {
|
||||||
throw new Error('GetSystemInfo API 不可用')
|
if (connectionManager.isRemote()) return {}
|
||||||
}
|
try { return await (window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
|
||||||
return await window.go.main.App.GetSystemInfo()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getMemoryInfo() {
|
||||||
* 获取 CPU 信息
|
if (connectionManager.isRemote()) return {}
|
||||||
*/
|
try { return await (window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
|
||||||
export async function getCPUInfo(): Promise<CPU> {
|
|
||||||
if (!window.go?.main?.App?.GetCPUInfo) {
|
|
||||||
throw new Error('GetCPUInfo API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.GetCPUInfo()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getDiskInfo() {
|
||||||
* 获取内存信息
|
if (connectionManager.isRemote()) return {}
|
||||||
*/
|
try { return await (window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
|
||||||
export async function getMemoryInfo(): Promise<Memory> {
|
|
||||||
if (!window.go?.main?.App?.GetMemoryInfo) {
|
|
||||||
throw new Error('GetMemoryInfo API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.GetMemoryInfo()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取磁盘信息
|
|
||||||
*/
|
|
||||||
export async function getDiskInfo(): Promise<Disk> {
|
|
||||||
if (!window.go?.main?.App?.GetDiskInfo) {
|
|
||||||
throw new Error('GetDiskInfo API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.GetDiskInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 列出目录文件
|
|
||||||
*/
|
|
||||||
export async function listDir(path: string): Promise<File[]> {
|
export async function listDir(path: string): Promise<File[]> {
|
||||||
if (!window.go?.main?.App?.ListDir) {
|
return transformFileList(await t().listDir(path))
|
||||||
throw new Error('ListDir API 不可用')
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await window.go.main.App.ListDir(path)
|
|
||||||
return transformFileList(files)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取文件
|
|
||||||
*/
|
|
||||||
export async function readFile(path: string): Promise<string> {
|
export async function readFile(path: string): Promise<string> {
|
||||||
if (!window.go?.main?.App?.ReadFile) {
|
return t().readFile(path)
|
||||||
throw new Error('ReadFile API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.ReadFile(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入文件
|
|
||||||
*/
|
|
||||||
export async function writeFile(path: string, content: string): Promise<void> {
|
export async function writeFile(path: string, content: string): Promise<void> {
|
||||||
if (!window.go?.main?.App?.WriteFile) {
|
await t().writeFile(path, String(content))
|
||||||
throw new Error('WriteFile API 不可用')
|
|
||||||
}
|
|
||||||
// 确保传递的是字符串类型
|
|
||||||
await window.go.main.App.WriteFile({
|
|
||||||
path: String(path),
|
|
||||||
content: String(content)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存 Base64 编码的二进制文件(图片等)
|
|
||||||
*/
|
|
||||||
export async function saveBase64File(path: string, base64Content: string): Promise<void> {
|
export async function saveBase64File(path: string, base64Content: string): Promise<void> {
|
||||||
if (!window.go?.main?.App?.SaveBase64File) {
|
if (!base64Content) throw new Error('无效的 base64 内容')
|
||||||
throw new Error('SaveBase64File API 不可用')
|
await t().saveBase64File(path, base64Content)
|
||||||
}
|
|
||||||
if (!base64Content) {
|
|
||||||
throw new Error('无效的 base64 内容')
|
|
||||||
}
|
|
||||||
await window.go.main.App.SaveBase64File({
|
|
||||||
path: String(path),
|
|
||||||
content: base64Content
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除文件或目录
|
|
||||||
*/
|
|
||||||
export async function deletePath(path: string): Promise<any> {
|
export async function deletePath(path: string): Promise<any> {
|
||||||
if (!window.go?.main?.App?.DeletePath) {
|
return t().deletePath(path)
|
||||||
throw new Error('DeletePath API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.DeletePath(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建目录(parentPath + dirname 拼接为完整路径)
|
|
||||||
*/
|
|
||||||
export async function createDir(parentPath: string, dirname: string): Promise<any> {
|
export async function createDir(parentPath: string, dirname: string): Promise<any> {
|
||||||
if (!window.go?.main?.App?.CreateDir) {
|
return t().createDir(parentPath, dirname)
|
||||||
throw new Error('CreateDir API 不可用')
|
|
||||||
}
|
|
||||||
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
|
||||||
return await window.go.main.App.CreateDir(fullPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建文件(dirPath + filename 拼接为完整路径)
|
|
||||||
*/
|
|
||||||
export async function createFile(dirPath: string, filename: string): Promise<any> {
|
export async function createFile(dirPath: string, filename: string): Promise<any> {
|
||||||
if (!window.go?.main?.App?.CreateFile) {
|
return t().createFile(dirPath, filename)
|
||||||
throw new Error('CreateFile API 不可用')
|
|
||||||
}
|
|
||||||
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
|
||||||
return await window.go.main.App.CreateFile(fullPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 重命名文件或目录
|
|
||||||
*/
|
|
||||||
export async function renamePath(oldPath: string, newPath: string): Promise<any> {
|
export async function renamePath(oldPath: string, newPath: string): Promise<any> {
|
||||||
if (!window.go?.main?.App?.RenamePath) {
|
return t().renamePath(oldPath, String(newPath))
|
||||||
throw new Error('RenamePath API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.RenamePath({
|
|
||||||
oldPath: String(oldPath),
|
|
||||||
newPath: String(newPath)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取环境变量
|
|
||||||
*/
|
|
||||||
export async function getEnvVars(): Promise<Record<string, string>> {
|
export async function getEnvVars(): Promise<Record<string, string>> {
|
||||||
if (!window.go?.main?.App?.GetEnvVars) {
|
try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
|
||||||
throw new Error('GetEnvVars API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.GetEnvVars()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 列出 zip 文件内容
|
|
||||||
*/
|
|
||||||
export async function listZipContents(zipPath: string): Promise<File[]> {
|
export async function listZipContents(zipPath: string): Promise<File[]> {
|
||||||
if (!window.go?.main?.App?.ListZipContents) {
|
return transformFileList(await t().listZipContents(zipPath))
|
||||||
throw new Error('ListZipContents API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.go.main.App.ListZipContents(zipPath)
|
|
||||||
return transformFileList(result)
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] listZipContents 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 zip 文件中提取单个文件内容
|
|
||||||
*/
|
|
||||||
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
||||||
if (!window.go?.main?.App?.ExtractFileFromZip) {
|
return t().extractFileFromZip(zipPath, filePath)
|
||||||
throw new Error('ExtractFileFromZip API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.go.main.App.ExtractFileFromZip(zipPath, filePath)
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] extractFileFromZip 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 zip 文件中提取单个文件到临时目录
|
|
||||||
* 返回临时文件的完整路径,适用于图片等二进制文件
|
|
||||||
*/
|
|
||||||
export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
|
export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
|
||||||
if (!window.go?.main?.App?.ExtractFileFromZipToTemp) {
|
return t().extractFileFromZipToTemp(zipPath, filePath)
|
||||||
throw new Error('ExtractFileFromZipToTemp API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.go.main.App.ExtractFileFromZipToTemp(zipPath, filePath)
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] extractFileFromZipToTemp 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 zip 文件中特定文件的信息
|
|
||||||
*/
|
|
||||||
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
|
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
|
||||||
if (!window.go?.main?.App?.GetZipFileInfo) {
|
return transformFile(await t().getZipFileInfo(zipPath, filePath))
|
||||||
throw new Error('GetZipFileInfo API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.go.main.App.GetZipFileInfo(zipPath, filePath)
|
|
||||||
return transformFile(result)
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] getZipFileInfo 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用系统默认程序打开文件或目录
|
|
||||||
*/
|
|
||||||
export async function openPath(path: string): Promise<void> {
|
export async function openPath(path: string): Promise<void> {
|
||||||
if (!window.go?.main?.App?.OpenPath) {
|
await t().openPath(path)
|
||||||
throw new Error('OpenPath API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await window.go.main.App.OpenPath(path)
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] openPath 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取本地文件服务器URL
|
|
||||||
*/
|
|
||||||
export async function getFileServerURL(): Promise<string> {
|
export async function getFileServerURL(): Promise<string> {
|
||||||
if (!window.go?.main?.App?.GetFileServerURL) {
|
return t().getFileServerURL()
|
||||||
throw new Error('GetFileServerURL API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.GetFileServerURL()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function resolveShortcut(lnkPath: string): Promise<any> {
|
||||||
* 解析快捷方式文件,返回目标路径信息
|
return t().resolveShortcut(lnkPath)
|
||||||
*/
|
|
||||||
export async function resolveShortcut(lnkPath: string): Promise<{
|
|
||||||
success: boolean
|
|
||||||
message?: string
|
|
||||||
targetPath?: string
|
|
||||||
targetExists?: boolean
|
|
||||||
targetAccessible?: boolean
|
|
||||||
targetInfo?: any
|
|
||||||
}> {
|
|
||||||
if (!window.go?.main?.App?.ResolveShortcut) {
|
|
||||||
throw new Error('ResolveShortcut API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.go.main.App.ResolveShortcut(lnkPath)
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] resolveShortcut 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function detectFileTypeByContent(path: string) {
|
||||||
* 通过文件内容检测文件类型(用于小文件,500KB以内)
|
return t().detectFileTypeByContent(path)
|
||||||
*/
|
}
|
||||||
export async function detectFileTypeByContent(path: string): Promise<{
|
|
||||||
extension: string
|
export async function getCommonPaths() {
|
||||||
category: string // 'image' | 'text' | 'binary' | 'pdf' | 'archive' | 'unknown'
|
return t().getCommonPaths()
|
||||||
mime_type: string
|
|
||||||
confidence: number
|
|
||||||
}> {
|
|
||||||
if (!window.go?.main?.App?.DetectFileTypeByContent) {
|
|
||||||
throw new Error('DetectFileTypeByContent API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.go.main.App.DetectFileTypeByContent(path)
|
|
||||||
return result as any
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] detectFileTypeByContent 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
71
web/src/api/transport.ts
Normal file
71
web/src/api/transport.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 文件系统传输层接口
|
||||||
|
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
|
||||||
|
* Composable 和组件不感知底层差异
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FileItem = {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
size: number
|
||||||
|
size_str?: string
|
||||||
|
is_dir: boolean
|
||||||
|
mod_time?: string
|
||||||
|
mode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileOperationResult = {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
size_str?: string
|
||||||
|
is_dir: boolean
|
||||||
|
mod_time?: string
|
||||||
|
mode?: string
|
||||||
|
old_path?: string
|
||||||
|
deleted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DetectTypeResult = {
|
||||||
|
extension: string
|
||||||
|
category: string
|
||||||
|
mime_type: string
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FsTransport {
|
||||||
|
// 文件列表与信息
|
||||||
|
listDir(path: string): Promise<FileItem[]>
|
||||||
|
getFileInfo(path: string): Promise<Record<string, any>>
|
||||||
|
|
||||||
|
// 文件读写
|
||||||
|
readFile(path: string): Promise<string>
|
||||||
|
writeFile(path: string, content: string): Promise<void>
|
||||||
|
saveBase64File(path: string, content: string): Promise<void>
|
||||||
|
|
||||||
|
// 文件操作
|
||||||
|
createFile(dirPath: string, filename: string): Promise<FileOperationResult>
|
||||||
|
createDir(parentPath: string, dirname: string): Promise<FileOperationResult>
|
||||||
|
deletePath(path: string): Promise<FileOperationResult>
|
||||||
|
renamePath(oldPath: string, newPath: string): Promise<FileOperationResult>
|
||||||
|
|
||||||
|
// ZIP 操作(Wave 3)
|
||||||
|
listZipContents(zipPath: string): Promise<FileItem[]>
|
||||||
|
extractFileFromZip(zipPath: string, filePath: string): Promise<string>
|
||||||
|
extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string>
|
||||||
|
getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem>
|
||||||
|
|
||||||
|
// 系统操作
|
||||||
|
openPath(path: string): Promise<void>
|
||||||
|
getFileServerURL(): Promise<string>
|
||||||
|
getPreviewToken(): string
|
||||||
|
resolveShortcut(lnkPath: string): Promise<any>
|
||||||
|
detectFileTypeByContent(path: string): Promise<DetectTypeResult>
|
||||||
|
getCommonPaths(): Promise<Record<string, string>>
|
||||||
|
|
||||||
|
// 回收站(Wave 3)
|
||||||
|
getRecycleBinEntries(): Promise<any[]>
|
||||||
|
restoreFromRecycleBin(path: string): Promise<void>
|
||||||
|
deletePermanently(path: string): Promise<void>
|
||||||
|
emptyRecycleBin(): Promise<void>
|
||||||
|
}
|
||||||
139
web/src/api/wails-transport.ts
Normal file
139
web/src/api/wails-transport.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Wails Transport — 本地文件操作(通过 Wails IPC)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
|
||||||
|
|
||||||
|
function transformFile(file: any): FileItem {
|
||||||
|
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformFileList(files: any[]): FileItem[] {
|
||||||
|
return files.map(transformFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WailsTransport implements FsTransport {
|
||||||
|
private checkAvailable(method: string) {
|
||||||
|
if (!window.go?.main?.App?.[method]) {
|
||||||
|
throw new Error(`${method} API 不可用`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDir(path: string): Promise<FileItem[]> {
|
||||||
|
this.checkAvailable('ListDir')
|
||||||
|
return transformFileList(await window.go.main.App.ListDir(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileInfo(path: string): Promise<Record<string, any>> {
|
||||||
|
this.checkAvailable('GetFileInfo')
|
||||||
|
return window.go.main.App.GetFileInfo(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(path: string): Promise<string> {
|
||||||
|
this.checkAvailable('ReadFile')
|
||||||
|
return window.go.main.App.ReadFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, content: string): Promise<void> {
|
||||||
|
this.checkAvailable('WriteFile')
|
||||||
|
await window.go.main.App.WriteFile({ path: String(path), content: String(content) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBase64File(path: string, content: string): Promise<void> {
|
||||||
|
this.checkAvailable('SaveBase64File')
|
||||||
|
if (!content) throw new Error('无效的 base64 内容')
|
||||||
|
await window.go.main.App.SaveBase64File({ path: String(path), content })
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||||
|
this.checkAvailable('CreateFile')
|
||||||
|
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
||||||
|
return window.go.main.App.CreateFile(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||||
|
this.checkAvailable('CreateDir')
|
||||||
|
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
||||||
|
return window.go.main.App.CreateDir(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePath(path: string): Promise<FileOperationResult> {
|
||||||
|
this.checkAvailable('DeletePath')
|
||||||
|
return window.go.main.App.DeletePath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
|
||||||
|
this.checkAvailable('RenamePath')
|
||||||
|
return window.go.main.App.RenamePath({ oldPath: String(oldPath), newPath: String(newPath) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async listZipContents(zipPath: string): Promise<FileItem[]> {
|
||||||
|
this.checkAvailable('ListZipContents')
|
||||||
|
return transformFileList(await window.go.main.App.ListZipContents(zipPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
||||||
|
this.checkAvailable('ExtractFileFromZip')
|
||||||
|
return window.go.main.App.ExtractFileFromZip(zipPath, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
|
||||||
|
this.checkAvailable('ExtractFileFromZipToTemp')
|
||||||
|
return window.go.main.App.ExtractFileFromZipToTemp(zipPath, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem> {
|
||||||
|
this.checkAvailable('GetZipFileInfo')
|
||||||
|
return transformFile(await window.go.main.App.GetZipFileInfo(zipPath, filePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
async openPath(path: string): Promise<void> {
|
||||||
|
this.checkAvailable('OpenPath')
|
||||||
|
await window.go.main.App.OpenPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileServerURL(): Promise<string> {
|
||||||
|
this.checkAvailable('GetFileServerURL')
|
||||||
|
return window.go.main.App.GetFileServerURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreviewToken(): string {
|
||||||
|
return '' // 本地模式无需 token
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveShortcut(lnkPath: string): Promise<any> {
|
||||||
|
this.checkAvailable('ResolveShortcut')
|
||||||
|
return window.go.main.App.ResolveShortcut(lnkPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectFileTypeByContent(path: string): Promise<DetectTypeResult> {
|
||||||
|
this.checkAvailable('DetectFileTypeByContent')
|
||||||
|
const result = await window.go.main.App.DetectFileTypeByContent(path)
|
||||||
|
return result as unknown as DetectTypeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommonPaths(): Promise<Record<string, string>> {
|
||||||
|
this.checkAvailable('GetCommonPaths')
|
||||||
|
return window.go.main.App.GetCommonPaths()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecycleBinEntries(): Promise<any[]> {
|
||||||
|
this.checkAvailable('GetRecycleBinEntries')
|
||||||
|
return window.go.main.App.GetRecycleBinEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreFromRecycleBin(path: string): Promise<void> {
|
||||||
|
this.checkAvailable('RestoreFromRecycleBin')
|
||||||
|
await window.go.main.App.RestoreFromRecycleBin(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePermanently(path: string): Promise<void> {
|
||||||
|
this.checkAvailable('DeletePermanently')
|
||||||
|
await window.go.main.App.DeletePermanently(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async emptyRecycleBin(): Promise<void> {
|
||||||
|
this.checkAvailable('EmptyRecycleBin')
|
||||||
|
await window.go.main.App.EmptyRecycleBin()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal :visible="props.visible" :title="editingId ? '编辑连接' : '添加服务器'" unmount-on-close @cancel="emit('update:visible', false)" @before-ok="handleOk" :ok-loading="submitting">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 16px; max-width: 400px">
|
||||||
|
<div>
|
||||||
|
<div style="margin-bottom: 4px; font-size: 14px">名称</div>
|
||||||
|
<a-input v-model="form.name" placeholder="如:生产服务器" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="margin-bottom: 4px; font-size: 14px">地址</div>
|
||||||
|
<a-input v-model="form.host" placeholder="192.168.1.100" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="margin-bottom: 4px; font-size: 14px">端口</div>
|
||||||
|
<a-input-number v-model="form.port" :min="1" :max="65535" placeholder="9876" style="width: 100%" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="margin-bottom: 4px; font-size: 14px">
|
||||||
|
Token <span style="color: var(--color-text-3); font-size: 12px">API 认证令牌(与服务器配置一致)</span>
|
||||||
|
</div>
|
||||||
|
<a-input v-model="form.token" type="password" placeholder="留空则不认证" allow-clear />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
|
|
||||||
|
const props = defineProps<{ visible: boolean }>()
|
||||||
|
const emit = defineEmits<{ (e: 'update:visible', val: boolean): void; (e: 'close'): void }>()
|
||||||
|
|
||||||
|
const editingId = ref<string | null>(null)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
port: 9876,
|
||||||
|
token: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.visible, (val) => {
|
||||||
|
if (!val) return
|
||||||
|
editingId.value = null
|
||||||
|
Object.assign(form, { name: '', host: '', port: 9876, token: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleOk(): Promise<boolean> {
|
||||||
|
if (!form.name?.trim()) { Message.warning('请输入名称'); return false }
|
||||||
|
if (!form.host?.trim()) { Message.warning('请输入地址'); return false }
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
connectionManager.updateProfile(editingId.value, { ...form })
|
||||||
|
Message.success('已更新')
|
||||||
|
} else {
|
||||||
|
connectionManager.addProfile({ ...form, type: 'remote' })
|
||||||
|
Message.success('已添加')
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editProfile(id: string) {
|
||||||
|
const profile = connectionManager.profiles.find(p => p.id === id)
|
||||||
|
if (!profile) return
|
||||||
|
editingId.value = id
|
||||||
|
Object.assign(form, { name: profile.name, host: profile.host, port: profile.port, token: profile.token || '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ editProfile })
|
||||||
|
</script>
|
||||||
270
web/src/components/FileSystem/components/ConnectionIndicator.vue
Normal file
270
web/src/components/FileSystem/components/ConnectionIndicator.vue
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 无远程配置:极简入口按钮 -->
|
||||||
|
<div v-if="!hasRemote" class="connection-indicator mini" @click="$emit('add')">
|
||||||
|
<icon-cloud />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 有远程配置:完整标签 + 下拉菜单 -->
|
||||||
|
<div v-else class="connection-indicator" @click.stop="showMenu = !showMenu">
|
||||||
|
<span :class="['dot', state]" />
|
||||||
|
<span class="label">{{ label }}</span>
|
||||||
|
|
||||||
|
<div v-if="showMenu" class="menu" @click.stop>
|
||||||
|
<div class="menu-header">远程连接</div>
|
||||||
|
<div
|
||||||
|
v-for="p in profiles"
|
||||||
|
:key="p.id"
|
||||||
|
:class="['menu-item', { active: p.id === activeId }]"
|
||||||
|
@click="handleSelect(p)"
|
||||||
|
>
|
||||||
|
<span :class="['dot', p.type === 'remote' ? 'remote' : 'local']"></span>
|
||||||
|
<span class="menu-name">{{ p.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="p.type === 'remote'"
|
||||||
|
class="more-btn"
|
||||||
|
title="更多操作"
|
||||||
|
@click.stop="toggleMore(p)"
|
||||||
|
>···</span>
|
||||||
|
<!-- 更多操作子菜单 -->
|
||||||
|
<div v-if="moreOpenId === p.id" class="more-menu" @click.stop>
|
||||||
|
<div class="more-item" @click="handleEdit(p)">编辑</div>
|
||||||
|
<div class="more-item danger" @click="handleDelete(p)">删除</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-divider" />
|
||||||
|
<button class="menu-item add-btn" @click="$emit('add')">
|
||||||
|
+ 添加服务器
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { IconCloud } from '@arco-design/web-vue/es/icon'
|
||||||
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'add'): void; (e: 'select', id: string): void; (e: 'edit', id: string): void }>()
|
||||||
|
|
||||||
|
const showMenu = ref(false)
|
||||||
|
const moreOpenId = ref<string | null>(null)
|
||||||
|
const profiles = shallowRef(connectionManager.profiles)
|
||||||
|
const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
||||||
|
|
||||||
|
// 是否有远程 profile(决定显示模式)
|
||||||
|
const hasRemote = computed(() => profiles.value.some(p => p.type === 'remote'))
|
||||||
|
|
||||||
|
// 防抖:避免 connecting→connected 快速切换导致闪烁
|
||||||
|
const displayState = ref(connectionManager.state)
|
||||||
|
let _stateTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const state = computed(() => displayState.value)
|
||||||
|
const label = computed(() => {
|
||||||
|
const p = profiles.value.find(p => p.id === activeId.value)
|
||||||
|
if (!p || p.type === 'local') return '本地'
|
||||||
|
return p.name
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听连接变化,主动触发更新(带防抖)
|
||||||
|
connectionManager.onStateChange((newState) => {
|
||||||
|
profiles.value = connectionManager.profiles
|
||||||
|
activeId.value = connectionManager.activeProfile?.id ?? ''
|
||||||
|
if (_stateTimer) clearTimeout(_stateTimer)
|
||||||
|
if (newState === 'connecting') {
|
||||||
|
_stateTimer = setTimeout(() => { displayState.value = newState }, 300)
|
||||||
|
} else {
|
||||||
|
displayState.value = newState
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 点击外部关闭菜单
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
if (!el.closest('.connection-indicator')) {
|
||||||
|
showMenu.value = false
|
||||||
|
moreOpenId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||||
|
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
|
||||||
|
|
||||||
|
function handleSelect(p: { id: string }) {
|
||||||
|
connectionManager.connect(p.id)
|
||||||
|
showMenu.value = false
|
||||||
|
emit('select', p.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMore(p: { id: string }) {
|
||||||
|
moreOpenId.value = moreOpenId.value === p.id ? null : p.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(p: { id: string }) {
|
||||||
|
moreOpenId.value = null
|
||||||
|
showMenu.value = false
|
||||||
|
emit('edit', p.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(p: { id: string; name: string }) {
|
||||||
|
connectionManager.removeProfile(p.id)
|
||||||
|
moreOpenId.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.connection-indicator {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-indicator:hover {
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
border-color: var(--color-border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 极简模式:仅图标 */
|
||||||
|
.connection-indicator.mini {
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: none;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.connection-indicator.mini:hover {
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.connection-indicator.mini :deep(.arco-icon) {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.connected { background: rgb(var(--green-6)); }
|
||||||
|
.dot.connecting { background: #f5a623; animation: pulse 1.5s infinite; }
|
||||||
|
.dot.disconnected { background: var(--color-danger-6); }
|
||||||
|
.dot.error { background: var(--color-danger-6); }
|
||||||
|
.dot.local { background: var(--color-text-3); }
|
||||||
|
.dot.remote { background: #165dff; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
max-width: 70px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--color-bg-popup);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
border-bottom: 1px solid var(--color-fill-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.menu-item:hover { background: var(--color-fill-1); }
|
||||||
|
.menu-item.active { background: var(--color-primary-light-1); color: var(--color-primary-6); }
|
||||||
|
|
||||||
|
.menu-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.menu-item:hover .more-btn { opacity: 1; }
|
||||||
|
|
||||||
|
/* 更多操作子菜单 */
|
||||||
|
.more-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
min-width: 90px;
|
||||||
|
background: var(--color-bg-popup);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 10;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-item {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.more-item:hover { background: var(--color-fill-1); }
|
||||||
|
.more-item.danger { color: var(--color-danger-6); }
|
||||||
|
|
||||||
|
.menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-fill-1);
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-primary-6);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.add-btn:hover { background: var(--color-primary-light-1); }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
|
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
|
||||||
|
<!-- 有选中文件时显示表头和内容 -->
|
||||||
|
<template v-if="config.currentFileName">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">
|
<span class="panel-title">
|
||||||
<template v-if="config.isImageView">🖼️ 图片预览</template>
|
<template v-if="config.isImageView">🖼️ 图片预览</template>
|
||||||
@@ -72,7 +74,8 @@
|
|||||||
|
|
||||||
<!-- 视频预览 -->
|
<!-- 视频预览 -->
|
||||||
<div v-else-if="config.isVideoView" class="media-preview">
|
<div v-else-if="config.isVideoView" class="media-preview">
|
||||||
<video :src="config.previewUrl" controls class="preview-video"></video>
|
<video :src="config.previewUrl" controls class="preview-video" @error="handleMediaError('视频')"></video>
|
||||||
|
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||||
<div class="media-meta">
|
<div class="media-meta">
|
||||||
<a-tag color="arcoblue">🎬 视频</a-tag>
|
<a-tag color="arcoblue">🎬 视频</a-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +83,8 @@
|
|||||||
|
|
||||||
<!-- 音频预览 -->
|
<!-- 音频预览 -->
|
||||||
<div v-else-if="config.isAudioView" class="media-preview">
|
<div v-else-if="config.isAudioView" class="media-preview">
|
||||||
<audio :src="config.previewUrl" controls class="preview-audio"></audio>
|
<audio :src="config.previewUrl" controls class="preview-audio" @error="handleMediaError('音频')"></audio>
|
||||||
|
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||||
<div class="media-meta">
|
<div class="media-meta">
|
||||||
<a-tag color="green">🎵 音频</a-tag>
|
<a-tag color="green">🎵 音频</a-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +92,8 @@
|
|||||||
|
|
||||||
<!-- PDF 预览 -->
|
<!-- PDF 预览 -->
|
||||||
<div v-else-if="config.isPdfFile" class="media-preview media-preview-pdf">
|
<div v-else-if="config.isPdfFile" class="media-preview media-preview-pdf">
|
||||||
<iframe :src="config.previewUrl" class="preview-pdf"></iframe>
|
<iframe :src="config.previewUrl" class="preview-pdf" @load="handlePdfLoad"></iframe>
|
||||||
|
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||||
<div class="media-meta">
|
<div class="media-meta">
|
||||||
<a-tag color="orangered">📕 PDF</a-tag>
|
<a-tag color="orangered">📕 PDF</a-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,6 +359,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -367,6 +373,7 @@ import type { FileEditorPanelConfig } from '@/types/file-system'
|
|||||||
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||||
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
|
|
||||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||||
const AsyncCodeEditor = defineAsyncComponent({
|
const AsyncCodeEditor = defineAsyncComponent({
|
||||||
@@ -432,13 +439,27 @@ interface Emits {
|
|||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
// HTML 预览 URL(使用后端接口)
|
// HTML 预览 URL(实时从 connectionManager 读取,不缓存)
|
||||||
|
function resolveHtmlPreviewBase(): string {
|
||||||
|
if (!connectionManager.isRemote()) return 'http://localhost:8073'
|
||||||
|
const base = connectionManager.getFileServerBaseURL()
|
||||||
|
if (!base) return 'http://localhost:8073'
|
||||||
|
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs(htmlPreviewUrl 会替换为 html-preview)
|
||||||
|
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||||
|
}
|
||||||
|
|
||||||
const htmlPreviewUrl = computed(() => {
|
const htmlPreviewUrl = computed(() => {
|
||||||
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) {
|
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) return ''
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
|
const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
|
||||||
return `http://localhost:8073/localfs/html-preview?path=${encodedPath}`
|
const isRemote = connectionManager.isRemote()
|
||||||
|
const base = resolveHtmlPreviewBase()
|
||||||
|
if (isRemote) {
|
||||||
|
// 远程模式:走 /api/v1/proxy/html-preview 路由
|
||||||
|
const baseUrl = base.replace(/\/proxy\/localfs\/?$/, '')
|
||||||
|
return `${baseUrl}/proxy/html-preview?path=${encodedPath}`
|
||||||
|
}
|
||||||
|
// 本地模式:直连文件服务器
|
||||||
|
return `${base}/localfs/html-preview?path=${encodedPath}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算属性:判断文件是否在当前目录
|
// 计算属性:判断文件是否在当前目录
|
||||||
@@ -498,6 +519,30 @@ const handleImageError = () => {
|
|||||||
emit('imageError')
|
emit('imageError')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mediaErrorMsg = ref('')
|
||||||
|
const handleMediaError = (type: string) => {
|
||||||
|
mediaErrorMsg.value = `${type}文件加载失败,请检查网络连接或文件权限`
|
||||||
|
}
|
||||||
|
const handlePdfLoad = (event: Event) => {
|
||||||
|
const iframe = event.target as HTMLIFrameElement
|
||||||
|
try {
|
||||||
|
// iframe 加载后检查内容是否为空(401/404 等错误页面通常内容很少)
|
||||||
|
if (!iframe.contentDocument || iframe.contentDocument.body.innerHTML.length < 100) {
|
||||||
|
mediaErrorMsg.value = 'PDF 文件加载失败,请检查网络连接或文件权限'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 跨域时无法访问 contentDocument,忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带认证的 fetch(远程模式自动附加 Bearer token)
|
||||||
|
const authFetch = async (url: string): Promise<Response> => {
|
||||||
|
const token = connectionManager.activeProfile?.token
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
return fetch(url, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
// 打印窗口导出 PDF 公共函数
|
// 打印窗口导出 PDF 公共函数
|
||||||
const openPrintWindow = (title: string, bodyHtml: string, extraStyle = '') => {
|
const openPrintWindow = (title: string, bodyHtml: string, extraStyle = '') => {
|
||||||
const printWindow = window.open('', '_blank')
|
const printWindow = window.open('', '_blank')
|
||||||
@@ -650,7 +695,7 @@ const loadExcelPreview = async (filePath: string) => {
|
|||||||
|
|
||||||
// 直接从本地文件服务器获取(不走 base64)
|
// 直接从本地文件服务器获取(不走 base64)
|
||||||
const fileUrl = props.config.previewUrl
|
const fileUrl = props.config.previewUrl
|
||||||
const response = await fetch(fileUrl)
|
const response = await authFetch(fileUrl)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
|
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
|
||||||
|
|
||||||
@@ -679,7 +724,7 @@ const loadWordPreview = async (filePath: string) => {
|
|||||||
wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||||
|
|
||||||
const fileUrl = props.config.previewUrl
|
const fileUrl = props.config.previewUrl
|
||||||
const response = await fetch(fileUrl)
|
const response = await authFetch(fileUrl)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' })
|
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' })
|
||||||
|
|
||||||
@@ -709,7 +754,7 @@ const loadCsvPreview = async (filePath: string) => {
|
|||||||
|
|
||||||
const blob = props.config.fileContent && !props.config.isBinaryFile
|
const blob = props.config.fileContent && !props.config.isBinaryFile
|
||||||
? new Blob([props.config.fileContent], { type: 'text/csv' })
|
? new Blob([props.config.fileContent], { type: 'text/csv' })
|
||||||
: await (await fetch(props.config.previewUrl)).blob()
|
: await (await authFetch(props.config.previewUrl)).blob()
|
||||||
const file = new File([blob], getFileName(filePath), { type: 'text/csv' })
|
const file = new File([blob], getFileName(filePath), { type: 'text/csv' })
|
||||||
|
|
||||||
const result = await previewCsv(file, csvPreviewRef.value)
|
const result = await previewCsv(file, csvPreviewRef.value)
|
||||||
@@ -786,8 +831,8 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
|
|||||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
|
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
'null', // about:blank 或 data: URL
|
'null',
|
||||||
'http://localhost:8073', // 本地文件服务器
|
resolveHtmlPreviewBase(), // 动态:本地 localhost:8073 或远程代理地址
|
||||||
]
|
]
|
||||||
if (!allowedOrigins.includes(event.origin)) {
|
if (!allowedOrigins.includes(event.origin)) {
|
||||||
return
|
return
|
||||||
@@ -835,11 +880,9 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 12px;
|
padding: 3px 12px;
|
||||||
background: var(--color-fill-1);
|
background: var(--color-bg-2);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
@@ -944,6 +987,13 @@ onUnmounted(() => {
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-error {
|
||||||
|
color: var(--color-danger-6);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.media-meta {
|
.media-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">📋 文件列表</span>
|
<span class="panel-title">📋 文件列表</span>
|
||||||
<div class="panel-header-right">
|
<div class="panel-header-right">
|
||||||
<span class="panel-count">{{ config.fileList.length }} 项</span>
|
|
||||||
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }">
|
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }">
|
||||||
<a-button size="mini" type="text" class="settings-btn">
|
<a-button size="mini" type="text" class="settings-btn">
|
||||||
<icon-more />
|
<icon-more />
|
||||||
@@ -50,21 +49,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="file-list-wrapper"
|
class="file-list-wrapper thin-dark-scrollbar"
|
||||||
@contextmenu.prevent="handleWrapperContextMenu"
|
@contextmenu.prevent="handleWrapperContextMenu"
|
||||||
>
|
>
|
||||||
<!-- 文件列表(a-table) -->
|
<!-- 文件列表(滚动区域) -->
|
||||||
<a-table
|
<a-table
|
||||||
v-if="config.fileList.length > 0 || config.fileLoading"
|
v-if="config.fileList.length > 0 || config.fileLoading"
|
||||||
:columns="tableColumns"
|
:columns="tableColumns"
|
||||||
:data="config.fileList"
|
:data="pagedFileList"
|
||||||
:loading="config.fileLoading"
|
:loading="config.fileLoading"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
:show-header="showHeader"
|
:show-header="showHeader"
|
||||||
size="mini"
|
size="mini"
|
||||||
:row-class-name="getRowClassName"
|
:row-class-name="getRowClassName"
|
||||||
:scroll="{ y: 'auto' }"
|
|
||||||
class="file-table"
|
class="file-table"
|
||||||
@row-click="handleRowClick"
|
@row-click="handleRowClick"
|
||||||
@row-contextmenu="handleRowContextMenu"
|
@row-contextmenu="handleRowContextMenu"
|
||||||
@@ -76,13 +74,27 @@
|
|||||||
<span>此文件夹为空</span>
|
<span>此文件夹为空</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页栏(固定在面板底部,不随内容滚动) -->
|
||||||
|
<div v-if="config.fileList.length > 0" class="pagination-bar">
|
||||||
|
<span class="pagination-total">共 {{ config.fileList.length }} 项</span>
|
||||||
|
<span class="pagination-nav">
|
||||||
|
<span class="page-btn" :class="{ disabled: currentPage <= 1 }" @click="onPageChange(currentPage - 1)">
|
||||||
|
<icon-left />
|
||||||
|
</span>
|
||||||
|
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
|
||||||
|
<span class="page-btn" :class="{ disabled: currentPage >= totalPages }" @click="onPageChange(currentPage + 1)">
|
||||||
|
<icon-right />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { h, computed, nextTick, ref } from 'vue'
|
import { h, computed, nextTick, ref, watch } from 'vue'
|
||||||
import { Input, Button } from '@arco-design/web-vue'
|
import { Input, Button } from '@arco-design/web-vue'
|
||||||
import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore } from '@arco-design/web-vue/es/icon'
|
import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore, IconLeft, IconRight } from '@arco-design/web-vue/es/icon'
|
||||||
import { formatBytes, formatFileTime, getFileIcon, getExt } from '@/utils/fileUtils'
|
import { formatBytes, formatFileTime, getFileIcon, getExt } from '@/utils/fileUtils'
|
||||||
import { STORAGE_KEYS } from '@/utils/constants'
|
import { STORAGE_KEYS } from '@/utils/constants'
|
||||||
import type { FileListPanelConfig, FileItem } from '@/types/file-system'
|
import type { FileListPanelConfig, FileItem } from '@/types/file-system'
|
||||||
@@ -159,8 +171,8 @@ function loadColSettings(): ColumnConfig[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const colSettings = ref<ColumnConfig[]>(loadColSettings())
|
const colSettings = ref<ColumnConfig[]>(loadColSettings())
|
||||||
// 默认显示表头(localStorage 无值时兼容旧行为)
|
// 默认隐藏表头(localStorage 无值时默认不显示)
|
||||||
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) !== 'false')
|
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) === 'true')
|
||||||
|
|
||||||
// 手动持久化(避免 deep watch 频繁写入)
|
// 手动持久化(避免 deep watch 频繁写入)
|
||||||
function saveColSettings() {
|
function saveColSettings() {
|
||||||
@@ -332,6 +344,26 @@ const tableColumns = computed(() => {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ========== 分页 ==========
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = 100
|
||||||
|
|
||||||
|
const pagedFileList = computed(() => {
|
||||||
|
const list = props.config.fileList
|
||||||
|
const start = (currentPage.value - 1) * pageSize
|
||||||
|
return list.slice(start, start + pageSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(props.config.fileList.length / pageSize)))
|
||||||
|
|
||||||
|
const onPageChange = (page: number) => {
|
||||||
|
if (page < 1 || page > totalPages.value) return
|
||||||
|
currentPage.value = page
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当文件列表变化时重置到第1页
|
||||||
|
watch(() => props.config.fileList.length, () => { currentPage.value = 1 })
|
||||||
|
|
||||||
// ========== 行事件处理 ==========
|
// ========== 行事件处理 ==========
|
||||||
const handleRowClick = (record: FileItem, ev: Event) => {
|
const handleRowClick = (record: FileItem, ev: Event) => {
|
||||||
const target = ev.target as HTMLElement
|
const target = ev.target as HTMLElement
|
||||||
@@ -372,12 +404,13 @@ defineExpose({ focusEditingItem })
|
|||||||
.file-list-panel {
|
.file-list-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
flex: 1; /* 父级是 flex 容器,用 flex:1 而非 height:100% */
|
||||||
|
min-height: 0; /* 允许收缩到小于内容高度 */
|
||||||
background: var(--color-bg-1);
|
background: var(--color-bg-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
padding: 6px 12px;
|
padding: 3px 12px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
background: var(--color-bg-2);
|
background: var(--color-bg-2);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -422,15 +455,20 @@ defineExpose({ focusEditingItem })
|
|||||||
color: rgb(var(--primary-6));
|
color: rgb(var(--primary-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动容器 */
|
/* 滚动容器(table + 分页 的统一滚动层) */
|
||||||
.file-list-wrapper {
|
.file-list-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====== Table 全局覆盖 ====== */
|
/* ====== Table ====== */
|
||||||
|
.file-table {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
.file-table :deep(.arco-table) {
|
.file-table :deep(.arco-table) {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
@@ -564,4 +602,35 @@ defineExpose({ focusEditingItem })
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.empty-state span:nth-child(2) { font-size: 14px; }
|
.empty-state span:nth-child(2) { font-size: 14px; }
|
||||||
|
|
||||||
|
/* 分页栏(固定底部) */
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--color-bg-2);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pagination-total {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
.pagination-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.page-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
padding: 0 3px;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.page-btn:hover:not(.disabled) { color: rgb(var(--primary-6)); }
|
||||||
|
.page-btn.disabled { color: var(--color-text-4); cursor: default; }
|
||||||
|
.page-info { color: var(--color-text-2); min-width: 28px; text-align: center; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<!-- 路径段 -->
|
<!-- 路径段 -->
|
||||||
<div
|
<div
|
||||||
class="breadcrumb-segment"
|
class="breadcrumb-segment"
|
||||||
:class="{ 'is-hoverable': index < segments.length - 1 }"
|
:class="{ 'is-hoverable': index < segments.length - 1 || segments.length === 1 }"
|
||||||
@mouseenter="onHover(segment, index)"
|
@mouseenter="onHover(segment, index)"
|
||||||
@mouseleave="onLeave"
|
@mouseleave="onLeave"
|
||||||
@click="onClick(segment)"
|
@click="onClick(segment)"
|
||||||
@@ -152,7 +152,8 @@ const resetAndClose = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onHover = (segment: PathSegment, index: number) => {
|
const onHover = (segment: PathSegment, index: number) => {
|
||||||
if (index === segments.value.length - 1) return
|
// 根目录(如 C:)只有一段,也允许悬停弹出子目录
|
||||||
|
if (index === segments.value.length - 1 && segments.value.length > 1) return
|
||||||
|
|
||||||
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||||
if (closeTimer.value) clearTimeout(closeTimer.value)
|
if (closeTimer.value) clearTimeout(closeTimer.value)
|
||||||
@@ -209,7 +210,7 @@ watch(() => props.path, () => {
|
|||||||
.breadcrumb-items {
|
.breadcrumb-items {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +243,7 @@ watch(() => props.path, () => {
|
|||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 0 2px;
|
margin: 0 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹出菜单 */
|
/* 弹出菜单 */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div v-show="config.visible" class="sidebar">
|
<div v-show="config.visible" class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<span class="sidebar-title">⭐ 收藏夹</span>
|
<span class="sidebar-title">⭐ 收藏夹</span>
|
||||||
<span class="sidebar-count">{{ config.favoriteFiles.length }}</span>
|
<span class="sidebar-count">共{{ config.favoriteFiles.length }}项</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<div
|
<div
|
||||||
@@ -154,7 +154,7 @@ const handleDragEnd = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
padding: 12px 16px;
|
padding: 6px 12px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -163,17 +163,14 @@ const handleDragEnd = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-title {
|
.sidebar-title {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-count {
|
.sidebar-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
background: var(--color-fill-2);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
📦 {{ config.zipFileName }}
|
📦 {{ config.zipFileName }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0">
|
<template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0">
|
||||||
<icon-right class="breadcrumb-separator" />
|
<icon-right class="breadcrumb-sep" />
|
||||||
<a-tag
|
<a-tag
|
||||||
v-for="(crumb, index) in config.zipBreadcrumbs"
|
v-for="(crumb, index) in config.zipBreadcrumbs"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -25,41 +25,49 @@
|
|||||||
退出 ZIP
|
退出 ZIP
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 正常模式:面包屑导航 -->
|
<!-- 正常模式:连接指示器 + 面包屑导航(融合布局) -->
|
||||||
<div v-else class="path-breadcrumb-wrapper">
|
<div v-else class="path-breadcrumb-wrapper">
|
||||||
<!-- 快捷访问(仅图标,面包屑前) -->
|
<!-- 连接指示器(紧凑标签样式,作为面包屑首段) -->
|
||||||
<a-dropdown>
|
<ConnectionIndicator @add="showConnectionDialog = true" @select="onConnectionChanged" @edit="onEditProfile" />
|
||||||
<a-button size="mini" type="text">
|
<span class="breadcrumb-sep">›</span>
|
||||||
<template #icon><icon-forward /></template>
|
<!-- 路径面包屑 -->
|
||||||
</a-button>
|
|
||||||
<template #content>
|
|
||||||
<a-doption
|
|
||||||
v-for="shortcut in config.commonPaths"
|
|
||||||
:key="shortcut.path"
|
|
||||||
@click="handleGoToPath(shortcut.path)"
|
|
||||||
>
|
|
||||||
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
|
|
||||||
{{ (shortcut.name || '').substring(2) }}
|
|
||||||
</a-doption>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
<PathBreadcrumb
|
<PathBreadcrumb
|
||||||
:path="config.filePath"
|
:path="config.filePath"
|
||||||
@navigate="handleGoToPath"
|
@navigate="handleGoToPath"
|
||||||
@openFile="handleOpenFile"
|
@openFile="handleOpenFile"
|
||||||
/>
|
/>
|
||||||
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="top">
|
<!-- 右侧操作:快捷路径 + 复制 -->
|
||||||
<a-button
|
<div class="breadcrumb-right-actions">
|
||||||
size="mini"
|
<a-tooltip content="快捷路径" position="bottom">
|
||||||
type="text"
|
<a-dropdown>
|
||||||
:status="copied ? 'success' : 'normal'"
|
<a-button size="mini" type="text" class="shortcut-btn">
|
||||||
class="toolbar-copy-btn"
|
<template #icon><icon-forward /></template>
|
||||||
@click="handleCopyPath"
|
</a-button>
|
||||||
>
|
<template #content>
|
||||||
<icon-copy v-if="!copied" />
|
<a-doption
|
||||||
<icon-check v-else />
|
v-for="shortcut in config.commonPaths"
|
||||||
</a-button>
|
:key="shortcut.path"
|
||||||
</a-tooltip>
|
@click="handleGoToPath(shortcut.path)"
|
||||||
|
>
|
||||||
|
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
|
||||||
|
{{ (shortcut.name || '').substring(2) }}
|
||||||
|
</a-doption>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</a-tooltip>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +81,7 @@
|
|||||||
class="toolbar-search"
|
class="toolbar-search"
|
||||||
allow-clear
|
allow-clear
|
||||||
@search="handleSearch"
|
@search="handleSearch"
|
||||||
@update:model-value="handleSearchInput"
|
@update:model-value="handleSearch"
|
||||||
@keyup.escape="handleClearSearch"
|
@keyup.escape="handleClearSearch"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -124,13 +132,18 @@
|
|||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConnectionDialog ref="connectionDialogRef" v-model:visible="showConnectionDialog" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { nextTick } from 'vue'
|
||||||
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy, IconCheck } 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 type { ToolbarConfig } from '@/types/file-system'
|
||||||
import PathBreadcrumb from './PathBreadcrumb.vue'
|
import PathBreadcrumb from './PathBreadcrumb.vue'
|
||||||
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||||
|
import ConnectionIndicator from './ConnectionIndicator.vue'
|
||||||
|
import ConnectionDialog from './ConnectionDialog.vue'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -149,10 +162,24 @@ interface Emits {
|
|||||||
(e: 'goToPath', path: string): void
|
(e: 'goToPath', path: string): void
|
||||||
(e: 'openFile', path: string): void
|
(e: 'openFile', path: string): void
|
||||||
(e: 'navigateToZipDirectory', path: string): void
|
(e: 'navigateToZipDirectory', path: string): void
|
||||||
|
(e: 'connectionChanged'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// 连接对话框
|
||||||
|
const showConnectionDialog = ref(false)
|
||||||
|
const connectionDialogRef = ref<InstanceType<typeof ConnectionDialog>>()
|
||||||
|
const onConnectionChanged = async (_id: string) => {
|
||||||
|
emit('connectionChanged')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEditProfile = (id: string) => {
|
||||||
|
showConnectionDialog.value = true
|
||||||
|
// 等待 DOM 更新后调用 editProfile 填充表单
|
||||||
|
nextTick(() => connectionDialogRef.value?.editProfile(id))
|
||||||
|
}
|
||||||
|
|
||||||
// 历史记录下拉显隐(供父组件 Ctrl+H 调用)
|
// 历史记录下拉显隐(供父组件 Ctrl+H 调用)
|
||||||
const historyPopupVisible = ref(false)
|
const historyPopupVisible = ref(false)
|
||||||
|
|
||||||
@@ -177,10 +204,6 @@ const handleNavigateToZipRoot = () => {
|
|||||||
emit('navigateToZipDirectory', '')
|
emit('navigateToZipDirectory', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNavigateToZipDirectory = (path: string) => {
|
|
||||||
emit('navigateToZipDirectory', path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleSidebar = () => {
|
const handleToggleSidebar = () => {
|
||||||
emit('update:showSidebar', !props.config.showSidebar)
|
emit('update:showSidebar', !props.config.showSidebar)
|
||||||
}
|
}
|
||||||
@@ -189,10 +212,6 @@ const handleSearch = (keyword: string) => {
|
|||||||
emit('update:searchKeyword', keyword)
|
emit('update:searchKeyword', keyword)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchInput = (keyword: string) => {
|
|
||||||
emit('update:searchKeyword', keyword)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearSearch = () => {
|
const handleClearSearch = () => {
|
||||||
emit('update:searchKeyword', '')
|
emit('update:searchKeyword', '')
|
||||||
}
|
}
|
||||||
@@ -237,6 +256,11 @@ const handleCopyPath = async () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-right :deep(.arco-btn-size-small),
|
||||||
|
.toolbar-right :deep(.arco-input-wrapper) {
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-search {
|
.toolbar-search {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -247,27 +271,44 @@ const handleCopyPath = async () => {
|
|||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-breadcrumb-wrapper {
|
.path-breadcrumb-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--color-fill-1);
|
background: var(--color-fill-1);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-breadcrumb-wrapper:hover {
|
.path-breadcrumb-wrapper:hover {
|
||||||
border-color: var(--color-border-2);
|
border-color: var(--color-border-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.breadcrumb-sep {
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-right-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-btn {
|
||||||
|
padding: 1px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-copy-btn {
|
.toolbar-copy-btn {
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
}
|
}
|
||||||
@@ -293,12 +334,6 @@ const handleCopyPath = async () => {
|
|||||||
border-color: rgb(var(--primary-6));
|
border-color: rgb(var(--primary-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-separator {
|
|
||||||
color: var(--color-text-3);
|
|
||||||
font-size: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-tag {
|
.breadcrumb-tag {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -312,15 +347,6 @@ const handleCopyPath = async () => {
|
|||||||
border-color: rgb(var(--primary-6));
|
border-color: rgb(var(--primary-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
.zip-path-text {
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-text-2);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 历史记录下拉 */
|
/* 历史记录下拉 */
|
||||||
.history-dropdown-content {
|
.history-dropdown-content {
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
|
|||||||
@@ -5,90 +5,56 @@
|
|||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { PATH_ICONS } from '@/utils/constants'
|
import { PATH_ICONS } from '@/utils/constants'
|
||||||
|
import { getCommonPaths } from '@/api/system'
|
||||||
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import type { ShortcutPath } from '@/types/file-system'
|
import type { ShortcutPath } from '@/types/file-system'
|
||||||
|
|
||||||
export function useCommonPaths() {
|
export function useCommonPaths() {
|
||||||
// 系统路径
|
|
||||||
const commonPaths = ref<ShortcutPath[]>([])
|
const commonPaths = ref<ShortcutPath[]>([])
|
||||||
const systemPaths = ref<Record<string, string>>({})
|
const systemPaths = ref<Record<string, string>>({})
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载常用系统路径
|
|
||||||
*/
|
|
||||||
const loadCommonPaths = async () => {
|
const loadCommonPaths = async () => {
|
||||||
try {
|
try {
|
||||||
// 检查 Wails API 是否可用
|
const paths = await getCommonPaths()
|
||||||
if (!window.go?.main?.App?.GetCommonPaths) {
|
if (!paths) throw new Error('无法获取系统路径')
|
||||||
// 降级方案:使用默认路径
|
|
||||||
commonPaths.value = [
|
|
||||||
{ name: '💿 C盘', path: 'C:\\' },
|
|
||||||
{ name: '💿 D盘', path: 'D:\\' }
|
|
||||||
]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const paths = await window.go.main.App.GetCommonPaths()
|
|
||||||
if (!paths) {
|
|
||||||
throw new Error('无法获取系统路径')
|
|
||||||
}
|
|
||||||
|
|
||||||
systemPaths.value = paths
|
systemPaths.value = paths
|
||||||
const platform = window.navigator.platform
|
|
||||||
const pathList: ShortcutPath[] = []
|
const pathList: ShortcutPath[] = []
|
||||||
|
// 根据返回数据判断平台(Linux agent 返回 root key,Windows 返回 root_ 前缀)
|
||||||
|
const isWin = !!Object.keys(paths).find(k => k.startsWith('root_'))
|
||||||
|
|
||||||
if (platform.includes('Win')) {
|
if (isWin) {
|
||||||
// Windows: 先添加基础路径,再添加所有盘符
|
|
||||||
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
|
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
|
||||||
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
|
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
|
||||||
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
|
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
|
||||||
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 用户目录`, path: paths.home })
|
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 用户目录`, path: paths.home })
|
||||||
|
|
||||||
// 动态添加所有盘符(按字母顺序)
|
|
||||||
const drives: Array<{ letter: string; path: string }> = []
|
const drives: Array<{ letter: string; path: string }> = []
|
||||||
for (const key in paths) {
|
for (const key in paths) {
|
||||||
if (key.startsWith('root_')) {
|
if (key.startsWith('root_')) {
|
||||||
const driveLetter = key.substring(5)
|
drives.push({ letter: key.substring(5), path: paths[key] })
|
||||||
drives.push({
|
|
||||||
letter: driveLetter,
|
|
||||||
path: paths[key]
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drives.sort((a, b) => a.letter.localeCompare(b.letter))
|
drives.sort((a, b) => a.letter.localeCompare(b.letter))
|
||||||
|
drives.forEach(d => pathList.push({ name: `${PATH_ICONS.DRIVE} ${d.letter}盘`, path: d.path }))
|
||||||
// 添加盘符到路径列表
|
|
||||||
drives.forEach(drive => {
|
|
||||||
pathList.push({
|
|
||||||
name: `${PATH_ICONS.DRIVE} ${drive.letter}盘`,
|
|
||||||
path: drive.path
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
// macOS/Linux: 使用系统路径
|
// Linux 远程模式
|
||||||
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
|
|
||||||
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
|
|
||||||
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
|
|
||||||
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home })
|
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home })
|
||||||
pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' })
|
if (paths.root) pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' })
|
||||||
|
if (paths.users) pathList.push({ name: `👥 /home`, path: paths.users })
|
||||||
}
|
}
|
||||||
|
|
||||||
commonPaths.value = pathList.length > 0 ? pathList : [
|
commonPaths.value = pathList.length > 0 ? pathList : (
|
||||||
{ name: '💿 C盘', path: 'C:\\' },
|
connectionManager.isRemote()
|
||||||
{ name: '💿 D盘', path: 'D:\\' }
|
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||||
]
|
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载系统路径失败:', error)
|
console.error('加载系统路径失败:', error)
|
||||||
// 降级方案
|
commonPaths.value = connectionManager.isRemote()
|
||||||
commonPaths.value = [
|
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||||
{ name: '💿 C盘', path: 'C:\\' },
|
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||||
{ name: '💿 D盘', path: 'D:\\' }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { commonPaths, systemPaths, loadCommonPaths }
|
||||||
commonPaths,
|
|
||||||
systemPaths,
|
|
||||||
loadCommonPaths
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ref } from 'vue'
|
|||||||
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||||
import { normalizeFilePath, getExt } from '@/utils/fileUtils'
|
import { normalizeFilePath, getExt } from '@/utils/fileUtils'
|
||||||
import { detectFileTypeByContent } from '@/api/system'
|
import { detectFileTypeByContent } from '@/api/system'
|
||||||
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import {
|
import {
|
||||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||||
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
||||||
@@ -26,21 +27,22 @@ export interface UseFilePreviewOptions {
|
|||||||
isBrowsingZip?: boolean
|
isBrowsingZip?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocalServerURL(): string {
|
||||||
|
return 'http://localhost:8073'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFileServerBase(): string {
|
||||||
|
// 单一数据源:从 connectionManager 实时读取,不缓存
|
||||||
|
if (!connectionManager.isRemote()) return getLocalServerURL()
|
||||||
|
const base = connectionManager.getFileServerBaseURL()
|
||||||
|
if (!base) return getLocalServerURL()
|
||||||
|
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs
|
||||||
|
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||||
|
}
|
||||||
|
|
||||||
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||||
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
||||||
|
|
||||||
// 文件服务器 URL(优先从后端获取,降级到默认值)
|
|
||||||
let _fileServerURL = 'http://localhost:8073'
|
|
||||||
const initFileServerURL = async () => {
|
|
||||||
try {
|
|
||||||
const url = await window.go.main.App.GetFileServerURL()
|
|
||||||
if (url) _fileServerURL = url
|
|
||||||
} catch { /* 使用默认值 */ }
|
|
||||||
}
|
|
||||||
initFileServerURL()
|
|
||||||
|
|
||||||
const getFileServerURL = () => _fileServerURL
|
|
||||||
|
|
||||||
// 预览 URL
|
// 预览 URL
|
||||||
const previewUrl = ref('')
|
const previewUrl = ref('')
|
||||||
|
|
||||||
@@ -49,12 +51,19 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
|||||||
const currentImageDimensions = ref('')
|
const currentImageDimensions = ref('')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取预览 URL(与旧版本保持一致)
|
* 获取预览 URL(本地/远程自适应,每次实时计算)
|
||||||
|
* 本地: http://localhost:8073/localfs/{encoded_path}
|
||||||
|
* 远程: {baseUrl}/api/v1/proxy/localfs/{raw_path}(Cookie 自动携带认证)
|
||||||
*/
|
*/
|
||||||
const getPreviewUrl = (path: string): string => {
|
const getPreviewUrl = (path: string): string => {
|
||||||
if (!path) return ''
|
if (!path) return ''
|
||||||
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
|
const isRemote = connectionManager.isRemote()
|
||||||
return `${getFileServerURL()}/localfs/${normalizeFilePath(path, true)}`
|
const base = resolveFileServerBase()
|
||||||
|
let normalized = normalizeFilePath(path, true)
|
||||||
|
// 远程模式去掉前导 /,避免与 URL 基础路径拼接产生双斜杠(导致 307 重定向)
|
||||||
|
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||||
|
const sep = base.endsWith('/') ? '' : '/'
|
||||||
|
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,7 +94,7 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
|||||||
/**
|
/**
|
||||||
* 更新预览 URL
|
* 更新预览 URL
|
||||||
*/
|
*/
|
||||||
const updatePreviewUrl = (path: string) => {
|
const updatePreviewUrl = async (path: string) => {
|
||||||
previewUrl.value = getPreviewUrl(path)
|
previewUrl.value = getPreviewUrl(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
@navigate-to-zip-directory="handleNavigateToZipDirectory"
|
@navigate-to-zip-directory="handleNavigateToZipDirectory"
|
||||||
@update:search-keyword="handleSearchKeywordUpdate"
|
@update:search-keyword="handleSearchKeywordUpdate"
|
||||||
@show-message="handleShowMessage"
|
@show-message="handleShowMessage"
|
||||||
|
@connection-changed="handleConnectionChanged"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
<!-- 主内容区 -->
|
||||||
@@ -56,9 +57,8 @@
|
|||||||
<!-- 分隔条 -->
|
<!-- 分隔条 -->
|
||||||
<div class="resizer" @mousedown="handleHorizontalResize"></div>
|
<div class="resizer" @mousedown="handleHorizontalResize"></div>
|
||||||
|
|
||||||
<!-- 文件编辑器面板 -->
|
<!-- 文件编辑器面板(始终显示,无选中文件时为空白预览区) -->
|
||||||
<FileEditorPanel
|
<FileEditorPanel
|
||||||
v-if="hasSelectedFile"
|
|
||||||
:config="fileEditorPanelConfig"
|
:config="fileEditorPanelConfig"
|
||||||
:width="panelWidth.right"
|
:width="panelWidth.right"
|
||||||
:current-directory="filePath"
|
:current-directory="filePath"
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { getPathSeparator } from '@/utils/fileUtils'
|
import { getPathSeparator } from '@/utils/fileUtils'
|
||||||
import { Message, Modal } from '@arco-design/web-vue'
|
import { Message, Modal } from '@arco-design/web-vue'
|
||||||
import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
// 导入子组件
|
// 导入子组件
|
||||||
@@ -129,6 +129,7 @@ import { useCommonPaths } from './composables/useCommonPaths'
|
|||||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||||
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
|
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
|
||||||
import { listDir, saveBase64File } from '@/api/system'
|
import { listDir, saveBase64File } from '@/api/system'
|
||||||
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||||
import { createResizeHandler } from '@/utils/resize'
|
import { createResizeHandler } from '@/utils/resize'
|
||||||
|
|
||||||
@@ -336,10 +337,24 @@ const computeRendered = computed(() => {
|
|||||||
if (isHtmlFile(currentFileName)) {
|
if (isHtmlFile(currentFileName)) {
|
||||||
return fileContent.value || ''
|
return fileContent.value || ''
|
||||||
} else if (isMarkdownFile(currentFileName)) {
|
} else if (isMarkdownFile(currentFileName)) {
|
||||||
// 使用配置好的 marked 渲染 Markdown(支持 mermaid)
|
// 使用配置好的 marked 渲染 Markdown(支持 mermaid + 图片相对路径转换)
|
||||||
try {
|
try {
|
||||||
const content = fileContent.value || ''
|
const content = fileContent.value || ''
|
||||||
return marked(content)
|
|
||||||
|
// 设置图片路径转换所需的上下文(renderer.image 钩子中读取)
|
||||||
|
// dir: 当前 md 文件所在目录(从文件完整路径中去掉文件名)
|
||||||
|
const fullPath = selectedFileItem.value?.path || ''
|
||||||
|
const dir = fullPath ? fullPath.replace(/[/\\][^/\\]+$/, '') : (filePath.value || '')
|
||||||
|
setCurrentFileDir(dir)
|
||||||
|
|
||||||
|
// 设置文件服务器 Base URL
|
||||||
|
const isRemote = connectionManager.isRemote()
|
||||||
|
const base = isRemote
|
||||||
|
? (connectionManager.getFileServerBaseURL().replace(/\/$/, '') + '/api/v1/proxy/localfs')
|
||||||
|
: 'http://localhost:8073/localfs'
|
||||||
|
setFileServerBase(base)
|
||||||
|
|
||||||
|
return marked.parse(content) as string
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Markdown 解析失败:', error)
|
console.error('Markdown 解析失败:', error)
|
||||||
return fileContent.value || ''
|
return fileContent.value || ''
|
||||||
@@ -399,10 +414,23 @@ const handleRefresh = async () => {
|
|||||||
await loadDirectory(filePath.value)
|
await loadDirectory(filePath.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 连接切换后重置路径(仅监听状态变化以刷新快捷入口,不做自动导航)
|
||||||
|
connectionManager.onStateChange(async (state) => {
|
||||||
|
if (state === 'connected') {
|
||||||
|
await loadCommonPaths()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const handleSearchKeywordUpdate = (keyword: string) => {
|
const handleSearchKeywordUpdate = (keyword: string) => {
|
||||||
searchKeyword.value = keyword
|
searchKeyword.value = keyword
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用户主动切换连接时重置到根路径
|
||||||
|
const handleConnectionChanged = async () => {
|
||||||
|
await loadCommonPaths()
|
||||||
|
await navigate(connectionManager.isRemote() ? '/' : 'C:/')
|
||||||
|
}
|
||||||
|
|
||||||
const handleGoToPath = async (path: string) => {
|
const handleGoToPath = async (path: string) => {
|
||||||
await navigate(path)
|
await navigate(path)
|
||||||
}
|
}
|
||||||
@@ -500,6 +528,26 @@ const handleShowMessage = (message: string, type: 'success' | 'error' | 'warning
|
|||||||
|
|
||||||
// 侧边栏事件
|
// 侧边栏事件
|
||||||
const handleOpenFavorite = async (file: FavoriteFile) => {
|
const handleOpenFavorite = async (file: FavoriteFile) => {
|
||||||
|
// 根据路径格式自动切换连接(Linux 路径 → 远程,Windows 路径 → 本地)
|
||||||
|
const isLinuxPath = /^[\/]/.test(file.path) && !/^[A-Za-z]:/.test(file.path)
|
||||||
|
const shouldBeRemote = isLinuxPath
|
||||||
|
const isCurrentlyRemote = connectionManager.isRemote()
|
||||||
|
|
||||||
|
if (shouldBeRemote !== isCurrentlyRemote) {
|
||||||
|
// 需要切换连接
|
||||||
|
if (shouldBeRemote) {
|
||||||
|
// 切换到远程:找第一个 remote profile
|
||||||
|
const remoteProfile = connectionManager.profiles.find(p => p.type === 'remote')
|
||||||
|
if (remoteProfile) {
|
||||||
|
connectionManager.connect(remoteProfile.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 切换到本地
|
||||||
|
connectionManager.disconnect()
|
||||||
|
}
|
||||||
|
await loadCommonPaths()
|
||||||
|
}
|
||||||
|
|
||||||
if (file.isDir) {
|
if (file.isDir) {
|
||||||
await navigate(file.path)
|
await navigate(file.path)
|
||||||
} else {
|
} else {
|
||||||
@@ -1024,6 +1072,9 @@ const selectFile = async (path: string) => {
|
|||||||
|
|
||||||
// 加载文件内容
|
// 加载文件内容
|
||||||
await loadFileContent(path)
|
await loadFileContent(path)
|
||||||
|
|
||||||
|
// 记住上次打开的文件
|
||||||
|
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadFileContent = async (path: string) => {
|
const loadFileContent = async (path: string) => {
|
||||||
@@ -1178,7 +1229,7 @@ const extractZipImageAndPreview = async (zipPath: string, filePath: string): Pro
|
|||||||
const temp = await fileOps.extractZipFileToTemp(zipPath, filePath)
|
const temp = await fileOps.extractZipFileToTemp(zipPath, filePath)
|
||||||
const url = await fileOps.getFileServerURL()
|
const url = await fileOps.getFileServerURL()
|
||||||
const normalized = temp.replace(/\\/g, '/')
|
const normalized = temp.replace(/\\/g, '/')
|
||||||
updatePreviewUrl(`${url}/localfs/${encodeURIComponent(normalized)}`)
|
updatePreviewUrl(normalized)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('提取图片失败:', error)
|
console.error('提取图片失败:', error)
|
||||||
Message.error(`提取图片失败: ${error}`)
|
Message.error(`提取图片失败: ${error}`)
|
||||||
@@ -1217,18 +1268,32 @@ const handleHorizontalResize = createResizeHandler(
|
|||||||
|
|
||||||
// ========== 生命周期 ==========
|
// ========== 生命周期 ==========
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// 加载系统路径
|
// 加载系统路径(阻塞,确保快捷入口就绪)
|
||||||
loadCommonPaths()
|
await loadCommonPaths()
|
||||||
|
|
||||||
// 初始化加载
|
// 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径
|
||||||
if (!filePath.value) {
|
const startPath = connectionManager.isRemote() ? '/'
|
||||||
// 设置默认路径
|
: (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/')
|
||||||
const defaultPath = commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:\\'
|
if (filePath.value && !connectionManager.isRemote()) {
|
||||||
filePath.value = defaultPath
|
await loadDirectory(filePath.value)
|
||||||
loadDirectory(defaultPath)
|
|
||||||
} else {
|
} else {
|
||||||
loadDirectory(filePath.value)
|
filePath.value = startPath
|
||||||
|
await loadDirectory(startPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复上次打开的文件
|
||||||
|
const lastFile = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
|
||||||
|
if (lastFile) {
|
||||||
|
const normalized = lastFile.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
|
const currentDir = filePath.value.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
|
const lastFileDir = normalized.substring(0, normalized.lastIndexOf('/')) || '/'
|
||||||
|
if (lastFileDir.toLowerCase() === currentDir.toLowerCase()) {
|
||||||
|
const found = fileList.value.find(f => f.path.replace(/\\/g, '/').toLowerCase() === normalized.toLowerCase())
|
||||||
|
if (found && !found.isDir) {
|
||||||
|
await selectFile(found.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加键盘快捷键
|
// 添加键盘快捷键
|
||||||
@@ -1497,7 +1562,7 @@ watch(() => themeStore.isDark, async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resizer {
|
.resizer {
|
||||||
width: 4px;
|
width: 3px;
|
||||||
background: var(--color-border);
|
background: var(--color-border);
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
|||||||
44
web/src/stores/connection.ts
Normal file
44
web/src/stores/connection.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 连接状态 Pinia Store
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { connectionManager, type ConnectionProfile, type ConnectionState } from '@/api/connection-manager'
|
||||||
|
|
||||||
|
export const useConnectionStore = defineStore('connection', () => {
|
||||||
|
const state = ref<ConnectionState>(connectionManager.state)
|
||||||
|
const activeProfile = ref<ConnectionProfile | null>(connectionManager.activeProfile)
|
||||||
|
|
||||||
|
connectionManager.onStateChange((s) => { state.value = s })
|
||||||
|
|
||||||
|
const isConnected = computed(() => state.value === 'connected')
|
||||||
|
const isRemote = computed(() => connectionManager.isRemote())
|
||||||
|
const fileServerBaseURL = computed(() => connectionManager.getFileServerBaseURL())
|
||||||
|
|
||||||
|
function connect(id: string) {
|
||||||
|
connectionManager.connect(id)
|
||||||
|
activeProfile.value = connectionManager.activeProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
connectionManager.disconnect()
|
||||||
|
activeProfile.value = connectionManager.activeProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
activeProfile.value = connectionManager.activeProfile
|
||||||
|
state.value = connectionManager.state
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
activeProfile,
|
||||||
|
isConnected,
|
||||||
|
isRemote,
|
||||||
|
fileServerBaseURL,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -30,6 +30,7 @@ export const STORAGE_KEYS = {
|
|||||||
SORT: 'app-filesystem-sort', // 排序状态
|
SORT: 'app-filesystem-sort', // 排序状态
|
||||||
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
||||||
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
||||||
|
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设备测试模块
|
// 设备测试模块
|
||||||
@@ -154,6 +155,7 @@ export const FILE_ICONS = {
|
|||||||
RUBY: '💎',
|
RUBY: '💎',
|
||||||
DART: '🎯',
|
DART: '🎯',
|
||||||
DOCKERFILE: '🐳',
|
DOCKERFILE: '🐳',
|
||||||
|
VUE: '💚',
|
||||||
|
|
||||||
// 数据库
|
// 数据库
|
||||||
DATABASE: '🗄️',
|
DATABASE: '🗄️',
|
||||||
@@ -270,6 +272,8 @@ const initIconMap = () => {
|
|||||||
'dart': FILE_ICONS.DART,
|
'dart': FILE_ICONS.DART,
|
||||||
// Dockerfile
|
// Dockerfile
|
||||||
'dockerfile': FILE_ICONS.DOCKERFILE,
|
'dockerfile': FILE_ICONS.DOCKERFILE,
|
||||||
|
// Vue
|
||||||
|
'vue': FILE_ICONS.VUE,
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))
|
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))
|
||||||
|
|||||||
@@ -100,6 +100,80 @@ renderer.heading = function(token: any) {
|
|||||||
</h${depth}>`
|
</h${depth}>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 图片相对路径转换支持 ==========
|
||||||
|
// 当前 Markdown 文件所在目录(由调用方在渲染前设置)
|
||||||
|
let _currentFileDir: string = ''
|
||||||
|
// 文件服务器 Base URL(由调用方在渲染前设置)
|
||||||
|
let _fileServerBase: string = 'http://localhost:8073/localfs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前 Markdown 文件所在目录(用于图片相对路径→文件服务器 URL 转换)
|
||||||
|
* @param dir 文件所在目录的绝对路径,如 "D:/docs" 或 "/"(根目录)
|
||||||
|
*/
|
||||||
|
export function setCurrentFileDir(dir: string): void {
|
||||||
|
_currentFileDir = dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前设置的文件目录 */
|
||||||
|
export function getCurrentFileDir(): string {
|
||||||
|
return _currentFileDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置文件服务器 Base URL(用于图片相对路径转换)
|
||||||
|
* @param base 完整的 base URL 前缀,如 "http://localhost:8073/localfs" 或 "https://host:port/api/v1/proxy/localfs"
|
||||||
|
*/
|
||||||
|
export function setFileServerBase(base: string): void {
|
||||||
|
_fileServerBase = base
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将相对路径图片 src 解析为文件服务器 URL
|
||||||
|
* - 绝对路径(Windows: D:/...、Unix: /usr/...)、网络URL、data URI → 不转换
|
||||||
|
* - 相对路径 → 基于当前文件目录解析为绝对路径,再编码为文件服务器 URL
|
||||||
|
*/
|
||||||
|
function resolveImageUrl(src: string, fileServerBase: string): string {
|
||||||
|
if (!src) return src
|
||||||
|
// 不转换:绝对路径(Windows 盘符)、网络协议、锚点、data URI
|
||||||
|
if (/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) return src
|
||||||
|
|
||||||
|
// 解析相对路径(处理 ../ 和 ./)
|
||||||
|
const dir = _currentFileDir || '/'
|
||||||
|
const sep = dir.includes('\\') ? '\\' : '/'
|
||||||
|
let resolved = normalizeRelativePath(dir, src, sep)
|
||||||
|
|
||||||
|
// 编码路径(保留 / 分隔符)
|
||||||
|
const encoded = encodeURIComponent(resolved).replace(/%2F/gi, '/').replace(/%5C/gi, '\\')
|
||||||
|
return `${fileServerBase}/${encoded}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化相对路径,处理 .. 和 . 段
|
||||||
|
*/
|
||||||
|
function normalizeRelativePath(base: string, relative: string, sep: string): string {
|
||||||
|
// 确保基础路径不以分隔符结尾
|
||||||
|
let baseNormalized = base.replace(/[\\/]+$/, '')
|
||||||
|
if (!baseNormalized) baseNormalized = sep === '/' ? '/' : 'C:\\'
|
||||||
|
|
||||||
|
const baseParts = baseNormalized.split(sep).filter(Boolean)
|
||||||
|
const relParts = relative.split(/[\\/]/).filter(Boolean)
|
||||||
|
|
||||||
|
for (const part of relParts) {
|
||||||
|
if (part === '..') {
|
||||||
|
baseParts.pop() // 向上一级
|
||||||
|
} else if (part !== '.') {
|
||||||
|
baseParts.push(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重建路径:Windows 绝对路径保留盘符前缀
|
||||||
|
if (/^[a-zA-Z]:$/i.test(baseNormalized.split(sep)[0] || '')) {
|
||||||
|
return baseParts.join(sep)
|
||||||
|
}
|
||||||
|
// Unix 风格:以 / 开头
|
||||||
|
return sep + baseParts.join(sep)
|
||||||
|
}
|
||||||
|
|
||||||
// 判断是否为本地文件链接(相对路径或本地绝对路径)
|
// 判断是否为本地文件链接(相对路径或本地绝对路径)
|
||||||
const isLocalFileLink = (href: string): boolean => {
|
const isLocalFileLink = (href: string): boolean => {
|
||||||
if (!href) return false
|
if (!href) return false
|
||||||
@@ -108,6 +182,23 @@ const isLocalFileLink = (href: string): boolean => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义图片渲染器 - 转换相对路径为文件服务器 URL
|
||||||
|
renderer.image = function(token: any) {
|
||||||
|
const src = token.href || ''
|
||||||
|
const title = token.title || ''
|
||||||
|
const alt = token.text || ''
|
||||||
|
const titleAttr = title ? ` title="${title}"` : ''
|
||||||
|
|
||||||
|
// 判断是否需要转换(仅处理相对路径,且当前目录已设置)
|
||||||
|
if (_currentFileDir && !/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) {
|
||||||
|
const resolvedSrc = resolveImageUrl(src, _fileServerBase)
|
||||||
|
return `<img src="${resolvedSrc}" alt="${alt}"${titleAttr}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认渲染(绝对路径 / 网络 URL / data URI / 未设置目录时原样输出)
|
||||||
|
return `<img src="${src}" alt="${alt}"${titleAttr}>`
|
||||||
|
}
|
||||||
|
|
||||||
// 自定义链接渲染器 - 支持本地文件链接
|
// 自定义链接渲染器 - 支持本地文件链接
|
||||||
renderer.link = function(token: any) {
|
renderer.link = function(token: any) {
|
||||||
const href = token.href || ''
|
const href = token.href || ''
|
||||||
@@ -126,7 +217,7 @@ renderer.link = function(token: any) {
|
|||||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
||||||
}
|
}
|
||||||
|
|
||||||
marked.use({ renderer, breaks: true, gfm: true })
|
marked.use({ renderer, breaks: true, gfm: true, async: false })
|
||||||
|
|
||||||
export { marked }
|
export { marked }
|
||||||
|
|
||||||
|
|||||||
39
web/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
39
web/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
@@ -1,7 +1,6 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
import {filesystem} from '../models';
|
import {filesystem} from '../models';
|
||||||
import {api} from '../models';
|
|
||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
|
|
||||||
export function CheckUpdate():Promise<Record<string, any>>;
|
export function CheckUpdate():Promise<Record<string, any>>;
|
||||||
@@ -12,22 +11,16 @@ export function CreateDir(arg1:string):Promise<filesystem.FileOperationResult>;
|
|||||||
|
|
||||||
export function CreateFile(arg1:string):Promise<filesystem.FileOperationResult>;
|
export function CreateFile(arg1:string):Promise<filesystem.FileOperationResult>;
|
||||||
|
|
||||||
export function DeleteDbConnection(arg1:number):Promise<void>;
|
|
||||||
|
|
||||||
export function DeletePath(arg1:string):Promise<filesystem.FileOperationResult>;
|
export function DeletePath(arg1:string):Promise<filesystem.FileOperationResult>;
|
||||||
|
|
||||||
export function DeletePermanently(arg1:string):Promise<void>;
|
export function DeletePermanently(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DeleteResultHistory(arg1:number):Promise<void>;
|
|
||||||
|
|
||||||
export function DetectFileTypeByContent(arg1:string):Promise<Record<string, any>>;
|
export function DetectFileTypeByContent(arg1:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function DownloadUpdate(arg1:string):Promise<Record<string, any>>;
|
export function DownloadUpdate(arg1:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function EmptyRecycleBin():Promise<void>;
|
export function EmptyRecycleBin():Promise<void>;
|
||||||
|
|
||||||
export function ExecuteSQL(arg1:number,arg2:string,arg3:string):Promise<Record<string, any>>;
|
|
||||||
|
|
||||||
export function ExportPDF(arg1:string,arg2:string,arg3:string,arg4:number,arg5:number,arg6:number):Promise<Record<string, any>>;
|
export function ExportPDF(arg1:string,arg2:string,arg3:string,arg4:number,arg5:number,arg6:number):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function ExtractFileFromZip(arg1:string,arg2:string):Promise<string>;
|
export function ExtractFileFromZip(arg1:string,arg2:string):Promise<string>;
|
||||||
@@ -44,8 +37,6 @@ export function GetCommonPaths():Promise<Record<string, string>>;
|
|||||||
|
|
||||||
export function GetCurrentVersion():Promise<Record<string, any>>;
|
export function GetCurrentVersion():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetDatabases(arg1:number):Promise<Array<string>>;
|
|
||||||
|
|
||||||
export function GetDiskInfo():Promise<Array<Record<string, any>>>;
|
export function GetDiskInfo():Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
export function GetEnvVars():Promise<Record<string, string>>;
|
export function GetEnvVars():Promise<Record<string, string>>;
|
||||||
@@ -54,22 +45,12 @@ export function GetFileInfo(arg1:string):Promise<Record<string, any>>;
|
|||||||
|
|
||||||
export function GetFileServerURL():Promise<string>;
|
export function GetFileServerURL():Promise<string>;
|
||||||
|
|
||||||
export function GetIndexes(arg1:number,arg2:string,arg3:string):Promise<Array<Record<string, any>>>;
|
|
||||||
|
|
||||||
export function GetMemoryInfo():Promise<Record<string, any>>;
|
export function GetMemoryInfo():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetRecycleBinEntries():Promise<Array<Record<string, any>>>;
|
export function GetRecycleBinEntries():Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
export function GetResultHistory(arg1:any,arg2:string,arg3:number,arg4:number):Promise<Record<string, any>>;
|
|
||||||
|
|
||||||
export function GetResultHistoryByID(arg1:number):Promise<Record<string, any>>;
|
|
||||||
|
|
||||||
export function GetSystemInfo():Promise<Record<string, any>>;
|
export function GetSystemInfo():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetTableStructure(arg1:number,arg2:string,arg3:string):Promise<Record<string, any>>;
|
|
||||||
|
|
||||||
export function GetTables(arg1:number,arg2:string):Promise<Array<string>>;
|
|
||||||
|
|
||||||
export function GetUpdateConfig():Promise<Record<string, any>>;
|
export function GetUpdateConfig():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetZipFileInfo(arg1:string,arg2:string):Promise<Record<string, any>>;
|
export function GetZipFileInfo(arg1:string,arg2:string):Promise<Record<string, any>>;
|
||||||
@@ -78,20 +59,12 @@ export function InstallUpdate(arg1:string,arg2:boolean):Promise<Record<string, a
|
|||||||
|
|
||||||
export function InstallUpdateWithHash(arg1:string,arg2:boolean,arg3:string,arg4:string):Promise<Record<string, any>>;
|
export function InstallUpdateWithHash(arg1:string,arg2:boolean,arg3:string,arg4:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function ListDbConnections():Promise<Array<Record<string, any>>>;
|
|
||||||
|
|
||||||
export function ListDir(arg1:string):Promise<Array<Record<string, any>>>;
|
export function ListDir(arg1:string):Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
export function ListSqlTabs():Promise<Array<Record<string, any>>>;
|
|
||||||
|
|
||||||
export function ListZipContents(arg1:string):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 OpenPath(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function PreviewTableStructure(arg1:number,arg2:string,arg3:string,arg4:Record<string, any>):Promise<Array<string>>;
|
|
||||||
|
|
||||||
export function ReadFile(arg1:string):Promise<string>;
|
export function ReadFile(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function Reload():Promise<void>;
|
export function Reload():Promise<void>;
|
||||||
@@ -106,22 +79,10 @@ export function SaveAppConfig(arg1:main.SaveAppConfigRequest):Promise<Record<str
|
|||||||
|
|
||||||
export function SaveBase64File(arg1:main.SaveBase64FileRequest):Promise<void>;
|
export function SaveBase64File(arg1:main.SaveBase64FileRequest):Promise<void>;
|
||||||
|
|
||||||
export function SaveDbConnection(arg1:api.SaveConnectionRequest):Promise<void>;
|
|
||||||
|
|
||||||
export function SaveResult(arg1:number,arg2:string,arg3:string,arg4:string,arg5:any,arg6:Array<string>,arg7:number,arg8:number):Promise<Record<string, any>>;
|
|
||||||
|
|
||||||
export function SaveSqlTabs(arg1:Array<Record<string, any>>):Promise<void>;
|
|
||||||
|
|
||||||
export function SelectPDFSaveDirectory():Promise<string>;
|
export function SelectPDFSaveDirectory():Promise<string>;
|
||||||
|
|
||||||
export function SetUpdateConfig(arg1:boolean,arg2:number,arg3:string):Promise<Record<string, any>>;
|
export function SetUpdateConfig(arg1:boolean,arg2:number,arg3:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function TestDbConnection(arg1:number):Promise<void>;
|
|
||||||
|
|
||||||
export function TestDbConnectionWithParams(arg1:api.TestConnectionRequest):Promise<void>;
|
|
||||||
|
|
||||||
export function UpdateTableStructure(arg1:number,arg2:string,arg3:string,arg4:Record<string, any>):Promise<Array<string>>;
|
|
||||||
|
|
||||||
export function VerifyUpdateFile(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
|
export function VerifyUpdateFile(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function WindowClose():Promise<void>;
|
export function WindowClose():Promise<void>;
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ export function CreateFile(arg1) {
|
|||||||
return window['go']['main']['App']['CreateFile'](arg1);
|
return window['go']['main']['App']['CreateFile'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteDbConnection(arg1) {
|
|
||||||
return window['go']['main']['App']['DeleteDbConnection'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeletePath(arg1) {
|
export function DeletePath(arg1) {
|
||||||
return window['go']['main']['App']['DeletePath'](arg1);
|
return window['go']['main']['App']['DeletePath'](arg1);
|
||||||
}
|
}
|
||||||
@@ -30,10 +26,6 @@ export function DeletePermanently(arg1) {
|
|||||||
return window['go']['main']['App']['DeletePermanently'](arg1);
|
return window['go']['main']['App']['DeletePermanently'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteResultHistory(arg1) {
|
|
||||||
return window['go']['main']['App']['DeleteResultHistory'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DetectFileTypeByContent(arg1) {
|
export function DetectFileTypeByContent(arg1) {
|
||||||
return window['go']['main']['App']['DetectFileTypeByContent'](arg1);
|
return window['go']['main']['App']['DetectFileTypeByContent'](arg1);
|
||||||
}
|
}
|
||||||
@@ -46,10 +38,6 @@ export function EmptyRecycleBin() {
|
|||||||
return window['go']['main']['App']['EmptyRecycleBin']();
|
return window['go']['main']['App']['EmptyRecycleBin']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExecuteSQL(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['ExecuteSQL'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExportPDF(arg1, arg2, arg3, arg4, arg5, arg6) {
|
export function ExportPDF(arg1, arg2, arg3, arg4, arg5, arg6) {
|
||||||
return window['go']['main']['App']['ExportPDF'](arg1, arg2, arg3, arg4, arg5, arg6);
|
return window['go']['main']['App']['ExportPDF'](arg1, arg2, arg3, arg4, arg5, arg6);
|
||||||
}
|
}
|
||||||
@@ -82,10 +70,6 @@ export function GetCurrentVersion() {
|
|||||||
return window['go']['main']['App']['GetCurrentVersion']();
|
return window['go']['main']['App']['GetCurrentVersion']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetDatabases(arg1) {
|
|
||||||
return window['go']['main']['App']['GetDatabases'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetDiskInfo() {
|
export function GetDiskInfo() {
|
||||||
return window['go']['main']['App']['GetDiskInfo']();
|
return window['go']['main']['App']['GetDiskInfo']();
|
||||||
}
|
}
|
||||||
@@ -102,10 +86,6 @@ export function GetFileServerURL() {
|
|||||||
return window['go']['main']['App']['GetFileServerURL']();
|
return window['go']['main']['App']['GetFileServerURL']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetIndexes(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['GetIndexes'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetMemoryInfo() {
|
export function GetMemoryInfo() {
|
||||||
return window['go']['main']['App']['GetMemoryInfo']();
|
return window['go']['main']['App']['GetMemoryInfo']();
|
||||||
}
|
}
|
||||||
@@ -114,26 +94,10 @@ export function GetRecycleBinEntries() {
|
|||||||
return window['go']['main']['App']['GetRecycleBinEntries']();
|
return window['go']['main']['App']['GetRecycleBinEntries']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetResultHistory(arg1, arg2, arg3, arg4) {
|
|
||||||
return window['go']['main']['App']['GetResultHistory'](arg1, arg2, arg3, arg4);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetResultHistoryByID(arg1) {
|
|
||||||
return window['go']['main']['App']['GetResultHistoryByID'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetSystemInfo() {
|
export function GetSystemInfo() {
|
||||||
return window['go']['main']['App']['GetSystemInfo']();
|
return window['go']['main']['App']['GetSystemInfo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetTableStructure(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['GetTableStructure'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetTables(arg1, arg2) {
|
|
||||||
return window['go']['main']['App']['GetTables'](arg1, arg2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetUpdateConfig() {
|
export function GetUpdateConfig() {
|
||||||
return window['go']['main']['App']['GetUpdateConfig']();
|
return window['go']['main']['App']['GetUpdateConfig']();
|
||||||
}
|
}
|
||||||
@@ -150,34 +114,18 @@ export function InstallUpdateWithHash(arg1, arg2, arg3, arg4) {
|
|||||||
return window['go']['main']['App']['InstallUpdateWithHash'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['InstallUpdateWithHash'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListDbConnections() {
|
|
||||||
return window['go']['main']['App']['ListDbConnections']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ListDir(arg1) {
|
export function ListDir(arg1) {
|
||||||
return window['go']['main']['App']['ListDir'](arg1);
|
return window['go']['main']['App']['ListDir'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListSqlTabs() {
|
|
||||||
return window['go']['main']['App']['ListSqlTabs']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ListZipContents(arg1) {
|
export function ListZipContents(arg1) {
|
||||||
return window['go']['main']['App']['ListZipContents'](arg1);
|
return window['go']['main']['App']['ListZipContents'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadAllDatabases(arg1) {
|
|
||||||
return window['go']['main']['App']['LoadAllDatabases'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenPath(arg1) {
|
export function OpenPath(arg1) {
|
||||||
return window['go']['main']['App']['OpenPath'](arg1);
|
return window['go']['main']['App']['OpenPath'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PreviewTableStructure(arg1, arg2, arg3, arg4) {
|
|
||||||
return window['go']['main']['App']['PreviewTableStructure'](arg1, arg2, arg3, arg4);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReadFile(arg1) {
|
export function ReadFile(arg1) {
|
||||||
return window['go']['main']['App']['ReadFile'](arg1);
|
return window['go']['main']['App']['ReadFile'](arg1);
|
||||||
}
|
}
|
||||||
@@ -206,18 +154,6 @@ export function SaveBase64File(arg1) {
|
|||||||
return window['go']['main']['App']['SaveBase64File'](arg1);
|
return window['go']['main']['App']['SaveBase64File'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaveDbConnection(arg1) {
|
|
||||||
return window['go']['main']['App']['SaveDbConnection'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SaveResult(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) {
|
|
||||||
return window['go']['main']['App']['SaveResult'](arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SaveSqlTabs(arg1) {
|
|
||||||
return window['go']['main']['App']['SaveSqlTabs'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectPDFSaveDirectory() {
|
export function SelectPDFSaveDirectory() {
|
||||||
return window['go']['main']['App']['SelectPDFSaveDirectory']();
|
return window['go']['main']['App']['SelectPDFSaveDirectory']();
|
||||||
}
|
}
|
||||||
@@ -226,18 +162,6 @@ export function SetUpdateConfig(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['SetUpdateConfig'](arg1, arg2, arg3);
|
return window['go']['main']['App']['SetUpdateConfig'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestDbConnection(arg1) {
|
|
||||||
return window['go']['main']['App']['TestDbConnection'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TestDbConnectionWithParams(arg1) {
|
|
||||||
return window['go']['main']['App']['TestDbConnectionWithParams'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UpdateTableStructure(arg1, arg2, arg3, arg4) {
|
|
||||||
return window['go']['main']['App']['UpdateTableStructure'](arg1, arg2, arg3, arg4);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VerifyUpdateFile(arg1, arg2, arg3) {
|
export function VerifyUpdateFile(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['VerifyUpdateFile'](arg1, arg2, arg3);
|
return window['go']['main']['App']['VerifyUpdateFile'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,88 +18,6 @@ export namespace api {
|
|||||||
this.enabled = source["enabled"];
|
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;
|
|
||||||
type: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
database: string;
|
|
||||||
options: string;
|
|
||||||
visible_databases: string;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
|
||||||
return new SaveConnectionRequest(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
|
||||||
this.id = source["id"];
|
|
||||||
this.name = source["name"];
|
|
||||||
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"];
|
|
||||||
this.visible_databases = source["visible_databases"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export class TestConnectionRequest {
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
database: string;
|
|
||||||
options: string;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
|
||||||
return new TestConnectionRequest(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"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user