diff --git a/.gitignore b/.gitignore index 120d7bb..543e46c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/windows/nsis/MicrosoftEdgeWebview2Setup.exe .claude/ u-desk.exe u-fs-agent-linux +docs/08-用户指南/u-desk-site/ diff --git a/app.go b/app.go index 8a554bb..f307971 100644 --- a/app.go +++ b/app.go @@ -8,16 +8,16 @@ import ( "fmt" "os" "path/filepath" - "strconv" stdruntime "runtime" + "strconv" "strings" "sync" "time" - "golang.org/x/sys/windows/registry" "u-desk/internal/api" "u-desk/internal/common" "u-desk/internal/filesystem" + "u-desk/internal/hotkey" osssvc "u-desk/internal/ossdrv" "u-desk/internal/service" "u-desk/internal/sftp" @@ -25,24 +25,28 @@ import ( "u-desk/internal/storage/models" "u-desk/internal/system" + "golang.org/x/sys/windows/registry" + "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/w32" + "gorm.io/gorm" ) // App 应用结构体 type App struct { - ctx context.Context - mainWindow *application.WebviewWindow - updateAPI *api.UpdateAPI - updateTicker *time.Ticker - configAPI *api.ConfigAPI - pdfAPI *api.PdfAPI - filesystem *filesystem.FileSystemService - sftpService *sftp.Service - ossService *osssvc.Service - profileSvc *service.ProfileService - isAlwaysOnTop bool - mu sync.Mutex + ctx context.Context + mainWindow *application.WebviewWindow + updateAPI *api.UpdateAPI + updateTicker *time.Ticker + configAPI *api.ConfigAPI + pdfAPI *api.PdfAPI + filesystem *filesystem.FileSystemService + sftpService *sftp.Service + ossService *osssvc.Service + profileSvc *service.ProfileService + isAlwaysOnTop bool + mu sync.Mutex + unregisterHotkey func() } // App 方法命名约定: @@ -59,6 +63,41 @@ func (a *App) SetMainWindow(w *application.WebviewWindow) { a.mainWindow = w } +// RegisterGlobalHotkey 注册 Ctrl+Shift+B 全局热键(需在窗口创建后调用) +func (a *App) RegisterGlobalHotkey() { + if a.mainWindow == nil { + return + } + a.mu.Lock() + if a.unregisterHotkey != nil { + a.mu.Unlock() + return + } + a.mu.Unlock() + hwnd := uintptr(a.mainWindow.NativeWindow()) + if hwnd == 0 { + fmt.Println("[全局热键] HWND 为 0,注册跳过") + return + } + const id int32 = 1 + if err := hotkey.Register(hwnd, id, hotkey.ModControl|hotkey.ModShift, 0x42); err != nil { + fmt.Printf("[全局热键] RegisterHotKey Ctrl+Shift+B 失败: %v\n", err) + return + } + fmt.Println("[全局热键] Ctrl+Shift+B 已注册") + a.mu.Lock() + a.unregisterHotkey = func() { hotkey.Unregister(hwnd, id) } + a.mu.Unlock() +} + +// HandleHotkey 处理全局热键回调:切换 BgmBar 显示/隐藏 +func (a *App) HandleHotkey() { + if a.mainWindow == nil { + return + } + a.mainWindow.EmitEvent("toggle-bgm-bar") +} + // ServiceStartup Wails v3 服务启动生命周期(替代 v2 的 Startup) func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error { a.ctx = ctx @@ -101,8 +140,8 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) return fmt.Errorf("模块初始化失败: %w", err) } - // 5. 清理遗留的 SFTP 临时预览文件 - sftp.CleanupTempFiles() + // 5. 清理过期的下载缓存 + storage.CleanupExpiredCache() // 6. 异步初始化:UpdateAPI(涉及网络请求,完全异步) go func() { @@ -121,6 +160,24 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) } }() + // 延迟注册全局热键(轮询等待原生窗口创建完成) + // RegisterHotKey 必须在创建窗口的同一线程调用, + // 通过 PostMessage 将注册请求投递到主线程消息循环 + go func() { + for i := 0; i < 20; i++ { + time.Sleep(200 * time.Millisecond) + if a.mainWindow == nil { + return + } + hwnd := uintptr(a.mainWindow.NativeWindow()) + if hwnd != 0 { + hotkey.PostMessage(hwnd, hotkey.WM_APP_HOTKEY, 0, 0) + return + } + } + fmt.Println("[全局热键] 等待窗口超时") + }() + return nil } @@ -193,6 +250,9 @@ func (a *App) ServiceShutdown() error { if a.updateTicker != nil { a.updateTicker.Stop() } + if a.unregisterHotkey != nil { + a.unregisterHotkey() + } shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -217,7 +277,7 @@ func (a *App) ServiceShutdown() error { if a.sftpService != nil { sftp.GetManager().Shutdown() } - sftp.CleanupTempFiles() + storage.CleanupExpiredCache() // 关闭所有 OSS 连接 osssvc.GetManager().Shutdown() @@ -856,11 +916,11 @@ func (a *App) ensureSftpService() *sftp.Service { // 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"` + 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"` } @@ -874,8 +934,12 @@ func (a *App) SftpConnect(req SftpConnectRequest) (string, error) { KeyPath: req.KeyPath, KeyPassphrase: req.KeyPassphrase, } - if config.Port == 0 { config.Port = 22 } - if config.Timeout == 0 { config.Timeout = 15 * time.Second } + if config.Port == 0 { + config.Port = 22 + } + if config.Timeout == 0 { + config.Timeout = 15 * time.Second + } svc := a.ensureSftpService() _, err := svc.GetManager().Connect(config) @@ -967,6 +1031,16 @@ func (a *App) SftpDownloadToTemp(connID string, remotePath string) (string, erro return a.ensureSftpService().DownloadToTemp(connID, remotePath) } +// SftpDownloadSiteForPreview 下载 HTML 及其网站资源到本地临时目录 +func (a *App) SftpDownloadSiteForPreview(connID string, remotePath string) (string, error) { + return a.ensureSftpService().DownloadSiteForPreview(connID, remotePath) +} + +// SftpDownloadToTempCached 带缓存的 SFTP 下载(命中缓存直接返回本地路径) +func (a *App) SftpDownloadToTempCached(connID string, remotePath string, fileSize int64, modTime string) (string, error) { + return a.ensureSftpService().DownloadToTempCached(connID, remotePath, fileSize, modTime) +} + // SftpGetCommonPaths 获取 SFTP 远程主机常用路径 func (a *App) SftpGetCommonPaths(connID string) (map[string]string, error) { return a.ensureSftpService().GetCommonPaths(connID) @@ -1018,6 +1092,16 @@ func (a *App) OssDownloadToTemp(connID string, key string) (string, error) { return a.ensureOssService().DownloadToTemp(connID, key) } +// OssDownloadSiteForPreview OSS 下载 HTML 及其引用的资源到临时目录 +func (a *App) OssDownloadSiteForPreview(connID string, key string) (string, error) { + return a.ensureOssService().DownloadSiteForPreview(connID, key) +} + +// OssDownloadToTempCached 带缓存的 OSS 下载(命中缓存直接返回本地路径) +func (a *App) OssDownloadToTempCached(connID string, key string, fileSize int64, modTime string) (string, error) { + return a.ensureOssService().DownloadToTempCached(connID, key, fileSize, modTime) +} + // OssReadFile OSS 读取文件 func (a *App) OssReadFile(connID string, key string) (string, error) { return a.ensureOssService().ReadFile(connID, key) @@ -1078,21 +1162,22 @@ func (a *App) OssGetSignedURL(connID string, key string) (string, error) { // --- 连接配置 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"` - AccessKey string `json:"access_key"` - SecretKey string `json:"secret_key"` - Bucket string `json:"bucket"` - Region string `json:"region"` - Endpoint string `json:"endpoint"` - LastConnected *int64 `json:"last_connected"` + 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"` + Provider string `json:"provider"` + Token string `json:"token"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + Bucket string `json:"bucket"` + Region string `json:"region"` + Endpoint string `json:"endpoint"` + LastConnected *int64 `json:"last_connected"` } func (a *App) ensureProfileSvc() *service.ProfileService { @@ -1106,7 +1191,9 @@ func (a *App) ensureProfileSvc() *service.ProfileService { func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) { list, err := a.ensureProfileSvc().ListProfiles() - if err != nil { return nil, err } + if err != nil { + return nil, err + } result := make([]map[string]interface{}, len(list)) for i, p := range list { result[i] = map[string]interface{}{ @@ -1118,6 +1205,7 @@ func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) { "password": p.Password, "keyPath": p.KeyPath, "type": p.Type, + "provider": p.Provider, "token": p.Token, "accessKey": p.AccessKey, "secretKey": p.SecretKey, @@ -1135,7 +1223,7 @@ func (a *App) SaveConnectionProfile(req SaveProfileRequest) (map[string]interfac 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, + KeyPath: req.KeyPath, Type: req.Type, Provider: req.Provider, Token: req.Token, AccessKey: req.AccessKey, SecretKey: req.SecretKey, Bucket: req.Bucket, Region: req.Region, Endpoint: req.Endpoint, } @@ -1161,18 +1249,70 @@ func (a *App) GetLocalSystemInfo() (map[string]interface{}, error) { cpuInfo, err := system.GetCPUInfo() if err == nil && cpuInfo != nil { - if v, ok := cpuInfo["usage"].(string); ok { info["cpu_usage"] = v } + 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 } + 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 } + if v, ok := diskInfos[0]["usage"].(string); ok { + info["disk_usage"] = v + } } return info, nil } + +// ========== BGM 播放列表持久化 ========== + +// BgmPlaylistItem 播放列表条目 +type BgmPlaylistItem struct { + Name string `json:"name"` + Path string `json:"path"` + ProfileID string `json:"profile_id"` +} + +// BgmGetPlaylist 获取播放列表 +func (a *App) BgmGetPlaylist() ([]BgmPlaylistItem, error) { + db := storage.GetDB() + if db == nil { + return nil, fmt.Errorf("数据库未初始化") + } + var rows []models.BgmPlaylist + db.Order("sort ASC").Find(&rows) + items := make([]BgmPlaylistItem, len(rows)) + for i, r := range rows { + items[i] = BgmPlaylistItem{Name: r.Name, Path: r.Path, ProfileID: r.ProfileID} + } + return items, nil +} + +// BgmSavePlaylist 全量保存播放列表(前端调用时传完整列表) +func (a *App) BgmSavePlaylist(items []BgmPlaylistItem) error { + db := storage.GetDB() + if db == nil { + return fmt.Errorf("数据库未初始化") + } + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("DELETE FROM bgm_playlist").Error; err != nil { + return err + } + sort := uint(0) + for _, item := range items { + if item.Name == "" || item.Path == "" { + continue + } + tx.Create(&models.BgmPlaylist{Name: item.Name, Path: item.Path, ProfileID: item.ProfileID, Sort: sort}) + sort++ + } + return nil + }) +} diff --git a/docs/04-功能迭代/GO-DESK-10.SFTP直连支持/README.md b/docs/04-功能迭代/GO-DESK-10.SFTP直连支持/README.md new file mode 100644 index 0000000..6ea039c --- /dev/null +++ b/docs/04-功能迭代/GO-DESK-10.SFTP直连支持/README.md @@ -0,0 +1,177 @@ +# GO-DESK-10: SFTP 直连支持 + +> 版本: v0.5.0 | 状态: 开发中 | 分支: fs-only-v3 → u-desk-sftp + +## 概述 + +为 U-Desk 新增 **SFTP(SSH File Transfer Protocol)直连模式**,作为第三种文件系统传输方式,与现有的本地模式(Wails IPC)和远程 HTTP Agent 模式并列。 + +用户无需在目标机器部署任何 Agent 服务,仅需 SSH 账号即可直接浏览和操作远程 Linux 服务器的文件系统。 + +## 架构设计 + +``` +FsTransport (interface) +├── WailsTransport → Wails IPC → FileSystemService (本地 os) +├── HttpTransport → HTTP REST → u-fs-agent (远程部署) +└── SftpTransport [NEW]→ Wails IPC → SftpService → pkg/sftp (SSH/SFTP) +``` + +**核心原则**:复用现有 `FsTransport` 接口抽象,前端组件无感知。 + +## 技术方案 + +### 后端(Go) + +**新增依赖**: +- `github.com/pkg/sftp` — SFTP 客户端库 +- `golang.org/x/crypto/ssh` — SSH 协议(已在 indirect 中,提升为 direct) + +**新增包 `internal/sftp/`**(4 个文件): + +| 文件 | 职责 | +|------|------| +| `config.go` | `Config` 结构体:Host/Port/Username/Password/KeyPath/Timeout | +| `client.go` | `Manager`(sync.Map 连接池,以 host:port 为 key)+ `Client`(单连接封装:SSH 握手、健康检查、自动重连、双认证) | +| `service.go` | `Service`(对齐 FileSystemService 返回格式的文件操作方法) | +| `errors.go` | `ConnectionError` + `ToUserMessage()` 中文友好错误映射 | + +**连接管理**: +- 以 `host:port` 为 key 的 `sync.Map` 连接池 +- 支持密码认证 / 私钥文件认证(二选一) +- `WithRetry(fn)` — 操作前检查健康度,断线自动重连(3 次,指数退避) +- `IsHealthy()` — 通过 `Stat("/")` 探测 +- 切换 profile 时复用已有连接(避免重复 SSH 握手) + +**App 层新增绑定方法**(12 个): + +``` +SftpConnect(req) → connID 建立连接 +SftpDisconnect(connID) 断开连接 +SftpListDir(connID, path) 列目录 +SftpReadFile(connID, path) 读文件 +SftpWriteFile(req) 写文件 +SftpGetFileInfo(connID, path) 文件信息 +SftpCreateDir(connID, path) 创建目录 +SftpCreateFile(connID, path) 创建文件 +SftpDeletePath(connID, path) 删除 +SftpRenamePath(req) 重命名 +SftpDownloadToTemp(connID, path) 下载到临时目录(预览用) +SftpGetCommonPaths(connID) 远程主机常用路径 +``` + +### 前端(TypeScript/Vue) + +**新建 `frontend/src/api/sftp-transport.ts`**: +- 实现 `FsTransport` 接口的完整 23 个方法 +- `connect()/disconnect()/requireConn()` 管理 SFTP 会话生命周期 +- `downloadForPreview(remotePath)` 下载远程文件到本地临时目录(带缓存) +- ZIP/回收站/openPath 等不适用方法抛出 "暂未实现" + +**修改 `connection-manager.ts`**: +- `ConnectionType` 扩展:`'local' | 'remote' | 'sftp'` +- `ConnectionProfile` 新增字段:`username?`, `password?`, `keyPath?` +- `applyActive()` 增加 sftp 分支(创建 SftpTransport → connect() → 连通性检查) +- 新增 `isSftp()` 方法 +- `isRemote()` 扩展为包含 `'sftp'` +- `getFileServerBaseURL()` sftp 模式返回 `'http://localhost:8073'` +- 切换/删除 profile 时显式断开 SFTP 连接 + +**修改 `ConnectionDialog.vue`**: +- 新增连接类型选择器(RadioGroup: HTTP Agent / SFTP) +- SFTP 类型显示额外字段:用户名(默认 root)、密码、私钥路径 +- HTTP Agent 类型保持原有 Token 字段 +- 默认端口根据类型切换(SFTP=22,HTTP=9876) +- `addProfile` 时 type 使用表单选择的值 + +**修改 `ConnectionIndicator.vue`**: +- `.dot.sftp` 样式(紫色 `#7c3aed`) +- `dotClass(p)` 函数返回 local/remote/sftp +- "更多操作"按钮条件从 `type === 'remote'` 改为 `type !== 'local'` + +**修改 `Sidebar.vue`**: +- 模式标签区分显示:本地(绿)/远程(蓝)/SFTP(紫) +- 新增 `isSftp` 响应式变量 + +**修改 `useFilePreview.ts`**: +- SFTP 模式下 `updatePreviewUrl()` 先调用 `downloadForPreview()` 下载到本地临时目录 +- 下载完成后使用 `http://localhost:8073/localfs/{temp_path}` 预览(复用现有 LocalFileServer) +- 已下载文件缓存避免重复下载 + +### 文件预览方案 + +**策略:下载到本地临时目录 + 复用 LocalFileServer** + +``` +用户点击 SFTP 远程文件 + → useFilePreview 检测 isSftp() + → SftpTransport.downloadForPreview(remotePath) + → Go 后端 SFTP 下载到 %TEMP%/udesk-sftp-preview-{filename} + → 返回本地绝对路径 + → 预览 URL = http://localhost:8073/localfs/{temp_path} + → 现有 LocalFileServer 直接提供服务 +``` + +**临时文件管理**: +- 启动时清理上次遗留的 `udesk-sftp-preview-*` 文件(`ServiceStartup` 中调用 `CleanupTempFiles()`) +- 关闭时清理本次创建的临时文件(`ServiceShutdown` 中调用) +- SftpTransport 内部维护 remotePath → localTempPath 缓存 + +## 文件变更清单 + +### 新增(5 个) + +| 文件 | 说明 | +|------|------| +| `internal/sftp/config.go` | SFTP 配置结构体 | +| `internal/sftp/client.go` | SSH/SFTP 连接管理器 | +| `internal/sftp/service.go` | SFTP 文件操作服务 | +| `internal/sftp/errors.go` | 错误处理 + 用户友好消息 | +| `frontend/src/api/sftp-transport.ts` | 前端 FsTransport 实现 | + +### 修改(8 个) + +| 文件 | 变更 | +|------|------| +| `go.mod` | 添加 `github.com/pkg/sftp` 依赖 | +| `internal/filesystem/fs.go` | `formatBytes` 导出为 `FormatBytes` | +| `app.go` | +sftpService 字段 + 12 个绑定方法 + Shutdown 扩展 + 清理临时文件 | +| `frontend/src/api/connection-manager.ts` | 类型扩展 + sftp 分支 + isSftp() | +| `frontend/src/.../ConnectionDialog.vue` | 类型选择器 + SFTP 表单 | +| `frontend/src/.../ConnectionIndicator.vue` | sftp 样式 + dotClass | +| `frontend/src/.../Sidebar.vue` | SFTP 模式标签 | +| `frontend/src/.../useFilePreview.ts` | SFTP 下载预览 | + +### 自动生成 + +| 文件 | 触发方式 | +|------|---------| +| `frontend/src/wailsjs/v3-bindings/u-desk/app.ts` | `wails dev` 启动时重新生成 | + +## 与现有模式的对比 + +| 特性 | 本地 (WailsTransport) | 远程 (HttpTransport) | SFTP (SftpTransport) | +|------|----------------------|---------------------|---------------------| +| 协议 | Wails IPC | HTTP REST | SSH/SFTP | +| 目标要求 | 本地桌面 | 部署 u-fs-agent | SSH 服务 | +| 认证 | 无 | Bearer Token | 密码 / 私钥 | +| 文件预览 | LocalFileServer | Agent 反向代理 | 下载到临时目录 | +| ZIP 支持 | ✅ | ❌ | ❌ | +| 回收站 | ✅ | ❌ | ❌ | +| 延迟 | < 10ms | 取决于网络 | 取决于网络(首次握手 ~2s) | +| 连接复用 | N/A | 每次请求 HTTP | sync.Map 连接池 | + +## 后续迭代方向 + +### Phase 2(体验完善) +- 密钥文件选择器(Wails OpenFileDialog) +- 断线重连 UI 提示 + 手动重连按钮 +- 大文件传输进度显示 +- saveBase64File 二进制上传支持 + +### Phase 3(高级功能) +- SSH known_hosts 安全验证(替换 InsecureIgnoreHostKey) +- TCP KeepAlive + 应用层心跳防空闲断开 +- 端口转发(SOCKS5/本地转发) +- 符号链接处理选项 +- 并发传输队列 + 带宽限制 diff --git a/docs/04-功能迭代/GO-DESK-10.SFTP直连支持/开发经验.md b/docs/04-功能迭代/GO-DESK-10.SFTP直连支持/开发经验.md new file mode 100644 index 0000000..7ab2551 --- /dev/null +++ b/docs/04-功能迭代/GO-DESK-10.SFTP直连支持/开发经验.md @@ -0,0 +1,114 @@ +# SFTP 直连 + autoConnect 开发经验 + +> 日期:2026-05-04 | 对应分支:fs-only-v3 + +--- + +## 架构决策 + +### 1. 连接池模式替代单连接 + +**背景**: 原方案切换 profile 时断开旧连接再建新连接,切换慢且丢失状态。 + +**决策**: `Map` 连接池。所有 profile 可同时在线,切换为 O(1)。 + +**关键实现**: +- `buildAndPool()`: 创建 transport 并入池 +- `connect()`: 池中已有则直接复用,否则新建 +- `disconnectProfile(id)`: 断开指定 profile,从池移除 +- `disconnectAll()`: 清空池,保留 local + +### 2. 文件服务器 URL 集中管理 + +**背景**: 前端 8+ 处硬编码 `localhost:2652`,端口冲突时全部失效。 + +**决策**: 新建 `file-server.ts`,从后端动态获取 URL。 + +**原则**: **单一数据源**。`connectionManager` 不再缓存 URL,所有模块从 `file-server.ts` 读取。 + +### 3. 端口自动回退 + +**背景**: 端口被占用时应用崩溃,用户必须手动杀进程。 + +**决策**: `listenWithFallback(basePort, handler)` 尝试 basePort + 0..9,直接 `srv.Serve(l)` 消除 TOCTOU。 + +**关键**: 不用 `Listen → Close → ListenAndServe`(有竞态),改为把 listener 传给 `Serve`。 + +--- + +## 踩坑记录 + +### 踩坑 1: autoConnect 不工作 — 三层嵌套根因 + +**现象**: 开启"启动时自动连接",重启后服务器不连接。手动点击则正常。 + +**排查过程**: +1. 加诊断日志 → 发现 `loadFromDB` 正常执行,DB 返回 4 条记录 +2. 日志显示 `lastConnected: null`(所有 profile)→ `lc: false` +3. 原因链: + - Go JSON tag 是 `last_connected`,前端读 `p.lastConnected` → **字段名不匹配** + - `SaveProfileRequest` 没有 `LastConnected` 字段 → **从未持久化** + - autoConnect 守卫 `p.lastConnected` → 即使修了前两层,旧数据仍是 null → **守卫逻辑有误** + +**修复**: +```ts +// 1. 字段名 fallback +lastConnected: p.lastConnected || p.last_connected ? ... : undefined +// 2. persistProfile 传递 lastConnected +lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined +// 3. 去掉 lastConnected 守卫(核心修复) +if (p.type !== 'local') { // 原来是 p.type !== 'local' && p.lastConnected +``` + +**教训**: 数据链路问题要追踪完整链路:前端写入 → Go 接收 → DB 存储 → DB 读取 → Go 返回 → 前端读取。每一步都可能有字段名/类型/单位不匹配。 + +### 踩坑 2: Settings 弹窗被 overflow:hidden 裁剪 + +**现象**: 点击表头 `···` 按钮,弹窗不出现。 + +**根因**: `settings-panel` 在 `server-content`(`overflow: hidden`)内部。 + +**修复**: DOM 上移到 `server-content` 外部,改用 `position: absolute` 相对 `sidebar-section`。 + +### 踩坑 3: More-menu 被收藏夹遮挡 + +**现象**: 最后一个服务器行的 `···` 菜单被收藏夹区块盖住。 + +**根因**: `.section-content` 有 `overflow: hidden`(用于折叠动画),裁剪了 `.more-menu`。同时服务器 section 的 z-index 低于收藏夹。 + +**修复**: +- `.server-content` 加 `overflow: visible` 覆盖通用规则 +- 服务器 section 在菜单打开时加 `z-index: 30`(`.section-on-top` 类) + +### 踩坑 4: require() 在 Vite ESM 构建中不可靠 + +**现象**: `const { getFileServerBaseURL } = require('./file-server')` 在 dev 模式正常,production build 失败。 + +**修复**: 全部改为 ES 静态 `import`。 + +### 踩坑 5: Go time.Unix 返回值不能直接赋给 *time.Time + +```go +// ❌ 编译错误 +p.LastConnected = time.Unix(*req.LastConnected, 0) + +// ✅ 中间变量 +t := time.Unix(*req.LastConnected, 0) +p.LastConnected = &t +``` + +--- + +## 最佳实践 + +### SFTP 二进制文件写入 +前端剪贴板图片 → `canvas.toDataURL()` 得到 base64 → `SftpWriteBase64File` Go binding → base64 解码 → SFTP Create + Write。 + +### CSS overflow 与弹窗 +`overflow: hidden` 用于折叠动画时,会裁剪子元素的 `position: absolute` 弹窗。解法:弹窗放在 overflow 容器外部,用 `position: absolute` 相对最近的 `position: relative` 祖先定位。 + +### z-index 层级管理 +- 基础层: 1-10 +- 弹出菜单/面板: 20-30 +- 模态/对话框: 40-50 +- 确保弹出元素的父容器也有足够的 z-index diff --git a/docs/04-功能迭代/GO-DESK-8.Wails-v3迁移/README.md b/docs/04-功能迭代/GO-DESK-8.Wails-v3迁移/README.md new file mode 100644 index 0000000..28cb4a0 --- /dev/null +++ b/docs/04-功能迭代/GO-DESK-8.Wails-v3迁移/README.md @@ -0,0 +1,126 @@ +# GO-DESK-8: Wails v3 迁移变更分析 + +> 范围: `44847e0`(v0.4.0) → `f54bf1c`(fs-only-v3) | 提交数: 1 | 日期: 2026-05-01 + +## 变更总览 + +| 类别 | 数量 | 说明 | +|------|------|------| +| 重命名 | 77 | web/ → frontend/(git rename,历史保留) | +| 新增 | 94 | v3 构建模板 + bindings + 新文件 | +| 删除 | 4 | 旧文件(clipboard png、md5、v2 transport/types) | +| 修改 | 10 | 核心代码适配 v3 API | +| **合计** | **185** | **+7,772 / -918 行** | + +--- + +## 一、框架升级(核心) + +### Wails v2 → v3 API 映射 + +| v2 (旧) | v3 (新) | 文件 | +|---------|---------|------| +| `wails.Run(&options.App{...})` | `application.New()` + `Window.NewWithOptions()` | main.go | +| `options.App.Bind: []interface{}{app}` | `Services: []application.Service{application.NewService(app)}` | main.go | +| `AssetServer: &assetserver.Options{}` | `Assets: application.AssetOptions{Handler, Middleware}` | main.go | +| `app.Startup(ctx)` / `app.Shutdown(ctx)` | `app.ServiceStartup(ctx, opts)` / `app.ServiceShutdown()` | app.go | +| `runtime.Window(ctx)` | `a.mainWindow`(手动注入) | app.go | +| `runtime.*` 函数调用 | `window.*` 方法 + v3-bindings 自动生成 | 前端 | +| `//go:embed all:web/dist` | `//go:embed all:frontend/dist` | main.go | + +### main.go 实质性变更 +- **Middleware 中间件**: 拦截 `/wails/custom.js` 返回空 200,消除控制台 404 +- **DevTools**: 延迟 2s 调用 `window.OpenDevTools()`(production + devtools build tag) +- **窗口主题**: Windows CustomTheme 配置亮/暗模式标题栏颜色 + +### app.go 实质性变更 +- **新增** `SetMainWindow()` — v3 需要手动注入窗口引用 +- **新增** `SetWindowTitleBarColor()` — v3 窗口主题色动态切换 +- **新增** `sync.Mutex` — 并发安全保护 mainWindow +- **生命周期**: `Startup/Shutdown` → `ServiceStartup/ServiceShutdown`(返回 error) +- **错误处理**: panic → return error(符合 Go 惯例) + +--- + +## 二、前端代码修改(有业务逻辑变化的) + +### App.vue (+375/-60 行) +- Tabs padding-top 覆盖(Arco Design 默认 16px → 0) +- import 路径更新:`@/wailsjs/v3-bindings/u-desk/app` +- 窗口控制方法改用 v3 binding 导入 + +### Sidebar.vue (+406 行) +- **新架构**: 双区块折叠(收藏夹 + 帮助文档),各自独立 header + content +- **滚动优化**: `.sidebar overflow:hidden` + 收藏内容区 `overflow-y:auto` 内部滚动 +- **帮助区块**: 固定底部 `flex-shrink:0`,默认展开 +- 折叠动画: max-height + opacity CSS transition + +### useFavorites.ts (+259 行) +- **修复**: `longPressTimer` const → let(解决 Assignment to constant variable TypeError) + +### stores/ (config/theme/update) +- **config.ts**: Wails v3 绑定加载方式调整 +- **theme.ts**: 窗口主题色通过 v3 API 设置 +- **update.ts**: 更新检查逻辑适配 v3 事件系统 + +### wails-transport.ts (+121 行) +- **全新**: v3 transport 层实现(替代 v2 的 runtime 调用) + +### UpdateNotification.vue / UpdatePanel.vue +- 事件监听从 v2 runtime 改为 v3 OffAll/events 模式 + +--- + +## 三、依赖变更 + +```diff +- github.com/wailsapp/wails/v2 v2.12.0 ++ github.com/wailsapp/wails/v3 v3.0.0-alpha.80 + +- go 1.25.6 ++ go 1.26 + ++ github.com/wailsapp/wails/v3/pkg/w32 # Win32 直接调用 ++ dario.cat/mergo v1.0.2 # 结构体合并 +``` + +移除: `go-toast/v2`(v3 自带通知)、`gosod`/`slicer`(v2 工具库) + +--- + +## 四、新增构建基础设施(94 个文件,均为 Wails v3 标准模板) + +以下由 `wails3 task generate` 自动生成,无自定义逻辑: + +| 目录 | 用途 | +|------|------| +| `build/config.yml` | 项目配置(dev mode executes 流水线) | +| `Taskfile.yml` | 根级任务定义(dev/build/run) | +| `build/android/` | Android 构建模板(Gradle + Java Bridge) | +| `build/darwin/` | macOS 构建(Info.plist + Icons) | +| `build/ios/` | iOS 构建(Xcode project) | +| `build/linux/` | Linux 构建(AppImage + nfpm) | +| `build/docker/` | Docker 交叉编译 | +| `build/windows/nsis/` | NSIS 安装包脚本 | +| `build/windows/msix/` | MSIX 打包配置 | +| `frontend/src/wailsjs/v3-bindings/` | v3 TypeScript 绑定(自动生成) | +| `frontend/bindings/` | v3 绑定副本(备用路径) | + +--- + +## 五、删除项(4 个文件) + +| 文件 | 原因 | +|------|------| +| `cmd/agent/clipboard_*.png` | 截图残留,已归档到 `.archive/` | +| `web/package.json.md5` | 旧完整性校验文件 | +| `web/src/api/wails-transport.ts` | v2 版本,已被 frontend 下新版替代 | +| `web/src/types/window.d.ts` | v2 类型声明,已被 frontend 下新版替代 | + +--- + +## 六、风险点 + +1. **alpha.80 稳定性**: Wails v3 仍为 alpha,部分 API 可能后续 breaking change +2. **OpenDevTools sleep hack**: 2s 硬编码延迟不够可靠,待 OnDomReady 稳定后替换 +3. **v2 bindings 残留**: `frontend/src/wailsjs/wailsjs/`(v2)仍随重命名保留在 frontend/ 下,如不再使用应清理 diff --git a/docs/04-功能迭代/GO-DESK-9.插件系统/README.md b/docs/04-功能迭代/GO-DESK-9.插件系统/README.md new file mode 100644 index 0000000..e9b8e55 --- /dev/null +++ b/docs/04-功能迭代/GO-DESK-9.插件系统/README.md @@ -0,0 +1,66 @@ +# GO-DESK-9: 插件系统 + +> 状态:Phase 0 已实施完成,待推进 Phase 1 +> 创建日期:2026-05-01 +> 前置文档:`../../02-架构设计/插件化架构方案.md`(初版调研) + +--- + +## 一、背景与动机 + +### 1.1 当前痛点 + +| 痛点 | 现状 | 影响 | +|------|------|------| +| **app.go God Object** | 825 行,47 个方法全在一个 struct 上 | 难以维护,新功能必须改核心文件 | +| **App.vue 硬编码映射** | `getComponent()` 只有 2 个 key 的字面量对象 | 新 Tab 必须改源码 | +| **FileEditorPanel if/else 链** | 10 层 v-if/v-else-if | 新增文件类型需改 5+ 处 | +| **前后端配置断层** | 后端定义 3 个 Tab,前端硬编码只保留 file-system | 新 Tab 无法透传到前端 | +| **无扩展机制** | 所有功能编译时固定 | 无法按需加载,安装包膨胀 | + +### 1.2 目标 + +建立**两层插件体系**(内置 + 外部市场),使 u-desk 从"单体应用"演进为**可扩展平台**。 + +## 二、文档结构 + +``` +GO-DESK-9.插件系统/ +├── README.md ← 本文件(总览) +├── 设计文档/ +│ ├── 架构设计.md ← 系统形态、两层体系、设置面板原型 +│ ├── 接口定义.md ← Go 后端 + TS 前端完整接口 +│ ├── 数据模型.md ← plugin_state 表 DDL 与存储策略 +│ └── 复杂度与价值评估.md ← 投入产出分析 + 远期风险预警 +├── 任务规划/ +│ ├── 实施路线图.md ← Phase 0~5 全景时间线与范围 +│ └── Phase0-基础设施.md ← Phase 0 详细步骤与验证标准 +└── 决策记录/ + └── README.md ← 关键技术决策(adapter 模式等) +``` + +## 三、快速导航 + +| 如果你想看 | 去哪里 | +|-----------|--------| +| 系统长什么样 | `设计文档/架构设计.md` | +| UI 插槽怎么切 | `设计文档/架构设计.md` → 第四章 | +| 接口怎么定义的 | `设计文档/接口定义.md` | +| 数据库存什么 | `设计文档/数据模型.md` | +| 复杂度值不值 | `设计文档/复杂度与价值评估.md` | +| 分几步做、每步做什么 | `任务规划/实施路线图.md` | +| Phase 0 具体怎么动手 | `任务规划/Phase0-基础设施.md` | +| 为什么选这个方案不选那个 | `决策记录/README.md` | + +## 四、里程碑概览 + +``` +Phase 0 ████████████ 基础设施骨架(当前目标) +Phase 1 ████ Draw.io 验证插件 +Phase 2 █████████████ 预览系统重构 +Phase 3 █████████████ Tab 插件化 + 设置面板 + UI 插槽 +Phase 4 ██████ 外部插件支持 +Phase 5 ░░░░░░░░░░░░░ 插件市场(远景) +``` + +每个 Phase 可独立交付验证。 diff --git a/docs/04-功能迭代/GO-DESK-9.插件系统/任务规划/Phase0-基础设施.md b/docs/04-功能迭代/GO-DESK-9.插件系统/任务规划/Phase0-基础设施.md new file mode 100644 index 0000000..b26d53a --- /dev/null +++ b/docs/04-功能迭代/GO-DESK-9.插件系统/任务规划/Phase0-基础设施.md @@ -0,0 +1,149 @@ +# Phase 0:基础设施骨架 + +## 目标 + +建好管道,不改现有功能。验证编译通过 + API 可调用。 + +**不包含**:无 UI 变化、无真实插件注册、无设置面板。 + +--- + +## 文件清单与实施顺序 + +### Step 1:核心接口定义 + +**新建** `internal/plugin/plugin.go` + +内容:PluginID、PluginSource、PluginMetadata、PluginCapability、Plugin 接口、TabProvider、FilePreviewHandler、PreviewInfo、TabDef、CoreServices 接口定义。 + +依赖:无 + +--- + +### Step 2:适配器(避免方法泄漏) + +**新建** `internal/plugin/adapter.go` + +内容:`adapter` 结构体实现 `CoreServices` 接口的 6 个方法。构造函数 `NewAdapter(app *App) CoreServices`。 + +关键设计:不在 App 上直接实现 CoreServices(会导致 6 个内部方法被 Wails v3 自动暴露为前端 API),而是通过独立 adapter 封装。 + +依赖:Step 1 + +--- + +### Step 3:Tab 注册表 + +**新建** `internal/plugin/tab_registry.go` + +内容: +- `TabRegistry` struct(mu + entries map) +- Register(冲突检测)、GetByTabKey、GetAllDefinitions(按 Order 排序)、GetAllProviders、Count + +依赖:Step 1 + +--- + +### Step 4:预览注册表 + +**新建** `internal/plugin/preview_registry.go` + +内容: +- `PreviewRegistry` struct(mu + handlers 切片,按 priority 降序) +- Register(自动排序)、Resolve(遍历匹配第一个 canHandle)、GetAllHandlers、Count + +依赖:Step 1 + +--- + +### Step 5:PluginManager + +**新建** `internal/plugin/manager.go` + +内容: +- `Manager` struct(plugins map + core + tabReg + previewReg + ctx + initialized) +- NewManager、Register(自动分发到子注册表 + 回滚)、InitAll、StartByTabKey +- GetPluginInfos、ResolvePreview(附加 PluginID 到响应)、GetTabDefinitions、Shutdown + +依赖:Step 1 ~ 4 全部 + +--- + +### Step 6:前端类型定义 + +**新建** `frontend/src/plugin/types.ts` + +内容:PluginCapability enum、PluginMetadata、TabPluginDefinition、FilePreviewHandlerDefinition、RenderConfig 接口。 + +依赖:无 + +--- + +### Step 7:前端注册中心 + +**新建** `frontend/src/plugin/registry.ts` + +内容: +- Vue reactive state(tabPlugins Map + previewHandlers 数组) +- Tab API:registerTabPlugin / getTabComponent / getAllTabDefinitions / hasTabPlugin +- Preview API:registerPreviewHandler(按 priority 插入)/ resolvePreviewHandler / getAllPreviewHandlers +- 调试工具:getRegistryStats + +依赖:Step 6 + +--- + +### Step 8:集成到 App + +**修改** `app.go` + +改动点: + +1. **新增字段**:`pluginMgr *plugin.Manager`(在 `mu` 字段之后) +2. **ServiceStartup 中**(步骤 4 之后):初始化 pluginMgr + ```go + fmt.Println("[启动] 初始化插件管理器...") + a.pluginMgr = plugin.NewManager(plugin.NewAdapter(a)) + ``` +3. **ServiceShutdown 末尾**:关闭 pluginMgr + ```go + if a.pluginMgr != nil { a.pluginMgr.Shutdown() } + ``` +4. **新增绑定方法**(Wails v3 自动暴露前端): + - `GetPluginInfos() ([]map[string]interface{}, error)` — 返回空数组或插件列表 + - `ResolvePreview(req ResolvePreviewRequest) (map[string]interface{}, error)` — 解析文件预览处理器 + - `ResolvePreviewRequest{Filename string}` 请求结构体 + +依赖:Step 5 + +--- + +## 验证方案 + +### 编译验证 + +```bash +go build ./internal/plugin/ +go build . +cd frontend && npx vue-tsc --noEmit +``` + +### 运行时验证 + +| # | 操作 | 预期结果 | +|---|------|---------| +| V1 | 运行应用 | 日志出现 `[启动] 初始化插件管理器...` | +| V2 | 关闭应用 | 日志出现 `[插件管理器] 已关闭` | +| V3 | DevTools: `GetPluginInfos()` | 返回 `{success:true, data:[]}` | +| V4 | DevTools: `ResolvePreview({filename:'test'})` | 返回 `{success:false, message:"no preview handler..."}` | +| V5 | 前端 import registry | 无 TS 错误 | + +### 边界情况 + +| 场景 | 预期行为 | +|------|---------| +| pluginMgr 为 nil 调用 GetPluginInfos | 返回空数组,不 panic | +| pluginMgr 为 nil 调用 ResolvePreview | 返回 success:false,不 panic | +| TabRegistry.Register 同一 TabKey 两次 | 返回冲突错误 | +| PreviewRegistry.Resolve 无匹配文件 | 返回 nil, "" | +| Manager.Shutdown 无插件注册 | 正常返回 nil | diff --git a/docs/04-功能迭代/GO-DESK-9.插件系统/任务规划/实施路线图.md b/docs/04-功能迭代/GO-DESK-9.插件系统/任务规划/实施路线图.md new file mode 100644 index 0000000..f1fbd2d --- /dev/null +++ b/docs/04-功能迭代/GO-DESK-9.插件系统/任务规划/实施路线图.md @@ -0,0 +1,167 @@ +# 实施路线图 + +## 总览 + +``` +Phase 0 ████████████ 基础设施骨架 +Phase 1 ████ Draw.io 验证插件 +Phase 2 █████████████ 预览系统重构 +Phase 3 █████████████ Tab 插件化 + 设置面板 +Phase 4 ██████ 外部插件支持 +Phase 5 ░░░░░░░░░░░░░ 插件市场(远景) +``` + +每个 Phase 可独立交付验证。 + +--- + +## Phase 0:基础设施骨架 + +**目标**:建好管道,不改现有功能。验证编译通过 + API 可调用。 + +详细步骤见 [Phase0-基础设施.md](./Phase0-基础设施.md) + +--- + +## Phase 1:首个内置插件验证(Draw.io) + +**目标**:用第一个真实插件验证整条链路端到端打通。 + +| 步骤 | 文件 | 操作 | +|------|------|------| +| 1 | `internal/plugin/builtin/drawio_plugin.go` | 新建 DrawIoPlugin 实现 | +| 2 | `frontend/src/plugin/built-in/drawio-handler.ts` | 新建前端 handler 注册 | +| 3 | `app.go` | 在 ServiceStartup 中 Register(DrawIoPlugin) | +| 4 | `FileEditorPanel.vue` | 在 v-if 链末尾追加 drawio 分支 | + +**验证标准**:打开 `.drawio` 文件 → 显示 iframe 预览 → 其他文件不受影响。 + +--- + +## Phase 2:文件预览系统重构 + +**目标**:将全部 10 种内置预览迁移到插件注册表,消除 v-if 链。 + +| 步骤 | 文件 | 操作 | +|------|------|------| +| 1 | `frontend/src/plugin/built-in/preview-handlers.ts` | 新建,注册 image/video/audio/pdf/html/md/excel/word/csv/text/code 共 12 个处理器 | +| 2 | `FileEditorPanel.vue` | 模板重写为 `` + `