修复: 粘贴图失效与SFTP串台回退等一批问题
粘贴图根修(Ctrl+V直读系统剪贴板,不再依赖不可靠的paste事件)/SFTP串台双刀(拨号期禁回退本机+盘符路径自动回根)/PDF导出目录设置与文案带落盘路径/性能批(连接保存下载防重/md渲染防抖+预热/hljs按需加载/审计锁外置/快捷方式解析超时/webview-watch可停)/前端62类型错清零(含keyPath蛇形字段炸弹)/目录图标字段错位修复
This commit is contained in:
@@ -9,3 +9,7 @@ build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||
u-desk.exe
|
||||
u-fs-agent-linux
|
||||
docs/08-用户指南/u-desk-site/
|
||||
|
||||
temp/
|
||||
frontend/temp/
|
||||
build/publish/*.exe
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"u-desk/internal/api"
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/filewatch"
|
||||
"u-desk/internal/filesystem"
|
||||
"u-desk/internal/hotkey"
|
||||
@@ -134,7 +135,7 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
|
||||
slog.Info("可用的模块", "modules", visibleTabs)
|
||||
|
||||
// 非关键路径并行初始化(PdfAPI/Filesystem/Cleanup 不阻塞首屏渲染)
|
||||
go func() {
|
||||
common.Go("init-modules", func() {
|
||||
// 2.6. 初始化PDF导出API
|
||||
slog.Info("初始化PDF导出模块")
|
||||
if pdfAPI, err := api.NewPdfAPI(); err != nil {
|
||||
@@ -154,10 +155,10 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
|
||||
// 清理过期的下载缓存
|
||||
storage.CleanupExpiredCache()
|
||||
storage.CleanupSiteCacheDirs()
|
||||
}()
|
||||
})
|
||||
|
||||
// 6. 异步初始化:UpdateAPI(涉及网络请求,完全异步)
|
||||
go func() {
|
||||
common.Go("init-update-api", func() {
|
||||
checkURL := "https://c.1216.top/last-version.json"
|
||||
if cfg, err := service.LoadUpdateConfig(); err == nil && cfg.CheckURL != "" {
|
||||
checkURL = cfg.CheckURL
|
||||
@@ -176,12 +177,12 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
|
||||
a.updateAPI.SetConfigChangedCallback(a.resetAutoUpdateCheck)
|
||||
a.startAutoUpdateCheck()
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
// 延迟注册全局热键(轮询等待原生窗口创建完成)
|
||||
// RegisterHotKey 必须在创建窗口的同一线程调用,
|
||||
// 通过 PostMessage 将注册请求投递到主线程消息循环
|
||||
go func() {
|
||||
common.Go("register-hotkey", func() {
|
||||
delays := []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond}
|
||||
for i := 0; i < 15; i++ {
|
||||
time.Sleep(delays[min(i, len(delays)-1)])
|
||||
@@ -195,7 +196,7 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
|
||||
}
|
||||
}
|
||||
slog.Warn("等待窗口超时")
|
||||
}()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/image/bmp"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// binaryLE DIB/BMP 头字段均为小端
|
||||
var binaryLE = binary.LittleEndian
|
||||
|
||||
var (
|
||||
procRegisterClipboardFormatW = windows.NewLazySystemDLL("user32.dll").NewProc("RegisterClipboardFormatW")
|
||||
procGlobalLockImg = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalLock")
|
||||
procGlobalUnlockImg = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalUnlock")
|
||||
procGlobalSizeImg = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalSize")
|
||||
)
|
||||
|
||||
// cfDIB 剪贴板设备无关位图格式,Win32 稳定 ABI 值
|
||||
const cfDIB = 8
|
||||
|
||||
// pngFormatAtom "PNG" 注册格式的剪贴板原子号(系统级稳定,进程内缓存一次)
|
||||
var pngFormatAtom uintptr
|
||||
|
||||
// ReadClipboardImage 读取系统剪贴板中的图片,返回 PNG 的 base64(无图返回空串+nil)。
|
||||
// 优先取 "PNG" 注册格式(截图工具/浏览器复制时放的就是完整 PNG 文件字节),
|
||||
// 否则回落 CF_DIB:补 14 字节 BMP 文件头后交 x/image/bmp 解码再重编为 PNG。
|
||||
// 供前端 Ctrl+V keydown 直读(WebView2 无编辑焦点时 DOM paste 事件不可靠派发)。
|
||||
func (a *App) ReadClipboardImage() (string, error) {
|
||||
// OpenClipboard 与 CloseClipboard 必须同线程,否则剪贴板死锁
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
if pngFormatAtom == 0 {
|
||||
name, err := syscall.UTF16PtrFromString("PNG")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if atom, _, _ := procRegisterClipboardFormatW.Call(uintptr(unsafe.Pointer(name))); atom != 0 {
|
||||
pngFormatAtom = atom
|
||||
}
|
||||
}
|
||||
|
||||
// 剪贴板是全局互斥资源,短重试避开其他进程的瞬时占用
|
||||
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 "", syscall.EBUSY
|
||||
}
|
||||
defer procCloseClipboard.Call()
|
||||
|
||||
if pngFormatAtom != 0 {
|
||||
if r, _, _ := procIsClipboardFormatAvailable.Call(pngFormatAtom); r != 0 {
|
||||
data, ok, err := clipboardFormatData(pngFormatAtom)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ok && len(data) > 0 {
|
||||
return base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r, _, _ := procIsClipboardFormatAvailable.Call(cfDIB); r == 0 {
|
||||
return "", nil
|
||||
}
|
||||
data, ok, err := clipboardFormatData(cfDIB)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok || len(data) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
pngBytes, err := dibToPNG(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(pngBytes), nil
|
||||
}
|
||||
|
||||
// clipboardFormatData 取指定格式的剪贴板字节(调用方须已 OpenClipboard)
|
||||
func clipboardFormatData(format uintptr) ([]byte, bool, error) {
|
||||
h, _, _ := procGetClipboardData.Call(format)
|
||||
if h == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
p, _, _ := procGlobalLockImg.Call(h)
|
||||
if p == 0 {
|
||||
return nil, false, syscall.EBUSY
|
||||
}
|
||||
defer procGlobalUnlockImg.Call(h)
|
||||
|
||||
size, _, _ := procGlobalSizeImg.Call(h)
|
||||
if size == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
data := make([]byte, size)
|
||||
copy(data, unsafe.Slice((*byte)(unsafe.Pointer(p)), size))
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
// dibToPNG 把 CF_DIB(BITMAPINFO 起、无 14 字节文件头)转为 PNG 字节。
|
||||
// 覆盖 24/32bpp 的 BI_RGB(0) 与 BI_BITFIELDS(3) 常见情形:DIB 原样透传,
|
||||
// 仅补 BMP 文件头后交给 x/image/bmp 解码(行字节对齐、bottom-up、负高度=top-down 均由解码器处理)
|
||||
func dibToPNG(dib []byte) ([]byte, error) {
|
||||
if len(dib) < 40 {
|
||||
return nil, fmt.Errorf("DIB 数据过短: %d 字节", len(dib))
|
||||
}
|
||||
headerSize := binaryLE.Uint32(dib[0:4])
|
||||
bpp := binaryLE.Uint16(dib[14:16])
|
||||
compression := binaryLE.Uint32(dib[16:20])
|
||||
|
||||
if headerSize < 40 || int(headerSize) > len(dib) {
|
||||
return nil, fmt.Errorf("不支持的 DIB 头大小: %d", headerSize)
|
||||
}
|
||||
if bpp != 24 && bpp != 32 {
|
||||
return nil, fmt.Errorf("不支持的位深: %d", bpp)
|
||||
}
|
||||
if compression != 0 && compression != 3 { // BI_RGB=0, BI_BITFIELDS=3
|
||||
return nil, fmt.Errorf("不支持的压缩格式: %d", compression)
|
||||
}
|
||||
|
||||
// 像素数据偏移 = 14 字节文件头 + 信息头;BI_BITFIELDS 且 40 字节头时
|
||||
// 3 个掩码 DWORD 紧随其后(V4/V5 头 ≥108 字节的掩码内嵌在头里,无需外加)
|
||||
pixelOffset := 14 + int(headerSize)
|
||||
if compression == 3 && headerSize == 40 {
|
||||
pixelOffset += 12
|
||||
}
|
||||
|
||||
bmpBuf := make([]byte, 14+len(dib))
|
||||
bmpBuf[0], bmpBuf[1] = 'B', 'M'
|
||||
binaryLE.PutUint32(bmpBuf[2:6], uint32(len(bmpBuf)))
|
||||
// 6..9 保留位保持 0
|
||||
binaryLE.PutUint32(bmpBuf[10:14], uint32(pixelOffset))
|
||||
copy(bmpBuf[14:], dib)
|
||||
|
||||
img, err := bmp.Decode(bytes.NewReader(bmpBuf))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DIB 解码失败: %w", err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, img); err != nil {
|
||||
return nil, fmt.Errorf("PNG 编码失败: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
+8
-2
@@ -41,12 +41,18 @@ func (a *App) SftpConnect(req SftpConnectRequest) (string, error) {
|
||||
}
|
||||
|
||||
svc := a.ensureSftpService()
|
||||
_, err := svc.GetManager().Connect(config)
|
||||
// 冷连接 SSH 链路串行且最长 10s,各阶段通过事件推给前端做分步反馈,替代盲等
|
||||
connID := sftp.ConnID(config.Host, config.Port)
|
||||
_, err := svc.GetManager().Connect(config, func(stage string) {
|
||||
a.emitEvent("sftp:connect-stage", map[string]interface{}{
|
||||
"conn_id": connID,
|
||||
"stage": stage,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return "", sftp.ToUserMessage(err)
|
||||
}
|
||||
|
||||
connID := sftp.ConnID(config.Host, config.Port)
|
||||
return connID, nil
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"u-desk/internal/api"
|
||||
"u-desk/internal/common"
|
||||
)
|
||||
|
||||
// ========== 版本更新管理接口 ==========
|
||||
@@ -139,9 +140,9 @@ func (a *App) startAutoUpdateCheck() {
|
||||
ticker := a.updateTicker
|
||||
a.mu.Unlock()
|
||||
|
||||
go a.checkUpdate()
|
||||
common.Go("check-update", a.checkUpdate)
|
||||
|
||||
go func() {
|
||||
common.Go("update-ticker", func() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
@@ -150,7 +151,7 @@ func (a *App) startAutoUpdateCheck() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// stopUpdateTicker 停止自动更新检查定时器及其 goroutine
|
||||
|
||||
+60
-22
@@ -10,6 +10,7 @@ import (
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"u-desk/internal/common"
|
||||
)
|
||||
|
||||
// Chrome_WidgetWin 系列类名是 WebView2 的宿主窗口层级
|
||||
@@ -28,6 +29,12 @@ var (
|
||||
// 修复去重:同一时刻只允许一个校验在跑(回调写共享切片要求互斥)
|
||||
var webviewFixRunning atomic.Bool
|
||||
|
||||
// WM_SIZE 去重:脏标记 + 单 worker(每条 WM_SIZE 只置位,不再批量 spawn goroutine)
|
||||
var (
|
||||
webviewSizeDirty atomic.Bool
|
||||
webviewSizeWorkerActive atomic.Bool
|
||||
)
|
||||
|
||||
// 当前校验的目标主窗口(枚举回调过滤直属子窗口用)
|
||||
var webviewTargetHwnd uintptr
|
||||
|
||||
@@ -77,32 +84,60 @@ func checkAndFixFill(hwnd uintptr) {
|
||||
}
|
||||
}
|
||||
|
||||
// runGuarded 统一 panic 兜底:windowsgui 下 goroutine panic 会静默杀进程
|
||||
// runGuarded 同步调用点的 panic 兜底:panic 取证落盘后不外抛,
|
||||
// 让调用方(如周期循环)能继续跑下一轮
|
||||
func runGuarded(fn func()) {
|
||||
defer func() { recover() }()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
common.AppendPanicLog("webview-fix", r)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
// fixWebviewFill WM_SIZE 路径:等 resize 序列结束、wails 内部去抖落定后校验一次
|
||||
// fixWebviewFill WM_SIZE 路径:等 resize 序列结束、wails 内部去抖落定后校验一次。
|
||||
// WM_SIZE 只置脏标记;活跃期间的后续 WM_SIZE 合并进去(worker 循环消费),
|
||||
// 拖拽缩放的风暴从"每条消息一个 goroutine"降为全程单 worker
|
||||
func fixWebviewFill(hwnd uintptr) {
|
||||
if !webviewFixRunning.CompareAndSwap(false, true) {
|
||||
webviewTargetHwnd = hwnd
|
||||
webviewSizeDirty.Store(true)
|
||||
if !webviewSizeWorkerActive.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer webviewFixRunning.Store(false)
|
||||
runGuarded(func() {
|
||||
common.Go("webview-fix", func() {
|
||||
defer webviewSizeWorkerActive.Store(false)
|
||||
for webviewSizeDirty.CompareAndSwap(true, false) {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
checkAndFixFill(hwnd)
|
||||
if webviewFixRunning.CompareAndSwap(false, true) {
|
||||
runGuarded(func() { checkAndFixFill(webviewTargetHwnd) })
|
||||
webviewFixRunning.Store(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// stopWebviewWatch 周期兜底循环的停止信号;StartWebviewFillWatch 重入时先关旧的(重启语义)
|
||||
var stopWebviewWatch chan struct{}
|
||||
|
||||
// StartWebviewFillWatch 周期兜底:覆盖"无新 WM_SIZE 的迟到重放"场景
|
||||
// (主线程忙时排队的过期重绘在窗口静止时才执行,此时不会产生任何 WM_SIZE)
|
||||
func StartWebviewFillWatch() {
|
||||
go runGuarded(func() {
|
||||
if stopWebviewWatch != nil {
|
||||
select {
|
||||
case <-stopWebviewWatch: // 旧循环已自行退出
|
||||
default:
|
||||
close(stopWebviewWatch)
|
||||
}
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
stopWebviewWatch = stop
|
||||
common.Go("webview-watch", func() {
|
||||
for {
|
||||
time.Sleep(3 * time.Second)
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
if webviewFixRunning.CompareAndSwap(false, true) {
|
||||
runGuarded(func() {
|
||||
checkAndFixFill(findMainWindowHwnd())
|
||||
@@ -114,20 +149,23 @@ func StartWebviewFillWatch() {
|
||||
}
|
||||
|
||||
// findMainWindowHwnd 找本进程的可见主窗口
|
||||
func findMainWindowHwnd() uintptr {
|
||||
var found uintptr
|
||||
pid := windows.GetCurrentProcessId()
|
||||
cb := syscall.NewCallback(func(h uintptr, _ uintptr) uintptr {
|
||||
// 回调必须进程级复用(同 enumChildWebviewHosts):runtime 的 Windows callback
|
||||
// 硬上限 2000 个且按闭包指针去重,每 3 秒新建一个约 100 分钟即耗尽,
|
||||
// 触发 runtime throw("too many callback functions") 直接静默退出(无法 recover)
|
||||
var mainWindowHwndResult uintptr
|
||||
|
||||
var enumMainWindowHwnd = 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
|
||||
if winPid == windows.GetCurrentProcessId() && windows.IsWindowVisible(windows.HWND(h)) {
|
||||
mainWindowHwndResult = h
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
})
|
||||
runGuarded(func() {
|
||||
windows.EnumWindows(cb, nil)
|
||||
})
|
||||
return found
|
||||
})
|
||||
|
||||
func findMainWindowHwnd() uintptr {
|
||||
mainWindowHwndResult = 0
|
||||
windows.EnumWindows(enumMainWindowHwnd, nil)
|
||||
return mainWindowHwndResult
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "0.5.0",
|
||||
"download_url": "https://c.1216.top/download/u-desk-0.5.0.exe",
|
||||
"changelog": "### 新增 ✨\n- **预览导出 PDF**: HTML/Markdown 预览一键导出为 PDF,成功后自动打开所在文件夹并选中文件\n- **剪贴板粘贴文件**: 资源管理器复制的文件/文件夹可直接粘贴进文件列表(本机=复制,SFTP=上传)\n- **收藏展示名自定义**: 收藏项可自定义显示名称,收藏列表默认展开\n- **服务器右键菜单**: 服务器列表支持右键菜单,连接/管理操作直达\n- **一键全连**: 服务器管理菜单新增\"一键全连\",并发连接全部服务器\n\n### 优化 🚀\n- **收藏置顶项**: 置顶背景加深为清晰浅蓝,一眼区分置顶与普通收藏\n- **收藏栏宽度可调**: 拖拽调整侧边栏收藏区宽度并记忆\n\n### 修复 🐛\n- **窗口内容收窄**: 切换服务器/目录时偶发的内容区收窄卡住不再出现,自动恢复满宽\n- **Markdown 导出**: file URL 转义修正,含空格/特殊字符路径导出正常\n- **PDF 导出稳定性**: 浏览器探测补全,导出失败立即提示不再无响应",
|
||||
"force_update": false,
|
||||
"release_date": "2026-10-01",
|
||||
"file_size": 8324096,
|
||||
"sha256": "26422786300ea1d0b7100295893b11aaf9bfb61078180745f12b5d09e019878d"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"updated_at": "2026-10-01T20:30:00+08:00",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.5.0",
|
||||
"release_date": "2026-10-01",
|
||||
"changelog": "### 新增 ✨\n- **预览导出 PDF**: HTML/Markdown 预览一键导出为 PDF,成功后自动打开所在文件夹并选中文件\n- **剪贴板粘贴文件**: 资源管理器复制的文件/文件夹可直接粘贴进文件列表(本机=复制,SFTP=上传)\n- **收藏展示名自定义**: 收藏项可自定义显示名称,收藏列表默认展开\n- **服务器右键菜单**: 服务器列表支持右键菜单,连接/管理操作直达\n- **一键全连**: 服务器管理菜单新增\"一键全连\",并发连接全部服务器\n\n### 优化 🚀\n- **收藏置顶项**: 置顶背景加深为清晰浅蓝,一眼区分置顶与普通收藏\n- **收藏栏宽度可调**: 拖拽调整侧边栏收藏区宽度并记忆\n\n### 修复 🐛\n- **窗口内容收窄**: 切换服务器/目录时偶发的内容区收窄卡住不再出现,自动恢复满宽\n- **Markdown 导出**: file URL 转义修正,含空格/特殊字符路径导出正常\n- **PDF 导出稳定性**: 浏览器探测补全,导出失败立即提示不再无响应",
|
||||
"download_url": "https://c.1216.top/download/u-desk-0.5.0.exe",
|
||||
"file_size": 8324096,
|
||||
"sha256": "26422786300ea1d0b7100295893b11aaf9bfb61078180745f12b5d09e019878d"
|
||||
},
|
||||
{
|
||||
"version": "0.4.2",
|
||||
"release_date": "2026-09-15",
|
||||
"changelog": "### 修复 🐛\n- 启动初始化: 启动瞬间操作文件不再无响应\n- 跨盘剪切: 大目录移动显示进度,失败自动清理残留\n- SFTP 空目录下载/上传正确创建\n- drawio 首次下载失败立即提示\n- 网络重试后进度不再虚跳,失败条目正确显示错误\n- 连接失败后不再错误回落本地磁盘列表\n- OSS 签名兼容性,batch 脚本 %1/%%i 高亮",
|
||||
"download_url": "https://c.1216.top/download/u-desk-0.4.2.exe",
|
||||
"file_size": 8315904,
|
||||
"sha256": "d753648db78f68d7ad5646e60ea063ac23afaa9d10da4017d2b0657101836c53"
|
||||
},
|
||||
{
|
||||
"version": "0.4.1",
|
||||
"release_date": "2026-05-16",
|
||||
"changelog": "### 新增 ✨\n- 云 OSS 存储: 七牛云/阿里云双厂商\n- HTML 网站预览,BGM 播放器+LRC 歌词\n- 全局热键,远程服务器监控,侧边栏自定义\n\n### 优化 🚀\n- Wails v3 迁移,GBK 编码自动转换,SFTP 功能对齐\n\n### 修复 🐛\n- 阿里云 OSS 签名/OSS 目录重命名等",
|
||||
"download_url": "https://c.1216.top/download/u-desk-0.4.1.exe",
|
||||
"file_size": 8225280,
|
||||
"sha256": "60f0ffbee46f35358f33bafa66c6d178c0b919b389e64ba5915d015b587185db"
|
||||
},
|
||||
{
|
||||
"version": "0.4.0",
|
||||
"release_date": "2026-04-25",
|
||||
"changelog": "### 重构 🔧\n- **移除数据库客户端模块**: 删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理\n- **清理依赖**: 移除 go-sql-driver/mysql、go-redis/v9、mongo-driver/v2 等驱动依赖\n- **构建体积优化**: 原始 exe 从 36MB 降至 26MB,UPX 压缩后仅 7.5MB(压缩率 28.8%)",
|
||||
"download_url": "https://c.1216.top/download/u-desk-0.4.0.exe",
|
||||
"file_size": 7766016,
|
||||
"sha256": "532c30bdc57ea0ff5bc71756714b7ca18388ad3e09b2c4eefcdb6816349c7dda"
|
||||
},
|
||||
{
|
||||
"version": "0.3.3",
|
||||
"release_date": "2026-04-13",
|
||||
"changelog": "### 新增 ✨\n- Markdown 编辑器、PDF 导出、窗口置顶、收藏夹置顶\n- Excel/Word 文件预览、数据库 UI 大幅改进\n\n### 优化 🚀\n- MySQL 动态连接池、SQL 查询优化器、Redis Pipeline\n- FileListPanel 重写、CSV 编辑优化\n\n### 安全修复 🔒\n- XSS 防护、PDF 导出路径穿越防护\n\n### 重构 🔧\n- CodeMirror 架构优化、大规模死代码清理 (-1306 行)",
|
||||
"download_url": "https://c.1216.top/download/u-desk-0.3.3.exe",
|
||||
"file_size": 9801728,
|
||||
"sha256": "829c79a91c10277011159749110f4ebee5e3638a078e86850c03b1c9f09e184c"
|
||||
},
|
||||
{
|
||||
"version": "0.3.2",
|
||||
"release_date": "2026-02-05",
|
||||
"changelog": "### 重构 🔧\n- CodeMirror 统一导出避免多实例问题\n- 动态主题切换(Compartment 无损切换)\n\n### 优化 🚀\n- 编辑器防抖、亮色主题改进",
|
||||
"download_url": "",
|
||||
"file_size": 0,
|
||||
"sha256": ""
|
||||
},
|
||||
{
|
||||
"version": "0.3.0",
|
||||
"release_date": "2026-02-04",
|
||||
"changelog": "### 新增 ✨\n- Markdown 图表支持 (Mermaid)\n- 代码语法高亮 (20+ 语言)\n- 文件列表排序优化",
|
||||
"download_url": "",
|
||||
"file_size": 0,
|
||||
"sha256": ""
|
||||
},
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"release_date": "2026-01-28",
|
||||
"changelog": "### 新增 ✨\n- 应用配置管理、智能更新提醒\n- 应用更名为 u-desk",
|
||||
"download_url": "",
|
||||
"file_size": 0,
|
||||
"sha256": ""
|
||||
},
|
||||
{
|
||||
"version": "0.1.5",
|
||||
"release_date": "2026-01-22",
|
||||
"changelog": "### 新增 ✨\n- 文件管理模块、版本更新管理、系统信息查询",
|
||||
"download_url": "",
|
||||
"file_size": 0,
|
||||
"sha256": ""
|
||||
},
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"release_date": "2026-01-18",
|
||||
"changelog": "### 新增 ✨\n- 数据库管理 - 支持多种数据库连接和查询功能",
|
||||
"download_url": "",
|
||||
"file_size": 0,
|
||||
"sha256": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# U-Desk 功能清单
|
||||
|
||||
> 应用版本:v0.5.0
|
||||
> 应用版本:v0.5.1
|
||||
> 更新时间:2026-10-01 10:00
|
||||
> 用途:测试、开发、迭代过程中验证核对
|
||||
> 核对状态:✅ 已通过Git版本对比 + 源码验证
|
||||
@@ -489,7 +489,7 @@
|
||||
|
||||
**文档维护**: 开发团队
|
||||
**最后更新**: 2026-10-01 10:00
|
||||
**应用版本**: v0.5.0
|
||||
**应用版本**: v0.5.1
|
||||
**核对状态**: ✅ 已通过Git版本对比 + 源码验证 + 需求对照
|
||||
**核对报告**: [功能清单核对报告.md](./功能清单核对报告.md)
|
||||
**下次审查**: 每次发版前
|
||||
|
||||
@@ -56,8 +56,8 @@
|
||||
</a-layout-header>
|
||||
<a-layout-content class="content">
|
||||
<!-- 动态渲染 Tab 内容 -->
|
||||
<!-- 使用 KeepAlive 缓存组件状态,避免切换时重新加载 -->
|
||||
<KeepAlive include="FileSystem">
|
||||
<!-- 缓存全部顶层 tab:include 白名单会让 Markdown 编辑器切走时丢状态,故不再过滤 -->
|
||||
<KeepAlive>
|
||||
<component :is="getComponent(activeTab)"/>
|
||||
</KeepAlive>
|
||||
</a-layout-content>
|
||||
@@ -104,7 +104,9 @@ import {
|
||||
WindowMinimize, WindowToggleAlwaysOnTop,
|
||||
WindowMaximize, WindowIsMaximized, WindowClose
|
||||
} from './wailsjs/v3-bindings/u-desk/app'
|
||||
import { OffAll } from '@wailsio/events'
|
||||
import { Events } from '@wailsio/runtime'
|
||||
|
||||
const { OffAll } = Events
|
||||
|
||||
// 存储键
|
||||
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
||||
|
||||
@@ -10,6 +10,8 @@ import { SftpTransport } from './sftp-transport'
|
||||
import { OssTransport } from './oss-transport'
|
||||
import { getFileServerBaseURL } from './file-server'
|
||||
import { errMessage } from '@/utils/errors'
|
||||
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||
import { Events } from '@wailsio/runtime'
|
||||
import {
|
||||
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
|
||||
SftpGetSystemInfo, GetLocalSystemInfo,
|
||||
@@ -62,12 +64,32 @@ class ConnectionManagerImpl {
|
||||
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
|
||||
/** 每个 profile 的独立连接状态(连接菜单圆点用) */
|
||||
private _profileStates = new Map<string, { state: ConnectionState; error?: string }>()
|
||||
/** 每个 profile 的连接阶段(dial/auth/channel,冷连接分步反馈用) */
|
||||
private _profileStages = new Map<string, string>()
|
||||
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
|
||||
private _sysInfoCache = new Map<string, SystemInfo>()
|
||||
|
||||
constructor() {
|
||||
this.initDefaultLocal()
|
||||
this.loadFromDB()
|
||||
// 单例常驻,事件监听注册一次即可,无需退订
|
||||
Events.On('sftp:connect-stage', this.onConnectStage)
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端连接阶段事件:conn_id 为 host:port,映射到同地址的 sftp profile
|
||||
*/
|
||||
private onConnectStage = (event: unknown) => {
|
||||
const data = parseWailsEventData(event) as { conn_id?: string; stage?: string }
|
||||
if (!data?.conn_id || !data?.stage) return
|
||||
let matched = false
|
||||
for (const p of this._profiles) {
|
||||
if (p.type === 'sftp' && `${p.host}:${p.port || 22}` === data.conn_id) {
|
||||
matched = true
|
||||
this._profileStages.set(p.id, data.stage)
|
||||
}
|
||||
}
|
||||
if (matched) this.notifyChange()
|
||||
}
|
||||
|
||||
private initDefaultLocal() {
|
||||
@@ -139,7 +161,7 @@ class ConnectionManagerImpl {
|
||||
port: profile.port,
|
||||
username: profile.username || 'root',
|
||||
password: profile.password || '',
|
||||
keyPath: profile.keyPath || '',
|
||||
key_path: profile.keyPath || '',
|
||||
type: profile.type,
|
||||
provider: profile.provider || '',
|
||||
token: profile.token || '',
|
||||
@@ -148,7 +170,7 @@ class ConnectionManagerImpl {
|
||||
bucket: profile.bucket || '',
|
||||
region: profile.region || '',
|
||||
endpoint: profile.endpoint || '',
|
||||
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
|
||||
last_connected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
|
||||
})
|
||||
const realId = Number((result as any)?.id ?? 0)
|
||||
return realId > 0 ? realId : undefined
|
||||
@@ -168,6 +190,10 @@ class ConnectionManagerImpl {
|
||||
/** 更新单个 profile 的连接状态(对齐 Rust 版,供连接菜单圆点着色) */
|
||||
private setProfileState(profileId: string, state: ConnectionState, error?: string) {
|
||||
this._profileStates.set(profileId, { state, error })
|
||||
// 离开 connecting 即连接成功或失败,阶段文案随之失效
|
||||
if (state !== 'connecting') {
|
||||
this._profileStages.delete(profileId)
|
||||
}
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
@@ -176,6 +202,11 @@ class ConnectionManagerImpl {
|
||||
return this._profileStates.get(profileId) ?? { state: 'disconnected' }
|
||||
}
|
||||
|
||||
/** 获取单个 profile 的连接阶段(未在连接中时为空串) */
|
||||
getProfileStage(profileId: string): string {
|
||||
return this._profileStages.get(profileId) ?? ''
|
||||
}
|
||||
|
||||
private notifyChange() {
|
||||
this._stateChangeCallbacks.forEach(cb => cb(this._state))
|
||||
}
|
||||
@@ -201,6 +232,10 @@ class ConnectionManagerImpl {
|
||||
if (this._activeId && this._pool.has(this._activeId)) {
|
||||
return this._pool.get(this._activeId)!
|
||||
}
|
||||
// 拨号窗口期禁止静默回退本机 transport:否则远端面板标签+本机数据错位串台
|
||||
if (this._activeId && this._activeId !== 'local-default') {
|
||||
throw new Error('连接建立中,请稍候重试')
|
||||
}
|
||||
return this._pool.get('local-default')!
|
||||
}
|
||||
|
||||
@@ -276,12 +311,27 @@ class ConnectionManagerImpl {
|
||||
return info
|
||||
}
|
||||
|
||||
/** per-profileId 的 in-flight connect 去重:同配置连点共享同一次连接(成功/失败都清理) */
|
||||
private _connectInflight = new Map<string, Promise<void>>()
|
||||
|
||||
/**
|
||||
* 切换到指定 profile(连接池模式)
|
||||
* 池中有 → 直接复用,瞬间切换
|
||||
* 池中无 → 新建连接并存入池
|
||||
*/
|
||||
async connect(profileId: string): Promise<void> {
|
||||
// 同 profile 已在连接中:复用 in-flight Promise,避免并发拨号重复建连/状态互踩
|
||||
const inflight = this._connectInflight.get(profileId)
|
||||
if (inflight) return inflight
|
||||
|
||||
const p = this.doConnect(profileId).finally(() => {
|
||||
this._connectInflight.delete(profileId)
|
||||
})
|
||||
this._connectInflight.set(profileId, p)
|
||||
return p
|
||||
}
|
||||
|
||||
private async doConnect(profileId: string): Promise<void> {
|
||||
const profile = this._profiles.find(p => p.id === profileId)
|
||||
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
|
||||
|
||||
|
||||
@@ -24,16 +24,29 @@ export class SftpTransport implements FsTransport {
|
||||
}
|
||||
|
||||
async connect(): Promise<string> {
|
||||
const result = await SftpConnect({
|
||||
// 前端反馈层 9s 超时:后端连接继续由既有逻辑管理(不主动断),
|
||||
// 仅避免 UI 在网络不可达时无限挂起,用户能立刻拿到失败反馈
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('连接超时,请检查网络')), 9000)
|
||||
})
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
SftpConnect({
|
||||
host: this.profile.host,
|
||||
port: this.profile.port || 22,
|
||||
username: this.profile.username || 'root',
|
||||
password: this.profile.password || '',
|
||||
key_path: this.profile.keyPath || '',
|
||||
key_passphrase: '',
|
||||
})
|
||||
}),
|
||||
timeout,
|
||||
])
|
||||
this.connID = result
|
||||
return result
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async isAlive(): Promise<boolean> {
|
||||
@@ -56,23 +69,34 @@ export class SftpTransport implements FsTransport {
|
||||
return this.connID
|
||||
}
|
||||
|
||||
/** 远程路径守卫:本机盘符路径串入 SFTP(tab 恢复/面板状态错位)会在服务器端报 No such file */
|
||||
private assertRemotePath(path: string): void {
|
||||
if (/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
throw new Error(`本机路径不可用于远程操作: ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 文件列表与信息 ======
|
||||
|
||||
async listDir(path: string): Promise<FileItem[]> {
|
||||
this.assertRemotePath(path)
|
||||
return transformFileList(await SftpListDir(this.requireConn(), path))
|
||||
}
|
||||
|
||||
async getFileInfo(path: string): Promise<Record<string, any>> {
|
||||
this.assertRemotePath(path)
|
||||
return SftpGetFileInfo(this.requireConn(), path)
|
||||
}
|
||||
|
||||
// ====== 文件读写 ======
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
this.assertRemotePath(path)
|
||||
return SftpReadFile(this.requireConn(), path)
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string): Promise<void> {
|
||||
this.assertRemotePath(path)
|
||||
await SftpWriteFile({
|
||||
session_id: this.requireConn(),
|
||||
path,
|
||||
@@ -82,24 +106,30 @@ export class SftpTransport implements FsTransport {
|
||||
|
||||
async saveBase64File(path: string, content: string): Promise<void> {
|
||||
if (!content) throw new Error('无效的 base64 内容')
|
||||
this.assertRemotePath(path)
|
||||
await SftpWriteBase64File(this.requireConn(), path, content)
|
||||
}
|
||||
|
||||
// ====== 文件操作 ======
|
||||
|
||||
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||
this.assertRemotePath(dirPath)
|
||||
return SftpCreateFile(this.requireConn(), joinPath(dirPath, filename))
|
||||
}
|
||||
|
||||
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||
this.assertRemotePath(parentPath)
|
||||
return SftpCreateDir(this.requireConn(), joinPath(parentPath, dirname))
|
||||
}
|
||||
|
||||
async deletePath(path: string): Promise<FileOperationResult> {
|
||||
this.assertRemotePath(path)
|
||||
return SftpDeletePath(this.requireConn(), path)
|
||||
}
|
||||
|
||||
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
|
||||
this.assertRemotePath(oldPath)
|
||||
this.assertRemotePath(newPath)
|
||||
return SftpRenamePath({
|
||||
session_id: this.requireConn(),
|
||||
old_path: oldPath,
|
||||
@@ -121,11 +151,13 @@ export class SftpTransport implements FsTransport {
|
||||
|
||||
/** 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径 */
|
||||
async downloadToFile(remotePath: string, localDstDir: string): Promise<string> {
|
||||
this.assertRemotePath(remotePath)
|
||||
return SftpDownloadToFile(this.requireConn(), remotePath, localDstDir)
|
||||
}
|
||||
|
||||
/** 上传本地文件/目录到远程目录(流式,目录递归) */
|
||||
async uploadFromFile(localPath: string, remoteDir: string): Promise<void> {
|
||||
this.assertRemotePath(remoteDir)
|
||||
await SftpUploadFromFile(this.requireConn(), localPath, remoteDir)
|
||||
}
|
||||
|
||||
@@ -190,11 +222,13 @@ export class SftpTransport implements FsTransport {
|
||||
|
||||
/** 下载远程文件到本地临时目录用于预览(缓存由 Go 后端管理) */
|
||||
async downloadForPreview(remotePath: string): Promise<string> {
|
||||
this.assertRemotePath(remotePath)
|
||||
return SftpDownloadToTemp(this.requireConn(), remotePath)
|
||||
}
|
||||
|
||||
/** 下载 HTML 及其网站资源到临时目录用于预览 */
|
||||
async downloadSiteForPreview(remotePath: string): Promise<string> {
|
||||
this.assertRemotePath(remotePath)
|
||||
return SftpDownloadSiteForPreview(this.requireConn(), remotePath)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
import { connectionManager } from './connection-manager'
|
||||
import { PickDirectory } from '../wailsjs/v3-bindings/u-desk/app'
|
||||
import { PickDirectory, GetCPUInfo, GetMemoryInfo, GetDiskInfo, GetEnvVars } from '../wailsjs/v3-bindings/u-desk/app'
|
||||
|
||||
/** 转换后端蛇形字段为前端驼峰格式 */
|
||||
function toFileItem(file: any): FileItem {
|
||||
@@ -22,17 +22,17 @@ export async function getSystemInfo() { return t().getFileInfo('/') }
|
||||
|
||||
export async function getCPUInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return (await window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
|
||||
try { return (await GetCPUInfo()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
export async function getMemoryInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return (await window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
|
||||
try { return (await GetMemoryInfo()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
export async function getDiskInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return (await window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
|
||||
try { return (await GetDiskInfo()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
export async function listDir(path: string): Promise<FileItem[]> {
|
||||
@@ -88,7 +88,7 @@ export async function uploadFromFile(localPath: string, remoteDir: string): Prom
|
||||
}
|
||||
|
||||
export async function getEnvVars(): Promise<Record<string, string>> {
|
||||
try { return (await window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
|
||||
try { return (await GetEnvVars()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
export async function listZipContents(zipPath: string): Promise<FileItem[]> {
|
||||
|
||||
@@ -167,16 +167,19 @@ const createEditor = (docContent = '') => {
|
||||
|
||||
view = new EditorView({ state, parent: editorContainer.value })
|
||||
|
||||
// 滚动时防抖保存位置
|
||||
view.scrollDOM.addEventListener('scroll', () => {
|
||||
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||
saveScrollTimer = setTimeout(saveScrollPosition, 200)
|
||||
}, { passive: true })
|
||||
// 滚动时防抖保存位置(具名引用:removeEventListener 必须传同一函数引用才生效)
|
||||
view.scrollDOM.addEventListener('scroll', onEditorScroll, { passive: true })
|
||||
|
||||
// 初始化语言
|
||||
initLanguage()
|
||||
}
|
||||
|
||||
// 滚动防抖保存位置(注册/移除共用同一具名引用)
|
||||
const onEditorScroll = () => {
|
||||
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||
saveScrollTimer = setTimeout(saveScrollPosition, 200)
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(() => {
|
||||
@@ -196,7 +199,7 @@ onBeforeUnmount(() => {
|
||||
if (emitTimeout) clearTimeout(emitTimeout)
|
||||
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||
if (view?.scrollDOM) {
|
||||
view.scrollDOM.removeEventListener('scroll', saveScrollPosition)
|
||||
view.scrollDOM.removeEventListener('scroll', onEditorScroll)
|
||||
}
|
||||
view?.destroy()
|
||||
view = null
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
@click.stop="toggleMenu"
|
||||
@mouseenter="onDirHover"
|
||||
@mouseleave="onDirLeave">
|
||||
<span class="label">{{ label }}</span>
|
||||
<a-spin v-if="activeStage" :size="12" />
|
||||
<span class="label">{{ activeStage || label }}</span>
|
||||
|
||||
<!-- 悬停弹出:根目录子目录列表 -->
|
||||
<Transition name="dropdown-fade">
|
||||
@@ -54,6 +55,7 @@
|
||||
>
|
||||
<span :class="['dot', dotClass(p)]"></span>
|
||||
<span class="menu-name">{{ p.name }}</span>
|
||||
<span v-if="stageLabel(p)" class="menu-stage">{{ stageLabel(p) }}</span>
|
||||
<span
|
||||
v-if="p.type !== 'local'"
|
||||
class="more-btn"
|
||||
@@ -120,6 +122,27 @@ const label = computed(() => {
|
||||
return p.name
|
||||
})
|
||||
|
||||
// 连接阶段的中文文案,未知阶段回落到连接中
|
||||
const STAGE_TEXTS: Record<string, string> = { dial: '连接中', auth: '认证中', channel: '建立通道' }
|
||||
function stageText(stage: string): string {
|
||||
return STAGE_TEXTS[stage] ?? '连接中'
|
||||
}
|
||||
|
||||
// 活跃连接进行中的阶段文案(空串表示非连接中)
|
||||
const activeStage = computed(() => {
|
||||
const p = profiles.value.find(p => p.id === activeId.value)
|
||||
if (!p || p.type === 'local') return ''
|
||||
if (connectionManager.getProfileState(p.id).state !== 'connecting') return ''
|
||||
return stageText(connectionManager.getProfileStage(p.id))
|
||||
})
|
||||
|
||||
// 菜单条目进行中的阶段文案(空串表示非连接中)
|
||||
function stageLabel(p: { id: string; type: string }): string {
|
||||
if (p.type === 'local') return ''
|
||||
if (connectionManager.getProfileState(p.id).state !== 'connecting') return ''
|
||||
return stageText(connectionManager.getProfileStage(p.id))
|
||||
}
|
||||
|
||||
connectionManager.onStateChange(() => {
|
||||
profiles.value = connectionManager.profiles
|
||||
activeId.value = connectionManager.activeProfile?.id ?? ''
|
||||
@@ -148,8 +171,8 @@ const dirError = ref('')
|
||||
const dirChildren = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
|
||||
const dirLastLoadedPath = ref('')
|
||||
|
||||
const dirHoverTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const dirCloseTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const dirHoverTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const dirCloseTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const rootPath = computed(() => {
|
||||
const active = connectionManager.activeProfile
|
||||
@@ -261,8 +284,10 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
// === 原有方法 ===
|
||||
// 连接动作由父级统一入口处理,这里只上抛所选服务器
|
||||
function handleSelect(p: { id: string }) {
|
||||
// 连接动作由父级统一入口处理,这里只上抛所选服务器;
|
||||
// connecting 中忽略(防 UI 层重复触发,connect 侧另有 in-flight 去重兜底)
|
||||
function handleSelect(p: { id: string; type?: string }) {
|
||||
if (p.type && p.type !== 'local' && connectionManager.getProfileState(p.id).state === 'connecting') return
|
||||
showMenu.value = false
|
||||
emit('select', p.id)
|
||||
}
|
||||
@@ -407,6 +432,12 @@ watch(() => props.filePath, () => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-stage {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@click.stop
|
||||
>
|
||||
<!-- 空白区域菜单 -->
|
||||
<template v-if="config.context === 'blank'">
|
||||
<template v-if="config.context === 'empty'">
|
||||
<div class="context-menu-item" @click="handleCreateFile">
|
||||
<span class="context-menu-icon">📄</span>
|
||||
<span>新建文件</span>
|
||||
@@ -31,7 +31,7 @@
|
||||
</template>
|
||||
|
||||
<!-- 文件菜单 -->
|
||||
<template v-else-if="config.context === 'file' && config.selectedFile">
|
||||
<template v-else-if="config.context === 'file-list' && config.selectedFile">
|
||||
<div class="context-menu-item" @click="handleCreateFile">
|
||||
<span class="context-menu-icon">📄</span>
|
||||
<span>新建文件</span>
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
// 关键:viewer base 必须用本地文件服务器(getFileServerBaseURL),与 /drawio-viewer/ 路由及 .drawio 原文同源。
|
||||
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
import { Events } from '@wailsio/runtime'
|
||||
|
||||
const { On, Off } = Events
|
||||
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||
import { resolveFileUrl } from '../composables/useFilePreview'
|
||||
import { getFileServerBaseURL } from '@/api/file-server'
|
||||
|
||||
@@ -84,8 +84,8 @@ const error = ref('')
|
||||
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
|
||||
const style = ref<{ top: string; left: string }>({ top: '0px', left: '0px' })
|
||||
|
||||
const hoverTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const leaveTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const hoverTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const leaveTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const hoveringMenu = ref(false)
|
||||
|
||||
const menuKey = `menu-${props.item.path}-${props.level}`
|
||||
@@ -168,8 +168,8 @@ const onClick = (event: MouseEvent) => {
|
||||
// 阻止事件冒泡,避免触发父级 breadcrumb-segment 的点击
|
||||
event.stopPropagation()
|
||||
|
||||
const eventType = props.item.isDir ? 'navigate' : 'openFile'
|
||||
emit(eventType, props.item.path)
|
||||
if (props.item.isDir) emit('navigate', props.item.path)
|
||||
else emit('openFile', props.item.path)
|
||||
}
|
||||
|
||||
const emitNavigate = (path: string) => emit('navigate', path)
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="config.canSaveFile" position="left" content="保存 (Ctrl+S)">
|
||||
<a-button type="primary" size="small" @click="handleSave">
|
||||
<a-button type="primary" size="small" :loading="config.isSaving" @click="handleSave">
|
||||
<template #icon><icon-save /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
@@ -450,7 +450,9 @@ import { useThemeStore } from '@/stores/theme'
|
||||
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||
import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
|
||||
import { isDirty } from '../composables/useMultiPreview'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
import { Events } from '@wailsio/runtime'
|
||||
|
||||
const { On, Off } = Events
|
||||
import { ExportHtmlFileToPDF } from '@bindings/u-desk/app'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import { listDir, readFile } from '@/api/system'
|
||||
@@ -545,7 +547,7 @@ const htmlPreviewLocalPath = ref('')
|
||||
|
||||
watch(
|
||||
() => [props.config.currentFileFullPath, props.config.isHtmlFile],
|
||||
async ([path, isHtml]) => {
|
||||
async ([path, isHtml]: [string, boolean]) => {
|
||||
htmlPreviewUrl.value = ''
|
||||
htmlPreviewLocalPath.value = ''
|
||||
if (!path || !isHtml) return
|
||||
@@ -929,8 +931,8 @@ const handleExportHtmlPDF = async () => {
|
||||
try {
|
||||
const res = await ExportHtmlFileToPDF(srcPath, baseName)
|
||||
if (res?.success) {
|
||||
// 后端导出成功已自动打开资源管理器选中文件,弹窗只做明确指向,不依赖用户记住路径
|
||||
Message.success('PDF 已导出,已在桌面打开所在位置')
|
||||
// 导出成功只落盘不打开(用户要求),提示带落盘路径让产物位置明确
|
||||
Message.success({ content: `PDF 已导出到: ${res?.path || '桌面'}`, duration: 6000 })
|
||||
} else {
|
||||
Message.error(res?.message || 'PDF 导出失败')
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="col-setting-title">列设置</div>
|
||||
<div class="col-setting-item" style="cursor: default;">
|
||||
<span class="drag-handle"></span>
|
||||
<a-checkbox :model-value="showHeader" @change="(val: boolean) => { showHeader = val; localStorage.setItem(SHOW_HEADER_KEY, String(val)) }">
|
||||
<a-checkbox :model-value="showHeader" @change="(val: boolean) => toggleShowHeader(val)">
|
||||
显示表头
|
||||
</a-checkbox>
|
||||
</div>
|
||||
@@ -175,6 +175,12 @@ const colSettings = ref<ColumnConfig[]>(loadColSettings())
|
||||
// 默认隐藏表头(localStorage 无值时默认不显示)
|
||||
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) === 'true')
|
||||
|
||||
/** 切换表头显示并持久化(模板表达式取不到 localStorage,经此方法写入) */
|
||||
const toggleShowHeader = (val: boolean) => {
|
||||
showHeader.value = val
|
||||
localStorage.setItem(SHOW_HEADER_KEY, String(val))
|
||||
}
|
||||
|
||||
// 手动持久化(避免 deep watch 频繁写入)
|
||||
function saveColSettings() {
|
||||
localStorage.setItem(COL_SETTINGS_KEY, JSON.stringify(colSettings.value))
|
||||
@@ -235,12 +241,12 @@ function buildColumn(key: string, editPath: string | undefined) {
|
||||
]),
|
||||
width: 32,
|
||||
bodyCellClass: 'col-icon',
|
||||
render: ({ record }: { record: FileItem }) => {
|
||||
render: ({ record }: { record: any }) => {
|
||||
const ext = getExt(record.name)
|
||||
return h('span', {
|
||||
class: 'file-item-icon',
|
||||
title: ext ? `.${ext.toUpperCase()} : ${record.name}` : record.name
|
||||
}, getFileIcon(record))
|
||||
}, getFileIcon({ is_dir: record.isDir, name: record.name }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +261,7 @@ function buildColumn(key: string, editPath: string | undefined) {
|
||||
]),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true,
|
||||
render: ({ record }: { record: FileItem }) => {
|
||||
render: ({ record }: { record: any }) => {
|
||||
const isEditing = editPath === record.path
|
||||
if (isEditing) {
|
||||
return h(Input, {
|
||||
@@ -287,7 +293,7 @@ function buildColumn(key: string, editPath: string | undefined) {
|
||||
dataIndex: 'modified_time',
|
||||
width: 125,
|
||||
align: 'right' as const,
|
||||
render: ({ record }: { record: FileItem }) => {
|
||||
render: ({ record }: { record: any }) => {
|
||||
if (editPath === record.path || !record.modified_time) return null
|
||||
return h('span', { class: 'file-item-time' }, formatFileTime(record.modified_time))
|
||||
}
|
||||
@@ -305,7 +311,7 @@ function buildColumn(key: string, editPath: string | undefined) {
|
||||
dataIndex: 'size',
|
||||
width: 70,
|
||||
align: 'right' as const,
|
||||
render: ({ record }: { record: FileItem }) => {
|
||||
render: ({ record }: { record: any }) => {
|
||||
if (record.isDir || editPath === record.path) return null
|
||||
return h('span', { class: 'file-item-size' }, formatBytes(record.size))
|
||||
}
|
||||
@@ -315,7 +321,7 @@ function buildColumn(key: string, editPath: string | undefined) {
|
||||
return {
|
||||
title: '',
|
||||
width: 28,
|
||||
render: ({ record }: { record: FileItem }) => {
|
||||
render: ({ record }: { record: any }) => {
|
||||
if (editPath === record.path) return null
|
||||
const favorited = props.favorites.includes(record.path)
|
||||
return h(Button, {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
class="breadcrumb-segment"
|
||||
:class="{ 'is-hoverable': index < segments.length - 1 || segments.length === 1 }"
|
||||
@mouseenter="onHover(segment, index)"
|
||||
@mouseleave="onLeave"
|
||||
@mouseleave="onMenuLeave"
|
||||
@click="onClick(segment)"
|
||||
>
|
||||
<span class="segment-text">{{ segment.name }}</span>
|
||||
@@ -125,8 +125,8 @@ const segments = computed<PathSegment[]>(() => {
|
||||
})
|
||||
|
||||
const activeIndex = ref<number | null>(null)
|
||||
const hoverTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const closeTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const hoverTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const closeTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
@@ -639,10 +639,12 @@ function stateText(profileId: string): string {
|
||||
return '未连接'
|
||||
}
|
||||
|
||||
// 同一服务器重复点击也上抛切换事件,由父级强制重载列表
|
||||
// 同一服务器重复点击也上抛切换事件,由父级强制重载列表;
|
||||
// 但 connecting 中直接忽略(connect 已有 in-flight 去重,这里防 UI 层重复触发)
|
||||
function handleSelect(p: { id: string; type: string }) {
|
||||
moreOpenId.value = null
|
||||
moreProfile.value = null
|
||||
if (p.type !== 'local' && connectionManager.getProfileState(p.id).state === 'connecting') return
|
||||
emit('serverSelect', p.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,8 @@ provide('dropdownOwner', dropdownOwner)
|
||||
// Props
|
||||
interface Props {
|
||||
config: ToolbarConfig
|
||||
openConnectionDialog?: boolean
|
||||
/** 连接对话框触发计数器:父组件自增触发打开(>0 即开) */
|
||||
openConnectionDialog?: number
|
||||
editProfileId?: string | null
|
||||
}
|
||||
|
||||
@@ -220,6 +221,10 @@ const handleNavigateToZipRoot = () => {
|
||||
emit('navigateToZipDirectory', '')
|
||||
}
|
||||
|
||||
const handleNavigateToZipDirectory = (path: string) => {
|
||||
emit('navigateToZipDirectory', path)
|
||||
}
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
emit('update:showSidebar', !props.config.showSidebar)
|
||||
}
|
||||
|
||||
@@ -317,6 +317,9 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
* 保存文件内容
|
||||
*/
|
||||
const saveFile = async (path?: string, isShortcut: boolean = false) => {
|
||||
// 防重入:上次保存尚未落盘时忽略本次触发(Ctrl+S 连按/按钮连点会并发写同一文件)
|
||||
if (isSaving.value) return
|
||||
|
||||
// 获取目标路径(优先使用传入的 path,否则从 currentFilePath 中提取)
|
||||
let targetPath = path
|
||||
if (!targetPath && currentFilePath.value) {
|
||||
|
||||
@@ -125,11 +125,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, watchEffect, nextTick } from 'vue'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
import { ReadClipboardFiles } from '@bindings/u-desk/app'
|
||||
import { Events } from '@wailsio/runtime'
|
||||
|
||||
const { On, Off } = Events
|
||||
import { ReadClipboardFiles, ReadClipboardImage } from '@bindings/u-desk/app'
|
||||
import { renderMarkdown, parseMarkdown, isMarkedReady, preloadMarked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useTransferStore } from '@/stores/transfer'
|
||||
@@ -337,7 +339,7 @@ const contextMenuConfig = ref<ContextMenuConfig>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
context: 'blank',
|
||||
context: 'empty',
|
||||
selectedFile: undefined
|
||||
})
|
||||
|
||||
@@ -379,7 +381,7 @@ const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, dete
|
||||
useFilePreview()
|
||||
|
||||
// 文件编辑
|
||||
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
|
||||
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, isSaving, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
|
||||
useFileEdit({
|
||||
currentFilePath: selectedFileItem,
|
||||
currentDirectory: filePath
|
||||
@@ -506,6 +508,7 @@ const fileEditorPanelConfig = computed(() => {
|
||||
officeLoading: false,
|
||||
officeError: null,
|
||||
canSaveFile: canSaveFile.value,
|
||||
isSaving: isSaving.value,
|
||||
canResetContent: canResetContent.value,
|
||||
canPreviewFile: isEditableWithPreview(currentFileName),
|
||||
imageLoading: imageLoading.value,
|
||||
@@ -954,7 +957,7 @@ const handleContextMenu = (event: MouseEvent, file: FileItem | null = null) => {
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
context: file ? 'file' : 'blank',
|
||||
context: file ? 'file-list' : 'empty',
|
||||
selectedFile: file || undefined,
|
||||
isSftp: connectionManager.isSftp(),
|
||||
clipboardEmpty: clipboardEmpty.value
|
||||
@@ -1094,6 +1097,11 @@ const handlePasteClipboard = async () => {
|
||||
|
||||
/** SFTP 文件/目录下载到本机(目录选择器 → 流式下载) */
|
||||
const handleDownloadToLocal = async (file: FileItem) => {
|
||||
// 防状态错位串台:本机路径混入 SFTP 下载会让远程端 stat 盘符路径报"No such file"
|
||||
if (!connectionManager.isSftp()) {
|
||||
Message.warning('本机文件无需下载,请使用复制/剪切')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dstDir = await pickDirectory()
|
||||
if (!dstDir) return
|
||||
@@ -1613,6 +1621,11 @@ const loadFileContent = async (path: string) => {
|
||||
}
|
||||
|
||||
const loadDirectory = async (path: string) => {
|
||||
// SFTP 面板收到本机盘符路径=连接切换窗口期串台,重置回远程根目录
|
||||
if (connectionManager.isSftp() && /^[A-Za-z]:[\\/]/.test(path)) {
|
||||
Message.warning('远程面板不能浏览本机路径,已回到根目录')
|
||||
return loadDirectory('/')
|
||||
}
|
||||
fileLoading.value = true
|
||||
try {
|
||||
fileList.value = await fileOps.listDirectory(path)
|
||||
@@ -1943,11 +1956,16 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === 'v') {
|
||||
// 剪贴板有文件项时优先生效(远程面板由粘贴逻辑 toast 提示),否则放行走「粘贴剪贴板图片」路径
|
||||
// 剪贴板有文件项时优先生效(远程面板由粘贴逻辑 toast 提示)
|
||||
if (clipboard.value && clipboard.value.items.length > 0) {
|
||||
event.preventDefault()
|
||||
await handlePasteClipboard()
|
||||
return
|
||||
}
|
||||
// 应用内剪贴板空:keydown 直读系统剪贴板图片。
|
||||
// WebView2 无编辑焦点时 window paste 事件不可靠派发,只依赖它会全程静默(bug 根修)
|
||||
event.preventDefault()
|
||||
await pasteClipboardImage()
|
||||
return
|
||||
}
|
||||
// 复制/剪切仅本机面板生效;SFTP 面板静默(菜单本就不提供),
|
||||
@@ -2065,11 +2083,48 @@ const handlePaste = async (event: ClipboardEvent) => {
|
||||
|
||||
const imageItem = Array.from(items).find(item => item.type.startsWith('image/'))
|
||||
if (imageItem) {
|
||||
// 同一次 Ctrl+V 的 keydown 路径刚保存过时跳过(双保险链路防双写)
|
||||
if (Date.now() - lastClipboardImageSaveAt < 1500) return
|
||||
event.preventDefault()
|
||||
await pasteImageToFile(imageItem)
|
||||
}
|
||||
}
|
||||
|
||||
// keydown 路径最近一次保存剪贴板图片的时间戳(与 DOM paste 事件链去重用)
|
||||
let lastClipboardImageSaveAt = 0
|
||||
|
||||
// Ctrl+V keydown 直读系统剪贴板图片(后端 ReadClipboardImage,PNG base64)。
|
||||
// 无图静默维持原行为;有图落盘当前目录,文件名 clipboard_YYYYMMDD_HHmmss.png 同款
|
||||
const pasteClipboardImage = async (): Promise<void> => {
|
||||
if (!filePath.value) return
|
||||
try {
|
||||
const base64 = await ReadClipboardImage()
|
||||
if (!base64) return
|
||||
lastClipboardImageSaveAt = Date.now()
|
||||
const now = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||
const fileName = `clipboard_${ts}.png`
|
||||
const fullPath = filePath.value + getPathSeparator(filePath.value) + fileName
|
||||
await saveClipboardImage(fullPath, base64)
|
||||
} catch { /* 剪贴板占用/格式不支持等,维持原静默 */ }
|
||||
}
|
||||
|
||||
// 剪贴板图片落盘公共实现:保存 base64 到 fullPath,成功后提示并刷新列表
|
||||
const saveClipboardImage = async (fullPath: string, base64Data: string) => {
|
||||
const fileName = fullPath.replace(/^.*[\\/]/, '')
|
||||
Message.loading({ content: `正在保存 ${fileName}...`, duration: 0 })
|
||||
try {
|
||||
await saveBase64File(fullPath, base64Data)
|
||||
Message.clear()
|
||||
Message.success(`已保存: ${fileName}`)
|
||||
loadDirectory(filePath.value)
|
||||
} catch (err: any) {
|
||||
Message.clear()
|
||||
Message.error('保存失败: ' + (err?.message || err))
|
||||
}
|
||||
}
|
||||
|
||||
// 将剪贴板图片保存为文件
|
||||
const pasteImageToFile = async (item: DataTransferItem) => {
|
||||
if (!filePath.value) {
|
||||
@@ -2104,17 +2159,8 @@ const pasteImageToFile = async (item: DataTransferItem) => {
|
||||
Message.error('图片数据格式无效')
|
||||
return
|
||||
}
|
||||
|
||||
Message.loading({ content: `正在保存 ${fileName}...`, duration: 0 })
|
||||
try {
|
||||
await saveBase64File(fullPath, base64Data)
|
||||
Message.clear()
|
||||
Message.success(`已保存: ${fileName}`)
|
||||
loadDirectory(filePath.value)
|
||||
} catch (err: any) {
|
||||
Message.clear()
|
||||
Message.error('保存失败: ' + (err?.message || err))
|
||||
}
|
||||
lastClipboardImageSaveAt = Date.now()
|
||||
await saveClipboardImage(fullPath, base64Data)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} catch (err: any) {
|
||||
@@ -2122,16 +2168,47 @@ const pasteImageToFile = async (item: DataTransferItem) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染 Mermaid 图表
|
||||
watch(async () => {
|
||||
// 渲染 Mermaid 图表:300ms 防抖 + in-flight 标志。
|
||||
// mermaid.run 并发重入会互相打断产生半渲染/残留节点,连续触发热源(内容流式更新等)在此收敛
|
||||
let mermaidRenderTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let mermaidRendering = false
|
||||
let mermaidRenderPending = false
|
||||
|
||||
const runMermaidRender = async () => {
|
||||
mermaidRenderTimer = null
|
||||
if (mermaidRendering) {
|
||||
mermaidRenderPending = true
|
||||
return
|
||||
}
|
||||
mermaidRendering = true
|
||||
try {
|
||||
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await renderMermaidDiagrams()
|
||||
// 强制容器重新计算高度
|
||||
await nextTick()
|
||||
}
|
||||
}, { immediate: true })
|
||||
} finally {
|
||||
mermaidRendering = false
|
||||
if (mermaidRenderPending) {
|
||||
mermaidRenderPending = false
|
||||
scheduleMermaidRender()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleMermaidRender = () => {
|
||||
if (mermaidRenderTimer) clearTimeout(mermaidRenderTimer)
|
||||
mermaidRenderTimer = setTimeout(runMermaidRender, 300)
|
||||
}
|
||||
|
||||
// 同步读取依赖保持原有触发条件,执行体走防抖+防并发
|
||||
// (watchEffect 即立即执行+依赖重跑,等价原 immediate 语义且类型正确)
|
||||
watchEffect(() => {
|
||||
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
|
||||
scheduleMermaidRender()
|
||||
}
|
||||
})
|
||||
|
||||
// 主题变化时重新渲染 mermaid 图表(跟随暗色/亮色)
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
@@ -140,6 +140,30 @@
|
||||
<a-tab-pane key="update" title="版本更新">
|
||||
<UpdatePanel @open-version-history="handleOpenVersionHistory" />
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 导出设置 -->
|
||||
<a-tab-pane key="export" title="导出设置">
|
||||
<a-space direction="vertical" fill style="width: 100%">
|
||||
<a-alert type="info" :show-icon="true">
|
||||
设置 PDF 导出的保存位置;不设置时默认保存到桌面
|
||||
</a-alert>
|
||||
<a-space>
|
||||
<a-input
|
||||
readonly
|
||||
:model-value="pdfExportDir || '默认(桌面)'"
|
||||
style="width: 340px"
|
||||
/>
|
||||
<a-button type="primary" @click="handlePickExportDir">
|
||||
<template #icon><icon-folder /></template>
|
||||
选择目录
|
||||
</a-button>
|
||||
<a-button :disabled="!pdfExportDir" @click="handleResetExportDir">
|
||||
<template #icon><icon-refresh /></template>
|
||||
恢复默认
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-space>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-drawer>
|
||||
</template>
|
||||
@@ -147,7 +171,9 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconDragArrow, IconCheck, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { IconDragArrow, IconCheck, IconRefresh, IconFolder } from '@arco-design/web-vue/es/icon'
|
||||
import { GetPDFExportDir, SavePDFExportDir } from '@bindings/u-desk/app'
|
||||
import { pickDirectory } from '@/api/system'
|
||||
import UpdatePanel from './UpdatePanel.vue'
|
||||
|
||||
// Props
|
||||
@@ -179,6 +205,40 @@ const localConfig = ref({
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
// PDF 导出目录设置(独立于 tab 配置保存链,选完即存)
|
||||
const pdfExportDir = ref('')
|
||||
const loadExportDir = async () => {
|
||||
try {
|
||||
pdfExportDir.value = (await GetPDFExportDir()) || ''
|
||||
} catch {
|
||||
// 读取失败按未设置处理,走默认桌面
|
||||
pdfExportDir.value = ''
|
||||
}
|
||||
}
|
||||
const handlePickExportDir = async () => {
|
||||
const dir = await pickDirectory()
|
||||
if (!dir) return
|
||||
try {
|
||||
await SavePDFExportDir(dir)
|
||||
pdfExportDir.value = dir
|
||||
Message.success('导出目录已保存')
|
||||
} catch (e) {
|
||||
Message.error(`保存导出目录失败: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}
|
||||
const handleResetExportDir = async () => {
|
||||
try {
|
||||
await SavePDFExportDir('')
|
||||
pdfExportDir.value = ''
|
||||
Message.success('已恢复默认导出目录')
|
||||
} catch (e) {
|
||||
Message.error(`恢复默认失败: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}
|
||||
watch(visible, (val) => {
|
||||
if (val) loadExportDir()
|
||||
})
|
||||
const draggedIndex = ref(null)
|
||||
|
||||
// 隐藏的 Tabs(不在 visibleTabs 中)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="percent(item)"
|
||||
:status="item.status === 'error' ? 'exception' : item.status === 'done' ? 'success' : 'normal'"
|
||||
:status="item.status === 'error' ? 'danger' : item.status === 'done' ? 'success' : 'normal'"
|
||||
:show-text="false"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function useTimeout(options: TimeoutOptions = {}) {
|
||||
const { autoCleanup = true } = options
|
||||
|
||||
// 使用 Set 存储所有定时器 ID
|
||||
const timers = ref<Set<NodeJS.Timeout>>(new Set())
|
||||
const timers = ref<Set<ReturnType<typeof setTimeout>>>(new Set())
|
||||
|
||||
/**
|
||||
* 设置定时器(自动管理生命周期)
|
||||
@@ -53,7 +53,7 @@ export function useTimeout(options: TimeoutOptions = {}) {
|
||||
const setTimeout = <T = void>(
|
||||
callback: () => T,
|
||||
delay: number
|
||||
): NodeJS.Timeout => {
|
||||
): ReturnType<typeof setTimeout> => {
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
callback()
|
||||
@@ -73,7 +73,7 @@ export function useTimeout(options: TimeoutOptions = {}) {
|
||||
* 清除特定定时器
|
||||
* @param timer - 要清除的定时器 ID
|
||||
*/
|
||||
const clearTimeout = (timer: NodeJS.Timeout) => {
|
||||
const clearTimeout = (timer: ReturnType<typeof setTimeout>) => {
|
||||
window.clearTimeout(timer)
|
||||
timers.value.delete(timer)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import './style.css'
|
||||
import App from './App.vue'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
import { startFreezeWatchdog } from './utils/freezeWatchdog'
|
||||
import { preloadMarked } from './utils/markedExtensions'
|
||||
import '@/plugin/built-in' // 注册内置预览插件(drawio 等)
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -21,3 +22,14 @@ startFreezeWatchdog()
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 空闲预热 markdown 渲染链(marked + highlight.js 各语言模块)与 mermaid 模块:
|
||||
// 只拉取模块不执行渲染,首屏不受影响;requestIdleCallback 缺失时 setTimeout 3s 兜底。
|
||||
// 首次打开 md 文件时 ensureMarked/loadMermaid 的动态 import 直接命中缓存,实现秒开
|
||||
const idlePreload = typeof window.requestIdleCallback === 'function'
|
||||
? (cb) => window.requestIdleCallback(() => cb())
|
||||
: (cb) => setTimeout(cb, 3000)
|
||||
idlePreload(() => {
|
||||
preloadMarked()
|
||||
import('mermaid').catch(() => {})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
import { Events } from '@wailsio/runtime'
|
||||
|
||||
const { On, Off } = Events
|
||||
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||
|
||||
/** 传输条目(与后端 transfer-progress 事件 payload 对应) */
|
||||
|
||||
@@ -6,7 +6,9 @@ import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||
import {
|
||||
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate, InstallUpdateWithHash
|
||||
} from '../wailsjs/v3-bindings/u-desk/app'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
import { Events } from '@wailsio/runtime'
|
||||
|
||||
const { On, Off } = Events
|
||||
|
||||
// 本次应用运行内已自动弹过的版本(升级后自动恢复提醒)
|
||||
let sessionNotifiedVersion = ''
|
||||
@@ -63,15 +65,6 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
if (version) {
|
||||
sessionNotifiedVersion = version
|
||||
}
|
||||
if (!force) {
|
||||
// 自动发现时的系统通知
|
||||
try {
|
||||
window.runtime?.SendNotification?.({
|
||||
title: 'U-Desk',
|
||||
body: `发现新版本 ${version},点击查看`
|
||||
})
|
||||
} catch { /* 通知不可用时忽略 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,13 +239,6 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
|
||||
const fileSize = (data.file_size as number) || 0
|
||||
|
||||
// 系统通知:下载完成
|
||||
try {
|
||||
window.runtime?.SendNotification?.({
|
||||
title: 'U-Desk',
|
||||
body: `更新包下载完成 (${formatFileSize(fileSize)}),正在安装...`
|
||||
})
|
||||
} catch { /* 通知不可用时忽略 */ }
|
||||
progressInfo.value = {
|
||||
speed: 0,
|
||||
downloaded: fileSize,
|
||||
|
||||
@@ -195,6 +195,8 @@ export interface FileEditorPanelConfig {
|
||||
officeError: string | null
|
||||
/** 是否可以保存 */
|
||||
canSaveFile: boolean
|
||||
/** 保存中(保存按钮 loading,防重复点击) */
|
||||
isSaving: boolean
|
||||
/** 是否可以重置 */
|
||||
canResetContent: boolean
|
||||
/** 是否可以预览 */
|
||||
|
||||
@@ -3,6 +3,62 @@ import { getHljsLanguage } from './languageMap'
|
||||
let mermaidInstance: typeof import('mermaid').default | null = null
|
||||
let mermaidTheme: string | null = null
|
||||
|
||||
// languageMap.ts(commonLangNames 直出 + 扩展名映射值)能产出的 hljs 语言全集。
|
||||
// 用 lib/core + 逐语言异步 import 替代全量包(全量 ~190 种语言严重拖大首包);
|
||||
// shell/cmd/html 是 bash/dos/xml 模块自带的别名,plaintext 为 hljs 内建,无需单独注册
|
||||
const HLJS_LANGUAGES: Record<string, () => Promise<{ default: unknown }>> = {
|
||||
javascript: () => import('highlight.js/lib/languages/javascript'),
|
||||
typescript: () => import('highlight.js/lib/languages/typescript'),
|
||||
python: () => import('highlight.js/lib/languages/python'),
|
||||
java: () => import('highlight.js/lib/languages/java'),
|
||||
go: () => import('highlight.js/lib/languages/go'),
|
||||
rust: () => import('highlight.js/lib/languages/rust'),
|
||||
c: () => import('highlight.js/lib/languages/c'),
|
||||
cpp: () => import('highlight.js/lib/languages/cpp'),
|
||||
csharp: () => import('highlight.js/lib/languages/csharp'),
|
||||
php: () => import('highlight.js/lib/languages/php'),
|
||||
ruby: () => import('highlight.js/lib/languages/ruby'),
|
||||
swift: () => import('highlight.js/lib/languages/swift'),
|
||||
kotlin: () => import('highlight.js/lib/languages/kotlin'),
|
||||
scala: () => import('highlight.js/lib/languages/scala'),
|
||||
dart: () => import('highlight.js/lib/languages/dart'),
|
||||
lua: () => import('highlight.js/lib/languages/lua'),
|
||||
r: () => import('highlight.js/lib/languages/r'),
|
||||
bash: () => import('highlight.js/lib/languages/bash'),
|
||||
powershell: () => import('highlight.js/lib/languages/powershell'),
|
||||
dos: () => import('highlight.js/lib/languages/dos'),
|
||||
sql: () => import('highlight.js/lib/languages/sql'),
|
||||
yaml: () => import('highlight.js/lib/languages/yaml'),
|
||||
json: () => import('highlight.js/lib/languages/json'),
|
||||
xml: () => import('highlight.js/lib/languages/xml'),
|
||||
markdown: () => import('highlight.js/lib/languages/markdown'),
|
||||
css: () => import('highlight.js/lib/languages/css'),
|
||||
scss: () => import('highlight.js/lib/languages/scss'),
|
||||
less: () => import('highlight.js/lib/languages/less'),
|
||||
ini: () => import('highlight.js/lib/languages/ini'),
|
||||
makefile: () => import('highlight.js/lib/languages/makefile'),
|
||||
dockerfile: () => import('highlight.js/lib/languages/dockerfile'),
|
||||
cmake: () => import('highlight.js/lib/languages/cmake'),
|
||||
latex: () => import('highlight.js/lib/languages/latex'),
|
||||
objectivec: () => import('highlight.js/lib/languages/objectivec'),
|
||||
autohotkey: () => import('highlight.js/lib/languages/autohotkey'),
|
||||
diff: () => import('highlight.js/lib/languages/diff'),
|
||||
graphql: () => import('highlight.js/lib/languages/graphql'),
|
||||
nginx: () => import('highlight.js/lib/languages/nginx'),
|
||||
perl: () => import('highlight.js/lib/languages/perl'),
|
||||
haskell: () => import('highlight.js/lib/languages/haskell'),
|
||||
elixir: () => import('highlight.js/lib/languages/elixir'),
|
||||
erlang: () => import('highlight.js/lib/languages/erlang'),
|
||||
clojure: () => import('highlight.js/lib/languages/clojure'),
|
||||
ocaml: () => import('highlight.js/lib/languages/ocaml'),
|
||||
vbnet: () => import('highlight.js/lib/languages/vbnet'),
|
||||
wasm: () => import('highlight.js/lib/languages/wasm'),
|
||||
fsharp: () => import('highlight.js/lib/languages/fsharp'),
|
||||
groovy: () => import('highlight.js/lib/languages/groovy'),
|
||||
julia: () => import('highlight.js/lib/languages/julia'),
|
||||
matlab: () => import('highlight.js/lib/languages/matlab'),
|
||||
}
|
||||
|
||||
// 懒加载 marked + hljs,首次调用时才初始化
|
||||
let _marked: typeof import('marked').marked | null = null
|
||||
let _initPromise: Promise<typeof import('marked').marked> | null = null
|
||||
@@ -12,22 +68,16 @@ async function ensureMarked(): Promise<typeof import('marked').marked> {
|
||||
if (_initPromise) return _initPromise
|
||||
|
||||
_initPromise = (async () => {
|
||||
const [markedModule, hljs, ...extras] = await Promise.all([
|
||||
const [markedModule, hljs, ...langModules] = await Promise.all([
|
||||
import('marked'),
|
||||
import('highlight.js'),
|
||||
import('highlight.js/lib/languages/powershell'),
|
||||
import('highlight.js/lib/languages/dos'),
|
||||
import('highlight.js/lib/languages/autohotkey'),
|
||||
import('highlight.js/lib/languages/latex'),
|
||||
import('highlight.js/lib/languages/dockerfile'),
|
||||
import('highlight.js/lib/languages/cmake'),
|
||||
import('highlight.js/lib/languages/scala'),
|
||||
import('highlight.js/lib/languages/dart'),
|
||||
import('highlight.js/lib/core'),
|
||||
...Object.values(HLJS_LANGUAGES).map(load => load()),
|
||||
])
|
||||
// 注册额外语言到 hljs
|
||||
const langNames = ['powershell', 'dos', 'autohotkey', 'latex', 'dockerfile', 'cmake', 'scala', 'dart']
|
||||
extras.forEach((mod, i) => {
|
||||
hljs.default.registerLanguage(langNames[i], (mod as any).default)
|
||||
// 注册语言到 hljs core(Promise.all 保序,键序与模块序一一对应)
|
||||
const hljsCore = hljs.default
|
||||
const langNames = Object.keys(HLJS_LANGUAGES)
|
||||
langNames.forEach((name, i) => {
|
||||
hljsCore.registerLanguage(name, (langModules[i] as any).default)
|
||||
})
|
||||
|
||||
const marked = markedModule.marked
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/service"
|
||||
)
|
||||
|
||||
@@ -133,7 +134,7 @@ func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}
|
||||
return errorResponse("已有下载任务进行中"), nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
common.Go("download-update", func() {
|
||||
defer api.downloading.Store(false)
|
||||
|
||||
progressCallback := func(progress float64, speed float64, downloaded int64, total int64) {
|
||||
@@ -162,7 +163,7 @@ func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}
|
||||
}
|
||||
api.emit("download-complete", resultInfo)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
return successResponse(map[string]interface{}{
|
||||
"message": "下载已开始",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetLogsDir 日志目录:~/.u-desk/logs(audit 日志同目录)
|
||||
func GetLogsDir() string {
|
||||
return filepath.Join(GetUserDataDir(), "logs")
|
||||
}
|
||||
|
||||
// dailyFile 按天滚动的追加写文件:每次写时发现日期变化即切换新文件。
|
||||
// 只做按天滚动,不搞大小上限/压缩/清理,崩溃取证够用。
|
||||
type dailyFile struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
naming func(t time.Time) string
|
||||
f *os.File
|
||||
day string
|
||||
}
|
||||
|
||||
func newDailyFile(dir string, naming func(time.Time) string) *dailyFile {
|
||||
return &dailyFile{dir: dir, naming: naming}
|
||||
}
|
||||
|
||||
func (w *dailyFile) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
day := now.Format("2006-01-02")
|
||||
if w.f != nil && day != w.day {
|
||||
w.f.Close()
|
||||
w.f = nil
|
||||
}
|
||||
if w.f == nil {
|
||||
if err := os.MkdirAll(w.dir, 0755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(w.dir, w.naming(now)), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.f, w.day = f, day
|
||||
}
|
||||
return w.f.Write(p)
|
||||
}
|
||||
|
||||
func (w *dailyFile) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.f == nil {
|
||||
return nil
|
||||
}
|
||||
err := w.f.Close()
|
||||
w.f = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// FileLogOut 应用文件日志的底层写出器(stderr + 按天文件 MultiWriter)。
|
||||
// SetupFileLogging 之后有效;供需要独立过滤级别的 logger 复用同一落盘目的地
|
||||
// (如 wails 系统日志降到 Warn,业务 slog.Default() 保持 INFO 全量)。
|
||||
var FileLogOut io.Writer = os.Stderr
|
||||
|
||||
// SetupFileLogging slog 双路落盘:stderr + ~/.u-desk/logs/app-YYYY-MM-DD.log。
|
||||
// windowsgui 下 stderr 被丢弃,文件路是日志唯一可见载体;
|
||||
// 文件侧打不开时静默降级为仅 stderr,不因日志失败阻断启动。
|
||||
func SetupFileLogging() {
|
||||
out := io.Writer(os.Stderr)
|
||||
w := newDailyFile(GetLogsDir(), func(t time.Time) string {
|
||||
return fmt.Sprintf("app-%s.log", t.Format("2006-01-02"))
|
||||
})
|
||||
// 预开当天文件探测文件侧可用性(Write 内部含懒开/换天逻辑)
|
||||
if _, err := w.Write(nil); err == nil {
|
||||
// 文件必须放 MultiWriter 首位:windowsgui 下 stderr 句柄无效,
|
||||
// MultiWriter 按序短路,stderr 在前会吞掉后续文件路(真机零日志的根因)
|
||||
out = io.MultiWriter(w, os.Stderr)
|
||||
} else {
|
||||
w.Close()
|
||||
}
|
||||
FileLogOut = out
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(out, nil)))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var panicLogMu sync.Mutex
|
||||
|
||||
// AppendPanicLog 把一次 panic 的来源名、值与完整栈追加写入
|
||||
// ~/.u-desk/logs/panic-YYYYMMDD.log。写失败静默放弃:取证失败不能引发二次故障。
|
||||
func AppendPanicLog(name string, r any) {
|
||||
dir := GetLogsDir()
|
||||
panicLogMu.Lock()
|
||||
defer panicLogMu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(dir, fmt.Sprintf("panic-%s.log", time.Now().Format("20060102")))
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
// debug.Stack 在 defer 内 panic 未展开时调用,包含 panic 原始调用栈
|
||||
fmt.Fprintf(f, "[%s] goroutine=%s panic=%v\n%s\n",
|
||||
time.Now().Format("2006-01-02 15:04:05.000"), name, r, debug.Stack())
|
||||
}
|
||||
|
||||
// Go 带 panic 兜底的 goroutine:panic 时写完整取证后仅结束该 goroutine,
|
||||
// 不让单点 panic 杀掉整个进程(windowsgui 下默认静默退出零痕迹)。
|
||||
func Go(name string, fn func()) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
AppendPanicLog(name, r)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
@@ -199,10 +199,11 @@ func RunConcurrent[T any](tasks []T, limit int, fn func(t T)) {
|
||||
for _, task := range tasks {
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(t T) {
|
||||
t := task
|
||||
Go("bounded-parallel", func() {
|
||||
defer func() { <-sem; wg.Done() }()
|
||||
fn(t)
|
||||
}(task)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
)
|
||||
|
||||
const DefaultFileServerPort = 2652
|
||||
@@ -142,11 +144,11 @@ func listenWithFallback(basePort int, handler http.Handler) (addr string, srv *h
|
||||
l, e := net.Listen("tcp", addr)
|
||||
if e == nil {
|
||||
srv = &http.Server{Handler: handler}
|
||||
go func() {
|
||||
common.Go("local-file-server-serve", func() {
|
||||
if se := srv.Serve(l); se != http.ErrServerClosed {
|
||||
log.Printf("[LocalFileServer] 异常退出: %v", se)
|
||||
}
|
||||
}()
|
||||
})
|
||||
return addr, srv, nil
|
||||
}
|
||||
log.Printf("[LocalFileServer] 端口 %d 被占用,尝试 %d...", port, port+1)
|
||||
@@ -346,11 +348,11 @@ type LocalFileHandler struct {
|
||||
// NewLocalFileHandler 创建本地文件处理器
|
||||
func NewLocalFileHandler() *LocalFileHandler {
|
||||
// 启动本地文件服务器
|
||||
go func() {
|
||||
common.Go("local-file-handler-start", func() {
|
||||
if _, err := StartLocalFileServer(); err != nil {
|
||||
log.Printf("[LocalFileHandler] 启动本地文件服务器失败: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
return &LocalFileHandler{}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,14 @@ package filesystem
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
)
|
||||
|
||||
// AuditOperation 审计操作类型
|
||||
@@ -78,7 +81,7 @@ func (a *AuditLogger) ensureReady() error {
|
||||
}
|
||||
a.logFile = f
|
||||
a.initialized = true
|
||||
go a.backgroundFlush()
|
||||
common.Go("audit-flush", a.backgroundFlush)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -102,10 +105,14 @@ func (a *AuditLogger) Log(entry AuditLogEntry) error {
|
||||
// 添加到缓冲区
|
||||
a.buffer = append(a.buffer, entry)
|
||||
|
||||
// 如果缓冲区满了,立即写入
|
||||
// 如果缓冲区满了,立即写入(缓冲满即刷的热路径语义不变:写+Sync 在已持有的锁内完成)
|
||||
if len(a.buffer) >= a.bufferSize {
|
||||
if err := a.flush(); err != nil {
|
||||
if f, err := a.flush(); err != nil {
|
||||
return err
|
||||
} else if f != nil {
|
||||
if err := f.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,10 +173,11 @@ func (a *AuditLogger) LogRead(path string, size int64, err error) {
|
||||
_ = a.Log(entry)
|
||||
}
|
||||
|
||||
// flush 将缓冲区写入文件
|
||||
func (a *AuditLogger) flush() error {
|
||||
// flush 将缓冲区写入文件(调用方须持有 a.mu),返回刚写入的文件供调用方在锁外 Sync。
|
||||
// Sync 是慢速磁盘操作,不放在锁内执行,避免阻塞 Log 热路径。
|
||||
func (a *AuditLogger) flush() (*os.File, error) {
|
||||
if len(a.buffer) == 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 序列化所有条目为JSON(每行一个)
|
||||
@@ -179,19 +187,25 @@ func (a *AuditLogger) flush() error {
|
||||
continue // 序列化失败,跳过该条目
|
||||
}
|
||||
if _, err := a.logFile.Write(append(data, '\n')); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新到磁盘
|
||||
if err := a.logFile.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 清空缓冲区
|
||||
a.buffer = a.buffer[:0]
|
||||
|
||||
return nil
|
||||
return a.logFile, nil
|
||||
}
|
||||
|
||||
// flushSynced 锁内写缓冲、锁外 Sync:fsync 不占 a.mu,后台定时刷盘不再阻塞 Log
|
||||
func (a *AuditLogger) flushSynced() error {
|
||||
a.mu.Lock()
|
||||
f, err := a.flush()
|
||||
a.mu.Unlock()
|
||||
if err != nil || f == nil {
|
||||
return err
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
// backgroundFlush 后台协程,定期刷新缓冲区
|
||||
@@ -202,14 +216,10 @@ func (a *AuditLogger) backgroundFlush() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
a.mu.Lock()
|
||||
_ = a.flush()
|
||||
a.mu.Unlock()
|
||||
_ = a.flushSynced()
|
||||
case <-a.stopChan:
|
||||
// 停止前刷新一次
|
||||
a.mu.Lock()
|
||||
_ = a.flush()
|
||||
a.mu.Unlock()
|
||||
_ = a.flushSynced()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -227,31 +237,54 @@ func (a *AuditLogger) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 刷新剩余缓冲区
|
||||
if err := a.flush(); err != nil {
|
||||
// 刷新剩余缓冲区(进程退出路径,Sync 在锁内不影响运行期热路径)
|
||||
if f, err := a.flush(); err != nil {
|
||||
return err
|
||||
} else if f != nil {
|
||||
if err := f.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭文件
|
||||
return a.logFile.Close()
|
||||
}
|
||||
|
||||
// GetRecentLogs 获取最近的审计日志
|
||||
// GetRecentLogs 获取最近的审计日志(尾部倒读:文件大于 64KB 时只 Seek 读末尾 64KB,
|
||||
// 日志单行远小于 64KB,足以覆盖 limit 条最新记录,避免随日志增长全量读盘)
|
||||
func GetRecentLogs(logDir string, limit int) ([]AuditLogEntry, error) {
|
||||
// 读取今天的日志文件
|
||||
timestamp := time.Now().Format("2006-01-02")
|
||||
logPath := filepath.Join(logDir, fmt.Sprintf("audit_%s.log", timestamp))
|
||||
|
||||
data, err := os.ReadFile(logPath)
|
||||
f, err := os.Open(logPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 解析JSON(每行一个条目)
|
||||
var entries []AuditLogEntry
|
||||
const tailSize = 64 * 1024
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
offset := info.Size() - tailSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
data := make([]byte, info.Size()-offset)
|
||||
if _, err := f.ReadAt(data, offset); err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 从文件中间开始读时首个字节位置未必在行边界,丢弃首条残行
|
||||
lines := parseLines(string(data))
|
||||
if offset > 0 && len(lines) > 0 {
|
||||
lines = lines[1:]
|
||||
}
|
||||
|
||||
// 从后往前读取(最新的在前)
|
||||
// 解析JSON(每行一个条目),从后往前读取(最新的在前)
|
||||
var entries []AuditLogEntry
|
||||
start := len(lines) - limit
|
||||
if start < 0 {
|
||||
start = 0
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
)
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
@@ -43,10 +45,10 @@ func OpenPath(path string) error {
|
||||
}
|
||||
|
||||
// 给进程一点时间启动
|
||||
go func() {
|
||||
common.Go("fs-process-release", func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cmd.Process.Release()
|
||||
}()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func NewRecycleBin(binPath string) (*RecycleBin, error) {
|
||||
}
|
||||
|
||||
// 启动自动清理协程
|
||||
go bin.autoCleanup()
|
||||
common.Go("recycle-autoclean", bin.autoCleanup)
|
||||
|
||||
return bin, nil
|
||||
}
|
||||
|
||||
@@ -878,10 +878,15 @@ func (s *FileSystemService) ResolveShortcut(lnkPath string) (targetPath string,
|
||||
lnkPath,
|
||||
)
|
||||
|
||||
// 执行 PowerShell 命令
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
|
||||
// 执行 PowerShell 命令(10s 超时:PowerShell 冷启动/被安全软件拖住时不无限挂起)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return "", fmt.Errorf("解析快捷方式超时(10s)")
|
||||
}
|
||||
return "", fmt.Errorf("解析快捷方式失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
)
|
||||
|
||||
// ZipFileEntry 表示 zip 文件中的一个文件条目
|
||||
@@ -231,7 +233,7 @@ func ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
|
||||
debugLog("[ExtractFileFromZipToTemp] 开始提取: %s from %s", filePath, zipPath)
|
||||
|
||||
// 启动临时文件清理协程
|
||||
go CleanOldTempFiles()
|
||||
common.Go("zip-clean-temp", CleanOldTempFiles)
|
||||
|
||||
// 创建临时目录
|
||||
tempDir := filepath.Join(os.TempDir(), TempFileDir)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"u-desk/internal/common"
|
||||
)
|
||||
|
||||
type Watcher struct {
|
||||
@@ -62,7 +63,7 @@ func (w *Watcher) WatchFile(path string) error {
|
||||
w.watched = absPath
|
||||
|
||||
// 后台消费事件
|
||||
go w.consumeEvents()
|
||||
common.Go("filewatch-consume", w.consumeEvents)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -210,10 +210,10 @@ func InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash
|
||||
|
||||
// 自动重启
|
||||
if autoRestart {
|
||||
go func() {
|
||||
common.Go("update-restart", func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
restartApplication()
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
return &InstallResult{Success: true, Message: "安装成功"}, nil
|
||||
@@ -275,10 +275,10 @@ func replaceExecutableFileWindows(newFilePath, execPath string) error {
|
||||
}
|
||||
|
||||
// 延迟删除旧文件
|
||||
go func() {
|
||||
common.Go("update-remove-old-exe", func() {
|
||||
time.Sleep(10 * time.Second)
|
||||
os.Remove(oldExecPath)
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+30
-12
@@ -10,11 +10,16 @@ import (
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"u-desk/internal/common"
|
||||
)
|
||||
|
||||
// 连接建立全流程(拨号+认证+SFTP 子系统)的总超时预算
|
||||
const connectTimeout = 10 * time.Second
|
||||
|
||||
// 拨号独立预算,小于总预算:冷连接卡死大多发生在 TCP 拨号,
|
||||
// 独立预算保证握手认证阶段始终有剩余 deadline 可用
|
||||
const dialTimeout = 6 * time.Second
|
||||
|
||||
// 保活参数:周期发送 keepalive 并等待回复,超过 keepaliveReplyWait 未回复判定连接已死
|
||||
const (
|
||||
keepaliveInterval = 30 * time.Second
|
||||
@@ -45,7 +50,8 @@ func GetManager() *Manager {
|
||||
}
|
||||
|
||||
// Connect 创建或复用 SFTP 连接
|
||||
func (m *Manager) Connect(config *Config) (*Client, error) {
|
||||
// onStage 在连接各阶段推进时回调(dial/auth/channel),可为 nil
|
||||
func (m *Manager) Connect(config *Config, onStage func(stage string)) (*Client, error) {
|
||||
key := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||
|
||||
m.mu.Lock()
|
||||
@@ -60,7 +66,7 @@ func (m *Manager) Connect(config *Config) (*Client, error) {
|
||||
m.clients.Delete(key)
|
||||
}
|
||||
|
||||
c, err := newClient(config)
|
||||
c, err := newClient(config, onStage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -102,8 +108,8 @@ func (m *Manager) Shutdown() {
|
||||
// --- 内部 ---
|
||||
|
||||
// newClient 建立连接并启动保活循环
|
||||
func newClient(config *Config) (*Client, error) {
|
||||
c, err := buildClient(config)
|
||||
func newClient(config *Config, onStage func(stage string)) (*Client, error) {
|
||||
c, err := buildClient(config, onStage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -112,7 +118,14 @@ func newClient(config *Config) (*Client, error) {
|
||||
}
|
||||
|
||||
// buildClient 拨号+认证+SFTP 子系统建立(全程受 connectTimeout 约束),不启动保活
|
||||
func buildClient(config *Config) (*Client, error) {
|
||||
// onStage 标记各阶段开始:dial=拨号、auth=认证、channel=建通道;SSH 协议链路串行,
|
||||
// 无法并行加速,分步回调让前端可见当前卡点
|
||||
func buildClient(config *Config, onStage func(stage string)) (*Client, error) {
|
||||
notify := func(stage string) {
|
||||
if onStage != nil {
|
||||
onStage(stage)
|
||||
}
|
||||
}
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
Config: ssh.Config{
|
||||
KeyExchanges: []string{
|
||||
@@ -160,14 +173,17 @@ func buildClient(config *Config) (*Client, error) {
|
||||
|
||||
// JoinHostPort 保证 IPv6 主机地址被方括号包裹,fmt 拼接对 IPv6 产生非法地址
|
||||
addr := net.JoinHostPort(config.Host, strconv.Itoa(config.Port))
|
||||
// 拨号与认证、SFTP 子系统握手共享同一总超时预算,认证阶段无界挂起由 deadline 掐断
|
||||
// 总 deadline 从拨号前起算:拨号占独立 6s 预算,认证与 SFTP 子系统握手受剩余总预算约束,
|
||||
// 认证阶段无界挂起由 deadline 掐断
|
||||
deadline := time.Now().Add(connectTimeout)
|
||||
sshConn, err := net.DialTimeout("tcp", addr, connectTimeout)
|
||||
notify("dial")
|
||||
sshConn, err := net.DialTimeout("tcp", addr, dialTimeout)
|
||||
if err != nil {
|
||||
return nil, &ConnectionError{Op: "dial", Err: err}
|
||||
}
|
||||
sshConn.SetDeadline(deadline)
|
||||
|
||||
notify("auth")
|
||||
sshConnConn, chans, reqs, err := ssh.NewClientConn(sshConn, addr, sshConfig)
|
||||
if err != nil {
|
||||
sshConn.Close()
|
||||
@@ -176,6 +192,7 @@ func buildClient(config *Config) (*Client, error) {
|
||||
|
||||
sshClient := ssh.NewClient(sshConnConn, chans, reqs)
|
||||
|
||||
notify("channel")
|
||||
sftpClient, err := sftp.NewClient(sshClient)
|
||||
sshConn.SetDeadline(time.Time{})
|
||||
if err != nil {
|
||||
@@ -196,7 +213,7 @@ func (c *Client) startKeepalive() {
|
||||
stop := make(chan struct{})
|
||||
sshClient := c.sshClient
|
||||
c.stopKeep = stop
|
||||
go func() {
|
||||
common.Go("sftp-keepalive", func() {
|
||||
t := time.NewTicker(keepaliveInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
@@ -210,16 +227,16 @@ func (c *Client) startKeepalive() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// sshKeepalive 发送一次 keepalive 请求并限时等待回复,超时或出错视作连接已死
|
||||
func sshKeepalive(sshClient *ssh.Client, wait time.Duration) bool {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
common.Go("sftp-keepalive-probe", func() {
|
||||
_, _, err := sshClient.SendRequest("keepalive@openssh.com", true, nil)
|
||||
done <- err
|
||||
}()
|
||||
})
|
||||
select {
|
||||
case err := <-done:
|
||||
return err == nil
|
||||
@@ -295,7 +312,8 @@ func (c *Client) WithRetry(fn func(*sftp.Client) error) error {
|
||||
|
||||
func (c *Client) reconnect() error {
|
||||
// 用 buildClient 而非 newClient,避免保活循环绑定到即将丢弃的临时容器
|
||||
nc, err := buildClient(c.config)
|
||||
// 后台重连无阶段展示需求,回调传 nil
|
||||
nc, err := buildClient(c.config, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -844,9 +844,9 @@ func (s *Service) GetSystemInfo(connID string) (map[string]interface{}, error) {
|
||||
out, e := c.RunCommand(cmd)
|
||||
results <- cmdResult{key, out, e}
|
||||
}
|
||||
go runCmd("df", "df -B1 / 2>/dev/null | tail -1")
|
||||
go runCmd("mem", "free -b 2>/dev/null | grep -E '^Mem:' || cat /proc/meminfo 2>/dev/null | head -2")
|
||||
go runCmd("cpu", "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
|
||||
common.Go("sftp-cmd-df", func() { runCmd("df", "df -B1 / 2>/dev/null | tail -1") })
|
||||
common.Go("sftp-cmd-mem", func() { runCmd("mem", "free -b 2>/dev/null | grep -E '^Mem:' || cat /proc/meminfo 2>/dev/null | head -2") })
|
||||
common.Go("sftp-cmd-cpu", func() { runCmd("cpu", "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1") })
|
||||
|
||||
var dfOut, memOut, cpuOut string
|
||||
hasErr := false
|
||||
|
||||
@@ -3,11 +3,13 @@ package main
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/hotkey"
|
||||
"u-desk/internal/service"
|
||||
)
|
||||
@@ -23,6 +25,15 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 崩溃取证基建先于一切:slog 落盘 + 主 goroutine panic 落盘
|
||||
common.SetupFileLogging()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
common.AppendPanicLog("main", r)
|
||||
os.Exit(2)
|
||||
}
|
||||
}()
|
||||
|
||||
// 启动时完成上次更新遗留的 exe 替换
|
||||
service.ReplacePendingFile()
|
||||
|
||||
@@ -31,6 +42,9 @@ func main() {
|
||||
wailsApp := application.New(application.Options{
|
||||
Name: "U-Desk",
|
||||
Description: "桌面文件管理器",
|
||||
// wails 系统日志(webview 生命周期、AssetFileServerFS 的 INFO 刷屏等)降到 Warn 落盘,
|
||||
// 业务日志仍走 slog.Default() 的 INFO 全量——两路共用同一文件 writer,只是过滤级别不同
|
||||
Logger: slog.New(slog.NewTextHandler(common.FileLogOut, &slog.HandlerOptions{Level: slog.LevelWarn})),
|
||||
SingleInstance: &application.SingleInstanceOptions{
|
||||
UniqueID: "top.1216.udesk",
|
||||
},
|
||||
@@ -65,8 +79,9 @@ func main() {
|
||||
return 0, true
|
||||
}
|
||||
case wmSizeMsg:
|
||||
// webview 尺寸失同步兜底(app_window_fix.go)
|
||||
go fixWebviewFill(hwnd)
|
||||
// webview 尺寸失同步兜底(app_window_fix.go):
|
||||
// 只置脏标记,单 worker goroutine 统一消费,WM_SIZE 风暴不再批量 spawn goroutine
|
||||
fixWebviewFill(hwnd)
|
||||
}
|
||||
return 0, false
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user