686
docs/08-新建文件夹功能.md
Normal file
686
docs/08-新建文件夹功能.md
Normal file
@@ -0,0 +1,686 @@
|
||||
# 模块08:新建文件夹功能
|
||||
|
||||
---
|
||||
|
||||
## 🎨 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(标准布局)
|
||||
|
||||
---
|
||||
|
||||
## 8.1 功能概述
|
||||
实现在本地文件系统和SFTP服务器上创建新文件夹的功能。
|
||||
|
||||
## 8.2 后端设计
|
||||
|
||||
### 8.2.1 LocalFileService创建目录方法
|
||||
|
||||
```java
|
||||
// 创建目录
|
||||
public boolean createDirectory(String path) throws Exception {
|
||||
File directory = new File(path);
|
||||
|
||||
if (directory.exists()) {
|
||||
throw new Exception("目录已存在: " + path);
|
||||
}
|
||||
|
||||
// 验证目录名
|
||||
String dirName = directory.getName();
|
||||
if (!isValidDirectoryName(dirName)) {
|
||||
throw new Exception("目录名包含非法字符: " + dirName);
|
||||
}
|
||||
|
||||
// 检查父目录是否存在
|
||||
File parentDir = directory.getParentFile();
|
||||
if (parentDir != null && !parentDir.exists()) {
|
||||
// 父目录不存在,询问是否创建多级目录
|
||||
throw new Exception("父目录不存在: " + parentDir.getPath());
|
||||
}
|
||||
|
||||
boolean result = directory.mkdirs();
|
||||
if (!result) {
|
||||
throw new Exception("创建目录失败");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 创建多级目录
|
||||
public boolean createDirectories(String path) throws Exception {
|
||||
File directory = new File(path);
|
||||
|
||||
if (directory.exists()) {
|
||||
throw new Exception("目录已存在: " + path);
|
||||
}
|
||||
|
||||
// 验证路径中的每个目录名
|
||||
String[] parts = path.split("[/\\\\]");
|
||||
for (String part : parts) {
|
||||
if (!isValidDirectoryName(part)) {
|
||||
throw new Exception("路径包含非法字符: " + part);
|
||||
}
|
||||
}
|
||||
|
||||
boolean result = directory.mkdirs();
|
||||
if (!result) {
|
||||
throw new Exception("创建目录失败");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 验证目录名是否有效
|
||||
private boolean isValidDirectoryName(String dirName) {
|
||||
if (dirName == null || dirName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Windows非法字符
|
||||
String illegalChars = "\\/:*?\"<>|";
|
||||
for (char c : illegalChars.toCharArray()) {
|
||||
if (dirName.indexOf(c) != -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查长度限制
|
||||
if (dirName.length() > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查保留名称(Windows)
|
||||
String upperName = dirName.toUpperCase();
|
||||
String[] reservedNames = {"CON", "PRN", "AUX", "NUL"};
|
||||
for (String reserved : reservedNames) {
|
||||
if (upperName.equals(reserved)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2.2 SftpService创建目录方法
|
||||
|
||||
```java
|
||||
// 创建目录
|
||||
public boolean createDirectory(String sessionId, String path) throws Exception {
|
||||
ChannelSftp channel = sessionManager.getSession(sessionId);
|
||||
if (channel == null) {
|
||||
throw new Exception("会话不存在或已断开");
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查目录是否已存在
|
||||
try {
|
||||
channel.stat(path);
|
||||
throw new Exception("目录已存在: " + path);
|
||||
} catch (SftpException e) {
|
||||
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建目录
|
||||
channel.mkdir(path);
|
||||
return true;
|
||||
} catch (SftpException e) {
|
||||
throw new Exception("创建目录失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建多级目录
|
||||
public boolean createDirectories(String sessionId, String path) throws Exception {
|
||||
ChannelSftp channel = sessionManager.getSession(sessionId);
|
||||
if (channel == null) {
|
||||
throw new Exception("会话不存在或已断开");
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查目录是否已存在
|
||||
try {
|
||||
channel.stat(path);
|
||||
throw new Exception("目录已存在: " + path);
|
||||
} catch (SftpException e) {
|
||||
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 递归创建多级目录
|
||||
createDirectoriesRecursive(channel, path);
|
||||
return true;
|
||||
} catch (SftpException e) {
|
||||
throw new Exception("创建目录失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归创建多级目录
|
||||
private void createDirectoriesRecursive(ChannelSftp channel, String path) throws SftpException {
|
||||
// 如果是根目录,直接返回
|
||||
if (path.equals("/") || path.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查父目录是否存在
|
||||
String parentPath = getParentPath(path);
|
||||
if (!parentPath.equals(path)) {
|
||||
try {
|
||||
channel.stat(parentPath);
|
||||
} catch (SftpException e) {
|
||||
if (e.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) {
|
||||
// 父目录不存在,递归创建
|
||||
createDirectoriesRecursive(channel, parentPath);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建当前目录
|
||||
channel.mkdir(path);
|
||||
}
|
||||
|
||||
// 获取父路径
|
||||
private String getParentPath(String path) {
|
||||
if (path.endsWith("/")) {
|
||||
path = path.substring(0, path.length() - 1);
|
||||
}
|
||||
int index = path.lastIndexOf("/");
|
||||
if (index == -1) {
|
||||
return "/";
|
||||
}
|
||||
if (index == 0) {
|
||||
return "/";
|
||||
}
|
||||
return path.substring(0, index);
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2.3 FileController创建目录接口
|
||||
|
||||
```java
|
||||
// 创建目录
|
||||
@PostMapping("/mkdir")
|
||||
public ApiResponse<Void> createDirectory(@RequestBody Map<String, String> request) {
|
||||
try {
|
||||
String sessionId = request.get("sessionId");
|
||||
String path = request.get("path");
|
||||
|
||||
if (path == null || path.isEmpty()) {
|
||||
return ApiResponse.error("路径不能为空");
|
||||
}
|
||||
|
||||
boolean result;
|
||||
if ("local".equals(sessionId)) {
|
||||
result = localFileService.createDirectory(path);
|
||||
} else {
|
||||
result = sftpService.createDirectory(sessionId, path);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return ApiResponse.success("创建成功", null);
|
||||
} else {
|
||||
return ApiResponse.error("创建失败");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("创建失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 创建多级目录
|
||||
@PostMapping("/mkdir-p")
|
||||
public ApiResponse<Void> createDirectories(@RequestBody Map<String, String> request) {
|
||||
try {
|
||||
String sessionId = request.get("sessionId");
|
||||
String path = request.get("path");
|
||||
|
||||
if (path == null || path.isEmpty()) {
|
||||
return ApiResponse.error("路径不能为空");
|
||||
}
|
||||
|
||||
boolean result;
|
||||
if ("local".equals(sessionId)) {
|
||||
result = localFileService.createDirectories(path);
|
||||
} else {
|
||||
result = sftpService.createDirectories(sessionId, path);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return ApiResponse.success("创建成功", null);
|
||||
} else {
|
||||
return ApiResponse.error("创建失败");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("创建失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8.3 前端设计
|
||||
|
||||
### 8.3.1 新建文件夹交互
|
||||
|
||||
```javascript
|
||||
// 显示新建文件夹对话框
|
||||
function showMkdirDialog(panelId) {
|
||||
const sessionId = panelState[panelId].sessionId;
|
||||
const currentPath = panelState[panelId].currentPath;
|
||||
|
||||
const folderName = prompt('请输入文件夹名称:', '新建文件夹');
|
||||
if (folderName && folderName.trim() !== '') {
|
||||
createDirectory(sessionId, currentPath, folderName);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建目录
|
||||
function createDirectory(sessionId, parentPath, folderName) {
|
||||
// 验证文件夹名称
|
||||
if (!validateDirectoryName(folderName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建完整路径
|
||||
let fullPath;
|
||||
if ("local".equals(sessionId)) {
|
||||
fullPath = parentPath + File.separator + folderName;
|
||||
} else {
|
||||
fullPath = parentPath.endsWith('/') ?
|
||||
parentPath + folderName :
|
||||
parentPath + '/' + folderName;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: '/api/files/mkdir',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
sessionId: sessionId,
|
||||
path: fullPath
|
||||
}),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
alert('文件夹创建成功');
|
||||
refreshCurrentPanel();
|
||||
} else {
|
||||
alert('创建失败: ' + response.message);
|
||||
}
|
||||
},
|
||||
error: handleError
|
||||
});
|
||||
}
|
||||
|
||||
// 验证目录名
|
||||
function validateDirectoryName(dirName) {
|
||||
if (!dirName || dirName.trim() === '') {
|
||||
alert('文件夹名称不能为空');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Windows非法字符
|
||||
const illegalChars = /\\\/:\*\?"<>\|/;
|
||||
if (illegalChars.test(dirName)) {
|
||||
alert('文件夹名称包含非法字符');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 长度限制
|
||||
if (dirName.length > 255) {
|
||||
alert('文件夹名称过长(最大255字符)');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 刷新当前面板
|
||||
function refreshCurrentPanel() {
|
||||
const activePanelId = getActivePanelId();
|
||||
loadFiles(activePanelId);
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3.2 新建文件夹按钮
|
||||
|
||||
```html
|
||||
<div class="toolbar">
|
||||
<button onclick="showMkdirDialog('left')">新建文件夹</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 8.3.3 右键菜单新建文件夹
|
||||
|
||||
```javascript
|
||||
// 在右键菜单中添加新建文件夹选项
|
||||
function showContextMenu(event, panelId) {
|
||||
event.preventDefault();
|
||||
|
||||
const menu = $('<div class="context-menu">');
|
||||
menu.css({
|
||||
position: 'absolute',
|
||||
left: event.pageX + 'px',
|
||||
top: event.pageY + 'px'
|
||||
});
|
||||
|
||||
menu.append('<div class="menu-item" onclick="showMkdirDialog(\'' +
|
||||
panelId + '\')">新建文件夹</div>');
|
||||
|
||||
$('body').append(menu);
|
||||
|
||||
$(document).one('click', function() {
|
||||
menu.remove();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3.4 快捷键支持
|
||||
|
||||
```javascript
|
||||
// 监听键盘事件
|
||||
$(document).on('keydown', function(e) {
|
||||
// Ctrl+Shift+N: 新建文件夹
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'N') {
|
||||
const activePanelId = getActivePanelId();
|
||||
showMkdirDialog(activePanelId);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 8.3.5 在线输入(高级功能)
|
||||
|
||||
```javascript
|
||||
// 在文件列表中直接输入新文件夹名称
|
||||
function showInlineMkdir(panelId) {
|
||||
const fileList = $(`#${panelId}-file-list`);
|
||||
|
||||
// 创建新建文件夹项
|
||||
const newItem = $(`
|
||||
<div class="file-item new-folder">
|
||||
<span class="file-icon">📁</span>
|
||||
<span class="file-name">
|
||||
<input type="text" class="new-folder-input" placeholder="输入文件夹名称">
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// 插入到列表顶部
|
||||
fileList.prepend(newItem);
|
||||
|
||||
// 聚焦输入框
|
||||
const input = newItem.find('.new-folder-input');
|
||||
input.focus();
|
||||
|
||||
// 失去焦点时创建
|
||||
input.on('blur', function() {
|
||||
const folderName = input.val().trim();
|
||||
if (folderName) {
|
||||
createDirectory(panelState[panelId].sessionId,
|
||||
panelState[panelId].currentPath,
|
||||
folderName);
|
||||
}
|
||||
newItem.remove();
|
||||
});
|
||||
|
||||
// 按Enter创建
|
||||
input.on('keypress', function(e) {
|
||||
if (e.which === 13) {
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// 按Esc取消
|
||||
input.on('keydown', function(e) {
|
||||
if (e.which === 27) {
|
||||
newItem.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 8.4 输入验证
|
||||
|
||||
### 8.4.1 前端验证
|
||||
|
||||
```javascript
|
||||
// 验证目录名
|
||||
function validateDirectoryName(dirName) {
|
||||
// 基本验证
|
||||
if (!dirName || dirName.trim() === '') {
|
||||
alert('文件夹名称不能为空');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 去除首尾空格
|
||||
dirName = dirName.trim();
|
||||
|
||||
// Windows非法字符
|
||||
const illegalChars = /\\\/:\*\?"<>\|/;
|
||||
if (illegalChars.test(dirName)) {
|
||||
alert('文件夹名称包含非法字符: \\ / : * ? " < > |');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 不能以点开头或结尾(Linux/Mac隐藏目录)
|
||||
if (dirName.startsWith('.') || dirName.endsWith('.')) {
|
||||
alert('文件夹名称不能以点开头或结尾');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 长度限制
|
||||
if (dirName.length > 255) {
|
||||
alert('文件夹名称过长(最大255字符)');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Windows保留名称
|
||||
const upperName = dirName.toUpperCase();
|
||||
const reservedNames = ['CON', 'PRN', 'AUX', 'NUL',
|
||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
|
||||
if (reservedNames.includes(upperName)) {
|
||||
alert('文件夹名称是系统保留名称');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4.2 后端验证
|
||||
|
||||
```java
|
||||
// 验证目录名(后端)
|
||||
private boolean isValidDirectoryName(String dirName) {
|
||||
if (dirName == null || dirName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 去除首尾空格
|
||||
dirName = dirName.trim();
|
||||
|
||||
// Windows非法字符
|
||||
String illegalChars = "\\/:*?\"<>|";
|
||||
for (char c : illegalChars.toCharArray()) {
|
||||
if (dirName.indexOf(c) != -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 不能以点开头或结尾
|
||||
if (dirName.startsWith(".") || dirName.endsWith(".")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 长度限制
|
||||
if (dirName.length() > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Windows保留名称
|
||||
String upperName = dirName.toUpperCase();
|
||||
String[] reservedNames = {"CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"};
|
||||
for (String reserved : reservedNames) {
|
||||
if (upperName.equals(reserved)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4.3 路径处理
|
||||
|
||||
```javascript
|
||||
// 拼接路径
|
||||
function joinPath(parentPath, childName) {
|
||||
// 移除子名称的首尾空格
|
||||
childName = childName.trim();
|
||||
|
||||
// 判断是否为本地路径
|
||||
const isLocal = parentPath.includes('\\') ||
|
||||
parentPath.includes('C:') ||
|
||||
parentPath.includes('D:');
|
||||
|
||||
if (isLocal) {
|
||||
// Windows路径
|
||||
if (parentPath.endsWith('\\') || parentPath.endsWith('/')) {
|
||||
return parentPath + childName;
|
||||
} else {
|
||||
return parentPath + '\\' + childName;
|
||||
}
|
||||
} else {
|
||||
// Linux/SFTP路径
|
||||
if (parentPath.endsWith('/')) {
|
||||
return parentPath + childName;
|
||||
} else {
|
||||
return parentPath + '/' + childName;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. **更新LocalFileService**:添加创建目录方法
|
||||
|
||||
2. **更新SftpService**:添加创建目录方法
|
||||
|
||||
3. **更新FileController**:添加创建目录接口
|
||||
|
||||
4. **添加前端创建目录功能**
|
||||
|
||||
5. **编译测试**
|
||||
```
|
||||
mvn clean compile
|
||||
```
|
||||
|
||||
6. **启动服务**
|
||||
```
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
## 测试验证
|
||||
|
||||
### 1. 创建单级目录
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/sftp-manager/api/files/mkdir \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sessionId": "local",
|
||||
"path": "C:/test/newfolder"
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. 创建多级目录
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/sftp-manager/api/files/mkdir-p \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sessionId": "local",
|
||||
"path": "C:/test/level1/level2/level3"
|
||||
}'
|
||||
```
|
||||
|
||||
### 3. 在SFTP上创建目录
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/sftp-manager/api/files/mkdir \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sessionId": "sftp-uuid",
|
||||
"path": "/home/user/newfolder"
|
||||
}'
|
||||
```
|
||||
|
||||
### 4. 测试非法目录名
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/sftp-manager/api/files/mkdir \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sessionId": "local",
|
||||
"path": "C:/test/folder<name"
|
||||
}'
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **目录名验证**:严格验证目录名,防止非法字符
|
||||
2. **路径拼接**:正确处理不同操作系统的路径分隔符
|
||||
3. **权限检查**:确保有权限在指定位置创建目录
|
||||
4. **同名检查**:创建前检查目录是否已存在
|
||||
5. **父目录存在性**:单级创建时检查父目录是否存在
|
||||
|
||||
## 下一步
|
||||
|
||||
完成模块08后,继续模块09:双面板UI界面设计
|
||||
Reference in New Issue
Block a user