feat: 新增工作台我的技能和技能配置功能
新增功能: - 我的技能页面:管理已订阅技能,支持启用/禁用/配置/删除 - 技能配置页面:为已订阅技能提供 key-value 变量配置能力 - 导航栏新增"我的技能"入口(使用 FiBox 图标) 重构内容: - 技能市场页面:移除"全部技能/已订阅"切换,专注技能浏览和订阅 - 技能详情页面:移除订阅逻辑,统一使用"当前生效版本"布局 - 技能图标样式:移除渐变色背景,改为纯 emoji 显示 数据结构: - 新增 userSubscriptions 数组(用户级订阅和配置数据) 状态显示: - 我的技能列表状态改为纯文字(启用/禁用/已下架)
This commit is contained in:
@@ -72,6 +72,8 @@ grandclaw-archtype/
|
||||
│ │ │ ├── ChatPage.jsx # 聊天页面
|
||||
│ │ │ ├── SkillsPage.jsx # 技能市场
|
||||
│ │ │ ├── SkillDetailPage.jsx # 技能详情
|
||||
│ │ │ ├── MySkillsPage.jsx # 我的技能管理(NEW)
|
||||
│ │ │ ├── SkillConfigPage.jsx # 技能配置(NEW)
|
||||
│ │ │ ├── LogsPage.jsx # 日志查询
|
||||
│ │ │ ├── TasksPage.jsx # 定时任务
|
||||
│ │ │ ├── TaskDetailPage.jsx # 任务详情
|
||||
@@ -166,7 +168,9 @@ pnpm build
|
||||
|
||||
### 3. 工作台(Console)
|
||||
- **聊天界面**:支持多种聊天场景(欢迎页、普通对话、技能调用、文件上传)
|
||||
- **技能市场**:浏览、订阅、查看技能详情(仅展示最新版本)
|
||||
- **技能市场**:浏览已上架技能、订阅技能(仅展示已上架技能)
|
||||
- **我的技能**:管理已订阅技能,支持启用/禁用、配置变量、取消订阅(NEW)
|
||||
- **技能配置**:为已订阅技能配置 key-value 变量(NEW)
|
||||
- **日志查询**:支持按用户、类型、状态筛选
|
||||
- **定时任务**:管理定时任务,支持启用/禁用,查看任务详情
|
||||
- **项目管理**:成员列表,增加成员
|
||||
|
||||
124
openspec/specs/my-skills/spec.md
Normal file
124
openspec/specs/my-skills/spec.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Capability: 我的技能
|
||||
|
||||
## Purpose
|
||||
|
||||
提供我的技能管理功能,允许用户查看、启用/禁用、配置和取消订阅已订阅的技能。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 我的技能页面访问
|
||||
系统 SHALL 在工作台导航栏提供"我的技能"入口,用户点击后进入我的技能管理页面。
|
||||
|
||||
#### Scenario: 进入我的技能页面
|
||||
- **WHEN** 用户点击导航栏"我的技能"入口
|
||||
- **THEN** 系统显示我的技能管理页面
|
||||
- **AND** 页面包含搜索/筛选卡片
|
||||
- **AND** 页面展示已订阅技能列表
|
||||
|
||||
### Requirement: 技能列表展示
|
||||
系统 SHALL 以表格形式展示已订阅的技能,包括技能名称、描述、分类、状态和操作列。
|
||||
|
||||
#### Scenario: 查看技能列表
|
||||
- **WHEN** 我的技能页面加载完成
|
||||
- **THEN** 系统显示表格,包含以下列:
|
||||
- 技能名称(带图标)
|
||||
- 描述
|
||||
- 分类
|
||||
- 状态
|
||||
- 操作
|
||||
|
||||
#### Scenario: 列表数据来源
|
||||
- **WHEN** 我的技能页面加载
|
||||
- **THEN** 系统显示 userSubscriptions 中的所有订阅记录
|
||||
- **AND** 根据 skillId 关联获取技能的 currentVersion 信息
|
||||
|
||||
### Requirement: 技能状态显示
|
||||
系统 SHALL 根据技能上架状态和用户启用状态显示不同的状态标识。
|
||||
|
||||
#### Scenario: 已上架且启用的技能
|
||||
- **WHEN** 技能状态为 published 且 enabled 为 true
|
||||
- **THEN** 状态列显示"●启用"(绿色)
|
||||
|
||||
#### Scenario: 已上架但禁用的技能
|
||||
- **WHEN** 技能状态为 published 且 enabled 为 false
|
||||
- **THEN** 状态列显示"○禁用"(灰色)
|
||||
|
||||
#### Scenario: 已下架的技能
|
||||
- **WHEN** 技能状态为 unlisting、unlisted 或 dev
|
||||
- **THEN** 状态列显示"▣已下架"(红色/警告色)
|
||||
|
||||
### Requirement: 技能启用/禁用
|
||||
系统 SHALL 允许用户启用或禁用已上架的技能。
|
||||
|
||||
#### Scenario: 启用技能
|
||||
- **WHEN** 用户点击"启用"按钮
|
||||
- **THEN** 系统显示确认弹框
|
||||
- **AND** 弹框标题为"确认启用"
|
||||
- **AND** 弹框内容为"确定要启用"<技能名称>"吗?"
|
||||
- **AND** 用户确认后将 enabled 设置为 true
|
||||
- **AND** 显示启用成功提示
|
||||
|
||||
#### Scenario: 禁用技能
|
||||
- **WHEN** 用户点击"禁用"按钮
|
||||
- **THEN** 系统显示确认弹框
|
||||
- **AND** 弹框标题为"确认禁用"
|
||||
- **AND** 弹框内容为"确定要禁用"<技能名称>"吗?"
|
||||
- **AND** 用户确认后将 enabled 设置为 false
|
||||
- **AND** 显示禁用成功提示
|
||||
|
||||
#### Scenario: 下架技能禁用启用按钮
|
||||
- **WHEN** 技能状态为"已下架"
|
||||
- **THEN** 操作列不显示启用/禁用按钮
|
||||
- **AND** 仅显示删除按钮
|
||||
|
||||
### Requirement: 技能配置入口
|
||||
系统 SHALL 为已订阅的技能提供配置入口。
|
||||
|
||||
#### Scenario: 配置按钮显示
|
||||
- **WHEN** 技能状态为"已上架"
|
||||
- **THEN** 操作列显示"配置"按钮
|
||||
|
||||
#### Scenario: 下架技能禁用配置按钮
|
||||
- **WHEN** 技能状态为"已下架"
|
||||
- **THEN** 操作列不显示"配置"按钮
|
||||
|
||||
#### Scenario: 进入配置页面
|
||||
- **WHEN** 用户点击"配置"按钮
|
||||
- **THEN** 系统跳转到技能配置页面
|
||||
- **AND** 传递当前技能的订阅信息
|
||||
|
||||
### Requirement: 取消订阅
|
||||
系统 SHALL 允许用户取消订阅(删除)技能。
|
||||
|
||||
#### Scenario: 删除已订阅技能
|
||||
- **WHEN** 用户点击"删除"按钮
|
||||
- **THEN** 系统显示确认弹框
|
||||
- **AND** 弹框标题为"确认取消订阅"
|
||||
- **AND** 弹框内容为"确定要取消订阅"<技能名称>"吗?取消后将无法使用该技能,且配置数据将被删除。"
|
||||
- **AND** 用户确认后从 userSubscriptions 中移除该订阅记录
|
||||
- **AND** 显示删除成功提示
|
||||
|
||||
#### Scenario: 删除下架技能
|
||||
- **WHEN** 技能状态为"已下架"且用户点击"删除"按钮
|
||||
- **THEN** 系统执行相同的删除流程
|
||||
- **AND** 删除后技能不再显示在列表中
|
||||
|
||||
### Requirement: 搜索和筛选
|
||||
系统 SHALL 支持按关键词、分类和状态筛选已订阅的技能。
|
||||
|
||||
#### Scenario: 关键词搜索
|
||||
- **WHEN** 用户在搜索框输入关键词
|
||||
- **THEN** 系统实时过滤显示匹配的技能
|
||||
- **AND** 匹配范围包括技能名称和描述
|
||||
|
||||
#### Scenario: 按分类筛选
|
||||
- **WHEN** 用户选择分类下拉框中的分类
|
||||
- **THEN** 系统仅显示该分类下的已订阅技能
|
||||
|
||||
#### Scenario: 按状态筛选
|
||||
- **WHEN** 用户选择状态下拉框中的选项
|
||||
- **THEN** 系统根据选择显示相应状态的技能:
|
||||
- "全部":显示所有已订阅技能
|
||||
- "启用":仅显示 enabled 为 true 的已上架技能
|
||||
- "禁用":仅显示 enabled 为 false 的已上架技能
|
||||
- "已下架":仅显示已下架的技能
|
||||
128
openspec/specs/skill-config/spec.md
Normal file
128
openspec/specs/skill-config/spec.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Capability: 技能配置
|
||||
|
||||
## Purpose
|
||||
|
||||
提供技能配置功能,允许用户为已订阅的技能配置 key-value 变量。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 技能配置页面访问
|
||||
系统 SHALL 提供技能配置页面,允许用户为已订阅的技能配置 key-value 变量。
|
||||
|
||||
#### Scenario: 从我的技能进入配置
|
||||
- **WHEN** 用户在"我的技能"页面点击"配置"按钮
|
||||
- **THEN** 系统跳转到技能配置页面
|
||||
- **AND** 显示当前技能的基本信息
|
||||
- **AND** 显示当前技能的配置变量列表
|
||||
|
||||
#### Scenario: 返回我的技能
|
||||
- **WHEN** 用户点击"返回我的技能"链接
|
||||
- **THEN** 系统返回"我的技能"页面
|
||||
|
||||
### Requirement: 技能基本信息展示
|
||||
系统 SHALL 在配置页面顶部展示技能基本信息,布局和样式参考开发台"当前生效版本"卡片。
|
||||
|
||||
#### Scenario: 查看技能基本信息
|
||||
- **WHEN** 技能配置页面加载完成
|
||||
- **THEN** 系统在第一个卡片显示:
|
||||
- 技能图标(大尺寸,48px)
|
||||
- 技能公开名称(h3 标题样式)
|
||||
- 分类标签(蓝色高亮)和其他标签
|
||||
- 技能描述
|
||||
- 订阅数、评分和版本号(带图标)
|
||||
|
||||
### Requirement: 变量配置列表
|
||||
系统 SHALL 以表格形式展示技能的配置变量,每行包含 Key、Value 和操作列。
|
||||
|
||||
#### Scenario: 查看配置列表
|
||||
- **WHEN** 配置页面加载完成
|
||||
- **THEN** 系统在第二个卡片显示配置变量表格
|
||||
- **AND** 表格包含以下列:
|
||||
- Key 输入框
|
||||
- Value 输入框
|
||||
- 删除按钮(×)
|
||||
|
||||
#### Scenario: 空配置状态
|
||||
- **WHEN** 用户尚未添加任何配置变量
|
||||
- **THEN** 系统显示空表格或提示信息
|
||||
|
||||
### Requirement: 新增配置项
|
||||
系统 SHALL 允许用户新增配置项。
|
||||
|
||||
#### Scenario: 点击新增配置按钮
|
||||
- **WHEN** 用户点击右上角"+ 新增配置"按钮
|
||||
- **THEN** 系统在表格中新增一行
|
||||
- **AND** 新行包含空的 Key 输入框、空的 Value 输入框和删除按钮
|
||||
- **AND** Key 输入框自动获得焦点
|
||||
|
||||
### Requirement: 删除配置项
|
||||
系统 SHALL 允许用户删除配置项。
|
||||
|
||||
#### Scenario: 删除配置项
|
||||
- **WHEN** 用户点击某行的删除按钮(×)
|
||||
- **THEN** 系统从表格中移除该行
|
||||
- **AND** 不需要确认
|
||||
|
||||
#### Scenario: 删除最后一个配置项
|
||||
- **WHEN** 用户删除最后一个配置项
|
||||
- **THEN** 系统允许删除操作
|
||||
- **AND** 表格变为空状态
|
||||
|
||||
### Requirement: 配置输入校验
|
||||
系统 SHALL 对配置输入进行校验,确保 Key 和 Value 不能为空。
|
||||
|
||||
#### Scenario: Key 为空时保存
|
||||
- **WHEN** 用户点击"保存"按钮且存在 Key 为空的配置项
|
||||
- **THEN** 系统阻止保存操作
|
||||
- **AND** 显示错误提示"配置项的 Key 不能为空"
|
||||
- **AND** 高亮显示 Key 为空的输入框
|
||||
|
||||
#### Scenario: Value 为空时保存
|
||||
- **WHEN** 用户点击"保存"按钮且存在 Value 为空的配置项
|
||||
- **THEN** 系统阻止保存操作
|
||||
- **AND** 显示错误提示"配置项的 Value 不能为空"
|
||||
- **AND** 高亮显示 Value 为空的输入框
|
||||
|
||||
#### Scenario: 所有配置项填写完整
|
||||
- **WHEN** 用户点击"保存"按钮且所有配置项的 Key 和 Value 都不为空
|
||||
- **THEN** 系统允许保存操作
|
||||
|
||||
### Requirement: 保存配置
|
||||
系统 SHALL 允许用户保存配置到用户订阅数据中。
|
||||
|
||||
#### Scenario: 保存成功
|
||||
- **WHEN** 用户点击右下角"保存"按钮且校验通过
|
||||
- **THEN** 系统将配置数据保存到 userSubscriptions 的 config 字段
|
||||
- **AND** 显示保存成功提示
|
||||
- **AND** 返回"我的技能"页面
|
||||
|
||||
#### Scenario: 未修改直接返回
|
||||
- **WHEN** 用户未保存配置直接点击"返回我的技能"
|
||||
- **THEN** 系统直接返回"我的技能"页面
|
||||
- **AND** 不显示任何提示
|
||||
|
||||
### Requirement: 配置数据存储
|
||||
系统 SHALL 为每个用户订阅的技能独立存储配置数据。
|
||||
|
||||
#### Scenario: 配置数据结构
|
||||
- **WHEN** 系统保存配置数据
|
||||
- **THEN** 数据存储格式为:
|
||||
```javascript
|
||||
config: [
|
||||
{ key: "apiKey", value: "sk-xxxxx" },
|
||||
{ key: "model", value: "gpt-4" }
|
||||
]
|
||||
```
|
||||
|
||||
#### Scenario: 用户级配置隔离
|
||||
- **WHEN** 多个用户订阅同一技能
|
||||
- **THEN** 每个用户的配置数据独立存储
|
||||
- **AND** 互不影响
|
||||
|
||||
### Requirement: 配置页面禁用状态处理
|
||||
系统 SHALL 在技能下架后禁止访问配置页面。
|
||||
|
||||
#### Scenario: 下架技能无法配置
|
||||
- **WHEN** 技能状态为"已下架"
|
||||
- **THEN** "我的技能"页面不显示"配置"按钮
|
||||
- **AND** 用户无法访问配置页面
|
||||
87
openspec/specs/skill-market/spec.md
Normal file
87
openspec/specs/skill-market/spec.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Capability: 技能市场
|
||||
|
||||
## Purpose
|
||||
|
||||
提供技能市场功能,允许用户浏览、搜索和订阅已上架的技能。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 技能市场浏览
|
||||
系统 SHALL 在工作台提供技能市场页面,展示所有已上架的技能供用户浏览和订阅。
|
||||
|
||||
#### Scenario: 查看技能市场
|
||||
- **WHEN** 用户点击导航栏"技能市场"入口
|
||||
- **THEN** 系统显示技能市场页面
|
||||
- **AND** 页面包含搜索/筛选卡片
|
||||
- **AND** 页面展示所有状态为"已上架"(published)的技能卡片
|
||||
|
||||
#### Scenario: 搜索技能
|
||||
- **WHEN** 用户在搜索框输入关键词
|
||||
- **THEN** 系统实时过滤显示匹配的技能
|
||||
- **AND** 匹配范围包括技能名称、描述、分类和标签
|
||||
|
||||
#### Scenario: 按分类筛选
|
||||
- **WHEN** 用户选择分类下拉框中的分类
|
||||
- **THEN** 系统仅显示该分类下的技能
|
||||
|
||||
### Requirement: 技能卡片展示
|
||||
系统 SHALL 以卡片形式展示技能信息,包括技能图标、名称、作者、描述、分类、标签、订阅数和评分。
|
||||
|
||||
#### Scenario: 查看技能卡片
|
||||
- **WHEN** 技能市场页面加载完成
|
||||
- **THEN** 每个技能卡片显示以下信息:
|
||||
- 技能图标(emoji)
|
||||
- 技能公开名称
|
||||
- 作者名称
|
||||
- 技能描述
|
||||
- 分类标签(蓝色高亮)
|
||||
- 其他标签
|
||||
- 订阅数
|
||||
- 评分
|
||||
- "订阅"按钮
|
||||
|
||||
#### Scenario: 订阅按钮状态
|
||||
- **WHEN** 用户查看任意技能卡片
|
||||
- **THEN** 订阅按钮始终显示"订阅"文本
|
||||
- **AND** 不显示已订阅状态
|
||||
|
||||
### Requirement: 技能详情查看
|
||||
系统 SHALL 允许用户点击技能卡片查看技能详情。
|
||||
|
||||
#### Scenario: 进入技能详情页
|
||||
- **WHEN** 用户点击技能卡片
|
||||
- **THEN** 系统跳转到技能详情页面
|
||||
- **AND** 显示技能基本信息、使用说明、文件列表和当前版本
|
||||
|
||||
#### Scenario: 返回技能市场
|
||||
- **WHEN** 用户在技能详情页点击"返回技能市场"
|
||||
- **THEN** 系统返回技能市场页面
|
||||
|
||||
### Requirement: 技能订阅
|
||||
系统 SHALL 允许用户订阅技能,订阅后技能自动加入"我的技能"列表。
|
||||
|
||||
#### Scenario: 发起订阅
|
||||
- **WHEN** 用户点击技能卡片的"订阅"按钮
|
||||
- **THEN** 系统显示确认弹框
|
||||
- **AND** 弹框标题为"确认订阅"
|
||||
- **AND** 弹框内容为"确定要订阅"<技能名称>"吗?"
|
||||
|
||||
#### Scenario: 确认订阅
|
||||
- **WHEN** 用户在确认弹框中点击"订阅"按钮
|
||||
- **THEN** 系统将技能添加到用户订阅列表
|
||||
- **AND** 设置 enabled 为 true(默认启用)
|
||||
- **AND** 初始化 config 为空数组
|
||||
- **AND** 显示订阅成功提示
|
||||
|
||||
#### Scenario: 取消订阅操作
|
||||
- **WHEN** 用户在确认弹框中点击"取消"按钮
|
||||
- **THEN** 系统关闭弹框
|
||||
- **AND** 不执行订阅操作
|
||||
|
||||
### Requirement: 下架技能处理
|
||||
系统 SHALL 不在技能市场显示已下架的技能。
|
||||
|
||||
#### Scenario: 技能市场过滤下架技能
|
||||
- **WHEN** 技能市场页面加载
|
||||
- **THEN** 系统仅显示 status 为 "published" 的技能
|
||||
- **AND** 不显示 status 为 "unlisting"、"unlisted" 或 "dev" 的技能
|
||||
@@ -213,6 +213,46 @@ export const pendingUnlistReviews = [
|
||||
}
|
||||
];
|
||||
|
||||
// 用户订阅数据
|
||||
export const userSubscriptions = [
|
||||
{
|
||||
id: 1,
|
||||
skillId: 1,
|
||||
subscribedAt: '2026-03-20',
|
||||
enabled: true,
|
||||
config: [
|
||||
{ key: 'apiKey', value: 'sk-xxxxx' },
|
||||
{ key: 'model', value: 'gpt-4' },
|
||||
{ key: 'maxTokens', value: '2048' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
skillId: 2,
|
||||
subscribedAt: '2026-03-18',
|
||||
enabled: false,
|
||||
config: [
|
||||
{ key: 'dataSource', value: 'production' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
skillId: 4,
|
||||
subscribedAt: '2026-03-15',
|
||||
enabled: true,
|
||||
config: []
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
skillId: 5,
|
||||
subscribedAt: '2026-03-10',
|
||||
enabled: false,
|
||||
config: [
|
||||
{ key: 'syncInterval', value: '3600' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 技能图标映射
|
||||
const skillIcons = ['💻', '📊', '📝', '👥', '📈', '🔧'];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { FiPlus, FiClock, FiList, FiUsers } from 'react-icons/fi';
|
||||
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';
|
||||
@@ -13,6 +13,8 @@ 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';
|
||||
@@ -38,6 +40,7 @@ function ConsolePage() {
|
||||
});
|
||||
const [currentSkillId, setCurrentSkillId] = useState(null);
|
||||
const [currentTaskId, setCurrentTaskId] = useState(null);
|
||||
const [currentSubscriptionId, setCurrentSubscriptionId] = useState(null);
|
||||
|
||||
// 处理主页跳转重置
|
||||
useEffect(() => {
|
||||
@@ -58,6 +61,9 @@ function ConsolePage() {
|
||||
if (data.skillId !== undefined) {
|
||||
setCurrentSkillId(data.skillId);
|
||||
}
|
||||
if (data.subscriptionId !== undefined) {
|
||||
setCurrentSubscriptionId(data.subscriptionId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkillClick = (skillId) => {
|
||||
@@ -90,19 +96,29 @@ function ConsolePage() {
|
||||
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
|
||||
return <TasksPage
|
||||
onViewDetail={(taskId) => {
|
||||
setCurrentTaskId(taskId);
|
||||
switchPage('taskDetail');
|
||||
}}
|
||||
}}
|
||||
/>;
|
||||
case 'taskDetail':
|
||||
return <TaskDetailPage
|
||||
taskId={currentTaskId}
|
||||
onBack={() => switchPage('scheduledTasks')}
|
||||
return <TaskDetailPage
|
||||
taskId={currentTaskId}
|
||||
onBack={() => switchPage('scheduledTasks')}
|
||||
/>;
|
||||
case 'account':
|
||||
return <AccountPage />;
|
||||
@@ -168,6 +184,12 @@ function ConsolePage() {
|
||||
active={currentPage === 'skills'}
|
||||
onClick={() => switchPage('skills')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiBox />}
|
||||
label="我的技能"
|
||||
active={currentPage === 'mySkills'}
|
||||
onClick={() => switchPage('mySkills')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FiClock />}
|
||||
label="定时任务"
|
||||
|
||||
300
src/pages/console/MySkillsPage.jsx
Normal file
300
src/pages/console/MySkillsPage.jsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useState } from 'react';
|
||||
import { FiSearch } from 'react-icons/fi';
|
||||
import { FaBoxOpen } from 'react-icons/fa';
|
||||
import { skills, userSubscriptions } from '../../data/skills.js';
|
||||
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 }) {
|
||||
const [filters, setFilters] = useState({ keyword: '', category: '', status: '' });
|
||||
const [subscriptions, setSubscriptions] = useState(userSubscriptions);
|
||||
const [actionTarget, setActionTarget] = useState(null);
|
||||
const [actionType, setActionType] = useState(null);
|
||||
const [toast, setToast] = useState({ visible: false, type: 'success', message: '' });
|
||||
|
||||
// 关联订阅和技能数据
|
||||
const subscriptionList = subscriptions.map(sub => {
|
||||
const skill = skills.find(s => s.id === sub.skillId);
|
||||
return {
|
||||
...sub,
|
||||
skill: skill || null
|
||||
};
|
||||
}).filter(item => item.skill !== null);
|
||||
|
||||
// 获取技能状态显示
|
||||
const getSkillStatus = (subscription) => {
|
||||
const skill = subscription.skill;
|
||||
if (!skill || !skill.currentVersion) {
|
||||
return { text: '已下架', className: 'status-error' };
|
||||
}
|
||||
if (skill.status !== 'published') {
|
||||
return { text: '已下架', className: 'status-error' };
|
||||
}
|
||||
if (subscription.enabled) {
|
||||
return { text: '启用', className: 'status-running' };
|
||||
}
|
||||
return { text: '禁用', className: 'status-stopped' };
|
||||
};
|
||||
|
||||
// 检查技能是否已下架
|
||||
const isSkillDelisted = (subscription) => {
|
||||
const skill = subscription.skill;
|
||||
return !skill || !skill.currentVersion || skill.status !== 'published';
|
||||
};
|
||||
|
||||
// 筛选逻辑
|
||||
const filteredList = subscriptionList.filter(item => {
|
||||
const skill = item.skill;
|
||||
const cv = skill?.currentVersion;
|
||||
|
||||
if (filters.keyword) {
|
||||
const keyword = filters.keyword.toLowerCase();
|
||||
if (cv && !cv.publicName.toLowerCase().includes(keyword) && !cv.publicDesc.toLowerCase().includes(keyword)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.category && cv && cv.category !== filters.category) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
if (filters.status === 'enabled' && (isSkillDelisted(item) || !item.enabled)) return false;
|
||||
if (filters.status === 'disabled' && (isSkillDelisted(item) || item.enabled)) return false;
|
||||
if (filters.status === 'delisted' && !isSkillDelisted(item)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilters({ keyword: '', category: '', status: '' });
|
||||
};
|
||||
|
||||
const handleEnable = (subscription) => {
|
||||
setActionTarget(subscription);
|
||||
setActionType('enable');
|
||||
};
|
||||
|
||||
const handleDisable = (subscription) => {
|
||||
setActionTarget(subscription);
|
||||
setActionType('disable');
|
||||
};
|
||||
|
||||
const handleDelete = (subscription) => {
|
||||
setActionTarget(subscription);
|
||||
setActionType('delete');
|
||||
};
|
||||
|
||||
const confirmAction = () => {
|
||||
if (!actionTarget) return;
|
||||
|
||||
if (actionType === 'enable') {
|
||||
setSubscriptions(prev => prev.map(s =>
|
||||
s.id === actionTarget.id ? { ...s, enabled: true } : s
|
||||
));
|
||||
setToast({ visible: true, type: 'success', message: `已启用"${actionTarget.skill?.currentVersion?.publicName}"` });
|
||||
} else if (actionType === 'disable') {
|
||||
setSubscriptions(prev => prev.map(s =>
|
||||
s.id === actionTarget.id ? { ...s, enabled: false } : s
|
||||
));
|
||||
setToast({ visible: true, type: 'success', message: `已禁用"${actionTarget.skill?.currentVersion?.publicName}"` });
|
||||
} else if (actionType === 'delete') {
|
||||
setSubscriptions(prev => prev.filter(s => s.id !== actionTarget.id));
|
||||
setToast({ visible: true, type: 'success', message: `已取消订阅"${actionTarget.skill?.currentVersion?.publicName}"` });
|
||||
}
|
||||
|
||||
setActionTarget(null);
|
||||
setActionType(null);
|
||||
};
|
||||
|
||||
const cancelAction = () => {
|
||||
setActionTarget(null);
|
||||
setActionType(null);
|
||||
};
|
||||
|
||||
const getModalTitle = () => {
|
||||
if (actionType === 'enable') return '确认启用';
|
||||
if (actionType === 'disable') return '确认禁用';
|
||||
if (actionType === 'delete') return '确认取消订阅';
|
||||
return '';
|
||||
};
|
||||
|
||||
const getModalContent = () => {
|
||||
const skillName = actionTarget?.skill?.currentVersion?.publicName;
|
||||
if (actionType === 'enable') return `确定要启用"${skillName}"吗?`;
|
||||
if (actionType === 'disable') return `确定要禁用"${skillName}"吗?`;
|
||||
if (actionType === 'delete') return `确定要取消订阅"${skillName}"吗?取消后将无法使用该技能,且配置数据将被删除。`;
|
||||
return '';
|
||||
};
|
||||
|
||||
const getConfirmText = () => {
|
||||
if (actionType === 'enable') return '启用';
|
||||
if (actionType === 'disable') return '禁用';
|
||||
if (actionType === 'delete') return '取消订阅';
|
||||
return '确定';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="search-bar">
|
||||
<div className="search-item" style={{ flex: 1, minWidth: '200px' }}>
|
||||
<label>关键词</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="搜索技能名称、描述..."
|
||||
value={filters.keyword}
|
||||
onChange={e => handleFilterChange('keyword', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="search-item">
|
||||
<label>分类</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={filters.category}
|
||||
onChange={e => handleFilterChange('category', e.target.value)}
|
||||
>
|
||||
<option value="">全部分类</option>
|
||||
<option>开发工具</option>
|
||||
<option>数据分析</option>
|
||||
<option>办公效率</option>
|
||||
<option>业务系统</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="search-item">
|
||||
<label>状态</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={filters.status}
|
||||
onChange={e => handleFilterChange('status', e.target.value)}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="enabled">启用</option>
|
||||
<option value="disabled">禁用</option>
|
||||
<option value="delisted">已下架</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="search-actions">
|
||||
<button className="btn btn-primary"><FiSearch /> 查询</button>
|
||||
<button className="btn" onClick={handleReset}>重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">我的技能</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{filteredList.length > 0 ? (
|
||||
<div className="table-wrapper">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>技能名称</th>
|
||||
<th>描述</th>
|
||||
<th>分类</th>
|
||||
<th>状态</th>
|
||||
<th style={{ width: '200px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredList.map(item => {
|
||||
const skill = item.skill;
|
||||
const cv = skill?.currentVersion;
|
||||
const statusInfo = getSkillStatus(item);
|
||||
const delisted = isSkillDelisted(item);
|
||||
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '20px' }}>{cv?.icon || '📦'}</span>
|
||||
<span>{cv?.publicName || skill?.name || '未知技能'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ color: '#64748B' }}>{cv?.publicDesc || skill?.desc || '-'}</td>
|
||||
<td>{cv?.category || '-'}</td>
|
||||
<td>
|
||||
<span className={`status ${statusInfo.className}`}>
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{!delisted && (
|
||||
<>
|
||||
{item.enabled ? (
|
||||
<button
|
||||
className="text-btn text-btn-primary"
|
||||
onClick={() => handleDisable(item)}
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="text-btn text-btn-primary"
|
||||
onClick={() => handleEnable(item)}
|
||||
>
|
||||
启用
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="text-btn text-btn-primary"
|
||||
onClick={() => onConfig(item.id)}
|
||||
>
|
||||
配置
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="text-btn text-btn-danger"
|
||||
onClick={() => handleDelete(item)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<FaBoxOpen size={48} />}
|
||||
message="暂无已订阅技能"
|
||||
description={filters.keyword || filters.category || filters.status ? '当前筛选条件下没有技能' : '前往技能市场订阅技能'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
visible={!!actionTarget}
|
||||
title={getModalTitle()}
|
||||
onConfirm={confirmAction}
|
||||
onCancel={cancelAction}
|
||||
confirmText={getConfirmText()}
|
||||
>
|
||||
{getModalContent()}
|
||||
</Modal>
|
||||
<Toast
|
||||
visible={toast.visible}
|
||||
type={toast.type}
|
||||
message={toast.message}
|
||||
onClose={() => setToast(prev => ({ ...prev, visible: false }))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MySkillsPage;
|
||||
226
src/pages/console/SkillConfigPage.jsx
Normal file
226
src/pages/console/SkillConfigPage.jsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FiChevronLeft, 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 }) {
|
||||
const [subscriptions, setSubscriptions] = useState(userSubscriptions);
|
||||
const [subscription, setSubscription] = useState(null);
|
||||
const [skill, setSkill] = useState(null);
|
||||
const [config, setConfig] = useState([]);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [toast, setToast] = useState({ visible: false, type: 'success', message: '' });
|
||||
|
||||
useEffect(() => {
|
||||
const sub = subscriptions.find(s => s.id === subscriptionId);
|
||||
if (sub) {
|
||||
setSubscription(sub);
|
||||
const skillData = skills.find(s => s.id === sub.skillId);
|
||||
setSkill(skillData);
|
||||
setConfig(sub.config || []);
|
||||
}
|
||||
}, [subscriptionId, subscriptions]);
|
||||
|
||||
const handleAddConfig = () => {
|
||||
setConfig([...config, { key: '', value: '' }]);
|
||||
};
|
||||
|
||||
const handleRemoveConfig = (index) => {
|
||||
const newConfig = config.filter((_, i) => i !== index);
|
||||
setConfig(newConfig);
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
const handleConfigChange = (index, field, value) => {
|
||||
const newConfig = [...config];
|
||||
newConfig[index][field] = value;
|
||||
setConfig(newConfig);
|
||||
// 清除该字段的错误
|
||||
if (errors[index] && errors[index][field]) {
|
||||
const newErrors = { ...errors };
|
||||
delete newErrors[index][field];
|
||||
if (Object.keys(newErrors[index]).length === 0) {
|
||||
delete newErrors[index];
|
||||
}
|
||||
setErrors(newErrors);
|
||||
}
|
||||
};
|
||||
|
||||
const validateConfig = () => {
|
||||
const newErrors = {};
|
||||
let hasError = false;
|
||||
|
||||
config.forEach((item, index) => {
|
||||
if (!item.key || item.key.trim() === '') {
|
||||
if (!newErrors[index]) newErrors[index] = {};
|
||||
newErrors[index].key = 'Key 不能为空';
|
||||
hasError = true;
|
||||
}
|
||||
if (!item.value || item.value.trim() === '') {
|
||||
if (!newErrors[index]) newErrors[index] = {};
|
||||
newErrors[index].value = 'Value 不能为空';
|
||||
hasError = true;
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return !hasError;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!validateConfig()) {
|
||||
setToast({ visible: true, type: 'error', message: '请填写完整的配置项' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新订阅配置
|
||||
setSubscriptions(prev => prev.map(s =>
|
||||
s.id === subscriptionId ? { ...s, config } : s
|
||||
));
|
||||
|
||||
setToast({ visible: true, type: 'success', message: '配置已保存' });
|
||||
|
||||
// 延迟返回
|
||||
setTimeout(() => {
|
||||
onBack();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const cv = skill?.currentVersion;
|
||||
|
||||
if (!subscription || !skill) {
|
||||
return <div>加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dev-back-btn" onClick={onBack} style={{ marginBottom: '16px' }}>
|
||||
<FiChevronLeft /> 返回我的技能
|
||||
</div>
|
||||
|
||||
{/* 技能基本信息卡片 */}
|
||||
{cv && (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'flex-start' }}>
|
||||
<div style={{ fontSize: '48px' }}>{cv.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', color: '#1E293B' }}>{cv.publicName}</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
|
||||
<span className="dev-detail-tag" style={{ background: '#EFF6FF', color: '#1E40AF', fontWeight: '600' }}>{cv.category}</span>
|
||||
{cv.tags.map(tag => (
|
||||
<span key={tag} className="dev-detail-tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#475569', lineHeight: '1.6' }}>
|
||||
{cv.publicDesc}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '24px', color: '#64748B', fontSize: '14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<FiUsers />
|
||||
<span>{skill.subs || 0} 订阅</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<FiStar />
|
||||
<span>{cv.rating || 0} 评分</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<FiPackage />
|
||||
<span>v{cv.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变量配置卡片 */}
|
||||
<div className="card" style={{ marginTop: '16px' }}>
|
||||
<div className="card-header">
|
||||
<div className="card-title">变量配置</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleAddConfig}>
|
||||
<FiPlus /> 新增配置
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{config.length > 0 ? (
|
||||
<div className="table-wrapper">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th style={{ width: '80px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control ${errors[index]?.key ? 'is-invalid' : ''}`}
|
||||
value={item.key}
|
||||
onChange={e => handleConfigChange(index, 'key', e.target.value)}
|
||||
placeholder="配置项名称"
|
||||
/>
|
||||
{errors[index]?.key && (
|
||||
<div style={{ color: '#EF4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{errors[index].key}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control ${errors[index]?.value ? 'is-invalid' : ''}`}
|
||||
value={item.value}
|
||||
onChange={e => handleConfigChange(index, 'value', e.target.value)}
|
||||
placeholder="配置项值"
|
||||
/>
|
||||
{errors[index]?.value && (
|
||||
<div style={{ color: '#EF4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{errors[index].value}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="text-btn text-btn-danger"
|
||||
onClick={() => handleRemoveConfig(index)}
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: '#94A3B8' }}>
|
||||
暂无配置项,点击右上角"新增配置"添加
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '16px', textAlign: 'right' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toast
|
||||
visible={toast.visible}
|
||||
type={toast.type}
|
||||
message={toast.message}
|
||||
onClose={() => setToast(prev => ({ ...prev, visible: false }))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillConfigPage;
|
||||
@@ -1,76 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import { FiChevronLeft, FiFile } from 'react-icons/fi';
|
||||
import { FiChevronLeft, FiFile, FiUsers, FiStar, FiPackage } from 'react-icons/fi';
|
||||
import { skills, skillFiles } from '../../data/skills.js';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
|
||||
function SkillDetailPage({ skillId, onBack }) {
|
||||
const skill = skills.find(s => s.id === skillId);
|
||||
const [subscribed, setSubscribed] = useState(skill?.subscribed || false);
|
||||
const [showUnsubModal, setShowUnsubModal] = useState(false);
|
||||
|
||||
if (!skill) {
|
||||
return <div>Skill not found</div>;
|
||||
}
|
||||
|
||||
const handleSubscribeClick = () => {
|
||||
if (subscribed) {
|
||||
setShowUnsubModal(true);
|
||||
} else {
|
||||
setSubscribed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmUnsubscribe = () => {
|
||||
setSubscribed(false);
|
||||
setShowUnsubModal(false);
|
||||
};
|
||||
|
||||
const cancelUnsubscribe = () => {
|
||||
setShowUnsubModal(false);
|
||||
};
|
||||
|
||||
const currentVersion = skill.currentVersion;
|
||||
const cv = skill.currentVersion;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="skill-back-btn" onClick={onBack}>
|
||||
<div className="dev-back-btn" onClick={onBack} style={{ marginBottom: '16px' }}>
|
||||
<FiChevronLeft /> 返回技能市场
|
||||
</div>
|
||||
{currentVersion ? (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="skill-detail-header">
|
||||
<div className="skill-detail-icon">{currentVersion.icon}</div>
|
||||
<div className="skill-detail-main">
|
||||
<h2 style={{ fontSize: '22px', fontWeight: 800, marginBottom: '6px' }}>{currentVersion.publicName}</h2>
|
||||
<p style={{ color: '#64748B', marginBottom: '8px' }}>by {skill.author}</p>
|
||||
<div className="skill-detail-tags">
|
||||
<span className="skill-detail-tag" style={{ background: '#EFF6FF', color: '#1E40AF', fontWeight: '600' }}>{currentVersion.category}</span>
|
||||
{currentVersion.tags.map(tag => (
|
||||
<span key={tag} className="skill-detail-tag">{tag}</span>
|
||||
))}
|
||||
{cv ? (
|
||||
<>
|
||||
{/* 技能基本信息卡片 - 参考配置页面布局 */}
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'flex-start' }}>
|
||||
<div style={{ fontSize: '48px' }}>{cv.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', color: '#1E293B' }}>{cv.publicName}</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
|
||||
<span className="dev-detail-tag" style={{ background: '#EFF6FF', color: '#1E40AF', fontWeight: '600' }}>{cv.category}</span>
|
||||
{cv.tags.map(tag => (
|
||||
<span key={tag} className="dev-detail-tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#475569', lineHeight: '1.6' }}>
|
||||
{cv.publicDesc}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '24px', color: '#64748B', fontSize: '14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<FiUsers />
|
||||
<span>{skill.subs || 0} 订阅</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<FiStar />
|
||||
<span>{cv.rating || 0} 评分</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<FiPackage />
|
||||
<span>v{cv.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="skill-detail-stats">
|
||||
<span>👤 {skill.subs.toLocaleString()} 订阅</span>
|
||||
<span>⭐ {currentVersion.rating || 0} 评分</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<button className={`btn ${subscribed ? '' : 'btn-primary'}`} onClick={handleSubscribeClick}>
|
||||
{subscribed ? '取消订阅' : '立即订阅'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="skill-detail-section">
|
||||
</div>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<div className="card" style={{ marginTop: '16px' }}>
|
||||
<div className="card-body">
|
||||
<h3>使用说明</h3>
|
||||
<p style={{ color: '#475569', lineHeight: 1.8 }}>
|
||||
{currentVersion.publicDesc}。安装后,您可以在对话中直接调用该技能。例如,您可以说:
|
||||
安装后,您可以在对话中直接调用该技能。例如,您可以说:
|
||||
</p>
|
||||
<div style={{ marginTop: '12px', padding: '12px 16px', background: '#F8FAFC', borderRadius: '8px', color: '#3B82F6', fontFamily: 'monospace' }}>
|
||||
"帮我用这个技能 查询一下数据"
|
||||
</div>
|
||||
</div>
|
||||
<div className="skill-detail-section">
|
||||
</div>
|
||||
|
||||
{/* 文件列表 */}
|
||||
<div className="card" style={{ marginTop: '16px' }}>
|
||||
<div className="card-body">
|
||||
<h3>文件列表</h3>
|
||||
{skillFiles.map(file => (
|
||||
<div key={file.name} className="file-list-item">
|
||||
@@ -82,17 +81,21 @@ function SkillDetailPage({ skillId, onBack }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skill-detail-section">
|
||||
</div>
|
||||
|
||||
{/* 当前版本 */}
|
||||
<div className="card" style={{ marginTop: '16px' }}>
|
||||
<div className="card-body">
|
||||
<h3>当前版本</h3>
|
||||
<div className="version-list-item">
|
||||
<div className="version-info">
|
||||
<span className="version-tag current">v{currentVersion.version}</span>
|
||||
<span className="version-desc">{currentVersion.publicDesc}</span>
|
||||
<span className="version-tag current">v{cv.version}</span>
|
||||
<span className="version-desc">{cv.publicDesc}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
@@ -103,15 +106,6 @@ function SkillDetailPage({ skillId, onBack }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
visible={showUnsubModal}
|
||||
title="确认取消订阅"
|
||||
onConfirm={confirmUnsubscribe}
|
||||
onCancel={cancelUnsubscribe}
|
||||
confirmText="取消订阅"
|
||||
>
|
||||
确定要取消订阅"{currentVersion?.publicName || skill.name}"吗?取消后将无法使用该技能。
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { FiUser, FiStar, FiSearch } from 'react-icons/fi';
|
||||
import { FaBoxOpen } from 'react-icons/fa';
|
||||
import { skills } from '../../data/skills.js';
|
||||
import { skills, userSubscriptions } from '../../data/skills.js';
|
||||
import EmptyState from '../../components/common/EmptyState.jsx';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function SkillCard({ skill, onClick, onSubscribe }) {
|
||||
const currentVersion = skill.currentVersion;
|
||||
@@ -35,10 +36,10 @@ function SkillCard({ skill, onClick, onSubscribe }) {
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className={`btn ${skill.subscribed ? 'btn-primary' : ''} btn-sm`}
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={e => { e.stopPropagation(); onSubscribe(skill); }}
|
||||
>
|
||||
{skill.subscribed ? '已订阅' : '订阅'}
|
||||
订阅
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,18 +47,14 @@ function SkillCard({ skill, onClick, onSubscribe }) {
|
||||
}
|
||||
|
||||
function SkillsPage({ onSkillClick }) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [sort, setSort] = useState('subs');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [skillsState, setSkillsState] = useState(skills);
|
||||
const [subscriptions, setSubscriptions] = useState(userSubscriptions);
|
||||
const [modalTarget, setModalTarget] = useState(null);
|
||||
|
||||
const filteredSkills = filter === 'subscribed'
|
||||
? skillsState.filter(s => s.subscribed)
|
||||
: [...skillsState];
|
||||
const [toast, setToast] = useState({ visible: false, type: 'success', message: '' });
|
||||
|
||||
const searchedSkills = searchQuery
|
||||
? filteredSkills.filter(s => {
|
||||
? skills.filter(s => {
|
||||
const cv = s.currentVersion;
|
||||
if (!cv) return false;
|
||||
return cv.publicName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -65,7 +62,7 @@ function SkillsPage({ onSkillClick }) {
|
||||
cv.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cv.tags.some(t => t.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
})
|
||||
: filteredSkills;
|
||||
: skills;
|
||||
|
||||
// 只显示有当前生效版本的已上架技能
|
||||
const displaySkills = searchedSkills
|
||||
@@ -82,10 +79,16 @@ function SkillsPage({ onSkillClick }) {
|
||||
|
||||
const confirmSubscribe = () => {
|
||||
if (modalTarget) {
|
||||
setSkillsState(prev => prev.map(s =>
|
||||
s.id === modalTarget.id ? { ...s, subscribed: !s.subscribed } : s
|
||||
));
|
||||
const newSubscription = {
|
||||
id: subscriptions.length + 1,
|
||||
skillId: modalTarget.id,
|
||||
subscribedAt: new Date().toISOString().split('T')[0],
|
||||
enabled: true,
|
||||
config: []
|
||||
};
|
||||
setSubscriptions(prev => [...prev, newSubscription]);
|
||||
setModalTarget(null);
|
||||
setToast({ visible: true, type: 'success', message: `已订阅"${modalTarget.currentVersion.publicName}"` });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,14 +134,6 @@ function SkillsPage({ onSkillClick }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div className="btn-group">
|
||||
<button className={`btn ${filter === 'all' ? 'btn-primary' : ''}`} onClick={() => setFilter('all')}>全部技能</button>
|
||||
<button className={`btn ${filter === 'subscribed' ? 'btn-primary' : ''}`} onClick={() => setFilter('subscribed')}>
|
||||
已订阅 ({skillsState.filter(s => s.subscribed).length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{displaySkills.length > 0 ? (
|
||||
<div className="skill-grid">
|
||||
{displaySkills.map(skill => (
|
||||
@@ -159,16 +154,19 @@ function SkillsPage({ onSkillClick }) {
|
||||
)}
|
||||
<Modal
|
||||
visible={!!modalTarget}
|
||||
title={modalTarget?.subscribed ? '确认取消订阅' : '确认订阅'}
|
||||
title="确认订阅"
|
||||
onConfirm={confirmSubscribe}
|
||||
onCancel={cancelSubscribe}
|
||||
confirmText={modalTarget?.subscribed ? '取消订阅' : '订阅'}
|
||||
confirmText="订阅"
|
||||
>
|
||||
{modalTarget?.subscribed
|
||||
? `确定要取消订阅"${modalTarget?.currentVersion?.publicName}"吗?取消后将无法使用该技能。`
|
||||
: `确定要订阅"${modalTarget?.currentVersion?.publicName}"吗?`
|
||||
}
|
||||
确定要订阅"{modalTarget?.currentVersion?.publicName}"吗?
|
||||
</Modal>
|
||||
<Toast
|
||||
visible={toast.visible}
|
||||
type={toast.type}
|
||||
message={toast.message}
|
||||
onClose={() => setToast(prev => ({ ...prev, visible: false }))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -902,13 +902,10 @@ input:checked + .slider:before {
|
||||
.skill-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, #8B5CF6 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #FFFFFF;
|
||||
font-size: 24px;
|
||||
font-size: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user