优化:工具栏高度对齐+面板统一+远程连接架构+自动恢复预览
- 工具栏:面包屑与右侧组件像素级等高(: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/chromedp v0.14.2
|
||||
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/wailsapp/wails/v2 v2.12.0
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
golang.org/x/sys v0.40.0
|
||||
gopkg.in/yaml.v3 v3.0.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/jinzhu/inflection v1.0.0 // 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/leaanthony/go-ansi-parser v1.6.1 // 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/net v0.49.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/mathutil v1.7.1 // 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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 确保绝对路径(Linux 以 / 开头,Windows 以盘符开头)
|
||||
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' {
|
||||
filePath = "/" + filePath
|
||||
}
|
||||
|
||||
if !isSafePath(filePath) {
|
||||
return "", ErrPathUnsafe
|
||||
}
|
||||
@@ -153,8 +166,20 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path)
|
||||
|
||||
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
|
||||
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
|
||||
// 从 URL 路径获取文件路径(移除所有 /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 {
|
||||
log.Printf("[LocalFileHandler] 路径前缀无效")
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package filesystem
|
||||
|
||||
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 { debugError } from '@/utils/debugLog'
|
||||
import type { File } from './types'
|
||||
import { connectionManager } from './connection-manager'
|
||||
|
||||
/**
|
||||
* 转换后端文件数据格式(蛇形 → 驼峰)
|
||||
* 后端返回 is_dir,前端使用 isDir
|
||||
*/
|
||||
function transformFile(file: any): File {
|
||||
return {
|
||||
...file,
|
||||
isDir: file.is_dir,
|
||||
modified_time: file.mod_time
|
||||
}
|
||||
return { ...file, isDir: file.is_dir, modified_time: file.mod_time }
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量转换文件列表
|
||||
*/
|
||||
function transformFileList(files: any[]): File[] {
|
||||
return files.map(transformFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统信息
|
||||
*/
|
||||
export async function getSystemInfo(): Promise<SystemInfo> {
|
||||
if (!window.go?.main?.App?.GetSystemInfo) {
|
||||
throw new Error('GetSystemInfo API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetSystemInfo()
|
||||
const t = () => connectionManager.getTransport()
|
||||
|
||||
export async function getSystemInfo() { return t().getFileInfo('/') }
|
||||
|
||||
export async function getCPUInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CPU 信息
|
||||
*/
|
||||
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 getMemoryInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } 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() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取磁盘信息
|
||||
*/
|
||||
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[]> {
|
||||
if (!window.go?.main?.App?.ListDir) {
|
||||
throw new Error('ListDir API 不可用')
|
||||
}
|
||||
|
||||
const files = await window.go.main.App.ListDir(path)
|
||||
return transformFileList(files)
|
||||
return transformFileList(await t().listDir(path))
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件
|
||||
*/
|
||||
export async function readFile(path: string): Promise<string> {
|
||||
if (!window.go?.main?.App?.ReadFile) {
|
||||
throw new Error('ReadFile API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ReadFile(path)
|
||||
return t().readFile(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文件
|
||||
*/
|
||||
export async function writeFile(path: string, content: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.WriteFile) {
|
||||
throw new Error('WriteFile API 不可用')
|
||||
}
|
||||
// 确保传递的是字符串类型
|
||||
await window.go.main.App.WriteFile({
|
||||
path: String(path),
|
||||
content: String(content)
|
||||
})
|
||||
await t().writeFile(path, String(content))
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Base64 编码的二进制文件(图片等)
|
||||
*/
|
||||
export async function saveBase64File(path: string, base64Content: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.SaveBase64File) {
|
||||
throw new Error('SaveBase64File API 不可用')
|
||||
}
|
||||
if (!base64Content) {
|
||||
throw new Error('无效的 base64 内容')
|
||||
}
|
||||
await window.go.main.App.SaveBase64File({
|
||||
path: String(path),
|
||||
content: base64Content
|
||||
})
|
||||
if (!base64Content) throw new Error('无效的 base64 内容')
|
||||
await t().saveBase64File(path, base64Content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件或目录
|
||||
*/
|
||||
export async function deletePath(path: string): Promise<any> {
|
||||
if (!window.go?.main?.App?.DeletePath) {
|
||||
throw new Error('DeletePath API 不可用')
|
||||
}
|
||||
return await window.go.main.App.DeletePath(path)
|
||||
return t().deletePath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建目录(parentPath + dirname 拼接为完整路径)
|
||||
*/
|
||||
export async function createDir(parentPath: string, dirname: string): Promise<any> {
|
||||
if (!window.go?.main?.App?.CreateDir) {
|
||||
throw new Error('CreateDir API 不可用')
|
||||
}
|
||||
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
||||
return await window.go.main.App.CreateDir(fullPath)
|
||||
return t().createDir(parentPath, dirname)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件(dirPath + filename 拼接为完整路径)
|
||||
*/
|
||||
export async function createFile(dirPath: string, filename: string): Promise<any> {
|
||||
if (!window.go?.main?.App?.CreateFile) {
|
||||
throw new Error('CreateFile API 不可用')
|
||||
}
|
||||
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
||||
return await window.go.main.App.CreateFile(fullPath)
|
||||
return t().createFile(dirPath, filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件或目录
|
||||
*/
|
||||
export async function renamePath(oldPath: string, newPath: string): Promise<any> {
|
||||
if (!window.go?.main?.App?.RenamePath) {
|
||||
throw new Error('RenamePath API 不可用')
|
||||
}
|
||||
return await window.go.main.App.RenamePath({
|
||||
oldPath: String(oldPath),
|
||||
newPath: String(newPath)
|
||||
})
|
||||
return t().renamePath(oldPath, String(newPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取环境变量
|
||||
*/
|
||||
export async function getEnvVars(): Promise<Record<string, string>> {
|
||||
if (!window.go?.main?.App?.GetEnvVars) {
|
||||
throw new Error('GetEnvVars API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetEnvVars()
|
||||
try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 zip 文件内容
|
||||
*/
|
||||
export async function listZipContents(zipPath: string): Promise<File[]> {
|
||||
if (!window.go?.main?.App?.ListZipContents) {
|
||||
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
|
||||
}
|
||||
return transformFileList(await t().listZipContents(zipPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 zip 文件中提取单个文件内容
|
||||
*/
|
||||
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
||||
if (!window.go?.main?.App?.ExtractFileFromZip) {
|
||||
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
|
||||
}
|
||||
return t().extractFileFromZip(zipPath, filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 zip 文件中提取单个文件到临时目录
|
||||
* 返回临时文件的完整路径,适用于图片等二进制文件
|
||||
*/
|
||||
export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
|
||||
if (!window.go?.main?.App?.ExtractFileFromZipToTemp) {
|
||||
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
|
||||
}
|
||||
return t().extractFileFromZipToTemp(zipPath, filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 zip 文件中特定文件的信息
|
||||
*/
|
||||
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
|
||||
if (!window.go?.main?.App?.GetZipFileInfo) {
|
||||
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
|
||||
}
|
||||
return transformFile(await t().getZipFileInfo(zipPath, filePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用系统默认程序打开文件或目录
|
||||
*/
|
||||
export async function openPath(path: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.OpenPath) {
|
||||
throw new Error('OpenPath API 不可用')
|
||||
}
|
||||
try {
|
||||
await window.go.main.App.OpenPath(path)
|
||||
} catch (error) {
|
||||
debugError('[API] openPath 错误:', error)
|
||||
throw error
|
||||
}
|
||||
await t().openPath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地文件服务器URL
|
||||
*/
|
||||
export async function getFileServerURL(): Promise<string> {
|
||||
if (!window.go?.main?.App?.GetFileServerURL) {
|
||||
throw new Error('GetFileServerURL API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetFileServerURL()
|
||||
return t().getFileServerURL()
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析快捷方式文件,返回目标路径信息
|
||||
*/
|
||||
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 resolveShortcut(lnkPath: string): Promise<any> {
|
||||
return t().resolveShortcut(lnkPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过文件内容检测文件类型(用于小文件,500KB以内)
|
||||
*/
|
||||
export async function detectFileTypeByContent(path: string): Promise<{
|
||||
extension: string
|
||||
category: string // 'image' | 'text' | 'binary' | 'pdf' | 'archive' | 'unknown'
|
||||
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
|
||||
}
|
||||
export async function detectFileTypeByContent(path: string) {
|
||||
return t().detectFileTypeByContent(path)
|
||||
}
|
||||
|
||||
export async function getCommonPaths() {
|
||||
return t().getCommonPaths()
|
||||
}
|
||||
|
||||
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>
|
||||
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
|
||||
<!-- 有选中文件时显示表头和内容 -->
|
||||
<template v-if="config.currentFileName">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<template v-if="config.isImageView">🖼️ 图片预览</template>
|
||||
@@ -72,7 +74,8 @@
|
||||
|
||||
<!-- 视频预览 -->
|
||||
<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">
|
||||
<a-tag color="arcoblue">🎬 视频</a-tag>
|
||||
</div>
|
||||
@@ -80,7 +83,8 @@
|
||||
|
||||
<!-- 音频预览 -->
|
||||
<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">
|
||||
<a-tag color="green">🎵 音频</a-tag>
|
||||
</div>
|
||||
@@ -88,7 +92,8 @@
|
||||
|
||||
<!-- 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">
|
||||
<a-tag color="orangered">📕 PDF</a-tag>
|
||||
</div>
|
||||
@@ -354,6 +359,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -367,6 +373,7 @@ import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
|
||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||
const AsyncCodeEditor = defineAsyncComponent({
|
||||
@@ -432,13 +439,27 @@ interface 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(() => {
|
||||
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) {
|
||||
return ''
|
||||
}
|
||||
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) return ''
|
||||
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')
|
||||
}
|
||||
|
||||
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 公共函数
|
||||
const openPrintWindow = (title: string, bodyHtml: string, extraStyle = '') => {
|
||||
const printWindow = window.open('', '_blank')
|
||||
@@ -650,7 +695,7 @@ const loadExcelPreview = async (filePath: string) => {
|
||||
|
||||
// 直接从本地文件服务器获取(不走 base64)
|
||||
const fileUrl = props.config.previewUrl
|
||||
const response = await fetch(fileUrl)
|
||||
const response = await authFetch(fileUrl)
|
||||
const blob = await response.blob()
|
||||
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>'
|
||||
|
||||
const fileUrl = props.config.previewUrl
|
||||
const response = await fetch(fileUrl)
|
||||
const response = await authFetch(fileUrl)
|
||||
const blob = await response.blob()
|
||||
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
|
||||
? 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 result = await previewCsv(file, csvPreviewRef.value)
|
||||
@@ -786,8 +831,8 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
|
||||
const allowedOrigins = [
|
||||
window.location.origin,
|
||||
'null', // about:blank 或 data: URL
|
||||
'http://localhost:8073', // 本地文件服务器
|
||||
'null',
|
||||
resolveHtmlPreviewBase(), // 动态:本地 localhost:8073 或远程代理地址
|
||||
]
|
||||
if (!allowedOrigins.includes(event.origin)) {
|
||||
return
|
||||
@@ -835,11 +880,9 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-fill-1);
|
||||
padding: 3px 12px;
|
||||
background: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -944,6 +987,13 @@ onUnmounted(() => {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.media-error {
|
||||
color: var(--color-danger-6);
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">📋 文件列表</span>
|
||||
<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-button size="mini" type="text" class="settings-btn">
|
||||
<icon-more />
|
||||
@@ -50,21 +49,20 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="file-list-wrapper"
|
||||
class="file-list-wrapper thin-dark-scrollbar"
|
||||
@contextmenu.prevent="handleWrapperContextMenu"
|
||||
>
|
||||
<!-- 文件列表(a-table) -->
|
||||
<!-- 文件列表(滚动区域) -->
|
||||
<a-table
|
||||
v-if="config.fileList.length > 0 || config.fileLoading"
|
||||
:columns="tableColumns"
|
||||
:data="config.fileList"
|
||||
:data="pagedFileList"
|
||||
:loading="config.fileLoading"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
:show-header="showHeader"
|
||||
size="mini"
|
||||
:row-class-name="getRowClassName"
|
||||
:scroll="{ y: 'auto' }"
|
||||
class="file-table"
|
||||
@row-click="handleRowClick"
|
||||
@row-contextmenu="handleRowContextMenu"
|
||||
@@ -76,13 +74,27 @@
|
||||
<span>此文件夹为空</span>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<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 { 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 { STORAGE_KEYS } from '@/utils/constants'
|
||||
import type { FileListPanelConfig, FileItem } from '@/types/file-system'
|
||||
@@ -159,8 +171,8 @@ function loadColSettings(): ColumnConfig[] {
|
||||
}
|
||||
|
||||
const colSettings = ref<ColumnConfig[]>(loadColSettings())
|
||||
// 默认显示表头(localStorage 无值时兼容旧行为)
|
||||
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) !== 'false')
|
||||
// 默认隐藏表头(localStorage 无值时默认不显示)
|
||||
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) === 'true')
|
||||
|
||||
// 手动持久化(避免 deep watch 频繁写入)
|
||||
function saveColSettings() {
|
||||
@@ -332,6 +344,26 @@ const tableColumns = computed(() => {
|
||||
.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 target = ev.target as HTMLElement
|
||||
@@ -372,12 +404,13 @@ defineExpose({ focusEditingItem })
|
||||
.file-list-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex: 1; /* 父级是 flex 容器,用 flex:1 而非 height:100% */
|
||||
min-height: 0; /* 允许收缩到小于内容高度 */
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 6px 12px;
|
||||
padding: 3px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-2);
|
||||
flex-shrink: 0;
|
||||
@@ -422,15 +455,20 @@ defineExpose({ focusEditingItem })
|
||||
color: rgb(var(--primary-6));
|
||||
}
|
||||
|
||||
/* 滚动容器 */
|
||||
/* 滚动容器(table + 分页 的统一滚动层) */
|
||||
.file-list-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* ====== Table 全局覆盖 ====== */
|
||||
/* ====== Table ====== */
|
||||
.file-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.file-table :deep(.arco-table) {
|
||||
font-size: 13px;
|
||||
table-layout: fixed;
|
||||
@@ -564,4 +602,35 @@ defineExpose({ focusEditingItem })
|
||||
gap: 8px;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- 路径段 -->
|
||||
<div
|
||||
class="breadcrumb-segment"
|
||||
:class="{ 'is-hoverable': index < segments.length - 1 }"
|
||||
:class="{ 'is-hoverable': index < segments.length - 1 || segments.length === 1 }"
|
||||
@mouseenter="onHover(segment, index)"
|
||||
@mouseleave="onLeave"
|
||||
@click="onClick(segment)"
|
||||
@@ -152,7 +152,8 @@ const resetAndClose = () => {
|
||||
}
|
||||
|
||||
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 (closeTimer.value) clearTimeout(closeTimer.value)
|
||||
@@ -209,7 +210,7 @@ watch(() => props.path, () => {
|
||||
.breadcrumb-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -242,7 +243,7 @@ watch(() => props.path, () => {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin: 0 2px;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
/* 弹出菜单 */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div v-show="config.visible" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">⭐ 收藏夹</span>
|
||||
<span class="sidebar-count">{{ config.favoriteFiles.length }}</span>
|
||||
<span class="sidebar-count">共{{ config.favoriteFiles.length }}项</span>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div
|
||||
@@ -154,7 +154,7 @@ const handleDragEnd = () => {
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px 16px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -163,17 +163,14 @@ const handleDragEnd = () => {
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.sidebar-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-fill-2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
📦 {{ config.zipFileName }}
|
||||
</a-tag>
|
||||
<template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0">
|
||||
<icon-right class="breadcrumb-separator" />
|
||||
<icon-right class="breadcrumb-sep" />
|
||||
<a-tag
|
||||
v-for="(crumb, index) in config.zipBreadcrumbs"
|
||||
:key="index"
|
||||
@@ -25,41 +25,49 @@
|
||||
退出 ZIP
|
||||
</a-button>
|
||||
</div>
|
||||
<!-- 正常模式:面包屑导航 -->
|
||||
<!-- 正常模式:连接指示器 + 面包屑导航(融合布局) -->
|
||||
<div v-else class="path-breadcrumb-wrapper">
|
||||
<!-- 快捷访问(仅图标,面包屑前) -->
|
||||
<a-dropdown>
|
||||
<a-button size="mini" type="text">
|
||||
<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>
|
||||
<!-- 连接指示器(紧凑标签样式,作为面包屑首段) -->
|
||||
<ConnectionIndicator @add="showConnectionDialog = true" @select="onConnectionChanged" @edit="onEditProfile" />
|
||||
<span class="breadcrumb-sep">›</span>
|
||||
<!-- 路径面包屑 -->
|
||||
<PathBreadcrumb
|
||||
:path="config.filePath"
|
||||
@navigate="handleGoToPath"
|
||||
@openFile="handleOpenFile"
|
||||
/>
|
||||
<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 class="breadcrumb-right-actions">
|
||||
<a-tooltip content="快捷路径" position="bottom">
|
||||
<a-dropdown>
|
||||
<a-button size="mini" type="text" class="shortcut-btn">
|
||||
<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>
|
||||
</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>
|
||||
@@ -73,7 +81,7 @@
|
||||
class="toolbar-search"
|
||||
allow-clear
|
||||
@search="handleSearch"
|
||||
@update:model-value="handleSearchInput"
|
||||
@update:model-value="handleSearch"
|
||||
@keyup.escape="handleClearSearch"
|
||||
/>
|
||||
|
||||
@@ -124,13 +132,18 @@
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionDialog ref="connectionDialogRef" v-model:visible="showConnectionDialog" />
|
||||
</template>
|
||||
|
||||
<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 type { ToolbarConfig } from '@/types/file-system'
|
||||
import PathBreadcrumb from './PathBreadcrumb.vue'
|
||||
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||
import ConnectionIndicator from './ConnectionIndicator.vue'
|
||||
import ConnectionDialog from './ConnectionDialog.vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
@@ -149,10 +162,24 @@ interface Emits {
|
||||
(e: 'goToPath', path: string): void
|
||||
(e: 'openFile', path: string): void
|
||||
(e: 'navigateToZipDirectory', path: string): void
|
||||
(e: 'connectionChanged'): void
|
||||
}
|
||||
|
||||
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 调用)
|
||||
const historyPopupVisible = ref(false)
|
||||
|
||||
@@ -177,10 +204,6 @@ const handleNavigateToZipRoot = () => {
|
||||
emit('navigateToZipDirectory', '')
|
||||
}
|
||||
|
||||
const handleNavigateToZipDirectory = (path: string) => {
|
||||
emit('navigateToZipDirectory', path)
|
||||
}
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
emit('update:showSidebar', !props.config.showSidebar)
|
||||
}
|
||||
@@ -189,10 +212,6 @@ const handleSearch = (keyword: string) => {
|
||||
emit('update:searchKeyword', keyword)
|
||||
}
|
||||
|
||||
const handleSearchInput = (keyword: string) => {
|
||||
emit('update:searchKeyword', keyword)
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
emit('update:searchKeyword', '')
|
||||
}
|
||||
@@ -237,6 +256,11 @@ const handleCopyPath = async () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-right :deep(.arco-btn-size-small),
|
||||
.toolbar-right :deep(.arco-input-wrapper) {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
@@ -247,27 +271,44 @@ const handleCopyPath = async () => {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.path-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.path-breadcrumb-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: border-color 0.2s;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.path-breadcrumb-wrapper:hover {
|
||||
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 {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
@@ -293,12 +334,6 @@ const handleCopyPath = async () => {
|
||||
border-color: rgb(var(--primary-6));
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-tag {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
@@ -312,15 +347,6 @@ const handleCopyPath = async () => {
|
||||
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 {
|
||||
max-width: 420px;
|
||||
|
||||
@@ -5,90 +5,56 @@
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { PATH_ICONS } from '@/utils/constants'
|
||||
import { getCommonPaths } from '@/api/system'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import type { ShortcutPath } from '@/types/file-system'
|
||||
|
||||
export function useCommonPaths() {
|
||||
// 系统路径
|
||||
const commonPaths = ref<ShortcutPath[]>([])
|
||||
const systemPaths = ref<Record<string, string>>({})
|
||||
|
||||
/**
|
||||
* 加载常用系统路径
|
||||
*/
|
||||
const loadCommonPaths = async () => {
|
||||
try {
|
||||
// 检查 Wails API 是否可用
|
||||
if (!window.go?.main?.App?.GetCommonPaths) {
|
||||
// 降级方案:使用默认路径
|
||||
commonPaths.value = [
|
||||
{ name: '💿 C盘', path: 'C:\\' },
|
||||
{ name: '💿 D盘', path: 'D:\\' }
|
||||
]
|
||||
return
|
||||
}
|
||||
|
||||
const paths = await window.go.main.App.GetCommonPaths()
|
||||
if (!paths) {
|
||||
throw new Error('无法获取系统路径')
|
||||
}
|
||||
const paths = await getCommonPaths()
|
||||
if (!paths) throw new Error('无法获取系统路径')
|
||||
|
||||
systemPaths.value = paths
|
||||
const platform = window.navigator.platform
|
||||
const pathList: ShortcutPath[] = []
|
||||
// 根据返回数据判断平台(Linux agent 返回 root key,Windows 返回 root_ 前缀)
|
||||
const isWin = !!Object.keys(paths).find(k => k.startsWith('root_'))
|
||||
|
||||
if (platform.includes('Win')) {
|
||||
// Windows: 先添加基础路径,再添加所有盘符
|
||||
if (isWin) {
|
||||
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 })
|
||||
|
||||
// 动态添加所有盘符(按字母顺序)
|
||||
const drives: Array<{ letter: string; path: string }> = []
|
||||
for (const key in paths) {
|
||||
if (key.startsWith('root_')) {
|
||||
const driveLetter = key.substring(5)
|
||||
drives.push({
|
||||
letter: driveLetter,
|
||||
path: paths[key]
|
||||
})
|
||||
drives.push({ letter: key.substring(5), path: paths[key] })
|
||||
}
|
||||
}
|
||||
drives.sort((a, b) => a.letter.localeCompare(b.letter))
|
||||
|
||||
// 添加盘符到路径列表
|
||||
drives.forEach(drive => {
|
||||
pathList.push({
|
||||
name: `${PATH_ICONS.DRIVE} ${drive.letter}盘`,
|
||||
path: drive.path
|
||||
})
|
||||
})
|
||||
drives.forEach(d => pathList.push({ name: `${PATH_ICONS.DRIVE} ${d.letter}盘`, path: d.path }))
|
||||
} else {
|
||||
// macOS/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 })
|
||||
// Linux 远程模式
|
||||
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 : [
|
||||
{ name: '💿 C盘', path: 'C:\\' },
|
||||
{ name: '💿 D盘', path: 'D:\\' }
|
||||
]
|
||||
commonPaths.value = pathList.length > 0 ? pathList : (
|
||||
connectionManager.isRemote()
|
||||
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('加载系统路径失败:', error)
|
||||
// 降级方案
|
||||
commonPaths.value = [
|
||||
{ name: '💿 C盘', path: 'C:\\' },
|
||||
{ name: '💿 D盘', path: 'D:\\' }
|
||||
]
|
||||
commonPaths.value = connectionManager.isRemote()
|
||||
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
commonPaths,
|
||||
systemPaths,
|
||||
loadCommonPaths
|
||||
}
|
||||
return { commonPaths, systemPaths, loadCommonPaths }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ref } from 'vue'
|
||||
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { normalizeFilePath, getExt } from '@/utils/fileUtils'
|
||||
import { detectFileTypeByContent } from '@/api/system'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import {
|
||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
||||
@@ -26,21 +27,22 @@ export interface UseFilePreviewOptions {
|
||||
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 = {}) {
|
||||
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
|
||||
const previewUrl = ref('')
|
||||
|
||||
@@ -49,12 +51,19 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const currentImageDimensions = ref('')
|
||||
|
||||
/**
|
||||
* 获取预览 URL(与旧版本保持一致)
|
||||
* 获取预览 URL(本地/远程自适应,每次实时计算)
|
||||
* 本地: http://localhost:8073/localfs/{encoded_path}
|
||||
* 远程: {baseUrl}/api/v1/proxy/localfs/{raw_path}(Cookie 自动携带认证)
|
||||
*/
|
||||
const getPreviewUrl = (path: string): string => {
|
||||
if (!path) return ''
|
||||
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
|
||||
return `${getFileServerURL()}/localfs/${normalizeFilePath(path, true)}`
|
||||
const isRemote = connectionManager.isRemote()
|
||||
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
|
||||
*/
|
||||
const updatePreviewUrl = (path: string) => {
|
||||
const updatePreviewUrl = async (path: string) => {
|
||||
previewUrl.value = getPreviewUrl(path)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
@navigate-to-zip-directory="handleNavigateToZipDirectory"
|
||||
@update:search-keyword="handleSearchKeywordUpdate"
|
||||
@show-message="handleShowMessage"
|
||||
@connection-changed="handleConnectionChanged"
|
||||
/>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
@@ -56,9 +57,8 @@
|
||||
<!-- 分隔条 -->
|
||||
<div class="resizer" @mousedown="handleHorizontalResize"></div>
|
||||
|
||||
<!-- 文件编辑器面板 -->
|
||||
<!-- 文件编辑器面板(始终显示,无选中文件时为空白预览区) -->
|
||||
<FileEditorPanel
|
||||
v-if="hasSelectedFile"
|
||||
:config="fileEditorPanelConfig"
|
||||
:width="panelWidth.right"
|
||||
:current-directory="filePath"
|
||||
@@ -107,7 +107,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
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'
|
||||
|
||||
// 导入子组件
|
||||
@@ -129,6 +129,7 @@ import { useCommonPaths } from './composables/useCommonPaths'
|
||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
|
||||
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 { createResizeHandler } from '@/utils/resize'
|
||||
|
||||
@@ -336,10 +337,24 @@ const computeRendered = computed(() => {
|
||||
if (isHtmlFile(currentFileName)) {
|
||||
return fileContent.value || ''
|
||||
} else if (isMarkdownFile(currentFileName)) {
|
||||
// 使用配置好的 marked 渲染 Markdown(支持 mermaid)
|
||||
// 使用配置好的 marked 渲染 Markdown(支持 mermaid + 图片相对路径转换)
|
||||
try {
|
||||
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) {
|
||||
console.error('Markdown 解析失败:', error)
|
||||
return fileContent.value || ''
|
||||
@@ -399,10 +414,23 @@ const handleRefresh = async () => {
|
||||
await loadDirectory(filePath.value)
|
||||
}
|
||||
|
||||
// 连接切换后重置路径(仅监听状态变化以刷新快捷入口,不做自动导航)
|
||||
connectionManager.onStateChange(async (state) => {
|
||||
if (state === 'connected') {
|
||||
await loadCommonPaths()
|
||||
}
|
||||
})
|
||||
|
||||
const handleSearchKeywordUpdate = (keyword: string) => {
|
||||
searchKeyword.value = keyword
|
||||
}
|
||||
|
||||
// 用户主动切换连接时重置到根路径
|
||||
const handleConnectionChanged = async () => {
|
||||
await loadCommonPaths()
|
||||
await navigate(connectionManager.isRemote() ? '/' : 'C:/')
|
||||
}
|
||||
|
||||
const handleGoToPath = async (path: string) => {
|
||||
await navigate(path)
|
||||
}
|
||||
@@ -500,6 +528,26 @@ const handleShowMessage = (message: string, type: 'success' | 'error' | 'warning
|
||||
|
||||
// 侧边栏事件
|
||||
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) {
|
||||
await navigate(file.path)
|
||||
} else {
|
||||
@@ -1024,6 +1072,9 @@ const selectFile = async (path: string) => {
|
||||
|
||||
// 加载文件内容
|
||||
await loadFileContent(path)
|
||||
|
||||
// 记住上次打开的文件
|
||||
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE, path)
|
||||
}
|
||||
|
||||
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 url = await fileOps.getFileServerURL()
|
||||
const normalized = temp.replace(/\\/g, '/')
|
||||
updatePreviewUrl(`${url}/localfs/${encodeURIComponent(normalized)}`)
|
||||
updatePreviewUrl(normalized)
|
||||
} catch (error) {
|
||||
console.error('提取图片失败:', error)
|
||||
Message.error(`提取图片失败: ${error}`)
|
||||
@@ -1217,18 +1268,32 @@ const handleHorizontalResize = createResizeHandler(
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
|
||||
onMounted(() => {
|
||||
// 加载系统路径
|
||||
loadCommonPaths()
|
||||
onMounted(async () => {
|
||||
// 加载系统路径(阻塞,确保快捷入口就绪)
|
||||
await loadCommonPaths()
|
||||
|
||||
// 初始化加载
|
||||
if (!filePath.value) {
|
||||
// 设置默认路径
|
||||
const defaultPath = commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:\\'
|
||||
filePath.value = defaultPath
|
||||
loadDirectory(defaultPath)
|
||||
// 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径
|
||||
const startPath = connectionManager.isRemote() ? '/'
|
||||
: (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/')
|
||||
if (filePath.value && !connectionManager.isRemote()) {
|
||||
await loadDirectory(filePath.value)
|
||||
} 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 {
|
||||
width: 4px;
|
||||
width: 3px;
|
||||
background: var(--color-border);
|
||||
cursor: col-resize;
|
||||
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', // 排序状态
|
||||
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
||||
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
||||
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
|
||||
},
|
||||
|
||||
// 设备测试模块
|
||||
@@ -154,6 +155,7 @@ export const FILE_ICONS = {
|
||||
RUBY: '💎',
|
||||
DART: '🎯',
|
||||
DOCKERFILE: '🐳',
|
||||
VUE: '💚',
|
||||
|
||||
// 数据库
|
||||
DATABASE: '🗄️',
|
||||
@@ -270,6 +272,8 @@ const initIconMap = () => {
|
||||
'dart': FILE_ICONS.DART,
|
||||
// Dockerfile
|
||||
'dockerfile': FILE_ICONS.DOCKERFILE,
|
||||
// Vue
|
||||
'vue': FILE_ICONS.VUE,
|
||||
}
|
||||
|
||||
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))
|
||||
|
||||
@@ -100,6 +100,80 @@ renderer.heading = function(token: any) {
|
||||
</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 => {
|
||||
if (!href) return false
|
||||
@@ -108,6 +182,23 @@ const isLocalFileLink = (href: string): boolean => {
|
||||
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) {
|
||||
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>`
|
||||
}
|
||||
|
||||
marked.use({ renderer, breaks: true, gfm: true })
|
||||
marked.use({ renderer, breaks: true, gfm: true, async: false })
|
||||
|
||||
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
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {filesystem} from '../models';
|
||||
import {api} from '../models';
|
||||
import {main} from '../models';
|
||||
|
||||
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 DeleteDbConnection(arg1:number):Promise<void>;
|
||||
|
||||
export function DeletePath(arg1:string):Promise<filesystem.FileOperationResult>;
|
||||
|
||||
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 DownloadUpdate(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
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 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 GetDatabases(arg1:number):Promise<Array<string>>;
|
||||
|
||||
export function GetDiskInfo():Promise<Array<Record<string, any>>>;
|
||||
|
||||
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 GetIndexes(arg1:number,arg2:string,arg3:string):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function GetMemoryInfo():Promise<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 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 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 ListDbConnections():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 LoadAllDatabases(arg1:api.LoadAllDatabasesRequest):Promise<Array<string>>;
|
||||
|
||||
export function OpenPath(arg1:string):Promise<void>;
|
||||
|
||||
export function PreviewTableStructure(arg1:number,arg2:string,arg3:string,arg4:Record<string, any>):Promise<Array<string>>;
|
||||
|
||||
export function ReadFile(arg1:string):Promise<string>;
|
||||
|
||||
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 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 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 WindowClose():Promise<void>;
|
||||
|
||||
@@ -18,10 +18,6 @@ export function CreateFile(arg1) {
|
||||
return window['go']['main']['App']['CreateFile'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteDbConnection(arg1) {
|
||||
return window['go']['main']['App']['DeleteDbConnection'](arg1);
|
||||
}
|
||||
|
||||
export function DeletePath(arg1) {
|
||||
return window['go']['main']['App']['DeletePath'](arg1);
|
||||
}
|
||||
@@ -30,10 +26,6 @@ export function DeletePermanently(arg1) {
|
||||
return window['go']['main']['App']['DeletePermanently'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteResultHistory(arg1) {
|
||||
return window['go']['main']['App']['DeleteResultHistory'](arg1);
|
||||
}
|
||||
|
||||
export function DetectFileTypeByContent(arg1) {
|
||||
return window['go']['main']['App']['DetectFileTypeByContent'](arg1);
|
||||
}
|
||||
@@ -46,10 +38,6 @@ export function 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) {
|
||||
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']();
|
||||
}
|
||||
|
||||
export function GetDatabases(arg1) {
|
||||
return window['go']['main']['App']['GetDatabases'](arg1);
|
||||
}
|
||||
|
||||
export function GetDiskInfo() {
|
||||
return window['go']['main']['App']['GetDiskInfo']();
|
||||
}
|
||||
@@ -102,10 +86,6 @@ export function 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() {
|
||||
return window['go']['main']['App']['GetMemoryInfo']();
|
||||
}
|
||||
@@ -114,26 +94,10 @@ export function 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() {
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
|
||||
export function ListDbConnections() {
|
||||
return window['go']['main']['App']['ListDbConnections']();
|
||||
}
|
||||
|
||||
export function ListDir(arg1) {
|
||||
return window['go']['main']['App']['ListDir'](arg1);
|
||||
}
|
||||
|
||||
export function ListSqlTabs() {
|
||||
return window['go']['main']['App']['ListSqlTabs']();
|
||||
}
|
||||
|
||||
export function ListZipContents(arg1) {
|
||||
return window['go']['main']['App']['ListZipContents'](arg1);
|
||||
}
|
||||
|
||||
export function LoadAllDatabases(arg1) {
|
||||
return window['go']['main']['App']['LoadAllDatabases'](arg1);
|
||||
}
|
||||
|
||||
export function OpenPath(arg1) {
|
||||
return window['go']['main']['App']['OpenPath'](arg1);
|
||||
}
|
||||
|
||||
export function PreviewTableStructure(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['PreviewTableStructure'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ReadFile(arg1) {
|
||||
return window['go']['main']['App']['ReadFile'](arg1);
|
||||
}
|
||||
@@ -206,18 +154,6 @@ export function 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() {
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
return window['go']['main']['App']['VerifyUpdateFile'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
@@ -18,88 +18,6 @@ export namespace api {
|
||||
this.enabled = source["enabled"];
|
||||
}
|
||||
}
|
||||
export class LoadAllDatabasesRequest {
|
||||
id: number;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
database: string;
|
||||
options: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new LoadAllDatabasesRequest(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.username = source["username"];
|
||||
this.password = source["password"];
|
||||
this.database = source["database"];
|
||||
this.options = source["options"];
|
||||
}
|
||||
}
|
||||
export class SaveConnectionRequest {
|
||||
id: number;
|
||||
name: string;
|
||||
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