From 56fe0d9b826ea3738f0461814f7c8509d469adeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Tue, 15 Sep 2026 23:49:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E5=88=9D=E5=A7=8B=E5=8C=96=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E4=B8=8E=E4=BC=A0=E8=BE=93=E6=94=B6=E5=B0=BE=E7=AD=89=E4=B8=83?= =?UTF-8?q?=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filesystemSvc 守卫17绑定方法,异步初始化完成前返回明确错误 - MovePath 跨盘剪切建 move emitter,失败清理目标盘残留 - SFTP 空目录下载/上传补 MkdirAll 落地 - drawio 下载失败等待者立即返回,不再空轮询30分钟 - batch 高亮补 %1 与 %%i 规则 - 错误文案转换抽 errMessage 共享函数,11处归一 - log.Printf 统一 slog.Warn --- app_filesystem.go | 126 ++++++++++++++++--- app_recycle.go | 24 +++- frontend/src/api/connection-manager.ts | 18 ++- frontend/src/components/FileSystem/index.vue | 11 +- frontend/src/utils/codeMirrorLoader.js | 4 +- frontend/src/utils/errors.ts | 7 ++ internal/filesystem/drawio_viewer.go | 7 ++ internal/filesystem/service.go | 8 +- internal/ossdrv/service.go | 4 +- internal/sftp/service.go | 13 +- 10 files changed, 173 insertions(+), 49 deletions(-) create mode 100644 frontend/src/utils/errors.ts diff --git a/app_filesystem.go b/app_filesystem.go index 8297699..b2b6500 100644 --- a/app_filesystem.go +++ b/app_filesystem.go @@ -11,6 +11,15 @@ import ( "github.com/wailsapp/wails/v3/pkg/application" ) +// filesystemSvc 返回文件系统服务。初始化在启动 goroutine 异步进行, +// 完成前前端触发文件操作时返回明确错误而非 nil panic +func (a *App) filesystemSvc() (*filesystem.FileSystemService, error) { + if a.filesystem == nil { + return nil, fmt.Errorf("文件系统服务尚未初始化完成,请稍后重试") + } + return a.filesystem, nil +} + // EnsureDrawioViewer 确保本地已缓存 drawio viewer(首次预览时下载),前端打开 .drawio 前调用。 // 通过 drawio-download-progress 事件推送下载进度。 func (a *App) EnsureDrawioViewer() (map[string]interface{}, error) { @@ -30,7 +39,11 @@ func (a *App) EnsureDrawioViewer() (map[string]interface{}, error) { // ReadFile 读取文件 func (a *App) ReadFile(path string) (string, error) { - return a.filesystem.ReadFile(path) + fs, err := a.filesystemSvc() + if err != nil { + return "", err + } + return fs.ReadFile(path) } // WatchFile 开始监听指定文件的变化,变化时发送 file-changed 事件 @@ -50,52 +63,93 @@ func (a *App) UnwatchFile() { // WriteFile 写入文件 func (a *App) WriteFile(req WriteFileRequest) error { - return a.filesystem.WriteFile(req.Path, req.Content) + fs, err := a.filesystemSvc() + if err != nil { + return err + } + return fs.WriteFile(req.Path, req.Content) } // SaveBase64File 将 base64 内容解码后写入文件(用于图片等二进制数据) func (a *App) SaveBase64File(req SaveBase64FileRequest) error { - return a.filesystem.SaveBase64File(req.Path, req.Content) + fs, err := a.filesystemSvc() + if err != nil { + return err + } + return fs.SaveBase64File(req.Path, req.Content) } // ListDir 列出目录 func (a *App) ListDir(path string) ([]map[string]interface{}, error) { - return a.filesystem.ListDir(path) + fs, err := a.filesystemSvc() + if err != nil { + return nil, err + } + return fs.ListDir(path) } // CreateDir 创建目录 func (a *App) CreateDir(path string) (*filesystem.FileOperationResult, error) { - return a.filesystem.CreateDir(path) + fs, err := a.filesystemSvc() + if err != nil { + return nil, err + } + return fs.CreateDir(path) } // CreateFile 创建文件 func (a *App) CreateFile(path string) (*filesystem.FileOperationResult, error) { - return a.filesystem.CreateFile(path) + fs, err := a.filesystemSvc() + if err != nil { + return nil, err + } + return fs.CreateFile(path) } // DeletePath 删除文件或目录 func (a *App) DeletePath(path string) (*filesystem.FileOperationResult, error) { - return a.filesystem.DeletePath(path) + fs, err := a.filesystemSvc() + if err != nil { + return nil, err + } + return fs.DeletePath(path) } // RenamePath 重命名文件或目录 func (a *App) RenamePath(req RenamePathRequest) (*filesystem.FileOperationResult, error) { - return a.filesystem.RenamePath(req.OldPath, req.NewPath) + fs, err := a.filesystemSvc() + if err != nil { + return nil, err + } + return fs.RenamePath(req.OldPath, req.NewPath) } // CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度 func (a *App) CopyPath(src, dst string) error { + fs, err := a.filesystemSvc() + if err != nil { + return err + } emitter := newTransferEmitter(a, "copy", filepath.Base(filepath.FromSlash(src))) - err := a.filesystem.CopyPath(src, dst, func(copied, total int64) { + err = fs.CopyPath(src, dst, func(copied, total int64) { emitter.progress(emitter.name, copied, total) }) emitter.finish(err) return err } -// MovePath 移动文件或目录(同盘重命名,跨盘复制后删除) +// MovePath 移动文件或目录(同盘重命名,跨盘复制后删除),跨盘时经 transfer-progress 事件推送进度 func (a *App) MovePath(src, dst string) error { - return a.filesystem.MovePath(src, dst) + fs, err := a.filesystemSvc() + if err != nil { + return err + } + emitter := newTransferEmitter(a, "move", filepath.Base(filepath.FromSlash(src))) + err = fs.MovePath(src, dst, func(copied, total int64) { + emitter.progress(emitter.name, copied, total) + }) + emitter.finish(err) + return err } // PickDirectory 打开系统目录选择器,返回选中目录(取消时返回空串) @@ -177,39 +231,67 @@ func (t *transferEmitter) finish(err error) { // GetFileInfo 获取文件信息 func (a *App) GetFileInfo(path string) (map[string]interface{}, error) { - return a.filesystem.GetFileInfo(path) + fs, err := a.filesystemSvc() + if err != nil { + return nil, err + } + return fs.GetFileInfo(path) } // OpenPath 使用系统默认程序打开文件或目录 func (a *App) OpenPath(path string) error { - return a.filesystem.OpenPath(path) + fs, err := a.filesystemSvc() + if err != nil { + return err + } + return fs.OpenPath(path) } // ========== Zip 文件操作接口 ========== // ListZipContents 列出 zip 文件内容 func (a *App) ListZipContents(zipPath string) ([]map[string]interface{}, error) { - return a.filesystem.ListZipContents(zipPath) + fs, err := a.filesystemSvc() + if err != nil { + return nil, err + } + return fs.ListZipContents(zipPath) } // ExtractFileFromZip 从 zip 文件中提取单个文件内容 func (a *App) ExtractFileFromZip(zipPath, filePath string) (string, error) { - return a.filesystem.ExtractFileFromZip(zipPath, filePath) + fs, err := a.filesystemSvc() + if err != nil { + return "", err + } + return fs.ExtractFileFromZip(zipPath, filePath) } // ExtractFileFromZipToTemp 从 zip 文件中提取单个文件到临时目录 func (a *App) ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) { - return a.filesystem.ExtractFileFromZipToTemp(zipPath, filePath) + fs, err := a.filesystemSvc() + if err != nil { + return "", err + } + return fs.ExtractFileFromZipToTemp(zipPath, filePath) } // GetZipFileInfo 获取 zip 文件中特定文件的信息 func (a *App) GetZipFileInfo(zipPath, filePath string) (map[string]interface{}, error) { - return a.filesystem.GetZipFileInfo(zipPath, filePath) + fs, err := a.filesystemSvc() + if err != nil { + return nil, err + } + return fs.GetZipFileInfo(zipPath, filePath) } // ResolveShortcut 解析快捷方式文件,返回目标路径信息 func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) { - targetPath, err := a.filesystem.ResolveShortcut(lnkPath) + fs, err := a.filesystemSvc() + if err != nil { + return nil, err + } + targetPath, err := fs.ResolveShortcut(lnkPath) if err != nil { return map[string]interface{}{ "success": false, @@ -217,7 +299,7 @@ func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) { }, err } - fileInfo, err := a.filesystem.GetFileInfo(targetPath) + fileInfo, err := fs.GetFileInfo(targetPath) if err != nil { return map[string]interface{}{ "success": true, @@ -284,5 +366,9 @@ func (a *App) initFilesystemModule() { // GetAuditLogs 获取审计日志 func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) { - return a.filesystem.GetAuditLogs(limit) + fs, err := a.filesystemSvc() + if err != nil { + return nil, err + } + return fs.GetAuditLogs(limit) } diff --git a/app_recycle.go b/app_recycle.go index 9096444..d3a86c8 100644 --- a/app_recycle.go +++ b/app_recycle.go @@ -4,20 +4,36 @@ package main // GetRecycleBinEntries 获取回收站条目 func (a *App) GetRecycleBinEntries() ([]map[string]interface{}, error) { - return a.filesystem.GetRecycleBinEntries() + fs, err := a.filesystemSvc() + if err != nil { + return nil, err + } + return fs.GetRecycleBinEntries() } // RestoreFromRecycleBin 从回收站恢复文件 func (a *App) RestoreFromRecycleBin(recyclePath string) error { - return a.filesystem.RestoreFromRecycleBin(recyclePath) + fs, err := a.filesystemSvc() + if err != nil { + return err + } + return fs.RestoreFromRecycleBin(recyclePath) } // DeletePermanently 永久删除回收站中的文件 func (a *App) DeletePermanently(recyclePath string) error { - return a.filesystem.DeletePermanently(recyclePath) + fs, err := a.filesystemSvc() + if err != nil { + return err + } + return fs.DeletePermanently(recyclePath) } // EmptyRecycleBin 清空回收站 func (a *App) EmptyRecycleBin() error { - return a.filesystem.EmptyRecycleBin() + fs, err := a.filesystemSvc() + if err != nil { + return err + } + return fs.EmptyRecycleBin() } diff --git a/frontend/src/api/connection-manager.ts b/frontend/src/api/connection-manager.ts index 9555028..3ce55db 100644 --- a/frontend/src/api/connection-manager.ts +++ b/frontend/src/api/connection-manager.ts @@ -9,6 +9,7 @@ import { HttpTransport } from './http-transport' import { SftpTransport } from './sftp-transport' import { OssTransport } from './oss-transport' import { getFileServerBaseURL } from './file-server' +import { errMessage } from '@/utils/errors' import { LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile, SftpGetSystemInfo, GetLocalSystemInfo, @@ -268,7 +269,7 @@ class ConnectionManagerImpl { } } } - } catch (e) { info._error = true; info._errorMsg = errMsg(e) } + } catch (e) { info._error = true; info._errorMsg = errMessage(e) } this._sysInfoCache.set(profileId, info) this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info)) @@ -320,7 +321,7 @@ class ConnectionManagerImpl { } catch (err) { // 回退目标可能已不在池中(死连接重建时 disconnectProfile 先移除),指向池外会让 getTransport 静默回落本地造成 UI 幻影 this._activeId = (prevActiveId && this._pool.has(prevActiveId)) ? prevActiveId : 'local-default' - this.setProfileState(profileId, 'error', err instanceof Error ? err.message : String(err)) + this.setProfileState(profileId, 'error', errMessage(err)) this.notifyChange() throw err } @@ -433,7 +434,7 @@ class ConnectionManagerImpl { this.fetchSystemInfo(profileId).catch(() => {}) } catch (err) { this._pool.delete(profileId) - this.setProfileState(profileId, 'error', errMsg(err)) + this.setProfileState(profileId, 'error', errMessage(err)) this.setState('error') throw err } @@ -453,7 +454,7 @@ class ConnectionManagerImpl { this.updateProfile(profileId, { lastConnected: Date.now() }) } catch (err) { this._pool.delete(profileId) - this.setProfileState(profileId, 'error', errMsg(err)) + this.setProfileState(profileId, 'error', errMessage(err)) this.setState('error') throw err } @@ -474,13 +475,13 @@ class ConnectionManagerImpl { this.updateProfile(profileId, { lastConnected: Date.now() }) this.fetchSystemInfo(profileId).catch(() => {}) }).catch((e) => { - this.setProfileState(profileId, 'error', errMsg(e)) + this.setProfileState(profileId, 'error', errMessage(e)) if (this._activeId !== profileId) return this.setState('error') }) } catch (err) { this._pool.delete(profileId) - this.setProfileState(profileId, 'error', errMsg(err)) + this.setProfileState(profileId, 'error', errMessage(err)) this.setState('error') throw err } @@ -489,11 +490,6 @@ class ConnectionManagerImpl { export const connectionManager = new ConnectionManagerImpl() -/** 任意抛出值转错误文案 */ -function errMsg(e: unknown): string { - return e instanceof Error ? e.message : String(e) -} - /** snake_case → camelCase */ function snakeToCamel(obj: Record): Record { const result: Record = {} diff --git a/frontend/src/components/FileSystem/index.vue b/frontend/src/components/FileSystem/index.vue index f6aaa70..902fbbd 100644 --- a/frontend/src/components/FileSystem/index.vue +++ b/frontend/src/components/FileSystem/index.vue @@ -155,6 +155,7 @@ import { connectionManager } from '@/api/connection-manager' import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants' import { isAutoRestoreDisabled, clearAutoRestoreDisabled } from '@/utils/freezeWatchdog' import { parseWailsEventData } from '@/utils/wailsEvent' +import { errMessage } from '@/utils/errors' import { createResizeHandler } from '@/utils/resize' // 导入类型 @@ -524,7 +525,7 @@ const handleConnectionChanged = async (profileId: string) => { try { await switchConnection(profileId) } catch (err) { - Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`) + Message.error(`连接失败: ${errMessage(err)}`) } } @@ -647,7 +648,7 @@ const handleOpenFavorite = async (file: FavoriteFile) => { await loadCommonPaths() } catch (e) { console.error('切换连接失败:', e) - Message.error(`无法连接: ${e instanceof Error ? e.message : String(e)}`) + Message.error(`无法连接: ${errMessage(e)}`) return } } else if (!file.profileId) { @@ -1011,7 +1012,7 @@ const handlePasteClipboard = async () => { } catch (error) { failed++ console.error('[Clipboard] 粘贴失败:', item.path, error) - Message.error(`粘贴失败: ${item.name} - ${error instanceof Error ? error.message : error}`) + Message.error(`粘贴失败: ${item.name} - ${errMessage(error)}`) } } @@ -1033,7 +1034,7 @@ const handleDownloadToLocal = async (file: FileItem) => { const localPath = await connectionManager.getTransport().downloadToFile(file.path, dstDir) Message.success(`已下载到: ${localPath}`) } catch (error) { - Message.error(`下载失败: ${error instanceof Error ? error.message : error}`) + Message.error(`下载失败: ${errMessage(error)}`) } } @@ -1067,7 +1068,7 @@ const handleExternalDrop = async (paths: string[]) => { Message.success(`已复制 ${paths.length} 个项目`) } } catch (error) { - Message.error(`拖入失败: ${error instanceof Error ? error.message : error}`) + Message.error(`拖入失败: ${errMessage(error)}`) await loadDirectory(targetDir) } } diff --git a/frontend/src/utils/codeMirrorLoader.js b/frontend/src/utils/codeMirrorLoader.js index febbf9f..f79fbf4 100644 --- a/frontend/src/utils/codeMirrorLoader.js +++ b/frontend/src/utils/codeMirrorLoader.js @@ -108,8 +108,8 @@ export async function loadLanguageExtension(language) { if (stream.eatSpace()) return null if (stream.match(/^"(?:[^"\r\n]|"")*"?/)) return 'string' if (stream.eat('^')) return 'operator' - // !var! 延迟变量、%var% 环境变量、%~d0 类批处理参数 - if (stream.match(/^!\w+!/) || stream.match(/^%\w+%/) || stream.match(/^%~[a-z]*[\d*]/i)) return 'atom' + // !var! 延迟变量、%var% 环境变量、%~d0 类批处理参数、%1 位置参数、%%i for 循环变量 + if (stream.match(/^!\w+!/) || stream.match(/^%\w+%/) || stream.match(/^%~[a-z]*[\d*]/i) || stream.match(/^%\d/) || stream.match(/^%%\w+/)) return 'atom' if (stream.match(/^\w+/)) { return batchKeywords.has(stream.current().toLowerCase()) ? 'keyword' : null } diff --git a/frontend/src/utils/errors.ts b/frontend/src/utils/errors.ts new file mode 100644 index 0000000..a6792bf --- /dev/null +++ b/frontend/src/utils/errors.ts @@ -0,0 +1,7 @@ +/** + * 任意抛出值转错误文案(Error 取 message,其余 String 化) + */ + +export function errMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} diff --git a/internal/filesystem/drawio_viewer.go b/internal/filesystem/drawio_viewer.go index 70710ed..40a552d 100644 --- a/internal/filesystem/drawio_viewer.go +++ b/internal/filesystem/drawio_viewer.go @@ -67,6 +67,13 @@ func EnsureDrawioViewer(progress service.DownloadProgress) error { if isDrawioViewerReady() { return nil } + // 首个下载方已退出仍未就绪(下载失败),等待者立即失败而非空轮询到超时 + drawioEnsureMu.Lock() + ensuring := drawioEnsuring + drawioEnsureMu.Unlock() + if !ensuring { + return fmt.Errorf("drawio viewer 下载失败,请重试") + } time.Sleep(500 * time.Millisecond) } return fmt.Errorf("等待 drawio viewer 下载超时") diff --git a/internal/filesystem/service.go b/internal/filesystem/service.go index c6a50b9..e397fe8 100644 --- a/internal/filesystem/service.go +++ b/internal/filesystem/service.go @@ -552,7 +552,8 @@ func (s *FileSystemService) CopyPath(src, dst string, onProgress func(copied, to } // MovePath 移动文件或目录:同盘直接重命名,跨盘回退为复制后永久删除源(不走回收站) -func (s *FileSystemService) MovePath(src, dst string) error { +// 跨盘复制阶段经 onProgress 回调进度(可为 nil),同盘 rename 无进度 +func (s *FileSystemService) MovePath(src, dst string, onProgress func(copied, total int64)) error { info, err := s.checkCopyMovePaths(src, dst) if err != nil { return err @@ -564,8 +565,9 @@ func (s *FileSystemService) MovePath(src, dst string) error { return nil } - // 跨盘:复制 + 删除源 - if err := s.CopyPath(src, dst, nil); err != nil { + // 跨盘:复制 + 删除源;复制中途失败清理 dst 残留,否则重试会因目标已存在被拒 + if err := s.CopyPath(src, dst, onProgress); err != nil { + _ = os.RemoveAll(dst) return err } if err := os.RemoveAll(src); err != nil { diff --git a/internal/ossdrv/service.go b/internal/ossdrv/service.go index 2b1bb3d..f792577 100644 --- a/internal/ossdrv/service.go +++ b/internal/ossdrv/service.go @@ -5,7 +5,7 @@ import ( "context" "encoding/base64" "fmt" - "log" + "log/slog" "os" "path" "path/filepath" @@ -851,7 +851,7 @@ func supplementDir(c oss.OSSProvider, ctx context.Context, remoteDir string, tmp continue } if count >= common.MaxSupplementFiles { - log.Printf("[站点下载] 补充扫描达到上限: count=%d", common.MaxSupplementFiles) + slog.Warn("站点下载补充扫描达到上限", "count", common.MaxSupplementFiles) return } localPath := filepath.Join(tmpDir, filepath.FromSlash(f.Key)) diff --git a/internal/sftp/service.go b/internal/sftp/service.go index f13ca5d..4d32ca9 100644 --- a/internal/sftp/service.go +++ b/internal/sftp/service.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "errors" "fmt" - "log" "log/slog" "io" "io/fs" @@ -539,7 +538,7 @@ func sftpSupplementDir(s *Service, c *Client, remoteDir string, tmpDir string, s continue } if count >= common.MaxSupplementFiles { - log.Printf("[站点下载] 补充扫描达到上限: count=%d", common.MaxSupplementFiles) + slog.Warn("站点下载补充扫描达到上限", "count", common.MaxSupplementFiles) break } fullPath := path.Join(remoteDir, entry.Name()) @@ -617,6 +616,10 @@ func (s *Service) DownloadToFile(connID, remotePath, localDstDir string, onProgr if err := walk(remotePath, dstDir); err != nil { return "", fmt.Errorf("遍历远程目录失败: %w", err) } + // 空目录时清单为空,downloadFileTo 不会执行,目标目录须显式创建 + if err := os.MkdirAll(dstDir, 0755); err != nil { + return "", fmt.Errorf("创建本地目录失败: %w", err) + } var copied int64 for _, e := range entries { @@ -714,6 +717,12 @@ func (s *Service) UploadFromFile(connID, localPath, remoteDir string, onProgress if err != nil { return err } + // 空目录时文件清单为空,uploadFileTo 不会执行,远端根目录须显式创建 + if err := c.WithRetry(func(sc *sftpclient.Client) error { + return sc.MkdirAll(remoteRoot) + }); err != nil { + return fmt.Errorf("创建远程目录失败: %w", err) + } var copied int64 for _, f := range files { if err := s.uploadFileTo(c, f.local, path.Join(remoteRoot, f.rel), &copied, total, onProgress); err != nil {