Compare commits
26 Commits
a576a5e40e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7125753ca2 | |||
| 9dd2d4a1fc | |||
| ff4217c72a | |||
| 5f333b116a | |||
| e382a60e0a | |||
| def2b6bf61 | |||
| f507032d98 | |||
| 6e73e6a297 | |||
| 3f815db0b2 | |||
| eeef824b24 | |||
| 4f2faa3e8d | |||
| ea81a714bb | |||
| a67b0262d4 | |||
| 9d30c5e21f | |||
| f46f26fe27 | |||
| fb9833663c | |||
| ae75c82505 | |||
| 46016b0786 | |||
| 1455cc850d | |||
| 9feb62da3f | |||
| 7f493aa921 | |||
| ce9ebe5784 | |||
| bc4537b3bc | |||
| 76d613c4fe | |||
| f1d5e77285 | |||
| b00d75de8a |
240
docs/模型管理.md
Normal file
240
docs/模型管理.md
Normal 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兼容接口)
|
||||
- Temperature(0-2,默认0.7)
|
||||
- Max Tokens(1-128000,默认4096)
|
||||
- Top P(0-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*
|
||||
106
docs/规范整理.md
Normal file
106
docs/规范整理.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 规范文件整理流程
|
||||
|
||||
## 使用方式
|
||||
|
||||
将下方提示词完整复制给 AI 工具,即可启动一次规范文件的全面审查和整理。
|
||||
|
||||
---
|
||||
|
||||
## 提示词
|
||||
|
||||
```
|
||||
请对 openspec/specs/ 下的所有规范文件进行审查和整理,按以下流程执行:
|
||||
|
||||
## 第一步:全面阅读
|
||||
|
||||
1. 逐个读取 openspec/specs/ 下每个子目录的 spec.md,理解每个规范的覆盖范围
|
||||
2. 读取项目源码(src/ 目录),理解实际代码实现
|
||||
3. 读取 openspec/config.yaml,了解项目约束和规范
|
||||
|
||||
## 第二步:对比分析
|
||||
|
||||
将每个规范与实际代码对比,按以下维度逐项检查:
|
||||
|
||||
### A. 过时检查
|
||||
- 规范描述的功能/组件/样式是否在当前代码中仍然存在
|
||||
- 规范引用的文件路径、类名、API 接口是否与代码一致
|
||||
- 规范描述的交互流程是否仍是当前的实现方式
|
||||
|
||||
### B. 重复检查
|
||||
- 不同规范是否描述了相同的组件/功能/场景
|
||||
- 场景级别的重复(A 规范的 Scenario 与 B 规范的 Scenario 重复)
|
||||
- 概念级别的重复(A 规范整体描述的就是 B 规范已覆盖的内容)
|
||||
|
||||
### C. 错位检查
|
||||
- A 规范中是否有场景应该属于 B 规范
|
||||
- 某个 Requirement 是否放在了错误的功能域下
|
||||
|
||||
### D. 合并检查
|
||||
- 描述同一类主题的规范是否分散在多个文件中
|
||||
- 某个规范是否可以作为子集被另一个更大的规范吸收
|
||||
|
||||
### E. 命名检查
|
||||
- 规范名称是否准确反映其实际内容
|
||||
- 命名是否遵循统一的前缀约定(平台前缀:admin- / developer- / console-)
|
||||
- 名称是否便于 AI 工具搜索匹配(暴露关键业务词和组件名)
|
||||
|
||||
### F. 格式检查
|
||||
- 是否使用标准的 SHALL/WHEN/THEN 规范格式
|
||||
- 是否混入了变更记录(如"移除以下列"、"ADDED Requirements")而非功能规范
|
||||
- 是否存在空目录
|
||||
|
||||
## 第三步:输出分析报告
|
||||
|
||||
按以下结构输出:
|
||||
|
||||
1. 问题总览表(问题类型 × 涉及规范数)
|
||||
2. 逐项分析(每个有问题的规范,说明具体问题和建议)
|
||||
3. 重构方案(删除/合并/重命名/内容调整的具体操作)
|
||||
4. 重构后的规范目录结构
|
||||
|
||||
## 第四步:执行重构
|
||||
|
||||
按优先级分批执行:
|
||||
- P0:删除空目录和完全冗余的规范
|
||||
- P1:合并重复/子集规范到主规范中
|
||||
- P2:重命名不精准的规范、拆分错位的内容
|
||||
- P3:修正与代码不匹配的细节描述
|
||||
|
||||
每步执行后确认目录结构完整。
|
||||
|
||||
## 命名约定
|
||||
|
||||
规范目录命名遵循以下规则,确保 AI 工具搜索时能精准匹配:
|
||||
|
||||
| 类型 | 命名模式 | 示例 |
|
||||
|------|---------|------|
|
||||
| 平台专属功能 | `{平台}-{功能}` | `admin-platform`、`console-my-skills`、`developer-platform` |
|
||||
| 跨平台组件/架构 | `{类别}` | `component-library`、`layout-system`、`design-tokens` |
|
||||
| 技能领域 | `skill-{方面}` | `skill-market`、`skill-status-rules`、`skill-version-management` |
|
||||
| 业务功能 | `{业务名词}` | `account-management`、`chat-scenarios` |
|
||||
|
||||
命名原则(提升 AI 检索命中率):
|
||||
- 名称中暴露可搜索的业务关键词(如 skill、modal、toast、account)
|
||||
- 同一平台的功能使用统一前缀(admin- / console- / developer-)
|
||||
- 同一领域的功能使用统一领域词前缀(skill-)
|
||||
- 避免泛化词(display → rules/behavior,basic → 删掉,general → 删掉)
|
||||
- 避免实现模式词(crud、list、table)而使用业务领域词
|
||||
- 避免同一关键词在不同规范中重复出现导致歧义(如 layout 只出现在一个规范名中)
|
||||
- 长度控制在 2-3 个词,去掉不影响检索的冗余词(info、data 等)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 补充说明
|
||||
|
||||
### 审查时的判断边界
|
||||
|
||||
- **规范 vs 代码**:规范描述"应该是什么",不描述"代码怎么写"。如果规范中出现了具体文件路径(如 `src/data/adminData.js`),通常是实现细节而非规范,应该清理
|
||||
- **规范 vs 变更记录**:规范用 SHALL/WHEN/THEN 格式描述功能需求。如果出现"移除以下列"、"保持现有样式"、"ADDED/MODIFIED Requirements"等措辞,说明混入了变更指令,需要改写
|
||||
- **规范 vs 文档**:规范不替代 README 或开发文档,不需要描述项目背景、技术选型等宏观信息
|
||||
|
||||
### 建议的定期审查节奏
|
||||
|
||||
- 每完成一批功能变更后,对照新代码检查相关规范是否需要更新
|
||||
- 规范数量超过 30 个时,建议做一次全面审查
|
||||
- 新增规范前,先搜索现有规范名称和内容,确认是否有可复用/扩展的规范
|
||||
@@ -5,11 +5,16 @@ context: |
|
||||
- 纯前端展示原型项目(非功能原型),无后端交互,供内部开发人员参考UI界面使用,目标在于展示页面布局、样式和组件能力
|
||||
- 允许轻量级交互展示(如表单验证、弹框),状态展示策略:不重叠的状态通过静态数据驱动展示,重叠/覆盖类状态(弹框、下拉、抽屉等)允许简单交互切换
|
||||
- 示例数据应精心设计,展示不同的页面元素状态
|
||||
- 新增代码要遵循原有代码的设计风格和模式,优先考虑复用已有页面或组件的布局,优先考虑复用已有样式(src/styles)
|
||||
- 不引入UI库,使用当前SCSS样式方案
|
||||
- 使用pnpm作为包管理器,javascript作为开发语言,不引入typescript或eslint
|
||||
- 不构建测试,使用pnpm build验证打包即可,AI禁止运行pnpm dev(会挂起流程)
|
||||
- 不做性能优化,保持vite-plugin-singlefile单文件打包
|
||||
- 不做安全防御性编程,eval/dangerouslySetInnerHTML等按需使用
|
||||
- README.md是项目的开发文档,记录代码结构和关键开发模式,优先读取获取上下文
|
||||
- **优先阅读README.md**,README.md文档是项目的开发文档,记录代码结构和关键开发模式,优先读取获取上下文
|
||||
- 涉及页面/路由/组件/功能模块变更或技术栈调整时,同步更新README.md
|
||||
- Git提交: 仅中文; 格式为"类型: 简短描述",类型可选: feat(新功能)/fix(修复)/refactor(重构)/docs(文档)/style(格式)/test(测试)/chore(构建/工具); 多行描述空行后加详细说明; 禁创建git操作task
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
- 仔细审查每一个过往spec判断是否存在Modified Capabilities
|
||||
|
||||
124
openspec/specs/account-management/spec.md
Normal file
124
openspec/specs/account-management/spec.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Capability: 账号管理
|
||||
|
||||
提供账号管理功能,允许用户查看和编辑个人信息、修改密码。
|
||||
|
||||
## Purpose
|
||||
|
||||
账号管理页面允许当前登录用户管理自己的个人信息,包括查看账号信息、编辑基本信息、修改密码。三端(工作台、管理台、开发台)共享同一个账号管理组件。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 账号信息展示
|
||||
|
||||
系统 SHALL 在账号管理页面展示当前登录用户的账号信息,包括头像、用户名、姓名、邮箱、手机号、所属部门。
|
||||
|
||||
#### Scenario: 展示用户头像
|
||||
- **WHEN** 用户进入账号管理页面
|
||||
- **THEN** 系统显示用户头像(首字母)
|
||||
- **AND** 头像使用渐变背景色
|
||||
|
||||
#### Scenario: 展示基本信息
|
||||
- **WHEN** 用户进入账号管理页面
|
||||
- **THEN** 系统显示用户名、姓名、邮箱、手机号、所属部门
|
||||
- **AND** 用户名字段为只读状态
|
||||
- **AND** 所属部门字段为只读状态
|
||||
|
||||
### Requirement: 编辑基本信息
|
||||
|
||||
系统 SHALL 允许用户编辑姓名、邮箱、手机号等可修改的基本信息。
|
||||
|
||||
#### Scenario: 编辑姓名
|
||||
- **WHEN** 用户修改姓名字段
|
||||
- **THEN** 输入框可编辑
|
||||
- **AND** 点击保存按钮后显示保存成功提示
|
||||
|
||||
#### Scenario: 编辑邮箱
|
||||
- **WHEN** 用户修改邮箱字段
|
||||
- **THEN** 输入框可编辑
|
||||
- **AND** 点击保存按钮后显示保存成功提示
|
||||
|
||||
#### Scenario: 编辑手机号
|
||||
- **WHEN** 用户修改手机号字段
|
||||
- **THEN** 输入框可编辑
|
||||
- **AND** 点击保存按钮后显示保存成功提示
|
||||
|
||||
#### Scenario: 保存成功提示
|
||||
- **WHEN** 用户点击"保存修改"按钮
|
||||
- **THEN** 系统显示 Toast 提示"保存成功"
|
||||
- **AND** Toast 在 3 秒后自动消失
|
||||
|
||||
### Requirement: 修改密码
|
||||
|
||||
系统 SHALL 允许用户通过输入当前密码和新密码来修改登录密码。
|
||||
|
||||
#### Scenario: 显示修改密码表单
|
||||
- **WHEN** 用户进入账号管理页面
|
||||
- **THEN** 系统显示修改密码卡片
|
||||
- **AND** 包含当前密码、新密码、确认密码三个输入框
|
||||
|
||||
#### Scenario: 密码验证 - 当前密码为空
|
||||
- **WHEN** 用户未输入当前密码
|
||||
- **AND** 用户点击"更新密码"按钮
|
||||
- **THEN** 系统显示错误提示"请输入当前密码"
|
||||
|
||||
#### Scenario: 密码验证 - 新密码为空
|
||||
- **WHEN** 用户未输入新密码
|
||||
- **AND** 用户点击"更新密码"按钮
|
||||
- **THEN** 系统显示错误提示"请输入新密码"
|
||||
|
||||
#### Scenario: 密码验证 - 确认密码为空
|
||||
- **WHEN** 用户未输入确认密码
|
||||
- **AND** 用户点击"更新密码"按钮
|
||||
- **THEN** 系统显示错误提示"请再次输入新密码"
|
||||
|
||||
#### Scenario: 密码验证 - 两次密码不一致
|
||||
- **WHEN** 用户输入的新密码与确认密码不一致
|
||||
- **AND** 用户点击"更新密码"按钮
|
||||
- **THEN** 系统显示错误提示"两次输入的密码不一致"
|
||||
|
||||
### Requirement: 表单校验错误展示
|
||||
|
||||
系统 SHALL 在表单中展示校验错误状态,输入框边框变红并在下方显示错误提示文字。
|
||||
|
||||
#### Scenario: 必填项为空
|
||||
- **WHEN** 用户在修改密码表单中不输入当前密码直接点击"更新密码"
|
||||
- **THEN** 当前密码输入框边框变红,下方显示"请输入当前密码"错误提示
|
||||
|
||||
#### Scenario: 邮箱格式错误
|
||||
- **WHEN** 用户在个人信息表单中输入无效邮箱格式
|
||||
- **THEN** 邮箱输入框边框变红,下方显示"请输入有效的邮箱地址"错误提示
|
||||
|
||||
#### Scenario: 密码不一致
|
||||
- **WHEN** 用户在修改密码表单中输入两次不同的新密码
|
||||
- **THEN** 确认密码输入框边框变红,下方显示"两次输入的密码不一致"错误提示
|
||||
|
||||
### Requirement: 三端统一入口
|
||||
|
||||
系统 SHALL 在工作台、管理台、开发台的侧边栏用户信息区域提供账号管理入口。
|
||||
|
||||
#### Scenario: 工作台入口
|
||||
- **WHEN** 用户在工作台点击侧边栏用户信息区域
|
||||
- **THEN** 系统导航到账号管理页面
|
||||
|
||||
#### Scenario: 管理台入口
|
||||
- **WHEN** 用户在管理台点击侧边栏用户信息区域
|
||||
- **THEN** 系统导航到账号管理页面
|
||||
|
||||
#### Scenario: 开发台入口
|
||||
- **WHEN** 用户在开发台点击侧边栏用户信息区域
|
||||
- **THEN** 系统导航到账号管理页面
|
||||
|
||||
### Requirement: 管理台账号管理页面配置
|
||||
|
||||
管理台 SHALL 在页面配置中包含账号管理页面配置。
|
||||
|
||||
#### Scenario: 管理台页面配置
|
||||
- **WHEN** 用户查看管理台页面配置
|
||||
- **THEN** 系统包含 account 页面配置
|
||||
- **AND** account 页面标题为"账号管理"
|
||||
- **AND** account 页面图标为 FiUser
|
||||
|
||||
#### Scenario: 管理台侧边栏用户点击
|
||||
- **WHEN** 用户在管理台侧边栏点击用户信息区域
|
||||
- **THEN** 系统导航到账号管理页面
|
||||
- **AND** 页面标题显示为"账号管理"
|
||||
@@ -1,27 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 管理台静态数据文件
|
||||
管理台 SHALL 有独立的数据文件,提供部门、用户、项目、总览指标、全局日志的模拟数据。
|
||||
|
||||
#### Scenario: 数据文件结构
|
||||
- **WHEN** 项目加载
|
||||
- **THEN** `src/data/adminData.js` 导出 adminDepartments(部门列表)、adminUsers(用户列表)、adminProjects(项目列表)、adminOverview(总览指标和异常数据)、adminLogs(全局日志数据)
|
||||
|
||||
### Requirement: 示例数据展示多种状态
|
||||
管理台数据 SHALL 包含不同状态的示例记录,以展示页面的各种展示状态。
|
||||
|
||||
#### Scenario: 部门数据状态
|
||||
- **WHEN** 加载部门数据
|
||||
- **THEN** 数据包含"正常"和"禁用"两种状态的部门记录
|
||||
|
||||
#### Scenario: 用户数据状态
|
||||
- **WHEN** 加载用户数据
|
||||
- **THEN** 数据包含"管理员"、"开发者"、"成员"三种角色,以及"正常"和"禁用"两种状态的用户记录
|
||||
|
||||
#### Scenario: 项目数据状态
|
||||
- **WHEN** 加载项目数据
|
||||
- **THEN** 数据包含"正常"和"禁用"两种状态的项目记录
|
||||
|
||||
#### Scenario: 日志数据状态
|
||||
- **WHEN** 加载全局日志数据
|
||||
- **THEN** 数据包含不同用户、部门、类型、状态的日志记录,至少包含"成功"、"失败"、"警告"三种状态
|
||||
@@ -1,43 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 全局日志列表展示
|
||||
管理台日志查询页 SHALL 展示平台全局系统操作日志列表。
|
||||
|
||||
#### Scenario: 日志列表渲染
|
||||
- **WHEN** 用户点击侧边栏"日志查询"导航项
|
||||
- **THEN** 页面显示日志列表表格,列包含时间、用户、部门、类型、操作、状态、详情
|
||||
|
||||
### Requirement: 多维度日志筛选
|
||||
管理台日志查询页 SHALL 支持按关键词、用户、部门、类型、状态、时间范围进行筛选。
|
||||
|
||||
#### Scenario: 关键词筛选
|
||||
- **WHEN** 用户在关键词输入框输入文本并点击查询
|
||||
- **THEN** 日志列表仅显示操作或详情中包含该关键词的记录
|
||||
|
||||
#### Scenario: 用户筛选
|
||||
- **WHEN** 用户选择某个用户并点击查询
|
||||
- **THEN** 日志列表仅显示该用户的操作记录
|
||||
|
||||
#### Scenario: 部门筛选
|
||||
- **WHEN** 用户选择某个部门并点击查询
|
||||
- **THEN** 日志列表仅显示该部门成员的操作记录
|
||||
|
||||
#### Scenario: 类型筛选
|
||||
- **WHEN** 用户选择某种类型(登录、实例操作、技能、配置修改、文件上传)并点击查询
|
||||
- **THEN** 日志列表仅显示该类型的记录
|
||||
|
||||
#### Scenario: 状态筛选
|
||||
- **WHEN** 用户选择某种状态(成功、失败、警告)并点击查询
|
||||
- **THEN** 日志列表仅显示该状态的记录
|
||||
|
||||
#### Scenario: 时间范围筛选
|
||||
- **WHEN** 用户设置开始日期和结束日期并点击查询
|
||||
- **THEN** 日志列表仅显示时间范围内的记录
|
||||
|
||||
#### Scenario: 筛选重置
|
||||
- **WHEN** 用户点击重置按钮
|
||||
- **THEN** 所有筛选条件清空,日志列表恢复显示全部记录
|
||||
|
||||
#### Scenario: 无匹配结果
|
||||
- **WHEN** 用户筛选后无匹配日志
|
||||
- **THEN** 显示空状态组件,提示"暂无匹配日志"
|
||||
112
openspec/specs/admin-model-config/spec.md
Normal file
112
openspec/specs/admin-model-config/spec.md
Normal file
@@ -0,0 +1,112 @@
|
||||
## Purpose
|
||||
|
||||
管理台模型配置管理功能,支持管理员管理多组 AI 模型接入配置,并可选择其中一组作为平台默认配置生效。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 配置列表展示
|
||||
系统 SHALL 在管理台展示模型配置列表,包括当前生效配置卡片和所有配置表格。
|
||||
|
||||
#### Scenario: 查看配置列表页
|
||||
- **WHEN** 管理员进入模型配置管理页面
|
||||
- **THEN** 系统展示当前生效配置卡片(包含名称、类型)
|
||||
- **AND** 系统展示配置列表表格(包含名称、类型、操作按钮)
|
||||
|
||||
#### Scenario: 默认配置徽章展示
|
||||
- **WHEN** 配置列表中有多个配置
|
||||
- **THEN** 当前生效配置在配置名称旁显示"[默认]"标签,使用 `tag tag--admin` 样式类
|
||||
- **AND** 其他配置不显示标签
|
||||
- **AND** 配置行不使用高亮样式
|
||||
|
||||
### Requirement: 设为默认配置
|
||||
系统 SHALL 允许管理员将非生效配置设为平台默认配置,操作需二次确认。
|
||||
|
||||
#### Scenario: 成功切换默认配置
|
||||
- **WHEN** 管理员点击某未生效配置的"设为默认"按钮
|
||||
- **THEN** 系统显示二次确认弹窗
|
||||
- **AND** 管理员确认后,该配置变为生效状态
|
||||
- **AND** 原生效配置变为未生效状态
|
||||
|
||||
#### Scenario: 取消切换默认配置
|
||||
- **WHEN** 管理员点击"设为默认"按钮后
|
||||
- **AND** 在二次确认弹窗中点击取消
|
||||
- **THEN** 系统关闭弹窗,配置状态保持不变
|
||||
|
||||
### Requirement: 删除配置
|
||||
系统 SHALL 允许管理员删除非生效的配置,删除前需二次确认。
|
||||
|
||||
#### Scenario: 成功删除配置
|
||||
- **WHEN** 管理员点击某未生效配置的"删除"按钮
|
||||
- **THEN** 系统显示二次确认弹窗
|
||||
- **AND** 管理员确认后,该配置从列表中移除
|
||||
|
||||
#### Scenario: 无法删除生效配置
|
||||
- **WHEN** 某配置是当前生效配置
|
||||
- **THEN** 其删除按钮被禁用
|
||||
- **AND** 鼠标悬停时显示提示"生效中的配置不可删除"
|
||||
|
||||
### Requirement: 新增配置
|
||||
系统 SHALL 提供独立页面供管理员新增模型配置,支持选择配置类型并填写对应字段。
|
||||
|
||||
#### Scenario: 成功新增基础类型配置
|
||||
- **WHEN** 管理员点击"新增配置"按钮进入新增页面
|
||||
- **AND** 选择配置类型为"OpenAI 兼容接口"
|
||||
- **AND** 填写配置名称、API 地址、API 密钥、模型名称
|
||||
- **AND** 填写常用参数(Temperature、Max Tokens、Top P)
|
||||
- **AND** 点击保存
|
||||
- **THEN** 系统创建新配置并返回列表页
|
||||
- **AND** 新配置默认状态为"未生效"
|
||||
|
||||
#### Scenario: 成功新增智算平台配置
|
||||
- **WHEN** 管理员在新增页面选择配置类型为"智算管理平台"
|
||||
- **THEN** 表单动态切换为智算平台字段(API 地址、App ID、App Secret)
|
||||
- **AND** 管理员填写完成后保存
|
||||
- **THEN** 系统创建新配置并返回列表页
|
||||
|
||||
#### Scenario: 新增时切换类型清空字段
|
||||
- **WHEN** 管理员在新增页面已填写某类型的部分字段
|
||||
- **AND** 切换配置类型为另一种类型
|
||||
- **THEN** 系统清空已填写的类型特定字段
|
||||
- **AND** 保留通用字段(配置名称)
|
||||
|
||||
#### Scenario: 新增配置表单验证失败
|
||||
- **WHEN** 管理员提交表单时未填写必填项
|
||||
- **THEN** 系统高亮显示未填写的必填字段
|
||||
- **AND** 阻止表单提交
|
||||
|
||||
### Requirement: 编辑配置
|
||||
系统 SHALL 提供独立页面供管理员编辑现有配置,仅允许编辑非生效配置。
|
||||
|
||||
#### Scenario: 成功编辑配置
|
||||
- **WHEN** 管理员点击某未生效配置的"编辑"按钮
|
||||
- **THEN** 系统进入编辑页面,预填充该配置的当前值
|
||||
- **AND** 配置类型字段显示为只读
|
||||
- **AND** 管理员修改其他字段后保存
|
||||
- **THEN** 系统更新配置并返回列表页
|
||||
|
||||
#### Scenario: 无法编辑生效配置
|
||||
- **WHEN** 某配置是当前生效配置
|
||||
- **THEN** 其编辑按钮被禁用
|
||||
- **AND** 鼠标悬停时显示提示"生效中的配置不可编辑"
|
||||
|
||||
#### Scenario: 编辑时类型字段只读
|
||||
- **WHEN** 管理员在编辑配置页面
|
||||
- **THEN** 配置类型选择器被禁用
|
||||
- **AND** 显示提示"配置类型不可修改"
|
||||
|
||||
### Requirement: 配置类型注册表
|
||||
系统 SHALL 使用可扩展的注册表机制定义配置类型,每种类型包含独立的字段定义和验证规则。
|
||||
|
||||
#### Scenario: 扩展新配置类型
|
||||
- **WHEN** 需要添加新的配置类型(如"阿里云百炼")
|
||||
- **THEN** 开发者只需在注册表中添加类型定义
|
||||
- **AND** 无需修改配置表单页面的核心逻辑
|
||||
- **AND** 新类型自动在类型选择器和表单中生效
|
||||
|
||||
### Requirement: 密钥字段掩码显示
|
||||
系统 SHALL 在配置表单页对所有敏感字段(API 密钥、App Secret 等)使用掩码显示。
|
||||
|
||||
#### Scenario: 表单页密钥掩码
|
||||
- **WHEN** 编辑配置时表单显示已保存的密钥
|
||||
- **THEN** 密钥输入框显示为掩码格式
|
||||
- **AND** 管理员可点击显示/隐藏按钮切换明文/密文
|
||||
@@ -1,22 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 运营指标展示
|
||||
管理台总览页 SHALL 展示平台核心运营指标数据,以卡片形式呈现。
|
||||
|
||||
#### Scenario: 指标卡片展示
|
||||
- **WHEN** 用户打开管理台总览页
|
||||
- **THEN** 页面顶部显示4个指标卡片:用户总数、部门数量、项目数量、今日调用次数,每个卡片包含数值和趋势变化值
|
||||
|
||||
### Requirement: 异常/待办事项提醒
|
||||
管理台总览页 SHALL 展示平台异常事件和待办事项列表。
|
||||
|
||||
#### Scenario: 异常事项展示
|
||||
- **WHEN** 用户打开管理台总览页
|
||||
- **THEN** 页面左侧区域显示异常/待办事项列表,每条包含警告图标和事项描述(如定时任务执行失败、用户账号被禁用、项目处于禁用状态等)
|
||||
|
||||
### Requirement: 最近操作日志展示
|
||||
管理台总览页 SHALL 展示最近的操作日志精简列表。
|
||||
|
||||
#### Scenario: 日志列表展示
|
||||
- **WHEN** 用户打开管理台总览页
|
||||
- **THEN** 页面右侧区域显示最近5条操作日志,每条包含时间、用户、操作类型、状态标签
|
||||
@@ -1,6 +1,33 @@
|
||||
## ADDED Requirements
|
||||
# Capability: 管理台
|
||||
|
||||
### Requirement: 列表搜索筛选生效
|
||||
## Purpose
|
||||
|
||||
管理台提供平台运营管理功能,包括总览仪表盘、部门/用户/项目管理、全局日志查询。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 运营指标展示
|
||||
管理台总览页 SHALL 展示平台核心运营指标数据,以卡片形式呈现。
|
||||
|
||||
#### Scenario: 指标卡片展示
|
||||
- **WHEN** 用户打开管理台总览页
|
||||
- **THEN** 页面顶部显示4个指标卡片:用户总数、部门数量、项目数量、今日调用次数,每个卡片包含数值和趋势变化值
|
||||
|
||||
### Requirement: 异常/待办事项提醒
|
||||
管理台总览页 SHALL 展示平台异常事件和待办事项列表。
|
||||
|
||||
#### Scenario: 异常事项展示
|
||||
- **WHEN** 用户打开管理台总览页
|
||||
- **THEN** 页面左侧区域显示异常/待办事项列表,每条包含警告图标和事项描述(如定时任务执行失败、用户账号被禁用、项目处于禁用状态等)
|
||||
|
||||
### Requirement: 最近操作日志展示
|
||||
管理台总览页 SHALL 展示最近的操作日志精简列表。
|
||||
|
||||
#### Scenario: 日志列表展示
|
||||
- **WHEN** 用户打开管理台总览页
|
||||
- **THEN** 页面右侧区域显示最近5条操作日志,每条包含时间、用户、操作类型、状态标签
|
||||
|
||||
### Requirement: 实体列表搜索筛选
|
||||
部门管理、用户管理、项目管理列表页 SHALL 支持按关键词和其他条件筛选列表数据。
|
||||
|
||||
#### Scenario: 部门关键词搜索
|
||||
@@ -85,76 +112,44 @@
|
||||
- **WHEN** 用户在确认弹框中点击取消按钮
|
||||
- **THEN** 弹框关闭,列表不变
|
||||
|
||||
### Requirement: 技能列表搜索筛选
|
||||
我的技能列表 SHALL 支持按关键词、分类和状态筛选技能数据。
|
||||
### Requirement: 全局日志列表展示
|
||||
管理台日志查询页 SHALL 展示平台全局系统操作日志列表。
|
||||
|
||||
#### Scenario: 关键词搜索
|
||||
- **WHEN** 用户在筛选卡片的关键词输入框中输入文本并点击查询
|
||||
- **THEN** 列表仅显示技能名称或描述中包含该关键词的记录
|
||||
#### Scenario: 日志列表渲染
|
||||
- **WHEN** 用户点击侧边栏"日志查询"导航项
|
||||
- **THEN** 页面显示日志列表表格,列包含时间、用户、部门、类型、操作、状态、详情
|
||||
|
||||
#### Scenario: 分类筛选
|
||||
- **WHEN** 用户在筛选卡片的分类下拉框选择某个分类并点击查询
|
||||
- **THEN** 列表仅显示该分类的技能记录
|
||||
### Requirement: 多维度日志筛选
|
||||
管理台日志查询页 SHALL 支持按关键词、用户、部门、类型、状态、时间范围进行筛选。
|
||||
|
||||
#### Scenario: 关键词筛选
|
||||
- **WHEN** 用户在关键词输入框输入文本并点击查询
|
||||
- **THEN** 日志列表仅显示操作或详情中包含该关键词的记录
|
||||
|
||||
#### Scenario: 用户筛选
|
||||
- **WHEN** 用户选择某个用户并点击查询
|
||||
- **THEN** 日志列表仅显示该用户的操作记录
|
||||
|
||||
#### Scenario: 部门筛选
|
||||
- **WHEN** 用户选择某个部门并点击查询
|
||||
- **THEN** 日志列表仅显示该部门成员的操作记录
|
||||
|
||||
#### Scenario: 类型筛选
|
||||
- **WHEN** 用户选择某种类型(登录、实例操作、技能、配置修改、文件上传)并点击查询
|
||||
- **THEN** 日志列表仅显示该类型的记录
|
||||
|
||||
#### Scenario: 状态筛选
|
||||
- **WHEN** 用户在筛选卡片的状态下拉框选择某个状态(已发布/草稿)并点击查询
|
||||
- **THEN** 列表仅显示该状态的技能记录
|
||||
- **WHEN** 用户选择某种状态(成功、失败、警告)并点击查询
|
||||
- **THEN** 日志列表仅显示该状态的记录
|
||||
|
||||
#### Scenario: 时间范围筛选
|
||||
- **WHEN** 用户设置开始日期和结束日期并点击查询
|
||||
- **THEN** 日志列表仅显示时间范围内的记录
|
||||
|
||||
#### Scenario: 筛选重置
|
||||
- **WHEN** 用户在筛选卡片点击重置按钮
|
||||
- **THEN** 筛选条件清空,列表恢复显示全部技能
|
||||
- **WHEN** 用户点击重置按钮
|
||||
- **THEN** 所有筛选条件清空,日志列表恢复显示全部记录
|
||||
|
||||
### Requirement: 技能列表分页
|
||||
我的技能列表 SHALL 在表格底部展示分页组件。
|
||||
|
||||
#### Scenario: 分页展示
|
||||
- **WHEN** 用户打开我的技能列表页
|
||||
- **THEN** 表格底部右侧显示分页组件,包含页码按钮和前后翻页按钮
|
||||
|
||||
### Requirement: 技能上架下架
|
||||
我的技能列表和技能详情页 SHALL 提供技能的上架/下架操作入口。
|
||||
|
||||
#### Scenario: 列表页下架操作
|
||||
- **WHEN** 用户在已发布技能的操作列点击"下架"按钮
|
||||
- **THEN** 页面展示成功提示"已下架"
|
||||
|
||||
#### Scenario: 详情页上架/下架切换
|
||||
- **WHEN** 用户在技能详情页点击"下架技能"或"上架技能"按钮
|
||||
- **THEN** 页面展示对应的成功提示
|
||||
|
||||
### Requirement: 技能删除确认
|
||||
我的技能列表和技能详情页 SHALL 提供技能删除操作,需弹框确认。
|
||||
|
||||
#### Scenario: 列表页删除确认
|
||||
- **WHEN** 用户在技能列表的操作列点击"删除"按钮
|
||||
- **THEN** 弹出确认弹框,显示"确定要删除技能"{技能名称}"吗?此操作不可撤销。"
|
||||
|
||||
#### Scenario: 确认删除技能
|
||||
- **WHEN** 用户在确认弹框中点击"删除"按钮
|
||||
- **THEN** 弹框关闭,页面展示成功提示"已删除"
|
||||
|
||||
#### Scenario: 取消删除技能
|
||||
- **WHEN** 用户在确认弹框中点击"取消"按钮
|
||||
- **THEN** 弹框关闭,列表不变
|
||||
|
||||
#### Scenario: 详情页删除确认
|
||||
- **WHEN** 用户在技能详情页点击"删除技能"按钮
|
||||
- **THEN** 弹出确认弹框,显示"确定要删除技能"{技能名称}"吗?此操作不可撤销。"
|
||||
|
||||
### Requirement: 版本删除确认
|
||||
技能详情页版本历史表格 SHALL 为未启用版本提供删除操作,需弹框确认。
|
||||
|
||||
#### Scenario: 版本删除按钮展示
|
||||
- **WHEN** 用户在技能详情页版本历史表格中查看某个未启用的版本
|
||||
- **THEN** 该版本操作列显示"删除"按钮
|
||||
|
||||
#### Scenario: 确认删除版本
|
||||
- **WHEN** 用户点击版本的"删除"按钮并在确认弹框中点击"删除"
|
||||
- **THEN** 弹框关闭,页面展示成功提示"已删除"
|
||||
|
||||
### Requirement: 版本审核拒绝原因展示
|
||||
技能详情页版本历史表格 SHALL 为被拒绝的版本展示拒绝原因。
|
||||
|
||||
#### Scenario: 拒绝原因展示
|
||||
- **WHEN** 用户在版本历史表格中查看状态为"审核拒绝"的版本
|
||||
- **THEN** 该版本状态标签下方显示红色小字的拒绝原因信息
|
||||
#### Scenario: 无匹配结果
|
||||
- **WHEN** 用户筛选后无匹配日志
|
||||
- **THEN** 显示空状态组件,提示"暂无匹配日志"
|
||||
89
openspec/specs/ai-message-blocks/spec.md
Normal file
89
openspec/specs/ai-message-blocks/spec.md
Normal file
@@ -0,0 +1,89 @@
|
||||
## Purpose
|
||||
|
||||
定义AI消息中思考过程、工具调用和对话正文的多Block结构规范。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: AI消息Block结构
|
||||
AI消息气泡内 SHALL 支持多个Block的灵活组织,Block类型包括thinking、tool、content三种。
|
||||
|
||||
#### Scenario: 单个Thinking Block
|
||||
- **WHEN** AI消息包含thinking过程
|
||||
- **THEN** 在消息气泡内显示thinking Block,包含标题、执行时间、可展开的详细内容
|
||||
|
||||
#### Scenario: 单个Tool Block
|
||||
- **WHEN** AI消息包含工具调用
|
||||
- **THEN** 在消息气泡内显示tool Block,包含工具名称、状态(input/output)
|
||||
|
||||
#### Scenario: 多个Block组合
|
||||
- **WHEN** AI消息包含多个thinking和tool
|
||||
- **THEN** 按声明顺序依次显示,最后显示content
|
||||
- **AND** 所有thinking和tool必须在content之前
|
||||
|
||||
### Requirement: 可折叠容器
|
||||
Thinking和Tool Block SHALL 使用统一的可折叠容器样式。
|
||||
|
||||
#### Scenario: 展开状态
|
||||
- **WHEN** 用户点击Block头部
|
||||
- **THEN** 显示详细内容区域
|
||||
|
||||
#### Scenario: 收起状态
|
||||
- **WHEN** Block初始状态为收起
|
||||
- **THEN** 只显示头部标题和状态,点击后展开
|
||||
|
||||
#### Scenario: Thinking默认展开
|
||||
- **WHEN** 渲染thinking Block
|
||||
- **THEN** 默认展开显示内容
|
||||
|
||||
#### Scenario: Tool默认收起
|
||||
- **WHEN** 渲染tool Block
|
||||
- **THEN** 默认收起,只显示头部
|
||||
|
||||
### Requirement: 工具调用显示
|
||||
Tool Block SHALL 清晰展示input和output内容。
|
||||
|
||||
#### Scenario: 显示输入参数
|
||||
- **WHEN** tool Block展开
|
||||
- **THEN** 显示INPUT区域,包含JSON格式化的调用参数
|
||||
|
||||
#### Scenario: 显示返回结果
|
||||
- **WHEN** tool执行完成
|
||||
- **THEN** 显示OUTPUT区域,包含JSON格式化的返回值
|
||||
|
||||
#### Scenario: 执行状态
|
||||
- **WHEN** 渲染tool Block
|
||||
- **THEN** 显示执行状态:running(运行中)/completed(完成)/error(失败)
|
||||
- **AND** running状态带脉冲动画
|
||||
|
||||
### Requirement: 时间显示
|
||||
时间显示在Block头部的status区域中。
|
||||
|
||||
#### Scenario: Block执行时间
|
||||
- **WHEN** 渲染thinking或tool Block
|
||||
- **THEN** 在头部status区域显示执行耗时,如"2.1s"
|
||||
|
||||
#### Scenario: Content时间
|
||||
- **WHEN** 渲染content Block
|
||||
- **THEN** 在Block底部显示消息发送时间,如"14:31"
|
||||
|
||||
### Requirement: 消息气泡样式
|
||||
AI消息气泡 SHALL 使用毛玻璃效果和精细阴影。
|
||||
|
||||
#### Scenario: 毛玻璃效果
|
||||
- **WHEN** 渲染AI消息气泡
|
||||
- **THEN** 使用backdrop-filter: blur(12px)实现毛玻璃效果
|
||||
|
||||
#### Scenario: 精细阴影
|
||||
- **WHEN** 渲染AI消息气泡
|
||||
- **THEN** 使用多层阴影实现精细立体效果
|
||||
|
||||
### Requirement: 场景数据
|
||||
消息场景数据 SHALL 符合新的Block结构。
|
||||
|
||||
#### Scenario: 删除File场景
|
||||
- **WHEN** 系统加载场景列表
|
||||
- **THEN** 不包含file场景
|
||||
|
||||
#### Scenario: 场景消息结构
|
||||
- **WHEN** 渲染场景消息
|
||||
- **THEN** 使用blocks结构组织thinking、tool、content
|
||||
26
openspec/specs/build-output-naming/spec.md
Normal file
26
openspec/specs/build-output-naming/spec.md
Normal file
@@ -0,0 +1,26 @@
|
||||
### Requirement: 构建输出文件命名
|
||||
|
||||
系统 SHALL 在执行 `pnpm build` 时,将输出文件命名为 `grandclaw-archtype-YYYYMMDD.html` 格式,其中 YYYYMMDD 为构建当天的本地日期。
|
||||
|
||||
#### Scenario: 标准构建输出
|
||||
- **WHEN** 执行 `pnpm build` 命令
|
||||
- **THEN** dist 目录中生成文件名为 `grandclaw-archtype-YYYYMMDD.html` 的单文件 HTML
|
||||
- **AND** 文件名为当天本地日期(如 2026年3月28日 构建则输出 `grandclaw-archtype-20260328.html`)
|
||||
|
||||
#### Scenario: 同日多次构建
|
||||
- **WHEN** 同一天内多次执行 `pnpm build`
|
||||
- **THEN** 每次构建输出覆盖同一天的文件
|
||||
- **AND** dist 目录中只存在一个文件
|
||||
|
||||
#### Scenario: 跨天构建
|
||||
- **WHEN** 不同日期执行 `pnpm build`
|
||||
- **THEN** 每次构建生成当天日期命名的文件
|
||||
- **AND** 历史文件被清空(Vite 默认行为,emptyOutDir: true)
|
||||
|
||||
### Requirement: 零额外依赖
|
||||
|
||||
构建输出命名功能 SHALL 通过自定义 Vite 插件实现,不引入额外的 npm 依赖。
|
||||
|
||||
#### Scenario: 依赖检查
|
||||
- **WHEN** 查看 package.json 的 dependencies 和 devDependencies
|
||||
- **THEN** 不存在为文件命名功能新增的依赖项
|
||||
322
openspec/specs/component-library/spec.md
Normal file
322
openspec/specs/component-library/spec.md
Normal file
@@ -0,0 +1,322 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 按钮组件
|
||||
组件库 SHALL 提供完整的按钮组件系统,支持多种变体和尺寸。
|
||||
|
||||
#### Scenario: 基础按钮
|
||||
- **WHEN** 开发者需要标准按钮
|
||||
- **THEN** 系统 SHALL 提供 `.btn` 类,包含基础样式(flex布局、居中、间距、过渡动画)
|
||||
|
||||
#### Scenario: 颜色变体
|
||||
- **WHEN** 开发者需要不同用途的按钮
|
||||
- **THEN** 系统 SHALL 提供 `.btn--primary`、`.btn--success`、`.btn--danger`、`.btn--warning`、`.btn--ghost` 等变体
|
||||
|
||||
#### Scenario: 尺寸变体
|
||||
- **WHEN** 开发者需要不同大小的按钮
|
||||
- **THEN** 系统 SHALL 提供 `.btn--sm`、`.btn--lg` 尺寸变体
|
||||
|
||||
#### Scenario: 图标按钮
|
||||
- **WHEN** 开发者需要仅含图标的按钮
|
||||
- **THEN** 系统 SHALL 提供 `.btn--icon-only` 修饰符
|
||||
|
||||
#### Scenario: 块级按钮
|
||||
- **WHEN** 开发者需要宽度占满的按钮
|
||||
- **THEN** 系统 SHALL 提供 `.btn--block` 修饰符
|
||||
|
||||
#### Scenario: 加载状态
|
||||
- **WHEN** 按钮处于加载中状态
|
||||
- **THEN** 系统 SHALL 提供 `.btn--loading` 修饰符,显示加载动画并禁用点击
|
||||
|
||||
### Requirement: 卡片组件
|
||||
组件库 SHALL 提供卡片容器组件。
|
||||
|
||||
#### Scenario: 基础卡片
|
||||
- **WHEN** 开发者需要内容容器
|
||||
- **THEN** 系统 SHALL 提供 `.card` 类,包含背景、边框、圆角、阴影
|
||||
|
||||
#### Scenario: 卡片区块
|
||||
- **WHEN** 卡片需要头部、主体、底部结构
|
||||
- **THEN** 系统 SHALL 提供 `.card__header`、`.card__body`、`.card__footer` 元素类
|
||||
|
||||
#### Scenario: 卡片变体
|
||||
- **WHEN** 开发者需要不同视觉风格的卡片
|
||||
- **THEN** 系统 SHALL 提供 `.card--flat`(无阴影)、`.card--elevated`(高阴影)等变体
|
||||
|
||||
### Requirement: 表格组件
|
||||
组件库 SHALL 提供数据表格组件。
|
||||
|
||||
#### Scenario: 基础表格
|
||||
- **WHEN** 开发者需要展示数据列表
|
||||
- **THEN** 系统 SHALL 提供 `.table` 类,包含表头、行、单元格样式
|
||||
|
||||
#### Scenario: 表格包装器
|
||||
- **WHEN** 表格需要横向滚动
|
||||
- **THEN** 系统 SHALL 提供 `.table__wrapper` 类,处理溢出和滚动
|
||||
|
||||
#### Scenario: 行悬停效果
|
||||
- **WHEN** 表格需要行悬停反馈
|
||||
- **THEN** 系统 SHALL 在 `.table tbody tr` 上提供悬停背景色变化
|
||||
|
||||
### Requirement: 表单组件
|
||||
组件库 SHALL 提供表单元素组件。
|
||||
|
||||
#### Scenario: 表单组
|
||||
- **WHEN** 开发者需要将标签和输入框组合
|
||||
- **THEN** 系统 SHALL 提供 `.form-group` 类,设置间距
|
||||
|
||||
#### Scenario: 标签
|
||||
- **WHEN** 表单需要字段标签
|
||||
- **THEN** 系统 SHALL 提供 `.form__label` 类,包含必填标记样式
|
||||
|
||||
#### Scenario: 输入框
|
||||
- **WHEN** 开发者需要文本输入
|
||||
- **THEN** 系统 SHALL 提供 `.form__input` 类,包含聚焦、错误状态样式
|
||||
|
||||
#### Scenario: 表单行
|
||||
- **WHEN** 表单项需要横向排列
|
||||
- **THEN** 系统 SHALL 提供 `.form__row` 和 `.form__col` 类
|
||||
|
||||
### Requirement: 状态标签组件
|
||||
组件库 SHALL 提供状态指示标签。
|
||||
|
||||
#### Scenario: 状态展示
|
||||
- **WHEN** 开发者需要展示运行/停止/错误等状态
|
||||
- **THEN** 系统 SHALL 提供 `.tag` 类和 `.tag--running`、`.tag--stopped`、`.tag--error`、`.tag--warning` 等变体
|
||||
|
||||
#### Scenario: 角色标签
|
||||
- **WHEN** 开发者需要展示用户角色
|
||||
- **THEN** 系统 SHALL 提供 `.tag--admin`、`.tag--member`、`.tag--developer` 等变体
|
||||
|
||||
### Requirement: 弹窗组件
|
||||
组件库 SHALL 提供模态弹窗组件,用于各类确认操作场景。
|
||||
|
||||
#### Scenario: 弹窗容器
|
||||
- **WHEN** 开发者需要显示模态弹窗
|
||||
- **THEN** 系统 SHALL 提供 `.modal` 和 `.modal__overlay` 类
|
||||
|
||||
#### Scenario: 弹窗结构
|
||||
- **WHEN** 弹窗需要标题、内容、操作区
|
||||
- **THEN** 系统 SHALL 提供 `.modal__header`、`.modal__body`、`.modal__footer` 元素类
|
||||
|
||||
#### Scenario: 删除任务确认
|
||||
- **WHEN** 用户点击定时任务的"删除"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认删除",内容为"确定要删除这个任务吗?"
|
||||
|
||||
#### Scenario: 取消订阅确认
|
||||
- **WHEN** 用户点击技能详情页的"取消订阅"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认取消订阅",内容为"确定要取消订阅该技能吗?"
|
||||
|
||||
#### Scenario: 移除成员确认
|
||||
- **WHEN** 用户点击项目成员的"移除"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认移除",内容为"确定要将该成员移出项目吗?"
|
||||
|
||||
#### Scenario: 技能市场订阅确认
|
||||
- **WHEN** 用户点击技能卡片的"订阅"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认订阅",内容为"确定要订阅该技能吗?"
|
||||
|
||||
#### Scenario: 技能市场取消订阅确认
|
||||
- **WHEN** 用户点击技能卡片的"已订阅"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认取消订阅",内容为"确定要取消订阅该技能吗?取消后将无法使用该技能。"
|
||||
|
||||
#### Scenario: 技能列表删除确认
|
||||
- **WHEN** 用户点击技能列表中某个技能的"删除"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认删除",内容为"确定要删除技能"{技能名称}"吗?此操作不可撤销。"
|
||||
|
||||
#### Scenario: 技能详情页删除确认
|
||||
- **WHEN** 用户点击技能详情页的"删除技能"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认删除",内容为"确定要删除技能"{技能名称}"吗?此操作不可撤销。"
|
||||
|
||||
#### Scenario: 版本删除确认
|
||||
- **WHEN** 用户点击版本历史表格中某个未启用版本的"删除"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认删除",内容为"确定要删除此版本吗?此操作不可撤销。"
|
||||
|
||||
### Requirement: 提示组件
|
||||
组件库 SHALL 提供 Toast 提示组件,用于各类操作结果反馈。
|
||||
|
||||
#### Scenario: 提示消息
|
||||
- **WHEN** 开发者需要显示操作反馈
|
||||
- **THEN** 系统 SHALL 提供 `.toast` 类和 `.toast--success`、`.toast--error`、`.toast--warning`、`.toast--info` 等变体
|
||||
|
||||
#### Scenario: 保存成功提示
|
||||
- **WHEN** 用户在账号管理页面点击"保存修改"按钮
|
||||
- **THEN** 页面顶部展示绿色成功提示"保存成功"
|
||||
|
||||
#### Scenario: 操作失败提示
|
||||
- **WHEN** 用户执行操作失败
|
||||
- **THEN** 页面顶部展示红色错误提示"操作失败,请重试"
|
||||
|
||||
#### Scenario: 上架/下架成功提示
|
||||
- **WHEN** 用户执行上架或下架操作
|
||||
- **THEN** 页面顶部展示绿色成功提示("已上架"或"已下架")
|
||||
|
||||
#### Scenario: 删除成功提示
|
||||
- **WHEN** 用户确认删除技能或版本
|
||||
- **THEN** 页面顶部展示绿色成功提示"已删除"
|
||||
|
||||
#### Scenario: 提交审核成功提示
|
||||
- **WHEN** 用户在上传新版本页面点击"提交审核"按钮
|
||||
- **THEN** 页面顶部展示绿色成功提示"已提交审核"
|
||||
|
||||
#### Scenario: 创建技能成功提示
|
||||
- **WHEN** 用户在创建技能页面点击"创建技能"按钮
|
||||
- **THEN** 页面顶部展示绿色成功提示"创建成功"
|
||||
|
||||
### Requirement: 分页组件
|
||||
组件库 SHALL 提供分页导航组件。
|
||||
|
||||
#### Scenario: 分页导航
|
||||
- **WHEN** 列表需要分页
|
||||
- **THEN** 系统 SHALL 提供 `.pagination` 类和 `.pagination__item`、`.pagination__item--active` 元素类
|
||||
|
||||
### Requirement: 空状态组件
|
||||
组件库 SHALL 提供空状态展示组件,用于列表或页面无数据时的展示。
|
||||
|
||||
#### Scenario: 无数据展示
|
||||
- **WHEN** 列表或页面无数据时
|
||||
- **THEN** 系统 SHALL 提供 `.empty-state` 类,包含图标、文字、可选操作按钮区域
|
||||
|
||||
#### Scenario: 技能市场搜索无结果
|
||||
- **WHEN** 用户在技能市场搜索框输入关键词后点击查询
|
||||
- **THEN** 页面展示 EmptyState 组件,显示"暂无匹配技能"提示
|
||||
|
||||
#### Scenario: 日志查询筛选无结果
|
||||
- **WHEN** 用户选择筛选条件后点击查询按钮
|
||||
- **THEN** 页面展示 EmptyState 组件,显示"暂无匹配日志"提示
|
||||
|
||||
#### Scenario: 定时任务列表为空
|
||||
- **WHEN** 用户进入定时任务页面
|
||||
- **THEN** 页面展示 EmptyState 组件,显示"暂无定时任务"提示
|
||||
|
||||
#### Scenario: 项目管理成员为空或筛选无结果
|
||||
- **WHEN** 用户进入项目管理页面且没有成员,或选择筛选条件后无匹配
|
||||
- **THEN** 页面展示 EmptyState 组件,显示"暂无匹配成员"提示
|
||||
|
||||
### Requirement: 开关组件
|
||||
组件库 SHALL 提供切换开关组件。
|
||||
|
||||
#### Scenario: 布尔切换
|
||||
- **WHEN** 开发者需要开关控件
|
||||
- **THEN** 系统 SHALL 提供 `.switch` 类,包含滑块和选中状态样式
|
||||
|
||||
### Requirement: 技能卡片组件
|
||||
组件库 SHALL 提供技能展示专用卡片。
|
||||
|
||||
#### Scenario: 技能展示
|
||||
- **WHEN** 工作台需要展示技能列表
|
||||
- **THEN** 系统 SHALL 提供 `.skill-card` 类,包含图标区、信息区、统计区布局
|
||||
|
||||
### Requirement: 导航项组件
|
||||
组件库 SHALL 提供统一的侧边栏导航项组件。
|
||||
|
||||
#### Scenario: 导航菜单
|
||||
- **WHEN** 侧边栏需要导航菜单项
|
||||
- **THEN** 系统 SHALL 提供 `.nav-item` 类,统一替换 `.menu-item`、`.chat-nav-item`、`.admin-nav-item`
|
||||
|
||||
#### Scenario: 导航图标
|
||||
- **WHEN** 导航项需要图标
|
||||
- **THEN** 系统 SHALL 提供 `.nav-item__icon` 元素类
|
||||
|
||||
#### Scenario: 激活状态
|
||||
- **WHEN** 导航项需要显示激活状态
|
||||
- **THEN** 系统 SHALL 提供 `.nav-item--active` 修饰符类
|
||||
|
||||
### Requirement: 详情页头部组件
|
||||
组件库 SHALL 提供详情页头部布局组件。
|
||||
|
||||
#### Scenario: 详情头部布局
|
||||
- **WHEN** 详情页需要展示标题、图标、标签、统计
|
||||
- **THEN** 系统 SHALL 提供 `.detail-header` 类,替换内联样式 `display: flex; gap: 20px; align-items: flex-start`
|
||||
|
||||
#### Scenario: 详情图标
|
||||
- **WHEN** 详情头部需要大图标
|
||||
- **THEN** 系统 SHALL 提供 `.detail-header__icon` 元素类
|
||||
|
||||
#### Scenario: 详情标签组
|
||||
- **WHEN** 详情头部需要展示标签
|
||||
- **THEN** 系统 SHALL 提供 `.detail-tags` 类,替换内联样式
|
||||
|
||||
#### Scenario: 详情统计
|
||||
- **WHEN** 详情头部需要展示统计信息
|
||||
- **THEN** 系统 SHALL 提供 `.detail-stats` 类,替换内联样式
|
||||
|
||||
### Requirement: 文件列表组件
|
||||
组件库 SHALL 提供文件列表展示组件。
|
||||
|
||||
#### Scenario: 文件列表项
|
||||
- **WHEN** 详情页需要展示文件列表
|
||||
- **THEN** 系统 SHALL 提供 `.file-list__item` 类,包含图标、文件名、文件大小布局
|
||||
|
||||
#### Scenario: 文件图标
|
||||
- **WHEN** 文件项需要类型图标
|
||||
- **THEN** 系统 SHALL 提供 `.file-list__icon` 元素类
|
||||
|
||||
#### Scenario: 文件信息
|
||||
- **WHEN** 文件项需要展示名称和大小
|
||||
- **THEN** 系统 SHALL 提供 `.file-list__info`、`.file-list__name`、`.file-list__size` 元素类
|
||||
|
||||
### Requirement: 版本列表组件
|
||||
组件库 SHALL 提供版本历史列表组件。
|
||||
|
||||
#### Scenario: 版本列表项
|
||||
- **WHEN** 详情页需要展示版本历史
|
||||
- **THEN** 系统 SHALL 提供 `.version-list__item` 类,包含版本号、描述、日期布局
|
||||
|
||||
#### Scenario: 版本标签
|
||||
- **WHEN** 需要标记当前版本
|
||||
- **THEN** 系统 SHALL 提供 `.version-list__tag` 和 `.version-list__tag--current` 类
|
||||
|
||||
### Requirement: 返回按钮组件
|
||||
组件库 SHALL 提供统一的返回按钮样式,所有二级页面使用统一的样式类名。
|
||||
|
||||
#### Scenario: 返回按钮
|
||||
- **WHEN** 详情页需要返回按钮
|
||||
- **THEN** 系统 SHALL 提供 `.page-back-btn` 类,按钮显示为蓝色主题色文字、带左箭头图标、文字为粗体
|
||||
- **AND** 返回按钮显示在页面内容区左上角
|
||||
- **AND** 与上级页面名称关联(如"返回技能市场")
|
||||
|
||||
#### Scenario: 废弃旧样式类名
|
||||
- **WHEN** 代码中使用返回按钮
|
||||
- **THEN** 必须使用 `.page-back-btn` 类名
|
||||
- **AND** 不再使用 `dev-back-btn` 或 `console-back-btn`
|
||||
|
||||
#### Scenario: 样式定义位置
|
||||
- **WHEN** 开发者查找返回按钮样式定义
|
||||
- **THEN** 样式定义位于 `src/styles/components/_index.scss`
|
||||
- **AND** 不位于任何页面级样式文件
|
||||
|
||||
### Requirement: 表单提示组件
|
||||
组件库 SHALL 提供表单辅助提示样式。
|
||||
|
||||
#### Scenario: 表单提示文字
|
||||
- **WHEN** 表单字段需要辅助说明
|
||||
- **THEN** 系统 SHALL 提供 `.form__hint` 类,替换内联样式 `font-size: 12px; color: #6B7280; margin-top: 4px`
|
||||
|
||||
### Requirement: 密码输入框组件
|
||||
组件库 SHALL 提供密码输入框组件,支持显示/隐藏切换功能。
|
||||
|
||||
#### Scenario: 基础密码输入框
|
||||
- **WHEN** 表单需要密码输入
|
||||
- **THEN** 系统 SHALL 提供 `type="password"` 的 `.form__input` 输入框
|
||||
|
||||
#### Scenario: 带切换的密码输入框
|
||||
- **WHEN** 表单需要密码输入并支持显示/隐藏切换
|
||||
- **THEN** 系统 SHALL 提供 `.password-input` 容器,包含 `.form__input` 和 `.password-toggle` 按钮
|
||||
|
||||
#### Scenario: 切换按钮样式
|
||||
- **WHEN** 用户需要切换密码可见性
|
||||
- **THEN** 系统 SHALL 提供 `.password-toggle` 类,定位在输入框右侧,包含眼睛图标
|
||||
|
||||
#### Scenario: 输入框错误状态
|
||||
- **WHEN** 密码验证失败
|
||||
- **THEN** 系统 SHALL 提供 `.form__input--error` 修饰符,输入框边框变红,并通过 `.form__error` 显示错误提示
|
||||
|
||||
### Requirement: 类名规范检查
|
||||
组件库 SHALL 确保所有样式类名符合 BEM 规范,无分散的内联样式。
|
||||
|
||||
#### Scenario: BEM 命名验证
|
||||
- **WHEN** 检查组件类名
|
||||
- **THEN** 所有类名 SHALL 遵循 `.block__element--modifier` 格式
|
||||
|
||||
#### Scenario: 分散样式收拢
|
||||
- **WHEN** 检查 HTML/JSX 文件
|
||||
- **THEN** 所有样式定义 SHALL 集中在样式文件中,无独立 `<style>` 标签或内联 `style` 属性
|
||||
@@ -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 不能为空。
|
||||
|
||||
48
openspec/specs/conversation-delete/spec.md
Normal file
48
openspec/specs/conversation-delete/spec.md
Normal file
@@ -0,0 +1,48 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 对话列表项支持删除操作
|
||||
|
||||
对话列表中的每一项 SHALL 提供删除功能,允许用户移除不需要的对话记录。
|
||||
|
||||
#### Scenario: Hover 显示删除按钮
|
||||
|
||||
- **WHEN** 用户将鼠标悬停在对话卡片上
|
||||
- **THEN** 系统 SHALL 在卡片右侧显示删除图标按钮
|
||||
|
||||
#### Scenario: 默认隐藏删除按钮
|
||||
|
||||
- **WHEN** 用户未将鼠标悬停在对话卡片上
|
||||
- **THEN** 系统 SHALL 隐藏删除按钮,保持界面简洁
|
||||
|
||||
### Requirement: 删除操作需确认
|
||||
|
||||
系统 SHALL 在执行删除前显示确认弹窗,防止用户误操作。
|
||||
|
||||
#### Scenario: 点击删除按钮显示确认弹窗
|
||||
|
||||
- **WHEN** 用户点击删除按钮
|
||||
- **THEN** 系统 SHALL 显示确认弹窗,包含对话标题和确认/取消按钮
|
||||
|
||||
#### Scenario: 确认删除
|
||||
|
||||
- **WHEN** 用户在确认弹窗中点击"确定"按钮
|
||||
- **THEN** 系统 SHALL 关闭弹窗并模拟删除操作
|
||||
|
||||
#### Scenario: 取消删除
|
||||
|
||||
- **WHEN** 用户在确认弹窗中点击"取消"按钮或点击遮罩层
|
||||
- **THEN** 系统 SHALL 关闭弹窗,不执行删除操作
|
||||
|
||||
### Requirement: 删除按钮样式符合设计规范
|
||||
|
||||
删除按钮的样式 SHALL 符合项目现有的设计规范。
|
||||
|
||||
#### Scenario: 删除按钮默认样式
|
||||
|
||||
- **WHEN** 删除按钮显示时
|
||||
- **THEN** 按钮 SHALL 使用灰色图标,不干扰主要内容
|
||||
|
||||
#### Scenario: 删除按钮 Hover 样式
|
||||
|
||||
- **WHEN** 用户将鼠标悬停在删除按钮上
|
||||
- **THEN** 按钮 SHALL 变为红色,提示危险操作
|
||||
62
openspec/specs/design-tokens/spec.md
Normal file
62
openspec/specs/design-tokens/spec.md
Normal file
@@ -0,0 +1,62 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 颜色令牌定义
|
||||
设计系统 SHALL 提供统一的颜色令牌,包括品牌色、功能色、中性色、背景色、边框色。
|
||||
|
||||
#### Scenario: 品牌色使用
|
||||
- **WHEN** 开发者需要应用主色调
|
||||
- **THEN** 系统 SHALL 提供 `--color-primary`、`--color-primary-light`、`--color-primary-dark` 等变量
|
||||
|
||||
#### Scenario: 功能色使用
|
||||
- **WHEN** 开发者需要表示状态(成功、警告、错误)
|
||||
- **THEN** 系统 SHALL 提供 `--color-success`、`--color-warning`、`--color-danger` 及其浅色变体
|
||||
|
||||
#### Scenario: 中性色使用
|
||||
- **WHEN** 开发者需要文本、边框、背景色
|
||||
- **THEN** 系统 SHALL 提供 `--color-text-1` 到 `--color-text-4`、`--color-border-1` 到 `--color-border-3`、`--color-bg-1` 到 `--color-bg-4`
|
||||
|
||||
### Requirement: 间距令牌定义
|
||||
设计系统 SHALL 提供统一的间距令牌,基于 4px 基数网格。
|
||||
|
||||
#### Scenario: 间距应用
|
||||
- **WHEN** 开发者需要设置 margin、padding、gap
|
||||
- **THEN** 系统 SHALL 提供标准化的间距值(4, 8, 12, 16, 20, 24, 32, 40, 48px)
|
||||
|
||||
### Requirement: 阴影令牌定义
|
||||
设计系统 SHALL 提供统一的阴影令牌,用于表达层级和深度。
|
||||
|
||||
#### Scenario: 阴影应用
|
||||
- **WHEN** 开发者需要为卡片、弹窗、下拉菜单添加阴影
|
||||
- **THEN** 系统 SHALL 提供 `--shadow-1`、`--shadow-2`、`--shadow-3`、`--shadow-card` 四级阴影
|
||||
|
||||
### Requirement: 圆角令牌定义
|
||||
设计系统 SHALL 提供统一的圆角令牌。
|
||||
|
||||
#### Scenario: 圆角应用
|
||||
- **WHEN** 开发者需要设置组件圆角
|
||||
- **THEN** 系统 SHALL 提供 `--radius-sm`(6px)、`--radius-md`(8px)、`--radius-lg`(12px)、`--radius-xl`(16px)
|
||||
|
||||
### Requirement: 过渡动画令牌定义
|
||||
设计系统 SHALL 提供统一的过渡动画参数。
|
||||
|
||||
#### Scenario: 过渡动画应用
|
||||
- **WHEN** 开发者需要为交互状态添加过渡效果
|
||||
- **THEN** 系统 SHALL 提供 `--transition` 标准过渡参数(0.2s cubic-bezier(0.4, 0, 0.2, 1))
|
||||
|
||||
### Requirement: 布局尺寸令牌定义
|
||||
设计系统 SHALL 提供统一的布局尺寸令牌。
|
||||
|
||||
#### Scenario: 布局尺寸应用
|
||||
- **WHEN** 开发者需要设置侧边栏宽度、顶部栏高度
|
||||
- **THEN** 系统 SHALL 提供 `--sidebar-width`、`--header-height` 等变量
|
||||
|
||||
### Requirement: SCSS 变量与 CSS 变量映射
|
||||
设计系统 SHALL 同时提供 SCSS 变量和 CSS 变量,满足不同场景需求。
|
||||
|
||||
#### Scenario: 编译时使用
|
||||
- **WHEN** 开发者在 SCSS 中需要引用变量
|
||||
- **THEN** 系统 SHALL 提供 `$primary`、`$spacing-4` 等 SCSS 变量
|
||||
|
||||
#### Scenario: 运行时使用
|
||||
- **WHEN** 开发者需要在运行时动态获取变量值
|
||||
- **THEN** 系统 SHALL 在 `:root` 中定义对应的 CSS 自定义属性
|
||||
@@ -1,34 +0,0 @@
|
||||
## Purpose
|
||||
开发者基本信息编辑功能用于管理技能的内部信息,这些信息仅供开发者自己使用,不影响技能商店展示。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 内部信息编辑表单
|
||||
UpdateSkillInfoPage SHALL 提供开发者内部信息的编辑表单,预填当前数据。
|
||||
|
||||
#### Scenario: 表单字段简化
|
||||
- **WHEN** 用户从技能详情页点击"编辑内部信息"进入 UpdateSkillInfoPage
|
||||
- **THEN** 表单仅包含两个字段:开发者内部技能名称、开发者内部技能描述
|
||||
|
||||
#### Scenario: 移除字段
|
||||
- **WHEN** 用户在内部信息编辑页面时
|
||||
- **THEN** 不显示分类、标签、图标选择器(这些字段已移至版本发布信息)
|
||||
|
||||
#### Scenario: 提交内部信息修改
|
||||
- **WHEN** 用户填写完内部信息后点击"保存修改"按钮
|
||||
- **THEN** 页面展示成功提示"保存成功",并返回技能详情页
|
||||
|
||||
#### Scenario: 取消编辑
|
||||
- **WHEN** 用户在内部信息编辑页面点击"取消"按钮
|
||||
- **THEN** 返回技能详情页,不保存任何修改
|
||||
|
||||
### Requirement: 技能图标选择
|
||||
UploadSkillPage SHALL 提供技能图标的 emoji 选择器(仅用于创建技能时的图标选择)。
|
||||
|
||||
#### Scenario: 图标选择展示
|
||||
- **WHEN** 用户在创建技能页面看到图标选择区域
|
||||
- **THEN** 页面展示 emoji 网格,当前选中项高亮显示
|
||||
|
||||
#### Scenario: 切换图标
|
||||
- **WHEN** 用户点击 emoji 网格中的某个图标
|
||||
- **THEN** 该图标高亮选中,之前的选中项取消高亮
|
||||
@@ -1,61 +0,0 @@
|
||||
# 开发者我的技能列表 - 规格说明
|
||||
|
||||
## 功能描述
|
||||
|
||||
展示开发者自己创建的技能列表,展示开发者内部名称,支持筛选和操作。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 表格列定义
|
||||
表格必须包含以下列:
|
||||
|
||||
| 列名 | 内容 | 显示要求 |
|
||||
|------|------|----------|
|
||||
| 技能名称 | `skill.name`(开发者内部名称) | 普通字重,不加粗 |
|
||||
| 技能描述 | `skill.desc`(开发者内部描述) | 正常显示 |
|
||||
| 状态 | `skill.status` | 使用 `skillStatusMap` 映射显示中文状态和对应样式,**只显示技能整体状态** |
|
||||
| 操作 | 编辑/下架/删除按钮 | 保持按钮组布局 |
|
||||
|
||||
**移除以下列:**
|
||||
- 分类 (`category`)
|
||||
- 版本 (`version`)
|
||||
- 安装量 (`installs`)
|
||||
- 评分 (`rating`)
|
||||
|
||||
### Requirement: 状态显示规则
|
||||
1. **只显示技能的整体状态**,使用 `skillStatusMap` 映射:
|
||||
- `dev` → 开发中
|
||||
- `published` → 已上架
|
||||
- `unlisting` → 下架审核中
|
||||
- `unlisted` → 已下架
|
||||
|
||||
2. **不再额外显示** `skill.hasPendingReview` 的"审核中"徽章。版本审核状态在技能详情页展示即可。
|
||||
|
||||
### Requirement: 操作按钮规则
|
||||
|
||||
#### 下架按钮
|
||||
- 只在 `skill.status === 'published'` 时显示
|
||||
- 当 `skill.hasPendingReview === true` 时禁用,提示"存在审核中的版本,请先撤回后再下架"
|
||||
- 点击触发出下架操作
|
||||
|
||||
#### 删除按钮
|
||||
- 当 `skill.status === 'published'` 或 `skill.status === 'unlisting'` 或 `skill.hasPendingReview === true` 时禁用
|
||||
- 提示信息根据状态变化
|
||||
- 点击触发确认删除弹框
|
||||
- 确认后执行删除操作
|
||||
|
||||
#### 编辑按钮
|
||||
- 始终显示
|
||||
- 点击跳转到技能详情编辑页
|
||||
|
||||
以上规则必须与**技能详情页**的按钮逻辑保持完全一致。
|
||||
|
||||
### Requirement: 交互行为
|
||||
- 点击表格行任意位置跳转到技能详情编辑页(保持不变)
|
||||
- 筛选功能保持不变,支持关键词、状态筛选(移除分类筛选)
|
||||
- 分页保持不变
|
||||
|
||||
### Requirement: 样式要求
|
||||
- 保持现有表格样式体系
|
||||
- 不引入新的样式类名
|
||||
- 技能名称使用正常字重(移除 `fontWeight: 600`)
|
||||
@@ -1,30 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 开发者指标展示
|
||||
开发台总览页 SHALL 展示开发者维度的核心指标数据,以卡片形式呈现。
|
||||
|
||||
#### Scenario: 指标卡片展示
|
||||
- **WHEN** 用户打开开发台总览页
|
||||
- **THEN** 页面顶部显示4个指标卡片:我的技能总数、已上架数量、开发中数量、待审核版本数量,每个卡片包含数值
|
||||
|
||||
### Requirement: 待审核项目提醒
|
||||
开发台总览页 SHALL 展示待审核的版本项目列表。
|
||||
|
||||
#### Scenario: 待审核列表展示
|
||||
- **WHEN** 用户打开开发台总览页
|
||||
- **THEN** 页面左侧区域显示待审核项目列表,每条包含技能名称、版本号、审核状态标签和日期,点击可跳转到对应技能详情页
|
||||
|
||||
#### Scenario: 审核拒绝项展示
|
||||
- **WHEN** 待审核列表中包含被拒绝的版本
|
||||
- **THEN** 该项显示拒绝状态标签和"查看原因"链接
|
||||
|
||||
#### Scenario: 下架审核项展示
|
||||
- **WHEN** 待审核列表中包含下架审核
|
||||
- **THEN** 该项显示"下架审核"状态标签
|
||||
|
||||
### Requirement: 最近动态展示
|
||||
开发台总览页 SHALL 展示开发者最近的操作动态记录。
|
||||
|
||||
#### Scenario: 动态列表展示
|
||||
- **WHEN** 用户打开开发台总览页
|
||||
- **THEN** 页面右侧区域显示最近操作动态列表,每条包含时间、操作描述和状态标签
|
||||
148
openspec/specs/developer-platform/spec.md
Normal file
148
openspec/specs/developer-platform/spec.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Capability: 开发台
|
||||
|
||||
## Purpose
|
||||
|
||||
开发台提供技能开发和管理功能,包括总览仪表盘、技能列表管理、技能内部信息编辑。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 开发者指标展示
|
||||
开发台总览页 SHALL 展示开发者维度的核心指标数据,以卡片形式呈现。
|
||||
|
||||
#### Scenario: 指标卡片展示
|
||||
- **WHEN** 用户打开开发台总览页
|
||||
- **THEN** 页面顶部显示4个指标卡片:我的技能总数、已上架数量、开发中数量、待审核版本数量,每个卡片包含数值
|
||||
|
||||
### Requirement: 待审核项目提醒
|
||||
开发台总览页 SHALL 展示待审核的版本项目列表。
|
||||
|
||||
#### Scenario: 待审核列表展示
|
||||
- **WHEN** 用户打开开发台总览页
|
||||
- **THEN** 页面左侧区域显示待审核项目列表,每条包含技能名称、版本号、审核状态标签和日期,点击可跳转到对应技能详情页
|
||||
|
||||
#### Scenario: 审核拒绝项展示
|
||||
- **WHEN** 待审核列表中包含被拒绝的版本
|
||||
- **THEN** 该项显示拒绝状态标签和"查看原因"链接
|
||||
|
||||
#### Scenario: 下架审核项展示
|
||||
- **WHEN** 待审核列表中包含下架审核
|
||||
- **THEN** 该项显示"下架审核"状态标签
|
||||
|
||||
### Requirement: 最近动态展示
|
||||
开发台总览页 SHALL 展示开发者最近的操作动态记录。
|
||||
|
||||
#### Scenario: 动态列表展示
|
||||
- **WHEN** 用户打开开发台总览页
|
||||
- **THEN** 页面右侧区域显示最近操作动态列表,每条包含时间、操作描述和状态标签
|
||||
|
||||
### Requirement: 技能列表展示
|
||||
系统 SHALL 以表格形式展示开发者创建的技能,包含技能名称、描述、状态和操作列。
|
||||
|
||||
#### Scenario: 表格列展示
|
||||
- **WHEN** 用户打开开发者技能列表页
|
||||
- **THEN** 系统显示表格,包含以下列:
|
||||
- 技能名称(开发者内部名称,普通字重)
|
||||
- 技能描述(开发者内部描述)
|
||||
- 状态(使用状态标签显示技能整体状态)
|
||||
- 操作(编辑/下架/删除按钮)
|
||||
|
||||
#### Scenario: 状态显示
|
||||
- **WHEN** 用户查看技能列表的状态列
|
||||
- **THEN** 系统根据技能整体状态显示对应标签:
|
||||
- `dev` → "开发中"
|
||||
- `published` → "已上架"
|
||||
- `unlisting` → "下架审核中"
|
||||
- `unlisted` → "已下架"
|
||||
|
||||
### Requirement: 技能列表搜索筛选
|
||||
开发者技能列表 SHALL 支持按关键词和状态筛选技能数据。
|
||||
|
||||
#### Scenario: 关键词搜索
|
||||
- **WHEN** 用户在筛选区域的关键词输入框中输入文本并点击查询
|
||||
- **THEN** 列表仅显示技能名称或描述中包含该关键词的记录
|
||||
|
||||
#### Scenario: 状态筛选
|
||||
- **WHEN** 用户在状态下拉框选择某个状态(已发布/草稿)并点击查询
|
||||
- **THEN** 列表仅显示该状态的技能记录
|
||||
|
||||
#### Scenario: 筛选重置
|
||||
- **WHEN** 用户在筛选区域点击重置按钮
|
||||
- **THEN** 筛选条件清空,列表恢复显示全部技能
|
||||
|
||||
### Requirement: 技能列表分页
|
||||
开发者技能列表 SHALL 在表格底部展示分页组件。
|
||||
|
||||
#### Scenario: 分页展示
|
||||
- **WHEN** 用户打开技能列表页
|
||||
- **THEN** 表格底部右侧显示分页组件,包含页码按钮和前后翻页按钮
|
||||
|
||||
### Requirement: 技能下架操作
|
||||
开发者技能列表 SHALL 在已发布技能的操作列提供下架操作入口。
|
||||
|
||||
#### Scenario: 列表页下架操作
|
||||
- **WHEN** 用户在已发布技能的操作列点击"下架"按钮
|
||||
- **THEN** 页面展示成功提示"已下架"
|
||||
|
||||
#### Scenario: 下架按钮可用性
|
||||
- **WHEN** 技能状态为 `published` 且不存在审核中的版本
|
||||
- **THEN** 显示"下架"按钮
|
||||
- **WHEN** 技能状态为 `published` 且存在审核中的版本
|
||||
- **THEN** "下架"按钮禁用,提示"存在审核中的版本,请先撤回后再下架"
|
||||
|
||||
### Requirement: 技能删除操作
|
||||
开发者技能列表 SHALL 提供技能删除操作,需弹框确认。
|
||||
|
||||
#### Scenario: 删除确认
|
||||
- **WHEN** 用户在技能列表的操作列点击"删除"按钮
|
||||
- **THEN** 弹出确认弹框,显示"确定要删除技能"{技能名称}"吗?此操作不可撤销。"
|
||||
|
||||
#### Scenario: 确认删除
|
||||
- **WHEN** 用户在确认弹框中点击"删除"按钮
|
||||
- **THEN** 弹框关闭,页面展示成功提示"已删除"
|
||||
|
||||
#### Scenario: 删除按钮可用性
|
||||
- **WHEN** 技能状态为 `published`、`unlisting` 或存在审核中的版本
|
||||
- **THEN** "删除"按钮禁用,根据状态显示对应提示信息
|
||||
- **WHEN** 技能状态为 `dev` 或 `unlisted` 且无审核中版本
|
||||
- **THEN** "删除"按钮可用
|
||||
|
||||
### Requirement: 技能编辑入口
|
||||
开发者技能列表 SHALL 始终提供技能编辑入口。
|
||||
|
||||
#### Scenario: 编辑按钮
|
||||
- **WHEN** 用户点击技能操作列的"编辑"按钮
|
||||
- **THEN** 系统跳转到技能详情编辑页
|
||||
|
||||
#### Scenario: 行点击跳转
|
||||
- **WHEN** 用户点击表格行任意位置
|
||||
- **THEN** 系统跳转到技能详情编辑页
|
||||
|
||||
### Requirement: 内部信息编辑表单
|
||||
UpdateSkillInfoPage SHALL 提供开发者内部信息的编辑表单,预填当前数据。
|
||||
|
||||
#### Scenario: 表单字段简化
|
||||
- **WHEN** 用户从技能详情页点击"编辑内部信息"进入 UpdateSkillInfoPage
|
||||
- **THEN** 表单仅包含两个字段:开发者内部技能名称、开发者内部技能描述
|
||||
|
||||
#### Scenario: 移除字段
|
||||
- **WHEN** 用户在内部信息编辑页面时
|
||||
- **THEN** 不显示分类、标签、图标选择器(这些字段已移至版本发布信息)
|
||||
|
||||
#### Scenario: 提交内部信息修改
|
||||
- **WHEN** 用户填写完内部信息后点击"保存修改"按钮
|
||||
- **THEN** 页面展示成功提示"保存成功",并返回技能详情页
|
||||
|
||||
#### Scenario: 取消编辑
|
||||
- **WHEN** 用户在内部信息编辑页面点击"取消"按钮
|
||||
- **THEN** 返回技能详情页,不保存任何修改
|
||||
|
||||
### Requirement: 技能图标选择
|
||||
UploadSkillPage SHALL 提供技能图标的 emoji 选择器(仅用于创建技能时的图标选择)。
|
||||
|
||||
#### Scenario: 图标选择展示
|
||||
- **WHEN** 用户在创建技能页面看到图标选择区域
|
||||
- **THEN** 页面展示 emoji 网格,当前选中项高亮显示
|
||||
|
||||
#### Scenario: 切换图标
|
||||
- **WHEN** 用户点击 emoji 网格中的某个图标
|
||||
- **THEN** 该图标高亮选中,之前的选中项取消高亮
|
||||
@@ -1,37 +0,0 @@
|
||||
## Purpose
|
||||
|
||||
定义工作台各页面空状态的展示规范。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 技能市场空状态展示
|
||||
当技能市场无搜索结果时,系统 SHALL 展示 EmptyState 组件。
|
||||
|
||||
#### Scenario: 搜索无结果
|
||||
- **WHEN** 用户在技能市场搜索框输入关键词后点击查询
|
||||
- **THEN** 页面展示 EmptyState 组件,显示"暂无匹配技能"提示
|
||||
|
||||
### Requirement: 日志查询空状态展示
|
||||
当日志查询无匹配结果时,系统 SHALL 展示 EmptyState 组件。
|
||||
|
||||
#### Scenario: 筛选无结果
|
||||
- **WHEN** 用户选择筛选条件后点击查询按钮
|
||||
- **THEN** 页面展示 EmptyState 组件,显示"暂无匹配日志"提示
|
||||
|
||||
### Requirement: 定时任务空状态展示
|
||||
当定时任务列表为空时,系统 SHALL 展示 EmptyState 组件。
|
||||
|
||||
#### Scenario: 无任务
|
||||
- **WHEN** 用户进入定时任务页面
|
||||
- **THEN** 页面展示 EmptyState 组件,显示"暂无定时任务"提示
|
||||
|
||||
### Requirement: 项目管理空状态展示
|
||||
当项目成员列表为空或筛选无结果时,系统 SHALL 展示 EmptyState 组件。
|
||||
|
||||
#### Scenario: 无成员
|
||||
- **WHEN** 用户进入项目管理页面且没有成员
|
||||
- **THEN** 页面展示 EmptyState 组件,显示"暂无匹配成员"提示
|
||||
|
||||
#### Scenario: 筛选无结果
|
||||
- **WHEN** 用户选择筛选条件后点击查询按钮
|
||||
- **THEN** 页面展示 EmptyState 组件,显示"暂无匹配成员"提示
|
||||
@@ -1,80 +0,0 @@
|
||||
## Purpose
|
||||
|
||||
定义确认弹窗(Modal)和消息提示(Toast)的展示规范。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 确认弹窗展示
|
||||
系统 SHALL 提供 Modal 组件用于展示确认弹窗。
|
||||
|
||||
#### Scenario: 删除任务确认
|
||||
- **WHEN** 用户点击定时任务的"删除"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认删除",内容为"确定要删除这个任务吗?"
|
||||
|
||||
#### Scenario: 取消订阅确认
|
||||
- **WHEN** 用户点击技能详情页的"取消订阅"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认取消订阅",内容为"确定要取消订阅该技能吗?"
|
||||
|
||||
#### Scenario: 移除成员确认
|
||||
- **WHEN** 用户点击项目成员的"移除"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认移除",内容为"确定要将该成员移出项目吗?"
|
||||
|
||||
#### Scenario: 技能市场订阅确认
|
||||
- **WHEN** 用户点击技能卡片的"订阅"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认订阅",内容为"确定要订阅该技能吗?"
|
||||
|
||||
#### Scenario: 技能市场取消订阅确认
|
||||
- **WHEN** 用户点击技能卡片的"已订阅"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认取消订阅",内容为"确定要取消订阅该技能吗?取消后将无法使用该技能。"
|
||||
|
||||
### Requirement: 消息提示展示
|
||||
系统 SHALL 提供 Toast 组件用于展示操作结果提示。
|
||||
|
||||
#### Scenario: 保存成功提示
|
||||
- **WHEN** 用户在账号管理页面点击"保存修改"按钮
|
||||
- **THEN** 页面顶部展示绿色成功提示"保存成功"
|
||||
|
||||
#### Scenario: 操作失败提示
|
||||
- **WHEN** 用户执行操作失败
|
||||
- **THEN** 页面顶部展示红色错误提示"操作失败,请重试"
|
||||
|
||||
### Requirement: 技能删除确认弹窗
|
||||
系统 SHALL 提供 Modal 组件用于技能删除操作的确认。
|
||||
|
||||
#### Scenario: 技能列表删除确认
|
||||
- **WHEN** 用户点击技能列表中某个技能的"删除"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认删除",内容为"确定要删除技能"{技能名称}"吗?此操作不可撤销。"
|
||||
|
||||
#### Scenario: 技能详情页删除确认
|
||||
- **WHEN** 用户点击技能详情页的"删除技能"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认删除",内容为"确定要删除技能"{技能名称}"吗?此操作不可撤销。"
|
||||
|
||||
### Requirement: 版本删除确认弹窗
|
||||
系统 SHALL 提供 Modal 组件用于版本删除操作的确认。
|
||||
|
||||
#### Scenario: 版本删除确认
|
||||
- **WHEN** 用户点击版本历史表格中某个未启用版本的"删除"按钮
|
||||
- **THEN** 页面展示确认弹窗,标题为"确认删除",内容为"确定要删除此版本吗?此操作不可撤销。"
|
||||
|
||||
### Requirement: 开发台操作结果消息提示
|
||||
系统 SHALL 提供 Toast 组件用于开发台操作的结果提示。
|
||||
|
||||
#### Scenario: 上架/下架成功提示
|
||||
- **WHEN** 用户执行上架或下架操作
|
||||
- **THEN** 页面顶部展示绿色成功提示("已上架"或"已下架")
|
||||
|
||||
#### Scenario: 删除成功提示
|
||||
- **WHEN** 用户确认删除技能或版本
|
||||
- **THEN** 页面顶部展示绿色成功提示"已删除"
|
||||
|
||||
#### Scenario: 保存成功提示
|
||||
- **WHEN** 用户在更新基本信息页面点击"保存修改"按钮
|
||||
- **THEN** 页面顶部展示绿色成功提示"保存成功"
|
||||
|
||||
#### Scenario: 提交审核成功提示
|
||||
- **WHEN** 用户在上传新版本页面点击"提交审核"按钮
|
||||
- **THEN** 页面顶部展示绿色成功提示"已提交审核"
|
||||
|
||||
#### Scenario: 创建技能成功提示
|
||||
- **WHEN** 用户在创建技能页面点击"创建技能"按钮
|
||||
- **THEN** 页面顶部展示绿色成功提示"创建成功"
|
||||
189
openspec/specs/file-operations/spec.md
Normal file
189
openspec/specs/file-operations/spec.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Purpose
|
||||
|
||||
定义工作空间文件操作功能,包括重命名、新建、上传、移动、查看详情、刷新等操作。(TBD - 待补充详细目的)
|
||||
|
||||
# Requirements
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 文件重命名弹框
|
||||
系统应提供重命名弹框,允许用户输入新文件名。
|
||||
|
||||
#### Scenario: 点击重命名显示弹框
|
||||
- **WHEN** 用户点击右键菜单的"重命名"项
|
||||
- **THEN** 显示重命名弹框,输入框预填充当前文件名
|
||||
|
||||
#### Scenario: 重命名弹框确认
|
||||
- **WHEN** 用户在重命名弹框中输入新名称并点击"确认"
|
||||
- **THEN** 显示 Toast 提示"重命名成功",弹框关闭
|
||||
|
||||
#### Scenario: 重命名弹框取消
|
||||
- **WHEN** 用户点击重命名弹框的"取消"按钮
|
||||
- **THEN** 弹框关闭,不执行重命名操作
|
||||
|
||||
#### Scenario: 重命名弹框标题
|
||||
- **WHEN** 重命名弹框显示
|
||||
- **THEN** 弹框标题为"重命名"
|
||||
|
||||
### Requirement: 新建文件功能
|
||||
系统应允许用户新建文件。
|
||||
|
||||
#### Scenario: 点击新建文件显示弹框
|
||||
- **WHEN** 用户点击标题栏 [+] 按钮并选择"新建文件"
|
||||
- **THEN** 显示新建弹框,类型默认为"文件",输入框为空
|
||||
|
||||
#### Scenario: 新建文件确认
|
||||
- **WHEN** 用户在新建弹框中输入文件名并点击"确认"
|
||||
- **THEN** 显示 Toast 提示"文件创建成功",弹框关闭
|
||||
|
||||
#### Scenario: 新建文件弹框标题
|
||||
- **WHEN** 新建文件弹框显示
|
||||
- **THEN** 弹框标题为"新建文件"
|
||||
|
||||
### Requirement: 新建文件夹功能
|
||||
系统应允许用户新建文件夹。
|
||||
|
||||
#### Scenario: 点击新建文件夹显示弹框
|
||||
- **WHEN** 用户点击标题栏 [+] 按钮并选择"新建文件夹"
|
||||
- **THEN** 显示新建弹框,类型默认为"文件夹",输入框为空
|
||||
|
||||
#### Scenario: 新建文件夹确认
|
||||
- **WHEN** 用户在新建弹框中输入文件夹名并点击"确认"
|
||||
- **THEN** 显示 Toast 提示"文件夹创建成功",弹框关闭
|
||||
|
||||
#### Scenario: 新建文件夹弹框标题
|
||||
- **WHEN** 新建文件夹弹框显示
|
||||
- **THEN** 弹框标题为"新建文件夹"
|
||||
|
||||
### Requirement: 上传文件功能
|
||||
系统应允许用户上传文件。
|
||||
|
||||
#### Scenario: 点击上传文件显示文件选择器
|
||||
- **WHEN** 用户点击标题栏 [+] 按钮并选择"上传文件"
|
||||
- **THEN** 打开原生文件选择对话框
|
||||
|
||||
#### Scenario: 选择文件后模拟上传
|
||||
- **WHEN** 用户在文件选择器中选择文件并确认
|
||||
- **THEN** 显示 Toast 提示"文件上传成功"
|
||||
|
||||
#### Scenario: 取消文件选择
|
||||
- **WHEN** 用户在文件选择器中点击取消
|
||||
- **THEN** 不执行任何操作
|
||||
|
||||
### Requirement: 移动文件功能
|
||||
系统应允许用户移动文件到其他文件夹。
|
||||
|
||||
#### Scenario: 点击移动显示目标选择弹框
|
||||
- **WHEN** 用户点击右键菜单的"移动到"项
|
||||
- **THEN** 显示移动弹框,以树状结构列出所有可选的目标文件夹
|
||||
|
||||
#### Scenario: 文件夹树状展示
|
||||
- **WHEN** 移动弹框显示
|
||||
- **THEN** 文件夹以树状结构展示,子文件夹带缩进(每级 20px)和文件夹图标,层级清晰可辨
|
||||
|
||||
#### Scenario: 选中目标文件夹
|
||||
- **WHEN** 用户点击某个文件夹
|
||||
- **THEN** 该文件夹高亮选中,可切换选择其他文件夹
|
||||
|
||||
#### Scenario: 确认移动
|
||||
- **WHEN** 用户选中目标文件夹后点击"确认"按钮
|
||||
- **THEN** 显示 Toast 提示"文件已移动到 [文件夹名]",弹框关闭
|
||||
|
||||
#### Scenario: 取消移动
|
||||
- **WHEN** 用户点击"取消"按钮或遮罩层
|
||||
- **THEN** 弹框关闭,不执行移动操作
|
||||
|
||||
#### Scenario: 未选择时确认按钮禁用
|
||||
- **WHEN** 移动弹框显示但未选中任何目标文件夹
|
||||
- **THEN** "确认"按钮处于 disabled 状态
|
||||
|
||||
#### Scenario: 移动弹框仅显示文件夹
|
||||
- **WHEN** 移动弹框显示
|
||||
- **THEN** 仅列出文件夹项,不包含文件项
|
||||
|
||||
### Requirement: 查看文件详情功能
|
||||
系统应允许用户查看文件详细信息。
|
||||
|
||||
#### Scenario: 点击查看详情显示弹框
|
||||
- **WHEN** 用户点击右键菜单的"查看详情"项
|
||||
- **THEN** 显示详情弹框,展示文件完整信息
|
||||
|
||||
#### Scenario: 详情弹框显示完整信息
|
||||
- **WHEN** 文件详情弹框显示
|
||||
- **THEN** 弹框显示文件名、类型、大小、创建时间、修改时间、路径等信息
|
||||
|
||||
#### Scenario: 详情弹框标题
|
||||
- **WHEN** 文件详情弹框显示
|
||||
- **THEN** 弹框标题为"文件详情"
|
||||
|
||||
#### Scenario: 详情弹框关闭
|
||||
- **WHEN** 用户点击详情弹框的"关闭"按钮
|
||||
- **THEN** 弹框关闭
|
||||
|
||||
### Requirement: 刷新文件列表功能
|
||||
系统应允许用户刷新文件列表。
|
||||
|
||||
#### Scenario: 点击刷新按钮
|
||||
- **WHEN** 用户点击标题栏的刷新按钮(↻)
|
||||
- **THEN** 刷新按钮显示旋转动画,文件列表淡出淡入
|
||||
|
||||
#### Scenario: 刷新完成提示
|
||||
- **WHEN** 刷新操作完成
|
||||
- **THEN** 显示 Toast 提示"文件列表已刷新",旋转动画停止
|
||||
|
||||
#### Scenario: 刷新按钮位置
|
||||
- **WHEN** 工作空间侧边栏打开
|
||||
- **THEN** 刷新按钮显示在标题"工作空间"右侧
|
||||
|
||||
### Requirement: 右键菜单分组
|
||||
右键菜单应按功能分组显示。
|
||||
|
||||
#### Scenario: 基础操作组
|
||||
- **WHEN** 右键菜单显示
|
||||
- **THEN** 第一组包含"下载"、"重命名"、"移动到"操作
|
||||
|
||||
#### Scenario: 详细信息组
|
||||
- **WHEN** 右键菜单显示
|
||||
- **THEN** 第二组包含"查看详情"操作,与第一组用分隔线分开
|
||||
|
||||
#### Scenario: 危险操作组
|
||||
- **WHEN** 右键菜单显示
|
||||
- **THEN** 第三组包含"删除"操作,与第二组用分隔线分开,使用红色文字
|
||||
|
||||
#### Scenario: 菜单项显示图标
|
||||
- **WHEN** 右键菜单显示
|
||||
- **THEN** 每个菜单项左侧显示对应图标
|
||||
|
||||
### Requirement: 操作按钮 toggle 行为
|
||||
文件项的操作按钮应支持 toggle 行为。
|
||||
|
||||
#### Scenario: 点击按钮显示菜单
|
||||
- **WHEN** 用户点击文件项的操作按钮(⋯)且菜单未显示
|
||||
- **THEN** 显示该文件的右键菜单
|
||||
|
||||
#### Scenario: 再次点击按钮关闭菜单
|
||||
- **WHEN** 用户点击文件项的操作按钮(⋯)且菜单已显示
|
||||
- **THEN** 关闭右键菜单
|
||||
|
||||
#### Scenario: 点击其他文件按钮切换菜单
|
||||
- **WHEN** 右键菜单显示文件 A 的菜单
|
||||
- **THEN** 用户点击文件 B 的操作按钮
|
||||
- **AND** 关闭文件 A 的菜单,显示文件 B 的菜单
|
||||
|
||||
### Requirement: 文件夹右键菜单
|
||||
文件夹项应支持特定的右键菜单操作。
|
||||
|
||||
#### Scenario: 文件夹右键菜单项
|
||||
- **WHEN** 用户点击文件夹的操作按钮
|
||||
- **THEN** 显示包含"新建文件"、"新建文件夹"、"上传文件"、"重命名"、"删除"等操作的菜单
|
||||
|
||||
#### Scenario: 文件夹菜单不显示下载项
|
||||
- **WHEN** 文件夹右键菜单显示
|
||||
- **THEN** 菜单不包含"下载"操作项
|
||||
|
||||
### Requirement: 不支持预览的文件类型提示
|
||||
点击不支持预览的文件类型时,应显示 Toast 提示而非打开预览弹框。
|
||||
|
||||
#### Scenario: 点击不可预览文件类型
|
||||
- **WHEN** 用户点击压缩包(archive)或未知类型(unknown)的文件
|
||||
- **THEN** 显示 Toast 提示"该文件类型不支持预览",不打开预览弹框
|
||||
150
openspec/specs/file-preview/spec.md
Normal file
150
openspec/specs/file-preview/spec.md
Normal file
@@ -0,0 +1,150 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 文件预览弹窗显示文件信息
|
||||
文件预览面板应显示文件的基本信息,嵌入在侧边栏右侧,不使用模态弹窗。
|
||||
|
||||
#### Scenario: 显示文件基本信息
|
||||
- **WHEN** 文件预览面板打开
|
||||
- **THEN** 面板显示文件名、文件类型、文件大小、修改时间
|
||||
|
||||
#### Scenario: 弹窗标题显示文件名
|
||||
- **WHEN** 文件预览面板打开
|
||||
- **THEN** 面板 header 显示文件图标、文件名和关闭按钮(×)
|
||||
|
||||
#### Scenario: 面板嵌入侧边栏
|
||||
- **WHEN** 文件预览面板打开
|
||||
- **THEN** 面板嵌入在侧边栏右侧,不覆盖整个页面
|
||||
|
||||
### Requirement: 文本文件预览
|
||||
文本文件预览应显示文件内容。
|
||||
|
||||
#### Scenario: 文本文件显示内容
|
||||
- **WHEN** 用户预览文本文件(.txt、.md、.json、.js、.css 等)
|
||||
- **THEN** 预览区域显示文件内容(使用模拟数据)
|
||||
|
||||
#### Scenario: 代码文件语法高亮
|
||||
- **WHEN** 用户预览代码文件(.js、.jsx、.py 等)
|
||||
- **THEN** 预览区域显示代码内容,可考虑简单的语法高亮(非必须)
|
||||
|
||||
### Requirement: Office 文件预览
|
||||
Office 文件预览应显示文件信息和提示。
|
||||
|
||||
#### Scenario: Word 文件预览
|
||||
- **WHEN** 用户预览 Word 文档(.doc、.docx)
|
||||
- **THEN** 预览区域显示文件类型图标和提示"点击下载查看完整内容"
|
||||
|
||||
#### Scenario: Excel 文件预览
|
||||
- **WHEN** 用户预览 Excel 文档(.xls、.xlsx)
|
||||
- **THEN** 预览区域显示文件类型图标和提示"点击下载查看完整内容"
|
||||
|
||||
#### Scenario: PowerPoint 文件预览
|
||||
- **WHEN** 用户预览 PowerPoint 文档(.ppt、.pptx)
|
||||
- **THEN** 预览区域显示文件类型图标和提示"点击下载查看完整内容"
|
||||
|
||||
### Requirement: 图片文件预览
|
||||
图片文件预览应显示图片预览区域。
|
||||
|
||||
#### Scenario: 图片文件预览区域
|
||||
- **WHEN** 用户预览图片文件(.jpg、.png、.gif 等)
|
||||
- **THEN** 预览区域显示图片预览区域(占位符或实际图片)
|
||||
|
||||
#### Scenario: 图片文件显示尺寸信息
|
||||
- **WHEN** 用户预览图片文件
|
||||
- **THEN** 文件信息显示图片尺寸(如 "1920 × 1080")
|
||||
|
||||
### Requirement: 视频文件预览
|
||||
视频文件预览应显示文件信息和提示。
|
||||
|
||||
#### Scenario: 视频文件预览
|
||||
- **WHEN** 用户预览视频文件(.mp4、.avi、.mov 等)
|
||||
- **THEN** 预览区域显示视频图标和提示"点击下载播放视频"
|
||||
|
||||
#### Scenario: 视频文件显示时长
|
||||
- **WHEN** 用户预览视频文件
|
||||
- **THEN** 文件信息显示视频时长(如 "02:35")
|
||||
|
||||
### Requirement: 音频文件预览
|
||||
音频文件预览应显示文件信息和提示。
|
||||
|
||||
#### Scenario: 音频文件预览
|
||||
- **WHEN** 用户预览音频文件(.mp3、.wav、.ogg 等)
|
||||
- **THEN** 预览区域显示音频图标和提示"点击下载播放音频"
|
||||
|
||||
#### Scenario: 音频文件显示时长
|
||||
- **WHEN** 用户预览音频文件
|
||||
- **THEN** 文件信息显示音频时长(如 "03:45")
|
||||
|
||||
### Requirement: 文件预览弹窗操作
|
||||
文件预览面板应提供下载和关闭操作。
|
||||
|
||||
#### Scenario: 点击下载按钮
|
||||
- **WHEN** 用户点击预览面板的"下载"按钮
|
||||
- **THEN** 显示 Toast 提示"文件下载中..."
|
||||
|
||||
#### Scenario: 点击关闭按钮
|
||||
- **WHEN** 用户点击预览面板的"关闭"按钮
|
||||
- **THEN** 预览面板关闭,文件树保持显示
|
||||
|
||||
### Requirement: 文件右键菜单
|
||||
文件项应支持右键菜单操作。
|
||||
|
||||
#### Scenario: 点击操作按钮显示菜单
|
||||
- **WHEN** 用户点击文件项的操作按钮(⋮)
|
||||
- **THEN** 显示右键菜单,包含"下载"、"重命名"、"删除"选项
|
||||
|
||||
#### Scenario: 点击下载菜单项
|
||||
- **WHEN** 用户点击右键菜单的"下载"项
|
||||
- **THEN** 显示 Toast 提示"文件下载中...",菜单关闭
|
||||
|
||||
#### Scenario: 点击重命名菜单项
|
||||
- **WHEN** 用户点击右键菜单的"重命名"项
|
||||
- **THEN** 显示 Toast 提示"文件已重命名",菜单关闭
|
||||
|
||||
#### Scenario: 点击删除菜单项
|
||||
- **WHEN** 用户点击右键菜单的"删除"项
|
||||
- **THEN** 显示删除确认弹窗
|
||||
|
||||
### Requirement: 删除确认弹窗
|
||||
删除操作应显示二次确认弹窗。
|
||||
|
||||
#### Scenario: 删除确认弹窗显示
|
||||
- **WHEN** 用户点击右键菜单的"删除"项
|
||||
- **THEN** 显示确认弹窗,提示"确定要删除文件 [文件名] 吗?"
|
||||
|
||||
#### Scenario: 确认删除
|
||||
- **WHEN** 用户点击确认弹窗的"确认"按钮
|
||||
- **THEN** 显示 Toast 提示"文件已删除",弹窗关闭
|
||||
|
||||
#### Scenario: 取消删除
|
||||
- **WHEN** 用户点击确认弹窗的"取消"按钮
|
||||
- **THEN** 弹窗关闭,不执行删除操作
|
||||
|
||||
### Requirement: 文件预览面板始终可见
|
||||
文件预览面板应始终可见,不打断用户操作流程。
|
||||
|
||||
#### Scenario: 点击文件立即显示预览
|
||||
- **WHEN** 用户点击文件树中的文件
|
||||
- **THEN** 预览面板立即在右侧显示文件内容,不弹出模态框
|
||||
|
||||
#### Scenario: 切换文件预览面板保持打开
|
||||
- **WHEN** 用户点击另一个文件
|
||||
- **THEN** 预览面板内容更新为新文件内容,面板保持打开状态
|
||||
|
||||
#### Scenario: 预览面板独立滚动
|
||||
- **WHEN** 预览文件内容过长
|
||||
- **THEN** 预览面板内容区域独立滚动,不影响文件树
|
||||
|
||||
### Requirement: 预览面板布局
|
||||
预览面板应采用固定布局结构。
|
||||
|
||||
#### Scenario: 预览面板布局结构
|
||||
- **WHEN** 预览面板显示
|
||||
- **THEN** 面板从上到下依次为:Header(文件名+关闭按钮)、Info(文件信息)、Content(文件内容)、Actions(操作按钮)
|
||||
|
||||
#### Scenario: 预览面板最小宽度
|
||||
- **WHEN** 预览面板显示
|
||||
- **THEN** 预览面板最小宽度为 200px,保证内容可读
|
||||
|
||||
#### Scenario: 预览面板自适应宽度
|
||||
- **WHEN** 侧边栏宽度或文件树宽度改变
|
||||
- **THEN** 预览面板宽度自动调整(sidebarWidth - fileTreeWidth - dividerWidth)
|
||||
194
openspec/specs/file-tree/spec.md
Normal file
194
openspec/specs/file-tree/spec.md
Normal file
@@ -0,0 +1,194 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 文件树显示文件和文件夹
|
||||
文件树应展示工作空间中的文件和文件夹结构。
|
||||
|
||||
#### Scenario: 文件树显示文件项
|
||||
- **WHEN** 工作空间包含文件
|
||||
- **THEN** 文件树显示文件项,包含文件图标、文件名、文件大小、修改时间
|
||||
|
||||
#### Scenario: 文件树显示文件夹项
|
||||
- **WHEN** 工作空间包含文件夹
|
||||
- **THEN** 文件树显示文件夹项,包含文件夹图标、文件夹名称、展开/折叠箭头
|
||||
|
||||
### Requirement: 文件夹默认折叠
|
||||
文件夹应默认处于折叠状态。
|
||||
|
||||
#### Scenario: 文件夹初始状态为折叠
|
||||
- **WHEN** 文件树加载完成
|
||||
- **THEN** 所有文件夹处于折叠状态,不显示子文件
|
||||
|
||||
#### Scenario: 打开侧边栏文件夹重置为折叠
|
||||
- **WHEN** 用户关闭侧边栏后再次打开
|
||||
- **THEN** 所有文件夹恢复为折叠状态
|
||||
|
||||
### Requirement: 文件夹可展开
|
||||
系统应允许用户点击文件夹展开查看子文件。
|
||||
|
||||
#### Scenario: 点击文件夹展开
|
||||
- **WHEN** 用户点击折叠状态的文件夹
|
||||
- **THEN** 文件夹展开,显示所有子文件和子文件夹
|
||||
|
||||
#### Scenario: 展开文件夹箭头旋转
|
||||
- **WHEN** 文件夹从折叠变为展开
|
||||
- **THEN** 箭头图标从向右(▶)旋转为向下(▼)
|
||||
|
||||
### Requirement: 文件夹可折叠
|
||||
系统应允许用户点击已展开的文件夹将其折叠。
|
||||
|
||||
#### Scenario: 点击已展开文件夹折叠
|
||||
- **WHEN** 用户点击已展开的文件夹
|
||||
- **THEN** 文件夹折叠,隐藏所有子文件和子文件夹
|
||||
|
||||
#### Scenario: 折叠文件夹箭头旋转
|
||||
- **WHEN** 文件夹从展开变为折叠
|
||||
- **THEN** 箭头图标从向下(▼)旋转为向右(▶)
|
||||
|
||||
### Requirement: 文件树显示文件大小
|
||||
文件项应显示文件大小。
|
||||
|
||||
#### Scenario: 文件大小显示格式
|
||||
- **WHEN** 文件树显示文件项
|
||||
- **THEN** 文件大小以 KB 或 MB 为单位显示(如 "156 KB"、"2.5 MB")
|
||||
|
||||
#### Scenario: 文件夹不显示大小
|
||||
- **WHEN** 文件树显示文件夹项
|
||||
- **THEN** 文件夹不显示大小信息
|
||||
|
||||
### Requirement: 文件树显示修改时间
|
||||
文件项应显示修改时间。
|
||||
|
||||
#### Scenario: 修改时间显示格式
|
||||
- **WHEN** 文件树显示文件项
|
||||
- **THEN** 修改时间以 "MM-DD" 格式显示(如 "04-09")
|
||||
|
||||
#### Scenario: 文件夹不显示修改时间
|
||||
- **WHEN** 文件树显示文件夹项
|
||||
- **THEN** 文件夹不显示修改时间
|
||||
|
||||
### Requirement: 文件树支持交互
|
||||
文件项和文件夹项应支持点击预览和右键菜单操作。
|
||||
|
||||
#### Scenario: 点击文件打开预览
|
||||
- **WHEN** 用户点击文件项
|
||||
- **THEN** 打开文件预览弹窗,显示文件详情
|
||||
|
||||
#### Scenario: 操作按钮始终显示
|
||||
- **WHEN** 文件树渲染文件项或文件夹项
|
||||
- **THEN** 操作按钮(⋯)始终显示在右侧,不依赖鼠标悬停
|
||||
|
||||
#### Scenario: 文件夹也有操作按钮
|
||||
- **WHEN** 文件树显示文件夹项
|
||||
- **THEN** 文件夹项右侧也显示操作按钮(⋯)
|
||||
|
||||
### Requirement: 文件树使用图标库图标
|
||||
文件树应使用 react-icons 图标库显示文件类型图标。
|
||||
|
||||
#### Scenario: 文件夹显示文件夹图标
|
||||
- **WHEN** 文件树显示文件夹项
|
||||
- **THEN** 使用 FiFolder 图标
|
||||
|
||||
#### Scenario: 代码文件显示代码图标
|
||||
- **WHEN** 文件树显示代码文件(.js、.py 等)
|
||||
- **THEN** 使用 FiCode 图标
|
||||
|
||||
#### Scenario: Office 文件显示对应图标
|
||||
- **WHEN** 文件树显示 Office 文件
|
||||
- **THEN** Word 使用 FaFileWord、Excel 使用 FaFileExcel、PowerPoint 使用 FaFilePowerpoint
|
||||
|
||||
#### Scenario: 媒体文件显示对应图标
|
||||
- **WHEN** 文件树显示媒体文件
|
||||
- **THEN** 图片使用 FiImage、视频使用 FiVideo、音频使用 FiMusic
|
||||
|
||||
### Requirement: 文件树样式遵循 BEM 规范
|
||||
文件树样式应使用 BEM 命名规范。
|
||||
|
||||
#### Scenario: 文件树类名使用 BEM
|
||||
- **WHEN** 文件树渲染
|
||||
- **THEN** 使用 .file-tree 作为 block,.file-tree__item、.file-tree__icon 等作为 element
|
||||
|
||||
#### Scenario: 文件树使用设计令牌
|
||||
- **WHEN** 文件树样式定义
|
||||
- **THEN** 使用现有设计令牌(颜色、间距、字号等),不硬编码样式值
|
||||
|
||||
### Requirement: 文件树布局紧凑统一
|
||||
文件树应采用紧凑统一的布局。
|
||||
|
||||
#### Scenario: 文件和文件夹高度一致
|
||||
- **WHEN** 文件树显示文件项和文件夹项
|
||||
- **THEN** 文件项和文件夹项的高度相同(padding: 4px 8px)
|
||||
|
||||
#### Scenario: 文件信息紧凑显示
|
||||
- **WHEN** 文件树显示文件项
|
||||
- **THEN** 文件大小宽度为 50px,修改时间宽度为 40px,字号为 11px
|
||||
|
||||
### Requirement: 文件树支持空状态显示
|
||||
文件树应支持空状态显示。
|
||||
|
||||
#### Scenario: 工作空间为空显示空状态
|
||||
- **WHEN** 工作空间没有任何文件或文件夹
|
||||
- **THEN** 文件树显示空状态界面,提示"工作空间为空"
|
||||
|
||||
#### Scenario: 空文件夹展开后为空
|
||||
- **WHEN** 文件夹展开且文件夹为空
|
||||
- **THEN** 文件夹下方不显示任何内容
|
||||
|
||||
### Requirement: 文件树子项缩进
|
||||
文件树子项应通过缩进体现层级关系。
|
||||
|
||||
#### Scenario: 子项缩进显示
|
||||
- **WHEN** 文件夹展开显示子项
|
||||
- **THEN** 子项左侧有 16px 缩进,与父级区分层级
|
||||
|
||||
### Requirement: 文件树包含丰富的示例数据
|
||||
文件树应包含多种类型的文件示例。
|
||||
|
||||
#### Scenario: 包含中文文件名
|
||||
- **WHEN** 文件树加载
|
||||
- **THEN** 显示包含中文文件名的文件(如"产品需求文档.docx"、"用户反馈汇总.xlsx")
|
||||
|
||||
#### Scenario: 包含无法预览的文件类型
|
||||
- **WHEN** 文件树加载
|
||||
- **THEN** 显示压缩包、数据库、二进制文件等无法预览的文件类型
|
||||
|
||||
#### Scenario: 包含多级文件夹嵌套
|
||||
- **WHEN** 文件树加载
|
||||
- **THEN** 显示至少 3 级文件夹嵌套(如 project/src/components/Button/)
|
||||
|
||||
#### Scenario: 包含空文件夹示例
|
||||
- **WHEN** 文件树加载
|
||||
- **THEN** 显示至少一个空文件夹
|
||||
|
||||
### Requirement: 文件树宽度可调整
|
||||
文件树宽度应支持拖动调整。
|
||||
|
||||
#### Scenario: 文件树默认宽度
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树默认宽度为 200px
|
||||
|
||||
#### Scenario: 文件树宽度范围
|
||||
- **WHEN** 用户拖动分隔线调整文件树宽度
|
||||
- **THEN** 文件树宽度在 180px 到 300px 之间变化
|
||||
|
||||
#### Scenario: 文件树宽度约束
|
||||
- **WHEN** 文件树宽度调整到边界值
|
||||
- **THEN** 最小 180px 保证文件名可读,最大 300px 避免压缩预览面板
|
||||
|
||||
### Requirement: 文件树作为侧边栏左侧面板
|
||||
文件树应作为侧边栏的左侧面板显示。
|
||||
|
||||
#### Scenario: 文件树固定在左侧
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树固定在侧边栏左侧,flex-shrink: 0,不被压缩
|
||||
|
||||
#### Scenario: 文件树独立滚动
|
||||
- **WHEN** 文件树内容过多
|
||||
- **THEN** 文件树独立滚动(overflow-y: auto),不影响预览面板
|
||||
|
||||
#### Scenario: 文件树背景色
|
||||
- **WHEN** 文件树显示
|
||||
- **THEN** 文件树背景色为 var(--color-bg-2),与侧边栏整体一致
|
||||
|
||||
#### Scenario: 文件树右侧边框
|
||||
- **WHEN** 文件树显示
|
||||
- **THEN** 文件树右侧显示 1px 边框(var(--color-border-2)),与预览面板分隔
|
||||
@@ -1,20 +0,0 @@
|
||||
## Purpose
|
||||
|
||||
定义表单校验错误状态的展示规范。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 表单校验错误状态展示
|
||||
系统 SHALL 在表单中展示校验错误状态。
|
||||
|
||||
#### Scenario: 必填项为空
|
||||
- **WHEN** 用户在修改密码表单中不输入当前密码直接点击"更新密码"
|
||||
- **THEN** 当前密码输入框边框变红,下方显示"请输入当前密码"错误提示
|
||||
|
||||
#### Scenario: 邮箱格式错误
|
||||
- **WHEN** 用户在个人信息表单中输入无效邮箱格式
|
||||
- **THEN** 邮箱输入框边框变红,下方显示"请输入有效的邮箱地址"错误提示
|
||||
|
||||
#### Scenario: 密码不一致
|
||||
- **WHEN** 用户在修改密码表单中输入两次不同的新密码
|
||||
- **THEN** 确认密码输入框边框变红,下方显示"两次输入的密码不一致"错误提示
|
||||
214
openspec/specs/layout-system/spec.md
Normal file
214
openspec/specs/layout-system/spec.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Capability: 布局系统
|
||||
|
||||
## Purpose
|
||||
|
||||
布局系统提供应用级外壳布局、页面内容区骨架、页面导航行为和样式文件组织规范。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 应用外壳布局
|
||||
布局系统 SHALL 提供应用级外壳布局,包含全局头部、可选侧边栏、主内容区。
|
||||
|
||||
#### Scenario: 基础应用布局
|
||||
- **WHEN** 开发者需要管理控制台布局
|
||||
- **THEN** 系统 SHALL 提供 `.app-layout` 类,包含 `.app-layout__header`、`.app-layout__sidebar`(可选)、`.app-layout__main` 区域
|
||||
|
||||
#### Scenario: 全局头部
|
||||
- **WHEN** 页面需要统一导航
|
||||
- **THEN** 系统 SHALL 在 `.app-layout__header` 区域渲染 `AppHeader` 组件
|
||||
- **AND** 头部固定在页面顶部
|
||||
|
||||
#### Scenario: 侧边栏可选
|
||||
- **WHEN** 页面需要侧边栏(如工作台、开发台、管理台)
|
||||
- **THEN** 系统 SHALL 渲染 `.app-layout__sidebar` 区域
|
||||
- **WHEN** 页面不需要侧边栏(如主页)
|
||||
- **THEN** 系统 SHALL 不渲染侧边栏区域
|
||||
|
||||
#### Scenario: 导航项
|
||||
- **WHEN** 侧边栏需要导航菜单
|
||||
- **THEN** 系统 SHALL 提供 `.nav-item` 类和 `.nav-item--active` 状态类
|
||||
|
||||
#### Scenario: 导航统一
|
||||
- **WHEN** 控制台、聊天页、管理台都需要导航项
|
||||
- **THEN** 系统 SHALL 统一使用 `.nav-item` 替换原有的 `.menu-item`、`.chat-nav-item`、`.admin-nav-item`
|
||||
|
||||
#### Scenario: 导航图标
|
||||
- **WHEN** 导航项需要图标
|
||||
- **THEN** 系统 SHALL 提供 `.nav-item__icon` 元素类
|
||||
|
||||
#### Scenario: 导航文本
|
||||
- **WHEN** 导航项需要文本和额外信息
|
||||
- **THEN** 系统 SHALL 提供 `.nav-item__text` 和 `.nav-item__meta` 元素类
|
||||
|
||||
### Requirement: 聊天页面布局
|
||||
布局系统 SHALL 提供聊天页面专用布局,侧边栏不包含品牌区和用户区。
|
||||
|
||||
#### Scenario: 聊天布局容器
|
||||
- **WHEN** 开发者需要聊天界面布局
|
||||
- **THEN** 系统 SHALL 提供 `.chat-layout` 类,包含 `.chat-layout__sidebar`、`.chat-layout__content`
|
||||
- **AND** 侧边栏不包含品牌区(`.sidebar-brand`)
|
||||
|
||||
#### Scenario: 会话列表
|
||||
- **WHEN** 侧边栏需要展示会话列表
|
||||
- **THEN** 系统 SHALL 提供 `.conversation-list` 容器和 `.conversation-item` 项类
|
||||
|
||||
#### Scenario: 聊天内容区
|
||||
- **WHEN** 需要展示消息和输入区
|
||||
- **THEN** 系统 SHALL 提供 `.chat-content__messages` 和 `.chat-content__input` 区域
|
||||
|
||||
#### Scenario: 侧边栏不含用户区
|
||||
- **WHEN** 聊天页面侧边栏渲染时
|
||||
- **THEN** 系统 SHALL 不显示用户状态区域
|
||||
- **AND** 用户状态统一在全局头部显示
|
||||
|
||||
### Requirement: 管理台布局
|
||||
布局系统 SHALL 提供管理台页面布局,侧边栏不包含品牌区和用户区。
|
||||
|
||||
#### Scenario: 管理台侧边栏
|
||||
- **WHEN** 管理台需要独立导航结构
|
||||
- **THEN** 系统 SHALL 提供 `.admin-layout` 类,包含 `.admin-layout__sidebar` 和 `.admin-layout__content`
|
||||
- **AND** 侧边栏不包含品牌区(`.admin-sidebar-header`)和用户区(`.admin-sidebar-user`)
|
||||
|
||||
#### Scenario: 侧边栏不含用户区
|
||||
- **WHEN** 管理台侧边栏渲染时
|
||||
- **THEN** 系统 SHALL 不显示用户状态区域
|
||||
- **AND** 用户状态统一在全局头部显示
|
||||
|
||||
### Requirement: 页面内容区
|
||||
布局系统 SHALL 提供标准化的页面内容容器。
|
||||
|
||||
#### Scenario: 标准页面内容
|
||||
- **WHEN** 页面需要标准内边距和滚动
|
||||
- **THEN** 系统 SHALL 提供 `.page-content` 类,包含标准 padding 和 overflow 设置
|
||||
|
||||
#### Scenario: 全宽页面内容
|
||||
- **WHEN** 页面需要无内边距全宽布局
|
||||
- **THEN** 系统 SHALL 提供 `.page-content--full` 修饰符
|
||||
|
||||
### Requirement: 搜索栏布局
|
||||
布局系统 SHALL 提供搜索过滤区域布局。
|
||||
|
||||
#### Scenario: 搜索表单
|
||||
- **WHEN** 列表页面需要搜索过滤
|
||||
- **THEN** 系统 SHALL 提供 `.search-bar` 类,支持多列网格布局
|
||||
|
||||
### Requirement: 统计卡片网格
|
||||
布局系统 SHALL 提供统计数据展示网格。
|
||||
|
||||
#### Scenario: 统计展示
|
||||
- **WHEN** 概览页面需要展示关键指标
|
||||
- **THEN** 系统 SHALL 提供 `.stats-grid` 和 `.stat-card` 类
|
||||
|
||||
### Requirement: 响应式断点
|
||||
布局系统 SHALL 提供响应式布局支持。
|
||||
|
||||
#### Scenario: 移动端适配
|
||||
- **WHEN** 屏幕宽度小于 768px
|
||||
- **THEN** 系统 SHALL 隐藏侧边栏(可通过 `.sidebar--open` 手动展开)、调整网格为单列、减小间距
|
||||
|
||||
#### Scenario: 侧边栏遮罩
|
||||
- **WHEN** 移动端侧边栏展开时
|
||||
- **THEN** 系统 SHALL 显示 `.sidebar__overlay` 遮罩层,点击可关闭侧边栏
|
||||
|
||||
### Requirement: 表单页面按钮组合完整
|
||||
|
||||
表单类二级页面必须同时具有左上角返回按钮和底部取消按钮。
|
||||
|
||||
#### Scenario: 表单页面包含返回和取消按钮
|
||||
- **WHEN** 用户访问表单类二级页面(如新增、编辑页面)
|
||||
- **THEN** 页面左上角显示返回按钮
|
||||
- **AND** 页面底部显示"取消"和"确定/保存"按钮组合
|
||||
|
||||
#### Scenario: 点击返回按钮返回上级
|
||||
- **WHEN** 用户点击左上角返回按钮
|
||||
- **THEN** 页面返回至上级页面
|
||||
- **AND** 不触发任何保存操作
|
||||
|
||||
#### Scenario: 点击取消按钮返回上级
|
||||
- **WHEN** 用户点击底部取消按钮
|
||||
- **THEN** 页面返回至上级页面
|
||||
- **AND** 不触发任何保存操作
|
||||
|
||||
### Requirement: 详情页面仅保留返回按钮
|
||||
|
||||
只读详情类二级页面仅需左上角返回按钮,无需底部取消按钮。
|
||||
|
||||
#### Scenario: 详情页返回按钮
|
||||
- **WHEN** 用户访问详情类二级页面(如任务详情、审核详情)
|
||||
- **THEN** 页面左上角显示返回按钮
|
||||
- **AND** 页面底部不显示取消按钮
|
||||
|
||||
### Requirement: 样式文件按页面拆分
|
||||
系统 SHALL 将页面特定样式从 global.scss 中拆分到独立的页面样式文件中,按功能组织。
|
||||
|
||||
#### Scenario: 工作台样式独立文件
|
||||
- **WHEN** 系统包含 _console.scss 文件
|
||||
- **THEN** 该文件包含工作台相关的所有样式(聊天、技能市场、日志查询、定时任务、项目管理等)
|
||||
|
||||
#### Scenario: 管理台样式独立文件
|
||||
- **WHEN** 系统包含 _admin.scss 文件
|
||||
- **THEN** 该文件包含管理台相关的所有样式(总览、部门管理、用户管理、项目管理等)
|
||||
|
||||
#### Scenario: 开发台样式独立文件
|
||||
- **WHEN** 系统包含 _developer.scss 文件
|
||||
- **THEN** 该文件包含开发台相关的所有样式(我的技能、技能编辑、开发文档等)
|
||||
|
||||
#### Scenario: 首页样式独立文件
|
||||
- **WHEN** 系统包含 _home.scss 文件
|
||||
- **THEN** 该文件包含首页相关的所有样式
|
||||
|
||||
### Requirement: global.scss 作为样式主入口
|
||||
系统 SHALL 保持 global.scss 作为样式主入口文件,导入所有样式模块。
|
||||
|
||||
#### Scenario: global.scss 导入设计系统模块
|
||||
- **WHEN** global.scss 文件被加载
|
||||
- **THEN** 系统按顺序导入 _variables.scss、_mixins.scss、_base.scss、_components.scss、_layout.scss
|
||||
|
||||
#### Scenario: global.scss 导入页面样式模块
|
||||
- **WHEN** global.scss 文件被加载
|
||||
- **THEN** 系统导入 pages/_console.scss、pages/_admin.scss、pages/_developer.scss、pages/_home.scss
|
||||
|
||||
#### Scenario: 通用样式保留在主文件中
|
||||
- **WHEN** 样式属于通用组件(按钮、表单、表格、状态标签等)
|
||||
- **THEN** 该样式保留在 _components.scss 或 _layout.scss 中,不移动到页面样式文件
|
||||
|
||||
### Requirement: 页面样式文件组织结构
|
||||
系统 SHALL 在 src/styles/pages/ 目录下按页面组织样式文件,每个文件包含对应页面的所有特定样式。
|
||||
|
||||
#### Scenario: 页面样式文件命名规范
|
||||
- **WHEN** 创建页面样式文件
|
||||
- **THEN** 文件名使用 _<page-name>.scss 格式(如 _console.scss、_admin.scss)
|
||||
|
||||
#### 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` 组件渲染
|
||||
49
openspec/specs/modal-height-constraint/spec.md
Normal file
49
openspec/specs/modal-height-constraint/spec.md
Normal file
@@ -0,0 +1,49 @@
|
||||
## Purpose
|
||||
|
||||
定义 Modal 弹窗的高度约束机制,确保弹窗在各种视窗尺寸下完整显示,并提供良好的内容滚动体验。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Modal 最大高度限制
|
||||
|
||||
Modal 弹窗 SHALL 设置最大高度,确保弹窗始终在视窗内完整显示。
|
||||
|
||||
#### Scenario: 视窗高度充足
|
||||
- **WHEN** 浏览器视窗高度大于弹窗内容高度
|
||||
- **THEN** 弹窗高度自适应内容,不显示滚动条
|
||||
|
||||
#### Scenario: 视窗高度不足
|
||||
- **WHEN** 浏览器视窗高度小于弹窗内容高度
|
||||
- **THEN** 弹窗最大高度为 `calc(100dvh - 48px)`(兼容旧浏览器使用 `calc(100vh - 48px)` fallback)
|
||||
- **THEN** 弹窗上下各预留 24px 边距
|
||||
|
||||
### Requirement: Modal 标题栏固定可见
|
||||
|
||||
Modal 标题栏(`.modal-header`)SHALL 始终可见,不被内容挤压或遮挡。
|
||||
|
||||
#### Scenario: 内容超出滚动
|
||||
- **WHEN** Modal body 内容超出高度限制
|
||||
- **THEN** 标题栏固定在弹窗顶部,高度不变
|
||||
- **THEN** 标题栏不参与滚动
|
||||
|
||||
### Requirement: Modal 底部栏固定可见
|
||||
|
||||
Modal 底部栏(`.modal-footer`,如存在)SHALL 始终可见,不被内容挤压或遮挡。
|
||||
|
||||
#### Scenario: 内容超出滚动且有 footer
|
||||
- **WHEN** Modal body 内容超出高度限制且存在 footer
|
||||
- **THEN** footer 固定在弹窗底部,高度不变
|
||||
- **THEN** footer 不参与滚动
|
||||
|
||||
### Requirement: Modal body 内容可滚动
|
||||
|
||||
Modal body(`.modal-body`)SHALL 在内容超出时支持滚动。
|
||||
|
||||
#### Scenario: 内容超出时滚动
|
||||
- **WHEN** Modal body 内容高度超过可用空间
|
||||
- **THEN** body 显示纵向滚动条
|
||||
- **THEN** 用户可滚动查看全部内容
|
||||
|
||||
#### Scenario: 内容未超出时不滚动
|
||||
- **WHEN** Modal body 内容高度未超过可用空间
|
||||
- **THEN** body 不显示滚动条
|
||||
184
openspec/specs/model-selector/spec.md
Normal file
184
openspec/specs/model-selector/spec.md
Normal 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 个
|
||||
@@ -1,52 +0,0 @@
|
||||
# Modular Styles Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
提供模块化的样式文件组织结构,将页面特定样式从全局样式文件中拆分到独立的页面样式文件中,提高代码的可维护性。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 样式文件按页面拆分
|
||||
系统 SHALL 将页面特定样式从 global.scss 中拆分到独立的页面样式文件中,按功能组织。
|
||||
|
||||
#### Scenario: 工作台样式独立文件
|
||||
- **WHEN** 系统包含 _console.scss 文件
|
||||
- **THEN** 该文件包含工作台相关的所有样式(聊天、技能市场、日志查询、定时任务、项目管理等)
|
||||
|
||||
#### Scenario: 管理台样式独立文件
|
||||
- **WHEN** 系统包含 _admin.scss 文件
|
||||
- **THEN** 该文件包含管理台相关的所有样式(总览、部门管理、用户管理、项目管理等)
|
||||
|
||||
#### Scenario: 开发台样式独立文件
|
||||
- **WHEN** 系统包含 _developer.scss 文件
|
||||
- **THEN** 该文件包含开发台相关的所有样式(我的技能、技能编辑、开发文档等)
|
||||
|
||||
#### Scenario: 首页样式独立文件
|
||||
- **WHEN** 系统包含 _home.scss 文件
|
||||
- **THEN** 该文件包含首页相关的所有样式
|
||||
|
||||
### Requirement: global.scss 作为样式主入口
|
||||
系统 SHALL 保持 global.scss 作为样式主入口文件,导入所有样式模块。
|
||||
|
||||
#### Scenario: global.scss 导入设计系统模块
|
||||
- **WHEN** global.scss 文件被加载
|
||||
- **THEN** 系统按顺序导入 _variables.scss、_mixins.scss、_base.scss、_components.scss、_layout.scss
|
||||
|
||||
#### Scenario: global.scss 导入页面样式模块
|
||||
- **WHEN** global.scss 文件被加载
|
||||
- **THEN** 系统导入 pages/_console.scss、pages/_admin.scss、pages/_developer.scss、pages/_home.scss
|
||||
|
||||
#### Scenario: 通用样式保留在主文件中
|
||||
- **WHEN** 样式属于通用组件(按钮、表单、表格、状态标签等)
|
||||
- **THEN** 该样式保留在 _components.scss 或 _layout.scss 中,不移动到页面样式文件
|
||||
|
||||
### Requirement: 页面样式文件组织结构
|
||||
系统 SHALL 在 src/styles/pages/ 目录下按页面组织样式文件,每个文件包含对应页面的所有特定样式。
|
||||
|
||||
#### Scenario: 页面样式文件命名规范
|
||||
- **WHEN** 创建页面样式文件
|
||||
- **THEN** 文件名使用 _<page-name>.scss 格式(如 _console.scss、_admin.scss)
|
||||
|
||||
#### Scenario: 页面样式文件内容结构
|
||||
- **WHEN** 查看页面样式文件
|
||||
- **THEN** 该文件包含页面特定的布局、组件、状态等样式,使用清晰的注释分节
|
||||
89
openspec/specs/platform-model-config/spec.md
Normal file
89
openspec/specs/platform-model-config/spec.md
Normal 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** 系统不显示"设为默认"按钮
|
||||
53
openspec/specs/project-management-nav/spec.md
Normal file
53
openspec/specs/project-management-nav/spec.md
Normal 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** "技能配置"子菜单项显示激活状态
|
||||
87
openspec/specs/project-model-config/spec.md
Normal file
87
openspec/specs/project-model-config/spec.md
Normal 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** 系统不显示"设为默认"按钮
|
||||
110
openspec/specs/responsive-chat-input/spec.md
Normal file
110
openspec/specs/responsive-chat-input/spec.md
Normal 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
|
||||
@@ -18,7 +18,7 @@
|
||||
- **THEN** 页面顶部显示返回按钮,点击后返回"我的技能"页面
|
||||
|
||||
### Requirement: 头部概览卡片(内部信息)
|
||||
技能详情页面 SHALL 在页面顶部显示头部概览卡片,展示开发者内部信息。
|
||||
技能详情页面 SHALL 在页面顶部显示头部概览卡片,展示开发者内部信息。卡片具有白色背景、圆角 12px、轻微阴影效果。
|
||||
|
||||
#### Scenario: 概览卡片信息展示
|
||||
- **WHEN** 用户查看技能详情页面
|
||||
@@ -28,6 +28,14 @@
|
||||
- **WHEN** 用户查看头部概览卡片
|
||||
- **THEN** 第一行显示:内部技能名称、状态标签、右上角"编辑内部信息"按钮
|
||||
|
||||
#### Scenario: 状态标签样式
|
||||
- **WHEN** 用户查看概览卡片中的状态标签
|
||||
- **THEN** 状态标签为纯文字样式,不使用图标,通过颜色区分状态类型(开发中、已上架、下架审核中、已下架)
|
||||
|
||||
#### Scenario: 响应式布局
|
||||
- **WHEN** 用户在较小屏幕设备上查看概览卡片
|
||||
- **THEN** 卡片内容自动调整布局,确保信息清晰可读
|
||||
|
||||
### Requirement: 当前生效版本卡片
|
||||
技能详情页面 SHALL 在头部概览卡片下方显示当前生效版本卡片。
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
## Purpose
|
||||
技能概览卡片用于在技能编辑页面顶部集中展示开发者内部信息,提供清晰的内部视图与商店发布视图的分离。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 技能概览卡片
|
||||
技能编辑页面 SHALL 在页面顶部显示技能概览卡片,展示开发者内部信息。
|
||||
|
||||
#### Scenario: 卡片布局结构
|
||||
- **WHEN** 用户打开技能编辑页面
|
||||
- **THEN** 页面顶部显示技能概览卡片,包含内部名称、状态标签、右上角"编辑内部信息"按钮
|
||||
|
||||
#### Scenario: 内部名称和状态显示
|
||||
- **WHEN** 用户查看技能概览卡片
|
||||
- **THEN** 卡片右侧第一行显示开发者内部技能名称、状态标签,名称右侧显示"编辑内部信息"按钮
|
||||
|
||||
#### Scenario: 状态标签样式
|
||||
- **WHEN** 用户查看技能概览卡片
|
||||
- **THEN** 状态标签为纯文字样式,不使用图标,通过颜色区分状态类型
|
||||
|
||||
#### Scenario: 操作按钮显示
|
||||
- **WHEN** 用户查看技能概览卡片
|
||||
- **THEN** 卡片右上角只显示"编辑内部信息"操作按钮
|
||||
|
||||
#### Scenario: 卡片视觉样式
|
||||
- **WHEN** 用户查看技能概览卡片
|
||||
- **THEN** 卡片具有白色背景、圆角12像素、轻微阴影效果
|
||||
|
||||
#### Scenario: 响应式布局
|
||||
- **WHEN** 用户在较小屏幕设备上查看技能概览卡片
|
||||
- **THEN** 卡片内容自动调整布局,确保信息清晰可读
|
||||
@@ -7,17 +7,6 @@ MySkillsPage SHALL 在技能列表中展示技能状态。
|
||||
- **WHEN** 用户查看我的技能列表
|
||||
- **THEN** 状态列显示技能的当前状态:开发中、已上架、下架审核中、已下架
|
||||
|
||||
#### Scenario: 审核中版本提示
|
||||
- **WHEN** 技能存在审核中的版本
|
||||
- **THEN** 状态列额外显示版本审核状态(如"已上架 · v1.2 审核中")
|
||||
|
||||
### Requirement: 侧边栏技能状态展示
|
||||
DeveloperPage SHALL 在侧边栏技能列表中展示状态标签。
|
||||
|
||||
#### Scenario: 侧边栏状态展示
|
||||
- **WHEN** 用户查看开发台侧边栏的技能列表
|
||||
- **THEN** 每个技能项显示对应的状态标签(开发中、已上架、下架审核中、已下架)
|
||||
|
||||
### Requirement: 技能操作按钮可用性
|
||||
MySkillsPage 和 SkillEditorPage SHALL 根据技能状态控制操作按钮的可用性。
|
||||
|
||||
111
openspec/specs/unified-header/spec.md
Normal file
111
openspec/specs/unified-header/spec.md
Normal file
@@ -0,0 +1,111 @@
|
||||
## Purpose
|
||||
统一的全局导航头部,包含品牌标识、台入口切换和用户状态管理。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 统一导航头部
|
||||
系统 SHALL 提供统一的全局导航头部组件,包含品牌标识、台入口切换和用户状态。
|
||||
|
||||
#### Scenario: 头部基础结构
|
||||
- **WHEN** 页面需要显示导航头部
|
||||
- **THEN** 系统 SHALL 提供 `AppHeader` 组件
|
||||
- **AND** 组件包含左侧品牌区和右侧功能区
|
||||
|
||||
#### Scenario: 品牌区展示
|
||||
- **WHEN** 用户查看头部左侧
|
||||
- **THEN** 系统 SHALL 显示 Logo 图标和 "GrandClaw" 标题
|
||||
- **AND** Logo 使用 `.sidebar-logo-icon` 样式类
|
||||
|
||||
### Requirement: 台入口切换
|
||||
系统 SHALL 在头部提供三个台的入口导航,支持切换确认和高亮状态。
|
||||
|
||||
#### Scenario: 台入口展示
|
||||
- **WHEN** 用户查看头部右侧
|
||||
- **THEN** 系统 SHALL 显示"工作台"、"开发台"、"管理台"三个入口
|
||||
- **AND** 每个入口包含图标和文字
|
||||
|
||||
#### Scenario: 当前台高亮
|
||||
- **WHEN** 用户在某个台内(如工作台)
|
||||
- **THEN** 系统 SHALL 为当前台入口添加高亮样式
|
||||
- **AND** 其他台入口保持默认样式
|
||||
|
||||
#### Scenario: 台切换确认
|
||||
- **WHEN** 用户点击非当前台的入口
|
||||
- **THEN** 系统 SHALL 显示确认对话框
|
||||
- **AND** 对话框提示"切换到[台名称]?"
|
||||
- **AND** 用户确认后执行跳转
|
||||
|
||||
#### Scenario: 台入口路由
|
||||
- **WHEN** 用户确认切换
|
||||
- **THEN** 系统 SHALL 跳转到对应的路由
|
||||
- **AND** 工作台路由为 `/console`
|
||||
- **AND** 开发台路由为 `/developer`
|
||||
- **AND** 管理台路由为 `/admin`
|
||||
|
||||
### Requirement: 用户状态区域
|
||||
系统 SHALL 在头部右侧显示当前用户状态,支持已登录和未登录两种状态。
|
||||
|
||||
#### Scenario: 已登录状态展示
|
||||
- **WHEN** 用户已登录
|
||||
- **THEN** 系统 SHALL 显示用户头像、用户名和下拉图标
|
||||
- **AND** 头像使用 `user-avatar` 样式类
|
||||
|
||||
#### Scenario: 未登录状态展示
|
||||
- **WHEN** 用户未登录
|
||||
- **THEN** 系统 SHALL 显示"登录"按钮
|
||||
- **AND** 点击后跳转到登录页
|
||||
|
||||
### Requirement: 用户下拉菜单
|
||||
系统 SHALL 提供用户下拉菜单,包含账户设置和退出登录选项。
|
||||
|
||||
#### Scenario: 下拉菜单展开
|
||||
- **WHEN** 用户点击用户状态区域
|
||||
- **THEN** 系统 SHALL 展开下拉菜单
|
||||
- **AND** 菜单包含"账户设置"和"退出登录"选项
|
||||
|
||||
#### Scenario: 账户设置入口
|
||||
- **WHEN** 用户点击"账户设置"
|
||||
- **THEN** 系统 SHALL 打开账户设置弹框
|
||||
- **AND** 下拉菜单收起
|
||||
- **AND** 弹框宽度为 720px
|
||||
- **AND** 弹框内按钮靠右对齐
|
||||
|
||||
#### Scenario: 退出登录
|
||||
- **WHEN** 用户点击"退出登录"
|
||||
- **THEN** 系统 SHALL 执行退出操作
|
||||
- **AND** 跳转到登录页
|
||||
|
||||
#### Scenario: 点击外部关闭
|
||||
- **WHEN** 下拉菜单展开时
|
||||
- **AND** 用户点击菜单外部区域
|
||||
- **THEN** 系统 SHALL 收起下拉菜单
|
||||
|
||||
### Requirement: 头部移动端适配
|
||||
系统 SHALL 在移动端适配头部布局,将台入口收起到汉堡菜单。
|
||||
|
||||
#### Scenario: 移动端台入口收起
|
||||
- **WHEN** 屏幕宽度小于 768px
|
||||
- **THEN** 系统 SHALL 隐藏台入口的直接显示
|
||||
- **AND** 显示汉堡菜单按钮
|
||||
|
||||
#### Scenario: 移动端菜单展开
|
||||
- **WHEN** 用户点击汉堡菜单按钮
|
||||
- **THEN** 系统 SHALL 展开移动端菜单
|
||||
- **AND** 菜单包含三个台入口选项
|
||||
|
||||
#### Scenario: 移动端用户状态保留
|
||||
- **WHEN** 屏幕宽度小于 768px
|
||||
- **THEN** 系统 SHALL 保持用户状态区域可见
|
||||
- **AND** 用户状态不收起到汉堡菜单中
|
||||
|
||||
### Requirement: 头部布局路由控制
|
||||
系统 SHALL 通过路由配置控制头部的显示与隐藏。
|
||||
|
||||
#### Scenario: 需要 Header 的页面
|
||||
- **WHEN** 用户访问主页、工作台、开发台、管理台
|
||||
- **THEN** 系统 SHALL 显示统一头部
|
||||
|
||||
#### Scenario: 不需要 Header 的页面
|
||||
- **WHEN** 用户访问登录页
|
||||
- **THEN** 系统 SHALL 不显示头部
|
||||
- **AND** 页面使用独立布局
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
提供统一的状态管理方案,包括全局用户信息上下文、页面状态持久化、导航逻辑管理,确保应用状态的一致性和可维护性。
|
||||
提供统一的全局用户信息上下文,确保应用状态的一致性和可维护性。页面状态和导航逻辑由 URL 路由驱动。
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -20,33 +20,3 @@
|
||||
#### Scenario: UserContext 提供默认值
|
||||
- **WHEN** 应用启动且没有提供用户信息
|
||||
- **THEN** 系统使用默认用户信息(name: '张三', avatar: '张', role: 'AI 产品部')
|
||||
|
||||
### Requirement: 页面状态持久化 Hook
|
||||
系统 SHALL 提供 usePageState Hook,封装页面状态持久化逻辑,自动处理 localStorage 同步和主页跳转重置。
|
||||
|
||||
#### Scenario: usePageState 初始化从 localStorage 恢复状态
|
||||
- **WHEN** 页面使用 usePageState Hook 并传入 storageKey 和 defaultPage
|
||||
- **THEN** 系统从 localStorage 读取之前保存的页面状态,若无则使用 defaultPage
|
||||
|
||||
#### Scenario: usePageState 自动同步状态到 localStorage
|
||||
- **WHEN** 调用 usePageState 返回的 setCurrentPage 函数
|
||||
- **THEN** 系统更新状态并自动保存到 localStorage
|
||||
|
||||
#### Scenario: usePageState 处理主页跳转重置
|
||||
- **WHEN** 从主页跳转到页面(location.state.fromHome 为 true)
|
||||
- **THEN** 系统重置页面状态为默认值,并清除路由 state
|
||||
|
||||
#### Scenario: usePageState 提供 getPageTitle 函数
|
||||
- **WHEN** 调用 usePageState 返回的 getPageTitle 函数
|
||||
- **THEN** 系统根据当前页面 ID 从配置中查找并返回对应的页面标题
|
||||
|
||||
### Requirement: 导航逻辑 Hook
|
||||
系统 SHALL 提供 useNavigation Hook,统一处理页面导航和路由状态管理。
|
||||
|
||||
#### Scenario: useNavigation 提供页面切换函数
|
||||
- **WHEN** 调用 useNavigation 返回的 navigateToPage 函数并传入目标页面 ID
|
||||
- **THEN** 系统更新当前页面状态并执行相应导航逻辑
|
||||
|
||||
#### Scenario: useNavigation 处理带数据的页面切换
|
||||
- **WHEN** 调用 navigateToPage 并传入目标页面 ID 和附加数据(如 skillId)
|
||||
- **THEN** 系统更新页面状态和附加数据状态
|
||||
|
||||
87
openspec/specs/user-model-config/spec.md
Normal file
87
openspec/specs/user-model-config/spec.md
Normal 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** 系统不显示"设为默认"按钮
|
||||
169
openspec/specs/workspace-sidebar/spec.md
Normal file
169
openspec/specs/workspace-sidebar/spec.md
Normal file
@@ -0,0 +1,169 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 工作空间侧边栏默认关闭
|
||||
系统应在对话界面加载时将工作空间侧边栏默认设置为关闭状态。
|
||||
|
||||
#### Scenario: 进入对话页面侧边栏关闭
|
||||
- **WHEN** 用户进入对话页面
|
||||
- **THEN** 工作空间侧边栏处于关闭状态,不遮挡对话内容
|
||||
|
||||
#### Scenario: 切换对话侧边栏保持关闭
|
||||
- **WHEN** 用户从一个对话切换到另一个对话
|
||||
- **THEN** 工作空间侧边栏保持关闭状态
|
||||
|
||||
### Requirement: 工作空间侧边栏可展开
|
||||
系统应允许用户通过点击展开按钮打开工作空间侧边栏。
|
||||
|
||||
#### Scenario: 点击展开按钮打开侧边栏
|
||||
- **WHEN** 用户点击对话界面右上角的展开按钮
|
||||
- **THEN** 工作空间侧边栏从右侧展开,宽度为 240px
|
||||
|
||||
#### Scenario: 侧边栏展开显示文件树
|
||||
- **WHEN** 工作空间侧边栏展开
|
||||
- **THEN** 侧边栏内显示文件树组件,展示工作空间的文件和文件夹结构
|
||||
|
||||
### Requirement: 工作空间侧边栏可关闭
|
||||
系统应允许用户通过点击关闭按钮关闭工作空间侧边栏。
|
||||
|
||||
#### Scenario: 点击关闭按钮关闭侧边栏
|
||||
- **WHEN** 用户点击侧边栏 header 内的关闭按钮
|
||||
- **THEN** 工作空间侧边栏关闭,恢复为隐藏状态
|
||||
|
||||
### Requirement: 工作空间侧边栏布局
|
||||
工作空间侧边栏应包含 header 区域和 content 区域,content 区域采用左右分栏布局。
|
||||
|
||||
#### Scenario: 侧边栏 header 显示标题和关闭按钮
|
||||
- **WHEN** 工作空间侧边栏展开
|
||||
- **THEN** header 区域显示"工作空间"标题和关闭按钮(×)
|
||||
|
||||
#### Scenario: 侧边栏 content 左右分栏
|
||||
- **WHEN** 工作空间侧边栏展开
|
||||
- **THEN** content 区域分为左侧文件树和右侧预览面板,使用 Flex 布局
|
||||
|
||||
### Requirement: 侧边栏展开按钮位置
|
||||
展开按钮应固定在对话界面的右上角,位于 header 下方。
|
||||
|
||||
#### Scenario: 展开按钮在右上角
|
||||
- **WHEN** 工作空间侧边栏关闭
|
||||
- **THEN** 对话界面右上角显示展开按钮(工作空间图标),位于 header 下方
|
||||
|
||||
#### Scenario: 展开按钮始终可见
|
||||
- **WHEN** 用户滚动对话内容
|
||||
- **THEN** 展开按钮保持在右上角固定位置,不随内容滚动
|
||||
|
||||
#### Scenario: 展开按钮不被遮挡
|
||||
- **WHEN** 展开按钮显示
|
||||
- **THEN** 按钮的 z-index 为 150,高于 header 的 100,不被遮挡
|
||||
|
||||
### Requirement: 侧边栏打开时隐藏展开按钮
|
||||
展开按钮应在侧边栏打开时隐藏。
|
||||
|
||||
#### Scenario: 侧边栏打开按钮隐藏
|
||||
- **WHEN** 用户点击展开按钮打开侧边栏
|
||||
- **THEN** 展开按钮自动隐藏
|
||||
|
||||
#### Scenario: 侧边栏关闭按钮显示
|
||||
- **WHEN** 用户关闭侧边栏
|
||||
- **THEN** 展开按钮重新显示
|
||||
|
||||
### Requirement: 侧边栏支持拖动调整宽度
|
||||
用户应能够通过拖动调整侧边栏宽度。
|
||||
|
||||
#### Scenario: 默认宽度
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 侧边栏默认宽度为 500px
|
||||
|
||||
#### Scenario: 拖动调整宽度
|
||||
- **WHEN** 用户拖动侧边栏左边缘的手柄
|
||||
- **THEN** 侧边栏宽度随鼠标移动而改变,范围限制在 400px 到 800px
|
||||
|
||||
#### Scenario: 拖动手柄始终显示
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 左边缘的拖动手柄始终显示(opacity: 0.5),hover 时 opacity 为 1
|
||||
|
||||
#### Scenario: 拖动性能流畅
|
||||
- **WHEN** 用户拖动调整宽度
|
||||
- **THEN** 使用 requestAnimationFrame 优化,拖动流畅跟手,无卡顿
|
||||
|
||||
### Requirement: 标题栏显示操作按钮
|
||||
工作空间侧边栏标题栏应显示操作按钮。
|
||||
|
||||
#### Scenario: 标题栏布局
|
||||
- **WHEN** 工作空间侧边栏打开
|
||||
- **THEN** 标题栏显示"工作空间"标题,右侧依次显示刷新按钮、新建按钮、关闭按钮
|
||||
|
||||
#### Scenario: 刷新按钮位置
|
||||
- **WHEN** 标题栏显示
|
||||
- **THEN** 刷新按钮(↻)显示在标题右侧,使用 FiRefresh 图标
|
||||
|
||||
#### Scenario: 新建按钮位置
|
||||
- **WHEN** 标题栏显示
|
||||
- **THEN** 新建按钮(+)显示在刷新按钮右侧,使用 FiPlus 图标
|
||||
|
||||
#### Scenario: 新建按钮下拉菜单
|
||||
- **WHEN** 用户点击新建按钮
|
||||
- **THEN** 显示下拉菜单,包含"新建文件"、"新建文件夹"、"上传文件"选项
|
||||
|
||||
#### Scenario: 关闭按钮位置
|
||||
- **WHEN** 标题栏显示
|
||||
- **THEN** 关闭按钮(×)显示在最右侧
|
||||
|
||||
### Requirement: 标题栏按钮样式一致
|
||||
标题栏按钮应使用统一的样式。
|
||||
|
||||
#### Scenario: 按钮尺寸一致
|
||||
- **WHEN** 标题栏按钮显示
|
||||
- **THEN** 刷新按钮、新建按钮、关闭按钮的尺寸一致(28px × 28px)
|
||||
|
||||
#### Scenario: 按钮交互效果
|
||||
- **WHEN** 用户 hover 标题栏按钮
|
||||
- **THEN** 按钮背景变为 var(--color-bg-1),图标颜色变为 var(--color-text-1)
|
||||
|
||||
#### Scenario: 按钮间距
|
||||
- **WHEN** 标题栏按钮显示
|
||||
- **THEN** 按钮之间有 4px 间距
|
||||
|
||||
### Requirement: 侧边栏内部左右分栏布局
|
||||
侧边栏 content 区域应采用左右分栏布局。
|
||||
|
||||
#### Scenario: 文件树在左侧
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树显示在左侧,默认宽度 200px,范围 180-300px
|
||||
|
||||
#### Scenario: 预览面板在右侧
|
||||
- **WHEN** 用户点击文件树中的文件
|
||||
- **THEN** 预览面板显示在右侧,宽度自适应(flex: 1)
|
||||
|
||||
#### Scenario: 分隔线显示
|
||||
- **WHEN** 侧边栏打开
|
||||
- **THEN** 文件树和预览面板之间显示 1px 分隔线
|
||||
|
||||
### Requirement: 双拖动调整机制
|
||||
侧边栏应支持两层拖动调整。
|
||||
|
||||
#### Scenario: 外部拖动调整整体宽度
|
||||
- **WHEN** 用户拖动侧边栏左边缘手柄
|
||||
- **THEN** 侧边栏整体宽度改变(400-800px),文件树宽度不变,预览面板宽度自适应
|
||||
|
||||
#### Scenario: 内部拖动调整文件树宽度
|
||||
- **WHEN** 用户拖动文件树和预览面板之间的分隔线
|
||||
- **THEN** 文件树宽度改变(180-300px),侧边栏整体宽度不变,预览面板宽度自适应
|
||||
|
||||
#### Scenario: 分隔线可拖动
|
||||
- **WHEN** 用户 hover 分隔线
|
||||
- **THEN** 分隔线背景色变为 var(--color-primary),鼠标变为 ew-resize
|
||||
|
||||
#### Scenario: 分隔线拖动区域扩大
|
||||
- **WHEN** 用户在分隔线附近 4px 范围内按下鼠标
|
||||
- **THEN** 开始拖动调整文件树宽度
|
||||
|
||||
### Requirement: 对话区域最小宽度约束
|
||||
对话区域应保留最小宽度,保证可用性。
|
||||
|
||||
#### Scenario: 对话区域最小宽度
|
||||
- **WHEN** 侧边栏宽度调整
|
||||
- **THEN** 对话区域宽度不小于 480px,保证输入框和消息区可用
|
||||
|
||||
#### Scenario: 侧边栏最大宽度限制
|
||||
- **WHEN** 对话区域宽度接近 480px
|
||||
- **THEN** 侧边栏宽度不再增加,保证对话区域可用性
|
||||
116
src/App.jsx
116
src/App.jsx
@@ -1,25 +1,123 @@
|
||||
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { UserProvider } from './contexts/UserContext.jsx';
|
||||
import AppLayout from './components/layout/AppLayout.jsx';
|
||||
import ConsoleLayout from './components/layout/ConsoleLayout.jsx';
|
||||
import AdminLayout from './components/layout/AdminLayout.jsx';
|
||||
import DeveloperLayout from './components/layout/DeveloperLayout.jsx';
|
||||
import HomePage from './pages/HomePage.jsx';
|
||||
import LoginPage from './pages/LoginPage.jsx';
|
||||
import ConsolePage from './pages/ConsolePage.jsx';
|
||||
import AdminPage from './pages/AdminPage.jsx';
|
||||
import DeveloperPage from './pages/DeveloperPage.jsx';
|
||||
|
||||
// Console 子页面
|
||||
import ChatPage from './pages/console/ChatPage.jsx';
|
||||
import SkillsPage from './pages/console/SkillsPage.jsx';
|
||||
import SkillDetailPage from './pages/console/SkillDetailPage.jsx';
|
||||
import ConsoleMySkillsPage from './pages/console/MySkillsPage.jsx';
|
||||
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 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';
|
||||
import DepartmentsPage from './pages/admin/DepartmentsPage.jsx';
|
||||
import AddDepartmentPage from './pages/admin/AddDepartmentPage.jsx';
|
||||
import UsersPage from './pages/admin/UsersPage.jsx';
|
||||
import AddUserPage from './pages/admin/AddUserPage.jsx';
|
||||
import AdminProjectsPage from './pages/admin/AdminProjectsPage.jsx';
|
||||
import AddProjectPage from './pages/admin/AddProjectPage.jsx';
|
||||
import AdminLogsPage from './pages/admin/AdminLogsPage.jsx';
|
||||
import ModelConfigsPage from './pages/admin/ModelConfigsPage.jsx';
|
||||
import AddModelConfigPage from './pages/admin/AddModelConfigPage.jsx';
|
||||
|
||||
// Developer 子页面
|
||||
import DevOverviewPage from './pages/developer/DevOverviewPage.jsx';
|
||||
import DeveloperMySkillsPage from './pages/developer/MySkillsPage.jsx';
|
||||
import UploadSkillPage from './pages/developer/UploadSkillPage.jsx';
|
||||
import SkillEditorPage from './pages/developer/SkillEditorPage.jsx';
|
||||
import UploadVersionPage from './pages/developer/UploadVersionPage.jsx';
|
||||
import UpdateSkillInfoPage from './pages/developer/UpdateSkillInfoPage.jsx';
|
||||
import DevDocsPage from './pages/developer/DevDocsPage.jsx';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<UserProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/console" element={<ConsolePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/developer" element={<DeveloperPage />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
|
||||
<Route path="/console" element={<ConsoleLayout />}>
|
||||
<Route index element={<Navigate to="chat/welcome" replace />} />
|
||||
<Route path="chat" element={<ChatPage />} />
|
||||
<Route path="chat/:scene" element={<ChatPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="skills/:skillId" element={<SkillDetailPage />} />
|
||||
<Route path="my-skills" element={<ConsoleMySkillsPage />} />
|
||||
<Route path="my-skills/:subscriptionId/config" element={<SkillConfigPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
<Route path="tasks" element={<TasksPage />} />
|
||||
<Route path="tasks/:taskId" element={<TaskDetailPage />} />
|
||||
<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 />}>
|
||||
<Route index element={<Navigate to="overview" replace />} />
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
<Route path="reviews" element={<ConsoleReviewListPage />} />
|
||||
<Route path="reviews/:type/:reviewId" element={<ConsoleReviewDetailPage />} />
|
||||
<Route path="departments" element={<DepartmentsPage />} />
|
||||
<Route path="departments/add" element={<AddDepartmentPage />} />
|
||||
<Route path="departments/:id/edit" element={<AddDepartmentPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/add" element={<AddUserPage />} />
|
||||
<Route path="users/:id/edit" element={<AddUserPage />} />
|
||||
<Route path="projects" element={<AdminProjectsPage />} />
|
||||
<Route path="projects/add" element={<AddProjectPage />} />
|
||||
<Route path="projects/:id/edit" element={<AddProjectPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="models" element={<ModelConfigsPage />} />
|
||||
<Route path="models/add" element={<AddModelConfigPage />} />
|
||||
<Route path="models/:id/edit" element={<AddModelConfigPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/developer" element={<DeveloperLayout />}>
|
||||
<Route index element={<Navigate to="overview" replace />} />
|
||||
<Route path="overview" element={<DevOverviewPage />} />
|
||||
<Route path="my-skills" element={<DeveloperMySkillsPage />} />
|
||||
<Route path="my-skills/upload" element={<UploadSkillPage />} />
|
||||
<Route path="my-skills/:skillId/editor" element={<SkillEditorPage />} />
|
||||
<Route path="my-skills/:skillId/new-version" element={<UploadVersionPage />} />
|
||||
<Route path="my-skills/:skillId/update-info" element={<UpdateSkillInfoPage />} />
|
||||
<Route path="docs" element={<DevDocsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { FiMenu } from 'react-icons/fi';
|
||||
|
||||
function Layout({ sidebar, headerTitle, children, sidebarClassName = 'sidebar', contentClassName = '' }) {
|
||||
function Layout({ sidebar, children, sidebarClassName = 'sidebar', contentClassName = '' }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
@@ -17,14 +16,6 @@ function Layout({ sidebar, headerTitle, children, sidebarClassName = 'sidebar',
|
||||
{sidebar}
|
||||
</aside>
|
||||
<main className="main-content">
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
<div className="mobile-menu-btn" onClick={toggleSidebar}>
|
||||
<FiMenu />
|
||||
</div>
|
||||
<div className="header-title">{headerTitle}</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className={`page-content ${contentClassName}`}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
2
src/components/account/index.js
Normal file
2
src/components/account/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AccountPage } from './AccountPage.jsx';
|
||||
export { default } from './AccountPage.jsx';
|
||||
@@ -1,11 +1,21 @@
|
||||
import { FiX } from 'react-icons/fi';
|
||||
|
||||
function Modal({ visible, title, children, onConfirm, onCancel, confirmText = '确定', cancelText = '取消' }) {
|
||||
function Modal({
|
||||
visible,
|
||||
title,
|
||||
children,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText = '确定',
|
||||
cancelText = '取消',
|
||||
showConfirm = true,
|
||||
width
|
||||
}) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()} style={width ? { width } : undefined}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
<div className="modal-close" onClick={onCancel}>
|
||||
@@ -15,10 +25,12 @@ function Modal({ visible, title, children, onConfirm, onCancel, confirmText = '
|
||||
<div className="modal-body">
|
||||
{children}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn" onClick={onCancel}>{cancelText}</button>
|
||||
<button className="btn btn-primary" onClick={onConfirm}>{confirmText}</button>
|
||||
</div>
|
||||
{showConfirm && (
|
||||
<div className="modal-footer">
|
||||
{cancelText && <button className="btn" onClick={onCancel}>{cancelText}</button>}
|
||||
<button className="btn btn-primary" onClick={onConfirm}>{confirmText}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
92
src/components/layout/AdminLayout.jsx
Normal file
92
src/components/layout/AdminLayout.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { FiHome, FiBarChart2, FiUsers, FiList, FiCheckCircle, FiActivity, FiSettings, FiCpu } from 'react-icons/fi';
|
||||
import Layout from '../Layout.jsx';
|
||||
import SidebarNavItem from './SidebarNavItem.jsx';
|
||||
|
||||
function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isPathActive = (basePath) => location.pathname.startsWith(basePath);
|
||||
|
||||
const sidebar = (
|
||||
<>
|
||||
<nav className="admin-sidebar-nav">
|
||||
<SidebarNavItem
|
||||
icon={<FiHome />}
|
||||
label="总览"
|
||||
active={location.pathname === '/admin/overview'}
|
||||
onClick={() => navigate('/admin/overview')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiCheckCircle />}
|
||||
label="审核管理"
|
||||
active={isPathActive('/admin/reviews')}
|
||||
onClick={() => navigate('/admin/reviews')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiBarChart2 />}
|
||||
label="部门管理"
|
||||
active={isPathActive('/admin/departments')}
|
||||
onClick={() => navigate('/admin/departments')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiUsers />}
|
||||
label="用户管理"
|
||||
active={isPathActive('/admin/users')}
|
||||
onClick={() => navigate('/admin/users')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiList />}
|
||||
label="项目管理"
|
||||
active={isPathActive('/admin/projects')}
|
||||
onClick={() => navigate('/admin/projects')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
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={<FiActivity />}
|
||||
label="日志查询"
|
||||
active={location.pathname === '/admin/logs'}
|
||||
onClick={() => navigate('/admin/logs')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
sidebar={sidebar}
|
||||
sidebarClassName="admin-sidebar"
|
||||
>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminLayout;
|
||||
114
src/components/layout/AppHeader.jsx
Normal file
114
src/components/layout/AppHeader.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
||||
import { FiSettings, FiCode, FiUsers, FiMenu, FiX } from 'react-icons/fi';
|
||||
import Modal from '../common/Modal.jsx';
|
||||
import UserDropdown from './UserDropdown.jsx';
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'console', name: '工作台', path: '/console', icon: FiSettings },
|
||||
{ id: 'developer', name: '开发台', path: '/developer', icon: FiCode },
|
||||
{ id: 'admin', name: '管理台', path: '/admin', icon: FiUsers },
|
||||
];
|
||||
|
||||
function AppHeader() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [confirmModal, setConfirmModal] = useState({ visible: false, platform: null });
|
||||
const mobileMenuRef = useRef(null);
|
||||
|
||||
const currentPlatform = PLATFORMS.find(p => location.pathname.startsWith(p.path))?.id || null;
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target)) {
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handlePlatformClick = (platform) => {
|
||||
if (platform.id === currentPlatform) return;
|
||||
setConfirmModal({ visible: true, platform });
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleConfirmSwitch = () => {
|
||||
if (confirmModal.platform) {
|
||||
navigate(confirmModal.platform.path);
|
||||
}
|
||||
setConfirmModal({ visible: false, platform: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="app-header">
|
||||
<div className="app-header__left">
|
||||
<Link to="/" className="app-header__brand">
|
||||
<div className="sidebar-logo-icon">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<span className="app-header__title">GrandClaw</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="app-header__right">
|
||||
<nav className="app-header__nav">
|
||||
{PLATFORMS.map(platform => {
|
||||
const Icon = platform.icon;
|
||||
return (
|
||||
<div
|
||||
key={platform.id}
|
||||
className={`app-header__nav-item ${currentPlatform === platform.id ? 'active' : ''}`}
|
||||
onClick={() => handlePlatformClick(platform)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{platform.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<UserDropdown />
|
||||
|
||||
<div className="app-header__mobile" ref={mobileMenuRef}>
|
||||
<div className="app-header__mobile-btn" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
|
||||
{mobileMenuOpen ? <FiX /> : <FiMenu />}
|
||||
</div>
|
||||
{mobileMenuOpen && (
|
||||
<div className="app-header__mobile-menu">
|
||||
{PLATFORMS.map(platform => {
|
||||
const Icon = platform.icon;
|
||||
return (
|
||||
<div
|
||||
key={platform.id}
|
||||
className={`app-header__mobile-item ${currentPlatform === platform.id ? 'active' : ''}`}
|
||||
onClick={() => handlePlatformClick(platform)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{platform.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
visible={confirmModal.visible}
|
||||
title="切换确认"
|
||||
onConfirm={handleConfirmSwitch}
|
||||
onCancel={() => setConfirmModal({ visible: false, platform: null })}
|
||||
confirmText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<p>切换到{confirmModal.platform?.name}?</p>
|
||||
</Modal>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppHeader;
|
||||
15
src/components/layout/AppLayout.jsx
Normal file
15
src/components/layout/AppLayout.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import AppHeader from './AppHeader.jsx';
|
||||
|
||||
function AppLayout() {
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<AppHeader />
|
||||
<main className="app-layout__main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
164
src/components/layout/ConsoleLayout.jsx
Normal file
164
src/components/layout/ConsoleLayout.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
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';
|
||||
|
||||
function ConsoleLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
|
||||
const sceneMatch = location.pathname.match(/\/console\/chat\/(.+)$/);
|
||||
const currentScene = sceneMatch ? sceneMatch[1] : null;
|
||||
|
||||
const isChatPage = location.pathname === '/console/chat' ||
|
||||
location.pathname.startsWith('/console/chat/');
|
||||
|
||||
const isPathActive = (basePath) => location.pathname.startsWith(basePath);
|
||||
|
||||
const handleDeleteClick = (e, conv) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(conv);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
console.log('删除对话:', deleteTarget?.id);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const sidebar = (
|
||||
<>
|
||||
<div className="chat-sidebar-header">
|
||||
<button className="btn btn-primary" style={{ width: '100%' }}
|
||||
onClick={() => navigate('/console/chat')}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
|
||||
<FiPlus /> 新建对话
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="chat-sidebar-content">
|
||||
{api.conversations.list().map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
className={`conversation-item ${conv.scene === currentScene ? 'active' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="conversation-item__content"
|
||||
onClick={() => navigate(`/console/chat/${conv.scene}`)}
|
||||
>
|
||||
<div className="conversation-title">{conv.title}</div>
|
||||
<div className="conversation-time">{conv.time}</div>
|
||||
</div>
|
||||
<button
|
||||
className="conversation-item__delete"
|
||||
onClick={(e) => handleDeleteClick(e, conv)}
|
||||
title="删除对话"
|
||||
>
|
||||
<FiTrash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="chat-sidebar-project">
|
||||
<label className="chat-sidebar-project-label">当前项目</label>
|
||||
<select className="form-control chat-sidebar-project-select">
|
||||
<option>企业 AI 智算平台</option>
|
||||
<option>知识库管理系统</option>
|
||||
<option>数据分析平台</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="chat-sidebar-nav">
|
||||
<SidebarNavItem
|
||||
icon={<FaPuzzlePiece />}
|
||||
label="技能市场"
|
||||
active={isPathActive('/console/skills')}
|
||||
onClick={() => navigate('/console/skills')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiBox />}
|
||||
label="我的技能"
|
||||
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="定时任务"
|
||||
active={isPathActive('/console/tasks')}
|
||||
onClick={() => navigate('/console/tasks')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiList />}
|
||||
label="日志查询"
|
||||
active={isPathActive('/console/logs')}
|
||||
onClick={() => navigate('/console/logs')}
|
||||
/>
|
||||
<SidebarNavGroup
|
||||
icon={<FiFolder />}
|
||||
label="项目管理"
|
||||
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}
|
||||
title="确认删除"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
>
|
||||
确定要删除对话「{deleteTarget?.title}」吗?
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
sidebar={sidebar}
|
||||
sidebarClassName="chat-sidebar"
|
||||
contentClassName={isChatPage ? 'page-content-full' : ''}
|
||||
>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConsoleLayout;
|
||||
84
src/components/layout/DeveloperLayout.jsx
Normal file
84
src/components/layout/DeveloperLayout.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { FiPlus, FiTerminal, FiHome } from 'react-icons/fi';
|
||||
import { FaPuzzlePiece } from 'react-icons/fa';
|
||||
import Layout from '../Layout.jsx';
|
||||
import SidebarNavItem from './SidebarNavItem.jsx';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
const skillStatusMap = {
|
||||
dev: { text: '开发中', className: 'status-stopped' },
|
||||
published: { text: '已上架', className: 'status-running' },
|
||||
unlisting: { text: '下架审核中', className: 'status-warning' },
|
||||
unlisted: { text: '已下架', className: 'status-stopped' }
|
||||
};
|
||||
|
||||
function DeveloperLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isPathActive = (basePath) => location.pathname.startsWith(basePath);
|
||||
|
||||
// 获取当前技能编辑器中的 skillId
|
||||
const editorMatch = location.pathname.match(/\/developer\/my-skills\/(\d+)\/editor$/);
|
||||
const activeSkillId = editorMatch ? parseInt(editorMatch[1]) : null;
|
||||
|
||||
const sidebar = (
|
||||
<>
|
||||
<div className="chat-sidebar-header">
|
||||
<button className="btn btn-primary" style={{ width: '100%' }}
|
||||
onClick={() => navigate('/developer/my-skills/upload')}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
|
||||
<FiPlus /> 创建技能
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="chat-sidebar-content">
|
||||
{api.developer.getMySkills().map(skill => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className={`conversation-item ${activeSkillId === skill.id ? 'active' : ''}`}
|
||||
onClick={() => navigate(`/developer/my-skills/${skill.id}/editor`)}
|
||||
>
|
||||
<div className="conversation-title">{skill.name}</div>
|
||||
<div className="conversation-time">
|
||||
<span className={`status ${skillStatusMap[skill.status]?.className || 'status-stopped'}`}>
|
||||
{skillStatusMap[skill.status]?.text || skill.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="chat-sidebar-nav">
|
||||
<SidebarNavItem
|
||||
icon={<FiHome />}
|
||||
label="总览"
|
||||
active={location.pathname === '/developer/overview'}
|
||||
onClick={() => navigate('/developer/overview')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FaPuzzlePiece />}
|
||||
label="我的技能"
|
||||
active={isPathActive('/developer/my-skills')}
|
||||
onClick={() => navigate('/developer/my-skills')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiTerminal />}
|
||||
label="开发文档"
|
||||
active={location.pathname === '/developer/docs'}
|
||||
onClick={() => navigate('/developer/docs')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
sidebar={sidebar}
|
||||
sidebarClassName="chat-sidebar"
|
||||
>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeveloperLayout;
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* SidebarBrand - 侧边栏品牌区域组件
|
||||
* 统一显示 GrandClaw 品牌标识和副标题
|
||||
*
|
||||
* @param {Object} props - 组件属性
|
||||
* @param {string} [props.subtitle] - 副标题文本(如"企业级AI平台"、"运营管理台"、"技能开发台")
|
||||
*/
|
||||
function SidebarBrand({ subtitle = '企业级AI平台' }) {
|
||||
return (
|
||||
<div className="sidebar-brand">
|
||||
<div className="sidebar-logo-icon">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div className="sidebar-brand-text">
|
||||
<div className="sidebar-logo">GrandClaw</div>
|
||||
<div className="sidebar-subtitle">{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SidebarBrand;
|
||||
44
src/components/layout/SidebarNavGroup.jsx
Normal file
44
src/components/layout/SidebarNavGroup.jsx
Normal 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;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useUserContext } from '../../contexts/UserContext.jsx';
|
||||
|
||||
/**
|
||||
* SidebarUser - 侧边栏用户信息组件
|
||||
* 从 UserContext 获取用户信息并显示
|
||||
*
|
||||
* @param {Object} props - 组件属性
|
||||
* @param {Function} [props.onClick] - 点击回调函数
|
||||
* @param {string} [props.wrapperClassName] - 包装器类名(如"chat-sidebar-user"、"admin-sidebar-user")
|
||||
* @param {string} [props.infoClassName] - 信息容器类名(如"chat-sidebar-user-info"、"admin-sidebar-user-info")
|
||||
* @param {string} [props.nameClassName] - 姓名容器类名(如"chat-sidebar-user-name"、"admin-sidebar-user-name")
|
||||
* @param {string} [props.roleClassName] - 角色容器类名(如"chat-sidebar-user-role"、"admin-sidebar-user-role")
|
||||
*/
|
||||
function SidebarUser({
|
||||
onClick,
|
||||
wrapperClassName = 'chat-sidebar-user',
|
||||
infoClassName = 'chat-sidebar-user-info',
|
||||
nameClassName = 'chat-sidebar-user-name',
|
||||
roleClassName = 'chat-sidebar-user-role',
|
||||
}) {
|
||||
const { user } = useUserContext();
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName} onClick={onClick}>
|
||||
<div className="user-avatar">{user.avatar}</div>
|
||||
<div className={infoClassName}>
|
||||
<div className={nameClassName}>{user.name}</div>
|
||||
<div className={roleClassName}>{user.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SidebarUser;
|
||||
222
src/components/layout/UserDropdown.jsx
Normal file
222
src/components/layout/UserDropdown.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FiChevronDown, FiUser, FiLogOut } from 'react-icons/fi';
|
||||
import { useUserContext } from '../../contexts/UserContext.jsx';
|
||||
import Modal from '../common/Modal.jsx';
|
||||
import Toast from '../common/Toast.jsx';
|
||||
|
||||
function UserDropdown() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUserContext();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showAccountModal, setShowAccountModal] = useState(false);
|
||||
const [profileToast, setProfileToast] = useState(null);
|
||||
const [passwordErrors, setPasswordErrors] = useState({});
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleAccountClick = () => {
|
||||
setIsOpen(false);
|
||||
setShowAccountModal(true);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setIsOpen(false);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleProfileSave = () => {
|
||||
setProfileToast({ type: 'success', message: '保存成功' });
|
||||
setTimeout(() => setProfileToast(null), 3000);
|
||||
};
|
||||
|
||||
const handlePasswordChange = (field, value) => {
|
||||
setPasswordForm(prev => ({ ...prev, [field]: value }));
|
||||
setPasswordErrors(prev => ({ ...prev, [field]: '' }));
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = () => {
|
||||
const errors = {};
|
||||
if (!passwordForm.currentPassword) {
|
||||
errors.currentPassword = '请输入当前密码';
|
||||
}
|
||||
if (!passwordForm.newPassword) {
|
||||
errors.newPassword = '请输入新密码';
|
||||
}
|
||||
if (!passwordForm.confirmPassword) {
|
||||
errors.confirmPassword = '请再次输入新密码';
|
||||
} else if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
errors.confirmPassword = '两次输入的密码不一致';
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length === 0) {
|
||||
setProfileToast({ type: 'success', message: '密码更新成功' });
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
setTimeout(() => setProfileToast(null), 3000);
|
||||
} else {
|
||||
setPasswordErrors(errors);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-header__user" ref={dropdownRef}>
|
||||
<div className="app-header__user-trigger" onClick={() => setIsOpen(!isOpen)}>
|
||||
<div className="user-avatar">{user.avatar}</div>
|
||||
<span className="app-header__user-name">{user.name}</span>
|
||||
<FiChevronDown className={`app-header__user-arrow ${isOpen ? 'open' : ''}`} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="app-header__dropdown">
|
||||
<div className="app-header__dropdown-item" onClick={handleAccountClick}>
|
||||
<FiUser />
|
||||
<span>账户设置</span>
|
||||
</div>
|
||||
<div className="app-header__dropdown-item" onClick={handleLogout}>
|
||||
<FiLogOut />
|
||||
<span>退出登录</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
visible={showAccountModal}
|
||||
title="账户设置"
|
||||
onCancel={() => setShowAccountModal(false)}
|
||||
showConfirm={false}
|
||||
width="720px"
|
||||
>
|
||||
<div className="account-modal-content">
|
||||
<div style={{ textAlign: 'center', marginBottom: '24px', paddingBottom: '24px', borderBottom: '1px solid var(--color-border-2)' }}>
|
||||
<div style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #3B82F6, #8B5CF6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '32px',
|
||||
margin: '0 auto 12px'
|
||||
}}>{user.avatar}</div>
|
||||
<button className="btn btn-sm">更换头像</button>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-col">
|
||||
<div className="form-group">
|
||||
<label className="form-label">用户名</label>
|
||||
<input type="text" className="form-control" defaultValue="zhangsan" readOnly style={{ background: '#F8FAFC' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<div className="form-group">
|
||||
<label className="form-label">姓名</label>
|
||||
<input type="text" className="form-control" defaultValue={user.name} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-col">
|
||||
<div className="form-group">
|
||||
<label className="form-label">邮箱</label>
|
||||
<input type="email" className="form-control" defaultValue="zhangsan@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<div className="form-group">
|
||||
<label className="form-label">手机号</label>
|
||||
<input type="text" className="form-control" defaultValue="138****8888" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">所属部门</label>
|
||||
<input type="text" className="form-control" defaultValue={user.role} readOnly style={{ background: '#F8FAFC' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px', textAlign: 'right' }}>
|
||||
<button className="btn btn-primary" onClick={handleProfileSave}>保存修改</button>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--color-border-2)', paddingTop: '20px', marginTop: '20px' }}>
|
||||
<h4 style={{ fontSize: '15px', fontWeight: '600', marginBottom: '16px', color: 'var(--color-text-1)' }}>修改密码</h4>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className={`form-control ${passwordErrors.currentPassword ? 'is-invalid' : ''}`}
|
||||
placeholder="请输入当前密码"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={e => handlePasswordChange('currentPassword', e.target.value)}
|
||||
/>
|
||||
{passwordErrors.currentPassword && (
|
||||
<div className="form-error">{passwordErrors.currentPassword}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className={`form-control ${passwordErrors.newPassword ? 'is-invalid' : ''}`}
|
||||
placeholder="请输入新密码"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={e => handlePasswordChange('newPassword', e.target.value)}
|
||||
/>
|
||||
{passwordErrors.newPassword && (
|
||||
<div className="form-error">{passwordErrors.newPassword}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className={`form-control ${passwordErrors.confirmPassword ? 'is-invalid' : ''}`}
|
||||
placeholder="请再次输入新密码"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={e => handlePasswordChange('confirmPassword', e.target.value)}
|
||||
/>
|
||||
{passwordErrors.confirmPassword && (
|
||||
<div className="form-error">{passwordErrors.confirmPassword}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<button className="btn btn-primary" onClick={handlePasswordSubmit}>更新密码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Toast
|
||||
visible={!!profileToast}
|
||||
type={profileToast?.type}
|
||||
message={profileToast?.message}
|
||||
onClose={() => setProfileToast(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserDropdown;
|
||||
55
src/components/workspace/CreateModal.jsx
Normal file
55
src/components/workspace/CreateModal.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from '../common/Modal.jsx';
|
||||
|
||||
/**
|
||||
* 新建弹框组件
|
||||
* 支持新建文件和文件夹
|
||||
*/
|
||||
function CreateModal({ type, onConfirm, onCancel }) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
if (!type) return null;
|
||||
|
||||
const title = type === 'file' ? '新建文件' : '新建文件夹';
|
||||
const placeholder = type === 'file' ? '请输入文件名' : '请输入文件夹名';
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (name.trim()) {
|
||||
onConfirm && onConfirm(name.trim(), type);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
title={title}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={onCancel}
|
||||
confirmText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--color-text-2)' }}>
|
||||
名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--color-border-2)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateModal;
|
||||
140
src/components/workspace/FileContextMenu.jsx
Normal file
140
src/components/workspace/FileContextMenu.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { FiDownload, FiEdit2, FiTrash2, FiInfo, FiFolder, FiFilePlus, FiFolderPlus, FiUpload } from 'react-icons/fi';
|
||||
|
||||
/**
|
||||
* 文件右键菜单组件
|
||||
*/
|
||||
function FileContextMenu({ file, position, onDownload, onRename, onDelete, onDetail, onMove, onNewFile, onNewFolder, onUpload, onClose }) {
|
||||
const isFolder = file.type === 'folder';
|
||||
|
||||
const handleAction = (action) => {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
onDownload && onDownload(file);
|
||||
break;
|
||||
case 'rename':
|
||||
onRename && onRename(file);
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete && onDelete(file);
|
||||
break;
|
||||
case 'detail':
|
||||
onDetail && onDetail(file);
|
||||
break;
|
||||
case 'move':
|
||||
onMove && onMove(file);
|
||||
break;
|
||||
case 'new-file':
|
||||
onNewFile && onNewFile(file);
|
||||
break;
|
||||
case 'new-folder':
|
||||
onNewFolder && onNewFolder(file);
|
||||
break;
|
||||
case 'upload':
|
||||
onUpload && onUpload(file);
|
||||
break;
|
||||
}
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="file-context-menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
}}
|
||||
>
|
||||
{/* 文件夹特有操作 */}
|
||||
{isFolder && (
|
||||
<>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('new-file')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiFilePlus size={14} />
|
||||
</span>
|
||||
新建文件
|
||||
</div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('new-folder')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiFolderPlus size={14} />
|
||||
</span>
|
||||
新建文件夹
|
||||
</div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('upload')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiUpload size={14} />
|
||||
</span>
|
||||
上传文件
|
||||
</div>
|
||||
<div className="file-context-menu__divider"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 基础操作 */}
|
||||
{!isFolder && (
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('download')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiDownload size={14} />
|
||||
</span>
|
||||
下载
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('rename')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiEdit2 size={14} />
|
||||
</span>
|
||||
重命名
|
||||
</div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('move')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiFolder size={14} />
|
||||
</span>
|
||||
移动到
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<div className="file-context-menu__divider"></div>
|
||||
<div
|
||||
className="file-context-menu__item"
|
||||
onClick={() => handleAction('detail')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiInfo size={14} />
|
||||
</span>
|
||||
查看详情
|
||||
</div>
|
||||
|
||||
{/* 危险操作 */}
|
||||
<div className="file-context-menu__divider"></div>
|
||||
<div
|
||||
className="file-context-menu__item file-context-menu__item--danger"
|
||||
onClick={() => handleAction('delete')}
|
||||
>
|
||||
<span className="file-context-menu__icon">
|
||||
<FiTrash2 size={14} />
|
||||
</span>
|
||||
删除
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileContextMenu;
|
||||
121
src/components/workspace/FileDetailModal.jsx
Normal file
121
src/components/workspace/FileDetailModal.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { FiX } from 'react-icons/fi';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
* 文件详情弹框组件
|
||||
*/
|
||||
function FileDetailModal({ file, onClose }) {
|
||||
if (!file) return null;
|
||||
|
||||
const icon = api.workspace.getFileIcon(file.fileType);
|
||||
const isFolder = file.type === 'folder';
|
||||
|
||||
// 获取文件类型描述
|
||||
const getTypeDesc = (fileType) => {
|
||||
const typeMap = {
|
||||
text: '文本文件',
|
||||
markdown: 'Markdown 文件',
|
||||
json: 'JSON 文件',
|
||||
javascript: 'JavaScript 文件',
|
||||
python: 'Python 文件',
|
||||
css: 'CSS 文件',
|
||||
html: 'HTML 文件',
|
||||
word: 'Word 文档',
|
||||
excel: 'Excel 文档',
|
||||
powerpoint: 'PowerPoint 文档',
|
||||
image: '图片文件',
|
||||
video: '视频文件',
|
||||
audio: '音频文件',
|
||||
pdf: 'PDF 文件',
|
||||
archive: '压缩文件',
|
||||
unknown: '未知类型',
|
||||
};
|
||||
return typeMap[fileType] || '文件';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">文件详情</div>
|
||||
<div className="modal-close" onClick={onClose}>
|
||||
<FiX size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
名称
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
类型
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{isFolder ? '文件夹' : getTypeDesc(file.fileType)}
|
||||
</div>
|
||||
</div>
|
||||
{!isFolder && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
大小
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.size || '-'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{file.dimensions && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
尺寸
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.dimensions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{file.duration && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
时长
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.duration}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
修改时间
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
{file.modifiedAt || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '4px' }}>
|
||||
路径
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-1)' }}>
|
||||
/workspace/{file.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn--primary" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileDetailModal;
|
||||
156
src/components/workspace/FilePreviewPanel.jsx
Normal file
156
src/components/workspace/FilePreviewPanel.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { FiX, FiDownload } from 'react-icons/fi';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
* 文件预览面板组件
|
||||
* 显示在侧边栏右侧,不打断用户操作
|
||||
*/
|
||||
function FilePreviewPanel({ file, onClose }) {
|
||||
if (!file) return null;
|
||||
|
||||
const icon = api.workspace.getFileIcon(file.fileType);
|
||||
|
||||
// 获取文件类型描述
|
||||
const getTypeDesc = (fileType) => {
|
||||
const typeMap = {
|
||||
text: '文本文件',
|
||||
markdown: 'Markdown 文件',
|
||||
json: 'JSON 文件',
|
||||
javascript: 'JavaScript 文件',
|
||||
python: 'Python 文件',
|
||||
css: 'CSS 文件',
|
||||
html: 'HTML 文件',
|
||||
word: 'Word 文档',
|
||||
excel: 'Excel 文档',
|
||||
powerpoint: 'PowerPoint 文档',
|
||||
image: '图片文件',
|
||||
video: '视频文件',
|
||||
audio: '音频文件',
|
||||
pdf: 'PDF 文件',
|
||||
};
|
||||
return typeMap[fileType] || '文件';
|
||||
};
|
||||
|
||||
// 渲染文件信息
|
||||
const renderFileInfo = () => (
|
||||
<div className="file-preview-panel__info">
|
||||
<div className="file-preview-panel__info-item">
|
||||
<span className="label">类型:</span>
|
||||
<span className="value">{getTypeDesc(file.fileType)}</span>
|
||||
</div>
|
||||
<div className="file-preview-panel__info-item">
|
||||
<span className="label">大小:</span>
|
||||
<span className="value">{file.size}</span>
|
||||
</div>
|
||||
{file.dimensions && (
|
||||
<div className="file-preview-panel__info-item">
|
||||
<span className="label">尺寸:</span>
|
||||
<span className="value">{file.dimensions}</span>
|
||||
</div>
|
||||
)}
|
||||
{file.duration && (
|
||||
<div className="file-preview-panel__info-item">
|
||||
<span className="label">时长:</span>
|
||||
<span className="value">{file.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="file-preview-panel__info-item">
|
||||
<span className="label">修改:</span>
|
||||
<span className="value">{file.modifiedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染预览内容
|
||||
const renderPreview = () => {
|
||||
// 文本类型:显示内容
|
||||
if (['text', 'markdown', 'json', 'javascript', 'python', 'css', 'html'].includes(file.fileType)) {
|
||||
return (
|
||||
<div className="file-preview-panel__content">
|
||||
<pre className="file-preview-panel__code">{file.content || '(无内容)'}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Office 类型:显示占位符
|
||||
if (['word', 'excel', 'powerpoint'].includes(file.fileType)) {
|
||||
return (
|
||||
<div className="file-preview-panel__placeholder">
|
||||
<div className="file-preview-panel__placeholder-icon">{icon}</div>
|
||||
<div className="file-preview-panel__placeholder-text">
|
||||
点击下载查看完整内容
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 图片类型:显示占位符
|
||||
if (file.fileType === 'image') {
|
||||
return (
|
||||
<div className="file-preview-panel__placeholder">
|
||||
<div className="file-preview-panel__placeholder-icon">{icon}</div>
|
||||
<div className="file-preview-panel__placeholder-text">
|
||||
图片预览区域
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 视频/音频类型:显示占位符
|
||||
if (['video', 'audio'].includes(file.fileType)) {
|
||||
const text = file.fileType === 'video' ? '点击下载播放视频' : '点击下载播放音频';
|
||||
return (
|
||||
<div className="file-preview-panel__placeholder">
|
||||
<div className="file-preview-panel__placeholder-icon">{icon}</div>
|
||||
<div className="file-preview-panel__placeholder-text">{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他类型
|
||||
return (
|
||||
<div className="file-preview-panel__placeholder">
|
||||
<div className="file-preview-panel__placeholder-icon">{icon}</div>
|
||||
<div className="file-preview-panel__placeholder-text">
|
||||
预览不可用
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 下载操作
|
||||
const handleDownload = () => {
|
||||
alert('文件下载中...');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="file-preview-panel">
|
||||
{/* Header - 文件名 + 关闭按钮 */}
|
||||
<div className="file-preview-panel__header">
|
||||
<div className="file-preview-panel__icon">{icon}</div>
|
||||
<div className="file-preview-panel__name">{file.name}</div>
|
||||
<div className="file-preview-panel__close" onClick={onClose}>
|
||||
<FiX size={16} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info - 文件信息 */}
|
||||
{renderFileInfo()}
|
||||
|
||||
{/* Content - 文件内容预览 */}
|
||||
<div className="file-preview-panel__content-wrapper">
|
||||
{renderPreview()}
|
||||
</div>
|
||||
|
||||
{/* Actions - 操作按钮 */}
|
||||
<div className="file-preview-panel__actions">
|
||||
<button className="btn" onClick={handleDownload}>
|
||||
<FiDownload />
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilePreviewPanel;
|
||||
39
src/components/workspace/FileTree.jsx
Normal file
39
src/components/workspace/FileTree.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import FileTreeItem from './FileTreeItem.jsx';
|
||||
import { FiFolder } from 'react-icons/fi';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
* 文件树组件
|
||||
* 渲染工作空间的文件和文件夹结构
|
||||
*/
|
||||
function FileTree({ onFileClick, onMenuClick }) {
|
||||
const files = api.workspace.getFiles();
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return (
|
||||
<div className="file-tree">
|
||||
<div className="file-tree__empty">
|
||||
<div className="file-tree__empty-icon">
|
||||
<FiFolder size={48} />
|
||||
</div>
|
||||
<div className="file-tree__empty-text">工作空间为空</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{files.map(item => (
|
||||
<FileTreeItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onFileClick={onFileClick}
|
||||
onMenuClick={onMenuClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileTree;
|
||||
134
src/components/workspace/FileTreeItem.jsx
Normal file
134
src/components/workspace/FileTreeItem.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { FiChevronRight, FiFolder, FiFile, FiFileText, FiCode, FiImage, FiVideo, FiMusic } from 'react-icons/fi';
|
||||
import { FaFileWord, FaFileExcel, FaFilePowerpoint, FaFilePdf, FaFileArchive } from 'react-icons/fa';
|
||||
|
||||
/**
|
||||
* 文件树项组件
|
||||
* 渲染单个文件或文件夹项
|
||||
*/
|
||||
function FileTreeItem({ item, onFileClick, onMenuClick }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const isFolder = item.type === 'folder';
|
||||
|
||||
// 根据文件类型获取图标组件
|
||||
const getIconComponent = () => {
|
||||
if (isFolder) return <FiFolder size={16} />;
|
||||
|
||||
const fileType = item.fileType;
|
||||
switch (fileType) {
|
||||
case 'text':
|
||||
case 'markdown':
|
||||
return <FiFileText size={16} />;
|
||||
case 'json':
|
||||
case 'javascript':
|
||||
case 'python':
|
||||
case 'css':
|
||||
case 'html':
|
||||
return <FiCode size={16} />;
|
||||
case 'word':
|
||||
return <FaFileWord size={16} />;
|
||||
case 'excel':
|
||||
return <FaFileExcel size={16} />;
|
||||
case 'powerpoint':
|
||||
return <FaFilePowerpoint size={16} />;
|
||||
case 'image':
|
||||
return <FiImage size={16} />;
|
||||
case 'video':
|
||||
return <FiVideo size={16} />;
|
||||
case 'audio':
|
||||
return <FiMusic size={16} />;
|
||||
case 'pdf':
|
||||
return <FaFilePdf size={16} />;
|
||||
case 'archive':
|
||||
return <FaFileArchive size={16} />;
|
||||
default:
|
||||
return <FiFile size={16} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化修改时间 (2026-04-09 -> 04-09)
|
||||
const formatTime = (time) => {
|
||||
if (!time) return '';
|
||||
const parts = time.split('-');
|
||||
return `${parts[1]}-${parts[2]}`;
|
||||
};
|
||||
|
||||
// 点击文件夹:展开/折叠
|
||||
const handleFolderClick = () => {
|
||||
if (isFolder) {
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击文件:打开预览
|
||||
const handleFileClick = () => {
|
||||
if (!isFolder && onFileClick) {
|
||||
onFileClick(item);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击操作按钮
|
||||
const handleMenuClick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onMenuClick) {
|
||||
onMenuClick(item, e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="file-tree__node">
|
||||
<div
|
||||
className={`file-tree__item file-tree__item--${item.type}`}
|
||||
onClick={isFolder ? handleFolderClick : handleFileClick}
|
||||
>
|
||||
{/* 展开/折叠箭头 */}
|
||||
{isFolder && (
|
||||
<span className={`file-tree__toggle ${isExpanded ? 'file-tree__toggle--expanded' : ''}`}>
|
||||
<FiChevronRight size={14} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className="file-tree__icon">{getIconComponent()}</span>
|
||||
|
||||
{/* 名称 */}
|
||||
<span className="file-tree__name">{item.name}</span>
|
||||
|
||||
{/* 文件大小 */}
|
||||
{!isFolder && item.size && (
|
||||
<span className="file-tree__size">{item.size}</span>
|
||||
)}
|
||||
|
||||
{/* 修改时间 */}
|
||||
{!isFolder && item.modifiedAt && (
|
||||
<span className="file-tree__time">{formatTime(item.modifiedAt)}</span>
|
||||
)}
|
||||
|
||||
{/* 操作按钮(文件夹和文件都显示) */}
|
||||
<span
|
||||
className="file-tree__actions"
|
||||
onClick={handleMenuClick}
|
||||
>
|
||||
⋯
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 子项(文件夹展开时显示) */}
|
||||
{isFolder && isExpanded && item.children && item.children.length > 0 && (
|
||||
<div className="file-tree__children">
|
||||
{item.children.map(child => (
|
||||
<FileTreeItem
|
||||
key={child.id}
|
||||
item={child}
|
||||
onFileClick={onFileClick}
|
||||
onMenuClick={onMenuClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileTreeItem;
|
||||
50
src/components/workspace/RenameModal.jsx
Normal file
50
src/components/workspace/RenameModal.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from '../common/Modal.jsx';
|
||||
|
||||
/**
|
||||
* 重命名弹框组件
|
||||
*/
|
||||
function RenameModal({ file, onConfirm, onCancel }) {
|
||||
const [newName, setNewName] = useState(file ? file.name : '');
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (newName.trim()) {
|
||||
onConfirm && onConfirm(newName.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
title="重命名"
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={onCancel}
|
||||
confirmText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--color-text-2)' }}>
|
||||
新名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--color-border-2)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameModal;
|
||||
38
src/components/workspace/UploadButton.jsx
Normal file
38
src/components/workspace/UploadButton.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 上传文件组件
|
||||
* 使用原生 input[type="file"]
|
||||
*/
|
||||
function UploadButton({ onUpload }) {
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const handleClick = () => {
|
||||
inputRef.current && inputRef.current.click();
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
onUpload && onUpload(file);
|
||||
// 重置 input,允许重复上传同一文件
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
|
||||
上传文件
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UploadButton;
|
||||
555
src/components/workspace/WorkspaceSidebar.jsx
Normal file
555
src/components/workspace/WorkspaceSidebar.jsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { FiX, FiFolder, FiRefreshCw, FiPlus, FiFilePlus, FiFolderPlus, FiUpload } from 'react-icons/fi';
|
||||
import FileTree from './FileTree.jsx';
|
||||
import FileContextMenu from './FileContextMenu.jsx';
|
||||
import FilePreviewPanel from './FilePreviewPanel.jsx';
|
||||
import RenameModal from './RenameModal.jsx';
|
||||
import CreateModal from './CreateModal.jsx';
|
||||
import FileDetailModal from './FileDetailModal.jsx';
|
||||
import Modal from '../common/Modal.jsx';
|
||||
import Toast from '../common/Toast.jsx';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
/**
|
||||
* 工作空间侧边栏组件
|
||||
* 支持双拖动调整:整体宽度和文件树宽度
|
||||
*/
|
||||
function WorkspaceSidebar({ isOpen, onClose }) {
|
||||
const [contextMenu, setContextMenu] = useState(null); // { file, position }
|
||||
const [previewFile, setPreviewFile] = useState(null);
|
||||
const [deleteFile, setDeleteFile] = useState(null);
|
||||
const [renameFile, setRenameFile] = useState(null);
|
||||
const [createType, setCreateType] = useState(null); // 'file' | 'folder'
|
||||
const [detailFile, setDetailFile] = useState(null);
|
||||
const [moveFile, setMoveFile] = useState(null);
|
||||
const [moveTarget, setMoveTarget] = useState(null); // 选中的目标文件夹
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [toast, setToast] = useState(null); // { type, message }
|
||||
|
||||
// 宽度状态
|
||||
const [sidebarWidth, setSidebarWidth] = useState(500); // 侧边栏整体宽度
|
||||
const [fileTreeWidth, setFileTreeWidth] = useState(200); // 文件树宽度
|
||||
|
||||
// Refs
|
||||
const sidebarRef = useRef(null);
|
||||
const isResizingSidebarRef = useRef(false); // 是否正在调整侧边栏宽度
|
||||
const isResizingTreeRef = useRef(false); // 是否正在调整文件树宽度
|
||||
const lastXRef = useRef(0);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// ========== 拖动调整:侧边栏整体宽度 ==========
|
||||
const handleSidebarMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
isResizingSidebarRef.current = true;
|
||||
lastXRef.current = e.clientX;
|
||||
|
||||
document.body.style.cursor = 'ew-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.style.transition = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 拖动调整:文件树宽度 ==========
|
||||
const handleDividerMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // 防止触发侧边栏拖动
|
||||
isResizingTreeRef.current = true;
|
||||
lastXRef.current = e.clientX;
|
||||
|
||||
document.body.style.cursor = 'ew-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
// ========== 统一的鼠标移动和释放处理 ==========
|
||||
useEffect(() => {
|
||||
let animationFrameId = null;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isResizingSidebarRef.current && !isResizingTreeRef.current) return;
|
||||
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(() => {
|
||||
const deltaX = e.clientX - lastXRef.current;
|
||||
lastXRef.current = e.clientX;
|
||||
|
||||
// 调整侧边栏宽度(向左拖动减小,向右拖动增加)
|
||||
if (isResizingSidebarRef.current) {
|
||||
setSidebarWidth(prev => {
|
||||
const newWidth = prev - deltaX; // 注意:向左拖动是负数
|
||||
const maxWidth = Math.min(1200, window.innerWidth - 480);
|
||||
return Math.max(400, Math.min(maxWidth, newWidth));
|
||||
});
|
||||
}
|
||||
|
||||
// 调整文件树宽度(向右拖动增加,向左拖动减小)
|
||||
if (isResizingTreeRef.current) {
|
||||
setFileTreeWidth(prev => {
|
||||
const newWidth = prev + deltaX;
|
||||
const minWidth = 300;
|
||||
const maxWidth = previewFile ? sidebarWidth - 32 : sidebarWidth;
|
||||
return Math.max(minWidth, Math.min(maxWidth, newWidth));
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isResizingSidebarRef.current && !isResizingTreeRef.current) return;
|
||||
|
||||
isResizingSidebarRef.current = false;
|
||||
isResizingTreeRef.current = false;
|
||||
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.style.transition = '';
|
||||
}
|
||||
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 点击外部关闭右键菜单和下拉菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
// 关闭右键菜单
|
||||
if (contextMenu) {
|
||||
setContextMenu(null);
|
||||
}
|
||||
// 关闭下拉菜单
|
||||
if (showDropdown && dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, [contextMenu, showDropdown]);
|
||||
|
||||
// 文件点击:打开预览
|
||||
const handleFileClick = (file) => {
|
||||
// 不支持预览的文件类型直接提示
|
||||
const unpreviewableTypes = ['archive', 'unknown'];
|
||||
if (unpreviewableTypes.includes(file.fileType)) {
|
||||
showToast('该文件类型不支持预览', 'warning');
|
||||
return;
|
||||
}
|
||||
setPreviewFile(file);
|
||||
};
|
||||
|
||||
// 右键菜单点击
|
||||
const handleMenuClick = (file, e) => {
|
||||
// 如果点击的是同一个文件,且菜单已经显示,则关闭菜单(toggle)
|
||||
if (contextMenu && contextMenu.file.id === file.id) {
|
||||
setContextMenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
// 菜单显示在按钮左侧,避免超出页面
|
||||
const menuWidth = 160; // 菜单宽度
|
||||
const x = rect.right - menuWidth;
|
||||
const y = rect.bottom + 4;
|
||||
|
||||
setContextMenu({
|
||||
file,
|
||||
position: {
|
||||
x: Math.max(8, x), // 确保不超出左边界
|
||||
y,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 显示 Toast 提示
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToast({ type, message });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
// 下载操作
|
||||
const handleDownload = (file) => {
|
||||
showToast('文件下载中...', 'info');
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// 重命名操作
|
||||
const handleRename = (file) => {
|
||||
setRenameFile(file);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleRenameConfirm = (newName) => {
|
||||
showToast('重命名成功');
|
||||
setRenameFile(null);
|
||||
};
|
||||
|
||||
// 删除操作
|
||||
const handleDelete = (file) => {
|
||||
setDeleteFile(file);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
showToast('文件已删除');
|
||||
setDeleteFile(null);
|
||||
};
|
||||
|
||||
// 新建文件/文件夹
|
||||
const handleNewFile = () => {
|
||||
setCreateType('file');
|
||||
setShowDropdown(false);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleNewFolder = () => {
|
||||
setCreateType('folder');
|
||||
setShowDropdown(false);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleCreateConfirm = (name, type) => {
|
||||
const message = type === 'file' ? '文件创建成功' : '文件夹创建成功';
|
||||
showToast(message);
|
||||
setCreateType(null);
|
||||
};
|
||||
|
||||
// 上传文件
|
||||
const handleUpload = (file) => {
|
||||
showToast('文件上传成功');
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
// 触发文件选择
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.onchange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
handleUpload(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
setShowDropdown(false);
|
||||
};
|
||||
|
||||
// 移动文件
|
||||
const handleMove = (file) => {
|
||||
setMoveFile(file);
|
||||
setMoveTarget(null);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleMoveConfirm = () => {
|
||||
if (moveTarget) {
|
||||
showToast(`文件已移动到 ${moveTarget}`);
|
||||
setMoveFile(null);
|
||||
setMoveTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCancel = () => {
|
||||
setMoveFile(null);
|
||||
setMoveTarget(null);
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleDetail = (file) => {
|
||||
setDetailFile(file);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
setIsRefreshing(true);
|
||||
setTimeout(() => {
|
||||
setIsRefreshing(false);
|
||||
showToast('文件列表已刷新');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 下拉菜单切换
|
||||
const handleDropdownToggle = (e) => {
|
||||
e.stopPropagation();
|
||||
setShowDropdown(!showDropdown);
|
||||
};
|
||||
|
||||
// 获取所有文件夹(树形结构,用于移动文件)
|
||||
const getFolderTree = () => {
|
||||
const files = api.workspace.getFiles();
|
||||
const extractFolders = (items) => {
|
||||
return items
|
||||
.filter(item => item.type === 'folder')
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
children: item.children ? extractFolders(item.children) : [],
|
||||
}));
|
||||
};
|
||||
return extractFolders(files);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const folderTree = getFolderTree();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="workspace-sidebar"
|
||||
ref={sidebarRef}
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<div className="workspace-sidebar__header">
|
||||
<div className="workspace-sidebar__title">工作空间</div>
|
||||
<div className="workspace-sidebar__actions">
|
||||
<div
|
||||
className={`workspace-sidebar__action-btn ${isRefreshing ? 'workspace-sidebar__refresh--rotating' : ''}`}
|
||||
onClick={handleRefresh}
|
||||
title="刷新"
|
||||
>
|
||||
<FiRefreshCw size={16} />
|
||||
</div>
|
||||
<div style={{ position: 'relative' }} ref={dropdownRef}>
|
||||
<div
|
||||
className="workspace-sidebar__action-btn"
|
||||
onClick={handleDropdownToggle}
|
||||
title="新建"
|
||||
>
|
||||
<FiPlus size={16} />
|
||||
</div>
|
||||
{showDropdown && (
|
||||
<div className="workspace-sidebar__dropdown">
|
||||
<div className="workspace-sidebar__dropdown__item" onClick={handleNewFile}>
|
||||
<span className="workspace-sidebar__dropdown__icon">
|
||||
<FiFilePlus size={14} />
|
||||
</span>
|
||||
新建文件
|
||||
</div>
|
||||
<div className="workspace-sidebar__dropdown__item" onClick={handleNewFolder}>
|
||||
<span className="workspace-sidebar__dropdown__icon">
|
||||
<FiFolderPlus size={14} />
|
||||
</span>
|
||||
新建文件夹
|
||||
</div>
|
||||
<div className="workspace-sidebar__dropdown__item" onClick={handleUploadClick}>
|
||||
<span className="workspace-sidebar__dropdown__icon">
|
||||
<FiUpload size={14} />
|
||||
</span>
|
||||
上传文件
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-sidebar__close" onClick={onClose}>
|
||||
<FiX size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - 左右分栏 */}
|
||||
<div className="workspace-sidebar__content">
|
||||
{/* FileTree */}
|
||||
<div
|
||||
className="workspace-sidebar__file-tree"
|
||||
style={{
|
||||
width: previewFile ? `${fileTreeWidth}px` : 'auto',
|
||||
flex: previewFile ? 'none' : '1'
|
||||
}}
|
||||
>
|
||||
<FileTree
|
||||
onFileClick={handleFileClick}
|
||||
onMenuClick={handleMenuClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分隔线(可拖动调整) */}
|
||||
{previewFile && (
|
||||
<div
|
||||
className="workspace-sidebar__divider"
|
||||
onMouseDown={handleDividerMouseDown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Preview Panel */}
|
||||
{previewFile && (
|
||||
<div className="workspace-sidebar__preview">
|
||||
<FilePreviewPanel
|
||||
file={previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 外部拖动手柄(调整侧边栏整体宽度) */}
|
||||
<div
|
||||
className="workspace-sidebar__resize-handle"
|
||||
onMouseDown={handleSidebarMouseDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<FileContextMenu
|
||||
file={contextMenu.file}
|
||||
position={contextMenu.position}
|
||||
onDownload={handleDownload}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
onDetail={handleDetail}
|
||||
onMove={handleMove}
|
||||
onNewFile={handleNewFile}
|
||||
onNewFolder={handleNewFolder}
|
||||
onUpload={handleUploadClick}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{deleteFile && (
|
||||
<Modal
|
||||
visible={true}
|
||||
title="确认删除"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteFile(null)}
|
||||
>
|
||||
确定要删除文件「{deleteFile.name}」吗?
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 重命名弹框 */}
|
||||
{renameFile && (
|
||||
<RenameModal
|
||||
file={renameFile}
|
||||
onConfirm={handleRenameConfirm}
|
||||
onCancel={() => setRenameFile(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 新建弹框 */}
|
||||
{createType && (
|
||||
<CreateModal
|
||||
type={createType}
|
||||
onConfirm={handleCreateConfirm}
|
||||
onCancel={() => setCreateType(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文件详情弹框 */}
|
||||
{detailFile && (
|
||||
<FileDetailModal
|
||||
file={detailFile}
|
||||
onClose={() => setDetailFile(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 移动文件弹框 */}
|
||||
{moveFile && (
|
||||
<div className="modal-overlay" onClick={handleMoveCancel}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ width: '400px' }}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">移动到</div>
|
||||
<div className="modal-close" onClick={handleMoveCancel}>
|
||||
<FiX size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<MoveFolderTree
|
||||
folders={folderTree}
|
||||
selected={moveTarget}
|
||||
onSelect={setMoveTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn" onClick={handleMoveCancel}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleMoveConfirm} disabled={!moveTarget}>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast 提示 */}
|
||||
<Toast
|
||||
visible={!!toast}
|
||||
type={toast?.type}
|
||||
message={toast?.message}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动文件弹框中的文件夹树组件
|
||||
*/
|
||||
function MoveFolderTree({ folders, selected, onSelect, depth = 0 }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
{folders.map(folder => (
|
||||
<div key={folder.id}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
paddingLeft: `${12 + depth * 20}px`,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '14px',
|
||||
transition: 'background 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: selected === folder.name ? 'var(--color-bg-2)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (selected !== folder.name) e.currentTarget.style.background = 'var(--color-bg-2)'; }}
|
||||
onMouseLeave={(e) => { if (selected !== folder.name) e.currentTarget.style.background = 'transparent'; }}
|
||||
onClick={() => onSelect(folder.name)}
|
||||
>
|
||||
<FiFolder size={14} style={{ flexShrink: 0 }} />
|
||||
{folder.name}
|
||||
</div>
|
||||
{folder.children.length > 0 && (
|
||||
<MoveFolderTree
|
||||
folders={folder.children}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作空间展开按钮组件
|
||||
*/
|
||||
export function WorkspaceToggleBtn({ onClick }) {
|
||||
return (
|
||||
<div className="workspace-toggle-btn" onClick={onClick} title="工作空间">
|
||||
<FiFolder size={18} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspaceSidebar;
|
||||
@@ -1,56 +0,0 @@
|
||||
// 页面配置常量
|
||||
|
||||
/**
|
||||
* 工作台页面配置
|
||||
*/
|
||||
export const CONSOLE_PAGES = {
|
||||
chat: { title: '智能助手', icon: 'FiMessageSquare' },
|
||||
skills: { title: '技能市场', icon: 'FaPuzzlePiece' },
|
||||
skillDetail: { title: '技能详情', icon: null },
|
||||
logs: { title: '日志查询', icon: 'FiList' },
|
||||
scheduledTasks: { title: '定时任务', icon: 'FiClock' },
|
||||
taskDetail: { title: '任务详情', icon: null },
|
||||
account: { title: '账号管理', icon: 'FiUser' },
|
||||
projects: { title: '项目管理', icon: 'FiUsers' },
|
||||
memberConfig: { title: '成员配置', icon: null },
|
||||
addMember: { title: '增加成员', icon: null },
|
||||
};
|
||||
|
||||
/**
|
||||
* 管理台页面配置
|
||||
*/
|
||||
export const ADMIN_PAGES = {
|
||||
overview: { title: '总览', icon: 'FiHome' },
|
||||
departments: { title: '部门管理', icon: 'FiBarChart2' },
|
||||
users: { title: '用户管理', icon: 'FiUsers' },
|
||||
projects: { title: '项目管理', icon: 'FiList' },
|
||||
adminLogs: { title: '日志查询', icon: 'FiActivity' },
|
||||
reviewList: { title: '审核管理', icon: 'FiCheckCircle' },
|
||||
reviewDetail: { title: '审核详情', icon: null },
|
||||
addDepartment: { title: '新增部门', icon: null },
|
||||
addUser: { title: '新增用户', icon: null },
|
||||
addProject: { title: '新增项目', icon: null },
|
||||
};
|
||||
|
||||
/**
|
||||
* 开发台页面配置
|
||||
*/
|
||||
export const DEVELOPER_PAGES = {
|
||||
overview: { title: '总览', icon: 'FiHome' },
|
||||
mySkills: { title: '我的技能', icon: 'FaPuzzlePiece' },
|
||||
uploadSkill: { title: '创建技能', icon: 'FiPlus' },
|
||||
newVersion: { title: '上传新版本', icon: null },
|
||||
devDocs: { title: '开发文档', icon: 'FiTerminal' },
|
||||
devAccount: { title: '开发者设置', icon: 'FiSettings' },
|
||||
skillEditor: { title: '技能详情', icon: null },
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取页面标题
|
||||
* @param {string} pageId - 页面ID
|
||||
* @param {Object} pagesConfig - 页面配置对象
|
||||
* @returns {string} 页面标题
|
||||
*/
|
||||
export function getPageTitle(pageId, pagesConfig) {
|
||||
return pagesConfig[pageId]?.title || '';
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// localStorage 键名常量
|
||||
|
||||
/**
|
||||
* 工作台相关键名
|
||||
*/
|
||||
export const CONSOLE_KEYS = {
|
||||
CURRENT_PAGE: 'console_currentPage',
|
||||
CURRENT_SCENE: 'console_currentScene',
|
||||
};
|
||||
|
||||
/**
|
||||
* 管理台相关键名
|
||||
*/
|
||||
export const ADMIN_KEYS = {
|
||||
CURRENT_PAGE: 'admin_currentPage',
|
||||
};
|
||||
|
||||
/**
|
||||
* 开发台相关键名
|
||||
*/
|
||||
export const DEVELOPER_KEYS = {
|
||||
CURRENT_PAGE: 'developer_currentPage',
|
||||
CURRENT_SKILL_ID: 'developer_currentSkillId',
|
||||
};
|
||||
@@ -52,6 +52,8 @@ export const adminOverview = {
|
||||
};
|
||||
|
||||
export const adminLogs = [
|
||||
{ time: '2026-03-26 15:30:22', user: '管理员', department: '系统管理部', type: '配置变更', action: '切换默认模型配置', status: '成功', detail: '从"智算平台生产环境"切换至"阿里云百炼主账号"' },
|
||||
{ time: '2026-03-26 10:15:33', user: '张三', department: 'AI 产品部', type: '配置变更', action: '新增模型配置', status: '成功', detail: '新增配置"智算平台测试环境"' },
|
||||
{ time: '2026-03-19 16:42:33', user: '张三', department: 'AI 产品部', type: '实例操作', action: '启动实例', status: '成功', detail: '实例 teleclaw-zhangsan 启动成功' },
|
||||
{ time: '2026-03-19 15:28:17', user: '张三', department: 'AI 产品部', type: '技能', action: '调用 代码生成助手', status: '成功', detail: 'Token 消耗: 1,234' },
|
||||
{ time: '2026-03-19 14:55:02', user: '李四', department: '技术研发部', type: '文件上传', action: '上传数据文件', status: '成功', detail: '文件 sales_2026_q1.xlsx 上传完成' },
|
||||
@@ -86,3 +88,65 @@ export const availableDepartments = [
|
||||
{ id: 5, name: '测试部', description: '负责产品质量保障', head: '周九', memberCount: 5 },
|
||||
{ id: 6, name: '客户服务部', description: '负责客户支持与服务', head: '吴十', memberCount: 12 }
|
||||
];
|
||||
|
||||
// 模型配置数据
|
||||
export const modelConfigs = [
|
||||
{
|
||||
id: 'cfg_001',
|
||||
name: '阿里云百炼主账号',
|
||||
type: 'basic',
|
||||
isActive: true,
|
||||
createdAt: '2026-03-20T10:30:00',
|
||||
updatedAt: '2026-03-26T14:30:00',
|
||||
basic: {
|
||||
apiUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
apiKey: 'sk-ds-abc123xyz789',
|
||||
modelName: 'qwen-max',
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096,
|
||||
topP: 0.9
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'cfg_002',
|
||||
name: '智算平台生产环境',
|
||||
type: 'zhisuan',
|
||||
isActive: false,
|
||||
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: 'cfg_003',
|
||||
name: 'DeepSeek 备用',
|
||||
type: 'basic',
|
||||
isActive: false,
|
||||
createdAt: '2026-03-24T16:00:00',
|
||||
updatedAt: '2026-03-24T16:00:00',
|
||||
basic: {
|
||||
apiUrl: 'https://api.deepseek.com/v1',
|
||||
apiKey: 'sk-ds-deepseek456',
|
||||
modelName: 'deepseek-chat',
|
||||
temperature: 0.5,
|
||||
maxTokens: 8192,
|
||||
topP: 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'cfg_004',
|
||||
name: '智算平台测试环境',
|
||||
type: 'zhisuan',
|
||||
isActive: false,
|
||||
createdAt: '2026-03-23T11:00:00',
|
||||
updatedAt: '2026-03-23T11:00:00',
|
||||
zhisuan: {
|
||||
apiUrl: 'https://zhisuan-test.internal.company.com/api/v1',
|
||||
appId: 'app_test_001',
|
||||
appSecret: 'secret_test_abc789xyz'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
93
src/data/configTypes.js
Normal file
93
src/data/configTypes.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 模型配置类型注册表
|
||||
* 定义支持的配置类型及其字段元数据
|
||||
*/
|
||||
|
||||
export const MODEL_CONFIG_TYPES = {
|
||||
basic: {
|
||||
key: 'basic',
|
||||
label: 'OpenAI 兼容接口',
|
||||
description: '支持标准的 OpenAI API 格式',
|
||||
fields: [
|
||||
{ key: 'apiUrl', label: 'API 地址', type: 'url', required: true },
|
||||
{ key: 'apiKey', label: 'API 密钥', type: 'password', required: true },
|
||||
{ key: 'modelName', label: '模型名称', type: 'text', required: true },
|
||||
{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 2, step: 0.1, default: 0.7 },
|
||||
{ key: 'maxTokens', label: 'Max Tokens', type: 'number', min: 1, max: 128000, step: 1, default: 4096 },
|
||||
{ key: 'topP', label: 'Top P', type: 'number', min: 0, max: 1, step: 0.1, default: 0.9 },
|
||||
]
|
||||
},
|
||||
zhisuan: {
|
||||
key: 'zhisuan',
|
||||
label: '智算管理平台',
|
||||
description: '内部智算平台接入',
|
||||
fields: [
|
||||
{ key: 'apiUrl', label: 'API 地址', type: 'url', required: true },
|
||||
{ key: 'appId', label: 'App ID', type: 'text', required: true },
|
||||
{ key: 'appSecret', label: 'App Secret', type: 'password', required: true },
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有配置类型列表
|
||||
* @returns {Array} 配置类型列表
|
||||
*/
|
||||
export const getConfigTypeList = () => {
|
||||
return Object.values(MODEL_CONFIG_TYPES).map(type => ({
|
||||
key: type.key,
|
||||
label: type.label,
|
||||
description: type.description
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 key 获取配置类型定义
|
||||
* @param {string} key - 配置类型 key
|
||||
* @returns {Object|undefined} 配置类型定义
|
||||
*/
|
||||
export const getConfigTypeByKey = (key) => {
|
||||
return MODEL_CONFIG_TYPES[key];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取配置类型的字段定义
|
||||
* @param {string} key - 配置类型 key
|
||||
* @returns {Array} 字段定义数组
|
||||
*/
|
||||
export const getConfigFields = (key) => {
|
||||
const configType = MODEL_CONFIG_TYPES[key];
|
||||
return configType ? configType.fields : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成配置摘要信息
|
||||
* @param {Object} config - 配置对象
|
||||
* @returns {string} 摘要字符串
|
||||
*/
|
||||
export const getConfigSummary = (config) => {
|
||||
if (!config) return '-';
|
||||
|
||||
const type = MODEL_CONFIG_TYPES[config.type];
|
||||
if (!type) return '-';
|
||||
|
||||
if (config.type === 'basic') {
|
||||
return config.basic?.modelName || '-';
|
||||
}
|
||||
|
||||
if (config.type === 'zhisuan') {
|
||||
return '智算平台接入';
|
||||
}
|
||||
|
||||
return '-';
|
||||
};
|
||||
|
||||
/**
|
||||
* 掩码显示敏感信息
|
||||
* @param {string} value - 原始值
|
||||
* @returns {string} 掩码后的值
|
||||
*/
|
||||
export const maskSensitiveValue = (value) => {
|
||||
if (!value || value.length < 8) return '****';
|
||||
return value.substring(0, 4) + '****' + value.substring(value.length - 4);
|
||||
};
|
||||
@@ -4,7 +4,6 @@ export const conversations = [
|
||||
{ id: 'welcome', title: '新对话', time: '欢迎页', scene: 'welcome', status: 'running' },
|
||||
{ id: 'text', title: '代码重构方案讨论', time: '普通对话', scene: 'text', status: 'running' },
|
||||
{ id: 'skill', title: '查询客户数据', time: '调用 Skill', scene: 'skill', status: 'running' },
|
||||
{ id: 'file', title: '分析上传的报表', time: '上传文件', scene: 'file', status: 'running' },
|
||||
{ id: 'code', title: '生成 Python 函数', time: '代码展示', scene: 'code', status: 'running' },
|
||||
{ id: 'table', title: '查询销售报表', time: '表格数据', scene: 'table', status: 'running' },
|
||||
{ id: 'multiTurn', title: '产品方案讨论', time: '多轮对话', scene: 'multiTurn', status: 'running' },
|
||||
@@ -74,12 +73,13 @@ export function getChatScenes() {
|
||||
<div class="message assistant">
|
||||
<div class="message-avatar assistant">🤖</div>
|
||||
<div class="message-content">
|
||||
<div class="message-thinking">
|
||||
<div class="message-thinking-header">
|
||||
<span class="message-thinking-icon">▶</span>
|
||||
<span>已深度思考</span>
|
||||
<div class="message-block expanded">
|
||||
<div class="message-block-header">
|
||||
<span class="message-block-header-icon">▶</span>
|
||||
<span class="message-block-header-title">已深度思考</span>
|
||||
<span class="message-block-header-status">2.1s</span>
|
||||
</div>
|
||||
<div class="message-thinking-content">
|
||||
<div class="message-block-content">
|
||||
<p>让我分析一下代码重构的思路:</p>
|
||||
<ul>
|
||||
<li>首先识别代码中的重复模式和冗余逻辑</li>
|
||||
@@ -90,6 +90,7 @@ export function getChatScenes() {
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-bubble">
|
||||
<p>好的,我来帮你重构这段代码。首先让我分析一下现有代码的问题,然后提供优化方案。</p>
|
||||
<p style="margin-top: 12px;"><strong>优化建议:</strong></p>
|
||||
@@ -114,23 +115,13 @@ export function getChatScenes() {
|
||||
<div class="message assistant">
|
||||
<div class="message-avatar assistant">🤖</div>
|
||||
<div class="message-content">
|
||||
<div class="message-bubble">
|
||||
<p style="display: flex; align-items: center; gap: 8px; color: #3B82F6;">
|
||||
🧩 已加载CRM客户查询技能
|
||||
</p>
|
||||
</div>
|
||||
<div class="message-time">10:15</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message assistant">
|
||||
<div class="message-avatar assistant">🤖</div>
|
||||
<div class="message-content">
|
||||
<div class="message-thinking">
|
||||
<div class="message-thinking-header">
|
||||
<span class="message-thinking-icon">▶</span>
|
||||
<span>已深度思考</span>
|
||||
<div class="message-block expanded">
|
||||
<div class="message-block-header">
|
||||
<span class="message-block-header-icon">▶</span>
|
||||
<span class="message-block-header-title">已深度思考</span>
|
||||
<span class="message-block-header-status">1.8s</span>
|
||||
</div>
|
||||
<div class="message-thinking-content">
|
||||
<div class="message-block-content">
|
||||
<p>正在调用CRM客户查询技能...</p>
|
||||
<ul>
|
||||
<li>识别用户意图:查询客户"张三"的订单信息</li>
|
||||
@@ -141,6 +132,56 @@ export function getChatScenes() {
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-block message-tool">
|
||||
<div class="message-block-header">
|
||||
<span class="message-block-header-icon">▶</span>
|
||||
<span class="message-block-header-title">🧩 使用工具: CRM客户查询</span>
|
||||
<span class="message-block-header-status message-tool-completed">✓ 0.4s</span>
|
||||
</div>
|
||||
<div class="message-block-content">
|
||||
<div class="message-tool-io">
|
||||
<div class="message-tool-io-label">INPUT</div>
|
||||
<div class="message-tool-io-content"><pre>{ "name": "张三", "timeRange": "1year" }</pre></div>
|
||||
</div>
|
||||
<div class="message-tool-io">
|
||||
<div class="message-tool-io-label">OUTPUT</div>
|
||||
<div class="message-tool-io-content"><pre>{ "customerId": "C001", "orders": 3, "totalAmount": 28560 }</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-block message-tool">
|
||||
<div class="message-block-header">
|
||||
<span class="message-block-header-icon">▶</span>
|
||||
<span class="message-block-header-title">🧩 使用工具: 订单数据分析</span>
|
||||
<span class="message-block-header-status message-tool-completed">✓ 0.3s</span>
|
||||
</div>
|
||||
<div class="message-block-content">
|
||||
<div class="message-tool-io">
|
||||
<div class="message-tool-io-label">INPUT</div>
|
||||
<div class="message-tool-io-content"><pre>{ "orders": [...], "timeRange": "1year" }</pre></div>
|
||||
</div>
|
||||
<div class="message-tool-io">
|
||||
<div class="message-tool-io-label">OUTPUT</div>
|
||||
<div class="message-tool-io-content"><pre>{ "totalAmount": 28560, "orderCount": 3, "lastOrderDate": "2026-03-10" }</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-block expanded">
|
||||
<div class="message-block-header">
|
||||
<span class="message-block-header-icon">▶</span>
|
||||
<span class="message-block-header-title">已深度思考</span>
|
||||
<span class="message-block-header-status">0.6s</span>
|
||||
</div>
|
||||
<div class="message-block-content">
|
||||
<p>根据分析结果,用户可能还想知道:</p>
|
||||
<ul>
|
||||
<li>订单详情列表</li>
|
||||
<li>客户等级权益</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-bubble">
|
||||
<p><strong>客户信息查询结果:</strong></p>
|
||||
<div style="margin-top: 12px; padding: 12px; background: #F8FAFC; border-radius: 8px;">
|
||||
@@ -154,70 +195,6 @@ export function getChatScenes() {
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
file: `
|
||||
<div class="message user">
|
||||
<div class="message-avatar user">张</div>
|
||||
<div class="message-content">
|
||||
<div class="message-bubble">
|
||||
<p>帮我分析一下这个 Excel 文件里的销售数据</p>
|
||||
<div style="margin-top: 12px; padding: 10px 14px; background: #F8FAFC; border-radius: 8px; display: inline-flex; align-items: center; gap: 10px; color: #475569;">
|
||||
<span style="font-size: 20px;">📊</span>
|
||||
<div>
|
||||
<div style="font-weight: 600;">Q1销售数据.xlsx</div>
|
||||
<div style="font-size: 12px; color: #94A3B8;">2.4 MB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-time">16:20</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message assistant">
|
||||
<div class="message-avatar assistant">🤖</div>
|
||||
<div class="message-content">
|
||||
<div class="message-bubble">
|
||||
<p>文件已接收!正在分析数据...</p>
|
||||
<div style="margin-top: 14px;">
|
||||
<div style="height: 6px; background: #E2E8F0; border-radius: 3px; overflow: hidden;">
|
||||
<div style="width: 65%; height: 100%; background: linear-gradient(90deg, #3B82F6, #8B5CF6); border-radius: 3px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-time">16:20</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message assistant">
|
||||
<div class="message-avatar assistant">🤖</div>
|
||||
<div class="message-content">
|
||||
<div class="message-thinking">
|
||||
<div class="message-thinking-header">
|
||||
<span class="message-thinking-icon">▶</span>
|
||||
<span>已深度思考</span>
|
||||
</div>
|
||||
<div class="message-thinking-content">
|
||||
<p>正在分析 Excel 文件中的销售数据...</p>
|
||||
<ul>
|
||||
<li>文件格式识别:Excel (.xlsx),大小 2.4MB</li>
|
||||
<li>数据结构解析:包含日期、产品、销售额等字段</li>
|
||||
<li>时间范围:2026年 Q1(1-3月)</li>
|
||||
<li>计算指标:总销售额、同比增长、月度趋势、产品占比</li>
|
||||
<li>异常检测:无异常值或缺失数据</li>
|
||||
<li>关键洞察提取:3月表现突出,产品C增长势头强劲</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-bubble">
|
||||
<p><strong>数据分析完成!以下是关键发现:</strong></p>
|
||||
<ul style="margin-top: 12px; padding-left: 20px;">
|
||||
<li>Q1 总销售额:<strong>¥128.5 万</strong>,同比增长 18%</li>
|
||||
<li>3 月份表现最佳,单月突破 50 万</li>
|
||||
<li>产品 A 占比最高(42%),产品 C 增长最快</li>
|
||||
</ul>
|
||||
<p style="margin-top: 12px;">需要生成可视化图表吗?</p>
|
||||
</div>
|
||||
<div class="message-time">16:22</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
code: `
|
||||
<div class="message user">
|
||||
<div class="message-avatar user">张</div>
|
||||
@@ -229,12 +206,13 @@ export function getChatScenes() {
|
||||
<div class="message assistant">
|
||||
<div class="message-avatar assistant">🤖</div>
|
||||
<div class="message-content">
|
||||
<div class="message-thinking">
|
||||
<div class="message-thinking-header">
|
||||
<span class="message-thinking-icon">▶</span>
|
||||
<span>已深度思考</span>
|
||||
<div class="message-block expanded">
|
||||
<div class="message-block-header">
|
||||
<span class="message-block-header-icon">▶</span>
|
||||
<span class="message-block-header-title">已深度思考</span>
|
||||
<span class="message-block-header-status">0.8s</span>
|
||||
</div>
|
||||
<div class="message-thinking-content">
|
||||
<div class="message-block-content">
|
||||
<p>分析需求:列表去重并保持顺序</p>
|
||||
<ul>
|
||||
<li>使用字典键的有序性(Python 3.7+)</li>
|
||||
@@ -243,6 +221,7 @@ export function getChatScenes() {
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-bubble">
|
||||
<p>好的,这是一个常用的工具函数。以下提供两种实现方式:</p>
|
||||
<pre style="margin-top: 12px; padding: 16px; background: #1E293B; border-radius: 8px; overflow-x: auto;"><code style="color: #E2E8F0; font-family: 'Fira Code', monospace; font-size: 13px; line-height: 1.6;"><span style="color: #60A5FA;">def</span> <span style="color: #34D399;">dedupe_ordered</span>(lst):
|
||||
@@ -274,12 +253,13 @@ data = [<span style="color: #A78BFA;">1</span>, <span style="color: #A78BFA;">2<
|
||||
<div class="message assistant">
|
||||
<div class="message-avatar assistant">🤖</div>
|
||||
<div class="message-content">
|
||||
<div class="message-thinking">
|
||||
<div class="message-thinking-header">
|
||||
<span class="message-thinking-icon">▶</span>
|
||||
<span>已深度思考</span>
|
||||
<div class="message-block expanded">
|
||||
<div class="message-block-header">
|
||||
<span class="message-block-header-icon">▶</span>
|
||||
<span class="message-block-header-title">已深度思考</span>
|
||||
<span class="message-block-header-status">1.2s</span>
|
||||
</div>
|
||||
<div class="message-thinking-content">
|
||||
<div class="message-block-content">
|
||||
<p>正在查询销售数据...</p>
|
||||
<ul>
|
||||
<li>时间范围:2026年3月1日 - 3月19日</li>
|
||||
@@ -288,53 +268,54 @@ data = [<span style="color: #A78BFA;">1</span>, <span style="color: #A78BFA;">2<
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-bubble">
|
||||
<p><strong>本月各部门销售业绩汇总:</strong></p>
|
||||
<table style="margin-top: 12px; width: 100%; border-collapse: collapse; font-size: 13px;">
|
||||
<table class="msg-table">
|
||||
<thead>
|
||||
<tr style="background: #F1F5F9;">
|
||||
<th style="padding: 10px 12px; text-align: left; border: 1px solid #E2E8F0; font-weight: 600;">部门</th>
|
||||
<th style="padding: 10px 12px; text-align: right; border: 1px solid #E2E8F0; font-weight: 600;">销售额</th>
|
||||
<th style="padding: 10px 12px; text-align: right; border: 1px solid #E2E8F0; font-weight: 600;">订单数</th>
|
||||
<th style="padding: 10px 12px; text-align: right; border: 1px solid #E2E8F0; font-weight: 600;">完成率</th>
|
||||
<tr>
|
||||
<th>部门</th>
|
||||
<th class="text-right">销售额</th>
|
||||
<th class="text-right">订单数</th>
|
||||
<th class="text-right">完成率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0;">华东区</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right; font-weight: 600; color: #10B981;">¥528,600</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;">186</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;"><span style="color: #10B981;">112%</span></td>
|
||||
</tr>
|
||||
<tr style="background: #FAFAFA;">
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0;">华南区</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right; font-weight: 600;">¥412,300</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;">142</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;"><span style="color: #F59E0B;">98%</span></td>
|
||||
<td>华东区</td>
|
||||
<td class="text-right value-success">¥528,600</td>
|
||||
<td class="text-right">186</td>
|
||||
<td class="text-right"><span class="value-success">112%</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0;">华北区</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right; font-weight: 600;">¥385,900</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;">128</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;"><span style="color: #10B981;">105%</span></td>
|
||||
<td>华南区</td>
|
||||
<td class="text-right">¥412,300</td>
|
||||
<td class="text-right">142</td>
|
||||
<td class="text-right"><span class="value-warning">98%</span></td>
|
||||
</tr>
|
||||
<tr style="background: #FAFAFA;">
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0;">西南区</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right; font-weight: 600; color: #EF4444;">¥267,400</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;">95</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;"><span style="color: #EF4444;">76%</span></td>
|
||||
<tr>
|
||||
<td>华北区</td>
|
||||
<td class="text-right">¥385,900</td>
|
||||
<td class="text-right">128</td>
|
||||
<td class="text-right"><span class="value-success">105%</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>西南区</td>
|
||||
<td class="text-right value-danger">¥267,400</td>
|
||||
<td class="text-right">95</td>
|
||||
<td class="text-right"><span class="value-danger">76%</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="background: #F1F5F9; font-weight: 600;">
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0;">合计</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right; color: #3B82F6;">¥1,594,200</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;">551</td>
|
||||
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;"><span style="color: #3B82F6;">98%</span></td>
|
||||
<tr>
|
||||
<td>合计</td>
|
||||
<td class="text-right value-primary">¥1,594,200</td>
|
||||
<td class="text-right">551</td>
|
||||
<td class="text-right"><span class="value-primary">98%</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<p style="margin-top: 12px; font-size: 13px; color: #64748B;">数据更新时间:2026-03-19 11:30</p>
|
||||
<p class="msg-meta">数据更新时间:2026-03-19 11:30</p>
|
||||
</div>
|
||||
<div class="message-time">11:31</div>
|
||||
</div>
|
||||
@@ -423,12 +404,13 @@ data = [<span style="color: #A78BFA;">1</span>, <span style="color: #A78BFA;">2<
|
||||
<div class="message assistant">
|
||||
<div class="message-avatar assistant">🤖</div>
|
||||
<div class="message-content">
|
||||
<div class="message-thinking">
|
||||
<div class="message-thinking-header">
|
||||
<span class="message-thinking-icon">▶</span>
|
||||
<span>已深度思考</span>
|
||||
<div class="message-block expanded">
|
||||
<div class="message-block-header">
|
||||
<span class="message-block-header-icon">▶</span>
|
||||
<span class="message-block-header-title">已深度思考</span>
|
||||
<span class="message-block-header-status">30.2s</span>
|
||||
</div>
|
||||
<div class="message-thinking-content">
|
||||
<div class="message-block-content">
|
||||
<p>正在连接日志数据库...</p>
|
||||
<ul>
|
||||
<li>目标数据库:log-db-prod-01</li>
|
||||
@@ -437,6 +419,7 @@ data = [<span style="color: #A78BFA;">1</span>, <span style="color: #A78BFA;">2<
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-bubble" style="border-left: 3px solid #EF4444; background: #FEF2F2;">
|
||||
<p style="display: flex; align-items: center; gap: 8px; color: #EF4444; font-weight: 600;">
|
||||
❌ 请求失败
|
||||
|
||||
47
src/data/projectModelConfigs.js
Normal file
47
src/data/projectModelConfigs.js
Normal 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
|
||||
}
|
||||
}
|
||||
];
|
||||
34
src/data/userModelConfigs.js
Normal file
34
src/data/userModelConfigs.js
Normal file
@@ -0,0 +1,34 @@
|
||||
export const userModelConfigs = [
|
||||
{
|
||||
id: 'user_001',
|
||||
name: '我的 GPT-4',
|
||||
type: 'basic',
|
||||
isActive: true,
|
||||
createdAt: '2026-03-26T10:00:00',
|
||||
updatedAt: '2026-03-26T10:00:00',
|
||||
basic: {
|
||||
apiUrl: 'https://api.openai.com/v1',
|
||||
apiKey: 'sk-user-abc123xyz456',
|
||||
modelName: 'gpt-4o',
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096,
|
||||
topP: 0.9
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'user_002',
|
||||
name: 'Claude 备用',
|
||||
type: 'basic',
|
||||
isActive: false,
|
||||
createdAt: '2026-03-28T16:00:00',
|
||||
updatedAt: '2026-03-28T16:00:00',
|
||||
basic: {
|
||||
apiUrl: 'https://api.anthropic.com/v1',
|
||||
apiKey: 'sk-user-claude789abc',
|
||||
modelName: 'claude-3-sonnet',
|
||||
temperature: 0.8,
|
||||
maxTokens: 8192,
|
||||
topP: 0.9
|
||||
}
|
||||
}
|
||||
];
|
||||
365
src/data/workspace.js
Normal file
365
src/data/workspace.js
Normal file
@@ -0,0 +1,365 @@
|
||||
// 工作空间文件数据
|
||||
|
||||
// 文件图标映射
|
||||
export const fileIcons = {
|
||||
folder: '📁',
|
||||
// 文本类型
|
||||
text: '📄',
|
||||
markdown: '📝',
|
||||
json: '📋',
|
||||
javascript: '💻',
|
||||
python: '🐍',
|
||||
css: '🎨',
|
||||
html: '🌐',
|
||||
// Office 类型
|
||||
word: '📘',
|
||||
excel: '📗',
|
||||
powerpoint: '📙',
|
||||
// 媒体类型
|
||||
image: '🖼️',
|
||||
video: '🎬',
|
||||
audio: '🎵',
|
||||
// 其他
|
||||
pdf: '📕',
|
||||
archive: '📦',
|
||||
unknown: '📄'
|
||||
};
|
||||
|
||||
// 根据文件扩展名获取文件类型
|
||||
export function getFileType(filename) {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
|
||||
// 文本类型
|
||||
if (['txt'].includes(ext)) return 'text';
|
||||
if (['md'].includes(ext)) return 'markdown';
|
||||
if (['json'].includes(ext)) return 'json';
|
||||
if (['js', 'jsx', 'ts', 'tsx'].includes(ext)) return 'javascript';
|
||||
if (['py'].includes(ext)) return 'python';
|
||||
if (['css', 'scss', 'sass'].includes(ext)) return 'css';
|
||||
if (['html', 'htm'].includes(ext)) return 'html';
|
||||
|
||||
// Office 类型
|
||||
if (['doc', 'docx'].includes(ext)) return 'word';
|
||||
if (['xls', 'xlsx'].includes(ext)) return 'excel';
|
||||
if (['ppt', 'pptx'].includes(ext)) return 'powerpoint';
|
||||
|
||||
// 媒体类型
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'].includes(ext)) return 'image';
|
||||
if (['mp4', 'avi', 'mov', 'mkv', 'webm'].includes(ext)) return 'video';
|
||||
if (['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(ext)) return 'audio';
|
||||
|
||||
// 其他
|
||||
if (['pdf'].includes(ext)) return 'pdf';
|
||||
if (['zip', 'rar', 'tar', 'gz'].includes(ext)) return 'archive';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// 工作空间文件树数据
|
||||
export const workspaceFiles = [
|
||||
{
|
||||
id: 'folder_1',
|
||||
name: 'documents',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_1',
|
||||
name: 'report.docx',
|
||||
type: 'file',
|
||||
fileType: 'word',
|
||||
size: '156 KB',
|
||||
modifiedAt: '2026-04-09'
|
||||
},
|
||||
{
|
||||
id: 'file_2',
|
||||
name: 'notes.txt',
|
||||
type: 'file',
|
||||
fileType: 'text',
|
||||
size: '1.2 KB',
|
||||
modifiedAt: '2026-04-10',
|
||||
content: '这是笔记内容...\n\n1. 第一点\n2. 第二点\n3. 第三点\n\n完成!'
|
||||
},
|
||||
{
|
||||
id: 'file_3',
|
||||
name: 'presentation.pptx',
|
||||
type: 'file',
|
||||
fileType: 'powerpoint',
|
||||
size: '2.3 MB',
|
||||
modifiedAt: '2026-04-08'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'folder_2',
|
||||
name: 'code',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_4',
|
||||
name: 'app.js',
|
||||
type: 'file',
|
||||
fileType: 'javascript',
|
||||
size: '3.5 KB',
|
||||
modifiedAt: '2026-04-08',
|
||||
content: `import React from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<h1>Hello World</h1>
|
||||
<p>Count: {count}</p>
|
||||
<button onClick={() => setCount(count + 1)}>
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;`
|
||||
},
|
||||
{
|
||||
id: 'file_5',
|
||||
name: 'style.css',
|
||||
type: 'file',
|
||||
fileType: 'css',
|
||||
size: '1.8 KB',
|
||||
modifiedAt: '2026-04-07',
|
||||
content: `.app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
background: #3B82F6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #2563EB;
|
||||
}`
|
||||
},
|
||||
{
|
||||
id: 'file_6',
|
||||
name: 'config.json',
|
||||
type: 'file',
|
||||
fileType: 'json',
|
||||
size: '856 B',
|
||||
modifiedAt: '2026-04-06',
|
||||
content: `{
|
||||
"name": "my-app",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample application",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"build": "webpack --mode production"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'folder_3',
|
||||
name: 'media',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_7',
|
||||
name: 'photo.jpg',
|
||||
type: 'file',
|
||||
fileType: 'image',
|
||||
size: '245 KB',
|
||||
dimensions: '1920 × 1080',
|
||||
modifiedAt: '2026-04-07'
|
||||
},
|
||||
{
|
||||
id: 'file_8',
|
||||
name: 'demo.mp4',
|
||||
type: 'file',
|
||||
fileType: 'video',
|
||||
size: '12.5 MB',
|
||||
duration: '02:35',
|
||||
modifiedAt: '2026-04-06'
|
||||
},
|
||||
{
|
||||
id: 'file_9',
|
||||
name: 'music.mp3',
|
||||
type: 'file',
|
||||
fileType: 'audio',
|
||||
size: '4.8 MB',
|
||||
duration: '03:45',
|
||||
modifiedAt: '2026-04-05'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'file_10',
|
||||
name: 'README.md',
|
||||
type: 'file',
|
||||
fileType: 'markdown',
|
||||
size: '2.1 KB',
|
||||
modifiedAt: '2026-04-08',
|
||||
content: `# 项目说明
|
||||
|
||||
这是一个示例项目,用于展示工作空间文件管理功能。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- 文件树展示
|
||||
- 文件预览
|
||||
- 文件操作
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 点击文件查看预览
|
||||
2. 右键菜单进行操作
|
||||
|
||||
## 注意事项
|
||||
|
||||
这是一个原型项目,文件操作仅为演示。`
|
||||
},
|
||||
{
|
||||
id: 'file_11',
|
||||
name: 'data.xlsx',
|
||||
type: 'file',
|
||||
fileType: 'excel',
|
||||
size: '89 KB',
|
||||
modifiedAt: '2026-04-04'
|
||||
},
|
||||
{
|
||||
id: 'folder_4',
|
||||
name: '中文文档',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_12',
|
||||
name: '产品需求文档.docx',
|
||||
type: 'file',
|
||||
fileType: 'word',
|
||||
size: '234 KB',
|
||||
modifiedAt: '2026-04-10'
|
||||
},
|
||||
{
|
||||
id: 'file_13',
|
||||
name: '用户反馈汇总.xlsx',
|
||||
type: 'file',
|
||||
fileType: 'excel',
|
||||
size: '156 KB',
|
||||
modifiedAt: '2026-04-09'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'folder_5',
|
||||
name: 'archives',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_14',
|
||||
name: 'archive.zip',
|
||||
type: 'file',
|
||||
fileType: 'archive',
|
||||
size: '5.6 MB',
|
||||
modifiedAt: '2026-04-08'
|
||||
},
|
||||
{
|
||||
id: 'file_15',
|
||||
name: 'database.db',
|
||||
type: 'file',
|
||||
fileType: 'unknown',
|
||||
size: '1.2 MB',
|
||||
modifiedAt: '2026-04-07'
|
||||
},
|
||||
{
|
||||
id: 'file_16',
|
||||
name: 'binary.bin',
|
||||
type: 'file',
|
||||
fileType: 'unknown',
|
||||
size: '512 KB',
|
||||
modifiedAt: '2026-04-06'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'folder_6',
|
||||
name: 'project',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'folder_7',
|
||||
name: 'src',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'folder_8',
|
||||
name: 'components',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'folder_9',
|
||||
name: 'Button',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'file_17',
|
||||
name: 'Button.jsx',
|
||||
type: 'file',
|
||||
fileType: 'javascript',
|
||||
size: '2.1 KB',
|
||||
modifiedAt: '2026-04-05',
|
||||
content: `import React from 'react';
|
||||
|
||||
function Button({ children, onClick }) {
|
||||
return (
|
||||
<button className="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;`
|
||||
},
|
||||
{
|
||||
id: 'file_18',
|
||||
name: 'Button.scss',
|
||||
type: 'file',
|
||||
fileType: 'css',
|
||||
size: '856 B',
|
||||
modifiedAt: '2026-04-04',
|
||||
content: `.button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}`
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'folder_10',
|
||||
name: 'empty-folder',
|
||||
type: 'folder',
|
||||
children: []
|
||||
}
|
||||
];
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function useLocalStorage(key, initialValue) {
|
||||
const [value, setValue] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(key);
|
||||
return saved ? JSON.parse(saved) : initialValue;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
}, [key, value]);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
export default useLocalStorage;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 导航逻辑 Hook
|
||||
* 统一处理页面导航和附加数据状态管理
|
||||
*
|
||||
* @param {Function} setPageCallback - 设置当前页面的回调函数
|
||||
* @returns {Object} 导航操作函数
|
||||
*/
|
||||
function useNavigation(setPageCallback) {
|
||||
const [extraData, setExtraData] = useState({});
|
||||
|
||||
/**
|
||||
* 导航到指定页面
|
||||
* @param {string} pageId - 目标页面 ID
|
||||
* @param {Object} data - 附加数据(如 skillId、taskId 等)
|
||||
*/
|
||||
const navigateToPage = useCallback((pageId, data = {}) => {
|
||||
setPageCallback(pageId);
|
||||
|
||||
// 如果有附加数据,更新 extraData
|
||||
if (Object.keys(data).length > 0) {
|
||||
setExtraData(data);
|
||||
}
|
||||
}, [setPageCallback]);
|
||||
|
||||
/**
|
||||
* 设置附加数据
|
||||
* @param {Object} data - 附加数据对象
|
||||
*/
|
||||
const setExtraDataValue = useCallback((data) => {
|
||||
setExtraData(prev => ({ ...prev, ...data }));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 清除附加数据
|
||||
*/
|
||||
const clearExtraData = useCallback(() => {
|
||||
setExtraData({});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
extraData,
|
||||
navigateToPage,
|
||||
setExtraData: setExtraDataValue,
|
||||
clearExtraData,
|
||||
};
|
||||
}
|
||||
|
||||
export default useNavigation;
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* 页面状态持久化 Hook
|
||||
* 封装页面状态管理、localStorage 同步和主页跳转重置逻辑
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.storageKey - localStorage 存储键名
|
||||
* @param {string} options.defaultPage - 默认页面 ID
|
||||
* @param {Object} options.pageTitles - 页面标题映射对象
|
||||
* @param {Function} options.getPageTitle - 自定义获取页面标题函数(可选)
|
||||
* @returns {Object} 状态和操作函数
|
||||
*/
|
||||
function usePageState({
|
||||
storageKey,
|
||||
defaultPage,
|
||||
pageTitles,
|
||||
getPageTitle: customGetPageTitle,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 从 localStorage 恢复或使用默认值
|
||||
const [currentPage, setCurrentPage] = useState(() => {
|
||||
const saved = localStorage.getItem(`${storageKey}_currentPage`);
|
||||
return saved || defaultPage;
|
||||
});
|
||||
|
||||
// 处理主页跳转重置
|
||||
useEffect(() => {
|
||||
if (location.state?.fromHome) {
|
||||
setCurrentPage(defaultPage);
|
||||
navigate('.', { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state, navigate, defaultPage]);
|
||||
|
||||
// 同步到 localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem(`${storageKey}_currentPage`, currentPage);
|
||||
}, [storageKey, currentPage]);
|
||||
|
||||
// 获取页面标题
|
||||
const getPageTitle = (pageId = currentPage) => {
|
||||
if (customGetPageTitle) {
|
||||
return customGetPageTitle(pageId, currentPage);
|
||||
}
|
||||
return pageTitles[pageId] || '';
|
||||
};
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
getPageTitle,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePageState;
|
||||
@@ -1,196 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { FiHome, FiBarChart2, FiUsers, FiList, FiCheckCircle, FiActivity } from 'react-icons/fi';
|
||||
import Layout from '../components/Layout.jsx';
|
||||
import SidebarBrand from '../components/layout/SidebarBrand.jsx';
|
||||
import SidebarUser from '../components/layout/SidebarUser.jsx';
|
||||
import SidebarNavItem from '../components/layout/SidebarNavItem.jsx';
|
||||
import usePageState from '../hooks/usePageState.js';
|
||||
import { ADMIN_PAGES } from '../constants/pages.js';
|
||||
import { ADMIN_KEYS } from '../constants/storageKeys.js';
|
||||
import OverviewPage from './admin/OverviewPage.jsx';
|
||||
import DepartmentsPage from './admin/DepartmentsPage.jsx';
|
||||
import UsersPage from './admin/UsersPage.jsx';
|
||||
import AdminProjectsPage from './admin/AdminProjectsPage.jsx';
|
||||
import AddDepartmentPage from './admin/AddDepartmentPage.jsx';
|
||||
import AddUserPage from './admin/AddUserPage.jsx';
|
||||
import AddProjectPage from './admin/AddProjectPage.jsx';
|
||||
import AdminLogsPage from './admin/AdminLogsPage.jsx';
|
||||
import ConsoleReviewListPage from './console/ConsoleReviewListPage.jsx';
|
||||
import ConsoleReviewDetailPage from './console/ConsoleReviewDetailPage.jsx';
|
||||
|
||||
function AdminPage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { currentPage, setCurrentPage } = usePageState({
|
||||
storageKey: ADMIN_KEYS.CURRENT_PAGE,
|
||||
defaultPage: 'overview',
|
||||
pageTitles: ADMIN_PAGES,
|
||||
});
|
||||
|
||||
const [editData, setEditData] = useState(null);
|
||||
const [reviewType, setReviewType] = useState(null);
|
||||
const [reviewId, setReviewId] = useState(null);
|
||||
|
||||
const navigateTo = (page, data) => {
|
||||
setEditData(data || null);
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handleReviewClick = (type, id) => {
|
||||
setReviewType(type);
|
||||
setReviewId(id);
|
||||
navigateTo('reviewDetail');
|
||||
};
|
||||
|
||||
const handleReviewBack = () => {
|
||||
setReviewType(null);
|
||||
setReviewId(null);
|
||||
navigateTo('reviewList');
|
||||
};
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'overview':
|
||||
return <OverviewPage />;
|
||||
case 'departments':
|
||||
return <DepartmentsPage
|
||||
onAdd={() => navigateTo('addDepartment')}
|
||||
onEdit={(dept) => navigateTo('addDepartment', dept)}
|
||||
/>;
|
||||
case 'users':
|
||||
return <UsersPage
|
||||
onAdd={() => navigateTo('addUser')}
|
||||
onEdit={(user) => navigateTo('addUser', user)}
|
||||
/>;
|
||||
case 'projects':
|
||||
return <AdminProjectsPage
|
||||
onAdd={() => navigateTo('addProject')}
|
||||
onEdit={(project) => navigateTo('addProject', project)}
|
||||
/>;
|
||||
case 'adminLogs':
|
||||
return <AdminLogsPage />;
|
||||
case 'reviewList':
|
||||
return <ConsoleReviewListPage onReviewClick={handleReviewClick} />;
|
||||
case 'reviewDetail':
|
||||
return <ConsoleReviewDetailPage
|
||||
type={reviewType}
|
||||
reviewId={reviewId}
|
||||
onBack={handleReviewBack}
|
||||
/>;
|
||||
case 'addDepartment':
|
||||
return <AddDepartmentPage
|
||||
onBack={() => navigateTo('departments')}
|
||||
editData={editData}
|
||||
/>;
|
||||
case 'addUser':
|
||||
return <AddUserPage
|
||||
onBack={() => navigateTo('users')}
|
||||
editData={editData}
|
||||
/>;
|
||||
case 'addProject':
|
||||
return <AddProjectPage
|
||||
onBack={() => navigateTo('projects')}
|
||||
editData={editData}
|
||||
/>;
|
||||
default:
|
||||
return <div>Page not found</div>;
|
||||
}
|
||||
};
|
||||
|
||||
const getPageTitle = () => {
|
||||
if (editData && (currentPage === 'addDepartment' || currentPage === 'addUser' || currentPage === 'addProject')) {
|
||||
const prefix = '编辑';
|
||||
const nameMap = { addDepartment: '部门', addUser: '用户', addProject: '项目' };
|
||||
return prefix + nameMap[currentPage];
|
||||
}
|
||||
if (currentPage === 'reviewDetail') {
|
||||
return reviewType === 'version' ? '版本审核' : '下架审核';
|
||||
}
|
||||
return ADMIN_PAGES[currentPage]?.title || '';
|
||||
};
|
||||
|
||||
const sidebar = (
|
||||
<>
|
||||
<div className="admin-sidebar-header">
|
||||
<SidebarBrand subtitle="运营管理台" />
|
||||
</div>
|
||||
<nav className="admin-sidebar-nav">
|
||||
<SidebarNavItem
|
||||
icon={<FiHome />}
|
||||
label="总览"
|
||||
active={currentPage === 'overview'}
|
||||
onClick={() => navigateTo('overview')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiCheckCircle />}
|
||||
label="审核管理"
|
||||
active={currentPage === 'reviewList' || currentPage === 'reviewDetail'}
|
||||
onClick={() => navigateTo('reviewList')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiBarChart2 />}
|
||||
label="部门管理"
|
||||
active={currentPage === 'departments'}
|
||||
onClick={() => navigateTo('departments')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiUsers />}
|
||||
label="用户管理"
|
||||
active={currentPage === 'users'}
|
||||
onClick={() => navigateTo('users')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiList />}
|
||||
label="项目管理"
|
||||
active={currentPage === 'projects'}
|
||||
onClick={() => navigateTo('projects')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiActivity />}
|
||||
label="日志查询"
|
||||
active={currentPage === 'adminLogs'}
|
||||
onClick={() => navigateTo('adminLogs')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
</nav>
|
||||
<SidebarUser
|
||||
onClick={() => {}}
|
||||
wrapperClassName="admin-sidebar-user"
|
||||
infoClassName="admin-sidebar-user-info"
|
||||
nameClassName="admin-sidebar-user-name"
|
||||
roleClassName="admin-sidebar-user-role"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
sidebar={sidebar}
|
||||
headerTitle={getPageTitle()}
|
||||
sidebarClassName="admin-sidebar"
|
||||
>
|
||||
{renderPage()}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminPage;
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { FiPlus, FiClock, FiList, FiUsers, FiBox } from 'react-icons/fi';
|
||||
import { FaPuzzlePiece } from 'react-icons/fa';
|
||||
import Layout from '../components/Layout.jsx';
|
||||
import SidebarBrand from '../components/layout/SidebarBrand.jsx';
|
||||
import SidebarUser from '../components/layout/SidebarUser.jsx';
|
||||
import SidebarNavItem from '../components/layout/SidebarNavItem.jsx';
|
||||
import usePageState from '../hooks/usePageState.js';
|
||||
import { CONSOLE_PAGES } from '../constants/pages.js';
|
||||
import { CONSOLE_KEYS } from '../constants/storageKeys.js';
|
||||
import api from '../services/api.js';
|
||||
import ChatPage from './console/ChatPage.jsx';
|
||||
import SkillsPage from './console/SkillsPage.jsx';
|
||||
import SkillDetailPage from './console/SkillDetailPage.jsx';
|
||||
import MySkillsPage from './console/MySkillsPage.jsx';
|
||||
import SkillConfigPage from './console/SkillConfigPage.jsx';
|
||||
import LogsPage from './console/LogsPage.jsx';
|
||||
import TasksPage from './console/TasksPage.jsx';
|
||||
import TaskDetailPage from './console/TaskDetailPage.jsx';
|
||||
import AccountPage from './console/AccountPage.jsx';
|
||||
import ProjectsPage from './console/ProjectsPage.jsx';
|
||||
import MemberConfigPage from './console/MemberConfigPage.jsx';
|
||||
import AddMemberPage from './console/AddMemberPage.jsx';
|
||||
|
||||
function ConsolePage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 使用 usePageState 管理 currentPage(不使用其返回的 getPageTitle,因为需要访问组件局部变量)
|
||||
const { currentPage, setCurrentPage } = usePageState({
|
||||
storageKey: CONSOLE_KEYS.CURRENT_PAGE,
|
||||
defaultPage: 'chat',
|
||||
pageTitles: CONSOLE_PAGES,
|
||||
});
|
||||
|
||||
// 保留额外的状态(scene 和 skillId 等需要特殊处理)
|
||||
const [currentScene, setCurrentScene] = useState(() => {
|
||||
return localStorage.getItem(CONSOLE_KEYS.CURRENT_SCENE) || 'welcome';
|
||||
});
|
||||
const [currentSkillId, setCurrentSkillId] = useState(null);
|
||||
const [currentTaskId, setCurrentTaskId] = useState(null);
|
||||
const [currentSubscriptionId, setCurrentSubscriptionId] = useState(null);
|
||||
|
||||
// 处理主页跳转重置
|
||||
useEffect(() => {
|
||||
if (location.state?.fromHome) {
|
||||
setCurrentPage('chat');
|
||||
setCurrentScene('welcome');
|
||||
navigate('.', { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state, navigate, setCurrentPage, setCurrentScene]);
|
||||
|
||||
// 同步 currentScene 到 localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem(CONSOLE_KEYS.CURRENT_SCENE, currentScene);
|
||||
}, [currentScene]);
|
||||
|
||||
const switchPage = (pageId, data = {}) => {
|
||||
setCurrentPage(pageId);
|
||||
if (data.skillId !== undefined) {
|
||||
setCurrentSkillId(data.skillId);
|
||||
}
|
||||
if (data.subscriptionId !== undefined) {
|
||||
setCurrentSubscriptionId(data.subscriptionId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkillClick = (skillId) => {
|
||||
switchPage('skillDetail', { skillId });
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
switchPage('skills');
|
||||
};
|
||||
|
||||
const switchChatScene = (scene) => {
|
||||
setCurrentScene(scene);
|
||||
if (currentPage !== 'chat') {
|
||||
setCurrentPage('chat');
|
||||
}
|
||||
};
|
||||
|
||||
const createNewChat = () => {
|
||||
setCurrentScene('welcome');
|
||||
setCurrentPage('chat');
|
||||
};
|
||||
|
||||
const activeScene = currentPage === 'chat' ? currentScene : null;
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'chat':
|
||||
return <ChatPage scene={currentScene} />;
|
||||
case 'skills':
|
||||
return <SkillsPage onSkillClick={handleSkillClick} />;
|
||||
case 'skillDetail':
|
||||
return <SkillDetailPage skillId={currentSkillId} onBack={handleBack} />;
|
||||
case 'mySkills':
|
||||
return <MySkillsPage
|
||||
onConfig={(subscriptionId) => switchPage('skillConfig', { subscriptionId })}
|
||||
onBack={() => switchPage('skills')}
|
||||
/>;
|
||||
case 'skillConfig':
|
||||
return <SkillConfigPage
|
||||
subscriptionId={currentSubscriptionId}
|
||||
onBack={() => switchPage('mySkills')}
|
||||
/>;
|
||||
case 'logs':
|
||||
return <LogsPage />;
|
||||
case 'scheduledTasks':
|
||||
return <TasksPage
|
||||
onViewDetail={(taskId) => {
|
||||
setCurrentTaskId(taskId);
|
||||
switchPage('taskDetail');
|
||||
}}
|
||||
/>;
|
||||
case 'taskDetail':
|
||||
return <TaskDetailPage
|
||||
taskId={currentTaskId}
|
||||
onBack={() => switchPage('scheduledTasks')}
|
||||
/>;
|
||||
case 'account':
|
||||
return <AccountPage />;
|
||||
case 'projects':
|
||||
return <ProjectsPage onAddMember={() => switchPage('addMember')} />;
|
||||
case 'memberConfig':
|
||||
return <MemberConfigPage onBack={() => switchPage('projects')} />;
|
||||
case 'addMember':
|
||||
return <AddMemberPage onBack={() => switchPage('projects')} />;
|
||||
default:
|
||||
return <div>Page not found</div>;
|
||||
}
|
||||
};
|
||||
|
||||
const getPageTitle = () => {
|
||||
let title = CONSOLE_PAGES[currentPage]?.title || '';
|
||||
if (currentPage === 'chat') {
|
||||
const conv = api.conversations.list().find(c => c.scene === currentScene);
|
||||
title = conv?.title || '智能助手';
|
||||
}
|
||||
if (currentPage === 'skillDetail' && currentSkillId) {
|
||||
const skill = api.skills.getById(currentSkillId);
|
||||
title = skill?.name || '技能详情';
|
||||
}
|
||||
return title;
|
||||
};
|
||||
|
||||
const sidebar = (
|
||||
<>
|
||||
<div className="chat-sidebar-header">
|
||||
<SidebarBrand subtitle="企业级AI平台" />
|
||||
<div className="sidebar-divider"></div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} onClick={createNewChat}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
|
||||
<FiPlus /> 新建对话
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="chat-sidebar-content">
|
||||
{api.conversations.list().map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
className={`conversation-item ${conv.scene === activeScene ? 'active' : ''}`}
|
||||
onClick={() => switchChatScene(conv.scene)}
|
||||
>
|
||||
<div className="conversation-title">{conv.title}</div>
|
||||
<div className="conversation-time">{conv.time}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="chat-sidebar-project">
|
||||
<label className="chat-sidebar-project-label">当前项目</label>
|
||||
<select className="form-control chat-sidebar-project-select">
|
||||
<option>企业 AI 智算平台</option>
|
||||
<option>知识库管理系统</option>
|
||||
<option>数据分析平台</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="chat-sidebar-nav">
|
||||
<SidebarNavItem
|
||||
icon={<FaPuzzlePiece />}
|
||||
label="技能市场"
|
||||
active={currentPage === 'skills'}
|
||||
onClick={() => switchPage('skills')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiBox />}
|
||||
label="我的技能"
|
||||
active={currentPage === 'mySkills'}
|
||||
onClick={() => switchPage('mySkills')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiClock />}
|
||||
label="定时任务"
|
||||
active={currentPage === 'scheduledTasks'}
|
||||
onClick={() => switchPage('scheduledTasks')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiList />}
|
||||
label="日志查询"
|
||||
active={currentPage === 'logs'}
|
||||
onClick={() => switchPage('logs')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiUsers />}
|
||||
label="项目管理"
|
||||
active={currentPage === 'projects'}
|
||||
onClick={() => switchPage('projects')}
|
||||
/>
|
||||
</div>
|
||||
<SidebarUser onClick={() => switchPage('account')} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
sidebar={sidebar}
|
||||
headerTitle={getPageTitle()}
|
||||
sidebarClassName="chat-sidebar"
|
||||
contentClassName={currentPage === 'chat' ? 'page-content-full' : ''}
|
||||
>
|
||||
{renderPage()}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConsolePage;
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { FiPlus, FiTerminal, FiHome } from 'react-icons/fi';
|
||||
import { FaPuzzlePiece } from 'react-icons/fa';
|
||||
import Layout from '../components/Layout.jsx';
|
||||
import SidebarBrand from '../components/layout/SidebarBrand.jsx';
|
||||
import SidebarUser from '../components/layout/SidebarUser.jsx';
|
||||
import SidebarNavItem from '../components/layout/SidebarNavItem.jsx';
|
||||
import usePageState from '../hooks/usePageState.js';
|
||||
import { DEVELOPER_PAGES } from '../constants/pages.js';
|
||||
import { DEVELOPER_KEYS } from '../constants/storageKeys.js';
|
||||
import api from '../services/api.js';
|
||||
import DevOverviewPage from './developer/DevOverviewPage.jsx';
|
||||
import MySkillsPage from './developer/MySkillsPage.jsx';
|
||||
import UploadSkillPage from './developer/UploadSkillPage.jsx';
|
||||
import NewVersionPage from './developer/NewVersionPage.jsx';
|
||||
import DevDocsPage from './developer/DevDocsPage.jsx';
|
||||
import DevAccountPage from './developer/DevAccountPage.jsx';
|
||||
import SkillEditorPage from './developer/SkillEditorPage.jsx';
|
||||
import UpdateSkillInfoPage from './developer/UpdateSkillInfoPage.jsx';
|
||||
import UploadVersionPage from './developer/UploadVersionPage.jsx';
|
||||
|
||||
const skillStatusMap = {
|
||||
dev: { text: '开发中', className: 'status-stopped' },
|
||||
published: { text: '已上架', className: 'status-running' },
|
||||
unlisting: { text: '下架审核中', className: 'status-warning' },
|
||||
unlisted: { text: '已下架', className: 'status-stopped' }
|
||||
};
|
||||
|
||||
function DeveloperPage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 使用 usePageState 管理页面状态
|
||||
const { currentPage, setCurrentPage } = usePageState({
|
||||
storageKey: DEVELOPER_KEYS.CURRENT_PAGE,
|
||||
defaultPage: 'overview',
|
||||
pageTitles: DEVELOPER_PAGES,
|
||||
});
|
||||
|
||||
// 保留额外的状态(currentSkillId 需要持久化到 localStorage)
|
||||
const [currentSkillId, setCurrentSkillId] = useState(() => {
|
||||
const saved = localStorage.getItem(DEVELOPER_KEYS.CURRENT_SKILL_ID);
|
||||
return saved ? JSON.parse(saved) : null;
|
||||
});
|
||||
const [newVersionSkillName, setNewVersionSkillName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.fromHome) {
|
||||
setCurrentPage('overview');
|
||||
setCurrentSkillId(null);
|
||||
navigate('.', { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state, navigate, setCurrentPage, setCurrentSkillId]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(DEVELOPER_KEYS.CURRENT_SKILL_ID, JSON.stringify(currentSkillId));
|
||||
}, [DEVELOPER_KEYS.CURRENT_SKILL_ID, currentSkillId]);
|
||||
|
||||
const switchPage = (pageId, data = {}) => {
|
||||
setCurrentPage(pageId);
|
||||
if (data.skillId !== undefined) {
|
||||
setCurrentSkillId(data.skillId);
|
||||
}
|
||||
};
|
||||
|
||||
const openSkillEditor = (skillId) => {
|
||||
setCurrentSkillId(skillId);
|
||||
setCurrentPage('skillEditor');
|
||||
};
|
||||
|
||||
const createNewProject = () => {
|
||||
setCurrentPage('uploadSkill');
|
||||
};
|
||||
|
||||
const openNewVersionPage = (skillName) => {
|
||||
setNewVersionSkillName(skillName);
|
||||
setCurrentPage('newVersion');
|
||||
};
|
||||
|
||||
const openUpdateInfoPage = (skillId) => {
|
||||
setCurrentSkillId(skillId);
|
||||
setCurrentPage('updateInfo');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentPage('mySkills');
|
||||
setCurrentSkillId(null);
|
||||
};
|
||||
|
||||
const handleEditorBack = () => {
|
||||
setCurrentPage('skillEditor');
|
||||
setNewVersionSkillName('');
|
||||
};
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'overview':
|
||||
return <DevOverviewPage onSkillClick={openSkillEditor} />;
|
||||
case 'mySkills':
|
||||
return <MySkillsPage onSkillClick={openSkillEditor} />;
|
||||
case 'uploadSkill':
|
||||
return <UploadSkillPage onBack={() => switchPage('mySkills')} />;
|
||||
case 'devDocs':
|
||||
return <DevDocsPage />;
|
||||
case 'devAccount':
|
||||
return <DevAccountPage />;
|
||||
case 'skillEditor':
|
||||
return <SkillEditorPage
|
||||
skillId={currentSkillId}
|
||||
onBack={handleBack}
|
||||
onUploadNewVersion={openNewVersionPage}
|
||||
onUpdateInfo={openUpdateInfoPage}
|
||||
/>;
|
||||
case 'newVersion':
|
||||
return <UploadVersionPage skillName={newVersionSkillName} onBack={handleEditorBack} />;
|
||||
case 'updateInfo':
|
||||
return <UpdateSkillInfoPage
|
||||
skill={api.developer.getSkillById(currentSkillId)}
|
||||
onBack={handleEditorBack}
|
||||
/>;
|
||||
default:
|
||||
return <div>Page not found</div>;
|
||||
}
|
||||
};
|
||||
|
||||
const getPageTitle = () => {
|
||||
return DEVELOPER_PAGES[currentPage]?.title || '';
|
||||
};
|
||||
|
||||
const sidebar = (
|
||||
<>
|
||||
<div className="chat-sidebar-header">
|
||||
<SidebarBrand subtitle="技能开发台" />
|
||||
<div className="sidebar-divider"></div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} onClick={createNewProject}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
|
||||
<FiPlus /> 创建技能
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="chat-sidebar-content">
|
||||
{api.developer.getMySkills().map(skill => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className={`conversation-item ${currentSkillId === skill.id && currentPage === 'skillEditor' ? 'active' : ''}`}
|
||||
onClick={() => openSkillEditor(skill.id)}
|
||||
>
|
||||
<div className="conversation-title">{skill.name}</div>
|
||||
<div className="conversation-time">
|
||||
<span className={`status ${skillStatusMap[skill.status]?.className || 'status-stopped'}`}>
|
||||
{skillStatusMap[skill.status]?.text || skill.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="chat-sidebar-nav">
|
||||
<SidebarNavItem
|
||||
icon={<FiHome />}
|
||||
label="总览"
|
||||
active={currentPage === 'overview'}
|
||||
onClick={() => switchPage('overview')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FaPuzzlePiece />}
|
||||
label="我的技能"
|
||||
active={currentPage === 'mySkills'}
|
||||
onClick={() => switchPage('mySkills')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiTerminal />}
|
||||
label="开发文档"
|
||||
active={currentPage === 'devDocs'}
|
||||
onClick={() => switchPage('devDocs')}
|
||||
/>
|
||||
</div>
|
||||
<SidebarUser onClick={() => switchPage('devAccount')} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
sidebar={sidebar}
|
||||
headerTitle={getPageTitle()}
|
||||
sidebarClassName="chat-sidebar"
|
||||
>
|
||||
{renderPage()}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeveloperPage;
|
||||
@@ -1,33 +1,10 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FiSettings, FiCode, FiUsers, FiMonitor, FiList, FiLogIn } from 'react-icons/fi';
|
||||
import { FiMonitor, FiList } from 'react-icons/fi';
|
||||
import { FaRobot, FaPuzzlePiece } from 'react-icons/fa';
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="home-layout">
|
||||
<header className="home-header">
|
||||
<div className="home-logo">
|
||||
<div className="sidebar-logo-icon">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
GrandClaw
|
||||
</div>
|
||||
<nav className="home-nav">
|
||||
<Link to="/console" state={{ fromHome: true }}>
|
||||
<FiSettings /> 工作台
|
||||
</Link>
|
||||
<Link to="/developer?init=true" state={{ fromHome: true }}>
|
||||
<FiCode /> 开发台
|
||||
</Link>
|
||||
<Link to="/admin" state={{ fromHome: true }}>
|
||||
<FiUsers /> 管理台
|
||||
</Link>
|
||||
<Link to="/login" className="home-nav-login">
|
||||
<FiLogIn /> 登录
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="home-main">
|
||||
<div className="home-badge">
|
||||
<span className="home-badge-dot"></span>
|
||||
@@ -38,7 +15,7 @@ function HomePage() {
|
||||
基于容器化实例的 智能助手平台,提供租户隔离、技能市场、安全审计等核心能力
|
||||
</p>
|
||||
<div className="home-buttons">
|
||||
<Link to="/console" className="home-btn primary" state={{ fromHome: true }}>
|
||||
<Link to="/console" className="home-btn primary">
|
||||
<FaRobot /> 进入工作台
|
||||
</Link>
|
||||
</div>
|
||||
@@ -66,9 +43,6 @@ function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="home-footer">
|
||||
© 2026 GrandClaw Team · 前端原型演示
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ function LoginPage() {
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', padding: '11px', fontSize: '14px', fontWeight: '600' }}
|
||||
onClick={() => navigate('/console', { state: { fromHome: true } })}
|
||||
onClick={() => navigate('/console')}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import ListSelector from '../../components/ListSelector.jsx';
|
||||
import { availableLeaders } from '../../data/adminData.js';
|
||||
import { api } from '../../services/api.js';
|
||||
|
||||
function AddDepartmentPage({ onBack, editData }) {
|
||||
function AddDepartmentPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const editData = id ? api.admin.departments.getById(Number(id)) : null;
|
||||
const isEdit = !!editData;
|
||||
const [name, setName] = useState(editData?.name || '');
|
||||
const [description, setDescription] = useState(editData?.description || '');
|
||||
@@ -21,10 +26,15 @@ function AddDepartmentPage({ onBack, editData }) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">{isEdit ? '编辑部门' : '新增部门'}</div>
|
||||
<>
|
||||
<div className="page-back-btn" onClick={() => navigate('/admin/departments')}>
|
||||
<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>
|
||||
@@ -48,11 +58,12 @@ function AddDepartmentPage({ onBack, editData }) {
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn" onClick={() => navigate('/admin/departments')}>取消</button>
|
||||
<button className="btn btn-primary">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
231
src/pages/admin/AddModelConfigPage.jsx
Normal file
231
src/pages/admin/AddModelConfigPage.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
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 AddModelConfigPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const editData = id ? api.admin.modelConfigs.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.admin.modelConfigs.update(editData.id, configData);
|
||||
} else {
|
||||
api.admin.modelConfigs.create(configData);
|
||||
}
|
||||
|
||||
navigate('/admin/models');
|
||||
};
|
||||
|
||||
// 获取当前类型的字段定义
|
||||
const currentFields = getConfigFields(configType);
|
||||
const configTypeList = getConfigTypeList();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={() => navigate('/admin/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('/admin/models')}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleSave}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddModelConfigPage;
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import ListSelector from '../../components/ListSelector.jsx';
|
||||
import { availableLeaders } from '../../data/adminData.js';
|
||||
import { api } from '../../services/api.js';
|
||||
|
||||
function AddProjectPage({ onBack, editData }) {
|
||||
function AddProjectPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const editData = id ? api.admin.projects.getById(Number(id)) : null;
|
||||
const isEdit = !!editData;
|
||||
const [name, setName] = useState(editData?.name || '');
|
||||
const [description, setDescription] = useState(editData?.description || '');
|
||||
@@ -21,10 +26,15 @@ function AddProjectPage({ onBack, editData }) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">{isEdit ? '编辑项目' : '新增项目'}</div>
|
||||
<>
|
||||
<div className="page-back-btn" onClick={() => navigate('/admin/projects')}>
|
||||
<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>
|
||||
@@ -48,11 +58,12 @@ function AddProjectPage({ onBack, editData }) {
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn" onClick={() => navigate('/admin/projects')}>取消</button>
|
||||
<button className="btn btn-primary">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import ListSelector from '../../components/ListSelector.jsx';
|
||||
import { availableDepartments } from '../../data/adminData.js';
|
||||
import { api } from '../../services/api.js';
|
||||
|
||||
function AddUserPage({ onBack, editData }) {
|
||||
function AddUserPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const editData = id ? api.admin.users.getById(Number(id)) : null;
|
||||
const isEdit = !!editData;
|
||||
const [name, setName] = useState(editData?.name || '');
|
||||
const [role, setRole] = useState(editData?.role || '成员');
|
||||
@@ -23,10 +28,15 @@ function AddUserPage({ onBack, editData }) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">{isEdit ? '编辑用户' : '新增用户'}</div>
|
||||
<>
|
||||
<div className="page-back-btn" onClick={() => navigate('/admin/users')}>
|
||||
<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>
|
||||
@@ -62,11 +72,12 @@ function AddUserPage({ onBack, editData }) {
|
||||
<input type="tel" className="form-control" placeholder="请输入手机号" value={phone} onChange={e => setPhone(e.target.value)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn" onClick={() => navigate('/admin/users')}>取消</button>
|
||||
<button className="btn btn-primary">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../services/api.js';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
|
||||
@@ -7,7 +8,8 @@ function StatusTag({ status }) {
|
||||
return <span className={`status ${statusClass}`}>{status}</span>;
|
||||
}
|
||||
|
||||
function AdminProjectsPage({ onAdd, onEdit }) {
|
||||
function AdminProjectsPage() {
|
||||
const navigate = useNavigate();
|
||||
const sourceData = api.admin.projects.list();
|
||||
const [filters, setFilters] = useState({ keyword: '', status: '' });
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
@@ -71,7 +73,7 @@ function AdminProjectsPage({ onAdd, onEdit }) {
|
||||
<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={onAdd}>新增项目</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/admin/projects/add')}>新增项目</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="table-wrapper">
|
||||
@@ -84,7 +86,7 @@ function AdminProjectsPage({ onAdd, onEdit }) {
|
||||
<th>成员数</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th style={{ width: '200px' }}>操作</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -96,10 +98,10 @@ function AdminProjectsPage({ onAdd, onEdit }) {
|
||||
<td>{project.members} 人</td>
|
||||
<td><StatusTag status={project.status} /></td>
|
||||
<td>{project.createTime}</td>
|
||||
<td style={{ width: '200px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<td className="col-actions">
|
||||
<div className="table-actions">
|
||||
<button className={`text-btn ${project.status === '正常' ? 'text-btn-danger' : 'text-btn-primary'}`}>{project.status === '正常' ? '禁用' : '启用'}</button>
|
||||
<button className="text-btn text-btn-primary" onClick={() => onEdit(project)}>编辑</button>
|
||||
<button className="text-btn text-btn-primary" onClick={() => navigate(`/admin/projects/${project.id}/edit`)}>编辑</button>
|
||||
<button className="text-btn text-btn-danger" onClick={() => setDeleteTarget(project)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../services/api.js';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
|
||||
@@ -7,7 +8,8 @@ function StatusTag({ status }) {
|
||||
return <span className={`status ${statusClass}`}>{status}</span>;
|
||||
}
|
||||
|
||||
function DepartmentsPage({ onAdd, onEdit }) {
|
||||
function DepartmentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const sourceData = api.admin.departments.list();
|
||||
const [filters, setFilters] = useState({ keyword: '', status: '' });
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
@@ -71,7 +73,7 @@ function DepartmentsPage({ onAdd, onEdit }) {
|
||||
<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={onAdd}>新增部门</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/admin/departments/add')}>新增部门</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="table-wrapper">
|
||||
@@ -84,7 +86,7 @@ function DepartmentsPage({ onAdd, onEdit }) {
|
||||
<th>成员数</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th style={{ width: '200px' }}>操作</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -96,10 +98,10 @@ function DepartmentsPage({ onAdd, onEdit }) {
|
||||
<td>{dept.members} 人</td>
|
||||
<td><StatusTag status={dept.status} /></td>
|
||||
<td>{dept.createTime}</td>
|
||||
<td style={{ width: '200px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<td className="col-actions">
|
||||
<div className="table-actions">
|
||||
<button className={`text-btn ${dept.status === '正常' ? 'text-btn-danger' : 'text-btn-primary'}`}>{dept.status === '正常' ? '禁用' : '启用'}</button>
|
||||
<button className="text-btn text-btn-primary" onClick={() => onEdit(dept)}>编辑</button>
|
||||
<button className="text-btn text-btn-primary" onClick={() => navigate(`/admin/departments/${dept.id}/edit`)}>编辑</button>
|
||||
<button className="text-btn text-btn-danger" onClick={() => setDeleteTarget(dept)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
163
src/pages/admin/ModelConfigsPage.jsx
Normal file
163
src/pages/admin/ModelConfigsPage.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
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 ModelConfigsPage() {
|
||||
const navigate = useNavigate();
|
||||
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) => {
|
||||
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) => {
|
||||
if (config.isActive) {
|
||||
setSelectedConfig(config);
|
||||
setShowDeleteBlockedModal(true);
|
||||
return;
|
||||
}
|
||||
setSelectedConfig(config);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (selectedConfig) {
|
||||
api.admin.modelConfigs.delete(selectedConfig.id);
|
||||
setConfigs([...api.admin.modelConfigs.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('/admin/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(`/admin/models/${config.id}/edit`)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="text-btn text-btn-danger"
|
||||
onClick={() => handleDeleteClick(config)}
|
||||
style={config.isActive ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||||
>
|
||||
删除
|
||||
</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>
|
||||
|
||||
<Modal
|
||||
visible={showDeleteBlockedModal}
|
||||
title="无法删除"
|
||||
onConfirm={() => {
|
||||
setShowDeleteBlockedModal(false);
|
||||
setSelectedConfig(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowDeleteBlockedModal(false);
|
||||
setSelectedConfig(null);
|
||||
}}
|
||||
confirmText="我知道了"
|
||||
cancelText=""
|
||||
>
|
||||
<p>平台默认模型不允许删除。</p>
|
||||
<p>请先将另一个配置设为默认,然后再删除此配置。</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelConfigsPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../services/api.js';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
|
||||
@@ -12,7 +13,8 @@ function RoleTag({ role }) {
|
||||
return <span className={`status ${roleClass}`}>{role}</span>;
|
||||
}
|
||||
|
||||
function UsersPage({ onAdd, onEdit }) {
|
||||
function UsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const sourceData = api.admin.users.list();
|
||||
const [filters, setFilters] = useState({ keyword: '', department: '', status: '' });
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
@@ -94,7 +96,7 @@ function UsersPage({ onAdd, onEdit }) {
|
||||
<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={onAdd}>新增用户</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/admin/users/add')}>新增用户</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="table-wrapper">
|
||||
@@ -108,7 +110,7 @@ function UsersPage({ onAdd, onEdit }) {
|
||||
<th>手机号</th>
|
||||
<th>状态</th>
|
||||
<th>最后登录</th>
|
||||
<th style={{ width: '200px' }}>操作</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -121,10 +123,10 @@ function UsersPage({ onAdd, onEdit }) {
|
||||
<td>{user.phone}</td>
|
||||
<td><StatusTag status={user.status} /></td>
|
||||
<td>{user.lastLogin}</td>
|
||||
<td style={{ width: '200px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<td className="col-actions">
|
||||
<div className="table-actions">
|
||||
<button className={`text-btn ${user.status === '正常' ? 'text-btn-danger' : 'text-btn-primary'}`}>{user.status === '正常' ? '禁用' : '启用'}</button>
|
||||
<button className="text-btn text-btn-primary" onClick={() => onEdit(user)}>编辑</button>
|
||||
<button className="text-btn text-btn-primary" onClick={() => navigate(`/admin/users/${user.id}/edit`)}>编辑</button>
|
||||
<button className="text-btn text-btn-danger" onClick={() => setDeleteTarget(user)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function AccountPage() {
|
||||
const [profileToast, setProfileToast] = useState(null);
|
||||
const [passwordErrors, setPasswordErrors] = useState({});
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const handleProfileSave = () => {
|
||||
setProfileToast({ type: 'success', message: '保存成功' });
|
||||
setTimeout(() => setProfileToast(null), 3000);
|
||||
};
|
||||
|
||||
const handlePasswordChange = (field, value) => {
|
||||
setPasswordForm(prev => ({ ...prev, [field]: value }));
|
||||
setPasswordErrors(prev => ({ ...prev, [field]: '' }));
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = () => {
|
||||
const errors = {};
|
||||
if (!passwordForm.currentPassword) {
|
||||
errors.currentPassword = '请输入当前密码';
|
||||
}
|
||||
if (!passwordForm.newPassword) {
|
||||
errors.newPassword = '请输入新密码';
|
||||
}
|
||||
if (!passwordForm.confirmPassword) {
|
||||
errors.confirmPassword = '请再次输入新密码';
|
||||
} else if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
errors.confirmPassword = '两次输入的密码不一致';
|
||||
}
|
||||
setPasswordErrors(errors);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">账号信息</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{/* 头像区域 */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '24px', paddingBottom: '24px', borderBottom: '1px solid var(--color-border-2)' }}>
|
||||
<div style={{
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #3B82F6, #8B5CF6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '36px',
|
||||
margin: '0 auto 12px'
|
||||
}}>张</div>
|
||||
<button className="btn btn-sm">更换头像</button>
|
||||
</div>
|
||||
{/* 表单区域 */}
|
||||
<div className="form-row">
|
||||
<div className="form-col">
|
||||
<div className="form-group">
|
||||
<label className="form-label">用户名</label>
|
||||
<input type="text" className="form-control" defaultValue="zhangsan" readOnly style={{ background: '#F8FAFC' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<div className="form-group">
|
||||
<label className="form-label">姓名</label>
|
||||
<input type="text" className="form-control" defaultValue="张三" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-col">
|
||||
<div className="form-group">
|
||||
<label className="form-label">邮箱</label>
|
||||
<input type="email" className="form-control" defaultValue="zhangsan@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<div className="form-group">
|
||||
<label className="form-label">手机号</label>
|
||||
<input type="text" className="form-control" defaultValue="138****8888" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">所属部门</label>
|
||||
<input type="text" className="form-control" defaultValue="AI 产品部" readOnly style={{ background: '#F8FAFC' }} />
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleProfileSave}>保存修改</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">修改密码</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className={`form-control ${passwordErrors.currentPassword ? 'is-invalid' : ''}`}
|
||||
placeholder="请输入当前密码"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={e => handlePasswordChange('currentPassword', e.target.value)}
|
||||
/>
|
||||
{passwordErrors.currentPassword && (
|
||||
<div className="form-error">{passwordErrors.currentPassword}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className={`form-control ${passwordErrors.newPassword ? 'is-invalid' : ''}`}
|
||||
placeholder="请输入新密码"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={e => handlePasswordChange('newPassword', e.target.value)}
|
||||
/>
|
||||
{passwordErrors.newPassword && (
|
||||
<div className="form-error">{passwordErrors.newPassword}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className={`form-control ${passwordErrors.confirmPassword ? 'is-invalid' : ''}`}
|
||||
placeholder="请再次输入新密码"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={e => handlePasswordChange('confirmPassword', e.target.value)}
|
||||
/>
|
||||
{passwordErrors.confirmPassword && (
|
||||
<div className="form-error">{passwordErrors.confirmPassword}</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handlePasswordSubmit}>更新密码</button>
|
||||
</div>
|
||||
</div>
|
||||
<Toast
|
||||
visible={!!profileToast}
|
||||
type={profileToast?.type}
|
||||
message={profileToast?.message}
|
||||
onClose={() => setProfileToast(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ListSelector from '../../components/ListSelector.jsx';
|
||||
|
||||
const availableMembers = [
|
||||
@@ -12,7 +13,8 @@ const availableMembers = [
|
||||
{ id: 8, name: '杨十八', department: '数据分析部', email: 'yangshiba@example.com' }
|
||||
];
|
||||
|
||||
function AddMemberPage({ onBack }) {
|
||||
function AddMemberPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedMembers, setSelectedMembers] = useState([]);
|
||||
|
||||
const memberColumns = [
|
||||
@@ -30,29 +32,35 @@ function AddMemberPage({ onBack }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<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={onBack}>返回列表</button>
|
||||
<>
|
||||
<div className="page-back-btn" onClick={() => navigate('/console/project/members')}>
|
||||
<span>←</span>
|
||||
<span>返回成员列表</span>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ListSelector
|
||||
data={availableMembers}
|
||||
selectedIds={selectedMembers}
|
||||
onChange={setSelectedMembers}
|
||||
searchPlaceholder="搜索成员姓名、部门、邮箱..."
|
||||
columns={memberColumns}
|
||||
multiSelect={true}
|
||||
selectedLabel={selectedLabel}
|
||||
onClearSelected={() => setSelectedMembers([])}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}>
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={selectedMembers.length === 0}>
|
||||
添加选中成员 ({selectedMembers.length})
|
||||
</button>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">增加成员</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ListSelector
|
||||
data={availableMembers}
|
||||
selectedIds={selectedMembers}
|
||||
onChange={setSelectedMembers}
|
||||
searchPlaceholder="搜索成员姓名、部门、邮箱..."
|
||||
columns={memberColumns}
|
||||
multiSelect={true}
|
||||
selectedLabel={selectedLabel}
|
||||
onClearSelected={() => setSelectedMembers([])}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '16px' }}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
203
src/pages/console/AddProjectModelConfigPage.jsx
Normal file
203
src/pages/console/AddProjectModelConfigPage.jsx
Normal 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;
|
||||
203
src/pages/console/AddUserModelConfigPage.jsx
Normal file
203
src/pages/console/AddUserModelConfigPage.jsx
Normal 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;
|
||||
@@ -1,69 +1,242 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getChatScenes } from '../../data/conversations.js';
|
||||
import { FiPaperclip, FiCode, FiSend } from 'react-icons/fi';
|
||||
import { FiPaperclip, FiCode, FiSend, FiChevronDown } from 'react-icons/fi';
|
||||
import { api } from '../../services/api.js';
|
||||
import WorkspaceSidebar, { WorkspaceToggleBtn } from '../../components/workspace/WorkspaceSidebar.jsx';
|
||||
|
||||
function ChatPage({ scene }) {
|
||||
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();
|
||||
const currentScene = scene || 'welcome';
|
||||
const chatScenes = getChatScenes();
|
||||
const html = chatScenes[scene] || '';
|
||||
const html = chatScenes[currentScene] || '';
|
||||
const chatMessagesRef = useRef(null);
|
||||
const [isCompact, setIsCompact] = useState(false);
|
||||
const [workspaceOpen, setWorkspaceOpen] = useState(false); // 工作空间侧边栏状态
|
||||
|
||||
const defaultPlatformModel = api.admin.modelConfigs.list().find(c => c.isActive);
|
||||
const defaultProjectModel = api.consoleModels.project.list().find(c => c.isActive);
|
||||
const defaultUserModel = api.consoleModels.user.list().find(c => c.isActive);
|
||||
const initialModel = defaultUserModel || defaultProjectModel || defaultPlatformModel;
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState(() => {
|
||||
if (!initialModel) return null;
|
||||
const level = defaultUserModel ? 'user' : (defaultProjectModel ? 'project' : 'platform');
|
||||
return { id: initialModel.id, name: initialModel.name, level, isDefault: true };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatMessagesRef.current) return;
|
||||
|
||||
const thinkingElements = chatMessagesRef.current.querySelectorAll('.message-thinking');
|
||||
const blockElements = chatMessagesRef.current.querySelectorAll('.message-block');
|
||||
|
||||
const handleClick = (event) => {
|
||||
const thinkingElement = event.currentTarget;
|
||||
thinkingElement.classList.toggle('expanded');
|
||||
const blockElement = event.currentTarget;
|
||||
blockElement.classList.toggle('expanded');
|
||||
};
|
||||
|
||||
thinkingElements.forEach(el => {
|
||||
blockElements.forEach(el => {
|
||||
el.addEventListener('click', handleClick);
|
||||
el.style.cursor = 'pointer';
|
||||
});
|
||||
|
||||
return () => {
|
||||
thinkingElements.forEach(el => {
|
||||
blockElements.forEach(el => {
|
||||
el.removeEventListener('click', handleClick);
|
||||
});
|
||||
};
|
||||
}, [scene, html]); // 依赖场景和html内容
|
||||
}, [scene, html]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsCompact(window.innerWidth >= 480 && window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener('resize', checkScreenSize);
|
||||
return () => window.removeEventListener('resize', checkScreenSize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="chat-layout">
|
||||
<div className="chat-content">
|
||||
<div className="chat-messages">
|
||||
<div ref={chatMessagesRef} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="chat-input-container">
|
||||
<div className="chat-input-box">
|
||||
<div className="chat-input-main">
|
||||
<textarea
|
||||
className="chat-input"
|
||||
placeholder="输入消息... Enter 发送,Shift+Enter 换行"
|
||||
rows="1"
|
||||
<div className="chat-main">
|
||||
{/* 工作空间展开按钮(侧边栏关闭时显示) */}
|
||||
{!workspaceOpen && (
|
||||
<WorkspaceToggleBtn onClick={() => setWorkspaceOpen(true)} />
|
||||
)}
|
||||
|
||||
<div className="chat-content">
|
||||
<div className="chat-messages">
|
||||
<div ref={chatMessagesRef} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="chat-input-container">
|
||||
<div className="chat-input-box chat-input-box--horizontal">
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={setSelectedModel}
|
||||
variant={isCompact ? 'compact' : 'standard'}
|
||||
/>
|
||||
<div className="chat-input-actions">
|
||||
<div className="chat-input-tools">
|
||||
<div className="chat-input-tool" title="上传文件">
|
||||
<FiPaperclip />
|
||||
</div>
|
||||
<div className="chat-input-tool" title="代码块">
|
||||
<FiCode />
|
||||
<div className="chat-input-main">
|
||||
<textarea
|
||||
className="chat-input"
|
||||
placeholder="输入消息... Enter 发送,Shift+Enter 换行"
|
||||
rows="1"
|
||||
/>
|
||||
<div className="chat-input-actions">
|
||||
<div className="chat-input-tools">
|
||||
<div className="chat-input-tool" title="上传文件">
|
||||
<FiPaperclip />
|
||||
</div>
|
||||
<div className="chat-input-tool" title="代码块">
|
||||
<FiCode />
|
||||
</div>
|
||||
</div>
|
||||
<button className="chat-send-btn">
|
||||
<FiSend />
|
||||
</button>
|
||||
</div>
|
||||
<button className="chat-send-btn">
|
||||
<FiSend />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工作空间侧边栏 */}
|
||||
<WorkspaceSidebar
|
||||
isOpen={workspaceOpen}
|
||||
onClose={() => setWorkspaceOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatPage;
|
||||
export default ChatPage;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { FiChevronLeft, FiFile } from 'react-icons/fi';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { FiFile } from 'react-icons/fi';
|
||||
import { pendingVersionReviews, pendingUnlistReviews, skillFiles } from '../../data/skills.js';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function ConsoleReviewDetailPage({ type, reviewId, onBack }) {
|
||||
function ConsoleReviewDetailPage() {
|
||||
const { type, reviewId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
|
||||
const versionReview = type === 'version' ? pendingVersionReviews.find(r => r.id === reviewId) : null;
|
||||
const unlistReview = type === 'unlist' ? pendingUnlistReviews.find(r => r.id === reviewId) : null;
|
||||
const versionReview = type === 'version' ? pendingVersionReviews.find(r => r.id === Number(reviewId)) : null;
|
||||
const unlistReview = type === 'unlist' ? pendingUnlistReviews.find(r => r.id === Number(reviewId)) : null;
|
||||
|
||||
const review = versionReview || unlistReview;
|
||||
|
||||
@@ -20,7 +23,7 @@ function ConsoleReviewDetailPage({ type, reviewId, onBack }) {
|
||||
setToastMessage('审核通过');
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
onBack && onBack();
|
||||
onBack && navigate('/admin/reviews');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
@@ -28,14 +31,15 @@ function ConsoleReviewDetailPage({ type, reviewId, onBack }) {
|
||||
setToastMessage('已拒绝');
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
onBack && onBack();
|
||||
onBack && navigate('/admin/reviews');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="console-back-btn" onClick={onBack}>
|
||||
<FiChevronLeft /> 返回审核列表
|
||||
<div className="page-back-btn" onClick={() => navigate('/admin/reviews')}>
|
||||
<span>←</span>
|
||||
<span>返回审核列表</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { pendingVersionReviews, pendingUnlistReviews } from '../../data/skills.js';
|
||||
|
||||
function ConsoleReviewListPage({ onReviewClick }) {
|
||||
function ConsoleReviewListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState('version');
|
||||
|
||||
return (
|
||||
@@ -37,20 +39,20 @@ function ConsoleReviewListPage({ onReviewClick }) {
|
||||
<th>版本号</th>
|
||||
<th>提交时间</th>
|
||||
<th>开发者</th>
|
||||
<th style={{ width: '100px' }}>操作</th>
|
||||
<th className="col-actions--narrow">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingVersionReviews.map(review => (
|
||||
<tr key={review.id}>
|
||||
<td style={{ fontWeight: 600 }}>{review.skillName}</td>
|
||||
<td><strong>{review.skillName}</strong></td>
|
||||
<td>{review.version}</td>
|
||||
<td>{review.date}</td>
|
||||
<td>{review.developer}</td>
|
||||
<td>
|
||||
<button
|
||||
className="text-btn text-btn-primary"
|
||||
onClick={() => onReviewClick('version', review.id)}
|
||||
onClick={() => navigate(`/admin/reviews/version/${review.id}`)}
|
||||
>
|
||||
审核
|
||||
</button>
|
||||
@@ -71,20 +73,20 @@ function ConsoleReviewListPage({ onReviewClick }) {
|
||||
<th>当前版本</th>
|
||||
<th>申请时间</th>
|
||||
<th>开发者</th>
|
||||
<th style={{ width: '100px' }}>操作</th>
|
||||
<th className="col-actions--narrow">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingUnlistReviews.map(review => (
|
||||
<tr key={review.id}>
|
||||
<td style={{ fontWeight: 600 }}>{review.skillName}</td>
|
||||
<td><strong>{review.skillName}</strong></td>
|
||||
<td>{review.currentVersion}</td>
|
||||
<td>{review.date}</td>
|
||||
<td>{review.developer}</td>
|
||||
<td>
|
||||
<button
|
||||
className="text-btn text-btn-primary"
|
||||
onClick={() => onReviewClick('unlist', review.id)}
|
||||
onClick={() => navigate(`/admin/reviews/unlist/${review.id}`)}
|
||||
>
|
||||
审核
|
||||
</button>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
function MemberConfigPage({ onBack }) {
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
function MemberConfigPage() {
|
||||
const { memberId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<>
|
||||
<div className="page-back-btn" onClick={() => navigate('/console/project/members')}>
|
||||
<span>←</span>
|
||||
<span>返回成员列表</span>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">成员配置</div>
|
||||
<button className="btn btn-primary" onClick={onBack}>返回列表</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p>成员配置页面内容 (成员 ID: {memberId})</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p>成员配置页面内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemberConfigPage;
|
||||
export default MemberConfigPage;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FiUsers, FiSearch } from 'react-icons/fi';
|
||||
import { projectMembers } from '../../data/members.js';
|
||||
import EmptyState from '../../components/common/EmptyState.jsx';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
|
||||
function ProjectsPage({ onAddMember }) {
|
||||
function MembersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [members, setMembers] = useState(projectMembers);
|
||||
const [removeTarget, setRemoveTarget] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
@@ -82,7 +84,7 @@ function ProjectsPage({ onAddMember }) {
|
||||
<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={onAddMember}>增加成员</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/console/project/members/add')}>增加成员</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{filteredMembers.length > 0 ? (
|
||||
@@ -93,7 +95,7 @@ function ProjectsPage({ onAddMember }) {
|
||||
<tr>
|
||||
<th>成员</th>
|
||||
<th style={{ width: '100px' }}>角色</th>
|
||||
<th style={{ width: '120px' }}>操作</th>
|
||||
<th className="col-actions--narrow">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -108,9 +110,11 @@ function ProjectsPage({ onAddMember }) {
|
||||
</div>
|
||||
</td>
|
||||
<td><span className={`status ${member.role === '管理员' ? 'role-admin' : 'role-member'}`}>{member.role}</span></td>
|
||||
<td style={{ width: '120px' }}>
|
||||
<button className="text-btn text-btn-primary">配置</button>
|
||||
<button className="text-btn text-btn-danger" style={{ marginLeft: '8px' }} onClick={() => handleRemoveClick(member)}>移除</button>
|
||||
<td className="col-actions--narrow">
|
||||
<div className="table-actions">
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
@@ -146,4 +150,4 @@ function ProjectsPage({ onAddMember }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectsPage;
|
||||
export default MembersPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FiSearch } from 'react-icons/fi';
|
||||
import { FaBoxOpen } from 'react-icons/fa';
|
||||
import { skills, userSubscriptions } from '../../data/skills.js';
|
||||
@@ -6,7 +7,8 @@ import EmptyState from '../../components/common/EmptyState.jsx';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function MySkillsPage({ onConfig, onBack }) {
|
||||
function MySkillsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filters, setFilters] = useState({ keyword: '', category: '', status: '' });
|
||||
const [subscriptions, setSubscriptions] = useState(userSubscriptions);
|
||||
const [actionTarget, setActionTarget] = useState(null);
|
||||
@@ -203,7 +205,7 @@ function MySkillsPage({ onConfig, onBack }) {
|
||||
<th>描述</th>
|
||||
<th>分类</th>
|
||||
<th>状态</th>
|
||||
<th style={{ width: '200px' }}>操作</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -221,7 +223,7 @@ function MySkillsPage({ onConfig, onBack }) {
|
||||
<span>{cv?.publicName || skill?.name || '未知技能'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ color: '#64748B' }}>{cv?.publicDesc || skill?.desc || '-'}</td>
|
||||
<td className="cell-muted">{cv?.publicDesc || skill?.desc || '-'}</td>
|
||||
<td>{cv?.category || '-'}</td>
|
||||
<td>
|
||||
<span className={`status ${statusInfo.className}`}>
|
||||
@@ -229,7 +231,7 @@ function MySkillsPage({ onConfig, onBack }) {
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div className="table-actions">
|
||||
{!delisted && (
|
||||
<>
|
||||
{item.enabled ? (
|
||||
@@ -249,7 +251,7 @@ function MySkillsPage({ onConfig, onBack }) {
|
||||
)}
|
||||
<button
|
||||
className="text-btn text-btn-primary"
|
||||
onClick={() => onConfig(item.id)}
|
||||
onClick={() => navigate(`/console/my-skills/${item.id}/config`)}
|
||||
>
|
||||
配置
|
||||
</button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user