2026-05-25: 等级体系升级(20级)+论坛二级分类+会员空间
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
import { HeroBanner } from "@/components/common/HeroBanner";
|
||||
|
||||
interface Tool {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string;
|
||||
pricingModel: string;
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
interface FlatCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string | null;
|
||||
parentId: number | null;
|
||||
toolCount: number;
|
||||
tools: Tool[];
|
||||
}
|
||||
|
||||
interface ParentCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
interface HomePageClientProps {
|
||||
flatCategories: FlatCategory[];
|
||||
parentCategories: ParentCategory[];
|
||||
totalTools: number;
|
||||
totalSkills: number;
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
export default function HomePageClient({
|
||||
flatCategories: categories,
|
||||
parentCategories,
|
||||
totalTools,
|
||||
totalSkills,
|
||||
}: HomePageClientProps) {
|
||||
const router = useRouter();
|
||||
const [activeSection, setActiveSection] = useState<string>("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
|
||||
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (categories.length === 0) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
if (visible.length > 0) setActiveSection(visible[0].target.id);
|
||||
},
|
||||
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
|
||||
);
|
||||
Object.values(sectionRefs.current).forEach((el) => {
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [categories]);
|
||||
|
||||
const scrollToSection = useCallback((id: string) => {
|
||||
const el = sectionRefs.current[id];
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 80;
|
||||
window.scrollTo({ top, behavior: "smooth" });
|
||||
}
|
||||
setMobileNavOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (search.trim()) {
|
||||
router.push(`/tools?search=${encodeURIComponent(search.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveTab = (parentId: number, children: FlatCategory[]) => {
|
||||
if (activeTabs[parentId]) return activeTabs[parentId];
|
||||
return "all";
|
||||
};
|
||||
|
||||
const setActiveTab = (parentId: number, slug: string) => {
|
||||
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
|
||||
};
|
||||
|
||||
const groupedCategories = parentCategories.map((parent) => {
|
||||
const ownCat = categories.find((c) => c.id === parent.id);
|
||||
const children = categories.filter((c) => c.parentId === parent.id);
|
||||
return { parent, ownCat, children };
|
||||
});
|
||||
|
||||
const isEmpty = categories.length === 0 && parentCategories.length === 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="flex gap-6">
|
||||
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1 space-y-10">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderSidebar = () => (
|
||||
<nav className="space-y-0.5">
|
||||
<div className="relative mb-3">
|
||||
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -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={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="搜索工具..."
|
||||
className="input-field-compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/40 my-2" />
|
||||
|
||||
{parentCategories.map((p) => {
|
||||
const isActive = activeSection === `cat-${p.slug}`;
|
||||
const ownCount = categories.find((c) => c.id === p.id)?.toolCount || 0;
|
||||
const childCount = categories
|
||||
.filter((c) => c.parentId === p.id)
|
||||
.reduce((s, c) => s + c.toolCount, 0);
|
||||
const totalCount = ownCount + childCount;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => scrollToSection(`cat-${p.slug}`)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
|
||||
isActive
|
||||
? "bg-primary/10 font-semibold text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{p.name}</span>
|
||||
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
|
||||
isActive ? "text-primary/70" : "text-muted-foreground/50"
|
||||
}`}>{totalCount}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="h-px bg-border/40 my-2" />
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
|
||||
{categories.length} 个分类 · {totalTools} 个工具
|
||||
</p>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex gap-6">
|
||||
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
|
||||
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
|
||||
{renderSidebar()}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-5 flex items-center gap-3 lg:hidden">
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-3 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={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="搜索AI工具..."
|
||||
className="input-field !pl-10 !h-10 !rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
|
||||
onClick={() => setMobileNavOpen(!mobileNavOpen)}
|
||||
aria-label="分类导航"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileNavOpen && (
|
||||
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
|
||||
{renderSidebar()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HeroBanner
|
||||
title="追光"
|
||||
highlight="AI"
|
||||
highlightColor="blue"
|
||||
starStyle="cosmos"
|
||||
stats={[
|
||||
{ label: "AI工具", value: totalTools, unit: "个" },
|
||||
{ label: "分类", value: categories.length, unit: "个" },
|
||||
]}
|
||||
slogan="汇聚全球优质AI工具,赋能每一个创新者"
|
||||
subSlogan="发现、探索、使用 — 让人工智能触手可及"
|
||||
/>
|
||||
|
||||
{groupedCategories.map(({ parent, ownCat, children }) => {
|
||||
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatCategory[];
|
||||
if (allCatsInGroup.length === 0) return null;
|
||||
|
||||
const hasChildren = children.length > 0;
|
||||
const activeTab = getActiveTab(parent.id, children);
|
||||
const totalCount = allCatsInGroup.reduce((s, c) => s + c.toolCount, 0);
|
||||
|
||||
const allTools = allCatsInGroup.flatMap((c) => c.tools);
|
||||
const displayTools = activeTab === "all"
|
||||
? allTools
|
||||
: children.find((c) => c.slug === activeTab)?.tools || ownCat?.tools || [];
|
||||
const maxDisplay = 8;
|
||||
const visibleTools = displayTools.slice(0, maxDisplay);
|
||||
const hasMore = displayTools.length > maxDisplay;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={`cat-${parent.slug}`}
|
||||
id={`cat-${parent.slug}`}
|
||||
ref={(el) => { sectionRefs.current[`cat-${parent.slug}`] = el; }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
|
||||
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-[10px] font-semibold text-primary tabular-nums">
|
||||
{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasChildren && (
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => setActiveTab(parent.id, "all")}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
|
||||
activeTab === "all"
|
||||
? "bg-primary text-white shadow-sm shadow-primary/25"
|
||||
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
<span className={`text-[9px] tabular-nums ${
|
||||
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
|
||||
}`}>{totalCount}</span>
|
||||
</button>
|
||||
{children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => setActiveTab(parent.id, child.slug)}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
|
||||
activeTab === child.slug
|
||||
? "bg-primary text-white shadow-sm shadow-primary/25"
|
||||
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
|
||||
}`}
|
||||
>
|
||||
{child.name}
|
||||
<span className={`text-[9px] tabular-nums ${
|
||||
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
|
||||
}`}>{child.toolCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleTools.length > 0 ? (
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{visibleTools.map((tool) => (
|
||||
<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)]"
|
||||
onClick={() => router.push(`/tools/${tool.slug}`)}
|
||||
>
|
||||
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
|
||||
<FavoriteButton type="tool" id={tool.id} initialFavorited={false} />
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{tool.logoUrl ? (
|
||||
<img
|
||||
src={tool.logoUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/30 group-hover:shadow-sm"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-xs font-semibold text-primary transition-all duration-300 group-hover:scale-105 ${tool.logoUrl ? 'hidden' : ''}`}>
|
||||
{tool.name.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
|
||||
{tool.name}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground"}`}>
|
||||
{pricingLabel[tool.pricingModel] || tool.pricingModel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
|
||||
{tool.description || "暂无简介"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
|
||||
敬请期待
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="mt-3 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
const cat = activeTab === "all"
|
||||
? parent.slug
|
||||
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
|
||||
router.push(`/categories/${cat}`);
|
||||
}}
|
||||
>
|
||||
查看全部 {displayTools.length} 个 →
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user