feat: 新增工作台我的技能和技能配置功能

新增功能:
- 我的技能页面:管理已订阅技能,支持启用/禁用/配置/删除
- 技能配置页面:为已订阅技能提供 key-value 变量配置能力
- 导航栏新增"我的技能"入口(使用 FiBox 图标)

重构内容:
- 技能市场页面:移除"全部技能/已订阅"切换,专注技能浏览和订阅
- 技能详情页面:移除订阅逻辑,统一使用"当前生效版本"布局
- 技能图标样式:移除渐变色背景,改为纯 emoji 显示

数据结构:
- 新增 userSubscriptions 数组(用户级订阅和配置数据)

状态显示:
- 我的技能列表状态改为纯文字(启用/禁用/已下架)
This commit is contained in:
2026-03-23 18:38:52 +08:00
parent e9e1bd7184
commit a576a5e40e
11 changed files with 1021 additions and 101 deletions

View File

@@ -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
- **日志查询**:支持按用户、类型、状态筛选
- **定时任务**:管理定时任务,支持启用/禁用,查看任务详情
- **项目管理**:成员列表,增加成员

View 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 的已上架技能
- "已下架":仅显示已下架的技能

View 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** 用户无法访问配置页面

View 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" 的技能

View File

@@ -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 = ['💻', '📊', '📝', '👥', '📈', '🔧'];

View File

@@ -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="定时任务"

View 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;

View 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;

View File

@@ -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>
</>
);
}

View File

@@ -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 }))}
/>
</>
);
}

View File

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