Files
sftp-manager/docs/07-文件重命名功能.md
liu 14289beb66 Initial commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 10:10:11 +08:00

15 KiB
Raw Permalink Blame History

模块07文件重命名功能


🎨 UI设计系统概览

完整设计系统文档请参考: UI设计系统.md

核心设计原则

  • 现代简约:界面清晰,层次分明
  • 专业高效:减少操作步骤,提升工作效率
  • 一致性:统一的视觉语言和交互模式
  • 可访问性符合WCAG 2.1 AA标准

关键设计令牌

颜色系统:

  • 主色:#0d6efd(操作按钮、选中状态)
  • 成功:#198754(连接成功状态)
  • 危险:#dc3545(删除操作、错误提示)
  • 深灰:#212529(导航栏背景)
  • 浅灰:#e9ecef(工具栏背景)

字体系统:

  • 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等
  • 正文14px行高1.5
  • 标题20-32px行高1.2-1.4
  • 小号文字12px文件大小、日期等

间距系统:

  • 基础单位8px
  • 标准间距16px1rem
  • 组件内边距8px-16px

组件规范:

  • 导航栏高度48px深色背景
  • 工具栏浅灰背景按钮间距8px
  • 文件项最小高度44px悬停效果150ms
  • 按钮圆角4px过渡150ms

交互规范:

  • 悬停效果150ms过渡
  • 触摸目标最小44x44px
  • 键盘导航Tab、Enter、Delete、F2、F5、Esc
  • 焦点状态2px蓝色轮廓

响应式断点:

  • 移动端:< 768px双面板垂直排列
  • 平板768px - 1024px
  • 桌面:> 1024px标准布局

7.1 功能概述

实现重命名本地文件和SFTP服务器上文件的功能支持单个文件重命名。

7.2 后端设计

7.2.1 LocalFileService重命名方法

// 重命名文件
public boolean renameFile(String oldPath, String newPath) throws Exception {
    File oldFile = new File(oldPath);
    File newFile = new File(newPath);

    if (!oldFile.exists()) {
        throw new Exception("源文件不存在: " + oldPath);
    }

    if (newFile.exists()) {
        throw new Exception("目标文件已存在: " + newPath);
    }

    // 检查新文件名是否有效
    String newFileName = newFile.getName();
    if (!isValidFileName(newFileName)) {
        throw new Exception("文件名包含非法字符: " + newFileName);
    }

    boolean result = oldFile.renameTo(newFile);
    if (!result) {
        throw new Exception("重命名失败");
    }

    return true;
}

// 验证文件名是否有效
private boolean isValidFileName(String fileName) {
    if (fileName == null || fileName.isEmpty()) {
        return false;
    }

    // Windows非法字符
    String illegalChars = "\\/:*?\"<>|";
    for (char c : illegalChars.toCharArray()) {
        if (fileName.indexOf(c) != -1) {
            return false;
        }
    }

    // 检查长度限制
    if (fileName.length() > 255) {
        return false;
    }

    // 检查保留名称Windows
    String upperName = fileName.toUpperCase();
    String[] reservedNames = {"CON", "PRN", "AUX", "NUL",
                             "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
                             "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"};
    for (String reserved : reservedNames) {
        if (upperName.equals(reserved)) {
            return false;
        }
    }

    return true;
}

// 获取文件扩展名
public String getFileExtension(String fileName) {
    int index = fileName.lastIndexOf('.');
    if (index == -1 || index == fileName.length() - 1) {
        return "";
    }
    return fileName.substring(index);
}

// 获取文件名(不带扩展名)
public String getFileNameWithoutExtension(String fileName) {
    int index = fileName.lastIndexOf('.');
    if (index == -1) {
        return fileName;
    }
    return fileName.substring(0, index);
}

7.2.2 SftpService重命名方法

// 重命名文件
public boolean renameFile(String sessionId, String oldPath, String newPath) throws Exception {
    ChannelSftp channel = sessionManager.getSession(sessionId);
    if (channel == null) {
        throw new Exception("会话不存在或已断开");
    }

    try {
        // 检查源文件是否存在
        channel.stat(oldPath);

        // 检查目标文件是否已存在
        try {
            channel.stat(newPath);
            throw new Exception("目标文件已存在: " + newPath);
        } catch (SftpException e) {
            // 文件不存在,可以重命名
        }

        // 执行重命名
        channel.rename(oldPath, newPath);
        return true;
    } catch (SftpException e) {
        throw new Exception("重命名失败: " + e.getMessage(), e);
    }
}

7.2.3 FileController重命名接口

