2026-05-25: 等级体系升级(20级)+论坛二级分类+会员空间
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
children: Category[];
|
||||
}
|
||||
|
||||
interface Tool {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string;
|
||||
pricingModel: string;
|
||||
viewCount: number;
|
||||
favorited?: boolean;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
}
|
||||
|
||||
const pricingLabel: Record<string, string> = {
|
||||
free: "免费",
|
||||
freemium: "免费增值",
|
||||
paid: "付费",
|
||||
subscription: "订阅",
|
||||
};
|
||||
|
||||
const pricingColor: Record<string, string> = {
|
||||
free: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
|
||||
freemium: "bg-blue-500/15 text-blue-400 border-blue-500/20",
|
||||
paid: "bg-amber-500/15 text-amber-400 border-amber-500/20",
|
||||
subscription: "bg-purple-500/15 text-purple-400 border-purple-500/20",
|
||||
};
|
||||
|
||||
const pricingDot: Record<string, string> = {
|
||||
free: "bg-emerald-400",
|
||||
freemium: "bg-blue-400",
|
||||
paid: "bg-amber-400",
|
||||
subscription: "bg-purple-400",
|
||||
};
|
||||
|
||||
const pricingOptions = [
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "free", label: "免费" },
|
||||
{ value: "freemium", label: "免费增值" },
|
||||
{ value: "paid", label: "付费" },
|
||||
{ value: "subscription", label: "订阅" },
|
||||
];
|
||||
|
||||
const categoryIcons: Record<string, string> = {
|
||||
"文本生成": "M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7",
|
||||
"图像生成": "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z",
|
||||
"视频生成": "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z",
|
||||
"音频处理": "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z",
|
||||
"代码助手": "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
|
||||
"办公效率": "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
|
||||
"AI 角色": "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
|
||||
"设计工具": "M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01",
|
||||
"数据分析": "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z",
|
||||
"内容检测": "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
|
||||
};
|
||||
|
||||
interface ToolsContentProps {
|
||||
initialCategories: Category[];
|
||||
initialTools: Tool[];
|
||||
initialTotal: number;
|
||||
}
|
||||
|
||||
export default function ToolsContent({ initialCategories, initialTools, initialTotal }: ToolsContentProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [categories] = useState<Category[]>(initialCategories);
|
||||
const [tools, setTools] = useState<Tool[]>(initialTools);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const search = searchParams.get("search") || "";
|
||||
const categoryId = searchParams.get("categoryId") || "";
|
||||
const pricingModel = searchParams.get("pricingModel") || "";
|
||||
const pageSize = 12;
|
||||
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
pageSize: pageSize.toString(),
|
||||
});
|
||||
if (search) params.set("search", search);
|
||||
if (categoryId) params.set("categoryId", categoryId);
|
||||
if (pricingModel) params.set("pricingModel", pricingModel);
|
||||
|
||||
const res = await fetch(`/api/tools?${params}`);
|
||||
const data = await res.json();
|
||||
setTools(data.tools || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
setTools([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, categoryId, pricingModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
fetchTools();
|
||||
}, [fetchTools]);
|
||||
|
||||
const updateParams = (updates: Record<string, string>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const activeCategoryName = categoryId
|
||||
? categories.find((c) => c.id.toString() === categoryId)?.name || ""
|
||||
: "全部分类";
|
||||
|
||||
const activePricingName = pricingModel
|
||||
? pricingOptions.find((p) => p.value === pricingModel)?.label || ""
|
||||
: "";
|
||||
|
||||
const Sidebar = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path 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>
|
||||
分类筛选
|
||||
</h3>
|
||||
<nav className="space-y-0.5">
|
||||
<button
|
||||
onClick={() => updateParams({ categoryId: "" })}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
|
||||
!categoryId
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
|
||||
!categoryId ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
|
||||
}`}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
全部分类
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => updateParams({ categoryId: cat.id.toString() })}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
|
||||
categoryId === cat.id.toString()
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
|
||||
categoryId === cat.id.toString() ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
|
||||
}`}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={categoryIcons[cat.name] || "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"} />
|
||||
</svg>
|
||||
</div>
|
||||
{cat.name}
|
||||
{categoryId === cat.id.toString() && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="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>
|
||||
定价模型
|
||||
</h3>
|
||||
<nav className="space-y-0.5">
|
||||
{pricingOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => updateParams({ pricingModel: opt.value })}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
|
||||
pricingModel === opt.value
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{opt.value ? (
|
||||
<span className={`h-2 w-2 rounded-full transition-all duration-200 ${
|
||||
pricingModel === opt.value ? "scale-125" : pricingDot[opt.value] || "bg-muted-foreground/40"
|
||||
}`} style={{ backgroundColor: pricingModel === opt.value ? undefined : undefined }} />
|
||||
) : (
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-muted/50 group-hover:bg-accent">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{opt.label}
|
||||
{pricingModel === opt.value && opt.value && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
|
||||
|
||||
<div className="rounded-xl border border-border/50 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
💡 点击分类或定价模型进行筛选,支持组合筛选
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
|
||||
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 animate-fade-in">
|
||||
<div className="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>
|
||||
<span className="text-foreground">全部工具</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-3xl font-bold tracking-tight">
|
||||
探索<span className="gradient-text">AI</span>工具
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
发现最适合你的AI工具,提升工作效率与创造力
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
<aside className="hidden w-60 shrink-0 md:block">
|
||||
<div className="sticky top-24 rounded-xl border border-border/50 bg-white p-4 scrollbar-thin shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && updateParams({ search: searchInput })
|
||||
}
|
||||
placeholder="搜索AI工具..."
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-muted/50 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 focus:bg-white focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => { setSearchInput(""); updateParams({ search: "" }); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="default"
|
||||
onClick={() => updateParams({ search: searchInput })}
|
||||
className="h-11 gap-2 glow-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
搜索
|
||||
</Button>
|
||||
<button
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/50 text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground md:hidden"
|
||||
onClick={() => setMobileFilterOpen(!mobileFilterOpen)}
|
||||
aria-label="筛选"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileFilterOpen && (
|
||||
<div className="mb-6 rounded-xl border border-border/50 bg-white p-4 md:hidden animate-slide-down shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<Sidebar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{search ? `搜索 "${search}"` : activeCategoryName}
|
||||
</span>
|
||||
{activePricingName && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span className="text-sm text-muted-foreground">{activePricingName}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span className="text-sm font-semibold text-foreground">{total}</span>
|
||||
<span className="text-sm text-muted-foreground">个工具</span>
|
||||
</div>
|
||||
{(categoryId || pricingModel || search) && (
|
||||
<button
|
||||
onClick={() => { setSearchInput(""); router.push("/tools"); }}
|
||||
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/30" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-base font-medium text-muted-foreground">暂无匹配的工具</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground/50">试试其他关键词或筛选条件</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => { setSearchInput(""); router.push("/tools"); }}>
|
||||
清除筛选
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tools.map((tool, i) => (
|
||||
<Card
|
||||
key={tool.id}
|
||||
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
|
||||
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
|
||||
onClick={() => router.push(`/tools/${tool.slug}`)}
|
||||
>
|
||||
<div className="absolute top-2 right-2 z-10" onClick={(e) => e.stopPropagation()}>
|
||||
<FavoriteButton type="tool" id={tool.id} initialFavorited={tool.favorited ?? false} />
|
||||
</div>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
{tool.logoUrl ? (
|
||||
<img
|
||||
src={tool.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
|
||||
{tool.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
|
||||
{pricingLabel[tool.pricingModel] || tool.pricingModel}
|
||||
</span>
|
||||
{tool.category && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{tool.category.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
|
||||
{tool.description || "暂无简介"}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted-foreground/40">
|
||||
<span className="flex items-center gap-1">
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-10 flex items-center justify-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", (page - 1).toString());
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
}}
|
||||
className="gap-1.5 rounded-lg"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let pageNum: number;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (page <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (page >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = page - 2 + i;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", pageNum.toString());
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
}}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
page === pageNum
|
||||
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", (page + 1).toString());
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
}}
|
||||
className="gap-1.5 rounded-lg"
|
||||
>
|
||||
下一页
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+134
-509
@@ -1,528 +1,153 @@
|
||||
"use client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import ToolsContent from "./ToolsContent";
|
||||
|
||||
import { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}): Promise<Metadata> {
|
||||
const search =
|
||||
typeof searchParams.search === "string" ? searchParams.search : "";
|
||||
const categoryId =
|
||||
typeof searchParams.categoryId === "string"
|
||||
? searchParams.categoryId
|
||||
: "";
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
children: Category[];
|
||||
let title = "AI 工具库 - 发现优质AI工具";
|
||||
let description = "发现最适合你的AI工具,提升工作效率与创造力。浏览免费、付费、订阅等多种定价模式的AI工具。";
|
||||
|
||||
if (search) {
|
||||
title = `${search} - AI 工具搜索`;
|
||||
description = `搜索 "${search}" 相关的AI工具,找到你需要的AI应用和服务。`;
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
const category = await prisma.category.findUnique({
|
||||
where: { id: parseInt(categoryId) || 0 },
|
||||
select: { name: true },
|
||||
});
|
||||
if (category) {
|
||||
title = `${category.name} - AI 工具库`;
|
||||
description = `浏览${category.name}分类下的AI工具,找到最适合该领域的AI应用。`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Tool {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string;
|
||||
pricingModel: string;
|
||||
viewCount: number;
|
||||
favorited?: boolean;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
}
|
||||
|
||||
const pricingLabel: Record<string, string> = {
|
||||
free: "免费",
|
||||
freemium: "免费增值",
|
||||
paid: "付费",
|
||||
subscription: "订阅",
|
||||
};
|
||||
|
||||
const pricingColor: Record<string, string> = {
|
||||
free: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
|
||||
freemium: "bg-blue-500/15 text-blue-400 border-blue-500/20",
|
||||
paid: "bg-amber-500/15 text-amber-400 border-amber-500/20",
|
||||
subscription: "bg-purple-500/15 text-purple-400 border-purple-500/20",
|
||||
};
|
||||
|
||||
const pricingDot: Record<string, string> = {
|
||||
free: "bg-emerald-400",
|
||||
freemium: "bg-blue-400",
|
||||
paid: "bg-amber-400",
|
||||
subscription: "bg-purple-400",
|
||||
};
|
||||
|
||||
const pricingOptions = [
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "free", label: "免费" },
|
||||
{ value: "freemium", label: "免费增值" },
|
||||
{ value: "paid", label: "付费" },
|
||||
{ value: "subscription", label: "订阅" },
|
||||
];
|
||||
|
||||
const categoryIcons: Record<string, string> = {
|
||||
"文本生成": "M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7",
|
||||
"图像生成": "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z",
|
||||
"视频生成": "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z",
|
||||
"音频处理": "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z",
|
||||
"代码助手": "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
|
||||
"办公效率": "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
|
||||
"AI 角色": "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
|
||||
"设计工具": "M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01",
|
||||
"数据分析": "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z",
|
||||
"内容检测": "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
|
||||
};
|
||||
|
||||
export default function ToolsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center"><p className="text-muted-foreground">加载中...</p></div>}>
|
||||
<ToolsContent />
|
||||
</Suspense>
|
||||
export default async function ToolsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const page = Math.max(
|
||||
1,
|
||||
parseInt(
|
||||
typeof searchParams.page === "string" ? searchParams.page : "1"
|
||||
) || 1
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const search = searchParams.get("search") || "";
|
||||
const categoryId = searchParams.get("categoryId") || "";
|
||||
const pricingModel = searchParams.get("pricingModel") || "";
|
||||
const search =
|
||||
typeof searchParams.search === "string" ? searchParams.search : "";
|
||||
const categoryId =
|
||||
typeof searchParams.categoryId === "string"
|
||||
? searchParams.categoryId
|
||||
: "";
|
||||
const pricingModel =
|
||||
typeof searchParams.pricingModel === "string"
|
||||
? searchParams.pricingModel
|
||||
: "";
|
||||
const pageSize = 12;
|
||||
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
const [prismaCategories, toolData] = await Promise.all([
|
||||
prisma.category.findMany({
|
||||
where: { parentId: null },
|
||||
include: { children: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
}),
|
||||
(async () => {
|
||||
const where: Prisma.ToolWhereInput = {
|
||||
status: "published",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/categories")
|
||||
.then((r) => r.json())
|
||||
.then(setCategories)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchTools = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
pageSize: pageSize.toString(),
|
||||
});
|
||||
if (search) params.set("search", search);
|
||||
if (categoryId) params.set("categoryId", categoryId);
|
||||
if (pricingModel) params.set("pricingModel", pricingModel);
|
||||
|
||||
const res = await fetch(`/api/tools?${params}`);
|
||||
const data = await res.json();
|
||||
setTools(data.tools || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
setTools([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, categoryId, pricingModel]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTools();
|
||||
}, [fetchTools]);
|
||||
|
||||
const updateParams = (updates: Record<string, string>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
if (search) {
|
||||
where.name = { contains: search };
|
||||
}
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
if (categoryId) {
|
||||
const parsedId = parseInt(categoryId);
|
||||
if (!isNaN(parsedId)) {
|
||||
where.categoryId = parsedId;
|
||||
}
|
||||
}
|
||||
|
||||
const activeCategoryName = categoryId
|
||||
? categories.find((c) => c.id.toString() === categoryId)?.name || ""
|
||||
: "全部分类";
|
||||
if (pricingModel) {
|
||||
where.pricingModel = pricingModel as Prisma.EnumPricingModelFilter["equals"];
|
||||
}
|
||||
|
||||
const activePricingName = pricingModel
|
||||
? pricingOptions.find((p) => p.value === pricingModel)?.label || ""
|
||||
: "";
|
||||
const [tools, total] = await Promise.all([
|
||||
prisma.tool.findMany({
|
||||
where,
|
||||
include: { category: { select: { id: true, name: true, slug: true } } },
|
||||
orderBy: { viewCount: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.tool.count({ where }),
|
||||
]);
|
||||
|
||||
const Sidebar = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path 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>
|
||||
分类筛选
|
||||
</h3>
|
||||
<nav className="space-y-0.5">
|
||||
<button
|
||||
onClick={() => updateParams({ categoryId: "" })}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
|
||||
!categoryId
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
|
||||
!categoryId ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
|
||||
}`}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
全部分类
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => updateParams({ categoryId: cat.id.toString() })}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
|
||||
categoryId === cat.id.toString()
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
|
||||
categoryId === cat.id.toString() ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
|
||||
}`}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={categoryIcons[cat.name] || "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"} />
|
||||
</svg>
|
||||
</div>
|
||||
{cat.name}
|
||||
{categoryId === cat.id.toString() && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
return { tools, total };
|
||||
})(),
|
||||
]);
|
||||
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
|
||||
const initialTools = toolData.tools.map((tool) => ({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
slug: tool.slug,
|
||||
description: tool.description,
|
||||
logoUrl: tool.logoUrl,
|
||||
websiteUrl: tool.websiteUrl,
|
||||
pricingModel: tool.pricingModel,
|
||||
viewCount: tool.viewCount,
|
||||
favorited: false,
|
||||
category: tool.category,
|
||||
}));
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="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>
|
||||
定价模型
|
||||
</h3>
|
||||
<nav className="space-y-0.5">
|
||||
{pricingOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => updateParams({ pricingModel: opt.value })}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
|
||||
pricingModel === opt.value
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{opt.value ? (
|
||||
<span className={`h-2 w-2 rounded-full transition-all duration-200 ${
|
||||
pricingModel === opt.value ? "scale-125" : pricingDot[opt.value] || "bg-muted-foreground/40"
|
||||
}`} style={{ backgroundColor: pricingModel === opt.value ? undefined : undefined }} />
|
||||
) : (
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-muted/50 group-hover:bg-accent">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{opt.label}
|
||||
{pricingModel === opt.value && opt.value && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
|
||||
|
||||
<div className="rounded-xl border border-border/50 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
💡 点击分类或定价模型进行筛选,支持组合筛选
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const categories = prismaCategories.map((cat) => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
children: cat.children.map((child) => ({
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
slug: child.slug,
|
||||
children: [] as { id: number; name: string; slug: string; children: never[] }[],
|
||||
})),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
|
||||
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 animate-fade-in">
|
||||
<div className="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>
|
||||
<span className="text-foreground">全部工具</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-3xl font-bold tracking-tight">
|
||||
探索<span className="gradient-text">AI</span>工具
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
发现最适合你的AI工具,提升工作效率与创造力
|
||||
</p>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
<aside className="hidden w-60 shrink-0 md:block">
|
||||
<div className="sticky top-24 rounded-xl border border-border/50 bg-white p-4 scrollbar-thin shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && updateParams({ search: searchInput })
|
||||
}
|
||||
placeholder="搜索AI工具..."
|
||||
className="h-11 w-full rounded-xl border border-border/50 bg-muted/50 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 focus:bg-white focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => { setSearchInput(""); updateParams({ search: "" }); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="default"
|
||||
onClick={() => updateParams({ search: searchInput })}
|
||||
className="h-11 gap-2 glow-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
搜索
|
||||
</Button>
|
||||
<button
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/50 text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground md:hidden"
|
||||
onClick={() => setMobileFilterOpen(!mobileFilterOpen)}
|
||||
aria-label="筛选"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileFilterOpen && (
|
||||
<div className="mb-6 rounded-xl border border-border/50 bg-white p-4 md:hidden animate-slide-down shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<Sidebar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{search ? `搜索 "${search}"` : activeCategoryName}
|
||||
</span>
|
||||
{activePricingName && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span className="text-sm text-muted-foreground">{activePricingName}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span className="text-sm font-semibold text-foreground">{total}</span>
|
||||
<span className="text-sm text-muted-foreground">个工具</span>
|
||||
</div>
|
||||
{(categoryId || pricingModel || search) && (
|
||||
<button
|
||||
onClick={() => { setSearchInput(""); router.push("/tools"); }}
|
||||
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/30" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-base font-medium text-muted-foreground">暂无匹配的工具</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground/50">试试其他关键词或筛选条件</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => { setSearchInput(""); router.push("/tools"); }}>
|
||||
清除筛选
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tools.map((tool, i) => (
|
||||
<Card
|
||||
key={tool.id}
|
||||
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
|
||||
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
|
||||
onClick={() => router.push(`/tools/${tool.slug}`)}
|
||||
>
|
||||
<div className="absolute top-2 right-2 z-10" onClick={(e) => e.stopPropagation()}>
|
||||
<FavoriteButton type="tool" id={tool.id} initialFavorited={tool.favorited ?? false} />
|
||||
</div>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
{tool.logoUrl ? (
|
||||
<img
|
||||
src={tool.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
|
||||
{tool.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
|
||||
{pricingLabel[tool.pricingModel] || tool.pricingModel}
|
||||
</span>
|
||||
{tool.category && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{tool.category.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
|
||||
{tool.description || "暂无简介"}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted-foreground/40">
|
||||
<span className="flex items-center gap-1">
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-10 flex items-center justify-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", (page - 1).toString());
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
}}
|
||||
className="gap-1.5 rounded-lg"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let pageNum: number;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (page <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (page >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = page - 2 + i;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", pageNum.toString());
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
}}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
page === pageNum
|
||||
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", (page + 1).toString());
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
}}
|
||||
className="gap-1.5 rounded-lg"
|
||||
>
|
||||
下一页
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ToolsContent
|
||||
initialCategories={categories}
|
||||
initialTools={initialTools}
|
||||
initialTotal={toolData.total}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user