Files
qihuaiflow/frontend/src/pages/front/MarketPage.tsx
T

1793 lines
75 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: '#5B5FED' },
{ 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' },
{ id: 'mcp.so', name: 'MCP.so', url: 'https://mcp.so' },
{ id: 'mcpmarket', name: 'MCPMarket', url: 'https://mcpmarket.cn' },
];
// ===== 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.href = '/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, price?: number, description?: 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 [importPrice, setImportPrice] = useState<number>(0);
const [importStatus, setImportStatus] = useState<'idle' | 'verifying' | 'checking_format' | 'loading' | 'deploying' | 'published' | 'error'>('idle');
const [statusMsg, setStatusMsg] = useState('');
// MCP code editor state for hub import
// shared: used by the standalone custom-import button
const [hubCode, setHubCode] = useState('');
const [hubCodeVerifyResult, setHubCodeVerifyResult] = useState<'idle' | 'checking' | 'pass' | 'fail'>('idle');
const [hubCodeVerifyMsg, setHubCodeVerifyMsg] = useState('');
// per-platform: each MCP platform has its own Streamable HTTP code input
const [hubCodeMap, setHubCodeMap] = useState<Record<string, string>>({});
const [hubCodeVerifyResultMap, setHubCodeVerifyResultMap] = useState<Record<string, 'idle' | 'checking' | 'pass' | 'fail'>>({});
const [hubCodeVerifyMsgMap, setHubCodeVerifyMsgMap] = useState<Record<string, string>>({});
const [hubName, setHubName] = useState('');
const [hubDescription, setHubDescription] = 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 handleHubMcpVerifyForPlatform = (platformId: string) => {
const code = hubCodeMap[platformId] || '';
if (!code.trim()) return;
setHubCodeVerifyResultMap(prev => ({ ...prev, [platformId]: 'checking' }));
setHubCodeVerifyMsgMap(prev => ({ ...prev, [platformId]: '' }));
setTimeout(() => {
const trimmed = code.trim();
const hasMinLength = trimmed.length > 20;
const hasKeywords = /(transport|endpoint|mcp|server|client|connection|url)/i.test(trimmed);
if (hasMinLength && hasKeywords) {
setHubCodeVerifyResultMap(prev => ({ ...prev, [platformId]: 'pass' }));
setHubCodeVerifyMsgMap(prev => ({ ...prev, [platformId]: 'AI验证通过:代码包含必要的传输协议和端点配置,可以导入' }));
} else {
setHubCodeVerifyResultMap(prev => ({ ...prev, [platformId]: 'fail' }));
setHubCodeVerifyMsgMap(prev => ({ ...prev, [platformId]: 'AI验证失败:代码中缺少必要的关键词(transport/endpoint),请检查代码完整性' }));
}
}, 1200);
};
const handleImport = (platformId: string) => {
const url = importUrl[platformId];
const isCustomImport = platformId === 'custom-import';
const platformCode = isCustomImport ? hubCode : (hubCodeMap[platformId] || '');
// For Skills: require URL; for MCP: require code verification pass + name
if (appType === 'skill' && !url?.trim()) return;
if (appType === 'mcp') {
if (isCustomImport) {
if (hubCodeVerifyResult !== 'pass') return;
} else {
if (hubCodeVerifyResultMap[platformId] !== 'pass') return;
}
if (!hubName.trim()) 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(() => {
const name = appType === 'skill' ? (hubName || extractSkillName(url || '', platformId)) : hubName;
const importPayload = appType === 'mcp' ? platformCode : (url || '');
onImport(platformId, importPayload, name, importPrice, hubDescription);
setImportingId(null);
setImportStatus('idle');
setImportUrl(prev => ({ ...prev, [platformId]: '' }));
setHubCode('');
setHubCodeVerifyResult('idle');
setHubCodeVerifyMsg('');
setHubCodeMap(prev => ({ ...prev, [platformId]: '' }));
setHubCodeVerifyResultMap(prev => ({ ...prev, [platformId]: 'idle' }));
setHubCodeVerifyMsgMap(prev => ({ ...prev, [platformId]: '' }));
setHubName('');
setHubDescription('');
}, 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>
<div className="hub-price-section">
<label className="form-label">定价(元)</label>
<input
type="number"
min="0"
step="0.01"
className="hub-price-input"
value={importPrice}
onChange={e => setImportPrice(parseFloat(e.target.value) || 0)}
placeholder="0"
/>
<span className="hub-price-hint">
用户导入的{appType === 'skill' ? 'Skills' : 'MCP'}:是否收费由您决定(0 为免费)。管理员导入默认免费且审核通过。
</span>
</div>
{/* Name & Description */}
<div className="form-section">
<label className="form-label">名称 <span className="required">*</span></label>
<input
type="text"
className="form-input"
placeholder={appType === 'skill' ? '请输入Skills工具名称' : '请输入MCP应用名称'}
value={hubName}
onChange={e => setHubName(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">说明</label>
<textarea
className="form-textarea"
placeholder="请描述功能、使用场景等..."
value={hubDescription}
onChange={e => setHubDescription(e.target.value)}
rows={3}
/>
</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>
)}
{/* MCP 独立导入按钮:直接导入(不依赖选择平台卡片) */}
{appType === 'mcp' && hubCodeVerifyResult === 'pass' && (
<div className="hub-standalone-import" style={{ textAlign: 'center', marginBottom: 20, padding: '16px', background: 'var(--color-bg-secondary)', borderRadius: 8 }}>
<button
className="hub-import-btn"
style={{ padding: '10px 40px', fontSize: 15, fontWeight: 600 }}
onClick={() => handleImport('custom-import')}
disabled={!hubName.trim()}
>
{importingId === 'custom-import' ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div className="audit-spinner tiny" />
导入中...
</span>
) : '导入'}
</button>
{!hubName.trim() && <div style={{ color: '#f85149', fontSize: 12, marginTop: 6 }}>请先输入名称</div>}
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 6 }}>
直接从传输代码导入,无需选择平台
</div>
</div>
)}
<div className="hub-platforms">
{platforms.map(p => (
<div key={p.id} className="hub-platform-card">
<div className="hub-platform-header">
<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>
<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>
</div>
{appType === 'skill' ? (
<>
<div className="hub-import-col">
<input
type="url"
className="hub-url-input full"
placeholder="粘贴目标链接..."
value={importUrl[p.id] || ''}
onChange={e => setImportUrl(prev => ({ ...prev, [p.id]: e.target.value }))}
disabled={importingId === p.id}
/>
<div className="hub-import-actions">
{importUrl[p.id]?.trim() && (
<span className="hub-name-preview">
检测名称: <strong>{extractSkillName(importUrl[p.id], p.name)}</strong>
</span>
)}
<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>
</div>
</>
) : (
<div className="hub-import-col">
<div className="hub-import-actions">
<button
className="hub-import-btn"
onClick={() => handleImport(p.id)}
disabled={hubCodeVerifyResultMap[p.id] !== 'pass' || importingId !== null}
>
{importingId === p.id ? (
<span className="hub-importing-text">
<div className="audit-spinner tiny" />
{statusMsg}
</span>
) : '导入'}
</button>
</div>
</div>
)}
{appType === 'mcp' && (
<div className="hub-platform-code-section">
<label className="form-label">Streamable HTTP 传输代码 <span className="required">*</span></label>
<textarea
className="mcp-code-editor"
placeholder={`粘贴 ${p.name} 的 MCP 客户端代码(需通过AI验证后才能导入),例如:
const mcpClient = new MCPClient({
transport: { type: "streamable-http", endpoint: "https://..." }
});`}
value={hubCodeMap[p.id] || ''}
onChange={e => {
setHubCodeMap(prev => ({ ...prev, [p.id]: e.target.value }));
setHubCodeVerifyResultMap(prev => ({ ...prev, [p.id]: 'idle' }));
}}
rows={6}
/>
<div className="skills-upload-row">
<span className="mcp-code-hint">请粘贴从 {p.name} 获取的 MCP 客户端代码,通过AI验证后方可导入</span>
<button
className="format-check-btn"
onClick={() => handleHubMcpVerifyForPlatform(p.id)}
disabled={!(hubCodeMap[p.id] || '').trim()}
>
{hubCodeVerifyResultMap[p.id] === 'checking' ? '验证中...' : 'AI验证'}
</button>
</div>
{hubCodeVerifyResultMap[p.id] === 'checking' && (
<div className="format-check-status checking">正在进行AI代码验证...</div>
)}
{hubCodeVerifyResultMap[p.id] === 'pass' && (
<div className="format-check-status pass">
<CheckCircleIcon />
{hubCodeVerifyMsgMap[p.id]}
</div>
)}
{hubCodeVerifyResultMap[p.id] === 'fail' && (
<div className="format-check-status fail">{hubCodeVerifyMsgMap[p.id]}</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 单接入 Modal(修改8:以单个Agent方式接入,含名称/介绍) =====
interface AgentConnectModalProps {
onClose: () => void;
onConnect: (platformId: string, config: Record<string, string>, price?: number, name?: string, description?: string) => void;
}
const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnect }) => {
const [platform, setPlatform] = useState<string>('dify');
const [name, setName] = useState('');
const [intro, setIntro] = useState('');
const [apiKey, setApiKey] = useState('');
const [endpoint, setEndpoint] = useState('');
const [price, setPrice] = useState<number>(0);
const [connecting, setConnecting] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verifyMsg, setVerifyMsg] = useState<string>('');
const [verifyOk, setVerifyOk] = useState<boolean | null>(null);
const runVerify = async (): Promise<boolean | null> => {
if (!endpoint.trim() || !apiKey.trim()) {
setVerifyMsg('请先填写 API Endpoint 与 API Key');
setVerifyOk(false);
return false;
}
setVerifying(true);
setVerifyMsg('正在校验连通性与密钥...');
setVerifyOk(null);
try {
const res: any = await marketAPI.verifyAgent({
platform,
endpoint: endpoint.trim(),
api_key: apiKey.trim(),
});
if (res && res.code === 0 && res.data && res.data.ok) {
setVerifyOk(true);
setVerifyMsg('✓ ' + (res.data.detail || '校验通过'));
return true;
}
setVerifyOk(false);
setVerifyMsg('✗ ' + ((res && res.data && res.data.detail) || '校验未通过'));
return false;
} catch (e: any) {
setVerifyOk(false);
setVerifyMsg('✗ 校验请求失败:' + (e?.message || '网络错误'));
return false;
} finally {
setVerifying(false);
}
};
const handleConnect = async () => {
if (!name.trim() || !endpoint.trim() || !apiKey.trim()) return;
// 接入前必须先通过连通性 / 鉴权校验
const ok = await runVerify();
if (!ok) return;
setConnecting(true);
try {
await onConnect(platform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price, name.trim(), intro.trim());
} finally {
setConnecting(false);
}
};
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 方式接入(例如 Dify 中每个 Agent 都有独立的访问地址与密钥)。请填写该 Agent 的名称、介绍及连接信息。
</p>
<div className="form-section">
<label className="form-label">所属平台</label>
<select className="form-input" value={platform} onChange={e => setPlatform(e.target.value)}>
{agentPlatforms.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div className="form-section">
<label className="form-label">Agent 名称 <span className="required">*</span></label>
<input
type="text"
className="form-input"
placeholder="例如:我的客服 Agent"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">Agent 介绍</label>
<textarea
className="form-input"
rows={3}
placeholder="简要描述该 Agent 的能力与用途"
value={intro}
onChange={e => setIntro(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">API Endpoint(该 Agent 专属地址)<span className="required">*</span></label>
<input
type="text"
className="form-input"
placeholder="https://..."
value={endpoint}
onChange={e => setEndpoint(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">API Key <span className="required">*</span></label>
<input
type="password"
className="form-input"
placeholder="请输入该 Agent 的 API Key"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">定价(元)</label>
<input
type="number"
min="0"
step="0.01"
className="form-input"
placeholder="0 为免费"
value={price}
onChange={e => setPrice(parseFloat(e.target.value) || 0)}
/>
<span className="hub-price-hint">用户对接的 Agent 是否收费由您决定;管理员导入默认免费且审核通过。</span>
</div>
<div className="form-section agent-verify-row">
<button
type="button"
className="form-test-btn"
onClick={runVerify}
disabled={!endpoint.trim() || !apiKey.trim() || verifying}
>
{verifying ? '校验中...' : '测试连接'}
</button>
{verifyMsg && (
<span className={`agent-verify-msg ${verifyOk === true ? 'ok' : verifyOk === false ? 'err' : ''}`}>
{verifyMsg}
</span>
)}
</div>
<button
className="form-submit-btn"
onClick={handleConnect}
disabled={!name.trim() || !endpoint.trim() || !apiKey.trim() || connecting || verifying}
>
{connecting ? '接入中...' : '接入 Agent'}
</button>
</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, page_size: 100 } : { page_size: 100 }).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, price?: number, description?: string) => {
try {
const isMcp = showHubImport === 'mcp';
const desc = description || `从 ${platformName} 导入的${isMcp ? 'MCP应用' : 'Skills工具'}${!isMcp ? ` (来源: ${url})` : ''}`;
const payload: any = {
name: skillName,
type: showHubImport || 'skill',
description: desc,
price: price || 0,
is_member_free: (price || 0) === 0,
join_promotion: false,
source: 'user',
};
if (isMcp && url) {
payload.download_url = url;
}
await marketAPI.createApp(payload);
// 重新加载列表
loadApps();
} catch (e: any) {
console.error('导入应用失败:', e);
}
setShowHubImport(null);
};
// Handle agent connect - 调用后端 API 创建应用(单个Agent方式接入)
const handleAgentConnect = async (platformId: string, config: Record<string, string>, price?: number, name?: string, description?: string) => {
const platform = agentPlatforms.find(p => p.id === platformId);
const finalName = (name && name.trim()) ? name.trim() : `${platform?.name || '外部'} Agent`;
const finalDesc = (description && description.trim())
? description.trim()
: `从 ${platform?.name || '外部平台'} 对接导入的智能Agent`;
try {
await marketAPI.createApp({
name: finalName,
type: 'agent',
description: finalDesc,
price: price || 0,
is_member_free: (price || 0) === 0,
join_promotion: false,
source: 'user',
agent_endpoint: config.endpoint || '',
api_key: config.apiKey || '',
platform: platformId || 'dify',
});
// 重新加载列表
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;