Private
Public Access
1
0

新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放

This commit is contained in:
2026-05-12 11:06:28 +08:00
parent 545d7a864d
commit 2a363fd729
62 changed files with 6687 additions and 660 deletions

View File

@@ -0,0 +1,177 @@
# GO-DESK-10: SFTP 直连支持
> 版本: v0.5.0 | 状态: 开发中 | 分支: fs-only-v3 → u-desk-sftp
## 概述
为 U-Desk 新增 **SFTPSSH 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=22HTTP=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/本地转发)
- 符号链接处理选项
- 并发传输队列 + 带宽限制

View File

@@ -0,0 +1,114 @@
# SFTP 直连 + autoConnect 开发经验
> 日期2026-05-04 | 对应分支fs-only-v3
---
## 架构决策
### 1. 连接池模式替代单连接
**背景**: 原方案切换 profile 时断开旧连接再建新连接,切换慢且丢失状态。
**决策**: `Map<profileId, FsTransport>` 连接池。所有 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