// 重命名文件
@PostMapping("/rename")
public ApiResponse<Void> renameFile(@RequestBody Map<String, String> request) {
    try {
        String sessionId = request.get("sessionId");
        String oldPath = request.get("oldPath");
        String newName = request.get("newName");

        if (newName == null || newName.isEmpty()) {
            return ApiResponse.error("新文件名不能为空");
        }

        // 构建新路径
        String newPath;
        if ("local".equals(sessionId)) {
            File oldFile = new File(oldPath);
            File parentDir = oldFile.getParentFile();
            newPath = parentDir.getPath() + File.separator + newName;
            localFileService.renameFile(oldPath, newPath);
        } else {
            // 获取父目录
            String parentPath = getParentPath(oldPath);
            newPath = parentPath.endsWith("/") ?
                    parentPath + newName :
                    parentPath + "/" + newName;
            sftpService.renameFile(sessionId, oldPath, newPath);
        }

        return ApiResponse.success("重命名成功", null);
    } catch (Exception e) {
        return ApiResponse.error("重命名失败: " + e.getMessage());
    }
}

// 获取父路径
private String getParentPath(String path) {
    if (path.endsWith("/")) {
        path = path.substring(0, path.length() - 1);
    }
    int index = path.lastIndexOf("/");
    if (index == -1) {
        return "/";
    }
    return path.substring(0, index);
}

7.3 前端设计

7.3.1 重命名交互

// 显示重命名对话框
function showRenameDialog(panelId, path) {
    const sessionId = panelState[panelId].sessionId;
    const oldName = getFileNameFromPath(path);

    const newName = prompt('请输入新文件名:', oldName);
    if (newName && newName !== oldName) {
        renameFile(sessionId, path, newName);
    }
}

// 重命名文件
function renameFile(sessionId, oldPath, newName) {
    $.ajax({
        url: '/api/files/rename',
        method: 'POST',
        contentType: 'application/json',
        data: JSON.stringify({
            sessionId: sessionId,
            oldPath: oldPath,
            newName: newName
        }),
        success: function(response) {
            if (response.success) {
                alert('重命名成功');
                refreshCurrentPanel();
            } else {
                alert('重命名失败: ' + response.message);
            }
        },
        error: handleError
    });
}

// 从路径获取文件名
function getFileNameFromPath(path) {
    let separator = path.includes('\\') ? '\\' : '/';
    const index = path.lastIndexOf(separator);
    if (index === -1) {
        return path;
    }
    return path.substring(index + 1);
}

// 刷新当前面板
function refreshCurrentPanel() {
    const activePanelId = getActivePanelId();
    loadFiles(activePanelId);
}

// 获取当前活动面板ID
function getActivePanelId() {
    return 'left'; // 默认返回左侧,可根据实际逻辑调整
}

7.3.2 重命名按钮

<div class="toolbar">
    <button onclick="showRenameDialog('left', getSelectedPath('left'))">重命名</button>
</div>
// 获取选中文件的路径
function getSelectedPath(panelId) {
    const selectedFiles = panelState[panelId].selectedFiles;
    if (selectedFiles.length === 0) {
        alert('请先选择一个文件');
        return null;
    }
    if (selectedFiles.length > 1) {
        alert('一次只能重命名一个文件');
        return null;
    }
    return selectedFiles[0];
}

7.3.3 右键菜单重命名

// 在右键菜单中添加重命名选项
function showContextMenu(event, panelId, path) {
    event.preventDefault();

    const menu = $('<div class="context-menu">');
    menu.css({
        position: 'absolute',
        left: event.pageX + 'px',
        top: event.pageY + 'px'
    });

    menu.append('<div class="menu-item" onclick="deleteFile(\'' +
                panelId + '\', \'' + path + '\')">删除</div>');
    menu.append('<div class="menu-item" onclick="showRenameDialog(\'' +
                panelId + '\', \'' + path + '\')">重命名</div>');

    $('body').append(menu);

    $(document).one('click', function() {
        menu.remove();
    });
}

7.3.4 在线编辑(高级功能)

// 双击文件名进行重命名
function enableInlineRename() {
    $('.file-item').on('dblclick', function(e) {
        const fileItem = $(this);
        const isDirectory = fileItem.data('is-dir');

        // 如果是目录,进入目录;如果是文件,进入重命名模式
        if (!isDirectory) {
            const path = fileItem.data('path');
            showRenameDialogInline(fileItem, path);
        }
    });
}

// 在线重命名
function showRenameDialogInline(fileItem, path) {
    const nameElement = fileItem.find('.file-name');
    const currentName = nameElement.text();

    // 创建输入框
    const input = $('<input type="text" class="rename-input">');
    input.val(currentName);

    // 替换文件名显示为输入框
    nameElement.html(input);
    input.focus();
    input.select();

    // 失去焦点时保存
    input.on('blur', function() {
        const newName = input.val();
        if (newName && newName !== currentName) {
            renameFile(panelState.left.sessionId, path, newName);
        } else {
            // 取消重命名,恢复原名称
            nameElement.text(currentName);
        }
    });

    // 按Enter保存
    input.on('keypress', function(e) {
        if (e.which === 13) {
            input.blur();
        }
    });

    // 按Esc取消
    input.on('keydown', function(e) {
        if (e.which === 27) {
            nameElement.text(currentName);
        }
    });
}

