diff --git a/README.md b/README.md index 4ae47df..7190463 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ src/ ├── components/ # 组件库 │ ├── common/ # 通用组件 (Modal, Toast, EmptyState等) │ ├── layout/ # 布局组件 (AppHeader, AppLayout, UserDropdown, ConsoleLayout, AdminLayout, DeveloperLayout等) +│ ├── workspace/ # 工作空间组件 (WorkspaceSidebar, FileTree, FileTreeItem, FileContextMenu, FilePreviewModal) │ ├── Layout.jsx # 主布局组件(sidebar + content) │ └── ListSelector.jsx # 列表选择器 │ @@ -105,7 +106,8 @@ src/ ├── data/ # 模拟数据 │ ├── adminData.js # 管理台数据(含平台级模型配置) │ ├── projectModelConfigs.js # 项目级模型配置数据 -│ └── userModelConfigs.js # 个人模型配置数据 +│ ├── userModelConfigs.js # 个人模型配置数据 +│ └── workspace.js # 工作空间文件数据 │ ├── pages/ # 页面组件 │ ├── console/ # 工作台子页面 @@ -278,6 +280,11 @@ export default Example; | StatusBadge | `components/common/StatusBadge.jsx` | 状态标签 | | SidebarNavItem | `components/layout/SidebarNavItem.jsx` | 侧边栏导航项 | | SidebarNavGroup | `components/layout/SidebarNavGroup.jsx` | 可展开侧边栏导航组 | +| WorkspaceSidebar | `components/workspace/WorkspaceSidebar.jsx` | 工作空间侧边栏 | +| FileTree | `components/workspace/FileTree.jsx` | 文件树组件 | +| FileTreeItem | `components/workspace/FileTreeItem.jsx` | 文件树项组件 | +| FileContextMenu | `components/workspace/FileContextMenu.jsx` | 文件右键菜单 | +| FilePreviewModal | `components/workspace/FilePreviewModal.jsx` | 文件预览弹窗 | --- @@ -414,6 +421,12 @@ api.admin.modelConfigs.create(data); // 日志筛选 api.logs.filter({ user, type, status }); + +// 工作空间 +api.workspace.getFiles(); // 获取工作空间文件树 +api.workspace.getFileById(id); // 根据 ID 获取文件 +api.workspace.getFileIcon(fileType); // 获取文件图标 +api.workspace.getFileType(filename); // 根据文件名获取文件类型 ``` --- diff --git a/openspec/specs/file-preview/spec.md b/openspec/specs/file-preview/spec.md new file mode 100644 index 0000000..1d3bf75 --- /dev/null +++ b/openspec/specs/file-preview/spec.md @@ -0,0 +1,116 @@ +## ADDED Requirements + +### Requirement: 文件预览弹窗显示文件信息 +文件预览弹窗应显示文件的基本信息。 + +#### Scenario: 显示文件基本信息 +- **WHEN** 文件预览弹窗打开 +- **THEN** 弹窗显示文件名、文件类型、文件大小、修改时间 + +#### Scenario: 弹窗标题显示文件名 +- **WHEN** 文件预览弹窗打开 +- **THEN** 弹窗 header 显示文件名和关闭按钮(×) + +### Requirement: 文本文件预览 +文本文件预览应显示文件内容。 + +#### Scenario: 文本文件显示内容 +- **WHEN** 用户预览文本文件(.txt、.md、.json、.js、.css 等) +- **THEN** 预览区域显示文件内容(使用模拟数据) + +#### Scenario: 代码文件语法高亮 +- **WHEN** 用户预览代码文件(.js、.jsx、.py 等) +- **THEN** 预览区域显示代码内容,可考虑简单的语法高亮(非必须) + +### Requirement: Office 文件预览 +Office 文件预览应显示文件信息和提示。 + +#### Scenario: Word 文件预览 +- **WHEN** 用户预览 Word 文档(.doc、.docx) +- **THEN** 预览区域显示文件类型图标和提示"点击下载查看完整内容" + +#### Scenario: Excel 文件预览 +- **WHEN** 用户预览 Excel 文档(.xls、.xlsx) +- **THEN** 预览区域显示文件类型图标和提示"点击下载查看完整内容" + +#### Scenario: PowerPoint 文件预览 +- **WHEN** 用户预览 PowerPoint 文档(.ppt、.pptx) +- **THEN** 预览区域显示文件类型图标和提示"点击下载查看完整内容" + +### Requirement: 图片文件预览 +图片文件预览应显示图片预览区域。 + +#### Scenario: 图片文件预览区域 +- **WHEN** 用户预览图片文件(.jpg、.png、.gif 等) +- **THEN** 预览区域显示图片预览区域(占位符或实际图片) + +#### Scenario: 图片文件显示尺寸信息 +- **WHEN** 用户预览图片文件 +- **THEN** 文件信息显示图片尺寸(如 "1920 × 1080") + +### Requirement: 视频文件预览 +视频文件预览应显示文件信息和提示。 + +#### Scenario: 视频文件预览 +- **WHEN** 用户预览视频文件(.mp4、.avi、.mov 等) +- **THEN** 预览区域显示视频图标和提示"点击下载播放视频" + +#### Scenario: 视频文件显示时长 +- **WHEN** 用户预览视频文件 +- **THEN** 文件信息显示视频时长(如 "02:35") + +### Requirement: 音频文件预览 +音频文件预览应显示文件信息和提示。 + +#### Scenario: 音频文件预览 +- **WHEN** 用户预览音频文件(.mp3、.wav、.ogg 等) +- **THEN** 预览区域显示音频图标和提示"点击下载播放音频" + +#### Scenario: 音频文件显示时长 +- **WHEN** 用户预览音频文件 +- **THEN** 文件信息显示音频时长(如 "03:45") + +### Requirement: 文件预览弹窗操作 +文件预览弹窗应提供下载和关闭操作。 + +#### Scenario: 点击下载按钮 +- **WHEN** 用户点击预览弹窗的"下载"按钮 +- **THEN** 显示 Toast 提示"文件下载中..." + +#### Scenario: 点击关闭按钮 +- **WHEN** 用户点击预览弹窗的"关闭"按钮 +- **THEN** 预览弹窗关闭 + +### Requirement: 文件右键菜单 +文件项应支持右键菜单操作。 + +#### Scenario: 点击操作按钮显示菜单 +- **WHEN** 用户点击文件项的操作按钮(⋮) +- **THEN** 显示右键菜单,包含"下载"、"重命名"、"删除"选项 + +#### Scenario: 点击下载菜单项 +- **WHEN** 用户点击右键菜单的"下载"项 +- **THEN** 显示 Toast 提示"文件下载中...",菜单关闭 + +#### Scenario: 点击重命名菜单项 +- **WHEN** 用户点击右键菜单的"重命名"项 +- **THEN** 显示 Toast 提示"文件已重命名",菜单关闭 + +#### Scenario: 点击删除菜单项 +- **WHEN** 用户点击右键菜单的"删除"项 +- **THEN** 显示删除确认弹窗 + +### Requirement: 删除确认弹窗 +删除操作应显示二次确认弹窗。 + +#### Scenario: 删除确认弹窗显示 +- **WHEN** 用户点击右键菜单的"删除"项 +- **THEN** 显示确认弹窗,提示"确定要删除文件 [文件名] 吗?" + +#### Scenario: 确认删除 +- **WHEN** 用户点击确认弹窗的"确认"按钮 +- **THEN** 显示 Toast 提示"文件已删除",弹窗关闭 + +#### Scenario: 取消删除 +- **WHEN** 用户点击确认弹窗的"取消"按钮 +- **THEN** 弹窗关闭,不执行删除操作 diff --git a/openspec/specs/file-tree/spec.md b/openspec/specs/file-tree/spec.md new file mode 100644 index 0000000..4482536 --- /dev/null +++ b/openspec/specs/file-tree/spec.md @@ -0,0 +1,123 @@ +## ADDED Requirements + +### Requirement: 文件树显示文件和文件夹 +文件树应展示工作空间中的文件和文件夹结构。 + +#### Scenario: 文件树显示文件项 +- **WHEN** 工作空间包含文件 +- **THEN** 文件树显示文件项,包含文件图标、文件名、文件大小、修改时间 + +#### Scenario: 文件树显示文件夹项 +- **WHEN** 工作空间包含文件夹 +- **THEN** 文件树显示文件夹项,包含文件夹图标、文件夹名称、展开/折叠箭头 + +### Requirement: 文件夹默认折叠 +文件夹应默认处于折叠状态。 + +#### Scenario: 文件夹初始状态为折叠 +- **WHEN** 文件树加载完成 +- **THEN** 所有文件夹处于折叠状态,不显示子文件 + +#### Scenario: 打开侧边栏文件夹重置为折叠 +- **WHEN** 用户关闭侧边栏后再次打开 +- **THEN** 所有文件夹恢复为折叠状态 + +### Requirement: 文件夹可展开 +系统应允许用户点击文件夹展开查看子文件。 + +#### Scenario: 点击文件夹展开 +- **WHEN** 用户点击折叠状态的文件夹 +- **THEN** 文件夹展开,显示所有子文件和子文件夹 + +#### Scenario: 展开文件夹箭头旋转 +- **WHEN** 文件夹从折叠变为展开 +- **THEN** 箭头图标从向右(▶)旋转为向下(▼) + +### Requirement: 文件夹可折叠 +系统应允许用户点击已展开的文件夹将其折叠。 + +#### Scenario: 点击已展开文件夹折叠 +- **WHEN** 用户点击已展开的文件夹 +- **THEN** 文件夹折叠,隐藏所有子文件和子文件夹 + +#### Scenario: 折叠文件夹箭头旋转 +- **WHEN** 文件夹从展开变为折叠 +- **THEN** 箭头图标从向下(▼)旋转为向右(▶) + +### Requirement: 文件树显示文件大小 +文件项应显示文件大小。 + +#### Scenario: 文件大小显示格式 +- **WHEN** 文件树显示文件项 +- **THEN** 文件大小以 KB 或 MB 为单位显示(如 "156 KB"、"2.5 MB") + +#### Scenario: 文件夹不显示大小 +- **WHEN** 文件树显示文件夹项 +- **THEN** 文件夹不显示大小信息 + +### Requirement: 文件树显示修改时间 +文件项应显示修改时间。 + +#### Scenario: 修改时间显示格式 +- **WHEN** 文件树显示文件项 +- **THEN** 修改时间以 "MM-DD" 格式显示(如 "04-09") + +#### Scenario: 文件夹不显示修改时间 +- **WHEN** 文件树显示文件夹项 +- **THEN** 文件夹不显示修改时间 + +### Requirement: 文件树支持交互 +文件项和文件夹项应支持点击预览和右键菜单操作。 + +#### Scenario: 点击文件打开预览 +- **WHEN** 用户点击文件项 +- **THEN** 打开文件预览弹窗,显示文件详情 + +#### Scenario: 操作按钮始终显示 +- **WHEN** 文件树渲染文件项或文件夹项 +- **THEN** 操作按钮(⋯)始终显示在右侧,不依赖鼠标悬停 + +#### Scenario: 文件夹也有操作按钮 +- **WHEN** 文件树显示文件夹项 +- **THEN** 文件夹项右侧也显示操作按钮(⋯) + +### Requirement: 文件树使用图标库图标 +文件树应使用 react-icons 图标库显示文件类型图标。 + +#### Scenario: 文件夹显示文件夹图标 +- **WHEN** 文件树显示文件夹项 +- **THEN** 使用 FiFolder 图标 + +#### Scenario: 代码文件显示代码图标 +- **WHEN** 文件树显示代码文件(.js、.py 等) +- **THEN** 使用 FiCode 图标 + +#### Scenario: Office 文件显示对应图标 +- **WHEN** 文件树显示 Office 文件 +- **THEN** Word 使用 FaFileWord、Excel 使用 FaFileExcel、PowerPoint 使用 FaFilePowerpoint + +#### Scenario: 媒体文件显示对应图标 +- **WHEN** 文件树显示媒体文件 +- **THEN** 图片使用 FiImage、视频使用 FiVideo、音频使用 FiMusic + +### Requirement: 文件树样式遵循 BEM 规范 +文件树样式应使用 BEM 命名规范。 + +#### Scenario: 文件树类名使用 BEM +- **WHEN** 文件树渲染 +- **THEN** 使用 .file-tree 作为 block,.file-tree__item、.file-tree__icon 等作为 element + +#### Scenario: 文件树使用设计令牌 +- **WHEN** 文件树样式定义 +- **THEN** 使用现有设计令牌(颜色、间距、字号等),不硬编码样式值 + +### Requirement: 文件树布局紧凑统一 +文件树应采用紧凑统一的布局。 + +#### Scenario: 文件和文件夹高度一致 +- **WHEN** 文件树显示文件项和文件夹项 +- **THEN** 文件项和文件夹项的高度相同(padding: 4px 8px) + +#### Scenario: 文件信息紧凑显示 +- **WHEN** 文件树显示文件项 +- **THEN** 文件大小宽度为 50px,修改时间宽度为 40px,字号为 11px diff --git a/openspec/specs/workspace-sidebar/spec.md b/openspec/specs/workspace-sidebar/spec.md new file mode 100644 index 0000000..4fb713d --- /dev/null +++ b/openspec/specs/workspace-sidebar/spec.md @@ -0,0 +1,86 @@ +## ADDED Requirements + +### Requirement: 工作空间侧边栏默认关闭 +系统应在对话界面加载时将工作空间侧边栏默认设置为关闭状态。 + +#### Scenario: 进入对话页面侧边栏关闭 +- **WHEN** 用户进入对话页面 +- **THEN** 工作空间侧边栏处于关闭状态,不遮挡对话内容 + +#### Scenario: 切换对话侧边栏保持关闭 +- **WHEN** 用户从一个对话切换到另一个对话 +- **THEN** 工作空间侧边栏保持关闭状态 + +### Requirement: 工作空间侧边栏可展开 +系统应允许用户通过点击展开按钮打开工作空间侧边栏。 + +#### Scenario: 点击展开按钮打开侧边栏 +- **WHEN** 用户点击对话界面右上角的展开按钮 +- **THEN** 工作空间侧边栏从右侧展开,宽度为 240px + +#### Scenario: 侧边栏展开显示文件树 +- **WHEN** 工作空间侧边栏展开 +- **THEN** 侧边栏内显示文件树组件,展示工作空间的文件和文件夹结构 + +### Requirement: 工作空间侧边栏可关闭 +系统应允许用户通过点击关闭按钮关闭工作空间侧边栏。 + +#### Scenario: 点击关闭按钮关闭侧边栏 +- **WHEN** 用户点击侧边栏 header 内的关闭按钮 +- **THEN** 工作空间侧边栏关闭,恢复为隐藏状态 + +### Requirement: 工作空间侧边栏布局 +工作空间侧边栏应包含 header 区域和 content 区域。 + +#### Scenario: 侧边栏 header 显示标题和关闭按钮 +- **WHEN** 工作空间侧边栏展开 +- **THEN** header 区域显示"工作空间"标题和关闭按钮(×) + +#### Scenario: 侧边栏 content 显示文件树 +- **WHEN** 工作空间侧边栏展开 +- **THEN** content 区域显示文件树组件,可滚动查看文件列表 + +### Requirement: 侧边栏展开按钮位置 +展开按钮应固定在对话界面的右上角,位于 header 下方。 + +#### Scenario: 展开按钮在右上角 +- **WHEN** 工作空间侧边栏关闭 +- **THEN** 对话界面右上角显示展开按钮(工作空间图标),位于 header 下方 + +#### Scenario: 展开按钮始终可见 +- **WHEN** 用户滚动对话内容 +- **THEN** 展开按钮保持在右上角固定位置,不随内容滚动 + +#### Scenario: 展开按钮不被遮挡 +- **WHEN** 展开按钮显示 +- **THEN** 按钮的 z-index 为 150,高于 header 的 100,不被遮挡 + +### Requirement: 侧边栏打开时隐藏展开按钮 +展开按钮应在侧边栏打开时隐藏。 + +#### Scenario: 侧边栏打开按钮隐藏 +- **WHEN** 用户点击展开按钮打开侧边栏 +- **THEN** 展开按钮自动隐藏 + +#### Scenario: 侧边栏关闭按钮显示 +- **WHEN** 用户关闭侧边栏 +- **THEN** 展开按钮重新显示 + +### Requirement: 侧边栏支持拖动调整宽度 +用户应能够通过拖动调整侧边栏宽度。 + +#### Scenario: 默认宽度 +- **WHEN** 侧边栏打开 +- **THEN** 侧边栏默认宽度为 280px + +#### Scenario: 拖动调整宽度 +- **WHEN** 用户拖动侧边栏左边缘的手柄 +- **THEN** 侧边栏宽度随鼠标移动而改变,范围限制在 200px 到 500px + +#### Scenario: 拖动手柄始终显示 +- **WHEN** 侧边栏打开 +- **THEN** 左边缘的拖动手柄始终显示(opacity: 0.5),hover 时 opacity 为 1 + +#### Scenario: 拖动性能流畅 +- **WHEN** 用户拖动调整宽度 +- **THEN** 使用 requestAnimationFrame 优化,拖动流畅跟手,无卡顿 diff --git a/src/components/workspace/FileContextMenu.jsx b/src/components/workspace/FileContextMenu.jsx new file mode 100644 index 0000000..f67814b --- /dev/null +++ b/src/components/workspace/FileContextMenu.jsx @@ -0,0 +1,62 @@ +import { FiDownload, FiEdit2, FiTrash2 } from 'react-icons/fi'; + +/** + * 文件右键菜单组件 + */ +function FileContextMenu({ file, position, onDownload, onRename, onDelete, onClose }) { + const handleAction = (action) => { + switch (action) { + case 'download': + onDownload && onDownload(file); + break; + case 'rename': + onRename && onRename(file); + break; + case 'delete': + onDelete && onDelete(file); + break; + } + onClose && onClose(); + }; + + return ( +
+
handleAction('download')} + > + + + + 下载 +
+
handleAction('rename')} + > + + + + 重命名 +
+
handleAction('delete')} + > + + + + 删除 +
+
+ ); +} + +export default FileContextMenu; diff --git a/src/components/workspace/FilePreviewModal.jsx b/src/components/workspace/FilePreviewModal.jsx new file mode 100644 index 0000000..b17bf98 --- /dev/null +++ b/src/components/workspace/FilePreviewModal.jsx @@ -0,0 +1,154 @@ +import { FiX, FiDownload } from 'react-icons/fi'; +import api from '../../services/api.js'; + +/** + * 文件预览弹窗组件 + * 根据文件类型显示不同的预览内容 + */ +function FilePreviewModal({ file, onClose }) { + if (!file) return null; + + const icon = api.workspace.getFileIcon(file.fileType); + + // 获取文件类型描述 + 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 文件', + }; + return typeMap[fileType] || '文件'; + }; + + // 渲染文件信息 + const renderFileInfo = () => ( +
+
+ 类型: + {getTypeDesc(file.fileType)} +
+
+ 大小: + {file.size} +
+ {file.dimensions && ( +
+ 尺寸: + {file.dimensions} +
+ )} + {file.duration && ( +
+ 时长: + {file.duration} +
+ )} +
+ 修改: + {file.modifiedAt} +
+
+ ); + + // 渲染预览内容 + const renderPreview = () => { + // 文本类型:显示内容 + if (['text', 'markdown', 'json', 'javascript', 'python', 'css', 'html'].includes(file.fileType)) { + return ( +
+
{file.content || '(无内容)'}
+
+ ); + } + + // Office 类型:显示占位符 + if (['word', 'excel', 'powerpoint'].includes(file.fileType)) { + return ( +
+
{icon}
+
+ 点击下载查看完整内容 +
+
+ ); + } + + // 图片类型:显示占位符 + if (file.fileType === 'image') { + return ( +
+
{icon}
+
+ 图片预览区域 +
+
+ ); + } + + // 视频/音频类型:显示占位符 + if (['video', 'audio'].includes(file.fileType)) { + const text = file.fileType === 'video' ? '点击下载播放视频' : '点击下载播放音频'; + return ( +
+
{icon}
+
{text}
+
+ ); + } + + // 其他类型 + return ( +
+
{icon}
+
+ 预览不可用 +
+
+ ); + }; + + return ( +
+
e.stopPropagation()}> +
+
{file.name}
+
+ +
+
+
+ {renderFileInfo()} + {renderPreview()} +
+
+ + +
+
+
+ ); +} + +export default FilePreviewModal; diff --git a/src/components/workspace/FileTree.jsx b/src/components/workspace/FileTree.jsx new file mode 100644 index 0000000..0ed42a0 --- /dev/null +++ b/src/components/workspace/FileTree.jsx @@ -0,0 +1,25 @@ +import FileTreeItem from './FileTreeItem.jsx'; +import api from '../../services/api.js'; + +/** + * 文件树组件 + * 渲染工作空间的文件和文件夹结构 + */ +function FileTree({ onFileClick, onMenuClick }) { + const files = api.workspace.getFiles(); + + return ( +
+ {files.map(item => ( + + ))} +
+ ); +} + +export default FileTree; diff --git a/src/components/workspace/FileTreeItem.jsx b/src/components/workspace/FileTreeItem.jsx new file mode 100644 index 0000000..793a6a0 --- /dev/null +++ b/src/components/workspace/FileTreeItem.jsx @@ -0,0 +1,137 @@ +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'; + +/** + * 文件树项组件 + * 渲染单个文件或文件夹项 + */ +function FileTreeItem({ item, onFileClick, onMenuClick }) { + const [isExpanded, setIsExpanded] = useState(false); + const [showMenu, setShowMenu] = useState(false); + + const isFolder = item.type === 'folder'; + + // 根据文件类型获取图标组件 + const getIconComponent = () => { + if (isFolder) return ; + + const fileType = item.fileType; + switch (fileType) { + case 'text': + case 'markdown': + return ; + case 'json': + case 'javascript': + case 'python': + case 'css': + case 'html': + return ; + case 'word': + return ; + case 'excel': + return ; + case 'powerpoint': + return ; + case 'image': + return ; + case 'video': + return ; + case 'audio': + return ; + case 'pdf': + return ; + case 'archive': + return ; + default: + return ; + } + }; + + // 格式化修改时间 (2026-04-09 -> 04-09) + const formatTime = (time) => { + if (!time) return ''; + const parts = time.split('-'); + return `${parts[1]}-${parts[2]}`; + }; + + // 点击文件夹:展开/折叠 + const handleFolderClick = () => { + if (isFolder) { + setIsExpanded(!isExpanded); + } + }; + + // 点击文件:打开预览 + const handleFileClick = () => { + if (!isFolder && onFileClick) { + onFileClick(item); + } + }; + + // 点击操作按钮 + const handleMenuClick = (e) => { + e.stopPropagation(); + setShowMenu(!showMenu); + if (onMenuClick && !showMenu) { + onMenuClick(item, e); + } + }; + + return ( +
+
+ {/* 展开/折叠箭头 */} + {isFolder && ( + + + + )} + + {/* 图标 */} + {getIconComponent()} + + {/* 名称 */} + {item.name} + + {/* 文件大小 */} + {!isFolder && item.size && ( + {item.size} + )} + + {/* 修改时间 */} + {!isFolder && item.modifiedAt && ( + {formatTime(item.modifiedAt)} + )} + + {/* 操作按钮(文件夹和文件都显示) */} + + ⋯ + +
+ + {/* 子项(文件夹展开时显示) */} + {isFolder && isExpanded && item.children && ( +
+ {item.children.map(child => ( + + ))} +
+ )} +
+ ); +} + +export default FileTreeItem; diff --git a/src/components/workspace/WorkspaceSidebar.jsx b/src/components/workspace/WorkspaceSidebar.jsx new file mode 100644 index 0000000..0be8c5e --- /dev/null +++ b/src/components/workspace/WorkspaceSidebar.jsx @@ -0,0 +1,227 @@ +import { useState, useEffect, useRef } from 'react'; +import { FiX, FiFolder } from 'react-icons/fi'; +import FileTree from './FileTree.jsx'; +import FileContextMenu from './FileContextMenu.jsx'; +import FilePreviewModal from './FilePreviewModal.jsx'; +import Modal from '../common/Modal.jsx'; + +/** + * 工作空间侧边栏组件 + * 管理展开/关闭状态,集成文件树、右键菜单和预览弹窗 + */ +function WorkspaceSidebar({ isOpen, onClose }) { + const [contextMenu, setContextMenu] = useState(null); // { file, position } + const [previewFile, setPreviewFile] = useState(null); + const [deleteFile, setDeleteFile] = useState(null); + const [width, setWidth] = useState(280); // 默认宽度 280px + const sidebarRef = useRef(null); + const isResizingRef = useRef(false); + const lastXRef = useRef(0); + + // 拖动调整宽度 - 使用 requestAnimationFrame 优化 + const handleMouseDown = (e) => { + e.preventDefault(); + isResizingRef.current = true; + lastXRef.current = e.clientX; + + // 添加拖动时的样式 + document.body.style.cursor = 'ew-resize'; + document.body.style.userSelect = 'none'; + + if (sidebarRef.current) { + sidebarRef.current.style.transition = 'none'; + } + }; + + useEffect(() => { + let animationFrameId = null; + + const handleMouseMove = (e) => { + if (!isResizingRef.current) return; + + // 取消之前的动画帧 + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + + // 使用 requestAnimationFrame 批量更新 + animationFrameId = requestAnimationFrame(() => { + const deltaX = lastXRef.current - e.clientX; + lastXRef.current = e.clientX; + + setWidth(prevWidth => { + const newWidth = prevWidth + deltaX; + // 限制宽度范围:最小 200px,最大 500px + return Math.max(200, Math.min(500, newWidth)); + }); + }); + }; + + const handleMouseUp = () => { + if (!isResizingRef.current) return; + + isResizingRef.current = false; + + // 恢复样式 + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + if (sidebarRef.current) { + sidebarRef.current.style.transition = ''; + } + + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + }; + }, []); + + // 点击外部关闭右键菜单 + useEffect(() => { + const handleClickOutside = () => { + setContextMenu(null); + }; + if (contextMenu) { + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + } + }, [contextMenu]); + + // 文件点击:打开预览 + const handleFileClick = (file) => { + setPreviewFile(file); + }; + + // 右键菜单点击 + const handleMenuClick = (file, e) => { + const rect = e.currentTarget.getBoundingClientRect(); + // 菜单显示在按钮左侧,避免超出页面 + const menuWidth = 140; // 菜单宽度 + const x = rect.right - menuWidth; + const y = rect.bottom + 4; + + setContextMenu({ + file, + position: { + x: Math.max(8, x), // 确保不超出左边界 + y, + }, + }); + }; + + // 下载操作 + const handleDownload = (file) => { + alert('文件下载中...'); + setContextMenu(null); + }; + + // 重命名操作 + const handleRename = (file) => { + alert('文件已重命名'); + setContextMenu(null); + }; + + // 删除操作 + const handleDelete = (file) => { + setDeleteFile(file); + setContextMenu(null); + }; + + // 确认删除 + const handleDeleteConfirm = () => { + alert('文件已删除'); + setDeleteFile(null); + }; + + if (!isOpen) return null; + + return ( + <> +
+
+
工作空间
+
+ +
+
+
+ +
+ {/* 拖动调整宽度的手柄 */} +
+
+
+
+
+
+
+
+ + {/* 右键菜单 */} + {contextMenu && ( + setContextMenu(null)} + /> + )} + + {/* 文件预览弹窗 */} + {previewFile && ( + setPreviewFile(null)} + /> + )} + + {/* 删除确认弹窗 */} + {deleteFile && ( + setDeleteFile(null)} + > + 确定要删除文件「{deleteFile.name}」吗? + + )} + + ); +} + +/** + * 工作空间展开按钮组件 + */ +export function WorkspaceToggleBtn({ onClick }) { + return ( +
+ +
+ ); +} + +export default WorkspaceSidebar; diff --git a/src/data/workspace.js b/src/data/workspace.js new file mode 100644 index 0000000..41cb9d5 --- /dev/null +++ b/src/data/workspace.js @@ -0,0 +1,245 @@ +// 工作空间文件数据 + +// 文件图标映射 +export const fileIcons = { + folder: '📁', + // 文本类型 + text: '📄', + markdown: '📝', + json: '📋', + javascript: '💻', + python: '🐍', + css: '🎨', + html: '🌐', + // Office 类型 + word: '📘', + excel: '📗', + powerpoint: '📙', + // 媒体类型 + image: '🖼️', + video: '🎬', + audio: '🎵', + // 其他 + pdf: '📕', + archive: '📦', + unknown: '📄' +}; + +// 根据文件扩展名获取文件类型 +export function getFileType(filename) { + const ext = filename.split('.').pop().toLowerCase(); + + // 文本类型 + if (['txt'].includes(ext)) return 'text'; + if (['md'].includes(ext)) return 'markdown'; + if (['json'].includes(ext)) return 'json'; + if (['js', 'jsx', 'ts', 'tsx'].includes(ext)) return 'javascript'; + if (['py'].includes(ext)) return 'python'; + if (['css', 'scss', 'sass'].includes(ext)) return 'css'; + if (['html', 'htm'].includes(ext)) return 'html'; + + // Office 类型 + if (['doc', 'docx'].includes(ext)) return 'word'; + if (['xls', 'xlsx'].includes(ext)) return 'excel'; + if (['ppt', 'pptx'].includes(ext)) return 'powerpoint'; + + // 媒体类型 + if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'].includes(ext)) return 'image'; + if (['mp4', 'avi', 'mov', 'mkv', 'webm'].includes(ext)) return 'video'; + if (['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(ext)) return 'audio'; + + // 其他 + if (['pdf'].includes(ext)) return 'pdf'; + if (['zip', 'rar', 'tar', 'gz'].includes(ext)) return 'archive'; + + return 'unknown'; +} + +// 工作空间文件树数据 +export const workspaceFiles = [ + { + id: 'folder_1', + name: 'documents', + type: 'folder', + children: [ + { + id: 'file_1', + name: 'report.docx', + type: 'file', + fileType: 'word', + size: '156 KB', + modifiedAt: '2026-04-09' + }, + { + id: 'file_2', + name: 'notes.txt', + type: 'file', + fileType: 'text', + size: '1.2 KB', + modifiedAt: '2026-04-10', + content: '这是笔记内容...\n\n1. 第一点\n2. 第二点\n3. 第三点\n\n完成!' + }, + { + id: 'file_3', + name: 'presentation.pptx', + type: 'file', + fileType: 'powerpoint', + size: '2.3 MB', + modifiedAt: '2026-04-08' + } + ] + }, + { + id: 'folder_2', + name: 'code', + type: 'folder', + children: [ + { + id: 'file_4', + name: 'app.js', + type: 'file', + fileType: 'javascript', + size: '3.5 KB', + modifiedAt: '2026-04-08', + content: `import React from 'react'; +import { useState } from 'react'; + +function App() { + const [count, setCount] = useState(0); + + return ( +
+

