Merge branch 'feature/ai-timeline'
This commit is contained in:
@@ -81,6 +81,12 @@ export default async function LocaleLayout({
|
||||
>
|
||||
{tNav('keywordCloud')}
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href={`/${locale}/timeline`}
|
||||
>
|
||||
{tNav('timeline')}
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href="#"
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { getAIEvents } from '@/hooks/useAIEvents';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('timeline');
|
||||
|
||||
return {
|
||||
title: t('metaTitle'),
|
||||
description: t('metaDescription'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TimelinePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('timeline');
|
||||
const tCommon = await getTranslations('common');
|
||||
|
||||
const events = await getAIEvents();
|
||||
|
||||
// 按年份分组
|
||||
const eventsByYear = events.reduce((acc, event) => {
|
||||
const year = new Date(event.eventDate).getFullYear();
|
||||
if (!acc[year]) {
|
||||
acc[year] = [];
|
||||
}
|
||||
acc[year].push(event);
|
||||
return acc;
|
||||
}, {} as Record<number, typeof events>);
|
||||
|
||||
// 按年份降序排序
|
||||
const sortedYears = Object.keys(eventsByYear)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background-light dark:bg-background-dark flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="font-display font-black text-4xl mb-4">
|
||||
{t('emptyData')}
|
||||
</h1>
|
||||
<p className="font-mono text-gray-600">
|
||||
{t('collecting')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background-light dark:bg-background-dark">
|
||||
{/* Grid background */}
|
||||
<div className="fixed inset-0 pointer-events-none z-0 opacity-10 dark:opacity-20 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[size:40px_40px] bg-[linear-gradient(to_right,#e5e5e5_1px,transparent_1px),linear-gradient(to_bottom,#e5e5e5_1px,transparent_1px)] dark:bg-[linear-gradient(to_right,#333_1px,transparent_1px),linear-gradient(to_bottom,#333_1px,transparent_1px)]" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative z-10 pt-32 pb-20 px-4 max-w-[1600px] mx-auto">
|
||||
<header className="text-center mb-20 relative">
|
||||
<div className="inline-block relative">
|
||||
{/* Decorative SVG */}
|
||||
<svg
|
||||
className="absolute -top-6 -left-8 w-[120%] h-[150%] text-primary opacity-80 -z-10 animate-pulse"
|
||||
viewBox="0 0 200 200"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M44.7,-51.2C57.1,-41.5,66.1,-27.6,68.9,-12.8C71.7,2,68.3,17.7,60.4,30.9C52.5,44.1,40.1,54.8,26.4,60.1C12.7,65.4,-2.3,65.3,-17.1,61.1C-31.9,56.9,-46.5,48.6,-56.3,36.4C-66.1,24.2,-71.1,8.1,-67.3,-5.7C-63.5,-19.5,-50.9,-31,-38.7,-40.8C-26.5,-50.6,-14.7,-58.7,0.1,-58.8C14.9,-59,29.8,-51.2,32.3,-60.9"
|
||||
fill="currentColor"
|
||||
transform="translate(100 100)"
|
||||
/>
|
||||
</svg>
|
||||
<h1 className="font-display font-black text-6xl md:text-8xl tracking-tight leading-none text-black dark:text-white drop-shadow-sm">
|
||||
{t('title')}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-6 text-lg md:text-xl font-mono max-w-2xl mx-auto bg-white dark:bg-black border border-black dark:border-white p-2 rotate-1 inline-block shadow-[4px_4px_0px_0px_#000] dark:shadow-[4px_4px_0px_0px_#fff]">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Main Timeline */}
|
||||
<main className="relative px-4 md:px-12 pb-32">
|
||||
<div className="relative w-full">
|
||||
{/* Continuous timeline path */}
|
||||
<div className="absolute left-8 md:left-1/2 top-0 bottom-32 w-1 bg-gradient-to-b from-primary via-secondary to-primary opacity-50 hidden md:block" />
|
||||
|
||||
{/* Timeline dots */}
|
||||
{sortedYears.map((year, yearIndex) => (
|
||||
<div
|
||||
key={`dot-${year}`}
|
||||
className="absolute left-8 md:left-1/2 w-4 h-4 bg-primary border-4 border-black dark:border-white rounded-full -translate-x-1/2 hidden md:block"
|
||||
style={{
|
||||
top: `${20 + yearIndex * 35}rem`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Year sections */}
|
||||
{sortedYears.map((year, yearIndex) => (
|
||||
<section
|
||||
key={year}
|
||||
className={`relative min-h-[500px] mb-32 flex ${
|
||||
yearIndex % 2 === 0 ? 'flex-row' : 'flex-row-reverse'
|
||||
}`}
|
||||
>
|
||||
{/* Year Label */}
|
||||
<div
|
||||
className={`absolute ${yearIndex % 2 === 0 ? 'left-0 md:left-auto md:right-0' : 'left-0 md:left-0 md:right-auto'} -top-8 z-20`}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
yearIndex % 2 === 0 ? 'bg-primary' : 'bg-secondary'
|
||||
} border-4 border-black px-6 py-2 ${
|
||||
yearIndex % 2 === 0 ? '-rotate-2' : 'rotate-2'
|
||||
} shadow-hard`}
|
||||
>
|
||||
<span className="font-display font-black text-3xl md:text-5xl">
|
||||
{year}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events Container */}
|
||||
<div
|
||||
className={`w-full ${
|
||||
yearIndex % 2 === 0 ? 'pl-0 md:pl-16 pr-0 md:pr-32' : 'pl-0 md:pl-32 pr-0 md:pr-16'
|
||||
} pt-20`}
|
||||
>
|
||||
<div className="flex flex-nowrap overflow-x-visible items-center justify-start py-10">
|
||||
{eventsByYear[year]?.map((event, eventIndex) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="stack-card relative w-72 h-96 flex-shrink-0 bg-surface-light dark:bg-surface-dark border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56"
|
||||
style={{
|
||||
zIndex: Math.max(1, 40 - eventIndex * 10),
|
||||
transform: `rotate(${(eventIndex % 7 - 3) * 2}deg)`,
|
||||
}}
|
||||
>
|
||||
{/* Tape decoration */}
|
||||
<div className="tape absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6" />
|
||||
|
||||
{/* Image */}
|
||||
<div className="h-40 bg-primary border-2 border-black dark:border-white mb-4 flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={event.imageUrl}
|
||||
alt={event.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="font-display font-bold text-xl leading-none mb-2 uppercase">
|
||||
{event.title}
|
||||
</h3>
|
||||
<p className="text-xs leading-snug opacity-80 line-clamp-4 mb-4">
|
||||
{event.description}
|
||||
</p>
|
||||
|
||||
{/* Date */}
|
||||
<div className="absolute bottom-4 left-4 text-[10px] font-bold bg-black text-white px-2">
|
||||
{new Date(event.eventDate).toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US')}
|
||||
</div>
|
||||
|
||||
{/* Source Link */}
|
||||
{event.sourceUrl && (
|
||||
<a
|
||||
href={event.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute bottom-4 right-4 text-[10px] font-bold underline"
|
||||
>
|
||||
{locale === 'zh' ? '来源 →' : 'Source →'}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Newsletter section */}
|
||||
<div className="max-w-3xl mx-auto px-4">
|
||||
<div className="bg-primary border-4 border-black p-8 relative shadow-[12px_12px_0px_0px_#000] dark:shadow-[12px_12px_0px_0px_#fff]">
|
||||
<div className="absolute -top-10 -right-6 w-20 h-20 bg-white dark:bg-gray-800 border-4 border-black flex items-center justify-center rounded-full animate-bounce">
|
||||
<span className="material-icons-round text-4xl text-black dark:text-white">
|
||||
mail
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display font-black text-3xl md:text-5xl mb-4 text-black uppercase tracking-tight">
|
||||
{t('joinThePark')}
|
||||
</h2>
|
||||
<p className="font-mono text-black mb-8 text-base font-bold">
|
||||
{t('subscribeDesc')}
|
||||
</p>
|
||||
<form className="flex flex-col md:flex-row gap-4">
|
||||
<input
|
||||
className="flex-1 bg-white border-4 border-black px-6 py-4 font-mono focus:ring-0 focus:border-black focus:shadow-[4px_4px_0px_0px_#000] transition-all placeholder:text-gray-500 text-black text-lg"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
type="email"
|
||||
/>
|
||||
<button
|
||||
className="bg-black text-white px-10 py-4 font-black border-4 border-transparent hover:bg-white hover:text-black hover:border-black transition-all hover:shadow-[6px_6px_0px_0px_#000] uppercase text-lg"
|
||||
type="button"
|
||||
>
|
||||
{t('subscribe')}
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<input
|
||||
className="w-6 h-6 border-4 border-black text-black focus:ring-0 rounded-none bg-white checked:bg-black"
|
||||
id="check"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label className="text-sm font-black text-black uppercase" htmlFor="check">
|
||||
{t('agreeToBeCool')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back to top button */}
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<div className="bg-white dark:bg-black border-4 border-black dark:border-white p-3 shadow-hard dark:shadow-hard-dark cursor-pointer hover:-translate-y-2 transition-transform">
|
||||
<span className="material-icons-round text-3xl text-black dark:text-white">
|
||||
arrow_upward
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { POST, GET } from './route';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
describe('POST /api/events', () => {
|
||||
const validEvent = {
|
||||
title: 'GPT-4 发布',
|
||||
eventDate: '2023-03-14T00:00:00Z',
|
||||
description: 'OpenAI 发布多模态大语言模型',
|
||||
imageUrl: 'https://example.com/gpt4.jpg',
|
||||
};
|
||||
|
||||
it('should reject without API key', async () => {
|
||||
const request = new NextRequest('http://localhost:3000/api/events', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([validEvent]),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('should reject with invalid API key', async () => {
|
||||
const request = new NextRequest('http://localhost:3000/api/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': 'invalid-key',
|
||||
},
|
||||
body: JSON.stringify([validEvent]),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
// 注意: 以下测试需要设置 WEBHOOK_API_KEY 环境变量
|
||||
// 可以通过 vi.stubEnv 来模拟
|
||||
});
|
||||
|
||||
describe('GET /api/events', () => {
|
||||
it('should return events array', async () => {
|
||||
const request = new NextRequest('http://localhost:3000/api/events');
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json).toHaveProperty('events');
|
||||
expect(Array.isArray(json.events)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by year', async () => {
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/events?year=2024'
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json).toHaveProperty('events');
|
||||
});
|
||||
|
||||
it('should reject invalid year format', async () => {
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/events?year=invalid'
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe('Invalid query parameters');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { AIEventInputSchema, AIEventQuerySchema } from '@/lib/validations';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// 1. API Key 验证
|
||||
const apiKey = request.headers.get('X-API-Key');
|
||||
const expectedKey = process.env.WEBHOOK_API_KEY;
|
||||
|
||||
if (!apiKey || !expectedKey || !crypto.timingSafeEqual(
|
||||
Buffer.from(apiKey),
|
||||
Buffer.from(expectedKey)
|
||||
)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 解析请求体
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid JSON' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 验证数据
|
||||
const validationResult = AIEventInputSchema.array().safeParse(body);
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Validation failed',
|
||||
details: validationResult.error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 创建事件
|
||||
try {
|
||||
const result = await prisma.aIEvent.createMany({
|
||||
data: validationResult.data,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
created: result.count,
|
||||
total: validationResult.data.length,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create AI events:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// 1. 解析查询参数(将 null 转换为 undefined)
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryParams = {
|
||||
year: searchParams.get('year') || undefined,
|
||||
limit: searchParams.get('limit') || undefined,
|
||||
offset: searchParams.get('offset') || undefined,
|
||||
};
|
||||
|
||||
// 2. 验证查询参数
|
||||
const validationResult = AIEventQuerySchema.safeParse(queryParams);
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid query parameters',
|
||||
details: validationResult.error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 获取事件
|
||||
try {
|
||||
const events = await prisma.aIEvent.findMany({
|
||||
where: validationResult.data.year
|
||||
? {
|
||||
eventDate: {
|
||||
gte: new Date(`${validationResult.data.year}-01-01T00:00:00Z`),
|
||||
lte: new Date(`${validationResult.data.year}-12-31T23:59:59Z`),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { eventDate: 'desc' },
|
||||
take: validationResult.data.limit || 100,
|
||||
skip: validationResult.data.offset || 0,
|
||||
});
|
||||
|
||||
return NextResponse.json({ events });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI events:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,4 +103,24 @@
|
||||
@apply border border-black dark:border-gray-500 px-2 py-1 text-xs
|
||||
font-display font-bold uppercase bg-white dark:bg-gray-800;
|
||||
}
|
||||
|
||||
/* Timeline stack card animations */
|
||||
.stack-card {
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.stack-card:hover {
|
||||
z-index: 50 !important;
|
||||
transform: translateY(-20px) scale(1.05) rotate(0deg) !important;
|
||||
margin-right: 20px !important;
|
||||
margin-left: 20px !important;
|
||||
}
|
||||
|
||||
/* Tape decoration styling */
|
||||
.tape {
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(2px);
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { AIEvent } from '@prisma/client';
|
||||
|
||||
interface EventCardProps {
|
||||
event: AIEvent;
|
||||
index: number;
|
||||
baseIndex?: number;
|
||||
}
|
||||
|
||||
export function EventCard({ event, index, baseIndex = 30 }: EventCardProps) {
|
||||
// Generate consistent rotation based on event ID
|
||||
const rotation = ((parseInt(event.id.slice(-4), 36) % 14) - 7); // -7 to 7 degrees
|
||||
const zIndex = Math.max(1, baseIndex - index * 10);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="stack-card relative w-72 h-96 flex-shrink-0 bg-surface-light dark:bg-surface-dark border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56"
|
||||
style={{
|
||||
zIndex,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
}}
|
||||
>
|
||||
{/* Tape decoration with transparency and blur */}
|
||||
<div
|
||||
className="tape absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6 rotate-1"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
border: '1px solid rgba(255,255,255,0.6)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Image */}
|
||||
<div className="h-40 bg-primary border-2 border-black dark:border-white mb-4 flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={event.imageUrl}
|
||||
alt={event.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="font-display font-bold text-xl leading-none mb-2 uppercase">
|
||||
{event.title}
|
||||
</h3>
|
||||
<p className="text-xs leading-snug opacity-80 line-clamp-4 mb-4">
|
||||
{event.description}
|
||||
</p>
|
||||
|
||||
{/* Date */}
|
||||
<div className="absolute bottom-4 left-4 text-[10px] font-bold bg-black text-white px-2">
|
||||
{new Date(event.eventDate).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
|
||||
{/* Source Link */}
|
||||
{event.sourceUrl && (
|
||||
<a
|
||||
href={event.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute bottom-4 right-4 text-[10px] font-bold underline"
|
||||
>
|
||||
来源 →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AIEvent } from '@prisma/client';
|
||||
import { EventCard } from './EventCard';
|
||||
|
||||
interface TimelineSectionProps {
|
||||
year: number;
|
||||
events: AIEvent[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function TimelineSection({ year, events, index }: TimelineSectionProps) {
|
||||
const isEven = index % 2 === 0;
|
||||
|
||||
return (
|
||||
<section className={`relative min-h-[500px] mb-32 flex ${isEven ? 'flex-row' : 'flex-row-reverse'}`}>
|
||||
{/* Year Label */}
|
||||
<div className={`absolute ${isEven ? 'left-0' : 'right-0'} -top-8 z-20`}>
|
||||
<div
|
||||
className={`${isEven ? 'bg-primary' : 'bg-secondary'} border-4 border-black px-6 py-2 ${isEven ? '-rotate-2' : 'rotate-2'} shadow-hard`}
|
||||
>
|
||||
<span className="font-display font-black text-3xl md:text-5xl">
|
||||
{year}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events Container */}
|
||||
<div
|
||||
className={`w-full ${isEven ? 'pl-0 md:pl-8 pr-0 md:pr-32' : 'pl-0 md:pl-32 pr-0 md:pr-8'} pt-20`}
|
||||
>
|
||||
<div className="flex flex-nowrap overflow-x-visible items-center justify-start">
|
||||
{events.map((event, eventIndex) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={eventIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function getAIEvents(options?: {
|
||||
year?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const where = options?.year
|
||||
? {
|
||||
eventDate: {
|
||||
gte: new Date(`${options.year}-01-01T00:00:00Z`),
|
||||
lte: new Date(`${options.year}-12-31T23:59:59Z`),
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const events = await prisma.aIEvent.findMany({
|
||||
where,
|
||||
orderBy: { eventDate: 'desc' },
|
||||
take: options?.limit || 100,
|
||||
skip: options?.offset || 0,
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export async function getAIEventBySlug(slug: string) {
|
||||
// 暂不实现,后续如需要详细页面时添加
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getAllAIEventYears() {
|
||||
const events = await prisma.aIEvent.findMany({
|
||||
select: {
|
||||
eventDate: true,
|
||||
},
|
||||
orderBy: { eventDate: 'desc' },
|
||||
});
|
||||
|
||||
const years = new Set<number>();
|
||||
events.forEach(event => {
|
||||
years.add(new Date(event.eventDate).getFullYear());
|
||||
});
|
||||
|
||||
return Array.from(years).sort((a, b) => b - a);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AIEventInputSchema } from './validations';
|
||||
|
||||
describe('AIEventInputSchema', () => {
|
||||
const validEvent = {
|
||||
title: 'GPT-4 发布',
|
||||
eventDate: '2023-03-14T00:00:00Z',
|
||||
description: 'OpenAI 发布多模态大语言模型',
|
||||
imageUrl: 'https://example.com/gpt4.jpg',
|
||||
};
|
||||
|
||||
it('should validate valid event', () => {
|
||||
expect(() => AIEventInputSchema.parse(validEvent)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept event with optional English fields', () => {
|
||||
const eventWithEn = {
|
||||
...validEvent,
|
||||
titleEn: 'GPT-4 Release',
|
||||
descriptionEn: 'OpenAI launches multimodal LLM',
|
||||
sourceUrl: 'https://openai.com/blog/gpt-4',
|
||||
};
|
||||
expect(() => AIEventInputSchema.parse(eventWithEn)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject empty title', () => {
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, title: '' }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject title exceeding 200 characters', () => {
|
||||
const longTitle = 'A'.repeat(201);
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, title: longTitle }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject description shorter than 10 characters', () => {
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, description: '太短' }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject description exceeding 500 characters', () => {
|
||||
const longDesc = 'A'.repeat(501);
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, description: longDesc }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid eventDate format', () => {
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, eventDate: '2023-03-14' }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid imageUrl', () => {
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, imageUrl: 'not-a-url' }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid sourceUrl format', () => {
|
||||
expect(() => AIEventInputSchema.parse({
|
||||
...validEvent,
|
||||
sourceUrl: 'not-a-url'
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -203,6 +203,26 @@ export const KeywordCloudResponseSchema = z.object({
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
// ================================
|
||||
// AI Timeline Schemas
|
||||
// ================================
|
||||
|
||||
export const AIEventInputSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
titleEn: z.string().max(200).optional(),
|
||||
eventDate: z.string().datetime(),
|
||||
description: z.string().min(10).max(500),
|
||||
descriptionEn: z.string().max(500).optional(),
|
||||
imageUrl: z.string().url(),
|
||||
sourceUrl: z.string().url().optional(),
|
||||
})
|
||||
|
||||
export const AIEventQuerySchema = z.object({
|
||||
year: z.string().regex(/^\d{4}$/).optional(),
|
||||
limit: z.string().regex(/^\d+$/).transform(Number).optional(),
|
||||
offset: z.string().regex(/^\d+$/).transform(Number).optional(),
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Types
|
||||
// ================================
|
||||
@@ -211,4 +231,6 @@ export type KeywordInput = z.infer<typeof KeywordInputSchema>
|
||||
export type BatchKeywordsRequest = z.infer<typeof BatchKeywordsRequestSchema>
|
||||
export type Quarter = z.infer<typeof QuarterSchema>
|
||||
export type VisualStyleRule = z.infer<typeof VisualStyleRuleSchema>
|
||||
export type AIEventInput = z.infer<typeof AIEventInputSchema>
|
||||
export type AIEventQuery = z.infer<typeof AIEventQuerySchema>
|
||||
export type KeywordCloudResponse = z.infer<typeof KeywordCloudResponseSchema>
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"home": "Home",
|
||||
"projects": "Projects",
|
||||
"keywordCloud": "AI Word Cloud",
|
||||
"timeline": "AI Timeline",
|
||||
"blog": "Blog",
|
||||
"about": "About",
|
||||
"submitProject": "SUBMIT PROJECT"
|
||||
@@ -107,5 +108,19 @@
|
||||
"loadFailed": "Failed to load",
|
||||
"retry": "Retry",
|
||||
"hotKeyword": "“{word}” is the hottest keyword this quarter!"
|
||||
},
|
||||
"timeline": {
|
||||
"metaTitle": "AI Timeline - Agent Park",
|
||||
"metaDescription": "Explore the evolution of AI large language models from 2017 Transformer to today",
|
||||
"title": "THE STORY OF A.I.",
|
||||
"subtitle": "Pinned. Stacked. Zigzagged.",
|
||||
"emptyData": "No data available",
|
||||
"collecting": "Timeline data is being collected...",
|
||||
"joinThePark": "Join the Park",
|
||||
"subscribeDesc": "Subscribe to the Agent Park weekly zine. No spam, just ducks and data.",
|
||||
"emailPlaceholder": "Your email here...",
|
||||
"subscribe": "SUBSCRIBE",
|
||||
"agreeToBeCool": "I agree to be cool.",
|
||||
"earlier": "Earlier"
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -75,6 +75,7 @@
|
||||
"home": "首页",
|
||||
"projects": "项目列表",
|
||||
"keywordCloud": "AI 词云",
|
||||
"timeline": "AI 时间轴",
|
||||
"blog": "博客",
|
||||
"about": "关于",
|
||||
"submitProject": "提交项目"
|
||||
@@ -105,7 +106,20 @@
|
||||
"subtitle": "从 “大型语言模型” 到 “Agent 工作流”。探索 AI 话语的演变历程。",
|
||||
"loading": "加载中...",
|
||||
"loadFailed": "加载失败",
|
||||
"retry": "重试",
|
||||
"hotKeyword": "“{word}” 是本季度最热门词汇!"
|
||||
"retry": "重试"
|
||||
},
|
||||
"timeline": {
|
||||
"metaTitle": "AI 发展时间轴 - Agent Park",
|
||||
"metaDescription": "探索人工智能大语言模型的发展历程,从 2017 年 Transformer 到今天",
|
||||
"title": "AI 的故事",
|
||||
"subtitle": "钉住。堆叠。之字形。",
|
||||
"emptyData": "暂无数据",
|
||||
"collecting": "时间轴数据正在收集中...",
|
||||
"joinThePark": "加入 Agent Park",
|
||||
"subscribeDesc": "订阅 Agent Park 周刊。没有垃圾邮件,只有干货。",
|
||||
"emailPlaceholder": "您的电子邮箱...",
|
||||
"subscribe": "订阅",
|
||||
"agreeToBeCool": "我同意保持礼貌。",
|
||||
"earlier": "更早"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user