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

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
.claude/ .claude/
u-desk.exe u-desk.exe
u-fs-agent-linux u-fs-agent-linux
docs/08-用户指南/u-desk-site/

228
app.go
View File

@@ -8,16 +8,16 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
stdruntime "runtime" stdruntime "runtime"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"golang.org/x/sys/windows/registry"
"u-desk/internal/api" "u-desk/internal/api"
"u-desk/internal/common" "u-desk/internal/common"
"u-desk/internal/filesystem" "u-desk/internal/filesystem"
"u-desk/internal/hotkey"
osssvc "u-desk/internal/ossdrv" osssvc "u-desk/internal/ossdrv"
"u-desk/internal/service" "u-desk/internal/service"
"u-desk/internal/sftp" "u-desk/internal/sftp"
@@ -25,24 +25,28 @@ import (
"u-desk/internal/storage/models" "u-desk/internal/storage/models"
"u-desk/internal/system" "u-desk/internal/system"
"golang.org/x/sys/windows/registry"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/w32" "github.com/wailsapp/wails/v3/pkg/w32"
"gorm.io/gorm"
) )
// App 应用结构体 // App 应用结构体
type App struct { type App struct {
ctx context.Context ctx context.Context
mainWindow *application.WebviewWindow mainWindow *application.WebviewWindow
updateAPI *api.UpdateAPI updateAPI *api.UpdateAPI
updateTicker *time.Ticker updateTicker *time.Ticker
configAPI *api.ConfigAPI configAPI *api.ConfigAPI
pdfAPI *api.PdfAPI pdfAPI *api.PdfAPI
filesystem *filesystem.FileSystemService filesystem *filesystem.FileSystemService
sftpService *sftp.Service sftpService *sftp.Service
ossService *osssvc.Service ossService *osssvc.Service
profileSvc *service.ProfileService profileSvc *service.ProfileService
isAlwaysOnTop bool isAlwaysOnTop bool
mu sync.Mutex mu sync.Mutex
unregisterHotkey func()
} }
// App 方法命名约定: // App 方法命名约定:
@@ -59,6 +63,41 @@ func (a *App) SetMainWindow(w *application.WebviewWindow) {
a.mainWindow = w 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 // ServiceStartup Wails v3 服务启动生命周期(替代 v2 的 Startup
func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error { func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {
a.ctx = ctx a.ctx = ctx
@@ -101,8 +140,8 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
return fmt.Errorf("模块初始化失败: %w", err) return fmt.Errorf("模块初始化失败: %w", err)
} }
// 5. 清理遗留的 SFTP 临时预览文件 // 5. 清理过期的下载缓存
sftp.CleanupTempFiles() storage.CleanupExpiredCache()
// 6. 异步初始化UpdateAPI涉及网络请求完全异步 // 6. 异步初始化UpdateAPI涉及网络请求完全异步
go func() { 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 return nil
} }
@@ -193,6 +250,9 @@ func (a *App) ServiceShutdown() error {
if a.updateTicker != nil { if a.updateTicker != nil {
a.updateTicker.Stop() a.updateTicker.Stop()
} }
if a.unregisterHotkey != nil {
a.unregisterHotkey()
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -217,7 +277,7 @@ func (a *App) ServiceShutdown() error {
if a.sftpService != nil { if a.sftpService != nil {
sftp.GetManager().Shutdown() sftp.GetManager().Shutdown()
} }
sftp.CleanupTempFiles() storage.CleanupExpiredCache()
// 关闭所有 OSS 连接 // 关闭所有 OSS 连接
osssvc.GetManager().Shutdown() osssvc.GetManager().Shutdown()
@@ -856,11 +916,11 @@ func (a *App) ensureSftpService() *sftp.Service {
// SftpConnectRequest SFTP 连接请求 // SftpConnectRequest SFTP 连接请求
type SftpConnectRequest struct { type SftpConnectRequest struct {
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
KeyPath string `json:"key_path"` KeyPath string `json:"key_path"`
KeyPassphrase string `json:"key_passphrase"` KeyPassphrase string `json:"key_passphrase"`
} }
@@ -874,8 +934,12 @@ func (a *App) SftpConnect(req SftpConnectRequest) (string, error) {
KeyPath: req.KeyPath, KeyPath: req.KeyPath,
KeyPassphrase: req.KeyPassphrase, KeyPassphrase: req.KeyPassphrase,
} }
if config.Port == 0 { config.Port = 22 } if config.Port == 0 {
if config.Timeout == 0 { config.Timeout = 15 * time.Second } config.Port = 22
}
if config.Timeout == 0 {
config.Timeout = 15 * time.Second
}
svc := a.ensureSftpService() svc := a.ensureSftpService()
_, err := svc.GetManager().Connect(config) _, 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) 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 远程主机常用路径 // SftpGetCommonPaths 获取 SFTP 远程主机常用路径
func (a *App) SftpGetCommonPaths(connID string) (map[string]string, error) { func (a *App) SftpGetCommonPaths(connID string) (map[string]string, error) {
return a.ensureSftpService().GetCommonPaths(connID) 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) 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 读取文件 // OssReadFile OSS 读取文件
func (a *App) OssReadFile(connID string, key string) (string, error) { func (a *App) OssReadFile(connID string, key string) (string, error) {
return a.ensureOssService().ReadFile(connID, key) return a.ensureOssService().ReadFile(connID, key)
@@ -1078,21 +1162,22 @@ func (a *App) OssGetSignedURL(connID string, key string) (string, error) {
// --- 连接配置 CRUD (SQLite 持久化) --- // --- 连接配置 CRUD (SQLite 持久化) ---
type SaveProfileRequest struct { type SaveProfileRequest struct {
ID *uint `json:"id"` ID *uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
KeyPath string `json:"key_path"` KeyPath string `json:"key_path"`
Type string `json:"type"` Type string `json:"type"`
Token string `json:"token"` Provider string `json:"provider"`
AccessKey string `json:"access_key"` Token string `json:"token"`
SecretKey string `json:"secret_key"` AccessKey string `json:"access_key"`
Bucket string `json:"bucket"` SecretKey string `json:"secret_key"`
Region string `json:"region"` Bucket string `json:"bucket"`
Endpoint string `json:"endpoint"` Region string `json:"region"`
LastConnected *int64 `json:"last_connected"` Endpoint string `json:"endpoint"`
LastConnected *int64 `json:"last_connected"`
} }
func (a *App) ensureProfileSvc() *service.ProfileService { func (a *App) ensureProfileSvc() *service.ProfileService {
@@ -1106,7 +1191,9 @@ func (a *App) ensureProfileSvc() *service.ProfileService {
func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) { func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) {
list, err := a.ensureProfileSvc().ListProfiles() list, err := a.ensureProfileSvc().ListProfiles()
if err != nil { return nil, err } if err != nil {
return nil, err
}
result := make([]map[string]interface{}, len(list)) result := make([]map[string]interface{}, len(list))
for i, p := range list { for i, p := range list {
result[i] = map[string]interface{}{ result[i] = map[string]interface{}{
@@ -1118,6 +1205,7 @@ func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) {
"password": p.Password, "password": p.Password,
"keyPath": p.KeyPath, "keyPath": p.KeyPath,
"type": p.Type, "type": p.Type,
"provider": p.Provider,
"token": p.Token, "token": p.Token,
"accessKey": p.AccessKey, "accessKey": p.AccessKey,
"secretKey": p.SecretKey, "secretKey": p.SecretKey,
@@ -1135,7 +1223,7 @@ func (a *App) SaveConnectionProfile(req SaveProfileRequest) (map[string]interfac
p := &models.ConnectionProfile{ p := &models.ConnectionProfile{
Name: req.Name, Host: req.Host, Port: req.Port, Name: req.Name, Host: req.Host, Port: req.Port,
Username: req.Username, Password: req.Password, 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, AccessKey: req.AccessKey, SecretKey: req.SecretKey,
Bucket: req.Bucket, Region: req.Region, Endpoint: req.Endpoint, 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() cpuInfo, err := system.GetCPUInfo()
if err == nil && cpuInfo != nil { 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() memInfo, err := system.GetMemoryInfo()
if err == nil && memInfo != nil { 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() diskInfos, err := system.GetDiskInfo()
if err == nil && len(diskInfos) > 0 { 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 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
})
}

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

View File

@@ -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/ 下,如不再使用应清理

View File

@@ -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 可独立交付验证。

View File

@@ -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 3Tab 注册表
**新建** `internal/plugin/tab_registry.go`
内容:
- `TabRegistry` structmu + entries map
- Register冲突检测、GetByTabKey、GetAllDefinitions按 Order 排序、GetAllProviders、Count
依赖Step 1
---
### Step 4预览注册表
**新建** `internal/plugin/preview_registry.go`
内容:
- `PreviewRegistry` structmu + handlers 切片,按 priority 降序)
- Register自动排序、Resolve遍历匹配第一个 canHandle、GetAllHandlers、Count
依赖Step 1
---
### Step 5PluginManager
**新建** `internal/plugin/manager.go`
内容:
- `Manager` structplugins 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 statetabPlugins Map + previewHandlers 数组)
- Tab APIregisterTabPlugin / getTabComponent / getAllTabDefinitions / hasTabPlugin
- Preview APIregisterPreviewHandler按 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 |

View File

@@ -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` | 模板重写为 `<component :is>` + `<iframe>` 双分支 |
| 3 | `registry.ts` | 被 FileEditorPanel 实际导入使用 |
**收益**:新增文件类型只需写一个 TS 文件(~20 行),零改动核心组件。
---
## Phase 3Tab 系统插件化 + 设置面板 + UI 插槽
**目标**App.vue 不再硬编码,设置面板支持插件管理,建立 UI 插槽体系。
| 步骤 | 文件 | 操作 |
|------|------|------|
| 1 | `frontend/src/plugin/built-in/tabs.ts` | 注册 file-system / markdown-editor 等内置 Tab |
| 2 | `frontend/src/plugin/built-in/index.ts` | 统一副作用入口 |
| 3 | `frontend/src/plugin/slots.ts` | UISlotRegistry 实现(插槽注册/查询) |
| 4 | `App.vue` | getComponent 改为查 registryKeepAlive 动态化;接入 slot: titlebar-extra / sidebar-left / toolbar-extra 等 |
| 5 | `stores/config.ts` | loadConfig 合并插件 Tab修复前后端断层 |
| 6 | `SettingsPanel.vue` | 新增「插件管理」Tab 页(列表 + 启用/禁用) |
| 7 | `app.go` | 新增 SetPluginEnabled 绑定方法PluginMetadata 新增 UISlots 字段 |
| 8 | `service/config_service.go` | defaultTabConfig 改为动态合并插件 Tab |
| 9 | SQLite | 写入 plugin_state 初始数据 |
> UI 插槽详细设计见 `设计文档/架构设计.md` 第四章
---
## Phase 4外部插件支持
**目标**:用户可安装 .zip 格式的外部插件包。
### 插件包格式 (.uplugin)
```
my-plugin-v1.0.0.uplugin (ZIP)
├── manifest.json # 插件清单(必须)
├── plugin.wasm # WASM 入口(跨平台首选)
├── assets/ # 静态资源
└── frontend/ # 前端组件(可选)
```
### manifest.json 结构
```json
{
"id": "com.example.my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"entry": "plugin.wasm",
"capabilities": ["file-preview"],
"file_extensions": [".xyz"],
"min_app_version": "0.4.0",
"permissions": ["filesystem:read"],
"checksum_sha256": "..."
}
```
### Manager 新增能力
```go
InstallPlugin(packagePath string) error // 解压 + 校验 + 注册
UninstallPlugin(id PluginID) error // 停止 + 删除 + 清理
SetPluginEnabled(id, enabled) error // 启用/停用
ScanInstalledPlugins() error // 扫描恢复
```
### 安全模型
| 层级 | 措施 | Phase |
|------|------|-------|
| 签名校验 | checksum_sha256 安装时验证 | Phase 4 |
| 权限声明 | permissions 列表安装时展示确认 | Phase 4 |
| 沙箱执行 | WASM 沙箱 / 子进程隔离 | Phase 5 |
| 版本兼容 | min_app_version 检查 | Phase 4 |
---
## Phase 5插件市场远景
### 整体架构
```
┌──────────────────┐ ┌──────────────────┐
│ 插件市场服务端 │ │ u-desk 客户端 │
│ │◄───────►│ │
│ · 插件仓库 CRUD │ HTTPS │ · 浏览/搜索 │
│ · 元数据 API │ │ · 一键安装 │
│ · 包文件分发 │ │ · 自动更新检查 │
│ · 签名 & 信誉 │ │ · 评分/评论(预留) │
└──────────────────┘ └──────────────────┘
```
### 服务端职责(优先级排序)
| P0 | P1 | P2 | P3 |
|----|----|----|----|
| 插件仓库 CRUD | 搜索过滤 | 开发者门户 | 评分评论 |
| 包分发 CDN | 自动更新通知 | 审核流程 | |
| 版本管理 | | | |
| 签名验证 | | | |
### 客户端 MarketplaceClient API
```typescript
interface MarketplaceClient {
searchPlugins(query, category?): Promise<MarketplacePlugin[]>
getPluginDetail(id): Promise<MarketplacePluginDetail>
installPlugin(id): Promise<InstallProgress>
uninstallPlugin(id): Promise<void>
checkUpdates(): Promise<PluginUpdateInfo[]>
updatePlugin(id): Promise<InstallProgress>
}
```
### 更新机制(复用现有 UpdateAPI
```
应用更新已有CheckUpdate → DownloadUpdate → VerifyUpdateFile → InstallUpdateWithHash
插件更新新增CheckPluginUpdates → DownloadPlugin → VerifyPlugin → InstallPlugin
```

View File

@@ -0,0 +1,54 @@
# 决策记录
## ADR-001CoreServices 使用 adapter 模式而非 App 直接实现
**日期**2026-05-01
**状态**:已采纳
**背景**:插件需要访问 App 的内部服务ctx、mainWindow、filesystem 等),需要定义 CoreServices 接口。
**选项**
| 方案 | 做法 | 优点 | 缺点 |
|------|------|------|------|
| A. App 直接实现 | App struct 添加 6 个公开方法 | 简单,无需额外类型 | Wails v3 将这 6 个方法全部暴露给前端 API信息泄漏 |
| B. adapter 模式 | 新建独立 adapter 结构体实现接口 | 零新增公开方法;松耦合 | 多一个文件 (~40 行) |
**决策**:选 **B. adapter 模式**
**理由**
1. Wails v3 将 `application.NewService(app)` 的**所有导出方法**自动暴露给前端
2. `Context()``MainWindow()``FileSystem()``ConfigAPI()` 是内部实现细节,不应成为前端 API
3. adapter 仅 40 行代码,代价极小
**后果**
- 新增 `internal/plugin/adapter.go` 文件
- App 零新增公开方法
- Manager 通过 `NewAdapter(a)` 获取 CoreServices
---
## ADR-002plugin_state 使用独立表而非复用 app_config KV
**日期**2026-05-01
**状态**:已采纳
**背景**:插件的启用状态、版本、安装路径等数据需要持久化存储。
**选项**
| 方案 | 做法 | 适用阶段 |
|------|------|---------|
| A. 复用 app_config 表 | key 前缀 `plugin_enabled:xxx` / `plugin_config:xxx` | 插件数 < 20 时够用 |
| B. 独立 plugin_state 表 | 专用表,结构化字段 | 插件市场场景必需 |
**决策**:选 **B. 独立表**Phase 0 就建好 schema
**理由**
1. 插件市场远景需要存储 source/install_path/version/installed_at 等结构化字段KV 模式查询和索引能力不足
2. Phase 0 建表成本极低(只需在 AutoMigrate 加一个 model
3. 后续从 KV 迁移到独立表的数据迁移成本高且易出错
4. 与 app_config 职责分离清晰:一个管全局配置,一个管插件实例状态
**后果**
- 新增 `PluginState` GORM model
- `sqlite.go` AutoMigrate 增加一项
- Phase 3 才开始写入数据Phase 0 只建表

View File

@@ -0,0 +1,81 @@
# 复杂度与价值评估
> 评估时间Phase 0 实施完成后2026-05-03
> 评估范围:已实施的 Phase 0 + 远景 Phase 1~5
---
## 一、Phase 0 已投入的复杂度
### 1.1 新增代码量
| 文件 | 行数 | 职责 |
|------|------|------|
| `internal/plugin/plugin.go` | ~150 | 核心接口与类型定义 |
| `internal/plugin/adapter.go` | ~48 | CoreServices 适配器 |
| `internal/plugin/tab_registry.go` | ~98 | Tab 注册表(线程安全) |
| `internal/plugin/preview_registry.go` | ~80 | 预览注册表(线程安全) |
| `internal/plugin/manager.go` | ~196 | 插件管理器(生命周期) |
| `frontend/src/plugin/types.ts` | ~70 | TS 类型定义 |
| `frontend/src/plugin/registry.ts` | ~112 | 前端注册中心Vue reactive |
| `app.go` 改动 | ~40 | 集成 PluginManager + 2 个绑定方法 |
| **合计** | **~794** | |
### 1.2 架构复杂度增量
| 维度 | 具体表现 | 严重程度 | 已解决方式 |
|------|---------|----------|-----------|
| **Wails v3 方法泄露** | App struct 上所有导出方法自动暴露给前端 | 高 | adapter 模式隔离CoreServices 独立实现ADR-001 |
| **Vue 3 响应式陷阱** | `Map`/`Set``reactive()` 内不触发更新 | 中 | 使用 `Record<string, T>` 替代 Map已踩坑记录 |
| **并发安全** | Manager 多处读写共享状态 | 中 | 全量 RWMutex 保护 + copy-then-iterate 模式 |
| **两阶段回滚** | Register 时 Tab 成功但 Preview 失败需清理 | 低 | `tabRegistered` 标志位 + `Unregister()` 回滚 |
| **前后端类型桥接** | Go struct → `map[string]interface{}` → TS | 低 | json.Marshal round-trip 统一处理 |
### 1.3 运行时开销
| 开销项 | 数值 | 说明 |
|--------|------|------|
| 启动时内存 | ~0仅创建 Manager 和空 Registry | Phase 0 不注册任何插件 |
| 锁竞争 | 无Phase 0 无并发注册场景) | 为后续并发预留 |
| 方法调用链路 | App → pluginMgr → Registry | 多一层间接调用,可忽略 |
## 二、Phase 0 获得的价值
### 2.1 直接收益
| 收益点 | 说明 | 对应痛点 |
|--------|------|---------|
| **扩展骨架就绪** | 注册表、管理器、适配器全部可用 | app.go God Object |
| **新功能零侵入** | 未来新增预览格式 / Tab 页无需改 App.vue | 硬编码映射、v-if 链 |
| **懒启动支持** | Tab 插件按 `Start()` 按需激活 | 安装包膨胀、启动慢 |
| **优先级调度** | 预览处理器按 priority 排序匹配 | 无法控制预览顺序 |
| **内置/外置统一接口** | 同一套 Plugin 接口服务两类插件 | 两套逻辑 |
| **失败自动清理** | Register 部分失败时回滚已注册资源 | 残留脏状态 |
### 2.2 战略价值
| 价值维度 | 说明 |
|----------|------|
| **可演进性** | 从单体应用平滑过渡到平台型应用,每步可独立验证 |
| **文档资产** | 完整设计文档 + ADR + 接口定义 + 数据模型,新人可快速接手 |
| **技术债务清零** | 解决了 app.go 35+ 方法的 God Object 问题根源(不再往 App 加方法) |
| **市场基础** | Phase 0 的接口体系直接支撑 Phase 4~5 的外部插件和插件市场 |
## 三、远期复杂度预警Phase 3~5
| Phase | 主要复杂度来源 | 风险等级 | 缓解策略 |
|-------|---------------|----------|---------|
| **Phase 3** UI Slot 动态注入 | 9 个插槽的组件动态加载、生命周期协调、布局冲突 | 中 | 先做 2~3 个核心插槽验证,不全量铺开 |
| **Phase 4** 外部插件加载 | `.uplugin` 包解析、沙箱隔离、版本兼容、签名校验 | 高 | 参考 VS Code Extension Host 架构,独立进程运行 |
| **Phase 5** 插件市场 | 服务端 API、审核流程、支付、权限管理 | 极高 | 作为独立项目推进,不影响桌面端主迭代 |
## 四、总体评价
```
投入: ~800 行代码 + 6 个新文件 + 1 个文件改动
回报: 扩展能力从 0 → 完整骨架,后续每步增量开发
风险: Phase 0 本身风险已全部识别并解决
ROI: ★★★★☆ — 当前阶段性价比极高
```
**结论**Phase 0 是一次高 ROI 的基础设施投资。真正的复杂度在 Phase 4外部插件但那是独立里程碑可以单独做 go/no-go 决策。当前阶段建议继续推进 Phase 1Draw.io 验证插件),用真实插件验证骨架的完整性。

View File

@@ -0,0 +1,227 @@
# 接口定义
## 一、后端接口Go
> 文件位置:`internal/plugin/plugin.go`
```go
package plugin
import (
"context"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
// ========== 类型定义 ==========
type PluginID string
type PluginSource string
const (
SourceBuiltin PluginSource = "builtin"
SourceMarket PluginSource = "market"
)
// PluginMetadata 插件元数据JSON 序列化传给前端)
type PluginMetadata struct {
ID PluginID `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Author string `json:"author"`
Source PluginSource `json:"source"`
TabKey string `json:"tab_key,omitempty"`
FileExtensions []string `json:"file_extensions,omitempty"`
InstallPath string `json:"install_path,omitempty"`
InstalledAt time.Time `json:"installed_at,omitempty"`
UISlots []string `json:"ui_slots,omitempty"` // 声明占用的 UI 插槽Phase 3
}
// PluginCapability 插件能力标志位
type PluginCapability int
const (
CapabilityNone PluginCapability = 0
CapabilityTabProvider PluginCapability = 1 << iota
CapabilityFilePreview
CapabilitySettings
)
func (c PluginCapability) Has(cap PluginCapability) bool {
return c&cap != 0
}
// PreviewInfo 预览元信息
type PreviewInfo struct {
Type string `json:"type"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
NeedsContainer bool `json:"needs_container,omitempty"`
ContainerConfig map[string]any `json:"container_config,omitempty"`
SupportsEdit bool `json:"supports_edit"`
PreloadHint string `json:"preload_hint,omitempty"`
}
// TabDef Tab 定义
type TabDef struct {
Key string `json:"key"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
Order int `json:"order"`
}
// ========== 核心接口 ==========
// CoreServices 插件可访问的核心服务(由 adapter 实现,不挂在 App 上)
type CoreServices interface {
Context() context.Context
MainWindow() *application.WebviewWindow
EmitEvent(eventName string, data ...any)
FileSystem() any
ConfigAPI() any
GetFileServerURL() string
}
// Plugin 核心插件接口(所有插件必须实现)
type Plugin interface {
Meta() PluginMetadata
Capabilities() PluginCapability
Init(ctx context.Context, core CoreServices) error
Start() error
Stop() error
}
// TabProvider Tab 提供者接口(可选,实现 CapabilityTabProvider 时需同时实现)
type TabProvider interface {
Plugin
TabDefinition() TabDef
TabComponentPath() string
}
// FilePreviewHandler 文件预览处理器接口(可选,实现 CapabilityFilePreview 时需同时实现)
type FilePreviewHandler interface {
Plugin
CanPreview(filename string, mimeType string) bool
PreviewInfo(filename string) PreviewInfo
}
```
## 二、PluginManager API
> 文件位置:`internal/plugin/manager.go`
```go
type Manager struct {
mu sync.RWMutex
plugins map[PluginID]Plugin
core CoreServices
tabReg *TabRegistry
previewReg *PreviewRegistry
ctx context.Context
initialized bool
}
// 生命周期
func NewManager(core CoreServices) *Manager
func (m *Manager) Register(p Plugin) error // 注册 + 自动分发到子注册表
func (m *Manager) InitAll(ctx context.Context) error // 初始化所有插件
func (m *Manager) StartByTabKey(tabKey string) error // 按 Tab 懒启动
func (m *Manager) Shutdown() error // 停止所有插件
// 查询
func (m *Manager) GetPluginInfos() []PluginMetadata // 前端展示用
func (m *Manager) ResolvePreview(filename string) (*PreviewInfo, PluginID)
func (m *Manager) GetTabDefinitions() []TabDef
// 外部插件管理Phase 4+
func (m *Manager) InstallPlugin(packagePath string) error
func (m *Manager) UninstallPlugin(id PluginID) error
func (m *Manager) SetPluginEnabled(id PluginID, enabled bool) error
func (m *Manager) CheckPluginUpdates() []PluginUpdateInfo
```
## 三、前端接口TypeScript
> 文件位置:`frontend/src/plugin/types.ts`
```typescript
/** 插件能力标志位 */
export enum PluginCapability {
None = 0,
TabProvider = 1 << 0,
FilePreview = 1 << 1,
Settings = 1 << 2,
}
export type PluginSource = 'builtin' | 'market'
/** 后端返回的插件元信息 */
export interface PluginMetadata {
id: string
name: string
version: string
description: string
author: string
source: PluginSource
tab_key?: string
file_extensions?: string[]
install_path?: string
installed_at?: string
ui_slots?: string[]
}
/** Tab 插件定义(前端注册用) */
export interface TabPluginDefinition {
key: string
title: string
icon?: string
componentLoader: () => Promise<Component>
defaultVisible?: boolean
order?: number
keepAlive?: boolean
}
/** 文件预览处理器定义 */
export interface FilePreviewHandlerDefinition {
id: string
name: string
icon: string
priority: number
canHandle: (filename: string) => boolean
getComponent?: () => Promise<Component>
getRenderConfig?: (filePath: string) => RenderConfig
supportsEdit?: boolean
}
/** 渲染配置 */
export interface RenderConfig {
type: 'iframe' | 'html' | 'custom' | 'image' | 'video' | 'audio'
src?: string
htmlContent?: string
props?: Record<string, unknown>
}
```
## 四、前端 Registry API
> 文件位置:`frontend/src/plugin/registry.ts`
```typescript
// === Tab 插件 ===
function registerTabPlugin(def: TabPluginDefinition): void
function getTabComponent(key: string): (() => Promise<Component>) | null
function getAllTabDefinitions(): TabPluginDefinition[]
function hasTabPlugin(key: string): boolean
// === 文件预览 ===
function registerPreviewHandler(handler: FilePreviewHandlerDefinition): void
function resolvePreviewHandler(filename: string): FilePreviewHandlerDefinition | null
function getAllPreviewHandlers(): ReadonlyArray<FilePreviewHandlerDefinition>
// === 调试 ===
function getRegistryStats(): { tabCount, previewHandlerCount, tabKeys, handlerIds }
```

View File

@@ -0,0 +1,73 @@
# 数据模型
## 一、plugin_state 表(新增)
```sql
CREATE TABLE plugin_state (
plugin_id TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'builtin',
enabled INTEGER DEFAULT 1,
config TEXT,
install_path TEXT,
version TEXT,
installed_at DATETIME,
updated_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_plugin_source ON plugin_state(source);
CREATE INDEX idx_plugin_enabled ON plugin_state(enabled);
```
### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `plugin_id` | TEXT PK | 对应 PluginMetadata.ID`"builtin-drawio"` |
| `source` | TEXT | `'builtin'``'market'` |
| `enabled` | INTEGER | 1=启用 0=停用 |
| `config` | TEXT | JSON 格式的插件私有配置 |
| `install_path` | TEXT | 外部插件磁盘路径(内置为 NULL |
| `version` | TEXT | 当前安装的版本号 |
| `installed_at` | DATETIME | 安装时间 |
| `updated_at` | DATETIME | 最后状态变更时间 |
## 二、与现有表的关系
```
app_config 表(已有) plugin_state 表(新增)
┌──────────────┐ ┌──────────────┐
│ key='tab_config'│ │ plugin_id PK │
│ value=JSON │ ← 合并Tab →│ source │
│ (全局配置) │ │ enabled │
├──────────────┤ │ version │
│ key='plugin_ │ ← 全局设置→│ config │
│ global' │ │ install_path │
│ (Phase 3 新增)│ └──────────────┘
└──────────────┘
```
- `app_config`存全局应用配置tab_config、plugin_global 等)
- `plugin_state`:存每个插件的运行状态和独立配置
- 两者通过 `plugin_id` 关联
## 三、初始种子数据
应用首次启动时由 Go 代码写入:
```sql
INSERT OR IGNORE INTO plugin_state (plugin_id, source, enabled, version) VALUES
('builtin-file-system', 'builtin', 1, '0.4.0'),
('builtin-markdown', 'builtin', 1, '0.4.0'),
('builtin-drawio', 'builtin', 0, '1.0.0');
```
## 四、配置存储策略
| 存储位置 | 内容 | 写入时机 |
|---------|------|---------|
| `app_config['tab_config']` | Tab 可见性/排序/默认值 | Phase 3 动态合并 |
| `app_config['plugin_global']` | 插件全局设置(自动更新间隔等) | Phase 3 |
| `plugin_state.enabled` | 每个插件的启停状态 | Phase 3 SetPluginEnabled |
| `plugin_state.version` | 当前安装版本 | 安装/更新时 |
| `plugin_state.config` | 插件私有配置(如 Draw.io 端口号) | 用户修改插件设置时 |

View File

@@ -0,0 +1,250 @@
# 架构设计
## 一、架构全景
```
+==================================================================+
| u-desk Application |
+===================================================================+
| Core (Go) Plugin Manager |
| - App facade - Registry / Lifecycle / Loader |
| - ConfigStore (SQLite) - Builtin Plugins (编译时) |
| - EventBus (Wails Events) - External Plugins (运行时) |
| - UpdateEngine - Marketplace Client |
+----------+----------+---------+-----------+-----------+ |
| | | | | |
+-------+ +------+ +-----+ +--------+ +------+ +----+ |
|builtin: |builtin: |builtin | builtin: |extern:|extern |
| file-sys| md-edit| drawio | db-cli | user-A| user-B |
+---------+---------+--------+-----------+--------+--------------+
|
+=================================================================+|
| Frontend (Vue 3) ||
| PluginRegistry (TS) ||
| - TabProviders → 替代 App.vue 硬编码映射 ||
| - FilePreviewHandlers → 替代 FileEditorPanel if/else 链 ||
| - ComponentLoader → defineAsyncComponent 懒加载 ||
| - PluginSettingsUI → 设置面板插件管理区块 ||
+=================================================================+
```
## 二、两层插件体系
| 维度 | 内置插件 (Builtin) | 外部插件 (External/Market) |
|------|-------------------|--------------------------|
| **来源** | 编译时打包进二进制 | 运行时从市场安装 |
| **位置** | `internal/plugin/builtin/` | 用户数据目录 `plugins/` |
| **生命周期** | 随应用启动 | 用户控制 |
| **管理操作** | 启用 / 禁用 | 安装 / 卸载 / 启用 / 禁用 / 更新 |
| **更新方式** | 随应用版本更新 | 独立更新(复用 UpdateAPI |
| **安全级别** | 完全信任(同进程) | 沙箱隔离Phase 5 |
| **示例** | 文件系统、Markdown、Draw.io | 第三方预览器、云存储同步 |
## 三、设置面板形态(最终态)
```
┌─ 设置 ──────────────────────────────────────────────┐
│ │
│ ┌─ Tab 配置 ─┐ ┌─ 版本更新 ─┐ ┌─ 插件管理 ─┐ │
│ │ │ │ │ │ │ │
│ │ 拖拽排序 │ │ 自动检查 │ │ 内置插件 │ │
│ │ 显隐控制 │ │ 手动检查 │ │ ✓ Draw.io │ │
│ │ 默认选择 │ │ 下载/安装 │ │ ✓ Markdown │ │
│ │ │ │ │ │ ○ 数据库CLI │ │
│ └─────────────┘ └─────────────┘ │ │ │
│ │ 外部插件 │ │
│ │ ✓ 云盘同步 v2.1│ │
│ │ ○ 思维导图(停用)│ │
│ │ │ │
│ │ [浏览插件市场] │ │
│ └──────────────┘ │
└──────────────────────────────────────────────────────┘
```
## 四、UI 插槽体系Slot System
### 4.1 当前布局(硬编码)
```
┌─────────────────────────────────┐
│ 标题栏 + Tab 导航 + 窗口控制 │ ← 固定区域,不可扩展
├─────────────────────────────────┤
│ │
│ <component :is="activeTab"> │ ← 内容区getComponent() 硬编码
│ │
├─────────────────────────────────┤
│ 更新通知 / 设置抽屉 │ ← 固定区域
└─────────────────────────────────┘
```
**问题**:所有 UI 区域都是硬编码的。新插件无法在标题栏加按钮、无法在工具栏扩展、无法在侧边栏注入面板。
### 4.2 插件化后的插槽布局
```
┌── slot: titlebar-extra ──────────────────────────────┐
│ [u-desk] [文件管理 | Markdown] [🔌插件A] [🔌插件B] [—][□][×] │
├──────────────────────────────────────────────────────┤
│ slot: sidebar-left │ slot: main-content │ slot: sidebar-right │
│ │ │ │
│ [内置侧边栏] │ <component :is="activeTab"> │ (预留) │
│ ───────────── │ │ │
│ [插件侧边面板] │ │ │
│ │ │ │
├────────────────────┴───────────────────────────────────┴──────────────────────┤
│ slot: toolbar-extra: [📋] [🔍] [🔌插件工具按钮...] │
├──────────────────────────────────────────────────────────────────────────────┤
│ slot: status-bar: [就绪] [行: 128] [编码: UTF-8] [🔌插件状态项...] │
└──────────────────────────────────────────────────────────────────────────────┘
右键菜单 → slot: context-menu-extra追加菜单项
设置面板 → slot: settings-panel内嵌区块
```
### 4.3 插槽定义表
| 插槽 ID | 位置 | 控制方式 | 谁可注册 | Phase |
|---------|------|---------|---------|-------|
| `titlebar-extra` | 标题栏右侧(窗口控制按钮左侧) | 声明式PluginMetadata.uiSlots[] | 内置 + 插件 | 3 |
| `tab-bar` | Tab 导航条内部 | 自动CapabilityTabProvider 插件自动合并 | PluginManager 驱动 | 1 |
| `main-content` | 中央主内容区 | 自动getComponent() 查 registry | registry 驱动 | 1 |
| `sidebar-left` | 左侧边栏底部FileSystem Sidebar 之后) | 声明式 | 插件 | 3 |
| `sidebar-right` | 右侧边栏(预览区旁) | 声明式 | 插件 | 3 |
| `toolbar-extra` | 工具栏末尾追加按钮 | 声明式 | 插件 | 2 |
| `status-bar` | 底部状态栏追加信息 | 声明式 | 插件 | 3 |
| `context-menu-extra` | 右键菜单追加项 | 声明式 | 插件 | 2 |
| `settings-panel` | 设置面板内嵌区块 | 声明式CapabilitySettings | 插件 | 3 |
### 4.4 插槽注册机制(前端)
```typescript
// frontend/src/plugin/slots.ts
interface SlotDefinition {
id: string // 插槽 ID
component?: Component // 注入的 Vue 组件
position?: 'prepend' | 'append' | 'before' | 'after' // 插入位置
order?: number // 同一插槽内的排序权重
}
interface UISlotRegistry {
/** 插件声明要占用哪些插槽 */
registerSlot(pluginId: string, def: SlotDefinition): void
/** App.vue 查询某插槽有哪些组件 */
getSlotComponents(slotId: string): SlotDefinition[]
/** 所有已注册的插槽概览 */
getAllSlots(): Map<string, SlotDefinition[]>
}
```
### 4.5 App.vue 插槽改造示意
```vue
<template>
<!-- 标题栏 -->
<div class="titlebar">
<div class="titlebar-left">u-desk</div>
<a-tabs v-model="activeTab" class="header-tabs">...</a-tabs>
<!-- 插槽: titlebar-extra -->
<component
v-for="slot in getSlotComponents('titlebar-extra')"
:key="slot.id"
:is="slot.component"
/>
<WindowControls />
</div>
<!-- 主区域 -->
<div class="main-layout">
<!-- 插槽: sidebar-left -->
<component
v-for="slot in getSlotComponents('sidebar-left')"
:is="slot.component"
/>
<!-- 插槽: main-content核心 -->
<KeepAlive :include="cacheableComponents">
<component :is="getComponent(activeTab)" />
</KeepAlive>
</div>
<!-- 工具栏插槽等... -->
</template>
```
### 4.6 后端接口支持
```go
// PluginMetadata 新增字段
type PluginMetadata struct {
// ... 现有字段 ...
UISlots []string `json:"ui_slots,omitempty"` // 声明占用的 UI 插槽列表
}
// Manager 新增方法
func (m *Manager) GetUISlotContents(slotID string) []UISlotEntry
```
### 4.7 切割原则
| 原则 | 说明 |
|------|------|
| **最小暴露** | 只开放必要的插槽,不把整个布局变成"配置驱动" |
| **位置固定** | 每个插槽的位置在 App.vue 中固定,插件只能决定"往里放什么",不能移动插槽本身 |
| **顺序可控** | 同一插槽多插件通过 order 控制排列顺序 |
| **隔离渲染** | 每个插槽内的插件组件有独立作用域,避免样式/状态污染 |
| **优雅降级** | 插件组件报错不影响宿主 UIErrorBoundary 包裹) |
## 五、关键改造点对比
### 5.1 App.vue 改造Phase 3
**改造前**
```typescript
import FileSystem from './components/FileSystem/index.vue'
import MarkdownEditor from './views/markdown-editor/index.vue'
const getComponent = (key: string) => ({
'file-system': FileSystem,
'markdown-editor': MarkdownEditor
}[key] || null)
```
**改造后**
```typescript
import '@/plugin/built-in' // 副作用:执行所有内置插件注册
import { getTabComponent } from '@/plugin/registry'
const getComponent = (key: string) => {
const loader = getTabComponent(key)
return loader ? defineAsyncComponent(loader) : null
}
```
### 5.2 FileEditorPanel.vue 改造Phase 2
**改造前**10 层 v-if/v-else-if 链binary/image/video/audio/pdf/excel/word/csv/html/md/text
**改造后**
```vue
<template>
<div class="editor-content">
<iframe v-if="renderConfig?.type === 'iframe'" :src="renderConfig.src" />
<component v-else-if="previewComponent" :is="previewComponent" v-bind="previewProps" />
<CodeEditor v-else ... />
</div>
</template>
<script setup lang="ts">
import { resolvePreviewHandler } from '@/plugin/registry'
import { computed } from 'vue'
const handler = computed(() =>
props.config.currentFileName ? resolvePreviewHandler(props.config.currentFileName) : null
)
const previewComponent = computed(() => handler.value?.getComponent?.())
const renderConfig = computed(() => handler.value?.getRenderConfig?.(props.filePath ?? ''))
</script>
```

View File

@@ -0,0 +1,263 @@
# 音乐开放平台接入方案备忘录
> 最后更新2026-05-08 | 用途U-Desk 生态链接 — 音乐模块可行性评估
---
## 一、平台总览对比
| 维度 | QQ音乐 | 网易云音乐 | 酷狗音乐 | Spotify | Apple Music |
|------|--------|-----------|----------|---------|-------------|
| **官网** | [developer.y.qq.com](https://developer.y.qq.com/) | [developer.music.163.com](https://developer.music.163.com/) | [open.kugou.com](https://open.kugou.com/) | [developer.spotify.com](https://developer.spotify.com/documentation/web-api/) | [Apple MusicKit](https://developer.apple.com/documentation/musickit) |
| **官方性质** | 官方 | 非官方(社区逆向) | 官方 | 官方 | 官方 |
| **曲库规模** | 海量正版(业内估计亿级,待文档中心登录确认) | 数亿首 | 4000万+ | 1亿+ | 1亿+ |
| **费用** | 未公开(需商务) | 免费(非商用) | 按千次播放计费 | 免费额度/按量 | 免费(Apple Developer) |
| **Windows桌面支持** | **❌ 确认不支持2026-05-08 核实)** | HTTP调用可用 | ❌ 仅Android/iOS | Web Playback SDK | MusicKit JS |
| **中文歌曲覆盖** | 极全 | 极全(含小众) | 全 | 中等 | 中等 |
| **中国区可用性** | 原生 | 原生 | 原生 | 需翻墙 | 受限 |
| **推荐评级** | ⭐⭐⭐ 待确认 | ⭐⭐ 灰色地带 | ⭐ 不适配 | ⭐⭐⭐ 技术可行 | ⭐⭐ 有限 |
---
## 二、QQ音乐开发者平台首选候选
### 基本信息
- **官网**https://developer.y.qq.com/
- **文档中心**https://developer.y.qq.com/docs/openapi
- **联系邮箱**qmopen@tencent.com
- **版权归属**:腾讯公司
### 核心能力(三大服务模块)
#### 1. 登录授权 (Login Auth)
- 基于腾讯社交账号体系(微信/QQ/QQ音乐APP
- QPlay Auth 授权方式
- 授权后可获取音乐流、播放控制、在线音乐服务、个人权益
#### 2. OpenAPI
- 在线听歌、排行榜、热门歌曲
- OpenId 用户标识
- 歌词、MV/视频("音视听资源"
- 文档子分类登录鉴权、SDK、OpenAPI、APP互联、QPlay
#### 3. QPlay 协议
- QPlay Auth授权认证
- QPlay Cloud云端服务
- QPlay Lan局域网协议
- QPlay IPC进程间通信
### 支持终端类型6类
| 终端 | 支持 |
|------|------|
| 移动应用 (iOS/Android) | ✅ |
| 网站/小程序 | ✅ |
| 智能硬件 | ✅ |
| 车载应用 | ✅ |
| 公播盒子 | ✅ |
| **Windows桌面EXE** | **❓ 未明确列出** |
### 行业解决方案
社交行业、直播行业、TV/大屏、智能车载、公播行业 — 均有定制方案
### 费用模式
- **未公开透明**,需商务对接获取报价
- 通常为按调用量计费或预付授权金模式
### 关键风险2026-05-08 深入核实后更新)
> **Windows桌面端确认不支持**。官网明确列出 6 类终端(移动应用/网站小程序/智能硬件/车载应用/公播盒子/大屏解决方案),**无一可映射到 Windows 桌面端 EXE**。QPlay 协议描述的"软件应用"在终端列表中被具体化为"移动应用"和"网站/小程序",无 Windows 入口。
### 评估结论:**可能性低(接近零)**
- QQ 音乐开发者平台面向腾讯生态内典型场景移动App/小程序/IoT/车载)
- Windows 桌面端不在目标覆盖范围
- 申请接入时很可能被拒绝或无法通过审核
- **但仍建议发邮件 `qmopen@tencent.com` 正式询问**(预期负面,但留档备查)
### 备选方案优先级调整
由于 QQ 音乐 Windows 桌面端支持概率极低:
1. **第一优先级应调整为 Spotify Web API**Web Playback SDK 可在 Wails WebView2 运行)
2. QQ 音乐降为"长期跟进"(定期重试官网看是否新增 Windows 支持)
3. 网易云音乐保持"实验性/个人版"定位不变
---
## 三、网易云音乐(技术可行但灰色地带)
### 基本信息
- **官网**https://developer.music.163.com/
- **实际可用API**[NeteaseCloudMusicApi](https://binaryify.github.io/NeteaseCloudMusicApi/)社区维护GitHub Star 30k+
### 核心能力150+ 接口)
| 模块 | 主要接口 |
|------|---------|
| 登录鉴权 | 登录/刷新/手机验证码/注册/退出 |
| 用户信息 | 用户详情/歌单/关注/粉丝/动态/播放记录 |
| 搜索 | 搜单曲/专辑/歌手/歌单/MV/歌词/电台/用户 |
| 歌曲 | 详情/歌词/评论/相似/喜欢/打卡 |
| 歌单 | 精品歌单/详情/分类/推荐/每日推荐 |
| 专辑 | 内容/评论/新碟上架/最新 |
| 歌手 | 热门/单曲/MV/专辑/描述/相似/榜单 |
| MV/视频 | 最新/推荐/排行/播放/相似/收藏/评论 |
| 排行榜 | 所有榜单及内容摘要 |
| 电台/DJ | 推荐/分类/订阅/详情/节目 |
| 个性化 | 私人FM/每日推荐/推荐新音乐/独家放送 |
| 社交互动 | 评论/动态/转发/分享/关注 |
| 云盘 | 上传/详情/删除 |
### 优势
- API 能力极其丰富,几乎覆盖所有功能
- 社区活跃多语言客户端Python/Java/Go
- 免费使用,无需预充值
- 曲库规模大,独立音乐人内容丰富
- 个性化推荐算法优秀
### 劣势(致命问题)
- **非官方逆向工程 API**,存在法律合规风险
- 无官方 SLA 保障,接口随时可能变更失效
- 需要用户账号 Cookie/Token 登录
- 存在频率限制,过频请求会被临时封禁
- **不适合正式商业产品发布**
### 适用场景
个人工具 / 内部使用 / 原型验证 — **不可作为正式功能上线**
---
## 四、酷狗音乐(不推荐)
### 基本信息
- **官网**https://open.kugou.com/
- **曲库开放计划**https://open.kugou.com/docs/open-player/
### 两条产品线
| 产品线 | 说明 |
|--------|------|
| 曲库开放组件SDK | 千万级正版曲库,仅在线流媒体播放 |
| 酷狗小程序 | 4000万曲库仅在酷狗APP内运行 |
### 费用模式
- **按千次有效播放计费**
- **预充值模式**,随充随用
- 小程序免费但只能在酷狗APP内
### 致命缺陷
- **SDK 仅支持 Android + iOS**,无 Windows 版本
- 无 H5/Web 版本
- 无自定义 UI纯后台播放组件
- 必须标注"酷狗提供技术支持"
- 需要企业资质申请
### 结论:**完全不适用于 U-Desk**
---
## 五、Spotify Web API国际备选
### 基本信息
- **文档**https://developer.spotify.com/documentation/web-api/
- **基础地址**https://api.spotify.com
- **认证方式**OAuth 2.0Spotify Accounts Service
### 核心能力
- 搜索艺术家/专辑/曲目/播放列表
- 获取曲目元数据(名称、时长、封面、音频特性)
- 用户相关数据(播放列表、已保存音乐)
- 分页查询、条件请求缓存ETag
### 费用
- **免费层级**:有速率限制
- 应用审核后可获得更高配额
### U-Desk 适配分析
- **Web Playback SDK** 可在浏览器环境运行 → Wails WebView2 兼容
- RESTful JSON API → Go 后端可直接调用
- **但中国区需要翻墙**,且中文曲库覆盖一般
### 推荐作为:英文/国际化音乐的补充方案(非主力)
---
## 六、Apple MusicKit
### 基本信息
- **文档**https://developer.apple.com/documentation/musickit
- **MusicKit JS**:可在网页中嵌入 Apple Music 播放器
### 能力
- 搜索/浏览 Apple Music 曲库
- 播放控制(需用户 Apple Music 订阅)
- 元数据获取(封面、歌词等)
### 限制
- 需要 Apple Developer 账号($99/年)
- 中国区 Apple Music 曲库受限
- 用户需要有有效的 Apple Music 订阅才能播放完整曲目
### 结论:**不纳入本次实施范围**(成本高 $99/年 + 中国区曲库受限)
---
## 七、合规硬性规则
| 规则 | 说明 |
|------|------|
| 禁止爬虫 | 不得通过爬虫抓取音频文件 |
| 禁止解密 | 不得解密 DRM 保护的内容 |
| 禁止本地缓存 | 不得将音乐文件缓存在本地 |
| 用户授权 | 必须使用用户自己的账号登录,借用其会员权益播放 |
| 如实上报 | 必须按接口要求上报播放流水(用于平台与版权方结算) |
| 版权标注 | 必须标注版权归对应平台及版权方所有 |
---
## 八、最终推荐方案2026-05-08 更新)
### 第一优先级Spotify Web API调整为首选
- **理由**:免费 + 文档完善 + **Web Playback SDK 可在 Wails WebView2 运行**QQ音乐 Windows 支持概率极低)
- **用途**:主力音乐接入(国际+通过代理可访问中文内容)
- **限制**:需翻墙、中文曲库有限
### 第二优先级QQ音乐开放平台降为"长期跟进"
- **理由**:正版授权 + 曲库全 + 腾讯生态 — **但 Windows 桌面端确认不支持**
- **状态**:定期(月度)重试官网看是否新增 Windows 终端类型
- **前置条件**:发邮件 `qmopen@tencent.com` 获得书面回复确认后才可启动开发
- **风险评估**:高(大概率被拒或无回复)
### 第三优先级:网易云音乐(实验性不变)
- **理由**:功能最丰富、开发最快
- **用途**:个人版/内测版本验证产品形态
- **红线**:正式版必须替换为官方合规方案
### 明确舍弃
- ~~酷狗音乐~~ — 不支持桌面端
- ~~Apple MusicKit~~ — 成本高、中国区差
- ~~QQ 音乐(作为 MVP 首选)~~ — Windows 桌面端不支持,从 P0 降为长期跟进
---
## 九、快速上手清单(待确认后补充)
> 以下步骤依赖 QQ 音乐平台的 Windows 桌面端支持确认结果:
1. [ ] 发送咨询邮件至 qmopen@tencent.com
2. [ ] 注册 QQ 音乐开发者账号
3. [ ] 创建应用,获取 AppID/AppKey
4. [ ] 选择接入方式OpenAPI 或 SDK
5. [ ] 配置回调域名/URL Scheme
6. [ ] 集成登录授权流程
7. [ ] 对接核心 API搜索/播放/歌词)
8. [ ] 提交应用审核
9. [ ] 接入播放流水上报
10. [ ] 上线发布
---
*Sources:*
- *QQ音乐开发者平台: https://developer.y.qq.com/*
- *NeteaseCloudMusicApi: https://binaryify.github.io/NeteaseCloudMusicApi/*
- *酷狗开放平台: https://open.kugou.com/*
- *Spotify Web API: https://developer.spotify.com/documentation/web-api/*
- *Apple MusicKit: https://developer.apple.com/documentation/musickit*

View File

@@ -0,0 +1,335 @@
# 视频平台开放API接入方案备忘录
> 最后更新2026-05-08 | 用途U-Desk 生态链接 — 视频模块可行性评估
---
## 一、平台总览对比
| 维度 | 哔哩哔哩 | **抖音开放平台** | 腾讯视频 | 爱奇艺 | 优酷 | YouTube IFrame |
|------|---------|------------|---------|--------|------|----------------|
| **官网** | [developer.bilibili.com](https://developer.bilibili.com/) | [open.douyin.com](https://open.douyin.com/) | [open.tencent.com](https://open.tencent.com/) | 开放程度有限 | 开放程度有限 | [developers.google.com/youtube](https://developers.google.com/youtube) |
| **官方开放平台** | ✅ 有 | ✅ **有(完善)** | ✅ 有 | ⚠️ 有限 | ⚠️ 有限 | ✅ IFrame API |
| **核心能力** | 搜索/播放/弹幕/投屏 | **分享/授权/小程序/支付/数据** | 播放SDK | 嵌入播放 | 嵌入播放 | 嵌入播放/数据API |
| **费用** | 需申请确认 | **免费** | 商务合作 | 不明 | 不明 | 免费 |
| **Windows桌面适配** | 待确认(官网500) | **SDK / H5** | SDK/H5 | H5嵌入 | H5嵌入 | WebView2完美 |
| **中文内容** | 极全 | **极全日活8亿+** | 极全 | 全 | 全 | 中等 |
| **推荐评级** | ⭐⭐⭐ 待确认 | **⭐⭐⭐ 国内首选** | ⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐ 技术可行 |
---
## 二、抖音开放平台(国内首选 ⭐⭐⭐)
### 基本信息
- **官网**https://open.douyin.com/
- **归属**:字节跳动 / 抖音
- **资质**:增值电信业务经营许可证 川B2-20220549
- **客服电话**400-140-2108
- **状态****活跃维护中2026年3月仍有更新日志**
### 开放形态5种载体
| 载体 | 说明 | U-Desk 适配 |
|------|------|-------------|
| **抖音开放能力 SDK** | 分享/授权/投稿/名片/数据能力 | ✅ **核心接入方式** |
| **抖音小程序** | 在抖音内运行的小程序 | 可选(需抖音内分发) |
| **抖音小游戏** | 游戏类小程序 | 不适用 |
| **直播小玩法** | 直播互动工具 | 不适用 |
| **网站应用** | H5 网页应用 | 备选 |
### 核心能力SDK 详细)
#### 分享能力
| 能力 | 说明 |
|------|------|
| **分享到私信** | 从第三方 App 指定链接分享至抖音 IM含私聊/群聊),生成消息卡片 |
| **分享到朋友日常** | 转发非本人创作内容至"朋友"Tab 社交分发 |
| **投稿到抖音** | 从第三方 App 直接发布视频到抖音 |
#### 授权与身份
| 能力 | 说明 |
|------|------|
| **抖音授权** | OAuth 式用户授权登录 |
| **抖音名片** | 展示用户在抖音的身份信息 |
#### 数据能力
| 能力 | 说明 |
|------|------|
| **数据 API** | 获取分享数据、播放量等统计 |
| **搜索服务直达** | 从抖音搜索直达第三方服务 |
### 行业解决方案
| 方向 | 核心能力 |
|------|---------|
| **通用行业** | 搜索直达 / 短视频达人推广 / 短视频挂载 / 直播挂载 |
| **生活服务** | 餐饮团购 / 酒店景区 / 到综服务(线上购买+线下履约) |
| **交易能力** | 线上支付 / 结算分账 |
### 接入流程
```
01 注册/入驻 → 02 选择业务生态 → 03 开发调试 → 04 经营业务
```
### 费用模式
- **免费接入**(基础 SDK 能力)
- 交易场景涉及支付结算时按规则分成
- 无月费/年费
### U-Desk 集成方案
```
U-Desk + 抖音 SDK
├── 视频分享 → 选中视频文件 → 一键分享到抖音
├── 投稿发布 → 编辑好的视频 → 直接发布到抖音
├── 用户授权 → OAuth 登录(可选,用于同步抖音身份)
└── 数据回流 → 抖音播放/互动数据回传 U-Desk 统计
```
### 为什么是"国内首选"
1. **日活 8 亿+** — 国内最大短视频平台,远超 B站
2. **完善的开放平台** — 官网活跃、文档齐全、SDK 成熟
3. **免费接入** — 无门槛,个人开发者友好
4. **Windows 桌面支持** — SDK 支持 H5/网站应用形态
5. **生态完整** — 分享/投稿/授权/支付/数据全链路
6. **与文件管理器天然契合** — 视频文件管理 → 分享/投稿一键触达
### 与 YouTube IFrame 的分工
| 场景 | 选抖音 | 选 YouTube |
|------|--------|-----------|
| 分享视频到社交平台 | ✅ 抖音国内8亿用户 | ❌ |
| 发布/投稿视频 | ✅ 抖音(直接发布) | ❌ |
| 在 U-Desk 内播放视频 | ❌ | ✅ YouTubeIFrame 嵌入) |
| 国际内容消费 | ❌ | ✅ YouTube |
| 搜索国际视频元数据 | ❌ | ✅ YouTube Data API |
---
## 三、哔哩哔哩开放平台(⚠️ 已确认不可用)
### 基本信息
- **官网**https://developer.bilibili.com/
- **状态****❌ 持续不可达**2026-05-08 多次验证connection refused
- **法律状态****⚠️ 非官方 API 文档已被律师函强制关停**(见下方证据)
### ⚠️ 法律风险升级2026-05-08 核实)
> **B站最大 GitHub API 文档仓库 `SocialSisterYi/bilibili-API-collect` 已于 2026-01-28 收到 B 委托律师事务所发律师函警告邮件,维护者随即停止维护并删除相关文档及源代码。**
这意味着:
- 文档中此前列出的"已知能力"搜索API/视频播放/弹幕/用户信息/投屏/数据统计等)**全部来自已被法律关停的非官方逆向工程**
- **无法作为合法接入依据**
- 使用此类 API 的商用产品面临法律风险
### 可行方案(仅限轻量场景)
| 方案 | 可行性 | 风险 |
|------|--------|------|
| iframe 嵌入 B站视频播放页 | ✅ 技术可行 | 低(官方分享嵌入通常允许) |
| 非官方 API自部署 NeteaseCloudMusicApi | ✅ 技术可行 | **高(法律风险,已发生律师函先例)** |
| 官方 OpenAPI | ❌ | 平台不可达 + 无公开文档 |
### 最终评估
- **作为 API 数据源**:❌ **不可用**(平台不可达 + 非官方文档已关停)
- **作为 iframe 嵌入**:⭐ 可行但功能有限(仅播放,无搜索/推荐/个性化)
- **推荐评级从 "待确认" 降为 D不可用**
- **搜索API**:搜视频/番剧/影视/直播/用户
- **视频播放**支持嵌入播放器iframe
- **弹幕系统**:发送/获取弹幕
- **用户信息**:关注/粉丝/动态
- **投屏协议**DLNA/Cast
- **数据统计**:播放量/点赞/投币/收藏
### 接入方式(推测)
| 方式 | 说明 |
|------|------|
| iframe 嵌入 | 最简单,直接嵌入视频播放页 |
| OpenAPI | RESTful 接口,需申请 AppKey |
| SDK | 可能有桌面端 SDK待确认 |
### U-Desk 适配路径
1. **轻量方案**WebView2 iframe 嵌入 B站视频 → 最快实现
2. **深度方案**:对接 OpenAPI → 搜索+自定义播放器UI
### 费用
- 个人开发者通常有免费额度
- 商用需联系商务
### 下一步
- 等 B站开发者平台恢复后查看完整文档
- 备选:直接使用 iframe 嵌入方案无需API
---
## 四、腾讯视频开放平台(⚠️ 需区分两条路径)
### 基本信息
- **C端产品**https://v.qq.com/ (腾讯视频消费者端)
- **B端云服务**https://cloud.tencent.com/product/vod (腾讯云点播 VOD
- **开放平台**https://open.tencent.com/ (存在,主要覆盖应用宝/微信/QQ
### ⚠️ 关键区分2026-05-08 核实)
| 路径 | 说明 | U-Desk 适配 |
|------|------|-------------|
| **A. 腾讯云点播 VOD** | B端云服务上传/管理/播放**自己的视频内容** | 如果 U-Desk 要做"用户上传视频→云端转码→分发播放",可用此 API |
| **B. 腾讯视频内容接入** | 在 U-Desk 中嵌入播放腾讯视频的**自有内容** | ❌ **无官方 API**。v.qq.com 无开发者页面返回404 |
### 腾讯云 VOD 能力(路径 A如需要
- 完整的媒资上传/转码/处理/分发 API
- 控制台 + SDK但主要是移动端/服务器端)
- 计费:按存储量 + 转码时长 + CDN 流量
### 结论
- 文档此前写的"可能通过腾讯云点播 VOD"是**方向正确但场景需澄清**
- **如果目标是"在 U-Desk 里看腾讯视频"**:只能 H5 嵌入(无官方 API
- **如果目标是"U-Desk 做视频托管平台"**:腾讯云 VOD 有完善 API 可用
- 对 U-Desk 当前定位(文件管理器 + 生态入口),**腾讯视频价值有限**,优先级低于抖音和 YouTube
---
## 五、爱奇艺 / 优酷
### 共同特点
- 开放程度有限,主要以 **H5 嵌入****合作接入** 为主
- 无完善的公开开发者 API 文档
- 更倾向于大客户商务合作模式
### 可行方案
- **iframe 嵌入**:直接在 WebView2 中加载视频页面
- 作为"快捷链接"功能而非深度集成
- 合规风险较低(官方提供的分享嵌入代码)
---
## 六、YouTube IFrame API国际内容补充
### 基本信息
- **文档**https://developers.google.com/youtube/iframe_api_reference
- **Player API**:完整的播放控制 JavaScript API
- **Data API v3**:搜索/获取视频元数据(需要 API Key
### 核心能力
```javascript
// IFrame API 示例 — 完美兼容 Wails WebView2
var player;
function onYouTubeIframeAPIReady() {
player = new YT.Player('player', {
height: '360',
width: '640',
videoId: 'M7lc1UVf-VE',
events: {
'onReady': onPlayerReady,
'onStateChange': onPlayerStateChange
}
});
}
```
- 播放/暂停/停止/音量/seek
- 播放状态事件监听
- 播放列表/队列管理
- 全屏控制
### 费用
- **IFrame Player API**:完全免费,无需 API Key
- **Data API v3**:免费额度 10,000 units/day
### U-Desk 适配
- **完美适配**:纯前端 JS APIWebView2 原生运行
- Go 后端可选调用 Data API 补充搜索能力
- 中国区需翻墙
### 推荐用途
- 国际视频内容补充
- 技术验证的首选平台(零成本、文档完善)
---
## 七、Vimeo Player API
### 基本信息
- **文档**https://developer.vimeo.com/api/reference
- **嵌入式播放器**:高度可定制
### 能力
- 嵌入式播放器(类似 YouTube
- Player JS API播放控制
- Upload API上传视频
- Data API视频信息获取
### 费用
- 免费层级可用(有播放限制)
- Pro 版本 ($7/月) 解锁高级功能
### 特点
- 无广告、画质高
- 设计感强,适合高端场景
- 中文内容较少
---
## 八、合规要点
| 规则 | 说明 |
|------|------|
| 嵌入播放 | 使用平台官方提供的嵌入代码/播放器,不自行解析视频流 |
| 广告保留 | 不得去除原平台的广告或会员提示 |
| 内容审核 | 平台对内容负责,但需注意嵌入内容的合规性 |
| 版权标注 | 视频版权归创作者及平台所有 |
| 流量上报 | 如有要求,按规范上报播放数据 |
> **关键原则**:视频模块优先采用 **H5/iframe 嵌入** 方案,而非自建播放器解析流地址。这样既合规又简单。
---
## 九、最终推荐方案
### MVP 阶段(快速上线)
| 优先级 | 方案 | 工作量 | 说明 |
|--------|------|--------|------|
| P0 | **抖音开放 SDK** | 1-2天 | 国内首选:分享/投稿/授权 |
| P0 | **YouTube IFrame API** | 0.5天 | 国际视频 + 嵌入播放 |
| P1 | **B站 iframe 嵌入** | 0.5天 | 国内二次元/长视频社区 |
### 迭代阶段(深度集成)
| 优先级 | 方案 | 工作量 | 说明 |
|--------|------|--------|------|
| P1 | B站 OpenAPI 对接 | 3-5天 | 搜索/推荐/个性化(需等官网恢复) |
| P1 | 抖音数据能力对接 | 2-3天 | 播放量/互动数据回传统计 |
| P2 | 腾讯视频 H5 嵌入 | 0.5天 | 补充国内长视频 |
| P2 | Vimeo API | 2-3天 | 高端无广告体验 |
### 明确舍弃
- ~~爱奇艺/优酷深度API~~ — 开放程度不足
- ~~自建视频流解析~~ — 合规风险极高
---
## 十、U-Desk 视频模块架构建议
```
U-Desk 生态链接 - 视频模块
├── VideoPlayer (Vue Component)
│ ├── YouTubePlayer.vue → YT.Iframe API
│ ├── BilibiliEmbed.vue → iframe embed
│ ├── TencentVideo.vue → H5 embed
│ └── GenericPlayer.vue → 统一播放器外壳
├── VideoService (Go)
│ ├── SearchYouTube() → YouTube Data API (可选)
│ ├── SearchBilibili() → B站 OpenAPI (后续)
│ └── SearchLocal() → 本地视频文件索引
└── VideoPanel.vue → 主面板 (搜索 + 列表 + 播放)
```
### 与文件管理器的融合点
- 在文件浏览时识别视频文件 → 提供在线搜索相似内容
- 右键菜单:"在线搜索相关视频"
- 底部栏迷你播放器(浏览文件时同时看视频)
- 收藏夹同步(收藏的视频与文件收藏统一管理)
---
*Sources:*
- *B站开放平台: https://developer.bilibili.com/*
- *YouTube IFrame API: https://developers.google.com/youtube/iframe_api_reference*
- *Vimeo Developer: https://developer.vimeo.com/api/reference*

View File

@@ -0,0 +1,335 @@
# 广播电台 / 电视 / 播客 开放平台接入方案备忘录
> 最后更新2026-05-08 | 用途U-Desk 生态链接 — 音频流媒体模块可行性评估
---
## 一、平台总览对比
| 平台 | 类型 | 费用 | 中文内容 | 桌面端适配 | 合规性 | 接入难度 | 评级 |
|------|------|------|----------|------------|--------|----------|------|
| **Radio Browser API** | 国际电台 | 免费 | 中 | 极高(RESTful) | 高 | 极低 | **A** |
| **Apple iTunes Search API** | 播客索引 | 免费 | 高 | 极高(HTTP) | 高 | 低 | **A-** |
| **喜马拉雅开放平台** | 国内音频 | 商务合作 | 极高 | 中低(Web嵌入) | 高 | 高 | B- |
| **蜻蜓FM开放平台** | 国内音频 | 商务合作 | 极高 | 低 | 高 | 高 | C+ |
| **CCTV 嵌入** | 电视 | 不定 | 极高 | 中 | 中风险 | 中 | C- |
| **TuneIn API** | 国际电台 | 不开放 | 低 | 无 | - | - | D |
| **荔枝FM / 云听** | 国内音频 | 无API | 高 | 无 | - | - | D |
| **IPTV/电视直播** | 电视 | 无合法方案 | 极高 | 无 | - | - | D |
---
## 二、Radio Browser API强烈推荐 ⭐⭐⭐)
### 基本信息
- **官网**https://www.radio-browser.info
- **API 地址**https://api.radio-browser.info
- **协议**开源项目GitLab可自由用于免费和商业软件
- **版本**v0.7.442026-05-08 验证API 在线,`/json/stats` 端点可能已迁移,但核心 station 接口正常)
- **当前规模**27,000+ 可用电台 / 80+ 国家 / 59+ 语言
- **验证状态**:✅ **已验证在线**`de1.api.radio-browser.info/json/stations/topclick` 返回实时数据2026-05-08 15:26 UTC
### 核心能力
#### 列表类接口
| 接口 | 说明 |
|------|------|
| `/json/countries` | 按国家列出电台 |
| `/json/codecs` | 编码格式列表 (MP3/AAC+/OGG) |
| `/json/states` | 按州/省列出 |
| `/json/languages` | 语言列表 (含 ISO 639 代码) |
| `/json/tags` | 标签/流派列表 (jazz/pop/rock/news) |
| `/json/stations` | 全部电台列表 |
#### 搜索类接口
| 接口 | 说明 |
|------|------|
| `/json/stations/search` | **高级搜索**name/country/language/tag/codec/bitrate/geo 多维度组合查询 |
| `/json/stations/byname/{term}` | 按名称搜索 |
| `/json/stations/bycountry/{code}` | 按国家搜索 (`CN`=中国) |
| `/json/stations/bylanguage/{lang}` | 按语言搜索 (`chinese`) |
| `/json/stations/bytag/{tag}` | 按标签搜索 |
#### 排行类接口
| 接口 | 说明 |
|------|------|
| `/json/stations/topclick` | 最热门电台 |
| `/json/stations/topvote` | 最高评分电台 |
| `/json/stations/lastclick` | 最近被点击的 |
#### 交互类接口
| 接口 | 说明 |
|------|------|
| `/json/url/{uuid}` | 点击计数 + 获取播放 URL |
| `/json/vote/{uuid}` | 为电台投票 |
| `/json/add` | 添加新电台到数据库 |
#### 运维类接口
| 接口 | 说明 |
|------|------|
| `/json/stats` | 服务器统计 |
| `/json/servers` | 镜像服务器列表(可自建) |
| `/json/config` | 服务器配置 |
### 数据结构关键字段
```json
{
"stationuuid": "唯一ID",
"name": "电台名称",
"url": "原始流地址",
"url_resolved": "已解析的直接播放URL ← 核心字段",
"homepage": "电台主页",
"favicon": "图标",
"tags": ["标签"],
"countrycode": "CN",
"language": "chinese",
"codec": "MP3",
"bitrate": 128,
"lastcheckok": true,
"clickcount": 24
}
```
### 输出格式
JSON / XML / CSV / M3U / PLS / XSPF / TTL7种
### U-Desk 集成方式
```
Go 后端 → HTTP GET radio-browser API → JSON → Vue 展示电台列表
↓ 点击播放
url_resolved 字段 = 直接可播放的 MP3/AAC 流
```
- **Go 有现成库**`goradios`
- **零成本、零审核、零依赖**
- 工作量估算2-3天含 UI
### 特别优势
- 支持按 `countrycode=CN` 筛选中文电台
- 用户可添加自定义电台(`/json/add`
- 可自建镜像服务器(完全自主可控)
- 支持 M3U/PLS 导出(兼容各类播放器)
---
## 三、Apple iTunes Search API — 播客发现(推荐 ⭐⭐⭐)
### 基本信息
- **文档**https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/ (⚠️ 国内访问可能超时需翻墙API 端点本身通常可达)
- **API 端点**`https://itunes.apple.com/search``https://itunes.apple.com/lookup`
- **费用**:免费(速率限制约 20次/分钟)
### 核心能力
#### 搜索参数
| 参数 | 说明 | 示例 |
|------|------|------|
| `term` | 搜索关键词 | `news`, `故事`, `科技` |
| `media` | 媒体类型 | `podcast` |
| `entity` | 实体类型 | `podcast`, `podcastAuthor` |
| `country` | 国家代码 | `cn` |
| `limit` | 返回数量 | 25 |
#### 使用示例
```
# 搜索中文新闻播客
GET https://itunes.apple.com/search?term=news&country=cn&media=podcast&limit=25
# 搜索特定作者
GET https://itunes.apple.com/search?term=故事&entity=podcastAuthor&country=cn
```
#### 返回数据关键字段
```json
{
"collectionName": "播客名称",
"artistName": "作者",
"feedUrl": "RSS订阅地址 ← 核心:获取音频的入口",
"artworkUrl60": "小封面",
"artworkUrl100": "大封面",
"releaseDate": "发布日期",
"primaryGenreName": "分类"
}
```
### 播放流程
```
iTunes Search API → 获取 feedUrl (RSS)
解析 RSS XML → 提取 episode 列表
enclosure URL → 音频播放
```
### U-Desk 集成方式
1. Go 后端调用 Search API → 获取播客列表和 RSS 地址
2. 解析 RSS XML 提取 episode 音频 URL
3. 前端播放器播放音频流
4. 实现 20次/分钟速率限制的客户端缓存
### 中文播客覆盖
- iTunes Podcast 目录包含大量中文播客(大陆/台湾/香港创作者)
- 覆盖新闻/故事/科技/商业/教育等各领域
### 工作量估算3-4天含 RSS 解析逻辑)
---
## 四、喜马拉雅开放平台(备选 ⭐⭐)
### 基本信息
- **官网**https://open.ximalaya.com/
- **定位**:国内最大音频分享平台(纽交所上市)
- **商务热线**(021)50179077-8806 / -8803
### 核心能力
- 音频内容分发 SDK/API
- 支持移动应用、智能硬件、车载、网页/小程序接入
- 内容类型:有声书、课程、播客、相声评书、新闻资讯、亲子儿童、景点导览
- AI 制作专区
### 接入方式
| 方式 | 说明 |
|------|------|
| 移动应用 SDK | Android/iOS/Flutter |
| 智能硬件 SDK | IoT 设备 |
| **网页/小程序 JS SDK** | ← U-Desk 的可行路径 |
### 费用模式
- **商务合作模式**,需"立即入驻"申请后商谈
- 通常为分成或流量付费模式
- 有运营中心(内容中心/数据中心/活动中心)
### U-Desk 适配路径
通过 Wails WebView 加载喜马拉雅 H5/JS SDK 播放器
- 技术层面工作量1-2天
- 商务层面周期不确定(需审批)
### 注意事项
- 开发者文档页面部分返回 404生态维护程度一般
- 但"网页/小程序"接入通道确实存在
---
## 五、蜻蜓FM开放平台低优先级
### 基本信息
- **官网**https://open.qingting.fm
- 能力SDK/API/H5嵌入式播放器
- 内容:直播流、点播、有声书、相声评书、全国电台
### 问题
- 主要面向 B 端大客户(车企、智能硬件厂商)
- 对独立桌面应用开发者门槛较高
- 文档需入驻后才可查看完整内容
- Windows 桌面端无原生支持
### 结论:优先级低于喜马拉雅
---
## 六、电视/IPTV当前不可行
### CCTV 央视网
- **无公开开发者 API**
- 可能存在 iframe 嵌入方案(灰色地带)
- 版权严格管控,合规风险高
- 评级C-(仅作为外部链接实验)
### 各省市 IPTV
- 三大运营商分别运营,无统一 API
- 专有传输协议IGMP/RTP/RTSP需专用机顶盒
- **完全不存在合法的桌面端接入途径**
- 评级D
### EPG 数据源(国际参考)
- [epg-guide.com](https://epg-guide.com):免费 EPG 数据XMLTV 格式
- 仅覆盖欧洲(意大利为主),不支持中文电视台
- 技术成熟但无中文内容,参考价值有限
### 结论:**电视模块暂不纳入 MVP**
---
## 七、荔枝FM / 云听
| 平台 | 状态 | 原因 |
|------|------|------|
| 荔枝FM (lizhi.fm) | ❌ 无公开 API | 官网无 developer/open 入口 |
| 云听 (yunting.fm) | ❌ 服务不稳定 | 访问时 500 错误,且无 API |
---
## 八、最终推荐组合方案
### 第一优先级MVP 即可上线)
| 平台 | 用途 | 工作量 | 成本 |
|------|------|--------|------|
| **Radio Browser API** | 全球网络电台收听 | 2-3天 | 免费 |
| **iTunes Search API** | 播客发现与播放 | 3-4天 | 免费 |
### 第二优先级(后续迭代)
| 平台 | 用途 | 工作量 | 前置条件 |
|------|------|--------|----------|
| **喜马拉雅 Web 嵌入** | 国内有声书/课程 | 1-2天技术 + 商务周期 | 商务合作审批 |
| **央视网链接** | 电视直播快捷入口 | 0.5天 | 关注版权政策 |
### 不推荐
- ~~TuneIn~~ — 不开放第三方 API
- ~~IPTV/电视直播~~ — 无合法接入途径
- ~~荔枝FM/云听~~ — 无可用 API
---
## 九、U-Desk 广播/播客模块架构建议
```
U-Desk 生态链接 - 音频流媒体模块
├── RadioService (Go)
│ ├── StationSearch() → Radio Browser /stations/search
│ ├── TopStations() → /stations/topclick
│ ├── StationsByCountry() → /stations/bycountry/CN
│ ├── StationsByTag() → /stations/bytag/{tag}
│ └── PlayStation() → /json/url/{uuid} → url_resolved 直播流
├── PodcastService (Go)
│ ├── SearchPodcasts() → iTunes Search API (media=podcast)
│ ├── GetPodcastDetail() → iTunes Lookup API
│ ├── ParseFeed() → RSS XML → episode 列表
│ └── PlayEpisode() → enclosure URL → 音频播放
├── AudioPlayer (Vue Component)
│ ├── RadioPanel.vue → 电台浏览/搜索/播放/收藏
│ ├── PodcastPanel.vue → 播客发现/订阅/播放/下载
│ └── MiniPlayer.vue -> 底部栏迷你播放器
└── QuickLinks (Vue)
└── LinkCard.vue → 央视网/喜马拉雅等外部链接卡片
```
### 与文件管理器的融合点
- 浏览文件时底部迷你播放器持续播放电台/播客
- 收藏夹统一管理(收藏的电台/播客/文件混合展示)
- 右键菜单:"搜索相关播客内容"
---
## 十、合规要点总结
| 平台 | 合规要求 |
|------|---------|
| Radio Browser | 开源协议友好,商用无限制,仅需合理 User-Agent |
| iTunes API | 遵守 Apple 使用条款(速率限制 + 展示要求) |
| 喜马拉雅 | 必须走正式商务流程,签订合作协议 |
| 央视网 | 以外部链接方式最安全,避免深度集成 |
---
*Sources:*
- *Radio Browser: https://www.radio-browser.info / https://api.radio-browser.info*
- *iTunes Search API: https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/*
- *喜马拉雅开放平台: https://open.ximalaya.com/*
- *蜻蜓FM开放平台: https://open.qingting.fm*
- *EPG Guide: https://epg-guide.com*

View File

@@ -0,0 +1,295 @@
# 课程专栏 / 知识付费 开放平台接入方案备忘录
> 最后更新2026-05-08 | 用途U-Desk 生态链接 — 知识学习模块可行性评估
---
## 一、平台总览对比
| 平台 | 类型 | 官方API | 费用 | 桌面端适配 | 中文内容 | 推荐评级 |
|------|------|---------|------|------------|----------|---------|
| **喜马拉雅** | 有声书/课程 | ✅ SDK+API | 商务合作 | Web嵌入 | 极全 | **A-** |
| **得到 (Dedao)** | 知识付费 | ❌ 已确认无API | - | 仅WebView嵌入 | 全 | **C+** |
| **知乎 / 盐选** | 问答/专栏 | ⚠️ 有限 | 部分免费 | Web抓取 | 全 | **B** |
| **豆瓣** | 读书/影视数据 | ⚠️ 非官方(社区维护) | 免费 | HTTP调用 | 高 | **B+** |
| **微信读书** | 电子书阅读 | ❌ 无公开API | - | - | 极全 | C |
| **Apple Books / iTunes** | 电子书/播客 | ✅ Search API | 免费 | HTTP调用 | 中 | **B+** |
| **Udemy** | 在线课程 | ✅ REST API | 免费(aff) | HTTP调用 | 低(英文) | B |
| **Coursera** | 在线课程 | ✅ API | 免费 | HTTP调用 | 低(英文) | B |
| **中国大学MOOC** | 高等教育 | ⚠️ 有限 | 免费 | Web | 高 | B- |
| **即刻时间** | 知识碎片 | ❌ 无公开API | - | - | 中 | D |
| **知识星球** | 社群/知识 | ❌ 无公开API | - | - | 中 | D |
---
## 二、喜马拉雅(首选 ⭐⭐⭐)
### 基本信息
- **开放平台**https://open.ximalaya.com/
- **定位**国内最大音频分享平台纽交所上市XIMA
- **商务热线**(021)50179077 转 8806 或 8803
### 核心能力(与 U-Desk 课程场景高度匹配)
| 内容类型 | 说明 |
|---------|------|
| **有声书** | 出版物音频化,海量书籍资源 |
| **课程/专栏** | 知识付费核心品类 |
| **播客** | UGC/PUGC 音频内容 |
| **新闻资讯** | 实时资讯音频版 |
| **亲子儿童** | 儿童故事/教育内容 |
| **相声评书** | 传统曲艺内容 |
| **景点导览** | 旅游场景音频导览 |
### 接入方式
| 方式 | 平台支持 | U-Desk 可行性 |
|------|---------|---------------|
| 移动应用 SDK | Android/iOS/Flutter | ❌ 不适用 |
| 智能硬件 SDK | IoT 设备 | ❌ 不适用 |
| **网页/小程序 JS SDK** | H5/Web 环境 | ✅ **Wails WebView2 可用** |
### 费用模式
- **商务合作**,需申请入驻后商谈
- 通常为分成模式或流量采购
- 有完整的运营中心(内容中心/数据中心/活动中心)
### U-Desk 集成路径
```
喜马拉雅 JS SDK → Wails WebView2 加载 → 课程浏览/搜索/播放
```
- 技术工作量1-2天
- 商务周期不确定,需提前启动
### 与广播电台模块的协同
- 喜马拉雅同时覆盖"课程"和"广播"两个模块
- 可作为统一音频内容入口
---
## 三、得到 Dedao已确认无开放API ❌)
### 基本信息
- **官网**https://www.dedao.cn/
- **定位**:高端知识服务平台(罗辑思维团队)
- **内容品质**:国内顶尖,每门课程均经过严格品控
### 平台规模2026年实测数据
| 维度 | 数据 |
|------|------|
| 平台用户 | **6400 万+** |
| 精品课程 | **390 门**(覆盖 20+ 领域) |
| 电子书 | **10 万+ 本**(含 1300+ 经典套系、800+ 期刊杂志) |
| 得到听书 | **3400+ 本**,每天上新 1 本 |
| 线下学习中心 | **10 个**(北京/上海/杭州/广州/深圳/成都/西安/昆明/武汉/郑州) |
| 2024 用户学习时长 | **18 亿小时+** |
| 2024 用户笔记 | **60 亿字+** |
| 企业客户 | 上千家企业 |
### 核心产品矩阵
| 产品 | 说明 |
|------|------|
| **精品课** | 系统化课程如薛兆丰经济学课、吴军科技史纲60讲 |
| **每天听本书** | 专业解读3400+ 本书 |
| **得到听书** | 名家讲书 / 精品听书 / 章鱼书场 等 |
| **电子书** | 10万本支持全文搜索 + 多设备同步 |
| **免费专区** | 得到头条 / 文明之旅 / 长谈 / 得到精选等 |
| **得到新商学** | 创业者社区12场线下大课/年 |
| **直播** | 定期直播(改稿/对谈/颁奖等) |
### 热门讲师(部分)
薛兆丰(经济学)、香帅(金融学)、吴军(计算机科学)、万维钢(科学)、刘擎(哲学)、刘润(商业)、吴伯凡(商业思想)、尹烨(健康)、蔡钰(批判性思维)、马江博(政经)...
### 开放平台/API 状态:**❌ 无任何公开 API**
- 官网无 developer/open/api 入口
- 无 SDK / 无文档 / 无开发者计划
- **结论:得到是封闭生态,不对外开放数据或能力**
### 可行的接入路径(有限)
| 方案 | 可行性 | 说明 |
|------|--------|------|
| WebView2 嵌入 Web 版 | ⭐⭐ 技术可行 | 可加载得到网页版,但无法获取结构化数据 |
| RSS / 内容抓取 | ⭐ 合规风险 | 违反服务条款,不可商用 |
| 商务合作 | ⭐⭐ 待洽谈 | 面向企业客户的定制化解决方案B端培训集成 |
### 最终评估
- **作为 API 数据源**:❌ 不可行(无开放接口)
- **作为 WebView 嵌入**:⭐⭐ 可行但价值有限(不如直接用 App
- **作为竞品参考**:⭐⭐⭐ 极有价值(产品设计/内容分类/用户体验值得学习)
- **推荐评级从 B+ 降为 C+**(无 API = 无法深度集成)
> **替代建议**:如果需要高品质中文课程内容,优先选择 **喜马拉雅**(有 JS SDK 可接入)或 **iTunes Search API**(免费播客/有声书元数据)。得到的优质内容建议引导用户通过 App 使用。
---
## 四、豆瓣(⚠️ 已确认无可用 API降级为嵌入方案
### 基本信息
- **原豆瓣 API v2 社区仓库**https://github.com/douban/douban-api-v2 → **❌ 404仓库已删除/改名)**
- **豆瓣官方客户端库**https://github.com/douban/douban-client → **❌ 已归档Archived最后更新 2014-06-22**
- **官方状态****豆瓣已完全停止公开 API 服务**。官方 OAuth 客户端库于 2014 年归档,社区维护的 v2 兼容接口仓库也已下线
### 核心能力(历史参考 — 当前均不可用)
| 接口 | 说明 | 状态 |
|------|------|------|
| 图书搜索/详情 | ISBN/书名/作者搜索,含评分/简介/封面 | ❌ 接口已失效 |
| 电影搜索/详情 | 影片信息/评分/演员/导演 | ❌ 接口已失效 |
| 读书笔记 | 用户的读书标记和短评 | ❌ 接口已失效 |
| 书单/豆列 | 用户创建的书籍列表 | ❌ 接口已失效 |
### U-Desk 可行方案(降级后)
- **WebView2 嵌入**加载豆瓣网页版book.douban.com / movie.douban.com用户手动浏览
- **右键快捷方式**:选中 PDF/电子书文件 → 右键"在豆瓣搜索" → 打开豆瓣网页搜索该 ISBN/书名
- **元数据层**:❌ 不可行(无可用 API 获取结构化数据)
### 费用N/A无可用的 API 服务)
### 评级调整:从 B+ 降至 **C+**
> **理由**:作为结构化数据源已完全不可用。仅能作为 WebView 嵌入/外部链接的跳板,价值大幅降低。
---
## 五、知乎 / 知乎盐选
### 基本信息
- **官网**https://www.zhihu.com/
- **盐选会员**https://www.zhihu.com/xuan
### 能力评估
- **无完善的公开开发者 API**
- 存在一些非官方的爬虫式接口(不推荐商用)
- 网页版可在 WebView2 中加载
### 可行方案
- **轻量集成**WebView2 嵌入知乎专栏页面
- 作为"快捷链接"而非深度集成
- 合规风险较低(官方提供的分享链接)
### 内容价值
- 国内最优质的问答/专栏内容库
- 盐选付费内容涵盖大量系统化课程
- 与"课程专栏"模块定位契合
---
## 六、微信读书(✅ 已确认完全封闭)
### 基本信息
- **官网**https://weread.qq.com/
- **定位**:腾讯旗下电子书阅读平台
- **内容规模**:极全(出版书籍 + 网文 + 漫画)
- **状态****✅ 已确认完全封闭,无任何公开开发者入口**
### 封闭证据2026-05-08 核实)
| 路径 | 响应 | 含义 |
|------|------|------|
| `weread.qq.com/api` | 404 | 无公开 API 入口 |
| `weread.qq.com/developer` | 404 | 无开发者平台 |
| `weread.qq.com/open` | 404 | 无开放入口 |
| **`i.weread.qq.com/`** | **401 Unauthorized** | ⚠️ **证实内部 API 存在但需认证,不对外开放** |
### 结论
- 公开 API**无**
- 开放平台:**无**
- 内部接口:存在(`i.weread.qq.com` 返回 401 = 需登录态认证)→ **确认不对外**
- 唯一可行方案WebView2 嵌入 Web 版或外部快捷链接
- **评级维持 C合理** — 与得到一致,都是腾讯系封闭内容生态
---
## 七、国际平台补充
### Apple iTunes Search API已在上篇详述
- 搜索 Audiobooks / Podcasts / 教育类内容
- 免费使用20次/分钟限制
- 返回 RSS Feed URL 用于内容获取
### Udemy
- **Developer API**https://www.udemy.com/developers/affiliate/
- **类型**Affiliate API联盟营销
- 能力:课程搜索/详情/分类
- 费用:免费(通过推广获得佣金)
- 限制:主要英文内容,中文课程有限
### Coursera
- **API**https://build.coursera.org/
- 能力:课程目录/详情/学习者进度
- 主要面向机构合作
- 个人开发者可获取基础课程数据
### 中国大学 MOOCicourse163
- **官网**https://www.icourse163.org/
- 国内最大的高等教育在线课程平台
- 无公开 API可通过 Web 抓取部分数据
- 内容质量高(来自北大/清华等名校)
---
## 八、明确不可行的平台
| 平台 | 原因 |
|------|------|
| 即刻时间 | 无公开 API产品形态偏碎片化 |
| 知识星球 | 无公开 API定位为私密社群 |
| 荔枝微课 | 未找到稳定的开放接口 |
| 千聊 | 主要面向知识创作者工具端 |
---
## 九、最终推荐方案
### MVP 阶段
| 优先级 | 平台 | 用途 | 工作量 |
|--------|------|------|--------|
| P0 | **喜马拉雅 JS SDK** | 有声书 + 课程 + 播客一体化 | 1-2天技术 + 商务周期 |
| P1 | **iTunes Search API** | 国际有声书/播客补充 | 1天 |
| P2 | **豆瓣 WebView 嵌入** (⚠已无API) | 右键跳转豆瓣网页搜索 | 0.5天(极轻量) |
### 迭代阶段
| 优先级 | 平台 | 用途 | 前置条件 |
|--------|------|------|----------|
| P1 | **知乎嵌入** | 专栏/问答快捷入口 | 无 |
| P2 | **Udemy Affiliate** | 英文课程补充 | 申请联盟账号 |
### 明确舍弃
- ~~得到~~ — **已确认无任何开放 API**封闭生态6400万用户/390门课/10万本电子书均无法通过 API 获取)
---
## 十、U-Desk 课程/知识模块架构建议
```
U-Desk 生态链接 - 知识学习模块
├── KnowledgeService (Go)
│ ├── SearchBooks() → 豆瓣 API (ISBN/书名)
│ ├── GetBookDetail() → 豆瓣 API (评分/简介/封面)
│ ├── SearchAudiobooks() → iTunes API (media=audiobook)
│ └── SearchCourses() → 喜马拉雅 SDK / 得到(后续)
├── KnowledgePanel (Vue)
│ ├── Bookshelf.vue → 正在听的书籍/课程
│ ├── CourseBrowser.vue → 浏览/搜索课程
│ ├── DailyBook.vue → "每天一本书"推荐卡片
│ └── FileBookLink.vue → 文件 ↔ 关联书籍信息
└── XimalayaPlayer (Vue/WebView)
└── XimalayaEmbed.vue → 喜马拉雅内容嵌入式播放
```
### 与文件管理器的融合点(核心差异化)
1. **PDF/电子书增强**:浏览电子书文件时,侧栏显示豆瓣评分和简介
2. **"边管边听"**:底部迷你播放器持续播放有声书/课程
3. **智能推荐**:根据当前文件夹内容类型(如代码/设计稿),推荐相关课程
4. **收藏联动**:收藏的书籍/课程与文件收藏夹统一视图
5. **专注模式**:一键隐藏文件管理器面板,仅保留课程播放界面
---
*Sources:*
- *喜马拉雅开放平台: https://open.ximalaya.com/*
- *得到: https://www.dedao.cn/*
- *豆瓣 API: https://github.com/douban/douban-api-v2*
- *知乎: https://www.zhihu.com/*
- *Udemy Affiliate: https://www.udemy.com/developers/affiliate/*
- *Coursera API: https://build.coursera.org/*
- *中国大学MOOC: https://www.icourse163.org/*

View File

@@ -0,0 +1,494 @@
# 生活服务 开放平台接入方案备忘录
> 最后更新2026-05-08 | 用途U-Desk 生态链接 — 生活服务入口模块可行性评估
---
## 一、平台总览对比
### 天气服务
| 平台 | 官网 | 费用 | 数据质量 | 推荐评级 |
|------|------|------|---------|---------|
| **和风天气 (QWeather)** | [dev.qweather.com](https://dev.qweather.com/) | 按量付费(有免费层) | 极高 | **A** |
| 高德天气 API | [lbs.amap.com](https://lbs.amap.com/) | 免费(含配额) | 高 | **A** |
| 中国气象局公开数据 | 无统一API | 免费 | 官方权威 | B+ |
| OpenWeatherMap | [openweathermap.org](https://openweathermap.org/) | 免费/付费 | 国际 | B |
### 快递物流
| 平台 | 官网 | 费用 | 覆盖范围 | 推荐评级 |
|------|------|------|---------|---------|
| **快递100** | [kuaidi100.com/openapi](https://www.kuaidi100.com/openapi/) | 按量付费(有免费) | 2200+ 家快递公司 | **A** |
| 快递鸟 | 不详 | 商务合作 | 主要快递 | B |
| 各快递官方API | 分散 | 需单独申请 | 单家 | C |
### 地图导航
| 平台 | 官网 | 费用 | 能力 | 推荐评级 |
|------|------|------|------|---------|
| **高德开放平台** | [lbs.amap.com](https://lbs.amap.com/) | 免费额度 + 按量 | 全能LBS平台 | **A** |
| **百度地图开放平台** | [lbsyun.baidu.com](https://lbsyun.baidu.com/) | 免费额度 + 按量 | 全能LBS + AI增强 | **A** |
| **腾讯位置服务** | [lbs.qq.com](https://lbs.qq.com/) | 免费额度 + 按量 | 全能LBS + 街景 | **A-** |
### 翻译服务
| 平台 | 费用 | 免费额度 | 推荐评级 |
|------|------|---------|---------|
| **百度翻译 API** | 按量 | **⚠️ 服务器500 审计中** | **待验证** |
| 有道翻译 API | 按量 | 有免费层级 | A- |
| 腾讯翻译君 API | 按量 | 有免费层级 | B+ |
| Google Translate API | 按量(较贵) | 50万字符/月免费 | B |
### 日历/效率
| 平台 | 能力 | 推荐评级 |
|------|------|---------|
| **钉钉开放平台** | 4000+ API日历/待办/文档 | **A** |
| **飞书开放平台** | 日历/文档/多维表格 | **A** |
| 微软 Graph API (Outlook) | Calendar/To Do/Mail | A- |
### 新闻资讯
| 平台 | 方式 | 推荐评级 |
|------|------|---------|
| **RSS 聚合(自建)** | 开源方案,完全自主可控 | **A** |
| 今日头条开放平台 | 内容分发(面向创作者) | B |
| 各新闻客户端API | 通常不开放第三方 | C |
---
## 二、和风天气 QWeather推荐 ⭐⭐⭐)
### 基本信息
- **官网**https://dev.qweather.com/
- **定位**:专业气象数据服务商
### 核心能力
- **实时天气**:温度/湿度/气压/风速/能见度
- **天气预报**未来3-7天逐小时/逐日预报
- **灾害预警**:气象灾害预警信息
- **空气质量**AQI / PM2.5 / PM10 / 主要污染物
- **历史天气**:历史气象数据查询
- **地理天气**:目标区域当前及未来天气
- **全球部署**:全球范围数据覆盖
### 技术特点
- 标准 RESTful API + 多语言 SDK
- 全球部署(无需面对各国本地化问题)
- **按量计费**:用多少付多少,无需预付,无隐藏费用
- 注册后创建项目和 KEY 即可使用
### 免费额度
- 基础订阅包含一定的免费调用次数
- 个人开发者/小应用通常够用
### U-Desk 使用场景
- 任务栏/状态栏显示实时天气
- 文件管理器侧栏天气小组件
- 出行前快速查看目的地天气
---
## 三、高德地图开放平台(推荐 ⭐⭐⭐)
### 基本信息
- **官网**https://lbs.amap.com/
- **归属**:阿里巴巴集团
- **定位**:国内最完善的 LBS基于位置的服务开放平台
### 核心能力矩阵
#### 搜索定位类
| 产品 | 说明 | 形态 |
|------|------|------|
| **搜索** | 位置/周边/行政区/POI ID 查询 | API / JS / Android / iOS |
| **定位** | 基于 LBS 的定位服务 | API / Android / iOS |
| **地理/逆地理编码** | 经纬度 ↔ 地址转换 | API / JS / Android / iOS |
| **地理围栏** | 虚拟空间围栏服务 | API / Android / iOS |
| **天气查询** | 目标区域当前/未来天气 | API |
| **智能硬件定位** | 基站/WiFi 定位 | SDK |
#### 路线导航类
| 产品 | 说明 | 形态 |
|------|------|------|
| **导航** | 专业导航能力 | Android / iOS |
| **路线规划** | 步行/驾车等路径规划 | API / JS / Android / iOS |
| **猎鹰服务** | 专业轨迹管理 | API / Android / iOS |
| **货车路径规划** | 专业货运路径规划 | API |
| **公交信息查询** | 公交线路/站点查询 | API |
| **交通路况查询** | 实时交通态势 | API |
#### 地图产品类
| 产品 | 说明 | 形态 |
|------|------|------|
| **动态地图** | 2D 地图展示与配置 | API / JS / Android / iOS |
| **3D 地图** | 3D 渲染地图视图 | JS / Android / iOS |
| **静态地图** | 将地图嵌入网页 | 图片 |
| **地铁图** | 地铁线路图 | JS / Android / iOS |
| **3D 地形图** | 卫星地形图 | JS |
#### 高级工具
| 产品 | 说明 |
|------|------|
| **世界地图** | 全球 LBS 服务 |
| **LOCA 数据可视化** | 百万级数据可视化渲染 |
| **GeoHUB 地图数据中心** | 数据管理/编辑/发布/分析 |
| **MCP Server** | 12项核心功能 SSE 快速集成 ← 新! |
| **CLI 工具** | 命令行调用高德能力 ← 新! |
### 规模数据
- 日均处理近 **1000亿次** 定位及路线规划请求
- 覆盖超过 **7000万** POI 数据点
- 覆盖超过 **3500万** 地址库数据
- 地级市实时路况准确率 **95%**
- 服务可用性 **99.9%**
- 平均响应时长 **≤300ms**
### 费用模式
- **Web 服务 API**:免费额度(基础调用),超出按量计费
- **JS API**:大部分免费(商业授权需认证)
- 定价页面https://lbs.amap.com/ 定价
### U-Desk 使用场景
1. **文件位置展示**:文件带有 GPS 信息时在地图上标记
2. **地址解析**:将文本地址转为经纬度坐标
3. **路线规划**:查看两个地点间的路线
4. **周边搜索**:查找文件所在位置周边的 POI
5. **天气集成**:高德自带天气查询 API可替代独立天气服务
---
## 四、快递100 API推荐 ⭐⭐⭐)
### 基本信息
- **官网**https://www.kuaidi100.com/openapi/
- **定位**:企业级快递物流信息集成解决方案
- **规模**15年经验、250万+企业客户、日均查询突破 **4亿** 次、吞吐量近 **40万/秒**
### 核心能力
#### 查询类
| API | 说明 |
|-----|------|
| **实时快递查询 API** | 主动查询物流状态,即时返回最新信息 |
| **快递查询地图轨迹 API** | 返回包裹地图轨迹 + 预估时效 |
| **智能单号识别 API** | 自动判断单号归属的快递公司 |
#### 订阅推送类
| API | 说明 |
|-----|------|
| **地图轨迹推送服务 API** | 定时监控并推送地图轨迹 + 预估送达时间 |
#### 电子面单类
| API | 说明 |
|-----|------|
| **电子面单 API** | 支持 50+ 快递公司面单获取、云打印、本地打印 |
| **自定义打印 API** | 自定义内容(商品清单/发票/发货单) |
| **电商平台打单** | 同步多平台订单,统一发货打单 |
#### 物流服务类
| API | 说明 |
|-----|------|
| **商家寄件 API** | 下单至快递公司,上门取件,线上结算 |
| **同城配送 API** | 多家同城配送公司支持 |
| **物流全链路监控** | 15+ 电商平台发货规则同步,履约时效监控 |
#### 跨境服务类
| API | 说明 |
|-----|------|
| **国际物流查询 API** | 2200+ 全球运输商,支持简/繁/英 |
| **国际地址解析 API** | 国际地址标准化输出 |
| **国际电子面单 API** | 国际面单获取与绑定 |
| **跨境发货助手** | 一站式跨境发货 |
#### 增值服务
| API | 说明 |
|-----|------|
| 智能地址解析 | 提取姓名/电话/地址并结构化 |
| 快递时效查询 | 预测到达时间 |
| 拦截改址 | 运输中快件拦截处理 |
| 短信提醒发送 | 物流节点通知 |
| 快递面单 OCR | 面单识别提取信息 |
| Excel 批量查询 | 非技术用户批量查询 |
| 快递预估价格 | 不同快递成本估算 |
| 快递可用性查询 | 线路是否支持寄送 |
### 覆盖范围
- **2200+** 全球运输商物流轨迹查询
- **50+** 快递公司面单打印
- **10+** 快递公司寄件下单
- 支持国内外主流快递
### U-Desk 使用场景
1. **快递追踪面板**:输入单号一键查询物流状态
2. **文件关联**:收到快递单号截图/PDF 时自动识别单号
3. **批量查询**Excel 表格批量导入单号查询
4. **桌面小组件**:常驻快递列表,状态更新提醒
---
## 五、钉钉开放平台(效率工具 ⭐⭐⭐)
### 基本信息
- **官网**https://open.dingtalk.com/
- **归属**:阿里巴巴 / 钉钉科技有限公司
- **定位**:企业数字化一站式开发平台
### 核心数据
- **4000+** 开放接口
- 应用类型:网页应用 / 小程序 / 机器人 / 酷应用 / AI助理 / AI工作流
### 核心能力
| 能力域 | 说明 |
|--------|------|
| **通讯录** | 用户/部门/角色信息读取 |
| **消息** | 发送工作通知/群消息/卡片消息 |
| **日历** | 日程/会议/空闲忙查询 |
| **待办事项** | 创建/查询/更新待办 |
| **审批** | 发起/审批流程实例 |
| **考勤** | 打卡记录/排班/请假 |
| **文档** | 在线文档读写 |
| **机器人** | 群机器人 @ 触发回复 |
| **AI 工作流** | AI 驱动的自动化流程 |
| **酷应用** | 深度嵌入钉钉的富交互应用 |
### 开发方式
- **全代码开发**:完整 API 控制
- **低代码开发**:宜搭平台快速搭建
- **AI 开发**AI 辅助开发
- **硬件开发**:智能硬件接入
### U-Desk 使用场景
1. **日历联动**U-Desk 显示钉钉日程/待办
2. **文件快捷分享**:选中文件一键发送到钉钉会话
3. **审批关联**:文件作为审批附件
4. **消息通知**U-Desk 事件推送到钉钉
> 注意:钉钉主要面向企业场景。个人版 U-Desk 可选接入。
---
## 六、百度地图开放平台(与高德同级 ⭐⭐⭐)
### 基本信息
- **官网**https://lbsyun.baidu.com/
- **归属**:百度
- **定位**:百万开发者首选的地图服务商
### 核心能力矩阵
#### 热门产品
| 产品 | 说明 |
|------|------|
| **地点检索 3.0** | 多维检索 + AI 向导 |
| **北斗定位** | 优先北斗卫星定位 |
| **鹰眼轨迹** | 专业轨迹管理服务 |
| **货车导航** | 专业的货运路径规划 |
| **地址服务** | 智能地址解析与标准化 |
| **地图可视化** | 数据可视化渲染引擎 |
| **专网地图** | 私有化部署地图服务 |
#### 开发形态(三档接入)
| 接入方式 | 适用场景 |
|----------|---------|
| **简单接口服务**3步接入 | 注册 → 获取密钥(AK) → 集成 API/SDK — 个人开发者/小应用 |
| **复杂定制服务** | 需要更多能力或定制化的中大型应用 |
| **行业解决方案** | 物流/文旅/交通/汽车/金融等千行百业 |
#### SDK / API 覆盖
| 平台 | 产品 |
|------|------|
| Web | JS API GL / Three.js 数据可视化 / 微信小程序 / 地铁图 |
| Android | 地图 SDK / 定位 SDK / 鹰眼轨迹 SDK / 导航 SDK / 全景 SDK / 司乘同显 SDK |
| iOS | 同 Android 全套 |
| **HarmonyOS** | NEXT 版地图/定位/鹰眼/步骑行导航/驾车导航/司乘同显 ← **华为生态全覆盖** |
#### 特色能力(区别于高德)
| 能力 | 说明 |
|------|------|
| **AI 向导** | 检索结果 AI 增强,智能推荐 |
| **境内英文地图** | 行业首家,支持全流程英文出行 |
| **全球位置服务** | 2B2C 一体,助力政企出海 |
| **超视距巡航** | 3D 地图高级浏览 |
| **红绿灯倒计时** | 路线规划含实时信号灯信息 |
| **定位防作弊** | 虚假位置检测 |
| **离线定位** | 无网络环境下的定位能力 |
### 接入流程(个人开发者)
1. 注册/登录百度账号
2. 创建应用,获取密钥 (AK)
3. 集成服务 API / SDK
### 费用模式
- **Web 服务 API**:免费额度(基础调用),超出按量计费
- **JS API**:大部分免费
- 定价页面可查看详细阶梯
### U-Desk 使用场景(与高德互补)
- 百度地图在 **AI 检索增强****HarmonyOS 支持** 方面有特色
- 如果 U-Desk 未来考虑鸿蒙端,百度有现成 HarmonyOS NEXT SDK
- 建议与高德 **二选一或同时接入**(切换成本低,都是标准 RESTful API
---
## 七、腾讯位置服务(备选 ⭐⭐⭐)
### 基本信息
- **官网**https://lbs.qq.com/
- **归属**:腾讯
- **定位**:立足生态,连接未来
### 核心能力
- **JavaScript API**Web 端地图开发
- **移动端 Native SDK**Android / iOS
- **WebService 接口**:服务端调用
- **街景 API**:腾讯特色的街景视图(高德/百度无此能力)
- **路线规划**:驾车/步行/公交/骑行
- **地理编码/逆地理编码**:地址 ↔ 经纬度
- **地点搜索**POI 关键词检索
### 特色优势
| 能力 | 说明 |
|------|------|
| **街景服务** | 腾讯独有360° 街景全景图 |
| **微信生态整合** | 与微信小程序/公众号天然打通 |
| **腾讯系协同** | 与 QQ 音乐、腾讯视频等同一生态 |
### 费用模式
- 免费额度 + 按量计费(与高德/百度类似)
### U-Desk 适配评估
- 技术层面完全可行(标准 JS API / RESTful
- **如果 U-Desk 已接入 QQ 音乐/腾讯视频**,腾讯地图可作为统一腾讯生态补充
- 街景能力是差异化亮点(如展示文件拍摄地点的街景)
---
## 八、其他生活服务速查
### 翻译服务(三选一即可)
| 服务 | 免费额度 | 特点 |
|------|---------|------|
| 百度翻译 API | 标准版免费 5万字符/月 | 中文优化好 |
| 有道翻译 API | 免费额度 | 学术文献翻译强 |
| 腾讯翻译君 API | 免费额度 | 多语种支持广 |
**建议**:选百度翻译(免费额度大 + 中文优化)
### 新闻资讯 — RSS 自建方案(强烈推荐)
- 完全自主可控,无第三方依赖
- 推荐框架:`rss-parser` (Node.js) 或 `feedparser` (Go)
- 可订阅源36氪/虎嗅/少数派/V2EX/Hacker News 等
- U-Desk 实现Go 后端定时抓取 → Vue 展示新闻列表
### 12306 / 航旅
- **12306**:无公开 API极其严格
- **航旅纵横**:无公开开发者接口
- **各航空公司**:部分有开放 API如东航/南航)
- 结论:暂不可行,以外部链接方式提供
### 外卖(美团/饿了么)
- 主要面向商家端 API
- 无消费者端的开放接口
- 结论:暂不可行
### 支付宝/微信支付
- 面向商户的支付能力开放
- 与 U-Desk 场景关联度低
- 如需"打赏"等功能可后续考虑
---
## 九、最终推荐组合方案
### 第一优先级MVP 必选)
| 服务 | 平台 | 用途 | 工作量 | 成本 |
|------|------|------|--------|------|
| 天气 | 和风天气 / 高德天气 | 状态栏/侧栏天气显示 | 0.5天 | 免费~少量 |
| 地图 | 高德开放平台 | 文件位置/地址解析/路线 | 1-2天 | 免费 |
| 快递 | 快递100 API | 物流追踪面板 | 1-2天 | 免费~少量 |
### 第二优先级(体验增强)
| 服务 | 平台 | 用途 | 工作量 |
|------|------|------|--------|
| 翻译 | 百度翻译 API | 文件内容/界面翻译 | 0.5天 |
| 新闻 | RSS 自建聚合 | 信息流/资讯面板 | 1天 |
| 效率 | 钉钉开放平台 | 日历/待办/文件分享 | 2-3天 |
### 第三优先级(锦上添花)
| 服务 | 平台 | 用途 |
|------|------|------|
| 日历 | 微软 Graph API | Outlook 日历同步 |
| AI 助手 | 对接已有 AI 工作台 | 自然语言操作文件 |
---
## 十、U-Desk 生活服务模块架构建议
```
U-Desk 生态链接 - 生活服务模块
├── WeatherService (Go)
│ ├── GetCurrentWeather() → QWeather / 高德天气 API
│ ├── GetForecast() → 未来3-7天预报
│ └── GetAirQuality() → 空气质量指数
├── MapService (Go)
│ ├── Geocode() → 地址 → 经纬度 (高德)
│ ├── ReverseGeocode() → 经纬度 → 地址
│ ├── SearchPOI() → 周边 POI 搜索
│ └── RoutePlan() → 路线规划
├── ExpressService (Go)
│ ├── TrackPackage() → 快递100 查询
│ ├── AutoDetectCarrier() → 单号自动识别
│ └── BatchTrack() → 批量查询
├── TranslateService (Go)
│ └── Translate() → 百度翻译 API
├── NewsService (Go)
│ ├── FetchFeeds() → RSS 抓取聚合
│ └── ParseFeed() → 解析/去重/分类
├── LifePanel (Vue Components)
│ ├── WeatherWidget.vue → 天气小组件
│ ├── ExpressTracker.vue → 快递追踪面板
│ ├── MapViewer.vue → 地图查看器
│ ├── NewsFeed.vue → 新闻资讯流
│ └── QuickTools.vue → 翻译/计算器/汇率等
└── DingTalkBridge (Go)
├── SyncCalendar() → 日历同步
├── ShareToFile() → 文件分享到钉钉
└── PushNotification() → 事件通知
```
### 与文件管理器的融合点(核心价值)
1. **天气**:任务栏图标 hover 显示天气,不影响主界面
2. **快递**:收到快递单号图片/PDF → 右键"查询物流"
3. **地图**GPS 照片/日志文件 → 自动在地图标注位置
4. **翻译**:选中文件名或文本 → 右键"翻译"
5. **新闻**:文件管理间隙浏览资讯(不打断工作流)
6. **钉钉**:文件右键一键分享到钉钉聊天/文档
---
## 九、成本预估汇总
| 服务 | 月成本(个人版预估) | 备注 |
|------|---------------------|------|
| 和风天气 | ¥0 ~ ¥50 | 免费层够个人使用 |
| 高德地图 | ¥0 ~ ¥100 | Web API 免费额度较大 |
| 快递100 | ¥0 ~ ¥50 | 有免费试用额度 |
| 百度翻译 | ¥0 | 标准版 5万字符/月免费 |
| RSS 新闻 | ¥0 | 完全自建,无成本 |
| 钉钉 | ¥0 | 企业版可能收费 |
| **合计** | **¥0 ~ ¥200/月** | MVP 阶段接近零成本 |
---
*Sources:*
- *和风天气: https://dev.qweather.com/*
- *高德开放平台: https://lbs.amap.com/*
- *快递100: https://www.kuaidi100.com/openapi/*
- *钉钉开放平台: https://open.dingtalk.com/*
- *百度翻译 API: https://fanyi-api.baidu.com/*
- *OpenWeatherMap: https://openweathermap.org/api*

View File

@@ -0,0 +1,106 @@
# U-Desk 生态链接 — 开放平台接入方案总览
> 最后更新2026-05-08 | 状态:调研完成,待实施
---
## 什么是"生态链接"
U-Desk 作为 Wails v3 桌面文件管理器,除了核心的文件管理能力外,通过接入第三方开放平台的 **合规 API/SDK**,为用户提供一站式的 **音乐 / 视频 / 广播 / 课程 / 生活服务** 入口。
**核心理念**:文件管理是主场景,生态服务是增强层 — 不喧宾夺主,而是让用户在管理文件的过程中顺手获得其他服务。
## 文档结构
```
docs/04-功能迭代/生态链接/
├── README.md ← 本文件(总览)
├── 01-音乐平台.md ← QQ音乐/网易云/酷狗/Spotify/Apple Music
├── 02-视频平台.md ← B站/腾讯视频/YouTube/Vimeo
├── 03-广播电台.md ← Radio Browser/iTunes播客/喜马拉雅/CCTV
├── 04-课程专栏.md ← 喜马拉雅/得到/豆瓣/知乎/国际MOOC
└── 05-生活服务.md ← 天气/快递/地图/翻译/钉钉/RSS新闻
```
## 各模块推荐方案速查
| 模块 | P0 首选(国内) | P0 首选(国际) | P1 备选 | MVP 工作量 |
|------|---------------|---------------|---------|-----------|
| **音乐** | ~~QQ音乐~~ (❌ Win不支持) → **Spotify Web API** | **Spotify** | 网易云(实验性) | 3-5天 |
| **视频** | **抖音开放 SDK** ✅ | YouTube IFrame | ~~B站~~ (⚠️ API仓库被律师函关停) | 2-3天 |
| **广播/播客** | Radio Browser API | iTunes Podcast | 喜马拉雅 Web | 4-6天 |
| **课程/知识** | 喜马拉雅 JS SDK | iTunes Search API | ~~豆瓣API~~ (❌仓库404+官方库2014归档) | 2-3天 |
| **生活服务** | **高德 / 百度地图**(二选一) | - | 快递100+和风天气+钉钉 | 3-5天 |
> * 音乐模块含 QQ 音乐桌面端确认的前置依赖,若不支持需切换方案
## 合规底线
1. **全部使用官方 API/SDK**,禁止爬虫/逆向/解密
2. **必须用户授权登录**,借用用户自身会员权益
3. **如实上报播放/调用流水**
4. **标注内容版权归原平台及版权方所有**
5. **优先采用 iframe/H5 嵌入** 方案(视频/音频),而非自建播放器解析流
## 技术适配原则
| 原则 | 说明 |
|------|------|
| WebView2 兼容 | 所有 Web/JS 方案均可在 Wails WebView2 中运行 |
| Go 后端代理 | 敏感操作API Key 管理/速率控制)通过 Go 后端代理 |
| 渐进式加载 | 生态模块按需加载,不影响文件管理器启动速度 |
| 统一 UI 风格 | 即使嵌入第三方内容,也用 U-Desk 统一外壳包裹 |
## 国内平台调研状态总览
| 平台 | 调研深度 | 状态 | 主要发现 |
|------|---------|------|---------|
| **抖音开放平台** | ✅ 官网完整抓取 | **可接入** | SDK完善免费分享/投稿/授权/支付全链路 |
| **高德地图** | ✅ 官网完整抓取 | **可接入** | 44种产品MCP/CLI 新能力HarmonyOS 支持 |
| **百度地图** | ✅ 官网完整抓取 | **可接入** | AI 向导检索HarmonyOS 全覆盖,与高德同级 |
| **腾讯位置服务** | ✅ 开放平台确认 | **可接入** | 街景特色,微信生态协同 |
| **喜马拉雅** | ✅ 官网完整抓取 | **需商务审批** | JS SDK 可用,内容极全(有声书+课程+播客) |
| **QQ音乐** | ✅ 代理深度报告 | **⚠️ 待确认** | Windows 桌面端未在支持列表,需邮件确认 |
| **得到** | ✅ 官网完整抓取 | **❌ 无 API** | 封闭生态6400万用户/390门课/10万本电子书均不开放 |
| **B站开放平台** | ⚠️ 官网持续不可达connection refused | **❌ 已确认不可用** | 非官方API文档已于2026-01-28被律师函关停SocialSisterYi/bilibili-API-collect |
| **快递100** | ✅ 官网完整抓取 | **可接入** | 2200+ 快递公司15年经验企业级方案 |
| **和风天气** | ✅ 官网完整抓取 | **可接入** | 全球部署,按量计费,有免费层 |
| **钉钉开放平台** | ✅ 官网完整抓取 | **可接入** | 4000+ 接口,全代码/低代码/AI 开发 |
> **跨模块注意**:喜马拉雅同时覆盖「广播电台」和「课程专栏」两个模块,接入一次即可服务双场景。
## 实施路线图
### Phase 0 — 技术验证1周
- [ ] 发邮件至 QQ 音乐确认 Windows 桌面端支持
- [ ] Radio Browser API 对接验证Go 调用 → Vue 展示 → 播放)
- [ ] YouTube IFrame API 嵌入验证
- [ ] 高德地图 JS API 嵌入验证
### Phase 1 — MVP 上线2-3周
- [ ] 广播电台模块Radio Browser + iTunes 播客)
- [ ] 视频嵌入模块YouTube + B站
- [ ] 天气小组件(和风天气 / 高德天气)
- [ ] 快递追踪面板快递100
### Phase 2 — 深度集成3-4周
- [ ] 音乐模块QQ 音乐或 Spotify
- [ ] 地图能力(高德完整 LBS
- [ ] 课程/知识入口(喜马拉雅 + 豆瓣元数据)
- [ ] RSS 新闻聚合
### Phase 3 — 生态完善(持续迭代)
- [ ] 钉钉/飞书效率工具联动
- [ ] 翻译服务集成
- [ ] 更多平台按需接入
## 成本预估
| 阶段 | 月成本 | 说明 |
|------|--------|------|
| Phase 0 | ¥0 | 全部使用免费额度验证 |
| Phase 1 | ¥0 ~ ¥100 | 天气+快递少量调用 |
| Phase 2 | ¥50 ~ ¥300 | 根据用户量增长 |
| Phase 3 | ¥100 ~ ¥500+ | 多平台叠加 |
> 个人使用场景下,**MVP 阶段月成本可控制在 ¥100 以内**。

Submodule docs/08-用户指南/u-desk-site added at 8f29a7e985

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -21,12 +21,28 @@ import * as filesystem$0 from "./internal/filesystem/models.js";
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import * as $models from "./models.js"; import * as $models from "./models.js";
/**
* BgmGetPlaylist 获取播放列表
*/
export function BgmGetPlaylist(): $CancellablePromise<$models.BgmPlaylistItem[]> {
return $Call.ByID(3200870077).then(($result: any) => {
return $$createType1($result);
});
}
/**
* BgmSavePlaylist 全量保存播放列表(前端调用时传完整列表)
*/
export function BgmSavePlaylist(items: $models.BgmPlaylistItem[]): $CancellablePromise<void> {
return $Call.ByID(2929660002, items);
}
/** /**
* CheckUpdate 检查更新 * CheckUpdate 检查更新
*/ */
export function CheckUpdate(): $CancellablePromise<{ [_ in string]?: any }> { export function CheckUpdate(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(586574094).then(($result: any) => { return $Call.ByID(586574094).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -42,7 +58,7 @@ export function ClearCache(): $CancellablePromise<void> {
*/ */
export function CreateDir(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function CreateDir(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(632035444, path).then(($result: any) => { return $Call.ByID(632035444, path).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -51,7 +67,7 @@ export function CreateDir(path: string): $CancellablePromise<filesystem$0.FileOp
*/ */
export function CreateFile(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function CreateFile(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(3418645411, path).then(($result: any) => { return $Call.ByID(3418645411, path).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -64,7 +80,7 @@ export function DeleteConnectionProfile(id: number): $CancellablePromise<void> {
*/ */
export function DeletePath(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function DeletePath(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(1564637217, path).then(($result: any) => { return $Call.ByID(1564637217, path).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -80,7 +96,7 @@ export function DeletePermanently(recyclePath: string): $CancellablePromise<void
*/ */
export function DetectFileTypeByContent(path: string): $CancellablePromise<{ [_ in string]?: any }> { export function DetectFileTypeByContent(path: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(3067282982, path).then(($result: any) => { return $Call.ByID(3067282982, path).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -89,7 +105,7 @@ export function DetectFileTypeByContent(path: string): $CancellablePromise<{ [_
*/ */
export function DownloadUpdate(downloadURL: string): $CancellablePromise<{ [_ in string]?: any }> { export function DownloadUpdate(downloadURL: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(115027584, downloadURL).then(($result: any) => { return $Call.ByID(115027584, downloadURL).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -105,7 +121,7 @@ export function EmptyRecycleBin(): $CancellablePromise<void> {
*/ */
export function ExportPDF(content: string, title: string, fileName: string, fontSize: number, pageWidth: number, pageHeight: number): $CancellablePromise<{ [_ in string]?: any }> { export function ExportPDF(content: string, title: string, fileName: string, fontSize: number, pageWidth: number, pageHeight: number): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1770450987, content, title, fileName, fontSize, pageWidth, pageHeight).then(($result: any) => { return $Call.ByID(1770450987, content, title, fileName, fontSize, pageWidth, pageHeight).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -128,7 +144,7 @@ export function ExtractFileFromZipToTemp(zipPath: string, filePath: string): $Ca
*/ */
export function GetAppConfig(): $CancellablePromise<{ [_ in string]?: any }> { export function GetAppConfig(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(2006534548).then(($result: any) => { return $Call.ByID(2006534548).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -137,7 +153,7 @@ export function GetAppConfig(): $CancellablePromise<{ [_ in string]?: any }> {
*/ */
export function GetAuditLogs(limit: number): $CancellablePromise<{ [_ in string]?: any }[]> { export function GetAuditLogs(limit: number): $CancellablePromise<{ [_ in string]?: any }[]> {
return $Call.ByID(3554903517, limit).then(($result: any) => { return $Call.ByID(3554903517, limit).then(($result: any) => {
return $$createType3($result); return $$createType5($result);
}); });
} }
@@ -146,7 +162,7 @@ export function GetAuditLogs(limit: number): $CancellablePromise<{ [_ in string]
*/ */
export function GetCPUInfo(): $CancellablePromise<{ [_ in string]?: any }> { export function GetCPUInfo(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(2509681007).then(($result: any) => { return $Call.ByID(2509681007).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -155,7 +171,7 @@ export function GetCPUInfo(): $CancellablePromise<{ [_ in string]?: any }> {
*/ */
export function GetCommonPaths(): $CancellablePromise<{ [_ in string]?: string }> { export function GetCommonPaths(): $CancellablePromise<{ [_ in string]?: string }> {
return $Call.ByID(3953343786).then(($result: any) => { return $Call.ByID(3953343786).then(($result: any) => {
return $$createType4($result); return $$createType6($result);
}); });
} }
@@ -164,7 +180,7 @@ export function GetCommonPaths(): $CancellablePromise<{ [_ in string]?: string }
*/ */
export function GetCurrentVersion(): $CancellablePromise<{ [_ in string]?: any }> { export function GetCurrentVersion(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1827245900).then(($result: any) => { return $Call.ByID(1827245900).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -173,7 +189,7 @@ export function GetCurrentVersion(): $CancellablePromise<{ [_ in string]?: any }
*/ */
export function GetDiskInfo(): $CancellablePromise<{ [_ in string]?: any }[]> { export function GetDiskInfo(): $CancellablePromise<{ [_ in string]?: any }[]> {
return $Call.ByID(3756377758).then(($result: any) => { return $Call.ByID(3756377758).then(($result: any) => {
return $$createType3($result); return $$createType5($result);
}); });
} }
@@ -182,7 +198,7 @@ export function GetDiskInfo(): $CancellablePromise<{ [_ in string]?: any }[]> {
*/ */
export function GetEnvVars(): $CancellablePromise<{ [_ in string]?: string }> { export function GetEnvVars(): $CancellablePromise<{ [_ in string]?: string }> {
return $Call.ByID(363814436).then(($result: any) => { return $Call.ByID(363814436).then(($result: any) => {
return $$createType4($result); return $$createType6($result);
}); });
} }
@@ -191,7 +207,7 @@ export function GetEnvVars(): $CancellablePromise<{ [_ in string]?: string }> {
*/ */
export function GetFileInfo(path: string): $CancellablePromise<{ [_ in string]?: any }> { export function GetFileInfo(path: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(2071650585, path).then(($result: any) => { return $Call.ByID(2071650585, path).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -204,7 +220,7 @@ export function GetFileServerURL(): $CancellablePromise<string> {
export function GetLocalSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> { export function GetLocalSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(2203542363).then(($result: any) => { return $Call.ByID(2203542363).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -213,7 +229,7 @@ export function GetLocalSystemInfo(): $CancellablePromise<{ [_ in string]?: any
*/ */
export function GetMemoryInfo(): $CancellablePromise<{ [_ in string]?: any }> { export function GetMemoryInfo(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(2096905876).then(($result: any) => { return $Call.ByID(2096905876).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -222,7 +238,7 @@ export function GetMemoryInfo(): $CancellablePromise<{ [_ in string]?: any }> {
*/ */
export function GetRecycleBinEntries(): $CancellablePromise<{ [_ in string]?: any }[]> { export function GetRecycleBinEntries(): $CancellablePromise<{ [_ in string]?: any }[]> {
return $Call.ByID(2312855399).then(($result: any) => { return $Call.ByID(2312855399).then(($result: any) => {
return $$createType3($result); return $$createType5($result);
}); });
} }
@@ -231,7 +247,7 @@ export function GetRecycleBinEntries(): $CancellablePromise<{ [_ in string]?: an
*/ */
export function GetSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> { export function GetSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1347250254).then(($result: any) => { return $Call.ByID(1347250254).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -240,7 +256,7 @@ export function GetSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> {
*/ */
export function GetUpdateConfig(): $CancellablePromise<{ [_ in string]?: any }> { export function GetUpdateConfig(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(680804904).then(($result: any) => { return $Call.ByID(680804904).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -249,16 +265,23 @@ export function GetUpdateConfig(): $CancellablePromise<{ [_ in string]?: any }>
*/ */
export function GetZipFileInfo(zipPath: string, filePath: string): $CancellablePromise<{ [_ in string]?: any }> { export function GetZipFileInfo(zipPath: string, filePath: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(2031617692, zipPath, filePath).then(($result: any) => { return $Call.ByID(2031617692, zipPath, filePath).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
/**
* HandleHotkey 处理全局热键回调:切换 BgmBar 显示/隐藏
*/
export function HandleHotkey(): $CancellablePromise<void> {
return $Call.ByID(420101833);
}
/** /**
* InstallUpdate 安装更新包 * InstallUpdate 安装更新包
*/ */
export function InstallUpdate(installerPath: string, autoRestart: boolean): $CancellablePromise<{ [_ in string]?: any }> { export function InstallUpdate(installerPath: string, autoRestart: boolean): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(2443992793, installerPath, autoRestart).then(($result: any) => { return $Call.ByID(2443992793, installerPath, autoRestart).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -267,7 +290,7 @@ export function InstallUpdate(installerPath: string, autoRestart: boolean): $Can
*/ */
export function InstallUpdateWithHash(installerPath: string, autoRestart: boolean, expectedHash: string, hashType: string): $CancellablePromise<{ [_ in string]?: any }> { export function InstallUpdateWithHash(installerPath: string, autoRestart: boolean, expectedHash: string, hashType: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(3787276601, installerPath, autoRestart, expectedHash, hashType).then(($result: any) => { return $Call.ByID(3787276601, installerPath, autoRestart, expectedHash, hashType).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -276,7 +299,7 @@ export function InstallUpdateWithHash(installerPath: string, autoRestart: boolea
*/ */
export function ListDir(path: string): $CancellablePromise<{ [_ in string]?: any }[]> { export function ListDir(path: string): $CancellablePromise<{ [_ in string]?: any }[]> {
return $Call.ByID(2120475736, path).then(($result: any) => { return $Call.ByID(2120475736, path).then(($result: any) => {
return $$createType3($result); return $$createType5($result);
}); });
} }
@@ -285,13 +308,13 @@ export function ListDir(path: string): $CancellablePromise<{ [_ in string]?: any
*/ */
export function ListZipContents(zipPath: string): $CancellablePromise<{ [_ in string]?: any }[]> { export function ListZipContents(zipPath: string): $CancellablePromise<{ [_ in string]?: any }[]> {
return $Call.ByID(3013109042, zipPath).then(($result: any) => { return $Call.ByID(3013109042, zipPath).then(($result: any) => {
return $$createType3($result); return $$createType5($result);
}); });
} }
export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?: any }[]> { export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?: any }[]> {
return $Call.ByID(454364767).then(($result: any) => { return $Call.ByID(454364767).then(($result: any) => {
return $$createType3($result); return $$createType5($result);
}); });
} }
@@ -311,7 +334,7 @@ export function OssConnect(req: $models.OssConnectRequest): $CancellablePromise<
*/ */
export function OssCreateDir(connID: string, dirPath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function OssCreateDir(connID: string, dirPath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(605668951, connID, dirPath).then(($result: any) => { return $Call.ByID(605668951, connID, dirPath).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -320,7 +343,7 @@ export function OssCreateDir(connID: string, dirPath: string): $CancellablePromi
*/ */
export function OssCreateFile(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function OssCreateFile(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(4148593430, connID, filePath).then(($result: any) => { return $Call.ByID(4148593430, connID, filePath).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -329,7 +352,7 @@ export function OssCreateFile(connID: string, filePath: string): $CancellablePro
*/ */
export function OssDeletePath(connID: string, key: string): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function OssDeletePath(connID: string, key: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(4285234744, connID, key).then(($result: any) => { return $Call.ByID(4285234744, connID, key).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -340,6 +363,13 @@ export function OssDisconnect(connID: string): $CancellablePromise<void> {
return $Call.ByID(3427288622, connID); return $Call.ByID(3427288622, connID);
} }
/**
* OssDownloadSiteForPreview OSS 下载 HTML 及其引用的资源到临时目录
*/
export function OssDownloadSiteForPreview(connID: string, key: string): $CancellablePromise<string> {
return $Call.ByID(1387550222, connID, key);
}
/** /**
* OssDownloadToTemp OSS 下载到临时文件 * OssDownloadToTemp OSS 下载到临时文件
*/ */
@@ -347,12 +377,19 @@ export function OssDownloadToTemp(connID: string, key: string): $CancellableProm
return $Call.ByID(370656471, connID, key); return $Call.ByID(370656471, connID, key);
} }
/**
* OssDownloadToTempCached 带缓存的 OSS 下载(命中缓存直接返回本地路径)
*/
export function OssDownloadToTempCached(connID: string, key: string, fileSize: number, modTime: string): $CancellablePromise<string> {
return $Call.ByID(1312098141, connID, key, fileSize, modTime);
}
/** /**
* OssGetCommonPaths OSS 获取常用路径 * OssGetCommonPaths OSS 获取常用路径
*/ */
export function OssGetCommonPaths(connID: string): $CancellablePromise<{ [_ in string]?: string }> { export function OssGetCommonPaths(connID: string): $CancellablePromise<{ [_ in string]?: string }> {
return $Call.ByID(3525024115, connID).then(($result: any) => { return $Call.ByID(3525024115, connID).then(($result: any) => {
return $$createType4($result); return $$createType6($result);
}); });
} }
@@ -361,7 +398,7 @@ export function OssGetCommonPaths(connID: string): $CancellablePromise<{ [_ in s
*/ */
export function OssGetFileInfo(connID: string, key: string): $CancellablePromise<{ [_ in string]?: any }> { export function OssGetFileInfo(connID: string, key: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(852430614, connID, key).then(($result: any) => { return $Call.ByID(852430614, connID, key).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -377,7 +414,7 @@ export function OssGetSignedURL(connID: string, key: string): $CancellablePromis
*/ */
export function OssListDir(connID: string, prefix: string): $CancellablePromise<{ [_ in string]?: any }[]> { export function OssListDir(connID: string, prefix: string): $CancellablePromise<{ [_ in string]?: any }[]> {
return $Call.ByID(3013212019, connID, prefix).then(($result: any) => { return $Call.ByID(3013212019, connID, prefix).then(($result: any) => {
return $$createType3($result); return $$createType5($result);
}); });
} }
@@ -393,7 +430,7 @@ export function OssReadFile(connID: string, key: string): $CancellablePromise<st
*/ */
export function OssRenamePath(req: $models.OssRenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function OssRenamePath(req: $models.OssRenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(4218061693, req).then(($result: any) => { return $Call.ByID(4218061693, req).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -418,6 +455,13 @@ export function ReadFile(path: string): $CancellablePromise<string> {
return $Call.ByID(1160596971, path); return $Call.ByID(1160596971, path);
} }
/**
* RegisterGlobalHotkey 注册 Ctrl+Shift+B 全局热键(需在窗口创建后调用)
*/
export function RegisterGlobalHotkey(): $CancellablePromise<void> {
return $Call.ByID(2089930789);
}
/** /**
* Reload 重新加载窗口(用于菜单项) * Reload 重新加载窗口(用于菜单项)
*/ */
@@ -430,7 +474,7 @@ export function Reload(): $CancellablePromise<void> {
*/ */
export function RenamePath(req: $models.RenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function RenamePath(req: $models.RenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(1959759948, req).then(($result: any) => { return $Call.ByID(1959759948, req).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -439,7 +483,7 @@ export function RenamePath(req: $models.RenamePathRequest): $CancellablePromise<
*/ */
export function ResolveShortcut(lnkPath: string): $CancellablePromise<{ [_ in string]?: any }> { export function ResolveShortcut(lnkPath: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(4051288361, lnkPath).then(($result: any) => { return $Call.ByID(4051288361, lnkPath).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -455,7 +499,7 @@ export function RestoreFromRecycleBin(recyclePath: string): $CancellablePromise<
*/ */
export function SaveAppConfig(req: $models.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> { export function SaveAppConfig(req: $models.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1942219977, req).then(($result: any) => { return $Call.ByID(1942219977, req).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -468,7 +512,7 @@ export function SaveBase64File(req: $models.SaveBase64FileRequest): $Cancellable
export function SaveConnectionProfile(req: $models.SaveProfileRequest): $CancellablePromise<{ [_ in string]?: any }> { export function SaveConnectionProfile(req: $models.SaveProfileRequest): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(3622685069, req).then(($result: any) => { return $Call.ByID(3622685069, req).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -491,7 +535,7 @@ export function SetMainWindow(w: application$0.WebviewWindow | null): $Cancellab
*/ */
export function SetUpdateConfig(autoCheckEnabled: boolean, checkIntervalMinutes: number, checkURL: string): $CancellablePromise<{ [_ in string]?: any }> { export function SetUpdateConfig(autoCheckEnabled: boolean, checkIntervalMinutes: number, checkURL: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(4271731092, autoCheckEnabled, checkIntervalMinutes, checkURL).then(($result: any) => { return $Call.ByID(4271731092, autoCheckEnabled, checkIntervalMinutes, checkURL).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -514,7 +558,7 @@ export function SftpConnect(req: $models.SftpConnectRequest): $CancellablePromis
*/ */
export function SftpCreateDir(connID: string, dirPath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function SftpCreateDir(connID: string, dirPath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(586600875, connID, dirPath).then(($result: any) => { return $Call.ByID(586600875, connID, dirPath).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -523,7 +567,7 @@ export function SftpCreateDir(connID: string, dirPath: string): $CancellableProm
*/ */
export function SftpCreateFile(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function SftpCreateFile(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(623026146, connID, filePath).then(($result: any) => { return $Call.ByID(623026146, connID, filePath).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -532,7 +576,7 @@ export function SftpCreateFile(connID: string, filePath: string): $CancellablePr
*/ */
export function SftpDeletePath(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function SftpDeletePath(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(1833619836, connID, filePath).then(($result: any) => { return $Call.ByID(1833619836, connID, filePath).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -543,6 +587,13 @@ export function SftpDisconnect(connID: string): $CancellablePromise<void> {
return $Call.ByID(597628874, connID); return $Call.ByID(597628874, connID);
} }
/**
* SftpDownloadSiteForPreview 下载 HTML 及其网站资源到本地临时目录
*/
export function SftpDownloadSiteForPreview(connID: string, remotePath: string): $CancellablePromise<string> {
return $Call.ByID(1591575570, connID, remotePath);
}
/** /**
* SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览) * SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
*/ */
@@ -550,12 +601,19 @@ export function SftpDownloadToTemp(connID: string, remotePath: string): $Cancell
return $Call.ByID(1159267603, connID, remotePath); return $Call.ByID(1159267603, connID, remotePath);
} }
/**
* SftpDownloadToTempCached 带缓存的 SFTP 下载(命中缓存直接返回本地路径)
*/
export function SftpDownloadToTempCached(connID: string, remotePath: string, fileSize: number, modTime: string): $CancellablePromise<string> {
return $Call.ByID(3935472409, connID, remotePath, fileSize, modTime);
}
/** /**
* SftpGetCommonPaths 获取 SFTP 远程主机常用路径 * SftpGetCommonPaths 获取 SFTP 远程主机常用路径
*/ */
export function SftpGetCommonPaths(connID: string): $CancellablePromise<{ [_ in string]?: string }> { export function SftpGetCommonPaths(connID: string): $CancellablePromise<{ [_ in string]?: string }> {
return $Call.ByID(2874386183, connID).then(($result: any) => { return $Call.ByID(2874386183, connID).then(($result: any) => {
return $$createType4($result); return $$createType6($result);
}); });
} }
@@ -564,7 +622,7 @@ export function SftpGetCommonPaths(connID: string): $CancellablePromise<{ [_ in
*/ */
export function SftpGetFileInfo(connID: string, filePath: string): $CancellablePromise<{ [_ in string]?: any }> { export function SftpGetFileInfo(connID: string, filePath: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1959840482, connID, filePath).then(($result: any) => { return $Call.ByID(1959840482, connID, filePath).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -573,7 +631,7 @@ export function SftpGetFileInfo(connID: string, filePath: string): $CancellableP
*/ */
export function SftpGetSystemInfo(connID: string): $CancellablePromise<{ [_ in string]?: any }> { export function SftpGetSystemInfo(connID: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1950143653, connID).then(($result: any) => { return $Call.ByID(1950143653, connID).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -582,7 +640,7 @@ export function SftpGetSystemInfo(connID: string): $CancellablePromise<{ [_ in s
*/ */
export function SftpListDir(connID: string, dirPath: string): $CancellablePromise<{ [_ in string]?: any }[]> { export function SftpListDir(connID: string, dirPath: string): $CancellablePromise<{ [_ in string]?: any }[]> {
return $Call.ByID(2061863855, connID, dirPath).then(($result: any) => { return $Call.ByID(2061863855, connID, dirPath).then(($result: any) => {
return $$createType3($result); return $$createType5($result);
}); });
} }
@@ -598,7 +656,7 @@ export function SftpReadFile(connID: string, filePath: string): $CancellableProm
*/ */
export function SftpRenamePath(req: $models.SftpRenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> { export function SftpRenamePath(req: $models.SftpRenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> {
return $Call.ByID(183173937, req).then(($result: any) => { return $Call.ByID(183173937, req).then(($result: any) => {
return $$createType2($result); return $$createType4($result);
}); });
} }
@@ -621,7 +679,7 @@ export function SftpWriteFile(req: $models.SftpWriteFileRequest): $CancellablePr
*/ */
export function VerifyUpdateFile(filePath: string, expectedHash: string, hashType: string): $CancellablePromise<{ [_ in string]?: any }> { export function VerifyUpdateFile(filePath: string, expectedHash: string, hashType: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(2181909867, filePath, expectedHash, hashType).then(($result: any) => { return $Call.ByID(2181909867, filePath, expectedHash, hashType).then(($result: any) => {
return $$createType0($result); return $$createType2($result);
}); });
} }
@@ -668,8 +726,10 @@ export function WriteFile(req: $models.WriteFileRequest): $CancellablePromise<vo
} }
// Private type creation functions // Private type creation functions
const $$createType0 = $Create.Map($Create.Any, $Create.Any); const $$createType0 = $models.BgmPlaylistItem.createFrom;
const $$createType1 = filesystem$0.FileOperationResult.createFrom; const $$createType1 = $Create.Array($$createType0);
const $$createType2 = $Create.Nullable($$createType1); const $$createType2 = $Create.Map($Create.Any, $Create.Any);
const $$createType3 = $Create.Array($$createType0); const $$createType3 = filesystem$0.FileOperationResult.createFrom;
const $$createType4 = $Create.Map($Create.Any, $Create.Any); const $$createType4 = $Create.Nullable($$createType3);
const $$createType5 = $Create.Array($$createType2);
const $$createType6 = $Create.Map($Create.Any, $Create.Any);

View File

@@ -7,6 +7,7 @@ export {
}; };
export { export {
BgmPlaylistItem,
OssConnectRequest, OssConnectRequest,
OssRenamePathRequest, OssRenamePathRequest,
RenamePathRequest, RenamePathRequest,

View File

@@ -9,6 +9,38 @@ import { Create as $Create } from "@wailsio/runtime";
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import * as api$0 from "./internal/api/models.js"; import * as api$0 from "./internal/api/models.js";
/**
* BgmPlaylistItem 播放列表条目
*/
export class BgmPlaylistItem {
"name": string;
"path": string;
"profile_id": string;
/** Creates a new BgmPlaylistItem instance. */
constructor($$source: Partial<BgmPlaylistItem> = {}) {
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("path" in $$source)) {
this["path"] = "";
}
if (!("profile_id" in $$source)) {
this["profile_id"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new BgmPlaylistItem instance from a string or object.
*/
static createFrom($$source: any = {}): BgmPlaylistItem {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new BgmPlaylistItem($$parsedSource as Partial<BgmPlaylistItem>);
}
}
export class OssConnectRequest { export class OssConnectRequest {
"provider": string; "provider": string;
"access_key": string; "access_key": string;
@@ -183,6 +215,7 @@ export class SaveProfileRequest {
"password": string; "password": string;
"key_path": string; "key_path": string;
"type": string; "type": string;
"provider": string;
"token": string; "token": string;
"access_key": string; "access_key": string;
"secret_key": string; "secret_key": string;
@@ -217,6 +250,9 @@ export class SaveProfileRequest {
if (!("type" in $$source)) { if (!("type" in $$source)) {
this["type"] = ""; this["type"] = "";
} }
if (!("provider" in $$source)) {
this["provider"] = "";
}
if (!("token" in $$source)) { if (!("token" in $$source)) {
this["token"] = ""; this["token"] = "";
} }

View File

@@ -14,7 +14,8 @@ import {
SftpGetSystemInfo, GetLocalSystemInfo, SftpGetSystemInfo, GetLocalSystemInfo,
} from '@bindings/u-desk/app' } from '@bindings/u-desk/app'
export type ConnectionType = 'local' | 'remote' | 'sftp' | 'qiniu' | 'aliyun' export type ConnectionType = 'local' | 'remote' | 'sftp' | 'oss'
export type OssProvider = 'qiniu' | 'aliyun'
export interface ConnectionProfile { export interface ConnectionProfile {
id: string | number id: string | number
@@ -23,6 +24,7 @@ export interface ConnectionProfile {
port: number port: number
token: string token: string
type: ConnectionType type: ConnectionType
provider?: OssProvider
username?: string username?: string
password?: string password?: string
keyPath?: string keyPath?: string
@@ -129,6 +131,7 @@ class ConnectionManagerImpl {
password: profile.password || '', password: profile.password || '',
keyPath: profile.keyPath || '', keyPath: profile.keyPath || '',
type: profile.type, type: profile.type,
provider: profile.provider || '',
token: profile.token || '', token: profile.token || '',
access_key: profile.accessKey || '', access_key: profile.accessKey || '',
secret_key: profile.secretKey || '', secret_key: profile.secretKey || '',
@@ -200,7 +203,7 @@ class ConnectionManagerImpl {
isRemote(): boolean { isRemote(): boolean {
const t = this.activeProfile?.type const t = this.activeProfile?.type
return t === 'remote' || t === 'sftp' || t === 'qiniu' || t === 'aliyun' return t === 'remote' || t === 'sftp' || t === 'oss'
} }
getSystemInfo(profileId: string): SystemInfo | undefined { getSystemInfo(profileId: string): SystemInfo | undefined {
@@ -221,7 +224,7 @@ class ConnectionManagerImpl {
const data = await SftpGetSystemInfo(t.sessionId) const data = await SftpGetSystemInfo(t.sessionId)
if (data) Object.assign(info, snakeToCamel(data)) if (data) Object.assign(info, snakeToCamel(data))
} }
} else if (profile.type === 'qiniu' || profile.type === 'aliyun') { } else if (profile.type === 'oss') {
// OSS 无系统信息可采集 // OSS 无系统信息可采集
info.diskUsage = '-' info.diskUsage = '-'
info.cpuUsage = '-' info.cpuUsage = '-'
@@ -363,8 +366,8 @@ class ConnectionManagerImpl {
return return
} }
// OSS (qiniu / aliyun) // OSS
if (profile.type === 'qiniu' || profile.type === 'aliyun') { if (profile.type === 'oss') {
this.setState('connecting') this.setState('connecting')
try { try {
const t = new OssTransport(profile) const t = new OssTransport(profile)

View File

@@ -30,10 +30,5 @@ export function getFileServerBaseURL(): string {
return _cachedURL || FALLBACK_URL return _cachedURL || FALLBACK_URL
} }
/** 获取带 /localfs 后缀的完整 base */
export function getLocalFsBaseURL(): string {
return `${getFileServerBaseURL()}/localfs`
}
/** 启动时自动初始化 */ /** 启动时自动初始化 */
initFileServerURL().catch(() => {}) initFileServerURL().catch(() => {})

View File

@@ -133,4 +133,8 @@ export class HttpTransport implements FsTransport {
async deletePermanently(_path: string): Promise<void> {} async deletePermanently(_path: string): Promise<void> {}
async emptyRecycleBin(): Promise<void> {} async emptyRecycleBin(): Promise<void> {}
async downloadForPreview(path: string): Promise<string> {
return path
}
} }

View File

@@ -9,8 +9,8 @@ import type { ConnectionProfile } from './connection-manager'
import { import {
OssConnect, OssDisconnect, OssListDir, OssReadFile, OssConnect, OssDisconnect, OssListDir, OssReadFile,
OssWriteFile, OssGetFileInfo, OssCreateDir, OssCreateFile, OssWriteFile, OssGetFileInfo, OssCreateDir, OssCreateFile,
OssDeletePath, OssRenamePath, OssDownloadToTemp, OssGetCommonPaths, OssDeletePath, OssRenamePath, OssDownloadToTemp, OssDownloadSiteForPreview,
OssWriteBase64File, OssGetSignedURL, OssGetCommonPaths, OssWriteBase64File, OssGetSignedURL,
} from '@bindings/u-desk/app' } from '@bindings/u-desk/app'
function transformFile(file: any): FileItem { function transformFile(file: any): FileItem {
@@ -21,13 +21,9 @@ function transformFileList(files: any[]): FileItem[] {
return files.map(transformFile) return files.map(transformFile)
} }
const PREVIEW_CACHE_MAX = 50
export class OssTransport implements FsTransport { export class OssTransport implements FsTransport {
private profile: ConnectionProfile private profile: ConnectionProfile
private connID: string | null = null private connID: string | null = null
private previewCache = new Map<string, string>()
private previewOrder: string[] = []
constructor(profile: ConnectionProfile) { constructor(profile: ConnectionProfile) {
this.profile = profile this.profile = profile
@@ -35,7 +31,7 @@ export class OssTransport implements FsTransport {
async connect(): Promise<string> { async connect(): Promise<string> {
const result = await OssConnect({ const result = await OssConnect({
provider: this.profile.type, provider: this.profile.provider || 'qiniu',
access_key: this.profile.accessKey || '', access_key: this.profile.accessKey || '',
secret_key: this.profile.secretKey || '', secret_key: this.profile.secretKey || '',
endpoint: this.profile.endpoint || '', endpoint: this.profile.endpoint || '',
@@ -53,8 +49,6 @@ export class OssTransport implements FsTransport {
} }
this.connID = null this.connID = null
} }
this.previewCache.clear()
this.previewOrder = []
} }
private requireConn(): string { private requireConn(): string {
@@ -169,22 +163,14 @@ export class OssTransport implements FsTransport {
// ====== 预览辅助 ====== // ====== 预览辅助 ======
/** 下载远程文件到本地临时目录用于预览(缓存由 Go 后端管理) */
async downloadForPreview(remotePath: string): Promise<string> { async downloadForPreview(remotePath: string): Promise<string> {
if (this.previewCache.has(remotePath)) { return OssDownloadToTemp(this.requireConn(), remotePath)
this.previewOrder = this.previewOrder.filter(p => p !== remotePath) }
this.previewOrder.push(remotePath)
return this.previewCache.get(remotePath)!
}
const localPath = await OssDownloadToTemp(this.requireConn(), remotePath)
if (this.previewOrder.length >= PREVIEW_CACHE_MAX) { /** 下载 HTML 及其引用的资源到临时目录用于网站预览 */
const oldest = this.previewOrder.shift()! async downloadSiteForPreview(remotePath: string): Promise<string> {
this.previewCache.delete(oldest) return OssDownloadSiteForPreview(this.requireConn(), remotePath)
}
this.previewCache.set(remotePath, localPath)
this.previewOrder.push(remotePath)
return localPath
} }
/** 获取预签名 URL用于直接预览 */ /** 获取预签名 URL用于直接预览 */

View File

@@ -9,8 +9,8 @@ import type { ConnectionProfile } from './connection-manager'
import { import {
SftpConnect, SftpDisconnect, SftpListDir, SftpReadFile, SftpConnect, SftpDisconnect, SftpListDir, SftpReadFile,
SftpWriteFile, SftpGetFileInfo, SftpCreateDir, SftpCreateFile, SftpWriteFile, SftpGetFileInfo, SftpCreateDir, SftpCreateFile,
SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, SftpGetCommonPaths, SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, SftpDownloadSiteForPreview,
SftpWriteBase64File, SftpGetCommonPaths, SftpWriteBase64File,
} from '@bindings/u-desk/app' } from '@bindings/u-desk/app'
function transformFile(file: any): FileItem { function transformFile(file: any): FileItem {
@@ -21,13 +21,9 @@ function transformFileList(files: any[]): FileItem[] {
return files.map(transformFile) return files.map(transformFile)
} }
const PREVIEW_CACHE_MAX = 50
export class SftpTransport implements FsTransport { export class SftpTransport implements FsTransport {
private profile: ConnectionProfile private profile: ConnectionProfile
private connID: string | null = null private connID: string | null = null
private previewCache = new Map<string, string>() // remotePath -> localTempPath (LRU)
private previewOrder: string[] = [] // LRU 排序键列表
constructor(profile: ConnectionProfile) { constructor(profile: ConnectionProfile) {
this.profile = profile this.profile = profile
@@ -55,8 +51,6 @@ export class SftpTransport implements FsTransport {
} }
this.connID = null this.connID = null
} }
this.previewCache.clear()
this.previewOrder = []
} }
private requireConn(): string { private requireConn(): string {
@@ -176,24 +170,13 @@ export class SftpTransport implements FsTransport {
// ====== 预览辅助 ====== // ====== 预览辅助 ======
/** 下载远程文件到本地临时目录用于预览(带 LRU 缓存,上限 50 */ /** 下载远程文件到本地临时目录用于预览(缓存由 Go 后端管理 */
async downloadForPreview(remotePath: string): Promise<string> { async downloadForPreview(remotePath: string): Promise<string> {
// 命中:移到队尾(最近使用) return SftpDownloadToTemp(this.requireConn(), remotePath)
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)
// 淘汰最旧条目 /** 下载 HTML 及其网站资源到临时目录用于预览 */
if (this.previewOrder.length >= PREVIEW_CACHE_MAX) { async downloadSiteForPreview(remotePath: string): Promise<string> {
const oldest = this.previewOrder.shift()! return SftpDownloadSiteForPreview(this.requireConn(), remotePath)
this.previewCache.delete(oldest)
}
this.previewCache.set(remotePath, localPath)
this.previewOrder.push(remotePath)
return localPath
} }
} }

View File

@@ -68,4 +68,12 @@ export interface FsTransport {
restoreFromRecycleBin(path: string): Promise<void> restoreFromRecycleBin(path: string): Promise<void>
deletePermanently(path: string): Promise<void> deletePermanently(path: string): Promise<void>
emptyRecycleBin(): Promise<void> emptyRecycleBin(): Promise<void>
// 预览辅助
/** 下载远程文件到本地临时目录,本地/HTTP 直接返回原路径 */
downloadForPreview(path: string): Promise<string>
/** 下载 HTML 及其引用的资源到临时目录OSS 实现) */
downloadSiteForPreview?(path: string): Promise<string>
/** 获取预签名 URL仅 OSS 实现,其他返回空串) */
getSignedUrl?(path: string): Promise<string>
} }

View File

@@ -116,4 +116,8 @@ export class WailsTransport implements FsTransport {
async emptyRecycleBin(): Promise<void> { async emptyRecycleBin(): Promise<void> {
await EmptyRecycleBin() await EmptyRecycleBin()
} }
async downloadForPreview(path: string): Promise<string> {
return path
}
} }

View File

@@ -13,7 +13,7 @@
<!-- OSS 厂商表单行仅在选中云OSS时显示 --> <!-- OSS 厂商表单行仅在选中云OSS时显示 -->
<div v-if="category === 'oss'" style="display: flex; align-items: center; gap: 8px"> <div v-if="category === 'oss'" 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-radio-group v-model="form.type" type="button" size="small"> <a-radio-group v-model="form.provider" type="button" size="small">
<a-radio value="qiniu">七牛云</a-radio> <a-radio value="qiniu">七牛云</a-radio>
<a-radio value="aliyun">阿里云</a-radio> <a-radio value="aliyun">阿里云</a-radio>
</a-radio-group> </a-radio-group>
@@ -33,7 +33,7 @@
</div> </div>
<!-- OSS 认证字段 --> <!-- OSS 认证字段 -->
<template v-if="form.type === 'qiniu' || form.type === 'aliyun'"> <template v-if="form.type === 'oss'">
<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">AK</label> <label style="font-size: 13px; width: 36px; flex-shrink: 0">AK</label>
<a-input v-model="form.accessKey" placeholder="AccessKey" style="flex: 1" /> <a-input v-model="form.accessKey" placeholder="AccessKey" style="flex: 1" />
@@ -93,7 +93,7 @@ import { Message } from '@arco-design/web-vue'
import { Dialogs } from '@wailsio/runtime' import { Dialogs } from '@wailsio/runtime'
import { GetEnvVars } from '@bindings/u-desk/app' 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' import type { ConnectionType, OssProvider } 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 }>()
@@ -113,6 +113,7 @@ const form = reactive({
port: 22, port: 22,
token: '', token: '',
type: 'sftp' as ConnectionType, type: 'sftp' as ConnectionType,
provider: 'qiniu' as OssProvider,
username: 'root', username: 'root',
password: '', password: '',
keyPath: '', keyPath: '',
@@ -124,12 +125,9 @@ const form = reactive({
}) })
const category = computed({ const category = computed({
get: () => { get: () => form.type,
if (form.type === 'qiniu' || form.type === 'aliyun') return 'oss'
return form.type
},
set: (v: string) => { set: (v: string) => {
if (v === 'oss') form.type = 'qiniu' if (v === 'oss') form.type = 'oss'
else form.type = v as ConnectionType else form.type = v as ConnectionType
}, },
}) })
@@ -140,6 +138,7 @@ watch(() => props.visible, (val) => {
Object.assign(form, { Object.assign(form, {
name: '', host: '', port: 22, token: '', name: '', host: '', port: 22, token: '',
type: 'sftp' as ConnectionType, type: 'sftp' as ConnectionType,
provider: 'qiniu' as OssProvider,
username: 'root', password: '', keyPath: '', username: 'root', password: '', keyPath: '',
accessKey: '', secretKey: '', bucket: '', region: '', endpoint: '', accessKey: '', secretKey: '', bucket: '', region: '', endpoint: '',
}) })
@@ -153,7 +152,7 @@ watch(() => form.type, (t) => {
async function handleOk(): Promise<boolean> { async function handleOk(): Promise<boolean> {
if (!form.name?.trim()) { Message.warning('请输入名称'); return false } if (!form.name?.trim()) { Message.warning('请输入名称'); return false }
const isOss = form.type === 'qiniu' || form.type === 'aliyun' const isOss = form.type === 'oss'
if (isOss) { if (isOss) {
if (!form.accessKey?.trim()) { Message.warning('请输入 AccessKey'); return false } if (!form.accessKey?.trim()) { Message.warning('请输入 AccessKey'); return false }
if (!form.secretKey?.trim()) { Message.warning('请输入 SecretKey'); return false } if (!form.secretKey?.trim()) { Message.warning('请输入 SecretKey'); return false }
@@ -200,6 +199,7 @@ function editProfile(id: string) {
port: profile.port, port: profile.port,
token: profile.token || '', token: profile.token || '',
type: profile.type || 'remote', type: profile.type || 'remote',
provider: (profile.provider as OssProvider) || 'qiniu',
username: profile.username || 'root', username: profile.username || 'root',
password: profile.password || '', password: profile.password || '',
keyPath: profile.keyPath || '', keyPath: profile.keyPath || '',

View File

@@ -4,10 +4,46 @@
<icon-cloud /> <icon-cloud />
</div> </div>
<!-- 有远程配置完整标签 + 下拉菜单 --> <!-- 有远程配置完整标签 + 悬停目录列表 + 点击连接菜单 -->
<div v-else class="connection-indicator" @click.stop="showMenu = !showMenu"> <div v-else class="connection-indicator"
@click.stop="toggleMenu"
@mouseenter="onDirHover"
@mouseleave="onDirLeave">
<span class="label">{{ label }}</span> <span class="label">{{ label }}</span>
<!-- 悬停弹出根目录子目录列表 -->
<Transition name="dropdown-fade">
<div v-if="showDirDropdown"
class="dir-dropdown"
@mouseenter="onDirMenuEnter"
@mouseleave="onDirMenuLeave"
@click.stop>
<div v-if="dirLoading" class="dropdown-loading">
<a-spin :size="16" />
<span>加载中...</span>
</div>
<div v-else-if="dirError" class="dropdown-error">
<icon-exclamation-circle />
<span>{{ dirError }}</span>
</div>
<div v-else-if="!dirChildren.length" class="dropdown-empty">
<icon-folder />
<span>空文件夹</span>
</div>
<template v-else>
<DropdownItem
v-for="child in dirChildren"
:key="child.path"
:item="child"
:level="1"
@navigate="onDirNavigate"
@openFile="onDirOpenFile"
/>
</template>
</div>
</Transition>
<!-- 点击弹出连接切换菜单 -->
<div v-if="showMenu" class="menu" @click.stop> <div v-if="showMenu" class="menu" @click.stop>
<div class="menu-header">远程连接</div> <div class="menu-header">远程连接</div>
<div <div
@@ -39,58 +75,176 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, shallowRef, onMounted, onUnmounted } from 'vue' import { ref, computed, shallowRef, onMounted, onUnmounted, provide, watch } from 'vue'
import { IconCloud } from '@arco-design/web-vue/es/icon' import { IconCloud, IconFolder, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { connectionManager } from '@/api/connection-manager' import { connectionManager } from '@/api/connection-manager'
import { listDir } from '@/api/system'
import { sortFileList } from '@/utils/fileUtils'
import { useTimeout } from '@/composables/useTimeout'
import DropdownItem from './DropdownItem.vue'
const emit = defineEmits<{ (e: 'add'): void; (e: 'select', id: string): void; (e: 'edit', id: string): void }>() const props = defineProps<{
filePath?: string
}>()
const emit = defineEmits<{
(e: 'add'): void
(e: 'select', id: string): void
(e: 'edit', id: string): void
(e: 'navigate', path: string): void
(e: 'openFile', path: string): void
}>()
const { setTimeout: delay, clearTimeout: clearDelay } = useTimeout()
// === 连接菜单(原有逻辑) ===
const showMenu = ref(false) const showMenu = ref(false)
const moreOpenId = ref<string | null>(null) 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 ?? '')
// 是否有远程/SFTP profile决定显示模式
const hasRemote = computed(() => profiles.value.some(p => p.type !== 'local')) const hasRemote = computed(() => profiles.value.some(p => p.type !== 'local'))
// 防抖:避免 connecting→connected 快速切换导致闪烁
const displayState = ref(connectionManager.state)
let _stateTimer: ReturnType<typeof setTimeout> | null = null
const state = computed(() => displayState.value)
const label = computed(() => { const label = computed(() => {
const p = profiles.value.find(p => p.id === activeId.value) const p = profiles.value.find(p => p.id === activeId.value)
if (!p || p.type === 'local') return '本地' if (!p || p.type === 'local') return '本地'
return p.name return p.name
}) })
// 监听连接变化,主动触发更新(带防抖) connectionManager.onStateChange(() => {
connectionManager.onStateChange((newState) => {
profiles.value = connectionManager.profiles profiles.value = connectionManager.profiles
activeId.value = connectionManager.activeProfile?.id ?? '' activeId.value = connectionManager.activeProfile?.id ?? ''
if (_stateTimer) clearTimeout(_stateTimer)
if (newState === 'connecting') {
_stateTimer = setTimeout(() => { displayState.value = newState }, 300)
} else {
displayState.value = newState
}
}) })
// 点击外部关闭菜单 // === 悬停目录列表(新增) ===
const openMenus = ref<Map<number, string>>(new Map())
const closeMenuFn = (level: number) => {
const newMap = new Map(openMenus.value)
newMap.delete(level)
openMenus.value = newMap
}
const closeAllMenusFn = () => {
openMenus.value = new Map()
}
provide('openMenus', openMenus)
provide('closeMenu', closeMenuFn)
provide('closeAllMenus', closeAllMenusFn)
const showDirDropdown = ref(false)
const dirLoading = ref(false)
const dirError = ref('')
const dirChildren = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
const dirLastLoadedPath = ref('')
const dirHoverTimer = ref<NodeJS.Timeout | null>(null)
const dirCloseTimer = ref<NodeJS.Timeout | null>(null)
const rootPath = computed(() => {
const path = props.filePath?.replace(/\\/g, '/') || ''
if (/^[A-Za-z]:/.test(path)) return path.substring(0, 2) + '/'
return '/'
})
const loadRootChildren = async () => {
const path = rootPath.value
if (path === dirLastLoadedPath.value) return
dirLoading.value = true
dirError.value = ''
try {
const files = await listDir(path)
dirLastLoadedPath.value = path
dirChildren.value = sortFileList(files.map(f => ({
name: f.name,
path: f.path,
isDir: f.isDir
})))
} catch (err) {
console.error('[ConnectionIndicator] 加载根目录失败:', err)
dirError.value = '加载失败'
} finally {
dirLoading.value = false
}
}
const onDirHover = () => {
if (dirHoverTimer.value) clearDelay(dirHoverTimer.value)
if (dirCloseTimer.value) clearDelay(dirCloseTimer.value)
dirHoverTimer.value = delay(() => {
showDirDropdown.value = true
showMenu.value = false
closeAllMenusFn()
loadRootChildren()
}, 200)
}
const onDirLeave = () => {
if (dirHoverTimer.value) clearDelay(dirHoverTimer.value)
dirCloseTimer.value = delay(() => {
showDirDropdown.value = false
closeAllMenusFn()
}, 100)
}
const onDirMenuEnter = () => {
if (dirHoverTimer.value) clearDelay(dirHoverTimer.value)
if (dirCloseTimer.value) clearDelay(dirCloseTimer.value)
}
const onDirMenuLeave = () => {
if (dirHoverTimer.value) clearDelay(dirHoverTimer.value)
dirCloseTimer.value = delay(() => {
showDirDropdown.value = false
closeAllMenusFn()
}, 100)
}
const onDirNavigate = (path: string) => {
showDirDropdown.value = false
closeAllMenusFn()
emit('navigate', path)
}
const onDirOpenFile = (path: string) => {
showDirDropdown.value = false
closeAllMenusFn()
emit('openFile', path)
}
// === 点击切换菜单 ===
const toggleMenu = () => {
showMenu.value = !showMenu.value
if (showMenu.value) {
showDirDropdown.value = false
closeAllMenusFn()
}
}
// === 点击外部关闭 ===
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
const el = e.target as HTMLElement const el = e.target as HTMLElement
if (!el.closest('.connection-indicator')) { if (!el.closest('.connection-indicator')) {
showMenu.value = false showMenu.value = false
moreOpenId.value = null moreOpenId.value = null
showDirDropdown.value = false
closeAllMenusFn()
} }
} }
onMounted(() => document.addEventListener('click', handleClickOutside)) onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
if (_stateTimer) clearTimeout(_stateTimer)
}) })
// === 原有方法 ===
async function handleSelect(p: { id: string }) { async function handleSelect(p: { id: string }) {
showMenu.value = false showMenu.value = false
try { try {
@@ -120,9 +274,17 @@ function handleDelete(p: { id: string; name: string }) {
function dotClass(p: { type: string }): string { function dotClass(p: { type: string }): string {
if (p.type === 'sftp') return 'sftp' if (p.type === 'sftp') return 'sftp'
if (p.type === 'remote') return 'remote' if (p.type === 'remote') return 'remote'
if (p.type === 'qiniu' || p.type === 'aliyun') return 'oss' if (p.type === 'oss') return 'oss'
return 'local' return 'local'
} }
// 路径变化时重置目录列表状态
watch(() => props.filePath, () => {
showDirDropdown.value = false
dirChildren.value = []
dirLastLoadedPath.value = ''
openMenus.value = new Map()
})
</script> </script>
<style scoped> <style scoped>
@@ -280,6 +442,65 @@ function dotClass(p: { type: string }): string {
} }
.add-btn:hover { background: var(--color-primary-light-1); } .add-btn:hover { background: var(--color-primary-light-1); }
/* 悬停目录列表下拉 */
.dir-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 200px;
max-width: 280px;
max-height: 320px;
overflow-y: auto;
background: var(--color-bg-popup);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-small);
box-shadow: var(--shadow2-dropdown);
z-index: 1000;
padding: 4px;
}
.dropdown-loading,
.dropdown-error,
.dropdown-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
color: var(--color-text-3);
font-size: 13px;
}
/* 动画 */
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
transition: opacity 0.15s, transform 0.15s;
}
.dropdown-fade-enter-from,
.dropdown-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
/* 滚动条 */
.dir-dropdown::-webkit-scrollbar {
width: 6px;
}
.dir-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.dir-dropdown::-webkit-scrollbar-thumb {
background: var(--color-fill-3);
border-radius: 3px;
}
.dir-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--color-fill-4);
}
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.4; } 50% { opacity: 0.4; }

View File

@@ -0,0 +1,600 @@
<template>
<div>
<!-- 歌词面板 -->
<div v-if="src && lyrics.length && lyricsOpen" class="bgm-lyrics" ref="lyricsWrapRef">
<div
v-for="(line, i) in lyrics" :key="i"
class="bgm-lyric-line"
:class="{ active: i === currentLyricIdx }"
@click="seekToLyric(line.time)"
>{{ line.text || '...' }}</div>
</div>
<div class="bgm-bar" ref="bgmBarEl">
<span class="bgm-icon">📻</span>
<template v-if="src">
<button class="bgm-btn" @click="playPrev" title="上一首"></button>
<button class="bgm-btn" @click="togglePlay">{{ playing ? '' : '' }}</button>
<button class="bgm-btn" @click="playNext" title="下一首"></button>
<span class="bgm-time">{{ currentFmt }} / {{ durationFmt }}</span>
<div class="bgm-progress" @click="seek">
<div class="bgm-progress-filled" :style="{ width: progress + '%' }"></div>
</div>
<span class="bgm-title">{{ title }}</span>
<button class="bgm-btn" @click="cyclePlayMode" :title="playModeLabel">{{ playModeIcon }}</button>
<button v-if="lyrics.length" class="bgm-btn bgm-btn-text" @click="lyricsOpen = !lyricsOpen"
:title="lyricsOpen ? '收起歌词' : '展开歌词'" :style="{ color: lyricsOpen ? 'rgb(var(--primary-6))' : undefined }"></button>
</template>
<template v-else>
<button class="bgm-btn" disabled></button>
<button class="bgm-btn" disabled></button>
<button class="bgm-btn" disabled></button>
<span class="bgm-time">0:00 / 0:00</span>
<div class="bgm-progress">
<div class="bgm-progress-filled" style="width:0%"></div>
</div>
<span class="bgm-title bgm-title-idle">暂无播放音乐</span>
<button class="bgm-btn" disabled title="播放模式">🔁</button>
</template>
<button class="bgm-btn" @click="playlistOpen = !playlistOpen" title="播放列表">🎵</button>
<button class="bgm-btn bgm-btn-close" @click="emit('stop')" title="关闭 BGM"></button>
<div v-if="playlistOpen" class="bgm-playlist">
<div class="bgm-playlist-header">播放列表</div>
<div v-for="(track, idx) in playlist" :key="track.path"
class="bgm-playlist-item"
:class="{ active: track.path === currentPath || (!currentPath && track.path === lastPlayedPath) }"
@click="playTrack(track)"
>
<span class="bgm-pl-name">{{ track.name }}</span>
<span class="bgm-pl-remove" @click.stop="removeTrack(idx)"></span>
</div>
<div v-if="playlist.length === 0" class="bgm-playlist-empty">暂无记录</div>
</div>
</div>
<audio
ref="audioRef"
:src="src"
style="display:none"
@ended="onEnded"
@timeupdate="onTimeUpdate"
@loadedmetadata="onTimeUpdate"
@play="playing = true"
@pause="playing = false"
@error="onAudioError"
></audio>
</div>
</template>
<script lang="ts">
export interface BgmTrack { name: string; path: string; url: string; profileId?: string }
</script>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import type { LrcLine } from '@/utils/lrcParser'
import { BgmGetPlaylist, BgmSavePlaylist } from '@bindings/u-desk/app'
import { connectionManager } from '@/api/connection-manager'
const props = defineProps<{
src: string
currentPath: string
title: string
lyrics: LrcLine[]
}>()
const emit = defineEmits<{
(e: 'stop'): void
(e: 'switch', track: BgmTrack): void
}>()
const audioRef = ref<HTMLAudioElement | null>(null)
const bgmBarEl = ref<HTMLElement | null>(null)
const pendingTimer = ref<ReturnType<typeof setTimeout>>()
const playing = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const playlistOpen = ref(false)
const lyricsOpen = ref(false)
const currentLyricIdx = ref(-1)
const lyricsWrapRef = ref<HTMLElement | null>(null)
const playingPath = ref('')
// 播放模式
type PlayMode = 'single' | 'sequential' | 'random' | 'loop'
const PLAY_MODE_ICONS: Record<PlayMode, string> = { single: '🔂', sequential: '⏩', random: '🔀', loop: '🔁' }
const PLAY_MODE_LABELS: Record<PlayMode, string> = { single: '单曲播放', sequential: '顺序播放', random: '随机播放', loop: '循环播放' }
const PLAY_MODE_ORDER: PlayMode[] = ['single', 'sequential', 'random', 'loop']
const VALID_MODES = new Set<PlayMode>(PLAY_MODE_ORDER)
const _initPlayMode = (): PlayMode => {
const v = localStorage.getItem('desk:bgmPlayMode')
return v && VALID_MODES.has(v as PlayMode) ? (v as PlayMode) : 'loop'
}
const playMode = ref<PlayMode>(_initPlayMode())
const playModeIcon = computed(() => PLAY_MODE_ICONS[playMode.value])
const playModeLabel = computed(() => PLAY_MODE_LABELS[playMode.value])
watch(playMode, (v) => localStorage.setItem('desk:bgmPlayMode', v)) // 播放模式保留 localStorage轻量偏好
const cyclePlayMode = () => {
const idx = PLAY_MODE_ORDER.indexOf(playMode.value)
playMode.value = PLAY_MODE_ORDER[(idx + 1) % PLAY_MODE_ORDER.length]
}
// 播放列表(持久化到 SQLiteurl 运行时生成)
const PLAYLIST_MAX = 20
const NEXT_TRACK_DELAY = 1500
const playlist = ref<BgmTrack[]>([])
type PlaylistEntry = Pick<BgmTrack, 'name' | 'path'>
const savePlaylist = async () => {
const entries = playlist.value.map(t => ({ name: t.name, path: t.path, profile_id: t.profileId || '' }))
try { await BgmSavePlaylist(entries) } catch (e) { console.debug('[BgmBar] 保存播放列表失败:', e) }
}
// 从 SQLite 恢复播放列表(可选 resolver 解析 URL无 resolver 则 url 留空延迟解析)
const restorePlaylist = async (resolver?: (path: string) => Promise<string | undefined>) => {
try {
const entries = await BgmGetPlaylist()
if (!entries?.length) return
if (resolver) {
const results = await Promise.all(entries.map(e => resolver(e.path)))
playlist.value = entries
.map((e, i) => ({ name: e.name, path: e.path, url: results[i] || '' }))
.filter((_, i) => results[i])
} else {
playlist.value = entries.map(e => ({ name: e.name, path: e.path, url: '', profileId: (e as any).profile_id || '' }))
}
} catch (e) { console.debug('[BgmBar] 恢复播放列表失败:', e) }
}
// 启动时自动恢复url 延迟解析),并选中上次播放的曲目
const lastPlayedPath = ref('')
const _restoreLastPlayed = () => {
const saved = localStorage.getItem('desk:bgmLastPlayed')
if (!saved) return
const track = playlist.value.find(t => t.path === saved)
if (!track) return
lastPlayedPath.value = saved
playingPath.value = saved
emit('switch', track)
}
restorePlaylist().then(_restoreLastPlayed)
const addToPlaylist = (name: string, path: string, url: string) => {
const profileId = connectionManager.activeProfile?.id
playlist.value = playlist.value.filter(t => t.path !== path)
playlist.value.unshift({ name, path, url, profileId })
if (playlist.value.length > PLAYLIST_MAX) playlist.value.length = PLAYLIST_MAX
playingPath.value = path
savePlaylist()
}
const addBatch = (tracks: BgmTrack[]) => {
const profileId = connectionManager.activeProfile?.id
const existPaths = new Set(playlist.value.map(t => t.path))
for (const t of tracks) {
if (existPaths.has(t.path)) continue
if (!t.profileId) t.profileId = profileId
playlist.value.unshift(t)
existPaths.add(t.path)
}
if (playlist.value.length > PLAYLIST_MAX) playlist.value.length = PLAYLIST_MAX
savePlaylist()
}
const removeTrack = (idx: number) => {
playlist.value.splice(idx, 1)
savePlaylist()
}
// 时间格式化
const formatTime = (s: number): string => {
if (!s || !isFinite(s)) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}
const currentFmt = computed(() => formatTime(currentTime.value))
const durationFmt = computed(() => formatTime(duration.value))
const progress = computed(() => duration.value ? Math.min((currentTime.value / duration.value) * 100, 100) : 0)
// 歌词同步
const syncLyrics = (t: number) => {
if (!props.lyrics.length) { currentLyricIdx.value = -1; return }
const lines = props.lyrics
let idx = -1
for (let i = lines.length - 1; i >= 0; i--) {
if (t >= lines[i].time) { idx = i; break }
}
if (idx !== currentLyricIdx.value) {
currentLyricIdx.value = idx
// 自动滚动
if (idx >= 0 && lyricsWrapRef.value) {
const el = lyricsWrapRef.value.children[idx] as HTMLElement | undefined
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
}
const onTimeUpdate = () => {
if (audioRef.value) {
currentTime.value = audioRef.value.currentTime
duration.value = audioRef.value.duration || 0
syncLyrics(currentTime.value)
}
}
const seekToLyric = (time: number) => {
if (audioRef.value) audioRef.value.currentTime = time
}
const togglePlay = () => {
if (!audioRef.value) return
if (audioRef.value.paused) {
audioRef.value.play().catch(() => {})
} else {
audioRef.value.pause()
}
}
const seek = (e: MouseEvent) => {
if (!audioRef.value || !duration.value) return
const bar = e.currentTarget as HTMLElement
const rect = bar.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
audioRef.value.currentTime = ratio * duration.value
}
const playTrack = (track: BgmTrack) => {
playlistOpen.value = false
lastPlayedPath.value = ''
playingPath.value = track.path
localStorage.setItem('desk:bgmLastPlayed', track.path)
emit('switch', track)
}
const curIdx = () => {
if (playingPath.value) {
const idx = playlist.value.findIndex(t => t.path === playingPath.value)
if (idx >= 0) return idx
}
if (props.currentPath) {
const idx = playlist.value.findIndex(t => t.path === props.currentPath)
if (idx >= 0) return idx
}
return playlist.value.findIndex(t => t.url === props.src)
}
const getNextIdx = (): number => {
const idx = curIdx()
if (idx === -1) return 0
if (playMode.value === 'random') {
if (playlist.value.length <= 1) return 0
let next = idx
while (next === idx) next = Math.floor(Math.random() * playlist.value.length)
return next
}
// single/sequential/loop: 顺序前进,区别在 onEnded 处理
return (idx + 1) % playlist.value.length
}
const getPrevIdx = (): number => {
const idx = curIdx()
if (idx === -1) return 0
if (playMode.value === 'random') {
if (playlist.value.length <= 1) return 0
let prev = idx
while (prev === idx) prev = Math.floor(Math.random() * playlist.value.length)
return prev
}
return (idx - 1 + playlist.value.length) % playlist.value.length
}
const playPrev = () => {
if (!playlist.value.length) return
const idx = getPrevIdx()
playTrack(playlist.value[idx])
}
const playNext = () => {
if (!playlist.value.length) return
const idx = getNextIdx()
playTrack(playlist.value[idx])
}
const onEnded = () => {
const mode = playMode.value
if (mode === 'single') {
emit('stop')
return
}
// 只有一首歌时loop 直接重播,其他模式停止
if (playlist.value.length <= 1) {
if (mode === 'loop') {
nextTick(() => {
if (audioRef.value) { audioRef.value.currentTime = 0; audioRef.value.play().catch(() => {}) }
})
} else {
emit('stop')
}
return
}
const idx = curIdx()
// sequential: 到末尾停止
if (mode === 'sequential' && idx === playlist.value.length - 1) {
emit('stop')
return
}
const nextIdx = getNextIdx()
clearTimeout(pendingTimer.value)
pendingTimer.value = setTimeout(() => playTrack(playlist.value[nextIdx]), NEXT_TRACK_DELAY)
}
// 播放失败自动跳到下一首(不删除,下次重试下载)
const _failedUrls = new Set<string>()
const onAudioError = () => {
if (!props.src) return
_failedUrls.add(props.src)
console.warn('[BgmBar] 播放失败,跳过:', props.src)
const len = playlist.value.length
if (len <= 1) { emit('stop'); return }
// 最多尝试 len 次,避免无限循环
for (let attempt = 0; attempt < len; attempt++) {
const nextIdx = getNextIdx()
const next = playlist.value[nextIdx]
if (next && !_failedUrls.has(next.url)) {
clearTimeout(pendingTimer.value)
pendingTimer.value = setTimeout(() => playTrack(next), NEXT_TRACK_DELAY)
return
}
}
emit('stop')
}
watch(() => props.src, (url) => {
console.debug('[BgmBar] src 变化:', url, 'title:', props.title)
currentLyricIdx.value = -1
_failedUrls.clear()
if (!url) { playingPath.value = ''; return }
if (playingPath.value) {
const track = playlist.value.find(t => t.path === playingPath.value)
if (track) track.url = url
}
})
function playFrom(currentTime?: number) {
console.debug('[BgmBar] playFrom, src:', props.src, 'time:', currentTime)
nextTick(() => {
if (audioRef.value) {
if (currentTime !== undefined) audioRef.value.currentTime = currentTime
audioRef.value.play().catch(() => {})
}
})
}
function getAudioElement() {
return audioRef.value
}
onUnmounted(() => {
clearTimeout(pendingTimer.value)
if (audioRef.value) {
audioRef.value.pause()
audioRef.value.src = ''
}
document.removeEventListener('click', handleClickOutside)
})
const handleClickOutside = (e: MouseEvent) => {
if (playlistOpen.value && bgmBarEl.value && !bgmBarEl.value.contains(e.target as Node)) {
playlistOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
// 防止浏览器恢复媒体会话导致自动播放
if (audioRef.value) audioRef.value.pause()
})
defineExpose({ playFrom, getAudioElement, addToPlaylist, addBatch, restorePlaylist })
</script>
<style scoped>
.bgm-lyrics {
max-height: 180px;
overflow-y: auto;
padding: 10px 16px;
text-align: center;
background: var(--color-bg-3);
border-top: 1px solid var(--color-border-2);
mask-image: linear-gradient(to bottom, transparent 0%, black 12%, black 88%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 12%, black 88%, transparent 100%);
}
.bgm-lyric-line {
padding: 5px 8px;
font-size: 13px;
color: var(--color-text-3);
line-height: 1.8;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s ease;
}
.bgm-lyric-line:hover {
background: var(--color-fill-2);
}
.bgm-lyric-line.active {
color: rgb(var(--primary-6));
font-size: 15px;
font-weight: 600;
}
.bgm-bar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
background: var(--color-bg-3);
border-top: 1px solid var(--color-border-2);
z-index: 10;
position: relative;
}
.bgm-icon {
font-size: 14px;
flex-shrink: 0;
}
.bgm-btn {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
padding: 2px 4px;
color: var(--color-text-2);
line-height: 1;
flex-shrink: 0;
}
.bgm-btn:hover {
color: var(--color-text-1);
}
.bgm-btn-text {
font-size: 11px;
font-weight: 600;
font-family: system-ui, sans-serif;
}
.bgm-btn-close:hover {
color: var(--color-danger-6);
}
.bgm-time {
font-size: 11px;
color: var(--color-text-3);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
min-width: 72px;
}
.bgm-progress {
flex: 1;
height: 4px;
background: var(--color-fill-3);
border-radius: 2px;
cursor: pointer;
min-width: 80px;
transition: height 0.15s;
}
.bgm-progress:hover {
height: 6px;
}
.bgm-progress-filled {
height: 100%;
background: rgb(var(--primary-6));
border-radius: 2px;
transition: width 0.1s linear;
}
.bgm-title {
font-size: 12px;
color: var(--color-text-2);
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
}
.bgm-title-idle {
font-style: italic;
opacity: 0.5;
}
.bgm-playlist {
position: absolute;
bottom: 100%;
right: 0;
width: 280px;
max-height: 300px;
background: var(--color-bg-popup);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.12);
z-index: 20;
overflow-y: auto;
}
.bgm-playlist-header {
padding: 6px 10px;
font-size: 12px;
font-weight: 500;
color: var(--color-text-2);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
background: var(--color-bg-popup);
}
.bgm-playlist-item {
display: flex;
align-items: center;
padding: 5px 10px;
cursor: pointer;
font-size: 12px;
color: var(--color-text-1);
transition: background 0.12s;
}
.bgm-playlist-item:hover {
background: var(--color-fill-2);
}
.bgm-playlist-item.active {
color: rgb(var(--primary-6));
background: var(--color-primary-light-1);
}
.bgm-pl-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bgm-pl-remove {
flex-shrink: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: var(--color-text-4);
border-radius: 50%;
opacity: 0;
transition: opacity 0.15s;
}
.bgm-playlist-item:hover .bgm-pl-remove {
opacity: 1;
}
.bgm-pl-remove:hover {
color: var(--color-danger-6);
background: var(--color-fill-3);
}
.bgm-playlist-empty {
padding: 16px;
text-align: center;
color: var(--color-text-4);
font-size: 12px;
}
</style>

View File

@@ -83,10 +83,22 @@
<!-- 音频预览 --> <!-- 音频预览 -->
<div v-else-if="config.isAudioView" class="media-preview"> <div v-else-if="config.isAudioView" class="media-preview">
<audio :src="config.previewUrl" controls class="preview-audio" @error="handleMediaError('音频')" @canplay="mediaErrorMsg = ''"></audio> <audio
ref="audioRef"
:src="config.previewUrl"
:loop="audioLoop"
controls
preload="none"
class="preview-audio"
@error="handleMediaError('音频')"
@canplay="mediaErrorMsg = ''"
></audio>
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div> <div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
<div class="media-meta"> <div class="media-meta">
<a-tag color="green">🎵 音频</a-tag> <a-tag color="green">🎵 音频</a-tag>
<a-button v-if="!audioBGM" size="mini" :type="audioLoop ? 'primary' : 'outline'" @click="audioLoop = !audioLoop">🔁 循环</a-button>
<a-button size="mini" :type="audioBGM ? 'primary' : 'outline'" @click="toggleBGM">📻 BGM</a-button>
<a-button v-if="audioBGM" size="mini" type="outline" @click="scanAndAddAll" :loading="scanLoading" title="扫描当前目录音频文件并添加到播放列表">🎵+ 全部</a-button>
</div> </div>
</div> </div>
@@ -388,6 +400,18 @@
</div> </div>
</div> </div>
</div> </div>
<!-- BGM 背景播放条 -->
<BgmBar
v-show="!bgmHidden"
ref="bgmBarRef"
:src="bgmSrc"
:current-path="config.currentFileFullPath"
:title="bgmTitle"
:lyrics="bgmLyrics"
@stop="stopBGM"
@switch="onBgmSwitch"
/>
</div> </div>
</template> </template>
@@ -395,15 +419,21 @@
import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted, onUnmounted } from 'vue' import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted, onUnmounted } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy, IconCheck, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon' import { IconSave, IconEdit, IconEye, IconUndo, IconCopy, IconCheck, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon'
import { getFileName, escapeHtml } from '@/utils/fileUtils' import { getFileName, escapeHtml, getParentPath, getExt } from '@/utils/fileUtils'
import { useClipboardCopy } from '../composables/useClipboardCopy' import { useClipboardCopy } from '../composables/useClipboardCopy'
import type { FileEditorPanelConfig } from '@/types/file-system' import type { FileEditorPanelConfig } from '@/types/file-system'
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions' import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers' import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
import { getFileCategory } from '@/utils/fileTypeHelpers' import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
import { isDirty } from '../composables/useMultiPreview' import { isDirty } from '../composables/useMultiPreview'
import { On, Off } from '@wailsio/events'
import { connectionManager } from '@/api/connection-manager' import { connectionManager } from '@/api/connection-manager'
import { listDir, readFile } from '@/api/system'
import { resolveFileUrl, resolveFileUrlForProfile } from '../composables/useFilePreview'
import { getFileServerBaseURL } from '@/api/file-server'
import { parseLrc, type LrcLine } from '@/utils/lrcParser'
import BgmBar, { type BgmTrack } from './FileEditor/BgmBar.vue'
// 异步加载 CodeEditor 组件,减少初始包大小 // 异步加载 CodeEditor 组件,减少初始包大小
const AsyncCodeEditor = defineAsyncComponent({ const AsyncCodeEditor = defineAsyncComponent({
@@ -485,28 +515,38 @@ const getFileTypeIcon = (filename: string): string => {
return CATEGORY_ICONS[getFileCategory(filename)] || '📄' return CATEGORY_ICONS[getFileCategory(filename)] || '📄'
} }
// HTML 预览 URL实时从 connectionManager 读取,不缓存 // HTML 预览 URLOSS 需要先下载到本地再预览
function resolveHtmlPreviewBase(): string { const htmlPreviewUrl = ref('')
if (!connectionManager.isRemote()) return 'http://localhost:2652'
const base = connectionManager.getFileServerBaseURL()
if (!base) return 'http://localhost:2652'
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfshtmlPreviewUrl 会替换为 html-preview
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
}
const htmlPreviewUrl = computed(() => { watch(
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) return '' () => [props.config.currentFileFullPath, props.config.isHtmlFile],
const encodedPath = encodeURIComponent(props.config.currentFileFullPath) async ([path, isHtml]) => {
const isRemote = connectionManager.isRemote() htmlPreviewUrl.value = ''
const base = resolveHtmlPreviewBase() if (!path || !isHtml) return
if (isRemote) {
// 远程模式:走 /api/v1/proxy/html-preview 路由 // 下载到临时目录后是本地文件,统一用本地文件服务器
const baseUrl = base.replace(/\/proxy\/localfs\/?$/, '') const base = getFileServerBaseURL()
return `${baseUrl}/proxy/html-preview?path=${encodedPath}` const makeUrl = (localPath: string) => `${base}/localfs/html-preview?path=${encodeURIComponent(localPath)}`
}
// 本地模式:直连文件服务器 if (connectionManager.isRemote()) {
return `${base}/localfs/html-preview?path=${encodedPath}` try {
}) const transport = connectionManager.getTransport()
if (transport?.downloadSiteForPreview) {
const tempPath = await transport.downloadSiteForPreview(path)
htmlPreviewUrl.value = makeUrl(tempPath)
} else if (transport?.downloadForPreview) {
const tempPath = await transport.downloadForPreview(path)
htmlPreviewUrl.value = makeUrl(tempPath)
}
} catch (e) {
console.warn('[HTML预览] 下载失败:', path, e)
}
} else {
htmlPreviewUrl.value = makeUrl(path)
}
},
{ immediate: true }
)
// 计算属性:判断文件是否在当前目录 // 计算属性:判断文件是否在当前目录
const isFileInCurrentDirectory = computed(() => { const isFileInCurrentDirectory = computed(() => {
@@ -566,10 +606,172 @@ const handleImageError = () => {
} }
const mediaErrorMsg = ref('') const mediaErrorMsg = ref('')
watch(() => props.config.previewUrl, () => { mediaErrorMsg.value = '' })
const handleMediaError = (type: string) => { const handleMediaError = (type: string) => {
mediaErrorMsg.value = `${type}文件加载失败,请检查网络连接或文件权限` mediaErrorMsg.value = `${type}文件加载失败,请检查网络连接或文件权限`
} }
// 音频控制
const audioRef = ref<HTMLAudioElement | null>(null)
// 防止浏览器恢复媒体会话导致自动播放
let suppressAudioAutoPlay = true
watch(audioRef, (el) => {
if (el && suppressAudioAutoPlay) {
el.pause()
suppressAudioAutoPlay = false
}
})
const bgmBarRef = ref<InstanceType<typeof BgmBar> | null>(null)
const audioLoop = ref(localStorage.getItem('desk:audioLoop') === 'true')
const audioBGM = ref(false)
const bgmHidden = ref(true)
const bgmSrc = ref('')
const bgmTitle = ref('')
const bgmLyrics = ref<LrcLine[]>([])
// 构造 .lrc 文件路径
const buildLrcPath = (audioPath: string): string => {
const parent = getParentPath(audioPath)
const name = getFileName(audioPath)
const ext = getExt(name)
const baseName = ext ? name.slice(0, -(ext.length + 1)) : name
// getParentPath 始终返回 / 分隔的路径(内部已 normalizePathSeparators
// 所以统一用 / 拼接,兼容 OSS/SFTP/本地路径
const sep = parent.endsWith('/') ? '' : '/'
return parent + sep + baseName + '.lrc'
}
// 加载歌词(防重复:同文件不重复请求)
let _lastLrcAudioPath = ''
const loadLyrics = async (audioPath: string) => {
bgmLyrics.value = []
if (!audioPath) return
if (_lastLrcAudioPath === audioPath) return
_lastLrcAudioPath = audioPath
try {
const lrcPath = buildLrcPath(audioPath)
console.debug('[BGM] 加载歌词:', lrcPath, '<- audioPath:', audioPath)
const content = await readFile(lrcPath)
const data = parseLrc(content)
console.debug('[BGM] 歌词解析完成:', data.lines.length, '行')
bgmLyrics.value = data.lines
} catch (e) {
console.debug('[BGM] 无歌词文件:', audioPath, e)
}
}
watch(audioLoop, (v) => localStorage.setItem('desk:audioLoop', String(v)))
// BGM 模式:将当前音频转为后台播放
const toggleBGM = () => {
if (!audioBGM.value) {
bgmSrc.value = props.config.previewUrl
bgmTitle.value = props.config.currentFileName
audioBGM.value = true
bgmHidden.value = false
if (props.config.currentFileFullPath) loadLyrics(props.config.currentFileFullPath)
// 暂停内联播放器,切换到 BgmBar
const t = audioRef.value?.currentTime || 0
if (audioRef.value) audioRef.value.pause()
nextTick(() => bgmBarRef.value?.playFrom(t))
} else {
stopBGM()
}
}
const stopBGM = () => {
bgmHidden.value = true
}
// 外部调用:直接将音频加入 BGM 播放列表并播放(不打开 tab
const playAudioAsBGM = (name: string, path: string, url: string) => {
bgmSrc.value = url
bgmTitle.value = name
audioBGM.value = true
bgmHidden.value = false
nextTick(() => {
bgmBarRef.value?.addToPlaylist(name, path, url)
bgmBarRef.value?.playFrom(0)
})
if (path) loadLyrics(path)
}
const onBgmSwitch = async (track: BgmTrack) => {
bgmTitle.value = track.name
audioBGM.value = true
bgmHidden.value = false
try {
if (track.url) {
bgmSrc.value = track.url
} else if (track.path) {
bgmSrc.value = await resolveFileUrlForProfile(track.path, track.profileId, true)
}
} catch (e) {
console.warn('[BGM] 切歌 URL 解析失败:', e)
bgmSrc.value = ''
return
}
if (track.path) {
_lastLrcAudioPath = ''
loadLyrics(track.path)
}
nextTick(() => bgmBarRef.value?.playFrom(0))
}
const scanLoading = ref(false)
const scanAndAddAll = async () => {
if (connectionManager.isRemote()) {
Message.warning('远程模式下暂不支持批量扫描')
return
}
const dir = props.currentDirectory || getParentPath(props.config.currentFileFullPath)
if (!dir) return
console.debug('[BGM] 扫描目录:', dir)
scanLoading.value = true
try {
const files = await listDir(dir)
const audioFiles = files.filter(f => !f.isDir && isAudioFile(f.name))
if (!audioFiles.length) {
Message.info('当前目录未发现音频文件')
return
}
const results = await Promise.allSettled(audioFiles.map(f => resolveFileUrl(f.path, true)))
const tracks: BgmTrack[] = []
for (let i = 0; i < audioFiles.length; i++) {
const r = results[i]
if (r.status === 'fulfilled' && r.value) {
tracks.push({ name: audioFiles[i].name, path: audioFiles[i].path, url: r.value })
}
}
if (!tracks.length) {
Message.warning('音频文件 URL 解析均失败')
return
}
bgmBarRef.value?.addBatch(tracks)
Message.success(`已添加 ${tracks.length} 首音频到播放列表`)
} catch (e) {
Message.error(`扫描失败: ${e?.message || e}`)
} finally {
scanLoading.value = false
}
}
// 切换文件时自动替换 BGM 内容(仅在 BGM 已激活时)
watch(() => props.config.previewUrl, (url) => {
mediaErrorMsg.value = ''
if (!props.config.isAudioView || !url || !audioBGM.value) return
if (url !== bgmSrc.value) {
const audioEl = bgmBarRef.value?.getAudioElement()
const wasPlaying = audioEl && !audioEl.paused
bgmSrc.value = url
bgmTitle.value = props.config.currentFileName
bgmBarRef.value?.addToPlaylist(props.config.currentFileName, props.config.currentFileFullPath, url)
if (props.config.currentFileFullPath) loadLyrics(props.config.currentFileFullPath)
if (wasPlaying) {
nextTick(() => bgmBarRef.value?.playFrom())
}
}
})
const handlePdfLoad = (event: Event) => { const handlePdfLoad = (event: Event) => {
const iframe = event.target as HTMLIFrameElement const iframe = event.target as HTMLIFrameElement
try { try {
@@ -879,7 +1081,7 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
const allowedOrigins = [ const allowedOrigins = [
window.location.origin, window.location.origin,
'null', 'null',
resolveHtmlPreviewBase(), // 动态:本地 localhost:2652 或远程代理地址 getFileServerBaseURL(),
] ]
if (!allowedOrigins.includes(event.origin)) { if (!allowedOrigins.includes(event.origin)) {
return return
@@ -896,6 +1098,9 @@ onMounted(() => {
window.addEventListener('message', handleHtmlIframeMessage) window.addEventListener('message', handleHtmlIframeMessage)
document.addEventListener('fullscreenchange', onFullscreenChange) document.addEventListener('fullscreenchange', onFullscreenChange)
document.addEventListener('keydown', onKeyDown) document.addEventListener('keydown', onKeyDown)
On('toggle-bgm-bar', () => {
bgmHidden.value = !bgmHidden.value
})
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -905,8 +1110,11 @@ onUnmounted(() => {
window.removeEventListener('message', handleHtmlIframeMessage) window.removeEventListener('message', handleHtmlIframeMessage)
document.removeEventListener('fullscreenchange', onFullscreenChange) document.removeEventListener('fullscreenchange', onFullscreenChange)
document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keydown', onKeyDown)
Off('toggle-bgm-bar')
copyCleanup() copyCleanup()
}) })
defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHidden.value }, playAudioAsBGM })
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,14 +1,16 @@
<template> <template>
<transition name="slide"> <transition name="slide">
<div v-show="config.visible" class="sidebar"> <div v-show="config.visible" class="sidebar">
<template v-for="section in sectionOrder" :key="section">
<!-- 服务器区块 --> <!-- 服务器区块 -->
<div class="sidebar-section" :class="{ 'section-on-top': settingsOpen || moreOpenId }"> <div v-if="section === 'server' && showServer" 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>
<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-wrap" :class="{ collapsed: serverCollapsed }">
<div class="section-content server-content">
<!-- 表头 --> <!-- 表头 -->
<div class="server-table-head"> <div class="server-table-head">
<span class="col-name">名称</span> <span class="col-name">名称</span>
@@ -19,16 +21,16 @@
</div> </div>
<!-- 表格行 --> <!-- 表格行 -->
<div <div
v-for="p in profiles" v-for="p in visibleProfiles"
:key="p.id" :key="p.id"
class="server-table-row" class="server-table-row"
:class="{ active: p.id === activeId }" :class="{ active: p.id === activeId }"
@click="handleSelect(p)" @click="handleSelect(p)"
> >
<span class="col-name" :title="stateText(p.id)"><span :class="['dot', stateDotClass(p.id)]" />{{ p.name }}</span> <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" :class="metricWarnClass(p.id, 'disk')" :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" :class="metricWarnClass(p.id, 'cpu')" :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 class="col-metric" :class="metricWarnClass(p.id, 'mem')" :title="metricTooltip(p.id, 'mem')">{{ sysInfo(p.id)?.memUsage || '-' }}</span>
<span <span
v-if="p.type !== 'local'" v-if="p.type !== 'local'"
class="col-action more-btn" class="col-action more-btn"
@@ -44,8 +46,7 @@
</div> </div>
</div> </div>
</div>
<!-- 管理设置面板放在 section-content/overflow 容器外部 -->
<div v-if="settingsOpen" class="settings-panel" @click.stop> <div v-if="settingsOpen" class="settings-panel" @click.stop>
<div class="settings-item" @click="$emit('openConnectionDialog'); settingsOpen = false"> <div class="settings-item" @click="$emit('openConnectionDialog'); settingsOpen = false">
<icon-plus /> 添加服务器 <icon-plus /> 添加服务器
@@ -58,18 +59,30 @@
<icon-check-circle :style="{ opacity: autoRefresh ? 1 : 0.3 }" /> <icon-check-circle :style="{ opacity: autoRefresh ? 1 : 0.3 }" />
自动刷新系统信息 (15s) 自动刷新系统信息 (15s)
</div> </div>
<div class="settings-divider" />
<div class="settings-label">显示服务器</div>
<div
v-for="p in profiles"
:key="'vis-' + p.id"
class="settings-item"
@click="toggleServerVisibility(p.id)"
>
<icon-check-circle :style="{ opacity: !hiddenServerIds.includes(String(p.id)) ? 1 : 0.3 }" />
{{ p.name }}
</div>
</div> </div>
</div> </div>
<!-- 收藏夹区块 --> <!-- 收藏夹区块 -->
<div class="sidebar-section"> <div v-if="section === 'favorites' && showFavorites" class="sidebar-section fav-section">
<div class="section-header" @click="favCollapsed = !favCollapsed"> <div class="section-header" @click="favCollapsed = !favCollapsed">
<span class="section-title"> 收藏夹</span> <span class="section-title"> 收藏夹</span>
<span class="section-count">{{ config.favoriteFiles.length }}</span> <span class="section-count">{{ config.favoriteFiles.length }}</span>
<icon-down v-if="!favCollapsed" class="section-toggle" /> <icon-down v-if="!favCollapsed" class="section-toggle" />
<icon-right v-else class="section-toggle" /> <icon-right v-else class="section-toggle" />
</div> </div>
<div class="section-content fav-content" :class="{ collapsed: favCollapsed }"> <div class="section-content-wrap" :class="{ collapsed: favCollapsed }">
<div class="section-content fav-content">
<div <div
v-for="(fav, index) in config.favoriteFiles" v-for="(fav, index) in config.favoriteFiles"
:key="fav.path" :key="fav.path"
@@ -124,22 +137,26 @@
<span class="sidebar-hint">点击文件列表中的星标收藏</span> <span class="sidebar-hint">点击文件列表中的星标收藏</span>
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- 帮助文档区块 --> <!-- 帮助文档区块 -->
<div class="sidebar-section"> <div v-if="section === 'help' && showHelp" class="sidebar-section">
<div class="section-header" @click="helpCollapsed = !helpCollapsed"> <div class="section-header" @click="helpCollapsed = !helpCollapsed">
<span class="section-title">📖 帮助</span> <span class="section-title">📖 帮助</span>
<icon-down v-if="!helpCollapsed" class="section-toggle" /> <icon-down v-if="!helpCollapsed" class="section-toggle" />
<icon-right v-else class="section-toggle" /> <icon-right v-else class="section-toggle" />
</div> </div>
<div class="section-content help-content" :class="{ collapsed: helpCollapsed }"> <div class="section-content-wrap" :class="{ collapsed: helpCollapsed }">
<div class="section-content help-content">
<div class="help-item" v-for="item in helpItems" :key="item.key"> <div class="help-item" v-for="item in helpItems" :key="item.key">
<span class="help-key">{{ item.key }}</span> <span class="help-key">{{ item.key }}</span>
<span class="help-desc">{{ item.desc }}</span> <span class="help-desc">{{ item.desc }}</span>
</div> </div>
</div> </div>
</div>
</div> </div>
</template>
</div> </div>
</transition> </transition>
</template> </template>
@@ -149,10 +166,13 @@ 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 type { SystemInfo } from '@/api/connection-manager'
import { connectionManager } from '@/api/connection-manager' import { connectionManager } from '@/api/connection-manager'
import { useConfigStore } from '@/stores/config'
import { IconStar, IconClose, IconPushpin, IconDown, IconRight, IconStorage, IconComputer, IconDesktop, IconPlus, IconCheckCircle } 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' import { Message } from '@arco-design/web-vue'
const configStore = useConfigStore()
// Props // Props
interface Props { interface Props {
config: SidebarConfig config: SidebarConfig
@@ -165,10 +185,18 @@ const serverCollapsed = ref(false)
const favCollapsed = ref(false) const favCollapsed = ref(false)
const helpCollapsed = ref(false) const helpCollapsed = ref(false)
// 侧边栏区块可见性(从配置读取)
const showServer = computed(() => configStore.appConfig.sidebarSections?.includes('server') ?? true)
const showFavorites = computed(() => configStore.appConfig.sidebarSections?.includes('favorites') ?? true)
const showHelp = computed(() => configStore.appConfig.sidebarSections?.includes('help') ?? true)
// 区块排序
const sectionOrder = computed(() => configStore.appConfig.sidebarSections || ['server', 'favorites', 'help'])
// 管理设置 // 管理设置
const settingsOpen = ref(false) const settingsOpen = ref(false)
const autoConnect = ref(localStorage.getItem('desk:autoConnect') !== 'false') const autoConnect = ref(localStorage.getItem('desk:autoConnect') !== 'false')
const autoRefresh = ref(localStorage.getItem('desk:autoRefresh') === 'true') const autoRefresh = ref(localStorage.getItem('desk:autoRefresh') === 'true')
const hiddenServerIds = ref<string[]>(JSON.parse(localStorage.getItem('desk:hiddenServers') || '[]'))
let refreshTimer: ReturnType<typeof setInterval> | null = null let refreshTimer: ReturnType<typeof setInterval> | null = null
function toggleAutoConnect() { function toggleAutoConnect() {
@@ -189,7 +217,7 @@ function toggleAutoRefresh() {
function startAutoRefresh() { function startAutoRefresh() {
stopAutoRefresh() stopAutoRefresh()
refreshTimer = setInterval(() => { refreshTimer = setInterval(() => {
profiles.value.forEach(p => { connectionManager.fetchSystemInfo(p.id).catch(() => {}) }) visibleProfiles.value.forEach(p => { connectionManager.fetchSystemInfo(p.id).catch(() => {}) })
}, 15000) }, 15000)
} }
@@ -221,6 +249,21 @@ const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
const moreOpenId = ref<string | null>(null) const moreOpenId = ref<string | null>(null)
const sysInfoMap = ref<Record<string, SystemInfo>>({}) const sysInfoMap = ref<Record<string, SystemInfo>>({})
const visibleProfiles = computed(() =>
profiles.value.filter(p => !hiddenServerIds.value.includes(String(p.id)))
)
function toggleServerVisibility(id: string) {
const sid = String(id)
const idx = hiddenServerIds.value.indexOf(sid)
if (idx >= 0) {
hiddenServerIds.value.splice(idx, 1)
} else {
hiddenServerIds.value.push(sid)
}
localStorage.setItem('desk:hiddenServers', JSON.stringify(hiddenServerIds.value))
}
// 监听连接变化 + 系统信息变化 // 监听连接变化 + 系统信息变化
connectionManager.onStateChange(() => { connectionManager.onStateChange(() => {
profiles.value = connectionManager.profiles profiles.value = connectionManager.profiles
@@ -345,6 +388,23 @@ function metricTooltip(profileId: string, type: 'disk' | 'cpu' | 'mem'): string
return '-' return '-'
} }
function metricWarnClass(profileId: string, type: 'disk' | 'cpu' | 'mem'): string {
const info = sysInfoMap.value[profileId]
if (!info) return ''
if (type === 'disk') {
const pct = parseFloat(info.diskUsage || '')
const freeGB = info.diskTotal != null && info.diskUsed != null ? (info.diskTotal - info.diskUsed) / 1073741824 : Infinity
if (!isNaN(pct) && pct >= 90 || freeGB < 10) return 'metric-warn'
} else if (type === 'cpu') {
const pct = parseFloat(info.cpuUsage || '')
if (!isNaN(pct) && pct >= 90) return 'metric-warn'
} else {
const pct = parseFloat(info.memUsage || '')
if (!isNaN(pct) && pct >= 90) return 'metric-warn'
}
return ''
}
function formatBytes(n: number): string { function formatBytes(n: number): string {
if (n < 1024) return `${n} B` if (n < 1024) return `${n} B`
if (n < 1048576) return `${(n / 1024).toFixed(0)} KB` if (n < 1048576) return `${(n / 1024).toFixed(0)} KB`
@@ -411,6 +471,36 @@ function handleDelete(p: { id: string; name: string }) {
position: relative; position: relative;
} }
/* 服务器区块不收缩 */
.sidebar-section:first-child {
flex-shrink: 0;
}
/* 收藏夹区块弹性填充剩余空间 */
.fav-section {
flex: 1;
min-height: 0;
overflow: hidden;
}
/* 收藏夹 section-content-wrap 由 flex 控制高度 */
.fav-section > .section-content-wrap {
flex: 1;
min-height: 0;
}
.fav-section > .section-content-wrap.collapsed {
flex: 0;
}
.fav-section > .section-content-wrap > .section-content {
overflow-y: auto;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.sidebar-section.section-on-top { .sidebar-section.section-on-top {
z-index: 30; z-index: 30;
} }
@@ -454,19 +544,19 @@ function handleDelete(p: { id: string; name: string }) {
transition: transform 0.2s; transition: transform 0.2s;
} }
/* 区块内容 - 可折叠 */ /* 区块折叠容器 - grid 动画精确匹配内容高度 */
.section-content { .section-content-wrap {
overflow: hidden; display: grid;
transition: max-height 0.25s ease, opacity 0.2s ease; grid-template-rows: 1fr;
max-height: calc(100vh - 80px); transition: grid-template-rows 0.2s ease;
opacity: 1;
} }
.section-content.collapsed { .section-content-wrap.collapsed {
max-height: 0; grid-template-rows: 0fr;
opacity: 0; }
padding-top: 0;
padding-bottom: 0; .section-content-wrap > .section-content {
overflow: hidden;
} }
/* 收藏夹内容 - 内部独立滚动 */ /* 收藏夹内容 - 内部独立滚动 */
@@ -581,6 +671,10 @@ function handleDelete(p: { id: string; name: string }) {
color: var(--color-text-2); color: var(--color-text-2);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.col-metric.metric-warn {
color: #e53e3e;
font-weight: 600;
}
.col-action { .col-action {
width: 20px; width: 20px;
flex-shrink: 0; flex-shrink: 0;
@@ -659,6 +753,17 @@ function handleDelete(p: { id: string; name: string }) {
color: var(--color-text-2); color: var(--color-text-2);
} }
.settings-item:hover { background: var(--color-fill-1); } .settings-item:hover { background: var(--color-fill-1); }
.settings-divider {
height: 1px;
background: var(--color-border-2);
margin: 4px 12px;
}
.settings-label {
padding: 4px 12px 2px;
font-size: 11px;
color: var(--color-text-3);
user-select: none;
}
/* 区块操作图标 */ /* 区块操作图标 */
.section-action { .section-action {
@@ -675,7 +780,7 @@ function handleDelete(p: { id: string; name: string }) {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 12px; padding: 6px;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;

View File

@@ -28,7 +28,7 @@
<!-- 正常模式连接指示器 + 面包屑导航融合布局 --> <!-- 正常模式连接指示器 + 面包屑导航融合布局 -->
<div v-else class="path-breadcrumb-wrapper"> <div v-else class="path-breadcrumb-wrapper">
<!-- 连接指示器紧凑标签样式作为面包屑首段 --> <!-- 连接指示器紧凑标签样式作为面包屑首段 -->
<ConnectionIndicator @add="showConnectionDialog = true" @select="onConnectionChanged" @edit="onEditProfile" /> <ConnectionIndicator :file-path="config.filePath" @add="showConnectionDialog = true" @select="onConnectionChanged" @edit="onEditProfile" @navigate="handleGoToPath" @openFile="handleOpenFile" />
<span class="breadcrumb-sep"></span> <span class="breadcrumb-sep"></span>
<!-- 路径面包屑 --> <!-- 路径面包屑 -->
<PathBreadcrumb <PathBreadcrumb

View File

@@ -7,6 +7,7 @@ import { ref } from 'vue'
import { STORAGE_KEYS, DEFAULTS } from '@/utils/constants' import { STORAGE_KEYS, DEFAULTS } from '@/utils/constants'
import { getPathSeparator } from '@/utils/fileUtils' import { getPathSeparator } from '@/utils/fileUtils'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { connectionManager } from '@/api/connection-manager'
import type { FavoriteFile, FileItem, DraggingState } from '@/types/file-system' import type { FavoriteFile, FileItem, DraggingState } from '@/types/file-system'
export function useFavorites() { export function useFavorites() {
@@ -37,15 +38,33 @@ export function useFavorites() {
const stored = localStorage.getItem(STORAGE_KEYS.FAVORITE_FILES) const stored = localStorage.getItem(STORAGE_KEYS.FAVORITE_FILES)
if (stored) { if (stored) {
const loaded = JSON.parse(stored) as FavoriteFile[] const loaded = JSON.parse(stored) as FavoriteFile[]
let migrated = false
// 数据迁移:将旧字段 is_dir 转换为 isDir // 数据迁移:将旧字段 is_dir 转换为 isDir,补充缺失的 profileId
favorites.value = loaded.map(fav => ({ favorites.value = loaded.map(fav => {
...fav, const fixed = {
isDir: fav.isDir ?? (fav as any).is_dir ?? false ...fav,
})) isDir: fav.isDir ?? (fav as any).is_dir ?? false
}
// 旧收藏无 profileId根据路径格式推断
if (!fixed.profileId) {
const isLocalPath = /^[A-Za-z]:/.test(fixed.path) || fixed.path.startsWith('\\\\')
if (!isLocalPath) {
// 远程路径 → 找到第一个远程/OSS profile
const remoteProfile = connectionManager.profiles.find(p =>
p.type === 'remote' || p.type === 'sftp' || p.type === 'oss'
)
if (remoteProfile) {
fixed.profileId = remoteProfile.id
migrated = true
}
}
}
return fixed
})
// 仅排序(置顶项归组到前面),保持用户拖拽顺序
sortFavorites() sortFavorites()
if (migrated) saveFavorites()
} }
} catch (error) { } catch (error) {
console.error('加载收藏列表失败:', error) console.error('加载收藏列表失败:', error)
@@ -83,11 +102,19 @@ export function useFavorites() {
return false return false
} }
favorites.value.push({ const newFav: FavoriteFile = {
...file, ...file,
addedAt: Date.now() addedAt: Date.now(),
} as FavoriteFile) profileId: connectionManager.activeProfile?.id
sortFavorites() }
// 插入到第一个非置顶项位置(置顶项之后、非置顶项之前)
const insertIdx = favorites.value.findIndex(f => !f.pinnedAt)
if (insertIdx === -1) {
favorites.value.push(newFav)
} else {
favorites.value.splice(insertIdx, 0, newFav)
}
saveFavorites() saveFavorites()
return true return true
} }
@@ -165,7 +192,7 @@ export function useFavorites() {
} }
// 拖拽方法 // 拖拽方法
let longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null) let longPressTimer: ReturnType<typeof setTimeout> | null = null
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => { const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
if (event instanceof MouseEvent && event.button !== 0) return if (event instanceof MouseEvent && event.button !== 0) return

View File

@@ -127,9 +127,9 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
* 计算属性:文件内容是否改变 * 计算属性:文件内容是否改变
*/ */
const contentChanged = computed(() => { const contentChanged = computed(() => {
return fileContent.value !== '' && if (fileContent.value === '' || originalContent.value === undefined) return false
originalContent.value !== undefined && // 统一 CRLF → LF 再比较,避免编辑器内部 \n 与文件原始 \r\n 产生误判
originalContent.value !== fileContent.value return originalContent.value.replace(/\r\n/g, '\n') !== fileContent.value.replace(/\r\n/g, '\n')
}) })
/** /**
@@ -428,9 +428,13 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
return return
} }
// 恢复草稿内容 // 恢复草稿内容(仅当内容不同时才覆盖,避免无意义的脏标记)
fileContent.value = draft.content if (draft.content !== fileContent.value) {
Message.info('已恢复未保存的草稿') fileContent.value = draft.content
Message.info('已恢复未保存的草稿')
} else {
clearDraft()
}
} }
} catch (error) { } catch (error) {
console.error('加载草稿失败:', error) console.error('加载草稿失败:', error)
@@ -484,6 +488,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
const resetContent = () => { const resetContent = () => {
if (originalContent.value !== undefined) { if (originalContent.value !== undefined) {
fileContent.value = originalContent.value fileContent.value = originalContent.value
clearDraft()
Message.info('已恢复原始内容') Message.info('已恢复原始内容')
} }
} }

View File

@@ -8,15 +8,13 @@ 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 { OssTransport } from '@/api/oss-transport'
import { getFileServerBaseURL } from '@/api/file-server' import { getFileServerBaseURL } from '@/api/file-server'
import { import {
isImageFile, isVideoFile, isAudioFile, isPdfFile, isImageFile, isVideoFile, isAudioFile, isPdfFile,
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType, isHtmlFile, isMarkdownFile,
isTextEditable, isConfigFile isTextEditable, isConfigFile
} from '@/utils/fileTypeHelpers' } from '@/utils/fileTypeHelpers'
import type { FilePreviewMetadata, FileType } from '@/types/file-system' import type { FileType } from '@/types/file-system'
// 内容检测大小限制(与后端一致) // 内容检测大小限制(与后端一致)
const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB
@@ -25,26 +23,73 @@ const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB
const contentDetectCache = new Map<string, { timestamp: number; result: any }>() const contentDetectCache = new Map<string, { timestamp: number; result: any }>()
const CACHE_TTL = 60000 // 1分钟缓存 const CACHE_TTL = 60000 // 1分钟缓存
export interface UseFilePreviewOptions { // ====== URL 工具函数(导出供外部使用) ======
filePath?: string
isBrowsingZip?: boolean
}
function getLocalServerURL(): string { /** 解析文件服务器 base URL区分本地/远程模式) */
return getFileServerBaseURL() export function resolveFileServerBase(): string {
} if (!connectionManager.isRemote()) return getFileServerBaseURL()
function resolveFileServerBase(): string {
// 单一数据源:从 connectionManager 实时读取,不缓存
if (!connectionManager.isRemote()) return getLocalServerURL()
const base = connectionManager.getFileServerBaseURL() const base = connectionManager.getFileServerBaseURL()
if (!base) return getLocalServerURL() if (!base) return getFileServerBaseURL()
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs' return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
} }
export function useFilePreview(options: UseFilePreviewOptions = {}) { /** 拼接本地文件服务器 URL远程文件下载到临时目录后使用 */
const { filePath = ref(''), isBrowsingZip = ref(false) } = options export function buildLocalUrl(localPath: string): string {
const base = getFileServerBaseURL()
let normalized = normalizeFilePath(localPath, true)
if (normalized.startsWith('/')) normalized = normalized.slice(1)
const sep = base.endsWith('/') ? '' : '/'
return `${base}${sep}localfs/${normalized}`
}
/** 统一 URL 解析核心(传入 transport 实例,消除 instanceof 分支) */
async function resolveWithTransport(
transport: import('@/api/transport').FsTransport,
path: string,
stream: boolean
): Promise<string> {
// 需要下载到本地的传输类型
if (transport.downloadForPreview) {
// OSS 支持签名 URL 流式播放
if (stream && transport.getSignedUrl) {
return await transport.getSignedUrl(path)
}
const tempPath = await transport.downloadForPreview(path)
if (tempPath !== path) return buildLocalUrl(tempPath)
// downloadForPreview 返回原路径 → 本地/HTTP直接拼 URL
}
// 本地/HTTP: 直接拼文件服务器 URL
const base = resolveFileServerBase()
let normalized = normalizeFilePath(path, true)
if (normalized.startsWith('/')) normalized = normalized.slice(1)
const sep = base.endsWith('/') ? '' : '/'
return `${base}${sep}${connectionManager.isRemote() ? '' : 'localfs/'}${normalized}`
}
/**
* 统一文件 URL 解析(使用当前活跃 transport
*/
export async function resolveFileUrl(path: string, stream = false): Promise<string> {
if (!path) return ''
return resolveWithTransport(connectionManager.getTransport(), path, stream)
}
/**
* 按指定 profileId 解析文件 URL播放列表恢复用
* 不切换当前活跃连接,直接从连接池取对应 transport
*/
export async function resolveFileUrlForProfile(
path: string, profileId: string | undefined, stream = false
): Promise<string> {
if (!path) return ''
if (profileId) {
const transport = connectionManager.getTransportFor(profileId)
if (transport) return resolveWithTransport(transport, path, stream)
}
return resolveFileUrl(path, stream)
}
export function useFilePreview() {
// 预览 URL // 预览 URL
const previewUrl = ref('') const previewUrl = ref('')
@@ -55,46 +100,24 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
/** /**
* 获取预览 URL本地/远程/SFTP 自适应,每次实时计算) * 获取预览 URL本地/远程/SFTP 自适应,每次实时计算)
* 本地: {fileServerBaseURL}/localfs/{encoded_path}
* 远程(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()
// 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)
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1) if (normalized.startsWith('/')) normalized = normalized.slice(1)
const sep = base.endsWith('/') ? '' : '/' const sep = base.endsWith('/') ? '' : '/'
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}` return `${base}${sep}${connectionManager.isRemote() ? '' : 'localfs/'}${normalized}`
} }
/** /**
* 通过内容检测文件类型(用于小文件) * 通过内容检测文件类型(用于小文件)
*/ */
const detectByContent = async (path: string, fileSize?: number): Promise<{ category: string; ext: string } | null> => { const detectByContent = async (path: string, fileSize?: number): Promise<{ category: string; ext: string } | null> => {
// 如果文件太大,跳过内容检测 if (fileSize !== undefined && fileSize > CONTENT_DETECT_MAX_SIZE) return null
if (fileSize !== undefined && fileSize > CONTENT_DETECT_MAX_SIZE) {
return null
}
// 检查缓存
const cached = contentDetectCache.get(path) const cached = contentDetectCache.get(path)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) { if (cached && Date.now() - cached.timestamp < CACHE_TTL) return cached.result
return cached.result
}
try { try {
const result = await detectFileTypeByContent(path) const result = await detectFileTypeByContent(path)
@@ -108,28 +131,15 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
/** /**
* 更新预览 URL * 更新预览 URL
* SFTP/OSS下载到本地临时目录后用本地文件服务器预览
*/ */
const updatePreviewUrl = async (path: string) => { const updatePreviewUrl = async (path: string) => {
if (!path) { previewUrl.value = ''; return } if (!path) { previewUrl.value = ''; return }
const transport = connectionManager.getTransport() try {
previewUrl.value = await resolveFileUrl(path)
// SFTP / OSS下载到本地临时目录后用本地文件服务器预览 } catch (e) {
if (transport instanceof SftpTransport || transport instanceof OssTransport) { console.warn('[Preview] 下载失败:', path, e)
try { previewUrl.value = ''
const tempPath = await transport.downloadForPreview(path)
// 临时文件通过本地文件服务器提供,始终用 localfs 路径
const base = getLocalServerURL()
const normalized = normalizeFilePath(tempPath, true)
const sep = base.endsWith('/') ? '' : '/'
previewUrl.value = `${base}${sep}localfs/${normalized}`
return
} catch {
// 下载失败,回退
}
} }
previewUrl.value = getPreviewUrl(path)
} }
/** /**
@@ -151,22 +161,11 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
return 'Binary' as FileType return 'Binary' as FileType
} }
/**
* 判断文件是否可预览
*/
const isPreviewable = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
return isPreviewableType(filename)
}
/** /**
* 判断文件是否可编辑 * 判断文件是否可编辑
*/ */
const isEditable = (filename: string, fileSize: number): boolean => { const isEditable = (filename: string, fileSize: number): boolean => {
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) { if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) return false
return false
}
if (!filename || typeof filename !== 'string') return false if (!filename || typeof filename !== 'string') return false
const ext = getExt(filename) const ext = getExt(filename)
return FILE_EXTENSIONS.CODE.includes(ext) || return FILE_EXTENSIONS.CODE.includes(ext) ||
@@ -187,71 +186,28 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
imageLoading.value = false imageLoading.value = false
} }
/**
* 图片加载失败
*/
const onImageError = () => { const onImageError = () => {
imageLoading.value = false imageLoading.value = false
currentImageDimensions.value = '' currentImageDimensions.value = ''
} }
/**
* 开始加载图片
*/
const startImageLoad = () => { const startImageLoad = () => {
imageLoading.value = true imageLoading.value = true
currentImageDimensions.value = '' currentImageDimensions.value = ''
} }
/**
* 获取媒体元数据
*/
const getMediaMetadata = async (url: string): Promise<FilePreviewMetadata> => {
const metadata: FilePreviewMetadata = {}
// 对于图片,使用 Image 对象
if (url.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
metadata.width = img.naturalWidth
metadata.height = img.naturalHeight
resolve(metadata)
}
img.onerror = () => resolve(metadata)
img.src = url
})
}
// 对于视频/音频,可以使用 Video/Audio 对象
// 但由于跨域等问题,这里简化处理
return metadata
}
return { return {
// 状态
previewUrl, previewUrl,
imageLoading, imageLoading,
currentImageDimensions, currentImageDimensions,
// URL 相关
getPreviewUrl, getPreviewUrl,
updatePreviewUrl, updatePreviewUrl,
resolveFileUrl,
// 文件类型判断(同步,基于扩展名)
getFileType, getFileType,
isPreviewable,
isEditable, isEditable,
// 内容检测(异步,基于文件内容)
detectByContent, detectByContent,
// 事件处理
onImageLoad, onImageLoad,
onImageError, onImageError,
startImageLoad, startImageLoad
// 工具方法
getMediaMetadata
} }
} }

View File

@@ -5,6 +5,7 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { STORAGE_KEYS } from '@/utils/constants' import { STORAGE_KEYS } from '@/utils/constants'
import { connectionManager } from '@/api/connection-manager'
import type { FileItem } from '@/types/file-system' import type { FileItem } from '@/types/file-system'
export interface PreviewTab { export interface PreviewTab {
@@ -12,6 +13,8 @@ export interface PreviewTab {
id: string id: string
/** 文件信息 */ /** 文件信息 */
fileItem: FileItem fileItem: FileItem
/** 所属连接 profileId */
profileId?: string
/** 缓存的预览 URL */ /** 缓存的预览 URL */
previewUrl: string previewUrl: string
/** 缓存的文件内容 */ /** 缓存的文件内容 */
@@ -30,6 +33,7 @@ export interface PreviewTab {
interface PersistedTab { interface PersistedTab {
path: string path: string
active: boolean active: boolean
profileId?: string
/** 未保存的内容(有修改时才存) */ /** 未保存的内容(有修改时才存) */
unsavedContent?: string unsavedContent?: string
originalContent?: string originalContent?: string
@@ -48,11 +52,13 @@ interface UnsavedEntry {
interface RestoredSession { interface RestoredSession {
paths: string[] paths: string[]
activePath: string | null activePath: string | null
profileMap: Map<string, string | undefined>
unsavedMap: Map<string, UnsavedEntry> unsavedMap: Map<string, UnsavedEntry>
} }
function pathToId(path: string): string { function pathToId(path: string): string {
return path.replace(/\\/g, '/').toLowerCase() const normalized = path.replace(/\\/g, '/')
return connectionManager.isRemote() ? normalized : normalized.toLowerCase()
} }
export function isDirty(tab: PreviewTab): boolean { export function isDirty(tab: PreviewTab): boolean {
@@ -69,9 +75,10 @@ export function useMultiPreview() {
}) })
/** 创建一个新 tab */ /** 创建一个新 tab */
const createTab = (fileItem: FileItem): PreviewTab => ({ const createTab = (fileItem: FileItem, profileId?: string): PreviewTab => ({
id: pathToId(fileItem.path), id: pathToId(fileItem.path),
fileItem, fileItem,
profileId,
previewUrl: '', previewUrl: '',
fileContent: '', fileContent: '',
originalContent: '', originalContent: '',
@@ -85,19 +92,21 @@ export function useMultiPreview() {
/** 从 localStorage 恢复会话 */ /** 从 localStorage 恢复会话 */
const restoreSession = (): RestoredSession => { const restoreSession = (): RestoredSession => {
const unsavedMap = new Map<string, UnsavedEntry>() const unsavedMap = new Map<string, UnsavedEntry>()
const profileMap = new Map<string, string | undefined>()
let activePath: string | null = null let activePath: string | null = null
const paths: string[] = [] const paths: string[] = []
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { paths, activePath, unsavedMap } if (!raw) return { paths, activePath, profileMap, unsavedMap }
const persisted: PersistedTab[] = JSON.parse(raw) const persisted: PersistedTab[] = JSON.parse(raw)
if (!Array.isArray(persisted)) return { paths, activePath, unsavedMap } if (!Array.isArray(persisted)) return { paths, activePath, profileMap, unsavedMap }
for (const p of persisted) { for (const p of persisted) {
if (!p.path) continue if (!p.path) continue
paths.push(p.path) paths.push(p.path)
profileMap.set(pathToId(p.path), p.profileId)
if (p.active) activePath = p.path if (p.active) activePath = p.path
if (p.unsavedContent !== undefined) { if (p.unsavedContent !== undefined) {
unsavedMap.set(pathToId(p.path), { unsavedMap.set(pathToId(p.path), {
@@ -111,18 +120,19 @@ export function useMultiPreview() {
localStorage.removeItem(STORAGE_KEY) localStorage.removeItem(STORAGE_KEY)
} }
return { paths, activePath, unsavedMap } return { paths, activePath, profileMap, unsavedMap }
} }
/** 保存会话到 localStorage */ /** 保存会话到 localStorage */
const persistSession = () => { const persistSession = () => {
const persisted: PersistedTab[] = tabs.value.map(tab => { const persisted: PersistedTab[] = tabs.value.map(tab => {
const hasUnsaved = tab.fileContent && tab.originalContent !== undefined && tab.fileContent !== tab.originalContent const hasUnsaved = isDirty(tab)
// 限制存储大小,超过 100KB 的内容不存入 localStorage // 限制存储大小,超过 100KB 的内容不存入 localStorage
const canSave = hasUnsaved && tab.fileContent.length <= 100_000 const canSave = hasUnsaved && tab.fileContent.length <= 100_000
return { return {
path: tab.fileItem.path, path: tab.fileItem.path,
active: tab.id === activeTabId.value, active: tab.id === activeTabId.value,
profileId: tab.profileId,
unsavedContent: canSave ? tab.fileContent : undefined, unsavedContent: canSave ? tab.fileContent : undefined,
originalContent: canSave ? tab.originalContent : undefined, originalContent: canSave ? tab.originalContent : undefined,
isEditMode: canSave ? tab.isEditMode : undefined isEditMode: canSave ? tab.isEditMode : undefined
@@ -148,7 +158,7 @@ export function useMultiPreview() {
} }
/** 添加或激活 tab返回 { tab, isNew } */ /** 添加或激活 tab返回 { tab, isNew } */
const addTab = (fileItem: FileItem): { tab: PreviewTab; isNew: boolean } => { const addTab = (fileItem: FileItem, profileId?: string): { tab: PreviewTab; isNew: boolean } => {
const id = pathToId(fileItem.path) const id = pathToId(fileItem.path)
const existing = tabs.value.find(t => t.id === id) const existing = tabs.value.find(t => t.id === id)
if (existing) { if (existing) {
@@ -162,7 +172,7 @@ export function useMultiPreview() {
if (victimIdx !== -1) tabs.value.splice(victimIdx, 1) if (victimIdx !== -1) tabs.value.splice(victimIdx, 1)
} }
const tab = createTab(fileItem) const tab = createTab(fileItem, profileId)
tabs.value.push(tab) tabs.value.push(tab)
activeTabId.value = tab.id activeTabId.value = tab.id
return { tab, isNew: true } return { tab, isNew: true }

View File

@@ -10,6 +10,8 @@ import type { PathHistory } from '@/types/file-system'
export interface UsePathNavigationOptions { export interface UsePathNavigationOptions {
onListDirectory?: (path: string) => Promise<void> onListDirectory?: (path: string) => Promise<void>
/** 获取当前 profileId用于历史记录绑定 */
getCurrentProfileId?: () => string | undefined
initialPath?: string initialPath?: string
} }
@@ -20,7 +22,6 @@ const restoreLastPath = (): string | null => {
try { try {
const lastPath = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.FILE_PATH) const lastPath = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.FILE_PATH)
if (lastPath) { if (lastPath) {
// 规范化旧路径(可能包含反斜杠)
return normalizePathSeparators(lastPath) return normalizePathSeparators(lastPath)
} }
return lastPath return lastPath
@@ -42,33 +43,31 @@ const saveLastPath = (path: string) => {
} }
export function usePathNavigation(options: UsePathNavigationOptions = {}) { export function usePathNavigation(options: UsePathNavigationOptions = {}) {
const { onListDirectory, initialPath = '' } = options const { onListDirectory, getCurrentProfileId, initialPath = '' } = options
// 尝试恢复上次的路径,如果没有则使用初始路径
const savedPath = restoreLastPath() const savedPath = restoreLastPath()
const filePath = ref(savedPath || initialPath) const filePath = ref(savedPath || initialPath)
// 历史记录 // 历史记录(每条路径绑定 profileId
const history = ref<PathHistory>({ const history = ref<PathHistory>({
paths: [], paths: [],
profileIds: [],
currentIndex: -1 currentIndex: -1
}) })
/** /**
* 导航到指定路径(带错误处理) * 导航到指定路径
*/ */
const navigate = async (path: string) => { const navigate = async (path: string) => {
if (!path || path === filePath.value) return if (!path || path === filePath.value) return
try { try {
// 路径规范化(处理反斜杠并统一为正斜杠)
const normalizedPath = normalizePathSeparators(path) const normalizedPath = normalizePathSeparators(path)
filePath.value = normalizedPath filePath.value = normalizedPath
// 添加到历史记录 const profileId = getCurrentProfileId?.()
addToHistory(normalizedPath) addToHistory(normalizedPath, profileId)
// 触发目录列出
if (onListDirectory) { if (onListDirectory) {
await onListDirectory(normalizedPath) await onListDirectory(normalizedPath)
} }
@@ -78,32 +77,28 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
} }
} }
/** /** 添加到历史记录 */
* 添加到历史记录 const addToHistory = (path: string, profileId?: string) => {
*/ const { paths, profileIds, currentIndex } = history.value
const addToHistory = (path: string) => {
const { paths, currentIndex } = history.value
// 如果当前不在历史记录末尾,删除当前位置之后的所有记录
if (currentIndex < paths.length - 1) { if (currentIndex < paths.length - 1) {
history.value.paths = paths.slice(0, currentIndex + 1) history.value.paths = paths.slice(0, currentIndex + 1)
history.value.profileIds = profileIds.slice(0, currentIndex + 1)
} }
// 避免重复添加相同路径
const lastPath = history.value.paths[history.value.paths.length - 1] const lastPath = history.value.paths[history.value.paths.length - 1]
if (lastPath !== path) { if (lastPath !== path) {
history.value.paths.push(path) history.value.paths.push(path)
history.value.profileIds.push(profileId)
history.value.currentIndex = history.value.paths.length - 1 history.value.currentIndex = history.value.paths.length - 1
} }
} }
/** /** 后退,返回目标 profileId */
* 后退(带错误处理) const back = async (): Promise<string | undefined> => {
*/
const back = async () => {
const { paths, currentIndex } = history.value const { paths, currentIndex } = history.value
if (currentIndex <= 0) return if (currentIndex <= 0) return undefined
try { try {
const newIndex = currentIndex - 1 const newIndex = currentIndex - 1
@@ -113,19 +108,18 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
if (onListDirectory) { if (onListDirectory) {
await onListDirectory(paths[newIndex]) await onListDirectory(paths[newIndex])
} }
return history.value.profileIds[newIndex]
} catch (error) { } catch (error) {
console.error('后退失败:', error) console.error('后退失败:', error)
throw error throw error
} }
} }
/** /** 前进,返回目标 profileId */
* 前进(带错误处理) const forward = async (): Promise<string | undefined> => {
*/
const forward = async () => {
const { paths, currentIndex } = history.value const { paths, currentIndex } = history.value
if (currentIndex >= paths.length - 1) return if (currentIndex >= paths.length - 1) return undefined
try { try {
const newIndex = currentIndex + 1 const newIndex = currentIndex + 1
@@ -135,45 +129,42 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
if (onListDirectory) { if (onListDirectory) {
await onListDirectory(paths[newIndex]) await onListDirectory(paths[newIndex])
} }
return history.value.profileIds[newIndex]
} catch (error) { } catch (error) {
console.error('前进失败:', error) console.error('前进失败:', error)
throw error throw error
} }
} }
/** /** 获取指定历史索引的 profileId */
* 路径输入选择 const getHistoryProfileId = (index: number): string | undefined => {
*/ return history.value.profileIds[index]
}
/** 通过路径查历史 profileId */
const getProfileIdForPath = (path: string): string | undefined => {
const idx = history.value.paths.indexOf(path)
return idx !== -1 ? history.value.profileIds[idx] : undefined
}
const onPathSelect = (value: string) => { const onPathSelect = (value: string) => {
navigate(value) navigate(value)
} }
/**
* 路径输入回车
*/
const onPathEnter = (value: string) => { const onPathEnter = (value: string) => {
navigate(value) navigate(value)
} }
/**
* 浏览目录(双击或回车)
*/
const browseDirectory = async (path: string) => { const browseDirectory = async (path: string) => {
await navigate(path) await navigate(path)
} }
/**
* 获取父目录路径
*/
const getParentPath = (path: string): string => { const getParentPath = (path: string): string => {
const separator = path.includes('\\') ? '\\' : '/' const separator = path.includes('\\') ? '\\' : '/'
const lastSeparator = path.lastIndexOf(separator) const lastSeparator = path.lastIndexOf(separator)
return lastSeparator > 0 ? path.substring(0, lastSeparator) : path return lastSeparator > 0 ? path.substring(0, lastSeparator) : path
} }
/**
* 上级目录
*/
const goUp = async () => { const goUp = async () => {
const parentPath = getParentPath(filePath.value) const parentPath = getParentPath(filePath.value)
if (parentPath !== filePath.value) { if (parentPath !== filePath.value) {
@@ -181,35 +172,22 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
} }
} }
/**
* 路径规范化(统一为正斜杠)
*/
const normalizePath = (path: string): string => { const normalizePath = (path: string): string => {
return normalizePathSeparators(path) return normalizePathSeparators(path)
} }
/**
* 判断是否可以后退
*/
const canGoBack = computed(() => { const canGoBack = computed(() => {
return history.value.currentIndex > 0 return history.value.currentIndex > 0
}) })
/**
* 判断是否可以前进
*/
const canGoForward = computed(() => { const canGoForward = computed(() => {
return history.value.currentIndex < history.value.paths.length - 1 return history.value.currentIndex < history.value.paths.length - 1
}) })
/**
* 获取历史记录列表(用于自动完成)
*/
const getPathHistory = computed(() => { const getPathHistory = computed(() => {
return history.value.paths.slice().reverse() // 最新的在前 return history.value.paths.slice().reverse()
}) })
// 监听路径变化,自动保存到 localStorage
watch(filePath, (newPath) => { watch(filePath, (newPath) => {
if (newPath) { if (newPath) {
saveLastPath(newPath) saveLastPath(newPath)
@@ -217,31 +195,23 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
}) })
return { return {
// 状态
filePath, filePath,
history, history,
// 导航方法
navigate, navigate,
back, back,
forward, forward,
goUp, goUp,
browseDirectory, browseDirectory,
// 事件处理
onPathSelect, onPathSelect,
onPathEnter, onPathEnter,
// 工具方法
getParentPath, getParentPath,
normalizePath, normalizePath,
// 计算属性
canGoBack, canGoBack,
canGoForward, canGoForward,
getPathHistory getPathHistory,
getHistoryProfileId,
getProfileIdForPath
} }
} }
// 导出类型(用于外部使用)
export type { PathHistory } export type { PathHistory }

View File

@@ -64,6 +64,7 @@
<!-- 文件编辑器面板始终显示无选中文件时为空白预览区 --> <!-- 文件编辑器面板始终显示无选中文件时为空白预览区 -->
<FileEditorPanel <FileEditorPanel
ref="fileEditorPanelRef"
:config="fileEditorPanelConfig" :config="fileEditorPanelConfig"
:width="panelWidth.right" :width="panelWidth.right"
:current-directory="filePath" :current-directory="filePath"
@@ -129,7 +130,7 @@ import ContextMenu from './components/ContextMenu.vue'
import { useFileOperations } from './composables/useFileOperations' import { useFileOperations } from './composables/useFileOperations'
import { useFavorites } from './composables/useFavorites' import { useFavorites } from './composables/useFavorites'
import { usePathNavigation } from './composables/usePathNavigation' import { usePathNavigation } from './composables/usePathNavigation'
import { useFilePreview } from './composables/useFilePreview' import { useFilePreview, resolveFileUrl, resolveFileServerBase } from './composables/useFilePreview'
import { useFileEdit } from './composables/useFileEdit' import { useFileEdit } from './composables/useFileEdit'
import { useCommonPaths } from './composables/useCommonPaths' import { useCommonPaths } from './composables/useCommonPaths'
import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMultiPreview' import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMultiPreview'
@@ -165,6 +166,7 @@ const fileList = ref<FileItem[]>([])
const fileLoading = ref(false) 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 fileEditorPanelRef = ref<InstanceType<typeof FileEditorPanel> | null>(null)
const triggerConnectionDialog = ref(0) const triggerConnectionDialog = ref(0)
const pendingEditProfileId = ref<string | null>(null) const pendingEditProfileId = ref<string | null>(null)
@@ -294,18 +296,17 @@ const fileOps = useFileOperations({
const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin, updateFavoritePath, onLongPressStart, onLongPressCancel, onDragStart, onDragOver, onDrop, onDragEnd } = useFavorites() const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin, updateFavoritePath, onLongPressStart, onLongPressCancel, onDragStart, onDragOver, onDrop, onDragEnd } = useFavorites()
// 路径导航 // 路径导航
const { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath } = const { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath, getProfileIdForPath } =
usePathNavigation({ usePathNavigation({
onListDirectory: async (path) => { onListDirectory: async (path) => {
await loadDirectory(path) await loadDirectory(path)
} },
getCurrentProfileId: () => connectionManager.activeProfile?.id
}) })
// 文件预览 // 文件预览
const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, detectByContent } = const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, detectByContent } =
useFilePreview({ useFilePreview()
filePath
})
// 文件编辑 // 文件编辑
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } = const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
@@ -326,7 +327,7 @@ const hasSelectedFile = computed(() => selectedFileItem.value !== null)
// 工具栏配置 // 工具栏配置
const toolbarConfig = computed(() => ({ const toolbarConfig = computed(() => ({
filePath: filePath.value || '', filePath: filePath.value || '',
pathHistory: history.value.paths.slice(-10), pathHistory: history.value.paths.slice(-50),
commonPaths: commonPaths.value, commonPaths: commonPaths.value,
isBrowsingZip: false, isBrowsingZip: false,
displayPath: '', displayPath: '',
@@ -373,10 +374,8 @@ const computeRendered = computed(() => {
// 设置文件服务器 Base URL // 设置文件服务器 Base URL
const isRemote = connectionManager.isRemote() const isRemote = connectionManager.isRemote()
const base = isRemote const base = resolveFileServerBase()
? (connectionManager.getFileServerBaseURL().replace(/\/$/, '') + '/api/v1/proxy/localfs') setFileServerBase(isRemote ? base : base + '/localfs')
: 'http://localhost:2652/localfs'
setFileServerBase(base)
return marked.parse(content) as string return marked.parse(content) as string
} catch (error) { } catch (error) {
@@ -441,9 +440,12 @@ const handleRefresh = async () => {
await loadDirectory(filePath.value) await loadDirectory(filePath.value)
} }
// 程序化切换 profile 时抑制自动导航(如打开收藏/tab切换
let _suppressAutoNav = false
// 连接切换后重置路径并刷新文件列表 // 连接切换后重置路径并刷新文件列表
connectionManager.onStateChange(async (state) => { connectionManager.onStateChange(async (state) => {
if (state === 'connected') { if (state === 'connected' && !_suppressAutoNav) {
await loadCommonPaths() await loadCommonPaths()
const targetPath = connectionManager.isRemote() ? '/' : 'C:/' const targetPath = connectionManager.isRemote() ? '/' : 'C:/'
filePath.value = targetPath filePath.value = targetPath
@@ -462,6 +464,12 @@ const handleConnectionChanged = async () => {
} }
const handleGoToPath = async (path: string) => { const handleGoToPath = async (path: string) => {
// 历史记录下拉选择时,检查是否需要切换 profile
const targetProfileId = getProfileIdForPath(path)
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
_suppressAutoNav = true
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
}
await navigate(path) await navigate(path)
} }
@@ -478,6 +486,10 @@ const handleOpenFile = async (path: string) => {
if (targetFile.isDir) { if (targetFile.isDir) {
// 是目录,导航进入 // 是目录,导航进入
await navigate(path) await navigate(path)
} else if (isAudioFile(targetFile.name)) {
// 音频文件:加入 BGM 播放列表,不打开 tab
const url = await resolveFileUrl(path, true)
fileEditorPanelRef.value?.playAudioAsBGM(targetFile.name, path, url)
} else { } else {
// 是文件,先加载内容,再更新选中状态(避免闪烁) // 是文件,先加载内容,再更新选中状态(避免闪烁)
await loadFileContent(path) await loadFileContent(path)
@@ -558,29 +570,54 @@ const handleShowMessage = (message: string, type: 'success' | 'error' | 'warning
// 侧边栏事件 // 侧边栏事件
const handleOpenFavorite = async (file: FavoriteFile) => { const handleOpenFavorite = async (file: FavoriteFile) => {
// 根据路径格式自动切换连接Linux 路径 → 远程Windows 路径 → 本地) const currentProfileId = connectionManager.activeProfile?.id
const isLinuxPath = /^[\/]/.test(file.path) && !/^[A-Za-z]:/.test(file.path) const needSwitch = file.profileId && file.profileId !== currentProfileId
const shouldBeRemote = isLinuxPath
const isCurrentlyRemote = connectionManager.isRemote()
if (shouldBeRemote !== isCurrentlyRemote) { if (needSwitch) {
// 需要切换连接 _suppressAutoNav = true
if (shouldBeRemote) { try {
// 切换到远程:找第一个 remote profile await connectionManager.connect(file.profileId)
const remoteProfile = connectionManager.profiles.find(p => p.type === 'remote') await loadCommonPaths()
if (remoteProfile) { } catch (e) {
connectionManager.connect(remoteProfile.id) console.error('切换连接失败:', e)
} } finally {
} else { _suppressAutoNav = false
// 切换到本地 }
connectionManager.disconnect() } else if (!file.profileId) {
// 无 profileId旧数据按路径格式自动切换连接
const isLinuxPath = /^[\/]/.test(file.path) && !/^[A-Za-z]:/.test(file.path)
const shouldBeRemote = isLinuxPath
const isCurrentlyRemote = connectionManager.isRemote()
if (shouldBeRemote !== isCurrentlyRemote) {
_suppressAutoNav = true
try {
if (shouldBeRemote) {
const remoteProfile = connectionManager.profiles.find(p =>
p.type === 'remote' || p.type === 'sftp' || p.type === 'oss'
)
if (remoteProfile) {
await connectionManager.connect(remoteProfile.id)
}
} else {
// disconnect() 也会触发 onStateChange需要 suppress
connectionManager.disconnect()
}
await loadCommonPaths()
} finally {
_suppressAutoNav = false
}
} }
await loadCommonPaths()
} }
if (file.isDir) { if (file.isDir) {
await navigate(file.path) await navigate(file.path)
} else { } else {
// 先导航到父目录,再选中文件
const parentPath = file.path.substring(0, Math.max(file.path.lastIndexOf('/'), file.path.lastIndexOf('\\')))
if (parentPath && parentPath !== filePath.value) {
await navigate(parentPath)
}
await selectFile(file.path) await selectFile(file.path)
} }
} }
@@ -621,6 +658,9 @@ const handleDragEnd = () => {
const handleFileClick = async (file: FileItem) => { const handleFileClick = async (file: FileItem) => {
if (file.isDir) { if (file.isDir) {
await navigate(file.path) await navigate(file.path)
} else if (isAudioFile(file.name)) {
const url = await resolveFileUrl(file.path, true)
fileEditorPanelRef.value?.playAudioAsBGM(file.name, file.path, url)
} else { } else {
openFileAsTab(file) openFileAsTab(file)
} }
@@ -1065,19 +1105,33 @@ const isMediaPreviewable = (filename: string): boolean => {
/** 激活 tab设置选中项 + 加载或恢复内容 */ /** 激活 tab设置选中项 + 加载或恢复内容 */
const activateTab = async (tab: PreviewTab) => { const activateTab = async (tab: PreviewTab) => {
// 如果 tab 属于不同 profile自动切换连接
if (tab.profileId && tab.profileId !== connectionManager.activeProfile?.id) {
_suppressAutoNav = true
try {
await connectionManager.connect(tab.profileId)
} catch (e) {
console.error('切换连接失败:', e)
} finally {
_suppressAutoNav = false
}
}
selectedFileItem.value = tab.fileItem selectedFileItem.value = tab.fileItem
if (tab.loaded) { if (tab.loaded) {
restoreTabState(tab) restoreTabState(tab)
} else { } else {
await loadFileContent(tab.fileItem.path) await loadFileContent(tab.fileItem.path)
tab.loaded = true tab.loaded = true
// 首次加载完成后确保 dirty 状态正确(加载过程中 fileContent/originalContent 可能不同步)
tab.originalContent = fileContent.value
} }
} }
/** 文件 → 添加到 tab 并激活 */ /** 文件 → 添加到 tab 并激活 */
const openFileAsTab = async (file: FileItem) => { const openFileAsTab = async (file: FileItem) => {
cacheCurrentTabState() cacheCurrentTabState()
const { tab } = multiPreview.addTab(file) const currentProfileId = connectionManager.activeProfile?.id
const { tab } = multiPreview.addTab(file, currentProfileId)
await activateTab(tab) await activateTab(tab)
} }
@@ -1382,10 +1436,20 @@ onMounted(async () => {
// 恢复多文件预览会话 // 恢复多文件预览会话
const session = multiPreview.restoreSession() const session = multiPreview.restoreSession()
if (session.paths.length > 0 && !connectionManager.isRemote()) { if (session.paths.length > 0) {
// 找到激活 tab 的 profileId先切换到该连接
const activeId = session.activePath
? session.profileMap.get(session.activePath.replace(/\\/g, '/').toLowerCase())
: undefined
if (activeId && activeId !== connectionManager.activeProfile?.id) {
_suppressAutoNav = true
try { await connectionManager.connect(activeId) } catch (e) { console.error('session恢复连接失败:', e) } finally { _suppressAutoNav = false }
}
for (const path of session.paths) { for (const path of session.paths) {
const name = path.split(/[/\\]/).pop() || path const name = path.split(/[/\\]/).pop() || path
const { tab } = multiPreview.addTab({ path, name, isDir: false, size: 0, modified_time: '' }) const tabProfileId = session.profileMap.get(path.replace(/\\/g, '/').toLowerCase())
const { tab } = multiPreview.addTab({ path, name, isDir: false, size: 0, modified_time: '' }, tabProfileId)
if (path === session.activePath || path.replace(/\\/g, '/').toLowerCase() === (session.activePath || '').replace(/\\/g, '/').toLowerCase()) { if (path === session.activePath || path.replace(/\\/g, '/').toLowerCase() === (session.activePath || '').replace(/\\/g, '/').toLowerCase()) {
multiPreview.activeTabId.value = tab.id multiPreview.activeTabId.value = tab.id
} }
@@ -1499,6 +1563,13 @@ const handleKeyDown = async (event: KeyboardEvent) => {
handleToggleEditMode() handleToggleEditMode()
return return
} }
// Ctrl+Shift+B 切换 BGM 播放条显隐
if (driveLetter === 'B') {
event.preventDefault()
fileEditorPanelRef.value?.toggleBgmVisibility()
return
}
} }
// Ctrl+S 保存 // Ctrl+S 保存
@@ -1541,7 +1612,11 @@ const handleKeyDown = async (event: KeyboardEvent) => {
event.preventDefault() event.preventDefault()
isNavigating.value = true isNavigating.value = true
try { try {
await back() const targetProfileId = await back()
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
_suppressAutoNav = true
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
}
} finally { } finally {
isNavigating.value = false isNavigating.value = false
} }
@@ -1553,7 +1628,11 @@ const handleKeyDown = async (event: KeyboardEvent) => {
event.preventDefault() event.preventDefault()
isNavigating.value = true isNavigating.value = true
try { try {
await forward() const targetProfileId = await forward()
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
_suppressAutoNav = true
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
}
} finally { } finally {
isNavigating.value = false isNavigating.value = false
} }

View File

@@ -8,7 +8,7 @@
> >
<a-tabs default-active-key="tab-config"> <a-tabs default-active-key="tab-config">
<!-- Tab 配置 --> <!-- Tab 配置 -->
<a-tab-pane key="tab-config" title="Tab 配置"> <a-tab-pane key="tab-config" title="功能配置">
<a-space direction="vertical" style="width: 100%" :size="16"> <a-space direction="vertical" style="width: 100%" :size="16">
<!-- 说明文字 --> <!-- 说明文字 -->
@@ -76,6 +76,47 @@
至少需要保留一个可见的 Tab 至少需要保留一个可见的 Tab
</a-alert> </a-alert>
<!-- 侧边栏区块配置 -->
<a-divider>侧边栏区块</a-divider>
<a-alert type="info" :show-icon="true">
控制左侧边栏各区块的显示和顺序
</a-alert>
<div class="tab-config-list">
<div
v-for="(sectionKey, index) in localConfig.sidebarSections"
:key="sectionKey"
class="tab-config-item"
draggable="true"
@dragstart="handleSidebarDragStart(index, $event)"
@dragover.prevent="handleSidebarDragOver(index, $event)"
@drop="handleSidebarDrop(index, $event)"
@dragend="handleSidebarDragEnd"
>
<icon-drag-arrow class="drag-handle" />
<a-checkbox
:model-value="true"
@change="(value) => handleSidebarVisibilityChange(sectionKey, value)"
/>
<span class="tab-title">{{ getSidebarTitle(sectionKey) }}</span>
</div>
<template v-if="hiddenSidebarSections.length > 0">
<a-divider>已隐藏</a-divider>
<div
v-for="sectionKey in hiddenSidebarSections"
:key="'hidden-' + sectionKey"
class="tab-config-item hidden"
>
<icon-drag-arrow class="drag-handle disabled" />
<a-checkbox
:model-value="false"
@change="(value) => handleSidebarVisibilityChange(sectionKey, value)"
/>
<span class="tab-title">{{ getSidebarTitle(sectionKey) }}</span>
<span class="hidden-tag">已隐藏</span>
</div>
</template>
</div>
<!-- 保存按钮 --> <!-- 保存按钮 -->
<a-space> <a-space>
<a-button type="primary" @click="handleSave" :loading="saving"> <a-button type="primary" @click="handleSave" :loading="saving">
@@ -133,7 +174,8 @@ const visible = computed({
const localConfig = ref({ const localConfig = ref({
tabs: [], tabs: [],
visibleTabs: [], visibleTabs: [],
defaultTab: '' defaultTab: '',
sidebarSections: []
}) })
const saving = ref(false) const saving = ref(false)
@@ -155,7 +197,8 @@ watch(() => props.config, (newConfig) => {
localConfig.value = { localConfig.value = {
tabs: [...newConfig.tabs], tabs: [...newConfig.tabs],
visibleTabs: [...newConfig.visibleTabs], visibleTabs: [...newConfig.visibleTabs],
defaultTab: newConfig.defaultTab defaultTab: newConfig.defaultTab,
sidebarSections: [...(newConfig.sidebarSections || ['server', 'favorites', 'help'])]
} }
} }
}, { immediate: true, deep: true }) }, { immediate: true, deep: true })
@@ -266,19 +309,13 @@ const handleSave = async () => {
const configToSave = { const configToSave = {
tabs: syncedTabs, tabs: syncedTabs,
visibleTabs: [...localConfig.value.visibleTabs], visibleTabs: [...localConfig.value.visibleTabs],
defaultTab: localConfig.value.defaultTab defaultTab: localConfig.value.defaultTab,
sidebarSections: [...localConfig.value.sidebarSections]
} }
saving.value = true saving.value = true
emit('save', configToSave)
try { saving.value = false
await emit('save', configToSave)
} catch (error) {
console.error('保存配置失败:', error)
Message.error('保存配置失败:' + (error.message || error))
} finally {
saving.value = false
}
} }
// 重置配置 // 重置配置
@@ -287,11 +324,59 @@ const handleReset = () => {
localConfig.value = { localConfig.value = {
tabs: [...props.config.tabs], tabs: [...props.config.tabs],
visibleTabs: [...props.config.visibleTabs], visibleTabs: [...props.config.visibleTabs],
defaultTab: props.config.defaultTab defaultTab: props.config.defaultTab,
sidebarSections: [...(props.config.sidebarSections || ['server', 'favorites', 'help'])]
} }
} }
} }
// ========== 侧边栏区块配置 ==========
const allSidebarSections = ['server', 'favorites', 'help']
const sidebarTitles = { server: '🖥️ 服务器', favorites: '⭐ 收藏夹', help: '📖 帮助' }
const getSidebarTitle = (key) => sidebarTitles[key] || key
const hiddenSidebarSections = computed(() => {
return allSidebarSections.filter(s => !localConfig.value.sidebarSections.includes(s))
})
const handleSidebarVisibilityChange = (sectionKey, visible) => {
if (visible) {
if (!localConfig.value.sidebarSections.includes(sectionKey)) {
localConfig.value.sidebarSections.push(sectionKey)
}
} else {
localConfig.value.sidebarSections = localConfig.value.sidebarSections.filter(s => s !== sectionKey)
}
}
const sidebarDraggedIndex = ref(null)
const handleSidebarDragStart = (index, event) => {
sidebarDraggedIndex.value = index
event.dataTransfer.effectAllowed = 'move'
event.target.classList.add('dragging')
}
const handleSidebarDragOver = (index, event) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}
const handleSidebarDrop = (index, event) => {
event.preventDefault()
if (sidebarDraggedIndex.value === null || sidebarDraggedIndex.value === index) return
const list = [...localConfig.value.sidebarSections]
const [removed] = list.splice(sidebarDraggedIndex.value, 1)
list.splice(index, 0, removed)
localConfig.value.sidebarSections = list
}
const handleSidebarDragEnd = (event) => {
event.target.classList.remove('dragging')
sidebarDraggedIndex.value = null
}
// 打开版本历史 // 打开版本历史
const handleOpenVersionHistory = () => { const handleOpenVersionHistory = () => {
emit('open-version-history') emit('open-version-history')

View File

@@ -20,6 +20,7 @@ export interface AppConfig {
tabs: TabConfig[] tabs: TabConfig[]
visibleTabs: string[] visibleTabs: string[]
defaultTab: string defaultTab: string
sidebarSections: string[]
} }
/** /**
@@ -31,7 +32,8 @@ export const useConfigStore = defineStore('config', () => {
const appConfig = ref<AppConfig>({ const appConfig = ref<AppConfig>({
tabs: [], tabs: [],
visibleTabs: [], visibleTabs: [],
defaultTab: 'file-system' defaultTab: 'file-system',
sidebarSections: ['server', 'favorites', 'help']
}) })
const loading = ref(false) const loading = ref(false)
@@ -79,7 +81,7 @@ export const useConfigStore = defineStore('config', () => {
const result = await GetAppConfig() const result = await GetAppConfig()
if (!result.success) throw new Error(result.message) if (!result.success) throw new Error(result.message)
const { tabs = [], visibleTabs = [], defaultTab = 'file-system' } = result.data const { tabs = [], visibleTabs = [], defaultTab = 'file-system', sidebarSections } = result.data
// 一级 Tab 只有文件管理和数据库其他功能Markdown、版本历史不作为独立 Tab // 一级 Tab 只有文件管理和数据库其他功能Markdown、版本历史不作为独立 Tab
const allKeys = ['file-system'] const allKeys = ['file-system']
@@ -89,10 +91,16 @@ export const useConfigStore = defineStore('config', () => {
? visibleTabs.filter(k => allKeys.includes(k)) ? visibleTabs.filter(k => allKeys.includes(k))
: allKeys : allKeys
const defaultSidebar = ['server', 'favorites', 'help']
const validSections = ['server', 'favorites', 'help']
appConfig.value = { appConfig.value = {
tabs: mergedTabs.map(tab => ({ ...tab, visible: mergedVisible.includes(tab.key) })), tabs: mergedTabs.map(tab => ({ ...tab, visible: mergedVisible.includes(tab.key) })),
visibleTabs: mergedVisible, visibleTabs: mergedVisible,
defaultTab: defaultTab || 'file-system' defaultTab: defaultTab || 'file-system',
sidebarSections: Array.isArray(sidebarSections)
? sidebarSections.filter((s: string) => validSections.includes(s))
: defaultSidebar
} }
} catch (error) { } catch (error) {
console.error('加载配置失败:', error) console.error('加载配置失败:', error)
@@ -111,7 +119,8 @@ export const useConfigStore = defineStore('config', () => {
{ key: 'file-system', title: '文件管理', visible: true, enabled: true } { key: 'file-system', title: '文件管理', visible: true, enabled: true }
], ],
visibleTabs: ['file-system'], visibleTabs: ['file-system'],
defaultTab: 'file-system' defaultTab: 'file-system',
sidebarSections: ['server', 'favorites', 'help']
} }
} }
@@ -125,7 +134,8 @@ export const useConfigStore = defineStore('config', () => {
const result = await SaveAppConfig({ const result = await SaveAppConfig({
tabs: config.tabs, tabs: config.tabs,
visibleTabs: config.visibleTabs, visibleTabs: config.visibleTabs,
defaultTab: config.defaultTab defaultTab: config.defaultTab,
sidebarSections: config.sidebarSections
}) })
if (!result.success) { if (!result.success) {
@@ -137,7 +147,8 @@ export const useConfigStore = defineStore('config', () => {
appConfig.value = { appConfig.value = {
tabs: [...config.tabs], tabs: [...config.tabs],
visibleTabs: [...config.visibleTabs], visibleTabs: [...config.visibleTabs],
defaultTab: config.defaultTab defaultTab: config.defaultTab,
sidebarSections: [...config.sidebarSections]
} }
Message.success('配置保存成功') Message.success('配置保存成功')

View File

@@ -31,6 +31,8 @@ export interface FavoriteFile extends FileItem {
addedAt: number addedAt: number
/** 置顶时间时间戳undefined 表示未置顶 */ /** 置顶时间时间戳undefined 表示未置顶 */
pinnedAt?: number pinnedAt?: number
/** 关联的连接配置 ID用于打开收藏时自动切换连接 */
profileId?: string
} }
/** /**
@@ -248,6 +250,8 @@ export interface FileOperationResult {
export interface PathHistory { export interface PathHistory {
/** 历史记录数组 */ /** 历史记录数组 */
paths: string[] paths: string[]
/** 每条路径对应的 profileId */
profileIds: (string | undefined)[]
/** 当前索引 */ /** 当前索引 */
currentIndex: number currentIndex: number
} }

View File

@@ -0,0 +1,48 @@
export interface LrcLine {
time: number
text: string
}
export interface LrcData {
title?: string
artist?: string
lines: LrcLine[]
}
export function parseLrc(content: string): LrcData {
const result: LrcData = { lines: [] }
const lines = content.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
// 元信息
const metaTitle = trimmed.match(/^\[ti:(.*?)\]$/i)
if (metaTitle) { result.title = metaTitle[1].trim(); continue }
const metaArtist = trimmed.match(/^\[ar:(.*?)\]$/i)
if (metaArtist) { result.artist = metaArtist[1].trim(); continue }
// 时间标签
const timeTags: number[] = []
let text = trimmed
const tagRegex = /\[(\d{2}):(\d{2})([.:])(\d{2,3})\]/g
let m
while ((m = tagRegex.exec(trimmed)) !== null) {
const min = parseInt(m[1])
const sec = parseInt(m[2])
const ms = parseInt(m[4].padEnd(3, '0'))
timeTags.push(min * 60 + sec + ms / 1000)
}
if (timeTags.length === 0) continue
text = trimmed.replace(/\[\d{2}:\d{2}[.:]\d{2,3}\]/g, '').trim()
for (const t of timeTags) {
result.lines.push({ time: t, text })
}
}
result.lines.sort((a, b) => a.time - b.time)
return result
}

View File

@@ -45,9 +45,8 @@ var (
es6DynamicImport = regexp.MustCompile(`import\s*\(\s*["']([^"']+)["']\s*\)`) es6DynamicImport = regexp.MustCompile(`import\s*\(\s*["']([^"']+)["']\s*\)`)
es6BareImport = regexp.MustCompile(`(?m)^\s*import\s+["']([^"']+)["']`) es6BareImport = regexp.MustCompile(`(?m)^\s*import\s+["']([^"']+)["']`)
// HTML 预览路径修复 // Windows 盘符检测
locationPathRegex = regexp.MustCompile(`\blocation\.pathname\b`) winDriveRegex = regexp.MustCompile(`^[A-Za-z]:`)
winDriveRegex = regexp.MustCompile(`^[A-Za-z]:`)
) )
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译) // HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
@@ -78,6 +77,8 @@ func validateFilePath(rawPath string, logPrefix string) (string, error) {
clean = strings.TrimPrefix(clean, "/localfs/") clean = strings.TrimPrefix(clean, "/localfs/")
clean = strings.TrimPrefix(clean, "localfs/") clean = strings.TrimPrefix(clean, "localfs/")
} }
// 清理残留的前导斜杠(避免 /u-res/... 类路径在 Windows 上异常)
clean = strings.TrimLeft(clean, "/")
// 平台适配Windows 用反斜杠Linux/macOS 保持正斜杠 // 平台适配Windows 用反斜杠Linux/macOS 保持正斜杠
filePath := filepath.FromSlash(clean) filePath := filepath.FromSlash(clean)
@@ -304,7 +305,12 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
// 设置响应头 // 设置响应头
contentType := getContentType(ext) contentType := getContentType(ext)
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=3600") // 媒体文件禁用缓存(避免 Chromium ERR_CACHE_OPERATION_NOT_SUPPORTED
if isMediaExt(ext) {
w.Header().Set("Cache-Control", "no-store")
} else {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
// 支持 Range 请求 // 支持 Range 请求
w.Header().Set("Accept-Ranges", "bytes") w.Header().Set("Accept-Ranges", "bytes")
@@ -365,6 +371,16 @@ func isAllowedFileType(ext string) bool {
return defaultFileTypeManager.IsAllowed(ext) return defaultFileTypeManager.IsAllowed(ext)
} }
// isMediaExt 判断是否为音频/视频扩展名
func isMediaExt(ext string) bool {
switch ext {
case ".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma",
".mp4", ".webm", ".mkv", ".avi", ".mov", ".wmv":
return true
}
return false
}
// Shutdown 优雅关闭文件服务器 // Shutdown 优雅关闭文件服务器
func (lfs *LocalFileServer) Shutdown() error { func (lfs *LocalFileServer) Shutdown() error {
if lfs == nil || lfs.server == nil { if lfs == nil || lfs.server == nil {
@@ -556,11 +572,8 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
// 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL // 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL
processedContent := transformHtmlResourcePaths(string(content), baseDir) processedContent := transformHtmlResourcePaths(string(content), baseDir)
// 修复 JS 中基于 location.pathname 的相对路径计算 // 注入路径拦截脚本(处理 webpack 等动态加载的绝对路径资源)
// 预览模式下 location.pathname = "/localfs/html-preview",与实际文件路径不一致 processedContent = injectPathInterceptor(processedContent, baseDir)
// ⚠️ 会替换所有出现位置含JS字符串内HTML预览场景下可接受
correctPathname := `"/localfs/` + strings.ReplaceAll(baseDir, "\\", "/") + `/`
processedContent = locationPathRegex.ReplaceAllString(processedContent, correctPathname)
// 注入链接点击拦截脚本 // 注入链接点击拦截脚本
finalContent := injectLinkInterceptor(processedContent) finalContent := injectLinkInterceptor(processedContent)
@@ -870,3 +883,38 @@ func injectLinkInterceptor(htmlContent string) string {
// 没有 body 标签,在末尾插入 // 没有 body 标签,在末尾插入
return htmlContent + script return htmlContent + script
} }
// injectPathInterceptor 注入路径拦截脚本(处理 webpack 等动态加载的绝对路径资源)
// 重写动态创建的 <script src="/..."> 和 <link href="/..."> 为 /localfs/ 前缀路径
func injectPathInterceptor(htmlContent string, baseDir string) string {
// 直接使用 baseDirHTML 所在目录)作为 base与 transformHtmlResourcePaths 的路径解析一致
base := toLocalServerUrl(strings.ReplaceAll(baseDir, "\\", "/"))
script := `<script data-udesk-intercept="true">
(function(){
var base = "` + base + `/";
function rw(v){if(typeof v!=='string')return v;if(v[0]==='/'&&!v.startsWith('/localfs/')&&!v.startsWith('//')&&!v.startsWith('http'))return base+v.substring(1);return v;}
var sa=Element.prototype.setAttribute;
Element.prototype.setAttribute=function(n,v){if((n==='src'||n==='href'||n==='data'||n==='poster')&&typeof v==='string')v=rw(v);return sa.call(this,n,v);};
try{var d=Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype,'src');Object.defineProperty(HTMLScriptElement.prototype,'src',{set:function(v){d.set.call(this,rw(v))},get:d.get,configurable:true});}catch(e){}
try{var d2=Object.getOwnPropertyDescriptor(HTMLLinkElement.prototype,'href');Object.defineProperty(HTMLLinkElement.prototype,'href',{set:function(v){d2.set.call(this,rw(v))},get:d2.get,configurable:true});}catch(e){}
})();
</script>`
// 在 <head> 后立即插入(确保在任何其他脚本之前执行)
if idx := strings.Index(htmlContent, "<head>"); idx >= 0 {
return htmlContent[:idx+6] + script + htmlContent[idx+6:]
}
if idx := strings.Index(htmlContent, "<HEAD>"); idx >= 0 {
return htmlContent[:idx+6] + script + htmlContent[idx+6:]
}
// 没有 head 标签,在 <!DOCTYPE> 和 <html> 后插入
if idx := strings.Index(htmlContent, "<html"); idx >= 0 {
end := strings.Index(htmlContent[idx:], ">")
if end >= 0 {
pos := idx + end + 1
return htmlContent[:pos] + script + htmlContent[pos:]
}
}
return script + htmlContent
}

41
internal/hotkey/hotkey.go Normal file
View File

@@ -0,0 +1,41 @@
package hotkey
import (
"fmt"
"syscall"
)
var (
moduser32 = syscall.NewLazyDLL("user32.dll")
procRegisterHotKey = moduser32.NewProc("RegisterHotKey")
procUnregisterHotKey = moduser32.NewProc("UnregisterHotKey")
procPostMessage = moduser32.NewProc("PostMessageW")
)
const (
ModAlt = 0x0001
ModControl = 0x0002
ModShift = 0x0004
ModWin = 0x0008
WM_HOTKEY = 0x0312
WM_APP_HOTKEY = 0x8001 // 自定义消息:在主线程触发热键注册
)
func Register(hwnd uintptr, id int32, modifiers uint32, vk uint32) error {
ret, _, _ := procRegisterHotKey.Call(hwnd, uintptr(id), uintptr(modifiers), uintptr(vk))
if ret == 0 {
return fmt.Errorf("RegisterHotKey failed for id=%d", id)
}
return nil
}
func Unregister(hwnd uintptr, id int32) bool {
ret, _, _ := procUnregisterHotKey.Call(hwnd, uintptr(id))
return ret != 0
}
// PostMessage 向窗口投递异步消息(用于跨线程调度到主线程)
func PostMessage(hwnd uintptr, msg uint32, wParam, lParam uintptr) {
procPostMessage.Call(hwnd, uintptr(msg), wParam, lParam)
}

View File

@@ -99,6 +99,55 @@ func (c *Client) GetBucketDomains(ctx context.Context) ([]string, error) {
return domains, nil return domains, nil
} }
// GetBucketRegion 查询桶的真实区域
// API: POST https://uc.qbox.me/v2/buckets → 遍历匹配桶名获取 region
func (c *Client) GetBucketRegion(ctx context.Context) (string, error) {
// 使用 UC API 获取所有桶列表(含 region
req, err := http.NewRequestWithContext(ctx, "POST", "https://uc.qbox.me/v2/buckets", nil)
if err != nil {
return "", oss.NewError("BUCKET_ERROR", "failed to create request", err)
}
path := "/v2/buckets"
host := "uc.qbox.me"
authToken := c.generateAuthTokenWithQuery("POST", path, "", host, "application/x-www-form-urlencoded", nil)
req.Header.Set("Host", host)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", authToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", oss.NewError("BUCKET_ERROR", "failed to query bucket region", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", oss.NewError("BUCKET_ERROR", "failed to read response", err)
}
if resp.StatusCode != 200 {
return "", oss.NewError("BUCKET_ERROR",
fmt.Sprintf("query bucket region failed with status %d: %s", resp.StatusCode, string(body)), nil)
}
var buckets []struct {
ID string `json:"id"`
Region string `json:"region"`
}
if err := json.Unmarshal(body, &buckets); err != nil {
return "", oss.NewError("BUCKET_ERROR", "failed to parse response", err)
}
for _, b := range buckets {
if b.ID == c.config.Bucket {
return b.Region, nil
}
}
return "", oss.NewError("BUCKET_ERROR", fmt.Sprintf("bucket %s not found in account", c.config.Bucket), nil)
}
// SetBucketAccess 设置空间访问权限(公开/私有) // SetBucketAccess 设置空间访问权限(公开/私有)
// 根据: https://developer.qiniu.com/kodo/api/3946/set-bucket-private // 根据: https://developer.qiniu.com/kodo/api/3946/set-bucket-private
// //

View File

@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -18,12 +19,12 @@ import (
// Config 七牛云配置 // Config 七牛云配置
type Config struct { type Config struct {
AccessKey string // 访问密钥 AccessKey string // 访问密钥
SecretKey string // 秘钥 SecretKey string // 秘钥
Bucket string // 存储空间名称 Bucket string // 存储空间名称
Region string // 区域 z0=华东, as0=亚太0区 Region string // 区域 z0=华东, z2=华南, as0=亚太0区
UseHTTPS bool // 是否使用 HTTPS UseHTTPS bool // 是否使用 HTTPS
UploadDomain string // 上传域名(可选,默认根据 Region 自动选择 DownloadDomain string // 缓存的下载域名(由 resolveDownloadDomain 自动设置
} }
// Client 七牛云客户端 // Client 七牛云客户端
@@ -61,84 +62,31 @@ func NewClient(config *Config) (*Client, error) {
}, nil }, nil
} }
// generateSignature 生成七牛云管理凭证签名
// 根据官方文档https://developer.qiniu.com/kodo/1201/access-token
func (c *Client) generateSignature(method, path, host, contentType string, body []byte) string {
// 七牛云管理凭证签名格式:
// signingStr = Method + " " + Path + "\nHost: " + Host + "\n" + [Content-Type] + "\n\n" + [body]
var signingStr string
// 1. Method + " " + Path
signingStr = method + " " + path
// 2. Host header
signingStr += "\nHost: " + host
// 3. Content-Type header (如果设置了)
if contentType != "" {
signingStr += "\nContent-Type: " + contentType
}
// 4. 两个连续换行符
signingStr += "\n\n"
// 5. Body (如果设置了 Content-Type 且不是 application/octet-stream)
if contentType != "" && contentType != "application/octet-stream" && len(body) > 0 {
signingStr += string(body)
}
// 使用 HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(signingStr))
// Base64 URL 安全编码
signature := base64.URLEncoding.EncodeToString(h.Sum(nil))
return signature
}
// generateAuthToken 生成管理认证 Token // generateAuthToken 生成管理认证 Token
func (c *Client) generateAuthToken(method, path, host, contentType string, body []byte) string { func (c *Client) generateAuthToken(method, path, host, contentType string, body []byte) string {
signature := c.generateSignature(method, path, host, contentType, body) return c.generateAuthTokenWithQuery(method, path, "", host, contentType, body)
return "Qiniu " + c.config.AccessKey + ":" + signature
} }
// generateAuthTokenWithQuery 生成管理认证 Token支持 query string // generateAuthTokenWithQuery 生成管理认证 Token支持 query string
// https://developer.qiniu.com/kodo/1201/access-token
func (c *Client) generateAuthTokenWithQuery(method, path, query, host, contentType string, body []byte) string { func (c *Client) generateAuthTokenWithQuery(method, path, query, host, contentType string, body []byte) string {
// 七牛云管理凭证签名格式:
// 如果 query 为非空字符串: signingStr = Method + " " + Path + "?" + query + "\nHost: " + Host + ...
// 如果 query 为空: signingStr = Method + " " + Path + "\nHost: " + Host + ...
var signingStr string var signingStr string
// 1. Method + " " + Path
signingStr = method + " " + path signingStr = method + " " + path
// 2. Query string (如果有)
if query != "" { if query != "" {
signingStr += "?" + query signingStr += "?" + query
} }
// 3. Host header
signingStr += "\nHost: " + host signingStr += "\nHost: " + host
// 4. Content-Type header (如果设置了)
if contentType != "" { if contentType != "" {
signingStr += "\nContent-Type: " + contentType signingStr += "\nContent-Type: " + contentType
} }
// 5. 两个连续换行符
signingStr += "\n\n" signingStr += "\n\n"
// 6. Body (如果设置了 Content-Type 且不是 application/octet-stream)
if contentType != "" && contentType != "application/octet-stream" && len(body) > 0 { if contentType != "" && contentType != "application/octet-stream" && len(body) > 0 {
signingStr += string(body) signingStr += string(body)
} }
// 使用 HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey)) h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(signingStr)) h.Write([]byte(signingStr))
// Base64 URL 安全编码
signature := base64.URLEncoding.EncodeToString(h.Sum(nil)) signature := base64.URLEncoding.EncodeToString(h.Sum(nil))
return "Qiniu " + c.config.AccessKey + ":" + signature return "Qiniu " + c.config.AccessKey + ":" + signature
@@ -152,12 +100,11 @@ func (c *Client) encodeEntry(key string) string {
// getUploadDomain 获取上传域名 // getUploadDomain 获取上传域名
func (c *Client) getUploadDomain() string { func (c *Client) getUploadDomain() string {
// 如果配置了自定义上传域名,使用自定义的 if c.config.DownloadDomain != "" {
if c.config.UploadDomain != "" {
if c.config.UseHTTPS { if c.config.UseHTTPS {
return "https://" + c.config.UploadDomain return "https://" + c.config.DownloadDomain
} }
return "http://" + c.config.UploadDomain return "http://" + c.config.DownloadDomain
} }
// 根据区域选择默认上传域名 // 根据区域选择默认上传域名
@@ -264,85 +211,169 @@ func (c *Client) Upload(ctx context.Context, key string, reader io.Reader, optio
return uploadClient.Upload(ctx, key, reader) return uploadClient.Upload(ctx, key, reader)
} }
// generateUploadToken 生成上传凭证 // generateToken 生成上传凭证
func (c *Client) generateUploadToken(key string) string { func (c *Client) generateToken(scope string) string {
// 七牛云上传凭证的生成 putPolicy := fmt.Sprintf(`{"scope":"%s","deadline":%d}`, scope, time.Now().Add(1*time.Hour).Unix())
// 1. 创建 putPolicy encoded := base64.URLEncoding.EncodeToString([]byte(putPolicy))
putPolicy := fmt.Sprintf(`{"scope":"%s:%s","deadline":%d}`,
c.config.Bucket, key, time.Now().Add(1*time.Hour).Unix())
// 2. 对 putPolicy 进行 base64 URL 编码
encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy))
// 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey)) h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(encodedPutPolicy)) h.Write([]byte(encoded))
encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil)) sign := base64.URLEncoding.EncodeToString(h.Sum(nil))
return c.config.AccessKey + ":" + sign + ":" + encoded
// 4. 组合 token }
return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy
func (c *Client) generateUploadToken(key string) string {
return c.generateToken(c.config.Bucket + ":" + key)
} }
// generateBucketToken 生成 bucket 级别的上传凭证(用于分片上传 v2
func (c *Client) generateBucketToken() string { func (c *Client) generateBucketToken() string {
// 分片上传 v2 需要 bucket 级别的 token return c.generateToken(c.config.Bucket)
// 1. 创建 putPolicy }
putPolicy := fmt.Sprintf(`{"scope":"%s","deadline":%d}`,
c.config.Bucket, time.Now().Add(1*time.Hour).Unix())
// 2. 对 putPolicy 进行 base64 URL 编码 // 七牛云临时域名后缀(平台分配的 CDN 域名,稳定性高)
encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy)) var qiniuTempSuffixes = []string{
".qiniudns.com", ".clouddn.com", ".qbox.me",
".qnssl.com", ".qnybgz.cn", ".qiniudns.com.cn",
}
// 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名 // extractHost 从 URL 提取主机名(去掉 scheme、path、port
h := hmac.New(sha1.New, []byte(c.config.SecretKey)) func extractHost(domainURL string) string {
h.Write([]byte(encodedPutPolicy)) host := strings.TrimPrefix(domainURL, "http://")
encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil)) host = strings.TrimPrefix(host, "https://")
if idx := strings.Index(host, "/"); idx >= 0 {
host = host[:idx]
}
if h, _, err := net.SplitHostPort(host); err == nil {
return h
}
return host
}
// 4. 组合 token // isTempDomain 判断是否为七牛平台分配的临时域名(后缀匹配)
return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy func (c *Client) isTempDomain(domain string) bool {
host := strings.ToLower(extractHost(domain))
for _, s := range qiniuTempSuffixes {
if strings.HasSuffix(host, s) {
return true
}
}
return false
}
// classifyDomains 将域名列表分为临时域名和自定义域名
func (c *Client) classifyDomains(domains []string) (tempDomains, customDomains []string) {
for _, d := range domains {
if !strings.HasPrefix(d, "http://") && !strings.HasPrefix(d, "https://") {
d = "http://" + d
}
if c.isTempDomain(d) {
tempDomains = append(tempDomains, d)
} else {
customDomains = append(customDomains, d)
}
}
return
} }
// resolveDownloadDomain 解析并缓存下载域名 // resolveDownloadDomain 解析并缓存下载域名
// 策略API 域名列表(临时优先→自定义)→ 兜底默认 CDN
// 不做 HTTP 探测Download 使用签名 URL即使有防盗链也能通过
func (c *Client) resolveDownloadDomain() (string, error) { func (c *Client) resolveDownloadDomain() (string, error) {
if c.config.UploadDomain != "" { if c.config.DownloadDomain != "" {
return c.config.UploadDomain, nil return c.config.DownloadDomain, nil
} }
domains, err := c.GetBucketDomains(context.Background()) domains, err := c.GetBucketDomains(context.Background())
if err != nil || len(domains) == 0 {
return "", fmt.Errorf("无法获取桶 %s 的下载域名: %v", c.config.Bucket, err) if err == nil && len(domains) > 0 {
tempDomains, customDomains := c.classifyDomains(domains)
// 精准获取桶的真实区域
c.resolveRegion(tempDomains)
// 优先使用临时域名(平台分配,稳定性高)
if len(tempDomains) > 0 {
d := tempDomains[0]
c.config.DownloadDomain = d
return d, nil
}
// 降级到自定义域名
if len(customDomains) > 0 {
d := customDomains[0]
c.config.DownloadDomain = d
return d, nil
}
} }
domain := domains[0]
if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") { // 无域名 → 兜底默认 CDN可能不存在但给一个机会
domain = "http://" + domain fallback := c.defaultCDNDomain()
} c.config.DownloadDomain = fallback
c.config.UploadDomain = domain return fallback, nil
return domain, nil
} }
// Download 下载文件 // defaultCDNDomain 构造七牛默认 CDN 域名
func (c *Client) defaultCDNDomain() string {
return fmt.Sprintf("http://%s-%s.qiniudns.com", c.config.Bucket, c.config.Region)
}
// ClearDownloadDomain 清除缓存的下载域名(下载失败时调用,下次重新解析)
func (c *Client) ClearDownloadDomain() {
c.config.DownloadDomain = ""
}
// resolveRegion 精准获取桶的真实区域
// 优先从临时域名提取 → 查询 API → 使用配置值兜底
func (c *Client) resolveRegion(tempDomains []string) {
// 1. 从临时域名提取
bucketLower := strings.ToLower(c.config.Bucket)
for _, d := range tempDomains {
host := extractHost(d)
host = strings.ToLower(host)
if !strings.HasPrefix(host, bucketLower+"-") {
continue
}
rest := host[len(bucketLower)+1:]
if idx := strings.Index(rest, "."); idx > 0 {
c.config.Region = rest[:idx]
return
}
}
// 2. 查询七牛 API
if region, err := c.GetBucketRegion(context.Background()); err == nil && region != "" {
c.config.Region = region
}
}
// Download 下载文件(使用签名 URL绕过防盗链
func (c *Client) Download(ctx context.Context, key string, writer io.Writer) error { func (c *Client) Download(ctx context.Context, key string, writer io.Writer) error {
baseURL, err := c.resolveDownloadDomain() signedURL, err := c.GetSignedURL(ctx, key, 1*time.Hour)
if err != nil { if err != nil {
return oss.NewError("DOWNLOAD_ERROR", err.Error(), err) return oss.NewError("DOWNLOAD_ERROR", err.Error(), err)
} }
url := fmt.Sprintf("%s/%s", baseURL, key)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", signedURL, nil)
if err != nil { if err != nil {
return oss.NewError("DOWNLOAD_ERROR", "failed to create request", err) return oss.NewError("DOWNLOAD_ERROR", "failed to create request", err)
} }
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
c.ClearDownloadDomain()
return oss.NewError("DOWNLOAD_ERROR", "failed to download file", err) return oss.NewError("DOWNLOAD_ERROR", "failed to download file", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return oss.NewError("DOWNLOAD_ERROR", fmt.Sprintf("download failed with status %d", resp.StatusCode), nil) c.ClearDownloadDomain()
body, _ := io.ReadAll(resp.Body)
return oss.NewError("DOWNLOAD_ERROR",
fmt.Sprintf("download failed with status %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])), nil)
} }
_, err = io.Copy(writer, resp.Body) _, err = io.Copy(writer, resp.Body)
if err != nil {
c.ClearDownloadDomain()
}
return err return err
} }
@@ -407,11 +438,27 @@ func (c *Client) GetFileInfo(ctx context.Context, key string) (*oss.FileInfo, er
return nil, oss.NewError("STAT_ERROR", fmt.Sprintf("stat failed with status %d: %s", resp.StatusCode, string(body)), nil) return nil, oss.NewError("STAT_ERROR", fmt.Sprintf("stat failed with status %d: %s", resp.StatusCode, string(body)), nil)
} }
// 解析响应 (简化实现) var statResp struct {
// 实际响应格式: {"hash":"xxx","fsize":123,"mimeType":"xxx","putTime":123} Hash string `json:"hash"`
// 这里返回一个简化的 FileInfo Fsize int64 `json:"fsize"`
MimeType string `json:"mimeType"`
PutTime int64 `json:"putTime"`
}
if err := json.Unmarshal(body, &statResp); err != nil {
return nil, oss.NewError("STAT_ERROR", "failed to parse response", err)
}
var modTime time.Time
if statResp.PutTime > 0 {
modTime = time.Unix(0, statResp.PutTime)
}
return &oss.FileInfo{ return &oss.FileInfo{
Key: key, Key: key,
Size: statResp.Fsize,
ETag: statResp.Hash,
ContentType: statResp.MimeType,
LastModified: modTime,
}, nil }, nil
} }
@@ -471,11 +518,16 @@ func (c *Client) ListFiles(ctx context.Context, options *oss.ListOptions) (*oss.
// 转换为统一格式 // 转换为统一格式
files := make([]oss.FileInfo, 0, len(listResp.Items)) files := make([]oss.FileInfo, 0, len(listResp.Items))
for _, item := range listResp.Items { for _, item := range listResp.Items {
var modTime time.Time
if item.PutTime > 0 {
modTime = time.Unix(0, item.PutTime)
}
files = append(files, oss.FileInfo{ files = append(files, oss.FileInfo{
Key: item.Key, Key: item.Key,
Size: item.Fsize, Size: item.Fsize,
ETag: item.Hash, ETag: item.Hash,
ContentType: item.MimeType, ContentType: item.MimeType,
LastModified: modTime,
}) })
} }
@@ -488,27 +540,22 @@ func (c *Client) ListFiles(ctx context.Context, options *oss.ListOptions) (*oss.
} }
// GetSignedURL 获取预签名URL // GetSignedURL 获取预签名URL
// 签名格式: hmac_sha1(SecretKey, "<downloadURL>?e=<deadline>")
func (c *Client) GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) { func (c *Client) GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) {
// 七牛云私有空间下载需要生成私有下载 URL
deadline := time.Now().Add(expiresIn).Unix() deadline := time.Now().Add(expiresIn).Unix()
// 构建 download URL
baseURL, err := c.resolveDownloadDomain() baseURL, err := c.resolveDownloadDomain()
if err != nil { if err != nil {
return "", err return "", err
} }
downloadURL := fmt.Sprintf("%s/%s", baseURL, key)
// 生成签名 // 签名字符串 = 完整 URL + ?e=deadline
urlToSign := fmt.Sprintf("%s/%s?e=%d", baseURL, key, deadline)
h := hmac.New(sha1.New, []byte(c.config.SecretKey)) h := hmac.New(sha1.New, []byte(c.config.SecretKey))
signStr := fmt.Sprintf("%s\n%d", downloadURL, deadline) h.Write([]byte(urlToSign))
h.Write([]byte(signStr))
sign := base64.URLEncoding.EncodeToString(h.Sum(nil)) sign := base64.URLEncoding.EncodeToString(h.Sum(nil))
// 构建最终 URL return fmt.Sprintf("%s&token=%s:%s", urlToSign, c.config.AccessKey, sign), nil
signedURL := fmt.Sprintf("%s?e=%d&token=%s:%s", downloadURL, deadline, c.config.AccessKey, sign)
return signedURL, nil
} }
// Copy 复制文件 // Copy 复制文件

View File

@@ -0,0 +1,190 @@
package qiniu
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"u-desk/internal/oss"
)
// 临时测试配置 — 提交前删除此文件
func testConfig() *Config {
return &Config{
AccessKey: "eUjiDJGy9CkRb3-Ad3jCubPrm49xeBTesHYckIwc",
SecretKey: "LE8XL-LmoMkpy0jNK-kDhgL_w7A6MRXD1Msqd1Y4",
Bucket: "u-res",
Region: "as0",
UseHTTPS: true,
}
}
const testKey = "music/03.一人一首成名曲【特调音源】/001.雨一直下-张宇.mp3"
// TestListBuckets 列举桶
func TestListBuckets(t *testing.T) {
buckets, err := ListBuckets("eUjiDJGy9CkRb3-Ad3jCubPrm49xeBTesHYckIwc", "LE8XL-LmoMkpy0jNK-kDhgL_w7A6MRXD1Msqd1Y4")
if err != nil {
t.Fatalf("ListBuckets 失败: %v", err)
}
for _, b := range buckets {
t.Logf("桶: %s 区域: %s", b.Name, b.Region)
}
}
// TestGetBucketDomains 获取桶域名
func TestGetBucketDomains(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
domains, err := c.GetBucketDomains(context.Background())
if err != nil {
t.Fatalf("获取域名失败: %v", err)
}
t.Logf("桶域名: %v", domains)
}
// TestDownloadDirect 裸 URL 下载(测试桶公开/私有)
func TestDownloadDirect(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
domain, err := c.resolveDownloadDomain()
if err != nil {
t.Fatalf("获取下载域名失败: %v", err)
}
t.Logf("下载域名: %s", domain)
rawURL := fmt.Sprintf("%s/%s", domain, testKey)
t.Logf("裸 URL: %s", rawURL)
httpResp, err := http.Get(rawURL)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer httpResp.Body.Close()
t.Logf("裸 URL 状态码: %d", httpResp.StatusCode)
var buf bytes.Buffer
buf.ReadFrom(httpResp.Body)
t.Logf("响应大小: %d bytes", buf.Len())
}
// TestDownloadSigned 签名 URL 下载
func TestDownloadSigned(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
signedURL, err := c.GetSignedURL(context.Background(), testKey, 1*time.Hour)
if err != nil {
t.Fatalf("生成签名 URL 失败: %v", err)
}
t.Logf("签名 URL: %s...", signedURL[:min(120, len(signedURL))])
httpResp, err := http.Get(signedURL)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer httpResp.Body.Close()
t.Logf("签名 URL 状态码: %d", httpResp.StatusCode)
var buf bytes.Buffer
buf.ReadFrom(httpResp.Body)
t.Logf("下载大小: %d bytes", buf.Len())
if httpResp.StatusCode != 200 {
t.Errorf("下载失败: %d, body: %s", httpResp.StatusCode, buf.String()[:min(200, buf.Len())])
}
}
// TestDownloadViaClient 通过 Client.Download 方法下载
func TestDownloadViaClient(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
var buf bytes.Buffer
err = c.Download(context.Background(), testKey, &buf)
if err != nil {
t.Errorf("Client.Download 失败: %v", err)
} else {
t.Logf("Client.Download 成功,大小: %d bytes (预期 ~7MB)", buf.Len())
}
}
// TestGetFileInfo 获取文件信息
func TestGetFileInfo(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
info, err := c.GetFileInfo(context.Background(), testKey)
if err != nil {
t.Errorf("GetFileInfo 失败: %v", err)
} else {
t.Logf("GetFileInfo: key=%s size=%d", info.Key, info.Size)
}
}
// TestListFiles 列举文件
func TestListFiles(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
result, err := c.ListFiles(context.Background(), &oss.ListOptions{Prefix: "music/", MaxKeys: 10})
if err != nil {
t.Fatalf("ListFiles 失败: %v", err)
}
for _, f := range result.Files {
t.Logf("文件: %-80s size: %d", f.Key, f.Size)
}
}
// TestListFilesRaw 原始 RSF 请求查看响应结构
func TestListFilesRaw(t *testing.T) {
c, err := NewClient(testConfig())
if err != nil {
t.Fatal(err)
}
defer c.Close()
resp, err := c.doRSFRequest("GET", fmt.Sprintf("/list?bucket=%s&limit=3&prefix=music/", testConfig().Bucket))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var buf bytes.Buffer
buf.ReadFrom(resp.Body)
var pretty bytes.Buffer
json.Indent(&pretty, buf.Bytes(), "", " ")
t.Logf("原始响应:\n%s", pretty.String())
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -156,8 +156,8 @@ func (uc *UploadClient) Upload(ctx context.Context, key string, reader io.Reader
} }
var uploadURL string var uploadURL string
if uc.config.UploadDomain != "" { if uc.config.DownloadDomain != "" {
uploadURL = scheme + uc.config.UploadDomain uploadURL = scheme + uc.config.DownloadDomain
} else { } else {
// 根据区域选择 // 根据区域选择
switch uc.config.Region { switch uc.config.Region {

View File

@@ -7,6 +7,8 @@ import (
"fmt" "fmt"
"os" "os"
"path" "path"
"path/filepath"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -15,6 +17,7 @@ import (
"u-desk/internal/oss" "u-desk/internal/oss"
"u-desk/internal/oss/aliyun" "u-desk/internal/oss/aliyun"
"u-desk/internal/oss/qiniu" "u-desk/internal/oss/qiniu"
"u-desk/internal/storage"
) )
// accountCredentials 账户级凭据 // accountCredentials 账户级凭据
@@ -36,17 +39,19 @@ var globalManager = &Manager{}
func GetManager() *Manager { return globalManager } func GetManager() *Manager { return globalManager }
// Connect 建立账户级连接(验证凭据通过 ListBuckets // Connect 建立账户级连接(验证凭据通过 ListBuckets,同时缓存桶区域
func (m *Manager) Connect(provider, accessKey, secretKey, endpoint string) error { func (m *Manager) Connect(provider, accessKey, secretKey, endpoint string) error {
// 验证凭据 var entries []oss.BucketEntry
var err error
switch provider { switch provider {
case "qiniu": case "qiniu":
_, err := qiniu.ListBuckets(accessKey, secretKey) entries, err = qiniu.ListBuckets(accessKey, secretKey)
if err != nil { if err != nil {
return fmt.Errorf("七牛云连接失败: %w", err) return fmt.Errorf("七牛云连接失败: %w", err)
} }
case "aliyun": case "aliyun":
_, err := aliyun.ListBuckets(accessKey, secretKey, endpoint) entries, err = aliyun.ListBuckets(accessKey, secretKey, endpoint)
if err != nil { if err != nil {
return fmt.Errorf("阿里云连接失败: %w", err) return fmt.Errorf("阿里云连接失败: %w", err)
} }
@@ -54,6 +59,13 @@ func (m *Manager) Connect(provider, accessKey, secretKey, endpoint string) error
return fmt.Errorf("不支持的 OSS 提供商: %s", provider) return fmt.Errorf("不支持的 OSS 提供商: %s", provider)
} }
// 连接时立即缓存桶区域,避免后续操作因缺少 region 使用默认区域
for _, e := range entries {
if e.Region != "" {
m.bucketRegions.Store(provider+":"+e.Name, e.Region)
}
}
m.accounts.Store(provider, &accountCredentials{ m.accounts.Store(provider, &accountCredentials{
Provider: provider, Provider: provider,
AccessKey: accessKey, AccessKey: accessKey,
@@ -76,10 +88,15 @@ func (m *Manager) getOrCreateBucketClient(provider, bucket, region string) (oss.
} }
c := cred.(*accountCredentials) c := cred.(*accountCredentials)
// 如果未传 region从缓存取 // 如果未传 region从缓存取;仍为空则主动探测
if region == "" { if region == "" {
if v, ok := m.bucketRegions.Load(key); ok { if v, ok := m.bucketRegions.Load(key); ok {
region = v.(string) region = v.(string)
} else {
region = m.detectBucketRegion(provider, bucket, c)
if region != "" {
m.bucketRegions.Store(key, region)
}
} }
} }
@@ -96,12 +113,17 @@ func (m *Manager) getOrCreateBucketClient(provider, bucket, region string) (oss.
UseHTTPS: true, UseHTTPS: true,
}) })
case "aliyun": case "aliyun":
// 有桶级 region 时不传账户 Endpoint让 NewClient 从 region 派生正确的 endpoint
ep := c.Endpoint
if region != "" {
ep = ""
}
client, err = aliyun.NewClient(&aliyun.Config{ client, err = aliyun.NewClient(&aliyun.Config{
AccessKeyID: c.AccessKey, AccessKeyID: c.AccessKey,
AccessKeySecret: c.SecretKey, AccessKeySecret: c.SecretKey,
Bucket: bucket, Bucket: bucket,
Region: region, Region: region,
Endpoint: c.Endpoint, Endpoint: ep,
UseHTTPS: true, UseHTTPS: true,
}) })
default: default:
@@ -116,6 +138,41 @@ func (m *Manager) getOrCreateBucketClient(provider, bucket, region string) (oss.
return client, nil return client, nil
} }
// detectBucketRegion 主动探测桶区域(缓存未命中时调用)
func (m *Manager) detectBucketRegion(provider, bucket string, c *accountCredentials) string {
switch provider {
case "aliyun":
entries, err := aliyun.ListBuckets(c.AccessKey, c.SecretKey, c.Endpoint)
if err != nil {
return ""
}
for _, e := range entries {
key := provider + ":" + e.Name
if e.Region != "" {
m.bucketRegions.Store(key, e.Region)
}
if e.Name == bucket {
return e.Region
}
}
case "qiniu":
entries, err := qiniu.ListBuckets(c.AccessKey, c.SecretKey)
if err != nil {
return ""
}
for _, e := range entries {
key := provider + ":" + e.Name
if e.Region != "" {
m.bucketRegions.Store(key, e.Region)
}
if e.Name == bucket {
return e.Region
}
}
}
return ""
}
// GetClient 获取已有的桶级客户端 // GetClient 获取已有的桶级客户端
func (m *Manager) GetClient(provider, bucket string) oss.OSSProvider { func (m *Manager) GetClient(provider, bucket string) oss.OSSProvider {
if v, ok := m.clients.Load(provider + ":" + bucket); ok { if v, ok := m.clients.Load(provider + ":" + bucket); ok {
@@ -583,8 +640,255 @@ func (s *Service) RenamePath(connID string, oldPath string, newPath string) (*fi
return result, nil return result, nil
} }
// DownloadToTemp 下载文件到本地临时目录 // htmlResourceRegex 提取 HTML 资源引用的正则
var htmlResourceRegex = regexp.MustCompile(`(?:src|href|data|poster)=["']([^"']+)["']`)
var htmlCssUrlRegex = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
// DownloadSiteForPreview 下载 HTML 及其引用的资源到临时目录
// 对绝对路径(/开头)从 HTML 目录逐级向上嗅探网站根目录
func (s *Service) DownloadSiteForPreview(connID string, rawPath string) (string, error) {
bucket, key := parseBucketPath(rawPath)
if bucket == "" {
return "", fmt.Errorf("路径中缺少桶名")
}
c, err := s.manager.getOrCreateBucketClient(connID, bucket, "")
if err != nil {
return "", err
}
ctx := context.Background()
// 1. 创建临时目录,保留 OSS 目录结构
tmpDir, err := os.MkdirTemp("", "udesk-site-*")
if err != nil {
return "", fmt.Errorf("创建临时目录失败: %w", err)
}
keyDir := path.Dir(key)
var htmlLocalPath string
if keyDir != "" && keyDir != "." {
htmlLocalPath = filepath.Join(tmpDir, filepath.FromSlash(keyDir), path.Base(key))
if err := os.MkdirAll(filepath.Dir(htmlLocalPath), 0755); err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("创建目录失败: %w", err)
}
} else {
htmlLocalPath = filepath.Join(tmpDir, path.Base(key))
}
// 2. 下载 HTML
f, err := os.Create(htmlLocalPath)
if err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("创建临时文件失败: %w", err)
}
if err := c.Download(ctx, key, f); err != nil {
f.Close()
os.RemoveAll(tmpDir)
return "", fmt.Errorf("下载 HTML 失败: %w", err)
}
f.Close()
// 3. 解析 HTML 提取资源路径
htmlContent, err := os.ReadFile(htmlLocalPath)
if err != nil {
return htmlLocalPath, nil // HTML 已下载,资源解析失败不影响
}
resources := extractHtmlResources(string(htmlContent))
// 4. 下载资源
htmlOssDir := keyDir
if htmlOssDir == "." {
htmlOssDir = ""
}
htmlLocalDir := filepath.Dir(htmlLocalPath)
var siteRoot string
var discoveredDirs []string
seenDir := make(map[string]bool)
recordDir := func(ossKey string) {
dir := path.Dir(ossKey)
if !seenDir[dir] {
seenDir[dir] = true
discoveredDirs = append(discoveredDirs, dir)
}
}
for _, resPath := range resources {
if shouldSkipResource(resPath) {
continue
}
isAbsolute := strings.HasPrefix(resPath, "/")
cleanPath := strings.TrimPrefix(resPath, "/")
cleanPath = strings.TrimPrefix(cleanPath, "./")
if cleanPath == "" {
continue
}
localPath := filepath.Join(htmlLocalDir, filepath.FromSlash(cleanPath))
if isAbsolute {
resolvedKey := resolveAndDownload(c, ctx, htmlOssDir, cleanPath, localPath, &siteRoot)
if resolvedKey != "" {
recordDir(resolvedKey)
}
} else {
var ossKey string
if htmlOssDir != "" {
ossKey = htmlOssDir + "/" + cleanPath
} else {
ossKey = cleanPath
}
if downloadResource(c, ctx, ossKey, localPath) {
recordDir(ossKey)
}
}
}
// 5. 补充下载已发现目录中的剩余文件(覆盖 webpack 动态 chunk 等)
for _, dir := range discoveredDirs {
supplementDir(c, ctx, dir, tmpDir, siteRoot)
}
return htmlLocalPath, nil
}
// resolveAbsoluteResourcePath 解析绝对路径资源,首次嗅探网站根,后续直接使用
// resolveAndDownload 解析绝对路径并下载:首次嗅探网站根,后续直接使用
func resolveAndDownload(c oss.OSSProvider, ctx context.Context, htmlOssDir string, cleanPath string, localPath string, siteRoot *string) string {
if *siteRoot != "" {
downloadResource(c, ctx, *siteRoot+cleanPath, localPath)
return *siteRoot + cleanPath
}
// 从 HTML 目录向上逐级探测(同时下载,成功即完成嗅探)
dir := htmlOssDir
for {
var candidate string
if dir == "" {
candidate = cleanPath
} else {
candidate = dir + "/" + cleanPath
}
if downloadResource(c, ctx, candidate, localPath) {
if dir == "" {
*siteRoot = ""
} else {
*siteRoot = dir + "/"
}
return candidate
}
if dir == "" {
break
}
parent := path.Dir(dir)
if parent == dir || parent == "." {
dir = ""
} else {
dir = parent
}
}
return ""
}
// downloadResource 下载资源到本地(失败静默,返回是否成功)
func downloadResource(c oss.OSSProvider, ctx context.Context, ossKey string, localPath string) bool {
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
return false
}
f, err := os.Create(localPath)
if err != nil {
return false
}
if err := c.Download(ctx, ossKey, f); err != nil {
f.Close()
os.Remove(localPath)
return false
}
f.Close()
return true
}
// supplementDir 补充下载远程目录中尚未下载的文件(只处理已知资源所在目录)
func supplementDir(c oss.OSSProvider, ctx context.Context, remoteDir string, tmpDir string, siteRoot string) {
prefix := remoteDir + "/"
result, err := c.ListFiles(ctx, &oss.ListOptions{Prefix: prefix, MaxKeys: 200})
if err != nil {
return
}
for _, f := range result.Files {
if strings.HasSuffix(f.Key, "/") || f.Size == 0 {
continue
}
relPath := strings.TrimPrefix(f.Key, siteRoot)
localPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
if _, err := os.Stat(localPath); err == nil {
continue
}
downloadResource(c, ctx, f.Key, localPath)
}
}
func extractHtmlResources(html string) []string {
seen := make(map[string]bool)
var resources []string
add := func(v string) {
v = strings.TrimSpace(v)
if v != "" && !seen[v] {
seen[v] = true
resources = append(resources, v)
}
}
for _, m := range htmlResourceRegex.FindAllStringSubmatch(html, -1) {
if len(m) > 1 {
add(m[1])
}
}
for _, m := range htmlCssUrlRegex.FindAllStringSubmatch(html, -1) {
if len(m) > 1 {
add(m[1])
}
}
return resources
}
// shouldSkipResource 判断资源路径是否应跳过
func shouldSkipResource(p string) bool {
return strings.HasPrefix(p, "data:") ||
strings.HasPrefix(p, "http://") ||
strings.HasPrefix(p, "https://") ||
strings.HasPrefix(p, "//") ||
strings.HasPrefix(p, "#") ||
strings.HasPrefix(p, "javascript:") ||
strings.HasPrefix(p, "mailto:") ||
strings.HasPrefix(p, "blob:")
}
// DownloadToTemp 下载文件到本地临时目录(带 SQLite 缓存)
func (s *Service) DownloadToTemp(connID string, rawPath string) (string, error) { func (s *Service) DownloadToTemp(connID string, rawPath string) (string, error) {
// 先获取文件元信息用于缓存键,确保远程文件变更时能淘汰旧缓存
var fileSize int64
var modTime string
if info, err := s.GetFileInfo(connID, rawPath); err == nil {
if sz, ok := info["size"].(int64); ok {
fileSize = sz
}
if mt, ok := info["mod_time"].(string); ok {
modTime = mt
}
}
return storage.DownloadToTempCached("oss", connID, rawPath, fileSize, modTime, func() (string, error) {
return s.downloadToTempDirect(connID, rawPath)
})
}
// downloadToTempDirect 实际执行下载(无缓存)
func (s *Service) downloadToTempDirect(connID string, rawPath string) (string, error) {
bucket, key := parseBucketPath(rawPath) bucket, key := parseBucketPath(rawPath)
if bucket == "" { if bucket == "" {
return "", fmt.Errorf("路径中缺少桶名") return "", fmt.Errorf("路径中缺少桶名")
@@ -609,6 +913,13 @@ func (s *Service) DownloadToTemp(connID string, rawPath string) (string, error)
return localPath, nil return localPath, nil
} }
// DownloadToTempCached 带缓存的 OSS 下载(命中缓存直接返回本地路径,支持传入文件元信息)
func (s *Service) DownloadToTempCached(connID, rawPath string, fileSize int64, modTime string) (string, error) {
return storage.DownloadToTempCached("oss", connID, rawPath, fileSize, modTime, func() (string, error) {
return s.downloadToTempDirect(connID, rawPath)
})
}
// GetCommonPaths 返回常用路径 // GetCommonPaths 返回常用路径
func (s *Service) GetCommonPaths(connID string) (map[string]string, error) { func (s *Service) GetCommonPaths(connID string) (map[string]string, error) {
return map[string]string{ return map[string]string{

View File

@@ -8,15 +8,22 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"u-desk/internal/filesystem" "u-desk/internal/filesystem"
"u-desk/internal/storage"
sftpclient "github.com/pkg/sftp" sftpclient "github.com/pkg/sftp"
) )
var (
sftpResRegex = regexp.MustCompile(`(?:src|href|data|poster)=["']([^"']+)["']`)
sftpCssUrlRe = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
)
// Service SFTP 文件操作服务 // Service SFTP 文件操作服务
type Service struct { type Service struct {
manager *Manager manager *Manager
@@ -257,8 +264,26 @@ func (s *Service) RenamePath(connID string, oldPath, newPath string) (*filesyste
return result, nil return result, nil
} }
// DownloadToTemp 下载远程文件到本地临时目录(用于预览 // DownloadToTemp 下载远程文件到本地临时目录(带 SQLite 缓存
func (s *Service) DownloadToTemp(connID string, remotePath string) (string, error) { func (s *Service) DownloadToTemp(connID string, remotePath string) (string, error) {
// 先获取文件元信息用于缓存键,确保远程文件变更时能淘汰旧缓存
var fileSize int64
var modTime string
if info, err := s.GetFileInfo(connID, remotePath); err == nil {
if sz, ok := info["size"].(int64); ok {
fileSize = sz
}
if mt, ok := info["mod_time"].(string); ok {
modTime = mt
}
}
return storage.DownloadToTempCached("sftp", connID, remotePath, fileSize, modTime, func() (string, error) {
return s.downloadToTempDirect(connID, remotePath)
})
}
// downloadToTempDirect 实际执行下载(无缓存)
func (s *Service) downloadToTempDirect(connID string, remotePath string) (string, error) {
c, err := s.getClient(connID) c, err := s.getClient(connID)
if err != nil { if err != nil {
return "", err return "", err
@@ -303,12 +328,201 @@ func (s *Service) DownloadToTemp(connID string, remotePath string) (string, erro
return e return e
}) })
if err != nil { if err != nil {
os.Remove(localPath)
return "", fmt.Errorf("下载文件失败: %w", err) return "", fmt.Errorf("下载文件失败: %w", err)
} }
return localPath, nil return localPath, nil
} }
// DownloadToTempCached 带缓存的 SFTP 下载(支持传入文件元信息)
func (s *Service) DownloadToTempCached(connID, remotePath string, fileSize int64, modTime string) (string, error) {
return storage.DownloadToTempCached("sftp", connID, remotePath, fileSize, modTime, func() (string, error) {
return s.downloadToTempDirect(connID, remotePath)
})
}
// DownloadSiteForPreview 下载 HTML 及其网站资源到本地临时目录
func (s *Service) DownloadSiteForPreview(connID string, remotePath string) (string, error) {
c, err := s.getClient(connID)
if err != nil {
return "", err
}
// 1. 创建临时目录
tmpDir, err := os.MkdirTemp("", "udesk-sftp-site-*")
if err != nil {
return "", fmt.Errorf("创建临时目录失败: %w", err)
}
// 2. 确定远程网站根目录(从 HTML 路径推断)
keyDir := path.Dir(remotePath)
if keyDir == "." {
keyDir = ""
}
var htmlLocalPath string
if keyDir != "" {
htmlLocalPath = filepath.Join(tmpDir, filepath.FromSlash(keyDir), path.Base(remotePath))
if err := os.MkdirAll(filepath.Dir(htmlLocalPath), 0755); err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("创建目录失败: %w", err)
}
} else {
htmlLocalPath = filepath.Join(tmpDir, path.Base(remotePath))
}
// 3. 下载 HTML
if err := s.sftpDownloadFile(c, remotePath, htmlLocalPath); err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("下载 HTML 失败: %w", err)
}
// 4. 解析 HTML 提取资源路径
htmlContent, err := os.ReadFile(htmlLocalPath)
if err != nil {
return htmlLocalPath, nil
}
resources := sftpExtractResources(string(htmlContent))
// 5. 下载静态引用资源(嗅探网站根)
htmlRemoteDir := keyDir
if htmlRemoteDir == "/" {
htmlRemoteDir = ""
}
htmlLocalDir := filepath.Dir(htmlLocalPath)
var siteRoot string
var discoveredDirs []string
seenDir := make(map[string]bool)
recordDir := func(remoteKey string) {
dir := path.Dir(remoteKey)
if !seenDir[dir] {
seenDir[dir] = true
discoveredDirs = append(discoveredDirs, dir)
}
}
for _, resPath := range resources {
if sftpShouldSkip(resPath) {
continue
}
isAbsolute := strings.HasPrefix(resPath, "/")
cleanPath := strings.TrimPrefix(resPath, "/")
cleanPath = strings.TrimPrefix(cleanPath, "./")
if cleanPath == "" {
continue
}
localPath := filepath.Join(htmlLocalDir, filepath.FromSlash(cleanPath))
if isAbsolute {
resolvedKey := sftpResolveAndDownload(s, c, htmlRemoteDir, cleanPath, localPath, &siteRoot)
if resolvedKey != "" {
recordDir(resolvedKey)
}
} else {
remoteKey := path.Join(htmlRemoteDir, cleanPath)
if s.sftpTryDownload(c, remoteKey, localPath) {
recordDir(remoteKey)
}
}
}
// 6. 补充下载已发现目录中的剩余文件(覆盖动态 chunk 等)
for _, dir := range discoveredDirs {
sftpSupplementDir(s, c, dir, tmpDir, siteRoot)
}
return htmlLocalPath, nil
}
// sftpDownloadFile 下载单个远程文件到本地路径
func (s *Service) sftpDownloadFile(c *Client, remotePath, localPath string) error {
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
return err
}
return c.WithRetry(func(sc *sftpclient.Client) error {
src, err := sc.Open(remotePath)
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(localPath)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
return err
})
}
// sftpTryDownload 尝试下载(失败静默,返回是否成功)
func (s *Service) sftpTryDownload(c *Client, remotePath, localPath string) bool {
err := s.sftpDownloadFile(c, remotePath, localPath)
if err != nil {
os.Remove(localPath)
return false
}
return true
}
// sftpResolveAndDownload 嗅探网站根并下载绝对路径资源
func sftpResolveAndDownload(s *Service, c *Client, htmlDir string, cleanPath string, localPath string, siteRoot *string) string {
if *siteRoot != "" {
s.sftpTryDownload(c, *siteRoot+cleanPath, localPath)
return *siteRoot + cleanPath
}
dir := htmlDir
for {
candidate := path.Join(dir, cleanPath)
if s.sftpTryDownload(c, candidate, localPath) {
if dir == "" {
*siteRoot = ""
} else {
*siteRoot = dir + "/"
}
return candidate
}
if dir == "" {
break
}
parent := path.Dir(dir)
if parent == dir || parent == "." {
dir = ""
} else {
dir = parent
}
}
return ""
}
// sftpSupplementDir 补充下载远程目录中尚未下载的文件(只处理已知资源所在目录)
func sftpSupplementDir(s *Service, c *Client, remoteDir string, tmpDir string, siteRoot string) {
var entries []fs.FileInfo
err := c.WithRetry(func(sc *sftpclient.Client) error {
var e error
entries, e = sc.ReadDir(remoteDir)
return e
})
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() || entry.Size() == 0 {
continue
}
fullPath := path.Join(remoteDir, entry.Name())
relPath := strings.TrimPrefix(fullPath, siteRoot)
relPath = strings.TrimPrefix(relPath, "/")
localPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
if _, err := os.Stat(localPath); err == nil {
continue
}
s.sftpTryDownload(c, fullPath, localPath)
}
}
// GetCommonPaths 返回 SFTP 远程主机常用路径 // GetCommonPaths 返回 SFTP 远程主机常用路径
func (s *Service) GetCommonPaths(connID string) (map[string]string, error) { func (s *Service) GetCommonPaths(connID string) (map[string]string, error) {
c := s.manager.GetClient(connID) c := s.manager.GetClient(connID)
@@ -331,18 +545,9 @@ func (s *Service) GetCommonPaths(connID string) (map[string]string, error) {
}, nil }, nil
} }
// CleanupTempFiles 清理遗留的临时预览文件 // CleanupTempFiles 清理遗留的临时预览文件(已由 SQLite 缓存接管)
func CleanupTempFiles() { func CleanupTempFiles() {
tmpDir := os.TempDir() storage.CleanupExpiredCache()
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/内存) // GetSystemInfo 通过 SSH 命令采集远程系统信息(磁盘/CPU/内存)
@@ -501,3 +706,39 @@ func toFileOperationResult(m map[string]interface{}, isDir bool) *filesystem.Fil
Mode: mode, Mode: mode,
} }
} }
// sftpExtractResources 从 HTML 内容提取资源路径
func sftpExtractResources(html string) []string {
seen := make(map[string]bool)
var resources []string
add := func(v string) {
v = strings.TrimSpace(v)
if v != "" && !seen[v] {
seen[v] = true
resources = append(resources, v)
}
}
for _, m := range sftpResRegex.FindAllStringSubmatch(html, -1) {
if len(m) > 1 {
add(m[1])
}
}
for _, m := range sftpCssUrlRe.FindAllStringSubmatch(html, -1) {
if len(m) > 1 {
add(m[1])
}
}
return resources
}
// sftpShouldSkip 判断资源路径是否应跳过
func sftpShouldSkip(p string) bool {
return strings.HasPrefix(p, "data:") ||
strings.HasPrefix(p, "http://") ||
strings.HasPrefix(p, "https://") ||
strings.HasPrefix(p, "//") ||
strings.HasPrefix(p, "#") ||
strings.HasPrefix(p, "javascript:") ||
strings.HasPrefix(p, "mailto:") ||
strings.HasPrefix(p, "blob:")
}

View File

@@ -0,0 +1,187 @@
package storage
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"time"
"u-desk/internal/storage/models"
"gorm.io/gorm"
)
const downloadCacheTTL = 24 * time.Hour
// cacheTempDir 确定性临时目录
var cacheTempDir = filepath.Join(os.TempDir(), "u-desk-cache")
// GetCachedPath 查询缓存,验证文件存在后返回本地路径
func GetCachedPath(transport, connID, remotePath string, fileSize int64, modTime string) (string, bool) {
db := GetDB()
if db == nil {
return "", false
}
var entry models.DownloadCache
err := db.Where("transport = ? AND conn_id = ? AND remote_path = ? AND file_size = ? AND mod_time = ?",
transport, connID, remotePath, fileSize, modTime).First(&entry).Error
if err != nil {
return "", false
}
// 检查文件是否仍然存在于磁盘
if _, err := os.Stat(entry.LocalPath); err != nil {
// 文件已丢失,清理过期记录
db.Delete(&entry)
return "", false
}
// 检查是否过期
if time.Since(entry.DownloadedAt) > downloadCacheTTL {
os.Remove(entry.LocalPath)
db.Delete(&entry)
return "", false
}
return entry.LocalPath, true
}
// SaveCache 保存或更新缓存记录
func SaveCache(transport, connID, remotePath string, fileSize int64, modTime, localPath string) {
db := GetDB()
if db == nil {
return
}
var existing models.DownloadCache
err := db.Where("transport = ? AND conn_id = ? AND remote_path = ? AND file_size = ? AND mod_time = ?",
transport, connID, remotePath, fileSize, modTime).First(&existing).Error
if err == gorm.ErrRecordNotFound {
db.Create(&models.DownloadCache{
Transport: transport,
ConnID: connID,
RemotePath: remotePath,
FileSize: fileSize,
ModTime: modTime,
LocalPath: localPath,
DownloadedAt: time.Now(),
})
} else if err == nil {
db.Model(&existing).Updates(map[string]any{
"local_path": localPath,
"downloaded_at": time.Now(),
})
}
}
// CleanupExpiredCache 清理超过 24h 的缓存记录并删除对应临时文件
func CleanupExpiredCache() {
db := GetDB()
if db == nil {
return
}
cutoff := time.Now().Add(-downloadCacheTTL)
var expired []models.DownloadCache
db.Where("downloaded_at < ?", cutoff).Find(&expired)
for _, entry := range expired {
os.Remove(entry.LocalPath)
db.Delete(&entry)
}
if len(expired) > 0 {
fmt.Printf("[下载缓存] 清理 %d 条过期记录\n", len(expired))
}
}
// DownloadToTempCached 带缓存的下载:命中返回本地路径,未命中调用 downloadFn 后缓存结果
func DownloadToTempCached(transport, connID, remotePath string, fileSize int64, modTime string, downloadFn func() (string, error)) (string, error) {
// 1. 查缓存
if localPath, hit := GetCachedPath(transport, connID, remotePath, fileSize, modTime); hit {
return localPath, nil
}
// 2. 缓存未命中,执行下载
tempPath, err := downloadFn()
if err != nil {
return "", err
}
// 3. 生成确定性路径并移动文件
deterministicPath, err := deterministicCachePath(transport, connID, remotePath, fileSize, modTime)
if err != nil {
// 降级:直接使用 downloadFn 返回的路径,仍然缓存
SaveCache(transport, connID, remotePath, fileSize, modTime, tempPath)
return tempPath, nil
}
// 确保目录存在
if err := os.MkdirAll(filepath.Dir(deterministicPath), 0755); err != nil {
SaveCache(transport, connID, remotePath, fileSize, modTime, tempPath)
return tempPath, nil
}
// 移动文件到确定性路径
if err := os.Rename(tempPath, deterministicPath); err != nil {
// Rename 可能跨卷失败,尝试 Copy+Delete
if copyFile(tempPath, deterministicPath) != nil {
SaveCache(transport, connID, remotePath, fileSize, modTime, tempPath)
return tempPath, nil
}
os.Remove(tempPath)
}
SaveCache(transport, connID, remotePath, fileSize, modTime, deterministicPath)
return deterministicPath, nil
}
// deterministicCachePath 根据文件信息生成确定性的缓存路径
func deterministicCachePath(transport, connID, remotePath string, fileSize int64, modTime string) (string, error) {
h := sha256.New()
h.Write([]byte(fmt.Sprintf("%s:%s:%s:%d:%s", transport, connID, remotePath, fileSize, modTime)))
hash := fmt.Sprintf("%x", h.Sum(nil))[:16]
baseName := filepath.Base(remotePath)
if baseName == "" || baseName == "." || baseName == "/" {
baseName = "file"
}
// 截断过长的文件名
if len(baseName) > 64 {
ext := filepath.Ext(baseName)
maxName := 64 - len(ext)
if maxName <= 0 {
maxName = 1
ext = ext[:63]
}
baseName = baseName[:maxName] + ext
}
fileName := fmt.Sprintf("%s_%s", hash, baseName)
return filepath.Join(cacheTempDir, fileName), nil
}
// copyFile 复制文件内容
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := out.ReadFrom(in); err != nil {
os.Remove(dst)
return err
}
return nil
}

View File

@@ -0,0 +1,12 @@
package models
// BgmPlaylist BGM 播放列表持久化
type BgmPlaylist struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"not null;size:255"`
Path string `gorm:"not null;size:500;uniqueIndex"`
ProfileID string `gorm:"type:varchar(50)" json:"profile_id"`
Sort uint `gorm:"not null"`
}
func (BgmPlaylist) TableName() string { return "bgm_playlist" }

View File

@@ -11,7 +11,8 @@ type ConnectionProfile struct {
Username string `gorm:"type:varchar(100);default:root" json:"username"` Username string `gorm:"type:varchar(100);default:root" json:"username"`
Password string `gorm:"type:text" json:"password"` Password string `gorm:"type:text" json:"password"`
KeyPath string `gorm:"type:text" json:"key_path"` KeyPath string `gorm:"type:text" json:"key_path"`
Type string `gorm:"type:varchar(20);not null;index" json:"type"` // local|remote|sftp|qiniu|aliyun Type string `gorm:"type:varchar(20);not null;index" json:"type"` // local|remote|sftp|oss
Provider string `gorm:"type:varchar(20)" json:"provider"` // qiniu|aliyun (仅 type=oss)
Token string `gorm:"type:text" json:"token"` Token string `gorm:"type:text" json:"token"`
AccessKey string `gorm:"type:text" json:"access_key"` AccessKey string `gorm:"type:text" json:"access_key"`
SecretKey string `gorm:"type:text" json:"secret_key"` SecretKey string `gorm:"type:text" json:"secret_key"`

View File

@@ -0,0 +1,17 @@
package models
import "time"
// DownloadCache 下载缓存模型SQLite 持久化)
type DownloadCache struct {
ID uint `gorm:"primaryKey"`
Transport string `gorm:"not null;size:10;index:idx_cache_lookup"`
ConnID string `gorm:"not null;index:idx_cache_lookup"`
RemotePath string `gorm:"not null;index:idx_cache_lookup"`
FileSize int64 `gorm:"not null;index:idx_cache_lookup"`
ModTime string `gorm:"not null;index:idx_cache_lookup"`
LocalPath string `gorm:"not null"`
DownloadedAt time.Time `gorm:"not null"`
}
func (DownloadCache) TableName() string { return "download_cache" }

View File

@@ -53,10 +53,14 @@ func InitFast() (*gorm.DB, error) {
sqlDB.SetMaxIdleConns(1) sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(time.Hour) sqlDB.SetConnMaxLifetime(time.Hour)
if e := db.AutoMigrate(&models.AppConfig{}, &models.ConnectionProfile{}); e != nil { if e := db.AutoMigrate(&models.AppConfig{}, &models.ConnectionProfile{}, &models.DownloadCache{}, &models.BgmPlaylist{}); e != nil {
initErr = e initErr = e
return return
} }
// 数据迁移qiniu/aliyun → oss + provider
db.Exec("UPDATE connection_profiles SET provider = type, type = 'oss' WHERE type IN ('qiniu', 'aliyun')")
// 为旧 BGM 播放列表补充 profile_id找第一个 OSS profile
db.Exec("UPDATE bgm_playlist SET profile_id = (SELECT CAST(id AS VARCHAR) FROM connection_profiles WHERE type = 'oss' LIMIT 1) WHERE (profile_id = '' OR profile_id IS NULL) AND path NOT LIKE '%:'")
globalDB = db globalDB = db
}) })
if initErr != nil { if initErr != nil {

16
main.go
View File

@@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
"u-desk/internal/hotkey"
) )
//go:embed all:frontend/dist //go:embed all:frontend/dist
@@ -43,6 +44,21 @@ func main() {
Mac: application.MacOptions{ Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: true, ApplicationShouldTerminateAfterLastWindowClosed: true,
}, },
Windows: application.WindowsOptions{
WndProcInterceptor: func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (uintptr, bool) {
switch msg {
case hotkey.WM_APP_HOTKEY:
app.RegisterGlobalHotkey()
return 0, true
case hotkey.WM_HOTKEY:
if wParam == 1 {
app.HandleHotkey()
return 0, true
}
}
return 0, false
},
},
}) })
window := application.Get().Window.NewWithOptions(application.WebviewWindowOptions{ window := application.Get().Window.NewWithOptions(application.WebviewWindowOptions{