Files
sftp-manager/docs/09-双面板UI界面设计.md
liu 14289beb66 Initial commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 10:10:11 +08:00

24 KiB
Raw Blame History

模块09双面板UI界面设计


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

9.1 界面布局

9.1.1 整体HTML结构

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SFTP文件管理器</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <div class="app-container">
        <!-- 顶部导航栏 -->
        <nav class="navbar navbar-dark bg-dark">
            <div class="container-fluid">
                <span class="navbar-brand mb-0 h1">SFTP文件管理器</span>
                <div>
                    <button class="btn btn-primary btn-sm" onclick="showConnectionDialog()">连接管理</button>
                </div>
            </div>
        </nav>

        <!-- 工具栏 -->
        <div class="toolbar bg-light border-bottom">
            <div class="btn-group" role="group">
                <button type="button" class="btn btn-sm btn-outline-primary" onclick="uploadFiles()">上传</button>
                <button type="button" class="btn btn-sm btn-outline-primary" onclick="downloadFiles()">下载</button>
                <button type="button" class="btn btn-sm btn-outline-primary" onclick="transferFiles()">传输到右侧</button>
                <button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteFiles()">删除</button>
                <button type="button" class="btn btn-sm btn-outline-secondary" onclick="showRenameDialog()">重命名</button>
                <button type="button" class="btn btn-sm btn-outline-secondary" onclick="showMkdirDialog()">新建文件夹</button>
                <button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshPanels()">刷新</button>
            </div>
        </div>

        <!-- 双面板区域 -->
        <div class="panels-container">
            <!-- 左面板 -->
            <div class="panel" id="left-panel">
                <div class="panel-header">
                    <select class="form-select form-select-sm panel-mode" id="left-mode" onchange="onModeChange('left')">
                        <option value="local">本地文件</option>
                        <option value="sftp">SFTP服务器</option>
                    </select>
                    <select class="form-select form-select-sm connection-select" id="left-connection" style="display:none;" onchange="onConnectionChange('left')">
                        <!-- SFTP连接列表动态生成 -->
                    </select>
                </div>
                <div class="path-bar">
                    <button class="btn btn-sm btn-outline-secondary" onclick="goUp('left')"></button>
                    <input type="text" class="form-control form-control-sm path-input" id="left-path" readonly>
                </div>
                <div class="file-list" id="left-file-list">
                    <!-- 文件列表项动态生成 -->
                </div>
            </div>

            <!-- 右面板 -->
            <div class="panel" id="right-panel">
                <div class="panel-header">
                    <select class="form-select form-select-sm panel-mode" id="right-mode" onchange="onModeChange('right')">
                        <option value="local">本地文件</option>
                        <option value="sftp">SFTP服务器</option>
                    </select>
                    <select class="form-select form-select-sm connection-select" id="right-connection" style="display:none;" onchange="onConnectionChange('right')">
                        <!-- SFTP连接列表动态生成 -->
                    </select>
                </div>
                <div class="path-bar">
                    <button class="btn btn-sm btn-outline-secondary" onclick="goUp('right')"></button>
                    <input type="text" class="form-control form-control-sm path-input" id="right-path" readonly>
                </div>
                <div class="file-list" id="right-file-list">
                    <!-- 文件列表项动态生成 -->
                </div>
            </div>
        </div>

        <!-- 状态栏 -->
        <div class="status-bar bg-light border-top">
            <span id="status-text">就绪</span>
        </div>
    </div>

    <!-- 连接管理对话框 -->
    <div class="modal fade" id="connectionModal" tabindex="-1">
        <div class="modal-dialog modal-lg">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">连接管理</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <div class="mb-3">
                        <button class="btn btn-primary" onclick="showAddConnectionDialog()">添加连接</button>
                    </div>
                    <div class="connection-list" id="connection-list">
                        <!-- 连接列表动态生成 -->
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
                </div>
            </div>
        </div>
    </div>

    <!-- 添加连接对话框 -->
    <div class="modal fade" id="addConnectionModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">添加SFTP连接</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <form id="connection-form">
                        <div class="mb-3">
                            <label class="form-label">连接名称</label>
                            <input type="text" class="form-control" name="name" required>
                        </div>
                        <div class="mb-3">
                            <label class="form-label">主机地址</label>
                            <input type="text" class="form-control" name="host" required>
                        </div>
                        <div class="mb-3">
                            <label class="form-label">端口</label>
                            <input type="number" class="form-control" name="port" value="22" required>
                        </div>
                        <div class="mb-3">
                            <label class="form-label">用户名</label>
                            <input type="text" class="form-control" name="username" required>
                        </div>
                        <div class="mb-3">
                            <label class="form-label">密码</label>
                            <input type="password" class="form-control" name="password">
                        </div>
                        <div class="mb-3">
                            <label class="form-label">私钥路径(可选)</label>
                            <input type="text" class="form-control" name="privateKeyPath">
                        </div>
                        <div class="mb-3">
                            <label class="form-label">私钥密码(可选)</label>
                            <input type="password" class="form-control" name="passPhrase">
                        </div>
                    </form>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                    <button type="button" class="btn btn-primary" onclick="saveConnection()">保存</button>
                </div>
            </div>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script src="/js/app.js"></script>
