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

17 KiB
Raw Permalink Blame History

模块08新建文件夹功能


🎨 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标准布局

8.1 功能概述

实现在本地文件系统和SFTP服务器上创建新文件夹的功能。

8.2 后端设计

8.2.1 LocalFileService创建目录方法

// 创建目录
public boolean createDirectory(String path) throws Exception {
    File directory = new File(path);

    if (directory.exists()) {
        throw new Exception("目录已存在: " + path);
    }

    // 验证目录名
    String dirName = directory.getName();
    if (!isValidDirectoryName(dirName)) {
        throw new Exception("目录名包含非法字符: " + dirName);
    }

    // 检查父目录是否存在
    File parentDir = directory.getParentFile();
    if (parentDir != null && !parentDir.exists()) {
        // 父目录不存在,询问是否创建多级目录
        throw new Exception("父目录不存在: " + parentDir.getPath());
    }

    boolean result = directory.mkdirs();
    if (!result) {
        throw new Exception("创建目录失败");
    }

    return true;
}

// 创建多级目录
public boolean createDirectories(String path) throws Exception {
    File directory = new File(path);

    if (directory.exists()) {
        throw new Exception("目录已存在: " + path);
    }

    // 验证路径中的每个目录名
    String[] parts = path.split("[/\\\\]");
    for (String part : parts) {
        if (!isValidDirectoryName(part)) {
            throw new Exception("路径包含非法字符: " + part);
        }
    }

    boolean result = directory.mkdirs();
    if (!result) {
        throw new Exception("创建目录失败");
    }

    return true;
}

// 验证目录名是否有效
private boolean isValidDirectoryName(String dirName) {
    if (dirName == null || dirName.isEmpty()) {
        return false;
    }

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

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

    // 检查保留名称Windows
    String upperName = dirName.toUpperCase();
    String[] reservedNames = {"CON", "PRN", "AUX", "NUL"};
    for (String reserved : reservedNames) {
        if (upperName.equals(reserved)) {
            return false;
        }
    }

    return true;
}

8.2.2 SftpService创建目录方法

// 创建目录
public boolean createDirectory(String sessionId, String path) throws Exception {
    ChannelSftp channel = sessionManager.getSession(sessionId);
    if (channel == null) {
        throw new Exception("会话不存在或已断开");
    }

    try {
        // 检查目录是否已存在
        try {
            channel.stat(path);
            throw new Exception("目录已存在: " + path);
        } catch (SftpException e) {
            if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) {
                throw e;
            }
        }

        // 创建目录
        channel.mkdir(path);
        return true;
    } catch (SftpException e) {
        throw new Exception("创建目录失败: " + e.getMessage(), e);
    }
}

// 创建多级目录
public boolean createDirectories(String sessionId, String path) throws Exception {
    ChannelSftp channel = sessionManager.getSession(sessionId);
    if (channel == null) {
        throw new Exception("会话不存在或已断开");
    }

    try {
        // 检查目录是否已存在
        try {
            channel.stat(path);
            throw new Exception("目录已存在: " + path);
        } catch (SftpException e) {
            if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) {
                throw e;
            }
        }

        // 递归创建多级目录
        createDirectoriesRecursive(channel, path);
        return true;
    } catch (SftpException e) {
        throw new Exception("创建目录失败: " + e.getMessage(), e);
    }
}

// 递归创建多级目录
private void createDirectoriesRecursive(ChannelSftp channel, String path) throws SftpException {
    // 如果是根目录,直接返回
    if (path.equals("/") || path.isEmpty()) {
        return;
    }

    // 检查父目录是否存在
    String parentPath = getParentPath(path);
    if (!parentPath.equals(path)) {
        try {
            channel.stat(parentPath);
        } catch (SftpException e) {
            if (e.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) {
                // 父目录不存在,递归创建
                createDirectoriesRecursive(channel, parentPath);
            } else {
                throw e;
            }
        }
    }

    // 创建当前目录
    channel.mkdir(path);
}

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

