Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d211365856 | ||
|
|
0475d55bff | ||
|
|
6ea9d9ac99 | ||
|
|
e7a2c5148e | ||
|
|
f1bc5b49f1 | ||
|
|
767311fc04 |
@@ -2,9 +2,62 @@
|
|||||||
|
|
||||||
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
|
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
|
||||||
|
|
||||||
## [0.5.0] - 2026-05-01 (fs-only-v3)
|
## [0.4.1] - 2026-05-01 ~ 05-16 (fs-only-v3)
|
||||||
|
|
||||||
### Wails v3 迁移 🏗️
|
---
|
||||||
|
|
||||||
|
### 2026-05-16 增量更新
|
||||||
|
|
||||||
|
#### App 结构体拆分 🏗️
|
||||||
|
|
||||||
|
app.go 从 1281 行拆为 10 个域文件:
|
||||||
|
|
||||||
|
| 文件 | 方法数 | 职责 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `app.go` | 5 | 结构体定义、生命周期、请求类型 |
|
||||||
|
| `app_config.go` | 12 | 配置管理、BGM、Profile CRUD |
|
||||||
|
| `app_filesystem.go` | 23 | 文件操作、回收站、文件监听 |
|
||||||
|
| `app_sftp.go` | 17 | SFTP 连接管理 |
|
||||||
|
| `app_oss.go` | 17 | OSS 对象存储 |
|
||||||
|
| `app_window.go` | 8 | 窗口控制 |
|
||||||
|
| `app_system.go` | 7 | 系统信息 |
|
||||||
|
| `app_update.go` | 11 | 版本更新管理 |
|
||||||
|
| `app_pdf.go` | 2 | PDF 导出 |
|
||||||
|
| `app_windows.go` | 4 | Windows 平台专用(`//go:build windows`) |
|
||||||
|
|
||||||
|
#### 结构化日志 📝
|
||||||
|
|
||||||
|
`fmt.Print*` → `log/slog`(45 处):app.go、filesystem/service.go、storage/download_cache.go
|
||||||
|
|
||||||
|
#### Bug 修复 🐛
|
||||||
|
|
||||||
|
- **预览状态级联失败**: `clearContent()` 未重置 `isBinaryFile`,模板 `v-if` 链被阻断 → 加 `isBinaryFile.value = false`
|
||||||
|
- **Tab 切换状态不同步**: `activateTab` 未更新 `filePath` → 加 `getParentPath` + `loadDirectory`
|
||||||
|
- **AsyncCodeEditor 永久死亡**: `defineAsyncComponent` 无 `onError` → 添加重试(限 2 次)
|
||||||
|
- **FileLock 误创建文件**: `O_RDWR|O_CREAT` → `O_RDWR`,`mode 0666` → `0`
|
||||||
|
- **OSS 目录重命名**: `c.Delete()` 返回值未检查,目录标记删除添加容错
|
||||||
|
|
||||||
|
#### 并发安全 🔒
|
||||||
|
|
||||||
|
- **RecycleBin**: 所有公开方法添加 `sync.Mutex`,`ListEntries()` 返回深拷贝副本
|
||||||
|
|
||||||
|
#### 缓存优化 📦
|
||||||
|
|
||||||
|
- **contentDetectCache**: 添加 200 条上限,超限全量清空
|
||||||
|
|
||||||
|
#### 依赖升级 ⬆️
|
||||||
|
|
||||||
|
Wails alpha.80→alpha.92, echo v4.15.1→v4.15.2, go-sqlite v1.22, modernc/sqlite v1.50, go-git v5.19
|
||||||
|
|
||||||
|
#### 代码审查 (15/17 修复)
|
||||||
|
|
||||||
|
🔴 构建标签 · 死参数 · 重复逻辑 · 并发锁 | 🟡 错误处理 · 方法归属 · 日志 | ⚪ 注释 · 去重
|
||||||
|
|
||||||
|
**暂不处理**: ⚪⑬ fallback 路径 · ⚪⑯ ensure 风格 · ⚪⑰ ensure 模式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-05-01 Wails v3 迁移
|
||||||
|
|
||||||
#### 框架升级
|
#### 框架升级
|
||||||
- **Wails v2.12 → v3 alpha.80**: 全面迁移至 Wails v3 架构
|
- **Wails v2.12 → v3 alpha.80**: 全面迁移至 Wails v3 架构
|
||||||
|
|||||||
@@ -1,40 +1,42 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
## [0.5.0] - 2026-05-05 (fs-only-v3)
|
## [0.4.1] - 2026-05-16 (fs-only-v3)
|
||||||
|
|
||||||
### 新增 ✨
|
### 新增 ✨
|
||||||
- **云 OSS 存储**: 七牛云/阿里云双厂商支持,AK/SK 认证连接
|
- **云 OSS 存储**: 七牛云/阿里云双厂商,AK/SK 认证,桶级客户端懒创建+缓存
|
||||||
- **多桶导航**: 根目录自动列出所有桶,点进桶浏览文件,桶级客户端懒创建+缓存
|
|
||||||
- **OSS 全功能 CRUD**: 列目录/读文件/写文件/创建/删除/重命名/预签名 URL
|
- **OSS 全功能 CRUD**: 列目录/读文件/写文件/创建/删除/重命名/预签名 URL
|
||||||
- **GBK 编码自动转换**: 文件预览智能检测编码(UTF-8/GBK),解决 LRC 等中文文件乱码
|
- **OSS 区域自动嗅探**: 连接时自动探测桶区域,无需手动填写
|
||||||
- **桶图标 🪣**: OSS 桶与普通文件夹图标区分
|
- **七牛云桶管理**: 查询桶域名、真实区域、设置访问权限(公开/私有)
|
||||||
- **连接对话框 OSS 分类**: 「云OSS」父分类 + 厂商子选择(七牛云/阿里云)
|
- **HTML 网站预览**: OSS/SFTP 上的 HTML 文件自动下载关联资源(JS/CSS/图片),逐级嗅探网站根目录,完整预览静态站点
|
||||||
- **Sidebar 折叠架构**: 收藏夹和帮助文档独立区块,各自支持折叠/展开
|
- **BGM 音乐播放器**: 编辑器底部控制条,支持播放/暂停/上下首/进度拖拽/四种播放模式(单曲/顺序/随机/循环),播放列表持久化,失败自动跳过
|
||||||
- **帮助文档区块**: 静态快捷键参考面板,默认展示
|
- **LRC 歌词同步显示**: 播放时自动查找同名 .lrc 文件,实时高亮滚动歌词,点击歌词行跳转
|
||||||
- **收藏夹内部滚动**: 收藏内容多时列表区域独立滚动,帮助区块固定底部不溢出
|
- **全局热键**: Ctrl+Shift+B 系统级快捷键,应用最小化时快速唤起
|
||||||
|
- **文件变更监听**: 文件外部被修改时自动刷新,避免编辑冲突
|
||||||
|
- **下载缓存**: 远程文件预览缓存到本地 SQLite(24 小时过期),重启后仍有效
|
||||||
|
- **远程服务器监控**: 侧边栏显示磁盘/CPU/内存使用率,超 90% 红色警告,15 秒自动刷新
|
||||||
|
- **连接指示器快速导航**: 悬停弹出根目录子文件夹列表,可递归展开,快速跳转
|
||||||
|
- **侧边栏自定义**: 设置面板支持服务器/收藏夹/帮助区块拖拽排序和显隐控制
|
||||||
|
|
||||||
### 重构 🔧
|
### 优化 🚀
|
||||||
- **Wails v3 迁移**: 从 Wails v2 升级至 v3 alpha.80,全面重构项目架构
|
- **侧边栏折叠架构**: 服务器、收藏夹、帮助独立可折叠区块,收藏夹内部独立滚动
|
||||||
- **前端目录规范化**: `web/` → `frontend/`,对齐 Wails v3 标准目录结构
|
- **七牛云目录层级**: ListFiles 支持 delimiter,正确展示文件夹层级而非平铺
|
||||||
- **跨平台构建配置**: 新增 Android/iOS/Linux/macOS/Docker 构建模板(Taskfile.yml)
|
- **七牛云签名 URL**: 修正私有空间下载/预览签名格式
|
||||||
- **v3 Bindings**: 自动生成的 TypeScript 绑定替代手动维护的 wailsjs
|
- **七牛云下载域名**: 智能解析策略:临时域名→自定义域名→CDN 兜底,失败自动重试
|
||||||
|
- **GBK 编码自动转换**: 文件预览智能检测编码(UTF-8/GBK),中文文件不再乱码
|
||||||
|
- **SFTP 功能对齐**: 补全粘贴图片、网站预览、系统监控、常用路径等能力
|
||||||
|
- **预览缓存迁移**: 前端 LRU → 后端 SQLite,重启后缓存仍有效
|
||||||
|
- **Wails v3 迁移**: 框架升级至 v3 alpha.92,核心依赖全面更新
|
||||||
|
|
||||||
### 修复 🐛
|
### 修复 🐛
|
||||||
- **MP3 误报加载失败**: 音频 @canplay 清除错误状态 + previewUrl watcher 重置
|
- **二进制文件后预览失效**: 打开二进制文件后图片/视频无法预览
|
||||||
- **启动路径恢复错误**: 本地模式跳过 Linux/OSS 路径残留,避免 `open /bucket` 报错
|
- **跨服务器 Tab 切换**: 面包屑和文件列表未同步更新
|
||||||
- **阿里云签名修复**: ListFiles 签名不含 list 查询参数(prefix/delimiter/marker/max-keys 非子资源)
|
- **代码编辑器加载失败**: 异步组件失败后自动重试
|
||||||
- **阿里云 XML 解析**: `<Contents>` 直接映射文件字段,修正 `Contents.Object` 嵌套错误
|
- **OSS 收藏打开失败**: provider 字段映射修正
|
||||||
- **阿里云 LastModified**: 宽容时间解析(4 种格式兼容)
|
- **阿里云 OSS 签名/解析**: 文件列表获取失败、时间格式不兼容等问题
|
||||||
- **临时文件白名单放行**: OSS/SFTP 预览文件绕过文件类型限制
|
- **启动路径错误**: 本地模式跳过无效的 OSS 路径残留
|
||||||
- **custom.js 404**: AssetOptions Middleware 拦截返回空响应,消除控制台报错
|
- **OSS 目录重命名**: 单文件失败不中断整体操作
|
||||||
- **longPressTimer TypeError**: `const` → `let` 修复重复赋值错误
|
- **文件锁定检查**: 不再误创建空文件
|
||||||
- **Arco Tabs padding**: 覆盖默认 16px padding-top
|
- **长期运行内存增长**: 文件类型检测缓存添加上限
|
||||||
- **DevTools 可用性**: production 构建带 devtools tag + 延迟 OpenDevTools() 调用
|
|
||||||
|
|
||||||
### 变更说明
|
|
||||||
- 分支: `feature/fs-only` → `fs-only-v3`
|
|
||||||
- 入口: main.go 新增 Middleware 中间件模式
|
|
||||||
- build/config.yml executes 流水线对齐官方模板(once → background → blocking → primary)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,52 +4,57 @@
|
|||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 文件浏览 / 编辑 / 预览(文本、Markdown、图片、Office、PDF)
|
- **本地文件管理**: 浏览、编辑、重命名、删除、回收站
|
||||||
- 收藏夹管理(折叠/展开、拖拽排序、置顶)
|
- **SFTP 远程连接**: SSH 密钥/密码认证,文件上传下载
|
||||||
- Markdown 编辑器(实时预览、语法高亮、Mermaid 图表)
|
- **云 OSS 存储**: 七牛云/阿里云,多桶浏览
|
||||||
- 远程文件服务器连接
|
- **多格式预览**: 图片/视频/音频/PDF/Office/HTML
|
||||||
- 主题切换(亮色/暗色)
|
- **Markdown 编辑器**: 实时预览、Mermaid 图表、语法高亮
|
||||||
- 版本更新检查
|
- **收藏夹管理**: 折叠/展开、拖拽排序、置顶
|
||||||
|
- **BGM 播放**: 音频播放列表、全局热键 Ctrl+Shift+B
|
||||||
|
- **PDF 导出**: Markdown/HTML 导出为 PDF
|
||||||
|
- **版本更新检查**: 自动检测新版本
|
||||||
|
- **主题切换**: 亮色/暗色
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 层 | 技术 |
|
| 层 | 技术 |
|
||||||
|---|------|
|
|---|------|
|
||||||
| 桌面框架 | Wails v3 (alpha.80) |
|
| 桌面框架 | Wails v3 (alpha.82) |
|
||||||
| 后端 | Go 1.22+ |
|
| 后端 | Go 1.24 |
|
||||||
| 前端 | Vue 3 + TypeScript |
|
| 前端 | Vue 3 + TypeScript |
|
||||||
| UI 组件库 | Arco Design Vue |
|
| UI 组件库 | Arco Design Vue |
|
||||||
| 编辑器 | CodeMirror 6 |
|
| 编辑器 | CodeMirror 6 |
|
||||||
| 构建 | Vite 7 + Taskfile |
|
| 构建 | Vite 7 + Taskfile |
|
||||||
|
| 数据库 | SQLite (配置/历史) |
|
||||||
|
| 文件服务 | 内置 HTTP 文件服务器 (端口 2652) |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
├── main.go # 入口:窗口配置、中间件、DevTools
|
├── main.go # 入口:窗口配置、中间件
|
||||||
├── app.go # 应用逻辑:文件系统、更新检查等
|
├── app.go # App 结构体定义、生命周期、请求类型
|
||||||
├── internal/ # 内部模块
|
├── app_*.go # 按功能域拆分(config/sftp/oss/...)
|
||||||
│ ├── filesystem/ # 文件操作、锁、预览服务
|
├── internal/
|
||||||
│ └── api/ # API 处理器
|
│ ├── api/ # Wails 绑定 API 层
|
||||||
├── frontend/ # 前端代码 (Vue 3)
|
│ ├── filesystem/ # 文件系统服务 + HTTP 文件服务器
|
||||||
│ ├── src/
|
│ ├── ossdrv/ # OSS 驱动(七牛/阿里云)
|
||||||
│ │ ├── components/FileSystem/ # 文件管理主组件
|
│ ├── sftp/ # SFTP 客户端
|
||||||
│ │ ├── stores/ # Pinia 状态管理
|
│ ├── storage/ # SQLite 存储
|
||||||
│ │ ├── api/ # 后端调用封装
|
│ └── service/ # 业务服务
|
||||||
│ │ └── utils/ # 工具函数
|
├── frontend/
|
||||||
│ └── vite.config.js
|
│ └── src/
|
||||||
|
│ ├── components/FileSystem/ # 文件管理主组件
|
||||||
|
│ ├── api/ # 后端调用封装
|
||||||
|
│ ├── composables/ # Vue Composables
|
||||||
|
│ └── stores/ # Pinia 状态管理
|
||||||
├── build/ # 构建配置(跨平台)
|
├── build/ # 构建配置(跨平台)
|
||||||
│ ├── config.yml # Wails 项目配置
|
|
||||||
│ └── windows/ # Windows 构建脚本
|
|
||||||
└── configs/ # 运行时配置
|
└── configs/ # 运行时配置
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装依赖
|
# 启动开发模式
|
||||||
wails3 task common:install:frontend:deps
|
|
||||||
|
|
||||||
# 启动开发模式(热重载)
|
|
||||||
wails3 dev
|
wails3 dev
|
||||||
|
|
||||||
# 生产构建
|
# 生产构建
|
||||||
@@ -59,16 +64,16 @@ wails3 build
|
|||||||
### 构建标签
|
### 构建标签
|
||||||
|
|
||||||
- `production` — 生产模式,使用嵌入的 frontend dist
|
- `production` — 生产模式,使用嵌入的 frontend dist
|
||||||
- `devtools` — 在生产构建中保留 DevTools(F12)
|
- `devtools` — 在生产构建中保留 DevTools
|
||||||
|
|
||||||
## 快捷键
|
## 快捷键
|
||||||
|
|
||||||
| 快捷键 | 功能 |
|
| 快捷键 | 功能 |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| Ctrl+B | 切换侧边栏 |
|
| Ctrl+B | 切换侧边栏 |
|
||||||
| Ctrl+H | 历史记录 |
|
| Ctrl+Shift+B | BGM 播放栏 |
|
||||||
| Ctrl+F | 聚焦搜索 |
|
| F11 | 全屏预览 |
|
||||||
|
|
||||||
## 版本历史
|
## 版本历史
|
||||||
|
|
||||||
详见 [CHANGELOG.md](./CHANGELOG.md)
|
详见 [CHANGELOG.md](./CHANGELOG.md) | 内部技术细节见 [CHANGELOG.internal.md](./CHANGELOG.internal.md)
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/api"
|
||||||
|
"u-desk/internal/common"
|
||||||
|
"u-desk/internal/service"
|
||||||
|
"u-desk/internal/storage"
|
||||||
|
"u-desk/internal/storage/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== 应用配置接口 ==========
|
||||||
|
|
||||||
|
// GetAppConfig 获取应用配置
|
||||||
|
func (a *App) GetAppConfig() (map[string]interface{}, error) {
|
||||||
|
if a.configAPI == nil {
|
||||||
|
return nil, fmt.Errorf("配置服务正在初始化中")
|
||||||
|
}
|
||||||
|
return a.configAPI.GetAppConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAppConfig 保存应用配置
|
||||||
|
func (a *App) SaveAppConfig(req SaveAppConfigRequest) (map[string]interface{}, error) {
|
||||||
|
if a.configAPI == nil {
|
||||||
|
return nil, fmt.Errorf("配置服务正在初始化中")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldConfig, _ := a.configAPI.GetAppConfig()
|
||||||
|
var oldVisibleTabs []string
|
||||||
|
if success, ok := oldConfig["success"].(bool); ok && success {
|
||||||
|
if data, ok := oldConfig["data"].(map[string]interface{}); ok {
|
||||||
|
if vtInterface, ok := data["visibleTabs"].([]interface{}); ok {
|
||||||
|
oldVisibleTabs = common.InterfaceSliceToStringSlice(vtInterface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apiReq := api.SaveAppConfigRequest{
|
||||||
|
Tabs: req.Tabs,
|
||||||
|
VisibleTabs: req.VisibleTabs,
|
||||||
|
DefaultTab: req.DefaultTab,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := a.configAPI.SaveAppConfig(apiReq)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if success, ok := result["success"].(bool); ok && success {
|
||||||
|
a.handleNewlyEnabledModules(oldVisibleTabs, req.VisibleTabs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) {
|
||||||
|
list, err := a.ensureProfileSvc().ListProfiles()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result []map[string]interface{}
|
||||||
|
data, _ := json.Marshal(list)
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析连接配置失败: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveConnectionProfile(req SaveProfileRequest) (map[string]interface{}, error) {
|
||||||
|
p := &models.ConnectionProfile{
|
||||||
|
Name: req.Name, Host: req.Host, Port: req.Port,
|
||||||
|
Username: req.Username, Password: req.Password,
|
||||||
|
KeyPath: req.KeyPath, Type: req.Type, Provider: req.Provider, Token: req.Token,
|
||||||
|
AccessKey: req.AccessKey, SecretKey: req.SecretKey,
|
||||||
|
Bucket: req.Bucket, Region: req.Region, Endpoint: req.Endpoint,
|
||||||
|
}
|
||||||
|
if req.LastConnected != nil {
|
||||||
|
t := time.Unix(*req.LastConnected, 0)
|
||||||
|
p.LastConnected = &t
|
||||||
|
}
|
||||||
|
if req.ID != nil {
|
||||||
|
p.ID = *req.ID
|
||||||
|
}
|
||||||
|
if err := a.ensureProfileSvc().SaveProfile(p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"id": float64(p.ID), "success": true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteConnectionProfile(id uint) error {
|
||||||
|
return a.ensureProfileSvc().DeleteProfile(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BgmGetPlaylist 获取播放列表
|
||||||
|
func (a *App) BgmGetPlaylist() ([]BgmPlaylistItem, error) {
|
||||||
|
db := storage.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil, fmt.Errorf("数据库未初始化")
|
||||||
|
}
|
||||||
|
var rows []models.BgmPlaylist
|
||||||
|
if err := db.Order("sort ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("查询播放列表失败: %w", err)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if err := tx.Create(&models.BgmPlaylist{Name: item.Name, Path: item.Path, ProfileID: item.ProfileID, Sort: sort}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sort++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureProfileSvc() *service.ProfileService {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.profileSvc == nil {
|
||||||
|
a.profileSvc = service.NewProfileService()
|
||||||
|
}
|
||||||
|
return a.profileSvc
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVisibleTabs 获取配置中的可见 Tabs
|
||||||
|
func (a *App) getVisibleTabs() []string {
|
||||||
|
config, err := a.configAPI.GetAppConfig()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("读取配置失败,使用默认配置", "error", err)
|
||||||
|
return common.DefaultVisibleTabs
|
||||||
|
}
|
||||||
|
|
||||||
|
success, ok := config["success"].(bool)
|
||||||
|
if !ok || !success {
|
||||||
|
slog.Warn("配置读取失败,使用默认配置")
|
||||||
|
return common.DefaultVisibleTabs
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := config["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return common.DefaultVisibleTabs
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleTabsInterface, ok := data["visibleTabs"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return common.DefaultVisibleTabs
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleTabs := common.InterfaceSliceToStringSlice(visibleTabsInterface)
|
||||||
|
|
||||||
|
if len(visibleTabs) == 0 {
|
||||||
|
return common.DefaultVisibleTabs
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleTabs
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNewlyEnabledModules 处理新启用的模块
|
||||||
|
func (a *App) handleNewlyEnabledModules(oldTabs, newTabs []string) {
|
||||||
|
newlyEnabled := common.Difference(newTabs, oldTabs)
|
||||||
|
|
||||||
|
if len(newlyEnabled) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("检测到新启用的模块", "modules", newlyEnabled)
|
||||||
|
|
||||||
|
for _, tab := range newlyEnabled {
|
||||||
|
switch tab {
|
||||||
|
case common.TabFileSystem:
|
||||||
|
a.initFilesystemModule()
|
||||||
|
case common.TabDevice:
|
||||||
|
slog.Info("设备测试模块已启用")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initModulesByConfig 根据配置初始化模块
|
||||||
|
func (a *App) initModulesByConfig(visibleTabs []string) error {
|
||||||
|
if common.Contains(visibleTabs, common.TabFileSystem) {
|
||||||
|
a.initFilesystemModule()
|
||||||
|
if a.filesystem == nil {
|
||||||
|
return fmt.Errorf("文件系统服务初始化失败")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Info("跳过文件系统模块(未启用)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/filesystem"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnsureDrawioViewer 确保本地已缓存 drawio viewer(首次预览时下载),前端打开 .drawio 前调用。
|
||||||
|
// 通过 drawio-download-progress 事件推送下载进度。
|
||||||
|
func (a *App) EnsureDrawioViewer() (map[string]interface{}, error) {
|
||||||
|
err := filesystem.EnsureDrawioViewer(func(progress, speed float64, downloaded, total int64) {
|
||||||
|
a.mainWindow.EmitEvent("drawio-download-progress", map[string]interface{}{
|
||||||
|
"progress": progress,
|
||||||
|
"speed": speed,
|
||||||
|
"downloaded": downloaded,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return map[string]interface{}{
|
||||||
|
"ready": err == nil,
|
||||||
|
"version": filesystem.DrawioViewerVersion(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile 读取文件
|
||||||
|
func (a *App) ReadFile(path string) (string, error) {
|
||||||
|
return a.filesystem.ReadFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchFile 开始监听指定文件的变化,变化时发送 file-changed 事件
|
||||||
|
func (a *App) WatchFile(path string) error {
|
||||||
|
if a.fileWatcher == nil {
|
||||||
|
return fmt.Errorf("文件监听器未初始化")
|
||||||
|
}
|
||||||
|
return a.fileWatcher.WatchFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnwatchFile 停止监听文件变化
|
||||||
|
func (a *App) UnwatchFile() {
|
||||||
|
if a.fileWatcher != nil {
|
||||||
|
a.fileWatcher.UnwatchFile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile 写入文件
|
||||||
|
func (a *App) WriteFile(req WriteFileRequest) error {
|
||||||
|
return a.filesystem.WriteFile(req.Path, req.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveBase64File 将 base64 内容解码后写入文件(用于图片等二进制数据)
|
||||||
|
func (a *App) SaveBase64File(req SaveBase64FileRequest) error {
|
||||||
|
return a.filesystem.SaveBase64File(req.Path, req.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDir 列出目录
|
||||||
|
func (a *App) ListDir(path string) ([]map[string]interface{}, error) {
|
||||||
|
return a.filesystem.ListDir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDir 创建目录
|
||||||
|
func (a *App) CreateDir(path string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.filesystem.CreateDir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFile 创建文件
|
||||||
|
func (a *App) CreateFile(path string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.filesystem.CreateFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePath 删除文件或目录
|
||||||
|
func (a *App) DeletePath(path string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.filesystem.DeletePath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenamePath 重命名文件或目录
|
||||||
|
func (a *App) RenamePath(req RenamePathRequest) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.filesystem.RenamePath(req.OldPath, req.NewPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度
|
||||||
|
func (a *App) CopyPath(src, dst string) error {
|
||||||
|
emitter := newTransferEmitter(a, "copy", filepath.Base(filepath.FromSlash(src)))
|
||||||
|
err := a.filesystem.CopyPath(src, dst, func(copied, total int64) {
|
||||||
|
emitter.progress(emitter.name, copied, total)
|
||||||
|
})
|
||||||
|
emitter.finish(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
|
||||||
|
func (a *App) MovePath(src, dst string) error {
|
||||||
|
return a.filesystem.MovePath(src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickDirectory 打开系统目录选择器,返回选中目录(取消时返回空串)
|
||||||
|
func (a *App) PickDirectory() (string, error) {
|
||||||
|
dialog := application.Get().Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{
|
||||||
|
CanChooseDirectories: true,
|
||||||
|
CanChooseFiles: false,
|
||||||
|
CanCreateDirectories: true,
|
||||||
|
Title: "选择目录",
|
||||||
|
})
|
||||||
|
return dialog.PromptForSingleSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 传输进度事件(下载/上传/本地复制共用) ==========
|
||||||
|
|
||||||
|
// transferProgressInterval 进度事件最小推送间隔(节流)
|
||||||
|
const transferProgressInterval = 100 * time.Millisecond
|
||||||
|
|
||||||
|
// transferEmitter 封装 transfer-progress 事件的节流推送
|
||||||
|
type transferEmitter struct {
|
||||||
|
app *App
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
lastEmit time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTransferID 生成传输任务标识
|
||||||
|
func newTransferID(prefix string) string {
|
||||||
|
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTransferEmitter 创建进度推送器并发送首帧 running 事件
|
||||||
|
func newTransferEmitter(a *App, prefix, name string) *transferEmitter {
|
||||||
|
te := &transferEmitter{app: a, id: newTransferID(prefix), name: name}
|
||||||
|
te.emit(name, 0, 0, "")
|
||||||
|
return te
|
||||||
|
}
|
||||||
|
|
||||||
|
// emit 推送一帧进度(errMsg 非空表示失败)
|
||||||
|
func (t *transferEmitter) emit(name string, copied, total int64, errMsg string) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"id": t.id,
|
||||||
|
"name": name,
|
||||||
|
"copied": copied,
|
||||||
|
"total": total,
|
||||||
|
"status": "running",
|
||||||
|
}
|
||||||
|
if errMsg != "" {
|
||||||
|
payload["status"] = "error"
|
||||||
|
payload["error"] = errMsg
|
||||||
|
}
|
||||||
|
t.app.emitEvent("transfer-progress", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// progress 流式进度回调(100ms 节流;目录传输时 name 为当前文件名)
|
||||||
|
func (t *transferEmitter) progress(name string, copied, total int64) {
|
||||||
|
t.name = name
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(t.lastEmit) < transferProgressInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.lastEmit = now
|
||||||
|
t.emit(name, copied, total, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// finish 结束传输:成功发 done,失败发 error(各一帧,不节流)
|
||||||
|
func (t *transferEmitter) finish(err error) {
|
||||||
|
if err != nil {
|
||||||
|
t.emit(t.name, 0, 0, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"id": t.id,
|
||||||
|
"name": t.name,
|
||||||
|
"status": "done",
|
||||||
|
}
|
||||||
|
t.app.emitEvent("transfer-progress", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileInfo 获取文件信息
|
||||||
|
func (a *App) GetFileInfo(path string) (map[string]interface{}, error) {
|
||||||
|
return a.filesystem.GetFileInfo(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenPath 使用系统默认程序打开文件或目录
|
||||||
|
func (a *App) OpenPath(path string) error {
|
||||||
|
return a.filesystem.OpenPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Zip 文件操作接口 ==========
|
||||||
|
|
||||||
|
// ListZipContents 列出 zip 文件内容
|
||||||
|
func (a *App) ListZipContents(zipPath string) ([]map[string]interface{}, error) {
|
||||||
|
return a.filesystem.ListZipContents(zipPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractFileFromZip 从 zip 文件中提取单个文件内容
|
||||||
|
func (a *App) ExtractFileFromZip(zipPath, filePath string) (string, error) {
|
||||||
|
return a.filesystem.ExtractFileFromZip(zipPath, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractFileFromZipToTemp 从 zip 文件中提取单个文件到临时目录
|
||||||
|
func (a *App) ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
|
||||||
|
return a.filesystem.ExtractFileFromZipToTemp(zipPath, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetZipFileInfo 获取 zip 文件中特定文件的信息
|
||||||
|
func (a *App) GetZipFileInfo(zipPath, filePath string) (map[string]interface{}, error) {
|
||||||
|
return a.filesystem.GetZipFileInfo(zipPath, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveShortcut 解析快捷方式文件,返回目标路径信息
|
||||||
|
func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
|
||||||
|
targetPath, err := a.filesystem.ResolveShortcut(lnkPath)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo, err := a.filesystem.GetFileInfo(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"targetPath": targetPath,
|
||||||
|
"targetExists": false,
|
||||||
|
"targetAccessible": false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"targetPath": targetPath,
|
||||||
|
"targetExists": true,
|
||||||
|
"targetAccessible": true,
|
||||||
|
"targetInfo": fileInfo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startFileServer 启动文件服务器
|
||||||
|
func (a *App) startFileServer() {
|
||||||
|
if _, err := filesystem.StartLocalFileServer(); err != nil {
|
||||||
|
slog.Error("文件服务器启动失败", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("文件服务器已启动", "addr", "http://"+filesystem.GetLocalFileServerAddr())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 文件服务器接口 ==========
|
||||||
|
|
||||||
|
// GetFileServerURL 获取本地文件服务器的URL
|
||||||
|
func (a *App) GetFileServerURL() string {
|
||||||
|
return fmt.Sprintf("http://%s", filesystem.GetLocalFileServerAddr())
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectFileTypeByContent 通过文件内容检测文件类型
|
||||||
|
func (a *App) DetectFileTypeByContent(path string) (map[string]interface{}, error) {
|
||||||
|
return filesystem.DetectFileTypeByContentSimple(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initFilesystemModule 延迟初始化文件系统模块
|
||||||
|
func (a *App) initFilesystemModule() {
|
||||||
|
if a.filesystem != nil {
|
||||||
|
slog.Info("文件系统模块已初始化,跳过")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("延迟初始化文件系统模块")
|
||||||
|
fsConfig := filesystem.DefaultConfig()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
a.filesystem, err = filesystem.NewFileSystemService(fsConfig)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("文件系统模块初始化失败", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// startFileServer 内部 listen 同步(极快)、Serve 异步,无需再包 goroutine
|
||||||
|
a.startFileServer()
|
||||||
|
|
||||||
|
slog.Info("文件系统模块初始化完成")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 审计日志接口 ==========
|
||||||
|
|
||||||
|
// GetAuditLogs 获取审计日志
|
||||||
|
func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
|
||||||
|
return a.filesystem.GetAuditLogs(limit)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"u-desk/internal/filesystem"
|
||||||
|
osssvc "u-desk/internal/ossdrv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== OSS 接口 ==========
|
||||||
|
|
||||||
|
func (a *App) ensureOssService() *osssvc.Service {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.ossService == nil {
|
||||||
|
a.ossService = osssvc.NewService()
|
||||||
|
}
|
||||||
|
return a.ossService
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssConnect 连接 OSS 存储
|
||||||
|
func (a *App) OssConnect(req OssConnectRequest) (string, error) {
|
||||||
|
if err := a.ensureOssService().GetManager().Connect(req.Provider, req.AccessKey, req.SecretKey, req.Endpoint); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return req.Provider, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssDisconnect 断开 OSS 连接
|
||||||
|
func (a *App) OssDisconnect(connID string) error {
|
||||||
|
osssvc.GetManager().Disconnect(connID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssListDir OSS 列出目录
|
||||||
|
func (a *App) OssListDir(connID string, prefix string) ([]map[string]interface{}, error) {
|
||||||
|
return a.ensureOssService().ListDir(connID, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssDownloadToTemp OSS 下载到临时文件
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssWriteFile OSS 写入文件
|
||||||
|
func (a *App) OssWriteFile(connID string, key string, content string) error {
|
||||||
|
return a.ensureOssService().WriteFile(connID, key, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssWriteBase64File OSS 写入 base64 编码文件
|
||||||
|
func (a *App) OssWriteBase64File(connID string, key string, base64Content string) error {
|
||||||
|
return a.ensureOssService().WriteBase64File(connID, key, base64Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssGetFileInfo OSS 获取文件信息
|
||||||
|
func (a *App) OssGetFileInfo(connID string, key string) (map[string]interface{}, error) {
|
||||||
|
return a.ensureOssService().GetFileInfo(connID, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssCreateDir OSS 创建目录
|
||||||
|
func (a *App) OssCreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureOssService().CreateDir(connID, dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssCreateFile OSS 创建文件
|
||||||
|
func (a *App) OssCreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureOssService().CreateFile(connID, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssDeletePath OSS 删除
|
||||||
|
func (a *App) OssDeletePath(connID string, key string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureOssService().DeletePath(connID, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssRenamePath OSS 重命名
|
||||||
|
func (a *App) OssRenamePath(req OssRenamePathRequest) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureOssService().RenamePath(req.ConnID, req.OldPath, req.NewPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssGetCommonPaths OSS 获取常用路径
|
||||||
|
func (a *App) OssGetCommonPaths(connID string) (map[string]string, error) {
|
||||||
|
return a.ensureOssService().GetCommonPaths(connID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssGetSignedURL OSS 获取预签名 URL
|
||||||
|
func (a *App) OssGetSignedURL(connID string, key string) (string, error) {
|
||||||
|
return a.ensureOssService().GetSignedURL(connID, key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"u-desk/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requirePdfAPI 检查 pdfAPI 是否已初始化
|
||||||
|
func (a *App) requirePdfAPI() (*api.PdfAPI, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.pdfAPI == nil {
|
||||||
|
return nil, fmt.Errorf("PDF导出功能正在初始化中")
|
||||||
|
}
|
||||||
|
return a.pdfAPI, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportPDF 导出PDF文件
|
||||||
|
func (a *App) ExportPDF(content string, title string, fileName string, fontSize int, pageWidth int, pageHeight int) (map[string]interface{}, error) {
|
||||||
|
pdfAPI, err := a.requirePdfAPI()
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req := api.PdfExportRequest{
|
||||||
|
Content: content,
|
||||||
|
Title: title,
|
||||||
|
FileName: fileName,
|
||||||
|
FontSize: fontSize,
|
||||||
|
PageWidth: pageWidth,
|
||||||
|
PageHeight: pageHeight,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := pdfAPI.ExportMarkdownToPDF(req)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": result.Success,
|
||||||
|
"message": result.Message,
|
||||||
|
"path": result.Path,
|
||||||
|
"size": result.Size,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectPDFSaveDirectory 选择PDF保存目录
|
||||||
|
func (a *App) SelectPDFSaveDirectory() (string, error) {
|
||||||
|
pdfAPI, err := a.requirePdfAPI()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pdfAPI.SelectDirectory()
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// ========== 回收站接口 ==========
|
||||||
|
|
||||||
|
// GetRecycleBinEntries 获取回收站条目
|
||||||
|
func (a *App) GetRecycleBinEntries() ([]map[string]interface{}, error) {
|
||||||
|
return a.filesystem.GetRecycleBinEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreFromRecycleBin 从回收站恢复文件
|
||||||
|
func (a *App) RestoreFromRecycleBin(recyclePath string) error {
|
||||||
|
return a.filesystem.RestoreFromRecycleBin(recyclePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePermanently 永久删除回收站中的文件
|
||||||
|
func (a *App) DeletePermanently(recyclePath string) error {
|
||||||
|
return a.filesystem.DeletePermanently(recyclePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmptyRecycleBin 清空回收站
|
||||||
|
func (a *App) EmptyRecycleBin() error {
|
||||||
|
return a.filesystem.EmptyRecycleBin()
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/filesystem"
|
||||||
|
"u-desk/internal/sftp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== SFTP 接口 ==========
|
||||||
|
|
||||||
|
func (a *App) ensureSftpService() *sftp.Service {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.sftpService == nil {
|
||||||
|
a.sftpService = sftp.NewService()
|
||||||
|
}
|
||||||
|
return a.sftpService
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpConnect 建立 SFTP 连接,返回连接标识符 connID
|
||||||
|
func (a *App) SftpConnect(req SftpConnectRequest) (string, error) {
|
||||||
|
config := &sftp.Config{
|
||||||
|
Host: req.Host,
|
||||||
|
Port: req.Port,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
KeyPath: req.KeyPath,
|
||||||
|
KeyPassphrase: req.KeyPassphrase,
|
||||||
|
}
|
||||||
|
if config.Port == 0 {
|
||||||
|
config.Port = 22
|
||||||
|
}
|
||||||
|
if config.Timeout == 0 {
|
||||||
|
config.Timeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := a.ensureSftpService()
|
||||||
|
_, err := svc.GetManager().Connect(config)
|
||||||
|
if err != nil {
|
||||||
|
return "", sftp.ToUserMessage(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
connID := sftp.ConnID(config.Host, config.Port)
|
||||||
|
return connID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpDisconnect 断开 SFTP 连接
|
||||||
|
func (a *App) SftpDisconnect(connID string) error {
|
||||||
|
parts := strings.SplitN(connID, ":", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fmt.Errorf("无效的连接标识符")
|
||||||
|
}
|
||||||
|
host := parts[0]
|
||||||
|
port, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("无效的端口号")
|
||||||
|
}
|
||||||
|
|
||||||
|
sftp.GetManager().Disconnect(host, port)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpListDir SFTP 列出目录
|
||||||
|
func (a *App) SftpListDir(connID string, dirPath string) ([]map[string]interface{}, error) {
|
||||||
|
return a.ensureSftpService().ListDir(connID, dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpReadFile SFTP 读取文件内容
|
||||||
|
func (a *App) SftpReadFile(connID string, filePath string) (string, error) {
|
||||||
|
return a.ensureSftpService().ReadFile(connID, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpWriteFile SFTP 写入文件
|
||||||
|
func (a *App) SftpWriteFile(req SftpWriteFileRequest) error {
|
||||||
|
return a.ensureSftpService().WriteFile(req.SessionID, req.Path, req.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等)
|
||||||
|
func (a *App) SftpWriteBase64File(sessionID, filePath, base64Content string) error {
|
||||||
|
return a.ensureSftpService().WriteBase64File(sessionID, filePath, base64Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpGetFileInfo SFTP 获取文件信息
|
||||||
|
func (a *App) SftpGetFileInfo(connID string, filePath string) (map[string]interface{}, error) {
|
||||||
|
return a.ensureSftpService().GetFileInfo(connID, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpCreateDir SFTP 创建目录
|
||||||
|
func (a *App) SftpCreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureSftpService().CreateDir(connID, dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpCreateFile SFTP 创建文件
|
||||||
|
func (a *App) SftpCreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureSftpService().CreateFile(connID, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpDeletePath SFTP 删除文件或目录
|
||||||
|
func (a *App) SftpDeletePath(connID string, filePath string) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureSftpService().DeletePath(connID, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpRenamePath SFTP 重命名文件或目录
|
||||||
|
func (a *App) SftpRenamePath(req SftpRenamePathRequest) (*filesystem.FileOperationResult, error) {
|
||||||
|
return a.ensureSftpService().RenamePath(req.SessionID, req.OldPath, req.NewPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
||||||
|
func (a *App) SftpDownloadToTemp(connID string, remotePath string) (string, error) {
|
||||||
|
return a.ensureSftpService().DownloadToTemp(connID, remotePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpDownloadToFile 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径
|
||||||
|
func (a *App) SftpDownloadToFile(connID string, remotePath string, localDstDir string) (string, error) {
|
||||||
|
emitter := newTransferEmitter(a, "download", path.Base(remotePath))
|
||||||
|
localPath, err := a.ensureSftpService().DownloadToFile(connID, remotePath, localDstDir, emitter.progress)
|
||||||
|
emitter.finish(err)
|
||||||
|
return localPath, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpUploadFromFile 上传本地文件/目录到远程目录(流式,目录递归,重名自动副本)
|
||||||
|
func (a *App) SftpUploadFromFile(connID string, localPath string, remoteDir string) error {
|
||||||
|
emitter := newTransferEmitter(a, "upload", filepath.Base(localPath))
|
||||||
|
err := a.ensureSftpService().UploadFromFile(connID, localPath, remoteDir, emitter.progress)
|
||||||
|
emitter.finish(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpGetCommonPaths 获取 SFTP 远程主机常用路径
|
||||||
|
func (a *App) SftpGetCommonPaths(connID string) (map[string]string, error) {
|
||||||
|
return a.ensureSftpService().GetCommonPaths(connID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SftpGetSystemInfo 获取 SFTP 远程主机系统信息(CPU/内存/磁盘)
|
||||||
|
func (a *App) SftpGetSystemInfo(connID string) (map[string]interface{}, error) {
|
||||||
|
return a.ensureSftpService().GetSystemInfo(connID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"u-desk/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSystemInfo 获取系统信息
|
||||||
|
func (a *App) GetSystemInfo() (map[string]interface{}, error) {
|
||||||
|
return system.GetSystemInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCPUInfo 获取 CPU 信息
|
||||||
|
func (a *App) GetCPUInfo() (map[string]interface{}, error) {
|
||||||
|
return system.GetCPUInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemoryInfo 获取内存信息
|
||||||
|
func (a *App) GetMemoryInfo() (map[string]interface{}, error) {
|
||||||
|
return system.GetMemoryInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDiskInfo 获取磁盘信息
|
||||||
|
func (a *App) GetDiskInfo() ([]map[string]interface{}, error) {
|
||||||
|
return system.GetDiskInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetLocalSystemInfo() (map[string]interface{}, error) {
|
||||||
|
info := make(map[string]interface{})
|
||||||
|
|
||||||
|
cpuInfo, err := system.GetCPUInfo()
|
||||||
|
if err == nil && cpuInfo != nil {
|
||||||
|
if v, ok := cpuInfo["usage"].(string); ok {
|
||||||
|
info["cpu_usage"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memInfo, err := system.GetMemoryInfo()
|
||||||
|
if err == nil && memInfo != nil {
|
||||||
|
if v, ok := memInfo["usage"].(string); ok {
|
||||||
|
info["mem_usage"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diskInfos, err := system.GetDiskInfo()
|
||||||
|
if err == nil && len(diskInfos) > 0 {
|
||||||
|
if v, ok := diskInfos[0]["usage"].(string); ok {
|
||||||
|
info["disk_usage"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCommonPaths 获取常用系统路径
|
||||||
|
func (a *App) GetCommonPaths() (map[string]string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := map[string]string{
|
||||||
|
"home": homeDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range resolveFolderPaths(homeDir) {
|
||||||
|
paths[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnvVars 获取环境变量
|
||||||
|
func (a *App) GetEnvVars() (map[string]string, error) {
|
||||||
|
envVars := make(map[string]string)
|
||||||
|
for _, env := range os.Environ() {
|
||||||
|
if key, value, found := strings.Cut(env, "="); found {
|
||||||
|
envVars[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return envVars, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== 版本更新管理接口 ==========
|
||||||
|
|
||||||
|
// requireUpdateAPI 检查 updateAPI 是否已初始化
|
||||||
|
func (a *App) requireUpdateAPI() (*api.UpdateAPI, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.updateAPI == nil {
|
||||||
|
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||||
|
}
|
||||||
|
return a.updateAPI, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckUpdate 检查更新
|
||||||
|
func (a *App) CheckUpdate() (map[string]interface{}, error) {
|
||||||
|
api, err := a.requireUpdateAPI()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return api.CheckUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentVersion 获取当前版本号
|
||||||
|
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
|
||||||
|
api, err := a.requireUpdateAPI()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return api.GetCurrentVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpdateConfig 获取更新配置
|
||||||
|
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
|
||||||
|
api, err := a.requireUpdateAPI()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return api.GetUpdateConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUpdateConfig 设置更新配置
|
||||||
|
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
|
||||||
|
api, err := a.requireUpdateAPI()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return api.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadUpdate 下载更新包
|
||||||
|
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
|
||||||
|
api, err := a.requireUpdateAPI()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return api.DownloadUpdate(downloadURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallUpdate 安装更新包
|
||||||
|
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
|
||||||
|
api, err := a.requireUpdateAPI()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return api.InstallUpdate(installerPath, autoRestart)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallUpdateWithHash 安装更新包(带哈希验证)
|
||||||
|
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||||
|
api, err := a.requireUpdateAPI()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return api.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyUpdateFile 验证更新文件哈希值
|
||||||
|
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||||
|
api, err := a.requireUpdateAPI()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAutoUpdateCheck 启动自动更新检查
|
||||||
|
func (a *App) startAutoUpdateCheck() {
|
||||||
|
updateAPI, err := a.requireUpdateAPI()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := updateAPI.GetUpdateConfig()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("获取更新配置失败", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success, ok := config["success"].(bool)
|
||||||
|
if !ok || !success {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configData, ok := config["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
autoCheckEnabled, ok := configData["auto_check_enabled"].(bool)
|
||||||
|
if !ok || !autoCheckEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
interval, ok := configData["check_interval_minutes"].(int)
|
||||||
|
if !ok || interval <= 0 {
|
||||||
|
interval = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
go a.checkUpdate()
|
||||||
|
|
||||||
|
a.updateTicker = time.NewTicker(time.Duration(interval) * time.Minute)
|
||||||
|
go func() {
|
||||||
|
for range a.updateTicker.C {
|
||||||
|
a.checkUpdate()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUpdate 执行更新检查
|
||||||
|
func (a *App) checkUpdate() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
slog.Error("自动检查更新发生错误", "error", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
api := a.updateAPI
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if api == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := api.CheckUpdate()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
success, ok := result["success"].(bool)
|
||||||
|
if !ok || !success {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := result["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasUpdate, ok := data["has_update"].(bool)
|
||||||
|
if ok && hasUpdate && a.mainWindow != nil {
|
||||||
|
a.mainWindow.EmitEvent("update-available", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== 窗口控制方法 ==========
|
||||||
|
|
||||||
|
// WindowMinimize 最小化窗口
|
||||||
|
func (a *App) WindowMinimize() {
|
||||||
|
if a.mainWindow != nil {
|
||||||
|
a.mainWindow.Minimise()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WindowMaximize 最大化/还原窗口
|
||||||
|
func (a *App) WindowMaximize() {
|
||||||
|
if a.mainWindow == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.mainWindow.IsMaximised() {
|
||||||
|
a.mainWindow.UnMaximise()
|
||||||
|
} else {
|
||||||
|
a.mainWindow.Maximise()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WindowClose 关闭窗口
|
||||||
|
func (a *App) WindowClose() {
|
||||||
|
application.Get().Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WindowIsMaximized 检查窗口是否最大化
|
||||||
|
func (a *App) WindowIsMaximized() bool {
|
||||||
|
if a.mainWindow != nil {
|
||||||
|
return a.mainWindow.IsMaximised()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// WindowToggleAlwaysOnTop 切换窗口置顶
|
||||||
|
func (a *App) WindowToggleAlwaysOnTop() bool {
|
||||||
|
if a.mainWindow == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
a.isAlwaysOnTop = !a.isAlwaysOnTop
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.mainWindow.SetAlwaysOnTop(a.isAlwaysOnTop)
|
||||||
|
return a.isAlwaysOnTop
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWindowTitleBarColor 设置原生标题栏颜色 + 主题模式(0x00BBGGRR 格式)
|
||||||
|
func (a *App) SetWindowTitleBarColor(color uint32, isDark bool) {
|
||||||
|
if a.mainWindow == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hwnd := uintptr(a.mainWindow.NativeWindow())
|
||||||
|
if hwnd == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setWindowTitleBar(hwnd, color, isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload 重新加载窗口(用于菜单项)
|
||||||
|
func (a *App) Reload() {
|
||||||
|
if a.mainWindow != nil {
|
||||||
|
a.mainWindow.Reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearCache 清理本地缓存(用于菜单项)
|
||||||
|
func (a *App) ClearCache() {
|
||||||
|
if a.mainWindow != nil {
|
||||||
|
a.mainWindow.EmitEvent("clear-cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/w32"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getWindowsSpecialFolder 从注册表读取 Windows 特殊文件夹的真实路径
|
||||||
|
func getWindowsSpecialFolder(guid string) string {
|
||||||
|
key, err := registry.OpenKey(registry.CURRENT_USER,
|
||||||
|
`Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders`,
|
||||||
|
registry.READ)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer key.Close()
|
||||||
|
|
||||||
|
val, _, err := key.GetStringValue(guid)
|
||||||
|
if err != nil || val == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
path := os.ExpandEnv(val)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWindowsDrivePaths 返回所有可用盘符路径
|
||||||
|
func getWindowsDrivePaths() map[string]string {
|
||||||
|
paths := make(map[string]string)
|
||||||
|
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
|
||||||
|
path := string(drive) + ":\\"
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
key := string(drive)
|
||||||
|
paths[key] = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// setWindowTitleBar 设置原生标题栏颜色 + 主题模式
|
||||||
|
func setWindowTitleBar(hwnd uintptr, color uint32, isDark bool) {
|
||||||
|
w32.SetTheme(hwnd, isDark)
|
||||||
|
w32.SetTitleBarColour(hwnd, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFolderPaths 解析特殊文件夹路径
|
||||||
|
func resolveFolderPaths(homeDir string) map[string]string {
|
||||||
|
paths := make(map[string]string)
|
||||||
|
folderGUIDs := map[string]string{
|
||||||
|
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
|
||||||
|
"documents": "{D20B4C7F-5EA7-424C-B25E-039F6F1FCC8A}",
|
||||||
|
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
|
||||||
|
}
|
||||||
|
for name, guid := range folderGUIDs {
|
||||||
|
if p := getWindowsSpecialFolder(guid); p != "" {
|
||||||
|
paths[name] = p
|
||||||
|
} else {
|
||||||
|
paths[name] = filepath.Join(homeDir, strings.ToUpper(name[:1])+name[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range getWindowsDrivePaths() {
|
||||||
|
paths["root_"+k] = v
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
@@ -0,0 +1,664 @@
|
|||||||
|
# 功能决策记录
|
||||||
|
|
||||||
|
> 日常开发中对各功能做的细节取舍(功能粒度,补架构决策之下的实现层)。聚焦「为什么这么定」。
|
||||||
|
> 创建:2026-06-12 | 维护:随开发追加
|
||||||
|
>
|
||||||
|
> 来源:历史会话挖掘 + 当前会话。覆盖 u-desk 0.3.x → 0.4.x(fs-only-v3)开发期。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 启动 / 关闭速度
|
||||||
|
|
||||||
|
### ServiceStartup 非关键路径并行化
|
||||||
|
- **决策**:SQLite + ConfigAPI 前置同步,PdfAPI/FileSystemService/CleanupExpiredCache/CleanupSiteCacheDirs 移入 goroutine 并行。
|
||||||
|
- **原因/取舍**:前置依赖必须先就绪;非关键子系统串行会阻塞 Wails 窗口加载前端,并行后首屏不受拖累。
|
||||||
|
- **状态**:✅ 已落地(`app.go` ServiceStartup)
|
||||||
|
|
||||||
|
### 审计日志懒初始化 ensureReady
|
||||||
|
- **决策**:审计日志从启动全量初始化改为首条写入时才 `ensureReady`(开文件 + 起 flush 协程)。
|
||||||
|
- **原因/取舍**:原 `NewAuditLogger` 启动即建目录/开文件/起协程拖慢启动。懒初始化零启动开销;用 mutex+initialized 标志替代 `sync.Once`,因 sync.Once 消费后不再执行、初始化失败后后续 Log 静默丢失。
|
||||||
|
- **状态**:✅ 已落地(`audit_log.go`)
|
||||||
|
|
||||||
|
### 回收站 metadata 延迟加载
|
||||||
|
- **决策**:回收站 `metadata.json` 从启动加载改为首次访问时 `ensureLoaded`。
|
||||||
|
- **原因/取舍**:用户可能整个会话不用回收站;延迟加载减启动 IO 和内存。
|
||||||
|
- **状态**:✅ 已落地(`recycle_bin.go`)
|
||||||
|
|
||||||
|
### startFileServer 同步启动
|
||||||
|
- **决策**:`startFileServer` 从 `go a.startFileServer()` 改为同步调用。
|
||||||
|
- **原因/取舍**:该函数只注册 HTTP handler 不阻塞;异步启动反而有 handler 未注册完成时前端已请求的竞态。
|
||||||
|
- **状态**:✅ 已落地(`app_filesystem.go`)
|
||||||
|
|
||||||
|
### loadFromDB 延迟 500ms 自动连接
|
||||||
|
- **决策**:`loadFromDB` 中系统信息采集 + 自动连接包进 `setTimeout(500)`。
|
||||||
|
- **原因/取舍**:远程 profile 自动连接并发网络 IO 阻塞首屏;延迟 500ms 让空壳 UI 先渲染。代价:首屏 500ms 内远程不可用,且与 session restore 存在同 profile 双连接竞争(见连接管理域)。
|
||||||
|
- **状态**:✅ 已落地(`connection-manager.ts`)
|
||||||
|
|
||||||
|
### session restore 延迟到 rAF
|
||||||
|
- **决策**:会话恢复(路径/连接)延迟到 `requestAnimationFrame`。
|
||||||
|
- **原因/取舍**:`onMounted` 恢复阻塞 Vue 首帧渲染;rAF 让首帧先绘制再恢复。
|
||||||
|
- **状态**:✅ 已落地(`index.vue` onMounted)
|
||||||
|
|
||||||
|
### Markdown / highlight.js 动态懒加载
|
||||||
|
- **决策**:`marked` + `highlight.js` 从静态 import 改为按需动态 import。
|
||||||
|
- **原因/取舍**:两库 ~200KB+,全量导入阻塞首屏;文件预览是按需功能不应拖慢所有用户。代价:首次开 MD 有原文闪烁。
|
||||||
|
- **状态**:✅ 已落地(`markedExtensions.ts`)
|
||||||
|
|
||||||
|
### 启动后 3s 延迟静默检查更新
|
||||||
|
- **决策**:启动后 `setTimeout(3000)` 静默检查更新。
|
||||||
|
- **原因/取舍**:启动即发网络请求增加首屏时间;延迟 3s 让 UI 先渲染,静默不弹窗。
|
||||||
|
- **状态**:✅ 已落地(`App.vue`)
|
||||||
|
|
||||||
|
### ServiceShutdown errgroup 并行 + 5s 总超时
|
||||||
|
- **决策**:关闭用 errgroup 并行关 4 个子系统,共享 5s 总超时。
|
||||||
|
- **原因/取舍**:原串行关闭耗时叠加;并行后总耗时取最慢子系统,5s 兜底防无限挂起。
|
||||||
|
- **状态**:✅ 已落地(`app.go` ServiceShutdown)
|
||||||
|
|
||||||
|
### 热键轮询递增间隔
|
||||||
|
- **决策**:热键轮询从固定 200ms 改为 50→100→200ms 递增。
|
||||||
|
- **原因/取舍**:首检快 150ms(50ms 捕获),后续逐步放宽降 CPU。
|
||||||
|
- **状态**:✅ 已落地(`app.go`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件删除 / 回收站
|
||||||
|
|
||||||
|
### 回收站优先策略
|
||||||
|
- **决策**:删除文件时**先尝试移入回收站**,移动失败才永久删除。
|
||||||
|
- **原因/取舍**:旧逻辑「先永久删除再移回收站」——文件已删,移回收站必然失败,回收站形同虚设。改为先移后删保证误删可恢复。永久删除仅在跨卷/权限失败时降级兜底。
|
||||||
|
- **状态**:✅ 已落地(`filesystem/service.go`)
|
||||||
|
|
||||||
|
### isInRecycleBin 用 HasPrefix
|
||||||
|
- **决策**:判断路径是否在回收站内用 `strings.HasPrefix` 而非切片前缀比较。
|
||||||
|
- **原因/取舍**:旧 `cleanPath[:len(cleanBinPath)]` 当 `len(cleanPath) < len(cleanBinPath)` 时切片越界 panic。HasPrefix 自带长度判断。
|
||||||
|
- **状态**:✅ 已落地
|
||||||
|
|
||||||
|
### 回收站 entries 并发安全
|
||||||
|
- **决策**:`RecycleBin.entries` 加 `sync.Mutex`,`ListEntries` 返回副本。
|
||||||
|
- **原因/取舍**:`autoCleanup` 后台协程与用户操作并发读写 entries 存在竞争;返回副本避免外部遍历被 cleanup 修改 panic。
|
||||||
|
- **状态**:✅ 已落地(`recycle_bin.go`)
|
||||||
|
|
||||||
|
### 随机字符串加索引偏移
|
||||||
|
- **决策**:`generateRandomString` 每个字符对 nanoseed 加索引 `i` 偏移再取模。
|
||||||
|
- **原因/取舍**:同一次调用内多字符同纳秒生成、seed 相同导致全串重复字符。加索引打破局部相同。非密码学随机,简单偏移足够。
|
||||||
|
- **状态**:✅ 已落地(`recycle_bin.go`)
|
||||||
|
|
||||||
|
### IsFileLocked 去 O_CREAT
|
||||||
|
- **决策**:`IsFileLocked` 去掉 `O_CREAT` 改纯只读探测。
|
||||||
|
- **原因/取舍**:`O_CREAT` 检查锁时会意外创建空文件——目标已删时被「复活」成 0 字节误导。纯探测不应有写入语义。
|
||||||
|
- **状态**:✅ 已落地(`file_lock.go`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 远程 HTML 站点预览
|
||||||
|
|
||||||
|
### 确定性站点缓存目录
|
||||||
|
- **决策**:远程 HTML 预览用 `common.SiteCacheDir`(SHA-256 哈希 transport+connID+path+size+modTime)生成确定性路径,命中直接返回;SFTP 与 OSS 统一。
|
||||||
|
- **原因/取舍**:旧 OSS 方案每次 `os.MkdirTemp` 全量重下,二次打开仍慢。确定性哈希让同文件二次打开命中缓存,size+modTime 进哈希源文件变动自动失效。取舍:路径可预测(非随机名),去重收益远大于此。
|
||||||
|
- **状态**:✅ 已落地(`common/utils.go` + `ossdrv`/`sftp` DownloadSiteForPreview)
|
||||||
|
|
||||||
|
### 资源下载范围演进
|
||||||
|
- **决策**:全量下载 → 仅补充已发现资源所在目录 → `supplementDir` 限制 50 文件 / 单文件 5MB。
|
||||||
|
- **原因/取舍**:用户指出「网站可能 100G」全量不可行;改为只补充资源所在目录(webpack chunk 等)。最终加 50 文件 + 5MB 上限防失控,SFTP/OSS 统一。
|
||||||
|
- **状态**:✅ 已落地(`sftp/service.go`、`ossdrv/service.go`)
|
||||||
|
|
||||||
|
### OSS 补充分页
|
||||||
|
- **决策**:OSS `supplementDir` 列目录循环翻页(`IsTruncated` + `NextMarker`),取完或达上限止。
|
||||||
|
- **原因/取舍**:旧 `MaxKeys:200` 单次调用,目录超 200 文件直接截断漏下载。分页保完整 + 上限防失控。
|
||||||
|
- **状态**:✅ 已落地(`ossdrv/service.go`)
|
||||||
|
|
||||||
|
### 资源下载并发策略
|
||||||
|
- **决策**:相对路径资源 8 并发、绝对路径资源串行。
|
||||||
|
- **原因/取舍**:绝对路径 siteRoot 嗅探是共享状态需顺序探测;相对路径无依赖可并行。20 资源从 20×RTT 降到 ~3 轮;8 并发信号量防连接数爆炸。
|
||||||
|
- **状态**:✅ 已落地(`sftp`/`ossdrv` DownloadSiteForPreview)
|
||||||
|
|
||||||
|
### 绝对路径 siteRoot 向上嗅探
|
||||||
|
- **决策**:绝对路径 `/assets/js/main.js` 的根含义通过从 HTML 所在目录逐级向上尝试下载直到桶根来确定,非简单去 `/`。
|
||||||
|
- **原因/取舍**:不同 HTML 的 `/` 可能指向不同根(多站点共存);简单相对 HTML 目录在嵌套场景出错(`pages/about.html` 引 `/assets/` 应到桶根而非 pages/assets)。
|
||||||
|
- **状态**:✅ 已落地(`ossdrv` resolveAbsoluteResourcePath)
|
||||||
|
|
||||||
|
### siteRoot 不跨 HTML 共享
|
||||||
|
- **决策**:每个 HTML 独立嗅探 siteRoot,不跨调用共享。
|
||||||
|
- **原因/取舍**:同桶多个不同网站的 `/` 可能指不同根;共享会跨站污染。
|
||||||
|
- **状态**:✅ 已落地
|
||||||
|
|
||||||
|
### 路径重写:正则 → JS setter 拦截
|
||||||
|
- **决策**:用 JS 拦截脚本覆盖 `HTMLScriptElement.src` 等 setter 重写绝对路径,替代 `locationPathRegex` 正则替换。
|
||||||
|
- **原因/取舍**:正则替换 `location.pathname` 在 inline JS 字符串内破坏引号致 SyntaxError;正则无法处理 webpack 动态 `import()` chunk。setter 拦截覆盖静态 + 动态两种。
|
||||||
|
- **状态**:✅ 已落地(`asset_handler.go` injectPathInterceptor)
|
||||||
|
|
||||||
|
### 拦截脚本 base 用 baseDir
|
||||||
|
- **决策**:拦截脚本 base 直接用 HTML 文件所在目录,不用 `findSiteRootDir` 嗅探的网站根。
|
||||||
|
- **原因/取舍**:SFTP 场景 `findSiteRootDir` 返回临时目录根,实际网站根在子目录;用 baseDir 与 `transformHtmlResourcePaths` 解析一致避免层级错位。
|
||||||
|
- **状态**:✅ 已落地(`asset_handler.go`/`sftp/service.go`)
|
||||||
|
|
||||||
|
### CSS MIME 按扩展名显式设置
|
||||||
|
- **决策**:文件服务器按扩展名设 Content-Type(CSS → `text/css`),不依赖 `http.DetectContentType`。
|
||||||
|
- **原因/取舍**:`DetectContentType` 对 CSS 返回 `text/plain`,浏览器 strict MIME checking 拒绝应用样式。
|
||||||
|
- **状态**:✅ 已落地(`asset_handler.go`)
|
||||||
|
|
||||||
|
### 资源探测用 try-download
|
||||||
|
- **决策**:用 try-download 探测资源存在性,而非先 `GetFileInfo`/`Exists`。
|
||||||
|
- **原因/取舍**:七牛 `GetFileInfo` 可能走签名域名返回 EOF,接口无 `Exists`;直接尝试下载更稳妥,失败即跳过。
|
||||||
|
- **状态**:✅ 已落地(`ossdrv` tryDownloadResource)
|
||||||
|
|
||||||
|
### OSS HTML 预览路由:/proxy → /localfs
|
||||||
|
- **决策**:OSS 的 HTML 预览从 `/proxy/html-preview`(远程代理路由)改为先下载到临时目录再用 `/localfs/html-preview`。
|
||||||
|
- **原因/取舍**:本地文件服务器只注册了 `/localfs/html-preview`,`/proxy/` 仅在 agent server;OSS 路径不是本地 FS 路径,代理路由无法处理。
|
||||||
|
- **状态**:✅ 已落地(`useFilePreview.ts` htmlPreviewUrl)
|
||||||
|
|
||||||
|
### 仅 HTML 类型走整站流程
|
||||||
|
- **决策**:仅 HTML 预览走独立整站下载流程,其他类型(图片/视频/音频)走 `resolveWithTransport()` 单文件。
|
||||||
|
- **原因/取舍**:其他类型已通过 `downloadForPreview`+`buildLocalUrl` 走 `/localfs/`;只有 HTML 需下载关联资源。
|
||||||
|
- **状态**:✅ 已落地(`useFilePreview.ts`)
|
||||||
|
|
||||||
|
### HTML 资源正则收窄 + srcset 补全
|
||||||
|
- **决策**:`htmlResRegex` 中 `data` 收窄为 `data-src|data-url`;新增 `srcset` 正则独立提取;排除 `<a href>` 导航链接和非资源扩展名。
|
||||||
|
- **原因/取舍**:旧 `data=` 误匹配 `data-id` 等无关属性产生无效拉取;srcset 是响应式图片标准属性旧逻辑完全遗漏;`<a href>` 导航链接不应下载。
|
||||||
|
- **状态**:✅ 已落地(`common/utils.go`、`site_resource.go`)
|
||||||
|
|
||||||
|
### 资源提取逻辑 DRY 统一
|
||||||
|
- **决策**:OSS 和 SFTP 的 `extractResources`/`shouldSkipResource` 重复代码统一到 `filesystem/site_resource.go`。
|
||||||
|
- **原因/取舍**:两 service 各复制一份相同逻辑违反 DRY;统一净减 114 行。
|
||||||
|
- **状态**:✅ 已落地
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七牛 OSS
|
||||||
|
|
||||||
|
### 域名分类:临时域名优先
|
||||||
|
- **决策**:下载域名解析临时域名优先(按后缀 `*.qiniudns.com`/`*.clouddn.com`/`*.qbox.me` 分类),自定义域名降级。
|
||||||
|
- **原因/取舍**:用户桶绑定的自定义域名不可达导致 EOF;临时域名更稳定。后缀 + 前缀双重匹配防误判。
|
||||||
|
- **状态**:✅ 已落地(`qiniu/client.go` resolveDownloadDomain)
|
||||||
|
|
||||||
|
### 域名探测策略演进:TCP → HTTP HEAD → 移除
|
||||||
|
- **决策**:域名可达性探测从 TCP 连接 → HTTP HEAD → 最终移除,改用签名 URL 直接下载。
|
||||||
|
- **原因/取舍**:TCP 探测端口开放但 HTTP 返回 EOF 无法发现;HTTP HEAD 对 5xx 无法区分真假可用;签名 URL 可绕防盗链,探测逻辑不再需要。
|
||||||
|
- **状态**:✅ 已落地(`qiniu/client.go`,probeDomain 已删)
|
||||||
|
|
||||||
|
### 签名 URL 绕防盗链
|
||||||
|
- **决策**:`Download` 内部改用签名 URL 而非裸 HTTP GET;签名格式从 `URL\ndeadline` 修正为 `URL?e=deadline`。
|
||||||
|
- **原因/取舍**:裸 GET 遇 CDN 防盗链(478)被拒;签名 URL 绕过防盗链;参照 kodo-browser 始终用签名 URL。
|
||||||
|
- **状态**:✅ 已落地(`qiniu/client.go` GetSignedURL/Download)
|
||||||
|
|
||||||
|
### region 从域名提取
|
||||||
|
- **决策**:从 API 返回的临时域名提取真实 region(`detectRegionFromDomains`),不依赖用户配置的 `config.Region`。
|
||||||
|
- **原因/取舍**:用户填的 Region 可能错误(填 z0 实际 as0),构造的降级域名不存在(502);从 `u-res-as0.qiniudns.com` 提取才准确。
|
||||||
|
- **状态**:✅ 已落地
|
||||||
|
|
||||||
|
### 跨账号桶返回错误
|
||||||
|
- **决策**:`resolveDownloadDomain` 在 API 返回空域名列表时直接返回错误,不伪造 `<bucket>-<region>.qiniudns.com`。
|
||||||
|
- **原因/取舍**:跨账号桶 `GetBucketDomains` 返回空,伪造域名不存在致 403;代码无法修跨账号问题。
|
||||||
|
- **状态**:✅ 已落地
|
||||||
|
|
||||||
|
### 阿里云不适用此逻辑
|
||||||
|
- **决策**:阿里云 OSS 不加域名探测/降级/签名 URL 逻辑。
|
||||||
|
- **原因/取舍**:阿里云下载 URL 直接 `<bucket>.<region>.aliyuncs.com`,endpoint 固定官方域名始终可达;用户要求不影响阿里云正确访问。
|
||||||
|
- **状态**:✅ 已落地
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阿里云 OSS
|
||||||
|
|
||||||
|
### 手动签名不用官方 SDK
|
||||||
|
- **决策**:采用手动签名,不引入阿里云官方 SDK。
|
||||||
|
- **原因/取舍**:项目已有手动签名实现,避免重量级 SDK 依赖;需自行处理 base64(StdEncoding→URLEncoding)、URL 编码(PathEscape + QueryEscape)。
|
||||||
|
- **状态**:✅ 已落地(`oss/aliyun/client.go`)
|
||||||
|
|
||||||
|
### endpoint 信任用户配置
|
||||||
|
- **决策**:endpoint 直接传用户配置值,信任用户配置,不自动清空/覆盖。
|
||||||
|
- **原因/取舍**:旧代码 region 非空时清空 endpoint 让 NewClient 从 region 派生,破坏 VPC/自定义域名场景;改为信任配置,NewClient 在 endpoint 空时才从 region 派生(已有此逻辑)。
|
||||||
|
- **状态**:✅ 已落地(`ossdrv/service.go`)
|
||||||
|
|
||||||
|
### region 全量缓存 + 探测
|
||||||
|
- **决策**:`Connect()` 时立即缓存所有桶 region 到 `bucketRegions`,未命中时主动 `ListBuckets` 探测。
|
||||||
|
- **原因/取舍**:app 重启后直接跳上次浏览的桶时缓存无数据,客户端用默认 hangzhou 创建致跨 region 访问失败。
|
||||||
|
- **状态**:✅ 已落地(`oss/aliyun/`)
|
||||||
|
|
||||||
|
### 缓存 key:GetFileInfo 失败直接报错
|
||||||
|
- **决策**:`DownloadToTemp` 中 GetFileInfo 失败时直接返回错误,不静默降级用空 modTime。
|
||||||
|
- **原因/取舍**:原失败时用空 modTime 继续生成 key,失败/成功状态产生不同 key 永远命中不了缓存;确定报错让调用方明确处理。
|
||||||
|
- **状态**:✅ 已落地(`ossdrv/service.go` DownloadToTemp)
|
||||||
|
|
||||||
|
### Last-Modified 解析填入 FileInfo
|
||||||
|
- **决策**:解析阿里云 HEAD 响应的 Last-Modified 头填入 FileInfo(原 `_ =` 丢弃)。
|
||||||
|
- **原因/取舍**:丢弃致 `LastModified` 永远零值,缓存 key 用零值格式化后不稳定(失败/成功 key 不同),永远命中不了已有缓存。
|
||||||
|
- **状态**:✅ 已落地(`oss/aliyun/client.go` GetFileInfo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 缓存管理
|
||||||
|
|
||||||
|
### 预览缓存:前端 LRU Map → 后端 SQLite 24h
|
||||||
|
- **决策**:预览缓存从前端 LRU Map(上限 50)迁移到后端 SQLite,键 (transport, connID, remotePath, fileSize, modTime),24h TTL。
|
||||||
|
- **原因/取舍**:前端 JS Map 重启即丢失且无过期;后端 SQLite 重启后仍有效,size+modTime 进键源文件变动自动失效。
|
||||||
|
- **状态**:✅ 已落地(`storage/download_cache.go`)
|
||||||
|
|
||||||
|
### 站点缓存清理时机:启动 + 关闭
|
||||||
|
- **决策**:站点预览缓存在启动和关闭时各清理一次 24h 过期目录,不引入后台定时器。
|
||||||
|
- **原因/取舍**:站点缓存用户驱动低频,不需常驻 goroutine 轮询;启停各清一次覆盖典型生命周期。同时清遗留旧随机目录 `udesk-site-*`。
|
||||||
|
- **状态**:✅ 已落地(`download_cache.go` CleanupSiteCacheDirs + `app.go`)
|
||||||
|
|
||||||
|
### 内容类型检测缓存 200 条满清空
|
||||||
|
- **决策**:`contentDetectCache` 加 200 条上限,满时整体 `clear()`(非逐条 LRU)。
|
||||||
|
- **原因/取舍**:无上限会内存泄漏;选「满即清空」非真 LRU——文件类型检测重复命中率低、清空成本低、实现简单。
|
||||||
|
- **状态**:✅ 已落地(`useFilePreview.ts`)
|
||||||
|
|
||||||
|
### 面包屑目录缓存 200 条
|
||||||
|
- **决策**:面包屑悬停子目录列表用 `Map<path, result>` 缓存,限 200 条。
|
||||||
|
- **原因/取舍**:悬停需频繁查询,缓存避免重复网络请求;只缓存目录(悬停只展示可导航子目录)。
|
||||||
|
- **状态**:✅ 已落地(`PathBreadcrumb.vue` dirCache)
|
||||||
|
|
||||||
|
### CodeEditor 滚动位置缓存
|
||||||
|
- **决策**:滚动位置 LRU 最多 5 份,每份 3min 过期。
|
||||||
|
- **原因/取舍**:文件切换保留编辑位置提升体验;5 份对应最近 5 文件,3min 过期避免长期占内存。
|
||||||
|
- **状态**:✅ 已落地(`CodeEditor.vue`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误处理策略
|
||||||
|
|
||||||
|
### GetFileInfo 错误:吞掉 → slog.Warn
|
||||||
|
- **决策**:Go 后端 6 处 `infoMap, _ := GetFileInfo(...)` 改为 `slog.Warn` 可追踪。
|
||||||
|
- **原因/取舍**:吞错误致前端收到零值数据(name/path/size 全零)既不可用又不可调试。
|
||||||
|
- **状态**:✅ 已落地(`sftp`/`ossdrv`)
|
||||||
|
|
||||||
|
### 前端 useFileOperations 去 catch 纯代理
|
||||||
|
- **决策**:`useFileOperations` 删除全部 try-catch 改为纯代理。
|
||||||
|
- **原因/取舍**:原 catch → onError → throw 三重处理致同一错误弹两次消息;改为边界层统一处理,中间层不吞异常。
|
||||||
|
- **状态**:✅ 已落地(`useFileOperations.ts`)
|
||||||
|
|
||||||
|
### 系统 API 边界层合理兜底(回滚)
|
||||||
|
- **决策**:`system.ts` 4 个系统信息 API(CPU/内存/磁盘/环境变量)先删空 catch 后又加回轻量 `try { ... } catch { return {} }`。
|
||||||
|
- **原因/取舍**:先按确定性编程删空 catch,但审查发现系统信息属边界展示层——获取失败不应崩 UI,返回 `{}` 是合理降级。与中间层「不吞异常」原则不同,这是边界层合理兜底。
|
||||||
|
- **状态**:✅ 已落地(`api/system.ts`)
|
||||||
|
|
||||||
|
### 目录加载失败提示
|
||||||
|
- **决策**:`loadDirectory(dir).catch(() => {})` 静默吞改为 `Message.error` 提示。
|
||||||
|
- **原因/取舍**:fire-and-forget 静默吞错时加载失败用户不知列表为何没更新,无从排障;用户感知优先。
|
||||||
|
- **状态**:✅ 已落地(`index.vue`)
|
||||||
|
|
||||||
|
### 系统信息部分成功降级
|
||||||
|
- **决策**:`GetLocalSystemInfo` 三项采集全部失败才返回 error,部分成功仍返回已得数据。
|
||||||
|
- **原因/取舍**:单项失败不应让整个接口不可用(降级优于全失败);全失败才说明采集机制本身有问题。
|
||||||
|
- **状态**:✅ 已落地(`app_system.go`)
|
||||||
|
|
||||||
|
### OSS 重命名单文件失败不中断
|
||||||
|
- **决策**:批量重命名中单文件删除失败不中断整批,记录并返回最后一个错误。
|
||||||
|
- **原因/取舍**:单文件失败中断整批时已删无法回滚、未删无法继续体验更差;ossdrv 未引 slog 故用 `fmt.Errorf` 累积。
|
||||||
|
- **状态**:✅ 已落地(`ossdrv/service.go`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 连接管理
|
||||||
|
|
||||||
|
### _activeId 提前设置 + 失败回退
|
||||||
|
- **决策**:`_activeId` 在 `buildAndPool()` 之前设置,失败时回退旧值。
|
||||||
|
- **原因/取舍**:原在 buildAndPool 之后设,`setState('connected')` 回调读到旧 activeProfile 致 transport 类型判断错误。提前设置引入新问题:失败时卡在失败 ID 上 Sidebar 无法重试 → 保存旧值失败恢复。
|
||||||
|
- **状态**:✅ 已落地(`connection-manager.ts` connect)
|
||||||
|
|
||||||
|
### _lastActiveId 去重提前执行
|
||||||
|
- **决策**:`_lastActiveId` 去重检查在 `_suppressAutoNav` 之前执行。
|
||||||
|
- **原因/取舍**:原更新在 suppressAutoNav 检查之后,抑制时回调 return 致 `_lastActiveId` 不更新;抑制后 `updateProfile` 异步 notifyChange 触发回调时 `_lastActiveId` 过期误触发导航到 `/` 覆盖正确路径。
|
||||||
|
- **状态**:✅ 已落地(`index.vue` onStateChange)
|
||||||
|
|
||||||
|
### 连接池复用前 isAlive 探测
|
||||||
|
- **决策**:`connect()` 复用池中连接前先调 `transport.isAlive()`,失效则清理重建。
|
||||||
|
- **原因/取舍**:原见池中有就返回「已连接」,僵尸连接点击时直接返回已连接实际已断;复用前探测识别并移除失效连接。
|
||||||
|
- **状态**:✅ 已落地(`connection-manager.ts`)
|
||||||
|
|
||||||
|
### isAlive 做成可选方法
|
||||||
|
- **决策**:`FsTransport` 接口新增可选 `isAlive?(): Promise<boolean>`,SFTP 用 `SftpGetFileInfo('/')`、OSS 用 `OssListDir('')` 探测。
|
||||||
|
- **原因/取舍**:用轻量只读操作作心跳而非重连,失败 catch 返回 false;可选方法(`?`)保持对其他实现的向后兼容。
|
||||||
|
- **状态**:✅ 已落地(`transport.ts` + 各 transport 实现)
|
||||||
|
|
||||||
|
### 远程 rootPath 始终返回 /
|
||||||
|
- **决策**:远程连接的 `rootPath` 始终返回 `/`,忽略 localStorage 残留的 Windows 路径。
|
||||||
|
- **原因/取舍**:原对远程用 filePath 前 4 段算 rootPath 过滤非目录后为空,悬停子目录列表空;远程根目录就是 `/`。远程模式若沿用本地缓存路径致导航失败。
|
||||||
|
- **状态**:✅ 已落地(`ConnectionIndicator.vue`)
|
||||||
|
|
||||||
|
### 连接失败中止打开文件
|
||||||
|
- **决策**:`handleOpenFavorite` 连接失败时 `return` 中止,不用错误 transport 继续打开。
|
||||||
|
- **原因/取舍**:原失败后继续导航用错误 transport(local)加载远程路径,OSS 文件被当本地处理(路径变 `C:\Users\...`)。
|
||||||
|
- **状态**:✅ 已落地(`index.vue` handleOpenFavorite)
|
||||||
|
|
||||||
|
### loadFileContent await updatePreviewUrl
|
||||||
|
- **决策**:`loadFileContent` 中 `updatePreviewUrl` 改为 `await`。
|
||||||
|
- **原因/取舍**:OSS/SFTP 预览 URL 依赖异步下载到临时文件,不等待会竞态;本地文本不必要但无害,统一 await 简化逻辑。
|
||||||
|
- **状态**:✅ 已落地(`index.vue`)
|
||||||
|
|
||||||
|
### 字段映射 snakeToCamel
|
||||||
|
- **决策**:前端 `loadFromDB()` 用 `snakeToCamel()` 转换 Go 返回的 snake_case JSON。
|
||||||
|
- **原因/取舍**:Go JSON 用 snake_case(`access_key`),前端 TS 期望 camelCase(`accessKey`);直接 `...p` 展开不转换致 `profile.secretKey` 为 undefined,OSS 认证失败——收藏图片打不开的根因。
|
||||||
|
- **状态**:✅ 已落地(`connection-manager.ts`)
|
||||||
|
|
||||||
|
### LoadConnectionProfiles 用 json.Marshal
|
||||||
|
- **决策**:`LoadConnectionProfiles` 用 `json.Marshal/Unmarshal` 替代手动 `map[string]interface{}` 字段转换。
|
||||||
|
- **原因/取舍**:手动 map 每字段 `float64()` 断言,新增字段必漏;JSON 自动处理类型转换,Go struct 到前端 camelCase 全链路映射更可靠。
|
||||||
|
- **状态**:✅ 已落地(`app.go`)
|
||||||
|
|
||||||
|
### watchFile 仅本地生效
|
||||||
|
- **决策**:`watchFile`/`unwatchFile` 仅本地模式生效,远程连接静默跳过。
|
||||||
|
- **原因/取舍**:远程 FS 无 inotify/ReadDirectoryChangesW 机制,强行监听增加复杂度和网络开销。
|
||||||
|
- **状态**:✅ 已落地(`app.go` + `index.vue`)
|
||||||
|
|
||||||
|
### onStateChange 职责拆分(未实施)
|
||||||
|
- **决策**:`onStateChange` 回调应拆分为「只管连接状态通知,导航权归 UI 层」。
|
||||||
|
- **原因/取舍**:当前回调同时承担状态跟踪 + 自动导航,`_suppressAutoNav` 全局标志脆弱,任何异步竞争都绕过它(`updateProfile` 的 notifyChange 异步触发是典型)。长期方案:连接管理器不管导航、导航权在 UI 显式调用、updateProfile 不触发 notifyChange。当前用 `_lastActiveId` 去重 + 提前更新作临时修复。
|
||||||
|
- **状态**:📐 设计未实施(临时修复已落地)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFTP
|
||||||
|
|
||||||
|
### SSH keepalive 30s
|
||||||
|
- **决策**:为每个 SSH 连接起独立 goroutine,每 30s 发送 `keepalive@openssh.com`。
|
||||||
|
- **原因/取舍**:原无 keepalive,长时间空闲后 NAT/防火墙丢连接 SFTP 变「僵尸」;30s 防连接被杀。
|
||||||
|
- **状态**:✅ 已落地(`sftp/client.go`)
|
||||||
|
|
||||||
|
### 临时文件去掉 CreateTemp
|
||||||
|
- **决策**:去掉 `CreateTemp` + 二次 `Create`,直接拼路径。
|
||||||
|
- **原因/取舍**:原创建两次临时文件(先 CreateTemp 再 Create),多余 IO 增加失败点;直接拼路径减少一次系统调用与竞态窗口。
|
||||||
|
- **状态**:✅ 已落地(`sftp/service.go`)
|
||||||
|
|
||||||
|
### parseProcMeminfo 统一单位
|
||||||
|
- **决策**:`parseProcMeminfo` 统一先按 kB 运算、最后转 bytes。
|
||||||
|
- **原因/取舍**:原 kB/bytes 混用易中间步骤算错;统一单位运算只在出口转换减少混淆。
|
||||||
|
- **状态**:✅ 已落地(`sftp/service.go`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件预览 / 编辑
|
||||||
|
|
||||||
|
### 文件读写大小上限
|
||||||
|
- **决策**:本地读写上限 10MB,SFTP 对齐 10MB;预览模式放宽 50MB。
|
||||||
|
- **原因/取舍**:编辑模式整文件加载到内存,10MB 平衡可用性和内存安全;预览可流式/只读,50MB 覆盖绝大多数视频/图片/音频。SFTP 与本地一致避免行为不一致。
|
||||||
|
- **状态**:✅ 已落地(`filesystem/service.go`、`sftp/service.go`)
|
||||||
|
|
||||||
|
### 大文件预检 BIG_FILE
|
||||||
|
- **决策**:预览文件超 `BIG_FILE`(1MB)阈值时不读取内容,显示提示 return。
|
||||||
|
- **原因/取舍**:基于目录列表 size 预检,避免读大文件致编辑器/前端卡死;阈值常量化便于统一调整。
|
||||||
|
- **状态**:✅ 已落地(`FileEditorPanel.vue`、`constants.ts`)
|
||||||
|
|
||||||
|
### clearContent 重置 isBinaryFile
|
||||||
|
- **决策**:`clearContent()` 必须重置 `isBinaryFile = false`(之前只清 fileContent/originalContent)。
|
||||||
|
- **原因/取舍**:模板 `v-if` 链以 `isBinaryFile` 为第一分支;上一个是二进制、切换到图片/视频时 clearContent 不清该标志会永久命中二进制分支、阻断所有图片/视频预览(cascade 失效根因)。
|
||||||
|
- **状态**:✅ 已落地(`useFileEdit.ts`)
|
||||||
|
|
||||||
|
### 二进制/媒体用 isMediaPreviewable 判定
|
||||||
|
- **决策**:用 `isMediaPreviewable(filename)` 显式判断,替代 `originalContent.value === undefined`。
|
||||||
|
- **原因/取舍**:`originalContent === undefined` 在首次加载未完成时也 undefined,靠它区分「非文本」语义模糊不可靠。
|
||||||
|
- **状态**:✅ 已落地(`index.vue`)
|
||||||
|
|
||||||
|
### 非文本文件变更 _t 强制刷新
|
||||||
|
- **决策**:非文本文件(图片/视频)变更后用 URL 追加 `_t=timestamp` 强制刷新。
|
||||||
|
- **原因/取舍**:方案简洁不侵入文件服务器 URL 生成;只在真正变化时追加,首次走浏览器缓存、tab 切换从缓存恢复原 URL 不产生额外请求。
|
||||||
|
- **状态**:✅ 已落地(`index.vue` handleFileChanged)
|
||||||
|
|
||||||
|
### _t 用真实 mtime 而非 Date.now
|
||||||
|
- **决策**:`_t` 参数从 `Date.now()` 改为文件实际 mtime(`os.Stat` ModTime)。
|
||||||
|
- **原因/取舍**:同文件多次保存同内容时 `Date.now()` 让 mtime 不变但时间戳变;真实 mtime 正确反映修改时刻,且一次 os.Stat 同时完成存在性判断 + 取 mtime。事件 payload 从 string 演进为 `{path, mtime}` map,前端兼容新旧格式。
|
||||||
|
- **状态**:✅ 已落地(`filewatch/watcher.go` + `index.vue`)
|
||||||
|
|
||||||
|
### 草稿恢复脏标记(取舍未决)
|
||||||
|
- **决策**:加载草稿后不同步 originalContent,致文件打开即显示「脏」标记。
|
||||||
|
- **原因/取舍**:草稿残留(localStorage 24h)在 loadFile 后覆盖 fileContent,originalContent 已设为原始内容,二者不同触发脏标记。设计意图恢复未保存编辑(预期),但「一打开就脏」让人困惑。取舍:保草稿恢复 vs 视觉干净——暂未改。
|
||||||
|
- **状态**:❓ 未决
|
||||||
|
|
||||||
|
### 单文件 Tab 显示 + 空状态
|
||||||
|
- **决策**:预览 Tab 栏显示条件从 `length > 1` 改为 `> 0`(单文件也显示可关闭);无预览显示 logo + 「U-Desk / 文件管理器」空状态。
|
||||||
|
- **原因/取舍**:支持「关闭所有预览回到空状态」;空状态低透明度居中不抢焦点。
|
||||||
|
- **状态**:✅ 已落地(`FileEditorPanel.vue`)
|
||||||
|
|
||||||
|
### AsyncCodeEditor 重试上限 2 次
|
||||||
|
- **决策**:`defineAsyncComponent` 的 onError 限重试 2 次。
|
||||||
|
- **原因/取舍**:Vue3 加载/渲染失败后进入永久死亡状态不自动重试;无限 `retry()` 在网络持续故障时无限重试。策略演进:无限重试 → 上限 2 次。
|
||||||
|
- **状态**:✅ 已落地(`FileEditorPanel.vue`)
|
||||||
|
|
||||||
|
### BGM 播放失败跳过不删
|
||||||
|
- **决策**:播放失败自动跳下一首,不删除曲目。
|
||||||
|
- **原因/取舍**:网络文件可能临时不可用(SFTP 断连、OSS 签名过期),删曲目会永久丢失播放列表;跳过保留重试机会。
|
||||||
|
- **状态**:✅ 已落地(`BgmBar.vue`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件监听
|
||||||
|
|
||||||
|
### 300ms 防抖
|
||||||
|
- **决策**:fsnotify 文件变化事件后端 300ms 防抖后再 `EmitEvent("file-changed")`。
|
||||||
|
- **原因/取舍**:编辑器连续保存(VS Code)短时间多次触发 fsnotify;防抖合并多次事件为一次避免前端反复刷新。
|
||||||
|
- **状态**:✅ 已落地(`filewatch/watcher.go`)
|
||||||
|
|
||||||
|
### 仅监听当前预览单文件 + 脏数据保护
|
||||||
|
- **决策**:仅监听当前正在预览的单个文件;用户有未保存修改时跳过自动刷新。
|
||||||
|
- **原因/取舍**:用户选「自动刷新 + 通知」+「仅当前预览文件」(拒绝「同时监听目录」开销大);跳过脏数据刷新防丢失未保存编辑。
|
||||||
|
- **状态**:✅ 已落地(`index.vue` handleFileChanged)
|
||||||
|
|
||||||
|
### 防抖回调捕获局部 emit 变量
|
||||||
|
- **决策**:防抖回调里把 `w.emitEvent` 先捕获到局部变量 `emit` 再调用。
|
||||||
|
- **原因/取舍**:`debounceTimer` 回调在独立 goroutine 执行,直接访问 `w.emitEvent` 不持锁,与 `Close()` 并发会 nil pointer panic;捕获局部变量即无需持锁。
|
||||||
|
- **状态**:✅ 已落地(`watcher.go`)
|
||||||
|
|
||||||
|
### 监听目录而非单文件
|
||||||
|
- **决策**:监听文件所在目录而非文件本身。
|
||||||
|
- **原因/取舍**:部分系统(某些 Linux)不支持直接监听单文件;监听目录兼容性更好,配合防抖过滤目标文件。
|
||||||
|
- **状态**:✅ 已落地(`watcher.go`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自动更新
|
||||||
|
|
||||||
|
### os.Exit → Quit 释放 mutex
|
||||||
|
- **决策**:`os.Exit(0)` 改为 `application.Get().Quit()` 优雅退出。
|
||||||
|
- **原因/取舍**:Wails v3 启用 SingleInstance 单实例锁;旧流程 bat 先 start 新进程再 os.Exit,新实例启动时旧进程未完全退出,SingleInstance 检测冲突拒绝。Quit 释放 mutex,bat 轮询等旧 PID 退出后再启新实例。
|
||||||
|
- **状态**:✅ 已落地(`update.go` restartApplication + `main.go`)
|
||||||
|
|
||||||
|
### .new 延迟替换
|
||||||
|
- **决策**:运行中 exe 用 `.new` 延迟替换(安装阶段只复制到 .new,重启阶段 bat 替换)。
|
||||||
|
- **原因/取舍**:Windows 不允许覆盖正在运行的 exe;旧 `os.Rename` 失败后静默 `return nil` 实际没替换。改为安装复制 .new、重启 bat 先杀进程再 move、启动 `ReplacePendingFile()` 兜底。
|
||||||
|
- **状态**:✅ 已落地(`update.go`)
|
||||||
|
|
||||||
|
### bat 启动方式
|
||||||
|
- **决策**:bat 用 `exec.Command("cmd", "/C", batFile)` + `SysProcAttr{HideWindow: true}`,不经 `cmd /C start`。
|
||||||
|
- **原因/取舍**:`-H windowsgui` 模式下 `cmd /C start` 启动的 bat 可能被 Windows 安全策略拦截;直接 exec.Command + CREATE_NO_WINDOW 避免弹窗且不触发拦截。
|
||||||
|
- **状态**:✅ 已落地(`update.go`)
|
||||||
|
|
||||||
|
### bat 写失败降级重启
|
||||||
|
- **决策**:bat 写入失败时降级为 `os.Exit(0)` + 直接 `exec.Command("cmd", "/C", "start", "", execPath).Start()`。
|
||||||
|
- **原因/取舍**:bat 依赖临时目录可写,权限不足/磁盘满则无法完成标准流程;降级牺牲 .new 替换但保证能退出并尝试重启。
|
||||||
|
- **状态**:✅ 已落地(`update.go` fallbackRestart)
|
||||||
|
|
||||||
|
### check_url 空值才用默认
|
||||||
|
- **决策**:`check_url` 仅在为空时用默认值,不覆盖已存在的旧 URL。
|
||||||
|
- **原因/取舍**:尊重用户已配置值 vs 自动修正旧失效 URL(如 `img.1216.top`)——可能导致旧用户无法检测更新。迁移逻辑暂未加。
|
||||||
|
- **状态**:🚧 待实测(当前配置已修,迁移逻辑未加)
|
||||||
|
|
||||||
|
### 更新检查 URL 配置化
|
||||||
|
- **决策**:检查更新 URL 从硬编码改为读 `update_config.json`,空值 fallback 默认。
|
||||||
|
- **原因/取舍**:硬编码无法按部署环境切换;完全无默认值会致配置缺失时检查失效,故保留 fallback。
|
||||||
|
- **状态**:✅ 已落地(`app.go`、`update_config.go`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 代码结构 / DRY
|
||||||
|
|
||||||
|
### 三套 FileItem 类型统一
|
||||||
|
- **决策**:三套 FileItem 定义统一为 `types/file-system.ts` 的一个,删除 `api/types.ts` 的 `File` interface。
|
||||||
|
- **原因/取舍**:原 transport.ts 用 `is_dir`/`mod_time`(蛇形)、file-system.ts 用 `isDir`/`modified_time`(驼峰)、api/types.ts 用 `isDir`/`modified`(又不同),加两套 transformFile 易错;统一为驼峰,transport 层做一次蛇→驼峰转换。
|
||||||
|
- **状态**:✅ 已落地(`types/file-system.ts`、`api/transport.ts`、`api/types.ts`)
|
||||||
|
|
||||||
|
### CopyFile 失败清理 + 权限保留
|
||||||
|
- **决策**:`common.CopyFile` 加失败清理(os.Remove dst)+ 保留源文件权限(os.Chmod)。
|
||||||
|
- **原因/取舍**:从 download_cache/recycle_bin/update 三处提取共享 CopyFile 时,原各处有失败清理和权限保留,合并不能丢;recycle_bin 依赖文件权限不变。
|
||||||
|
- **状态**:✅ 已落地(`common/utils.go`)
|
||||||
|
|
||||||
|
### 全局单例死代码删除
|
||||||
|
- **决策**:删除 `globalLogger`/`globalRecycleBin`/`LogOperation`/`LogError`/`GetGlobalLogger`/`GetStackTrace` 等全局单例,而非加 sync.Once 保护。
|
||||||
|
- **原因/取舍**:这些只有声明和 getter 从未初始化或调用,属死代码;直接删比加保护更干净。
|
||||||
|
- **状态**:✅ 已落地(`logger.go`、`recycle_bin.go`)
|
||||||
|
|
||||||
|
### App 拆分粒度:11 文件不再动
|
||||||
|
- **决策**:11 个 `app_*.go` 按功能域拆分,不再进一步合并或细分。
|
||||||
|
- **原因/取舍**:命名清晰符合 Go 多文件包惯例;继续拆分或合并都增加认知负担,「保持现状」是当前最佳点。
|
||||||
|
- **状态**:✅ 已落地(决定不动)
|
||||||
|
|
||||||
|
### Windows 专用代码 build tag 隔离
|
||||||
|
- **决策**:`registry`/`w32` 等 Windows 专用包函数移到 `app_windows.go`,跨平台函数留 `app.go`。
|
||||||
|
- **原因/取舍**:Windows 专用包直接 import 致跨平台编译失败;build tag 文件拆分而非运行时 GOOS 判断,是 Go 惯例且零运行时开销。
|
||||||
|
- **状态**:✅ 已落地(`app.go`/`app_windows.go`)
|
||||||
|
|
||||||
|
### watchFile 接口统一
|
||||||
|
- **决策**:`watchFile`/`unwatchFile` 统一走 `FsTransport` 接口。
|
||||||
|
- **原因/取舍**:原各 transport 各自实现接口不统一;收敛后共用同一套签名和调用模式。
|
||||||
|
- **状态**:✅ 已落地(`transport.ts` + 4 个 transport 实现)
|
||||||
|
|
||||||
|
### 旧版遗留清理
|
||||||
|
- **决策**:删除 3 个旧版零引用 composable JS 文件(~1000 行)+ Wails v2 遗留 wailsjs 绑定。
|
||||||
|
- **原因/取舍**:旧版 JS/wailsjs 文件无 import 引用属历史遗留,留着混淆开发。
|
||||||
|
- **状态**:✅ 已落地(`composables/*.js`、`wailsjs/wailsjs/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 窗口外观
|
||||||
|
|
||||||
|
### DWMWA_COLOR_NONE 抑制白边
|
||||||
|
- **决策**:用 `DWMWA_COLOR_NONE`(0xFFFFFFFE)作四主题状态 BorderColour,移除 `DisableFramelessWindowDecorations`。
|
||||||
|
- **原因/取舍**:白边根因是 `ExtendFrameIntoClientArea` 保留 DWM 框架、暗色模式未告知 DWM 用暗色渲染;试过 `#2D2D2D`、移除 CustomTheme、`DisableFramelessWindowDecorations:true` 均无效;`DWMWA_COLOR_NONE` 是 Win11 官方抑制边框 API。Win10 不支持降级无装饰(取舍:牺牲 Win10 Aero 阴影/圆角换 Win11 干净边缘)。
|
||||||
|
- **状态**:✅ 已落地(`main.go`)
|
||||||
|
|
||||||
|
### 背景色固定 #2D2D2D
|
||||||
|
- **决策**:窗口 `BackgroundColour` 固定 `#2D2D2D`(暗色安全)。
|
||||||
|
- **原因/取舍**:此色仅 CSS 未渲染前的加载瞬间可见;暗色模式不露白边,亮色模式被前端 CSS 覆盖;不随主题切换避免 Go 端复杂度。
|
||||||
|
- **状态**:✅ 已落地(`main.go`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI 交互
|
||||||
|
|
||||||
|
### 设置面板 Teleport to body
|
||||||
|
- **决策**:弹出面板从 sidebar 内 `position: absolute` 改为 `<Teleport to="body">` + `position: fixed`。
|
||||||
|
- **原因/取舍**:旧方案被 `overflow: hidden` 容器裁切,设置按钮/更多菜单弹出被截断;Teleport 彻底解决 z-index 和 overflow:hidden 裁剪。
|
||||||
|
- **状态**:✅ 已落地(`Sidebar.vue`)
|
||||||
|
|
||||||
|
### 服务器区块 max-height: none
|
||||||
|
- **决策**:服务器区块 `max-height: none` 不限高;收藏夹区块保留 `max-height` 折叠动画。
|
||||||
|
- **原因/取舍**:`max-height: 500px` 在服务器列表 >15 行时裁剪;服务器区块在 `flex-shrink: 0` 内高度由内容决定即可。折叠动画只适合可变高度区块。
|
||||||
|
- **状态**:✅ 已落地(`Sidebar.vue`)
|
||||||
|
|
||||||
|
### 收藏夹默认折叠
|
||||||
|
- **决策**:收藏夹侧栏 `favCollapsed` 初始值改 `true`(默认收起)。
|
||||||
|
- **原因/取舍**:保持界面简洁,用户按需展开。
|
||||||
|
- **状态**:✅ 已落地
|
||||||
|
|
||||||
|
### 折叠动画 max-height:0
|
||||||
|
- **决策**:折叠动画从 `grid-template-rows: 0fr` 改为 `max-height: 0` + `overflow: hidden`。
|
||||||
|
- **原因/取舍**:`0fr` 方案折叠后仍有残留高度;帮助区块不再 `margin-top: auto`,依赖收藏区块 `flex-grow` 自然推到底。
|
||||||
|
- **状态**:✅ 已落地(`Sidebar.vue`)
|
||||||
|
|
||||||
|
### toggleSettings 用 closest
|
||||||
|
- **决策**:`toggleSettings` 用 `closest('.settings-btn')` 替代 `e.currentTarget`。
|
||||||
|
- **原因/取舍**:Vue 3 内联事件 `e.currentTarget` 有 null 风险(Vue 内部包装),原 `|| e.target` 兜底有类型安全隐患(e.target 可能是子文本节点);closest 向上查找更可靠。
|
||||||
|
- **状态**:✅ 已落地(`Sidebar.vue`)
|
||||||
|
|
||||||
|
### 面包屑下拉过滤 + 200 + 缓存
|
||||||
|
- **决策**:面包屑下拉只获取目录(跳过文件),`slice(0, 200)` 限制,`Map<path, result>` 跨导航缓存。
|
||||||
|
- **原因/取舍**:`/tmp` 等大目录返回过多卡顿;用户只能导航到目录,过滤文件减 payload;200 上限防极端,Map 缓存避免悬停同路径重复请求。
|
||||||
|
- **状态**:✅ 已落地(`PathBreadcrumb.vue`)
|
||||||
|
|
||||||
|
### 拖拽 iframe 遮罩
|
||||||
|
- **决策**:resize 拖拽时 `mousedown` 即激活全屏遮罩,并缓存容器尺寸。
|
||||||
|
- **原因/取舍**:iframe(PDF/图片预览)捕获鼠标事件致拖拽中断;每帧 `getBoundingClientRect()` 强制回流;遮罩从 rAF 回调改 mousedown 立即触发,避免鼠标未移动时遮罩不出现。
|
||||||
|
- **状态**:✅ 已落地(`resize.ts`)
|
||||||
|
|
||||||
|
### 系统信息 15s 自动刷新
|
||||||
|
- **决策**:系统信息面板 `setInterval` 每 15s 刷新。
|
||||||
|
- **原因/取舍**:固定周期足够反映服务器状态变化且不致过大开销。
|
||||||
|
- **状态**:✅ 已落地(连接管理器 refreshTimer)
|
||||||
|
|
||||||
|
### F11 预览全屏:OS 级 → 窗口内全屏 [2026-06-13]
|
||||||
|
- **决策**:文件预览区 F11 全屏从浏览器 Fullscreen API(OS 级,占整个屏幕)改为 CSS class 驱动的窗口内全屏(`position: fixed; inset: 0`),只盖 u-desk 应用窗口视口。
|
||||||
|
- **原因/取舍**:OS 全屏独占整个显示器、遮任务栏、退出后窗口尺寸/位置可能错乱,对桌面文件管理器过重;窗口内全屏只占应用视口,保留任务栏与多窗口切换,符合「应用内聚焦预览」预期。代价:失去浏览器原生 ESC 退出 → 自行补 ESC 监听退出 + F11 再按切换。实现:`isFullscreen` ref 驱动 `.is-fullscreen` class,移除 `requestFullscreen`/`exitFullscreen`/`fullscreenchange` 监听器及 `panelRef`,`:fullscreen` 选择器全部改 `.is-fullscreen`。
|
||||||
|
- **状态**:✅ 已落地(`FileEditorPanel.vue`,wails3 build 通过 2026-06-13)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快捷键 / 热键
|
||||||
|
|
||||||
|
### RegisterGlobalHotkey 单 defer Unlock
|
||||||
|
- **决策**:合并为单一 `defer mu.Unlock()`,消除锁间隙竞态。
|
||||||
|
- **原因/取舍**:原 nil 检查和赋值不在同一把锁内(Lock→检查→Unlock→注册→Lock→赋值→Unlock),两 goroutine 可同时通过 nil 检查致重复注册。
|
||||||
|
- **状态**:✅ 已落地(`app.go` RegisterGlobalHotkey)
|
||||||
|
|
||||||
|
### 盘符快捷键先回本地
|
||||||
|
- **决策**:`Ctrl+Shift+C~H` 盘符快捷键切换前先回本地 transport。
|
||||||
|
- **原因/取舍**:远程模式下按盘符快捷键会报错(C:~H: 盘符操作不适用远程 transport);先切回本地再执行盘符切换。
|
||||||
|
- **状态**:✅ 已落地(`index.vue`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DevTools
|
||||||
|
|
||||||
|
### build tag 控制
|
||||||
|
- **决策**:用 Go build tag(`!production`/`production`)控制 DevTools 开关。
|
||||||
|
- **原因/取舍**:`wails dev` 和 `wails3 build` 默认都带 devtools tag 致生产包弹 Inspector;曾考虑运行时 `Env.Info().Debug` 判断但 Run() 前不可用;build tag 编译期控制零运行时开销更可靠。
|
||||||
|
- **状态**:✅ 已落地(`devtools.go`/`devtools_prod.go`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 桌面应用体验
|
||||||
|
|
||||||
|
### UniqueID 反向域名
|
||||||
|
- **决策**:UniqueID 用 `top.1216.udesk`,不用 `com.1216.top.udesk`。
|
||||||
|
- **原因/取舍**:用户域名 `1216.top`,按反向域名规范应为 `top.1216.udesk`;`com.` 开头系统层面也能工作但用户明确要求严格反向域名规范。
|
||||||
|
- **状态**:✅ 已落地(`main.go` SingleInstance)
|
||||||
|
|
||||||
|
### 全局禁用浏览器右键菜单
|
||||||
|
- **决策**:`App.vue` onMounted 添加 `contextmenu` 事件拦截,全局禁用浏览器默认右键菜单。
|
||||||
|
- **原因/取舍**:桌面应用露浏览器默认右键菜单破坏原生体验;CSS `--default-contextmenu: hide` 在 WebView2 不一定生效,JS `preventDefault` 更可靠。
|
||||||
|
- **状态**:✅ 已落地(`App.vue`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本 / 依赖
|
||||||
|
|
||||||
|
### Wails 暂缓升级
|
||||||
|
- **决策**:暂不升级(停留 alpha.80,最新 alpha.86)。
|
||||||
|
- **原因/取舍**:升级主要带来 Windows 托盘 GDI 内存泄漏修复、WebView2 焦点空指针修复,但对本项目影响不大;而改进清单依赖的原生对话框/窗口事件 API 新版仍未提供,升级收益不足以抵消回归风险。
|
||||||
|
- **状态**:🚧 待实测(暂缓非永久否决)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档 / 流程
|
||||||
|
|
||||||
|
### CHANGELOG 用户视角
|
||||||
|
- **决策**:CHANGELOG.md 只写用户能感知的变化,不写内部实现细节。
|
||||||
|
- **原因/取舍**:CHANGELOG 面向用户非开发者;内部重构(结构化日志、并发安全、依赖升级)只作概括条目,详细技术改动放 CHANGELOG.internal.md;新功能不应归入「修复」。
|
||||||
|
- **状态**:✅ 已落地
|
||||||
|
|
||||||
|
### 调试工具验证后统一清理
|
||||||
|
- **决策**:调试日志和临时测试工具在功能验证通过后再统一清理,不随修复合并。
|
||||||
|
- **原因/取舍**:OSS 连接问题未完全验证前用户要求「先不清理」;调试日志帮助定位了 `updateProfile` 异步 notifyChange 误触发导航的 bug。最终验证后统一清理 cmd 调试工具、DebugLog 绑定、前端调试日志。
|
||||||
|
- **状态**:✅ 已落地
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已知上游依赖问题
|
||||||
|
|
||||||
|
### Chromium 149 中文 IME 标点按两次丢失 [2026-06-13]
|
||||||
|
- **决策**:确认是 Chromium 149 上游 bug,**不在应用层修**;等 Chrome/Edge 151 推送更新 WebView2 Runtime 解,或临时切 IME 英文标点绕过。
|
||||||
|
- **原因/取舍**:症状——中文输入法下编辑器(CodeMirror)直接打全角标点(`。,;:`)每隔一次丢失,需按两次。排查已排除:u-desk 全局快捷键(`index.vue` handleKeyDown / `FileEditorPanel` onKeyDown 全部需 Ctrl/Meta/Alt/Shift 或功能键,裸标点不命中不拦截)、CodeMirror 配置(6.41.1,无 closeBrigits/无标点 keymap 绑定)、`watch modelValue` 回写(字符串相等不重派发)。CodeMirror 作者 marijn 用裸 `<div contenteditable>` 也在 Chrome 149 stable 复现,提 Chromium [#521205128](https://issues.chromium.org/issues/521205128) → 纯内核 bug,非本项目代码。**应用层无法修**:丢失的那次按键在 Chromium 内部被吞,不触发任何 JS 事件(无 keydown/beforeinput/compositionend),拦不到。**不在 CodeMirror 加 inputHandler/keydown hack**:作者本人只提 issue 未给 workaround,硬 hack 引新问题。当前环境:WebView2 Runtime = Edge = 149.0.4022.62(Chromium 149,2026-06-02 stable),通道覆盖未设(stable 正常)。**根因**(2026-06-12 上游确认):Chromium 的 TSFTextStore 在 IME 活动时仍跑 autocorrect 检测,吞掉标点;修复 CL [7917332](https://chromium-review.googlesource.com/c/chromium/src/+/7917332)(Pranav Modi/MS)标题 "Skip autocorrect detection when IME is active in TSFTextStore"。
|
||||||
|
- **状态**:🚧 上游已修复待发布(2026-06-12 在 Chrome 151.0.7888.0 / Win11 验证有效,已加 verified 标签;接受现状不应用层修)
|
||||||
|
- **📋 待办**:修复落 **Chrome 151**(非 150)。等 Edge/WebView2 Runtime 升到 151 stable(Chrome 150 stable ~2026-06-29,151 stable 约 2026-07 下旬;急用可装 Edge Beta 151 提前验)→ 更新后回归验证编辑器中文标点。参考 [CodeMirror 论坛 #9741](https://discuss.codemirror.net/t/chinese-ime-punctuation-input-loses-every-other-keypress-requires-2-presses-per-character/9741)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 维护说明
|
||||||
|
|
||||||
|
本文档为初始整理,覆盖 7 个历史会话 + 当前会话,共 ~90 条去重决策。
|
||||||
|
|
||||||
|
**压缩信号**:当前 ~19 功能域、超 300 行。后续触发以下任一条件时压缩:
|
||||||
|
- 老 Sprint 决策超 3 个 → 归档到 `功能决策记录-归档.md`
|
||||||
|
- 某功能域 > 10 条 → 单独拆文件
|
||||||
|
- 已稳定决策合并精简,只留结论 + 关键原因
|
||||||
|
|
||||||
|
**更新优先于新增**:决策点已存在就改原条,演进用 `→` 串联,不留重复版本。
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# 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/ 下,如不再使用应清理
|
|
||||||
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 594 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 677 KiB |
|
Before Width: | Height: | Size: 493 KiB |
@@ -15,9 +15,43 @@
|
|||||||
|
|
||||||
## 待处理
|
## 待处理
|
||||||
|
|
||||||
- [ ] ④ App 结构体拆分 — 1344 行,应拆为 FilesystemService / ProfileService / BgmService / UpdateService 等 v3 Service
|
- [x] ④ App 结构体拆分 — 1281行 app.go 拆为 9 个域文件:app_update/filesystem/sftp/oss/window/system/config/recycle/pdf + app_windows.go
|
||||||
- [ ] ⑦ `internal/api/pdf_api.go:371` SelectDirectory 改用 Wails 原生对话框 `application.Get().Dialog.OpenFile()`
|
- [ ] ⑦ `internal/api/pdf_api.go:371` SelectDirectory 改用 Wails 原生对话框 `application.Get().Dialog.OpenFile()`
|
||||||
- [ ] ⑧ `app.go:176-189` HWND 轮询改事件驱动 — v3 alpha 暂无对应 API,后续跟进
|
- [ ] ⑧ `app.go:176-189` HWND 轮询改事件驱动 — v3 alpha 暂无对应 API,后续跟进
|
||||||
- [ ] ⑩ `app.go:29` Windows 专用导入 `golang.org/x/sys/windows/registry` 加构建标签拆到 `*_windows.go`
|
- [x] ⑩ `app.go:29` Windows 专用导入拆到 `app_windows.go`(registry/w32 包隔离)
|
||||||
- [ ] ⑪ 全局结构化日志 — `fmt.Println` 替换为 `log/slog`,按优先级分批替换
|
- [x] ⑪ 全局结构化日志 — `fmt.Println/Printf` 全部替换为 `log/slog`(app.go + internal/ 共 45 处)
|
||||||
- [ ] ⑬ `app.go:158` 更新检查 URL `https://c.1216.top/last-version.json` 移入配置
|
- [x] ⑬ `app.go` 更新检查 URL 改从 `update_config.json` 动态读取,不再硬编码
|
||||||
|
|
||||||
|
## 代码审查修复 (2026-05-16)
|
||||||
|
|
||||||
|
> 基于 App 拆分后全量审查,17 项问题已修复 15 项
|
||||||
|
|
||||||
|
### 已修复
|
||||||
|
|
||||||
|
- [x] 🔴① app_windows.go 添加 `//go:build windows` 构建标签
|
||||||
|
- [x] 🔴② app_windows.go 移除 `fallbackName` 死参数
|
||||||
|
- [x] 🔴③ initModulesByConfig 复用 initFilesystemModule,消除重复初始化逻辑
|
||||||
|
- [x] 🔴④ WindowToggleAlwaysOnTop 的 isAlwaysOnTop 修改加 mu 锁保护
|
||||||
|
- [x] 🟡⑤ GetEnvVars 从 app_filesystem.go 移至 app_system.go(归属修正)
|
||||||
|
- [x] 🟡⑥ GetAuditLogs 从 app_config.go 移至 app_filesystem.go(归属修正)
|
||||||
|
- [x] 🟡⑦ BgmGetPlaylist 处理 db.Find 错误
|
||||||
|
- [x] 🟡⑧ LoadConnectionProfiles 处理 json.Unmarshal 错误
|
||||||
|
- [x] 🟡⑨ startAutoUpdateCheck 改用 requireUpdateAPI 统一加锁
|
||||||
|
- [x] 🟡⑩ startAutoUpdateCheck 配置读取失败添加 slog.Warn 日志
|
||||||
|
- [x] 🟡⑪ MigrateTabConfig 错误从 `_ =` 改为 slog.Warn
|
||||||
|
- [x] 🟡⑫ app_recycle.go 移除空 import 块
|
||||||
|
- [x] ⚪⑭ OssConnect 补充 godoc 注释
|
||||||
|
- [x] ⚪⑮ EmitEvent 闭包提取为 app.emitEvent 方法,消除重复
|
||||||
|
|
||||||
|
## Bug 修复 (2026-05-16)
|
||||||
|
|
||||||
|
- [x] 🔴 预览状态级联失败: `clearContent()` 未重置 `isBinaryFile`,打开二进制文件后所有图片/视频预览失效
|
||||||
|
- [x] 🔴 Tab 切换状态不同步: 跨服务器 Tab 切换时面包屑和文件列表未更新
|
||||||
|
- [x] 🟡 AsyncCodeEditor 永久死亡: `defineAsyncComponent` 添加 `onError` 重试(限 2 次)
|
||||||
|
- [x] 🟡 loadDirectory 静默吞异常: 切换 Tab 加载目录失败时添加用户提示
|
||||||
|
|
||||||
|
### 暂不处理
|
||||||
|
|
||||||
|
- [ ] ⚪⑬ fallback 路径在非英文 Windows 上不匹配 — 边缘场景,影响有限
|
||||||
|
- [ ] ⚪⑯ SftpDisconnect 未走 ensureSftpService — 功能正确,风格统一待后续
|
||||||
|
- [ ] ⚪⑰ ensure 模式 5 种写法统一 — 大规模重构,独立迭代
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ export function ClearCache(): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(1413834504);
|
return $Call.ByID(1413834504);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度
|
||||||
|
*/
|
||||||
|
export function CopyPath(src: string, dst: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(1297703531, src, dst);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CreateDir 创建目录
|
* CreateDir 创建目录
|
||||||
*/
|
*/
|
||||||
@@ -116,6 +123,16 @@ export function EmptyRecycleBin(): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(4176312624);
|
return $Call.ByID(4176312624);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EnsureDrawioViewer 确保本地已缓存 drawio viewer(首次预览时下载),前端打开 .drawio 前调用。
|
||||||
|
* 通过 drawio-download-progress 事件推送下载进度。
|
||||||
|
*/
|
||||||
|
export function EnsureDrawioViewer(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||||
|
return $Call.ByID(1345697171).then(($result: any) => {
|
||||||
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExportPDF 导出PDF文件
|
* ExportPDF 导出PDF文件
|
||||||
*/
|
*/
|
||||||
@@ -318,6 +335,13 @@ export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
|
||||||
|
*/
|
||||||
|
export function MovePath(src: string, dst: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(2028899675, src, dst);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenPath 使用系统默认程序打开文件或目录
|
* OpenPath 使用系统默认程序打开文件或目录
|
||||||
*/
|
*/
|
||||||
@@ -325,6 +349,9 @@ export function OpenPath(path: string): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(1591734570, path);
|
return $Call.ByID(1591734570, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OssConnect 连接 OSS 存储
|
||||||
|
*/
|
||||||
export function OssConnect(req: $models.OssConnectRequest): $CancellablePromise<string> {
|
export function OssConnect(req: $models.OssConnectRequest): $CancellablePromise<string> {
|
||||||
return $Call.ByID(3667022538, req);
|
return $Call.ByID(3667022538, req);
|
||||||
}
|
}
|
||||||
@@ -448,6 +475,13 @@ export function OssWriteFile(connID: string, key: string, content: string): $Can
|
|||||||
return $Call.ByID(39773277, connID, key, content);
|
return $Call.ByID(39773277, connID, key, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PickDirectory 打开系统目录选择器,返回选中目录(取消时返回空串)
|
||||||
|
*/
|
||||||
|
export function PickDirectory(): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(1347829059);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReadFile 读取文件
|
* ReadFile 读取文件
|
||||||
*/
|
*/
|
||||||
@@ -594,6 +628,13 @@ export function SftpDownloadSiteForPreview(connID: string, remotePath: string):
|
|||||||
return $Call.ByID(1591575570, connID, remotePath);
|
return $Call.ByID(1591575570, connID, remotePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpDownloadToFile 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径
|
||||||
|
*/
|
||||||
|
export function SftpDownloadToFile(connID: string, remotePath: string, localDstDir: string): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(2935422351, connID, remotePath, localDstDir);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
* SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
||||||
*/
|
*/
|
||||||
@@ -660,6 +701,13 @@ export function SftpRenamePath(req: $models.SftpRenamePathRequest): $Cancellable
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpUploadFromFile 上传本地文件/目录到远程目录(流式,目录递归,重名自动副本)
|
||||||
|
*/
|
||||||
|
export function SftpUploadFromFile(connID: string, localPath: string, remoteDir: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(3304594335, connID, localPath, remoteDir);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等)
|
* SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -459,361 +459,130 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.27.7",
|
"version": "0.27.7",
|
||||||
@@ -1386,102 +1155,46 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.60.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
|
||||||
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
|
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.60.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
|
||||||
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
|
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.60.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
|
||||||
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
|
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.60.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
|
||||||
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
|
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.60.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
|
||||||
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
|
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.60.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
|
||||||
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
|
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.60.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
|
||||||
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
|
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"optional": true
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.60.2",
|
"version": "4.60.2",
|
||||||
@@ -3009,19 +2722,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"optional": true
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export type ConnectionType = 'local' | 'remote' | 'sftp' | 'oss'
|
|||||||
export type OssProvider = 'qiniu' | 'aliyun'
|
export type OssProvider = 'qiniu' | 'aliyun'
|
||||||
|
|
||||||
export interface ConnectionProfile {
|
export interface ConnectionProfile {
|
||||||
id: string | number
|
/** 统一为字符串: 本地固定 'local-default',其余为数据库自增 id 的十进制字符串 */
|
||||||
|
id: string
|
||||||
name: string
|
name: string
|
||||||
host: string
|
host: string
|
||||||
port: number
|
port: number
|
||||||
@@ -58,6 +59,8 @@ class ConnectionManagerImpl {
|
|||||||
private _activeId: string | null = null
|
private _activeId: string | null = null
|
||||||
private _state: ConnectionState = 'disconnected'
|
private _state: ConnectionState = 'disconnected'
|
||||||
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
|
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
|
||||||
|
/** 每个 profile 的独立连接状态(连接菜单圆点用) */
|
||||||
|
private _profileStates = new Map<string, { state: ConnectionState; error?: string }>()
|
||||||
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
|
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
|
||||||
private _sysInfoCache = new Map<string, SystemInfo>()
|
private _sysInfoCache = new Map<string, SystemInfo>()
|
||||||
|
|
||||||
@@ -91,8 +94,8 @@ class ConnectionManagerImpl {
|
|||||||
try {
|
try {
|
||||||
const list = await LoadConnectionProfiles()
|
const list = await LoadConnectionProfiles()
|
||||||
if (list && list.length > 0) {
|
if (list && list.length > 0) {
|
||||||
this._profiles = list.map((p: any) => {
|
this._profiles = list.map((p: any): ConnectionProfile => {
|
||||||
const camel = snakeToCamel(p)
|
const camel = snakeToCamel(p) as ConnectionProfile
|
||||||
return {
|
return {
|
||||||
...camel,
|
...camel,
|
||||||
id: String(p.id),
|
id: String(p.id),
|
||||||
@@ -117,18 +120,18 @@ class ConnectionManagerImpl {
|
|||||||
if (autoConnect !== 'false') {
|
if (autoConnect !== 'false') {
|
||||||
for (const p of this._profiles) {
|
for (const p of this._profiles) {
|
||||||
if (p.type !== 'local') {
|
if (p.type !== 'local') {
|
||||||
this.buildAndPool(String(p.id), p).catch(() => {})
|
this.buildAndPool(String(p.id), p).catch(e => console.warn('[连接管理器] 自动连接失败:', p.name, e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存/更新单个 profile 到 SQLite */
|
/** 保存/更新单个 profile 到 SQLite,返回数据库真实 id(新建时) */
|
||||||
private async persistProfile(profile: ConnectionProfile) {
|
private async persistProfile(profile: ConnectionProfile): Promise<number | undefined> {
|
||||||
if (profile.type === 'local') return
|
if (profile.type === 'local') return undefined
|
||||||
const id = profile.id !== 'local-default' ? Number(profile.id) : 0
|
const id = profile.id !== 'local-default' ? Number(profile.id) : 0
|
||||||
await SaveConnectionProfile({
|
const result = await SaveConnectionProfile({
|
||||||
id: id > 0 ? id : undefined,
|
id: id > 0 ? id : undefined,
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
host: profile.host,
|
host: profile.host,
|
||||||
@@ -146,6 +149,8 @@ class ConnectionManagerImpl {
|
|||||||
endpoint: profile.endpoint || '',
|
endpoint: profile.endpoint || '',
|
||||||
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
|
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
|
||||||
})
|
})
|
||||||
|
const realId = Number((result as any)?.id ?? 0)
|
||||||
|
return realId > 0 ? realId : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 SQLite 删除 profile */
|
/** 从 SQLite 删除 profile */
|
||||||
@@ -159,6 +164,17 @@ class ConnectionManagerImpl {
|
|||||||
this.notifyChange()
|
this.notifyChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新单个 profile 的连接状态(对齐 Rust 版,供连接菜单圆点着色) */
|
||||||
|
private setProfileState(profileId: string, state: ConnectionState, error?: string) {
|
||||||
|
this._profileStates.set(profileId, { state, error })
|
||||||
|
this.notifyChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
getProfileState(profileId: string): { state: ConnectionState; error?: string } {
|
||||||
|
if (profileId === 'local-default') return { state: 'connected' }
|
||||||
|
return this._profileStates.get(profileId) ?? { state: 'disconnected' }
|
||||||
|
}
|
||||||
|
|
||||||
private notifyChange() {
|
private notifyChange() {
|
||||||
this._stateChangeCallbacks.forEach(cb => cb(this._state))
|
this._stateChangeCallbacks.forEach(cb => cb(this._state))
|
||||||
}
|
}
|
||||||
@@ -252,7 +268,7 @@ class ConnectionManagerImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { info._error = true; info._errorMsg = e instanceof Error ? e.message : String(e) }
|
} catch (e) { info._error = true; info._errorMsg = errMsg(e) }
|
||||||
|
|
||||||
this._sysInfoCache.set(profileId, info)
|
this._sysInfoCache.set(profileId, info)
|
||||||
this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info))
|
this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info))
|
||||||
@@ -268,12 +284,33 @@ class ConnectionManagerImpl {
|
|||||||
const profile = this._profiles.find(p => p.id === profileId)
|
const profile = this._profiles.find(p => p.id === profileId)
|
||||||
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
|
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
|
||||||
|
|
||||||
// 池中已有,直接复用
|
this.setProfileState(profileId, 'connecting')
|
||||||
|
|
||||||
|
// 池中已有,探测有效性(探测加超时:半开死连接上的网络探测会无限挂起,超时按死连接处理走重建)
|
||||||
if (this._pool.has(profileId)) {
|
if (this._pool.has(profileId)) {
|
||||||
|
const t = this._pool.get(profileId)!
|
||||||
|
if (t.isAlive) {
|
||||||
|
let probeTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const alive = await Promise.race([
|
||||||
|
t.isAlive().catch(() => false),
|
||||||
|
new Promise<false>(resolve => { probeTimer = setTimeout(() => resolve(false), 2500) }),
|
||||||
|
])
|
||||||
|
if (probeTimer) clearTimeout(probeTimer)
|
||||||
|
if (alive) {
|
||||||
this._activeId = profileId
|
this._activeId = profileId
|
||||||
|
this.setProfileState(profileId, 'connected')
|
||||||
this.setState('connected')
|
this.setState('connected')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this._activeId = profileId
|
||||||
|
this.setProfileState(profileId, 'connected')
|
||||||
|
this.setState('connected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 连接已死,清理后重建
|
||||||
|
this.disconnectProfile(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
// 先设 activeId(确保回调读到正确的 activeProfile),失败时回退
|
// 先设 activeId(确保回调读到正确的 activeProfile),失败时回退
|
||||||
const prevActiveId = this._activeId
|
const prevActiveId = this._activeId
|
||||||
@@ -281,7 +318,9 @@ class ConnectionManagerImpl {
|
|||||||
try {
|
try {
|
||||||
await this.buildAndPool(profileId, profile)
|
await this.buildAndPool(profileId, profile)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._activeId = prevActiveId
|
// 回退目标可能已不在池中(死连接重建时 disconnectProfile 先移除),指向池外会让 getTransport 静默回落本地造成 UI 幻影
|
||||||
|
this._activeId = (prevActiveId && this._pool.has(prevActiveId)) ? prevActiveId : 'local-default'
|
||||||
|
this.setProfileState(profileId, 'error', err instanceof Error ? err.message : String(err))
|
||||||
this.notifyChange()
|
this.notifyChange()
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -294,6 +333,7 @@ class ConnectionManagerImpl {
|
|||||||
if (t instanceof SftpTransport) { t.disconnect() }
|
if (t instanceof SftpTransport) { t.disconnect() }
|
||||||
if (t instanceof OssTransport) { t.disconnect() }
|
if (t instanceof OssTransport) { t.disconnect() }
|
||||||
this._pool.delete(profileId)
|
this._pool.delete(profileId)
|
||||||
|
this.setProfileState(profileId, 'disconnected')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 断开所有远程连接(保留 local) */
|
/** 断开所有远程连接(保留 local) */
|
||||||
@@ -313,10 +353,26 @@ class ConnectionManagerImpl {
|
|||||||
this.disconnectAll()
|
this.disconnectAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 查找同名且同地址(OSS 比对 bucket)的既有条目,local 不参与查重;查重谓词唯一实现,供对话框预判与 addProfile 共用避免两处漂移 */
|
||||||
|
findDuplicateProfile(profile: Pick<ConnectionProfile, 'type' | 'name' | 'host' | 'port' | 'bucket'>): ConnectionProfile | undefined {
|
||||||
|
return this._profiles.find(p =>
|
||||||
|
p.type !== 'local' && p.name === profile.name &&
|
||||||
|
(profile.type === 'oss' ? p.bucket === profile.bucket : p.host === profile.host && p.port === profile.port)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async addProfile(profile: Omit<ConnectionProfile, 'id'>): Promise<ConnectionProfile> {
|
async addProfile(profile: Omit<ConnectionProfile, 'id'>): Promise<ConnectionProfile> {
|
||||||
|
// 防重复: 同名且同地址的配置已存在时直接返回既有条目(表单双击/重试等场景)
|
||||||
|
const dup = this.findDuplicateProfile(profile)
|
||||||
|
if (dup) return dup
|
||||||
const newProfile: ConnectionProfile = { ...profile, id: crypto.randomUUID() }
|
const newProfile: ConnectionProfile = { ...profile, id: crypto.randomUUID() }
|
||||||
this._profiles.push(newProfile)
|
this._profiles.push(newProfile)
|
||||||
await this.persistProfile(newProfile)
|
// 持久化后回填数据库真实 id(自增主键),否则后续 updateProfile
|
||||||
|
// 会因 UUID 无法转数字而每次都 INSERT 新行 → 列表出现重复服务器
|
||||||
|
const realId = await this.persistProfile(newProfile)
|
||||||
|
if (realId !== undefined) {
|
||||||
|
newProfile.id = String(realId)
|
||||||
|
}
|
||||||
this.notifyChange()
|
this.notifyChange()
|
||||||
return newProfile
|
return newProfile
|
||||||
}
|
}
|
||||||
@@ -354,25 +410,30 @@ class ConnectionManagerImpl {
|
|||||||
private async buildAndPool(profileId: string, profile: ConnectionProfile): Promise<void> {
|
private async buildAndPool(profileId: string, profile: ConnectionProfile): Promise<void> {
|
||||||
if (profile.type === 'local') {
|
if (profile.type === 'local') {
|
||||||
this._pool.set(profileId, new WailsTransport())
|
this._pool.set(profileId, new WailsTransport())
|
||||||
|
this.setProfileState(profileId, 'connected')
|
||||||
this.setState('connected')
|
this.setState('connected')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profile.type === 'sftp') {
|
if (profile.type === 'sftp') {
|
||||||
if (!profile.password && !profile.keyPath) {
|
if (!profile.password && !profile.keyPath) {
|
||||||
|
this.setProfileState(profileId, 'error', '未配置密码或私钥')
|
||||||
this.setState('error')
|
this.setState('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
this.setProfileState(profileId, 'connecting')
|
||||||
this.setState('connecting')
|
this.setState('connecting')
|
||||||
try {
|
try {
|
||||||
const t = new SftpTransport(profile)
|
const t = new SftpTransport(profile)
|
||||||
await t.connect()
|
await t.connect()
|
||||||
this._pool.set(profileId, t)
|
this._pool.set(profileId, t)
|
||||||
|
this.setProfileState(profileId, 'connected')
|
||||||
this.setState('connected')
|
this.setState('connected')
|
||||||
this.updateProfile(profileId, { lastConnected: Date.now() })
|
this.updateProfile(profileId, { lastConnected: Date.now() })
|
||||||
this.fetchSystemInfo(profileId).catch(() => {})
|
this.fetchSystemInfo(profileId).catch(() => {})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._pool.delete(profileId)
|
this._pool.delete(profileId)
|
||||||
|
this.setProfileState(profileId, 'error', errMsg(err))
|
||||||
this.setState('error')
|
this.setState('error')
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -381,15 +442,18 @@ class ConnectionManagerImpl {
|
|||||||
|
|
||||||
// OSS
|
// OSS
|
||||||
if (profile.type === 'oss') {
|
if (profile.type === 'oss') {
|
||||||
|
this.setProfileState(profileId, 'connecting')
|
||||||
this.setState('connecting')
|
this.setState('connecting')
|
||||||
try {
|
try {
|
||||||
const t = new OssTransport(profile)
|
const t = new OssTransport(profile)
|
||||||
await t.connect()
|
await t.connect()
|
||||||
this._pool.set(profileId, t)
|
this._pool.set(profileId, t)
|
||||||
|
this.setProfileState(profileId, 'connected')
|
||||||
this.setState('connected')
|
this.setState('connected')
|
||||||
this.updateProfile(profileId, { lastConnected: Date.now() })
|
this.updateProfile(profileId, { lastConnected: Date.now() })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._pool.delete(profileId)
|
this._pool.delete(profileId)
|
||||||
|
this.setProfileState(profileId, 'error', errMsg(err))
|
||||||
this.setState('error')
|
this.setState('error')
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -397,22 +461,26 @@ class ConnectionManagerImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// remote / http agent
|
// remote / http agent
|
||||||
|
this.setProfileState(profileId, 'connecting')
|
||||||
this.setState('connecting')
|
this.setState('connecting')
|
||||||
try {
|
try {
|
||||||
const t = new HttpTransport(profile.host, profile.port, profile.token)
|
const t = new HttpTransport(profile.host, profile.port, profile.token)
|
||||||
this._pool.set(profileId, t)
|
this._pool.set(profileId, t)
|
||||||
// 验证连接可用性
|
// 验证连接可用性
|
||||||
t.getFileInfo('/').then(() => {
|
t.getFileInfo('/').then(() => {
|
||||||
|
this.setProfileState(profileId, 'connected')
|
||||||
if (this._activeId !== profileId) return
|
if (this._activeId !== profileId) return
|
||||||
this.setState('connected')
|
this.setState('connected')
|
||||||
this.updateProfile(profileId, { lastConnected: Date.now() })
|
this.updateProfile(profileId, { lastConnected: Date.now() })
|
||||||
this.fetchSystemInfo(profileId).catch(() => {})
|
this.fetchSystemInfo(profileId).catch(() => {})
|
||||||
}).catch(() => {
|
}).catch((e) => {
|
||||||
|
this.setProfileState(profileId, 'error', errMsg(e))
|
||||||
if (this._activeId !== profileId) return
|
if (this._activeId !== profileId) return
|
||||||
this.setState('error')
|
this.setState('error')
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._pool.delete(profileId)
|
this._pool.delete(profileId)
|
||||||
|
this.setProfileState(profileId, 'error', errMsg(err))
|
||||||
this.setState('error')
|
this.setState('error')
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -421,6 +489,11 @@ class ConnectionManagerImpl {
|
|||||||
|
|
||||||
export const connectionManager = new ConnectionManagerImpl()
|
export const connectionManager = new ConnectionManagerImpl()
|
||||||
|
|
||||||
|
/** 任意抛出值转错误文案 */
|
||||||
|
function errMsg(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e)
|
||||||
|
}
|
||||||
|
|
||||||
/** snake_case → camelCase */
|
/** snake_case → camelCase */
|
||||||
function snakeToCamel(obj: Record<string, any>): Record<string, any> {
|
function snakeToCamel(obj: Record<string, any>): Record<string, any> {
|
||||||
const result: Record<string, any> = {}
|
const result: Record<string, any> = {}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ export class HttpTransport implements FsTransport {
|
|||||||
private baseUrl: string
|
private baseUrl: string
|
||||||
private token: string
|
private token: string
|
||||||
|
|
||||||
constructor(host: string, port: number, token: string) {
|
constructor(host: string, port: number | undefined, token: string) {
|
||||||
const scheme = port === 443 ? 'https' : 'http'
|
const scheme = port === 443 ? 'https' : 'http'
|
||||||
this.baseUrl = `${scheme}://${host}${port === 80 || port === 443 ? '' : ':' + port}`
|
const portSuffix = (!port || port === 80 || port === 443) ? '' : `:${port}`
|
||||||
|
this.baseUrl = `${scheme}://${host}${portSuffix}`
|
||||||
this.token = token
|
this.token = token
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +83,32 @@ export class HttpTransport implements FsTransport {
|
|||||||
return this.request<FileOperationResult>('PATCH', '/api/v1/fs/rename', { path: oldPath }, { new_path: newPath })
|
return this.request<FileOperationResult>('PATCH', '/api/v1/fs/rename', { path: oldPath }, { new_path: newPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== 复制/移动/传输(不支持)======
|
||||||
|
|
||||||
|
async copyPath(_src: string, _dst: string): Promise<void> {
|
||||||
|
throw new Error('远程模式暂不支持复制/粘贴')
|
||||||
|
}
|
||||||
|
|
||||||
|
async movePath(_src: string, _dst: string): Promise<void> {
|
||||||
|
throw new Error('远程模式暂不支持剪切/粘贴')
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadToFile(_path: string, _localDstDir: string): Promise<string> {
|
||||||
|
throw new Error('远程模式暂不支持下载到本机')
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFromFile(_localPath: string, _remoteDir: string): Promise<void> {
|
||||||
|
throw new Error('远程模式暂不支持上传')
|
||||||
|
}
|
||||||
|
|
||||||
async listZipContents(zipPath: string): Promise<FileItem[]> {
|
async listZipContents(zipPath: string): Promise<FileItem[]> {
|
||||||
// Wave 3 实现
|
// Wave 3 实现
|
||||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async watchFile(_path: string): Promise<void> { /* 远程 HTTP 模式不支持文件监听 */ }
|
||||||
|
async unwatchFile(): Promise<void> { /* 远程 HTTP 模式不支持文件监听 */ }
|
||||||
|
|
||||||
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
|
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
|
||||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,4 @@
|
|||||||
* API 统一导出
|
* API 统一导出
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './types'
|
|
||||||
export * from './system'
|
export * from './system'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import type {
|
import type {
|
||||||
FsTransport, FileItem, FileOperationResult, DetectTypeResult,
|
FsTransport, FileItem, FileOperationResult, DetectTypeResult,
|
||||||
} from './transport'
|
} from './transport'
|
||||||
|
import { transformFileList, joinPath, probeAlive } from './transport'
|
||||||
import type { ConnectionProfile } from './connection-manager'
|
import type { ConnectionProfile } from './connection-manager'
|
||||||
import {
|
import {
|
||||||
OssConnect, OssDisconnect, OssListDir, OssReadFile,
|
OssConnect, OssDisconnect, OssListDir, OssReadFile,
|
||||||
@@ -13,14 +14,6 @@ import {
|
|||||||
OssGetCommonPaths, OssWriteBase64File, OssGetSignedURL,
|
OssGetCommonPaths, OssWriteBase64File, OssGetSignedURL,
|
||||||
} from '@bindings/u-desk/app'
|
} from '@bindings/u-desk/app'
|
||||||
|
|
||||||
function transformFile(file: any): FileItem {
|
|
||||||
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformFileList(files: any[]): FileItem[] {
|
|
||||||
return files.map(transformFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OssTransport implements FsTransport {
|
export class OssTransport implements FsTransport {
|
||||||
private profile: ConnectionProfile
|
private profile: ConnectionProfile
|
||||||
private connID: string | null = null
|
private connID: string | null = null
|
||||||
@@ -40,6 +33,10 @@ export class OssTransport implements FsTransport {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isAlive(): Promise<boolean> {
|
||||||
|
return probeAlive(this.connID, id => OssListDir(id, ''))
|
||||||
|
}
|
||||||
|
|
||||||
get sessionId(): string | null { return this.connID }
|
get sessionId(): string | null { return this.connID }
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
@@ -84,13 +81,11 @@ export class OssTransport implements FsTransport {
|
|||||||
// ====== 文件操作 ======
|
// ====== 文件操作 ======
|
||||||
|
|
||||||
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||||
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
return OssCreateFile(this.requireConn(), joinPath(dirPath, filename))
|
||||||
return OssCreateFile(this.requireConn(), fullPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||||
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
return OssCreateDir(this.requireConn(), joinPath(parentPath, dirname))
|
||||||
return OssCreateDir(this.requireConn(), fullPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deletePath(path: string): Promise<FileOperationResult> {
|
async deletePath(path: string): Promise<FileOperationResult> {
|
||||||
@@ -105,6 +100,24 @@ export class OssTransport implements FsTransport {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== 复制/移动/传输(不支持)======
|
||||||
|
|
||||||
|
async copyPath(_src: string, _dst: string): Promise<void> {
|
||||||
|
throw new Error('OSS 面板暂不支持复制/粘贴')
|
||||||
|
}
|
||||||
|
|
||||||
|
async movePath(_src: string, _dst: string): Promise<void> {
|
||||||
|
throw new Error('OSS 面板暂不支持剪切/粘贴')
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadToFile(_path: string, _localDstDir: string): Promise<string> {
|
||||||
|
throw new Error('OSS 下载到本机暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFromFile(_localPath: string, _remoteDir: string): Promise<void> {
|
||||||
|
throw new Error('OSS 上传暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
// ====== ZIP 操作(不支持)======
|
// ====== ZIP 操作(不支持)======
|
||||||
|
|
||||||
async listZipContents(_zipPath: string): Promise<FileItem[]> {
|
async listZipContents(_zipPath: string): Promise<FileItem[]> {
|
||||||
@@ -177,4 +190,7 @@ export class OssTransport implements FsTransport {
|
|||||||
async getSignedUrl(key: string): Promise<string> {
|
async getSignedUrl(key: string): Promise<string> {
|
||||||
return OssGetSignedURL(this.requireConn(), key)
|
return OssGetSignedURL(this.requireConn(), key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async watchFile(_path: string): Promise<void> { /* OSS 模式不支持文件监听 */ }
|
||||||
|
async unwatchFile(): Promise<void> { /* OSS 模式不支持文件监听 */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,16 @@
|
|||||||
import type {
|
import type {
|
||||||
FsTransport, FileItem, FileOperationResult, DetectTypeResult,
|
FsTransport, FileItem, FileOperationResult, DetectTypeResult,
|
||||||
} from './transport'
|
} from './transport'
|
||||||
|
import { transformFileList, joinPath, probeAlive } from './transport'
|
||||||
import type { ConnectionProfile } from './connection-manager'
|
import type { ConnectionProfile } from './connection-manager'
|
||||||
import {
|
import {
|
||||||
SftpConnect, SftpDisconnect, SftpListDir, SftpReadFile,
|
SftpConnect, SftpDisconnect, SftpListDir, SftpReadFile,
|
||||||
SftpWriteFile, SftpGetFileInfo, SftpCreateDir, SftpCreateFile,
|
SftpWriteFile, SftpGetFileInfo, SftpCreateDir, SftpCreateFile,
|
||||||
SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, SftpDownloadSiteForPreview,
|
SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, SftpDownloadSiteForPreview,
|
||||||
|
SftpDownloadToFile, SftpUploadFromFile,
|
||||||
SftpGetCommonPaths, SftpWriteBase64File,
|
SftpGetCommonPaths, SftpWriteBase64File,
|
||||||
} from '@bindings/u-desk/app'
|
} from '@bindings/u-desk/app'
|
||||||
|
|
||||||
function transformFile(file: any): FileItem {
|
|
||||||
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformFileList(files: any[]): FileItem[] {
|
|
||||||
return files.map(transformFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SftpTransport implements FsTransport {
|
export class SftpTransport implements FsTransport {
|
||||||
private profile: ConnectionProfile
|
private profile: ConnectionProfile
|
||||||
private connID: string | null = null
|
private connID: string | null = null
|
||||||
@@ -42,6 +36,10 @@ export class SftpTransport implements FsTransport {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isAlive(): Promise<boolean> {
|
||||||
|
return probeAlive(this.connID, id => SftpGetFileInfo(id, '/'))
|
||||||
|
}
|
||||||
|
|
||||||
get sessionId(): string | null { return this.connID }
|
get sessionId(): string | null { return this.connID }
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
@@ -90,13 +88,11 @@ export class SftpTransport implements FsTransport {
|
|||||||
// ====== 文件操作 ======
|
// ====== 文件操作 ======
|
||||||
|
|
||||||
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||||
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
return SftpCreateFile(this.requireConn(), joinPath(dirPath, filename))
|
||||||
return SftpCreateFile(this.requireConn(), fullPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||||
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
return SftpCreateDir(this.requireConn(), joinPath(parentPath, dirname))
|
||||||
return SftpCreateDir(this.requireConn(), fullPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deletePath(path: string): Promise<FileOperationResult> {
|
async deletePath(path: string): Promise<FileOperationResult> {
|
||||||
@@ -111,6 +107,28 @@ export class SftpTransport implements FsTransport {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== 复制/移动(粘贴仅本地面板支持)======
|
||||||
|
|
||||||
|
async copyPath(_src: string, _dst: string): Promise<void> {
|
||||||
|
throw new Error('SFTP 面板暂不支持粘贴,可使用拖拽上传')
|
||||||
|
}
|
||||||
|
|
||||||
|
async movePath(_src: string, _dst: string): Promise<void> {
|
||||||
|
throw new Error('SFTP 面板暂不支持粘贴,可使用拖拽上传')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 传输 ======
|
||||||
|
|
||||||
|
/** 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径 */
|
||||||
|
async downloadToFile(remotePath: string, localDstDir: string): Promise<string> {
|
||||||
|
return SftpDownloadToFile(this.requireConn(), remotePath, localDstDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传本地文件/目录到远程目录(流式,目录递归) */
|
||||||
|
async uploadFromFile(localPath: string, remoteDir: string): Promise<void> {
|
||||||
|
await SftpUploadFromFile(this.requireConn(), localPath, remoteDir)
|
||||||
|
}
|
||||||
|
|
||||||
// ====== ZIP 操作(不支持)======
|
// ====== ZIP 操作(不支持)======
|
||||||
|
|
||||||
async listZipContents(_zipPath: string): Promise<FileItem[]> {
|
async listZipContents(_zipPath: string): Promise<FileItem[]> {
|
||||||
@@ -179,4 +197,7 @@ export class SftpTransport implements FsTransport {
|
|||||||
async downloadSiteForPreview(remotePath: string): Promise<string> {
|
async downloadSiteForPreview(remotePath: string): Promise<string> {
|
||||||
return SftpDownloadSiteForPreview(this.requireConn(), remotePath)
|
return SftpDownloadSiteForPreview(this.requireConn(), remotePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async watchFile(_path: string): Promise<void> { /* SFTP 模式不支持文件监听 */ }
|
||||||
|
async unwatchFile(): Promise<void> { /* SFTP 模式不支持文件监听 */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,17 @@
|
|||||||
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
|
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { File } from './types'
|
import type { FileItem } from '@/types/file-system'
|
||||||
import { connectionManager } from './connection-manager'
|
import { connectionManager } from './connection-manager'
|
||||||
|
import { PickDirectory } from '../wailsjs/v3-bindings/u-desk/app'
|
||||||
|
|
||||||
/**
|
/** 转换后端蛇形字段为前端驼峰格式 */
|
||||||
* 转换后端文件数据格式(蛇形 → 驼峰)
|
function toFileItem(file: any): FileItem {
|
||||||
*/
|
|
||||||
function transformFile(file: any): File {
|
|
||||||
return { ...file, isDir: file.is_dir, modified_time: file.mod_time }
|
return { ...file, isDir: file.is_dir, modified_time: file.mod_time }
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformFileList(files: any[]): File[] {
|
function toFileItemList(files: any[]): FileItem[] {
|
||||||
return files.map(transformFile)
|
return files.map(toFileItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = () => connectionManager.getTransport()
|
const t = () => connectionManager.getTransport()
|
||||||
@@ -23,21 +22,21 @@ export async function getSystemInfo() { return t().getFileInfo('/') }
|
|||||||
|
|
||||||
export async function getCPUInfo() {
|
export async function getCPUInfo() {
|
||||||
if (connectionManager.isRemote()) return {}
|
if (connectionManager.isRemote()) return {}
|
||||||
try { return await (window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
|
try { return (await window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMemoryInfo() {
|
export async function getMemoryInfo() {
|
||||||
if (connectionManager.isRemote()) return {}
|
if (connectionManager.isRemote()) return {}
|
||||||
try { return await (window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
|
try { return (await window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDiskInfo() {
|
export async function getDiskInfo() {
|
||||||
if (connectionManager.isRemote()) return {}
|
if (connectionManager.isRemote()) return {}
|
||||||
try { return await (window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
|
try { return (await window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listDir(path: string): Promise<File[]> {
|
export async function listDir(path: string): Promise<FileItem[]> {
|
||||||
return transformFileList(await t().listDir(path))
|
return toFileItemList(await t().listDir(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readFile(path: string): Promise<string> {
|
export async function readFile(path: string): Promise<string> {
|
||||||
@@ -69,12 +68,31 @@ export async function renamePath(oldPath: string, newPath: string): Promise<any>
|
|||||||
return t().renamePath(oldPath, String(newPath))
|
return t().renamePath(oldPath, String(newPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnvVars(): Promise<Record<string, string>> {
|
// 复制/移动(本机面板粘贴;进度经 transfer-progress 事件推送)
|
||||||
try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
|
export async function copyPath(src: string, dst: string): Promise<void> {
|
||||||
|
await t().copyPath(src, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listZipContents(zipPath: string): Promise<File[]> {
|
export async function movePath(src: string, dst: string): Promise<void> {
|
||||||
return transformFileList(await t().listZipContents(zipPath))
|
await t().movePath(src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载远程文件/目录到本机指定目录(SFTP),返回本地落地路径
|
||||||
|
export async function downloadToFile(path: string, localDstDir: string): Promise<string> {
|
||||||
|
return t().downloadToFile(path, localDstDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传本地文件/目录到远程目录(SFTP)
|
||||||
|
export async function uploadFromFile(localPath: string, remoteDir: string): Promise<void> {
|
||||||
|
await t().uploadFromFile(localPath, remoteDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEnvVars(): Promise<Record<string, string>> {
|
||||||
|
try { return (await window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listZipContents(zipPath: string): Promise<FileItem[]> {
|
||||||
|
return toFileItemList(await t().listZipContents(zipPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
||||||
@@ -85,8 +103,8 @@ export async function extractFileFromZipToTemp(zipPath: string, filePath: string
|
|||||||
return t().extractFileFromZipToTemp(zipPath, filePath)
|
return t().extractFileFromZipToTemp(zipPath, filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
|
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem> {
|
||||||
return transformFile(await t().getZipFileInfo(zipPath, filePath))
|
return toFileItem(await t().getZipFileInfo(zipPath, filePath))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPath(path: string): Promise<void> {
|
export async function openPath(path: string): Promise<void> {
|
||||||
@@ -109,19 +127,16 @@ export async function getCommonPaths() {
|
|||||||
return t().getCommonPaths()
|
return t().getCommonPaths()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件监听(仅本地模式,直接调用 Wails 绑定)
|
// 打开系统目录选择器(取消返回空串)
|
||||||
|
export async function pickDirectory(): Promise<string> {
|
||||||
|
return PickDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件监听(通过 Transport 层,本地模式走 Wails IPC,远程模式空操作)
|
||||||
export async function watchFile(path: string): Promise<void> {
|
export async function watchFile(path: string): Promise<void> {
|
||||||
if (connectionManager.isRemote()) return
|
await t().watchFile(path)
|
||||||
try {
|
|
||||||
const { WatchFile } = await import('../wailsjs/v3-bindings/u-desk/app')
|
|
||||||
await WatchFile(path)
|
|
||||||
} catch { /* 忽略绑定未生成的场景 */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unwatchFile(): Promise<void> {
|
export async function unwatchFile(): Promise<void> {
|
||||||
if (connectionManager.isRemote()) return
|
await t().unwatchFile()
|
||||||
try {
|
|
||||||
const { UnwatchFile } = await import('../wailsjs/v3-bindings/u-desk/app')
|
|
||||||
await UnwatchFile()
|
|
||||||
} catch { /* 忽略 */ }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export type DetectTypeResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FsTransport {
|
export interface FsTransport {
|
||||||
|
/** 检测连接是否仍然存活 */
|
||||||
|
isAlive?(): Promise<boolean>
|
||||||
|
|
||||||
// 文件列表与信息
|
// 文件列表与信息
|
||||||
listDir(path: string): Promise<FileItem[]>
|
listDir(path: string): Promise<FileItem[]>
|
||||||
getFileInfo(path: string): Promise<Record<string, any>>
|
getFileInfo(path: string): Promise<Record<string, any>>
|
||||||
@@ -49,6 +52,14 @@ export interface FsTransport {
|
|||||||
deletePath(path: string): Promise<FileOperationResult>
|
deletePath(path: string): Promise<FileOperationResult>
|
||||||
renamePath(oldPath: string, newPath: string): Promise<FileOperationResult>
|
renamePath(oldPath: string, newPath: string): Promise<FileOperationResult>
|
||||||
|
|
||||||
|
// 复制/移动(本机面板粘贴;SFTP/OSS/HTTP 暂不支持)
|
||||||
|
copyPath(src: string, dst: string): Promise<void>
|
||||||
|
movePath(src: string, dst: string): Promise<void>
|
||||||
|
|
||||||
|
// 传输(SFTP 下载到本机 / 本地文件上传到 SFTP;进度经 transfer-progress 事件推送)
|
||||||
|
downloadToFile(path: string, localDstDir: string): Promise<string>
|
||||||
|
uploadFromFile(localPath: string, remoteDir: string): Promise<void>
|
||||||
|
|
||||||
// ZIP 操作(Wave 3)
|
// ZIP 操作(Wave 3)
|
||||||
listZipContents(zipPath: string): Promise<FileItem[]>
|
listZipContents(zipPath: string): Promise<FileItem[]>
|
||||||
extractFileFromZip(zipPath: string, filePath: string): Promise<string>
|
extractFileFromZip(zipPath: string, filePath: string): Promise<string>
|
||||||
@@ -76,4 +87,42 @@ export interface FsTransport {
|
|||||||
downloadSiteForPreview?(path: string): Promise<string>
|
downloadSiteForPreview?(path: string): Promise<string>
|
||||||
/** 获取预签名 URL(仅 OSS 实现,其他返回空串) */
|
/** 获取预签名 URL(仅 OSS 实现,其他返回空串) */
|
||||||
getSignedUrl?(path: string): Promise<string>
|
getSignedUrl?(path: string): Promise<string>
|
||||||
|
|
||||||
|
// 文件监听(仅本地 Wails 模式有效)
|
||||||
|
watchFile(path: string): Promise<void>
|
||||||
|
unwatchFile(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 共享工具函数 ==========
|
||||||
|
|
||||||
|
// 说明:后端蛇形 → 前端驼峰(isDir/modified_time)的统一转换在 system.ts 的
|
||||||
|
// toFileItem/toFileItemList 完成;下面两个函数仅做【类型适配】(运行时恒等),
|
||||||
|
// 把绑定层返回的松散类型断言为 FileItem,供各 transport 复用。
|
||||||
|
|
||||||
|
/** 类型适配:断言后端文件数据为 FileItem(运行时恒等) */
|
||||||
|
export function transformFile(file: any): FileItem {
|
||||||
|
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformFileList(files: any[]): FileItem[] {
|
||||||
|
return files.map(transformFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 路径拼接:确保父路径以 / 结尾后拼接文件名 */
|
||||||
|
export function joinPath(parent: string, name: string): string {
|
||||||
|
return parent.replace(/\/$/, '') + '/' + name
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连接存活探测:无会话 id 或探测调用失败均视为死连接(SFTP/OSS 共用) */
|
||||||
|
export async function probeAlive(
|
||||||
|
connId: string | null,
|
||||||
|
probe: (id: string) => Promise<unknown>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!connId) return false
|
||||||
|
try {
|
||||||
|
await probe(connId)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* API 类型定义
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 系统信息
|
|
||||||
export interface SystemInfo {
|
|
||||||
os: string
|
|
||||||
arch: string
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CPU {
|
|
||||||
model: string
|
|
||||||
cores: number
|
|
||||||
usage: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Memory {
|
|
||||||
total: number
|
|
||||||
used: number
|
|
||||||
free: number
|
|
||||||
usage: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Disk {
|
|
||||||
path: string
|
|
||||||
total: number
|
|
||||||
used: number
|
|
||||||
free: number
|
|
||||||
usage: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface File {
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
size: number
|
|
||||||
isDir: boolean
|
|
||||||
modified?: string
|
|
||||||
modified_time?: string
|
|
||||||
}
|
|
||||||
@@ -3,25 +3,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
|
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
|
||||||
|
import { transformFile, transformFileList, joinPath } from './transport'
|
||||||
import {
|
import {
|
||||||
ListDir, GetFileInfo, ReadFile, WriteFile, SaveBase64File,
|
ListDir, GetFileInfo, ReadFile, WriteFile, SaveBase64File,
|
||||||
CreateFile, CreateDir, DeletePath, RenamePath,
|
CreateFile, CreateDir, DeletePath, RenamePath, CopyPath, MovePath,
|
||||||
ListZipContents, ExtractFileFromZip, ExtractFileFromZipToTemp, GetZipFileInfo,
|
ListZipContents, ExtractFileFromZip, ExtractFileFromZipToTemp, GetZipFileInfo,
|
||||||
OpenPath, GetFileServerURL, ResolveShortcut, DetectFileTypeByContent,
|
OpenPath, GetFileServerURL, ResolveShortcut, DetectFileTypeByContent,
|
||||||
GetCommonPaths, GetRecycleBinEntries, RestoreFromRecycleBin,
|
GetCommonPaths, GetRecycleBinEntries, RestoreFromRecycleBin,
|
||||||
DeletePermanently, EmptyRecycleBin
|
DeletePermanently, EmptyRecycleBin,
|
||||||
|
WatchFile, UnwatchFile
|
||||||
} from '../wailsjs/v3-bindings/u-desk/app'
|
} from '../wailsjs/v3-bindings/u-desk/app'
|
||||||
|
|
||||||
function transformFile(file: any): FileItem {
|
|
||||||
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformFileList(files: any[]): FileItem[] {
|
|
||||||
return files.map(transformFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WailsTransport implements FsTransport {
|
export class WailsTransport implements FsTransport {
|
||||||
async listDir(path: string): Promise<FileItem[]> {
|
async listDir(path: string): Promise<FileItem[]> {
|
||||||
|
// 蛇形→驼峰转换统一由 system.ts 的 toFileItemList 完成,此处仅类型适配
|
||||||
return transformFileList(await ListDir(path))
|
return transformFileList(await ListDir(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +38,11 @@ export class WailsTransport implements FsTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||||
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
return CreateFile(joinPath(dirPath, filename))
|
||||||
return CreateFile(fullPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||||
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
return CreateDir(joinPath(parentPath, dirname))
|
||||||
return CreateDir(fullPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deletePath(path: string): Promise<FileOperationResult> {
|
async deletePath(path: string): Promise<FileOperationResult> {
|
||||||
@@ -60,6 +53,22 @@ export class WailsTransport implements FsTransport {
|
|||||||
return RenamePath({ oldPath: String(oldPath), newPath: String(newPath) })
|
return RenamePath({ oldPath: String(oldPath), newPath: String(newPath) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async copyPath(src: string, dst: string): Promise<void> {
|
||||||
|
await CopyPath(String(src), String(dst))
|
||||||
|
}
|
||||||
|
|
||||||
|
async movePath(src: string, dst: string): Promise<void> {
|
||||||
|
await MovePath(String(src), String(dst))
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadToFile(path: string, _localDstDir: string): Promise<string> {
|
||||||
|
return path // 本地文件无需下载
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFromFile(_localPath: string, _remoteDir: string): Promise<void> {
|
||||||
|
// 本地面板无上传语义(外部文件拖入走 copyPath)
|
||||||
|
}
|
||||||
|
|
||||||
async listZipContents(zipPath: string): Promise<FileItem[]> {
|
async listZipContents(zipPath: string): Promise<FileItem[]> {
|
||||||
return transformFileList(await ListZipContents(zipPath))
|
return transformFileList(await ListZipContents(zipPath))
|
||||||
}
|
}
|
||||||
@@ -73,7 +82,7 @@ export class WailsTransport implements FsTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem> {
|
async getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem> {
|
||||||
return transform(await GetZipFileInfo(zipPath, filePath))
|
return transformFile(await GetZipFileInfo(zipPath, filePath))
|
||||||
}
|
}
|
||||||
|
|
||||||
async openPath(path: string): Promise<void> {
|
async openPath(path: string): Promise<void> {
|
||||||
@@ -120,4 +129,12 @@ export class WailsTransport implements FsTransport {
|
|||||||
async downloadForPreview(path: string): Promise<string> {
|
async downloadForPreview(path: string): Promise<string> {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async watchFile(path: string): Promise<void> {
|
||||||
|
await WatchFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async unwatchFile(): Promise<void> {
|
||||||
|
await UnwatchFile()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,9 +145,12 @@ watch(() => props.visible, (val) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(() => form.type, (t) => {
|
watch(() => form.type, (t) => {
|
||||||
if (t === 'sftp') form.port = 22
|
// 仅当前端口是其它类型的默认值(而非用户自定义值)时才重置,避免切类型丢失自定义端口
|
||||||
else if (t === 'remote') form.port = 9876
|
const DEFAULT_PORTS: Record<string, number> = { sftp: 22, remote: 9876, oss: 0 }
|
||||||
else form.port = 0
|
const isOtherDefault = Object.entries(DEFAULT_PORTS).some(([, v]) => form.port === v)
|
||||||
|
if (isOtherDefault) {
|
||||||
|
form.port = DEFAULT_PORTS[t] ?? 0
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleOk(): Promise<boolean> {
|
async function handleOk(): Promise<boolean> {
|
||||||
@@ -166,8 +169,10 @@ async function handleOk(): Promise<boolean> {
|
|||||||
await connectionManager.updateProfile(editingId.value, { ...form })
|
await connectionManager.updateProfile(editingId.value, { ...form })
|
||||||
Message.success('已更新')
|
Message.success('已更新')
|
||||||
} else {
|
} else {
|
||||||
|
// addProfile 遇同名同地址的已有配置会静默复用,提前查重以区分提示
|
||||||
|
const existed = !!connectionManager.findDuplicateProfile(form)
|
||||||
await connectionManager.addProfile({ ...form, type: form.type })
|
await connectionManager.addProfile({ ...form, type: form.type })
|
||||||
Message.success('已添加')
|
Message.success(existed ? '已存在同名配置,已复用' : '已添加')
|
||||||
}
|
}
|
||||||
emit('update:visible', false)
|
emit('update:visible', false)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -77,7 +77,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, shallowRef, onMounted, onUnmounted, provide, inject, watch, type Ref } from 'vue'
|
import { ref, computed, shallowRef, onMounted, onUnmounted, provide, inject, watch, type Ref } from 'vue'
|
||||||
import { IconCloud, IconFolder, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
|
import { IconCloud, IconFolder, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
|
||||||
import { Message } from '@arco-design/web-vue'
|
|
||||||
import { connectionManager } from '@/api/connection-manager'
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import { listDir } from '@/api/system'
|
import { listDir } from '@/api/system'
|
||||||
import { sortFileList } from '@/utils/fileUtils'
|
import { sortFileList } from '@/utils/fileUtils'
|
||||||
@@ -256,17 +255,16 @@ function handleClickOutside(e: MouseEvent) {
|
|||||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
// 清理目录悬停延迟 timer,避免卸载后回调仍执行
|
||||||
|
if (dirHoverTimer.value) clearDelay(dirHoverTimer.value)
|
||||||
|
if (dirCloseTimer.value) clearDelay(dirCloseTimer.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
// === 原有方法 ===
|
// === 原有方法 ===
|
||||||
async function handleSelect(p: { id: string }) {
|
// 连接动作由父级统一入口处理,这里只上抛所选服务器
|
||||||
|
function handleSelect(p: { id: string }) {
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
try {
|
|
||||||
await connectionManager.connect(p.id)
|
|
||||||
emit('select', p.id)
|
emit('select', p.id)
|
||||||
} catch (err) {
|
|
||||||
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMore(p: { id: string }) {
|
function toggleMore(p: { id: string }) {
|
||||||
@@ -281,14 +279,22 @@ function handleEdit(p: { id: string }) {
|
|||||||
|
|
||||||
function handleDelete(p: { id: string; name: string }) {
|
function handleDelete(p: { id: string; name: string }) {
|
||||||
if (!window.confirm(`确定删除「${p.name}」?`)) return
|
if (!window.confirm(`确定删除「${p.name}」?`)) return
|
||||||
|
const wasActive = p.id === activeId.value
|
||||||
connectionManager.removeProfile(p.id)
|
connectionManager.removeProfile(p.id)
|
||||||
moreOpenId.value = null
|
moreOpenId.value = null
|
||||||
|
// 删除的是活跃服务器时连接已回落本地,上抛切换让视图加载本地根目录
|
||||||
|
if (wasActive) emit('select', 'local-default')
|
||||||
}
|
}
|
||||||
|
|
||||||
function dotClass(p: { type: string }): string {
|
function dotClass(p: { id: string; type: string }): string {
|
||||||
if (p.type === 'sftp') return 'sftp'
|
// 优先反映实际连接状态;未连接/出错才回落到类型色(disconnected 用灰,不与 error 红混淆)
|
||||||
if (p.type === 'remote') return 'remote'
|
if (p.type !== 'local') {
|
||||||
if (p.type === 'oss') return 'oss'
|
const st = connectionManager.getProfileState(p.id).state
|
||||||
|
if (st === 'connected') return 'connected'
|
||||||
|
if (st === 'connecting') return 'connecting'
|
||||||
|
if (st === 'error') return 'error'
|
||||||
|
return 'disconnected-idle'
|
||||||
|
}
|
||||||
return 'local'
|
return 'local'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +353,7 @@ watch(() => props.filePath, () => {
|
|||||||
|
|
||||||
.dot.connected { background: var(--color-success-6); }
|
.dot.connected { background: var(--color-success-6); }
|
||||||
.dot.connecting { background: var(--color-warning-6); animation: pulse 1.5s infinite; }
|
.dot.connecting { background: var(--color-warning-6); animation: pulse 1.5s infinite; }
|
||||||
.dot.disconnected { background: var(--color-danger-6); }
|
.dot.disconnected-idle { background: var(--color-text-4); }
|
||||||
.dot.error { background: var(--color-danger-6); }
|
.dot.error { background: var(--color-danger-6); }
|
||||||
.dot.local { background: var(--color-text-3); }
|
.dot.local { background: var(--color-text-3); }
|
||||||
.dot.remote { background: var(--color-primary-6); }
|
.dot.remote { background: var(--color-primary-6); }
|
||||||
|
|||||||
@@ -18,6 +18,16 @@
|
|||||||
<span>新建文件夹</span>
|
<span>新建文件夹</span>
|
||||||
<span class="context-menu-shortcut">Ctrl+Shift+N</span>
|
<span class="context-menu-shortcut">Ctrl+Shift+N</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="context-menu-item"
|
||||||
|
:class="{ disabled: config.clipboardEmpty }"
|
||||||
|
:title="config.clipboardEmpty ? '剪贴板为空' : undefined"
|
||||||
|
@click="handlePaste"
|
||||||
|
>
|
||||||
|
<span class="context-menu-icon">📋</span>
|
||||||
|
<span>粘贴</span>
|
||||||
|
<span class="context-menu-shortcut">Ctrl+V</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 文件菜单 -->
|
<!-- 文件菜单 -->
|
||||||
@@ -34,13 +44,35 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="context-menu-divider"></div>
|
<div class="context-menu-divider"></div>
|
||||||
<div
|
<div
|
||||||
v-if="!config.selectedFile.is_dir && isOfficeFile(config.selectedFile.name)"
|
v-if="!config.selectedFile.isDir && isOfficeFile(config.selectedFile.name)"
|
||||||
class="context-menu-item"
|
class="context-menu-item"
|
||||||
@click="handleOpenWithSystem"
|
@click="handleOpenWithSystem"
|
||||||
>
|
>
|
||||||
<span class="context-menu-icon">🚀</span>
|
<span class="context-menu-icon">🚀</span>
|
||||||
<span>系统默认程序打开</span>
|
<span>系统默认程序打开</span>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="config.isSftp">
|
||||||
|
<!-- SFTP 面板:下载到本机 -->
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
|
<div class="context-menu-item" @click="handleDownload">
|
||||||
|
<span class="context-menu-icon">⬇️</span>
|
||||||
|
<span>下载到本机...</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<!-- 本机面板:复制/剪切 -->
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
|
<div class="context-menu-item" @click="handleCopy">
|
||||||
|
<span class="context-menu-icon">📋</span>
|
||||||
|
<span>复制</span>
|
||||||
|
<span class="context-menu-shortcut">Ctrl+C</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" @click="handleCut">
|
||||||
|
<span class="context-menu-icon">✂️</span>
|
||||||
|
<span>剪切</span>
|
||||||
|
<span class="context-menu-shortcut">Ctrl+X</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<div class="context-menu-divider"></div>
|
<div class="context-menu-divider"></div>
|
||||||
<div class="context-menu-item" @click="handleRename">
|
<div class="context-menu-item" @click="handleRename">
|
||||||
<span class="context-menu-icon">✏️</span>
|
<span class="context-menu-icon">✏️</span>
|
||||||
@@ -111,26 +143,31 @@ const handleCreateDir = () => {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenWithSystem = () => {
|
/** 文件类菜单项:带选中文件发出动作并关闭(未选中仅关闭) */
|
||||||
|
const emitFileAction = (action: string) => {
|
||||||
if (props.config.selectedFile) {
|
if (props.config.selectedFile) {
|
||||||
emit('action', 'openWithSystem', props.config.selectedFile)
|
emit('action', action, props.config.selectedFile)
|
||||||
}
|
}
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRename = () => {
|
const handleOpenWithSystem = () => emitFileAction('openWithSystem')
|
||||||
if (props.config.selectedFile) {
|
|
||||||
emit('action', 'rename', props.config.selectedFile)
|
const handleCopy = () => emitFileAction('copy')
|
||||||
}
|
|
||||||
|
const handleCut = () => emitFileAction('cut')
|
||||||
|
|
||||||
|
const handleDownload = () => emitFileAction('download')
|
||||||
|
|
||||||
|
const handlePaste = () => {
|
||||||
|
if (props.config.clipboardEmpty) return
|
||||||
|
emit('action', 'paste')
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleRename = () => emitFileAction('rename')
|
||||||
if (props.config.selectedFile) {
|
|
||||||
emit('action', 'delete', props.config.selectedFile)
|
const handleDelete = () => emitFileAction('delete')
|
||||||
}
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -169,6 +206,15 @@ const handleDelete = () => {
|
|||||||
background: rgb(var(--danger-1));
|
background: rgb(var(--danger-1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-menu-item.disabled {
|
||||||
|
color: var(--color-text-4);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item.disabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.context-menu-icon {
|
.context-menu-icon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<div class="drawio-preview">
|
||||||
|
<!-- 下载中(首次使用时下载 viewer) -->
|
||||||
|
<div v-if="downloading" class="drawio-loading">
|
||||||
|
<a-spin :loading="true" />
|
||||||
|
<p class="drawio-loading-text">
|
||||||
|
{{ hasProgress ? `正在下载 Draw.io 查看器 ${progress}%` : '正在准备 Draw.io 查看器...' }}
|
||||||
|
</p>
|
||||||
|
<a-progress v-if="hasProgress" :percent="progress" style="max-width: 360px; width: 80%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误 -->
|
||||||
|
<div v-else-if="errorMsg" class="drawio-error">
|
||||||
|
<a-alert type="error" message="Draw.io 预览失败" :description="errorMsg" />
|
||||||
|
<a-button size="small" @click="load">重试</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- drawio viewer iframe(viewer 用 ?url= 自取 .drawio 原文) -->
|
||||||
|
<iframe v-else-if="viewerSrc" :src="viewerSrc" class="drawio-iframe" frameborder="0" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// DrawioPreview:drawio 文件预览插件组件。
|
||||||
|
// 流程:① 调 EnsureDrawioViewer 确保本地已缓存 viewer(首次触发下载,监听进度);
|
||||||
|
// ② 用 resolveFileUrl 拿 .drawio 原文 URL,拼 viewer iframe src(viewer 用 ?url= 自取原文)。
|
||||||
|
// 关键:viewer base 必须用本地文件服务器(getFileServerBaseURL),与 /drawio-viewer/ 路由及 .drawio 原文同源。
|
||||||
|
|
||||||
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { On, Off } from '@wailsio/events'
|
||||||
|
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||||
|
import { resolveFileUrl } from '../composables/useFilePreview'
|
||||||
|
import { getFileServerBaseURL } from '@/api/file-server'
|
||||||
|
import { EnsureDrawioViewer } from '@bindings/u-desk/app'
|
||||||
|
import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||||
|
|
||||||
|
const props = defineProps<{ config: FileEditorPanelConfig }>()
|
||||||
|
|
||||||
|
const downloading = ref(false)
|
||||||
|
const hasProgress = ref(false)
|
||||||
|
const progress = ref(0)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const viewerSrc = ref('')
|
||||||
|
|
||||||
|
const EVENT_NAME = 'drawio-download-progress'
|
||||||
|
|
||||||
|
const onProgress = (event: unknown) => {
|
||||||
|
const d = parseWailsEventData(event)
|
||||||
|
const p = Number(d.progress)
|
||||||
|
if (!isNaN(p) && p >= 0) {
|
||||||
|
hasProgress.value = true
|
||||||
|
progress.value = Math.round(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造 viewer iframe src
|
||||||
|
const buildViewerSrc = async (filePath: string): Promise<string> => {
|
||||||
|
// 1. .drawio 原文 URL(本地/远程自适应;远程先下载到 temp 再走 localfs)
|
||||||
|
const xmlUrl = await resolveFileUrl(filePath)
|
||||||
|
if (!xmlUrl) throw new Error('无法解析文件 URL(远程下载可能失败)')
|
||||||
|
|
||||||
|
// 2. viewer base = 本地文件服务器(与 /drawio-viewer/ 路由及原文同源)
|
||||||
|
const base = getFileServerBaseURL().replace(/\/$/, '')
|
||||||
|
|
||||||
|
// 3. chrome=0 隐藏工具栏,lightbox=1 只读灯箱,layers=1 显示图层
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
url: xmlUrl,
|
||||||
|
chrome: '0',
|
||||||
|
lightbox: '1',
|
||||||
|
layers: '1',
|
||||||
|
})
|
||||||
|
return `${base}/drawio-viewer/index.html?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
errorMsg.value = ''
|
||||||
|
viewerSrc.value = ''
|
||||||
|
const filePath = props.config.currentFileFullPath
|
||||||
|
if (!filePath) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 确保 viewer 缓存就绪(首次触发下载)
|
||||||
|
downloading.value = true
|
||||||
|
hasProgress.value = false
|
||||||
|
progress.value = 0
|
||||||
|
await EnsureDrawioViewer()
|
||||||
|
downloading.value = false
|
||||||
|
|
||||||
|
// 2. 构造 viewer URL 并渲染
|
||||||
|
viewerSrc.value = await buildViewerSrc(filePath)
|
||||||
|
} catch (e: any) {
|
||||||
|
downloading.value = false
|
||||||
|
console.error('[DrawioPreview] 加载失败:', e)
|
||||||
|
errorMsg.value = e?.message || String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.config.currentFileFullPath, () => load(), { immediate: true })
|
||||||
|
|
||||||
|
// 进度监听只注册一次;Wails On 每次调用都会追加监听器,放在 load() 里会随文件切换累积
|
||||||
|
onMounted(() => {
|
||||||
|
On(EVENT_NAME, onProgress)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
Off(EVENT_NAME)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.drawio-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-bg-1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.drawio-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.drawio-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.drawio-loading-text {
|
||||||
|
color: var(--color-text-2);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.drawio-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -72,6 +72,7 @@ export interface BgmTrack { name: string; path: string; url: string; profileId?:
|
|||||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import type { LrcLine } from '@/utils/lrcParser'
|
import type { LrcLine } from '@/utils/lrcParser'
|
||||||
import { BgmGetPlaylist, BgmSavePlaylist } from '@bindings/u-desk/app'
|
import { BgmGetPlaylist, BgmSavePlaylist } from '@bindings/u-desk/app'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { connectionManager } from '@/api/connection-manager'
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -143,7 +144,10 @@ const restorePlaylist = async (resolver?: (path: string) => Promise<string | und
|
|||||||
} else {
|
} else {
|
||||||
playlist.value = entries.map(e => ({ name: e.name, path: e.path, url: '', profileId: (e as any).profile_id || '' }))
|
playlist.value = entries.map(e => ({ name: e.name, path: e.path, url: '', profileId: (e as any).profile_id || '' }))
|
||||||
}
|
}
|
||||||
} catch (e) { console.debug('[BgmBar] 恢复播放列表失败:', e) }
|
} catch (e) {
|
||||||
|
console.debug('[BgmBar] 恢复播放列表失败:', e)
|
||||||
|
Message.warning('背景音乐播放列表恢复失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动时自动恢复(url 延迟解析),并选中上次播放的曲目
|
// 启动时自动恢复(url 延迟解析),并选中上次播放的曲目
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
|
<div class="file-editor-panel" :class="{ 'is-fullscreen': isFullscreen }" :style="{ width: width + '%' }">
|
||||||
<!-- 有选中文件时显示表头和内容 -->
|
<!-- 有选中文件时显示表头和内容 -->
|
||||||
<template v-if="config.currentFileName">
|
<template v-if="config.currentFileName">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">
|
<span class="panel-title">
|
||||||
<template v-if="config.isImageView">🖼️ 图片预览</template>
|
<template v-if="config.activePlugin">{{ config.activePlugin.icon }} {{ config.activePlugin.name }}</template>
|
||||||
|
<template v-else-if="config.isImageView">🖼️ 图片预览</template>
|
||||||
<template v-else-if="config.isVideoView">🎬 视频预览</template>
|
<template v-else-if="config.isVideoView">🎬 视频预览</template>
|
||||||
<template v-else-if="config.isAudioView">🎵 音频预览</template>
|
<template v-else-if="config.isAudioView">🎵 音频预览</template>
|
||||||
<template v-else-if="config.isPdfFile">📕 PDF 预览</template>
|
<template v-else-if="config.isPdfFile">📕 PDF 预览</template>
|
||||||
@@ -47,8 +48,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-content thin-dark-scrollbar">
|
<div class="editor-content thin-dark-scrollbar">
|
||||||
|
<!-- 插件预览(drawio 等,优先于内置 v-if 链) -->
|
||||||
|
<component v-if="config.activePluginComponent" :is="config.activePluginComponent" :config="config" />
|
||||||
<!-- 二进制文件提示 -->
|
<!-- 二进制文件提示 -->
|
||||||
<div v-if="config.isBinaryFile" class="binary-file-message">
|
<div v-else-if="config.isBinaryFile" class="binary-file-message">
|
||||||
<pre>{{ config.fileContent }}</pre>
|
<pre>{{ config.fileContent }}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -445,10 +448,15 @@ import { parseLrc, type LrcLine } from '@/utils/lrcParser'
|
|||||||
import BgmBar, { type BgmTrack } from './FileEditor/BgmBar.vue'
|
import BgmBar, { type BgmTrack } from './FileEditor/BgmBar.vue'
|
||||||
|
|
||||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||||
|
let editorRetryCount = 0
|
||||||
const AsyncCodeEditor = defineAsyncComponent({
|
const AsyncCodeEditor = defineAsyncComponent({
|
||||||
loader: () => import('@/components/CodeEditor.vue'),
|
loader: () => import('@/components/CodeEditor.vue'),
|
||||||
delay: 200,
|
delay: 200,
|
||||||
timeout: 10000
|
timeout: 10000,
|
||||||
|
onError(error, retry) {
|
||||||
|
console.error('[AsyncCodeEditor] 加载失败:', error)
|
||||||
|
if (++editorRetryCount <= 2) retry()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Office 预览容器引用
|
// Office 预览容器引用
|
||||||
@@ -459,21 +467,11 @@ const csvPreviewRef = ref<HTMLElement | null>(null)
|
|||||||
// Markdown 预览容器引用
|
// Markdown 预览容器引用
|
||||||
const markdownPreviewRef = ref<HTMLElement | null>(null)
|
const markdownPreviewRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// 全屏
|
// 全屏(仅应用窗口内全屏,非 OS 级 fullscreen)
|
||||||
const panelRef = ref<HTMLElement | null>(null)
|
|
||||||
const isFullscreen = ref(false)
|
const isFullscreen = ref(false)
|
||||||
|
|
||||||
function toggleFullscreen() {
|
function toggleFullscreen() {
|
||||||
if (!panelRef.value) return
|
isFullscreen.value = !isFullscreen.value
|
||||||
if (!document.fullscreenElement) {
|
|
||||||
panelRef.value.requestFullscreen().then(() => { isFullscreen.value = true })
|
|
||||||
} else {
|
|
||||||
document.exitFullscreen().then(() => { isFullscreen.value = false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFullscreenChange() {
|
|
||||||
isFullscreen.value = !!document.fullscreenElement
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
@@ -481,6 +479,10 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
toggleFullscreen()
|
toggleFullscreen()
|
||||||
}
|
}
|
||||||
|
// ESC 退出窗口内全屏
|
||||||
|
if (e.key === 'Escape' && isFullscreen.value) {
|
||||||
|
isFullscreen.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
@@ -793,12 +795,12 @@ const handlePdfLoad = (event: Event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 带认证的 fetch(远程模式自动附加 Bearer token)
|
// 带认证的 fetch(远程模式自动附加 Bearer token),支持取消信号
|
||||||
const authFetch = async (url: string): Promise<Response> => {
|
const authFetch = async (url: string, signal?: AbortSignal): Promise<Response> => {
|
||||||
const token = connectionManager.activeProfile?.token
|
const token = connectionManager.activeProfile?.token
|
||||||
const headers: Record<string, string> = {}
|
const headers: Record<string, string> = {}
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
return fetch(url, { headers })
|
return fetch(url, { headers, signal })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打印窗口导出 PDF 公共函数
|
// 打印窗口导出 PDF 公共函数
|
||||||
@@ -945,23 +947,34 @@ watch(() => [props.config.isCsvFile, props.config.currentFileFullPath, props.con
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Excel 预览加载(直接使用本地文件服务器,秒开)
|
// Excel 预览加载(直接使用本地文件服务器,秒开)
|
||||||
|
// 取消上一个进行中的预览:快速切文件时 abort 旧下载 + worker 侧 wid 自动推进丢弃旧解析
|
||||||
|
let excelAbortController: AbortController | null = null
|
||||||
const loadExcelPreview = async (filePath: string) => {
|
const loadExcelPreview = async (filePath: string) => {
|
||||||
if (!excelPreviewRef.value) return
|
if (!excelPreviewRef.value) return
|
||||||
|
|
||||||
|
// 取消上一次尚未完成的下载
|
||||||
|
if (excelAbortController) excelAbortController.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
excelAbortController = controller
|
||||||
|
|
||||||
try {
|
try {
|
||||||
excelPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
excelPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||||
|
|
||||||
// 直接从本地文件服务器获取(不走 base64)
|
// 直接从本地文件服务器获取(不走 base64)
|
||||||
const fileUrl = props.config.previewUrl
|
const fileUrl = props.config.previewUrl
|
||||||
const response = await authFetch(fileUrl)
|
const response = await authFetch(fileUrl, controller.signal)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
|
if (controller.signal.aborted) return
|
||||||
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
|
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
|
||||||
|
|
||||||
const result = await previewExcel(file, excelPreviewRef.value)
|
const result = await previewExcel(file, excelPreviewRef.value)
|
||||||
|
if (controller.signal.aborted) return
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || '预览失败')
|
throw new Error(result.error || '预览失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
// 被后续切换主动取消 —— 静默
|
||||||
|
if (controller.signal.aborted || error?.name === 'AbortError') return
|
||||||
console.error('[loadExcelPreview] 错误:', error)
|
console.error('[loadExcelPreview] 错误:', error)
|
||||||
if (excelPreviewRef.value) {
|
if (excelPreviewRef.value) {
|
||||||
excelPreviewRef.value.innerHTML = `
|
excelPreviewRef.value.innerHTML = `
|
||||||
@@ -1083,6 +1096,20 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 切换文件时重置 Markdown 预览的滚动位置
|
||||||
|
// v-html 只替换内容,外层滚动容器的 scrollTop 不会自动归零,
|
||||||
|
// 否则从 A 文档跳进 B 文档时会停留在 A 的滚动位置
|
||||||
|
watch(
|
||||||
|
() => props.config.currentFileFullPath,
|
||||||
|
() => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (markdownPreviewRef.value) {
|
||||||
|
markdownPreviewRef.value.scrollTop = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 处理 HTML iframe 发送的消息(链接点击)
|
// 处理 HTML iframe 发送的消息(链接点击)
|
||||||
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||||
// 安全检查:接受来自本地文件服务器或同源的消息
|
// 安全检查:接受来自本地文件服务器或同源的消息
|
||||||
@@ -1105,7 +1132,6 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
|
|||||||
// 监听 iframe 的 postMessage + 全屏事件
|
// 监听 iframe 的 postMessage + 全屏事件
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('message', handleHtmlIframeMessage)
|
window.addEventListener('message', handleHtmlIframeMessage)
|
||||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener('keydown', onKeyDown)
|
||||||
On('toggle-bgm-bar', () => {
|
On('toggle-bgm-bar', () => {
|
||||||
bgmHidden.value = !bgmHidden.value
|
bgmHidden.value = !bgmHidden.value
|
||||||
@@ -1117,7 +1143,6 @@ onUnmounted(() => {
|
|||||||
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
|
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
|
||||||
}
|
}
|
||||||
window.removeEventListener('message', handleHtmlIframeMessage)
|
window.removeEventListener('message', handleHtmlIframeMessage)
|
||||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
Off('toggle-bgm-bar')
|
Off('toggle-bgm-bar')
|
||||||
copyCleanup()
|
copyCleanup()
|
||||||
@@ -1135,9 +1160,11 @@ defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHid
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-editor-panel:fullscreen {
|
.file-editor-panel.is-fullscreen {
|
||||||
width: 100vw !important;
|
position: fixed;
|
||||||
height: 100vh;
|
inset: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,7 +1184,7 @@ defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHid
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 仅全屏模式下 header 可拖动窗口 */
|
/* 仅全屏模式下 header 可拖动窗口 */
|
||||||
.file-editor-panel:fullscreen .panel-header {
|
.file-editor-panel.is-fullscreen .panel-header {
|
||||||
--wails-draggable: drag;
|
--wails-draggable: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1607,54 +1634,7 @@ defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHid
|
|||||||
fill: var(--color-text-1);
|
fill: var(--color-text-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 代码高亮主题色(不依赖 hljs 主题 CSS) ========== */
|
/* 亮色 hljs 样式由全局 style.css 提供 */
|
||||||
|
|
||||||
/* 亮色模式 - GitHub 配色 */
|
|
||||||
.markdown-preview-content :deep(.hljs) {
|
|
||||||
color: #24292e;
|
|
||||||
background: #f6f8fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-comment),
|
|
||||||
.markdown-preview-content :deep(.hljs-quote) { color: #6a737d; font-style: italic; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-keyword),
|
|
||||||
.markdown-preview-content :deep(.hljs-selector-tag),
|
|
||||||
.markdown-preview-content :deep(.hljs-subst) { color: #d73a49; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-string),
|
|
||||||
.markdown-preview-content :deep(.hljs-doctag) { color: #032f62; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-number),
|
|
||||||
.markdown-preview-content :deep(.hljs-literal),
|
|
||||||
.markdown-preview-content :deep(.hljs-variable),
|
|
||||||
.markdown-preview-content :deep(.hljs-template-variable),
|
|
||||||
.markdown-preview-content :deep(.hljs-tag .hljs-attr) { color: #005cc5; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-title),
|
|
||||||
.markdown-preview-content :deep(.hljs-section),
|
|
||||||
.markdown-preview-content :deep(.hljs-selector-id) { color: #6f42c1; font-weight: bold; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-type),
|
|
||||||
.markdown-preview-content :deep(.hljs-class .hljs-title) { color: #6f42c1; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-tag .hljs-keyword),
|
|
||||||
.markdown-preview-content :deep(.hljs-tag .hljs-title) { color: #22863a; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-bullet) { color: #e36209; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-symbol) { color: #005cc5; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-built_in),
|
|
||||||
.markdown-preview-content :deep(.hljs-type) { color: #005cc5; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-attr) { color: #e36209; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-meta) { color: #735c0f; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-addition) { color: #22863a; background-color: #f0fff4; }
|
|
||||||
|
|
||||||
.markdown-preview-content :deep(.hljs-deletion) { color: #b31d28; background-color: #ffeef0; }
|
|
||||||
|
|
||||||
/* ========== 深色模式适配 ========== */
|
/* ========== 深色模式适配 ========== */
|
||||||
|
|
||||||
@@ -1725,12 +1705,16 @@ body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-link) {
|
|||||||
border: 1px solid color-mix(in srgb, var(--color-border-2) 40%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-border-2) 40%, transparent);
|
||||||
border-right: none;
|
border-right: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
opacity: 0.68;
|
/* 未悬停时高透明避免遮挡内容,容器 hover / tab hover 恢复;
|
||||||
|
active 态与未保存 dirty 标记保持较高可见度(状态不能被压暗) */
|
||||||
|
opacity: 0.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-tabs:hover .preview-tab {
|
.preview-tabs:hover .preview-tab,
|
||||||
|
.preview-tab:hover,
|
||||||
|
.preview-tab.active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1741,6 +1725,7 @@ body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-link) {
|
|||||||
.preview-tab.active {
|
.preview-tab.active {
|
||||||
background: var(--color-fill-2);
|
background: var(--color-fill-2);
|
||||||
border-left-color: transparent;
|
border-left-color: transparent;
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-tab.active::before {
|
.preview-tab.active::before {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="file-list-wrapper thin-dark-scrollbar"
|
class="file-list-wrapper thin-dark-scrollbar"
|
||||||
|
data-file-drop-target
|
||||||
@contextmenu.prevent="handleWrapperContextMenu"
|
@contextmenu.prevent="handleWrapperContextMenu"
|
||||||
>
|
>
|
||||||
<!-- 文件列表(滚动区域) -->
|
<!-- 文件列表(滚动区域) -->
|
||||||
@@ -378,7 +379,8 @@ const handleRowContextMenu = (record: FileItem, ev: Event) => {
|
|||||||
|
|
||||||
const getRowClassName = (record: FileItem): string => [
|
const getRowClassName = (record: FileItem): string => [
|
||||||
props.config.selectedFileItem?.path === record.path && 'row-selected',
|
props.config.selectedFileItem?.path === record.path && 'row-selected',
|
||||||
props.config.editingFilePath === record.path && 'row-editing'
|
props.config.editingFilePath === record.path && 'row-editing',
|
||||||
|
props.config.cutPaths?.includes(record.path) && 'row-cut'
|
||||||
].filter(Boolean).join(' ')
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
const handleWrapperContextMenu = (event: MouseEvent) => {
|
const handleWrapperContextMenu = (event: MouseEvent) => {
|
||||||
@@ -537,6 +539,17 @@ defineExpose({ focusEditingItem })
|
|||||||
background: var(--color-fill-1);
|
background: var(--color-fill-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 被剪切的文件行 */
|
||||||
|
.file-table :deep(.arco-table-tr.row-cut) {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 外部文件拖入悬停反馈(Wails 运行时自动添加的类) */
|
||||||
|
.file-list-wrapper.file-drop-target-active {
|
||||||
|
outline: 1px dashed rgb(var(--primary-6));
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ====== 列内容 ====== */
|
/* ====== 列内容 ====== */
|
||||||
.col-icon { text-align: center; vertical-align: middle !important; }
|
.col-icon { text-align: center; vertical-align: middle !important; }
|
||||||
.file-item-icon {
|
.file-item-icon {
|
||||||
|
|||||||
@@ -172,7 +172,6 @@ import { connectionManager } from '@/api/connection-manager'
|
|||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { IconStar, IconClose, IconPushpin, IconDown, IconRight, IconStorage, IconComputer, IconDesktop, IconPlus, IconCheckCircle } from '@arco-design/web-vue/es/icon'
|
import { IconStar, IconClose, IconPushpin, IconDown, IconRight, IconStorage, IconComputer, IconDesktop, IconPlus, IconCheckCircle } from '@arco-design/web-vue/es/icon'
|
||||||
import { getFileIcon } from '@/utils/fileUtils'
|
import { getFileIcon } from '@/utils/fileUtils'
|
||||||
import { Message } from '@arco-design/web-vue'
|
|
||||||
|
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
@@ -351,6 +350,7 @@ interface Emits {
|
|||||||
(e: 'drop', event: DragEvent, targetIndex: number): void
|
(e: 'drop', event: DragEvent, targetIndex: number): void
|
||||||
(e: 'dragEnd'): void
|
(e: 'dragEnd'): void
|
||||||
(e: 'openConnectionDialog', editId?: string): void
|
(e: 'openConnectionDialog', editId?: string): void
|
||||||
|
(e: 'serverSelect', id: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
@@ -472,15 +472,11 @@ function stateText(profileId: string): string {
|
|||||||
return '未连接'
|
return '未连接'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSelect(p: { id: string; type: string }) {
|
// 同一服务器重复点击也上抛切换事件,由父级强制重载列表
|
||||||
|
function handleSelect(p: { id: string; type: string }) {
|
||||||
moreOpenId.value = null
|
moreOpenId.value = null
|
||||||
moreProfile.value = null
|
moreProfile.value = null
|
||||||
if (p.id === activeId.value) return
|
emit('serverSelect', p.id)
|
||||||
try {
|
|
||||||
await connectionManager.connect(p.id)
|
|
||||||
} catch (err) {
|
|
||||||
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit(p: { id: string }) {
|
function handleEdit(p: { id: string }) {
|
||||||
@@ -491,9 +487,12 @@ function handleEdit(p: { id: string }) {
|
|||||||
|
|
||||||
function handleDelete(p: { id: string; name: string }) {
|
function handleDelete(p: { id: string; name: string }) {
|
||||||
if (!window.confirm(`确定删除「${p.name}」?`)) return
|
if (!window.confirm(`确定删除「${p.name}」?`)) return
|
||||||
|
const wasActive = p.id === activeId.value
|
||||||
connectionManager.removeProfile(p.id)
|
connectionManager.removeProfile(p.id)
|
||||||
moreOpenId.value = null
|
moreOpenId.value = null
|
||||||
moreProfile.value = null
|
moreProfile.value = null
|
||||||
|
// 删除的是活跃服务器时连接已回落本地,补发切换让视图加载本地根目录
|
||||||
|
if (wasActive) emit('serverSelect', 'local-default')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -541,6 +540,9 @@ function handleDelete(p: { id: string; name: string }) {
|
|||||||
.fav-section > .section-content-wrap {
|
.fav-section > .section-content-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fav-section > .section-content-wrap.collapsed {
|
.fav-section > .section-content-wrap.collapsed {
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ interface Emits {
|
|||||||
(e: 'goToPath', path: string): void
|
(e: 'goToPath', path: string): void
|
||||||
(e: 'openFile', path: string): void
|
(e: 'openFile', path: string): void
|
||||||
(e: 'navigateToZipDirectory', path: string): void
|
(e: 'navigateToZipDirectory', path: string): void
|
||||||
(e: 'connectionChanged'): void
|
(e: 'connectionChanged', id: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
@@ -186,8 +186,8 @@ watch(() => props.openConnectionDialog, (v) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const onConnectionChanged = async (_id: string) => {
|
const onConnectionChanged = (id: string) => {
|
||||||
emit('connectionChanged')
|
emit('connectionChanged', id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onEditProfile = (id: string) => {
|
const onEditProfile = (id: string) => {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 文件剪贴板 Composable(应用内内存态单例)
|
||||||
|
* 复制/剪切 → 粘贴到其他目录;粘贴动作由 index.vue 执行
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { FileItem } from '@/types/file-system'
|
||||||
|
|
||||||
|
export type ClipboardMode = 'copy' | 'cut'
|
||||||
|
|
||||||
|
interface FileClipboardState {
|
||||||
|
mode: ClipboardMode
|
||||||
|
items: FileItem[]
|
||||||
|
fromDir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模块级单例状态(跨组件共享)
|
||||||
|
const state = ref<FileClipboardState | null>(null)
|
||||||
|
|
||||||
|
export function useFileClipboard() {
|
||||||
|
const clipboard = computed(() => state.value)
|
||||||
|
|
||||||
|
const clipboardEmpty = computed(() => !state.value || state.value.items.length === 0)
|
||||||
|
|
||||||
|
/** 设置复制项 */
|
||||||
|
const setCopy = (items: FileItem[], fromDir: string) => {
|
||||||
|
state.value = { mode: 'copy', items: [...items], fromDir }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置剪切项 */
|
||||||
|
const setCut = (items: FileItem[], fromDir: string) => {
|
||||||
|
state.value = { mode: 'cut', items: [...items], fromDir }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空剪贴板 */
|
||||||
|
const clear = () => {
|
||||||
|
state.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clipboard, clipboardEmpty, setCopy, setCut, clear }
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@ import { getExt } from '@/utils/fileUtils'
|
|||||||
import {
|
import {
|
||||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||||
isExcelFile, isWordFile, isCsvFile,
|
isExcelFile, isWordFile, isCsvFile,
|
||||||
isTextEditable, isConfigFile
|
isTextEditable, isConfigFile, isKnownBinary
|
||||||
} from '@/utils/fileTypeHelpers'
|
} from '@/utils/fileTypeHelpers'
|
||||||
import { useFileOperations } from './useFileOperations'
|
import { readFile as readFileApi, writeFile as writeFileApi } from '@/api'
|
||||||
import type { FileItem } from '@/types/file-system'
|
import type { FileItem } from '@/types/file-system'
|
||||||
|
|
||||||
export interface UseFileEditOptions {
|
export interface UseFileEditOptions {
|
||||||
@@ -47,15 +47,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
// 文件版本跟踪(用于防止切换文件后的过期更新)
|
// 文件版本跟踪(用于防止切换文件后的过期更新)
|
||||||
const fileVersion = ref(0)
|
const fileVersion = ref(0)
|
||||||
|
|
||||||
// 使用文件操作 composable
|
// 直接调用底层 API(错误由本文件 catch 统一处理)
|
||||||
const { readFile, writeFile } = useFileOperations({
|
|
||||||
onSuccess: (operation, data) => {
|
|
||||||
// 可以在这里添加成功处理逻辑
|
|
||||||
},
|
|
||||||
onError: (operation, error) => {
|
|
||||||
Message.error(`${operation} 失败: ${error.message}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取文件路径(从 FileItem 对象或字符串中提取)
|
* 获取文件路径(从 FileItem 对象或字符串中提取)
|
||||||
@@ -67,13 +59,8 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已知二进制扩展名(无需读取内容即可判定)
|
// 已知二进制扩展名判定委托给 fileTypeHelpers.isKnownBinary
|
||||||
const KNOWN_BINARY_EXTS = new Set([
|
// 不再本地维护列表,统一入口
|
||||||
'exe', 'dll', 'so', 'bin', 'dat', 'db', 'sqlite', 'pdb', 'idb',
|
|
||||||
'lib', 'obj', 'o', 'a', 'class', 'pyc', 'pyo', 'wasm',
|
|
||||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
|
|
||||||
'msi', 'jar', 'war', 'ear', 'apk'
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否为二进制文件(基于扩展名)
|
* 判断是否为二进制文件(基于扩展名)
|
||||||
@@ -85,7 +72,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
||||||
|
|
||||||
// 已知二进制扩展名 → 直接判定
|
// 已知二进制扩展名 → 直接判定
|
||||||
if (KNOWN_BINARY_EXTS.has(ext)) return true
|
if (isKnownBinary(path)) return true
|
||||||
|
|
||||||
// 媒体文件(可预览,不算二进制)
|
// 媒体文件(可预览,不算二进制)
|
||||||
const isMediaFile = isImageFile(path) ||
|
const isMediaFile = isImageFile(path) ||
|
||||||
@@ -191,7 +178,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
|
|
||||||
// Office 文件直接读取内容进行预览,跳过二进制检测
|
// Office 文件直接读取内容进行预览,跳过二进制检测
|
||||||
if (isExcelFile(filename) || isWordFile(filename)) {
|
if (isExcelFile(filename) || isWordFile(filename)) {
|
||||||
const content = await readFile(path)
|
const content = await readFileApi(path)
|
||||||
fileContent.value = content
|
fileContent.value = content
|
||||||
originalContent.value = content
|
originalContent.value = content
|
||||||
isEditMode.value = false
|
isEditMode.value = false
|
||||||
@@ -253,7 +240,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 对于无扩展名或未知类型文件,先尝试读取
|
// 对于无扩展名或未知类型文件,先尝试读取
|
||||||
const content = await readFile(path)
|
const content = await readFileApi(path)
|
||||||
|
|
||||||
// 检查文件大小
|
// 检查文件大小
|
||||||
const fileSize = content.length // UTF-16 字符数
|
const fileSize = content.length // UTF-16 字符数
|
||||||
@@ -352,7 +339,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await writeFile(targetPath, fileContent.value)
|
await writeFileApi(targetPath, fileContent.value)
|
||||||
originalContent.value = fileContent.value
|
originalContent.value = fileContent.value
|
||||||
|
|
||||||
// 清除草稿
|
// 清除草稿
|
||||||
@@ -499,6 +486,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
const clearContent = () => {
|
const clearContent = () => {
|
||||||
fileContent.value = ''
|
fileContent.value = ''
|
||||||
originalContent.value = ''
|
originalContent.value = ''
|
||||||
|
isBinaryFile.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 文件操作 Composable
|
* 文件操作 Composable(纯代理层)
|
||||||
* 提供文件读取、写入、删除等基础操作,包括 ZIP 文件浏览
|
* 直接委托底层 API,不做 try-catch,错误由调用方处理
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Message } from '@arco-design/web-vue'
|
|
||||||
import { getPathSeparator } from '@/utils/fileUtils'
|
import { getPathSeparator } from '@/utils/fileUtils'
|
||||||
import {
|
import {
|
||||||
listDir,
|
listDir,
|
||||||
@@ -13,255 +12,123 @@ import {
|
|||||||
createFile,
|
createFile,
|
||||||
createDir,
|
createDir,
|
||||||
renamePath as renamePathApi,
|
renamePath as renamePathApi,
|
||||||
|
copyPath as copyPathApi,
|
||||||
|
movePath as movePathApi,
|
||||||
listZipContents as listZipContentsApi,
|
listZipContents as listZipContentsApi,
|
||||||
extractFileFromZip,
|
extractFileFromZip,
|
||||||
extractFileFromZipToTemp as extractZipToTempApi,
|
extractFileFromZipToTemp as extractZipToTempApi,
|
||||||
getFileServerURL as getFileServerUrlApi
|
getFileServerURL as getFileServerUrlApi
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import type { FileItem, FileOperationResult } from '@/types/file-system'
|
import type { FileItem } from '@/types/file-system'
|
||||||
|
|
||||||
export interface UseFileOperationsOptions {
|
export interface UseFileOperationsOptions {
|
||||||
onSuccess?: (operation: string, data: any) => void
|
onSuccess?: (operation: string, data: any) => void
|
||||||
onError?: (operation: string, error: Error) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件操作结果
|
* 文件操作结果
|
||||||
*/
|
*/
|
||||||
export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
||||||
const { onSuccess, onError } = options
|
const { onSuccess } = options
|
||||||
|
|
||||||
/**
|
/** 列出目录内容 */
|
||||||
* 列出目录内容
|
|
||||||
*/
|
|
||||||
const listDirectory = async (path: string): Promise<FileItem[]> => {
|
const listDirectory = async (path: string): Promise<FileItem[]> => {
|
||||||
try {
|
|
||||||
const result = await listDir(path)
|
const result = await listDir(path)
|
||||||
onSuccess?.('listDirectory', result)
|
onSuccess?.('listDirectory', result)
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('listDirectory', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 读取文件内容 */
|
||||||
* 读取文件内容
|
|
||||||
*/
|
|
||||||
const readFile = async (path: string): Promise<string> => {
|
const readFile = async (path: string): Promise<string> => {
|
||||||
try {
|
|
||||||
const content = await readFileApi(path)
|
const content = await readFileApi(path)
|
||||||
onSuccess?.('readFile', { path, size: content.length })
|
onSuccess?.('readFile', { path, size: content.length })
|
||||||
return content
|
return content
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('readFile', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 写入文件内容 */
|
||||||
* 写入文件内容
|
const writeFile = async (path: string, content: string): Promise<void> => {
|
||||||
*/
|
|
||||||
const writeFile = async (
|
|
||||||
path: string,
|
|
||||||
content: string,
|
|
||||||
createBackup: boolean = false
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await writeFileApi(path, content)
|
await writeFileApi(path, content)
|
||||||
onSuccess?.('writeFile', { path, size: content.length })
|
onSuccess?.('writeFile', { path, size: content.length })
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('writeFile', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 删除路径 */
|
||||||
* 删除路径(文件或目录),返回被删除的文件信息
|
|
||||||
*/
|
|
||||||
const deletePath = async (path: string): Promise<FileItem> => {
|
const deletePath = async (path: string): Promise<FileItem> => {
|
||||||
try {
|
|
||||||
const result = await deletePathApi(path)
|
const result = await deletePathApi(path)
|
||||||
onSuccess?.('deletePath', { path, result })
|
onSuccess?.('deletePath', { path, result })
|
||||||
return result as FileItem
|
return result as FileItem
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('deletePath', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 创建新文件 */
|
||||||
* 创建新文件,返回创建的文件信息
|
const createNewFile = async (dirPath: string, filename: string): Promise<FileItem> => {
|
||||||
*/
|
|
||||||
const createNewFile = async (
|
|
||||||
dirPath: string,
|
|
||||||
filename: string
|
|
||||||
): Promise<FileItem> => {
|
|
||||||
try {
|
|
||||||
const result = await createFile(dirPath, filename)
|
const result = await createFile(dirPath, filename)
|
||||||
onSuccess?.('createFile', { dirPath, filename, result })
|
onSuccess?.('createFile', { dirPath, filename, result })
|
||||||
return result as FileItem
|
return result as FileItem
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('createFile', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 创建新目录 */
|
||||||
* 创建新目录,返回创建的目录信息
|
|
||||||
*/
|
|
||||||
const createNewDir = async (parentPath: string, dirname: string): Promise<FileItem> => {
|
const createNewDir = async (parentPath: string, dirname: string): Promise<FileItem> => {
|
||||||
try {
|
|
||||||
const result = await createDir(parentPath, dirname)
|
const result = await createDir(parentPath, dirname)
|
||||||
onSuccess?.('createDir', { parentPath, dirname, result })
|
onSuccess?.('createDir', { parentPath, dirname, result })
|
||||||
return result as FileItem
|
return result as FileItem
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('createDir', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 重命名 */
|
||||||
* 重命名文件或目录,返回新文件信息
|
|
||||||
*/
|
|
||||||
const rename = async (oldPath: string, newName: string): Promise<FileItem> => {
|
const rename = async (oldPath: string, newName: string): Promise<FileItem> => {
|
||||||
// 构造新路径
|
|
||||||
const separator = getPathSeparator(oldPath)
|
const separator = getPathSeparator(oldPath)
|
||||||
const parentPath = oldPath.substring(
|
const parentPath = oldPath.substring(
|
||||||
0,
|
0,
|
||||||
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
|
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
|
||||||
)
|
)
|
||||||
const newPath = parentPath + separator + newName
|
const newPath = parentPath + separator + newName
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await renamePathApi(oldPath, newPath)
|
const result = await renamePathApi(oldPath, newPath)
|
||||||
onSuccess?.('rename', { oldPath, newPath, result })
|
onSuccess?.('rename', { oldPath, newPath, result })
|
||||||
return result as FileItem
|
return result as FileItem
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('rename', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 复制(递归,进度经 transfer-progress 事件) */
|
||||||
* 复制文件或目录
|
|
||||||
*/
|
|
||||||
const copy = async (fromPath: string, toPath: string): Promise<void> => {
|
const copy = async (fromPath: string, toPath: string): Promise<void> => {
|
||||||
try {
|
await copyPathApi(fromPath, toPath)
|
||||||
// TODO: 实现复制逻辑
|
|
||||||
Message.warning('复制功能暂未实现')
|
|
||||||
onSuccess?.('copy', { fromPath, toPath })
|
onSuccess?.('copy', { fromPath, toPath })
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('copy', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 移动(跨盘自动回退复制+删除) */
|
||||||
* 移动文件或目录
|
|
||||||
*/
|
|
||||||
const move = async (fromPath: string, toPath: string): Promise<void> => {
|
const move = async (fromPath: string, toPath: string): Promise<void> => {
|
||||||
try {
|
await movePathApi(fromPath, toPath)
|
||||||
// TODO: 实现移动逻辑
|
|
||||||
Message.warning('移动功能暂未实现')
|
|
||||||
onSuccess?.('move', { fromPath, toPath })
|
onSuccess?.('move', { fromPath, toPath })
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('move', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 列出 ZIP 文件内容 */
|
||||||
* 列出 ZIP 文件内容
|
|
||||||
*/
|
|
||||||
const listZipContents = async (zipPath: string): Promise<FileItem[]> => {
|
const listZipContents = async (zipPath: string): Promise<FileItem[]> => {
|
||||||
try {
|
|
||||||
const result = await listZipContentsApi(zipPath)
|
const result = await listZipContentsApi(zipPath)
|
||||||
onSuccess?.('listZipContents', { zipPath, count: result.length })
|
onSuccess?.('listZipContents', { zipPath, count: result.length })
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('listZipContents', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 从 ZIP 中提取文件内容 */
|
||||||
* 从 ZIP 中提取文件内容(文本)
|
|
||||||
*/
|
|
||||||
const extractZipFile = async (zipPath: string, filePath: string): Promise<string> => {
|
const extractZipFile = async (zipPath: string, filePath: string): Promise<string> => {
|
||||||
try {
|
|
||||||
const content = await extractFileFromZip(zipPath, filePath)
|
const content = await extractFileFromZip(zipPath, filePath)
|
||||||
onSuccess?.('extractZipFile', { zipPath, filePath, size: content.length })
|
onSuccess?.('extractZipFile', { zipPath, filePath, size: content.length })
|
||||||
return content
|
return content
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('extractZipFile', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 从 ZIP 中提取文件到临时目录 */
|
||||||
* 从 ZIP 中提取文件到临时目录(二进制文件,如图片)
|
|
||||||
*/
|
|
||||||
const extractZipFileToTemp = async (zipPath: string, filePath: string): Promise<string> => {
|
const extractZipFileToTemp = async (zipPath: string, filePath: string): Promise<string> => {
|
||||||
try {
|
|
||||||
const tempPath = await extractZipToTempApi(zipPath, filePath)
|
const tempPath = await extractZipToTempApi(zipPath, filePath)
|
||||||
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
|
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
|
||||||
return tempPath
|
return tempPath
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('extractZipFileToTemp', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 获取文件服务器 URL */
|
||||||
* 获取文件服务器 URL
|
|
||||||
*/
|
|
||||||
const getFileServerURL = async (): Promise<string> => {
|
const getFileServerURL = async (): Promise<string> => {
|
||||||
try {
|
|
||||||
const url = await getFileServerUrlApi()
|
const url = await getFileServerUrlApi()
|
||||||
onSuccess?.('getFileServerURL', { url })
|
onSuccess?.('getFileServerURL', { url })
|
||||||
return url
|
return url
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error
|
|
||||||
onError?.('getFileServerURL', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 基础操作
|
listDirectory, readFile, writeFile, deletePath,
|
||||||
listDirectory,
|
createNewFile, createNewDir,
|
||||||
readFile,
|
rename, copy, move,
|
||||||
writeFile,
|
listZipContents, extractZipFile, extractZipFileToTemp, getFileServerURL
|
||||||
deletePath,
|
|
||||||
|
|
||||||
// 创建操作
|
|
||||||
createNewFile,
|
|
||||||
createNewDir,
|
|
||||||
|
|
||||||
// 高级操作
|
|
||||||
rename,
|
|
||||||
copy,
|
|
||||||
move,
|
|
||||||
|
|
||||||
// ZIP 操作
|
|
||||||
listZipContents,
|
|
||||||
extractZipFile,
|
|
||||||
extractZipFileToTemp,
|
|
||||||
getFileServerURL
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注意:FileItem 类型已统一定义在 @/types/file-system.ts
|
|
||||||
@@ -22,6 +22,7 @@ const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB
|
|||||||
// 缓存检测结果
|
// 缓存检测结果
|
||||||
const contentDetectCache = new Map<string, { timestamp: number; result: any }>()
|
const contentDetectCache = new Map<string, { timestamp: number; result: any }>()
|
||||||
const CACHE_TTL = 60000 // 1分钟缓存
|
const CACHE_TTL = 60000 // 1分钟缓存
|
||||||
|
const CACHE_MAX_SIZE = 200
|
||||||
|
|
||||||
// ====== URL 工具函数(导出供外部使用) ======
|
// ====== URL 工具函数(导出供外部使用) ======
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ export function useFilePreview() {
|
|||||||
try {
|
try {
|
||||||
const result = await detectFileTypeByContent(path)
|
const result = await detectFileTypeByContent(path)
|
||||||
const data = { category: result.category, ext: result.extension }
|
const data = { category: result.category, ext: result.extension }
|
||||||
|
if (contentDetectCache.size >= CACHE_MAX_SIZE) contentDetectCache.clear()
|
||||||
contentDetectCache.set(path, { timestamp: Date.now(), result: data })
|
contentDetectCache.set(path, { timestamp: Date.now(), result: data })
|
||||||
return data
|
return data
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
|||||||
* 导航到指定路径
|
* 导航到指定路径
|
||||||
*/
|
*/
|
||||||
const navigate = async (path: string) => {
|
const navigate = async (path: string) => {
|
||||||
if (!path || path === filePath.value) return
|
if (!path || path === filePath.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const normalizedPath = normalizePathSeparators(path)
|
const normalizedPath = normalizePathSeparators(path)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
@drop="handleDrop"
|
@drop="handleDrop"
|
||||||
@drag-end="handleDragEnd"
|
@drag-end="handleDragEnd"
|
||||||
@open-connection-dialog="handleOpenConnectionDialog"
|
@open-connection-dialog="handleOpenConnectionDialog"
|
||||||
|
@server-select="handleConnectionChanged"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 右侧工作区:面包屑工具栏 + 文件列表/编辑器 -->
|
<!-- 右侧工作区:面包屑工具栏 + 文件列表/编辑器 -->
|
||||||
@@ -93,6 +94,9 @@
|
|||||||
@close="hideContextMenu"
|
@close="hideContextMenu"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 传输进度面板(复制/移动/下载/上传共用) -->
|
||||||
|
<TransferPanel />
|
||||||
|
|
||||||
<!-- 输入对话框 -->
|
<!-- 输入对话框 -->
|
||||||
<a-modal
|
<a-modal
|
||||||
v-model:visible="inputDialogVisible"
|
v-model:visible="inputDialogVisible"
|
||||||
@@ -122,6 +126,7 @@ import { Message, Modal } from '@arco-design/web-vue'
|
|||||||
import { On, Off } from '@wailsio/events'
|
import { On, Off } from '@wailsio/events'
|
||||||
import { renderMarkdown, parseMarkdown, isMarkedReady, preloadMarked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
|
import { renderMarkdown, parseMarkdown, isMarkedReady, preloadMarked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import { useTransferStore } from '@/stores/transfer'
|
||||||
|
|
||||||
// 导入子组件
|
// 导入子组件
|
||||||
import Toolbar from './components/Toolbar.vue'
|
import Toolbar from './components/Toolbar.vue'
|
||||||
@@ -129,12 +134,15 @@ import Sidebar from './components/Sidebar.vue'
|
|||||||
import FileListPanel from './components/FileListPanel.vue'
|
import FileListPanel from './components/FileListPanel.vue'
|
||||||
import FileEditorPanel from './components/FileEditorPanel.vue'
|
import FileEditorPanel from './components/FileEditorPanel.vue'
|
||||||
import ContextMenu from './components/ContextMenu.vue'
|
import ContextMenu from './components/ContextMenu.vue'
|
||||||
|
import TransferPanel from '@/components/TransferPanel.vue'
|
||||||
|
|
||||||
// 导入 Composables
|
// 导入 Composables
|
||||||
import { useFileOperations } from './composables/useFileOperations'
|
import { useFileOperations } from './composables/useFileOperations'
|
||||||
|
import { useFileClipboard } from './composables/useFileClipboard'
|
||||||
import { useFavorites } from './composables/useFavorites'
|
import { useFavorites } from './composables/useFavorites'
|
||||||
import { usePathNavigation } from './composables/usePathNavigation'
|
import { usePathNavigation } from './composables/usePathNavigation'
|
||||||
import { useFilePreview, resolveFileUrl, resolveFileServerBase } from './composables/useFilePreview'
|
import { useFilePreview, resolveFileUrl, resolveFileServerBase } from './composables/useFilePreview'
|
||||||
|
import { resolvePreviewHandler, resolvePreviewComponent } from '@/plugin/registry'
|
||||||
import { useFileEdit } from './composables/useFileEdit'
|
import { useFileEdit } from './composables/useFileEdit'
|
||||||
import { useCommonPaths } from './composables/useCommonPaths'
|
import { useCommonPaths } from './composables/useCommonPaths'
|
||||||
import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMultiPreview'
|
import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMultiPreview'
|
||||||
@@ -142,9 +150,11 @@ import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMult
|
|||||||
// 导入工具函数
|
// 导入工具函数
|
||||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||||
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
|
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
|
||||||
import { listDir, saveBase64File, watchFile, unwatchFile } from '@/api/system'
|
import { listDir, saveBase64File, watchFile, unwatchFile, pickDirectory } from '@/api/system'
|
||||||
import { connectionManager } from '@/api/connection-manager'
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||||
|
import { isAutoRestoreDisabled, clearAutoRestoreDisabled } from '@/utils/freezeWatchdog'
|
||||||
|
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||||
import { createResizeHandler } from '@/utils/resize'
|
import { createResizeHandler } from '@/utils/resize'
|
||||||
|
|
||||||
// 导入类型
|
// 导入类型
|
||||||
@@ -284,17 +294,14 @@ const fileListPanelRef = ref()
|
|||||||
|
|
||||||
// ========== Composables 初始化 ==========
|
// ========== Composables 初始化 ==========
|
||||||
|
|
||||||
// 文件操作
|
// 文件操作(纯代理层,copy/move 经此调用)
|
||||||
const fileOps = useFileOperations({
|
const fileOps = useFileOperations()
|
||||||
onSuccess: (operation, data) => {
|
|
||||||
// 操作成功处理
|
// 文件剪贴板(复制/剪切,内存态单例)
|
||||||
},
|
const { clipboard, clipboardEmpty, setCopy, setCut, clear: clearClipboard } = useFileClipboard()
|
||||||
onError: (operation, error) => {
|
|
||||||
// 注意:具体的操作(如重命名、删除等)已经有自己的错误处理
|
// 传输进度面板
|
||||||
// 这里只记录日志,避免重复显示错误消息
|
const transferStore = useTransferStore()
|
||||||
console.error(`[FileOperation] ${operation} 失败:`, error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 收藏夹
|
// 收藏夹
|
||||||
const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin, updateFavoritePath, onLongPressStart, onLongPressCancel, onDragStart, onDragOver, onDrop, onDragEnd } = useFavorites()
|
const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin, updateFavoritePath, onLongPressStart, onLongPressCancel, onDragStart, onDragOver, onDrop, onDragEnd } = useFavorites()
|
||||||
@@ -351,13 +358,21 @@ const sidebarConfig = computed(() => ({
|
|||||||
draggingState: draggingState.value
|
draggingState: draggingState.value
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// 被剪切的文件路径(仅剪切来源为当前目录时高亮)
|
||||||
|
const cutFilePaths = computed(() =>
|
||||||
|
clipboard.value?.mode === 'cut' && clipboard.value.fromDir === filePath.value
|
||||||
|
? clipboard.value.items.map(i => i.path)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
|
||||||
// 文件列表面板配置
|
// 文件列表面板配置
|
||||||
const fileListPanelConfig = computed(() => ({
|
const fileListPanelConfig = computed(() => ({
|
||||||
fileList: filteredFileList.value,
|
fileList: filteredFileList.value,
|
||||||
fileLoading: fileLoading.value,
|
fileLoading: fileLoading.value,
|
||||||
selectedFileItem: selectedFileItem.value,
|
selectedFileItem: selectedFileItem.value,
|
||||||
editingFilePath: editingFilePath.value,
|
editingFilePath: editingFilePath.value,
|
||||||
editingFileName: editingFileName.value
|
editingFileName: editingFileName.value,
|
||||||
|
cutPaths: cutFilePaths.value
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Markdown 渲染状态(异步加载 marked 后触发重渲染)
|
// Markdown 渲染状态(异步加载 marked 后触发重渲染)
|
||||||
@@ -441,7 +456,10 @@ const fileEditorPanelConfig = computed(() => {
|
|||||||
fileMtime: selectedFileItem.value?.modified_time || '',
|
fileMtime: selectedFileItem.value?.modified_time || '',
|
||||||
// 多 tab
|
// 多 tab
|
||||||
previewTabs: multiPreview.tabs.value,
|
previewTabs: multiPreview.tabs.value,
|
||||||
activeTabId: multiPreview.activeTabId.value
|
activeTabId: multiPreview.activeTabId.value,
|
||||||
|
// 预览插件(优先于内置 v-if 链;.drawio/.dio 命中,其余为 null)
|
||||||
|
activePlugin: currentFileName ? resolvePreviewHandler(currentFileName) : null,
|
||||||
|
activePluginComponent: currentFileName ? resolvePreviewComponent(currentFileName) : null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -462,8 +480,9 @@ const handleRefresh = async () => {
|
|||||||
|
|
||||||
// 文件变化事件处理:自动刷新预览(有未保存修改时跳过)
|
// 文件变化事件处理:自动刷新预览(有未保存修改时跳过)
|
||||||
const handleFileChanged = async (event: any) => {
|
const handleFileChanged = async (event: any) => {
|
||||||
// Wails v3 On 回调参数是 WailsEvent 对象,路径在 event.data
|
const payload = event?.data
|
||||||
const changedPath = event?.data ?? ''
|
const changedPath = typeof payload === 'string' ? payload : payload?.path ?? ''
|
||||||
|
const mtime = typeof payload === 'object' ? payload?.mtime : undefined
|
||||||
if (!changedPath || !selectedFileItem.value) return
|
if (!changedPath || !selectedFileItem.value) return
|
||||||
|
|
||||||
// 标准化路径比较
|
// 标准化路径比较
|
||||||
@@ -473,43 +492,51 @@ const handleFileChanged = async (event: any) => {
|
|||||||
// 有未保存修改时不覆盖用户编辑
|
// 有未保存修改时不覆盖用户编辑
|
||||||
if (originalContent.value !== undefined && fileContent.value !== originalContent.value) return
|
if (originalContent.value !== undefined && fileContent.value !== originalContent.value) return
|
||||||
|
|
||||||
|
// 非文本文件(图片/视频等):刷新预览 URL 并追加文件变更时间打破浏览器缓存
|
||||||
|
if (isMediaPreviewable(selectedFileItem.value.name)) {
|
||||||
|
const url = await resolveFileUrl(changedPath)
|
||||||
|
const sep = url.includes('?') ? '&' : '?'
|
||||||
|
previewUrl.value = `${url}${sep}_t=${mtime ?? Date.now()}`
|
||||||
|
Message.success('文件已更新')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本文件:重新加载内容
|
||||||
await loadFileContent(changedPath)
|
await loadFileContent(changedPath)
|
||||||
Message.success('文件已更新')
|
Message.success('文件已更新')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 程序化切换 profile 时抑制自动导航(如打开收藏/tab切换)
|
// 连接切换唯一入口:谁切换连接谁负责刷新常用路径并设根路径加载。
|
||||||
let _suppressAutoNav = false
|
// 同路径也强制重载:navigate 同路径早退时列表会永远不加载
|
||||||
let _lastActiveId = connectionManager.activeProfile?.id
|
const switchConnection = async (profileId: string) => {
|
||||||
|
await connectionManager.connect(profileId)
|
||||||
// 连接切换后重置路径并刷新文件列表(仅在活跃连接实际变化时触发)
|
|
||||||
connectionManager.onStateChange(async (state) => {
|
|
||||||
const currentId = connectionManager.activeProfile?.id
|
|
||||||
if (state !== 'connected') return
|
|
||||||
if (currentId === _lastActiveId) return
|
|
||||||
_lastActiveId = currentId
|
|
||||||
if (_suppressAutoNav) return
|
|
||||||
await loadCommonPaths()
|
await loadCommonPaths()
|
||||||
const targetPath = connectionManager.isRemote() ? '/' : 'C:/'
|
const targetPath = connectionManager.isRemote() ? '/' : 'C:/'
|
||||||
filePath.value = targetPath
|
if (filePath.value === targetPath) {
|
||||||
await loadDirectory(targetPath)
|
await loadDirectory(targetPath)
|
||||||
})
|
} else {
|
||||||
|
await navigate(targetPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 侧边栏服务器行/连接指示器选择:统一走 switchConnection,失败在此提示
|
||||||
|
const handleConnectionChanged = async (profileId: string) => {
|
||||||
|
try {
|
||||||
|
await switchConnection(profileId)
|
||||||
|
} catch (err) {
|
||||||
|
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearchKeywordUpdate = (keyword: string) => {
|
const handleSearchKeywordUpdate = (keyword: string) => {
|
||||||
searchKeyword.value = keyword
|
searchKeyword.value = keyword
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户主动切换连接时重置到根路径
|
|
||||||
const handleConnectionChanged = async () => {
|
|
||||||
await loadCommonPaths()
|
|
||||||
await navigate(connectionManager.isRemote() ? '/' : 'C:/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleGoToPath = async (path: string) => {
|
const handleGoToPath = async (path: string) => {
|
||||||
// 历史记录下拉选择时,检查是否需要切换 profile
|
// 历史记录下拉选择时,检查是否需要切换 profile
|
||||||
const targetProfileId = getProfileIdForPath(path)
|
const targetProfileId = getProfileIdForPath(path)
|
||||||
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
||||||
_suppressAutoNav = true
|
await connectionManager.connect(targetProfileId)
|
||||||
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
|
|
||||||
}
|
}
|
||||||
await navigate(path)
|
await navigate(path)
|
||||||
}
|
}
|
||||||
@@ -615,7 +642,6 @@ const handleOpenFavorite = async (file: FavoriteFile) => {
|
|||||||
const needSwitch = file.profileId && file.profileId !== currentProfileId
|
const needSwitch = file.profileId && file.profileId !== currentProfileId
|
||||||
|
|
||||||
if (needSwitch) {
|
if (needSwitch) {
|
||||||
_suppressAutoNav = true
|
|
||||||
try {
|
try {
|
||||||
await connectionManager.connect(file.profileId)
|
await connectionManager.connect(file.profileId)
|
||||||
await loadCommonPaths()
|
await loadCommonPaths()
|
||||||
@@ -623,8 +649,6 @@ const handleOpenFavorite = async (file: FavoriteFile) => {
|
|||||||
console.error('切换连接失败:', e)
|
console.error('切换连接失败:', e)
|
||||||
Message.error(`无法连接: ${e instanceof Error ? e.message : String(e)}`)
|
Message.error(`无法连接: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
return
|
return
|
||||||
} finally {
|
|
||||||
_suppressAutoNav = false
|
|
||||||
}
|
}
|
||||||
} else if (!file.profileId) {
|
} else if (!file.profileId) {
|
||||||
// 无 profileId(旧数据),按路径格式自动切换连接
|
// 无 profileId(旧数据),按路径格式自动切换连接
|
||||||
@@ -633,8 +657,6 @@ const handleOpenFavorite = async (file: FavoriteFile) => {
|
|||||||
const isCurrentlyRemote = connectionManager.isRemote()
|
const isCurrentlyRemote = connectionManager.isRemote()
|
||||||
|
|
||||||
if (shouldBeRemote !== isCurrentlyRemote) {
|
if (shouldBeRemote !== isCurrentlyRemote) {
|
||||||
_suppressAutoNav = true
|
|
||||||
try {
|
|
||||||
if (shouldBeRemote) {
|
if (shouldBeRemote) {
|
||||||
const remoteProfile = connectionManager.profiles.find(p =>
|
const remoteProfile = connectionManager.profiles.find(p =>
|
||||||
p.type === 'remote' || p.type === 'sftp' || p.type === 'oss'
|
p.type === 'remote' || p.type === 'sftp' || p.type === 'oss'
|
||||||
@@ -646,9 +668,6 @@ const handleOpenFavorite = async (file: FavoriteFile) => {
|
|||||||
connectionManager.disconnect()
|
connectionManager.disconnect()
|
||||||
}
|
}
|
||||||
await loadCommonPaths()
|
await loadCommonPaths()
|
||||||
} finally {
|
|
||||||
_suppressAutoNav = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,7 +887,9 @@ const handleContextMenu = (event: MouseEvent, file: FileItem | null = null) => {
|
|||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
context: file ? 'file' : 'blank',
|
context: file ? 'file' : 'blank',
|
||||||
selectedFile: file || undefined
|
selectedFile: file || undefined,
|
||||||
|
isSftp: connectionManager.isSftp(),
|
||||||
|
clipboardEmpty: clipboardEmpty.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -887,6 +908,24 @@ const handleContextMenuAction = async (action: string, payload?: any) => {
|
|||||||
case 'openWithSystem':
|
case 'openWithSystem':
|
||||||
Message.info('系统打开功能开发中...')
|
Message.info('系统打开功能开发中...')
|
||||||
break
|
break
|
||||||
|
case 'copy':
|
||||||
|
if (payload) {
|
||||||
|
handleCopyFiles([payload])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'cut':
|
||||||
|
if (payload) {
|
||||||
|
handleCutFiles([payload])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'download':
|
||||||
|
if (payload) {
|
||||||
|
await handleDownloadToLocal(payload)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'paste':
|
||||||
|
await handlePasteClipboard()
|
||||||
|
break
|
||||||
case 'rename':
|
case 'rename':
|
||||||
if (payload) {
|
if (payload) {
|
||||||
handleStartEditing(payload.path, payload.name)
|
handleStartEditing(payload.path, payload.name)
|
||||||
@@ -901,6 +940,145 @@ const handleContextMenuAction = async (action: string, payload?: any) => {
|
|||||||
hideContextMenu()
|
hideContextMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 文件剪贴板(复制/剪切/粘贴) ==========
|
||||||
|
|
||||||
|
/** 复制到文件剪贴板(仅本机面板;远程面板给出提示而非静默) */
|
||||||
|
const handleCopyFiles = (items: FileItem[]) => {
|
||||||
|
if (connectionManager.isRemote()) {
|
||||||
|
Message.info('当前面板暂不支持复制')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCopy(items, filePath.value)
|
||||||
|
Message.success(`已复制 ${items.length} 个项目`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剪切到文件剪贴板(仅本机面板;远程面板给出提示而非静默) */
|
||||||
|
const handleCutFiles = (items: FileItem[]) => {
|
||||||
|
if (connectionManager.isRemote()) {
|
||||||
|
Message.info('当前面板暂不支持剪切')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCut(items, filePath.value)
|
||||||
|
Message.success(`已剪切 ${items.length} 个项目`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 目标目录内的不重名文件名:「名称 - 副本」「名称 - 副本 (2)」(扩展名前插入) */
|
||||||
|
const uniqueNameInList = (name: string): string => {
|
||||||
|
const exists = (n: string) => fileList.value.some(f => f.name.toLowerCase() === n.toLowerCase())
|
||||||
|
if (!exists(name)) return name
|
||||||
|
const dot = name.lastIndexOf('.')
|
||||||
|
const base = dot > 0 ? name.slice(0, dot) : name
|
||||||
|
const ext = dot > 0 ? name.slice(dot) : ''
|
||||||
|
let candidate = `${base} - 副本${ext}`
|
||||||
|
let i = 2
|
||||||
|
while (exists(candidate)) {
|
||||||
|
candidate = `${base} - 副本 (${i})${ext}`
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 粘贴文件剪贴板(copy → 复制,cut → 移动;仅本地面板) */
|
||||||
|
const handlePasteClipboard = async () => {
|
||||||
|
const clip = clipboard.value
|
||||||
|
if (!clip || clip.items.length === 0) {
|
||||||
|
Message.info('剪贴板为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (connectionManager.isRemote()) {
|
||||||
|
Message.info('当前面板暂不支持粘贴,可使用拖拽上传')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const targetDir = filePath.value
|
||||||
|
if (!targetDir) return
|
||||||
|
|
||||||
|
// 剪切源即目标目录时无操作(继续执行会把源文件改名成副本),Explorer 语义是清空剪贴板
|
||||||
|
if (clip.mode === 'cut' && clip.fromDir === targetDir) {
|
||||||
|
clearClipboard()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = 0
|
||||||
|
for (const item of clip.items) {
|
||||||
|
const dstName = uniqueNameInList(item.name)
|
||||||
|
const dstPath = targetDir + getPathSeparator(targetDir) + dstName
|
||||||
|
try {
|
||||||
|
if (clip.mode === 'copy') {
|
||||||
|
await fileOps.copy(item.path, dstPath)
|
||||||
|
} else {
|
||||||
|
await fileOps.move(item.path, dstPath)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failed++
|
||||||
|
console.error('[Clipboard] 粘贴失败:', item.path, error)
|
||||||
|
Message.error(`粘贴失败: ${item.name} - ${error instanceof Error ? error.message : error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clip.mode === 'cut' && failed === 0) {
|
||||||
|
clearClipboard()
|
||||||
|
}
|
||||||
|
await loadDirectory(targetDir)
|
||||||
|
if (failed === 0) {
|
||||||
|
Message.success(`已粘贴 ${clip.items.length} 个项目`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SFTP 文件/目录下载到本机(目录选择器 → 流式下载) */
|
||||||
|
const handleDownloadToLocal = async (file: FileItem) => {
|
||||||
|
try {
|
||||||
|
const dstDir = await pickDirectory()
|
||||||
|
if (!dstDir) return
|
||||||
|
|
||||||
|
const localPath = await connectionManager.getTransport().downloadToFile(file.path, dstDir)
|
||||||
|
Message.success(`已下载到: ${localPath}`)
|
||||||
|
} catch (error) {
|
||||||
|
Message.error(`下载失败: ${error instanceof Error ? error.message : error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 外部文件拖入(OS 资源管理器 → 文件列表) ==========
|
||||||
|
|
||||||
|
const handleExternalDrop = async (paths: string[]) => {
|
||||||
|
if (!paths || paths.length === 0) return
|
||||||
|
const targetDir = filePath.value
|
||||||
|
if (!targetDir) return
|
||||||
|
|
||||||
|
const transport = connectionManager.getTransport()
|
||||||
|
try {
|
||||||
|
if (connectionManager.isSftp()) {
|
||||||
|
// SFTP 面板:上传到服务器当前目录(目录递归)
|
||||||
|
for (const p of paths) {
|
||||||
|
await transport.uploadFromFile(p, targetDir)
|
||||||
|
}
|
||||||
|
await loadDirectory(targetDir)
|
||||||
|
Message.success(`已上传 ${paths.length} 个项目`)
|
||||||
|
} else if (connectionManager.isRemote()) {
|
||||||
|
Message.info('当前面板暂不支持拖入文件')
|
||||||
|
} else {
|
||||||
|
// 本机面板:复制进当前目录(重名自动副本)
|
||||||
|
for (const p of paths) {
|
||||||
|
const name = getFileName(p)
|
||||||
|
if (!name) continue
|
||||||
|
const dstName = uniqueNameInList(name)
|
||||||
|
await transport.copyPath(p, targetDir + getPathSeparator(targetDir) + dstName)
|
||||||
|
}
|
||||||
|
await loadDirectory(targetDir)
|
||||||
|
Message.success(`已复制 ${paths.length} 个项目`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Message.error(`拖入失败: ${error instanceof Error ? error.message : error}`)
|
||||||
|
await loadDirectory(targetDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 external-file-drop 事件 payload(事件体或 .data 均可) */
|
||||||
|
const handleExternalDropEvent = (event: unknown) => {
|
||||||
|
const data: any = parseWailsEventData(event)
|
||||||
|
const files = Array.isArray(data) ? data : (data?.files ?? [])
|
||||||
|
handleExternalDrop(files.map(String))
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 创建文件/文件夹 ==========
|
// ========== 创建文件/文件夹 ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1148,16 +1326,21 @@ const isMediaPreviewable = (filename: string): boolean => {
|
|||||||
const activateTab = async (tab: PreviewTab) => {
|
const activateTab = async (tab: PreviewTab) => {
|
||||||
// 如果 tab 属于不同 profile,自动切换连接
|
// 如果 tab 属于不同 profile,自动切换连接
|
||||||
if (tab.profileId && tab.profileId !== connectionManager.activeProfile?.id) {
|
if (tab.profileId && tab.profileId !== connectionManager.activeProfile?.id) {
|
||||||
_suppressAutoNav = true
|
|
||||||
try {
|
try {
|
||||||
await connectionManager.connect(tab.profileId)
|
await connectionManager.connect(tab.profileId)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('切换连接失败:', e)
|
console.error('切换连接失败:', e)
|
||||||
} finally {
|
|
||||||
_suppressAutoNav = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selectedFileItem.value = tab.fileItem
|
selectedFileItem.value = tab.fileItem
|
||||||
|
|
||||||
|
// 更新面包屑和文件列表到文件所在目录
|
||||||
|
const dir = getParentPath(tab.fileItem.path)
|
||||||
|
if (dir && dir !== filePath.value) {
|
||||||
|
filePath.value = dir
|
||||||
|
loadDirectory(dir).catch(e => Message.error(`目录加载失败: ${e}`))
|
||||||
|
}
|
||||||
|
|
||||||
if (tab.loaded) {
|
if (tab.loaded) {
|
||||||
restoreTabState(tab)
|
restoreTabState(tab)
|
||||||
} else {
|
} else {
|
||||||
@@ -1262,12 +1445,24 @@ const selectFile = async (path: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载顺序令牌:快速连续点击文件时,先发起的慢请求(远程下载/大文件)
|
||||||
|
// 若后完成会覆盖新文件的 previewUrl,导致图片预览 src 指向错误文件(显示为破损小方框)。
|
||||||
|
// 每次进入 loadFileContent 递增令牌,await 返回后校验,过期请求直接丢弃。
|
||||||
|
let loadFileSeq = 0
|
||||||
|
|
||||||
const loadFileContent = async (path: string) => {
|
const loadFileContent = async (path: string) => {
|
||||||
// 切换文件时先取消前一个文件的监听
|
// 切换文件时先取消前一个文件的监听
|
||||||
unwatchFile().catch(() => {})
|
unwatchFile().catch(() => {})
|
||||||
|
|
||||||
|
const seq = ++loadFileSeq
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updatePreviewUrl(path)
|
await updatePreviewUrl(path)
|
||||||
|
// await 期间用户已点开其它文件 → 本次结果是过期的,丢弃,不得污染全局状态
|
||||||
|
if (seq !== loadFileSeq) return
|
||||||
|
|
||||||
|
// 所有文件类型都启动监听(包括图片/视频等非文本文件)
|
||||||
|
watchFile(path).catch(() => {})
|
||||||
|
|
||||||
const fileName = getFileName(path)
|
const fileName = getFileName(path)
|
||||||
const fileItem = selectedFileItem.value
|
const fileItem = selectedFileItem.value
|
||||||
@@ -1309,6 +1504,8 @@ const loadFileContent = async (path: string) => {
|
|||||||
if (!ext || !FILE_EXTENSIONS.IMAGE.includes(ext)) {
|
if (!ext || !FILE_EXTENSIONS.IMAGE.includes(ext)) {
|
||||||
try {
|
try {
|
||||||
const detected = await detectByContent(path, fileSize)
|
const detected = await detectByContent(path, fileSize)
|
||||||
|
// await 期间已切换到其它文件 → 丢弃
|
||||||
|
if (seq !== loadFileSeq) return
|
||||||
if (detected && detected.category === 'image') {
|
if (detected && detected.category === 'image') {
|
||||||
// 内容检测确认为图片,清空文本内容,让预览URL生效
|
// 内容检测确认为图片,清空文本内容,让预览URL生效
|
||||||
clearContent()
|
clearContent()
|
||||||
@@ -1322,7 +1519,6 @@ const loadFileContent = async (path: string) => {
|
|||||||
|
|
||||||
// 其他情况:加载为文本文件
|
// 其他情况:加载为文本文件
|
||||||
await loadFile(path)
|
await loadFile(path)
|
||||||
watchFile(path).catch(() => {})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Message.error(`读取文件失败: ${error}`)
|
Message.error(`读取文件失败: ${error}`)
|
||||||
}
|
}
|
||||||
@@ -1475,10 +1671,16 @@ onMounted(async () => {
|
|||||||
window.addEventListener('paste', handlePaste)
|
window.addEventListener('paste', handlePaste)
|
||||||
On('file-changed', handleFileChanged)
|
On('file-changed', handleFileChanged)
|
||||||
|
|
||||||
|
// 传输进度事件(复制/移动/下载/上传)
|
||||||
|
transferStore.setupEventListeners()
|
||||||
|
|
||||||
|
// 外部文件拖入(Wails EnableFileDrop:OS 级拖放经 Go 转发为 external-file-drop 事件)
|
||||||
|
On('external-file-drop', handleExternalDropEvent)
|
||||||
|
|
||||||
// 2. 加载系统路径
|
// 2. 加载系统路径
|
||||||
await loadCommonPaths()
|
await loadCommonPaths()
|
||||||
|
|
||||||
// SFTP 连接是异步的,未就绪时跳过初始加载,由 onStateChange('connected') 触发
|
// SFTP 握手未就绪时只设根路径不加载:此时列表请求会回落本地 transport,显示幻影本地文件
|
||||||
if (connectionManager.isSftp() && connectionManager.state !== 'connected') {
|
if (connectionManager.isSftp() && connectionManager.state !== 'connected') {
|
||||||
filePath.value = '/'
|
filePath.value = '/'
|
||||||
return
|
return
|
||||||
@@ -1503,14 +1705,20 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// 会话恢复(不阻塞首屏)
|
// 会话恢复(不阻塞首屏)
|
||||||
async function restoreSession() {
|
async function restoreSession() {
|
||||||
|
// 卡死自愈熔断:上次启动若触发了卡死自愈,本次主动跳过自动恢复(防 reload-loop)
|
||||||
|
if (isAutoRestoreDisabled()) {
|
||||||
|
clearAutoRestoreDisabled()
|
||||||
|
console.warn('[restoreSession] 检测到上次卡死自愈标记,本次跳过自动恢复历史文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const session = multiPreview.restoreSession()
|
const session = multiPreview.restoreSession()
|
||||||
if (session.paths.length > 0) {
|
if (session.paths.length > 0) {
|
||||||
const activeId = session.activePath
|
const activeId = session.activePath
|
||||||
? session.profileMap.get(session.activePath.replace(/\\/g, '/').toLowerCase())
|
? session.profileMap.get(session.activePath.replace(/\\/g, '/').toLowerCase())
|
||||||
: undefined
|
: undefined
|
||||||
if (activeId && activeId !== connectionManager.activeProfile?.id) {
|
if (activeId && activeId !== connectionManager.activeProfile?.id) {
|
||||||
_suppressAutoNav = true
|
try { await connectionManager.connect(activeId) } catch (e) { console.error('session恢复连接失败:', e) }
|
||||||
try { await connectionManager.connect(activeId) } catch (e) { console.error('session恢复连接失败:', e) } finally { _suppressAutoNav = false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const path of session.paths) {
|
for (const path of session.paths) {
|
||||||
@@ -1554,6 +1762,8 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('click', hideContextMenu)
|
window.removeEventListener('click', hideContextMenu)
|
||||||
window.removeEventListener('paste', handlePaste)
|
window.removeEventListener('paste', handlePaste)
|
||||||
Off('file-changed')
|
Off('file-changed')
|
||||||
|
Off('external-file-drop')
|
||||||
|
transferStore.removeEventListeners()
|
||||||
unwatchFile()
|
unwatchFile()
|
||||||
// 应用关闭时立即持久化当前会话
|
// 应用关闭时立即持久化当前会话
|
||||||
cacheCurrentTabState()
|
cacheCurrentTabState()
|
||||||
@@ -1598,12 +1808,7 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
|||||||
try {
|
try {
|
||||||
// 如果当前在远程连接,先切回本地
|
// 如果当前在远程连接,先切回本地
|
||||||
if (connectionManager.isRemote()) {
|
if (connectionManager.isRemote()) {
|
||||||
_suppressAutoNav = true
|
|
||||||
try {
|
|
||||||
await connectionManager.connect('local-default')
|
await connectionManager.connect('local-default')
|
||||||
} finally {
|
|
||||||
_suppressAutoNav = false
|
|
||||||
}
|
|
||||||
await loadCommonPaths()
|
await loadCommonPaths()
|
||||||
}
|
}
|
||||||
await navigate(drivePath)
|
await navigate(drivePath)
|
||||||
@@ -1642,6 +1847,34 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+C / Ctrl+X / Ctrl+V 文件剪贴板(输入框/行内编辑态放行系统默认行为)
|
||||||
|
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && ['c', 'x', 'v'].includes(event.key.toLowerCase())) {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
const inEditable = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
|
||||||
|
if (inEditable || editingFilePath.value) return
|
||||||
|
|
||||||
|
const key = event.key.toLowerCase()
|
||||||
|
if (key === 'v') {
|
||||||
|
// 剪贴板有文件项时优先生效(远程面板由粘贴逻辑 toast 提示),否则放行走「粘贴剪贴板图片」路径
|
||||||
|
if (clipboard.value && clipboard.value.items.length > 0) {
|
||||||
|
event.preventDefault()
|
||||||
|
await handlePasteClipboard()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 复制/剪切仅本机面板生效;SFTP 面板静默(菜单本就不提供),
|
||||||
|
// HTTP 远程/OSS 面板放行到 handler 给出提示
|
||||||
|
if (connectionManager.isSftp()) return
|
||||||
|
if (selectedFileItem.value) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (key === 'c') {
|
||||||
|
handleCopyFiles([selectedFileItem.value])
|
||||||
|
} else {
|
||||||
|
handleCutFiles([selectedFileItem.value])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl+S 保存
|
// Ctrl+S 保存
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -1684,8 +1917,7 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
|||||||
try {
|
try {
|
||||||
const targetProfileId = await back()
|
const targetProfileId = await back()
|
||||||
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
||||||
_suppressAutoNav = true
|
await connectionManager.connect(targetProfileId)
|
||||||
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isNavigating.value = false
|
isNavigating.value = false
|
||||||
@@ -1700,8 +1932,7 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
|||||||
try {
|
try {
|
||||||
const targetProfileId = await forward()
|
const targetProfileId = await forward()
|
||||||
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
||||||
_suppressAutoNav = true
|
await connectionManager.connect(targetProfileId)
|
||||||
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isNavigating.value = false
|
isNavigating.value = false
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="transferStore.items.length" class="transfer-panel">
|
||||||
|
<div v-for="item in transferStore.items" :key="item.id" class="transfer-item">
|
||||||
|
<div class="transfer-item-info">
|
||||||
|
<span class="transfer-item-name" :title="item.name">
|
||||||
|
{{ item.name }}
|
||||||
|
</span>
|
||||||
|
<span class="transfer-item-meta">
|
||||||
|
<span v-if="item.status === 'error'" class="transfer-item-status error">失败</span>
|
||||||
|
<span v-else class="transfer-item-status">{{ percent(item) }}%</span>
|
||||||
|
<span class="transfer-item-close" title="关闭" @click="transferStore.dismiss(item.id)">×</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a-progress
|
||||||
|
:percent="percent(item)"
|
||||||
|
:status="item.status === 'error' ? 'exception' : item.status === 'done' ? 'success' : 'normal'"
|
||||||
|
:show-text="false"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<div v-if="item.status === 'error' && item.error" class="transfer-item-error" :title="item.error">
|
||||||
|
{{ item.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTransferStore } from '../stores/transfer'
|
||||||
|
import type { TransferItem } from '../stores/transfer'
|
||||||
|
|
||||||
|
const transferStore = useTransferStore()
|
||||||
|
|
||||||
|
const percent = (item: TransferItem): number => {
|
||||||
|
if (item.total > 0) {
|
||||||
|
return Math.min(100, Math.round((item.copied / item.total) * 100))
|
||||||
|
}
|
||||||
|
// 总大小未知(空文件等)按完成度显示
|
||||||
|
return item.status === 'done' ? 100 : 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transfer-panel {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
width: 280px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-bg-2);
|
||||||
|
border: 1px solid var(--color-border-2);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1100;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-item-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-item-name {
|
||||||
|
color: var(--color-text-2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-item-status {
|
||||||
|
color: var(--color-text-3);
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-item-status.error {
|
||||||
|
color: rgb(var(--danger-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-item-close {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-item-close:hover {
|
||||||
|
color: var(--color-text-1);
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-item-error {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(var(--danger-6));
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,7 @@ import { Modal, Message, Progress } from '@arco-design/web-vue'
|
|||||||
import { useUpdateStore } from '../stores/update'
|
import { useUpdateStore } from '../stores/update'
|
||||||
import { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions'
|
import { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions'
|
||||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||||
|
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||||
import { DownloadUpdate } from '../wailsjs/v3-bindings/u-desk/app'
|
import { DownloadUpdate } from '../wailsjs/v3-bindings/u-desk/app'
|
||||||
import { On, Off } from '@wailsio/events'
|
import { On, Off } from '@wailsio/events'
|
||||||
|
|
||||||
@@ -292,7 +293,7 @@ const handleDownload = async () => {
|
|||||||
|
|
||||||
// 下载完成处理(本地覆盖:关闭弹窗)
|
// 下载完成处理(本地覆盖:关闭弹窗)
|
||||||
const onDownloadComplete = async (event) => {
|
const onDownloadComplete = async (event) => {
|
||||||
const data = typeof event === 'string' ? JSON.parse(event) : event
|
const data = parseWailsEventData(event)
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
closeProgressModal()
|
closeProgressModal()
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ import { IconHistory } from '@arco-design/web-vue/es/icon'
|
|||||||
import { useUpdateStore } from '../stores/update'
|
import { useUpdateStore } from '../stores/update'
|
||||||
import { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions'
|
import { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions'
|
||||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||||
|
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||||
import { GetCurrentVersion, GetUpdateConfig, InstallUpdate } from '../wailsjs/v3-bindings/u-desk/app'
|
import { GetCurrentVersion, GetUpdateConfig, InstallUpdate } from '../wailsjs/v3-bindings/u-desk/app'
|
||||||
import { On, Off } from '@wailsio/events'
|
import { On, Off } from '@wailsio/events'
|
||||||
|
|
||||||
@@ -228,12 +229,7 @@ const handleInstall = async () => {
|
|||||||
|
|
||||||
// 监听下载完成事件(本地覆盖:记录下载文件路径)
|
// 监听下载完成事件(本地覆盖:记录下载文件路径)
|
||||||
const onDownloadComplete = (event) => {
|
const onDownloadComplete = (event) => {
|
||||||
let data: any
|
const data = parseWailsEventData(event)
|
||||||
try {
|
|
||||||
data = typeof event === 'string' ? JSON.parse(event) : event
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.success && data.file_path) {
|
if (data.success && data.file_path) {
|
||||||
downloadedFile.value = data.file_path
|
downloadedFile.value = data.file_path
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
/**
|
|
||||||
* 收藏夹管理 composable
|
|
||||||
*
|
|
||||||
* @module composables/useFavoriteFiles
|
|
||||||
* @description 封装收藏夹的增删改查逻辑,支持持久化存储
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { Message } from '@arco-design/web-vue'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 收藏夹 composable
|
|
||||||
* @param {string} storageKey - localStorage 键名
|
|
||||||
* @param {Object} [options] - 配置选项
|
|
||||||
* @param {number} [options.maxLength=50] - 最大收藏数量
|
|
||||||
* @param {Function} [options.onAdd] - 添加收藏回调
|
|
||||||
* @param {Function} [options.onRemove] - 移除收藏回调
|
|
||||||
* @returns {UseFavoriteFilesReturn} 收藏夹操作API
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const {
|
|
||||||
* favoriteFiles,
|
|
||||||
* isFavorite,
|
|
||||||
* toggleFavorite,
|
|
||||||
* removeFavorite,
|
|
||||||
* clearAll
|
|
||||||
* } = useFavoriteFiles('app-favorites')
|
|
||||||
*
|
|
||||||
* // 在模板中使用
|
|
||||||
* <a-button @click="toggleFavorite(file)">
|
|
||||||
* <icon-star-fill v-if="isFavorite(file.path)" />
|
|
||||||
* <icon-star v-else />
|
|
||||||
* </a-button>
|
|
||||||
*/
|
|
||||||
export function useFavoriteFiles(storageKey, options = {}) {
|
|
||||||
const {
|
|
||||||
maxLength = 50,
|
|
||||||
onAdd = () => {},
|
|
||||||
onRemove = () => {},
|
|
||||||
} = options
|
|
||||||
|
|
||||||
// 收藏列表
|
|
||||||
const favoriteFiles = ref([])
|
|
||||||
|
|
||||||
const load = () => {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(storageKey)
|
|
||||||
if (stored) {
|
|
||||||
favoriteFiles.value = JSON.parse(stored)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载收藏列表失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = (data) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(data || favoriteFiles.value))
|
|
||||||
} catch (e) {
|
|
||||||
console.error('保存收藏列表失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断文件/目录是否已收藏
|
|
||||||
* @param {string} path - 文件/目录路径
|
|
||||||
* @returns {boolean} 是否已收藏
|
|
||||||
*/
|
|
||||||
const isFavorite = (path) => {
|
|
||||||
if (!path || !Array.isArray(favoriteFiles.value)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return favoriteFiles.value.some(fav => fav.path === path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 排序收藏列表(按创建时间倒序,最新的在上面)
|
|
||||||
*/
|
|
||||||
const sortFavorites = () => {
|
|
||||||
if (!Array.isArray(favoriteFiles.value)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
favoriteFiles.value.sort((a, b) => {
|
|
||||||
const timeA = a.addedAt || 0
|
|
||||||
const timeB = b.addedAt || 0
|
|
||||||
return timeB - timeA // 倒序:最新的在上面
|
|
||||||
})
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换收藏状态
|
|
||||||
* @param {Object} item - 文件/目录信息
|
|
||||||
* @param {string} item.path - 路径
|
|
||||||
* @param {string} item.name - 名称
|
|
||||||
* @param {boolean} item.is_dir - 是否为目录
|
|
||||||
* @returns {boolean} 操作后是否为收藏状态
|
|
||||||
*/
|
|
||||||
const toggleFavorite = (item) => {
|
|
||||||
if (!item || !item.path) {
|
|
||||||
Message.warning('无效的文件信息')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = favoriteFiles.value.findIndex(fav => fav.path === item.path)
|
|
||||||
|
|
||||||
if (index > -1) {
|
|
||||||
// 已收藏,执行取消收藏
|
|
||||||
favoriteFiles.value.splice(index, 1)
|
|
||||||
save(favoriteFiles.value) // 直接保存,不重新排序
|
|
||||||
|
|
||||||
onRemove(item)
|
|
||||||
Message.info(`已取消收藏: ${item.name}`)
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
// 未收藏,执行添加收藏
|
|
||||||
if (favoriteFiles.value.length >= maxLength) {
|
|
||||||
Message.warning(`收藏夹已满,最多只能收藏 ${maxLength} 项`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
favoriteFiles.value.push({
|
|
||||||
path: item.path,
|
|
||||||
name: item.name,
|
|
||||||
isDir: item.isDir || false,
|
|
||||||
addedAt: Date.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
save(favoriteFiles.value) // 直接保存,不重新排序(新项目添加到末尾)
|
|
||||||
|
|
||||||
onAdd(item)
|
|
||||||
Message.success(`已收藏: ${item.name}`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除收藏
|
|
||||||
* @param {string} path - 文件/目录路径
|
|
||||||
* @returns {boolean} 是否成功移除
|
|
||||||
*/
|
|
||||||
const removeFavorite = (path) => {
|
|
||||||
if (!path) {
|
|
||||||
Message.warning('请提供有效的路径')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = favoriteFiles.value.findIndex(fav => fav.path === path)
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
Message.warning('该路径不在收藏夹中')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = favoriteFiles.value[index]
|
|
||||||
favoriteFiles.value.splice(index, 1)
|
|
||||||
|
|
||||||
save(favoriteFiles.value) // 直接保存,不重新排序
|
|
||||||
|
|
||||||
onRemove(item)
|
|
||||||
Message.info(`已取消收藏: ${item.name}`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开收藏的文件/目录
|
|
||||||
* @param {string} path - 文件/目录路径
|
|
||||||
* @param {Function} onOpen - 打开回调函数
|
|
||||||
* @returns {Promise<boolean>} 是否成功打开
|
|
||||||
*/
|
|
||||||
const openFavorite = async (path, onOpen) => {
|
|
||||||
if (!path || !onOpen) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = favoriteFiles.value.find(fav => fav.path === path)
|
|
||||||
if (!item) {
|
|
||||||
Message.warning('收藏项不存在')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return await onOpen(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空所有收藏
|
|
||||||
* @param {boolean} [confirm=true] - 是否需要确认
|
|
||||||
* @returns {boolean} 是否成功清空
|
|
||||||
*/
|
|
||||||
const clearAll = (confirm = true) => {
|
|
||||||
const executeClear = () => {
|
|
||||||
const count = favoriteFiles.value.length
|
|
||||||
favoriteFiles.value = []
|
|
||||||
save([])
|
|
||||||
|
|
||||||
Message.success(`已清空 ${count} 个收藏项`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirm) {
|
|
||||||
return executeClear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用原生 confirm(简单场景)
|
|
||||||
if (window.confirm(`确定要清空所有 ${favoriteFiles.value.length} 个收藏项吗?`)) {
|
|
||||||
return executeClear()
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取收藏列表(按创建时间排序)
|
|
||||||
* @param {string} [order='desc'] - 排序方式:'asc'或'desc'
|
|
||||||
* @returns {Array} 排序后的收藏列表
|
|
||||||
*/
|
|
||||||
const getSortedFavorites = (order = 'desc') => {
|
|
||||||
const sorted = [...favoriteFiles.value]
|
|
||||||
sorted.sort((a, b) => {
|
|
||||||
const timeA = a.addedAt || 0
|
|
||||||
const timeB = b.addedAt || 0
|
|
||||||
return order === 'desc' ? timeB - timeA : timeA - timeB
|
|
||||||
})
|
|
||||||
return sorted
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按名称搜索收藏
|
|
||||||
* @param {string} keyword - 搜索关键词
|
|
||||||
* @returns {Array} 匹配的收藏列表
|
|
||||||
*/
|
|
||||||
const searchFavorites = (keyword) => {
|
|
||||||
if (!keyword || !keyword.trim()) {
|
|
||||||
return favoriteFiles.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerKeyword = keyword.toLowerCase().trim()
|
|
||||||
return favoriteFiles.value.filter(fav =>
|
|
||||||
fav.name.toLowerCase().includes(lowerKeyword) ||
|
|
||||||
fav.path.toLowerCase().includes(lowerKeyword)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重新排序收藏列表(拖拽排序)
|
|
||||||
* @param {number} fromIndex - 源索引
|
|
||||||
* @param {number} toIndex - 目标索引
|
|
||||||
* @returns {boolean} 是否成功重排序
|
|
||||||
*/
|
|
||||||
const reorderFavorites = (fromIndex, toIndex) => {
|
|
||||||
if (!Array.isArray(favoriteFiles.value)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fromIndex < 0 || fromIndex >= favoriteFiles.value.length ||
|
|
||||||
toIndex < 0 || toIndex >= favoriteFiles.value.length) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fromIndex === toIndex) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移动数组元素
|
|
||||||
const [movedItem] = favoriteFiles.value.splice(fromIndex, 1)
|
|
||||||
favoriteFiles.value.splice(toIndex, 0, movedItem)
|
|
||||||
|
|
||||||
// 保存新顺序
|
|
||||||
save(favoriteFiles.value)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 旧字段名 → 新字段名迁移(一次性,迁移后自动回写)
|
|
||||||
const migrateFieldNames = (list) => {
|
|
||||||
if (!Array.isArray(list)) return
|
|
||||||
const map = { is_dir: 'isDir', created_at: 'addedAt' }
|
|
||||||
let changed = false
|
|
||||||
list.forEach(item => {
|
|
||||||
for (const [old, newKey] of Object.entries(map)) {
|
|
||||||
if (old in item) {
|
|
||||||
if (!(newKey in item)) item[newKey] = item[old]
|
|
||||||
delete item[old]
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (changed) save(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件挂载时加载数据并迁移旧字段
|
|
||||||
onMounted(() => {
|
|
||||||
load()
|
|
||||||
migrateFieldNames(favoriteFiles.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 状态
|
|
||||||
favoriteFiles,
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
isFavorite,
|
|
||||||
toggleFavorite,
|
|
||||||
removeFavorite,
|
|
||||||
openFavorite,
|
|
||||||
clearAll,
|
|
||||||
getSortedFavorites,
|
|
||||||
searchFavorites,
|
|
||||||
sortFavorites,
|
|
||||||
reorderFavorites,
|
|
||||||
load,
|
|
||||||
save,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} UseFavoriteFilesReturn
|
|
||||||
* @property {Ref<Array>} favoriteFiles - 收藏列表(自动按时间倒序排列)
|
|
||||||
* @property {Function} isFavorite - 判断是否已收藏
|
|
||||||
* @property {Function} toggleFavorite - 切换收藏状态
|
|
||||||
* @property {Function} removeFavorite - 移除收藏
|
|
||||||
* @property {Function} openFavorite - 打开收藏项
|
|
||||||
* @property {Function} clearAll - 清空所有收藏
|
|
||||||
* @property {Function} getSortedFavorites - 获取排序后的列表
|
|
||||||
* @property {Function} searchFavorites - 搜索收藏
|
|
||||||
* @property {Function} sortFavorites - 手动排序收藏列表
|
|
||||||
* @property {Function} reorderFavorites - 拖拽重新排序
|
|
||||||
* @property {Function} load - 手动加载数据
|
|
||||||
* @property {Function} save - 手动保存数据
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建多个收藏夹管理实例
|
|
||||||
* @param {Object} config - 配置对象
|
|
||||||
* @returns {Object} 收藏夹管理实例集合
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const {
|
|
||||||
* filesystemFavs,
|
|
||||||
* deviceTestFavs
|
|
||||||
* } = createMultipleFavorites({
|
|
||||||
* filesystem: 'app-filesystem-favorites',
|
|
||||||
* deviceTest: 'app-device-test-favorites'
|
|
||||||
* })
|
|
||||||
*/
|
|
||||||
export function createMultipleFavorites(config) {
|
|
||||||
const result = {}
|
|
||||||
|
|
||||||
Object.keys(config).forEach(key => {
|
|
||||||
result[key] = useFavoriteFiles(config[key])
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
/**
|
|
||||||
* 文件操作逻辑封装
|
|
||||||
*
|
|
||||||
* @module composables/useFileOperations
|
|
||||||
* @description 封装所有文件操作逻辑,提供统一的错误处理和状态管理
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { Message, Modal } from '@arco-design/web-vue'
|
|
||||||
import {
|
|
||||||
listDir,
|
|
||||||
readFile as readFileApi,
|
|
||||||
writeFile as writeFileApi,
|
|
||||||
deletePath as deletePathApi,
|
|
||||||
} from '@/api'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LocalStorage 键名
|
|
||||||
*/
|
|
||||||
const STORAGE_KEY_LAST_PATH = 'app-filesystem-last-path'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件操作 composable
|
|
||||||
* @param {Object} [options] - 配置选项
|
|
||||||
* @param {Function} [options.onSuccess] - 操作成功回调
|
|
||||||
* @param {Function} [options.onError] - 操作失败回调
|
|
||||||
* @returns {UseFileOperationsReturn} 文件操作API
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const {
|
|
||||||
* filePath,
|
|
||||||
* fileContent,
|
|
||||||
* fileList,
|
|
||||||
* fileLoading,
|
|
||||||
* listDirectory,
|
|
||||||
* readFile,
|
|
||||||
* writeFile,
|
|
||||||
* deleteFile
|
|
||||||
* } = useFileOperations({
|
|
||||||
* onSuccess: (operation, data) => console.log(operation, data),
|
|
||||||
* onError: (operation, error) => console.error(operation, error)
|
|
||||||
* })
|
|
||||||
*/
|
|
||||||
export function useFileOperations(options = {}) {
|
|
||||||
const { onSuccess = () => {}, onError = () => {} } = options
|
|
||||||
|
|
||||||
// ========== 响应式状态 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前文件/目录路径
|
|
||||||
* @type {Ref<string>}
|
|
||||||
*/
|
|
||||||
// 从 localStorage 恢复上次路径
|
|
||||||
const savedPath = localStorage.getItem(STORAGE_KEY_LAST_PATH)
|
|
||||||
const filePath = ref(savedPath || '')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件内容
|
|
||||||
* @type {Ref<string>}
|
|
||||||
*/
|
|
||||||
const fileContent = ref('')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件列表
|
|
||||||
* @type {Ref<Array>}
|
|
||||||
*/
|
|
||||||
const fileList = ref([])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载状态
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const fileLoading = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 正在删除状态(防止并发删除)
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isDeleting = ref(false)
|
|
||||||
|
|
||||||
// ========== 文件操作方法 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 列出目录内容
|
|
||||||
* @param {string} [path] - 目录路径,不传则使用当前路径
|
|
||||||
* @returns {Promise<boolean>} 是否成功
|
|
||||||
*/
|
|
||||||
const listDirectory = async (path) => {
|
|
||||||
const targetPath = path || filePath.value
|
|
||||||
|
|
||||||
if (!targetPath) {
|
|
||||||
Message.error('请输入目录路径')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fileLoading.value = true
|
|
||||||
try {
|
|
||||||
const result = await listDir(targetPath)
|
|
||||||
fileList.value = result
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
// 如果没有传参,更新当前路径
|
|
||||||
filePath.value = targetPath
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess('listDirectory', { path: targetPath, count: result.length })
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
onError('listDirectory', error)
|
|
||||||
const errorMsg = error.message || error || '未知错误'
|
|
||||||
Message.error(`列出目录失败 [${targetPath}]: ${errorMsg}`)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
fileLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取文件内容
|
|
||||||
* @param {string} [path] - 文件路径,不传则使用当前路径
|
|
||||||
* @returns {Promise<boolean>} 是否成功
|
|
||||||
*/
|
|
||||||
const readFile = async (path) => {
|
|
||||||
const targetPath = path || filePath.value
|
|
||||||
|
|
||||||
if (!targetPath) {
|
|
||||||
Message.error('请输入文件路径')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fileLoading.value = true
|
|
||||||
try {
|
|
||||||
const content = await readFileApi(targetPath)
|
|
||||||
fileContent.value = content
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
filePath.value = targetPath
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess('readFile', { path: targetPath })
|
|
||||||
// 文件读取成功,静默无提示
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
onError('readFile', error)
|
|
||||||
Message.error(`读取文件失败: ${error.message || error}`)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
fileLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入文件内容
|
|
||||||
* @param {string} [content] - 要写入的内容,不传则使用当前内容
|
|
||||||
* @param {string} [path] - 文件路径,不传则使用当前路径
|
|
||||||
* @param {string} [fileName] - 文件名,用于成功提示显示
|
|
||||||
* @param {boolean} [isShortcut=false] - 是否是快捷键触发(快捷键不显示提示)
|
|
||||||
* @returns {Promise<boolean>} 是否成功
|
|
||||||
*/
|
|
||||||
const writeFile = async (content, path, fileName, isShortcut = false) => {
|
|
||||||
// 忽略事件对象(当点击按钮时 Vue 会传递事件对象)
|
|
||||||
const targetContent = (content !== undefined && typeof content === 'string') ? content : fileContent.value
|
|
||||||
const targetPath = (path !== undefined && typeof path === 'string') ? path : filePath.value
|
|
||||||
|
|
||||||
if (!targetPath) {
|
|
||||||
Message.error('请输入文件路径')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fileLoading.value = true
|
|
||||||
try {
|
|
||||||
await writeFileApi(targetPath, targetContent)
|
|
||||||
|
|
||||||
if (content !== undefined) {
|
|
||||||
fileContent.value = targetContent
|
|
||||||
}
|
|
||||||
if (path) {
|
|
||||||
filePath.value = path
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess('writeFile', { path: targetPath })
|
|
||||||
|
|
||||||
// 差异化反馈:快捷键不显示提示,按钮点击显示轻提示
|
|
||||||
if (!isShortcut) {
|
|
||||||
// 按钮点击保存:显示轻量 Toast 提示
|
|
||||||
if (fileName && typeof fileName === 'string') {
|
|
||||||
Message.success({
|
|
||||||
content: `✓ ${fileName} 已保存`,
|
|
||||||
duration: 1500, // 1.5秒后自动消失
|
|
||||||
position: 'bottom' // 底部显示,不打断操作
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Message.success({
|
|
||||||
content: '文件已保存',
|
|
||||||
duration: 1500,
|
|
||||||
position: 'bottom'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 快捷键保存:无提示(静默成功)
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
onError('writeFile', error)
|
|
||||||
// 保存失败:总是显示醒目错误提示(需手动关闭)
|
|
||||||
Message.error({
|
|
||||||
content: `文件保存失败: ${error.message || error}`,
|
|
||||||
duration: 5000, // 错误提示显示更久
|
|
||||||
closable: true // 允许手动关闭
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
fileLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除文件或目录
|
|
||||||
* 🔒 安全修复:移除 confirm 参数,始终需要用户确认,防止绕过
|
|
||||||
* 🔒 安全修复:添加并发删除检查,防止批量删除攻击
|
|
||||||
* @param {string} [path] - 文件路径,不传则使用当前路径
|
|
||||||
* @returns {Promise<boolean>} 用户是否确认及操作是否成功
|
|
||||||
*/
|
|
||||||
const deleteFile = async (path) => {
|
|
||||||
const targetPath = path || filePath.value
|
|
||||||
|
|
||||||
if (!targetPath) {
|
|
||||||
Message.error('请输入文件路径')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔒 安全修复:添加调用频率限制(防止批量删除攻击)
|
|
||||||
if (isDeleting.value) {
|
|
||||||
Message.warning('正在删除中,请稍候...')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const executeDelete = async () => {
|
|
||||||
isDeleting.value = true
|
|
||||||
fileLoading.value = true
|
|
||||||
try {
|
|
||||||
await deletePathApi(targetPath)
|
|
||||||
|
|
||||||
// 清空状态
|
|
||||||
filePath.value = ''
|
|
||||||
fileContent.value = ''
|
|
||||||
fileList.value = []
|
|
||||||
|
|
||||||
onSuccess('deleteFile', { path: targetPath })
|
|
||||||
Message.success('删除成功')
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
onError('deleteFile', error)
|
|
||||||
Message.error(`删除失败: ${error.message || error}`)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
fileLoading.value = false
|
|
||||||
isDeleting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔒 安全修复:始终显示确认对话框,无法绕过
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: '⚠️ 确认删除',
|
|
||||||
content: `确定要删除 ${targetPath} 吗?此操作不可恢复!`,
|
|
||||||
okText: '确定删除',
|
|
||||||
cancelText: '取消',
|
|
||||||
okButtonProps: { status: 'danger' }, // 红色按钮提醒危险
|
|
||||||
onOk: async () => {
|
|
||||||
const result = await executeDelete()
|
|
||||||
resolve(result)
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
resolve(false)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 选择文件(智能判断是文件还是目录)
|
|
||||||
* @param {string} path - 文件/目录路径
|
|
||||||
* @param {Array} fileListData - 文件列表数据
|
|
||||||
* @returns {Promise<boolean>} 是否成功
|
|
||||||
*/
|
|
||||||
const selectFile = async (path, fileListData) => {
|
|
||||||
if (!path) return false
|
|
||||||
|
|
||||||
filePath.value = path
|
|
||||||
|
|
||||||
// 从文件列表中查找该项
|
|
||||||
const item = fileListData.find(f => f.path === path)
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
// 如果列表中找不到,尝试根据路径判断
|
|
||||||
// 简单判断:路径以 / 或 \ 结尾可能是目录
|
|
||||||
const isDir = path.endsWith('/') || path.endsWith('\\')
|
|
||||||
if (isDir) {
|
|
||||||
return await listDirectory(path)
|
|
||||||
} else {
|
|
||||||
return await readFile(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.is_dir) {
|
|
||||||
// 是目录,列出内容
|
|
||||||
return await listDirectory(path)
|
|
||||||
} else {
|
|
||||||
// 是文件,读取内容
|
|
||||||
return await readFile(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空所有状态
|
|
||||||
*/
|
|
||||||
const clearAll = () => {
|
|
||||||
filePath.value = ''
|
|
||||||
fileContent.value = ''
|
|
||||||
fileList.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 持久化 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听路径变化,自动保存到 localStorage
|
|
||||||
* 用于下次启动时恢复上次访问的路径
|
|
||||||
*/
|
|
||||||
watch(filePath, (newPath) => {
|
|
||||||
try {
|
|
||||||
if (newPath) {
|
|
||||||
localStorage.setItem(STORAGE_KEY_LAST_PATH, newPath)
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(STORAGE_KEY_LAST_PATH)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[useFileOperations] 保存路径失败:', e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ========== 返回公共API ==========
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 状态
|
|
||||||
filePath,
|
|
||||||
fileContent,
|
|
||||||
fileList,
|
|
||||||
fileLoading,
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
listDirectory,
|
|
||||||
readFile,
|
|
||||||
writeFile,
|
|
||||||
deleteFile,
|
|
||||||
selectFile,
|
|
||||||
clearAll,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} UseFileOperationsReturn
|
|
||||||
* @property {Ref<string>} filePath - 当前文件路径
|
|
||||||
* @property {Ref<string>} fileContent - 文件内容
|
|
||||||
* @property {Ref<Array>} fileList - 文件列表
|
|
||||||
* @property {Ref<boolean>} fileLoading - 加载状态
|
|
||||||
* @property {Function} listDirectory - 列出目录
|
|
||||||
* @property {Function} readFile - 读取文件
|
|
||||||
* @property {Function} writeFile - 写入文件
|
|
||||||
* @property {Function} deleteFile - 删除文件
|
|
||||||
* @property {Function} selectFile - 智能选择文件
|
|
||||||
* @property {Function} clearAll - 清空所有状态
|
|
||||||
*/
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
/**
|
|
||||||
* 导航和路径管理 composable
|
|
||||||
*
|
|
||||||
* @module composables/useNavigation
|
|
||||||
* @description 封装文件系统的导航历史、路径操作等逻辑
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { Message } from '@arco-design/web-vue'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 路径历史 localStorage 键
|
|
||||||
*/
|
|
||||||
const STORAGE_KEY_PATH_HISTORY = 'app-filesystem-path-history'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导航管理 composable
|
|
||||||
* @param {Object} options - 配置选项
|
|
||||||
* @param {Ref<string>} options.filePath - 当前路径 ref
|
|
||||||
* @param {Function} options.onListDirectory - 列出目录的函数
|
|
||||||
* @param {Function} options.onExitZipMode - 退出 ZIP 模式的函数
|
|
||||||
* @returns {UseNavigationReturn} 导航操作 API
|
|
||||||
*/
|
|
||||||
export function useNavigation(options = {}) {
|
|
||||||
const {
|
|
||||||
filePath,
|
|
||||||
onListDirectory,
|
|
||||||
onExitZipMode,
|
|
||||||
} = options
|
|
||||||
|
|
||||||
// ========== 导航历史记录(支持后退/前进) ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导航历史栈
|
|
||||||
* @type {Ref<Array<string>>}
|
|
||||||
*/
|
|
||||||
const navHistory = ref([])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前在历史栈中的位置
|
|
||||||
* @type {Ref<number>}
|
|
||||||
*/
|
|
||||||
const navIndex = ref(-1)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否正在导航(防止重复记录)
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isNavigating = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 路径历史记录(用于下拉列表)
|
|
||||||
* @type {Ref<Array<string>>}
|
|
||||||
*/
|
|
||||||
const pathHistory = ref([])
|
|
||||||
|
|
||||||
// ========== 计算属性 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否可以后退
|
|
||||||
*/
|
|
||||||
const canGoBack = computed(() => navIndex.value > 0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否可以前进
|
|
||||||
*/
|
|
||||||
const canGoForward = computed(() => navIndex.value < navHistory.value.length - 1)
|
|
||||||
|
|
||||||
// ========== 导航操作 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加到路径历史记录
|
|
||||||
* @param {string} path - 路径
|
|
||||||
*/
|
|
||||||
const addToHistory = (path) => {
|
|
||||||
if (!path || path === filePath.value) return
|
|
||||||
|
|
||||||
// 去重:如果路径已在历史中,先删除
|
|
||||||
const index = pathHistory.value.indexOf(path)
|
|
||||||
if (index > -1) {
|
|
||||||
pathHistory.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加到开头
|
|
||||||
pathHistory.value.unshift(path)
|
|
||||||
|
|
||||||
// 限制历史记录数量
|
|
||||||
if (pathHistory.value.length > 50) {
|
|
||||||
pathHistory.value = pathHistory.value.slice(0, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 持久化
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY_PATH_HISTORY, JSON.stringify(pathHistory.value))
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略 localStorage 错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 推送到导航历史栈
|
|
||||||
* @param {string} path - 路径
|
|
||||||
*/
|
|
||||||
const pushNav = (path) => {
|
|
||||||
if (isNavigating.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果当前位置不在历史末尾,删除后续历史
|
|
||||||
if (navIndex.value < navHistory.value.length - 1) {
|
|
||||||
navHistory.value = navHistory.value.slice(0, navIndex.value + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加到历史
|
|
||||||
navHistory.value.push(path)
|
|
||||||
navIndex.value = navHistory.value.length - 1
|
|
||||||
|
|
||||||
// 同时添加到路径历史
|
|
||||||
addToHistory(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 后退
|
|
||||||
* @returns {Promise<boolean>} 是否成功
|
|
||||||
*/
|
|
||||||
const goBack = async () => {
|
|
||||||
if (!canGoBack.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
isNavigating.value = true
|
|
||||||
try {
|
|
||||||
navIndex.value--
|
|
||||||
const path = navHistory.value[navIndex.value]
|
|
||||||
await onListDirectory(path)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
Message.error(`后退失败: ${error.message || error}`)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
isNavigating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 前进
|
|
||||||
* @returns {Promise<boolean>} 是否成功
|
|
||||||
*/
|
|
||||||
const goForward = async () => {
|
|
||||||
if (!canGoForward.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
isNavigating.value = true
|
|
||||||
try {
|
|
||||||
navIndex.value++
|
|
||||||
const path = navHistory.value[navIndex.value]
|
|
||||||
await onListDirectory(path)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
Message.error(`前进失败: ${error.message || error}`)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
isNavigating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 路径操作 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 路径选择(从下拉列表)
|
|
||||||
* @param {string} value - 选中的路径
|
|
||||||
*/
|
|
||||||
const onPathSelect = (value) => {
|
|
||||||
if (value && value !== filePath.value) {
|
|
||||||
goToPath(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 路径输入框回车事件
|
|
||||||
*/
|
|
||||||
const onPathEnter = () => {
|
|
||||||
const path = filePath.value?.trim()
|
|
||||||
if (path) {
|
|
||||||
goToPath(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 跳转到指定路径
|
|
||||||
* @param {string} path - 目标路径
|
|
||||||
* @returns {Promise<boolean>} 是否成功
|
|
||||||
*/
|
|
||||||
const goToPath = async (path) => {
|
|
||||||
if (!path) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 退出 ZIP 模式
|
|
||||||
if (onExitZipMode) {
|
|
||||||
onExitZipMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
return await onListDirectory(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 浏览目录(打开系统文件选择对话框)
|
|
||||||
*/
|
|
||||||
const browseDirectory = async () => {
|
|
||||||
Message.info('请手动输入目录路径')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 初始化 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载路径历史记录
|
|
||||||
*/
|
|
||||||
const loadPathHistory = () => {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(STORAGE_KEY_PATH_HISTORY)
|
|
||||||
if (saved) {
|
|
||||||
pathHistory.value = JSON.parse(saved)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[useNavigation] 加载路径历史失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
loadPathHistory()
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 状态
|
|
||||||
navHistory,
|
|
||||||
navIndex,
|
|
||||||
isNavigating,
|
|
||||||
pathHistory,
|
|
||||||
canGoBack,
|
|
||||||
canGoForward,
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
addToHistory,
|
|
||||||
pushNav,
|
|
||||||
goBack,
|
|
||||||
goForward,
|
|
||||||
onPathSelect,
|
|
||||||
onPathEnter,
|
|
||||||
goToPath,
|
|
||||||
browseDirectory,
|
|
||||||
loadPathHistory,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} UseNavigationReturn
|
|
||||||
* @property {Ref<Array<string>>} navHistory - 导航历史栈
|
|
||||||
* @property {Ref<number>} navIndex - 当前在历史栈中的位置
|
|
||||||
* @property {Ref<boolean>} isNavigating - 是否正在导航
|
|
||||||
* @property {Ref<Array<string>>} pathHistory - 路径历史记录(下拉列表)
|
|
||||||
* @property {ComputedRef<boolean>} canGoBack - 是否可以后退
|
|
||||||
* @property {ComputedRef<boolean>} canGoForward - 是否可以前进
|
|
||||||
* @property {Function} addToHistory - 添加到路径历史记录
|
|
||||||
* @property {Function} pushNav - 推送到导航历史栈
|
|
||||||
* @property {Function} goBack - 后退
|
|
||||||
* @property {Function} goForward - 前进
|
|
||||||
* @property {Function} onPathSelect - 路径选择
|
|
||||||
* @property {Function} onPathEnter - 路径输入框回车事件
|
|
||||||
* @property {Function} goToPath - 跳转到指定路径
|
|
||||||
* @property {Function} browseDirectory - 浏览目录
|
|
||||||
* @property {Function} loadPathHistory - 加载路径历史记录
|
|
||||||
*/
|
|
||||||
@@ -4,6 +4,8 @@ import { createPinia } from 'pinia'
|
|||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { useThemeStore } from './stores/theme'
|
import { useThemeStore } from './stores/theme'
|
||||||
|
import { startFreezeWatchdog } from './utils/freezeWatchdog'
|
||||||
|
import '@/plugin/built-in' // 注册内置预览插件(drawio 等)
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
@@ -14,5 +16,8 @@ app.use(pinia)
|
|||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
themeStore.initTheme()
|
themeStore.initTheme()
|
||||||
|
|
||||||
|
// 启动主线程卡死看门狗(尽早注册 pong 回调;自愈绝不 reload,防 loop)
|
||||||
|
startFreezeWatchdog()
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// drawio 预览插件注册
|
||||||
|
// canHandle 严格匹配 .drawio / .dio 扩展名,不认 .xml(避免误吞配置文件,配置文件走代码编辑器)。
|
||||||
|
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { registerPreviewHandler } from '../registry'
|
||||||
|
import type { FilePreviewHandlerDefinition } from '../types'
|
||||||
|
|
||||||
|
// 仅 .drawio / .dio;.drawio.png / .drawio.svg 本就走图片预览,不冲突
|
||||||
|
const DRAWIO_EXT_REGEX = /\.(drawio|dio)$/i
|
||||||
|
|
||||||
|
const drawioHandler: FilePreviewHandlerDefinition = {
|
||||||
|
id: 'drawio-preview',
|
||||||
|
name: 'Draw.io 查看器',
|
||||||
|
icon: '📊',
|
||||||
|
priority: 90,
|
||||||
|
canHandle: (filename: string): boolean => DRAWIO_EXT_REGEX.test(filename || ''),
|
||||||
|
getComponent: (): Promise<Component> =>
|
||||||
|
import('../../components/FileSystem/components/DrawioPreview.vue').then(m => m.default),
|
||||||
|
supportsEdit: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPreviewHandler(drawioHandler)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// 内置预览插件聚合:import 此文件即触发注册(副作用模块)
|
||||||
|
// main.js 顶部 import '@/plugin/built-in' 完成全部内置插件注册。
|
||||||
|
|
||||||
|
import './drawio-handler'
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// 预览插件注册中心
|
||||||
|
// 模块级单例:registerPreviewHandler 注册,resolvePreviewHandler 按优先级解析。
|
||||||
|
// 现有 9 类预览不进此注册表(仍走 FileEditorPanel 的 v-if/v-else-if 链);
|
||||||
|
// 仅当 resolvePreviewHandler 返回非 null 时,模板的 <component> 分支命中,优先于老链。
|
||||||
|
|
||||||
|
import { defineAsyncComponent, type Component } from 'vue'
|
||||||
|
import type { FilePreviewHandlerDefinition } from './types'
|
||||||
|
|
||||||
|
// 按 priority 降序维护的注册表
|
||||||
|
const previewHandlers: FilePreviewHandlerDefinition[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册一个文件预览处理器
|
||||||
|
* 重复 id 会覆盖旧注册(便于热替换/测试),注册后保持 priority 降序。
|
||||||
|
*/
|
||||||
|
export function registerPreviewHandler(handler: FilePreviewHandlerDefinition): void {
|
||||||
|
const idx = previewHandlers.findIndex(h => h.id === handler.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
previewHandlers[idx] = handler
|
||||||
|
} else {
|
||||||
|
previewHandlers.push(handler)
|
||||||
|
}
|
||||||
|
previewHandlers.sort((a, b) => b.priority - a.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析文件名对应的预览处理器
|
||||||
|
* 按 priority 降序遍历,返回第一个 canHandle=true 的 handler,无匹配返回 null。
|
||||||
|
*/
|
||||||
|
export function resolvePreviewHandler(filename: string): FilePreviewHandlerDefinition | null {
|
||||||
|
if (!filename) return null
|
||||||
|
for (const handler of previewHandlers) {
|
||||||
|
try {
|
||||||
|
if (handler.canHandle(filename)) return handler
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[PreviewRegistry] handler ${handler.id} canHandle 异常:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件缓存:同一插件的异步组件只创建一次,避免重复 defineAsyncComponent
|
||||||
|
const componentCache = new Map<string, Component>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析文件名对应的预览组件(Vue 异步组件,内部缓存)。
|
||||||
|
* 供 <component :is> 直接使用;无匹配返回 null。
|
||||||
|
* 与 resolvePreviewHandler 分离:handler 用于标题(icon/name),component 用于渲染。
|
||||||
|
*/
|
||||||
|
export function resolvePreviewComponent(filename: string): Component | null {
|
||||||
|
const handler = resolvePreviewHandler(filename)
|
||||||
|
if (!handler?.getComponent) return null
|
||||||
|
let comp = componentCache.get(handler.id)
|
||||||
|
if (!comp) {
|
||||||
|
comp = defineAsyncComponent(handler.getComponent)
|
||||||
|
componentCache.set(handler.id, comp)
|
||||||
|
}
|
||||||
|
return comp
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// 预览插件类型定义
|
||||||
|
// 对齐 docs/02-架构设计/插件化架构方案.md 3.2 节 FilePreviewHandlerDefinition,day-1 简化到 drawio 所需字段。
|
||||||
|
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
/** 文件预览处理器定义 */
|
||||||
|
export interface FilePreviewHandlerDefinition {
|
||||||
|
/** 插件唯一标识,如 'drawio-preview' */
|
||||||
|
id: string
|
||||||
|
/** 展示名,用于面板标题 */
|
||||||
|
name: string
|
||||||
|
/** 图标(emoji 或图标名) */
|
||||||
|
icon: string
|
||||||
|
/** 优先级,越大越优先(resolvePreviewHandler 按 priority 降序遍历) */
|
||||||
|
priority: number
|
||||||
|
/** 判断是否能处理该文件名(严格扩展名匹配,避免吞其他类型) */
|
||||||
|
canHandle: (filename: string) => boolean
|
||||||
|
/** 异步加载预览组件(配合 defineAsyncComponent 懒加载,返回 Promise<Component>) */
|
||||||
|
getComponent?: () => Promise<Component>
|
||||||
|
/** 是否支持编辑(day-1 drawio 只读) */
|
||||||
|
supportsEdit?: boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { On, Off } from '@wailsio/events'
|
||||||
|
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||||
|
|
||||||
|
/** 传输条目(与后端 transfer-progress 事件 payload 对应) */
|
||||||
|
export interface TransferItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
copied: number
|
||||||
|
total: number
|
||||||
|
status: 'running' | 'done' | 'error'
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完成条目自动移除延迟 */
|
||||||
|
const DONE_REMOVE_DELAY = 2000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 传输进度 Store — 监听后端 transfer-progress 事件
|
||||||
|
* 下载/上传/本地复制共用,done 后 2s 自动移除,error 手动关闭
|
||||||
|
*/
|
||||||
|
export const useTransferStore = defineStore('transfer', () => {
|
||||||
|
const items = ref<TransferItem[]>([])
|
||||||
|
|
||||||
|
const handleProgress = (event: unknown) => {
|
||||||
|
const data = parseWailsEventData(event)
|
||||||
|
const id = String(data.id || '')
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const status = (data.status as TransferItem['status']) || 'running'
|
||||||
|
const index = items.value.findIndex(i => i.id === id)
|
||||||
|
if (index === -1) {
|
||||||
|
items.value.push({
|
||||||
|
id,
|
||||||
|
name: String(data.name || ''),
|
||||||
|
copied: Number(data.copied) || 0,
|
||||||
|
total: Number(data.total) || 0,
|
||||||
|
status,
|
||||||
|
error: data.error ? String(data.error) : undefined
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const item = items.value[index]
|
||||||
|
// 结束事件只带状态,保留上一帧的名称/进度
|
||||||
|
if (data.name) item.name = String(data.name)
|
||||||
|
if (data.copied !== undefined) item.copied = Number(data.copied) || item.copied
|
||||||
|
if (data.total !== undefined) item.total = Number(data.total) || item.total
|
||||||
|
item.status = status
|
||||||
|
if (data.error) item.error = String(data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'done') {
|
||||||
|
const item = items.value.find(i => i.id === id)
|
||||||
|
if (item) {
|
||||||
|
item.copied = item.total
|
||||||
|
setTimeout(() => dismiss(id), DONE_REMOVE_DELAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动移除条目 */
|
||||||
|
const dismiss = (id: string) => {
|
||||||
|
items.value = items.value.filter(i => i.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置事件监听 */
|
||||||
|
const setupEventListeners = () => {
|
||||||
|
On('transfer-progress', handleProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除事件监听 */
|
||||||
|
const removeEventListeners = () => {
|
||||||
|
Off('transfer-progress')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, setupEventListeners, removeEventListeners, dismiss }
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { formatBytes as formatFileSize } from '@/utils/fileUtils'
|
import { formatBytes as formatFileSize } from '@/utils/fileUtils'
|
||||||
|
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||||
import {
|
import {
|
||||||
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate
|
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate
|
||||||
} from '../wailsjs/v3-bindings/u-desk/app'
|
} from '../wailsjs/v3-bindings/u-desk/app'
|
||||||
@@ -35,14 +36,6 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
const MIN_DISPLAY_TIME = 5000 // 5 秒最小显示时间
|
const MIN_DISPLAY_TIME = 5000 // 5 秒最小显示时间
|
||||||
|
|
||||||
// ==================== 工具函数 ====================
|
// ==================== 工具函数 ====================
|
||||||
const parseEventData = (event: unknown) => {
|
|
||||||
try {
|
|
||||||
return typeof event === 'string' ? JSON.parse(event) : (event as Record<string, unknown>)
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatSpeed = (bytesPerSecond: number): string => {
|
const formatSpeed = (bytesPerSecond: number): string => {
|
||||||
return formatFileSize(bytesPerSecond) + '/s'
|
return formatFileSize(bytesPerSecond) + '/s'
|
||||||
}
|
}
|
||||||
@@ -167,7 +160,7 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastUpdateTime = now
|
lastUpdateTime = now
|
||||||
const data = parseEventData(event)
|
const data = parseWailsEventData(event)
|
||||||
|
|
||||||
progressInfo.value = {
|
progressInfo.value = {
|
||||||
speed: (data.speed as number) || 0,
|
speed: (data.speed as number) || 0,
|
||||||
@@ -184,7 +177,7 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
* 下载完成处理
|
* 下载完成处理
|
||||||
*/
|
*/
|
||||||
const onDownloadComplete = (event: unknown) => {
|
const onDownloadComplete = (event: unknown) => {
|
||||||
const data = parseEventData(event)
|
const data = parseWailsEventData(event)
|
||||||
|
|
||||||
// 错误处理
|
// 错误处理
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
|
|||||||
@@ -429,3 +429,127 @@ body.resizing {
|
|||||||
.arco-tooltip-content-white::before {
|
.arco-tooltip-content-white::before {
|
||||||
background: var(--color-bg-1) !important;
|
background: var(--color-bg-1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== 暗色模式覆盖 ========== */
|
||||||
|
|
||||||
|
/* --- hljs 代码高亮暗色 --- */
|
||||||
|
body[arco-theme*='dark'] .hljs {
|
||||||
|
background: #1e1e2e;
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .hljs-comment,
|
||||||
|
body[arco-theme*='dark'] .hljs-quote {
|
||||||
|
color: #6c7086;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .hljs-keyword,
|
||||||
|
body[arco-theme*='dark'] .hljs-selector-tag,
|
||||||
|
body[arco-theme*='dark'] .hljs-addition {
|
||||||
|
color: #cba6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .hljs-number,
|
||||||
|
body[arco-theme*='dark'] .hljs-string,
|
||||||
|
body[arco-theme*='dark'] .hljs-meta .hljs-string,
|
||||||
|
body[arco-theme*='dark'] .hljs-literal,
|
||||||
|
body[arco-theme*='dark'] .hljs-doctag,
|
||||||
|
body[arco-theme*='dark'] .hljs-regexp {
|
||||||
|
color: #a6e3a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .hljs-title,
|
||||||
|
body[arco-theme*='dark'] .hljs-section,
|
||||||
|
body[arco-theme*='dark'] .hljs-name,
|
||||||
|
body[arco-theme*='dark'] .hljs-selector-id,
|
||||||
|
body[arco-theme*='dark'] .hljs-selector-class {
|
||||||
|
color: #89b4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .hljs-attribute,
|
||||||
|
body[arco-theme*='dark'] .hljs-attr,
|
||||||
|
body[arco-theme*='dark'] .hljs-variable,
|
||||||
|
body[arco-theme*='dark'] .hljs-template-variable,
|
||||||
|
body[arco-theme*='dark'] .hljs-class .hljs-title,
|
||||||
|
body[arco-theme*='dark'] .hljs-type {
|
||||||
|
color: #fab387;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .hljs-symbol,
|
||||||
|
body[arco-theme*='dark'] .hljs-bullet,
|
||||||
|
body[arco-theme*='dark'] .hljs-subst,
|
||||||
|
body[arco-theme*='dark'] .hljs-meta,
|
||||||
|
body[arco-theme*='dark'] .hljs-meta .hljs-keyword,
|
||||||
|
body[arco-theme*='dark'] .hljs-selector-attr,
|
||||||
|
body[arco-theme*='dark'] .hljs-selector-pseudo,
|
||||||
|
body[arco-theme*='dark'] .hljs-link {
|
||||||
|
color: #f9e2af;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .hljs-built_in,
|
||||||
|
body[arco-theme*='dark'] .hljs-deletion {
|
||||||
|
color: #f38ba8;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .hljs-formula {
|
||||||
|
background: #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .hljs-addition {
|
||||||
|
background-color: rgba(166, 227, 161, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .hljs-deletion {
|
||||||
|
background-color: rgba(243, 139, 168, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Markdown 预览暗色 --- */
|
||||||
|
body[arco-theme*='dark'] .markdown-body {
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .markdown-body h1 {
|
||||||
|
border-bottom-color: #45475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .markdown-body h2 {
|
||||||
|
border-bottom-color: #45475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .markdown-body h6 {
|
||||||
|
color: #a6adc8;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .markdown-body blockquote {
|
||||||
|
color: #a6adc8;
|
||||||
|
border-left-color: #45475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .markdown-body code {
|
||||||
|
background-color: rgba(205, 214, 244, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .markdown-body pre {
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .markdown-body table th,
|
||||||
|
body[arco-theme*='dark'] .markdown-body table td {
|
||||||
|
border-color: #45475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .markdown-body table th {
|
||||||
|
background-color: #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .markdown-body table tr:nth-child(even) {
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .markdown-body img {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[arco-theme*='dark'] .markdown-body hr {
|
||||||
|
background-color: #45475a;
|
||||||
|
}
|
||||||
@@ -147,6 +147,8 @@ export interface FileListPanelConfig {
|
|||||||
editingFilePath: string
|
editingFilePath: string
|
||||||
/** 编辑中的文件名 */
|
/** 编辑中的文件名 */
|
||||||
editingFileName: string
|
editingFileName: string
|
||||||
|
/** 被剪切的文件路径(行样式半透明) */
|
||||||
|
cutPaths?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -209,6 +211,10 @@ export interface FileEditorPanelConfig {
|
|||||||
previewTabs?: any[]
|
previewTabs?: any[]
|
||||||
/** 当前激活的 Tab ID */
|
/** 当前激活的 Tab ID */
|
||||||
activeTabId?: string | null
|
activeTabId?: string | null
|
||||||
|
/** 预览插件处理器(用于面板标题;null 则走内置 v-if 链) */
|
||||||
|
activePlugin?: import('@/plugin/types').FilePreviewHandlerDefinition | null
|
||||||
|
/** 预览插件组件(用于渲染;null 则走内置 v-if 链) */
|
||||||
|
activePluginComponent?: import('vue').Component | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,6 +236,10 @@ export interface ContextMenuConfig {
|
|||||||
context: ContextMenuContext
|
context: ContextMenuContext
|
||||||
/** 选中的文件(file-list 上下文) */
|
/** 选中的文件(file-list 上下文) */
|
||||||
selectedFile?: FileItem
|
selectedFile?: FileItem
|
||||||
|
/** 是否 SFTP 面板(file 上下文显示「下载到本机」) */
|
||||||
|
isSftp?: boolean
|
||||||
|
/** 文件剪贴板是否为空(blank 上下文「粘贴」项禁用) */
|
||||||
|
clipboardEmpty?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -89,6 +89,36 @@ export async function loadLanguageExtension(language) {
|
|||||||
extension = StreamLanguage.define(powerShell)
|
extension = StreamLanguage.define(powerShell)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'batch': {
|
||||||
|
// 自写 DOS batch 流式模式:REM/:: 整行注释,%var% 变量,常用关键字
|
||||||
|
const { StreamLanguage } = await import('@codemirror/language')
|
||||||
|
const batchKeywords = new Set('echo off on set setlocal endlocal enabledelayedexpansion if not exist defined equ neq lss leq gtr geq for in do goto call exit exitb start del erase copy move ren rename md mkdir rd rmdir cd chdir type cls pause shift errorlevel title ver vol prompt pushd popd xcopy robocopy powershell cmd'.split(' '))
|
||||||
|
extension = StreamLanguage.define({
|
||||||
|
name: 'batch',
|
||||||
|
token(stream) {
|
||||||
|
if (stream.sol()) {
|
||||||
|
stream.eatSpace()
|
||||||
|
// 行首 REM(大小写不敏感)或 :: 开头的整行注释;:标签名 视作 keyword
|
||||||
|
if (stream.match(/^rem(?:\s|$)/i) || stream.match(/^::/)) {
|
||||||
|
stream.skipToEnd()
|
||||||
|
return 'comment'
|
||||||
|
}
|
||||||
|
if (stream.match(/^:[\w.-]+/)) return 'keyword'
|
||||||
|
}
|
||||||
|
if (stream.eatSpace()) return null
|
||||||
|
if (stream.match(/^"(?:[^"\r\n]|"")*"?/)) return 'string'
|
||||||
|
if (stream.eat('^')) return 'operator'
|
||||||
|
// !var! 延迟变量、%var% 环境变量、%~d0 类批处理参数
|
||||||
|
if (stream.match(/^!\w+!/) || stream.match(/^%\w+%/) || stream.match(/^%~[a-z]*[\d*]/i)) return 'atom'
|
||||||
|
if (stream.match(/^\w+/)) {
|
||||||
|
return batchKeywords.has(stream.current().toLowerCase()) ? 'keyword' : null
|
||||||
|
}
|
||||||
|
stream.next()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'dart': {
|
case 'dart': {
|
||||||
const { StreamLanguage, clike: dart } = await import('@codemirror/legacy-modes/mode/clike')
|
const { StreamLanguage, clike: dart } = await import('@codemirror/legacy-modes/mode/clike')
|
||||||
extension = StreamLanguage.define(dart)
|
extension = StreamLanguage.define(dart)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const FILE_EXTENSIONS = {
|
|||||||
CODE: [
|
CODE: [
|
||||||
'js', 'ts', 'jsx', 'tsx', 'cts', 'mts', 'cjs', 'mjs',
|
'js', 'ts', 'jsx', 'tsx', 'cts', 'mts', 'cjs', 'mjs',
|
||||||
'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
|
'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
|
||||||
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'ps1',
|
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'cmd', 'ps1',
|
||||||
'flow', 'pch', 'cc', 'cxx', 'hpp', 'hxx', 'tcc', 'defs', 'makefile', 'mk', 'cmake',
|
'flow', 'pch', 'cc', 'cxx', 'hpp', 'hxx', 'tcc', 'defs', 'makefile', 'mk', 'cmake',
|
||||||
'dockerfile', 'm', 'r', 'matlab'
|
'dockerfile', 'm', 'r', 'matlab'
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Excel 解析 Worker
|
||||||
|
* 把 XLSX.read() 与 sheet_to_json() 这两个【同步阻塞】操作隔离到独立线程,
|
||||||
|
* 避免大表解析冻结 webview 主线程(导致整个窗口点击无响应)。
|
||||||
|
*
|
||||||
|
* 协议:
|
||||||
|
* 主线程 -> { type: 'parse', reqId, wid, buffer: ArrayBuffer } (buffer 会被 transfer)
|
||||||
|
* 主线程 -> { type: 'sheet', reqId, wid, idx }
|
||||||
|
* worker -> { type: 'ready', reqId, wid, sheetNames: string[] }
|
||||||
|
* worker -> { type: 'sheet', reqId, wid, idx, data: any[][] }
|
||||||
|
* worker -> { type: 'stale', reqId } // wid 不匹配当前已加载工作簿
|
||||||
|
* worker -> { type: 'error', reqId, message }
|
||||||
|
*
|
||||||
|
* wid(workbook id)由主线程递增分配;切到新文件时 wid 变化,
|
||||||
|
* 旧 wid 的 sheet 请求会被识别为 stale,防止乱序回包覆盖新内容。
|
||||||
|
*/
|
||||||
|
|
||||||
|
let XLSX = null
|
||||||
|
let workbook = null
|
||||||
|
let loadedWid = 0
|
||||||
|
|
||||||
|
async function ensureXlsx() {
|
||||||
|
if (!XLSX) XLSX = await import('xlsx')
|
||||||
|
return XLSX
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = async (e) => {
|
||||||
|
const msg = e.data || {}
|
||||||
|
const { type, reqId, wid, idx, buffer } = msg
|
||||||
|
try {
|
||||||
|
if (type === 'parse') {
|
||||||
|
const X = await ensureXlsx()
|
||||||
|
workbook = X.read(buffer, { type: 'array' })
|
||||||
|
loadedWid = wid
|
||||||
|
self.postMessage({ type: 'ready', reqId, wid, sheetNames: workbook.SheetNames || [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'sheet') {
|
||||||
|
// wid 不匹配(已切到别的文件)→ 丢弃,避免旧回包覆盖
|
||||||
|
if (!workbook || wid !== loadedWid) {
|
||||||
|
self.postMessage({ type: 'stale', reqId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const X = await ensureXlsx()
|
||||||
|
const ws = workbook.Sheets[workbook.SheetNames[idx]]
|
||||||
|
const data = X.utils.sheet_to_json(ws, { header: 1, defval: '' })
|
||||||
|
self.postMessage({ type: 'sheet', reqId, wid, idx, data })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({ type: 'error', reqId, message: err && err.message ? err.message : String(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Excel 解析 Worker 单例客户端
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 懒加载并复用单个 excel.worker.js;
|
||||||
|
* 2. 用 wid(generation)机制忽略过期解析结果——快速切换文件时,旧文件的
|
||||||
|
* 解析回包会被 isCurrent(wid) 判定为 stale 而丢弃;
|
||||||
|
* 3. Worker 不可用时退化为「主线程同步解析」,保证功能不丢(极少数老 webview)。
|
||||||
|
*
|
||||||
|
* 根治点:主线程不再出现 XLSX.read() 同步调用,从源头消除卡死。
|
||||||
|
*/
|
||||||
|
|
||||||
|
let worker = null
|
||||||
|
let useFallback = false
|
||||||
|
const pending = new Map() // reqId -> { resolve, reject }
|
||||||
|
let nextReqId = 1
|
||||||
|
let currentWid = 0 // 每次 parseWorkbook 自增;切文件 / 自愈时也会变
|
||||||
|
|
||||||
|
// ---- fallback(无 Worker 环境的同步退化实现)----
|
||||||
|
let fbXlsx = null
|
||||||
|
let fbWorkbook = null
|
||||||
|
let fbWid = 0
|
||||||
|
|
||||||
|
function getWorker() {
|
||||||
|
if (worker) return worker
|
||||||
|
if (useFallback) return null
|
||||||
|
try {
|
||||||
|
worker = new Worker(new URL('./excel.worker.js', import.meta.url), { type: 'module' })
|
||||||
|
worker.onmessage = (e) => {
|
||||||
|
const msg = e.data || {}
|
||||||
|
const p = pending.get(msg.reqId)
|
||||||
|
if (!p) return
|
||||||
|
pending.delete(msg.reqId)
|
||||||
|
if (msg.type === 'error') p.reject(new Error(msg.message || 'excel worker error'))
|
||||||
|
else p.resolve(msg)
|
||||||
|
}
|
||||||
|
worker.onerror = (e) => {
|
||||||
|
// worker 崩溃:reject 所有在途请求,并退化为同步
|
||||||
|
for (const p of pending.values()) p.reject(new Error(e.message || 'excel worker crash'))
|
||||||
|
pending.clear()
|
||||||
|
useFallback = true
|
||||||
|
worker = null
|
||||||
|
}
|
||||||
|
return worker
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[excelWorkerClient] Worker 创建失败,退化为同步解析:', e)
|
||||||
|
useFallback = true
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(type, payload, transfer = []) {
|
||||||
|
const w = getWorker()
|
||||||
|
if (!w) return Promise.reject(new Error('worker unavailable'))
|
||||||
|
const reqId = nextReqId++
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pending.set(reqId, { resolve, reject })
|
||||||
|
w.postMessage({ type, reqId, ...payload }, transfer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureFallbackXlsx() {
|
||||||
|
if (!fbXlsx) fbXlsx = await import('xlsx')
|
||||||
|
return fbXlsx
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fallbackParse(buffer, wid) {
|
||||||
|
const X = await ensureFallbackXlsx()
|
||||||
|
fbWorkbook = X.read(buffer, { type: 'array' })
|
||||||
|
fbWid = wid
|
||||||
|
return { wid, sheetNames: fbWorkbook.SheetNames || [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析整个工作簿(在 worker 线程)。buffer 会被 transfer,主线程不再持有。
|
||||||
|
* @returns {Promise<{ wid: number, sheetNames: string[] }>}
|
||||||
|
*/
|
||||||
|
export async function parseWorkbook(buffer) {
|
||||||
|
const wid = ++currentWid
|
||||||
|
if (useFallback || !getWorker()) {
|
||||||
|
return fallbackParse(buffer, wid)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await send('parse', { wid, buffer }, [buffer])
|
||||||
|
return { wid, sheetNames: res.sheetNames || [] }
|
||||||
|
} catch (e) {
|
||||||
|
// worker 通道异常 → 本次及后续退化为同步
|
||||||
|
console.warn('[excelWorkerClient] parse 走 worker 失败,退化为同步:', e)
|
||||||
|
useFallback = true
|
||||||
|
return await fallbackParse(buffer, wid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取某个 sheet 的二维数据(在 worker 线程)。
|
||||||
|
* @returns {Promise<any[][]>}
|
||||||
|
*/
|
||||||
|
export async function getSheet(wid, idx) {
|
||||||
|
if (wid !== currentWid) throw new Error('stale')
|
||||||
|
if (useFallback) {
|
||||||
|
if (wid !== fbWid || !fbWorkbook) throw new Error('stale')
|
||||||
|
const X = await ensureFallbackXlsx()
|
||||||
|
const ws = fbWorkbook.Sheets[fbWorkbook.SheetNames[idx]]
|
||||||
|
return X.utils.sheet_to_json(ws, { header: 1, defval: '' })
|
||||||
|
}
|
||||||
|
const res = await send('sheet', { wid, idx })
|
||||||
|
if (res.type === 'stale') throw new Error('stale')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指定 wid 是否仍是当前(最新)解析任务,用于丢弃过期回包 */
|
||||||
|
export function isCurrent(wid) {
|
||||||
|
return wid === currentWid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 让所有在途解析结果立即作废(wid 推进)。
|
||||||
|
* 卡死自愈时调用,确保旧解析不会在恢复后覆盖界面。
|
||||||
|
*/
|
||||||
|
export function invalidateExcelPreview() {
|
||||||
|
currentWid++
|
||||||
|
}
|
||||||
@@ -1,30 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* Office 文件预览处理器
|
* Office 文件预览处理器
|
||||||
|
*
|
||||||
|
* Excel 解析已迁移到 Web Worker(见 excel.worker.js + excelWorkerClient.js),
|
||||||
|
* 主线程不再出现 XLSX.read() 同步调用,避免大表解析冻结整个界面。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { escapeHtml } from './fileUtils'
|
import { escapeHtml } from './fileUtils'
|
||||||
import { isExcelFile, isWordFile, isOfficeFile, isCsvFile } from './fileTypeHelpers'
|
import { isExcelFile, isWordFile, isOfficeFile, isCsvFile } from './fileTypeHelpers'
|
||||||
|
import { parseWorkbook, getSheet, isCurrent } from './excelWorkerClient'
|
||||||
|
|
||||||
// 每批加载行数
|
// 每批加载行数
|
||||||
const BATCH_ROWS = 200
|
const BATCH_ROWS = 200
|
||||||
const LOAD_MORE_THRESHOLD = 100
|
const LOAD_MORE_THRESHOLD = 100
|
||||||
|
|
||||||
// Excel 预览处理器
|
// Excel 预览处理器(解析在 Web Worker 中进行,不阻塞主线程)
|
||||||
export async function previewExcel(file, container) {
|
export async function previewExcel(file, container) {
|
||||||
const XLSX = await import('xlsx')
|
// 1. 取二进制并交给 worker 解析(XLSX.read / sheet_to_json 全在 worker 线程)
|
||||||
const arrayBuffer = await file.arrayBuffer()
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
|
const { wid, sheetNames } = await parseWorkbook(arrayBuffer)
|
||||||
|
// 切到别的文件时 wid 会推进,丢弃本次结果
|
||||||
|
if (!isCurrent(wid)) return { success: true }
|
||||||
|
|
||||||
// 渲染标签页
|
// 2. 渲染骨架(标签页 + 信息栏 + 内容区)
|
||||||
const tabs = workbook.SheetNames.map((name, i) =>
|
const tabs = sheetNames.map((name, i) =>
|
||||||
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${name}</button>`
|
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${escapeHtml(name)}</button>`
|
||||||
).join('')
|
).join('')
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="excel-preview">
|
<div class="excel-preview">
|
||||||
<div class="excel-tabs">${tabs}</div>
|
<div class="excel-tabs">${tabs}</div>
|
||||||
<div class="excel-info"></div>
|
<div class="excel-info"></div>
|
||||||
<div class="excel-content"></div>
|
<div class="excel-content"><div class="loading-hint">解析中...</div></div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
.excel-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
|
.excel-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
|
||||||
@@ -41,6 +47,7 @@ export async function previewExcel(file, container) {
|
|||||||
.excel-content th.row-num{z-index:3;top:0;left:0}
|
.excel-content th.row-num{z-index:3;top:0;left:0}
|
||||||
.excel-content tr:hover td{background:var(--color-fill-1)}
|
.excel-content tr:hover td{background:var(--color-fill-1)}
|
||||||
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
|
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
|
||||||
|
.loading-hint{padding:24px;text-align:center;color:var(--color-text-3);font-size:13px}
|
||||||
</style>
|
</style>
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -49,19 +56,15 @@ export async function previewExcel(file, container) {
|
|||||||
const tabsEl = container.querySelector('.excel-tabs')
|
const tabsEl = container.querySelector('.excel-tabs')
|
||||||
|
|
||||||
// 当前 sheet 状态
|
// 当前 sheet 状态
|
||||||
let currentSheet = { idx: 0, data: null, renderedRows: 0, totalRows: 0 }
|
let currentSheet = { idx: -1, data: null, renderedRows: 0, totalRows: 0 }
|
||||||
|
// 渲染令牌:切 sheet 自增,丢弃乱序回包
|
||||||
// 获取 sheet 数据
|
let renderToken = 0
|
||||||
const getSheetData = (idx) => {
|
|
||||||
const ws = workbook.Sheets[workbook.SheetNames[idx]]
|
|
||||||
return XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染表格(带行号)
|
// 渲染表格(带行号)
|
||||||
const renderTable = (data, startRow = 0) => {
|
const renderTable = (data, startRow = 0) => {
|
||||||
let html = '<table><thead><tr><th class="row-num">#</th>'
|
let html = '<table><thead><tr><th class="row-num">#</th>'
|
||||||
if (data[0]) {
|
if (data[0]) {
|
||||||
data[0].forEach((cell, i) => {
|
data[0].forEach((cell) => {
|
||||||
html += `<th>${escapeHtml(cell)}</th>`
|
html += `<th>${escapeHtml(cell)}</th>`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -80,7 +83,7 @@ export async function previewExcel(file, container) {
|
|||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
// 追加行
|
// 追加行(从内存中的 data 追加,不再过 worker)
|
||||||
const appendRows = (data, fromRow, toRow) => {
|
const appendRows = (data, fromRow, toRow) => {
|
||||||
const tbody = contentEl.querySelector('tbody')
|
const tbody = contentEl.querySelector('tbody')
|
||||||
if (!tbody) return
|
if (!tbody) return
|
||||||
@@ -107,17 +110,35 @@ export async function previewExcel(file, container) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换 sheet
|
// 切换 sheet(worker 异步解析单个 sheet)
|
||||||
const loadSheet = (idx) => {
|
const loadSheet = async (idx) => {
|
||||||
currentSheet = {
|
const token = ++renderToken
|
||||||
idx,
|
|
||||||
data: getSheetData(idx),
|
|
||||||
renderedRows: BATCH_ROWS,
|
|
||||||
totalRows: 0
|
|
||||||
}
|
|
||||||
currentSheet.totalRows = currentSheet.data.length
|
|
||||||
|
|
||||||
const displayData = currentSheet.data.slice(0, BATCH_ROWS + 1)
|
// 标签高亮
|
||||||
|
tabsEl.querySelectorAll('.excel-tab').forEach(t => {
|
||||||
|
t.classList.toggle('active', parseInt(t.dataset.idx, 10) === idx)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 该 sheet 尚未解析过 → 显示解析中
|
||||||
|
if (currentSheet.idx !== idx) {
|
||||||
|
contentEl.innerHTML = '<div class="loading-hint">解析中...</div>'
|
||||||
|
}
|
||||||
|
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
data = await getSheet(wid, idx)
|
||||||
|
} catch (e) {
|
||||||
|
if (isCurrent(wid) && token === renderToken) {
|
||||||
|
contentEl.innerHTML = `<div class="preview-error"><p>❌ Excel 解析失败</p><p class="error-detail">${escapeHtml(e && e.message ? e.message : String(e))}</p></div>`
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 丢弃过期 / 乱序回包
|
||||||
|
if (!isCurrent(wid) || token !== renderToken) return
|
||||||
|
|
||||||
|
currentSheet = { idx, data, renderedRows: BATCH_ROWS, totalRows: data.length }
|
||||||
|
const displayData = data.slice(0, BATCH_ROWS + 1)
|
||||||
contentEl.innerHTML = renderTable(displayData)
|
contentEl.innerHTML = renderTable(displayData)
|
||||||
updateInfo()
|
updateInfo()
|
||||||
}
|
}
|
||||||
@@ -127,7 +148,7 @@ export async function previewExcel(file, container) {
|
|||||||
const { scrollTop, scrollHeight, clientHeight } = contentEl
|
const { scrollTop, scrollHeight, clientHeight } = contentEl
|
||||||
if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
|
if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
|
||||||
const { data, renderedRows, totalRows } = currentSheet
|
const { data, renderedRows, totalRows } = currentSheet
|
||||||
if (renderedRows < totalRows - 1) {
|
if (data && renderedRows < totalRows - 1) {
|
||||||
const newEnd = Math.min(renderedRows + BATCH_ROWS, totalRows - 1)
|
const newEnd = Math.min(renderedRows + BATCH_ROWS, totalRows - 1)
|
||||||
appendRows(data, renderedRows + 1, newEnd)
|
appendRows(data, renderedRows + 1, newEnd)
|
||||||
currentSheet.renderedRows = newEnd
|
currentSheet.renderedRows = newEnd
|
||||||
@@ -136,17 +157,17 @@ export async function previewExcel(file, container) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSheet(0)
|
|
||||||
|
|
||||||
// 标签页切换
|
// 标签页切换
|
||||||
tabsEl.onclick = (e) => {
|
tabsEl.onclick = (e) => {
|
||||||
const tab = e.target.closest('.excel-tab')
|
const tab = e.target.closest('.excel-tab')
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
tabsEl.querySelectorAll('.excel-tab').forEach(t => t.classList.remove('active'))
|
const idx = parseInt(tab.dataset.idx, 10)
|
||||||
tab.classList.add('active')
|
if (idx === currentSheet.idx) return
|
||||||
loadSheet(parseInt(tab.dataset.idx, 10))
|
loadSheet(idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadSheet(0)
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +254,7 @@ export async function previewCsv(file, container) {
|
|||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="csv-preview">
|
<div class="csv-preview">
|
||||||
<div class="csv-info">📋 ${file.name}</div>
|
<div class="csv-info">📋 ${escapeHtml(file.name)}</div>
|
||||||
<div class="csv-content"></div>
|
<div class="csv-content"></div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -29,13 +29,15 @@ export const PREVIEWABLE_TYPES = [
|
|||||||
*/
|
*/
|
||||||
export const KNOWN_BINARY_TYPES = [
|
export const KNOWN_BINARY_TYPES = [
|
||||||
// 可执行文件
|
// 可执行文件
|
||||||
'exe', 'dll', 'so', 'bin',
|
'exe', 'dll', 'so', 'bin', 'dat', 'db', 'sqlite',
|
||||||
// 压缩文件
|
// 压缩文件
|
||||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
|
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
|
||||||
|
// 编译/中间产物
|
||||||
|
'pdb', 'idb', 'lib', 'obj', 'o', 'a', 'class', 'pyc', 'pyo', 'wasm',
|
||||||
|
// 安装包
|
||||||
|
'msi', 'jar', 'war', 'ear', 'apk',
|
||||||
// Office PowerPoint(暂不支持预览)
|
// Office PowerPoint(暂不支持预览)
|
||||||
'ppt', 'pptx',
|
'ppt', 'pptx',
|
||||||
// 其他二进制
|
|
||||||
'pdb', 'idb', 'lib', 'obj', 'o', 'a'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 主线程卡死检测 + 事后自愈(主线程侧控制器)
|
||||||
|
*
|
||||||
|
* 能力边界(必须如实告知):
|
||||||
|
* - 主线程冻结「进行中」无法自救(回调被一起冻住);
|
||||||
|
* - 本模块只能「事后」检测到冻结(心跳断流),并在主线程恢复后做自愈;
|
||||||
|
* - 自愈动作绝不包含 location.reload(),否则会与「启动自动预览历史 Excel」
|
||||||
|
* 形成死循环(reload → 再次自动预览 → 再次冻结 → 再次 reload)。
|
||||||
|
*
|
||||||
|
* 自愈动作(每次冻结事件只执行一次,有 healing 防抖):
|
||||||
|
* 1. invalidateExcelPreview() —— 推进 wid,丢弃在途 Excel 解析回包;
|
||||||
|
* 2. 清掉持久化会话(LAST_OPENED_FILE + MULTI_PREVIEW_TABS),断开 reload-loop;
|
||||||
|
* 3. 置位 autorestore-disabled 标记,下次启动 restoreSession 主动跳过自动恢复;
|
||||||
|
* 4. toast 提示用户。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { STORAGE_KEYS } from './constants'
|
||||||
|
import { invalidateExcelPreview } from './excelWorkerClient'
|
||||||
|
|
||||||
|
const AUTORESTORE_DISABLED_KEY = 'app-filesystem-autorestore-disabled'
|
||||||
|
|
||||||
|
let started = false
|
||||||
|
let healing = false
|
||||||
|
|
||||||
|
export function isAutoRestoreDisabled(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(AUTORESTORE_DISABLED_KEY) === '1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAutoRestoreDisabled(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(AUTORESTORE_DISABLED_KEY)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAutoRestoreDisabled(): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AUTORESTORE_DISABLED_KEY, '1')
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function heal(freezeMs: number) {
|
||||||
|
if (healing) return // 同一次冻结可能触发多次 recovered,只处理一次
|
||||||
|
healing = true
|
||||||
|
|
||||||
|
console.error(`[freezeWatchdog] 检测到主线程冻结约 ${(freezeMs / 1000).toFixed(1)}s,执行自愈`)
|
||||||
|
|
||||||
|
// 1. 丢弃在途 Excel 解析
|
||||||
|
try {
|
||||||
|
invalidateExcelPreview()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 清持久化会话 —— 断开 reload-loop 的关键
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.FILESYSTEM.MULTI_PREVIEW_TABS)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 置位熔断标记:下次启动跳过自动恢复(防御纵深,即便上一步失败也不会 loop)
|
||||||
|
setAutoRestoreDisabled()
|
||||||
|
|
||||||
|
// 4. 提示用户(懒加载避免循环依赖)
|
||||||
|
import('@arco-design/web-vue')
|
||||||
|
.then(({ Message }) => {
|
||||||
|
Message.warning(`检测到界面卡顿 ${(freezeMs / 1000).toFixed(0)}s,已自动恢复并清理历史预览会话`)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* ignore */
|
||||||
|
})
|
||||||
|
|
||||||
|
// 短暂冷却后释放防抖锁,允许后续「新的」冻结事件再次自愈
|
||||||
|
setTimeout(() => {
|
||||||
|
healing = false
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动看门狗(幂等,全进程只需启动一次) */
|
||||||
|
export function startFreezeWatchdog(): void {
|
||||||
|
if (started) return
|
||||||
|
if (typeof Worker === 'undefined') return // 环境不支持,静默跳过
|
||||||
|
try {
|
||||||
|
const w = new Worker(new URL('./freezeWatchdog.worker.js', import.meta.url), { type: 'module' })
|
||||||
|
w.onmessage = (e) => {
|
||||||
|
const msg = e.data || {}
|
||||||
|
if (msg.type === 'ping') {
|
||||||
|
// 主线程活着 → 立即回 pong(这一行在冻结期间跑不到,正是检测依据)
|
||||||
|
w.postMessage({ type: 'pong' })
|
||||||
|
} else if (msg.type === 'recovered') {
|
||||||
|
heal(msg.freezeMs || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.onerror = (e) => {
|
||||||
|
console.warn('[freezeWatchdog] worker 异常,看门狗停止:', e.message)
|
||||||
|
}
|
||||||
|
started = true
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[freezeWatchdog] 启动失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 卡死看门狗 Worker
|
||||||
|
*
|
||||||
|
* 原理:主线程被【同步任务】阻塞时,连定时器回调都跑不了。
|
||||||
|
* 因此由独立 worker 线程周期性 ping 主线程;主线程收到立即 pong。
|
||||||
|
* 若 worker 在 FREEZE_MS 内收不到 pong,即可断定主线程被冻结。
|
||||||
|
*
|
||||||
|
* 注意:worker 线程本身不被主线程的同步阻塞影响,故能在冻结期间持续计时。
|
||||||
|
* 这里只有一个 2s 的低频定时器,不是忙循环,不会消耗 CPU。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PING_MS = 2000 // 每 2s 探测一次
|
||||||
|
const FREEZE_MS = 8000 // 超过 8s 没 pong 即判定冻结(正常异步 IO 不会触发)
|
||||||
|
|
||||||
|
let lastPongTs = Date.now()
|
||||||
|
let frozen = false
|
||||||
|
let freezeStartTs = 0
|
||||||
|
|
||||||
|
self.setInterval(() => {
|
||||||
|
self.postMessage({ type: 'ping' })
|
||||||
|
|
||||||
|
const sincePong = Date.now() - lastPongTs
|
||||||
|
if (!frozen && sincePong > FREEZE_MS) {
|
||||||
|
// 进入冻结态,记录起点(用最后一次 pong 时间近似冻结起点)
|
||||||
|
frozen = true
|
||||||
|
freezeStartTs = lastPongTs
|
||||||
|
}
|
||||||
|
}, PING_MS)
|
||||||
|
|
||||||
|
self.onmessage = (e) => {
|
||||||
|
const msg = e.data || {}
|
||||||
|
if (msg.type !== 'pong') return
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
if (frozen) {
|
||||||
|
// 主线程恢复响应 → 报告本次冻结时长
|
||||||
|
frozen = false
|
||||||
|
self.postMessage({ type: 'recovered', freezeMs: now - freezeStartTs })
|
||||||
|
}
|
||||||
|
lastPongTs = now
|
||||||
|
}
|
||||||
@@ -65,7 +65,10 @@ const extensionToLanguage: Record<string, { hljs?: string; cm?: string }> = {
|
|||||||
shell: { hljs: 'bash', cm: 'shell' },
|
shell: { hljs: 'bash', cm: 'shell' },
|
||||||
zsh: { hljs: 'bash', cm: 'shell' },
|
zsh: { hljs: 'bash', cm: 'shell' },
|
||||||
ps1: { hljs: 'powershell', cm: 'powershell' },
|
ps1: { hljs: 'powershell', cm: 'powershell' },
|
||||||
bat: { hljs: 'dos', cm: 'text' },
|
psm1: { hljs: 'powershell', cm: 'powershell' },
|
||||||
|
psd1: { hljs: 'powershell', cm: 'powershell' },
|
||||||
|
cmd: { hljs: 'dos', cm: 'batch' },
|
||||||
|
bat: { hljs: 'dos', cm: 'batch' },
|
||||||
ahk: { hljs: 'autohotkey', cm: 'text' },
|
ahk: { hljs: 'autohotkey', cm: 'text' },
|
||||||
lua: { hljs: 'lua', cm: 'text' },
|
lua: { hljs: 'lua', cm: 'text' },
|
||||||
r: { hljs: 'r', cm: 'text' },
|
r: { hljs: 'r', cm: 'text' },
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Wails 事件 payload 解析(统一入口)
|
||||||
|
*
|
||||||
|
* 运行时 On 回调收到的是 WailsEvent 对象,真实载荷在其 .data 字段;
|
||||||
|
* 不同版本/路径下 payload 可能是 JSON 字符串或对象,这里统一兼容:
|
||||||
|
* 解包 .data → 字符串则 JSON.parse → 异常/空值回落空对象。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function parseWailsEventData(event: unknown): any {
|
||||||
|
const data = (event as any)?.data ?? event
|
||||||
|
try {
|
||||||
|
return typeof data === 'string' ? JSON.parse(data) : (data ?? {})
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,13 @@ export function ClearCache(): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(1413834504);
|
return $Call.ByID(1413834504);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度
|
||||||
|
*/
|
||||||
|
export function CopyPath(src: string, dst: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(1297703531, src, dst);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CreateDir 创建目录
|
* CreateDir 创建目录
|
||||||
*/
|
*/
|
||||||
@@ -175,6 +182,15 @@ export function GetCommonPaths(): $CancellablePromise<{ [_ in string]?: string }
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EnsureDrawioViewer 确保 drawio viewer 已下载就绪(用于 .drawio/.dio 预览)
|
||||||
|
*/
|
||||||
|
export function EnsureDrawioViewer(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||||
|
return $Call.ByID(1345697171).then(($result: any) => {
|
||||||
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GetCurrentVersion 获取当前版本号
|
* GetCurrentVersion 获取当前版本号
|
||||||
*/
|
*/
|
||||||
@@ -318,6 +334,13 @@ export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
|
||||||
|
*/
|
||||||
|
export function MovePath(src: string, dst: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(2028899675, src, dst);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenPath 使用系统默认程序打开文件或目录
|
* OpenPath 使用系统默认程序打开文件或目录
|
||||||
*/
|
*/
|
||||||
@@ -448,6 +471,13 @@ export function OssWriteFile(connID: string, key: string, content: string): $Can
|
|||||||
return $Call.ByID(39773277, connID, key, content);
|
return $Call.ByID(39773277, connID, key, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PickDirectory 打开系统目录选择器,返回选中目录(取消时返回空串)
|
||||||
|
*/
|
||||||
|
export function PickDirectory(): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(1347829059);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReadFile 读取文件
|
* ReadFile 读取文件
|
||||||
*/
|
*/
|
||||||
@@ -608,6 +638,20 @@ export function SftpDownloadToTempCached(connID: string, remotePath: string, fil
|
|||||||
return $Call.ByID(3935472409, connID, remotePath, fileSize, modTime);
|
return $Call.ByID(3935472409, connID, remotePath, fileSize, modTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpDownloadToFile 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本)
|
||||||
|
*/
|
||||||
|
export function SftpDownloadToFile(connID: string, remotePath: string, localDstDir: string): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(2935422351, connID, remotePath, localDstDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpUploadFromFile 上传本地文件/目录到远程目录(流式,目录递归,重名自动副本)
|
||||||
|
*/
|
||||||
|
export function SftpUploadFromFile(connID: string, localPath: string, remoteDir: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(3304594335, connID, localPath, remoteDir);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SftpGetCommonPaths 获取 SFTP 远程主机常用路径
|
* SftpGetCommonPaths 获取 SFTP 远程主机常用路径
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,8 +15,10 @@
|
|||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"],
|
||||||
|
"@bindings/*": ["bindings/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export default defineConfig({
|
|||||||
'@wailsio/events': fileURLToPath(new URL('./node_modules/@wailsio/runtime/dist/events.js', import.meta.url))
|
'@wailsio/events': fileURLToPath(new URL('./node_modules/@wailsio/runtime/dist/events.js', import.meta.url))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Excel 解析 / 卡死看门狗使用 module worker(new Worker(url,{type:'module'})),
|
||||||
|
// 且 worker 内部用了动态 import('xlsx') 需要代码分割,故 worker 格式必须为 es。
|
||||||
|
// 目标平台 Windows WebView2(Chromium) 完整支持 module worker。
|
||||||
|
worker: {
|
||||||
|
format: 'es'
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ require (
|
|||||||
github.com/chromedp/chromedp v0.15.1
|
github.com/chromedp/chromedp v0.15.1
|
||||||
github.com/fsnotify/fsnotify v1.10.1
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/labstack/echo/v4 v4.15.1
|
github.com/labstack/echo/v4 v4.15.2
|
||||||
github.com/pkg/sftp v1.13.10
|
github.com/pkg/sftp v1.13.10
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.80
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.92
|
||||||
github.com/yuin/goldmark v1.7.16
|
github.com/yuin/goldmark v1.8.2
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.51.0
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sync v0.20.0
|
||||||
golang.org/x/text v0.33.0
|
golang.org/x/sys v0.44.0
|
||||||
|
golang.org/x/text v0.37.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
@@ -22,7 +23,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||||
github.com/adrg/xdg v0.5.3 // indirect
|
github.com/adrg/xdg v0.5.3 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||||
@@ -30,13 +31,13 @@ require (
|
|||||||
github.com/coder/websocket v1.8.14 // indirect
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.9.1 // indirect
|
github.com/ebitengine/purego v0.10.0 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
github.com/go-git/go-billy/v5 v5.9.0 // indirect
|
||||||
github.com/go-git/go-git/v5 v5.16.4 // indirect
|
github.com/go-git/go-git/v5 v5.19.0 // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
@@ -48,39 +49,40 @@ require (
|
|||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/kr/fs v0.1.0 // indirect
|
github.com/kr/fs v0.1.0 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.5.0 // indirect
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
github.com/leaanthony/u v1.1.1 // indirect
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
github.com/lmittmann/tint v1.1.2 // indirect
|
github.com/lmittmann/tint v1.1.3 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/samber/lo v1.52.0 // indirect
|
github.com/samber/lo v1.53.0 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
github.com/shoenig/go-m1cpu v0.2.1 // indirect
|
||||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
github.com/wailsapp/wails/webview2 v1.0.24 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.44.3 // indirect
|
modernc.org/sqlite v1.50.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
|||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
@@ -30,30 +30,30 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
|
||||||
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
|
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc=
|
||||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 h1:5KGUhXZFTN1PrCY4zUZLe1J8n7uBNmPDbCLCn78EbPQ=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
@@ -67,7 +67,6 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
|||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
@@ -84,8 +83,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
|||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
@@ -97,35 +96,35 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
|
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
|
||||||
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
||||||
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -134,8 +133,8 @@ github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
|||||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
@@ -143,16 +142,16 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4=
|
||||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
|
||||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||||
@@ -161,36 +160,36 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
|
|||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.92 h1:LYVe5RcGDB/x1QTWE0JbonXZc7+BmCtjb3+mHa1riao=
|
||||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.92/go.mod h1:4cKvtUppwqYC9tVtvgHWzEmXfUnuLEV3q8d0Jh6xkQQ=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.80 h1:AeJGPYEf4MnEzNAqHzmhWTuvv7ZyKL51hFQsDxEWBks=
|
github.com/wailsapp/wails/webview2 v1.0.24 h1:uULnjCSaRfMlU84mS3kjLgPsRosEOIusVK1nFOHZHzs=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.80/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
|
github.com/wailsapp/wails/webview2 v1.0.24/go.mod h1:sdf+s0nAdxlzVWf9SCxC15XaxnQPJeY+uU1Ucn3jHQM=
|
||||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -202,22 +201,19 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
@@ -230,30 +226,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
"golang.org/x/text/transform"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InterfaceSliceToStringSlice 将 []interface{} 安全转换为 []string
|
// InterfaceSliceToStringSlice 将 []interface{} 安全转换为 []string
|
||||||
@@ -16,8 +28,7 @@ func InterfaceSliceToStringSlice(slice []interface{}) []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatBytes 格式化字节大小为人类可读格式
|
// FormatBytes 格式化字节大小为人类可读格式(uint64 版本)
|
||||||
// 例如: 1024 → "1.00 KB", 1048576 → "1.00 MB"
|
|
||||||
func FormatBytes(bytes uint64) string {
|
func FormatBytes(bytes uint64) string {
|
||||||
const unit = 1024
|
const unit = 1024
|
||||||
if bytes < unit {
|
if bytes < unit {
|
||||||
@@ -31,6 +42,104 @@ func FormatBytes(bytes uint64) string {
|
|||||||
return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatBytesInt 格式化字节大小(int64 版本,用于文件大小)
|
||||||
|
func FormatBytesInt(bytes int64) string {
|
||||||
|
return FormatBytes(uint64(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
_, err = out.ReadFrom(in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BytesToString 智能编码转换:UTF-8 直接返回,否则尝试 GBK → UTF-8
|
||||||
|
func BytesToString(data []byte) string {
|
||||||
|
if utf8.Valid(data) {
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
reader := transform.NewReader(bytes.NewReader(data), simplifiedchinese.GBK.NewDecoder())
|
||||||
|
decoded, err := io.ReadAll(reader)
|
||||||
|
if err != nil || !utf8.Valid(decoded) {
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
return string(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
htmlResRegex = regexp.MustCompile(`(?:src|href|data-src|data-url|poster)=["']([^"']+)["']`)
|
||||||
|
htmlSrcsetRegex = regexp.MustCompile(`srcset\s*=\s*["']([^"']+)["']`)
|
||||||
|
htmlCssUrlRe = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseSrcsetUrls 从 srcset 属性值中提取各 URL
|
||||||
|
// srcset 格式: "small.jpg 480w, large.jpg 1024w" → ["small.jpg", "large.jpg"]
|
||||||
|
func parseSrcsetUrls(srcset string) []string {
|
||||||
|
var urls []string
|
||||||
|
for _, candidate := range strings.Split(srcset, ",") {
|
||||||
|
parts := strings.Fields(strings.TrimSpace(candidate))
|
||||||
|
if len(parts) > 0 {
|
||||||
|
urls = append(urls, parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractHtmlResources 从 HTML 内容提取资源路径
|
||||||
|
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 htmlResRegex.FindAllStringSubmatch(html, -1) {
|
||||||
|
if len(m) > 1 {
|
||||||
|
add(m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range htmlSrcsetRegex.FindAllStringSubmatch(html, -1) {
|
||||||
|
if len(m) > 1 {
|
||||||
|
for _, u := range parseSrcsetUrls(m[1]) {
|
||||||
|
add(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range htmlCssUrlRe.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:")
|
||||||
|
}
|
||||||
|
|
||||||
// Contains 检查切片是否包含元素
|
// Contains 检查切片是否包含元素
|
||||||
func Contains[T comparable](slice []T, item T) bool {
|
func Contains[T comparable](slice []T, item T) bool {
|
||||||
for _, s := range slice {
|
for _, s := range slice {
|
||||||
@@ -61,3 +170,81 @@ func IsWindows() bool {
|
|||||||
return runtime.GOOS == "windows"
|
return runtime.GOOS == "windows"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// siteCacheDir 返回站点预览的确定性缓存目录路径
|
||||||
|
func SiteCacheDir(transport, connID, remotePath string, fileSize int64, modTime string) string {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(fmt.Sprintf("site:%s:%s:%s:%d:%s", transport, connID, remotePath, fileSize, modTime)))
|
||||||
|
hash := fmt.Sprintf("%x", h.Sum(nil))[:16]
|
||||||
|
return filepath.Join(os.TempDir(), "u-desk-cache", "site-"+hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 站点预览补充下载限制(sftp 与 ossdrv 共用)
|
||||||
|
const (
|
||||||
|
MaxSupplementFiles = 50 // 每目录补充下载文件数上限
|
||||||
|
MaxSupplementFileSize = 5 << 20 // 单文件补充下载大小上限(5MB)
|
||||||
|
SiteDownloadConcurrency = 8 // 相对路径资源并行下载并发数
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunConcurrent 以最多 limit 个并发执行 tasks,全部完成后返回
|
||||||
|
// fn 在独立 goroutine 中运行,需自行处理错误与共享状态的加锁
|
||||||
|
func RunConcurrent[T any](tasks []T, limit int, fn func(t T)) {
|
||||||
|
if len(tasks) == 0 || limit <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if limit > len(tasks) {
|
||||||
|
limit = len(tasks)
|
||||||
|
}
|
||||||
|
sem := make(chan struct{}, limit)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, task := range tasks {
|
||||||
|
sem <- struct{}{}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(t T) {
|
||||||
|
defer func() { <-sem; wg.Done() }()
|
||||||
|
fn(t)
|
||||||
|
}(task)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniqueName 返回不与现有条目冲突的文件名:重名时追加「 - 副本」「 - 副本 (2)」后缀(扩展名前插入)。
|
||||||
|
// exists 报告候选文件名是否已被占用(本地文件系统与 SFTP 各自提供实现)。
|
||||||
|
func UniqueName(name string, exists func(candidate string) (bool, error)) (string, error) {
|
||||||
|
taken, err := exists(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !taken {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
base := strings.TrimSuffix(name, ext)
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
suffix := " - 副本"
|
||||||
|
if i > 1 {
|
||||||
|
suffix = fmt.Sprintf(" - 副本 (%d)", i)
|
||||||
|
}
|
||||||
|
candidate := base + suffix + ext
|
||||||
|
taken, err := exists(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !taken {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountingReader 包装读取器,按实际读取字节数回调 OnN(n > 0 时)
|
||||||
|
type CountingReader struct {
|
||||||
|
R io.Reader
|
||||||
|
OnN func(n int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cr *CountingReader) Read(buf []byte) (int, error) {
|
||||||
|
n, err := cr.R.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
cr.OnN(int64(n))
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ func StartLocalFileServer() (string, error) {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/localfs/", handleLocalFileRequest)
|
mux.HandleFunc("/localfs/", handleLocalFileRequest)
|
||||||
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
|
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
|
||||||
|
mux.HandleFunc("/drawio-viewer/", handleDrawioViewerRequest)
|
||||||
|
|
||||||
addr, srv, err := listenWithFallback(DefaultFileServerPort, mux)
|
addr, srv, err := listenWithFallback(DefaultFileServerPort, mux)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -155,26 +156,50 @@ func listenWithFallback(basePort int, handler http.Handler) (addr string, srv *h
|
|||||||
|
|
||||||
// GetLocalFileServerAddr 返回实际绑定的地址(含动态分配的端口)
|
// GetLocalFileServerAddr 返回实际绑定的地址(含动态分配的端口)
|
||||||
func GetLocalFileServerAddr() string {
|
func GetLocalFileServerAddr() string {
|
||||||
if localFileServer == nil { return fmt.Sprintf("http://localhost:%d", DefaultFileServerPort) }
|
if localFileServer == nil {
|
||||||
|
return fmt.Sprintf("http://localhost:%d", DefaultFileServerPort)
|
||||||
|
}
|
||||||
return localFileServer.addr
|
return localFileServer.addr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeCORSHeaders 写入 CORS 头并处理 OPTIONS 预检,返回 true 表示已处理(调用方应 return)
|
||||||
|
func writeCORSHeaders(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureGetMethod 检查请求是否为 GET,非 GET 返回 405,返回 true 表示不是 GET
|
||||||
|
func ensureGetMethod(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePathValidationError 将路径校验错误转换为 HTTP 响应
|
||||||
|
func handlePathValidationError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrPathInvalidEncoding):
|
||||||
|
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||||
|
case errors.Is(err, ErrPathTraversal):
|
||||||
|
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
||||||
|
case errors.Is(err, ErrPathUnsafe):
|
||||||
|
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||||
|
default:
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleLocalFileRequest 处理本地文件请求
|
// handleLocalFileRequest 处理本地文件请求
|
||||||
func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
// CORS 头:允许所有源访问(因为这是本地文件服务器)
|
if writeCORSHeaders(w, r) || ensureGetMethod(w, r) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
|
||||||
|
|
||||||
// 处理 OPTIONS 预检请求
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只处理 GET 请求
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,26 +220,11 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
pathPart = "/" + pathPart
|
pathPart = "/" + pathPart
|
||||||
}
|
}
|
||||||
|
|
||||||
if pathPart == "" || pathPart == r.URL.Path {
|
// 校验路径安全性
|
||||||
log.Printf("[LocalFileHandler] 路径前缀无效")
|
|
||||||
http.Error(w, "Invalid path. Use: /localfs/C:/path/to/file", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
|
||||||
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]")
|
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[LocalFileHandler] 路径校验失败: %v (%s)", err, pathPart)
|
log.Printf("[LocalFileHandler] 路径校验失败: %v (%s)", err, pathPart)
|
||||||
switch {
|
handlePathValidationError(w, err)
|
||||||
case errors.Is(err, ErrPathInvalidEncoding):
|
|
||||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
|
||||||
case errors.Is(err, ErrPathTraversal):
|
|
||||||
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
|
||||||
case errors.Is(err, ErrPathUnsafe):
|
|
||||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
|
||||||
default:
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
||||||
@@ -515,20 +525,7 @@ func isAbsoluteURL(path string) bool {
|
|||||||
// - path: HTML 文件绝对路径(URL 编码)
|
// - path: HTML 文件绝对路径(URL 编码)
|
||||||
// - theme: 主题(light / dark)
|
// - theme: 主题(light / dark)
|
||||||
func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
// CORS
|
if writeCORSHeaders(w, r) || ensureGetMethod(w, r) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
|
||||||
|
|
||||||
// 处理 OPTIONS 预检请求
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只处理 GET 请求
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,20 +536,11 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
theme = "light"
|
theme = "light"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
// 校验路径安全性
|
||||||
filePath, err := validateFilePath(rawPath, "[HtmlPreview]")
|
filePath, err := validateFilePath(rawPath, "[HtmlPreview]")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[HtmlPreview] 路径校验失败: %v (%s)", err, rawPath)
|
log.Printf("[HtmlPreview] 路径校验失败: %v (%s)", err, rawPath)
|
||||||
switch {
|
handlePathValidationError(w, err)
|
||||||
case errors.Is(err, ErrPathInvalidEncoding):
|
|
||||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
|
||||||
case errors.Is(err, ErrPathTraversal):
|
|
||||||
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
|
||||||
case errors.Is(err, ErrPathUnsafe):
|
|
||||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
|
||||||
default:
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,14 +557,21 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 获取文件所在目录(用于解析相对路径)
|
// 获取文件所在目录(用于解析相对路径)
|
||||||
baseDir := filepath.Dir(filePath)
|
baseDir := filepath.Dir(filePath)
|
||||||
|
|
||||||
|
// 摘出 <script>/<style> 块做占位保护,防止资源路径正则误改 JS 字符串中的 HTML 文本
|
||||||
|
// (如模板字符串 `style="${x==='a'?...}"` 被引号重包裹破坏,导致整页脚本 SyntaxError 白屏)
|
||||||
|
protected := protectScriptStyleBlocks(string(content), baseDir)
|
||||||
|
|
||||||
// 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL)
|
// 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL)
|
||||||
processedContent := transformHtmlResourcePaths(string(content), baseDir)
|
processedContent := transformHtmlResourcePaths(protected.html, baseDir)
|
||||||
|
|
||||||
// 注入路径拦截脚本(处理 webpack 等动态加载的绝对路径资源)
|
// 注入路径拦截脚本(处理 webpack 等动态加载的绝对路径资源)
|
||||||
processedContent = injectPathInterceptor(processedContent, baseDir)
|
processedContent = injectPathInterceptor(processedContent, baseDir)
|
||||||
|
|
||||||
// 注入链接点击拦截脚本
|
// 注入链接点击拦截脚本
|
||||||
finalContent := injectLinkInterceptor(processedContent)
|
processedContent = injectLinkInterceptor(processedContent)
|
||||||
|
|
||||||
|
// 回填受保护的 script 内容;style 内容单独走 CSS url() 重写后回填
|
||||||
|
finalContent := restoreScriptStyleBlocks(processedContent, protected, baseDir)
|
||||||
|
|
||||||
// 返回处理后的 HTML
|
// 返回处理后的 HTML
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -586,6 +581,69 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("[HtmlPreview] 处理完成: %s (%d -> %d bytes)", filePath, len(content), len(finalContent))
|
log.Printf("[HtmlPreview] 处理完成: %s (%d -> %d bytes)", filePath, len(content), len(finalContent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scriptStylePlaceholder 占位标记(含随机性低的固定前缀,正常 HTML 不会出现)
|
||||||
|
const scriptStyleMarker = "UDESK_PROTECTED_BLOCK_"
|
||||||
|
|
||||||
|
// protectedBlocks 记录摘出的块信息
|
||||||
|
type protectedBlocks struct {
|
||||||
|
scripts []string // <script>...</script> 原文(含标签),回填时不做任何改写
|
||||||
|
styles []styleBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
// styleBlock 单个 <style> 块
|
||||||
|
type styleBlock struct {
|
||||||
|
openTag string // <style ...> 开标签
|
||||||
|
content string // 标签间内容(CSS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractScriptStyleRegex 摘块正则(非贪婪,覆盖有无属性的 script)
|
||||||
|
var extractScriptStyleRegex = regexp.MustCompile(`(?is)<script(\s[^>]*)?>(.*?)</script>|<style(\s[^>]*)?>(.*?)</style>`)
|
||||||
|
|
||||||
|
// protectScriptStyleBlocks 将所有 <script>/<style> 块替换为占位标记。
|
||||||
|
// 外链 script(带 src 属性)先重写 src 指向本地服务器,内容不做任何改写。
|
||||||
|
func protectScriptStyleBlocks(html string, baseDir string) protectedBlocksResult {
|
||||||
|
blocks := protectedBlocks{}
|
||||||
|
out := extractScriptStyleRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||||||
|
lower := strings.ToLower(match)
|
||||||
|
if strings.HasPrefix(lower, "<script") {
|
||||||
|
// 外链 script: 重写 src 属性(内联 script 无 src,attrRegex 不命中则原样)
|
||||||
|
rewritten := replaceHtmlTagAttribute(match, htmlScriptTagRegex, "src", baseDir)
|
||||||
|
idx := len(blocks.scripts)
|
||||||
|
blocks.scripts = append(blocks.scripts, rewritten)
|
||||||
|
return scriptStyleMarker + fmt.Sprintf("SCRIPT%d_", idx) + scriptStyleMarker
|
||||||
|
}
|
||||||
|
// <style> 块:拆开标签,内容交给后续 CSS 重写
|
||||||
|
sm := extractScriptStyleRegex.FindStringSubmatch(match)
|
||||||
|
// style 分支: sm[3]=属性 sm[4]=内容
|
||||||
|
openTag := "<style>"
|
||||||
|
if sm[3] != "" {
|
||||||
|
openTag = "<style" + sm[3] + ">"
|
||||||
|
}
|
||||||
|
idx := len(blocks.styles)
|
||||||
|
blocks.styles = append(blocks.styles, styleBlock{openTag: openTag, content: sm[4]})
|
||||||
|
return scriptStyleMarker + fmt.Sprintf("STYLE%d_", idx) + scriptStyleMarker
|
||||||
|
})
|
||||||
|
return protectedBlocksResult{html: out, blocks: blocks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// protectedBlocksResult 摘块结果
|
||||||
|
type protectedBlocksResult struct {
|
||||||
|
html string
|
||||||
|
blocks protectedBlocks
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreScriptStyleBlocks 回填 script 原文;style 内容做 CSS url()/@import 重写后回填
|
||||||
|
func restoreScriptStyleBlocks(html string, protected protectedBlocksResult, baseDir string) string {
|
||||||
|
for i, s := range protected.blocks.scripts {
|
||||||
|
html = strings.Replace(html, scriptStyleMarker+fmt.Sprintf("SCRIPT%d_", i)+scriptStyleMarker, s, 1)
|
||||||
|
}
|
||||||
|
for i, b := range protected.blocks.styles {
|
||||||
|
content := transformCssContent(b.content, baseDir)
|
||||||
|
html = strings.Replace(html, scriptStyleMarker+fmt.Sprintf("STYLE%d_", i)+scriptStyleMarker, b.openTag+content+"</style>", 1)
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
// transformHtmlResourcePaths 转换 HTML 中的资源路径为本地文件服务器 URL
|
// transformHtmlResourcePaths 转换 HTML 中的资源路径为本地文件服务器 URL
|
||||||
func transformHtmlResourcePaths(htmlContent string, baseDir string) string {
|
func transformHtmlResourcePaths(htmlContent string, baseDir string) string {
|
||||||
if baseDir == "" {
|
if baseDir == "" {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -18,6 +19,7 @@ const (
|
|||||||
OperationDelete AuditOperation = "delete" // 删除文件
|
OperationDelete AuditOperation = "delete" // 删除文件
|
||||||
OperationCreate AuditOperation = "create" // 创建目录
|
OperationCreate AuditOperation = "create" // 创建目录
|
||||||
OperationRename AuditOperation = "rename" // 重命名
|
OperationRename AuditOperation = "rename" // 重命名
|
||||||
|
OperationCopy AuditOperation = "copy" // 复制
|
||||||
OperationMove AuditOperation = "move" // 移动
|
OperationMove AuditOperation = "move" // 移动
|
||||||
OperationList AuditOperation = "list" // 列出目录
|
OperationList AuditOperation = "list" // 列出目录
|
||||||
OperationDownload AuditOperation = "download" // 下载
|
OperationDownload AuditOperation = "download" // 下载
|
||||||
@@ -40,42 +42,44 @@ type AuditLogEntry struct {
|
|||||||
// AuditLogger 审计日志记录器
|
// AuditLogger 审计日志记录器
|
||||||
type AuditLogger struct {
|
type AuditLogger struct {
|
||||||
logFile *os.File
|
logFile *os.File
|
||||||
logPath string
|
logDir string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buffer []AuditLogEntry
|
buffer []AuditLogEntry
|
||||||
bufferSize int
|
bufferSize int
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
initialized bool // 懒初始化标志(失败时允许重试)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuditLogger 创建审计日志记录器
|
// NewAuditLogger 创建审计日志记录器(延迟初始化,不立即打开文件)
|
||||||
func NewAuditLogger(logDir string) (*AuditLogger, error) {
|
func NewAuditLogger(logDir string) (*AuditLogger, error) {
|
||||||
// 创建日志目录
|
return &AuditLogger{
|
||||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
logDir: logDir,
|
||||||
return nil, fmt.Errorf("创建日志目录失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成日志文件名(按日期)
|
|
||||||
timestamp := time.Now().Format("2006-01-02")
|
|
||||||
logPath := filepath.Join(logDir, fmt.Sprintf("audit_%s.log", timestamp))
|
|
||||||
|
|
||||||
// 打开日志文件(追加模式)
|
|
||||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("打开日志文件失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger := &AuditLogger{
|
|
||||||
logFile: logFile,
|
|
||||||
logPath: logPath,
|
|
||||||
buffer: make([]AuditLogEntry, 0, 100),
|
buffer: make([]AuditLogEntry, 0, 100),
|
||||||
bufferSize: 100, // 缓冲100条记录后批量写入
|
bufferSize: 100,
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动后台协程,定期刷新缓冲区
|
// ensureReady 首次写入时才创建目录、打开文件、启动后台协程
|
||||||
go logger.backgroundFlush()
|
// 在 a.mu.Lock 保护下调用,失败时下次 Log 会重试
|
||||||
|
func (a *AuditLogger) ensureReady() error {
|
||||||
return logger, nil
|
if a.initialized {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(a.logDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("创建日志目录失败: %v", err)
|
||||||
|
}
|
||||||
|
timestamp := time.Now().Format("2006-01-02")
|
||||||
|
f, err := os.OpenFile(
|
||||||
|
filepath.Join(a.logDir, fmt.Sprintf("audit_%s.log", timestamp)),
|
||||||
|
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.logFile = f
|
||||||
|
a.initialized = true
|
||||||
|
go a.backgroundFlush()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log 记录操作日志
|
// Log 记录操作日志
|
||||||
@@ -88,6 +92,13 @@ func (a *AuditLogger) Log(entry AuditLogEntry) error {
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
// 懒初始化:首次写入时才打开文件
|
||||||
|
if a.logFile == nil {
|
||||||
|
if err := a.ensureReady(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 添加到缓冲区
|
// 添加到缓冲区
|
||||||
a.buffer = append(a.buffer, entry)
|
a.buffer = append(a.buffer, entry)
|
||||||
|
|
||||||
@@ -211,6 +222,11 @@ func (a *AuditLogger) Close() error {
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
// 从未写入过日志,无需关闭
|
||||||
|
if a.logFile == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 刷新剩余缓冲区
|
// 刷新剩余缓冲区
|
||||||
if err := a.flush(); err != nil {
|
if err := a.flush(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -251,35 +267,15 @@ func GetRecentLogs(logDir string, limit int) ([]AuditLogEntry, error) {
|
|||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseLines 解析文本为行
|
// parseLines 解析文本为行(过滤空行)
|
||||||
func parseLines(text string) []string {
|
func parseLines(text string) []string {
|
||||||
lines := make([]string, 0)
|
raw := strings.Split(text, "\n")
|
||||||
current := ""
|
lines := make([]string, 0, len(raw))
|
||||||
|
for _, line := range raw {
|
||||||
for _, ch := range text {
|
if line != "" {
|
||||||
if ch == '\n' {
|
lines = append(lines, line)
|
||||||
if current != "" {
|
|
||||||
lines = append(lines, current)
|
|
||||||
current = ""
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
current += string(ch)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if current != "" {
|
|
||||||
lines = append(lines, current)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局审计日志记录器
|
|
||||||
var globalAuditLogger *AuditLogger
|
|
||||||
var auditLoggerOnce sync.Once
|
|
||||||
|
|
||||||
// GetAuditLogger 获取全局审计日志记录器
|
|
||||||
func GetAuditLogger() *AuditLogger {
|
|
||||||
return globalAuditLogger
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,8 @@ func getAllowedExtensions() map[string]bool {
|
|||||||
".md": true,
|
".md": true,
|
||||||
".json": true,
|
".json": true,
|
||||||
".xml": true,
|
".xml": true,
|
||||||
|
".drawio": true,
|
||||||
|
".dio": true,
|
||||||
".html": true,
|
".html": true,
|
||||||
".css": true,
|
".css": true,
|
||||||
".js": true,
|
".js": true,
|
||||||
@@ -369,6 +371,8 @@ func getMIMETypeMapping() map[string]string {
|
|||||||
".js": "application/javascript",
|
".js": "application/javascript",
|
||||||
".json": "application/json",
|
".json": "application/json",
|
||||||
".xml": "application/xml",
|
".xml": "application/xml",
|
||||||
|
".drawio": "application/xml",
|
||||||
|
".dio": "application/xml",
|
||||||
".md": "text/markdown",
|
".md": "text/markdown",
|
||||||
// 表格
|
// 表格
|
||||||
".csv": "text/csv; charset=utf-8",
|
".csv": "text/csv; charset=utf-8",
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/common"
|
||||||
|
"u-desk/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// drawio viewer 运行时下载缓存方案:
|
||||||
|
// 首次预览 .drawio 时,从自建下载源拉取 drawio webapp zip → 解压到 ~/.u-desk/drawio-viewer/
|
||||||
|
// → 文件服务器 /drawio-viewer/ 从缓存目录托管静态资源 → 之后离线可用。
|
||||||
|
// 升级 drawio 时:改下面的版本号 + URL + SHA256。
|
||||||
|
|
||||||
|
const (
|
||||||
|
drawioViewerVersion = "30.3.8" // drawio 版本,升级时改这里
|
||||||
|
drawioViewerZipURL = "https://c.1216.top/u-desk/drawio-viewer-30.3.8.war" // 自建下载源(draw.war 解压即 webapp)
|
||||||
|
drawioViewerSHA256 = "e12a3e190adda7c1486572bd28c1af9fffbe932df2efead3c9aa8347862505da" // draw.war v30.3.8 SHA256
|
||||||
|
drawioViewerDirName = "drawio-viewer"
|
||||||
|
versionFileName = "VERSION"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 并发保护:多个 .drawio 同时打开时只下载一次,其余等待
|
||||||
|
var (
|
||||||
|
drawioEnsureMu sync.Mutex
|
||||||
|
drawioEnsuring bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// DrawioViewerVersion 返回当前配置的 viewer 版本(供 binding 返回前端)
|
||||||
|
func DrawioViewerVersion() string { return drawioViewerVersion }
|
||||||
|
|
||||||
|
// drawioCacheDir 缓存目录 ~/.u-desk/drawio-viewer/
|
||||||
|
func drawioCacheDir() string {
|
||||||
|
return filepath.Join(common.GetUserDataDir(), drawioViewerDirName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDrawioViewerReady 缓存目录存在且 VERSION 文件匹配当前版本
|
||||||
|
func isDrawioViewerReady() bool {
|
||||||
|
data, err := os.ReadFile(filepath.Join(drawioCacheDir(), versionFileName))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data)) == drawioViewerVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDrawioViewer 确保本地已缓存 drawio viewer。未就绪则下载 zip + 解压。
|
||||||
|
// progress 回调推送下载进度(可为 nil)。并发安全:同时调用只下载一次,其余等待。
|
||||||
|
func EnsureDrawioViewer(progress service.DownloadProgress) error {
|
||||||
|
if isDrawioViewerReady() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发保护:已有线程在下载 → 轮询等待就绪
|
||||||
|
drawioEnsureMu.Lock()
|
||||||
|
if drawioEnsuring {
|
||||||
|
drawioEnsureMu.Unlock()
|
||||||
|
for i := 0; i < 3600; i++ { // 最多等待 30 分钟(与下载超时对齐)
|
||||||
|
if isDrawioViewerReady() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("等待 drawio viewer 下载超时")
|
||||||
|
}
|
||||||
|
drawioEnsuring = true
|
||||||
|
drawioEnsureMu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
drawioEnsureMu.Lock()
|
||||||
|
drawioEnsuring = false
|
||||||
|
drawioEnsureMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
cacheDir := drawioCacheDir()
|
||||||
|
|
||||||
|
// 1. 下载 zip(复用 service.DownloadUpdate,自带断点续传 + 哈希)
|
||||||
|
res, err := service.DownloadUpdate(drawioViewerZipURL, progress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("下载 drawio viewer 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. SHA256 校验(若常量非空)。DownloadUpdate 成功时必返回 SHA256Hash
|
||||||
|
if drawioViewerSHA256 != "" {
|
||||||
|
if !strings.EqualFold(res.SHA256Hash, drawioViewerSHA256) {
|
||||||
|
_ = os.Remove(res.FilePath) // 删除损坏文件,下次重试
|
||||||
|
return fmt.Errorf("drawio viewer 哈希校验失败: 期望 %s, 实际 %s", drawioViewerSHA256, res.SHA256Hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 解压到临时目录,成功后原子替换缓存目录(避免半解压状态)
|
||||||
|
tmpDir := cacheDir + ".tmp-" + drawioViewerVersion
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
if err := unzipToDir(res.FilePath, tmpDir); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return fmt.Errorf("解压 drawio viewer 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 写 VERSION 文件
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, versionFileName), []byte(drawioViewerVersion), 0644); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return fmt.Errorf("写入 VERSION 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 原子替换缓存目录
|
||||||
|
_ = os.RemoveAll(cacheDir)
|
||||||
|
if err := os.Rename(tmpDir, cacheDir); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return fmt.Errorf("移动 drawio viewer 到缓存目录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安装成功后删除下载的 war 包,避免长期占用磁盘(失败路径保留以支持断点续传)
|
||||||
|
_ = os.Remove(res.FilePath)
|
||||||
|
|
||||||
|
log.Printf("[DrawioViewer] 缓存就绪: %s (版本 %s)", cacheDir, drawioViewerVersion)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDrawioViewerRequest /drawio-viewer/* → 从缓存目录读静态资源(绕过文件类型白名单)
|
||||||
|
// viewer 资源含 .woff2/.stencil 等白名单外扩展名,故独立路由,不走 handleLocalFileRequest。
|
||||||
|
func handleDrawioViewerRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if writeCORSHeaders(w, r) || ensureGetMethod(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isDrawioViewerReady() {
|
||||||
|
http.Error(w, "drawio viewer not ready, please call EnsureDrawioViewer first", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剥离 /drawio-viewer/ 前缀
|
||||||
|
subPath := strings.TrimPrefix(r.URL.Path, "/drawio-viewer/")
|
||||||
|
if subPath == "" {
|
||||||
|
subPath = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径清理 + 防遍历:限制在缓存目录内
|
||||||
|
cleanPath, ok := sanitizeSubPath(subPath)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cleanPath == "." || cleanPath == "" {
|
||||||
|
cleanPath = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, ok := joinWithinBase(drawioCacheDir(), cleanPath)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", getDrawioViewerMIME(filepath.Ext(cleanPath)))
|
||||||
|
if cleanPath == "index.html" {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache") // 入口短缓存,便于升级
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
}
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeSubPath 将子路径(URL 或 zip 条目名)清理为正斜杠相对路径,含 .. 视为可疑,返回 false
|
||||||
|
func sanitizeSubPath(sub string) (string, bool) {
|
||||||
|
cleaned := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+sub)), "/")
|
||||||
|
if strings.Contains(cleaned, "..") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return cleaned, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinWithinBase 将相对路径拼进 base 并确认结果仍在 base 内(防符号链接绕过),越界返回 false
|
||||||
|
func joinWithinBase(base, sub string) (string, bool) {
|
||||||
|
absPath := filepath.Join(base, filepath.FromSlash(sub))
|
||||||
|
if rel, err := filepath.Rel(base, absPath); err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return absPath, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDrawioViewerMIME 返回 viewer 静态资源的 MIME
|
||||||
|
func getDrawioViewerMIME(ext string) string {
|
||||||
|
switch strings.ToLower(ext) {
|
||||||
|
case ".html", ".htm":
|
||||||
|
return "text/html; charset=utf-8"
|
||||||
|
case ".js", ".mjs":
|
||||||
|
return "application/javascript; charset=utf-8"
|
||||||
|
case ".css":
|
||||||
|
return "text/css; charset=utf-8"
|
||||||
|
case ".json":
|
||||||
|
return "application/json"
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml"
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".woff":
|
||||||
|
return "font/woff"
|
||||||
|
case ".woff2":
|
||||||
|
return "font/woff2"
|
||||||
|
case ".ttf":
|
||||||
|
return "font/ttf"
|
||||||
|
case ".xml":
|
||||||
|
return "application/xml"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 辅助函数 ====================
|
||||||
|
|
||||||
|
// unzipToDir 解压整个 zip 到目标目录(覆盖已存在文件)
|
||||||
|
func unzipToDir(zipPath, destDir string) error {
|
||||||
|
r, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range r.File {
|
||||||
|
if err := extractZipFile(f, destDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractZipFile 解压单个 zip 条目到目标目录(带路径遍历防护)
|
||||||
|
func extractZipFile(f *zip.File, destDir string) error {
|
||||||
|
name, ok := sanitizeSubPath(f.Name)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("可疑路径: %s", f.Name)
|
||||||
|
}
|
||||||
|
destPath, ok := joinWithinBase(destDir, name)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("逃逸路径: %s", f.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
return os.MkdirAll(destPath, 0755)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, rc)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package filesystem
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"golang.org/x/text/encoding/simplifiedchinese"
|
|
||||||
"golang.org/x/text/transform"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BytesToString 智能编码转换:UTF-8 直接返回,否则尝试 GBK → UTF-8
|
|
||||||
func BytesToString(data []byte) string {
|
|
||||||
if utf8.Valid(data) {
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
// 尝试 GBK 解码
|
|
||||||
reader := transform.NewReader(bytes.NewReader(data), simplifiedchinese.GBK.NewDecoder())
|
|
||||||
decoded, err := io.ReadAll(reader)
|
|
||||||
if err != nil || !utf8.Valid(decoded) {
|
|
||||||
return string(data) // 转换失败或结果无效,返回原始内容
|
|
||||||
}
|
|
||||||
return string(decoded)
|
|
||||||
}
|
|
||||||
@@ -20,8 +20,8 @@ func NewFileLockChecker() *FileLockChecker {
|
|||||||
// IsFileLocked 检查文件是否被锁定(被其他进程占用)
|
// IsFileLocked 检查文件是否被锁定(被其他进程占用)
|
||||||
// 返回: (是否锁定, 错误信息, 错误)
|
// 返回: (是否锁定, 错误信息, 错误)
|
||||||
func (c *FileLockChecker) IsFileLocked(path string) (bool, string, error) {
|
func (c *FileLockChecker) IsFileLocked(path string) (bool, string, error) {
|
||||||
// 尝试以独占写模式打开文件
|
// 尝试以独占写模式打开文件(不创建新文件)
|
||||||
file, err := os.OpenFile(path, os.O_RDWR|syscall.O_CREAT, 0666)
|
file, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 检查是否是锁相关的错误
|
// 检查是否是锁相关的错误
|
||||||
if isLockError(err) {
|
if isLockError(err) {
|
||||||
|
|||||||
@@ -50,19 +50,3 @@ func OpenPath(path string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 工具函数 ==========
|
|
||||||
|
|
||||||
// FormatBytes 格式化字节大小为人类可读格式(导出供 sftp 等外部包使用)
|
|
||||||
func FormatBytes(bytes int64) string {
|
|
||||||
const unit = 1024
|
|
||||||
if bytes < unit {
|
|
||||||
return fmt.Sprintf("%d B", bytes)
|
|
||||||
}
|
|
||||||
div, exp := int64(unit), 0
|
|
||||||
for n := bytes / unit; n >= unit; n /= unit {
|
|
||||||
div *= unit
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -103,43 +103,4 @@ func (l *Logger) log(level LogLevel, levelStr, format string, args ...interface{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogOperation 记录操作日志(辅助函数)
|
|
||||||
func LogOperation(operation, path string, success bool, err error) {
|
|
||||||
logger := GetGlobalLogger()
|
|
||||||
if logger == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if success {
|
|
||||||
logger.Info("操作: %s %s - 成功", operation, path)
|
|
||||||
} else {
|
|
||||||
logger.Error("操作: %s %s - 失败: %v", operation, path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogError 记录错误日志(辅助函数)
|
|
||||||
func LogError(operation string, path string, err error) {
|
|
||||||
logger := GetGlobalLogger()
|
|
||||||
if logger == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Error("错误: %s %s - %v", operation, path, err)
|
|
||||||
|
|
||||||
// 如果是调试模式,输出堆栈跟踪
|
|
||||||
if os.Getenv("UDESK_DEBUG") == "1" {
|
|
||||||
logger.Debug("堆栈:\n%s", GetStackTrace(2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 全局日志记录器(向后兼容)==========
|
|
||||||
|
|
||||||
var (
|
|
||||||
globalLogger *Logger
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetGlobalLogger 获取全局日志记录器
|
|
||||||
func GetGlobalLogger() *Logger {
|
|
||||||
return globalLogger
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"u-desk/internal/common"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ type RecycleBinEntry struct {
|
|||||||
|
|
||||||
// RecycleBin 回收站管理器
|
// RecycleBin 回收站管理器
|
||||||
type RecycleBin struct {
|
type RecycleBin struct {
|
||||||
|
mu sync.Mutex
|
||||||
binPath string
|
binPath string
|
||||||
metadataFile string
|
metadataFile string
|
||||||
entries []RecycleBinEntry
|
entries []RecycleBinEntry
|
||||||
@@ -57,6 +59,8 @@ func NewRecycleBin(binPath string) (*RecycleBin, error) {
|
|||||||
|
|
||||||
// MoveToRecycleBin 移动文件到回收站
|
// MoveToRecycleBin 移动文件到回收站
|
||||||
func (rb *RecycleBin) MoveToRecycleBin(path string) error {
|
func (rb *RecycleBin) MoveToRecycleBin(path string) error {
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
// 获取文件信息
|
// 获取文件信息
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,6 +115,8 @@ func (rb *RecycleBin) MoveToRecycleBin(path string) error {
|
|||||||
|
|
||||||
// RestoreFromRecycleBin 从回收站恢复文件
|
// RestoreFromRecycleBin 从回收站恢复文件
|
||||||
func (rb *RecycleBin) RestoreFromRecycleBin(recyclePath string) error {
|
func (rb *RecycleBin) RestoreFromRecycleBin(recyclePath string) error {
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
// 查找对应的元数据条目
|
// 查找对应的元数据条目
|
||||||
var entry *RecycleBinEntry
|
var entry *RecycleBinEntry
|
||||||
for i := range rb.entries {
|
for i := range rb.entries {
|
||||||
@@ -156,6 +162,8 @@ func (rb *RecycleBin) RestoreFromRecycleBin(recyclePath string) error {
|
|||||||
|
|
||||||
// DeletePermanently 永久删除回收站中的文件
|
// DeletePermanently 永久删除回收站中的文件
|
||||||
func (rb *RecycleBin) DeletePermanently(recyclePath string) error {
|
func (rb *RecycleBin) DeletePermanently(recyclePath string) error {
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
// 查找元数据条目
|
// 查找元数据条目
|
||||||
for i, entry := range rb.entries {
|
for i, entry := range rb.entries {
|
||||||
if entry.DeletedPath == recyclePath {
|
if entry.DeletedPath == recyclePath {
|
||||||
@@ -180,11 +188,15 @@ func (rb *RecycleBin) DeletePermanently(recyclePath string) error {
|
|||||||
|
|
||||||
// ListEntries 列出回收站中的所有条目
|
// ListEntries 列出回收站中的所有条目
|
||||||
func (rb *RecycleBin) ListEntries() []RecycleBinEntry {
|
func (rb *RecycleBin) ListEntries() []RecycleBinEntry {
|
||||||
return rb.entries
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
|
return append([]RecycleBinEntry(nil), rb.entries...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty 清空回收站
|
// Empty 清空回收站
|
||||||
func (rb *RecycleBin) Empty() error {
|
func (rb *RecycleBin) Empty() error {
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
// 删除所有文件
|
// 删除所有文件
|
||||||
for _, entry := range rb.entries {
|
for _, entry := range rb.entries {
|
||||||
if err := os.RemoveAll(entry.DeletedPath); err != nil {
|
if err := os.RemoveAll(entry.DeletedPath); err != nil {
|
||||||
@@ -215,6 +227,8 @@ func (rb *RecycleBin) autoCleanup() {
|
|||||||
|
|
||||||
// cleanupExpiredEntries 清理过期的条目
|
// cleanupExpiredEntries 清理过期的条目
|
||||||
func (rb *RecycleBin) cleanupExpiredEntries() {
|
func (rb *RecycleBin) cleanupExpiredEntries() {
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
expiredEntries := make([]int, 0)
|
expiredEntries := make([]int, 0)
|
||||||
|
|
||||||
@@ -274,7 +288,7 @@ func copyRecursively(src, dst string) error {
|
|||||||
return copyDirectory(src, dst)
|
return copyDirectory(src, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
return copyFile(src, dst)
|
return common.CopyFile(src, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyDirectory 复制目录
|
// copyDirectory 复制目录
|
||||||
@@ -300,7 +314,7 @@ func copyDirectory(src, dst string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := copyFile(srcPath, dstPath); err != nil {
|
if err := common.CopyFile(srcPath, dstPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,36 +323,6 @@ func copyDirectory(src, dst string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyFile 复制文件
|
|
||||||
func copyFile(src, dst string) error {
|
|
||||||
// 打开源文件
|
|
||||||
srcFile, err := os.Open(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer srcFile.Close()
|
|
||||||
|
|
||||||
// 创建目标文件
|
|
||||||
dstFile, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer dstFile.Close()
|
|
||||||
|
|
||||||
// 复制内容
|
|
||||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 复制文件权限
|
|
||||||
srcInfo, err := os.Stat(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.Chmod(dst, srcInfo.Mode())
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDevice 获取文件所在设备(盘符)
|
// getDevice 获取文件所在设备(盘符)
|
||||||
func getDevice(path string) string {
|
func getDevice(path string) string {
|
||||||
absPath, err := filepath.Abs(path)
|
absPath, err := filepath.Abs(path)
|
||||||
@@ -363,8 +347,8 @@ func generateRandomString(length int) string {
|
|||||||
for i := range b {
|
for i := range b {
|
||||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 如果加密随机数生成失败,回退到时间戳(极低概率)
|
// crypto/rand 失败时用时间戳+索引偏移避免碰撞
|
||||||
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
|
b[i] = charset[(time.Now().UnixNano()+int64(i))%int64(len(charset))]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b[i] = charset[n.Int64()]
|
b[i] = charset[n.Int64()]
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -142,11 +145,10 @@ func (s *FileSystemService) ReadFile(path string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.logRead(path, int64(len(data)), nil)
|
s.logRead(path, int64(len(data)), nil)
|
||||||
return BytesToString(data), nil
|
return common.BytesToString(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write 写入文件内容(实现 FileService 接口)
|
// writeFileWithLog 内部写入实现(路径验证+大小检查+写入+日志)
|
||||||
// writeFile 内部写入实现(路径验证+大小检查+写入+日志)
|
|
||||||
func (s *FileSystemService) writeFileWithLog(path string, data []byte) error {
|
func (s *FileSystemService) writeFileWithLog(path string, data []byte) error {
|
||||||
if err := s.validatePath(path); err != nil {
|
if err := s.validatePath(path); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -228,28 +230,20 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行删除
|
// 回收站优先:先尝试移动,失败再永久删除
|
||||||
var deleteErr error
|
if s.recycleBin != nil && !isInRecycleBin(path) {
|
||||||
if info.IsDir() {
|
|
||||||
deleteErr = os.RemoveAll(path)
|
|
||||||
} else {
|
|
||||||
deleteErr = os.Remove(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logDelete(path, info.IsDir(), info.Size(), deleteErr)
|
|
||||||
|
|
||||||
if deleteErr != nil {
|
|
||||||
return nil, fmt.Errorf("删除失败: %v", deleteErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果启用回收站,移动到回收站而非永久删除
|
|
||||||
if s.recycleBin != nil {
|
|
||||||
// 检查是否已在回收站中
|
|
||||||
if !isInRecycleBin(path) {
|
|
||||||
if err := s.recycleBin.MoveToRecycleBin(path); err != nil {
|
if err := s.recycleBin.MoveToRecycleBin(path); err != nil {
|
||||||
// 回收站失败,记录但继续
|
slog.Warn("回收站移动失败,改为永久删除", "error", err)
|
||||||
fmt.Printf("[警告] 移动到回收站失败: %v\n", err)
|
if err := s.permanentDelete(path, info); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
s.logDelete(path, info.IsDir(), info.Size(), nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 无回收站或已在回收站中,永久删除
|
||||||
|
if err := s.permanentDelete(path, info); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +252,7 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
|
|||||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||||
Name: info.Name(),
|
Name: info.Name(),
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
SizeStr: FormatBytes(info.Size()),
|
SizeStr: common.FormatBytesInt(info.Size()),
|
||||||
IsDir: info.IsDir(),
|
IsDir: info.IsDir(),
|
||||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
Mode: info.Mode().String(),
|
Mode: info.Mode().String(),
|
||||||
@@ -365,7 +359,7 @@ func (s *FileSystemService) CreateDir(path string) (*FileOperationResult, error)
|
|||||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||||
Name: info.Name(),
|
Name: info.Name(),
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
SizeStr: FormatBytes(info.Size()),
|
SizeStr: common.FormatBytesInt(info.Size()),
|
||||||
IsDir: true,
|
IsDir: true,
|
||||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
Mode: info.Mode().String(),
|
Mode: info.Mode().String(),
|
||||||
@@ -413,7 +407,7 @@ func (s *FileSystemService) CreateFile(path string) (*FileOperationResult, error
|
|||||||
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
Path: filepath.ToSlash(path), // 统一使用正斜杠
|
||||||
Name: info.Name(),
|
Name: info.Name(),
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
SizeStr: FormatBytes(info.Size()),
|
SizeStr: common.FormatBytesInt(info.Size()),
|
||||||
IsDir: false,
|
IsDir: false,
|
||||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
Mode: info.Mode().String(),
|
Mode: info.Mode().String(),
|
||||||
@@ -438,7 +432,7 @@ func (s *FileSystemService) GetFileInfo(path string) (map[string]interface{}, er
|
|||||||
"name": info.Name(),
|
"name": info.Name(),
|
||||||
"path": filepath.ToSlash(path), // 统一使用正斜杠
|
"path": filepath.ToSlash(path), // 统一使用正斜杠
|
||||||
"size": info.Size(),
|
"size": info.Size(),
|
||||||
"size_str": FormatBytes(info.Size()),
|
"size_str": common.FormatBytesInt(info.Size()),
|
||||||
"is_dir": info.IsDir(),
|
"is_dir": info.IsDir(),
|
||||||
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
|
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
"mode": info.Mode().String(),
|
"mode": info.Mode().String(),
|
||||||
@@ -494,7 +488,7 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
|
|||||||
Path: filepath.ToSlash(newPath), // 统一使用正斜杠
|
Path: filepath.ToSlash(newPath), // 统一使用正斜杠
|
||||||
Name: info.Name(),
|
Name: info.Name(),
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
SizeStr: FormatBytes(info.Size()),
|
SizeStr: common.FormatBytesInt(info.Size()),
|
||||||
IsDir: info.IsDir(),
|
IsDir: info.IsDir(),
|
||||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
Mode: info.Mode().String(),
|
Mode: info.Mode().String(),
|
||||||
@@ -502,6 +496,174 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 复制与移动 ==========
|
||||||
|
|
||||||
|
// checkCopyMovePaths 复制/移动共用的前置校验:路径合法、源存在、目标不存在
|
||||||
|
func (s *FileSystemService) checkCopyMovePaths(src, dst string) (os.FileInfo, error) {
|
||||||
|
if err := s.validatePath(src); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.validatePath(dst); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Lstat(src)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("源路径不存在: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(dst); err == nil {
|
||||||
|
return nil, fmt.Errorf("目标已存在: %s", dst)
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// logCopyMoveAudit 记录复制/移动成功审计日志
|
||||||
|
func (s *FileSystemService) logCopyMoveAudit(op AuditOperation, src, dst string, isDir bool) {
|
||||||
|
s.logAudit(AuditLogEntry{
|
||||||
|
Timestamp: getCurrentTimestamp(),
|
||||||
|
Operation: op,
|
||||||
|
Path: dst,
|
||||||
|
OldPath: src,
|
||||||
|
IsDirectory: isDir,
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyPath 递归复制文件或目录到目标路径(dst 必须不存在,唯一性由调用方保证)
|
||||||
|
// onProgress 每读取一块回调一次(可为 nil),copied/total 单位字节
|
||||||
|
func (s *FileSystemService) CopyPath(src, dst string, onProgress func(copied, total int64)) error {
|
||||||
|
info, err := s.checkCopyMovePaths(src, dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := pathSize(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("统计源大小失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var copied int64
|
||||||
|
if err := copyPathRecursive(src, dst, info, &copied, total, onProgress); err != nil {
|
||||||
|
return fmt.Errorf("复制失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logCopyMoveAudit(OperationCopy, src, dst, info.IsDir())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MovePath 移动文件或目录:同盘直接重命名,跨盘回退为复制后永久删除源(不走回收站)
|
||||||
|
func (s *FileSystemService) MovePath(src, dst string) error {
|
||||||
|
info, err := s.checkCopyMovePaths(src, dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快路径:同盘 rename(不发进度)
|
||||||
|
if err := os.Rename(src, dst); err == nil {
|
||||||
|
s.logCopyMoveAudit(OperationMove, src, dst, info.IsDir())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨盘:复制 + 删除源
|
||||||
|
if err := s.CopyPath(src, dst, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(src); err != nil {
|
||||||
|
return fmt.Errorf("移动后删除源失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logCopyMoveAudit(OperationMove, src, dst, info.IsDir())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniquePath 返回不冲突的目标路径:重名时追加「 - 副本」「 - 副本 (2)」后缀(扩展名前插入)
|
||||||
|
// 命名循环复用 common.UniqueName(与 SFTP 远端重名逻辑同源)
|
||||||
|
func UniquePath(path string) string {
|
||||||
|
dir, name := filepath.Split(path)
|
||||||
|
unique, err := common.UniqueName(name, func(candidate string) (bool, error) {
|
||||||
|
_, err := os.Lstat(filepath.Join(dir, candidate))
|
||||||
|
return err == nil, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return path // exists 恒不返回错误,防御
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, unique)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyPathRecursive 递归复制单个路径(文件/目录/符号链接)
|
||||||
|
func copyPathRecursive(src, dst string, info os.FileInfo, copied *int64, total int64, onProgress func(copied, total int64)) error {
|
||||||
|
switch {
|
||||||
|
case info.IsDir():
|
||||||
|
if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
entryInfo, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := copyPathRecursive(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name()), entryInfo, copied, total, onProgress); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case info.Mode()&os.ModeSymlink != 0:
|
||||||
|
target, err := os.Readlink(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Symlink(target, dst)
|
||||||
|
default:
|
||||||
|
return copyFileWithProgress(src, dst, info, copied, total, onProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFileWithProgress 复制单个普通文件,按读取量累计回调进度
|
||||||
|
func copyFileWithProgress(src, dst string, info os.FileInfo, copied *int64, total int64, onProgress func(copied, total int64)) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
var reader io.Reader = in
|
||||||
|
if onProgress != nil {
|
||||||
|
reader = &common.CountingReader{R: in, OnN: func(n int64) {
|
||||||
|
*copied += n
|
||||||
|
onProgress(*copied, total)
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
_, err = io.Copy(out, reader)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathSize 统计路径总大小(文件为自身大小,目录递归求和,跳过符号链接)
|
||||||
|
func pathSize(path string) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.Type().IsRegular() {
|
||||||
|
if info, e := d.Info(); e == nil {
|
||||||
|
total += info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
// ========== ZIP操作接口 ==========
|
// ========== ZIP操作接口 ==========
|
||||||
|
|
||||||
// ListZipContents 列出ZIP文件内容(别名,保持向后兼容)
|
// ListZipContents 列出ZIP文件内容(别名,保持向后兼容)
|
||||||
@@ -531,12 +693,27 @@ func getCurrentTimestamp() time.Time {
|
|||||||
return time.Now()
|
return time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// permanentDelete 永久删除路径(目录 RemoveAll / 文件 Remove)并记录删除日志
|
||||||
|
func (s *FileSystemService) permanentDelete(path string, info os.FileInfo) error {
|
||||||
|
var deleteErr error
|
||||||
|
if info.IsDir() {
|
||||||
|
deleteErr = os.RemoveAll(path)
|
||||||
|
} else {
|
||||||
|
deleteErr = os.Remove(path)
|
||||||
|
}
|
||||||
|
s.logDelete(path, info.IsDir(), info.Size(), deleteErr)
|
||||||
|
if deleteErr != nil {
|
||||||
|
return fmt.Errorf("删除失败: %v", deleteErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// isInRecycleBin 检查路径是否在回收站中
|
// isInRecycleBin 检查路径是否在回收站中
|
||||||
func isInRecycleBin(path string) bool {
|
func isInRecycleBin(path string) bool {
|
||||||
recycleBinPath := filepath.Join(common.GetUserDataDir(), "recycle_bin")
|
recycleBinPath := filepath.Join(common.GetUserDataDir(), "recycle_bin")
|
||||||
cleanPath := filepath.Clean(path)
|
cleanPath := filepath.Clean(path)
|
||||||
cleanBinPath := filepath.Clean(recycleBinPath)
|
cleanBinPath := filepath.Clean(recycleBinPath)
|
||||||
return len(cleanPath) >= len(cleanBinPath) && cleanPath[:len(cleanBinPath)] == cleanBinPath
|
return strings.HasPrefix(cleanPath, cleanBinPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 辅助方法 ==========
|
// ========== 辅助方法 ==========
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
package filesystem
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
htmlResRegex = regexp.MustCompile(`(?:src|href|data|poster)=["']([^"']+)["']`)
|
|
||||||
htmlCssUrlRe = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExtractHtmlResources 从 HTML 内容提取资源路径
|
|
||||||
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 htmlResRegex.FindAllStringSubmatch(html, -1) {
|
|
||||||
if len(m) > 1 {
|
|
||||||
add(m[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, m := range htmlCssUrlRe.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:")
|
|
||||||
}
|
|
||||||
@@ -116,11 +116,15 @@ func (w *Watcher) consumeEvents() {
|
|||||||
}
|
}
|
||||||
debounceTimer = time.AfterFunc(debounceDelay, func() {
|
debounceTimer = time.AfterFunc(debounceDelay, func() {
|
||||||
// 文件已不存在则跳过(如被删除)
|
// 文件已不存在则跳过(如被删除)
|
||||||
if _, err := os.Stat(target); err != nil {
|
info, err := os.Stat(target)
|
||||||
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if w.emitEvent != nil {
|
if w.emitEvent != nil {
|
||||||
w.emitEvent("file-changed", target)
|
w.emitEvent("file-changed", map[string]any{
|
||||||
|
"path": target,
|
||||||
|
"mtime": info.ModTime().UnixMilli(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -122,14 +123,7 @@ func (c *Client) canonicalizeOSSHeaders(headers map[string]string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 按字典序排序
|
// 按字典序排序
|
||||||
// 这里简单处理,实际应该用排序算法
|
sort.Strings(ossHeaders)
|
||||||
for i := 0; i < len(ossHeaders); i++ {
|
|
||||||
for j := i + 1; j < len(ossHeaders); j++ {
|
|
||||||
if ossHeaders[i] > ossHeaders[j] {
|
|
||||||
ossHeaders[i], ossHeaders[j] = ossHeaders[j], ossHeaders[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拼接结果
|
// 拼接结果
|
||||||
if len(ossHeaders) == 0 {
|
if len(ossHeaders) == 0 {
|
||||||
@@ -315,13 +309,14 @@ func (c *Client) GetFileInfo(ctx context.Context, key string) (*oss.FileInfo, er
|
|||||||
size := resp.ContentLength
|
size := resp.ContentLength
|
||||||
etag := resp.Header.Get("ETag")
|
etag := resp.Header.Get("ETag")
|
||||||
contentType := resp.Header.Get("Content-Type")
|
contentType := resp.Header.Get("Content-Type")
|
||||||
_ = resp.Header.Get("Last-Modified") // 预留,可能需要解析时间
|
lastModified, _ := http.ParseTime(resp.Header.Get("Last-Modified"))
|
||||||
|
|
||||||
return &oss.FileInfo{
|
return &oss.FileInfo{
|
||||||
Key: key,
|
Key: key,
|
||||||
Size: size,
|
Size: size,
|
||||||
ETag: strings.Trim(etag, "\""),
|
ETag: strings.Trim(etag, "\""),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
|
LastModified: lastModified,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,26 +482,24 @@ func (c *Client) DeleteMultiple(ctx context.Context, keys []string) (*oss.Delete
|
|||||||
|
|
||||||
// GetSignedURL 获取预签名URL
|
// GetSignedURL 获取预签名URL
|
||||||
func (c *Client) GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) {
|
func (c *Client) GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) {
|
||||||
// 阿里云 OSS 使用签名 URL
|
|
||||||
// 格式: ?OSSAccessKeyId=xxx&Expires=xxx&Signature=xxx
|
|
||||||
expiration := time.Now().Add(expiresIn).Unix()
|
expiration := time.Now().Add(expiresIn).Unix()
|
||||||
|
|
||||||
// 构建签名
|
// 签名计算用原始 key(不编码)
|
||||||
path := "/" + c.config.Bucket + "/" + key
|
path := "/" + c.config.Bucket + "/" + key
|
||||||
stringToSign := "GET\n\n\n" + fmt.Sprintf("%d", expiration) + "\n" + path
|
stringToSign := "GET\n\n\n" + fmt.Sprintf("%d", expiration) + "\n" + path
|
||||||
h := hmac.New(sha1.New, []byte(c.config.AccessKeySecret))
|
h := hmac.New(sha1.New, []byte(c.config.AccessKeySecret))
|
||||||
h.Write([]byte(stringToSign))
|
h.Write([]byte(stringToSign))
|
||||||
|
// 阿里云签名规范是标准 base64 字母表并保留 padding,= 由下方 QueryEscape 转义
|
||||||
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
|
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||||
signature = strings.TrimRight(signature, "=") // URL Safe
|
|
||||||
|
|
||||||
scheme := "https://"
|
scheme := "https://"
|
||||||
if !c.config.UseHTTPS {
|
if !c.config.UseHTTPS {
|
||||||
scheme = "http://"
|
scheme = "http://"
|
||||||
}
|
}
|
||||||
baseURL := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key
|
baseURL := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + url.PathEscape(key)
|
||||||
|
|
||||||
signedURL := fmt.Sprintf("%s?OSSAccessKeyId=%s&Expires=%d&Signature=%s",
|
signedURL := fmt.Sprintf("%s?OSSAccessKeyId=%s&Expires=%d&Signature=%s",
|
||||||
baseURL, c.config.AccessKeyID, expiration, signature)
|
baseURL, url.QueryEscape(c.config.AccessKeyID), expiration, url.QueryEscape(signature))
|
||||||
|
|
||||||
return signedURL, nil
|
return signedURL, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/common"
|
||||||
"u-desk/internal/filesystem"
|
"u-desk/internal/filesystem"
|
||||||
"u-desk/internal/oss"
|
"u-desk/internal/oss"
|
||||||
"u-desk/internal/oss/aliyun"
|
"u-desk/internal/oss/aliyun"
|
||||||
@@ -112,17 +114,12 @@ func (m *Manager) getOrCreateBucketClient(provider, bucket, region string) (oss.
|
|||||||
UseHTTPS: true,
|
UseHTTPS: true,
|
||||||
})
|
})
|
||||||
case "aliyun":
|
case "aliyun":
|
||||||
// 有桶级 region 时不传账户 Endpoint,让 NewClient 从 region 派生正确的 endpoint
|
|
||||||
ep := c.Endpoint
|
|
||||||
if region != "" {
|
|
||||||
ep = ""
|
|
||||||
}
|
|
||||||
client, err = aliyun.NewClient(&aliyun.Config{
|
client, err = aliyun.NewClient(&aliyun.Config{
|
||||||
AccessKeyID: c.AccessKey,
|
AccessKeyID: c.AccessKey,
|
||||||
AccessKeySecret: c.SecretKey,
|
AccessKeySecret: c.SecretKey,
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Region: region,
|
Region: region,
|
||||||
Endpoint: ep,
|
Endpoint: c.Endpoint,
|
||||||
UseHTTPS: true,
|
UseHTTPS: true,
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
@@ -367,14 +364,14 @@ func (s *Service) ReadFile(connID string, rawPath string) (string, error) {
|
|||||||
return "", fmt.Errorf("获取文件信息失败: %w", err)
|
return "", fmt.Errorf("获取文件信息失败: %w", err)
|
||||||
}
|
}
|
||||||
if info.Size > maxSize {
|
if info.Size > maxSize {
|
||||||
return "", fmt.Errorf("文件过大 (%s),超过 %d 限制", filesystem.FormatBytes(info.Size), maxSize)
|
return "", fmt.Errorf("文件过大 (%s),超过 %d 限制", common.FormatBytesInt(info.Size), maxSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := c.Download(ctx, key, &buf); err != nil {
|
if err := c.Download(ctx, key, &buf); err != nil {
|
||||||
return "", fmt.Errorf("读取文件失败: %w", err)
|
return "", fmt.Errorf("读取文件失败: %w", err)
|
||||||
}
|
}
|
||||||
return filesystem.BytesToString(buf.Bytes()), nil
|
return common.BytesToString(buf.Bytes()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteFile 写入文件内容
|
// WriteFile 写入文件内容
|
||||||
@@ -442,7 +439,7 @@ func (s *Service) GetFileInfo(connID string, rawPath string) (map[string]interfa
|
|||||||
"name": path.Base(info.Key),
|
"name": path.Base(info.Key),
|
||||||
"path": bucketPrefix + info.Key,
|
"path": bucketPrefix + info.Key,
|
||||||
"size": info.Size,
|
"size": info.Size,
|
||||||
"size_str": filesystem.FormatBytes(info.Size),
|
"size_str": common.FormatBytesInt(info.Size),
|
||||||
"is_dir": strings.HasSuffix(info.Key, "/"),
|
"is_dir": strings.HasSuffix(info.Key, "/"),
|
||||||
"mod_time": info.LastModified.Format("2006-01-02 15:04:05"),
|
"mod_time": info.LastModified.Format("2006-01-02 15:04:05"),
|
||||||
}, nil
|
}, nil
|
||||||
@@ -474,7 +471,7 @@ func (s *Service) CreateDir(connID string, rawPath string) (*filesystem.FileOper
|
|||||||
Path: "/" + bucket + "/" + key,
|
Path: "/" + bucket + "/" + key,
|
||||||
Name: name,
|
Name: name,
|
||||||
IsDir: true,
|
IsDir: true,
|
||||||
SizeStr: filesystem.FormatBytes(0),
|
SizeStr: common.FormatBytesInt(0),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +496,7 @@ func (s *Service) CreateFile(connID string, rawPath string) (*filesystem.FileOpe
|
|||||||
Path: "/" + bucket + "/" + key,
|
Path: "/" + bucket + "/" + key,
|
||||||
Name: path.Base(key),
|
Name: path.Base(key),
|
||||||
IsDir: false,
|
IsDir: false,
|
||||||
SizeStr: filesystem.FormatBytes(0),
|
SizeStr: common.FormatBytesInt(0),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,13 +614,18 @@ func (s *Service) RenamePath(connID string, oldPath string, newPath string) (*fi
|
|||||||
if err := c.Copy(ctx, f.Key, newPrefix+relativeKey); err != nil {
|
if err := c.Copy(ctx, f.Key, newPrefix+relativeKey); err != nil {
|
||||||
return nil, fmt.Errorf("复制失败: %w", err)
|
return nil, fmt.Errorf("复制失败: %w", err)
|
||||||
}
|
}
|
||||||
c.Delete(ctx, f.Key)
|
if err := c.Delete(ctx, f.Key); err != nil {
|
||||||
|
return nil, fmt.Errorf("删除源文件失败: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !listResult.IsTruncated {
|
if !listResult.IsTruncated {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.Delete(ctx, oldKey) // marker
|
if err := c.Delete(ctx, oldKey); err != nil { // marker
|
||||||
|
// 目录标记删除失败不影响重命名结果
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := c.Copy(ctx, oldKey, newKey); err != nil {
|
if err := c.Copy(ctx, oldKey, newKey); err != nil {
|
||||||
return nil, fmt.Errorf("复制失败: %w", err)
|
return nil, fmt.Errorf("复制失败: %w", err)
|
||||||
@@ -656,23 +658,37 @@ func (s *Service) DownloadSiteForPreview(connID string, rawPath string) (string,
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// 1. 创建临时目录,保留 OSS 目录结构
|
// 1. 缓存检查:用确定性路径,命中直接返回
|
||||||
tmpDir, err := os.MkdirTemp("", "udesk-site-*")
|
htmlInfo, _ := c.GetFileInfo(ctx, key)
|
||||||
if err != nil {
|
var htmlSize int64
|
||||||
return "", fmt.Errorf("创建临时目录失败: %w", err)
|
var htmlModTime string
|
||||||
|
if htmlInfo != nil {
|
||||||
|
htmlSize = htmlInfo.Size
|
||||||
|
htmlModTime = htmlInfo.LastModified.Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
|
cacheDir := common.SiteCacheDir("oss", connID, rawPath, htmlSize, htmlModTime)
|
||||||
|
|
||||||
keyDir := path.Dir(key)
|
keyDir := path.Dir(key)
|
||||||
var htmlLocalPath string
|
var htmlCachePath string
|
||||||
if keyDir != "" && keyDir != "." {
|
if keyDir != "" && keyDir != "." {
|
||||||
htmlLocalPath = filepath.Join(tmpDir, filepath.FromSlash(keyDir), path.Base(key))
|
htmlCachePath = filepath.Join(cacheDir, filepath.FromSlash(keyDir), path.Base(key))
|
||||||
|
} else {
|
||||||
|
htmlCachePath = filepath.Join(cacheDir, path.Base(key))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(htmlCachePath); err == nil {
|
||||||
|
return htmlCachePath, nil // 缓存命中
|
||||||
|
}
|
||||||
|
os.RemoveAll(cacheDir)
|
||||||
|
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("创建缓存目录失败: %w", err)
|
||||||
|
}
|
||||||
|
tmpDir := cacheDir
|
||||||
|
|
||||||
|
htmlLocalPath := htmlCachePath
|
||||||
if err := os.MkdirAll(filepath.Dir(htmlLocalPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(htmlLocalPath), 0755); err != nil {
|
||||||
os.RemoveAll(tmpDir)
|
os.RemoveAll(tmpDir)
|
||||||
return "", fmt.Errorf("创建目录失败: %w", err)
|
return "", fmt.Errorf("创建目录失败: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
htmlLocalPath = filepath.Join(tmpDir, path.Base(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 下载 HTML
|
// 2. 下载 HTML
|
||||||
f, err := os.Create(htmlLocalPath)
|
f, err := os.Create(htmlLocalPath)
|
||||||
@@ -692,7 +708,7 @@ func (s *Service) DownloadSiteForPreview(connID string, rawPath string) (string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return htmlLocalPath, nil // HTML 已下载,资源解析失败不影响
|
return htmlLocalPath, nil // HTML 已下载,资源解析失败不影响
|
||||||
}
|
}
|
||||||
resources := filesystem.ExtractHtmlResources(string(htmlContent))
|
resources := common.ExtractHtmlResources(string(htmlContent))
|
||||||
|
|
||||||
// 4. 下载资源
|
// 4. 下载资源
|
||||||
htmlOssDir := keyDir
|
htmlOssDir := keyDir
|
||||||
@@ -712,8 +728,11 @@ func (s *Service) DownloadSiteForPreview(connID string, rawPath string) (string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绝对路径资源:串行嗅探 siteRoot,相对路径:并行下载
|
||||||
|
type relativeTask struct{ ossKey, localPath string }
|
||||||
|
var relativeTasks []relativeTask
|
||||||
for _, resPath := range resources {
|
for _, resPath := range resources {
|
||||||
if filesystem.ShouldSkipResource(resPath) {
|
if common.ShouldSkipResource(resPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,11 +757,19 @@ func (s *Service) DownloadSiteForPreview(connID string, rawPath string) (string,
|
|||||||
} else {
|
} else {
|
||||||
ossKey = cleanPath
|
ossKey = cleanPath
|
||||||
}
|
}
|
||||||
if downloadResource(c, ctx, ossKey, localPath) {
|
relativeTasks = append(relativeTasks, relativeTask{ossKey, localPath})
|
||||||
recordDir(ossKey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 相对路径资源并行下载(recordDir 需加锁,多 goroutine 并发回调)
|
||||||
|
var mu sync.Mutex
|
||||||
|
common.RunConcurrent(relativeTasks, common.SiteDownloadConcurrency, func(t relativeTask) {
|
||||||
|
if downloadResource(c, ctx, t.ossKey, t.localPath) {
|
||||||
|
mu.Lock()
|
||||||
|
recordDir(t.ossKey)
|
||||||
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
|
})
|
||||||
// 5. 补充下载已发现目录中的剩余文件(覆盖 webpack 动态 chunk 等)
|
// 5. 补充下载已发现目录中的剩余文件(覆盖 webpack 动态 chunk 等)
|
||||||
for _, dir := range discoveredDirs {
|
for _, dir := range discoveredDirs {
|
||||||
supplementDir(c, ctx, dir, tmpDir, siteRoot)
|
supplementDir(c, ctx, dir, tmpDir, siteRoot)
|
||||||
@@ -751,7 +778,6 @@ func (s *Service) DownloadSiteForPreview(connID string, rawPath string) (string,
|
|||||||
return htmlLocalPath, nil
|
return htmlLocalPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAbsoluteResourcePath 解析绝对路径资源,首次嗅探网站根,后续直接使用
|
|
||||||
// resolveAndDownload 解析绝对路径并下载:首次嗅探网站根,后续直接使用
|
// resolveAndDownload 解析绝对路径并下载:首次嗅探网站根,后续直接使用
|
||||||
func resolveAndDownload(c oss.OSSProvider, ctx context.Context, htmlOssDir string, cleanPath string, localPath string, siteRoot *string) string {
|
func resolveAndDownload(c oss.OSSProvider, ctx context.Context, htmlOssDir string, cleanPath string, localPath string, siteRoot *string) string {
|
||||||
if *siteRoot != "" {
|
if *siteRoot != "" {
|
||||||
@@ -810,37 +836,48 @@ func downloadResource(c oss.OSSProvider, ctx context.Context, ossKey string, loc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// supplementDir 补充下载远程目录中尚未下载的文件(只处理已知资源所在目录)
|
// supplementDir 补充下载远程目录中尚未下载的文件(只处理已知资源所在目录)
|
||||||
|
// 数量与单文件大小上限见 common.MaxSupplementFiles / common.MaxSupplementFileSize
|
||||||
func supplementDir(c oss.OSSProvider, ctx context.Context, remoteDir string, tmpDir string, siteRoot string) {
|
func supplementDir(c oss.OSSProvider, ctx context.Context, remoteDir string, tmpDir string, siteRoot string) {
|
||||||
prefix := remoteDir + "/"
|
prefix := remoteDir + "/"
|
||||||
result, err := c.ListFiles(ctx, &oss.ListOptions{Prefix: prefix, MaxKeys: 200})
|
var marker string
|
||||||
|
count := 0
|
||||||
|
for count < common.MaxSupplementFiles {
|
||||||
|
result, err := c.ListFiles(ctx, &oss.ListOptions{Prefix: prefix, MaxKeys: 200, Marker: marker})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, f := range result.Files {
|
for _, f := range result.Files {
|
||||||
if strings.HasSuffix(f.Key, "/") || f.Size == 0 {
|
if strings.HasSuffix(f.Key, "/") || f.Size == 0 || f.Size > common.MaxSupplementFileSize {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if count >= common.MaxSupplementFiles {
|
||||||
|
log.Printf("[站点下载] 补充扫描达到上限: count=%d", common.MaxSupplementFiles)
|
||||||
|
return
|
||||||
|
}
|
||||||
localPath := filepath.Join(tmpDir, filepath.FromSlash(f.Key))
|
localPath := filepath.Join(tmpDir, filepath.FromSlash(f.Key))
|
||||||
if _, err := os.Stat(localPath); err == nil {
|
if _, err := os.Stat(localPath); err == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
downloadResource(c, ctx, f.Key, localPath)
|
if downloadResource(c, ctx, f.Key, localPath) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !result.IsTruncated {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
marker = result.NextMarker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadToTemp 下载文件到本地临时目录(带 SQLite 缓存)
|
// DownloadToTemp 下载文件到本地临时目录(带 SQLite 缓存)
|
||||||
func (s *Service) DownloadToTemp(connID string, rawPath string) (string, error) {
|
func (s *Service) DownloadToTemp(connID string, rawPath string) (string, error) {
|
||||||
// 先获取文件元信息用于缓存键,确保远程文件变更时能淘汰旧缓存
|
// 获取文件元信息用于缓存键
|
||||||
var fileSize int64
|
info, err := s.GetFileInfo(connID, rawPath)
|
||||||
var modTime string
|
if err != nil {
|
||||||
if info, err := s.GetFileInfo(connID, rawPath); err == nil {
|
return "", fmt.Errorf("获取文件信息失败: %w", err)
|
||||||
if sz, ok := info["size"].(int64); ok {
|
|
||||||
fileSize = sz
|
|
||||||
}
|
|
||||||
if mt, ok := info["mod_time"].(string); ok {
|
|
||||||
modTime = mt
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
fileSize, _ := info["size"].(int64)
|
||||||
|
modTime, _ := info["mod_time"].(string)
|
||||||
return storage.DownloadToTempCached("oss", connID, rawPath, fileSize, modTime, func() (string, error) {
|
return storage.DownloadToTempCached("oss", connID, rawPath, fileSize, modTime, func() (string, error) {
|
||||||
return s.downloadToTempDirect(connID, rawPath)
|
return s.downloadToTempDirect(connID, rawPath)
|
||||||
})
|
})
|
||||||
@@ -915,7 +952,7 @@ func toOssOperationResult(m map[string]interface{}, isDir bool) *filesystem.File
|
|||||||
Path: p,
|
Path: p,
|
||||||
Name: name,
|
Name: name,
|
||||||
Size: size,
|
Size: size,
|
||||||
SizeStr: filesystem.FormatBytes(size),
|
SizeStr: common.FormatBytesInt(size),
|
||||||
IsDir: isDir,
|
IsDir: isDir,
|
||||||
ModTime: modTime,
|
ModTime: modTime,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ func replaceExecutableFile(newFilePath, execPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unix-like: 直接替换
|
// Unix-like: 直接替换
|
||||||
if err := copyFile(newFilePath, execPath); err != nil {
|
if err := common.CopyFile(newFilePath, execPath); err != nil {
|
||||||
return fmt.Errorf("复制文件失败: %v", err)
|
return fmt.Errorf("复制文件失败: %v", err)
|
||||||
}
|
}
|
||||||
return os.Chmod(execPath, 0755)
|
return os.Chmod(execPath, 0755)
|
||||||
@@ -256,13 +256,13 @@ func replaceExecutableFileWindows(newFilePath, execPath string) error {
|
|||||||
os.Remove(newExecPathTemp)
|
os.Remove(newExecPathTemp)
|
||||||
|
|
||||||
// 复制新文件到临时位置
|
// 复制新文件到临时位置
|
||||||
if err := copyFile(newFilePath, newExecPathTemp); err != nil {
|
if err := common.CopyFile(newFilePath, newExecPathTemp); err != nil {
|
||||||
return fmt.Errorf("复制新文件失败: %v", err)
|
return fmt.Errorf("复制新文件失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试重命名当前文件(如果失败,说明文件正在使用)
|
// 尝试重命名当前文件(如果失败,说明文件正在使用)
|
||||||
if err := os.Rename(execPath, oldExecPath); err != nil {
|
if err := os.Rename(execPath, oldExecPath); err != nil {
|
||||||
return nil
|
return fmt.Errorf("当前文件正在使用,无法替换: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 替换文件
|
// 替换文件
|
||||||
@@ -350,7 +350,7 @@ func rollbackFromBackup(backupPath string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return copyFile(backupPath, execPath)
|
return common.CopyFile(backupPath, execPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupApplication 备份当前应用
|
// BackupApplication 备份当前应用
|
||||||
@@ -369,27 +369,21 @@ func BackupApplication() (string, error) {
|
|||||||
backupFileName := fmt.Sprintf("u-desk-backup-%s%s", timestamp, filepath.Ext(execPath))
|
backupFileName := fmt.Sprintf("u-desk-backup-%s%s", timestamp, filepath.Ext(execPath))
|
||||||
backupPath := filepath.Join(backupDir, backupFileName)
|
backupPath := filepath.Join(backupDir, backupFileName)
|
||||||
|
|
||||||
if err := copyFile(execPath, backupPath); err != nil {
|
if err := common.CopyFile(execPath, backupPath); err != nil {
|
||||||
return "", fmt.Errorf("复制文件失败: %v", err)
|
return "", fmt.Errorf("复制文件失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return backupPath, nil
|
return backupPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyFile 复制文件
|
// ReplacePendingFile 启动时完成上次更新遗留的 exe 替换。
|
||||||
func copyFile(src, dst string) error {
|
// 此时进程刚启动,exe 文件无占用,可安全完成上次因文件占用未完成的替换,
|
||||||
sourceFile, err := os.Open(src)
|
// 并清理残留的 .old 备份。
|
||||||
|
func ReplacePendingFile() {
|
||||||
|
execPath, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
defer sourceFile.Close()
|
_ = replacePendingFile(execPath) // 完成遗留的 .new -> exe 替换
|
||||||
|
os.Remove(execPath + ".old") // 清理残留备份
|
||||||
destFile, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer destFile.Close()
|
|
||||||
|
|
||||||
_, err = destFile.ReadFrom(sourceFile)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|||||||