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>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Wrench, ExternalLink, Star, ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface ToolDetail {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
metaTitle: string | null;
|
||||
metaDesc: string | null;
|
||||
description: string | null;
|
||||
content: string | null;
|
||||
url: string;
|
||||
icon: string | null;
|
||||
affiliateLink: string | null;
|
||||
tags: string | null;
|
||||
isFeatured: boolean;
|
||||
viewCount: number;
|
||||
category: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
async function getTool(slug: string): Promise<ToolDetail | null> {
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1'}/tools/${slug}`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
|
||||
const tool = await getTool(params.slug);
|
||||
if (!tool) return { title: '工具未找到' };
|
||||
|
||||
return {
|
||||
title: tool.metaTitle || `${tool.name} - 功能介绍、使用教程、评测 | 宇之然 AI 工具指南`,
|
||||
description: tool.metaDesc || tool.description || `${tool.name} AI工具详细介绍、使用教程和评测`,
|
||||
openGraph: {
|
||||
title: tool.metaTitle || `${tool.name} - AI工具评测`,
|
||||
description: tool.metaDesc || tool.description || '',
|
||||
type: 'article',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
let apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1';
|
||||
// Build-time fetch requires absolute URL; if relative, prefix localhost:4000
|
||||
if (!apiBase.startsWith('http')) {
|
||||
apiBase = `http://localhost:4000${apiBase.startsWith('/') ? '' : '/'}${apiBase}`;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/tools?pageSize=100`);
|
||||
const data = await res.json();
|
||||
return (data.items || []).map((tool: any) => ({ slug: tool.slug }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ToolDetailPage({ params }: { params: { slug: string } }) {
|
||||
const tool = await getTool(params.slug);
|
||||
if (!tool) notFound();
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
url: tool.url,
|
||||
applicationCategory: 'AI工具',
|
||||
};
|
||||
|
||||
// 解析描述中的结构化字段
|
||||
function parseSection(desc: string | null, key: string): string | null {
|
||||
if (!desc) return null;
|
||||
const match = desc.match(new RegExp(`【${key}】\\s*(.+?)(?:\\n\\n|$)`, 's'));
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
function parseBaseDesc(desc: string | null): string {
|
||||
if (!desc) return '';
|
||||
return desc.split('\n\n')[0] || '';
|
||||
}
|
||||
const reason = parseSection(tool.description, '推荐理由');
|
||||
const audience = parseSection(tool.description, '适合人群');
|
||||
const baseDesc = parseBaseDesc(tool.description);
|
||||
|
||||
// 收益标签映射
|
||||
const roiTags: Record<string, string> = {
|
||||
'编程': '日省2-4小时编码时间',
|
||||
'图像': '替代部分设计师,省¥1000-5000/月',
|
||||
'视频': '短视频效率提升5-10倍',
|
||||
'对话': '替代部分人工客服,省¥3000-8000/月',
|
||||
'办公': '文档效率提升3-5倍',
|
||||
'搜索': '调研效率提升5-10倍',
|
||||
'语音': '语音处理效率提升10倍',
|
||||
};
|
||||
const category = tool.tags ? tool.tags.split(',')[0] : '';
|
||||
const roiTag = roiTags[category] || '提升工作效率';
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 面包屑导航 */}
|
||||
<nav className="flex items-center gap-2 text-sm text-muted-foreground mb-6">
|
||||
<Link href="/" className="hover:text-foreground">首页</Link>
|
||||
<span>/</span>
|
||||
<Link href="/tools" className="hover:text-foreground">AI工具</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{tool.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* 工具头部 */}
|
||||
<div className="flex items-start gap-4 mb-8">
|
||||
<div className="w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-2xl flex items-center justify-center shrink-0">
|
||||
{tool.icon ? (
|
||||
<img src={tool.icon} alt={tool.name} className="w-10 h-10" />
|
||||
) : (
|
||||
<Wrench className="w-8 h-8 text-brand-600 dark:text-brand-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-3xl font-bold text-foreground">{tool.name}</h1>
|
||||
{tool.isFeatured && <Star className="w-5 h-5 text-amber-500 fill-amber-500" />}
|
||||
</div>
|
||||
{tool.category && (
|
||||
<Badge variant="secondary" className="mt-2">{tool.category.name}</Badge>
|
||||
)}
|
||||
{baseDesc && <p className="mt-3 text-lg text-muted-foreground">{baseDesc}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 推荐理由 + 收益标签 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||
{reason && (
|
||||
<Card className="p-5 bg-gradient-to-br from-brand-50 to-amber-50/50 dark:from-brand-950/40 dark:to-amber-950/20 border-brand-200 dark:border-brand-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">💡</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-brand-700 dark:text-brand-300 text-sm mb-1">推荐理由</h3>
|
||||
<p className="text-sm text-foreground">{reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<Card className="p-5 bg-gradient-to-br from-emerald-50 to-teal-50/50 dark:from-emerald-950/40 dark:to-teal-950/20 border-emerald-200 dark:border-emerald-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">📈</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-emerald-700 dark:text-emerald-300 text-sm mb-1">预期收益</h3>
|
||||
<p className="text-sm text-foreground">{roiTag}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 适用人群 */}
|
||||
{audience && (
|
||||
<Card className="p-5 mb-8 bg-gradient-to-br from-purple-50 to-pink-50/50 dark:from-purple-950/40 dark:to-pink-950/20 border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">👥</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-purple-700 dark:text-purple-300 text-sm mb-1">适合人群</h3>
|
||||
<p className="text-sm text-foreground">{audience}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-3 mb-10">
|
||||
<a href={tool.url} target="_blank" rel="noopener noreferrer">
|
||||
<Button className="gap-2">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
访问 {tool.name}
|
||||
</Button>
|
||||
</a>
|
||||
<Link href="/sandbox">
|
||||
<Button variant="outline" className="gap-2">
|
||||
在沙盒中体验
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 详细介绍 */}
|
||||
{tool.content && (
|
||||
<Card className="p-6 mb-8">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
{tool.content.split('\n').map((line, i) => {
|
||||
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-bold mt-6 mb-3">{line.slice(3)}</h2>;
|
||||
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-bold mt-4 mb-2">{line.slice(4)}</h3>;
|
||||
if (line.startsWith('- **')) {
|
||||
const match = line.match(/- \*\*(.+?)\*\*(:?)\s*(.*)/);
|
||||
if (match) return <p key={i}><strong>{match[1]}</strong>{match[3] && `: ${match[3]}`}</p>;
|
||||
}
|
||||
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>;
|
||||
if (line.trim() === '') return <div key={i} className="h-2" />;
|
||||
return <p key={i} className="mb-2">{line}</p>;
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{tool.tags && (
|
||||
<div className="flex gap-2 flex-wrap mb-8">
|
||||
{tool.tags.split(',').map(tag => (
|
||||
<Badge key={tag} variant="secondary">{tag.trim()}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<Card className="p-6 bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-950/30 dark:to-blue-950/20">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">准备好使用 {tool.name} 了吗?</h3>
|
||||
</div>
|
||||
<a href={tool.url} target="_blank" rel="noopener noreferrer">
|
||||
<Button>立即使用 →</Button>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 浏览量 */}
|
||||
<p className="text-xs text-muted-foreground mt-6 text-center">
|
||||
此页面已被浏览 {tool.viewCount} 次
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user