新增:SFTP直连+连接池+autoConnect+文件服务器端口自动回退
- SFTP模块:连接/断开/文件CRUD/系统信息采集/base64二进制写入 - 连接池:多服务器同时在线,瞬间切换profile - autoConnect:启动时自动连接所有非本地服务器 - 端口自动回退:listenWithFallback消除TOCTOU,解决端口冲突崩溃 - 文件服务器URL集中管理:file-server.ts消除8+处硬编码端口 - Sidebar设置面板:添加服务器/自动连接/自动刷新开关 - 修复:validateFilePath越界panic、正则预编译 - 修复:注释准确性(RemoveAll/端口8073/动态端口文档)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ build/linux/appimage/build
|
|||||||
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||||
.idea/
|
.idea/
|
||||||
.claude/
|
.claude/
|
||||||
|
u-desk.exe
|
||||||
|
u-fs-agent-linux
|
||||||
|
|||||||
244
app.go
244
app.go
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
stdruntime "runtime"
|
stdruntime "runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -18,7 +19,9 @@ import (
|
|||||||
"u-desk/internal/common"
|
"u-desk/internal/common"
|
||||||
"u-desk/internal/filesystem"
|
"u-desk/internal/filesystem"
|
||||||
"u-desk/internal/service"
|
"u-desk/internal/service"
|
||||||
|
"u-desk/internal/sftp"
|
||||||
"u-desk/internal/storage"
|
"u-desk/internal/storage"
|
||||||
|
"u-desk/internal/storage/models"
|
||||||
"u-desk/internal/system"
|
"u-desk/internal/system"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
@@ -34,6 +37,7 @@ type App struct {
|
|||||||
configAPI *api.ConfigAPI
|
configAPI *api.ConfigAPI
|
||||||
pdfAPI *api.PdfAPI
|
pdfAPI *api.PdfAPI
|
||||||
filesystem *filesystem.FileSystemService
|
filesystem *filesystem.FileSystemService
|
||||||
|
sftpService *sftp.Service
|
||||||
isAlwaysOnTop bool
|
isAlwaysOnTop bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -94,7 +98,10 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
|
|||||||
return fmt.Errorf("模块初始化失败: %w", err)
|
return fmt.Errorf("模块初始化失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 异步初始化:UpdateAPI(涉及网络请求,完全异步)
|
// 5. 清理遗留的 SFTP 临时预览文件
|
||||||
|
sftp.CleanupTempFiles()
|
||||||
|
|
||||||
|
// 6. 异步初始化:UpdateAPI(涉及网络请求,完全异步)
|
||||||
go func() {
|
go func() {
|
||||||
if updateAPI, err := api.NewUpdateAPI("https://c.1216.top/last-version.json"); err == nil {
|
if updateAPI, err := api.NewUpdateAPI("https://c.1216.top/last-version.json"); err == nil {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
@@ -175,7 +182,7 @@ func (a *App) startFileServer() {
|
|||||||
fmt.Printf("[文件服务器] 启动失败: %v\n", err)
|
fmt.Printf("[文件服务器] 启动失败: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println("[文件服务器] 启动在 http://localhost:8073")
|
fmt.Printf("[文件服务器] 启动在 http://%s\n", filesystem.GetLocalFileServerAddr())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceShutdown Wails v3 服务关闭生命周期(替代 v2 的 Shutdown)
|
// ServiceShutdown Wails v3 服务关闭生命周期(替代 v2 的 Shutdown)
|
||||||
@@ -202,6 +209,13 @@ func (a *App) ServiceShutdown() error {
|
|||||||
} else {
|
} else {
|
||||||
fmt.Println("[文件服务器] 已关闭")
|
fmt.Println("[文件服务器] 已关闭")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关闭所有 SFTP 连接 + 清理临时文件
|
||||||
|
if a.sftpService != nil {
|
||||||
|
sftp.GetManager().Shutdown()
|
||||||
|
}
|
||||||
|
sftp.CleanupTempFiles()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,7 +671,7 @@ func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
|
|||||||
|
|
||||||
// GetFileServerURL 获取本地文件服务器的URL
|
// GetFileServerURL 获取本地文件服务器的URL
|
||||||
func (a *App) GetFileServerURL() string {
|
func (a *App) GetFileServerURL() string {
|
||||||
return "http://localhost:8073"
|
return fmt.Sprintf("http://%s", filesystem.GetLocalFileServerAddr())
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectFileTypeByContent 通过文件内容检测文件类型
|
// DetectFileTypeByContent 通过文件内容检测文件类型
|
||||||
@@ -822,3 +836,227 @@ func (a *App) SelectPDFSaveDirectory() (string, error) {
|
|||||||
|
|
||||||
return a.pdfAPI.SelectDirectory()
|
return a.pdfAPI.SelectDirectory()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== SFTP 接口 ==========
|
||||||
|
|
||||||
|
func (a *App) ensureSftpService() *sftp.Service {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.sftpService == nil {
|
||||||
|
a.sftpService = sftp.NewService()
|
||||||
|
}
|
||||||
|
return a.sftpService
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpConnectRequest SFTP 连接请求
|
||||||
|
type SftpConnectRequest struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
KeyPath string `json:"key_path"`
|
||||||
|
KeyPassphrase string `json:"key_passphrase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpConnect 建立 SFTP 连接,返回连接标识符 connID
|
||||||
|
func (a *App) SftpConnect(req SftpConnectRequest) (string, error) {
|
||||||
|
config := &sftp.Config{
|
||||||
|
Host: req.Host,
|
||||||
|
Port: req.Port,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
KeyPath: req.KeyPath,
|
||||||
|
KeyPassphrase: req.KeyPassphrase,
|
||||||
|
}
|
||||||
|
if config.Port == 0 { config.Port = 22 }
|
||||||
|
if config.Timeout == 0 { config.Timeout = 15 * time.Second }
|
||||||
|
|
||||||
|
svc := a.ensureSftpService()
|
||||||
|
_, err := svc.GetManager().Connect(config)
|
||||||
|
if err != nil {
|
||||||
|
return "", sftp.ToUserMessage(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
connID := sftp.ConnID(config.Host, config.Port)
|
||||||
|
return connID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpDisconnect 断开 SFTP 连接
|
||||||
|
func (a *App) SftpDisconnect(connID string) error {
|
||||||
|
parts := strings.SplitN(connID, ":", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fmt.Errorf("无效的连接标识符")
|
||||||
|
}
|
||||||
|
host := parts[0]
|
||||||
|
port, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("无效的端口号")
|
||||||
|
}
|
||||||
|
|
||||||
|
sftp.GetManager().Disconnect(host, port)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpListDir SFTP 列出目录
|
||||||
|
func (a *App) SftpListDir(connID string, dirPath string) ([]map[string]interface{}, error) {
|
||||||
|
return a.ensureSftpService().ListDir(connID, dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpReadFile SFTP 读取文件内容
|
||||||
|
func (a *App) SftpReadFile(connID string, filePath string) (string, error) {
|
||||||
|
return a.ensureSftpService().ReadFile(connID, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpWriteFileRequest SFTP 写入请求
|
||||||
|
type SftpWriteFileRequest struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpWriteFile SFTP 写入文件
|
||||||
|
func (a *App) SftpWriteFile(req SftpWriteFileRequest) error {
|
||||||
|
return a.ensureSftpService().WriteFile(req.SessionID, req.Path, req.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等)
|
||||||
|
func (a *App) SftpWriteBase64File(sessionID, filePath, base64Content string) error {
|
||||||
|
return a.ensureSftpService().WriteBase64File(sessionID, filePath, base64Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpGetFileInfo SFTP 获取文件信息
|
||||||
|
func (a *App) SftpGetFileInfo(connID string, filePath string) (map[string]interface{}, error) {
|
||||||
|
return a.ensureSftpService().GetFileInfo(connID, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpCreateDir SFTP 创建目录
|
||||||
|
func (a *App) SftpCreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureSftpService().CreateDir(connID, dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpCreateFile SFTP 创建文件
|
||||||
|
func (a *App) SftpCreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureSftpService().CreateFile(connID, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpDeletePath SFTP 删除文件或目录
|
||||||
|
func (a *App) SftpDeletePath(connID string, filePath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureSftpService().DeletePath(connID, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpRenamePathRequest SFTP 重命名请求
|
||||||
|
type SftpRenamePathRequest struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
OldPath string `json:"old_path"`
|
||||||
|
NewPath string `json:"new_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpRenamePath SFTP 重命名文件或目录
|
||||||
|
func (a *App) SftpRenamePath(req SftpRenamePathRequest) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureSftpService().RenamePath(req.SessionID, req.OldPath, req.NewPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
||||||
|
func (a *App) SftpDownloadToTemp(connID string, remotePath string) (string, error) {
|
||||||
|
return a.ensureSftpService().DownloadToTemp(connID, remotePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpGetCommonPaths 获取 SFTP 远程主机常用路径
|
||||||
|
func (a *App) SftpGetCommonPaths(connID string) (map[string]string, error) {
|
||||||
|
return a.ensureSftpService().GetCommonPaths(connID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpGetSystemInfo 获取 SFTP 远程主机系统信息(CPU/内存/磁盘)
|
||||||
|
func (a *App) SftpGetSystemInfo(connID string) (map[string]interface{}, error) {
|
||||||
|
return a.ensureSftpService().GetSystemInfo(connID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 连接配置 CRUD (SQLite 持久化) ---
|
||||||
|
|
||||||
|
type SaveProfileRequest struct {
|
||||||
|
ID *uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
KeyPath string `json:"key_path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
LastConnected *int64 `json:"last_connected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var profileSvc *service.ProfileService
|
||||||
|
|
||||||
|
func (a *App) ensureProfileSvc() *service.ProfileService {
|
||||||
|
if profileSvc == nil {
|
||||||
|
profileSvc = service.NewProfileService()
|
||||||
|
}
|
||||||
|
return profileSvc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) {
|
||||||
|
list, err := a.ensureProfileSvc().ListProfiles()
|
||||||
|
if err != nil { return nil, err }
|
||||||
|
result := make([]map[string]interface{}, len(list))
|
||||||
|
for i, p := range list {
|
||||||
|
result[i] = map[string]interface{}{
|
||||||
|
"id": float64(p.ID),
|
||||||
|
"name": p.Name,
|
||||||
|
"host": p.Host,
|
||||||
|
"port": p.Port,
|
||||||
|
"username": p.Username,
|
||||||
|
"password": p.Password,
|
||||||
|
"keyPath": p.KeyPath,
|
||||||
|
"type": p.Type,
|
||||||
|
"token": p.Token,
|
||||||
|
"lastConnected": p.LastConnected,
|
||||||
|
"sortOrder": float64(p.SortOrder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveConnectionProfile(req SaveProfileRequest) (map[string]interface{}, error) {
|
||||||
|
p := &models.ConnectionProfile{
|
||||||
|
Name: req.Name, Host: req.Host, Port: req.Port,
|
||||||
|
Username: req.Username, Password: req.Password,
|
||||||
|
KeyPath: req.KeyPath, Type: req.Type, Token: req.Token,
|
||||||
|
}
|
||||||
|
if req.LastConnected != nil {
|
||||||
|
t := time.Unix(*req.LastConnected, 0)
|
||||||
|
p.LastConnected = &t
|
||||||
|
}
|
||||||
|
if req.ID != nil {
|
||||||
|
p.ID = *req.ID
|
||||||
|
}
|
||||||
|
if err := a.ensureProfileSvc().SaveProfile(p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"id": float64(p.ID), "success": true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteConnectionProfile(id uint) error {
|
||||||
|
return a.ensureProfileSvc().DeleteProfile(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetLocalSystemInfo() (map[string]interface{}, error) {
|
||||||
|
info := make(map[string]interface{})
|
||||||
|
|
||||||
|
cpuInfo, err := system.GetCPUInfo()
|
||||||
|
if err == nil && cpuInfo != nil {
|
||||||
|
if v, ok := cpuInfo["usage"].(string); ok { info["cpu_usage"] = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
memInfo, err := system.GetMemoryInfo()
|
||||||
|
if err == nil && memInfo != nil {
|
||||||
|
if v, ok := memInfo["usage"].(string); ok { info["mem_usage"] = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
diskInfos, err := system.GetDiskInfo()
|
||||||
|
if err == nil && len(diskInfos) > 0 {
|
||||||
|
if v, ok := diskInfos[0]["usage"].(string); ok { info["disk_usage"] = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ func main() {
|
|||||||
{
|
{
|
||||||
sys.GET("/common-paths", h.CommonPaths)
|
sys.GET("/common-paths", h.CommonPaths)
|
||||||
sys.GET("/drives", h.Drives)
|
sys.GET("/drives", h.Drives)
|
||||||
|
sys.GET("/stats", h.Stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy := api.Group("/proxy")
|
proxy := api.Group("/proxy")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ log:
|
|||||||
format: "json" # json / text
|
format: "json" # json / text
|
||||||
|
|
||||||
file_server:
|
file_server:
|
||||||
port: 8073 # 内置文件服务器端口(用于媒体预览代理)
|
port: 2652 # 内置文件服务器端口(用于媒体预览代理)
|
||||||
max_file_size: 524288000 # 最大文件大小 500MB
|
max_file_size: 524288000 # 最大文件大小 500MB
|
||||||
|
|
||||||
security:
|
security:
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export function CreateFile(path: string): $CancellablePromise<filesystem$0.FileO
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DeleteConnectionProfile(id: number): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(2675016907, id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DeletePath 删除文件或目录
|
* DeletePath 删除文件或目录
|
||||||
*/
|
*/
|
||||||
@@ -198,6 +202,12 @@ export function GetFileServerURL(): $CancellablePromise<string> {
|
|||||||
return $Call.ByID(4117667287);
|
return $Call.ByID(4117667287);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLocalSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||||
|
return $Call.ByID(2203542363).then(($result: any) => {
|
||||||
|
return $$createType0($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GetMemoryInfo 获取内存信息
|
* GetMemoryInfo 获取内存信息
|
||||||
*/
|
*/
|
||||||
@@ -279,6 +289,12 @@ export function ListZipContents(zipPath: string): $CancellablePromise<{ [_ in st
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||||
|
return $Call.ByID(454364767).then(($result: any) => {
|
||||||
|
return $$createType3($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenPath 使用系统默认程序打开文件或目录
|
* OpenPath 使用系统默认程序打开文件或目录
|
||||||
*/
|
*/
|
||||||
@@ -341,6 +357,12 @@ export function SaveBase64File(req: $models.SaveBase64FileRequest): $Cancellable
|
|||||||
return $Call.ByID(1355120553, req);
|
return $Call.ByID(1355120553, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveConnectionProfile(req: $models.SaveProfileRequest): $CancellablePromise<{ [_ in string]?: any }> {
|
||||||
|
return $Call.ByID(3622685069, req).then(($result: any) => {
|
||||||
|
return $$createType0($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SelectPDFSaveDirectory 选择PDF保存目录
|
* SelectPDFSaveDirectory 选择PDF保存目录
|
||||||
*/
|
*/
|
||||||
@@ -371,6 +393,120 @@ export function SetWindowTitleBarColor(color: number, isDark: boolean): $Cancell
|
|||||||
return $Call.ByID(1570627619, color, isDark);
|
return $Call.ByID(1570627619, color, isDark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpConnect 建立 SFTP 连接,返回连接标识符 connID
|
||||||
|
*/
|
||||||
|
export function SftpConnect(req: $models.SftpConnectRequest): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(2742828454, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpCreateDir SFTP 创建目录
|
||||||
|
*/
|
||||||
|
export function SftpCreateDir(connID: string, dirPath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||||
|
return $Call.ByID(586600875, connID, dirPath).then(($result: any) => {
|
||||||
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpCreateFile SFTP 创建文件
|
||||||
|
*/
|
||||||
|
export function SftpCreateFile(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||||
|
return $Call.ByID(623026146, connID, filePath).then(($result: any) => {
|
||||||
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpDeletePath SFTP 删除文件或目录
|
||||||
|
*/
|
||||||
|
export function SftpDeletePath(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||||
|
return $Call.ByID(1833619836, connID, filePath).then(($result: any) => {
|
||||||
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpDisconnect 断开 SFTP 连接
|
||||||
|
*/
|
||||||
|
export function SftpDisconnect(connID: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(597628874, connID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
||||||
|
*/
|
||||||
|
export function SftpDownloadToTemp(connID: string, remotePath: string): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(1159267603, connID, remotePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpGetCommonPaths 获取 SFTP 远程主机常用路径
|
||||||
|
*/
|
||||||
|
export function SftpGetCommonPaths(connID: string): $CancellablePromise<{ [_ in string]?: string }> {
|
||||||
|
return $Call.ByID(2874386183, connID).then(($result: any) => {
|
||||||
|
return $$createType4($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpGetFileInfo SFTP 获取文件信息
|
||||||
|
*/
|
||||||
|
export function SftpGetFileInfo(connID: string, filePath: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||||
|
return $Call.ByID(1959840482, connID, filePath).then(($result: any) => {
|
||||||
|
return $$createType0($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpGetSystemInfo 获取 SFTP 远程主机系统信息(CPU/内存/磁盘)
|
||||||
|
*/
|
||||||
|
export function SftpGetSystemInfo(connID: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||||
|
return $Call.ByID(1950143653, connID).then(($result: any) => {
|
||||||
|
return $$createType0($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpListDir SFTP 列出目录
|
||||||
|
*/
|
||||||
|
export function SftpListDir(connID: string, dirPath: string): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||||
|
return $Call.ByID(2061863855, connID, dirPath).then(($result: any) => {
|
||||||
|
return $$createType3($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpReadFile SFTP 读取文件内容
|
||||||
|
*/
|
||||||
|
export function SftpReadFile(connID: string, filePath: string): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(3068590994, connID, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpRenamePath SFTP 重命名文件或目录
|
||||||
|
*/
|
||||||
|
export function SftpRenamePath(req: $models.SftpRenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||||
|
return $Call.ByID(183173937, req).then(($result: any) => {
|
||||||
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等)
|
||||||
|
*/
|
||||||
|
export function SftpWriteBase64File(sessionID: string, filePath: string, base64Content: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(139141998, sessionID, filePath, base64Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpWriteFile SFTP 写入文件
|
||||||
|
*/
|
||||||
|
export function SftpWriteFile(req: $models.SftpWriteFileRequest): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(2401472593, req);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VerifyUpdateFile 验证更新文件哈希值
|
* VerifyUpdateFile 验证更新文件哈希值
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,5 +10,9 @@ export {
|
|||||||
RenamePathRequest,
|
RenamePathRequest,
|
||||||
SaveAppConfigRequest,
|
SaveAppConfigRequest,
|
||||||
SaveBase64FileRequest,
|
SaveBase64FileRequest,
|
||||||
|
SaveProfileRequest,
|
||||||
|
SftpConnectRequest,
|
||||||
|
SftpRenamePathRequest,
|
||||||
|
SftpWriteFileRequest,
|
||||||
WriteFileRequest
|
WriteFileRequest
|
||||||
} from "./models.js";
|
} from "./models.js";
|
||||||
|
|||||||
@@ -109,6 +109,171 @@ export class SaveBase64FileRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SaveProfileRequest {
|
||||||
|
"id": number | null;
|
||||||
|
"name": string;
|
||||||
|
"host": string;
|
||||||
|
"port": number;
|
||||||
|
"username": string;
|
||||||
|
"password": string;
|
||||||
|
"key_path": string;
|
||||||
|
"type": string;
|
||||||
|
"token": string;
|
||||||
|
"last_connected": number | null;
|
||||||
|
|
||||||
|
/** Creates a new SaveProfileRequest instance. */
|
||||||
|
constructor($$source: Partial<SaveProfileRequest> = {}) {
|
||||||
|
if (!("id" in $$source)) {
|
||||||
|
this["id"] = null;
|
||||||
|
}
|
||||||
|
if (!("name" in $$source)) {
|
||||||
|
this["name"] = "";
|
||||||
|
}
|
||||||
|
if (!("host" in $$source)) {
|
||||||
|
this["host"] = "";
|
||||||
|
}
|
||||||
|
if (!("port" in $$source)) {
|
||||||
|
this["port"] = 0;
|
||||||
|
}
|
||||||
|
if (!("username" in $$source)) {
|
||||||
|
this["username"] = "";
|
||||||
|
}
|
||||||
|
if (!("password" in $$source)) {
|
||||||
|
this["password"] = "";
|
||||||
|
}
|
||||||
|
if (!("key_path" in $$source)) {
|
||||||
|
this["key_path"] = "";
|
||||||
|
}
|
||||||
|
if (!("type" in $$source)) {
|
||||||
|
this["type"] = "";
|
||||||
|
}
|
||||||
|
if (!("token" in $$source)) {
|
||||||
|
this["token"] = "";
|
||||||
|
}
|
||||||
|
if (!("last_connected" in $$source)) {
|
||||||
|
this["last_connected"] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(this, $$source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new SaveProfileRequest instance from a string or object.
|
||||||
|
*/
|
||||||
|
static createFrom($$source: any = {}): SaveProfileRequest {
|
||||||
|
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||||
|
return new SaveProfileRequest($$parsedSource as Partial<SaveProfileRequest>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpConnectRequest SFTP 连接请求
|
||||||
|
*/
|
||||||
|
export class SftpConnectRequest {
|
||||||
|
"host": string;
|
||||||
|
"port": number;
|
||||||
|
"username": string;
|
||||||
|
"password": string;
|
||||||
|
"key_path": string;
|
||||||
|
"key_passphrase": string;
|
||||||
|
|
||||||
|
/** Creates a new SftpConnectRequest instance. */
|
||||||
|
constructor($$source: Partial<SftpConnectRequest> = {}) {
|
||||||
|
if (!("host" in $$source)) {
|
||||||
|
this["host"] = "";
|
||||||
|
}
|
||||||
|
if (!("port" in $$source)) {
|
||||||
|
this["port"] = 0;
|
||||||
|
}
|
||||||
|
if (!("username" in $$source)) {
|
||||||
|
this["username"] = "";
|
||||||
|
}
|
||||||
|
if (!("password" in $$source)) {
|
||||||
|
this["password"] = "";
|
||||||
|
}
|
||||||
|
if (!("key_path" in $$source)) {
|
||||||
|
this["key_path"] = "";
|
||||||
|
}
|
||||||
|
if (!("key_passphrase" in $$source)) {
|
||||||
|
this["key_passphrase"] = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(this, $$source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new SftpConnectRequest instance from a string or object.
|
||||||
|
*/
|
||||||
|
static createFrom($$source: any = {}): SftpConnectRequest {
|
||||||
|
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||||
|
return new SftpConnectRequest($$parsedSource as Partial<SftpConnectRequest>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpRenamePathRequest SFTP 重命名请求
|
||||||
|
*/
|
||||||
|
export class SftpRenamePathRequest {
|
||||||
|
"session_id": string;
|
||||||
|
"old_path": string;
|
||||||
|
"new_path": string;
|
||||||
|
|
||||||
|
/** Creates a new SftpRenamePathRequest instance. */
|
||||||
|
constructor($$source: Partial<SftpRenamePathRequest> = {}) {
|
||||||
|
if (!("session_id" in $$source)) {
|
||||||
|
this["session_id"] = "";
|
||||||
|
}
|
||||||
|
if (!("old_path" in $$source)) {
|
||||||
|
this["old_path"] = "";
|
||||||
|
}
|
||||||
|
if (!("new_path" in $$source)) {
|
||||||
|
this["new_path"] = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(this, $$source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new SftpRenamePathRequest instance from a string or object.
|
||||||
|
*/
|
||||||
|
static createFrom($$source: any = {}): SftpRenamePathRequest {
|
||||||
|
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||||
|
return new SftpRenamePathRequest($$parsedSource as Partial<SftpRenamePathRequest>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpWriteFileRequest SFTP 写入请求
|
||||||
|
*/
|
||||||
|
export class SftpWriteFileRequest {
|
||||||
|
"session_id": string;
|
||||||
|
"path": string;
|
||||||
|
"content": string;
|
||||||
|
|
||||||
|
/** Creates a new SftpWriteFileRequest instance. */
|
||||||
|
constructor($$source: Partial<SftpWriteFileRequest> = {}) {
|
||||||
|
if (!("session_id" in $$source)) {
|
||||||
|
this["session_id"] = "";
|
||||||
|
}
|
||||||
|
if (!("path" in $$source)) {
|
||||||
|
this["path"] = "";
|
||||||
|
}
|
||||||
|
if (!("content" in $$source)) {
|
||||||
|
this["content"] = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(this, $$source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new SftpWriteFileRequest instance from a string or object.
|
||||||
|
*/
|
||||||
|
static createFrom($$source: any = {}): SftpWriteFileRequest {
|
||||||
|
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||||
|
return new SftpWriteFileRequest($$parsedSource as Partial<SftpWriteFileRequest>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WriteFileRequest 写入文件请求结构体
|
* WriteFileRequest 写入文件请求结构体
|
||||||
*/
|
*/
|
||||||
|
|||||||
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
@@ -2138,7 +2138,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-12.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-12.0.0.tgz",
|
||||||
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chevrotain/cst-dts-gen": "12.0.0",
|
"@chevrotain/cst-dts-gen": "12.0.0",
|
||||||
"@chevrotain/gast": "12.0.0",
|
"@chevrotain/gast": "12.0.0",
|
||||||
@@ -2311,7 +2310,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.3.tgz",
|
"resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.3.tgz",
|
||||||
"integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==",
|
"integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
@@ -2712,7 +2710,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -3737,7 +3734,6 @@
|
|||||||
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
|
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -4186,7 +4182,6 @@
|
|||||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -4310,7 +4305,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.33.tgz",
|
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.33.tgz",
|
||||||
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
|
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.33",
|
"@vue/compiler-dom": "3.5.33",
|
||||||
"@vue/compiler-sfc": "3.5.33",
|
"@vue/compiler-sfc": "3.5.33",
|
||||||
|
|||||||
@@ -93,8 +93,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||||
import {IconSettings, IconPushpin} from '@arco-design/web-vue/es/icon'
|
import {IconSettings, IconPushpin} from '@arco-design/web-vue/es/icon'
|
||||||
import MarkdownEditor from './views/markdown-editor/index.vue'
|
import MarkdownEditor from './components/MarkdownEditorPage.vue'
|
||||||
import VersionHistory from './views/version/index.vue'
|
import VersionHistory from './components/VersionHistory.vue'
|
||||||
import ThemeToggle from './components/ThemeToggle.vue'
|
import ThemeToggle from './components/ThemeToggle.vue'
|
||||||
import FileSystem from './components/FileSystem/index.vue'
|
import FileSystem from './components/FileSystem/index.vue'
|
||||||
import SettingsPanel from './components/SettingsPanel.vue'
|
import SettingsPanel from './components/SettingsPanel.vue'
|
||||||
|
|||||||
@@ -1,39 +1,61 @@
|
|||||||
/**
|
/**
|
||||||
* 连接管理器 — 管理本地/远程传输层切换
|
* 连接管理器 — 管理本地/远程/SFTP 传输层切换
|
||||||
|
* 配置持久化:SQLite(后端),local profile 仅内存
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FsTransport } from './transport'
|
import type { FsTransport } from './transport'
|
||||||
import { WailsTransport } from './wails-transport'
|
import { WailsTransport } from './wails-transport'
|
||||||
import { HttpTransport } from './http-transport'
|
import { HttpTransport } from './http-transport'
|
||||||
|
import { SftpTransport } from './sftp-transport'
|
||||||
|
import { getFileServerBaseURL } from './file-server'
|
||||||
|
import {
|
||||||
|
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
|
||||||
|
SftpGetSystemInfo, GetLocalSystemInfo,
|
||||||
|
} from '@bindings/u-desk/app'
|
||||||
|
|
||||||
export type ConnectionType = 'local' | 'remote'
|
export type ConnectionType = 'local' | 'remote' | 'sftp'
|
||||||
|
|
||||||
export interface ConnectionProfile {
|
export interface ConnectionProfile {
|
||||||
id: string
|
id: string | number
|
||||||
name: string
|
name: string
|
||||||
host: string
|
host: string
|
||||||
port: number
|
port: number
|
||||||
token: string
|
token: string
|
||||||
type: ConnectionType
|
type: ConnectionType
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
keyPath?: string
|
||||||
lastConnected?: number
|
lastConnected?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||||
|
|
||||||
const PROFILES_KEY = 'fs_connection_profiles'
|
export interface SystemInfo {
|
||||||
const ACTIVE_KEY = 'fs_active_connection'
|
cpuCores?: number
|
||||||
|
cpuUsage?: string
|
||||||
|
memTotal?: number
|
||||||
|
memUsed?: number
|
||||||
|
memUsage?: string
|
||||||
|
diskTotal?: number
|
||||||
|
diskUsed?: number
|
||||||
|
diskUsage?: string
|
||||||
|
_error?: boolean
|
||||||
|
_errorMsg?: string
|
||||||
|
}
|
||||||
|
|
||||||
class ConnectionManagerImpl {
|
class ConnectionManagerImpl {
|
||||||
private _transport: FsTransport | null = null
|
/** 连接池:profileId → transport 实例 */
|
||||||
|
private _pool = new Map<string, FsTransport>()
|
||||||
private _profiles: ConnectionProfile[] = []
|
private _profiles: ConnectionProfile[] = []
|
||||||
private _activeId: string | null = null
|
private _activeId: string | null = null
|
||||||
private _state: ConnectionState = 'disconnected'
|
private _state: ConnectionState = 'disconnected'
|
||||||
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
|
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
|
||||||
private _connectSeq = 0
|
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
|
||||||
|
private _sysInfoCache = new Map<string, SystemInfo>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadProfiles()
|
|
||||||
this.initDefaultLocal()
|
this.initDefaultLocal()
|
||||||
|
this.loadFromDB()
|
||||||
}
|
}
|
||||||
|
|
||||||
private initDefaultLocal() {
|
private initDefaultLocal() {
|
||||||
@@ -48,26 +70,68 @@ class ConnectionManagerImpl {
|
|||||||
if (!this._profiles.find(p => p.id === localProfile.id)) {
|
if (!this._profiles.find(p => p.id === localProfile.id)) {
|
||||||
this._profiles.unshift(localProfile)
|
this._profiles.unshift(localProfile)
|
||||||
}
|
}
|
||||||
// 默认连接本地
|
|
||||||
if (!this._activeId) {
|
if (!this._activeId) {
|
||||||
this._activeId = localProfile.id
|
this._activeId = localProfile.id
|
||||||
}
|
}
|
||||||
this.applyActive()
|
// local 直接入池,无需连接
|
||||||
|
this._pool.set('local-default', new WailsTransport())
|
||||||
|
this.setState('connected')
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadProfiles() {
|
/** 从 SQLite 加载配置 */
|
||||||
|
private async loadFromDB() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(PROFILES_KEY)
|
const list = await LoadConnectionProfiles()
|
||||||
if (raw) this._profiles = JSON.parse(raw)
|
if (list && list.length > 0) {
|
||||||
this._activeId = localStorage.getItem(ACTIVE_KEY)
|
this._profiles = list.map((p: any) => ({
|
||||||
|
...p,
|
||||||
|
id: String(p.id),
|
||||||
|
lastConnected: p.lastConnected || p.last_connected ? new Date(p.lastConnected || p.last_connected).getTime() : undefined,
|
||||||
|
}))
|
||||||
|
const hasLocal = this._profiles.some(p => p.type === 'local')
|
||||||
|
if (!hasLocal) {
|
||||||
|
this._profiles.unshift({
|
||||||
|
id: 'local-default', name: '本地', host: '', port: 0, token: '', type: 'local',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch { /* 首次使用 */ }
|
} catch { /* 首次使用 */ }
|
||||||
|
this.notifyChange()
|
||||||
|
this._profiles.forEach(p => {
|
||||||
|
if (p.type === 'local') { this.fetchSystemInfo(p.id).catch(() => {}) }
|
||||||
|
})
|
||||||
|
const autoConnect = localStorage.getItem('desk:autoConnect')
|
||||||
|
if (autoConnect !== 'false') {
|
||||||
|
for (const p of this._profiles) {
|
||||||
|
if (p.type !== 'local') {
|
||||||
|
this.buildAndPool(String(p.id), p).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveProfiles() {
|
/** 保存/更新单个 profile 到 SQLite */
|
||||||
localStorage.setItem(PROFILES_KEY, JSON.stringify(this._profiles))
|
private async persistProfile(profile: ConnectionProfile) {
|
||||||
if (this._activeId) {
|
if (profile.type === 'local') return
|
||||||
localStorage.setItem(ACTIVE_KEY, this._activeId)
|
const id = profile.id !== 'local-default' ? Number(profile.id) : 0
|
||||||
}
|
await SaveConnectionProfile({
|
||||||
|
id: id > 0 ? id : undefined,
|
||||||
|
name: profile.name,
|
||||||
|
host: profile.host,
|
||||||
|
port: profile.port,
|
||||||
|
username: profile.username || 'root',
|
||||||
|
password: profile.password || '',
|
||||||
|
keyPath: profile.keyPath || '',
|
||||||
|
type: profile.type,
|
||||||
|
token: profile.token || '',
|
||||||
|
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 SQLite 删除 profile */
|
||||||
|
private async removePersisted(id: string) {
|
||||||
|
if (id === 'local-default') return
|
||||||
|
await DeleteConnectionProfile(Number(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
private setState(state: ConnectionState) {
|
private setState(state: ConnectionState) {
|
||||||
@@ -83,117 +147,236 @@ class ConnectionManagerImpl {
|
|||||||
this._stateChangeCallbacks.push(cb)
|
this._stateChangeCallbacks.push(cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
get state(): ConnectionState {
|
onSystemInfoChange(cb: (profileId: string, info: SystemInfo) => void) {
|
||||||
return this._state
|
this._sysInfoChangeCallbacks.push(cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
get profiles(): ConnectionProfile[] {
|
get state(): ConnectionState { return this._state }
|
||||||
return [...this._profiles]
|
|
||||||
}
|
get profiles(): ConnectionProfile[] { return [...this._profiles] }
|
||||||
|
|
||||||
get activeProfile(): ConnectionProfile | null {
|
get activeProfile(): ConnectionProfile | null {
|
||||||
return this._profiles.find(p => p.id === this._activeId) ?? null
|
return this._profiles.find(p => p.id === this._activeId) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取当前激活的 transport(从池中取) */
|
||||||
getTransport(): FsTransport {
|
getTransport(): FsTransport {
|
||||||
if (!this._transport) {
|
if (this._activeId && this._pool.has(this._activeId)) {
|
||||||
this.applyActive()
|
return this._pool.get(this._activeId)!
|
||||||
}
|
}
|
||||||
return this._transport!
|
return this._pool.get('local-default')!
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取指定 profile 的 transport(用于跨 profile 操作如采集系统信息) */
|
||||||
|
getTransportFor(profileId: string): FsTransport | null {
|
||||||
|
return this._pool.get(profileId) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileServerBaseURL(): string {
|
getFileServerBaseURL(): string {
|
||||||
if (this._transport instanceof HttpTransport) {
|
const t = this.getTransport()
|
||||||
|
if (t instanceof HttpTransport) {
|
||||||
const profile = this.activeProfile
|
const profile = this.activeProfile
|
||||||
if (!profile) return ''
|
if (!profile) return ''
|
||||||
const scheme = profile.port === 443 ? 'https' : 'http'
|
const scheme = profile.port === 443 ? 'https' : 'http'
|
||||||
const port = (profile.port === 80 || profile.port === 443) ? '' : `:${profile.port}`
|
const port = (profile.port === 80 || profile.port === 443) ? '' : `:${profile.port}`
|
||||||
return `${scheme}://${profile.host}${port}`
|
return `${scheme}://${profile.host}${port}`
|
||||||
}
|
}
|
||||||
// Wails 模式返回空字符串,让 useFilePreview 走原有逻辑
|
if (t instanceof SftpTransport) { return getFileServerBaseURL() }
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSftp(): boolean { return this.activeProfile?.type === 'sftp' }
|
||||||
|
|
||||||
isRemote(): boolean {
|
isRemote(): boolean {
|
||||||
return this.activeProfile?.type === 'remote'
|
const t = this.activeProfile?.type
|
||||||
|
return t === 'remote' || t === 'sftp'
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(profileId: string): void {
|
getSystemInfo(profileId: string): SystemInfo | undefined {
|
||||||
|
return this._sysInfoCache.get(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchSystemInfo(profileId: string): Promise<SystemInfo> {
|
||||||
const profile = this._profiles.find(p => p.id === profileId)
|
const profile = this._profiles.find(p => p.id === profileId)
|
||||||
if (!profile) return
|
if (!profile) return { _error: true }
|
||||||
|
const info: SystemInfo = {}
|
||||||
|
try {
|
||||||
|
if (profile.type === 'local') {
|
||||||
|
const data = await GetLocalSystemInfo()
|
||||||
|
if (data) Object.assign(info, data)
|
||||||
|
} else if (profile.type === 'sftp') {
|
||||||
|
const t = this.getTransportFor(profileId)
|
||||||
|
if (t instanceof SftpTransport && t.sessionId) {
|
||||||
|
const data = await SftpGetSystemInfo(t.sessionId)
|
||||||
|
if (data) Object.assign(info, snakeToCamel(data))
|
||||||
|
}
|
||||||
|
} else if (profile.type === 'remote') {
|
||||||
|
const t = this.getTransportFor(profileId)
|
||||||
|
if (t instanceof HttpTransport) {
|
||||||
|
const baseUrl = this.getFileServerBaseURL()
|
||||||
|
if (baseUrl) {
|
||||||
|
const token = profile.token || ''
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
const res = await fetch(`${baseUrl}/api/v1/system/stats`, { headers })
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json()
|
||||||
|
const data = json.data ?? json
|
||||||
|
if (data) Object.assign(info, snakeToCamel(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { info._error = true; info._errorMsg = e instanceof Error ? e.message : String(e) }
|
||||||
|
|
||||||
|
this._sysInfoCache.set(profileId, info)
|
||||||
|
this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info))
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换到指定 profile(连接池模式)
|
||||||
|
* 池中有 → 直接复用,瞬间切换
|
||||||
|
* 池中无 → 新建连接并存入池
|
||||||
|
*/
|
||||||
|
async connect(profileId: string): Promise<void> {
|
||||||
|
const profile = this._profiles.find(p => p.id === profileId)
|
||||||
|
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
|
||||||
|
|
||||||
this._activeId = profileId
|
this._activeId = profileId
|
||||||
this.saveProfiles()
|
|
||||||
this.applyActive()
|
// 池中已有,直接复用
|
||||||
|
if (this._pool.has(profileId)) {
|
||||||
|
this.setState('connected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建连接并入池
|
||||||
|
await this.buildAndPool(profileId, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 断开指定 profile 并从池移除 */
|
||||||
|
disconnectProfile(profileId: string): void {
|
||||||
|
if (profileId === 'local-default') return
|
||||||
|
const t = this._pool.get(profileId)
|
||||||
|
if (t instanceof SftpTransport) { t.disconnect() }
|
||||||
|
this._pool.delete(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 断开所有远程连接(保留 local) */
|
||||||
|
disconnectAll(): void {
|
||||||
|
for (const [id, t] of this._pool) {
|
||||||
|
if (id !== 'local-default' && t instanceof SftpTransport) {
|
||||||
|
t.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._pool.clear()
|
||||||
|
this._pool.set('local-default', new WailsTransport())
|
||||||
|
this._activeId = 'local-default'
|
||||||
|
this.setState('connected')
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this._activeId = 'local-default'
|
this.disconnectAll()
|
||||||
this.saveProfiles()
|
|
||||||
this.applyActive()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addProfile(profile: Omit<ConnectionProfile, 'id'>): ConnectionProfile {
|
async addProfile(profile: Omit<ConnectionProfile, 'id'>): Promise<ConnectionProfile> {
|
||||||
const newProfile: ConnectionProfile = {
|
const newProfile: ConnectionProfile = { ...profile, id: crypto.randomUUID() }
|
||||||
...profile,
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
}
|
|
||||||
this._profiles.push(newProfile)
|
this._profiles.push(newProfile)
|
||||||
this.saveProfiles()
|
await this.persistProfile(newProfile)
|
||||||
this.notifyChange()
|
this.notifyChange()
|
||||||
return newProfile
|
return newProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProfile(id: string, updates: Partial<ConnectionProfile>): void {
|
async updateProfile(id: string, updates: Partial<ConnectionProfile>): Promise<void> {
|
||||||
const idx = this._profiles.findIndex(p => p.id === id)
|
const idx = this._profiles.findIndex(p => p.id === id)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
this._profiles[idx] = { ...this._profiles[idx], ...updates }
|
this._profiles[idx] = { ...this._profiles[idx], ...updates }
|
||||||
this.saveProfiles()
|
await this.persistProfile(this._profiles[idx])
|
||||||
// 仅当连接参数变化时重新应用(避免 lastConnected 等元数据更新触发死循环)
|
// 连接参数变更 → 淘汰旧连接,下次 connect 时重建
|
||||||
const REAPPLY_KEYS = ['host', 'port', 'token'] as const
|
const EVICT_KEYS = ['host', 'port', 'token', 'username', 'password', 'keyPath'] as const
|
||||||
const needsReapply = REAPPLY_KEYS.some(k => k in updates)
|
if (EVICT_KEYS.some(k => k in updates)) {
|
||||||
if (needsReapply && id === this._activeId) {
|
this.disconnectProfile(id)
|
||||||
this.applyActive()
|
if (id === this._activeId) {
|
||||||
|
this.connect(id).catch(err => console.warn('[SFTP] 编辑后重连失败:', err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.notifyChange()
|
this.notifyChange()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeProfile(id: string): void {
|
async removeProfile(id: string): Promise<void> {
|
||||||
if (id === 'local-default') return // 不允许删除本地配置
|
if (id === 'local-default') return
|
||||||
this._profiles = this._profiles.filter(p => p.id !== id)
|
this._profiles = this._profiles.filter(p => p.id !== id)
|
||||||
|
this.disconnectProfile(id)
|
||||||
if (this._activeId === id) {
|
if (this._activeId === id) {
|
||||||
this._activeId = 'local-default'
|
this._activeId = 'local-default'
|
||||||
|
this.setState('connected')
|
||||||
}
|
}
|
||||||
this.saveProfiles()
|
await this.removePersisted(id)
|
||||||
this.applyActive()
|
|
||||||
this.notifyChange()
|
this.notifyChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyActive() {
|
/** 新建 transport 并存入连接池 */
|
||||||
const profile = this.activeProfile
|
private async buildAndPool(profileId: string, profile: ConnectionProfile): Promise<void> {
|
||||||
const seq = ++this._connectSeq
|
if (profile.type === 'local') {
|
||||||
if (!profile || profile.type === 'local') {
|
this._pool.set(profileId, new WailsTransport())
|
||||||
this._transport = new WailsTransport()
|
|
||||||
this.setState('connected')
|
this.setState('connected')
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.type === 'sftp') {
|
||||||
|
if (!profile.password && !profile.keyPath) {
|
||||||
|
this._pool.set(profileId, new WailsTransport())
|
||||||
|
this.setState('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
this.setState('connecting')
|
this.setState('connecting')
|
||||||
try {
|
try {
|
||||||
this._transport = new HttpTransport(profile.host, profile.port, profile.token)
|
const t = new SftpTransport(profile)
|
||||||
// 快速连通性检查(用轻量 ping 代替 getCommonPaths)
|
await t.connect()
|
||||||
this._transport.getFileInfo('/').then(() => {
|
this._pool.set(profileId, t)
|
||||||
if (seq !== this._connectSeq) return // 已被后续连接覆盖
|
this.setState('connected')
|
||||||
this.setState('connected')
|
this.updateProfile(profileId, { lastConnected: Date.now() })
|
||||||
this.updateProfile(profile.id!, { lastConnected: Date.now() })
|
this.fetchSystemInfo(profileId).catch(() => {})
|
||||||
}).catch(() => {
|
} catch (err) {
|
||||||
if (seq !== this._connectSeq) return
|
this._pool.delete(profileId)
|
||||||
this.setState('error')
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
this.setState('error')
|
this.setState('error')
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// remote / http agent
|
||||||
|
this.setState('connecting')
|
||||||
|
try {
|
||||||
|
const t = new HttpTransport(profile.host, profile.port, profile.token)
|
||||||
|
this._pool.set(profileId, t)
|
||||||
|
// 验证连接可用性
|
||||||
|
t.getFileInfo('/').then(() => {
|
||||||
|
if (this._activeId !== profileId) return
|
||||||
|
this.setState('connected')
|
||||||
|
this.updateProfile(profileId, { lastConnected: Date.now() })
|
||||||
|
this.fetchSystemInfo(profileId).catch(() => {})
|
||||||
|
}).catch(() => {
|
||||||
|
if (this._activeId !== profileId) return
|
||||||
|
this.setState('error')
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
this._pool.delete(profileId)
|
||||||
|
this.setState('error')
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const connectionManager = new ConnectionManagerImpl()
|
export const connectionManager = new ConnectionManagerImpl()
|
||||||
|
|
||||||
|
/** snake_case → camelCase */
|
||||||
|
function snakeToCamel(obj: Record<string, any>): Record<string, any> {
|
||||||
|
const result: Record<string, any> = {}
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
result[k.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
39
frontend/src/api/file-server.ts
Normal file
39
frontend/src/api/file-server.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 文件服务器 URL 集中管理
|
||||||
|
* 单一数据源:从后端 GetFileServerURL() 获取动态端口
|
||||||
|
* 所有模块统一引用此处,消除硬编码端口号
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GetFileServerURL } from '@bindings/u-desk/app'
|
||||||
|
|
||||||
|
const FALLBACK_URL = 'http://localhost:2652'
|
||||||
|
|
||||||
|
let _cachedURL: string | null = null
|
||||||
|
let _initPromise: Promise<string> | null = null
|
||||||
|
|
||||||
|
/** 初始化(调用一次即可,内部缓存) */
|
||||||
|
export function initFileServerURL(): Promise<string> {
|
||||||
|
if (_cachedURL) return Promise.resolve(_cachedURL)
|
||||||
|
if (_initPromise) return _initPromise
|
||||||
|
_initPromise = GetFileServerURL().then(url => {
|
||||||
|
_cachedURL = url
|
||||||
|
return url
|
||||||
|
}).catch(() => {
|
||||||
|
_cachedURL = FALLBACK_URL
|
||||||
|
return FALLBACK_URL
|
||||||
|
})
|
||||||
|
return _initPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步获取(需先调用过 initFileServerURL,否则返回 fallback) */
|
||||||
|
export function getFileServerBaseURL(): string {
|
||||||
|
return _cachedURL || FALLBACK_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取带 /localfs 后缀的完整 base */
|
||||||
|
export function getLocalFsBaseURL(): string {
|
||||||
|
return `${getFileServerBaseURL()}/localfs`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动时自动初始化 */
|
||||||
|
initFileServerURL().catch(() => {})
|
||||||
199
frontend/src/api/sftp-transport.ts
Normal file
199
frontend/src/api/sftp-transport.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* SFTP Transport — 通过 Wails IPC 调用 Go 后端 SFTP 客户端
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FsTransport, FileItem, FileOperationResult, DetectTypeResult,
|
||||||
|
} from './transport'
|
||||||
|
import type { ConnectionProfile } from './connection-manager'
|
||||||
|
import {
|
||||||
|
SftpConnect, SftpDisconnect, SftpListDir, SftpReadFile,
|
||||||
|
SftpWriteFile, SftpGetFileInfo, SftpCreateDir, SftpCreateFile,
|
||||||
|
SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, SftpGetCommonPaths,
|
||||||
|
SftpWriteBase64File,
|
||||||
|
} from '@bindings/u-desk/app'
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW_CACHE_MAX = 50
|
||||||
|
|
||||||
|
export class SftpTransport implements FsTransport {
|
||||||
|
private profile: ConnectionProfile
|
||||||
|
private connID: string | null = null
|
||||||
|
private previewCache = new Map<string, string>() // remotePath -> localTempPath (LRU)
|
||||||
|
private previewOrder: string[] = [] // LRU 排序键列表
|
||||||
|
|
||||||
|
constructor(profile: ConnectionProfile) {
|
||||||
|
this.profile = profile
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<string> {
|
||||||
|
const result = await SftpConnect({
|
||||||
|
host: this.profile.host,
|
||||||
|
port: this.profile.port || 22,
|
||||||
|
username: this.profile.username || 'root',
|
||||||
|
password: this.profile.password || '',
|
||||||
|
key_path: this.profile.keyPath || '',
|
||||||
|
key_passphrase: '',
|
||||||
|
})
|
||||||
|
this.connID = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
get sessionId(): string | null { return this.connID }
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.connID) {
|
||||||
|
try { await SftpDisconnect(this.connID) } catch (e) {
|
||||||
|
console.warn('[SFTP] disconnect error:', e)
|
||||||
|
}
|
||||||
|
this.connID = null
|
||||||
|
}
|
||||||
|
this.previewCache.clear()
|
||||||
|
this.previewOrder = []
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireConn(): string {
|
||||||
|
if (!this.connID) throw new Error('SFTP 未连接')
|
||||||
|
return this.connID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 文件列表与信息 ======
|
||||||
|
|
||||||
|
async listDir(path: string): Promise<FileItem[]> {
|
||||||
|
return transformFileList(await SftpListDir(this.requireConn(), path))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileInfo(path: string): Promise<Record<string, any>> {
|
||||||
|
return SftpGetFileInfo(this.requireConn(), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 文件读写 ======
|
||||||
|
|
||||||
|
async readFile(path: string): Promise<string> {
|
||||||
|
return SftpReadFile(this.requireConn(), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, content: string): Promise<void> {
|
||||||
|
await SftpWriteFile({
|
||||||
|
session_id: this.requireConn(),
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBase64File(path: string, content: string): Promise<void> {
|
||||||
|
if (!content) throw new Error('无效的 base64 内容')
|
||||||
|
await SftpWriteBase64File(this.requireConn(), path, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 文件操作 ======
|
||||||
|
|
||||||
|
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||||
|
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
||||||
|
return SftpCreateFile(this.requireConn(), fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||||
|
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
||||||
|
return SftpCreateDir(this.requireConn(), fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePath(path: string): Promise<FileOperationResult> {
|
||||||
|
return SftpDeletePath(this.requireConn(), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
|
||||||
|
return SftpRenamePath({
|
||||||
|
session_id: this.requireConn(),
|
||||||
|
old_path: oldPath,
|
||||||
|
new_path: newPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== ZIP 操作(不支持)======
|
||||||
|
|
||||||
|
async listZipContents(_zipPath: string): Promise<FileItem[]> {
|
||||||
|
throw new Error('ZIP 操作在 SFTP 模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
|
||||||
|
throw new Error('ZIP 操作在 SFTP 模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractFileFromZipToTemp(_zipPath: string, _filePath: string): Promise<string> {
|
||||||
|
throw new Error('ZIP 操作在 SFTP 模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getZipFileInfo(_zipPath: string, _filePath: string): Promise<FileItem> {
|
||||||
|
throw new Error('ZIP 操作在 SFTP 模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 系统操作 ======
|
||||||
|
|
||||||
|
async openPath(_path: string): Promise<void> {
|
||||||
|
throw new Error('SFTP 模式不支持打开本地路径')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileServerURL(): Promise<string> {
|
||||||
|
const { getFileServerBaseURL } = await import('./file-server')
|
||||||
|
return getFileServerBaseURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreviewToken(): string {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveShortcut(_lnkPath: string): Promise<any> {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectFileTypeByContent(_path: string): Promise<DetectTypeResult> {
|
||||||
|
return { extension: '', category: 'unknown', mime_type: '', confidence: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommonPaths(): Promise<Record<string, string>> {
|
||||||
|
return SftpGetCommonPaths(this.requireConn())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 回收站(无)======
|
||||||
|
|
||||||
|
async getRecycleBinEntries(): Promise<any[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreFromRecycleBin(_path: string): Promise<void> {}
|
||||||
|
|
||||||
|
async deletePermanently(_path: string): Promise<void> {}
|
||||||
|
|
||||||
|
async emptyRecycleBin(): Promise<void> {}
|
||||||
|
|
||||||
|
// ====== 预览辅助 ======
|
||||||
|
|
||||||
|
/** 下载远程文件到本地临时目录用于预览(带 LRU 缓存,上限 50) */
|
||||||
|
async downloadForPreview(remotePath: string): Promise<string> {
|
||||||
|
// 命中:移到队尾(最近使用)
|
||||||
|
if (this.previewCache.has(remotePath)) {
|
||||||
|
this.previewOrder = this.previewOrder.filter(p => p !== remotePath)
|
||||||
|
this.previewOrder.push(remotePath)
|
||||||
|
return this.previewCache.get(remotePath)!
|
||||||
|
}
|
||||||
|
const localPath = await SftpDownloadToTemp(this.requireConn(), remotePath)
|
||||||
|
|
||||||
|
// 淘汰最旧条目
|
||||||
|
if (this.previewOrder.length >= PREVIEW_CACHE_MAX) {
|
||||||
|
const oldest = this.previewOrder.shift()!
|
||||||
|
this.previewCache.delete(oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previewCache.set(remotePath, localPath)
|
||||||
|
this.previewOrder.push(remotePath)
|
||||||
|
return localPath
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,113 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-modal :visible="props.visible" :title="editingId ? '编辑连接' : '添加服务器'" unmount-on-close @cancel="emit('update:visible', false)" @before-ok="handleOk" :ok-loading="submitting">
|
<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: 10px; max-width: 400px">
|
<div style="display: flex; flex-direction: column; gap: 10px; max-width: 420px">
|
||||||
|
<!-- 连接类型 -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">类型</label>
|
||||||
|
<a-radio-group v-model="form.type" type="button" size="small">
|
||||||
|
<a-radio value="sftp">SFTP</a-radio>
|
||||||
|
<a-radio value="remote">HTTP Agent</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 名称 -->
|
||||||
<div style="display: flex; align-items: center; gap: 8px">
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">名称</label>
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">名称</label>
|
||||||
<a-input v-model="form.name" placeholder="如:生产服务器" style="flex: 1" />
|
<a-input v-model="form.name" placeholder="如:生产服务器" style="flex: 1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 地址 -->
|
||||||
<div style="display: flex; align-items: center; gap: 8px">
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">地址</label>
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">地址</label>
|
||||||
<a-input v-model="form.host" placeholder="192.168.1.100" style="flex: 1" />
|
<a-input v-model="form.host" :placeholder="form.type === 'sftp' ? '192.168.1.100' : '192.168.1.100'" style="flex: 1" />
|
||||||
<a-input-number v-model="form.port" :min="1" :max="65535" placeholder="端口" style="width: 90px" hide-button />
|
<a-input-number v-model="form.port" :min="1" :max="65535" :placeholder="form.type === 'sftp' ? '22' : '9876'" style="width: 90px" hide-button />
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; gap: 8px">
|
|
||||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">Token</label>
|
<!-- SFTP 认证字段 -->
|
||||||
<div style="flex: 1">
|
<template v-if="form.type === 'sftp'">
|
||||||
<a-input v-model="form.token" type="password" placeholder="留空则不认证" allow-clear />
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
<div style="font-size: 11px; color: var(--color-text-3); margin-top: 2px">API 认证令牌(与服务器配置一致)</div>
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">用户</label>
|
||||||
|
<a-input v-model="form.username" placeholder="root" style="flex: 1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">密码</label>
|
||||||
|
<a-input v-model="form.password" type="password" placeholder="登录密码(或使用密钥)" allow-clear style="flex: 1" />
|
||||||
|
</div>
|
||||||
|
<div v-if="editingId && form.type === 'sftp' && !form.password" style="font-size: 11px; color: var(--color-text-3); margin-left: 44px">
|
||||||
|
未填写密码,将使用密钥认证(若密钥也为空则连接将失败)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 暂时隐藏密钥栏
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">密钥</label>
|
||||||
|
<a-input v-model="form.keyPath" placeholder="私钥文件路径(可选,优先于密码)" style="flex: 1" />
|
||||||
|
<a-button size="small" @click="selectKeyFile">浏览</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 11px; color: var(--color-text-3)">
|
||||||
|
支持密码或私钥文件认证,两者填一即可
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- HTTP Agent Token 字段 -->
|
||||||
|
<template v-if="form.type === 'remote'">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">Token</label>
|
||||||
|
<div style="flex: 1">
|
||||||
|
<a-input v-model="form.token" type="password" placeholder="留空则不认证" allow-clear />
|
||||||
|
<div style="font-size: 11px; color: var(--color-text-3); margin-top: 2px">API 认证令牌(与服务器配置一致)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { reactive, ref, watch, onMounted } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import { Dialogs } from '@wailsio/runtime'
|
||||||
|
import { GetEnvVars } from '@bindings/u-desk/app'
|
||||||
import { connectionManager } from '@/api/connection-manager'
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
|
import type { ConnectionType } from '@/api/connection-manager'
|
||||||
|
|
||||||
const props = defineProps<{ visible: boolean }>()
|
const props = defineProps<{ visible: boolean }>()
|
||||||
const emit = defineEmits<{ (e: 'update:visible', val: boolean): void; (e: 'close'): void }>()
|
const emit = defineEmits<{ (e: 'update:visible', val: boolean): void; (e: 'close'): void }>()
|
||||||
|
|
||||||
const editingId = ref<string | null>(null)
|
const editingId = ref<string | null>(null)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
let cachedSshDir = ''
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const env = await GetEnvVars()
|
||||||
|
cachedSshDir = `${env.USERPROFILE || env.HOME || ''}\\.ssh`
|
||||||
|
})
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
host: '',
|
host: '',
|
||||||
port: 9876,
|
port: 22,
|
||||||
token: '',
|
token: '',
|
||||||
|
type: 'sftp' as ConnectionType,
|
||||||
|
username: 'root',
|
||||||
|
password: '',
|
||||||
|
keyPath: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
watch(() => props.visible, (val) => {
|
||||||
if (!val) return
|
if (!val) return
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
Object.assign(form, { name: '', host: '', port: 9876, token: '' })
|
Object.assign(form, {
|
||||||
|
name: '', host: '', port: 22, token: '',
|
||||||
|
type: 'sftp' as ConnectionType,
|
||||||
|
username: 'root', password: '', keyPath: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => form.type, (t) => {
|
||||||
|
form.port = t === 'sftp' ? 22 : 9876
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleOk(): Promise<boolean> {
|
async function handleOk(): Promise<boolean> {
|
||||||
@@ -52,23 +117,46 @@ async function handleOk(): Promise<boolean> {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
connectionManager.updateProfile(editingId.value, { ...form })
|
await connectionManager.updateProfile(editingId.value, { ...form })
|
||||||
Message.success('已更新')
|
Message.success('已更新')
|
||||||
} else {
|
} else {
|
||||||
connectionManager.addProfile({ ...form, type: 'remote' })
|
await connectionManager.addProfile({ ...form, type: form.type })
|
||||||
Message.success('已添加')
|
Message.success('已添加')
|
||||||
}
|
}
|
||||||
|
emit('update:visible', false)
|
||||||
return true
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
Message.error(err instanceof Error ? err.message : '保存失败')
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectKeyFile() {
|
||||||
|
const path = await Dialogs.OpenFile({
|
||||||
|
Title: '选择私钥文件',
|
||||||
|
Directory: cachedSshDir,
|
||||||
|
CanChooseFiles: true,
|
||||||
|
CanChooseDirectories: false,
|
||||||
|
})
|
||||||
|
if (path) form.keyPath = path
|
||||||
|
}
|
||||||
|
|
||||||
function editProfile(id: string) {
|
function editProfile(id: string) {
|
||||||
const profile = connectionManager.profiles.find(p => p.id === id)
|
const profile = connectionManager.profiles.find(p => p.id === id)
|
||||||
if (!profile) return
|
if (!profile) return
|
||||||
editingId.value = id
|
editingId.value = id
|
||||||
Object.assign(form, { name: profile.name, host: profile.host, port: profile.port, token: profile.token || '' })
|
Object.assign(form, {
|
||||||
|
name: profile.name,
|
||||||
|
host: profile.host,
|
||||||
|
port: profile.port,
|
||||||
|
token: profile.token || '',
|
||||||
|
type: profile.type || 'remote',
|
||||||
|
username: profile.username || 'root',
|
||||||
|
password: profile.password || '',
|
||||||
|
keyPath: profile.keyPath || '',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ editProfile })
|
defineExpose({ editProfile })
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
:class="['menu-item', { active: p.id === activeId }]"
|
:class="['menu-item', { active: p.id === activeId }]"
|
||||||
@click="handleSelect(p)"
|
@click="handleSelect(p)"
|
||||||
>
|
>
|
||||||
<span :class="['dot', p.type === 'remote' ? 'remote' : 'local']"></span>
|
<span :class="['dot', dotClass(p)]"></span>
|
||||||
<span class="menu-name">{{ p.name }}</span>
|
<span class="menu-name">{{ p.name }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="p.type === 'remote'"
|
v-if="p.type !== 'local'"
|
||||||
class="more-btn"
|
class="more-btn"
|
||||||
title="更多操作"
|
title="更多操作"
|
||||||
@click.stop="toggleMore(p)"
|
@click.stop="toggleMore(p)"
|
||||||
@@ -40,8 +40,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||||
import { IconCloud } from '@arco-design/web-vue/es/icon'
|
import { IconCloud } from '@arco-design/web-vue/es/icon'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { connectionManager } from '@/api/connection-manager'
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'add'): void; (e: 'select', id: string): void; (e: 'edit', id: string): void }>()
|
const emit = defineEmits<{ (e: 'add'): void; (e: 'select', id: string): void; (e: 'edit', id: string): void }>()
|
||||||
@@ -51,8 +52,8 @@ const moreOpenId = ref<string | null>(null)
|
|||||||
const profiles = shallowRef(connectionManager.profiles)
|
const profiles = shallowRef(connectionManager.profiles)
|
||||||
const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
||||||
|
|
||||||
// 是否有远程 profile(决定显示模式)
|
// 是否有远程/SFTP profile(决定显示模式)
|
||||||
const hasRemote = computed(() => profiles.value.some(p => p.type === 'remote'))
|
const hasRemote = computed(() => profiles.value.some(p => p.type !== 'local'))
|
||||||
|
|
||||||
// 防抖:避免 connecting→connected 快速切换导致闪烁
|
// 防抖:避免 connecting→connected 快速切换导致闪烁
|
||||||
const displayState = ref(connectionManager.state)
|
const displayState = ref(connectionManager.state)
|
||||||
@@ -86,12 +87,19 @@ function handleClickOutside(e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||||
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
if (_stateTimer) clearTimeout(_stateTimer)
|
||||||
|
})
|
||||||
|
|
||||||
function handleSelect(p: { id: string }) {
|
async function handleSelect(p: { id: string }) {
|
||||||
connectionManager.connect(p.id)
|
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
emit('select', p.id)
|
try {
|
||||||
|
await connectionManager.connect(p.id)
|
||||||
|
emit('select', p.id)
|
||||||
|
} catch (err) {
|
||||||
|
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMore(p: { id: string }) {
|
function toggleMore(p: { id: string }) {
|
||||||
@@ -105,9 +113,16 @@ function handleEdit(p: { id: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(p: { id: string; name: string }) {
|
function handleDelete(p: { id: string; name: string }) {
|
||||||
|
if (!window.confirm(`确定删除「${p.name}」?`)) return
|
||||||
connectionManager.removeProfile(p.id)
|
connectionManager.removeProfile(p.id)
|
||||||
moreOpenId.value = null
|
moreOpenId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dotClass(p: { type: string }): string {
|
||||||
|
if (p.type === 'sftp') return 'sftp'
|
||||||
|
if (p.type === 'remote') return 'remote'
|
||||||
|
return 'local'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -160,6 +175,7 @@ function handleDelete(p: { id: string; name: string }) {
|
|||||||
.dot.error { background: var(--color-danger-6); }
|
.dot.error { background: var(--color-danger-6); }
|
||||||
.dot.local { background: var(--color-text-3); }
|
.dot.local { background: var(--color-text-3); }
|
||||||
.dot.remote { background: var(--color-primary-6); }
|
.dot.remote { background: var(--color-primary-6); }
|
||||||
|
.dot.sftp { background: #7c3aed; }
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
max-width: 70px;
|
max-width: 70px;
|
||||||
|
|||||||
@@ -441,9 +441,9 @@ const emit = defineEmits<Emits>()
|
|||||||
|
|
||||||
// HTML 预览 URL(实时从 connectionManager 读取,不缓存)
|
// HTML 预览 URL(实时从 connectionManager 读取,不缓存)
|
||||||
function resolveHtmlPreviewBase(): string {
|
function resolveHtmlPreviewBase(): string {
|
||||||
if (!connectionManager.isRemote()) return 'http://localhost:8073'
|
if (!connectionManager.isRemote()) return 'http://localhost:2652'
|
||||||
const base = connectionManager.getFileServerBaseURL()
|
const base = connectionManager.getFileServerBaseURL()
|
||||||
if (!base) return 'http://localhost:8073'
|
if (!base) return 'http://localhost:2652'
|
||||||
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs(htmlPreviewUrl 会替换为 html-preview)
|
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs(htmlPreviewUrl 会替换为 html-preview)
|
||||||
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||||
}
|
}
|
||||||
@@ -828,11 +828,11 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
|
|||||||
// 处理 HTML iframe 发送的消息(链接点击)
|
// 处理 HTML iframe 发送的消息(链接点击)
|
||||||
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||||
// 安全检查:接受来自本地文件服务器或同源的消息
|
// 安全检查:接受来自本地文件服务器或同源的消息
|
||||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
|
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:2652
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
'null',
|
'null',
|
||||||
resolveHtmlPreviewBase(), // 动态:本地 localhost:8073 或远程代理地址
|
resolveHtmlPreviewBase(), // 动态:本地 localhost:2652 或远程代理地址
|
||||||
]
|
]
|
||||||
if (!allowedOrigins.includes(event.origin)) {
|
if (!allowedOrigins.includes(event.origin)) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,44 +2,61 @@
|
|||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<div v-show="config.visible" class="sidebar">
|
<div v-show="config.visible" class="sidebar">
|
||||||
<!-- 服务器区块 -->
|
<!-- 服务器区块 -->
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section" :class="{ 'section-on-top': settingsOpen || moreOpenId }">
|
||||||
<div class="section-header" @click="serverCollapsed = !serverCollapsed">
|
<div class="section-header" @click="serverCollapsed = !serverCollapsed">
|
||||||
<span class="section-title">🖥️ 服务器</span>
|
<span class="section-title">🖥️ 服务器</span>
|
||||||
<a-tag :color="statusTagColor" size="small">{{ statusLabel }}</a-tag>
|
|
||||||
<icon-down v-if="!serverCollapsed" class="section-toggle" />
|
<icon-down v-if="!serverCollapsed" class="section-toggle" />
|
||||||
<icon-right v-else class="section-toggle" />
|
<icon-right v-else class="section-toggle" />
|
||||||
</div>
|
</div>
|
||||||
<div class="section-content server-content" :class="{ collapsed: serverCollapsed }">
|
<div class="section-content server-content" :class="{ collapsed: serverCollapsed }">
|
||||||
<div class="server-info">
|
<!-- 表头 -->
|
||||||
<div class="server-row">
|
<div class="server-table-head">
|
||||||
<span class="server-label">模式</span>
|
<span class="col-name">名称</span>
|
||||||
<a-tag :color="isRemote ? 'blue' : 'green'" size="small">{{ isRemote ? '远程' : '本地' }}</a-tag>
|
<span class="col-metric">磁盘</span>
|
||||||
</div>
|
<span class="col-metric">CPU</span>
|
||||||
<div v-if="activeProfile" class="server-row">
|
<span class="col-metric">内存</span>
|
||||||
<span class="server-label">服务器</span>
|
<span class="col-action settings-btn" @click.stop="settingsOpen = !settingsOpen" title="管理">···</span>
|
||||||
<span class="server-val">{{ activeProfile.name }}</span>
|
</div>
|
||||||
|
<!-- 表格行 -->
|
||||||
|
<div
|
||||||
|
v-for="p in profiles"
|
||||||
|
:key="p.id"
|
||||||
|
class="server-table-row"
|
||||||
|
:class="{ active: p.id === activeId }"
|
||||||
|
@click="handleSelect(p)"
|
||||||
|
>
|
||||||
|
<span class="col-name" :title="stateText(p.id)"><span :class="['dot', stateDotClass(p.id)]" />{{ p.name }}</span>
|
||||||
|
<span class="col-metric" :title="metricTooltip(p.id, 'disk')">{{ sysInfo(p.id)?.diskUsage || '-' }}</span>
|
||||||
|
<span class="col-metric" :title="metricTooltip(p.id, 'cpu')">{{ sysInfo(p.id)?.cpuUsage || '-' }}</span>
|
||||||
|
<span class="col-metric" :title="metricTooltip(p.id, 'mem')">{{ sysInfo(p.id)?.memUsage || '-' }}</span>
|
||||||
|
<span
|
||||||
|
v-if="p.type !== 'local'"
|
||||||
|
class="col-action more-btn"
|
||||||
|
title="更多操作"
|
||||||
|
@click.stop="toggleMore(p.id)"
|
||||||
|
>···</span>
|
||||||
|
<span v-else class="col-action"></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>
|
</div>
|
||||||
<div class="server-actions">
|
|
||||||
<a-button
|
</div>
|
||||||
v-if="!isRemote"
|
|
||||||
type="outline"
|
<!-- 管理设置面板(放在 section-content/overflow 容器外部) -->
|
||||||
size="mini"
|
<div v-if="settingsOpen" class="settings-panel" @click.stop>
|
||||||
long
|
<div class="settings-item" @click="$emit('openConnectionDialog'); settingsOpen = false">
|
||||||
@click.stop="handleConnectRemote"
|
<icon-plus /> 添加服务器
|
||||||
>
|
</div>
|
||||||
连接远程
|
<div class="settings-item" @click="toggleAutoConnect">
|
||||||
</a-button>
|
<icon-check-circle :style="{ opacity: autoConnect ? 1 : 0.3 }" />
|
||||||
<a-button
|
启动时自动连接所有服务器
|
||||||
v-else
|
</div>
|
||||||
type="outline"
|
<div class="settings-item" @click="toggleAutoRefresh">
|
||||||
status="danger"
|
<icon-check-circle :style="{ opacity: autoRefresh ? 1 : 0.3 }" />
|
||||||
size="mini"
|
自动刷新系统信息 (15s)
|
||||||
long
|
|
||||||
@click.stop="handleDisconnect"
|
|
||||||
>
|
|
||||||
断开连接
|
|
||||||
</a-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,11 +145,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||||
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
||||||
|
import type { SystemInfo } from '@/api/connection-manager'
|
||||||
import { connectionManager } from '@/api/connection-manager'
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import { IconStar, IconClose, IconPushpin, IconDown, IconRight } from '@arco-design/web-vue/es/icon'
|
import { IconStar, IconClose, IconPushpin, IconDown, IconRight, IconStorage, IconComputer, IconDesktop, IconPlus, IconCheckCircle } from '@arco-design/web-vue/es/icon'
|
||||||
import { getFileIcon } from '@/utils/fileUtils'
|
import { getFileIcon } from '@/utils/fileUtils'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -146,26 +165,70 @@ const serverCollapsed = ref(false)
|
|||||||
const favCollapsed = ref(false)
|
const favCollapsed = ref(false)
|
||||||
const helpCollapsed = ref(false)
|
const helpCollapsed = ref(false)
|
||||||
|
|
||||||
// 服务器响应式状态(connectionManager 非响应式,需手动桥接)
|
// 管理设置
|
||||||
const connState = ref(connectionManager.state)
|
const settingsOpen = ref(false)
|
||||||
const isRemote = ref(connectionManager.isRemote())
|
const autoConnect = ref(localStorage.getItem('desk:autoConnect') !== 'false')
|
||||||
const activeProfile = ref(connectionManager.activeProfile)
|
const autoRefresh = ref(localStorage.getItem('desk:autoRefresh') === 'true')
|
||||||
|
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function toggleAutoConnect() {
|
||||||
|
autoConnect.value = !autoConnect.value
|
||||||
|
localStorage.setItem('desk:autoConnect', String(autoConnect.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
autoRefresh.value = !autoRefresh.value
|
||||||
|
localStorage.setItem('desk:autoRefresh', String(autoRefresh.value))
|
||||||
|
if (autoRefresh.value) {
|
||||||
|
startAutoRefresh()
|
||||||
|
} else {
|
||||||
|
stopAutoRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoRefresh() {
|
||||||
|
stopAutoRefresh()
|
||||||
|
refreshTimer = setInterval(() => {
|
||||||
|
profiles.value.forEach(p => { connectionManager.fetchSystemInfo(p.id).catch(() => {}) })
|
||||||
|
}, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoRefresh() {
|
||||||
|
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (autoRefresh.value) startAutoRefresh()
|
||||||
|
})
|
||||||
|
onUnmounted(() => stopAutoRefresh())
|
||||||
|
|
||||||
|
// 点击外部关闭更多菜单
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
if (!el.closest('.server-table-row')) {
|
||||||
|
moreOpenId.value = null
|
||||||
|
}
|
||||||
|
if (!el.closest('.settings-panel') && !el.closest('.settings-btn')) {
|
||||||
|
settingsOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||||
|
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
|
||||||
|
|
||||||
|
// 服务器 Profile 列表状态
|
||||||
|
const profiles = shallowRef(connectionManager.profiles)
|
||||||
|
const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
||||||
|
const moreOpenId = ref<string | null>(null)
|
||||||
|
const sysInfoMap = ref<Record<string, SystemInfo>>({})
|
||||||
|
|
||||||
|
// 监听连接变化 + 系统信息变化
|
||||||
connectionManager.onStateChange(() => {
|
connectionManager.onStateChange(() => {
|
||||||
connState.value = connectionManager.state
|
profiles.value = connectionManager.profiles
|
||||||
isRemote.value = connectionManager.isRemote()
|
activeId.value = connectionManager.activeProfile?.id ?? ''
|
||||||
activeProfile.value = connectionManager.activeProfile
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusMap: Record<string, string> = {
|
connectionManager.onSystemInfoChange((profileId, info) => {
|
||||||
connecting: '连接中...',
|
sysInfoMap.value = { ...sysInfoMap.value, [profileId]: info }
|
||||||
connected: '已连接',
|
|
||||||
error: '异常',
|
|
||||||
}
|
|
||||||
const statusLabel = computed(() => statusMap[connState.value] || connState.value)
|
|
||||||
const statusTagColor = computed(() => {
|
|
||||||
const map: Record<string, string> = { connecting: 'orangered', connected: 'blue', error: 'red' }
|
|
||||||
return map[connState.value] || 'gray'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算第一个和最后一个置顶项的索引
|
// 计算第一个和最后一个置顶项的索引
|
||||||
@@ -198,7 +261,7 @@ interface Emits {
|
|||||||
(e: 'dragOver', event: DragEvent): void
|
(e: 'dragOver', event: DragEvent): void
|
||||||
(e: 'drop', event: DragEvent, targetIndex: number): void
|
(e: 'drop', event: DragEvent, targetIndex: number): void
|
||||||
(e: 'dragEnd'): void
|
(e: 'dragEnd'): void
|
||||||
(e: 'openConnectionDialog'): void
|
(e: 'openConnectionDialog', editId?: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
@@ -241,17 +304,91 @@ const handleDragEnd = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 服务器操作
|
// 服务器操作
|
||||||
const handleConnectRemote = () => {
|
function sysInfo(profileId: string): SystemInfo | undefined {
|
||||||
const remote = connectionManager.profiles.find(p => p.type === 'remote')
|
return sysInfoMap.value[profileId]
|
||||||
if (remote) {
|
}
|
||||||
connectionManager.connect(remote.id)
|
|
||||||
} else {
|
function stateDotClass(profileId: string): string {
|
||||||
emit('openConnectionDialog')
|
if (profileId === activeId.value) {
|
||||||
|
const state = connectionManager.state
|
||||||
|
if (state === 'connected') return 'connected'
|
||||||
|
if (state === 'connecting') return 'connecting'
|
||||||
|
if (state === 'error') return 'error'
|
||||||
|
return 'disconnected'
|
||||||
|
}
|
||||||
|
const info = sysInfoMap.value[profileId]
|
||||||
|
if (info?._error) return 'error'
|
||||||
|
if (info && Object.keys(info).length > 1) return 'connected'
|
||||||
|
return 'disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricTooltip(profileId: string, type: 'disk' | 'cpu' | 'mem'): string {
|
||||||
|
const info = sysInfoMap.value[profileId]
|
||||||
|
if (!info) return '-'
|
||||||
|
if (type === 'disk') {
|
||||||
|
const usage = info.diskUsage ? `${info.diskUsage}` : '-'
|
||||||
|
const used = info.diskUsed != null ? formatBytes(info.diskUsed) : ''
|
||||||
|
const total = info.diskTotal ? formatBytes(info.diskTotal) : ''
|
||||||
|
return total ? `${used} / ${total} (${usage})` : usage || '-'
|
||||||
|
}
|
||||||
|
if (type === 'cpu') {
|
||||||
|
const usage = info.cpuUsage ? `${info.cpuUsage}` : '-'
|
||||||
|
const cores = info.cpuCores ? `${info.cpuCores} 核` : ''
|
||||||
|
return cores ? `${usage} / ${cores}` : usage || '-'
|
||||||
|
}
|
||||||
|
if (type === 'mem') {
|
||||||
|
const usage = info.memUsage ? `${info.memUsage}` : '-'
|
||||||
|
const used = info.memUsed != null ? formatBytes(info.memUsed) : ''
|
||||||
|
const total = info.memTotal ? formatBytes(info.memTotal) : ''
|
||||||
|
return total ? `${used} / ${total} (${usage})` : usage || '-'
|
||||||
|
}
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`
|
||||||
|
if (n < 1048576) return `${(n / 1024).toFixed(0)} KB`
|
||||||
|
if (n < 1073741824) return `${(n / 1048576).toFixed(1)} MB`
|
||||||
|
return `${(n / 1073741824).toFixed(1)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateText(profileId: string): string {
|
||||||
|
if (profileId === activeId.value) {
|
||||||
|
const s = connectionManager.state
|
||||||
|
if (s === 'connected') return '已连接'
|
||||||
|
if (s === 'connecting') return '连接中...'
|
||||||
|
if (s === 'error') return '连接失败'
|
||||||
|
return '未连接'
|
||||||
|
}
|
||||||
|
const info = sysInfoMap.value[profileId]
|
||||||
|
if (info?._error) return info._errorMsg || '采集失败'
|
||||||
|
if (info && Object.keys(info).length > 1) return '已连接'
|
||||||
|
return '未连接'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelect(p: { id: string; type: string }) {
|
||||||
|
moreOpenId.value = null
|
||||||
|
if (p.id === activeId.value) return
|
||||||
|
try {
|
||||||
|
await connectionManager.connect(p.id)
|
||||||
|
} catch (err) {
|
||||||
|
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
function toggleMore(id: string) {
|
||||||
connectionManager.disconnect()
|
moreOpenId.value = moreOpenId.value === id ? null : id
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(p: { id: string }) {
|
||||||
|
moreOpenId.value = null
|
||||||
|
emit('openConnectionDialog', p.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(p: { id: string; name: string }) {
|
||||||
|
if (!window.confirm(`确定删除「${p.name}」?`)) return
|
||||||
|
connectionManager.removeProfile(p.id)
|
||||||
|
moreOpenId.value = null
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -271,6 +408,11 @@ const handleDisconnect = () => {
|
|||||||
.sidebar-section {
|
.sidebar-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section.section-on-top {
|
||||||
|
z-index: 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 帮助区块固定在底部,不被推出窗口 */
|
/* 帮助区块固定在底部,不被推出窗口 */
|
||||||
@@ -333,6 +475,8 @@ const handleDisconnect = () => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 8px 0;
|
padding: 4px 8px 0;
|
||||||
|
background: var(--color-bg-2);
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 帮助内容 */
|
/* 帮助内容 */
|
||||||
@@ -365,37 +509,166 @@ const handleDisconnect = () => {
|
|||||||
|
|
||||||
/* 服务器内容 */
|
/* 服务器内容 */
|
||||||
.server-content {
|
.server-content {
|
||||||
padding: 8px 12px;
|
padding: 2px 4px 6px;
|
||||||
background: var(--color-fill-1);
|
background: var(--color-fill-1);
|
||||||
border-radius: 0 0 6px 6px;
|
border-radius: 0 0 6px 6px;
|
||||||
|
border-left: 3px solid var(--color-primary-6);
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-info {
|
/* 表头 */
|
||||||
|
.server-table-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 6px;
|
padding: 3px 4px;
|
||||||
margin-bottom: 8px;
|
font-size: 11px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
border-bottom: 1px solid var(--color-border-2);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.server-table-head > span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-row {
|
/* 表格行 */
|
||||||
|
.server-table-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.server-table-row:hover { background: var(--color-fill-2); }
|
||||||
|
.server-table-row.active { background: var(--color-primary-light-1); }
|
||||||
|
|
||||||
|
.col-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.col-name .dot { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dot.connected { background: #00b42a; }
|
||||||
|
.dot.connecting { background: #165dff; animation: pulse 1s infinite; }
|
||||||
|
.dot.disconnected { background: var(--color-text-4); }
|
||||||
|
.dot.error { background: #f53f3f; }
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
.col-metric {
|
||||||
|
width: 42px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.col-action {
|
||||||
|
width: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 更多按钮 */
|
||||||
|
.more-btn {
|
||||||
|
opacity: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.server-table-row:hover .more-btn { opacity: 1; }
|
||||||
|
|
||||||
|
/* 更多操作子菜单 */
|
||||||
|
.more-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
top: 100%;
|
||||||
|
min-width: 80px;
|
||||||
|
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: 5px 12px;
|
||||||
|
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); }
|
||||||
|
|
||||||
|
/* 设置按钮 */
|
||||||
|
.settings-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.settings-btn:hover { color: var(--color-primary-6); }
|
||||||
|
|
||||||
|
/* 设置面板 — 绝对定位浮在按钮下方 */
|
||||||
|
.settings-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
top: 50px;
|
||||||
|
z-index: 20;
|
||||||
|
min-width: 200px;
|
||||||
|
background: var(--color-bg-popup);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
.settings-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 12px;
|
padding: 7px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
color: var(--color-text-2);
|
||||||
}
|
}
|
||||||
|
.settings-item:hover { background: var(--color-fill-1); }
|
||||||
|
|
||||||
.server-label {
|
/* 区块操作图标 */
|
||||||
|
.section-action {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
min-width: 42px;
|
cursor: pointer;
|
||||||
}
|
transition: color 0.15s;
|
||||||
|
|
||||||
.server-val {
|
|
||||||
color: var(--color-text-1);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.server-actions :deep(.arco-btn) {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
.section-action:hover { color: var(--color-primary-6); }
|
||||||
|
|
||||||
/* 收藏项 */
|
/* 收藏项 */
|
||||||
.sidebar-item {
|
.sidebar-item {
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ import ConnectionDialog from './ConnectionDialog.vue'
|
|||||||
interface Props {
|
interface Props {
|
||||||
config: ToolbarConfig
|
config: ToolbarConfig
|
||||||
openConnectionDialog?: boolean
|
openConnectionDialog?: boolean
|
||||||
|
editProfileId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -172,7 +173,14 @@ const emit = defineEmits<Emits>()
|
|||||||
const showConnectionDialog = ref(false)
|
const showConnectionDialog = ref(false)
|
||||||
const connectionDialogRef = ref<InstanceType<typeof ConnectionDialog>>()
|
const connectionDialogRef = ref<InstanceType<typeof ConnectionDialog>>()
|
||||||
|
|
||||||
watch(() => props.openConnectionDialog, (v) => { if (v > 0) showConnectionDialog.value = true })
|
watch(() => props.openConnectionDialog, (v) => {
|
||||||
|
if (v > 0) {
|
||||||
|
showConnectionDialog.value = true
|
||||||
|
if (props.editProfileId) {
|
||||||
|
nextTick(() => connectionDialogRef.value?.editProfile(props.editProfileId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onConnectionChanged = async (_id: string) => {
|
const onConnectionChanged = async (_id: string) => {
|
||||||
emit('connectionChanged')
|
emit('connectionChanged')
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ export function useCommonPaths() {
|
|||||||
|
|
||||||
const loadCommonPaths = async () => {
|
const loadCommonPaths = async () => {
|
||||||
try {
|
try {
|
||||||
|
// SFTP 未连接时返回默认路径,避免 requireConn 抛错;已连接则获取真实远程路径
|
||||||
|
if (connectionManager.isSftp() && connectionManager.state !== 'connected') {
|
||||||
|
const fallback: ShortcutPath[] = [
|
||||||
|
{ name: `${PATH_ICONS.HOME} 主目录`, path: '/root' },
|
||||||
|
{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' },
|
||||||
|
{ name: '/tmp', path: '/tmp' },
|
||||||
|
]
|
||||||
|
commonPaths.value = fallback
|
||||||
|
systemPaths.value = { home: '/root', tmp: '/tmp', root: '/' }
|
||||||
|
return
|
||||||
|
}
|
||||||
const paths = await getCommonPaths()
|
const paths = await getCommonPaths()
|
||||||
if (!paths) throw new Error('无法获取系统路径')
|
if (!paths) throw new Error('无法获取系统路径')
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ 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 { connectionManager } from '@/api/connection-manager'
|
||||||
|
import { SftpTransport } from '@/api/sftp-transport'
|
||||||
|
import { getFileServerBaseURL } from '@/api/file-server'
|
||||||
import {
|
import {
|
||||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||||
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
||||||
@@ -28,7 +30,7 @@ export interface UseFilePreviewOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getLocalServerURL(): string {
|
function getLocalServerURL(): string {
|
||||||
return 'http://localhost:8073'
|
return getFileServerBaseURL()
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveFileServerBase(): string {
|
function resolveFileServerBase(): string {
|
||||||
@@ -51,16 +53,28 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
|||||||
const currentImageDimensions = ref('')
|
const currentImageDimensions = ref('')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取预览 URL(本地/远程自适应,每次实时计算)
|
* 获取预览 URL(本地/远程/SFTP 自适应,每次实时计算)
|
||||||
* 本地: http://localhost:8073/localfs/{encoded_path}
|
* 本地: {fileServerBaseURL}/localfs/{encoded_path}
|
||||||
* 远程: {baseUrl}/api/v1/proxy/localfs/{raw_path}(Cookie 自动携带认证)
|
* 远程(HTTP): {baseUrl}/api/v1/proxy/localfs/{raw_path}
|
||||||
|
* SFTP: 下载到本地临时目录 → {fileServerBaseURL}/localfs/{temp_path}
|
||||||
*/
|
*/
|
||||||
const getPreviewUrl = (path: string): string => {
|
const getPreviewUrl = (path: string): string => {
|
||||||
if (!path) return ''
|
if (!path) return ''
|
||||||
|
const isSftp = connectionManager.isSftp()
|
||||||
const isRemote = connectionManager.isRemote()
|
const isRemote = connectionManager.isRemote()
|
||||||
|
|
||||||
|
// SFTP 模式:需要先下载到本地临时目录
|
||||||
|
// 注意:这里返回的是同步路径,实际下载在 updatePreviewUrl 中异步完成
|
||||||
|
// 对于 SFTP 模式,getPreviewUrl 返回的 URL 会在 updatePreviewUrl 中被覆盖为临时文件路径
|
||||||
|
if (isSftp) {
|
||||||
|
const base = getLocalServerURL()
|
||||||
|
let normalized = normalizeFilePath(path, true)
|
||||||
|
const sep = base.endsWith('/') ? '' : '/'
|
||||||
|
return `${base}${sep}localfs/${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
const base = resolveFileServerBase()
|
const base = resolveFileServerBase()
|
||||||
let normalized = normalizeFilePath(path, true)
|
let normalized = normalizeFilePath(path, true)
|
||||||
// 远程模式去掉前导 /,避免与 URL 基础路径拼接产生双斜杠(导致 307 重定向)
|
|
||||||
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
|
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||||
const sep = base.endsWith('/') ? '' : '/'
|
const sep = base.endsWith('/') ? '' : '/'
|
||||||
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}`
|
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}`
|
||||||
@@ -92,9 +106,25 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新预览 URL
|
* 更新预览 URL(SFTP 模式会先下载到本地临时目录)
|
||||||
*/
|
*/
|
||||||
const updatePreviewUrl = async (path: string) => {
|
const updatePreviewUrl = async (path: string) => {
|
||||||
|
if (!path) { previewUrl.value = ''; return }
|
||||||
|
|
||||||
|
// SFTP 模式:下载到本地临时目录后用本地文件服务器预览
|
||||||
|
if (connectionManager.isSftp()) {
|
||||||
|
const transport = connectionManager.getTransport()
|
||||||
|
if (transport instanceof SftpTransport) {
|
||||||
|
try {
|
||||||
|
const tempPath = await transport.downloadForPreview(path)
|
||||||
|
previewUrl.value = getPreviewUrl(tempPath)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// 下载失败,回退显示原始路径(会无法预览但不会崩溃)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
previewUrl.value = getPreviewUrl(path)
|
previewUrl.value = getPreviewUrl(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
@drag-over="handleDragOver"
|
@drag-over="handleDragOver"
|
||||||
@drop="handleDrop"
|
@drop="handleDrop"
|
||||||
@drag-end="handleDragEnd"
|
@drag-end="handleDragEnd"
|
||||||
@open-connection-dialog="triggerConnectionDialog++"
|
@open-connection-dialog="handleOpenConnectionDialog"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 右侧工作区:面包屑工具栏 + 文件列表/编辑器 -->
|
<!-- 右侧工作区:面包屑工具栏 + 文件列表/编辑器 -->
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
ref="toolbarRef"
|
ref="toolbarRef"
|
||||||
:config="toolbarConfig"
|
:config="toolbarConfig"
|
||||||
:open-connection-dialog="triggerConnectionDialog"
|
:open-connection-dialog="triggerConnectionDialog"
|
||||||
|
:edit-profile-id="pendingEditProfileId"
|
||||||
@update:file-path="handleFilePathUpdate"
|
@update:file-path="handleFilePathUpdate"
|
||||||
@update:show-sidebar="handleSidebarToggle"
|
@update:show-sidebar="handleSidebarToggle"
|
||||||
@refresh="handleRefresh"
|
@refresh="handleRefresh"
|
||||||
@@ -162,6 +163,17 @@ const fileLoading = ref(false)
|
|||||||
const selectedFileItem = ref<FileItem | null>(null)
|
const selectedFileItem = ref<FileItem | null>(null)
|
||||||
const toolbarRef = ref<InstanceType<typeof import('./components/Toolbar.vue').default> | null>(null)
|
const toolbarRef = ref<InstanceType<typeof import('./components/Toolbar.vue').default> | null>(null)
|
||||||
const triggerConnectionDialog = ref(0)
|
const triggerConnectionDialog = ref(0)
|
||||||
|
const pendingEditProfileId = ref<string | null>(null)
|
||||||
|
|
||||||
|
function handleOpenConnectionDialog(editId?: string) {
|
||||||
|
if (editId) {
|
||||||
|
pendingEditProfileId.value = editId
|
||||||
|
triggerConnectionDialog.value++
|
||||||
|
} else {
|
||||||
|
pendingEditProfileId.value = null
|
||||||
|
triggerConnectionDialog.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 排序状态(带 localStorage 持久化)
|
// 排序状态(带 localStorage 持久化)
|
||||||
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
|
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
|
||||||
@@ -357,7 +369,7 @@ const computeRendered = computed(() => {
|
|||||||
const isRemote = connectionManager.isRemote()
|
const isRemote = connectionManager.isRemote()
|
||||||
const base = isRemote
|
const base = isRemote
|
||||||
? (connectionManager.getFileServerBaseURL().replace(/\/$/, '') + '/api/v1/proxy/localfs')
|
? (connectionManager.getFileServerBaseURL().replace(/\/$/, '') + '/api/v1/proxy/localfs')
|
||||||
: 'http://localhost:8073/localfs'
|
: 'http://localhost:2652/localfs'
|
||||||
setFileServerBase(base)
|
setFileServerBase(base)
|
||||||
|
|
||||||
return marked.parse(content) as string
|
return marked.parse(content) as string
|
||||||
@@ -420,10 +432,13 @@ const handleRefresh = async () => {
|
|||||||
await loadDirectory(filePath.value)
|
await loadDirectory(filePath.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 连接切换后重置路径(仅监听状态变化以刷新快捷入口,不做自动导航)
|
// 连接切换后重置路径并刷新文件列表
|
||||||
connectionManager.onStateChange(async (state) => {
|
connectionManager.onStateChange(async (state) => {
|
||||||
if (state === 'connected') {
|
if (state === 'connected') {
|
||||||
await loadCommonPaths()
|
await loadCommonPaths()
|
||||||
|
const targetPath = connectionManager.isRemote() ? '/' : 'C:/'
|
||||||
|
filePath.value = targetPath
|
||||||
|
await loadDirectory(targetPath)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1278,6 +1293,12 @@ onMounted(async () => {
|
|||||||
// 加载系统路径(阻塞,确保快捷入口就绪)
|
// 加载系统路径(阻塞,确保快捷入口就绪)
|
||||||
await loadCommonPaths()
|
await loadCommonPaths()
|
||||||
|
|
||||||
|
// SFTP 连接是异步的,未就绪时跳过初始加载,由 onStateChange('connected') 触发
|
||||||
|
if (connectionManager.isSftp() && connectionManager.state !== 'connected') {
|
||||||
|
filePath.value = '/'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径
|
// 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径
|
||||||
const startPath = connectionManager.isRemote() ? '/'
|
const startPath = connectionManager.isRemote() ? '/'
|
||||||
: (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/')
|
: (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/')
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import MarkdownEditor from '../../components/MarkdownEditor.vue'
|
import MarkdownEditor from './MarkdownEditor.vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
|
||||||
const markdownContent = ref('')
|
const markdownContent = ref('')
|
||||||
@@ -104,7 +104,7 @@ renderer.heading = function(token: any) {
|
|||||||
// 当前 Markdown 文件所在目录(由调用方在渲染前设置)
|
// 当前 Markdown 文件所在目录(由调用方在渲染前设置)
|
||||||
let _currentFileDir: string = ''
|
let _currentFileDir: string = ''
|
||||||
// 文件服务器 Base URL(由调用方在渲染前设置)
|
// 文件服务器 Base URL(由调用方在渲染前设置)
|
||||||
let _fileServerBase: string = 'http://localhost:8073/localfs'
|
let _fileServerBase: string = 'http://localhost:2652/localfs'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置当前 Markdown 文件所在目录(用于图片相对路径→文件服务器 URL 转换)
|
* 设置当前 Markdown 文件所在目录(用于图片相对路径→文件服务器 URL 转换)
|
||||||
@@ -121,7 +121,7 @@ export function getCurrentFileDir(): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置文件服务器 Base URL(用于图片相对路径转换)
|
* 设置文件服务器 Base URL(用于图片相对路径转换)
|
||||||
* @param base 完整的 base URL 前缀,如 "http://localhost:8073/localfs" 或 "https://host:port/api/v1/proxy/localfs"
|
* @param base 完整的 base URL 前缀,如 "http://localhost:2652/localfs" 或 "https://host:port/api/v1/proxy/localfs"
|
||||||
*/
|
*/
|
||||||
export function setFileServerBase(base: string): void {
|
export function setFileServerBase(base: string): void {
|
||||||
_fileServerBase = base
|
_fileServerBase = base
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="markdown-viewer-container">
|
|
||||||
<div class="viewer-header">
|
|
||||||
<div class="title">
|
|
||||||
<icon-file-text />
|
|
||||||
<span>{{ currentFile?.name || 'Markdown 文档' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<PdfExportButton @export-complete="onExportComplete" />
|
|
||||||
<a-button @click="handleBackToList" type="outline">
|
|
||||||
<icon-arrow-left />
|
|
||||||
返回列表
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="viewer-content">
|
|
||||||
<MarkdownEditor
|
|
||||||
:content="fileContent"
|
|
||||||
@content-change="handleContentChange"
|
|
||||||
@save="handleSave"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部状态栏 -->
|
|
||||||
<div class="status-bar">
|
|
||||||
<div class="file-info">
|
|
||||||
<span>{{ currentFile?.path }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="content-info">
|
|
||||||
<span>{{ wordCount }} 字符 | {{ lineCount }} 行</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { Message } from '@arco-design/web-vue'
|
|
||||||
import MarkdownEditor from '@/components/MarkdownEditor.vue'
|
|
||||||
import PdfExportButton from '@/components/PdfExportButton.vue'
|
|
||||||
import { useFileOperations } from '@/composables/useFileOperations'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'MarkdownViewer',
|
|
||||||
components: {
|
|
||||||
MarkdownEditor,
|
|
||||||
PdfExportButton
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
filePath: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
emits: ['back'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const fileOperations = useFileOperations()
|
|
||||||
const fileContent = ref('')
|
|
||||||
const currentFile = ref(null)
|
|
||||||
const hasChanges = ref(false)
|
|
||||||
const lastSavedContent = ref('')
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const wordCount = computed(() => {
|
|
||||||
return fileContent.value.length
|
|
||||||
})
|
|
||||||
|
|
||||||
const lineCount = computed(() => {
|
|
||||||
return fileContent.value.split('\n').length
|
|
||||||
})
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
const loadFile = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fileOperations.readFile(props.filePath)
|
|
||||||
fileContent.value = response.content
|
|
||||||
lastSavedContent.value = response.content
|
|
||||||
hasChanges.value = false
|
|
||||||
|
|
||||||
// 获取文件信息
|
|
||||||
const fileName = props.filePath.split('/').pop() || props.filePath.split('\\').pop()
|
|
||||||
currentFile.value = {
|
|
||||||
name: fileName,
|
|
||||||
path: props.filePath
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Message.error(`读取文件失败: ${error?.message ?? String(error)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContentChange = (content) => {
|
|
||||||
hasChanges.value = content !== lastSavedContent.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
await fileOperations.saveFile(props.filePath, fileContent.value)
|
|
||||||
lastSavedContent.value = fileContent.value
|
|
||||||
hasChanges.value = false
|
|
||||||
Message.success('文件已保存')
|
|
||||||
} catch (error) {
|
|
||||||
Message.error(`保存文件失败: ${error?.message ?? String(error)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onExportComplete = () => {
|
|
||||||
Message.success('PDF 导出完成')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBackToList = () => {
|
|
||||||
emit('back')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
loadFile()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
fileContent,
|
|
||||||
currentFile,
|
|
||||||
hasChanges,
|
|
||||||
wordCount,
|
|
||||||
lineCount,
|
|
||||||
handleContentChange,
|
|
||||||
handleSave,
|
|
||||||
onExportComplete,
|
|
||||||
handleBackToList
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.markdown-viewer-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px 20px;
|
|
||||||
background: white;
|
|
||||||
border-bottom: 1px solid #e8e8e8;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
background: white;
|
|
||||||
margin: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 20px;
|
|
||||||
background: white;
|
|
||||||
border-top: 1px solid #e8e8e8;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-info {
|
|
||||||
font-family: monospace;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-info {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式设计 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.viewer-header {
|
|
||||||
padding: 12px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-content {
|
|
||||||
margin: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar {
|
|
||||||
padding: 6px 16px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src'),
|
'@': resolve(__dirname, 'src'),
|
||||||
|
'@bindings': resolve(__dirname, 'bindings'),
|
||||||
'@wailsio/events': fileURLToPath(new URL('./node_modules/@wailsio/runtime/dist/events.js', import.meta.url))
|
'@wailsio/events': fileURLToPath(new URL('./node_modules/@wailsio/runtime/dist/events.js', import.meta.url))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -7,9 +7,11 @@ require (
|
|||||||
github.com/chromedp/chromedp v0.15.1
|
github.com/chromedp/chromedp v0.15.1
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/labstack/echo/v4 v4.15.1
|
github.com/labstack/echo/v4 v4.15.1
|
||||||
|
github.com/pkg/sftp v1.13.10
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.80
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.80
|
||||||
github.com/yuin/goldmark v1.7.16
|
github.com/yuin/goldmark v1.7.16
|
||||||
|
golang.org/x/crypto v0.47.0
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sys v0.42.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
@@ -46,6 +48,7 @@ require (
|
|||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/kr/fs v0.1.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/u v1.1.1 // indirect
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
@@ -70,7 +73,6 @@ require (
|
|||||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
|
||||||
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
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -86,6 +86,8 @@ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PW
|
|||||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -126,6 +128,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd
|
|||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||||
|
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func Default() *Config {
|
|||||||
Format: "json",
|
Format: "json",
|
||||||
},
|
},
|
||||||
FileServer: FileServerConfig{
|
FileServer: FileServerConfig{
|
||||||
Port: 8073,
|
Port: 2652,
|
||||||
MaxFileSize: 500 * 1024 * 1024,
|
MaxFileSize: 500 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
Security: SecurityConfig{
|
Security: SecurityConfig{
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ func (h *Handler) HTMLPreviewProxy(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
theme := c.QueryParam("theme")
|
theme := c.QueryParam("theme")
|
||||||
|
|
||||||
targetURL := fmt.Sprintf("http://localhost:8073/localfs/html-preview?path=%s&theme=%s",
|
targetURL := fmt.Sprintf("%s/localfs/html-preview?path=%s&theme=%s",
|
||||||
url.QueryEscape(clean), url.QueryEscape(theme))
|
h.cfg.FileServerAddr(), url.QueryEscape(clean), url.QueryEscape(theme))
|
||||||
|
|
||||||
resp, err := http.Get(targetURL)
|
resp, err := http.Get(targetURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -9,6 +10,9 @@ import (
|
|||||||
"u-desk/internal/agent/model"
|
"u-desk/internal/agent/model"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/shirou/gopsutil/v3/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v3/disk"
|
||||||
|
"github.com/shirou/gopsutil/v3/mem"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ping 健康检查
|
// Ping 健康检查
|
||||||
@@ -111,3 +115,39 @@ func (h *Handler) Drives(c echo.Context) error {
|
|||||||
|
|
||||||
return c.JSON(http.StatusOK, model.OK(drives))
|
return c.JSON(http.StatusOK, model.OK(drives))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats 返回系统资源使用统计(CPU/内存/磁盘)
|
||||||
|
func (h *Handler) Stats(c echo.Context) error {
|
||||||
|
info := make(map[string]interface{})
|
||||||
|
|
||||||
|
// CPU
|
||||||
|
if cores, err := cpu.Counts(true); err == nil {
|
||||||
|
info["cpu_cores"] = cores
|
||||||
|
}
|
||||||
|
if percents, err := cpu.Percent(0, false); err == nil && len(percents) > 0 {
|
||||||
|
info["cpu_usage"] = fmt.Sprintf("%.0f%%", percents[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存
|
||||||
|
if memInfo, err := mem.VirtualMemory(); err == nil {
|
||||||
|
info["mem_total"] = memInfo.Total
|
||||||
|
info["mem_used"] = memInfo.Used
|
||||||
|
info["mem_usage"] = fmt.Sprintf("%.0f%%", memInfo.UsedPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 磁盘(取根分区)
|
||||||
|
if partitions, err := disk.Partitions(false); err == nil {
|
||||||
|
for _, p := range partitions {
|
||||||
|
if p.Mountpoint == "/" || (runtime.GOOS == "windows" && len(p.Mountpoint) == 3 && p.Mountpoint[1] == ':') {
|
||||||
|
if usage, err := disk.Usage(p.Mountpoint); err == nil {
|
||||||
|
info["disk_total"] = usage.Total
|
||||||
|
info["disk_used"] = usage.Used
|
||||||
|
info["disk_usage"] = fmt.Sprintf("%.0f%%", usage.UsedPercent)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, model.OK(info))
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,6 +16,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const DefaultFileServerPort = 2652
|
||||||
|
|
||||||
// 预编译正则表达式(避免每次调用重复编译)
|
// 预编译正则表达式(避免每次调用重复编译)
|
||||||
var (
|
var (
|
||||||
// CSS 相关
|
// CSS 相关
|
||||||
@@ -44,6 +47,7 @@ var (
|
|||||||
|
|
||||||
// HTML 预览路径修复
|
// HTML 预览路径修复
|
||||||
locationPathRegex = regexp.MustCompile(`\blocation\.pathname\b`)
|
locationPathRegex = regexp.MustCompile(`\blocation\.pathname\b`)
|
||||||
|
winDriveRegex = regexp.MustCompile(`^[A-Za-z]:`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
|
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
|
||||||
@@ -80,7 +84,7 @@ func validateFilePath(rawPath string, logPrefix string) (string, error) {
|
|||||||
filePath = filepath.Clean(filePath)
|
filePath = filepath.Clean(filePath)
|
||||||
|
|
||||||
// 确保绝对路径(Linux 以 / 开头,Windows 以盘符开头)
|
// 确保绝对路径(Linux 以 / 开头,Windows 以盘符开头)
|
||||||
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' {
|
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && (len(filePath) < 2 || filePath[1] != ':') {
|
||||||
filePath = "/" + filePath
|
filePath = "/" + filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,40 +107,22 @@ var (
|
|||||||
localFileServerOnce sync.Once
|
localFileServerOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartLocalFileServer 启动本地文件服务器
|
// StartLocalFileServer 启动本地文件服务器(端口被占用时自动递增)
|
||||||
func StartLocalFileServer() (string, error) {
|
func StartLocalFileServer() (string, error) {
|
||||||
var initErr error
|
var initErr error
|
||||||
localFileServerOnce.Do(func() {
|
localFileServerOnce.Do(func() {
|
||||||
// 创建多路复用器
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// 注册 /localfs/ 路由
|
|
||||||
mux.HandleFunc("/localfs/", handleLocalFileRequest)
|
mux.HandleFunc("/localfs/", handleLocalFileRequest)
|
||||||
|
|
||||||
// 注册 HTML 预览专用路由
|
|
||||||
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
|
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
|
||||||
|
|
||||||
// 创建服务器(固定端口)
|
addr, srv, err := listenWithFallback(DefaultFileServerPort, mux)
|
||||||
server := &http.Server{
|
if err != nil {
|
||||||
Addr: "localhost:8073",
|
initErr = fmt.Errorf("无法绑定端口(%d起始): %w", DefaultFileServerPort, err)
|
||||||
Handler: mux,
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动服务器
|
localFileServer = &LocalFileServer{server: srv, addr: addr}
|
||||||
go func() {
|
log.Printf("[LocalFileServer] 已启动,监听: %s", addr)
|
||||||
log.Printf("[LocalFileServer] 正在启动...")
|
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Printf("[LocalFileServer] 启动失败: %v", err)
|
|
||||||
initErr = err
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
localFileServer = &LocalFileServer{
|
|
||||||
server: server,
|
|
||||||
addr: "localhost:8073",
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if localFileServer == nil {
|
if localFileServer == nil {
|
||||||
@@ -145,6 +131,33 @@ func StartLocalFileServer() (string, error) {
|
|||||||
return localFileServer.addr, initErr
|
return localFileServer.addr, initErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listenWithFallback 从 basePort 开始尝试绑定,递增直到成功(最多试 10 次)
|
||||||
|
// 返回的 server 已在 goroutine 中 Serve(l),调用方无需再启动
|
||||||
|
func listenWithFallback(basePort int, handler http.Handler) (addr string, srv *http.Server, err error) {
|
||||||
|
for offset := 0; offset < 10; offset++ {
|
||||||
|
port := basePort + offset
|
||||||
|
addr = fmt.Sprintf("localhost:%d", port)
|
||||||
|
l, e := net.Listen("tcp", addr)
|
||||||
|
if e == nil {
|
||||||
|
srv = &http.Server{Handler: handler}
|
||||||
|
go func() {
|
||||||
|
if se := srv.Serve(l); se != http.ErrServerClosed {
|
||||||
|
log.Printf("[LocalFileServer] 异常退出: %v", se)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return addr, srv, nil
|
||||||
|
}
|
||||||
|
log.Printf("[LocalFileServer] 端口 %d 被占用,尝试 %d...", port, port+1)
|
||||||
|
}
|
||||||
|
return "", nil, fmt.Errorf("端口 %d-%d 均不可用", basePort, basePort+9)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalFileServerAddr 返回实际绑定的地址(含动态分配的端口)
|
||||||
|
func GetLocalFileServerAddr() string {
|
||||||
|
if localFileServer == nil { return fmt.Sprintf("http://localhost:%d", DefaultFileServerPort) }
|
||||||
|
return localFileServer.addr
|
||||||
|
}
|
||||||
|
|
||||||
// handleLocalFileRequest 处理本地文件请求
|
// handleLocalFileRequest 处理本地文件请求
|
||||||
func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
// CORS 头:允许所有源访问(因为这是本地文件服务器)
|
// CORS 头:允许所有源访问(因为这是本地文件服务器)
|
||||||
@@ -177,7 +190,7 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 仅对非绝对路径添加前导 /(Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /)
|
// 仅对非绝对路径添加前导 /(Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /)
|
||||||
if !strings.HasPrefix(pathPart, "/") && !regexp.MustCompile(`^[A-Za-z]:`).MatchString(pathPart) {
|
if !strings.HasPrefix(pathPart, "/") && !winDriveRegex.MatchString(pathPart) {
|
||||||
pathPart = "/" + pathPart
|
pathPart = "/" + pathPart
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ func OpenPath(path string) error {
|
|||||||
|
|
||||||
// ========== 工具函数 ==========
|
// ========== 工具函数 ==========
|
||||||
|
|
||||||
// formatBytes 格式化字节大小为人类可读格式
|
// FormatBytes 格式化字节大小为人类可读格式(导出供 sftp 等外部包使用)
|
||||||
func formatBytes(bytes int64) string {
|
func FormatBytes(bytes int64) string {
|
||||||
const unit = 1024
|
const unit = 1024
|
||||||
if bytes < unit {
|
if bytes < unit {
|
||||||
return fmt.Sprintf("%d B", bytes)
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
|
|||||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||||
Name: info.Name(),
|
Name: info.Name(),
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
SizeStr: formatBytes(info.Size()),
|
SizeStr: FormatBytes(info.Size()),
|
||||||
IsDir: info.IsDir(),
|
IsDir: info.IsDir(),
|
||||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
Mode: info.Mode().String(),
|
Mode: info.Mode().String(),
|
||||||
@@ -341,7 +341,7 @@ func (s *FileSystemService) CreateDir(path string) (*FileOperationResult, error)
|
|||||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||||
Name: info.Name(),
|
Name: info.Name(),
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
SizeStr: formatBytes(info.Size()),
|
SizeStr: FormatBytes(info.Size()),
|
||||||
IsDir: true,
|
IsDir: true,
|
||||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
Mode: info.Mode().String(),
|
Mode: info.Mode().String(),
|
||||||
@@ -389,7 +389,7 @@ func (s *FileSystemService) CreateFile(path string) (*FileOperationResult, error
|
|||||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||||
Name: info.Name(),
|
Name: info.Name(),
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
SizeStr: formatBytes(info.Size()),
|
SizeStr: FormatBytes(info.Size()),
|
||||||
IsDir: false,
|
IsDir: false,
|
||||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
Mode: info.Mode().String(),
|
Mode: info.Mode().String(),
|
||||||
@@ -414,7 +414,7 @@ func (s *FileSystemService) GetFileInfo(path string) (map[string]interface{}, er
|
|||||||
"name": info.Name(),
|
"name": info.Name(),
|
||||||
"path": filepath.ToSlash(path), // 统一使用正斜杠
|
"path": filepath.ToSlash(path), // 统一使用正斜杠
|
||||||
"size": info.Size(),
|
"size": info.Size(),
|
||||||
"size_str": formatBytes(info.Size()),
|
"size_str": FormatBytes(info.Size()),
|
||||||
"is_dir": info.IsDir(),
|
"is_dir": info.IsDir(),
|
||||||
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
|
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
"mode": info.Mode().String(),
|
"mode": info.Mode().String(),
|
||||||
@@ -470,7 +470,7 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
|
|||||||
Path: filepath.ToSlash(newPath), // 统一使用正斜杠
|
Path: filepath.ToSlash(newPath), // 统一使用正斜杠
|
||||||
Name: info.Name(),
|
Name: info.Name(),
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
SizeStr: formatBytes(info.Size()),
|
SizeStr: FormatBytes(info.Size()),
|
||||||
IsDir: info.IsDir(),
|
IsDir: info.IsDir(),
|
||||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
Mode: info.Mode().String(),
|
Mode: info.Mode().String(),
|
||||||
|
|||||||
41
internal/service/profile_service.go
Normal file
41
internal/service/profile_service.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"u-desk/internal/storage"
|
||||||
|
"u-desk/internal/storage/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileService 连接配置 CRUD
|
||||||
|
type ProfileService struct{}
|
||||||
|
|
||||||
|
func NewProfileService() *ProfileService { return &ProfileService{} }
|
||||||
|
|
||||||
|
func (s *ProfileService) ListProfiles() ([]models.ConnectionProfile, error) {
|
||||||
|
db := storage.GetDB()
|
||||||
|
var list []models.ConnectionProfile
|
||||||
|
err := db.Order("sort_order asc, id asc").Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProfileService) GetProfile(id uint) (*models.ConnectionProfile, error) {
|
||||||
|
db := storage.GetDB()
|
||||||
|
var p models.ConnectionProfile
|
||||||
|
err := db.First(&p, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProfileService) SaveProfile(p *models.ConnectionProfile) error {
|
||||||
|
db := storage.GetDB()
|
||||||
|
if p.ID == 0 {
|
||||||
|
return db.Create(p).Error
|
||||||
|
}
|
||||||
|
return db.Save(p).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProfileService) DeleteProfile(id uint) error {
|
||||||
|
db := storage.GetDB()
|
||||||
|
return db.Delete(&models.ConnectionProfile{}, id).Error
|
||||||
|
}
|
||||||
260
internal/sftp/client.go
Normal file
260
internal/sftp/client.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package sftp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client SFTP 客户端封装(单连接)
|
||||||
|
type Client struct {
|
||||||
|
config *Config
|
||||||
|
client *sftp.Client
|
||||||
|
sshClient *ssh.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager 全局 SFTP 连接管理器(以 host:port 为 key 的连接池)
|
||||||
|
type Manager struct {
|
||||||
|
clients sync.Map // map[string]*Client
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalManager = &Manager{}
|
||||||
|
|
||||||
|
// GetManager 获取全局连接管理器
|
||||||
|
func GetManager() *Manager {
|
||||||
|
return globalManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect 创建或复用 SFTP 连接
|
||||||
|
func (m *Manager) Connect(config *Config) (*Client, error) {
|
||||||
|
key := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||||
|
|
||||||
|
if existing, ok := m.clients.Load(key); ok {
|
||||||
|
c := existing.(*Client)
|
||||||
|
if c.IsHealthy() {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
m.clients.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := newClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.clients.Store(key, c)
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClient 获取已有连接(不复用也不新建)
|
||||||
|
func (m *Manager) GetClient(connID string) *Client {
|
||||||
|
if val, ok := m.clients.Load(connID); ok {
|
||||||
|
return val.(*Client)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect 关闭并移除指定连接
|
||||||
|
func (m *Manager) Disconnect(host string, port int) {
|
||||||
|
key := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
if val, ok := m.clients.LoadAndDelete(key); ok {
|
||||||
|
val.(*Client).Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown 关闭所有连接
|
||||||
|
func (m *Manager) Shutdown() {
|
||||||
|
m.clients.Range(func(key, value any) bool {
|
||||||
|
value.(*Client).Close()
|
||||||
|
m.clients.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 内部 ---
|
||||||
|
|
||||||
|
func newClient(config *Config) (*Client, error) {
|
||||||
|
sshConfig := &ssh.ClientConfig{
|
||||||
|
Config: ssh.Config{
|
||||||
|
KeyExchanges: []string{
|
||||||
|
"curve25519-sha256", "curve25519-sha256@libssh.org",
|
||||||
|
"ecdh-sha2-nistp256", "ecdh-sha2-nistp384",
|
||||||
|
"diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
User: config.Username,
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
Timeout: config.Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认证方式选择
|
||||||
|
if config.KeyPath != "" {
|
||||||
|
key, err := os.ReadFile(config.KeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &ConnectionError{Op: "auth", Err: fmt.Errorf("读取密钥文件失败: %w", err)}
|
||||||
|
}
|
||||||
|
var signer ssh.Signer
|
||||||
|
if config.KeyPassphrase != "" {
|
||||||
|
signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(config.KeyPassphrase))
|
||||||
|
} else {
|
||||||
|
signer, err = ssh.ParsePrivateKey(key)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, &ConnectionError{Op: "auth", Err: fmt.Errorf("解析密钥失败: %w", err)}
|
||||||
|
}
|
||||||
|
sshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
|
||||||
|
} else if config.Password != "" {
|
||||||
|
pw := config.Password
|
||||||
|
sshConfig.Auth = []ssh.AuthMethod{
|
||||||
|
ssh.Password(pw),
|
||||||
|
ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||||
|
answers := make([]string, len(questions))
|
||||||
|
for i := range questions {
|
||||||
|
answers[i] = pw
|
||||||
|
}
|
||||||
|
return answers, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, &ConnectionError{Op: "auth", Err: fmt.Errorf("必须提供密码或密钥文件")}
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||||
|
sshConn, err := net.DialTimeout("tcp", addr, config.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &ConnectionError{Op: "dial", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConnConn, chans, reqs, err := ssh.NewClientConn(sshConn, addr, sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
sshConn.Close()
|
||||||
|
return nil, &ConnectionError{Op: "handshake", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
sshClient := ssh.NewClient(sshConnConn, chans, reqs)
|
||||||
|
sftpClient, err := sftp.NewClient(sshClient)
|
||||||
|
if err != nil {
|
||||||
|
sshClient.Close()
|
||||||
|
return nil, &ConnectionError{Op: "sftp_init", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
config: config,
|
||||||
|
client: sftpClient,
|
||||||
|
sshClient: sshClient,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHealthy 检查连接是否健康(先取引用再解锁,避免持锁做 I/O)
|
||||||
|
func (c *Client) IsHealthy() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
client := c.client
|
||||||
|
c.mu.Unlock()
|
||||||
|
if client == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := client.Stat("/")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRetry 带重试的操作执行(自动处理断线重连)
|
||||||
|
func (c *Client) WithRetry(fn func(*sftp.Client) error) error {
|
||||||
|
const maxRetries = 3
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(time.Duration((attempt+1)*2) * time.Second)
|
||||||
|
if reconnectErr := c.reconnect(); reconnectErr != nil {
|
||||||
|
lastErr = reconnectErr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
client := c.client
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
lastErr = fmt.Errorf("SFTP 客户端未初始化")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fn(client); err != nil {
|
||||||
|
if isNetworkError(err) {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("操作失败(已重试 %d 次): %w", maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) reconnect() error {
|
||||||
|
nc, err := newClient(c.config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.closeLocked()
|
||||||
|
c.client = nc.client
|
||||||
|
c.sshClient = nc.sshClient
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Close() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.closeLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) closeLocked() {
|
||||||
|
if c.client != nil {
|
||||||
|
c.client.Close()
|
||||||
|
c.client = nil
|
||||||
|
}
|
||||||
|
if c.sshClient != nil {
|
||||||
|
c.sshClient.Close()
|
||||||
|
c.sshClient = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCommand 通过 SSH Session 执行远程命令,返回 stdout
|
||||||
|
func (c *Client) RunCommand(cmd string) (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
sshClient := c.sshClient
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if sshClient == nil {
|
||||||
|
return "", fmt.Errorf("SSH 客户端未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("创建 SSH 会话失败: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
out, err := session.CombinedOutput(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("执行命令失败 [%s]: %w", cmd, err)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawClient 获取底层 sftp.Client(高级用法)
|
||||||
|
func (c *Client) RawClient() *sftp.Client {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.client
|
||||||
|
}
|
||||||
22
internal/sftp/config.go
Normal file
22
internal/sftp/config.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package sftp
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Config SFTP 连接配置
|
||||||
|
type Config struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
KeyPath string `json:"key_path"`
|
||||||
|
KeyPassphrase string `json:"key_passphrase"`
|
||||||
|
Timeout time.Duration `json:"timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig 返回默认配置
|
||||||
|
func DefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Port: 22,
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
69
internal/sftp/errors.go
Normal file
69
internal/sftp/errors.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package sftp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionError SFTP 连接错误(区分阶段)
|
||||||
|
type ConnectionError struct {
|
||||||
|
Op string // dial / handshake / auth / sftp_init
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ConnectionError) Error() string {
|
||||||
|
return fmt.Sprintf("SFTP 连接失败 [%s]: %v", e.Op, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ConnectionError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
// ToUserMessage 将底层错误转换为用户友好的中文错误(返回 error 类型)
|
||||||
|
func ToUserMessage(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var connErr *ConnectionError
|
||||||
|
if errors.As(err, &connErr) {
|
||||||
|
switch connErr.Op {
|
||||||
|
case "dial":
|
||||||
|
return fmt.Errorf("无法连接到服务器 (%v)。请检查地址和端口是否正确", connErr.Err)
|
||||||
|
case "handshake":
|
||||||
|
return fmt.Errorf("SSH 握手失败,请检查服务是否正常运行")
|
||||||
|
case "auth":
|
||||||
|
return fmt.Errorf("认证失败,请检查用户名和密码/密钥是否正确")
|
||||||
|
case "sftp_init":
|
||||||
|
return fmt.Errorf("SFTP 子系统初始化失败,请确认远程服务器支持 SFTP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := err.Error()
|
||||||
|
switch {
|
||||||
|
case strings.Contains(msg, "unable authenticate"), strings.Contains(msg, "no auth method"):
|
||||||
|
return fmt.Errorf("认证失败:用户名或密码/密钥不正确")
|
||||||
|
case strings.Contains(msg, "connection refused"):
|
||||||
|
return fmt.Errorf("连接被拒绝:目标机器可能未开启 SSH 服务")
|
||||||
|
case strings.Contains(msg, "timeout"), strings.Contains(msg, "i/o timeout"):
|
||||||
|
return fmt.Errorf("连接超时:请检查网络连通性或防火墙设置")
|
||||||
|
case strings.Contains(msg, "host key mismatch"):
|
||||||
|
return fmt.Errorf("主机密钥不匹配:可能存在中间人攻击风险")
|
||||||
|
case strings.Contains(msg, "permission denied"):
|
||||||
|
return fmt.Errorf("权限不足:当前用户无权访问该资源")
|
||||||
|
case strings.Contains(msg, "no such file"), strings.Contains(msg, "not found"):
|
||||||
|
return fmt.Errorf("文件或目录不存在")
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNetworkError 判断是否为网络层错误(需要重连)
|
||||||
|
func isNetworkError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "broken pipe") ||
|
||||||
|
strings.Contains(msg, "connection reset") ||
|
||||||
|
strings.Contains(msg, "timeout")
|
||||||
|
}
|
||||||
499
internal/sftp/service.go
Normal file
499
internal/sftp/service.go
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
package sftp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/filesystem"
|
||||||
|
|
||||||
|
sftpclient "github.com/pkg/sftp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service SFTP 文件操作服务
|
||||||
|
type Service struct {
|
||||||
|
manager *Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService 创建 SFTP 服务实例
|
||||||
|
func NewService() *Service {
|
||||||
|
return &Service{manager: GetManager()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetManager 获取底层连接管理器(供 App 层调用)
|
||||||
|
func (s *Service) GetManager() *Manager {
|
||||||
|
return s.manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnID 从配置生成连接标识符
|
||||||
|
func ConnID(host string, port int) string {
|
||||||
|
return fmt.Sprintf("%s:%d", host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 核心文件操作 ---
|
||||||
|
|
||||||
|
func (s *Service) ListDir(connID string, dirPath string) ([]map[string]interface{}, error) {
|
||||||
|
c, err := s.getClient(connID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []fs.FileInfo
|
||||||
|
err = c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
var e error
|
||||||
|
entries, e = sc.ReadDir(dirPath)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取目录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]map[string]interface{}, 0, len(entries))
|
||||||
|
for _, info := range entries {
|
||||||
|
fullPath := path.Join(dirPath, info.Name())
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"name": info.Name(),
|
||||||
|
"path": toUnixPath(fullPath),
|
||||||
|
"is_dir": info.IsDir(),
|
||||||
|
"size": info.Size(),
|
||||||
|
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReadFile(connID string, filePath string) (string, error) {
|
||||||
|
c, err := s.getClient(connID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大小限制(与本地模式 ReadFile 的 10MB 上限对齐)
|
||||||
|
const maxSize int64 = 10 << 20
|
||||||
|
err = c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
fi, e := sc.Stat(filePath)
|
||||||
|
if e != nil { return e }
|
||||||
|
if fi.Size() > maxSize {
|
||||||
|
return fmt.Errorf("文件过大 (%s),超过 %d 限制", filesystem.FormatBytes(fi.Size()), maxSize)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
err = c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
f, e := sc.Open(filePath)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
data, e = io.ReadAll(f)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("读取文件失败: %w", err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) WriteFile(connID string, filePath string, content string) error {
|
||||||
|
c, err := s.getClient(connID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
f, e := sc.Create(filePath)
|
||||||
|
if e != nil {
|
||||||
|
return fmt.Errorf("创建文件失败: %w", e)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, e = f.Write([]byte(content))
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteBase64File 将 base64 编码的二进制内容写入远程文件(用于粘贴图片等场景)
|
||||||
|
func (s *Service) WriteBase64File(connID string, filePath string, base64Content string) error {
|
||||||
|
c, err := s.getClient(connID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Content)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("base64 解码失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
f, e := sc.Create(filePath)
|
||||||
|
if e != nil {
|
||||||
|
return fmt.Errorf("创建文件失败: %w", e)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, e = f.Write(data)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetFileInfo(connID string, filePath string) (map[string]interface{}, error) {
|
||||||
|
c, err := s.getClient(connID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var info fs.FileInfo
|
||||||
|
err = c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
var e error
|
||||||
|
info, e = sc.Stat(filePath)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取文件信息失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"name": info.Name(),
|
||||||
|
"path": toUnixPath(filePath),
|
||||||
|
"size": info.Size(),
|
||||||
|
"size_str": filesystem.FormatBytes(info.Size()),
|
||||||
|
"is_dir": info.IsDir(),
|
||||||
|
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
|
"mode": info.Mode().String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
c, err := s.getClient(connID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
return sc.MkdirAll(dirPath)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建目录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
infoMap, _ := s.GetFileInfo(connID, dirPath)
|
||||||
|
return toFileOperationResult(infoMap, true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
c, err := s.getClient(connID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
f, e := sc.Create(filePath)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return f.Close()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
infoMap, _ := s.GetFileInfo(connID, filePath)
|
||||||
|
return toFileOperationResult(infoMap, false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeletePath(connID string, filePath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
c, err := s.getClient(connID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
infoMap, _ := s.GetFileInfo(connID, filePath)
|
||||||
|
|
||||||
|
err = c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
fi, e := sc.Stat(filePath)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
// 递归删除目录
|
||||||
|
return sc.RemoveAll(filePath)
|
||||||
|
}
|
||||||
|
return sc.Remove(filePath)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("删除失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := toFileOperationResult(infoMap, false)
|
||||||
|
result.Deleted = true
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RenamePath(connID string, oldPath, newPath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
c, err := s.getClient(connID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
return sc.Rename(oldPath, newPath)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("重命名失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
infoMap, _ := s.GetFileInfo(connID, newPath)
|
||||||
|
result := toFileOperationResult(infoMap, false)
|
||||||
|
result.OldPath = oldPath
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
||||||
|
func (s *Service) DownloadToTemp(connID string, remotePath string) (string, error) {
|
||||||
|
c, err := s.getClient(connID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览文件大小上限 50MB(比编辑模式宽松)
|
||||||
|
const maxPreviewSize int64 = 50 << 20
|
||||||
|
err = c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
fi, e := sc.Stat(remotePath)
|
||||||
|
if e != nil { return e }
|
||||||
|
if fi.Size() > maxPreviewSize {
|
||||||
|
return fmt.Errorf("预览文件过大: %s", filesystem.FormatBytes(fi.Size()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := os.TempDir()
|
||||||
|
// 用时间戳+随机数避免同名文件覆盖
|
||||||
|
localPath := filepath.Join(tmpDir, fmt.Sprintf("udesk-sftp-preview-%d-%s", time.Now().UnixNano(), filepath.Base(remotePath)))
|
||||||
|
|
||||||
|
err = c.WithRetry(func(sc *sftpclient.Client) error {
|
||||||
|
src, e := sc.Open(remotePath)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dst, e := os.Create(localPath)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
_, e = io.Copy(dst, src)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("下载文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return localPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCommonPaths 返回 SFTP 远程主机常用路径
|
||||||
|
func (s *Service) GetCommonPaths(connID string) (map[string]string, error) {
|
||||||
|
c := s.manager.GetClient(connID)
|
||||||
|
username := "root"
|
||||||
|
if c != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
username = c.config.Username
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
home := "/root"
|
||||||
|
if username != "root" && username != "" {
|
||||||
|
home = fmt.Sprintf("/home/%s", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]string{
|
||||||
|
"home": home,
|
||||||
|
"tmp": "/tmp",
|
||||||
|
"root": "/",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupTempFiles 清理遗留的临时预览文件
|
||||||
|
func CleanupTempFiles() {
|
||||||
|
tmpDir := os.TempDir()
|
||||||
|
entries, err := os.ReadDir(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), "udesk-sftp-preview-") {
|
||||||
|
os.Remove(filepath.Join(tmpDir, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemInfo 通过 SSH 命令采集远程系统信息(磁盘/CPU/内存)
|
||||||
|
func (s *Service) GetSystemInfo(connID string) (map[string]interface{}, error) {
|
||||||
|
c, err := s.getClient(connID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type cmdResult struct {
|
||||||
|
key string // "df" | "mem" | "cpu"
|
||||||
|
output string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发执行三条命令(每条独立超时)
|
||||||
|
results := make(chan cmdResult, 3)
|
||||||
|
cmdTimeout := 6 * time.Second
|
||||||
|
|
||||||
|
runCmd := func(key, cmd string) {
|
||||||
|
out, e := c.RunCommand(cmd)
|
||||||
|
results <- cmdResult{key, out, e}
|
||||||
|
}
|
||||||
|
go runCmd("df", "df -B1 / 2>/dev/null | tail -1")
|
||||||
|
go runCmd("mem", "free -b 2>/dev/null | grep -E '^Mem:' || cat /proc/meminfo 2>/dev/null | head -2")
|
||||||
|
go runCmd("cpu", "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
|
||||||
|
|
||||||
|
var dfOut, memOut, cpuOut string
|
||||||
|
hasErr := false
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
select {
|
||||||
|
case r := <-results:
|
||||||
|
switch r.key {
|
||||||
|
case "df":
|
||||||
|
dfOut = r.output
|
||||||
|
case "mem":
|
||||||
|
memOut = r.output
|
||||||
|
case "cpu":
|
||||||
|
cpuOut = r.output
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
hasErr = true
|
||||||
|
}
|
||||||
|
case <-time.After(cmdTimeout):
|
||||||
|
hasErr = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info := make(map[string]interface{})
|
||||||
|
|
||||||
|
// 解析 CPU 使用率
|
||||||
|
cpuOut = strings.TrimSpace(cpuOut)
|
||||||
|
if usage, err := strconv.ParseFloat(cpuOut, 64); err == nil && usage >= 0 {
|
||||||
|
info["cpu_usage"] = fmt.Sprintf("%.0f%%", usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析磁盘信息: df -B1 / → Filesystem 1M-blocks Used Available Use% Mounted on
|
||||||
|
dfOut = strings.TrimSpace(dfOut)
|
||||||
|
if dfFields := strings.Fields(dfOut); len(dfFields) >= 5 {
|
||||||
|
var diskTotal, diskUsed uint64
|
||||||
|
if v, err := strconv.ParseUint(dfFields[1], 10, 64); err == nil {
|
||||||
|
diskTotal = v
|
||||||
|
info["disk_total"] = v
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseUint(dfFields[2], 10, 64); err == nil {
|
||||||
|
diskUsed = v
|
||||||
|
info["disk_used"] = v
|
||||||
|
}
|
||||||
|
if diskTotal > 0 {
|
||||||
|
info["disk_usage"] = fmt.Sprintf("%.0f%%", float64(diskUsed)/float64(diskTotal)*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析内存信息: free -b | grep Mem: 或 /proc/meminfo
|
||||||
|
memOut = strings.TrimSpace(memOut)
|
||||||
|
if strings.Contains(memOut, "MemTotal:") {
|
||||||
|
parseProcMeminfo(memOut, info)
|
||||||
|
} else if fields := strings.Fields(memOut); len(fields) >= 3 {
|
||||||
|
var memTotal, memUsed uint64
|
||||||
|
if v, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
|
||||||
|
memTotal = v
|
||||||
|
info["mem_total"] = v
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseUint(fields[2], 10, 64); err == nil {
|
||||||
|
memUsed = v
|
||||||
|
info["mem_used"] = v
|
||||||
|
}
|
||||||
|
if memTotal > 0 {
|
||||||
|
info["mem_usage"] = fmt.Sprintf("%.0f%%", float64(memUsed)/float64(memTotal)*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasErr && len(info) == 0 {
|
||||||
|
return nil, fmt.Errorf("采集远程系统信息失败")
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProcMeminfo(output string, info map[string]interface{}) {
|
||||||
|
lines := strings.Split(output, "\n")
|
||||||
|
memMap := make(map[string]uint64)
|
||||||
|
for _, line := range lines {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
key := strings.TrimSuffix(fields[0], ":")
|
||||||
|
if val, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
|
||||||
|
memMap[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total := memMap["MemTotal"] * 1024 // kB → bytes
|
||||||
|
// 可用内存 ≈ MemAvailable (较新内核) 或 MemFree + Buffers + Cached
|
||||||
|
free := memMap["MemFree"]
|
||||||
|
if avail, ok := memMap["MemAvailable"]; ok {
|
||||||
|
free = avail
|
||||||
|
} else {
|
||||||
|
free += memMap["Buffers"] + memMap["Cached"]
|
||||||
|
}
|
||||||
|
used := total - free*1024
|
||||||
|
|
||||||
|
info["mem_total"] = total
|
||||||
|
info["mem_used"] = used
|
||||||
|
if total > 0 {
|
||||||
|
info["mem_usage"] = fmt.Sprintf("%.0f%%", float64(used)/float64(total)*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 内部辅助 ---
|
||||||
|
|
||||||
|
func (s *Service) getClient(connID string) (*Client, error) {
|
||||||
|
c := s.manager.GetClient(connID)
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("SFTP 连接不存在: %s", connID)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toUnixPath(p string) string {
|
||||||
|
return strings.ReplaceAll(p, "\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFileOperationResult(m map[string]interface{}, isDir bool) *filesystem.FileOperationResult {
|
||||||
|
name, _ := m["name"].(string)
|
||||||
|
p, _ := m["path"].(string)
|
||||||
|
size, _ := m["size"].(int64)
|
||||||
|
modTime, _ := m["mod_time"].(string)
|
||||||
|
mode, _ := m["mode"].(string)
|
||||||
|
|
||||||
|
return &filesystem.FileOperationResult{
|
||||||
|
Path: p,
|
||||||
|
Name: name,
|
||||||
|
Size: size,
|
||||||
|
SizeStr: filesystem.FormatBytes(size),
|
||||||
|
IsDir: isDir,
|
||||||
|
ModTime: modTime,
|
||||||
|
Mode: mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
22
internal/storage/models/connection_profile.go
Normal file
22
internal/storage/models/connection_profile.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ConnectionProfile 连接配置模型(SQLite 持久化)
|
||||||
|
type ConnectionProfile struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||||
|
Host string `gorm:"type:varchar(255)" json:"host"`
|
||||||
|
Port int `gorm:"default:22" json:"port"`
|
||||||
|
Username string `gorm:"type:varchar(100);default:root" json:"username"`
|
||||||
|
Password string `gorm:"type:text" json:"password"`
|
||||||
|
KeyPath string `gorm:"type:text" json:"key_path"`
|
||||||
|
Type string `gorm:"type:varchar(20);not null;index" json:"type"` // local|remote|sftp
|
||||||
|
Token string `gorm:"type:text" json:"token"`
|
||||||
|
LastConnected *time.Time `json:"last_connected"`
|
||||||
|
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ConnectionProfile) TableName() string { return "connection_profiles" }
|
||||||
@@ -63,6 +63,7 @@ func InitFast() (*gorm.DB, error) {
|
|||||||
// SQLite 的 AutoMigrate 很快,不会造成明显延迟
|
// SQLite 的 AutoMigrate 很快,不会造成明显延迟
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&models.AppConfig{},
|
&models.AppConfig{},
|
||||||
|
&models.ConnectionProfile{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
"name": "u-desk",
|
"name": "u-desk",
|
||||||
"email": "lxy208@126.com"
|
"email": "lxy208@126.com"
|
||||||
},
|
},
|
||||||
"frontend:dir": "web",
|
"frontend:dir": "frontend",
|
||||||
"wailsjsdir": "./web/src/wailsjs"
|
"wailsjsdir": "./frontend/src/wailsjs"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user