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
+241 -52
View File
@@ -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": {
+22 -10
View File
@@ -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.