09bf329544
- 详情页注入 Article+BreadcrumbList JSON-LD,工具页 SoftwareApplication - 课程/技能/文章/练习 动态 generateMetadata(canonical/OG) - 新增 skills/contents/practices/prompts/models 列表页 metadata - 修正 hreflang(删除不存在的/en),用 next/og 生成 OG 分享图
250 lines
9.7 KiB
TypeScript
250 lines
9.7 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
} |