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
+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,
}
}