632
docs/04-文件浏览功能.md
Normal file
632
docs/04-文件浏览功能.md
Normal file
@@ -0,0 +1,632 @@
|
||||
# 模块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:文件上传下载功能
|
||||
Reference in New Issue
Block a user