7.4 输入验证

7.4.1 前端验证

// 验证文件名
function validateFileName(fileName) {
    if (!fileName || fileName.trim() === '') {
        alert('文件名不能为空');
        return false;
    }

    // Windows非法字符
    const illegalChars = /\\\/:\*\?"<>\|/;
    if (illegalChars.test(fileName)) {
        alert('文件名包含非法字符');
        return false;
    }

    // 长度限制
    if (fileName.length > 255) {
        alert('文件名过长最大255字符');
        return false;
    }

    return true;
}

// 修改重命名对话框
function showRenameDialog(panelId, path) {
    const oldName = getFileNameFromPath(path);

    while (true) {
        const newName = prompt('请输入新文件名:', oldName);
        if (newName === null) {
            // 用户取消
            return;
        }
        if (newName === '' || newName === oldName) {
            // 无效输入
            continue;
        }

        if (validateFileName(newName)) {
            renameFile(panelState[panelId].sessionId, path, newName);
            break;
        }
    }
}

7.4.2 保留扩展名

// 提取扩展名
function getFileExtension(fileName) {
    const index = fileName.lastIndexOf('.');
    if (index === -1 || index === fileName.length - 1) {
        return '';
    }
    return fileName.substring(index);
}

// 修改重命名对话框(保留扩展名)
function showRenameDialog(panelId, path) {
    const sessionId = panelState[panelId].sessionId;
    const oldName = getFileNameFromPath(path);
    const extension = getFileExtension(oldName);
    const baseName = oldName.substring(0, oldName.length - extension.length);

    const newName = prompt('请输入新文件名:', baseName);
    if (newName && newName !== baseName) {
        // 保留原扩展名
        const fullName = newName + extension;
        renameFile(sessionId, path, fullName);
    }
}

7.4.3 同名检查

// 检查同名文件是否存在
function checkFileExists(sessionId, parentPath, fileName, callback) {
    const fullPath = parentPath.endsWith('/') ?
            parentPath + fileName :
            parentPath + '/' + fileName;

    $.ajax({
        url: '/api/files/info',
        method: 'POST',
        contentType: 'application/json',
        data: JSON.stringify({
            sessionId: sessionId,
            path: fullPath
        }),
        success: function(response) {
            // 文件存在
            callback(true);
        },
        error: function() {
            // 文件不存在
            callback(false);
        }
    });
}

// 重命名时检查同名
function renameFile(sessionId, oldPath, newName) {
    const parentPath = getParentPath(oldPath);

    checkFileExists(sessionId, parentPath, newName, function(exists) {
        if (exists) {
            if (confirm('目标文件已存在,是否覆盖?')) {
                doRename(sessionId, oldPath, newName);
            }
        } else {
            doRename(sessionId, oldPath, newName);
        }
    });
}

function doRename(sessionId, oldPath, newName) {
    // 原重命名逻辑
    $.ajax({
        url: '/api/files/rename',
        method: 'POST',
        contentType: 'application/json',
        data: JSON.stringify({
            sessionId: sessionId,
            oldPath: oldPath,
            newName: newName
        }),
        success: function(response) {
            if (response.success) {
                alert('重命名成功');
                refreshCurrentPanel();
            } else {
                alert('重命名失败: ' + response.message);
            }
        },
        error: handleError
    });
}

实施步骤

  1. 更新LocalFileService:添加重命名和验证方法

  2. 更新SftpService:添加重命名方法

  3. 更新FileController:添加重命名接口

  4. 添加前端重命名功能

  5. 编译测试

    mvn clean compile
    
  6. 启动服务

    mvn spring-boot:run
    

测试验证

1. 重命名文件

curl -X POST http://localhost:8080/sftp-manager/api/files/rename \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "local",
    "oldPath": "C:/test/old.txt",
    "newName": "new.txt"
  }'

2. 测试非法文件名

curl -X POST http://localhost:8080/sftp-manager/api/files/rename \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "local",
    "oldPath": "C:/test/test.txt",
    "newName": "test<.txt"
  }'

3. 测试重名

curl -X POST http://localhost:8080/sftp-manager/api/files/rename \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "local",
    "oldPath": "C:/test/file1.txt",
    "newName": "file2.txt"
  }'

注意事项

  1. 文件名验证:严格验证文件名,防止非法字符
  2. 同名检查:重命名前检查目标名称是否已存在
  3. 扩展名处理:可选择保留原扩展名
  4. 用户确认:重名时需要用户确认是否覆盖
  5. 路径处理:正确处理不同操作系统的路径分隔符

下一步

完成模块07后继续模块08新建文件夹功能