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 (
+
+ );
+ }
+
+ // 视频/音频类型:显示占位符
+ if (['video', 'audio'].includes(file.fileType)) {
+ const text = file.fileType === 'video' ? '点击下载播放视频' : '点击下载播放音频';
+ return (
+
+ );
+ }
+
+ // 其他类型
+ return (
+
+ );
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
+ {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 (