31cc1e02ce
- 新增 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>
219 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|