Files
sftp-manager/docs/04-文件浏览功能.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

模块04文件浏览功能


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

4.1 功能概述

实现本地文件系统和SFTP服务器文件系统的浏览功能支持目录导航、文件列表展示。

4.2 后端设计

4.2.1 LocalFileService本地文件服务

package com.sftp.manager.service;

import com.sftp.manager.model.FileInfo;
import org.springframework.stereotype.Service;

import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;

@Service
public class LocalFileService {

    public List<FileInfo> listFiles(String path) throws Exception {
        List<FileInfo> files = new ArrayList<>();
        File directory = new File(path);

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

        File[] fileArray = directory.listFiles();
        if (fileArray != null) {
            for (File file : fileArray) {
                FileInfo fileInfo = new FileInfo();
                fileInfo.setName(file.getName());
                fileInfo.setPath(file.getAbsolutePath());
                fileInfo.setSize(file.length());
                fileInfo.setIsDirectory(file.isDirectory());

                // 获取修改时间
                BasicFileAttributes attrs = Files.readAttributes(
                        file.toPath(), BasicFileAttributes.class);
                fileInfo.setModifiedTime(LocalDateTime.ofInstant(
                        attrs.lastModifiedTime().toInstant(),
                        ZoneId.systemDefault()));

                // 设置权限仅Windows
                if (file.canRead() && file.canWrite()) {
                    fileInfo.setPermissions("-rw-r--r--");
                } else if (file.canRead()) {
                    fileInfo.setPermissions("-r--r--r--");
                }

                files.add(fileInfo);
            }
        }

        return files;
    }

    public boolean fileExists(String path) {
        return new File(path).exists();
    }

    public FileInfo getFileInfo(String path) throws Exception {
        File file = new File(path);
        if (!file.exists()) {
            throw new Exception("文件不存在: " + path);
        }

        FileInfo fileInfo = new FileInfo();
        fileInfo.setName(file.getName());
        fileInfo.setPath(file.getAbsolutePath());
        fileInfo.setSize(file.length());
        fileInfo.setIsDirectory(file.isDirectory());

        BasicFileAttributes attrs = Files.readAttributes(
                file.toPath(), BasicFileAttributes.class);
        fileInfo.setModifiedTime(LocalDateTime.ofInstant(
                attrs.lastModifiedTime().toInstant(),
                ZoneId.systemDefault()));

        return fileInfo;
    }

    public String getParentPath(String path) {
        File file = new File(path);
        return file.getParent();
    }
}

4.2.2 SftpService SFTP文件服务

package com.sftp.manager.service;

import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.SftpException;
import com.sftp.manager.model.FileInfo;
import com.sftp.manager.service.SessionManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Vector;

@Service
public class SftpService {

    @Autowired
    private SessionManager sessionManager;

    public List<FileInfo> listFiles(String sessionId, String path) throws Exception {
        ChannelSftp channel = sessionManager.getSession(sessionId);
        if (channel == null) {
            throw new Exception("会话不存在或已断开");
        }

        try {
            List<FileInfo> files = new ArrayList<>();
            Vector<ChannelSftp.LsEntry> entries = channel.ls(path);

            for (ChannelSftp.LsEntry entry : entries) {
                String fileName = entry.getFilename();

                // 跳过.和..
                if (".".equals(fileName) || "..".equals(fileName)) {
                    continue;
                }

                FileInfo fileInfo = new FileInfo();
                fileInfo.setName(fileName);
                fileInfo.setPath(path.endsWith("/") ? path + fileName : path + "/" + fileName);

                ChannelSftp.LsEntry attrs = entry;
                fileInfo.setSize(attrs.getSize());
                fileInfo.setIsDirectory(attrs.getAttrs().isDir());

                // 获取修改时间
                int mtime = attrs.getAttrs().getMTime();
                fileInfo.setModifiedTime(LocalDateTime.ofInstant(
                        Instant.ofEpochSecond(mtime),
                        ZoneId.systemDefault()));

                // 获取权限
                fileInfo.setPermissions(attrs.getAttrs().getPermissionsString());

                files.add(fileInfo);
            }

            return files;
        } catch (SftpException e) {
            throw new Exception("列出文件失败: " + e.getMessage(), e);
        }
    }

    public String pwd(String sessionId) throws Exception {
        ChannelSftp channel = sessionManager.getSession(sessionId);
        if (channel == null) {
            throw new Exception("会话不存在或已断开");
        }

        try {
            return channel.pwd();
        } catch (SftpException e) {
            throw new Exception("获取当前路径失败: " + e.getMessage(), e);
        }
    }

