新增: 文件操作增强与预览扩展

- 文件剪贴板复制剪切粘贴,重名自动副本
- SFTP下载到本机与外部文件拖入,目录递归
- 传输进度浮动面板,transfer-progress事件链
- Drawio预览,Excel工作线程,冻结看门狗
- cmd与bat自写batch语法高亮,psm1等映射补齐
This commit is contained in:
lxy
2026-09-15 22:26:12 +08:00
parent f1bc5b49f1
commit e7a2c5148e
59 changed files with 3185 additions and 2047 deletions
+206 -30
View File
@@ -5,6 +5,8 @@ import (
"encoding/base64"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"os/exec"
@@ -143,11 +145,10 @@ func (s *FileSystemService) ReadFile(path string) (string, error) {
}
s.logRead(path, int64(len(data)), nil)
return BytesToString(data), nil
return common.BytesToString(data), nil
}
// Write 写入文件内容(实现 FileService 接口)
// writeFile 内部写入实现(路径验证+大小检查+写入+日志)
// writeFileWithLog 内部写入实现(路径验证+大小检查+写入+日志)
func (s *FileSystemService) writeFileWithLog(path string, data []byte) error {
if err := s.validatePath(path); err != nil {
return err
@@ -229,28 +230,20 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
}
}
// 执行删除
var deleteErr error
if info.IsDir() {
deleteErr = os.RemoveAll(path)
} else {
deleteErr = os.Remove(path)
}
s.logDelete(path, info.IsDir(), info.Size(), deleteErr)
if deleteErr != nil {
return nil, fmt.Errorf("删除失败: %v", deleteErr)
}
// 如果启用回收站,移动到回收站而非永久删除
if s.recycleBin != nil {
// 检查是否已在回收站中
if !isInRecycleBin(path) {
if err := s.recycleBin.MoveToRecycleBin(path); err != nil {
// 回收站失败,记录但继续
slog.Warn("移动到回收站失败", "error", err)
// 回收站优先:先尝试移动,失败再永久删除
if s.recycleBin != nil && !isInRecycleBin(path) {
if err := s.recycleBin.MoveToRecycleBin(path); err != nil {
slog.Warn("回收站移动失败,改为永久删除", "error", err)
if err := s.permanentDelete(path, info); err != nil {
return nil, err
}
} else {
s.logDelete(path, info.IsDir(), info.Size(), nil)
}
} else {
// 无回收站或已在回收站中,永久删除
if err := s.permanentDelete(path, info); err != nil {
return nil, err
}
}
@@ -259,7 +252,7 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
Path: filepath.ToSlash(path), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: FormatBytes(info.Size()),
SizeStr: common.FormatBytesInt(info.Size()),
IsDir: info.IsDir(),
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),
@@ -366,7 +359,7 @@ func (s *FileSystemService) CreateDir(path string) (*FileOperationResult, error)
Path: filepath.ToSlash(path), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: FormatBytes(info.Size()),
SizeStr: common.FormatBytesInt(info.Size()),
IsDir: true,
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),
@@ -414,7 +407,7 @@ func (s *FileSystemService) CreateFile(path string) (*FileOperationResult, error
Path: filepath.ToSlash(path), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: FormatBytes(info.Size()),
SizeStr: common.FormatBytesInt(info.Size()),
IsDir: false,
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),
@@ -439,7 +432,7 @@ func (s *FileSystemService) GetFileInfo(path string) (map[string]interface{}, er
"name": info.Name(),
"path": filepath.ToSlash(path), // 统一使用正斜杠
"size": info.Size(),
"size_str": FormatBytes(info.Size()),
"size_str": common.FormatBytesInt(info.Size()),
"is_dir": info.IsDir(),
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
"mode": info.Mode().String(),
@@ -495,7 +488,7 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
Path: filepath.ToSlash(newPath), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: FormatBytes(info.Size()),
SizeStr: common.FormatBytesInt(info.Size()),
IsDir: info.IsDir(),
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),
@@ -503,6 +496,174 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
}, nil
}
// ========== 复制与移动 ==========
// checkCopyMovePaths 复制/移动共用的前置校验:路径合法、源存在、目标不存在
func (s *FileSystemService) checkCopyMovePaths(src, dst string) (os.FileInfo, error) {
if err := s.validatePath(src); err != nil {
return nil, err
}
if err := s.validatePath(dst); err != nil {
return nil, err
}
info, err := os.Lstat(src)
if err != nil {
return nil, fmt.Errorf("源路径不存在: %v", err)
}
if _, err := os.Lstat(dst); err == nil {
return nil, fmt.Errorf("目标已存在: %s", dst)
}
return info, nil
}
// logCopyMoveAudit 记录复制/移动成功审计日志
func (s *FileSystemService) logCopyMoveAudit(op AuditOperation, src, dst string, isDir bool) {
s.logAudit(AuditLogEntry{
Timestamp: getCurrentTimestamp(),
Operation: op,
Path: dst,
OldPath: src,
IsDirectory: isDir,
Success: true,
})
}
// CopyPath 递归复制文件或目录到目标路径(dst 必须不存在,唯一性由调用方保证)
// onProgress 每读取一块回调一次(可为 nil),copied/total 单位字节
func (s *FileSystemService) CopyPath(src, dst string, onProgress func(copied, total int64)) error {
info, err := s.checkCopyMovePaths(src, dst)
if err != nil {
return err
}
total, err := pathSize(src)
if err != nil {
return fmt.Errorf("统计源大小失败: %v", err)
}
var copied int64
if err := copyPathRecursive(src, dst, info, &copied, total, onProgress); err != nil {
return fmt.Errorf("复制失败: %w", err)
}
s.logCopyMoveAudit(OperationCopy, src, dst, info.IsDir())
return nil
}
// MovePath 移动文件或目录:同盘直接重命名,跨盘回退为复制后永久删除源(不走回收站)
func (s *FileSystemService) MovePath(src, dst string) error {
info, err := s.checkCopyMovePaths(src, dst)
if err != nil {
return err
}
// 快路径:同盘 rename(不发进度)
if err := os.Rename(src, dst); err == nil {
s.logCopyMoveAudit(OperationMove, src, dst, info.IsDir())
return nil
}
// 跨盘:复制 + 删除源
if err := s.CopyPath(src, dst, nil); err != nil {
return err
}
if err := os.RemoveAll(src); err != nil {
return fmt.Errorf("移动后删除源失败: %v", err)
}
s.logCopyMoveAudit(OperationMove, src, dst, info.IsDir())
return nil
}
// UniquePath 返回不冲突的目标路径:重名时追加「 - 副本」「 - 副本 (2)」后缀(扩展名前插入)
// 命名循环复用 common.UniqueName(与 SFTP 远端重名逻辑同源)
func UniquePath(path string) string {
dir, name := filepath.Split(path)
unique, err := common.UniqueName(name, func(candidate string) (bool, error) {
_, err := os.Lstat(filepath.Join(dir, candidate))
return err == nil, nil
})
if err != nil {
return path // exists 恒不返回错误,防御
}
return filepath.Join(dir, unique)
}
// copyPathRecursive 递归复制单个路径(文件/目录/符号链接)
func copyPathRecursive(src, dst string, info os.FileInfo, copied *int64, total int64, onProgress func(copied, total int64)) error {
switch {
case info.IsDir():
if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
entryInfo, err := entry.Info()
if err != nil {
return err
}
if err := copyPathRecursive(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name()), entryInfo, copied, total, onProgress); err != nil {
return err
}
}
return nil
case info.Mode()&os.ModeSymlink != 0:
target, err := os.Readlink(src)
if err != nil {
return err
}
return os.Symlink(target, dst)
default:
return copyFileWithProgress(src, dst, info, copied, total, onProgress)
}
}
// copyFileWithProgress 复制单个普通文件,按读取量累计回调进度
func copyFileWithProgress(src, dst string, info os.FileInfo, copied *int64, total int64, onProgress func(copied, total int64)) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm())
if err != nil {
return err
}
defer out.Close()
var reader io.Reader = in
if onProgress != nil {
reader = &common.CountingReader{R: in, OnN: func(n int64) {
*copied += n
onProgress(*copied, total)
}}
}
_, err = io.Copy(out, reader)
return err
}
// pathSize 统计路径总大小(文件为自身大小,目录递归求和,跳过符号链接)
func pathSize(path string) (int64, error) {
var total int64
err := filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type().IsRegular() {
if info, e := d.Info(); e == nil {
total += info.Size()
}
}
return nil
})
return total, err
}
// ========== ZIP操作接口 ==========
// ListZipContents 列出ZIP文件内容(别名,保持向后兼容)
@@ -532,12 +693,27 @@ func getCurrentTimestamp() time.Time {
return time.Now()
}
// permanentDelete 永久删除路径(目录 RemoveAll / 文件 Remove)并记录删除日志
func (s *FileSystemService) permanentDelete(path string, info os.FileInfo) error {
var deleteErr error
if info.IsDir() {
deleteErr = os.RemoveAll(path)
} else {
deleteErr = os.Remove(path)
}
s.logDelete(path, info.IsDir(), info.Size(), deleteErr)
if deleteErr != nil {
return fmt.Errorf("删除失败: %v", deleteErr)
}
return nil
}
// isInRecycleBin 检查路径是否在回收站中
func isInRecycleBin(path string) bool {
recycleBinPath := filepath.Join(common.GetUserDataDir(), "recycle_bin")
cleanPath := filepath.Clean(path)
cleanBinPath := filepath.Clean(recycleBinPath)
return len(cleanPath) >= len(cleanBinPath) && cleanPath[:len(cleanBinPath)] == cleanBinPath
return strings.HasPrefix(cleanPath, cleanBinPath)
}
// ========== 辅助方法 ==========