feat: 新增首页洞察模块与优化 n8n 工作流
首页新增四个洞察组件: - HomeOverviewStats: 项目统计概览 - HomeRankings: 最新项目与星标排行榜 - HomeTagInsights: 标签分布与热门标签 - HomeRecentTimeline: 最近项目时间线 - 新增 useHome hook 聚合首页数据 n8n 工作流优化: - 节点名称中文化,提升可读性 - HTTP 节点内置分页支持拉取全量项目 - 新增"拆分项目列表"节点实现逐项目处理 - 移除 SITE_BASE_URL 依赖,使用固定生产环境 URL
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user