feat: 完善工作空间文件操作功能
新增功能: - 重命名弹框(替换 alert) - 新建文件/文件夹弹框 - 上传文件功能 - 移动文件功能(树状选择+确认) - 查看文件详情弹框 - 刷新文件列表按钮 交互优化: - 修复操作按钮 toggle 行为 - 右键菜单分组显示(基础操作/详细信息/危险操作) - 预览弹框宽度调整为 600px - 不支持预览的文件类型 Toast 提示 - 文件树子项缩进 16px - 空文件夹展开后直接为空 数据增强: - 中文文件名示例 - 无法预览类型示例(压缩包、数据库、二进制) - 多级文件夹嵌套示例 - 空文件夹示例 样式新增: - 标题栏操作按钮样式 - 刷新按钮旋转动画 - 新建按钮下拉菜单 - 右键菜单分组分隔线 - 空状态样式
This commit is contained in:
55
src/components/workspace/CreateModal.jsx
Normal file
55
src/components/workspace/CreateModal.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from '../common/Modal.jsx';
|
||||
|
||||
/**
|
||||
* 新建弹框组件
|
||||
* 支持新建文件和文件夹
|
||||
*/
|
||||
function CreateModal({ type, onConfirm, onCancel }) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
if (!type) return null;
|
||||
|
||||
const title = type === 'file' ? '新建文件' : '新建文件夹';
|
||||
const placeholder = type === 'file' ? '请输入文件名' : '请输入文件夹名';
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (name.trim()) {
|
||||
onConfirm && onConfirm(name.trim(), type);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
title={title}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={onCancel}
|
||||
confirmText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--color-text-2)' }}>
|
||||
名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--color-border-2)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateModal;
|
||||
@@ -1,9 +1,11 @@
|
||||
import { FiDownload, FiEdit2, FiTrash2 } from 'react-icons/fi';
|
||||
import { FiDownload, FiEdit2, FiTrash2, FiInfo, FiFolder, FiFilePlus, FiFolderPlus, FiUpload } from 'react-icons/fi';
|
||||
|
||||
/**
|
||||
* 文件右键菜单组件
|
||||
*/
|
||||
function FileContextMenu({ file, position, onDownload, onRename, onDelete, onClose }) {
|
||||
function FileContextMenu({ file, position, onDownload, onRename, onDelete, onDetail, onMove, onNewFile, onNewFolder, onUpload, onClose }) {
|
||||
const isFolder = file.type === 'folder';
|
||||
|
||||
const handleAction = (action) => {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
@@ -15,6 +17,21 @@ function FileContextMenu({ file, position, onDownload, onRename, onDelete, onClo
|
||||
case 'delete':
|
||||
onDelete && onDelete(file);
|
||||
break;
|
||||
case 'detail':
|
||||
onDetail && onDetail(file);
|
||||
break;
|
||||
case 'move':
|
||||
onMove && onMove(file);
|
||||
break;
|
||||
case 'new-file':
|
||||
onNewFile && onNewFile(file);
|
||||
break;
|
||||
case 'new-folder':
|
||||
onNewFolder && onNewFolder(file);
|
||||
break;
|
||||
case 'upload':
|
||||
onUpload && onUpload(file);
|
||||
break;
|
||||
}
|
||||
onClose && onClose();
|
||||
};
|
||||
@@ -28,15 +45,52 @@ function FileContextMenu({ file, position, onDownload, onRename, onDelete, onClo
|
||||
left: position.x,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('download')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiDownload size={14} />
|
||||
</span>
|
||||
下载
|
||||
</div>
|
||||
{/* 文件夹特有操作 */}
|
||||
{isFolder && (
|
||||
<>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('new-file')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiFilePlus size={14} />
|
||||
</span>
|
||||
新建文件
|
||||
</div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('new-folder')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiFolderPlus size={14} />
|
||||
</span>
|
||||
新建文件夹
|
||||
</div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('upload')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiUpload size={14} />
|
||||
</span>
|
||||
上传文件
|
||||
</div>
|
||||
<div className="file-context-menu__divider"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 基础操作 */}
|
||||
{!isFolder && (
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('download')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiDownload size={14} />
|
||||
</span>
|
||||
下载
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('rename')}
|
||||
@@ -46,6 +100,30 @@ function FileContextMenu({ file, position, onDownload, onRename, onDelete, onClo
|
||||
</span>
|
||||
重命名
|
||||
</div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('move')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiFolder size={14} />
|
||||
</span>
|
||||
移动到
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<div className="file-context-menu__divider"></div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('detail')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiInfo size={14} />
|
||||
</span>
|
||||
查看详情
|
||||
</div>
|
||||
|
||||
{/* 危险操作 */}
|
||||
<div className="file-context-menu__divider"></div>
|
||||
<div
|
||||
className="file-context-menu__item file-context-menu__item--danger"
|
||||
onClick={() => handleAction('delete')}
|
||||
|
||||
121
src/components/workspace/FileDetailModal.jsx
Normal file
121
src/components/workspace/FileDetailModal.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { FiX } from 'react-icons/fi';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
* 文件详情弹框组件
|
||||
*/
|
||||
function FileDetailModal({ file, onClose }) {
|
||||
if (!file) return null;
|
||||
|
||||
const icon = api.workspace.getFileIcon(file.fileType);
|
||||
const isFolder = file.type === 'folder';
|
||||
|
||||
// 获取文件类型描述
|
||||
const getTypeDesc = (fileType) => {
|
||||
const typeMap = {
|
||||
text: '文本文件',
|
||||
markdown: 'Markdown 文件',
|
||||
json: 'JSON 文件',
|
||||
javascript: 'JavaScript 文件',
|
||||
python: 'Python 文件',
|
||||
css: 'CSS 文件',
|
||||
html: 'HTML 文件',
|
||||
word: 'Word 文档',
|
||||
excel: 'Excel 文档',
|
||||
powerpoint: 'PowerPoint 文档',
|
||||
image: '图片文件',
|
||||
video: '视频文件',
|
||||
audio: '音频文件',
|
||||
pdf: 'PDF 文件',
|
||||
archive: '压缩文件',
|
||||
unknown: '未知类型',
|
||||
};
|
||||
return typeMap[fileType] || '文件';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">文件详情</div>
|
||||
<div className="modal-close" onClick={onClose}>
|
||||
<FiX size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
名称
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
类型
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{isFolder ? '文件夹' : getTypeDesc(file.fileType)}
|
||||
</div>
|
||||
</div>
|
||||
{!isFolder && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
大小
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.size || '-'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{file.dimensions && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
尺寸
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.dimensions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{file.duration && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
时长
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.duration}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
修改时间
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.modifiedAt || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
路径
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
/workspace/{file.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn--primary" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileDetailModal;
|
||||
@@ -1,4 +1,5 @@
|
||||
import FileTreeItem from './FileTreeItem.jsx';
|
||||
import { FiFolder } from 'react-icons/fi';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
@@ -8,6 +9,19 @@ import api from '../../services/api.js';
|
||||
function FileTree({ onFileClick, onMenuClick }) {
|
||||
const files = api.workspace.getFiles();
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return (
|
||||
<div className="file-tree">
|
||||
<div className="file-tree__empty">
|
||||
<div className="file-tree__empty-icon">
|
||||
<FiFolder size={48} />
|
||||
</div>
|
||||
<div className="file-tree__empty-text">工作空间为空</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{files.map(item => (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { FiChevronRight, FiFolder, FiFile, FiFileText, FiCode, FiImage, FiVideo, FiMusic } from 'react-icons/fi';
|
||||
import { FaFileWord, FaFileExcel, FaFilePowerpoint, FaFilePdf, FaFileArchive } from 'react-icons/fa';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
* 文件树项组件
|
||||
@@ -9,7 +8,6 @@ import api from '../../services/api.js';
|
||||
*/
|
||||
function FileTreeItem({ item, onFileClick, onMenuClick }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
const isFolder = item.type === 'folder';
|
||||
|
||||
@@ -73,8 +71,7 @@ function FileTreeItem({ item, onFileClick, onMenuClick }) {
|
||||
// 点击操作按钮
|
||||
const handleMenuClick = (e) => {
|
||||
e.stopPropagation();
|
||||
setShowMenu(!showMenu);
|
||||
if (onMenuClick && !showMenu) {
|
||||
if (onMenuClick) {
|
||||
onMenuClick(item, e);
|
||||
}
|
||||
};
|
||||
@@ -118,7 +115,7 @@ function FileTreeItem({ item, onFileClick, onMenuClick }) {
|
||||
</div>
|
||||
|
||||
{/* 子项(文件夹展开时显示) */}
|
||||
{isFolder && isExpanded && item.children && (
|
||||
{isFolder && isExpanded && item.children && item.children.length > 0 && (
|
||||
<div className="file-tree__children">
|
||||
{item.children.map(child => (
|
||||
<FileTreeItem
|
||||
|
||||
50
src/components/workspace/RenameModal.jsx
Normal file
50
src/components/workspace/RenameModal.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from '../common/Modal.jsx';
|
||||
|
||||
/**
|
||||
* 重命名弹框组件
|
||||
*/
|
||||
function RenameModal({ file, onConfirm, onCancel }) {
|
||||
const [newName, setNewName] = useState(file ? file.name : '');
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (newName.trim()) {
|
||||
onConfirm && onConfirm(newName.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
title="重命名"
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={onCancel}
|
||||
confirmText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--color-text-2)' }}>
|
||||
新名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--color-border-2)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameModal;
|
||||
38
src/components/workspace/UploadButton.jsx
Normal file
38
src/components/workspace/UploadButton.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 上传文件组件
|
||||
* 使用原生 input[type="file"]
|
||||
*/
|
||||
function UploadButton({ onUpload }) {
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const handleClick = () => {
|
||||
inputRef.current && inputRef.current.click();
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
onUpload && onUpload(file);
|
||||
// 重置 input,允许重复上传同一文件
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
|
||||
上传文件
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UploadButton;
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { FiX, FiFolder } from 'react-icons/fi';
|
||||
import { FiX, FiFolder, FiRefreshCw, FiPlus, FiFilePlus, FiFolderPlus, FiUpload } from 'react-icons/fi';
|
||||
import FileTree from './FileTree.jsx';
|
||||
import FileContextMenu from './FileContextMenu.jsx';
|
||||
import FilePreviewModal from './FilePreviewModal.jsx';
|
||||
import RenameModal from './RenameModal.jsx';
|
||||
import CreateModal from './CreateModal.jsx';
|
||||
import FileDetailModal from './FileDetailModal.jsx';
|
||||
import Modal from '../common/Modal.jsx';
|
||||
import Toast from '../common/Toast.jsx';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
* 工作空间侧边栏组件
|
||||
@@ -13,10 +18,19 @@ function WorkspaceSidebar({ isOpen, onClose }) {
|
||||
const [contextMenu, setContextMenu] = useState(null); // { file, position }
|
||||
const [previewFile, setPreviewFile] = useState(null);
|
||||
const [deleteFile, setDeleteFile] = useState(null);
|
||||
const [renameFile, setRenameFile] = useState(null);
|
||||
const [createType, setCreateType] = useState(null); // 'file' | 'folder'
|
||||
const [detailFile, setDetailFile] = useState(null);
|
||||
const [moveFile, setMoveFile] = useState(null);
|
||||
const [moveTarget, setMoveTarget] = useState(null); // 选中的目标文件夹
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [toast, setToast] = useState(null); // { type, message }
|
||||
const [width, setWidth] = useState(280); // 默认宽度 280px
|
||||
const sidebarRef = useRef(null);
|
||||
const isResizingRef = useRef(false);
|
||||
const lastXRef = useRef(0);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// 拖动调整宽度 - 使用 requestAnimationFrame 优化
|
||||
const handleMouseDown = (e) => {
|
||||
@@ -87,27 +101,44 @@ function WorkspaceSidebar({ isOpen, onClose }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 点击外部关闭右键菜单
|
||||
// 点击外部关闭右键菜单和下拉菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => {
|
||||
setContextMenu(null);
|
||||
const handleClickOutside = (e) => {
|
||||
// 关闭右键菜单
|
||||
if (contextMenu) {
|
||||
setContextMenu(null);
|
||||
}
|
||||
// 关闭下拉菜单
|
||||
if (showDropdown && dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
if (contextMenu) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [contextMenu]);
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, [contextMenu, showDropdown]);
|
||||
|
||||
// 文件点击:打开预览
|
||||
const handleFileClick = (file) => {
|
||||
// 不支持预览的文件类型直接提示
|
||||
const unpreviewableTypes = ['archive', 'unknown'];
|
||||
if (unpreviewableTypes.includes(file.fileType)) {
|
||||
showToast('该文件类型不支持预览', 'warning');
|
||||
return;
|
||||
}
|
||||
setPreviewFile(file);
|
||||
};
|
||||
|
||||
// 右键菜单点击
|
||||
const handleMenuClick = (file, e) => {
|
||||
// 如果点击的是同一个文件,且菜单已经显示,则关闭菜单(toggle)
|
||||
if (contextMenu && contextMenu.file.id === file.id) {
|
||||
setContextMenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
// 菜单显示在按钮左侧,避免超出页面
|
||||
const menuWidth = 140; // 菜单宽度
|
||||
const menuWidth = 160; // 菜单宽度
|
||||
const x = rect.right - menuWidth;
|
||||
const y = rect.bottom + 4;
|
||||
|
||||
@@ -120,32 +151,139 @@ function WorkspaceSidebar({ isOpen, onClose }) {
|
||||
});
|
||||
};
|
||||
|
||||
// 显示 Toast 提示
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToast({ type, message });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
// 下载操作
|
||||
const handleDownload = (file) => {
|
||||
alert('文件下载中...');
|
||||
showToast('文件下载中...', 'info');
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// 重命名操作
|
||||
const handleRename = (file) => {
|
||||
alert('文件已重命名');
|
||||
setRenameFile(file);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleRenameConfirm = (newName) => {
|
||||
showToast('重命名成功');
|
||||
setRenameFile(null);
|
||||
};
|
||||
|
||||
// 删除操作
|
||||
const handleDelete = (file) => {
|
||||
setDeleteFile(file);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// 确认删除
|
||||
const handleDeleteConfirm = () => {
|
||||
alert('文件已删除');
|
||||
showToast('文件已删除');
|
||||
setDeleteFile(null);
|
||||
};
|
||||
|
||||
// 新建文件/文件夹
|
||||
const handleNewFile = () => {
|
||||
setCreateType('file');
|
||||
setShowDropdown(false);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleNewFolder = () => {
|
||||
setCreateType('folder');
|
||||
setShowDropdown(false);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleCreateConfirm = (name, type) => {
|
||||
const message = type === 'file' ? '文件创建成功' : '文件夹创建成功';
|
||||
showToast(message);
|
||||
setCreateType(null);
|
||||
};
|
||||
|
||||
// 上传文件
|
||||
const handleUpload = (file) => {
|
||||
showToast('文件上传成功');
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
// 触发文件选择
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.onchange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
handleUpload(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
setShowDropdown(false);
|
||||
};
|
||||
|
||||
// 移动文件
|
||||
const handleMove = (file) => {
|
||||
setMoveFile(file);
|
||||
setMoveTarget(null);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleMoveConfirm = () => {
|
||||
if (moveTarget) {
|
||||
showToast(`文件已移动到 ${moveTarget}`);
|
||||
setMoveFile(null);
|
||||
setMoveTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCancel = () => {
|
||||
setMoveFile(null);
|
||||
setMoveTarget(null);
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleDetail = (file) => {
|
||||
setDetailFile(file);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
setIsRefreshing(true);
|
||||
setTimeout(() => {
|
||||
setIsRefreshing(false);
|
||||
showToast('文件列表已刷新');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 下拉菜单切换
|
||||
const handleDropdownToggle = (e) => {
|
||||
e.stopPropagation();
|
||||
setShowDropdown(!showDropdown);
|
||||
};
|
||||
|
||||
// 获取所有文件夹(树形结构,用于移动文件)
|
||||
const getFolderTree = () => {
|
||||
const files = api.workspace.getFiles();
|
||||
const extractFolders = (items) => {
|
||||
return items
|
||||
.filter(item => item.type === 'folder')
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
children: item.children ? extractFolders(item.children) : [],
|
||||
}));
|
||||
};
|
||||
return extractFolders(files);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const folderTree = getFolderTree();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -155,6 +293,46 @@ function WorkspaceSidebar({ isOpen, onClose }) {
|
||||
>
|
||||
<div className="workspace-sidebar__header">
|
||||
<div className="workspace-sidebar__title">工作空间</div>
|
||||
<div className="workspace-sidebar__actions">
|
||||
<div
|
||||
className={`workspace-sidebar__action-btn ${isRefreshing ? 'workspace-sidebar__refresh--rotating' : ''}`}
|
||||
onClick={handleRefresh}
|
||||
title="刷新"
|
||||
>
|
||||
<FiRefreshCw size={16} />
|
||||
</div>
|
||||
<div style={{ position: 'relative' }} ref={dropdownRef}>
|
||||
<div
|
||||
className="workspace-sidebar__action-btn"
|
||||
onClick={handleDropdownToggle}
|
||||
title="新建"
|
||||
>
|
||||
<FiPlus size={16} />
|
||||
</div>
|
||||
{showDropdown && (
|
||||
<div className="workspace-sidebar__dropdown">
|
||||
<div className="workspace-sidebar__dropdown__item" onClick={handleNewFile}>
|
||||
<span className="workspace-sidebar__dropdown__icon">
|
||||
<FiFilePlus size={14} />
|
||||
</span>
|
||||
新建文件
|
||||
</div>
|
||||
<div className="workspace-sidebar__dropdown__item" onClick={handleNewFolder}>
|
||||
<span className="workspace-sidebar__dropdown__icon">
|
||||
<FiFolderPlus size={14} />
|
||||
</span>
|
||||
新建文件夹
|
||||
</div>
|
||||
<div className="workspace-sidebar__dropdown__item" onClick={handleUploadClick}>
|
||||
<span className="workspace-sidebar__dropdown__icon">
|
||||
<FiUpload size={14} />
|
||||
</span>
|
||||
上传文件
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-sidebar__close" onClick={onClose}>
|
||||
<FiX size={18} />
|
||||
</div>
|
||||
@@ -186,6 +364,11 @@ function WorkspaceSidebar({ isOpen, onClose }) {
|
||||
onDownload={handleDownload}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
onDetail={handleDetail}
|
||||
onMove={handleMove}
|
||||
onNewFile={handleNewFile}
|
||||
onNewFolder={handleNewFolder}
|
||||
onUpload={handleUploadClick}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -209,10 +392,111 @@ function WorkspaceSidebar({ isOpen, onClose }) {
|
||||
确定要删除文件「{deleteFile.name}」吗?
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 重命名弹框 */}
|
||||
{renameFile && (
|
||||
<RenameModal
|
||||
file={renameFile}
|
||||
onConfirm={handleRenameConfirm}
|
||||
onCancel={() => setRenameFile(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 新建弹框 */}
|
||||
{createType && (
|
||||
<CreateModal
|
||||
type={createType}
|
||||
onConfirm={handleCreateConfirm}
|
||||
onCancel={() => setCreateType(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文件详情弹框 */}
|
||||
{detailFile && (
|
||||
<FileDetailModal
|
||||
file={detailFile}
|
||||
onClose={() => setDetailFile(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 移动文件弹框 */}
|
||||
{moveFile && (
|
||||
<div className="modal-overlay" onClick={handleMoveCancel}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ width: '400px' }}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">移动到</div>
|
||||
<div className="modal-close" onClick={handleMoveCancel}>
|
||||
<FiX size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<MoveFolderTree
|
||||
folders={folderTree}
|
||||
selected={moveTarget}
|
||||
onSelect={setMoveTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn" onClick={handleMoveCancel}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleMoveConfirm} disabled={!moveTarget}>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast 提示 */}
|
||||
<Toast
|
||||
visible={!!toast}
|
||||
type={toast?.type}
|
||||
message={toast?.message}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动文件弹框中的文件夹树组件
|
||||
*/
|
||||
function MoveFolderTree({ folders, selected, onSelect, depth = 0 }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
{folders.map(folder => (
|
||||
<div key={folder.id}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
paddingLeft: `${12 + depth * 20}px`,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '14px',
|
||||
transition: 'background 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: selected === folder.name ? 'var(--color-bg-2)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (selected !== folder.name) e.currentTarget.style.background = 'var(--color-bg-2)'; }}
|
||||
onMouseLeave={(e) => { if (selected !== folder.name) e.currentTarget.style.background = 'transparent'; }}
|
||||
onClick={() => onSelect(folder.name)}
|
||||
>
|
||||
<FiFolder size={14} style={{ flexShrink: 0 }} />
|
||||
{folder.name}
|
||||
</div>
|
||||
{folder.children.length > 0 && (
|
||||
<MoveFolderTree
|
||||
folders={folder.children}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作空间展开按钮组件
|
||||
*/
|
||||
|
||||
@@ -241,5 +241,125 @@ button:hover {
|
||||
fileType: 'excel',
|
||||
size: '89 KB',
|
||||
modifiedAt: '2026-04-04'
|
||||
},
|
||||
{
|
||||
id: 'folder_4',
|
||||
name: '中文文档',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_12',
|
||||
name: '产品需求文档.docx',
|
||||
type: 'file',
|
||||
fileType: 'word',
|
||||
size: '234 KB',
|
||||
modifiedAt: '2026-04-10'
|
||||
},
|
||||
{
|
||||
id: 'file_13',
|
||||
name: '用户反馈汇总.xlsx',
|
||||
type: 'file',
|
||||
fileType: 'excel',
|
||||
size: '156 KB',
|
||||
modifiedAt: '2026-04-09'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'folder_5',
|
||||
name: 'archives',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_14',
|
||||
name: 'archive.zip',
|
||||
type: 'file',
|
||||
fileType: 'archive',
|
||||
size: '5.6 MB',
|
||||
modifiedAt: '2026-04-08'
|
||||
},
|
||||
{
|
||||
id: 'file_15',
|
||||
name: 'database.db',
|
||||
type: 'file',
|
||||
fileType: 'unknown',
|
||||
size: '1.2 MB',
|
||||
modifiedAt: '2026-04-07'
|
||||
},
|
||||
{
|
||||
id: 'file_16',
|
||||
name: 'binary.bin',
|
||||
type: 'file',
|
||||
fileType: 'unknown',
|
||||
size: '512 KB',
|
||||
modifiedAt: '2026-04-06'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'folder_6',
|
||||
name: 'project',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'folder_7',
|
||||
name: 'src',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'folder_8',
|
||||
name: 'components',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'folder_9',
|
||||
name: 'Button',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_17',
|
||||
name: 'Button.jsx',
|
||||
type: 'file',
|
||||
fileType: 'javascript',
|
||||
size: '2.1 KB',
|
||||
modifiedAt: '2026-04-05',
|
||||
content: `import React from 'react';
|
||||
|
||||
function Button({ children, onClick }) {
|
||||
return (
|
||||
<button className="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;`
|
||||
},
|
||||
{
|
||||
id: 'file_18',
|
||||
name: 'Button.scss',
|
||||
type: 'file',
|
||||
fileType: 'css',
|
||||
size: '856 B',
|
||||
modifiedAt: '2026-04-04',
|
||||
content: `.button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}`
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'folder_10',
|
||||
name: 'empty-folder',
|
||||
type: 'folder',
|
||||
children: []
|
||||
}
|
||||
];
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
background: var(--color-bg-1);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.16);
|
||||
width: 420px;
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
max-height: calc(100vh - 48px);
|
||||
max-height: calc(100dvh - 48px);
|
||||
|
||||
@@ -52,6 +52,73 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&__action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-1);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
&__refresh--rotating {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--color-bg-1);
|
||||
border: 1px solid var(--color-border-3);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.16);
|
||||
z-index: $z-index-modal;
|
||||
min-width: 140px;
|
||||
padding: 4px 0;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: $font-size-sm;
|
||||
color: var(--color-text-1);
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -225,7 +292,7 @@
|
||||
}
|
||||
|
||||
&__children {
|
||||
padding-left: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +374,7 @@
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.16);
|
||||
z-index: $z-index-modal;
|
||||
min-width: 140px;
|
||||
min-width: 160px;
|
||||
padding: 4px 0;
|
||||
|
||||
&__item {
|
||||
@@ -341,4 +408,50 @@
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
height: 1px;
|
||||
background: var(--color-border-2);
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 空状态样式
|
||||
.file-tree__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-3);
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.file-tree__empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.file-tree__empty-text {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
// 空文件夹占位符
|
||||
.file-tree__folder-empty {
|
||||
padding: 8px 28px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// 旋转动画
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user