feat: 新增本地存储文件夹管理功能
新功能概述:
- 支持在本地存储模式下创建文件夹
- 支持删除文件夹(递归删除)
- 支持重命名文件夹(已有功能,天然支持)
- 文件夹配额计算正确
后端改动:
1. 新增创建文件夹API (backend/server.js)
- POST /api/files/mkdir
- 参数: path(当前路径), folderName(文件夹名称)
- 安全检查: 禁止特殊字符(/ \ .. :),防止路径遍历攻击
- 仅限本地存储使用
- 创建前检查文件夹是否已存在
2. 改进删除功能 (backend/storage.js)
- LocalStorageClient.delete() 现在支持删除文件夹
- 使用 fs.rmSync(path, { recursive: true }) 递归删除
- 新增 calculateFolderSize() 方法计算文件夹总大小
- 删除文件夹时正确更新配额使用情况
前端改动:
1. 新建文件夹按钮 (frontend/app.html)
- 在"上传文件"按钮旁新增"新建文件夹"按钮
- 仅本地存储模式显示
2. 新建文件夹弹窗 (frontend/app.html)
- 简洁的创建表单
- 支持回车键快速创建
- 使用优化的弹窗关闭逻辑(防止拖动选择文本时误关闭)
3. 前端API调用 (frontend/app.js)
- 新增 createFolderForm 状态
- 新增 createFolder() 方法
- 前端参数验证
- 创建成功后自动刷新文件列表
4. 右键菜单优化 (frontend/app.html)
- 文件夹不显示"下载"按钮(文件夹暂不支持打包下载)
- 文件夹不显示"分享"按钮(分享单个文件夹暂不支持)
- 文件夹支持"重命名"和"删除"操作
安全性:
- 文件夹名称严格验证,禁止包含 / \ .. : 等特殊字符
- 路径安全检查,防止目录遍历攻击
- 仅限本地存储模式使用(SFTP存储使用上传工具管理)
配额管理:
- 空文件夹不占用配额
- 删除文件夹时正确释放配额(计算所有子文件大小)
- 删除非空文件夹会递归删除所有内容
使用方式:
1. 登录后切换到本地存储模式
2. 点击"新建文件夹"按钮
3. 输入文件夹名称,点击创建
4. 双击文件夹进入,支持多级目录
5. 右键文件夹可重命名或删除
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1066,6 +1066,73 @@ app.post('/api/files/rename', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 创建文件夹
|
||||||
|
app.post('/api/files/mkdir', authMiddleware, async (req, res) => {
|
||||||
|
const { path, folderName } = req.body;
|
||||||
|
let storage;
|
||||||
|
|
||||||
|
// 参数验证
|
||||||
|
if (!folderName || folderName.trim() === '') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '文件夹名称不能为空'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件名安全检查 - 防止路径遍历攻击
|
||||||
|
if (folderName.includes('/') || folderName.includes('\\') || folderName.includes('..') || folderName.includes(':')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '文件夹名称不能包含特殊字符 (/ \\ .. :)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只允许本地存储创建文件夹
|
||||||
|
if (req.user.current_storage_type !== 'local') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '只有本地存储支持创建文件夹'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { StorageInterface } = require('./storage');
|
||||||
|
const storageInterface = new StorageInterface(req.user);
|
||||||
|
storage = await storageInterface.connect();
|
||||||
|
|
||||||
|
// 构造文件夹路径
|
||||||
|
const basePath = path || '/';
|
||||||
|
const folderPath = basePath === '/' ? `/${folderName}` : `${basePath}/${folderName}`;
|
||||||
|
const fullPath = storage.getFullPath(folderPath);
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
if (fs.existsSync(fullPath)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '文件夹已存在'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建文件夹 (不使用recursive,只创建当前层级)
|
||||||
|
fs.mkdirSync(fullPath, { mode: 0o755 });
|
||||||
|
|
||||||
|
console.log(`[创建文件夹成功] 用户${req.user.id}: ${folderPath}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '文件夹创建成功'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[创建文件夹失败]', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '创建文件夹失败: ' + error.message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (storage) await storage.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 删除文件
|
// 删除文件
|
||||||
app.post('/api/files/delete', authMiddleware, async (req, res) => {
|
app.post('/api/files/delete', authMiddleware, async (req, res) => {
|
||||||
const { fileName, path } = req.body;
|
const { fileName, path } = req.body;
|
||||||
@@ -1090,7 +1157,7 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '文件删除成功'
|
message: '删除成功'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除文件失败:', error);
|
console.error('删除文件失败:', error);
|
||||||
|
|||||||
@@ -137,16 +137,55 @@ class LocalStorageClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除文件
|
* 删除文件或文件夹
|
||||||
*/
|
*/
|
||||||
async delete(filePath) {
|
async delete(filePath) {
|
||||||
const fullPath = this.getFullPath(filePath);
|
const fullPath = this.getFullPath(filePath);
|
||||||
const stats = fs.statSync(fullPath);
|
const stats = fs.statSync(fullPath);
|
||||||
|
|
||||||
fs.unlinkSync(fullPath);
|
if (stats.isDirectory()) {
|
||||||
|
// 删除文件夹 - 递归删除
|
||||||
|
// 先计算文件夹内所有文件的总大小
|
||||||
|
const folderSize = this.calculateFolderSize(fullPath);
|
||||||
|
|
||||||
// 更新已使用空间
|
// 删除文件夹及其内容
|
||||||
this.updateUsedSpace(-stats.size);
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||||
|
|
||||||
|
// 更新已使用空间
|
||||||
|
if (folderSize > 0) {
|
||||||
|
this.updateUsedSpace(-folderSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 删除文件
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
|
||||||
|
// 更新已使用空间
|
||||||
|
this.updateUsedSpace(-stats.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算文件夹大小
|
||||||
|
*/
|
||||||
|
calculateFolderSize(folderPath) {
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
const items = fs.readdirSync(folderPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = path.join(folderPath, item.name);
|
||||||
|
|
||||||
|
if (item.isDirectory()) {
|
||||||
|
// 递归计算子文件夹
|
||||||
|
totalSize += this.calculateFolderSize(itemPath);
|
||||||
|
} else {
|
||||||
|
// 累加文件大小
|
||||||
|
const stats = fs.statSync(itemPath);
|
||||||
|
totalSize += stats.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -669,6 +669,9 @@
|
|||||||
<button v-if="storageType === 'local'" class="btn btn-primary" @click="$refs.fileUploadInput.click()">
|
<button v-if="storageType === 'local'" class="btn btn-primary" @click="$refs.fileUploadInput.click()">
|
||||||
<i class="fas fa-upload"></i> 上传文件
|
<i class="fas fa-upload"></i> 上传文件
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="storageType === 'local'" class="btn btn-primary" @click="showCreateFolderModal = true">
|
||||||
|
<i class="fas fa-folder-plus"></i> 新建文件夹
|
||||||
|
</button>
|
||||||
<!-- SFTP存储:显示下载上传工具按钮 -->
|
<!-- SFTP存储:显示下载上传工具按钮 -->
|
||||||
<button v-else class="btn btn-primary" @click="downloadUploadTool" :disabled="downloadingTool">
|
<button v-else class="btn btn-primary" @click="downloadUploadTool" :disabled="downloadingTool">
|
||||||
<i :class="downloadingTool ? 'fas fa-spinner fa-spin' : 'fas fa-download'"></i>
|
<i :class="downloadingTool ? 'fas fa-spinner fa-spin' : 'fas fa-download'"></i>
|
||||||
@@ -811,6 +814,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 新建文件夹模态框 -->
|
||||||
|
<div v-if="showCreateFolderModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showCreateFolderModal')">
|
||||||
|
<div class="modal-content" @click.stop>
|
||||||
|
<h3 style="margin-bottom: 20px;">
|
||||||
|
<i class="fas fa-folder-plus"></i> 新建文件夹
|
||||||
|
</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">文件夹名称</label>
|
||||||
|
<input type="text" class="form-input" v-model="createFolderForm.folderName" @keyup.enter="createFolder()" placeholder="请输入文件夹名称" autofocus>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
||||||
|
<button class="btn btn-primary" @click="createFolder()" style="flex: 1;">
|
||||||
|
<i class="fas fa-check"></i> 创建
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="showCreateFolderModal = false; createFolderForm.folderName = ''" style="flex: 1;">
|
||||||
|
<i class="fas fa-times"></i> 取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 分享所有文件模态框 -->
|
<!-- 分享所有文件模态框 -->
|
||||||
<div v-if="showShareAllModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showShareAllModal')">
|
<div v-if="showShareAllModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showShareAllModal')">
|
||||||
<div class="modal-content" @click.stop>
|
<div class="modal-content" @click.stop>
|
||||||
@@ -1674,13 +1698,14 @@
|
|||||||
<div v-if="isPreviewable(contextMenuFile)" class="context-menu-item" @click="contextMenuAction('preview')">
|
<div v-if="isPreviewable(contextMenuFile)" class="context-menu-item" @click="contextMenuAction('preview')">
|
||||||
<i class="fas fa-eye"></i> 预览
|
<i class="fas fa-eye"></i> 预览
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" @click="contextMenuAction('download')">
|
<!-- 文件夹不显示下载和分享按钮 -->
|
||||||
|
<div v-if="!contextMenuFile.isDirectory" class="context-menu-item" @click="contextMenuAction('download')">
|
||||||
<i class="fas fa-download"></i> 下载
|
<i class="fas fa-download"></i> 下载
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" @click="contextMenuAction('rename')">
|
<div class="context-menu-item" @click="contextMenuAction('rename')">
|
||||||
<i class="fas fa-edit"></i> 重命名
|
<i class="fas fa-edit"></i> 重命名
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" @click="contextMenuAction('share')">
|
<div v-if="!contextMenuFile.isDirectory" class="context-menu-item" @click="contextMenuAction('share')">
|
||||||
<i class="fas fa-share"></i> 分享
|
<i class="fas fa-share"></i> 分享
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-divider"></div>
|
<div class="context-menu-divider"></div>
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ createApp({
|
|||||||
path: ""
|
path: ""
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 创建文件夹
|
||||||
|
showCreateFolderModal: false,
|
||||||
|
createFolderForm: {
|
||||||
|
folderName: ""
|
||||||
|
},
|
||||||
|
|
||||||
// 上传
|
// 上传
|
||||||
showUploadModal: false,
|
showUploadModal: false,
|
||||||
uploadProgress: 0,
|
uploadProgress: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user