Files
ai-learning-platform/frontend/src/app/tools/[slug]/page.tsx
T
TradeMate Dev 09bf329544 feat(seo): 结构化数据/每页元数据/列表页metadata/hreflang修正/OG分享图
- 详情页注入 Article+BreadcrumbList JSON-LD,工具页 SoftwareApplication
- 课程/技能/文章/练习 动态 generateMetadata(canonical/OG)
- 新增 skills/contents/practices/prompts/models 列表页 metadata
- 修正 hreflang(删除不存在的/en),用 next/og 生成 OG 分享图
2026-07-11 17:23:56 +08:00

250 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { SITE_URL } from '@/lib/config';
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: 'UtilitiesApplication',
operatingSystem: 'Web',
offers: { '@type': 'Offer', price: '0', priceCurrency: 'CNY' },
publisher: {
'@type': 'Organization',
name: '宇之然 AI',
url: SITE_URL,
logo: { '@type': 'ImageObject', url: `${SITE_URL}/favicon.png` },
},
};
// 解析描述中的结构化字段
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>
</>
);
}