修复: 文件系统初始化守卫与传输收尾等七处

- filesystemSvc 守卫17绑定方法,异步初始化完成前返回明确错误
- MovePath 跨盘剪切建 move emitter,失败清理目标盘残留
- SFTP 空目录下载/上传补 MkdirAll 落地
- drawio 下载失败等待者立即返回,不再空轮询30分钟
- batch 高亮补 %1 与 %%i 规则
- 错误文案转换抽 errMessage 共享函数,11处归一
- log.Printf 统一 slog.Warn
This commit is contained in:
lxy
2026-09-15 23:49:01 +08:00
parent d211365856
commit 56fe0d9b82
10 changed files with 173 additions and 49 deletions
+106 -20
View File
@@ -11,6 +11,15 @@ import (
"github.com/wailsapp/wails/v3/pkg/application"
)
// filesystemSvc 返回文件系统服务。初始化在启动 goroutine 异步进行,
// 完成前前端触发文件操作时返回明确错误而非 nil panic
func (a *App) filesystemSvc() (*filesystem.FileSystemService, error) {
if a.filesystem == nil {
return nil, fmt.Errorf("文件系统服务尚未初始化完成,请稍后重试")
}
return a.filesystem, nil
}
// EnsureDrawioViewer 确保本地已缓存 drawio viewer(首次预览时下载),前端打开 .drawio 前调用。
// 通过 drawio-download-progress 事件推送下载进度。
func (a *App) EnsureDrawioViewer() (map[string]interface{}, error) {
@@ -30,7 +39,11 @@ func (a *App) EnsureDrawioViewer() (map[string]interface{}, error) {
// ReadFile 读取文件
func (a *App) ReadFile(path string) (string, error) {
return a.filesystem.ReadFile(path)
fs, err := a.filesystemSvc()
if err != nil {
return "", err
}
return fs.ReadFile(path)
}
// WatchFile 开始监听指定文件的变化,变化时发送 file-changed 事件
@@ -50,52 +63,93 @@ func (a *App) UnwatchFile() {
// WriteFile 写入文件
func (a *App) WriteFile(req WriteFileRequest) error {
return a.filesystem.WriteFile(req.Path, req.Content)
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.WriteFile(req.Path, req.Content)
}
// SaveBase64File 将 base64 内容解码后写入文件(用于图片等二进制数据)
func (a *App) SaveBase64File(req SaveBase64FileRequest) error {
return a.filesystem.SaveBase64File(req.Path, req.Content)
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.SaveBase64File(req.Path, req.Content)
}
// ListDir 列出目录
func (a *App) ListDir(path string) ([]map[string]interface{}, error) {
return a.filesystem.ListDir(path)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.ListDir(path)
}
// CreateDir 创建目录
func (a *App) CreateDir(path string) (*filesystem.FileOperationResult, error) {
return a.filesystem.CreateDir(path)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.CreateDir(path)
}
// CreateFile 创建文件
func (a *App) CreateFile(path string) (*filesystem.FileOperationResult, error) {
return a.filesystem.CreateFile(path)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.CreateFile(path)
}
// DeletePath 删除文件或目录
func (a *App) DeletePath(path string) (*filesystem.FileOperationResult, error) {
return a.filesystem.DeletePath(path)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.DeletePath(path)
}
// RenamePath 重命名文件或目录
func (a *App) RenamePath(req RenamePathRequest) (*filesystem.FileOperationResult, error) {
return a.filesystem.RenamePath(req.OldPath, req.NewPath)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.RenamePath(req.OldPath, req.NewPath)
}
// CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度
func (a *App) CopyPath(src, dst string) error {
fs, err := a.filesystemSvc()
if err != nil {
return err
}
emitter := newTransferEmitter(a, "copy", filepath.Base(filepath.FromSlash(src)))
err := a.filesystem.CopyPath(src, dst, func(copied, total int64) {
err = fs.CopyPath(src, dst, func(copied, total int64) {
emitter.progress(emitter.name, copied, total)
})
emitter.finish(err)
return err
}
// MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
// MovePath 移动文件或目录(同盘重命名,跨盘复制后删除),跨盘时经 transfer-progress 事件推送进度
func (a *App) MovePath(src, dst string) error {
return a.filesystem.MovePath(src, dst)
fs, err := a.filesystemSvc()
if err != nil {
return err
}
emitter := newTransferEmitter(a, "move", filepath.Base(filepath.FromSlash(src)))
err = fs.MovePath(src, dst, func(copied, total int64) {
emitter.progress(emitter.name, copied, total)
})
emitter.finish(err)
return err
}
// PickDirectory 打开系统目录选择器,返回选中目录(取消时返回空串)
@@ -177,39 +231,67 @@ func (t *transferEmitter) finish(err error) {
// GetFileInfo 获取文件信息
func (a *App) GetFileInfo(path string) (map[string]interface{}, error) {
return a.filesystem.GetFileInfo(path)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.GetFileInfo(path)
}
// OpenPath 使用系统默认程序打开文件或目录
func (a *App) OpenPath(path string) error {
return a.filesystem.OpenPath(path)
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.OpenPath(path)
}
// ========== Zip 文件操作接口 ==========
// ListZipContents 列出 zip 文件内容
func (a *App) ListZipContents(zipPath string) ([]map[string]interface{}, error) {
return a.filesystem.ListZipContents(zipPath)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.ListZipContents(zipPath)
}
// ExtractFileFromZip 从 zip 文件中提取单个文件内容
func (a *App) ExtractFileFromZip(zipPath, filePath string) (string, error) {
return a.filesystem.ExtractFileFromZip(zipPath, filePath)
fs, err := a.filesystemSvc()
if err != nil {
return "", err
}
return fs.ExtractFileFromZip(zipPath, filePath)
}
// ExtractFileFromZipToTemp 从 zip 文件中提取单个文件到临时目录
func (a *App) ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
return a.filesystem.ExtractFileFromZipToTemp(zipPath, filePath)
fs, err := a.filesystemSvc()
if err != nil {
return "", err
}
return fs.ExtractFileFromZipToTemp(zipPath, filePath)
}
// GetZipFileInfo 获取 zip 文件中特定文件的信息
func (a *App) GetZipFileInfo(zipPath, filePath string) (map[string]interface{}, error) {
return a.filesystem.GetZipFileInfo(zipPath, filePath)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.GetZipFileInfo(zipPath, filePath)
}
// ResolveShortcut 解析快捷方式文件,返回目标路径信息
func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
targetPath, err := a.filesystem.ResolveShortcut(lnkPath)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
targetPath, err := fs.ResolveShortcut(lnkPath)
if err != nil {
return map[string]interface{}{
"success": false,
@@ -217,7 +299,7 @@ func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
}, err
}
fileInfo, err := a.filesystem.GetFileInfo(targetPath)
fileInfo, err := fs.GetFileInfo(targetPath)
if err != nil {
return map[string]interface{}{
"success": true,
@@ -284,5 +366,9 @@ func (a *App) initFilesystemModule() {
// GetAuditLogs 获取审计日志
func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
return a.filesystem.GetAuditLogs(limit)
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.GetAuditLogs(limit)
}
+20 -4
View File
@@ -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()
}
+7 -11
View File
@@ -9,6 +9,7 @@ import { HttpTransport } from './http-transport'
import { SftpTransport } from './sftp-transport'
import { OssTransport } from './oss-transport'
import { getFileServerBaseURL } from './file-server'
import { errMessage } from '@/utils/errors'
import {
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
SftpGetSystemInfo, GetLocalSystemInfo,
@@ -268,7 +269,7 @@ class ConnectionManagerImpl {
}
}
}
} catch (e) { info._error = true; info._errorMsg = errMsg(e) }
} catch (e) { info._error = true; info._errorMsg = errMessage(e) }
this._sysInfoCache.set(profileId, info)
this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info))
@@ -320,7 +321,7 @@ class ConnectionManagerImpl {
} catch (err) {
// 回退目标可能已不在池中(死连接重建时 disconnectProfile 先移除),指向池外会让 getTransport 静默回落本地造成 UI 幻影
this._activeId = (prevActiveId && this._pool.has(prevActiveId)) ? prevActiveId : 'local-default'
this.setProfileState(profileId, 'error', err instanceof Error ? err.message : String(err))
this.setProfileState(profileId, 'error', errMessage(err))
this.notifyChange()
throw err
}
@@ -433,7 +434,7 @@ class ConnectionManagerImpl {
this.fetchSystemInfo(profileId).catch(() => {})
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMsg(err))
this.setProfileState(profileId, 'error', errMessage(err))
this.setState('error')
throw err
}
@@ -453,7 +454,7 @@ class ConnectionManagerImpl {
this.updateProfile(profileId, { lastConnected: Date.now() })
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMsg(err))
this.setProfileState(profileId, 'error', errMessage(err))
this.setState('error')
throw err
}
@@ -474,13 +475,13 @@ class ConnectionManagerImpl {
this.updateProfile(profileId, { lastConnected: Date.now() })
this.fetchSystemInfo(profileId).catch(() => {})
}).catch((e) => {
this.setProfileState(profileId, 'error', errMsg(e))
this.setProfileState(profileId, 'error', errMessage(e))
if (this._activeId !== profileId) return
this.setState('error')
})
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMsg(err))
this.setProfileState(profileId, 'error', errMessage(err))
this.setState('error')
throw err
}
@@ -489,11 +490,6 @@ class ConnectionManagerImpl {
export const connectionManager = new ConnectionManagerImpl()
/** 任意抛出值转错误文案 */
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
/** snake_case → camelCase */
function snakeToCamel(obj: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {}
+6 -5
View File
@@ -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)
}
}
+2 -2
View File
@@ -108,8 +108,8 @@ export async function loadLanguageExtension(language) {
if (stream.eatSpace()) return null
if (stream.match(/^"(?:[^"\r\n]|"")*"?/)) return 'string'
if (stream.eat('^')) return 'operator'
// !var! 延迟变量、%var% 环境变量、%~d0 类批处理参数
if (stream.match(/^!\w+!/) || stream.match(/^%\w+%/) || stream.match(/^%~[a-z]*[\d*]/i)) return 'atom'
// !var! 延迟变量、%var% 环境变量、%~d0 类批处理参数、%1 位置参数、%%i for 循环变量
if (stream.match(/^!\w+!/) || stream.match(/^%\w+%/) || stream.match(/^%~[a-z]*[\d*]/i) || stream.match(/^%\d/) || stream.match(/^%%\w+/)) return 'atom'
if (stream.match(/^\w+/)) {
return batchKeywords.has(stream.current().toLowerCase()) ? 'keyword' : null
}
+7
View File
@@ -0,0 +1,7 @@
/**
* 任意抛出值转错误文案(Error 取 message,其余 String 化)
*/
export function errMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
+7
View File
@@ -67,6 +67,13 @@ func EnsureDrawioViewer(progress service.DownloadProgress) error {
if isDrawioViewerReady() {
return nil
}
// 首个下载方已退出仍未就绪(下载失败),等待者立即失败而非空轮询到超时
drawioEnsureMu.Lock()
ensuring := drawioEnsuring
drawioEnsureMu.Unlock()
if !ensuring {
return fmt.Errorf("drawio viewer 下载失败,请重试")
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("等待 drawio viewer 下载超时")
+5 -3
View File
@@ -552,7 +552,8 @@ func (s *FileSystemService) CopyPath(src, dst string, onProgress func(copied, to
}
// MovePath 移动文件或目录:同盘直接重命名,跨盘回退为复制后永久删除源(不走回收站)
func (s *FileSystemService) MovePath(src, dst string) error {
// 跨盘复制阶段经 onProgress 回调进度(可为 nil),同盘 rename 无进度
func (s *FileSystemService) MovePath(src, dst string, onProgress func(copied, total int64)) error {
info, err := s.checkCopyMovePaths(src, dst)
if err != nil {
return err
@@ -564,8 +565,9 @@ func (s *FileSystemService) MovePath(src, dst string) error {
return nil
}
// 跨盘:复制 + 删除源
if err := s.CopyPath(src, dst, nil); err != nil {
// 跨盘:复制 + 删除源;复制中途失败清理 dst 残留,否则重试会因目标已存在被拒
if err := s.CopyPath(src, dst, onProgress); err != nil {
_ = os.RemoveAll(dst)
return err
}
if err := os.RemoveAll(src); err != nil {
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"context"
"encoding/base64"
"fmt"
"log"
"log/slog"
"os"
"path"
"path/filepath"
@@ -851,7 +851,7 @@ func supplementDir(c oss.OSSProvider, ctx context.Context, remoteDir string, tmp
continue
}
if count >= common.MaxSupplementFiles {
log.Printf("[站点下载] 补充扫描达到上限: count=%d", common.MaxSupplementFiles)
slog.Warn("站点下载补充扫描达到上限", "count", common.MaxSupplementFiles)
return
}
localPath := filepath.Join(tmpDir, filepath.FromSlash(f.Key))
+11 -2
View File
@@ -5,7 +5,6 @@ import (
"encoding/base64"
"errors"
"fmt"
"log"
"log/slog"
"io"
"io/fs"
@@ -539,7 +538,7 @@ func sftpSupplementDir(s *Service, c *Client, remoteDir string, tmpDir string, s
continue
}
if count >= common.MaxSupplementFiles {
log.Printf("[站点下载] 补充扫描达到上限: count=%d", common.MaxSupplementFiles)
slog.Warn("站点下载补充扫描达到上限", "count", common.MaxSupplementFiles)
break
}
fullPath := path.Join(remoteDir, entry.Name())
@@ -617,6 +616,10 @@ func (s *Service) DownloadToFile(connID, remotePath, localDstDir string, onProgr
if err := walk(remotePath, dstDir); err != nil {
return "", fmt.Errorf("遍历远程目录失败: %w", err)
}
// 空目录时清单为空,downloadFileTo 不会执行,目标目录须显式创建
if err := os.MkdirAll(dstDir, 0755); err != nil {
return "", fmt.Errorf("创建本地目录失败: %w", err)
}
var copied int64
for _, e := range entries {
@@ -714,6 +717,12 @@ func (s *Service) UploadFromFile(connID, localPath, remoteDir string, onProgress
if err != nil {
return err
}
// 空目录时文件清单为空,uploadFileTo 不会执行,远端根目录须显式创建
if err := c.WithRetry(func(sc *sftpclient.Client) error {
return sc.MkdirAll(remoteRoot)
}); err != nil {
return fmt.Errorf("创建远程目录失败: %w", err)
}
var copied int64
for _, f := range files {
if err := s.uploadFileTo(c, f.local, path.Join(remoteRoot, f.rel), &copied, total, onProgress); err != nil {