新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||
.claude/
|
||||
u-desk.exe
|
||||
u-fs-agent-linux
|
||||
docs/08-用户指南/u-desk-site/
|
||||
|
||||
228
app.go
228
app.go
@@ -8,16 +8,16 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
stdruntime "runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"u-desk/internal/api"
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/filesystem"
|
||||
"u-desk/internal/hotkey"
|
||||
osssvc "u-desk/internal/ossdrv"
|
||||
"u-desk/internal/service"
|
||||
"u-desk/internal/sftp"
|
||||
@@ -25,24 +25,28 @@ import (
|
||||
"u-desk/internal/storage/models"
|
||||
"u-desk/internal/system"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/w32"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// App 应用结构体
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
mainWindow *application.WebviewWindow
|
||||
updateAPI *api.UpdateAPI
|
||||
updateTicker *time.Ticker
|
||||
configAPI *api.ConfigAPI
|
||||
pdfAPI *api.PdfAPI
|
||||
filesystem *filesystem.FileSystemService
|
||||
sftpService *sftp.Service
|
||||
ossService *osssvc.Service
|
||||
profileSvc *service.ProfileService
|
||||
isAlwaysOnTop bool
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
mainWindow *application.WebviewWindow
|
||||
updateAPI *api.UpdateAPI
|
||||
updateTicker *time.Ticker
|
||||
configAPI *api.ConfigAPI
|
||||
pdfAPI *api.PdfAPI
|
||||
filesystem *filesystem.FileSystemService
|
||||
sftpService *sftp.Service
|
||||
ossService *osssvc.Service
|
||||
profileSvc *service.ProfileService
|
||||
isAlwaysOnTop bool
|
||||
mu sync.Mutex
|
||||
unregisterHotkey func()
|
||||
}
|
||||
|
||||
// App 方法命名约定:
|
||||
@@ -59,6 +63,41 @@ func (a *App) SetMainWindow(w *application.WebviewWindow) {
|
||||
a.mainWindow = w
|
||||
}
|
||||
|
||||
// RegisterGlobalHotkey 注册 Ctrl+Shift+B 全局热键(需在窗口创建后调用)
|
||||
func (a *App) RegisterGlobalHotkey() {
|
||||
if a.mainWindow == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.unregisterHotkey != nil {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
a.mu.Unlock()
|
||||
hwnd := uintptr(a.mainWindow.NativeWindow())
|
||||
if hwnd == 0 {
|
||||
fmt.Println("[全局热键] HWND 为 0,注册跳过")
|
||||
return
|
||||
}
|
||||
const id int32 = 1
|
||||
if err := hotkey.Register(hwnd, id, hotkey.ModControl|hotkey.ModShift, 0x42); err != nil {
|
||||
fmt.Printf("[全局热键] RegisterHotKey Ctrl+Shift+B 失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("[全局热键] Ctrl+Shift+B 已注册")
|
||||
a.mu.Lock()
|
||||
a.unregisterHotkey = func() { hotkey.Unregister(hwnd, id) }
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// HandleHotkey 处理全局热键回调:切换 BgmBar 显示/隐藏
|
||||
func (a *App) HandleHotkey() {
|
||||
if a.mainWindow == nil {
|
||||
return
|
||||
}
|
||||
a.mainWindow.EmitEvent("toggle-bgm-bar")
|
||||
}
|
||||
|
||||
// ServiceStartup Wails v3 服务启动生命周期(替代 v2 的 Startup)
|
||||
func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {
|
||||
a.ctx = ctx
|
||||
@@ -101,8 +140,8 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
|
||||
return fmt.Errorf("模块初始化失败: %w", err)
|
||||
}
|
||||
|
||||
// 5. 清理遗留的 SFTP 临时预览文件
|
||||
sftp.CleanupTempFiles()
|
||||
// 5. 清理过期的下载缓存
|
||||
storage.CleanupExpiredCache()
|
||||
|
||||
// 6. 异步初始化:UpdateAPI(涉及网络请求,完全异步)
|
||||
go func() {
|
||||
@@ -121,6 +160,24 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
|
||||
}
|
||||
}()
|
||||
|
||||
// 延迟注册全局热键(轮询等待原生窗口创建完成)
|
||||
// RegisterHotKey 必须在创建窗口的同一线程调用,
|
||||
// 通过 PostMessage 将注册请求投递到主线程消息循环
|
||||
go func() {
|
||||
for i := 0; i < 20; i++ {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if a.mainWindow == nil {
|
||||
return
|
||||
}
|
||||
hwnd := uintptr(a.mainWindow.NativeWindow())
|
||||
if hwnd != 0 {
|
||||
hotkey.PostMessage(hwnd, hotkey.WM_APP_HOTKEY, 0, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
fmt.Println("[全局热键] 等待窗口超时")
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -193,6 +250,9 @@ func (a *App) ServiceShutdown() error {
|
||||
if a.updateTicker != nil {
|
||||
a.updateTicker.Stop()
|
||||
}
|
||||
if a.unregisterHotkey != nil {
|
||||
a.unregisterHotkey()
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -217,7 +277,7 @@ func (a *App) ServiceShutdown() error {
|
||||
if a.sftpService != nil {
|
||||
sftp.GetManager().Shutdown()
|
||||
}
|
||||
sftp.CleanupTempFiles()
|
||||
storage.CleanupExpiredCache()
|
||||
|
||||
// 关闭所有 OSS 连接
|
||||
osssvc.GetManager().Shutdown()
|
||||
@@ -856,11 +916,11 @@ func (a *App) ensureSftpService() *sftp.Service {
|
||||
|
||||
// SftpConnectRequest SFTP 连接请求
|
||||
type SftpConnectRequest struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
KeyPath string `json:"key_path"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
KeyPath string `json:"key_path"`
|
||||
KeyPassphrase string `json:"key_passphrase"`
|
||||
}
|
||||
|
||||
@@ -874,8 +934,12 @@ func (a *App) SftpConnect(req SftpConnectRequest) (string, error) {
|
||||
KeyPath: req.KeyPath,
|
||||
KeyPassphrase: req.KeyPassphrase,
|
||||
}
|
||||
if config.Port == 0 { config.Port = 22 }
|
||||
if config.Timeout == 0 { config.Timeout = 15 * time.Second }
|
||||
if config.Port == 0 {
|
||||
config.Port = 22
|
||||
}
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 15 * time.Second
|
||||
}
|
||||
|
||||
svc := a.ensureSftpService()
|
||||
_, err := svc.GetManager().Connect(config)
|
||||
@@ -967,6 +1031,16 @@ func (a *App) SftpDownloadToTemp(connID string, remotePath string) (string, erro
|
||||
return a.ensureSftpService().DownloadToTemp(connID, remotePath)
|
||||
}
|
||||
|
||||
// SftpDownloadSiteForPreview 下载 HTML 及其网站资源到本地临时目录
|
||||
func (a *App) SftpDownloadSiteForPreview(connID string, remotePath string) (string, error) {
|
||||
return a.ensureSftpService().DownloadSiteForPreview(connID, remotePath)
|
||||
}
|
||||
|
||||
// SftpDownloadToTempCached 带缓存的 SFTP 下载(命中缓存直接返回本地路径)
|
||||
func (a *App) SftpDownloadToTempCached(connID string, remotePath string, fileSize int64, modTime string) (string, error) {
|
||||
return a.ensureSftpService().DownloadToTempCached(connID, remotePath, fileSize, modTime)
|
||||
}
|
||||
|
||||
// SftpGetCommonPaths 获取 SFTP 远程主机常用路径
|
||||
func (a *App) SftpGetCommonPaths(connID string) (map[string]string, error) {
|
||||
return a.ensureSftpService().GetCommonPaths(connID)
|
||||
@@ -1018,6 +1092,16 @@ func (a *App) OssDownloadToTemp(connID string, key string) (string, error) {
|
||||
return a.ensureOssService().DownloadToTemp(connID, key)
|
||||
}
|
||||
|
||||
// OssDownloadSiteForPreview OSS 下载 HTML 及其引用的资源到临时目录
|
||||
func (a *App) OssDownloadSiteForPreview(connID string, key string) (string, error) {
|
||||
return a.ensureOssService().DownloadSiteForPreview(connID, key)
|
||||
}
|
||||
|
||||
// OssDownloadToTempCached 带缓存的 OSS 下载(命中缓存直接返回本地路径)
|
||||
func (a *App) OssDownloadToTempCached(connID string, key string, fileSize int64, modTime string) (string, error) {
|
||||
return a.ensureOssService().DownloadToTempCached(connID, key, fileSize, modTime)
|
||||
}
|
||||
|
||||
// OssReadFile OSS 读取文件
|
||||
func (a *App) OssReadFile(connID string, key string) (string, error) {
|
||||
return a.ensureOssService().ReadFile(connID, key)
|
||||
@@ -1078,21 +1162,22 @@ func (a *App) OssGetSignedURL(connID string, key string) (string, error) {
|
||||
// --- 连接配置 CRUD (SQLite 持久化) ---
|
||||
|
||||
type SaveProfileRequest struct {
|
||||
ID *uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
KeyPath string `json:"key_path"`
|
||||
Type string `json:"type"`
|
||||
Token string `json:"token"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
LastConnected *int64 `json:"last_connected"`
|
||||
ID *uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
KeyPath string `json:"key_path"`
|
||||
Type string `json:"type"`
|
||||
Provider string `json:"provider"`
|
||||
Token string `json:"token"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
LastConnected *int64 `json:"last_connected"`
|
||||
}
|
||||
|
||||
func (a *App) ensureProfileSvc() *service.ProfileService {
|
||||
@@ -1106,7 +1191,9 @@ func (a *App) ensureProfileSvc() *service.ProfileService {
|
||||
|
||||
func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) {
|
||||
list, err := a.ensureProfileSvc().ListProfiles()
|
||||
if err != nil { return nil, err }
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]map[string]interface{}, len(list))
|
||||
for i, p := range list {
|
||||
result[i] = map[string]interface{}{
|
||||
@@ -1118,6 +1205,7 @@ func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) {
|
||||
"password": p.Password,
|
||||
"keyPath": p.KeyPath,
|
||||
"type": p.Type,
|
||||
"provider": p.Provider,
|
||||
"token": p.Token,
|
||||
"accessKey": p.AccessKey,
|
||||
"secretKey": p.SecretKey,
|
||||
@@ -1135,7 +1223,7 @@ func (a *App) SaveConnectionProfile(req SaveProfileRequest) (map[string]interfac
|
||||
p := &models.ConnectionProfile{
|
||||
Name: req.Name, Host: req.Host, Port: req.Port,
|
||||
Username: req.Username, Password: req.Password,
|
||||
KeyPath: req.KeyPath, Type: req.Type, Token: req.Token,
|
||||
KeyPath: req.KeyPath, Type: req.Type, Provider: req.Provider, Token: req.Token,
|
||||
AccessKey: req.AccessKey, SecretKey: req.SecretKey,
|
||||
Bucket: req.Bucket, Region: req.Region, Endpoint: req.Endpoint,
|
||||
}
|
||||
@@ -1161,18 +1249,70 @@ func (a *App) GetLocalSystemInfo() (map[string]interface{}, error) {
|
||||
|
||||
cpuInfo, err := system.GetCPUInfo()
|
||||
if err == nil && cpuInfo != nil {
|
||||
if v, ok := cpuInfo["usage"].(string); ok { info["cpu_usage"] = v }
|
||||
if v, ok := cpuInfo["usage"].(string); ok {
|
||||
info["cpu_usage"] = v
|
||||
}
|
||||
}
|
||||
|
||||
memInfo, err := system.GetMemoryInfo()
|
||||
if err == nil && memInfo != nil {
|
||||
if v, ok := memInfo["usage"].(string); ok { info["mem_usage"] = v }
|
||||
if v, ok := memInfo["usage"].(string); ok {
|
||||
info["mem_usage"] = v
|
||||
}
|
||||
}
|
||||
|
||||
diskInfos, err := system.GetDiskInfo()
|
||||
if err == nil && len(diskInfos) > 0 {
|
||||
if v, ok := diskInfos[0]["usage"].(string); ok { info["disk_usage"] = v }
|
||||
if v, ok := diskInfos[0]["usage"].(string); ok {
|
||||
info["disk_usage"] = v
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ========== BGM 播放列表持久化 ==========
|
||||
|
||||
// BgmPlaylistItem 播放列表条目
|
||||
type BgmPlaylistItem struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
}
|
||||
|
||||
// BgmGetPlaylist 获取播放列表
|
||||
func (a *App) BgmGetPlaylist() ([]BgmPlaylistItem, error) {
|
||||
db := storage.GetDB()
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("数据库未初始化")
|
||||
}
|
||||
var rows []models.BgmPlaylist
|
||||
db.Order("sort ASC").Find(&rows)
|
||||
items := make([]BgmPlaylistItem, len(rows))
|
||||
for i, r := range rows {
|
||||
items[i] = BgmPlaylistItem{Name: r.Name, Path: r.Path, ProfileID: r.ProfileID}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// BgmSavePlaylist 全量保存播放列表(前端调用时传完整列表)
|
||||
func (a *App) BgmSavePlaylist(items []BgmPlaylistItem) error {
|
||||
db := storage.GetDB()
|
||||
if db == nil {
|
||||
return fmt.Errorf("数据库未初始化")
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("DELETE FROM bgm_playlist").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
sort := uint(0)
|
||||
for _, item := range items {
|
||||
if item.Name == "" || item.Path == "" {
|
||||
continue
|
||||
}
|
||||
tx.Create(&models.BgmPlaylist{Name: item.Name, Path: item.Path, ProfileID: item.ProfileID, Sort: sort})
|
||||
sort++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
177
docs/04-功能迭代/GO-DESK-10.SFTP直连支持/README.md
Normal file
177
docs/04-功能迭代/GO-DESK-10.SFTP直连支持/README.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# GO-DESK-10: SFTP 直连支持
|
||||
|
||||
> 版本: v0.5.0 | 状态: 开发中 | 分支: fs-only-v3 → u-desk-sftp
|
||||
|
||||
## 概述
|
||||
|
||||
为 U-Desk 新增 **SFTP(SSH File Transfer Protocol)直连模式**,作为第三种文件系统传输方式,与现有的本地模式(Wails IPC)和远程 HTTP Agent 模式并列。
|
||||
|
||||
用户无需在目标机器部署任何 Agent 服务,仅需 SSH 账号即可直接浏览和操作远程 Linux 服务器的文件系统。
|
||||
|
||||
## 架构设计
|
||||
|
||||
```
|
||||
FsTransport (interface)
|
||||
├── WailsTransport → Wails IPC → FileSystemService (本地 os)
|
||||
├── HttpTransport → HTTP REST → u-fs-agent (远程部署)
|
||||
└── SftpTransport [NEW]→ Wails IPC → SftpService → pkg/sftp (SSH/SFTP)
|
||||
```
|
||||
|
||||
**核心原则**:复用现有 `FsTransport` 接口抽象,前端组件无感知。
|
||||
|
||||
## 技术方案
|
||||
|
||||
### 后端(Go)
|
||||
|
||||
**新增依赖**:
|
||||
- `github.com/pkg/sftp` — SFTP 客户端库
|
||||
- `golang.org/x/crypto/ssh` — SSH 协议(已在 indirect 中,提升为 direct)
|
||||
|
||||
**新增包 `internal/sftp/`**(4 个文件):
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `config.go` | `Config` 结构体:Host/Port/Username/Password/KeyPath/Timeout |
|
||||
| `client.go` | `Manager`(sync.Map 连接池,以 host:port 为 key)+ `Client`(单连接封装:SSH 握手、健康检查、自动重连、双认证) |
|
||||
| `service.go` | `Service`(对齐 FileSystemService 返回格式的文件操作方法) |
|
||||
| `errors.go` | `ConnectionError` + `ToUserMessage()` 中文友好错误映射 |
|
||||
|
||||
**连接管理**:
|
||||
- 以 `host:port` 为 key 的 `sync.Map` 连接池
|
||||
- 支持密码认证 / 私钥文件认证(二选一)
|
||||
- `WithRetry(fn)` — 操作前检查健康度,断线自动重连(3 次,指数退避)
|
||||
- `IsHealthy()` — 通过 `Stat("/")` 探测
|
||||
- 切换 profile 时复用已有连接(避免重复 SSH 握手)
|
||||
|
||||
**App 层新增绑定方法**(12 个):
|
||||
|
||||
```
|
||||
SftpConnect(req) → connID 建立连接
|
||||
SftpDisconnect(connID) 断开连接
|
||||
SftpListDir(connID, path) 列目录
|
||||
SftpReadFile(connID, path) 读文件
|
||||
SftpWriteFile(req) 写文件
|
||||
SftpGetFileInfo(connID, path) 文件信息
|
||||
SftpCreateDir(connID, path) 创建目录
|
||||
SftpCreateFile(connID, path) 创建文件
|
||||
SftpDeletePath(connID, path) 删除
|
||||
SftpRenamePath(req) 重命名
|
||||
SftpDownloadToTemp(connID, path) 下载到临时目录(预览用)
|
||||
SftpGetCommonPaths(connID) 远程主机常用路径
|
||||
```
|
||||
|
||||
### 前端(TypeScript/Vue)
|
||||
|
||||
**新建 `frontend/src/api/sftp-transport.ts`**:
|
||||
- 实现 `FsTransport` 接口的完整 23 个方法
|
||||
- `connect()/disconnect()/requireConn()` 管理 SFTP 会话生命周期
|
||||
- `downloadForPreview(remotePath)` 下载远程文件到本地临时目录(带缓存)
|
||||
- ZIP/回收站/openPath 等不适用方法抛出 "暂未实现"
|
||||
|
||||
**修改 `connection-manager.ts`**:
|
||||
- `ConnectionType` 扩展:`'local' | 'remote' | 'sftp'`
|
||||
- `ConnectionProfile` 新增字段:`username?`, `password?`, `keyPath?`
|
||||
- `applyActive()` 增加 sftp 分支(创建 SftpTransport → connect() → 连通性检查)
|
||||
- 新增 `isSftp()` 方法
|
||||
- `isRemote()` 扩展为包含 `'sftp'`
|
||||
- `getFileServerBaseURL()` sftp 模式返回 `'http://localhost:8073'`
|
||||
- 切换/删除 profile 时显式断开 SFTP 连接
|
||||
|
||||
**修改 `ConnectionDialog.vue`**:
|
||||
- 新增连接类型选择器(RadioGroup: HTTP Agent / SFTP)
|
||||
- SFTP 类型显示额外字段:用户名(默认 root)、密码、私钥路径
|
||||
- HTTP Agent 类型保持原有 Token 字段
|
||||
- 默认端口根据类型切换(SFTP=22,HTTP=9876)
|
||||
- `addProfile` 时 type 使用表单选择的值
|
||||
|
||||
**修改 `ConnectionIndicator.vue`**:
|
||||
- `.dot.sftp` 样式(紫色 `#7c3aed`)
|
||||
- `dotClass(p)` 函数返回 local/remote/sftp
|
||||
- "更多操作"按钮条件从 `type === 'remote'` 改为 `type !== 'local'`
|
||||
|
||||
**修改 `Sidebar.vue`**:
|
||||
- 模式标签区分显示:本地(绿)/远程(蓝)/SFTP(紫)
|
||||
- 新增 `isSftp` 响应式变量
|
||||
|
||||
**修改 `useFilePreview.ts`**:
|
||||
- SFTP 模式下 `updatePreviewUrl()` 先调用 `downloadForPreview()` 下载到本地临时目录
|
||||
- 下载完成后使用 `http://localhost:8073/localfs/{temp_path}` 预览(复用现有 LocalFileServer)
|
||||
- 已下载文件缓存避免重复下载
|
||||
|
||||
### 文件预览方案
|
||||
|
||||
**策略:下载到本地临时目录 + 复用 LocalFileServer**
|
||||
|
||||
```
|
||||
用户点击 SFTP 远程文件
|
||||
→ useFilePreview 检测 isSftp()
|
||||
→ SftpTransport.downloadForPreview(remotePath)
|
||||
→ Go 后端 SFTP 下载到 %TEMP%/udesk-sftp-preview-{filename}
|
||||
→ 返回本地绝对路径
|
||||
→ 预览 URL = http://localhost:8073/localfs/{temp_path}
|
||||
→ 现有 LocalFileServer 直接提供服务
|
||||
```
|
||||
|
||||
**临时文件管理**:
|
||||
- 启动时清理上次遗留的 `udesk-sftp-preview-*` 文件(`ServiceStartup` 中调用 `CleanupTempFiles()`)
|
||||
- 关闭时清理本次创建的临时文件(`ServiceShutdown` 中调用)
|
||||
- SftpTransport 内部维护 remotePath → localTempPath 缓存
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
### 新增(5 个)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `internal/sftp/config.go` | SFTP 配置结构体 |
|
||||
| `internal/sftp/client.go` | SSH/SFTP 连接管理器 |
|
||||
| `internal/sftp/service.go` | SFTP 文件操作服务 |
|
||||
| `internal/sftp/errors.go` | 错误处理 + 用户友好消息 |
|
||||
| `frontend/src/api/sftp-transport.ts` | 前端 FsTransport 实现 |
|
||||
|
||||
### 修改(8 个)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `go.mod` | 添加 `github.com/pkg/sftp` 依赖 |
|
||||
| `internal/filesystem/fs.go` | `formatBytes` 导出为 `FormatBytes` |
|
||||
| `app.go` | +sftpService 字段 + 12 个绑定方法 + Shutdown 扩展 + 清理临时文件 |
|
||||
| `frontend/src/api/connection-manager.ts` | 类型扩展 + sftp 分支 + isSftp() |
|
||||
| `frontend/src/.../ConnectionDialog.vue` | 类型选择器 + SFTP 表单 |
|
||||
| `frontend/src/.../ConnectionIndicator.vue` | sftp 样式 + dotClass |
|
||||
| `frontend/src/.../Sidebar.vue` | SFTP 模式标签 |
|
||||
| `frontend/src/.../useFilePreview.ts` | SFTP 下载预览 |
|
||||
|
||||
### 自动生成
|
||||
|
||||
| 文件 | 触发方式 |
|
||||
|------|---------|
|
||||
| `frontend/src/wailsjs/v3-bindings/u-desk/app.ts` | `wails dev` 启动时重新生成 |
|
||||
|
||||
## 与现有模式的对比
|
||||
|
||||
| 特性 | 本地 (WailsTransport) | 远程 (HttpTransport) | SFTP (SftpTransport) |
|
||||
|------|----------------------|---------------------|---------------------|
|
||||
| 协议 | Wails IPC | HTTP REST | SSH/SFTP |
|
||||
| 目标要求 | 本地桌面 | 部署 u-fs-agent | SSH 服务 |
|
||||
| 认证 | 无 | Bearer Token | 密码 / 私钥 |
|
||||
| 文件预览 | LocalFileServer | Agent 反向代理 | 下载到临时目录 |
|
||||
| ZIP 支持 | ✅ | ❌ | ❌ |
|
||||
| 回收站 | ✅ | ❌ | ❌ |
|
||||
| 延迟 | < 10ms | 取决于网络 | 取决于网络(首次握手 ~2s) |
|
||||
| 连接复用 | N/A | 每次请求 HTTP | sync.Map 连接池 |
|
||||
|
||||
## 后续迭代方向
|
||||
|
||||
### Phase 2(体验完善)
|
||||
- 密钥文件选择器(Wails OpenFileDialog)
|
||||
- 断线重连 UI 提示 + 手动重连按钮
|
||||
- 大文件传输进度显示
|
||||
- saveBase64File 二进制上传支持
|
||||
|
||||
### Phase 3(高级功能)
|
||||
- SSH known_hosts 安全验证(替换 InsecureIgnoreHostKey)
|
||||
- TCP KeepAlive + 应用层心跳防空闲断开
|
||||
- 端口转发(SOCKS5/本地转发)
|
||||
- 符号链接处理选项
|
||||
- 并发传输队列 + 带宽限制
|
||||
114
docs/04-功能迭代/GO-DESK-10.SFTP直连支持/开发经验.md
Normal file
114
docs/04-功能迭代/GO-DESK-10.SFTP直连支持/开发经验.md
Normal 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
|
||||
126
docs/04-功能迭代/GO-DESK-8.Wails-v3迁移/README.md
Normal file
126
docs/04-功能迭代/GO-DESK-8.Wails-v3迁移/README.md
Normal 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/ 下,如不再使用应清理
|
||||
66
docs/04-功能迭代/GO-DESK-9.插件系统/README.md
Normal file
66
docs/04-功能迭代/GO-DESK-9.插件系统/README.md
Normal 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 可独立交付验证。
|
||||
149
docs/04-功能迭代/GO-DESK-9.插件系统/任务规划/Phase0-基础设施.md
Normal file
149
docs/04-功能迭代/GO-DESK-9.插件系统/任务规划/Phase0-基础设施.md
Normal 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 3:Tab 注册表
|
||||
|
||||
**新建** `internal/plugin/tab_registry.go`
|
||||
|
||||
内容:
|
||||
- `TabRegistry` struct(mu + entries map)
|
||||
- Register(冲突检测)、GetByTabKey、GetAllDefinitions(按 Order 排序)、GetAllProviders、Count
|
||||
|
||||
依赖:Step 1
|
||||
|
||||
---
|
||||
|
||||
### Step 4:预览注册表
|
||||
|
||||
**新建** `internal/plugin/preview_registry.go`
|
||||
|
||||
内容:
|
||||
- `PreviewRegistry` struct(mu + handlers 切片,按 priority 降序)
|
||||
- Register(自动排序)、Resolve(遍历匹配第一个 canHandle)、GetAllHandlers、Count
|
||||
|
||||
依赖:Step 1
|
||||
|
||||
---
|
||||
|
||||
### Step 5:PluginManager
|
||||
|
||||
**新建** `internal/plugin/manager.go`
|
||||
|
||||
内容:
|
||||
- `Manager` struct(plugins map + core + tabReg + previewReg + ctx + initialized)
|
||||
- NewManager、Register(自动分发到子注册表 + 回滚)、InitAll、StartByTabKey
|
||||
- GetPluginInfos、ResolvePreview(附加 PluginID 到响应)、GetTabDefinitions、Shutdown
|
||||
|
||||
依赖:Step 1 ~ 4 全部
|
||||
|
||||
---
|
||||
|
||||
### Step 6:前端类型定义
|
||||
|
||||
**新建** `frontend/src/plugin/types.ts`
|
||||
|
||||
内容:PluginCapability enum、PluginMetadata、TabPluginDefinition、FilePreviewHandlerDefinition、RenderConfig 接口。
|
||||
|
||||
依赖:无
|
||||
|
||||
---
|
||||
|
||||
### Step 7:前端注册中心
|
||||
|
||||
**新建** `frontend/src/plugin/registry.ts`
|
||||
|
||||
内容:
|
||||
- Vue reactive state(tabPlugins Map + previewHandlers 数组)
|
||||
- Tab API:registerTabPlugin / getTabComponent / getAllTabDefinitions / hasTabPlugin
|
||||
- Preview API:registerPreviewHandler(按 priority 插入)/ resolvePreviewHandler / getAllPreviewHandlers
|
||||
- 调试工具:getRegistryStats
|
||||
|
||||
依赖:Step 6
|
||||
|
||||
---
|
||||
|
||||
### Step 8:集成到 App
|
||||
|
||||
**修改** `app.go`
|
||||
|
||||
改动点:
|
||||
|
||||
1. **新增字段**:`pluginMgr *plugin.Manager`(在 `mu` 字段之后)
|
||||
2. **ServiceStartup 中**(步骤 4 之后):初始化 pluginMgr
|
||||
```go
|
||||
fmt.Println("[启动] 初始化插件管理器...")
|
||||
a.pluginMgr = plugin.NewManager(plugin.NewAdapter(a))
|
||||
```
|
||||
3. **ServiceShutdown 末尾**:关闭 pluginMgr
|
||||
```go
|
||||
if a.pluginMgr != nil { a.pluginMgr.Shutdown() }
|
||||
```
|
||||
4. **新增绑定方法**(Wails v3 自动暴露前端):
|
||||
- `GetPluginInfos() ([]map[string]interface{}, error)` — 返回空数组或插件列表
|
||||
- `ResolvePreview(req ResolvePreviewRequest) (map[string]interface{}, error)` — 解析文件预览处理器
|
||||
- `ResolvePreviewRequest{Filename string}` 请求结构体
|
||||
|
||||
依赖:Step 5
|
||||
|
||||
---
|
||||
|
||||
## 验证方案
|
||||
|
||||
### 编译验证
|
||||
|
||||
```bash
|
||||
go build ./internal/plugin/
|
||||
go build .
|
||||
cd frontend && npx vue-tsc --noEmit
|
||||
```
|
||||
|
||||
### 运行时验证
|
||||
|
||||
| # | 操作 | 预期结果 |
|
||||
|---|------|---------|
|
||||
| V1 | 运行应用 | 日志出现 `[启动] 初始化插件管理器...` |
|
||||
| V2 | 关闭应用 | 日志出现 `[插件管理器] 已关闭` |
|
||||
| V3 | DevTools: `GetPluginInfos()` | 返回 `{success:true, data:[]}` |
|
||||
| V4 | DevTools: `ResolvePreview({filename:'test'})` | 返回 `{success:false, message:"no preview handler..."}` |
|
||||
| V5 | 前端 import registry | 无 TS 错误 |
|
||||
|
||||
### 边界情况
|
||||
|
||||
| 场景 | 预期行为 |
|
||||
|------|---------|
|
||||
| pluginMgr 为 nil 调用 GetPluginInfos | 返回空数组,不 panic |
|
||||
| pluginMgr 为 nil 调用 ResolvePreview | 返回 success:false,不 panic |
|
||||
| TabRegistry.Register 同一 TabKey 两次 | 返回冲突错误 |
|
||||
| PreviewRegistry.Resolve 无匹配文件 | 返回 nil, "" |
|
||||
| Manager.Shutdown 无插件注册 | 正常返回 nil |
|
||||
167
docs/04-功能迭代/GO-DESK-9.插件系统/任务规划/实施路线图.md
Normal file
167
docs/04-功能迭代/GO-DESK-9.插件系统/任务规划/实施路线图.md
Normal 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 3:Tab 系统插件化 + 设置面板 + 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 改为查 registry;KeepAlive 动态化;接入 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
|
||||
```
|
||||
54
docs/04-功能迭代/GO-DESK-9.插件系统/决策记录/README.md
Normal file
54
docs/04-功能迭代/GO-DESK-9.插件系统/决策记录/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 决策记录
|
||||
|
||||
## ADR-001:CoreServices 使用 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-002:plugin_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 只建表
|
||||
81
docs/04-功能迭代/GO-DESK-9.插件系统/设计文档/复杂度与价值评估.md
Normal file
81
docs/04-功能迭代/GO-DESK-9.插件系统/设计文档/复杂度与价值评估.md
Normal 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 1(Draw.io 验证插件),用真实插件验证骨架的完整性。
|
||||
227
docs/04-功能迭代/GO-DESK-9.插件系统/设计文档/接口定义.md
Normal file
227
docs/04-功能迭代/GO-DESK-9.插件系统/设计文档/接口定义.md
Normal 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 }
|
||||
```
|
||||
73
docs/04-功能迭代/GO-DESK-9.插件系统/设计文档/数据模型.md
Normal file
73
docs/04-功能迭代/GO-DESK-9.插件系统/设计文档/数据模型.md
Normal 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 端口号) | 用户修改插件设置时 |
|
||||
250
docs/04-功能迭代/GO-DESK-9.插件系统/设计文档/架构设计.md
Normal file
250
docs/04-功能迭代/GO-DESK-9.插件系统/设计文档/架构设计.md
Normal 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 控制排列顺序 |
|
||||
| **隔离渲染** | 每个插槽内的插件组件有独立作用域,避免样式/状态污染 |
|
||||
| **优雅降级** | 插件组件报错不影响宿主 UI(ErrorBoundary 包裹) |
|
||||
|
||||
## 五、关键改造点对比
|
||||
|
||||
### 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>
|
||||
```
|
||||
263
docs/04-功能迭代/生态链接/01-音乐平台.md
Normal file
263
docs/04-功能迭代/生态链接/01-音乐平台.md
Normal 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.0(Spotify 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*
|
||||
335
docs/04-功能迭代/生态链接/02-视频平台.md
Normal file
335
docs/04-功能迭代/生态链接/02-视频平台.md
Normal 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 内播放视频 | ❌ | ✅ YouTube(IFrame 嵌入) |
|
||||
| 国际内容消费 | ❌ | ✅ 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 API,WebView2 原生运行
|
||||
- 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*
|
||||
335
docs/04-功能迭代/生态链接/03-广播电台.md
Normal file
335
docs/04-功能迭代/生态链接/03-广播电台.md
Normal 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.44(2026-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 / TTL(7种!)
|
||||
|
||||
### 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*
|
||||
295
docs/04-功能迭代/生态链接/04-课程专栏.md
Normal file
295
docs/04-功能迭代/生态链接/04-课程专栏.md
Normal 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/
|
||||
- 能力:课程目录/详情/学习者进度
|
||||
- 主要面向机构合作
|
||||
- 个人开发者可获取基础课程数据
|
||||
|
||||
### 中国大学 MOOC(icourse163)
|
||||
- **官网**: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/*
|
||||
494
docs/04-功能迭代/生态链接/05-生活服务.md
Normal file
494
docs/04-功能迭代/生态链接/05-生活服务.md
Normal 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*
|
||||
106
docs/04-功能迭代/生态链接/README.md
Normal file
106
docs/04-功能迭代/生态链接/README.md
Normal 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 以内**。
|
||||
1
docs/08-用户指南/u-desk-site
Submodule
1
docs/08-用户指南/u-desk-site
Submodule
Submodule docs/08-用户指南/u-desk-site added at 8f29a7e985
BIN
docs/clipboard_20260505_022416.png
Normal file
BIN
docs/clipboard_20260505_022416.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 493 KiB |
BIN
docs/clipboard_20260505_022516.png
Normal file
BIN
docs/clipboard_20260505_022516.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -21,12 +21,28 @@ import * as filesystem$0 from "./internal/filesystem/models.js";
|
||||
// @ts-ignore: Unused imports
|
||||
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 检查更新
|
||||
*/
|
||||
export function CheckUpdate(): $CancellablePromise<{ [_ in string]?: 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> {
|
||||
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> {
|
||||
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> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }[]> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }[]> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }[]> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 安装更新包
|
||||
*/
|
||||
export function InstallUpdate(installerPath: string, autoRestart: boolean): $CancellablePromise<{ [_ in string]?: 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 }> {
|
||||
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 }[]> {
|
||||
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 }[]> {
|
||||
return $Call.ByID(3013109042, zipPath).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
return $$createType5($result);
|
||||
});
|
||||
}
|
||||
|
||||
export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?: 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> {
|
||||
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> {
|
||||
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> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* OssDownloadSiteForPreview OSS 下载 HTML 及其引用的资源到临时目录
|
||||
*/
|
||||
export function OssDownloadSiteForPreview(connID: string, key: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1387550222, connID, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* OssDownloadToTemp OSS 下载到临时文件
|
||||
*/
|
||||
@@ -347,12 +377,19 @@ export function OssDownloadToTemp(connID: string, key: string): $CancellableProm
|
||||
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 获取常用路径
|
||||
*/
|
||||
export function OssGetCommonPaths(connID: string): $CancellablePromise<{ [_ in string]?: string }> {
|
||||
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 }> {
|
||||
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 }[]> {
|
||||
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> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* RegisterGlobalHotkey 注册 Ctrl+Shift+B 全局热键(需在窗口创建后调用)
|
||||
*/
|
||||
export function RegisterGlobalHotkey(): $CancellablePromise<void> {
|
||||
return $Call.ByID(2089930789);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload 重新加载窗口(用于菜单项)
|
||||
*/
|
||||
@@ -430,7 +474,7 @@ export function Reload(): $CancellablePromise<void> {
|
||||
*/
|
||||
export function RenamePath(req: $models.RenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* SftpDownloadSiteForPreview 下载 HTML 及其网站资源到本地临时目录
|
||||
*/
|
||||
export function SftpDownloadSiteForPreview(connID: string, remotePath: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1591575570, connID, remotePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
||||
*/
|
||||
@@ -550,12 +601,19 @@ export function SftpDownloadToTemp(connID: string, remotePath: string): $Cancell
|
||||
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 远程主机常用路径
|
||||
*/
|
||||
export function SftpGetCommonPaths(connID: string): $CancellablePromise<{ [_ in string]?: string }> {
|
||||
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 }> {
|
||||
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 }> {
|
||||
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 }[]> {
|
||||
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> {
|
||||
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 }> {
|
||||
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
|
||||
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType1 = filesystem$0.FileOperationResult.createFrom;
|
||||
const $$createType2 = $Create.Nullable($$createType1);
|
||||
const $$createType3 = $Create.Array($$createType0);
|
||||
const $$createType4 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType0 = $models.BgmPlaylistItem.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType2 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType3 = filesystem$0.FileOperationResult.createFrom;
|
||||
const $$createType4 = $Create.Nullable($$createType3);
|
||||
const $$createType5 = $Create.Array($$createType2);
|
||||
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
};
|
||||
|
||||
export {
|
||||
BgmPlaylistItem,
|
||||
OssConnectRequest,
|
||||
OssRenamePathRequest,
|
||||
RenamePathRequest,
|
||||
|
||||
@@ -9,6 +9,38 @@ import { Create as $Create } from "@wailsio/runtime";
|
||||
// @ts-ignore: Unused imports
|
||||
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 {
|
||||
"provider": string;
|
||||
"access_key": string;
|
||||
@@ -183,6 +215,7 @@ export class SaveProfileRequest {
|
||||
"password": string;
|
||||
"key_path": string;
|
||||
"type": string;
|
||||
"provider": string;
|
||||
"token": string;
|
||||
"access_key": string;
|
||||
"secret_key": string;
|
||||
@@ -217,6 +250,9 @@ export class SaveProfileRequest {
|
||||
if (!("type" in $$source)) {
|
||||
this["type"] = "";
|
||||
}
|
||||
if (!("provider" in $$source)) {
|
||||
this["provider"] = "";
|
||||
}
|
||||
if (!("token" in $$source)) {
|
||||
this["token"] = "";
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
SftpGetSystemInfo, GetLocalSystemInfo,
|
||||
} 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 {
|
||||
id: string | number
|
||||
@@ -23,6 +24,7 @@ export interface ConnectionProfile {
|
||||
port: number
|
||||
token: string
|
||||
type: ConnectionType
|
||||
provider?: OssProvider
|
||||
username?: string
|
||||
password?: string
|
||||
keyPath?: string
|
||||
@@ -129,6 +131,7 @@ class ConnectionManagerImpl {
|
||||
password: profile.password || '',
|
||||
keyPath: profile.keyPath || '',
|
||||
type: profile.type,
|
||||
provider: profile.provider || '',
|
||||
token: profile.token || '',
|
||||
access_key: profile.accessKey || '',
|
||||
secret_key: profile.secretKey || '',
|
||||
@@ -200,7 +203,7 @@ class ConnectionManagerImpl {
|
||||
|
||||
isRemote(): boolean {
|
||||
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 {
|
||||
@@ -221,7 +224,7 @@ class ConnectionManagerImpl {
|
||||
const data = await SftpGetSystemInfo(t.sessionId)
|
||||
if (data) Object.assign(info, snakeToCamel(data))
|
||||
}
|
||||
} else if (profile.type === 'qiniu' || profile.type === 'aliyun') {
|
||||
} else if (profile.type === 'oss') {
|
||||
// OSS 无系统信息可采集
|
||||
info.diskUsage = '-'
|
||||
info.cpuUsage = '-'
|
||||
@@ -363,8 +366,8 @@ class ConnectionManagerImpl {
|
||||
return
|
||||
}
|
||||
|
||||
// OSS (qiniu / aliyun)
|
||||
if (profile.type === 'qiniu' || profile.type === 'aliyun') {
|
||||
// OSS
|
||||
if (profile.type === 'oss') {
|
||||
this.setState('connecting')
|
||||
try {
|
||||
const t = new OssTransport(profile)
|
||||
|
||||
@@ -30,10 +30,5 @@ export function getFileServerBaseURL(): string {
|
||||
return _cachedURL || FALLBACK_URL
|
||||
}
|
||||
|
||||
/** 获取带 /localfs 后缀的完整 base */
|
||||
export function getLocalFsBaseURL(): string {
|
||||
return `${getFileServerBaseURL()}/localfs`
|
||||
}
|
||||
|
||||
/** 启动时自动初始化 */
|
||||
initFileServerURL().catch(() => {})
|
||||
|
||||
@@ -133,4 +133,8 @@ export class HttpTransport implements FsTransport {
|
||||
async deletePermanently(_path: string): Promise<void> {}
|
||||
|
||||
async emptyRecycleBin(): Promise<void> {}
|
||||
|
||||
async downloadForPreview(path: string): Promise<string> {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { ConnectionProfile } from './connection-manager'
|
||||
import {
|
||||
OssConnect, OssDisconnect, OssListDir, OssReadFile,
|
||||
OssWriteFile, OssGetFileInfo, OssCreateDir, OssCreateFile,
|
||||
OssDeletePath, OssRenamePath, OssDownloadToTemp, OssGetCommonPaths,
|
||||
OssWriteBase64File, OssGetSignedURL,
|
||||
OssDeletePath, OssRenamePath, OssDownloadToTemp, OssDownloadSiteForPreview,
|
||||
OssGetCommonPaths, OssWriteBase64File, OssGetSignedURL,
|
||||
} from '@bindings/u-desk/app'
|
||||
|
||||
function transformFile(file: any): FileItem {
|
||||
@@ -21,13 +21,9 @@ function transformFileList(files: any[]): FileItem[] {
|
||||
return files.map(transformFile)
|
||||
}
|
||||
|
||||
const PREVIEW_CACHE_MAX = 50
|
||||
|
||||
export class OssTransport implements FsTransport {
|
||||
private profile: ConnectionProfile
|
||||
private connID: string | null = null
|
||||
private previewCache = new Map<string, string>()
|
||||
private previewOrder: string[] = []
|
||||
|
||||
constructor(profile: ConnectionProfile) {
|
||||
this.profile = profile
|
||||
@@ -35,7 +31,7 @@ export class OssTransport implements FsTransport {
|
||||
|
||||
async connect(): Promise<string> {
|
||||
const result = await OssConnect({
|
||||
provider: this.profile.type,
|
||||
provider: this.profile.provider || 'qiniu',
|
||||
access_key: this.profile.accessKey || '',
|
||||
secret_key: this.profile.secretKey || '',
|
||||
endpoint: this.profile.endpoint || '',
|
||||
@@ -53,8 +49,6 @@ export class OssTransport implements FsTransport {
|
||||
}
|
||||
this.connID = null
|
||||
}
|
||||
this.previewCache.clear()
|
||||
this.previewOrder = []
|
||||
}
|
||||
|
||||
private requireConn(): string {
|
||||
@@ -169,22 +163,14 @@ export class OssTransport implements FsTransport {
|
||||
|
||||
// ====== 预览辅助 ======
|
||||
|
||||
/** 下载远程文件到本地临时目录用于预览(缓存由 Go 后端管理) */
|
||||
async downloadForPreview(remotePath: string): Promise<string> {
|
||||
if (this.previewCache.has(remotePath)) {
|
||||
this.previewOrder = this.previewOrder.filter(p => p !== remotePath)
|
||||
this.previewOrder.push(remotePath)
|
||||
return this.previewCache.get(remotePath)!
|
||||
}
|
||||
const localPath = await OssDownloadToTemp(this.requireConn(), remotePath)
|
||||
return OssDownloadToTemp(this.requireConn(), remotePath)
|
||||
}
|
||||
|
||||
if (this.previewOrder.length >= PREVIEW_CACHE_MAX) {
|
||||
const oldest = this.previewOrder.shift()!
|
||||
this.previewCache.delete(oldest)
|
||||
}
|
||||
|
||||
this.previewCache.set(remotePath, localPath)
|
||||
this.previewOrder.push(remotePath)
|
||||
return localPath
|
||||
/** 下载 HTML 及其引用的资源到临时目录用于网站预览 */
|
||||
async downloadSiteForPreview(remotePath: string): Promise<string> {
|
||||
return OssDownloadSiteForPreview(this.requireConn(), remotePath)
|
||||
}
|
||||
|
||||
/** 获取预签名 URL(用于直接预览) */
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { ConnectionProfile } from './connection-manager'
|
||||
import {
|
||||
SftpConnect, SftpDisconnect, SftpListDir, SftpReadFile,
|
||||
SftpWriteFile, SftpGetFileInfo, SftpCreateDir, SftpCreateFile,
|
||||
SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, SftpGetCommonPaths,
|
||||
SftpWriteBase64File,
|
||||
SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, SftpDownloadSiteForPreview,
|
||||
SftpGetCommonPaths, SftpWriteBase64File,
|
||||
} from '@bindings/u-desk/app'
|
||||
|
||||
function transformFile(file: any): FileItem {
|
||||
@@ -21,13 +21,9 @@ function transformFileList(files: any[]): FileItem[] {
|
||||
return files.map(transformFile)
|
||||
}
|
||||
|
||||
const PREVIEW_CACHE_MAX = 50
|
||||
|
||||
export class SftpTransport implements FsTransport {
|
||||
private profile: ConnectionProfile
|
||||
private connID: string | null = null
|
||||
private previewCache = new Map<string, string>() // remotePath -> localTempPath (LRU)
|
||||
private previewOrder: string[] = [] // LRU 排序键列表
|
||||
|
||||
constructor(profile: ConnectionProfile) {
|
||||
this.profile = profile
|
||||
@@ -55,8 +51,6 @@ export class SftpTransport implements FsTransport {
|
||||
}
|
||||
this.connID = null
|
||||
}
|
||||
this.previewCache.clear()
|
||||
this.previewOrder = []
|
||||
}
|
||||
|
||||
private requireConn(): string {
|
||||
@@ -176,24 +170,13 @@ export class SftpTransport implements FsTransport {
|
||||
|
||||
// ====== 预览辅助 ======
|
||||
|
||||
/** 下载远程文件到本地临时目录用于预览(带 LRU 缓存,上限 50) */
|
||||
/** 下载远程文件到本地临时目录用于预览(缓存由 Go 后端管理) */
|
||||
async downloadForPreview(remotePath: string): Promise<string> {
|
||||
// 命中:移到队尾(最近使用)
|
||||
if (this.previewCache.has(remotePath)) {
|
||||
this.previewOrder = this.previewOrder.filter(p => p !== remotePath)
|
||||
this.previewOrder.push(remotePath)
|
||||
return this.previewCache.get(remotePath)!
|
||||
}
|
||||
const localPath = await SftpDownloadToTemp(this.requireConn(), remotePath)
|
||||
return SftpDownloadToTemp(this.requireConn(), remotePath)
|
||||
}
|
||||
|
||||
// 淘汰最旧条目
|
||||
if (this.previewOrder.length >= PREVIEW_CACHE_MAX) {
|
||||
const oldest = this.previewOrder.shift()!
|
||||
this.previewCache.delete(oldest)
|
||||
}
|
||||
|
||||
this.previewCache.set(remotePath, localPath)
|
||||
this.previewOrder.push(remotePath)
|
||||
return localPath
|
||||
/** 下载 HTML 及其网站资源到临时目录用于预览 */
|
||||
async downloadSiteForPreview(remotePath: string): Promise<string> {
|
||||
return SftpDownloadSiteForPreview(this.requireConn(), remotePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +68,12 @@ export interface FsTransport {
|
||||
restoreFromRecycleBin(path: string): Promise<void>
|
||||
deletePermanently(path: string): 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>
|
||||
}
|
||||
|
||||
@@ -116,4 +116,8 @@ export class WailsTransport implements FsTransport {
|
||||
async emptyRecycleBin(): Promise<void> {
|
||||
await EmptyRecycleBin()
|
||||
}
|
||||
|
||||
async downloadForPreview(path: string): Promise<string> {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<!-- OSS 厂商(表单行,仅在选中云OSS时显示) -->
|
||||
<div v-if="category === 'oss'" style="display: flex; align-items: center; gap: 8px">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">厂商</label>
|
||||
<a-radio-group v-model="form.type" type="button" size="small">
|
||||
<a-radio-group v-model="form.provider" type="button" size="small">
|
||||
<a-radio value="qiniu">七牛云</a-radio>
|
||||
<a-radio value="aliyun">阿里云</a-radio>
|
||||
</a-radio-group>
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">AK</label>
|
||||
<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 { GetEnvVars } from '@bindings/u-desk/app'
|
||||
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 emit = defineEmits<{ (e: 'update:visible', val: boolean): void; (e: 'close'): void }>()
|
||||
@@ -113,6 +113,7 @@ const form = reactive({
|
||||
port: 22,
|
||||
token: '',
|
||||
type: 'sftp' as ConnectionType,
|
||||
provider: 'qiniu' as OssProvider,
|
||||
username: 'root',
|
||||
password: '',
|
||||
keyPath: '',
|
||||
@@ -124,12 +125,9 @@ const form = reactive({
|
||||
})
|
||||
|
||||
const category = computed({
|
||||
get: () => {
|
||||
if (form.type === 'qiniu' || form.type === 'aliyun') return 'oss'
|
||||
return form.type
|
||||
},
|
||||
get: () => form.type,
|
||||
set: (v: string) => {
|
||||
if (v === 'oss') form.type = 'qiniu'
|
||||
if (v === 'oss') form.type = 'oss'
|
||||
else form.type = v as ConnectionType
|
||||
},
|
||||
})
|
||||
@@ -140,6 +138,7 @@ watch(() => props.visible, (val) => {
|
||||
Object.assign(form, {
|
||||
name: '', host: '', port: 22, token: '',
|
||||
type: 'sftp' as ConnectionType,
|
||||
provider: 'qiniu' as OssProvider,
|
||||
username: 'root', password: '', keyPath: '',
|
||||
accessKey: '', secretKey: '', bucket: '', region: '', endpoint: '',
|
||||
})
|
||||
@@ -153,7 +152,7 @@ watch(() => form.type, (t) => {
|
||||
|
||||
async function handleOk(): Promise<boolean> {
|
||||
if (!form.name?.trim()) { Message.warning('请输入名称'); return false }
|
||||
const isOss = form.type === 'qiniu' || form.type === 'aliyun'
|
||||
const isOss = form.type === 'oss'
|
||||
if (isOss) {
|
||||
if (!form.accessKey?.trim()) { Message.warning('请输入 AccessKey'); return false }
|
||||
if (!form.secretKey?.trim()) { Message.warning('请输入 SecretKey'); return false }
|
||||
@@ -200,6 +199,7 @@ function editProfile(id: string) {
|
||||
port: profile.port,
|
||||
token: profile.token || '',
|
||||
type: profile.type || 'remote',
|
||||
provider: (profile.provider as OssProvider) || 'qiniu',
|
||||
username: profile.username || 'root',
|
||||
password: profile.password || '',
|
||||
keyPath: profile.keyPath || '',
|
||||
|
||||
@@ -4,10 +4,46 @@
|
||||
<icon-cloud />
|
||||
</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>
|
||||
|
||||
<!-- 悬停弹出:根目录子目录列表 -->
|
||||
<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 class="menu-header">远程连接</div>
|
||||
<div
|
||||
@@ -39,58 +75,176 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { IconCloud } from '@arco-design/web-vue/es/icon'
|
||||
import { ref, computed, shallowRef, onMounted, onUnmounted, provide, watch } from 'vue'
|
||||
import { IconCloud, IconFolder, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
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 moreOpenId = ref<string | null>(null)
|
||||
const profiles = shallowRef(connectionManager.profiles)
|
||||
const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
||||
|
||||
// 是否有远程/SFTP profile(决定显示模式)
|
||||
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 p = profiles.value.find(p => p.id === activeId.value)
|
||||
if (!p || p.type === 'local') return '本地'
|
||||
return p.name
|
||||
})
|
||||
|
||||
// 监听连接变化,主动触发更新(带防抖)
|
||||
connectionManager.onStateChange((newState) => {
|
||||
connectionManager.onStateChange(() => {
|
||||
profiles.value = connectionManager.profiles
|
||||
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) {
|
||||
const el = e.target as HTMLElement
|
||||
if (!el.closest('.connection-indicator')) {
|
||||
showMenu.value = false
|
||||
moreOpenId.value = null
|
||||
showDirDropdown.value = false
|
||||
closeAllMenusFn()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
if (_stateTimer) clearTimeout(_stateTimer)
|
||||
})
|
||||
|
||||
// === 原有方法 ===
|
||||
async function handleSelect(p: { id: string }) {
|
||||
showMenu.value = false
|
||||
try {
|
||||
@@ -120,9 +274,17 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
function dotClass(p: { type: string }): string {
|
||||
if (p.type === 'sftp') return 'sftp'
|
||||
if (p.type === 'remote') return 'remote'
|
||||
if (p.type === 'qiniu' || p.type === 'aliyun') return 'oss'
|
||||
if (p.type === 'oss') return 'oss'
|
||||
return 'local'
|
||||
}
|
||||
|
||||
// 路径变化时重置目录列表状态
|
||||
watch(() => props.filePath, () => {
|
||||
showDirDropdown.value = false
|
||||
dirChildren.value = []
|
||||
dirLastLoadedPath.value = ''
|
||||
openMenus.value = new Map()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -280,6 +442,65 @@ function dotClass(p: { type: string }): string {
|
||||
}
|
||||
.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 {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
// 播放列表(持久化到 SQLite,url 运行时生成)
|
||||
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>
|
||||
@@ -83,10 +83,22 @@
|
||||
|
||||
<!-- 音频预览 -->
|
||||
<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 class="media-meta">
|
||||
<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>
|
||||
|
||||
@@ -388,6 +400,18 @@
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -395,15 +419,21 @@
|
||||
import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted, onUnmounted } from '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 { getFileName, escapeHtml } from '@/utils/fileUtils'
|
||||
import { getFileName, escapeHtml, getParentPath, getExt } from '@/utils/fileUtils'
|
||||
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||
import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||
import { getFileCategory } from '@/utils/fileTypeHelpers'
|
||||
import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
|
||||
import { isDirty } from '../composables/useMultiPreview'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
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 组件,减少初始包大小
|
||||
const AsyncCodeEditor = defineAsyncComponent({
|
||||
@@ -485,28 +515,38 @@ const getFileTypeIcon = (filename: string): string => {
|
||||
return CATEGORY_ICONS[getFileCategory(filename)] || '📄'
|
||||
}
|
||||
|
||||
// HTML 预览 URL(实时从 connectionManager 读取,不缓存)
|
||||
function resolveHtmlPreviewBase(): string {
|
||||
if (!connectionManager.isRemote()) return 'http://localhost:2652'
|
||||
const base = connectionManager.getFileServerBaseURL()
|
||||
if (!base) return 'http://localhost:2652'
|
||||
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs(htmlPreviewUrl 会替换为 html-preview)
|
||||
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||
}
|
||||
// HTML 预览 URL(OSS 需要先下载到本地再预览)
|
||||
const htmlPreviewUrl = ref('')
|
||||
|
||||
const htmlPreviewUrl = computed(() => {
|
||||
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) return ''
|
||||
const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
|
||||
const isRemote = connectionManager.isRemote()
|
||||
const base = resolveHtmlPreviewBase()
|
||||
if (isRemote) {
|
||||
// 远程模式:走 /api/v1/proxy/html-preview 路由
|
||||
const baseUrl = base.replace(/\/proxy\/localfs\/?$/, '')
|
||||
return `${baseUrl}/proxy/html-preview?path=${encodedPath}`
|
||||
}
|
||||
// 本地模式:直连文件服务器
|
||||
return `${base}/localfs/html-preview?path=${encodedPath}`
|
||||
})
|
||||
watch(
|
||||
() => [props.config.currentFileFullPath, props.config.isHtmlFile],
|
||||
async ([path, isHtml]) => {
|
||||
htmlPreviewUrl.value = ''
|
||||
if (!path || !isHtml) return
|
||||
|
||||
// 下载到临时目录后是本地文件,统一用本地文件服务器
|
||||
const base = getFileServerBaseURL()
|
||||
const makeUrl = (localPath: string) => `${base}/localfs/html-preview?path=${encodeURIComponent(localPath)}`
|
||||
|
||||
if (connectionManager.isRemote()) {
|
||||
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(() => {
|
||||
@@ -566,10 +606,172 @@ const handleImageError = () => {
|
||||
}
|
||||
|
||||
const mediaErrorMsg = ref('')
|
||||
watch(() => props.config.previewUrl, () => { mediaErrorMsg.value = '' })
|
||||
const handleMediaError = (type: string) => {
|
||||
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 iframe = event.target as HTMLIFrameElement
|
||||
try {
|
||||
@@ -879,7 +1081,7 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||
const allowedOrigins = [
|
||||
window.location.origin,
|
||||
'null',
|
||||
resolveHtmlPreviewBase(), // 动态:本地 localhost:2652 或远程代理地址
|
||||
getFileServerBaseURL(),
|
||||
]
|
||||
if (!allowedOrigins.includes(event.origin)) {
|
||||
return
|
||||
@@ -896,6 +1098,9 @@ onMounted(() => {
|
||||
window.addEventListener('message', handleHtmlIframeMessage)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
On('toggle-bgm-bar', () => {
|
||||
bgmHidden.value = !bgmHidden.value
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -905,8 +1110,11 @@ onUnmounted(() => {
|
||||
window.removeEventListener('message', handleHtmlIframeMessage)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
Off('toggle-bgm-bar')
|
||||
copyCleanup()
|
||||
})
|
||||
|
||||
defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHidden.value }, playAudioAsBGM })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<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">
|
||||
<span class="section-title">🖥️ 服务器</span>
|
||||
<icon-down v-if="!serverCollapsed" class="section-toggle" />
|
||||
<icon-right v-else class="section-toggle" />
|
||||
</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">
|
||||
<span class="col-name">名称</span>
|
||||
@@ -19,16 +21,16 @@
|
||||
</div>
|
||||
<!-- 表格行 -->
|
||||
<div
|
||||
v-for="p in profiles"
|
||||
v-for="p in visibleProfiles"
|
||||
:key="p.id"
|
||||
class="server-table-row"
|
||||
:class="{ active: p.id === activeId }"
|
||||
@click="handleSelect(p)"
|
||||
>
|
||||
<span class="col-name" :title="stateText(p.id)"><span :class="['dot', stateDotClass(p.id)]" />{{ p.name }}</span>
|
||||
<span class="col-metric" :title="metricTooltip(p.id, 'disk')">{{ sysInfo(p.id)?.diskUsage || '-' }}</span>
|
||||
<span class="col-metric" :title="metricTooltip(p.id, 'cpu')">{{ sysInfo(p.id)?.cpuUsage || '-' }}</span>
|
||||
<span class="col-metric" :title="metricTooltip(p.id, 'mem')">{{ sysInfo(p.id)?.memUsage || '-' }}</span>
|
||||
<span class="col-metric" :class="metricWarnClass(p.id, 'disk')" :title="metricTooltip(p.id, 'disk')">{{ sysInfo(p.id)?.diskUsage || '-' }}</span>
|
||||
<span class="col-metric" :class="metricWarnClass(p.id, 'cpu')" :title="metricTooltip(p.id, 'cpu')">{{ sysInfo(p.id)?.cpuUsage || '-' }}</span>
|
||||
<span class="col-metric" :class="metricWarnClass(p.id, 'mem')" :title="metricTooltip(p.id, 'mem')">{{ sysInfo(p.id)?.memUsage || '-' }}</span>
|
||||
<span
|
||||
v-if="p.type !== 'local'"
|
||||
class="col-action more-btn"
|
||||
@@ -44,8 +46,7 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 管理设置面板(放在 section-content/overflow 容器外部) -->
|
||||
</div>
|
||||
<div v-if="settingsOpen" class="settings-panel" @click.stop>
|
||||
<div class="settings-item" @click="$emit('openConnectionDialog'); settingsOpen = false">
|
||||
<icon-plus /> 添加服务器
|
||||
@@ -58,18 +59,30 @@
|
||||
<icon-check-circle :style="{ opacity: autoRefresh ? 1 : 0.3 }" />
|
||||
自动刷新系统信息 (15s)
|
||||
</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 class="sidebar-section">
|
||||
<div v-if="section === 'favorites' && showFavorites" class="sidebar-section fav-section">
|
||||
<div class="section-header" @click="favCollapsed = !favCollapsed">
|
||||
<span class="section-title">⭐ 收藏夹</span>
|
||||
<span class="section-count">共{{ config.favoriteFiles.length }}项</span>
|
||||
<icon-down v-if="!favCollapsed" class="section-toggle" />
|
||||
<icon-right v-else class="section-toggle" />
|
||||
</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
|
||||
v-for="(fav, index) in config.favoriteFiles"
|
||||
:key="fav.path"
|
||||
@@ -124,22 +137,26 @@
|
||||
<span class="sidebar-hint">点击文件列表中的星标收藏</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 帮助文档区块 -->
|
||||
<div class="sidebar-section">
|
||||
<div v-if="section === 'help' && showHelp" class="sidebar-section">
|
||||
<div class="section-header" @click="helpCollapsed = !helpCollapsed">
|
||||
<span class="section-title">📖 帮助</span>
|
||||
<icon-down v-if="!helpCollapsed" class="section-toggle" />
|
||||
<icon-right v-else class="section-toggle" />
|
||||
</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">
|
||||
<span class="help-key">{{ item.key }}</span>
|
||||
<span class="help-desc">{{ item.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
@@ -149,10 +166,13 @@ import { ref, computed, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
||||
import type { SystemInfo } 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 { getFileIcon } from '@/utils/fileUtils'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
config: SidebarConfig
|
||||
@@ -165,10 +185,18 @@ const serverCollapsed = ref(false)
|
||||
const favCollapsed = 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 autoConnect = ref(localStorage.getItem('desk:autoConnect') !== 'false')
|
||||
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
|
||||
|
||||
function toggleAutoConnect() {
|
||||
@@ -189,7 +217,7 @@ function toggleAutoRefresh() {
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh()
|
||||
refreshTimer = setInterval(() => {
|
||||
profiles.value.forEach(p => { connectionManager.fetchSystemInfo(p.id).catch(() => {}) })
|
||||
visibleProfiles.value.forEach(p => { connectionManager.fetchSystemInfo(p.id).catch(() => {}) })
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
@@ -221,6 +249,21 @@ const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
||||
const moreOpenId = ref<string | null>(null)
|
||||
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(() => {
|
||||
profiles.value = connectionManager.profiles
|
||||
@@ -345,6 +388,23 @@ function metricTooltip(profileId: string, type: 'disk' | 'cpu' | 'mem'): string
|
||||
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 {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1048576) return `${(n / 1024).toFixed(0)} KB`
|
||||
@@ -411,6 +471,36 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
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 {
|
||||
z-index: 30;
|
||||
}
|
||||
@@ -454,19 +544,19 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
/* 区块内容 - 可折叠 */
|
||||
.section-content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease, opacity 0.2s ease;
|
||||
max-height: calc(100vh - 80px);
|
||||
opacity: 1;
|
||||
/* 区块折叠容器 - grid 动画精确匹配内容高度 */
|
||||
.section-content-wrap {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 0.2s ease;
|
||||
}
|
||||
|
||||
.section-content.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
.section-content-wrap.collapsed {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
.section-content-wrap > .section-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 收藏夹内容 - 内部独立滚动 */
|
||||
@@ -581,6 +671,10 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
color: var(--color-text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.col-metric.metric-warn {
|
||||
color: #e53e3e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.col-action {
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
@@ -659,6 +753,17 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.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 {
|
||||
@@ -675,7 +780,7 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<!-- 正常模式:连接指示器 + 面包屑导航(融合布局) -->
|
||||
<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>
|
||||
<!-- 路径面包屑 -->
|
||||
<PathBreadcrumb
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ref } from 'vue'
|
||||
import { STORAGE_KEYS, DEFAULTS } from '@/utils/constants'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import type { FavoriteFile, FileItem, DraggingState } from '@/types/file-system'
|
||||
|
||||
export function useFavorites() {
|
||||
@@ -37,15 +38,33 @@ export function useFavorites() {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.FAVORITE_FILES)
|
||||
if (stored) {
|
||||
const loaded = JSON.parse(stored) as FavoriteFile[]
|
||||
let migrated = false
|
||||
|
||||
// 数据迁移:将旧字段 is_dir 转换为 isDir
|
||||
favorites.value = loaded.map(fav => ({
|
||||
...fav,
|
||||
isDir: fav.isDir ?? (fav as any).is_dir ?? false
|
||||
}))
|
||||
// 数据迁移:将旧字段 is_dir 转换为 isDir,补充缺失的 profileId
|
||||
favorites.value = loaded.map(fav => {
|
||||
const fixed = {
|
||||
...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()
|
||||
if (migrated) saveFavorites()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载收藏列表失败:', error)
|
||||
@@ -83,11 +102,19 @@ export function useFavorites() {
|
||||
return false
|
||||
}
|
||||
|
||||
favorites.value.push({
|
||||
const newFav: FavoriteFile = {
|
||||
...file,
|
||||
addedAt: Date.now()
|
||||
} as FavoriteFile)
|
||||
sortFavorites()
|
||||
addedAt: Date.now(),
|
||||
profileId: connectionManager.activeProfile?.id
|
||||
}
|
||||
|
||||
// 插入到第一个非置顶项位置(置顶项之后、非置顶项之前)
|
||||
const insertIdx = favorites.value.findIndex(f => !f.pinnedAt)
|
||||
if (insertIdx === -1) {
|
||||
favorites.value.push(newFav)
|
||||
} else {
|
||||
favorites.value.splice(insertIdx, 0, newFav)
|
||||
}
|
||||
saveFavorites()
|
||||
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) => {
|
||||
if (event instanceof MouseEvent && event.button !== 0) return
|
||||
|
||||
@@ -127,9 +127,9 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
* 计算属性:文件内容是否改变
|
||||
*/
|
||||
const contentChanged = computed(() => {
|
||||
return fileContent.value !== '' &&
|
||||
originalContent.value !== undefined &&
|
||||
originalContent.value !== fileContent.value
|
||||
if (fileContent.value === '' || originalContent.value === undefined) return false
|
||||
// 统一 CRLF → LF 再比较,避免编辑器内部 \n 与文件原始 \r\n 产生误判
|
||||
return originalContent.value.replace(/\r\n/g, '\n') !== fileContent.value.replace(/\r\n/g, '\n')
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -428,9 +428,13 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
return
|
||||
}
|
||||
|
||||
// 恢复草稿内容
|
||||
fileContent.value = draft.content
|
||||
Message.info('已恢复未保存的草稿')
|
||||
// 恢复草稿内容(仅当内容不同时才覆盖,避免无意义的脏标记)
|
||||
if (draft.content !== fileContent.value) {
|
||||
fileContent.value = draft.content
|
||||
Message.info('已恢复未保存的草稿')
|
||||
} else {
|
||||
clearDraft()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载草稿失败:', error)
|
||||
@@ -484,6 +488,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
const resetContent = () => {
|
||||
if (originalContent.value !== undefined) {
|
||||
fileContent.value = originalContent.value
|
||||
clearDraft()
|
||||
Message.info('已恢复原始内容')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,13 @@ import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { normalizeFilePath, getExt } from '@/utils/fileUtils'
|
||||
import { detectFileTypeByContent } from '@/api/system'
|
||||
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 {
|
||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
||||
isHtmlFile, isMarkdownFile,
|
||||
isTextEditable, isConfigFile
|
||||
} 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
|
||||
@@ -25,26 +23,73 @@ const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB
|
||||
const contentDetectCache = new Map<string, { timestamp: number; result: any }>()
|
||||
const CACHE_TTL = 60000 // 1分钟缓存
|
||||
|
||||
export interface UseFilePreviewOptions {
|
||||
filePath?: string
|
||||
isBrowsingZip?: boolean
|
||||
}
|
||||
// ====== URL 工具函数(导出供外部使用) ======
|
||||
|
||||
function getLocalServerURL(): string {
|
||||
return getFileServerBaseURL()
|
||||
}
|
||||
|
||||
function resolveFileServerBase(): string {
|
||||
// 单一数据源:从 connectionManager 实时读取,不缓存
|
||||
if (!connectionManager.isRemote()) return getLocalServerURL()
|
||||
/** 解析文件服务器 base URL(区分本地/远程模式) */
|
||||
export function resolveFileServerBase(): string {
|
||||
if (!connectionManager.isRemote()) return getFileServerBaseURL()
|
||||
const base = connectionManager.getFileServerBaseURL()
|
||||
if (!base) return getLocalServerURL()
|
||||
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs
|
||||
if (!base) return getFileServerBaseURL()
|
||||
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||
}
|
||||
|
||||
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
||||
/** 拼接本地文件服务器 URL(远程文件下载到临时目录后使用) */
|
||||
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
|
||||
const previewUrl = ref('')
|
||||
@@ -55,46 +100,24 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
|
||||
/**
|
||||
* 获取预览 URL(本地/远程/SFTP 自适应,每次实时计算)
|
||||
* 本地: {fileServerBaseURL}/localfs/{encoded_path}
|
||||
* 远程(HTTP): {baseUrl}/api/v1/proxy/localfs/{raw_path}
|
||||
* SFTP: 下载到本地临时目录 → {fileServerBaseURL}/localfs/{temp_path}
|
||||
*/
|
||||
const getPreviewUrl = (path: string): string => {
|
||||
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()
|
||||
let normalized = normalizeFilePath(path, true)
|
||||
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
if (normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
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> => {
|
||||
// 如果文件太大,跳过内容检测
|
||||
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)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.result
|
||||
}
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) return cached.result
|
||||
|
||||
try {
|
||||
const result = await detectFileTypeByContent(path)
|
||||
@@ -108,28 +131,15 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
|
||||
/**
|
||||
* 更新预览 URL
|
||||
* SFTP/OSS:下载到本地临时目录后用本地文件服务器预览
|
||||
*/
|
||||
const updatePreviewUrl = async (path: string) => {
|
||||
if (!path) { previewUrl.value = ''; return }
|
||||
const transport = connectionManager.getTransport()
|
||||
|
||||
// SFTP / OSS:下载到本地临时目录后用本地文件服务器预览
|
||||
if (transport instanceof SftpTransport || transport instanceof OssTransport) {
|
||||
try {
|
||||
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 {
|
||||
// 下载失败,回退
|
||||
}
|
||||
try {
|
||||
previewUrl.value = await resolveFileUrl(path)
|
||||
} catch (e) {
|
||||
console.warn('[Preview] 下载失败:', path, e)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
previewUrl.value = getPreviewUrl(path)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,22 +161,11 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
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 => {
|
||||
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) return false
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = getExt(filename)
|
||||
return FILE_EXTENSIONS.CODE.includes(ext) ||
|
||||
@@ -187,71 +186,28 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
imageLoading.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片加载失败
|
||||
*/
|
||||
const onImageError = () => {
|
||||
imageLoading.value = false
|
||||
currentImageDimensions.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始加载图片
|
||||
*/
|
||||
const startImageLoad = () => {
|
||||
imageLoading.value = true
|
||||
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 {
|
||||
// 状态
|
||||
previewUrl,
|
||||
imageLoading,
|
||||
currentImageDimensions,
|
||||
|
||||
// URL 相关
|
||||
getPreviewUrl,
|
||||
updatePreviewUrl,
|
||||
|
||||
// 文件类型判断(同步,基于扩展名)
|
||||
resolveFileUrl,
|
||||
getFileType,
|
||||
isPreviewable,
|
||||
isEditable,
|
||||
|
||||
// 内容检测(异步,基于文件内容)
|
||||
detectByContent,
|
||||
|
||||
// 事件处理
|
||||
onImageLoad,
|
||||
onImageError,
|
||||
startImageLoad,
|
||||
|
||||
// 工具方法
|
||||
getMediaMetadata
|
||||
startImageLoad
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { STORAGE_KEYS } from '@/utils/constants'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
|
||||
export interface PreviewTab {
|
||||
@@ -12,6 +13,8 @@ export interface PreviewTab {
|
||||
id: string
|
||||
/** 文件信息 */
|
||||
fileItem: FileItem
|
||||
/** 所属连接 profileId */
|
||||
profileId?: string
|
||||
/** 缓存的预览 URL */
|
||||
previewUrl: string
|
||||
/** 缓存的文件内容 */
|
||||
@@ -30,6 +33,7 @@ export interface PreviewTab {
|
||||
interface PersistedTab {
|
||||
path: string
|
||||
active: boolean
|
||||
profileId?: string
|
||||
/** 未保存的内容(有修改时才存) */
|
||||
unsavedContent?: string
|
||||
originalContent?: string
|
||||
@@ -48,11 +52,13 @@ interface UnsavedEntry {
|
||||
interface RestoredSession {
|
||||
paths: string[]
|
||||
activePath: string | null
|
||||
profileMap: Map<string, string | undefined>
|
||||
unsavedMap: Map<string, UnsavedEntry>
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -69,9 +75,10 @@ export function useMultiPreview() {
|
||||
})
|
||||
|
||||
/** 创建一个新 tab */
|
||||
const createTab = (fileItem: FileItem): PreviewTab => ({
|
||||
const createTab = (fileItem: FileItem, profileId?: string): PreviewTab => ({
|
||||
id: pathToId(fileItem.path),
|
||||
fileItem,
|
||||
profileId,
|
||||
previewUrl: '',
|
||||
fileContent: '',
|
||||
originalContent: '',
|
||||
@@ -85,19 +92,21 @@ export function useMultiPreview() {
|
||||
/** 从 localStorage 恢复会话 */
|
||||
const restoreSession = (): RestoredSession => {
|
||||
const unsavedMap = new Map<string, UnsavedEntry>()
|
||||
const profileMap = new Map<string, string | undefined>()
|
||||
let activePath: string | null = null
|
||||
const paths: string[] = []
|
||||
|
||||
try {
|
||||
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)
|
||||
if (!Array.isArray(persisted)) return { paths, activePath, unsavedMap }
|
||||
if (!Array.isArray(persisted)) return { paths, activePath, profileMap, unsavedMap }
|
||||
|
||||
for (const p of persisted) {
|
||||
if (!p.path) continue
|
||||
paths.push(p.path)
|
||||
profileMap.set(pathToId(p.path), p.profileId)
|
||||
if (p.active) activePath = p.path
|
||||
if (p.unsavedContent !== undefined) {
|
||||
unsavedMap.set(pathToId(p.path), {
|
||||
@@ -111,18 +120,19 @@ export function useMultiPreview() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
return { paths, activePath, unsavedMap }
|
||||
return { paths, activePath, profileMap, unsavedMap }
|
||||
}
|
||||
|
||||
/** 保存会话到 localStorage */
|
||||
const persistSession = () => {
|
||||
const persisted: PersistedTab[] = tabs.value.map(tab => {
|
||||
const hasUnsaved = tab.fileContent && tab.originalContent !== undefined && tab.fileContent !== tab.originalContent
|
||||
const hasUnsaved = isDirty(tab)
|
||||
// 限制存储大小,超过 100KB 的内容不存入 localStorage
|
||||
const canSave = hasUnsaved && tab.fileContent.length <= 100_000
|
||||
return {
|
||||
path: tab.fileItem.path,
|
||||
active: tab.id === activeTabId.value,
|
||||
profileId: tab.profileId,
|
||||
unsavedContent: canSave ? tab.fileContent : undefined,
|
||||
originalContent: canSave ? tab.originalContent : undefined,
|
||||
isEditMode: canSave ? tab.isEditMode : undefined
|
||||
@@ -148,7 +158,7 @@ export function useMultiPreview() {
|
||||
}
|
||||
|
||||
/** 添加或激活 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 existing = tabs.value.find(t => t.id === id)
|
||||
if (existing) {
|
||||
@@ -162,7 +172,7 @@ export function useMultiPreview() {
|
||||
if (victimIdx !== -1) tabs.value.splice(victimIdx, 1)
|
||||
}
|
||||
|
||||
const tab = createTab(fileItem)
|
||||
const tab = createTab(fileItem, profileId)
|
||||
tabs.value.push(tab)
|
||||
activeTabId.value = tab.id
|
||||
return { tab, isNew: true }
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { PathHistory } from '@/types/file-system'
|
||||
|
||||
export interface UsePathNavigationOptions {
|
||||
onListDirectory?: (path: string) => Promise<void>
|
||||
/** 获取当前 profileId,用于历史记录绑定 */
|
||||
getCurrentProfileId?: () => string | undefined
|
||||
initialPath?: string
|
||||
}
|
||||
|
||||
@@ -20,7 +22,6 @@ const restoreLastPath = (): string | null => {
|
||||
try {
|
||||
const lastPath = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.FILE_PATH)
|
||||
if (lastPath) {
|
||||
// 规范化旧路径(可能包含反斜杠)
|
||||
return normalizePathSeparators(lastPath)
|
||||
}
|
||||
return lastPath
|
||||
@@ -42,33 +43,31 @@ const saveLastPath = (path: string) => {
|
||||
}
|
||||
|
||||
export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
const { onListDirectory, initialPath = '' } = options
|
||||
const { onListDirectory, getCurrentProfileId, initialPath = '' } = options
|
||||
|
||||
// 尝试恢复上次的路径,如果没有则使用初始路径
|
||||
const savedPath = restoreLastPath()
|
||||
const filePath = ref(savedPath || initialPath)
|
||||
|
||||
// 历史记录
|
||||
// 历史记录(每条路径绑定 profileId)
|
||||
const history = ref<PathHistory>({
|
||||
paths: [],
|
||||
profileIds: [],
|
||||
currentIndex: -1
|
||||
})
|
||||
|
||||
/**
|
||||
* 导航到指定路径(带错误处理)
|
||||
* 导航到指定路径
|
||||
*/
|
||||
const navigate = async (path: string) => {
|
||||
if (!path || path === filePath.value) return
|
||||
|
||||
try {
|
||||
// 路径规范化(处理反斜杠并统一为正斜杠)
|
||||
const normalizedPath = normalizePathSeparators(path)
|
||||
filePath.value = normalizedPath
|
||||
|
||||
// 添加到历史记录
|
||||
addToHistory(normalizedPath)
|
||||
const profileId = getCurrentProfileId?.()
|
||||
addToHistory(normalizedPath, profileId)
|
||||
|
||||
// 触发目录列出
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(normalizedPath)
|
||||
}
|
||||
@@ -78,32 +77,28 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到历史记录
|
||||
*/
|
||||
const addToHistory = (path: string) => {
|
||||
const { paths, currentIndex } = history.value
|
||||
/** 添加到历史记录 */
|
||||
const addToHistory = (path: string, profileId?: string) => {
|
||||
const { paths, profileIds, currentIndex } = history.value
|
||||
|
||||
// 如果当前不在历史记录末尾,删除当前位置之后的所有记录
|
||||
if (currentIndex < paths.length - 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]
|
||||
if (lastPath !== path) {
|
||||
history.value.paths.push(path)
|
||||
history.value.profileIds.push(profileId)
|
||||
history.value.currentIndex = history.value.paths.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后退(带错误处理)
|
||||
*/
|
||||
const back = async () => {
|
||||
/** 后退,返回目标 profileId */
|
||||
const back = async (): Promise<string | undefined> => {
|
||||
const { paths, currentIndex } = history.value
|
||||
|
||||
if (currentIndex <= 0) return
|
||||
if (currentIndex <= 0) return undefined
|
||||
|
||||
try {
|
||||
const newIndex = currentIndex - 1
|
||||
@@ -113,19 +108,18 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(paths[newIndex])
|
||||
}
|
||||
return history.value.profileIds[newIndex]
|
||||
} catch (error) {
|
||||
console.error('后退失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 前进(带错误处理)
|
||||
*/
|
||||
const forward = async () => {
|
||||
/** 前进,返回目标 profileId */
|
||||
const forward = async (): Promise<string | undefined> => {
|
||||
const { paths, currentIndex } = history.value
|
||||
|
||||
if (currentIndex >= paths.length - 1) return
|
||||
if (currentIndex >= paths.length - 1) return undefined
|
||||
|
||||
try {
|
||||
const newIndex = currentIndex + 1
|
||||
@@ -135,45 +129,42 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(paths[newIndex])
|
||||
}
|
||||
return history.value.profileIds[newIndex]
|
||||
} catch (error) {
|
||||
console.error('前进失败:', 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) => {
|
||||
navigate(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径输入回车
|
||||
*/
|
||||
const onPathEnter = (value: string) => {
|
||||
navigate(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览目录(双击或回车)
|
||||
*/
|
||||
const browseDirectory = async (path: string) => {
|
||||
await navigate(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取父目录路径
|
||||
*/
|
||||
const getParentPath = (path: string): string => {
|
||||
const separator = path.includes('\\') ? '\\' : '/'
|
||||
const lastSeparator = path.lastIndexOf(separator)
|
||||
return lastSeparator > 0 ? path.substring(0, lastSeparator) : path
|
||||
}
|
||||
|
||||
/**
|
||||
* 上级目录
|
||||
*/
|
||||
const goUp = async () => {
|
||||
const parentPath = getParentPath(filePath.value)
|
||||
if (parentPath !== filePath.value) {
|
||||
@@ -181,35 +172,22 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径规范化(统一为正斜杠)
|
||||
*/
|
||||
const normalizePath = (path: string): string => {
|
||||
return normalizePathSeparators(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否可以后退
|
||||
*/
|
||||
const canGoBack = computed(() => {
|
||||
return history.value.currentIndex > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 判断是否可以前进
|
||||
*/
|
||||
const canGoForward = computed(() => {
|
||||
return history.value.currentIndex < history.value.paths.length - 1
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取历史记录列表(用于自动完成)
|
||||
*/
|
||||
const getPathHistory = computed(() => {
|
||||
return history.value.paths.slice().reverse() // 最新的在前
|
||||
return history.value.paths.slice().reverse()
|
||||
})
|
||||
|
||||
// 监听路径变化,自动保存到 localStorage
|
||||
watch(filePath, (newPath) => {
|
||||
if (newPath) {
|
||||
saveLastPath(newPath)
|
||||
@@ -217,31 +195,23 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
filePath,
|
||||
history,
|
||||
|
||||
// 导航方法
|
||||
navigate,
|
||||
back,
|
||||
forward,
|
||||
goUp,
|
||||
browseDirectory,
|
||||
|
||||
// 事件处理
|
||||
onPathSelect,
|
||||
onPathEnter,
|
||||
|
||||
// 工具方法
|
||||
getParentPath,
|
||||
normalizePath,
|
||||
|
||||
// 计算属性
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
getPathHistory
|
||||
getPathHistory,
|
||||
getHistoryProfileId,
|
||||
getProfileIdForPath
|
||||
}
|
||||
}
|
||||
|
||||
// 导出类型(用于外部使用)
|
||||
export type { PathHistory }
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
|
||||
<!-- 文件编辑器面板(始终显示,无选中文件时为空白预览区) -->
|
||||
<FileEditorPanel
|
||||
ref="fileEditorPanelRef"
|
||||
:config="fileEditorPanelConfig"
|
||||
:width="panelWidth.right"
|
||||
:current-directory="filePath"
|
||||
@@ -129,7 +130,7 @@ import ContextMenu from './components/ContextMenu.vue'
|
||||
import { useFileOperations } from './composables/useFileOperations'
|
||||
import { useFavorites } from './composables/useFavorites'
|
||||
import { usePathNavigation } from './composables/usePathNavigation'
|
||||
import { useFilePreview } from './composables/useFilePreview'
|
||||
import { useFilePreview, resolveFileUrl, resolveFileServerBase } from './composables/useFilePreview'
|
||||
import { useFileEdit } from './composables/useFileEdit'
|
||||
import { useCommonPaths } from './composables/useCommonPaths'
|
||||
import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMultiPreview'
|
||||
@@ -165,6 +166,7 @@ const fileList = ref<FileItem[]>([])
|
||||
const fileLoading = ref(false)
|
||||
const selectedFileItem = ref<FileItem | 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 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 { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath } =
|
||||
const { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath, getProfileIdForPath } =
|
||||
usePathNavigation({
|
||||
onListDirectory: async (path) => {
|
||||
await loadDirectory(path)
|
||||
}
|
||||
},
|
||||
getCurrentProfileId: () => connectionManager.activeProfile?.id
|
||||
})
|
||||
|
||||
// 文件预览
|
||||
const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, detectByContent } =
|
||||
useFilePreview({
|
||||
filePath
|
||||
})
|
||||
useFilePreview()
|
||||
|
||||
// 文件编辑
|
||||
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(() => ({
|
||||
filePath: filePath.value || '',
|
||||
pathHistory: history.value.paths.slice(-10),
|
||||
pathHistory: history.value.paths.slice(-50),
|
||||
commonPaths: commonPaths.value,
|
||||
isBrowsingZip: false,
|
||||
displayPath: '',
|
||||
@@ -373,10 +374,8 @@ const computeRendered = computed(() => {
|
||||
|
||||
// 设置文件服务器 Base URL
|
||||
const isRemote = connectionManager.isRemote()
|
||||
const base = isRemote
|
||||
? (connectionManager.getFileServerBaseURL().replace(/\/$/, '') + '/api/v1/proxy/localfs')
|
||||
: 'http://localhost:2652/localfs'
|
||||
setFileServerBase(base)
|
||||
const base = resolveFileServerBase()
|
||||
setFileServerBase(isRemote ? base : base + '/localfs')
|
||||
|
||||
return marked.parse(content) as string
|
||||
} catch (error) {
|
||||
@@ -441,9 +440,12 @@ const handleRefresh = async () => {
|
||||
await loadDirectory(filePath.value)
|
||||
}
|
||||
|
||||
// 程序化切换 profile 时抑制自动导航(如打开收藏/tab切换)
|
||||
let _suppressAutoNav = false
|
||||
|
||||
// 连接切换后重置路径并刷新文件列表
|
||||
connectionManager.onStateChange(async (state) => {
|
||||
if (state === 'connected') {
|
||||
if (state === 'connected' && !_suppressAutoNav) {
|
||||
await loadCommonPaths()
|
||||
const targetPath = connectionManager.isRemote() ? '/' : 'C:/'
|
||||
filePath.value = targetPath
|
||||
@@ -462,6 +464,12 @@ const handleConnectionChanged = async () => {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -478,6 +486,10 @@ const handleOpenFile = async (path: string) => {
|
||||
if (targetFile.isDir) {
|
||||
// 是目录,导航进入
|
||||
await navigate(path)
|
||||
} else if (isAudioFile(targetFile.name)) {
|
||||
// 音频文件:加入 BGM 播放列表,不打开 tab
|
||||
const url = await resolveFileUrl(path, true)
|
||||
fileEditorPanelRef.value?.playAudioAsBGM(targetFile.name, path, url)
|
||||
} else {
|
||||
// 是文件,先加载内容,再更新选中状态(避免闪烁)
|
||||
await loadFileContent(path)
|
||||
@@ -558,29 +570,54 @@ const handleShowMessage = (message: string, type: 'success' | 'error' | 'warning
|
||||
|
||||
// 侧边栏事件
|
||||
const handleOpenFavorite = async (file: FavoriteFile) => {
|
||||
// 根据路径格式自动切换连接(Linux 路径 → 远程,Windows 路径 → 本地)
|
||||
const isLinuxPath = /^[\/]/.test(file.path) && !/^[A-Za-z]:/.test(file.path)
|
||||
const shouldBeRemote = isLinuxPath
|
||||
const isCurrentlyRemote = connectionManager.isRemote()
|
||||
const currentProfileId = connectionManager.activeProfile?.id
|
||||
const needSwitch = file.profileId && file.profileId !== currentProfileId
|
||||
|
||||
if (shouldBeRemote !== isCurrentlyRemote) {
|
||||
// 需要切换连接
|
||||
if (shouldBeRemote) {
|
||||
// 切换到远程:找第一个 remote profile
|
||||
const remoteProfile = connectionManager.profiles.find(p => p.type === 'remote')
|
||||
if (remoteProfile) {
|
||||
connectionManager.connect(remoteProfile.id)
|
||||
}
|
||||
} else {
|
||||
// 切换到本地
|
||||
connectionManager.disconnect()
|
||||
if (needSwitch) {
|
||||
_suppressAutoNav = true
|
||||
try {
|
||||
await connectionManager.connect(file.profileId)
|
||||
await loadCommonPaths()
|
||||
} catch (e) {
|
||||
console.error('切换连接失败:', e)
|
||||
} finally {
|
||||
_suppressAutoNav = false
|
||||
}
|
||||
} 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) {
|
||||
await navigate(file.path)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
@@ -621,6 +658,9 @@ const handleDragEnd = () => {
|
||||
const handleFileClick = async (file: FileItem) => {
|
||||
if (file.isDir) {
|
||||
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 {
|
||||
openFileAsTab(file)
|
||||
}
|
||||
@@ -1065,19 +1105,33 @@ const isMediaPreviewable = (filename: string): boolean => {
|
||||
|
||||
/** 激活 tab:设置选中项 + 加载或恢复内容 */
|
||||
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
|
||||
if (tab.loaded) {
|
||||
restoreTabState(tab)
|
||||
} else {
|
||||
await loadFileContent(tab.fileItem.path)
|
||||
tab.loaded = true
|
||||
// 首次加载完成后确保 dirty 状态正确(加载过程中 fileContent/originalContent 可能不同步)
|
||||
tab.originalContent = fileContent.value
|
||||
}
|
||||
}
|
||||
|
||||
/** 文件 → 添加到 tab 并激活 */
|
||||
const openFileAsTab = async (file: FileItem) => {
|
||||
cacheCurrentTabState()
|
||||
const { tab } = multiPreview.addTab(file)
|
||||
const currentProfileId = connectionManager.activeProfile?.id
|
||||
const { tab } = multiPreview.addTab(file, currentProfileId)
|
||||
await activateTab(tab)
|
||||
}
|
||||
|
||||
@@ -1382,10 +1436,20 @@ onMounted(async () => {
|
||||
|
||||
// 恢复多文件预览会话
|
||||
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) {
|
||||
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()) {
|
||||
multiPreview.activeTabId.value = tab.id
|
||||
}
|
||||
@@ -1499,6 +1563,13 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
handleToggleEditMode()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+Shift+B 切换 BGM 播放条显隐
|
||||
if (driveLetter === 'B') {
|
||||
event.preventDefault()
|
||||
fileEditorPanelRef.value?.toggleBgmVisibility()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+S 保存
|
||||
@@ -1541,7 +1612,11 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
isNavigating.value = true
|
||||
try {
|
||||
await back()
|
||||
const targetProfileId = await back()
|
||||
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
||||
_suppressAutoNav = true
|
||||
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
|
||||
}
|
||||
} finally {
|
||||
isNavigating.value = false
|
||||
}
|
||||
@@ -1553,7 +1628,11 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
isNavigating.value = true
|
||||
try {
|
||||
await forward()
|
||||
const targetProfileId = await forward()
|
||||
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
||||
_suppressAutoNav = true
|
||||
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
|
||||
}
|
||||
} finally {
|
||||
isNavigating.value = false
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<a-tabs default-active-key="tab-config">
|
||||
<!-- 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">
|
||||
|
||||
<!-- 说明文字 -->
|
||||
@@ -76,6 +76,47 @@
|
||||
至少需要保留一个可见的 Tab
|
||||
</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-button type="primary" @click="handleSave" :loading="saving">
|
||||
@@ -133,7 +174,8 @@ const visible = computed({
|
||||
const localConfig = ref({
|
||||
tabs: [],
|
||||
visibleTabs: [],
|
||||
defaultTab: ''
|
||||
defaultTab: '',
|
||||
sidebarSections: []
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
@@ -155,7 +197,8 @@ watch(() => props.config, (newConfig) => {
|
||||
localConfig.value = {
|
||||
tabs: [...newConfig.tabs],
|
||||
visibleTabs: [...newConfig.visibleTabs],
|
||||
defaultTab: newConfig.defaultTab
|
||||
defaultTab: newConfig.defaultTab,
|
||||
sidebarSections: [...(newConfig.sidebarSections || ['server', 'favorites', 'help'])]
|
||||
}
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
@@ -266,19 +309,13 @@ const handleSave = async () => {
|
||||
const configToSave = {
|
||||
tabs: syncedTabs,
|
||||
visibleTabs: [...localConfig.value.visibleTabs],
|
||||
defaultTab: localConfig.value.defaultTab
|
||||
defaultTab: localConfig.value.defaultTab,
|
||||
sidebarSections: [...localConfig.value.sidebarSections]
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
await emit('save', configToSave)
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
Message.error('保存配置失败:' + (error.message || error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
emit('save', configToSave)
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
@@ -287,11 +324,59 @@ const handleReset = () => {
|
||||
localConfig.value = {
|
||||
tabs: [...props.config.tabs],
|
||||
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 = () => {
|
||||
emit('open-version-history')
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface AppConfig {
|
||||
tabs: TabConfig[]
|
||||
visibleTabs: string[]
|
||||
defaultTab: string
|
||||
sidebarSections: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +32,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const appConfig = ref<AppConfig>({
|
||||
tabs: [],
|
||||
visibleTabs: [],
|
||||
defaultTab: 'file-system'
|
||||
defaultTab: 'file-system',
|
||||
sidebarSections: ['server', 'favorites', 'help']
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -79,7 +81,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const result = await GetAppConfig()
|
||||
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
|
||||
const allKeys = ['file-system']
|
||||
@@ -89,10 +91,16 @@ export const useConfigStore = defineStore('config', () => {
|
||||
? visibleTabs.filter(k => allKeys.includes(k))
|
||||
: allKeys
|
||||
|
||||
const defaultSidebar = ['server', 'favorites', 'help']
|
||||
const validSections = ['server', 'favorites', 'help']
|
||||
|
||||
appConfig.value = {
|
||||
tabs: mergedTabs.map(tab => ({ ...tab, visible: mergedVisible.includes(tab.key) })),
|
||||
visibleTabs: mergedVisible,
|
||||
defaultTab: defaultTab || 'file-system'
|
||||
defaultTab: defaultTab || 'file-system',
|
||||
sidebarSections: Array.isArray(sidebarSections)
|
||||
? sidebarSections.filter((s: string) => validSections.includes(s))
|
||||
: defaultSidebar
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
@@ -111,7 +119,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
{ key: 'file-system', title: '文件管理', visible: true, enabled: true }
|
||||
],
|
||||
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({
|
||||
tabs: config.tabs,
|
||||
visibleTabs: config.visibleTabs,
|
||||
defaultTab: config.defaultTab
|
||||
defaultTab: config.defaultTab,
|
||||
sidebarSections: config.sidebarSections
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
@@ -137,7 +147,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
appConfig.value = {
|
||||
tabs: [...config.tabs],
|
||||
visibleTabs: [...config.visibleTabs],
|
||||
defaultTab: config.defaultTab
|
||||
defaultTab: config.defaultTab,
|
||||
sidebarSections: [...config.sidebarSections]
|
||||
}
|
||||
|
||||
Message.success('配置保存成功')
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface FavoriteFile extends FileItem {
|
||||
addedAt: number
|
||||
/** 置顶时间(时间戳),undefined 表示未置顶 */
|
||||
pinnedAt?: number
|
||||
/** 关联的连接配置 ID,用于打开收藏时自动切换连接 */
|
||||
profileId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,6 +250,8 @@ export interface FileOperationResult {
|
||||
export interface PathHistory {
|
||||
/** 历史记录数组 */
|
||||
paths: string[]
|
||||
/** 每条路径对应的 profileId */
|
||||
profileIds: (string | undefined)[]
|
||||
/** 当前索引 */
|
||||
currentIndex: number
|
||||
}
|
||||
|
||||
48
frontend/src/utils/lrcParser.ts
Normal file
48
frontend/src/utils/lrcParser.ts
Normal 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
|
||||
}
|
||||
@@ -45,9 +45,8 @@ var (
|
||||
es6DynamicImport = regexp.MustCompile(`import\s*\(\s*["']([^"']+)["']\s*\)`)
|
||||
es6BareImport = regexp.MustCompile(`(?m)^\s*import\s+["']([^"']+)["']`)
|
||||
|
||||
// HTML 预览路径修复
|
||||
locationPathRegex = regexp.MustCompile(`\blocation\.pathname\b`)
|
||||
winDriveRegex = regexp.MustCompile(`^[A-Za-z]:`)
|
||||
// Windows 盘符检测
|
||||
winDriveRegex = regexp.MustCompile(`^[A-Za-z]:`)
|
||||
)
|
||||
|
||||
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
|
||||
@@ -78,6 +77,8 @@ func validateFilePath(rawPath string, logPrefix string) (string, error) {
|
||||
clean = strings.TrimPrefix(clean, "/localfs/")
|
||||
clean = strings.TrimPrefix(clean, "localfs/")
|
||||
}
|
||||
// 清理残留的前导斜杠(避免 /u-res/... 类路径在 Windows 上异常)
|
||||
clean = strings.TrimLeft(clean, "/")
|
||||
|
||||
// 平台适配:Windows 用反斜杠,Linux/macOS 保持正斜杠
|
||||
filePath := filepath.FromSlash(clean)
|
||||
@@ -304,7 +305,12 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
// 设置响应头
|
||||
contentType := getContentType(ext)
|
||||
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 请求
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
|
||||
@@ -365,6 +371,16 @@ func isAllowedFileType(ext string) bool {
|
||||
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 优雅关闭文件服务器
|
||||
func (lfs *LocalFileServer) Shutdown() error {
|
||||
if lfs == nil || lfs.server == nil {
|
||||
@@ -556,11 +572,8 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
// 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL)
|
||||
processedContent := transformHtmlResourcePaths(string(content), baseDir)
|
||||
|
||||
// 修复 JS 中基于 location.pathname 的相对路径计算
|
||||
// 预览模式下 location.pathname = "/localfs/html-preview",与实际文件路径不一致
|
||||
// ⚠️ 会替换所有出现位置(含JS字符串内),HTML预览场景下可接受
|
||||
correctPathname := `"/localfs/` + strings.ReplaceAll(baseDir, "\\", "/") + `/`
|
||||
processedContent = locationPathRegex.ReplaceAllString(processedContent, correctPathname)
|
||||
// 注入路径拦截脚本(处理 webpack 等动态加载的绝对路径资源)
|
||||
processedContent = injectPathInterceptor(processedContent, baseDir)
|
||||
|
||||
// 注入链接点击拦截脚本
|
||||
finalContent := injectLinkInterceptor(processedContent)
|
||||
@@ -870,3 +883,38 @@ func injectLinkInterceptor(htmlContent string) string {
|
||||
// 没有 body 标签,在末尾插入
|
||||
return htmlContent + script
|
||||
}
|
||||
|
||||
// injectPathInterceptor 注入路径拦截脚本(处理 webpack 等动态加载的绝对路径资源)
|
||||
// 重写动态创建的 <script src="/..."> 和 <link href="/..."> 为 /localfs/ 前缀路径
|
||||
func injectPathInterceptor(htmlContent string, baseDir string) string {
|
||||
// 直接使用 baseDir(HTML 所在目录)作为 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
41
internal/hotkey/hotkey.go
Normal 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)
|
||||
}
|
||||
@@ -99,6 +99,55 @@ func (c *Client) GetBucketDomains(ctx context.Context) ([]string, error) {
|
||||
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 设置空间访问权限(公开/私有)
|
||||
// 根据: https://developer.qiniu.com/kodo/api/3946/set-bucket-private
|
||||
//
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,12 +19,12 @@ import (
|
||||
|
||||
// Config 七牛云配置
|
||||
type Config struct {
|
||||
AccessKey string // 访问密钥
|
||||
SecretKey string // 秘钥
|
||||
Bucket string // 存储空间名称
|
||||
Region string // 区域 z0=华东, as0=亚太0区
|
||||
UseHTTPS bool // 是否使用 HTTPS
|
||||
UploadDomain string // 上传域名(可选,默认根据 Region 自动选择)
|
||||
AccessKey string // 访问密钥
|
||||
SecretKey string // 秘钥
|
||||
Bucket string // 存储空间名称
|
||||
Region string // 区域 z0=华东, z2=华南, as0=亚太0区
|
||||
UseHTTPS bool // 是否使用 HTTPS
|
||||
DownloadDomain string // 缓存的下载域名(由 resolveDownloadDomain 自动设置)
|
||||
}
|
||||
|
||||
// Client 七牛云客户端
|
||||
@@ -61,84 +62,31 @@ func NewClient(config *Config) (*Client, error) {
|
||||
}, 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
|
||||
func (c *Client) generateAuthToken(method, path, host, contentType string, body []byte) string {
|
||||
signature := c.generateSignature(method, path, host, contentType, body)
|
||||
return "Qiniu " + c.config.AccessKey + ":" + signature
|
||||
return c.generateAuthTokenWithQuery(method, path, "", host, contentType, body)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 七牛云管理凭证签名格式:
|
||||
// 如果 query 为非空字符串: signingStr = Method + " " + Path + "?" + query + "\nHost: " + Host + ...
|
||||
// 如果 query 为空: signingStr = Method + " " + Path + "\nHost: " + Host + ...
|
||||
var signingStr string
|
||||
|
||||
// 1. Method + " " + Path
|
||||
signingStr = method + " " + path
|
||||
|
||||
// 2. Query string (如果有)
|
||||
if query != "" {
|
||||
signingStr += "?" + query
|
||||
}
|
||||
|
||||
// 3. Host header
|
||||
signingStr += "\nHost: " + host
|
||||
|
||||
// 4. Content-Type header (如果设置了)
|
||||
if contentType != "" {
|
||||
signingStr += "\nContent-Type: " + contentType
|
||||
}
|
||||
|
||||
// 5. 两个连续换行符
|
||||
signingStr += "\n\n"
|
||||
|
||||
// 6. 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 "Qiniu " + c.config.AccessKey + ":" + signature
|
||||
@@ -152,12 +100,11 @@ func (c *Client) encodeEntry(key string) string {
|
||||
|
||||
// getUploadDomain 获取上传域名
|
||||
func (c *Client) getUploadDomain() string {
|
||||
// 如果配置了自定义上传域名,使用自定义的
|
||||
if c.config.UploadDomain != "" {
|
||||
if c.config.DownloadDomain != "" {
|
||||
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)
|
||||
}
|
||||
|
||||
// generateUploadToken 生成上传凭证
|
||||
func (c *Client) generateUploadToken(key string) string {
|
||||
// 七牛云上传凭证的生成
|
||||
// 1. 创建 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 签名
|
||||
// generateToken 生成上传凭证
|
||||
func (c *Client) generateToken(scope string) string {
|
||||
putPolicy := fmt.Sprintf(`{"scope":"%s","deadline":%d}`, scope, time.Now().Add(1*time.Hour).Unix())
|
||||
encoded := base64.URLEncoding.EncodeToString([]byte(putPolicy))
|
||||
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
|
||||
h.Write([]byte(encodedPutPolicy))
|
||||
encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
// 4. 组合 token
|
||||
return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy
|
||||
h.Write([]byte(encoded))
|
||||
sign := base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||||
return c.config.AccessKey + ":" + sign + ":" + encoded
|
||||
}
|
||||
|
||||
func (c *Client) generateUploadToken(key string) string {
|
||||
return c.generateToken(c.config.Bucket + ":" + key)
|
||||
}
|
||||
|
||||
// generateBucketToken 生成 bucket 级别的上传凭证(用于分片上传 v2)
|
||||
func (c *Client) generateBucketToken() string {
|
||||
// 分片上传 v2 需要 bucket 级别的 token
|
||||
// 1. 创建 putPolicy
|
||||
putPolicy := fmt.Sprintf(`{"scope":"%s","deadline":%d}`,
|
||||
c.config.Bucket, time.Now().Add(1*time.Hour).Unix())
|
||||
return c.generateToken(c.config.Bucket)
|
||||
}
|
||||
|
||||
// 2. 对 putPolicy 进行 base64 URL 编码
|
||||
encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy))
|
||||
// 七牛云临时域名后缀(平台分配的 CDN 域名,稳定性高)
|
||||
var qiniuTempSuffixes = []string{
|
||||
".qiniudns.com", ".clouddn.com", ".qbox.me",
|
||||
".qnssl.com", ".qnybgz.cn", ".qiniudns.com.cn",
|
||||
}
|
||||
|
||||
// 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名
|
||||
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
|
||||
h.Write([]byte(encodedPutPolicy))
|
||||
encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||||
// extractHost 从 URL 提取主机名(去掉 scheme、path、port)
|
||||
func extractHost(domainURL string) string {
|
||||
host := strings.TrimPrefix(domainURL, "http://")
|
||||
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
|
||||
return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy
|
||||
// isTempDomain 判断是否为七牛平台分配的临时域名(后缀匹配)
|
||||
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 解析并缓存下载域名
|
||||
// 策略:API 域名列表(临时优先→自定义)→ 兜底默认 CDN
|
||||
// 不做 HTTP 探测:Download 使用签名 URL,即使有防盗链也能通过
|
||||
func (c *Client) resolveDownloadDomain() (string, error) {
|
||||
if c.config.UploadDomain != "" {
|
||||
return c.config.UploadDomain, nil
|
||||
if c.config.DownloadDomain != "" {
|
||||
return c.config.DownloadDomain, nil
|
||||
}
|
||||
|
||||
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://") {
|
||||
domain = "http://" + domain
|
||||
}
|
||||
c.config.UploadDomain = domain
|
||||
return domain, nil
|
||||
|
||||
// 无域名 → 兜底默认 CDN(可能不存在,但给一个机会)
|
||||
fallback := c.defaultCDNDomain()
|
||||
c.config.DownloadDomain = fallback
|
||||
return fallback, 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 {
|
||||
baseURL, err := c.resolveDownloadDomain()
|
||||
signedURL, err := c.GetSignedURL(ctx, key, 1*time.Hour)
|
||||
if err != nil {
|
||||
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 {
|
||||
return oss.NewError("DOWNLOAD_ERROR", "failed to create request", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.ClearDownloadDomain()
|
||||
return oss.NewError("DOWNLOAD_ERROR", "failed to download file", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
c.ClearDownloadDomain()
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 解析响应 (简化实现)
|
||||
// 实际响应格式: {"hash":"xxx","fsize":123,"mimeType":"xxx","putTime":123}
|
||||
// 这里返回一个简化的 FileInfo
|
||||
var statResp struct {
|
||||
Hash string `json:"hash"`
|
||||
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{
|
||||
Key: key,
|
||||
Key: key,
|
||||
Size: statResp.Fsize,
|
||||
ETag: statResp.Hash,
|
||||
ContentType: statResp.MimeType,
|
||||
LastModified: modTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -471,11 +518,16 @@ func (c *Client) ListFiles(ctx context.Context, options *oss.ListOptions) (*oss.
|
||||
// 转换为统一格式
|
||||
files := make([]oss.FileInfo, 0, len(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{
|
||||
Key: item.Key,
|
||||
Size: item.Fsize,
|
||||
ETag: item.Hash,
|
||||
ContentType: item.MimeType,
|
||||
Key: item.Key,
|
||||
Size: item.Fsize,
|
||||
ETag: item.Hash,
|
||||
ContentType: item.MimeType,
|
||||
LastModified: modTime,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -488,27 +540,22 @@ func (c *Client) ListFiles(ctx context.Context, options *oss.ListOptions) (*oss.
|
||||
}
|
||||
|
||||
// GetSignedURL 获取预签名URL
|
||||
// 签名格式: hmac_sha1(SecretKey, "<downloadURL>?e=<deadline>")
|
||||
func (c *Client) GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) {
|
||||
// 七牛云私有空间下载需要生成私有下载 URL
|
||||
deadline := time.Now().Add(expiresIn).Unix()
|
||||
|
||||
// 构建 download URL
|
||||
baseURL, err := c.resolveDownloadDomain()
|
||||
if err != nil {
|
||||
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))
|
||||
signStr := fmt.Sprintf("%s\n%d", downloadURL, deadline)
|
||||
h.Write([]byte(signStr))
|
||||
h.Write([]byte(urlToSign))
|
||||
sign := base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
// 构建最终 URL
|
||||
signedURL := fmt.Sprintf("%s?e=%d&token=%s:%s", downloadURL, deadline, c.config.AccessKey, sign)
|
||||
|
||||
return signedURL, nil
|
||||
return fmt.Sprintf("%s&token=%s:%s", urlToSign, c.config.AccessKey, sign), nil
|
||||
}
|
||||
|
||||
// Copy 复制文件
|
||||
|
||||
190
internal/oss/qiniu/client_test.go
Normal file
190
internal/oss/qiniu/client_test.go
Normal 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
|
||||
}
|
||||
@@ -156,8 +156,8 @@ func (uc *UploadClient) Upload(ctx context.Context, key string, reader io.Reader
|
||||
}
|
||||
|
||||
var uploadURL string
|
||||
if uc.config.UploadDomain != "" {
|
||||
uploadURL = scheme + uc.config.UploadDomain
|
||||
if uc.config.DownloadDomain != "" {
|
||||
uploadURL = scheme + uc.config.DownloadDomain
|
||||
} else {
|
||||
// 根据区域选择
|
||||
switch uc.config.Region {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"u-desk/internal/oss"
|
||||
"u-desk/internal/oss/aliyun"
|
||||
"u-desk/internal/oss/qiniu"
|
||||
"u-desk/internal/storage"
|
||||
)
|
||||
|
||||
// accountCredentials 账户级凭据
|
||||
@@ -36,17 +39,19 @@ var globalManager = &Manager{}
|
||||
|
||||
func GetManager() *Manager { return globalManager }
|
||||
|
||||
// Connect 建立账户级连接(验证凭据通过 ListBuckets)
|
||||
// Connect 建立账户级连接(验证凭据通过 ListBuckets,同时缓存桶区域)
|
||||
func (m *Manager) Connect(provider, accessKey, secretKey, endpoint string) error {
|
||||
// 验证凭据
|
||||
var entries []oss.BucketEntry
|
||||
var err error
|
||||
|
||||
switch provider {
|
||||
case "qiniu":
|
||||
_, err := qiniu.ListBuckets(accessKey, secretKey)
|
||||
entries, err = qiniu.ListBuckets(accessKey, secretKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("七牛云连接失败: %w", err)
|
||||
}
|
||||
case "aliyun":
|
||||
_, err := aliyun.ListBuckets(accessKey, secretKey, endpoint)
|
||||
entries, err = aliyun.ListBuckets(accessKey, secretKey, endpoint)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 连接时立即缓存桶区域,避免后续操作因缺少 region 使用默认区域
|
||||
for _, e := range entries {
|
||||
if e.Region != "" {
|
||||
m.bucketRegions.Store(provider+":"+e.Name, e.Region)
|
||||
}
|
||||
}
|
||||
|
||||
m.accounts.Store(provider, &accountCredentials{
|
||||
Provider: provider,
|
||||
AccessKey: accessKey,
|
||||
@@ -76,10 +88,15 @@ func (m *Manager) getOrCreateBucketClient(provider, bucket, region string) (oss.
|
||||
}
|
||||
c := cred.(*accountCredentials)
|
||||
|
||||
// 如果未传 region,从缓存取
|
||||
// 如果未传 region,从缓存取;仍为空则主动探测
|
||||
if region == "" {
|
||||
if v, ok := m.bucketRegions.Load(key); ok {
|
||||
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,
|
||||
})
|
||||
case "aliyun":
|
||||
// 有桶级 region 时不传账户 Endpoint,让 NewClient 从 region 派生正确的 endpoint
|
||||
ep := c.Endpoint
|
||||
if region != "" {
|
||||
ep = ""
|
||||
}
|
||||
client, err = aliyun.NewClient(&aliyun.Config{
|
||||
AccessKeyID: c.AccessKey,
|
||||
AccessKeySecret: c.SecretKey,
|
||||
Bucket: bucket,
|
||||
Region: region,
|
||||
Endpoint: c.Endpoint,
|
||||
Endpoint: ep,
|
||||
UseHTTPS: true,
|
||||
})
|
||||
default:
|
||||
@@ -116,6 +138,41 @@ func (m *Manager) getOrCreateBucketClient(provider, bucket, region string) (oss.
|
||||
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 获取已有的桶级客户端
|
||||
func (m *Manager) GetClient(provider, bucket string) oss.OSSProvider {
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 先获取文件元信息用于缓存键,确保远程文件变更时能淘汰旧缓存
|
||||
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)
|
||||
if bucket == "" {
|
||||
return "", fmt.Errorf("路径中缺少桶名")
|
||||
@@ -609,6 +913,13 @@ func (s *Service) DownloadToTemp(connID string, rawPath string) (string, error)
|
||||
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 返回常用路径
|
||||
func (s *Service) GetCommonPaths(connID string) (map[string]string, error) {
|
||||
return map[string]string{
|
||||
|
||||
@@ -8,15 +8,22 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/filesystem"
|
||||
"u-desk/internal/storage"
|
||||
|
||||
sftpclient "github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
var (
|
||||
sftpResRegex = regexp.MustCompile(`(?:src|href|data|poster)=["']([^"']+)["']`)
|
||||
sftpCssUrlRe = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
|
||||
)
|
||||
|
||||
// Service SFTP 文件操作服务
|
||||
type Service struct {
|
||||
manager *Manager
|
||||
@@ -257,8 +264,26 @@ func (s *Service) RenamePath(connID string, oldPath, newPath string) (*filesyste
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
||||
// DownloadToTemp 下载远程文件到本地临时目录(带 SQLite 缓存)
|
||||
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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -303,12 +328,201 @@ func (s *Service) DownloadToTemp(connID string, remotePath string) (string, erro
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
os.Remove(localPath)
|
||||
return "", fmt.Errorf("下载文件失败: %w", err)
|
||||
}
|
||||
|
||||
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 远程主机常用路径
|
||||
func (s *Service) GetCommonPaths(connID string) (map[string]string, error) {
|
||||
c := s.manager.GetClient(connID)
|
||||
@@ -331,18 +545,9 @@ func (s *Service) GetCommonPaths(connID string) (map[string]string, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CleanupTempFiles 清理遗留的临时预览文件
|
||||
// CleanupTempFiles 清理遗留的临时预览文件(已由 SQLite 缓存接管)
|
||||
func CleanupTempFiles() {
|
||||
tmpDir := os.TempDir()
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), "udesk-sftp-preview-") {
|
||||
os.Remove(filepath.Join(tmpDir, entry.Name()))
|
||||
}
|
||||
}
|
||||
storage.CleanupExpiredCache()
|
||||
}
|
||||
|
||||
// GetSystemInfo 通过 SSH 命令采集远程系统信息(磁盘/CPU/内存)
|
||||
@@ -501,3 +706,39 @@ func toFileOperationResult(m map[string]interface{}, isDir bool) *filesystem.Fil
|
||||
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:")
|
||||
}
|
||||
|
||||
187
internal/storage/download_cache.go
Normal file
187
internal/storage/download_cache.go
Normal 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
|
||||
}
|
||||
12
internal/storage/models/bgm_playlist.go
Normal file
12
internal/storage/models/bgm_playlist.go
Normal 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" }
|
||||
@@ -11,7 +11,8 @@ type ConnectionProfile struct {
|
||||
Username string `gorm:"type:varchar(100);default:root" json:"username"`
|
||||
Password string `gorm:"type:text" json:"password"`
|
||||
KeyPath string `gorm:"type:text" json:"key_path"`
|
||||
Type string `gorm:"type:varchar(20);not null;index" json:"type"` // local|remote|sftp|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"`
|
||||
AccessKey string `gorm:"type:text" json:"access_key"`
|
||||
SecretKey string `gorm:"type:text" json:"secret_key"`
|
||||
|
||||
17
internal/storage/models/download_cache.go
Normal file
17
internal/storage/models/download_cache.go
Normal 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" }
|
||||
@@ -53,10 +53,14 @@ func InitFast() (*gorm.DB, error) {
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
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
|
||||
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
|
||||
})
|
||||
if initErr != nil {
|
||||
|
||||
16
main.go
16
main.go
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"u-desk/internal/hotkey"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
@@ -43,6 +44,21 @@ func main() {
|
||||
Mac: application.MacOptions{
|
||||
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{
|
||||
|
||||
Reference in New Issue
Block a user