diff --git a/README.md b/README.md index bc1584a..cf51744 100644 --- a/README.md +++ b/README.md @@ -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) - **日志查询**:支持按用户、类型、状态筛选 - **定时任务**:管理定时任务,支持启用/禁用,查看任务详情 - **项目管理**:成员列表,增加成员 diff --git a/openspec/specs/my-skills/spec.md b/openspec/specs/my-skills/spec.md new file mode 100644 index 0000000..6669ac9 --- /dev/null +++ b/openspec/specs/my-skills/spec.md @@ -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 的已上架技能 + - "已下架":仅显示已下架的技能 diff --git a/openspec/specs/skill-config/spec.md b/openspec/specs/skill-config/spec.md new file mode 100644 index 0000000..630d5de --- /dev/null +++ b/openspec/specs/skill-config/spec.md @@ -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** 用户无法访问配置页面 diff --git a/openspec/specs/skill-market/spec.md b/openspec/specs/skill-market/spec.md new file mode 100644 index 0000000..d69098e --- /dev/null +++ b/openspec/specs/skill-market/spec.md @@ -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" 的技能 diff --git a/src/data/skills.js b/src/data/skills.js index 0b2063b..f75d9aa 100644 --- a/src/data/skills.js +++ b/src/data/skills.js @@ -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 = ['💻', '📊', '📝', '👥', '📈', '🔧']; diff --git a/src/pages/ConsolePage.jsx b/src/pages/ConsolePage.jsx index 22e48aa..a42b62a 100644 --- a/src/pages/ConsolePage.jsx +++ b/src/pages/ConsolePage.jsx @@ -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 ; case 'skillDetail': return ; + case 'mySkills': + return switchPage('skillConfig', { subscriptionId })} + onBack={() => switchPage('skills')} + />; + case 'skillConfig': + return switchPage('mySkills')} + />; case 'logs': return ; case 'scheduledTasks': - return { setCurrentTaskId(taskId); switchPage('taskDetail'); - }} + }} />; case 'taskDetail': - return switchPage('scheduledTasks')} + return switchPage('scheduledTasks')} />; case 'account': return ; @@ -168,6 +184,12 @@ function ConsolePage() { active={currentPage === 'skills'} onClick={() => switchPage('skills')} /> + } + label="我的技能" + active={currentPage === 'mySkills'} + onClick={() => switchPage('mySkills')} + /> } label="定时任务" diff --git a/src/pages/console/MySkillsPage.jsx b/src/pages/console/MySkillsPage.jsx new file mode 100644 index 0000000..9615ed1 --- /dev/null +++ b/src/pages/console/MySkillsPage.jsx @@ -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 ( + <> +
+
+
+
+ + handleFilterChange('keyword', e.target.value)} + /> +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+
我的技能
+
+
+ {filteredList.length > 0 ? ( +
+ + + + + + + + + + + + {filteredList.map(item => { + const skill = item.skill; + const cv = skill?.currentVersion; + const statusInfo = getSkillStatus(item); + const delisted = isSkillDelisted(item); + + return ( + + + + + + + + ); + })} + +
技能名称描述分类状态操作
+
+ {cv?.icon || '📦'} + {cv?.publicName || skill?.name || '未知技能'} +
+
{cv?.publicDesc || skill?.desc || '-'}{cv?.category || '-'} + + {statusInfo.text} + + +
+ {!delisted && ( + <> + {item.enabled ? ( + + ) : ( + + )} + + + )} + +
+
+
+ ) : ( + } + message="暂无已订阅技能" + description={filters.keyword || filters.category || filters.status ? '当前筛选条件下没有技能' : '前往技能市场订阅技能'} + /> + )} +
+
+ + {getModalContent()} + + setToast(prev => ({ ...prev, visible: false }))} + /> + + ); +} + +export default MySkillsPage; diff --git a/src/pages/console/SkillConfigPage.jsx b/src/pages/console/SkillConfigPage.jsx new file mode 100644 index 0000000..1bbff20 --- /dev/null +++ b/src/pages/console/SkillConfigPage.jsx @@ -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
加载中...
; + } + + return ( + <> +
+ 返回我的技能 +
+ + {/* 技能基本信息卡片 */} + {cv && ( +
+
+
+
{cv.icon}
+
+
+

{cv.publicName}

+
+
+ {cv.category} + {cv.tags.map(tag => ( + {tag} + ))} +
+

+ {cv.publicDesc} +

+
+
+ + {skill.subs || 0} 订阅 +
+
+ + {cv.rating || 0} 评分 +
+
+ + v{cv.version} +
+
+
+
+
+
+ )} + + {/* 变量配置卡片 */} +
+
+
变量配置
+ +
+
+ {config.length > 0 ? ( +
+ + + + + + + + + + {config.map((item, index) => ( + + + + + + ))} + +
KeyValue操作
+ handleConfigChange(index, 'key', e.target.value)} + placeholder="配置项名称" + /> + {errors[index]?.key && ( +
+ {errors[index].key} +
+ )} +
+ handleConfigChange(index, 'value', e.target.value)} + placeholder="配置项值" + /> + {errors[index]?.value && ( +
+ {errors[index].value} +
+ )} +
+ +
+
+ ) : ( +
+ 暂无配置项,点击右上角"新增配置"添加 +
+ )} +
+ +
+
+
+ + setToast(prev => ({ ...prev, visible: false }))} + /> + + ); +} + +export default SkillConfigPage; diff --git a/src/pages/console/SkillDetailPage.jsx b/src/pages/console/SkillDetailPage.jsx index 94327ea..840b256 100644 --- a/src/pages/console/SkillDetailPage.jsx +++ b/src/pages/console/SkillDetailPage.jsx @@ -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
Skill not found
; } - 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 ( <> -
+
返回技能市场
- {currentVersion ? ( -
-
-
-
{currentVersion.icon}
-
-

{currentVersion.publicName}

-

by {skill.author}

-
- {currentVersion.category} - {currentVersion.tags.map(tag => ( - {tag} - ))} + {cv ? ( + <> + {/* 技能基本信息卡片 - 参考配置页面布局 */} +
+
+
+
{cv.icon}
+
+
+

{cv.publicName}

+
+
+ {cv.category} + {cv.tags.map(tag => ( + {tag} + ))} +
+

+ {cv.publicDesc} +

+
+
+ + {skill.subs || 0} 订阅 +
+
+ + {cv.rating || 0} 评分 +
+
+ + v{cv.version} +
+
-
- 👤 {skill.subs.toLocaleString()} 订阅 - ⭐ {currentVersion.rating || 0} 评分 -
-
-
-
-
+
+ + {/* 使用说明 */} +
+

使用说明

- {currentVersion.publicDesc}。安装后,您可以在对话中直接调用该技能。例如,您可以说: + 安装后,您可以在对话中直接调用该技能。例如,您可以说:

"帮我用这个技能 查询一下数据"
-
+
+ + {/* 文件列表 */} +
+

文件列表

{skillFiles.map(file => (
@@ -82,17 +81,21 @@ function SkillDetailPage({ skillId, onBack }) {
))}
-
+
+ + {/* 当前版本 */} +
+

当前版本

- v{currentVersion.version} - {currentVersion.publicDesc} + v{cv.version} + {cv.publicDesc}
-
+ ) : (
@@ -103,15 +106,6 @@ function SkillDetailPage({ skillId, onBack }) {
)} - - 确定要取消订阅"{currentVersion?.publicName || skill.name}"吗?取消后将无法使用该技能。 - ); } diff --git a/src/pages/console/SkillsPage.jsx b/src/pages/console/SkillsPage.jsx index 7b5d1c9..dc804f4 100644 --- a/src/pages/console/SkillsPage.jsx +++ b/src/pages/console/SkillsPage.jsx @@ -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 }) {
@@ -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 }) {
-
-
- - -
-
{displaySkills.length > 0 ? (
{displaySkills.map(skill => ( @@ -159,16 +154,19 @@ function SkillsPage({ onSkillClick }) { )} - {modalTarget?.subscribed - ? `确定要取消订阅"${modalTarget?.currentVersion?.publicName}"吗?取消后将无法使用该技能。` - : `确定要订阅"${modalTarget?.currentVersion?.publicName}"吗?` - } + 确定要订阅"{modalTarget?.currentVersion?.publicName}"吗? + setToast(prev => ({ ...prev, visible: false }))} + /> ); } diff --git a/src/styles/global.scss b/src/styles/global.scss index d779eca..04b8aa7 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -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; }