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

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

View File

@@ -1,3 +1,6 @@
//go:build windows
// +build windows
package filesystem
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
}