Compare commits

..

7 Commits

Author SHA1 Message Date
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
38 changed files with 2549 additions and 71 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` | 技能开发、版本管理 |
@@ -272,6 +277,7 @@ export default Example;
| SearchBar | `components/common/SearchBar.jsx` | 搜索框 |
| StatusBadge | `components/common/StatusBadge.jsx` | 状态标签 |
| SidebarNavItem | `components/layout/SidebarNavItem.jsx` | 侧边栏导航项 |
| SidebarNavGroup | `components/layout/SidebarNavGroup.jsx` | 可展开侧边栏导航组 |
---
@@ -295,6 +301,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>
@@ -434,4 +446,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

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

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

@@ -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,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>
@@ -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,135 @@
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, 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,6 +137,18 @@ function ChatPage() {
const chatScenes = getChatScenes();
const html = chatScenes[currentScene] || '';
const chatMessagesRef = useRef(null);
const [isCompact, setIsCompact] = 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;
@@ -30,7 +170,17 @@ function ChatPage() {
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">
@@ -40,7 +190,12 @@ function ChatPage() {
</div>
<div className="chat-input-wrapper">
<div className="chat-input-container">
<div className="chat-input-box">
<div className="chat-input-box chat-input-box--horizontal">
<ModelSelector
selectedModel={selectedModel}
onSelectModel={setSelectedModel}
variant={isCompact ? 'compact' : 'standard'}
/>
<div className="chat-input-main">
<textarea
className="chat-input"
@@ -69,4 +224,4 @@ function ChatPage() {
);
}
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,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

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

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

View File

@@ -208,11 +208,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 +233,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 +267,11 @@
align-items: center;
gap: 6px;
flex-shrink: 0;
@include chat-mobile {
width: 100%;
justify-content: space-between;
}
}
.chat-input-tools {
@@ -261,6 +279,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 +898,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;