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

59 KiB

季度 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: 检查项目依赖

确认以下依赖已安装:

cat package.json | grep -E "(prisma|@radix|lucide|tailwind)"

如果没有缺失的依赖,继续。如果有,运行:

pnpm add prisma @prisma/client

Step 2: 验证数据库连接

# 检查 .env.local 是否有 DATABASE_URL
grep DATABASE_URL .env.local

如果存在,继续。如果不存在,提示用户配置。

Step 3: 验证 n8n 环境

确认你有访问 n8n 实例的权限:

  • 自托管: 检查 n8n 是否在本地运行 (localhost:5678)
  • n8n Cloud: 确认有账号和登录凭据

Step 4: 创建工作目录

# 创建 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 末尾添加新模型

// 季度元数据表
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: 生成并运行迁移

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

pnpm prisma generate

预期输出:

✔ Generated Prisma Client to ./node_modules/.prisma/client in XXXms

Step 4: 验证表结构

pnpm prisma studio

在打开的 Prisma Studio 中检查新表是否正确创建。

Step 5: 提交

git add prisma/schema.prisma prisma/migrations
git commit -m "feat: 添加关键词词云系统数据库模型"

Task 2: 添加 Zod 验证 Schema

文件:

  • 修改: src/lib/validations.ts

Step 1: 在 validations.ts 中添加关键词词云相关的 Schema

在文件末尾添加:

// 视觉配置 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 检查

pnpm tsc --noEmit

确保没有类型错误。

Step 3: 提交

git add src/lib/validations.ts
git commit -m "feat: 添加关键词词云系统 Zod 验证 Schema"

Task 3: 创建数据访问层函数

文件:

  • 创建: src/hooks/useKeywordCloud.ts

Step 1: 创建服务器端数据获取函数

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 检查

pnpm tsc --noEmit

Step 3: 提交

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 路由处理函数

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

# 启动开发服务器(如果未运行)
pnpm dev

# 在另一个终端测试
curl http://localhost:3000/api/keyword-cloud/quarters

预期输出(空数组,因为还没有数据):

{
  "success": true,
  "quarters": []
}

Step 3: 提交

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 路由处理函数

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: 提交

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 路由处理函数

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: 提交

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 路由处理函数

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: 提交

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: 创建单个词汇组件

'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: 提交

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: 创建季度导航组件

'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: 提交

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: 创建进度条组件

'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: 提交

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: 创建词云容器组件

'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: 提交

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

'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: 提交

git add src/hooks/useKeywordCloudClient.ts
git commit -m "feat: 添加关键词词云客户端数据获取 Hook"

Task 13: 创建词云页面

文件:

  • 创建: src/app/[locale]/keyword-cloud/page.tsx

Step 1: 创建主页面

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: 测试页面

# 访问页面
open http://localhost:3000/zh/keyword-cloud

预期输出: 应该看到页面布局,但由于还没有数据,会显示"加载中"或错误提示。

Step 3: 提交

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 配置

{
  "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: 提交

git add n8n-workflows/keyword-cloud-workflow.json
git commit -m "feat: 添加 n8n 关键词词云工作流配置"

第五阶段:初始化数据

Task 15: 初始化视觉规则数据

文件:

  • 创建: scripts/seed-keyword-cloud.ts

Step 1: 创建种子数据脚本

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: 运行种子数据脚本

pnpm tsx scripts/seed-keyword-cloud.ts

预期输出:

开始初始化关键词词云数据...
✓ 创建规则: 热门大词-金色
✓ 创建规则: 中等词汇-蓝色
✓ 创建规则: 小词汇-紫色
✓ 创建规则: 长尾词-灰色
✓ 创建季度: 2023-Q1
✓ 创建关键词: ChatGPT
✓ 创建关键词: GPT-4
✓ 创建关键词: LLM
✓ 创建关键词: Prompt Engineering
✓ 创建关键词: Transformer

初始化完成!

Step 3: 测试前端页面

# 访问页面
open http://localhost:3000/zh/keyword-cloud?quarter=2023-Q1

预期输出: 应该看到 2023-Q1 的词云,包含 5 个示例词汇。

Step 4: 提交

git add scripts/seed-keyword-cloud.ts
git commit -m "feat: 添加关键词词云种子数据脚本"

第六阶段:集成测试

Task 16: 端到端测试

文件:

  • 创建: tests/e2e/keyword-cloud.spec.ts

Step 1: 创建 E2E 测试

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 测试

pnpm test:e2e

预期输出: 所有测试通过。

Step 3: 提交

git add tests/e2e/keyword-cloud.spec.ts
git commit -m "test: 添加关键词词云 E2E 测试"

第七阶段:n8n 集成

Task 17: 配置 n8n 环境变量

文件:

  • 修改: .env.local

Step 1: 添加 n8n 所需的环境变量

# 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: 提交(不要提交实际密钥)

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: 验证数据

# 使用 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:

# 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: 提交

git add n8n-workflows/README.md
git commit -m "docs: 添加 n8n 工作流文档"

第八阶段:部署和监控

Task 19: 配置生产环境

文件:

  • 创建: scripts/deploy-keyword-cloud.sh

Step 1: 创建部署脚本

#!/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: 赋予执行权限

chmod +x scripts/deploy-keyword-cloud.sh

Step 3: 提交

git add scripts/deploy-keyword-cloud.sh
git commit -m "chore: 添加关键词词云部署脚本"

Task 20: 添加监控和告警

文件:

  • 创建: src/app/api/keyword-cloud/health/route.ts

Step 1: 创建健康检查端点

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: 测试健康检查

curl http://localhost:3000/api/keyword-cloud/health

预期输出:

{
  "success": true,
  "status": "healthy",
  "stats": {
    "quarters": 1,
    "keywords": 5,
    "activeRules": 4,
    "recentErrors": 0
  },
  "timestamp": "2026-01-25T..."
}

Step 3: 提交

git add src/app/api/keyword-cloud/health/route.ts
git commit -m "feat: 添加关键词词云健康检查端点"

第九阶段:文档和清理

Task 21: 更新项目文档

文件:

  • 修改: CLAUDE.md

Step 1: 在 CLAUDE.md 中添加关键词词云系统说明

在 "Architecture Overview" 部分后添加:

### 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: 运行完整测试套件

# 单元测试
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: 性能检查

# 构建生产版本
pnpm build

# 检查包大小
pnpm build --analyze

Step 4: 安全检查

# 检查依赖漏洞
pnpm audit

Step 5: 提交最终更新

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