diff --git a/openspec/specs/file-operations/spec.md b/openspec/specs/file-operations/spec.md new file mode 100644 index 0000000..8924562 --- /dev/null +++ b/openspec/specs/file-operations/spec.md @@ -0,0 +1,189 @@ +# Purpose + +定义工作空间文件操作功能,包括重命名、新建、上传、移动、查看详情、刷新等操作。(TBD - 待补充详细目的) + +# Requirements + +## ADDED Requirements + +### Requirement: 文件重命名弹框 +系统应提供重命名弹框,允许用户输入新文件名。 + +#### Scenario: 点击重命名显示弹框 +- **WHEN** 用户点击右键菜单的"重命名"项 +- **THEN** 显示重命名弹框,输入框预填充当前文件名 + +#### Scenario: 重命名弹框确认 +- **WHEN** 用户在重命名弹框中输入新名称并点击"确认" +- **THEN** 显示 Toast 提示"重命名成功",弹框关闭 + +#### Scenario: 重命名弹框取消 +- **WHEN** 用户点击重命名弹框的"取消"按钮 +- **THEN** 弹框关闭,不执行重命名操作 + +#### Scenario: 重命名弹框标题 +- **WHEN** 重命名弹框显示 +- **THEN** 弹框标题为"重命名" + +### Requirement: 新建文件功能 +系统应允许用户新建文件。 + +#### Scenario: 点击新建文件显示弹框 +- **WHEN** 用户点击标题栏 [+] 按钮并选择"新建文件" +- **THEN** 显示新建弹框,类型默认为"文件",输入框为空 + +#### Scenario: 新建文件确认 +- **WHEN** 用户在新建弹框中输入文件名并点击"确认" +- **THEN** 显示 Toast 提示"文件创建成功",弹框关闭 + +#### Scenario: 新建文件弹框标题 +- **WHEN** 新建文件弹框显示 +- **THEN** 弹框标题为"新建文件" + +### Requirement: 新建文件夹功能 +系统应允许用户新建文件夹。 + +#### Scenario: 点击新建文件夹显示弹框 +- **WHEN** 用户点击标题栏 [+] 按钮并选择"新建文件夹" +- **THEN** 显示新建弹框,类型默认为"文件夹",输入框为空 + +#### Scenario: 新建文件夹确认 +- **WHEN** 用户在新建弹框中输入文件夹名并点击"确认" +- **THEN** 显示 Toast 提示"文件夹创建成功",弹框关闭 + +#### Scenario: 新建文件夹弹框标题 +- **WHEN** 新建文件夹弹框显示 +- **THEN** 弹框标题为"新建文件夹" + +### Requirement: 上传文件功能 +系统应允许用户上传文件。 + +#### Scenario: 点击上传文件显示文件选择器 +- **WHEN** 用户点击标题栏 [+] 按钮并选择"上传文件" +- **THEN** 打开原生文件选择对话框 + +#### Scenario: 选择文件后模拟上传 +- **WHEN** 用户在文件选择器中选择文件并确认 +- **THEN** 显示 Toast 提示"文件上传成功" + +#### Scenario: 取消文件选择 +- **WHEN** 用户在文件选择器中点击取消 +- **THEN** 不执行任何操作 + +### Requirement: 移动文件功能 +系统应允许用户移动文件到其他文件夹。 + +#### Scenario: 点击移动显示目标选择弹框 +- **WHEN** 用户点击右键菜单的"移动到"项 +- **THEN** 显示移动弹框,以树状结构列出所有可选的目标文件夹 + +#### Scenario: 文件夹树状展示 +- **WHEN** 移动弹框显示 +- **THEN** 文件夹以树状结构展示,子文件夹带缩进(每级 20px)和文件夹图标,层级清晰可辨 + +#### Scenario: 选中目标文件夹 +- **WHEN** 用户点击某个文件夹 +- **THEN** 该文件夹高亮选中,可切换选择其他文件夹 + +#### Scenario: 确认移动 +- **WHEN** 用户选中目标文件夹后点击"确认"按钮 +- **THEN** 显示 Toast 提示"文件已移动到 [文件夹名]",弹框关闭 + +#### Scenario: 取消移动 +- **WHEN** 用户点击"取消"按钮或遮罩层 +- **THEN** 弹框关闭,不执行移动操作 + +#### Scenario: 未选择时确认按钮禁用 +- **WHEN** 移动弹框显示但未选中任何目标文件夹 +- **THEN** "确认"按钮处于 disabled 状态 + +#### Scenario: 移动弹框仅显示文件夹 +- **WHEN** 移动弹框显示 +- **THEN** 仅列出文件夹项,不包含文件项 + +### Requirement: 查看文件详情功能 +系统应允许用户查看文件详细信息。 + +#### Scenario: 点击查看详情显示弹框 +- **WHEN** 用户点击右键菜单的"查看详情"项 +- **THEN** 显示详情弹框,展示文件完整信息 + +#### Scenario: 详情弹框显示完整信息 +- **WHEN** 文件详情弹框显示 +- **THEN** 弹框显示文件名、类型、大小、创建时间、修改时间、路径等信息 + +#### Scenario: 详情弹框标题 +- **WHEN** 文件详情弹框显示 +- **THEN** 弹框标题为"文件详情" + +#### Scenario: 详情弹框关闭 +- **WHEN** 用户点击详情弹框的"关闭"按钮 +- **THEN** 弹框关闭 + +### Requirement: 刷新文件列表功能 +系统应允许用户刷新文件列表。 + +#### Scenario: 点击刷新按钮 +- **WHEN** 用户点击标题栏的刷新按钮(↻) +- **THEN** 刷新按钮显示旋转动画,文件列表淡出淡入 + +#### Scenario: 刷新完成提示 +- **WHEN** 刷新操作完成 +- **THEN** 显示 Toast 提示"文件列表已刷新",旋转动画停止 + +#### Scenario: 刷新按钮位置 +- **WHEN** 工作空间侧边栏打开 +- **THEN** 刷新按钮显示在标题"工作空间"右侧 + +### Requirement: 右键菜单分组 +右键菜单应按功能分组显示。 + +#### Scenario: 基础操作组 +- **WHEN** 右键菜单显示 +- **THEN** 第一组包含"下载"、"重命名"、"移动到"操作 + +#### Scenario: 详细信息组 +- **WHEN** 右键菜单显示 +- **THEN** 第二组包含"查看详情"操作,与第一组用分隔线分开 + +#### Scenario: 危险操作组 +- **WHEN** 右键菜单显示 +- **THEN** 第三组包含"删除"操作,与第二组用分隔线分开,使用红色文字 + +#### Scenario: 菜单项显示图标 +- **WHEN** 右键菜单显示 +- **THEN** 每个菜单项左侧显示对应图标 + +### Requirement: 操作按钮 toggle 行为 +文件项的操作按钮应支持 toggle 行为。 + +#### Scenario: 点击按钮显示菜单 +- **WHEN** 用户点击文件项的操作按钮(⋯)且菜单未显示 +- **THEN** 显示该文件的右键菜单 + +#### Scenario: 再次点击按钮关闭菜单 +- **WHEN** 用户点击文件项的操作按钮(⋯)且菜单已显示 +- **THEN** 关闭右键菜单 + +#### Scenario: 点击其他文件按钮切换菜单 +- **WHEN** 右键菜单显示文件 A 的菜单 +- **THEN** 用户点击文件 B 的操作按钮 +- **AND** 关闭文件 A 的菜单,显示文件 B 的菜单 + +### Requirement: 文件夹右键菜单 +文件夹项应支持特定的右键菜单操作。 + +#### Scenario: 文件夹右键菜单项 +- **WHEN** 用户点击文件夹的操作按钮 +- **THEN** 显示包含"新建文件"、"新建文件夹"、"上传文件"、"重命名"、"删除"等操作的菜单 + +#### Scenario: 文件夹菜单不显示下载项 +- **WHEN** 文件夹右键菜单显示 +- **THEN** 菜单不包含"下载"操作项 + +### Requirement: 不支持预览的文件类型提示 +点击不支持预览的文件类型时,应显示 Toast 提示而非打开预览弹框。 + +#### Scenario: 点击不可预览文件类型 +- **WHEN** 用户点击压缩包(archive)或未知类型(unknown)的文件 +- **THEN** 显示 Toast 提示"该文件类型不支持预览",不打开预览弹框 diff --git a/openspec/specs/file-preview/spec.md b/openspec/specs/file-preview/spec.md index 1d3bf75..be6382c 100644 --- a/openspec/specs/file-preview/spec.md +++ b/openspec/specs/file-preview/spec.md @@ -11,6 +11,10 @@ - **WHEN** 文件预览弹窗打开 - **THEN** 弹窗 header 显示文件名和关闭按钮(×) +#### Scenario: 弹窗宽度适中 +- **WHEN** 文件预览弹窗打开 +- **THEN** 弹窗宽度为 600px,最大宽度为 90vw + ### Requirement: 文本文件预览 文本文件预览应显示文件内容。 diff --git a/openspec/specs/file-tree/spec.md b/openspec/specs/file-tree/spec.md index 4482536..0ed6a54 100644 --- a/openspec/specs/file-tree/spec.md +++ b/openspec/specs/file-tree/spec.md @@ -121,3 +121,40 @@ #### Scenario: 文件信息紧凑显示 - **WHEN** 文件树显示文件项 - **THEN** 文件大小宽度为 50px,修改时间宽度为 40px,字号为 11px + +### Requirement: 文件树支持空状态显示 +文件树应支持空状态显示。 + +#### Scenario: 工作空间为空显示空状态 +- **WHEN** 工作空间没有任何文件或文件夹 +- **THEN** 文件树显示空状态界面,提示"工作空间为空" + +#### Scenario: 空文件夹展开后为空 +- **WHEN** 文件夹展开且文件夹为空 +- **THEN** 文件夹下方不显示任何内容 + +### Requirement: 文件树子项缩进 +文件树子项应通过缩进体现层级关系。 + +#### Scenario: 子项缩进显示 +- **WHEN** 文件夹展开显示子项 +- **THEN** 子项左侧有 16px 缩进,与父级区分层级 + +### Requirement: 文件树包含丰富的示例数据 +文件树应包含多种类型的文件示例。 + +#### Scenario: 包含中文文件名 +- **WHEN** 文件树加载 +- **THEN** 显示包含中文文件名的文件(如"产品需求文档.docx"、"用户反馈汇总.xlsx") + +#### Scenario: 包含无法预览的文件类型 +- **WHEN** 文件树加载 +- **THEN** 显示压缩包、数据库、二进制文件等无法预览的文件类型 + +#### Scenario: 包含多级文件夹嵌套 +- **WHEN** 文件树加载 +- **THEN** 显示至少 3 级文件夹嵌套(如 project/src/components/Button/) + +#### Scenario: 包含空文件夹示例 +- **WHEN** 文件树加载 +- **THEN** 显示至少一个空文件夹 diff --git a/openspec/specs/workspace-sidebar/spec.md b/openspec/specs/workspace-sidebar/spec.md index 4fb713d..a903306 100644 --- a/openspec/specs/workspace-sidebar/spec.md +++ b/openspec/specs/workspace-sidebar/spec.md @@ -84,3 +84,41 @@ #### Scenario: 拖动性能流畅 - **WHEN** 用户拖动调整宽度 - **THEN** 使用 requestAnimationFrame 优化,拖动流畅跟手,无卡顿 + +### Requirement: 标题栏显示操作按钮 +工作空间侧边栏标题栏应显示操作按钮。 + +#### Scenario: 标题栏布局 +- **WHEN** 工作空间侧边栏打开 +- **THEN** 标题栏显示"工作空间"标题,右侧依次显示刷新按钮、新建按钮、关闭按钮 + +#### Scenario: 刷新按钮位置 +- **WHEN** 标题栏显示 +- **THEN** 刷新按钮(↻)显示在标题右侧,使用 FiRefresh 图标 + +#### Scenario: 新建按钮位置 +- **WHEN** 标题栏显示 +- **THEN** 新建按钮(+)显示在刷新按钮右侧,使用 FiPlus 图标 + +#### Scenario: 新建按钮下拉菜单 +- **WHEN** 用户点击新建按钮 +- **THEN** 显示下拉菜单,包含"新建文件"、"新建文件夹"、"上传文件"选项 + +#### Scenario: 关闭按钮位置 +- **WHEN** 标题栏显示 +- **THEN** 关闭按钮(×)显示在最右侧 + +### Requirement: 标题栏按钮样式一致 +标题栏按钮应使用统一的样式。 + +#### Scenario: 按钮尺寸一致 +- **WHEN** 标题栏按钮显示 +- **THEN** 刷新按钮、新建按钮、关闭按钮的尺寸一致(28px × 28px) + +#### Scenario: 按钮交互效果 +- **WHEN** 用户 hover 标题栏按钮 +- **THEN** 按钮背景变为 var(--color-bg-1),图标颜色变为 var(--color-text-1) + +#### Scenario: 按钮间距 +- **WHEN** 标题栏按钮显示 +- **THEN** 按钮之间有 4px 间距 diff --git a/src/components/workspace/CreateModal.jsx b/src/components/workspace/CreateModal.jsx new file mode 100644 index 0000000..61e5937 --- /dev/null +++ b/src/components/workspace/CreateModal.jsx @@ -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 ( + +
+ + 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 + /> +
+
+ ); +} + +export default CreateModal; diff --git a/src/components/workspace/FileContextMenu.jsx b/src/components/workspace/FileContextMenu.jsx index f67814b..ad680c4 100644 --- a/src/components/workspace/FileContextMenu.jsx +++ b/src/components/workspace/FileContextMenu.jsx @@ -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, }} > -
handleAction('download')} - > - - - - 下载 -
+ {/* 文件夹特有操作 */} + {isFolder && ( + <> +
handleAction('new-file')} + > + + + + 新建文件 +
+
handleAction('new-folder')} + > + + + + 新建文件夹 +
+
handleAction('upload')} + > + + + + 上传文件 +
+
+ + )} + + {/* 基础操作 */} + {!isFolder && ( +
handleAction('download')} + > + + + + 下载 +
+ )}
handleAction('rename')} @@ -46,6 +100,30 @@ function FileContextMenu({ file, position, onDownload, onRename, onDelete, onClo 重命名
+
handleAction('move')} + > + + + + 移动到 +
+ + {/* 详细信息 */} +
+
handleAction('detail')} + > + + + + 查看详情 +
+ + {/* 危险操作 */} +
handleAction('delete')} diff --git a/src/components/workspace/FileDetailModal.jsx b/src/components/workspace/FileDetailModal.jsx new file mode 100644 index 0000000..efa88fd --- /dev/null +++ b/src/components/workspace/FileDetailModal.jsx @@ -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 ( +
+
e.stopPropagation()}> +
+
文件详情
+
+ +
+
+
+
+
+
+ 名称 +
+
+ {file.name} +
+
+
+
+ 类型 +
+
+ {isFolder ? '文件夹' : getTypeDesc(file.fileType)} +
+
+ {!isFolder && ( +
+
+ 大小 +
+
+ {file.size || '-'} +
+
+ )} + {file.dimensions && ( +
+
+ 尺寸 +
+
+ {file.dimensions} +
+
+ )} + {file.duration && ( +
+
+ 时长 +
+
+ {file.duration} +
+
+ )} +
+
+ 修改时间 +
+
+ {file.modifiedAt || '-'} +
+
+
+
+ 路径 +
+
+ /workspace/{file.name} +
+
+
+
+
+ +
+
+
+ ); +} + +export default FileDetailModal; diff --git a/src/components/workspace/FileTree.jsx b/src/components/workspace/FileTree.jsx index 0ed42a0..9c2d3d4 100644 --- a/src/components/workspace/FileTree.jsx +++ b/src/components/workspace/FileTree.jsx @@ -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 ( +
+
+
+ +
+
工作空间为空
+
+
+ ); + } + return (
{files.map(item => ( diff --git a/src/components/workspace/FileTreeItem.jsx b/src/components/workspace/FileTreeItem.jsx index 793a6a0..528ae22 100644 --- a/src/components/workspace/FileTreeItem.jsx +++ b/src/components/workspace/FileTreeItem.jsx @@ -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 }) {
{/* 子项(文件夹展开时显示) */} - {isFolder && isExpanded && item.children && ( + {isFolder && isExpanded && item.children && item.children.length > 0 && (
{item.children.map(child => ( { + if (newName.trim()) { + onConfirm && onConfirm(newName.trim()); + } + }; + + return ( + +
+ + 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 + /> +
+
+ ); +} + +export default RenameModal; diff --git a/src/components/workspace/UploadButton.jsx b/src/components/workspace/UploadButton.jsx new file mode 100644 index 0000000..4064b43 --- /dev/null +++ b/src/components/workspace/UploadButton.jsx @@ -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 ( + <> + +
+ 上传文件 +
+ + ); +} + +export default UploadButton; diff --git a/src/components/workspace/WorkspaceSidebar.jsx b/src/components/workspace/WorkspaceSidebar.jsx index 0be8c5e..361b797 100644 --- a/src/components/workspace/WorkspaceSidebar.jsx +++ b/src/components/workspace/WorkspaceSidebar.jsx @@ -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 ( <>
工作空间
+
+
+ +
+
+
+ +
+ {showDropdown && ( +
+
+ + + + 新建文件 +
+
+ + + + 新建文件夹 +
+
+ + + + 上传文件 +
+
+ )} +
+
@@ -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}」吗? )} + + {/* 重命名弹框 */} + {renameFile && ( + setRenameFile(null)} + /> + )} + + {/* 新建弹框 */} + {createType && ( + setCreateType(null)} + /> + )} + + {/* 文件详情弹框 */} + {detailFile && ( + setDetailFile(null)} + /> + )} + + {/* 移动文件弹框 */} + {moveFile && ( +
+
e.stopPropagation()} style={{ width: '400px' }}> +
+
移动到
+
+ +
+
+
+ +
+
+ + +
+
+
+ )} + + {/* Toast 提示 */} + setToast(null)} + /> ); } +/** + * 移动文件弹框中的文件夹树组件 + */ +function MoveFolderTree({ folders, selected, onSelect, depth = 0 }) { + return ( +
+ {folders.map(folder => ( +
+
{ 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)} + > + + {folder.name} +
+ {folder.children.length > 0 && ( + + )} +
+ ))} +
+ ); +} + /** * 工作空间展开按钮组件 */ diff --git a/src/data/workspace.js b/src/data/workspace.js index 41cb9d5..8a37333 100644 --- a/src/data/workspace.js +++ b/src/data/workspace.js @@ -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 ( + + ); +} + +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: [] } ]; diff --git a/src/styles/components/modal/_index.scss b/src/styles/components/modal/_index.scss index 07ace80..6729f04 100644 --- a/src/styles/components/modal/_index.scss +++ b/src/styles/components/modal/_index.scss @@ -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); diff --git a/src/styles/components/workspace/_index.scss b/src/styles/components/workspace/_index.scss index 00121c2..21bed8c 100644 --- a/src/styles/components/workspace/_index.scss +++ b/src/styles/components/workspace/_index.scss @@ -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); + } }