17 Commits
Author SHA1 Message Date
lxy 066ac2b339 修复: 连接地址拼接兼容IPv6主机
JoinHostPort替换fmt拼接,IPv6裸地址缺方括号导致拨号失败
2026-10-03 10:18:31 +08:00
lxy c7978a5bfd 修复: 版本更新与设置面板走查问题
- 更新链路: 检查记录远端SHA256,下载完成事件回传,安装时带哈希校验
- 续传: 服务器忽略Range返回200时截断重下防追加损坏,文件名去query
- 定时器: 自动检查可重入,配置变更免重启生效,关闭即停,下载加互斥
- 前端: 支持跳过此版本并按版本去重,手动检查不受门禁限制,启动未就绪延时重试
- 更新面板: 新增自动检查开关/间隔/地址配置与进度条状态映射
- 版本历史: cache参数强制刷新与加载超时重试兜底
- 配置: 侧边栏分区全链路持久化,保存请求复用api类型防字段丢失
- 侧边栏: 区块定位改显式类名,不再依赖排序位置
- 绑定: 重新生成并与镜像目录同步
2026-10-03 10:18:27 +08:00
lxy 81e245eade 更新: 站点子仓指针至 0.5.0 版 2026-10-01 20:28:40 +08:00
lxy 37cbc39154 更新: 版本号 0.4.2 → 0.5.0 2026-10-01 20:28:40 +08:00
lxy e28ae653b0 修复: webview尺寸收窄卡住与兜底失效闪退
- 窗口尺寸变化后webview收窄卡住根修
- 尺寸兜底失效致闪退与修复空转加固
- 跨服务器预览残留清理
2026-10-01 01:02:39 +08:00
lxy b9c0786c02 新增: 资源管理器文件粘贴到文件列表
- 后端 ReadClipboardFiles 读系统剪贴板 CF_HDROP 拿路径列表
- Ctrl+V 优先粘贴系统剪贴板文件,复用拖入导入链路(SFTP 上传/本机复制/目录递归)
- 补齐设计文档记录的历史缺口(Wails 限制未做)
2026-09-29 18:47:09 +08:00
lxy 4746f8cac2 优化: 收藏置顶项背景加重并加服务器一键全连
- 置顶项背景改用 --color-primary-light-1 对齐服务器列表焦点色
- 服务器管理菜单新增一键全连,全连后恢复原焦点
- 修复连 B 失败后焦点回落 A 状态点误染红(改用 per-profile 状态)
2026-09-28 14:42:22 +08:00
lxy b488354369 新增: 服务器列表右键菜单
- 右键弹出连接编辑删除,复用既有操作链路
- 本地项仅连接,远程项含编辑删除
- 与三点菜单和设置面板互斥展开
2026-09-28 13:53:37 +08:00
lxy 6c59f81b44 优化: 收藏栏宽度可调与操作菜单收敛
- 侧边栏宽度拖拽调整180-420并持久化
- 收藏操作收敛为三点菜单加右键菜单,移除悬停按钮组
- 右键与三点菜单互斥,打开其一收起另一
- 置顶项背景改清晰浅蓝
2026-09-28 13:18:16 +08:00
lxy 15dedfc563 新增: 收藏展示名自定义与列表默认展开
- 收藏项支持重命名,displayName空值回退原名
- 悬停提示改两行,路径加来源连接名区分同名收藏
- 收藏夹区块默认展开
2026-09-28 12:18:55 +08:00
lxy e8ccb4280b 优化: PDF导出成功后打开所在位置选中文件
- 导出落盘后explorer选中PDF,不再只靠一闪而过的弹窗
- 成功文案明确指向导出位置
- explorer用WINDIR绝对路径,不依赖PATH
2026-09-28 09:33:53 +08:00
lxy fa3c3234b9 修复: Markdown导出file URL转义并加导出诊断插桩
- Markdown通道file URL整体转义,中文空格临时路径可导航
- bindings同步删除已清理的目录选择死代码
- 导出PDF链路插桩日志落.pdf-export.log,临时诊断定位后移除
2026-09-28 09:17:04 +08:00
lxy 2002bb8cca 修复: PDF导出浏览器探测补全与链路加固
- 浏览器探测补按用户安装路径LOCALAPPDATA
- fs-agent远程HTML导出先拉取到本地临时目录
- Markdown通道空结果守卫,杜绝0字节PDF误报成功
- 清理死参数与无调用死代码
2026-09-27 11:13:50 +08:00
lxy e87ffe1d0c 新增: HTML预览导出PDF并兼容Edge环境
- chromedp 通道新增源文件入口,file:// 加载原始HTML
- 相对资源在原目录天然解析,printBackground 保背景色
- 预览工具栏新增导出按钮,默认文件名取当前HTML名
- 远程文件复用站点缓存路径,本地远程同一命令
- 浏览器探测扩Chrome+Edge双查,Markdown通道重复探测归一
2026-09-17 16:24:46 +08:00
lxy 54b3f03d1f 更新: 站点子仓指针至 0.4.2 版 2026-09-16 00:13:20 +08:00
lxy 1db2b8ff70 更新: 版本号 0.4.1 → 0.4.2
- CHANGELOG 与内部日志补 0.4.2 修复段
- 功能清单与站点版本引用同步
- bindings 随 MovePath 注释再生成
2026-09-16 00:12:54 +08:00
lxy 56fe0d9b82 修复: 文件系统初始化守卫与传输收尾等七处
- filesystemSvc 守卫17绑定方法,异步初始化完成前返回明确错误
- MovePath 跨盘剪切建 move emitter,失败清理目标盘残留
- SFTP 空目录下载/上传补 MkdirAll 落地
- drawio 下载失败等待者立即返回,不再空轮询30分钟
- batch 高亮补 %1 与 %%i 规则
- 错误文案转换抽 errMessage 共享函数,11处归一
- log.Printf 统一 slog.Warn
2026-09-15 23:49:01 +08:00
53 changed files with 1773 additions and 476 deletions
+26
View File
@@ -2,6 +2,32 @@
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
## [0.5.0] - 2026-10-01
- **webview 尺寸兜底** (app_window_fix.go): wails alpha.92 WM_SIZE 竞态(resizePending 丢消息+过期 bounds 重放)致 webview 停在中间尺寸;WndProcInterceptor 挂 WM_SIZE 校验 + 3s 周期兜底双路,GetClientRect/SetWindowPos 直铺 Chrome_WidgetWin_0。坑:user32 只导出无后缀名(带 W 的 proc 找不到会 panic);EnumChildWindows 回调须包级变量传目标(param 传栈地址恒不匹配);windowsgui 下 goroutine panic 静默杀进程须 recover
- **剪贴板文件粘贴** (app_clipboard.go): CF_HDROP(15) + LockOSThread + OpenClipboard 重试 20×10ms,DragQueryFileW 解析路径后复用拖入链路 handleExternalDrop
- **预览联动**: switchConnection/handleGoToPath 后 syncPreviewToActiveProfile 按 profileId 过滤预览 Tab,跨服务器切换不再残留他机预览
- **PDF 导出**: HTML 预览导出 PDF,成功后 explorer /select 定位文件;插桩日志 ~/.u-desk/logs/pdf-export.log 为临时诊断,后续可清理
- **收藏栏**: 宽度拖拽 180-420 持久化、展示名自定义、置顶浅蓝(--arcoblue-1)、双菜单互斥;Sidebar stateDotClass 改 per-profile getProfileState(全局 state 不带 profile 维度会误染红)
- **一键全连**: connectAllServers 用 Promise.allSettled 并发连接可见非 local 项,完成后恢复原焦点
- **绑定坑**: wails3 generate bindings 必须带 -clean=true -ts(否则 app.ts 被覆盖为 .js)
---
## [0.4.2] - 2026-09-15
- **初始化守卫**: 17 个 Wails 绑定方法加未就绪守卫,启动瞬间操作返回明确提示
- **MovePath**: 跨盘剪切改 emitter 上报传输进度,失败自动清理残留文件
- **SFTP 空目录**: 下载/上传目标 MkdirAll,空目录正确创建不丢失
- **drawio 预览**: 首次下载失败时唤醒全部等待者,立即提示
- **重试进度**: 网络重试后进度基线重置,失败条目显示错误状态
- **连接回退**: 回退池校验,连接失败不回落本地磁盘列表
- **OSS 签名**: base64 StdEncoding 兼容性修复
- **errMessage 归一**: 错误消息统一提取
- **slog 统一**: 日志输出统一 log/slog
---
## [0.4.1] - 2026-05-01 ~ 05-16 (fs-only-v3)
---
+33
View File
@@ -1,5 +1,38 @@
# 更新日志
## [0.5.0] - 2026-10-01
### 新增 ✨
- **预览导出 PDF**: HTML/Markdown 预览一键导出为 PDF,成功后自动打开所在文件夹并选中文件
- **剪贴板粘贴文件**: 资源管理器复制的文件/文件夹可直接粘贴进文件列表(本机=复制,SFTP=上传)
- **收藏展示名自定义**: 收藏项可自定义显示名称,收藏列表默认展开
- **服务器右键菜单**: 服务器列表支持右键菜单,连接/管理操作直达
- **一键全连**: 服务器管理菜单新增"一键全连",并发连接全部服务器
### 优化 🚀
- **收藏置顶项**: 置顶背景加深为清晰浅蓝,一眼区分置顶与普通收藏
- **收藏栏宽度可调**: 拖拽调整侧边栏收藏区宽度并记忆
### 修复 🐛
- **窗口内容收窄**: 切换服务器/目录时偶发的内容区收窄卡住不再出现,自动恢复满宽
- **Markdown 导出**: file URL 转义修正,含空格/特殊字符路径导出正常
- **PDF 导出稳定性**: 浏览器探测补全,导出失败立即提示不再无响应
---
## [0.4.2] - 2026-09-15
### 修复 🐛
- **启动初始化**: 启动瞬间操作文件不再无响应,未就绪时给出明确提示
- **跨盘剪切**: 大目录移动显示传输进度,失败自动清理残留文件
- **SFTP 空目录**: 空目录下载/上传后正确创建,不再静默丢失
- **drawio 预览**: 首次下载失败后立即提示,不再长时间无响应
- **进度可靠性**: 网络重试后进度不再虚跳,失败条目正确显示错误状态
- **连接回退**: 连接失败后不再错误回落到本地磁盘列表
- **编码细节**: OSS 签名兼容性修复,batch 脚本 %1/%%i 变量高亮
---
## [0.4.1] - 2026-05-16 (fs-only-v3)
### 新增 ✨
+3 -10
View File
@@ -30,6 +30,7 @@ type App struct {
mainWindow *application.WebviewWindow
updateAPI *api.UpdateAPI
updateTicker *time.Ticker
updateCheckStop chan struct{}
configAPI *api.ConfigAPI
pdfAPI *api.PdfAPI
filesystem *filesystem.FileSystemService
@@ -172,6 +173,7 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
a.mainWindow.EmitEvent(name, data...)
}
})
a.updateAPI.SetConfigChangedCallback(a.resetAutoUpdateCheck)
a.startAutoUpdateCheck()
}
}()
@@ -200,9 +202,7 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
// ServiceShutdown Wails v3 服务关闭生命周期(替代 v2 的 Shutdown)
func (a *App) ServiceShutdown() error {
if a.updateTicker != nil {
a.updateTicker.Stop()
}
a.stopUpdateTicker()
if a.unregisterHotkey != nil {
a.unregisterHotkey()
}
@@ -273,13 +273,6 @@ type RenamePathRequest struct {
NewPath string `json:"newPath"`
}
// SaveAppConfigRequest 保存应用配置请求
type SaveAppConfigRequest struct {
Tabs []api.AppTabDefinition `json:"tabs"`
VisibleTabs []string `json:"visibleTabs"`
DefaultTab string `json:"defaultTab"`
}
// SftpConnectRequest SFTP 连接请求
type SftpConnectRequest struct {
Host string `json:"host"`
+71
View File
@@ -0,0 +1,71 @@
//go:build windows
package main
import (
"runtime"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
var (
procIsClipboardFormatAvailable = windows.NewLazySystemDLL("user32.dll").NewProc("IsClipboardFormatAvailable")
procOpenClipboard = windows.NewLazySystemDLL("user32.dll").NewProc("OpenClipboard")
procCloseClipboard = windows.NewLazySystemDLL("user32.dll").NewProc("CloseClipboard")
procGetClipboardData = windows.NewLazySystemDLL("user32.dll").NewProc("GetClipboardData")
procDragQueryFileW = windows.NewLazySystemDLL("shell32.dll").NewProc("DragQueryFileW")
)
// cfHDrop 剪贴板文件列表格式(资源管理器复制文件/文件夹),Win32 稳定 ABI 值
const cfHDrop = 15
// ReadClipboardFiles 读取系统剪贴板中的文件/文件夹路径列表。
// 剪贴板为文本/截图等非文件内容时返回空列表,前端据此回落图片粘贴等原有链路。
func (a *App) ReadClipboardFiles() ([]string, error) {
// OpenClipboard 与 CloseClipboard 必须同线程,否则剪贴板死锁
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if r, _, _ := procIsClipboardFormatAvailable.Call(cfHDrop); r == 0 {
return nil, nil
}
// 剪贴板是全局互斥资源,短重试避开其他进程的瞬时占用
opened := false
for i := 0; i < 20; i++ {
if r, _, _ := procOpenClipboard.Call(0); r != 0 {
opened = true
break
}
time.Sleep(10 * time.Millisecond)
}
if !opened {
return nil, syscall.EBUSY
}
defer procCloseClipboard.Call()
h, _, _ := procGetClipboardData.Call(cfHDrop)
if h == 0 {
return nil, nil
}
count, _, _ := procDragQueryFileW.Call(h, 0xFFFFFFFF, 0, 0)
if count == 0 {
return nil, nil
}
paths := make([]string, 0, count)
for i := uintptr(0); i < count; i++ {
n, _, _ := procDragQueryFileW.Call(h, i, 0, 0)
if n == 0 {
continue
}
buf := make([]uint16, n+1)
procDragQueryFileW.Call(h, i, uintptr(unsafe.Pointer(&buf[0])), n+1)
paths = append(paths, syscall.UTF16ToString(buf))
}
return paths, nil
}
+3 -8
View File
@@ -26,7 +26,8 @@ func (a *App) GetAppConfig() (map[string]interface{}, error) {
}
// SaveAppConfig 保存应用配置
func (a *App) SaveAppConfig(req SaveAppConfigRequest) (map[string]interface{}, error) {
// 直接复用 api.SaveAppConfigRequest,避免 main 包副本转发时丢字段
func (a *App) SaveAppConfig(req api.SaveAppConfigRequest) (map[string]interface{}, error) {
if a.configAPI == nil {
return nil, fmt.Errorf("配置服务正在初始化中")
}
@@ -41,13 +42,7 @@ func (a *App) SaveAppConfig(req SaveAppConfigRequest) (map[string]interface{}, e
}
}
apiReq := api.SaveAppConfigRequest{
Tabs: req.Tabs,
VisibleTabs: req.VisibleTabs,
DefaultTab: req.DefaultTab,
}
result, err := a.configAPI.SaveAppConfig(apiReq)
result, err := a.configAPI.SaveAppConfig(req)
if err != nil {
return result, err
}
+106 -20
View File
@@ -11,6 +11,15 @@ import (
"github.com/wailsapp/wails/v3/pkg/application"
)
// filesystemSvc 返回文件系统服务。初始化在启动 goroutine 异步进行,
// 完成前前端触发文件操作时返回明确错误而非 nil panic
func (a *App) filesystemSvc() (*filesystem.FileSystemService, error) {
if a.filesystem == nil {
return nil, fmt.Errorf("文件系统服务尚未初始化完成,请稍后重试")
}
return a.filesystem, nil
}
// EnsureDrawioViewer 确保本地已缓存 drawio viewer(首次预览时下载),前端打开 .drawio 前调用。
// 通过 drawio-download-progress 事件推送下载进度。
func (a *App) EnsureDrawioViewer() (map[string]interface{}, error) {
@@ -30,7 +39,11 @@ func (a *App) EnsureDrawioViewer() (map[string]interface{}, error) {
// ReadFile 读取文件
func (a *App) ReadFile(path string) (string, error) {
return a.filesystem.ReadFile(path)
fs, err := a.filesystemSvc()
if err != nil {
return "", err
}
return fs.ReadFile(path)
}
// WatchFile 开始监听指定文件的变化,变化时发送 file-changed 事件
@@ -50,52 +63,93 @@ func (a *App) UnwatchFile() {
// WriteFile 写入文件
func (a *App) WriteFile(req WriteFileRequest) error {
return a.filesystem.WriteFile(req.Path, req.Content)
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.WriteFile(req.Path, req.Content)
}
// SaveBase64File 将 base64 内容解码后写入文件(用于图片等二进制数据)
func (a *App) SaveBase64File(req SaveBase64FileRequest) error {
return a.filesystem.SaveBase64File(req.Path, req.Content)
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.SaveBase64File(req.Path, req.Content)
}
// ListDir 列出目录
func (a *App) ListDir(path string) ([]map[string]interface{}, error) {
return a.filesystem.ListDir(path)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.ListDir(path)
}
// CreateDir 创建目录
func (a *App) CreateDir(path string) (*filesystem.FileOperationResult, error) {
return a.filesystem.CreateDir(path)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.CreateDir(path)
}
// CreateFile 创建文件
func (a *App) CreateFile(path string) (*filesystem.FileOperationResult, error) {
return a.filesystem.CreateFile(path)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.CreateFile(path)
}
// DeletePath 删除文件或目录
func (a *App) DeletePath(path string) (*filesystem.FileOperationResult, error) {
return a.filesystem.DeletePath(path)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.DeletePath(path)
}
// RenamePath 重命名文件或目录
func (a *App) RenamePath(req RenamePathRequest) (*filesystem.FileOperationResult, error) {
return a.filesystem.RenamePath(req.OldPath, req.NewPath)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.RenamePath(req.OldPath, req.NewPath)
}
// CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度
func (a *App) CopyPath(src, dst string) error {
fs, err := a.filesystemSvc()
if err != nil {
return err
}
emitter := newTransferEmitter(a, "copy", filepath.Base(filepath.FromSlash(src)))
err := a.filesystem.CopyPath(src, dst, func(copied, total int64) {
err = fs.CopyPath(src, dst, func(copied, total int64) {
emitter.progress(emitter.name, copied, total)
})
emitter.finish(err)
return err
}
// MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
// MovePath 移动文件或目录(同盘重命名,跨盘复制后删除),跨盘时经 transfer-progress 事件推送进度
func (a *App) MovePath(src, dst string) error {
return a.filesystem.MovePath(src, dst)
fs, err := a.filesystemSvc()
if err != nil {
return err
}
emitter := newTransferEmitter(a, "move", filepath.Base(filepath.FromSlash(src)))
err = fs.MovePath(src, dst, func(copied, total int64) {
emitter.progress(emitter.name, copied, total)
})
emitter.finish(err)
return err
}
// PickDirectory 打开系统目录选择器,返回选中目录(取消时返回空串)
@@ -177,39 +231,67 @@ func (t *transferEmitter) finish(err error) {
// GetFileInfo 获取文件信息
func (a *App) GetFileInfo(path string) (map[string]interface{}, error) {
return a.filesystem.GetFileInfo(path)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.GetFileInfo(path)
}
// OpenPath 使用系统默认程序打开文件或目录
func (a *App) OpenPath(path string) error {
return a.filesystem.OpenPath(path)
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.OpenPath(path)
}
// ========== Zip 文件操作接口 ==========
// ListZipContents 列出 zip 文件内容
func (a *App) ListZipContents(zipPath string) ([]map[string]interface{}, error) {
return a.filesystem.ListZipContents(zipPath)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.ListZipContents(zipPath)
}
// ExtractFileFromZip 从 zip 文件中提取单个文件内容
func (a *App) ExtractFileFromZip(zipPath, filePath string) (string, error) {
return a.filesystem.ExtractFileFromZip(zipPath, filePath)
fs, err := a.filesystemSvc()
if err != nil {
return "", err
}
return fs.ExtractFileFromZip(zipPath, filePath)
}
// ExtractFileFromZipToTemp 从 zip 文件中提取单个文件到临时目录
func (a *App) ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
return a.filesystem.ExtractFileFromZipToTemp(zipPath, filePath)
fs, err := a.filesystemSvc()
if err != nil {
return "", err
}
return fs.ExtractFileFromZipToTemp(zipPath, filePath)
}
// GetZipFileInfo 获取 zip 文件中特定文件的信息
func (a *App) GetZipFileInfo(zipPath, filePath string) (map[string]interface{}, error) {
return a.filesystem.GetZipFileInfo(zipPath, filePath)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.GetZipFileInfo(zipPath, filePath)
}
// ResolveShortcut 解析快捷方式文件,返回目标路径信息
func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
targetPath, err := a.filesystem.ResolveShortcut(lnkPath)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
targetPath, err := fs.ResolveShortcut(lnkPath)
if err != nil {
return map[string]interface{}{
"success": false,
@@ -217,7 +299,7 @@ func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
}, err
}
fileInfo, err := a.filesystem.GetFileInfo(targetPath)
fileInfo, err := fs.GetFileInfo(targetPath)
if err != nil {
return map[string]interface{}{
"success": true,
@@ -284,5 +366,9 @@ func (a *App) initFilesystemModule() {
// GetAuditLogs 获取审计日志
func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
return a.filesystem.GetAuditLogs(limit)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.GetAuditLogs(limit)
}
+20 -4
View File
@@ -51,12 +51,28 @@ func (a *App) ExportPDF(content string, title string, fileName string, fontSize
}, nil
}
// SelectPDFSaveDirectory 选择PDF保存目录
func (a *App) SelectPDFSaveDirectory() (string, error) {
// ExportHtmlFileToPDF 将HTML文件导出为PDF
func (a *App) ExportHtmlFileToPDF(path string, saveName string) (map[string]interface{}, error) {
pdfAPI, err := a.requirePdfAPI()
if err != nil {
return "", err
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, err
}
return pdfAPI.SelectDirectory()
result, err := pdfAPI.ExportHtmlFileToPDF(path, saveName)
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
}
+20 -4
View File
@@ -4,20 +4,36 @@ package main
// GetRecycleBinEntries 获取回收站条目
func (a *App) GetRecycleBinEntries() ([]map[string]interface{}, error) {
return a.filesystem.GetRecycleBinEntries()
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.GetRecycleBinEntries()
}
// RestoreFromRecycleBin 从回收站恢复文件
func (a *App) RestoreFromRecycleBin(recyclePath string) error {
return a.filesystem.RestoreFromRecycleBin(recyclePath)
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.RestoreFromRecycleBin(recyclePath)
}
// DeletePermanently 永久删除回收站中的文件
func (a *App) DeletePermanently(recyclePath string) error {
return a.filesystem.DeletePermanently(recyclePath)
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.DeletePermanently(recyclePath)
}
// EmptyRecycleBin 清空回收站
func (a *App) EmptyRecycleBin() error {
return a.filesystem.EmptyRecycleBin()
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.EmptyRecycleBin()
}
+42 -3
View File
@@ -92,7 +92,7 @@ func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType st
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
}
// startAutoUpdateCheck 启动自动更新检查
// startAutoUpdateCheck 启动自动更新检查(可重入:先停止旧定时器再按当前配置新建)
func (a *App) startAutoUpdateCheck() {
updateAPI, err := a.requireUpdateAPI()
if err != nil {
@@ -116,6 +116,8 @@ func (a *App) startAutoUpdateCheck() {
autoCheckEnabled, ok := configData["auto_check_enabled"].(bool)
if !ok || !autoCheckEnabled {
// 关闭自动检查时停止存量定时器
a.stopUpdateTicker()
return
}
@@ -124,16 +126,53 @@ func (a *App) startAutoUpdateCheck() {
interval = 5
}
a.mu.Lock()
if a.updateCheckStop != nil {
close(a.updateCheckStop)
}
if a.updateTicker != nil {
a.updateTicker.Stop()
}
stop := make(chan struct{})
a.updateCheckStop = stop
a.updateTicker = time.NewTicker(time.Duration(interval) * time.Minute)
ticker := a.updateTicker
a.mu.Unlock()
go a.checkUpdate()
a.updateTicker = time.NewTicker(time.Duration(interval) * time.Minute)
go func() {
for range a.updateTicker.C {
for {
select {
case <-ticker.C:
a.checkUpdate()
case <-stop:
return
}
}
}()
}
// stopUpdateTicker 停止自动更新检查定时器及其 goroutine
func (a *App) stopUpdateTicker() {
a.mu.Lock()
defer a.mu.Unlock()
if a.updateCheckStop != nil {
close(a.updateCheckStop)
a.updateCheckStop = nil
}
if a.updateTicker != nil {
a.updateTicker.Stop()
a.updateTicker = nil
}
}
// resetAutoUpdateCheck 重置自动更新检查(更新配置变更后调用,免重启生效)
func (a *App) resetAutoUpdateCheck() {
a.stopUpdateTicker()
a.startAutoUpdateCheck()
}
// checkUpdate 执行更新检查
func (a *App) checkUpdate() {
defer func() {
+133
View File
@@ -0,0 +1,133 @@
//go:build windows
package main
import (
"strings"
"sync/atomic"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
// Chrome_WidgetWin 系列类名是 WebView2 的宿主窗口层级
const webviewHostClassFragment = "Chrome_WidgetWin"
// wmSize Win32 窗口尺寸变化消息
const wmSizeMsg uint32 = 0x0005
var (
// user32 对这三个 API 只导出无后缀名(无 A/W 变体,带 W 后缀找不到 proc 会 panic)
procGetClientRectFix = windows.NewLazySystemDLL("user32.dll").NewProc("GetClientRect")
procSetWindowPosFix = windows.NewLazySystemDLL("user32.dll").NewProc("SetWindowPos")
procGetAncestorFix = windows.NewLazySystemDLL("user32.dll").NewProc("GetAncestor")
)
// 修复去重:同一时刻只允许一个校验在跑(回调写共享切片要求互斥)
var webviewFixRunning atomic.Bool
// 当前校验的目标主窗口(枚举回调过滤直属子窗口用)
var webviewTargetHwnd uintptr
// webviewHosts 收集结果容器;enumChildWebviewHosts 回调写入
var webviewHosts []windows.HWND
// 包级变量:syscall.NewCallback 指向的 Go 闭包必须存活于整个进程生命周期
var enumChildWebviewHosts = syscall.NewCallback(func(h uintptr, _ uintptr) uintptr {
// GA_PARENT(1):只收直属子窗口,避免动到更深层的 WebView2 内部窗口
parent, _, _ := procGetAncestorFix.Call(h, 1)
if parent != webviewTargetHwnd {
return 1
}
var buf [256]uint16
if _, err := windows.GetClassName(windows.HWND(h), &buf[0], int32(len(buf))); err != nil {
return 1
}
if strings.Contains(syscall.UTF16ToString(buf[:]), webviewHostClassFragment) {
webviewHosts = append(webviewHosts, windows.HWND(h))
}
return 1
})
// checkAndFixFill 校验 WebView2 宿主是否铺满窗口客户区,未铺满则强制铺满。
// wails alpha 的 WM_SIZE 处理存在竞态:主线程忙时消息被 resizePending 标志丢弃,
// 迟到执行的重铺又使用闭包捕获的过期 bounds,webview 会停在中间尺寸且不再有
// 新消息来纠正,表现为整个应用内容收窄、四周露出窗口底色。
func checkAndFixFill(hwnd uintptr) {
if hwnd == 0 {
return
}
var rc struct{ Left, Top, Right, Bottom int32 }
if r, _, _ := procGetClientRectFix.Call(hwnd, uintptr(unsafe.Pointer(&rc))); r == 0 {
return
}
cw, ch := int(rc.Right-rc.Left), int(rc.Bottom-rc.Top)
if cw <= 0 || ch <= 0 {
return
}
webviewTargetHwnd = hwnd
webviewHosts = webviewHosts[:0]
windows.EnumChildWindows(windows.HWND(hwnd), enumChildWebviewHosts, nil)
for _, h := range webviewHosts {
// SWP_NOZORDER(0x0004):只改尺寸位置,不干预 wails 管理的层级
procSetWindowPosFix.Call(uintptr(h), 0, 0, 0, uintptr(cw), uintptr(ch), 0x0004)
}
}
// runGuarded 统一 panic 兜底:windowsgui 下 goroutine panic 会静默杀进程
func runGuarded(fn func()) {
defer func() { recover() }()
fn()
}
// fixWebviewFill WM_SIZE 路径:等 resize 序列结束、wails 内部去抖落定后校验一次
func fixWebviewFill(hwnd uintptr) {
if !webviewFixRunning.CompareAndSwap(false, true) {
return
}
go func() {
defer webviewFixRunning.Store(false)
runGuarded(func() {
time.Sleep(150 * time.Millisecond)
checkAndFixFill(hwnd)
})
}()
}
// StartWebviewFillWatch 周期兜底:覆盖"无新 WM_SIZE 的迟到重放"场景
// (主线程忙时排队的过期重绘在窗口静止时才执行,此时不会产生任何 WM_SIZE)
func StartWebviewFillWatch() {
go runGuarded(func() {
for {
time.Sleep(3 * time.Second)
if webviewFixRunning.CompareAndSwap(false, true) {
runGuarded(func() {
checkAndFixFill(findMainWindowHwnd())
})
webviewFixRunning.Store(false)
}
}
})
}
// findMainWindowHwnd 找本进程的可见主窗口
func findMainWindowHwnd() uintptr {
var found uintptr
pid := windows.GetCurrentProcessId()
cb := syscall.NewCallback(func(h uintptr, _ uintptr) uintptr {
var winPid uint32
windows.GetWindowThreadProcessId(windows.HWND(h), &winPid)
if winPid == pid && windows.IsWindowVisible(windows.HWND(h)) {
found = h
return 0
}
return 1
})
runGuarded(func() {
windows.EnumWindows(cb, nil)
})
return found
}
@@ -1,7 +1,7 @@
# U-Desk 功能清单
> 应用版本:v0.3.3
> 更新时间:2026-02-28 10:00
> 应用版本:v0.5.0
> 更新时间:2026-10-01 10:00
> 用途:测试、开发、迭代过程中验证核对
> 核对状态:✅ 已通过Git版本对比 + 源码验证
> 核对报告:[功能清单核对报告.md](./功能清单核对报告.md)
@@ -488,8 +488,8 @@
---
**文档维护**: 开发团队
**最后更新**: 2026-02-28 10:00
**应用版本**: v0.3.3
**最后更新**: 2026-10-01 10:00
**应用版本**: v0.5.0
**核对状态**: ✅ 已通过Git版本对比 + 源码验证 + 需求对照
**核对报告**: [功能清单核对报告.md](./功能清单核对报告.md)
**下次审查**: 每次发版前
Submodule docs/08-用户指南/u-desk-site updated: 8f29a7e985...15016c01b7
+26 -9
View File
@@ -15,6 +15,9 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
import * as application$0 from "../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as api$0 from "./internal/api/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as filesystem$0 from "./internal/filesystem/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -133,6 +136,15 @@ export function EnsureDrawioViewer(): $CancellablePromise<{ [_ in string]?: any
});
}
/**
* ExportHtmlFileToPDF 将HTML文件导出为PDF
*/
export function ExportHtmlFileToPDF(path: string, saveName: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(3012957793, path, saveName).then(($result: any) => {
return $$createType2($result);
});
}
/**
* ExportPDF 导出PDF文件
*/
@@ -336,7 +348,7 @@ export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?:
}
/**
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除),跨盘时经 transfer-progress 事件推送进度
*/
export function MovePath(src: string, dst: string): $CancellablePromise<void> {
return $Call.ByID(2028899675, src, dst);
@@ -482,6 +494,16 @@ export function PickDirectory(): $CancellablePromise<string> {
return $Call.ByID(1347829059);
}
/**
* ReadClipboardFiles 读取系统剪贴板中的文件/文件夹路径列表。
* 剪贴板为文本/截图等非文件内容时返回空列表,前端据此回落图片粘贴等原有链路。
*/
export function ReadClipboardFiles(): $CancellablePromise<string[]> {
return $Call.ByID(2636780132).then(($result: any) => {
return $$createType7($result);
});
}
/**
* ReadFile 读取文件
*/
@@ -530,8 +552,9 @@ export function RestoreFromRecycleBin(recyclePath: string): $CancellablePromise<
/**
* SaveAppConfig 保存应用配置
* 直接复用 api.SaveAppConfigRequest,避免 main 包副本转发时丢字段
*/
export function SaveAppConfig(req: $models.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
export function SaveAppConfig(req: api$0.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1942219977, req).then(($result: any) => {
return $$createType2($result);
});
@@ -550,13 +573,6 @@ export function SaveConnectionProfile(req: $models.SaveProfileRequest): $Cancell
});
}
/**
* SelectPDFSaveDirectory 选择PDF保存目录
*/
export function SelectPDFSaveDirectory(): $CancellablePromise<string> {
return $Call.ByID(1403263131);
}
/**
* SetMainWindow 设置主窗口引用(由 main.go 在创建窗口后调用)
*/
@@ -795,3 +811,4 @@ const $$createType3 = filesystem$0.FileOperationResult.createFrom;
const $$createType4 = $Create.Nullable($$createType3);
const $$createType5 = $Create.Array($$createType2);
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
const $$createType7 = $Create.Array($Create.Any);
-1
View File
@@ -11,7 +11,6 @@ export {
OssConnectRequest,
OssRenamePathRequest,
RenamePathRequest,
SaveAppConfigRequest,
SaveBase64FileRequest,
SaveProfileRequest,
SftpConnectRequest,
@@ -2,5 +2,6 @@
// This file is automatically generated. DO NOT EDIT
export {
AppTabDefinition
AppTabDefinition,
SaveAppConfigRequest
} from "./models.js";
@@ -40,3 +40,56 @@ export class AppTabDefinition {
return new AppTabDefinition($$parsedSource as Partial<AppTabDefinition>);
}
}
/**
* SaveAppConfigRequest 保存应用配置请求(前端格式)
*/
export class SaveAppConfigRequest {
"tabs": AppTabDefinition[];
"visibleTabs": string[];
"defaultTab": string;
"sidebarSections": string[];
/** Creates a new SaveAppConfigRequest instance. */
constructor($$source: Partial<SaveAppConfigRequest> = {}) {
if (!("tabs" in $$source)) {
this["tabs"] = [];
}
if (!("visibleTabs" in $$source)) {
this["visibleTabs"] = [];
}
if (!("defaultTab" in $$source)) {
this["defaultTab"] = "";
}
if (!("sidebarSections" in $$source)) {
this["sidebarSections"] = [];
}
Object.assign(this, $$source);
}
/**
* Creates a new SaveAppConfigRequest instance from a string or object.
*/
static createFrom($$source: any = {}): SaveAppConfigRequest {
const $$createField0_0 = $$createType1;
const $$createField1_0 = $$createType2;
const $$createField3_0 = $$createType2;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("tabs" in $$parsedSource) {
$$parsedSource["tabs"] = $$createField0_0($$parsedSource["tabs"]);
}
if ("visibleTabs" in $$parsedSource) {
$$parsedSource["visibleTabs"] = $$createField1_0($$parsedSource["visibleTabs"]);
}
if ("sidebarSections" in $$parsedSource) {
$$parsedSource["sidebarSections"] = $$createField3_0($$parsedSource["sidebarSections"]);
}
return new SaveAppConfigRequest($$parsedSource as Partial<SaveAppConfigRequest>);
}
}
// Private type creation functions
const $$createType0 = AppTabDefinition.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = $Create.Array($Create.Any);
-49
View File
@@ -5,10 +5,6 @@
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as api$0 from "./internal/api/models.js";
/**
* BgmPlaylistItem 播放列表条目
*/
@@ -134,46 +130,6 @@ export class RenamePathRequest {
}
}
/**
* SaveAppConfigRequest 保存应用配置请求
*/
export class SaveAppConfigRequest {
"tabs": api$0.AppTabDefinition[];
"visibleTabs": string[];
"defaultTab": string;
/** Creates a new SaveAppConfigRequest instance. */
constructor($$source: Partial<SaveAppConfigRequest> = {}) {
if (!("tabs" in $$source)) {
this["tabs"] = [];
}
if (!("visibleTabs" in $$source)) {
this["visibleTabs"] = [];
}
if (!("defaultTab" in $$source)) {
this["defaultTab"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new SaveAppConfigRequest instance from a string or object.
*/
static createFrom($$source: any = {}): SaveAppConfigRequest {
const $$createField0_0 = $$createType1;
const $$createField1_0 = $$createType2;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("tabs" in $$parsedSource) {
$$parsedSource["tabs"] = $$createField0_0($$parsedSource["tabs"]);
}
if ("visibleTabs" in $$parsedSource) {
$$parsedSource["visibleTabs"] = $$createField1_0($$parsedSource["visibleTabs"]);
}
return new SaveAppConfigRequest($$parsedSource as Partial<SaveAppConfigRequest>);
}
}
/**
* SaveBase64FileRequest 保存 Base64 编码的二进制文件
*/
@@ -422,8 +378,3 @@ export class WriteFileRequest {
return new WriteFileRequest($$parsedSource as Partial<WriteFileRequest>);
}
}
// Private type creation functions
const $$createType0 = api$0.AppTabDefinition.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = $Create.Array($Create.Any);
+6 -4
View File
@@ -85,7 +85,6 @@
<UpdateNotification
v-model="updateStore.showUpdate"
:update-info="updateStore.updateInfo"
@install="updateStore.installUpdate"
/>
</a-layout>
</template>
@@ -179,9 +178,12 @@ onMounted(() => {
// 禁用浏览器默认右键菜单(桌面应用体验)
document.addEventListener('contextmenu', e => e.preventDefault())
// 延迟检查更新(启动后 3 秒,静默模式)
setTimeout(() => {
updateStore.checkForUpdates(true)
// 延迟检查更新(启动后 3 秒,静默模式;后端更新模块异步初始化,未就绪则 15 秒后重试一次)
setTimeout(async () => {
const ok = await updateStore.checkForUpdates(true)
if (!ok) {
setTimeout(() => { updateStore.checkForUpdates(true) }, 15000)
}
}, 3000)
})
+7 -11
View File
@@ -9,6 +9,7 @@ import { HttpTransport } from './http-transport'
import { SftpTransport } from './sftp-transport'
import { OssTransport } from './oss-transport'
import { getFileServerBaseURL } from './file-server'
import { errMessage } from '@/utils/errors'
import {
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
SftpGetSystemInfo, GetLocalSystemInfo,
@@ -268,7 +269,7 @@ class ConnectionManagerImpl {
}
}
}
} catch (e) { info._error = true; info._errorMsg = errMsg(e) }
} catch (e) { info._error = true; info._errorMsg = errMessage(e) }
this._sysInfoCache.set(profileId, info)
this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info))
@@ -320,7 +321,7 @@ class ConnectionManagerImpl {
} catch (err) {
// 回退目标可能已不在池中(死连接重建时 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.setProfileState(profileId, 'error', errMessage(err))
this.notifyChange()
throw err
}
@@ -433,7 +434,7 @@ class ConnectionManagerImpl {
this.fetchSystemInfo(profileId).catch(() => {})
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMsg(err))
this.setProfileState(profileId, 'error', errMessage(err))
this.setState('error')
throw err
}
@@ -453,7 +454,7 @@ class ConnectionManagerImpl {
this.updateProfile(profileId, { lastConnected: Date.now() })
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMsg(err))
this.setProfileState(profileId, 'error', errMessage(err))
this.setState('error')
throw err
}
@@ -474,13 +475,13 @@ class ConnectionManagerImpl {
this.updateProfile(profileId, { lastConnected: Date.now() })
this.fetchSystemInfo(profileId).catch(() => {})
}).catch((e) => {
this.setProfileState(profileId, 'error', errMsg(e))
this.setProfileState(profileId, 'error', errMessage(e))
if (this._activeId !== profileId) return
this.setState('error')
})
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMsg(err))
this.setProfileState(profileId, 'error', errMessage(err))
this.setState('error')
throw err
}
@@ -489,11 +490,6 @@ class ConnectionManagerImpl {
export const connectionManager = new ConnectionManagerImpl()
/** 任意抛出值转错误文案 */
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
/** snake_case → camelCase */
function snakeToCamel(obj: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {}
+14 -1
View File
@@ -3,6 +3,7 @@
*/
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
import { GetEnvVars, WriteFile } from '@bindings/u-desk/app'
const CONTENT_TYPE = 'application/json'
@@ -156,7 +157,19 @@ export class HttpTransport implements FsTransport {
async emptyRecycleBin(): Promise<void> {}
// HTML 的 PDF 导出与本地预览都依赖本地真实文件(导出在后端 os.Stat,预览走本地文件服务器),
// 因此把远端 HTML 文本拉到本机临时目录;其余类型保持返回原路径走 fs-agent 代理流式预览,
// 二进制内容若也走 read 接口会被当文本损坏
async downloadForPreview(path: string): Promise<string> {
return path
if (!/\.html?$/i.test(path)) return path
const content = await this.readFile(path)
const env = await GetEnvVars()
const tempDir = env['TEMP'] || env['TMP'] || env['TMPDIR'] || ''
if (!tempDir) return path
const sep = tempDir.includes('\\') ? '\\' : '/'
const base = path.split(/[\\/]/).pop() || 'preview.html'
const localPath = `${tempDir.replace(/[\\/]+$/, '')}${sep}udesk-http-${Date.now()}-${base}`
await WriteFile({ path: localPath, content })
return localPath
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ export interface FsTransport {
emptyRecycleBin(): Promise<void>
// 预览辅助
/** 下载远程文件到本地临时目录,本地/HTTP 直接返回原路径 */
/** 下载远程文件到本地临时目录,本地直接返回原路径;HTTP 仅对 HTML 落本地副本,其余类型返回原路径 */
downloadForPreview(path: string): Promise<string>
/** 下载 HTML 及其引用的资源到临时目录(OSS 实现) */
downloadSiteForPreview?(path: string): Promise<string>
@@ -210,6 +210,17 @@
<template #icon><icon-save /></template>
</a-button>
</a-tooltip>
<!-- PDF 导出按钮(仅预览模式显示,后端 Chrome 渲染输出到桌面) -->
<a-tooltip v-if="!config.isEditMode" position="left" content="导出 PDF">
<a-button
type="outline"
size="small"
:loading="htmlExporting"
@click="handleExportHtmlPDF"
>
<template #icon><icon-file-pdf /></template>
</a-button>
</a-tooltip>
<!-- 预览/编辑切换按钮 -->
<a-tooltip
position="left"
@@ -440,6 +451,7 @@ import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandle
import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
import { isDirty } from '../composables/useMultiPreview'
import { On, Off } from '@wailsio/events'
import { ExportHtmlFileToPDF } from '@bindings/u-desk/app'
import { connectionManager } from '@/api/connection-manager'
import { listDir, readFile } from '@/api/system'
import { resolveFileUrl, resolveFileUrlForProfile } from '../composables/useFilePreview'
@@ -528,32 +540,39 @@ const getFileTypeIcon = (filename: string): string => {
// HTML 预览 URL(OSS 需要先下载到本地再预览)
const htmlPreviewUrl = ref('')
// 导出 PDF 需要本地可访问的源文件路径:远程文件取缓存副本,本地文件即原路径
const htmlPreviewLocalPath = ref('')
watch(
() => [props.config.currentFileFullPath, props.config.isHtmlFile],
async ([path, isHtml]) => {
htmlPreviewUrl.value = ''
htmlPreviewLocalPath.value = ''
if (!path || !isHtml) return
// 下载到临时目录后是本地文件,统一用本地文件服务器
const base = getFileServerBaseURL()
const makeUrl = (localPath: string) => `${base}/localfs/html-preview?path=${encodeURIComponent(localPath)}`
const applyLocalPath = (localPath: string) => {
htmlPreviewLocalPath.value = localPath
htmlPreviewUrl.value = makeUrl(localPath)
}
if (connectionManager.isRemote()) {
try {
const transport = connectionManager.getTransport()
if (transport?.downloadSiteForPreview) {
const tempPath = await transport.downloadSiteForPreview(path)
htmlPreviewUrl.value = makeUrl(tempPath)
applyLocalPath(tempPath)
} else if (transport?.downloadForPreview) {
const tempPath = await transport.downloadForPreview(path)
htmlPreviewUrl.value = makeUrl(tempPath)
applyLocalPath(tempPath)
}
} catch (e) {
console.warn('[HTML预览] 下载失败:', path, e)
}
} else {
htmlPreviewUrl.value = makeUrl(path)
applyLocalPath(path)
}
},
{ immediate: true }
@@ -897,6 +916,32 @@ const handleExportCsvPDF = async () => {
)
}
// HTML 文件导出 PDF:交给后端 headless Chrome 渲染,绕开浏览器打印对话框
const htmlExporting = ref(false)
const handleExportHtmlPDF = async () => {
const srcPath = htmlPreviewLocalPath.value
if (!srcPath) {
Message.error('HTML 预览尚未就绪,请稍候重试')
return
}
const baseName = (props.config.currentFileName || 'export').replace(/\.[^.]+$/, '')
htmlExporting.value = true
try {
const res = await ExportHtmlFileToPDF(srcPath, baseName)
if (res?.success) {
// 后端导出成功已自动打开资源管理器选中文件,弹窗只做明确指向,不依赖用户记住路径
Message.success('PDF 已导出,已在桌面打开所在位置')
} else {
Message.error(res?.message || 'PDF 导出失败')
}
} catch (e: any) {
// 后端约定的错误文案随异常抛出,无 Chrome 时向用户展示安装提示
Message.error(e?.message || 'PDF 导出失败')
} finally {
htmlExporting.value = false
}
}
// 监听模式切换,切换到预览模式时渲染 Mermaid 图表
watch(() => props.config.isEditMode, async (newVal, oldVal) => {
// 从编辑模式切换到预览模式
@@ -1,9 +1,9 @@
<template>
<transition name="slide">
<div v-show="config.visible" class="sidebar">
<div v-show="config.visible" class="sidebar" :style="{ width: width + 'px' }">
<template v-for="section in sectionOrder" :key="section">
<!-- 服务器区块 -->
<div v-if="section === 'server' && showServer" class="sidebar-section">
<div v-if="section === 'server' && showServer" class="sidebar-section server-section">
<div class="section-header" @click="serverCollapsed = !serverCollapsed">
<span class="section-title">🖥️ 服务器</span>
<icon-down v-if="!serverCollapsed" class="section-toggle" />
@@ -19,10 +19,16 @@
<span class="col-metric">内存</span>
<span class="col-action settings-btn" @click="toggleSettings($event)" title="管理">···</span>
</div>
<!-- 表格行 -->
<div
v-for="p in visibleProfiles"
<!-- 表格行(右键菜单与行内「...」菜单共用同一组操作,互斥展开) -->
<a-dropdown
v-for="(p, idx) in visibleProfiles"
:key="p.id"
trigger="contextMenu"
align-point
:popup-visible="srvCtxMenuIndex === idx"
@popup-visible-change="(v: boolean) => onSrvCtxVisible(idx, v)"
>
<div
class="server-table-row"
:class="{ active: p.id === activeId }"
@click="handleSelect(p)"
@@ -39,6 +45,23 @@
>···</span>
<span v-else class="col-action"></span>
</div>
<template #content>
<a-doption @click="handleSelect(p)">
<template #icon><icon-computer /></template>
连接
</a-doption>
<template v-if="p.type !== 'local'">
<a-doption @click="handleEdit(p)">
<template #icon><icon-edit /></template>
编辑
</a-doption>
<a-doption class="danger" @click="handleDelete(p)">
<template #icon><icon-close /></template>
删除
</a-doption>
</template>
</template>
</a-dropdown>
</div>
</div>
@@ -54,9 +77,15 @@
</div>
<div class="section-content-wrap" :class="{ collapsed: favCollapsed }">
<div class="section-content fav-content">
<div
<a-dropdown
v-for="(fav, index) in config.favoriteFiles"
:key="fav.path"
trigger="contextMenu"
align-point
:popup-visible="favCtxMenuIndex === index"
@popup-visible-change="(v: boolean) => onFavCtxVisible(index, v)"
>
<div
class="sidebar-item"
:class="{
'sidebar-item-pinned': fav.pinnedAt,
@@ -78,30 +107,49 @@
@drop="handleDrop($event, index)"
@dragend="handleDragEnd"
>
<span class="sidebar-item-icon">{{ getFileIcon(fav) }}</span>
<span class="sidebar-item-name" :title="fav.name">{{ fav.name }}</span>
<a-button
type="text"
size="mini"
@click.stop="handleTogglePin(fav)"
class="sidebar-item-pin"
:class="{ 'is-pinned': fav.pinnedAt }"
<span class="sidebar-item-icon">{{ getFileIcon({ is_dir: fav.isDir, name: fav.name }) }}</span>
<span class="sidebar-item-name" :title="favoriteTooltip(fav)">{{ favoriteName(fav) }}</span>
<a-dropdown
trigger="click"
:popup-visible="favMenuIndex === index"
@popup-visible-change="(v: boolean) => onFavMenuVisible(index, v)"
>
<a-button type="text" size="mini" title="更多操作" class="sidebar-item-more" @click.stop>
<template #icon>
<icon-pushpin :style="{ opacity: fav.pinnedAt ? 1 : 0.4 }" />
<icon-more />
</template>
</a-button>
<a-button
type="text"
size="mini"
@click.stop="handleRemoveFavorite(fav)"
class="sidebar-item-remove"
>
<template #icon>
<icon-close />
<template #content>
<a-doption @click="openRename(fav)">
<template #icon><icon-edit /></template>
重命名
</a-doption>
<a-doption @click="handleTogglePin(fav)">
<template #icon><icon-pushpin /></template>
{{ fav.pinnedAt ? '取消置顶' : '置顶' }}
</a-doption>
<a-doption class="danger" @click="handleRemoveFavorite(fav)">
<template #icon><icon-close /></template>
删除
</a-doption>
</template>
</a-button>
</a-dropdown>
</div>
<template #content>
<a-doption @click="openRename(fav)">
<template #icon><icon-edit /></template>
重命名
</a-doption>
<a-doption @click="handleTogglePin(fav)">
<template #icon><icon-pushpin /></template>
{{ fav.pinnedAt ? '取消置顶' : '置顶' }}
</a-doption>
<a-doption class="danger" @click="handleRemoveFavorite(fav)">
<template #icon><icon-close /></template>
删除
</a-doption>
</template>
</a-dropdown>
<div v-if="config.favoriteFiles.length === 0" class="sidebar-empty">
<icon-star />
<span>暂无收藏</span>
@@ -112,7 +160,7 @@
</div>
<!-- 帮助文档区块 -->
<div v-if="section === 'help' && showHelp" class="sidebar-section">
<div v-if="section === 'help' && showHelp" class="sidebar-section help-section">
<div class="section-header" @click="helpCollapsed = !helpCollapsed">
<span class="section-title">📖 帮助</span>
<icon-down v-if="!helpCollapsed" class="section-toggle" />
@@ -131,6 +179,26 @@
</div>
</transition>
<!-- 重命名收藏弹窗 -->
<a-modal
:visible="renameVisible"
title="重命名收藏"
:width="420"
:mask-closable="false"
unmount-on-close
@cancel="renameVisible = false"
@before-ok="handleRenameOk"
>
<a-input
ref="renameInputRef"
v-model="renameValue"
:placeholder="renameOriginal"
:max-length="255"
allow-clear
@keydown.enter.prevent="confirmRename"
/>
</a-modal>
<!-- 更多操作子菜单 — Teleport 到 body -->
<Teleport to="body">
<div v-if="moreOpenId" class="sidebar-popup more-menu" :style="moreMenuStyle" @click.stop>
@@ -141,6 +209,9 @@
<div class="settings-item" @click="$emit('openConnectionDialog'); settingsOpen = false">
<icon-plus /> 添加服务器
</div>
<div class="settings-item" @click="connectAllServers">
<icon-link /> 一键全连
</div>
<div class="settings-item" @click="toggleAutoConnect">
<icon-check-circle :style="{ opacity: autoConnect ? 1 : 0.3 }" />
启动时自动连接所有服务器
@@ -165,12 +236,12 @@
</template>
<script setup lang="ts">
import { ref, computed, shallowRef, onMounted, onUnmounted } from 'vue'
import { ref, computed, shallowRef, watch, onMounted, onUnmounted } from 'vue'
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
import type { SystemInfo } from '@/api/connection-manager'
import { connectionManager } from '@/api/connection-manager'
import { useConfigStore } from '@/stores/config'
import { IconStar, IconClose, IconPushpin, IconDown, IconRight, IconStorage, IconComputer, IconDesktop, IconPlus, IconCheckCircle } from '@arco-design/web-vue/es/icon'
import { IconStar, IconClose, IconPushpin, IconEdit, IconMore, IconDown, IconRight, IconStorage, IconComputer, IconDesktop, IconPlus, IconCheckCircle, IconLink } from '@arco-design/web-vue/es/icon'
import { getFileIcon } from '@/utils/fileUtils'
const configStore = useConfigStore()
@@ -178,13 +249,14 @@ const configStore = useConfigStore()
// Props
interface Props {
config: SidebarConfig
width: number
}
const props = defineProps<Props>()
// 折叠状态(组件内部,不污染父组件)
// 折叠状态(组件内部,不污染父组件);收藏夹默认展开,用户手动折叠后会话内保持
const serverCollapsed = ref(false)
const favCollapsed = ref(true)
const favCollapsed = ref(false)
const helpCollapsed = ref(true)
// 侧边栏区块可见性(从配置读取)
@@ -222,6 +294,7 @@ function toggleSettings(e: MouseEvent) {
return
}
moreOpenId.value = null
srvCtxMenuIndex.value = -1
const btn = (e.target as HTMLElement).closest<HTMLElement>('.settings-btn')
if (!btn) return
settingsPanelStyle.value = calcPopupStyle(btn)
@@ -235,6 +308,7 @@ function toggleMore(p: { id: string; name: string }, e: MouseEvent) {
return
}
settingsOpen.value = false
srvCtxMenuIndex.value = -1
moreOpenId.value = p.id
moreProfile.value = p
const target = (e.target as HTMLElement).closest('.more-btn') as HTMLElement
@@ -308,6 +382,25 @@ function toggleServerVisibility(id: string) {
localStorage.setItem('desk:hiddenServers', JSON.stringify(hiddenServerIds.value))
}
// 一键全连:并发连接所有可见的非本地服务器(连接池模式,已连的探测复用),
// 全部落地后恢复原焦点,不打扰当前视图
const connectingAll = ref(false)
async function connectAllServers() {
if (connectingAll.value) return
settingsOpen.value = false
connectingAll.value = true
const prevActive = activeId.value
try {
const targets = visibleProfiles.value.filter(p => p.type !== 'local')
await Promise.allSettled(targets.map(p => connectionManager.connect(p.id)))
if (prevActive && profiles.value.some(p => p.id === prevActive)) {
await connectionManager.connect(prevActive).catch(() => {})
}
} finally {
connectingAll.value = false
}
}
// 监听连接变化 + 系统信息变化
connectionManager.onStateChange(() => {
profiles.value = connectionManager.profiles
@@ -343,6 +436,7 @@ interface Emits {
(e: 'openFavorite', file: FavoriteFile): void
(e: 'removeFavorite', path: string): void
(e: 'togglePin', path: string): void
(e: 'renameFavorite', path: string, displayName: string): void
(e: 'longPressStart', event: MouseEvent | TouchEvent, index: number): void
(e: 'longPressCancel'): void
(e: 'dragStart', event: DragEvent, index: number): void
@@ -368,6 +462,76 @@ const handleTogglePin = (file: FavoriteFile) => {
emit('togglePin', file.path)
}
// 收藏项双菜单受控互斥:右键菜单与「...」点击菜单,一方展开强制收起另一方
const favMenuIndex = ref(-1)
const favCtxMenuIndex = ref(-1)
function onFavCtxVisible(index: number, visible: boolean) {
favCtxMenuIndex.value = visible ? index : -1
if (visible) favMenuIndex.value = -1
}
function onFavMenuVisible(index: number, visible: boolean) {
favMenuIndex.value = visible ? index : -1
if (visible) favCtxMenuIndex.value = -1
}
// 重命名收藏弹窗(预填展示名,placeholder 显示原名)
const renameVisible = ref(false)
const renameValue = ref('')
const renameTarget = ref<FavoriteFile | null>(null)
const renameInputRef = ref()
const renameOriginal = computed(() => renameTarget.value?.name ?? '')
const openRename = (fav: FavoriteFile) => {
renameTarget.value = fav
renameValue.value = fav.displayName || ''
renameVisible.value = true
}
watch(renameVisible, (val) => {
if (!val) return
setTimeout(() => {
renameInputRef.value?.focus()
}, 100)
})
const handleRenameOk = (done: (closed?: boolean) => void) => {
if (!renameTarget.value) {
done(false)
return
}
// 空值表示清除展示名,回退显示原名
emit('renameFavorite', renameTarget.value.path, renameValue.value.trim())
done()
}
// 输入框回车与点击确定走同一提交逻辑
const confirmRename = () => {
handleRenameOk(() => { renameVisible.value = false })
}
// 展示名优先,未设置回退原名
const favoriteName = (fav: FavoriteFile): string => fav.displayName || fav.name
// 悬停提示两行:完整路径 + 来源连接名
const favoriteTooltip = (fav: FavoriteFile): string => {
const source = favoriteSourceName(fav)
return source ? `${fav.path}\n来源:${source}` : fav.path
}
function favoriteSourceName(fav: FavoriteFile): string {
if (fav.profileId) {
return connectionManager.profiles.find(p => p.id === fav.profileId)?.name ?? ''
}
// 旧数据无 profileId,按路径格式推断连接(与加载迁移逻辑一致)
const isLocalPath = /^[A-Za-z]:/.test(fav.path) || fav.path.startsWith('\\\\')
if (isLocalPath) {
return connectionManager.profiles.find(p => p.type === 'local')?.name ?? '本地'
}
return connectionManager.profiles.find(p => p.type !== 'local')?.name ?? ''
}
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
emit('longPressStart', event, index)
}
@@ -399,7 +563,9 @@ function sysInfo(profileId: string): SystemInfo | undefined {
function stateDotClass(profileId: string): string {
if (profileId === activeId.value) {
const state = connectionManager.state
// 用 per-profile 状态而非全局 state:全局 state 只描述最近一次连接尝试,
// 连 B 失败后全局停在 error,焦点回落 A 会把 A 误染红
const state = connectionManager.getProfileState(profileId).state
if (state === 'connected') return 'connected'
if (state === 'connecting') return 'connecting'
if (state === 'error') return 'error'
@@ -460,7 +626,8 @@ function formatBytes(n: number): string {
function stateText(profileId: string): string {
if (profileId === activeId.value) {
const s = connectionManager.state
// 同 stateDotClass:per-profile 状态不随连接尝试漂移
const s = connectionManager.getProfileState(profileId).state
if (s === 'connected') return '已连接'
if (s === 'connecting') return '连接中...'
if (s === 'error') return '连接失败'
@@ -494,6 +661,18 @@ function handleDelete(p: { id: string; name: string }) {
// 删除的是活跃服务器时连接已回落本地,补发切换让视图加载本地根目录
if (wasActive) emit('serverSelect', 'local-default')
}
// 服务器项右键菜单(与行内「...」菜单互斥:一方展开强制收起另一方)
const srvCtxMenuIndex = ref(-1)
function onSrvCtxVisible(index: number, visible: boolean) {
srvCtxMenuIndex.value = visible ? index : -1
if (visible) {
moreOpenId.value = null
moreProfile.value = null
settingsOpen.value = false
}
}
</script>
<style scoped>
@@ -515,13 +694,13 @@ function handleDelete(p: { id: string; name: string }) {
position: relative;
}
/* 服务器区块不收缩 */
.sidebar-section:first-child {
/* 服务器区块不收缩(按显式类名定位,不依赖排序位置) */
.server-section {
flex-shrink: 0;
}
/* 服务器区块不需要 max-height 限制 */
.sidebar-section:first-child > .section-content-wrap {
.server-section > .section-content-wrap {
max-height: none;
}
@@ -557,8 +736,8 @@ function handleDelete(p: { id: string; name: string }) {
flex-direction: column;
}
/* 帮助区块固定在底部 */
.sidebar-section:last-child {
/* 帮助区块不收缩(按显式类名定位,不依赖排序位置) */
.help-section {
flex-shrink: 0;
}
@@ -809,17 +988,8 @@ function handleDelete(p: { id: string; name: string }) {
text-overflow: ellipsis;
}
.sidebar-item-remove {
opacity: 0;
transition: opacity 0.2s;
}
.sidebar-item:hover .sidebar-item-remove {
opacity: 1;
}
.sidebar-item-pinned {
background: var(--color-fill-1);
background: var(--color-primary-light-1);
border-radius: 0;
}
@@ -835,12 +1005,13 @@ function handleDelete(p: { id: string; name: string }) {
border-radius: 6px;
}
.sidebar-item-pin {
/* 「...」操作菜单按钮,悬停收藏项时显示 */
.sidebar-item-more {
opacity: 0;
transition: opacity 0.2s;
}
.sidebar-item:hover .sidebar-item-pin {
.sidebar-item:hover .sidebar-item-more {
opacity: 1;
}
@@ -888,6 +1059,17 @@ function handleDelete(p: { id: string; name: string }) {
}
</style>
<!-- 非 scoped:Arco 下拉菜单挂载在 body 下,删除项标红需全局样式 -->
<style>
.arco-dropdown-option.danger {
color: rgb(var(--danger-6));
}
.arco-dropdown-option.danger:hover {
background: rgb(var(--danger-1));
}
</style>
<!-- 非 scoped:Teleport 到 body 的面板样式 -->
<style>
/* 弹出面板公共 */
@@ -173,6 +173,17 @@ export function useFavorites() {
return !!fav?.pinnedAt
}
/**
* 设置收藏项展示名(空串表示清除,显示时回退原名)
*/
const renameFavorite = (path: string, displayName: string) => {
const normalizedPath = normalizePath(path)
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
if (!fav) return
fav.displayName = displayName
saveFavorites()
}
/**
* 更新收藏项路径(重命名时使用,保留置顶状态和添加时间)
*/
@@ -268,6 +279,7 @@ export function useFavorites() {
isFavorite,
togglePin,
isPinned,
renameFavorite,
updateFavoritePath,
onLongPressStart,
+121 -9
View File
@@ -1,14 +1,16 @@
<template>
<div class="file-system-container">
<!-- 主内容区:左侧边栏 + 右侧工作区 -->
<div class="main-content">
<div ref="mainContentRef" class="main-content">
<!-- 左侧收藏夹侧边栏(全高) -->
<Sidebar
v-if="showSidebar"
:config="sidebarConfig"
:width="sidebarWidth"
@open-favorite="handleOpenFavorite"
@remove-favorite="handleRemoveFavorite"
@toggle-pin="handleToggleFavPin"
@rename-favorite="handleRenameFavorite"
@long-press-start="handleLongPressStart"
@long-press-cancel="handleLongPressCancel"
@drag-start="handleDragStart"
@@ -19,6 +21,9 @@
@server-select="handleConnectionChanged"
/>
<!-- 侧边栏拖宽把手 -->
<div v-if="showSidebar" class="sidebar-resizer" @mousedown="handleSidebarResizeWithOverlay"></div>
<!-- 右侧工作区:面包屑工具栏 + 文件列表/编辑器 -->
<div class="workspace-area">
<!-- 面包屑导航工具栏 -->
@@ -124,6 +129,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { getPathSeparator } from '@/utils/fileUtils'
import { Message, Modal } from '@arco-design/web-vue'
import { On, Off } from '@wailsio/events'
import { ReadClipboardFiles } from '@bindings/u-desk/app'
import { renderMarkdown, parseMarkdown, isMarkedReady, preloadMarked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme'
import { useTransferStore } from '@/stores/transfer'
@@ -155,6 +161,7 @@ import { connectionManager } from '@/api/connection-manager'
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 { errMessage } from '@/utils/errors'
import { createResizeHandler } from '@/utils/resize'
// 导入类型
@@ -270,6 +277,58 @@ const savePanelWidth = (width: { left: number; right: number }) => {
const panelWidth = ref(restorePanelWidth())
const workspaceRef = ref<HTMLElement | null>(null)
// 侧边栏宽度(拖拽调整,localStorage 持久化)
const restoreSidebarWidth = (): number => {
try {
const saved = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.SIDEBAR_WIDTH)
if (saved) {
const width = Number(saved)
if (Number.isFinite(width)) {
return Math.min(DEFAULTS.MAX_SIDEBAR_WIDTH, Math.max(DEFAULTS.MIN_SIDEBAR_WIDTH, width))
}
}
} catch (error) {
console.error('恢复侧边栏宽度失败:', error)
}
return DEFAULTS.SIDEBAR_WIDTH
}
const saveSidebarWidth = (width: number) => {
try {
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.SIDEBAR_WIDTH, String(width))
} catch (error) {
console.error('保存侧边栏宽度失败:', error)
}
}
const sidebarWidth = ref(restoreSidebarWidth())
const mainContentRef = ref<HTMLElement | null>(null)
const handleSidebarHorizontalResize = createResizeHandler(
() => mainContentRef.value,
() => sidebarWidth.value,
{
direction: 'horizontal',
outputMode: 'pixels',
minPercent: 0,
maxPercent: 100,
minPixels: DEFAULTS.MIN_SIDEBAR_WIDTH,
onResize: (px) => {
sidebarWidth.value = Math.min(DEFAULTS.MAX_SIDEBAR_WIDTH, Math.max(DEFAULTS.MIN_SIDEBAR_WIDTH, px))
},
onResizeEnd: () => {
isResizing.value = false
saveSidebarWidth(sidebarWidth.value)
},
}
)
// mousedown 时复用工作区遮罩,防止拖拽经过编辑器 iframe 时丢事件
const handleSidebarResizeWithOverlay = (e: MouseEvent) => {
isResizing.value = true
handleSidebarHorizontalResize(e)
}
// 系统路径(使用 composable)
const { commonPaths, systemPaths, loadCommonPaths } = useCommonPaths()
@@ -304,7 +363,7 @@ const { clipboard, clipboardEmpty, setCopy, setCut, clear: clearClipboard } = us
const transferStore = useTransferStore()
// 收藏夹
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, renameFavorite: renameFav, updateFavoritePath, onLongPressStart, onLongPressCancel, onDragStart, onDragOver, onDrop, onDragEnd } = useFavorites()
// 路径导航
const { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath, getProfileIdForPath } =
@@ -454,8 +513,11 @@ const fileEditorPanelConfig = computed(() => {
currentFileExtension,
isBinaryFile: isBinaryFileRef.value,
fileMtime: selectedFileItem.value?.modified_time || '',
// 多 tab
previewTabs: multiPreview.tabs.value,
// 多 tab(按当前服务器过滤,跨服务器的标签不显示在浮动标签栏)
previewTabs: (() => {
const pid = connectionManager.activeProfile?.id
return multiPreview.tabs.value.filter(t => (t.profileId ?? 'local-default') === pid)
})(),
activeTabId: multiPreview.activeTabId.value,
// 预览插件(优先于内置 v-if 链;.drawio/.dio 命中,其余为 null)
activePlugin: currentFileName ? resolvePreviewHandler(currentFileName) : null,
@@ -510,6 +572,7 @@ const handleFileChanged = async (event: any) => {
// 同路径也强制重载:navigate 同路径早退时列表会永远不加载
const switchConnection = async (profileId: string) => {
await connectionManager.connect(profileId)
await syncPreviewToActiveProfile()
await loadCommonPaths()
const targetPath = connectionManager.isRemote() ? '/' : 'C:/'
if (filePath.value === targetPath) {
@@ -524,7 +587,7 @@ const handleConnectionChanged = async (profileId: string) => {
try {
await switchConnection(profileId)
} catch (err) {
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
Message.error(`连接失败: ${errMessage(err)}`)
}
}
@@ -537,6 +600,7 @@ const handleGoToPath = async (path: string) => {
const targetProfileId = getProfileIdForPath(path)
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
await connectionManager.connect(targetProfileId)
await syncPreviewToActiveProfile()
}
await navigate(path)
}
@@ -647,7 +711,7 @@ const handleOpenFavorite = async (file: FavoriteFile) => {
await loadCommonPaths()
} catch (e) {
console.error('切换连接失败:', e)
Message.error(`无法连接: ${e instanceof Error ? e.message : String(e)}`)
Message.error(`无法连接: ${errMessage(e)}`)
return
}
} else if (!file.profileId) {
@@ -690,6 +754,10 @@ const handleToggleFavPin = (path: string) => {
togglePin(path)
}
const handleRenameFavorite = (path: string, displayName: string) => {
renameFav(path, displayName)
}
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
onLongPressStart(event, index)
}
@@ -1011,7 +1079,7 @@ const handlePasteClipboard = async () => {
} catch (error) {
failed++
console.error('[Clipboard] 粘贴失败:', item.path, error)
Message.error(`粘贴失败: ${item.name} - ${error instanceof Error ? error.message : error}`)
Message.error(`粘贴失败: ${item.name} - ${errMessage(error)}`)
}
}
@@ -1033,7 +1101,7 @@ const handleDownloadToLocal = async (file: FileItem) => {
const localPath = await connectionManager.getTransport().downloadToFile(file.path, dstDir)
Message.success(`已下载到: ${localPath}`)
} catch (error) {
Message.error(`下载失败: ${error instanceof Error ? error.message : error}`)
Message.error(`下载失败: ${errMessage(error)}`)
}
}
@@ -1067,7 +1135,7 @@ const handleExternalDrop = async (paths: string[]) => {
Message.success(`已复制 ${paths.length} 个项目`)
}
} catch (error) {
Message.error(`拖入失败: ${error instanceof Error ? error.message : error}`)
Message.error(`拖入失败: ${errMessage(error)}`)
await loadDirectory(targetDir)
}
}
@@ -1396,6 +1464,26 @@ const doCloseTab = (tabId: string) => {
}
}
/** 切换服务器后预览区联动:只显示当前服务器的标签与内容,避免旧服务器文档残留卡住 */
const syncPreviewToActiveProfile = async () => {
const currentProfileId = connectionManager.activeProfile?.id
const isMine = (t: PreviewTab) => (t.profileId ?? 'local-default') === currentProfileId
const active = multiPreview.activeTab.value
if (active && isMine(active)) return
// 面板上残留的是旧服务器内容,先缓存回它自己的 tab(loaded 标记让切回时秒恢复)
cacheCurrentTabState()
const mine = multiPreview.tabs.value.find(isMine)
if (mine) {
await activateTab(mine)
} else {
// 当前服务器没有已打开的预览:回到空白预览态
selectedFileItem.value = null
clearContent()
}
}
/** 缓存当前 tab 的编辑状态 */
const cacheCurrentTabState = () => {
multiPreview.cacheCurrentTab({
@@ -1961,6 +2049,17 @@ const handlePaste = async (event: ClipboardEvent) => {
const target = event.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
// 系统剪贴板文件(资源管理器复制,文件+文件夹)优先:
// 后端读 CF_HDROP 拿真实路径(DOM paste 拿不到文件夹),复用拖入导入链路
try {
const sysPaths = await ReadClipboardFiles()
if (sysPaths && sysPaths.length > 0) {
event.preventDefault()
await handleExternalDrop(sysPaths)
return
}
} catch { /* 剪贴板被占用等,回落图片粘贴 */ }
const items = event.clipboardData?.items
if (!items) return
@@ -2088,6 +2187,19 @@ watch(() => themeStore.isDark, async () => {
background: var(--color-primary-light-1);
}
/* 侧边栏拖宽把手(与文件列表区分隔条同款) */
.sidebar-resizer {
width: 3px;
background: var(--color-border);
cursor: col-resize;
transition: background 0.2s;
flex-shrink: 0;
}
.sidebar-resizer:hover {
background: var(--color-primary-light-1);
}
.resize-overlay {
position: absolute;
inset: 0;
+46 -41
View File
@@ -3,14 +3,12 @@
</template>
<script setup>
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
import { Modal, Message, Progress } from '@arco-design/web-vue'
import { computed, ref, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
import { Modal, Message, Progress, Button } from '@arco-design/web-vue'
import { useUpdateStore } from '../stores/update'
import { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions'
import { sanitizeHtml } from '@/utils/fileUtils'
import { parseWailsEventData } from '@/utils/wailsEvent'
import { DownloadUpdate } from '../wailsjs/v3-bindings/u-desk/app'
import { On, Off } from '@wailsio/events'
import { GetCurrentVersion } from '../wailsjs/v3-bindings/u-desk/app'
const props = defineProps({
modelValue: {
@@ -33,7 +31,9 @@ let confirmModalInstance = null
let progressModalInstance = null
// Computed
const currentVersion = computed(() => props.updateInfo?.current_version || '0.1.0')
// 更新信息未带 current_version 时从后端兜底获取
const backendVersion = ref('')
const currentVersion = computed(() => props.updateInfo?.current_version || backendVersion.value)
const latestVersion = computed(() => props.updateInfo?.latest_version || '')
const changelog = computed(() => props.updateInfo?.changelog || '')
const forceUpdate = computed(() => props.updateInfo?.force_update || false)
@@ -133,6 +133,15 @@ const showUpdateModal = () => {
)
}
// 跳过此版本(强制更新不可跳过)
if (!forceUpdate.value) {
elements.push(
h('div', { style: { marginTop: '8px', textAlign: 'right' } }, [
h(Button, { size: 'small', type: 'text', onClick: handleSkipVersion }, () => '跳过此版本')
])
)
}
return elements
},
okText: '立即更新',
@@ -187,8 +196,7 @@ const getProgressModalContent = () => {
]
}
// 安装中状态
if (updateStore.installing) {
// 安装中状态:下载完成后进入安装阶段,弹窗保持打开直到进程重启
return [
h('div', { style: { marginBottom: '16px' } }, [
h('div', { style: { marginBottom: '8px', fontSize: '14px', color: 'var(--color-text-2)' } }, '正在安装更新...')
@@ -197,15 +205,6 @@ const getProgressModalContent = () => {
]
}
// 完成状态
return [
h('div', { style: { marginBottom: '16px', textAlign: 'center' } }, [
h('div', { style: { fontSize: '16px', color: 'rgb(var(--success-6))', marginBottom: '8px' } }, '✓ 更新完成')
]),
h('div', { style: { fontSize: '14px', color: 'var(--color-text-2)' } }, '应用将在几秒后自动重启...')
]
}
// 更新进度弹窗内容
const updateProgressModal = async () => {
if (!progressModalInstance) return
@@ -274,16 +273,23 @@ const closeModals = () => {
closeProgressModal()
}
// 下载更新
// 跳过此版本
const handleSkipVersion = async () => {
try {
await updateStore.skipVersion(props.updateInfo.latest_version)
} catch (error) {
console.error('跳过版本失败:', error)
}
closeModals()
emit('update:modelValue', false)
}
// 下载更新(进度与完成事件由 store 统一处理)
const handleDownload = async () => {
await showProgressModal()
try {
const result = await DownloadUpdate(props.updateInfo.download_url)
if (result.success) return
closeProgressModal()
Message.error(result.message || '下载启动失败')
await updateStore.downloadUpdate()
} catch (error) {
console.error('下载失败:', error)
closeProgressModal()
@@ -291,35 +297,34 @@ const handleDownload = async () => {
}
}
// 下载完成处理(本地覆盖:关闭弹窗)
const onDownloadComplete = async (event) => {
const data = parseWailsEventData(event)
if (data.error) {
// 下载/安装状态收尾:下载失败收起进度弹窗;安装流程结束(重启前或失败)关闭弹窗
watch(
() => [updateStore.installing, updateStore.downloadStatus],
([installing, status], [prevInstalling]) => {
if (status === 'exception') {
closeProgressModal()
Message.error('下载失败:' + data.error)
return
}
if (!data.success || !data.file_path) {
closeProgressModal()
Message.error('下载完成但数据不完整')
return
}
// 等待安装完成
await new Promise(resolve => setTimeout(resolve, 3000))
if (prevInstalling && !installing) {
closeProgressModal()
emit('update:modelValue', false)
}
}
)
// 生命周期
onMounted(() => {
On('download-complete', onDownloadComplete)
onMounted(async () => {
try {
const result = await GetCurrentVersion()
if (result.success) {
backendVersion.value = result.data?.version || ''
}
} catch {
// 后端未就绪时忽略,留空兜底
}
})
onUnmounted(() => {
Off('download-complete')
closeModals()
})
</script>
+98 -8
View File
@@ -45,6 +45,30 @@
</a-button>
</a-space>
<!-- 更新设置 -->
<div class="config-section">
<div class="config-row">
<span class="config-label">自动检查更新</span>
<a-switch v-model="autoCheckEnabled" />
</div>
<div class="config-row">
<span class="config-label">检查间隔(分钟)</span>
<a-input-number v-model="checkIntervalMinutes" :min="5" :max="1440" style="width: 120px" />
</div>
<div class="config-row">
<span class="config-label">检查地址</span>
<a-input
v-model="checkURL"
placeholder="留空使用默认 https://c.1216.top/last-version.json"
style="width: 320px"
allow-clear
/>
</div>
<a-button type="primary" size="small" :loading="savingConfig" @click="handleSaveConfig">
保存设置
</a-button>
</div>
<!-- 更新信息 -->
<a-alert
v-if="updateInfo"
@@ -67,7 +91,7 @@
<template #icon>
<icon-download />
</template>
{{ downloadProgress > 0 ? '下载中...' : '下载更新' }}
{{ downloadButtonText }}
</a-button>
<a-button
v-if="downloadedFile"
@@ -89,7 +113,7 @@
<div v-if="downloadProgress > 0 || downloading" class="download-progress">
<a-progress
:percent="downloadProgress"
:status="downloadStatus"
:status="progressBarStatus"
/>
<div class="progress-info">
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span>
@@ -113,7 +137,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { storeToRefs } from 'pinia'
import { IconHistory } from '@arco-design/web-vue/es/icon'
@@ -121,8 +145,8 @@ import { useUpdateStore } from '../stores/update'
import { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions'
import { sanitizeHtml } from '@/utils/fileUtils'
import { parseWailsEventData } from '@/utils/wailsEvent'
import { GetCurrentVersion, GetUpdateConfig, InstallUpdate } from '../wailsjs/v3-bindings/u-desk/app'
import { On, Off } from '@wailsio/events'
import { GetCurrentVersion, GetUpdateConfig, SetUpdateConfig, InstallUpdate } from '../wailsjs/v3-bindings/u-desk/app'
import { Events } from '@wailsio/runtime'
// Emits
defineEmits(['open-version-history'])
@@ -139,6 +163,26 @@ const lastCheckTime = ref('-')
const installResult = ref(null)
const downloadedFile = ref(null)
// 更新设置
const autoCheckEnabled = ref(false)
const checkIntervalMinutes = ref(60)
const checkURL = ref('')
const savingConfig = ref(false)
// 下载按钮文案:下载中/安装中/可下载三态
const downloadButtonText = computed(() => {
if (downloading.value) return `下载中...${Math.round(downloadProgress.value)}%`
if (installing.value) return '安装中...'
return '下载更新'
})
// a-progress 状态映射:store 的 active/exception/success → arco 的 normal/danger/success
const progressBarStatus = computed(() => {
if (downloadStatus.value === 'exception') return 'danger'
if (downloadStatus.value === 'success') return 'success'
return 'normal'
})
/** 渲染 changelog(Markdown → HTML) */
function renderChangelog(text: string): string {
if (!text) return ''
@@ -163,13 +207,39 @@ const loadConfig = async () => {
const result = await GetUpdateConfig()
if (!result.success) return
const { last_check_time = '-' } = result.data || {}
const {
last_check_time = '-',
auto_check_enabled = false,
check_interval_minutes = 60,
check_url = ''
} = result.data || {}
lastCheckTime.value = last_check_time
autoCheckEnabled.value = auto_check_enabled
checkIntervalMinutes.value = check_interval_minutes
checkURL.value = check_url
} catch (error) {
console.error('加载配置失败:', error)
}
}
// 保存设置
const handleSaveConfig = async () => {
savingConfig.value = true
try {
const result = await SetUpdateConfig(autoCheckEnabled.value, checkIntervalMinutes.value, checkURL.value)
if (result.success) {
Message.success('设置已保存')
} else {
Message.error(result.message || '保存设置失败')
}
} catch (error) {
console.error('保存设置失败:', error)
Message.error('保存设置失败:' + (error as Error).message)
} finally {
savingConfig.value = false
}
}
// 检查更新
const handleCheckUpdate = async () => {
installResult.value = null
@@ -241,11 +311,11 @@ onMounted(async () => {
await loadConfig()
// 监听下载完成事件(仅用于记录文件路径)
On('download-complete', onDownloadComplete)
Events.On('download-complete', onDownloadComplete)
})
onUnmounted(() => {
Off('download-complete')
Events.Off('download-complete')
})
</script>
@@ -353,4 +423,24 @@ onUnmounted(() => {
font-size: 12px;
color: var(--color-text-3);
}
.config-section {
margin-top: 16px;
padding: 12px 16px;
background: var(--color-fill-1);
border-radius: 4px;
}
.config-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.config-label {
width: 110px;
flex-shrink: 0;
font-size: 13px;
color: var(--color-text-2);
}
</style>
+44 -14
View File
@@ -17,14 +17,20 @@
<!-- iframe 嵌入远程页面 -->
<div class="iframe-wrapper">
<iframe
ref="iframeRef"
:src="iframeSrc"
class="version-iframe"
frameborder="0"
@load="onIframeLoad"
/>
<div v-if="loading" class="iframe-loading">
<a-spin size="32" />
<div v-if="loadFailed" class="iframe-loading">
<p>加载失败,请检查网络</p>
<a-button type="primary" size="small" @click="handleRefresh">
<template #icon><icon-refresh /></template>
重试
</a-button>
</div>
<div v-else-if="loading" class="iframe-loading">
<a-spin :size="32" />
<p>加载中...</p>
</div>
</div>
@@ -32,33 +38,50 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { IconRefresh, IconExport } from '@arco-design/web-vue/es/icon'
import { GetCurrentVersion } from '../wailsjs/v3-bindings/u-desk/app'
// ==================== 常量 ====================
const BASE_URL = 'https://c.1216.top/u-desk/version.html'
const LOAD_TIMEOUT = 10000
// ==================== 状态 ====================
const currentVersion = ref('')
const loading = ref(true)
const refreshing = ref(false)
const iframeRef = ref<HTMLIFrameElement | null>(null)
const loadFailed = ref(false)
const cacheBuster = ref(Date.now())
let loadTimer: number | undefined
// ==================== 计算属性 ====================
const iframeSrc = computed(() => {
if (currentVersion.value) {
return `${BASE_URL}?v=${currentVersion.value}&vis=u-desk`
}
return `${BASE_URL}?vis=u-desk`
const version = currentVersion.value ? `v=${currentVersion.value}&` : ''
return `${BASE_URL}?${version}vis=u-desk&_t=${cacheBuster.value}`
})
// ==================== 操作 ====================
function clearLoadTimer(): void {
if (loadTimer !== undefined) {
window.clearTimeout(loadTimer)
loadTimer = undefined
}
}
function startLoadTimer(): void {
clearLoadTimer()
loadFailed.value = false
loadTimer = window.setTimeout(() => {
if (loading.value) loadFailed.value = true
}, LOAD_TIMEOUT)
}
function handleRefresh(): void {
refreshing.value = true
loading.value = true
if (iframeRef.value) {
iframeRef.value.src = iframeSrc.value
}
// 更新 cache-buster 触发 iframe 重载(赋相同值不会重新加载)
cacheBuster.value = Date.now()
startLoadTimer()
}
function handleOpenExternal(): void {
@@ -66,21 +89,28 @@ function handleOpenExternal(): void {
}
function onIframeLoad(): void {
clearLoadTimer()
loading.value = false
refreshing.value = false
}
// ==================== 生命周期 ====================
onMounted(async () => {
startLoadTimer()
try {
const result = await window.go?.main?.App?.GetCurrentVersion?.()
const result = await GetCurrentVersion()
if (result?.success) {
currentVersion.value = result.data?.version || ''
startLoadTimer()
}
} catch {
// Wails 未就绪时忽略
// 后端未就绪时忽略,不带版本参数加载
}
})
onUnmounted(() => {
clearLoadTimer()
})
</script>
<style scoped>
+1 -1
View File
@@ -97,7 +97,7 @@ export const useConfigStore = defineStore('config', () => {
appConfig.value = {
tabs: mergedTabs.map(tab => ({ ...tab, visible: mergedVisible.includes(tab.key) })),
visibleTabs: mergedVisible,
defaultTab: defaultTab || 'file-system',
defaultTab: allKeys.includes(defaultTab) ? defaultTab : 'file-system',
sidebarSections: Array.isArray(sidebarSections)
? sidebarSections.filter((s: string) => validSections.includes(s))
: defaultSidebar
+73 -16
View File
@@ -4,10 +4,15 @@ import { Message } from '@arco-design/web-vue'
import { formatBytes as formatFileSize } from '@/utils/fileUtils'
import { parseWailsEventData } from '@/utils/wailsEvent'
import {
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate, InstallUpdateWithHash
} from '../wailsjs/v3-bindings/u-desk/app'
import { On, Off } from '@wailsio/events'
// 本次应用运行内已自动弹过的版本(升级后自动恢复提醒)
let sessionNotifiedVersion = ''
// 下载完成事件携带的远端声明哈希,安装时用于完整性校验
let lastExpectedSha256 = ''
/**
* 更新管理 Store
* 统一管理版本检查、下载、安装等更新相关逻辑
@@ -41,34 +46,65 @@ export const useUpdateStore = defineStore('update', () => {
}
// ==================== 核心方法 ====================
// 用户显式跳过的版本(localStorage):该版本不再自动弹窗,手动检查不受限
const SKIPPED_VERSION_KEY = 'udesk-update-skipped-version'
/**
* 展示更新弹窗。自动路径(静默检查/后端广播)按版本去重(用户已跳过或本次运行已弹过则不弹),手动检查强制弹
*/
const tryShowUpdate = (data: UpdateInfo, force = false) => {
const version = String(data?.latest_version || '')
if (!force && version) {
if (localStorage.getItem(SKIPPED_VERSION_KEY) === version) return
if (sessionNotifiedVersion === version) return
}
updateInfo.value = data
showUpdate.value = true
if (version) {
sessionNotifiedVersion = version
}
if (!force) {
// 自动发现时的系统通知
try {
window.runtime?.SendNotification?.({
title: 'U-Desk',
body: `发现新版本 ${version},点击查看`
})
} catch { /* 通知不可用时忽略 */ }
}
}
/**
* 稍后提醒:记录用户显式跳过的版本,该版本不再自动弹窗(手动检查仍强制弹)
*/
const skipVersion = (version: string) => {
if (version) {
localStorage.setItem(SKIPPED_VERSION_KEY, version)
}
showUpdate.value = false
}
/**
* 检查更新
* @param silent 是否静默模式(不显示消息)
* @param manual 是否手动触发(跳过 auto_check_enabled 门禁)
* @returns 检查是否实际完成(false=后端未就绪或网络失败,调用方可重试)
*/
const checkForUpdates = async (silent = false) => {
if (checking.value) return
const checkForUpdates = async (silent = false, manual = false): Promise<boolean> => {
if (checking.value) return true
checking.value = true
try {
const configResult = await GetUpdateConfig()
if (!configResult.success) return
if (!configResult.success) return false
const { auto_check_enabled } = configResult.data || {}
if (!auto_check_enabled) return
if (!manual && !auto_check_enabled) return true
const result = await CheckUpdate()
if (result.success && result.data?.has_update) {
updateInfo.value = result.data
showUpdate.value = true
// 系统通知:发现新版本
try {
window.runtime?.SendNotification?.({
title: 'U-Desk',
body: `发现新版本 ${result.data.latest_version},点击查看`
})
} catch { /* 通知不可用时忽略 */ }
tryShowUpdate(result.data, !silent)
if (!silent) {
Message.success('发现新版本!')
@@ -76,11 +112,13 @@ export const useUpdateStore = defineStore('update', () => {
} else if (!silent) {
Message.success('已是最新版本')
}
return result.success === true
} catch (error) {
if (!silent) {
console.error('检查更新失败:', error)
Message.error('检查更新失败:' + (error as Error).message)
}
return false
} finally {
checking.value = false
}
@@ -134,7 +172,10 @@ export const useUpdateStore = defineStore('update', () => {
installing.value = true
try {
const result = await InstallUpdate(filePath, true)
// 优先带哈希校验安装(期望哈希来自下载完成事件的远端声明)
const result = lastExpectedSha256
? await InstallUpdateWithHash(filePath, true, lastExpectedSha256, 'sha256')
: await InstallUpdate(filePath, true)
if (result.success) {
Message.success({
content: '安装成功!应用将在几秒后重启...',
@@ -200,6 +241,8 @@ export const useUpdateStore = defineStore('update', () => {
// 完成下载
downloadProgress.value = 100
downloadStatus.value = 'success'
// 记录远端声明的期望哈希,安装时用于校验
lastExpectedSha256 = String(data.expected_sha256 || '')
const fileSize = (data.file_size as number) || 0
@@ -227,12 +270,24 @@ export const useUpdateStore = defineStore('update', () => {
}, remainingTime)
}
/**
* 后端广播"发现新版本"处理(启动即查与周期检查命中时后端都会发)
*/
const onUpdateAvailable = (event: unknown) => {
const data = parseWailsEventData(event) as UpdateInfo
if (!data?.latest_version) {
return
}
tryShowUpdate(data)
}
/**
* 设置事件监听
*/
const setupEventListeners = () => {
On('download-progress', onDownloadProgress)
On('download-complete', onDownloadComplete)
On('update-available', onUpdateAvailable)
}
/**
@@ -241,6 +296,7 @@ export const useUpdateStore = defineStore('update', () => {
const removeEventListeners = () => {
Off('download-progress')
Off('download-complete')
Off('update-available')
}
// ==================== 返回 ====================
@@ -259,6 +315,7 @@ export const useUpdateStore = defineStore('update', () => {
checkForUpdates,
downloadUpdate,
installUpdate,
skipVersion,
setupEventListeners,
removeEventListeners,
formatFileSize,
+2
View File
@@ -33,6 +33,8 @@ export interface FavoriteFile extends FileItem {
pinnedAt?: number
/** 关联的连接配置 ID,用于打开收藏时自动切换连接 */
profileId?: string
/** 展示名,空串或缺省表示未设置(显示时回退原名) */
displayName?: string
}
/**
+2 -2
View File
@@ -108,8 +108,8 @@ export async function loadLanguageExtension(language) {
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'
// !var! 延迟变量、%var% 环境变量、%~d0 类批处理参数、%1 位置参数、%%i for 循环变量
if (stream.match(/^!\w+!/) || stream.match(/^%\w+%/) || stream.match(/^%~[a-z]*[\d*]/i) || stream.match(/^%\d/) || stream.match(/^%%\w+/)) return 'atom'
if (stream.match(/^\w+/)) {
return batchKeywords.has(stream.current().toLowerCase()) ? 'keyword' : null
}
+6 -1
View File
@@ -23,6 +23,7 @@ export const STORAGE_KEYS = {
PATH_HISTORY: 'app-filesystem-path-history',
FILE_CONTENT_HEIGHT: 'app-filesystem-file-content-height',
PANEL_WIDTH: 'app-filesystem-panel-width',
SIDEBAR_WIDTH: 'app-filesystem-sidebar-width',
SIDEBAR_VISIBLE: 'app-filesystem-sidebar-visible',
FAVORITE_FILES: 'app-filesystem-favorite-files',
EDIT_MODE: 'app-filesystem-edit-mode', // HTML/Markdown 编辑模式状态
@@ -334,8 +335,12 @@ export const DEFAULTS = {
MAX_PANEL_WIDTH: 80,
DEFAULT_PANEL_WIDTH: 50,
// 侧边栏宽度(px)
// 侧边栏默认宽度(px)
SIDEBAR_WIDTH: 220,
// 侧边栏宽度范围(px)
MIN_SIDEBAR_WIDTH: 180,
MAX_SIDEBAR_WIDTH: 420,
}
/**
+7
View File
@@ -0,0 +1,7 @@
/**
* 任意抛出值转错误文案(Error 取 message,其余 String 化)
*/
export function errMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
+53 -32
View File
@@ -15,6 +15,9 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
import * as application$0 from "../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as api$0 from "./internal/api/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as filesystem$0 from "./internal/filesystem/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -123,6 +126,25 @@ export function EmptyRecycleBin(): $CancellablePromise<void> {
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);
});
}
/**
* ExportHtmlFileToPDF 将HTML文件导出为PDF
*/
export function ExportHtmlFileToPDF(path: string, saveName: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(3012957793, path, saveName).then(($result: any) => {
return $$createType2($result);
});
}
/**
* ExportPDF 导出PDF文件
*/
@@ -182,15 +204,6 @@ 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 获取当前版本号
*/
@@ -335,7 +348,7 @@ export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?:
}
/**
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除),跨盘时经 transfer-progress 事件推送进度
*/
export function MovePath(src: string, dst: string): $CancellablePromise<void> {
return $Call.ByID(2028899675, src, dst);
@@ -348,6 +361,9 @@ export function OpenPath(path: string): $CancellablePromise<void> {
return $Call.ByID(1591734570, path);
}
/**
* OssConnect 连接 OSS 存储
*/
export function OssConnect(req: $models.OssConnectRequest): $CancellablePromise<string> {
return $Call.ByID(3667022538, req);
}
@@ -478,6 +494,16 @@ export function PickDirectory(): $CancellablePromise<string> {
return $Call.ByID(1347829059);
}
/**
* ReadClipboardFiles 读取系统剪贴板中的文件/文件夹路径列表。
* 剪贴板为文本/截图等非文件内容时返回空列表,前端据此回落图片粘贴等原有链路。
*/
export function ReadClipboardFiles(): $CancellablePromise<string[]> {
return $Call.ByID(2636780132).then(($result: any) => {
return $$createType7($result);
});
}
/**
* ReadFile 读取文件
*/
@@ -526,8 +552,9 @@ export function RestoreFromRecycleBin(recyclePath: string): $CancellablePromise<
/**
* SaveAppConfig 保存应用配置
* 直接复用 api.SaveAppConfigRequest,避免 main 包副本转发时丢字段
*/
export function SaveAppConfig(req: $models.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
export function SaveAppConfig(req: api$0.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1942219977, req).then(($result: any) => {
return $$createType2($result);
});
@@ -546,13 +573,6 @@ export function SaveConnectionProfile(req: $models.SaveProfileRequest): $Cancell
});
}
/**
* SelectPDFSaveDirectory 选择PDF保存目录
*/
export function SelectPDFSaveDirectory(): $CancellablePromise<string> {
return $Call.ByID(1403263131);
}
/**
* SetMainWindow 设置主窗口引用(由 main.go 在创建窗口后调用)
*/
@@ -624,6 +644,13 @@ export function SftpDownloadSiteForPreview(connID: string, remotePath: string):
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 下载远程文件到本地临时目录(用于预览)
*/
@@ -638,20 +665,6 @@ export function SftpDownloadToTempCached(connID: string, remotePath: string, fil
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 远程主机常用路径
*/
@@ -704,6 +717,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 编码的二进制文件(粘贴图片等)
*/
@@ -791,3 +811,4 @@ const $$createType3 = filesystem$0.FileOperationResult.createFrom;
const $$createType4 = $Create.Nullable($$createType3);
const $$createType5 = $Create.Array($$createType2);
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
const $$createType7 = $Create.Array($Create.Any);
@@ -11,7 +11,6 @@ export {
OssConnectRequest,
OssRenamePathRequest,
RenamePathRequest,
SaveAppConfigRequest,
SaveBase64FileRequest,
SaveProfileRequest,
SftpConnectRequest,
@@ -2,5 +2,6 @@
// This file is automatically generated. DO NOT EDIT
export {
AppTabDefinition
AppTabDefinition,
SaveAppConfigRequest
} from "./models.js";
@@ -40,3 +40,56 @@ export class AppTabDefinition {
return new AppTabDefinition($$parsedSource as Partial<AppTabDefinition>);
}
}
/**
* SaveAppConfigRequest 保存应用配置请求(前端格式)
*/
export class SaveAppConfigRequest {
"tabs": AppTabDefinition[];
"visibleTabs": string[];
"defaultTab": string;
"sidebarSections": string[];
/** Creates a new SaveAppConfigRequest instance. */
constructor($$source: Partial<SaveAppConfigRequest> = {}) {
if (!("tabs" in $$source)) {
this["tabs"] = [];
}
if (!("visibleTabs" in $$source)) {
this["visibleTabs"] = [];
}
if (!("defaultTab" in $$source)) {
this["defaultTab"] = "";
}
if (!("sidebarSections" in $$source)) {
this["sidebarSections"] = [];
}
Object.assign(this, $$source);
}
/**
* Creates a new SaveAppConfigRequest instance from a string or object.
*/
static createFrom($$source: any = {}): SaveAppConfigRequest {
const $$createField0_0 = $$createType1;
const $$createField1_0 = $$createType2;
const $$createField3_0 = $$createType2;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("tabs" in $$parsedSource) {
$$parsedSource["tabs"] = $$createField0_0($$parsedSource["tabs"]);
}
if ("visibleTabs" in $$parsedSource) {
$$parsedSource["visibleTabs"] = $$createField1_0($$parsedSource["visibleTabs"]);
}
if ("sidebarSections" in $$parsedSource) {
$$parsedSource["sidebarSections"] = $$createField3_0($$parsedSource["sidebarSections"]);
}
return new SaveAppConfigRequest($$parsedSource as Partial<SaveAppConfigRequest>);
}
}
// Private type creation functions
const $$createType0 = AppTabDefinition.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = $Create.Array($Create.Any);
@@ -5,10 +5,6 @@
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as api$0 from "./internal/api/models.js";
/**
* BgmPlaylistItem 播放列表条目
*/
@@ -134,46 +130,6 @@ export class RenamePathRequest {
}
}
/**
* SaveAppConfigRequest 保存应用配置请求
*/
export class SaveAppConfigRequest {
"tabs": api$0.AppTabDefinition[];
"visibleTabs": string[];
"defaultTab": string;
/** Creates a new SaveAppConfigRequest instance. */
constructor($$source: Partial<SaveAppConfigRequest> = {}) {
if (!("tabs" in $$source)) {
this["tabs"] = [];
}
if (!("visibleTabs" in $$source)) {
this["visibleTabs"] = [];
}
if (!("defaultTab" in $$source)) {
this["defaultTab"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new SaveAppConfigRequest instance from a string or object.
*/
static createFrom($$source: any = {}): SaveAppConfigRequest {
const $$createField0_0 = $$createType1;
const $$createField1_0 = $$createType2;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("tabs" in $$parsedSource) {
$$parsedSource["tabs"] = $$createField0_0($$parsedSource["tabs"]);
}
if ("visibleTabs" in $$parsedSource) {
$$parsedSource["visibleTabs"] = $$createField1_0($$parsedSource["visibleTabs"]);
}
return new SaveAppConfigRequest($$parsedSource as Partial<SaveAppConfigRequest>);
}
}
/**
* SaveBase64FileRequest 保存 Base64 编码的二进制文件
*/
@@ -422,8 +378,3 @@ export class WriteFileRequest {
return new WriteFileRequest($$parsedSource as Partial<WriteFileRequest>);
}
}
// Private type creation functions
const $$createType0 = api$0.AppTabDefinition.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = $Create.Array($Create.Any);
+5
View File
@@ -27,6 +27,7 @@ type GetAppConfigResponse struct {
Tabs []AppTabDefinition `json:"tabs"`
VisibleTabs []string `json:"visibleTabs"`
DefaultTab string `json:"defaultTab"`
SidebarSections []string `json:"sidebarSections"`
}
// AppTabDefinition 应用 Tab 定义(前端格式)
@@ -42,6 +43,7 @@ type SaveAppConfigRequest struct {
Tabs []AppTabDefinition `json:"tabs"`
VisibleTabs []string `json:"visibleTabs"`
DefaultTab string `json:"defaultTab"`
SidebarSections []string `json:"sidebarSections"`
}
// GetAppConfig 获取应用配置
@@ -76,6 +78,7 @@ func (api *ConfigAPI) GetAppConfig() (map[string]interface{}, error) {
Tabs: tabs,
VisibleTabs: tabConfig.VisibleTabs,
DefaultTab: tabConfig.DefaultTab,
SidebarSections: tabConfig.SidebarSections,
},
}, nil
}
@@ -119,6 +122,7 @@ func (api *ConfigAPI) SaveAppConfig(req SaveAppConfigRequest) (map[string]interf
AvailableTabs: availableTabs,
VisibleTabs: req.VisibleTabs,
DefaultTab: req.DefaultTab,
SidebarSections: req.SidebarSections,
}
// 保存配置
@@ -204,5 +208,6 @@ func (api *ConfigAPI) MigrateTabConfig() error {
AvailableTabs: newTabs,
VisibleTabs: newVisible,
DefaultTab: defaultTab,
SidebarSections: config.SidebarSections,
})
}
+230 -30
View File
@@ -4,12 +4,15 @@ import (
"context"
"fmt"
"html"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/yuin/goldmark"
"u-desk/internal/common"
@@ -70,7 +73,7 @@ func (api *PdfAPI) ExportMarkdownToPDF(req PdfExportRequest) (*PdfExportResponse
htmlContent := api.markdownToHTML(req.Content, req.Title, req.FontSize)
// 使用chromedp生成PDF
pdfBuffer, err := api.generatePDFFromHTML(htmlContent, req.Title, req.PageWidth, req.PageHeight)
pdfBuffer, err := api.generatePDFFromHTML(htmlContent, req.PageWidth, req.PageHeight)
if err != nil {
return nil, fmt.Errorf("生成PDF失败: %v", err)
}
@@ -261,7 +264,7 @@ func (api *PdfAPI) markdownToHTML(markdownContent string, title string, fontSize
}
// generatePDFFromHTML 使用chromedp从HTML生成PDF
func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pageHeight int) ([]byte, error) {
func (api *PdfAPI) generatePDFFromHTML(htmlContent string, pageWidth, pageHeight int) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 配置chromedp选项
@@ -275,22 +278,12 @@ func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pag
chromedp.Flag("disable-notifications", true),
}
// 在Windows上设置Chrome路径
if common.IsWindows() {
// 常见的Windows Chrome路径
chromePaths := []string{
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Users\\" + os.Getenv("USERNAME") + "\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe",
}
for _, path := range chromePaths {
if _, err := os.Stat(path); err == nil {
opts = append(opts, chromedp.ExecPath(path))
break
}
}
// 浏览器路径统一走 findChromeExecutable,Chrome 与 Edge 任一可用即可
browserPath, err := findChromeExecutable()
if err != nil {
return nil, err
}
opts = append(opts, chromedp.ExecPath(browserPath))
// 创建执行分配器上下文
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
@@ -315,10 +308,11 @@ func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pag
var buf []byte
// 使用 file URL 加载本地HTML文件
// file URL 需整体转义,与 generatePDFFromLocalFile 同姿势,中文/空格临时路径才能正确导航
fileURL := (&url.URL{Scheme: "file", Path: filepath.ToSlash(htmlFile)}).String()
err = chromedp.Run(chromeCtx,
// 导航到HTML文件
chromedp.Navigate("file://"+htmlFile),
chromedp.Navigate(fileURL),
// 等待页面加载完成
chromedp.WaitReady("body"),
// 打印到PDF
@@ -345,6 +339,222 @@ func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pag
return nil, fmt.Errorf("chromedp执行失败: %v", err)
}
if len(buf) == 0 {
return nil, fmt.Errorf("PDF生成结果为空")
}
return buf, nil
}
// findChromeExecutable 探测本机可用的浏览器可执行文件,Chrome 优先,全部未命中再探测 Edge
func findChromeExecutable() (string, error) {
if common.IsWindows() {
candidates := []string{
`C:\Program Files\Google\Chrome\Application\chrome.exe`,
`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local", "Google", "Chrome", "Application", "chrome.exe"),
`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
`C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
// 按用户安装的 Edge 不落在 Program Files,LOCALAPPDATA 下才有
filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "Edge", "Application", "msedge.exe"),
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
for _, name := range []string{"chrome.exe", "msedge.exe"} {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
}
} else {
for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser"} {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
}
}
return "", fmt.Errorf("未找到 Chrome 或 Edge 浏览器,导出 PDF 需要安装其一")
}
// appendExportLog 追加一行PDF导出诊断日志到 %USERPROFILE%\.u-desk\logs\pdf-export.log
// 临时诊断插桩:用户反馈导出转圈即停且错误无处可查,用于收口记录导出链路每个返回点,定位后整体移除
// 写日志自身失败静默,不向导出主链路传播错误
func appendExportLog(msg string) {
home := os.Getenv("USERPROFILE")
if home == "" {
return
}
f, err := os.OpenFile(filepath.Join(home, ".u-desk", "logs", "pdf-export.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "%s %s\n", time.Now().Format("2006-01-02 15:04:05"), msg)
}
// ExportHtmlFileToPDF 将本地HTML文件加载渲染后导出为PDF
func (api *PdfAPI) ExportHtmlFileToPDF(srcPath string, saveName string) (resp *PdfExportResponse, err error) {
// 临时诊断插桩:defer 收口记录每个 error 返回点与最终成功结果
defer func() {
if err != nil {
appendExportLog(fmt.Sprintf("ExportHtmlFileToPDF error=%s", err.Error()))
return
}
if resp != nil {
appendExportLog(fmt.Sprintf("ExportHtmlFileToPDF ok path=%s size=%d", resp.Path, resp.Size))
}
}()
appendExportLog(fmt.Sprintf("ExportHtmlFileToPDF entry srcPath=%q saveName=%q", srcPath, saveName))
// 只接受实际存在的 html 文件,避免把任意路径交给 Chrome 渲染
info, err := os.Stat(srcPath)
if err != nil {
return nil, fmt.Errorf("HTML文件不存在: %s", srcPath)
}
appendExportLog(fmt.Sprintf("ExportHtmlFileToPDF stat ok size=%d isDir=%v", info.Size(), info.IsDir()))
if info.IsDir() {
return nil, fmt.Errorf("目标路径是目录,不是HTML文件: %s", srcPath)
}
ext := strings.ToLower(filepath.Ext(srcPath))
if ext != ".html" && ext != ".htm" {
return nil, fmt.Errorf("仅支持 .html/.htm 文件,当前扩展名: %s", ext)
}
// 保存名兜底:空名用源文件名,缺扩展补 .pdf,取 Base 防止路径片段拼进桌面目录
if strings.TrimSpace(saveName) == "" {
saveName = filepath.Base(srcPath)
}
if !strings.HasSuffix(strings.ToLower(saveName), ".pdf") {
saveName += ".pdf"
}
saveName = filepath.Base(saveName)
pdfBuf, err := api.generatePDFFromLocalFile(srcPath)
if err != nil {
return nil, err
}
saveDir := api.getDesktopDirectory()
if err := os.MkdirAll(saveDir, 0755); err != nil {
return nil, fmt.Errorf("创建目录失败: %v", err)
}
savePath := filepath.Join(saveDir, saveName)
if err := os.WriteFile(savePath, pdfBuf, 0644); err != nil {
return nil, fmt.Errorf("保存PDF文件失败: %v", err)
}
fileInfo, err := os.Stat(savePath)
if err != nil {
return nil, fmt.Errorf("获取文件信息失败: %v", err)
}
// 成功弹窗几秒即逝用户难感知产物位置,改用资源管理器选中 PDF 明示落盘位置;失败静默不影响导出结果
// 用户 PATH 无 C:\Windows 根目录,explorer 走 LookPath 解析不到,必须用 WINDIR 拼绝对路径
if common.IsWindows() {
exePath := filepath.Join(os.Getenv("WINDIR"), "explorer.exe")
if selectErr := exec.Command(exePath, "/select,", savePath).Start(); selectErr != nil {
appendExportLog(fmt.Sprintf("ExportHtmlFileToPDF explorer select error=%s", selectErr.Error()))
}
}
return &PdfExportResponse{
Success: true,
Message: "PDF生成成功",
Path: savePath,
Size: fileInfo.Size(),
}, nil
}
// generatePDFFromLocalFile 用 headless Chrome 加载本地HTML文件并打印为PDF
func (api *PdfAPI) generatePDFFromLocalFile(srcPath string) (buf []byte, err error) {
start := time.Now()
appendExportLog(fmt.Sprintf("generatePDFFromLocalFile start srcPath=%q", srcPath))
// 临时诊断插桩:defer 收口记录结束耗时与结果
defer func() {
if err != nil {
appendExportLog(fmt.Sprintf("generatePDFFromLocalFile end error=%s elapsed=%s", err.Error(), time.Since(start)))
return
}
appendExportLog(fmt.Sprintf("generatePDFFromLocalFile end ok bytes=%d elapsed=%s", len(buf), time.Since(start)))
}()
chromePath, err := findChromeExecutable()
if err != nil {
appendExportLog(fmt.Sprintf("generatePDFFromLocalFile findChromeExecutable error=%s", err.Error()))
return nil, err
}
appendExportLog(fmt.Sprintf("generatePDFFromLocalFile browser hit=%q", chromePath))
absPath, err := filepath.Abs(srcPath)
if err != nil {
return nil, fmt.Errorf("解析文件路径失败: %v", err)
}
// file URL 需整体转义,含中文和空格的路径才能正确导航
fileURL := (&url.URL{Scheme: "file", Path: filepath.ToSlash(absPath)}).String()
// 外部HTML常引用图片等资源,加载时间给得比Markdown通道更宽裕
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
opts := []chromedp.ExecAllocatorOption{
chromedp.ExecPath(chromePath),
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.Flag("disable-software-rasterizer", true),
chromedp.Flag("disable-extensions", true),
chromedp.Flag("disable-notifications", true),
}
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
defer allocCancel()
chromeCtx, chromeCancel := chromedp.NewContext(allocCtx)
defer chromeCancel()
err = chromedp.Run(chromeCtx,
chromedp.Navigate(fileURL),
// 先确保新文档的 body 可查询,后续 JS 才会落在正确文档上
chromedp.WaitReady("body"),
// PrintToPDF 不等图片资源,必须等 load 事件完成后才打印
chromedp.ActionFunc(func(ctx context.Context) error {
_, exp, err := runtime.Evaluate(`new Promise(resolve => {
if (document.readyState === 'complete') resolve(true)
else window.addEventListener('load', () => resolve(true))
})`).WithAwaitPromise(true).WithReturnByValue(true).Do(ctx)
if exp != nil {
return exp
}
return err
}),
chromedp.ActionFunc(func(ctx context.Context) error {
printToPDF := page.PrintToPDF().
WithPrintBackground(true).
WithLandscape(false).
WithMarginTop(0).
WithMarginBottom(0).
WithMarginLeft(0).
WithMarginRight(0).
WithPaperWidth(210.0/25.4). // CDP 纸张单位是英寸,A4 宽 210mm
WithPaperHeight(297.0/25.4)
var printErr error
buf, _, printErr = printToPDF.Do(ctx)
return printErr
}),
)
if err != nil {
return nil, fmt.Errorf("chromedp执行失败: %v", err)
}
if len(buf) == 0 {
return nil, fmt.Errorf("PDF生成结果为空")
}
return buf, nil
}
@@ -367,13 +577,3 @@ func (api *PdfAPI) getDesktopDirectory() string {
// 备用:当前目录
return "."
}
// SelectDirectory 选择保存目录(简化版,实际应该使用Wails runtime)
func (api *PdfAPI) SelectDirectory() (string, error) {
// 简化版:直接返回桌面目录
desktop := api.getDesktopDirectory()
if desktop == "." {
return "", fmt.Errorf("无法确定默认目录")
}
return desktop, nil
}
+28 -1
View File
@@ -2,8 +2,10 @@ package api
import (
"context"
"u-desk/internal/service"
"sync/atomic"
"time"
"u-desk/internal/service"
)
// UpdateAPI 版本更新 API
@@ -11,6 +13,9 @@ type UpdateAPI struct {
updateService *service.UpdateService
ctx context.Context
eventEmitter func(name string, data ...any)
configChanged func() // 更新配置变更回调(重置自动检查定时器)
expectedSHA256 string // 最近一次检查得到的远端包 SHA256
downloading atomic.Bool // 下载进行中标志(并发互斥)
}
// NewUpdateAPI 创建版本更新 API
@@ -25,6 +30,11 @@ func (api *UpdateAPI) SetEventEmitter(emitter func(name string, data ...any)) {
api.eventEmitter = emitter
}
// SetConfigChangedCallback 设置配置变更回调(由 App 注入,用于重置自动检查定时器)
func (api *UpdateAPI) SetConfigChangedCallback(cb func()) {
api.configChanged = cb
}
// SetContext 设置上下文(用于事件推送)
func (api *UpdateAPI) SetContext(ctx context.Context) {
api.ctx = ctx
@@ -54,6 +64,9 @@ func (api *UpdateAPI) CheckUpdate() (map[string]interface{}, error) {
return errorResponse(err.Error()), nil
}
// 记录远端包哈希,供下载完成事件比对
api.expectedSHA256 = result.SHA256
return successResponse(result), nil
}
@@ -99,6 +112,11 @@ func (api *UpdateAPI) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinute
return errorResponse(err.Error()), nil
}
// 配置已变更,通知重置自动检查定时器(免重启生效)
if api.configChanged != nil {
api.configChanged()
}
return successResponse(map[string]interface{}{
"message": "配置保存成功",
}), nil
@@ -110,7 +128,14 @@ func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}
return errorResponse("下载地址不能为空"), nil
}
// 并发互斥:已有下载任务进行中则拒绝
if !api.downloading.CompareAndSwap(false, true) {
return errorResponse("已有下载任务进行中"), nil
}
go func() {
defer api.downloading.Store(false)
progressCallback := func(progress float64, speed float64, downloaded int64, total int64) {
progressInfo := map[string]interface{}{
"progress": progress,
@@ -132,6 +157,8 @@ func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}
"success": true,
"file_path": result.FilePath,
"file_size": result.FileSize,
"sha256": result.SHA256Hash,
"expected_sha256": api.expectedSHA256,
}
api.emit("download-complete", resultInfo)
}
+7
View File
@@ -67,6 +67,13 @@ func EnsureDrawioViewer(progress service.DownloadProgress) error {
if isDrawioViewerReady() {
return nil
}
// 首个下载方已退出仍未就绪(下载失败),等待者立即失败而非空轮询到超时
drawioEnsureMu.Lock()
ensuring := drawioEnsuring
drawioEnsureMu.Unlock()
if !ensuring {
return fmt.Errorf("drawio viewer 下载失败,请重试")
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("等待 drawio viewer 下载超时")
+5 -3
View File
@@ -552,7 +552,8 @@ func (s *FileSystemService) CopyPath(src, dst string, onProgress func(copied, to
}
// MovePath 移动文件或目录:同盘直接重命名,跨盘回退为复制后永久删除源(不走回收站)
func (s *FileSystemService) MovePath(src, dst string) error {
// 跨盘复制阶段经 onProgress 回调进度(可为 nil),同盘 rename 无进度
func (s *FileSystemService) MovePath(src, dst string, onProgress func(copied, total int64)) error {
info, err := s.checkCopyMovePaths(src, dst)
if err != nil {
return err
@@ -564,8 +565,9 @@ func (s *FileSystemService) MovePath(src, dst string) error {
return nil
}
// 跨盘:复制 + 删除源
if err := s.CopyPath(src, dst, nil); err != nil {
// 跨盘:复制 + 删除源;复制中途失败清理 dst 残留,否则重试会因目标已存在被拒
if err := s.CopyPath(src, dst, onProgress); err != nil {
_ = os.RemoveAll(dst)
return err
}
if err := os.RemoveAll(src); err != nil {
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"context"
"encoding/base64"
"fmt"
"log"
"log/slog"
"os"
"path"
"path/filepath"
@@ -851,7 +851,7 @@ func supplementDir(c oss.OSSProvider, ctx context.Context, remoteDir string, tmp
continue
}
if count >= common.MaxSupplementFiles {
log.Printf("[站点下载] 补充扫描达到上限: count=%d", common.MaxSupplementFiles)
slog.Warn("站点下载补充扫描达到上限", "count", common.MaxSupplementFiles)
return
}
localPath := filepath.Join(tmpDir, filepath.FromSlash(f.Key))
+10
View File
@@ -36,8 +36,12 @@ type TabConfig struct {
AvailableTabs []TabDefinition `json:"available_tabs"`
VisibleTabs []string `json:"visible_tabs"`
DefaultTab string `json:"default_tab"`
SidebarSections []string `json:"sidebar_sections"`
}
// 默认侧边栏分区
var defaultSidebarSections = []string{"server", "favorites", "help"}
// 默认 Tab 配置
var defaultTabConfig = TabConfig{
AvailableTabs: []TabDefinition{
@@ -47,6 +51,7 @@ var defaultTabConfig = TabConfig{
},
VisibleTabs: []string{"file-system", "markdown-editor", "version"},
DefaultTab: "file-system",
SidebarSections: defaultSidebarSections,
}
const (
@@ -79,6 +84,11 @@ func (s *ConfigService) GetTabConfig() (*TabConfig, error) {
return &defaultTabConfig, nil
}
// 侧边栏分区为空时回退默认值(兼容旧配置)
if len(tabConfig.SidebarSections) == 0 {
tabConfig.SidebarSections = defaultSidebarSections
}
return &tabConfig, nil
}
+3
View File
@@ -30,6 +30,7 @@ type RemoteVersionInfo struct {
ForceUpdate bool `json:"force_update"`
ReleaseDate string `json:"release_date"`
FileSize int64 `json:"file_size"`
SHA256 string `json:"sha256"`
}
// UpdateCheckResult 更新检查结果
@@ -42,6 +43,7 @@ type UpdateCheckResult struct {
ForceUpdate bool `json:"force_update"`
ReleaseDate string `json:"release_date"`
FileSize int64 `json:"file_size"`
SHA256 string `json:"sha256"`
}
// InstallResult 安装结果
@@ -99,6 +101,7 @@ func (s *UpdateService) CheckUpdate() (*UpdateCheckResult, error) {
ForceUpdate: remoteInfo.ForceUpdate,
ReleaseDate: remoteInfo.ReleaseDate,
FileSize: remoteInfo.FileSize,
SHA256: remoteInfo.SHA256,
}, nil
}
+17 -3
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"u-desk/internal/common"
@@ -41,9 +42,13 @@ func DownloadUpdate(downloadURL string, progressCallback DownloadProgress) (*Dow
return nil, fmt.Errorf("创建下载目录失败: %v", err)
}
// 从 URL 提取文件名
filename := filepath.Base(downloadURL)
if filename == "" || filename == "." {
// 从 URL 提取文件名(去除 query 参数与 # 片段)
cleanURL := downloadURL
if idx := strings.IndexAny(cleanURL, "?#"); idx != -1 {
cleanURL = cleanURL[:idx]
}
filename := filepath.Base(cleanURL)
if filename == "" || filename == "." || filename == "/" {
filename = fmt.Sprintf("update-%d.exe", time.Now().Unix())
}
@@ -127,6 +132,15 @@ func DownloadUpdate(downloadURL string, progressCallback DownloadProgress) (*Dow
return nil, fmt.Errorf("服务器返回错误状态码: %d", resp.StatusCode)
}
// 服务器忽略 Range 返回 200 时,本地已下载部分不能追加,必须截断从头写,否则文件损坏
if resp.StatusCode == http.StatusOK && downloadedSize > 0 {
if err := file.Truncate(0); err != nil {
return nil, fmt.Errorf("重置下载文件失败: %v", err)
}
downloadedSize = 0
log.Printf("[下载] 服务器不支持断点续传(返回 200),从头下载")
}
// 获取文件总大小
contentLength := resp.ContentLength
gotTotalFromRange := false
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// ==================== 常量定义 ====================
// AppVersion 应用版本号(发布时直接修改此处)
const AppVersion = "0.4.1"
const AppVersion = "0.5.0"
// 版本号缓存
var (
+3 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net"
"os"
"strconv"
"sync"
"time"
@@ -157,7 +158,8 @@ func buildClient(config *Config) (*Client, error) {
return nil, &ConnectionError{Op: "auth", Err: fmt.Errorf("必须提供密码或密钥文件")}
}
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
// JoinHostPort 保证 IPv6 主机地址被方括号包裹,fmt 拼接对 IPv6 产生非法地址
addr := net.JoinHostPort(config.Host, strconv.Itoa(config.Port))
// 拨号与认证、SFTP 子系统握手共享同一总超时预算,认证阶段无界挂起由 deadline 掐断
deadline := time.Now().Add(connectTimeout)
sshConn, err := net.DialTimeout("tcp", addr, connectTimeout)
+11 -2
View File
@@ -5,7 +5,6 @@ import (
"encoding/base64"
"errors"
"fmt"
"log"
"log/slog"
"io"
"io/fs"
@@ -539,7 +538,7 @@ func sftpSupplementDir(s *Service, c *Client, remoteDir string, tmpDir string, s
continue
}
if count >= common.MaxSupplementFiles {
log.Printf("[站点下载] 补充扫描达到上限: count=%d", common.MaxSupplementFiles)
slog.Warn("站点下载补充扫描达到上限", "count", common.MaxSupplementFiles)
break
}
fullPath := path.Join(remoteDir, entry.Name())
@@ -617,6 +616,10 @@ func (s *Service) DownloadToFile(connID, remotePath, localDstDir string, onProgr
if err := walk(remotePath, dstDir); err != nil {
return "", fmt.Errorf("遍历远程目录失败: %w", err)
}
// 空目录时清单为空,downloadFileTo 不会执行,目标目录须显式创建
if err := os.MkdirAll(dstDir, 0755); err != nil {
return "", fmt.Errorf("创建本地目录失败: %w", err)
}
var copied int64
for _, e := range entries {
@@ -714,6 +717,12 @@ func (s *Service) UploadFromFile(connID, localPath, remoteDir string, onProgress
if err != nil {
return err
}
// 空目录时文件清单为空,uploadFileTo 不会执行,远端根目录须显式创建
if err := c.WithRetry(func(sc *sftpclient.Client) error {
return sc.MkdirAll(remoteRoot)
}); err != nil {
return fmt.Errorf("创建远程目录失败: %w", err)
}
var copied int64
for _, f := range files {
if err := s.uploadFileTo(c, f.local, path.Join(remoteRoot, f.rel), &copied, total, onProgress); err != nil {
+6
View File
@@ -64,6 +64,9 @@ func main() {
app.HandleHotkey()
return 0, true
}
case wmSizeMsg:
// webview 尺寸失同步兜底(app_window_fix.go)
go fixWebviewFill(hwnd)
}
return 0, false
},
@@ -105,6 +108,9 @@ func main() {
app.SetMainWindow(window)
// webview 与窗口尺寸失同步的周期兜底(app_window_fix.go)
StartWebviewFillWatch()
// 外部文件拖入窗口(OS 资源管理器拖放):把本地路径转发给前端处理
// (本机面板=复制进当前目录,SFTP 面板=上传)
window.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "u-desk",
"outputfilename": "u-desk",
"version": "0.4.1",
"version": "0.5.0",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"author": {