refactor: 移除 AI 词云、AI 时间轴和博客模块

暂停开发以下功能,将设计文档移至未完成计划文件夹:

- AI 词云 (Keyword Cloud): 前端、API、数据库模型、n8n 工作流
- AI 时间轴 (AI Timeline): 前端、API、数据库模型
- 博客 (Blog): 导航链接占位

变更内容:
- 删除词云和时间轴的前端页面及组件
- 删除 /api/keyword-cloud/* 和 /api/events/* API 端点
- 从 prisma/schema.prisma 移除 Keyword, Quarter, VisualStyleRule, KeywordCloudErrorLog, AIEvent 模型
- 从 validations.ts 移除相关 Zod Schema
- 从国际化消息中移除 keywordCloud/timeline 命名空间
- 从导航菜单移除词云、时间轴、博客链接
- 更新 CLAUDE.md 移除词云系统文档

设计文档已移至 .omc/plans/postponed-features/ 供后续恢复开发参考
This commit is contained in:
2026-02-21 12:35:54 +08:00
parent c2eabfea97
commit 79c25eb1b3
42 changed files with 708 additions and 3124 deletions
@@ -1,119 +0,0 @@
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
interface VisualConfig {
color: 'primary' | 'secondary' | 'accent' | 'gray';
size: string;
border: string;
rotation?: string;
}
export interface KeywordData {
id: number;
word: string;
trendScore: number;
description: string;
descriptionEn?: string | null;
detailPoints: string[];
detailPointsEn?: string[] | null;
visualConfig: VisualConfig;
}
interface CloudWordProps {
data: KeywordData;
locale: string;
}
// 颜色映射
const colorMap: Record<string, string> = {
primary: 'bg-primary',
secondary: 'bg-secondary',
accent: 'bg-accent',
gray: 'bg-gray-100 dark:bg-gray-700',
};
export function CloudWord({ data, locale }: CloudWordProps) {
const { word, visualConfig, description, descriptionEn, detailPoints, detailPointsEn } = data;
const { color, size, border, rotation } = visualConfig;
const colorClass = colorMap[color];
// 根据语言选择内容
const displayDescription = locale === 'en' && descriptionEn ? descriptionEn : description;
const displayPoints = locale === 'en' && detailPointsEn ? detailPointsEn : detailPoints;
return (
<span
className={cn(
'cloud-word',
'inline-block',
'whitespace-nowrap',
'relative',
'transition-all',
'duration-200',
'cursor-pointer',
border,
'border-black',
colorClass,
'px-6',
'py-3',
'rounded-full',
size,
'font-black',
'shadow-hard',
rotation,
'hover:scale-105',
'hover:z-10'
)}
>
{word}
<WordPopover
title={word}
description={displayDescription}
points={displayPoints}
titleColor={color}
/>
</span>
);
}
interface WordPopoverProps {
title: string;
description: string;
points: string[];
titleColor: string;
}
function WordPopover({ title, description, points, titleColor }: WordPopoverProps) {
const titleColorClass = colorMap[titleColor] || 'bg-gray-100';
return (
<div className="absolute bottom-[calc(100%+12px)] left-1/2 -translate-x-1/2 w-48 bg-white dark:bg-gray-800 border-2 border-black shadow-hard-sm opacity-0 pointer-events-none transition-all duration-300 translate-y-2 z-50 text-left group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto">
{/* 标题栏 */}
<div className={cn(
'text-black dark:text-white font-display font-bold p-2 border-b-2 border-black text-sm uppercase',
titleColorClass
)}>
{title}
</div>
{/* 内容区域 */}
<div className="p-3 space-y-2 text-xs font-medium dark:text-gray-100">
<div className="flex gap-2 items-start">
<span></span>
<span>{description}</span>
</div>
{points.map((point, i) => (
<div key={i} className="flex gap-2 items-start">
<span></span>
<span>{point}</span>
</div>
))}
</div>
{/* 箭头 */}
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-black" />
</div>
);
}
@@ -1,115 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { CloudWord, type KeywordData } from './CloudWord';
import { QuarterNavigator } from './QuarterNavigator';
import { ProgressIndicator } from './ProgressIndicator';
import { useKeywordCloud } from '@/hooks/useKeywordCloudClient';
interface KeywordCloudProps {
initialQuarter: string;
locale: string;
texts: {
loading: string;
loadFailed: string;
retry: string;
hotKeyword: string;
};
}
export function KeywordCloud({ initialQuarter, locale, texts }: KeywordCloudProps) {
const [currentQuarter, setCurrentQuarter] = useState(initialQuarter);
const [quarters, setQuarters] = useState<string[]>([]);
const { data, isLoading, error } = useKeywordCloud(currentQuarter);
// 加载季度列表
useEffect(() => {
async function loadQuarters() {
try {
const response = await fetch('/api/keyword-cloud/quarters');
const json = await response.json();
if (json.success) {
const quarterStrings = json.quarters.map((q: any) => q.quarter);
setQuarters(quarterStrings);
}
} catch (err) {
console.error('Failed to load quarters:', err);
}
}
loadQuarters();
}, []);
const handleNavigate = (quarter: string) => {
setCurrentQuarter(quarter);
// 更新 URL 而不刷新页面
const url = new URL(window.location.href);
url.searchParams.set('quarter', quarter);
window.history.pushState({}, '', url.toString());
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-black border-t-primary mb-4" />
<p className="font-display font-bold text-lg">{texts.loading}</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="font-display font-bold text-lg text-red-500 mb-4">
{texts.loadFailed}
</p>
<button
onClick={() => window.location.reload()}
className="bg-primary text-black border-2 border-black px-4 py-2 font-bold hover:bg-yellow-500 transition-colors"
>
{texts.retry}
</button>
</div>
</div>
);
}
const firstKeyword = data.keywords[0];
return (
<div className="bg-white/50 dark:bg-black/20 backdrop-blur-sm border-4 border-black p-8 md:p-12 shadow-hard relative overflow-visible">
{/* 进度条 */}
<ProgressIndicator
currentQuarter={currentQuarter}
totalQuarters={quarters.length}
quarters={quarters}
/>
{/* 季度导航 */}
<QuarterNavigator
current={currentQuarter}
quarters={quarters}
onNavigate={handleNavigate}
/>
{/* 词云区域 */}
<div className="relative py-12 px-4">
<div className="word-cluster flex flex-wrap gap-3 items-center justify-center max-w-full">
{data.keywords.map((keyword) => (
<CloudWord key={keyword.id} data={keyword} locale={locale} />
))}
</div>
</div>
{/* 装饰元素 */}
{data.keywords.length > 0 && firstKeyword && (
<div className="absolute -top-16 -right-8 md:right-0 bg-black text-white p-5 rounded-xl text-sm md:text-base font-display w-64 shadow-hard rotate-6 hidden lg:block">
{texts.hotKeyword.replace('{word}', firstKeyword.word)}
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-black transform rotate-45" />
</div>
)}
</div>
);
}
@@ -1,44 +0,0 @@
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
interface ProgressIndicatorProps {
currentQuarter: string;
totalQuarters: number;
quarters: string[];
}
export function ProgressIndicator({ currentQuarter, totalQuarters, quarters }: ProgressIndicatorProps) {
const currentIndex = quarters.indexOf(currentQuarter);
return (
<div className="flex gap-2 mb-12 max-w-md mx-auto">
{quarters.map((quarter, index) => {
const isCompleted = index <= currentIndex;
const isCurrent = index === currentIndex;
const isLast = index === quarters.length - 1;
return (
<div
key={quarter}
className={cn(
'progress-step',
'h-3',
'flex-1',
'border-2',
'border-black',
'transition-colors',
'duration-300',
isCurrent && 'bg-primary',
isCompleted && !isCurrent && 'bg-secondary',
!isCompleted && 'bg-gray-200 dark:bg-gray-700',
isLast && 'border-dashed'
)}
aria-label={`Quarter ${quarter}`}
/>
);
})}
</div>
);
}
@@ -1,84 +0,0 @@
'use client';
import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface QuarterNavigatorProps {
current: string;
quarters: string[];
onNavigate: (quarter: string) => void;
}
export function QuarterNavigator({ current, quarters, onNavigate }: QuarterNavigatorProps) {
const currentIndex = quarters.indexOf(current);
const canGoPrev = currentIndex > 0;
const canGoNext = currentIndex < quarters.length - 1;
const handlePrev = () => {
if (canGoPrev) {
const prevQuarter = quarters[currentIndex - 1];
if (prevQuarter) onNavigate(prevQuarter);
}
};
const handleNext = () => {
if (canGoNext) {
const nextQuarter = quarters[currentIndex + 1];
if (nextQuarter) onNavigate(nextQuarter);
}
};
return (
<div className="flex flex-col md:flex-row items-center justify-between gap-8 mb-12">
{/* 上一个季度按钮 */}
<button
disabled={!canGoPrev}
className={cn(
'nav-button',
'order-2 md:order-1',
'flex items-center justify-center',
'w-12 h-12 md:w-16 md:h-16',
'bg-white dark:bg-gray-800',
'border-4 border-black',
'shadow-hard',
'hover:translate-y-0.5 hover:shadow-none',
'transition-all',
!canGoPrev && 'opacity-50 cursor-not-allowed'
)}
onClick={handlePrev}
aria-label="Previous quarter"
>
<ChevronLeft className="w-8 h-8 md:w-10 md:h-10" />
</button>
{/* 当前季度显示 */}
<div className="text-center order-1 md:order-2 flex-1">
<div className="inline-block bg-primary border-4 border-black px-10 py-4 shadow-hard font-display font-bold text-4xl md:text-5xl mb-4 rotate-1">
{current}
</div>
</div>
{/* 下一个季度按钮 */}
<button
disabled={!canGoNext}
className={cn(
'nav-button',
'order-3',
'flex items-center justify-center',
'w-12 h-12 md:w-16 md:h-16',
'bg-white dark:bg-gray-800',
'border-4 border-black',
'shadow-hard',
'hover:translate-y-0.5 hover:shadow-none',
'transition-all',
!canGoNext && 'opacity-50 cursor-not-allowed'
)}
onClick={handleNext}
aria-label="Next quarter"
>
<ChevronRight className="w-8 h-8 md:w-10 md:h-10" />
</button>
</div>
);
}
-28
View File
@@ -1,28 +0,0 @@
import { KeywordCloud } from "./components/KeywordCloud";
import { getTranslations } from "next-intl/server";
interface PageProps {
params: Promise<{ locale: string }>;
searchParams: Promise<{ quarter?: string }>;
}
export async function generateMetadata({ params }: PageProps) {
const resolvedParams = (await params) ?? {};
const rawLocale = resolvedParams.locale;
const locale = (Array.isArray(rawLocale) ? rawLocale[0] : rawLocale) ?? "zh";
const t = await getTranslations("keywordCloud");
return {
title: t("metaTitle"),
description: t("metaDescription"),
};
}
export default async function KeywordCloudPage({ searchParams, params }: PageProps) {
const { locale } = await params;
const resolvedSearchParams = await searchParams;
const t = await getTranslations("keywordCloud");
// 如果 URL 中有 quarter 参数,使用它;否则使用默认季度
const quarter = resolvedSearchParams.quarter || "2024-Q1";
}
+1 -19
View File
@@ -75,24 +75,6 @@ export default async function LocaleLayout({
>
{tNav('projects')}
</Link>
<Link
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
href={`/${locale}/keyword-cloud`}
>
{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="#"
>
{tNav('blog')}
</Link>
<Link
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
href="#"
@@ -187,7 +169,7 @@ export default async function LocaleLayout({
<div>
<h4 className="font-display font-bold text-lg mb-4">{t('resources')}</h4>
<ul className="space-y-2 text-sm font-sans text-gray-600 dark:text-gray-400">
<li><Link className="hover:text-black dark:hover:text-white" href="#">{tNav('blog')}</Link></li>
<li><Link className="hover:text-black dark:hover:text-white" href="#">Newsletter</Link></li>
<li><Link className="hover:text-black dark:hover:text-white" href="#">Newsletter</Link></li>
<li><Link className="hover:text-black dark:hover:text-white" href="#">Documentation</Link></li>
</ul>
-248
View File
@@ -1,248 +0,0 @@
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>
);
}
-78
View File
@@ -1,78 +0,0 @@
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');
});
});
-116
View File
@@ -1,116 +0,0 @@
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;
const providedBuf = Buffer.from(apiKey || '');
const expectedBuf = Buffer.from(expectedKey || '');
if (
!apiKey ||
!expectedKey ||
providedBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(providedBuf, expectedBuf)
) {
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 }
);
}
}
-53
View File
@@ -1,53 +0,0 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
/**
* GET /api/keyword-cloud/health
* 健康检查端点(用于监控)
*/
export async function GET() {
try {
// 检查数据库连接
await prisma.$queryRaw`SELECT 1`;
// 统计数据
const quarterCount = await prisma.quarter.count();
const keywordCount = await prisma.keyword.count();
const ruleCount = await prisma.visualStyleRule.count({
where: { enabled: true },
});
const errorCount = await prisma.keywordCloudErrorLog.count({
where: {
createdAt: {
gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // 最近24小时
},
},
});
return NextResponse.json({
success: true,
status: 'healthy',
stats: {
quarters: quarterCount,
keywords: keywordCount,
activeRules: ruleCount,
recentErrors: errorCount,
},
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('Health check failed:', error);
return NextResponse.json(
{
success: false,
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
},
{ status: 503 }
);
}
}
@@ -1,56 +0,0 @@
import { NextResponse } from "next/server";
import { getKeywordsByQuarter } from "@/hooks/useKeywordCloud";
export const dynamic = "force-dynamic";
/**
* GET /api/keyword-cloud/keywords/[quarter]
* 获取指定季度的关键词
*/
export async function GET(request: Request, { params }: { params: Promise<{ quarter: string }> }) {
try {
const { quarter } = await params;
// 验证 quarter 格式
if (!/^\d{4}-Q[1-4]$/.test(quarter)) {
return NextResponse.json(
{
success: false,
error: "Invalid quarter format. Expected: YYYY-QN",
},
{ status: 400 }
);
}
const data = await getKeywordsByQuarter(quarter);
if (!data) {
return NextResponse.json(
{
success: false,
error: `Quarter ${quarter} not found`,
},
{ status: 404 }
);
}
return NextResponse.json({
success: true,
quarter: data.quarter,
title: data.title,
titleEn: data.titleEn,
subtitle: data.subtitle,
subtitleEn: data.subtitleEn,
keywords: data.keywords,
});
} catch (error) {
console.error("Error fetching keywords:", error);
return NextResponse.json(
{
success: false,
error: "Failed to fetch keywords",
},
{ status: 500 }
);
}
}
-131
View File
@@ -1,131 +0,0 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { BatchKeywordsRequestSchema } from '@/lib/validations';
import { upsertQuarter, createKeywords, logKeywordCloudError } from '@/hooks/useKeywordCloud';
import crypto from 'crypto';
export const dynamic = 'force-dynamic';
/**
* POST /api/keyword-cloud/keywords
* 批量写入关键词(n8n 工作流使用)
*/
export async function POST(request: Request) {
try {
// 1. 验证 API Key
const body = await request.json();
const { apiKey, ...requestData } = body;
if (!apiKey) {
return NextResponse.json(
{
success: false,
error: 'Missing API key',
},
{ status: 401 }
);
}
const expectedApiKey = process.env.WEBHOOK_API_KEY;
if (!expectedApiKey) {
console.error('WEBHOOK_API_KEY not configured');
return NextResponse.json(
{
success: false,
error: 'Server configuration error',
},
{ status: 500 }
);
}
// 使用 timing-safe 比较防止时序攻击
try {
const apiKeyBuffer = Buffer.from(apiKey, 'utf-8');
const expectedBuffer = Buffer.from(expectedApiKey, 'utf-8');
if (apiKeyBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(apiKeyBuffer, expectedBuffer)) {
return NextResponse.json(
{
success: false,
error: 'Invalid API key',
},
{ status: 401 }
);
}
} catch (error) {
return NextResponse.json(
{
success: false,
error: 'Authentication failed',
},
{ status: 401 }
);
}
// 2. 验证请求数据
const validationResult = BatchKeywordsRequestSchema.safeParse(requestData);
if (!validationResult.success) {
return NextResponse.json(
{
success: false,
error: 'Validation failed',
details: validationResult.error.errors,
},
{ status: 400 }
);
}
const { quarter, keywords } = validationResult.data;
// 3. 创建或更新季度记录
const quarterData = await upsertQuarter(quarter, {
title: `${quarter.replace('-', '年')}季度`,
titleEn: quarter.replace('-', ' '),
});
// 4. 批量创建关键词
const result = await createKeywords(quarterData.id, keywords);
// 5. 记录错误
for (const error of result.errors) {
await logKeywordCloudError({
quarter,
keyword: error.word,
errorType: 'DB_ERROR',
errorMessage: error.error,
});
}
// 6. 返回结果
return NextResponse.json({
success: true,
created: result.created,
failed: result.failed,
errors: result.errors,
});
} catch (error) {
console.error('Error creating keywords:', error);
// 记录未捕获的错误
try {
await logKeywordCloudError({
quarter: 'unknown',
errorType: 'API_ERROR',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
rawData: { error },
});
} catch (logError) {
console.error('Failed to log error:', logError);
}
return NextResponse.json(
{
success: false,
error: 'Failed to create keywords',
},
{ status: 500 }
);
}
}
@@ -1,47 +0,0 @@
import { NextResponse } from 'next/server';
import { getAllQuarters } from '@/hooks/useKeywordCloud';
export const dynamic = 'force-dynamic';
/**
* GET /api/keyword-cloud/quarters
* 获取季度列表
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const isActive = searchParams.get('isActive');
const quarters = await getAllQuarters(
isActive !== null ? { isActive: isActive === 'true' } : undefined
);
// 为每个季度添加关键词计数
const { prisma } = await import('@/lib/prisma');
const quartersWithCount = await Promise.all(
quarters.map(async (q) => {
const count = await prisma.keyword.count({
where: { quarterId: q.id },
});
return {
...q,
keywordCount: count,
};
})
);
return NextResponse.json({
success: true,
quarters: quartersWithCount,
});
} catch (error) {
console.error('Error fetching quarters:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch quarters',
},
{ status: 500 }
);
}
}
-49
View File
@@ -1,49 +0,0 @@
import { NextResponse } from 'next/server';
import { getVisualStyleRules } from '@/hooks/useKeywordCloud';
export const dynamic = 'force-dynamic';
/**
* GET /api/keyword-cloud/rules
* 获取视觉样式规则配置
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const enabled = searchParams.get('enabled');
const rules = await getVisualStyleRules(
enabled !== null ? { enabled: enabled === 'true' } : undefined
);
// 转换规则格式以匹配前端期望
const formattedRules = rules.map(rule => ({
id: rule.id,
name: rule.name,
minScore: rule.minScore,
maxScore: rule.maxScore,
visualConfig: {
color: rule.color,
size: rule.size,
border: rule.border,
rotation: rule.rotation,
},
priority: rule.priority,
enabled: rule.enabled,
}));
return NextResponse.json({
success: true,
rules: formattedRules,
});
} catch (error) {
console.error('Error fetching visual style rules:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch rules',
},
{ status: 500 }
);
}
}
-68
View File
@@ -1,68 +0,0 @@
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>
);
}
@@ -1,42 +0,0 @@
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>
);
}
-46
View File
@@ -1,46 +0,0 @@
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);
}
-191
View File
@@ -1,191 +0,0 @@
import { prisma } from '@/lib/prisma';
import type { Quarter, Keyword, VisualStyleRule } from '@prisma/client';
// 类型定义
export type KeywordWithVisual = Keyword & {
visualConfig: {
color: string;
size: string;
border: string;
rotation?: string;
};
};
export type QuarterWithKeywords = Quarter & {
keywords: KeywordWithVisual[];
_count?: { keywords: number };
};
export type QuarterWithCount = Quarter & {
_count?: { keywords: number };
};
/**
* 获取所有季度列表
*/
export async function getAllQuarters(
options?: { isActive?: boolean }
): Promise<Quarter[]> {
const where = options?.isActive !== undefined
? { isActive: options.isActive }
: {};
return prisma.quarter.findMany({
where,
orderBy: { displayOrder: 'asc' },
});
}
/**
* 获取单个季度的详情(包含关键词计数)
*/
export async function getQuarterByQuarter(
quarter: string
): Promise<QuarterWithCount | null> {
const quarterData = await prisma.quarter.findUnique({
where: { quarter },
include: {
_count: {
select: { keywords: true },
},
},
});
return quarterData as QuarterWithCount | null;
}
/**
* 获取指定季度的所有关键词
*/
export async function getKeywordsByQuarter(
quarter: string
): Promise<QuarterWithKeywords | null> {
const quarterData = await prisma.quarter.findUnique({
where: { quarter },
include: {
keywords: {
orderBy: { trendScore: 'desc' },
},
},
});
if (!quarterData) {
return null;
}
// 转换 visualConfig 从 JSON 到对象
const keywords: KeywordWithVisual[] = quarterData.keywords.map(kw => ({
...kw,
visualConfig: (typeof kw.visualConfig === 'string'
? JSON.parse(kw.visualConfig)
: kw.visualConfig) as KeywordWithVisual['visualConfig'],
}));
return {
...quarterData,
keywords,
} as QuarterWithKeywords;
}
/**
* 获取所有启用的视觉规则
*/
export async function getVisualStyleRules(
options?: { enabled?: boolean }
): Promise<VisualStyleRule[]> {
const where = options?.enabled !== undefined
? { enabled: options.enabled }
: {};
return prisma.visualStyleRule.findMany({
where,
orderBy: [
{ priority: 'asc' },
{ minScore: 'desc' },
],
});
}
/**
* 创建或更新季度
*/
export async function upsertQuarter(
quarter: string,
data: {
title: string;
titleEn?: string;
subtitle?: string;
subtitleEn?: string;
displayOrder?: number;
}
): Promise<Quarter> {
return prisma.quarter.upsert({
where: { quarter },
update: data,
create: {
quarter,
...data,
},
});
}
/**
* 批量创建关键词
*/
export async function createKeywords(
quarterId: number,
keywords: Array<{
word: string;
trendScore: number;
description: string;
descriptionEn?: string;
detailPoints: string[];
detailPointsEn?: string[];
visualConfig: Record<string, any>;
}>
): Promise<{ created: number; failed: number; errors: Array<{ word: string; error: string }> }> {
const errors: Array<{ word: string; error: string }> = [];
let created = 0;
for (const kw of keywords) {
try {
await prisma.keyword.create({
data: {
quarterId,
word: kw.word,
trendScore: kw.trendScore,
description: kw.description,
descriptionEn: kw.descriptionEn,
detailPoints: kw.detailPoints as any, // Prisma Json 类型
detailPointsEn: kw.detailPointsEn as any,
visualConfig: kw.visualConfig as any,
},
});
created++;
} catch (error) {
errors.push({
word: kw.word,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return { created, failed: errors.length, errors };
}
/**
* 记录错误日志
*/
export async function logKeywordCloudError(
data: {
quarter: string;
keyword?: string;
errorType: string;
errorMessage: string;
rawData?: any;
}
): Promise<void> {
await prisma.keywordCloudErrorLog.create({
data,
});
}
-52
View File
@@ -1,52 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import type { KeywordData } from '@/app/[locale]/keyword-cloud/components/CloudWord';
interface QuarterData {
quarter: string;
title: string;
titleEn?: string;
subtitle?: string;
subtitleEn?: string;
keywords: KeywordData[];
}
export function useKeywordCloud(quarter: string) {
const [data, setData] = useState<QuarterData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchData() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/keyword-cloud/keywords/${quarter}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if (!json.success) {
throw new Error(json.error || 'Unknown error');
}
setData(json);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setIsLoading(false);
}
}
if (quarter) {
fetchData();
}
}, [quarter]);
return { data, isLoading, error };
}
+23 -115
View File
@@ -145,121 +145,6 @@ export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>;
export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>;
export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>;
// ================================
// Keyword Cloud Schemas
// ================================
// 视觉配置 Schema
const VisualConfigSchema = z.object({
color: z.enum(["primary", "secondary", "accent", "gray"]),
size: z.enum(["text-5xl", "text-4xl", "text-3xl", "text-2xl", "text-xl", "text-lg", "text-base"]),
border: z.enum(["border-4", "border-2"]),
rotation: z
.string()
.regex(/^-?rotate-\d+$/)
.nullable()
.optional(),
});
// 关键词输入 Schema
export const KeywordInputSchema = z.object({
word: z.string().min(1).max(100),
trendScore: z.number().int().min(0).max(100),
description: z.string().min(10).max(500),
descriptionEn: z.string().max(500).optional(),
detailPoints: z.array(z.string().min(5).max(100)).min(1).max(5),
detailPointsEn: z.array(z.string().max(100)).max(5).optional(),
visualConfig: VisualConfigSchema,
});
// 批量写入关键词请求 Schema
export const BatchKeywordsRequestSchema = z.object({
quarter: z.string().regex(/^\d{4}-Q[1-4]$/, "格式应为 YYYY-QN"),
keywords: z.array(KeywordInputSchema).min(1).max(50),
});
// 季度 Schema
export const QuarterSchema = z.object({
quarter: z.string().regex(/^\d{4}-Q[1-4]$/),
title: z.string().min(1).max(200),
titleEn: z.string().max(200).optional(),
subtitle: z.string().max(500).optional(),
subtitleEn: z.string().max(500).optional(),
displayOrder: z.number().int().min(0).default(0),
isActive: z.boolean().default(true),
});
// 视觉规则 Schema
export const VisualStyleRuleSchema = z
.object({
name: z.string().min(1).max(100),
minScore: z.number().int().min(0).max(100),
maxScore: z.number().int().min(0).max(100),
color: z.enum(["primary", "secondary", "accent", "gray"]),
size: z.enum([
"text-5xl",
"text-4xl",
"text-3xl",
"text-2xl",
"text-xl",
"text-lg",
"text-base",
]),
border: z.enum(["border-4", "border-2"]),
rotation: z
.string()
.regex(/^-?rotate-\d+$/)
.nullable()
.optional(),
priority: z.number().int().min(0).default(0),
enabled: z.boolean().default(true),
})
.refine((data) => data.minScore < data.maxScore, {
message: "minScore 必须小于 maxScore",
});
// API 响应 Schema
export const KeywordCloudResponseSchema = z.object({
success: z.boolean(),
data: z.any().optional(),
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
// ================================
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>;
// ================================
// Tags API Schemas
// ================================
@@ -344,6 +229,26 @@ export const TagMaintenanceRequestSchema = z.object({
});
});
export const TagMatchCandidateSchema = z.object({
name: z.string().min(1, "Tag name is required").max(100),
nameEn: z.string().max(100).optional(),
});
export const TagMatchAvailableTagSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(100),
nameEn: z.string().max(100).nullable().optional(),
slug: z.string().max(150).optional(),
projectCount: z.number().int().nonnegative().optional(),
});
export const TagMatchRequestSchema = z.object({
apiKey: z.string().min(32, "Invalid API key format"),
candidates: z.array(TagMatchCandidateSchema).min(1).max(30),
availableTags: z.array(TagMatchAvailableTagSchema).max(1000).optional(),
limit: z.number().int().min(1).max(10).default(5),
});
// ================================
// Types
// ================================
@@ -352,3 +257,6 @@ export type TagUpdate = z.infer<typeof TagUpdateSchema>;
export type MergeTarget = z.infer<typeof MergeTargetSchema>;
export type TagMerge = z.infer<typeof TagMergeSchema>;
export type TagMaintenanceRequest = z.infer<typeof TagMaintenanceRequestSchema>;
export type TagMatchCandidate = z.infer<typeof TagMatchCandidateSchema>;
export type TagMatchAvailableTag = z.infer<typeof TagMatchAvailableTagSchema>;
export type TagMatchRequest = z.infer<typeof TagMatchRequestSchema>;
-29
View File
@@ -74,9 +74,6 @@
"navigation": {
"home": "Home",
"projects": "Projects",
"keywordCloud": "AI Word Cloud",
"timeline": "AI Timeline",
"blog": "Blog",
"about": "About",
"submitProject": "SUBMIT PROJECT"
},
@@ -96,31 +93,5 @@
"followUs": "Follow Us",
"copyright": "© 2025 Agent Park. All rights reserved.",
"designedFor": "DESIGNED FOR AI BUILDERS"
},
"keywordCloud": {
"metaTitle": "AI Hotspot Word Cloud - Agent Park",
"metaDescription": "Explore the evolution of quarterly AI hotspots, from Large Language Models to Agent Workflows",
"badge": "AI Trend Tracker",
"title": "Quarterly AI",
"titleHighlight": "Hotspot Word Cloud",
"subtitle": "From &ldquo;Large Language Models&rdquo; to &ldquo;Agent Workflows&rdquo;. Explore the evolution of AI discourse.",
"loading": "Loading...",
"loadFailed": "Failed to load",
"retry": "Retry",
"hotKeyword": "&ldquo;{word}&rdquo; 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"
}
}
-28
View File
@@ -74,9 +74,6 @@
"navigation": {
"home": "首页",
"projects": "项目列表",
"keywordCloud": "AI 词云",
"timeline": "AI 时间轴",
"blog": "博客",
"about": "关于",
"submitProject": "提交项目"
},
@@ -96,30 +93,5 @@
"followUs": "关注我们",
"copyright": "© 2025 Agent Park. 保留所有权利。",
"designedFor": "专为 AI 构建者设计"
},
"keywordCloud": {
"metaTitle": "AI 热点词云 - Agent Park",
"metaDescription": "探索季度 AI 热点词汇的演变历程,从大型语言模型到 Agent 工作流",
"badge": "AI 热点追踪",
"title": "季度 AI",
"titleHighlight": "热点词云",
"subtitle": "从 &ldquo;大型语言模型&rdquo; 到 &ldquo;Agent 工作流&rdquo;。探索 AI 话语的演变历程。",
"loading": "加载中...",
"loadFailed": "加载失败",
"retry": "重试"
},
"timeline": {
"metaTitle": "AI 发展时间轴 - Agent Park",
"metaDescription": "探索人工智能大语言模型的发展历程,从 2017 年 Transformer 到今天",
"title": "AI 的故事",
"subtitle": "钉住。堆叠。之字形。",
"emptyData": "暂无数据",
"collecting": "时间轴数据正在收集中...",
"joinThePark": "加入 Agent Park",
"subscribeDesc": "订阅 Agent Park 周刊。没有垃圾邮件,只有干货。",
"emailPlaceholder": "您的电子邮箱...",
"subscribe": "订阅",
"agreeToBeCool": "我同意保持礼貌。",
"earlier": "更早"
}
}