    public void cd(String sessionId, String path) throws Exception {
        ChannelSftp channel = sessionManager.getSession(sessionId);
        if (channel == null) {
            throw new Exception("会话不存在或已断开");
        }

        try {
            channel.cd(path);
        } catch (SftpException e) {
            throw new Exception("切换目录失败: " + e.getMessage(), e);
        }
    }

    public FileInfo getFileInfo(String sessionId, String path) throws Exception {
        ChannelSftp channel = sessionManager.getSession(sessionId);
        if (channel == null) {
            throw new Exception("会话不存在或已断开");
        }

        try {
            ChannelSftp.LsEntry entry = channel.stat(path);
            FileInfo fileInfo = new FileInfo();
            fileInfo.setName(entry.getFilename());
            fileInfo.setPath(path);
            fileInfo.setSize(entry.getSize());
            fileInfo.setIsDirectory(entry.getAttrs().isDir());

            int mtime = entry.getAttrs().getMTime();
            fileInfo.setModifiedTime(LocalDateTime.ofInstant(
                    Instant.ofEpochSecond(mtime),
                    ZoneId.systemDefault()));

            fileInfo.setPermissions(entry.getAttrs().getPermissionsString());

            return fileInfo;
        } catch (SftpException e) {
            throw new Exception("获取文件信息失败: " + e.getMessage(), e);
        }
    }
}

4.2.3 FileController文件控制器

package com.sftp.manager.controller;

import com.sftp.manager.dto.ApiResponse;
import com.sftp.manager.dto.FileListRequest;
import com.sftp.manager.dto.FileOperationRequest;
import com.sftp.manager.model.FileInfo;
import com.sftp.manager.service.LocalFileService;
import com.sftp.manager.service.SftpService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/files")
@CrossOrigin(origins = "*")
public class FileController {

    @Autowired
    private LocalFileService localFileService;

    @Autowired
    private SftpService sftpService;

    @PostMapping("/list")
    public ApiResponse<List<FileInfo>> listFiles(@RequestBody FileListRequest request) {
        try {
            String sessionId = request.getSessionId();
            String path = request.getPath();

            List<FileInfo> files;
            if ("local".equals(sessionId)) {
                files = localFileService.listFiles(path);
            } else {
                files = sftpService.listFiles(sessionId, path);
            }

            return ApiResponse.success("查询成功", files);
        } catch (Exception e) {
            return ApiResponse.error("列出文件失败: " + e.getMessage());
        }
    }

    @PostMapping("/info")
    public ApiResponse<FileInfo> getFileInfo(@RequestBody FileOperationRequest request) {
        try {
            String sessionId = request.getSessionId();
            String path = request.getPath();

            FileInfo fileInfo;
            if ("local".equals(sessionId)) {
                fileInfo = localFileService.getFileInfo(path);
            } else {
                fileInfo = sftpService.getFileInfo(sessionId, path);
            }

            return ApiResponse.success("查询成功", fileInfo);
        } catch (Exception e) {
            return ApiResponse.error("获取文件信息失败: " + e.getMessage());
        }
    }

    @GetMapping("/path")
    public ApiResponse<Map<String, String>> getCurrentPath(@RequestParam String sessionId) {
        try {
            Map<String, String> result = new java.util.HashMap<>();
            if ("local".equals(sessionId)) {
                result.put("path", System.getProperty("user.home"));
            } else {
                String path = sftpService.pwd(sessionId);
                result.put("path", path);
            }
            return ApiResponse.success("查询成功", result);
        } catch (Exception e) {
            return ApiResponse.error("获取路径失败: " + e.getMessage());
        }
    }
}

4.3 前端设计

4.3.1 文件列表展示

<div class="panel" id="left-panel">
    <div class="panel-header">
        <select class="panel-mode" onchange="onModeChange('left')">
            <option value="local">本地文件</option>
            <option value="sftp">SFTP服务器</option>
        </select>
        <select class="connection-select" style="display:none;" onchange="onConnectionChange('left')">
            <!-- SFTP连接列表 -->
        </select>
    </div>
    <div class="path-bar">
        <button onclick="goUp('left')"></button>
        <input type="text" class="path-input" id="left-path" readonly>
    </div>
    <div class="file-list" id="left-file-list">
        <!-- 文件列表项 -->
    </div>
</div>

4.3.2 CSS样式

.file-list {
    flex: 1;
    overflow-y: auto;
    background-color: #fff;
}

.file-item {
    padding: 8px 10px;
    cursor: pointer;
    display: flex;
    align-items: center;
    border-bottom: 1px solid #f0f0f0;
    transition: background-color 0.2s;
}

