Compare commits
3 Commits
ce9ebe5784
...
1455cc850d
| Author | SHA1 | Date | |
|---|---|---|---|
| 1455cc850d | |||
| 9feb62da3f | |||
| 7f493aa921 |
127
README.md
127
README.md
@@ -96,13 +96,11 @@ pnpm build # 验证打包(不运行pnpm dev,会挂起流程)
|
||||
src/
|
||||
├── components/ # 组件库
|
||||
│ ├── common/ # 通用组件 (Modal, Toast, EmptyState等)
|
||||
│ ├── layout/ # 布局组件 (SidebarBrand, SidebarUser等)
|
||||
│ ├── Layout.jsx # 主布局组件
|
||||
│ ├── layout/ # 布局组件 (AppHeader, AppLayout, UserDropdown, ConsoleLayout, AdminLayout, DeveloperLayout等)
|
||||
│ ├── Layout.jsx # 主布局组件(sidebar + content)
|
||||
│ └── ListSelector.jsx # 列表选择器
|
||||
│
|
||||
├── contexts/ # 全局状态 (UserContext)
|
||||
├── hooks/ # 自定义Hook (usePageState, useNavigation)
|
||||
├── constants/ # 常量配置 (pages, storageKeys)
|
||||
├── services/ # 数据访问层 (api.js)
|
||||
├── data/ # 模拟数据
|
||||
│
|
||||
@@ -264,68 +262,90 @@ export default Example;
|
||||
|
||||
| 组件 | 路径 | 用途 |
|
||||
|------|------|------|
|
||||
| Layout | `components/Layout.jsx` | 主布局(sidebar+header+main) |
|
||||
| Layout | `components/Layout.jsx` | 主布局(sidebar + content) |
|
||||
| AppLayout | `components/layout/AppLayout.jsx` | 全局布局(header + main) |
|
||||
| AppHeader | `components/layout/AppHeader.jsx` | 统一导航头部 |
|
||||
| UserDropdown | `components/layout/UserDropdown.jsx` | 用户下拉菜单 |
|
||||
| Modal | `components/common/Modal.jsx` | 确认弹窗 |
|
||||
| Toast | `components/common/Toast.jsx` | 消息提示 |
|
||||
| EmptyState | `components/common/EmptyState.jsx` | 空状态展示 |
|
||||
| SearchBar | `components/common/SearchBar.jsx` | 搜索框 |
|
||||
| StatusBadge | `components/common/StatusBadge.jsx` | 状态标签 |
|
||||
| SidebarBrand | `components/layout/SidebarBrand.jsx` | 侧边栏品牌 |
|
||||
| SidebarUser | `components/layout/SidebarUser.jsx` | 侧边栏用户信息 |
|
||||
| SidebarNavItem | `components/layout/SidebarNavItem.jsx` | 侧边栏导航项 |
|
||||
|
||||
---
|
||||
|
||||
## 路由规范
|
||||
|
||||
### 顶层路由
|
||||
### 嵌套路由结构
|
||||
|
||||
所有页面通过正式路由导航,使用 HashRouter + 嵌套路由。
|
||||
|
||||
```jsx
|
||||
// App.jsx
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/console" element={<ConsolePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/developer" element={<DeveloperPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<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>
|
||||
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="overview" replace />} />
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
<Route path="departments" element={<DepartmentsPage />} />
|
||||
<Route path="departments/add" element={<AddDepartmentPage />} />
|
||||
<Route path="departments/:id/edit" element={<AddDepartmentPage />} />
|
||||
{/* ...更多子路由 */}
|
||||
</Route>
|
||||
|
||||
<Route path="/developer" element={<DeveloperLayout />}>
|
||||
<Route index element={<Navigate to="overview" replace />} />
|
||||
{/* ...子路由 */}
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
```
|
||||
|
||||
### 子页面路由
|
||||
**说明**:
|
||||
- `AppLayout` 包裹所有需要统一 Header 的页面
|
||||
- 登录页独立,不使用 `AppLayout`
|
||||
- 每个模块(Console/Admin/Developer)使用独立的 Layout 组件包裹 `<Outlet />`
|
||||
- 模块根路径自动重定向到默认子页面
|
||||
- 新增/编辑表单通过 URL 参数区分:`/admin/departments/add` vs `/admin/departments/:id/edit`
|
||||
|
||||
每个主页面内部管理子页面:
|
||||
### 子页面参数获取
|
||||
|
||||
```jsx
|
||||
// ConsolePage.jsx示例
|
||||
const [currentPage, setCurrentPage] = useState('chat');
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'chat': return <ChatPage />;
|
||||
case 'skills': return <SkillsPage />;
|
||||
// ...
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 页面配置
|
||||
|
||||
```javascript
|
||||
// constants/pages.js
|
||||
export const CONSOLE_PAGES = {
|
||||
chat: { title: '智能助手', icon: 'FiMessageSquare' },
|
||||
skills: { title: '技能市场', icon: 'FaPuzzlePiece' },
|
||||
// 使用 useParams 获取 URL 参数
|
||||
function SkillDetailPage() {
|
||||
const { skillId } = useParams();
|
||||
const skill = api.skills.getById(Number(skillId));
|
||||
// ...
|
||||
};
|
||||
}
|
||||
|
||||
// 使用 useNavigate 进行导航
|
||||
function SkillsPage() {
|
||||
const navigate = useNavigate();
|
||||
return <button onClick={() => navigate('/console/skills/1')}>查看</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### 新增页面流程
|
||||
|
||||
1. 在 `constants/pages.js` 添加页面配置
|
||||
2. 在父页面组件导入新页面
|
||||
3. 在 `renderPage()` 添加case分支
|
||||
4. 添加导航项(如需要)
|
||||
1. 在 `App.jsx` 添加路由定义
|
||||
2. 创建页面组件(使用 `useParams` / `useNavigate`)
|
||||
3. 在对应 Layout 的 sidebar 添加导航项
|
||||
4. 确保页面返回按钮使用固定路径导航
|
||||
|
||||
---
|
||||
|
||||
@@ -343,28 +363,17 @@ function Component() {
|
||||
}
|
||||
```
|
||||
|
||||
### 页面状态持久化
|
||||
### 页面状态由 URL 驱动
|
||||
|
||||
| 模块 | localStorage键 | 默认值 |
|
||||
|------|---------------|--------|
|
||||
| 工作台 | `console_currentPage` | `'chat'` |
|
||||
| 管理台 | `admin_currentPage` | `'overview'` |
|
||||
| 开发台 | `developer_currentPage` | `'overview'` |
|
||||
所有页面状态(当前页面、场景名、实体 ID)通过 URL 参数驱动,不依赖 localStorage。
|
||||
|
||||
### 自定义Hook
|
||||
|
||||
```javascript
|
||||
// usePageState - 页面状态持久化
|
||||
const { currentPage, setCurrentPage } = usePageState({
|
||||
storageKey: CONSOLE_KEYS.CURRENT_PAGE,
|
||||
defaultPage: 'chat',
|
||||
pageTitles: CONSOLE_PAGES,
|
||||
});
|
||||
|
||||
// useNavigation - 导航逻辑
|
||||
const { navigateToPage, extraData } = useNavigation(setCurrentPage);
|
||||
navigateToPage('skillDetail', { skillId: '1' });
|
||||
```
|
||||
| 状态类型 | 驱动方式 | 示例 |
|
||||
|---------|---------|------|
|
||||
| 当前页面 | URL 路径 | `/console/skills` |
|
||||
| 实体 ID | URL 参数 | `/console/skills/:skillId` |
|
||||
| 场景名 | URL 参数 | `/console/chat/:scene` |
|
||||
| 新增/编辑模式 | URL 参数有无 | `/admin/users/add` vs `/admin/users/:id/edit` |
|
||||
| 编辑数据 | `api.getById(Number(id))` | 通过 ID 重新获取 |
|
||||
|
||||
---
|
||||
|
||||
@@ -425,4 +434,4 @@ api.logs.filter({ user, type, status });
|
||||
|
||||
---
|
||||
|
||||
*最后更新:2026-03-26*
|
||||
*最后更新:2026-03-27*
|
||||
|
||||
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 个时,建议做一次全面审查
|
||||
- 新增规范前,先搜索现有规范名称和内容,确认是否有可复用/扩展的规范
|
||||
@@ -14,3 +14,7 @@ context: |
|
||||
- **优先阅读README.md**,README.md文档是项目的开发文档,记录代码结构和关键开发模式,优先读取获取上下文
|
||||
- 涉及页面/路由/组件/功能模块变更或技术栈调整时,同步更新README.md
|
||||
- Git提交: 仅中文; 格式为"类型: 简短描述",类型可选: feat(新功能)/fix(修复)/refactor(重构)/docs(文档)/style(格式)/test(测试)/chore(构建/工具); 多行描述空行后加详细说明; 禁创建git操作task
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
- 仔细审查每一个过往spec判断是否存在Modified Capabilities
|
||||
|
||||
@@ -76,6 +76,22 @@
|
||||
- **AND** 用户点击"更新密码"按钮
|
||||
- **THEN** 系统显示错误提示"两次输入的密码不一致"
|
||||
|
||||
### Requirement: 表单校验错误展示
|
||||
|
||||
系统 SHALL 在表单中展示校验错误状态,输入框边框变红并在下方显示错误提示文字。
|
||||
|
||||
#### Scenario: 必填项为空
|
||||
- **WHEN** 用户在修改密码表单中不输入当前密码直接点击"更新密码"
|
||||
- **THEN** 当前密码输入框边框变红,下方显示"请输入当前密码"错误提示
|
||||
|
||||
#### Scenario: 邮箱格式错误
|
||||
- **WHEN** 用户在个人信息表单中输入无效邮箱格式
|
||||
- **THEN** 邮箱输入框边框变红,下方显示"请输入有效的邮箱地址"错误提示
|
||||
|
||||
#### Scenario: 密码不一致
|
||||
- **WHEN** 用户在修改密码表单中输入两次不同的新密码
|
||||
- **THEN** 确认密码输入框边框变红,下方显示"两次输入的密码不一致"错误提示
|
||||
|
||||
### Requirement: 三端统一入口
|
||||
|
||||
系统 SHALL 在工作台、管理台、开发台的侧边栏用户信息区域提供账号管理入口。
|
||||
@@ -91,3 +107,18 @@
|
||||
#### 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** 显示空状态组件,提示"暂无匹配日志"
|
||||
@@ -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** 显示空状态组件,提示"暂无匹配日志"
|
||||
@@ -88,7 +88,7 @@
|
||||
- **THEN** 系统 SHALL 提供 `.tag--admin`、`.tag--member`、`.tag--developer` 等变体
|
||||
|
||||
### Requirement: 弹窗组件
|
||||
组件库 SHALL 提供模态弹窗组件。
|
||||
组件库 SHALL 提供模态弹窗组件,用于各类确认操作场景。
|
||||
|
||||
#### Scenario: 弹窗容器
|
||||
- **WHEN** 开发者需要显示模态弹窗
|
||||
@@ -98,13 +98,69 @@
|
||||
- **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 提示组件。
|
||||
组件库 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 提供分页导航组件。
|
||||
|
||||
@@ -113,12 +169,28 @@
|
||||
- **THEN** 系统 SHALL 提供 `.pagination` 类和 `.pagination__item`、`.pagination__item--active` 元素类
|
||||
|
||||
### Requirement: 空状态组件
|
||||
组件库 SHALL 提供空状态展示组件。
|
||||
组件库 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 提供切换开关组件。
|
||||
|
||||
@@ -194,11 +266,23 @@
|
||||
- **THEN** 系统 SHALL 提供 `.version-list__tag` 和 `.version-list__tag--current` 类
|
||||
|
||||
### Requirement: 返回按钮组件
|
||||
组件库 SHALL 提供统一的返回按钮样式。
|
||||
组件库 SHALL 提供统一的返回按钮样式,所有二级页面使用统一的样式类名。
|
||||
|
||||
#### Scenario: 返回按钮
|
||||
- **WHEN** 详情页需要返回按钮
|
||||
- **THEN** 系统 SHALL 提供 `.back-btn` 类,替换内联样式 `display: inline-flex; align-items: center; gap: 6px; color: #3B82F6`
|
||||
- **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 提供表单辅助提示样式。
|
||||
|
||||
@@ -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** 页面顶部展示绿色成功提示"创建成功"
|
||||
@@ -1,20 +0,0 @@
|
||||
## Purpose
|
||||
|
||||
定义表单校验错误状态的展示规范。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 表单校验错误状态展示
|
||||
系统 SHALL 在表单中展示校验错误状态。
|
||||
|
||||
#### Scenario: 必填项为空
|
||||
- **WHEN** 用户在修改密码表单中不输入当前密码直接点击"更新密码"
|
||||
- **THEN** 当前密码输入框边框变红,下方显示"请输入当前密码"错误提示
|
||||
|
||||
#### Scenario: 邮箱格式错误
|
||||
- **WHEN** 用户在个人信息表单中输入无效邮箱格式
|
||||
- **THEN** 邮箱输入框边框变红,下方显示"请输入有效的邮箱地址"错误提示
|
||||
|
||||
#### Scenario: 密码不一致
|
||||
- **WHEN** 用户在修改密码表单中输入两次不同的新密码
|
||||
- **THEN** 确认密码输入框边框变红,下方显示"两次输入的密码不一致"错误提示
|
||||
@@ -1,15 +1,28 @@
|
||||
## ADDED Requirements
|
||||
# Capability: 布局系统
|
||||
|
||||
## Purpose
|
||||
|
||||
布局系统提供应用级外壳布局、页面内容区骨架、页面导航行为和样式文件组织规范。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 应用外壳布局
|
||||
布局系统 SHALL 提供应用级外壳布局,包含侧边栏、顶部栏、主内容区。
|
||||
布局系统 SHALL 提供应用级外壳布局,包含全局头部、可选侧边栏、主内容区。
|
||||
|
||||
#### Scenario: 基础应用布局
|
||||
- **WHEN** 开发者需要管理控制台布局
|
||||
- **THEN** 系统 SHALL 提供 `.app-shell` 类,包含 `.app-shell__sidebar`、`.app-shell__header`、`.app-shell__main` 区域
|
||||
- **THEN** 系统 SHALL 提供 `.app-layout` 类,包含 `.app-layout__header`、`.app-layout__sidebar`(可选)、`.app-layout__main` 区域
|
||||
|
||||
#### Scenario: 侧边栏结构
|
||||
- **WHEN** 侧边栏需要品牌区、导航区、用户区
|
||||
- **THEN** 系统 SHALL 提供 `.sidebar__brand`、`.sidebar__nav`、`.sidebar__user` 元素类
|
||||
#### Scenario: 全局头部
|
||||
- **WHEN** 页面需要统一导航
|
||||
- **THEN** 系统 SHALL 在 `.app-layout__header` 区域渲染 `AppHeader` 组件
|
||||
- **AND** 头部固定在页面顶部
|
||||
|
||||
#### Scenario: 侧边栏可选
|
||||
- **WHEN** 页面需要侧边栏(如工作台、开发台、管理台)
|
||||
- **THEN** 系统 SHALL 渲染 `.app-layout__sidebar` 区域
|
||||
- **WHEN** 页面不需要侧边栏(如主页)
|
||||
- **THEN** 系统 SHALL 不渲染侧边栏区域
|
||||
|
||||
#### Scenario: 导航项
|
||||
- **WHEN** 侧边栏需要导航菜单
|
||||
@@ -27,16 +40,13 @@
|
||||
- **WHEN** 导航项需要文本和额外信息
|
||||
- **THEN** 系统 SHALL 提供 `.nav-item__text` 和 `.nav-item__meta` 元素类
|
||||
|
||||
#### Scenario: 顶部栏结构
|
||||
- **WHEN** 顶部栏需要左侧标题区和右侧操作区
|
||||
- **THEN** 系统 SHALL 提供 `.header__left`、`.header__right` 元素类
|
||||
|
||||
### Requirement: 聊天页面布局
|
||||
布局系统 SHALL 提供聊天页面专用布局。
|
||||
布局系统 SHALL 提供聊天页面专用布局,侧边栏不包含品牌区和用户区。
|
||||
|
||||
#### Scenario: 聊天布局容器
|
||||
- **WHEN** 开发者需要聊天界面布局
|
||||
- **THEN** 系统 SHALL 提供 `.chat-layout` 类,包含 `.chat-layout__header`、`.chat-layout__sidebar`、`.chat-layout__content`
|
||||
- **THEN** 系统 SHALL 提供 `.chat-layout` 类,包含 `.chat-layout__sidebar`、`.chat-layout__content`
|
||||
- **AND** 侧边栏不包含品牌区(`.sidebar-brand`)
|
||||
|
||||
#### Scenario: 会话列表
|
||||
- **WHEN** 侧边栏需要展示会话列表
|
||||
@@ -46,12 +56,23 @@
|
||||
- **WHEN** 需要展示消息和输入区
|
||||
- **THEN** 系统 SHALL 提供 `.chat-content__messages` 和 `.chat-content__input` 区域
|
||||
|
||||
#### Scenario: 侧边栏不含用户区
|
||||
- **WHEN** 聊天页面侧边栏渲染时
|
||||
- **THEN** 系统 SHALL 不显示用户状态区域
|
||||
- **AND** 用户状态统一在全局头部显示
|
||||
|
||||
### Requirement: 管理台布局
|
||||
布局系统 SHALL 提供管理台页面布局。
|
||||
布局系统 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 提供标准化的页面内容容器。
|
||||
@@ -88,3 +109,76 @@
|
||||
#### 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** 该文件包含页面特定的布局、组件、状态等样式,使用清晰的注释分节
|
||||
|
||||
@@ -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** 该文件包含页面特定的布局、组件、状态等样式,使用清晰的注释分节
|
||||
@@ -1,80 +0,0 @@
|
||||
# Capability: 页面导航返回按钮
|
||||
|
||||
提供统一的二级页面返回按钮样式规范,确保用户界面一致性。
|
||||
|
||||
### Requirement: 二级页面返回按钮样式统一
|
||||
|
||||
所有二级页面必须使用统一的 `page-back-btn` 样式类名作为返回按钮。
|
||||
|
||||
#### Scenario: 返回按钮位于页面左上角
|
||||
- **WHEN** 用户访问任意二级页面
|
||||
- **THEN** 返回按钮显示在页面内容区左上角
|
||||
- **AND** 返回按钮使用 `page-back-btn` 类名
|
||||
|
||||
#### Scenario: 返回按钮样式一致性
|
||||
- **WHEN** 用户查看返回按钮
|
||||
- **THEN** 按钮显示为蓝色主题色文字
|
||||
- **AND** 按钮带有左箭头图标
|
||||
- **AND** 文字为粗体
|
||||
- **AND** 与上级页面名称关联(如"返回技能市场")
|
||||
|
||||
### Requirement: 表单页面按钮组合完整
|
||||
|
||||
表单类二级页面必须同时具有左上角返回按钮和底部取消按钮。
|
||||
|
||||
#### Scenario: 表单页面包含返回和取消按钮
|
||||
- **WHEN** 用户访问表单类二级页面(如新增、编辑页面)
|
||||
- **THEN** 页面左上角显示返回按钮
|
||||
- **AND** 页面底部显示"取消"和"确定/保存"按钮组合
|
||||
|
||||
#### Scenario: 点击返回按钮返回上级
|
||||
- **WHEN** 用户点击左上角返回按钮
|
||||
- **THEN** 页面返回至上级页面
|
||||
- **AND** 不触发任何保存操作
|
||||
|
||||
#### Scenario: 点击取消按钮返回上级
|
||||
- **WHEN** 用户点击底部取消按钮
|
||||
- **THEN** 页面返回至上级页面
|
||||
- **AND** 不触发任何保存操作
|
||||
|
||||
### Requirement: 详情页面仅保留返回按钮
|
||||
|
||||
只读详情类二级页面仅需左上角返回按钮,无需底部取消按钮。
|
||||
|
||||
#### Scenario: 详情页返回按钮
|
||||
- **WHEN** 用户访问详情类二级页面(如任务详情、审核详情)
|
||||
- **THEN** 页面左上角显示返回按钮
|
||||
- **AND** 页面底部不显示取消按钮
|
||||
|
||||
### Requirement: 废弃旧样式类名
|
||||
|
||||
`dev-back-btn` 和 `console-back-btn` 样式类名不再使用,全部替换为 `page-back-btn`。
|
||||
|
||||
#### Scenario: 样式类名替换
|
||||
- **WHEN** 代码中使用返回按钮
|
||||
- **THEN** 必须使用 `page-back-btn` 类名
|
||||
- **AND** 不再使用 `dev-back-btn` 或 `console-back-btn`
|
||||
|
||||
### Requirement: 样式定义位置
|
||||
|
||||
`page-back-btn` 样式定义在组件样式层,而非页面样式层。
|
||||
|
||||
#### Scenario: 样式文件位置
|
||||
- **WHEN** 开发者查找返回按钮样式定义
|
||||
- **THEN** 样式定义位于 `src/styles/components/_index.scss`
|
||||
- **AND** 不位于任何页面级样式文件
|
||||
|
||||
### Requirement: 管理台账号管理页面导航
|
||||
|
||||
管理台 SHALL 在页面配置中包含账号管理页面配置。
|
||||
|
||||
#### Scenario: 管理台页面配置
|
||||
- **WHEN** 用户查看管理台页面配置
|
||||
- **THEN** 系统包含 account 页面配置
|
||||
- **AND** account 页面标题为"账号管理"
|
||||
- **AND** account 页面图标为 FiUser
|
||||
|
||||
#### Scenario: 管理台侧边栏用户点击
|
||||
- **WHEN** 用户在管理台侧边栏点击用户信息区域
|
||||
- **THEN** 系统导航到账号管理页面
|
||||
- **AND** 页面标题显示为"账号管理"
|
||||
@@ -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** 系统更新页面状态和附加数据状态
|
||||
|
||||
101
src/App.jsx
101
src/App.jsx
@@ -1,25 +1,108 @@
|
||||
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 ProjectsPage from './pages/console/ProjectsPage.jsx';
|
||||
import MemberConfigPage from './pages/console/MemberConfigPage.jsx';
|
||||
import AddMemberPage from './pages/console/AddMemberPage.jsx';
|
||||
import ConsoleReviewListPage from './pages/console/ConsoleReviewListPage.jsx';
|
||||
import ConsoleReviewDetailPage from './pages/console/ConsoleReviewDetailPage.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="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/members/add" element={<AddMemberPage />} />
|
||||
<Route path="projects/members/:memberId/config" element={<MemberConfigPage />} />
|
||||
</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>
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useUserContext } from '../../contexts/UserContext.jsx';
|
||||
import Toast from '../common/Toast.jsx';
|
||||
|
||||
function AccountPage() {
|
||||
const { user } = useUserContext();
|
||||
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'
|
||||
}}>{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>
|
||||
<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,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">
|
||||
<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 } 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={<FiActivity />}
|
||||
label="日志查询"
|
||||
active={location.pathname === '/admin/logs'}
|
||||
onClick={() => navigate('/admin/logs')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiSettings />}
|
||||
label="模型配置"
|
||||
active={isPathActive('/admin/models')}
|
||||
onClick={() => navigate('/admin/models')}
|
||||
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;
|
||||
99
src/components/layout/ConsoleLayout.jsx
Normal file
99
src/components/layout/ConsoleLayout.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Outlet, 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 '../Layout.jsx';
|
||||
import SidebarNavItem from './SidebarNavItem.jsx';
|
||||
import api from '../../services/api.js';
|
||||
|
||||
function ConsoleLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 从 URL 提取当前 scene
|
||||
const sceneMatch = location.pathname.match(/\/console\/chat\/(.+)$/);
|
||||
const currentScene = sceneMatch ? sceneMatch[1] : null;
|
||||
|
||||
// 判断是否在 chat 页面(需要全宽布局)
|
||||
const isChatPage = location.pathname === '/console/chat' ||
|
||||
location.pathname.startsWith('/console/chat/');
|
||||
|
||||
// sidebar 高亮判断
|
||||
const isPathActive = (basePath) => location.pathname.startsWith(basePath);
|
||||
|
||||
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' : ''}`}
|
||||
onClick={() => navigate(`/console/chat/${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={isPathActive('/console/skills')}
|
||||
onClick={() => navigate('/console/skills')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiBox />}
|
||||
label="我的技能"
|
||||
active={isPathActive('/console/my-skills')}
|
||||
onClick={() => navigate('/console/my-skills')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiClock />}
|
||||
label="定时任务"
|
||||
active={isPathActive('/console/tasks')}
|
||||
onClick={() => navigate('/console/tasks')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiList />}
|
||||
label="日志查询"
|
||||
active={isPathActive('/console/logs')}
|
||||
onClick={() => navigate('/console/logs')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiUsers />}
|
||||
label="项目管理"
|
||||
active={isPathActive('/console/projects')}
|
||||
onClick={() => navigate('/console/projects')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -1,61 +0,0 @@
|
||||
// 页面配置常量
|
||||
|
||||
/**
|
||||
* 工作台页面配置
|
||||
*/
|
||||
export const CONSOLE_PAGES = {
|
||||
chat: { title: '智能助手', icon: 'FiMessageSquare' },
|
||||
skills: { title: '技能市场', icon: 'FaPuzzlePiece' },
|
||||
skillDetail: { title: '技能详情', icon: null },
|
||||
mySkills: { title: '我的技能', icon: 'FiBox' },
|
||||
skillConfig: { 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' },
|
||||
modelConfigs: { title: '模型配置', icon: 'FiSettings' },
|
||||
adminLogs: { title: '日志查询', icon: 'FiActivity' },
|
||||
reviewList: { title: '审核管理', icon: 'FiCheckCircle' },
|
||||
reviewDetail: { title: '审核详情', icon: null },
|
||||
addDepartment: { title: '新增部门', icon: null },
|
||||
addUser: { title: '新增用户', icon: null },
|
||||
addProject: { title: '新增项目', icon: null },
|
||||
addModelConfig: { title: '新增配置', icon: null },
|
||||
account: { title: '账号管理', icon: 'FiUser' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 开发台页面配置
|
||||
*/
|
||||
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,25 +0,0 @@
|
||||
// localStorage 键名常量
|
||||
|
||||
/**
|
||||
* 工作台相关键名
|
||||
*/
|
||||
export const CONSOLE_KEYS = {
|
||||
CURRENT_PAGE: 'console_currentPage',
|
||||
CURRENT_SCENE: 'console_currentScene',
|
||||
};
|
||||
|
||||
/**
|
||||
* 管理台相关键名
|
||||
*/
|
||||
export const ADMIN_KEYS = {
|
||||
CURRENT_PAGE: 'admin_currentPage',
|
||||
MODEL_CONFIG_EDIT_DATA: 'admin_modelConfigEditData',
|
||||
};
|
||||
|
||||
/**
|
||||
* 开发台相关键名
|
||||
*/
|
||||
export const DEVELOPER_KEYS = {
|
||||
CURRENT_PAGE: 'developer_currentPage',
|
||||
CURRENT_SKILL_ID: 'developer_currentSkillId',
|
||||
};
|
||||
@@ -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,220 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { FiHome, FiBarChart2, FiUsers, FiList, FiCheckCircle, FiActivity, FiSettings } 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';
|
||||
import ModelConfigsPage from './admin/ModelConfigsPage.jsx';
|
||||
import AddModelConfigPage from './admin/AddModelConfigPage.jsx';
|
||||
import AccountPage from '../components/account/AccountPage.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}
|
||||
/>;
|
||||
case 'modelConfigs':
|
||||
return <ModelConfigsPage
|
||||
onAdd={() => navigateTo('addModelConfig')}
|
||||
onEdit={(config) => navigateTo('addModelConfig', config)}
|
||||
/>;
|
||||
case 'addModelConfig':
|
||||
return <AddModelConfigPage
|
||||
onBack={() => navigateTo('modelConfigs')}
|
||||
editData={editData}
|
||||
/>;
|
||||
case 'account':
|
||||
return <AccountPage />;
|
||||
default:
|
||||
return <div>Page not found</div>;
|
||||
}
|
||||
};
|
||||
|
||||
const getPageTitle = () => {
|
||||
if (editData && (currentPage === 'addDepartment' || currentPage === 'addUser' || currentPage === 'addProject' || currentPage === 'addModelConfig')) {
|
||||
const prefix = '编辑';
|
||||
const nameMap = { addDepartment: '部门', addUser: '用户', addProject: '项目', addModelConfig: '配置' };
|
||||
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"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiSettings />}
|
||||
label="模型配置"
|
||||
active={currentPage === 'modelConfigs' || currentPage === 'addModelConfig'}
|
||||
onClick={() => navigateTo('modelConfigs')}
|
||||
itemClassName="admin-nav-item"
|
||||
iconClassName="admin-nav-icon"
|
||||
textClassName="admin-nav-text"
|
||||
/>
|
||||
</nav>
|
||||
<SidebarUser
|
||||
onClick={() => navigateTo('account')}
|
||||
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 '../components/account/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 AccountPage from '../components/account/AccountPage.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 <AccountPage />;
|
||||
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 || '');
|
||||
@@ -22,7 +27,7 @@ function AddDepartmentPage({ onBack, editData }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/admin/departments')}>
|
||||
<span>←</span>
|
||||
<span>返回部门列表</span>
|
||||
</div>
|
||||
@@ -53,7 +58,7 @@ 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>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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({ onBack, editData }) {
|
||||
function AddModelConfigPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const editData = id ? api.admin.modelConfigs.getById(id) : null;
|
||||
const isEdit = !!editData;
|
||||
|
||||
// 基础信息
|
||||
@@ -98,7 +102,7 @@ function AddModelConfigPage({ onBack, editData }) {
|
||||
api.admin.modelConfigs.create(configData);
|
||||
}
|
||||
|
||||
onBack();
|
||||
navigate('/admin/models');
|
||||
};
|
||||
|
||||
// 获取当前类型的字段定义
|
||||
@@ -107,7 +111,7 @@ function AddModelConfigPage({ onBack, editData }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/admin/models')}>
|
||||
<span>←</span>
|
||||
<span>返回配置列表</span>
|
||||
</div>
|
||||
@@ -215,7 +219,7 @@ function AddModelConfigPage({ onBack, editData }) {
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn" onClick={() => navigate('/admin/models')}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleSave}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 || '');
|
||||
@@ -22,7 +27,7 @@ function AddProjectPage({ onBack, editData }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/admin/projects')}>
|
||||
<span>←</span>
|
||||
<span>返回项目列表</span>
|
||||
</div>
|
||||
@@ -53,7 +58,7 @@ 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>
|
||||
|
||||
@@ -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 || '成员');
|
||||
@@ -24,7 +29,7 @@ function AddUserPage({ onBack, editData }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/admin/users')}>
|
||||
<span>←</span>
|
||||
<span>返回用户列表</span>
|
||||
</div>
|
||||
@@ -67,7 +72,7 @@ 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>
|
||||
|
||||
@@ -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">
|
||||
@@ -99,7 +101,7 @@ function AdminProjectsPage({ onAdd, onEdit }) {
|
||||
<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">
|
||||
@@ -99,7 +101,7 @@ function DepartmentsPage({ onAdd, onEdit }) {
|
||||
<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>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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, getConfigSummary } from '../../data/configTypes.js';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
|
||||
function ModelConfigsPage({ onAdd, onEdit }) {
|
||||
function ModelConfigsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [configs, setConfigs] = useState(api.admin.modelConfigs.list());
|
||||
const [showSetActiveModal, setShowSetActiveModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
@@ -44,7 +46,7 @@ function ModelConfigsPage({ 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 className="btn btn-primary btn-sm" onClick={() => navigate('/admin/models/add')}>
|
||||
<FiPlus /> 新增配置
|
||||
</button>
|
||||
</div>
|
||||
@@ -85,7 +87,7 @@ function ModelConfigsPage({ onAdd, onEdit }) {
|
||||
)}
|
||||
<button
|
||||
className="text-btn text-btn-primary"
|
||||
onClick={() => onEdit(config)}
|
||||
onClick={() => navigate(`/admin/models/${config.id}/edit`)}
|
||||
disabled={config.isActive}
|
||||
title={config.isActive ? '生效中的配置不可编辑' : ''}
|
||||
style={config.isActive ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||||
|
||||
@@ -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">
|
||||
@@ -124,7 +126,7 @@ function UsersPage({ onAdd, onEdit }) {
|
||||
<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,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 = [
|
||||
@@ -31,7 +33,7 @@ function AddMemberPage({ onBack }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/console/projects')}>
|
||||
<span>←</span>
|
||||
<span>返回成员列表</span>
|
||||
</div>
|
||||
@@ -51,7 +53,7 @@ function AddMemberPage({ onBack }) {
|
||||
onClearSelected={() => setSelectedMembers([])}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '16px' }}>
|
||||
<button className="btn btn-secondary" onClick={onBack}>取消</button>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/console/projects')}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={selectedMembers.length === 0}>
|
||||
添加选中成员 ({selectedMembers.length})
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getChatScenes } from '../../data/conversations.js';
|
||||
import { FiPaperclip, FiCode, FiSend } from 'react-icons/fi';
|
||||
|
||||
function ChatPage({ scene }) {
|
||||
function ChatPage() {
|
||||
const { scene } = useParams();
|
||||
const currentScene = scene || 'welcome';
|
||||
const chatScenes = getChatScenes();
|
||||
const html = chatScenes[scene] || '';
|
||||
const html = chatScenes[currentScene] || '';
|
||||
const chatMessagesRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
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,13 +31,13 @@ function ConsoleReviewDetailPage({ type, reviewId, onBack }) {
|
||||
setToastMessage('已拒绝');
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
onBack && onBack();
|
||||
onBack && navigate('/admin/reviews');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/admin/reviews')}>
|
||||
<span>←</span>
|
||||
<span>返回审核列表</span>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
@@ -50,7 +52,7 @@ function ConsoleReviewListPage({ onReviewClick }) {
|
||||
<td>
|
||||
<button
|
||||
className="text-btn text-btn-primary"
|
||||
onClick={() => onReviewClick('version', review.id)}
|
||||
onClick={() => navigate(`/admin/reviews/version/${review.id}`)}
|
||||
>
|
||||
审核
|
||||
</button>
|
||||
@@ -84,7 +86,7 @@ function ConsoleReviewListPage({ onReviewClick }) {
|
||||
<td>
|
||||
<button
|
||||
className="text-btn text-btn-primary"
|
||||
onClick={() => onReviewClick('unlist', review.id)}
|
||||
onClick={() => navigate(`/admin/reviews/unlist/${review.id}`)}
|
||||
>
|
||||
审核
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
function MemberConfigPage({ onBack }) {
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
function MemberConfigPage() {
|
||||
const { memberId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/console/projects')}>
|
||||
<span>←</span>
|
||||
<span>返回成员列表</span>
|
||||
</div>
|
||||
@@ -10,11 +15,11 @@ function MemberConfigPage({ onBack }) {
|
||||
<div className="card-title">成员配置</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p>成员配置页面内容</p>
|
||||
<p>成员配置页面内容 (成员 ID: {memberId})</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemberConfigPage;
|
||||
export default MemberConfigPage;
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ProjectsPage() {
|
||||
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/projects/members/add')}>增加成员</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{filteredMembers.length > 0 ? (
|
||||
@@ -110,7 +112,7 @@ function ProjectsPage({ onAddMember }) {
|
||||
<td><span className={`status ${member.role === '管理员' ? 'role-admin' : 'role-member'}`}>{member.role}</span></td>
|
||||
<td className="col-actions--narrow">
|
||||
<div className="table-actions">
|
||||
<button className="text-btn text-btn-primary">配置</button>
|
||||
<button className="text-btn text-btn-primary" onClick={() => navigate(`/console/projects/members/${member.id}/config`)}>配置</button>
|
||||
<button className="text-btn text-btn-danger" onClick={() => handleRemoveClick(member)}>移除</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { FiPlus, FiX, FiUsers, FiStar, FiPackage } from 'react-icons/fi';
|
||||
import { skills, userSubscriptions } from '../../data/skills.js';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function SkillConfigPage({ subscriptionId, onBack }) {
|
||||
function SkillConfigPage() {
|
||||
const { subscriptionId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [subscriptions, setSubscriptions] = useState(userSubscriptions);
|
||||
const [subscription, setSubscription] = useState(null);
|
||||
const [skill, setSkill] = useState(null);
|
||||
@@ -12,7 +15,7 @@ function SkillConfigPage({ subscriptionId, onBack }) {
|
||||
const [toast, setToast] = useState({ visible: false, type: 'success', message: '' });
|
||||
|
||||
useEffect(() => {
|
||||
const sub = subscriptions.find(s => s.id === subscriptionId);
|
||||
const sub = subscriptions.find(s => s.id === Number(subscriptionId));
|
||||
if (sub) {
|
||||
setSubscription(sub);
|
||||
const skillData = skills.find(s => s.id === sub.skillId);
|
||||
@@ -82,7 +85,7 @@ function SkillConfigPage({ subscriptionId, onBack }) {
|
||||
|
||||
// 延迟返回
|
||||
setTimeout(() => {
|
||||
onBack();
|
||||
navigate('/console/my-skills');
|
||||
}, 500);
|
||||
};
|
||||
|
||||
@@ -94,7 +97,7 @@ function SkillConfigPage({ subscriptionId, onBack }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/console/my-skills')}>
|
||||
<span>←</span>
|
||||
<span>返回我的技能</span>
|
||||
</div>
|
||||
@@ -207,7 +210,7 @@ function SkillConfigPage({ subscriptionId, onBack }) {
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '16px', textAlign: 'right', display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<button className="btn btn-secondary" onClick={onBack}>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/console/my-skills')}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSave}>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { FiChevronLeft, FiFile, FiUsers, FiStar, FiPackage } from 'react-icons/fi';
|
||||
import { skills, skillFiles } from '../../data/skills.js';
|
||||
|
||||
function SkillDetailPage({ skillId, onBack }) {
|
||||
const skill = skills.find(s => s.id === skillId);
|
||||
function SkillDetailPage() {
|
||||
const { skillId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const skill = skills.find(s => s.id === Number(skillId));
|
||||
|
||||
if (!skill) {
|
||||
return <div>Skill not found</div>;
|
||||
@@ -12,7 +15,7 @@ function SkillDetailPage({ skillId, onBack }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dev-back-btn" onClick={onBack} style={{ marginBottom: '16px' }}>
|
||||
<div className="dev-back-btn" onClick={() => navigate('/console/skills')} style={{ marginBottom: '16px' }}>
|
||||
<FiChevronLeft /> 返回技能市场
|
||||
</div>
|
||||
{cv ? (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FiUser, FiStar, FiSearch } from 'react-icons/fi';
|
||||
import { FaBoxOpen } from 'react-icons/fa';
|
||||
import { skills, userSubscriptions } from '../../data/skills.js';
|
||||
@@ -46,7 +47,8 @@ function SkillCard({ skill, onClick, onSubscribe }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsPage({ onSkillClick }) {
|
||||
function SkillsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [sort, setSort] = useState('subs');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [subscriptions, setSubscriptions] = useState(userSubscriptions);
|
||||
@@ -140,7 +142,7 @@ function SkillsPage({ onSkillClick }) {
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onClick={() => onSkillClick(skill.id)}
|
||||
onClick={() => navigate(`/console/skills/${skill.id}`)}
|
||||
onSubscribe={handleSubscribeClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { scheduledTasks } from '../../data/tasks.js';
|
||||
|
||||
function TaskDetailPage({ taskId, onBack }) {
|
||||
const task = scheduledTasks.find(t => t.id === taskId);
|
||||
function TaskDetailPage() {
|
||||
const { taskId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const task = scheduledTasks.find(t => t.id === Number(taskId));
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/console/tasks')}>
|
||||
<span>←</span>
|
||||
<span>返回任务列表</span>
|
||||
</div>
|
||||
@@ -24,7 +27,7 @@ function TaskDetailPage({ taskId, onBack }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/console/tasks')}>
|
||||
<span>←</span>
|
||||
<span>返回任务列表</span>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FiClock } from 'react-icons/fi';
|
||||
import { scheduledTasks } from '../../data/tasks.js';
|
||||
import EmptyState from '../../components/common/EmptyState.jsx';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
|
||||
function TasksPage({ onViewDetail }) {
|
||||
function TasksPage() {
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = useState(scheduledTasks);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
|
||||
@@ -66,7 +68,7 @@ function TasksPage({ onViewDetail }) {
|
||||
<button className={`text-btn ${task.enabled ? 'text-btn-danger' : 'text-btn-primary'}`} onClick={() => toggleTask(task.id)}>
|
||||
{task.enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button className="text-btn text-btn-primary" style={{ marginLeft: '8px' }} onClick={() => onViewDetail(task.id)}>详情</button>
|
||||
<button className="text-btn text-btn-primary" style={{ marginLeft: '8px' }} onClick={() => navigate(`/console/tasks/${task.id}`)}>详情</button>
|
||||
<button className="text-btn text-btn-danger" style={{ marginLeft: '8px' }} onClick={() => handleDeleteClick(task)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FiAlertTriangle, FiInfo } from 'react-icons/fi';
|
||||
import { api } from '../../services/api.js';
|
||||
|
||||
function DevOverviewPage({ onSkillClick }) {
|
||||
function DevOverviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const data = api.developer.getOverview();
|
||||
|
||||
return (
|
||||
@@ -36,7 +38,7 @@ function DevOverviewPage({ onSkillClick }) {
|
||||
key={index}
|
||||
className={`anomaly-item ${item.status === 'rejected' ? 'anomaly-warning' : 'anomaly-info'}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onSkillClick && onSkillClick(item.skillId)}
|
||||
onClick={() => navigate(`/developer/my-skills/${item.skillId}/editor`)}
|
||||
>
|
||||
<span className="anomaly-icon">
|
||||
{item.status === 'rejected' ? <FiAlertTriangle /> : <FiInfo />}
|
||||
|
||||
@@ -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';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
@@ -10,7 +11,8 @@ const skillStatusMap = {
|
||||
unlisted: { text: '已下架', className: 'status-stopped' }
|
||||
};
|
||||
|
||||
function MySkillsPage({ onSkillClick }) {
|
||||
function MySkillsPage() {
|
||||
const navigate = useNavigate();
|
||||
const sourceData = api.developer.getMySkills();
|
||||
const [filters, setFilters] = useState({ keyword: '', status: '' });
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
@@ -100,7 +102,7 @@ function MySkillsPage({ onSkillClick }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredList.map(skill => (
|
||||
<tr key={skill.id} className="tr-clickable" onClick={() => onSkillClick(skill.id)}>
|
||||
<tr key={skill.id} className="tr-clickable" onClick={() => navigate(`/developer/my-skills/${skill.id}/editor`)}>
|
||||
<td>{skill.name}</td>
|
||||
<td>{skill.desc}</td>
|
||||
<td>
|
||||
@@ -110,7 +112,7 @@ function MySkillsPage({ onSkillClick }) {
|
||||
</td>
|
||||
<td>
|
||||
<div className="table-actions">
|
||||
<button className="text-btn text-btn-primary" onClick={e => { e.stopPropagation(); onSkillClick(skill.id); }}>
|
||||
<button className="text-btn text-btn-primary" onClick={e => { e.stopPropagation(); navigate(`/developer/my-skills/${skill.id}/editor`); }}>
|
||||
编辑
|
||||
</button>
|
||||
{skill.status === 'published' && (
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { FiUpload } from 'react-icons/fi';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function NewVersionPage({ skillName, onBack }) {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
onBack();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<span>←</span>
|
||||
<span>返回技能详情</span>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">上传新版本 — {skillName}</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label required">版本说明</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows="3"
|
||||
placeholder="请描述本次版本更新的内容"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label required">技能包上传</label>
|
||||
<div style={{ border: '2px dashed #E2E8F0', borderRadius: '8px', padding: '40px', textAlign: 'center', color: '#94A3B8' }}>
|
||||
<FiUpload size={48} style={{ marginBottom: '16px' }} />
|
||||
<div>点击或拖拽文件到此处上传</div>
|
||||
<div style={{ fontSize: '12px', marginTop: '8px' }}>支持 .zip 格式</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleSubmit}>提交审核</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toast
|
||||
visible={showToast}
|
||||
type="success"
|
||||
message="已提交审核"
|
||||
onClose={() => setShowToast(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewVersionPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { FiUpload, FiUsers, FiPackage, FiStar, FiRotateCcw } from 'react-icons/fi';
|
||||
import { api } from '../../services/api.js';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
@@ -18,8 +19,10 @@ const skillStatusMap = {
|
||||
unlisted: { text: '已下架', className: 'status-stopped' }
|
||||
};
|
||||
|
||||
function SkillEditorPage({ skillId, onBack, onUploadNewVersion, onUpdateInfo }) {
|
||||
const skill = api.developer.getSkillById(skillId);
|
||||
function SkillEditorPage() {
|
||||
const { skillId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const skill = api.developer.getSkillById(Number(skillId));
|
||||
const [deleteSkillModal, setDeleteSkillModal] = useState(false);
|
||||
const [unlistSkillModal, setUnlistSkillModal] = useState(false);
|
||||
const [deleteVersionTarget, setDeleteVersionTarget] = useState(null);
|
||||
@@ -50,7 +53,7 @@ function SkillEditorPage({ skillId, onBack, onUploadNewVersion, onUpdateInfo })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/developer/my-skills')}>
|
||||
<span>←</span>
|
||||
<span>返回我的技能</span>
|
||||
</div>
|
||||
@@ -64,7 +67,7 @@ function SkillEditorPage({ skillId, onBack, onUploadNewVersion, onUpdateInfo })
|
||||
{skillStatusMap[skill.status]?.text || skill.status}
|
||||
</span>
|
||||
<div className="skill-actions">
|
||||
<button className="btn btn-primary btn-sm" onClick={() => onUpdateInfo && onUpdateInfo(skill.id)}>编辑内部信息</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/developer/my-skills/${skillId}/update-info`)}>编辑内部信息</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="skill-desc-row">
|
||||
@@ -121,7 +124,7 @@ function SkillEditorPage({ skillId, onBack, onUploadNewVersion, onUpdateInfo })
|
||||
<div className="card-title">版本历史</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => onUploadNewVersion(skill)}
|
||||
onClick={() => navigate(`/developer/my-skills/${skillId}/new-version`)}
|
||||
disabled={skill.status === 'unlisting' || skill.status === 'unlisted' || skill.hasPendingReview}
|
||||
title={skill.status === 'unlisted' ? '已下架的技能不能上传新版本' : (skill.status === 'unlisting' ? '下架审核中的技能不能上传新版本' : (skill.hasPendingReview ? '存在审核中的版本,请先撤回后再上传新版本' : ''))}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../services/api.js';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function UpdateSkillInfoPage({ skill, onBack }) {
|
||||
function UpdateSkillInfoPage() {
|
||||
const { skillId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const skill = api.developer.getSkillById(Number(skillId));
|
||||
const [name, setName] = useState(skill?.name || '');
|
||||
const [desc, setDesc] = useState(skill?.desc || '');
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
@@ -9,13 +14,13 @@ function UpdateSkillInfoPage({ skill, onBack }) {
|
||||
const handleSave = () => {
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
onBack();
|
||||
navigate(`/developer/my-skills/${skillId}/editor`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate(`/developer/my-skills/${skillId}/editor`)}>
|
||||
<span>←</span>
|
||||
<span>返回技能详情</span>
|
||||
</div>
|
||||
@@ -48,7 +53,7 @@ function UpdateSkillInfoPage({ skill, onBack }) {
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn" onClick={() => navigate(`/developer/my-skills/${skillId}/editor`)}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleSave}>保存修改</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function UploadSkillPage({ onBack }) {
|
||||
function UploadSkillPage() {
|
||||
const navigate = useNavigate();
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const handleCreate = () => {
|
||||
@@ -10,7 +12,7 @@ function UploadSkillPage({ onBack }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate('/developer/my-skills')}>
|
||||
<span>←</span>
|
||||
<span>返回技能管理</span>
|
||||
</div>
|
||||
@@ -31,7 +33,7 @@ function UploadSkillPage({ onBack }) {
|
||||
<textarea className="form-control" rows="3" placeholder="请输入开发者内部技能描述" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn" onClick={() => navigate('/developer/my-skills')}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>创建技能</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { FiUpload, FiX } from 'react-icons/fi';
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../services/api.js';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
const ICON_OPTIONS = ['🌤️', '📊', '📝', '🔧', '💻', '📋', '🔍', '📈', '🎯', '⚡', '🌐', '🤖'];
|
||||
|
||||
function UploadVersionPage({ skill, onBack }) {
|
||||
function UploadVersionPage() {
|
||||
const { skillId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const skill = api.developer.getSkillById(Number(skillId));
|
||||
const categories = api.developer.getCategories();
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
@@ -38,13 +42,13 @@ function UploadVersionPage({ skill, onBack }) {
|
||||
const handleSubmit = () => {
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
onBack();
|
||||
navigate(`/developer/my-skills/${skillId}/editor`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-back-btn" onClick={onBack}>
|
||||
<div className="page-back-btn" onClick={() => navigate(`/developer/my-skills/${skillId}/editor`)}>
|
||||
<span>←</span>
|
||||
<span>返回技能详情</span>
|
||||
</div>
|
||||
@@ -162,7 +166,7 @@ function UploadVersionPage({ skill, onBack }) {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn" onClick={() => navigate(`/developer/my-skills/${skillId}/editor`)}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleSubmit}>提交审核</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
@forward 'password-input';
|
||||
@forward 'search-bar';
|
||||
@forward 'stat-card';
|
||||
@forward 'header';
|
||||
|
||||
.page-back-btn {
|
||||
display: inline-flex;
|
||||
|
||||
251
src/styles/components/header/_index.scss
Normal file
251
src/styles/components/header/_index.scss
Normal file
@@ -0,0 +1,251 @@
|
||||
// AppHeader 组件样式
|
||||
|
||||
@use '../../tokens' as *;
|
||||
|
||||
// 全局布局容器
|
||||
.app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
.app-layout__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// 头部容器
|
||||
.app-header {
|
||||
height: var(--header-height);
|
||||
background: var(--color-bg-1);
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: $z-index-header;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 左侧品牌区
|
||||
.app-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-header__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-header__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
// 右侧功能区
|
||||
.app-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// 导航区
|
||||
.app-header__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-header__nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.2s;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
// 用户状态区
|
||||
.app-header__user {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-header__user-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
}
|
||||
|
||||
.app-header__user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.app-header__user-arrow {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--color-text-3);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉菜单
|
||||
.app-header__dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 160px;
|
||||
background: var(--color-bg-1);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1);
|
||||
overflow: hidden;
|
||||
z-index: $z-index-modal;
|
||||
}
|
||||
|
||||
.app-header__dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-2);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端菜单
|
||||
.app-header__mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__mobile-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
}
|
||||
|
||||
.app-header__mobile-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 16px;
|
||||
margin-top: 4px;
|
||||
min-width: 160px;
|
||||
background: var(--color-bg-1);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1);
|
||||
overflow: hidden;
|
||||
z-index: $z-index-modal;
|
||||
}
|
||||
|
||||
.app-header__mobile-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-2);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式 - 移动端
|
||||
@include mobile {
|
||||
.app-header__nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__mobile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-header__user-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.admin-sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
}
|
||||
|
||||
.admin-sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -68,41 +63,6 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// 管理台用户区域
|
||||
.admin-sidebar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
background: var(--color-bg-2);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-sidebar-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-sidebar-user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 2px;
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
.admin-sidebar-user-role {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
// 管理台内容区
|
||||
.admin-layout__content {
|
||||
flex: 1;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
.app-shell,
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
background: var(--color-bg-1);
|
||||
border-right: 1px solid var(--color-border-2);
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: $z-index-sidebar;
|
||||
@@ -24,23 +24,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// 侧边栏头部
|
||||
.sidebar-header {
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 品牌区
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-logo-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -87,20 +70,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
letter-spacing: -0.3px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
// 导航区
|
||||
.sidebar__nav,
|
||||
.sidebar-menu {
|
||||
@@ -114,13 +83,6 @@
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.sidebar-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
// 导航项 - 统一使用 .nav-item
|
||||
.nav-item,
|
||||
.menu-item {
|
||||
@@ -178,34 +140,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 顶部栏
|
||||
.app-shell__header,
|
||||
.header {
|
||||
height: var(--header-height);
|
||||
background: var(--color-bg-1);
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: $z-index-header;
|
||||
}
|
||||
|
||||
.header__left,
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
// 用户头像
|
||||
.user-avatar {
|
||||
width: 34px;
|
||||
@@ -290,10 +224,6 @@
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
@@ -177,41 +177,6 @@
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
// 侧边栏用户状态区域
|
||||
.chat-sidebar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
background: var(--color-bg-1);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-sidebar-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-sidebar-user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 2px;
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
.chat-sidebar-user-role {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
// 侧边栏项目切换区域
|
||||
.chat-sidebar-project {
|
||||
padding: 16px;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@use '../tokens' as *;
|
||||
|
||||
.home-layout {
|
||||
min-height: 100vh;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #F8FAFC 0%, #FFFFFF 100%);
|
||||
@@ -33,116 +33,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.home-header {
|
||||
padding: 0 48px;
|
||||
height: 68px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.home-logo {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
letter-spacing: -0.3px;
|
||||
|
||||
.sidebar-logo-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 10px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 4px 4px 2px 2px;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 10px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 2px 2px 4px 4px;
|
||||
bottom: 4px;
|
||||
}
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
border-radius: 50%;
|
||||
top: 9px;
|
||||
z-index: 1;
|
||||
|
||||
&:nth-child(1) { left: 9px; }
|
||||
&:nth-child(2) { right: 9px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.home-nav {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
a {
|
||||
color: var(--color-text-2);
|
||||
text-decoration: none;
|
||||
padding: 9px 16px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
svg, .home-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.home-nav-login {
|
||||
color: #3B82F6;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
color: #2563EB;
|
||||
background: #EFF6FF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.home-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -300,22 +190,8 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.home-footer {
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
color: var(--color-text-4);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@include mobile {
|
||||
.home-header {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.home-title {
|
||||
font-size: 36px;
|
||||
}
|
||||
@@ -338,8 +214,4 @@
|
||||
.home-features {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.home-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user