refactor: 移除 AI 词云、AI 时间轴和博客模块
暂停开发以下功能,将设计文档移至未完成计划文件夹: - AI 词云 (Keyword Cloud): 前端、API、数据库模型、n8n 工作流 - AI 时间轴 (AI Timeline): 前端、API、数据库模型 - 博客 (Blog): 导航链接占位 变更内容: - 删除词云和时间轴的前端页面及组件 - 删除 /api/keyword-cloud/* 和 /api/events/* API 端点 - 从 prisma/schema.prisma 移除 Keyword, Quarter, VisualStyleRule, KeywordCloudErrorLog, AIEvent 模型 - 从 validations.ts 移除相关 Zod Schema - 从国际化消息中移除 keywordCloud/timeline 命名空间 - 从导航菜单移除词云、时间轴、博客链接 - 更新 CLAUDE.md 移除词云系统文档 设计文档已移至 .omc/plans/postponed-features/ 供后续恢复开发参考
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
# AI 项目导航网站 - 下一阶段功能实施计划
|
||||
|
||||
**创建日期**: 2026-02-20
|
||||
**计划版本**: 1.0
|
||||
**预计工期**: 6-8 周
|
||||
|
||||
---
|
||||
|
||||
## 需求摘要
|
||||
|
||||
本计划涵盖两个并行推进的方向:
|
||||
|
||||
### 方向 A - 视觉与交互体验
|
||||
提升用户体验的视觉优化,包括深浅主题切换、移动端适配、无障碍访问和布局优化。
|
||||
|
||||
### 方向 B - 质量控制体系
|
||||
建立项目审核流程和质量评分机制,确保平台内容质量,包含审核状态工作流、质量评分、垃圾内容过滤和标签规范化。
|
||||
|
||||
---
|
||||
|
||||
## 验收标准(可测试)
|
||||
|
||||
### 方向 A 验收标准
|
||||
- [ ] **A1**: 用户可通过切换按钮在深色/浅色主题间切换,选择持久化到 localStorage
|
||||
- [ ] **A2**: 在 375px-768px-1440px 三个断点下,所有页面布局正确无溢出
|
||||
- [ ] **A3**: 通过 Lighthouse 无障碍审计得分 >= 90
|
||||
- [ ] **A4**: 所有交互元素可通过键盘访问(Tab 导航 + Enter/Space 激活)
|
||||
- [ ] **A5**: 首屏加载 LCP <= 2.5s(移动端 4G 网络)
|
||||
|
||||
### 方向 B 验收标准
|
||||
- [ ] **B1**: 新项目默认状态为 `PENDING_REVIEW`,管理员可将其变为 `APPROVED`/`REJECTED`
|
||||
- [ ] **B2**: 项目详情页显示质量评分(0-100),评分依据内容完整度和标签准确性
|
||||
- [ ] **B3**: 系统可自动标记疑似垃圾/低质项目(评分 < 30)
|
||||
- [ ] **B4**: Tag Janitor API 支持批量标签规范化操作
|
||||
- [ ] **B5**: 审核日志记录所有状态变更(谁、何时、从什么状态改为什么状态)
|
||||
|
||||
---
|
||||
|
||||
## 分阶段实施步骤
|
||||
|
||||
---
|
||||
|
||||
## 阶段 1:基础设施准备(Week 1)
|
||||
|
||||
### 1.1 数据库 Schema 扩展(方向 B)
|
||||
|
||||
**目标**: 为质量控制体系添加必要的数据库字段和模型
|
||||
|
||||
**涉及的文件**:
|
||||
- `prisma/schema.prisma`
|
||||
|
||||
**具体变更**:
|
||||
|
||||
```prisma
|
||||
// 新增:项目审核状态枚举
|
||||
enum ReviewStatus {
|
||||
PENDING_REVIEW // 待审核
|
||||
APPROVED // 已批准
|
||||
REJECTED // 已拒绝
|
||||
FLAGGED // 已标记(可疑内容)
|
||||
}
|
||||
|
||||
// 修改:Project 模型添加审核和质量字段
|
||||
model Project {
|
||||
// ... 现有字段 ...
|
||||
|
||||
// 审核相关
|
||||
reviewStatus ReviewStatus @default(PENDING_REVIEW)
|
||||
reviewedAt DateTime?
|
||||
reviewedBy String? // 审核人标识(未来可关联用户系统)
|
||||
|
||||
// 质量评分
|
||||
qualityScore Int? // 0-100 分
|
||||
qualityFactors Json? // 评分因素明细
|
||||
qualityUpdatedAt DateTime?
|
||||
|
||||
// 索引更新
|
||||
@@index([reviewStatus], map: "idx_project_reviewStatus")
|
||||
@@index([qualityScore], map: "idx_project_qualityScore")
|
||||
}
|
||||
|
||||
// 新增:审核日志模型
|
||||
model ReviewLog {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
fromStatus ReviewStatus
|
||||
toStatus ReviewStatus
|
||||
reason String? // 变更原因
|
||||
reviewedBy String? // 审核人
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([projectId], map: "idx_reviewLog_projectId")
|
||||
@@index([createdAt], map: "idx_reviewLog_createdAt")
|
||||
@@map("review_logs")
|
||||
}
|
||||
```
|
||||
|
||||
**验收检查**:
|
||||
```bash
|
||||
pnpm prisma migrate dev --name add_review_system
|
||||
pnpm prisma generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 主题系统基础(方向 A)
|
||||
|
||||
**目标**: 建立主题切换的客户端基础设施
|
||||
|
||||
**涉及的文件**:
|
||||
- `src/components/theme/ThemeProvider.tsx` (新建)
|
||||
- `src/components/theme/ThemeToggle.tsx` (新建)
|
||||
- `src/app/layout.tsx`
|
||||
- `src/hooks/useTheme.ts` (新建)
|
||||
|
||||
**具体实现要点**:
|
||||
|
||||
1. **ThemeProvider.tsx**: 创建客户端主题上下文
|
||||
- 读取 localStorage 中的 `theme` 值
|
||||
- 支持 `light`、`dark`、`system` 三种模式
|
||||
- 通过 `next-themes` 或自定义 Context 实现
|
||||
|
||||
2. **ThemeToggle.tsx**: 主题切换按钮组件
|
||||
- 显示当前主题图标(太阳/月亮/系统)
|
||||
- 点击切换主题
|
||||
- 适配 neo-brutalism 设计风格
|
||||
|
||||
3. **layout.tsx 修改**: 包装 ThemeProvider
|
||||
```tsx
|
||||
<ThemeProvider attribute="class" defaultTheme="system">
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
**验收检查**:
|
||||
- 切换主题后刷新页面,主题保持不变
|
||||
- 系统主题变化时自动跟随(当选择 `system` 模式)
|
||||
|
||||
---
|
||||
|
||||
## 阶段 2:核心功能实现(Week 2-3)
|
||||
|
||||
### 2.1 主题切换 UI 集成(方向 A)
|
||||
|
||||
**目标**: 将主题切换功能集成到网站导航栏
|
||||
|
||||
**涉及的文件**:
|
||||
- `src/app/[locale]/layout.tsx`
|
||||
- `src/components/theme/ThemeToggle.tsx`
|
||||
- `src/messages/zh.json`
|
||||
- `src/messages/en.json`
|
||||
|
||||
**具体变更**:
|
||||
|
||||
1. 在导航栏右侧添加 ThemeToggle 按钮(LocaleSwitcher 旁边)
|
||||
2. 添加国际化文本:
|
||||
```json
|
||||
// zh.json
|
||||
"theme": {
|
||||
"light": "浅色模式",
|
||||
"dark": "深色模式",
|
||||
"system": "跟随系统"
|
||||
}
|
||||
|
||||
// en.json
|
||||
"theme": {
|
||||
"light": "Light Mode",
|
||||
"dark": "Dark Mode",
|
||||
"system": "System"
|
||||
}
|
||||
```
|
||||
|
||||
3. 确保所有 `dark:` Tailwind 类正确应用
|
||||
|
||||
**验收检查**:
|
||||
- [ ] 导航栏显示主题切换按钮
|
||||
- [ ] 点击切换立即生效,无闪烁
|
||||
- [ ] 两种主题下所有组件颜色正确
|
||||
|
||||
---
|
||||
|
||||
### 2.2 审核状态 API(方向 B)
|
||||
|
||||
**目标**: 创建项目审核相关的 API 端点
|
||||
|
||||
**涉及的文件**:
|
||||
- `src/lib/validations.ts`
|
||||
- `src/app/api/admin/review/route.ts` (新建)
|
||||
- `src/app/api/admin/review/service.ts` (新建)
|
||||
- `src/hooks/useProjects.ts`
|
||||
|
||||
**具体实现**:
|
||||
|
||||
1. **validations.ts**: 添加审核相关 Schema
|
||||
```typescript
|
||||
export const ReviewStatusEnum = z.enum([
|
||||
"PENDING_REVIEW", "APPROVED", "REJECTED", "FLAGGED"
|
||||
]);
|
||||
|
||||
export const UpdateReviewStatusSchema = z.object({
|
||||
apiKey: z.string().min(32),
|
||||
projectId: z.string().min(1),
|
||||
status: ReviewStatusEnum,
|
||||
reason: z.string().max(500).optional(),
|
||||
reviewedBy: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export const GetPendingReviewsQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().positive().max(50).default(20),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
sortBy: z.enum(['createdAt', 'qualityScore']).default('createdAt'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('asc'),
|
||||
});
|
||||
```
|
||||
|
||||
2. **route.ts**: 实现 PATCH 端点更新审核状态
|
||||
|
||||
3. **service.ts**: 业务逻辑
|
||||
- 更新项目审核状态
|
||||
- 创建审核日志记录
|
||||
- 验证状态转换合法性
|
||||
|
||||
**验收检查**:
|
||||
```bash
|
||||
# 测试 API
|
||||
curl -X PATCH http://localhost:3000/api/admin/review \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"apiKey":"...","projectId":"xxx","status":"APPROVED"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 质量评分系统(方向 B)
|
||||
|
||||
**目标**: 实现自动计算项目质量评分的算法
|
||||
|
||||
**涉及的文件**:
|
||||
- `src/lib/quality-scorer.ts` (新建)
|
||||
- `src/app/api/webhook/projects/route.ts`
|
||||
- `src/lib/validations.ts`
|
||||
|
||||
**评分规则**(总分 100):
|
||||
|
||||
| 因素 | 分值 | 说明 |
|
||||
|------|------|------|
|
||||
| 描述长度 | 0-15 | 10-50字=5分,50-200字=10分,200-500字=15分 |
|
||||
| 内容完整性 | 0-20 | 有 content=10分,content > 200字=+10分 |
|
||||
| 标签数量 | 0-15 | 1-3个=5分,4-6个=10分,7-10个=15分 |
|
||||
| 链接完整性 | 0-20 | 有 GITHUB=10分,有 WEBSITE=+5分,有其他=+5分 |
|
||||
| 英文翻译 | 0-15 | nameEn 存在=5分,descriptionEn 存在=5分,contentEn 存在=5分 |
|
||||
| 媒体丰富度 | 0-15 | 未来扩展(截图、视频等) |
|
||||
|
||||
**具体实现**:
|
||||
|
||||
```typescript
|
||||
// quality-scorer.ts
|
||||
export function calculateQualityScore(project: ProjectInput): QualityScoreResult {
|
||||
const factors: QualityFactor[] = [];
|
||||
|
||||
// 描述长度评分
|
||||
const descLength = project.description.length;
|
||||
let descScore = 0;
|
||||
if (descLength >= 10 && descLength < 50) descScore = 5;
|
||||
else if (descLength >= 50 && descLength < 200) descScore = 10;
|
||||
else if (descLength >= 200 && descLength <= 500) descScore = 15;
|
||||
factors.push({ name: 'descriptionLength', score: descScore, maxScore: 15 });
|
||||
|
||||
// ... 其他评分逻辑
|
||||
|
||||
return {
|
||||
totalScore: factors.reduce((sum, f) => sum + f.score, 0),
|
||||
factors,
|
||||
flagged: totalScore < 30, // 低质量标记
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**验收检查**:
|
||||
- [ ] 新项目通过 webhook 导入时自动计算评分
|
||||
- [ ] 评分 < 30 的项目自动标记为 `FLAGGED`
|
||||
|
||||
---
|
||||
|
||||
## 阶段 3:UI 完善(Week 4-5)
|
||||
|
||||
### 3.1 移动端适配优化(方向 A)
|
||||
|
||||
**目标**: 优化移动端布局和交互体验
|
||||
|
||||
**涉及的文件**:
|
||||
- `src/app/[locale]/layout.tsx`
|
||||
- `src/components/layout/MobileMenu.tsx` (新建)
|
||||
- `src/components/project/ProjectCard.tsx`
|
||||
- `src/components/project/ProjectList.tsx`
|
||||
- `src/app/[locale]/projects/[id]/page.tsx`
|
||||
|
||||
**具体变更**:
|
||||
|
||||
1. **MobileMenu.tsx**: 实现汉堡菜单
|
||||
- 使用 Radix UI Dialog 或自定义实现
|
||||
- 包含导航链接、语言切换、主题切换
|
||||
- 平滑的打开/关闭动画
|
||||
|
||||
2. **layout.tsx**: 替换现有的简单按钮
|
||||
- 在移动端显示 MobileMenu
|
||||
- 添加 `aria-label` 和键盘支持
|
||||
|
||||
3. **ProjectCard.tsx**: 优化移动端布局
|
||||
- 字体大小调整
|
||||
- 触摸目标至少 44x44px
|
||||
- 标签横向滚动或折叠
|
||||
|
||||
**验收检查**:
|
||||
- [ ] Chrome DevTools 模拟 375px 宽度下无布局溢出
|
||||
- [ ] 所有按钮/链接触摸区域 >= 44x44px
|
||||
- [ ] 移动端菜单正常工作
|
||||
|
||||
---
|
||||
|
||||
### 3.2 无障碍访问优化(方向 A)
|
||||
|
||||
**目标**: 通过 Lighthouse 无障碍审计
|
||||
|
||||
**涉及的文件**:
|
||||
- 所有组件文件
|
||||
- `src/app/layout.tsx`
|
||||
|
||||
**具体变更**:
|
||||
|
||||
1. **语义化 HTML**:
|
||||
- 使用 `<nav>`, `<main>`, `<article>`, `<section>`
|
||||
- 表单控件关联 `<label>`
|
||||
- 图片添加 `alt` 属性
|
||||
|
||||
2. **ARIA 增强**:
|
||||
- 按钮添加 `aria-label`(图标按钮)
|
||||
- 导航添加 `aria-current="page"`
|
||||
- 模态框添加 `aria-modal`, `role="dialog"`
|
||||
|
||||
3. **键盘导航**:
|
||||
- 焦点可见样式(`focus:ring-2`)
|
||||
- 跳过导航链接(Skip to content)
|
||||
- Tab 顺序合理
|
||||
|
||||
4. **色彩对比度**:
|
||||
- 确保文本对比度 >= 4.5:1
|
||||
- 大文本对比度 >= 3:1
|
||||
|
||||
**验收检查**:
|
||||
```bash
|
||||
# Lighthouse CLI 审计
|
||||
npx lighthouse http://localhost:3000/zh --only-categories=accessibility --output=json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 审核管理界面(方向 B)
|
||||
|
||||
**目标**: 创建管理员审核项目的页面(可选,可先用 API)
|
||||
|
||||
**涉及的文件**:
|
||||
- `src/app/[locale]/admin/review/page.tsx` (新建)
|
||||
- `src/app/[locale]/admin/review/ReviewList.tsx` (新建)
|
||||
- `src/app/[locale]/admin/review/QualityBadge.tsx` (新建)
|
||||
|
||||
**具体实现**:
|
||||
|
||||
1. **ReviewList.tsx**: 待审核项目列表
|
||||
- 显示项目基本信息、质量评分
|
||||
- 批量操作(批准/拒绝)
|
||||
- 筛选和排序
|
||||
|
||||
2. **QualityBadge.tsx**: 质量评分徽章
|
||||
- 高分(>=80): 绿色
|
||||
- 中分(50-79): 黄色
|
||||
- 低分(<50): 红色
|
||||
|
||||
**验收检查**:
|
||||
- [ ] 管理员可查看待审核项目列表
|
||||
- [ ] 可单个/批量更新审核状态
|
||||
- [ ] 显示质量评分和因素明细
|
||||
|
||||
---
|
||||
|
||||
## 阶段 4:集成与优化(Week 6)
|
||||
|
||||
### 4.1 标签规范化增强(方向 B)
|
||||
|
||||
**目标**: 增强 Tag Janitor API 支持更多规范化操作
|
||||
|
||||
**涉及的文件**:
|
||||
- `src/app/api/tags/maintenance/service.ts`
|
||||
- `src/lib/validations.ts`
|
||||
|
||||
**新增功能**:
|
||||
|
||||
1. **标签重命名**: 批量更新标签名称
|
||||
2. **标签分类**: 为标签添加分类(技术/应用/状态)
|
||||
3. **相似标签检测**: 基于名称相似度推荐合并
|
||||
|
||||
**Schema 扩展**:
|
||||
```typescript
|
||||
export const TagNormalizationSchema = z.object({
|
||||
apiKey: z.string().min(32),
|
||||
operations: z.array(z.union([
|
||||
z.object({ type: z.literal('rename'), tagId: z.string(), newName: z.string(), newNameEn: z.string().optional() }),
|
||||
z.object({ type: z.literal('categorize'), tagId: z.string(), category: z.enum(['tech', 'application', 'status']) }),
|
||||
z.object({ type: z.literal('merge'), sourceIds: z.array(z.string()), targetId: z.string() }),
|
||||
])),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 性能优化(方向 A)
|
||||
|
||||
**目标**: 优化首屏加载性能
|
||||
|
||||
**涉及的文件**:
|
||||
- `next.config.js`
|
||||
- `src/app/[locale]/page.tsx`
|
||||
- `src/components/` 相关组件
|
||||
|
||||
**具体优化**:
|
||||
|
||||
1. **图片优化**:
|
||||
- 使用 Next.js Image 组件
|
||||
- 配置图片优先级和占位符
|
||||
|
||||
2. **代码分割**:
|
||||
- 动态导入大型组件(如 MarkdownContent)
|
||||
- 使用 `loading.tsx` 实现页面级加载状态
|
||||
|
||||
3. **字体优化**:
|
||||
- 预加载关键字体
|
||||
- 使用 `font-display: swap`
|
||||
|
||||
**验收检查**:
|
||||
```bash
|
||||
# Lighthouse 性能审计
|
||||
npx lighthouse http://localhost:3000/zh --only-categories=performance --output=json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 阶段 5:测试与文档(Week 7-8)
|
||||
|
||||
### 5.1 单元测试
|
||||
|
||||
**涉及的文件**:
|
||||
- `src/lib/quality-scorer.test.ts` (新建)
|
||||
- `src/app/api/admin/review/service.test.ts` (新建)
|
||||
- `src/components/theme/ThemeProvider.test.tsx` (新建)
|
||||
|
||||
**测试覆盖**:
|
||||
- 质量评分算法边界情况
|
||||
- 审核状态转换逻辑
|
||||
- 主题切换持久化
|
||||
|
||||
---
|
||||
|
||||
### 5.2 E2E 测试
|
||||
|
||||
**涉及的文件**:
|
||||
- `tests/e2e/theme-toggle.spec.ts` (新建)
|
||||
- `tests/e2e/mobile-layout.spec.ts` (新建)
|
||||
- `tests/e2e/review-workflow.spec.ts` (新建)
|
||||
|
||||
---
|
||||
|
||||
### 5.3 文档更新
|
||||
|
||||
**涉及的文件**:
|
||||
- `CLAUDE.md`
|
||||
- `README.md`
|
||||
- `n8n-workflows/README.md`(如涉及 n8n 集成)
|
||||
|
||||
---
|
||||
|
||||
## 风险与缓解措施
|
||||
|
||||
| 风险 | 影响 | 可能性 | 缓解措施 |
|
||||
|------|------|--------|----------|
|
||||
| 主题切换导致样式不一致 | 高 | 中 | 建立完整的 dark: 类检查清单,使用 CSS 变量 |
|
||||
| 审核流程复杂度超预期 | 中 | 中 | 先实现 API,管理界面可延后 |
|
||||
| 质量评分算法不准确 | 中 | 中 | 先小规模测试,根据反馈迭代 |
|
||||
| 移动端适配工作量大 | 中 | 高 | 优先核心页面(首页、项目列表、项目详情) |
|
||||
| 无障碍改造影响现有设计 | 低 | 低 | 使用 Tailwind 的 focus: 类,不改变视觉设计 |
|
||||
|
||||
---
|
||||
|
||||
## 验证步骤
|
||||
|
||||
### 方向 A 验证清单
|
||||
1. 运行 `pnpm build` 确保无构建错误
|
||||
2. 运行 `pnpm lint` 确保无 ESLint 错误
|
||||
3. Chrome DevTools 切换设备模拟器测试响应式
|
||||
4. 运行 Lighthouse 审计(性能 + 无障碍)
|
||||
5. 手动测试主题切换在所有页面正常工作
|
||||
6. 键盘导航测试(仅使用 Tab/Enter/Space)
|
||||
|
||||
### 方向 B 验证清单
|
||||
1. 运行数据库迁移 `pnpm prisma migrate dev`
|
||||
2. 测试审核 API 端点(curl 或 Postman)
|
||||
3. 验证质量评分计算逻辑(单元测试)
|
||||
4. 测试 Tag Janitor API 新增功能
|
||||
5. 审核日志正确记录状态变更
|
||||
|
||||
---
|
||||
|
||||
## 文件变更总览
|
||||
|
||||
### 新建文件
|
||||
- `src/components/theme/ThemeProvider.tsx`
|
||||
- `src/components/theme/ThemeToggle.tsx`
|
||||
- `src/hooks/useTheme.ts`
|
||||
- `src/lib/quality-scorer.ts`
|
||||
- `src/app/api/admin/review/route.ts`
|
||||
- `src/app/api/admin/review/service.ts`
|
||||
- `src/components/layout/MobileMenu.tsx`
|
||||
- `src/app/[locale]/admin/review/page.tsx`(可选)
|
||||
- `src/app/[locale]/admin/review/ReviewList.tsx`(可选)
|
||||
- `src/app/[locale]/admin/review/QualityBadge.tsx`(可选)
|
||||
|
||||
### 修改文件
|
||||
- `prisma/schema.prisma`
|
||||
- `src/app/layout.tsx`
|
||||
- `src/app/[locale]/layout.tsx`
|
||||
- `src/lib/validations.ts`
|
||||
- `src/hooks/useProjects.ts`
|
||||
- `src/components/project/ProjectCard.tsx`
|
||||
- `src/components/project/ProjectList.tsx`
|
||||
- `src/messages/zh.json`
|
||||
- `src/messages/en.json`
|
||||
- `tailwind.config.ts`(可能需要调整)
|
||||
|
||||
---
|
||||
|
||||
## 下一步行动
|
||||
|
||||
确认此计划后,运行以下命令开始实施:
|
||||
|
||||
```bash
|
||||
/oh-my-claudecode:start-work next-phase-features
|
||||
```
|
||||
|
||||
建议优先级:
|
||||
1. **Phase 1.1** - 数据库 Schema 扩展(阻塞其他方向 B 任务)
|
||||
2. **Phase 1.2** - 主题系统基础(阻塞其他方向 A 任务)
|
||||
3. 之后两个方向可并行推进
|
||||
@@ -0,0 +1,88 @@
|
||||
# 暂停开发的功能
|
||||
|
||||
本目录存放暂停开发的功能的设计文档和计划,这些功能将在后续版本中继续开发。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
postponed-features/
|
||||
├── README.md # 本文件
|
||||
├── drop-tables.sql # 数据库表删除脚本
|
||||
├── keyword-cloud/ # AI 词云功能
|
||||
│ ├── 2026-01-25-keyword-cloud-system-design.md
|
||||
│ └── 2026-01-25-keyword-cloud-implementation.md
|
||||
├── timeline/ # AI 时间轴功能
|
||||
│ ├── 2025-01-25-ai-timeline-feature-design.md
|
||||
│ └── 2025-01-25-ai-timeline-implementation.md
|
||||
├── hotpot cloud/ # 词云设计原型
|
||||
└── timeline/ # 时间轴设计原型
|
||||
```
|
||||
|
||||
## 暂停功能说明
|
||||
|
||||
### 1. AI 词云 (Keyword Cloud)
|
||||
|
||||
**功能描述**: 自动化采集 Google Trends 数据,展示季度 AI 热点词汇词云。
|
||||
|
||||
**暂停原因**: 优先级调整,后续版本继续开发。
|
||||
|
||||
**技术栈**:
|
||||
- 前端: Next.js 15 + React + Tailwind CSS
|
||||
- 后端: Prisma + PostgreSQL
|
||||
- 自动化: n8n 工作流
|
||||
- 数据源: Google Trends API
|
||||
|
||||
**数据库表**:
|
||||
- `keywords` - 关键词数据
|
||||
- `quarters` - 季度元数据
|
||||
- `visual_style_rules` - 视觉样式规则
|
||||
- `keyword_cloud_error_logs` - 错误日志
|
||||
|
||||
### 2. AI 时间轴 (AI Timeline)
|
||||
|
||||
**功能描述**: 展示 AI 大语言模型的发展历程,从 2017 年 Transformer 到今天。
|
||||
|
||||
**暂停原因**: 优先级调整,后续版本继续开发。
|
||||
|
||||
**技术栈**:
|
||||
- 前端: Next.js 15 + React + Tailwind CSS
|
||||
- 后端: Prisma + PostgreSQL
|
||||
|
||||
**数据库表**:
|
||||
- `ai_events` - AI 事件数据
|
||||
|
||||
### 3. 博客 (Blog)
|
||||
|
||||
**功能描述**: 博客文章发布和管理系统。
|
||||
|
||||
**状态**: 未实现,仅保留了导航链接占位。
|
||||
|
||||
**暂停原因**: 优先级调整,后续版本继续开发。
|
||||
|
||||
## 恢复开发指南
|
||||
|
||||
当需要恢复这些功能时:
|
||||
|
||||
1. **恢复数据库表**:
|
||||
```bash
|
||||
# 参考 drop-tables.sql 中的表结构定义
|
||||
# 在 prisma/schema.prisma 中恢复对应的模型
|
||||
pnpm prisma migrate dev --name restore-postponed-features
|
||||
```
|
||||
|
||||
2. **恢复代码**:
|
||||
- 从 git 历史中恢复相关代码文件
|
||||
- 或参考设计文档重新实现
|
||||
|
||||
3. **恢复国际化消息**:
|
||||
- 在 `src/messages/zh.json` 和 `src/messages/en.json` 中添加对应的命名空间
|
||||
|
||||
4. **恢复导航链接**:
|
||||
- 在国际化消息的 `navigation` 部分添加对应的导航项
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [AI 词云系统设计](./keyword-cloud/2026-01-25-keyword-cloud-system-design.md)
|
||||
- [AI 词云实现文档](./keyword-cloud/2026-01-25-keyword-cloud-implementation.md)
|
||||
- [AI 时间轴功能设计](./timeline/2025-01-25-ai-timeline-feature-design.md)
|
||||
- [AI 时间轴实现文档](./timeline/2025-01-25-ai-timeline-implementation.md)
|
||||
@@ -0,0 +1,45 @@
|
||||
-- =====================================================
|
||||
-- 暂停功能的数据库表删除脚本
|
||||
-- 创建时间: 2026-02-21
|
||||
-- 说明: 删除 AI 词云和 AI 时间轴相关的数据库表
|
||||
-- 警告: 执行前请确保已备份相关数据
|
||||
-- =====================================================
|
||||
|
||||
-- ================================
|
||||
-- AI 词云相关表
|
||||
-- ================================
|
||||
|
||||
-- 删除关键词表 (依赖 quarters 表)
|
||||
DROP TABLE IF EXISTS keywords CASCADE;
|
||||
|
||||
-- 删除季度元数据表
|
||||
DROP TABLE IF EXISTS quarters CASCADE;
|
||||
|
||||
-- 删除视觉样式规则表
|
||||
DROP TABLE IF EXISTS visual_style_rules CASCADE;
|
||||
|
||||
-- 删除词云错误日志表
|
||||
DROP TABLE IF EXISTS keyword_cloud_error_logs CASCADE;
|
||||
|
||||
-- ================================
|
||||
-- AI 时间轴相关表
|
||||
-- ================================
|
||||
|
||||
-- 删除 AI 事件表
|
||||
DROP TABLE IF EXISTS ai_events CASCADE;
|
||||
|
||||
-- ================================
|
||||
-- 清理相关索引 (PostgreSQL 会自动删除)
|
||||
-- ================================
|
||||
|
||||
-- 注意: 以下索引会随表删除自动清理
|
||||
-- idx_keyword_quarterId
|
||||
-- idx_keyword_trendScore
|
||||
-- idx_keyword_word
|
||||
-- idx_quarter_displayOrder
|
||||
-- idx_quarter_quarter
|
||||
-- idx_visualStyleRule_enabled
|
||||
-- idx_visualStyleRule_scoreRange
|
||||
-- idx_keywordCloudErrorLog_errorType
|
||||
-- idx_keywordCloudErrorLog_quarter
|
||||
-- ai_events 的 eventDate 和 createdAt 索引
|
||||
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
@@ -226,92 +226,6 @@ API Submitter Agent (提交到生产环境 API)
|
||||
/discover-projects all --batch=5
|
||||
```
|
||||
|
||||
### Keyword Cloud System (季度 AI 热点词云)
|
||||
|
||||
**功能**: 自动化采集 Google Trends 数据,展示季度 AI 热点词汇词云。
|
||||
|
||||
**数据流**: n8n 工作流 → AI 清洗 → 规则匹配 → PostgreSQL → Next.js 前端
|
||||
|
||||
#### 数据库表
|
||||
- `Quarter`: 季度元数据(quarter, title, titleEn, subtitle, subtitleEn, displayOrder, isActive)
|
||||
- `Keyword`: 关键词数据(word, trendScore, description, visualConfig)
|
||||
- `VisualStyleRule`: 视觉样式规则配置(name, minScore, maxScore, color, size, border, rotation)
|
||||
- `KeywordCloudErrorLog`: 错误日志(quarter, keyword, errorType, errorMessage)
|
||||
|
||||
#### API 端点
|
||||
- `GET /api/keyword-cloud/quarters`: 获取季度列表(支持 `isActive` 过滤)
|
||||
- `GET /api/keyword-cloud/keywords/[quarter]`: 获取指定季度的关键词
|
||||
- `GET /api/keyword-cloud/rules`: 获取视觉样式规则配置
|
||||
- `POST /api/keyword-cloud/keywords`: 批量写入关键词(n8n 使用,需 API Key 认证)
|
||||
- `GET /api/keyword-cloud/health`: 健康检查端点(返回系统统计信息)
|
||||
|
||||
#### 前端路由
|
||||
- `/[locale]/keyword-cloud`: 词云展示页面
|
||||
|
||||
#### 前端组件
|
||||
- **Location**: `src/app/[locale]/keyword-cloud/components/`
|
||||
- `CloudWord`: 单个词汇组件(支持颜色、大小、边框、旋转、悬停弹出框)
|
||||
- `QuarterNavigator`: 季度导航组件(前后切换)
|
||||
- `ProgressIndicator`: 进度条组件(显示季度进度)
|
||||
- `KeywordCloud`: 主容器组件(集成所有子组件,客户端组件)
|
||||
|
||||
#### 国际化支持
|
||||
- **消息键**: `src/messages/{locale}.json` 中的 `keywordCloud` 命名空间
|
||||
- **支持的字段**:
|
||||
- `metaTitle`/`metaDescription`: SEO 元数据
|
||||
- `badge`: 页面徽章文本
|
||||
- `title`/`titleHighlight`: 主标题(支持高亮)
|
||||
- `subtitle`: 副标题描述
|
||||
- `loading`/`loadFailed`/`retry`: 加载状态文本
|
||||
- `hotKeyword`: 热门词汇提示(支持 `{word}` 参数替换)
|
||||
- **导航菜单**: `navigation.keywordCloud` 键控制导航栏显示
|
||||
- **添加新翻译**: 更新 `src/messages/zh.json` 和 `src/messages/en.json` 中的 `keywordCloud` 部分
|
||||
|
||||
#### 数据访问层
|
||||
- **Location**: `src/hooks/useKeywordCloud.ts` (服务器端函数)
|
||||
- **Functions**:
|
||||
- `getAllQuarters()`: 获取所有季度列表
|
||||
- `getQuarterByQuarter()`: 获取单个季度详情(含关键词计数)
|
||||
- `getKeywordsByQuarter()`: 获取指定季度的所有关键词
|
||||
- `getVisualStyleRules()`: 获取视觉样式规则
|
||||
- `upsertQuarter()`: 创建或更新季度
|
||||
- `createKeywords()`: 批量创建关键词
|
||||
- `logKeywordCloudError()`: 记录错误日志
|
||||
|
||||
#### 客户端 Hook
|
||||
- **Location**: `src/hooks/useKeywordCloudClient.ts`
|
||||
- **Function**: `useKeywordCloud(quarter)` - 响应式获取季度关键词数据
|
||||
|
||||
#### n8n 工作流
|
||||
- **配置文件**: `n8n-workflows/keyword-cloud-workflow.json`
|
||||
- **文档**: `n8n-workflows/README.md`
|
||||
- **流程**:
|
||||
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: 写入数据库
|
||||
|
||||
#### 初始化数据
|
||||
```bash
|
||||
# 运行种子数据脚本(创建视觉规则和示例数据)
|
||||
pnpm tsx scripts/seed-keyword-cloud.ts
|
||||
```
|
||||
|
||||
#### 测试 API
|
||||
```bash
|
||||
# 运行 API 测试脚本
|
||||
set -a && source .env.local && set +a && npx tsx scripts/test-keyword-api.ts
|
||||
```
|
||||
|
||||
#### 环境变量
|
||||
- `WEBHOOK_API_KEY`: n8n 工作流使用的 API 密钥(必需)
|
||||
- n8n 环境变量(在 n8n 中设置):
|
||||
- `API_URL`: API 端点 URL(如 `http://localhost:3000`)
|
||||
- `API_KEY`: 与 `WEBHOOK_API_KEY` 相同
|
||||
|
||||
## MCP Servers Usage (按需使用)
|
||||
|
||||
1. **context7**: 不确定 API 用法时查阅最新文档
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# n8n-workflows/ - Automation Workflows
|
||||
|
||||
<!-- Parent: ../AGENTS.md -->
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
n8n workflow definitions for automated data collection, project discovery, and keyword cloud generation. Workflows sync with production n8n instance.
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `keyword-cloud-workflow.json` | Google Trends → Keywords API |
|
||||
| `README.md` | Workflow documentation |
|
||||
|
||||
## PRODUCTION WORKFLOWS (n8n Instance)
|
||||
|
||||
| Workflow | Status | Nodes | Purpose |
|
||||
|----------|--------|-------|---------|
|
||||
| 项目描述向量化 | ✅ Active | 11 | Generate project embeddings for RAG |
|
||||
| Github项目分析入库 | ⏸️ Inactive | 16 | Analyze & ingest GitHub repos |
|
||||
| RAG项目搜索 | ✅ Active | 6 | AI-powered vector search |
|
||||
| 每日Github Trending项目计划新增 | ✅ Active | 17 | Daily trending → discovery tasks |
|
||||
| Topic项目计划新增 | ⏸️ Inactive | 16 | Topic-based discovery |
|
||||
|
||||
## DATA FLOW
|
||||
|
||||
```
|
||||
GitHub Trending API
|
||||
↓
|
||||
每日Github Trending项目计划新增
|
||||
↓
|
||||
ProjectDiscoveryTask (PENDING)
|
||||
↓
|
||||
Content Explorer Agent (Claude)
|
||||
↓
|
||||
Project Ingestion API
|
||||
↓
|
||||
项目描述向量化 → Embeddings (pgvector)
|
||||
↓
|
||||
RAG项目搜索 ← User Query
|
||||
```
|
||||
|
||||
## FOR AI AGENTS
|
||||
|
||||
### When Modifying Workflows
|
||||
|
||||
1. Export from n8n UI → update JSON file
|
||||
2. Document changes in README.md
|
||||
3. Test with n8n-mcp tools before production
|
||||
|
||||
### Related API Endpoints
|
||||
|
||||
- `POST /api/discovery/tasks` - Create discovery tasks
|
||||
- `POST /api/keyword-cloud/keywords` - Bulk keyword upload
|
||||
- `POST /api/tags/maintenance` - Tag cleanup (n8n integration)
|
||||
|
||||
### Environment Variables (n8n)
|
||||
|
||||
- `API_URL` - Production API endpoint
|
||||
- `API_KEY` - Same as `WEBHOOK_API_KEY`
|
||||
|
||||
<!-- MANUAL: Additional notes can be added below -->
|
||||
@@ -1,117 +0,0 @@
|
||||
# n8n 工作流文档
|
||||
|
||||
本目录包含 Agent Park 的 n8n 工作流配置。
|
||||
|
||||
## 工作流清单
|
||||
|
||||
| 文件 | 功能 |
|
||||
| --- | --- |
|
||||
| `keyword-cloud-workflow.json` | Google Trends → 关键词词云入库 |
|
||||
| `tag-janitor-workflow.json` | 每日 AI 标签合并与 `nameEn` 补全 |
|
||||
|
||||
## 关键词词云工作流
|
||||
|
||||
**文件**: `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**: 写入数据库
|
||||
|
||||
### 环境变量
|
||||
|
||||
在 n8n 中设置以下环境变量:
|
||||
|
||||
| 变量名 | 说明 | 示例值 |
|
||||
|--------|------|--------|
|
||||
| `API_URL` | API 端点 URL | `http://localhost:3000` (本地) 或生产环境 URL |
|
||||
| `API_KEY` | Webhook API Key | 与项目的 `WEBHOOK_API_KEY` 相同 |
|
||||
|
||||
### 配置步骤
|
||||
|
||||
1. **导入工作流**:
|
||||
- 打开 n8n 界面
|
||||
- 点击 "Import from File"
|
||||
- 选择 `keyword-cloud-workflow.json`
|
||||
|
||||
2. **配置 Google Trends 节点**:
|
||||
- 确保已安装 `@gamal.dev/n8n-nodes-google-trends` 包
|
||||
- 根据需要调整搜索关键词列表
|
||||
|
||||
3. **配置环境变量**:
|
||||
- 在 n8n 设置中添加环境变量
|
||||
- 或在每个节点的表达式字段中使用具体值
|
||||
|
||||
4. **手动执行测试**:
|
||||
- 点击 "Execute Workflow" 按钮
|
||||
- 检查每个节点的输出
|
||||
- 验证数据库中的数据
|
||||
|
||||
### API 端点
|
||||
|
||||
工作流调用以下 API 端点:
|
||||
|
||||
- `GET {API_URL}/api/keyword-cloud/rules` - 获取视觉规则
|
||||
- `POST {API_URL}/api/keyword-cloud/keywords` - 批量写入关键词
|
||||
|
||||
## Tag Janitor 工作流
|
||||
|
||||
**文件**: `tag-janitor-workflow.json`
|
||||
|
||||
### 功能
|
||||
|
||||
每日自动拉取标签,按分桶分块交给 AI Agent 处理,并通过结构化输出解析器强制 JSON 格式,最终调用维护 API 执行合并与 `nameEn` 补全。
|
||||
|
||||
### 执行流程
|
||||
|
||||
1. **Schedule Trigger**: 每日 UTC 03:00 触发
|
||||
2. **Fetch Tags**: 调用 `GET /api/tags` 获取标签和项目计数
|
||||
3. **Prepare Chunk Batches**: 预处理并按 bucket + chunk 拆分(默认每块 80)
|
||||
4. **Tag Merge Agent + Structured Output Parser**: Agent 生成计划,Parser 强制输出 schema
|
||||
5. **Validate Plan**: 校验覆盖率(`reviewedTagIds` 必须覆盖整块)、ID 合法性、自合并
|
||||
6. **Retry Loop**: 校验失败会自动重试(默认 2 次,可配置)
|
||||
7. **Execute Maintenance**: 仅对校验通过的块调用 `POST /api/tags/maintenance`
|
||||
8. **Summarize Results**: 汇总 executed/noop/failed_validation/failed_execute
|
||||
|
||||
### API 端点
|
||||
|
||||
- `GET {API_URL}/api/tags` - 拉取标签列表(含 `_count.projects`)
|
||||
- `POST {API_URL}/api/tags/maintenance` - 执行批量 updates/merges
|
||||
|
||||
### 环境变量
|
||||
|
||||
在 n8n 进程环境中配置:
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `SITE_BASE_URL` | 站点地址 | 无 |
|
||||
| `WEBHOOK_API_KEY` | 维护 API 鉴权密钥 | 无 |
|
||||
| `TAG_JANITOR_CHUNK_SIZE` | 每块标签数量 | `80` |
|
||||
| `TAG_JANITOR_MAX_RETRIES` | 校验失败自动重试次数 | `2` |
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. **不要全量一次喂模型**:
|
||||
- 900+ 标签必须分块,否则大概率返回不完整。
|
||||
|
||||
2. **结构化输出是硬约束**:
|
||||
- 由 `Structured Output Parser` 保证格式。
|
||||
- `Validate Plan` 仍会做二次校验,防止“格式正确但内容不完整”。
|
||||
|
||||
3. **失败块不会误执行**:
|
||||
- 校验不通过的块不会调用维护 API,会在 summary 中归类为失败并给出原因。
|
||||
|
||||
### 扩展建议
|
||||
|
||||
- 将 `failed_*` 摘要接入 Slack/Telegram 告警
|
||||
- 对 `failed_validation` 块做二次人工审核队列
|
||||
- 在预处理阶段先做字符串近似聚类,进一步减少 LLM token 消耗
|
||||
@@ -1,197 +0,0 @@
|
||||
{
|
||||
"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",
|
||||
"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]
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
{
|
||||
"name": "Tag Janitor - Daily Cleanup",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [
|
||||
{
|
||||
"triggerAtHour": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "schedule",
|
||||
"name": "Daily 3AM UTC",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"position": [0, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "={{$env.SITE_BASE_URL}}/api/tags",
|
||||
"options": {}
|
||||
},
|
||||
"id": "fetch-tags",
|
||||
"name": "Fetch Tags",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [220, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const response = $input.first().json;\nif (!response.success) {\n throw new Error('Failed to fetch tags: ' + JSON.stringify(response));\n}\n\nconst rawTags = Array.isArray(response.tags) ? response.tags : [];\nconst chunkSize = Math.max(20, Math.min(120, Number.parseInt($env.TAG_JANITOR_CHUNK_SIZE || '80', 10) || 80));\n\nconst normalizedTags = rawTags\n .map((tag) => ({\n id: tag.id,\n name: String(tag.name || '').trim(),\n nameEn: String(tag.nameEn || '').trim(),\n projectCount: Number(tag?._count?.projects || 0),\n }))\n .filter((tag) => tag.id && tag.name);\n\nnormalizedTags.sort((a, b) => b.projectCount - a.projectCount || a.name.localeCompare(b.name));\n\nif (normalizedTags.length === 0) {\n return [{\n json: {\n chunkId: 'empty-0',\n bucketKey: 'empty',\n tags: [],\n totalInChunk: 0,\n totalTags: 0,\n retryCount: 0,\n retryFeedback: '',\n status: 'noop',\n shouldExecute: false\n }\n }];\n}\n\nconst bucketMap = new Map();\nfor (const tag of normalizedTags) {\n const seed = (tag.nameEn || tag.name).toLowerCase().trim();\n const normalized = seed.replace(/[^a-z0-9\\u4e00-\\u9fa5]+/g, '');\n\n let bucketKey = 'misc';\n if (normalized.length > 0) {\n const first = normalized[0];\n if (/[a-z0-9]/.test(first)) {\n bucketKey = 'en-' + first;\n } else if (/[\\u4e00-\\u9fa5]/.test(first)) {\n bucketKey = 'zh-' + first;\n }\n }\n\n const list = bucketMap.get(bucketKey) || [];\n list.push(tag);\n bucketMap.set(bucketKey, list);\n}\n\nconst chunks = [];\nfor (const [bucketKey, tags] of bucketMap.entries()) {\n tags.sort((a, b) => b.projectCount - a.projectCount || a.name.localeCompare(b.name));\n for (let i = 0; i < tags.length; i += chunkSize) {\n chunks.push({\n bucketKey,\n tags: tags.slice(i, i + chunkSize)\n });\n }\n}\n\nchunks.sort((a, b) => b.tags.length - a.tags.length);\n\nreturn chunks.map((chunk, idx) => ({\n json: {\n chunkId: chunk.bucketKey + '-' + (idx + 1),\n bucketKey: chunk.bucketKey,\n tags: chunk.tags,\n totalInChunk: chunk.tags.length,\n totalTags: normalizedTags.length,\n retryCount: 0,\n retryFeedback: ''\n }\n}));"
|
||||
},
|
||||
"id": "prepare-chunks",
|
||||
"name": "Prepare Chunk Batches",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [460, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"text": "=Chunk {{$json.chunkId}} from {{$json.bucketKey}}.\\nThis chunk contains {{$json.totalInChunk}} tags out of {{$json.totalTags}} total tags.\\nRetry count: {{$json.retryCount || 0}}.\\nRetry feedback: {{$json.retryFeedback || 'none'}}.\\n\\nOutput MUST be JSON and MUST follow the connected structured parser schema.\\nOnly use tag IDs from this chunk.\\n\\nChunk tags JSON:\\n{{JSON.stringify($json.tags)}}",
|
||||
"options": {
|
||||
"systemMessage": "You are a strict tag-governance agent.\\n\\nFor the current chunk only:\\n1) Find semantic duplicates and output merges\\n2) Fill missing or low-quality English names and output updates\\n3) Return reviewedTagIds that fully cover every tag ID in this chunk\\n4) Put uncertain tags into unresolvedTagIds\\n\\nHard rules:\\n- Never reference tag IDs outside the current chunk\\n- Prefer existing target ID with higher projectCount\\n- Do not self-merge\\n- Return no markdown, no prose, JSON only"
|
||||
},
|
||||
"promptType": "define",
|
||||
"hasOutputParser": true
|
||||
},
|
||||
"id": "tag-merge-agent",
|
||||
"name": "Tag Merge Agent",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 1.8,
|
||||
"position": [700, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": {
|
||||
"__rl": true,
|
||||
"mode": "list",
|
||||
"value": "gpt-4.1-mini",
|
||||
"cachedResultName": "gpt-4.1-mini"
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"id": "openai-model",
|
||||
"name": "OpenAI Chat Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1.2,
|
||||
"position": [700, 220]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"schemaType": "manual",
|
||||
"inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"reviewedTagIds\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"unresolvedTagIds\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"merges\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"target\": {\n \"oneOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"id\": { \"type\": \"string\" }\n },\n \"required\": [\"id\"]\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"nameEn\": { \"type\": \"string\" }\n },\n \"required\": [\"name\", \"nameEn\"]\n }\n ]\n },\n \"sourceTagIds\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\"target\", \"sourceTagIds\"]\n }\n },\n \"updates\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"tagId\": { \"type\": \"string\" },\n \"nameEn\": { \"type\": \"string\" }\n },\n \"required\": [\"tagId\", \"nameEn\"]\n }\n }\n },\n \"required\": [\"reviewedTagIds\", \"unresolvedTagIds\", \"merges\", \"updates\"]\n}",
|
||||
"autoFix": true
|
||||
},
|
||||
"id": "structured-parser",
|
||||
"name": "Structured Output Parser",
|
||||
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
|
||||
"typeVersion": 1.2,
|
||||
"position": [960, 220]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const maxRetries = Math.max(0, Number.parseInt($env.TAG_JANITOR_MAX_RETRIES || '2', 10) || 2);\n\nfunction normalizeIds(input) {\n if (!Array.isArray(input)) return [];\n return [...new Set(input.map((id) => String(id || '').trim()).filter(Boolean))];\n}\n\nfunction safeArray(input) {\n return Array.isArray(input) ? input : [];\n}\n\nreturn $input.all().map((item) => {\n const row = item.json || {};\n const parsed = row.output && typeof row.output === 'object' ? row.output : {};\n\n const tags = safeArray(row.tags);\n const chunkTagIds = normalizeIds(tags.map((tag) => tag.id));\n const chunkTagIdSet = new Set(chunkTagIds);\n\n const reviewedTagIds = normalizeIds(parsed.reviewedTagIds);\n const unresolvedTagIds = normalizeIds(parsed.unresolvedTagIds);\n\n const updates = safeArray(parsed.updates).flatMap((update) => {\n if (!update || typeof update !== 'object') return [];\n const tagId = String(update.tagId || '').trim();\n const nameEn = String(update.nameEn || '').trim();\n if (!tagId || !nameEn) return [];\n return [{ tagId, nameEn }];\n });\n\n const merges = safeArray(parsed.merges).flatMap((merge) => {\n if (!merge || typeof merge !== 'object') return [];\n const sourceTagIds = normalizeIds(merge.sourceTagIds);\n if (sourceTagIds.length === 0) return [];\n\n const target = merge.target;\n if (!target || typeof target !== 'object') return [];\n\n if (typeof target.id === 'string' && target.id.trim()) {\n return [{\n target: { id: target.id.trim() },\n sourceTagIds\n }];\n }\n\n const name = String(target.name || '').trim();\n const nameEn = String(target.nameEn || '').trim();\n if (!name || !nameEn) return [];\n\n return [{\n target: { name, nameEn },\n sourceTagIds\n }];\n });\n\n const errors = [];\n\n const reviewedSet = new Set(reviewedTagIds);\n const missingReviewed = chunkTagIds.filter((id) => !reviewedSet.has(id));\n if (missingReviewed.length > 0) {\n errors.push(`Missing reviewedTagIds coverage: ${missingReviewed.join(', ')}`);\n }\n\n for (const update of updates) {\n if (!chunkTagIdSet.has(update.tagId)) {\n errors.push(`Update references unknown tagId in chunk: ${update.tagId}`);\n }\n }\n\n for (const merge of merges) {\n for (const sourceTagId of merge.sourceTagIds) {\n if (!chunkTagIdSet.has(sourceTagId)) {\n errors.push(`Merge sourceTagId not in chunk: ${sourceTagId}`);\n }\n }\n\n if ('id' in merge.target) {\n if (!chunkTagIdSet.has(merge.target.id)) {\n errors.push(`Merge target.id not in chunk: ${merge.target.id}`);\n }\n if (merge.sourceTagIds.includes(merge.target.id)) {\n errors.push(`Self merge detected: ${merge.target.id}`);\n }\n }\n }\n\n const retryCount = Number(row.retryCount || 0);\n const hasValidationErrors = errors.length > 0;\n const needsRetry = hasValidationErrors && retryCount < maxRetries;\n const shouldExecute = !hasValidationErrors && (updates.length > 0 || merges.length > 0);\n\n let status = 'noop';\n if (needsRetry) status = 'retry';\n else if (hasValidationErrors) status = 'failed_validation';\n else if (shouldExecute) status = 'ready';\n\n return {\n json: {\n ...row,\n validated: {\n reviewedTagIds,\n unresolvedTagIds,\n updates,\n merges\n },\n validationErrors: errors,\n needsRetry,\n shouldExecute,\n status\n }\n };\n});"
|
||||
},
|
||||
"id": "validate-plan",
|
||||
"name": "Validate Plan",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [960, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"boolean": [
|
||||
{
|
||||
"value1": "={{$json.needsRetry}}",
|
||||
"value2": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "retry-needed",
|
||||
"name": "Retry Needed?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2,
|
||||
"position": [1180, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "return $input.all().map((item) => {\n const row = item.json || {};\n const nextRetryCount = Number(row.retryCount || 0) + 1;\n const retryFeedback = (row.validationErrors || []).join(' | ');\n\n return {\n json: {\n ...row,\n retryCount: nextRetryCount,\n retryFeedback\n }\n };\n});"
|
||||
},
|
||||
"id": "increment-retry",
|
||||
"name": "Increment Retry",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1400, -120]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"boolean": [
|
||||
{
|
||||
"value1": "={{$json.shouldExecute}}",
|
||||
"value2": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "execute-needed",
|
||||
"name": "Execute Needed?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2,
|
||||
"position": [1400, 120]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{$env.SITE_BASE_URL}}/api/tags/maintenance",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={\n \"apiKey\": \"{{$env.WEBHOOK_API_KEY}}\",\n \"updates\": {{JSON.stringify($json.validated.updates || [])}},\n \"merges\": {{JSON.stringify($json.validated.merges || [])}}\n}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "execute-maintenance",
|
||||
"name": "Execute Maintenance",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1620, 20],
|
||||
"continueOnFail": true
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "return $input.all().map((item) => {\n const row = item.json || {};\n return {\n json: {\n status: row.status || 'noop',\n chunkId: row.chunkId || null,\n retryCount: Number(row.retryCount || 0),\n validationErrors: row.validationErrors || [],\n updatedCount: 0,\n mergedCount: 0,\n deletedTagCount: 0\n }\n };\n});"
|
||||
},
|
||||
"id": "build-skip-result",
|
||||
"name": "Build Skipped Result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1620, 220]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "return $input.all().map((item) => {\n const row = item.json || {};\n\n if (row.success === true) {\n return {\n json: {\n status: 'executed',\n chunkId: null,\n retryCount: null,\n validationErrors: [],\n updatedCount: Number(row?.result?.updatedCount || 0),\n mergedCount: Number(row?.result?.mergedCount || 0),\n deletedTagCount: Number(row?.result?.deletedTagCount || 0)\n }\n };\n }\n\n const message = row?.error?.message || row?.message || row?.error || 'Unknown execution error';\n\n return {\n json: {\n status: 'failed_execute',\n chunkId: null,\n retryCount: null,\n validationErrors: [String(message)],\n updatedCount: 0,\n mergedCount: 0,\n deletedTagCount: 0\n }\n };\n});"
|
||||
},
|
||||
"id": "normalize-execution",
|
||||
"name": "Normalize Execution Result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1840, 20]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "append"
|
||||
},
|
||||
"id": "merge-results",
|
||||
"name": "Merge Results",
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 3.2,
|
||||
"position": [2060, 120]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const rows = $input.all().map((item) => item.json || {});\n\nconst summary = {\n status: 'success',\n chunkRuns: rows.length,\n executedRuns: 0,\n noopRuns: 0,\n retryRuns: 0,\n failedValidationRuns: 0,\n failedExecuteRuns: 0,\n updatedCount: 0,\n mergedCount: 0,\n deletedTagCount: 0,\n failures: [],\n timestamp: new Date().toISOString()\n};\n\nfor (const row of rows) {\n if (row.status === 'executed') {\n summary.executedRuns += 1;\n summary.updatedCount += Number(row.updatedCount || 0);\n summary.mergedCount += Number(row.mergedCount || 0);\n summary.deletedTagCount += Number(row.deletedTagCount || 0);\n continue;\n }\n\n if (row.status === 'noop') {\n summary.noopRuns += 1;\n continue;\n }\n\n if (row.status === 'retry') {\n summary.retryRuns += 1;\n continue;\n }\n\n if (row.status === 'failed_validation') {\n summary.failedValidationRuns += 1;\n summary.failures.push({\n type: 'failed_validation',\n chunkId: row.chunkId || null,\n reasons: row.validationErrors || []\n });\n continue;\n }\n\n if (row.status === 'failed_execute') {\n summary.failedExecuteRuns += 1;\n summary.failures.push({\n type: 'failed_execute',\n chunkId: row.chunkId || null,\n reasons: row.validationErrors || []\n });\n }\n}\n\nif (summary.failedValidationRuns > 0 || summary.failedExecuteRuns > 0) {\n summary.status = 'partial_failed';\n}\n\nsummary.failures = summary.failures.slice(0, 20);\n\nreturn [{ json: summary }];"
|
||||
},
|
||||
"id": "summarize-results",
|
||||
"name": "Summarize Results",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [2280, 120]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Daily 3AM UTC": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Fetch Tags",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fetch Tags": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Prepare Chunk Batches",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Prepare Chunk Batches": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Tag Merge Agent",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"OpenAI Chat Model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Tag Merge Agent",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Structured Output Parser": {
|
||||
"ai_outputParser": [
|
||||
[
|
||||
{
|
||||
"node": "Tag Merge Agent",
|
||||
"type": "ai_outputParser",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Tag Merge Agent": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Validate Plan",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Validate Plan": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Retry Needed?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Retry Needed?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Increment Retry",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Execute Needed?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Increment Retry": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Tag Merge Agent",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Execute Needed?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Execute Maintenance",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Build Skipped Result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Execute Maintenance": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Normalize Execution Result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Normalize Execution Result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge Results",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build Skipped Result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge Results",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Merge Results": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Summarize Results",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true
|
||||
}
|
||||
}
|
||||
@@ -24,40 +24,6 @@ model ExternalLink {
|
||||
@@map("external_links")
|
||||
}
|
||||
|
||||
model KeywordCloudErrorLog {
|
||||
id Int @id @default(autoincrement())
|
||||
quarter String
|
||||
keyword String?
|
||||
errorType String
|
||||
errorMessage String
|
||||
rawData Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([errorType], map: "idx_keywordCloudErrorLog_errorType")
|
||||
@@index([quarter], map: "idx_keywordCloudErrorLog_quarter")
|
||||
@@map("keyword_cloud_error_logs")
|
||||
}
|
||||
|
||||
model Keyword {
|
||||
id Int @id @default(autoincrement())
|
||||
word String
|
||||
trendScore Int
|
||||
quarterId Int
|
||||
description String
|
||||
descriptionEn String?
|
||||
detailPoints Json
|
||||
detailPointsEn Json?
|
||||
visualConfig Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
quarter Quarter @relation(fields: [quarterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([quarterId], map: "idx_keyword_quarterId")
|
||||
@@index([trendScore], map: "idx_keyword_trendScore")
|
||||
@@index([word], map: "idx_keyword_word")
|
||||
@@map("keywords")
|
||||
}
|
||||
|
||||
model ProjectDiscoveryTask {
|
||||
id String @id @default(cuid())
|
||||
status TaskStatus @default(PENDING)
|
||||
@@ -117,24 +83,6 @@ model Project {
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
model Quarter {
|
||||
id Int @id @default(autoincrement())
|
||||
quarter String @unique
|
||||
title String
|
||||
titleEn String?
|
||||
subtitle String?
|
||||
subtitleEn String?
|
||||
displayOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
keywords Keyword[]
|
||||
|
||||
@@index([displayOrder], map: "idx_quarter_displayOrder")
|
||||
@@index([quarter], map: "idx_quarter_quarter")
|
||||
@@map("quarters")
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
@@ -147,25 +95,6 @@ model Tag {
|
||||
@@map("tags")
|
||||
}
|
||||
|
||||
model VisualStyleRule {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
minScore Int
|
||||
maxScore Int
|
||||
color String
|
||||
size String
|
||||
border String
|
||||
rotation String?
|
||||
priority Int @default(0)
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([enabled], map: "idx_visualStyleRule_enabled")
|
||||
@@index([minScore, maxScore], map: "idx_visualStyleRule_scoreRange")
|
||||
@@map("visual_style_rules")
|
||||
}
|
||||
|
||||
enum LinkType {
|
||||
WEBSITE
|
||||
GITHUB
|
||||
@@ -185,24 +114,3 @@ enum TaskStatus {
|
||||
FAILED
|
||||
}
|
||||
|
||||
// ================================
|
||||
// AI Timeline System Models
|
||||
// ================================
|
||||
|
||||
model AIEvent {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
titleEn String?
|
||||
eventDate DateTime
|
||||
description String
|
||||
descriptionEn String?
|
||||
imageUrl String
|
||||
sourceUrl String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([eventDate(sort: Desc)])
|
||||
@@index([createdAt])
|
||||
@@map("ai_events")
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const historicalEvents = [
|
||||
{
|
||||
title: 'Attention Is All You Need',
|
||||
titleEn: 'Attention Is All You Need',
|
||||
eventDate: new Date('2017-06-12T00:00:00Z'),
|
||||
description: 'Google 团队发表 Transformer 论文,提出自注意力机制,彻底改变 NLP 领域',
|
||||
descriptionEn: 'Google team publishes Transformer paper, proposing self-attention mechanism that revolutionizes NLP',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||||
sourceUrl: 'https://arxiv.org/abs/1706.03762',
|
||||
},
|
||||
{
|
||||
title: 'GPT-1 发布',
|
||||
titleEn: 'GPT-1 Release',
|
||||
eventDate: new Date('2018-06-11T00:00:00Z'),
|
||||
description: 'OpenAI 发布第一代生成式预训练 Transformer 模型,展示无监督学习的潜力',
|
||||
descriptionEn: 'OpenAI releases first Generative Pre-trained Transformer model, demonstrating potential of unsupervised learning',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||||
sourceUrl: 'https://s3-us-west-2.amazonaws.com/openai-assets/research-covers/language-unsupervised/language-understanding-paper.pdf',
|
||||
},
|
||||
{
|
||||
title: 'BERT 发布',
|
||||
titleEn: 'BERT Release',
|
||||
eventDate: new Date('2018-10-11T00:00:00Z'),
|
||||
description: 'Google 发布双向编码器表示 Transformer,在 11 项 NLP 任务中创 SOTA',
|
||||
descriptionEn: 'Google releases Bidirectional Encoder Representations from Transformers, achieving SOTA on 11 NLP tasks',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||||
sourceUrl: 'https://arxiv.org/abs/1810.04805',
|
||||
},
|
||||
{
|
||||
title: 'GPT-2 发布',
|
||||
titleEn: 'GPT-2 Release',
|
||||
eventDate: new Date('2019-02-14T00:00:00Z'),
|
||||
description: 'OpenAI 发布 15 亿参数的 GPT-2,因"太危险"而不敢全部发布',
|
||||
descriptionEn: 'OpenAI releases 1.5B parameter GPT-2, initially withholding full release due to concerns about misuse',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||||
sourceUrl: 'https://openai.com/research/better-language-models',
|
||||
},
|
||||
{
|
||||
title: 'GPT-3 发布',
|
||||
titleEn: 'GPT-3 Release',
|
||||
eventDate: new Date('2020-05-28T00:00:00Z'),
|
||||
description: 'OpenAI 发布 1750 亿参数的 GPT-3,展示 few-shot 学习的强大能力',
|
||||
descriptionEn: 'OpenAI releases 175B parameter GPT-3, demonstrating powerful few-shot learning capabilities',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||||
sourceUrl: 'https://arxiv.org/abs/2005.14165',
|
||||
},
|
||||
{
|
||||
title: 'GitHub Copilot 发布',
|
||||
titleEn: 'GitHub Copilot Release',
|
||||
eventDate: new Date('2021-06-29T00:00:00Z'),
|
||||
description: 'GitHub 和 OpenAI 发布 AI 编程助手,基于 Codex 模型',
|
||||
descriptionEn: 'GitHub and OpenAI launch AI programming assistant powered by Codex model',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||||
sourceUrl: 'https://github.blog/news-insights/company-news/github-copilot/',
|
||||
},
|
||||
{
|
||||
title: 'ChatGPT 发布',
|
||||
titleEn: 'ChatGPT Release',
|
||||
eventDate: new Date('2022-11-30T00:00:00Z'),
|
||||
description: 'OpenAI 发布对话式 AI 助手 ChatGPT,5 天用户突破 100 万',
|
||||
descriptionEn: 'OpenAI launches conversational AI assistant ChatGPT, reaching 1 million users in 5 days',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||||
sourceUrl: 'https://openai.com/blog/chatgpt',
|
||||
},
|
||||
{
|
||||
title: 'GPT-4 发布',
|
||||
titleEn: 'GPT-4 Release',
|
||||
eventDate: new Date('2023-03-14T00:00:00Z'),
|
||||
description: 'OpenAI 发布多模态大语言模型 GPT-4,在各项基准测试中接近人类水平',
|
||||
descriptionEn: 'OpenAI releases multimodal LLM GPT-4, approaching human-level performance on various benchmarks',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||||
sourceUrl: 'https://openai.com/research/gpt-4',
|
||||
},
|
||||
{
|
||||
title: 'Claude 发布',
|
||||
titleEn: 'Claude Release',
|
||||
eventDate: new Date('2023-03-16T00:00:00Z'),
|
||||
description: 'Anthropic 发布 AI 助手 Claude,强调安全性和有用性',
|
||||
descriptionEn: 'Anthropic launches AI assistant Claude, emphasizing safety and helpfulness',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||||
sourceUrl: 'https://www.anthropic.com/index/claude-now-open',
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('开始插入历史事件...');
|
||||
|
||||
let successCount = 0;
|
||||
let skipCount = 0;
|
||||
|
||||
for (const event of historicalEvents) {
|
||||
try {
|
||||
await prisma.aIEvent.create({
|
||||
data: event,
|
||||
});
|
||||
console.log(`✓ ${event.title} (${event.eventDate.getFullYear()})`);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes('Unique constraint')) {
|
||||
console.log(`⊘ ${event.title} 已存在,跳过`);
|
||||
skipCount++;
|
||||
} else {
|
||||
console.log(`✗ ${event.title} 插入失败: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n历史事件插入完成!');
|
||||
console.log(`成功: ${successCount} 条`);
|
||||
console.log(`跳过: ${skipCount} 条`);
|
||||
console.log(`总计: ${historicalEvents.length} 条`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -1,172 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 先删除已存在的关键词
|
||||
await prisma.keyword.deleteMany({
|
||||
where: { quarterId: quarter.id },
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* 测试关键词词云 API 端点
|
||||
* 模拟 n8n 工作流发送的请求
|
||||
*
|
||||
* 运行前请确保已设置环境变量:
|
||||
* export API_URL=http://localhost:3000
|
||||
* export WEBHOOK_API_KEY=your_api_key
|
||||
*/
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:3000';
|
||||
const API_KEY = process.env.WEBHOOK_API_KEY || '';
|
||||
|
||||
async function testAPI() {
|
||||
console.log('🧪 测试关键词词云 API...\n');
|
||||
|
||||
// 测试 1: 获取季度列表
|
||||
console.log('1️⃣ 测试 GET /api/keyword-cloud/quarters');
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/keyword-cloud/quarters`);
|
||||
const data = await response.json();
|
||||
console.log('✅ 成功:', data.success);
|
||||
console.log(` 季度数量: ${data.quarters?.length || 0}`);
|
||||
if (data.quarters?.length > 0) {
|
||||
console.log(` 第一个季度: ${data.quarters[0].quarter} (${data.quarters[0].keywordCount} 个关键词)`);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.log('❌ 失败:', error.message);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 测试 2: 获取关键词
|
||||
console.log('2️⃣ 测试 GET /api/keyword-cloud/keywords/2023-Q1');
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/keyword-cloud/keywords/2023-Q1`);
|
||||
const data = await response.json();
|
||||
console.log('✅ 成功:', data.success);
|
||||
console.log(` 关键词数量: ${data.keywords?.length || 0}`);
|
||||
if (data.keywords?.length > 0) {
|
||||
console.log(` 最热词汇: ${data.keywords[0].word} (${data.keywords[0].trendScore}分)`);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.log('❌ 失败:', error.message);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 测试 3: 获取视觉规则
|
||||
console.log('3️⃣ 测试 GET /api/keyword-cloud/rules');
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/keyword-cloud/rules`);
|
||||
const data = await response.json();
|
||||
console.log('✅ 成功:', data.success);
|
||||
console.log(` 规则数量: ${data.rules?.length || 0}`);
|
||||
if (data.rules?.length > 0) {
|
||||
console.log(` 规则示例: ${data.rules[0].name} (${data.rules[0].minScore}-${data.rules[0].maxScore}分)`);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.log('❌ 失败:', error.message);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 测试 4: 批量写入关键词(模拟 n8n 工作流)
|
||||
console.log('4️⃣ 测试 POST /api/keyword-cloud/keywords (模拟 n8n 请求)');
|
||||
|
||||
const testPayload = {
|
||||
apiKey: API_KEY,
|
||||
quarter: '2024-Q1',
|
||||
keywords: [
|
||||
{
|
||||
word: 'Claude',
|
||||
trendScore: 95,
|
||||
description: 'Anthropic 开发的 AI 助手',
|
||||
descriptionEn: 'AI assistant developed by Anthropic',
|
||||
detailPoints: ['支持长上下文', '安全对齐', '多模态能力'],
|
||||
detailPointsEn: ['Long context', 'Safety alignment', 'Multimodal'],
|
||||
visualConfig: {
|
||||
color: 'primary',
|
||||
size: 'text-5xl',
|
||||
border: 'border-4',
|
||||
rotation: 'rotate-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
word: 'Gemini',
|
||||
trendScore: 88,
|
||||
description: 'Google 的多模态 AI 模型',
|
||||
descriptionEn: 'Multimodal AI model by Google',
|
||||
detailPoints: ['原生多模态', '长上下文窗口', '推理能力'],
|
||||
detailPointsEn: ['Native multimodal', 'Long context', 'Reasoning'],
|
||||
visualConfig: {
|
||||
color: 'secondary',
|
||||
size: 'text-4xl',
|
||||
border: 'border-4',
|
||||
rotation: 'rotate-2',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/keyword-cloud/keywords`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(testPayload),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
console.log('✅ 成功: 创建了', data.created, '个关键词');
|
||||
if (data.failed > 0) {
|
||||
console.log('⚠️ 失败:', data.failed, '个');
|
||||
data.errors?.forEach((err: any) => console.log(' -', err.word, ':', err.error));
|
||||
}
|
||||
} else {
|
||||
console.log('❌ 失败:', data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.log('❌ 失败:', error.message);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 验证新创建的数据
|
||||
console.log('5️⃣ 验证新创建的数据 (2024-Q1)');
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/keyword-cloud/keywords/2024-Q1`);
|
||||
const data = await response.json();
|
||||
console.log('✅ 成功:', data.success);
|
||||
console.log(` 关键词数量: ${data.keywords?.length || 0}`);
|
||||
if (data.keywords?.length > 0) {
|
||||
console.log(` 关键词: ${data.keywords.map((k: any) => k.word).join(', ')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.log('❌ 失败:', error.message);
|
||||
}
|
||||
console.log();
|
||||
|
||||
console.log('✨ 测试完成!');
|
||||
}
|
||||
|
||||
testAPI().catch(console.error);
|
||||
@@ -1,119 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface VisualConfig {
|
||||
color: 'primary' | 'secondary' | 'accent' | 'gray';
|
||||
size: string;
|
||||
border: string;
|
||||
rotation?: string;
|
||||
}
|
||||
|
||||
export interface KeywordData {
|
||||
id: number;
|
||||
word: string;
|
||||
trendScore: number;
|
||||
description: string;
|
||||
descriptionEn?: string | null;
|
||||
detailPoints: string[];
|
||||
detailPointsEn?: string[] | null;
|
||||
visualConfig: VisualConfig;
|
||||
}
|
||||
|
||||
interface CloudWordProps {
|
||||
data: KeywordData;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
// 颜色映射
|
||||
const colorMap: Record<string, string> = {
|
||||
primary: 'bg-primary',
|
||||
secondary: 'bg-secondary',
|
||||
accent: 'bg-accent',
|
||||
gray: 'bg-gray-100 dark:bg-gray-700',
|
||||
};
|
||||
|
||||
export function CloudWord({ data, locale }: CloudWordProps) {
|
||||
const { word, visualConfig, description, descriptionEn, detailPoints, detailPointsEn } = data;
|
||||
const { color, size, border, rotation } = visualConfig;
|
||||
const colorClass = colorMap[color];
|
||||
|
||||
// 根据语言选择内容
|
||||
const displayDescription = locale === 'en' && descriptionEn ? descriptionEn : description;
|
||||
const displayPoints = locale === 'en' && detailPointsEn ? detailPointsEn : detailPoints;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'cloud-word',
|
||||
'inline-block',
|
||||
'whitespace-nowrap',
|
||||
'relative',
|
||||
'transition-all',
|
||||
'duration-200',
|
||||
'cursor-pointer',
|
||||
border,
|
||||
'border-black',
|
||||
colorClass,
|
||||
'px-6',
|
||||
'py-3',
|
||||
'rounded-full',
|
||||
size,
|
||||
'font-black',
|
||||
'shadow-hard',
|
||||
rotation,
|
||||
'hover:scale-105',
|
||||
'hover:z-10'
|
||||
)}
|
||||
>
|
||||
{word}
|
||||
<WordPopover
|
||||
title={word}
|
||||
description={displayDescription}
|
||||
points={displayPoints}
|
||||
titleColor={color}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface WordPopoverProps {
|
||||
title: string;
|
||||
description: string;
|
||||
points: string[];
|
||||
titleColor: string;
|
||||
}
|
||||
|
||||
function WordPopover({ title, description, points, titleColor }: WordPopoverProps) {
|
||||
const titleColorClass = colorMap[titleColor] || 'bg-gray-100';
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-[calc(100%+12px)] left-1/2 -translate-x-1/2 w-48 bg-white dark:bg-gray-800 border-2 border-black shadow-hard-sm opacity-0 pointer-events-none transition-all duration-300 translate-y-2 z-50 text-left group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto">
|
||||
{/* 标题栏 */}
|
||||
<div className={cn(
|
||||
'text-black dark:text-white font-display font-bold p-2 border-b-2 border-black text-sm uppercase',
|
||||
titleColorClass
|
||||
)}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="p-3 space-y-2 text-xs font-medium dark:text-gray-100">
|
||||
<div className="flex gap-2 items-start">
|
||||
<span>•</span>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
{points.map((point, i) => (
|
||||
<div key={i} className="flex gap-2 items-start">
|
||||
<span>•</span>
|
||||
<span>{point}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 箭头 */}
|
||||
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-black" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CloudWord, type KeywordData } from './CloudWord';
|
||||
import { QuarterNavigator } from './QuarterNavigator';
|
||||
import { ProgressIndicator } from './ProgressIndicator';
|
||||
import { useKeywordCloud } from '@/hooks/useKeywordCloudClient';
|
||||
|
||||
interface KeywordCloudProps {
|
||||
initialQuarter: string;
|
||||
locale: string;
|
||||
texts: {
|
||||
loading: string;
|
||||
loadFailed: string;
|
||||
retry: string;
|
||||
hotKeyword: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function KeywordCloud({ initialQuarter, locale, texts }: KeywordCloudProps) {
|
||||
const [currentQuarter, setCurrentQuarter] = useState(initialQuarter);
|
||||
const [quarters, setQuarters] = useState<string[]>([]);
|
||||
const { data, isLoading, error } = useKeywordCloud(currentQuarter);
|
||||
|
||||
// 加载季度列表
|
||||
useEffect(() => {
|
||||
async function loadQuarters() {
|
||||
try {
|
||||
const response = await fetch('/api/keyword-cloud/quarters');
|
||||
const json = await response.json();
|
||||
if (json.success) {
|
||||
const quarterStrings = json.quarters.map((q: any) => q.quarter);
|
||||
setQuarters(quarterStrings);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load quarters:', err);
|
||||
}
|
||||
}
|
||||
loadQuarters();
|
||||
}, []);
|
||||
|
||||
const handleNavigate = (quarter: string) => {
|
||||
setCurrentQuarter(quarter);
|
||||
// 更新 URL 而不刷新页面
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('quarter', quarter);
|
||||
window.history.pushState({}, '', url.toString());
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-black border-t-primary mb-4" />
|
||||
<p className="font-display font-bold text-lg">{texts.loading}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<p className="font-display font-bold text-lg text-red-500 mb-4">
|
||||
{texts.loadFailed}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="bg-primary text-black border-2 border-black px-4 py-2 font-bold hover:bg-yellow-500 transition-colors"
|
||||
>
|
||||
{texts.retry}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const firstKeyword = data.keywords[0];
|
||||
|
||||
return (
|
||||
<div className="bg-white/50 dark:bg-black/20 backdrop-blur-sm border-4 border-black p-8 md:p-12 shadow-hard relative overflow-visible">
|
||||
{/* 进度条 */}
|
||||
<ProgressIndicator
|
||||
currentQuarter={currentQuarter}
|
||||
totalQuarters={quarters.length}
|
||||
quarters={quarters}
|
||||
/>
|
||||
|
||||
{/* 季度导航 */}
|
||||
<QuarterNavigator
|
||||
current={currentQuarter}
|
||||
quarters={quarters}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
|
||||
{/* 词云区域 */}
|
||||
<div className="relative py-12 px-4">
|
||||
<div className="word-cluster flex flex-wrap gap-3 items-center justify-center max-w-full">
|
||||
{data.keywords.map((keyword) => (
|
||||
<CloudWord key={keyword.id} data={keyword} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 装饰元素 */}
|
||||
{data.keywords.length > 0 && firstKeyword && (
|
||||
<div className="absolute -top-16 -right-8 md:right-0 bg-black text-white p-5 rounded-xl text-sm md:text-base font-display w-64 shadow-hard rotate-6 hidden lg:block">
|
||||
{texts.hotKeyword.replace('{word}', firstKeyword.word)}
|
||||
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-black transform rotate-45" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
currentQuarter: string;
|
||||
totalQuarters: number;
|
||||
quarters: string[];
|
||||
}
|
||||
|
||||
export function ProgressIndicator({ currentQuarter, totalQuarters, quarters }: ProgressIndicatorProps) {
|
||||
const currentIndex = quarters.indexOf(currentQuarter);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 mb-12 max-w-md mx-auto">
|
||||
{quarters.map((quarter, index) => {
|
||||
const isCompleted = index <= currentIndex;
|
||||
const isCurrent = index === currentIndex;
|
||||
const isLast = index === quarters.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={quarter}
|
||||
className={cn(
|
||||
'progress-step',
|
||||
'h-3',
|
||||
'flex-1',
|
||||
'border-2',
|
||||
'border-black',
|
||||
'transition-colors',
|
||||
'duration-300',
|
||||
isCurrent && 'bg-primary',
|
||||
isCompleted && !isCurrent && 'bg-secondary',
|
||||
!isCompleted && 'bg-gray-200 dark:bg-gray-700',
|
||||
isLast && 'border-dashed'
|
||||
)}
|
||||
aria-label={`Quarter ${quarter}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QuarterNavigatorProps {
|
||||
current: string;
|
||||
quarters: string[];
|
||||
onNavigate: (quarter: string) => void;
|
||||
}
|
||||
|
||||
export function QuarterNavigator({ current, quarters, onNavigate }: QuarterNavigatorProps) {
|
||||
const currentIndex = quarters.indexOf(current);
|
||||
const canGoPrev = currentIndex > 0;
|
||||
const canGoNext = currentIndex < quarters.length - 1;
|
||||
|
||||
const handlePrev = () => {
|
||||
if (canGoPrev) {
|
||||
const prevQuarter = quarters[currentIndex - 1];
|
||||
if (prevQuarter) onNavigate(prevQuarter);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (canGoNext) {
|
||||
const nextQuarter = quarters[currentIndex + 1];
|
||||
if (nextQuarter) onNavigate(nextQuarter);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-8 mb-12">
|
||||
{/* 上一个季度按钮 */}
|
||||
<button
|
||||
disabled={!canGoPrev}
|
||||
className={cn(
|
||||
'nav-button',
|
||||
'order-2 md:order-1',
|
||||
'flex items-center justify-center',
|
||||
'w-12 h-12 md:w-16 md:h-16',
|
||||
'bg-white dark:bg-gray-800',
|
||||
'border-4 border-black',
|
||||
'shadow-hard',
|
||||
'hover:translate-y-0.5 hover:shadow-none',
|
||||
'transition-all',
|
||||
!canGoPrev && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={handlePrev}
|
||||
aria-label="Previous quarter"
|
||||
>
|
||||
<ChevronLeft className="w-8 h-8 md:w-10 md:h-10" />
|
||||
</button>
|
||||
|
||||
{/* 当前季度显示 */}
|
||||
<div className="text-center order-1 md:order-2 flex-1">
|
||||
<div className="inline-block bg-primary border-4 border-black px-10 py-4 shadow-hard font-display font-bold text-4xl md:text-5xl mb-4 rotate-1">
|
||||
{current}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下一个季度按钮 */}
|
||||
<button
|
||||
disabled={!canGoNext}
|
||||
className={cn(
|
||||
'nav-button',
|
||||
'order-3',
|
||||
'flex items-center justify-center',
|
||||
'w-12 h-12 md:w-16 md:h-16',
|
||||
'bg-white dark:bg-gray-800',
|
||||
'border-4 border-black',
|
||||
'shadow-hard',
|
||||
'hover:translate-y-0.5 hover:shadow-none',
|
||||
'transition-all',
|
||||
!canGoNext && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={handleNext}
|
||||
aria-label="Next quarter"
|
||||
>
|
||||
<ChevronRight className="w-8 h-8 md:w-10 md:h-10" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { KeywordCloud } from "./components/KeywordCloud";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<{ quarter?: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps) {
|
||||
const resolvedParams = (await params) ?? {};
|
||||
const rawLocale = resolvedParams.locale;
|
||||
const locale = (Array.isArray(rawLocale) ? rawLocale[0] : rawLocale) ?? "zh";
|
||||
const t = await getTranslations("keywordCloud");
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function KeywordCloudPage({ searchParams, params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const t = await getTranslations("keywordCloud");
|
||||
|
||||
// 如果 URL 中有 quarter 参数,使用它;否则使用默认季度
|
||||
const quarter = resolvedSearchParams.quarter || "2024-Q1";
|
||||
}
|
||||
@@ -75,24 +75,6 @@ export default async function LocaleLayout({
|
||||
>
|
||||
{tNav('projects')}
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href={`/${locale}/keyword-cloud`}
|
||||
>
|
||||
{tNav('keywordCloud')}
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href={`/${locale}/timeline`}
|
||||
>
|
||||
{tNav('timeline')}
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href="#"
|
||||
>
|
||||
{tNav('blog')}
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href="#"
|
||||
@@ -187,7 +169,7 @@ export default async function LocaleLayout({
|
||||
<div>
|
||||
<h4 className="font-display font-bold text-lg mb-4">{t('resources')}</h4>
|
||||
<ul className="space-y-2 text-sm font-sans text-gray-600 dark:text-gray-400">
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">{tNav('blog')}</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">Newsletter</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">Newsletter</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">Documentation</Link></li>
|
||||
</ul>
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { getAIEvents } from '@/hooks/useAIEvents';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('timeline');
|
||||
|
||||
return {
|
||||
title: t('metaTitle'),
|
||||
description: t('metaDescription'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TimelinePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('timeline');
|
||||
const tCommon = await getTranslations('common');
|
||||
|
||||
const events = await getAIEvents();
|
||||
|
||||
// 按年份分组
|
||||
const eventsByYear = events.reduce((acc, event) => {
|
||||
const year = new Date(event.eventDate).getFullYear();
|
||||
if (!acc[year]) {
|
||||
acc[year] = [];
|
||||
}
|
||||
acc[year].push(event);
|
||||
return acc;
|
||||
}, {} as Record<number, typeof events>);
|
||||
|
||||
// 按年份降序排序
|
||||
const sortedYears = Object.keys(eventsByYear)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background-light dark:bg-background-dark flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="font-display font-black text-4xl mb-4">
|
||||
{t('emptyData')}
|
||||
</h1>
|
||||
<p className="font-mono text-gray-600">
|
||||
{t('collecting')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background-light dark:bg-background-dark">
|
||||
{/* Grid background */}
|
||||
<div className="fixed inset-0 pointer-events-none z-0 opacity-10 dark:opacity-20 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[size:40px_40px] bg-[linear-gradient(to_right,#e5e5e5_1px,transparent_1px),linear-gradient(to_bottom,#e5e5e5_1px,transparent_1px)] dark:bg-[linear-gradient(to_right,#333_1px,transparent_1px),linear-gradient(to_bottom,#333_1px,transparent_1px)]" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative z-10 pt-32 pb-20 px-4 max-w-[1600px] mx-auto">
|
||||
<header className="text-center mb-20 relative">
|
||||
<div className="inline-block relative">
|
||||
{/* Decorative SVG */}
|
||||
<svg
|
||||
className="absolute -top-6 -left-8 w-[120%] h-[150%] text-primary opacity-80 -z-10 animate-pulse"
|
||||
viewBox="0 0 200 200"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M44.7,-51.2C57.1,-41.5,66.1,-27.6,68.9,-12.8C71.7,2,68.3,17.7,60.4,30.9C52.5,44.1,40.1,54.8,26.4,60.1C12.7,65.4,-2.3,65.3,-17.1,61.1C-31.9,56.9,-46.5,48.6,-56.3,36.4C-66.1,24.2,-71.1,8.1,-67.3,-5.7C-63.5,-19.5,-50.9,-31,-38.7,-40.8C-26.5,-50.6,-14.7,-58.7,0.1,-58.8C14.9,-59,29.8,-51.2,32.3,-60.9"
|
||||
fill="currentColor"
|
||||
transform="translate(100 100)"
|
||||
/>
|
||||
</svg>
|
||||
<h1 className="font-display font-black text-6xl md:text-8xl tracking-tight leading-none text-black dark:text-white drop-shadow-sm">
|
||||
{t('title')}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-6 text-lg md:text-xl font-mono max-w-2xl mx-auto bg-white dark:bg-black border border-black dark:border-white p-2 rotate-1 inline-block shadow-[4px_4px_0px_0px_#000] dark:shadow-[4px_4px_0px_0px_#fff]">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Main Timeline */}
|
||||
<main className="relative px-4 md:px-12 pb-32">
|
||||
<div className="relative w-full">
|
||||
{/* Continuous timeline path */}
|
||||
<div className="absolute left-8 md:left-1/2 top-0 bottom-32 w-1 bg-gradient-to-b from-primary via-secondary to-primary opacity-50 hidden md:block" />
|
||||
|
||||
{/* Timeline dots */}
|
||||
{sortedYears.map((year, yearIndex) => (
|
||||
<div
|
||||
key={`dot-${year}`}
|
||||
className="absolute left-8 md:left-1/2 w-4 h-4 bg-primary border-4 border-black dark:border-white rounded-full -translate-x-1/2 hidden md:block"
|
||||
style={{
|
||||
top: `${20 + yearIndex * 35}rem`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Year sections */}
|
||||
{sortedYears.map((year, yearIndex) => (
|
||||
<section
|
||||
key={year}
|
||||
className={`relative min-h-[500px] mb-32 flex ${
|
||||
yearIndex % 2 === 0 ? 'flex-row' : 'flex-row-reverse'
|
||||
}`}
|
||||
>
|
||||
{/* Year Label */}
|
||||
<div
|
||||
className={`absolute ${yearIndex % 2 === 0 ? 'left-0 md:left-auto md:right-0' : 'left-0 md:left-0 md:right-auto'} -top-8 z-20`}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
yearIndex % 2 === 0 ? 'bg-primary' : 'bg-secondary'
|
||||
} border-4 border-black px-6 py-2 ${
|
||||
yearIndex % 2 === 0 ? '-rotate-2' : 'rotate-2'
|
||||
} shadow-hard`}
|
||||
>
|
||||
<span className="font-display font-black text-3xl md:text-5xl">
|
||||
{year}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events Container */}
|
||||
<div
|
||||
className={`w-full ${
|
||||
yearIndex % 2 === 0 ? 'pl-0 md:pl-16 pr-0 md:pr-32' : 'pl-0 md:pl-32 pr-0 md:pr-16'
|
||||
} pt-20`}
|
||||
>
|
||||
<div className="flex flex-nowrap overflow-x-visible items-center justify-start py-10">
|
||||
{eventsByYear[year]?.map((event, eventIndex) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="stack-card relative w-72 h-96 flex-shrink-0 bg-surface-light dark:bg-surface-dark border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56"
|
||||
style={{
|
||||
zIndex: Math.max(1, 40 - eventIndex * 10),
|
||||
transform: `rotate(${(eventIndex % 7 - 3) * 2}deg)`,
|
||||
}}
|
||||
>
|
||||
{/* Tape decoration */}
|
||||
<div className="tape absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6" />
|
||||
|
||||
{/* Image */}
|
||||
<div className="h-40 bg-primary border-2 border-black dark:border-white mb-4 flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={event.imageUrl}
|
||||
alt={event.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="font-display font-bold text-xl leading-none mb-2 uppercase">
|
||||
{event.title}
|
||||
</h3>
|
||||
<p className="text-xs leading-snug opacity-80 line-clamp-4 mb-4">
|
||||
{event.description}
|
||||
</p>
|
||||
|
||||
{/* Date */}
|
||||
<div className="absolute bottom-4 left-4 text-[10px] font-bold bg-black text-white px-2">
|
||||
{new Date(event.eventDate).toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US')}
|
||||
</div>
|
||||
|
||||
{/* Source Link */}
|
||||
{event.sourceUrl && (
|
||||
<a
|
||||
href={event.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute bottom-4 right-4 text-[10px] font-bold underline"
|
||||
>
|
||||
{locale === 'zh' ? '来源 →' : 'Source →'}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Newsletter section */}
|
||||
<div className="max-w-3xl mx-auto px-4">
|
||||
<div className="bg-primary border-4 border-black p-8 relative shadow-[12px_12px_0px_0px_#000] dark:shadow-[12px_12px_0px_0px_#fff]">
|
||||
<div className="absolute -top-10 -right-6 w-20 h-20 bg-white dark:bg-gray-800 border-4 border-black flex items-center justify-center rounded-full animate-bounce">
|
||||
<span className="material-icons-round text-4xl text-black dark:text-white">
|
||||
mail
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display font-black text-3xl md:text-5xl mb-4 text-black uppercase tracking-tight">
|
||||
{t('joinThePark')}
|
||||
</h2>
|
||||
<p className="font-mono text-black mb-8 text-base font-bold">
|
||||
{t('subscribeDesc')}
|
||||
</p>
|
||||
<form className="flex flex-col md:flex-row gap-4">
|
||||
<input
|
||||
className="flex-1 bg-white border-4 border-black px-6 py-4 font-mono focus:ring-0 focus:border-black focus:shadow-[4px_4px_0px_0px_#000] transition-all placeholder:text-gray-500 text-black text-lg"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
type="email"
|
||||
/>
|
||||
<button
|
||||
className="bg-black text-white px-10 py-4 font-black border-4 border-transparent hover:bg-white hover:text-black hover:border-black transition-all hover:shadow-[6px_6px_0px_0px_#000] uppercase text-lg"
|
||||
type="button"
|
||||
>
|
||||
{t('subscribe')}
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<input
|
||||
className="w-6 h-6 border-4 border-black text-black focus:ring-0 rounded-none bg-white checked:bg-black"
|
||||
id="check"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label className="text-sm font-black text-black uppercase" htmlFor="check">
|
||||
{t('agreeToBeCool')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back to top button */}
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<div className="bg-white dark:bg-black border-4 border-black dark:border-white p-3 shadow-hard dark:shadow-hard-dark cursor-pointer hover:-translate-y-2 transition-transform">
|
||||
<span className="material-icons-round text-3xl text-black dark:text-white">
|
||||
arrow_upward
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { POST, GET } from './route';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
describe('POST /api/events', () => {
|
||||
const validEvent = {
|
||||
title: 'GPT-4 发布',
|
||||
eventDate: '2023-03-14T00:00:00Z',
|
||||
description: 'OpenAI 发布多模态大语言模型',
|
||||
imageUrl: 'https://example.com/gpt4.jpg',
|
||||
};
|
||||
|
||||
it('should reject without API key', async () => {
|
||||
const request = new NextRequest('http://localhost:3000/api/events', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([validEvent]),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('should reject with invalid API key', async () => {
|
||||
const request = new NextRequest('http://localhost:3000/api/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': 'invalid-key',
|
||||
},
|
||||
body: JSON.stringify([validEvent]),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
// 注意: 以下测试需要设置 WEBHOOK_API_KEY 环境变量
|
||||
// 可以通过 vi.stubEnv 来模拟
|
||||
});
|
||||
|
||||
describe('GET /api/events', () => {
|
||||
it('should return events array', async () => {
|
||||
const request = new NextRequest('http://localhost:3000/api/events');
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json).toHaveProperty('events');
|
||||
expect(Array.isArray(json.events)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by year', async () => {
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/events?year=2024'
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json).toHaveProperty('events');
|
||||
});
|
||||
|
||||
it('should reject invalid year format', async () => {
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/events?year=invalid'
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe('Invalid query parameters');
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { AIEventInputSchema, AIEventQuerySchema } from '@/lib/validations';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// 1. API Key 验证
|
||||
const apiKey = request.headers.get('X-API-Key');
|
||||
const expectedKey = process.env.WEBHOOK_API_KEY;
|
||||
const providedBuf = Buffer.from(apiKey || '');
|
||||
const expectedBuf = Buffer.from(expectedKey || '');
|
||||
|
||||
if (
|
||||
!apiKey ||
|
||||
!expectedKey ||
|
||||
providedBuf.length !== expectedBuf.length ||
|
||||
!crypto.timingSafeEqual(providedBuf, expectedBuf)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 解析请求体
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid JSON' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 验证数据
|
||||
const validationResult = AIEventInputSchema.array().safeParse(body);
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Validation failed',
|
||||
details: validationResult.error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 创建事件
|
||||
try {
|
||||
const result = await prisma.aIEvent.createMany({
|
||||
data: validationResult.data,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
created: result.count,
|
||||
total: validationResult.data.length,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create AI events:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// 1. 解析查询参数(将 null 转换为 undefined)
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryParams = {
|
||||
year: searchParams.get('year') || undefined,
|
||||
limit: searchParams.get('limit') || undefined,
|
||||
offset: searchParams.get('offset') || undefined,
|
||||
};
|
||||
|
||||
// 2. 验证查询参数
|
||||
const validationResult = AIEventQuerySchema.safeParse(queryParams);
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid query parameters',
|
||||
details: validationResult.error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 获取事件
|
||||
try {
|
||||
const events = await prisma.aIEvent.findMany({
|
||||
where: validationResult.data.year
|
||||
? {
|
||||
eventDate: {
|
||||
gte: new Date(`${validationResult.data.year}-01-01T00:00:00Z`),
|
||||
lte: new Date(`${validationResult.data.year}-12-31T23:59:59Z`),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { eventDate: 'desc' },
|
||||
take: validationResult.data.limit || 100,
|
||||
skip: validationResult.data.offset || 0,
|
||||
});
|
||||
|
||||
return NextResponse.json({ events });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI events:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/keyword-cloud/health
|
||||
* 健康检查端点(用于监控)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// 检查数据库连接
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
// 统计数据
|
||||
const quarterCount = await prisma.quarter.count();
|
||||
const keywordCount = await prisma.keyword.count();
|
||||
const ruleCount = await prisma.visualStyleRule.count({
|
||||
where: { enabled: true },
|
||||
});
|
||||
const errorCount = await prisma.keywordCloudErrorLog.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // 最近24小时
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: 'healthy',
|
||||
stats: {
|
||||
quarters: quarterCount,
|
||||
keywords: keywordCount,
|
||||
activeRules: ruleCount,
|
||||
recentErrors: errorCount,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
status: 'unhealthy',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getKeywordsByQuarter } from "@/hooks/useKeywordCloud";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/keyword-cloud/keywords/[quarter]
|
||||
* 获取指定季度的关键词
|
||||
*/
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ quarter: string }> }) {
|
||||
try {
|
||||
const { quarter } = await params;
|
||||
|
||||
// 验证 quarter 格式
|
||||
if (!/^\d{4}-Q[1-4]$/.test(quarter)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Invalid quarter format. Expected: YYYY-QN",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await getKeywordsByQuarter(quarter);
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Quarter ${quarter} not found`,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
quarter: data.quarter,
|
||||
title: data.title,
|
||||
titleEn: data.titleEn,
|
||||
subtitle: data.subtitle,
|
||||
subtitleEn: data.subtitleEn,
|
||||
keywords: data.keywords,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching keywords:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Failed to fetch keywords",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { BatchKeywordsRequestSchema } from '@/lib/validations';
|
||||
import { upsertQuarter, createKeywords, logKeywordCloudError } from '@/hooks/useKeywordCloud';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/keyword-cloud/keywords
|
||||
* 批量写入关键词(n8n 工作流使用)
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// 1. 验证 API Key
|
||||
const body = await request.json();
|
||||
const { apiKey, ...requestData } = body;
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing API key',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const expectedApiKey = process.env.WEBHOOK_API_KEY;
|
||||
if (!expectedApiKey) {
|
||||
console.error('WEBHOOK_API_KEY not configured');
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Server configuration error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用 timing-safe 比较防止时序攻击
|
||||
try {
|
||||
const apiKeyBuffer = Buffer.from(apiKey, 'utf-8');
|
||||
const expectedBuffer = Buffer.from(expectedApiKey, 'utf-8');
|
||||
|
||||
if (apiKeyBuffer.length !== expectedBuffer.length ||
|
||||
!crypto.timingSafeEqual(apiKeyBuffer, expectedBuffer)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid API key',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Authentication failed',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 验证请求数据
|
||||
const validationResult = BatchKeywordsRequestSchema.safeParse(requestData);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Validation failed',
|
||||
details: validationResult.error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { quarter, keywords } = validationResult.data;
|
||||
|
||||
// 3. 创建或更新季度记录
|
||||
const quarterData = await upsertQuarter(quarter, {
|
||||
title: `${quarter.replace('-', '年')}季度`,
|
||||
titleEn: quarter.replace('-', ' '),
|
||||
});
|
||||
|
||||
// 4. 批量创建关键词
|
||||
const result = await createKeywords(quarterData.id, keywords);
|
||||
|
||||
// 5. 记录错误
|
||||
for (const error of result.errors) {
|
||||
await logKeywordCloudError({
|
||||
quarter,
|
||||
keyword: error.word,
|
||||
errorType: 'DB_ERROR',
|
||||
errorMessage: error.error,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. 返回结果
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: result.created,
|
||||
failed: result.failed,
|
||||
errors: result.errors,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating keywords:', error);
|
||||
|
||||
// 记录未捕获的错误
|
||||
try {
|
||||
await logKeywordCloudError({
|
||||
quarter: 'unknown',
|
||||
errorType: 'API_ERROR',
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
rawData: { error },
|
||||
});
|
||||
} catch (logError) {
|
||||
console.error('Failed to log error:', logError);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to create keywords',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAllQuarters } from '@/hooks/useKeywordCloud';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/keyword-cloud/quarters
|
||||
* 获取季度列表
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const isActive = searchParams.get('isActive');
|
||||
|
||||
const quarters = await getAllQuarters(
|
||||
isActive !== null ? { isActive: isActive === 'true' } : undefined
|
||||
);
|
||||
|
||||
// 为每个季度添加关键词计数
|
||||
const { prisma } = await import('@/lib/prisma');
|
||||
const quartersWithCount = await Promise.all(
|
||||
quarters.map(async (q) => {
|
||||
const count = await prisma.keyword.count({
|
||||
where: { quarterId: q.id },
|
||||
});
|
||||
return {
|
||||
...q,
|
||||
keywordCount: count,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
quarters: quartersWithCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching quarters:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch quarters',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getVisualStyleRules } from '@/hooks/useKeywordCloud';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/keyword-cloud/rules
|
||||
* 获取视觉样式规则配置
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const enabled = searchParams.get('enabled');
|
||||
|
||||
const rules = await getVisualStyleRules(
|
||||
enabled !== null ? { enabled: enabled === 'true' } : undefined
|
||||
);
|
||||
|
||||
// 转换规则格式以匹配前端期望
|
||||
const formattedRules = rules.map(rule => ({
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
minScore: rule.minScore,
|
||||
maxScore: rule.maxScore,
|
||||
visualConfig: {
|
||||
color: rule.color,
|
||||
size: rule.size,
|
||||
border: rule.border,
|
||||
rotation: rule.rotation,
|
||||
},
|
||||
priority: rule.priority,
|
||||
enabled: rule.enabled,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rules: formattedRules,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching visual style rules:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch rules',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { AIEvent } from '@prisma/client';
|
||||
|
||||
interface EventCardProps {
|
||||
event: AIEvent;
|
||||
index: number;
|
||||
baseIndex?: number;
|
||||
}
|
||||
|
||||
export function EventCard({ event, index, baseIndex = 30 }: EventCardProps) {
|
||||
// Generate consistent rotation based on event ID
|
||||
const rotation = ((parseInt(event.id.slice(-4), 36) % 14) - 7); // -7 to 7 degrees
|
||||
const zIndex = Math.max(1, baseIndex - index * 10);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="stack-card relative w-72 h-96 flex-shrink-0 bg-surface-light dark:bg-surface-dark border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56"
|
||||
style={{
|
||||
zIndex,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
}}
|
||||
>
|
||||
{/* Tape decoration with transparency and blur */}
|
||||
<div
|
||||
className="tape absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6 rotate-1"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
border: '1px solid rgba(255,255,255,0.6)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Image */}
|
||||
<div className="h-40 bg-primary border-2 border-black dark:border-white mb-4 flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={event.imageUrl}
|
||||
alt={event.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="font-display font-bold text-xl leading-none mb-2 uppercase">
|
||||
{event.title}
|
||||
</h3>
|
||||
<p className="text-xs leading-snug opacity-80 line-clamp-4 mb-4">
|
||||
{event.description}
|
||||
</p>
|
||||
|
||||
{/* Date */}
|
||||
<div className="absolute bottom-4 left-4 text-[10px] font-bold bg-black text-white px-2">
|
||||
{new Date(event.eventDate).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
|
||||
{/* Source Link */}
|
||||
{event.sourceUrl && (
|
||||
<a
|
||||
href={event.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute bottom-4 right-4 text-[10px] font-bold underline"
|
||||
>
|
||||
来源 →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { AIEvent } from '@prisma/client';
|
||||
import { EventCard } from './EventCard';
|
||||
|
||||
interface TimelineSectionProps {
|
||||
year: number;
|
||||
events: AIEvent[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function TimelineSection({ year, events, index }: TimelineSectionProps) {
|
||||
const isEven = index % 2 === 0;
|
||||
|
||||
return (
|
||||
<section className={`relative min-h-[500px] mb-32 flex ${isEven ? 'flex-row' : 'flex-row-reverse'}`}>
|
||||
{/* Year Label */}
|
||||
<div className={`absolute ${isEven ? 'left-0' : 'right-0'} -top-8 z-20`}>
|
||||
<div
|
||||
className={`${isEven ? 'bg-primary' : 'bg-secondary'} border-4 border-black px-6 py-2 ${isEven ? '-rotate-2' : 'rotate-2'} shadow-hard`}
|
||||
>
|
||||
<span className="font-display font-black text-3xl md:text-5xl">
|
||||
{year}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events Container */}
|
||||
<div
|
||||
className={`w-full ${isEven ? 'pl-0 md:pl-8 pr-0 md:pr-32' : 'pl-0 md:pl-32 pr-0 md:pr-8'} pt-20`}
|
||||
>
|
||||
<div className="flex flex-nowrap overflow-x-visible items-center justify-start">
|
||||
{events.map((event, eventIndex) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={eventIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function getAIEvents(options?: {
|
||||
year?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const where = options?.year
|
||||
? {
|
||||
eventDate: {
|
||||
gte: new Date(`${options.year}-01-01T00:00:00Z`),
|
||||
lte: new Date(`${options.year}-12-31T23:59:59Z`),
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const events = await prisma.aIEvent.findMany({
|
||||
where,
|
||||
orderBy: { eventDate: 'desc' },
|
||||
take: options?.limit || 100,
|
||||
skip: options?.offset || 0,
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export async function getAIEventBySlug(slug: string) {
|
||||
// 暂不实现,后续如需要详细页面时添加
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getAllAIEventYears() {
|
||||
const events = await prisma.aIEvent.findMany({
|
||||
select: {
|
||||
eventDate: true,
|
||||
},
|
||||
orderBy: { eventDate: 'desc' },
|
||||
});
|
||||
|
||||
const years = new Set<number>();
|
||||
events.forEach(event => {
|
||||
years.add(new Date(event.eventDate).getFullYear());
|
||||
});
|
||||
|
||||
return Array.from(years).sort((a, b) => b - a);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type { Quarter, Keyword, VisualStyleRule } from '@prisma/client';
|
||||
|
||||
// 类型定义
|
||||
export type KeywordWithVisual = Keyword & {
|
||||
visualConfig: {
|
||||
color: string;
|
||||
size: string;
|
||||
border: string;
|
||||
rotation?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type QuarterWithKeywords = Quarter & {
|
||||
keywords: KeywordWithVisual[];
|
||||
_count?: { keywords: number };
|
||||
};
|
||||
|
||||
export type QuarterWithCount = Quarter & {
|
||||
_count?: { keywords: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有季度列表
|
||||
*/
|
||||
export async function getAllQuarters(
|
||||
options?: { isActive?: boolean }
|
||||
): Promise<Quarter[]> {
|
||||
const where = options?.isActive !== undefined
|
||||
? { isActive: options.isActive }
|
||||
: {};
|
||||
|
||||
return prisma.quarter.findMany({
|
||||
where,
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个季度的详情(包含关键词计数)
|
||||
*/
|
||||
export async function getQuarterByQuarter(
|
||||
quarter: string
|
||||
): Promise<QuarterWithCount | null> {
|
||||
const quarterData = await prisma.quarter.findUnique({
|
||||
where: { quarter },
|
||||
include: {
|
||||
_count: {
|
||||
select: { keywords: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return quarterData as QuarterWithCount | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定季度的所有关键词
|
||||
*/
|
||||
export async function getKeywordsByQuarter(
|
||||
quarter: string
|
||||
): Promise<QuarterWithKeywords | null> {
|
||||
const quarterData = await prisma.quarter.findUnique({
|
||||
where: { quarter },
|
||||
include: {
|
||||
keywords: {
|
||||
orderBy: { trendScore: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!quarterData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 转换 visualConfig 从 JSON 到对象
|
||||
const keywords: KeywordWithVisual[] = quarterData.keywords.map(kw => ({
|
||||
...kw,
|
||||
visualConfig: (typeof kw.visualConfig === 'string'
|
||||
? JSON.parse(kw.visualConfig)
|
||||
: kw.visualConfig) as KeywordWithVisual['visualConfig'],
|
||||
}));
|
||||
|
||||
return {
|
||||
...quarterData,
|
||||
keywords,
|
||||
} as QuarterWithKeywords;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用的视觉规则
|
||||
*/
|
||||
export async function getVisualStyleRules(
|
||||
options?: { enabled?: boolean }
|
||||
): Promise<VisualStyleRule[]> {
|
||||
const where = options?.enabled !== undefined
|
||||
? { enabled: options.enabled }
|
||||
: {};
|
||||
|
||||
return prisma.visualStyleRule.findMany({
|
||||
where,
|
||||
orderBy: [
|
||||
{ priority: 'asc' },
|
||||
{ minScore: 'desc' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新季度
|
||||
*/
|
||||
export async function upsertQuarter(
|
||||
quarter: string,
|
||||
data: {
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
subtitle?: string;
|
||||
subtitleEn?: string;
|
||||
displayOrder?: number;
|
||||
}
|
||||
): Promise<Quarter> {
|
||||
return prisma.quarter.upsert({
|
||||
where: { quarter },
|
||||
update: data,
|
||||
create: {
|
||||
quarter,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建关键词
|
||||
*/
|
||||
export async function createKeywords(
|
||||
quarterId: number,
|
||||
keywords: Array<{
|
||||
word: string;
|
||||
trendScore: number;
|
||||
description: string;
|
||||
descriptionEn?: string;
|
||||
detailPoints: string[];
|
||||
detailPointsEn?: string[];
|
||||
visualConfig: Record<string, any>;
|
||||
}>
|
||||
): Promise<{ created: number; failed: number; errors: Array<{ word: string; error: string }> }> {
|
||||
const errors: Array<{ word: string; error: string }> = [];
|
||||
let created = 0;
|
||||
|
||||
for (const kw of keywords) {
|
||||
try {
|
||||
await prisma.keyword.create({
|
||||
data: {
|
||||
quarterId,
|
||||
word: kw.word,
|
||||
trendScore: kw.trendScore,
|
||||
description: kw.description,
|
||||
descriptionEn: kw.descriptionEn,
|
||||
detailPoints: kw.detailPoints as any, // Prisma Json 类型
|
||||
detailPointsEn: kw.detailPointsEn as any,
|
||||
visualConfig: kw.visualConfig as any,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
word: kw.word,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { created, failed: errors.length, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误日志
|
||||
*/
|
||||
export async function logKeywordCloudError(
|
||||
data: {
|
||||
quarter: string;
|
||||
keyword?: string;
|
||||
errorType: string;
|
||||
errorMessage: string;
|
||||
rawData?: any;
|
||||
}
|
||||
): Promise<void> {
|
||||
await prisma.keywordCloudErrorLog.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { KeywordData } from '@/app/[locale]/keyword-cloud/components/CloudWord';
|
||||
|
||||
interface QuarterData {
|
||||
quarter: string;
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
subtitle?: string;
|
||||
subtitleEn?: string;
|
||||
keywords: KeywordData[];
|
||||
}
|
||||
|
||||
export function useKeywordCloud(quarter: string) {
|
||||
const [data, setData] = useState<QuarterData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/keyword-cloud/keywords/${quarter}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (!json.success) {
|
||||
throw new Error(json.error || 'Unknown error');
|
||||
}
|
||||
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Unknown error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (quarter) {
|
||||
fetchData();
|
||||
}
|
||||
}, [quarter]);
|
||||
|
||||
return { data, isLoading, error };
|
||||
}
|
||||
+23
-115
@@ -145,121 +145,6 @@ export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>;
|
||||
export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>;
|
||||
export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>;
|
||||
|
||||
// ================================
|
||||
// Keyword Cloud Schemas
|
||||
// ================================
|
||||
|
||||
// 视觉配置 Schema
|
||||
const VisualConfigSchema = z.object({
|
||||
color: z.enum(["primary", "secondary", "accent", "gray"]),
|
||||
size: z.enum(["text-5xl", "text-4xl", "text-3xl", "text-2xl", "text-xl", "text-lg", "text-base"]),
|
||||
border: z.enum(["border-4", "border-2"]),
|
||||
rotation: z
|
||||
.string()
|
||||
.regex(/^-?rotate-\d+$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// 关键词输入 Schema
|
||||
export const KeywordInputSchema = z.object({
|
||||
word: z.string().min(1).max(100),
|
||||
trendScore: z.number().int().min(0).max(100),
|
||||
description: z.string().min(10).max(500),
|
||||
descriptionEn: z.string().max(500).optional(),
|
||||
detailPoints: z.array(z.string().min(5).max(100)).min(1).max(5),
|
||||
detailPointsEn: z.array(z.string().max(100)).max(5).optional(),
|
||||
visualConfig: VisualConfigSchema,
|
||||
});
|
||||
|
||||
// 批量写入关键词请求 Schema
|
||||
export const BatchKeywordsRequestSchema = z.object({
|
||||
quarter: z.string().regex(/^\d{4}-Q[1-4]$/, "格式应为 YYYY-QN"),
|
||||
keywords: z.array(KeywordInputSchema).min(1).max(50),
|
||||
});
|
||||
|
||||
// 季度 Schema
|
||||
export const QuarterSchema = z.object({
|
||||
quarter: z.string().regex(/^\d{4}-Q[1-4]$/),
|
||||
title: z.string().min(1).max(200),
|
||||
titleEn: z.string().max(200).optional(),
|
||||
subtitle: z.string().max(500).optional(),
|
||||
subtitleEn: z.string().max(500).optional(),
|
||||
displayOrder: z.number().int().min(0).default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
// 视觉规则 Schema
|
||||
export const VisualStyleRuleSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(100),
|
||||
minScore: z.number().int().min(0).max(100),
|
||||
maxScore: z.number().int().min(0).max(100),
|
||||
color: z.enum(["primary", "secondary", "accent", "gray"]),
|
||||
size: z.enum([
|
||||
"text-5xl",
|
||||
"text-4xl",
|
||||
"text-3xl",
|
||||
"text-2xl",
|
||||
"text-xl",
|
||||
"text-lg",
|
||||
"text-base",
|
||||
]),
|
||||
border: z.enum(["border-4", "border-2"]),
|
||||
rotation: z
|
||||
.string()
|
||||
.regex(/^-?rotate-\d+$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
priority: z.number().int().min(0).default(0),
|
||||
enabled: z.boolean().default(true),
|
||||
})
|
||||
.refine((data) => data.minScore < data.maxScore, {
|
||||
message: "minScore 必须小于 maxScore",
|
||||
});
|
||||
|
||||
// API 响应 Schema
|
||||
export const KeywordCloudResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.any().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
// ================================
|
||||
// AI Timeline Schemas
|
||||
// ================================
|
||||
|
||||
export const AIEventInputSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
titleEn: z.string().max(200).optional(),
|
||||
eventDate: z.string().datetime(),
|
||||
description: z.string().min(10).max(500),
|
||||
descriptionEn: z.string().max(500).optional(),
|
||||
imageUrl: z.string().url(),
|
||||
sourceUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export const AIEventQuerySchema = z.object({
|
||||
year: z
|
||||
.string()
|
||||
.regex(/^\d{4}$/)
|
||||
.optional(),
|
||||
limit: z.string().regex(/^\d+$/).transform(Number).optional(),
|
||||
offset: z.string().regex(/^\d+$/).transform(Number).optional(),
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Types
|
||||
// ================================
|
||||
|
||||
export type KeywordInput = z.infer<typeof KeywordInputSchema>;
|
||||
export type BatchKeywordsRequest = z.infer<typeof BatchKeywordsRequestSchema>;
|
||||
export type Quarter = z.infer<typeof QuarterSchema>;
|
||||
export type VisualStyleRule = z.infer<typeof VisualStyleRuleSchema>;
|
||||
export type AIEventInput = z.infer<typeof AIEventInputSchema>;
|
||||
export type AIEventQuery = z.infer<typeof AIEventQuerySchema>;
|
||||
export type KeywordCloudResponse = z.infer<typeof KeywordCloudResponseSchema>;
|
||||
|
||||
// ================================
|
||||
// Tags API Schemas
|
||||
// ================================
|
||||
@@ -344,6 +229,26 @@ export const TagMaintenanceRequestSchema = z.object({
|
||||
});
|
||||
});
|
||||
|
||||
export const TagMatchCandidateSchema = z.object({
|
||||
name: z.string().min(1, "Tag name is required").max(100),
|
||||
nameEn: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export const TagMatchAvailableTagSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(100),
|
||||
nameEn: z.string().max(100).nullable().optional(),
|
||||
slug: z.string().max(150).optional(),
|
||||
projectCount: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const TagMatchRequestSchema = z.object({
|
||||
apiKey: z.string().min(32, "Invalid API key format"),
|
||||
candidates: z.array(TagMatchCandidateSchema).min(1).max(30),
|
||||
availableTags: z.array(TagMatchAvailableTagSchema).max(1000).optional(),
|
||||
limit: z.number().int().min(1).max(10).default(5),
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Types
|
||||
// ================================
|
||||
@@ -352,3 +257,6 @@ export type TagUpdate = z.infer<typeof TagUpdateSchema>;
|
||||
export type MergeTarget = z.infer<typeof MergeTargetSchema>;
|
||||
export type TagMerge = z.infer<typeof TagMergeSchema>;
|
||||
export type TagMaintenanceRequest = z.infer<typeof TagMaintenanceRequestSchema>;
|
||||
export type TagMatchCandidate = z.infer<typeof TagMatchCandidateSchema>;
|
||||
export type TagMatchAvailableTag = z.infer<typeof TagMatchAvailableTagSchema>;
|
||||
export type TagMatchRequest = z.infer<typeof TagMatchRequestSchema>;
|
||||
|
||||
@@ -74,9 +74,6 @@
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"projects": "Projects",
|
||||
"keywordCloud": "AI Word Cloud",
|
||||
"timeline": "AI Timeline",
|
||||
"blog": "Blog",
|
||||
"about": "About",
|
||||
"submitProject": "SUBMIT PROJECT"
|
||||
},
|
||||
@@ -96,31 +93,5 @@
|
||||
"followUs": "Follow Us",
|
||||
"copyright": "© 2025 Agent Park. All rights reserved.",
|
||||
"designedFor": "DESIGNED FOR AI BUILDERS"
|
||||
},
|
||||
"keywordCloud": {
|
||||
"metaTitle": "AI Hotspot Word Cloud - Agent Park",
|
||||
"metaDescription": "Explore the evolution of quarterly AI hotspots, from Large Language Models to Agent Workflows",
|
||||
"badge": "AI Trend Tracker",
|
||||
"title": "Quarterly AI",
|
||||
"titleHighlight": "Hotspot Word Cloud",
|
||||
"subtitle": "From “Large Language Models” to “Agent Workflows”. Explore the evolution of AI discourse.",
|
||||
"loading": "Loading...",
|
||||
"loadFailed": "Failed to load",
|
||||
"retry": "Retry",
|
||||
"hotKeyword": "“{word}” is the hottest keyword this quarter!"
|
||||
},
|
||||
"timeline": {
|
||||
"metaTitle": "AI Timeline - Agent Park",
|
||||
"metaDescription": "Explore the evolution of AI large language models from 2017 Transformer to today",
|
||||
"title": "THE STORY OF A.I.",
|
||||
"subtitle": "Pinned. Stacked. Zigzagged.",
|
||||
"emptyData": "No data available",
|
||||
"collecting": "Timeline data is being collected...",
|
||||
"joinThePark": "Join the Park",
|
||||
"subscribeDesc": "Subscribe to the Agent Park weekly zine. No spam, just ducks and data.",
|
||||
"emailPlaceholder": "Your email here...",
|
||||
"subscribe": "SUBSCRIBE",
|
||||
"agreeToBeCool": "I agree to be cool.",
|
||||
"earlier": "Earlier"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,6 @@
|
||||
"navigation": {
|
||||
"home": "首页",
|
||||
"projects": "项目列表",
|
||||
"keywordCloud": "AI 词云",
|
||||
"timeline": "AI 时间轴",
|
||||
"blog": "博客",
|
||||
"about": "关于",
|
||||
"submitProject": "提交项目"
|
||||
},
|
||||
@@ -96,30 +93,5 @@
|
||||
"followUs": "关注我们",
|
||||
"copyright": "© 2025 Agent Park. 保留所有权利。",
|
||||
"designedFor": "专为 AI 构建者设计"
|
||||
},
|
||||
"keywordCloud": {
|
||||
"metaTitle": "AI 热点词云 - Agent Park",
|
||||
"metaDescription": "探索季度 AI 热点词汇的演变历程,从大型语言模型到 Agent 工作流",
|
||||
"badge": "AI 热点追踪",
|
||||
"title": "季度 AI",
|
||||
"titleHighlight": "热点词云",
|
||||
"subtitle": "从 “大型语言模型” 到 “Agent 工作流”。探索 AI 话语的演变历程。",
|
||||
"loading": "加载中...",
|
||||
"loadFailed": "加载失败",
|
||||
"retry": "重试"
|
||||
},
|
||||
"timeline": {
|
||||
"metaTitle": "AI 发展时间轴 - Agent Park",
|
||||
"metaDescription": "探索人工智能大语言模型的发展历程,从 2017 年 Transformer 到今天",
|
||||
"title": "AI 的故事",
|
||||
"subtitle": "钉住。堆叠。之字形。",
|
||||
"emptyData": "暂无数据",
|
||||
"collecting": "时间轴数据正在收集中...",
|
||||
"joinThePark": "加入 Agent Park",
|
||||
"subscribeDesc": "订阅 Agent Park 周刊。没有垃圾邮件,只有干货。",
|
||||
"emailPlaceholder": "您的电子邮箱...",
|
||||
"subscribe": "订阅",
|
||||
"agreeToBeCool": "我同意保持礼貌。",
|
||||
"earlier": "更早"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user