Files
zhuiguang-ai/src/app/tools/[slug]/page.tsx
T

326 lines
17 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useSession } from "next-auth/react";
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: "免费增值",
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",
];
export default function ToolDetailPage() {
const router = useRouter();
const params = useParams();
const slug = params.slug as string;
const { status } = useSession();
const [tool, setTool] = useState<ToolDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
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]);
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 (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>
);
}
const features = Array.isArray(tool.features) ? tool.features : [];
const tags = Array.isArray(tool.tags) ? tool.tags : [];
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">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<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>
<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={tool.favorited ?? false} />
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{tool.category && (
<button
onClick={() => router.push(`/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>
)}
<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>
{tool.viewCount} 次浏览
</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>
<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>
);
}