</body>
</html>

9.2 样式设计

9.2.1 主样式style.css

/* 全局样式 */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    font-size: 14px;
    background-color: #f5f5f5;
    overflow: hidden;
}

/* 应用容器 */
.app-container {
    display: flex;
    flex-direction: column;
    height: 100vh;
    background-color: #fff;
}

/* 导航栏 */
.navbar {
    flex-shrink: 0;
    padding: 8px 16px;
}

.navbar-brand {
    font-size: 18px;
    font-weight: 600;
}

/* 工具栏 */
.toolbar {
    flex-shrink: 0;
    padding: 8px 16px;
    display: flex;
    align-items: center;
    gap: 8px;
}

/* 双面板容器 */
.panels-container {
    display: flex;
    flex: 1;
    overflow: hidden;
}

/* 面板 */
.panel {
    flex: 1;
    display: flex;
    flex-direction: column;
    border-right: 1px solid #dee2e6;
    overflow: hidden;
}

.panel:last-child {
    border-right: none;
}

/* 面板头部 */
.panel-header {
    flex-shrink: 0;
    padding: 8px;
    background-color: #f8f9fa;
    border-bottom: 1px solid #dee2e6;
    display: flex;
    gap: 8px;
}

.panel-mode {
    flex: 1;
}

.connection-select {
    flex: 2;
}

/* 路径栏 */
.path-bar {
    flex-shrink: 0;
    padding: 8px;
    display: flex;
    gap: 8px;
    background-color: #fff;
    border-bottom: 1px solid #dee2e6;
}

.path-input {
    flex: 1;
}

/* 文件列表 */
.file-list {
    flex: 1;
    overflow-y: auto;
    overflow-x: hidden;
}

/* 文件项 */
.file-item {
    padding: 8px 12px;
    cursor: pointer;
    display: flex;
    align-items: center;
    border-bottom: 1px solid #f0f0f0;
    transition: background-color 0.15s;
    user-select: none;
}

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

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

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

.file-name {
    flex: 1;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.file-size {
    margin-left: 10px;
    min-width: 80px;
    text-align: right;
    font-size: 12px;
    color: #666;
}

.file-date {
    margin-left: 10px;
    min-width: 140px;
    text-align: right;
    font-size: 12px;
    color: #666;
}

/* 选中状态下的文件大小和日期 */
.file-item.selected .file-size,
.file-item.selected .file-date {
    color: rgba(255, 255, 255, 0.8);
}

/* 状态栏 */
.status-bar {
    flex-shrink: 0;
    padding: 4px 16px;
    font-size: 12px;
    color: #666;
}

/* 上下文菜单 */
.context-menu {
    position: absolute;
    background-color: #fff;
    border: 1px solid #ccc;
    box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2);
    z-index: 1000;
    min-width: 120px;
}

.menu-item {
    padding: 8px 12px;
    cursor: pointer;
    user-select: none;
}

.menu-item:hover {
    background-color: #007bff;
    color: white;
}

/* 新建文件夹项 */
.new-folder {
    background-color: #e7f3ff;
}

.new-folder-input {
    width: 100%;
    padding: 2px 4px;
    border: 1px solid #007bff;
    outline: none;
    font-size: 14px;
}

