feat: 添加工作空间侧边栏和文件树功能

- 新增工作空间侧边栏组件,支持展开/关闭和拖动调整宽度
- 实现文件树组件,支持文件夹展开/折叠,显示文件大小和修改时间
- 添加文件预览弹窗,支持文本、Office、图片、视频、音频等多种文件类型
- 实现文件右键菜单,提供下载、重命名、删除操作入口
- 使用 react-icons 图标库替代 emoji,提升视觉一致性
- 优化拖动性能,使用 requestAnimationFrame 确保流畅跟手
- 新增工作空间相关规范文档(workspace-sidebar、file-tree、file-preview)
This commit is contained in:
2026-04-17 15:15:57 +08:00
parent def2b6bf61
commit e382a60e0a
15 changed files with 1632 additions and 28 deletions

View File

@@ -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); // 根据文件名获取文件类型
```
---

View File

@@ -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** 弹窗关闭,不执行删除操作

View File

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

View File

@@ -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.5hover 时 opacity 为 1
#### Scenario: 拖动性能流畅
- **WHEN** 用户拖动调整宽度
- **THEN** 使用 requestAnimationFrame 优化,拖动流畅跟手,无卡顿

View File

@@ -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 (
<div
className="file-context-menu"
style={{
position: 'fixed',
top: position.y,
left: position.x,
}}
>
<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')}
>
<span className="file-context-menu__icon">
<FiEdit2 size={14} />
</span>
重命名
</div>
<div
className="file-context-menu__item file-context-menu__item--danger"
onClick={() => handleAction('delete')}
>
<span className="file-context-menu__icon">
<FiTrash2 size={14} />
</span>
删除
</div>
</div>
);
}
export default FileContextMenu;

View File

@@ -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 = () => (
<div className="file-preview-modal__info">
<div className="file-preview-modal__info-item">
<span className="file-preview-modal__info-label">类型</span>
<span className="file-preview-modal__info-value">{getTypeDesc(file.fileType)}</span>
</div>
<div className="file-preview-modal__info-item">
<span className="file-preview-modal__info-label">大小</span>
<span className="file-preview-modal__info-value">{file.size}</span>
</div>
{file.dimensions && (
<div className="file-preview-modal__info-item">
<span className="file-preview-modal__info-label">尺寸</span>
<span className="file-preview-modal__info-value">{file.dimensions}</span>
</div>
)}
{file.duration && (
<div className="file-preview-modal__info-item">
<span className="file-preview-modal__info-label">时长</span>
<span className="file-preview-modal__info-value">{file.duration}</span>
</div>
)}
<div className="file-preview-modal__info-item">
<span className="file-preview-modal__info-label">修改</span>
<span className="file-preview-modal__info-value">{file.modifiedAt}</span>
</div>
</div>
);
// 渲染预览内容
const renderPreview = () => {
// 文本类型:显示内容
if (['text', 'markdown', 'json', 'javascript', 'python', 'css', 'html'].includes(file.fileType)) {
return (
<div className="file-preview-modal__content">
<pre className="file-preview-modal__code">{file.content || '(无内容)'}</pre>
</div>
);
}
// Office 类型:显示占位符
if (['word', 'excel', 'powerpoint'].includes(file.fileType)) {
return (
<div className="file-preview-modal__placeholder">
<div className="file-preview-modal__placeholder-icon">{icon}</div>
<div className="file-preview-modal__placeholder-text">
点击下载查看完整内容
</div>
</div>
);
}
// 图片类型:显示占位符
if (file.fileType === 'image') {
return (
<div className="file-preview-modal__placeholder">
<div className="file-preview-modal__placeholder-icon">{icon}</div>
<div className="file-preview-modal__placeholder-text">
图片预览区域
</div>
</div>
);
}
// 视频/音频类型:显示占位符
if (['video', 'audio'].includes(file.fileType)) {
const text = file.fileType === 'video' ? '点击下载播放视频' : '点击下载播放音频';
return (
<div className="file-preview-modal__placeholder">
<div className="file-preview-modal__placeholder-icon">{icon}</div>
<div className="file-preview-modal__placeholder-text">{text}</div>
</div>
);
}
// 其他类型
return (
<div className="file-preview-modal__placeholder">
<div className="file-preview-modal__placeholder-icon">{icon}</div>
<div className="file-preview-modal__placeholder-text">
预览不可用
</div>
</div>
);
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">{file.name}</div>
<div className="modal-close" onClick={onClose}>
<FiX size={18} />
</div>
</div>
<div className="modal-body">
{renderFileInfo()}
{renderPreview()}
</div>
<div className="modal-footer">
<button
className="btn"
onClick={() => {
alert('文件下载中...');
onClose();
}}
>
<FiDownload />
下载
</button>
<button className="btn btn--primary" onClick={onClose}>
关闭
</button>
</div>
</div>
</div>
);
}
export default FilePreviewModal;

View File

@@ -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 (
<div className="file-tree">
{files.map(item => (
<FileTreeItem
key={item.id}
item={item}
onFileClick={onFileClick}
onMenuClick={onMenuClick}
/>
))}
</div>
);
}
export default FileTree;

View File

@@ -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 <FiFolder size={16} />;
const fileType = item.fileType;
switch (fileType) {
case 'text':
case 'markdown':
return <FiFileText size={16} />;
case 'json':
case 'javascript':
case 'python':
case 'css':
case 'html':
return <FiCode size={16} />;
case 'word':
return <FaFileWord size={16} />;
case 'excel':
return <FaFileExcel size={16} />;
case 'powerpoint':
return <FaFilePowerpoint size={16} />;
case 'image':
return <FiImage size={16} />;
case 'video':
return <FiVideo size={16} />;
case 'audio':
return <FiMusic size={16} />;
case 'pdf':
return <FaFilePdf size={16} />;
case 'archive':
return <FaFileArchive size={16} />;
default:
return <FiFile size={16} />;
}
};
// 格式化修改时间 (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 (
<div className="file-tree__node">
<div
className={`file-tree__item file-tree__item--${item.type}`}
onClick={isFolder ? handleFolderClick : handleFileClick}
>
{/* 展开/折叠箭头 */}
{isFolder && (
<span className={`file-tree__toggle ${isExpanded ? 'file-tree__toggle--expanded' : ''}`}>
<FiChevronRight size={14} />
</span>
)}
{/* 图标 */}
<span className="file-tree__icon">{getIconComponent()}</span>
{/* 名称 */}
<span className="file-tree__name">{item.name}</span>
{/* 文件大小 */}
{!isFolder && item.size && (
<span className="file-tree__size">{item.size}</span>
)}
{/* 修改时间 */}
{!isFolder && item.modifiedAt && (
<span className="file-tree__time">{formatTime(item.modifiedAt)}</span>
)}
{/* 操作按钮(文件夹和文件都显示) */}
<span
className="file-tree__actions"
onClick={handleMenuClick}
>
</span>
</div>
{/* 子项(文件夹展开时显示) */}
{isFolder && isExpanded && item.children && (
<div className="file-tree__children">
{item.children.map(child => (
<FileTreeItem
key={child.id}
item={child}
onFileClick={onFileClick}
onMenuClick={onMenuClick}
/>
))}
</div>
)}
</div>
);
}
export default FileTreeItem;

View File

@@ -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 (
<>
<div
className="workspace-sidebar"
ref={sidebarRef}
style={{ width: `${width}px` }}
>
<div className="workspace-sidebar__header">
<div className="workspace-sidebar__title">工作空间</div>
<div className="workspace-sidebar__close" onClick={onClose}>
<FiX size={18} />
</div>
</div>
<div className="workspace-sidebar__content">
<FileTree
onFileClick={handleFileClick}
onMenuClick={handleMenuClick}
/>
</div>
{/* 拖动调整宽度的手柄 */}
<div
className="workspace-sidebar__resize-handle"
onMouseDown={handleMouseDown}
>
<div className="workspace-sidebar__resize-lines">
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
{/* 右键菜单 */}
{contextMenu && (
<FileContextMenu
file={contextMenu.file}
position={contextMenu.position}
onDownload={handleDownload}
onRename={handleRename}
onDelete={handleDelete}
onClose={() => setContextMenu(null)}
/>
)}
{/* 文件预览弹窗 */}
{previewFile && (
<FilePreviewModal
file={previewFile}
onClose={() => setPreviewFile(null)}
/>
)}
{/* 删除确认弹窗 */}
{deleteFile && (
<Modal
visible={true}
title="确认删除"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteFile(null)}
>
确定要删除文件{deleteFile.name}
</Modal>
)}
</>
);
}
/**
* 工作空间展开按钮组件
*/
export function WorkspaceToggleBtn({ onClick }) {
return (
<div className="workspace-toggle-btn" onClick={onClick} title="工作空间">
<FiFolder size={18} />
</div>
);
}
export default WorkspaceSidebar;

245
src/data/workspace.js Normal file
View File

@@ -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 (
<div className="app">
<h1>Hello World</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
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'
}
];

View File

@@ -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 (
<div className="chat-layout">
<div className="chat-content">
<div className="chat-messages">
<div ref={chatMessagesRef} dangerouslySetInnerHTML={{ __html: html }} />
</div>
<div className="chat-input-wrapper">
<div className="chat-input-container">
<div className="chat-input-box chat-input-box--horizontal">
<ModelSelector
selectedModel={selectedModel}
onSelectModel={setSelectedModel}
variant={isCompact ? 'compact' : 'standard'}
/>
<div className="chat-input-main">
<textarea
className="chat-input"
placeholder="输入消息... Enter 发送Shift+Enter 换行"
rows="1"
<div className="chat-main">
{/* 工作空间展开按钮(侧边栏关闭时显示) */}
{!workspaceOpen && (
<WorkspaceToggleBtn onClick={() => setWorkspaceOpen(true)} />
)}
<div className="chat-content">
<div className="chat-messages">
<div ref={chatMessagesRef} dangerouslySetInnerHTML={{ __html: html }} />
</div>
<div className="chat-input-wrapper">
<div className="chat-input-container">
<div className="chat-input-box chat-input-box--horizontal">
<ModelSelector
selectedModel={selectedModel}
onSelectModel={setSelectedModel}
variant={isCompact ? 'compact' : 'standard'}
/>
<div className="chat-input-actions">
<div className="chat-input-tools">
<div className="chat-input-tool" title="上传文件">
<FiPaperclip />
</div>
<div className="chat-input-tool" title="代码块">
<FiCode />
<div className="chat-input-main">
<textarea
className="chat-input"
placeholder="输入消息... Enter 发送Shift+Enter 换行"
rows="1"
/>
<div className="chat-input-actions">
<div className="chat-input-tools">
<div className="chat-input-tool" title="上传文件">
<FiPaperclip />
</div>
<div className="chat-input-tool" title="代码块">
<FiCode />
</div>
</div>
<button className="chat-send-btn">
<FiSend />
</button>
</div>
<button className="chat-send-btn">
<FiSend />
</button>
</div>
</div>
</div>
</div>
</div>
{/* 工作空间侧边栏 */}
<WorkspaceSidebar
isOpen={workspaceOpen}
onClose={() => setWorkspaceOpen(false)}
/>
</div>
</div>
);

View File

@@ -13,6 +13,7 @@ import { scheduledTasks } from '../data/tasks.js';
import { adminDepartments, adminUsers, adminProjects, adminOverview, adminLogs, modelConfigs } from '../data/adminData.js';
import { projectModelConfigs } from '../data/projectModelConfigs.js';
import { userModelConfigs } from '../data/userModelConfigs.js';
import { workspaceFiles, fileIcons, getFileType } from '../data/workspace.js';
/**
* 用户相关 API
@@ -454,6 +455,52 @@ export const consoleModelConfigsApi = {
},
},
};
/**
* 工作空间相关 API
*/
export const workspaceApi = {
/**
* 获取工作空间文件树
* @returns {Array} 文件树数据
*/
getFiles: () => workspaceFiles,
/**
* 根据 ID 获取文件
* @param {string} id - 文件 ID
* @returns {Object|undefined} 文件对象
*/
getFileById: (id) => {
// 递归查找文件
const findFile = (items) => {
for (const item of items) {
if (item.id === id) return item;
if (item.type === 'folder' && item.children) {
const found = findFile(item.children);
if (found) return found;
}
}
return undefined;
};
return findFile(workspaceFiles);
},
/**
* 获取文件图标
* @param {string} fileType - 文件类型
* @returns {string} 图标 emoji
*/
getFileIcon: (fileType) => fileIcons[fileType] || fileIcons.unknown,
/**
* 根据文件名获取文件类型
* @param {string} filename - 文件名
* @returns {string} 文件类型
*/
getFileType: getFileType,
};
export const api = {
user,
skills: skillsApi,
@@ -464,6 +511,7 @@ export const api = {
tasks: tasksApi,
admin: adminApi,
consoleModels: consoleModelConfigsApi,
workspace: workspaceApi,
};
export default api;

View File

@@ -17,6 +17,7 @@
@forward 'search-bar';
@forward 'stat-card';
@forward 'header';
@forward 'workspace';
.page-back-btn {
display: inline-flex;

View File

@@ -0,0 +1,344 @@
// 工作空间组件样式
@use '../../tokens' as *;
// 工作空间侧边栏
.workspace-sidebar {
width: 240px;
background: var(--color-bg-2);
border-left: 1px solid var(--color-border-2);
display: flex;
flex-direction: column;
height: 100%;
flex-shrink: 0;
transition: width 0.3s ease, opacity 0.3s ease;
position: relative;
&--collapsed {
width: 0;
opacity: 0;
overflow: hidden;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--color-border-2);
flex-shrink: 0;
}
&__title {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: var(--color-text-1);
}
&__close {
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);
}
}
&__content {
flex: 1;
overflow-y: auto;
padding: 8px;
min-height: 0;
}
&__resize-handle {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
opacity: 0.5;
transition: opacity 0.2s;
z-index: 10;
&:hover {
opacity: 1;
}
}
&__resize-lines {
display: flex;
flex-direction: column;
gap: 2px;
div {
width: 4px;
height: 2px;
background: var(--color-text-3);
border-radius: 1px;
}
}
&__resize-handle:hover &__resize-lines div {
background: var(--color-text-1);
}
}
// 工作空间展开按钮
.workspace-toggle-btn {
position: fixed;
top: calc(var(--header-height) + 16px);
right: 16px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-1);
border: 1px solid var(--color-border-3);
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text-2);
transition: all 0.2s;
z-index: 150;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
&:hover {
background: var(--color-bg-2);
color: var(--color-text-1);
border-color: var(--color-border-3);
}
}
// 文件树
.file-tree {
&__item {
display: flex;
align-items: center;
padding: 4px 8px;
cursor: pointer;
transition: background 0.2s;
border-radius: var(--radius-sm);
margin-bottom: 1px;
position: relative;
&:hover {
background: var(--color-bg-1);
}
&--folder {
font-weight: $font-weight-medium;
}
&--file {
padding-left: 28px; // 文件缩进
}
}
&__toggle {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 2px;
color: var(--color-text-3);
transition: transform 0.2s;
flex-shrink: 0;
&--expanded {
transform: rotate(90deg);
}
}
&__icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 6px;
flex-shrink: 0;
color: var(--color-text-2);
}
&__name {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__size {
width: 50px;
text-align: right;
font-size: 11px;
color: var(--color-text-3);
margin-left: 6px;
flex-shrink: 0;
}
&__time {
width: 40px;
font-size: 11px;
color: var(--color-text-3);
margin-left: 6px;
flex-shrink: 0;
}
&__actions {
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
margin-left: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-3);
font-size: 14px;
line-height: 1;
transition: all 0.2s;
flex-shrink: 0;
&:hover {
background: var(--color-bg-2);
color: var(--color-text-1);
}
}
&__children {
padding-left: 0;
}
}
// 文件预览弹窗
.file-preview-modal {
&__info {
margin-bottom: 16px;
padding: 12px;
background: var(--color-bg-2);
border-radius: var(--radius-md);
}
&__info-item {
display: flex;
margin-bottom: 8px;
font-size: $font-size-sm;
&:last-child {
margin-bottom: 0;
}
}
&__info-label {
width: 80px;
color: var(--color-text-3);
flex-shrink: 0;
}
&__info-value {
flex: 1;
color: var(--color-text-1);
}
&__content {
margin-bottom: 16px;
padding: 16px;
background: var(--color-bg-2);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-2);
max-height: 400px;
overflow-y: auto;
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
}
&__placeholder-icon {
font-size: 48px;
margin-bottom: 16px;
}
&__placeholder-text {
font-size: $font-size-sm;
color: var(--color-text-2);
margin-bottom: 8px;
}
&__code {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
line-height: 1.6;
color: var(--color-text-1);
white-space: pre-wrap;
word-break: break-all;
}
}
// 文件右键菜单
.file-context-menu {
position: absolute;
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);
}
&--danger {
color: var(--color-danger);
&:hover {
background: var(--color-danger-light);
}
}
}
&__icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
}

View File

@@ -10,6 +10,14 @@
background: var(--color-bg-1);
}
// 聊天主区域(包含内容区和右侧工作空间侧边栏)
.chat-main {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
// 聊天顶部栏
.chat-layout__header,
.chat-header {