feat: 实现 Agent Park AI 项目导航网站核心功能

完成 Next.js 14+ 全栈应用的 MVP 实现,包含以下功能:

## 项目设置
- 初始化 Next.js 14+ 项目,配置 TypeScript 严格模式
- 配置 Tailwind CSS、ESLint、Prettier 代码质量工具
- 集成 shadcn/ui 组件库和 next-intl 国际化方案

## 数据层
- 配置 PostgreSQL + Prisma ORM
- 定义 Project、Tag、ExternalLink 数据模型
- 实现种子数据脚本(5个AI项目)

## 用户故事 1:浏览和搜索 AI 项目
- 实现首页,展示热门标签云和精选项目
- 实现项目列表页,支持搜索和标签筛选
- 创建 TagCloud、ProjectCard、ProjectList、SearchBar 组件
- 实现 ISR 缓存策略优化性能

## 用户故事 2:查看项目详细信息
- 实现项目详情页,显示完整信息
- 创建 ExternalLinkCard 组件展示外部链接
- 实现安全的 target="_blank" 外链跳转

## 用户故事 4:数据更新和管理
- 实现 webhook API 端点接收 n8n 数据推送
- 实现 API Key 身份验证
- 支持部分成功模式的批量数据处理
- 完善的错误处理和日志记录

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-25 15:12:09 +08:00
co-authored by Claude
parent 6d40d4e8e6
commit ba1fab65c6
34 changed files with 8225 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
import { notFound } from "next/navigation"
import { getRequestConfig } from 'next-intl/server'
import { setRequestLocale } from 'next-intl/server'
const locales = ['zh', 'en']
export async function generateStaticParams() {
return locales.map((locale) => ({ locale }))
}
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale } = await params
if (!locales.includes(locale)) {
notFound()
}
setRequestLocale(locale)
return (
<div className="min-h-screen flex flex-col">
<header className="border-b">
<div className="container mx-auto px-4 py-4">
<nav className="flex items-center justify-between">
<a href={`/${locale}`} className="text-xl font-semibold">
Agent Park
</a>
<div className="flex items-center gap-4">
<a href={`/${locale}`} className="text-sm hover:underline">
</a>
<a href={`/${locale}/projects`} className="text-sm hover:underline">
</a>
<div className="flex gap-2 text-sm">
<a href="/zh" className={locale === 'zh' ? 'font-semibold' : 'hover:underline'}>
</a>
<span>/</span>
<a href="/en" className={locale === 'en' ? 'font-semibold' : 'hover:underline'}>
EN
</a>
</div>
</div>
</nav>
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t py-6">
<div className="container mx-auto px-4 text-center text-sm text-muted-foreground">
© 2025 Agent Park. All rights reserved.
</div>
</footer>
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { getTranslations } from 'next-intl/server'
import { getProjects, getAllTags } from '@/hooks/useProjects'
import { ProjectList } from '@/components/project/ProjectList'
import { TagCloud } from '@/components/project/TagCloud'
import { SearchBar } from '@/components/search/SearchBar'
interface HomePageProps {
params: Promise<{ locale: string }>
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params
const t = await getTranslations('home')
const [projectsData, tags] = await Promise.all([
getProjects({ limit: 6 }),
getAllTags(),
])
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">{t('title')}</h1>
<p className="text-lg text-muted-foreground">{t('subtitle')}</p>
</div>
<SearchBar locale={locale} />
<div className="mb-12">
<h2 className="text-2xl font-semibold mb-4">{t('browseByTag')}</h2>
<TagCloud tags={tags} locale={locale} />
</div>
<div>
<h2 className="text-2xl font-semibold mb-6">{t('featuredProjects')}</h2>
<ProjectList projects={projectsData.projects} locale={locale} />
</div>
</div>
)
}
export const revalidate = 300 // ISR: revalidate every 5 minutes
+68
View File
@@ -0,0 +1,68 @@
import { notFound } from 'next/navigation'
import { getProjectBySlug } from '@/hooks/useProjects'
import { ExternalLinkCard } from '@/components/project/ExternalLinkCard'
interface ProjectDetailPageProps {
params: Promise<{ locale: string; id: string }>
}
export default async function ProjectDetailPage({
params,
}: ProjectDetailPageProps) {
const { locale, id } = await params
const project = await getProjectBySlug(id)
if (!project) {
notFound()
}
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<a
href={`/${locale}/projects`}
className="text-primary hover:underline text-sm"
>
</a>
</div>
<div className="mb-8">
<h1 className="text-3xl font-bold mb-4">{project.name}</h1>
{project.nameEn && (
<h2 className="text-xl text-muted-foreground mb-4">{project.nameEn}</h2>
)}
<p className="text-lg text-muted-foreground">{project.description}</p>
</div>
{project.content && (
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4"></h3>
<div className="prose max-w-none">
<p>{project.content}</p>
</div>
</div>
)}
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4"></h3>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-secondary text-secondary-foreground"
>
{tag.name}
</span>
))}
</div>
</div>
<ExternalLinkCard links={project.links} />
</div>
</div>
)
}
export const revalidate = 300 // ISR: revalidate every 5 minutes
+72
View File
@@ -0,0 +1,72 @@
import { getTranslations } from 'next-intl/server'
import { getProjects, getAllTags } from '@/hooks/useProjects'
import { ProjectList } from '@/components/project/ProjectList'
import { TagCloud } from '@/components/project/TagCloud'
import { SearchBar } from '@/components/search/SearchBar'
interface ProjectsPageProps {
params: Promise<{ locale: string }>
searchParams: Promise<{ search?: string; tag?: string; page?: string }>
}
export default async function ProjectsPage({
params,
searchParams,
}: ProjectsPageProps) {
const t = await getTranslations('home')
const [resolvedParams, resolvedSearchParams] = await Promise.all([params, searchParams])
const { locale } = resolvedParams
const search = resolvedSearchParams.search || ''
const tag = resolvedSearchParams.tag || ''
const page = Number(resolvedSearchParams.page) || 1
const [projectsData, tags] = await Promise.all([
getProjects({ search, tag, page }),
getAllTags(),
])
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-4"></h1>
<SearchBar locale={locale} />
</div>
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">{t('browseByTag')}</h2>
<TagCloud tags={tags} locale={locale} />
</div>
<div>
<ProjectList projects={projectsData.projects} locale={locale} />
{projectsData.pagination.totalPages > 1 && (
<div className="flex justify-center gap-2 mt-8">
{page > 1 && (
<a
href={`?search=${search}&tag=${tag}&page=${page - 1}`}
className="px-4 py-2 border rounded-lg hover:bg-secondary"
>
</a>
)}
<span className="px-4 py-2">
{page} / {projectsData.pagination.totalPages}
</span>
{page < projectsData.pagination.totalPages && (
<a
href={`?search=${search}&tag=${tag}&page=${page + 1}`}
className="px-4 py-2 border rounded-lg hover:bg-secondary"
>
</a>
)}
</div>
)}
</div>
</div>
)
}
export const revalidate = 300 // ISR: revalidate every 5 minutes
+203
View File
@@ -0,0 +1,203 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import {
WebhookPayloadSchema,
ProjectInputSchema,
type WebhookPayload,
type ProjectInput,
} from '@/lib/validations'
export async function POST(request: NextRequest) {
const startTime = Date.now()
try {
const body = await request.json()
// Validate payload
const validationResult = WebhookPayloadSchema.safeParse(body)
if (!validationResult.success) {
return NextResponse.json(
{
success: false,
error: 'Validation error',
details: validationResult.error.errors.map((e) => e.message),
},
{ status: 400 }
)
}
const payload = validationResult.data as WebhookPayload
// Verify API Key
const apiKey = process.env.WEBHOOK_API_KEY
if (payload.apiKey !== apiKey) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized',
details: ['Invalid or missing API Key'],
},
{ status: 401 }
)
}
// Process projects with partial success mode
const results = {
processed: payload.projects.length,
created: 0,
updated: 0,
failed: 0,
errors: [] as Array<{
index: number
field: string
message: string
value: any
}>,
}
for (let i = 0; i < payload.projects.length; i++) {
const projectData = payload.projects[i]
// Validate individual project
const projectValidation = ProjectInputSchema.safeParse(projectData)
if (!projectValidation.success) {
results.failed++
results.errors.push({
index: i,
field: projectValidation.error.errors[0].path.join('.'),
message: projectValidation.error.errors[0].message,
value: projectData,
})
continue
}
try {
const validProject = projectValidation.data as ProjectInput
// Upsert tags
const tagConnections = await Promise.all(
validProject.tags.map(async (tag) => {
const slug =
tag.nameEn?.toLowerCase().replace(/\s+/g, '-') ||
tag.name.toLowerCase().replace(/\s+/g, '-')
return prisma.tag.upsert({
where: { slug },
update: {},
create: {
name: tag.name,
nameEn: tag.nameEn || null,
slug,
},
})
})
)
// Generate slug for project
const slug =
validProject.nameEn?.toLowerCase().replace(/\s+/g, '-') ||
validProject.name.toLowerCase().replace(/\s+/g, '-')
// Upsert project
const existingProject = await prisma.project.findUnique({
where: { slug },
})
if (existingProject) {
// Update existing project
await prisma.project.update({
where: { id: existingProject.id },
data: {
name: validProject.name,
nameEn: validProject.nameEn || null,
description: validProject.description,
descriptionEn: validProject.descriptionEn || null,
content: validProject.content || null,
contentEn: validProject.contentEn || null,
status: validProject.status as any,
source: validProject.source || null,
tags: {
set: tagConnections.map((t) => ({ id: t.id })),
},
},
})
// Update links (delete old ones, create new ones)
await prisma.externalLink.deleteMany({
where: { projectId: existingProject.id },
})
await prisma.externalLink.createMany({
data: validProject.links.map((link) => ({
type: link.type as any,
url: link.url,
title: link.title || null,
projectId: existingProject.id,
})),
})
results.updated++
} else {
// Create new project
await prisma.project.create({
data: {
name: validProject.name,
nameEn: validProject.nameEn || null,
slug,
description: validProject.description,
descriptionEn: validProject.descriptionEn || null,
content: validProject.content || null,
contentEn: validProject.contentEn || null,
status: validProject.status as any,
source: validProject.source || null,
tags: {
connect: tagConnections.map((t) => ({ id: t.id })),
},
links: {
create: validProject.links.map((link) => ({
type: link.type as any,
url: link.url,
title: link.title || null,
})),
},
},
})
results.created++
}
} catch (error) {
results.failed++
results.errors.push({
index: i,
field: 'general',
message: error instanceof Error ? error.message : 'Unknown error',
value: projectData,
})
}
}
const duration = Date.now() - startTime
// Log request
console.log(
`[Webhook] Processed ${results.processed} projects in ${duration}ms: ${results.created} created, ${results.updated} updated, ${results.failed} failed`
)
return NextResponse.json({
success: true,
...results,
})
} catch (error) {
console.error('[Webhook] Error:', error)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
details: [error instanceof Error ? error.message : 'Unknown error'],
},
{ status: 500 }
)
}
}
+59
View File
@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import "./globals.css"
const inter = Inter({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "Agent Park - AI 项目导航",
description: "发现和探索全网优质 AI 项目",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="zh">
<body className={inter.className}>{children}</body>
</html>
)
}