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

633 lines
17 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.
# 模块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本地文件服务
```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文件上传下载功能