feat: 新增首页洞察模块与优化 n8n 工作流
首页新增四个洞察组件: - HomeOverviewStats: 项目统计概览 - HomeRankings: 最新项目与星标排行榜 - HomeTagInsights: 标签分布与热门标签 - HomeRecentTimeline: 最近项目时间线 - 新增 useHome hook 聚合首页数据 n8n 工作流优化: - 节点名称中文化,提升可读性 - HTTP 节点内置分页支持拉取全量项目 - 新增"拆分项目列表"节点实现逐项目处理 - 移除 SITE_BASE_URL 依赖,使用固定生产环境 URL
This commit is contained in:
@@ -4,42 +4,92 @@
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "manual-trigger",
|
||||
"name": "Manual Trigger",
|
||||
"name": "手动触发",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0]
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "={{$env.SITE_BASE_URL}}/api/tags",
|
||||
"url": "https://www.agentpark.fun/api/tags",
|
||||
"options": {}
|
||||
},
|
||||
"id": "fetch-tags",
|
||||
"name": "Fetch Tags",
|
||||
"name": "获取标签池",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.4,
|
||||
"position": [220, 0]
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "={{$env.SITE_BASE_URL}}/api/projects?limit=100&page=1&sort=latest",
|
||||
"options": {}
|
||||
"url": "https://www.agentpark.fun/api/projects",
|
||||
"sendQuery": true,
|
||||
"specifyQuery": "keypair",
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"value": "100"
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"value": "latest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"pagination": {
|
||||
"pagination": {
|
||||
"paginationMode": "updateAParameterInEachRequest",
|
||||
"parameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"type": "qs",
|
||||
"name": "page",
|
||||
"value": "={{($response.body.pagination.page ?? $response.body.pagination.currentPage ?? 1) + 1}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"paginationCompleteWhen": "other",
|
||||
"completeExpression": "={{!$response.body.pagination || (($response.body.pagination.page ?? $response.body.pagination.currentPage ?? 1) >= ($response.body.pagination.totalPages ?? 1))}}",
|
||||
"limitPagesFetched": true,
|
||||
"maxRequests": 20,
|
||||
"requestInterval": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "fetch-projects",
|
||||
"name": "Fetch Projects",
|
||||
"name": "获取项目列表",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.4,
|
||||
"position": [440, 0]
|
||||
"position": [
|
||||
440,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const tagsResponse = $('Fetch Tags').first().json;\nconst projectsResponse = $input.first().json;\n\nif (!tagsResponse?.success || !Array.isArray(tagsResponse?.tags)) {\n throw new Error('Failed to fetch tags from /api/tags');\n}\n\nif (!Array.isArray(projectsResponse?.projects)) {\n throw new Error('Failed to fetch projects from /api/projects');\n}\n\nif ((projectsResponse?.pagination?.total || 0) > projectsResponse.projects.length) {\n throw new Error(`Only fetched ${projectsResponse.projects.length} projects but total is ${projectsResponse.pagination.total}. Increase API limit or add pagination in workflow.`);\n}\n\nconst allowedCategories = [\n 'FIXED_PROJECT_TYPE',\n 'TECH_STACK',\n 'AI_PARADIGM',\n 'PRODUCT_FORM',\n 'DOMAIN_SCENARIO',\n];\n\nconst tagPools = Object.fromEntries(\n allowedCategories.map((category) => [\n category,\n tagsResponse.tags\n .filter((tag) => tag.category === category)\n .map((tag) => ({\n id: tag.id,\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n })),\n ])\n);\n\nreturn projectsResponse.projects.map((project) => ({\n json: {\n projectSlug: project.slug,\n projectName: project.name,\n projectNameEn: project.nameEn || '',\n projectDescription: project.description,\n projectDescriptionEn: project.descriptionEn || '',\n currentTags: Array.isArray(project.tags)\n ? project.tags.map((tag) => ({\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n }))\n : [],\n tagPools,\n },\n}));"
|
||||
"jsCode": "const tagsResponse = $('获取标签池').first().json;\nconst project = $input.first().json.projects;\n\nif (!tagsResponse?.success || !Array.isArray(tagsResponse?.tags)) {\n throw new Error('Failed to fetch tags from /api/tags');\n}\n\nif (!project || typeof project !== 'object' || !project.slug) {\n throw new Error('Invalid project item after split');\n}\n\nconst allowedCategories = [\n 'FIXED_PROJECT_TYPE',\n 'TECH_STACK',\n 'AI_PARADIGM',\n 'PRODUCT_FORM',\n 'DOMAIN_SCENARIO',\n];\n\nconst tagPools = Object.fromEntries(\n allowedCategories.map((category) => [\n category,\n tagsResponse.tags\n .filter((tag) => tag.category === category)\n .map((tag) => ({\n id: tag.id,\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n })),\n ])\n);\n\nreturn [\n {\n json: {\n projectSlug: project.slug,\n projectName: project.name,\n projectNameEn: project.nameEn || '',\n projectDescription: project.description,\n projectDescriptionEn: project.descriptionEn || '',\n currentTags: Array.isArray(project.tags)\n ? project.tags.map((tag) => ({\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n }))\n : [],\n tagPools,\n },\n },\n];"
|
||||
},
|
||||
"id": "prepare-classification-items",
|
||||
"name": "Prepare Classification Items",
|
||||
"name": "准备分类输入",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [660, 0]
|
||||
"position": [
|
||||
660,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -61,10 +111,13 @@
|
||||
}
|
||||
},
|
||||
"id": "ai-fixed-project-type",
|
||||
"name": "AI Fixed Project Type",
|
||||
"name": "AI 固定项目分类",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [880, 0]
|
||||
"position": [
|
||||
880,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -86,10 +139,13 @@
|
||||
}
|
||||
},
|
||||
"id": "ai-domain-scenario",
|
||||
"name": "AI Domain Scenario",
|
||||
"name": "AI 领域场景分类",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [1100, 0]
|
||||
"position": [
|
||||
1100,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -111,10 +167,13 @@
|
||||
}
|
||||
},
|
||||
"id": "ai-product-form",
|
||||
"name": "AI Product Form",
|
||||
"name": "AI 产品形态分类",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [1320, 0]
|
||||
"position": [
|
||||
1320,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -136,10 +195,13 @@
|
||||
}
|
||||
},
|
||||
"id": "ai-tech-stack",
|
||||
"name": "AI Tech Stack",
|
||||
"name": "AI 技术栈分类",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [1540, 0]
|
||||
"position": [
|
||||
1540,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -161,80 +223,207 @@
|
||||
}
|
||||
},
|
||||
"id": "ai-ai-paradigm",
|
||||
"name": "AI AI Paradigm",
|
||||
"name": "AI 技术范式分类",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [1760, 0]
|
||||
"position": [
|
||||
1760,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "function parseNodeJson(nodeJson) {\n const content = nodeJson?.message?.content || nodeJson?.text || nodeJson?.response || nodeJson;\n\n if (typeof content === 'string') {\n const trimmed = content.trim();\n try {\n return JSON.parse(trimmed);\n } catch {\n const jsonMatch = trimmed.match(/\\{[\\s\\S]*\\}/);\n if (jsonMatch) {\n return JSON.parse(jsonMatch[0]);\n }\n return { selected: [] };\n }\n }\n\n if (typeof content === 'object' && content !== null) {\n return content;\n }\n\n return { selected: [] };\n}\n\nfunction normalizeSelected(parsed, pool, minCount, maxCount) {\n const poolSlugs = pool.map((tag) => String(tag.slug).toLowerCase());\n const allowed = new Set(poolSlugs);\n const selectedRaw = Array.isArray(parsed?.selected) ? parsed.selected : [];\n const selected = [];\n\n for (const slug of selectedRaw) {\n const normalizedSlug = String(slug || '').trim().toLowerCase();\n if (!normalizedSlug || !allowed.has(normalizedSlug) || selected.includes(normalizedSlug)) {\n continue;\n }\n selected.push(normalizedSlug);\n if (selected.length >= maxCount) {\n break;\n }\n }\n\n if (selected.length < minCount) {\n return poolSlugs.slice(0, minCount);\n }\n return selected;\n}\n\nconst preparedItems = $items('Prepare Classification Items', 0);\nconst fixedItems = $items('AI Fixed Project Type', 0);\nconst domainItems = $items('AI Domain Scenario', 0);\nconst productItems = $items('AI Product Form', 0);\nconst techItems = $items('AI Tech Stack', 0);\nconst paradigmItems = $items('AI AI Paradigm', 0);\n\nif (\n preparedItems.length !== fixedItems.length ||\n preparedItems.length !== domainItems.length ||\n preparedItems.length !== productItems.length ||\n preparedItems.length !== techItems.length ||\n preparedItems.length !== paradigmItems.length\n) {\n throw new Error('AI node output item count mismatch');\n}\n\nconst output = [];\nfor (let i = 0; i < preparedItems.length; i++) {\n const base = preparedItems[i].json;\n const pools = base.tagPools;\n\n const fixed = normalizeSelected(\n parseNodeJson(fixedItems[i].json),\n pools.FIXED_PROJECT_TYPE,\n 1,\n 1\n );\n const domains = normalizeSelected(\n parseNodeJson(domainItems[i].json),\n pools.DOMAIN_SCENARIO,\n 1,\n 3\n );\n const productForms = normalizeSelected(\n parseNodeJson(productItems[i].json),\n pools.PRODUCT_FORM,\n 1,\n 3\n );\n const techStack = normalizeSelected(\n parseNodeJson(techItems[i].json),\n pools.TECH_STACK,\n 1,\n 8\n );\n const paradigms = normalizeSelected(\n parseNodeJson(paradigmItems[i].json),\n pools.AI_PARADIGM,\n 0,\n 5\n );\n\n output.push({\n json: {\n projectSlug: base.projectSlug,\n selectedTagSlugsByCategory: {\n FIXED_PROJECT_TYPE: fixed,\n TECH_STACK: techStack,\n AI_PARADIGM: paradigms,\n PRODUCT_FORM: productForms,\n DOMAIN_SCENARIO: domains,\n },\n },\n });\n}\n\nreturn output;"
|
||||
"jsCode": "function parseNodeJson(nodeJson) {\n const content = nodeJson?.message?.content || nodeJson?.text || nodeJson?.response || nodeJson;\n\n if (typeof content === 'string') {\n const trimmed = content.trim();\n try {\n return JSON.parse(trimmed);\n } catch {\n const jsonMatch = trimmed.match(/\\{[\\s\\S]*\\}/);\n if (jsonMatch) {\n return JSON.parse(jsonMatch[0]);\n }\n return { selected: [] };\n }\n }\n\n if (typeof content === 'object' && content !== null) {\n return content;\n }\n\n return { selected: [] };\n}\n\nfunction normalizeSelected(parsed, pool, minCount, maxCount) {\n const poolSlugs = pool.map((tag) => String(tag.slug).toLowerCase());\n const allowed = new Set(poolSlugs);\n const selectedRaw = Array.isArray(parsed?.selected) ? parsed.selected : [];\n const selected = [];\n\n for (const slug of selectedRaw) {\n const normalizedSlug = String(slug || '').trim().toLowerCase();\n if (!normalizedSlug || !allowed.has(normalizedSlug) || selected.includes(normalizedSlug)) {\n continue;\n }\n selected.push(normalizedSlug);\n if (selected.length >= maxCount) {\n break;\n }\n }\n\n if (selected.length < minCount) {\n return poolSlugs.slice(0, minCount);\n }\n return selected;\n}\n\nconst preparedItems = $items('准备分类输入', 0);\nconst fixedItems = $items('AI 固定项目分类', 0);\nconst domainItems = $items('AI 领域场景分类', 0);\nconst productItems = $items('AI 产品形态分类', 0);\nconst techItems = $items('AI 技术栈分类', 0);\nconst paradigmItems = $items('AI 技术范式分类', 0);\n\nif (\n preparedItems.length !== fixedItems.length ||\n preparedItems.length !== domainItems.length ||\n preparedItems.length !== productItems.length ||\n preparedItems.length !== techItems.length ||\n preparedItems.length !== paradigmItems.length\n) {\n throw new Error('AI node output item count mismatch');\n}\n\nconst output = [];\nfor (let i = 0; i < preparedItems.length; i++) {\n const base = preparedItems[i].json;\n const pools = base.tagPools;\n\n const fixed = normalizeSelected(\n parseNodeJson(fixedItems[i].json),\n pools.FIXED_PROJECT_TYPE,\n 1,\n 1\n );\n const domains = normalizeSelected(\n parseNodeJson(domainItems[i].json),\n pools.DOMAIN_SCENARIO,\n 1,\n 3\n );\n const productForms = normalizeSelected(\n parseNodeJson(productItems[i].json),\n pools.PRODUCT_FORM,\n 1,\n 3\n );\n const techStack = normalizeSelected(\n parseNodeJson(techItems[i].json),\n pools.TECH_STACK,\n 1,\n 8\n );\n const paradigms = normalizeSelected(\n parseNodeJson(paradigmItems[i].json),\n pools.AI_PARADIGM,\n 0,\n 5\n );\n\n output.push({\n json: {\n projectSlug: base.projectSlug,\n selectedTagSlugsByCategory: {\n FIXED_PROJECT_TYPE: fixed,\n TECH_STACK: techStack,\n AI_PARADIGM: paradigms,\n PRODUCT_FORM: productForms,\n DOMAIN_SCENARIO: domains,\n },\n },\n });\n}\n\nreturn output;"
|
||||
},
|
||||
"id": "build-reset-payload",
|
||||
"name": "Build Reset Payload",
|
||||
"name": "组装重置载荷",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1980, 0]
|
||||
"position": [
|
||||
1980,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{$env.SITE_BASE_URL}}/api/tags/reset-projects",
|
||||
"url": "https://www.agentpark.fun/api/tags/reset-projects",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={\n \"apiKey\": \"{{$env.WEBHOOK_API_KEY}}\",\n \"dryRun\": false,\n \"replaceAllCategories\": true,\n \"projects\": [\n {\n \"projectSlug\": \"{{$json.projectSlug}}\",\n \"selectedTagSlugsByCategory\": {{JSON.stringify($json.selectedTagSlugsByCategory)}}\n }\n ]\n}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "reset-project-tags",
|
||||
"name": "Reset Project Tags",
|
||||
"name": "调用标签重置接口",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.4,
|
||||
"position": [2200, 0]
|
||||
"position": [
|
||||
2200,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const results = $input.all().map((item) => item.json);\nconst summary = {\n totalRequests: results.length,\n successRequests: results.filter((r) => r.success === true).length,\n failedRequests: results.filter((r) => r.success !== true).length,\n timestamp: new Date().toISOString(),\n};\n\nreturn [{ json: { summary, results } }];"
|
||||
},
|
||||
"id": "summarize-results",
|
||||
"name": "Summarize Results",
|
||||
"name": "汇总执行结果",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [2420, 0]
|
||||
"position": [
|
||||
2420,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "splitOutItems",
|
||||
"fieldToSplitOut": "projects",
|
||||
"include": "noOtherFields",
|
||||
"options": {}
|
||||
},
|
||||
"id": "split-projects",
|
||||
"name": "拆分项目列表",
|
||||
"type": "n8n-nodes-base.itemLists",
|
||||
"typeVersion": 3.1,
|
||||
"position": [
|
||||
560,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Manual Trigger": {
|
||||
"main": [[{ "node": "Fetch Tags", "type": "main", "index": 0 }]]
|
||||
"手动触发": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "获取标签池",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fetch Tags": {
|
||||
"main": [[{ "node": "Fetch Projects", "type": "main", "index": 0 }]]
|
||||
"获取标签池": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "获取项目列表",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fetch Projects": {
|
||||
"main": [[{ "node": "Prepare Classification Items", "type": "main", "index": 0 }]]
|
||||
"获取项目列表": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "拆分项目列表",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Prepare Classification Items": {
|
||||
"main": [[{ "node": "AI Fixed Project Type", "type": "main", "index": 0 }]]
|
||||
"准备分类输入": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI 固定项目分类",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI Fixed Project Type": {
|
||||
"main": [[{ "node": "AI Domain Scenario", "type": "main", "index": 0 }]]
|
||||
"AI 固定项目分类": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI 领域场景分类",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI Domain Scenario": {
|
||||
"main": [[{ "node": "AI Product Form", "type": "main", "index": 0 }]]
|
||||
"AI 领域场景分类": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI 产品形态分类",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI Product Form": {
|
||||
"main": [[{ "node": "AI Tech Stack", "type": "main", "index": 0 }]]
|
||||
"AI 产品形态分类": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI 技术栈分类",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI Tech Stack": {
|
||||
"main": [[{ "node": "AI AI Paradigm", "type": "main", "index": 0 }]]
|
||||
"AI 技术栈分类": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI 技术范式分类",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI AI Paradigm": {
|
||||
"main": [[{ "node": "Build Reset Payload", "type": "main", "index": 0 }]]
|
||||
"AI 技术范式分类": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "组装重置载荷",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build Reset Payload": {
|
||||
"main": [[{ "node": "Reset Project Tags", "type": "main", "index": 0 }]]
|
||||
"组装重置载荷": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "调用标签重置接口",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Reset Project Tags": {
|
||||
"main": [[{ "node": "Summarize Results", "type": "main", "index": 0 }]]
|
||||
"调用标签重置接口": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "汇总执行结果",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"拆分项目列表": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "准备分类输入",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -19,22 +19,34 @@ Each AI node must select only from its own category pool.
|
||||
|
||||
## Required Environment Variables in n8n
|
||||
|
||||
- `SITE_BASE_URL`: e.g. `https://your-site.com`
|
||||
- `WEBHOOK_API_KEY`: same key configured on Next.js server
|
||||
|
||||
## Fixed Base URL
|
||||
|
||||
- All workflow API URLs are hardcoded to `https://www.agentpark.fun`
|
||||
|
||||
## Execution Flow
|
||||
|
||||
1. `Manual Trigger`
|
||||
2. `HTTP GET /api/tags` to load full tag pool
|
||||
3. `HTTP GET /api/projects?limit=100&page=1` to load projects
|
||||
4. `Code` prepares per-project classification items and category pools
|
||||
5. Five independent AI nodes classify by category
|
||||
6. `Code` normalizes/parses AI outputs into `selectedTagSlugsByCategory`
|
||||
7. `HTTP POST /api/tags/reset-projects` updates tags per project
|
||||
8. `Code` summarizes success/failure counts
|
||||
1. `手动触发`
|
||||
2. `获取标签池` (`HTTP GET /api/tags`) 读取标签池
|
||||
3. `获取项目列表` (`HTTP GET /api/projects`) 使用 Query 参数(`limit=100,page=1,sort=latest`)+ HTTP 节点内置分页拉取全部项目
|
||||
4. `拆分项目列表` 将每页 `projects[]` 拆分为“单项目一条记录”
|
||||
5. `准备分类输入` 仅做轻量字段整理(不再负责分页/HTTP 请求)
|
||||
6. 五个独立 AI 节点分别分类:
|
||||
- `AI 固定项目分类`
|
||||
- `AI 领域场景分类`
|
||||
- `AI 产品形态分类`
|
||||
- `AI 技术栈分类`
|
||||
- `AI 技术范式分类`
|
||||
7. `组装重置载荷` 归一化 AI 输出并生成 `selectedTagSlugsByCategory`
|
||||
8. `调用标签重置接口` (`HTTP POST /api/tags/reset-projects`) 回写标签
|
||||
9. `汇总执行结果` 输出成功/失败统计
|
||||
|
||||
## Notes
|
||||
|
||||
- Current workflow fetches up to 100 projects in one run. If total > 100, add pagination or increase API limit strategy.
|
||||
- Workflow uses HTTP node built-in pagination for `/api/projects` (`limit=100`) to process full project volume.
|
||||
- `page` 不再写死在 URL 上,避免出现重复请求同一页导致的 identical response 停止问题。
|
||||
- Pagination and project fan-out no longer rely on complex Code-node network logic.
|
||||
- Workflow no longer depends on `SITE_BASE_URL`; base URL is fixed to `https://www.agentpark.fun`.
|
||||
- Endpoint supports `dryRun`. You can set `"dryRun": true` first in the request node for safe validation.
|
||||
- Endpoint performs category validation and rejects cross-category slug usage.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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