2026-05-25: 等级体系升级(20级)+论坛二级分类+会员空间
This commit is contained in:
+74
-103
@@ -1,32 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
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";
|
||||
|
||||
interface ToolDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string;
|
||||
pricingModel: string;
|
||||
pricingDetail: string | null;
|
||||
features: unknown;
|
||||
tags: unknown;
|
||||
viewCount: number;
|
||||
favorited?: boolean;
|
||||
favoriteCount?: number;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const pricingLabel: Record<string, string> = {
|
||||
free: "免费",
|
||||
freemium: "免费增值",
|
||||
@@ -59,78 +39,71 @@ const tagColors = [
|
||||
"border-rose-200 bg-rose-50 text-rose-700",
|
||||
];
|
||||
|
||||
export default function ToolDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const slug = params.slug as string;
|
||||
const { status } = useSession();
|
||||
interface PageProps {
|
||||
params: { slug: string };
|
||||
}
|
||||
|
||||
const [tool, setTool] = useState<ToolDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/tools/${slug}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("工具不存在");
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setTool(data);
|
||||
if (status === "authenticated" && data?.id) {
|
||||
fetch("/api/user/history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ toolId: data.id }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [slug, status]);
|
||||
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 (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="animate-pulse space-y-8">
|
||||
<div className="h-4 w-20 rounded bg-muted/40" />
|
||||
<div className="rounded-2xl border border-border/50 bg-white p-8 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex gap-6">
|
||||
<div className="h-20 w-20 rounded-2xl bg-muted/40" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-8 w-56 rounded bg-muted/40" />
|
||||
<div className="h-5 w-40 rounded bg-muted/30" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 space-y-2">
|
||||
<div className="h-4 w-full rounded bg-muted/30" />
|
||||
<div className="h-4 w-3/4 rounded bg-muted/30" />
|
||||
<div className="h-4 w-1/2 rounded bg-muted/20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!tool) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (error || !tool) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col items-center px-4 py-24 sm:px-6 lg:px-8">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-lg font-medium text-muted-foreground">{error || "工具不存在"}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
|
||||
返回
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
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 features = Array.isArray(tool.features) ? tool.features : [];
|
||||
const tags = Array.isArray(tool.tags) ? tool.tags : [];
|
||||
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">
|
||||
@@ -142,9 +115,9 @@ export default function ToolDetailPage() {
|
||||
|
||||
<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">
|
||||
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
|
||||
<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>
|
||||
<button onClick={() => router.push("/tools")} className="transition-colors hover:text-foreground">全部工具</button>
|
||||
<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>
|
||||
@@ -169,17 +142,17 @@ export default function ToolDetailPage() {
|
||||
<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={tool.favorited ?? false} />
|
||||
<FavoriteButton type="tool" id={tool.id} initialFavorited={favorited} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{tool.category && (
|
||||
<button
|
||||
onClick={() => router.push(`/categories/${tool.category!.slug}`)}
|
||||
<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}
|
||||
</button>
|
||||
</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">
|
||||
@@ -192,14 +165,12 @@ export default function ToolDetailPage() {
|
||||
)}
|
||||
<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>
|
||||
{tool.viewCount} 次浏览
|
||||
{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>
|
||||
{tool.favoriteCount !== undefined && (
|
||||
<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>
|
||||
{tool.favoriteCount} 人收藏
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -322,4 +293,4 @@ export default function ToolDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user