Files
agent-park/docs/plans/2026-01-25-keyword-cloud-implementation.md
T

2409 lines
59 KiB
Markdown

# 季度 AI 热点词云系统实施计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**目标:** 构建一个自动化数据驱动的可视化词云系统,通过 n8n 工作流从 Google Trends 采集 AI 热点词汇,自动入库并在 Next.js 前端展示。
**架构:** n8n 工作流定时采集 Google Trends 数据 → AI 清洗生成内容 → 规则引擎分配视觉样式 → 写入 PostgreSQL → Next.js 从数据库读取并渲染交互式词云。
**技术栈:** Next.js 15, Prisma, PostgreSQL, n8n, @gamal.dev/n8n-nodes-google-trends, LangChain, Tailwind CSS
---
## 前置准备
### Task 0: 环境检查和依赖安装
**文件:**
- 检查: `package.json`
- 检查: `.env.local`
- 检查: `prisma/schema.prisma`
**Step 1: 检查项目依赖**
确认以下依赖已安装:
```bash
cat package.json | grep -E "(prisma|@radix|lucide|tailwind)"
```
如果没有缺失的依赖,继续。如果有,运行:
```bash
pnpm add prisma @prisma/client
```
**Step 2: 验证数据库连接**
```bash
# 检查 .env.local 是否有 DATABASE_URL
grep DATABASE_URL .env.local
```
如果存在,继续。如果不存在,提示用户配置。
**Step 3: 验证 n8n 环境**
确认你有访问 n8n 实例的权限:
- 自托管: 检查 n8n 是否在本地运行 (`localhost:5678`)
- n8n Cloud: 确认有账号和登录凭据
**Step 4: 创建工作目录**
```bash
# 创建 API 路由目录
mkdir -p src/app/api/keyword-cloud
# 创建组件目录
mkdir -p src/app/[locale]/keyword-cloud/components
# 创建 hooks 目录
ls src/hooks/useKeywordCloud.ts || echo "需要创建 hooks 文件"
```
---
## 第一阶段: 数据库层
### Task 1: 添加 Prisma 模型定义
**文件:**
- 修改: `prisma/schema.prisma`
**Step 1: 在 schema.prisma 末尾添加新模型**
```prisma
// 季度元数据表
model Quarter {
id Int @id @default(autoincrement())
quarter String @unique // "2023-Q1", "2023-Q2"
title String @db.Text // "2023年第一季度"
titleEn String? @db.Text // "Q1 2023"
subtitle String? @db.Text // "聊天界面的黎明"
subtitleEn String? @db.Text // "The dawn of chat interface"
displayOrder Int @default(0) // 前端排序
isActive Boolean @default(true) // 是否显示
keywords Keyword[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([quarter])
@@index([displayOrder])
}
// 关键词核心数据表
model Keyword {
id Int @id @default(autoincrement())
word String // "ChatGPT"
trendScore Int // 0-100, 从 Google Trends 获取
// 外键关联
quarterId Int
quarter Quarter @relation(fields: [quarterId], references: [id], onDelete: Cascade)
// 内容字段(支持中英双语)
description String @db.Text // AI 生成的一句话描述
descriptionEn String? @db.Text // 英文描述
detailPoints Json // JSON 数组: ["要点1", "要点2", "要点3"]
detailPointsEn Json? // 英文版要点
// 视觉样式配置
visualConfig Json // {color, size, rotation, border}
// 元数据
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([quarterId])
@@index([trendScore])
@@index([word])
}
// 视觉样式规则配置表
model VisualStyleRule {
id Int @id @default(autoincrement())
name String @unique // "热门大词-金色"
// 分数区间
minScore Int // 90
maxScore Int // 100
// 视觉属性
color String // "primary", "secondary", "accent"
size String // "text-5xl", "text-3xl", "text-xl"
border String // "border-4", "border-2"
rotation String? // "rotate-1", "rotate-2", null
// 控制
priority Int @default(0) // 优先级,分数重叠时按优先级
enabled Boolean @default(true) // 是否启用
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([enabled])
@@index([minScore, maxScore])
}
// 错误日志表
model KeywordCloudErrorLog {
id Int @id @default(autoincrement())
quarter String // "2023-Q1"
keyword String? // "ChatGPT"
errorType String // "INVALID_DATA", "API_ERROR", "DB_ERROR"
errorMessage String @db.Text // 详细错误信息
rawData Json? // 原始数据便于调试
createdAt DateTime @default(now())
@@index([quarter])
@@index([errorType])
}
```
**Step 2: 生成并运行迁移**
```bash
pnpm prisma migrate dev --name add_keyword_cloud_tables
```
预期输出:
```
✔ Generated Prisma Client
✔ The following migration 20260125xxxxxx_add_keyword_cloud_tables has been created and applied from src-schema.prisma:
create table "Quarter" ...
create table "Keyword" ...
create table "VisualStyleRule" ...
create table "KeywordCloudErrorLog" ...
```
**Step 3: 重新生成 Prisma Client**
```bash
pnpm prisma generate
```
预期输出:
```
✔ Generated Prisma Client to ./node_modules/.prisma/client in XXXms
```
**Step 4: 验证表结构**
```bash
pnpm prisma studio
```
在打开的 Prisma Studio 中检查新表是否正确创建。
**Step 5: 提交**
```bash
git add prisma/schema.prisma prisma/migrations
git commit -m "feat: 添加关键词词云系统数据库模型"
```
---
### Task 2: 添加 Zod 验证 Schema
**文件:**
- 修改: `src/lib/validations.ts`
**Step 1: 在 validations.ts 中添加关键词词云相关的 Schema**
在文件末尾添加:
```typescript
// 视觉配置 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(),
});
```
**Step 2: 运行 TypeScript 检查**
```bash
pnpm tsc --noEmit
```
确保没有类型错误。
**Step 3: 提交**
```bash
git add src/lib/validations.ts
git commit -m "feat: 添加关键词词云系统 Zod 验证 Schema"
```
---
### Task 3: 创建数据访问层函数
**文件:**
- 创建: `src/hooks/useKeywordCloud.ts`
**Step 1: 创建服务器端数据获取函数**
```typescript
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 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<QuarterWithKeywords | null> {
const quarterData = await prisma.quarter.findUnique({
where: { quarter },
include: {
_count: {
select: { keywords: true },
},
},
});
return quarterData;
}
/**
* 获取指定季度的所有关键词
*/
export async function getKeywordsByQuarter(
quarter: string
): Promise<QuarterWithKeywords | null> {
const quarterData = await prisma.quarter.findUnique({
where: { quarter },
include: {
keywords: {
orderBy: { trendScore: 'desc' },
},
},
});
// 转换 visualConfig 从 JSON 到对象
if (quarterData) {
quarterData.keywords = quarterData.keywords.map(kw => ({
...kw,
visualConfig: typeof kw.visualConfig === 'string'
? JSON.parse(kw.visualConfig)
: kw.visualConfig,
}));
}
return quarterData;
}
/**
* 获取所有启用的视觉规则
*/
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,
});
}
```
**Step 2: 运行 TypeScript 检查**
```bash
pnpm tsc --noEmit
```
**Step 3: 提交**
```bash
git add src/hooks/useKeywordCloud.ts
git commit -m "feat: 添加关键词词云数据访问层函数"
```
---
## 第二阶段: API 层
### Task 4: 创建获取季度列表 API
**文件:**
- 创建: `src/app/api/keyword-cloud/quarters/route.ts`
**Step 1: 创建 API 路由处理函数**
```typescript
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 quartersWithCount = await Promise.all(
quarters.map(async (q) => {
const { prisma } = await import('@/lib/prisma');
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 }
);
}
}
```
**Step 2: 测试 API**
```bash
# 启动开发服务器(如果未运行)
pnpm dev
# 在另一个终端测试
curl http://localhost:3000/api/keyword-cloud/quarters
```
预期输出(空数组,因为还没有数据):
```json
{
"success": true,
"quarters": []
}
```
**Step 3: 提交**
```bash
git add src/app/api/keyword-cloud/quarters/route.ts
git commit -m "feat: 添加获取季度列表 API"
```
---
### Task 5: 创建获取关键词 API
**文件:**
- 创建: `src/app/api/keyword-cloud/keywords/[quarter]/route.ts`
**Step 1: 创建 API 路由处理函数**
```typescript
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: { quarter: string } }
) {
try {
const { quarter } = 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 }
);
}
}
```
**Step 2: 测试 API(需要先有数据,先跳过测试)**
暂时不测试,因为还没有数据。稍后在 Task 7 中会测试。
**Step 3: 提交**
```bash
git add src/app/api/keyword-cloud/keywords/[quarter]/route.ts
git commit -m "feat: 添加获取关键词 API"
```
---
### Task 6: 创建批量写入关键词 API
**文件:**
- 创建: `src/app/api/keyword-cloud/keywords/route.ts`
**Step 1: 创建 API 路由处理函数**
```typescript
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 }
);
}
}
```
**Step 2: 测试 API(需要先有数据,先跳过)**
暂时不测试,稍后在 Task 7 中会测试。
**Step 3: 提交**
```bash
git add src/app/api/keyword-cloud/keywords/route.ts
git commit -m "feat: 添加批量写入关键词 API"
```
---
### Task 7: 创建获取视觉规则 API
**文件:**
- 创建: `src/app/api/keyword-cloud/rules/route.ts`
**Step 1: 创建 API 路由处理函数**
```typescript
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 }
);
}
}
```
**Step 2: 提交**
```bash
git add src/app/api/keyword-cloud/rules/route.ts
git commit -m "feat: 添加获取视觉规则 API"
```
---
## 第三阶段: 前端组件
### Task 8: 创建 CloudWord 组件
**文件:**
- 创建: `src/app/[locale]/keyword-cloud/components/CloudWord.tsx`
**Step 1: 创建单个词汇组件**
```typescript
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
interface VisualConfig {
color: 'primary' | 'secondary' | 'accent' | 'gray';
size: string;
border: string;
rotation?: string;
}
interface KeywordData {
id: number;
word: string;
trendScore: number;
description: string;
detailPoints: string[];
visualConfig: VisualConfig;
}
interface CloudWordProps {
data: KeywordData;
}
// 颜色映射
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 }: CloudWordProps) {
const { word, visualConfig, description, detailPoints } = data;
const { color, size, border, rotation } = visualConfig;
const colorClass = colorMap[color];
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={description}
points={detailPoints}
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>
);
}
```
**Step 2: 提交**
```bash
git add src/app/[locale]/keyword-cloud/components/CloudWord.tsx
git commit -m "feat: 添加 CloudWord 组件"
```
---
### Task 9: 创建 QuarterNavigator 组件
**文件:**
- 创建: `src/app/[locale]/keyword-cloud/components/QuarterNavigator.tsx`
**Step 1: 创建季度导航组件**
```typescript
'use client';
import React from 'react';
import MaterialSymbol from '@/components/ui/MaterialSymbol';
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;
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={() => canGoPrev && onNavigate(quarters[currentIndex - 1])}
aria-label="Previous quarter"
>
<MaterialSymbol symbol="chevron_left" className="text-4xl" />
</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={() => canGoNext && onNavigate(quarters[currentIndex + 1])}
aria-label="Next quarter"
>
<MaterialSymbol symbol="chevron_right" className="text-4xl" />
</button>
</div>
);
}
```
**Step 2: 提交**
```bash
git add src/app/[locale]/keyword-cloud/components/QuarterNavigator.tsx
git commit -m "feat: 添加 QuarterNavigator 组件"
```
---
### Task 10: 创建 ProgressIndicator 组件
**文件:**
- 创建: `src/app/[locale]/keyword-cloud/components/ProgressIndicator.tsx`
**Step 1: 创建进度条组件**
```typescript
'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>
);
}
```
**Step 2: 提交**
```bash
git add src/app/[locale]/keyword-cloud/components/ProgressIndicator.tsx
git commit -m "feat: 添加 ProgressIndicator 组件"
```
---
### Task 11: 创建 KeywordCloud 主容器组件
**文件:**
- 创建: `src/app/[locale]/keyword-cloud/components/KeywordCloud.tsx`
**Step 1: 创建词云容器组件**
```typescript
'use client';
import React, { useState, useEffect } from 'react';
import { CloudWord } from './CloudWord';
import { QuarterNavigator } from './QuarterNavigator';
import { ProgressIndicator } from './ProgressIndicator';
import { useKeywordCloud } from '@/hooks/useKeywordCloudClient';
interface KeywordData {
id: number;
word: string;
trendScore: number;
description: string;
detailPoints: string[];
visualConfig: {
color: string;
size: string;
border: string;
rotation?: string;
};
}
interface QuarterData {
quarter: string;
title: string;
titleEn?: string;
subtitle?: string;
subtitleEn?: string;
keywords: KeywordData[];
}
export function KeywordCloud({ initialQuarter }: { initialQuarter: string }) {
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 (error) {
console.error('Failed to load quarters:', error);
}
}
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">加载中...</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">
加载失败
</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"
>
重试
</button>
</div>
</div>
);
}
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: KeywordData) => (
<CloudWord key={keyword.id} data={keyword} />
))}
</div>
</div>
{/* 装饰元素 */}
{data.keywords.length > 0 && (
<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">
"{data.keywords[0].word}" 是本季度最热门词汇!
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-black transform rotate-45" />
</div>
)}
</div>
);
}
```
**Step 2: 提交**
```bash
git add src/app/[locale]/keyword-cloud/components/KeywordCloud.tsx
git commit -m "feat: 添加 KeywordCloud 主容器组件"
```
---
### Task 12: 创建客户端数据获取 Hook
**文件:**
- 创建: `src/hooks/useKeywordCloudClient.ts`
**Step 1: 创建客户端 Hook**
```typescript
'use client';
import { useState, useEffect } from 'react';
interface KeywordData {
id: number;
word: string;
trendScore: number;
description: string;
detailPoints: string[];
visualConfig: {
color: string;
size: string;
border: string;
rotation?: string;
};
}
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 };
}
```
**Step 2: 提交**
```bash
git add src/hooks/useKeywordCloudClient.ts
git commit -m "feat: 添加关键词词云客户端数据获取 Hook"
```
---
### Task 13: 创建词云页面
**文件:**
- 创建: `src/app/[locale]/keyword-cloud/page.tsx`
**Step 1: 创建主页面**
```typescript
import { KeywordCloud } from './components/KeywordCloud';
import { redirect } from 'next/navigation';
interface PageProps {
params: {
locale: string;
};
searchParams: {
quarter?: string;
};
}
export default function KeywordCloudPage({ searchParams }: PageProps) {
// 如果 URL 中有 quarter 参数,使用它;否则使用默认季度
const quarter = searchParams.quarter || '2024-Q1';
return (
<main className="relative z-10 max-w-6xl mx-auto px-4 pb-32 overflow-visible">
{/* 页面标题 */}
<header className="relative z-10 pt-16 pb-8 text-center max-w-4xl mx-auto px-4">
<div className="inline-block bg-accent dark:bg-purple-700 border-2 border-black px-3 py-1 font-display font-bold text-xs mb-4 shadow-hard-sm rotate-[-2deg]">
AI 热点追踪
</div>
<h1 className="font-display text-5xl md:text-7xl font-bold mb-6 tracking-tighter leading-tight">
季度 AI <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-purple-500" style={{ WebkitTextStroke: '1.5px black' }}>热点词云</span>
</h1>
<p className="text-lg md:text-xl max-w-2xl mx-auto font-medium text-gray-700 dark:text-gray-300 mb-8">
"大型语言模型" "Agent 工作流"。探索 AI 话语的演变历程。
</p>
</header>
{/* 词云组件 */}
<KeywordCloud initialQuarter={quarter} />
</main>
);
}
```
**Step 2: 测试页面**
```bash
# 访问页面
open http://localhost:3000/zh/keyword-cloud
```
预期输出: 应该看到页面布局,但由于还没有数据,会显示"加载中"或错误提示。
**Step 3: 提交**
```bash
git add src/app/[locale]/keyword-cloud/page.tsx
git commit -m "feat: 添加关键词词云页面"
```
---
## 第四阶段: n8n 工作流
### Task 14: 创建 n8n 工作流配置文件
**文件:**
- 创建: `n8n-workflows/keyword-cloud-workflow.json`
**Step 1: 创建 n8n 工作流 JSON 配置**
```json
{
"name": "Keyword Cloud Data Collection",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"cron": "0 0 23 28-31 * *"
}
]
}
},
"id": "schedule-trigger",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.1,
"position": [250, 300]
},
{
"parameters": {
"functionCode": "// 检查当前月份是否是季度末(3,6,9,12)\nconst now = new Date();\nconst month = now.getMonth() + 1; // 1-12\nconst isQuarterEnd = [3, 6, 9, 12].includes(month);\n\nif (!isQuarterEnd) {\n return [];\n}\n\n// 计算季度标识\nconst year = now.getFullYear();\nconst quarter = Math.ceil(month / 3);\nconst quarterString = `${year}-Q${quarter}`;\n\n// 计算季度的开始和结束日期\nconst quarterStart = new Date(year, (quarter - 1) * 3, 1);\nconst quarterEnd = new Date(year, quarter * 3, 0);\n\nreturn [{\n json: {\n quarter: quarterString,\n startDate: quarterStart.toISOString().split('T')[0],\n endDate: quarterEnd.toISOString().split('T')[0]\n }\n}];"
},
"id": "calculate-quarter",
"name": "Calculate Quarter",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [450, 300]
},
{
"parameters": {
"resource": "interestOverTime",
"operation": "get",
"searchTerms": "AI,artificial intelligence,machine learning,GPT,LLM,ChatGPT,transformer",
"timeRange": "={{ $json.startDate }} {{ $json.endDate }}",
"category": "0",
"geo": "GB"
},
"id": "google-trends",
"name": "Google Trends",
"type": "@gamal.dev/n8n-nodes-google-trends",
"typeVersion": 1,
"position": [650, 300]
},
{
"parameters": {
"functionCode": "// 从 Google Trends 数据中提取热门词汇\nconst trends = $input.all();\nconst keywords = [];\n\n// 假设 Google Trends 返回数据包含关键词和分数\nfor (const trend of trends) {\n const data = trend.json;\n \n if (data.timeline) {\n for (const [keyword, values] of Object.entries(data.timeline)) {\n if (Array.isArray(values) && values.length > 0) {\n // 计算平均分数\n const avgScore = values.reduce((a, b) => a + b, 0) / values.length;\n keywords.push({\n json: {\n word: keyword,\n trendScore: Math.round(avgScore),\n quarter: $('Calculate Quarter').item.json.quarter\n }\n });\n }\n }\n }\n}\n\nreturn keywords;"
},
"id": "extract-keywords",
"name": "Extract Keywords",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [850, 300]
},
{
"parameters": {
"values": {
"string": [
{
"name": "apiKey",
"value": "={{ $env.API_KEY }}"
}
]
},
"options": {}
},
"id": "set-api-key",
"name": "Set API Key",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1050, 300]
},
{
"parameters": {
"url": "={{ $env.API_URL }}/api/keyword-cloud/rules",
"options": {}
},
"id": "get-visual-rules",
"name": "Get Visual Rules",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [1250, 300]
},
{
"parameters": {
"jsCode": "// 获取关键词和规则\nconst keywords = $('Extract Keywords').all();\nconst rulesData = $('Get Visual Rules').first().json;\nconst rules = rulesData.rules || [];\n\n// 规则按优先级排序\nrules.sort((a, b) => a.priority - b.priority);\n\n// 为每个关键词匹配规则\nconst processed = keywords.map(item => {\n const keyword = item.json;\n \n // 查找匹配的规则\n const matchedRule = rules.find(rule => \n keyword.trendScore >= rule.minScore && \n keyword.trendScore <= rule.maxScore\n );\n \n const visualConfig = matchedRule ? matchedRule.visualConfig : {\n color: 'gray',\n size: 'text-base',\n border: 'border-2',\n rotation: null\n };\n \n return {\n json: {\n ...keyword,\n visualConfig\n }\n };\n});\n\nreturn processed;"
},
"id": "match-visual-rules",
"name": "Match Visual Rules",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1450, 300]
},
{
"parameters": {
"url": "={{ $env.API_URL }}/api/keyword-cloud/keywords",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"apiKey\": \"{{ $env.API_KEY }}\",\n \"quarter\": \"{{ $('Calculate Quarter').item.json.quarter }}\",\n \"keywords\": {{ $json.all().map(item => ({\n word: item.json.word,\n trendScore: item.json.trendScore,\n description: \"AI生成的描述\", // TODO: 使用 AI 节点生成\n detailPoints: [\"要点1\", \"要点2\", \"要点3\"] // TODO: 使用 AI 节点生成\n })) }}\n}",
"options": {}
},
"id": "send-to-api",
"name": "Send to API",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [1650, 300],
"credentials": {
"httpHeaderAuth": {
"id": "1",
n "name": "API Key Auth"
}
}
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "Calculate Quarter",
"type": "main",
"index": 0
}
]
]
},
"Calculate Quarter": {
"main": [
[
{
"node": "Google Trends",
"type": "main",
"index": 0
}
]
]
},
"Google Trends": {
"main": [
[
{
"node": "Extract Keywords",
"type": "main",
"index": 0
}
]
]
},
"Extract Keywords": {
"main": [
[
{
"node": "Set API Key",
"type": "main",
"index": 0
}
]
]
},
"Set API Key": {
"main": [
[
{
"node": "Get Visual Rules",
"type": "main",
"index": 0
}
]
]
},
"Get Visual Rules": {
"main": [
[
{
"node": "Match Visual Rules",
"type": "main",
"index": 0
}
]
]
},
"Match Visual Rules": {
"main": [
[
{
"node": "Send to API",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [],
"triggerCount": 0,
"updatedAt": "2026-01-25T00:00:00.000Z",
"versionId": "1"
}
```
**Step 2: 提交**
```bash
git add n8n-workflows/keyword-cloud-workflow.json
git commit -m "feat: 添加 n8n 关键词词云工作流配置"
```
---
## 第五阶段:初始化数据
### Task 15: 初始化视觉规则数据
**文件:**
- 创建: `scripts/seed-keyword-cloud.ts`
**Step 1: 创建种子数据脚本**
```typescript
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('开始初始化关键词词云数据...');
// 1. 创建视觉样式规则
const rules = [
{
name: '热门大词-金色',
minScore: 90,
maxScore: 100,
color: 'primary',
size: 'text-5xl',
border: 'border-4',
rotation: 'rotate-1',
priority: 0,
enabled: true,
},
{
name: '中等词汇-蓝色',
minScore: 70,
maxScore: 89,
color: 'secondary',
size: 'text-3xl',
border: 'border-4',
rotation: 'rotate-2',
priority: 1,
enabled: true,
},
{
name: '小词汇-紫色',
minScore: 50,
maxScore: 69,
color: 'accent',
size: 'text-xl',
border: 'border-2',
rotation: '-rotate-1',
priority: 2,
enabled: true,
},
{
name: '长尾词-灰色',
minScore: 0,
maxScore: 49,
color: 'gray',
size: 'text-base',
border: 'border-2',
rotation: null,
priority: 3,
enabled: true,
},
];
for (const rule of rules) {
await prisma.visualStyleRule.upsert({
where: { name: rule.name },
update: rule,
create: rule,
});
console.log(`✓ 创建规则: ${rule.name}`);
}
// 2. 创建示例季度(2023-Q1)
const quarter = await prisma.quarter.upsert({
where: { quarter: '2023-Q1' },
update: {},
create: {
quarter: '2023-Q1',
title: '2023年第一季度',
titleEn: 'Q1 2023',
subtitle: '聊天界面的黎明',
subtitleEn: 'The dawn of chat interface',
displayOrder: 0,
isActive: true,
},
});
console.log(`✓ 创建季度: ${quarter.quarter}`);
// 3. 创建示例关键词
const sampleKeywords = [
{
word: 'ChatGPT',
trendScore: 98,
description: 'OpenAI 开发的对话式人工智能助手,支持多轮对话',
detailPoints: ['基于 GPT-3.5 架构', '2023年用户突破1亿', '引领对话式AI热潮'],
visualConfig: {
color: 'secondary',
size: 'text-5xl',
border: 'border-4',
rotation: 'rotate-1',
},
},
{
word: 'GPT-4',
trendScore: 92,
description: 'OpenAI 发布的多模态大型语言模型',
detailPoints: ['支持图像输入', '推理能力显著提升', '上下文窗口扩大'],
visualConfig: {
color: 'primary',
size: 'text-4xl',
border: 'border-4',
rotation: 'rotate-2',
},
},
{
word: 'LLM',
trendScore: 88,
description: 'Large Language Model,大型语言模型',
detailPoints: ['基于Transformer架构', '参数规模达十亿级', '涌现能力'],
visualConfig: {
color: 'secondary',
size: 'text-3xl',
border: 'border-4',
rotation: '-rotate-1',
},
},
{
word: 'Prompt Engineering',
trendScore: 85,
description: '提示词工程,优化AI模型输入的技术',
detailPoints: ['Few-shot prompting', '思维链提示', '迭代优化'],
visualConfig: {
color: 'accent',
size: 'text-3xl',
border: 'border-2',
rotation: 'rotate-1',
},
},
{
word: 'Transformer',
trendScore: 75,
description: '基于自注意力机制的神经网络架构',
detailPoints: ['并行计算能力强', '成为LLM基础架构', '2017年Google提出'],
visualConfig: {
color: 'secondary',
size: 'text-2xl',
border: 'border-2',
rotation: null,
},
},
];
for (const kw of sampleKeywords) {
await prisma.keyword.create({
data: {
quarterId: quarter.id,
...kw,
detailPoints: kw.detailPoints as any,
visualConfig: kw.visualConfig as any,
},
});
console.log(`✓ 创建关键词: ${kw.word}`);
}
console.log('\n初始化完成!');
}
main()
.catch((e) => {
console.error('错误:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
```
**Step 2: 运行种子数据脚本**
```bash
pnpm tsx scripts/seed-keyword-cloud.ts
```
预期输出:
```
开始初始化关键词词云数据...
✓ 创建规则: 热门大词-金色
✓ 创建规则: 中等词汇-蓝色
✓ 创建规则: 小词汇-紫色
✓ 创建规则: 长尾词-灰色
✓ 创建季度: 2023-Q1
✓ 创建关键词: ChatGPT
✓ 创建关键词: GPT-4
✓ 创建关键词: LLM
✓ 创建关键词: Prompt Engineering
✓ 创建关键词: Transformer
初始化完成!
```
**Step 3: 测试前端页面**
```bash
# 访问页面
open http://localhost:3000/zh/keyword-cloud?quarter=2023-Q1
```
预期输出: 应该看到 2023-Q1 的词云,包含 5 个示例词汇。
**Step 4: 提交**
```bash
git add scripts/seed-keyword-cloud.ts
git commit -m "feat: 添加关键词词云种子数据脚本"
```
---
## 第六阶段:集成测试
### Task 16: 端到端测试
**文件:**
- 创建: `tests/e2e/keyword-cloud.spec.ts`
**Step 1: 创建 E2E 测试**
```typescript
import { test, expect } from '@playwright/test';
test.describe('关键词词云系统', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/zh/keyword-cloud?quarter=2023-Q1');
});
test('应该显示词云页面', async ({ page }) => {
await expect(page.locator('h1')).toContainText('季度 AI 热点词云');
});
test('应该显示季度导航', async ({ page }) => {
await expect(page.locator('text=2023-Q1')).toBeVisible();
});
test('应该显示关键词', async ({ page }) => {
await expect(page.locator('text=ChatGPT')).toBeVisible();
await expect(page.locator('text=GPT-4')).toBeVisible();
await expect(page.locator('text=LLM')).toBeVisible();
});
test('鼠标悬停应该显示弹出框', async ({ page }) => {
const chatgptWord = page.locator('text=ChatGPT').first();
await chatgptWord.hover();
await expect(page.locator('.popover')).toBeVisible();
await expect(page.locator('.popover')).toContainText('OpenAI 开发的对话式');
});
test('季度切换按钮状态正确', async ({ page }) => {
const prevButton = page.locator('button[aria-label="Previous quarter"]');
const nextButton = page.locator('button[aria-label="Next quarter"]');
// 2023-Q1 是第一个季度,所以应该禁用"上一个"按钮
await expect(prevButton).toBeDisabled();
await expect(nextButton).not.toBeDisabled();
});
test('API 端点测试: 获取季度列表', async ({ request }) => {
const response = await request.get('/api/keyword-cloud/quarters');
const data = await response.json();
expect(response.ok()).toBeTruthy();
expect(data.success).toBe(true);
expect(data.quarters).toBeInstanceOf(Array);
expect(data.quarters.length).toBeGreaterThan(0);
});
test('API 端点测试: 获取关键词', async ({ request }) => {
const response = await request.get('/api/keyword-cloud/keywords/2023-Q1');
const data = await response.json();
expect(response.ok()).toBeTruthy();
expect(data.success).toBe(true);
expect(data.quarter).toBe('2023-Q1');
expect(data.keywords).toBeInstanceOf(Array);
expect(data.keywords.length).toBeGreaterThan(0);
});
test('API 端点测试: 获取视觉规则', async ({ request }) => {
const response = await request.get('/api/keyword-cloud/rules');
const data = await response.json();
expect(response.ok()).toBeTruthy();
expect(data.success).toBe(true);
expect(data.rules).toBeInstanceOf(Array);
expect(data.rules.length).toBeGreaterThan(0);
});
});
```
**Step 2: 运行 E2E 测试**
```bash
pnpm test:e2e
```
预期输出: 所有测试通过。
**Step 3: 提交**
```bash
git add tests/e2e/keyword-cloud.spec.ts
git commit -m "test: 添加关键词词云 E2E 测试"
```
---
## 第七阶段:n8n 集成
### Task 17: 配置 n8n 环境变量
**文件:**
- 修改: `.env.local`
**Step 1: 添加 n8n 所需的环境变量**
```bash
# n8n 工作流配置
N8N_API_URL=http://localhost:3000
N8N_API_KEY=your-webhook-api-key-here
```
**注意**: `N8N_API_KEY` 应该与你的项目 `WEBHOOK_API_KEY` 相同,或者为 n8n 创建专门的 API Key。
**Step 2: 提交(不要提交实际密钥)**
```bash
git add .env.local.example # 如果有示例文件
git commit -m "chore: 添加 n8n 环境变量配置"
```
---
### Task 18: 导入并测试 n8n 工作流
**Step 1: 在 n8n 中导入工作流**
1. 打开 n8n 界面 (`localhost:5678` 或你的 n8n Cloud 实例)
2. 点击 "Import from File"
3. 选择 `n8n-workflows/keyword-cloud-workflow.json`
4. 保存工作流
**Step 2: 配置工作流凭证**
1. 点击 "Send to API" 节点
2. 配置 HTTP Header Auth 凭证:
- Name: `Authorization`
- Value: `Bearer YOUR_API_KEY`
3. 保存
**Step 3: 配置环境变量**
在 n8n 中设置以下环境变量:
- `API_URL`: `http://localhost:3000` (或你的生产环境 URL)
- `API_KEY`: 你的 `WEBHOOK_API_KEY`
**Step 4: 手动测试工作流**
1. 在 n8n 中点击 "Execute Workflow"
2. 观察每个节点的执行结果
3. 检查数据库中是否创建了新数据
**Step 5: 验证数据**
```bash
# 使用 Prisma Studio 检查数据
pnpm prisma studio
# 或使用 psql
psql $DATABASE_URL -c "SELECT * FROM \"Keyword\" ORDER BY \"createdAt\" DESC LIMIT 5;"
```
**Step 6: 提交 n8n 工作流文档**
创建 `n8n-workflows/README.md`:
```markdown
# n8n 工作流文档
本目录包含 Agent Park 的 n8n 工作流配置。
## 关键词词云工作流
**文件**: `keyword-cloud-workflow.json`
### 功能
自动采集 Google Trends 数据,生成 AI 热点词汇词云。
### 执行流程
1. **Schedule Trigger**: 每季度末最后一天的 23:00 自动触发
2. **Calculate Quarter**: 计算当前季度标识和时间范围
3. **Google Trends**: 采集热门搜索词
4. **Extract Keywords**: 提取关键词和热度分数
5. **Get Visual Rules**: 获取视觉样式规则
6. **Match Visual Rules**: 为关键词匹配视觉样式
7. **Send to API**: 写入数据库
### 环境变量
- `API_URL`: API 端点 URL
- `API_KEY`: Webhook API Key
### 手动执行
在 n8n 界面中点击 "Execute Workflow" 按钮。
### 调试
检查每个节点的输出,确保数据格式正确。
```
**Step 7: 提交**
```bash
git add n8n-workflows/README.md
git commit -m "docs: 添加 n8n 工作流文档"
```
---
## 第八阶段:部署和监控
### Task 19: 配置生产环境
**文件:**
- 创建: `scripts/deploy-keyword-cloud.sh`
**Step 1: 创建部署脚本**
```bash
#!/bin/bash
echo "🚀 部署关键词词云系统..."
# 1. 运行数据库迁移
echo "📦 运行数据库迁移..."
pnpm prisma migrate deploy
# 2. 生成 Prisma Client
echo "🔧 生成 Prisma Client..."
pnpm prisma generate
# 3. 初始化种子数据(仅首次)
echo "🌱 初始化种子数据..."
pnpm tsx scripts/seed-keyword-cloud.ts
# 4. 构建应用
echo "🏗️ 构建应用..."
pnpm build
# 5. 提示配置 n8n
echo ""
echo "✅ 部署完成!"
echo ""
echo "⚠️ 下一步:"
echo "1. 在 n8n 中导入工作流: n8n-workflows/keyword-cloud-workflow.json"
echo "2. 配置 n8n 环境变量: API_URL, API_KEY"
echo "3. 测试工作流执行"
echo ""
```
**Step 2: 赋予执行权限**
```bash
chmod +x scripts/deploy-keyword-cloud.sh
```
**Step 3: 提交**
```bash
git add scripts/deploy-keyword-cloud.sh
git commit -m "chore: 添加关键词词云部署脚本"
```
---
### Task 20: 添加监控和告警
**文件:**
- 创建: `src/app/api/keyword-cloud/health/route.ts`
**Step 1: 创建健康检查端点**
```typescript
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 }
);
}
}
```
**Step 2: 测试健康检查**
```bash
curl http://localhost:3000/api/keyword-cloud/health
```
预期输出:
```json
{
"success": true,
"status": "healthy",
"stats": {
"quarters": 1,
"keywords": 5,
"activeRules": 4,
"recentErrors": 0
},
"timestamp": "2026-01-25T..."
}
```
**Step 3: 提交**
```bash
git add src/app/api/keyword-cloud/health/route.ts
git commit -m "feat: 添加关键词词云健康检查端点"
```
---
## 第九阶段:文档和清理
### Task 21: 更新项目文档
**文件:**
- 修改: `CLAUDE.md`
**Step 1: 在 CLAUDE.md 中添加关键词词云系统说明**
在 "Architecture Overview" 部分后添加:
```markdown
### Keyword Cloud System (季度 AI 热点词云)
**功能**: 自动化采集 Google Trends 数据,展示季度 AI 热点词汇词云。
**数据流**: n8n 工作流 → AI 清洗 → 规则匹配 → PostgreSQL → Next.js 前端
**数据库表**:
- `Quarter`: 季度元数据
- `Keyword`: 关键词数据和视觉配置
- `VisualStyleRule`: 视觉样式规则配置
- `KeywordCloudErrorLog`: 错误日志
**API 端点**:
- `GET /api/keyword-cloud/quarters`: 获取季度列表
- `GET /api/keyword-cloud/keywords/[quarter]`: 获取关键词
- `GET /api/keyword-cloud/rules`: 获取视觉规则
- `POST /api/keyword-cloud/keywords`: 批量写入(n8n使用)
- `GET /api/keyword-cloud/health`: 健康检查
**前端路由**: `/[locale]/keyword-cloud`
**n8n 工作流**: `n8n-workflows/keyword-cloud-workflow.json`
**初始化**:
```bash
pnpm tsx scripts/seed-keyword-cloud.ts
```
```
**Step 2: 提交**
```bash
git add CLAUDE.md
git commit -m "docs: 更新 CLAUDE.md 添加关键词词云系统说明"
```
---
### Task 22: 最终代码审查和测试
**Step 1: 运行完整测试套件**
```bash
# 单元测试
pnpm test
# E2E 测试
pnpm test:e2e
# TypeScript 检查
pnpm tsc --noEmit
# ESLint 检查
pnpm lint
```
确保所有检查通过。
**Step 2: 手动测试所有功能**
1. 访问 `/zh/keyword-cloud`
2. 测试季度切换
3. 测试词汇悬停弹出框
4. 测试 API 端点
5. 检查响应式布局(移动端)
**Step 3: 性能检查**
```bash
# 构建生产版本
pnpm build
# 检查包大小
pnpm build --analyze
```
**Step 4: 安全检查**
```bash
# 检查依赖漏洞
pnpm audit
```
**Step 5: 提交最终更新**
```bash
git add .
git commit -m "chore: 关键词词云系统实施完成"
```
---
## 总结
实施计划包含 22 个任务,分为 9 个阶段:
1. ✅ 前置准备
2. ✅ 数据库层(3 个任务)
3. ✅ API 层(4 个任务)
4. ✅ 前端组件(6 个任务)
5. ✅ n8n 工作流配置(2 个任务)
6. ✅ 初始化数据(2 个任务)
7. ✅ 集成测试(1 个任务)
8. ✅ n8n 集成(2 个任务)
9. ✅ 部署和监控(2 个任务)
**总预计时间**: 8-12 小时
**技术栈**:
- Next.js 15 (App Router)
- Prisma ORM
- PostgreSQL
- n8n
- Tailwind CSS
**关键特性**:
- ✅ 自动化数据采集
- ✅ AI 清洗和内容生成
- ✅ 规则引擎视觉样式匹配
- ✅ 交互式词云展示
- ✅ 多语言支持
- ✅ 响应式设计
- ✅ 错误处理和监控