.file-item:hover {
    background-color: #f8f9fa;
}

.file-item.selected {
    background-color: #007bff;
    color: white;
}

.file-icon {
    margin-right: 10px;
    width: 20px;
    text-align: center;
}

.file-name {
    flex: 1;
}

.file-size {
    margin-left: 10px;
    font-size: 12px;
    color: #666;
}

.file-date {
    margin-left: 10px;
    font-size: 12px;
    color: #666;
}

4.3.3 JavaScript实现

// 文件列表加载
function loadFiles(panelId) {
    const sessionId = panelState[panelId].sessionId;
    const path = panelState[panelId].currentPath;

    $.ajax({
        url: '/api/files/list',
        method: 'POST',
        contentType: 'application/json',
        data: JSON.stringify({
            sessionId: sessionId,
            path: path
        }),
        success: function(response) {
            if (response.success) {
                renderFileList(panelId, response.data);
                updatePathInput(panelId, path);
            } else {
                alert(response.message);
            }
        },
        error: handleError
    });
}

// 渲染文件列表
function renderFileList(panelId, files) {
    const fileList = $(`#${panelId}-file-list`);
    fileList.empty();

    files.forEach(file => {
        const icon = file.isDirectory ? '📁' : '📄';
        const size = file.isDirectory ? '' : formatFileSize(file.size);
        const date = formatDate(file.modifiedTime);

        const item = $(`
            <div class="file-item" data-path="${file.path}" data-is-dir="${file.isDirectory}">
                <span class="file-icon">${icon}</span>
                <span class="file-name">${file.name}</span>
                <span class="file-size">${size}</span>
                <span class="file-date">${date}</span>
            </div>
        `);

        item.on('click', function() {
            selectFile(panelId, $(this));
        });

        item.on('dblclick', function() {
            if (file.isDirectory) {
                enterDirectory(panelId, file.path);
            }
        });

        fileList.append(item);
    });
}

// 进入目录
function enterDirectory(panelId, path) {
    panelState[panelId].currentPath = path;
    loadFiles(panelId);
}

// 返回上级目录
function goUp(panelId) {
    const currentPath = panelState[panelId].currentPath;
    const parentPath = getParentPath(currentPath);

    if (parentPath && parentPath !== currentPath) {
        panelState[panelId].currentPath = parentPath;
        loadFiles(panelId);
    }
}

// 选择文件
function selectFile(panelId, element) {
    $(`#${panelId}-file-list .file-item`).removeClass('selected');
    element.addClass('selected');

    const path = element.data('path');
    panelState[panelId].selectedFiles = [path];
}

// 格式化文件大小
function formatFileSize(bytes) {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}

// 格式化日期
function formatDate(dateString) {
    const date = new Date(dateString);
    return date.toLocaleString('zh-CN');
}

// 获取父路径
function getParentPath(path) {
    if (path === '/' || path.indexOf('/') === -1) {
        return path;
    }
    return path.substring(0, path.lastIndexOf('/'));
}

// 更新路径输入框
function updatePathInput(panelId, path) {
    $(`#${panelId}-path`).val(path);
}

4.4 特殊处理

4.4.1 路径分隔符处理

  • Windows系统使用反斜杠\
  • Linux/SFTP系统使用正斜杠/
  • 统一显示格式,底层自动转换

4.4.2 隐藏文件处理

  • 本地文件:可选择过滤以.开头的文件Linux/Mac
  • SFTP文件由服务器返回

4.4.3 文件图标

根据文件类型显示不同图标:

  • 目录:📁
  • 普通文件:📄
  • 图片:🖼️
  • 视频文件:🎬
  • 音频文件:🎵
  • 压缩文件:📦
  • 代码文件:💻

实施步骤

  1. 创建LocalFileService服务

    touch src/main/java/com/sftp/manager/service/LocalFileService.java
    
  2. 创建SftpService服务

    touch src/main/java/com/sftp/manager/service/SftpService.java
    
  3. 创建FileController控制器

    touch src/main/java/com/sftp/manager/controller/FileController.java
    
  4. 编译测试

    mvn clean compile
    
  5. 启动服务

    mvn spring-boot:run
    

测试验证

1. 测试本地文件列表

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

2. 测试SFTP文件列表

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

3. 获取当前路径

curl "http://localhost:8080/sftp-manager/api/files/path?sessionId=local"

注意事项

  1. 权限检查:确保有权限访问指定路径
  2. 路径安全:防止路径遍历攻击
  3. 性能优化:大目录可考虑分页加载
  4. 错误处理:友好提示文件不存在或无权限

下一步

完成模块04后继续模块05文件上传下载功能