8.2.3 FileController创建目录接口

// 创建目录
@PostMapping("/mkdir")
public ApiResponse<Void> createDirectory(@RequestBody Map<String, String> request) {
    try {
        String sessionId = request.get("sessionId");
        String path = request.get("path");

        if (path == null || path.isEmpty()) {
            return ApiResponse.error("路径不能为空");
        }

        boolean result;
        if ("local".equals(sessionId)) {
            result = localFileService.createDirectory(path);
        } else {
            result = sftpService.createDirectory(sessionId, path);
        }

        if (result) {
            return ApiResponse.success("创建成功", null);
        } else {
            return ApiResponse.error("创建失败");
        }
    } catch (Exception e) {
        return ApiResponse.error("创建失败: " + e.getMessage());
    }
}

// 创建多级目录
@PostMapping("/mkdir-p")
public ApiResponse<Void> createDirectories(@RequestBody Map<String, String> request) {
    try {
        String sessionId = request.get("sessionId");
        String path = request.get("path");

        if (path == null || path.isEmpty()) {
            return ApiResponse.error("路径不能为空");
        }

        boolean result;
        if ("local".equals(sessionId)) {
            result = localFileService.createDirectories(path);
        } else {
            result = sftpService.createDirectories(sessionId, path);
        }

        if (result) {
            return ApiResponse.success("创建成功", null);
        } else {
            return ApiResponse.error("创建失败");
        }
    } catch (Exception e) {
        return ApiResponse.error("创建失败: " + e.getMessage());
    }
}

8.3 前端设计

8.3.1 新建文件夹交互

// 显示新建文件夹对话框
function showMkdirDialog(panelId) {
    const sessionId = panelState[panelId].sessionId;
    const currentPath = panelState[panelId].currentPath;

    const folderName = prompt('请输入文件夹名称:', '新建文件夹');
    if (folderName && folderName.trim() !== '') {
        createDirectory(sessionId, currentPath, folderName);
    }
}

// 创建目录
function createDirectory(sessionId, parentPath, folderName) {
    // 验证文件夹名称
    if (!validateDirectoryName(folderName)) {
        return;
    }

    // 构建完整路径
    let fullPath;
    if ("local".equals(sessionId)) {
        fullPath = parentPath + File.separator + folderName;
    } else {
        fullPath = parentPath.endsWith('/') ?
                parentPath + folderName :
                parentPath + '/' + folderName;
    }

    $.ajax({
        url: '/api/files/mkdir',
        method: 'POST',
        contentType: 'application/json',
        data: JSON.stringify({
            sessionId: sessionId,
            path: fullPath
        }),
        success: function(response) {
            if (response.success) {
                alert('文件夹创建成功');
                refreshCurrentPanel();
            } else {
                alert('创建失败: ' + response.message);
            }
        },
        error: handleError
    });
}

// 验证目录名
function validateDirectoryName(dirName) {
    if (!dirName || dirName.trim() === '') {
        alert('文件夹名称不能为空');
        return false;
    }

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

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

    return true;
}

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

8.3.2 新建文件夹按钮

<div class="toolbar">
    <button onclick="showMkdirDialog('left')">新建文件夹</button>
</div>

8.3.3 右键菜单新建文件夹

// 在右键菜单中添加新建文件夹选项
function showContextMenu(event, panelId) {
    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="showMkdirDialog(\'' +
                panelId + '\')">新建文件夹</div>');

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

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

8.3.4 快捷键支持

// 监听键盘事件
$(document).on('keydown', function(e) {
    // Ctrl+Shift+N: 新建文件夹
    if (e.ctrlKey && e.shiftKey && e.key === 'N') {
        const activePanelId = getActivePanelId();
        showMkdirDialog(activePanelId);
        e.preventDefault();
    }
});

8.3.5 在线输入(高级功能)