Hello World

+

Count: {count}

+ +
+ ); +} + +export default App;` + }, + { + id: 'file_5', + name: 'style.css', + type: 'file', + fileType: 'css', + size: '1.8 KB', + modifiedAt: '2026-04-07', + content: `.app { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +h1 { + font-size: 24px; + color: #333; +} + +button { + padding: 10px 20px; + background: #3B82F6; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +button:hover { + background: #2563EB; +}` + }, + { + id: 'file_6', + name: 'config.json', + type: 'file', + fileType: 'json', + size: '856 B', + modifiedAt: '2026-04-06', + content: `{ + "name": "my-app", + "version": "1.0.0", + "description": "A sample application", + "main": "app.js", + "scripts": { + "start": "node app.js", + "build": "webpack --mode production" + }, + "dependencies": { + "react": "^19.2.4" + } +}` + } + ] + }, + { + id: 'folder_3', + name: 'media', + type: 'folder', + children: [ + { + id: 'file_7', + name: 'photo.jpg', + type: 'file', + fileType: 'image', + size: '245 KB', + dimensions: '1920 × 1080', + modifiedAt: '2026-04-07' + }, + { + id: 'file_8', + name: 'demo.mp4', + type: 'file', + fileType: 'video', + size: '12.5 MB', + duration: '02:35', + modifiedAt: '2026-04-06' + }, + { + id: 'file_9', + name: 'music.mp3', + type: 'file', + fileType: 'audio', + size: '4.8 MB', + duration: '03:45', + modifiedAt: '2026-04-05' + } + ] + }, + { + id: 'file_10', + name: 'README.md', + type: 'file', + fileType: 'markdown', + size: '2.1 KB', + modifiedAt: '2026-04-08', + content: `# 项目说明 + +这是一个示例项目,用于展示工作空间文件管理功能。 + +## 功能特点 + +- 文件树展示 +- 文件预览 +- 文件操作 + +## 使用方法 + +1. 点击文件查看预览 +2. 右键菜单进行操作 + +## 注意事项 + +这是一个原型项目,文件操作仅为演示。` + }, + { + id: 'file_11', + name: 'data.xlsx', + type: 'file', + fileType: 'excel', + size: '89 KB', + modifiedAt: '2026-04-04' + } +]; diff --git a/src/pages/console/ChatPage.jsx b/src/pages/console/ChatPage.jsx index 3f7acf3..55fc3da 100644 --- a/src/pages/console/ChatPage.jsx +++ b/src/pages/console/ChatPage.jsx @@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom'; import { getChatScenes } from '../../data/conversations.js'; import { FiPaperclip, FiCode, FiSend, FiChevronDown } from 'react-icons/fi'; import { api } from '../../services/api.js'; +import WorkspaceSidebar, { WorkspaceToggleBtn } from '../../components/workspace/WorkspaceSidebar.jsx'; function ModelSelector({ selectedModel, onSelectModel, variant = 'standard' }) { const [open, setOpen] = useState(false); @@ -138,6 +139,7 @@ function ChatPage() { const html = chatScenes[currentScene] || ''; const chatMessagesRef = useRef(null); const [isCompact, setIsCompact] = useState(false); + const [workspaceOpen, setWorkspaceOpen] = useState(false); // 工作空间侧边栏状态 const defaultPlatformModel = api.admin.modelConfigs.list().find(c => c.isActive); const defaultProjectModel = api.consoleModels.project.list().find(c => c.isActive); @@ -184,41 +186,54 @@ function ChatPage() { return (
-
-
-
-
-
-
-
- -
-