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

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