// 在文件列表中直接输入新文件夹名称
function showInlineMkdir(panelId) {
    const fileList = $(`#${panelId}-file-list`);

    // 创建新建文件夹项
    const newItem = $(`
        <div class="file-item new-folder">
            <span class="file-icon">📁</span>
            <span class="file-name">
                <input type="text" class="new-folder-input" placeholder="输入文件夹名称">
            </span>
        </div>
    `);

    // 插入到列表顶部
    fileList.prepend(newItem);

    // 聚焦输入框
    const input = newItem.find('.new-folder-input');
    input.focus();

    // 失去焦点时创建
    input.on('blur', function() {
        const folderName = input.val().trim();
        if (folderName) {
            createDirectory(panelState[panelId].sessionId,
                          panelState[panelId].currentPath,
                          folderName);
        }
        newItem.remove();
    });

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

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

8.4 输入验证

8.4.1 前端验证

// 验证目录名
function validateDirectoryName(dirName) {
    // 基本验证
    if (!dirName || dirName.trim() === '') {
        alert('文件夹名称不能为空');
        return false;
    }

    // 去除首尾空格
    dirName = dirName.trim();

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

    // 不能以点开头或结尾Linux/Mac隐藏目录
    if (dirName.startsWith('.') || dirName.endsWith('.')) {
        alert('文件夹名称不能以点开头或结尾');
        return false;
    }

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

    // Windows保留名称
    const upperName = dirName.toUpperCase();
    const reservedNames = ['CON', 'PRN', 'AUX', 'NUL',
                           'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
                           'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
    if (reservedNames.includes(upperName)) {
        alert('文件夹名称是系统保留名称');
        return false;
    }

    return true;
}

8.4.2 后端验证

// 验证目录名(后端)
private boolean isValidDirectoryName(String dirName) {
    if (dirName == null || dirName.isEmpty()) {
        return false;
    }

    // 去除首尾空格
    dirName = dirName.trim();

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

    // 不能以点开头或结尾
    if (dirName.startsWith(".") || dirName.endsWith(".")) {
        return false;
    }

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

    // Windows保留名称
    String upperName = dirName.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;
}

8.4.3 路径处理

// 拼接路径
function joinPath(parentPath, childName) {
    // 移除子名称的首尾空格
    childName = childName.trim();

    // 判断是否为本地路径
    const isLocal = parentPath.includes('\\') ||
                    parentPath.includes('C:') ||
                    parentPath.includes('D:');

    if (isLocal) {
        // Windows路径
        if (parentPath.endsWith('\\') || parentPath.endsWith('/')) {
            return parentPath + childName;
        } else {
            return parentPath + '\\' + childName;
        }
    } else {
        // Linux/SFTP路径
        if (parentPath.endsWith('/')) {
            return parentPath + childName;
        } else {
            return parentPath + '/' + childName;
        }
    }
}

实施步骤

  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/mkdir \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "local",
    "path": "C:/test/newfolder"
  }'

2. 创建多级目录

curl -X POST http://localhost:8080/sftp-manager/api/files/mkdir-p \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "local",
    "path": "C:/test/level1/level2/level3"
  }'

3. 在SFTP上创建目录

curl -X POST http://localhost:8080/sftp-manager/api/files/mkdir \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "sftp-uuid",
    "path": "/home/user/newfolder"
  }'

4. 测试非法目录名

curl -X POST http://localhost:8080/sftp-manager/api/files/mkdir \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "local",
    "path": "C:/test/folder<name"
  }'

注意事项

  1. 目录名验证:严格验证目录名,防止非法字符
  2. 路径拼接:正确处理不同操作系统的路径分隔符
  3. 权限检查:确保有权限在指定位置创建目录
  4. 同名检查:创建前检查目录是否已存在
  5. 父目录存在性:单级创建时检查父目录是否存在

下一步

完成模块08后继续模块09双面板UI界面设计