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
+12
View File
@@ -0,0 +1,12 @@
# Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/agent_park"
# Site
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
# Webhook API - Generate a secure key for production
WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars"
# Internationalization
NEXT_INTL_DEFAULT_LOCALE="zh"
NEXT_INTL_SUPPORTED_LOCALES="zh,en"
+8
View File
@@ -0,0 +1,8 @@
{
"extends": ["next/core-web-vitals", "prettier"],
"rules": {
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-explicit-any": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
+4
View File
@@ -71,6 +71,10 @@ temp/
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
# Prisma
/prisma/migrations/*_*/
!.prisma/migrations/migration_lock.toml
# Redis dump # Redis dump
dump.rdb dump.rdb
+8
View File
@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
+18
View File
@@ -0,0 +1,18 @@
const createNextIntlPlugin = require('next-intl/plugin')
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{ hostname: 'localhost' },
{ hostname: '*.anthropic.com' }
]
},
experimental: {
optimizePackageImports: ['lucide-react']
}
}
module.exports = withNextIntl(nextConfig)
+53
View File
@@ -0,0 +1,53 @@
{
"name": "agent-park-v2",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"next": "15.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next-intl": "^4.0.2",
"@prisma/client": "^6.1.0",
"zod": "^3.24.1",
"clsx": "^2.1.1",
"tailwind-merge": "^2.6.0",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.468.0",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.2",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5",
"eslint": "^9",
"eslint-config-next": "15.1.6",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"postcss": "^8",
"autoprefixer": "^10.4.20",
"prisma": "^6.1.0",
"vitest": "^2.1.8",
"@testing-library/react": "^16.1.0",
"@testing-library/jest-dom": "^6.6.3",
"@vitejs/plugin-react": "^4.3.4",
"@playwright/test": "^1.49.1",
"ts-node": "^10.9.2"
},
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}
}
+6752
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default config
+87
View File
@@ -0,0 +1,87 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ================================
// Enums
// ================================
enum ProjectStatus {
ACTIVE
ARCHIVED
}
enum LinkType {
WEBSITE
GITHUB
HUGGINGFACE
PAPER
}
// ================================
// Models
// ================================
model Project {
id String @id @default(cuid())
name String
nameEn String?
slug String @unique
description String
descriptionEn String?
content String? @db.Text
contentEn String? @db.Text
status ProjectStatus @default(ACTIVE)
source String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
tags Tag[]
links ExternalLink[]
// Indexes
@@index([status, createdAt], map: "idx_project_status_createdAt")
@@index([slug], map: "idx_project_slug")
@@map("projects")
}
model Tag {
id String @id @default(cuid())
name String @unique
nameEn String?
slug String @unique
createdAt DateTime @default(now())
// Relations
projects Project[]
// Indexes
@@index([slug], map: "idx_tag_slug")
@@map("tags")
}
model ExternalLink {
id String @id @default(cuid())
type LinkType
url String
title String?
projectId String
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
// Indexes
@@index([projectId], map: "idx_link_projectId")
@@index([type], map: "idx_link_type")
@@map("external_links")
}
+156
View File
@@ -0,0 +1,156 @@
import { PrismaClient, ProjectStatus, LinkType } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
console.log('Starting seed...')
// 创建标签
const tags = await Promise.all([
prisma.tag.upsert({
where: { slug: 'code-assistant' },
update: {},
create: {
name: '代码助手',
nameEn: 'Code Assistant',
slug: 'code-assistant'
}
}),
prisma.tag.upsert({
where: { slug: 'image-generation' },
update: {},
create: {
name: '图像生成',
nameEn: 'Image Generation',
slug: 'image-generation'
}
}),
prisma.tag.upsert({
where: { slug: 'data-analysis' },
update: {},
create: {
name: '数据分析',
nameEn: 'Data Analysis',
slug: 'data-analysis'
}
}),
prisma.tag.upsert({
where: { slug: 'chatbot' },
update: {},
create: {
name: '对话AI',
nameEn: 'Chatbot',
slug: 'chatbot'
}
}),
prisma.tag.upsert({
where: { slug: 'automation' },
update: {},
create: {
name: '自动化',
nameEn: 'Automation',
slug: 'automation'
}
})
])
console.log(`Created ${tags.length} tags`)
// 创建项目
const projects = [
{
name: 'Claude',
nameEn: 'Claude',
slug: 'claude',
description: 'Anthropic 开发的 AI 助手,擅长分析、写作和编程任务。',
descriptionEn: 'AI assistant by Anthropic, excels at analysis, writing, and coding.',
content: 'Claude 是由 Anthropic 开发的下一代 AI 助手。它基于 Constitutional AI 方法训练,强调安全性、诚实性和有用性。',
contentEn: 'Claude is a next-generation AI assistant developed by Anthropic. Trained using Constitutional AI methods.',
tagSlugs: ['code-assistant', 'chatbot'],
links: [
{ type: LinkType.WEBSITE, url: 'https://www.anthropic.com/claude', title: 'Official Website' },
{ type: LinkType.GITHUB, url: 'https://github.com/anthropics', title: 'GitHub' }
]
},
{
name: 'ChatGPT',
nameEn: 'ChatGPT',
slug: 'chatgpt',
description: 'OpenAI 开发的大型语言模型,支持对话、写作、编程等多种任务。',
descriptionEn: 'Large language model by OpenAI, supports conversation, writing, coding, etc.',
tagSlugs: ['chatbot', 'code-assistant'],
links: [
{ type: LinkType.WEBSITE, url: 'https://chat.openai.com', title: 'Official Website' },
{ type: LinkType.GITHUB, url: 'https://github.com/openai', title: 'GitHub' }
]
},
{
name: 'Midjourney',
nameEn: 'Midjourney',
slug: 'midjourney',
description: '强大的 AI 图像生成工具,通过文字描述创建精美图像。',
descriptionEn: 'Powerful AI image generation tool that creates stunning images from text descriptions.',
tagSlugs: ['image-generation'],
links: [
{ type: LinkType.WEBSITE, url: 'https://www.midjourney.com', title: 'Official Website' }
]
},
{
name: 'Stable Diffusion',
nameEn: 'Stable Diffusion',
slug: 'stable-diffusion',
description: '开源的图像生成模型,支持本地部署和自定义训练。',
descriptionEn: 'Open-source image generation model, supports local deployment and custom training.',
tagSlugs: ['image-generation'],
links: [
{ type: LinkType.WEBSITE, url: 'https://stability.ai', title: 'Official Website' },
{ type: LinkType.GITHUB, url: 'https://github.com/Stability-AI', title: 'GitHub' }
]
},
{
name: 'n8n',
nameEn: 'n8n',
slug: 'n8n',
description: '开源的工作流自动化工具,通过可视化界面连接各种服务和 API。',
descriptionEn: 'Open-source workflow automation tool that connects various services and APIs via visual interface.',
tagSlugs: ['automation'],
links: [
{ type: LinkType.WEBSITE, url: 'https://n8n.io', title: 'Official Website' },
{ type: LinkType.GITHUB, url: 'https://github.com/n8n-io/n8n', title: 'GitHub' }
]
}
]
for (const projectData of projects) {
const { tagSlugs, links, ...rest } = projectData
const projectTags = tags.filter(t => tagSlugs.includes(t.slug))
await prisma.project.upsert({
where: { slug: rest.slug },
update: {},
create: {
...rest,
status: ProjectStatus.ACTIVE,
source: 'manual',
tags: {
connect: projectTags.map(t => ({ id: t.id }))
},
links: {
create: links
}
}
})
}
console.log(`Created ${projects.length} projects`)
console.log('Seed completed successfully!')
}
main()
.catch((e) => {
console.error('Error seeding database:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+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>
)
}
@@ -0,0 +1,43 @@
import { ExternalLink } from '@prisma/client'
interface ExternalLinkCardProps {
links: ExternalLink[]
}
const linkTypeNames = {
WEBSITE: '官网',
GITHUB: 'GitHub',
HUGGINGFACE: 'HuggingFace',
PAPER: '论文',
}
export function ExternalLinkCard({ links }: ExternalLinkCardProps) {
if (links.length === 0) {
return null
}
return (
<div className="border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="space-y-3">
{links.map((link) => (
<a
key={link.id}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 border rounded-lg hover:bg-secondary transition-colors"
>
<div>
<div className="font-medium">
{link.title || linkTypeNames[link.type]}
</div>
<div className="text-sm text-muted-foreground">{link.url}</div>
</div>
<span className="text-primary"></span>
</a>
))}
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
import Link from 'next/link'
interface ProjectCardProps {
project: {
id: string
name: string
nameEn?: string | null
slug: string
description: string
descriptionEn?: string | null
tags: Array<{
id: string
name: string
nameEn?: string | null
slug: string
}>
}
locale: string
}
export function ProjectCard({ project, locale }: ProjectCardProps) {
return (
<div className="border rounded-lg p-6 hover:shadow-lg transition-shadow">
<h3 className="text-xl font-semibold mb-2">{project.name}</h3>
<p className="text-muted-foreground mb-4 line-clamp-2">{project.description}</p>
<div className="flex flex-wrap gap-2 mb-4">
{project.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2 py-1 rounded text-xs bg-secondary text-secondary-foreground"
>
{tag.name}
</span>
))}
</div>
<Link
href={`/${locale}/projects/${project.slug}`}
className="text-primary hover:underline text-sm font-medium"
>
</Link>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { ProjectCard } from './ProjectCard'
interface ProjectListProps {
projects: Array<{
id: string
name: string
nameEn?: string | null
slug: string
description: string
descriptionEn?: string | null
tags: Array<{
id: string
name: string
nameEn?: string | null
slug: string
}>
}>
locale: string
}
export function ProjectList({ projects, locale }: ProjectListProps) {
if (projects.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground"></p>
</div>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} locale={locale} />
))}
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import Link from 'next/link'
interface TagCloudProps {
tags: Array<{
id: string
name: string
nameEn?: string | null
slug: string
_count?: {
projects: number
}
}>
locale: string
}
export function TagCloud({ tags, locale }: TagCloudProps) {
return (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Link
key={tag.id}
href={`/${locale}/projects?tag=${tag.slug}`}
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors"
>
{tag.name}
{tag._count && tag._count.projects > 0 && (
<span className="ml-2 text-xs text-muted-foreground">
{tag._count.projects}
</span>
)}
</Link>
))}
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
interface SearchBarProps {
locale: string
}
export function SearchBar({ locale }: SearchBarProps) {
const router = useRouter()
const searchParams = useSearchParams()
const [query, setQuery] = useState(searchParams.get('search') || '')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const params = new URLSearchParams()
if (query) params.set('search', query)
router.push(`/${locale}/projects?${params.toString()}`)
}
return (
<form onSubmit={handleSubmit} className="w-full max-w-md mx-auto mb-8">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索 AI 项目..."
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-ring"
/>
</form>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { prisma } from '@/lib/prisma'
export async function getProjects(options?: {
search?: string
tag?: string
status?: 'ACTIVE' | 'ARCHIVED'
page?: number
limit?: number
}) {
const {
search,
tag,
status = 'ACTIVE',
page = 1,
limit = 20,
} = options || {}
const where: any = {
status,
}
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ nameEn: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ descriptionEn: { contains: search, mode: 'insensitive' } },
]
}
if (tag) {
where.tags = {
some: {
slug: tag,
},
}
}
const [projects, total] = await Promise.all([
prisma.project.findMany({
where,
include: {
tags: true,
links: true,
},
orderBy: {
createdAt: 'desc',
},
skip: (page - 1) * limit,
take: limit,
}),
prisma.project.count({ where }),
])
return {
projects,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
}
}
export async function getProjectBySlug(slug: string) {
return prisma.project.findUnique({
where: { slug },
include: {
tags: true,
links: true,
},
})
}
export async function getAllTags() {
return prisma.tag.findMany({
include: {
_count: {
select: { projects: true },
},
},
orderBy: {
name: 'asc',
},
})
}
export async function getTagsWithProjectCounts() {
const tags = await prisma.tag.findMany({
include: {
_count: {
select: { projects: true },
},
},
orderBy: {
name: 'asc',
},
})
return tags.filter(tag => tag._count.projects > 0)
}
+11
View File
@@ -0,0 +1,11 @@
import { useSearchParams } from 'next/navigation'
export function useSearch() {
const searchParams = useSearchParams()
return {
search: searchParams.get('search') || '',
tag: searchParams.get('tag') || '',
page: Number(searchParams.get('page')) || 1,
}
}
+16
View File
@@ -0,0 +1,16 @@
import { getRequestConfig } from 'next-intl/server'
export default getRequestConfig(async ({ requestLocale }) => {
// This typically corresponds to the `[locale]` segment
let locale = await requestLocale
// Ensure that a valid locale is used
if (!locale || !['zh', 'en'].includes(locale)) {
locale = 'zh'
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default
}
})
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+77
View File
@@ -0,0 +1,77 @@
import { z } from 'zod'
// ================================
// Enums
// ================================
export const ProjectStatusEnum = z.enum(['ACTIVE', 'ARCHIVED'])
export const LinkTypeEnum = z.enum(['WEBSITE', 'GITHUB', 'HUGGINGFACE', 'PAPER'])
// ================================
// Base Schemas
// ================================
export const ExternalLinkSchema = z.object({
type: LinkTypeEnum,
url: z.string().url('Invalid URL format'),
title: z.string().max(200).optional()
})
export const TagSchema = z.object({
name: z.string().min(1).max(50),
nameEn: z.string().max(50).optional()
})
// ================================
// Project Schemas
// ================================
export const ProjectBaseSchema = z.object({
name: z.string().min(1).max(200),
nameEn: z.string().max(200).optional(),
description: z.string().min(10).max(500),
descriptionEn: z.string().max(500).optional(),
content: z.string().max(10000).optional(),
contentEn: z.string().max(10000).optional(),
status: ProjectStatusEnum.default('ACTIVE'),
source: z.string().max(100).optional()
})
export const ProjectInputSchema = ProjectBaseSchema.extend({
tags: z.array(TagSchema).min(1, 'At least one tag is required').max(10),
links: z.array(ExternalLinkSchema).min(1, 'At least one link is required').max(10)
})
// ================================
// Webhook Schemas
// ================================
export const WebhookAuthSchema = z.object({
apiKey: z.string().min(32, 'Invalid API key format')
})
export const WebhookPayloadSchema = WebhookAuthSchema.extend({
projects: z.array(ProjectInputSchema).min(1).max(100)
})
// ================================
// Query Schemas
// ================================
export const ProjectQuerySchema = z.object({
search: z.string().max(100).optional(),
tags: z.array(z.string()).optional(),
status: ProjectStatusEnum.optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20)
})
// ================================
// Types
// ================================
export type ExternalLink = z.infer<typeof ExternalLinkSchema>
export type Tag = z.infer<typeof TagSchema>
export type ProjectInput = z.infer<typeof ProjectInputSchema>
export type WebhookPayload = z.infer<typeof WebhookPayloadSchema>
export type ProjectQuery = z.infer<typeof ProjectQuerySchema>
+31
View File
@@ -0,0 +1,31 @@
{
"common": {
"search": "Search",
"loading": "Loading...",
"noResults": "No results found",
"viewMore": "View More",
"backToHome": "Back to Home"
},
"home": {
"title": "AI Project Navigator",
"subtitle": "Discover and explore AI projects",
"featuredProjects": "Featured Projects",
"browseByTag": "Browse by Tag",
"searchPlaceholder": "Search AI projects..."
},
"project": {
"details": "Project Details",
"externalLinks": "External Links",
"viewProject": "View Project",
"tags": "Tags",
"website": "Website",
"github": "GitHub",
"huggingface": "HuggingFace",
"paper": "Paper"
},
"navigation": {
"home": "Home",
"projects": "Projects",
"about": "About"
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"common": {
"search": "搜索",
"loading": "加载中...",
"noResults": "未找到结果",
"viewMore": "查看更多",
"backToHome": "返回首页"
},
"home": {
"title": "AI 项目导航",
"subtitle": "发现和探索全网优质 AI 项目",
"featuredProjects": "精选项目",
"browseByTag": "按标签浏览",
"searchPlaceholder": "搜索 AI 项目..."
},
"project": {
"details": "项目详情",
"externalLinks": "外部链接",
"viewProject": "查看项目",
"tags": "标签",
"website": "官网",
"github": "GitHub",
"huggingface": "HuggingFace",
"paper": "论文"
},
"navigation": {
"home": "首页",
"projects": "项目列表",
"about": "关于"
}
}
+11
View File
@@ -0,0 +1,11 @@
import createMiddleware from 'next-intl/middleware'
export default createMiddleware({
locales: ['zh', 'en'],
defaultLocale: 'zh',
localePrefix: 'always'
})
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
}
+56
View File
@@ -0,0 +1,56 @@
import type { Config } from "tailwindcss"
const config: Config = {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [require("tailwindcss-animate")],
}
export default config
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}