- 前端 React+TypeScript (源码+构建产物) - 后端 Python FastAPI - 数据库迁移 001-015 - Docker部署配置 - 微信ClawBot API频道绑定 - 项目文档 README.md
1579 lines
64 KiB
TypeScript
1579 lines
64 KiB
TypeScript
import React, { useState, useMemo, useEffect } from 'react';
|
|
import { marketAPI } from '@/api';
|
|
import type { MarketApp, AppType, AppReview } from '@/types';
|
|
import './MarketPage.css';
|
|
|
|
// ===== SVG Icons =====
|
|
const StarFilledIcon = ({ size = 14 }: { size?: number }) => (
|
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
|
</svg>
|
|
);
|
|
|
|
const StarEmptyIcon = ({ size = 14 }: { size?: number }) => (
|
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
|
</svg>
|
|
);
|
|
|
|
const DownloadIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
|
</svg>
|
|
);
|
|
|
|
const CloseIcon = () => (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
);
|
|
|
|
const SkillIcon = () => (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
|
</svg>
|
|
);
|
|
|
|
const AgentIcon = () => (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="18" height="13" rx="2"/><path d="M12 16v4"/><path d="M8 20h8"/><circle cx="9" cy="9" r="1"/><circle cx="15" cy="9" r="1"/>
|
|
</svg>
|
|
);
|
|
|
|
const McpIcon = () => (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
</svg>
|
|
);
|
|
|
|
const UserIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
|
</svg>
|
|
);
|
|
|
|
const CheckCircleIcon = () => (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
|
|
</svg>
|
|
);
|
|
|
|
const OfficialBadge = () => (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
|
<path d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
|
</svg>
|
|
);
|
|
|
|
const MessageSquareIcon = () => (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
</svg>
|
|
);
|
|
|
|
const PlusIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
</svg>
|
|
);
|
|
|
|
const ImportIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
|
</svg>
|
|
);
|
|
|
|
const ApiIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M4 20h16"/><path d="M4 20V4m16 16V4M8 8h8M8 12h8M8 16h4"/>
|
|
</svg>
|
|
);
|
|
|
|
const ServerIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
|
</svg>
|
|
);
|
|
|
|
// ===== Color palette for avatar icons =====
|
|
const AVATAR_COLORS = [
|
|
{ bg: '#EEEDFF', fg: '#6C63FF' },
|
|
{ bg: '#E6F7EE', fg: '#219B5E' },
|
|
{ bg: '#FEF4E8', fg: '#E8922C' },
|
|
{ bg: '#EBF2FE', fg: '#3B82F6' },
|
|
{ bg: '#FCE7F3', fg: '#EC4899' },
|
|
{ bg: '#E0F2FE', fg: '#0EA5E9' },
|
|
{ bg: '#FEF3C7', fg: '#D97706' },
|
|
{ bg: '#EDE9FE', fg: '#8B5CF6' },
|
|
];
|
|
|
|
function getAvatarFromName(name: string): { letter: string; bg: string; fg: string } {
|
|
const letter = (name || '?').charAt(0).toUpperCase();
|
|
let hash = 0;
|
|
for (let i = 0; i < name.length; i++) {
|
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
const idx = Math.abs(hash) % AVATAR_COLORS.length;
|
|
return { letter, ...AVATAR_COLORS[idx] };
|
|
}
|
|
|
|
function renderAppIcon(app: MarketApp): React.ReactNode {
|
|
const icon = app.icon;
|
|
// 如果icon为空或就是问号,生成首字母头像
|
|
if (!icon || icon === '?') {
|
|
const avatar = getAvatarFromName(app.name);
|
|
return (
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
width: '100%', height: '100%',
|
|
background: avatar.bg, color: avatar.fg,
|
|
fontSize: 18, fontWeight: 700,
|
|
borderRadius: 10,
|
|
}}>
|
|
{avatar.letter}
|
|
</span>
|
|
);
|
|
}
|
|
// 如果icon是单个字符,生成头像
|
|
if (icon.length === 1) {
|
|
const avatar = getAvatarFromName(app.name);
|
|
return (
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
width: '100%', height: '100%',
|
|
background: avatar.bg, color: avatar.fg,
|
|
fontSize: 18, fontWeight: 700,
|
|
borderRadius: 10,
|
|
}}>
|
|
{app.name.charAt(0).toUpperCase()}
|
|
</span>
|
|
);
|
|
}
|
|
// 否则直接渲染icon文本(可能是emoji或其他)
|
|
return <span>{icon}</span>;
|
|
}
|
|
|
|
function renderAppIconLarge(app: MarketApp): React.ReactNode {
|
|
const icon = app.icon;
|
|
if (!icon || icon === '?' || icon.length === 1) {
|
|
const avatar = getAvatarFromName(app.name);
|
|
return (
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
width: '100%', height: '100%',
|
|
background: avatar.bg, color: avatar.fg,
|
|
fontSize: 28, fontWeight: 700,
|
|
borderRadius: 14,
|
|
}}>
|
|
{app.name.charAt(0).toUpperCase()}
|
|
</span>
|
|
);
|
|
}
|
|
return <span style={{ fontSize: 28 }}>{icon}</span>;
|
|
}
|
|
const typeLabels: Record<AppType, string> = {
|
|
skill: 'Skills工具',
|
|
agent: '智能Agent',
|
|
mcp: 'MCP协议应用',
|
|
};
|
|
|
|
const typeIcons: Record<AppType, React.ReactNode> = {
|
|
skill: <SkillIcon />,
|
|
agent: <AgentIcon />,
|
|
mcp: <McpIcon />,
|
|
};
|
|
|
|
// ===== Hub Platforms (修改3 & 修改5) =====
|
|
const skillsHubPlatforms = [
|
|
{ id: 'skills.sh', name: 'skills.sh', url: 'https://www.skills.sh' },
|
|
{ id: 'clawhub', name: 'ClawHub', url: 'https://clawhub.ai' },
|
|
{ id: 'github', name: 'GitHub', url: 'https://github.com' },
|
|
{ id: 'modelscope', name: 'ModelScope', url: 'https://modelscope.cn/skills' },
|
|
{ id: 'skillsmp', name: 'SkillsMP', url: 'https://skillsmp.com' },
|
|
];
|
|
|
|
const mcpHubPlatforms = [
|
|
{ id: 'modelscope-mcp', name: 'ModelScope MCP', url: 'https://modelscope.cn/mcp' },
|
|
];
|
|
|
|
// ===== Agent External Platforms (修改4) =====
|
|
const agentPlatforms = [
|
|
{ id: 'dify', name: 'Dify', description: '开源LLMOps平台,快速构建AI应用', type: 'external' },
|
|
{ id: 'n8n', name: 'n8n', description: '开源自动化工作流平台', type: 'external' },
|
|
{ id: 'coze', name: 'Coze', description: '字节跳动AI Bot开发平台', type: 'external' },
|
|
];
|
|
|
|
// MCP传输类型
|
|
type MCPTransportType = 'streamable-http' | 'sse';
|
|
|
|
// ===== Star Rating =====
|
|
const StarRating: React.FC<{ rating: number; count?: number; size?: number }> = ({ rating, count, size = 14 }) => {
|
|
const stars = [];
|
|
for (let i = 1; i <= 5; i++) {
|
|
if (i <= Math.floor(rating)) {
|
|
stars.push(<StarFilledIcon key={i} size={size} />);
|
|
} else if (i - 0.5 <= rating) {
|
|
stars.push(<StarFilledIcon key={i} size={size} />);
|
|
} else {
|
|
stars.push(<StarEmptyIcon key={i} size={size} />);
|
|
}
|
|
}
|
|
return (
|
|
<span className="star-rating">
|
|
<span className="star-icons">{stars}</span>
|
|
{count !== undefined && <span className="star-count">({count})</span>}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// ===== Helpers =====
|
|
function formatInstallCount(n: number): string {
|
|
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
|
return String(n);
|
|
}
|
|
|
|
// ===== App Detail Modal =====
|
|
interface AppDetailModalProps {
|
|
app: MarketApp;
|
|
onClose: () => void;
|
|
onAppChange?: () => void;
|
|
}
|
|
|
|
// 全局toast函数引用
|
|
let globalShowToast: ((msg: string) => void) | null = null;
|
|
|
|
const AppDetailModal: React.FC<AppDetailModalProps> = ({ app, onClose, onAppChange }) => {
|
|
const [toastMsg, setToastMsg] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
globalShowToast = setToastMsg;
|
|
return () => { globalShowToast = null; };
|
|
}, []);
|
|
|
|
const showToast = (msg: string) => {
|
|
setToastMsg(msg);
|
|
setTimeout(() => setToastMsg(null), 3000);
|
|
};
|
|
const [installed, setInstalled] = useState(!!app.installed);
|
|
const [reviews, setReviews] = useState<AppReview[]>([]);
|
|
const [installing, setInstalling] = useState(false);
|
|
const [uninstalling, setUninstalling] = useState(false);
|
|
|
|
// 当app prop变化时同步installed状态
|
|
useEffect(() => {
|
|
setInstalled(!!app.installed);
|
|
}, [app.id, app.installed]);
|
|
|
|
useEffect(() => {
|
|
// 加载评论
|
|
marketAPI.getReviews(app.id).then((res: any) => {
|
|
if (res.code === 0 && res.data) {
|
|
const items = res.data.items || [];
|
|
setReviews(items.map((r: any) => ({
|
|
id: r.id,
|
|
userId: r.user_id,
|
|
userName: r.username,
|
|
appId: app.id,
|
|
rating: r.rating,
|
|
content: r.content,
|
|
createdAt: r.created_at,
|
|
})));
|
|
}
|
|
}).catch(() => {});
|
|
}, [app.id]);
|
|
|
|
const handleInstall = async () => {
|
|
if (installing) return;
|
|
// 检查是否已安装
|
|
if (installed || app.installed) {
|
|
showToast(`"${app.name}" 已经安装,无需重复安装`);
|
|
return;
|
|
}
|
|
setInstalling(true);
|
|
try {
|
|
const res: any = await marketAPI.installApp(app.id);
|
|
if (res.code === 0) {
|
|
setInstalled(true);
|
|
showToast(`"${app.name}" 安装成功`);
|
|
onAppChange?.();
|
|
}
|
|
} catch (e: any) {
|
|
const msg = e.detail || e.message || '安装失败';
|
|
showToast(`安装失败:${msg}`);
|
|
} finally {
|
|
setInstalling(false);
|
|
}
|
|
};
|
|
|
|
const handleUninstall = async () => {
|
|
if (uninstalling) return;
|
|
if (!window.confirm(`确定要卸载 "${app.name}" 吗?`)) return;
|
|
setUninstalling(true);
|
|
try {
|
|
const res: any = await marketAPI.uninstallApp(app.id);
|
|
if (res.code === 0) {
|
|
setInstalled(false);
|
|
showToast(`"${app.name}" 已卸载`);
|
|
onAppChange?.();
|
|
}
|
|
} catch (e: any) {
|
|
const msg = e.detail || e.message || '卸载失败';
|
|
showToast(`卸载失败:${msg}`);
|
|
} finally {
|
|
setUninstalling(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="market-modal-overlay" onClick={onClose}>
|
|
<div className="market-modal" onClick={e => e.stopPropagation()}>
|
|
<button className="market-modal-close" onClick={onClose}>
|
|
<CloseIcon />
|
|
</button>
|
|
|
|
<div className="market-modal-body">
|
|
{/* App Info */}
|
|
<div className="modal-app-header">
|
|
<div className="modal-app-icon">{renderAppIconLarge(app)}</div>
|
|
<div className="modal-app-info">
|
|
<div className="modal-app-title-row">
|
|
<h2 className="modal-app-name">{app.name}</h2>
|
|
{app.source === 'official' && (
|
|
<span className="official-badge" title="官方认证">
|
|
<OfficialBadge />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="modal-app-meta">
|
|
<span className="modal-app-type-badge">
|
|
{typeIcons[app.type]}
|
|
{typeLabels[app.type]}
|
|
</span>
|
|
<span className="modal-app-publisher">
|
|
<UserIcon />
|
|
{app.publisherName}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rating & Price */}
|
|
<div className="modal-app-stats">
|
|
<div className="modal-stat">
|
|
<StarRating rating={app.ratingAvg} count={app.reviewCount} size={16} />
|
|
</div>
|
|
<div className="modal-stat">
|
|
<span className="modal-stat-label">安装量</span>
|
|
<span className="modal-stat-value">{formatInstallCount(app.installCount)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="modal-app-desc">
|
|
<h4>应用描述</h4>
|
|
<p>{app.description}</p>
|
|
</div>
|
|
|
|
{/* Price & Install */}
|
|
<div className="modal-app-bottom">
|
|
<div className="modal-price-area">
|
|
{app.isMemberFree ? (
|
|
<>
|
|
<span className="modal-price">会员免费</span>
|
|
<span className="modal-original-price">原价 {app.price}元/月</span>
|
|
</>
|
|
) : (
|
|
<span className="modal-price">{app.price} 元/月</span>
|
|
)}
|
|
</div>
|
|
{installed ? (
|
|
<button className="modal-install-btn uninstall" onClick={handleUninstall} disabled={uninstalling}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
</svg>
|
|
{uninstalling ? '卸载中...' : '卸载'}
|
|
</button>
|
|
) : (
|
|
<button className="modal-install-btn" onClick={handleInstall} disabled={installing}>
|
|
<DownloadIcon />
|
|
{installing ? '安装中...' : '安装应用'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reviews */}
|
|
{reviews.length > 0 && (
|
|
<div className="modal-reviews">
|
|
<h4>用户评价 ({reviews.length})</h4>
|
|
<div className="reviews-list">
|
|
{reviews.map(r => (
|
|
<div key={r.id} className="review-item">
|
|
<div className="review-header">
|
|
<div className="review-user">
|
|
<div className="review-avatar">{r.userName.charAt(0).toUpperCase()}</div>
|
|
<span className="review-username">{r.userName}</span>
|
|
</div>
|
|
<StarRating rating={r.rating} size={12} />
|
|
</div>
|
|
<p className="review-content">{r.content}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Toast notification */}
|
|
{toastMsg && (
|
|
<div style={{
|
|
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
|
padding: '10px 20px', borderRadius: 'var(--radius-md)',
|
|
background: toastMsg.includes('失败') ? 'var(--color-error)' : toastMsg.includes('已经') ? 'var(--color-warning)' : 'var(--color-success)',
|
|
color: '#fff', fontSize: 'var(--text-sm)', fontWeight: 500,
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', zIndex: 99999,
|
|
animation: 'fadeIn var(--transition-normal)',
|
|
}}>
|
|
{toastMsg}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===== Create Tool Modal (修改2: 用户自定义, 修改3: Skills特殊逻辑) =====
|
|
interface CreateToolModalProps {
|
|
appType: AppType;
|
|
onClose: () => void;
|
|
onSubmit: (app: Partial<MarketApp>) => void;
|
|
}
|
|
|
|
const CreateToolModal: React.FC<CreateToolModalProps> = ({ appType, onClose, onSubmit }) => {
|
|
const [name, setName] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [isPaid, setIsPaid] = useState(false);
|
|
const [price, setPrice] = useState('');
|
|
const [joinPromotion, setJoinPromotion] = useState(false);
|
|
const [creationMethod, setCreationMethod] = useState<'manual' | 'ai'>('manual');
|
|
const [mcpTransport, setMcpTransport] = useState<MCPTransportType>('streamable-http');
|
|
const [step, setStep] = useState<'form' | 'audit' | 'done'>('form');
|
|
|
|
// Skills-specific: manual creation
|
|
const [skillsContent, setSkillsContent] = useState('');
|
|
const [skillsFile, setSkillsFile] = useState<string | null>(null);
|
|
const [formatCheckResult, setFormatCheckResult] = useState<'idle' | 'checking' | 'pass' | 'fail'>('idle');
|
|
const [formatCheckMsg, setFormatCheckMsg] = useState('');
|
|
|
|
// MCP code editor state
|
|
const [mcpCode, setMcpCode] = useState('');
|
|
const [mcpCodeVerifyResult, setMcpCodeVerifyResult] = useState<'idle' | 'checking' | 'pass' | 'fail'>('idle');
|
|
const [mcpCodeVerifyMsg, setMcpCodeVerifyMsg] = useState('');
|
|
|
|
const handleCheckFormat = () => {
|
|
const content = skillsFile || skillsContent;
|
|
if (!content.trim()) return;
|
|
setFormatCheckResult('checking');
|
|
// Simulate AI format check
|
|
setTimeout(() => {
|
|
const hasMarkdownHeader = content.includes('#') || content.includes('##');
|
|
const hasMinLength = content.trim().length > 50;
|
|
const hasValidStructure = /(description|功能|描述|usage|用法|tool|skill)/i.test(content);
|
|
if (hasMinLength && (hasMarkdownHeader || hasValidStructure)) {
|
|
setFormatCheckResult('pass');
|
|
setFormatCheckMsg('格式检测通过:符合Skills标准规范(工具定义 + 使用说明 + 参数配置)');
|
|
} else {
|
|
setFormatCheckResult('fail');
|
|
setFormatCheckMsg('格式不符合Skills标准:请确保包含工具定义、功能描述、使用说明和参数配置');
|
|
}
|
|
}, 1500);
|
|
};
|
|
|
|
const handleMcpVerify = () => {
|
|
if (!mcpCode.trim()) return;
|
|
setMcpCodeVerifyResult('checking');
|
|
setTimeout(() => {
|
|
const code = mcpCode.trim();
|
|
const hasMinLength = code.length > 20;
|
|
const hasKeywords = /(transport|endpoint|mcp|server|client|connection|url)/i.test(code);
|
|
if (hasMinLength && hasKeywords) {
|
|
setMcpCodeVerifyResult('pass');
|
|
setMcpCodeVerifyMsg('AI验证通过:代码包含必要的传输协议和端点配置');
|
|
} else {
|
|
setMcpCodeVerifyResult('fail');
|
|
setMcpCodeVerifyMsg('AI验证失败:代码中缺少必要的关键词(transport/endpoint),请检查代码完整性');
|
|
}
|
|
}, 1200);
|
|
};
|
|
|
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
const content = ev.target?.result as string;
|
|
setSkillsFile(content);
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
if (!name.trim() || !description.trim()) return;
|
|
// Skills requires format check pass
|
|
if (appType === 'skill' && creationMethod === 'manual' && formatCheckResult !== 'pass') return;
|
|
// MCP requires code verification pass
|
|
if (appType === 'mcp' && mcpCodeVerifyResult !== 'pass') return;
|
|
setStep('audit');
|
|
setTimeout(() => {
|
|
setStep('done');
|
|
setTimeout(() => {
|
|
onSubmit({
|
|
name: name.trim(),
|
|
type: appType,
|
|
description: description.trim(),
|
|
price: isPaid ? parseFloat(price) || 0 : 0,
|
|
isMemberFree: !isPaid,
|
|
joinPromotion,
|
|
source: 'user',
|
|
publisherId: 'u1',
|
|
publisherName: 'demo_user',
|
|
status: 'published' as const,
|
|
icon: appType === 'skill' ? 'S' : appType === 'agent' ? 'A' : 'M',
|
|
});
|
|
}, 1500);
|
|
}, 2000);
|
|
};
|
|
|
|
return (
|
|
<div className="market-modal-overlay" onClick={onClose}>
|
|
<div className="market-modal create-tool-modal" onClick={e => e.stopPropagation()}>
|
|
<div className="market-modal-header">
|
|
<h2>创建{typeLabels[appType]}</h2>
|
|
<button className="market-modal-close" onClick={onClose}><CloseIcon /></button>
|
|
</div>
|
|
<div className="market-modal-body">
|
|
{step === 'form' && (
|
|
<div className="create-tool-form">
|
|
{/* Creation Method */}
|
|
<div className="form-section">
|
|
<label className="form-label">创建方式</label>
|
|
<div className="creation-method-tabs">
|
|
<button
|
|
className={`method-tab ${creationMethod === 'manual' ? 'active' : ''}`}
|
|
onClick={() => setCreationMethod('manual')}
|
|
>手动创建</button>
|
|
<button
|
|
className={`method-tab ${creationMethod === 'ai' ? 'active' : ''}`}
|
|
onClick={() => setCreationMethod('ai')}
|
|
>基于AI聊天开发</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* AI Chat Method - Skills only */}
|
|
{creationMethod === 'ai' && appType === 'skill' && (
|
|
<div className="ai-chat-redirect-card">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
|
|
</svg>
|
|
<div className="ai-chat-redirect-content">
|
|
<strong>即将跳转到AI聊天创建工具</strong>
|
|
<p>系统将为你在AI聊天中创建一个临时新对话窗口。接下来的所有对话内容都将转化为该Skills工具的核心定义——包括功能描述、使用方式、参数配置和输出格式。</p>
|
|
</div>
|
|
<button className="form-submit-btn" onClick={() => {
|
|
window.location.hash = '#/chat';
|
|
onClose();
|
|
}}>
|
|
前往AI聊天
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{creationMethod === 'ai' && appType !== 'skill' && (
|
|
<div className="form-section">
|
|
<label className="form-label">AI描述</label>
|
|
<textarea
|
|
className="form-textarea"
|
|
placeholder="描述你想创建的工具功能和应用场景,AI将自动生成工具配置..."
|
|
value={name || ''}
|
|
onChange={e => setName(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Manual Creation */}
|
|
{(creationMethod === 'manual' || appType !== 'skill') && (
|
|
<>
|
|
{/* Skills: content input or file upload */}
|
|
{appType === 'skill' && (
|
|
<div className="form-section">
|
|
<label className="form-label">Skills内容 <span className="required">*</span></label>
|
|
<textarea
|
|
className="form-textarea skills-content-input"
|
|
placeholder={`请输入符合Skills标准格式的内容,例如:
|
|
|
|
# 工具名称
|
|
## 功能描述
|
|
简要描述该工具的核心功能
|
|
|
|
## 使用方式
|
|
1. 调用方式说明
|
|
2. 参数说明
|
|
3. 返回值说明
|
|
|
|
## 参数配置
|
|
- param1: 参数1说明
|
|
- param2: 参数2说明`}
|
|
value={skillsContent}
|
|
onChange={e => { setSkillsContent(e.target.value); setFormatCheckResult('idle'); }}
|
|
rows={8}
|
|
/>
|
|
<div className="skills-upload-row">
|
|
<label className="file-upload-btn">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
|
</svg>
|
|
上传文件
|
|
<input type="file" accept=".md,.txt,.yaml,.yml,.json" onChange={handleFileUpload} hidden />
|
|
</label>
|
|
<button
|
|
className="format-check-btn"
|
|
onClick={handleCheckFormat}
|
|
disabled={!skillsContent.trim() && !skillsFile}
|
|
>
|
|
{formatCheckResult === 'checking' ? '检测中...' : 'AI格式检测'}
|
|
</button>
|
|
</div>
|
|
{skillsFile && (
|
|
<div className="skills-file-indicator">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
|
</svg>
|
|
文件已加载
|
|
</div>
|
|
)}
|
|
{formatCheckResult === 'checking' && (
|
|
<div className="format-check-status checking">正在AI检测Skills格式...</div>
|
|
)}
|
|
{formatCheckResult === 'pass' && (
|
|
<div className="format-check-status pass">
|
|
<CheckCircleIcon />
|
|
{formatCheckMsg}
|
|
</div>
|
|
)}
|
|
{formatCheckResult === 'fail' && (
|
|
<div className="format-check-status fail">{formatCheckMsg}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Name & Description */}
|
|
<div className="form-section">
|
|
<label className="form-label">名称 <span className="required">*</span></label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
placeholder={`请输入${typeLabels[appType]}名称`}
|
|
value={name}
|
|
onChange={e => setName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<label className="form-label">描述 <span className="required">*</span></label>
|
|
<textarea
|
|
className="form-textarea"
|
|
placeholder="请描述功能、使用场景等..."
|
|
value={description}
|
|
onChange={e => setDescription(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
{/* MCP-specific: Transport Type (修改5) */}
|
|
{appType === 'mcp' && (
|
|
<div className="form-section">
|
|
<label className="form-label">传输类型</label>
|
|
<div className="transport-type-selector">
|
|
<label className={`transport-option ${mcpTransport === 'streamable-http' ? 'active' : ''}`}>
|
|
<input type="radio" name="transport" checked={mcpTransport === 'streamable-http'} onChange={() => setMcpTransport('streamable-http')} />
|
|
<div className="transport-option-content">
|
|
<strong>Streamable HTTP</strong>
|
|
<span>支持流式HTTP传输协议</span>
|
|
</div>
|
|
</label>
|
|
<label className={`transport-option ${mcpTransport === 'sse' ? 'active' : ''}`}>
|
|
<input type="radio" name="transport" checked={mcpTransport === 'sse'} onChange={() => setMcpTransport('sse')} />
|
|
<div className="transport-option-content">
|
|
<strong>SSE</strong>
|
|
<span>Server-Sent Events推送协议</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* MCP: Code Editor + AI验证 */}
|
|
{appType === 'mcp' && (
|
|
<div className="form-section">
|
|
<label className="form-label">MCP客户端代码 <span className="required">*</span></label>
|
|
<textarea
|
|
className="mcp-code-editor"
|
|
placeholder={`粘贴MCP客户端代码,例如:
|
|
const client = new MCPClient({
|
|
transport: {
|
|
type: "${mcpTransport}",
|
|
endpoint: "https://your-mcp-server.com/sse"
|
|
}
|
|
});`}
|
|
value={mcpCode}
|
|
onChange={e => { setMcpCode(e.target.value); setMcpCodeVerifyResult('idle'); }}
|
|
rows={10}
|
|
/>
|
|
<div className="skills-upload-row">
|
|
<span className="mcp-code-hint">请在代码编辑器中粘贴MCP客户端/传输类型代码</span>
|
|
<button
|
|
className="format-check-btn"
|
|
onClick={handleMcpVerify}
|
|
disabled={!mcpCode.trim()}
|
|
>
|
|
{mcpCodeVerifyResult === 'checking' ? '验证中...' : 'AI验证'}
|
|
</button>
|
|
</div>
|
|
{mcpCodeVerifyResult === 'checking' && (
|
|
<div className="format-check-status checking">正在进行AI代码验证...</div>
|
|
)}
|
|
{mcpCodeVerifyResult === 'pass' && (
|
|
<div className="format-check-status pass">
|
|
<CheckCircleIcon />
|
|
{mcpCodeVerifyMsg}
|
|
</div>
|
|
)}
|
|
{mcpCodeVerifyResult === 'fail' && (
|
|
<div className="format-check-status fail">{mcpCodeVerifyMsg}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pricing */}
|
|
<div className="form-section">
|
|
<label className="form-label">收费设置</label>
|
|
<div className="pricing-toggle">
|
|
<button
|
|
className={`toggle-btn ${!isPaid ? 'active' : ''}`}
|
|
onClick={() => setIsPaid(false)}
|
|
>免费</button>
|
|
<button
|
|
className={`toggle-btn ${isPaid ? 'active' : ''}`}
|
|
onClick={() => setIsPaid(true)}
|
|
>付费</button>
|
|
</div>
|
|
{isPaid && (
|
|
<div className="price-input-row">
|
|
<input
|
|
type="number"
|
|
className="form-input price-input"
|
|
placeholder="固定金额"
|
|
value={price}
|
|
onChange={e => setPrice(e.target.value)}
|
|
min="0"
|
|
step="0.01"
|
|
/>
|
|
<span className="price-unit">元/月</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Join Promotion */}
|
|
<div className="form-section">
|
|
<label className="form-checkbox-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={joinPromotion}
|
|
onChange={e => setJoinPromotion(e.target.checked)}
|
|
/>
|
|
<span>参加平台活动推广</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Compliance Notice */}
|
|
<div className="compliance-notice">
|
|
<ServerIcon />
|
|
<span>提交后将经过平台代码合规性审核与AI自动化测试,通过后方可发布展示</span>
|
|
</div>
|
|
|
|
<button
|
|
className="form-submit-btn"
|
|
onClick={handleSubmit}
|
|
disabled={!name.trim() || !description.trim() || (appType === 'skill' && creationMethod === 'manual' && formatCheckResult !== 'pass') || (appType === 'mcp' && mcpCodeVerifyResult !== 'pass')}
|
|
>
|
|
提交审核
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{step === 'audit' && (
|
|
<div className="audit-progress">
|
|
<div className="audit-spinner" />
|
|
<h3>正在审核中...</h3>
|
|
<div className="audit-steps">
|
|
<div className="audit-step done"><CheckCircleIcon /> 代码合规性检查</div>
|
|
<div className="audit-step active"><div className="audit-dot" /> AI自动化测试中...</div>
|
|
<div className="audit-step"><div className="audit-dot-empty" /> 等待发布</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'done' && (
|
|
<div className="audit-done">
|
|
<CheckCircleIcon />
|
|
<h3>审核通过!</h3>
|
|
<p>你的{typeLabels[appType]}已发布至应用市场</p>
|
|
<button className="form-submit-btn" onClick={onClose}>完成</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===== Hub Import Modal (修改3 & 修改5, 修改4: 访问+导入分离) =====
|
|
|
|
// 从URL中智能提取skill/mcp名称
|
|
function extractSkillName(url: string, platformName: string): string {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const pathParts = urlObj.pathname.split('/').filter(p => p.length > 0);
|
|
if (pathParts.length > 0) {
|
|
// 取最后一段作为名称
|
|
let name = pathParts[pathParts.length - 1];
|
|
// 将 - 和 _ 替换为空格
|
|
name = name.replace(/[-_]/g, ' ');
|
|
// 移除文件扩展名 (如 .json, .yaml 等)
|
|
name = name.replace(/\.\w+$/, '');
|
|
// 每个单词首字母大写
|
|
name = name.replace(/\b\w/g, c => c.toUpperCase());
|
|
return name.trim() || `${platformName} Skill`;
|
|
}
|
|
} catch (e) {
|
|
// URL解析失败,使用默认名称
|
|
}
|
|
return `${platformName} Skill`;
|
|
}
|
|
|
|
interface HubImportModalProps {
|
|
appType: 'skill' | 'mcp';
|
|
platforms: typeof skillsHubPlatforms;
|
|
onClose: () => void;
|
|
onImport: (platformName: string, url: string, skillName: string) => void;
|
|
}
|
|
|
|
const HubImportModal: React.FC<HubImportModalProps> = ({ appType, platforms, onClose, onImport }) => {
|
|
const [importUrl, setImportUrl] = useState<Record<string, string>>({});
|
|
const [importingId, setImportingId] = useState<string | null>(null);
|
|
const [importStatus, setImportStatus] = useState<'idle' | 'verifying' | 'checking_format' | 'loading' | 'deploying' | 'published' | 'error'>('idle');
|
|
const [statusMsg, setStatusMsg] = useState('');
|
|
|
|
// MCP code editor state for hub import
|
|
const [hubCode, setHubCode] = useState('');
|
|
const [hubCodeVerifyResult, setHubCodeVerifyResult] = useState<'idle' | 'checking' | 'pass' | 'fail'>('idle');
|
|
const [hubCodeVerifyMsg, setHubCodeVerifyMsg] = useState('');
|
|
|
|
const handleVisit = (url: string) => {
|
|
window.open(url, '_blank');
|
|
};
|
|
|
|
const handleHubMcpVerify = () => {
|
|
if (!hubCode.trim()) return;
|
|
setHubCodeVerifyResult('checking');
|
|
setTimeout(() => {
|
|
const code = hubCode.trim();
|
|
const hasMinLength = code.length > 20;
|
|
const hasKeywords = /(transport|endpoint|mcp|server|client|connection|url)/i.test(code);
|
|
if (hasMinLength && hasKeywords) {
|
|
setHubCodeVerifyResult('pass');
|
|
setHubCodeVerifyMsg('AI验证通过:代码包含必要的传输协议和端点配置,可以导入');
|
|
} else {
|
|
setHubCodeVerifyResult('fail');
|
|
setHubCodeVerifyMsg('AI验证失败:代码中缺少必要的关键词(transport/endpoint),请检查代码完整性');
|
|
}
|
|
}, 1200);
|
|
};
|
|
|
|
const handleImport = (platformId: string) => {
|
|
const url = importUrl[platformId];
|
|
// For Skills: require URL; for MCP: require code verification pass
|
|
if (appType === 'skill' && !url?.trim()) return;
|
|
if (appType === 'mcp' && hubCodeVerifyResult !== 'pass') return;
|
|
setImportingId(platformId);
|
|
setImportStatus('verifying');
|
|
setStatusMsg(appType === 'mcp' ? '正在验证代码...' : '正在验证URL链接...');
|
|
// Simulate 5-step process
|
|
const steps: Array<{ status: typeof importStatus; msg: string; delay: number }> = [
|
|
{ status: 'verifying', msg: appType === 'mcp' ? '代码验证通过...' : 'URL链接验证通过...', delay: 800 },
|
|
{ status: 'checking_format', msg: '正在检测' + (appType === 'skill' ? 'Skills' : 'MCP') + '格式...', delay: 1000 },
|
|
{ status: 'loading', msg: '格式验证通过,正在加载...', delay: 800 },
|
|
{ status: 'deploying', msg: '加载完成,正在部署...', delay: 1000 },
|
|
{ status: 'published', msg: '部署完成,已发布至应用市场!', delay: 800 },
|
|
];
|
|
let delay = 0;
|
|
steps.forEach(step => {
|
|
delay += step.delay;
|
|
setTimeout(() => {
|
|
setImportStatus(step.status);
|
|
setStatusMsg(step.msg);
|
|
if (step.status === 'published') {
|
|
setTimeout(() => {
|
|
onImport(platformId, appType === 'mcp' ? hubCode : url, extractSkillName(url, platformId));
|
|
setImportingId(null);
|
|
setImportStatus('idle');
|
|
setImportUrl(prev => ({ ...prev, [platformId]: '' }));
|
|
setHubCode('');
|
|
setHubCodeVerifyResult('idle');
|
|
setHubCodeVerifyMsg('');
|
|
}, 1000);
|
|
}
|
|
}, delay);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="market-modal-overlay" onClick={onClose}>
|
|
<div className="market-modal hub-import-modal" onClick={e => e.stopPropagation()}>
|
|
<div className="market-modal-header">
|
|
<h2>导入Hub - {appType === 'skill' ? 'Skills' : 'MCP'}</h2>
|
|
<button className="market-modal-close" onClick={onClose}><CloseIcon /></button>
|
|
</div>
|
|
<div className="market-modal-body">
|
|
<div className="hub-import-steps-guide">
|
|
<div className="hub-step"><span className="hub-step-num">1</span> 点击访问按钮前往公共平台</div>
|
|
<div className="hub-step-arrow"/>
|
|
<div className="hub-step"><span className="hub-step-num">2</span> {appType === 'skill' ? '在平台找到目标链接并复制' : '在平台获取MCP客户端代码'}</div>
|
|
<div className="hub-step-arrow"/>
|
|
<div className="hub-step"><span className="hub-step-num">3</span> {appType === 'skill' ? '粘贴链接至输入框并点击导入' : '粘贴代码到编辑器,通过AI验证后点击导入'}</div>
|
|
</div>
|
|
{appType === 'skill' && <div className="hub-free-note">通过以下平台导入的Skills默认为免费。</div>}
|
|
|
|
{/* MCP Hub Import: Code Editor + AI验证 */}
|
|
{appType === 'mcp' && (
|
|
<div className="hub-code-section">
|
|
<label className="form-label">MCP传输代码 <span className="required">*</span></label>
|
|
<textarea
|
|
className="mcp-code-editor"
|
|
placeholder={`粘贴MCP客户端代码(需通过AI验证后才能导入),例如:
|
|
const mcpClient = new MCPClient({
|
|
transport: { type: "streamable-http", endpoint: "https://..." }
|
|
});`}
|
|
value={hubCode}
|
|
onChange={e => { setHubCode(e.target.value); setHubCodeVerifyResult('idle'); }}
|
|
rows={8}
|
|
/>
|
|
<div className="skills-upload-row">
|
|
<span className="mcp-code-hint">请粘贴从Hub获取的MCP客户端代码,通过AI验证后方可导入</span>
|
|
<button
|
|
className="format-check-btn"
|
|
onClick={handleHubMcpVerify}
|
|
disabled={!hubCode.trim()}
|
|
>
|
|
{hubCodeVerifyResult === 'checking' ? '验证中...' : 'AI验证'}
|
|
</button>
|
|
</div>
|
|
{hubCodeVerifyResult === 'checking' && (
|
|
<div className="format-check-status checking">正在进行AI代码验证...</div>
|
|
)}
|
|
{hubCodeVerifyResult === 'pass' && (
|
|
<div className="format-check-status pass">
|
|
<CheckCircleIcon />
|
|
{hubCodeVerifyMsg}
|
|
</div>
|
|
)}
|
|
{hubCodeVerifyResult === 'fail' && (
|
|
<div className="format-check-status fail">{hubCodeVerifyMsg}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="hub-platforms">
|
|
{platforms.map(p => (
|
|
<div key={p.id} className="hub-platform-card">
|
|
<div className="hub-platform-info">
|
|
<div className="hub-platform-icon">
|
|
<ImportIcon />
|
|
</div>
|
|
<div>
|
|
<strong>{p.name}</strong>
|
|
<span className="hub-platform-url">{p.url}</span>
|
|
</div>
|
|
</div>
|
|
<div className="hub-platform-actions">
|
|
<button
|
|
className="hub-visit-btn"
|
|
onClick={() => handleVisit(p.url)}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
|
|
</svg>
|
|
访问
|
|
</button>
|
|
{appType === 'skill' ? (
|
|
<div className="hub-import-col">
|
|
<div className="hub-import-row">
|
|
<input
|
|
type="url"
|
|
className="hub-url-input"
|
|
placeholder="粘贴目标链接..."
|
|
value={importUrl[p.id] || ''}
|
|
onChange={e => setImportUrl(prev => ({ ...prev, [p.id]: e.target.value }))}
|
|
disabled={importingId === p.id}
|
|
/>
|
|
<button
|
|
className="hub-import-btn"
|
|
onClick={() => handleImport(p.id)}
|
|
disabled={!importUrl[p.id]?.trim() || importingId !== null}
|
|
>
|
|
{importingId === p.id ? (
|
|
<span className="hub-importing-text">
|
|
<div className="audit-spinner tiny" />
|
|
{statusMsg}
|
|
</span>
|
|
) : '导入'}
|
|
</button>
|
|
</div>
|
|
{importUrl[p.id]?.trim() && (
|
|
<div className="hub-name-preview">
|
|
检测名称: <strong>{extractSkillName(importUrl[p.id], p.name)}</strong>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="hub-import-row">
|
|
<button
|
|
className="hub-import-btn"
|
|
onClick={() => handleImport(p.id)}
|
|
disabled={hubCodeVerifyResult !== 'pass' || importingId !== null}
|
|
>
|
|
{importingId === p.id ? (
|
|
<span className="hub-importing-text">
|
|
<div className="audit-spinner tiny" />
|
|
{statusMsg}
|
|
</span>
|
|
) : '导入'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{importStatus !== 'idle' && importingId && (
|
|
<div className="hub-import-progress">
|
|
<div className="import-progress-steps">
|
|
{['verifying', 'checking_format', 'loading', 'deploying', 'published'].map((s, i) => (
|
|
<div key={s} className={`import-step ${importStatus === s ? 'active' : (['verifying','checking_format','loading','deploying','published'].indexOf(importStatus) > i ? 'done' : '')}`}>
|
|
<div className="import-step-dot">
|
|
{['verifying','checking_format','loading','deploying','published'].indexOf(importStatus) > i ? <CheckCircleIcon /> : (importStatus === s ? <div className="audit-dot" /> : <div className="audit-dot-empty" />)}
|
|
</div>
|
|
<span>{i === 0 ? 'URL验证' : i === 1 ? '格式验证' : i === 2 ? '加载' : i === 3 ? '部署' : '发布'}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="import-status-msg">{statusMsg}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===== Agent Platform Connect Modal (修改4) =====
|
|
interface AgentConnectModalProps {
|
|
onClose: () => void;
|
|
onConnect: (platformId: string, config: Record<string, string>) => void;
|
|
}
|
|
|
|
const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnect }) => {
|
|
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
|
const [apiKey, setApiKey] = useState('');
|
|
const [endpoint, setEndpoint] = useState('');
|
|
const [connecting, setConnecting] = useState(false);
|
|
const [connected, setConnected] = useState<string | null>(null);
|
|
|
|
const handleConnect = () => {
|
|
if (!selectedPlatform || !apiKey.trim()) return;
|
|
setConnecting(true);
|
|
setTimeout(() => {
|
|
setConnecting(false);
|
|
setConnected(selectedPlatform);
|
|
onConnect(selectedPlatform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() });
|
|
}, 1500);
|
|
};
|
|
|
|
return (
|
|
<div className="market-modal-overlay" onClick={onClose}>
|
|
<div className="market-modal agent-connect-modal" onClick={e => e.stopPropagation()}>
|
|
<div className="market-modal-header">
|
|
<h2>对接外部Agent平台</h2>
|
|
<button className="market-modal-close" onClick={onClose}><CloseIcon /></button>
|
|
</div>
|
|
<div className="market-modal-body">
|
|
<p className="hub-import-desc">
|
|
将外部平台的Agent接入AIFlow应用市场,实现跨平台的Agent管理与调度。
|
|
</p>
|
|
|
|
{!selectedPlatform ? (
|
|
<div className="agent-platforms">
|
|
{agentPlatforms.map(p => (
|
|
<div
|
|
key={p.id}
|
|
className={`agent-platform-card ${connected === p.id ? 'connected' : ''}`}
|
|
onClick={() => { setSelectedPlatform(p.id); setEndpoint(p.id === 'dify' ? 'https://api.dify.ai/v1' : p.id === 'n8n' ? 'https://n8n.example.com/api/v1' : 'https://api.coze.cn/v1'); }}
|
|
>
|
|
<div className="agent-platform-icon">
|
|
<ApiIcon />
|
|
</div>
|
|
<div className="agent-platform-info">
|
|
<strong>{p.name}</strong>
|
|
<span>{p.description}</span>
|
|
</div>
|
|
{connected === p.id ? (
|
|
<span className="connected-badge"><CheckCircleIcon /> 已连接</span>
|
|
) : (
|
|
<span className="connect-indicator">配置</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="agent-config-form">
|
|
<button className="back-btn" onClick={() => setSelectedPlatform(null)}>
|
|
← 返回选择
|
|
</button>
|
|
<div className="form-section">
|
|
<label className="form-label">平台</label>
|
|
<input type="text" className="form-input" value={agentPlatforms.find(p => p.id === selectedPlatform)?.name || ''} disabled />
|
|
</div>
|
|
<div className="form-section">
|
|
<label className="form-label">API Key <span className="required">*</span></label>
|
|
<input
|
|
type="password"
|
|
className="form-input"
|
|
placeholder="请输入API Key"
|
|
value={apiKey}
|
|
onChange={e => setApiKey(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="form-section">
|
|
<label className="form-label">API Endpoint</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
placeholder="https://..."
|
|
value={endpoint}
|
|
onChange={e => setEndpoint(e.target.value)}
|
|
/>
|
|
</div>
|
|
<button
|
|
className="form-submit-btn"
|
|
onClick={handleConnect}
|
|
disabled={!apiKey.trim() || connecting}
|
|
>
|
|
{connecting ? '连接验证中...' : '连接并同步Agent'}
|
|
</button>
|
|
{connecting && (
|
|
<div className="hub-importing-status">
|
|
<div className="audit-spinner small" />
|
|
<span>正在验证连接并同步Agent列表...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
const MarketPage: React.FC = () => {
|
|
const [activeTab, setActiveTab] = useState<AppType | 'all'>('all');
|
|
const [selectedApp, setSelectedApp] = useState<MarketApp | null>(null);
|
|
const [showCreateModal, setShowCreateModal] = useState<AppType | null>(null);
|
|
const [showHubImport, setShowHubImport] = useState<'skill' | 'mcp' | null>(null);
|
|
const [showAgentConnect, setShowAgentConnect] = useState(false);
|
|
const [localApps, setLocalApps] = useState<MarketApp[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [installToast, setInstallToast] = useState<string | null>(null);
|
|
|
|
// Install toast
|
|
const showInstallToast = (msg: string) => {
|
|
setInstallToast(msg);
|
|
setTimeout(() => setInstallToast(null), 3000);
|
|
};
|
|
|
|
// 从后端加载应用列表
|
|
const loadApps = (search?: string) => {
|
|
setLoading(true);
|
|
marketAPI.getApps(search ? { search } : {}).then((res: any) => {
|
|
if (res.code === 0 && res.data) {
|
|
const items = (res.data.items || []).map((item: any) => ({
|
|
id: item.id,
|
|
name: item.name,
|
|
type: item.type,
|
|
source: item.source,
|
|
publisherId: item.publisher_id,
|
|
publisherName: item.publisher_name || '未知',
|
|
description: item.description || '',
|
|
icon: item.icon || '?',
|
|
price: item.price || 0,
|
|
isMemberFree: item.is_member_free || false,
|
|
joinPromotion: item.join_promotion || false,
|
|
status: item.status || 'published',
|
|
ratingAvg: item.rating_avg || 0,
|
|
reviewCount: item.review_count || 0,
|
|
installCount: item.install_count || 0,
|
|
installed: item.installed || false,
|
|
createdAt: item.created_at || new Date().toISOString(),
|
|
}));
|
|
setLocalApps(items);
|
|
}
|
|
}).catch((e: any) => {
|
|
console.error('加载应用列表失败:', e);
|
|
}).finally(() => {
|
|
setLoading(false);
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadApps();
|
|
}, []);
|
|
|
|
const filteredApps = useMemo(() => {
|
|
let base = activeTab === 'all'
|
|
? localApps
|
|
: localApps.filter(a => a.type === activeTab);
|
|
return base;
|
|
}, [activeTab, localApps]);
|
|
|
|
const tabs: { key: AppType | 'all'; label: string }[] = [
|
|
{ key: 'all', label: '全部' },
|
|
{ key: 'skill', label: 'Skills工具' },
|
|
{ key: 'agent', label: '智能Agent' },
|
|
{ key: 'mcp', label: 'MCP协议应用' },
|
|
];
|
|
|
|
// Handle create tool - 调用后端 API 创建应用
|
|
const handleCreateApp = async (data: Partial<MarketApp>) => {
|
|
try {
|
|
await marketAPI.createApp({
|
|
name: data.name,
|
|
type: data.type || 'skill',
|
|
description: data.description || '',
|
|
price: data.price || 0,
|
|
is_member_free: data.isMemberFree ?? true,
|
|
join_promotion: data.joinPromotion ?? false,
|
|
source: 'user',
|
|
});
|
|
// 重新加载列表
|
|
loadApps();
|
|
} catch (e: any) {
|
|
console.error('创建应用失败:', e);
|
|
}
|
|
setShowCreateModal(null);
|
|
};
|
|
|
|
// Handle hub import - 调用后端 API 创建应用
|
|
const handleHubImport = async (platformName: string, url: string, skillName: string) => {
|
|
try {
|
|
await marketAPI.createApp({
|
|
name: skillName,
|
|
type: showHubImport || 'skill',
|
|
description: `从 ${platformName} 导入的${showHubImport === 'skill' ? 'Skills工具' : 'MCP应用'} (来源: ${url})`,
|
|
price: 0,
|
|
is_member_free: true,
|
|
join_promotion: false,
|
|
source: 'user',
|
|
});
|
|
// 重新加载列表
|
|
loadApps();
|
|
} catch (e: any) {
|
|
console.error('导入应用失败:', e);
|
|
}
|
|
setShowHubImport(null);
|
|
};
|
|
|
|
// Handle agent connect - 调用后端 API 创建应用
|
|
const handleAgentConnect = async (platformId: string, config: Record<string, string>) => {
|
|
const platform = agentPlatforms.find(p => p.id === platformId);
|
|
if (!platform) return;
|
|
try {
|
|
await marketAPI.createApp({
|
|
name: `${platform.name} Agent`,
|
|
type: 'agent',
|
|
description: `从 ${platform.name} 对接导入的智能Agent`,
|
|
price: 0,
|
|
is_member_free: true,
|
|
join_promotion: false,
|
|
source: 'user',
|
|
agent_endpoint: config.endpoint || '',
|
|
});
|
|
// 重新加载列表
|
|
loadApps();
|
|
} catch (e: any) {
|
|
console.error('对接Agent失败:', e);
|
|
}
|
|
setShowAgentConnect(false);
|
|
};
|
|
|
|
// Handle uninstall from card
|
|
const handleCardUninstall = async (appId: string) => {
|
|
// 乐观更新:立即标记为已卸载
|
|
setLocalApps(prev => prev.map(a => a.id === appId ? { ...a, installed: false } : a));
|
|
try {
|
|
const res: any = await marketAPI.uninstallApp(appId);
|
|
if (res.code === 0) {
|
|
showInstallToast('已卸载');
|
|
// 更新详情弹窗中的app状态
|
|
if (selectedApp && selectedApp.id === appId) {
|
|
setSelectedApp(prev => prev ? { ...prev, installed: false } : null);
|
|
}
|
|
} else {
|
|
// 回滚
|
|
loadApps();
|
|
showInstallToast('卸载失败,请重试');
|
|
}
|
|
} catch (e) {
|
|
// 回滚
|
|
loadApps();
|
|
showInstallToast('卸载失败,请重试');
|
|
}
|
|
};
|
|
|
|
// Handle install from card
|
|
const handleCardInstall = async (appId: string) => {
|
|
// 乐观更新:立即标记为已安装
|
|
setLocalApps(prev => prev.map(a => a.id === appId ? { ...a, installed: true } : a));
|
|
try {
|
|
const res: any = await marketAPI.installApp(appId);
|
|
if (res.code === 0) {
|
|
showInstallToast('安装成功');
|
|
if (selectedApp && selectedApp.id === appId) {
|
|
setSelectedApp(prev => prev ? { ...prev, installed: true } : null);
|
|
}
|
|
} else {
|
|
loadApps();
|
|
showInstallToast('安装失败,请重试');
|
|
}
|
|
} catch (e) {
|
|
loadApps();
|
|
showInstallToast('安装失败,请重试');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="market-page">
|
|
<div className="market-page-inner">
|
|
{/* Page Header */}
|
|
<div className="market-page-header">
|
|
<h1 className="market-title">应用市场</h1>
|
|
<p className="market-subtitle">探索丰富的AI应用,提升你的工作效率</p>
|
|
</div>
|
|
|
|
{/* Tabs + Action Buttons */}
|
|
<div className="market-toolbar">
|
|
<div className="market-tabs">
|
|
{tabs.map(tab => (
|
|
<button
|
|
key={tab.key}
|
|
className={`market-tab ${activeTab === tab.key ? 'active' : ''}`}
|
|
onClick={() => setActiveTab(tab.key)}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="market-actions">
|
|
{/* Skills: Import Hub (修改3) - only on skill tab */}
|
|
{activeTab === 'skill' && (
|
|
<button className="action-btn hub-btn" onClick={() => setShowHubImport('skill')}>
|
|
<ImportIcon />
|
|
<span>导入Hub</span>
|
|
</button>
|
|
)}
|
|
{/* MCP: Import Hub (修改5) - only on mcp tab */}
|
|
{activeTab === 'mcp' && (
|
|
<button className="action-btn hub-btn" onClick={() => setShowHubImport('mcp')}>
|
|
<ImportIcon />
|
|
<span>导入Hub</span>
|
|
</button>
|
|
)}
|
|
{/* Agent: Connect Platform (修改4) - only on agent tab */}
|
|
{activeTab === 'agent' && (
|
|
<button className="action-btn connect-btn" onClick={() => setShowAgentConnect(true)}>
|
|
<ApiIcon />
|
|
<span>平台对接</span>
|
|
</button>
|
|
)}
|
|
{/* Custom Create (修改2) */}
|
|
{activeTab !== 'all' && (
|
|
<button className="action-btn create-btn" onClick={() => setShowCreateModal(activeTab)}>
|
|
<PlusIcon />
|
|
<span>自定义</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* MCP Client Badge (修改5) */}
|
|
{activeTab === 'mcp' && (
|
|
<div className="mcp-client-badge">
|
|
<ServerIcon />
|
|
<span>MCP应用作为Client客户端运行,连接外部MCP Server获取能力</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search Bar */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)', padding: 'var(--space-md) 0', position: 'relative' }}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ position: 'absolute', left: 10, zIndex: 1, pointerEvents: 'none' }}>
|
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
placeholder="搜索应用名称或描述..."
|
|
value={searchQuery}
|
|
onChange={e => { setSearchQuery(e.target.value); loadApps(e.target.value); }}
|
|
style={{ paddingLeft: 32, flex: 1, maxWidth: 400 }}
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
style={{ padding: '4px 10px', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', cursor: 'pointer', border: 'none', background: 'none' }}
|
|
onClick={() => setSearchQuery('')}
|
|
>清除</button>
|
|
)}
|
|
{searchQuery && (
|
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
|
|
找到 {filteredApps.length} 个结果
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* App Grid */}
|
|
{loading ? (
|
|
<div className="market-empty">
|
|
<p>加载中...</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="market-grid">
|
|
{filteredApps.map(app => (
|
|
<div
|
|
key={app.id}
|
|
className="market-card"
|
|
onClick={() => setSelectedApp(app)}
|
|
>
|
|
<div className="market-card-top">
|
|
<div className="market-card-icon">{renderAppIcon(app)}</div>
|
|
<div className="market-card-info">
|
|
<div className="market-card-name-row">
|
|
<h3 className="market-card-name">{app.name}</h3>
|
|
{app.source === 'official' && (
|
|
<span className="official-badge" title="官方认证">
|
|
<OfficialBadge />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="market-card-publisher">
|
|
<UserIcon />
|
|
{app.publisherName}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<p className="market-card-desc">{app.description}</p>
|
|
<div className="market-card-bottom">
|
|
<StarRating rating={app.ratingAvg} count={app.reviewCount} />
|
|
<div className="market-card-right">
|
|
{app.installed ? (
|
|
<button
|
|
className="market-card-uninstall-btn"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleCardUninstall(app.id);
|
|
}}
|
|
>
|
|
卸载
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="market-card-install-btn"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleCardInstall(app.id);
|
|
}}
|
|
>
|
|
{app.isMemberFree ? '免费安装' : `${app.price}元/月`}
|
|
</button>
|
|
)}
|
|
<span className="market-card-installs">
|
|
<DownloadIcon />
|
|
{formatInstallCount(app.installCount)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{filteredApps.length === 0 && (
|
|
<div className="market-empty">
|
|
<p>该分类暂无可用应用</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* App Detail Modal */}
|
|
{selectedApp && (
|
|
<AppDetailModal
|
|
app={selectedApp}
|
|
onClose={() => setSelectedApp(null)}
|
|
onAppChange={loadApps}
|
|
/>
|
|
)}
|
|
|
|
{/* Create Tool Modal (修改2) */}
|
|
{showCreateModal && (
|
|
<CreateToolModal
|
|
appType={showCreateModal}
|
|
onClose={() => setShowCreateModal(null)}
|
|
onSubmit={handleCreateApp}
|
|
/>
|
|
)}
|
|
|
|
{/* Hub Import Modal (修改3 & 修改5) */}
|
|
{showHubImport && (
|
|
<HubImportModal
|
|
appType={showHubImport}
|
|
platforms={showHubImport === 'skill' ? skillsHubPlatforms : mcpHubPlatforms}
|
|
onClose={() => setShowHubImport(null)}
|
|
onImport={handleHubImport}
|
|
/>
|
|
)}
|
|
|
|
{/* Agent Connect Modal (修改4) */}
|
|
{showAgentConnect && (
|
|
<AgentConnectModal
|
|
onClose={() => setShowAgentConnect(false)}
|
|
onConnect={handleAgentConnect}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MarketPage;
|