Compare commits
14
Commits
54b3f03d1f
...
066ac2b339
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
066ac2b339 | ||
|
|
c7978a5bfd | ||
|
|
81e245eade | ||
|
|
37cbc39154 | ||
|
|
e28ae653b0 | ||
|
|
b9c0786c02 | ||
|
|
4746f8cac2 | ||
|
|
b488354369 | ||
|
|
6c59f81b44 | ||
|
|
15dedfc563 | ||
|
|
e8ccb4280b | ||
|
|
fa3c3234b9 | ||
|
|
2002bb8cca | ||
|
|
e87ffe1d0c |
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
|
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
|
||||||
|
|
||||||
|
## [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
|
## [0.4.2] - 2026-09-15
|
||||||
|
|
||||||
- **初始化守卫**: 17 个 Wails 绑定方法加未就绪守卫,启动瞬间操作返回明确提示
|
- **初始化守卫**: 17 个 Wails 绑定方法加未就绪守卫,启动瞬间操作返回明确提示
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-10-01
|
||||||
|
|
||||||
|
### 新增 ✨
|
||||||
|
- **预览导出 PDF**: HTML/Markdown 预览一键导出为 PDF,成功后自动打开所在文件夹并选中文件
|
||||||
|
- **剪贴板粘贴文件**: 资源管理器复制的文件/文件夹可直接粘贴进文件列表(本机=复制,SFTP=上传)
|
||||||
|
- **收藏展示名自定义**: 收藏项可自定义显示名称,收藏列表默认展开
|
||||||
|
- **服务器右键菜单**: 服务器列表支持右键菜单,连接/管理操作直达
|
||||||
|
- **一键全连**: 服务器管理菜单新增"一键全连",并发连接全部服务器
|
||||||
|
|
||||||
|
### 优化 🚀
|
||||||
|
- **收藏置顶项**: 置顶背景加深为清晰浅蓝,一眼区分置顶与普通收藏
|
||||||
|
- **收藏栏宽度可调**: 拖拽调整侧边栏收藏区宽度并记忆
|
||||||
|
|
||||||
|
### 修复 🐛
|
||||||
|
- **窗口内容收窄**: 切换服务器/目录时偶发的内容区收窄卡住不再出现,自动恢复满宽
|
||||||
|
- **Markdown 导出**: file URL 转义修正,含空格/特殊字符路径导出正常
|
||||||
|
- **PDF 导出稳定性**: 浏览器探测补全,导出失败立即提示不再无响应
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.4.2] - 2026-09-15
|
## [0.4.2] - 2026-09-15
|
||||||
|
|
||||||
### 修复 🐛
|
### 修复 🐛
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type App struct {
|
|||||||
mainWindow *application.WebviewWindow
|
mainWindow *application.WebviewWindow
|
||||||
updateAPI *api.UpdateAPI
|
updateAPI *api.UpdateAPI
|
||||||
updateTicker *time.Ticker
|
updateTicker *time.Ticker
|
||||||
|
updateCheckStop chan struct{}
|
||||||
configAPI *api.ConfigAPI
|
configAPI *api.ConfigAPI
|
||||||
pdfAPI *api.PdfAPI
|
pdfAPI *api.PdfAPI
|
||||||
filesystem *filesystem.FileSystemService
|
filesystem *filesystem.FileSystemService
|
||||||
@@ -172,6 +173,7 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
|
|||||||
a.mainWindow.EmitEvent(name, data...)
|
a.mainWindow.EmitEvent(name, data...)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
a.updateAPI.SetConfigChangedCallback(a.resetAutoUpdateCheck)
|
||||||
a.startAutoUpdateCheck()
|
a.startAutoUpdateCheck()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -200,9 +202,7 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
|
|||||||
|
|
||||||
// ServiceShutdown Wails v3 服务关闭生命周期(替代 v2 的 Shutdown)
|
// ServiceShutdown Wails v3 服务关闭生命周期(替代 v2 的 Shutdown)
|
||||||
func (a *App) ServiceShutdown() error {
|
func (a *App) ServiceShutdown() error {
|
||||||
if a.updateTicker != nil {
|
a.stopUpdateTicker()
|
||||||
a.updateTicker.Stop()
|
|
||||||
}
|
|
||||||
if a.unregisterHotkey != nil {
|
if a.unregisterHotkey != nil {
|
||||||
a.unregisterHotkey()
|
a.unregisterHotkey()
|
||||||
}
|
}
|
||||||
@@ -273,13 +273,6 @@ type RenamePathRequest struct {
|
|||||||
NewPath string `json:"newPath"`
|
NewPath string `json:"newPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveAppConfigRequest 保存应用配置请求
|
|
||||||
type SaveAppConfigRequest struct {
|
|
||||||
Tabs []api.AppTabDefinition `json:"tabs"`
|
|
||||||
VisibleTabs []string `json:"visibleTabs"`
|
|
||||||
DefaultTab string `json:"defaultTab"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SftpConnectRequest SFTP 连接请求
|
// SftpConnectRequest SFTP 连接请求
|
||||||
type SftpConnectRequest struct {
|
type SftpConnectRequest struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
|
|||||||
@@ -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
@@ -26,7 +26,8 @@ func (a *App) GetAppConfig() (map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveAppConfig 保存应用配置
|
// 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 {
|
if a.configAPI == nil {
|
||||||
return nil, fmt.Errorf("配置服务正在初始化中")
|
return nil, fmt.Errorf("配置服务正在初始化中")
|
||||||
}
|
}
|
||||||
@@ -41,13 +42,7 @@ func (a *App) SaveAppConfig(req SaveAppConfigRequest) (map[string]interface{}, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apiReq := api.SaveAppConfigRequest{
|
result, err := a.configAPI.SaveAppConfig(req)
|
||||||
Tabs: req.Tabs,
|
|
||||||
VisibleTabs: req.VisibleTabs,
|
|
||||||
DefaultTab: req.DefaultTab,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := a.configAPI.SaveAppConfig(apiReq)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-4
@@ -51,12 +51,28 @@ func (a *App) ExportPDF(content string, title string, fileName string, fontSize
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelectPDFSaveDirectory 选择PDF保存目录
|
// ExportHtmlFileToPDF 将HTML文件导出为PDF
|
||||||
func (a *App) SelectPDFSaveDirectory() (string, error) {
|
func (a *App) ExportHtmlFileToPDF(path string, saveName string) (map[string]interface{}, error) {
|
||||||
pdfAPI, err := a.requirePdfAPI()
|
pdfAPI, err := a.requirePdfAPI()
|
||||||
if err != nil {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-3
@@ -92,7 +92,7 @@ func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType st
|
|||||||
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
|
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startAutoUpdateCheck 启动自动更新检查
|
// startAutoUpdateCheck 启动自动更新检查(可重入:先停止旧定时器再按当前配置新建)
|
||||||
func (a *App) startAutoUpdateCheck() {
|
func (a *App) startAutoUpdateCheck() {
|
||||||
updateAPI, err := a.requireUpdateAPI()
|
updateAPI, err := a.requireUpdateAPI()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -116,6 +116,8 @@ func (a *App) startAutoUpdateCheck() {
|
|||||||
|
|
||||||
autoCheckEnabled, ok := configData["auto_check_enabled"].(bool)
|
autoCheckEnabled, ok := configData["auto_check_enabled"].(bool)
|
||||||
if !ok || !autoCheckEnabled {
|
if !ok || !autoCheckEnabled {
|
||||||
|
// 关闭自动检查时停止存量定时器
|
||||||
|
a.stopUpdateTicker()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,16 +126,53 @@ func (a *App) startAutoUpdateCheck() {
|
|||||||
interval = 5
|
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()
|
go a.checkUpdate()
|
||||||
|
|
||||||
a.updateTicker = time.NewTicker(time.Duration(interval) * time.Minute)
|
|
||||||
go func() {
|
go func() {
|
||||||
for range a.updateTicker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
a.checkUpdate()
|
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 执行更新检查
|
// checkUpdate 执行更新检查
|
||||||
func (a *App) checkUpdate() {
|
func (a *App) checkUpdate() {
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -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 功能清单
|
# U-Desk 功能清单
|
||||||
|
|
||||||
> 应用版本:v0.4.2
|
> 应用版本:v0.5.0
|
||||||
> 更新时间:2026-02-28 10:00
|
> 更新时间:2026-10-01 10:00
|
||||||
> 用途:测试、开发、迭代过程中验证核对
|
> 用途:测试、开发、迭代过程中验证核对
|
||||||
> 核对状态:✅ 已通过Git版本对比 + 源码验证
|
> 核对状态:✅ 已通过Git版本对比 + 源码验证
|
||||||
> 核对报告:[功能清单核对报告.md](./功能清单核对报告.md)
|
> 核对报告:[功能清单核对报告.md](./功能清单核对报告.md)
|
||||||
@@ -488,8 +488,8 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
**文档维护**: 开发团队
|
**文档维护**: 开发团队
|
||||||
**最后更新**: 2026-02-28 10:00
|
**最后更新**: 2026-10-01 10:00
|
||||||
**应用版本**: v0.4.2
|
**应用版本**: v0.5.0
|
||||||
**核对状态**: ✅ 已通过Git版本对比 + 源码验证 + 需求对照
|
**核对状态**: ✅ 已通过Git版本对比 + 源码验证 + 需求对照
|
||||||
**核对报告**: [功能清单核对报告.md](./功能清单核对报告.md)
|
**核对报告**: [功能清单核对报告.md](./功能清单核对报告.md)
|
||||||
**下次审查**: 每次发版前
|
**下次审查**: 每次发版前
|
||||||
|
|||||||
+1
-1
Submodule docs/08-用户指南/u-desk-site updated: a91bf33df1...15016c01b7
@@ -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";
|
import * as application$0 from "../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore: Unused imports
|
// @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";
|
import * as filesystem$0 from "./internal/filesystem/models.js";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// 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文件
|
* ExportPDF 导出PDF文件
|
||||||
*/
|
*/
|
||||||
@@ -482,6 +494,16 @@ export function PickDirectory(): $CancellablePromise<string> {
|
|||||||
return $Call.ByID(1347829059);
|
return $Call.ByID(1347829059);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReadClipboardFiles 读取系统剪贴板中的文件/文件夹路径列表。
|
||||||
|
* 剪贴板为文本/截图等非文件内容时返回空列表,前端据此回落图片粘贴等原有链路。
|
||||||
|
*/
|
||||||
|
export function ReadClipboardFiles(): $CancellablePromise<string[]> {
|
||||||
|
return $Call.ByID(2636780132).then(($result: any) => {
|
||||||
|
return $$createType7($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReadFile 读取文件
|
* ReadFile 读取文件
|
||||||
*/
|
*/
|
||||||
@@ -530,8 +552,9 @@ export function RestoreFromRecycleBin(recyclePath: string): $CancellablePromise<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* SaveAppConfig 保存应用配置
|
* 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 $Call.ByID(1942219977, req).then(($result: any) => {
|
||||||
return $$createType2($result);
|
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 在创建窗口后调用)
|
* SetMainWindow 设置主窗口引用(由 main.go 在创建窗口后调用)
|
||||||
*/
|
*/
|
||||||
@@ -795,3 +811,4 @@ const $$createType3 = filesystem$0.FileOperationResult.createFrom;
|
|||||||
const $$createType4 = $Create.Nullable($$createType3);
|
const $$createType4 = $Create.Nullable($$createType3);
|
||||||
const $$createType5 = $Create.Array($$createType2);
|
const $$createType5 = $Create.Array($$createType2);
|
||||||
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
|
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
|
||||||
|
const $$createType7 = $Create.Array($Create.Any);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export {
|
|||||||
OssConnectRequest,
|
OssConnectRequest,
|
||||||
OssRenamePathRequest,
|
OssRenamePathRequest,
|
||||||
RenamePathRequest,
|
RenamePathRequest,
|
||||||
SaveAppConfigRequest,
|
|
||||||
SaveBase64FileRequest,
|
SaveBase64FileRequest,
|
||||||
SaveProfileRequest,
|
SaveProfileRequest,
|
||||||
SftpConnectRequest,
|
SftpConnectRequest,
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AppTabDefinition
|
AppTabDefinition,
|
||||||
|
SaveAppConfigRequest
|
||||||
} from "./models.js";
|
} from "./models.js";
|
||||||
|
|||||||
@@ -40,3 +40,56 @@ export class AppTabDefinition {
|
|||||||
return new AppTabDefinition($$parsedSource as Partial<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
|
// @ts-ignore: Unused imports
|
||||||
import { Create as $Create } from "@wailsio/runtime";
|
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 播放列表条目
|
* 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 编码的二进制文件
|
* SaveBase64FileRequest 保存 Base64 编码的二进制文件
|
||||||
*/
|
*/
|
||||||
@@ -422,8 +378,3 @@ export class WriteFileRequest {
|
|||||||
return new WriteFileRequest($$parsedSource as Partial<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);
|
|
||||||
|
|||||||
@@ -85,7 +85,6 @@
|
|||||||
<UpdateNotification
|
<UpdateNotification
|
||||||
v-model="updateStore.showUpdate"
|
v-model="updateStore.showUpdate"
|
||||||
:update-info="updateStore.updateInfo"
|
:update-info="updateStore.updateInfo"
|
||||||
@install="updateStore.installUpdate"
|
|
||||||
/>
|
/>
|
||||||
</a-layout>
|
</a-layout>
|
||||||
</template>
|
</template>
|
||||||
@@ -179,9 +178,12 @@ onMounted(() => {
|
|||||||
// 禁用浏览器默认右键菜单(桌面应用体验)
|
// 禁用浏览器默认右键菜单(桌面应用体验)
|
||||||
document.addEventListener('contextmenu', e => e.preventDefault())
|
document.addEventListener('contextmenu', e => e.preventDefault())
|
||||||
|
|
||||||
// 延迟检查更新(启动后 3 秒,静默模式)
|
// 延迟检查更新(启动后 3 秒,静默模式;后端更新模块异步初始化,未就绪则 15 秒后重试一次)
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
updateStore.checkForUpdates(true)
|
const ok = await updateStore.checkForUpdates(true)
|
||||||
|
if (!ok) {
|
||||||
|
setTimeout(() => { updateStore.checkForUpdates(true) }, 15000)
|
||||||
|
}
|
||||||
}, 3000)
|
}, 3000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
|
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
|
||||||
|
import { GetEnvVars, WriteFile } from '@bindings/u-desk/app'
|
||||||
|
|
||||||
const CONTENT_TYPE = 'application/json'
|
const CONTENT_TYPE = 'application/json'
|
||||||
|
|
||||||
@@ -156,7 +157,19 @@ export class HttpTransport implements FsTransport {
|
|||||||
|
|
||||||
async emptyRecycleBin(): Promise<void> {}
|
async emptyRecycleBin(): Promise<void> {}
|
||||||
|
|
||||||
|
// HTML 的 PDF 导出与本地预览都依赖本地真实文件(导出在后端 os.Stat,预览走本地文件服务器),
|
||||||
|
// 因此把远端 HTML 文本拉到本机临时目录;其余类型保持返回原路径走 fs-agent 代理流式预览,
|
||||||
|
// 二进制内容若也走 read 接口会被当文本损坏
|
||||||
async downloadForPreview(path: string): Promise<string> {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export interface FsTransport {
|
|||||||
emptyRecycleBin(): Promise<void>
|
emptyRecycleBin(): Promise<void>
|
||||||
|
|
||||||
// 预览辅助
|
// 预览辅助
|
||||||
/** 下载远程文件到本地临时目录,本地/HTTP 直接返回原路径 */
|
/** 下载远程文件到本地临时目录,本地直接返回原路径;HTTP 仅对 HTML 落本地副本,其余类型返回原路径 */
|
||||||
downloadForPreview(path: string): Promise<string>
|
downloadForPreview(path: string): Promise<string>
|
||||||
/** 下载 HTML 及其引用的资源到临时目录(OSS 实现) */
|
/** 下载 HTML 及其引用的资源到临时目录(OSS 实现) */
|
||||||
downloadSiteForPreview?(path: string): Promise<string>
|
downloadSiteForPreview?(path: string): Promise<string>
|
||||||
|
|||||||
@@ -210,6 +210,17 @@
|
|||||||
<template #icon><icon-save /></template>
|
<template #icon><icon-save /></template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-tooltip>
|
</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
|
<a-tooltip
|
||||||
position="left"
|
position="left"
|
||||||
@@ -440,6 +451,7 @@ import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandle
|
|||||||
import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
|
import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
|
||||||
import { isDirty } from '../composables/useMultiPreview'
|
import { isDirty } from '../composables/useMultiPreview'
|
||||||
import { On, Off } from '@wailsio/events'
|
import { On, Off } from '@wailsio/events'
|
||||||
|
import { ExportHtmlFileToPDF } from '@bindings/u-desk/app'
|
||||||
import { connectionManager } from '@/api/connection-manager'
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import { listDir, readFile } from '@/api/system'
|
import { listDir, readFile } from '@/api/system'
|
||||||
import { resolveFileUrl, resolveFileUrlForProfile } from '../composables/useFilePreview'
|
import { resolveFileUrl, resolveFileUrlForProfile } from '../composables/useFilePreview'
|
||||||
@@ -528,32 +540,39 @@ const getFileTypeIcon = (filename: string): string => {
|
|||||||
|
|
||||||
// HTML 预览 URL(OSS 需要先下载到本地再预览)
|
// HTML 预览 URL(OSS 需要先下载到本地再预览)
|
||||||
const htmlPreviewUrl = ref('')
|
const htmlPreviewUrl = ref('')
|
||||||
|
// 导出 PDF 需要本地可访问的源文件路径:远程文件取缓存副本,本地文件即原路径
|
||||||
|
const htmlPreviewLocalPath = ref('')
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.config.currentFileFullPath, props.config.isHtmlFile],
|
() => [props.config.currentFileFullPath, props.config.isHtmlFile],
|
||||||
async ([path, isHtml]) => {
|
async ([path, isHtml]) => {
|
||||||
htmlPreviewUrl.value = ''
|
htmlPreviewUrl.value = ''
|
||||||
|
htmlPreviewLocalPath.value = ''
|
||||||
if (!path || !isHtml) return
|
if (!path || !isHtml) return
|
||||||
|
|
||||||
// 下载到临时目录后是本地文件,统一用本地文件服务器
|
// 下载到临时目录后是本地文件,统一用本地文件服务器
|
||||||
const base = getFileServerBaseURL()
|
const base = getFileServerBaseURL()
|
||||||
const makeUrl = (localPath: string) => `${base}/localfs/html-preview?path=${encodeURIComponent(localPath)}`
|
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()) {
|
if (connectionManager.isRemote()) {
|
||||||
try {
|
try {
|
||||||
const transport = connectionManager.getTransport()
|
const transport = connectionManager.getTransport()
|
||||||
if (transport?.downloadSiteForPreview) {
|
if (transport?.downloadSiteForPreview) {
|
||||||
const tempPath = await transport.downloadSiteForPreview(path)
|
const tempPath = await transport.downloadSiteForPreview(path)
|
||||||
htmlPreviewUrl.value = makeUrl(tempPath)
|
applyLocalPath(tempPath)
|
||||||
} else if (transport?.downloadForPreview) {
|
} else if (transport?.downloadForPreview) {
|
||||||
const tempPath = await transport.downloadForPreview(path)
|
const tempPath = await transport.downloadForPreview(path)
|
||||||
htmlPreviewUrl.value = makeUrl(tempPath)
|
applyLocalPath(tempPath)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[HTML预览] 下载失败:', path, e)
|
console.warn('[HTML预览] 下载失败:', path, e)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
htmlPreviewUrl.value = makeUrl(path)
|
applyLocalPath(path)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ 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 图表
|
// 监听模式切换,切换到预览模式时渲染 Mermaid 图表
|
||||||
watch(() => props.config.isEditMode, async (newVal, oldVal) => {
|
watch(() => props.config.isEditMode, async (newVal, oldVal) => {
|
||||||
// 从编辑模式切换到预览模式
|
// 从编辑模式切换到预览模式
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<transition name="slide">
|
<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">
|
<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">
|
<div class="section-header" @click="serverCollapsed = !serverCollapsed">
|
||||||
<span class="section-title">🖥️ 服务器</span>
|
<span class="section-title">🖥️ 服务器</span>
|
||||||
<icon-down v-if="!serverCollapsed" class="section-toggle" />
|
<icon-down v-if="!serverCollapsed" class="section-toggle" />
|
||||||
@@ -19,10 +19,16 @@
|
|||||||
<span class="col-metric">内存</span>
|
<span class="col-metric">内存</span>
|
||||||
<span class="col-action settings-btn" @click="toggleSettings($event)" title="管理">···</span>
|
<span class="col-action settings-btn" @click="toggleSettings($event)" title="管理">···</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 表格行 -->
|
<!-- 表格行(右键菜单与行内「...」菜单共用同一组操作,互斥展开) -->
|
||||||
<div
|
<a-dropdown
|
||||||
v-for="p in visibleProfiles"
|
v-for="(p, idx) in visibleProfiles"
|
||||||
:key="p.id"
|
: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="server-table-row"
|
||||||
:class="{ active: p.id === activeId }"
|
:class="{ active: p.id === activeId }"
|
||||||
@click="handleSelect(p)"
|
@click="handleSelect(p)"
|
||||||
@@ -39,6 +45,23 @@
|
|||||||
>···</span>
|
>···</span>
|
||||||
<span v-else class="col-action"></span>
|
<span v-else class="col-action"></span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,9 +77,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="section-content-wrap" :class="{ collapsed: favCollapsed }">
|
<div class="section-content-wrap" :class="{ collapsed: favCollapsed }">
|
||||||
<div class="section-content fav-content">
|
<div class="section-content fav-content">
|
||||||
<div
|
<a-dropdown
|
||||||
v-for="(fav, index) in config.favoriteFiles"
|
v-for="(fav, index) in config.favoriteFiles"
|
||||||
:key="fav.path"
|
: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"
|
||||||
:class="{
|
:class="{
|
||||||
'sidebar-item-pinned': fav.pinnedAt,
|
'sidebar-item-pinned': fav.pinnedAt,
|
||||||
@@ -78,30 +107,49 @@
|
|||||||
@drop="handleDrop($event, index)"
|
@drop="handleDrop($event, index)"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
>
|
>
|
||||||
<span class="sidebar-item-icon">{{ getFileIcon(fav) }}</span>
|
<span class="sidebar-item-icon">{{ getFileIcon({ is_dir: fav.isDir, name: fav.name }) }}</span>
|
||||||
<span class="sidebar-item-name" :title="fav.name">{{ fav.name }}</span>
|
<span class="sidebar-item-name" :title="favoriteTooltip(fav)">{{ favoriteName(fav) }}</span>
|
||||||
<a-button
|
<a-dropdown
|
||||||
type="text"
|
trigger="click"
|
||||||
size="mini"
|
:popup-visible="favMenuIndex === index"
|
||||||
@click.stop="handleTogglePin(fav)"
|
@popup-visible-change="(v: boolean) => onFavMenuVisible(index, v)"
|
||||||
class="sidebar-item-pin"
|
|
||||||
:class="{ 'is-pinned': fav.pinnedAt }"
|
|
||||||
>
|
>
|
||||||
|
<a-button type="text" size="mini" title="更多操作" class="sidebar-item-more" @click.stop>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<icon-pushpin :style="{ opacity: fav.pinnedAt ? 1 : 0.4 }" />
|
<icon-more />
|
||||||
</template>
|
</template>
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<template #content>
|
||||||
type="text"
|
<a-doption @click="openRename(fav)">
|
||||||
size="mini"
|
<template #icon><icon-edit /></template>
|
||||||
@click.stop="handleRemoveFavorite(fav)"
|
重命名
|
||||||
class="sidebar-item-remove"
|
</a-doption>
|
||||||
>
|
<a-doption @click="handleTogglePin(fav)">
|
||||||
<template #icon>
|
<template #icon><icon-pushpin /></template>
|
||||||
<icon-close />
|
{{ fav.pinnedAt ? '取消置顶' : '置顶' }}
|
||||||
|
</a-doption>
|
||||||
|
<a-doption class="danger" @click="handleRemoveFavorite(fav)">
|
||||||
|
<template #icon><icon-close /></template>
|
||||||
|
删除
|
||||||
|
</a-doption>
|
||||||
</template>
|
</template>
|
||||||
</a-button>
|
</a-dropdown>
|
||||||
</div>
|
</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">
|
<div v-if="config.favoriteFiles.length === 0" class="sidebar-empty">
|
||||||
<icon-star />
|
<icon-star />
|
||||||
<span>暂无收藏</span>
|
<span>暂无收藏</span>
|
||||||
@@ -112,7 +160,7 @@
|
|||||||
</div>
|
</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">
|
<div class="section-header" @click="helpCollapsed = !helpCollapsed">
|
||||||
<span class="section-title">📖 帮助</span>
|
<span class="section-title">📖 帮助</span>
|
||||||
<icon-down v-if="!helpCollapsed" class="section-toggle" />
|
<icon-down v-if="!helpCollapsed" class="section-toggle" />
|
||||||
@@ -131,6 +179,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</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 到 body -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="moreOpenId" class="sidebar-popup more-menu" :style="moreMenuStyle" @click.stop>
|
<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">
|
<div class="settings-item" @click="$emit('openConnectionDialog'); settingsOpen = false">
|
||||||
<icon-plus /> 添加服务器
|
<icon-plus /> 添加服务器
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-item" @click="connectAllServers">
|
||||||
|
<icon-link /> 一键全连
|
||||||
|
</div>
|
||||||
<div class="settings-item" @click="toggleAutoConnect">
|
<div class="settings-item" @click="toggleAutoConnect">
|
||||||
<icon-check-circle :style="{ opacity: autoConnect ? 1 : 0.3 }" />
|
<icon-check-circle :style="{ opacity: autoConnect ? 1 : 0.3 }" />
|
||||||
启动时自动连接所有服务器
|
启动时自动连接所有服务器
|
||||||
@@ -165,12 +236,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
||||||
import type { SystemInfo } from '@/api/connection-manager'
|
import type { SystemInfo } from '@/api/connection-manager'
|
||||||
import { connectionManager } from '@/api/connection-manager'
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { 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'
|
import { getFileIcon } from '@/utils/fileUtils'
|
||||||
|
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
@@ -178,13 +249,14 @@ const configStore = useConfigStore()
|
|||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
config: SidebarConfig
|
config: SidebarConfig
|
||||||
|
width: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
// 折叠状态(组件内部,不污染父组件)
|
// 折叠状态(组件内部,不污染父组件);收藏夹默认展开,用户手动折叠后会话内保持
|
||||||
const serverCollapsed = ref(false)
|
const serverCollapsed = ref(false)
|
||||||
const favCollapsed = ref(true)
|
const favCollapsed = ref(false)
|
||||||
const helpCollapsed = ref(true)
|
const helpCollapsed = ref(true)
|
||||||
|
|
||||||
// 侧边栏区块可见性(从配置读取)
|
// 侧边栏区块可见性(从配置读取)
|
||||||
@@ -222,6 +294,7 @@ function toggleSettings(e: MouseEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
moreOpenId.value = null
|
moreOpenId.value = null
|
||||||
|
srvCtxMenuIndex.value = -1
|
||||||
const btn = (e.target as HTMLElement).closest<HTMLElement>('.settings-btn')
|
const btn = (e.target as HTMLElement).closest<HTMLElement>('.settings-btn')
|
||||||
if (!btn) return
|
if (!btn) return
|
||||||
settingsPanelStyle.value = calcPopupStyle(btn)
|
settingsPanelStyle.value = calcPopupStyle(btn)
|
||||||
@@ -235,6 +308,7 @@ function toggleMore(p: { id: string; name: string }, e: MouseEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
settingsOpen.value = false
|
settingsOpen.value = false
|
||||||
|
srvCtxMenuIndex.value = -1
|
||||||
moreOpenId.value = p.id
|
moreOpenId.value = p.id
|
||||||
moreProfile.value = p
|
moreProfile.value = p
|
||||||
const target = (e.target as HTMLElement).closest('.more-btn') as HTMLElement
|
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))
|
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(() => {
|
connectionManager.onStateChange(() => {
|
||||||
profiles.value = connectionManager.profiles
|
profiles.value = connectionManager.profiles
|
||||||
@@ -343,6 +436,7 @@ interface Emits {
|
|||||||
(e: 'openFavorite', file: FavoriteFile): void
|
(e: 'openFavorite', file: FavoriteFile): void
|
||||||
(e: 'removeFavorite', path: string): void
|
(e: 'removeFavorite', path: string): void
|
||||||
(e: 'togglePin', path: string): void
|
(e: 'togglePin', path: string): void
|
||||||
|
(e: 'renameFavorite', path: string, displayName: string): void
|
||||||
(e: 'longPressStart', event: MouseEvent | TouchEvent, index: number): void
|
(e: 'longPressStart', event: MouseEvent | TouchEvent, index: number): void
|
||||||
(e: 'longPressCancel'): void
|
(e: 'longPressCancel'): void
|
||||||
(e: 'dragStart', event: DragEvent, index: number): void
|
(e: 'dragStart', event: DragEvent, index: number): void
|
||||||
@@ -368,6 +462,76 @@ const handleTogglePin = (file: FavoriteFile) => {
|
|||||||
emit('togglePin', file.path)
|
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) => {
|
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||||
emit('longPressStart', event, index)
|
emit('longPressStart', event, index)
|
||||||
}
|
}
|
||||||
@@ -399,7 +563,9 @@ function sysInfo(profileId: string): SystemInfo | undefined {
|
|||||||
|
|
||||||
function stateDotClass(profileId: string): string {
|
function stateDotClass(profileId: string): string {
|
||||||
if (profileId === activeId.value) {
|
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 === 'connected') return 'connected'
|
||||||
if (state === 'connecting') return 'connecting'
|
if (state === 'connecting') return 'connecting'
|
||||||
if (state === 'error') return 'error'
|
if (state === 'error') return 'error'
|
||||||
@@ -460,7 +626,8 @@ function formatBytes(n: number): string {
|
|||||||
|
|
||||||
function stateText(profileId: string): string {
|
function stateText(profileId: string): string {
|
||||||
if (profileId === activeId.value) {
|
if (profileId === activeId.value) {
|
||||||
const s = connectionManager.state
|
// 同 stateDotClass:per-profile 状态不随连接尝试漂移
|
||||||
|
const s = connectionManager.getProfileState(profileId).state
|
||||||
if (s === 'connected') return '已连接'
|
if (s === 'connected') return '已连接'
|
||||||
if (s === 'connecting') return '连接中...'
|
if (s === 'connecting') return '连接中...'
|
||||||
if (s === 'error') return '连接失败'
|
if (s === 'error') return '连接失败'
|
||||||
@@ -494,6 +661,18 @@ function handleDelete(p: { id: string; name: string }) {
|
|||||||
// 删除的是活跃服务器时连接已回落本地,补发切换让视图加载本地根目录
|
// 删除的是活跃服务器时连接已回落本地,补发切换让视图加载本地根目录
|
||||||
if (wasActive) emit('serverSelect', 'local-default')
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -515,13 +694,13 @@ function handleDelete(p: { id: string; name: string }) {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 服务器区块不收缩 */
|
/* 服务器区块不收缩(按显式类名定位,不依赖排序位置) */
|
||||||
.sidebar-section:first-child {
|
.server-section {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 服务器区块不需要 max-height 限制 */
|
/* 服务器区块不需要 max-height 限制 */
|
||||||
.sidebar-section:first-child > .section-content-wrap {
|
.server-section > .section-content-wrap {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,8 +736,8 @@ function handleDelete(p: { id: string; name: string }) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 帮助区块固定在底部 */
|
/* 帮助区块不收缩(按显式类名定位,不依赖排序位置) */
|
||||||
.sidebar-section:last-child {
|
.help-section {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,17 +988,8 @@ function handleDelete(p: { id: string; name: string }) {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item-remove {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item:hover .sidebar-item-remove {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item-pinned {
|
.sidebar-item-pinned {
|
||||||
background: var(--color-fill-1);
|
background: var(--color-primary-light-1);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,12 +1005,13 @@ function handleDelete(p: { id: string; name: string }) {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item-pin {
|
/* 「...」操作菜单按钮,悬停收藏项时显示 */
|
||||||
|
.sidebar-item-more {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item:hover .sidebar-item-pin {
|
.sidebar-item:hover .sidebar-item-more {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,6 +1059,17 @@ function handleDelete(p: { id: string; name: string }) {
|
|||||||
}
|
}
|
||||||
</style>
|
</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 的面板样式 -->
|
<!-- 非 scoped:Teleport 到 body 的面板样式 -->
|
||||||
<style>
|
<style>
|
||||||
/* 弹出面板公共 */
|
/* 弹出面板公共 */
|
||||||
|
|||||||
@@ -173,6 +173,17 @@ export function useFavorites() {
|
|||||||
return !!fav?.pinnedAt
|
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,
|
isFavorite,
|
||||||
togglePin,
|
togglePin,
|
||||||
isPinned,
|
isPinned,
|
||||||
|
renameFavorite,
|
||||||
updateFavoritePath,
|
updateFavoritePath,
|
||||||
|
|
||||||
onLongPressStart,
|
onLongPressStart,
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="file-system-container">
|
<div class="file-system-container">
|
||||||
<!-- 主内容区:左侧边栏 + 右侧工作区 -->
|
<!-- 主内容区:左侧边栏 + 右侧工作区 -->
|
||||||
<div class="main-content">
|
<div ref="mainContentRef" class="main-content">
|
||||||
<!-- 左侧收藏夹侧边栏(全高) -->
|
<!-- 左侧收藏夹侧边栏(全高) -->
|
||||||
<Sidebar
|
<Sidebar
|
||||||
v-if="showSidebar"
|
v-if="showSidebar"
|
||||||
:config="sidebarConfig"
|
:config="sidebarConfig"
|
||||||
|
:width="sidebarWidth"
|
||||||
@open-favorite="handleOpenFavorite"
|
@open-favorite="handleOpenFavorite"
|
||||||
@remove-favorite="handleRemoveFavorite"
|
@remove-favorite="handleRemoveFavorite"
|
||||||
@toggle-pin="handleToggleFavPin"
|
@toggle-pin="handleToggleFavPin"
|
||||||
|
@rename-favorite="handleRenameFavorite"
|
||||||
@long-press-start="handleLongPressStart"
|
@long-press-start="handleLongPressStart"
|
||||||
@long-press-cancel="handleLongPressCancel"
|
@long-press-cancel="handleLongPressCancel"
|
||||||
@drag-start="handleDragStart"
|
@drag-start="handleDragStart"
|
||||||
@@ -19,6 +21,9 @@
|
|||||||
@server-select="handleConnectionChanged"
|
@server-select="handleConnectionChanged"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 侧边栏拖宽把手 -->
|
||||||
|
<div v-if="showSidebar" class="sidebar-resizer" @mousedown="handleSidebarResizeWithOverlay"></div>
|
||||||
|
|
||||||
<!-- 右侧工作区:面包屑工具栏 + 文件列表/编辑器 -->
|
<!-- 右侧工作区:面包屑工具栏 + 文件列表/编辑器 -->
|
||||||
<div class="workspace-area">
|
<div class="workspace-area">
|
||||||
<!-- 面包屑导航工具栏 -->
|
<!-- 面包屑导航工具栏 -->
|
||||||
@@ -124,6 +129,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|||||||
import { getPathSeparator } from '@/utils/fileUtils'
|
import { getPathSeparator } from '@/utils/fileUtils'
|
||||||
import { Message, Modal } from '@arco-design/web-vue'
|
import { Message, Modal } from '@arco-design/web-vue'
|
||||||
import { On, Off } from '@wailsio/events'
|
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 { renderMarkdown, parseMarkdown, isMarkedReady, preloadMarked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { useTransferStore } from '@/stores/transfer'
|
import { useTransferStore } from '@/stores/transfer'
|
||||||
@@ -271,6 +277,58 @@ const savePanelWidth = (width: { left: number; right: number }) => {
|
|||||||
const panelWidth = ref(restorePanelWidth())
|
const panelWidth = ref(restorePanelWidth())
|
||||||
const workspaceRef = ref<HTMLElement | null>(null)
|
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)
|
// 系统路径(使用 composable)
|
||||||
const { commonPaths, systemPaths, loadCommonPaths } = useCommonPaths()
|
const { commonPaths, systemPaths, loadCommonPaths } = useCommonPaths()
|
||||||
|
|
||||||
@@ -305,7 +363,7 @@ const { clipboard, clipboardEmpty, setCopy, setCut, clear: clearClipboard } = us
|
|||||||
const transferStore = useTransferStore()
|
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 } =
|
const { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath, getProfileIdForPath } =
|
||||||
@@ -455,8 +513,11 @@ const fileEditorPanelConfig = computed(() => {
|
|||||||
currentFileExtension,
|
currentFileExtension,
|
||||||
isBinaryFile: isBinaryFileRef.value,
|
isBinaryFile: isBinaryFileRef.value,
|
||||||
fileMtime: selectedFileItem.value?.modified_time || '',
|
fileMtime: selectedFileItem.value?.modified_time || '',
|
||||||
// 多 tab
|
// 多 tab(按当前服务器过滤,跨服务器的标签不显示在浮动标签栏)
|
||||||
previewTabs: multiPreview.tabs.value,
|
previewTabs: (() => {
|
||||||
|
const pid = connectionManager.activeProfile?.id
|
||||||
|
return multiPreview.tabs.value.filter(t => (t.profileId ?? 'local-default') === pid)
|
||||||
|
})(),
|
||||||
activeTabId: multiPreview.activeTabId.value,
|
activeTabId: multiPreview.activeTabId.value,
|
||||||
// 预览插件(优先于内置 v-if 链;.drawio/.dio 命中,其余为 null)
|
// 预览插件(优先于内置 v-if 链;.drawio/.dio 命中,其余为 null)
|
||||||
activePlugin: currentFileName ? resolvePreviewHandler(currentFileName) : null,
|
activePlugin: currentFileName ? resolvePreviewHandler(currentFileName) : null,
|
||||||
@@ -511,6 +572,7 @@ const handleFileChanged = async (event: any) => {
|
|||||||
// 同路径也强制重载:navigate 同路径早退时列表会永远不加载
|
// 同路径也强制重载:navigate 同路径早退时列表会永远不加载
|
||||||
const switchConnection = async (profileId: string) => {
|
const switchConnection = async (profileId: string) => {
|
||||||
await connectionManager.connect(profileId)
|
await connectionManager.connect(profileId)
|
||||||
|
await syncPreviewToActiveProfile()
|
||||||
await loadCommonPaths()
|
await loadCommonPaths()
|
||||||
const targetPath = connectionManager.isRemote() ? '/' : 'C:/'
|
const targetPath = connectionManager.isRemote() ? '/' : 'C:/'
|
||||||
if (filePath.value === targetPath) {
|
if (filePath.value === targetPath) {
|
||||||
@@ -538,6 +600,7 @@ const handleGoToPath = async (path: string) => {
|
|||||||
const targetProfileId = getProfileIdForPath(path)
|
const targetProfileId = getProfileIdForPath(path)
|
||||||
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
||||||
await connectionManager.connect(targetProfileId)
|
await connectionManager.connect(targetProfileId)
|
||||||
|
await syncPreviewToActiveProfile()
|
||||||
}
|
}
|
||||||
await navigate(path)
|
await navigate(path)
|
||||||
}
|
}
|
||||||
@@ -691,6 +754,10 @@ const handleToggleFavPin = (path: string) => {
|
|||||||
togglePin(path)
|
togglePin(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRenameFavorite = (path: string, displayName: string) => {
|
||||||
|
renameFav(path, displayName)
|
||||||
|
}
|
||||||
|
|
||||||
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||||
onLongPressStart(event, index)
|
onLongPressStart(event, index)
|
||||||
}
|
}
|
||||||
@@ -1397,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 的编辑状态 */
|
/** 缓存当前 tab 的编辑状态 */
|
||||||
const cacheCurrentTabState = () => {
|
const cacheCurrentTabState = () => {
|
||||||
multiPreview.cacheCurrentTab({
|
multiPreview.cacheCurrentTab({
|
||||||
@@ -1962,6 +2049,17 @@ const handlePaste = async (event: ClipboardEvent) => {
|
|||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
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
|
const items = event.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
|
|
||||||
@@ -2089,6 +2187,19 @@ watch(() => themeStore.isDark, async () => {
|
|||||||
background: var(--color-primary-light-1);
|
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 {
|
.resize-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -3,14 +3,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
|
import { computed, ref, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
|
||||||
import { Modal, Message, Progress } from '@arco-design/web-vue'
|
import { Modal, Message, Progress, Button } from '@arco-design/web-vue'
|
||||||
import { useUpdateStore } from '../stores/update'
|
import { useUpdateStore } from '../stores/update'
|
||||||
import { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions'
|
import { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions'
|
||||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||||
import { parseWailsEventData } from '@/utils/wailsEvent'
|
import { GetCurrentVersion } from '../wailsjs/v3-bindings/u-desk/app'
|
||||||
import { DownloadUpdate } from '../wailsjs/v3-bindings/u-desk/app'
|
|
||||||
import { On, Off } from '@wailsio/events'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -33,7 +31,9 @@ let confirmModalInstance = null
|
|||||||
let progressModalInstance = null
|
let progressModalInstance = null
|
||||||
|
|
||||||
// Computed
|
// 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 latestVersion = computed(() => props.updateInfo?.latest_version || '')
|
||||||
const changelog = computed(() => props.updateInfo?.changelog || '')
|
const changelog = computed(() => props.updateInfo?.changelog || '')
|
||||||
const forceUpdate = computed(() => props.updateInfo?.force_update || false)
|
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
|
return elements
|
||||||
},
|
},
|
||||||
okText: '立即更新',
|
okText: '立即更新',
|
||||||
@@ -187,8 +196,7 @@ const getProgressModalContent = () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 安装中状态
|
// 安装中状态:下载完成后进入安装阶段,弹窗保持打开直到进程重启
|
||||||
if (updateStore.installing) {
|
|
||||||
return [
|
return [
|
||||||
h('div', { style: { marginBottom: '16px' } }, [
|
h('div', { style: { marginBottom: '16px' } }, [
|
||||||
h('div', { style: { marginBottom: '8px', fontSize: '14px', color: 'var(--color-text-2)' } }, '正在安装更新...')
|
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 () => {
|
const updateProgressModal = async () => {
|
||||||
if (!progressModalInstance) return
|
if (!progressModalInstance) return
|
||||||
@@ -274,16 +273,23 @@ const closeModals = () => {
|
|||||||
closeProgressModal()
|
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 () => {
|
const handleDownload = async () => {
|
||||||
await showProgressModal()
|
await showProgressModal()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await DownloadUpdate(props.updateInfo.download_url)
|
await updateStore.downloadUpdate()
|
||||||
if (result.success) return
|
|
||||||
|
|
||||||
closeProgressModal()
|
|
||||||
Message.error(result.message || '下载启动失败')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('下载失败:', error)
|
console.error('下载失败:', error)
|
||||||
closeProgressModal()
|
closeProgressModal()
|
||||||
@@ -291,35 +297,34 @@ const handleDownload = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载完成处理(本地覆盖:关闭弹窗)
|
// 下载/安装状态收尾:下载失败收起进度弹窗;安装流程结束(重启前或失败)关闭弹窗
|
||||||
const onDownloadComplete = async (event) => {
|
watch(
|
||||||
const data = parseWailsEventData(event)
|
() => [updateStore.installing, updateStore.downloadStatus],
|
||||||
|
([installing, status], [prevInstalling]) => {
|
||||||
if (data.error) {
|
if (status === 'exception') {
|
||||||
closeProgressModal()
|
closeProgressModal()
|
||||||
Message.error('下载失败:' + data.error)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (prevInstalling && !installing) {
|
||||||
if (!data.success || !data.file_path) {
|
|
||||||
closeProgressModal()
|
|
||||||
Message.error('下载完成但数据不完整')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待安装完成
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
|
||||||
closeProgressModal()
|
closeProgressModal()
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
On('download-complete', onDownloadComplete)
|
try {
|
||||||
|
const result = await GetCurrentVersion()
|
||||||
|
if (result.success) {
|
||||||
|
backendVersion.value = result.data?.version || ''
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 后端未就绪时忽略,留空兜底
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
Off('download-complete')
|
|
||||||
closeModals()
|
closeModals()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -45,6 +45,30 @@
|
|||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</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
|
<a-alert
|
||||||
v-if="updateInfo"
|
v-if="updateInfo"
|
||||||
@@ -67,7 +91,7 @@
|
|||||||
<template #icon>
|
<template #icon>
|
||||||
<icon-download />
|
<icon-download />
|
||||||
</template>
|
</template>
|
||||||
{{ downloadProgress > 0 ? '下载中...' : '下载更新' }}
|
{{ downloadButtonText }}
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="downloadedFile"
|
v-if="downloadedFile"
|
||||||
@@ -89,7 +113,7 @@
|
|||||||
<div v-if="downloadProgress > 0 || downloading" class="download-progress">
|
<div v-if="downloadProgress > 0 || downloading" class="download-progress">
|
||||||
<a-progress
|
<a-progress
|
||||||
:percent="downloadProgress"
|
:percent="downloadProgress"
|
||||||
:status="downloadStatus"
|
:status="progressBarStatus"
|
||||||
/>
|
/>
|
||||||
<div class="progress-info">
|
<div class="progress-info">
|
||||||
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span>
|
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span>
|
||||||
@@ -113,7 +137,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { Message, Modal } from '@arco-design/web-vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { IconHistory } from '@arco-design/web-vue/es/icon'
|
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 { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions'
|
||||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||||
import { parseWailsEventData } from '@/utils/wailsEvent'
|
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||||
import { GetCurrentVersion, GetUpdateConfig, InstallUpdate } from '../wailsjs/v3-bindings/u-desk/app'
|
import { GetCurrentVersion, GetUpdateConfig, SetUpdateConfig, InstallUpdate } from '../wailsjs/v3-bindings/u-desk/app'
|
||||||
import { On, Off } from '@wailsio/events'
|
import { Events } from '@wailsio/runtime'
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
defineEmits(['open-version-history'])
|
defineEmits(['open-version-history'])
|
||||||
@@ -139,6 +163,26 @@ const lastCheckTime = ref('-')
|
|||||||
const installResult = ref(null)
|
const installResult = ref(null)
|
||||||
const downloadedFile = 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) */
|
/** 渲染 changelog(Markdown → HTML) */
|
||||||
function renderChangelog(text: string): string {
|
function renderChangelog(text: string): string {
|
||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
@@ -163,13 +207,39 @@ const loadConfig = async () => {
|
|||||||
const result = await GetUpdateConfig()
|
const result = await GetUpdateConfig()
|
||||||
if (!result.success) return
|
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
|
lastCheckTime.value = last_check_time
|
||||||
|
autoCheckEnabled.value = auto_check_enabled
|
||||||
|
checkIntervalMinutes.value = check_interval_minutes
|
||||||
|
checkURL.value = check_url
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载配置失败:', 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 () => {
|
const handleCheckUpdate = async () => {
|
||||||
installResult.value = null
|
installResult.value = null
|
||||||
@@ -241,11 +311,11 @@ onMounted(async () => {
|
|||||||
await loadConfig()
|
await loadConfig()
|
||||||
|
|
||||||
// 监听下载完成事件(仅用于记录文件路径)
|
// 监听下载完成事件(仅用于记录文件路径)
|
||||||
On('download-complete', onDownloadComplete)
|
Events.On('download-complete', onDownloadComplete)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
Off('download-complete')
|
Events.Off('download-complete')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -353,4 +423,24 @@ onUnmounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-text-3);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -17,14 +17,20 @@
|
|||||||
<!-- iframe 嵌入远程页面 -->
|
<!-- iframe 嵌入远程页面 -->
|
||||||
<div class="iframe-wrapper">
|
<div class="iframe-wrapper">
|
||||||
<iframe
|
<iframe
|
||||||
ref="iframeRef"
|
|
||||||
:src="iframeSrc"
|
:src="iframeSrc"
|
||||||
class="version-iframe"
|
class="version-iframe"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
@load="onIframeLoad"
|
@load="onIframeLoad"
|
||||||
/>
|
/>
|
||||||
<div v-if="loading" class="iframe-loading">
|
<div v-if="loadFailed" class="iframe-loading">
|
||||||
<a-spin size="32" />
|
<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>
|
<p>加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,33 +38,50 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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 BASE_URL = 'https://c.1216.top/u-desk/version.html'
|
||||||
|
const LOAD_TIMEOUT = 10000
|
||||||
|
|
||||||
// ==================== 状态 ====================
|
// ==================== 状态 ====================
|
||||||
const currentVersion = ref('')
|
const currentVersion = ref('')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const refreshing = ref(false)
|
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(() => {
|
const iframeSrc = computed(() => {
|
||||||
if (currentVersion.value) {
|
const version = currentVersion.value ? `v=${currentVersion.value}&` : ''
|
||||||
return `${BASE_URL}?v=${currentVersion.value}&vis=u-desk`
|
return `${BASE_URL}?${version}vis=u-desk&_t=${cacheBuster.value}`
|
||||||
}
|
|
||||||
return `${BASE_URL}?vis=u-desk`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== 操作 ====================
|
// ==================== 操作 ====================
|
||||||
|
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 {
|
function handleRefresh(): void {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
loading.value = true
|
loading.value = true
|
||||||
if (iframeRef.value) {
|
// 更新 cache-buster 触发 iframe 重载(赋相同值不会重新加载)
|
||||||
iframeRef.value.src = iframeSrc.value
|
cacheBuster.value = Date.now()
|
||||||
}
|
startLoadTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenExternal(): void {
|
function handleOpenExternal(): void {
|
||||||
@@ -66,21 +89,28 @@ function handleOpenExternal(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onIframeLoad(): void {
|
function onIframeLoad(): void {
|
||||||
|
clearLoadTimer()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
refreshing.value = false
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 生命周期 ====================
|
// ==================== 生命周期 ====================
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
startLoadTimer()
|
||||||
try {
|
try {
|
||||||
const result = await window.go?.main?.App?.GetCurrentVersion?.()
|
const result = await GetCurrentVersion()
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
currentVersion.value = result.data?.version || ''
|
currentVersion.value = result.data?.version || ''
|
||||||
|
startLoadTimer()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Wails 未就绪时忽略
|
// 后端未就绪时忽略,不带版本参数加载
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearLoadTimer()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
appConfig.value = {
|
appConfig.value = {
|
||||||
tabs: mergedTabs.map(tab => ({ ...tab, visible: mergedVisible.includes(tab.key) })),
|
tabs: mergedTabs.map(tab => ({ ...tab, visible: mergedVisible.includes(tab.key) })),
|
||||||
visibleTabs: mergedVisible,
|
visibleTabs: mergedVisible,
|
||||||
defaultTab: defaultTab || 'file-system',
|
defaultTab: allKeys.includes(defaultTab) ? defaultTab : 'file-system',
|
||||||
sidebarSections: Array.isArray(sidebarSections)
|
sidebarSections: Array.isArray(sidebarSections)
|
||||||
? sidebarSections.filter((s: string) => validSections.includes(s))
|
? sidebarSections.filter((s: string) => validSections.includes(s))
|
||||||
: defaultSidebar
|
: defaultSidebar
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import { Message } from '@arco-design/web-vue'
|
|||||||
import { formatBytes as formatFileSize } from '@/utils/fileUtils'
|
import { formatBytes as formatFileSize } from '@/utils/fileUtils'
|
||||||
import { parseWailsEventData } from '@/utils/wailsEvent'
|
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||||
import {
|
import {
|
||||||
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate
|
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate, InstallUpdateWithHash
|
||||||
} from '../wailsjs/v3-bindings/u-desk/app'
|
} from '../wailsjs/v3-bindings/u-desk/app'
|
||||||
import { On, Off } from '@wailsio/events'
|
import { On, Off } from '@wailsio/events'
|
||||||
|
|
||||||
|
// 本次应用运行内已自动弹过的版本(升级后自动恢复提醒)
|
||||||
|
let sessionNotifiedVersion = ''
|
||||||
|
// 下载完成事件携带的远端声明哈希,安装时用于完整性校验
|
||||||
|
let lastExpectedSha256 = ''
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新管理 Store
|
* 更新管理 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 silent 是否静默模式(不显示消息)
|
||||||
|
* @param manual 是否手动触发(跳过 auto_check_enabled 门禁)
|
||||||
|
* @returns 检查是否实际完成(false=后端未就绪或网络失败,调用方可重试)
|
||||||
*/
|
*/
|
||||||
const checkForUpdates = async (silent = false) => {
|
const checkForUpdates = async (silent = false, manual = false): Promise<boolean> => {
|
||||||
if (checking.value) return
|
if (checking.value) return true
|
||||||
|
|
||||||
checking.value = true
|
checking.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configResult = await GetUpdateConfig()
|
const configResult = await GetUpdateConfig()
|
||||||
if (!configResult.success) return
|
if (!configResult.success) return false
|
||||||
|
|
||||||
const { auto_check_enabled } = configResult.data || {}
|
const { auto_check_enabled } = configResult.data || {}
|
||||||
if (!auto_check_enabled) return
|
if (!manual && !auto_check_enabled) return true
|
||||||
|
|
||||||
const result = await CheckUpdate()
|
const result = await CheckUpdate()
|
||||||
if (result.success && result.data?.has_update) {
|
if (result.success && result.data?.has_update) {
|
||||||
updateInfo.value = result.data
|
tryShowUpdate(result.data, !silent)
|
||||||
showUpdate.value = true
|
|
||||||
|
|
||||||
// 系统通知:发现新版本
|
|
||||||
try {
|
|
||||||
window.runtime?.SendNotification?.({
|
|
||||||
title: 'U-Desk',
|
|
||||||
body: `发现新版本 ${result.data.latest_version},点击查看`
|
|
||||||
})
|
|
||||||
} catch { /* 通知不可用时忽略 */ }
|
|
||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
Message.success('发现新版本!')
|
Message.success('发现新版本!')
|
||||||
@@ -76,11 +112,13 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
} else if (!silent) {
|
} else if (!silent) {
|
||||||
Message.success('已是最新版本')
|
Message.success('已是最新版本')
|
||||||
}
|
}
|
||||||
|
return result.success === true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
console.error('检查更新失败:', error)
|
console.error('检查更新失败:', error)
|
||||||
Message.error('检查更新失败:' + (error as Error).message)
|
Message.error('检查更新失败:' + (error as Error).message)
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
checking.value = false
|
checking.value = false
|
||||||
}
|
}
|
||||||
@@ -134,7 +172,10 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
installing.value = true
|
installing.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await InstallUpdate(filePath, true)
|
// 优先带哈希校验安装(期望哈希来自下载完成事件的远端声明)
|
||||||
|
const result = lastExpectedSha256
|
||||||
|
? await InstallUpdateWithHash(filePath, true, lastExpectedSha256, 'sha256')
|
||||||
|
: await InstallUpdate(filePath, true)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
Message.success({
|
Message.success({
|
||||||
content: '安装成功!应用将在几秒后重启...',
|
content: '安装成功!应用将在几秒后重启...',
|
||||||
@@ -200,6 +241,8 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
// 完成下载
|
// 完成下载
|
||||||
downloadProgress.value = 100
|
downloadProgress.value = 100
|
||||||
downloadStatus.value = 'success'
|
downloadStatus.value = 'success'
|
||||||
|
// 记录远端声明的期望哈希,安装时用于校验
|
||||||
|
lastExpectedSha256 = String(data.expected_sha256 || '')
|
||||||
|
|
||||||
const fileSize = (data.file_size as number) || 0
|
const fileSize = (data.file_size as number) || 0
|
||||||
|
|
||||||
@@ -227,12 +270,24 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
}, remainingTime)
|
}, remainingTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端广播"发现新版本"处理(启动即查与周期检查命中时后端都会发)
|
||||||
|
*/
|
||||||
|
const onUpdateAvailable = (event: unknown) => {
|
||||||
|
const data = parseWailsEventData(event) as UpdateInfo
|
||||||
|
if (!data?.latest_version) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tryShowUpdate(data)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置事件监听
|
* 设置事件监听
|
||||||
*/
|
*/
|
||||||
const setupEventListeners = () => {
|
const setupEventListeners = () => {
|
||||||
On('download-progress', onDownloadProgress)
|
On('download-progress', onDownloadProgress)
|
||||||
On('download-complete', onDownloadComplete)
|
On('download-complete', onDownloadComplete)
|
||||||
|
On('update-available', onUpdateAvailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -241,6 +296,7 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
const removeEventListeners = () => {
|
const removeEventListeners = () => {
|
||||||
Off('download-progress')
|
Off('download-progress')
|
||||||
Off('download-complete')
|
Off('download-complete')
|
||||||
|
Off('update-available')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 返回 ====================
|
// ==================== 返回 ====================
|
||||||
@@ -259,6 +315,7 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
downloadUpdate,
|
downloadUpdate,
|
||||||
installUpdate,
|
installUpdate,
|
||||||
|
skipVersion,
|
||||||
setupEventListeners,
|
setupEventListeners,
|
||||||
removeEventListeners,
|
removeEventListeners,
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface FavoriteFile extends FileItem {
|
|||||||
pinnedAt?: number
|
pinnedAt?: number
|
||||||
/** 关联的连接配置 ID,用于打开收藏时自动切换连接 */
|
/** 关联的连接配置 ID,用于打开收藏时自动切换连接 */
|
||||||
profileId?: string
|
profileId?: string
|
||||||
|
/** 展示名,空串或缺省表示未设置(显示时回退原名) */
|
||||||
|
displayName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const STORAGE_KEYS = {
|
|||||||
PATH_HISTORY: 'app-filesystem-path-history',
|
PATH_HISTORY: 'app-filesystem-path-history',
|
||||||
FILE_CONTENT_HEIGHT: 'app-filesystem-file-content-height',
|
FILE_CONTENT_HEIGHT: 'app-filesystem-file-content-height',
|
||||||
PANEL_WIDTH: 'app-filesystem-panel-width',
|
PANEL_WIDTH: 'app-filesystem-panel-width',
|
||||||
|
SIDEBAR_WIDTH: 'app-filesystem-sidebar-width',
|
||||||
SIDEBAR_VISIBLE: 'app-filesystem-sidebar-visible',
|
SIDEBAR_VISIBLE: 'app-filesystem-sidebar-visible',
|
||||||
FAVORITE_FILES: 'app-filesystem-favorite-files',
|
FAVORITE_FILES: 'app-filesystem-favorite-files',
|
||||||
EDIT_MODE: 'app-filesystem-edit-mode', // HTML/Markdown 编辑模式状态
|
EDIT_MODE: 'app-filesystem-edit-mode', // HTML/Markdown 编辑模式状态
|
||||||
@@ -334,8 +335,12 @@ export const DEFAULTS = {
|
|||||||
MAX_PANEL_WIDTH: 80,
|
MAX_PANEL_WIDTH: 80,
|
||||||
DEFAULT_PANEL_WIDTH: 50,
|
DEFAULT_PANEL_WIDTH: 50,
|
||||||
|
|
||||||
// 侧边栏宽度(px)
|
// 侧边栏默认宽度(px)
|
||||||
SIDEBAR_WIDTH: 220,
|
SIDEBAR_WIDTH: 220,
|
||||||
|
|
||||||
|
// 侧边栏宽度范围(px)
|
||||||
|
MIN_SIDEBAR_WIDTH: 180,
|
||||||
|
MAX_SIDEBAR_WIDTH: 420,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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";
|
import * as application$0 from "../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore: Unused imports
|
// @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";
|
import * as filesystem$0 from "./internal/filesystem/models.js";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
@@ -123,6 +126,25 @@ export function EmptyRecycleBin(): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(4176312624);
|
return $Call.ByID(4176312624);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EnsureDrawioViewer 确保本地已缓存 drawio viewer(首次预览时下载),前端打开 .drawio 前调用。
|
||||||
|
* 通过 drawio-download-progress 事件推送下载进度。
|
||||||
|
*/
|
||||||
|
export function EnsureDrawioViewer(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||||
|
return $Call.ByID(1345697171).then(($result: any) => {
|
||||||
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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文件
|
* 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 获取当前版本号
|
* GetCurrentVersion 获取当前版本号
|
||||||
*/
|
*/
|
||||||
@@ -335,7 +348,7 @@ export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
|
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除),跨盘时经 transfer-progress 事件推送进度
|
||||||
*/
|
*/
|
||||||
export function MovePath(src: string, dst: string): $CancellablePromise<void> {
|
export function MovePath(src: string, dst: string): $CancellablePromise<void> {
|
||||||
return $Call.ByID(2028899675, src, dst);
|
return $Call.ByID(2028899675, src, dst);
|
||||||
@@ -348,6 +361,9 @@ export function OpenPath(path: string): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(1591734570, path);
|
return $Call.ByID(1591734570, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OssConnect 连接 OSS 存储
|
||||||
|
*/
|
||||||
export function OssConnect(req: $models.OssConnectRequest): $CancellablePromise<string> {
|
export function OssConnect(req: $models.OssConnectRequest): $CancellablePromise<string> {
|
||||||
return $Call.ByID(3667022538, req);
|
return $Call.ByID(3667022538, req);
|
||||||
}
|
}
|
||||||
@@ -478,6 +494,16 @@ export function PickDirectory(): $CancellablePromise<string> {
|
|||||||
return $Call.ByID(1347829059);
|
return $Call.ByID(1347829059);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReadClipboardFiles 读取系统剪贴板中的文件/文件夹路径列表。
|
||||||
|
* 剪贴板为文本/截图等非文件内容时返回空列表,前端据此回落图片粘贴等原有链路。
|
||||||
|
*/
|
||||||
|
export function ReadClipboardFiles(): $CancellablePromise<string[]> {
|
||||||
|
return $Call.ByID(2636780132).then(($result: any) => {
|
||||||
|
return $$createType7($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReadFile 读取文件
|
* ReadFile 读取文件
|
||||||
*/
|
*/
|
||||||
@@ -526,8 +552,9 @@ export function RestoreFromRecycleBin(recyclePath: string): $CancellablePromise<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* SaveAppConfig 保存应用配置
|
* 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 $Call.ByID(1942219977, req).then(($result: any) => {
|
||||||
return $$createType2($result);
|
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 在创建窗口后调用)
|
* SetMainWindow 设置主窗口引用(由 main.go 在创建窗口后调用)
|
||||||
*/
|
*/
|
||||||
@@ -624,6 +644,13 @@ export function SftpDownloadSiteForPreview(connID: string, remotePath: string):
|
|||||||
return $Call.ByID(1591575570, connID, remotePath);
|
return $Call.ByID(1591575570, connID, remotePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpDownloadToFile 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径
|
||||||
|
*/
|
||||||
|
export function SftpDownloadToFile(connID: string, remotePath: string, localDstDir: string): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(2935422351, connID, remotePath, localDstDir);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
* SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
||||||
*/
|
*/
|
||||||
@@ -638,20 +665,6 @@ export function SftpDownloadToTempCached(connID: string, remotePath: string, fil
|
|||||||
return $Call.ByID(3935472409, connID, remotePath, fileSize, modTime);
|
return $Call.ByID(3935472409, connID, remotePath, fileSize, modTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* SftpDownloadToFile 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本)
|
|
||||||
*/
|
|
||||||
export function SftpDownloadToFile(connID: string, remotePath: string, localDstDir: string): $CancellablePromise<string> {
|
|
||||||
return $Call.ByID(2935422351, connID, remotePath, localDstDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SftpUploadFromFile 上传本地文件/目录到远程目录(流式,目录递归,重名自动副本)
|
|
||||||
*/
|
|
||||||
export function SftpUploadFromFile(connID: string, localPath: string, remoteDir: string): $CancellablePromise<void> {
|
|
||||||
return $Call.ByID(3304594335, connID, localPath, remoteDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SftpGetCommonPaths 获取 SFTP 远程主机常用路径
|
* SftpGetCommonPaths 获取 SFTP 远程主机常用路径
|
||||||
*/
|
*/
|
||||||
@@ -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 编码的二进制文件(粘贴图片等)
|
* SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等)
|
||||||
*/
|
*/
|
||||||
@@ -791,3 +811,4 @@ const $$createType3 = filesystem$0.FileOperationResult.createFrom;
|
|||||||
const $$createType4 = $Create.Nullable($$createType3);
|
const $$createType4 = $Create.Nullable($$createType3);
|
||||||
const $$createType5 = $Create.Array($$createType2);
|
const $$createType5 = $Create.Array($$createType2);
|
||||||
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
|
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
|
||||||
|
const $$createType7 = $Create.Array($Create.Any);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export {
|
|||||||
OssConnectRequest,
|
OssConnectRequest,
|
||||||
OssRenamePathRequest,
|
OssRenamePathRequest,
|
||||||
RenamePathRequest,
|
RenamePathRequest,
|
||||||
SaveAppConfigRequest,
|
|
||||||
SaveBase64FileRequest,
|
SaveBase64FileRequest,
|
||||||
SaveProfileRequest,
|
SaveProfileRequest,
|
||||||
SftpConnectRequest,
|
SftpConnectRequest,
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AppTabDefinition
|
AppTabDefinition,
|
||||||
|
SaveAppConfigRequest
|
||||||
} from "./models.js";
|
} from "./models.js";
|
||||||
|
|||||||
@@ -40,3 +40,56 @@ export class AppTabDefinition {
|
|||||||
return new AppTabDefinition($$parsedSource as Partial<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
|
// @ts-ignore: Unused imports
|
||||||
import { Create as $Create } from "@wailsio/runtime";
|
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 播放列表条目
|
* 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 编码的二进制文件
|
* SaveBase64FileRequest 保存 Base64 编码的二进制文件
|
||||||
*/
|
*/
|
||||||
@@ -422,8 +378,3 @@ export class WriteFileRequest {
|
|||||||
return new WriteFileRequest($$parsedSource as Partial<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);
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type GetAppConfigResponse struct {
|
|||||||
Tabs []AppTabDefinition `json:"tabs"`
|
Tabs []AppTabDefinition `json:"tabs"`
|
||||||
VisibleTabs []string `json:"visibleTabs"`
|
VisibleTabs []string `json:"visibleTabs"`
|
||||||
DefaultTab string `json:"defaultTab"`
|
DefaultTab string `json:"defaultTab"`
|
||||||
|
SidebarSections []string `json:"sidebarSections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppTabDefinition 应用 Tab 定义(前端格式)
|
// AppTabDefinition 应用 Tab 定义(前端格式)
|
||||||
@@ -42,6 +43,7 @@ type SaveAppConfigRequest struct {
|
|||||||
Tabs []AppTabDefinition `json:"tabs"`
|
Tabs []AppTabDefinition `json:"tabs"`
|
||||||
VisibleTabs []string `json:"visibleTabs"`
|
VisibleTabs []string `json:"visibleTabs"`
|
||||||
DefaultTab string `json:"defaultTab"`
|
DefaultTab string `json:"defaultTab"`
|
||||||
|
SidebarSections []string `json:"sidebarSections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAppConfig 获取应用配置
|
// GetAppConfig 获取应用配置
|
||||||
@@ -76,6 +78,7 @@ func (api *ConfigAPI) GetAppConfig() (map[string]interface{}, error) {
|
|||||||
Tabs: tabs,
|
Tabs: tabs,
|
||||||
VisibleTabs: tabConfig.VisibleTabs,
|
VisibleTabs: tabConfig.VisibleTabs,
|
||||||
DefaultTab: tabConfig.DefaultTab,
|
DefaultTab: tabConfig.DefaultTab,
|
||||||
|
SidebarSections: tabConfig.SidebarSections,
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -119,6 +122,7 @@ func (api *ConfigAPI) SaveAppConfig(req SaveAppConfigRequest) (map[string]interf
|
|||||||
AvailableTabs: availableTabs,
|
AvailableTabs: availableTabs,
|
||||||
VisibleTabs: req.VisibleTabs,
|
VisibleTabs: req.VisibleTabs,
|
||||||
DefaultTab: req.DefaultTab,
|
DefaultTab: req.DefaultTab,
|
||||||
|
SidebarSections: req.SidebarSections,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存配置
|
// 保存配置
|
||||||
@@ -204,5 +208,6 @@ func (api *ConfigAPI) MigrateTabConfig() error {
|
|||||||
AvailableTabs: newTabs,
|
AvailableTabs: newTabs,
|
||||||
VisibleTabs: newVisible,
|
VisibleTabs: newVisible,
|
||||||
DefaultTab: defaultTab,
|
DefaultTab: defaultTab,
|
||||||
|
SidebarSections: config.SidebarSections,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+230
-30
@@ -4,12 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
|
"github.com/chromedp/cdproto/runtime"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
"u-desk/internal/common"
|
"u-desk/internal/common"
|
||||||
@@ -70,7 +73,7 @@ func (api *PdfAPI) ExportMarkdownToPDF(req PdfExportRequest) (*PdfExportResponse
|
|||||||
htmlContent := api.markdownToHTML(req.Content, req.Title, req.FontSize)
|
htmlContent := api.markdownToHTML(req.Content, req.Title, req.FontSize)
|
||||||
|
|
||||||
// 使用chromedp生成PDF
|
// 使用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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("生成PDF失败: %v", err)
|
return nil, fmt.Errorf("生成PDF失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -261,7 +264,7 @@ func (api *PdfAPI) markdownToHTML(markdownContent string, title string, fontSize
|
|||||||
}
|
}
|
||||||
|
|
||||||
// generatePDFFromHTML 使用chromedp从HTML生成PDF
|
// 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)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
// 配置chromedp选项
|
// 配置chromedp选项
|
||||||
@@ -275,22 +278,12 @@ func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pag
|
|||||||
chromedp.Flag("disable-notifications", true),
|
chromedp.Flag("disable-notifications", true),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在Windows上设置Chrome路径
|
// 浏览器路径统一走 findChromeExecutable,Chrome 与 Edge 任一可用即可
|
||||||
if common.IsWindows() {
|
browserPath, err := findChromeExecutable()
|
||||||
// 常见的Windows Chrome路径
|
if err != nil {
|
||||||
chromePaths := []string{
|
return nil, err
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
opts = append(opts, chromedp.ExecPath(browserPath))
|
||||||
|
|
||||||
// 创建执行分配器上下文
|
// 创建执行分配器上下文
|
||||||
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
|
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
|
||||||
@@ -315,10 +308,11 @@ func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pag
|
|||||||
|
|
||||||
var buf []byte
|
var buf []byte
|
||||||
|
|
||||||
// 使用 file URL 加载本地HTML文件
|
// file URL 需整体转义,与 generatePDFFromLocalFile 同姿势,中文/空格临时路径才能正确导航
|
||||||
|
fileURL := (&url.URL{Scheme: "file", Path: filepath.ToSlash(htmlFile)}).String()
|
||||||
|
|
||||||
err = chromedp.Run(chromeCtx,
|
err = chromedp.Run(chromeCtx,
|
||||||
// 导航到HTML文件
|
chromedp.Navigate(fileURL),
|
||||||
chromedp.Navigate("file://"+htmlFile),
|
|
||||||
// 等待页面加载完成
|
// 等待页面加载完成
|
||||||
chromedp.WaitReady("body"),
|
chromedp.WaitReady("body"),
|
||||||
// 打印到PDF
|
// 打印到PDF
|
||||||
@@ -345,6 +339,222 @@ func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pag
|
|||||||
return nil, fmt.Errorf("chromedp执行失败: %v", err)
|
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
|
return buf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,13 +577,3 @@ func (api *PdfAPI) getDesktopDirectory() string {
|
|||||||
// 备用:当前目录
|
// 备用:当前目录
|
||||||
return "."
|
return "."
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelectDirectory 选择保存目录(简化版,实际应该使用Wails runtime)
|
|
||||||
func (api *PdfAPI) SelectDirectory() (string, error) {
|
|
||||||
// 简化版:直接返回桌面目录
|
|
||||||
desktop := api.getDesktopDirectory()
|
|
||||||
if desktop == "." {
|
|
||||||
return "", fmt.Errorf("无法确定默认目录")
|
|
||||||
}
|
|
||||||
return desktop, nil
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"u-desk/internal/service"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateAPI 版本更新 API
|
// UpdateAPI 版本更新 API
|
||||||
@@ -11,6 +13,9 @@ type UpdateAPI struct {
|
|||||||
updateService *service.UpdateService
|
updateService *service.UpdateService
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
eventEmitter func(name string, data ...any)
|
eventEmitter func(name string, data ...any)
|
||||||
|
configChanged func() // 更新配置变更回调(重置自动检查定时器)
|
||||||
|
expectedSHA256 string // 最近一次检查得到的远端包 SHA256
|
||||||
|
downloading atomic.Bool // 下载进行中标志(并发互斥)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUpdateAPI 创建版本更新 API
|
// NewUpdateAPI 创建版本更新 API
|
||||||
@@ -25,6 +30,11 @@ func (api *UpdateAPI) SetEventEmitter(emitter func(name string, data ...any)) {
|
|||||||
api.eventEmitter = emitter
|
api.eventEmitter = emitter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetConfigChangedCallback 设置配置变更回调(由 App 注入,用于重置自动检查定时器)
|
||||||
|
func (api *UpdateAPI) SetConfigChangedCallback(cb func()) {
|
||||||
|
api.configChanged = cb
|
||||||
|
}
|
||||||
|
|
||||||
// SetContext 设置上下文(用于事件推送)
|
// SetContext 设置上下文(用于事件推送)
|
||||||
func (api *UpdateAPI) SetContext(ctx context.Context) {
|
func (api *UpdateAPI) SetContext(ctx context.Context) {
|
||||||
api.ctx = ctx
|
api.ctx = ctx
|
||||||
@@ -54,6 +64,9 @@ func (api *UpdateAPI) CheckUpdate() (map[string]interface{}, error) {
|
|||||||
return errorResponse(err.Error()), nil
|
return errorResponse(err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录远端包哈希,供下载完成事件比对
|
||||||
|
api.expectedSHA256 = result.SHA256
|
||||||
|
|
||||||
return successResponse(result), nil
|
return successResponse(result), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +112,11 @@ func (api *UpdateAPI) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinute
|
|||||||
return errorResponse(err.Error()), nil
|
return errorResponse(err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 配置已变更,通知重置自动检查定时器(免重启生效)
|
||||||
|
if api.configChanged != nil {
|
||||||
|
api.configChanged()
|
||||||
|
}
|
||||||
|
|
||||||
return successResponse(map[string]interface{}{
|
return successResponse(map[string]interface{}{
|
||||||
"message": "配置保存成功",
|
"message": "配置保存成功",
|
||||||
}), nil
|
}), nil
|
||||||
@@ -110,7 +128,14 @@ func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}
|
|||||||
return errorResponse("下载地址不能为空"), nil
|
return errorResponse("下载地址不能为空"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 并发互斥:已有下载任务进行中则拒绝
|
||||||
|
if !api.downloading.CompareAndSwap(false, true) {
|
||||||
|
return errorResponse("已有下载任务进行中"), nil
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
defer api.downloading.Store(false)
|
||||||
|
|
||||||
progressCallback := func(progress float64, speed float64, downloaded int64, total int64) {
|
progressCallback := func(progress float64, speed float64, downloaded int64, total int64) {
|
||||||
progressInfo := map[string]interface{}{
|
progressInfo := map[string]interface{}{
|
||||||
"progress": progress,
|
"progress": progress,
|
||||||
@@ -132,6 +157,8 @@ func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}
|
|||||||
"success": true,
|
"success": true,
|
||||||
"file_path": result.FilePath,
|
"file_path": result.FilePath,
|
||||||
"file_size": result.FileSize,
|
"file_size": result.FileSize,
|
||||||
|
"sha256": result.SHA256Hash,
|
||||||
|
"expected_sha256": api.expectedSHA256,
|
||||||
}
|
}
|
||||||
api.emit("download-complete", resultInfo)
|
api.emit("download-complete", resultInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ type TabConfig struct {
|
|||||||
AvailableTabs []TabDefinition `json:"available_tabs"`
|
AvailableTabs []TabDefinition `json:"available_tabs"`
|
||||||
VisibleTabs []string `json:"visible_tabs"`
|
VisibleTabs []string `json:"visible_tabs"`
|
||||||
DefaultTab string `json:"default_tab"`
|
DefaultTab string `json:"default_tab"`
|
||||||
|
SidebarSections []string `json:"sidebar_sections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 默认侧边栏分区
|
||||||
|
var defaultSidebarSections = []string{"server", "favorites", "help"}
|
||||||
|
|
||||||
// 默认 Tab 配置
|
// 默认 Tab 配置
|
||||||
var defaultTabConfig = TabConfig{
|
var defaultTabConfig = TabConfig{
|
||||||
AvailableTabs: []TabDefinition{
|
AvailableTabs: []TabDefinition{
|
||||||
@@ -47,6 +51,7 @@ var defaultTabConfig = TabConfig{
|
|||||||
},
|
},
|
||||||
VisibleTabs: []string{"file-system", "markdown-editor", "version"},
|
VisibleTabs: []string{"file-system", "markdown-editor", "version"},
|
||||||
DefaultTab: "file-system",
|
DefaultTab: "file-system",
|
||||||
|
SidebarSections: defaultSidebarSections,
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -79,6 +84,11 @@ func (s *ConfigService) GetTabConfig() (*TabConfig, error) {
|
|||||||
return &defaultTabConfig, nil
|
return &defaultTabConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 侧边栏分区为空时回退默认值(兼容旧配置)
|
||||||
|
if len(tabConfig.SidebarSections) == 0 {
|
||||||
|
tabConfig.SidebarSections = defaultSidebarSections
|
||||||
|
}
|
||||||
|
|
||||||
return &tabConfig, nil
|
return &tabConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type RemoteVersionInfo struct {
|
|||||||
ForceUpdate bool `json:"force_update"`
|
ForceUpdate bool `json:"force_update"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
FileSize int64 `json:"file_size"`
|
FileSize int64 `json:"file_size"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCheckResult 更新检查结果
|
// UpdateCheckResult 更新检查结果
|
||||||
@@ -42,6 +43,7 @@ type UpdateCheckResult struct {
|
|||||||
ForceUpdate bool `json:"force_update"`
|
ForceUpdate bool `json:"force_update"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
FileSize int64 `json:"file_size"`
|
FileSize int64 `json:"file_size"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstallResult 安装结果
|
// InstallResult 安装结果
|
||||||
@@ -99,6 +101,7 @@ func (s *UpdateService) CheckUpdate() (*UpdateCheckResult, error) {
|
|||||||
ForceUpdate: remoteInfo.ForceUpdate,
|
ForceUpdate: remoteInfo.ForceUpdate,
|
||||||
ReleaseDate: remoteInfo.ReleaseDate,
|
ReleaseDate: remoteInfo.ReleaseDate,
|
||||||
FileSize: remoteInfo.FileSize,
|
FileSize: remoteInfo.FileSize,
|
||||||
|
SHA256: remoteInfo.SHA256,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"u-desk/internal/common"
|
"u-desk/internal/common"
|
||||||
@@ -41,9 +42,13 @@ func DownloadUpdate(downloadURL string, progressCallback DownloadProgress) (*Dow
|
|||||||
return nil, fmt.Errorf("创建下载目录失败: %v", err)
|
return nil, fmt.Errorf("创建下载目录失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 URL 提取文件名
|
// 从 URL 提取文件名(去除 query 参数与 # 片段)
|
||||||
filename := filepath.Base(downloadURL)
|
cleanURL := downloadURL
|
||||||
if filename == "" || filename == "." {
|
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())
|
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)
|
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
|
contentLength := resp.ContentLength
|
||||||
gotTotalFromRange := false
|
gotTotalFromRange := false
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
// ==================== 常量定义 ====================
|
// ==================== 常量定义 ====================
|
||||||
|
|
||||||
// AppVersion 应用版本号(发布时直接修改此处)
|
// AppVersion 应用版本号(发布时直接修改此处)
|
||||||
const AppVersion = "0.4.2"
|
const AppVersion = "0.5.0"
|
||||||
|
|
||||||
// 版本号缓存
|
// 版本号缓存
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -157,7 +158,8 @@ func buildClient(config *Config) (*Client, error) {
|
|||||||
return nil, &ConnectionError{Op: "auth", Err: fmt.Errorf("必须提供密码或密钥文件")}
|
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 掐断
|
// 拨号与认证、SFTP 子系统握手共享同一总超时预算,认证阶段无界挂起由 deadline 掐断
|
||||||
deadline := time.Now().Add(connectTimeout)
|
deadline := time.Now().Add(connectTimeout)
|
||||||
sshConn, err := net.DialTimeout("tcp", addr, connectTimeout)
|
sshConn, err := net.DialTimeout("tcp", addr, connectTimeout)
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ func main() {
|
|||||||
app.HandleHotkey()
|
app.HandleHotkey()
|
||||||
return 0, true
|
return 0, true
|
||||||
}
|
}
|
||||||
|
case wmSizeMsg:
|
||||||
|
// webview 尺寸失同步兜底(app_window_fix.go)
|
||||||
|
go fixWebviewFill(hwnd)
|
||||||
}
|
}
|
||||||
return 0, false
|
return 0, false
|
||||||
},
|
},
|
||||||
@@ -105,6 +108,9 @@ func main() {
|
|||||||
|
|
||||||
app.SetMainWindow(window)
|
app.SetMainWindow(window)
|
||||||
|
|
||||||
|
// webview 与窗口尺寸失同步的周期兜底(app_window_fix.go)
|
||||||
|
StartWebviewFillWatch()
|
||||||
|
|
||||||
// 外部文件拖入窗口(OS 资源管理器拖放):把本地路径转发给前端处理
|
// 外部文件拖入窗口(OS 资源管理器拖放):把本地路径转发给前端处理
|
||||||
// (本机面板=复制进当前目录,SFTP 面板=上传)
|
// (本机面板=复制进当前目录,SFTP 面板=上传)
|
||||||
window.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
|
window.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "u-desk",
|
"name": "u-desk",
|
||||||
"outputfilename": "u-desk",
|
"outputfilename": "u-desk",
|
||||||
"version": "0.4.2",
|
"version": "0.5.0",
|
||||||
"frontend:install": "npm install",
|
"frontend:install": "npm install",
|
||||||
"frontend:build": "npm run build",
|
"frontend:build": "npm run build",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
Reference in New Issue
Block a user