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