Compare commits

...

12 Commits

Author SHA1 Message Date
7125753ca2 feat: 重构AI消息气泡结构,集成thinking、tool和content为统一block
- AI消息使用统一的可折叠容器(message-block)
- thinking和tool使用相同样式,支持展开/收起
- 工具调用显示input/output内容,区分入参和出参
- 添加状态徽章样式(running/completed/error + 脉冲动画)
- 消息气泡添加毛玻璃效果和精细阴影
- 删除file场景(分析上传的报表)
- 同步更新main specs
2026-04-19 16:34:50 +08:00
9dd2d4a1fc feat: 扩展工作空间侧边栏宽度约束并统一拖动手柄样式
- 侧边栏最大宽度从 800px 扩展至 1200px(含动态计算)
- 文件树最小宽度从 180px 提升至 300px
- 文件树最大宽度扩展为侧边栏宽度
- 预览关闭时文件树自动占满侧边栏,分隔线隐藏
- 外部拖动手柄改为与内部分隔线一致的样式(1px、hover 变色)
2026-04-19 15:19:26 +08:00
ff4217c72a feat: 工作空间文件预览改为侧边栏内嵌面板
- 将文件预览从模态弹窗改为侧边栏内嵌面板,不打断用户操作流程
- 支持双拖动调整:外部调整整体宽度(400-800px),内部调整文件树宽度(180-300px)
- 左右分栏布局:左侧文件树 + 右侧预览面板
- 对话区域保留最小宽度 480px,保证可用性
- 新建 FilePreviewPanel 组件,删除 FilePreviewModal 组件
- 更新相关样式和规格文档
2026-04-17 19:34:29 +08:00
5f333b116a feat: 完善工作空间文件操作功能
新增功能:
- 重命名弹框(替换 alert)
- 新建文件/文件夹弹框
- 上传文件功能
- 移动文件功能(树状选择+确认)
- 查看文件详情弹框
- 刷新文件列表按钮

交互优化:
- 修复操作按钮 toggle 行为
- 右键菜单分组显示(基础操作/详细信息/危险操作)
- 预览弹框宽度调整为 600px
- 不支持预览的文件类型 Toast 提示
- 文件树子项缩进 16px
- 空文件夹展开后直接为空

数据增强:
- 中文文件名示例
- 无法预览类型示例(压缩包、数据库、二进制)
- 多级文件夹嵌套示例
- 空文件夹示例

样式新增:
- 标题栏操作按钮样式
- 刷新按钮旋转动画
- 新建按钮下拉菜单
- 右键菜单分组分隔线
- 空状态样式
2026-04-17 17:10:46 +08:00
e382a60e0a feat: 添加工作空间侧边栏和文件树功能
- 新增工作空间侧边栏组件,支持展开/关闭和拖动调整宽度
- 实现文件树组件,支持文件夹展开/折叠,显示文件大小和修改时间
- 添加文件预览弹窗,支持文本、Office、图片、视频、音频等多种文件类型
- 实现文件右键菜单,提供下载、重命名、删除操作入口
- 使用 react-icons 图标库替代 emoji,提升视觉一致性
- 优化拖动性能,使用 requestAnimationFrame 确保流畅跟手
- 新增工作空间相关规范文档(workspace-sidebar、file-tree、file-preview)
2026-04-17 15:15:57 +08:00
def2b6bf61 refactor: 优化模型配置列表状态展示,移除'状态'列,改用徽章标记默认配置 2026-04-13 11:38:18 +08:00
f507032d98 style: 统一侧边栏菜单图标和顺序
- 工作台"个人模型"改名为"我的模型",并移至"我的技能"之后
- 管理台"模型配置"图标统一为 FiCpu
- 工作台"技能配置"图标改为 FiBox,与"我的技能"保持一致
2026-04-13 10:08:54 +08:00
6e73e6a297 refactor: 对话输入框响应式布局重构,支持水平/垂直布局自动切换 2026-04-10 17:46:56 +08:00
3f815db0b2 feat: 添加三层级模型配置管理系统原型
新增三个层级的模型配置管理功能:
- 平台级模型配置(管理台):配置列表、新增/编辑、删除(默认模型不允许删除)、设为默认(仅页面状态)
- 项目级模型配置(工作台):配置列表、新增/编辑、删除(允许删除默认)、设为默认(仅页面状态)
- 个人模型配置(工作台):配置列表、新增/编辑、删除(允许删除默认)、设为默认(仅页面状态)
- 融合式模型选择器:在聊天输入框顶部集成,按层级分组展示模型列表(平台/项目/个人)

技术实现:
- 新增项目级和个人级配置数据文件
- 扩展 api.js 数据访问层,添加 consoleModels.project 和 consoleModels.user 对象
- 新增 4 个页面组件(ProjectModelConfigsPage、AddProjectModelConfigPage、UserModelConfigsPage、AddUserModelConfigPage)
- 修改 2 个现有页面(ModelConfigsPage、ChatPage、ConsoleLayout)
- 修改 Modal 组件支持 cancelText 为空时隐藏取消按钮
- 在 App.jsx 中添加 6 条新路由
- 新增模型选择器样式文件(融合式设计、分组展示、响应式)
- 更新 README.md 项目结构

样式特点:
- 融合式模型选择器与输入框风格一致
- 下拉列表按层级分组(平台/项目/个人)
- 默认标记使用渐变背景色
- 选中状态高亮(浅蓝色背景 + 左侧边框 + 右侧勾选)
- 响应式设计(移动端适配)

