296 lines
15 KiB
TypeScript
296 lines
15 KiB
TypeScript
import { prisma } from "@/lib/prisma";
|
|
import { notFound } from "next/navigation";
|
|
import { getServerSession } from "next-auth";
|
|
import { authOptions } from "@/lib/auth";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
|
import { ReviewSection } from "@/components/common/ReviewSection";
|
|
|
|
const pricingLabel: Record<string, string> = {
|
|
free: "免费",
|
|
freemium: "免费增值",
|
|
paid: "付费",
|
|
subscription: "订阅",
|
|
};
|
|
|
|
const pricingColor: Record<string, string> = {
|
|
free: "bg-emerald-50 text-emerald-700 border border-emerald-200",
|
|
freemium: "bg-blue-50 text-blue-700 border border-blue-200",
|
|
paid: "bg-amber-50 text-amber-700 border border-amber-200",
|
|
subscription: "bg-purple-50 text-purple-700 border border-purple-200",
|
|
};
|
|
|
|
const pricingIcon: Record<string, string> = {
|
|
free: "M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z",
|
|
freemium: "M13 10V3L4 14h7v7l9-11h-7z",
|
|
paid: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
|
subscription: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15",
|
|
};
|
|
|
|
const tagColors = [
|
|
"border-blue-200 bg-blue-50 text-blue-700",
|
|
"border-purple-200 bg-purple-50 text-purple-700",
|
|
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
|
"border-amber-200 bg-amber-50 text-amber-700",
|
|
"border-pink-200 bg-pink-50 text-pink-700",
|
|
"border-cyan-200 bg-cyan-50 text-cyan-700",
|
|
"border-indigo-200 bg-indigo-50 text-indigo-700",
|
|
"border-rose-200 bg-rose-50 text-rose-700",
|
|
];
|
|
|
|
interface PageProps {
|
|
params: { slug: string };
|
|
}
|
|
|
|
export async function generateMetadata({ params }: PageProps) {
|
|
const tool = await prisma.tool.findUnique({
|
|
where: { slug: params.slug, status: "published" },
|
|
select: { name: true, description: true },
|
|
});
|
|
if (!tool) {
|
|
return { title: "工具不存在" };
|
|
}
|
|
return {
|
|
title: `${tool.name} - 追光AI工具详情`,
|
|
description: tool.description,
|
|
};
|
|
}
|
|
|
|
export default async function ToolDetailPage({ params }: PageProps) {
|
|
const tool = await prisma.tool.findUnique({
|
|
where: { slug: params.slug, status: "published" },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
description: true,
|
|
logoUrl: true,
|
|
websiteUrl: true,
|
|
pricingModel: true,
|
|
pricingDetail: true,
|
|
features: true,
|
|
tags: true,
|
|
viewCount: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
category: { select: { id: true, name: true, slug: true } },
|
|
},
|
|
});
|
|
|
|
if (!tool) {
|
|
notFound();
|
|
}
|
|
|
|
prisma.tool.update({
|
|
where: { id: tool.id },
|
|
data: { viewCount: { increment: 1 } },
|
|
}).catch(() => {});
|
|
|
|
const session = await getServerSession(authOptions);
|
|
let favorited = false;
|
|
if (session?.user?.id) {
|
|
const fav = await prisma.favorite.findFirst({
|
|
where: { userId: parseInt(session.user.id), toolId: tool.id },
|
|
select: { id: true },
|
|
});
|
|
favorited = !!fav;
|
|
}
|
|
|
|
const favoriteCount = await prisma.favorite.count({
|
|
where: { toolId: tool.id },
|
|
});
|
|
|
|
const displayViewCount = tool.viewCount + 1;
|
|
const features = (Array.isArray(tool.features) ? tool.features as string[] : []);
|
|
const tags = (Array.isArray(tool.tags) ? tool.tags as string[] : []);
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
|
<div className="absolute -top-20 left-1/4 h-[400px] w-[600px] rounded-full bg-primary/5 blur-[120px]" />
|
|
<div className="absolute top-40 right-0 h-[300px] w-[300px] rounded-full bg-purple-500/4 blur-[80px]" />
|
|
<div className="absolute bottom-20 -left-20 h-[250px] w-[250px] rounded-full bg-cyan-500/3 blur-[80px]" />
|
|
</div>
|
|
|
|
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 animate-fade-in">
|
|
<nav className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
|
|
<a href="/" className="transition-colors hover:text-foreground">首页</a>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
|
<a href="/tools" className="transition-colors hover:text-foreground">全部工具</a>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
|
<span className="text-foreground truncate max-w-[200px]">{tool.name}</span>
|
|
</nav>
|
|
|
|
<div className="rounded-2xl border border-border/60 bg-white p-6 sm:p-8 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_4px_12px_rgba(0,0,0,0.04)]">
|
|
<div className="relative z-10 flex flex-col gap-6 sm:flex-row sm:items-start">
|
|
<div className="shrink-0">
|
|
{tool.logoUrl ? (
|
|
<img
|
|
src={tool.logoUrl}
|
|
referrerPolicy="no-referrer"
|
|
alt=""
|
|
className="h-20 w-20 rounded-2xl border border-border/50 bg-white object-contain p-1.5 shadow-md"
|
|
/>
|
|
) : (
|
|
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-3xl font-bold text-primary shadow-md">
|
|
{tool.name.charAt(0)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-bold tracking-tight">{tool.name}</h1>
|
|
<FavoriteButton type="tool" id={tool.id} initialFavorited={favorited} />
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
{tool.category && (
|
|
<a
|
|
href={`/categories/${tool.category.slug}`}
|
|
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground transition-all duration-200 hover:border-primary/30 hover:text-foreground hover:bg-white hover:shadow-sm"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
|
|
{tool.category.name}
|
|
</a>
|
|
)}
|
|
<span className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d={pricingIcon[tool.pricingModel] || "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"} />
|
|
</svg>
|
|
{pricingLabel[tool.pricingModel] || tool.pricingModel}
|
|
</span>
|
|
{tool.pricingDetail && (
|
|
<span className="text-xs text-muted-foreground/60">{tool.pricingDetail}</span>
|
|
)}
|
|
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
|
{displayViewCount} 次浏览
|
|
</span>
|
|
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /></svg>
|
|
{favoriteCount} 人收藏
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<a
|
|
href={tool.websiteUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="hidden sm:block"
|
|
>
|
|
<Button className="gap-2 glow-sm">
|
|
访问官网
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
|
</Button>
|
|
</a>
|
|
</div>
|
|
|
|
<div className="relative z-10 mt-4 sm:hidden">
|
|
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer" className="block">
|
|
<Button className="w-full gap-2 glow-sm">
|
|
访问官网
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
|
</Button>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 space-y-4">
|
|
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
|
<CardContent className="p-6">
|
|
<h2 className="mb-4 flex items-center gap-2.5 text-lg font-semibold">
|
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-primary">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
简介
|
|
</h2>
|
|
<p className="whitespace-pre-wrap leading-relaxed text-muted-foreground/80 text-[15px]">
|
|
{tool.description || "暂无简介"}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{features.length > 0 && (
|
|
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
|
<CardContent className="p-6">
|
|
<h2 className="mb-4 flex items-center gap-2.5 text-lg font-semibold">
|
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-100">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-600">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
功能特性
|
|
</h2>
|
|
<ul className="grid gap-2.5 sm:grid-cols-2">
|
|
{features.map((f: string, i: number) => (
|
|
<li
|
|
key={i}
|
|
className="group flex items-center gap-3 rounded-xl border border-border/40 bg-white px-4 py-3 text-sm text-muted-foreground transition-all duration-200 hover:bg-white hover:border-border/60 hover:shadow-sm"
|
|
>
|
|
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 transition-colors group-hover:bg-emerald-200">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" className="text-emerald-600">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" stroke="currentColor" strokeWidth="3" />
|
|
</svg>
|
|
</div>
|
|
{f}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{tags.length > 0 && (
|
|
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
|
<CardContent className="p-6">
|
|
<h2 className="mb-4 flex items-center gap-2.5 text-lg font-semibold">
|
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-purple-100">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-purple-600">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
|
</svg>
|
|
</div>
|
|
标签
|
|
</h2>
|
|
<div className="flex flex-wrap gap-2">
|
|
{tags.map((t: string, i: number) => (
|
|
<span
|
|
key={i}
|
|
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-all duration-200 hover:scale-105 ${tagColors[i % tagColors.length]}`}
|
|
>
|
|
{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-6">
|
|
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
|
<CardContent className="p-6">
|
|
<ReviewSection type="tool" targetId={tool.id} showRating={true} />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="mt-8 flex items-center justify-between rounded-xl border border-border/50 bg-white px-5 py-3.5 text-xs text-muted-foreground/70 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
|
<span className="flex items-center gap-1.5">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
|
收录时间: {new Date(tool.createdAt).toLocaleDateString("zh-CN")}
|
|
</span>
|
|
{tool.updatedAt !== tool.createdAt && (
|
|
<span className="flex items-center gap-1.5">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
|
更新时间: {new Date(tool.updatedAt).toLocaleDateString("zh-CN")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |