feat: 新增首页洞察模块与优化 n8n 工作流

首页新增四个洞察组件:
- HomeOverviewStats: 项目统计概览
- HomeRankings: 最新项目与星标排行榜
- HomeTagInsights: 标签分布与热门标签
- HomeRecentTimeline: 最近项目时间线
- 新增 useHome hook 聚合首页数据

n8n 工作流优化:
- 节点名称中文化,提升可读性
- HTTP 节点内置分页支持拉取全量项目
- 新增"拆分项目列表"节点实现逐项目处理
- 移除 SITE_BASE_URL 依赖,使用固定生产环境 URL
This commit is contained in:
2026-02-22 19:45:01 +08:00
parent 8f04acb50c
commit c7e82b1f8b
8 changed files with 1052 additions and 63 deletions
+65 -1
View File
@@ -1,7 +1,12 @@
import { getTranslations } from 'next-intl/server'
import { getProjects } from '@/hooks/useProjects'
import { getHomePageData } from '@/hooks/useHome'
import { ProjectList } from '@/components/project/ProjectList'
import { HomeSearchBar } from '@/components/search/HomeSearchBar'
import { HomeOverviewStats } from '@/components/home/HomeOverviewStats'
import { HomeRankings } from '@/components/home/HomeRankings'
import { HomeTagInsights } from '@/components/home/HomeTagInsights'
import { HomeRecentTimeline } from '@/components/home/HomeRecentTimeline'
import { Suspense } from 'react'
interface HomePageProps {
@@ -13,7 +18,7 @@ export default async function HomePage({ params }: HomePageProps) {
const t = await getTranslations('home')
const tCommon = await getTranslations('common')
const projectsData = await getProjects({ limit: 6 })
const [projectsData, homeData] = await Promise.all([getProjects({ limit: 6 }), getHomePageData()])
return (
<>
@@ -43,6 +48,65 @@ export default async function HomePage({ params }: HomePageProps) {
</Suspense>
</section>
{/* Home Insights */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 space-y-12">
<HomeOverviewStats
locale={locale}
stats={homeData.overview}
translations={{
sectionTitle: t('insightsSection'),
totalProjects: t('statsTotalProjects'),
activeProjects: t('statsActiveProjects'),
archivedProjects: t('statsArchivedProjects'),
totalTags: t('statsTotalTags'),
newProjects7d: t('statsNewProjects7d'),
}}
/>
<HomeRankings
locale={locale}
latestByWindow={homeData.rankings.latestByWindow}
topStars={homeData.rankings.topStars}
translations={{
sectionTitle: t('rankingsSection'),
latestTitle: t('latestRankingTitle'),
starsTitle: t('starsRankingTitle'),
window24h: t('window24h'),
window7d: t('window7d'),
window30d: t('window30d'),
noLatestProjects: t('noLatestProjects'),
noStarsProjects: t('noStarsProjects'),
viewAllLatest: t('viewAllLatest'),
viewAllStars: t('viewAllByStars'),
}}
/>
<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'),
}}
/>
<HomeRecentTimeline
locale={locale}
projects={homeData.timeline}
translations={{
sectionTitle: t('recentTimelineSection'),
emptyText: t('noTimelineProjects'),
viewAllProjects: t('viewAllProjects'),
}}
/>
</section>
{/* Featured Projects Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-24">
<div className="flex items-end justify-between mb-8">
+57
View File
@@ -0,0 +1,57 @@
interface HomeOverviewStatsProps {
locale: string
stats: {
totalProjects: number
activeProjects: number
archivedProjects: number
totalTags: number
newProjects7d: number
}
translations: {
sectionTitle: string
totalProjects: string
activeProjects: string
archivedProjects: string
totalTags: string
newProjects7d: string
}
}
function formatNumber(value: number, locale: string): string {
const formatter = new Intl.NumberFormat(locale === 'en' ? 'en-US' : 'zh-CN')
return formatter.format(value)
}
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.newProjects7d, value: stats.newProjects7d },
]
return (
<section>
<div className="flex items-end justify-between mb-6">
<h2 className="font-display text-3xl font-bold">{translations.sectionTitle}</h2>
</div>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
{cards.map((card) => (
<article
key={card.label}
className="bg-white dark:bg-surface-dark border-2 border-black dark:border-gray-600 p-4 shadow-neo-sm"
>
<p className="text-xs uppercase font-display font-bold text-gray-500 dark:text-gray-400 mb-2">
{card.label}
</p>
<p className="font-display text-3xl font-bold leading-none">
{formatNumber(card.value, locale)}
</p>
</article>
))}
</div>
</section>
)
}
+188
View File
@@ -0,0 +1,188 @@
'use client'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import type { HomeProjectSummary } from '@/hooks/useHome'
type RankingWindow = '24h' | '7d' | '30d'
interface HomeRankingsProps {
locale: string
latestByWindow: Record<RankingWindow, HomeProjectSummary[]>
topStars: HomeProjectSummary[]
translations: {
sectionTitle: string
latestTitle: string
starsTitle: string
window24h: string
window7d: string
window30d: string
noLatestProjects: string
noStarsProjects: string
viewAllLatest: string
viewAllStars: string
}
}
function formatDate(date: string, locale: string): string {
const dateObj = new Date(date)
const formatter = new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
return formatter.format(dateObj)
}
function formatNumber(value: number, locale: string): string {
const formatter = new Intl.NumberFormat(locale === 'en' ? 'en-US' : 'zh-CN')
return formatter.format(value)
}
function ProjectRow({
project,
locale,
rank,
showStars,
}: {
project: HomeProjectSummary
locale: string
rank: number
showStars: boolean
}) {
const name = locale === 'en' && project.nameEn ? project.nameEn : project.name
return (
<li className="border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark p-3 shadow-neo-sm">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0">
<span className="font-display text-lg font-bold w-6 shrink-0">{rank}</span>
<div className="min-w-0">
<Link
href={`/${locale}/projects/${project.slug}`}
className="font-display font-bold hover:underline line-clamp-1"
>
{name}
</Link>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatDate(project.createdAt, locale)}
</p>
</div>
</div>
{showStars && (
<span className="font-display text-xs font-bold shrink-0">
{formatNumber(project.githubStars, locale)}
</span>
)}
</div>
</li>
)
}
export function HomeRankings({
locale,
latestByWindow,
topStars,
translations,
}: HomeRankingsProps) {
const [activeWindow, setActiveWindow] = useState<RankingWindow>('7d')
const windowOptions = useMemo(
() => [
{ key: '24h' as const, label: translations.window24h },
{ key: '7d' as const, label: translations.window7d },
{ key: '30d' as const, label: translations.window30d },
],
[translations.window24h, translations.window7d, translations.window30d]
)
const latestProjects = latestByWindow[activeWindow]
return (
<section>
<div className="flex items-end justify-between mb-6">
<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">
<div className="flex items-center justify-between gap-4 mb-4">
<h3 className="font-display text-xl font-bold">{translations.latestTitle}</h3>
<div className="flex items-center gap-2">
{windowOptions.map((option) => (
<button
key={option.key}
type="button"
onClick={() => setActiveWindow(option.key)}
className={`px-3 py-1 text-xs font-display font-bold border-2 border-black dark:border-gray-500 ${
activeWindow === option.key
? 'bg-black text-white dark:bg-primary dark:text-black'
: 'bg-white dark:bg-surface-dark hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{option.label}
</button>
))}
</div>
</div>
{latestProjects.length > 0 ? (
<ol className="space-y-3">
{latestProjects.map((project, index) => (
<ProjectRow
key={project.id}
project={project}
locale={locale}
rank={index + 1}
showStars={false}
/>
))}
</ol>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{translations.noLatestProjects}</p>
)}
<div className="mt-4">
<Link
href={`/${locale}/projects?sort=latest`}
className="inline-flex items-center font-display text-sm font-bold hover:underline"
>
{translations.viewAllLatest} <span className="ml-1"></span>
</Link>
</div>
</article>
<article className="border-2 border-black dark:border-gray-600 bg-surface-light dark:bg-surface-dark p-5 shadow-neo">
<div className="flex items-center justify-between gap-4 mb-4">
<h3 className="font-display text-xl font-bold">{translations.starsTitle}</h3>
</div>
{topStars.length > 0 ? (
<ol className="space-y-3">
{topStars.map((project, index) => (
<ProjectRow
key={project.id}
project={project}
locale={locale}
rank={index + 1}
showStars
/>
))}
</ol>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{translations.noStarsProjects}</p>
)}
<div className="mt-4">
<Link
href={`/${locale}/projects?sort=stars_desc`}
className="inline-flex items-center font-display text-sm font-bold hover:underline"
>
{translations.viewAllStars} <span className="ml-1"></span>
</Link>
</div>
</article>
</div>
</section>
)
}
@@ -0,0 +1,93 @@
import Link from 'next/link'
import type { HomeProjectSummary } from '@/hooks/useHome'
interface HomeRecentTimelineProps {
locale: string
projects: HomeProjectSummary[]
translations: {
sectionTitle: string
emptyText: string
viewAllProjects: string
}
}
function formatDate(date: string, locale: string): string {
const formatter = new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
return formatter.format(new Date(date))
}
export function HomeRecentTimeline({
locale,
projects,
translations,
}: HomeRecentTimelineProps) {
return (
<section>
<div className="flex items-end justify-between mb-6">
<h2 className="font-display text-3xl font-bold">{translations.sectionTitle}</h2>
</div>
<article className="border-2 border-black dark:border-gray-600 bg-surface-light dark:bg-surface-dark p-5 shadow-neo">
{projects.length > 0 ? (
<ol className="border-l-2 border-black dark:border-gray-600 pl-6 space-y-6">
{projects.map((project) => {
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
const displayDescription =
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
return (
<li key={project.id} className="relative">
<span
className="absolute -left-[34px] top-1 h-3 w-3 border-2 border-black dark:border-gray-600 bg-primary"
aria-hidden="true"
/>
<p className="font-display text-xs uppercase text-gray-500 dark:text-gray-400 mb-1">
{formatDate(project.createdAt, locale)}
</p>
<Link
href={`/${locale}/projects/${project.slug}`}
className="font-display text-lg font-bold hover:underline"
>
{displayName}
</Link>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
{displayDescription}
</p>
{project.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{project.tags.slice(0, 3).map((tag) => (
<Link
key={tag.id}
href={`/${locale}/projects?tag=${tag.slug}`}
className="px-2 py-1 border border-black dark:border-gray-600 text-[10px] uppercase font-display font-bold bg-white dark:bg-surface-dark"
>
{locale === 'en' && tag.nameEn ? tag.nameEn : tag.name}
</Link>
))}
</div>
)}
</li>
)
})}
</ol>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{translations.emptyText}</p>
)}
<div className="mt-6">
<Link
href={`/${locale}/projects?sort=latest`}
className="inline-flex items-center font-display text-sm font-bold hover:underline"
>
{translations.viewAllProjects} <span className="ml-1"></span>
</Link>
</div>
</article>
</section>
)
}
+125
View File
@@ -0,0 +1,125 @@
import Link from 'next/link'
import type { HomeCategoryDistribution } from '@/hooks/useHome'
interface HomeTagInsightsProps {
locale: string
topTags: Array<{
id: string
name: string
nameEn: string | null
slug: string
projectCount: number
}>
categories: HomeCategoryDistribution[]
translations: {
sectionTitle: string
topTagsTitle: string
categoryDistributionTitle: string
tagsUnit: string
projectsUnit: string
noTagData: string
viewAllProjects: string
}
}
function formatNumber(value: number, locale: string): string {
const formatter = new Intl.NumberFormat(locale === 'en' ? 'en-US' : 'zh-CN')
return formatter.format(value)
}
export function HomeTagInsights({
locale,
topTags,
categories,
translations,
}: HomeTagInsightsProps) {
const maxAssociations = Math.max(1, ...categories.map((category) => category.projectAssociationCount))
return (
<section>
<div className="flex items-end justify-between mb-6">
<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) => (
<Link
key={tag.id}
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"
>
<span>{locale === 'en' && tag.nameEn ? tag.nameEn : tag.name}</span>
<span className="ml-2 text-gray-500 dark:text-gray-400">
{formatNumber(tag.projectCount, locale)}
</span>
</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>
<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>
</section>
)
}
+261
View File
@@ -0,0 +1,261 @@
import { prisma } from '@/lib/prisma'
import { getTagCategoryGroups, getTopTags } from '@/hooks/useProjects'
import { Prisma } from '@prisma/client'
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const DEFAULT_RANKING_LIMIT = 6
const DEFAULT_TIMELINE_LIMIT = 8
const DEFAULT_TOP_TAG_LIMIT = 12
export type HomeProjectSummary = {
id: string
slug: string
name: string
nameEn: string | null
description: string
descriptionEn: string | null
githubStars: number
createdAt: string
tags: Array<{
id: string
name: string
nameEn: string | null
slug: string
}>
}
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
newProjects7d: number
}
rankings: {
latestByWindow: {
'24h': HomeProjectSummary[]
'7d': HomeProjectSummary[]
'30d': HomeProjectSummary[]
}
topStars: HomeProjectSummary[]
}
tagInsights: {
topTags: Array<{
id: string
name: string
nameEn: string | null
slug: string
projectCount: number
}>
categoryDistribution: HomeCategoryDistribution[]
}
timeline: HomeProjectSummary[]
}
type ProjectWithRelations = Prisma.ProjectGetPayload<{
include: {
tags: {
include: {
tag: true
}
}
}
}>
async function safeQuery<T>(operationName: string, fallback: T, task: () => Promise<T>): Promise<T> {
try {
return await task()
} catch (error) {
console.error(
`[db] ${operationName} degraded to fallback:`,
error instanceof Error ? error.message : String(error)
)
return fallback
}
}
function mapProjectSummary(project: ProjectWithRelations): HomeProjectSummary {
return {
id: project.id,
slug: project.slug,
name: project.name,
nameEn: project.nameEn,
description: project.description,
descriptionEn: project.descriptionEn,
githubStars: project.githubStars,
createdAt: project.createdAt.toISOString(),
tags: project.tags.map((projectTag) => ({
id: projectTag.tag.id,
name: projectTag.tag.name,
nameEn: projectTag.tag.nameEn,
slug: projectTag.tag.slug,
})),
}
}
async function getLatestProjects(limit: number, createdAfter?: Date): Promise<HomeProjectSummary[]> {
const projects = await safeQuery('getLatestProjects', [] as ProjectWithRelations[], () =>
prisma.project.findMany({
where: {
status: 'ACTIVE',
...(createdAfter ? { createdAt: { gte: createdAfter } } : {}),
},
include: {
tags: {
include: {
tag: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: limit,
})
)
return projects.map(mapProjectSummary)
}
async function getTopStarsProjects(limit: number): Promise<HomeProjectSummary[]> {
const projects = await safeQuery('getTopStarsProjects', [] as ProjectWithRelations[], () =>
prisma.project.findMany({
where: {
status: 'ACTIVE',
},
include: {
tags: {
include: {
tag: true,
},
},
},
orderBy: [{ githubStars: 'desc' }, { createdAt: 'desc' }],
take: limit,
})
)
return projects.map(mapProjectSummary)
}
export async function getHomePageData(): Promise<HomePageData> {
const now = Date.now()
const last24Hours = new Date(now - ONE_DAY_MS)
const last7Days = new Date(now - ONE_DAY_MS * 7)
const last30Days = new Date(now - ONE_DAY_MS * 30)
const [
totalProjects,
activeProjects,
archivedProjects,
totalTags,
newProjects7d,
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({
where: {
category: {
not: 'FIXED_PROJECT_TYPE',
},
projects: {
some: {},
},
},
})
),
safeQuery('countNewProjects7d', 0, () =>
prisma.project.count({
where: {
status: 'ACTIVE',
createdAt: {
gte: last7Days,
},
},
})
),
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,
newProjects7d,
},
rankings: {
latestByWindow: {
'24h': latest24h,
'7d': latest7d,
'30d': latest30d,
},
topStars,
},
tagInsights: {
topTags: topTags.map((tag) => ({
id: tag.id,
name: tag.name,
nameEn: tag.nameEn,
slug: tag.slug,
projectCount: tag._count.projects,
})),
categoryDistribution,
},
timeline,
}
}