Compare commits
3 Commits
def2b6bf61
...
ff4217c72a
| Author | SHA1 | Date | |
|---|---|---|---|
| ff4217c72a | |||
| 5f333b116a | |||
| e382a60e0a |
15
README.md
15
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); // 根据文件名获取文件类型
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
189
openspec/specs/file-operations/spec.md
Normal file
189
openspec/specs/file-operations/spec.md
Normal file
@@ -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 提示"该文件类型不支持预览",不打开预览弹框
|
||||
150
openspec/specs/file-preview/spec.md
Normal file
150
openspec/specs/file-preview/spec.md
Normal file
@@ -0,0 +1,150 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 文件预览弹窗显示文件信息
|
||||
文件预览面板应显示文件的基本信息,嵌入在侧边栏右侧,不使用模态弹窗。
|
||||
|
||||
#### Scenario: 显示文件基本信息
|
||||
- **WHEN** 文件预览面板打开
|
||||
- **THEN** 面板显示文件名、文件类型、文件大小、修改时间
|
||||
|
||||
#### Scenario: 弹窗标题显示文件名
|
||||
- **WHEN** 文件预览面板打开
|
||||
- **THEN** 面板 header 显示文件图标、文件名和关闭按钮(×)
|
||||
|
||||
#### Scenario: 面板嵌入侧边栏
|
||||
- **WHEN** 文件预览面板打开
|
||||
- **THEN** 面板嵌入在侧边栏右侧,不覆盖整个页面
|
||||
|
||||
### 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** 弹窗关闭,不执行删除操作
|
||||
|
||||
### Requirement: 文件预览面板始终可见
|
||||
文件预览面板应始终可见,不打断用户操作流程。
|
||||
|
||||
#### Scenario: 点击文件立即显示预览
|
||||
- **WHEN** 用户点击文件树中的文件
|
||||
- **THEN** 预览面板立即在右侧显示文件内容,不弹出模态框
|
||||
|
||||
#### Scenario: 切换文件预览面板保持打开
|
||||
- **WHEN** 用户点击另一个文件
|
||||
- **THEN** 预览面板内容更新为新文件内容,面板保持打开状态
|
||||
|
||||
#### Scenario: 预览面板独立滚动
|
||||
- **WHEN** 预览文件内容过长
|
||||
- **THEN** 预览面板内容区域独立滚动,不影响文件树
|
||||
|
||||
### Requirement: 预览面板布局
|
||||
预览面板应采用固定布局结构。
|
||||
|
||||
#### Scenario: 预览面板布局结构
|
||||
- **WHEN** 预览面板显示
|
||||
- **THEN** 面板从上到下依次为:Header(文件名+关闭按钮)、Info(文件信息)、Content(文件内容)、Actions(操作按钮)
|
||||
|
||||
#### Scenario: 预览面板最小宽度
|
||||
- **WHEN** 预览面板显示
|
||||
- **THEN** 预览面板最小宽度为 200px,保证内容可读
|
||||
|
||||
#### Scenario: 预览面板自适应宽度
|
||||
- **WHEN** 侧边栏宽度或文件树宽度改变
|
||||
- **THEN** 预览面板宽度自动调整(sidebarWidth - fileTreeWidth - dividerWidth)
|
||||
194
openspec/specs/file-tree/spec.md
Normal file
194
openspec/specs/file-tree/spec.md
Normal file
@@ -0,0 +1,194 @@
|
||||
## 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
|
||||
|
||||
### 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** 显示至少一个空文件夹
|
||||
|
||||
### Requirement: 文件树宽度可调整
|
||||
文件树宽度应支持拖动调整。
|
||||
|
||||
#### Scenario: 文件树默认宽度
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树默认宽度为 200px
|
||||
|
||||
#### Scenario: 文件树宽度范围
|
||||
- **WHEN** 用户拖动分隔线调整文件树宽度
|
||||
- **THEN** 文件树宽度在 180px 到 300px 之间变化
|
||||
|
||||
#### Scenario: 文件树宽度约束
|
||||
- **WHEN** 文件树宽度调整到边界值
|
||||
- **THEN** 最小 180px 保证文件名可读,最大 300px 避免压缩预览面板
|
||||
|
||||
### Requirement: 文件树作为侧边栏左侧面板
|
||||
文件树应作为侧边栏的左侧面板显示。
|
||||
|
||||
#### Scenario: 文件树固定在左侧
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树固定在侧边栏左侧,flex-shrink: 0,不被压缩
|
||||
|
||||
#### Scenario: 文件树独立滚动
|
||||
- **WHEN** 文件树内容过多
|
||||
- **THEN** 文件树独立滚动(overflow-y: auto),不影响预览面板
|
||||
|
||||
#### Scenario: 文件树背景色
|
||||
- **WHEN** 文件树显示
|
||||
- **THEN** 文件树背景色为 var(--color-bg-2),与侧边栏整体一致
|
||||
|
||||
#### Scenario: 文件树右侧边框
|
||||
- **WHEN** 文件树显示
|
||||
- **THEN** 文件树右侧显示 1px 边框(var(--color-border-2)),与预览面板分隔
|
||||
169
openspec/specs/workspace-sidebar/spec.md
Normal file
169
openspec/specs/workspace-sidebar/spec.md
Normal file
@@ -0,0 +1,169 @@
|
||||
## 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 区域,content 区域采用左右分栏布局。
|
||||
|
||||
#### Scenario: 侧边栏 header 显示标题和关闭按钮
|
||||
- **WHEN** 工作空间侧边栏展开
|
||||
- **THEN** header 区域显示"工作空间"标题和关闭按钮(×)
|
||||
|
||||
#### Scenario: 侧边栏 content 左右分栏
|
||||
- **WHEN** 工作空间侧边栏展开
|
||||
- **THEN** content 区域分为左侧文件树和右侧预览面板,使用 Flex 布局
|
||||
|
||||
### 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** 侧边栏默认宽度为 500px
|
||||
|
||||
#### Scenario: 拖动调整宽度
|
||||
- **WHEN** 用户拖动侧边栏左边缘的手柄
|
||||
- **THEN** 侧边栏宽度随鼠标移动而改变,范围限制在 400px 到 800px
|
||||
|
||||
#### Scenario: 拖动手柄始终显示
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 左边缘的拖动手柄始终显示(opacity: 0.5),hover 时 opacity 为 1
|
||||
|
||||
#### 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 间距
|
||||
|
||||
### Requirement: 侧边栏内部左右分栏布局
|
||||
侧边栏 content 区域应采用左右分栏布局。
|
||||
|
||||
#### Scenario: 文件树在左侧
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树显示在左侧,默认宽度 200px,范围 180-300px
|
||||
|
||||
#### Scenario: 预览面板在右侧
|
||||
- **WHEN** 用户点击文件树中的文件
|
||||
- **THEN** 预览面板显示在右侧,宽度自适应(flex: 1)
|
||||
|
||||
#### Scenario: 分隔线显示
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树和预览面板之间显示 1px 分隔线
|
||||
|
||||
### Requirement: 双拖动调整机制
|
||||
侧边栏应支持两层拖动调整。
|
||||
|
||||
#### Scenario: 外部拖动调整整体宽度
|
||||
- **WHEN** 用户拖动侧边栏左边缘手柄
|
||||
- **THEN** 侧边栏整体宽度改变(400-800px),文件树宽度不变,预览面板宽度自适应
|
||||
|
||||
#### Scenario: 内部拖动调整文件树宽度
|
||||
- **WHEN** 用户拖动文件树和预览面板之间的分隔线
|
||||
- **THEN** 文件树宽度改变(180-300px),侧边栏整体宽度不变,预览面板宽度自适应
|
||||
|
||||
#### Scenario: 分隔线可拖动
|
||||
- **WHEN** 用户 hover 分隔线
|
||||
- **THEN** 分隔线背景色变为 var(--color-primary),鼠标变为 ew-resize
|
||||
|
||||
#### Scenario: 分隔线拖动区域扩大
|
||||
- **WHEN** 用户在分隔线附近 4px 范围内按下鼠标
|
||||
- **THEN** 开始拖动调整文件树宽度
|
||||
|
||||
### Requirement: 对话区域最小宽度约束
|
||||
对话区域应保留最小宽度,保证可用性。
|
||||
|
||||
#### Scenario: 对话区域最小宽度
|
||||
- **WHEN** 侧边栏宽度调整
|
||||
- **THEN** 对话区域宽度不小于 480px,保证输入框和消息区可用
|
||||
|
||||
#### Scenario: 侧边栏最大宽度限制
|
||||
- **WHEN** 对话区域宽度接近 480px
|
||||
- **THEN** 侧边栏宽度不再增加,保证对话区域可用性
|
||||
55
src/components/workspace/CreateModal.jsx
Normal file
55
src/components/workspace/CreateModal.jsx
Normal file
@@ -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 (
|
||||
<Modal
|
||||
visible={true}
|
||||
title={title}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={onCancel}
|
||||
confirmText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--color-text-2)' }}>
|
||||
名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateModal;
|
||||
140
src/components/workspace/FileContextMenu.jsx
Normal file
140
src/components/workspace/FileContextMenu.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { FiDownload, FiEdit2, FiTrash2, FiInfo, FiFolder, FiFilePlus, FiFolderPlus, FiUpload } from 'react-icons/fi';
|
||||
|
||||
/**
|
||||
* 文件右键菜单组件
|
||||
*/
|
||||
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':
|
||||
onDownload && onDownload(file);
|
||||
break;
|
||||
case 'rename':
|
||||
onRename && onRename(file);
|
||||
break;
|
||||
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();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="file-context-menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
}}
|
||||
>
|
||||
{/* 文件夹特有操作 */}
|
||||
{isFolder && (
|
||||
<>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('new-file')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiFilePlus size={14} />
|
||||
</span>
|
||||
新建文件
|
||||
</div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('new-folder')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiFolderPlus size={14} />
|
||||
</span>
|
||||
新建文件夹
|
||||
</div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('upload')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiUpload size={14} />
|
||||
</span>
|
||||
上传文件
|
||||
</div>
|
||||
<div className="file-context-menu__divider"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 基础操作 */}
|
||||
{!isFolder && (
|
||||
<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"
|
||||
onClick={() => handleAction('move')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiFolder size={14} />
|
||||
</span>
|
||||
移动到
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<div className="file-context-menu__divider"></div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('detail')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiInfo size={14} />
|
||||
</span>
|
||||
查看详情
|
||||
</div>
|
||||
|
||||
{/* 危险操作 */}
|
||||
<div className="file-context-menu__divider"></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;
|
||||
121
src/components/workspace/FileDetailModal.jsx
Normal file
121
src/components/workspace/FileDetailModal.jsx
Normal file
@@ -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 (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">文件详情</div>
|
||||
<div className="modal-close" onClick={onClose}>
|
||||
<FiX size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
名称
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
类型
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{isFolder ? '文件夹' : getTypeDesc(file.fileType)}
|
||||
</div>
|
||||
</div>
|
||||
{!isFolder && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
大小
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.size || '-'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{file.dimensions && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
尺寸
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.dimensions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{file.duration && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
时长
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.duration}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
修改时间
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.modifiedAt || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
路径
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
/workspace/{file.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn--primary" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileDetailModal;
|
||||
156
src/components/workspace/FilePreviewPanel.jsx
Normal file
156
src/components/workspace/FilePreviewPanel.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { FiX, FiDownload } from 'react-icons/fi';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
* 文件预览面板组件
|
||||
* 显示在侧边栏右侧,不打断用户操作
|
||||
*/
|
||||
function FilePreviewPanel({ 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-panel__info">
|
||||
<div className="file-preview-panel__info-item">
|
||||
<span className="label">类型:</span>
|
||||
<span className="value">{getTypeDesc(file.fileType)}</span>
|
||||
</div>
|
||||
<div className="file-preview-panel__info-item">
|
||||
<span className="label">大小:</span>
|
||||
<span className="value">{file.size}</span>
|
||||
</div>
|
||||
{file.dimensions && (
|
||||
<div className="file-preview-panel__info-item">
|
||||
<span className="label">尺寸:</span>
|
||||
<span className="value">{file.dimensions}</span>
|
||||
</div>
|
||||
)}
|
||||
{file.duration && (
|
||||
<div className="file-preview-panel__info-item">
|
||||
<span className="label">时长:</span>
|
||||
<span className="value">{file.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="file-preview-panel__info-item">
|
||||
<span className="label">修改:</span>
|
||||
<span className="value">{file.modifiedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染预览内容
|
||||
const renderPreview = () => {
|
||||
// 文本类型:显示内容
|
||||
if (['text', 'markdown', 'json', 'javascript', 'python', 'css', 'html'].includes(file.fileType)) {
|
||||
return (
|
||||
<div className="file-preview-panel__content">
|
||||
<pre className="file-preview-panel__code">{file.content || '(无内容)'}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Office 类型:显示占位符
|
||||
if (['word', 'excel', 'powerpoint'].includes(file.fileType)) {
|
||||
return (
|
||||
<div className="file-preview-panel__placeholder">
|
||||
<div className="file-preview-panel__placeholder-icon">{icon}</div>
|
||||
<div className="file-preview-panel__placeholder-text">
|
||||
点击下载查看完整内容
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 图片类型:显示占位符
|
||||
if (file.fileType === 'image') {
|
||||
return (
|
||||
<div className="file-preview-panel__placeholder">
|
||||
<div className="file-preview-panel__placeholder-icon">{icon}</div>
|
||||
<div className="file-preview-panel__placeholder-text">
|
||||
图片预览区域
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 视频/音频类型:显示占位符
|
||||
if (['video', 'audio'].includes(file.fileType)) {
|
||||
const text = file.fileType === 'video' ? '点击下载播放视频' : '点击下载播放音频';
|
||||
return (
|
||||
<div className="file-preview-panel__placeholder">
|
||||
<div className="file-preview-panel__placeholder-icon">{icon}</div>
|
||||
<div className="file-preview-panel__placeholder-text">{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他类型
|
||||
return (
|
||||
<div className="file-preview-panel__placeholder">
|
||||
<div className="file-preview-panel__placeholder-icon">{icon}</div>
|
||||
<div className="file-preview-panel__placeholder-text">
|
||||
预览不可用
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 下载操作
|
||||
const handleDownload = () => {
|
||||
alert('文件下载中...');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="file-preview-panel">
|
||||
{/* Header - 文件名 + 关闭按钮 */}
|
||||
<div className="file-preview-panel__header">
|
||||
<div className="file-preview-panel__icon">{icon}</div>
|
||||
<div className="file-preview-panel__name">{file.name}</div>
|
||||
<div className="file-preview-panel__close" onClick={onClose}>
|
||||
<FiX size={16} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info - 文件信息 */}
|
||||
{renderFileInfo()}
|
||||
|
||||
{/* Content - 文件内容预览 */}
|
||||
<div className="file-preview-panel__content-wrapper">
|
||||
{renderPreview()}
|
||||
</div>
|
||||
|
||||
{/* Actions - 操作按钮 */}
|
||||
<div className="file-preview-panel__actions">
|
||||
<button className="btn" onClick={handleDownload}>
|
||||
<FiDownload />
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilePreviewPanel;
|
||||
39
src/components/workspace/FileTree.jsx
Normal file
39
src/components/workspace/FileTree.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import FileTreeItem from './FileTreeItem.jsx';
|
||||
import { FiFolder } from 'react-icons/fi';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
* 文件树组件
|
||||
* 渲染工作空间的文件和文件夹结构
|
||||
*/
|
||||
function FileTree({ onFileClick, onMenuClick }) {
|
||||
const files = api.workspace.getFiles();
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return (
|
||||
<div className="file-tree">
|
||||
<div className="file-tree__empty">
|
||||
<div className="file-tree__empty-icon">
|
||||
<FiFolder size={48} />
|
||||
</div>
|
||||
<div className="file-tree__empty-text">工作空间为空</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{files.map(item => (
|
||||
<FileTreeItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onFileClick={onFileClick}
|
||||
onMenuClick={onMenuClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileTree;
|
||||
134
src/components/workspace/FileTreeItem.jsx
Normal file
134
src/components/workspace/FileTreeItem.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 文件树项组件
|
||||
* 渲染单个文件或文件夹项
|
||||
*/
|
||||
function FileTreeItem({ item, onFileClick, onMenuClick }) {
|
||||
const [isExpanded, setIsExpanded] = 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();
|
||||
if (onMenuClick) {
|
||||
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 && item.children.length > 0 && (
|
||||
<div className="file-tree__children">
|
||||
{item.children.map(child => (
|
||||
<FileTreeItem
|
||||
key={child.id}
|
||||
item={child}
|
||||
onFileClick={onFileClick}
|
||||
onMenuClick={onMenuClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileTreeItem;
|
||||
50
src/components/workspace/RenameModal.jsx
Normal file
50
src/components/workspace/RenameModal.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from '../common/Modal.jsx';
|
||||
|
||||
/**
|
||||
* 重命名弹框组件
|
||||
*/
|
||||
function RenameModal({ file, onConfirm, onCancel }) {
|
||||
const [newName, setNewName] = useState(file ? file.name : '');
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (newName.trim()) {
|
||||
onConfirm && onConfirm(newName.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
title="重命名"
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={onCancel}
|
||||
confirmText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--color-text-2)' }}>
|
||||
新名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameModal;
|
||||
38
src/components/workspace/UploadButton.jsx
Normal file
38
src/components/workspace/UploadButton.jsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
|
||||
上传文件
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UploadButton;
|
||||
553
src/components/workspace/WorkspaceSidebar.jsx
Normal file
553
src/components/workspace/WorkspaceSidebar.jsx
Normal file
@@ -0,0 +1,553 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { FiX, FiFolder, FiRefreshCw, FiPlus, FiFilePlus, FiFolderPlus, FiUpload } from 'react-icons/fi';
|
||||
import FileTree from './FileTree.jsx';
|
||||
import FileContextMenu from './FileContextMenu.jsx';
|
||||
import FilePreviewPanel from './FilePreviewPanel.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';
|
||||
|
||||
/**
|
||||
* 工作空间侧边栏组件
|
||||
* 支持双拖动调整:整体宽度和文件树宽度
|
||||
*/
|
||||
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 [sidebarWidth, setSidebarWidth] = useState(500); // 侧边栏整体宽度
|
||||
const [fileTreeWidth, setFileTreeWidth] = useState(200); // 文件树宽度
|
||||
|
||||
// Refs
|
||||
const sidebarRef = useRef(null);
|
||||
const isResizingSidebarRef = useRef(false); // 是否正在调整侧边栏宽度
|
||||
const isResizingTreeRef = useRef(false); // 是否正在调整文件树宽度
|
||||
const lastXRef = useRef(0);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// ========== 拖动调整:侧边栏整体宽度 ==========
|
||||
const handleSidebarMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
isResizingSidebarRef.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';
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 拖动调整:文件树宽度 ==========
|
||||
const handleDividerMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // 防止触发侧边栏拖动
|
||||
isResizingTreeRef.current = true;
|
||||
lastXRef.current = e.clientX;
|
||||
|
||||
document.body.style.cursor = 'ew-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
// ========== 统一的鼠标移动和释放处理 ==========
|
||||
useEffect(() => {
|
||||
let animationFrameId = null;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isResizingSidebarRef.current && !isResizingTreeRef.current) return;
|
||||
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(() => {
|
||||
const deltaX = e.clientX - lastXRef.current;
|
||||
lastXRef.current = e.clientX;
|
||||
|
||||
// 调整侧边栏宽度(向左拖动减小,向右拖动增加)
|
||||
if (isResizingSidebarRef.current) {
|
||||
setSidebarWidth(prev => {
|
||||
const newWidth = prev - deltaX; // 注意:向左拖动是负数
|
||||
return Math.max(400, Math.min(800, newWidth));
|
||||
});
|
||||
}
|
||||
|
||||
// 调整文件树宽度(向右拖动增加,向左拖动减小)
|
||||
if (isResizingTreeRef.current) {
|
||||
setFileTreeWidth(prev => {
|
||||
const newWidth = prev + deltaX;
|
||||
return Math.max(180, Math.min(300, newWidth));
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isResizingSidebarRef.current && !isResizingTreeRef.current) return;
|
||||
|
||||
isResizingSidebarRef.current = false;
|
||||
isResizingTreeRef.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 = (e) => {
|
||||
// 关闭右键菜单
|
||||
if (contextMenu) {
|
||||
setContextMenu(null);
|
||||
}
|
||||
// 关闭下拉菜单
|
||||
if (showDropdown && dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
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 = 160; // 菜单宽度
|
||||
const x = rect.right - menuWidth;
|
||||
const y = rect.bottom + 4;
|
||||
|
||||
setContextMenu({
|
||||
file,
|
||||
position: {
|
||||
x: Math.max(8, x), // 确保不超出左边界
|
||||
y,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 显示 Toast 提示
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToast({ type, message });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
// 下载操作
|
||||
const handleDownload = (file) => {
|
||||
showToast('文件下载中...', 'info');
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// 重命名操作
|
||||
const handleRename = (file) => {
|
||||
setRenameFile(file);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleRenameConfirm = (newName) => {
|
||||
showToast('重命名成功');
|
||||
setRenameFile(null);
|
||||
};
|
||||
|
||||
// 删除操作
|
||||
const handleDelete = (file) => {
|
||||
setDeleteFile(file);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className="workspace-sidebar"
|
||||
ref={sidebarRef}
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<div className="workspace-sidebar__header">
|
||||
<div className="workspace-sidebar__title">工作空间</div>
|
||||
<div className="workspace-sidebar__actions">
|
||||
<div
|
||||
className={`workspace-sidebar__action-btn ${isRefreshing ? 'workspace-sidebar__refresh--rotating' : ''}`}
|
||||
onClick={handleRefresh}
|
||||
title="刷新"
|
||||
>
|
||||
<FiRefreshCw size={16} />
|
||||
</div>
|
||||
<div style={{ position: 'relative' }} ref={dropdownRef}>
|
||||
<div
|
||||
className="workspace-sidebar__action-btn"
|
||||
onClick={handleDropdownToggle}
|
||||
title="新建"
|
||||
>
|
||||
<FiPlus size={16} />
|
||||
</div>
|
||||
{showDropdown && (
|
||||
<div className="workspace-sidebar__dropdown">
|
||||
<div className="workspace-sidebar__dropdown__item" onClick={handleNewFile}>
|
||||
<span className="workspace-sidebar__dropdown__icon">
|
||||
<FiFilePlus size={14} />
|
||||
</span>
|
||||
新建文件
|
||||
</div>
|
||||
<div className="workspace-sidebar__dropdown__item" onClick={handleNewFolder}>
|
||||
<span className="workspace-sidebar__dropdown__icon">
|
||||
<FiFolderPlus size={14} />
|
||||
</span>
|
||||
新建文件夹
|
||||
</div>
|
||||
<div className="workspace-sidebar__dropdown__item" onClick={handleUploadClick}>
|
||||
<span className="workspace-sidebar__dropdown__icon">
|
||||
<FiUpload size={14} />
|
||||
</span>
|
||||
上传文件
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-sidebar__close" onClick={onClose}>
|
||||
<FiX size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - 左右分栏 */}
|
||||
<div className="workspace-sidebar__content">
|
||||
{/* FileTree */}
|
||||
<div
|
||||
className="workspace-sidebar__file-tree"
|
||||
style={{ width: `${fileTreeWidth}px` }}
|
||||
>
|
||||
<FileTree
|
||||
onFileClick={handleFileClick}
|
||||
onMenuClick={handleMenuClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分隔线(可拖动调整) */}
|
||||
<div
|
||||
className="workspace-sidebar__divider"
|
||||
onMouseDown={handleDividerMouseDown}
|
||||
/>
|
||||
|
||||
{/* Preview Panel */}
|
||||
{previewFile && (
|
||||
<div className="workspace-sidebar__preview">
|
||||
<FilePreviewPanel
|
||||
file={previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 外部拖动手柄(调整侧边栏整体宽度) */}
|
||||
<div
|
||||
className="workspace-sidebar__resize-handle"
|
||||
onMouseDown={handleSidebarMouseDown}
|
||||
>
|
||||
<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}
|
||||
onDetail={handleDetail}
|
||||
onMove={handleMove}
|
||||
onNewFile={handleNewFile}
|
||||
onNewFolder={handleNewFolder}
|
||||
onUpload={handleUploadClick}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{deleteFile && (
|
||||
<Modal
|
||||
visible={true}
|
||||
title="确认删除"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteFile(null)}
|
||||
>
|
||||
确定要删除文件「{deleteFile.name}」吗?
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 重命名弹框 */}
|
||||
{renameFile && (
|
||||
<RenameModal
|
||||
file={renameFile}
|
||||
onConfirm={handleRenameConfirm}
|
||||
onCancel={() => setRenameFile(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 新建弹框 */}
|
||||
{createType && (
|
||||
<CreateModal
|
||||
type={createType}
|
||||
onConfirm={handleCreateConfirm}
|
||||
onCancel={() => setCreateType(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文件详情弹框 */}
|
||||
{detailFile && (
|
||||
<FileDetailModal
|
||||
file={detailFile}
|
||||
onClose={() => setDetailFile(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 移动文件弹框 */}
|
||||
{moveFile && (
|
||||
<div className="modal-overlay" onClick={handleMoveCancel}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ width: '400px' }}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">移动到</div>
|
||||
<div className="modal-close" onClick={handleMoveCancel}>
|
||||
<FiX size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<MoveFolderTree
|
||||
folders={folderTree}
|
||||
selected={moveTarget}
|
||||
onSelect={setMoveTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn" onClick={handleMoveCancel}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleMoveConfirm} disabled={!moveTarget}>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast 提示 */}
|
||||
<Toast
|
||||
visible={!!toast}
|
||||
type={toast?.type}
|
||||
message={toast?.message}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动文件弹框中的文件夹树组件
|
||||
*/
|
||||
function MoveFolderTree({ folders, selected, onSelect, depth = 0 }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
{folders.map(folder => (
|
||||
<div key={folder.id}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
paddingLeft: `${12 + depth * 20}px`,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '14px',
|
||||
transition: 'background 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: selected === folder.name ? 'var(--color-bg-2)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => { 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)}
|
||||
>
|
||||
<FiFolder size={14} style={{ flexShrink: 0 }} />
|
||||
{folder.name}
|
||||
</div>
|
||||
{folder.children.length > 0 && (
|
||||
<MoveFolderTree
|
||||
folders={folder.children}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作空间展开按钮组件
|
||||
*/
|
||||
export function WorkspaceToggleBtn({ onClick }) {
|
||||
return (
|
||||
<div className="workspace-toggle-btn" onClick={onClick} title="工作空间">
|
||||
<FiFolder size={18} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspaceSidebar;
|
||||
365
src/data/workspace.js
Normal file
365
src/data/workspace.js
Normal file
@@ -0,0 +1,365 @@
|
||||
// 工作空间文件数据
|
||||
|
||||
// 文件图标映射
|
||||
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'
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<button className="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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: []
|
||||
}
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
@forward 'search-bar';
|
||||
@forward 'stat-card';
|
||||
@forward 'header';
|
||||
@forward 'workspace';
|
||||
|
||||
.page-back-btn {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -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);
|
||||
|
||||
643
src/styles/components/workspace/_index.scss
Normal file
643
src/styles/components/workspace/_index.scss
Normal file
@@ -0,0 +1,643 @@
|
||||
// 工作空间组件样式
|
||||
|
||||
@use '../../tokens' as *;
|
||||
|
||||
// 工作空间侧边栏
|
||||
.workspace-sidebar {
|
||||
width: 500px;
|
||||
min-width: 400px;
|
||||
max-width: 800px;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
&__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 - 左右分栏 ==========
|
||||
&__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// ========== FileTree ==========
|
||||
&__file-tree {
|
||||
min-width: 180px;
|
||||
max-width: 300px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
// ========== 分隔线(可拖动) ==========
|
||||
&__divider {
|
||||
width: 1px;
|
||||
background: var(--color-border-2);
|
||||
flex-shrink: 0;
|
||||
cursor: ew-resize;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
|
||||
// 增加可拖动区域
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
bottom: 0;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Preview Panel ==========
|
||||
&__preview {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
&__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: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// 文件预览弹窗
|
||||
.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-preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
// Header
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-1);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
// Info
|
||||
&__info {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__info-item {
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 60px;
|
||||
color: var(--color-text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
// Content Wrapper
|
||||
&__content-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// Content
|
||||
&__content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__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;
|
||||
background: var(--color-bg-2);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Placeholder
|
||||
&__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-3);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
&__placeholder-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__placeholder-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// Actions
|
||||
&__actions {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
}
|
||||
|
||||
// 文件右键菜单
|
||||
.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: 160px;
|
||||
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;
|
||||
}
|
||||
|
||||
&__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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -237,6 +245,7 @@
|
||||
background: var(--color-bg-1);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
min-width: 480px; // 保证对话区域最小宽度
|
||||
}
|
||||
|
||||
.chat-content__messages,
|
||||
|
||||
Reference in New Issue
Block a user