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

900 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模块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结构
```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
```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
```javascript
// 面板状态
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 文件列表加载
```javascript
// 加载文件列表
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 移动端适配
```css
/* 小屏幕设备 */
@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 文件选择
```javascript
// 全选
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 拖拽操作
```javascript
// 文件拖拽初始化
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 快捷键支持
```javascript
// 绑定键盘事件
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模式切换功能