/* 连接列表 */
.connection-item {
    padding: 8px 12px;
    border-bottom: 1px solid #f0f0f0;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

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

.connection-name {
    font-weight: 600;
}

.connection-info {
    font-size: 12px;
    color: #666;
}

.connection-actions {
    display: flex;
    gap: 4px;
}

.connection-actions button {
    padding: 2px 8px;
    font-size: 12px;
}

9.3 JavaScript基础逻辑

9.3.1 状态管理app.js

// 面板状态
const panelState = {
    left: {
        mode: 'local',           // 'local' 或 'sftp'
        sessionId: 'local',      // 'local' 或 SFTP会话ID
        currentPath: '',         // 当前路径
        selectedFiles: []        // 选中的文件
    },
    right: {
        mode: 'local',
        sessionId: 'local',
        currentPath: '',
        selectedFiles: []
    }
};

// 活跃连接列表
let activeConnections = {};

// 初始化
$(document).ready(function() {
    // 设置初始路径
    panelState.left.currentPath = getDefaultLocalPath();
    panelState.right.currentPath = getDefaultLocalPath();

    // 加载文件列表
    loadFiles('left');
    loadFiles('right');

    // 绑定键盘事件
    bindKeyboardEvents();
});

// 获取默认本地路径
function getDefaultLocalPath() {
    return System.getProperty('user.home');
}

9.3.2 文件列表加载

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

    updateStatus(`正在加载 ${panelId === 'left' ? '左' : '右'}面板文件列表...`);

    $.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);
                updateStatus('就绪');
            } else {
                alert('加载文件列表失败: ' + response.message);
                updateStatus('错误: ' + response.message);
            }
        },
        error: function(xhr, status, error) {
            alert('加载文件列表失败: ' + error);
            updateStatus('错误: ' + error);
        }
    });
}

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

    if (files.length === 0) {
        fileList.html('<div class="text-center text-muted p-3">文件夹为空</div>');
        return;
    }

    files.forEach(file => {
        const icon = getFileIcon(file.name, 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" title="${file.name}">${file.name}</span>
                <span class="file-size">${size}</span>
                <span class="file-date">${date}</span>
            </div>
        `);

        // 单击选择
        item.on('click', function(e) {
            if (!e.ctrlKey) {
                $(`#${panelId}-file-list .file-item`).removeClass('selected');
            }
            item.toggleClass('selected');
            updateSelectedFiles(panelId);
        });

        // 双击进入目录
        item.on('dblclick', function() {
            if (file.isDirectory) {
                enterDirectory(panelId, file.path);
            }
        });

        // 右键菜单
        item.on('contextmenu', function(e) {
            e.preventDefault();
            showContextMenu(e, panelId, file.path, file.isDirectory);
        });

        fileList.append(item);
    });
}

// 获取文件图标
function getFileIcon(fileName, isDirectory) {
    if (isDirectory) {
        return '📁';
    }

    const ext = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
    const iconMap = {
        'txt': '📄',
        'doc': '📄', 'docx': '📄',
        'xls': '📊', 'xlsx': '📊',
        'ppt': '📊', 'pptx': '📊',
        'pdf': '📕',
        'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️',
        'mp4': '🎬', 'avi': '🎬', 'mkv': '🎬',
        'mp3': '🎵', 'wav': '🎵',
        'zip': '📦', 'rar': '📦', '7z': '📦',
        'java': '☕', 'js': '💻', 'py': '🐍', 'php': '🐘',
        'html': '🌐', 'css': '🎨', 'json': '📋'
    };

    return iconMap[ext] || '📄';
}

// 格式化文件大小
function formatFileSize(bytes) {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
    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', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit'
    });
}

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

// 更新选中文件列表
function updateSelectedFiles(panelId) {
    const selected = $(`#${panelId}-file-list .file-item.selected`);
    panelState[panelId].selectedFiles = selected.map(function() {
        return $(this).data('path');
    }).get();
}

// 更新状态栏
function updateStatus(text) {
    $('#status-text').text(text);
}

9.4 响应式设计

9.4.1 移动端适配

/* 小屏幕设备 */
@media (max-width: 768px) {
    .panels-container {
        flex-direction: column;
    }

    .panel {
        height: 50%;
        border-right: none;
        border-bottom: 1px solid #dee2e6;
    }

    .file-size,
    .file-date {
        display: none;
    }

    .toolbar {
        flex-wrap: wrap;
    }

    .navbar-brand {
        font-size: 16px;
    }
}

/* 极小屏幕设备 */
@media (max-width: 576px) {
    .toolbar button {
        padding: 2px 8px;
        font-size: 12px;
    }

    .file-item {
        padding: 6px 8px;
    }
}

9.5 交互设计

9.5.1 文件选择

// 全选
function selectAll(panelId) {
    $(`#${panelId}-file-list .file-item`).addClass('selected');
    updateSelectedFiles(panelId);
}

// 取消选择
function deselectAll(panelId) {
    $(`#${panelId}-file-list .file-item`).removeClass('selected');
    updateSelectedFiles(panelId);
}

// 反选
function invertSelection(panelId) {
    $(`#${panelId}-file-list .file-item`).toggleClass('selected');
    updateSelectedFiles(panelId);
}

9.5.2 拖拽操作

