Private
Public Access
1
0

优化:工具栏高度对齐+面板统一+远程连接架构+自动恢复预览

- 工具栏:面包屑与右侧组件像素级等高(:deep 34px)、合并重复search handler、统一分隔符样式、删除死代码
- 面板对齐:三面板header统一padding/font-size、文件列表分页固定底部(自定义紧凑)、表头默认隐藏、滚动条统一样式
- 预览区:始终显示空白预览面板、重启自动恢复上次打开文件
- 收藏夹:简化计数显示(共N项)
- 远程连接:ConnectionIndicator自适应UI(无远程显示mini云图标)、ConnectionDialog支持编辑配置、transport抽象层(本地Wails/远程HTTP双模式)、agent后端模块
This commit is contained in:
2026-04-30 22:25:27 +08:00
parent 4f1d5f885f
commit 3d5a1e5892
36 changed files with 2236 additions and 628 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

105
cmd/agent/main.go Normal file
View 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
View 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
View File

@@ -6,10 +6,12 @@ require (
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2 github.com/chromedp/chromedp v0.14.2
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/labstack/echo/v4 v4.15.0
github.com/shirou/gopsutil/v3 v3.24.5 github.com/shirou/gopsutil/v3 v3.24.5
github.com/wailsapp/wails/v2 v2.12.0 github.com/wailsapp/wails/v2 v2.12.0
github.com/yuin/goldmark v1.8.2 github.com/yuin/goldmark v1.8.2
golang.org/x/sys v0.40.0 golang.org/x/sys v0.40.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
@@ -30,7 +32,6 @@ require (
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/labstack/echo/v4 v4.15.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect github.com/leaanthony/gosod v1.0.4 // indirect
@@ -59,6 +60,7 @@ require (
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
modernc.org/libc v1.67.7 // indirect modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

4
go.sum
View File

@@ -141,9 +141,13 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=

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

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

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

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

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

View 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 headerAPI 调用,首选)
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,
})
}

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

View File

