Files
ai-learning-platform/frontend/src/app/affiliate/page.tsx
T
TradeMate Dev 31cc1e02ce feat: 联盟返佣系统规范化并打通技能广场(无 ICP 证合规变现主路径)
- 新增 AffiliateProgram / AffiliateLink / AffiliateClick 规范化 Prisma 模型
  取代原手写裸表 affiliate_stats / affiliate_clicks
- 新增迁移 prisma/migrations/20260711000000_add_affiliate_models
- 重构 affiliate.service.ts 改用 Prisma ORM,消除 $queryRawUnsafe SQL 注入
- 重构 affiliate.controller.ts 接口:programs / links(?skillId,?toolId) / stats / click
- 前端 affiliate 页接入真实接口,移除硬编码 demo 数据
- 技能详情页新增「学此技能推荐使用的工具」联盟链接区块
- 新增 affiliate.service.spec.ts(4 用例通过)与幂等种子 seed-affiliate.ts
- 更新 docs/progress/current.md,明确无 ICP 经营许可证下以联盟返佣为合规变现主路径
- 含此前工作区未提交改动(工具 slug 路由、支付/订单、SEO 等)

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-11 14:47:41 +08:00

219 lines
8.4 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalLink, Link as LinkIcon, DollarSign, ArrowUpRight, BarChart3 } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useT } from '@/i18n';
interface AffiliateLink {
id: number;
title: string;
description: string | null;
url: string;
thumbnail: string | null;
isFeatured: boolean;
tags: string | null;
program?: { name: string; provider?: string | null };
}
interface AffiliateStats {
totalClicks: number;
totalRevenue: number;
topLinks: { id: number; title: string; clicks: number; estimatedRevenue: number }[];
}
function SkeletonCard() {
return (
<Card className="p-5">
<div className="flex items-start gap-3">
<Skeleton className="h-10 w-10 rounded-xl" />
<div className="flex-1">
<Skeleton className="h-5 w-1/2 mb-2" />
<Skeleton className="h-4 w-full mb-1" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</Card>
);
}
export default function AffiliatePage() {
const t = useT();
const [links, setLinks] = useState<AffiliateLink[]>([]);
const [stats, setStats] = useState<AffiliateStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/affiliate/links`).then((r) => r.json()),
fetch(`${API_BASE}/affiliate/stats`).then((r) => r.json()),
])
.then(([linksData, statsData]) => {
setLinks(linksData.links || []);
setStats(statsData);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
async function handlePromote(link: AffiliateLink) {
try {
const res = await fetch(`${API_BASE}/affiliate/click/${link.id}`, { method: 'POST' });
const data = await res.json();
if (data.redirectUrl) window.open(data.redirectUrl, '_blank', 'noopener,noreferrer');
} catch {
window.open(link.url, '_blank', 'noopener,noreferrer');
}
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-10">
<h1 className="text-3xl font-bold text-foreground">{t.affiliate.title}</h1>
<p className="mt-2 text-muted-foreground">{t.affiliate.desc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Card className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{t.affiliate.totalClicks}</p>
<p className="text-2xl font-bold text-foreground mt-1">
{(stats?.totalClicks ?? 0).toLocaleString()}
</p>
</div>
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-xl flex items-center justify-center">
<ArrowUpRight className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
</div>
</Card>
<Card className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{t.affiliate.totalRevenue}</p>
<p className="text-2xl font-bold text-foreground mt-1">
¥{(stats?.totalRevenue ?? 0).toLocaleString()}
</p>
</div>
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-xl flex items-center justify-center">
<DollarSign className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
</div>
</Card>
<Card className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{t.affiliate.partnerTools}</p>
<p className="text-2xl font-bold text-foreground mt-1">{links.length}</p>
</div>
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-xl flex items-center justify-center">
<BarChart3 className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
</div>
</Card>
</div>
<div className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-4">{t.affiliate.recommendTools}</h2>
{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) => (
<SkeletonCard key={i} />
))}
</div>
) : links.length === 0 ? (
<Card className="p-8 text-center text-muted-foreground">{t.affiliate.noData}</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{links.map((link) => (
<Card key={link.id} className="p-5 hover:shadow-md transition-all">
<div className="flex items-start gap-3 mb-3">
<div className="w-10 h-10 bg-brand-100 dark:bg-brand-900/30 rounded-xl flex items-center justify-center shrink-0">
<LinkIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold">{link.title}</h3>
{link.isFeatured && <Badge variant="secondary"></Badge>}
</div>
{link.program && (
<p className="text-xs text-muted-foreground mt-0.5">{link.program.name}</p>
)}
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{link.description}
</p>
</div>
</div>
<button
onClick={() => handlePromote(link)}
className="w-full text-center py-2 px-3 bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors text-sm"
>
{t.affiliate.promoteLink}
</button>
{link.tags && (
<div className="flex gap-1 mt-3 flex-wrap">
{link.tags
.split(',')
.slice(0, 3)
.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</Card>
))}
</div>
)}
</div>
{stats && stats.topLinks.length > 0 && (
<div>
<h2 className="text-xl font-semibold text-foreground mb-4">{t.affiliate.ranking}</h2>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-4 text-sm font-medium text-muted-foreground">
{t.affiliate.tool}
</th>
<th className="text-right p-4 text-sm font-medium text-muted-foreground">
{t.affiliate.clicks}
</th>
<th className="text-right p-4 text-sm font-medium text-muted-foreground">
{t.affiliate.revenue}
</th>
</tr>
</thead>
<tbody>
{stats.topLinks.map((item, index) => (
<tr key={item.id} className="border-t border-border">
<td className="p-4 font-medium">
<span className="text-muted-foreground mr-2">#{index + 1}</span>
{item.title}
</td>
<td className="p-4 text-right">{item.clicks.toLocaleString()}</td>
<td className="p-4 text-right font-medium text-green-600">
¥{item.estimatedRevenue.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
<p className="mt-10 text-xs text-muted-foreground text-center max-w-2xl mx-auto">
{t.affiliate.disclaimer}
</p>
</div>
);
}