2409 lines
59 KiB
Markdown
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 清洗和内容生成
|
|
- ✅ 规则引擎视觉样式匹配
|
|
- ✅ 交互式词云展示
|
|
- ✅ 多语言支持
|
|
- ✅ 响应式设计
|
|
- ✅ 错误处理和监控
|