@@ -68,9 +68,22 @@ func validateFilePath(rawPath string, logPrefix string) (string, error) {
return "", ErrPathTraversal return "", ErrPathTraversal
} }
filePath := strings.ReplaceAll(decodedPath, "/", "\\") // 去除代理引入的 /localfs/ 前缀(可能有多层)
clean := decodedPath
for strings.HasPrefix(clean, "/localfs/") || strings.HasPrefix(clean, "localfs/") {
clean = strings.TrimPrefix(clean, "/localfs/")
clean = strings.TrimPrefix(clean, "localfs/")
}
// 平台适配Windows 用反斜杠Linux/macOS 保持正斜杠
filePath := filepath.FromSlash(clean)
filePath = filepath.Clean(filePath) filePath = filepath.Clean(filePath)
// 确保绝对路径Linux 以 / 开头Windows 以盘符开头)
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' {
filePath = "/" + filePath
}
if !isSafePath(filePath) { if !isSafePath(filePath) {
return "", ErrPathUnsafe return "", ErrPathUnsafe
} }
@@ -153,8 +166,20 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path) log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path)
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀) // 从 URL 路径获取文件路径(移除所有 /localfs/ 前缀,兼容代理双重前缀)
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/") pathPart := r.URL.Path
for strings.HasPrefix(pathPart, "/localfs/") {
pathPart = strings.TrimPrefix(pathPart, "/localfs/")
}
if pathPart == "" || pathPart == r.URL.Path {
log.Printf("[LocalFileHandler] 路径前缀无效: original=%s", r.URL.Path)
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
// 仅对非绝对路径添加前导 /Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /
if !strings.HasPrefix(pathPart, "/") && !regexp.MustCompile(`^[A-Za-z]:`).MatchString(pathPart) {
pathPart = "/" + pathPart
}
if pathPart == "" || pathPart == r.URL.Path { if pathPart == "" || pathPart == r.URL.Path {
log.Printf("[LocalFileHandler] 路径前缀无效") log.Printf("[LocalFileHandler] 路径前缀无效")

View File

@@ -1,3 +1,6 @@
//go:build windows
// +build windows
package filesystem package filesystem
import ( import (

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

View 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()

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

View File

@@ -1,306 +1,110 @@
/** /**
* 系统信息相关 API * 系统信息相关 API — 委托给 Transport 层
* 本地模式走 Wails IPC远程模式走 HTTP REST API
*/ */
import type { SystemInfo, CPU, Memory, Disk, File } from './types' import type { File } from './types'
import { debugError } from '@/utils/debugLog' import { connectionManager } from './connection-manager'
/** /**
* 转换后端文件数据格式(蛇形 → 驼峰) * 转换后端文件数据格式(蛇形 → 驼峰)
* 后端返回 is_dir前端使用 isDir
*/ */
function transformFile(file: any): File { function transformFile(file: any): File {
return { return { ...file, isDir: file.is_dir, modified_time: file.mod_time }
...file,
isDir: file.is_dir,
modified_time: file.mod_time
}
} }
/**
* 批量转换文件列表
*/
function transformFileList(files: any[]): File[] { function transformFileList(files: any[]): File[] {
return files.map(transformFile) return files.map(transformFile)
} }
/** const t = () => connectionManager.getTransport()
* 获取系统信息
*/ export async function getSystemInfo() { return t().getFileInfo('/') }
export async function getSystemInfo(): Promise<SystemInfo> {
if (!window.go?.main?.App?.GetSystemInfo) { export async function getCPUInfo() {
throw new Error('GetSystemInfo API 不可用') if (connectionManager.isRemote()) return {}
} try { return await (window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
return await window.go.main.App.GetSystemInfo()
} }
/** export async function getMemoryInfo() {
* 获取 CPU 信息 if (connectionManager.isRemote()) return {}
*/ try { return await (window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
export async function getCPUInfo(): Promise<CPU> {
if (!window.go?.main?.App?.GetCPUInfo) {
throw new Error('GetCPUInfo API 不可用')
}
return await window.go.main.App.GetCPUInfo()
} }
/** export async function getDiskInfo() {
* 获取内存信息 if (connectionManager.isRemote()) return {}
*/ try { return await (window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
export async function getMemoryInfo(): Promise<Memory> {
if (!window.go?.main?.App?.GetMemoryInfo) {
throw new Error('GetMemoryInfo API 不可用')
}
return await window.go.main.App.GetMemoryInfo()
} }
/**
* 获取磁盘信息
*/
export async function getDiskInfo(): Promise<Disk> {
if (!window.go?.main?.App?.GetDiskInfo) {
throw new Error('GetDiskInfo API 不可用')
}
return await window.go.main.App.GetDiskInfo()
}
/**
* 列出目录文件
*/
export async function listDir(path: string): Promise<File[]> { export async function listDir(path: string): Promise<File[]> {
if (!window.go?.main?.App?.ListDir) { return transformFileList(await t().listDir(path))
throw new Error('ListDir API 不可用')
}
const files = await window.go.main.App.ListDir(path)
return transformFileList(files)
} }
/**
* 读取文件
*/
export async function readFile(path: string): Promise<string> { export async function readFile(path: string): Promise<string> {
if (!window.go?.main?.App?.ReadFile) { return t().readFile(path)
throw new Error('ReadFile API 不可用')
}
return await window.go.main.App.ReadFile(path)
} }
/**
* 写入文件
*/
export async function writeFile(path: string, content: string): Promise<void> { export async function writeFile(path: string, content: string): Promise<void> {
if (!window.go?.main?.App?.WriteFile) { await t().writeFile(path, String(content))
throw new Error('WriteFile API 不可用')
}
// 确保传递的是字符串类型
await window.go.main.App.WriteFile({
path: String(path),
content: String(content)
})
} }
/**
* 保存 Base64 编码的二进制文件(图片等)
*/
export async function saveBase64File(path: string, base64Content: string): Promise<void> { export async function saveBase64File(path: string, base64Content: string): Promise<void> {
if (!window.go?.main?.App?.SaveBase64File) { if (!base64Content) throw new Error('无效的 base64 内容')
throw new Error('SaveBase64File API 不可用') await t().saveBase64File(path, base64Content)
}
if (!base64Content) {
throw new Error('无效的 base64 内容')
}
await window.go.main.App.SaveBase64File({
path: String(path),
content: base64Content
})
} }
/**
* 删除文件或目录
*/
export async function deletePath(path: string): Promise<any> { export async function deletePath(path: string): Promise<any> {
if (!window.go?.main?.App?.DeletePath) { return t().deletePath(path)
throw new Error('DeletePath API 不可用')
}
return await window.go.main.App.DeletePath(path)
} }
/**
* 创建目录parentPath + dirname 拼接为完整路径)
*/
export async function createDir(parentPath: string, dirname: string): Promise<any> { export async function createDir(parentPath: string, dirname: string): Promise<any> {
if (!window.go?.main?.App?.CreateDir) { return t().createDir(parentPath, dirname)
throw new Error('CreateDir API 不可用')
}
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
return await window.go.main.App.CreateDir(fullPath)
} }
/**
* 创建文件dirPath + filename 拼接为完整路径)
*/
export async function createFile(dirPath: string, filename: string): Promise<any> { export async function createFile(dirPath: string, filename: string): Promise<any> {
if (!window.go?.main?.App?.CreateFile) { return t().createFile(dirPath, filename)
throw new Error('CreateFile API 不可用')
}
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
return await window.go.main.App.CreateFile(fullPath)
} }
/**
* 重命名文件或目录
*/
export async function renamePath(oldPath: string, newPath: string): Promise<any> { export async function renamePath(oldPath: string, newPath: string): Promise<any> {
if (!window.go?.main?.App?.RenamePath) { return t().renamePath(oldPath, String(newPath))
throw new Error('RenamePath API 不可用')
}
return await window.go.main.App.RenamePath({
oldPath: String(oldPath),
newPath: String(newPath)
})
} }
/**
* 获取环境变量
*/
export async function getEnvVars(): Promise<Record<string, string>> { export async function getEnvVars(): Promise<Record<string, string>> {
if (!window.go?.main?.App?.GetEnvVars) { try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
throw new Error('GetEnvVars API 不可用')
}
return await window.go.main.App.GetEnvVars()
} }
/**
* 列出 zip 文件内容
*/
export async function listZipContents(zipPath: string): Promise<File[]> { export async function listZipContents(zipPath: string): Promise<File[]> {
if (!window.go?.main?.App?.ListZipContents) { return transformFileList(await t().listZipContents(zipPath))
throw new Error('ListZipContents API 不可用')
}
try {
const result = await window.go.main.App.ListZipContents(zipPath)
return transformFileList(result)
} catch (error) {
debugError('[API] listZipContents 错误:', error)
throw error
}
} }
/**
* 从 zip 文件中提取单个文件内容
*/
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> { export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
if (!window.go?.main?.App?.ExtractFileFromZip) { return t().extractFileFromZip(zipPath, filePath)
throw new Error('ExtractFileFromZip API 不可用')
}
try {
const result = await window.go.main.App.ExtractFileFromZip(zipPath, filePath)
return result
} catch (error) {
debugError('[API] extractFileFromZip 错误:', error)
throw error
}
} }
/**
* 从 zip 文件中提取单个文件到临时目录
* 返回临时文件的完整路径,适用于图片等二进制文件
*/
export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> { export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
if (!window.go?.main?.App?.ExtractFileFromZipToTemp) { return t().extractFileFromZipToTemp(zipPath, filePath)
throw new Error('ExtractFileFromZipToTemp API 不可用')
}
try {
const result = await window.go.main.App.ExtractFileFromZipToTemp(zipPath, filePath)
return result
} catch (error) {
debugError('[API] extractFileFromZipToTemp 错误:', error)
throw error
}
} }
/**
* 获取 zip 文件中特定文件的信息
*/
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> { export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
if (!window.go?.main?.App?.GetZipFileInfo) { return transformFile(await t().getZipFileInfo(zipPath, filePath))
throw new Error('GetZipFileInfo API 不可用')
}
try {
const result = await window.go.main.App.GetZipFileInfo(zipPath, filePath)
return transformFile(result)
} catch (error) {
debugError('[API] getZipFileInfo 错误:', error)
throw error
}
} }
/**
* 使用系统默认程序打开文件或目录
*/
export async function openPath(path: string): Promise<void> { export async function openPath(path: string): Promise<void> {
if (!window.go?.main?.App?.OpenPath) { await t().openPath(path)
throw new Error('OpenPath API 不可用')
}
try {
await window.go.main.App.OpenPath(path)
} catch (error) {
debugError('[API] openPath 错误:', error)
throw error
}
} }
/**
* 获取本地文件服务器URL
*/
export async function getFileServerURL(): Promise<string> { export async function getFileServerURL(): Promise<string> {
if (!window.go?.main?.App?.GetFileServerURL) { return t().getFileServerURL()
throw new Error('GetFileServerURL API 不可用')
}
return await window.go.main.App.GetFileServerURL()
} }
/** export async function resolveShortcut(lnkPath: string): Promise<any> {
* 解析快捷方式文件,返回目标路径信息 return t().resolveShortcut(lnkPath)
*/
export async function resolveShortcut(lnkPath: string): Promise<{
success: boolean
message?: string
targetPath?: string
targetExists?: boolean
targetAccessible?: boolean
targetInfo?: any
}> {
if (!window.go?.main?.App?.ResolveShortcut) {
throw new Error('ResolveShortcut API 不可用')
}
try {
const result = await window.go.main.App.ResolveShortcut(lnkPath)
return result
} catch (error) {
debugError('[API] resolveShortcut 错误:', error)
throw error
}
} }
/** export async function detectFileTypeByContent(path: string) {
* 通过文件内容检测文件类型用于小文件500KB以内 return t().detectFileTypeByContent(path)
*/ }
export async function detectFileTypeByContent(path: string): Promise<{
extension: string export async function getCommonPaths() {
category: string // 'image' | 'text' | 'binary' | 'pdf' | 'archive' | 'unknown' return t().getCommonPaths()
mime_type: string
confidence: number
}> {
if (!window.go?.main?.App?.DetectFileTypeByContent) {
throw new Error('DetectFileTypeByContent API 不可用')
}
try {
const result = await window.go.main.App.DetectFileTypeByContent(path)
return result as any
} catch (error) {
debugError('[API] detectFileTypeByContent 错误:', error)
throw error
}
} }

71
web/src/api/transport.ts Normal file
View 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>
}

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

View File

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

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

View File

@@ -1,5 +1,7 @@
<template> <template>
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }"> <div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
<!-- 有选中文件时显示表头和内容 -->
<template v-if="config.currentFileName">
<div class="panel-header"> <div class="panel-header">
<span class="panel-title"> <span class="panel-title">
<template v-if="config.isImageView">🖼 图片预览</template> <template v-if="config.isImageView">🖼 图片预览</template>
@@ -72,7 +74,8 @@
<!-- 视频预览 --> <!-- 视频预览 -->
<div v-else-if="config.isVideoView" class="media-preview"> <div v-else-if="config.isVideoView" class="media-preview">
<video :src="config.previewUrl" controls class="preview-video"></video> <video :src="config.previewUrl" controls class="preview-video" @error="handleMediaError('视频')"></video>
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
<div class="media-meta"> <div class="media-meta">
<a-tag color="arcoblue">🎬 视频</a-tag> <a-tag color="arcoblue">🎬 视频</a-tag>
</div> </div>
@@ -80,7 +83,8 @@
<!-- 音频预览 --> <!-- 音频预览 -->
<div v-else-if="config.isAudioView" class="media-preview"> <div v-else-if="config.isAudioView" class="media-preview">
<audio :src="config.previewUrl" controls class="preview-audio"></audio> <audio :src="config.previewUrl" controls class="preview-audio" @error="handleMediaError('音频')"></audio>
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
<div class="media-meta"> <div class="media-meta">
<a-tag color="green">🎵 音频</a-tag> <a-tag color="green">🎵 音频</a-tag>
</div> </div>
@@ -88,7 +92,8 @@
<!-- PDF 预览 --> <!-- PDF 预览 -->
<div v-else-if="config.isPdfFile" class="media-preview media-preview-pdf"> <div v-else-if="config.isPdfFile" class="media-preview media-preview-pdf">
<iframe :src="config.previewUrl" class="preview-pdf"></iframe> <iframe :src="config.previewUrl" class="preview-pdf" @load="handlePdfLoad"></iframe>
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
<div class="media-meta"> <div class="media-meta">
<a-tag color="orangered">📕 PDF</a-tag> <a-tag color="orangered">📕 PDF</a-tag>
</div> </div>
@@ -354,6 +359,7 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>
@@ -367,6 +373,7 @@ import type { FileEditorPanelConfig } from '@/types/file-system'
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions' import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers' import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
import { connectionManager } from '@/api/connection-manager'
// 异步加载 CodeEditor 组件,减少初始包大小 // 异步加载 CodeEditor 组件,减少初始包大小
const AsyncCodeEditor = defineAsyncComponent({ const AsyncCodeEditor = defineAsyncComponent({
@@ -432,13 +439,27 @@ interface Emits {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// HTML 预览 URL使用后端接口 // HTML 预览 URL实时从 connectionManager 读取,不缓存
function resolveHtmlPreviewBase(): string {
if (!connectionManager.isRemote()) return 'http://localhost:8073'
const base = connectionManager.getFileServerBaseURL()
if (!base) return 'http://localhost:8073'
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfshtmlPreviewUrl 会替换为 html-preview
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
}
const htmlPreviewUrl = computed(() => { const htmlPreviewUrl = computed(() => {
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) { if (!props.config.currentFileFullPath || !props.config.isHtmlFile) return ''
return ''
}
const encodedPath = encodeURIComponent(props.config.currentFileFullPath) const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
return `http://localhost:8073/localfs/html-preview?path=${encodedPath}` const isRemote = connectionManager.isRemote()
const base = resolveHtmlPreviewBase()
if (isRemote) {
// 远程模式:走 /api/v1/proxy/html-preview 路由
const baseUrl = base.replace(/\/proxy\/localfs\/?$/, '')
return `${baseUrl}/proxy/html-preview?path=${encodedPath}`
}
// 本地模式:直连文件服务器
return `${base}/localfs/html-preview?path=${encodedPath}`
}) })
// 计算属性:判断文件是否在当前目录 // 计算属性:判断文件是否在当前目录
@@ -498,6 +519,30 @@ const handleImageError = () => {
emit('imageError') emit('imageError')
} }
const mediaErrorMsg = ref('')
const handleMediaError = (type: string) => {
mediaErrorMsg.value = `${type}文件加载失败,请检查网络连接或文件权限`
}
const handlePdfLoad = (event: Event) => {
const iframe = event.target as HTMLIFrameElement
try {
// iframe 加载后检查内容是否为空401/404 等错误页面通常内容很少)
if (!iframe.contentDocument || iframe.contentDocument.body.innerHTML.length < 100) {
mediaErrorMsg.value = 'PDF 文件加载失败,请检查网络连接或文件权限'
}
} catch {
// 跨域时无法访问 contentDocument忽略
}
}
// 带认证的 fetch远程模式自动附加 Bearer token
const authFetch = async (url: string): Promise<Response> => {
const token = connectionManager.activeProfile?.token
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
return fetch(url, { headers })
}
// 打印窗口导出 PDF 公共函数 // 打印窗口导出 PDF 公共函数
const openPrintWindow = (title: string, bodyHtml: string, extraStyle = '') => { const openPrintWindow = (title: string, bodyHtml: string, extraStyle = '') => {
const printWindow = window.open('', '_blank') const printWindow = window.open('', '_blank')
@@ -650,7 +695,7 @@ const loadExcelPreview = async (filePath: string) => {
// 直接从本地文件服务器获取(不走 base64 // 直接从本地文件服务器获取(不走 base64
const fileUrl = props.config.previewUrl const fileUrl = props.config.previewUrl
const response = await fetch(fileUrl) const response = await authFetch(fileUrl)
const blob = await response.blob() const blob = await response.blob()
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' }) const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
@@ -679,7 +724,7 @@ const loadWordPreview = async (filePath: string) => {
wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>' wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
const fileUrl = props.config.previewUrl const fileUrl = props.config.previewUrl
const response = await fetch(fileUrl) const response = await authFetch(fileUrl)
const blob = await response.blob() const blob = await response.blob()
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' }) const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' })
@@ -709,7 +754,7 @@ const loadCsvPreview = async (filePath: string) => {
const blob = props.config.fileContent && !props.config.isBinaryFile const blob = props.config.fileContent && !props.config.isBinaryFile
? new Blob([props.config.fileContent], { type: 'text/csv' }) ? new Blob([props.config.fileContent], { type: 'text/csv' })
: await (await fetch(props.config.previewUrl)).blob() : await (await authFetch(props.config.previewUrl)).blob()
const file = new File([blob], getFileName(filePath), { type: 'text/csv' }) const file = new File([blob], getFileName(filePath), { type: 'text/csv' })
const result = await previewCsv(file, csvPreviewRef.value) const result = await previewCsv(file, csvPreviewRef.value)
@@ -786,8 +831,8 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073 // Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
const allowedOrigins = [ const allowedOrigins = [
window.location.origin, window.location.origin,
'null', // about:blank 或 data: URL 'null',
'http://localhost:8073', // 本地文件服务器 resolveHtmlPreviewBase(), // 动态:本地 localhost:8073 或远程代理地址
] ]
if (!allowedOrigins.includes(event.origin)) { if (!allowedOrigins.includes(event.origin)) {
return return
@@ -835,11 +880,9 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 8px 12px; padding: 3px 12px;
background: var(--color-fill-1); background: var(--color-bg-2);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
font-size: 13px;
font-weight: 500;
flex-shrink: 0; flex-shrink: 0;
gap: 12px; gap: 12px;
} }
@@ -944,6 +987,13 @@ onUnmounted(() => {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.media-error {
color: var(--color-danger-6);
font-size: 12px;
padding: 4px 0;
text-align: center;
}
.media-meta { .media-meta {
display: flex; display: flex;
gap: 8px; gap: 8px;

View File

@@ -3,7 +3,6 @@
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">📋 文件列表</span> <span class="panel-title">📋 文件列表</span>
<div class="panel-header-right"> <div class="panel-header-right">
<span class="panel-count">{{ config.fileList.length }} </span>
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }"> <a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }">
<a-button size="mini" type="text" class="settings-btn"> <a-button size="mini" type="text" class="settings-btn">
<icon-more /> <icon-more />
@@ -50,21 +49,20 @@
</div> </div>
<div <div
class="file-list-wrapper" class="file-list-wrapper thin-dark-scrollbar"
@contextmenu.prevent="handleWrapperContextMenu" @contextmenu.prevent="handleWrapperContextMenu"
> >
<!-- 文件列表a-table --> <!-- 文件列表滚动区域 -->
<a-table <a-table
v-if="config.fileList.length > 0 || config.fileLoading" v-if="config.fileList.length > 0 || config.fileLoading"
:columns="tableColumns" :columns="tableColumns"
:data="config.fileList" :data="pagedFileList"
:loading="config.fileLoading" :loading="config.fileLoading"
:pagination="false" :pagination="false"
:bordered="false" :bordered="false"
:show-header="showHeader" :show-header="showHeader"
size="mini" size="mini"
:row-class-name="getRowClassName" :row-class-name="getRowClassName"
:scroll="{ y: 'auto' }"
class="file-table" class="file-table"
@row-click="handleRowClick" @row-click="handleRowClick"
@row-contextmenu="handleRowContextMenu" @row-contextmenu="handleRowContextMenu"
@@ -76,13 +74,27 @@
<span>此文件夹为空</span> <span>此文件夹为空</span>
</div> </div>
</div> </div>
<!-- 分页栏固定在面板底部不随内容滚动 -->
<div v-if="config.fileList.length > 0" class="pagination-bar">
<span class="pagination-total"> {{ config.fileList.length }} </span>
<span class="pagination-nav">
<span class="page-btn" :class="{ disabled: currentPage <= 1 }" @click="onPageChange(currentPage - 1)">
<icon-left />
</span>
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
<span class="page-btn" :class="{ disabled: currentPage >= totalPages }" @click="onPageChange(currentPage + 1)">
<icon-right />
</span>
</span>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { h, computed, nextTick, ref } from 'vue' import { h, computed, nextTick, ref, watch } from 'vue'
import { Input, Button } from '@arco-design/web-vue' import { Input, Button } from '@arco-design/web-vue'
import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore } from '@arco-design/web-vue/es/icon' import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore, IconLeft, IconRight } from '@arco-design/web-vue/es/icon'
import { formatBytes, formatFileTime, getFileIcon, getExt } from '@/utils/fileUtils' import { formatBytes, formatFileTime, getFileIcon, getExt } from '@/utils/fileUtils'
import { STORAGE_KEYS } from '@/utils/constants' import { STORAGE_KEYS } from '@/utils/constants'
import type { FileListPanelConfig, FileItem } from '@/types/file-system' import type { FileListPanelConfig, FileItem } from '@/types/file-system'
@@ -159,8 +171,8 @@ function loadColSettings(): ColumnConfig[] {
} }
const colSettings = ref<ColumnConfig[]>(loadColSettings()) const colSettings = ref<ColumnConfig[]>(loadColSettings())
// 默认显示表头localStorage 无值时兼容旧行为 // 默认隐藏表头localStorage 无值时默认不显示
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) !== 'false') const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) === 'true')
// 手动持久化(避免 deep watch 频繁写入) // 手动持久化(避免 deep watch 频繁写入)
function saveColSettings() { function saveColSettings() {
@@ -332,6 +344,26 @@ const tableColumns = computed(() => {
.filter(Boolean) .filter(Boolean)
}) })
// ========== 分页 ==========
const currentPage = ref(1)
const pageSize = 100
const pagedFileList = computed(() => {
const list = props.config.fileList
const start = (currentPage.value - 1) * pageSize
return list.slice(start, start + pageSize)
})
const totalPages = computed(() => Math.max(1, Math.ceil(props.config.fileList.length / pageSize)))
const onPageChange = (page: number) => {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
}
// 当文件列表变化时重置到第1页
watch(() => props.config.fileList.length, () => { currentPage.value = 1 })
// ========== 行事件处理 ========== // ========== 行事件处理 ==========
const handleRowClick = (record: FileItem, ev: Event) => { const handleRowClick = (record: FileItem, ev: Event) => {
const target = ev.target as HTMLElement const target = ev.target as HTMLElement
@@ -372,12 +404,13 @@ defineExpose({ focusEditingItem })
.file-list-panel { .file-list-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; flex: 1; /* 父级是 flex 容器,用 flex:1 而非 height:100% */
min-height: 0; /* 允许收缩到小于内容高度 */
background: var(--color-bg-1); background: var(--color-bg-1);
} }
.panel-header { .panel-header {
padding: 6px 12px; padding: 3px 12px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background: var(--color-bg-2); background: var(--color-bg-2);
flex-shrink: 0; flex-shrink: 0;
@@ -422,15 +455,20 @@ defineExpose({ focusEditingItem })
color: rgb(var(--primary-6)); color: rgb(var(--primary-6));
} }
/* 滚动容器 */ /* 滚动容器table + 分页 的统一滚动层) */
.file-list-wrapper { .file-list-wrapper {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 0 2px; padding: 0 2px;
} }
/* ====== Table 全局覆盖 ====== */ /* ====== Table ====== */
.file-table {
flex: 1;
min-height: 0;
}
.file-table :deep(.arco-table) { .file-table :deep(.arco-table) {
font-size: 13px; font-size: 13px;
table-layout: fixed; table-layout: fixed;
@@ -564,4 +602,35 @@ defineExpose({ focusEditingItem })
gap: 8px; gap: 8px;
} }
.empty-state span:nth-child(2) { font-size: 14px; } .empty-state span:nth-child(2) { font-size: 14px; }
/* 分页栏(固定底部) */
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px;
background: var(--color-bg-2);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.pagination-total {
font-size: 12px;
color: var(--color-text-3);
}
.pagination-nav {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.page-btn {
cursor: pointer;
color: var(--color-text-2);
padding: 0 3px;
line-height: 1;
user-select: none;
}
.page-btn:hover:not(.disabled) { color: rgb(var(--primary-6)); }
.page-btn.disabled { color: var(--color-text-4); cursor: default; }
.page-info { color: var(--color-text-2); min-width: 28px; text-align: center; }
</style> </style>

View File

@@ -5,7 +5,7 @@
<!-- 路径段 --> <!-- 路径段 -->
<div <div
class="breadcrumb-segment" class="breadcrumb-segment"
:class="{ 'is-hoverable': index < segments.length - 1 }" :class="{ 'is-hoverable': index < segments.length - 1 || segments.length === 1 }"
@mouseenter="onHover(segment, index)" @mouseenter="onHover(segment, index)"
@mouseleave="onLeave" @mouseleave="onLeave"
@click="onClick(segment)" @click="onClick(segment)"
@@ -152,7 +152,8 @@ const resetAndClose = () => {
} }
const onHover = (segment: PathSegment, index: number) => { const onHover = (segment: PathSegment, index: number) => {
if (index === segments.value.length - 1) return // 根目录(如 C:)只有一段,也允许悬停弹出子目录
if (index === segments.value.length - 1 && segments.value.length > 1) return
if (hoverTimer.value) clearTimeout(hoverTimer.value) if (hoverTimer.value) clearTimeout(hoverTimer.value)
if (closeTimer.value) clearTimeout(closeTimer.value) if (closeTimer.value) clearTimeout(closeTimer.value)
@@ -209,7 +210,7 @@ watch(() => props.path, () => {
.breadcrumb-items { .breadcrumb-items {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 2px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -242,7 +243,7 @@ watch(() => props.path, () => {
color: var(--color-text-3); color: var(--color-text-3);
font-size: 12px; font-size: 12px;
flex-shrink: 0; flex-shrink: 0;
margin: 0 2px; margin: 0 1px;
} }
/* 弹出菜单 */ /* 弹出菜单 */

View File

@@ -3,7 +3,7 @@
<div v-show="config.visible" class="sidebar"> <div v-show="config.visible" class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<span class="sidebar-title"> 收藏夹</span> <span class="sidebar-title"> 收藏夹</span>
<span class="sidebar-count">{{ config.favoriteFiles.length }}</span> <span class="sidebar-count">{{ config.favoriteFiles.length }}</span>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">
<div <div
@@ -154,7 +154,7 @@ const handleDragEnd = () => {
} }
.sidebar-header { .sidebar-header {
padding: 12px 16px; padding: 6px 12px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -163,17 +163,14 @@ const handleDragEnd = () => {
} }
.sidebar-title { .sidebar-title {
font-size: 14px; font-size: 13px;
font-weight: 600; font-weight: 500;
color: var(--color-text-1); color: var(--color-text-1);
} }
.sidebar-count { .sidebar-count {
font-size: 12px; font-size: 12px;
color: var(--color-text-3); color: var(--color-text-3);
background: var(--color-fill-2);
padding: 2px 8px;
border-radius: 10px;
} }
.sidebar-content { .sidebar-content {

View File

@@ -9,7 +9,7 @@
📦 {{ config.zipFileName }} 📦 {{ config.zipFileName }}
</a-tag> </a-tag>
<template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0"> <template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0">
<icon-right class="breadcrumb-separator" /> <icon-right class="breadcrumb-sep" />
<a-tag <a-tag
v-for="(crumb, index) in config.zipBreadcrumbs" v-for="(crumb, index) in config.zipBreadcrumbs"
:key="index" :key="index"
@@ -25,41 +25,49 @@
退出 ZIP 退出 ZIP
</a-button> </a-button>
</div> </div>
<!-- 正常模式面包屑导航 --> <!-- 正常模式连接指示器 + 面包屑导航融合布局 -->
<div v-else class="path-breadcrumb-wrapper"> <div v-else class="path-breadcrumb-wrapper">
<!-- 快捷访问仅图标面包屑 --> <!-- 连接指示器紧凑标签样式作为面包屑首段 -->
<a-dropdown> <ConnectionIndicator @add="showConnectionDialog = true" @select="onConnectionChanged" @edit="onEditProfile" />
<a-button size="mini" type="text"> <span class="breadcrumb-sep"></span>
<template #icon><icon-forward /></template> <!-- 路径面包屑 -->
</a-button>
<template #content>
<a-doption
v-for="shortcut in config.commonPaths"
:key="shortcut.path"
@click="handleGoToPath(shortcut.path)"
>
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
{{ (shortcut.name || '').substring(2) }}
</a-doption>
</template>
</a-dropdown>
<PathBreadcrumb <PathBreadcrumb
:path="config.filePath" :path="config.filePath"
@navigate="handleGoToPath" @navigate="handleGoToPath"
@openFile="handleOpenFile" @openFile="handleOpenFile"
/> />
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="top"> <!-- 右侧操作快捷路径 + 复制 -->
<a-button <div class="breadcrumb-right-actions">
size="mini" <a-tooltip content="快捷路径" position="bottom">
type="text" <a-dropdown>
:status="copied ? 'success' : 'normal'" <a-button size="mini" type="text" class="shortcut-btn">
class="toolbar-copy-btn" <template #icon><icon-forward /></template>
@click="handleCopyPath" </a-button>
> <template #content>
<icon-copy v-if="!copied" /> <a-doption
<icon-check v-else /> v-for="shortcut in config.commonPaths"
</a-button> :key="shortcut.path"
</a-tooltip> @click="handleGoToPath(shortcut.path)"
>
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
{{ (shortcut.name || '').substring(2) }}
</a-doption>
</template>
</a-dropdown>
</a-tooltip>
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="top">
<a-button
size="mini"
type="text"
:status="copied ? 'success' : 'normal'"
class="toolbar-copy-btn"
@click="handleCopyPath"
>
<icon-copy v-if="!copied" />
<icon-check v-else />
</a-button>
</a-tooltip>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -73,7 +81,7 @@
class="toolbar-search" class="toolbar-search"
allow-clear allow-clear
@search="handleSearch" @search="handleSearch"
@update:model-value="handleSearchInput" @update:model-value="handleSearch"
@keyup.escape="handleClearSearch" @keyup.escape="handleClearSearch"
/> />
@@ -124,13 +132,18 @@
</a-button> </a-button>
</div> </div>
</div> </div>
<ConnectionDialog ref="connectionDialogRef" v-model:visible="showConnectionDialog" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { nextTick } from 'vue'
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy, IconCheck } from '@arco-design/web-vue/es/icon' import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy, IconCheck } from '@arco-design/web-vue/es/icon'
import type { ToolbarConfig } from '@/types/file-system' import type { ToolbarConfig } from '@/types/file-system'
import PathBreadcrumb from './PathBreadcrumb.vue' import PathBreadcrumb from './PathBreadcrumb.vue'
import { useClipboardCopy } from '../composables/useClipboardCopy' import { useClipboardCopy } from '../composables/useClipboardCopy'
import ConnectionIndicator from './ConnectionIndicator.vue'
import ConnectionDialog from './ConnectionDialog.vue'
// Props // Props
interface Props { interface Props {
@@ -149,10 +162,24 @@ interface Emits {
(e: 'goToPath', path: string): void (e: 'goToPath', path: string): void
(e: 'openFile', path: string): void (e: 'openFile', path: string): void
(e: 'navigateToZipDirectory', path: string): void (e: 'navigateToZipDirectory', path: string): void
(e: 'connectionChanged'): void
} }
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// 连接对话框
const showConnectionDialog = ref(false)
const connectionDialogRef = ref<InstanceType<typeof ConnectionDialog>>()
const onConnectionChanged = async (_id: string) => {
emit('connectionChanged')
}
const onEditProfile = (id: string) => {
showConnectionDialog.value = true
// 等待 DOM 更新后调用 editProfile 填充表单
nextTick(() => connectionDialogRef.value?.editProfile(id))
}
// 历史记录下拉显隐(供父组件 Ctrl+H 调用) // 历史记录下拉显隐(供父组件 Ctrl+H 调用)
const historyPopupVisible = ref(false) const historyPopupVisible = ref(false)
@@ -177,10 +204,6 @@ const handleNavigateToZipRoot = () => {
emit('navigateToZipDirectory', '') emit('navigateToZipDirectory', '')
} }
const handleNavigateToZipDirectory = (path: string) => {
emit('navigateToZipDirectory', path)
}
const handleToggleSidebar = () => { const handleToggleSidebar = () => {
emit('update:showSidebar', !props.config.showSidebar) emit('update:showSidebar', !props.config.showSidebar)
} }
@@ -189,10 +212,6 @@ const handleSearch = (keyword: string) => {
emit('update:searchKeyword', keyword) emit('update:searchKeyword', keyword)
} }
const handleSearchInput = (keyword: string) => {
emit('update:searchKeyword', keyword)
}
const handleClearSearch = () => { const handleClearSearch = () => {
emit('update:searchKeyword', '') emit('update:searchKeyword', '')
} }
@@ -237,6 +256,11 @@ const handleCopyPath = async () => {
flex-shrink: 0; flex-shrink: 0;
} }
.toolbar-right :deep(.arco-btn-size-small),
.toolbar-right :deep(.arco-input-wrapper) {
height: 34px;
}
.toolbar-search { .toolbar-search {
width: 180px; width: 180px;
flex-shrink: 0; flex-shrink: 0;
@@ -247,27 +271,44 @@ const handleCopyPath = async () => {
min-width: 200px; min-width: 200px;
} }
.path-input {
width: 100%;
}
.path-breadcrumb-wrapper { .path-breadcrumb-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1; flex: 1;
min-width: 200px; min-width: 200px;
gap: 8px; gap: 4px;
padding: 4px 8px; padding: 4px 8px;
background: var(--color-fill-1); background: var(--color-fill-1);
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
transition: border-color 0.2s; transition: border-color 0.2s;
overflow: visible;
} }
.path-breadcrumb-wrapper:hover { .path-breadcrumb-wrapper:hover {
border-color: var(--color-border-2); border-color: var(--color-border-2);
} }
.breadcrumb-sep {
color: var(--color-text-3);
font-size: 12px;
flex-shrink: 0;
line-height: 1;
margin: 0 1px;
}
.breadcrumb-right-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
.shortcut-btn {
padding: 1px 3px;
}
.toolbar-copy-btn { .toolbar-copy-btn {
padding: 2px 4px; padding: 2px 4px;
} }
@@ -293,12 +334,6 @@ const handleCopyPath = async () => {
border-color: rgb(var(--primary-6)); border-color: rgb(var(--primary-6));
} }
.breadcrumb-separator {
color: var(--color-text-3);
font-size: 12px;
flex-shrink: 0;
}
.breadcrumb-tag { .breadcrumb-tag {
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
@@ -312,15 +347,6 @@ const handleCopyPath = async () => {
border-color: rgb(var(--primary-6)); border-color: rgb(var(--primary-6));
} }
.zip-path-text {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
color: var(--color-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 历史记录下拉 */ /* 历史记录下拉 */
.history-dropdown-content { .history-dropdown-content {
max-width: 420px; max-width: 420px;

View File

@@ -5,90 +5,56 @@
import { ref } from 'vue' import { ref } from 'vue'
import { PATH_ICONS } from '@/utils/constants' import { PATH_ICONS } from '@/utils/constants'
import { getCommonPaths } from '@/api/system'
import { connectionManager } from '@/api/connection-manager'
import type { ShortcutPath } from '@/types/file-system' import type { ShortcutPath } from '@/types/file-system'
export function useCommonPaths() { export function useCommonPaths() {
// 系统路径
const commonPaths = ref<ShortcutPath[]>([]) const commonPaths = ref<ShortcutPath[]>([])
const systemPaths = ref<Record<string, string>>({}) const systemPaths = ref<Record<string, string>>({})
/**
* 加载常用系统路径
*/
const loadCommonPaths = async () => { const loadCommonPaths = async () => {
try { try {
// 检查 Wails API 是否可用 const paths = await getCommonPaths()
if (!window.go?.main?.App?.GetCommonPaths) { if (!paths) throw new Error('无法获取系统路径')
// 降级方案:使用默认路径
commonPaths.value = [
{ name: '💿 C盘', path: 'C:\\' },
{ name: '💿 D盘', path: 'D:\\' }
]
return
}
const paths = await window.go.main.App.GetCommonPaths()
if (!paths) {
throw new Error('无法获取系统路径')
}
systemPaths.value = paths systemPaths.value = paths
const platform = window.navigator.platform
const pathList: ShortcutPath[] = [] const pathList: ShortcutPath[] = []
// 根据返回数据判断平台Linux agent 返回 root keyWindows 返回 root_ 前缀)
const isWin = !!Object.keys(paths).find(k => k.startsWith('root_'))
if (platform.includes('Win')) { if (isWin) {
// Windows: 先添加基础路径,再添加所有盘符
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop }) if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents }) if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads }) if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 用户目录`, path: paths.home }) if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 用户目录`, path: paths.home })
// 动态添加所有盘符(按字母顺序)
const drives: Array<{ letter: string; path: string }> = [] const drives: Array<{ letter: string; path: string }> = []
for (const key in paths) { for (const key in paths) {
if (key.startsWith('root_')) { if (key.startsWith('root_')) {
const driveLetter = key.substring(5) drives.push({ letter: key.substring(5), path: paths[key] })
drives.push({
letter: driveLetter,
path: paths[key]
})
} }
} }
drives.sort((a, b) => a.letter.localeCompare(b.letter)) drives.sort((a, b) => a.letter.localeCompare(b.letter))
drives.forEach(d => pathList.push({ name: `${PATH_ICONS.DRIVE} ${d.letter}`, path: d.path }))
// 添加盘符到路径列表
drives.forEach(drive => {
pathList.push({
name: `${PATH_ICONS.DRIVE} ${drive.letter}`,
path: drive.path
})
})
} else { } else {
// macOS/Linux: 使用系统路径 // Linux 远程模式
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home }) if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home })
pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }) if (paths.root) pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' })
if (paths.users) pathList.push({ name: `👥 /home`, path: paths.users })
} }
commonPaths.value = pathList.length > 0 ? pathList : [ commonPaths.value = pathList.length > 0 ? pathList : (
{ name: '💿 C盘', path: 'C:\\' }, connectionManager.isRemote()
{ name: '💿 D盘', path: 'D:\\' } ? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
] : [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
)
} catch (error) { } catch (error) {
console.error('加载系统路径失败:', error) console.error('加载系统路径失败:', error)
// 降级方案 commonPaths.value = connectionManager.isRemote()
commonPaths.value = [ ? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
{ name: '💿 C盘', path: 'C:\\' }, : [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
{ name: '💿 D盘', path: 'D:\\' }
]
} }
} }
return { return { commonPaths, systemPaths, loadCommonPaths }
commonPaths,
systemPaths,
loadCommonPaths
}
} }

View File

@@ -7,6 +7,7 @@ import { ref } from 'vue'
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants' import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import { normalizeFilePath, getExt } from '@/utils/fileUtils' import { normalizeFilePath, getExt } from '@/utils/fileUtils'
import { detectFileTypeByContent } from '@/api/system' import { detectFileTypeByContent } from '@/api/system'
import { connectionManager } from '@/api/connection-manager'
import { import {
isImageFile, isVideoFile, isAudioFile, isPdfFile, isImageFile, isVideoFile, isAudioFile, isPdfFile,
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType, isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
@@ -26,21 +27,22 @@ export interface UseFilePreviewOptions {
isBrowsingZip?: boolean isBrowsingZip?: boolean
} }
function getLocalServerURL(): string {
return 'http://localhost:8073'
}
function resolveFileServerBase(): string {
// 单一数据源:从 connectionManager 实时读取,不缓存
if (!connectionManager.isRemote()) return getLocalServerURL()
const base = connectionManager.getFileServerBaseURL()
if (!base) return getLocalServerURL()
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
}
export function useFilePreview(options: UseFilePreviewOptions = {}) { export function useFilePreview(options: UseFilePreviewOptions = {}) {
const { filePath = ref(''), isBrowsingZip = ref(false) } = options const { filePath = ref(''), isBrowsingZip = ref(false) } = options
// 文件服务器 URL优先从后端获取降级到默认值
let _fileServerURL = 'http://localhost:8073'
const initFileServerURL = async () => {
try {
const url = await window.go.main.App.GetFileServerURL()
if (url) _fileServerURL = url
} catch { /* 使用默认值 */ }
}
initFileServerURL()
const getFileServerURL = () => _fileServerURL
// 预览 URL // 预览 URL
const previewUrl = ref('') const previewUrl = ref('')
@@ -49,12 +51,19 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
const currentImageDimensions = ref('') const currentImageDimensions = ref('')
/** /**
* 获取预览 URL与旧版本保持一致 * 获取预览 URL本地/远程自适应,每次实时计算
* 本地: http://localhost:8073/localfs/{encoded_path}
* 远程: {baseUrl}/api/v1/proxy/localfs/{raw_path}Cookie 自动携带认证)
*/ */
const getPreviewUrl = (path: string): string => { const getPreviewUrl = (path: string): string => {
if (!path) return '' if (!path) return ''
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径 const isRemote = connectionManager.isRemote()
return `${getFileServerURL()}/localfs/${normalizeFilePath(path, true)}` const base = resolveFileServerBase()
let normalized = normalizeFilePath(path, true)
// 远程模式去掉前导 /,避免与 URL 基础路径拼接产生双斜杠(导致 307 重定向)
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
const sep = base.endsWith('/') ? '' : '/'
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}`
} }
/** /**
@@ -85,7 +94,7 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
/** /**
* 更新预览 URL * 更新预览 URL
*/ */
const updatePreviewUrl = (path: string) => { const updatePreviewUrl = async (path: string) => {
previewUrl.value = getPreviewUrl(path) previewUrl.value = getPreviewUrl(path)
} }

View File

@@ -13,6 +13,7 @@
@navigate-to-zip-directory="handleNavigateToZipDirectory" @navigate-to-zip-directory="handleNavigateToZipDirectory"
@update:search-keyword="handleSearchKeywordUpdate" @update:search-keyword="handleSearchKeywordUpdate"
@show-message="handleShowMessage" @show-message="handleShowMessage"
@connection-changed="handleConnectionChanged"
/> />
<!-- 主内容区 --> <!-- 主内容区 -->
@@ -56,9 +57,8 @@
<!-- 分隔条 --> <!-- 分隔条 -->
<div class="resizer" @mousedown="handleHorizontalResize"></div> <div class="resizer" @mousedown="handleHorizontalResize"></div>
<!-- 文件编辑器面板 --> <!-- 文件编辑器面板始终显示无选中文件时为空白预览区 -->
<FileEditorPanel <FileEditorPanel
v-if="hasSelectedFile"
:config="fileEditorPanelConfig" :config="fileEditorPanelConfig"
:width="panelWidth.right" :width="panelWidth.right"
:current-directory="filePath" :current-directory="filePath"
@@ -107,7 +107,7 @@
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { getPathSeparator } from '@/utils/fileUtils' import { getPathSeparator } from '@/utils/fileUtils'
import { Message, Modal } from '@arco-design/web-vue' import { Message, Modal } from '@arco-design/web-vue'
import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions' import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
// 导入子组件 // 导入子组件
@@ -129,6 +129,7 @@ import { useCommonPaths } from './composables/useCommonPaths'
import { getFileName, sortFileList } from '@/utils/fileUtils' import { getFileName, sortFileList } from '@/utils/fileUtils'
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers' import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
import { listDir, saveBase64File } from '@/api/system' import { listDir, saveBase64File } from '@/api/system'
import { connectionManager } from '@/api/connection-manager'
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants' import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import { createResizeHandler } from '@/utils/resize' import { createResizeHandler } from '@/utils/resize'
@@ -336,10 +337,24 @@ const computeRendered = computed(() => {
if (isHtmlFile(currentFileName)) { if (isHtmlFile(currentFileName)) {
return fileContent.value || '' return fileContent.value || ''
} else if (isMarkdownFile(currentFileName)) { } else if (isMarkdownFile(currentFileName)) {
// 使用配置好的 marked 渲染 Markdown支持 mermaid // 使用配置好的 marked 渲染 Markdown支持 mermaid + 图片相对路径转换
try { try {
const content = fileContent.value || '' const content = fileContent.value || ''
return marked(content)
// 设置图片路径转换所需的上下文renderer.image 钩子中读取)
// dir: 当前 md 文件所在目录(从文件完整路径中去掉文件名)
const fullPath = selectedFileItem.value?.path || ''
const dir = fullPath ? fullPath.replace(/[/\\][^/\\]+$/, '') : (filePath.value || '')
setCurrentFileDir(dir)
// 设置文件服务器 Base URL
const isRemote = connectionManager.isRemote()
const base = isRemote
? (connectionManager.getFileServerBaseURL().replace(/\/$/, '') + '/api/v1/proxy/localfs')
: 'http://localhost:8073/localfs'
setFileServerBase(base)
return marked.parse(content) as string
} catch (error) { } catch (error) {
console.error('Markdown 解析失败:', error) console.error('Markdown 解析失败:', error)
return fileContent.value || '' return fileContent.value || ''
@@ -399,10 +414,23 @@ const handleRefresh = async () => {
await loadDirectory(filePath.value) await loadDirectory(filePath.value)
} }
// 连接切换后重置路径(仅监听状态变化以刷新快捷入口,不做自动导航)
connectionManager.onStateChange(async (state) => {
if (state === 'connected') {
await loadCommonPaths()
}
})
const handleSearchKeywordUpdate = (keyword: string) => { const handleSearchKeywordUpdate = (keyword: string) => {
searchKeyword.value = keyword searchKeyword.value = keyword
} }
// 用户主动切换连接时重置到根路径
const handleConnectionChanged = async () => {
await loadCommonPaths()
await navigate(connectionManager.isRemote() ? '/' : 'C:/')
}
const handleGoToPath = async (path: string) => { const handleGoToPath = async (path: string) => {
await navigate(path) await navigate(path)
} }
@@ -500,6 +528,26 @@ const handleShowMessage = (message: string, type: 'success' | 'error' | 'warning
// 侧边栏事件 // 侧边栏事件
const handleOpenFavorite = async (file: FavoriteFile) => { const handleOpenFavorite = async (file: FavoriteFile) => {
// 根据路径格式自动切换连接Linux 路径 → 远程Windows 路径 → 本地)
const isLinuxPath = /^[\/]/.test(file.path) && !/^[A-Za-z]:/.test(file.path)
const shouldBeRemote = isLinuxPath
const isCurrentlyRemote = connectionManager.isRemote()
if (shouldBeRemote !== isCurrentlyRemote) {
// 需要切换连接
if (shouldBeRemote) {
// 切换到远程:找第一个 remote profile
const remoteProfile = connectionManager.profiles.find(p => p.type === 'remote')
if (remoteProfile) {
connectionManager.connect(remoteProfile.id)
}
} else {
// 切换到本地
connectionManager.disconnect()
}
await loadCommonPaths()
}
if (file.isDir) { if (file.isDir) {
await navigate(file.path) await navigate(file.path)
} else { } else {
@@ -1024,6 +1072,9 @@ const selectFile = async (path: string) => {
// 加载文件内容 // 加载文件内容
await loadFileContent(path) await loadFileContent(path)
// 记住上次打开的文件
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE, path)
} }
const loadFileContent = async (path: string) => { const loadFileContent = async (path: string) => {
@@ -1178,7 +1229,7 @@ const extractZipImageAndPreview = async (zipPath: string, filePath: string): Pro
const temp = await fileOps.extractZipFileToTemp(zipPath, filePath) const temp = await fileOps.extractZipFileToTemp(zipPath, filePath)
const url = await fileOps.getFileServerURL() const url = await fileOps.getFileServerURL()
const normalized = temp.replace(/\\/g, '/') const normalized = temp.replace(/\\/g, '/')
updatePreviewUrl(`${url}/localfs/${encodeURIComponent(normalized)}`) updatePreviewUrl(normalized)
} catch (error) { } catch (error) {
console.error('提取图片失败:', error) console.error('提取图片失败:', error)
Message.error(`提取图片失败: ${error}`) Message.error(`提取图片失败: ${error}`)
@@ -1217,18 +1268,32 @@ const handleHorizontalResize = createResizeHandler(
// ========== 生命周期 ========== // ========== 生命周期 ==========
onMounted(() => { onMounted(async () => {
// 加载系统路径 // 加载系统路径(阻塞,确保快捷入口就绪)
loadCommonPaths() await loadCommonPaths()
// 初始化加载 // 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径
if (!filePath.value) { const startPath = connectionManager.isRemote() ? '/'
// 设置默认路径 : (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/')
const defaultPath = commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:\\' if (filePath.value && !connectionManager.isRemote()) {
filePath.value = defaultPath await loadDirectory(filePath.value)
loadDirectory(defaultPath)
} else { } else {
loadDirectory(filePath.value) filePath.value = startPath
await loadDirectory(startPath)
}
// 恢复上次打开的文件
const lastFile = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
if (lastFile) {
const normalized = lastFile.replace(/\\/g, '/').replace(/\/+$/, '')
const currentDir = filePath.value.replace(/\\/g, '/').replace(/\/+$/, '')
const lastFileDir = normalized.substring(0, normalized.lastIndexOf('/')) || '/'
if (lastFileDir.toLowerCase() === currentDir.toLowerCase()) {
const found = fileList.value.find(f => f.path.replace(/\\/g, '/').toLowerCase() === normalized.toLowerCase())
if (found && !found.isDir) {
await selectFile(found.path)
}
}
} }
// 添加键盘快捷键 // 添加键盘快捷键
@@ -1497,7 +1562,7 @@ watch(() => themeStore.isDark, async () => {
} }
.resizer { .resizer {
width: 4px; width: 3px;
background: var(--color-border); background: var(--color-border);
cursor: col-resize; cursor: col-resize;
transition: background 0.2s; transition: background 0.2s;

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

View File

@@ -30,6 +30,7 @@ export const STORAGE_KEYS = {
SORT: 'app-filesystem-sort', // 排序状态 SORT: 'app-filesystem-sort', // 排序状态
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序) COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐 SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
}, },
// 设备测试模块 // 设备测试模块
@@ -154,6 +155,7 @@ export const FILE_ICONS = {
RUBY: '💎', RUBY: '💎',
DART: '🎯', DART: '🎯',
DOCKERFILE: '🐳', DOCKERFILE: '🐳',
VUE: '💚',
// 数据库 // 数据库
DATABASE: '🗄️', DATABASE: '🗄️',
@@ -270,6 +272,8 @@ const initIconMap = () => {
'dart': FILE_ICONS.DART, 'dart': FILE_ICONS.DART,
// Dockerfile // Dockerfile
'dockerfile': FILE_ICONS.DOCKERFILE, 'dockerfile': FILE_ICONS.DOCKERFILE,
// Vue
'vue': FILE_ICONS.VUE,
} }
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext])) Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))

View File

@@ -100,6 +100,80 @@ renderer.heading = function(token: any) {
</h${depth}>` </h${depth}>`
} }
// ========== 图片相对路径转换支持 ==========
// 当前 Markdown 文件所在目录(由调用方在渲染前设置)
let _currentFileDir: string = ''
// 文件服务器 Base URL由调用方在渲染前设置
let _fileServerBase: string = 'http://localhost:8073/localfs'
/**
* 设置当前 Markdown 文件所在目录(用于图片相对路径→文件服务器 URL 转换)
* @param dir 文件所在目录的绝对路径,如 "D:/docs" 或 "/"(根目录)
*/
export function setCurrentFileDir(dir: string): void {
_currentFileDir = dir
}
/** 获取当前设置的文件目录 */
export function getCurrentFileDir(): string {
return _currentFileDir
}
/**
* 设置文件服务器 Base URL用于图片相对路径转换
* @param base 完整的 base URL 前缀,如 "http://localhost:8073/localfs" 或 "https://host:port/api/v1/proxy/localfs"
*/
export function setFileServerBase(base: string): void {
_fileServerBase = base
}
/**
* 将相对路径图片 src 解析为文件服务器 URL
* - 绝对路径Windows: D:/...、Unix: /usr/...、网络URL、data URI → 不转换
* - 相对路径 → 基于当前文件目录解析为绝对路径,再编码为文件服务器 URL
*/
function resolveImageUrl(src: string, fileServerBase: string): string {
if (!src) return src
// 不转换绝对路径Windows 盘符、网络协议、锚点、data URI
if (/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) return src
// 解析相对路径(处理 ../ 和 ./
const dir = _currentFileDir || '/'
const sep = dir.includes('\\') ? '\\' : '/'
let resolved = normalizeRelativePath(dir, src, sep)
// 编码路径(保留 / 分隔符)
const encoded = encodeURIComponent(resolved).replace(/%2F/gi, '/').replace(/%5C/gi, '\\')
return `${fileServerBase}/${encoded}`
}
/**
* 规范化相对路径,处理 .. 和 . 段
*/
function normalizeRelativePath(base: string, relative: string, sep: string): string {
// 确保基础路径不以分隔符结尾
let baseNormalized = base.replace(/[\\/]+$/, '')
if (!baseNormalized) baseNormalized = sep === '/' ? '/' : 'C:\\'
const baseParts = baseNormalized.split(sep).filter(Boolean)
const relParts = relative.split(/[\\/]/).filter(Boolean)
for (const part of relParts) {
if (part === '..') {
baseParts.pop() // 向上一级
} else if (part !== '.') {
baseParts.push(part)
}
}
// 重建路径Windows 绝对路径保留盘符前缀
if (/^[a-zA-Z]:$/i.test(baseNormalized.split(sep)[0] || '')) {
return baseParts.join(sep)
}
// Unix 风格:以 / 开头
return sep + baseParts.join(sep)
}
// 判断是否为本地文件链接(相对路径或本地绝对路径) // 判断是否为本地文件链接(相对路径或本地绝对路径)
const isLocalFileLink = (href: string): boolean => { const isLocalFileLink = (href: string): boolean => {
if (!href) return false if (!href) return false
@@ -108,6 +182,23 @@ const isLocalFileLink = (href: string): boolean => {
return true return true
} }
// 自定义图片渲染器 - 转换相对路径为文件服务器 URL
renderer.image = function(token: any) {
const src = token.href || ''
const title = token.title || ''
const alt = token.text || ''
const titleAttr = title ? ` title="${title}"` : ''
// 判断是否需要转换(仅处理相对路径,且当前目录已设置)
if (_currentFileDir && !/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) {
const resolvedSrc = resolveImageUrl(src, _fileServerBase)
return `<img src="${resolvedSrc}" alt="${alt}"${titleAttr}>`
}
// 默认渲染(绝对路径 / 网络 URL / data URI / 未设置目录时原样输出)
return `<img src="${src}" alt="${alt}"${titleAttr}>`
}
// 自定义链接渲染器 - 支持本地文件链接 // 自定义链接渲染器 - 支持本地文件链接
renderer.link = function(token: any) { renderer.link = function(token: any) {
const href = token.href || '' const href = token.href || ''
@@ -126,7 +217,7 @@ renderer.link = function(token: any) {
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>` return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
} }
marked.use({ renderer, breaks: true, gfm: true }) marked.use({ renderer, breaks: true, gfm: true, async: false })
export { marked } export { marked }

View File

@@ -1,7 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {filesystem} from '../models'; import {filesystem} from '../models';
import {api} from '../models';
import {main} from '../models'; import {main} from '../models';
export function CheckUpdate():Promise<Record<string, any>>; export function CheckUpdate():Promise<Record<string, any>>;
@@ -12,22 +11,16 @@ export function CreateDir(arg1:string):Promise<filesystem.FileOperationResult>;
export function CreateFile(arg1:string):Promise<filesystem.FileOperationResult>; export function CreateFile(arg1:string):Promise<filesystem.FileOperationResult>;
export function DeleteDbConnection(arg1:number):Promise<void>;
export function DeletePath(arg1:string):Promise<filesystem.FileOperationResult>; export function DeletePath(arg1:string):Promise<filesystem.FileOperationResult>;
export function DeletePermanently(arg1:string):Promise<void>; export function DeletePermanently(arg1:string):Promise<void>;
export function DeleteResultHistory(arg1:number):Promise<void>;
export function DetectFileTypeByContent(arg1:string):Promise<Record<string, any>>; export function DetectFileTypeByContent(arg1:string):Promise<Record<string, any>>;
export function DownloadUpdate(arg1:string):Promise<Record<string, any>>; export function DownloadUpdate(arg1:string):Promise<Record<string, any>>;
export function EmptyRecycleBin():Promise<void>; export function EmptyRecycleBin():Promise<void>;
export function ExecuteSQL(arg1:number,arg2:string,arg3:string):Promise<Record<string, any>>;
export function ExportPDF(arg1:string,arg2:string,arg3:string,arg4:number,arg5:number,arg6:number):Promise<Record<string, any>>; export function ExportPDF(arg1:string,arg2:string,arg3:string,arg4:number,arg5:number,arg6:number):Promise<Record<string, any>>;
export function ExtractFileFromZip(arg1:string,arg2:string):Promise<string>; export function ExtractFileFromZip(arg1:string,arg2:string):Promise<string>;
@@ -44,8 +37,6 @@ export function GetCommonPaths():Promise<Record<string, string>>;
export function GetCurrentVersion():Promise<Record<string, any>>; export function GetCurrentVersion():Promise<Record<string, any>>;
export function GetDatabases(arg1:number):Promise<Array<string>>;
export function GetDiskInfo():Promise<Array<Record<string, any>>>; export function GetDiskInfo():Promise<Array<Record<string, any>>>;
export function GetEnvVars():Promise<Record<string, string>>; export function GetEnvVars():Promise<Record<string, string>>;
@@ -54,22 +45,12 @@ export function GetFileInfo(arg1:string):Promise<Record<string, any>>;
export function GetFileServerURL():Promise<string>; export function GetFileServerURL():Promise<string>;
export function GetIndexes(arg1:number,arg2:string,arg3:string):Promise<Array<Record<string, any>>>;
export function GetMemoryInfo():Promise<Record<string, any>>; export function GetMemoryInfo():Promise<Record<string, any>>;
export function GetRecycleBinEntries():Promise<Array<Record<string, any>>>; export function GetRecycleBinEntries():Promise<Array<Record<string, any>>>;
export function GetResultHistory(arg1:any,arg2:string,arg3:number,arg4:number):Promise<Record<string, any>>;
export function GetResultHistoryByID(arg1:number):Promise<Record<string, any>>;
export function GetSystemInfo():Promise<Record<string, any>>; export function GetSystemInfo():Promise<Record<string, any>>;
export function GetTableStructure(arg1:number,arg2:string,arg3:string):Promise<Record<string, any>>;
export function GetTables(arg1:number,arg2:string):Promise<Array<string>>;
export function GetUpdateConfig():Promise<Record<string, any>>; export function GetUpdateConfig():Promise<Record<string, any>>;
export function GetZipFileInfo(arg1:string,arg2:string):Promise<Record<string, any>>; export function GetZipFileInfo(arg1:string,arg2:string):Promise<Record<string, any>>;
@@ -78,20 +59,12 @@ export function InstallUpdate(arg1:string,arg2:boolean):Promise<Record<string, a
export function InstallUpdateWithHash(arg1:string,arg2:boolean,arg3:string,arg4:string):Promise<Record<string, any>>; export function InstallUpdateWithHash(arg1:string,arg2:boolean,arg3:string,arg4:string):Promise<Record<string, any>>;
export function ListDbConnections():Promise<Array<Record<string, any>>>;
export function ListDir(arg1:string):Promise<Array<Record<string, any>>>; export function ListDir(arg1:string):Promise<Array<Record<string, any>>>;
export function ListSqlTabs():Promise<Array<Record<string, any>>>;
export function ListZipContents(arg1:string):Promise<Array<Record<string, any>>>; export function ListZipContents(arg1:string):Promise<Array<Record<string, any>>>;
export function LoadAllDatabases(arg1:api.LoadAllDatabasesRequest):Promise<Array<string>>;
export function OpenPath(arg1:string):Promise<void>; export function OpenPath(arg1:string):Promise<void>;
export function PreviewTableStructure(arg1:number,arg2:string,arg3:string,arg4:Record<string, any>):Promise<Array<string>>;
export function ReadFile(arg1:string):Promise<string>; export function ReadFile(arg1:string):Promise<string>;
export function Reload():Promise<void>; export function Reload():Promise<void>;
@@ -106,22 +79,10 @@ export function SaveAppConfig(arg1:main.SaveAppConfigRequest):Promise<Record<str
export function SaveBase64File(arg1:main.SaveBase64FileRequest):Promise<void>; export function SaveBase64File(arg1:main.SaveBase64FileRequest):Promise<void>;
export function SaveDbConnection(arg1:api.SaveConnectionRequest):Promise<void>;
export function SaveResult(arg1:number,arg2:string,arg3:string,arg4:string,arg5:any,arg6:Array<string>,arg7:number,arg8:number):Promise<Record<string, any>>;
export function SaveSqlTabs(arg1:Array<Record<string, any>>):Promise<void>;
export function SelectPDFSaveDirectory():Promise<string>; export function SelectPDFSaveDirectory():Promise<string>;
export function SetUpdateConfig(arg1:boolean,arg2:number,arg3:string):Promise<Record<string, any>>; export function SetUpdateConfig(arg1:boolean,arg2:number,arg3:string):Promise<Record<string, any>>;
export function TestDbConnection(arg1:number):Promise<void>;
export function TestDbConnectionWithParams(arg1:api.TestConnectionRequest):Promise<void>;
export function UpdateTableStructure(arg1:number,arg2:string,arg3:string,arg4:Record<string, any>):Promise<Array<string>>;
export function VerifyUpdateFile(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>; export function VerifyUpdateFile(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
export function WindowClose():Promise<void>; export function WindowClose():Promise<void>;

View File

@@ -18,10 +18,6 @@ export function CreateFile(arg1) {
return window['go']['main']['App']['CreateFile'](arg1); return window['go']['main']['App']['CreateFile'](arg1);
} }
export function DeleteDbConnection(arg1) {
return window['go']['main']['App']['DeleteDbConnection'](arg1);
}
export function DeletePath(arg1) { export function DeletePath(arg1) {
return window['go']['main']['App']['DeletePath'](arg1); return window['go']['main']['App']['DeletePath'](arg1);
} }
@@ -30,10 +26,6 @@ export function DeletePermanently(arg1) {
return window['go']['main']['App']['DeletePermanently'](arg1); return window['go']['main']['App']['DeletePermanently'](arg1);
} }
export function DeleteResultHistory(arg1) {
return window['go']['main']['App']['DeleteResultHistory'](arg1);
}
export function DetectFileTypeByContent(arg1) { export function DetectFileTypeByContent(arg1) {
return window['go']['main']['App']['DetectFileTypeByContent'](arg1); return window['go']['main']['App']['DetectFileTypeByContent'](arg1);
} }
@@ -46,10 +38,6 @@ export function EmptyRecycleBin() {
return window['go']['main']['App']['EmptyRecycleBin'](); return window['go']['main']['App']['EmptyRecycleBin']();
} }
export function ExecuteSQL(arg1, arg2, arg3) {
return window['go']['main']['App']['ExecuteSQL'](arg1, arg2, arg3);
}
export function ExportPDF(arg1, arg2, arg3, arg4, arg5, arg6) { export function ExportPDF(arg1, arg2, arg3, arg4, arg5, arg6) {
return window['go']['main']['App']['ExportPDF'](arg1, arg2, arg3, arg4, arg5, arg6); return window['go']['main']['App']['ExportPDF'](arg1, arg2, arg3, arg4, arg5, arg6);
} }
@@ -82,10 +70,6 @@ export function GetCurrentVersion() {
return window['go']['main']['App']['GetCurrentVersion'](); return window['go']['main']['App']['GetCurrentVersion']();
} }
export function GetDatabases(arg1) {
return window['go']['main']['App']['GetDatabases'](arg1);
}
export function GetDiskInfo() { export function GetDiskInfo() {
return window['go']['main']['App']['GetDiskInfo'](); return window['go']['main']['App']['GetDiskInfo']();
} }
@@ -102,10 +86,6 @@ export function GetFileServerURL() {
return window['go']['main']['App']['GetFileServerURL'](); return window['go']['main']['App']['GetFileServerURL']();
} }
export function GetIndexes(arg1, arg2, arg3) {
return window['go']['main']['App']['GetIndexes'](arg1, arg2, arg3);
}
export function GetMemoryInfo() { export function GetMemoryInfo() {
return window['go']['main']['App']['GetMemoryInfo'](); return window['go']['main']['App']['GetMemoryInfo']();
} }
@@ -114,26 +94,10 @@ export function GetRecycleBinEntries() {
return window['go']['main']['App']['GetRecycleBinEntries'](); return window['go']['main']['App']['GetRecycleBinEntries']();
} }
export function GetResultHistory(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['GetResultHistory'](arg1, arg2, arg3, arg4);
}
export function GetResultHistoryByID(arg1) {
return window['go']['main']['App']['GetResultHistoryByID'](arg1);
}
export function GetSystemInfo() { export function GetSystemInfo() {
return window['go']['main']['App']['GetSystemInfo'](); return window['go']['main']['App']['GetSystemInfo']();
} }
export function GetTableStructure(arg1, arg2, arg3) {
return window['go']['main']['App']['GetTableStructure'](arg1, arg2, arg3);
}
export function GetTables(arg1, arg2) {
return window['go']['main']['App']['GetTables'](arg1, arg2);
}
export function GetUpdateConfig() { export function GetUpdateConfig() {
return window['go']['main']['App']['GetUpdateConfig'](); return window['go']['main']['App']['GetUpdateConfig']();
} }
@@ -150,34 +114,18 @@ export function InstallUpdateWithHash(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['InstallUpdateWithHash'](arg1, arg2, arg3, arg4); return window['go']['main']['App']['InstallUpdateWithHash'](arg1, arg2, arg3, arg4);
} }
export function ListDbConnections() {
return window['go']['main']['App']['ListDbConnections']();
}
export function ListDir(arg1) { export function ListDir(arg1) {
return window['go']['main']['App']['ListDir'](arg1); return window['go']['main']['App']['ListDir'](arg1);
} }
export function ListSqlTabs() {
return window['go']['main']['App']['ListSqlTabs']();
}
export function ListZipContents(arg1) { export function ListZipContents(arg1) {
return window['go']['main']['App']['ListZipContents'](arg1); return window['go']['main']['App']['ListZipContents'](arg1);
} }
export function LoadAllDatabases(arg1) {
return window['go']['main']['App']['LoadAllDatabases'](arg1);
}
export function OpenPath(arg1) { export function OpenPath(arg1) {
return window['go']['main']['App']['OpenPath'](arg1); return window['go']['main']['App']['OpenPath'](arg1);
} }
export function PreviewTableStructure(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['PreviewTableStructure'](arg1, arg2, arg3, arg4);
}
export function ReadFile(arg1) { export function ReadFile(arg1) {
return window['go']['main']['App']['ReadFile'](arg1); return window['go']['main']['App']['ReadFile'](arg1);
} }
@@ -206,18 +154,6 @@ export function SaveBase64File(arg1) {
return window['go']['main']['App']['SaveBase64File'](arg1); return window['go']['main']['App']['SaveBase64File'](arg1);
} }
export function SaveDbConnection(arg1) {
return window['go']['main']['App']['SaveDbConnection'](arg1);
}
export function SaveResult(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) {
return window['go']['main']['App']['SaveResult'](arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8);
}
export function SaveSqlTabs(arg1) {
return window['go']['main']['App']['SaveSqlTabs'](arg1);
}
export function SelectPDFSaveDirectory() { export function SelectPDFSaveDirectory() {
return window['go']['main']['App']['SelectPDFSaveDirectory'](); return window['go']['main']['App']['SelectPDFSaveDirectory']();
} }
@@ -226,18 +162,6 @@ export function SetUpdateConfig(arg1, arg2, arg3) {
return window['go']['main']['App']['SetUpdateConfig'](arg1, arg2, arg3); return window['go']['main']['App']['SetUpdateConfig'](arg1, arg2, arg3);
} }
export function TestDbConnection(arg1) {
return window['go']['main']['App']['TestDbConnection'](arg1);
}
export function TestDbConnectionWithParams(arg1) {
return window['go']['main']['App']['TestDbConnectionWithParams'](arg1);
}
export function UpdateTableStructure(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['UpdateTableStructure'](arg1, arg2, arg3, arg4);
}
export function VerifyUpdateFile(arg1, arg2, arg3) { export function VerifyUpdateFile(arg1, arg2, arg3) {
return window['go']['main']['App']['VerifyUpdateFile'](arg1, arg2, arg3); return window['go']['main']['App']['VerifyUpdateFile'](arg1, arg2, arg3);
} }

View File

@@ -18,88 +18,6 @@ export namespace api {
this.enabled = source["enabled"]; this.enabled = source["enabled"];
} }
} }
export class LoadAllDatabasesRequest {
id: number;
type: string;
host: string;
port: number;
username: string;
password: string;
database: string;
options: string;
static createFrom(source: any = {}) {
return new LoadAllDatabasesRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
this.username = source["username"];
this.password = source["password"];
this.database = source["database"];
this.options = source["options"];
}
}
export class SaveConnectionRequest {
id: number;
name: string;
type: string;
host: string;
port: number;
username: string;
password: string;
database: string;
options: string;
visible_databases: string;
static createFrom(source: any = {}) {
return new SaveConnectionRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
this.username = source["username"];
this.password = source["password"];
this.database = source["database"];
this.options = source["options"];
this.visible_databases = source["visible_databases"];
}
}
export class TestConnectionRequest {
id: number;
type: string;
host: string;
port: number;
username: string;
password: string;
database: string;
options: string;
static createFrom(source: any = {}) {
return new TestConnectionRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
this.username = source["username"];
this.password = source["password"];
this.database = source["database"];
this.options = source["options"];
}
}
} }