feat: 完善工作空间文件操作功能

新增功能:
- 重命名弹框(替换 alert)
- 新建文件/文件夹弹框
- 上传文件功能
- 移动文件功能(树状选择+确认)
- 查看文件详情弹框
- 刷新文件列表按钮

交互优化:
- 修复操作按钮 toggle 行为
- 右键菜单分组显示(基础操作/详细信息/危险操作)
- 预览弹框宽度调整为 600px
- 不支持预览的文件类型 Toast 提示
- 文件树子项缩进 16px
- 空文件夹展开后直接为空

数据增强:
- 中文文件名示例
- 无法预览类型示例(压缩包、数据库、二进制)
- 多级文件夹嵌套示例
- 空文件夹示例

样式新增:
- 标题栏操作按钮样式
- 刷新按钮旋转动画
- 新建按钮下拉菜单
- 右键菜单分组分隔线
- 空状态样式
This commit is contained in:
2026-04-17 17:10:46 +08:00
parent e382a60e0a
commit 5f333b116a
15 changed files with 1171 additions and 33 deletions

View 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;

View File

@@ -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')}

View 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;

View File

@@ -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 => (

View File

@@ -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

View 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;

View 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;

View File

@@ -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>
);
}
/**
* 工作空间展开按钮组件
*/

View File

@@ -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: []
}
];

View File

@@ -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);

View File

@@ -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);
}
}