607 lines
15 KiB
Markdown
607 lines
15 KiB
Markdown
# 模块07:文件重命名功能
|
||
|
||
---
|
||
|
||
## 🎨 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(标准布局)
|
||
|
||
---
|
||
|
||
## 7.1 功能概述
|
||
实现重命名本地文件和SFTP服务器上文件的功能,支持单个文件重命名。
|
||
|
||
## 7.2 后端设计
|
||
|
||
### 7.2.1 LocalFileService重命名方法
|
||
|
||
```java
|
||
// 重命名文件
|
||
public boolean renameFile(String oldPath, String newPath) throws Exception {
|
||
File oldFile = new File(oldPath);
|
||
File newFile = new File(newPath);
|
||
|
||
if (!oldFile.exists()) {
|
||
throw new Exception("源文件不存在: " + oldPath);
|
||
}
|
||
|
||
if (newFile.exists()) {
|
||
throw new Exception("目标文件已存在: " + newPath);
|
||
}
|
||
|
||
// 检查新文件名是否有效
|
||
String newFileName = newFile.getName();
|
||
if (!isValidFileName(newFileName)) {
|
||
throw new Exception("文件名包含非法字符: " + newFileName);
|
||
}
|
||
|
||
boolean result = oldFile.renameTo(newFile);
|
||
if (!result) {
|
||
throw new Exception("重命名失败");
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// 验证文件名是否有效
|
||
private boolean isValidFileName(String fileName) {
|
||
if (fileName == null || fileName.isEmpty()) {
|
||
return false;
|
||
}
|
||
|
||
// Windows非法字符
|
||
String illegalChars = "\\/:*?\"<>|";
|
||
for (char c : illegalChars.toCharArray()) {
|
||
if (fileName.indexOf(c) != -1) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 检查长度限制
|
||
if (fileName.length() > 255) {
|
||
return false;
|
||
}
|
||
|
||
// 检查保留名称(Windows)
|
||
String upperName = fileName.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;
|
||
}
|
||
|
||
// 获取文件扩展名
|
||
public String getFileExtension(String fileName) {
|
||
int index = fileName.lastIndexOf('.');
|
||
if (index == -1 || index == fileName.length() - 1) {
|
||
return "";
|
||
}
|
||
return fileName.substring(index);
|
||
}
|
||
|
||
// 获取文件名(不带扩展名)
|
||
public String getFileNameWithoutExtension(String fileName) {
|
||
int index = fileName.lastIndexOf('.');
|
||
if (index == -1) {
|
||
return fileName;
|
||
}
|
||
return fileName.substring(0, index);
|
||
}
|
||
```
|
||
|
||
### 7.2.2 SftpService重命名方法
|
||
|
||
```java
|
||
// 重命名文件
|
||
public boolean renameFile(String sessionId, String oldPath, String newPath) throws Exception {
|
||
ChannelSftp channel = sessionManager.getSession(sessionId);
|
||
if (channel == null) {
|
||
throw new Exception("会话不存在或已断开");
|
||
}
|
||
|
||
try {
|
||
// 检查源文件是否存在
|
||
channel.stat(oldPath);
|
||
|
||
// 检查目标文件是否已存在
|
||
try {
|
||
channel.stat(newPath);
|
||
throw new Exception("目标文件已存在: " + newPath);
|
||
} catch (SftpException e) {
|
||
// 文件不存在,可以重命名
|
||
}
|
||
|
||
// 执行重命名
|
||
channel.rename(oldPath, newPath);
|
||
return true;
|
||
} catch (SftpException e) {
|
||
throw new Exception("重命名失败: " + e.getMessage(), e);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 7.2.3 FileController重命名接口
|
||
|
||
```java
|
||
// 重命名文件
|
||
@PostMapping("/rename")
|
||
public ApiResponse<Void> renameFile(@RequestBody Map<String, String> request) {
|
||
try {
|
||
String sessionId = request.get("sessionId");
|
||
String oldPath = request.get("oldPath");
|
||
String newName = request.get("newName");
|
||
|
||
if (newName == null || newName.isEmpty()) {
|
||
return ApiResponse.error("新文件名不能为空");
|
||
}
|
||
|
||
// 构建新路径
|
||
String newPath;
|
||
if ("local".equals(sessionId)) {
|
||
File oldFile = new File(oldPath);
|
||
File parentDir = oldFile.getParentFile();
|
||
newPath = parentDir.getPath() + File.separator + newName;
|
||
localFileService.renameFile(oldPath, newPath);
|
||
} else {
|
||
// 获取父目录
|
||
String parentPath = getParentPath(oldPath);
|
||
newPath = parentPath.endsWith("/") ?
|
||
parentPath + newName :
|
||
parentPath + "/" + newName;
|
||
sftpService.renameFile(sessionId, oldPath, newPath);
|
||
}
|
||
|
||
return ApiResponse.success("重命名成功", null);
|
||
} catch (Exception e) {
|
||
return ApiResponse.error("重命名失败: " + e.getMessage());
|
||
}
|
||
}
|
||
|
||
// 获取父路径
|
||
private String getParentPath(String path) {
|
||
if (path.endsWith("/")) {
|
||
path = path.substring(0, path.length() - 1);
|
||
}
|
||
int index = path.lastIndexOf("/");
|
||
if (index == -1) {
|
||
return "/";
|
||
}
|
||
return path.substring(0, index);
|
||
}
|
||
```
|
||
|
||
## 7.3 前端设计
|
||
|
||
### 7.3.1 重命名交互
|
||
|
||
```javascript
|
||
// 显示重命名对话框
|
||
function showRenameDialog(panelId, path) {
|
||
const sessionId = panelState[panelId].sessionId;
|
||
const oldName = getFileNameFromPath(path);
|
||
|
||
const newName = prompt('请输入新文件名:', oldName);
|
||
if (newName && newName !== oldName) {
|
||
renameFile(sessionId, path, newName);
|
||
}
|
||
}
|
||
|
||
// 重命名文件
|
||
function renameFile(sessionId, oldPath, newName) {
|
||
$.ajax({
|
||
url: '/api/files/rename',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({
|
||
sessionId: sessionId,
|
||
oldPath: oldPath,
|
||
newName: newName
|
||
}),
|
||
success: function(response) {
|
||
if (response.success) {
|
||
alert('重命名成功');
|
||
refreshCurrentPanel();
|
||
} else {
|
||
alert('重命名失败: ' + response.message);
|
||
}
|
||
},
|
||
error: handleError
|
||
});
|
||
}
|
||
|
||
// 从路径获取文件名
|
||
function getFileNameFromPath(path) {
|
||
let separator = path.includes('\\') ? '\\' : '/';
|
||
const index = path.lastIndexOf(separator);
|
||
if (index === -1) {
|
||
return path;
|
||
}
|
||
return path.substring(index + 1);
|
||
}
|
||
|
||
// 刷新当前面板
|
||
function refreshCurrentPanel() {
|
||
const activePanelId = getActivePanelId();
|
||
loadFiles(activePanelId);
|
||
}
|
||
|
||
// 获取当前活动面板ID
|
||
function getActivePanelId() {
|
||
return 'left'; // 默认返回左侧,可根据实际逻辑调整
|
||
}
|
||
```
|
||
|
||
### 7.3.2 重命名按钮
|
||
|
||
```html
|
||
<div class="toolbar">
|
||
<button onclick="showRenameDialog('left', getSelectedPath('left'))">重命名</button>
|
||
</div>
|
||
```
|
||
|
||
```javascript
|
||
// 获取选中文件的路径
|
||
function getSelectedPath(panelId) {
|
||
const selectedFiles = panelState[panelId].selectedFiles;
|
||
if (selectedFiles.length === 0) {
|
||
alert('请先选择一个文件');
|
||
return null;
|
||
}
|
||
if (selectedFiles.length > 1) {
|
||
alert('一次只能重命名一个文件');
|
||
return null;
|
||
}
|
||
return selectedFiles[0];
|
||
}
|
||
```
|
||
|
||
### 7.3.3 右键菜单重命名
|
||
|
||
```javascript
|
||
// 在右键菜单中添加重命名选项
|
||
function showContextMenu(event, panelId, path) {
|
||
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="deleteFile(\'' +
|
||
panelId + '\', \'' + path + '\')">删除</div>');
|
||
menu.append('<div class="menu-item" onclick="showRenameDialog(\'' +
|
||
panelId + '\', \'' + path + '\')">重命名</div>');
|
||
|
||
$('body').append(menu);
|
||
|
||
$(document).one('click', function() {
|
||
menu.remove();
|
||
});
|
||
}
|
||
```
|
||
|
||
### 7.3.4 在线编辑(高级功能)
|
||
|
||
```javascript
|
||
// 双击文件名进行重命名
|
||
function enableInlineRename() {
|
||
$('.file-item').on('dblclick', function(e) {
|
||
const fileItem = $(this);
|
||
const isDirectory = fileItem.data('is-dir');
|
||
|
||
// 如果是目录,进入目录;如果是文件,进入重命名模式
|
||
if (!isDirectory) {
|
||
const path = fileItem.data('path');
|
||
showRenameDialogInline(fileItem, path);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 在线重命名
|
||
function showRenameDialogInline(fileItem, path) {
|
||
const nameElement = fileItem.find('.file-name');
|
||
const currentName = nameElement.text();
|
||
|
||
// 创建输入框
|
||
const input = $('<input type="text" class="rename-input">');
|
||
input.val(currentName);
|
||
|
||
// 替换文件名显示为输入框
|
||
nameElement.html(input);
|
||
input.focus();
|
||
input.select();
|
||
|
||
// 失去焦点时保存
|
||
input.on('blur', function() {
|
||
const newName = input.val();
|
||
if (newName && newName !== currentName) {
|
||
renameFile(panelState.left.sessionId, path, newName);
|
||
} else {
|
||
// 取消重命名,恢复原名称
|
||
nameElement.text(currentName);
|
||
}
|
||
});
|
||
|
||
// 按Enter保存
|
||
input.on('keypress', function(e) {
|
||
if (e.which === 13) {
|
||
input.blur();
|
||
}
|
||
});
|
||
|
||
// 按Esc取消
|
||
input.on('keydown', function(e) {
|
||
if (e.which === 27) {
|
||
nameElement.text(currentName);
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
## 7.4 输入验证
|
||
|
||
### 7.4.1 前端验证
|
||
|
||
```javascript
|
||
// 验证文件名
|
||
function validateFileName(fileName) {
|
||
if (!fileName || fileName.trim() === '') {
|
||
alert('文件名不能为空');
|
||
return false;
|
||
}
|
||
|
||
// Windows非法字符
|
||
const illegalChars = /\\\/:\*\?"<>\|/;
|
||
if (illegalChars.test(fileName)) {
|
||
alert('文件名包含非法字符');
|
||
return false;
|
||
}
|
||
|
||
// 长度限制
|
||
if (fileName.length > 255) {
|
||
alert('文件名过长(最大255字符)');
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// 修改重命名对话框
|
||
function showRenameDialog(panelId, path) {
|
||
const oldName = getFileNameFromPath(path);
|
||
|
||
while (true) {
|
||
const newName = prompt('请输入新文件名:', oldName);
|
||
if (newName === null) {
|
||
// 用户取消
|
||
return;
|
||
}
|
||
if (newName === '' || newName === oldName) {
|
||
// 无效输入
|
||
continue;
|
||
}
|
||
|
||
if (validateFileName(newName)) {
|
||
renameFile(panelState[panelId].sessionId, path, newName);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 7.4.2 保留扩展名
|
||
|
||
```javascript
|
||
// 提取扩展名
|
||
function getFileExtension(fileName) {
|
||
const index = fileName.lastIndexOf('.');
|
||
if (index === -1 || index === fileName.length - 1) {
|
||
return '';
|
||
}
|
||
return fileName.substring(index);
|
||
}
|
||
|
||
// 修改重命名对话框(保留扩展名)
|
||
function showRenameDialog(panelId, path) {
|
||
const sessionId = panelState[panelId].sessionId;
|
||
const oldName = getFileNameFromPath(path);
|
||
const extension = getFileExtension(oldName);
|
||
const baseName = oldName.substring(0, oldName.length - extension.length);
|
||
|
||
const newName = prompt('请输入新文件名:', baseName);
|
||
if (newName && newName !== baseName) {
|
||
// 保留原扩展名
|
||
const fullName = newName + extension;
|
||
renameFile(sessionId, path, fullName);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 7.4.3 同名检查
|
||
|
||
```javascript
|
||
// 检查同名文件是否存在
|
||
function checkFileExists(sessionId, parentPath, fileName, callback) {
|
||
const fullPath = parentPath.endsWith('/') ?
|
||
parentPath + fileName :
|
||
parentPath + '/' + fileName;
|
||
|
||
$.ajax({
|
||
url: '/api/files/info',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({
|
||
sessionId: sessionId,
|
||
path: fullPath
|
||
}),
|
||
success: function(response) {
|
||
// 文件存在
|
||
callback(true);
|
||
},
|
||
error: function() {
|
||
// 文件不存在
|
||
callback(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 重命名时检查同名
|
||
function renameFile(sessionId, oldPath, newName) {
|
||
const parentPath = getParentPath(oldPath);
|
||
|
||
checkFileExists(sessionId, parentPath, newName, function(exists) {
|
||
if (exists) {
|
||
if (confirm('目标文件已存在,是否覆盖?')) {
|
||
doRename(sessionId, oldPath, newName);
|
||
}
|
||
} else {
|
||
doRename(sessionId, oldPath, newName);
|
||
}
|
||
});
|
||
}
|
||
|
||
function doRename(sessionId, oldPath, newName) {
|
||
// 原重命名逻辑
|
||
$.ajax({
|
||
url: '/api/files/rename',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({
|
||
sessionId: sessionId,
|
||
oldPath: oldPath,
|
||
newName: newName
|
||
}),
|
||
success: function(response) {
|
||
if (response.success) {
|
||
alert('重命名成功');
|
||
refreshCurrentPanel();
|
||
} else {
|
||
alert('重命名失败: ' + response.message);
|
||
}
|
||
},
|
||
error: handleError
|
||
});
|
||
}
|
||
```
|
||
|
||
## 实施步骤
|
||
|
||
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/rename \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"sessionId": "local",
|
||
"oldPath": "C:/test/old.txt",
|
||
"newName": "new.txt"
|
||
}'
|
||
```
|
||
|
||
### 2. 测试非法文件名
|
||
|
||
```bash
|
||
curl -X POST http://localhost:8080/sftp-manager/api/files/rename \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"sessionId": "local",
|
||
"oldPath": "C:/test/test.txt",
|
||
"newName": "test<.txt"
|
||
}'
|
||
```
|
||
|
||
### 3. 测试重名
|
||
|
||
```bash
|
||
curl -X POST http://localhost:8080/sftp-manager/api/files/rename \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"sessionId": "local",
|
||
"oldPath": "C:/test/file1.txt",
|
||
"newName": "file2.txt"
|
||
}'
|
||
```
|
||
|
||
## 注意事项
|
||
|
||
1. **文件名验证**:严格验证文件名,防止非法字符
|
||
2. **同名检查**:重命名前检查目标名称是否已存在
|
||
3. **扩展名处理**:可选择保留原扩展名
|
||
4. **用户确认**:重名时需要用户确认是否覆盖
|
||
5. **路径处理**:正确处理不同操作系统的路径分隔符
|
||
|
||
## 下一步
|
||
|
||
完成模块07后,继续模块08:新建文件夹功能
|