refactor: 调整首页统计与标签洞察展示

This commit is contained in:
2026-02-23 18:12:54 +08:00
parent 49d673144b
commit d90236bcf0
8 changed files with 134 additions and 168 deletions
+2 -8
View File
@@ -56,10 +56,9 @@ export default async function HomePage({ params }: HomePageProps) {
translations={{ translations={{
sectionTitle: t('insightsSection'), sectionTitle: t('insightsSection'),
totalProjects: t('statsTotalProjects'), totalProjects: t('statsTotalProjects'),
activeProjects: t('statsActiveProjects'), newProjects30d: t('statsNewProjects30d'),
archivedProjects: t('statsArchivedProjects'),
totalTags: t('statsTotalTags'),
newProjects7d: t('statsNewProjects7d'), newProjects7d: t('statsNewProjects7d'),
newProjects24h: t('statsNewProjects24h'),
}} }}
/> />
@@ -84,13 +83,8 @@ export default async function HomePage({ params }: HomePageProps) {
<HomeTagInsights <HomeTagInsights
locale={locale} locale={locale}
topTags={homeData.tagInsights.topTags} topTags={homeData.tagInsights.topTags}
categories={homeData.tagInsights.categoryDistribution}
translations={{ translations={{
sectionTitle: t('tagInsightsSection'), sectionTitle: t('tagInsightsSection'),
topTagsTitle: t('topTagsTitle'),
categoryDistributionTitle: t('categoryDistributionTitle'),
tagsUnit: t('tagsUnit'),
projectsUnit: t('projectsUnit'),
noTagData: t('noTagData'), noTagData: t('noTagData'),
viewAllProjects: t('viewAllProjects'), viewAllProjects: t('viewAllProjects'),
}} }}
+55
View File
@@ -124,4 +124,59 @@
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
border: 1px solid rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.6);
} }
.tag-marquee::before,
.tag-marquee::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 2rem;
pointer-events: none;
z-index: 1;
}
.tag-marquee::before {
left: 0;
background: linear-gradient(to right, rgba(245, 242, 235, 1), rgba(245, 242, 235, 0));
}
.tag-marquee::after {
right: 0;
background: linear-gradient(to left, rgba(245, 242, 235, 1), rgba(245, 242, 235, 0));
}
.dark .tag-marquee::before {
background: linear-gradient(to right, rgba(30, 30, 30, 1), rgba(30, 30, 30, 0));
}
.dark .tag-marquee::after {
background: linear-gradient(to left, rgba(30, 30, 30, 1), rgba(30, 30, 30, 0));
}
.tag-marquee-track {
animation-name: tag-marquee-x;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
.tag-marquee:hover .tag-marquee-track {
animation-play-state: paused;
}
}
@keyframes tag-marquee-x {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.tag-marquee-track {
animation: none;
}
} }
+7 -10
View File
@@ -2,18 +2,16 @@ interface HomeOverviewStatsProps {
locale: string locale: string
stats: { stats: {
totalProjects: number totalProjects: number
activeProjects: number newProjects30d: number
archivedProjects: number
totalTags: number
newProjects7d: number newProjects7d: number
newProjects24h: number
} }
translations: { translations: {
sectionTitle: string sectionTitle: string
totalProjects: string totalProjects: string
activeProjects: string newProjects30d: string
archivedProjects: string
totalTags: string
newProjects7d: string newProjects7d: string
newProjects24h: string
} }
} }
@@ -25,10 +23,9 @@ function formatNumber(value: number, locale: string): string {
export function HomeOverviewStats({ locale, stats, translations }: HomeOverviewStatsProps) { export function HomeOverviewStats({ locale, stats, translations }: HomeOverviewStatsProps) {
const cards = [ const cards = [
{ label: translations.totalProjects, value: stats.totalProjects }, { label: translations.totalProjects, value: stats.totalProjects },
{ label: translations.activeProjects, value: stats.activeProjects }, { label: translations.newProjects30d, value: stats.newProjects30d },
{ label: translations.archivedProjects, value: stats.archivedProjects },
{ label: translations.totalTags, value: stats.totalTags },
{ label: translations.newProjects7d, value: stats.newProjects7d }, { label: translations.newProjects7d, value: stats.newProjects7d },
{ label: translations.newProjects24h, value: stats.newProjects24h },
] ]
return ( return (
@@ -37,7 +34,7 @@ export function HomeOverviewStats({ locale, stats, translations }: HomeOverviewS
<h2 className="font-display text-3xl font-bold">{translations.sectionTitle}</h2> <h2 className="font-display text-3xl font-bold">{translations.sectionTitle}</h2>
</div> </div>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((card) => ( {cards.map((card) => (
<article <article
key={card.label} key={card.label}
+10 -5
View File
@@ -2,6 +2,7 @@
import Link from 'next/link' import Link from 'next/link'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Star } from 'lucide-react'
import type { HomeProjectSummary } from '@/hooks/useHome' import type { HomeProjectSummary } from '@/hooks/useHome'
type RankingWindow = '24h' | '7d' | '30d' type RankingWindow = '24h' | '7d' | '30d'
@@ -70,8 +71,12 @@ function ProjectRow({
</div> </div>
</div> </div>
{showStars && ( {showStars && (
<span className="font-display text-xs font-bold shrink-0"> <span className="font-display text-xs font-bold shrink-0 inline-flex items-center gap-1.5">
{formatNumber(project.githubStars, locale)} <Star
aria-hidden="true"
className="h-5 w-5 text-primary fill-current drop-shadow-[1px_1px_0_rgba(0,0,0,1)]"
/>
{formatNumber(project.githubStars, locale)}
</span> </span>
)} )}
</div> </div>
@@ -85,13 +90,13 @@ export function HomeRankings({
topStars, topStars,
translations, translations,
}: HomeRankingsProps) { }: HomeRankingsProps) {
const [activeWindow, setActiveWindow] = useState<RankingWindow>('7d') const [activeWindow, setActiveWindow] = useState<RankingWindow>('30d')
const windowOptions = useMemo( const windowOptions = useMemo(
() => [ () => [
{ key: '24h' as const, label: translations.window24h },
{ key: '7d' as const, label: translations.window7d },
{ key: '30d' as const, label: translations.window30d }, { key: '30d' as const, label: translations.window30d },
{ key: '7d' as const, label: translations.window7d },
{ key: '24h' as const, label: translations.window24h },
], ],
[translations.window24h, translations.window7d, translations.window30d] [translations.window24h, translations.window7d, translations.window30d]
) )
+24 -75
View File
@@ -1,5 +1,4 @@
import Link from 'next/link' import Link from 'next/link'
import type { HomeCategoryDistribution } from '@/hooks/useHome'
interface HomeTagInsightsProps { interface HomeTagInsightsProps {
locale: string locale: string
@@ -10,13 +9,8 @@ interface HomeTagInsightsProps {
slug: string slug: string
projectCount: number projectCount: number
}> }>
categories: HomeCategoryDistribution[]
translations: { translations: {
sectionTitle: string sectionTitle: string
topTagsTitle: string
categoryDistributionTitle: string
tagsUnit: string
projectsUnit: string
noTagData: string noTagData: string
viewAllProjects: string viewAllProjects: string
} }
@@ -30,10 +24,10 @@ function formatNumber(value: number, locale: string): string {
export function HomeTagInsights({ export function HomeTagInsights({
locale, locale,
topTags, topTags,
categories,
translations, translations,
}: HomeTagInsightsProps) { }: HomeTagInsightsProps) {
const maxAssociations = Math.max(1, ...categories.map((category) => category.projectAssociationCount)) const marqueeItems = [...topTags, ...topTags]
const animationDurationSeconds = Math.max(18, topTags.length * 4)
return ( return (
<section> <section>
@@ -41,16 +35,18 @@ export function HomeTagInsights({
<h2 className="font-display text-3xl font-bold">{translations.sectionTitle}</h2> <h2 className="font-display text-3xl font-bold">{translations.sectionTitle}</h2>
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <article className="border-2 border-black dark:border-gray-600 bg-surface-light dark:bg-surface-dark p-5 shadow-neo">
<article className="border-2 border-black dark:border-gray-600 bg-surface-light dark:bg-surface-dark p-5 shadow-neo"> {topTags.length > 0 ? (
<h3 className="font-display text-xl font-bold mb-4">{translations.topTagsTitle}</h3> <div className="tag-marquee relative overflow-hidden">
{topTags.length > 0 ? ( <div
<div className="flex flex-wrap gap-2"> className="tag-marquee-track flex w-max items-center gap-2 pr-2"
{topTags.map((tag) => ( style={{ animationDuration: `${animationDurationSeconds}s` }}
>
{marqueeItems.map((tag, index) => (
<Link <Link
key={tag.id} key={`${tag.id}-${index}`}
href={`/${locale}/projects?tag=${tag.slug}`} href={`/${locale}/projects?tag=${tag.slug}`}
className="px-3 py-2 border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark text-xs font-display font-bold uppercase shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all" className="shrink-0 px-3 py-2 border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark text-xs font-display font-bold uppercase shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
> >
<span>{locale === 'en' && tag.nameEn ? tag.nameEn : tag.name}</span> <span>{locale === 'en' && tag.nameEn ? tag.nameEn : tag.name}</span>
<span className="ml-2 text-gray-500 dark:text-gray-400"> <span className="ml-2 text-gray-500 dark:text-gray-400">
@@ -59,67 +55,20 @@ export function HomeTagInsights({
</Link> </Link>
))} ))}
</div> </div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{translations.noTagData}</p>
)}
<div className="mt-4">
<Link
href={`/${locale}/projects`}
className="inline-flex items-center font-display text-sm font-bold hover:underline"
>
{translations.viewAllProjects} <span className="ml-1"></span>
</Link>
</div> </div>
</article> ) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{translations.noTagData}</p>
)}
<article className="border-2 border-black dark:border-gray-600 bg-surface-light dark:bg-surface-dark p-5 shadow-neo"> <div className="mt-4">
<h3 className="font-display text-xl font-bold mb-4">{translations.categoryDistributionTitle}</h3> <Link
{categories.length > 0 ? ( href={`/${locale}/projects`}
<div className="space-y-4"> className="inline-flex items-center font-display text-sm font-bold hover:underline"
{categories.map((category) => { >
const categoryName = {translations.viewAllProjects} <span className="ml-1"></span>
locale === 'en' ? category.nameEn : category.name </Link>
const widthPercent = Math.max( </div>
6, </article>
Math.round((category.projectAssociationCount / maxAssociations) * 100)
)
return (
<div key={category.category} className="space-y-2">
<div className="flex items-center justify-between gap-3">
<p className="font-display text-sm font-bold uppercase">{categoryName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatNumber(category.tagCount, locale)} {translations.tagsUnit} ·{' '}
{formatNumber(category.projectAssociationCount, locale)} {translations.projectsUnit}
</p>
</div>
<div className="h-3 border-2 border-black dark:border-gray-600 bg-white dark:bg-gray-800">
<div
className="h-full bg-primary"
style={{ width: `${widthPercent}%` }}
aria-hidden="true"
/>
</div>
{category.topTag && (
<Link
href={`/${locale}/projects?tag=${category.topTag.slug}`}
className="inline-flex text-xs font-display font-bold hover:underline"
>
{locale === 'en' && category.topTag.nameEn
? category.topTag.nameEn
: category.topTag.name}
</Link>
)}
</div>
)
})}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{translations.noTagData}</p>
)}
</article>
</div>
</section> </section>
) )
} }
+22 -60
View File
@@ -1,5 +1,5 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { getTagCategoryGroups, getTopTags } from '@/hooks/useProjects' import { getTopTags } from '@/hooks/useProjects'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
const ONE_DAY_MS = 24 * 60 * 60 * 1000 const ONE_DAY_MS = 24 * 60 * 60 * 1000
@@ -24,28 +24,12 @@ export type HomeProjectSummary = {
}> }>
} }
export type HomeCategoryDistribution = {
category: string
name: string
nameEn: string
tagCount: number
projectAssociationCount: number
topTag: {
id: string
name: string
nameEn: string | null
slug: string
projectCount: number
} | null
}
export type HomePageData = { export type HomePageData = {
overview: { overview: {
totalProjects: number totalProjects: number
activeProjects: number newProjects30d: number
archivedProjects: number
totalTags: number
newProjects7d: number newProjects7d: number
newProjects24h: number
} }
rankings: { rankings: {
latestByWindow: { latestByWindow: {
@@ -63,7 +47,6 @@ export type HomePageData = {
slug: string slug: string
projectCount: number projectCount: number
}> }>
categoryDistribution: HomeCategoryDistribution[]
} }
timeline: HomeProjectSummary[] timeline: HomeProjectSummary[]
} }
@@ -162,29 +145,23 @@ export async function getHomePageData(): Promise<HomePageData> {
const [ const [
totalProjects, totalProjects,
activeProjects, newProjects30d,
archivedProjects,
totalTags,
newProjects7d, newProjects7d,
newProjects24h,
latest24h, latest24h,
latest7d, latest7d,
latest30d, latest30d,
topStars, topStars,
timeline, timeline,
topTags, topTags,
tagCategoryGroups,
] = await Promise.all([ ] = await Promise.all([
safeQuery('countTotalProjects', 0, () => prisma.project.count()), safeQuery('countTotalProjects', 0, () => prisma.project.count()),
safeQuery('countActiveProjects', 0, () => prisma.project.count({ where: { status: 'ACTIVE' } })), safeQuery('countNewProjects30d', 0, () =>
safeQuery('countArchivedProjects', 0, () => prisma.project.count({ where: { status: 'ARCHIVED' } })), prisma.project.count({
safeQuery('countTotalTags', 0, () =>
prisma.tag.count({
where: { where: {
category: { status: 'ACTIVE',
not: 'FIXED_PROJECT_TYPE', createdAt: {
}, gte: last30Days,
projects: {
some: {},
}, },
}, },
}) })
@@ -199,44 +176,30 @@ export async function getHomePageData(): Promise<HomePageData> {
}, },
}) })
), ),
safeQuery('countNewProjects24h', 0, () =>
prisma.project.count({
where: {
status: 'ACTIVE',
createdAt: {
gte: last24Hours,
},
},
})
),
getLatestProjects(DEFAULT_RANKING_LIMIT, last24Hours), getLatestProjects(DEFAULT_RANKING_LIMIT, last24Hours),
getLatestProjects(DEFAULT_RANKING_LIMIT, last7Days), getLatestProjects(DEFAULT_RANKING_LIMIT, last7Days),
getLatestProjects(DEFAULT_RANKING_LIMIT, last30Days), getLatestProjects(DEFAULT_RANKING_LIMIT, last30Days),
getTopStarsProjects(DEFAULT_RANKING_LIMIT), getTopStarsProjects(DEFAULT_RANKING_LIMIT),
getLatestProjects(DEFAULT_TIMELINE_LIMIT), getLatestProjects(DEFAULT_TIMELINE_LIMIT),
getTopTags(DEFAULT_TOP_TAG_LIMIT), getTopTags(DEFAULT_TOP_TAG_LIMIT),
getTagCategoryGroups(),
]) ])
const categoryDistribution: HomeCategoryDistribution[] = tagCategoryGroups.map((group) => {
const projectAssociationCount = group.tags.reduce((sum, tag) => sum + tag._count.projects, 0)
const leadingTag = group.tags[0]
return {
category: group.category,
name: group.name,
nameEn: group.nameEn,
tagCount: group.tags.length,
projectAssociationCount,
topTag: leadingTag
? {
id: leadingTag.id,
name: leadingTag.name,
nameEn: leadingTag.nameEn,
slug: leadingTag.slug,
projectCount: leadingTag._count.projects,
}
: null,
}
})
return { return {
overview: { overview: {
totalProjects, totalProjects,
activeProjects, newProjects30d,
archivedProjects,
totalTags,
newProjects7d, newProjects7d,
newProjects24h,
}, },
rankings: { rankings: {
latestByWindow: { latestByWindow: {
@@ -254,7 +217,6 @@ export async function getHomePageData(): Promise<HomePageData> {
slug: tag.slug, slug: tag.slug,
projectCount: tag._count.projects, projectCount: tag._count.projects,
})), })),
categoryDistribution,
}, },
timeline, timeline,
} }
+7 -5
View File
@@ -22,15 +22,15 @@
"projectCount": "projects" "projectCount": "projects"
}, },
"home": { "home": {
"title": "AI Project Navigator", "title": "Agent Project Navigator",
"subtitle": "Discover and explore AI projects", "subtitle": "Discover and explore AI projects",
"featuredProjects": "Featured Projects", "featuredProjects": "Featured Projects",
"browseByTag": "Browse by Tag", "browseByTag": "Browse by Tag",
"searchPlaceholder": "Search AI projects...", "searchPlaceholder": "Search AI projects...",
"aiSearchPlaceholder": "Describe what you're looking for, e.g.: AI tools that can generate videos...", "aiSearchPlaceholder": "Describe what you're looking for, e.g.: AI tools that can generate videos...",
"metaTitle": "Agent Park - AI Project Navigator", "metaTitle": "Agent Park - Agent Project Navigator",
"metaDescription": "Discover and explore quality AI projects from across the web", "metaDescription": "Discover and explore quality AI projects from across the web",
"heroTitle": "AI PROJECT", "heroTitle": "AGENT PROJECT",
"heroTitle2": "NAVIGATOR", "heroTitle2": "NAVIGATOR",
"heroDescription": "Discover and explore high-quality AI projects from across the web. Curated for developers and enthusiasts.", "heroDescription": "Discover and explore high-quality AI projects from across the web. Curated for developers and enthusiasts.",
"browseByTagsSection": "BROWSE BY TAGS", "browseByTagsSection": "BROWSE BY TAGS",
@@ -41,6 +41,8 @@
"statsTotalProjects": "Total Projects", "statsTotalProjects": "Total Projects",
"statsActiveProjects": "Active Projects", "statsActiveProjects": "Active Projects",
"statsArchivedProjects": "Archived Projects", "statsArchivedProjects": "Archived Projects",
"statsNewProjects30d": "New in 30 Days",
"statsNewProjects24h": "New in 24h",
"statsTotalTags": "Total Tags", "statsTotalTags": "Total Tags",
"statsNewProjects7d": "New in 7 Days", "statsNewProjects7d": "New in 7 Days",
"rankingsSection": "Project Rankings", "rankingsSection": "Project Rankings",
@@ -53,8 +55,8 @@
"noStarsProjects": "No stars data available", "noStarsProjects": "No stars data available",
"viewAllLatest": "View all latest projects", "viewAllLatest": "View all latest projects",
"viewAllByStars": "View all by stars", "viewAllByStars": "View all by stars",
"tagInsightsSection": "Tag Popularity & Distribution", "tagInsightsSection": "Top Tags",
"topTagsTitle": "Top Tags", "topTagsTitle": "Top Tags Carousel",
"categoryDistributionTitle": "Category Distribution", "categoryDistributionTitle": "Category Distribution",
"tagsUnit": "tags", "tagsUnit": "tags",
"projectsUnit": "projects", "projectsUnit": "projects",
+7 -5
View File
@@ -22,15 +22,15 @@
"projectCount": "个项目" "projectCount": "个项目"
}, },
"home": { "home": {
"title": "AI 项目导航", "title": "Agent 项目导航",
"subtitle": "发现和探索全网优质 AI 项目", "subtitle": "发现和探索全网优质 AI 项目",
"featuredProjects": "精选项目", "featuredProjects": "精选项目",
"browseByTag": "按标签浏览", "browseByTag": "按标签浏览",
"searchPlaceholder": "搜索 AI 项目...", "searchPlaceholder": "搜索 AI 项目...",
"aiSearchPlaceholder": "描述你想要的项目,如:能生成视频的 AI 工具...", "aiSearchPlaceholder": "描述你想要的项目,如:能生成视频的 AI 工具...",
"metaTitle": "Agent Park - AI 项目导航", "metaTitle": "Agent Park - Agent 项目导航",
"metaDescription": "发现和探索全网优质 AI 项目", "metaDescription": "发现和探索全网优质 AI 项目",
"heroTitle": "AI 项目", "heroTitle": "Agent 项目",
"heroTitle2": "导航", "heroTitle2": "导航",
"heroDescription": "发现和探索来自全网的高质量 AI 项目。为开发者和爱好者精心策划。", "heroDescription": "发现和探索来自全网的高质量 AI 项目。为开发者和爱好者精心策划。",
"browseByTagsSection": "按标签浏览", "browseByTagsSection": "按标签浏览",
@@ -41,6 +41,8 @@
"statsTotalProjects": "项目总数", "statsTotalProjects": "项目总数",
"statsActiveProjects": "活跃项目", "statsActiveProjects": "活跃项目",
"statsArchivedProjects": "归档项目", "statsArchivedProjects": "归档项目",
"statsNewProjects30d": "近30天新增",
"statsNewProjects24h": "近24小时新增",
"statsTotalTags": "标签总数", "statsTotalTags": "标签总数",
"statsNewProjects7d": "近7天新增", "statsNewProjects7d": "近7天新增",
"rankingsSection": "项目榜单", "rankingsSection": "项目榜单",
@@ -53,8 +55,8 @@
"noStarsProjects": "暂无可展示的 Stars 数据", "noStarsProjects": "暂无可展示的 Stars 数据",
"viewAllLatest": "查看全部最新项目", "viewAllLatest": "查看全部最新项目",
"viewAllByStars": "查看全部 Stars 排序", "viewAllByStars": "查看全部 Stars 排序",
"tagInsightsSection": "标签热度与分布", "tagInsightsSection": "热门标签",
"topTagsTitle": "热门标签", "topTagsTitle": "热门标签轮播",
"categoryDistributionTitle": "分类分布", "categoryDistributionTitle": "分类分布",
"tagsUnit": "个标签", "tagsUnit": "个标签",
"projectsUnit": "个项目", "projectsUnit": "个项目",