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 个示例配置(不同类型和状态)
This commit is contained in:
2026-04-10 13:43:19 +08:00
parent eeef824b24
commit 3f815db0b2
21 changed files with 1836 additions and 18 deletions

View File

@@ -103,6 +103,9 @@ src/
├── contexts/ # 全局状态 (UserContext)
├── services/ # 数据访问层 (api.js)
├── data/ # 模拟数据
│ ├── adminData.js # 管理台数据(含平台级模型配置)
│ ├── projectModelConfigs.js # 项目级模型配置数据
│ └── userModelConfigs.js # 个人模型配置数据
├── pages/ # 页面组件
│ ├── console/ # 工作台子页面
@@ -115,6 +118,8 @@ src/
│ ├── layouts/ # 布局系统
│ ├── components/ # 组件样式
│ ├── pages/ # 页面样式
│ │ ├── model-selector/ # 模型选择器样式
│ │ └── ...
│ └── global.scss # 主入口
├── App.jsx # 路由配置
@@ -128,7 +133,7 @@ src/
| 模块 | 路由 | 功能 |
|------|------|------|
| 首页 | `/` | 品牌展示、登录入口 |
| 工作台 | `/console` | 聊天、技能市场、定时任务、项目管理(成员/权限/技能配置 |
| 工作台 | `/console` | 聊天、技能市场、定时任务、项目管理(成员/权限/技能/模型配置)、个人模型配置 |
| 管理台 | `/admin` | 部门/用户/项目管理、模型配置 |
| 开发台 | `/developer` | 技能开发、版本管理 |

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

@@ -0,0 +1,104 @@
## 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** 下拉列表展开
- **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** 触发器和下拉列表形成一个整体
- **THEN** 触发器使用浅灰色背景,圆角顶部 16px
- **THEN** 下拉列表使用白色背景,圆角底部 12px
- **THEN** 边框颜色和阴影与输入框一致
#### Scenario: 响应式设计
- **WHEN** 用户使用移动设备访问
- **THEN** 模型选择器自适应屏幕宽度
- **THEN** 触发器使用较小的内边距和字体大小
- **THEN** 下拉列表最大高度调整为 280px

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,81 @@
## ADDED Requirements
### Requirement: 展示项目级模型配置列表
系统 SHALL 在工作台的项目管理二级菜单中的模型配置页面展示所有项目级模型配置的列表,每个配置项显示配置名称、配置类型、状态和操作按钮。
#### Scenario: 查看配置列表
- **WHEN** 用户通过工作台项目管理菜单访问 `/console/project/models` 页面
- **THEN** 系统显示所有项目级模型配置的表格
- **THEN** 每个配置项显示配置名称、配置类型、状态(生效中/未生效)和操作按钮
- **THEN** 表格头部显示"新增配置"按钮
### 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,81 @@
## ADDED Requirements
### Requirement: 展示个人模型配置列表
系统 SHALL 在工作台侧边栏的个人模型配置页面展示所有个人模型配置的列表,每个配置项显示配置名称、配置类型、状态和操作按钮。
#### Scenario: 查看配置列表
- **WHEN** 用户通过工作台侧边栏访问 `/console/user-models` 页面
- **THEN** 系统显示所有个人模型配置的表格
- **THEN** 每个配置项显示配置名称、配置类型、状态(生效中/未生效)和操作按钮
- **THEN** 表格头部显示"新增配置"按钮
### 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

@@ -23,6 +23,10 @@ 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';
@@ -71,6 +75,12 @@ function App() {
<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,6 +1,6 @@
import { useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { FiPlus, FiClock, FiList, FiUsers, FiBox, FiFolder, FiShield, FiSettings } 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';
@@ -96,6 +96,12 @@ function ConsoleLayout() {
active={isPathActive('/console/tasks')}
onClick={() => navigate('/console/tasks')}
/>
<SidebarNavItem
icon={<FiCpu />}
label="个人模型"
active={isPathActive('/console/user-models')}
onClick={() => navigate('/console/user-models')}
/>
<SidebarNavItem
icon={<FiList />}
label="日志查询"
@@ -125,6 +131,12 @@ function ConsoleLayout() {
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

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
}
}
];

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>
@@ -86,17 +94,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 +114,6 @@ function ModelConfigsPage() {
</div>
</div>
{/* 设为默认确认弹窗 */}
<Modal
visible={showSetActiveModal}
title="确认切换默认配置"
@@ -127,7 +129,6 @@ function ModelConfigsPage() {
<p>切换后原生效配置将变为备用状态</p>
</Modal>
{/* 删除确认弹窗 */}
<Modal
visible={showDeleteModal}
title="确认删除"
@@ -142,6 +143,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

@@ -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,99 @@
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';
function ModelSelector({ selectedModel, onSelectModel }) {
const [open, setOpen] = useState(false);
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);
}, []);
const handleSelect = (model) => {
onSelectModel(model);
setOpen(false);
};
return (
<div className={`model-selector ${open ? 'model-selector--open' : ''}`} ref={ref}>
<div
className="model-selector__trigger"
onClick={() => setOpen(!open)}
>
<div className="model-selector__content">
<span className="model-selector__icon">
<FiCode size={12} />
</span>
<span className="model-selector__name">{selectedModel?.name || '选择模型'}</span>
{selectedModel?.isDefault && (
<span className="model-selector__tag">默认</span>
)}
</div>
<span className={`model-selector__arrow ${open ? 'model-selector__arrow--open' : ''}`}>
<FiChevronDown size={14} />
</span>
</div>
{open && (
<div className="model-selector__dropdown">
{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>
{model.isDefault && (
<span className="model-selector__item-tag">默认</span>
)}
</div>
))}
</div>
))}
</div>
)}
</div>
);
}
function ChatPage() {
const { scene } = useParams();
@@ -10,6 +102,17 @@ function ChatPage() {
const html = chatScenes[currentScene] || '';
const chatMessagesRef = useRef(null);
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;
@@ -30,7 +133,7 @@ function ChatPage() {
el.removeEventListener('click', handleClick);
});
};
}, [scene, html]); // 依赖场景和html内容
}, [scene, html]);
return (
<div className="chat-layout">
@@ -41,6 +144,10 @@ function ChatPage() {
<div className="chat-input-wrapper">
<div className="chat-input-container">
<div className="chat-input-box">
<ModelSelector
selectedModel={selectedModel}
onSelectModel={setSelectedModel}
/>
<div className="chat-input-main">
<textarea
className="chat-input"
@@ -69,4 +176,4 @@ function ChatPage() {
);
}
export default ChatPage;
export default ChatPage;

View File

@@ -0,0 +1,143 @@
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>状态</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>
<td>
{config.isActive ? (
<span className="status status-running">生效中</span>
) : (
<span className="status status-stopped">未生效</span>
)}
</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

@@ -0,0 +1,143 @@
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>状态</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>
<td>
{config.isActive ? (
<span className="status status-running">生效中</span>
) : (
<span className="status status-stopped">未生效</span>
)}
</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,8 @@ 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';
/**
* 用户相关 API
@@ -377,8 +379,81 @@ 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;
},
},
};
export const api = {
user,
skills: skillsApi,
@@ -388,6 +463,7 @@ export const api = {
members: membersApi,
tasks: tasksApi,
admin: adminApi,
consoleModels: consoleModelConfigsApi,
};
export default api;

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

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

View File

@@ -0,0 +1,219 @@
@use '../../tokens' as *;
.model-selector {
position: relative;
z-index: 100;
}
.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: 6px $spacing-3;
background: var(--color-bg-2);
border: none;
border-bottom: 1px solid var(--color-border-2);
cursor: pointer;
transition: background var(--transition);
user-select: none;
&:hover {
background: var(--color-bg-3);
}
}
.model-selector__content {
display: flex;
align-items: center;
gap: $spacing-2;
flex: 1;
min-width: 0;
}
.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: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-bg-1);
border: 1px solid var(--color-primary);
border-top: none;
border-radius: 0 0 $radius-lg $radius-lg;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06);
overflow: hidden;
animation: dropdownExpand 0.2s ease-out;
max-height: 350px;
overflow-y: auto;
&::-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: 4px $spacing-2;
}
.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;
}
}