数据示例:
- 项目级:3 个示例配置(不同类型和状态)
- 个人级:2 个示例配置(不同类型和状态)
2026-04-10 13:43:19 +08:00
eeef824b24 style: 管理台侧边栏模型配置菜单项移至日志查询前面 2026-04-09 14:33:37 +08:00
4f2faa3e8d refactor: 项目管理菜单改造为下拉导航组
- 新增 SidebarNavGroup 组件支持可展开导航组
- 路由从 /console/projects 调整为 /console/project/*
- 成员管理页面独立为子菜单
- 新增权限配置、技能配置占位页面
- URL 驱动展开状态,刷新保持
- 更新 README.md 和 specs
2026-03-30 14:11:31 +08:00
ea81a714bb style: 技能配置变量表格删除按钮改为文字样式
- 删除按钮从图标改为文字,使用 text-btn text-btn-danger 样式
- 操作列宽度从 80px 调整为 120px(col-actions--narrow)
- 移除未使用的 FiX 图标导入
2026-03-30 11:29:44 +08:00
58 changed files with 5944 additions and 211 deletions

View File

@@ -97,12 +97,17 @@ 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 # 列表选择器
├── contexts/ # 全局状态 (UserContext)
├── services/ # 数据访问层 (api.js)
├── data/ # 模拟数据
│ ├── adminData.js # 管理台数据(含平台级模型配置)
│ ├── projectModelConfigs.js # 项目级模型配置数据
│ ├── userModelConfigs.js # 个人模型配置数据
│ └── workspace.js # 工作空间文件数据
├── pages/ # 页面组件
│ ├── console/ # 工作台子页面
@@ -115,6 +120,8 @@ src/
│ ├── layouts/ # 布局系统
│ ├── components/ # 组件样式
│ ├── pages/ # 页面样式
│ │ ├── model-selector/ # 模型选择器样式
│ │ └── ...
│ └── global.scss # 主入口
├── App.jsx # 路由配置
@@ -128,7 +135,7 @@ src/
| 模块 | 路由 | 功能 |
|------|------|------|
| 首页 | `/` | 品牌展示、登录入口 |
| 工作台 | `/console` | 聊天、技能市场、定时任务、项目管理 |
| 工作台 | `/console` | 聊天、技能市场、定时任务、项目管理(成员/权限/技能/模型配置)、个人模型配置 |
| 管理台 | `/admin` | 部门/用户/项目管理、模型配置 |
| 开发台 | `/developer` | 技能开发、版本管理 |
@@ -272,6 +279,12 @@ export default Example;
| SearchBar | `components/common/SearchBar.jsx` | 搜索框 |
| 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` | 文件预览弹窗 |
---
@@ -295,6 +308,12 @@ export default Example;
<Route path="chat/:scene" element={<ChatPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/:skillId" element={<SkillDetailPage />} />
<Route path="project" element={<Navigate to="members" replace />} />
<Route path="project/members" element={<MembersPage />} />
<Route path="project/members/add" element={<AddMemberPage />} />
<Route path="project/members/:memberId/config" element={<MemberConfigPage />} />
<Route path="project/permissions" element={<PermissionsPage />} />
<Route path="project/skills" element={<SkillsConfigPage />} />
{/* ...更多子路由 */}
</Route>
@@ -402,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); // 根据文件名获取文件类型
```
---
@@ -434,4 +459,63 @@ api.logs.filter({ user, type, status });
---
*最后更新2026-03-27*
## 对话输入框布局
### 布局结构
对话输入框采用响应式水平布局,根据屏幕宽度自动调整组件排列方式。
**桌面端(>= 768px**
- 水平布局:模型选择器 → 输入区域 → 工具按钮 → 发送按钮
- 模型选择器:标准模式(显示完整模型名称,宽度 160px
- 分隔方式:右侧垂直边框
**平板端480-768px**
- 水平布局:模型选择器 → 输入区域 → 工具按钮 → 发送按钮
- 模型选择器:紧凑模式(仅显示图标,宽度 100px
- 工具按钮:上传文件、代码块
- 分隔方式:右侧垂直边框
**移动端(< 480px**
- 垂直布局:模型选择器在上,输入区域在下
- 模型选择器:标准模式(显示完整模型名称,宽度 160px
- 分隔方式:底部水平边框
### ModelSelector 组件
ModelSelector 组件支持 `variant` 属性,控制显示模式:
```jsx
<ModelSelector
selectedModel={selectedModel}
onSelectModel={setSelectedModel}
variant="standard" // "compact"
/>
```
**variant 属性值**
- `standard`:标准模式,显示完整模型名称
- `compact`:紧凑模式,仅显示图标
### 响应式断点
| 断点名称 | 屏幕宽度 | 布局模式 | 模型选择器 | 宽度 |
|---------|---------|---------|-----------|------|
| 桌面端 | >= 768px | 水平 | 标准 | 160px |
| 平板端 | 480-768px | 水平 | 紧凑 | 100px |
| 移动端 | < 480px | 垂直 | 标准 | 160px |
### 工具按钮
- 工具按钮横向排列在输入区域右侧(水平布局)或下方(垂直布局)
- 当前工具按钮:上传文件、代码块
- 工具按钮右侧显示分隔边框(水平布局)
### 下拉菜单方向
- 默认向下展开
- 如果下方空间不足,自动向上展开
---
*最后更新2026-04-10*

240
docs/模型管理.md Normal file
View File

@@ -0,0 +1,240 @@
# 模型配置管理业务设计
## 概述
模型配置管理是一个多层级模型配置系统,支持平台级、项目级、用户级三个层级的模型配置。各级配置独立管理,模型列表累加可见,默认模型按优先级链确定。
## 核心业务规则
### 模型配置层级结构
```mermaid
graph TB
subgraph Platform["平台级模型配置 (Platform Level)"]
P1[配置者: 平台管理员]
P2[可见性: 所有用户可见]
P3[用途: 作为所有用户的基础模型池]
P4[默认模型: 平台默认模型]
end
subgraph Project["项目级模型配置 (Project Level)"]
Pr1[配置者: 项目管理员]
Pr2[可见性: 仅项目成员可见]
Pr3[用途: 项目专用模型, 覆盖平台默认]
Pr4[默认模型: 项目默认模型]
end
subgraph User["用户级模型配置 (User Level)"]
U1[配置者: 用户自己]
U2[可见性: 仅自己可见]
U3[用途: 用户自定义模型, 覆盖项目/平台默认]
U4[默认模型: 个人默认模型]
end
Platform -->|继承| Project
Project -->|继承| User
```
### 权限边界
- **完全隔离**:各级别只能管理本级别的模型配置
- 平台管理员:只能管理平台级模型配置
- 项目管理员:只能管理本项目模型配置
- 普通用户:只能管理个人模型配置
- **不可干预**:平台管理员不可干预项目级或用户级模型配置
- **菜单可见性**:项目成员看不到"模型管理"菜单(仅项目管理员可见)
- **安全性**模型调用由服务端统一处理API密钥不暴露给前端
### 模型可见性规则
- **平台模型**:所有用户可见
- **项目模型**:仅项目成员可见
- **个人模型**:仅自己可见
- **跨项目隔离**项目X的模型在项目Y内不可见
- **累加模式**:用户可选模型 = 平台模型 当前项目模型 个人模型
### 模型选择规则
#### 可选模型范围
用户在项目X内
```
可选模型 = 平台模型 项目X模型 个人模型
```
#### 默认模型优先级链
新建对话时:
```
默认模型 = 个人默认 项目默认 平台默认
```
已有对话时:
```
使用模型 = 上次使用的模型
```
模型失效时:
```
回退到默认模型优先级链 → 个人默认 项目默认 平台默认
```
### 默认模型规则
- **自动设置**:新增第一个模型时,自动设为默认模型
- **删除规则**
- **平台级**:默认模型不允许直接删除,必须先将另一个模型设为默认,然后才能删除
- **项目级**:允许删除默认模型,删除后该级别没有默认模型
- **用户级**:允许删除默认模型,删除后该级别没有默认模型
- **配置数量**:各级别不限制模型配置数量
- **空列表处理**:没有任何模型时,用户可以新建对话,但对话输入框的模型下拉列表为空
### 跨项目对话隔离
对话列表按项目维度隔离:
- 在项目A的对话列表中只能看到项目A的对话
- 切换到项目B后在项目B的对话列表中只能看到项目B的对话
- 不同项目的对话互不可见
## 配置内容
### 配置字段
所有级别的模型配置包含相同的字段:
#### 基本信息
- 配置名称(必填)
- 配置类型(必填,新建时可选,编辑时只读)
- OpenAI 兼容接口
- 智算管理平台
#### API配置根据类型不同
**OpenAI 兼容接口**
- API地址必填
- API密钥必填掩码显示
- 模型名称(必填)
**智算管理平台**
- API地址必填
- App ID必填
- App Secret必填掩码显示
#### 参数配置仅OpenAI兼容接口
- Temperature0-2默认0.7
- Max Tokens1-128000默认4096
- Top P0-1默认0.9
## 附录:常见问题
### 📋 模型可见性问题
#### 1. 用户在项目内能看到哪些模型?模型可见性有哪些规则?
用户在项目X内可见的模型采用累加模式
```
可选模型 = 平台模型 项目X模型 个人模型
```
**可见性规则**
- **跨项目隔离**项目X的模型在项目Y内不可见
- **多项目差异**:用户在不同项目内看到的模型列表不同(平台模型和个人模型在所有项目可见,项目模型仅在本项目可见)
- **移出项目**:用户被移出项目后,项目模型从列表消失,但数据保留;重新加入后恢复可见
- **项目成员权限**:项目模型对所有成员可见,但只有管理员能管理,普通成员只能使用
- **模型下拉列表展示**:同名配置在模型下拉列表中会分组展示
### 🎯 默认模型问题
#### 2. 新增第一个模型时需要手动设为默认吗?
不需要。系统会自动将新增的第一个模型设为默认模型。各级别模型配置都遵循此规则:
- 平台新增第一个模型 → 自动设为平台默认
- 项目新增第一个模型 → 自动设为项目默认
- 用户新增第一个模型 → 自动设为个人默认
#### 3. 默认模型可以删除吗?
根据配置级别不同,删除规则不同:
- **平台级**:默认模型不允许直接删除,必须先将另一个模型设为默认,然后才能删除原来的默认模型
- **项目级**:允许删除默认模型,删除后该级别没有默认模型
- **用户级**:允许删除默认模型,删除后该级别没有默认模型
#### 4. 如果某个级别没有配置任何模型,会怎样?
如果某个级别没有配置任何模型,该级别的默认模型为空,系统会按优先级链继续向上查找:
- 用户没有个人模型 → 使用项目默认模型
- 项目没有项目模型 → 使用平台默认模型
- 如果平台也没有模型 → 用户可以新建对话,但对话输入框的模型下拉列表为空
#### 5. 项目管理员想更换项目默认模型,原来的默认模型会怎样?对已有对话有影响吗?
项目管理员将模型B设为新的默认模型后
- 原默认模型A变为普通模型is_default = false
- 模型B变为新的默认模型is_default = true
原默认模型A不会被删除仍然在项目模型列表中项目成员可以继续使用。
已有对话不受影响,继续使用对话创建时选择的模型。
### ⚙️ 配置管理问题
#### 6. 平台管理员能编辑或删除项目级、用户级的模型配置吗?
不能。各级模型配置权限完全隔离:
- 平台管理员:只能管理平台级模型配置
- 项目管理员:只能管理本项目模型配置
- 用户:只能管理个人模型配置
平台管理员无法查看、编辑、删除项目级或用户级的模型配置,确保各级模型配置的独立性和安全性。
#### 7. 项目管理员能看到项目成员的个人模型配置吗?
不能。个人模型配置仅用户自己可见,项目管理员无法查看或管理项目成员的个人模型。
用户在项目内使用个人模型时项目管理员只知道用户选择了某个模型但无法查看模型的配置详情如API密钥
#### 8. 编辑模型配置时,配置类型可以修改吗?
不可以。配置类型OpenAI 兼容接口 / 智算管理平台)在新建时选择,保存后不可修改。
编辑模型配置时配置类型字段为只读状态只能修改其他字段如API地址、密钥、参数等
如果需要更换配置类型,必须删除原模型配置,重新创建新模型配置。删除已被对话使用的模型配置后,该对话会失效并触发模型回退逻辑。
#### 9. 配置名称可以重复吗?
可以。配置名称仅用于展示和区分,不要求唯一性。同一个级别下可以有多个同名配置,但建议使用有区分度的名称便于管理。
系统通过配置ID唯一标识模型不依赖名称唯一性。同名配置在模型下拉列表中会分组展示。
### 🔐 安全性问题
#### 10. 项目成员能看到项目模型的API密钥吗
不能。项目成员看不到"模型管理"菜单无法访问模型配置页面因此无法查看API密钥。
只有项目管理员能查看和管理项目模型的配置详情包括API密钥且密钥在表单中默认掩码显示需要点击显示按钮才能查看明文。
#### 11. 不同级别的模型配置API密钥存储方式有区别吗
没有区别。所有级别的模型配置API密钥都存储在服务端数据库中存储方式一致安全级别一致。前端只知道模型ID从未接触API密钥。
### ❓ 其他问题
#### 12. 用户在项目X内使用项目模型D创建对话后来项目模型D被删除用户继续这个对话时会使用哪个模型
系统会自动切换到默认模型(按优先级链):
```
个人默认 → 项目X默认 → 平台默认
```
用户打开对话时系统检测到模型D已失效自动切换并提示"原模型已失效已切换为XXX"。后续新消息使用切换后的模型,历史消息保持不变。
#### 13. 用户在项目X内使用个人模型D创建对话后来用户被移出项目X个人模型D会被删除吗
不会被删除。个人模型是用户级别的模型配置跟着用户走不会因为被移出项目就被删除。用户在其他项目仍然可以使用个人模型D。
*最后更新2026-04-10*

View File

@@ -10,12 +10,13 @@
#### Scenario: 查看配置列表页
- **WHEN** 管理员进入模型配置管理页面
- **THEN** 系统展示当前生效配置卡片(包含名称、类型)
- **AND** 系统展示配置列表表格(包含名称、类型、状态、操作按钮)
- **AND** 系统展示配置列表表格(包含名称、类型、操作按钮)
#### Scenario: 区分配置状态
#### Scenario: 默认配置徽章展示
- **WHEN** 配置列表中有多个配置
- **THEN** 当前生效配置在表格中显示"生效中"状态标签
- **AND** 其他配置显示"未生效"状态标签
- **THEN** 当前生效配置在配置名称旁显示"[默认]"标签,使用 `tag tag--admin` 样式类
- **AND** 其他配置显示标签
- **AND** 配置行不使用高亮样式
### Requirement: 设为默认配置
系统 SHALL 允许管理员将非生效配置设为平台默认配置,操作需二次确认。

View File

@@ -0,0 +1,89 @@
## Purpose
定义AI消息中思考过程、工具调用和对话正文的多Block结构规范。
## ADDED Requirements
### Requirement: AI消息Block结构
AI消息气泡内 SHALL 支持多个Block的灵活组织Block类型包括thinking、tool、content三种。
#### Scenario: 单个Thinking Block
- **WHEN** AI消息包含thinking过程
- **THEN** 在消息气泡内显示thinking Block包含标题、执行时间、可展开的详细内容
#### Scenario: 单个Tool Block
- **WHEN** AI消息包含工具调用
- **THEN** 在消息气泡内显示tool Block包含工具名称、状态(input/output)
#### Scenario: 多个Block组合
- **WHEN** AI消息包含多个thinking和tool
- **THEN** 按声明顺序依次显示最后显示content
- **AND** 所有thinking和tool必须在content之前
### Requirement: 可折叠容器
Thinking和Tool Block SHALL 使用统一的可折叠容器样式。
#### Scenario: 展开状态
- **WHEN** 用户点击Block头部
- **THEN** 显示详细内容区域
#### Scenario: 收起状态
- **WHEN** Block初始状态为收起
- **THEN** 只显示头部标题和状态,点击后展开
#### Scenario: Thinking默认展开
- **WHEN** 渲染thinking Block
- **THEN** 默认展开显示内容
#### Scenario: Tool默认收起
- **WHEN** 渲染tool Block
- **THEN** 默认收起,只显示头部
### Requirement: 工具调用显示
Tool Block SHALL 清晰展示input和output内容。
#### Scenario: 显示输入参数
- **WHEN** tool Block展开
- **THEN** 显示INPUT区域包含JSON格式化的调用参数
#### Scenario: 显示返回结果
- **WHEN** tool执行完成
- **THEN** 显示OUTPUT区域包含JSON格式化的返回值
#### Scenario: 执行状态
- **WHEN** 渲染tool Block
- **THEN** 显示执行状态running(运行中)/completed(完成)/error(失败)
- **AND** running状态带脉冲动画
### Requirement: 时间显示
时间显示在Block头部的status区域中。
#### Scenario: Block执行时间
- **WHEN** 渲染thinking或tool Block
- **THEN** 在头部status区域显示执行耗时如"2.1s"
#### Scenario: Content时间
- **WHEN** 渲染content Block
- **THEN** 在Block底部显示消息发送时间如"14:31"
### Requirement: 消息气泡样式
AI消息气泡 SHALL 使用毛玻璃效果和精细阴影。
#### Scenario: 毛玻璃效果
- **WHEN** 渲染AI消息气泡
- **THEN** 使用backdrop-filter: blur(12px)实现毛玻璃效果
#### Scenario: 精细阴影
- **WHEN** 渲染AI消息气泡
- **THEN** 使用多层阴影实现精细立体效果
### Requirement: 场景数据
消息场景数据 SHALL 符合新的Block结构。
#### Scenario: 删除File场景
- **WHEN** 系统加载场景列表
- **THEN** 不包含file场景
#### Scenario: 场景消息结构
- **WHEN** 渲染场景消息
- **THEN** 使用blocks结构组织thinking、tool、content

View File

@@ -40,12 +40,17 @@
- **AND** 表格包含以下列:
- Key 输入框
- Value 输入框
- 删除按钮(×
- 操作列(包含"删除"文字按钮
#### Scenario: 空配置状态
- **WHEN** 用户尚未添加任何配置变量
- **THEN** 系统显示空表格或提示信息
#### Scenario: 操作列宽度
- **WHEN** 配置表格渲染完成
- **THEN** 操作列使用 `col-actions--narrow` 类名
- **AND** 操作列宽度为 120px
### Requirement: 新增配置项
系统 SHALL 允许用户新增配置项。
@@ -56,10 +61,10 @@
- **AND** Key 输入框自动获得焦点
### Requirement: 删除配置项
系统 SHALL 允许用户删除配置项。
系统 SHALL 允许用户删除配置项,使用文字删除按钮而非图标按钮
#### Scenario: 删除配置项
- **WHEN** 用户点击某行的删除按钮×
- **WHEN** 用户点击某行的"删除"文字按钮
- **THEN** 系统从表格中移除该行
- **AND** 不需要确认
@@ -68,6 +73,12 @@
- **THEN** 系统允许删除操作
- **AND** 表格变为空状态
#### Scenario: 删除按钮样式
- **WHEN** 配置表格渲染完成
- **THEN** 删除按钮显示为文字按钮
- **AND** 使用 `text-btn text-btn-danger` 样式
- **AND** 按钮文本为"删除"
### Requirement: 配置输入校验
系统 SHALL 对配置输入进行校验,确保 Key 和 Value 不能为空。

View 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 提示"该文件类型不支持预览",不打开预览弹框

View 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

View 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)),与预览面板分隔

View File

@@ -182,3 +182,33 @@
#### Scenario: 页面样式文件内容结构
- **WHEN** 查看页面样式文件
- **THEN** 该文件包含页面特定的布局、组件、状态等样式,使用清晰的注释分节
### Requirement: 可展开导航组组件
系统 SHALL 提供可展开的导航组组件 `SidebarNavGroup`,用于组织多个相关导航项。
#### Scenario: 导航组基本结构
- **WHEN** 开发者需要创建可展开的导航组
- **THEN** 系统 SHALL 提供 `SidebarNavGroup` 组件
- **AND** 组件接受 `icon``label``children` 属性
- **AND** 组件内部使用 `SidebarNavItem` 渲染子菜单项
#### Scenario: 导航组头部交互
- **WHEN** 用户点击导航组头部
- **THEN** 系统切换展开/收起状态
- **AND** 头部显示展开/收起箭头图标
#### Scenario: 导航组样式
- **WHEN** 导航组渲染时
- **THEN** 系统 SHALL 提供以下 BEM 类名:
- `.nav-group` 容器类
- `.nav-group__header` 头部类
- `.nav-group__icon` 图标类
- `.nav-group__label` 标签类
- `.nav-group__arrow` 箭头类
- `.nav-group__children` 子菜单容器类
- `.nav-group--expanded` 展开状态修饰符
#### Scenario: 子菜单项缩进
- **WHEN** 导航组展开显示子菜单
- **THEN** 子菜单项相对于父级有左侧缩进
- **AND** 子菜单项使用 `SidebarNavItem` 组件渲染

View File

@@ -0,0 +1,184 @@
## ADDED Requirements
### Requirement: 独立式模型选择器触发器
系统 SHALL 在对话输入框的左侧集成模型选择器触发器,显示当前选中的模型名称。
#### Scenario: 显示模型选择器触发器
- **WHEN** 用户访问聊天页面
- **THEN** 系统在聊天输入框左侧显示模型选择器触发器
- **THEN** 触发器显示模型图标、模型名称和下拉箭头
- **THEN** 触发器作为独立组件显示在输入框左侧
#### Scenario: 显示默认标记
- **WHEN** 当前选中的模型是默认模型(`isDefault = true`
- **THEN** 触发器在模型名称后显示"默认"标签
- **THEN** 标签使用渐变背景色,视觉突出
#### Scenario: 隐藏默认标记
- **WHEN** 当前选中的模型不是默认模型
- **THEN** 触发器不显示"默认"标签
### Requirement: 展开模型选择器下拉列表
系统 SHALL 在用户点击触发器时展开模型选择器下拉列表,按层级分组展示模型(平台模型/项目模型/个人模型)。
#### Scenario: 展开下拉列表
- **WHEN** 用户点击模型选择器触发器
- **THEN** 系统展开下拉列表,显示在触发器下方
- **THEN** 下拉列表按层级分组:平台模型、项目模型、个人模型
- **THEN** 每个分组显示分组标题和该层级的模型列表
- **THEN** 展开时有平滑的动画效果
#### Scenario: 默认向下展开
- **WHEN** 用户点击模型选择器触发器且下方空间充足(> 350px
- **THEN** 下拉列表向下展开
- **THEN** 下拉列表显示在触发器下方
#### Scenario: 向上展开
- **WHEN** 触发器下方空间不足以显示完整下拉列表(< 350px且上方空间充足
- **THEN** 下拉列表向上展开
- **THEN** 下拉列表显示在触发器上方
#### Scenario: 分组标题显示
- **WHEN** 下拉列表展开
- **THEN** 平台模型分组显示标题"📍 平台模型"
- **THEN** 项目模型分组显示标题"📍 项目模型"
- **THEN** 个人模型分组显示标题"📍 个人模型"
- **THEN** 分组标题使用小号字体和浅色文字
#### Scenario: 模型列表项显示
- **WHEN** 下拉列表展开
- **THEN** 每个模型显示模型图标、模型名称和默认标记(如果适用)
- **THEN** 选中模型使用浅蓝色背景和左侧边框高亮显示
- **THEN** 选中模型右侧显示勾选图标
#### Scenario: 鼠标悬停效果
- **WHEN** 用户鼠标悬停在某个模型上
- **THEN** 系统显示浅蓝色背景
### Requirement: 选择模型
系统 SHALL 允许用户从下拉列表中选择模型,更新当前选中状态并收起下拉列表。
#### Scenario: 选择非默认模型
- **WHEN** 用户点击下拉列表中的某个模型
- **THEN** 系统更新当前选中模型状态
- **THEN** 触发器显示新选中的模型名称
- **THEN** 触发器移除"默认"标签(如果新模型不是默认)
- **THEN** 下拉列表收起
#### Scenario: 选择默认模型
- **WHEN** 用户点击下拉列表中的某个默认模型
- **THEN** 系统更新当前选中模型状态
- **THEN** 触发器显示新选中的模型名称
- **THEN** 触发器显示"默认"标签
- **THEN** 下拉列表收起
#### Scenario: 模型列表中的默认标记
- **WHEN** 某个模型是默认模型(`isDefault = true`
- **THEN** 下拉列表中的该模型显示"默认"标签
- **THEN** 标签使用渐变背景色,视觉突出
### Requirement: 收起模型选择器下拉列表
系统 SHALL 在用户点击触发器或选择模型后收起下拉列表。
#### Scenario: 再次点击触发器收起
- **WHEN** 用户再次点击已展开的触发器
- **THEN** 系统收起下拉列表
#### Scenario: 选择模型后自动收起
- **WHEN** 用户从下拉列表中选择模型
- **THEN** 系统自动收起下拉列表
#### Scenario: 点击外部区域收起
- **WHEN** 用户点击下拉列表外部的区域
- **THEN** 系统收起下拉列表(如果当前是展开状态)
### Requirement: 模型选择器样式
系统 SHALL 支持标准显示和紧凑显示两种模式,在水平布局中与输入框风格协调。
#### Scenario: 标准显示模式
- **WHEN** 模型选择器使用标准模式(桌面端)
- **THEN** 触发器使用最小宽度 160px
- **THEN** 触发器右侧显示垂直分隔边框
- **THEN** 触发器和输入框形成水平排列
#### Scenario: 紧凑显示模式
- **WHEN** 模型选择器使用紧凑模式(平板端)
- **THEN** 触发器使用最小宽度 120px
- **THEN** 触发器显示为图标型(仅显示模型图标,隐藏模型名称)
- **THEN** 触发器右侧显示垂直分隔边框
#### Scenario: 响应式设计
- **WHEN** 用户使用桌面设备访问(屏幕宽度 >= 768px
- **THEN** 模型选择器使用标准显示模式
- **THEN** 触发器显示模型图标和完整模型名称
- **THEN** 触发器宽度为 160px
- **WHEN** 用户使用平板设备访问(屏幕宽度 480-768px
- **THEN** 模型选择器使用紧凑显示模式
- **THEN** 触发器显示为图标型(隐藏模型名称)
- **THEN** 触发器宽度为 100px
- **WHEN** 用户使用移动设备访问(屏幕宽度 < 480px
- **THEN** 模型选择器回退到垂直布局
- **THEN** 触发器位于输入框上方
- **THEN** 触发器显示模型图标和完整模型名称
- **THEN** 触发器宽度为 160px
### Requirement: 紧凑型模型选择器
系统 SHALL 支持紧凑型显示模式,通过 `variant` 属性控制。
#### Scenario: 启用紧凑模式
- **WHEN** 模型选择器组件接收 `variant="compact"` 属性
- **THEN** 触发器应用紧凑型样式
- **THEN** 触发器最小宽度为 120px
- **THEN** 触发器显示模型图标和下拉箭头,隐藏模型名称
#### Scenario: 紧凑模式下的下拉展开
- **WHEN** 用户点击紧凑型触发器
- **THEN** 系统展开下拉列表
- **THEN** 下拉列表使用固定定位position: fixed
- **THEN** 下拉列表根据触发器位置和可用空间自动调整展开方向
### Requirement: 水平布局输入框
系统 SHALL 支持水平布局的对话输入框,模型选择器位于输入框左侧。
#### Scenario: 桌面端水平布局
- **WHEN** 用户在桌面端访问(屏幕宽度 > 768px
- **THEN** 输入框容器使用水平 flex 布局
- **THEN** 从左到右依次显示:模型选择器、输入区域、工具按钮组、发送按钮
- **THEN** 模型选择器和输入框之间显示垂直分隔边框
#### Scenario: 平板端水平布局
- **WHEN** 用户在平板端访问(屏幕宽度 480-768px
- **THEN** 输入框容器使用水平 flex 布局
- **THEN** 从左到右依次显示:紧凑型模型选择器、输入区域、工具按钮组、发送按钮
- **THEN** 工具按钮组限制最多显示 4 个按钮
- **THEN** 紧凑型模型选择器和输入框之间显示垂直分隔边框
#### Scenario: 移动端垂直布局
- **WHEN** 用户在移动端访问(屏幕宽度 < 480px
- **THEN** 输入框容器使用垂直 flex 布局
- **THEN** 从上到下依次显示:模型选择器、输入区域、工具按钮组、发送按钮
- **THEN** 模型选择器和输入框之间显示水平分隔边框
### Requirement: 工具按钮优化
系统 SHALL 在水平布局中优化工具按钮的显示方式。
#### Scenario: 工具按钮横向排列
- **WHEN** 输入框使用水平布局(桌面端/平板端)
- **THEN** 工具按钮横向排列在输入区域右侧
- **THEN** 工具按钮限制最多显示 4 个
- **THEN** 超出 4 个的按钮不显示
#### Scenario: 工具按钮纵向排列
- **WHEN** 输入框使用垂直布局(移动端)
- **THEN** 工具按钮纵向排列在输入区域下方
- **THEN** 工具按钮横向排列,限制最多显示 4 个

View File

@@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: 展示平台级模型配置列表
系统 SHALL 在管理台的模型配置页面展示所有平台级模型配置的列表,每个配置项显示配置名称、配置类型、状态和操作按钮。
#### Scenario: 查看配置列表
- **WHEN** 用户访问 `/admin/models` 页面
- **THEN** 系统显示所有平台级模型配置的表格
- **THEN** 每个配置项显示配置名称、配置类型、状态(生效中/未生效)和操作按钮
- **THEN** 表格头部显示"新增配置"按钮
#### Scenario: 生效中的配置高亮显示
- **WHEN** 配置的 `isActive` 字段为 `true`
- **THEN** 配置项在表格中高亮显示(使用背景色或边框)
#### Scenario: 配置类型显示
- **WHEN** 配置类型为 `basic`
- **THEN** 配置类型列显示"OpenAI 兼容接口"
- **WHEN** 配置类型为 `zhisuan`
- **THEN** 配置类型列显示"智算管理平台"
### Requirement: 新增平台级模型配置
系统 SHALL 允许平台管理员创建新的平台级模型配置配置包含基本信息、API 配置和参数配置(根据类型不同)。
#### Scenario: 打开新增配置页面
- **WHEN** 用户点击"新增配置"按钮
- **THEN** 系统导航到 `/admin/models/add` 页面
- **THEN** 页面显示配置名称、配置类型、API 配置和参数配置表单
#### Scenario: 选择配置类型
- **WHEN** 用户在新增配置页面选择配置类型
- **THEN** 系统根据选择的类型显示相应的配置字段
- **WHEN** 用户选择"OpenAI 兼容接口"
- **THEN** 系统显示 API 地址、API 密钥、模型名称、Temperature、Max Tokens、Top P 字段
- **WHEN** 用户选择"智算管理平台"
- **THEN** 系统显示 API 地址、App ID、App Secret 字段
#### Scenario: 保存新配置
- **WHEN** 用户填写完整配置信息并点击"保存"按钮
- **THEN** 系统验证表单数据
- **WHEN** 表单验证通过
- **THEN** 系统创建新配置并导航到配置列表页面
- **THEN** 如果这是第一个配置,系统自动将其设为默认模型(`isActive = true`
### Requirement: 编辑平台级模型配置
系统 SHALL 允许平台管理员编辑已存在的平台级模型配置,但配置类型字段为只读。
#### Scenario: 打开编辑配置页面
- **WHEN** 用户点击某个配置的"编辑"按钮
- **THEN** 系统导航到 `/admin/models/:id/edit` 页面
- **THEN** 页面预填充该配置的所有信息
- **THEN** 配置类型字段为只读状态,显示"配置类型不可修改"提示
#### Scenario: 保存编辑的配置
- **WHEN** 用户修改配置信息并点击"保存"按钮
- **THEN** 系统更新配置并导航到配置列表页面
### Requirement: 删除平台级模型配置
系统 SHALL 允许平台管理员删除非默认的平台级模型配置,默认模型不允许删除。
#### Scenario: 删除非默认配置
- **WHEN** 用户点击某个非默认配置的"删除"按钮
- **THEN** 系统显示删除确认弹窗
- **WHEN** 用户确认删除
- **THEN** 系统删除该配置并刷新配置列表
#### Scenario: 删除默认配置被禁用
- **WHEN** 配置为默认模型(`isActive = true`
- **THEN** 系统禁用"删除"按钮
- **THEN** 鼠标悬停时显示提示"平台默认模型不允许删除,请先切换默认模型"
### Requirement: 设为平台级默认模型
系统 SHALL 允许平台管理员将某个非默认配置设为平台级默认模型,更新页面状态但不修改实际数据。
#### Scenario: 设为默认配置
- **WHEN** 用户点击某个非默认配置的"设为默认"按钮
- **THEN** 系统显示确认弹窗"确定将'XXX'设为平台默认模型配置吗?切换后,原生效配置将变为备用状态。"
- **WHEN** 用户确认切换
- **THEN** 系统更新页面状态,将原默认配置设为非默认,将目标配置设为默认
- **THEN** 系统不修改实际数据文件(原型行为)
#### Scenario: 默认配置不显示设为默认按钮
- **WHEN** 配置已经是默认模型(`isActive = true`
- **THEN** 系统不显示"设为默认"按钮

View File

@@ -0,0 +1,53 @@
# Capability: 项目管理导航
## Purpose
项目管理导航提供工作台侧边栏中项目管理功能的下拉导航组,包含成员管理、权限配置、技能配置等子菜单入口,支持 URL 驱动的展开状态。
## Requirements
### Requirement: 项目管理下拉导航组
系统 SHALL 提供可展开的项目管理导航组,包含成员管理、权限配置、技能配置三个子菜单入口。
#### Scenario: 默认收起状态
- **WHEN** 用户访问非项目管理相关页面(如聊天、技能市场)
- **THEN** 项目管理导航组显示为收起状态
- **AND** 仅显示项目管理的图标和标签
#### Scenario: URL 驱动自动展开
- **WHEN** 用户访问 `/console/project/members``/console/project/permissions``/console/project/skills` 及其子路径
- **THEN** 项目管理导航组自动展开
- **AND** 显示三个子菜单项:成员管理、权限配置、技能配置
- **AND** 当前访问的子菜单项高亮
#### Scenario: 点击头部展开收起
- **WHEN** 用户点击项目管理导航组的头部区域
- **THEN** 切换展开/收起状态
#### Scenario: 子菜单项导航
- **WHEN** 用户点击"成员管理"子菜单项
- **THEN** 导航至 `/console/project/members`
- **WHEN** 用户点击"权限配置"子菜单项
- **THEN** 导航至 `/console/project/permissions`
- **WHEN** 用户点击"技能配置"子菜单项
- **THEN** 导航至 `/console/project/skills`
#### Scenario: 页面刷新保持展开状态
- **WHEN** 用户在项目管理子页面刷新浏览器
- **THEN** 项目管理导航组保持展开状态
- **AND** 当前子菜单项保持高亮
### Requirement: 项目管理子菜单高亮
系统 SHALL 在导航组中高亮当前访问的子菜单项。
#### Scenario: 成员管理高亮
- **WHEN** 用户访问 `/console/project/members` 或其子路径(如 `/console/project/members/add`
- **THEN** "成员管理"子菜单项显示激活状态
#### Scenario: 权限配置高亮
- **WHEN** 用户访问 `/console/project/permissions`
- **THEN** "权限配置"子菜单项显示激活状态
#### Scenario: 技能配置高亮
- **WHEN** 用户访问 `/console/project/skills`
- **THEN** "技能配置"子菜单项显示激活状态

View File

@@ -0,0 +1,87 @@
## ADDED Requirements
### Requirement: 展示项目级模型配置列表
系统 SHALL 在工作台的项目管理二级菜单中的模型配置页面展示所有项目级模型配置的列表,每个配置项显示配置名称、配置类型、状态和操作按钮。
#### Scenario: 查看配置列表
- **WHEN** 用户通过工作台项目管理菜单访问 `/console/project/models` 页面
- **THEN** 系统显示所有项目级模型配置的表格
- **THEN** 每个配置项显示配置名称、配置类型和操作按钮
- **THEN** 表格头部显示"新增配置"按钮
#### Scenario: 默认配置徽章展示
- **WHEN** 配置列表中有多个配置
- **THEN** 当前生效配置在配置名称旁显示"[默认]"标签,使用 `tag tag--admin` 样式类
- **AND** 其他配置不显示标签
- **AND** 配置行不使用高亮样式
### Requirement: 新增项目级模型配置
系统 SHALL 允许项目管理员创建新的项目级模型配置配置包含基本信息、API 配置和参数配置(根据类型不同)。
#### Scenario: 打开新增配置页面
- **WHEN** 用户点击"新增配置"按钮
- **THEN** 系统导航到 `/console/project/models/add` 页面
- **THEN** 页面显示配置名称、配置类型、API 配置和参数配置表单
#### Scenario: 选择配置类型
- **WHEN** 用户在新增配置页面选择配置类型
- **THEN** 系统根据选择的类型显示相应的配置字段
- **WHEN** 用户选择"OpenAI 兼容接口"
- **THEN** 系统显示 API 地址、API 密钥、模型名称、Temperature、Max Tokens、Top P 字段
- **WHEN** 用户选择"智算管理平台"
- **THEN** 系统显示 API 地址、App ID、App Secret 字段
#### Scenario: 保存新配置
- **WHEN** 用户填写完整配置信息并点击"保存"按钮
- **THEN** 系统验证表单数据
- **WHEN** 表单验证通过
- **THEN** 系统创建新配置并导航到配置列表页面
- **THEN** 如果这是第一个配置,系统自动将其设为默认模型(`isActive = true`
### Requirement: 编辑项目级模型配置
系统 SHALL 允许项目管理员编辑已存在的项目级模型配置,但配置类型字段为只读。
#### Scenario: 打开编辑配置页面
- **WHEN** 用户点击某个配置的"编辑"按钮
- **THEN** 系统导航到 `/console/project/models/:id/edit` 页面
- **THEN** 页面预填充该配置的所有信息
- **THEN** 配置类型字段为只读状态,显示"配置类型不可修改"提示
#### Scenario: 保存编辑的配置
- **WHEN** 用户修改配置信息并点击"保存"按钮
- **THEN** 系统更新配置并导航到配置列表页面
### Requirement: 删除项目级模型配置
系统 SHALL 允许项目管理员删除任何项目级模型配置,包括默认模型。
#### Scenario: 删除默认配置
- **WHEN** 用户点击某个默认配置的"删除"按钮
- **THEN** 系统显示删除确认弹窗
- **WHEN** 用户确认删除
- **THEN** 系统删除该配置并刷新配置列表
- **THEN** 删除后该级别没有默认模型
#### Scenario: 删除非默认配置
- **WHEN** 用户点击某个非默认配置的"删除"按钮
- **THEN** 系统显示删除确认弹窗
- **WHEN** 用户确认删除
- **THEN** 系统删除该配置并刷新配置列表
### Requirement: 设为项目级默认模型
系统 SHALL 允许项目管理员将某个非默认配置设为项目级默认模型,更新页面状态但不修改实际数据。
#### Scenario: 设为默认配置
- **WHEN** 用户点击某个非默认配置的"设为默认"按钮
- **THEN** 系统显示确认弹窗
- **WHEN** 用户确认切换
- **THEN** 系统更新页面状态,将原默认配置设为非默认,将目标配置设为默认
- **THEN** 系统不修改实际数据文件(原型行为)
#### Scenario: 默认配置不显示设为默认按钮
- **WHEN** 配置已经是默认模型(`isActive = true`
- **THEN** 系统不显示"设为默认"按钮

View File

@@ -0,0 +1,110 @@
# Capability: responsive-chat-input
## Purpose
系统 SHALL 支持响应式对话输入框布局,根据屏幕宽度自动切换水平/垂直布局模式,优化在不同设备上的用户体验。
## ADDED Requirements
### Requirement: 响应式布局切换
系统 SHALL 根据屏幕宽度自动切换对话输入框的布局模式。
#### Scenario: 桌面端水平布局
- **WHEN** 用户在桌面端访问(屏幕宽度 >= 768px
- **THEN** 系统应用水平布局模式
- **THEN** 输入框容器使用 `flex-direction: row`
- **THEN** 组件从左到右依次排列:模型选择器、输入区域、工具按钮组、发送按钮
#### Scenario: 平板端水平布局
- **WHEN** 用户在平板端访问(屏幕宽度 480-768px
- **THEN** 系统应用水平布局模式
- **THEN** 输入框容器使用 `flex-direction: row`
- **THEN** 模型选择器使用紧凑型显示
- **THEN** 工具按钮组显示当前可用的工具按钮
#### Scenario: 移动端垂直布局
- **WHEN** 用户在移动端访问(屏幕宽度 < 480px
- **THEN** 系统应用垂直布局模式
- **THEN** 输入框容器使用 `flex-direction: column`
- **THEN** 组件从上到下依次排列:模型选择器、输入区域、工具按钮组、发送按钮
- **THEN** 模型选择器使用标准显示模式
### Requirement: 模型选择器布局适配
系统 SHALL 在不同布局模式下调整模型选择器的显示样式。
#### Scenario: 水平布局中的模型选择器
- **WHEN** 输入框使用水平布局(桌面端/平板端)
- **THEN** 模型选择器位于输入框左侧
- **THEN** 模型选择器右侧显示垂直分隔边框1px solid
- **THEN** 桌面端(>= 768px使用标准显示模式宽度 160px
- **THEN** 平板端480-768px使用紧凑显示模式宽度 100px图标型
#### Scenario: 垂直布局中的模型选择器
- **WHEN** 输入框使用垂直布局(移动端)
- **THEN** 模型选择器位于输入框上方
- **THEN** 模型选择器底部显示水平分隔边框1px solid
- **THEN** 使用标准显示模式(显示完整模型名称)
### Requirement: 输入区域布局适配
系统 SHALL 在不同布局模式下调整输入区域的尺寸和样式。
#### Scenario: 水平布局中的输入区域
- **WHEN** 输入框使用水平布局(桌面端/平板端)
- **THEN** 输入区域占据剩余水平空间(`flex: 1`
- **THEN** 输入区域高度固定或使用自动高度
#### Scenario: 垂直布局中的输入区域
- **WHEN** 输入框使用垂直布局(移动端)
- **THEN** 输入区域占据垂直空间,高度自适应内容
- **THEN** 输入区域宽度占满容器宽度
### Requirement: 工具按钮组布局适配
系统 SHALL 在不同布局模式下调整工具按钮组的排列方式。
#### Scenario: 水平布局中的工具按钮
- **WHEN** 输入框使用水平布局(桌面端/平板端)
- **THEN** 工具按钮组横向排列在输入区域右侧
- **THEN** 工具按钮包括:上传文件、代码块
- **THEN** 每个按钮使用图标型显示
- **THEN** 工具按钮右侧使用垂直分隔边框1px solid
#### Scenario: 垂直布局中的工具按钮
- **WHEN** 输入框使用垂直布局(移动端)
- **THEN** 工具按钮组横向排列在输入区域下方
- **THEN** 工具按钮包括:上传文件、代码块
- **THEN** 每个按钮使用图标型显示
- **THEN** 工具按钮组不显示右侧分隔边框
### Requirement: 发送按钮布局适配
系统 SHALL 在不同布局模式下调整发送按钮的位置和样式。
#### Scenario: 水平布局中的发送按钮
- **WHEN** 输入框使用水平布局(桌面端/平板端)
- **THEN** 发送按钮位于工具按钮组右侧
- **THEN** 发送按钮与工具按钮组之间保持适当间距
#### Scenario: 垂直布局中的发送按钮
- **WHEN** 输入框使用垂直布局(移动端)
- **THEN** 发送按钮位于工具按钮组下方或与工具按钮组并排
- **THEN** 发送按钮占据合适宽度
### Requirement: 视觉分隔适配
系统 SHALL 在不同布局模式下使用适当的视觉分隔方式。
#### Scenario: 水平布局中的垂直分隔
- **WHEN** 输入框使用水平布局
- **THEN** 模型选择器和输入框之间显示垂直分隔边框
- **THEN** 分隔边框颜色与输入框边框一致
- **THEN** 分隔边框宽度为 1px
#### Scenario: 垂直布局中的水平分隔
- **WHEN** 输入框使用垂直布局
- **THEN** 模型选择器和输入框之间显示水平分隔边框
- **THEN** 分隔边框颜色与输入框边框一致
- **THEN** 分隔边框宽度为 1px

View File

@@ -0,0 +1,87 @@
## ADDED Requirements
### Requirement: 展示个人模型配置列表
系统 SHALL 在工作台侧边栏的个人模型配置页面展示所有个人模型配置的列表,每个配置项显示配置名称、配置类型、状态和操作按钮。
#### Scenario: 查看配置列表
- **WHEN** 用户通过工作台侧边栏访问 `/console/user-models` 页面
- **THEN** 系统显示所有个人模型配置的表格
- **THEN** 每个配置项显示配置名称、配置类型和操作按钮
- **THEN** 表格头部显示"新增配置"按钮
#### Scenario: 默认配置徽章展示
- **WHEN** 配置列表中有多个配置
- **THEN** 当前生效配置在配置名称旁显示"[默认]"标签,使用 `tag tag--admin` 样式类
- **AND** 其他配置不显示标签
- **AND** 配置行不使用高亮样式
### Requirement: 新增个人模型配置
系统 SHALL 允许用户创建新的个人模型配置配置包含基本信息、API 配置和参数配置(根据类型不同)。
#### Scenario: 打开新增配置页面
- **WHEN** 用户点击"新增配置"按钮
- **THEN** 系统导航到 `/console/user-models/add` 页面
- **THEN** 页面显示配置名称、配置类型、API 配置和参数配置表单
#### Scenario: 选择配置类型
- **WHEN** 用户在新增配置页面选择配置类型
- **THEN** 系统根据选择的类型显示相应的配置字段
- **WHEN** 用户选择"OpenAI 兼容接口"
- **THEN** 系统显示 API 地址、API 密钥、模型名称、Temperature、Max Tokens、Top P 字段
- **WHEN** 用户选择"智算管理平台"
- **THEN** 系统显示 API 地址、App ID、App Secret 字段
#### Scenario: 保存新配置
- **WHEN** 用户填写完整配置信息并点击"保存"按钮
- **THEN** 系统验证表单数据
- **WHEN** 表单验证通过
- **THEN** 系统创建新配置并导航到配置列表页面
- **THEN** 如果这是第一个配置,系统自动将其设为默认模型(`isActive = true`
### Requirement: 编辑个人模型配置
系统 SHALL 允许用户编辑已存在的个人模型配置,但配置类型字段为只读。
#### Scenario: 打开编辑配置页面
- **WHEN** 用户点击某个配置的"编辑"按钮
- **THEN** 系统导航到 `/console/user-models/:id/edit` 页面
- **THEN** 页面预填充该配置的所有信息
- **THEN** 配置类型字段为只读状态,显示"配置类型不可修改"提示
#### Scenario: 保存编辑的配置
- **WHEN** 用户修改配置信息并点击"保存"按钮
- **THEN** 系统更新配置并导航到配置列表页面
### Requirement: 删除个人模型配置
系统 SHALL 允许用户删除任何个人模型配置,包括默认模型。
#### Scenario: 删除默认配置
- **WHEN** 用户点击某个默认配置的"删除"按钮
- **THEN** 系统显示删除确认弹窗
- **WHEN** 用户确认删除
- **THEN** 系统删除该配置并刷新配置列表
- **THEN** 删除后该级别没有默认模型
#### Scenario: 删除非默认配置
- **WHEN** 用户点击某个非默认配置的"删除"按钮
- **THEN** 系统显示删除确认弹窗
- **WHEN** 用户确认删除
- **THEN** 系统删除该配置并刷新配置列表
### Requirement: 设为个人默认模型
系统 SHALL 允许用户将某个非默认配置设为个人默认模型,更新页面状态但不修改实际数据。
#### Scenario: 设为默认配置
- **WHEN** 用户点击某个非默认配置的"设为默认"按钮
- **THEN** 系统显示确认弹窗
- **WHEN** 用户确认切换
- **THEN** 系统更新页面状态,将原默认配置设为非默认,将目标配置设为默认
- **THEN** 系统不修改实际数据文件(原型行为)
#### Scenario: 默认配置不显示设为默认按钮
- **WHEN** 配置已经是默认模型(`isActive = true`
- **THEN** 系统不显示"设为默认"按钮

View 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.5hover 时 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** 侧边栏宽度不再增加,保证对话区域可用性

View File

@@ -16,11 +16,17 @@ import SkillConfigPage from './pages/console/SkillConfigPage.jsx';
import LogsPage from './pages/console/LogsPage.jsx';
import TasksPage from './pages/console/TasksPage.jsx';
import TaskDetailPage from './pages/console/TaskDetailPage.jsx';
import ProjectsPage from './pages/console/ProjectsPage.jsx';
import MembersPage from './pages/console/MembersPage.jsx';
import MemberConfigPage from './pages/console/MemberConfigPage.jsx';
import AddMemberPage from './pages/console/AddMemberPage.jsx';
import PermissionsPage from './pages/console/PermissionsPage.jsx';
import SkillsConfigPage from './pages/console/SkillsConfigPage.jsx';
import ConsoleReviewListPage from './pages/console/ConsoleReviewListPage.jsx';
import ConsoleReviewDetailPage from './pages/console/ConsoleReviewDetailPage.jsx';
import ProjectModelConfigsPage from './pages/console/ProjectModelConfigsPage.jsx';
import AddProjectModelConfigPage from './pages/console/AddProjectModelConfigPage.jsx';
import UserModelConfigsPage from './pages/console/UserModelConfigsPage.jsx';
import AddUserModelConfigPage from './pages/console/AddUserModelConfigPage.jsx';
// Admin 子页面
import OverviewPage from './pages/admin/OverviewPage.jsx';
@@ -63,9 +69,18 @@ function App() {
<Route path="logs" element={<LogsPage />} />
<Route path="tasks" element={<TasksPage />} />
<Route path="tasks/:taskId" element={<TaskDetailPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/members/add" element={<AddMemberPage />} />
<Route path="projects/members/:memberId/config" element={<MemberConfigPage />} />
<Route path="project" element={<Navigate to="members" replace />} />
<Route path="project/members" element={<MembersPage />} />
<Route path="project/members/add" element={<AddMemberPage />} />
<Route path="project/members/:memberId/config" element={<MemberConfigPage />} />
<Route path="project/permissions" element={<PermissionsPage />} />
<Route path="project/skills" element={<SkillsConfigPage />} />
<Route path="project/models" element={<ProjectModelConfigsPage />} />
<Route path="project/models/add" element={<AddProjectModelConfigPage />} />
<Route path="project/models/:id/edit" element={<AddProjectModelConfigPage />} />
<Route path="user-models" element={<UserModelConfigsPage />} />
<Route path="user-models/add" element={<AddUserModelConfigPage />} />
<Route path="user-models/:id/edit" element={<AddUserModelConfigPage />} />
</Route>
<Route path="/admin" element={<AdminLayout />}>

View File

@@ -27,7 +27,7 @@ function Modal({
</div>
{showConfirm && (
<div className="modal-footer">
<button className="btn" onClick={onCancel}>{cancelText}</button>
{cancelText && <button className="btn" onClick={onCancel}>{cancelText}</button>}
<button className="btn btn-primary" onClick={onConfirm}>{confirmText}</button>
</div>
)}

View File

@@ -1,5 +1,5 @@
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { FiHome, FiBarChart2, FiUsers, FiList, FiCheckCircle, FiActivity, FiSettings } from 'react-icons/fi';
import { FiHome, FiBarChart2, FiUsers, FiList, FiCheckCircle, FiActivity, FiSettings, FiCpu } from 'react-icons/fi';
import Layout from '../Layout.jsx';
import SidebarNavItem from './SidebarNavItem.jsx';
@@ -58,19 +58,19 @@ function AdminLayout() {
textClassName="admin-nav-text"
/>
<SidebarNavItem
icon={<FiActivity />}
label="日志查询"
active={location.pathname === '/admin/logs'}
onClick={() => navigate('/admin/logs')}
icon={<FiCpu />}
label="模型配置"
active={isPathActive('/admin/models')}
onClick={() => navigate('/admin/models')}
itemClassName="admin-nav-item"
iconClassName="admin-nav-icon"
textClassName="admin-nav-text"
/>
<SidebarNavItem
icon={<FiSettings />}
label="模型配置"
active={isPathActive('/admin/models')}
onClick={() => navigate('/admin/models')}
icon={<FiActivity />}
label="日志查询"
active={location.pathname === '/admin/logs'}
onClick={() => navigate('/admin/logs')}
itemClassName="admin-nav-item"
iconClassName="admin-nav-icon"
textClassName="admin-nav-text"

View File

@@ -1,10 +1,11 @@
import { useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { FiPlus, FiClock, FiList, FiUsers, FiBox } from 'react-icons/fi';
import { FiPlus, FiClock, FiList, FiUsers, FiBox, FiFolder, FiShield, FiSettings, FiCpu } from 'react-icons/fi';
import { FiTrash2 } from 'react-icons/fi';
import { FaPuzzlePiece } from 'react-icons/fa';
import Layout from '../Layout.jsx';
import SidebarNavItem from './SidebarNavItem.jsx';
import SidebarNavGroup from './SidebarNavGroup.jsx';
import Modal from '../common/Modal.jsx';
import api from '../../services/api.js';
@@ -89,6 +90,12 @@ function ConsoleLayout() {
active={isPathActive('/console/my-skills')}
onClick={() => navigate('/console/my-skills')}
/>
<SidebarNavItem
icon={<FiCpu />}
label="我的模型"
active={isPathActive('/console/user-models')}
onClick={() => navigate('/console/user-models')}
/>
<SidebarNavItem
icon={<FiClock />}
label="定时任务"
@@ -101,12 +108,36 @@ function ConsoleLayout() {
active={isPathActive('/console/logs')}
onClick={() => navigate('/console/logs')}
/>
<SidebarNavItem
icon={<FiUsers />}
<SidebarNavGroup
icon={<FiFolder />}
label="项目管理"
active={isPathActive('/console/projects')}
onClick={() => navigate('/console/projects')}
/>
paths={['/console/project']}
>
<SidebarNavItem
icon={<FiUsers />}
label="成员管理"
active={isPathActive('/console/project/members')}
onClick={() => navigate('/console/project/members')}
/>
<SidebarNavItem
icon={<FiShield />}
label="权限配置"
active={isPathActive('/console/project/permissions')}
onClick={() => navigate('/console/project/permissions')}
/>
<SidebarNavItem
icon={<FiBox />}
label="技能配置"
active={isPathActive('/console/project/skills')}
onClick={() => navigate('/console/project/skills')}
/>
<SidebarNavItem
icon={<FiCpu />}
label="模型配置"
active={isPathActive('/console/project/models')}
onClick={() => navigate('/console/project/models')}
/>
</SidebarNavGroup>
</div>
<Modal
visible={!!deleteTarget}

View File

@@ -0,0 +1,44 @@
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { FiChevronDown, FiChevronRight } from 'react-icons/fi';
/**
* SidebarNavGroup - 可展开的侧边栏导航组组件
* 用于组织多个相关导航项
*
* @param {Object} props - 组件属性
* @param {React.ReactNode} props.icon - 主图标组件
* @param {string} props.label - 导航组标签文本
* @param {Array<string>} props.paths - 用于判断展开状态的路径前缀数组
* @param {React.ReactNode} props.children - 子菜单项SidebarNavItem 组件)
*/
function SidebarNavGroup({ icon, label, paths = [], children }) {
const location = useLocation();
const [manualExpanded, setManualExpanded] = useState(null);
const isUrlActive = paths.some(path => location.pathname.startsWith(path));
const isExpanded = manualExpanded !== null ? manualExpanded : isUrlActive;
const handleHeaderClick = () => {
setManualExpanded(!isExpanded);
};
return (
<div className={`nav-group ${isExpanded ? 'nav-group--expanded' : ''}`}>
<div className="nav-group__header" onClick={handleHeaderClick}>
<span className="nav-group__icon">{icon}</span>
<span className="nav-group__label">{label}</span>
<span className="nav-group__arrow">
{isExpanded ? <FiChevronDown /> : <FiChevronRight />}
</span>
</div>
{isExpanded && (
<div className="nav-group__children">
{children}
</div>
)}
</div>
);
}
export default SidebarNavGroup;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,555 @@
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; // 注意:向左拖动是负数
const maxWidth = Math.min(1200, window.innerWidth - 480);
return Math.max(400, Math.min(maxWidth, newWidth));
});
}
// 调整文件树宽度(向右拖动增加,向左拖动减小)
if (isResizingTreeRef.current) {
setFileTreeWidth(prev => {
const newWidth = prev + deltaX;
const minWidth = 300;
const maxWidth = previewFile ? sidebarWidth - 32 : sidebarWidth;
return Math.max(minWidth, Math.min(maxWidth, 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: previewFile ? `${fileTreeWidth}px` : 'auto',
flex: previewFile ? 'none' : '1'
}}
>
<FileTree
onFileClick={handleFileClick}
onMenuClick={handleMenuClick}
/>
</div>
{/* 分隔线(可拖动调整) */}
{previewFile && (
<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>
{/* 右键菜单 */}
{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;

View File

@@ -4,7 +4,6 @@ export const conversations = [
{ id: 'welcome', title: '新对话', time: '欢迎页', scene: 'welcome', status: 'running' },
{ id: 'text', title: '代码重构方案讨论', time: '普通对话', scene: 'text', status: 'running' },
{ id: 'skill', title: '查询客户数据', time: '调用 Skill', scene: 'skill', status: 'running' },
{ id: 'file', title: '分析上传的报表', time: '上传文件', scene: 'file', status: 'running' },
{ id: 'code', title: '生成 Python 函数', time: '代码展示', scene: 'code', status: 'running' },
{ id: 'table', title: '查询销售报表', time: '表格数据', scene: 'table', status: 'running' },
{ id: 'multiTurn', title: '产品方案讨论', time: '多轮对话', scene: 'multiTurn', status: 'running' },
@@ -74,12 +73,13 @@ export function getChatScenes() {
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-thinking">
<div class="message-thinking-header">
<span class="message-thinking-icon">▶</span>
<span>已深度思考</span>
<div class="message-block expanded">
<div class="message-block-header">
<span class="message-block-header-icon">▶</span>
<span class="message-block-header-title">已深度思考</span>
<span class="message-block-header-status">2.1s</span>
</div>
<div class="message-thinking-content">
<div class="message-block-content">
<p>让我分析一下代码重构的思路:</p>
<ul>
<li>首先识别代码中的重复模式和冗余逻辑</li>
@@ -90,6 +90,7 @@ export function getChatScenes() {
</ul>
</div>
</div>
<div class="message-bubble">
<p>好的,我来帮你重构这段代码。首先让我分析一下现有代码的问题,然后提供优化方案。</p>
<p style="margin-top: 12px;"><strong>优化建议:</strong></p>
@@ -114,23 +115,13 @@ export function getChatScenes() {
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-bubble">
<p style="display: flex; align-items: center; gap: 8px; color: #3B82F6;">
🧩 已加载CRM客户查询技能
</p>
</div>
<div class="message-time">10:15</div>
</div>
</div>
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-thinking">
<div class="message-thinking-header">
<span class="message-thinking-icon">▶</span>
<span>已深度思考</span>
<div class="message-block expanded">
<div class="message-block-header">
<span class="message-block-header-icon">▶</span>
<span class="message-block-header-title">已深度思考</span>
<span class="message-block-header-status">1.8s</span>
</div>
<div class="message-thinking-content">
<div class="message-block-content">
<p>正在调用CRM客户查询技能...</p>
<ul>
<li>识别用户意图:查询客户"张三"的订单信息</li>
@@ -141,6 +132,56 @@ export function getChatScenes() {
</ul>
</div>
</div>
<div class="message-block message-tool">
<div class="message-block-header">
<span class="message-block-header-icon">▶</span>
<span class="message-block-header-title">🧩 使用工具: CRM客户查询</span>
<span class="message-block-header-status message-tool-completed">✓ 0.4s</span>
</div>
<div class="message-block-content">
<div class="message-tool-io">
<div class="message-tool-io-label">INPUT</div>
<div class="message-tool-io-content"><pre>{ "name": "张三", "timeRange": "1year" }</pre></div>
</div>
<div class="message-tool-io">
<div class="message-tool-io-label">OUTPUT</div>
<div class="message-tool-io-content"><pre>{ "customerId": "C001", "orders": 3, "totalAmount": 28560 }</pre></div>
</div>
</div>
</div>
<div class="message-block message-tool">
<div class="message-block-header">
<span class="message-block-header-icon">▶</span>
<span class="message-block-header-title">🧩 使用工具: 订单数据分析</span>
<span class="message-block-header-status message-tool-completed">✓ 0.3s</span>
</div>
<div class="message-block-content">
<div class="message-tool-io">
<div class="message-tool-io-label">INPUT</div>
<div class="message-tool-io-content"><pre>{ "orders": [...], "timeRange": "1year" }</pre></div>
</div>
<div class="message-tool-io">
<div class="message-tool-io-label">OUTPUT</div>
<div class="message-tool-io-content"><pre>{ "totalAmount": 28560, "orderCount": 3, "lastOrderDate": "2026-03-10" }</pre></div>
</div>
</div>
</div>
<div class="message-block expanded">
<div class="message-block-header">
<span class="message-block-header-icon">▶</span>
<span class="message-block-header-title">已深度思考</span>
<span class="message-block-header-status">0.6s</span>
</div>
<div class="message-block-content">
<p>根据分析结果,用户可能还想知道:</p>
<ul>
<li>订单详情列表</li>
<li>客户等级权益</li>
</ul>
</div>
</div>
<div class="message-bubble">
<p><strong>客户信息查询结果:</strong></p>
<div style="margin-top: 12px; padding: 12px; background: #F8FAFC; border-radius: 8px;">
@@ -154,70 +195,6 @@ export function getChatScenes() {
</div>
</div>
`,
file: `
<div class="message user">
<div class="message-avatar user">张</div>
<div class="message-content">
<div class="message-bubble">
<p>帮我分析一下这个 Excel 文件里的销售数据</p>
<div style="margin-top: 12px; padding: 10px 14px; background: #F8FAFC; border-radius: 8px; display: inline-flex; align-items: center; gap: 10px; color: #475569;">
<span style="font-size: 20px;">📊</span>
<div>
<div style="font-weight: 600;">Q1销售数据.xlsx</div>
<div style="font-size: 12px; color: #94A3B8;">2.4 MB</div>
</div>
</div>
</div>
<div class="message-time">16:20</div>
</div>
</div>
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-bubble">
<p>文件已接收!正在分析数据...</p>
<div style="margin-top: 14px;">
<div style="height: 6px; background: #E2E8F0; border-radius: 3px; overflow: hidden;">
<div style="width: 65%; height: 100%; background: linear-gradient(90deg, #3B82F6, #8B5CF6); border-radius: 3px;"></div>
</div>
</div>
</div>
<div class="message-time">16:20</div>
</div>
</div>
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-thinking">
<div class="message-thinking-header">
<span class="message-thinking-icon">▶</span>
<span>已深度思考</span>
</div>
<div class="message-thinking-content">
<p>正在分析 Excel 文件中的销售数据...</p>
<ul>
<li>文件格式识别Excel (.xlsx),大小 2.4MB</li>
<li>数据结构解析:包含日期、产品、销售额等字段</li>
<li>时间范围2026年 Q11-3月</li>
<li>计算指标:总销售额、同比增长、月度趋势、产品占比</li>
<li>异常检测:无异常值或缺失数据</li>
<li>关键洞察提取3月表现突出产品C增长势头强劲</li>
</ul>
</div>
</div>
<div class="message-bubble">
<p><strong>数据分析完成!以下是关键发现:</strong></p>
<ul style="margin-top: 12px; padding-left: 20px;">
<li>Q1 总销售额:<strong>¥128.5 万</strong>,同比增长 18%</li>
<li>3 月份表现最佳,单月突破 50 万</li>
<li>产品 A 占比最高42%),产品 C 增长最快</li>
</ul>
<p style="margin-top: 12px;">需要生成可视化图表吗?</p>
</div>
<div class="message-time">16:22</div>
</div>
</div>
`,
code: `
<div class="message user">
<div class="message-avatar user">张</div>
@@ -229,12 +206,13 @@ export function getChatScenes() {
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-thinking">
<div class="message-thinking-header">
<span class="message-thinking-icon">▶</span>
<span>已深度思考</span>
<div class="message-block expanded">
<div class="message-block-header">
<span class="message-block-header-icon">▶</span>
<span class="message-block-header-title">已深度思考</span>
<span class="message-block-header-status">0.8s</span>
</div>
<div class="message-thinking-content">
<div class="message-block-content">
<p>分析需求:列表去重并保持顺序</p>
<ul>
<li>使用字典键的有序性Python 3.7+</li>
@@ -243,6 +221,7 @@ export function getChatScenes() {
</ul>
</div>
</div>
<div class="message-bubble">
<p>好的,这是一个常用的工具函数。以下提供两种实现方式:</p>
<pre style="margin-top: 12px; padding: 16px; background: #1E293B; border-radius: 8px; overflow-x: auto;"><code style="color: #E2E8F0; font-family: 'Fira Code', monospace; font-size: 13px; line-height: 1.6;"><span style="color: #60A5FA;">def</span> <span style="color: #34D399;">dedupe_ordered</span>(lst):
@@ -274,12 +253,13 @@ data = [<span style="color: #A78BFA;">1</span>, <span style="color: #A78BFA;">2<
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-thinking">
<div class="message-thinking-header">
<span class="message-thinking-icon">▶</span>
<span>已深度思考</span>
<div class="message-block expanded">
<div class="message-block-header">
<span class="message-block-header-icon">▶</span>
<span class="message-block-header-title">已深度思考</span>
<span class="message-block-header-status">1.2s</span>
</div>
<div class="message-thinking-content">
<div class="message-block-content">
<p>正在查询销售数据...</p>
<ul>
<li>时间范围2026年3月1日 - 3月19日</li>
@@ -288,6 +268,7 @@ data = [<span style="color: #A78BFA;">1</span>, <span style="color: #A78BFA;">2<
</ul>
</div>
</div>
<div class="message-bubble">
<p><strong>本月各部门销售业绩汇总:</strong></p>
<table class="msg-table">
@@ -423,12 +404,13 @@ data = [<span style="color: #A78BFA;">1</span>, <span style="color: #A78BFA;">2<
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-thinking">
<div class="message-thinking-header">
<span class="message-thinking-icon">▶</span>
<span>已深度思考</span>
<div class="message-block expanded">
<div class="message-block-header">
<span class="message-block-header-icon">▶</span>
<span class="message-block-header-title">已深度思考</span>
<span class="message-block-header-status">30.2s</span>
</div>
<div class="message-thinking-content">
<div class="message-block-content">
<p>正在连接日志数据库...</p>
<ul>
<li>目标数据库log-db-prod-01</li>
@@ -437,6 +419,7 @@ data = [<span style="color: #A78BFA;">1</span>, <span style="color: #A78BFA;">2<
</ul>
</div>
</div>
<div class="message-bubble" style="border-left: 3px solid #EF4444; background: #FEF2F2;">
<p style="display: flex; align-items: center; gap: 8px; color: #EF4444; font-weight: 600;">
❌ 请求失败

View File

@@ -0,0 +1,47 @@
export const projectModelConfigs = [
{
id: 'proj_001',
name: '智算平台生产环境',
type: 'zhisuan',
isActive: true,
createdAt: '2026-03-25T09:00:00',
updatedAt: '2026-03-25T09:00:00',
zhisuan: {
apiUrl: 'https://zhisuan.internal.company.com/api/v1',
appId: 'app_prod_001',
appSecret: 'secret_prod_xyz123abc'
}
},
{
id: 'proj_002',
name: 'DeepSeek 项目专用',
type: 'basic',
isActive: false,
createdAt: '2026-03-26T14:00:00',
updatedAt: '2026-03-26T14:00:00',
basic: {
apiUrl: 'https://api.deepseek.com/v1',
apiKey: 'sk-proj-deepseek789xyz',
modelName: 'deepseek-coder',
temperature: 0.3,
maxTokens: 8192,
topP: 0.95
}
},
{
id: 'proj_003',
name: '通义千问代码助手',
type: 'basic',
isActive: false,
createdAt: '2026-03-27T11:00:00',
updatedAt: '2026-03-27T11:00:00',
basic: {
apiUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: 'sk-proj-qwen456def',
modelName: 'qwen-coder-plus',
temperature: 0.5,
maxTokens: 16384,
topP: 0.8
}
}
];

View File

@@ -0,0 +1,34 @@
export const userModelConfigs = [
{
id: 'user_001',
name: '我的 GPT-4',
type: 'basic',
isActive: true,
createdAt: '2026-03-26T10:00:00',
updatedAt: '2026-03-26T10:00:00',
basic: {
apiUrl: 'https://api.openai.com/v1',
apiKey: 'sk-user-abc123xyz456',
modelName: 'gpt-4o',
temperature: 0.7,
maxTokens: 4096,
topP: 0.9
}
},
{
id: 'user_002',
name: 'Claude 备用',
type: 'basic',
isActive: false,
createdAt: '2026-03-28T16:00:00',
updatedAt: '2026-03-28T16:00:00',
basic: {
apiUrl: 'https://api.anthropic.com/v1',
apiKey: 'sk-user-claude789abc',
modelName: 'claude-3-sonnet',
temperature: 0.8,
maxTokens: 8192,
topP: 0.9
}
}
];

365
src/data/workspace.js Normal file
View 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: []
}
];

View File

@@ -10,6 +10,7 @@ function ModelConfigsPage() {
const [configs, setConfigs] = useState(api.admin.modelConfigs.list());
const [showSetActiveModal, setShowSetActiveModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showDeleteBlockedModal, setShowDeleteBlockedModal] = useState(false);
const [selectedConfig, setSelectedConfig] = useState(null);
const handleSetActiveClick = (config) => {
@@ -19,14 +20,22 @@ function ModelConfigsPage() {
const handleSetActiveConfirm = () => {
if (selectedConfig) {
api.admin.modelConfigs.setActive(selectedConfig.id);
setConfigs([...api.admin.modelConfigs.list()]);
const updatedConfigs = configs.map(c => ({
...c,
isActive: c.id === selectedConfig.id
}));
setConfigs(updatedConfigs);
}
setShowSetActiveModal(false);
setSelectedConfig(null);
};
const handleDeleteClick = (config) => {
if (config.isActive) {
setSelectedConfig(config);
setShowDeleteBlockedModal(true);
return;
}
setSelectedConfig(config);
setShowDeleteModal(true);
};
@@ -42,7 +51,6 @@ function ModelConfigsPage() {
return (
<div className="model-configs-page">
{/* 配置列表 */}
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="card-title">配置列表</div>
@@ -57,22 +65,17 @@ function ModelConfigsPage() {
<tr>
<th>配置名称</th>
<th>配置类型</th>
<th>状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{configs.map(config => (
<tr key={config.id} className={config.isActive ? 'active-row' : ''}>
<td><strong>{config.name}</strong></td>
<td>{MODEL_CONFIG_TYPES[config.type]?.label || config.type}</td>
<tr key={config.id}>
<td>
{config.isActive ? (
<span className="status status-running">生效中</span>
) : (
<span className="status status-stopped">未生效</span>
)}
<strong>{config.name}</strong>
{config.isActive && <span className="tag tag--admin" style={{ marginLeft: '8px' }}>默认</span>}
</td>
<td>{MODEL_CONFIG_TYPES[config.type]?.label || config.type}</td>
<td className="col-actions">
<div className="table-actions">
{!config.isActive && (
@@ -86,17 +89,12 @@ function ModelConfigsPage() {
<button
className="text-btn text-btn-primary"
onClick={() => navigate(`/admin/models/${config.id}/edit`)}
disabled={config.isActive}
title={config.isActive ? '生效中的配置不可编辑' : ''}
style={config.isActive ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
编辑
</button>
<button
className="text-btn text-btn-danger"
onClick={() => handleDeleteClick(config)}
disabled={config.isActive}
title={config.isActive ? '生效中的配置不可删除' : ''}
style={config.isActive ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
删除
@@ -111,7 +109,6 @@ function ModelConfigsPage() {
</div>
</div>
{/* 设为默认确认弹窗 */}
<Modal
visible={showSetActiveModal}
title="确认切换默认配置"
@@ -127,7 +124,6 @@ function ModelConfigsPage() {
<p>切换后原生效配置将变为备用状态</p>
</Modal>
{/* 删除确认弹窗 */}
<Modal
visible={showDeleteModal}
title="确认删除"
@@ -142,6 +138,24 @@ function ModelConfigsPage() {
<p>确定要删除配置"{selectedConfig?.name}"</p>
<p>此操作不可恢复</p>
</Modal>
<Modal
visible={showDeleteBlockedModal}
title="无法删除"
onConfirm={() => {
setShowDeleteBlockedModal(false);
setSelectedConfig(null);
}}
onCancel={() => {
setShowDeleteBlockedModal(false);
setSelectedConfig(null);
}}
confirmText="我知道了"
cancelText=""
>
<p>平台默认模型不允许删除</p>
<p>请先将另一个配置设为默认然后再删除此配置</p>
</Modal>
</div>
);
}

View File

@@ -33,7 +33,7 @@ function AddMemberPage() {
return (
<>
<div className="page-back-btn" onClick={() => navigate('/console/projects')}>
<div className="page-back-btn" onClick={() => navigate('/console/project/members')}>
<span></span>
<span>返回成员列表</span>
</div>
@@ -53,7 +53,7 @@ function AddMemberPage() {
onClearSelected={() => setSelectedMembers([])}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '16px' }}>
<button className="btn btn-secondary" onClick={() => navigate('/console/projects')}>取消</button>
<button className="btn btn-secondary" onClick={() => navigate('/console/project/members')}>取消</button>
<button className="btn btn-primary" onClick={handleAdd} disabled={selectedMembers.length === 0}>
添加选中成员 ({selectedMembers.length})
</button>

View File

@@ -0,0 +1,203 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { FiEye, FiEyeOff } from 'react-icons/fi';
import { api } from '../../services/api.js';
import { MODEL_CONFIG_TYPES, getConfigFields, getConfigTypeList } from '../../data/configTypes.js';
function AddProjectModelConfigPage() {
const { id } = useParams();
const navigate = useNavigate();
const editData = id ? api.consoleModels.project.getById(id) : null;
const isEdit = !!editData;
const [configName, setConfigName] = useState('');
const [configType, setConfigType] = useState('basic');
const [fieldValues, setFieldValues] = useState({});
const [showPasswords, setShowPasswords] = useState({});
useEffect(() => {
if (editData) {
setConfigName(editData.name || '');
setConfigType(editData.type || 'basic');
const typeData = editData[editData.type] || {};
const initialValues = {};
const fields = getConfigFields(editData.type);
fields.forEach(field => {
initialValues[field.key] = typeData[field.key] ?? field.default ?? '';
});
setFieldValues(initialValues);
} else {
const fields = getConfigFields('basic');
const initialValues = {};
fields.forEach(field => {
initialValues[field.key] = field.default ?? '';
});
setFieldValues(initialValues);
}
}, [editData]);
const handleTypeChange = (newType) => {
if (isEdit) return;
setConfigType(newType);
const fields = getConfigFields(newType);
const newValues = {};
fields.forEach(field => {
newValues[field.key] = field.default ?? '';
});
setFieldValues(newValues);
};
const handleFieldChange = (key, value) => {
setFieldValues(prev => ({ ...prev, [key]: value }));
};
const togglePasswordVisibility = (key) => {
setShowPasswords(prev => ({ ...prev, [key]: !prev[key] }));
};
const handleSave = () => {
const typeData = {};
const fields = getConfigFields(configType);
fields.forEach(field => {
let value = fieldValues[field.key];
if (field.type === 'number' && value !== '') {
value = Number(value);
}
typeData[field.key] = value;
});
const configData = {
name: configName.trim(),
type: configType,
[configType]: typeData
};
if (isEdit) {
api.consoleModels.project.update(editData.id, configData);
} else {
api.consoleModels.project.create(configData);
}
navigate('/console/project/models');
};
const currentFields = getConfigFields(configType);
const configTypeList = getConfigTypeList();
return (
<>
<div className="page-back-btn" onClick={() => navigate('/console/project/models')}>
<span></span>
<span>返回配置列表</span>
</div>
<div className="card">
<div className="card-header">
<div className="card-title">{isEdit ? '编辑配置' : '新增配置'}</div>
</div>
<div className="card-body">
<div className="form-group">
<label className="form-label required">配置名称</label>
<input
type="text"
className="form-control"
placeholder="请输入配置名称"
value={configName}
onChange={(e) => setConfigName(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label required">配置类型</label>
<select
className="form-control"
value={configType}
onChange={(e) => handleTypeChange(e.target.value)}
disabled={isEdit}
>
{configTypeList.map(type => (
<option key={type.key} value={type.key}>
{type.label}
</option>
))}
</select>
{isEdit && (
<div style={{ fontSize: '12px', color: '#6B7280', marginTop: '4px' }}>
配置类型不可修改
</div>
)}
</div>
<div style={{ marginTop: '24px', marginBottom: '16px' }}>
<div style={{ fontSize: '14px', fontWeight: 600, color: '#111827', marginBottom: '16px' }}>
{MODEL_CONFIG_TYPES[configType]?.label} 配置
</div>
{currentFields.map(field => (
<div key={field.key} className="form-group">
<label className={`form-label ${field.required ? 'required' : ''}`}>
{field.label}
</label>
{field.type === 'password' ? (
<div style={{ position: 'relative' }}>
<input
type={showPasswords[field.key] ? 'text' : 'password'}
className="form-control"
style={{ paddingRight: '40px' }}
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
<button
type="button"
onClick={() => togglePasswordVisibility(field.key)}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
padding: '6px',
cursor: 'pointer',
color: '#6B7280'
}}
>
{showPasswords[field.key] ? <FiEyeOff size={16} /> : <FiEye size={16} />}
</button>
</div>
) : field.type === 'number' ? (
<input
type="number"
className="form-control"
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
min={field.min}
max={field.max}
step={field.step}
/>
) : (
<input
type={field.type === 'url' ? 'url' : 'text'}
className="form-control"
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
)}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
<button className="btn" onClick={() => navigate('/console/project/models')}>取消</button>
<button className="btn btn-primary" onClick={handleSave}>保存</button>
</div>
</div>
</div>
</>
);
}
export default AddProjectModelConfigPage;

View File

@@ -0,0 +1,203 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { FiEye, FiEyeOff } from 'react-icons/fi';
import { api } from '../../services/api.js';
import { MODEL_CONFIG_TYPES, getConfigFields, getConfigTypeList } from '../../data/configTypes.js';
function AddUserModelConfigPage() {
const { id } = useParams();
const navigate = useNavigate();
const editData = id ? api.consoleModels.user.getById(id) : null;
const isEdit = !!editData;
const [configName, setConfigName] = useState('');
const [configType, setConfigType] = useState('basic');
const [fieldValues, setFieldValues] = useState({});
const [showPasswords, setShowPasswords] = useState({});
useEffect(() => {
if (editData) {
setConfigName(editData.name || '');
setConfigType(editData.type || 'basic');
const typeData = editData[editData.type] || {};
const initialValues = {};
const fields = getConfigFields(editData.type);
fields.forEach(field => {
initialValues[field.key] = typeData[field.key] ?? field.default ?? '';
});
setFieldValues(initialValues);
} else {
const fields = getConfigFields('basic');
const initialValues = {};
fields.forEach(field => {
initialValues[field.key] = field.default ?? '';
});
setFieldValues(initialValues);
}
}, [editData]);
const handleTypeChange = (newType) => {
if (isEdit) return;
setConfigType(newType);
const fields = getConfigFields(newType);
const newValues = {};
fields.forEach(field => {
newValues[field.key] = field.default ?? '';
});
setFieldValues(newValues);
};
const handleFieldChange = (key, value) => {
setFieldValues(prev => ({ ...prev, [key]: value }));
};
const togglePasswordVisibility = (key) => {
setShowPasswords(prev => ({ ...prev, [key]: !prev[key] }));
};
const handleSave = () => {
const typeData = {};
const fields = getConfigFields(configType);
fields.forEach(field => {
let value = fieldValues[field.key];
if (field.type === 'number' && value !== '') {
value = Number(value);
}
typeData[field.key] = value;
});
const configData = {
name: configName.trim(),
type: configType,
[configType]: typeData
};
if (isEdit) {
api.consoleModels.user.update(editData.id, configData);
} else {
api.consoleModels.user.create(configData);
}
navigate('/console/user-models');
};
const currentFields = getConfigFields(configType);
const configTypeList = getConfigTypeList();
return (
<>
<div className="page-back-btn" onClick={() => navigate('/console/user-models')}>
<span></span>
<span>返回配置列表</span>
</div>
<div className="card">
<div className="card-header">
<div className="card-title">{isEdit ? '编辑配置' : '新增配置'}</div>
</div>
<div className="card-body">
<div className="form-group">
<label className="form-label required">配置名称</label>
<input
type="text"
className="form-control"
placeholder="请输入配置名称"
value={configName}
onChange={(e) => setConfigName(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label required">配置类型</label>
<select
className="form-control"
value={configType}
onChange={(e) => handleTypeChange(e.target.value)}
disabled={isEdit}
>
{configTypeList.map(type => (
<option key={type.key} value={type.key}>
{type.label}
</option>
))}
</select>
{isEdit && (
<div style={{ fontSize: '12px', color: '#6B7280', marginTop: '4px' }}>
配置类型不可修改
</div>
)}
</div>
<div style={{ marginTop: '24px', marginBottom: '16px' }}>
<div style={{ fontSize: '14px', fontWeight: 600, color: '#111827', marginBottom: '16px' }}>
{MODEL_CONFIG_TYPES[configType]?.label} 配置
</div>
{currentFields.map(field => (
<div key={field.key} className="form-group">
<label className={`form-label ${field.required ? 'required' : ''}`}>
{field.label}
</label>
{field.type === 'password' ? (
<div style={{ position: 'relative' }}>
<input
type={showPasswords[field.key] ? 'text' : 'password'}
className="form-control"
style={{ paddingRight: '40px' }}
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
<button
type="button"
onClick={() => togglePasswordVisibility(field.key)}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
padding: '6px',
cursor: 'pointer',
color: '#6B7280'
}}
>
{showPasswords[field.key] ? <FiEyeOff size={16} /> : <FiEye size={16} />}
</button>
</div>
) : field.type === 'number' ? (
<input
type="number"
className="form-control"
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
min={field.min}
max={field.max}
step={field.step}
/>
) : (
<input
type={field.type === 'url' ? 'url' : 'text'}
className="form-control"
placeholder={`请输入${field.label}`}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
)}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
<button className="btn" onClick={() => navigate('/console/user-models')}>取消</button>
<button className="btn btn-primary" onClick={handleSave}>保存</button>
</div>
</div>
</div>
</>
);
}
export default AddUserModelConfigPage;

View File

@@ -1,7 +1,136 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { getChatScenes } from '../../data/conversations.js';
import { FiPaperclip, FiCode, FiSend } from 'react-icons/fi';
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);
const [dropdownDirection, setDropdownDirection] = useState('down');
const ref = useRef(null);
const platformModels = api.admin.modelConfigs.list().map(c => ({
id: c.id,
name: c.name,
level: 'platform',
isDefault: c.isActive,
}));
const projectModels = api.consoleModels.project.list().map(c => ({
id: c.id,
name: c.name,
level: 'project',
isDefault: c.isActive,
}));
const userModels = api.consoleModels.user.list().map(c => ({
id: c.id,
name: c.name,
level: 'user',
isDefault: c.isActive,
}));
const groups = [
{ key: 'platform', title: '平台模型', models: platformModels },
{ key: 'project', title: '项目模型', models: projectModels },
{ key: 'user', title: '个人模型', models: userModels },
];
useEffect(() => {
const handleClickOutside = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
if (open && ref.current) {
const rect = ref.current.getBoundingClientRect();
const dropdownHeight = 350;
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceBelow < dropdownHeight && rect.top > dropdownHeight) {
setDropdownDirection('up');
} else {
setDropdownDirection('down');
}
}
}, [open]);
const handleSelect = (model) => {
onSelectModel(model);
setOpen(false);
};
const getDropdownPosition = () => {
if (!ref.current) return {};
const rect = ref.current.getBoundingClientRect();
const dropdownHeight = 350;
const spaceBelow = window.innerHeight - rect.bottom;
if (dropdownDirection === 'down') {
return {
top: rect.bottom,
left: rect.left,
width: rect.width,
};
} else {
return {
bottom: window.innerHeight - rect.top,
left: rect.left,
width: rect.width,
};
}
};
return (
<div className={`model-selector ${open ? 'model-selector--open' : ''} ${variant === 'compact' ? 'model-selector--compact' : ''}`} ref={ref}>
<div
className="model-selector__trigger"
onClick={() => setOpen(!open)}
>
<div className="model-selector__content">
<span className="model-selector__icon">
<FiCode size={12} />
</span>
{variant !== 'compact' && (
<span className="model-selector__name">{selectedModel?.name || '选择模型'}</span>
)}
</div>
<span className={`model-selector__arrow ${open ? 'model-selector__arrow--open' : ''}`}>
<FiChevronDown size={14} />
</span>
</div>
{open && (
<div
className={`model-selector__dropdown model-selector__dropdown--${dropdownDirection}`}
style={getDropdownPosition()}
>
{groups.map(group => (
<div key={group.key} className="model-selector__group">
<div className="model-selector__group-title">{group.title}</div>
{group.models.map(model => (
<div
key={model.id}
className={`model-selector__item ${selectedModel?.id === model.id ? 'model-selector__item--selected' : ''}`}
onClick={() => handleSelect(model)}
>
<span className="model-selector__item-text">{model.name}</span>
</div>
))}
</div>
))}
</div>
)}
</div>
);
}
function ChatPage() {
const { scene } = useParams();
@@ -9,64 +138,105 @@ function ChatPage() {
const chatScenes = getChatScenes();
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);
const defaultUserModel = api.consoleModels.user.list().find(c => c.isActive);
const initialModel = defaultUserModel || defaultProjectModel || defaultPlatformModel;
const [selectedModel, setSelectedModel] = useState(() => {
if (!initialModel) return null;
const level = defaultUserModel ? 'user' : (defaultProjectModel ? 'project' : 'platform');
return { id: initialModel.id, name: initialModel.name, level, isDefault: true };
});
useEffect(() => {
if (!chatMessagesRef.current) return;
const thinkingElements = chatMessagesRef.current.querySelectorAll('.message-thinking');
const blockElements = chatMessagesRef.current.querySelectorAll('.message-block');
const handleClick = (event) => {
const thinkingElement = event.currentTarget;
thinkingElement.classList.toggle('expanded');
const blockElement = event.currentTarget;
blockElement.classList.toggle('expanded');
};
thinkingElements.forEach(el => {
blockElements.forEach(el => {
el.addEventListener('click', handleClick);
el.style.cursor = 'pointer';
});
return () => {
thinkingElements.forEach(el => {
blockElements.forEach(el => {
el.removeEventListener('click', handleClick);
});
};
}, [scene, html]); // 依赖场景和html内容
}, [scene, html]);
useEffect(() => {
const checkScreenSize = () => {
setIsCompact(window.innerWidth >= 480 && window.innerWidth < 768);
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
return () => window.removeEventListener('resize', checkScreenSize);
}, []);
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">
<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>
);
}
export default ChatPage;
export default ChatPage;

View File

@@ -6,7 +6,7 @@ function MemberConfigPage() {
return (
<>
<div className="page-back-btn" onClick={() => navigate('/console/projects')}>
<div className="page-back-btn" onClick={() => navigate('/console/project/members')}>
<span></span>
<span>返回成员列表</span>
</div>

View File

@@ -5,7 +5,7 @@ import { projectMembers } from '../../data/members.js';
import EmptyState from '../../components/common/EmptyState.jsx';
import Modal from '../../components/common/Modal.jsx';
function ProjectsPage() {
function MembersPage() {
const navigate = useNavigate();
const [members, setMembers] = useState(projectMembers);
const [removeTarget, setRemoveTarget] = useState(null);
@@ -84,7 +84,7 @@ function ProjectsPage() {
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="card-title">成员列表</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/console/projects/members/add')}>增加成员</button>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/console/project/members/add')}>增加成员</button>
</div>
<div className="card-body">
{filteredMembers.length > 0 ? (
@@ -112,7 +112,7 @@ function ProjectsPage() {
<td><span className={`status ${member.role === '管理员' ? 'role-admin' : 'role-member'}`}>{member.role}</span></td>
<td className="col-actions--narrow">
<div className="table-actions">
<button className="text-btn text-btn-primary" onClick={() => navigate(`/console/projects/members/${member.id}/config`)}>配置</button>
<button className="text-btn text-btn-primary" onClick={() => navigate(`/console/project/members/${member.id}/config`)}>配置</button>
<button className="text-btn text-btn-danger" onClick={() => handleRemoveClick(member)}>移除</button>
</div>
</td>
@@ -150,4 +150,4 @@ function ProjectsPage() {
);
}
export default ProjectsPage;
export default MembersPage;

View File

@@ -0,0 +1,21 @@
import { FiShield } from 'react-icons/fi';
import EmptyState from '../../components/common/EmptyState.jsx';
function PermissionsPage() {
return (
<div className="card">
<div className="card-header">
<div className="card-title">权限配置</div>
</div>
<div className="card-body">
<EmptyState
icon={<FiShield size={48} />}
message="权限配置功能开发中"
description="该功能即将上线,敬请期待"
/>
</div>
</div>
);
}
export default PermissionsPage;

View File

@@ -0,0 +1,138 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FiPlus } from 'react-icons/fi';
import { api } from '../../services/api.js';
import { MODEL_CONFIG_TYPES } from '../../data/configTypes.js';
import Modal from '../../components/common/Modal.jsx';
function ProjectModelConfigsPage() {
const navigate = useNavigate();
const [configs, setConfigs] = useState(api.consoleModels.project.list());
const [showSetActiveModal, setShowSetActiveModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedConfig, setSelectedConfig] = useState(null);
const handleSetActiveClick = (config) => {
setSelectedConfig(config);
setShowSetActiveModal(true);
};
const handleSetActiveConfirm = () => {
if (selectedConfig) {
const updatedConfigs = configs.map(c => ({
...c,
isActive: c.id === selectedConfig.id
}));
setConfigs(updatedConfigs);
}
setShowSetActiveModal(false);
setSelectedConfig(null);
};
const handleDeleteClick = (config) => {
setSelectedConfig(config);
setShowDeleteModal(true);
};
const handleDeleteConfirm = () => {
if (selectedConfig) {
api.consoleModels.project.delete(selectedConfig.id);
setConfigs([...api.consoleModels.project.list()]);
}
setShowDeleteModal(false);
setSelectedConfig(null);
};
return (
<div className="model-configs-page">
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="card-title">项目模型配置</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/console/project/models/add')}>
<FiPlus /> 新增配置
</button>
</div>
<div className="card-body">
<div className="table-wrapper">
<table className="table">
<thead>
<tr>
<th>配置名称</th>
<th>配置类型</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{configs.map(config => (
<tr key={config.id}>
<td>
<strong>{config.name}</strong>
{config.isActive && <span className="tag tag--admin" style={{ marginLeft: '8px' }}>默认</span>}
</td>
<td>{MODEL_CONFIG_TYPES[config.type]?.label || config.type}</td>
<td className="col-actions">
<div className="table-actions">
{!config.isActive && (
<button
className="text-btn text-btn-primary"
onClick={() => handleSetActiveClick(config)}
>
设为默认
</button>
)}
<button
className="text-btn text-btn-primary"
onClick={() => navigate(`/console/project/models/${config.id}/edit`)}
>
编辑
</button>
<button
className="text-btn text-btn-danger"
onClick={() => handleDeleteClick(config)}
>
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<Modal
visible={showSetActiveModal}
title="确认切换默认配置"
onConfirm={handleSetActiveConfirm}
onCancel={() => {
setShowSetActiveModal(false);
setSelectedConfig(null);
}}
confirmText="确认切换"
cancelText="取消"
>
<p>确定将"{selectedConfig?.name}"设为项目默认模型配置吗</p>
<p>切换后原生效配置将变为备用状态</p>
</Modal>
<Modal
visible={showDeleteModal}
title="确认删除"
onConfirm={handleDeleteConfirm}
onCancel={() => {
setShowDeleteModal(false);
setSelectedConfig(null);
}}
confirmText="删除"
cancelText="取消"
>
<p>确定要删除配置"{selectedConfig?.name}"</p>
<p>此操作不可恢复</p>
</Modal>
</div>
);
}
export default ProjectModelConfigsPage;

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { FiPlus, FiX, FiUsers, FiStar, FiPackage } from 'react-icons/fi';
import { FiPlus, FiUsers, FiStar, FiPackage } from 'react-icons/fi';
import { skills, userSubscriptions } from '../../data/skills.js';
import Toast from '../../components/common/Toast.jsx';
@@ -157,7 +157,7 @@ function SkillConfigPage() {
<tr>
<th>Key</th>
<th>Value</th>
<th className="col-actions--tiny">操作</th>
<th className="col-actions--narrow">操作</th>
</tr>
</thead>
<tbody>
@@ -191,12 +191,12 @@ function SkillConfigPage() {
</div>
)}
</td>
<td>
<td className="col-actions--narrow">
<button
className="text-btn text-btn-danger"
onClick={() => handleRemoveConfig(index)}
>
<FiX />
删除
</button>
</td>
</tr>

View File

@@ -0,0 +1,21 @@
import { FiSettings } from 'react-icons/fi';
import EmptyState from '../../components/common/EmptyState.jsx';
function SkillsConfigPage() {
return (
<div className="card">
<div className="card-header">
<div className="card-title">技能配置</div>
</div>
<div className="card-body">
<EmptyState
icon={<FiSettings size={48} />}
message="技能配置功能开发中"
description="该功能即将上线,敬请期待"
/>
</div>
</div>
);
}
export default SkillsConfigPage;

View File

@@ -0,0 +1,138 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FiPlus } from 'react-icons/fi';
import { api } from '../../services/api.js';
import { MODEL_CONFIG_TYPES } from '../../data/configTypes.js';
import Modal from '../../components/common/Modal.jsx';
function UserModelConfigsPage() {
const navigate = useNavigate();
const [configs, setConfigs] = useState(api.consoleModels.user.list());
const [showSetActiveModal, setShowSetActiveModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedConfig, setSelectedConfig] = useState(null);
const handleSetActiveClick = (config) => {
setSelectedConfig(config);
setShowSetActiveModal(true);
};
const handleSetActiveConfirm = () => {
if (selectedConfig) {
const updatedConfigs = configs.map(c => ({
...c,
isActive: c.id === selectedConfig.id
}));
setConfigs(updatedConfigs);
}
setShowSetActiveModal(false);
setSelectedConfig(null);
};
const handleDeleteClick = (config) => {
setSelectedConfig(config);
setShowDeleteModal(true);
};
const handleDeleteConfirm = () => {
if (selectedConfig) {
api.consoleModels.user.delete(selectedConfig.id);
setConfigs([...api.consoleModels.user.list()]);
}
setShowDeleteModal(false);
setSelectedConfig(null);
};
return (
<div className="model-configs-page">
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="card-title">个人模型配置</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/console/user-models/add')}>
<FiPlus /> 新增配置
</button>
</div>
<div className="card-body">
<div className="table-wrapper">
<table className="table">
<thead>
<tr>
<th>配置名称</th>
<th>配置类型</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{configs.map(config => (
<tr key={config.id}>
<td>
<strong>{config.name}</strong>
{config.isActive && <span className="tag tag--admin" style={{ marginLeft: '8px' }}>默认</span>}
</td>
<td>{MODEL_CONFIG_TYPES[config.type]?.label || config.type}</td>
<td className="col-actions">
<div className="table-actions">
{!config.isActive && (
<button
className="text-btn text-btn-primary"
onClick={() => handleSetActiveClick(config)}
>
设为默认
</button>
)}
<button
className="text-btn text-btn-primary"
onClick={() => navigate(`/console/user-models/${config.id}/edit`)}
>
编辑
</button>
<button
className="text-btn text-btn-danger"
onClick={() => handleDeleteClick(config)}
>
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<Modal
visible={showSetActiveModal}
title="确认切换默认配置"
onConfirm={handleSetActiveConfirm}
onCancel={() => {
setShowSetActiveModal(false);
setSelectedConfig(null);
}}
confirmText="确认切换"
cancelText="取消"
>
<p>确定将"{selectedConfig?.name}"设为个人默认模型配置吗</p>
<p>切换后原生效配置将变为备用状态</p>
</Modal>
<Modal
visible={showDeleteModal}
title="确认删除"
onConfirm={handleDeleteConfirm}
onCancel={() => {
setShowDeleteModal(false);
setSelectedConfig(null);
}}
confirmText="删除"
cancelText="取消"
>
<p>确定要删除配置"{selectedConfig?.name}"</p>
<p>此操作不可恢复</p>
</Modal>
</div>
);
}
export default UserModelConfigsPage;

View File

@@ -11,6 +11,9 @@ import { mySkills, skillCategories, devDocs, developerOverview } from '../data/d
import { projectMembers } from '../data/members.js';
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
@@ -377,8 +380,127 @@ export const adminApi = {
};
/**
* 统一 API 导出对象
* 控制台模型配置相关 API项目级 + 个人级)
*/
export const consoleModelConfigsApi = {
project: {
list: () => projectModelConfigs,
getById: (id) => projectModelConfigs.find(c => c.id === id),
create: (data) => {
const newConfig = {
...data,
id: `proj_${String(projectModelConfigs.length + 1).padStart(3, '0')}`,
isActive: projectModelConfigs.length === 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
projectModelConfigs.push(newConfig);
return newConfig;
},
update: (id, data) => {
const index = projectModelConfigs.findIndex(c => c.id === id);
if (index === -1) return undefined;
projectModelConfigs[index] = { ...projectModelConfigs[index], ...data, updatedAt: new Date().toISOString() };
return projectModelConfigs[index];
},
delete: (id) => {
const index = projectModelConfigs.findIndex(c => c.id === id);
if (index === -1) return false;
projectModelConfigs.splice(index, 1);
return true;
},
setActive: (id) => {
const target = projectModelConfigs.find(c => c.id === id);
if (!target) return undefined;
projectModelConfigs.forEach(c => { c.isActive = false; });
target.isActive = true;
target.updatedAt = new Date().toISOString();
return target;
},
},
user: {
list: () => userModelConfigs,
getById: (id) => userModelConfigs.find(c => c.id === id),
create: (data) => {
const newConfig = {
...data,
id: `user_${String(userModelConfigs.length + 1).padStart(3, '0')}`,
isActive: userModelConfigs.length === 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
userModelConfigs.push(newConfig);
return newConfig;
},
update: (id, data) => {
const index = userModelConfigs.findIndex(c => c.id === id);
if (index === -1) return undefined;
userModelConfigs[index] = { ...userModelConfigs[index], ...data, updatedAt: new Date().toISOString() };
return userModelConfigs[index];
},
delete: (id) => {
const index = userModelConfigs.findIndex(c => c.id === id);
if (index === -1) return false;
userModelConfigs.splice(index, 1);
return true;
},
setActive: (id) => {
const target = userModelConfigs.find(c => c.id === id);
if (!target) return undefined;
userModelConfigs.forEach(c => { c.isActive = false; });
target.isActive = true;
target.updatedAt = new Date().toISOString();
return target;
},
},
};
/**
* 工作空间相关 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,
@@ -388,6 +510,8 @@ export const api = {
members: membersApi,
tasks: tasksApi,
admin: adminApi,
consoleModels: consoleModelConfigsApi,
workspace: workspaceApi,
};
export default api;

View File

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

View File

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

View File

@@ -46,3 +46,62 @@
font-size: $font-size-sm;
color: var(--color-text-3);
}
// 可展开导航组
.nav-group {
margin-bottom: 4px;
}
.nav-group__header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.2s;
color: var(--color-text-2);
font-size: $font-size-base;
font-weight: $font-weight-medium;
&:hover {
background: var(--color-bg-2);
color: var(--color-text-1);
}
}
.nav-group__icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: $font-size-lg;
flex-shrink: 0;
}
.nav-group__label {
flex: 1;
}
.nav-group__arrow {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: $font-size-sm;
color: var(--color-text-3);
transition: transform 0.2s;
}
.nav-group__children {
margin-top: 4px;
padding-left: 12px;
}
.nav-group--expanded {
.nav-group__header {
color: var(--color-text-1);
}
}

View File

@@ -0,0 +1,632 @@
// 工作空间组件样式
@use '../../tokens' as *;
// 工作空间侧边栏
.workspace-sidebar {
width: 500px;
min-width: 400px;
max-width: 1200px;
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: 300px;
max-width: 100%;
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: 0;
bottom: 0;
width: 1px;
background: var(--color-border-2);
cursor: ew-resize;
transition: background 0.2s;
z-index: 10;
&::before {
content: '';
position: absolute;
top: 0;
left: -4px;
right: -4px;
bottom: 0;
cursor: ew-resize;
}
&:hover {
background: var(--color-primary);
}
}
}
// 工作空间展开按钮
.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);
}
}

View File

@@ -9,3 +9,4 @@
@use 'pages/admin' as *;
@use 'pages/developer' as *;
@use 'pages/home' as *;
@use 'pages/model-selector' as *;

View File

@@ -10,6 +10,14 @@
background: var(--color-bg-1);
}
// 聊天主区域(包含内容区和右侧工作空间侧边栏)
.chat-main {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
// 聊天顶部栏
.chat-layout__header,
.chat-header {
@@ -237,6 +245,7 @@
background: var(--color-bg-1);
min-height: 0;
overflow: hidden;
min-width: 480px; // 保证对话区域最小宽度
}
.chat-content__messages,

View File

@@ -105,7 +105,13 @@
}
.message.assistant .message-bubble {
background: var(--color-bg-2);
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.04),
0 4px 12px rgba(0, 0, 0, 0.06);
border-bottom-left-radius: 4px;
}
@@ -122,17 +128,27 @@
padding: 0 4px;
}
// AI 思考过程
.message-thinking {
// AI 消息Block容器thinking和tool通用
.message-block {
margin-bottom: 12px;
border: 1px solid var(--color-border-3);
border-radius: 12px;
overflow: hidden;
background: #FFFBEB;
cursor: pointer;
&.expanded {
.message-block-header-icon {
transform: rotate(90deg);
}
.message-block-content {
display: block;
}
}
}
.message-thinking-header {
.message-block-header {
display: flex;
align-items: center;
gap: 8px;
@@ -150,26 +166,31 @@
}
}
.message-thinking-icon {
.message-block-header-icon {
transition: transform 0.2s ease;
font-size: 12px;
flex-shrink: 0;
}
.message-thinking.expanded .message-thinking-icon {
transform: rotate(90deg);
.message-block-header-title {
flex: 1;
}
.message-thinking-content {
.message-block-header-status {
font-size: 12px;
color: var(--color-text-3);
display: flex;
align-items: center;
gap: 4px;
}
.message-block-content {
padding: 12px 14px;
font-size: 14px;
line-height: 1.6;
color: #78350F;
display: none;
.message-thinking.expanded & {
display: block;
}
ul {
margin: 8px 0 0 0;
padding-left: 20px;
@@ -180,6 +201,95 @@
}
}
.message-block-time {
font-size: 12px;
color: var(--color-text-4);
padding: 8px 14px 12px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
// 工具调用Block
.message-tool {
background: #F0F9FF;
border-color: #BFDBFE;
.message-block-header {
color: #1E40AF;
background: rgba(219, 234, 254, 0.5);
}
.message-block-header-status {
color: #1D4ED8;
}
}
.message-tool-running {
color: #3B82F6;
&::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
animation: pulse 1.5s ease-in-out infinite;
}
}
.message-tool-completed {
color: #10B981;
}
.message-tool-error {
color: #EF4444;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
// 工具调用的input/output分隔
.message-tool-io {
border-top: 1px solid rgba(0, 0, 0, 0.05);
padding-top: 12px;
margin-top: 12px;
&:first-child {
border-top: none;
padding-top: 0;
margin-top: 0;
}
}
.message-tool-io-label {
font-size: 12px;
font-weight: 600;
color: #64748B;
margin-bottom: 8px;
}
.message-tool-io-content {
background: #1E293B;
border-radius: 8px;
padding: 12px;
overflow-x: auto;
pre, code {
font-family: 'Fira Code', monospace;
font-size: 13px;
color: #E2E8F0;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
}
// 输入区
.chat-input-wrapper {
padding: 16px 24px 24px;
@@ -208,11 +318,18 @@
.chat-input-box {
border: 1px solid var(--color-border-3);
border-radius: 16px;
overflow: hidden;
background: var(--color-bg-1);
transition: all var(--transition);
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.04);
&--horizontal {
display: flex;
flex-direction: row;
align-items: stretch;
padding: 8px 12px 8px 8px;
min-height: 44px;
}
&:hover {
border-color: #CBD5E1;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
@@ -226,21 +343,27 @@
.chat-input-main {
display: flex;
align-items: flex-end;
align-items: center;
gap: 8px;
padding: 10px 12px 10px 14px;
padding: 0;
flex: 1;
@include chat-mobile {
width: 100%;
padding: 0;
}
}
.chat-input {
flex: 1;
padding: 6px 2px;
padding: 0 2px;
border: none;
outline: none;
font-size: 15px;
resize: none;
min-height: 24px;
height: 28px;
max-height: 200px;
line-height: 1.6;
line-height: 28px;
background: transparent;
color: var(--color-text-1);
@@ -254,6 +377,11 @@
align-items: center;
gap: 6px;
flex-shrink: 0;
@include chat-mobile {
width: 100%;
justify-content: space-between;
}
}
.chat-input-tools {
@@ -261,6 +389,10 @@
gap: 2px;
padding-right: 6px;
border-right: 1px solid var(--color-border-2);
@include chat-mobile {
border-right: none;
}
}
.chat-input-tool {
@@ -876,3 +1008,24 @@
padding: 12px 16px 16px;
}
}
// 聊天输入框响应式布局
@include chat-mobile {
.chat-input-box {
&--horizontal {
flex-direction: column;
padding: 8px 12px;
}
}
.model-selector {
width: 100%;
flex-shrink: 0;
.model-selector__trigger {
border-right: none;
border-bottom: 1px solid var(--color-border-2);
border-radius: 16px;
}
}
}

View File

@@ -4,3 +4,4 @@
@forward 'admin';
@forward 'developer';
@forward 'home';
@forward 'model-selector';

View File

@@ -0,0 +1,247 @@
@use '../../tokens' as *;
.model-selector {
position: relative;
z-index: 1;
display: flex;
align-items: stretch;
margin-right: 8px;
&--open {
z-index: 1000;
}
&__dropdown {
z-index: 1001;
}
&--compact {
width: 100px;
flex-shrink: 0;
}
&:not(.model-selector--compact) {
width: 160px;
flex-shrink: 0;
}
}
.model-selector--open {
.model-selector__arrow--open {
transform: rotate(180deg);
color: var(--color-primary);
}
.model-selector__trigger {
background: var(--color-bg-3);
}
}
.model-selector__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-2;
padding: 0 10px;
background: var(--color-bg-2);
border: none;
border-right: 1px solid var(--color-border-2);
border-radius: 16px;
cursor: pointer;
transition: background var(--transition);
user-select: none;
width: 100%;
box-sizing: border-box;
&:hover {
background: var(--color-bg-3);
}
.model-selector--compact & {
border-right: 1px solid var(--color-border-2);
border-radius: 16px;
padding: 0 8px;
}
}
.model-selector__content {
display: flex;
align-items: center;
gap: $spacing-2;
flex: 1;
min-width: 0;
overflow: hidden;
}
.model-selector__icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-primary) 0%, #8B5CF6 100%);
border-radius: $radius-sm;
color: #FFFFFF;
flex-shrink: 0;
}
.model-selector__name {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
@include text-truncate;
}
.model-selector__tag {
display: inline-flex;
align-items: center;
padding: 1px 6px;
background: linear-gradient(135deg, var(--color-primary) 0%, #60A5FA 100%);
color: #FFFFFF;
font-size: 10px;
font-weight: 600;
border-radius: 999px;
flex-shrink: 0;
}
.model-selector__arrow {
color: var(--color-text-3);
transition: transform var(--transition);
flex-shrink: 0;
display: flex;
align-items: center;
}
.model-selector__dropdown {
position: fixed;
background: var(--color-bg-1);
border: 1px solid var(--color-primary);
border-radius: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06);
animation: dropdownExpand 0.2s ease-out;
max-height: 350px;
overflow-y: auto;
min-width: 160px;
padding: 4px 0;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border-3);
border-radius: 3px;
&:hover {
background: var(--color-text-4);
}
}
}
@keyframes dropdownExpand {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.model-selector__group {
&:not(:last-child) {
border-bottom: 1px solid var(--color-border-2);
}
}
.model-selector__group-title {
display: flex;
align-items: center;
gap: 6px;
padding: 8px $spacing-3;
font-size: 11px;
font-weight: 600;
color: var(--color-text-3);
background: var(--color-bg-2);
border-bottom: 1px solid var(--color-border-2);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.model-selector__item {
display: flex;
align-items: center;
gap: $spacing-2;
padding: 8px $spacing-3;
padding-right: 32px;
cursor: pointer;
transition: background var(--transition-fast);
position: relative;
&:hover {
background: var(--color-primary-light);
}
&--selected {
background: var(--color-primary-light);
&::after {
content: '';
position: absolute;
right: $spacing-3;
top: 50%;
transform: translateY(-50%);
color: var(--color-primary);
font-weight: 700;
font-size: 12px;
}
}
}
.model-selector__item-text {
font-size: 13px;
color: var(--color-text-1);
flex: 1;
min-width: 0;
@include text-truncate;
}
.model-selector__item-tag {
display: inline-flex;
align-items: center;
padding: 1px 5px;
background: linear-gradient(135deg, var(--color-primary) 0%, #60A5FA 100%);
color: #FFFFFF;
font-size: 9px;
font-weight: 600;
border-radius: 999px;
flex-shrink: 0;
}
@include mobile {
.model-selector__trigger {
padding: 0 8px;
}
.model-selector__name {
font-size: 12px;
}
.model-selector__dropdown {
max-height: 280px;
}
.model-selector__item {
padding: 6px $spacing-2;
padding-right: 28px;
}
.model-selector__item-text {
font-size: 12px;
}
}

View File

@@ -3,3 +3,8 @@
$breakpoint-mobile: 768px;
$breakpoint-tablet: 1024px;
$breakpoint-desktop: 1025px;
// 聊天输入框断点
$breakpoint-chat-mobile: 480px;
$breakpoint-chat-tablet: 768px;
$breakpoint-chat-desktop: 769px;

View File

@@ -23,6 +23,25 @@
}
}
// 聊天输入框响应式断点
@mixin chat-mobile {
@media (max-width: #{$breakpoint-chat-mobile}) {
@content;
}
}
@mixin chat-tablet {
@media (min-width: #{$breakpoint-chat-mobile + 1}) and (max-width: #{$breakpoint-chat-tablet}) {
@content;
}
}
@mixin chat-desktop {
@media (min-width: #{$breakpoint-chat-desktop}) {
@content;
}
}
// 弹性布局
@mixin flex-center {
display: flex;