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={{
sectionTitle: t('insightsSection'),
totalProjects: t('statsTotalProjects'),
activeProjects: t('statsActiveProjects'),
archivedProjects: t('statsArchivedProjects'),
totalTags: t('statsTotalTags'),
newProjects30d: t('statsNewProjects30d'),
newProjects7d: t('statsNewProjects7d'),
newProjects24h: t('statsNewProjects24h'),
}}
/>
@@ -84,13 +83,8 @@ export default async function HomePage({ params }: HomePageProps) {
<HomeTagInsights
locale={locale}
topTags={homeData.tagInsights.topTags}
categories={homeData.tagInsights.categoryDistribution}
translations={{
sectionTitle: t('tagInsightsSection'),
topTagsTitle: t('topTagsTitle'),
categoryDistributionTitle: t('categoryDistributionTitle'),
tagsUnit: t('tagsUnit'),
projectsUnit: t('projectsUnit'),
noTagData: t('noTagData'),
viewAllProjects: t('viewAllProjects'),
}}
+55
View File
@@ -124,4 +124,59 @@
backdrop-filter: blur(2px);
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
stats: {
totalProjects: number
activeProjects: number
archivedProjects: number
totalTags: number
newProjects30d: number
newProjects7d: number
newProjects24h: number
}
translations: {
sectionTitle: string
totalProjects: string
activeProjects: string
archivedProjects: string
totalTags: string
newProjects30d: string
newProjects7d: string
newProjects24h: string
}
}
@@ -25,10 +23,9 @@ function formatNumber(value: number, locale: string): string {
export function HomeOverviewStats({ locale, stats, translations }: HomeOverviewStatsProps) {
const cards = [
{ label: translations.totalProjects, value: stats.totalProjects },
{ label: translations.activeProjects, value: stats.activeProjects },
{ label: translations.archivedProjects, value: stats.archivedProjects },
{ label: translations.totalTags, value: stats.totalTags },
{ label: translations.newProjects30d, value: stats.newProjects30d },
{ label: translations.newProjects7d, value: stats.newProjects7d },
{ label: translations.newProjects24h, value: stats.newProjects24h },
]
return (
@@ -37,7 +34,7 @@ export function HomeOverviewStats({ locale, stats, translations }: HomeOverviewS
<h2 className="font-display text-3xl font-bold">{translations.sectionTitle}</h2>
</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) => (
<article
key={card.label}
+10 -5
View File
@@ -2,6 +2,7 @@
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { Star } from 'lucide-react'
import type { HomeProjectSummary } from '@/hooks/useHome'
type RankingWindow = '24h' | '7d' | '30d'
@@ -70,8 +71,12 @@ function ProjectRow({
</div>
</div>
{showStars && (
<span className="font-display text-xs font-bold shrink-0">
{formatNumber(project.githubStars, locale)}
<span className="font-display text-xs font-bold shrink-0 inline-flex items-center gap-1.5">
<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>
)}
</div>
@@ -85,13 +90,13 @@ export function HomeRankings({
topStars,
translations,
}: HomeRankingsProps) {
const [activeWindow, setActiveWindow] = useState<RankingWindow>('7d')
const [activeWindow, setActiveWindow] = useState<RankingWindow>('30d')
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: '7d' as const, label: translations.window7d },
{ key: '24h' as const, label: translations.window24h },
],
[translations.window24h, translations.window7d, translations.window30d]
)
+24 -75
View File
@@ -1,5 +1,4 @@
import Link from 'next/link'
import type { HomeCategoryDistribution } from '@/hooks/useHome'
interface HomeTagInsightsProps {
locale: string
@@ -10,13 +9,8 @@ interface HomeTagInsightsProps {
slug: string
projectCount: number
}>
categories: HomeCategoryDistribution[]
translations: {
sectionTitle: string
topTagsTitle: string
categoryDistributionTitle: string
tagsUnit: string
projectsUnit: string
noTagData: string
viewAllProjects: string
}
@@ -30,10 +24,10 @@ function formatNumber(value: number, locale: string): string {
export function HomeTagInsights({
locale,
topTags,
categories,
translations,
}: HomeTagInsightsProps) {
const maxAssociations = Math.max(1, ...categories.map((category) => category.projectAssociationCount))
const marqueeItems = [...topTags, ...topTags]
const animationDurationSeconds = Math.max(18, topTags.length * 4)
return (
<section>
@@ -41,16 +35,18 @@ export function HomeTagInsights({
<h2 className="font-display text-3xl font-bold">{translations.sectionTitle}</h2>
</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">
<h3 className="font-display text-xl font-bold mb-4">{translations.topTagsTitle}</h3>
{topTags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{topTags.map((tag) => (
<article className="border-2 border-black dark:border-gray-600 bg-surface-light dark:bg-surface-dark p-5 shadow-neo">
{topTags.length > 0 ? (
<div className="tag-marquee relative overflow-hidden">
<div
className="tag-marquee-track flex w-max items-center gap-2 pr-2"
style={{ animationDuration: `${animationDurationSeconds}s` }}
>
{marqueeItems.map((tag, index) => (
<Link
key={tag.id}
key={`${tag.id}-${index}`}
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 className="ml-2 text-gray-500 dark:text-gray-400">
@@ -59,67 +55,20 @@ export function HomeTagInsights({
</Link>
))}
</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>
</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">
<h3 className="font-display text-xl font-bold mb-4">{translations.categoryDistributionTitle}</h3>
{categories.length > 0 ? (
<div className="space-y-4">
{categories.map((category) => {
const categoryName =
locale === 'en' ? category.nameEn : category.name
const widthPercent = Math.max(
6,
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>
<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>
</article>
</section>
)
}
+22 -60
View File
@@ -1,5 +1,5 @@
import { prisma } from '@/lib/prisma'
import { getTagCategoryGroups, getTopTags } from '@/hooks/useProjects'
import { getTopTags } from '@/hooks/useProjects'
import { Prisma } from '@prisma/client'
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 = {
overview: {
totalProjects: number
activeProjects: number
archivedProjects: number
totalTags: number
newProjects30d: number
newProjects7d: number
newProjects24h: number
}
rankings: {
latestByWindow: {
@@ -63,7 +47,6 @@ export type HomePageData = {
slug: string
projectCount: number
}>
categoryDistribution: HomeCategoryDistribution[]
}
timeline: HomeProjectSummary[]
}
@@ -162,29 +145,23 @@ export async function getHomePageData(): Promise<HomePageData> {
const [
totalProjects,
activeProjects,
archivedProjects,
totalTags,
newProjects30d,
newProjects7d,
newProjects24h,
latest24h,
latest7d,
latest30d,
topStars,
timeline,
topTags,
tagCategoryGroups,
] = await Promise.all([
safeQuery('countTotalProjects', 0, () => prisma.project.count()),
safeQuery('countActiveProjects', 0, () => prisma.project.count({ where: { status: 'ACTIVE' } })),
safeQuery('countArchivedProjects', 0, () => prisma.project.count({ where: { status: 'ARCHIVED' } })),
safeQuery('countTotalTags', 0, () =>
prisma.tag.count({
safeQuery('countNewProjects30d', 0, () =>
prisma.project.count({
where: {
category: {
not: 'FIXED_PROJECT_TYPE',
},
projects: {
some: {},
status: 'ACTIVE',
createdAt: {
gte: last30Days,
},
},
})
@@ -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, last7Days),
getLatestProjects(DEFAULT_RANKING_LIMIT, last30Days),
getTopStarsProjects(DEFAULT_RANKING_LIMIT),
getLatestProjects(DEFAULT_TIMELINE_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 {
overview: {
totalProjects,
activeProjects,
archivedProjects,
totalTags,
newProjects30d,
newProjects7d,
newProjects24h,
},
rankings: {
latestByWindow: {
@@ -254,7 +217,6 @@ export async function getHomePageData(): Promise<HomePageData> {
slug: tag.slug,
projectCount: tag._count.projects,
})),
categoryDistribution,
},
timeline,
}
+7 -5
View File
@@ -22,15 +22,15 @@
"projectCount": "projects"
},
"home": {
"title": "AI Project Navigator",
"title": "Agent Project Navigator",
"subtitle": "Discover and explore AI projects",
"featuredProjects": "Featured Projects",
"browseByTag": "Browse by Tag",
"searchPlaceholder": "Search AI projects...",
"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",
"heroTitle": "AI PROJECT",
"heroTitle": "AGENT PROJECT",
"heroTitle2": "NAVIGATOR",
"heroDescription": "Discover and explore high-quality AI projects from across the web. Curated for developers and enthusiasts.",
"browseByTagsSection": "BROWSE BY TAGS",
@@ -41,6 +41,8 @@
"statsTotalProjects": "Total Projects",
"statsActiveProjects": "Active Projects",
"statsArchivedProjects": "Archived Projects",
"statsNewProjects30d": "New in 30 Days",
"statsNewProjects24h": "New in 24h",
"statsTotalTags": "Total Tags",
"statsNewProjects7d": "New in 7 Days",
"rankingsSection": "Project Rankings",
@@ -53,8 +55,8 @@
"noStarsProjects": "No stars data available",
"viewAllLatest": "View all latest projects",
"viewAllByStars": "View all by stars",
"tagInsightsSection": "Tag Popularity & Distribution",
"topTagsTitle": "Top Tags",
"tagInsightsSection": "Top Tags",
"topTagsTitle": "Top Tags Carousel",
"categoryDistributionTitle": "Category Distribution",
"tagsUnit": "tags",
"projectsUnit": "projects",
+7 -5
View File
@@ -22,15 +22,15 @@
"projectCount": "个项目"
},
"home": {
"title": "AI 项目导航",
"title": "Agent 项目导航",
"subtitle": "发现和探索全网优质 AI 项目",
"featuredProjects": "精选项目",
"browseByTag": "按标签浏览",
"searchPlaceholder": "搜索 AI 项目...",
"aiSearchPlaceholder": "描述你想要的项目,如:能生成视频的 AI 工具...",
"metaTitle": "Agent Park - AI 项目导航",
"metaTitle": "Agent Park - Agent 项目导航",
"metaDescription": "发现和探索全网优质 AI 项目",
"heroTitle": "AI 项目",
"heroTitle": "Agent 项目",
"heroTitle2": "导航",
"heroDescription": "发现和探索来自全网的高质量 AI 项目。为开发者和爱好者精心策划。",
"browseByTagsSection": "按标签浏览",
@@ -41,6 +41,8 @@
"statsTotalProjects": "项目总数",
"statsActiveProjects": "活跃项目",
"statsArchivedProjects": "归档项目",
"statsNewProjects30d": "近30天新增",
"statsNewProjects24h": "近24小时新增",
"statsTotalTags": "标签总数",
"statsNewProjects7d": "近7天新增",
"rankingsSection": "项目榜单",
@@ -53,8 +55,8 @@
"noStarsProjects": "暂无可展示的 Stars 数据",
"viewAllLatest": "查看全部最新项目",
"viewAllByStars": "查看全部 Stars 排序",
"tagInsightsSection": "标签热度与分布",
"topTagsTitle": "热门标签",
"tagInsightsSection": "热门标签",
"topTagsTitle": "热门标签轮播",
"categoryDistributionTitle": "分类分布",
"tagsUnit": "个标签",
"projectsUnit": "个项目",