P8 平台轻量化改造 + SSG修复 + 编程导师 + 文档完善
- Prisma: Tool 模型加 affiliateLink;免费用户沙盒 10→5 次/日 - 后端: Tools API + /admin/tools CRUD 5 端点;Practices 完整模块 - 导航: 主菜单隐藏企业版/社区(URL 可访问) - 首页: 重定位为 AI 工具指南;新增精选工具区块;Feature 重写 - 工具页: affiliateLink 绿色推荐 Badge - SSG 修复: config.ts 构建时直连 localhost:4000,页面 108→127 - 沙盒: 新增编程导师场景(苏格拉底教学法) - 练习系统: Practices 多场景练习(含结构化评分) - 技能广场: 6 个付费 Skill(标题大师/回款助手等) - 管理后台: Models/Posts/Practices CRUD 页面 - 文档: README + progress.md 全面更新;AGENTS.md 同步定位 - 清理: .env.example 移除;tsbuildinfo gitignore
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/auth';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import PaymentModal from '@/components/ui/payment-modal';
|
||||
import { useT } from '@/i18n';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { ShoppingBag, CheckCircle, Lock, Sparkles } from 'lucide-react';
|
||||
|
||||
interface MarketplaceSkill {
|
||||
id: string; name: string; description: string; icon: string;
|
||||
category: string; difficulty: string; tags: string[];
|
||||
price: number | null; purchased: boolean; sortOrder: number;
|
||||
}
|
||||
|
||||
type FilterMode = 'all' | 'free' | 'premium' | 'purchased';
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
const { isLoggedIn } = useAuth();
|
||||
const [skills, setSkills] = useState<MarketplaceSkill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<FilterMode>('all');
|
||||
const [payLoading, setPayLoading] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [paymentModal, setPaymentModal] = useState<{
|
||||
open: boolean; orderNo: string; payResult: any; payChannel: 'wxpay' | 'alipay';
|
||||
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
|
||||
|
||||
useEffect(() => {
|
||||
const url = `${API_BASE}/skills/marketplace`;
|
||||
fetch(url, { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setSkills(Array.isArray(data) ? data : []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = skills.filter(s => {
|
||||
if (filter === 'free') return !s.price;
|
||||
if (filter === 'premium') return !!s.price;
|
||||
if (filter === 'purchased') return s.purchased;
|
||||
return true;
|
||||
});
|
||||
|
||||
async function handleBuy(skill: MarketplaceSkill) {
|
||||
if (!isLoggedIn) { router.push('/auth'); return; }
|
||||
setPayLoading(skill.id);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await apiFetch('/orders/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: skill.price,
|
||||
planType: 'SKILL',
|
||||
skillId: skill.id,
|
||||
payChannel: 'alipay',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.order && data.payResult) {
|
||||
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
|
||||
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
|
||||
if (payUrl || qrCode) {
|
||||
setPaymentModal({ open: true, orderNo: data.order.orderNo, payResult: data.payResult, payChannel: 'alipay' });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setPayLoading(null);
|
||||
}
|
||||
|
||||
function handlePaymentPaid() {
|
||||
setPaymentModal(prev => ({ ...prev, open: false }));
|
||||
setSuccess(t.marketplace.purchaseSuccess);
|
||||
}
|
||||
|
||||
const filters: { key: FilterMode; label: string }[] = [
|
||||
{ key: 'all', label: t.common.viewAll },
|
||||
{ key: 'free', label: t.marketplace.free },
|
||||
{ key: 'premium', label: t.marketplace.locked },
|
||||
{ key: 'purchased', label: t.marketplace.purchased },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">{t.marketplace.title}</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t.marketplace.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{filters.map(f => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
filter === f.key
|
||||
? 'bg-foreground text-background'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 shrink-0" />
|
||||
<span className="text-sm text-green-800 dark:text-green-300">{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1,2,3,4,5,6].map(i => <Skeleton key={i} className="h-52 rounded-2xl" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map(skill => {
|
||||
const isPremium = !!skill.price;
|
||||
const isOwned = skill.purchased;
|
||||
return (
|
||||
<div key={skill.id}
|
||||
className={`bg-card rounded-2xl border-2 p-6 transition-all hover:shadow-md flex flex-col ${
|
||||
isPremium && !isOwned ? 'border-amber-500/40' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
{isPremium && !isOwned && (
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 mb-2">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span>{t.marketplace.locked}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-3xl">{skill.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground">{skill.name}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{skill.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
skill.difficulty === 'beginner' ? 'bg-green-100 text-green-700' :
|
||||
skill.difficulty === 'intermediate' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{(t.skills as any)[skill.difficulty]}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{(t.skills.categories as any)[skill.category] || skill.category}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{skill.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2">
|
||||
{isOwned ? (
|
||||
<>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
{t.marketplace.purchased}
|
||||
</Badge>
|
||||
<Button asChild size="sm" variant="default" className="ml-auto">
|
||||
<Link href={`/sandbox?skill=${skill.id}`}>
|
||||
{t.skills.apply}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : isPremium ? (
|
||||
<>
|
||||
<span className="text-lg font-bold text-foreground">¥{skill.price}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleBuy(skill)}
|
||||
disabled={payLoading === skill.id}
|
||||
className="ml-auto"
|
||||
>
|
||||
{payLoading === skill.id ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="animate-spin h-3 w-3 border-2 border-current border-t-transparent rounded-full" />
|
||||
{t.marketplace.buying}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<ShoppingBag className="h-3.5 w-3.5" />
|
||||
{t.marketplace.buy.replace('{price}', String(skill.price))}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild size="sm" variant="outline" className="ml-auto">
|
||||
<Link href={`/sandbox?skill=${skill.id}`}>
|
||||
{t.skills.apply}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<ShoppingBag className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">{t.common.noData}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaymentModal
|
||||
open={paymentModal.open}
|
||||
orderNo={paymentModal.orderNo}
|
||||
payResult={paymentModal.payResult}
|
||||
payChannel={paymentModal.payChannel}
|
||||
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
|
||||
onPaid={handlePaymentPaid}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user