// 文件拖拽初始化
function initDragAndDrop() {
    $('.file-item').attr('draggable', true);

    // 拖拽开始
    $('.file-item').on('dragstart', function(e) {
        const panelId = $(this).closest('.panel').attr('id');
        const path = $(this).data('path');
        e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
            panelId: panelId,
            path: path
        }));
        $(this).addClass('dragging');
    });

    // 拖拽结束
    $('.file-item').on('dragend', function() {
        $(this).removeClass('dragging');
    });

    // 拖拽悬停
    $('.file-list').on('dragover', function(e) {
        e.preventDefault();
        const panelId = $(this).closest('.panel').attr('id');
        $(this).addClass('drag-over');
    });

    // 拖拽离开
    $('.file-list').on('dragleave', function() {
        $(this).removeClass('drag-over');
    });

    // 拖拽放置
    $('.file-list').on('drop', function(e) {
        e.preventDefault();
        $(this).removeClass('drag-over');

        try {
            const data = JSON.parse(e.originalEvent.dataTransfer.getData('text/plain'));
            const targetPanelId = $(this).closest('.panel').attr('id');
            handleFileDrop(data, targetPanelId);
        } catch (err) {
            console.error('拖拽失败:', err);
        }
    });
}

// 处理文件拖放
function handleFileDrop(data, targetPanelId) {
    const sourcePanelId = data.panelId;
    const sourcePath = data.path;
    const targetPath = panelState[targetPanelId].currentPath;

    if (sourcePanelId === targetPanelId) {
        // 同一面板,可以移动文件
        // 这里可以实现文件移动功能
        alert('文件移动功能开发中...');
    } else {
        // 跨面板传输
        $.ajax({
            url: '/api/files/transfer',
            method: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({
                sourceSessionId: panelState[sourcePanelId].sessionId,
                sourcePath: sourcePath,
                targetSessionId: panelState[targetPanelId].sessionId,
                targetPath: targetPath
            }),
            success: function(response) {
                if (response.success) {
                    alert('传输成功');
                    loadFiles(targetPanelId);
                } else {
                    alert('传输失败: ' + response.message);
                }
            },
            error: handleError
        });
    }
}

9.5.3 快捷键支持

// 绑定键盘事件
function bindKeyboardEvents() {
    $(document).on('keydown', function(e) {
        // Delete: 删除选中文件
        if (e.key === 'Delete') {
            deleteSelectedFiles(getActivePanelId());
            e.preventDefault();
        }

        // F2: 重命名
        if (e.key === 'F2') {
            const panelId = getActivePanelId();
            const selected = panelState[panelId].selectedFiles;
            if (selected.length === 1) {
                showRenameDialog(panelId, selected[0]);
            }
            e.preventDefault();
        }

        // F5: 刷新
        if (e.key === 'F5') {
            refreshPanels();
            e.preventDefault();
        }

        // Backspace: 返回上级目录
        if (e.key === 'Backspace') {
            const panelId = getActivePanelId();
            goUp(panelId);
            e.preventDefault();
        }

        // Ctrl+A: 全选
        if (e.ctrlKey && (e.key === 'a' || e.key === 'A')) {
            selectAll(getActivePanelId());
            e.preventDefault();
        }

        // Esc: 取消选择
        if (e.key === 'Escape') {
            const panelId = getActivePanelId();
            deselectAll(panelId);
            e.preventDefault();
        }

        // Ctrl+Shift+N: 新建文件夹
        if (e.ctrlKey && e.shiftKey && (e.key === 'n' || e.key === 'N')) {
            showMkdirDialog(getActivePanelId());
            e.preventDefault();
        }
    });
}

// 获取当前活动面板
function getActivePanelId() {
    // 简单实现:返回左侧面板
    // 可以根据鼠标位置等逻辑判断
    return 'left';
}

实施步骤

  1. 创建index.html包含完整的HTML结构

  2. 创建style.css:编写样式文件

  3. 创建app.js编写JavaScript基础逻辑

  4. 测试界面

    # 将文件放置到正确位置
    cp index.html src/main/resources/templates/
    cp style.css src/main/resources/static/css/
    cp app.js src/main/resources/static/js/
    
    # 启动服务
    mvn spring-boot:run
    
  5. 访问测试http://localhost:8080/sftp-manager

注意事项

  1. CDN资源确保网络可以访问Bootstrap和jQuery的CDN
  2. 静态资源:确保静态文件路径正确
  3. 浏览器兼容性:测试主流浏览器兼容性
  4. 响应式:在不同屏幕尺寸下测试界面
  5. 性能优化:大文件列表考虑虚拟滚动

下一步

完成模块09后继续模块10模式切换功能