633 lines
17 KiB
Markdown
633 lines
17 KiB
Markdown
# 模块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
|
||
- 标准间距:16px(1rem)
|
||
- 组件内边距: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本地文件服务
|
||
|
||
```java
|
||
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文件服务
|
||
|
||
```java
|
||
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文件控制器
|
||
|
||
```java
|
||
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 文件列表展示
|
||
|
||
```html
|
||
<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样式
|
||
|
||
```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实现
|
||
|
||
```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. 测试本地文件列表
|
||
|
||
```bash
|
||
curl -X POST http://localhost:8080/sftp-manager/api/files/list \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"sessionId":"local","path":"C:/Users"}'
|
||
```
|
||
|
||
### 2. 测试SFTP文件列表
|
||
|
||
```bash
|
||
curl -X POST http://localhost:8080/sftp-manager/api/files/list \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"sessionId":"sftp-uuid","path":"/home/user"}'
|
||
```
|
||
|
||
### 3. 获取当前路径
|
||
|
||
```bash
|
||
curl "http://localhost:8080/sftp-manager/api/files/path?sessionId=local"
|
||
```
|
||
|
||
## 注意事项
|
||
|
||
1. **权限检查**:确保有权限访问指定路径
|
||
2. **路径安全**:防止路径遍历攻击
|
||
3. **性能优化**:大目录可考虑分页加载
|
||
4. **错误处理**:友好提示文件不存在或无权限
|
||
|
||
## 下一步
|
||
|
||
完成模块04后,继续模块05:文件上传下载功能
|