feat: monorepo 重构 + 新增 5 个平台适配器

项目从单体结构重构为 pnpm monorepo (shared/backend/frontend),
新增 YouTube、Instagram、Twitter/X、哔哩哔哩、微博 5 个平台适配器,
包含完整的单元测试和 E2E 测试覆盖。

- 完成 T-031~T-044: 5 个适配器实现、注册、配置和测试
- 重构前后端分离: Hono 后端 + Next.js 前端
- 151 个单元测试 + 21 个 Mock E2E + 25 个真实 E2E
- 适配器基于真实 TikHub API 响应结构实现

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wxs 2026-03-03 15:43:25 +08:00
parent ce736f197d
commit 6cc703ada2
136 changed files with 16805 additions and 520 deletions

View File

@ -0,0 +1,122 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 最重要的事情
1. **TDD 先行** - fix/feat 必须先写失败测试,红黄绿循环
2. **原子提交** - 每个 commit 只做一件事,可独立回滚
3. **文档驱动** - feat 改动关联 doc/ 下文档多输出表格、流程图、ASCII 原型图
4. **知识沉淀** - 有价值的迭代沉淀到 CLAUDE.md拿捏不准主动问我
5. **利用现有工具** - 不重复造轮子,会开车 > 会修车
6. **有头有尾** - 头:确认清楚再动手,不清楚就一直问;尾:自己跑验证,不把验证甩给用户
7. **任务结束后追加** - 主人,用不用我沉淀 or git 提交?
## 项目概述
**{{项目名称}}** — {{一句话描述}}
**产品目标**
- {{目标1}}
- {{目标2}}
- {{目标3}}
## 技术栈
| 层级 | 技术 | 说明 |
|------|------|------|
| 前端 | {{前端技术}} | {{说明}} |
| 后端 | {{后端技术}} | {{说明}} |
| 数据库 | {{数据库}} | {{说明}} |
| 缓存 | {{缓存方案,如无可删除此行}} | {{说明}} |
| AI 服务 | {{AI 服务,如无可删除此行}} | {{说明}} |
| 部署 | {{部署方案}} | {{说明}} |
## 项目结构
```
{{项目名称}}/
├── {{目录1}}/ # {{说明}}
│ ├── {{子目录}}/ # {{说明}}
│ └── {{子目录}}/ # {{说明}}
├── doc/ # 项目文档
│ ├── PRD.md
│ ├── DevelopmentPlan.md
│ └── tasks.md
└── {{其他文件}} # {{说明}}
```
## 常用命令
### 开发
```bash
{{开发启动命令}}
```
### 构建
```bash
{{构建命令}}
```
### 测试
```bash
{{测试命令}}
```
### 部署
```bash
{{部署命令}}
```
## 开发约定
- **包管理器**: 使用 {{包管理器}}(不是 {{其他包管理器}}
- **TDD 流程**: 先写测试再实现,核心业务逻辑覆盖率 100%
- **日志规范**: 使用日志管理器,避免 console.log
- **知识沉淀**: 将有价值的对话迭代沉淀到文档中,包括:
- 重要技术决策和架构演进 → 更新 CLAUDE.md 相关章节
- 新功能实现方案 → 更新组件职责、数据流等章节
- 踩坑经验和解决方案 → 添加到踩坑经验章节
- API 使用技巧和注意事项 → 更新相关技术栈说明
{{在此添加项目特定的开发约定}}
## 交互准则
### 任务有头有尾
**头 — 确认清楚再动手**
- 收到任务后,先复述理解、列出不确定的点
- 不确定就问,一直问到双方对齐为止,**绝不带着假设开工**
- 确认范围边界:做什么、不做什么、验收标准
**尾 — 自己验证,说到做到**
- 任务完成后,自己执行验证(跑测试、构建、截图、检查输出等)
- 把验证结果直接展示给用户,而不是列一堆步骤让用户自己验
- 验证不通过就自己修,循环直到通过
- 最终交付物 = 已通过的验证结果根据既定的方案(所以倒逼开始的时候更明确才执行,否则自己打自己的脸。)
### 其他
- 任务彻底结束后,追加一句:**主人,用不用我沉淀 or git 提交?**
## 踩坑经验
<!-- 格式示例:
### {{问题简述}}
**问题现象**{{描述现象}}
**根因**{{分析根因}}
**解决方案**{{解决方案}}
**注意事项**{{补充说明}}
-->

View File

@ -0,0 +1 @@
just empety...

View File

@ -0,0 +1,109 @@
---
name: changelog
description: 一键发版:生成更新日志 → commit → 打 tag全流程自动化。
---
# Changelog - 一键发版
> **定位**:发版全流程。`/changelog 1.0.0227.1` 一个命令搞定日志生成 + commit + tag。
## 用法
```
/changelog <version> # 例: /changelog 1.0.0227.1
/changelog # 不传版本号则自动推断
```
## 执行流程
### 1. 确定版本号
**有参数**:直接使用用户传入的版本号。
**无参数**:自动推断。读取最新 tagbump build 号 +1。例如当前最新 `v1.1.0211.1` → 推断为 `1.1.0211.2`。若无 tag 则提示用户输入。
版本格式:`{major}.{minor}.{MMDD}.{build}`
### 2. 获取 Commits
```bash
# 找到上一个 tag
git tag --sort=-creatordate
# 读取 commits从上一个 tag 到 HEAD
git log {prev_tag}..HEAD --oneline --no-merges
```
如果没有上一个 tag则从初始 commit 开始。
### 3. AI 总结
将 commit 列表总结为用户友好的中文更新说明:
| type | emoji | 说明 |
|------|-------|------|
| feat | ✨ | 新功能 / 新 Skill |
| fix | 🐛 | Bug 修复 |
| refactor | ♻️ | 代码重构 |
| docs | 📝 | 文档更新 |
| chore | 🔧 | 杂项维护 |
规则:
- 每条说明 1 句话,简洁明了
- 合并相关的小 commit 为一条
- 面向用户描述(不要出现技术细节如文件名、函数名)
- summary一句话概括本次更新的核心主题
### 4. 展示草稿,等待确认
```
📦 v{version} · {date}
{summary}
✨ 新增 /go 执行按钮 Skill...
📝 更新 README 文档格式...
🐛 修复仓库 URL 配置...
```
**必须等用户确认或修改后才继续**。
### 5. 写入文件
用户确认后,更新 `CHANGELOG.md`
- 文件不存在时创建,头部加 `# Changelog` 标题
- 将新条目插入已有内容**头部**(标题行之后,最新版本在前),保持现有条目不变
- 条目格式:
```markdown
## v{version} ({YYYY-MM-DD})
{summary}
- ✨ xxx
- 🐛 xxx
- ♻️ xxx
```
### 6. Commit + Tag
```bash
# Stage 变更文件
git add CHANGELOG.md
# Commit
git commit -m "release: v{version}"
# 打 tag
git tag v{version}
```
### 7. 完成提示
```
✅ 发版完成!
- 📝 CHANGELOG.md: 新增 v{version} 条目
- 🏷️ git tag: v{version}
需要 push 到远程吗?(git push && git push --tags)
```

View File

@ -0,0 +1,316 @@
---
name: deploy
description: Drone CI + 服务器 CD 全流程引导:从基础设施检查到生成配置文件到验证部署,交互式完成。
---
# Deploy - CI/CD 全流程部署引导
> **定位**:基于公司 Drone CI + 私有 Docker Registry + Docker Compose 的自动化部署方案,交互式引导用户从零完成 CI/CD 接入。
当用户调用 `/deploy``/deploy <指令>` 时,执行以下步骤:
## 1. 收集项目信息
快速了解项目情况(已知的不重复问):
| 项目 | 说明 | 示例 |
|------|------|------|
| 项目名称 | 用于镜像命名 | `douyin`, `crm`, `blog` |
| 需构建的服务 | 每个服务对应一个镜像 | `backend`, `frontend` |
| 各服务的 Dockerfile 路径 | Docker build context | `./backend`, `./frontend` |
| 生产服务器 SSH 端口 | 默认 22 | `22`, `3141` |
| 部署目录 | 生产服务器上的路径 | `/opt/docker/myproject` |
| 数据库迁移命令 | 如有 | `alembic upgrade head`, `npx prisma migrate deploy` |
| 健康检查方式 | 三选一 | python / curl / host |
| 健康检查 URL | 容器内地址 | `http://127.0.0.1:8000/health` |
| 通知 Webhook | 可选,不配则跳过 | 企业微信/钉钉/飞书 |
确认后进入下一步。
## 2. 基础设施检查
输出 Checklist让用户逐项确认首次接入需全部完成后续项目可跳过
```
□ 基础设施(一次性,已完成则跳过)
□ Drone CI Server + Runner 已部署运行
□ 私有 Docker Registry 已运行(默认 :5000
□ insecure-registries 已配置Drone CI 服务器 + 生产服务器)
□ SSH 密钥已配置Drone CI 服务器 → 生产服务器免密登录)
□ 生产服务器用户已加入 docker 组
```
如果用户表示基础设施未就绪,输出对应的一次性搭建指引(参见下方「附录:基础设施搭建」)。
## 3. 生成配置文件
基于收集到的信息,生成以下文件:
### 3.1 `.drone.yml`
核心原则(踩坑总结,不可违反):
1. 使用 `docker:27-cli` + 宿主机 Docker socket**不用** `plugins/docker` DinD
2. 使用 `environment: { VAR: { from_secret: name } }` 注入密钥,**不用** `secrets:` 字段
3. 使用 `${DRONE_TAG:-latest}` 作为镜像 tag**不自定义中间变量**Drone 变量替换冲突)
4. 触发条件只用 `event: [tag, cron]`**不叠加** `cron: [name]`AND 运算陷阱)
生成内容包括:
- `trigger`: tag + cron
- `volumes`: 挂载宿主机 Docker socket
- 每个服务的 `build-<service>` step
- `deploy` step使用 `appleboy/drone-ssh`
- `notify-success` / `notify-failure` step如配置了 Webhook
### 3.2 `scripts/deploy-remote.sh`
部署脚本要点:
- `set -euo pipefail` 严格模式
- 部署锁PID 文件防并发)
- 同时 export `IMAGE_TAG``VERSION`(兼容不同 compose 变量命名)
- 按顺序pull → 停 beat → 更新核心服务 → 健康检查 → 数据库迁移 → 启动剩余服务 → 最终健康检查
- 健康检查根据用户选择的方式生成python / curl / host
### 3.3 生成后展示
```
已生成:
📄 .drone.yml — Drone CI 流水线配置
📄 scripts/deploy-remote.sh — 远程部署脚本
确认写入?[Y/n]
```
用户确认后写入文件。
## 4. Drone 面板配置引导
生成文件后,输出需要在 Drone 面板手动配置的清单:
### 4.1 仓库设置
```
在 Drone 面板完成以下配置:
1. 激活仓库SYNC → 找到仓库 → ACTIVATE
2. 开启 TrustedSettings → General → Project Settings → 勾选 Trusted
```
### 4.2 Secrets 配置
根据收集到的信息,输出具体的 Secret 列表:
```
在 Drone 面板 → 仓库 Settings → Secrets 添加:
| Secret 名称 | 填写内容 |
|-------------------|----------------------------------------------------|
| backend_repo | docker.internal.intelligrow.cn:5000/{project}-backend |
| frontend_repo | docker.internal.intelligrow.cn:5000/{project}-frontend |
| deploy_host | {生产服务器 IP} |
| deploy_user | {SSH 用户} |
| deploy_ssh_key | cat ~/.ssh/drone_deploy 的完整内容 |
| deploy_path | {部署目录} |
| wecom_webhook | {Webhook URL}(如已配置) |
```
### 4.3 Cron 配置(可选)
```
如需定时构建,在 Settings → Cron Jobs 添加:
| 字段 | 值 | 说明 |
|----------|------------------|-------------------------|
| Name | nightly-build | 任务名称 |
| Branch | main | 构建分支 |
| Schedule | 0 16 * * * | UTC 16:00 = 北京 00:00 |
```
## 5. 生产服务器配置引导
```
在生产服务器上确认:
1. 部署目录结构:
{deploy_path}/
├── docker-compose.prod.yml
├── .env
└── scripts/
└── deploy-remote.sh ← 需从代码仓库复制
2. .env 至少包含:
DOCKER_REGISTRY=docker.internal.intelligrow.cn:5000
(其他数据库密码等生产配置)
3. docker-compose.prod.yml 中镜像引用格式:
image: ${DOCKER_REGISTRY}/{project}-backend:${VERSION:-latest}
⚠️ 变量一致性Drone Secret 的镜像地址前缀 = .env 的 DOCKER_REGISTRY = compose 中的镜像名拼接结果
```
## 6. 验证
自己执行可执行的验证,不能远程执行的给出命令让用户确认结果:
### 6.1 本地验证(自己执行)
```bash
# 检查 .drone.yml 语法合法性YAML 解析)
# 检查 deploy-remote.sh 语法bash -n
# 检查文件是否已正确写入
```
### 6.2 远程验证引导(输出命令,让用户在服务器上执行并反馈结果)
```bash
# 推送 Tag 触发首次构建
git tag v{version}
git push origin v{version}
# 观察 Drone 面板 pipeline 状态
# 生产服务器检查
ssh -p {port} {user}@{host} "cd {deploy_path} && docker compose -f docker-compose.prod.yml ps"
```
## 7. 完成输出
```
✅ CI/CD 接入完成!
📄 生成的文件:
- .drone.yml
- scripts/deploy-remote.sh
🔧 Drone 面板配置(需手动):
- [x] 仓库已激活
- [x] Trusted 已开启
- [x] Secrets 已添加
- [ ] Cron Job可选
🖥️ 生产服务器:
- [ ] .env 已配置
- [ ] deploy-remote.sh 已复制
- [ ] 首次部署成功
📖 回滚方案:
方式1: ssh 到生产服务器执行 bash scripts/deploy-remote.sh {旧版本tag}
方式2: git tag {旧版本}-rollback {旧版本} && git push origin {旧版本}-rollback
主人,用不用我沉淀 or git 提交?
```
---
## 附录:基础设施搭建
当用户表示基础设施未就绪时,按需输出以下指引:
### A. Drone CI 部署
在 Drone CI 服务器创建 `~/drone/docker-compose.yml`
```yaml
services:
drone-server:
image: drone/drone:2
container_name: drone-server
restart: always
ports:
- "3080:80"
environment:
- DRONE_GITEA_SERVER=https://<your-gitea-domain>
- DRONE_GITEA_CLIENT_ID=<gitea-oauth-client-id>
- DRONE_GITEA_CLIENT_SECRET=<gitea-oauth-client-secret>
- DRONE_SERVER_HOST=<your-drone-domain>
- DRONE_SERVER_PROTO=https
- DRONE_RPC_SECRET=<openssl rand -hex 16 生成>
- DRONE_USER_CREATE=username:<gitea用户名>,admin:true
volumes:
- ./data:/data
drone-runner:
image: drone/drone-runner-docker:1
container_name: drone-runner
restart: always
depends_on:
- drone-server
environment:
- DRONE_RPC_PROTO=http
- DRONE_RPC_HOST=drone-server
- DRONE_RPC_SECRET=< server 相同>
- DRONE_RUNNER_CAPACITY=2
- DRONE_RUNNER_NAME=drone-runner-1
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
关键注意:
- `DRONE_RPC_PROTO=http`Runner 走 Docker 内网直连,不走 HTTPS
- `DRONE_USER_CREATE` 的 username 必须与 Gitea **登录用户名**完全一致(不是邮箱)
### B. 私有 Registry
```bash
docker run -d --name registry \
-p 5000:5000 \
-v /opt/registry-data:/var/lib/registry \
--restart always \
registry:2
```
### C. insecure-registries 配置
在 Drone CI 服务器和生产服务器的 `/etc/docker/daemon.json` 添加:
```json
{
"insecure-registries": ["<registry-host>:5000"]
}
```
**不要带 `http://` 前缀**,直接写 `host:port`。修改后 `sudo systemctl restart docker`
### D. SSH 免密
```bash
# Drone CI 服务器上生成密钥
ssh-keygen -t ed25519 -C "drone-ci-deploy" -f ~/.ssh/drone_deploy -N ""
# 将公钥添加到生产服务器
ssh-copy-id -i ~/.ssh/drone_deploy.pub -p <port> <user>@<production-ip>
# 验证
ssh -i ~/.ssh/drone_deploy -p <port> <user>@<production-ip> "echo ok"
```
---
## 踩坑清单(生成配置时必须规避)
| # | 坑 | 正确做法 |
|---|-----|---------|
| 1 | `insecure-registries``http://` 前缀 | 直接写 `host:port` |
| 2 | Drone `${VAR}` 与 shell 变量冲突 | 直接用 `${DRONE_TAG:-latest}`,不赋中间变量 |
| 3 | 用 `secrets:` 字段注入 secret | 用 `environment: { VAR: { from_secret: name } }` |
| 4 | `plugins/docker` DinD 启动失败 | 用 `docker:27-cli` + 挂载 Docker socket |
| 5 | `DRONE_USER_CREATE` 填邮箱 | 必须填 Gitea 登录用户名 |
| 6 | `event + cron` 触发条件互斥 | 只用 `event: [tag, cron]`,不加 `cron:` 过滤 |
| 7 | Registry 地址不一致IP vs 域名) | Drone Secret、`.env`、compose 三处统一 |
| 8 | SSH 端口不对 | `appleboy/drone-ssh` 显式指定 `port` |
| 9 | Docker 权限不足 | `sudo usermod -aG docker <user>` 后重新登录 |
| 10 | `daemon.json` 被覆盖 | 修改前先 cat 查看,合并内容 |
---
## 故障排查速查表
| 现象 | 检查方向 |
|------|---------|
| Pipeline 不触发 | Gitea Webhook 是否勾选"创建"事件;`.drone.yml` trigger |
| Step 一直 pending | Runner 是否连通 Server仓库是否 Trusted |
| 构建报 secret 为空 | `environment: from_secret` 而非 `secrets:` |
| Docker push 失败 (HTTPS) | 两台服务器 `insecure-registries` 配置 |
| SSH 部署超时 | 密钥是否正确端口是否匹配Docker 权限 |
| 镜像名 invalid reference | `.env``DOCKER_REGISTRY` 变量是否正确 |
| 数据库迁移失败 | `docker compose logs -f <service>` |
| 健康检查超时 | 增大 `MAX_ATTEMPTS`;检查服务启动日志 |

325
.claude/skills/go/SKILL.md Normal file
View File

@ -0,0 +1,325 @@
---
name: go
description: 终极执行按钮,激进模式一口气完成开发任务,兼容 0->1 和 1->100 场景。
---
# Go - 发射按钮
> **定位**:执行按钮。无论是从零开始的 0->1还是迭代优化的 1->100按下 `/go` 就开始干活,不要停。
当用户调用 `/go``/go <任务范围>` 时,执行以下步骤:
## 1. 前置检查
### 1.1 必要文档检查
检查以下文件是否存在:
| 文件 | 必要性 | 用途 |
|------|--------|------|
| `doc/tasks.md` | **必须** | 任务清单,执行的圣经 |
| `doc/PRD.md` | **必须** | 产品需求,理解业务 |
| `doc/FeatureSummary.md` | 建议 | 功能契约 |
| `doc/DevelopmentPlan.md` | 建议 | 技术方案 |
| `doc/UIDesign.md` | 可选 | 界面设计 |
| `doc/tdd.md` | 可选 | 测试用例 |
**缺少必要文档时**
```
❌ 缺少必要文档:
- doc/tasks.md (必须)
- doc/PRD.md (必须)
请先准备这些文档,或运行:
- /wp 生成 PRD
- /wt 生成 tasks
```
### 1.2 读取所有可用文档
读取存在的所有文档,建立完整上下文。
## 2. 智能判断执行范围
### 2.1 检测项目状态
```
┌─────────────────────────────────────────────────────────┐
│ 项目状态检测 │
├─────────────────────────────────────────────────────────┤
│ │
│ 检查 src/ 或主代码目录是否存在? │
│ │
│ ├── 不存在 ──▶ 0->1 模式(全新项目) │
│ │ │
│ └── 存在 ──▶ 检查 tasks.md 中的 ITER 标记 │
│ │ │
│ ├── 有 ITER 标记 ──▶ 1->100 模式 │
│ │ │
│ └── 无 ITER 标记 ──▶ 继续未完成任务 │
│ │
└─────────────────────────────────────────────────────────┘
```
### 2.2 确定任务范围
**用户指定范围**
```bash
/go T-005 # 执行单个任务
/go T-005~T-010 # 执行任务范围
/go T-005 T-008 # 执行多个指定任务
```
**自动判断范围**
| 场景 | 执行范围 |
|------|----------|
| 0->1 全新项目 | tasks.md 中的所有任务,从 T-001 开始 |
| 1->100 有 ITER 标记 | 优先执行 `<!-- ITER: -->` 标记的新任务 |
| 1->100 无 ITER 标记 | 执行所有状态为 pending/todo 的任务 |
### 2.3 向用户确认范围(唯一一次交互)
```
检测到项目状态:{0->1 全新项目 / 1->100 迭代项目}
即将执行任务:
- T-001: {任务名}
- T-002: {任务名}
- ...
- T-xxx: {任务名}
共 X 个任务。确认执行?[Y/n]
```
**用户确认后,不再有任何交互,直到全部完成。**
## 3. 激进模式执行
### 3.1 执行原则
```
┌─────────────────────────────────────────────────────────┐
│ 激进模式执行原则 │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. 以 tasks.md 为圣经,严格按顺序执行 │
│ │
│ 2. 不要停下来问用户,自主决策 │
│ │
│ 3. 遇到问题自主修复,修复失败则记录并继续 │
│ │
│ 4. 发现文档冲突,基于架构经验选最优解,注释说明 │
│ │
│ 5. 利用所有可用工具搜索、MCP、Skills │
│ │
│ 6. 每完成一个模块Git 提交一次 │
│ │
└─────────────────────────────────────────────────────────┘
```
### 3.2 任务执行流程
```
对于每个任务 T-xxx
├── 1. 读取任务详情(描述、验收标准、依赖)
├── 2. 检查依赖任务是否完成
│ └── 未完成 → 先执行依赖任务
├── 3. 执行任务
│ ├── 根据任务类型选择执行方式
│ ├── 编写代码 / 配置 / 测试
│ └── 验证验收标准
├── 4. 遇到问题?
│ ├── 尝试自主修复(最多 3 次)
│ ├── 修复成功 → 继续
│ └── 修复失败 → 记录问题,跳过,继续下一个
└── 5. 标记任务完成,更新 tasks.md
```
### 3.3 自主修复策略
| 问题类型 | 修复策略 |
|----------|----------|
| 编译错误 | 分析错误信息,修复代码 |
| 类型错误 | 检查类型定义,修复类型 |
| 依赖缺失 | 安装依赖包 |
| 测试失败 | 修复功能代码使测试通过 |
| 文档冲突 | 基于架构经验选最优解 |
| 未知错误 | 搜索解决方案,尝试修复 |
## 4. Git 提交规则
### 4.1 提交时机
每完成一个**模块/Sprint**后立即提交:
```
T-001 ~ T-004 → 提交一次(初始化模块)
T-005 ~ T-008 → 提交一次(核心功能模块)
T-009 ~ T-012 → 提交一次(扩展功能模块)
...
```
### 4.2 提交信息格式
```
feat(<scope>): <简要描述>
- 完成 T-xxx: {任务名}
- 完成 T-xxx: {任务名}
- ...
Co-Authored-By: Claude <noreply@anthropic.com>
```
**示例**
```
feat(auth): 完成用户认证模块
- 完成 T-005: 用户登录功能
- 完成 T-006: 用户注册功能
- 完成 T-007: JWT Token 管理
- 完成 T-008: 权限验证中间件
Co-Authored-By: Claude <noreply@anthropic.com>
```
## 5. 进度汇报
### 5.1 模块完成汇报
每完成一个模块,简要汇报:
```
✅ 模块完成:{模块名}
- T-005: 用户登录 ✓
- T-006: 用户注册 ✓
- T-007: JWT 管理 ✓
- T-008: 权限验证 ✓
Git 提交: feat(auth): 完成用户认证模块
继续执行下一模块...
```
### 5.2 最终汇报
全部完成后,输出完整报告:
```
## 🚀 执行完成
**执行模式**: {0->1 全新项目 / 1->100 迭代}
**任务统计**:
| 状态 | 数量 |
|------|------|
| ✅ 完成 | X 个 |
| ⚠️ 跳过 | X 个 |
| ❌ 失败 | X 个 |
**Git 提交记录**:
- feat(init): 项目初始化
- feat(auth): 用户认证模块
- feat(core): 核心功能模块
- ...
**跳过/失败的任务**(如有):
| 任务 | 原因 |
|------|------|
| T-xxx | {原因} |
**下一步建议**:
- 运行 `npm run dev` 验证
- 运行 `npm run test` 测试
- 检查跳过的任务
```
## 6. 特殊场景处理
### 6.1 技术栈识别
从文档中识别技术栈,自动适配:
| 识别来源 | 技术决策 |
|----------|----------|
| package.json 存在 | Node.js 项目 |
| requirements.txt 存在 | Python 项目 |
| DevelopmentPlan 指定 | 按文档技术栈 |
| 无明确指定 | 询问用户(唯一例外) |
### 6.2 测试策略
- 功能开发完成后执行测试任务
- 测试失败 → **先修复功能代码使测试通过**
- 不跳过失败的测试继续部署
### 6.3 部署任务
- 先本地测试验证
- 确保 build 和 start 正常
- 远程部署需用户额外确认
---
## 工作流总览
```
/go
├── 1. 前置检查
│ ├── tasks.md 存在? ──▶ 必须
│ └── PRD.md 存在? ──▶ 必须
├── 2. 读取文档,建立上下文
├── 3. 智能判断
│ ├── 项目状态0->1 / 1->100
│ └── 任务范围
├── 4. 确认执行范围(唯一交互)
├── 5. 激进模式执行
│ ├── 按顺序执行任务
│ ├── 自主修复问题
│ ├── 模块完成 → Git 提交
│ └── 汇报进度,继续下一个
└── 6. 最终汇报
├── 任务统计
├── Git 提交记录
└── 下一步建议
```
## 注意事项
- **tasks.md 是圣经**,严格按其顺序和内容执行
- **不要停下来问用户**,自主决策,自主修复
- **遇到无法解决的问题**,记录并跳过,最后汇报
- **每完成模块立即提交**,避免大量代码丢失风险
- **利用所有工具**搜索、MCP、其他 Skills
## 与其他 Skill 的关系
| 场景 | 使用方式 |
|------|----------|
| 准备文档 | `/wp` `/wf` `/wd` `/wu` `/wt` |
| 评审文档 | `/rp` `/rf` `/rd` `/ru` `/rt` |
| 修改文档 | `/mp` `/mf` `/md` `/mu` `/mt` |
| 迭代变更(更新文档) | `/iter` |
| **执行开发(本 Skill** | `/go` |
**典型工作流**
```
0->1需求 → /wp → /wf → /wd → /wt → /go
1->100发现问题 → /iter → /go
```

View File

@ -0,0 +1,210 @@
---
name: iter
description: 迭代变更入口,调研问题后更新 PRD.md 和 tasks.md支持 Bug 修复、功能迭代、技术重构。
---
# Iterate - 迭代变更
> **定位**1-100 阶段的变更入口。项目已上线,需要修复问题或迭代功能时,通过此 skill 调研、澄清、更新文档。
当用户调用 `/iter``/iter <问题描述>` 时,执行以下步骤:
⚠️ 重要:本 skill 只修改文档PRD.md、tasks.md绝不执行代码、不运行命令、不修改源文件。
## 1. 获取变更描述
如果用户提供了参数,使用该描述。否则询问:
> 请描述需要迭代的内容Bug/功能/重构)
**示例输入**
- "登录验证存在漏洞token 过期后仍可访问"
- "列表页需要增加按时间筛选功能"
- "用户模块性能太差,需要重构缓存策略"
## 2. 调研分析
### 2.1 读取现有文档
读取以下文件了解当前状态:
1. `doc/PRD.md` - 了解产品定义
2. `doc/tasks.md` - 了解任务现状
### 2.2 调研相关代码(可选)
根据问题描述,定位相关代码文件:
- 搜索关键词定位文件
- 读取相关模块代码
- 分析现有实现
### 2.3 分析变更类型
| 类型 | 特征 | 影响范围 |
|------|------|----------|
| Bug/漏洞 | 现有功能不符合预期 | 修复逻辑,可能涉及安全 |
| 功能迭代 | 在现有功能上增加/调整 | 新增或修改功能点 |
| 技术重构 | 不改功能,优化实现 | 性能、架构、代码质量 |
## 3. 澄清确认
**【必须】向用户提出澄清问题**,确保理解准确:
### 3.1 问题理解确认
向用户确认:
> 我理解的变更需求是:{一句话总结}
>
> 变更类型:{Bug修复 / 功能迭代 / 技术重构}
>
> 影响范围:{涉及的模块/功能}
### 3.2 方案选择(如有多种)
如果有多种解决方案,列出选项让用户选择:
```
方案 A{描述}
- 优点:...
- 缺点:...
方案 B{描述}
- 优点:...
- 缺点:...
请选择方案,或说明其他想法。
```
### 3.3 边界确认
确认变更边界:
> 本次变更**包含**
> - {范围1}
> - {范围2}
>
> 本次变更**不包含**
> - {排除项}
>
> 是否确认?
## 4. 用户确认后执行
**只有用户明确确认后**,才执行以下更新:
### 4.1 更新 PRD.md
使用增量修改标记:
```markdown
<!-- ITER: {日期} - {变更简述} -->
<!-- NEW START -->
新增内容...
<!-- NEW END -->
```
或修改现有内容:
```markdown
<!-- ITER: {日期} - {变更简述} -->
<!-- MODIFIED: 原内容为 "xxx" -->
修改后的内容
```
**更新位置**
- Bug 修复 → 更新对应功能的验收标准
- 功能迭代 → 在 3.2 功能详情添加/修改功能点
- 技术重构 → 在 4.x 非功能需求或 7.1 技术约束中说明
### 4.2 更新 tasks.md
新增任务使用标记:
```markdown
<!-- ITER: {日期} - {变更简述} -->
| T-xxx | {任务名} | {描述} | {依赖} | {优先级} | {验收标准} |
```
**任务 ID 规则**
- 查找现有最大 ID递增分配
- 格式T-xxx三位数字
### 4.3 标记规范
所有变更使用 `<!-- ITER: -->` 前缀,区分于 `/mp` `/mt` 的标记:
- `<!-- ITER: 2026-01-23 - 修复登录验证漏洞 -->`
- 便于追溯迭代历史
## 5. 输出摘要
完成后向用户展示:
```
## 迭代变更完成
**变更类型**: {Bug修复 / 功能迭代 / 技术重构}
**变更摘要**: {一句话描述}
**已更新文档**:
- doc/PRD.md: {更新位置}
- doc/tasks.md: 新增任务 T-xxx
**新增任务**:
| ID | 任务 | 优先级 |
|----|------|--------|
| T-xxx | {任务名} | P0/P1/P2 |
**下一步**:
- 执行任务 T-xxx
- 或运行 `/rp` `/rt` 评审变更
```
---
## 工作流示意
```
用户描述问题
┌─────────────┐
│ 调研分析 │ ──▶ 读取 PRD、tasks、相关代码
└──────┬──────┘
┌─────────────┐
│ 澄清确认 │ ──▶ 提问 → 用户回答 → 确认方案
└──────┬──────┘
▼ 用户确认
┌─────────────┐
│ 更新文档 │ ──▶ PRD.md + tasks.md
└──────┬──────┘
┌─────────────┐
│ 输出摘要 │
└─────────────┘
```
## 注意事项
- **必须先澄清确认**,不要假设用户意图
- 变更范围要明确,避免 scope creep
- 优先级根据问题严重程度判断:
- 安全漏洞 → P0
- 功能 Bug → P0/P1
- 功能迭代 → P1/P2
- 技术重构 → P1/P2
- 只更新 PRD + tasks保持轻量
- 如需更新其他文档,提示用户手动运行 `/mf` `/md`
## 与其他 skill 的关系
| 场景 | 使用 skill |
|------|------------|
| 迭代变更入口 | `/iter`(本 skill |
| 需要更新 FeatureSummary | `/iter` 后运行 `/mf` |
| 需要更新 DevelopmentPlan | `/iter` 后运行 `/md` |
| 需要评审变更 | `/iter` 后运行 `/rp` `/rt` |
| 从头生成文档 | 使用 `/wp` `/wf` `/wd` 等 |

112
.claude/skills/md/SKILL.md Normal file
View File

@ -0,0 +1,112 @@
---
name: md
description: 增量修改 DevelopmentPlan.md根据用户指令在现有内容基础上更新开发计划。
---
# Modify DevelopmentPlan
当用户调用 `/md` 时,执行以下步骤:
## 1. 读取目标文档
读取以下文件:
1. `doc/DevelopmentPlan.md` - 目标文档(必须存在)
2. `doc/FeatureSummary.md` - 上游参考文档
3. `doc/review-DevelopmentPlan-claude.md` - 评审报告(如果存在,自动作为修改依据)
如果 DevelopmentPlan.md 不存在,提示用户:
> DevelopmentPlan.md 不存在,请先使用 `/wd` 生成开发计划。
## 2. 确定修改来源
按以下优先级确定修改内容:
### 2.1 用户提供了修改指令
如果用户在调用 `/md` 时附带了参数或说明,直接使用该指令。
### 2.2 自动检测评审报告
如果用户未提供修改指令,**自动检测** `doc/review-DevelopmentPlan-claude.md` 是否存在:
- **存在**:读取评审报告,提取其中的问题清单,作为本次修改的依据。向用户确认:
> 检测到评审报告,包含 X 个问题。是否根据评审报告进行修改?
- **不存在**:询问用户:
> 请说明需要修改的内容,或先运行 `/rd` 生成评审报告。
## 3. 修改原则
### 3.1 增量修改
- 保留原有内容结构和格式
- 仅修改/新增指定部分
- 不删除未明确要求删除的内容
### 3.2 新增内容标记
对于新增的段落或章节:
```markdown
<!-- NEW START -->
新增内容...
<!-- NEW END -->
```
对于行内新增:
```markdown
原有内容 <!-- NEW --> 新增内容
```
### 3.3 修改内容标记
```markdown
<!-- MODIFIED: 原内容为 "xxx" -->
修改后的内容
```
### 3.4 与 FeatureSummary 一致性
- 开发任务必须覆盖所有功能
- 技术方案必须支撑功能需求
- 阶段划分必须合理
## 4. 执行修改
| 修改类型 | 处理方式 |
|----------|----------|
| 新增开发任务 | 在对应阶段表格中添加行 |
| 修改技术方案 | 更新技术方案章节,添加 MODIFIED 标记 |
| 调整阶段划分 | 移动任务到新阶段,标记变更 |
| 新增风险项 | 在风险管理表格中添加行 |
| 修改里程碑 | 更新里程碑表格 |
## 5. 保存并验证
1. 保存修改后的文档到 `doc/DevelopmentPlan.md`
2. 使用 git diff 展示变更内容
3. 向用户确认修改是否符合预期
## 6. 输出摘要
向用户展示修改摘要:
- 修改位置(章节/行号)
- 修改类型(新增/修改/删除)
- 修改内容概要
- 与 FeatureSummary 的一致性确认
---
## 注意事项
- DevelopmentPlan 依赖于 FeatureSummary修改时需确保与上游一致
- 修改后下游文档UIDesign、tasks可能需要同步更新
- 技术方案修改需谨慎评估影响范围
- 建议修改完成后运行 `/ru` 检查下游一致性
## 标记清理
用户确认修改无误后,可手动删除标记或保留作为变更历史参考。

111
.claude/skills/mf/SKILL.md Normal file
View File

@ -0,0 +1,111 @@
---
name: mf
description: 增量修改 FeatureSummary.md根据用户指令在现有内容基础上更新功能摘要。
---
# Modify FeatureSummary
当用户调用 `/mf` 时,执行以下步骤:
## 1. 读取目标文档
读取以下文件:
1. `doc/FeatureSummary.md` - 目标文档(必须存在)
2. `doc/PRD.md` - 上游参考文档
3. `doc/review-FeatureSummary-claude.md` - 评审报告(如果存在,自动作为修改依据)
如果 FeatureSummary.md 不存在,提示用户:
> FeatureSummary.md 不存在,请先使用 `/wf` 生成功能摘要。
## 2. 确定修改来源
按以下优先级确定修改内容:
### 2.1 用户提供了修改指令
如果用户在调用 `/mf` 时附带了参数或说明,直接使用该指令。
### 2.2 自动检测评审报告
如果用户未提供修改指令,**自动检测** `doc/review-FeatureSummary-claude.md` 是否存在:
- **存在**:读取评审报告,提取其中的问题清单,作为本次修改的依据。向用户确认:
> 检测到评审报告,包含 X 个问题。是否根据评审报告进行修改?
- **不存在**:询问用户:
> 请说明需要修改的内容,或先运行 `/rf` 生成评审报告。
## 3. 修改原则
### 3.1 增量修改
- 保留原有内容结构和格式
- 仅修改/新增指定部分
- 不删除未明确要求删除的内容
### 3.2 新增内容标记
对于新增的段落或章节:
```markdown
<!-- NEW START -->
新增内容...
<!-- NEW END -->
```
对于行内新增:
```markdown
原有内容 <!-- NEW --> 新增内容
```
### 3.3 修改内容标记
```markdown
<!-- MODIFIED: 原内容为 "xxx" -->
修改后的内容
```
### 3.4 与 PRD 一致性
- 所有功能必须来源于 PRD
- 修改后的功能描述必须与 PRD 一致
- 优先级必须与 PRD 匹配
## 4. 执行修改
| 修改类型 | 处理方式 |
|----------|----------|
| 新增功能 | 在对应模块表格中添加行 |
| 修改描述 | 更新功能描述,添加 MODIFIED 标记 |
| 修改优先级 | 更新优先级列 |
| 新增模块 | 在功能清单中添加新章节 |
| 删除功能 | 标记为删除而非直接移除 |
## 5. 保存并验证
1. 保存修改后的文档到 `doc/FeatureSummary.md`
2. 使用 git diff 展示变更内容
3. 向用户确认修改是否符合预期
## 6. 输出摘要
向用户展示修改摘要:
- 修改位置(章节/行号)
- 修改类型(新增/修改/删除)
- 修改内容概要
- 与 PRD 的一致性确认
---
## 注意事项
- FeatureSummary 依赖于 PRD修改时需确保与上游一致
- 修改后下游文档DevelopmentPlan 等)可能需要同步更新
- 建议修改完成后运行 `/rd` 检查下游一致性
## 标记清理
用户确认修改无误后,可手动删除标记或保留作为变更历史参考。

144
.claude/skills/mp/SKILL.md Normal file
View File

@ -0,0 +1,144 @@
---
name: mp
description: 增量修改 PRD.md根据用户指令在现有内容基础上更新产品需求文档。
---
# Modify PRD
当用户调用 `/mp` 时,执行以下步骤:
## 1. 读取目标文档
读取以下文件:
1. `doc/PRD.md` - 目标文档(必须存在)
2. `doc/RequirementsDoc.md` - 上游参考文档
3. `doc/review-PRD-claude.md` - 评审报告(如果存在,自动作为修改依据)
如果 PRD.md 不存在,提示用户:
> PRD.md 不存在,请先使用 `/wp` 生成产品需求文档。
## 2. 确定修改来源
按以下优先级确定修改内容:
### 2.1 用户提供了修改指令
如果用户在调用 `/mp` 时附带了参数或说明,直接使用该指令。
### 2.2 自动检测评审报告
如果用户未提供修改指令,**自动检测** `doc/review-PRD-claude.md` 是否存在:
- **存在**读取评审报告提取其中的问题清单Critical / Major / Minor作为本次修改的依据。向用户确认
> 检测到评审报告 `doc/review-PRD-claude.md`,包含 X 个问题。是否根据评审报告进行修改?
- **不存在**:询问用户:
> 请说明需要修改的内容,或先运行 `/rp` 生成评审报告。
### 2.3 支持的修改来源
- 具体的修改描述(如"在功能需求中增加用户权限管理模块"
- 评审报告(自动检测或手动指定路径)
- 对应的 RequirementsDoc 变更(如"/mr 已更新需求,请同步 PRD"
## 3. 修改原则
### 3.1 增量修改
- 保留原有内容结构和格式
- 仅修改/新增指定部分
- 不删除未明确要求删除的内容
### 3.2 新增内容标记
对于新增的段落或章节,使用 HTML 注释标记:
```markdown
<!-- NEW START -->
新增内容...
<!-- NEW END -->
```
对于行内新增,使用:
```markdown
原有内容 <!-- NEW --> 新增内容
```
### 3.3 修改内容标记
对于修改的内容,保留原文作为注释:
```markdown
<!-- MODIFIED: 原内容为 "xxx" -->
修改后的内容
```
### 3.4 与 RequirementsDoc 一致性
- 所有 PRD 内容必须可追溯到 RequirementsDoc
- 如果修改涉及新功能,先确认 RequirementsDoc 中已有对应需求
- 如果 RequirementsDoc 未包含相关需求,提醒用户先更新需求文档
## 4. 执行修改
按照用户指令修改文档:
1. 定位到需要修改的位置
2. 执行增量修改
3. 添加相应的标记
4. 保持文档格式一致性
5. 确保修改内容与 RequirementsDoc 一致
### 4.1 修改类型处理
| 修改类型 | 处理方式 |
|----------|----------|
| 新增功能点 | 在对应功能模块表格中添加行,关联用户故事 |
| 新增用户故事 | 在 2.2 用户故事列表中添加,分配 US-xxx ID |
| 修改优先级 | 更新功能点优先级,必要时调整用户故事分类 |
| 修改验收标准 | 更新对应功能点的验收标准列 |
| 新增模块 | 在 3.2 功能详情中添加新的子章节 |
| 修改非功能需求 | 在对应章节更新指标或要求 |
## 5. 保存并验证
1. 保存修改后的文档到 `doc/PRD.md`
2. 使用 git diff 展示变更内容
3. 向用户确认修改是否符合预期
## 6. 输出摘要
向用户展示修改摘要:
- 修改位置(章节/行号)
- 修改类型(新增/修改/删除)
- 修改内容概要
- 与 RequirementsDoc 的一致性确认
---
## 注意事项
- PRD 依赖于 RequirementsDoc修改时需确保与上游文档一致
- 修改 PRD 后下游文档FeatureSummary、DevelopmentPlan 等)可能需要同步更新
- 保持现有文档风格(标题层级、表格格式、列表样式)
- 用户故事 ID 必须唯一且连续US-001, US-002...
- 所有功能点必须关联到用户故事
- 重大修改建议先运行 `/rp` 评审确认影响范围
- 修改完成后,建议用户运行 `/rf` 检查下游文档一致性
## 标记清理
当用户确认修改无误后,可手动删除 `<!-- NEW -->``<!-- MODIFIED -->` 标记,或保留作为变更历史参考。
通过 git 可追溯完整修改历史。
## 质量检查
修改 PRD 后,自查以下项目:
- [ ] 修改内容与 RequirementsDoc 一致
- [ ] 新增用户故事有唯一 ID
- [ ] 新增功能点关联到用户故事
- [ ] 新增功能点有明确优先级和验收标准
- [ ] 标记格式正确(`<!-- NEW -->` / `<!-- MODIFIED -->`
- [ ] 文档结构完整,格式一致

View File

@ -0,0 +1,95 @@
---
name: mr
description: 增量修改 RequirementsDoc.md根据用户指令在现有内容基础上更新需求文档。
---
# Modify RequirementsDoc
当用户调用 `/mr` 时,执行以下步骤:
## 1. 读取目标文档
读取 `doc/RequirementsDoc.md` 文件。
如果文件不存在,提示用户:
> RequirementsDoc.md 不存在,请先使用人工方式创建需求文档。
## 2. 获取修改指令
向用户确认修改内容。用户应提供以下信息之一:
- 具体的修改描述(如"在第3节增加性能需求"
- 评审报告路径(如 `doc/review-RequirementsDoc-claude.md`
- 直接的修改内容
如果用户未提供修改指令,询问:
> 请说明需要修改的内容,或提供评审报告路径。
## 3. 修改原则
### 3.1 增量修改
- 保留原有内容结构和格式
- 仅修改/新增指定部分
- 不删除未明确要求删除的内容
### 3.2 新增内容标记
对于新增的段落或章节,使用 HTML 注释标记:
```markdown
<!-- NEW START -->
新增内容...
<!-- NEW END -->
```
对于行内新增,使用:
```markdown
原有内容 <!-- NEW --> 新增内容
```
### 3.3 修改内容标记
对于修改的内容,保留原文作为注释:
```markdown
<!-- MODIFIED: 原内容为 "xxx" -->
修改后的内容
```
## 4. 执行修改
按照用户指令修改文档:
1. 定位到需要修改的位置
2. 执行增量修改
3. 添加相应的标记
4. 保持文档格式一致性
## 5. 保存并验证
1. 保存修改后的文档到 `doc/RequirementsDoc.md`
2. 使用 git diff 展示变更内容
3. 向用户确认修改是否符合预期
## 6. 输出摘要
向用户展示修改摘要:
- 修改位置(章节/行号)
- 修改类型(新增/修改/删除)
- 修改内容概要
---
## 注意事项
- RequirementsDoc 是文档链源头,修改会影响所有下游文档
- 修改前确认用户意图,避免误改
- 保持现有文档风格(标题层级、表格格式、列表样式)
- 重大修改建议先运行 `/rr` 评审确认影响范围
- 修改完成后,建议用户检查下游文档是否需要同步更新
## 标记清理
当用户确认修改无误后,可手动删除 `<!-- NEW -->``<!-- MODIFIED -->` 标记,或保留作为变更历史参考。
通过 git 可追溯完整修改历史。

132
.claude/skills/mt/SKILL.md Normal file
View File

@ -0,0 +1,132 @@
---
name: mt
description: 增量修改 tasks.md根据用户指令在现有内容基础上更新任务列表。
---
# Modify Tasks
当用户调用 `/mt` 时,执行以下步骤:
## 1. 读取目标文档
读取以下文件:
1. `doc/tasks.md` - 目标文档(必须存在)
2. `doc/UIDesign.md` - 上游参考文档
3. `doc/DevelopmentPlan.md` - 上游参考文档
4. `doc/review-tasks-claude.md` - 评审报告(如果存在,自动作为修改依据)
如果 tasks.md 不存在,提示用户:
> tasks.md 不存在,请先使用 `/wt` 生成任务列表。
## 2. 确定修改来源
按以下优先级确定修改内容:
### 2.1 用户提供了修改指令
如果用户在调用 `/mt` 时附带了参数或说明,直接使用该指令。
### 2.2 自动检测评审报告
如果用户未提供修改指令,**自动检测** `doc/review-tasks-claude.md` 是否存在:
- **存在**:读取评审报告,提取其中的问题清单,作为本次修改的依据。向用户确认:
> 检测到评审报告,包含 X 个问题。是否根据评审报告进行修改?
- **不存在**:询问用户:
> 请说明需要修改的内容,或先运行 `/rt` 生成评审报告。
## 3. 修改原则
### 3.1 增量修改
- 保留原有内容结构和格式
- 仅修改/新增指定部分
- 不删除未明确要求删除的内容
### 3.2 新增内容标记
对于新增的段落或章节:
```markdown
<!-- NEW START -->
新增内容...
<!-- NEW END -->
```
对于行内新增:
```markdown
原有内容 <!-- NEW --> 新增内容
```
### 3.3 修改内容标记
```markdown
<!-- MODIFIED: 原内容为 "xxx" -->
修改后的内容
```
### 3.4 与上游文档一致性
- 任务必须覆盖 DevelopmentPlan 所有开发项
- 任务必须覆盖 UIDesign 所有页面实现
- 任务依赖关系必须合理
## 4. 执行修改
| 修改类型 | 处理方式 |
|----------|----------|
| 新增任务 | 在对应阶段表格中添加行,分配新 ID |
| 修改描述 | 更新任务描述,添加 MODIFIED 标记 |
| 修改优先级 | 更新优先级列 |
| 修改依赖 | 更新依赖列,检查循环依赖 |
| 修改验收标准 | 更新验收标准列 |
| 调整阶段 | 移动任务到新阶段,更新依赖图 |
### 4.1 任务 ID 规则
- 新增任务 ID 必须唯一
- ID 格式T-XXX三位数字如 T-001
- 在现有最大 ID 基础上递增
## 5. 保存并验证
1. 保存修改后的文档到 `doc/tasks.md`
2. 使用 git diff 展示变更内容
3. 向用户确认修改是否符合预期
## 6. 输出摘要
向用户展示修改摘要:
- 修改位置(章节/行号)
- 修改类型(新增/修改/删除)
- 修改内容概要
- 新增/修改的任务 ID 列表
- 与上游文档的一致性确认
---
## 注意事项
- tasks.md 是文档链末端,修改不影响其他文档
- 任务 ID 必须唯一,不可重复使用已删除的 ID
- 修改依赖关系时需检查是否产生循环依赖
- 验收标准必须具体可测试
- 任务粒度要适中
## 标记清理
用户确认修改无误后,可手动删除标记或保留作为变更历史参考。
## 质量检查
修改 tasks 后,自查以下项目:
- [ ] 任务 ID 唯一且格式正确
- [ ] 无循环依赖
- [ ] 验收标准明确
- [ ] 覆盖所有上游功能
- [ ] 标记格式正确

114
.claude/skills/mu/SKILL.md Normal file
View File

@ -0,0 +1,114 @@
---
name: mu
description: 增量修改 UIDesign.md根据用户指令在现有内容基础上更新 UI 设计文档。
---
# Modify UIDesign
当用户调用 `/mu` 时,执行以下步骤:
## 1. 读取目标文档
读取以下文件:
1. `doc/UIDesign.md` - 目标文档(必须存在)
2. `doc/DevelopmentPlan.md` - 上游参考文档
3. `doc/review-UIDesign-claude.md` - 评审报告(如果存在,自动作为修改依据)
如果 UIDesign.md 不存在,提示用户:
> UIDesign.md 不存在,请先使用 `/wu` 生成 UI 设计文档。
## 2. 确定修改来源
按以下优先级确定修改内容:
### 2.1 用户提供了修改指令
如果用户在调用 `/mu` 时附带了参数或说明,直接使用该指令。
### 2.2 自动检测评审报告
如果用户未提供修改指令,**自动检测** `doc/review-UIDesign-claude.md` 是否存在:
- **存在**:读取评审报告,提取其中的问题清单,作为本次修改的依据。向用户确认:
> 检测到评审报告,包含 X 个问题。是否根据评审报告进行修改?
- **不存在**:询问用户:
> 请说明需要修改的内容,或先运行 `/ru` 生成评审报告。
## 3. 修改原则
### 3.1 增量修改
- 保留原有内容结构和格式
- 仅修改/新增指定部分
- 不删除未明确要求删除的内容
### 3.2 新增内容标记
对于新增的段落或章节:
```markdown
<!-- NEW START -->
新增内容...
<!-- NEW END -->
```
对于行内新增:
```markdown
原有内容 <!-- NEW --> 新增内容
```
### 3.3 修改内容标记
```markdown
<!-- MODIFIED: 原内容为 "xxx" -->
修改后的内容
```
### 3.4 与 DevelopmentPlan 一致性
- 页面设计必须覆盖所有功能模块
- 交互流程必须支撑功能需求
- 设计规范必须统一
## 4. 执行修改
| 修改类型 | 处理方式 |
|----------|----------|
| 新增页面 | 在页面设计章节添加新子章节 |
| 修改布局 | 更新布局描述,添加 MODIFIED 标记 |
| 修改组件 | 更新组件表格 |
| 修改交互 | 更新交互说明 |
| 新增状态 | 在状态列表中添加项目 |
| 修改设计规范 | 更新设计规范章节 |
## 5. 保存并验证
1. 保存修改后的文档到 `doc/UIDesign.md`
2. 使用 git diff 展示变更内容
3. 向用户确认修改是否符合预期
## 6. 输出摘要
向用户展示修改摘要:
- 修改位置(章节/行号)
- 修改类型(新增/修改/删除)
- 修改内容概要
- 与 DevelopmentPlan 的一致性确认
---
## 注意事项
- UIDesign 依赖于 DevelopmentPlan修改时需确保与上游一致
- 修改后下游文档tasks可能需要同步更新
- 页面修改需考虑对用户流程的影响
- 设计规范修改需检查所有页面的一致性
- 建议修改完成后运行 `/rt` 检查下游一致性
## 标记清理
用户确认修改无误后,可手动删除标记或保留作为变更历史参考。

101
.claude/skills/rd/SKILL.md Normal file
View File

@ -0,0 +1,101 @@
---
name: rd
description: 评审 DevelopmentPlan.md检查技术可行性和与上游文档一致性输出结构化评审报告。
---
# Review DevelopmentPlan
当用户调用 `/rd` 时,执行以下步骤:
## 1. 读取文档
读取以下文件:
1. `doc/DevelopmentPlan.md` - 目标文档(必须存在)
2. `doc/FeatureSummary.md` - 上游参照文档
如果 DevelopmentPlan.md 不存在,提示用户:
> DevelopmentPlan.md 不存在,请先使用 `/wd` 生成开发计划。
## 2. 评审维度
### 2.1 与 FeatureSummary 一致性检查
- 开发任务是否覆盖所有功能模块
- 技术方案是否支撑功能需求
- 排期是否合理
### 2.2 技术可行性检查
- 技术方案是否可行
- 技术栈选择是否合理
- 是否存在技术风险
- 依赖关系是否明确
### 2.3 完整性检查
- 是否有明确的里程碑划分
- 是否有资源分配说明
- 是否有风险应对措施
## 3. 生成评审报告
输出到 `doc/review-DevelopmentPlan-claude.md`,结构如下:
```markdown
# DevelopmentPlan 评审报告
## 概要
| 项目 | 内容 |
|------|------|
| 评审时间 | {YYYY-MM-DD HH:MM} |
| 目标文档 | doc/DevelopmentPlan.md |
| 参照文档 | doc/FeatureSummary.md |
| 问题统计 | X 个严重 / Y 个一般 / Z 个建议 |
## 功能覆盖分析
| FeatureSummary 功能 | DevelopmentPlan 对应 | 状态 |
|---------------------|----------------------|------|
| {功能名} | {对应任务/模块} | ✅/⚠️/❌ |
## 技术风险分析
| 风险项 | 影响范围 | 严重程度 | 建议措施 |
|--------|----------|----------|----------|
| {风险} | {范围} | 高/中/低 | {措施} |
## 问题清单
### 严重问题 (Critical)
{问题列表,含位置引用}
### 一般问题 (Major)
{问题列表,含位置引用}
### 改进建议 (Minor)
{建议列表}
## 评审结论
{通过 / 需修改后通过 / 不通过}
### 下一步行动
- [ ] {待办事项}
```
## 4. 输出规范
- 输出语言:中文
- 问题分级Critical / Major / Minor
- 包含文件引用(如 `doc/DevelopmentPlan.md:28`
- 技术风险需明确影响范围和应对建议
---
## 注意事项
- 只做评审,不修改原文档
- 重点关注技术可行性和风险
- 评审报告保存后,建议用户根据问题运行 `/md` 修改

View File

@ -0,0 +1,96 @@
---
name: rf
description: 评审 FeatureSummary.md对比 PRD 检查一致性,输出结构化评审报告。
---
# Review FeatureSummary
当用户调用 `/rf` 时,执行以下步骤:
## 1. 读取文档
读取以下文件:
1. `doc/FeatureSummary.md` - 目标文档(必须存在)
2. `doc/PRD.md` - 上游参照文档
如果 FeatureSummary.md 不存在,提示用户:
> FeatureSummary.md 不存在,请先使用 `/wf` 生成功能摘要。
## 2. 评审维度
### 2.1 与 PRD 一致性检查
- 功能模块是否完整覆盖 PRD 3.2 功能详情
- 功能描述是否与 PRD 一致
- 优先级标注是否与 PRD 匹配
### 2.2 完整性检查
- 每个功能模块是否有清晰的描述
- 是否遗漏 PRD 中的功能点
- 功能分类是否合理
### 2.3 质量检查
- 描述是否简洁准确
- 是否有冗余或重复内容
- 格式是否规范统一
## 3. 生成评审报告
输出到 `doc/review-FeatureSummary-claude.md`,结构如下:
```markdown
# FeatureSummary 评审报告
## 概要
| 项目 | 内容 |
|------|------|
| 评审时间 | {YYYY-MM-DD HH:MM} |
| 目标文档 | doc/FeatureSummary.md |
| 参照文档 | doc/PRD.md |
| 问题统计 | X 个严重 / Y 个一般 / Z 个建议 |
## 覆盖度分析
| PRD 功能模块 | FeatureSummary 对应 | 状态 |
|--------------|---------------------|------|
| {模块名} | {对应位置} | ✅/⚠️/❌ |
**覆盖率**: X/Y 完全覆盖
## 问题清单
### 严重问题 (Critical)
{问题列表,含位置引用}
### 一般问题 (Major)
{问题列表,含位置引用}
### 改进建议 (Minor)
{建议列表}
## 评审结论
{通过 / 需修改后通过 / 不通过}
### 下一步行动
- [ ] {待办事项}
```
## 4. 输出规范
- 输出语言:中文
- 问题分级Critical / Major / Minor
- 包含文件引用(如 `doc/FeatureSummary.md:15`
- 问题按严重性排序
---
## 注意事项
- 只做评审,不修改原文档
- 重点检查与 PRD 的一致性
- 评审报告保存后,建议用户根据问题运行 `/mf` 修改

177
.claude/skills/rp/SKILL.md Normal file
View File

@ -0,0 +1,177 @@
---
name: rp
description: 评审 PRD.md对比 RequirementsDoc 检查一致性,输出结构化评审报告。
---
# Review PRD
当用户调用 `/rp` 时,执行以下步骤:
## 1. 读取文档
读取以下文件:
- 目标文档:`doc/PRD.md`
- 上游文档:`doc/RequirementsDoc.md`
如果 PRD.md 不存在,提示用户:
> PRD.md 不存在,请先使用 `/wp` 生成 PRD。
如果 RequirementsDoc.md 不存在,提示用户:
> RequirementsDoc.md 不存在,无法进行一致性检查。请先创建需求文档。
## 2. 评审维度
PRD 位于文档链的第二层,需要对比上游 RequirementsDoc 进行评审。
### 2.1 与 RequirementsDoc 的一致性
- [ ] PRD 是否覆盖了 RequirementsDoc 中的所有功能需求
- [ ] PRD 是否覆盖了 RequirementsDoc 中的所有非功能需求
- [ ] PRD 中是否有 RequirementsDoc 中未提及的需求(需标注来源)
- [ ] 术语定义是否与 RequirementsDoc 一致
- [ ] 优先级划分是否与 RequirementsDoc 一致
### 2.2 用户故事质量
- [ ] 所有用户故事是否有唯一 IDUS-xxx
- [ ] 用户故事是否符合格式:作为{角色},我想要{功能},以便{价值}
- [ ] 用户角色是否明确定义
- [ ] 验收标准是否具体可测试
- [ ] 用户旅程是否完整描述核心流程
### 2.3 功能需求完整性
- [ ] 功能架构是否清晰(模块划分合理)
- [ ] 所有功能点是否关联到用户故事
- [ ] 功能点是否有明确的优先级
- [ ] 功能点是否有验收标准
- [ ] 是否遗漏边界情况和异常处理
### 2.4 非功能需求
- [ ] 性能需求是否有量化指标
- [ ] 安全需求是否明确
- [ ] 兼容性需求是否完整
- [ ] 可用性需求是否可验证
### 2.5 文档结构
- [ ] 文档结构是否完整(无空章节)
- [ ] 格式是否统一(表格、列表、标题层级)
- [ ] 术语表是否完整
## 3. 生成评审报告
按以下格式输出评审报告:
```markdown
# PRD 评审报告
## 概要
| 项目 | 内容 |
|------|------|
| 评审时间 | {YYYY-MM-DD HH:mm} |
| 目标文档 | doc/PRD.md |
| 参照文档 | doc/RequirementsDoc.md |
| 问题统计 | {critical} 个严重 / {major} 个一般 / {minor} 个建议 |
## 一致性检查
### 需求覆盖分析
| RequirementsDoc 需求项 | PRD 对应位置 | 状态 |
|------------------------|--------------|------|
| {需求1} | {PRD章节/用户故事ID} | ✅ 已覆盖 / ⚠️ 部分覆盖 / ❌ 未覆盖 |
### 差异说明
{列出 PRD 中新增的、RequirementsDoc 未提及的内容,需说明来源或理由}
## 问题清单
### 严重问题 (Critical)
> 必须修复,否则影响后续文档生成
1. **[位置: doc/PRD.md:行号]** 问题描述
- 现状:...
- 与 RequirementsDoc 的差异:...
- 建议:...
### 一般问题 (Major)
> 建议修复,可提升文档质量
1. **[位置]** 问题描述
- 建议:...
### 改进建议 (Minor)
> 可选优化项
1. **[位置]** 建议内容
## 用户故事评估
| 评估项 | 结果 |
|--------|------|
| 用户故事总数 | {数量} |
| 符合格式规范 | {数量} / {总数} |
| 有验收标准 | {数量} / {总数} |
| 关联功能点 | {数量} / {总数} |
### 用户故事问题
{列出不符合规范的用户故事}
## 评审结论
{通过 / 需修改后通过 / 不通过}
**结论说明**
- 通过PRD 与 RequirementsDoc 一致,可进入下一阶段
- 需修改后通过:存在问题但不影响整体理解,修复后可继续
- 不通过:存在严重一致性问题或遗漏,需重新生成
### 下一步行动
- [ ] 行动项1
- [ ] 行动项2
```
## 4. 保存报告
将评审报告保存到 `doc/review-PRD-claude.md`
如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。
## 5. 输出摘要
向用户展示评审摘要:
- 一致性检查结果(覆盖率)
- 发现的问题数量(按严重程度分类)
- 用户故事评估结果
- 评审结论
- 报告文件路径
---
## 注意事项
- 评审时保持客观,聚焦于文档质量和一致性
- 问题描述要具体,给出明确的位置引用(如 `doc/PRD.md:42`
- 一致性检查要逐项对比,不能遗漏
- 建议要可操作,避免模糊表述
- 不要修改原文档,只输出评审报告
## 常见问题模式
在评审时重点关注以下常见问题:
1. **需求遗漏**RequirementsDoc 中有但 PRD 中没有的需求
2. **需求偏离**PRD 中的描述与 RequirementsDoc 不一致
3. **凭空添加**PRD 中有但 RequirementsDoc 中没有的需求(需要来源说明)
4. **用户故事缺陷**:格式不规范、缺少验收标准、角色不明确
5. **功能孤立**:功能点未关联到任何用户故事
6. **优先级冲突**PRD 与 RequirementsDoc 的优先级划分不一致

111
.claude/skills/rr/SKILL.md Normal file
View File

@ -0,0 +1,111 @@
---
name: rr
description: 评审 RequirementsDoc.md检查需求文档的完整性、清晰度和可执行性输出结构化评审报告。
---
# Review RequirementsDoc
当用户调用 `/rr` 时,执行以下步骤:
## 1. 读取目标文档
读取 `doc/RequirementsDoc.md` 文件。
如果文件不存在,提示用户:
> RequirementsDoc.md 不存在,请先创建需求文档。
## 2. 评审维度
RequirementsDoc 是文档链的源头,没有上游依赖。重点检查以下维度:
### 2.1 完整性
- [ ] 产品概述是否清晰(定位、目标用户、核心价值)
- [ ] 功能需求是否完整列出
- [ ] 非功能需求是否涵盖(性能、安全、兼容性)
- [ ] 数据规范是否明确(输入输出格式、字段定义)
- [ ] 边界条件和异常情况是否考虑
### 2.2 清晰度
- [ ] 术语定义是否一致,无歧义
- [ ] 用例描述是否具体可理解
- [ ] 优先级是否明确标注
- [ ] 是否有模糊表述("等"、"可能"、"应该"等)
### 2.3 可执行性
- [ ] 需求是否可被验证(有明确的验收标准)
- [ ] 技术约束是否合理
- [ ] 依赖项是否明确
### 2.4 结构规范
- [ ] 文档结构是否清晰(章节划分合理)
- [ ] 格式是否统一(表格、列表、标题层级)
## 3. 生成评审报告
按以下格式输出评审报告:
```markdown
# RequirementsDoc 评审报告
## 概要
| 项目 | 内容 |
|------|------|
| 评审时间 | {YYYY-MM-DD HH:mm} |
| 目标文档 | doc/RequirementsDoc.md |
| 问题统计 | {critical} 个严重 / {major} 个一般 / {minor} 个建议 |
## 问题清单
### 严重问题 (Critical)
> 必须修复,否则影响后续文档生成
1. **[位置: 第X节/第Y行]** 问题描述
- 现状:...
- 建议:...
### 一般问题 (Major)
> 建议修复,可提升文档质量
1. **[位置]** 问题描述
- 建议:...
### 改进建议 (Minor)
> 可选优化项
1. **[位置]** 建议内容
## 评审结论
{通过 / 需修改后通过 / 不通过}
### 下一步行动
- [ ] 行动项1
- [ ] 行动项2
```
## 4. 保存报告
将评审报告保存到 `doc/review-RequirementsDoc-claude.md`
如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。
## 5. 输出摘要
向用户展示评审摘要:
- 发现的问题数量(按严重程度分类)
- 评审结论
- 报告文件路径
---
## 注意事项
- 评审时保持客观,聚焦于文档质量而非业务判断
- 问题描述要具体,给出明确的位置引用
- 建议要可操作,避免模糊表述
- 不要修改原文档,只输出评审报告

115
.claude/skills/rt/SKILL.md Normal file
View File

@ -0,0 +1,115 @@
---
name: rt
description: 评审 tasks.md检查任务完整性和与上游文档一致性输出结构化评审报告。
---
# Review Tasks
当用户调用 `/rt` 时,执行以下步骤:
## 1. 读取文档
读取以下文件:
1. `doc/tasks.md` - 目标文档(必须存在)
2. `doc/UIDesign.md` - 上游参照文档
3. `doc/DevelopmentPlan.md` - 上游参照文档
如果 tasks.md 不存在,提示用户:
> tasks.md 不存在,请先使用 `/wt` 生成任务列表。
## 2. 评审维度
### 2.1 与上游文档一致性检查
- 任务是否覆盖 DevelopmentPlan 所有开发项
- 任务是否覆盖 UIDesign 所有页面实现
- 任务优先级是否与功能优先级匹配
### 2.2 任务完整性检查
- 每个任务是否有明确的描述
- 任务粒度是否合适(不过大也不过小)
- 任务依赖关系是否明确
- 验收标准是否清晰
### 2.3 可执行性检查
- 任务是否可直接开始执行
- 是否有阻塞项未说明
- 估时是否合理(如有)
## 3. 生成评审报告
输出到 `doc/review-tasks-claude.md`,结构如下:
```markdown
# Tasks 评审报告
## 概要
| 项目 | 内容 |
|------|------|
| 评审时间 | {YYYY-MM-DD HH:MM} |
| 目标文档 | doc/tasks.md |
| 参照文档 | doc/UIDesign.md, doc/DevelopmentPlan.md |
| 问题统计 | X 个严重 / Y 个一般 / Z 个建议 |
## 覆盖度分析
### DevelopmentPlan 覆盖
| 开发项 | 对应任务 | 状态 |
|--------|----------|------|
| {开发项} | {任务ID/名称} | ✅/⚠️/❌ |
### UIDesign 覆盖
| UI 页面 | 对应任务 | 状态 |
|---------|----------|------|
| {页面名} | {任务ID/名称} | ✅/⚠️/❌ |
**总覆盖率**: X/Y
## 任务质量分析
| 检查项 | 通过数 | 总数 |
|--------|--------|------|
| 有明确描述 | X | Y |
| 有验收标准 | X | Y |
| 粒度合适 | X | Y |
## 问题清单
### 严重问题 (Critical)
{问题列表,含位置引用}
### 一般问题 (Major)
{问题列表,含位置引用}
### 改进建议 (Minor)
{建议列表}
## 评审结论
{通过 / 需修改后通过 / 不通过}
### 下一步行动
- [ ] {待办事项}
```
## 4. 输出规范
- 输出语言:中文
- 问题分级Critical / Major / Minor
- 包含文件引用(如 `doc/tasks.md:12`
- 任务问题需说明对开发执行的影响
---
## 注意事项
- 只做评审,不修改原文档
- 重点检查任务覆盖度和可执行性
- tasks.md 是文档链末端,必须覆盖所有上游功能
- 评审报告保存后,建议用户根据问题运行 `/mt` 修改

105
.claude/skills/ru/SKILL.md Normal file
View File

@ -0,0 +1,105 @@
---
name: ru
description: 评审 UIDesign.md对比 DevelopmentPlan 检查设计一致性,输出结构化评审报告。
---
# Review UIDesign
当用户调用 `/ru` 时,执行以下步骤:
## 1. 读取文档
读取以下文件:
1. `doc/UIDesign.md` - 目标文档(必须存在)
2. `doc/DevelopmentPlan.md` - 上游参照文档
如果 UIDesign.md 不存在,提示用户:
> UIDesign.md 不存在,请先使用 `/wu` 生成 UI 设计文档。
## 2. 评审维度
### 2.1 与 DevelopmentPlan 一致性检查
- UI 页面是否覆盖所有功能模块
- 交互流程是否与开发计划匹配
- 页面结构是否支撑功能需求
### 2.2 设计完整性检查
- 页面列表是否完整
- 每个页面是否有清晰的布局描述
- 交互说明是否充分
- 状态变化是否考虑全面(加载、错误、空状态等)
### 2.3 可用性检查
- 用户流程是否顺畅
- 信息架构是否合理
- 是否有一致的设计规范
## 3. 生成评审报告
输出到 `doc/review-UIDesign-claude.md`,结构如下:
```markdown
# UIDesign 评审报告
## 概要
| 项目 | 内容 |
|------|------|
| 评审时间 | {YYYY-MM-DD HH:MM} |
| 目标文档 | doc/UIDesign.md |
| 参照文档 | doc/DevelopmentPlan.md |
| 问题统计 | X 个严重 / Y 个一般 / Z 个建议 |
## 页面覆盖分析
| DevelopmentPlan 功能 | UIDesign 页面 | 状态 |
|----------------------|---------------|------|
| {功能名} | {对应页面} | ✅/⚠️/❌ |
**覆盖率**: X/Y 完全覆盖
## 设计一致性检查
| 检查项 | 结果 |
|--------|------|
| 页面命名规范 | ✅/❌ |
| 布局风格统一 | ✅/❌ |
| 交互模式一致 | ✅/❌ |
## 问题清单
### 严重问题 (Critical)
{问题列表,含位置引用}
### 一般问题 (Major)
{问题列表,含位置引用}
### 改进建议 (Minor)
{建议列表}
## 评审结论
{通过 / 需修改后通过 / 不通过}
### 下一步行动
- [ ] {待办事项}
```
## 4. 输出规范
- 输出语言:中文
- 问题分级Critical / Major / Minor
- 包含文件引用(如 `doc/UIDesign.md:45`
- 设计问题需说明影响的用户体验
---
## 注意事项
- 只做评审,不修改原文档
- 重点检查页面覆盖度和设计一致性
- 评审报告保存后,建议用户根据问题运行 `/mu` 修改

View File

@ -0,0 +1,410 @@
---
name: tdd-workflow
description: Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 80%+ coverage including unit, integration, and E2E tests.
origin: ECC
---
# Test-Driven Development Workflow
This skill ensures all code development follows TDD principles with comprehensive test coverage.
## When to Activate
- Writing new features or functionality
- Fixing bugs or issues
- Refactoring existing code
- Adding API endpoints
- Creating new components
## Core Principles
### 1. Tests BEFORE Code
ALWAYS write tests first, then implement code to make tests pass.
### 2. Coverage Requirements
- Minimum 80% coverage (unit + integration + E2E)
- All edge cases covered
- Error scenarios tested
- Boundary conditions verified
### 3. Test Types
#### Unit Tests
- Individual functions and utilities
- Component logic
- Pure functions
- Helpers and utilities
#### Integration Tests
- API endpoints
- Database operations
- Service interactions
- External API calls
#### E2E Tests (Playwright)
- Critical user flows
- Complete workflows
- Browser automation
- UI interactions
## TDD Workflow Steps
### Step 1: Write User Journeys
```
As a [role], I want to [action], so that [benefit]
Example:
As a user, I want to search for markets semantically,
so that I can find relevant markets even without exact keywords.
```
### Step 2: Generate Test Cases
For each user journey, create comprehensive test cases:
```typescript
describe('Semantic Search', () => {
it('returns relevant markets for query', async () => {
// Test implementation
})
it('handles empty query gracefully', async () => {
// Test edge case
})
it('falls back to substring search when Redis unavailable', async () => {
// Test fallback behavior
})
it('sorts results by similarity score', async () => {
// Test sorting logic
})
})
```
### Step 3: Run Tests (They Should Fail)
```bash
npm test
# Tests should fail - we haven't implemented yet
```
### Step 4: Implement Code
Write minimal code to make tests pass:
```typescript
// Implementation guided by tests
export async function searchMarkets(query: string) {
// Implementation here
}
```
### Step 5: Run Tests Again
```bash
npm test
# Tests should now pass
```
### Step 6: Refactor
Improve code quality while keeping tests green:
- Remove duplication
- Improve naming
- Optimize performance
- Enhance readability
### Step 7: Verify Coverage
```bash
npm run test:coverage
# Verify 80%+ coverage achieved
```
## Testing Patterns
### Unit Test Pattern (Jest/Vitest)
```typescript
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'
describe('Button Component', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
```
### API Integration Test Pattern
```typescript
import { NextRequest } from 'next/server'
import { GET } from './route'
describe('GET /api/markets', () => {
it('returns markets successfully', async () => {
const request = new NextRequest('http://localhost/api/markets')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(Array.isArray(data.data)).toBe(true)
})
it('validates query parameters', async () => {
const request = new NextRequest('http://localhost/api/markets?limit=invalid')
const response = await GET(request)
expect(response.status).toBe(400)
})
it('handles database errors gracefully', async () => {
// Mock database failure
const request = new NextRequest('http://localhost/api/markets')
// Test error handling
})
})
```
### E2E Test Pattern (Playwright)
```typescript
import { test, expect } from '@playwright/test'
test('user can search and filter markets', async ({ page }) => {
// Navigate to markets page
await page.goto('/')
await page.click('a[href="/markets"]')
// Verify page loaded
await expect(page.locator('h1')).toContainText('Markets')
// Search for markets
await page.fill('input[placeholder="Search markets"]', 'election')
// Wait for debounce and results
await page.waitForTimeout(600)
// Verify search results displayed
const results = page.locator('[data-testid="market-card"]')
await expect(results).toHaveCount(5, { timeout: 5000 })
// Verify results contain search term
const firstResult = results.first()
await expect(firstResult).toContainText('election', { ignoreCase: true })
// Filter by status
await page.click('button:has-text("Active")')
// Verify filtered results
await expect(results).toHaveCount(3)
})
test('user can create a new market', async ({ page }) => {
// Login first
await page.goto('/creator-dashboard')
// Fill market creation form
await page.fill('input[name="name"]', 'Test Market')
await page.fill('textarea[name="description"]', 'Test description')
await page.fill('input[name="endDate"]', '2025-12-31')
// Submit form
await page.click('button[type="submit"]')
// Verify success message
await expect(page.locator('text=Market created successfully')).toBeVisible()
// Verify redirect to market page
await expect(page).toHaveURL(/\/markets\/test-market/)
})
```
## Test File Organization
```
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx # Unit tests
│ │ └── Button.stories.tsx # Storybook
│ └── MarketCard/
│ ├── MarketCard.tsx
│ └── MarketCard.test.tsx
├── app/
│ └── api/
│ └── markets/
│ ├── route.ts
│ └── route.test.ts # Integration tests
└── e2e/
├── markets.spec.ts # E2E tests
├── trading.spec.ts
└── auth.spec.ts
```
## Mocking External Services
### Supabase Mock
```typescript
jest.mock('@/lib/supabase', () => ({
supabase: {
from: jest.fn(() => ({
select: jest.fn(() => ({
eq: jest.fn(() => Promise.resolve({
data: [{ id: 1, name: 'Test Market' }],
error: null
}))
}))
}))
}
}))
```
### Redis Mock
```typescript
jest.mock('@/lib/redis', () => ({
searchMarketsByVector: jest.fn(() => Promise.resolve([
{ slug: 'test-market', similarity_score: 0.95 }
])),
checkRedisHealth: jest.fn(() => Promise.resolve({ connected: true }))
}))
```
### OpenAI Mock
```typescript
jest.mock('@/lib/openai', () => ({
generateEmbedding: jest.fn(() => Promise.resolve(
new Array(1536).fill(0.1) // Mock 1536-dim embedding
))
}))
```
## Test Coverage Verification
### Run Coverage Report
```bash
npm run test:coverage
```
### Coverage Thresholds
```json
{
"jest": {
"coverageThresholds": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
```
## Common Testing Mistakes to Avoid
### ❌ WRONG: Testing Implementation Details
```typescript
// Don't test internal state
expect(component.state.count).toBe(5)
```
### ✅ CORRECT: Test User-Visible Behavior
```typescript
// Test what users see
expect(screen.getByText('Count: 5')).toBeInTheDocument()
```
### ❌ WRONG: Brittle Selectors
```typescript
// Breaks easily
await page.click('.css-class-xyz')
```
### ✅ CORRECT: Semantic Selectors
```typescript
// Resilient to changes
await page.click('button:has-text("Submit")')
await page.click('[data-testid="submit-button"]')
```
### ❌ WRONG: No Test Isolation
```typescript
// Tests depend on each other
test('creates user', () => { /* ... */ })
test('updates same user', () => { /* depends on previous test */ })
```
### ✅ CORRECT: Independent Tests
```typescript
// Each test sets up its own data
test('creates user', () => {
const user = createTestUser()
// Test logic
})
test('updates user', () => {
const user = createTestUser()
// Update logic
})
```
## Continuous Testing
### Watch Mode During Development
```bash
npm test -- --watch
# Tests run automatically on file changes
```
### Pre-Commit Hook
```bash
# Runs before every commit
npm test && npm run lint
```
### CI/CD Integration
```yaml
# GitHub Actions
- name: Run Tests
run: npm test -- --coverage
- name: Upload Coverage
uses: codecov/codecov-action@v3
```
## Best Practices
1. **Write Tests First** - Always TDD
2. **One Assert Per Test** - Focus on single behavior
3. **Descriptive Test Names** - Explain what's tested
4. **Arrange-Act-Assert** - Clear test structure
5. **Mock External Dependencies** - Isolate unit tests
6. **Test Edge Cases** - Null, undefined, empty, large
7. **Test Error Paths** - Not just happy paths
8. **Keep Tests Fast** - Unit tests < 50ms each
9. **Clean Up After Tests** - No side effects
10. **Review Coverage Reports** - Identify gaps
## Success Metrics
- 80%+ code coverage achieved
- All tests passing (green)
- No skipped or disabled tests
- Fast test execution (< 30s for unit tests)
- E2E tests cover critical user flows
- Tests catch bugs before production
---
**Remember**: Tests are not optional. They are the safety net that enables confident refactoring, rapid development, and production reliability.

View File

@ -0,0 +1,78 @@
---
name: update
aliases: [up]
description: 收集用户反馈并更新最近使用的 skill。可通过 /update 或 /up 调用。在用完某个 skill 后调用此命令来优化该 skill。
disable-model-invocation: true
argument-hint: [skill-name]
---
# Skill 更新助手
当用户调用 `/up``/up <skill-name>` 时,执行以下步骤:
## 1. 识别目标 Skill
**如果用户提供了参数 `$ARGUMENTS`**
- 直接使用指定的 skill 名称作为更新目标
**如果没有提供参数**
分析当前对话历史,找出最近使用的 skill
- 搜索对话中的 `<command-name>` 标签,识别调用过的 skill
- 如果找到多个 skill让用户确认要更新哪一个
- 如果没有找到任何 skill 调用记录,提示用户先使用一个 skill
## 2. 收集用户反馈
向用户询问以下问题(使用 AskUserQuestion 工具):
**问题 1这次使用体验如何**
- 很好skill 完全满足需求
- 基本满足,但有改进空间
- 不太满意,需要较大调整
**问题 2具体需要改进的方面**(多选)
- 执行步骤不够清晰
- 缺少某些功能
- 输出格式需要调整
- 提示词需要优化
- 其他(用户自定义输入)
## 3. 分析优化点
基于用户反馈和本次 skill 使用过程,分析以下方面:
1. **执行流程**:哪些步骤可以简化或合并?
2. **指令清晰度**:哪些指令描述不够明确?
3. **遗漏功能**:有哪些场景没有覆盖到?
4. **输出质量**:输出格式是否符合用户预期?
## 4. 定位 Skill 文件
按以下优先级搜索 skill 文件:
1. 项目级:`.claude/skills/<skill-name>/SKILL.md`
2. 用户级:`~/.claude/skills/<skill-name>/SKILL.md`
## 5. 更新 Skill
读取现有的 SKILL.md 文件内容,根据分析结果进行更新:
- 保持 frontmatter 格式不变(除非需要修改 description
- 优化执行步骤的描述
- 添加缺失的功能说明
- 改进提示词的表达方式
- 添加必要的注意事项或边界情况处理
## 6. 确认更新
在更新前,向用户展示:
- 修改前后的对比diff 格式)
- 说明每处修改的原因
用户确认后才执行实际的文件更新。
## 注意事项
- 如果 skill 文件不存在或路径无法确定,提示用户手动指定路径
- 更新时保持 skill 的原有风格和结构
- 重大修改需要用户明确确认
- 保留原有的有效内容,只做增量优化

323
.claude/skills/wd/SKILL.md Normal file
View File

@ -0,0 +1,323 @@
---
name: wd
description: 从上游文档生成 DevelopmentPlan.md包含技术方案和开发排期。
---
# Write DevelopmentPlan
> **文档定位**DevelopmentPlan 是「执行蓝图」文档,偏技术语言和时间约束。定义技术架构、实现方案、开发阶段、里程碑,是开发团队的行动指南。
当用户调用 `/wd` 时,执行以下步骤:
## 1. 读取源文档
读取以下文件:
1. `doc/RequirementsDoc.md` - 必须存在
2. `doc/PRD.md` - 必须存在
3. `doc/FeatureSummary.md` - 必须存在
如果文件不存在,提示用户:
> 缺少上游文档,请先确保 RequirementsDoc.md、PRD.md 和 FeatureSummary.md 存在。
如果已存在 `doc/DevelopmentPlan.md`,同时读取作为参考(保持风格一致)。
## 2. 分析开发需求
从上游文档中提取以下信息:
### 2.1 功能需求
- 从 FeatureSummary 获取功能清单和契约
- 从 PRD 获取功能详情和验收标准
### 2.2 技术约束
- 从 PRD 获取技术约束
- 从 RequirementsDoc 获取技术决策
### 2.3 优先级排序
- 按 P0 → P1 → P2 顺序规划开发
- 考虑功能依赖关系
## 3. 生成 DevelopmentPlan
按以下结构生成文档:
```markdown
# {产品名称} - 开发计划
## 文档信息
| 项目 | 内容 |
|------|------|
| 版本 | v1.0 |
| 创建日期 | {YYYY-MM-DD} |
| 来源文档 | FeatureSummary.md |
## 1. 项目概述
### 1.1 项目目标
{从 PRD 提取的项目目标}
### 1.2 技术栈
| 层级 | 技术选型 | 版本 | 说明 |
|------|----------|------|------|
| 前端 | {技术} | {版本} | {说明} |
| 后端 | {技术} | {版本} | {说明} |
| 数据库 | {技术} | {版本} | {说明} |
| 基础设施 | {技术} | {版本} | {说明} |
### 1.3 开发原则
{开发规范和原则}
## 2. 技术架构
### 2.1 系统架构图
**【必须】使用架构图展示系统整体结构:**
```
┌─────────────────────────────────────────────────────────┐
│ 客户端层 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Web App │ │ Mobile App │ │
│ └──────┬───────┘ └──────┬───────┘ │
└─────────┼─────────────────┼─────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ API 网关层 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ API Gateway / Load Balancer │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 服务层 │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 服务 A │ │ 服务 B │ │ 服务 C │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
└────────┼───────────────┼───────────────┼────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────┐
│ 数据层 │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 数据库 │ │ 缓存 │ │ 消息队列 │ │
│ └────────────┘ └────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 2.2 模块依赖图
**【必须】使用依赖图展示模块间关系:**
```
┌──────────────┐
│ 模块 A │
│ (核心模块) │
└──────┬───────┘
┌───┴───┐
▼ ▼
┌──────┐ ┌──────┐
│模块B │ │模块C │
└──┬───┘ └──┬───┘
│ │
▼ ▼
┌──────────────┐
│ 模块 D │
│ (基础设施) │
└──────────────┘
```
### 2.3 数据流图
**【必须】使用数据流图展示关键数据流转:**
```
用户请求 ──▶ API Gateway ──▶ 服务A ──▶ 数据库
缓存层
服务B ──▶ 外部API
```
## 3. 开发阶段
### 3.1 阶段时间线
**【必须】使用时间线展示开发阶段:**
```
Phase 1 Phase 2 Phase 3
│ │ │
{起止日期} {起止日期} {起止日期}
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 基础 │ ────▶ │ 核心 │ ────▶ │ 优化 │
│ 架构 │ │ 功能 │ │ 扩展 │
└─────────┘ └─────────┘ └─────────┘
交付物: 交付物: 交付物:
• {交付1} • {交付1} • {交付1}
• {交付2} • {交付2} • {交付2}
```
### 3.2 Phase 1: {阶段名称}
**目标**: {阶段目标}
**时间**: {起止日期}
| 任务ID | 任务 | 描述 | 依赖 | 优先级 | 关联功能 |
|--------|------|------|------|--------|----------|
| T-001 | {任务名} | {描述} | - | P0 | F-001 |
| T-002 | {任务名} | {描述} | T-001 | P0 | F-002 |
**阶段依赖图:**
```
T-001 ──▶ T-002 ──▶ T-003
└──▶ T-004
```
{重复以上结构覆盖所有阶段}
## 4. 技术方案
### 4.1 {模块名称}
**功能**: {功能描述}
**技术选型**:
| 组件 | 技术 | 选型理由 |
|------|------|----------|
| {组件} | {技术} | {理由} |
**架构设计**:
```
┌─────────────────────────────────────┐
│ {模块名称} │
├─────────────────────────────────────┤
│ ┌─────────┐ ┌─────────┐ │
│ │ 组件A │ ───▶ │ 组件B │ │
│ └─────────┘ └─────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────┐ │
│ │ 数据层 │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────┘
```
**接口设计**:
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| {接口名} | GET/POST | /api/xxx | {说明} |
**实现要点**:
- {技术要点1}
- {技术要点2}
{重复以上结构覆盖所有模块}
## 5. 风险管理
| 风险 | 可能性 | 影响 | 应对措施 | 负责人 |
|------|--------|------|----------|--------|
| {风险} | 高/中/低 | 高/中/低 | {措施} | {负责人} |
## 6. 里程碑
**【必须】使用里程碑图展示关键节点:**
```
M1 M2 M3 M4
│ │ │ │
▼ ▼ ▼ ▼
◆───────────────◆───────────────◆───────────────◆
│ │ │ │
{日期} {日期} {日期} {日期}
{里程碑名} {里程碑名} {里程碑名} {里程碑名}
```
| 里程碑 | 日期 | 目标 | 交付物 | 验收标准 |
|--------|------|------|--------|----------|
| M1 | {日期} | {目标} | {交付物} | {标准} |
## 7. 资源需求
| 角色 | 人数 | 职责 | 参与阶段 |
|------|------|------|----------|
| {角色} | {人数} | {职责} | Phase 1-2 |
```
## 4. 保存文档
将生成的 DevelopmentPlan 保存到 `doc/DevelopmentPlan.md`
如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。
## 5. 输出摘要
向用户展示生成摘要:
- DevelopmentPlan 文件路径
- 开发阶段数量
- 技术方案模块数量
- 建议的下一步操作(运行 `/rd` 评审)
---
## 可视化输出要求
DevelopmentPlan 作为「执行蓝图」文档,需要清晰传达技术方案和时间安排,**必须包含**
| 章节 | 可视化形式 | 必要性 |
|------|------------|--------|
| 2.1 系统架构图 | 架构图ASCII | **必须** |
| 2.2 模块依赖图 | 依赖图ASCII | **必须** |
| 2.3 数据流图 | 数据流图ASCII | **必须** |
| 3.1 阶段时间线 | 时间线ASCII | **必须** |
| 3.x 阶段依赖图 | 任务依赖图 | **必须** |
| 4.x 模块架构 | 模块架构图 | 建议 |
| 6. 里程碑 | 里程碑图 | **必须** |
## 注意事项
- DevelopmentPlan 使用**技术语言**,面向开发团队
- 开发计划必须覆盖 FeatureSummary 所有功能
- 技术方案要具体可执行,避免过于抽象
- 阶段划分要合理,考虑依赖关系
- 时间安排要务实,预留缓冲
- 风险评估要全面,有应对措施
## 质量检查
生成 DevelopmentPlan 后,自查以下项目:
- [ ] 覆盖 FeatureSummary 所有功能
- [ ] **系统架构图清晰展示整体结构**
- [ ] **模块依赖图清晰展示依赖关系**
- [ ] **数据流图展示关键数据流转**
- [ ] **开发阶段有时间线图**
- [ ] **每个阶段有任务依赖图**
- [ ] **里程碑有里程碑图**
- [ ] 技术方案具体可执行
- [ ] 任务 ID 唯一T-xxx
- [ ] 任务与功能 ID 关联

234
.claude/skills/wf/SKILL.md Normal file
View File

@ -0,0 +1,234 @@
---
name: wf
description: 从 RequirementsDoc.md 和 PRD.md 生成 FeatureSummary.md提供功能全貌概览。
---
# Write FeatureSummary
> **文档定位**FeatureSummary 是「功能契约」文档,是产品与开发的桥梁。精确定义功能边界、输入输出、依赖关系,确保双方对"做什么"达成共识。
当用户调用 `/wf` 时,执行以下步骤:
## 1. 读取源文档
读取以下文件:
1. `doc/RequirementsDoc.md` - 必须存在
2. `doc/PRD.md` - 必须存在
如果文件不存在,提示用户:
> 缺少上游文档,请先确保 RequirementsDoc.md 和 PRD.md 存在。
如果已存在 `doc/FeatureSummary.md`,同时读取作为参考(保持风格一致)。
## 2. 分析功能需求
从 PRD 中提取以下信息:
### 2.1 功能模块
- 从 PRD 3.1 功能架构提取模块结构
- 从 PRD 3.2 功能详情提取各模块功能点
### 2.2 功能分类
按以下维度整理功能:
- 按模块分组
- 按优先级标注P0/P1/P2
- 按用户角色关联
### 2.3 功能边界
明确每个功能的:
- 输入:触发条件、输入数据
- 输出:预期结果、输出数据
- 边界:不包含什么、异常情况
## 3. 生成 FeatureSummary
按以下结构生成文档:
```markdown
# {产品名称} - 功能摘要
## 文档信息
| 项目 | 内容 |
|------|------|
| 版本 | v1.0 |
| 创建日期 | {YYYY-MM-DD} |
| 来源文档 | PRD.md |
## 1. 功能总览
### 1.1 功能统计
| 类别 | 数量 |
|------|------|
| 功能模块 | X 个 |
| P0 功能 | X 个 |
| P1 功能 | X 个 |
| P2 功能 | X 个 |
### 1.2 功能架构图
**【必须】使用模块图展示功能架构和模块关系:**
```
┌─────────────────────────────────────────────────┐
│ {产品名称} │
├─────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 模块A │ │ 模块B │ │ 模块C │ │
│ │ ──────── │ │ ──────── │ │ ──────── │ │
│ │ • 功能1 │ │ • 功能1 │ │ • 功能1 │ │
│ │ • 功能2 │ │ • 功能2 │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────┘
```
### 1.3 模块依赖关系
**【必须】使用依赖图展示模块间关系:**
```
┌──────────┐
│ 模块A │
└────┬─────┘
│ 依赖
┌──────────┐ ┌──────────┐
│ 模块B │ ◀── │ 模块C │
└──────────┘ └──────────┘
```
## 2. 功能清单
### 2.1 {模块名称}
**模块职责**: {一句话描述模块职责}
#### 功能列表
| ID | 功能 | 描述 | 优先级 | 关联用户故事 |
|----|------|------|--------|--------------|
| F-001 | {功能名} | {简要描述} | P0 | US-xxx |
#### 功能契约详情
**F-001: {功能名}**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | {什么情况下触发此功能} |
| **输入** | {输入数据/参数} |
| **处理逻辑** | {核心处理步骤} |
| **输出** | {输出结果/返回值} |
| **异常情况** | {可能的错误及处理} |
| **边界说明** | {不包含什么、限制条件} |
{重复以上结构覆盖所有功能}
{重复以上结构覆盖所有模块}
## 3. 功能依赖矩阵
**【必须】使用矩阵表格展示功能间依赖:**
| 功能 | 依赖 F-001 | 依赖 F-002 | 依赖 F-003 |
|------|------------|------------|------------|
| F-001 | - | | |
| F-002 | ✓ | - | |
| F-003 | | ✓ | - |
说明:
- ✓ 表示行功能依赖列功能
- 空白表示无依赖
## 4. 功能流程图
**【必须】使用流程图展示核心功能流程:**
### 4.1 {核心流程名称}
```
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ F-001 │ ──▶ │ F-002 │ ──▶ │ F-003 │ ──▶ │ 完成 │
│ {功能} │ │ {功能} │ │ {功能} │ │ │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
▼ 异常
┌─────────┐
│ 错误处理 │
└─────────┘
```
## 5. 版本规划
| 版本 | 包含功能 | 功能ID | 目标 |
|------|----------|--------|------|
| MVP | {功能列表} | F-001, F-002 | {目标} |
| v1.1 | {功能列表} | F-003, F-004 | {目标} |
| v2.0 | {功能列表} | F-005+ | {目标} |
## 6. 接口契约预览
> 详细接口定义在 DevelopmentPlan 中,此处仅列出关键接口
| 功能 | 接口类型 | 简要说明 |
|------|----------|----------|
| F-001 | API | {说明} |
| F-002 | Event | {说明} |
```
## 4. 保存文档
将生成的 FeatureSummary 保存到 `doc/FeatureSummary.md`
如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。
## 5. 输出摘要
向用户展示生成摘要:
- FeatureSummary 文件路径
- 功能模块数量
- 各优先级功能数量
- 建议的下一步操作(运行 `/rf` 评审)
---
## 可视化输出要求
FeatureSummary 作为「功能契约」文档,需要精确传达功能定义,**必须包含**
| 章节 | 可视化形式 | 必要性 |
|------|------------|--------|
| 1.2 功能架构图 | 模块图ASCII | **必须** |
| 1.3 模块依赖关系 | 依赖图ASCII | **必须** |
| 3. 功能依赖矩阵 | 矩阵表格 | **必须** |
| 4. 功能流程图 | 流程图ASCII | **必须** |
## 注意事项
- FeatureSummary 是产品与开发的**桥梁**,语言要精确、无歧义
- 功能摘要必须完全来源于 PRD不要臆造功能
- 每个功能必须有明确的**输入、输出、边界**
- 功能 ID 必须唯一F-xxx 格式)
- 优先级必须与 PRD 一致
- 功能依赖关系必须明确,避免循环依赖
## 质量检查
生成 FeatureSummary 后,自查以下项目:
- [ ] 所有功能都有唯一 IDF-xxx
- [ ] 所有功能都有契约详情(输入/输出/边界)
- [ ] **功能架构图清晰展示模块结构**
- [ ] **模块依赖图清晰展示依赖关系**
- [ ] **功能依赖矩阵完整**
- [ ] **核心流程有流程图**
- [ ] 优先级与 PRD 一致
- [ ] 无遗漏 PRD 中的功能

318
.claude/skills/wp/SKILL.md Normal file
View File

@ -0,0 +1,318 @@
---
name: wp
description: 从 RequirementsDoc.md 生成 PRD.md将需求文档转化为结构化的产品需求文档。
---
# Write PRD
> **文档定位**PRD 是「价值主张」文档,使用业务语言描述产品要解决什么问题、为谁创造什么价值。面向产品、业务、管理层沟通。
当用户调用 `/wp` 时,执行以下步骤:
## 1. 读取源文档
读取 `doc/RequirementsDoc.md` 文件。
如果文件不存在,提示用户:
> RequirementsDoc.md 不存在,请先创建需求文档。
如果已存在 `doc/PRD.md`,同时读取作为参考(保持风格一致)。
## 2. 分析需求文档
从 RequirementsDoc 中提取以下信息:
### 2.1 产品定位
- 产品名称
- 目标用户
- 核心价值主张
- 竞品对比(如有)
### 2.2 功能需求
- 功能模块划分
- 各模块详细需求
- 功能优先级P0/P1/P2
### 2.3 非功能需求
- 性能要求
- 安全要求
- 兼容性要求
- 可用性要求
### 2.4 约束条件
- 技术约束
- 业务约束
- 时间约束
## 3. 生成 PRD
按以下结构生成 PRD 文档:
```markdown
# {产品名称} - 产品需求文档 (PRD)
## 文档信息
| 项目 | 内容 |
|------|------|
| 版本 | v1.0 |
| 创建日期 | {YYYY-MM-DD} |
| 状态 | 草稿 |
## 1. 产品概述
### 1.1 产品背景
{从 RequirementsDoc 提取,说明产品解决的问题和市场机会}
### 1.2 产品定位
{目标用户、核心价值、差异化优势}
### 1.3 产品目标
| 目标 | 指标 | 衡量方式 |
|------|------|----------|
| {业务目标} | {量化指标} | {如何衡量} |
## 2. 用户故事
PRD 以用户故事为核心驱动,所有功能需求都应对应到具体的用户故事。
### 2.1 用户角色定义
| 角色 | 描述 | 核心目标 | 痛点 |
|------|------|----------|------|
| {角色1} | {角色描述} | {核心目标} | {当前痛点} |
### 2.2 用户故事列表
按优先级排列的用户故事:
#### P0 - 核心故事
| ID | 用户故事 | 验收标准 |
|----|----------|----------|
| US-001 | 作为{角色},我想要{功能},以便{价值} | {验收标准} |
#### P1 - 重要故事
| ID | 用户故事 | 验收标准 |
|----|----------|----------|
| US-xxx | 作为{角色},我想要{功能},以便{价值} | {验收标准} |
#### P2 - 次要故事
| ID | 用户故事 | 验收标准 |
|----|----------|----------|
| US-xxx | 作为{角色},我想要{功能},以便{价值} | {验收标准} |
### 2.3 用户旅程
**【必须】使用流程图展示核心用户旅程:**
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 触发点 │ ──▶ │ 关键步骤 │ ──▶ │ 目标达成 │
│ {描述} │ │ {描述} │ │ {描述} │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
{用户感受} {用户感受} {用户感受}
```
{描述用户完成核心任务的完整流程,从触发点到目标达成}
## 3. 功能需求
> 功能需求与用户故事的对应关系
### 3.1 功能架构
**【必须】使用树状图或模块图展示功能架构:**
```
{产品名称}
├── {模块A}
│ ├── {功能A1}
│ └── {功能A2}
├── {模块B}
│ ├── {功能B1}
│ └── {功能B2}
└── {模块C}
└── {功能C1}
```
### 3.2 功能详情
#### 3.2.1 {模块名称}
| 功能点 | 描述 | 关联用户故事 | 优先级 | 验收标准 |
|--------|------|--------------|--------|----------|
| {功能1} | {描述} | US-001 | P0 | {标准} |
{重复以上结构覆盖所有模块}
## 4. 非功能需求
### 4.1 性能需求
| 指标 | 要求 | 说明 |
|------|------|------|
| {响应时间} | {要求} | {场景说明} |
### 4.2 安全需求
{数据安全、访问控制、合规要求}
### 4.3 兼容性需求
| 平台/环境 | 支持版本 |
|-----------|----------|
| {平台} | {版本} |
### 4.4 可用性需求
{SLA、故障恢复、监控告警}
## 5. 数据需求
### 5.1 数据模型
**【建议】使用 ER 图或表格展示核心实体关系:**
```
┌──────────┐ ┌──────────┐
│ 实体A │ 1───n │ 实体B │
├──────────┤ ├──────────┤
│ 字段1 │ │ 字段1 │
│ 字段2 │ │ 字段2 │
└──────────┘ └──────────┘
```
### 5.2 数据规范
| 字段 | 类型 | 说明 | 校验规则 |
|------|------|------|----------|
| {字段名} | {类型} | {说明} | {规则} |
## 6. 接口需求
### 6.1 外部接口
| 接口 | 用途 | 提供方 |
|------|------|--------|
| {接口名} | {用途} | {第三方} |
### 6.2 内部接口
{模块间接口规范}
## 7. 约束与依赖
### 7.1 技术约束
| 约束 | 说明 | 影响 |
|------|------|------|
| {约束} | {说明} | {影响范围} |
### 7.2 业务约束
{法规、政策、合同限制}
### 7.3 外部依赖
{第三方服务、团队依赖}
## 8. 里程碑规划
**【建议】使用时间线展示里程碑:**
```
Phase 1 Phase 2 Phase 3
│ │ │
▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐
│ MVP │ ────▶ │ v1.1 │ ────▶ │ v2.0 │
└──────┘ └──────┘ └──────┘
{日期} {日期} {日期}
```
| 阶段 | 目标 | 交付物 |
|------|------|--------|
| {阶段1} | {目标} | {交付物} |
## 9. 风险评估
| 风险 | 可能性 | 影响 | 应对措施 |
|------|--------|------|----------|
| {风险1} | 高/中/低 | 高/中/低 | {措施} |
## 附录
### A. 术语表
| 术语 | 定义 |
|------|------|
| {术语} | {定义} |
### B. 参考文档
- RequirementsDoc.md
```
## 4. 保存文档
将生成的 PRD 保存到 `doc/PRD.md`
如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。
## 5. 输出摘要
向用户展示生成摘要:
- PRD 文件路径
- 包含的功能模块数量
- 主要章节概览
- 建议的下一步操作(运行 `/rp` 评审)
---
## 可视化输出要求
PRD 作为「价值主张」文档,需要便于业务沟通理解,**必须包含**
| 章节 | 可视化形式 | 必要性 |
|------|------------|--------|
| 2.3 用户旅程 | 流程图ASCII | **必须** |
| 3.1 功能架构 | 树状图/模块图 | **必须** |
| 5.1 数据模型 | ER 图 | 建议 |
| 8. 里程碑规划 | 时间线 | 建议 |
## 注意事项
- PRD 使用**业务语言**,避免过多技术术语
- PRD 内容必须完全来源于 RequirementsDoc不要臆造需求
- 如果 RequirementsDoc 信息不完整,在对应章节标注"待补充"
- 保持语言风格与现有文档一致
- 优先级标注遵循 P0 > P1 > P2 规则
- 验收标准要具体可测试
## 质量检查
生成 PRD 后,自查以下项目:
- [ ] 所有用户故事都有唯一 IDUS-xxx
- [ ] 所有用户故事都符合格式:作为{角色},我想要{功能},以便{价值}
- [ ] 所有功能点都关联到用户故事
- [ ] 所有功能点都有明确的优先级
- [ ] 所有功能点都有验收标准
- [ ] **用户旅程有流程图**
- [ ] **功能架构有模块图**
- [ ] 非功能需求有量化指标
- [ ] 无遗漏 RequirementsDoc 中的重要需求
- [ ] 文档结构完整,无空章节(或标注"待补充"

View File

@ -0,0 +1,135 @@
---
name: writeTest
description: 从上游文档生成 tdd.md创建覆盖所有功能的测试用例文档。
---
# Write Test Cases
> **文档定位**tdd.md 是测试驱动开发的核心文档,基于 PRD 和功能文档生成结构化测试用例,确保所有功能点有对应的测试覆盖。
当用户调用 `/writeTest` 时,执行以下步骤:
## 1. 读取源文档
读取以下文件:
1. `doc/PRD.md` - 必须存在(功能需求和验收标准的主要来源)
2. `doc/FeatureSummary.md` - 必须存在(功能模块全貌)
3. `doc/DevelopmentPlan.md` - 必须存在(技术方案参考)
4. `doc/tasks.md` - 可选(任务粒度参考)
5. `doc/UIDesign.md` - 可选(界面交互测试参考)
如果必要文件不存在,提示用户:
> 缺少必要文档,请先确保 PRD.md、DevelopmentPlan.md 和 FeatureSummary.md 存在。
如果已存在 `doc/tdd.md`,同时读取作为参考(保持风格一致)。
## 2. 分析测试需求
从上游文档中提取以下信息:
### 2.1 功能测试点
- 从 PRD 的用户故事和验收标准提取功能测试点
- 从 FeatureSummary 的功能模块提取模块级测试点
### 2.2 边界与异常场景
- 分析各功能的边界条件
- 识别异常输入和错误处理场景
### 2.3 非功能测试点
- 从 PRD 非功能需求提取性能、安全、兼容性测试点
## 3. 生成 tdd.md
按以下结构生成文档:
```markdown
# {产品名称} - 测试用例文档 (TDD)
## 文档信息
| 项目 | 内容 |
|------|------|
| 版本 | v1.0 |
| 创建日期 | {YYYY-MM-DD} |
| 来源文档 | PRD.md, FeatureSummary.md |
## 1. 测试总览
| 统计项 | 数量 |
|--------|------|
| 总用例数 | X |
| 功能测试 | X |
| 边界测试 | X |
| 异常测试 | X |
| 非功能测试 | X |
## 2. {模块名称} 测试
### 2.1 {功能名称}
| ID | 测试用例 | 前置条件 | 测试步骤 | 预期结果 | 优先级 | 关联用户故事 |
|----|----------|----------|----------|----------|--------|--------------|
| TC-001 | {用例名} | {前置条件} | {步骤} | {预期结果} | P0 | US-001 |
{重复以上结构覆盖所有模块和功能}
## N. 非功能测试
### N.1 性能测试
| ID | 测试用例 | 测试条件 | 预期指标 | 优先级 |
|----|----------|----------|----------|--------|
| TC-xxx | {用例名} | {条件} | {指标} | P0/P1 |
### N.2 安全测试
| ID | 测试用例 | 测试方法 | 预期结果 | 优先级 |
|----|----------|----------|----------|--------|
| TC-xxx | {用例名} | {方法} | {结果} | P0/P1 |
## 附录:测试覆盖矩阵
| 用户故事 | 功能测试 | 边界测试 | 异常测试 | 覆盖状态 |
|----------|----------|----------|----------|----------|
| US-001 | TC-001 | TC-xxx | TC-xxx | ✅ |
```
## 4. 保存文档
将生成的测试用例文档保存到 `doc/tdd.md`
如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。
## 5. 输出摘要
向用户展示生成摘要:
- tdd.md 文件路径
- 测试用例总数
- 各模块用例分布
- 测试覆盖率概览
- 建议的下一步操作运行wt生成开发任务文档
---
## 注意事项
- 测试用例必须覆盖 PRD 中所有用户故事的验收标准
- 用例 ID 必须唯一TC-001, TC-002...
- 每个用例必须有明确的预期结果
- 优先级与对应功能优先级一致P0 功能 → P0 测试)
- 关注边界条件和异常路径,不仅仅是正向流程
- 测试步骤要具体可执行,无歧义
## 与其他 Skill 的关系
| 场景 | 使用方式 |
|------|----------|
| 准备上游文档 | `/wp` `/wf` `/wd` `/wu` `/wt` |
| 生成测试用例(本 Skill | `/writeTest` |
| 执行开发(含测试) | `/go` |
| 迭代后更新测试 | `/iter` 后重新运行 `/writeTest` |

129
.claude/skills/wt/SKILL.md Normal file
View File

@ -0,0 +1,129 @@
---
name: wt
description: 从上游文档生成 tasks.md创建可直接执行的任务列表。
---
# Write Tasks
当用户调用 `/wt` 时,执行以下步骤:
## 1. 读取源文档
读取以下文件:
1. `doc/RequirementsDoc.md` - 建议存在
2. `doc/PRD.md` - 必须存在
3. `doc/FeatureSummary.md` - 必须存在
4. `doc/DevelopmentPlan.md` - 必须存在
5. `doc/UIDesign.md` - 必须存在
6. `doc/tdd.md` - 建议存在
如果文件不存在,提示用户:
> 缺少上游文档,请确保所有上游文档存在。
如果已存在 `doc/tasks.md`,同时读取作为参考(保持风格一致)。
## 2. 分析任务需求
从上游文档中提取以下信息:
### 2.1 开发任务
- 从 DevelopmentPlan 获取开发阶段和任务
- 从 UIDesign 获取页面实现任务
### 2.2 任务依赖
- 分析任务间的依赖关系
- 确定任务执行顺序
### 2.3 验收标准
- 从 PRD 获取功能验收标准
- 转化为任务级别的完成标准
## 3. 生成 Tasks
按以下结构生成文档:
```markdown
# {产品名称} - 任务列表
## 文档信息
| 项目 | 内容 |
|------|------|
| 版本 | v1.0 |
| 创建日期 | {YYYY-MM-DD} |
| 来源文档 | UIDesign.md, DevelopmentPlan.md |
## 1. 任务总览
| 统计项 | 数量 |
|--------|------|
| 总任务数 | X |
| P0 任务 | X |
| P1 任务 | X |
| P2 任务 | X |
## 2. Phase 1 任务
### 2.1 {模块/功能名}
| ID | 任务 | 描述 | 优先级 | 依赖 | 验收标准 |
|----|------|------|--------|------|----------|
| T-001 | {任务名} | {描述} | P0 | - | {标准} |
| T-002 | {任务名} | {描述} | P0 | T-001 | {标准} |
{重复以上结构覆盖所有模块}
## 3. Phase 2 任务
{同上结构}
## 4. Phase N 任务
{同上结构}
## 5. 任务依赖图
```
T-001 (基础设施)
├── T-002 (功能A)
│ └── T-005 (功能A优化)
└── T-003 (功能B)
└── T-004 (功能B扩展)
```
## 6. 执行检查清单
- [ ] T-001: {任务名}
- [ ] T-002: {任务名}
{所有任务的检查清单}
```
## 4. 保存文档
将生成的 tasks 保存到 `doc/tasks.md`
如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。
## 5. 输出摘要
向用户展示生成摘要:
- tasks 文件路径
- 任务总数
- 各阶段任务分布
- 建议的下一步操作(运行 `/rt` 评审)
---
## 注意事项
- 任务必须覆盖 DevelopmentPlan 和 UIDesign 所有内容
- 任务 ID 必须唯一T-001, T-002...
- 每个任务必须有明确的验收标准
- 任务粒度要适中,可在合理时间内完成
- 依赖关系要明确,避免循环依赖
- 任务应可直接执行,无歧义

352
.claude/skills/wu/SKILL.md Normal file
View File

@ -0,0 +1,352 @@
---
name: wu
description: 从上游文档生成 UIDesign.md覆盖所有用户界面设计。
---
# Write UIDesign
> **文档定位**UIDesign 是「界面蓝图」文档,用 ASCII 原型图精确传达页面布局、组件结构、交互流程,是前端开发的直接参考。
当用户调用 `/wu` 时,执行以下步骤:
## 1. 读取源文档
读取以下文件:
1. `doc/RequirementsDoc.md` - 必须存在
2. `doc/PRD.md` - 必须存在
3. `doc/FeatureSummary.md` - 必须存在
4. `doc/DevelopmentPlan.md` - 必须存在
如果文件不存在,提示用户:
> 缺少上游文档,请确保所有上游文档存在。
如果已存在 `doc/UIDesign.md`,同时读取作为参考(保持风格一致)。
## 2. 分析 UI 需求
从上游文档中提取以下信息:
### 2.1 页面需求
- 从 PRD 用户旅程分析所需页面
- 从 FeatureSummary 获取功能对应的界面
- 从 DevelopmentPlan 获取技术实现约束
### 2.2 用户流程
- 主要用户旅程
- 页面跳转关系
- 交互流程
## 3. 生成 UIDesign
按以下结构生成文档:
```markdown
# {产品名称} - UI 设计文档
## 文档信息
| 项目 | 内容 |
|------|------|
| 版本 | v1.0 |
| 创建日期 | {YYYY-MM-DD} |
| 来源文档 | DevelopmentPlan.md |
## 1. 设计概述
### 1.1 设计原则
{UI 设计原则和规范}
### 1.2 页面总览
| 页面ID | 页面名称 | 描述 | 对应功能 | 优先级 |
|--------|----------|------|----------|--------|
| P-001 | {页面名} | {描述} | F-001 | P0 |
### 1.3 页面导航图
**【必须】使用导航图展示页面跳转关系:**
```
┌─────────────┐
│ 首页 │
│ P-001 │
└──────┬──────┘
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 功能A页 │ │ 功能B页 │ │ 设置页 │
│ P-002 │ │ P-003 │ │ P-004 │
└──────┬──────┘ └─────────────┘ └─────────────┘
┌─────────────┐
│ 详情页 │
│ P-005 │
└─────────────┘
```
## 2. 页面设计
### 2.1 P-001: {页面名称}
**页面信息**
| 属性 | 值 |
|------|-----|
| 页面ID | P-001 |
| 对应功能 | F-001, F-002 |
| 入口 | {从哪些页面可进入} |
| 出口 | {可跳转到哪些页面} |
**【必须】页面布局 - ASCII 原型图**
```
┌────────────────────────────────────────────────────────┐
│ ┌─────────────────────────────────────────────────┐ │
│ │ Header │ │
│ │ [Logo] [Nav Item] [Nav Item] [用户]│ │
│ └─────────────────────────────────────────────────┘ │
├────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌───────────────────────────┐ │
│ │ │ │ │ │
│ │ Sidebar │ │ Main Content │ │
│ │ │ │ │ │
│ │ • Menu Item 1 │ │ ┌─────────────────────┐ │ │
│ │ • Menu Item 2 │ │ │ Card 1 │ │ │
│ │ • Menu Item 3 │ │ │ [Title] │ │ │
│ │ │ │ │ [Description...] │ │ │
│ │ │ │ │ [Action Button] │ │ │
│ │ │ │ └─────────────────────┘ │ │
│ │ │ │ │ │
│ │ │ │ ┌─────────────────────┐ │ │
│ │ │ │ │ Card 2 │ │ │
│ │ │ │ └─────────────────────┘ │ │
│ │ │ │ │ │
│ └──────────────────┘ └───────────────────────────┘ │
│ │
├────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────┐ │
│ │ Footer │ │
│ └─────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
```
**组件清单**
| 组件ID | 组件名称 | 类型 | 说明 | 交互 |
|--------|----------|------|------|------|
| C-001 | Header | 导航栏 | 顶部固定 | 点击 Logo 回首页 |
| C-002 | Sidebar | 侧边栏 | 左侧固定 | 点击菜单切换内容 |
| C-003 | Card | 卡片 | 内容展示 | 点击进入详情 |
**交互说明**
| 触发 | 动作 | 结果 |
|------|------|------|
| 点击 Card | 跳转 | 进入详情页 P-005 |
| 点击 Menu Item | 切换 | 更新 Main Content |
**页面状态**
| 状态 | 说明 | 展示 |
|------|------|------|
| 默认 | 正常加载完成 | 显示数据列表 |
| 加载中 | 数据请求中 | 骨架屏/Loading |
| 空状态 | 无数据 | 空状态插图+引导文案 |
| 错误 | 请求失败 | 错误提示+重试按钮 |
**空状态原型**
```
┌─────────────────────────────────────┐
│ │
│ ┌─────────────┐ │
│ │ (空图标) │ │
│ └─────────────┘ │
│ │
│ 暂无数据 │
│ │
│ [去添加数据] │
│ │
└─────────────────────────────────────┘
```
{重复以上结构覆盖所有页面}
## 3. 用户流程
### 3.1 {流程名称}
**【必须】使用流程图展示用户操作流程:**
```
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Step 1 │ ──▶ │ Step 2 │ ──▶ │ Step 3 │ ──▶ │ 完成 │
│ {操作} │ │ {操作} │ │ {操作} │ │ │
│ P-001 │ │ P-002 │ │ P-003 │ │ P-004 │
└─────────┘ └────┬────┘ └─────────┘ └─────────┘
▼ 取消
┌─────────┐
│ 返回 │
│ P-001 │
└─────────┘
```
**流程步骤**
| 步骤 | 页面 | 用户操作 | 系统响应 |
|------|------|----------|----------|
| 1 | P-001 | {操作} | {响应} |
| 2 | P-002 | {操作} | {响应} |
## 4. 组件规范
### 4.1 基础组件
**Button 按钮**
```
主按钮: ┌──────────────┐
│ 确认提交 │ (填充色背景)
└──────────────┘
次按钮: ┌──────────────┐
│ 取消 │ (边框样式)
└──────────────┘
禁用态: ┌──────────────┐
│ 不可点击 │ (灰色)
└──────────────┘
```
**Input 输入框**
```
默认态: ┌────────────────────────┐
│ 请输入... │
└────────────────────────┘
聚焦态: ┌────────────────────────┐
│ 输入内容 │ (高亮边框)
└────────────────────────┘
错误态: ┌────────────────────────┐
│ 错误输入 │ (红色边框)
└────────────────────────┘
⚠ 错误提示信息
```
### 4.2 业务组件
{项目特有的业务组件}
## 5. 设计规范
### 5.1 色彩规范
| 用途 | 色值 | 示例 |
|------|------|------|
| 主色 | #1890FF | 主按钮、链接 |
| 成功 | #52C41A | 成功提示 |
| 警告 | #FAAD14 | 警告提示 |
| 错误 | #FF4D4F | 错误提示 |
| 文字主色 | #262626 | 标题 |
| 文字次色 | #8C8C8C | 描述 |
### 5.2 字体规范
| 用途 | 字号 | 字重 |
|------|------|------|
| 大标题 | 24px | Bold |
| 标题 | 18px | Medium |
| 正文 | 14px | Regular |
| 辅助文字 | 12px | Regular |
### 5.3 间距规范
| 间距 | 值 | 用途 |
|------|-----|------|
| xs | 4px | 紧凑间距 |
| sm | 8px | 小间距 |
| md | 16px | 标准间距 |
| lg | 24px | 大间距 |
| xl | 32px | 特大间距 |
### 5.4 响应式断点
| 断点 | 宽度 | 布局说明 |
|------|------|----------|
| Mobile | < 768px | 单栏布局 |
| Tablet | 768px - 1024px | 双栏布局 |
| Desktop | > 1024px | 多栏布局 |
```
## 4. 保存文档
将生成的 UIDesign 保存到 `doc/UIDesign.md`
如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。
## 5. 输出摘要
向用户展示生成摘要:
- UIDesign 文件路径
- 页面数量
- 用户流程数量
- 建议的下一步操作(运行 `/ru` 评审)
---
## 可视化输出要求
UIDesign 作为「界面蓝图」文档,**必须大量使用 ASCII 原型图**
| 章节 | 可视化形式 | 必要性 |
|------|------------|--------|
| 1.3 页面导航图 | 导航关系图ASCII | **必须** |
| 2.x 页面布局 | **ASCII 原型图** | **必须(每页)** |
| 2.x 空状态 | ASCII 原型图 | 建议 |
| 3.x 用户流程 | 流程图ASCII | **必须** |
| 4.x 组件规范 | 组件示意图 | **必须** |
**ASCII 原型图要求**
- 每个页面**必须**有完整的布局原型图
- 原型图要体现:页面结构、组件位置、内容区域
- 使用 `┌ ┐ └ ┘ ─ │ ├ ┤ ┬ ┴ ┼` 等字符绘制边框
- 使用 `[ ]` 表示按钮
- 使用 `▼ ▶ ◀ ▲` 表示方向/展开
- 关键交互点要标注
## 注意事项
- UI 设计必须覆盖 DevelopmentPlan 所有功能模块
- **每个页面必须有 ASCII 原型图**
- 页面设计要考虑各种状态(默认、加载、空、错误)
- 交互说明要清晰具体
- 设计规范要统一一致
- 页面 ID 格式P-xxx
- 组件 ID 格式C-xxx
## 质量检查
生成 UIDesign 后,自查以下项目:
- [ ] 覆盖 DevelopmentPlan 所有功能模块
- [ ] **页面导航图清晰展示页面关系**
- [ ] **每个页面都有 ASCII 原型图**
- [ ] **原型图展示了完整的页面结构**
- [ ] **用户流程有流程图**
- [ ] 每个页面都有状态说明
- [ ] 组件清单完整
- [ ] 交互说明清晰
- [ ] 设计规范统一

24
.gitignore vendored
View File

@ -1,7 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
node_modules
/.pnp
.pnp.*
.yarn/*
@ -11,14 +9,15 @@
!.yarn/versions
# testing
/coverage
coverage
# next.js
/.next/
/out/
.next/
out/
# production
/build
build
dist
# misc
.DS_Store
@ -30,8 +29,10 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# env files
.env
.env.*
!.env.example
# vercel
.vercel
@ -39,3 +40,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# playwright
test-results/
playwright-report/
blob-report/

92
CLAUDE.md Normal file
View File

@ -0,0 +1,92 @@
# CLAUDE.md
本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指引。
## 常用命令
```bash
# 开发 — 同时启动前端 (3000) 和后端 (3001)
pnpm dev
# 构建所有包
pnpm build
# 单元测试所有包Vitest
pnpm test # 运行一次
pnpm test:watch # 监听模式
pnpm test:coverage # 含覆盖率(阈值 80%
# 运行单个包的测试
pnpm --filter @muse/backend test
pnpm --filter @muse/frontend test
pnpm --filter @muse/shared test
# 运行单个测试文件
npx vitest run packages/backend/src/lib/tikhub.test.ts
# 代码检查(仅前端)
pnpm lint
# E2E 测试Playwrightmock 后端)
pnpm test:e2e # 无头模式
pnpm test:e2e:ui # 交互式 UI
# E2E 测试(真实后端 + TikHub API仅在明确要求时运行
pnpm test:e2e:real
```
## 架构
pnpm monorepo包含三个包
```
packages/
frontend/ @muse/frontend Next.js 16 (App Router, React 19) → localhost:3000
backend/ @muse/backend Hono 4 on Node → localhost:3001
shared/ @muse/shared 类型定义与平台配置
```
**请求链路**:浏览器 → Next.js 前端 → Hono 后端 → TikHub API (api.tikhub.io)
前端通过 CORS 直接请求后端(不经过 Next.js API Routes 代理。API 基础地址来自环境变量 `NEXT_PUBLIC_API_URL`
### 后端
- **入口**`src/index.ts``src/app.ts`Hono + CORS 中间件)
- **路由**`/api/tikhub/:platform`(热门内容)、`/api/tikhub/:platform/detail`(内容详情)、`/api/settings`API Key 管理)
- **适配器**`src/lib/adapters/{douyin,tiktok,xiaohongshu,youtube,instagram,twitter,bilibili,weibo}.ts` — 各自实现 `PlatformAdapter` 接口,将平台特定响应标准化为 `ContentItem`(共 8 个平台)
- **限流**`src/lib/rate-limiter.ts` 滑动窗口限流10 请求/秒
- **API Key**:运行时 Key通过 POST /api/settings 设置)优先于 `process.env.TIKHUB_API_KEY`;后端不会自动加载 `.env`(未使用 dotenv
### 前端
- **状态管理**Zustand store + localStorage 持久化 — `settings`API Key、刷新间隔`favorites`(收藏列表)
- **数据获取**TanStack React Query — `useContentQuery`(热门列表)和 `useDetailQuery`(单条详情,带缓存查找)
- **页面**`/`(首页,含平台标签页 + 排序)、`/detail/[platform]/[id]``/favorites``/settings`
- **样式**Tailwind CSS v4、shadcn/ui 组件、Lucide 图标、Sonner toast 提示
### Shared共享包
导出 `Platform` 类型、`ContentItem` 接口、`PlatformAdapter` 接口、`MVP_PLATFORMS` 配置数组和 `getPlatformConfig()` 函数。
## 关键模式
- 平台适配器是集成边界 — 新增平台只需在 `packages/backend/src/lib/adapters/` 下创建适配器并在 `index.ts` 中注册
- 前端组件使用 `data-testid` 属性作为 E2E 选择器(如 `content-grid``content-card``favorite-btn``platform-tab-{id}``sort-select``sort-order`
- `e2e/` 中的 E2E 测试通过 `page.route()` mock 所有 `/api/tikhub/` 请求 — 仅测试前端逻辑
- `e2e-real/` 中的 E2E 测试走真实后端和 TikHub API — 必须串行运行(`workers: 1`)以避免共享状态污染
- 覆盖率配置在各包的 `vitest.config.ts` 中;前端仅覆盖 `src/lib/**``src/stores/**`
## 环境变量
后端 `.env`
```
TIKHUB_API_KEY=...
CORS_ORIGIN=http://localhost:3000
PORT=3001
```
前端 `.env.local`
```
NEXT_PUBLIC_API_URL=http://localhost:3001
```

669
doc/DevelopmentPlan.md Normal file
View File

@ -0,0 +1,669 @@
# Muse Creative Hotspots — 开发计划
## 文档信息
| 项目 | 内容 |
|------|------|
| 版本 | v1.0 |
| 创建日期 | 2026-03-02 |
| 来源文档 | FeatureSummary.md, PRD.md |
## 1. 项目概述
### 1.1 项目目标
构建面向个人创意工作者的全平台热点内容聚合浏览器MVP 阶段实现抖音 + TikTok + 小红书三个平台的热点内容聚合浏览,包含卡片信息流、筛选排序、内容详情、收藏系统、数据刷新和设置管理。
### 1.2 技术栈
| 层级 | 技术选型 | 版本 | 说明 |
|------|----------|------|------|
| 框架 | Next.js (App Router) | 14+ | 全栈能力API Routes 做后端代理Vercel 部署 |
| UI 库 | Tailwind CSS + shadcn/ui | Tailwind 3.x | 简约现代风格,组件丰富 |
| 状态管理 | Zustand | 4.x | 轻量状态管理persist 中间件支持持久化 |
| 数据请求 | TanStack Query | 5.x | 缓存、自动刷新、loading/error 状态管理 |
| 本地存储 | localStorage | - | MVP 阶段收藏/设置持久化 |
| 语言 | TypeScript | 5.x | 类型安全 |
| 包管理器 | pnpm | 8+ | 快速、节省磁盘 |
| 部署 | localhost → Vercel | - | 先本地开发,后期线上部署 |
### 1.3 开发原则
- **渐进式开发**:先跑通数据链路,再完善 UI 和交互
- **适配器模式**:平台差异封装在适配器内,新增平台零侵入
- **安全优先**API Key 仅存在于服务端,前端不暴露
- **类型驱动**:先定义 TypeScript 类型,再实现逻辑
- **组件化**UI 组件遵循单一职责,可独立测试
---
## 2. 技术架构
### 2.1 系统架构图
```
┌─────────────────────────────────────────────────────────────────┐
│ 客户端(浏览器) │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Next.js App (React) │ │
│ │ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌───────────┐ │ │
│ │ │ 首页 │ │ 详情页 │ │ 收藏页 │ │ 设置页 │ │ │
<!-- MODIFIED: 原内容为 "[id]/",补充 platform 参数M-001 -->
│ │ │ page.tsx│ │[plt]/[id]│ │favorites│ │ settings │ │ │
│ │ └────┬────┘ └────┬─────┘ └────┬────┘ └─────┬─────┘ │ │
│ │ │ │ │ │ │ │
│ │ ┌────▼────────────▼─────────────▼──────────────▼─────┐ │ │
│ │ │ TanStack Query (缓存 + 自动刷新) │ │ │
│ │ └────────────────────────┬───────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────────▼───────────────────────────┐ │ │
│ │ │ Zustand Stores (settings / favorites) │ │ │
│ │ │ ↕ localStorage │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────┬───────────────────────────────┘ │
└──────────────────────────────┼──────────────────────────────────┘
│ fetch /api/tikhub/[platform]
┌─────────────────────────────────────────────────────────────────┐
│ Next.js API Routes (服务端) │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ /api/tikhub/[platform]/route.ts │ │
│ │ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │ │
│ │ │ 请求验证 │→│ 频率限制 │→│ 平台适配器分发 │ │ │
│ │ │ API Key │ │ 10 req/s │ │ douyin/tiktok/xhs │ │ │
│ │ └──────────┘ └──────────────┘ └─────────┬─────────┘ │ │
│ └────────────────────────────────────────────┼─────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 平台适配器层 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ 抖音 │ │ TikTok │ │ 小红书 │ ... (扩展) │ │
│ │ │ Adapter │ │ Adapter │ │ Adapter │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │
│ │ │ │ │ │ │
│ │ └─────────────┼─────────────┘ │ │
│ │ ▼ │ │
│ │ ContentItem[] 统一数据模型 │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────┼───────────────────────────────────┘
│ Bearer Token
┌─────────────────────┐
│ TikHub API │
│ api.tikhub.io │
│ 10 req/s 限制 │
│ $0.001/请求 │
└─────────────────────┘
```
### 2.2 模块依赖图
```
┌───────────────────────────────────────────────────────┐
│ 页面层 (Pages) │
│ ┌──────────┐ ┌──────────┐ ┌──────┐ ┌──────────┐ │
│ │ 首页 │ │ 详情页 │ │收藏页│ │ 设置页 │ │
│ └────┬─────┘ └────┬─────┘ └──┬───┘ └────┬─────┘ │
└───────┼─────────────┼───────────┼───────────┼─────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌───────────────────────────────────────────────────────┐
│ 组件层 (Components) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ CardGrid │ │ DetailPnl│ │ Toolbar │ ... │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└───────┼─────────────┼──────────────┼──────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────┐
│ 数据层 (Hooks + Stores) │
│ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ TanStack Query │ │ Zustand Stores │ │
│ │ useContentQuery │ │ useFavoritesStore │ │
│ │ useDetailQuery │ │ useSettingsStore │ │
│ └────────┬─────────┘ └────────────┬─────────────┘ │
└───────────┼─────────────────────────┼─────────────────┘
│ │
▼ ▼
┌───────────────────────┐ ┌────────────────────────────┐
│ API 代理层 │ │ 本地存储 │
│ /api/tikhub/[plat] │ │ localStorage │
│ │ │ └────────────────────────────┘
│ ▼ │
│ 平台适配器层 │
│ adapters/*.ts │
│ │ │
│ ▼ │
│ ContentItem 类型 │
└───────────────────────┘
```
### 2.3 数据流图
```
用户操作 前端 API Route TikHub
│ │ │ │
│ 1.打开首页/切换Tab │ │ │
├─────────────────────▶│ │ │
│ │ 2.useContentQuery() │ │
│ ├───────────────────────▶│ │
│ │ │ 3.读取 API Key │
│ │ │ 4.选择适配器 │
│ │ ├────────────────────▶│
│ │ │ 5.TikHub原始响应 │
│ │ │◀────────────────────┤
│ │ │ 6.适配器转换 │
│ │ │ → ContentItem[] │
│ │ 7.返回标准化数据 │ │
│ │◀───────────────────────┤ │
│ │ 8.TanStack Query缓存 │ │
│ │ 9.前端排序/筛选 │ │
│ 10.渲染卡片网格 │ │ │
│◀─────────────────────┤ │ │
│ │ │ │
│ 11.点击收藏 │ │ │
├─────────────────────▶│ │ │
│ │ 12.Zustand更新 │ │
│ │ 13.localStorage持久化 │ │
│ 14.收藏状态反馈 │ │ │
│◀─────────────────────┤ │ │
```
---
## 3. 开发阶段
### 3.1 阶段时间线
```
Phase 1 Phase 2 Phase 3
基础架构搭建 核心功能实现 辅助功能 & 联调
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 项目初始化│ │ 内容展示 │ │ 收藏系统 │
│ 类型定义 │ ──────▶ │ 筛选排序 │ ──────▶ │ 设置页面 │
│ API代理层 │ │ 详情页 │ │ 性能优化 │
│ 适配器 │ │ 刷新机制 │ │ 联调验收 │
└──────────┘ └──────────┘ └──────────┘
交付物: 交付物: 交付物:
• 项目骨架 • 首页卡片信息流 • 收藏功能
• 类型系统 • 平台Tab切换 • 收藏夹页面
• 3平台适配器 • 排序功能 • 设置页面
• API代理可用 • 详情页 • 图片懒加载
• 全局布局 • 自动/手动刷新 • 全链路验收
```
### 3.2 Phase 1: 基础架构搭建
**目标**: 搭建项目骨架,打通 API 代理 → 适配器 → 统一数据模型的完整链路,确保能从 TikHub 获取到标准化的 ContentItem 数据。
| 任务ID | 任务 | 描述 | 依赖 | 优先级 | 关联功能 |
|--------|------|------|------|--------|----------|
<!-- MODIFIED: 补充 next.config.ts 图片域名配置S-002 -->
| T-001 | 项目初始化 | Next.js 14+ App Router + Tailwind + shadcn/ui + pnpm配置 next.config.ts images.remotePatterns各平台图片 CDN 域名白名单) | - | P0 | - |
| T-002 | TypeScript 类型定义 | ContentItem、Platform、PlatformAdapter 接口定义 | T-001 | P0 | F-015 |
| T-003 | API 代理层实现 | `/api/tikhub/[platform]/route.ts`Bearer Token 认证,频率限制 | T-001 | P0 | F-014 |
| T-004 | TikHub API 客户端 | 封装 HTTP 请求错误处理10 req/s 限流 | T-003 | P0 | F-014 |
| T-005 | 平台适配器 — 抖音 | 热搜榜 + 内容详情 API字段映射为 ContentItem | T-002, T-004 | P0 | F-016 |
| T-006 | 平台适配器 — TikTok | 趋势内容 + 内容详情 API字段映射为 ContentItem | T-002, T-004 | P0 | F-016 |
| T-007 | 平台适配器 — 小红书 | 推荐内容 + 笔记详情 API字段映射为 ContentItem | T-002, T-004 | P0 | F-016 |
| T-008 | Zustand Store 基础 | settingsStoreAPI Key、刷新间隔+ favoritesStore 骨架 | T-001 | P0 | F-009, F-010 |
| T-009 | 全局布局组件 | HeaderLogo + 平台 Tab + 设置入口)+ 主内容区域 | T-001 | P0 | - |
**阶段依赖图:**
```
T-001 (项目初始化)
├──▶ T-002 (类型定义) ──┐
├──▶ T-003 (API代理层) │
│ └──▶ T-004 (API客户端) ──┐
├──▶ T-008 (Zustand) │ │
└──▶ T-009 (全局布局) │ │
▼ ▼
T-005 (抖音适配器)
T-006 (TikTok适配器)
T-007 (小红书适配器)
```
**Phase 1 验收**: `GET /api/tikhub/douyin` 返回标准化 ContentItem[] JSON。
---
### 3.3 Phase 2: 核心功能实现
**目标**: 实现首页卡片信息流、平台 Tab 切换、排序、详情页、自动/手动刷新,完成核心浏览体验。
| 任务ID | 任务 | 描述 | 依赖 | 优先级 | 关联功能 |
|--------|------|------|------|--------|----------|
| T-010 | TanStack Query 集成 | 配置 QueryClient封装 `useContentQuery(platform)` hook | T-005~T-007 | P0 | F-001 |
| T-011 | 内容卡片组件 | ContentCard 组件:封面图、标题、平台图标、数据指标、作者信息 | T-002, T-009 | P0 | F-002 |
| T-012 | 卡片网格布局 | 响应式网格布局CSS Grid支持不同屏幕尺寸 | T-011 | P0 | F-002 |
| T-013 | 平台 Tab 切换 | 顶部 Tab 栏,切换平台触发数据重新获取,支持"全部"聚合视图 | T-010, T-012 | P0 | F-003 |
| T-014 | 排序功能 | 工具栏排序控件,支持 play_count/like_count/comment_count/publish_time + asc/desc | T-010 | P0 | F-003 |
<!-- MODIFIED: 路由改为 /detail/[platform]/[id]语义更清晰M-001 -->
| T-015 | 内容详情页 | `/detail/[platform]/[id]` 页面,完整信息展示 + "查看原文"跳转 + 收藏按钮 | T-010 | P0 | F-004 |
| T-016 | 自动定时刷新 | TanStack Query refetchInterval读取设置中的刷新间隔页面不可见时暂停 | T-010, T-008 | P0 | F-005 |
<!-- MODIFIED: 合并 T-018 到 T-017T-018 粒度过细S-003 -->
| T-017 | 手动刷新 + 刷新时间 | 工具栏刷新按钮invalidateQueriesloading 状态,防抖处理,重置自动刷新计时器;工具栏显示"上次刷新: HH:MM",刷新后更新 | T-010, T-016 | P0 | F-005, F-006 |
**阶段依赖图:**
```
T-010 (TanStack Query) ──┬──▶ T-013 (平台Tab)
├──▶ T-014 (排序)
├──▶ T-015 (详情页)
├──▶ T-016 (自动刷新) ──▶ T-017 (手动刷新+刷新时间)
T-011 (卡片组件) ──▶ T-012 (网格布局) ──▶ T-013
```
**Phase 2 验收**: 首页展示三个平台的热点内容卡片网格,可切换平台/排序,点击进入详情页,自动/手动刷新正常。
---
### 3.4 Phase 3: 辅助功能 & 联调
**目标**: 完成收藏系统、设置页面、错误处理、性能优化,通过全部 MVP 验收标准。
| 任务ID | 任务 | 描述 | 依赖 | 优先级 | 关联功能 |
|--------|------|------|------|--------|----------|
| T-019 | 收藏功能实现 | favoritesStore 完善addFavorite/removeFavoritepersist 到 localStorage | T-008, T-011 | P0 | F-007, F-009 |
| T-020 | 卡片收藏按钮 | ContentCard + DetailPage 中添加收藏按钮,实心/空心状态切换 | T-019, T-015 | P0 | F-007 |
| T-021 | 收藏夹页面 | `/favorites` 页面,网格展示收藏内容,支持取消收藏和跳转详情 | T-019, T-012 | P0 | F-008 |
| T-022 | 设置页面 | `/settings` 页面API Key 输入框 + 刷新间隔选择 | T-008 | P0 | F-010, F-011 |
| T-023 | 错误处理 & 空状态 | API 错误提示、Key 未配置引导、数据为空提示、封面图加载失败占位 | T-010~T-022 | P0 | - |
| T-024 | 图片懒加载 | Next.js Image 组件 + loading="lazy",首屏性能优化 | T-012 | P0 | - |
| T-025 | 全链路联调 & 验收 | 按 PRD 第8节 MVP 验收标准逐项测试 | T-023, T-024 | P0 | 全部 |
**阶段依赖图:**
```
T-019 (收藏Store) ──┬──▶ T-020 (收藏按钮)
└──▶ T-021 (收藏夹页面)
T-022 (设置页面)
T-023 (错误处理) ◀── T-019~T-022 全部完成
T-024 (图片懒加载)
T-025 (联调验收) ◀── T-023 + T-024
```
**Phase 3 验收**: 通过 PRD 第8节全部 MVP 验收标准。
---
## 4. 技术方案
### 4.1 统一数据模型F-015
**功能**: 定义 ContentItem TypeScript 类型,作为全系统的数据契约。
**类型定义**:
```typescript
// src/types/content.ts
interface ContentItem {
id: string;
title: string;
cover_url?: string;
video_url?: string;
author_name: string;
author_avatar?: string;
play_count?: number;
like_count?: number;
comment_count?: number;
share_count?: number;
publish_time: string;
platform: Platform;
original_url: string;
tags?: string[];
}
type Platform =
| 'douyin' | 'tiktok' | 'xiaohongshu' // MVP
| 'youtube' | 'instagram' | 'twitter' // P1
| 'bilibili' | 'weibo' // P1
| string; // P2 扩展
interface PlatformConfig {
id: Platform;
name: string;
icon: string;
color: string;
enabled: boolean;
endpoints: {
trending: string;
detail: string;
};
}
interface PlatformAdapter {
fetchTrending(count: number): Promise<ContentItem[]>;
fetchDetail(id: string): Promise<ContentItem>;
}
```
### 4.2 API 代理层F-014
**功能**: Next.js API Routes 代理 TikHub 请求,隐藏 API Key。
**接口设计**:
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 获取热榜 | GET | `/api/tikhub/[platform]?count=20` | 返回 ContentItem[] |
| 获取详情 | GET | `/api/tikhub/[platform]/detail?id=xxx` | 返回 ContentItem |
| 保存设置 | POST | `/api/settings` | 保存 API Key服务端 |
| 调用统计 | GET | `/api/stats` | 返回当日调用次数 |
**架构设计**:
```
┌───────────────────────────────────────────────────┐
│ /api/tikhub/[platform]/route.ts │
├───────────────────────────────────────────────────┤
│ │
│ 1. 解析 platform 参数 │
│ │ │
│ ▼ │
│ 2. 读取 API Key (环境变量 / settings) │
│ │ │
│ ▼ │
│ 3. 频率限制检查 (10 req/s) │
│ │ │
│ ▼ │
│ 4. 选择 PlatformAdapter │
│ ┌─────┼─────┐ │
│ ▼ ▼ ▼ │
│ douyin tiktok xiaohongshu │
│ │ │ │ │
│ └─────┼─────┘ │
│ ▼ │
│ 5. 调用 TikHub API + 转换为 ContentItem[] │
│ │ │
│ ▼ │
│ 6. 返回 JSON Response │
│ │
└───────────────────────────────────────────────────┘
```
**实现要点**:
<!-- MODIFIED: 明确 API Key 运行时存储方案和读取优先级M-002 -->
- API Key 读取优先级:① 运行时内存变量(设置页面覆盖值)→ ② `.env.local``TIKHUB_API_KEY` 环境变量(预配置)
- 设置页面保存 API Key 时,通过 `POST /api/settings` 将 Key 写入服务端内存变量(进程生命周期内有效),不写入 `.env.local`(运行时无法修改);服务重启后回退到 `.env.local` 配置
- MVP 阶段localhost推荐在 `.env.local` 中预配置 Key设置页面仅作为运行时覆盖手段
- 使用简单的内存计数器实现 10 req/s 限流(滑动窗口)
- 错误码映射TikHub 401 → 前端提示配置 Key429 → 提示稍后重试5xx → 通用错误
### 4.3 平台适配器F-016
**功能**: 各平台 API 调用和数据格式转换。
**架构设计**:
```
┌────────────────────────────────────────────────┐
│ PlatformAdapter 接口 │
│ fetchTrending(count) → ContentItem[] │
│ fetchDetail(id) → ContentItem │
└──────────────────┬─────────────────────────────┘
│ implements
┌─────────┼─────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Douyin │ │ TikTok │ │ Xhs │
│ Adapter │ │ Adapter │ │ Adapter │
├──────────┤ ├──────────┤ ├──────────┤
│ 端点: │ │ 端点: │ │ 端点: │
│ fetch_hot │ │ trending │ │ fetch_ │
_search_ │ │ _post │ │ feed │
│ result │ │ │ │ │
│ │ │ │ │ │
│ 映射: │ │ 映射: │ │ 映射: │
│ 平台特定 │ │ 平台特定 │ │ 平台特定 │
│ → Content │ │ → Content│ │ → Content│
│ Item │ │ Item │ │ Item │
└──────────┘ └──────────┘ └──────────┘
```
**MVP 平台端点配置**:
| 平台 | 热榜端点 | 详情端点 |
|------|----------|----------|
| 抖音 | `/api/v1/douyin/web/fetch_hot_search_result` | `/api/v1/douyin/web/fetch_one_video` |
| TikTok | `/api/v1/tiktok/web/fetch_trending_post` | `/api/v1/tiktok/web/fetch_post_detail` |
| 小红书 | `/api/v1/xiaohongshu/app/v2/fetch_feed` | `/api/v1/xiaohongshu/app/v2/fetch_note_detail` |
**实现要点**:
- 每个适配器独立文件:`src/lib/adapters/douyin.ts``tiktok.ts``xiaohongshu.ts`
- 适配器注册表:`src/lib/adapters/index.ts` 导出 `getAdapter(platform): PlatformAdapter`
- 字段映射中缺失字段使用合理默认值(如 `author_name: "未知作者"`
### 4.4 内容展示层F-002, F-003
**功能**: 卡片网格布局 + 平台 Tab 切换 + 排序。
**组件结构**:
```
┌────────────────────────────────────────────────┐
│ Header │
│ ┌──────────────────────────────────────────┐ │
│ │ Logo [全部][抖音][TikTok][小红书] ⚙️ │ │
│ └──────────────────────────────────────────┘ │
├────────────────────────────────────────────────┤
│ Toolbar │
│ ┌──────────────────────────────────────────┐ │
│ │ 排序: [▼最热|最新] 🔄刷新 上次:10:30│ │
│ └──────────────────────────────────────────┘ │
├────────────────────────────────────────────────┤
│ ContentGrid │
│ ┌──────────────────────────────────────────┐ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │Content │ │Content │ │Content │ ... │ │
│ │ │Card │ │Card │ │Card │ │ │
│ │ └────────┘ └────────┘ └────────┘ │ │
│ └──────────────────────────────────────────┘ │
└────────────────────────────────────────────────┘
```
**接口设计**:
| 组件 | Props | 说明 |
|------|-------|------|
| `PlatformTabs` | `platforms`, `active`, `onChange` | 平台切换 Tab 栏 |
| `SortToolbar` | `sortBy`, `sortOrder`, `onSort`, `onRefresh`, `lastRefresh` | 排序 + 刷新工具栏 |
| `ContentGrid` | `items: ContentItem[]`, `loading`, `error` | 卡片网格容器 |
| `ContentCard` | `item: ContentItem`, `onFavorite`, `isFavorited` | 单个内容卡片 |
**实现要点**:
- 网格布局使用 CSS Grid`grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))`
- 排序在前端内存中完成useMemo不重新请求 API
- 平台 Tab 切换触发 TanStack Query 的 queryKey 变更,自动重新获取数据
<!-- MODIFIED: 补充 rate-limiter 保护说明与风险管理一致S-001 -->
- "全部"视图使用 `Promise.all` 并发请求所有已启用平台,通过 rate-limiter 确保不超 10 req/s
### 4.5 收藏系统F-007, F-008, F-009
**功能**: Zustand + persist 实现收藏功能。
**接口设计**:
```typescript
// src/stores/favorites.ts
interface FavoritesStore {
items: ContentItem[];
addFavorite: (item: ContentItem) => void;
removeFavorite: (id: string, platform: Platform) => void;
isFavorited: (id: string, platform: Platform) => boolean;
}
```
**实现要点**:
- 使用 Zustand `persist` 中间件,存储到 localStorage key `muse-favorites`
- 收藏去重:以 `id + platform` 组合作为唯一键
- 收藏夹页面复用 `ContentGrid` + `ContentCard` 组件
### 4.6 设置管理F-010, F-011
**功能**: API Key 配置 + 刷新间隔设置。
**接口设计**:
```typescript
// src/stores/settings.ts
interface SettingsStore {
apiKey: string;
refreshInterval: 5 | 10 | 15 | 30 | 60; // 分钟
enabledPlatforms: Record<Platform, boolean>;
displayCount: number;
setApiKey: (key: string) => void;
setRefreshInterval: (minutes: number) => void;
togglePlatform: (platform: Platform) => void;
setDisplayCount: (count: number) => void;
}
```
**实现要点**:
<!-- MODIFIED: 与 4.2 保持一致,明确 API Key 存储为服务端内存变量M-002 -->
- API Key 通过 `/api/settings` POST 接口保存到服务端内存变量(非 localStorage读取优先级见 4.2 节
- 刷新间隔变更后,立即更新 TanStack Query 的 refetchInterval
- 设置页 UI 使用 shadcn/ui 的 Input、Select、Switch 组件
---
## 5. 项目目录结构
```
src/
├── app/
│ ├── layout.tsx # 全局布局 (Header + Main)
│ ├── page.tsx # 首页 (ContentGrid + Toolbar)
<!-- MODIFIED: 路由补充 platform 参数M-001 -->
│ ├── detail/[platform]/[id]/page.tsx # 详情页
│ ├── favorites/page.tsx # 收藏夹页面
│ ├── settings/page.tsx # 设置页面
│ └── api/
│ ├── tikhub/
│ │ └── [platform]/
│ │ ├── route.ts # 热榜内容代理
│ │ └── detail/route.ts # 内容详情代理
│ ├── settings/route.ts # 设置保存接口
│ └── stats/route.ts # API 调用统计
├── components/
│ ├── layout/
│ │ ├── Header.tsx # 顶部导航
│ │ ├── PlatformTabs.tsx # 平台 Tab 栏
│ │ └── SortToolbar.tsx # 排序 + 刷新工具栏
│ ├── card/
│ │ ├── ContentCard.tsx # 内容卡片
│ │ ├── ContentGrid.tsx # 卡片网格容器
│ │ └── CardSkeleton.tsx # 加载骨架屏
│ ├── detail/
│ │ └── DetailPanel.tsx # 详情信息面板
│ ├── common/
│ │ ├── EmptyState.tsx # 空状态组件
│ │ ├── ErrorState.tsx # 错误状态组件
│ │ └── FavoriteButton.tsx # 收藏按钮
│ └── ui/ # shadcn/ui 组件
├── lib/
│ ├── tikhub.ts # TikHub HTTP 客户端
│ ├── rate-limiter.ts # 请求频率限制
│ ├── adapters/
│ │ ├── index.ts # 适配器注册表
│ │ ├── douyin.ts # 抖音适配器
│ │ ├── tiktok.ts # TikTok 适配器
│ │ └── xiaohongshu.ts # 小红书适配器
│ ├── platforms.ts # 平台配置
│ └── utils.ts # 工具函数
├── hooks/
│ ├── useContentQuery.ts # 内容查询 hook
│ └── useDetailQuery.ts # 详情查询 hook
├── stores/
│ ├── favorites.ts # 收藏 store
│ └── settings.ts # 设置 store
└── types/
└── content.ts # 类型定义
```
---
## 6. 风险管理
| 风险 | 可能性 | 影响 | 应对措施 |
|------|--------|------|----------|
| TikHub API 端点变更 | 中 | 高 | 适配器模式隔离变更,仅需修改对应适配器文件 |
| TikHub API 响应格式变化 | 中 | 高 | 字段映射做容错处理,缺失字段使用默认值 |
<!-- MODIFIED: 统一并发策略描述,移除"串行请求"矛盾说法S-001 -->
| API 频率限制触发 (10 req/s) | 高 | 中 | 实现 rate-limiter 请求排队机制,确保并发请求不超 10 req/sMVP 仅 3 平台,并发风险低 |
| API 成本失控 | 低 | 中 | 默认 30 分钟刷新间隔页面不可见暂停刷新F-017 成本监控 |
| localStorage 容量限制 (5MB) | 低 | 低 | 收藏数据量预估较小(数百条 ContentItem ≈ 几百 KB |
| 跨域图片加载失败 | 高 | 中 | 使用 Next.js Image 组件配置 remotePatterns加载失败显示占位图 |
| P1/P2 平台 API 端点不确定 | 高 | 低 | MVP 不涉及P1 阶段前在 TikHub 控制台确认最新端点 |
---
## 7. 里程碑
```
M1 M2 M3 M4
│ │ │ │
▼ ▼ ▼ ▼
◆─────────────────◆─────────────────◆─────────────────◆
│ │ │ │
Phase 1 完成 Phase 2 完成 Phase 3 完成 MVP 发布
数据链路打通 核心浏览体验 全功能可用 验收通过
```
| 里程碑 | 目标 | 交付物 | 验收标准 |
|--------|------|--------|----------|
| M1 — 数据链路打通 | API 代理层 + 3 平台适配器可用 | T-001 ~ T-009 | `GET /api/tikhub/douyin` 返回标准化 JSON |
<!-- MODIFIED: T-018 合并到 T-017范围调整S-003 -->
| M2 — 核心浏览体验 | 首页可浏览、可切换、可排序 | T-010 ~ T-017 | 首页展示卡片网格Tab 切换 + 排序 + 详情页 + 刷新正常 |
| M3 — 全功能可用 | 收藏 + 设置 + 错误处理 | T-019 ~ T-024 | 收藏功能可用,设置页可配置 Key 和刷新间隔 |
| M4 — MVP 发布 | 全链路验收通过 | T-025 | 通过 PRD 第8节全部 8 条 MVP 验收标准 |
---
## 8. 任务与功能映射
| 功能ID | 功能名 | 实现任务 |
|--------|--------|----------|
| F-001 | 内容获取 | T-010 |
| F-002 | 卡片信息流展示 | T-011, T-012 |
| F-003 | 内容筛选与排序 | T-013, T-014 |
| F-004 | 内容详情页 | T-015 |
<!-- MODIFIED: T-017 合并了 T-018 的刷新时间展示补充映射S-003 -->
| F-005 | 自动定时刷新 | T-016, T-017 |
| F-006 | 手动刷新 | T-017 |
| F-007 | 内容收藏 | T-019, T-020 |
| F-008 | 收藏夹管理 | T-021 |
| F-009 | 收藏数据持久化 | T-019 |
| F-010 | API Key 配置 | T-022 |
| F-011 | 刷新间隔设置 | T-022 |
| F-014 | API 请求代理 | T-003, T-004 |
| F-015 | 统一数据模型 | T-002 |
| F-016 | 平台适配器 | T-005, T-006, T-007 |
> F-012平台管理、F-013展示数量设置、F-017API 调用量统计)为 v1.1/v2.0 功能,不在 MVP 任务中。
---
## 9. 资源需求
| 角色 | 人数 | 职责 | 参与阶段 |
|------|------|------|----------|
| 全栈开发 | 1 | 前后端全部实现 | Phase 1-3 |
> 本项目为个人项目,由单人全栈完成。

497
doc/FeatureSummary.md Normal file
View File

@ -0,0 +1,497 @@
# Muse Creative Hotspots — 功能摘要
## 文档信息
| 项目 | 内容 |
|------|------|
| 版本 | v1.0 |
| 创建日期 | 2026-03-02 |
| 来源文档 | PRD.md |
## 1. 功能总览
### 1.1 功能统计
| 类别 | 数量 |
|------|------|
| 功能模块 | 5 个 |
<!-- MODIFIED: 原内容为 "P0: 10, P1: 5, P2: 1, 总计: 16"。F-007~F-009 升 P0F-011 升 P0新增 F-017(P1) -->
| P0 功能 | 14 个 |
| P1 功能 | 2 个 |
| P2 功能 | 1 个 |
| **功能总计** | **17 个** |
### 1.2 功能架构图
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Muse Creative Hotspots秒思创意热点
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ A. 热点内容聚合 │ │ B. 数据刷新管理 │ │ C. 收藏/书签系统 │ │
│ │ ──────────────── │ │ ──────────────── │ │ ──────────────── │ │
│ │ • F-001 内容获取 │ │ • F-005 自动刷新 │ │ • F-007 内容收藏 │ │
│ │ • F-002 卡片展示 │ │ • F-006 手动刷新 │ │ • F-008 收藏管理 │ │
│ │ • F-003 筛选排序 │ │ │ │ • F-009 数据持久化 │ │
│ │ • F-004 详情页 │ │ │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ D. 设置管理 │ │ E. API 代理层 │ │
│ │ ──────────────── │ │ ──────────────── │ │
│ │ • F-010 Key配置 │ │ • F-014 请求代理 │ │
│ │ • F-011 刷新间隔 │ │ • F-015 数据模型 │ │
│ │ • F-012 平台管理 │ │ • F-016 平台适配 │ │
│ │ • F-013 数量设置 │ │ │ │
│ │ • F-017 调用统计 │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 1.3 模块依赖关系
```
┌──────────────────┐
│ E. API 代理层 │ ◀─── 所有数据请求的底层基础
│ F-014/F-015/F-016│
└───────┬──────────┘
│ 被依赖
┌──────────────────┐ ┌──────────────────┐
│ A. 热点内容聚合 │ ◀──── │ B. 数据刷新管理 │
│ F-001~F-004 │ │ F-005/F-006 │
└───────┬──────────┘ └──────────────────┘
│ 被依赖 ▲
▼ │ 读取配置
┌──────────────────┐ ┌──────────────────┐
│ C. 收藏/书签系统 │ │ D. 设置管理 │
│ F-007~F-009 │ │ F-010~F-013 │
└──────────────────┘ └──────────────────┘
│ 提供 API Key
┌──────────────────┐
│ E. API 代理层 │
└──────────────────┘
```
**依赖说明:**
- 模块 E 是基础设施层,模块 A/B 依赖其提供数据
- 模块 B 触发模块 A 的数据重新获取
- 模块 C 依赖模块 A 提供可收藏的内容
- 模块 D 为模块 B刷新间隔和模块 EAPI Key提供配置
---
## 2. 功能清单
### 2.1 模块 A — 热点内容聚合浏览
**模块职责**: 从各平台获取热点内容并以卡片信息流形式展示,支持筛选、排序和详情查看。
#### 功能列表
| ID | 功能 | 描述 | 优先级 | 关联场景 |
|----|------|------|--------|----------|
| F-001 | 内容获取 | 获取各平台官方热榜/推荐内容 | P0 | US-001/002/003 |
| F-002 | 卡片信息流展示 | 瀑布流/网格卡片布局展示内容 | P0 | US-001/004 |
| F-003 | 内容筛选与排序 | 按平台切换、按数据指标排序 | P0 | US-002/003 |
| F-004 | 内容详情页 | 站内详情页展示完整内容信息 | P0 | US-001/003/004 |
#### 功能契约详情
**F-001: 内容获取**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 1. 页面首次加载2. 自动定时刷新触发3. 手动点击刷新按钮4. 切换平台 Tab |
| **输入** | 平台标识platform、展示数量count默认 20-50、API Key从设置读取 |
| **处理逻辑** | 1. 通过 API 代理层调用 TikHub 对应平台端点2. 适配器将原始数据转换为统一 ContentItem3. 缓存结果供前端展示 |
| **输出** | ContentItem[] 数组,包含 title、cover_url、author_name、play_count、like_count 等标准字段 |
| **异常情况** | API Key 无效 → 提示配置;请求超时 → 显示重试按钮频率超限10 req/s→ 排队等待;平台未启用 → 跳过 |
| **边界说明** | 不包含内容的全文/完整视频获取;不做内容缓存持久化(刷新后替换);不支持分页加载更多 |
**F-002: 卡片信息流展示**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 内容数据加载完成F-001 返回结果) |
| **输入** | ContentItem[] 数组 |
| **处理逻辑** | 1. 按瀑布流/网格布局渲染卡片2. 每张卡片展示:封面图/缩略图、标题(截断)、平台图标+名称、关键数据(播放量/点赞/评论/分享)、发布时间、作者头像+昵称3. 支持 hover 预览更多信息 |
| **输出** | 可视化的卡片网格界面 |
| **异常情况** | 封面图加载失败 → 显示占位图;数据为空 → 显示空状态提示 |
| **边界说明** | 不包含视频内联播放;不包含无限滚动(展示固定 Top N卡片内标题截断展示完整内容在详情页 |
**F-003: 内容筛选与排序**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 用户点击平台 Tab 切换或选择排序方式 |
| **输入** | 筛选条件platform平台标识 / "all"排序条件sortByplay_count / like_count / comment_count / publish_time+ sortOrderasc / desc |
| **处理逻辑** | 1. 按平台筛选:切换 Tab 时过滤或重新请求对应平台数据2. 按指标排序前端对当前列表重新排序3. "全部"视图聚合所有已启用平台的内容 |
| **输出** | 重新排列后的 ContentItem[] → 更新卡片信息流 |
| **异常情况** | 某平台数据为空 → Tab 上标注"暂无数据";排序字段缺失 → 该条目排到末尾 |
| **边界说明** | 不包含关键词搜索功能;不包含多条件组合筛选;排序为前端内存排序,不重新请求 API |
**F-004: 内容详情页**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 用户点击内容卡片 |
| **输入** | ContentItem 的完整数据(或 contentId + platform 用于请求详情 API |
| **处理逻辑** | 1. 展示完整内容信息标题、描述、标签2. 展示完整数据指标面板(播放/点赞/评论/分享3. 展示作者信息(头像+昵称4. 提供"查看原文"按钮跳转原平台页面5. 提供收藏按钮 |
| **输出** | 站内详情页面 |
| **异常情况** | 详情数据加载失败 → 显示错误提示 + 重试按钮;原文链接失效 → 提示"原文可能已被删除" |
<!-- MODIFIED: 明确 video_url 的展示方式 -->
| **边界说明** | 不包含站内视频播放器(视频内容通过"查看原文"按钮跳转原平台播放video_url 不直接嵌入播放);不包含评论区展示;不包含相关推荐 |
---
### 2.2 模块 B — 数据刷新管理
**模块职责**: 管理内容数据的自动定时刷新和手动触发刷新机制。
#### 功能列表
| ID | 功能 | 描述 | 优先级 | 关联场景 |
|----|------|------|--------|----------|
| F-005 | 自动定时刷新 | 按设定间隔自动获取最新内容 | P0 | US-001/002 |
| F-006 | 手动刷新 | 用户点击按钮立即刷新内容 | P0 | US-001/002 |
#### 功能契约详情
**F-005: 自动定时刷新**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 页面加载后自动启动定时器 |
| **输入** | 刷新间隔(从设置读取,默认 30 分钟) |
| **处理逻辑** | 1. 启动定时器setInterval / TanStack Query refetchInterval2. 到达间隔时间后自动调用 F-001 重新获取所有已启用平台的内容3. 刷新时更新"上次刷新时间"显示 |
| **输出** | 更新后的内容列表 + 更新刷新时间戳 |
| **异常情况** | 刷新失败 → 保留上一次数据,显示刷新失败提示;页面后台(不可见)→ 暂停刷新节省 API 调用 |
| **边界说明** | 不包含增量更新(每次全量替换);不包含推送通知新内容;最小间隔限制 5 分钟(防止 API 滥用) |
**F-006: 手动刷新**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 用户点击工具栏刷新按钮 🔄 |
| **输入** | 当前选中平台(或"全部" |
| **处理逻辑** | 1. 触发 F-001 重新获取内容2. 刷新按钮显示 loading 状态3. 完成后重置自动刷新计时器4. 更新"上次刷新时间" |
| **输出** | 更新后的内容列表 + loading 状态反馈 |
| **异常情况** | 连续快速点击 → 防抖处理2 秒内忽略重复点击);刷新中再次点击 → 忽略 |
<!-- MODIFIED: 标注单平台刷新为设计决策而非 PRD 约束 -->
| **边界说明** | 【设计决策】不包含单平台独立刷新刷新所有已启用平台PRD 未明确此约束,后续可按需调整);手动刷新会重置自动刷新倒计时 |
---
### 2.3 模块 C — 收藏/书签系统
**模块职责**: 允许用户收藏感兴趣的内容,构建个人灵感库。
#### 功能列表
| ID | 功能 | 描述 | 优先级 | 关联场景 |
|----|------|------|--------|----------|
<!-- MODIFIED: 原内容为 "P1"PRD MVP 验收标准要求"收藏功能可用",升级为 P0 以匹配 MVP 纳入 -->
| F-007 | 内容收藏 | 将任意内容卡片添加到收藏夹 | P0 | US-001/004 |
| F-008 | 收藏夹管理 | 独立页面查看和管理收藏内容 | P0 | US-004 |
| F-009 | 收藏数据持久化 | 收藏数据本地持久化存储 | P0 | US-004 |
#### 功能契约详情
**F-007: 内容收藏**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 用户在卡片上或详情页中点击收藏按钮 |
| **输入** | ContentItem 完整数据 |
| **处理逻辑** | 1. 检查是否已收藏防重复2. 已收藏 → 取消收藏;未收藏 → 添加到收藏列表3. 更新收藏按钮状态(实心/空心4. 触发 F-009 持久化存储 |
| **输出** | 收藏状态变更 + 视觉反馈(按钮状态切换) |
| **异常情况** | 存储空间不足 → 提示清理旧收藏 |
| **边界说明** | 不包含收藏分类/文件夹功能;不包含收藏备注;不包含收藏分享 |
**F-008: 收藏夹管理**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 用户点击导航中的"收藏"入口 |
| **输入** | 本地存储的收藏列表 |
| **处理逻辑** | 1. 从本地存储读取收藏列表2. 以卡片网格形式展示所有收藏内容3. 支持取消收藏删除4. 点击卡片可跳转详情页 |
| **输出** | 收藏内容列表页面 |
| **异常情况** | 收藏为空 → 显示空状态引导 |
| **边界说明** | 不包含收藏搜索不包含收藏排序不包含收藏导出MVP 阶段不支持云同步 |
**F-009: 收藏数据持久化**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 收藏列表发生变更(添加/删除) |
| **输入** | 完整的收藏列表数据 |
| **处理逻辑** | 1. 使用 Zustand persist 中间件2. 将收藏数据序列化存储到 localStorage3. 页面加载时自动恢复收藏状态 |
| **输出** | 持久化的收藏数据 |
| **异常情况** | localStorage 不可用 → 降级为内存存储(关闭页面丢失);数据损坏 → 重置收藏列表并提示 |
| **边界说明** | MVP 仅支持 localStorage不包含 IndexedDB不包含数据库后端存储不包含跨设备同步 |
---
### 2.4 模块 D — 设置管理
**模块职责**: 提供应用配置界面,允许用户自定义 API 认证、刷新策略和平台偏好。
#### 功能列表
| ID | 功能 | 描述 | 优先级 | 关联场景 |
|----|------|------|--------|----------|
| F-010 | API Key 配置 | 配置 TikHub API Key | P0 | - |
<!-- MODIFIED: 原内容为 "P1",根据 PRD 第8节 MVP 验收标准"设置页可配置 API Key 和刷新间隔"升级为 P0 -->
| F-011 | 刷新间隔设置 | 自定义自动刷新频率 | P0 | US-001 |
| F-012 | 平台管理 | 启用/禁用各平台 | P1 | US-002 |
| F-013 | 展示数量设置 | 配置每平台默认展示数量 | P2 | US-003 |
<!-- NEW START -->
| F-017 | API 调用量统计 | 展示当日 API 调用次数及成本估算 | P1 | - |
<!-- NEW END -->
#### 功能契约详情
**F-010: API Key 配置**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 用户进入设置页面 |
| **输入** | 用户输入的 TikHub API Key 字符串 |
| **处理逻辑** | 1. 提供文本输入框供用户粘贴 API Key2. 保存时验证 Key 格式非空检查3. 存储到本地(加密或 env4. API 代理层读取此 Key 发起请求 |
| **输出** | API Key 保存成功/失败提示 |
<!-- MODIFIED: 原异常情况"Key 无效API 返回 401"与边界"保存时不测试连通性"矛盾,明确 401 在请求内容时触发 -->
| **异常情况** | Key 为空 → 提示必填Key 无效 → 首次请求内容时 API 返回 401引导用户检查 Key 配置 |
| **边界说明** | 保存时仅做非空校验,不测试 API 连通性(无效 Key 在实际请求时暴露);不包含多 Key 轮换Key 仅存储于服务端环境变量或加密本地存储 |
**F-011: 刷新间隔设置**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 用户在设置页面调整刷新间隔 |
| **输入** | 刷新间隔值分钟可选范围5 / 10 / 15 / 30 / 60 |
| **处理逻辑** | 1. 提供下拉选择或滑块控件2. 保存后立即更新 F-005 的定时器间隔3. 持久化存储设置 |
| **输出** | 新的刷新间隔生效 |
| **异常情况** | 无特殊异常 |
| **边界说明** | 最小 5 分钟API 成本控制);不支持自定义任意分钟数 |
**F-012: 平台管理**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 用户在设置页面切换平台开关 |
| **输入** | 平台标识 + 启用/禁用状态 |
| **处理逻辑** | 1. 展示所有支持的平台列表每个平台配有开关2. 切换开关后持久化设置3. 禁用的平台不再出现在首页 Tab 栏中4. 禁用平台的数据不再自动刷新 |
| **输出** | 平台启用状态更新 |
| **异常情况** | 至少保留一个平台启用 → 否则提示 |
| **边界说明** | 不包含平台顺序自定义;不包含自定义添加新平台 |
**F-013: 展示数量设置**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 用户在设置页面调整展示数量 |
| **输入** | 每平台展示数量(默认 20可选 10 / 20 / 30 / 50 |
| **处理逻辑** | 1. 提供数量选择控件2. 保存后下次刷新时按新数量请求3. 影响 F-001 的请求参数 |
| **输出** | 新的展示数量配置生效 |
| **异常情况** | 无特殊异常 |
| **边界说明** | 不支持每个平台独立设置不同数量(全局统一);更改后需等待下次刷新才生效 |
<!-- NEW START -->
**F-017: API 调用量统计**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 用户进入设置页面或首页工具栏查看 |
| **输入** | API 代理层的请求计数器数据 |
| **处理逻辑** | 1. API 代理层F-014每次转发请求时累加计数器2. 按日期维度统计调用次数3. 根据 $0.001/请求 计算估算成本4. 在设置页面或工具栏展示"今日调用: N 次(≈$X.XX" |
| **输出** | 当日 API 调用次数 + 成本估算显示 |
| **异常情况** | 计数器数据丢失(页面刷新)→ 重新从 0 计数并提示"本次会话统计" |
| **边界说明** | 仅统计当前会话/当日数据,不做历史统计;计数存储于内存或 localStorage成本为估算值不保证与实际账单一致 |
<!-- NEW END -->
---
### 2.5 模块 E — API 代理层
**模块职责**: 通过 Next.js API Routes 代理 TikHub 请求,隐藏 API Key 并统一数据格式。
#### 功能列表
| ID | 功能 | 描述 | 优先级 | 关联场景 |
|----|------|------|--------|----------|
| F-014 | API 请求代理 | 通过服务端代理 TikHub API 请求 | P0 | - |
| F-015 | 统一数据模型 | 所有平台数据映射为 ContentItem | P0 | - |
| F-016 | 平台适配器 | 各平台 API 调用与数据格式转换 | P0 | - |
#### 功能契约详情
**F-014: API 请求代理**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 前端发起内容获取请求 |
| **输入** | 平台标识、请求类型(热榜/详情)、请求参数 |
| **处理逻辑** | 1. Next.js API Route 接收前端请求2. 从环境变量/设置读取 API Key3. 构建 TikHub API 请求Bearer Token 认证4. 转发请求并返回结果5. 遵守 10 req/s 频率限制 |
| **输出** | TikHub API 原始响应数据 |
| **异常情况** | API Key 未配置 → 返回 401 + 提示配置TikHub 返回错误 → 透传错误信息;请求超时 → 返回超时错误;频率超限 → 排队或返回 429 |
| **边界说明** | 不包含响应缓存(由 TanStack Query 在前端处理);不包含请求日志记录;代理层仅做转发,不做业务逻辑 |
**F-015: 统一数据模型**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | 适配器处理 API 响应时 |
| **输入** | 各平台原始 API 响应数据 |
| **处理逻辑** | 定义统一 ContentItem 类型包含title、cover_url、video_url、author_name、author_avatar、play_count、like_count、comment_count、share_count、publish_time、platform、original_url、tags |
| **输出** | 标准化的 ContentItem 对象 |
| **异常情况** | 必填字段缺失 → 使用默认值(如"未知作者");数据类型不匹配 → 类型转换或置空 |
| **边界说明** | ContentItem 为只读展示模型,不包含编辑能力;字段定义以 PRD 4.3 为准 |
**F-016: 平台适配器**
| 契约项 | 说明 |
|--------|------|
| **触发条件** | F-001 调用内容获取时 |
| **输入** | 平台标识 + API 原始响应 |
| **处理逻辑** | 1. 根据平台标识选择对应适配器2. 适配器调用平台特定的 TikHub API 端点3. 将原始响应字段映射为 ContentItem参照 PRD 中各平台的字段映射规则4. MVP 实现抖音/TikTok/小红书三个适配器 |
| **输出** | ContentItem[] 标准化数组 |
| **异常情况** | 平台无适配器 → 返回空数组 + 日志警告API 端点变更 → 适配器更新 |
| **边界说明** | 每个适配器独立实现,互不影响;新增平台只需新增适配器,无需修改已有代码 |
---
## 3. 功能依赖矩阵
<!-- MODIFIED: 修正 F-002/F-003 依赖方向;新增 F-017 行列 -->
| 功能 | F-001 | F-002 | F-003 | F-004 | F-005 | F-006 | F-007 | F-008 | F-009 | F-010 | F-011 | F-012 | F-013 | F-014 | F-015 | F-016 | F-017 |
|------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|
| **F-001** 内容获取 | - | | | | | | | | | | | ✓ | ✓ | ✓ | ✓ | ✓ | |
| **F-002** 卡片展示 | ✓ | - | ✓ | | | | | | | | | | | | | | |
| **F-003** 筛选排序 | ✓ | | - | | | | | | | | | | | | | | |
| **F-004** 详情页 | ✓ | | | - | | | | | | | | | | ✓ | ✓ | ✓ | |
| **F-005** 自动刷新 | ✓ | | | | - | | | | | | ✓ | | | | | | |
| **F-006** 手动刷新 | ✓ | | | | | - | | | | | | | | | | | |
| **F-007** 内容收藏 | ✓ | | | | | | - | | ✓ | | | | | | | | |
| **F-008** 收藏管理 | | | | | | | | - | ✓ | | | | | | | | |
| **F-009** 数据持久化 | | | | | | | | | - | | | | | | | | |
| **F-010** Key配置 | | | | | | | | | | - | | | | | | | |
| **F-011** 刷新间隔 | | | | | | | | | | | - | | | | | | |
| **F-012** 平台管理 | | | | | | | | | | | | - | | | | | |
| **F-013** 数量设置 | | | | | | | | | | | | | - | | | | |
| **F-014** 请求代理 | | | | | | | | | | ✓ | | | | - | | | |
| **F-015** 数据模型 | | | | | | | | | | | | | | | - | | |
| **F-016** 平台适配 | | | | | | | | | | | | | | ✓ | ✓ | - | |
| **F-017** 调用统计 | | | | | | | | | | | | | | ✓ | | | - |
说明:
- ✓ 表示**行功能**依赖**列功能**
- 空白表示无依赖
- `-` 表示自身
---
## 4. 功能流程图
### 4.1 核心流程:内容浏览主流程
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ F-010 │ ──▶ │ F-014 │ ──▶ │ F-016 │ ──▶ │ F-015 │ ──▶ │ F-001 │
│ Key配置 │ │ 请求代理 │ │ 平台适配 │ │ 数据模型 │ │ 内容获取 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └─────┬────┘
┌───────────────────────────────────────────────────┘
┌──────────┐ ┌──────────┐ ┌──────────┐
│ F-002 │ ──▶ │ F-003 │ ──▶ │ F-004 │
│ 卡片展示 │ │ 筛选排序 │ │ 详情页 │
└──────────┘ └──────────┘ └──────────┘
```
### 4.2 数据刷新流程
```
┌──────────────────────────────────────────────────────────┐
│ 刷新触发 │
│ ┌──────────┐ ┌──────────┐ │
│ │ F-005 │ │ F-006 │ │
│ │ 自动刷新 │ │ 手动刷新 │ │
│ │ (定时器) │ │ (按钮) │ │
│ └────┬─────┘ └────┬─────┘ │
│ │ │ │
│ └──────────┬───────────┘ │
│ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ F-001 │ ──▶ │ F-002 │ │
│ │ 内容获取 │ │ 更新展示 │ │
│ └────┬─────┘ └──────────┘ │
│ │ │
│ ▼ 失败 │
│ ┌────────────┐ │
│ │ 保留旧数据 │ │
│ │ 显示错误提示│ │
│ └────────────┘ │
└──────────────────────────────────────────────────────────┘
```
### 4.3 收藏流程
```
┌──────────┐ ┌──────────┐ ┌──────────┐
│ F-002 │ ──▶ │ F-007 │ ──▶ │ F-009 │
│ 卡片展示 │ │ 点击收藏 │ │ 持久化 │
└──────────┘ └──────────┘ └──────────┘
或 │
┌──────────┐ ▼
│ F-004 │ ──▶ (同上) ┌──────────┐
│ 详情页 │ │ F-008 │
└──────────┘ │ 收藏管理 │
└──────────┘
```
---
## 5. 版本规划
| 版本 | 包含功能 | 功能ID | 目标 |
|------|----------|--------|------|
<!-- MODIFIED: MVP 纳入 F-011v1.1 纳入 F-017v2.0 暗色模式无功能 ID 故移除描述 -->
| **MVP** | 3平台内容获取、卡片展示、筛选排序、详情页、自动/手动刷新、收藏系统、API Key配置、刷新间隔设置、API代理层 | F-001 ~ F-011, F-014 ~ F-016 | 跑通核心链路:抖音+TikTok+小红书热点聚合浏览 |
| **v1.1** | 平台管理、API调用量统计、新增5个P1平台适配器 | F-012, F-017 + P1平台适配 | 扩展至8个平台完善运维功能 |
| **v2.0** | 展示数量设置、剩余9个P2平台适配器 | F-013 + P2平台适配 | 全平台覆盖17个平台 |
---
## 6. 接口契约预览
> 详细接口定义在 DevelopmentPlan 中,此处仅列出关键接口
| 功能 | 接口类型 | 简要说明 |
|------|----------|----------|
| F-001 | API (GET) | `GET /api/tikhub/[platform]` — 获取指定平台热榜内容 |
| F-004 | API (GET) | `GET /api/tikhub/[platform]/detail?id=xxx` — 获取内容详情 |
| F-005 | Event | TanStack Query `refetchInterval` 定时触发 |
| F-006 | Event | 用户点击 → `queryClient.invalidateQueries()` |
| F-007 | Store | Zustand `useFavoritesStore.addFavorite(item)` |
| F-009 | Storage | Zustand persist → localStorage |
| F-010 | API (POST) | `POST /api/settings` — 保存 API Key |
| F-014 | API (Proxy) | Next.js API Route → TikHub APIBearer Token |
| F-016 | Module | `PlatformAdapter.fetchTrending(platform)``ContentItem[]` |
<!-- NEW START -->
| F-017 | API (GET) | `GET /api/stats` — 获取当日 API 调用次数及成本估算 |
<!-- NEW END -->
---
## 附录:用户场景映射
| ID | 场景 | 描述 | 关联功能 |
|----|------|------|----------|
| US-001 | 创意灵感获取 | 每天浏览各平台热点趋势,发现创意表达和内容形式 | F-001~F-006 |
| US-002 | 竞品/行业监控 | 跟踪特定领域在各平台的热门内容表现 | F-001, F-003, F-005, F-006, F-012 |
| US-003 | 数据分析研究 | 对比同类内容在不同平台的数据差异 | F-001, F-003, F-004, F-013 |
| US-004 | 内容搬运/分发 | 发现优质内容后进行跨平台二次创作 | F-002, F-004, F-007, F-008 |

350
doc/PRD.md Normal file
View File

@ -0,0 +1,350 @@
# Muse Creative Hotspots — 产品需求文档 (PRD)
## 1. 产品概述
### 1.1 产品名称
Muse Creative Hotspots秒思创意热点
### 1.2 产品定位
面向个人创意工作者的**全平台热点内容聚合浏览器**,一站式查看 17 个主流社交媒体平台的热门内容及数据表现。
### 1.3 目标用户
个人独立使用者(创意工作者/内容创作者/自媒体从业者)
### 1.4 核心价值
- **效率提升**:告别逐个打开 17 个平台 APP/网站的低效模式
- **全局视野**:跨平台热点趋势一目了然
- **数据洞察**:热门内容的关键数据指标集中展示、可排序对比
- **灵感沉淀**:收藏感兴趣的内容,构建个人灵感库
### 1.5 核心使用场景
| 场景 | 描述 |
|------|------|
| 创意灵感获取 | 每天浏览各平台热点趋势,发现创意表达和内容形式 |
| 竞品/行业监控 | 跟踪特定领域在各平台的热门内容表现 |
| 数据分析研究 | 对比同类内容在不同平台的数据差异 |
| 内容搬运/分发 | 发现优质内容后进行跨平台二次创作 |
---
## 2. 平台覆盖范围
### 2.1 支持平台清单(共 17 个)
| 分类 | 平台 | 内容类型 | 优先级 |
|------|------|----------|--------|
| 国内短视频 | 抖音 | 短视频 | **MVP** |
| 国际短视频 | TikTok | 短视频 | **MVP** |
| 国内图文 | 小红书 | 图文+短视频 | **MVP** |
| 国际视频 | YouTube | 中长视频 | P1 |
| 国际图文 | Instagram | 图文+Reels | P1 |
| 国际社交 | Twitter/X | 文字+图文 | P1 |
| 国内视频 | 哔哩哔哩 | 中长视频 | P1 |
| 国内社交 | 微博 | 文字+图文+视频 | P1 |
| 国际社交 | Threads | 文字+图文 | P2 |
| 国际社交 | Reddit | 文字+图文+视频 | P2 |
| 国际职场 | 领英 (LinkedIn) | 文字+图文 | P2 |
| 国内问答 | 知乎 | 文字+图文 | P2 |
| 国际图文 | Lemon8 | 图文 | P2 |
| 国内短视频 | 快手 | 短视频 | P2 |
| 国内社交 | 微信 (公众号+视频号) | 图文+短视频 | P2 |
| 国内趣味 | 皮皮虾 | 短视频+图文 | P2 |
| AI 视频 | Sora | AI 生成视频 | P2 |
### 2.2 开发节奏
- **MVP 阶段**:抖音 + TikTok + 小红书3 个平台)
- **P1 阶段**YouTube + Instagram + Twitter/X + 哔哩哔哩 + 微博5 个平台)
- **P2 阶段**:其余 9 个平台
---
## 3. 功能需求
### 3.1 核心功能:热点内容聚合浏览
#### 3.1.1 内容获取
- 获取各平台**官方热榜/推荐内容**(如抖音热榜、微博热搜等)
- 每个平台展示热榜 Top N 条内容(默认 20-50 条)
- 数据刷新策略:**自动定时刷新(默认 30 分钟)+ 手动刷新按钮**
#### 3.1.2 内容展示 — 卡片信息流
- 采用**瀑布流/网格卡片**布局(类似小红书/Pinterest
- 每张卡片包含:
- 封面图/视频缩略图
- 内容标题/描述(截断展示)
- 来源平台标识(图标+名称)
- 关键数据指标(播放量/浏览量、点赞数、评论数、分享数)
- 发布时间
- 作者头像+昵称
- 卡片支持 hover 预览更多信息
#### 3.1.3 内容筛选与排序
- **按平台切换**:顶部 Tab 栏或侧边导航切换不同平台,支持"全部"聚合视图
- **按数据指标排序**:按播放量/浏览量、点赞数、评论数、发布时间等排序
- 支持切换排序方向(升序/降序)
#### 3.1.4 内容详情页
- 点击卡片进入**站内详情页**,展示:
- 完整的内容信息(标题、描述、标签等)
- 完整的数据指标面板
- 作者信息
- "查看原文"按钮(跳转原平台页面)
- 收藏按钮
### 3.2 辅助功能
#### 3.2.1 收藏/书签
- 支持将任意内容卡片添加到收藏夹
- 收藏夹独立页面查看
- 收藏数据持久化存储(本地存储/后端数据库)
#### 3.2.2 设置页面
- TikHub API Key 配置
- 自动刷新间隔设置
- 每个平台的启用/禁用开关
- 每个平台默认展示数量设置
---
## 4. 数据需求
### 4.1 数据源
- **API 提供商**TikHub (https://www.tikhub.io)
- **认证方式**Bearer Token已有 API Key
- **调用限制**10 请求/秒
- **计费方式**$0.001/请求
### 4.2 各平台核心 API 端点
#### MVP 平台
##### 抖音
| 功能 | 端点 |
|------|------|
| 热搜榜 | `/api/v1/douyin/web/fetch_hot_search_result` |
| 内容详情 | `/api/v1/douyin/web/fetch_one_video` |
| 用户信息 | `/api/v1/douyin/web/fetch_user_profile` |
##### TikTok
| 功能 | 端点 |
|------|------|
| 趋势内容 | `/api/v1/tiktok/web/fetch_trending_post` |
| 探索内容 | `/api/v1/tiktok/web/fetch_explore_post` |
| 内容详情 | `/api/v1/tiktok/web/fetch_post_detail` |
##### 小红书
| 功能 | 端点 |
|------|------|
| 推荐内容 | `/api/v1/xiaohongshu/app/v2/fetch_feed` (App V2 API) |
| 内容详情 | `/api/v1/xiaohongshu/app/v2/fetch_note_detail` |
| 用户信息 | `/api/v1/xiaohongshu/app/v2/fetch_user_info` |
#### P1 平台
> 以下端点来自 TikHub API 文档,实现前建议在 TikHub 控制台确认最新版本。
##### YouTube
| 功能 | 端点 |
|------|------|
| 趋势视频 | `/api/v1/youtube/web/fetch_trending_video` |
| 视频详情 | `/api/v1/youtube/web/fetch_video_detail` |
**字段映射**: `videoId``id`, `title``title`, `thumbnails.high.url``cover_url`, `contentDetails.videoId``video_url` (需拼接播放页 URL), `statistics.viewCount``play_count`, `statistics.likeCount``like_count`, `statistics.commentCount``comment_count`, `snippet.publishedAt``publish_time`
**内容类型**: 中长视频,封面比例 16:9
##### Instagram
| 功能 | 端点 |
|------|------|
| 探索内容 | `/api/v1/instagram/web/fetch_explore_feed` |
| 内容详情 | `/api/v1/instagram/web/fetch_post_detail` |
**字段映射**: `code``id`, `caption.text``title`, `image_versions2.candidates[0].url` / `thumbnail_url``cover_url`, `video_url``video_url` (Reels 有值,图文为 undefined), `user.username``author_name`, `like_count``like_count`, `comment_count``comment_count`, `taken_at``publish_time`
**内容类型**: 图文 (3:4) + Reels (9:16),通过是否有 `video_url` 区分
##### Twitter/X
| 功能 | 端点 |
|------|------|
| 热搜话题 | `/api/v1/twitter/web/fetch_trending_topics` |
| 推文详情 | `/api/v1/twitter/web/fetch_tweet_detail` |
**字段映射**: `id_str``id`, `full_text``title`, `entities.media[0].media_url_https``cover_url` (纯文字推文为 undefined), `user.name``author_name`, `user.profile_image_url_https``author_avatar`, `favorite_count``like_count`, `reply_count``comment_count`, `retweet_count``share_count`, `created_at``publish_time`
**内容类型**: 以文字卡片为主(无封面图时使用文字卡片样式)
##### 哔哩哔哩
| 功能 | 端点 |
|------|------|
| 热门视频 | `/api/v1/bilibili/web/fetch_popular_video_list` |
| 视频详情 | `/api/v1/bilibili/web/fetch_video_detail` |
**字段映射**: `bvid``id`, `title``title`, `pic``cover_url`, `owner.name``author_name`, `owner.face``author_avatar`, `stat.view``play_count`, `stat.like``like_count`, `stat.reply``comment_count`, `stat.share``share_count`, `pubdate` (Unix 时间戳) → `publish_time`
**内容类型**: 中长视频,封面比例 16:9
##### 微博
| 功能 | 端点 |
|------|------|
| 热搜榜 | `/api/v1/weibo/app/fetch_hot_search` |
| 微博详情 | `/api/v1/weibo/app/fetch_post_detail` |
**字段映射**: `id``id`, `text` (去 HTML 标签) → `title`, `pic_ids[0]` 拼接图片 URL → `cover_url` (无图时为 undefined), `user.screen_name``author_name`, `user.avatar_hd``author_avatar`, `attitudes_count``like_count`, `comments_count``comment_count`, `reposts_count``share_count`, `created_at``publish_time`
**内容类型**: 文字+图文,热搜词条无封面图时展示文字卡片
#### P2 平台
> 以下平台的 API 端点待根据 TikHub 最新文档确认,适配器实现时以实际接口为准。
| 平台 | 推测热榜端点 | 推测详情端点 |
|------|-------------|-------------|
| Threads | `/api/v1/threads/web/fetch_trending_post` | `/api/v1/threads/web/fetch_post_detail` |
| Reddit | `/api/v1/reddit/web/fetch_hot_post` | `/api/v1/reddit/web/fetch_post_detail` |
| LinkedIn | `/api/v1/linkedin/web/fetch_trending_post` | `/api/v1/linkedin/web/fetch_post_detail` |
| 知乎 | `/api/v1/zhihu/app/fetch_hot_question` | `/api/v1/zhihu/app/fetch_question_detail` |
| Lemon8 | `/api/v1/lemon8/web/fetch_trending_post` | `/api/v1/lemon8/web/fetch_post_detail` |
| 快手 | `/api/v1/kuaishou/app/fetch_hot_video` | `/api/v1/kuaishou/app/fetch_video_detail` |
| 微信 | `/api/v1/wechat/mp/fetch_hot_article` | `/api/v1/wechat/mp/fetch_article_detail` |
| 皮皮虾 | `/api/v1/pipix/app/fetch_hot_video` | `/api/v1/pipix/app/fetch_video_detail` |
| Sora | `/api/v1/sora/web/fetch_featured_video` | `/api/v1/sora/web/fetch_video_detail` |
### 4.3 每条内容需采集的数据字段
| 字段 | 说明 | 展示位置 |
|------|------|----------|
| title | 标题/描述 | 卡片+详情页 |
| cover_url | 封面图 URL | 卡片 |
| video_url | 视频播放地址 | 详情页 |
| author_name | 作者昵称 | 卡片+详情页 |
| author_avatar | 作者头像 | 卡片+详情页 |
| play_count | 播放量/浏览量 | 卡片+详情页 |
| like_count | 点赞数 | 卡片+详情页 |
| comment_count | 评论数 | 卡片+详情页 |
| share_count | 分享/转发数 | 详情页 |
| publish_time | 发布时间 | 卡片+详情页 |
| platform | 来源平台 | 卡片+详情页 |
| original_url | 原文链接 | 详情页 |
| tags | 标签/话题 | 详情页 |
---
## 5. 技术方案(推荐)
### 5.1 技术栈选择
| 层级 | 技术 | 理由 |
|------|------|------|
| 框架 | **Next.js 14+ (App Router)** | 全栈能力、API Routes 做后端代理、SSR 支持、Vercel 一键部署 |
| UI 库 | **Tailwind CSS + shadcn/ui** | 简约现代风格、组件丰富、高度可定制 |
| 状态管理 | **Zustand** | 轻量、简洁、适合中小型项目 |
| 数据请求 | **TanStack Query (React Query)** | 缓存管理、自动刷新、loading/error 状态 |
| 本地存储 | **localStorage / IndexedDB** | 收藏数据持久化MVP 阶段无需数据库) |
| 包管理器 | **pnpm** | 速度快、磁盘占用小 |
### 5.2 项目架构
```
muse_creative_hotspots/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── layout.tsx # 全局布局
│ │ ├── page.tsx # 首页(热点内容流)
│ │ ├── detail/[id]/ # 内容详情页
│ │ ├── favorites/ # 收藏页
│ │ ├── settings/ # 设置页
│ │ └── api/ # API Routes代理 TikHub
│ │ └── tikhub/
│ │ └── [platform]/route.ts
│ ├── components/ # UI 组件
│ │ ├── layout/ # 布局组件Header, Sidebar, etc.
│ │ ├── card/ # 内容卡片组件
│ │ └── ui/ # shadcn/ui 基础组件
│ ├── lib/ # 工具库
│ │ ├── tikhub.ts # TikHub API 封装
│ │ ├── platforms.ts # 平台配置与适配器
│ │ └── utils.ts # 通用工具函数
│ ├── stores/ # Zustand 状态管理
│ │ ├── favorites.ts # 收藏状态
│ │ └── settings.ts # 设置状态
│ └── types/ # TypeScript 类型定义
│ └── content.ts # 统一内容数据结构
├── public/ # 静态资源
├── tailwind.config.ts
├── next.config.ts
├── package.json
└── PRD.md
```
### 5.3 关键设计决策
1. **API 代理层**:通过 Next.js API Routes 代理 TikHub 请求,避免前端暴露 API Key
2. **统一数据模型**:所有平台的内容映射为统一的 `ContentItem` 类型,平台差异在适配器层处理
3. **平台适配器模式**:每个平台实现一个适配器,负责 API 调用和数据格式转换,方便后续扩展新平台
---
## 6. 页面设计规格
### 6.1 设计风格
- **简约现代**(参考 Notion/Linear 风格)
- 大量留白,信息层次清晰
- 浅色主题为主,后期可扩展暗色模式
- 平台图标采用各平台官方 logo 配色,便于快速识别
### 6.2 页面结构
```
┌─────────────────────────────────────────────┐
│ Logo [全部][抖音][TikTok][小红书]... ⚙️ │ ← 顶部导航:平台 Tab 切换
├─────────────────────────────────────────────┤
│ 排序: [最热] [最新] 🔄 刷新 上次: 10:30 │ ← 工具栏:排序+刷新
├─────────────────────────────────────────────┤
│ │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │封面│ │封面│ │封面│ │封面│ │封面│ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │标题│ │标题│ │标题│ │标题│ │标题│ │ ← 卡片网格信息流
│ │数据│ │数据│ │数据│ │数据│ │数据│ │
│ └────┘ └────┘ └────┘ └────┘ └────┘ │
│ │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │ .. │ │ .. │ │ .. │ │ .. │ │ .. │ │
│ └────┘ └────┘ └────┘ └────┘ └────┘ │
│ │
└─────────────────────────────────────────────┘
```
---
## 7. 非功能需求
| 项目 | 要求 |
|------|------|
| 部署方式 | 先本地开发localhost后期部署到 Vercel 或云服务器 |
| 响应式 | 桌面端优先,基本适配平板 |
| 性能 | 首屏加载 < 3s支持图片懒加载 |
| API 成本控制 | 默认 30 分钟刷新一次,支持手动调整;需展示当日 API 调用量 |
---
## 8. MVP 验收标准
- [ ] 成功接入抖音、TikTok、小红书三个平台的热榜/推荐内容
- [ ] 卡片流展示内容,包含封面图、标题、关键数据
- [ ] 可按平台 Tab 切换查看
- [ ] 可按播放量/点赞数排序
- [ ] 点击卡片进入站内详情页,展示完整数据+原文链接
- [ ] 收藏功能可用,收藏数据本地持久化
- [ ] 支持手动刷新 + 自动定时刷新
- [ ] 设置页可配置 API Key 和刷新间隔
---
## 验证方式
1. `pnpm dev` 启动本地开发服务器
2. 访问首页,确认三个平台的热点内容正常加载和展示
3. 测试平台切换、排序、收藏、详情页等功能
4. 检查 API 代理层是否正确隐藏了 API Key
5. 测试自动刷新和手动刷新功能

995
doc/UIDesign.md Normal file
View File

@ -0,0 +1,995 @@
# Muse Creative Hotspots — UI 设计文档
## 文档信息
| 项目 | 内容 |
|------|------|
| 版本 | v1.0 |
| 创建日期 | 2026-03-02 |
| 来源文档 | DevelopmentPlan.md, FeatureSummary.md, PRD.md |
## 1. 设计概述
### 1.1 设计原则
| 原则 | 说明 |
|------|------|
| 简约现代 | 参考 Notion/Linear 风格,大量留白,信息层次清晰 |
| 内容优先 | 卡片封面图和数据指标是核心UI 元素不喧宾夺主 |
| 平台可识别 | 平台图标采用官方配色,用户可快速辨别内容来源 |
| 状态完备 | 每个页面覆盖默认、加载中、空状态、错误四种状态 |
| 响应式 | 桌面端优先CSS Grid 自适应屏幕宽度 |
### 1.2 页面总览
| 页面ID | 页面名称 | 路由 | 描述 | 对应功能 | 优先级 |
|--------|----------|------|------|----------|--------|
| P-001 | 首页 | `/` | 热点内容卡片信息流,含平台 Tab、排序、刷新 | F-001, F-002, F-003, F-005, F-006 | P0 |
| P-002 | 详情页 | `/detail/[platform]/[id]` | 单条内容完整信息展示 | F-004, F-007 | P0 |
| P-003 | 收藏夹 | `/favorites` | 已收藏内容的网格展示与管理 | F-007, F-008, F-009 | P0 |
| P-004 | 设置页 | `/settings` | API Key 配置、刷新间隔设置 | F-010, F-011 | P0 |
### 1.3 页面导航图
```
┌──────────────────┐
│ P-001 │
│ 首页 │
│ (默认入口页面) │
└───────┬──────────┘
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ P-002 │ │ P-003 │ │ P-004 │
│ 详情页 │ │ 收藏夹 │ │ 设置页 │
│ 点击卡片进入 │ │ Header导航进入 │ │ Header ⚙ 进入 │
└────────┬───────┘ └────────┬───────┘ └────────────────┘
│ │
│ ┌─────────────┘
│ │ 点击收藏卡片
▼ ▼
┌────────────────┐
│ P-002 │
│ 详情页(复用) │
└────────────────┘
```
**导航说明**:
- Header 常驻所有页面提供全局导航Logo 回首页、收藏夹入口、设置入口)
- 首页 → 详情页:点击任意内容卡片
- 首页 → 收藏夹:点击 Header 收藏入口
- 首页 → 设置页:点击 Header ⚙ 图标
- 收藏夹 → 详情页:点击收藏的内容卡片
- 详情页 → 返回上一页:浏览器 Back / 返回按钮
---
## 2. 页面设计
### 2.1 P-001: 首页
**页面信息**
| 属性 | 值 |
|------|-----|
| 页面ID | P-001 |
| 路由 | `/` |
| 对应功能 | F-001, F-002, F-003, F-005, F-006 |
| 入口 | 应用默认页面Header Logo 点击 |
| 出口 | P-002点击卡片、P-003Header 收藏、P-004Header 设置) |
**页面布局 — ASCII 原型图**
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Header │ │
│ │ │ │
│ │ Muse [全部] [抖音] [TikTok] [小红书] [♡ 收藏] [⚙] │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Toolbar │ │
│ │ │ │
│ │ 排序: [▼ 播放量] [↓ 降序] [🔄 刷新] 上次: 10:30 │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ 封面图 │ │ │ │ 封面图 │ │ │ │ 封面图 │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ │
│ │ │ │ │ │ │ │
│ │ 📱 抖音 │ │ 🎵 TikTok │ │ 📕 小红书 │ │
│ │ 标题文字截断展 │ │ Title text tr.. │ │ 标题文字截断展 │ │
│ │ 示最多两行... │ │ uncated to two.. │ │ 示最多两行... │ │
│ │ │ │ │ │ │ │
│ │ 👤 作者昵称 │ │ 👤 Author Name │ │ 👤 作者昵称 │ │
│ │ ▶1.2M ❤5.3K │ │ ▶800K ❤3.1K │ │ ❤2.1K 💬156 │ │
│ │ 💬 203 [♡] │ │ 💬 150 [♡] │ │ [♡] │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │
│ │ │ 封面图 │ │ │ │ 封面图 │ │ │ │ 封面图 │ │ │
│ │ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ │
│ │ 📱 抖音 │ │ 📕 小红书 │ │ 🎵 TikTok │ │
│ │ 标题文字... │ │ 标题文字... │ │ Title text... │ │
│ │ 👤 作者 ▶ ❤ 💬 │ │ 👤 作者 ❤ 💬 │ │ 👤 Author ▶ ❤ │ │
│ │ [♡] │ │ [♡] │ │ [♡] │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ ... 更多卡片 ... │
│ │
└──────────────────────────────────────────────────────────────────────────┘
```
**组件清单**
| 组件ID | 组件名称 | 类型 | 说明 | 交互 |
|--------|----------|------|------|------|
| C-001 | Header | 导航栏 | 顶部固定,含 Logo、平台 Tab、收藏入口、设置入口 | Logo 回首页Tab 切换平台 |
| C-002 | PlatformTabs | Tab 栏 | Header 内嵌,展示"全部"+各平台 Tab | 点击切换平台,触发数据重新获取 |
| C-003 | SortToolbar | 工具栏 | 排序选择器 + 排序方向 + 刷新按钮 + 上次刷新时间 | 选择排序字段/方向;点击刷新 |
| C-004 | ContentCard | 卡片 | 单条内容:封面图、平台标识、标题、作者、数据指标、收藏按钮 | 点击进入详情页;点击 ♡ 收藏 |
| C-005 | ContentGrid | 网格容器 | CSS Grid 响应式网格 `repeat(auto-fill, minmax(280px, 1fr))` | - |
| C-006 | CardSkeleton | 骨架屏 | 数据加载时的占位动画 | - |
| C-008 | FavoriteButton | 收藏按钮 | 卡片右下角,♡ 空心/♥ 实心切换 | 点击切换收藏状态 |
**交互说明**
| 触发 | 动作 | 结果 |
|------|------|------|
| 点击平台 Tab | 切换 queryKey | 重新获取对应平台数据,"全部"聚合所有平台 |
| 选择排序字段/方向 | 前端内存排序useMemo | 卡片网格重新排列,不请求 API |
| 点击刷新按钮 | invalidateQueries + 重置自动刷新计时器 | 按钮显示 loading 旋转,完成后更新"上次刷新"时间 |
| 点击卡片(非收藏按钮区域) | 路由跳转 | 进入 `/detail/[platform]/[id]` 详情页 |
| 点击卡片收藏按钮 ♡ | Zustand addFavorite/removeFavorite | ♡ ↔ ♥ 状态切换,数据持久化到 localStorage |
| 自动定时刷新 | refetchInterval 触发 | 静默刷新数据,更新"上次刷新"时间 |
| 页面不可见(切换 Tab | 暂停自动刷新 | 节省 API 调用 |
**页面状态**
| 状态 | 说明 | 展示 |
|------|------|------|
| 默认 | 数据加载完成 | 卡片网格正常展示 |
| 加载中 | 首次加载或切换平台 | 骨架屏CardSkeleton x N |
| 刷新中 | 手动/自动刷新 | 刷新按钮旋转,保留当前卡片 |
| 空状态 | 平台无数据返回 | 空状态组件 + "暂无热点内容" |
| 错误 — API Key 未配置 | 未设置 API Key | 引导提示 + "去配置" 按钮跳转设置页 |
| 错误 — 请求失败 | 网络/服务端错误 | 错误提示 + "重试" 按钮 |
| 错误 — 频率超限 | 429 响应 | 提示"请求过于频繁,请稍后重试" |
**加载态原型**
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │
│ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │
│ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
│ ░░░░░░ │ │ ░░░░░░ │ │ ░░░░░░ │
│ ░░░░░░░░░░░░░░ │ │ ░░░░░░░░░░░░░░ │ │ ░░░░░░░░░░░░░░ │
│ ░░░░░░░░░░ │ │ ░░░░░░░░░░ │ │ ░░░░░░░░░░ │
│ ░░░░ ░░░░ ░░░░ │ │ ░░░░ ░░░░ ░░░░ │ │ ░░░░ ░░░░ ░░░░ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
**空状态原型**
```
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ │
│ │ 📭 │ │
│ └──────────┘ │
│ │
│ 暂无热点内容 │
│ 当前平台暂时没有热门内容 │
│ │
│ [🔄 刷新试试] │
│ │
└──────────────────────────────────────────────────────────┘
```
**API Key 未配置引导原型**
```
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ │
│ │ 🔑 │ │
│ └──────────┘ │
│ │
│ 请先配置 API Key │
│ 需要配置 TikHub API Key 才能获取内容 │
│ │
│ [⚙ 前往设置] │
│ │
└──────────────────────────────────────────────────────────┘
```
---
### 2.2 P-002: 详情页
**页面信息**
| 属性 | 值 |
|------|-----|
| 页面ID | P-002 |
| 路由 | `/detail/[platform]/[id]` |
| 对应功能 | F-004, F-007 |
| 入口 | P-001点击卡片、P-003点击收藏卡片 |
| 出口 | 返回上一页;外部跳转(查看原文) |
**页面布局 — ASCII 原型图**
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Header同首页 C-001 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ [← 返回] [♡ 收藏] │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────────────────────────────────────┐ ┌──────────────┐ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ 平台信息 │ │ │
│ │ │ │ │ │ │ │
│ │ │ 封面图 │ │ 📱 抖音 │ │ │
│ │ │ (大图展示) │ │ │ │ │
│ │ │ │ │ 发布时间 │ │ │
│ │ │ │ │ 2026-03-01 │ │ │
│ │ │ │ │ 14:30 │ │ │
│ │ │ │ │ │ │ │
│ │ └──────────────────────────────────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ 内容标题(完整展示,不截断) │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ 作者信息 │ │ │
│ │ │ ┌────┐ │ │ │
│ │ │ │头像│ 作者昵称 │ │ │
│ │ │ └────┘ │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ 数据指标面板 │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │
│ │ │ │ ▶ 播放量 │ │ ❤ 点赞 │ │ 💬 评论 │ │ ↗ 分享 │ │ │ │
│ │ │ │ 1,234,567│ │ 53,210 │ │ 2,031 │ │ 8,456 │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │
│ │ │ │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ 标签 │ │ │
│ │ │ [#热门话题] [#创意] [#内容创作] │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ [🔗 查看原文] │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
```
**组件清单**
| 组件ID | 组件名称 | 类型 | 说明 | 交互 |
|--------|----------|------|------|------|
| C-001 | Header | 导航栏 | 复用全局 Header | 同首页 |
| C-007 | DetailPanel | 信息面板 | 封面大图 + 标题 + 作者 + 数据指标 + 标签 | - |
| C-008 | FavoriteButton | 收藏按钮 | 右上角,大号 ♡/♥ | 点击切换收藏 |
**交互说明**
| 触发 | 动作 | 结果 |
|------|------|------|
| 点击 [← 返回] | 路由 back | 返回上一页面(首页或收藏夹) |
| 点击 [♡ 收藏] | Zustand toggle | 收藏状态切换 |
| 点击 [🔗 查看原文] | window.open | 新窗口打开原平台页面 |
| 封面图加载失败 | 显示占位图 | 灰色占位 + 平台图标 |
**页面状态**
| 状态 | 说明 | 展示 |
|------|------|------|
| 默认 | 详情数据加载完成 | 完整信息面板 |
| 加载中 | 请求详情 API | 骨架屏(大图区域 + 文字行) |
| 错误 | 详情加载失败 | 错误提示 + "重试" + "返回首页" 按钮 |
<!-- NEW START -->
**错误态原型**
```
┌────────────────────────────────────────────────────────────┐
│ [← 返回] │
│ │
│ │
│ ┌──────────┐ │
│ │ ⚠️ │ │
│ └──────────┘ │
│ │
│ 内容加载失败 │
│ 请检查网络连接或稍后重试 │
│ │
│ [🔄 重试] [🏠 返回首页] │
│ │
│ │
└────────────────────────────────────────────────────────────┘
```
<!-- NEW END -->
**加载态原型**
```
┌────────────────────────────────────────────────────────────┐
│ [← 返回] │
│ │
│ ┌──────────────────────────────────┐ ┌──────────────┐ │
│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░│ │
│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░│ │
│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░│ │
│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ └──────────────┘ │
│ └──────────────────────────────────┘ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░ ░░░░░░░░░░░ │
│ ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░ │
│ ░░░░░░░░░ ░░░░░░ ░░░░░░░ │
└────────────────────────────────────────────────────────────┘
```
---
### 2.3 P-003: 收藏夹页面
**页面信息**
| 属性 | 值 |
|------|-----|
| 页面ID | P-003 |
| 路由 | `/favorites` |
| 对应功能 | F-007, F-008, F-009 |
| 入口 | Header 收藏入口 |
| 出口 | P-002点击卡片Header 导航至其他页面 |
**页面布局 — ASCII 原型图**
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Header同首页 C-001 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 我的收藏 共 12 条 │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │
│ │ │ 封面图 │ │ │ │ 封面图 │ │ │ │ 封面图 │ │ │
│ │ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ │
│ │ 📱 抖音 │ │ 🎵 TikTok │ │ 📕 小红书 │ │
│ │ 标题文字... │ │ Title text... │ │ 标题文字... │ │
│ │ 👤 作者 │ │ 👤 Author │ │ 👤 作者 │ │
│ │ ▶1.2M ❤5.3K │ │ ▶800K ❤3.1K │ │ ❤2.1K 💬156 │ │
│ │ [♥] │ │ [♥] │ │ [♥] │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │
│ │ │ 封面图 │ │ │ │ 封面图 │ │ │
│ │ └─────────────┘ │ │ └─────────────┘ │ │
│ │ ... │ │ ... │ │
│ │ [♥] │ │ [♥] │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
```
**组件清单**
| 组件ID | 组件名称 | 类型 | 说明 | 交互 |
|--------|----------|------|------|------|
| C-001 | Header | 导航栏 | 复用全局 Header | 同首页 |
| C-005 | ContentGrid | 网格容器 | 复用首页网格布局 | - |
| C-004 | ContentCard | 卡片 | 复用首页卡片,收藏按钮为实心 ♥ | 点击进入详情;点击 ♥ 取消收藏 |
**交互说明**
| 触发 | 动作 | 结果 |
|------|------|------|
| 点击卡片 | 路由跳转 | 进入 `/detail/[platform]/[id]` |
| 点击 ♥ 取消收藏 | Zustand removeFavorite | 卡片从列表移除 |
**页面状态**
| 状态 | 说明 | 展示 |
|------|------|------|
| 默认 | 有收藏内容 | 卡片网格展示 |
| 空状态 | 无收藏内容 | 空状态引导 |
**空状态原型**
```
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ │
│ │ ♡ │ │
│ └──────────┘ │
│ │
│ 还没有收藏内容 │
│ 浏览热点内容时,点击 ♡ 收藏感兴趣的内容 │
│ │
│ [去浏览热点] │
│ │
└──────────────────────────────────────────────────────────┘
```
---
### 2.4 P-004: 设置页面
**页面信息**
| 属性 | 值 |
|------|-----|
| 页面ID | P-004 |
| 路由 | `/settings` |
| 对应功能 | F-010, F-011 |
| 入口 | Header ⚙ 图标 |
| 出口 | Header 导航至其他页面 |
**页面布局 — ASCII 原型图**
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Header同首页 C-001 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 设置 │ │
│ │ │ │
│ │ ─────────────────────────────────────────────── │ │
│ │ │ │
│ │ API 配置 │ │
│ │ │ │
│ │ TikHub API Key │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ sk-xxxxxxxxxxxxxxxxxxxx │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ API Key 保存在服务端,不会暴露到浏览器。 │ │
│ │ 优先使用 .env.local 中的预配置值。 │ │
│ │ │ │
│ │ [保存 API Key] │ │
│ │ │ │
│ │ ─────────────────────────────────────────────── │ │
│ │ │ │
│ │ 刷新设置 │ │
│ │ │ │
│ │ 自动刷新间隔 │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ 30 分钟 ▼ │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ 可选: 5 / 10 / 15 / 30 / 60 分钟 │ │
│ │ 最低 5 分钟,防止 API 成本过高。 │ │
│ │ │ │
│ │ ─────────────────────────────────────────────── │ │
│ │ │ │
│ │ 关于 │ │
│ │ │ │
│ │ Muse Creative Hotspots v1.0 │ │
│ │ 数据来源: TikHub API (api.tikhub.io) │ │
│ │ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
```
**组件清单**
| 组件ID | 组件名称 | 类型 | 说明 | 交互 |
|--------|----------|------|------|------|
| C-001 | Header | 导航栏 | 复用全局 Header | 同首页 |
| C-011 | ApiKeyInput | 输入框 | password 类型输入框 + 保存按钮 | 输入 Key → 点击保存 |
| C-012 | IntervalSelect | 下拉选择 | 刷新间隔选择器,选项: 5/10/15/30/60 分钟 | 选择后立即生效 |
**交互说明**
| 触发 | 动作 | 结果 |
|------|------|------|
| 输入 API Key + 点击保存 | POST /api/settings | 保存到服务端内存变量;成功/失败 Toast 提示 |
| 选择刷新间隔 | Zustand setRefreshInterval | 立即更新 TanStack Query refetchInterval |
| API Key 为空点击保存 | 前端校验 | 输入框红色边框 + "请输入 API Key" 提示 |
**页面状态**
| 状态 | 说明 | 展示 |
|------|------|------|
| 默认 | 正常展示设置表单 | 当前 Key掩码+ 当前间隔 |
| 保存中 | API Key 保存请求中 | 保存按钮 loading |
| 保存成功 | 保存完成 | Toast "API Key 已保存" |
| 保存失败 | 保存请求失败 | Toast "保存失败,请重试" |
---
## 3. 用户流程
### 3.1 内容浏览主流程
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 打开应用 │ ──▶ │ 浏览首页 │ ──▶ │ 筛选/排序 │ ──▶ │ 查看详情 │
│ │ │ 卡片信息流│ │ 切换平台 │ │ 完整信息 │
│ P-001 │ │ P-001 │ │ P-001 │ │ P-002 │
└──────────┘ └────┬─────┘ └──────────┘ └────┬─────┘
│ │
│ API Key 未配置 │
▼ ▼
┌──────────┐ ┌──────────┐
│ 配置Key │ │ 查看原文 │
│ P-004 │ │ (外部跳转)│
└──────────┘ └──────────┘
```
**流程步骤**
| 步骤 | 页面 | 用户操作 | 系统响应 |
|------|------|----------|----------|
| 1 | P-001 | 打开应用 | 自动获取默认平台(全部)的热点内容 |
| 2 | P-001 | 浏览卡片信息流 | 展示卡片网格(封面图+标题+数据) |
| 3 | P-001 | 点击平台 Tab 切换 | 重新获取对应平台数据,更新卡片网格 |
| 4 | P-001 | 选择排序方式 | 前端内存排序,卡片重新排列 |
| 5 | P-001 | 点击感兴趣的卡片 | 路由跳转到详情页 |
| 6 | P-002 | 查看完整信息 | 展示大图+标题+数据面板+标签 |
| 7 | P-002 | 点击"查看原文" | 新窗口打开原平台页面 |
### 3.2 收藏管理流程
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 浏览内容 │ ──▶ │ 点击收藏 │ ──▶ │ 打开收藏夹│ ──▶ │ 查看详情 │
│ P-001 │ │ ♡ → ♥ │ │ P-003 │ │ P-002 │
└──────────┘ └──────────┘ └──────────┘ └────┬─────┘
│ │
│ 也可在详情页收藏 │
▼ │
┌──────────┐ │
│ 详情页收藏│ │
│ P-002 │ ◀────────────────────────────┘
└──────────┘
```
**流程步骤**
| 步骤 | 页面 | 用户操作 | 系统响应 |
|------|------|----------|----------|
| 1 | P-001/P-002 | 点击卡片收藏按钮 ♡ | ♡ → ♥ 切换,存入 localStorage |
| 2 | P-003 | 点击 Header 收藏入口 | 展示所有已收藏内容的卡片网格 |
| 3 | P-003 | 点击卡片 | 跳转详情页 |
| 4 | P-003 | 点击 ♥ 取消收藏 | 卡片从收藏列表移除 |
### 3.3 设置配置流程
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 点击 ⚙ │ ──▶ │ 输入Key │ ──▶ │ 保存Key │ ──▶ │ 设置间隔 │
│ P-001 │ │ P-004 │ │ P-004 │ │ P-004 │
└──────────┘ └──────────┘ └────┬─────┘ └────┬─────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ 保存成功 │ │ 立即生效 │
│ Toast提示 │ │ 更新定时器│
└──────────┘ └──────────┘
```
**流程步骤**
| 步骤 | 页面 | 用户操作 | 系统响应 |
|------|------|----------|----------|
| 1 | P-001 | 点击 Header ⚙ 图标 | 跳转设置页 |
| 2 | P-004 | 输入 TikHub API Key | 输入框显示内容password 掩码) |
| 3 | P-004 | 点击"保存 API Key" | POST /api/settings成功后 Toast 提示 |
| 4 | P-004 | 选择刷新间隔(如 15 分钟) | Zustand 更新TanStack Query refetchInterval 立即变更 |
| 5 | P-001 | 返回首页 | 使用新 Key 获取数据,按新间隔自动刷新 |
### 3.4 数据刷新流程
```
┌─────────────────────────────────────┐
│ 刷新触发 │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 自动刷新 │ │ 手动刷新 │ │
│ │ 定时器 │ │ 点击 🔄 │ │
│ └────┬─────┘ └────┬─────┘ │
│ │ │ │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────┐ │
│ │ 获取数据 │ │
│ │ F-001 │ │
│ └────┬─────┘ │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ ┌────────┐ ┌────────┐ │
│ │ 成功 │ │ 失败 │ │
│ └───┬────┘ └───┬────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 更新卡片 │ │ 保留旧数据│ │
│ │ 更新时间 │ │ 错误提示 │ │
│ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────┘
```
---
## 4. 组件规范
### 4.1 全局组件
**C-001: Header 导航栏**
```
┌──────────────────────────────────────────────────────────────────────┐
│ │
│ Muse [全部] [抖音] [TikTok] [小红书] [♡ 收藏] [⚙] │
│ ─────── │
│ (当前Tab下划线高亮) │
│ │
└──────────────────────────────────────────────────────────────────────┘
说明:
- 左侧: Logo 文字 "Muse",点击回首页
- 中部: 平台 Tab 栏(仅首页展示),当前选中 Tab 有下划线高亮
- 右侧: 收藏入口 + 设置图标
- 固定在页面顶部 (sticky top)
- 背景: 白色,底部 1px 边框线
```
<!-- NEW START -->
**C-001 变体: 非首页 Header详情页、收藏夹、设置页使用**
```
┌──────────────────────────────────────────────────────────────────────┐
│ │
│ Muse [♡ 收藏] [⚙] │
│ │
└──────────────────────────────────────────────────────────────────────┘
说明:
- 平台 Tab 栏在非首页隐藏Logo 与右侧图标之间保持留白
- 其余元素位置不变Logo 左对齐,图标右对齐)
- 适用于: P-002 详情页、P-003 收藏夹、P-004 设置页
```
<!-- NEW END -->
**C-002: PlatformTabs 平台 Tab 栏**
```
默认态: [全部] [抖音] [TikTok] [小红书]
─────
(选中态: 下划线 + 文字加粗)
各 Tab 含平台图标:
全部: 🌐 全部
抖音: 📱 抖音 (品牌色: #000000)
TikTok: 🎵 TikTok (品牌色: #00F2EA)
小红书: 📕 小红书 (品牌色: #FF2442)
Tab 切换时,下划线平滑滑动过渡
```
**C-003: SortToolbar 工具栏**
```
┌──────────────────────────────────────────────────────────────────────┐
│ │
│ 排序: [▼ 播放量] [↓ 降序] [🔄 刷新] 上次: 10:30│
│ ────────── ──────── ──────── │
│ 下拉选择 切换按钮 图标按钮 │
│ │
└──────────────────────────────────────────────────────────────────────┘
排序字段选项:
- 播放量 (play_count)
- 点赞数 (like_count)
- 评论数 (comment_count)
- 发布时间 (publish_time)
排序方向:
- ↓ 降序 (desc) — 默认
- ↑ 升序 (asc)
刷新按钮状态:
- 默认: 🔄 图标
- 刷新中: 旋转动画
- 防抖: 2 秒内重复点击忽略
```
### 4.2 业务组件
**C-004: ContentCard 内容卡片**
```
┌─────────────────────┐
│ ┌─────────────────┐ │
│ │ │ │ ← 封面图区域 (aspect-ratio: 4/3 或 3/4)
│ │ Cover Image │ │ 加载失败 → 灰色占位 + 平台图标
│ │ │ │ 使用 Next.js Image + loading="lazy"
│ └─────────────────┘ │
│ │
│ 📱 抖音 │ ← 平台图标 + 平台名 (品牌色小标签)
│ │
│ 标题文字截断展示最 │ ← 标题 (最多 2 行, line-clamp-2)
│ 多两行省略号... │
│ │
│ ┌──┐ │
│ │头│ 作者昵称 │ ← 作者头像 (24px 圆形) + 昵称
│ └──┘ │
│ │
│ ▶ 1.2M ❤ 5.3K │ ← 数据指标 (数字缩写: K/M)
│ 💬 203 [♡] │ ← 评论数 + 收藏按钮
│ │
└─────────────────────┘
宽度: minmax(280px, 1fr)
圆角: 8px (rounded-lg)
阴影: hover 时提升阴影
边框: 1px solid border色
过渡: hover 时 translateY(-2px)
收藏按钮 [♡]:
未收藏: ♡ 空心,灰色
已收藏: ♥ 实心,红色
点击区域: 足够大 (44x44px),防止误触卡片跳转
指标展示规则: <!-- NEW -->
指标值为空undefined时隐藏该指标项而非展示 0。
与 DevelopmentPlan ContentItem 类型中 play_count?: number可选字段一致。
Hover 行为: <!-- NEW -->
MVP 阶段: hover 仅做视觉反馈(阴影提升 + translateY(-2px))。
v1.1 迭代: 考虑增加 hover tooltip/popover 展示更多信息(如完整标题、分享数)。
```
**C-006: CardSkeleton 骨架屏**
```
┌─────────────────────┐
│ ┌─────────────────┐ │
│ │░░░░░░░░░░░░░░░░░│ │ ← 封面占位 (pulse 动画)
│ │░░░░░░░░░░░░░░░░░│ │
│ │░░░░░░░░░░░░░░░░░│ │
│ └─────────────────┘ │
│ ░░░░░░░ │ ← 平台标签占位
│ ░░░░░░░░░░░░░░░░░░ │ ← 标题行占位
│ ░░░░░░░░░░░░░ │
│ ░░ ░░░░░░░░░ │ ← 作者占位
│ ░░░░ ░░░░ ░░░░ │ ← 数据指标占位
└─────────────────────┘
动画: Tailwind animate-pulse
颜色: bg-slate-200
```
**C-008: FavoriteButton 收藏按钮**
```
未收藏态: ♡ (text-slate-400, hover: text-red-400)
已收藏态: ♥ (text-red-500)
过渡动画: 点击时 scale 弹跳效果 (scale-110 → scale-100)
点击区域: 44px x 44px (无障碍最小触摸区域)
阻止冒泡: e.stopPropagation() 防止触发卡片跳转
```
**C-009: EmptyState 空状态**
```
┌──────────────────────────────────────┐
│ │
│ ┌──────────┐ │
│ │ {icon} │ │
│ └──────────┘ │
│ │
│ {主要文案} │ ← text-slate-800, 16px, medium
│ {辅助文案} │ ← text-slate-500, 14px, regular
│ │
│ [{action button}] │ ← 可选操作按钮
│ │
└──────────────────────────────────────┘
Props:
icon: ReactNode (图标)
title: string (主要文案)
description: string (辅助文案)
action?: { label: string, onClick: () => void }
```
<!-- NEW START -->
**C-013: Toast 通知**
```
成功态 (右上角弹出):
┌──────────────────────────────┐
│ ✅ API Key 已保存 │
└──────────────────────────────┘
失败态 (右上角弹出):
┌──────────────────────────────┐
│ ❌ 保存失败,请重试 │
└──────────────────────────────┘
规范:
- 使用 shadcn/ui Toast 组件
- 位置: 页面右上角 (top-right)
- 自动消失时间: 3 秒
- 成功态: 绿色左边框 (border-l-4 border-green-500)
- 失败态: 红色左边框 (border-l-4 border-red-500)
- 支持手动关闭 (点击 × 按钮)
- 背景: white阴影: shadow-lg
- 进入动画: slide-in-from-right
- 退出动画: fade-out
```
<!-- NEW END -->
**C-010: ErrorState 错误状态**
```
┌──────────────────────────────────────┐
│ │
│ ┌──────────┐ │
│ │ ⚠️ │ │
│ └──────────┘ │
│ │
│ {错误描述} │ ← text-red-600
│ │
│ [🔄 重试] │ ← 主按钮
│ │
└──────────────────────────────────────┘
```
---
## 5. 设计规范
### 5.1 色彩规范
| 用途 | 色值 | Tailwind Class | 示例 |
|------|------|----------------|------|
| 主色 | #2563EB | `blue-600` | 主按钮、链接、选中态 |
| 主色悬停 | #1D4ED8 | `blue-700` | 按钮 hover |
| 成功 | #16A34A | `green-600` | 保存成功提示 |
| 警告 | #D97706 | `amber-600` | 频率限制提示 |
| 错误 | #DC2626 | `red-600` | 错误提示、必填校验 |
| 收藏红 | #EF4444 | `red-500` | ♥ 已收藏状态 |
| 文字主色 | #1E293B | `slate-800` | 标题、正文 |
| 文字次色 | #64748B | `slate-500` | 描述、辅助信息 |
| 文字弱色 | #94A3B8 | `slate-400` | 占位文字、禁用态 |
| 背景色 | #FFFFFF | `white` | 页面背景 |
| 表面色 | #F8FAFC | `slate-50` | 卡片背景、工具栏 |
| 边框色 | #E2E8F0 | `slate-200` | 卡片边框、分割线 |
**平台品牌色**
| 平台 | 色值 | 用途 |
|------|------|------|
| 抖音 | #000000 | Tab 标签、平台图标背景 |
| TikTok | #00F2EA | Tab 标签、平台图标背景 |
| 小红书 | #FF2442 | Tab 标签、平台图标背景 |
### 5.2 字体规范
| 用途 | 字号 | 字重 | Tailwind Class |
|------|------|------|----------------|
| 页面标题 | 24px | Bold (700) | `text-2xl font-bold` |
| 区域标题 | 20px | Semibold (600) | `text-xl font-semibold` |
| 卡片标题 | 14px | Medium (500) | `text-sm font-medium` |
| 正文 | 14px | Regular (400) | `text-sm` |
| 数据指标 | 12px | Medium (500) | `text-xs font-medium` |
| 辅助文字 | 12px | Regular (400) | `text-xs` |
- 字体族: 系统默认字体栈 (Inter, system-ui, sans-serif)
### 5.3 间距规范
| 间距 | 值 | Tailwind | 用途 |
|------|-----|----------|------|
| xs | 4px | `1` | 图标与文字间距 |
| sm | 8px | `2` | 卡片内元素间距 |
| md | 12px | `3` | 卡片内部 padding |
| lg | 16px | `4` | 组件间距、网格 gap |
| xl | 24px | `6` | 区域间距 |
| 2xl | 32px | `8` | 页面 padding |
### 5.4 圆角规范
| 元素 | 圆角 | Tailwind |
|------|------|----------|
| 卡片 | 8px | `rounded-lg` |
| 按钮 | 6px | `rounded-md` |
| 输入框 | 6px | `rounded-md` |
| 头像 | 50% | `rounded-full` |
| 平台标签 | 4px | `rounded` |
### 5.5 阴影规范
| 场景 | 阴影 | Tailwind |
|------|------|----------|
| 卡片默认 | 0 1px 2px rgba(0,0,0,0.05) | `shadow-sm` |
| 卡片悬停 | 0 4px 6px rgba(0,0,0,0.1) | `shadow-md` |
| Header | 0 1px 3px rgba(0,0,0,0.1) | `shadow-sm` |
| 弹窗/Toast | 0 10px 15px rgba(0,0,0,0.1) | `shadow-lg` |
### 5.6 响应式断点
| 断点 | 宽度 | Tailwind | 布局说明 |
|------|------|----------|----------|
| Mobile | < 640px | `sm:` | 单栏卡片全宽 |
| Tablet | 640px - 1024px | `md:` / `lg:` | 2 列卡片网格 |
| Desktop | 1024px - 1280px | `xl:` | 3 列卡片网格 |
| Wide | > 1280px | `2xl:` | 4-5 列卡片网格 |
**网格响应式规则**:
```
CSS Grid: grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))
< 640px: 1 (卡片宽度 100%)
640-960: 2 列
960-1240: 3 列
1240-1520: 4 列
> 1520: 5 列
```
---
## 6. 页面与功能映射
| 功能ID | 功能名称 | 所在页面 | 主要组件 |
|--------|----------|----------|----------|
| F-001 | 内容获取 | P-001 | TanStack Query (数据层) |
| F-002 | 卡片信息流展示 | P-001 | C-005 ContentGrid + C-004 ContentCard |
| F-003 | 内容筛选与排序 | P-001 | C-002 PlatformTabs + C-003 SortToolbar |
| F-004 | 内容详情页 | P-002 | C-007 DetailPanel |
| F-005 | 自动定时刷新 | P-001 | C-003 SortToolbar (时间显示) |
| F-006 | 手动刷新 | P-001 | C-003 SortToolbar (刷新按钮) |
| F-007 | 内容收藏 | P-001, P-002 | C-008 FavoriteButton |
| F-008 | 收藏夹管理 | P-003 | C-005 ContentGrid + C-004 ContentCard |
| F-009 | 收藏数据持久化 | P-003 | Zustand persist (数据层) |
| F-010 | API Key 配置 | P-004 | C-011 ApiKeyInput |
| F-011 | 刷新间隔设置 | P-004 | C-012 IntervalSelect |
| F-014 | API 请求代理 | - | 后端 API Route (无 UI) |
| F-015 | 统一数据模型 | - | TypeScript 类型 (无 UI) |
| F-016 | 平台适配器 | - | 后端适配器 (无 UI) |

148
doc/tasks.md Normal file
View File

@ -0,0 +1,148 @@
# Muse Creative Hotspots — 任务列表
## 文档信息
| 项目 | 内容 |
|------|------|
| 版本 | v2.0 |
| 创建日期 | 2026-03-03 |
| 架构 | 前后端分离 Monorepo@muse/shared + @muse/backend + @muse/frontend |
## 1. 架构概览
```
museCreativeHotspots/
├── pnpm-workspace.yaml
├── packages/
│ ├── shared/ # @muse/shared — 共享类型和平台配置
│ ├── backend/ # @muse/backend — Hono API 服务器 (port 3001)
│ └── frontend/ # @muse/frontend — Next.js 前端 (port 3000)
```
---
## 2. Phase 1 — 基础架构搭建(已完成 ✅)
**目标**: 搭建 Monorepo 骨架共享类型系统API 代理链路,平台适配器。
| ID | 任务 | 包 | 状态 |
|----|------|----|------|
| T-001 | Monorepo 初始化 (pnpm workspace + 三个包) | root | ✅ |
| T-002 | TypeScript 类型定义 (ContentItem, Platform, PlatformAdapter) | @muse/shared | ✅ |
| T-003 | 平台配置 (MVP_PLATFORMS, getPlatformConfig) | @muse/shared | ✅ |
| T-004 | TikHub API 客户端与限流 (tikhubFetch, waitForSlot) | @muse/backend | ✅ |
| T-005 | 平台适配器 — 抖音 | @muse/backend | ✅ |
| T-006 | 平台适配器 — TikTok | @muse/backend | ✅ |
| T-007 | 平台适配器 — 小红书 | @muse/backend | ✅ |
| T-008 | 适配器注册表 (getAdapter, getSupportedPlatforms) | @muse/backend | ✅ |
| T-009 | Hono API 路由 — 热榜 (GET /:platform) | @muse/backend | ✅ |
| T-010 | Hono API 路由 — 详情 (GET /:platform/detail) | @muse/backend | ✅ |
| T-011 | Hono API 路由 — 设置 (GET /, POST /) | @muse/backend | ✅ |
---
## 3. Phase 2 — 核心功能实现(已完成 ✅)
**目标**: 前端页面、组件、数据查询、状态管理。
| ID | 任务 | 包 | 状态 |
|----|------|----|------|
| T-012 | Zustand Store — settings (apiKey, refreshInterval, persist) | @muse/frontend | ✅ |
| T-013 | Zustand Store — favorites (addFavorite, removeFavorite, persist) | @muse/frontend | ✅ |
| T-014 | TanStack Query 集成 (useContentQuery, useDetailQuery) | @muse/frontend | ✅ |
| T-015 | API_BASE_URL 前缀 (所有 fetch 指向 backend 3001) | @muse/frontend | ✅ |
| T-016 | 内容卡片组件 (ContentCard + ContentGrid + CardSkeleton) | @muse/frontend | ✅ |
| T-017 | 平台 Tab 切换 (PlatformTabs) | @muse/frontend | ✅ |
| T-018 | 排序功能 (SortToolbar) | @muse/frontend | ✅ |
| T-019 | 内容详情页 (DetailPanel + DetailSkeleton) | @muse/frontend | ✅ |
| T-020 | 收藏按钮组件 (FavoriteButton) | @muse/frontend | ✅ |
| T-021 | 首页页面组装 (page.tsx) | @muse/frontend | ✅ |
| T-022 | 设置页面 (settings/page.tsx) | @muse/frontend | ✅ |
| T-023 | 收藏夹页面 (favorites/page.tsx) | @muse/frontend | ✅ |
| T-024 | 全局布局 (layout.tsx + Header + QueryProvider) | @muse/frontend | ✅ |
---
## 4. Phase 3 — 测试与验证(已完成 ✅)
**目标**: 各包独立测试80% 覆盖率阈值。
| ID | 任务 | 包 | 状态 |
|----|------|----|------|
| T-025 | 共享包测试 (platforms.test.ts) | @muse/shared | ✅ |
| T-026 | 后端单元测试 — 限流器、API 客户端 | @muse/backend | ✅ |
| T-027 | 后端单元测试 — 适配器 (douyin, tiktok, xiaohongshu) | @muse/backend | ✅ |
| T-028 | 后端集成测试 — Hono 路由 (app.request 风格) | @muse/backend | ✅ |
| T-029 | 前端单元测试 — stores (favorites, settings) | @muse/frontend | ✅ |
| T-030 | 前端单元测试 — format.ts | @muse/frontend | ✅ |
---
## 5. Phase 4 — 新增平台适配器(已完成 ✅)
**目标**: 新增 YouTube、Instagram、Twitter/X、哔哩哔哩、微博 5 个平台的适配器、配置和测试。
| ID | 任务 | 包 | 状态 |
|----|------|----|------|
| T-031 | 平台适配器 — YouTube | @muse/backend | ✅ |
| T-032 | 平台适配器 — Instagram | @muse/backend | ✅ |
| T-033 | 平台适配器 — Twitter/X | @muse/backend | ✅ |
| T-034 | 平台适配器 — 哔哩哔哩 | @muse/backend | ✅ |
| T-035 | 平台适配器 — 微博 | @muse/backend | ✅ |
| T-036 | 适配器注册表更新 (8 个平台) | @muse/backend | ✅ |
| T-037 | 共享包平台配置更新 (MVP_PLATFORMS 8 项) | @muse/shared | ✅ |
| T-038 | YouTube 适配器单元测试 | @muse/backend | ✅ |
| T-039 | Instagram 适配器单元测试 | @muse/backend | ✅ |
| T-040 | Twitter/X 适配器单元测试 | @muse/backend | ✅ |
| T-041 | 哔哩哔哩适配器单元测试 | @muse/backend | ✅ |
| T-042 | 微博适配器单元测试 | @muse/backend | ✅ |
| T-043 | E2E Mock 测试更新 (fixtures + home.spec) | e2e/ | ✅ |
| T-044 | E2E 真实测试更新 (新平台切换) | e2e-real/ | ✅ |
---
## 6. 启动方式
```bash
# 安装依赖
pnpm install
# 同时启动前后端
pnpm dev
# 仅启动后端 (port 3001)
pnpm --filter @muse/backend dev
# 仅启动前端 (port 3000)
pnpm --filter @muse/frontend dev
# 运行全部测试
pnpm test
# 运行单包测试
pnpm --filter @muse/backend test
pnpm --filter @muse/frontend test
pnpm --filter @muse/shared test
```
---
## 7. 关键设计决策
1. **shared 包不构建**: 直接导出 `.ts` 源文件,消费方各自编译
2. **app.ts 与 index.ts 分离**: app.ts 只导出 Hono 实例,测试可直接 import
3. **NEXT_PUBLIC_ 前缀**: 前端 API URL 使用此前缀确保客户端可读
4. **CORS 中间件**: 后端配置 CORS 允许前端跨域调用
5. **API 路由前缀**: 保持 `/api/tikhub/...``/api/settings` 路径不变
---
## 8. 未来计划 (v1.1+)
| 功能 | 描述 |
|------|------|
| F-012 平台管理 | 前端可启用/禁用平台 |
| F-013 展示数量设置 | 用户自定义每页展示数量 |
| F-017 API 调用量统计 | 后端统计 API 调用次数 |
| Docker 部署 | 容器化部署方案 |
| E2E 测试 | Playwright 端到端测试 |

901
doc/tikhub_api.md Normal file
View File

@ -0,0 +1,901 @@
# TikHub API 参考文档
> 版本V5.2.92025-03-08
> Swagger UIhttps://api.tikhub.io
> ReDochttps://api.tikhub.io/docs
> Apifox 文档https://docs.tikhub.io
---
## 基础信息
| 项目 | 说明 |
|------|------|
| Base URL国际| `https://api.tikhub.io` |
| Base URL中国大陆| `https://api.tikhub.dev`(路径和参数完全相同,仅域名不同)|
| 认证方式 | `Authorization: Bearer {API_TOKEN}` |
| 计费 | $0.001 / 请求(按量付费,支持批量折扣)|
| 速率限制 | 令牌桶 10 req/s本项目在 `src/lib/tikhub.ts` 中已实现)|
| 超时 | 15s本项目已配置|
所有端点均为 `GET` 请求(除特别标注),返回 JSON。
---
## 错误码
| HTTP 状态码 | 含义 |
|------------|------|
| 400 | 参数错误 |
| 401 | API Key 无效或未提供 |
| 402 | 余额不足 |
| 403 | 无权限访问该端点 |
| 404 | 内容不存在 |
| 429 | 请求过于频繁 |
| 500 | 服务器内部错误 |
---
## 抖音Douyin
### 抖音网页版 API — `/api/v1/douyin/web/`
#### ★ `GET /api/v1/douyin/web/fetch_hot_search_result`
> **本项目使用** · 抖音热搜榜
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 无 | — | — | 无需参数 |
响应结构:
```json
{
"data": {
"data": {
"trending_list": [
{
"sentence_id": "string", // 热搜词 ID用作 ContentItem.id
"word": "string", // 热搜词文本
"word_cover": {
"url_list": ["string"] // 封面图列表
},
"hot_value": 10000, // 热度值(→ like_count
"view_count": 500000, // 阅读量(→ play_count
"discuss_video_count": 200, // 讨论视频数(→ comment_count
"event_time": 1709000000 // Unix 时间戳(秒)
}
],
"word_list": [/* 同上格式,普通热词 */]
}
}
}
```
> **注意**`trending_list` 为置顶热搜,`word_list` 为普通热词。本项目合并两者并按 `sentence_id` 去重。
---
#### ★ `GET /api/v1/douyin/web/fetch_one_video`
> **本项目使用** · 抖音视频详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `aweme_id` | string | 是 | 视频 ID |
响应结构:
```json
{
"data": {
"aweme_detail": {
"aweme_id": "string",
"desc": "string", // 视频标题/描述
"video": {
"cover": { "url_list": ["string"] },
"play_addr": { "url_list": ["string"] }
},
"author": {
"nickname": "string",
"avatar_thumb": { "url_list": ["string"] }
},
"statistics": {
"play_count": 100000,
"digg_count": 5000, // 点赞数(→ like_count
"comment_count": 200,
"share_count": 300
},
"create_time": 1709000000,
"text_extra": [{ "hashtag_name": "string" }]
}
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/douyin/web/fetch_user_profile` | 获取用户信息 | `unique_id`(用户名)或 `sec_user_id` |
| `GET /api/v1/douyin/web/fetch_user_post` | 获取用户作品 | `sec_user_id`, `cursor`(分页), `count` |
| `GET /api/v1/douyin/web/fetch_general_search_result` | 综合搜索 | `keyword`, `count`, `offset` |
| `GET /api/v1/douyin/web/fetch_video_search_result` | 视频搜索 | `keyword`, `count`, `offset` |
| `GET /api/v1/douyin/web/fetch_user_search_result_v2` | 用户搜索 | `keyword` |
| `GET /api/v1/douyin/web/fetch_post_comment` | 获取评论 | `aweme_id`, `cursor`, `count` |
| `GET /api/v1/douyin/app/v3/fetch_hot_search_list` | App 热搜榜App V3 推荐)| 无 |
| `GET /api/v1/douyin/billboard/*` | 各类榜单(热门/音乐/挑战等)| 参见 Swagger |
---
## TikTok
### TikTok 网页版 API — `/api/v1/tiktok/web/`
#### ★ `GET /api/v1/tiktok/web/fetch_explore_post`
> **本项目使用** · TikTok 探索/热门视频列表
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `count` | integer | 否 | 返回数量,默认 20 |
响应结构:
```json
{
"data": {
"itemList": [
{
"id": "string", // 视频 ID
"desc": "string", // 视频描述/标题
"video": {
"cover": "string", // 封面图 URL直接字符串
"playAddr": "string" // 播放地址
},
"author": {
"nickname": "string",
"avatarThumb": "string", // 头像 URL
"uniqueId": "string" // 用户名(用于构建主页链接)
},
"stats": {
"playCount": 500000,
"diggCount": 25000, // 点赞数(→ like_count
"commentCount": 1000,
"shareCount": 500
},
"createTime": 1709000000, // Unix 时间戳(秒)
"challenges": [{ "title": "string" }] // 话题标签(→ tags
}
]
}
}
```
> **注意**`video.cover` 是直接字符串,不是数组(与抖音不同)。
---
#### ★ `GET /api/v1/tiktok/web/fetch_post_detail`
> **本项目使用** · TikTok 视频详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `itemId` | string | 是 | 视频 ID |
响应结构:
```json
{
"data": {
"itemInfo": {
"itemStruct": { /* 同 itemList 中的单条格式 */ }
}
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/tiktok/web/fetch_user_profile` | 用户信息 | `uniqueId``secUid` |
| `GET /api/v1/tiktok/web/fetch_user_post` | 用户作品 | `secUid`, `cursor`, `count`, `post_item_list_request_type`(0=默认/1=热门/2=最旧) |
| `GET /api/v1/tiktok/web/fetch_user_like` | 用户喜欢(需公开)| `secUid`, `cursor`, `count` |
| `GET /api/v1/tiktok/web/fetch_post_comment` | 视频评论 | `aweme_id`, `cursor`, `count` |
| `GET /api/v1/tiktok/web/fetch_search_video` | 视频搜索 | `keyword`, `count`, `offset`, `search_id` |
| `GET /api/v1/tiktok/web/fetch_search_user` | 用户搜索 | `keyword`, `cursor`, `search_id` |
| `GET /api/v1/tiktok/web/fetch_tag_detail` | 话题详情 | `tag_name` |
| `GET /api/v1/tiktok/web/fetch_user_fans` | 粉丝列表 | `secUid`, `count`(默认30) |
| `GET /api/v1/tiktok/web/fetch_user_follow` | 关注列表 | `secUid`, `count`(默认30) |
| `POST /api/v1/tiktok/web/fetch_home_feed` | 首页推荐 | `count`, `cookie` |
---
## 小红书Xiaohongshu
### 小红书 Web V2 API — `/api/v1/xiaohongshu/web_v2/`
#### ★ `GET /api/v1/xiaohongshu/web_v2/fetch_hot_list`
> **本项目使用** · 获取热榜关键词列表(第一步)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 无 | — | — | 无需参数 |
响应结构:
```json
{
"data": {
"data": {
"items": [
{ "title": "string" } // 热词标题,取第一个作为搜索关键词
]
}
}
}
```
---
### 小红书 App V2 API — `/api/v1/xiaohongshu/app_v2/`(推荐)
#### ★ `GET /api/v1/xiaohongshu/app_v2/search_notes`
> **本项目使用** · 按关键词搜索笔记(第二步)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `keyword` | string | 是 | 搜索关键词 |
| `page` | integer | 否 | 页码,默认 1 |
响应结构:
```json
{
"data": {
"data": {
"items": [
{
"model_type": "note", // 只处理 model_type==='note' 的条目
"note": {
"id": "string",
"title": "string",
"desc": "string",
"type": "normal | video",
"timestamp": 1709000000,
"images_list": [
{
"url": "string",
"url_size_large": "string" // 优先使用大图
}
],
"user": {
"nickname": "string",
"images": "string", // 头像 URL
"user_id": "string"
},
"liked_count": 5000, // 点赞数(数字类型)
"comments_count": 200, // 评论数(注意:有 's'
"shared_count": 100,
"collected_count": 300,
"tag_info": [{ "name": "string" }]
}
}
]
}
}
}
```
> **注意**
> - `model_type` 不为 `"note"` 的条目(如广告)需过滤掉
> - 搜索结果中视频笔记的 `video_url` 不可用,仅详情接口可获取视频链接
---
#### ★ `GET /api/v1/xiaohongshu/app_v2/get_mixed_note_detail`
> **本项目使用** · 获取笔记详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `note_id` | string | 是 | 笔记 ID |
响应结构:
```json
{
"data": {
"id": "string",
"title": "string",
"name": "string", // 备用标题字段
"desc": "string",
"type": "normal | video",
"timestamp": 1709000000,
"likes": 5000, // 点赞数(注意:详情与搜索字段名不同)
"comments_count": 200,
"shared_count": 100,
"collected_count": 300,
"images_list": [
{ "url": "string", "url_size_large": "string" }
],
"user": {
"nickname": "string",
"images": "string"
},
"tag_info": [{ "name": "string" }],
"video_info_v2": {
"url": "string" // 仅视频笔记有此字段
}
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/xiaohongshu/app_v2/fetch_note_detail` | 笔记详情(旧版)| `note_id` |
| `GET /api/v1/xiaohongshu/web_v2/fetch_user_info` | 用户信息 | `user_id``username` |
| `GET /api/v1/xiaohongshu/web_v2/fetch_user_notes` | 用户笔记列表 | `user_id`, `cursor` |
| `GET /api/v1/xiaohongshu/app_v2/fetch_feed` | 推荐信息流 | `count` |
---
## 哔哩哔哩Bilibili
### Bilibili 网页版 API — `/api/v1/bilibili/web/`
#### ★ `GET /api/v1/bilibili/web/fetch_popular_video_list`
> **本项目使用** · 综合热门视频列表
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `pn` | integer | 否 | 页码,默认 1 |
| `ps` | integer | 否 | 每页数量,默认 20 |
响应结构:
```json
{
"data": {
"list": [
{
"bvid": "string", // 视频 BV 号(主要 ID
"aid": 123456, // av 号(备用)
"title": "string",
"pic": "string", // 封面图 URL直接字符串
"desc": "string",
"owner": {
"mid": 123456,
"name": "string",
"face": "string" // UP 主头像
},
"stat": {
"view": 100000, // 播放量(→ play_count
"like": 5000, // 点赞数
"reply": 200, // 评论数(→ comment_count
"share": 300,
"danmaku": 500, // 弹幕数(未映射)
"favorite": 1000, // 收藏数(未映射)
"coin": 800 // 投币数(未映射)
},
"pubdate": 1709000000, // Unix 时间戳(秒)
"duration": 300, // 时长(秒)
"tname": "string", // 分区名(→ tags[0]
"tags": [{ "tag_id": 1, "tag_name": "string" }]
}
]
}
}
```
---
#### ★ `GET /api/v1/bilibili/web/fetch_video_detail`
> **本项目使用** · 视频详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `bvid` | string | 是 | 视频 BV 号(如 `BV1xx411c7mD`|
响应结构:
```json
{
"data": { /* 同 list 中单条格式 */ }
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/bilibili/web/fetch_hot_search` | 热搜词 | 无 |
| `GET /api/v1/bilibili/web/fetch_user_info` | UP 主信息 | `mid`(用户 ID|
| `GET /api/v1/bilibili/web/fetch_user_videos` | UP 主视频列表 | `mid`, `pn`, `ps` |
| `GET /api/v1/bilibili/web/fetch_video_comment` | 视频评论 | `oid`aid, `pn`, `ps` |
| `GET /api/v1/bilibili/web/fetch_ranking` | 全站排行榜(每日更新)| `tid`(分区ID), `day`(1/3/7) |
---
## 微博Weibo
### 微博 App API — `/api/v1/weibo/app/`
#### ★ `GET /api/v1/weibo/app/fetch_hot_search`
> **本项目使用** · 微博热搜榜
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 无 | — | — | 无需参数 |
响应结构(两种格式,取其一):
**格式 1实际微博状态**
```json
{
"data": {
"statuses": [
{
"id": "string | number",
"mid": "string",
"text": "string", // HTML 格式,需 stripHtml 处理
"raw_text": "string",
"user": {
"screen_name": "string",
"profile_image_url": "string",
"avatar_hd": "string",
"avatar_large": "string"
},
"pic_ids": ["string"], // 图片 ID 列表
"pic_infos": {
"PIC_ID": {
"url": "string",
"large": { "url": "string" }
}
},
"attitudes_count": 5000, // 点赞数(→ like_count
"comments_count": 200,
"reposts_count": 300, // 转发数(→ share_count
"reads_count": 100000, // 阅读数(→ play_count
"created_at": "Mon Jan 01 00:00:00 +0800 2024"
}
]
}
}
```
**格式 2热搜话题**
```json
{
"data": {
"band_list": [
{
"word": "string", // 热搜词
"num": 10000, // 热度(→ play_count
"label_name": "string", // 标签(热、沸)
"mid": "string",
"rank": 1
}
],
"realtime": [/* 同上 */]
}
}
```
> **注意**
> - `text` 字段含 HTML 标签(`<a>`, `<span>` 等),需 strip 处理
> - 图片 URL 构建:`https://ww1.sinaimg.cn/large/{pic_id}.jpg`
> - 时间格式:`"Mon Jan 01 00:00:00 +0800 2024"`,可直接用 `new Date()` 解析
---
#### ★ `GET /api/v1/weibo/app/fetch_post_detail`
> **本项目使用** · 微博详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `mid` | string | 是 | 微博 ID |
响应结构:
```json
{
"data": { /* 同 statuses 中单条格式 */ }
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/weibo/web/fetch_hot_search` | 网页版热搜榜 | 无 |
| `GET /api/v1/weibo/web_v2/fetch_hot_search` | 网页版 V2 热搜榜 | 无 |
| `GET /api/v1/weibo/web/fetch_user_info` | 用户信息 | `uid``screen_name` |
| `GET /api/v1/weibo/web/fetch_user_statuses` | 用户微博列表 | `uid`, `page`, `count` |
---
## YouTube
### YouTube 网页版 API — `/api/v1/youtube/web/`
#### ★ `GET /api/v1/youtube/web/fetch_trending_video`
> **本项目使用** · YouTube 热门视频列表
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `count` | integer | 否 | 返回数量,默认 20 |
响应结构:
```json
{
"data": {
"items": [
{
"id": "string | { videoId: string }", // 视频 ID可能是字符串或对象
"snippet": {
"title": "string",
"description": "string",
"channelTitle": "string",
"publishedAt": "2024-01-15T10:00:00Z", // ISO 8601
"thumbnails": {
"maxres": { "url": "string" },
"high": { "url": "string" },
"medium": { "url": "string" },
"default": { "url": "string" }
},
"tags": ["string"]
},
"statistics": {
"viewCount": "100000", // 注意YouTube 统计字段是字符串类型
"likeCount": "5000", // 需 parseInt() 转换
"commentCount": "200"
}
}
]
}
}
```
> **注意**
> - `statistics.viewCount` 等字段为**字符串类型**,需用 `parseInt()` 转换
> - `id` 字段可能是字符串或 `{ videoId: string }` 对象,需兼容处理
> - 缩略图优先级:`maxres > high > medium > default`
---
#### ★ `GET /api/v1/youtube/web/fetch_video_detail`
> **本项目使用** · YouTube 视频详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `video_id` | string | 是 | 视频 ID`dQw4w9WgXcQ`|
响应结构:
```json
{
"data": {
"items": [{ /* 同 trending 中单条格式 */ }]
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/youtube/web/fetch_channel_info` | 频道信息 | `channel_id``username` |
| `GET /api/v1/youtube/web/fetch_channel_videos` | 频道视频列表 | `channel_id`, `count`, `page_token` |
| `GET /api/v1/youtube/web/fetch_search_results` | 视频搜索 | `keyword`, `count`, `page_token` |
| `GET /api/v1/youtube/web/fetch_playlist_videos` | 播放列表视频 | `playlist_id`, `count`, `page_token` |
---
## Instagram
### Instagram 网页版 API — `/api/v1/instagram/web/`
#### ★ `GET /api/v1/instagram/web/fetch_explore_feed`
> **本项目使用** · Instagram 探索页内容
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `count` | integer | 否 | 返回数量,默认 20 |
响应结构(两种格式,需同时处理):
**格式 1扁平 items 数组**
```json
{
"data": {
"items": [
{
"media": { /* InstagramMediaItem */ }
}
]
}
}
```
**格式 2分区 sectional_items**
```json
{
"data": {
"sectional_items": [
{
"layout_content": {
"medias": [
{ "media": { /* InstagramMediaItem */ } }
]
}
}
]
}
}
```
**InstagramMediaItem 结构**
```json
{
"pk": "string",
"id": "string",
"code": "string", // shortcode用于构建 URL 和作为 ID
"media_type": 1, // 1=图片, 2=视频, 8=轮播
"caption": { "text": "string" } | "string" | null,
"image_versions2": {
"candidates": [
{ "url": "string", "width": 1080, "height": 1080 }
]
},
"thumbnail_url": "string", // 视频封面(备用)
"video_url": "string", // 仅 media_type=2 时有值
"user": {
"username": "string",
"full_name": "string",
"profile_pic_url": "string"
},
"like_count": 5000,
"comment_count": 200,
"taken_at": 1709000000 // Unix 时间戳(秒)
}
```
> **注意**
> - 以 `code`shortcode作为内容 IDURL 格式:`https://www.instagram.com/p/{code}/`
> - `caption` 字段格式不固定,可能是对象、字符串或 null需兼容处理
---
#### ★ `GET /api/v1/instagram/web/fetch_post_detail`
> **本项目使用** · Instagram 帖子详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `shortcode` | string | 是 | 帖子 shortcode即内容 ID|
响应结构:
```json
{
"data": {
"items": [{ /* InstagramMediaItem */ }]
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/instagram/web/fetch_user_info` | 用户信息 | `username` |
| `GET /api/v1/instagram/web/fetch_user_posts` | 用户帖子 | `username`, `cursor`, `count` |
| `GET /api/v1/instagram/web/fetch_search_result` | 搜索用户/标签 | `keyword` |
| `GET /api/v1/instagram/web/fetch_hashtag_posts` | 标签内容 | `hashtag`, `cursor` |
---
## Twitter / X
### Twitter 网页版 API — `/api/v1/twitter/web/`
#### ★ `GET /api/v1/twitter/web/fetch_trending_topics`
> **本项目使用** · Twitter 热门话题和趋势推文
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `count` | integer | 否 | 返回数量,默认 20 |
响应结构(三种格式,需全部处理):
**格式 1推文数组**
```json
{
"data": {
"tweets": [
{
"id_str": "string",
"full_text": "string", // 优先使用
"text": "string", // 备用
"user": {
"name": "string",
"screen_name": "string", // 用户名(@后面的部分
"profile_image_url_https": "string"
},
"favorite_count": 5000, // 点赞数(→ like_count
"reply_count": 200, // 回复数(→ comment_count
"retweet_count": 300, // 转推数(→ share_count
"created_at": "Mon Jan 01 00:00:00 +0000 2024",
"entities": {
"media": [
{ "media_url_https": "string", "type": "photo | video" }
],
"hashtags": [{ "text": "string" }]
},
"extended_entities": {
"media": [/* 同上,优先使用 */]
}
}
]
}
}
```
**格式 2GraphQL 时间线Timeline Instructions**
```json
{
"data": {
"timeline": {
"instructions": [
{
"entries": [
{
"content": {
"itemContent": {
"tweet_results": {
"result": {
"legacy": { /* 同 TwitterTweet 格式 */ },
"core": {
"user_results": {
"result": {
"legacy": { /* TwitterUser 格式 */ }
}
}
}
}
}
}
}
}
]
}
]
}
}
}
```
**格式 3热门话题列表**
```json
{
"data": {
"trends": [
{
"name": "string", // 话题名称
"tweet_volume": 10000, // 推文量(→ play_count
"query": "string" // URL 编码的搜索查询
}
]
}
}
```
> **注意**
> - 时间格式:`"Mon Jan 01 00:00:00 +0000 2024"`,可直接用 `new Date()` 解析
> - 封面图来自 `extended_entities.media`(优先)或 `entities.media`,找 `type === 'photo'` 的条目
> - 原帖 URL 格式:`https://x.com/{screen_name}/status/{id_str}`
---
#### ★ `GET /api/v1/twitter/web/fetch_tweet_detail`
> **本项目使用** · 推文详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `tweet_id` | string | 是 | 推文 ID |
响应结构(两种格式):
```json
// 格式 1GraphQL
{
"data": {
"tweetResult": {
"result": {
"legacy": { /* TwitterTweet */ },
"core": { "user_results": { "result": { "legacy": { /* TwitterUser */ } } } }
}
}
}
}
// 格式 2直接对象
{
"data": {
"tweet": { /* TwitterTweet */ }
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/twitter/web/fetch_user_info` | 用户信息 | `screen_name` |
| `GET /api/v1/twitter/web/fetch_user_tweets` | 用户推文 | `screen_name`, `cursor`, `count` |
| `GET /api/v1/twitter/web/fetch_search_timeline` | 搜索推文 | `keyword`, `cursor`, `count` |
| `GET /api/v1/twitter/web/fetch_tweet_replies` | 推文回复 | `tweet_id`, `cursor` |
---
## 通用说明
### 本项目中使用的端点汇总
| 平台 | fetchHotList 端点 | fetchDetail 端点 |
|------|------------------|-----------------|
| 抖音 | `douyin/web/fetch_hot_search_result` | `douyin/web/fetch_one_video` → 回退 `fetch_hot_search_result` |
| TikTok | `tiktok/web/fetch_explore_post` | `tiktok/web/fetch_post_detail` |
| 小红书 | `xiaohongshu/web_v2/fetch_hot_list` + `xiaohongshu/app_v2/search_notes` | `xiaohongshu/app_v2/get_mixed_note_detail` |
| YouTube | `youtube/web/fetch_trending_video` | `youtube/web/fetch_video_detail` |
| Instagram | `instagram/web/fetch_explore_feed` | `instagram/web/fetch_post_detail` |
| Twitter | `twitter/web/fetch_trending_topics` | `twitter/web/fetch_tweet_detail` |
| 哔哩哔哩 | `bilibili/web/fetch_popular_video_list` | `bilibili/web/fetch_video_detail` |
| 微博 | `weibo/app/fetch_hot_search` | `weibo/app/fetch_post_detail` |
### 时间戳处理
| 平台 | 时间格式 | 处理方式 |
|------|---------|----------|
| 抖音、TikTok、小红书、B站、微博、Instagram | Unix 时间戳(秒)| `new Date(timestamp * 1000).toISOString()` |
| YouTube | ISO 8601 字符串 | 直接使用 |
| Twitter | `"Mon Jan 01 00:00:00 +0000 2024"` | `new Date(dateStr).toISOString()` |
| 微博 | `"Mon Jan 01 00:00:00 +0800 2024"` | `new Date(dateStr).toISOString()` |
### 图片 URL 类型
| 平台 | 图片字段类型 |
|------|------------|
| 抖音 | `url_list: string[]`(取 `[0]`|
| TikTok | `cover: string`(直接字符串)|
| 小红书 | `images_list[0].url_size_large \|\| url` |
| B站 | `pic: string`(直接字符串)|
| YouTube | `thumbnails.maxres.url`(对象嵌套)|
| Instagram | `image_versions2.candidates[0].url` |
| Twitter | `entities/extended_entities.media[0].media_url_https` |
| 微博 | `pic_infos[id].large.url``https://ww1.sinaimg.cn/large/{id}.jpg` |
---
*文档基于项目实际使用端点 + TikHub OpenAPI 规范整理。完整端点列表700+)请访问 https://api.tikhub.io*

496
e2e-real/real-e2e.spec.ts Normal file
View File

@ -0,0 +1,496 @@
/**
*
*
* mock E2E
* Next.js Hono TikHub API
*
*
* 1. localhost:3001 TikHub API Key
* 2. localhost:3000
*
*
* - API API
* - API
* -
*/
import { test, expect } from "@playwright/test";
const API_BASE = "http://localhost:3001";
const VALID_API_KEY =
process.env.TIKHUB_API_KEY ||
"gM8fC5kp2QikmV7IMZuR8D/TEAyDQbtE7jCE7n3bTuaTjmyeN1uNeh6AYA==";
// 全局串行执行 — 真实 E2E 共享后端状态,并行会互相干扰
test.describe.configure({ mode: "serial" });
// 每个测试给足 60 秒(外部 API 可能慢)
test.setTimeout(60_000);
// 确保后端已配置有效 API Key
test.beforeAll(async ({ request }) => {
await request.post(`${API_BASE}/api/settings`, {
data: { apiKey: VALID_API_KEY },
});
});
// ─── 首页流程 ───────────────────────────────────────────────
test.describe("真实 E2E: 首页", () => {
test("首页加载并渲染真实内容卡片", async ({ page }) => {
await page.goto("/");
// 等待内容网格出现(真实 API 可能较慢)
await expect(page.getByTestId("content-grid")).toBeVisible({
timeout: 45_000,
});
// 应至少有 1 张卡片(真实数据量不可预测,但不应为空)
const cards = page.getByTestId("content-card");
const count = await cards.count();
expect(count).toBeGreaterThan(0);
// 每张卡片应包含标题文本(非空)
const firstCardText = await cards.first().textContent();
expect(firstCardText?.trim().length).toBeGreaterThan(0);
});
test("平台切换后数据真实刷新", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible({
timeout: 45_000,
});
// 记录 "全部" 模式下的卡片数
const allCount = await page.getByTestId("content-card").count();
// 切换到抖音平台 — 需要等待新数据加载
await page.getByTestId("platform-tab-douyin").click();
// 等内容网格重新出现(切换平台会触发新请求)
// content-grid 在 loading 时不渲染,所以等它出现即表示新数据已到
await expect(page.getByTestId("content-grid")).toBeVisible({
timeout: 45_000,
});
const douyinCount = await page.getByTestId("content-card").count();
expect(douyinCount).toBeGreaterThan(0);
// 抖音数据应少于或等于全部
expect(douyinCount).toBeLessThanOrEqual(allCount);
});
for (const platform of ["youtube", "instagram", "twitter", "bilibili", "weibo"] as const) {
test(`平台 ${platform}: 切换加载内容`, async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible({
timeout: 45_000,
});
// 切换到目标平台
const tab = page.getByTestId(`platform-tab-${platform}`);
await expect(tab).toBeVisible({ timeout: 10_000 });
await tab.click();
// 等待内容加载content-grid 出现或 error/empty 出现
const grid = page.getByTestId("content-grid");
const errorState = page.getByText("加载失败");
const emptyState = page.getByTestId("empty-state");
await expect(
grid.or(errorState).or(emptyState)
).toBeVisible({ timeout: 45_000 });
// 如果成功加载了内容,验证卡片结构
if (await grid.isVisible()) {
const cards = page.getByTestId("content-card");
const count = await cards.count();
if (count > 0) {
// 卡片应包含非空文本
const firstText = await cards.first().textContent();
expect(firstText?.trim().length).toBeGreaterThan(0);
}
}
});
}
test("排序功能改变卡片顺序", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible({
timeout: 45_000,
});
// 获取默认排序下第一张卡片标题
const firstTitleBefore = await page
.getByTestId("content-card")
.first()
.textContent();
// 切换排序字段到 "发布时间"
await page.getByTestId("sort-select").selectOption("publish_time");
// 切换后第一张卡片可能不同(不是必然,但结构应完整)
const firstTitleAfter = await page
.getByTestId("content-card")
.first()
.textContent();
// 至少卡片仍然存在
expect(firstTitleAfter?.trim().length).toBeGreaterThan(0);
// 切换排序顺序(升序/降序)
await page.getByTestId("sort-order").click();
const firstTitleToggled = await page
.getByTestId("content-card")
.first()
.textContent();
expect(firstTitleToggled?.trim().length).toBeGreaterThan(0);
});
});
// ─── 详情页流程 ──────────────────────────────────────────────
test.describe("真实 E2E: 详情页", () => {
test("从首页点击卡片进入详情页", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible({
timeout: 45_000,
});
// 点击第一张卡片
await page.getByTestId("content-card").first().click();
// URL 应跳转到 /detail/平台/ID
await expect(page).toHaveURL(/\/detail\/\w+\/.+/, { timeout: 10_000 });
// 详情页应显示:标题、作者、统计数据
await expect(page.getByText("播放")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("点赞")).toBeVisible();
await expect(page.getByText("收藏")).toBeVisible();
// 查看原文按钮应存在
await expect(page.getByTestId("view-original")).toBeVisible();
await expect(page.getByTestId("view-original")).toContainText("查看原文");
});
test("详情页返回按钮回到首页", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible({
timeout: 45_000,
});
// 进入详情
await page.getByTestId("content-card").first().click();
await expect(page).toHaveURL(/\/detail\//, { timeout: 10_000 });
await expect(page.getByTestId("detail-back")).toBeVisible({
timeout: 30_000,
});
// 点击返回
await page.getByTestId("detail-back").click();
await expect(page).toHaveURL("/", { timeout: 10_000 });
});
for (const platform of ["youtube", "instagram", "twitter", "bilibili", "weibo"] as const) {
test(`平台 ${platform}: 详情页可正常打开`, async ({ page }) => {
// 平台详情测试需要更长时间:页面加载 + 平台切换 + 详情导航
test.setTimeout(120_000);
await page.goto("/");
// 首页可能因限流而加载失败,接受 grid/error/empty 任一状态
await expect(
page.getByTestId("content-grid")
.or(page.getByText("加载失败"))
.or(page.getByTestId("empty-state"))
).toBeVisible({ timeout: 45_000 });
// 切换到目标平台
await page.getByTestId(`platform-tab-${platform}`).click();
// 等待卡片链接更新为目标平台(或确认无内容)
let hasContent = false;
try {
await page.waitForFunction(
(p: string) => {
const card = document.querySelector('[data-testid="content-card"]');
if (!card) return false;
const href = card.getAttribute("href") || "";
return href.includes(`/detail/${p}/`);
},
platform,
{ timeout: 45_000 }
);
hasContent = true;
} catch {
// 超时说明平台无数据或加载失败
hasContent = false;
}
if (!hasContent) {
test.skip(true, `${platform} API 未返回内容(可能是地域/配额限制),跳过详情页验证`);
return;
}
const cards = page.getByTestId("content-card");
// 点击第一张卡片
await cards.first().click();
// URL 应跳转到对应平台的详情页
await expect(page).toHaveURL(
new RegExp(`/detail/${platform}/.+`),
{ timeout: 10_000 }
);
// 详情页应有查看原文按钮和返回按钮
await expect(page.getByTestId("view-original")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("detail-back")).toBeVisible();
// 返回首页
await page.getByTestId("detail-back").click();
await expect(page).toHaveURL("/", { timeout: 10_000 });
});
}
});
// ─── 收藏流程 ────────────────────────────────────────────────
test.describe("真实 E2E: 收藏", () => {
test("收藏真实内容并在收藏页查看", async ({ page }) => {
// 先清除可能残留的 localStorage
await page.goto("/");
await page.evaluate(() => localStorage.clear());
await page.reload();
await expect(page.getByTestId("content-grid")).toBeVisible({
timeout: 45_000,
});
// 点击第一张卡片的收藏按钮
const firstFavBtn = page.getByTestId("favorite-btn").first();
await expect(firstFavBtn).toHaveAttribute("aria-label", "收藏");
await firstFavBtn.click();
await expect(firstFavBtn).toHaveAttribute("aria-label", "取消收藏");
// 跳转到收藏页
await page.goto("/favorites");
await expect(page.getByTestId("favorites-count")).toContainText("1 个内容");
await expect(page.getByTestId("content-card")).toHaveCount(1);
});
test("取消收藏后收藏页为空", async ({ page }) => {
// 清理后重新收藏
await page.goto("/");
await page.evaluate(() => localStorage.clear());
await page.reload();
await expect(page.getByTestId("content-grid")).toBeVisible({
timeout: 45_000,
});
// 添加收藏
await page.getByTestId("favorite-btn").first().click();
await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute(
"aria-label",
"取消收藏"
);
// 进入收藏页
await page.goto("/favorites");
await expect(page.getByTestId("content-card")).toHaveCount(1);
// 取消收藏
await page.getByTestId("favorite-btn").first().click();
await expect(page.getByTestId("content-card")).toHaveCount(0);
await expect(page.getByText("还没有收藏")).toBeVisible();
});
});
// ─── 设置流程 ────────────────────────────────────────────────
// 设置测试放在最后,因为 "保存 API Key" 会污染后端状态
test.describe("真实 E2E: 设置", () => {
test("设置页加载并可配置 API Key", async ({ page }) => {
await page.goto("/settings");
// 页面基本结构
await expect(page.getByRole("heading", { name: "设置" })).toBeVisible();
await expect(page.getByText("TikHub API Key")).toBeVisible();
await expect(page.getByTestId("apikey-input")).toBeVisible();
await expect(page.getByTestId("apikey-save")).toBeVisible();
});
test("空 Key 提交被前端拦截", async ({ page }) => {
await page.goto("/settings");
await page.evaluate(() => localStorage.clear());
await page.reload();
await page.getByTestId("apikey-save").click();
await expect(page.getByText("请输入 API Key")).toBeVisible();
});
test("刷新间隔选择器工作正常", async ({ page }) => {
await page.goto("/settings");
// 默认 30 分钟被选中
const thirtyBtn = page.getByRole("button", { name: "30 分钟" });
await expect(thirtyBtn).toHaveClass(/border-blue-500/);
// 点击 10 分钟
const tenBtn = page.getByRole("button", { name: "10 分钟" });
await tenBtn.click();
await expect(tenBtn).toHaveClass(/border-blue-500/);
await expect(thirtyBtn).not.toHaveClass(/border-blue-500/);
});
test("保存 API Key 走真实后端并收到成功反馈", async ({ page, request }) => {
await page.goto("/settings");
// 输入一个测试 Key 并保存(走真实 POST /api/settings
await page.getByTestId("apikey-input").fill("test-real-e2e-key");
await page.getByTestId("apikey-save").click();
// 验证成功 toast
await expect(page.getByText("API Key 已保存")).toBeVisible({
timeout: 10_000,
});
// 恢复有效的 API Key避免污染后续测试或开发环境
await request.post(`${API_BASE}/api/settings`, {
data: { apiKey: VALID_API_KEY },
});
});
});
// ─── 全链路冒烟测试 ──────────────────────────────────────────
test.describe("真实 E2E: 全链路冒烟", () => {
for (const platform of ["youtube", "bilibili", "weibo"] as const) {
test(`平台 ${platform}: 完整用户旅程 (列表 → 详情 → 收藏)`, async ({ page }) => {
// 完整旅程需要更长时间
test.setTimeout(120_000);
// 清理状态
await page.goto("/");
await page.evaluate(() => localStorage.clear());
await page.reload();
await expect(page.getByTestId("content-grid")).toBeVisible({
timeout: 45_000,
});
// 1. 切换到目标平台
await page.getByTestId(`platform-tab-${platform}`).click();
// 等待内容加载或空/错误状态
const grid = page.getByTestId("content-grid");
const errorState = page.getByText("加载失败");
const emptyState = page.getByTestId("empty-state");
await expect(
grid.or(errorState).or(emptyState)
).toBeVisible({ timeout: 45_000 });
// 等待卡片链接更新为目标平台(或确认无内容)
let hasContent = false;
try {
await page.waitForFunction(
(p: string) => {
const card = document.querySelector('[data-testid="content-card"]');
if (!card) return false;
const href = card.getAttribute("href") || "";
return href.includes(`/detail/${p}/`);
},
platform,
{ timeout: 15_000 }
);
hasContent = true;
} catch {
hasContent = false;
}
if (!hasContent) {
test.skip(true, `${platform} API 未返回内容(可能是地域/配额限制),跳过完整旅程验证`);
return;
}
const cards = page.getByTestId("content-card");
// 2. 收藏第一张卡片
const favBtn = page.getByTestId("favorite-btn").first();
await favBtn.click();
await expect(favBtn).toHaveAttribute("aria-label", "取消收藏");
// 3. 点击卡片进入详情
await cards.first().click();
await expect(page).toHaveURL(
new RegExp(`/detail/${platform}/.+`),
{ timeout: 10_000 }
);
// 4. 详情页应有查看原文按钮和返回按钮
await expect(page.getByTestId("view-original")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("detail-back")).toBeVisible();
// 5. 返回首页
await page.getByTestId("detail-back").click();
await expect(page).toHaveURL("/", { timeout: 10_000 });
// 6. 进入收藏页验证
await page.goto("/favorites");
await expect(page.getByTestId("content-card")).toHaveCount(1);
});
}
test("完整用户旅程: 首页 → 详情 → 收藏 → 收藏页", async ({ page }) => {
// 清理状态
await page.goto("/");
await page.evaluate(() => localStorage.clear());
await page.reload();
// 1. 首页加载内容
await expect(page.getByTestId("content-grid")).toBeVisible({
timeout: 45_000,
});
const cardCount = await page.getByTestId("content-card").count();
expect(cardCount).toBeGreaterThan(0);
// 2. 记住第一张卡片的标题用于后续验证
const firstCardTitle = await page
.getByTestId("content-card")
.first()
.locator("h3")
.textContent();
// 3. 收藏第一张卡片
await page.getByTestId("favorite-btn").first().click();
await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute(
"aria-label",
"取消收藏"
);
// 4. 点击第一张卡片进入详情
await page.getByTestId("content-card").first().click();
await expect(page).toHaveURL(/\/detail\//, { timeout: 10_000 });
// 5. 详情页应显示统计信息
await expect(page.getByText("播放")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("view-original")).toBeVisible();
// 6. 返回首页
await page.getByTestId("detail-back").click();
await expect(page).toHaveURL("/", { timeout: 10_000 });
// 7. 进入收藏页查看已收藏内容
await page.goto("/favorites");
await expect(page.getByTestId("favorites-count")).toContainText("1 个内容");
const favCard = page.getByTestId("content-card");
await expect(favCard).toHaveCount(1);
// 8. 收藏页的卡片标题应与首页收藏的一致
if (firstCardTitle) {
await expect(favCard.first().locator("h3")).toContainText(firstCardTitle);
}
});
});

96
e2e/detail.spec.ts Normal file
View File

@ -0,0 +1,96 @@
import { test, expect, type Page } from "@playwright/test";
import { mockContentList, mockDetailItem } from "./fixtures";
async function mockAPIs(page: Page) {
await page.route(/\/api\/tikhub\//, async (route) => {
const url = route.request().url();
if (url.includes("/detail")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: mockDetailItem }),
});
} else {
const match = url.match(/\/api\/tikhub\/(\w+)/);
const platform = match?.[1];
const items = mockContentList.filter((i) => i.platform === platform);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: items }),
});
}
});
}
test.describe("详情页流程", () => {
test("详情加载", async ({ page }) => {
await mockAPIs(page);
await page.goto("/detail/douyin/dy-001");
// Verify title
await expect(page.getByText("创意摄影技巧")).toBeVisible();
// Verify author
await expect(page.getByText("摄影师小明")).toBeVisible();
// Verify stats panel
await expect(page.getByText("播放")).toBeVisible();
await expect(page.getByText("点赞")).toBeVisible();
});
test("标签显示", async ({ page }) => {
await mockAPIs(page);
await page.goto("/detail/douyin/dy-001");
// mockDetailItem has tags: ['摄影', '创意', '技巧']
await expect(page.getByText("#摄影")).toBeVisible();
await expect(page.getByText("#创意")).toBeVisible();
await expect(page.getByText("#技巧")).toBeVisible();
});
test("查看原文按钮", async ({ page }) => {
await mockAPIs(page);
await page.goto("/detail/douyin/dy-001");
await expect(page.getByTestId("view-original")).toBeVisible();
await expect(page.getByTestId("view-original")).toContainText("查看原文");
});
test("返回导航", async ({ page }) => {
await mockAPIs(page);
// Navigate to home first to build browser history
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible();
// Then navigate to detail
await page.goto("/detail/douyin/dy-001");
await expect(page.getByText("创意摄影技巧")).toBeVisible();
// Click back button
await page.getByTestId("detail-back").click();
await expect(page).toHaveURL("/");
});
test("详情加载失败", async ({ page }) => {
await page.route(/\/api\/tikhub\//, async (route) => {
const url = route.request().url();
if (url.includes("/detail")) {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "服务器错误" }),
});
} else {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [] }),
});
}
});
await page.goto("/detail/douyin/dy-001");
await expect(page.getByText("加载失败")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("重试")).toBeVisible();
await expect(page.getByText("返回首页")).toBeVisible();
});
});

104
e2e/favorites.spec.ts Normal file
View File

@ -0,0 +1,104 @@
import { test, expect, type Page } from "@playwright/test";
import { mockContentList } from "./fixtures";
async function mockTrendingAPI(page: Page) {
await page.route(/\/api\/tikhub\//, async (route) => {
const match = route.request().url().match(/\/api\/tikhub\/(\w+)/);
const platform = match?.[1];
const items = mockContentList.filter((i) => i.platform === platform);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: items }),
});
});
}
test.describe("收藏流程", () => {
test("添加收藏", async ({ page }) => {
await mockTrendingAPI(page);
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible();
// First favorite button should be "收藏" (not favorited)
const firstFavBtn = page.getByTestId("favorite-btn").first();
await expect(firstFavBtn).toHaveAttribute("aria-label", "收藏");
// Click to favorite
await firstFavBtn.click();
// Should now be "取消收藏" (favorited)
await expect(firstFavBtn).toHaveAttribute("aria-label", "取消收藏");
});
test("收藏页面显示收藏内容", async ({ page }) => {
await mockTrendingAPI(page);
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible();
// Favorite the first card
await page.getByTestId("favorite-btn").first().click();
// Navigate to favorites
await page.goto("/favorites");
// Verify count
await expect(page.getByTestId("favorites-count")).toContainText("1 个内容");
// Verify the card is shown
await expect(page.getByTestId("content-card")).toHaveCount(1);
});
test("取消收藏", async ({ page }) => {
await mockTrendingAPI(page);
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible();
// Add favorite
await page.getByTestId("favorite-btn").first().click();
await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute(
"aria-label",
"取消收藏"
);
// Navigate to favorites
await page.goto("/favorites");
await expect(page.getByTestId("content-card")).toHaveCount(1);
// Remove favorite
await page.getByTestId("favorite-btn").first().click();
// Card should be gone, empty state shown
await expect(page.getByTestId("content-card")).toHaveCount(0);
await expect(page.getByText("还没有收藏")).toBeVisible();
});
test("空收藏状态", async ({ page }) => {
await page.goto("/favorites");
await expect(page.getByText("还没有收藏")).toBeVisible();
await expect(page.getByText("去发现")).toBeVisible();
});
test("收藏持久化", async ({ page }) => {
await mockTrendingAPI(page);
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible();
// Add favorite
await page.getByTestId("favorite-btn").first().click();
await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute(
"aria-label",
"取消收藏"
);
// Reload the page — localStorage should persist
await page.reload();
await expect(page.getByTestId("content-grid")).toBeVisible();
// Verify favorite persisted
await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute(
"aria-label",
"取消收藏"
);
});
});

148
e2e/fixtures.ts Normal file
View File

@ -0,0 +1,148 @@
export const mockContentList = [
{
id: "dy-001",
title: "抖音热门视频测试内容 - 创意摄影技巧",
cover_url: "",
author_name: "摄影师小明",
author_avatar: "",
play_count: 1_500_000,
like_count: 80_000,
collect_count: 12_000,
comment_count: 3_500,
share_count: 5_000,
publish_time: "2024-01-15T08:00:00Z",
platform: "douyin",
original_url: "https://www.douyin.com/video/dy-001",
tags: ["摄影", "创意", "技巧"],
},
{
id: "tt-002",
title: "TikTok Viral Dance Challenge 2024",
cover_url: "",
author_name: "DanceMaster",
author_avatar: "",
play_count: 5_000_000,
like_count: 250_000,
collect_count: 45_000,
comment_count: 12_000,
share_count: 80_000,
publish_time: "2024-01-14T12:00:00Z",
platform: "tiktok",
original_url: "https://www.tiktok.com/@user/video/tt-002",
tags: ["dance", "viral", "challenge"],
},
{
id: "xhs-003",
title: "小红书美食探店 | 隐藏在街角的宝藏咖啡馆",
cover_url: "",
author_name: "美食家小红",
author_avatar: "",
play_count: 300_000,
like_count: 45_000,
collect_count: 28_000,
comment_count: 8_000,
share_count: 3_000,
publish_time: "2024-01-13T16:00:00Z",
platform: "xiaohongshu",
original_url: "https://www.xiaohongshu.com/explore/xhs-003",
tags: ["美食", "探店", "咖啡"],
},
{
id: "dy-004",
title: "抖音生活小妙招合集",
cover_url: "",
author_name: "生活达人",
author_avatar: "",
play_count: 800_000,
like_count: 35_000,
collect_count: 20_000,
comment_count: 5_000,
share_count: 15_000,
publish_time: "2024-01-12T10:00:00Z",
platform: "douyin",
original_url: "https://www.douyin.com/video/dy-004",
tags: [],
},
{
id: "yt-005",
title: "YouTube Top Music Video of 2024",
cover_url: "",
author_name: "MusicChannel",
author_avatar: "",
play_count: 10_000_000,
like_count: 500_000,
collect_count: 0,
comment_count: 50_000,
share_count: 100_000,
publish_time: "2024-01-16T10:00:00Z",
platform: "youtube",
original_url: "https://www.youtube.com/watch?v=yt-005",
tags: ["music", "trending"],
},
{
id: "ig-006",
title: "Instagram Explore: Stunning Photography",
cover_url: "",
author_name: "photoartist",
author_avatar: "",
play_count: 0,
like_count: 120_000,
collect_count: 30_000,
comment_count: 8_000,
share_count: 15_000,
publish_time: "2024-01-17T14:00:00Z",
platform: "instagram",
original_url: "https://www.instagram.com/p/ig-006/",
tags: [],
},
{
id: "tw-007",
title: "Breaking: Major tech announcement shakes the industry",
cover_url: "",
author_name: "TechReporter",
author_avatar: "",
play_count: 0,
like_count: 200_000,
collect_count: 50_000,
comment_count: 25_000,
share_count: 80_000,
publish_time: "2024-01-18T09:00:00Z",
platform: "twitter",
original_url: "https://twitter.com/i/status/tw-007",
tags: ["tech", "breaking"],
},
{
id: "bl-008",
title: "B站年度最佳科技视频解析",
cover_url: "",
author_name: "科技UP主",
author_avatar: "",
play_count: 3_000_000,
like_count: 150_000,
collect_count: 80_000,
comment_count: 20_000,
share_count: 40_000,
publish_time: "2024-01-19T11:00:00Z",
platform: "bilibili",
original_url: "https://www.bilibili.com/video/bl-008",
tags: ["科技"],
},
{
id: "wb-009",
title: "微博热搜:春节档电影票房破纪录",
cover_url: "",
author_name: "娱乐博主",
author_avatar: "",
play_count: 2_000_000,
like_count: 90_000,
collect_count: 10_000,
comment_count: 35_000,
share_count: 60_000,
publish_time: "2024-01-20T08:00:00Z",
platform: "weibo",
original_url: "https://weibo.com/u/wb-009",
tags: [],
},
];
export const mockDetailItem = mockContentList[0];

134
e2e/home.spec.ts Normal file
View File

@ -0,0 +1,134 @@
import { test, expect, type Page } from "@playwright/test";
import { mockContentList, mockDetailItem } from "./fixtures";
async function mockTrendingAPI(page: Page, items = mockContentList) {
await page.route(/\/api\/tikhub\//, async (route) => {
const url = route.request().url();
if (url.includes("/detail")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: mockDetailItem }),
});
return;
}
const match = url.match(/\/api\/tikhub\/(\w+)/);
const platform = match?.[1];
const filtered = items.filter((i) => i.platform === platform);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: filtered }),
});
});
}
test.describe("首页浏览流程", () => {
test("首页加载并显示内容卡片", async ({ page }) => {
await mockTrendingAPI(page);
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible();
const cards = page.getByTestId("content-card");
await expect(cards).toHaveCount(9);
});
test("平台切换", async ({ page }) => {
await mockTrendingAPI(page);
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible();
// Switch to douyin — 2 items (dy-001, dy-004)
await page.getByTestId("platform-tab-douyin").click();
await expect(page.getByTestId("content-card")).toHaveCount(2);
// Switch to tiktok — 1 item (tt-002)
await page.getByTestId("platform-tab-tiktok").click();
await expect(page.getByTestId("content-card")).toHaveCount(1);
// Switch to xiaohongshu — 1 item (xhs-003)
await page.getByTestId("platform-tab-xiaohongshu").click();
await expect(page.getByTestId("content-card")).toHaveCount(1);
// Switch to youtube — 1 item (yt-005)
await page.getByTestId("platform-tab-youtube").click();
await expect(page.getByTestId("content-card")).toHaveCount(1);
// Switch to instagram — 1 item (ig-006)
await page.getByTestId("platform-tab-instagram").click();
await expect(page.getByTestId("content-card")).toHaveCount(1);
// Switch to twitter — 1 item (tw-007)
await page.getByTestId("platform-tab-twitter").click();
await expect(page.getByTestId("content-card")).toHaveCount(1);
// Switch to bilibili — 1 item (bl-008)
await page.getByTestId("platform-tab-bilibili").click();
await expect(page.getByTestId("content-card")).toHaveCount(1);
// Switch to weibo — 1 item (wb-009)
await page.getByTestId("platform-tab-weibo").click();
await expect(page.getByTestId("content-card")).toHaveCount(1);
// Switch back to all — 9 items
await page.getByTestId("platform-tab-all").click();
await expect(page.getByTestId("content-card")).toHaveCount(9);
});
test("排序功能", async ({ page }) => {
await mockTrendingAPI(page);
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible();
// Default: play_count desc — YouTube (10M) first
await expect(page.getByTestId("content-card").first()).toContainText(
"YouTube Top Music"
);
// Switch sort field to like_count
await page.getByTestId("sort-select").selectOption("like_count");
// like_count desc: yt-005(500K) first
await expect(page.getByTestId("content-card").first()).toContainText(
"YouTube Top Music"
);
// Toggle to ascending
await page.getByTestId("sort-order").click();
// like_count asc: dy-004(35K) first
await expect(page.getByTestId("content-card").first()).toContainText(
"生活小妙招"
);
});
test("卡片导航到详情页", async ({ page }) => {
await mockTrendingAPI(page);
await page.goto("/");
await expect(page.getByTestId("content-grid")).toBeVisible();
// Default sort: play_count desc → first card is YouTube (yt-005, 10M)
await page.getByTestId("content-card").first().click();
await expect(page).toHaveURL(/\/detail\/youtube\/yt-005/);
});
test("空状态", async ({ page }) => {
await mockTrendingAPI(page, []);
await page.goto("/");
await expect(page.getByTestId("empty-state")).toBeVisible();
await expect(page.getByText("暂无内容")).toBeVisible();
});
test("错误状态和重试", async ({ page }) => {
await page.route(/\/api\/tikhub\//, async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "服务器错误" }),
});
});
await page.goto("/");
await expect(page.getByText("加载失败")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("error-retry")).toBeVisible();
});
});

73
e2e/settings.spec.ts Normal file
View File

@ -0,0 +1,73 @@
import { test, expect } from "@playwright/test";
test.describe("设置流程", () => {
test("导航到设置页", async ({ page }) => {
await page.goto("/settings");
await expect(page.getByRole("heading", { name: "设置" })).toBeVisible();
await expect(page.getByText("TikHub API Key")).toBeVisible();
});
test("保存 API Key", async ({ page }) => {
await page.route(/\/api\/settings/, async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true }),
});
}
});
await page.goto("/settings");
await page.getByTestId("apikey-input").fill("test-api-key-123");
await page.getByTestId("apikey-save").click();
// Verify success toast
await expect(page.getByText("API Key 已保存")).toBeVisible();
});
test("空 Key 校验", async ({ page }) => {
await page.goto("/settings");
await page.getByTestId("apikey-save").click();
// Verify error toast
await expect(page.getByText("请输入 API Key")).toBeVisible();
});
test("保存失败", async ({ page }) => {
await page.route(/\/api\/settings/, async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "服务器错误" }),
});
}
});
await page.goto("/settings");
await page.getByTestId("apikey-input").fill("test-api-key-123");
await page.getByTestId("apikey-save").click();
// Verify failure toast
await expect(page.getByText("保存失败,请重试")).toBeVisible();
});
test("刷新间隔切换", async ({ page }) => {
await page.goto("/settings");
// Default is 30 minutes — it should have selected styles
const thirtyMinBtn = page.getByRole("button", { name: "30 分钟" });
await expect(thirtyMinBtn).toHaveClass(/border-blue-500/);
// Click 10 minutes
const tenMinBtn = page.getByRole("button", { name: "10 分钟" });
await tenMinBtn.click();
// 10 min should now be selected
await expect(tenMinBtn).toHaveClass(/border-blue-500/);
// 30 min should be deselected
await expect(thirtyMinBtn).not.toHaveClass(/border-blue-500/);
});
});

View File

@ -0,0 +1,641 @@
# CI/CD 集成方案(最终落地版)
## 概述
本文档记录抖音评论管理系统的 CI/CD 方案:**Drone CI + 私有 Docker Registry + Docker Compose 自动部署**。
目标:
- 每天凌晨 0 点(北京时间)自动拉取 `main` 最新代码并部署
- 创建 Tag`v1.9.0210.7`)时自动构建并部署
- 不在每次 `push` 时触发
- 生产环境继续沿用 `docker-compose.prod.yml`
---
## 基础设施
| 角色 | 位置 | 说明 |
|------|------|------|
| Gitea 服务器 | 腾讯云 `git.internal.intelligrow.cn` | 代码仓库 |
| Drone CI 服务器 | Ubuntu x64 `192.168.31.107` | 执行构建任务 + 运行私有 Docker Registry |
| 生产服务器 | Ubuntu x64 `192.168.31.48` | 运行 Docker Compose 生产服务SSH 端口 3141 |
---
## 架构流程
```text
定时触发cron或 创建 tag
|
v
Drone 拉取代码
|
+-- 构建 backend 镜像并推送 Registry
+-- 构建 frontend 镜像并推送 Registry
v
SSH 到生产服务器执行 deploy-remote.sh
|
+-- pull 新镜像
+-- 停止 celery_beat
+-- 更新 backend + celery_worker
+-- 健康检查(容器内 /health
+-- alembic upgrade head失败即中断
+-- 启动 celery_beat + frontend
+-- 最终健康检查
v
发送企业微信通知(成功/失败)
```
---
## 文件结构
```text
.drone.yml # Drone CI 流水线
scripts/
+-- deploy.sh # 原有手动部署脚本
+-- deploy-remote.sh # Drone 远程调用的自动部署脚本
+-- server-setup.sh # 原有服务器初始化脚本
docker-compose.prod.yml # 生产环境 compose 配置
docs/
+-- cicd_integration_updated.md # 本文档
```
---
## Phase 1Drone CI 服务器准备(一次性)
### 1.1 Drone CI 服务 (docker-compose.yml)
在 Drone CI 服务器 `~/drone/docker-compose.yml`
```yaml
services:
drone-server:
image: drone/drone:2
container_name: drone-server
restart: always
ports:
- "3080:80"
environment:
- DRONE_GITEA_SERVER=https://git.internal.intelligrow.cn
- DRONE_GITEA_CLIENT_ID=<your-client-id>
- DRONE_GITEA_CLIENT_SECRET=<your-client-secret>
- DRONE_SERVER_HOST=drone.internal.intelligrow.cn
- DRONE_SERVER_PROTO=https
- DRONE_RPC_SECRET=<your-rpc-secret>
- DRONE_USER_CREATE=username:<your-gitea-username>,admin:true
volumes:
- ./data:/data
drone-runner:
image: drone/drone-runner-docker:1
container_name: drone-runner
restart: always
depends_on:
- drone-server
environment:
- DRONE_RPC_PROTO=http
- DRONE_RPC_HOST=drone-server
- DRONE_RPC_SECRET=<your-rpc-secret>
- DRONE_RUNNER_CAPACITY=2
- DRONE_RUNNER_NAME=drone-runner-1
- DRONE_RUNNER_PRIVILEGED_IMAGES=plugins/docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
关键配置说明:
- **`DRONE_RPC_PROTO=http`**Runner 在 Docker 内网直连 drone-server 容器的 80 端口,不走 HTTPS。外部通过反向代理 `drone.internal.intelligrow.cn` 访问时才用 HTTPS。
- **`DRONE_USER_CREATE`**`username` 必须与 Gitea 登录用户名完全一致(不是邮箱),否则管理员权限不生效。
- **`DRONE_RUNNER_PRIVILEGED_IMAGES=plugins/docker`**:允许 plugins/docker 以特权模式运行(本方案最终未使用 plugins/docker但保留配置以备后用
### 1.2 启动私有 Registry
在 Drone CI 服务器执行:
```bash
docker run -d --name registry \
-p 5000:5000 \
-v /opt/registry-data:/var/lib/registry \
--restart always \
registry:2
```
验证:
```bash
curl http://localhost:5000/v2/_catalog
# 预期输出: {"repositories":[]}
```
### 1.3 配置 insecure registry
**Drone CI 服务器** `/etc/docker/daemon.json`
```json
{
"registry-mirrors": [
"https://docker.1panel.live",
"https://docker.1panel.dev",
"https://docker.1ms.run"
],
"insecure-registries": [
"docker.internal.intelligrow.cn:5000",
"192.168.31.107:5000"
]
}
```
**生产服务器** `/etc/docker/daemon.json`
```json
{
"registry-mirrors": [
"https://docker.1panel.live",
"https://docker.1panel.dev",
"https://docker.1ms.run"
],
"insecure-registries": ["docker.internal.intelligrow.cn:5000"]
}
```
修改后重启 Docker
```bash
sudo systemctl restart docker
```
> 注意:`insecure-registries` 中不要带 `http://` 前缀,直接写 `host:port` 格式。
### 1.4 配置 SSH 免密
在 Drone CI 服务器生成密钥并添加到生产服务器:
```bash
ssh-keygen -t ed25519 -C "drone-ci" -f ~/.ssh/drone_deploy -N ""
ssh-copy-id -i ~/.ssh/drone_deploy.pub -p 3141 miaosi@192.168.31.48
```
验证:
```bash
ssh -i ~/.ssh/drone_deploy -p 3141 miaosi@192.168.31.48 "echo ok"
```
---
## Phase 2Drone 仓库设置
### 2.1 开启 Trusted 模式
在 Drone 面板 → 仓库 Settings → General → Project Settings → 勾选 **Trusted**
> 需要管理员权限。如果看不到 Trusted 选项,检查 `DRONE_USER_CREATE` 的 username 是否与 Gitea 用户名一致。
### 2.2 配置 Secrets
在 Drone 面板(仓库设置 → Secrets添加
| Secret 名称 | 用途 | 实际值示例 |
|-------------|------|-----------|
| `backend_repo` | 后端镜像完整仓库地址 | `docker.internal.intelligrow.cn:5000/douyin-backend` |
| `frontend_repo` | 前端镜像完整仓库地址 | `docker.internal.intelligrow.cn:5000/douyin-frontend` |
| `deploy_host` | 生产服务器 IP | `192.168.31.48` |
| `deploy_user` | SSH 用户 | `miaosi` |
| `deploy_ssh_key` | SSH 私钥内容 | `-----BEGIN OPENSSH PRIVATE KEY-----...-----END OPENSSH PRIVATE KEY-----` |
| `deploy_path` | 生产服务器部署目录 | `/opt/docker/douyin_comments_management` |
| `wecom_webhook` | 企业微信 Webhook可选 | `https://qyapi.weixin.qq.com/...` |
> 注意:`backend_repo``frontend_repo` 的 Registry 地址必须与生产服务器 `.env` 中的 `DOCKER_REGISTRY` 使用相同的主机名(都用域名或都用 IP否则 Docker 会认为是不同的镜像。
### 2.3 配置 Cron Job
在 Drone 面板(仓库设置 → Cron Jobs添加
| 字段 | 值 |
|------|------|
| Name | `nightly-build` |
| Branch | `main` |
| Schedule | `0 16 * * *` |
说明:
- Drone 默认按 UTC 解释 Cron
- `0 16 * * *` = UTC 16:00 = 北京时间次日 00:00
---
## Phase 3配置文件最终落地版本
### 3.1 `.drone.yml`
```yaml
kind: pipeline
type: docker
name: build-and-deploy
trigger:
event:
- tag
- cron
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
steps:
- name: build-backend
image: docker:27-cli
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
BACKEND_REPO:
from_secret: backend_repo
commands:
- '[ -n "$BACKEND_REPO" ] || (echo "backend_repo secret is empty" && exit 1)'
- echo "Building backend image tag:${DRONE_TAG:-latest}"
- docker build -t "$BACKEND_REPO:${DRONE_TAG:-latest}" -t "$BACKEND_REPO:latest" ./backend
- docker push "$BACKEND_REPO:${DRONE_TAG:-latest}"
- docker push "$BACKEND_REPO:latest"
- name: build-frontend
image: docker:27-cli
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
FRONTEND_REPO:
from_secret: frontend_repo
commands:
- '[ -n "$FRONTEND_REPO" ] || (echo "frontend_repo secret is empty" && exit 1)'
- echo "Building frontend image tag:${DRONE_TAG:-latest}"
- docker build -t "$FRONTEND_REPO:${DRONE_TAG:-latest}" -t "$FRONTEND_REPO:latest" ./frontend
- docker push "$FRONTEND_REPO:${DRONE_TAG:-latest}"
- docker push "$FRONTEND_REPO:latest"
- name: deploy
image: appleboy/drone-ssh
environment:
DEPLOY_PATH:
from_secret: deploy_path
settings:
host:
from_secret: deploy_host
username:
from_secret: deploy_user
key:
from_secret: deploy_ssh_key
port: 3141
command_timeout: 1800s
script_stop: true
envs:
- DRONE_TAG
- DEPLOY_PATH
script:
- IMAGE_TAG="$DRONE_TAG"; [ -n "$IMAGE_TAG" ] || IMAGE_TAG="latest"
- cd "$DEPLOY_PATH"
- bash scripts/deploy-remote.sh "$IMAGE_TAG"
- name: notify-success
image: curlimages/curl
environment:
WECOM_WEBHOOK:
from_secret: wecom_webhook
commands:
- |
if [ -n "${WECOM_WEBHOOK:-}" ]; then
VERSION="$DRONE_TAG"
[ -n "$VERSION" ] || VERSION="nightly-$(date +%Y%m%d)"
curl -sS -X POST "$WECOM_WEBHOOK" \
-H "Content-Type: application/json" \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"✅ 部署成功\\n版本: ${VERSION}\\n仓库: ${DRONE_REPO}\\n时间: $(date '+%Y-%m-%d %H:%M:%S')\"}}"
fi
when:
status:
- success
- name: notify-failure
image: curlimages/curl
environment:
WECOM_WEBHOOK:
from_secret: wecom_webhook
commands:
- |
if [ -n "${WECOM_WEBHOOK:-}" ]; then
VERSION="$DRONE_TAG"
[ -n "$VERSION" ] || VERSION="nightly-$(date +%Y%m%d)"
curl -sS -X POST "$WECOM_WEBHOOK" \
-H "Content-Type: application/json" \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"❌ 部署失败\\n版本: ${VERSION}\\n仓库: ${DRONE_REPO}\\n构建: ${DRONE_BUILD_LINK}\"}}"
fi
when:
status:
- failure
```
### 3.2 `scripts/deploy-remote.sh`
```bash
#!/usr/bin/env bash
# ========================================
# 远程部署脚本 - 被 Drone CI SSH 调用
# ========================================
# 用法: bash scripts/deploy-remote.sh [image_tag]
set -euo pipefail
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
DEPLOY_PATH="${DEPLOY_PATH:-/opt/docker/douyin_comments_management}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
IMAGE_TAG="${1:-latest}"
LOCK_FILE="/tmp/douyin-deploy.lock"
MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}"
cleanup_lock() {
rm -f "$LOCK_FILE"
}
acquire_lock() {
if [ -f "$LOCK_FILE" ]; then
local old_pid
old_pid=$(cat "$LOCK_FILE" 2>/dev/null || true)
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
log_error "已有部署进程运行中 (PID: $old_pid)"
exit 1
fi
rm -f "$LOCK_FILE"
fi
echo "$$" > "$LOCK_FILE"
trap cleanup_lock EXIT
}
compose() {
docker compose -f "$COMPOSE_FILE" "$@"
}
wait_backend_healthy() {
local attempt=1
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
if compose exec -T backend python -c "import sys,urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3);sys.exit(0)" >/dev/null 2>&1; then
log_info "后端健康检查通过"
return 0
fi
log_info "等待后端就绪... (${attempt}/${MAX_ATTEMPTS})"
sleep 3
attempt=$((attempt + 1))
done
return 1
}
main() {
acquire_lock
[ -d "$DEPLOY_PATH" ] || { log_error "部署目录不存在: $DEPLOY_PATH"; exit 1; }
cd "$DEPLOY_PATH"
[ -f "$COMPOSE_FILE" ] || { log_error "Compose 文件不存在: $COMPOSE_FILE"; exit 1; }
export IMAGE_TAG
export VERSION="$IMAGE_TAG"
log_info "开始部署,镜像版本: $IMAGE_TAG"
log_info "拉取最新镜像..."
compose pull backend celery_worker celery_beat frontend
log_info "停止 celery_beat..."
compose stop celery_beat || true
log_info "更新 backend 与 celery_worker..."
compose up -d --no-deps backend celery_worker
if ! wait_backend_healthy; then
log_error "后端未在预期时间内就绪"
compose logs --tail=200 backend || true
exit 1
fi
log_info "执行数据库迁移..."
compose exec -T backend alembic upgrade head
log_info "启动 celery_beat..."
compose up -d --no-deps celery_beat
log_info "更新 frontend..."
compose up -d --no-deps frontend
log_info "清理孤儿容器..."
compose up -d --remove-orphans
if ! wait_backend_healthy; then
log_error "部署完成后健康检查失败"
compose logs --tail=200 backend || true
exit 1
fi
docker image prune -f >/dev/null 2>&1 || log_warn "镜像清理失败,已跳过"
log_info "部署完成!版本: $IMAGE_TAG"
compose ps
}
main "$@"
```
> 注意:脚本同时 export `IMAGE_TAG``VERSION`,因为生产服务器的 `docker-compose.prod.yml` 使用 `${VERSION:-latest}` 作为镜像 tag 变量。
---
## 生产服务器 `.env` 配置
确保 `/opt/docker/douyin_comments_management/.env` 至少包含:
```bash
DOCKER_REGISTRY=docker.internal.intelligrow.cn:5000
POSTGRES_PASSWORD=xxx
REDIS_PASSWORD=xxx
RABBITMQ_PASSWORD=xxx
# 其余生产配置保持现有值
```
> `DOCKER_REGISTRY` 的值必须与 Drone Secret 中 `backend_repo` / `frontend_repo` 的 Registry 主机名一致。
---
## 回滚方案
### 方式 1推送旧版本 tag 触发重新部署
```bash
git tag v1.8.1-rollback v1.8.1
git push origin v1.8.1-rollback
```
### 方式 2生产服务器手动回滚
```bash
ssh -p 3141 miaosi@192.168.31.48
cd /opt/docker/douyin_comments_management
bash scripts/deploy-remote.sh v1.8.1
```
### 方式 3手动 compose 回滚
```bash
cd /opt/docker/douyin_comments_management
export VERSION=v1.8.1
docker compose -f docker-compose.prod.yml pull backend celery_worker celery_beat frontend
docker compose -f docker-compose.prod.yml up -d --no-deps backend celery_worker celery_beat frontend
```
---
## 验证方式
### 验证 Registry
```bash
# Drone CI 服务器
curl http://localhost:5000/v2/_catalog
curl http://localhost:5000/v2/douyin-backend/tags/list
curl http://localhost:5000/v2/douyin-frontend/tags/list
```
### 验证构建与部署触发
```bash
# 方式1Tag 触发
git tag v1.9.0210.8
git push origin v1.9.0210.8
# 方式2Drone 面板手动触发 cron 或点击 NEW BUILD
```
### 验证生产服务
```bash
# 检查容器状态
ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml ps"
# 后端健康检查(容器内)
ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml exec -T backend python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3);print('ok')\""
# 数据库迁移状态
ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml exec -T backend alembic current"
```
---
## 故障排查
| 问题 | 排查方式 |
|------|---------|
| YAML 解析错误 | 检查 `.drone.yml` 语法Drone 对 `volumes`/`environment` 格式敏感 |
| 构建失败 | Drone 面板查看 pipeline 日志 |
| 镜像推送失败 (HTTPS) | 确认两台服务器 `insecure-registries` 配置正确(不带 `http://` 前缀) |
| Secret 为空 | 使用 `environment: { VAR: { from_secret: name } }` 而非 `secrets` 字段 |
| Drone 变量替换冲突 | `${DRONE_TAG}` 是 Drone 变量可直接使用;自定义 shell 变量用 `$$VAR` 转义 |
| Tag 不触发构建 | 检查 Gitea Webhook 是否勾选"创建"事件;`.drone.yml` trigger 不要加 `cron: [name]` |
| Step is pending | 检查 Runner 是否连通 Server仓库是否开启 Trusted |
| DinD 启动失败 | 改用 `docker:27-cli` + 挂载宿主机 Docker socket本方案采用的方式 |
| deploy 找不到脚本 | 确认 `deploy-remote.sh` 已复制到生产服务器部署目录的 `scripts/` 下 |
| Docker 权限不足 | 生产服务器执行 `sudo usermod -aG docker <user>` 后重新登录 |
| 镜像名 invalid reference | 检查生产服务器 `.env``DOCKER_REGISTRY` 变量是否正确设置 |
| 数据库迁移失败 | `docker compose -f docker-compose.prod.yml logs -f backend` |
| Cron 未触发 | 核对 Drone Cron 名称、分支是否 `main`、Schedule 是否正确 |
---
## 踩坑记录
以下是实际部署过程中遇到的问题及解决方案,供后续参考:
### 1. Drone YAML 解析错误 (`cannot unmarshal !!map into string`)
**原因**Drone Docker pipeline 对 `environment``from_secret` 语法和 `volumes` 格式有特定要求。早期版本同时使用 `volumes` + `environment: from_secret` 会触发解析错误。
**解决**:确保仓库开启 Trusted 模式后,`volumes``environment: from_secret` 可以正常共存。
### 2. Tag 推送不触发构建
**原因**`.drone.yml` 中同时配置了 `event: [tag, cron]``cron: [nightly-build]`Drone 将触发条件做 AND 运算。Tag 事件无法满足 cron 条件,导致永远不触发。
**解决**:移除 `cron: [nightly-build]` 过滤,只保留 `event: [tag, cron]`
### 3. plugins/docker DinD 启动失败
**原因**`plugins/docker` 插件内部启动 Docker 守护进程Docker-in-Docker可能因 cgroup/存储驱动兼容性问题无法启动。
**解决**:放弃 `plugins/docker`,改用 `docker:27-cli` 镜像 + 挂载宿主机 Docker socket 的方式构建镜像。
### 4. plugins/docker 要求 semver 格式 tag
**原因**`auto_tag: true` 配置要求 Git tag 符合语义化版本(如 `v1.0.0`),非标准格式(如 `v1.9.0210.1`)会解析失败。
**解决**:移除 `auto_tag`,改用 `${DRONE_TAG:-latest}` 手动指定镜像 tag。
### 5. Drone 变量替换与 Shell 变量冲突
**原因**Drone 会在执行前对 `${VAR}` 语法做自身的变量替换。自定义 shell 变量(如 `TAG="xxx"; echo ${TAG}`)中的 `${TAG}` 会被 Drone 替换为空。
**解决**:直接使用 Drone 内置变量 `${DRONE_TAG:-latest}`,避免中间 shell 变量。
### 6. `secrets` 字段注入环境变量不生效
**原因**Drone 步骤级 `secrets:` 字段在某些场景下不会将 secret 注入为环境变量。
**解决**:改用 `environment: { VAR: { from_secret: name } }` 显式声明。
### 7. Runner 连接 Server 失败
**原因**Runner 配置 `DRONE_RPC_PROTO=https` 但 drone-server 容器内部只监听 80 端口HTTPS 由外部反向代理终止)。
**解决**Runner 通过外部域名 `drone.internal.intelligrow.cn` 连接(走反向代理的 HTTPS而非直连容器内网。
### 8. 管理员权限不生效(看不到 Trusted 选项)
**原因**`DRONE_USER_CREATE=username:zhanghuayu@intelligrow.ai,admin:true` 中的 username 使用了邮箱而非 Gitea 登录用户名。
**解决**:改为 `username:zhanghuayu,admin:true`(与 Gitea 用户名完全一致)。
### 9. 生产服务器镜像名 invalid reference
**原因**`docker-compose.prod.yml` 使用 `${DOCKER_REGISTRY}` 变量,但 `.env` 中未设置或变量名不匹配(曾误设为 `REGISTRY_HOST`)。
**解决**:在 `.env` 中添加 `DOCKER_REGISTRY=docker.internal.intelligrow.cn:5000`,确保变量名与 compose 文件一致。
### 10. insecure-registries 格式错误
**原因**`/etc/docker/daemon.json``insecure-registries` 配置了 `http://docker.internal.intelligrow.cn:5000`带协议前缀Docker 不识别。
**解决**:去掉 `http://` 前缀,直接写 `docker.internal.intelligrow.cn:5000`
---
## 注意事项
1. **Registry 无认证**:仅建议内网使用;公网请加 TLS + 认证(或使用 Harbor
2. **Docker Socket 挂载**:构建步骤挂载宿主机 Docker socket需仓库开启 Trusted 模式。
3. **并发部署保护**`deploy-remote.sh` 使用 `/tmp/douyin-deploy.lock` 防止并发部署。
4. **迁移失败即中断**`alembic upgrade head` 失败会使整个部署失败,防止"假成功"。
5. **健康检查不依赖 curl**:采用容器内 Python 请求 `/health`,与当前镜像一致。
6. **deploy-remote.sh 需手动同步**:该脚本存放在生产服务器上,代码更新后需手动复制或通过部署流程同步。
7. **VERSION 与 IMAGE_TAG**`deploy-remote.sh` 同时 export 两个变量,兼容不同 compose 文件的命名。

View File

@ -0,0 +1,947 @@
# Drone CI/CD 通用部署指南
基于 **Drone CI + 私有 Docker Registry + Docker Compose** 的自动化部署方案。适用于任何基于 Docker 镜像部署的项目。
本指南基于抖音评论管理系统的实际落地经验编写,涵盖从零搭建到生产可用的全流程。
---
## 目录
- [前提条件](#前提条件)
- [架构概览](#架构概览)
- [第一步:基础设施准备(一次性)](#第一步基础设施准备一次性)
- [第二步:项目接入 Drone CI](#第二步项目接入-drone-ci)
- [第三步:编写 .drone.yml](#第三步编写-droneyml)
- [第四步:编写部署脚本](#第四步编写部署脚本)
- [第五步:生产服务器配置](#第五步生产服务器配置)
- [第六步:配置 Drone 仓库设置](#第六步配置-drone-仓库设置)
- [第七步:验证](#第七步验证)
- [回滚方案](#回滚方案)
- [多项目管理](#多项目管理)
- [踩坑清单](#踩坑清单)
- [故障排查速查表](#故障排查速查表)
- [附录:完整模板文件](#附录完整模板文件)
---
## 前提条件
| 组件 | 要求 |
|------|------|
| Gitea 服务器 | 已有,可通过 HTTPS 访问 |
| Drone CI 服务器 | Ubuntu x64已安装 Docker已部署 Drone Server + Runner |
| 生产服务器 | Ubuntu x64已安装 Docker + Docker Compose |
| 项目 | 包含 Dockerfile可通过 `docker compose` 部署 |
如果 Drone CI 尚未部署,参见 [附录 ADrone CI 部署](#附录-a-drone-ci-部署)。
---
## 架构概览
```text
触发方式:
A) 创建 Git Tag如 v1.0.0)→ 自动构建部署指定版本
B) Drone Cron 定时任务 → 自动构建部署 latest
流水线:
Drone CI 拉取代码
|
+-- 构建镜像 1 (如 backend) → 推送到 Registry
+-- 构建镜像 2 (如 frontend) → 推送到 Registry
+-- 构建镜像 N ...
|
v
SSH 到生产服务器 → 执行部署脚本
|
+-- docker compose pull (拉取新镜像)
+-- docker compose up -d (滚动更新)
+-- 健康检查
+-- 数据库迁移(如有)
|
v
发送通知(企业微信/钉钉/飞书,可选)
```
三台服务器各司其职:
| 角色 | 职责 |
|------|------|
| Gitea 服务器 | 代码仓库,通过 Webhook 触发 Drone |
| Drone CI 服务器 | 执行构建 + 运行私有 Docker Registry |
| 生产服务器 | 运行 Docker Compose 部署的生产服务 |
---
## 第一步:基础设施准备(一次性)
以下操作只需做一次,所有项目共享。
### 1.1 启动私有 Docker Registry
在 **Drone CI 服务器**上执行:
```bash
docker run -d --name registry \
-p 5000:5000 \
-v /opt/registry-data:/var/lib/registry \
--restart always \
registry:2
```
验证:
```bash
curl http://localhost:5000/v2/_catalog
# 预期输出: {"repositories":[]}
```
### 1.2 配置 insecure-registries
由于 Registry 未配置 TLS需要在 **所有需要访问 Registry 的服务器** 上配置 insecure-registries。
编辑 `/etc/docker/daemon.json`
```json
{
"insecure-registries": ["<DRONE_CI_IP_OR_DOMAIN>:5000"]
}
```
然后重启 Docker
```bash
sudo systemctl restart docker
```
> **重要**
> - `insecure-registries` 的值 **不要**`http://` 前缀,直接写 `host:port`
> - 如果已有 `registry-mirrors` 等其他配置,合并到同一个 JSON 文件中,不要覆盖
> - Drone CI 服务器和生产服务器 **都需要** 配置
**示例**(假设 Drone CI 服务器 IP 为 `192.168.1.100`,域名为 `ci.example.com`
```json
{
"registry-mirrors": [
"https://docker.1panel.live"
],
"insecure-registries": [
"ci.example.com:5000",
"192.168.1.100:5000"
]
}
```
### 1.3 配置 SSH 免密登录
在 **Drone CI 服务器**上生成部署专用密钥:
```bash
ssh-keygen -t ed25519 -C "drone-ci-deploy" -f ~/.ssh/drone_deploy -N ""
```
将公钥添加到 **生产服务器**
```bash
# 如果 SSH 端口是 22
ssh-copy-id -i ~/.ssh/drone_deploy.pub <user>@<production-server-ip>
# 如果 SSH 端口不是 22如 3141
ssh-copy-id -i ~/.ssh/drone_deploy.pub -p <port> <user>@<production-server-ip>
```
验证:
```bash
ssh -i ~/.ssh/drone_deploy -p <port> <user>@<production-server-ip> "echo ok"
```
### 1.4 确保生产服务器用户有 Docker 权限
```bash
# 在生产服务器上
sudo usermod -aG docker <deploy-user>
# 需要重新登录生效
```
---
## 第二步:项目接入 Drone CI
### 2.1 在 Drone 面板激活仓库
1. 打开 Drone CI 面板(如 `https://drone.example.com`
2. 点击 **SYNC** 同步 Gitea 仓库列表
3. 找到目标仓库,点击 **ACTIVATE**
### 2.2 开启 Trusted 模式
仓库 Settings → General → Project Settings → 勾选 **Trusted**
> 这是必须的,因为构建步骤需要挂载宿主机 Docker socket。如果看不到 Trusted 选项,说明当前用户不是 Drone 管理员。检查 Drone Server 的 `DRONE_USER_CREATE` 环境变量,`username` 必须与 Gitea 登录用户名完全一致(不是邮箱)。
### 2.3 配置 Secrets
在仓库 Settings → Secrets 添加以下密钥:
| Secret 名称 | 说明 | 填写示例 |
|-------------|------|---------|
| `image_repo_<service>` | 每个需构建的服务的完整镜像地址 | `ci.example.com:5000/myproject-backend` |
| `deploy_host` | 生产服务器 IP 或域名 | `192.168.1.200` |
| `deploy_user` | SSH 登录用户名 | `ubuntu` |
| `deploy_ssh_key` | SSH **私钥**完整内容 | `-----BEGIN OPENSSH PRIVATE KEY-----...` |
| `deploy_path` | 生产服务器上的项目部署目录 | `/opt/myproject` |
| `wecom_webhook` | 通知 Webhook URL可选 | `https://qyapi.weixin.qq.com/...` |
> **关于 `image_repo_<service>`**:每个需要构建的服务单独一个 secret。例如项目有 backend 和 frontend就创建 `backend_repo``frontend_repo`。Registry 地址必须与生产服务器 `.env` 中的镜像地址一致。
### 2.4 配置 Cron 定时构建(可选)
仓库 Settings → Cron Jobs 添加:
| 字段 | 值 | 说明 |
|------|------|------|
| Name | `nightly-build` | 任务名称 |
| Branch | `main` | 构建分支 |
| Schedule | `0 16 * * *` | UTC 16:00 = 北京时间 00:00 |
> Drone 使用 UTC 时区解释 Cron 表达式。北京时间 = UTC + 8。
---
## 第三步:编写 .drone.yml
在项目根目录创建 `.drone.yml`
### 核心原则
1. **使用 `docker:27-cli` + 宿主机 Docker socket**,不用 `plugins/docker` DinD避免 DinD 启动失败问题)
2. **使用 `environment: from_secret`** 注入密钥,不用 `secrets:` 字段
3. **使用 `${DRONE_TAG:-latest}`** 作为镜像 tag不自定义中间变量避免 Drone 变量替换冲突)
4. **触发条件只用 `event`**,不叠加 `cron: [name]`Drone 触发条件是 AND 运算)
### 模板
```yaml
kind: pipeline
type: docker
name: build-and-deploy
trigger:
event:
- tag # Git Tag 触发
- cron # 定时触发
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
steps:
# ==========================================
# 构建步骤 - 每个服务一个 step
# ==========================================
- name: build-<service-1>
image: docker:27-cli
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
IMAGE_REPO:
from_secret: <service-1>_repo
commands:
- '[ -n "$IMAGE_REPO" ] || (echo "<service-1>_repo secret is empty" && exit 1)'
- echo "Building <service-1>, tag=${DRONE_TAG:-latest}"
- docker build -t "$IMAGE_REPO:${DRONE_TAG:-latest}" -t "$IMAGE_REPO:latest" ./<service-1-context>
- docker push "$IMAGE_REPO:${DRONE_TAG:-latest}"
- docker push "$IMAGE_REPO:latest"
- name: build-<service-2>
image: docker:27-cli
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
IMAGE_REPO:
from_secret: <service-2>_repo
commands:
- '[ -n "$IMAGE_REPO" ] || (echo "<service-2>_repo secret is empty" && exit 1)'
- echo "Building <service-2>, tag=${DRONE_TAG:-latest}"
- docker build -t "$IMAGE_REPO:${DRONE_TAG:-latest}" -t "$IMAGE_REPO:latest" ./<service-2-context>
- docker push "$IMAGE_REPO:${DRONE_TAG:-latest}"
- docker push "$IMAGE_REPO:latest"
# ==========================================
# 部署步骤
# ==========================================
- name: deploy
image: appleboy/drone-ssh
environment:
DEPLOY_PATH:
from_secret: deploy_path
settings:
host:
from_secret: deploy_host
username:
from_secret: deploy_user
key:
from_secret: deploy_ssh_key
port: 22 # ← 改成实际 SSH 端口
command_timeout: 1800s
script_stop: true
envs:
- DRONE_TAG
- DEPLOY_PATH
script:
- IMAGE_TAG="$DRONE_TAG"; [ -n "$IMAGE_TAG" ] || IMAGE_TAG="latest"
- cd "$DEPLOY_PATH"
- bash scripts/deploy-remote.sh "$IMAGE_TAG"
# ==========================================
# 通知步骤(可选)
# ==========================================
- name: notify-success
image: curlimages/curl
environment:
WEBHOOK_URL:
from_secret: wecom_webhook
commands:
- |
if [ -n "${WEBHOOK_URL:-}" ]; then
VERSION="${DRONE_TAG:-nightly-$(date +%Y%m%d)}"
curl -sS -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"✅ 部署成功\nProject: ${DRONE_REPO}\n版本: ${VERSION}\n时间: $(date '+%Y-%m-%d %H:%M:%S')\"}}"
fi
when:
status: [success]
- name: notify-failure
image: curlimages/curl
environment:
WEBHOOK_URL:
from_secret: wecom_webhook
commands:
- |
if [ -n "${WEBHOOK_URL:-}" ]; then
VERSION="${DRONE_TAG:-nightly-$(date +%Y%m%d)}"
curl -sS -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"❌ 部署失败\nProject: ${DRONE_REPO}\n版本: ${VERSION}\n构建: ${DRONE_BUILD_LINK}\"}}"
fi
when:
status: [failure]
```
**替换占位符**
| 占位符 | 替换为 | 示例 |
|--------|--------|------|
| `<service-1>`, `<service-2>` | 你的服务名 | `backend`, `frontend`, `api`, `web` |
| `<service-1-context>` | Docker build context 路径 | `./backend`, `./frontend`, `.` |
| `port: 22` | 生产服务器 SSH 端口 | `22`, `3141` |
---
## 第四步:编写部署脚本
在项目中创建 `scripts/deploy-remote.sh`,这个脚本由 Drone SSH 步骤在生产服务器上调用。
### 通用模板
```bash
#!/usr/bin/env bash
# ========================================
# 远程部署脚本 - 被 Drone CI SSH 调用
# ========================================
# 用法: bash scripts/deploy-remote.sh [image_tag]
set -euo pipefail
# ---------- 日志 ----------
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
# ---------- 配置(可通过环境变量覆盖) ----------
DEPLOY_PATH="${DEPLOY_PATH:-/opt/myproject}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
IMAGE_TAG="${1:-latest}"
LOCK_FILE="/tmp/$(basename "$DEPLOY_PATH")-deploy.lock"
MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}"
HEALTH_CHECK_INTERVAL="${HEALTH_CHECK_INTERVAL:-3}"
# ---------- 部署锁 ----------
cleanup_lock() { rm -f "$LOCK_FILE"; }
acquire_lock() {
if [ -f "$LOCK_FILE" ]; then
local old_pid
old_pid=$(cat "$LOCK_FILE" 2>/dev/null || true)
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
log_error "已有部署进程运行中 (PID: $old_pid)"
exit 1
fi
rm -f "$LOCK_FILE"
fi
echo "$$" > "$LOCK_FILE"
trap cleanup_lock EXIT
}
# ---------- 工具函数 ----------
compose() {
docker compose -f "$COMPOSE_FILE" "$@"
}
# 健康检查 - 根据项目实际情况修改
# 方式 1容器内用 python 请求(适用于 Python 后端镜像)
wait_healthy_python() {
local service="$1"
local url="${2:-http://127.0.0.1:8000/health}"
local attempt=1
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
if compose exec -T "$service" python -c \
"import sys,urllib.request;urllib.request.urlopen('${url}', timeout=3);sys.exit(0)" \
>/dev/null 2>&1; then
log_info "${service} 健康检查通过"
return 0
fi
log_info "等待 ${service} 就绪... (${attempt}/${MAX_ATTEMPTS})"
sleep "$HEALTH_CHECK_INTERVAL"
attempt=$((attempt + 1))
done
return 1
}
# 方式 2容器内用 curl 请求(适用于内置 curl 的镜像)
wait_healthy_curl() {
local service="$1"
local url="${2:-http://127.0.0.1:8000/health}"
local attempt=1
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
if compose exec -T "$service" curl -sf "$url" >/dev/null 2>&1; then
log_info "${service} 健康检查通过"
return 0
fi
log_info "等待 ${service} 就绪... (${attempt}/${MAX_ATTEMPTS})"
sleep "$HEALTH_CHECK_INTERVAL"
attempt=$((attempt + 1))
done
return 1
}
# 方式 3从宿主机请求适用于有端口映射的服务
wait_healthy_host() {
local url="${1:-http://127.0.0.1:8000/health}"
local attempt=1
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
if curl -sf "$url" >/dev/null 2>&1; then
log_info "健康检查通过: $url"
return 0
fi
log_info "等待服务就绪... (${attempt}/${MAX_ATTEMPTS})"
sleep "$HEALTH_CHECK_INTERVAL"
attempt=$((attempt + 1))
done
return 1
}
# ---------- 主流程 ----------
main() {
acquire_lock
[ -d "$DEPLOY_PATH" ] || { log_error "部署目录不存在: $DEPLOY_PATH"; exit 1; }
cd "$DEPLOY_PATH"
[ -f "$COMPOSE_FILE" ] || { log_error "Compose 文件不存在: $COMPOSE_FILE"; exit 1; }
# 导出镜像 tag 变量 - 根据 compose 文件中使用的变量名按需调整
export IMAGE_TAG
export VERSION="$IMAGE_TAG"
# export TAG="$IMAGE_TAG" # 如果你的 compose 用 ${TAG}
log_info "========== 开始部署 =========="
log_info "镜像版本: $IMAGE_TAG"
log_info "部署目录: $DEPLOY_PATH"
log_info "Compose: $COMPOSE_FILE"
# --- 1. 拉取新镜像 ---
log_info "[1/5] 拉取新镜像..."
compose pull
# 或者只拉取特定服务: compose pull backend frontend
# --- 2. 滚动更新服务 ---
log_info "[2/5] 更新服务..."
compose up -d --remove-orphans
# 如果需要控制更新顺序,拆分为多步:
# compose up -d --no-deps backend
# compose up -d --no-deps frontend
# --- 3. 健康检查 ---
log_info "[3/5] 健康检查..."
# 根据你的项目选择合适的健康检查方式,以下是示例:
# wait_healthy_python "backend" "http://127.0.0.1:8000/health"
# wait_healthy_curl "api" "http://127.0.0.1:3000/health"
# wait_healthy_host "http://127.0.0.1:8080/health"
#
# 如果健康检查失败,取消注释下面的代码:
# if ! wait_healthy_python "backend"; then
# log_error "健康检查失败"
# compose logs --tail=200 backend || true
# exit 1
# fi
# --- 4. 数据库迁移(如有) ---
log_info "[4/5] 数据库迁移..."
# 根据项目使用的迁移工具选择:
# compose exec -T backend alembic upgrade head # Python Alembic
# compose exec -T api npx prisma migrate deploy # Node Prisma
# compose exec -T api npm run migrate # 自定义脚本
# compose exec -T backend python manage.py migrate # Django
# 如果不需要迁移,注释掉即可
# --- 5. 清理 ---
log_info "[5/5] 清理旧镜像..."
docker image prune -f >/dev/null 2>&1 || log_warn "镜像清理失败,已跳过"
log_info "========== 部署完成 =========="
log_info "版本: $IMAGE_TAG"
compose ps
}
main "$@"
```
### 脚本要点
1. **部署锁**:通过 PID 文件防止并发部署
2. **`set -euo pipefail`**:任何命令失败立即退出,防止"假成功"
3. **变量导出**:同时 export `IMAGE_TAG``VERSION`,兼容不同 compose 文件的命名习惯
4. **健康检查**:提供三种方式,根据项目镜像内置的工具选择
5. **数据库迁移**:在健康检查通过后执行,迁移失败会中断部署
---
## 第五步:生产服务器配置
### 5.1 目录结构
确保生产服务器上部署目录结构如下:
```text
/opt/myproject/ # DEPLOY_PATH
├── docker-compose.prod.yml # 生产 compose 配置
├── .env # 环境变量(镜像地址、数据库密码等)
└── scripts/
└── deploy-remote.sh # 部署脚本(从代码仓库复制)
```
### 5.2 docker-compose.prod.yml 中的镜像引用
compose 文件中使用变量引用 Registry 中的镜像:
```yaml
services:
backend:
image: ${DOCKER_REGISTRY}/myproject-backend:${VERSION:-latest}
# ...
frontend:
image: ${DOCKER_REGISTRY}/myproject-frontend:${VERSION:-latest}
# ...
```
### 5.3 .env 文件
```bash
# 镜像仓库地址 - 必须与 Drone Secret 中的 image_repo 前缀一致
DOCKER_REGISTRY=ci.example.com:5000
# 其他生产环境配置
POSTGRES_PASSWORD=xxx
REDIS_PASSWORD=xxx
# ...
```
> **变量一致性**:假设 Drone Secret `backend_repo` = `ci.example.com:5000/myproject-backend`,那么 `.env` 中的 `DOCKER_REGISTRY` 必须是 `ci.example.com:5000`compose 中的镜像名必须是 `${DOCKER_REGISTRY}/myproject-backend:${VERSION:-latest}`。三者拼起来的镜像全名必须完全一致。
### 5.4 首次部署
首次部署需要手动初始化:
```bash
cd /opt/myproject
# 确认 .env 和 compose 文件就位
ls -la .env docker-compose.prod.yml
# 手动拉取并启动
export VERSION=latest
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
# 运行数据库迁移(如有)
docker compose -f docker-compose.prod.yml exec -T backend alembic upgrade head
```
---
## 第六步:配置 Drone 仓库设置
### Secrets 快速清单
针对你的新项目,在 Drone 面板创建以下 Secrets
```text
backend_repo = <registry-host>:5000/<project>-backend
frontend_repo = <registry-host>:5000/<project>-frontend # 如果有
deploy_host = <production-server-ip>
deploy_user = <ssh-username>
deploy_ssh_key = <SSH 私钥完整内容cat ~/.ssh/drone_deploy>
deploy_path = /opt/<project>
wecom_webhook = <通知 webhook url> # 可选
```
> `deploy_ssh_key` 是第一步中生成的 SSH 私钥内容,所有项目可以共用同一个密钥(只要目标生产服务器相同)。
### Cron 配置
如果需要定时构建:
| 字段 | 值 |
|------|------|
| Name | `nightly-build` |
| Branch | `main` |
| Schedule | `0 16 * * *`(北京时间 00:00 |
---
## 第七步:验证
### 7.1 Registry 验证
```bash
# 在 Drone CI 服务器上
curl http://localhost:5000/v2/_catalog
```
### 7.2 Tag 触发构建
```bash
git tag v1.0.0
git push origin v1.0.0
```
在 Drone 面板观察 pipeline 执行状态。
### 7.3 生产服务验证
```bash
# 检查容器状态
ssh -p <port> <user>@<prod-ip> "cd /opt/myproject && docker compose -f docker-compose.prod.yml ps"
# 检查镜像版本
ssh -p <port> <user>@<prod-ip> "docker images | grep myproject"
```
---
## 回滚方案
### 方式 1手动指定版本回滚
```bash
ssh -p <port> <user>@<prod-ip>
cd /opt/myproject
bash scripts/deploy-remote.sh v1.0.0 # 指定要回滚到的版本
```
### 方式 2直接 compose 回滚
```bash
ssh -p <port> <user>@<prod-ip>
cd /opt/myproject
export VERSION=v1.0.0
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
### 方式 3通过 Git Tag 触发
```bash
git tag v1.0.0-rollback v1.0.0
git push origin v1.0.0-rollback
```
---
## 多项目管理
当多个项目共用同一套 Drone CI + Registry 基础设施时:
### 共享部分(不需要重复)
- Drone CI Server + Runner已部署
- Docker Registry已运行在 :5000
- SSH 密钥(可共用同一个 `drone_deploy` 密钥)
- `insecure-registries` 配置(已在两台服务器上配置)
### 每个新项目需要做的
1. 在 Drone 面板激活仓库 + 开启 Trusted
2. 在 Drone 面板添加该仓库的 Secrets镜像地址、部署路径
3. 项目代码中添加 `.drone.yml``scripts/deploy-remote.sh`
4. 生产服务器创建部署目录 + 放置 compose 文件和 `.env`
5. (可选)配置 Cron 定时构建
### Registry 镜像命名规范
建议统一命名格式:
```text
<registry-host>:5000/<project-name>-<service>:<tag>
```
示例:
```text
ci.example.com:5000/douyin-backend:v1.0.0
ci.example.com:5000/douyin-frontend:v1.0.0
ci.example.com:5000/crm-api:v2.1.0
ci.example.com:5000/crm-web:v2.1.0
ci.example.com:5000/blog-app:latest
```
查看所有仓库:
```bash
curl http://localhost:5000/v2/_catalog
```
查看某个镜像的所有 tag
```bash
curl http://localhost:5000/v2/<project>-<service>/tags/list
```
---
## 踩坑清单
以下是实际部署中遇到的问题,按出现频率排序:
### 1. `insecure-registries` 格式错误
**错误**Docker push 报 `server gave HTTP response to HTTPS client`
**原因**`/etc/docker/daemon.json` 中写了 `http://host:5000`
**正确写法**:直接写 `host:5000`,不带协议前缀
### 2. Drone 变量替换冲突
**错误**:构建命令中的 shell 变量值为空
**原因**Drone 在执行命令前会对 `${VAR}` 做自身的变量替换,自定义 shell 变量 `${TAG}` 被 Drone 替换为空
**解决**:直接使用 Drone 内置变量 `${DRONE_TAG:-latest}`,不要赋值给中间变量
### 3. Secret 注入不生效
**错误**:环境变量为空,步骤因 secret 缺失而失败
**原因**:使用了步骤级 `secrets:` 字段
**解决**:改用 `environment: { VAR: { from_secret: name } }`
### 4. plugins/docker DinD 启动失败
**错误**`Unable to reach Docker Daemon after 15 attempts`
**原因**`plugins/docker` 内部启动 Docker 守护进程失败cgroup / 存储驱动兼容性)
**解决**:使用 `docker:27-cli` + 挂载宿主机 `/var/run/docker.sock`
### 5. Trusted 选项不可见
**错误**:仓库 Settings 中找不到 Trusted 选项
**原因**`DRONE_USER_CREATE``username` 填了邮箱,不是 Gitea 用户名
**解决**`username` 必须与 Gitea 登录用户名完全一致
### 6. Tag 触发与 Cron 触发互相排斥
**错误**:推送 Tag 后 Drone 不触发构建
**原因**`.drone.yml` 同时配了 `event: [tag, cron]``cron: [nightly-build]`Drone 触发条件是 **AND** 运算
**解决**:只保留 `event: [tag, cron]`,不加 `cron:` 过滤
### 7. 生产服务器镜像名不匹配
**错误**`docker compose pull` 报 invalid reference 或拉取到错误镜像
**原因**Drone Secret 中的 Registry 地址(如 IP与生产服务器 `.env` 中的(如域名)不一致
**解决**:统一使用同一个地址格式(建议统一用域名或统一用 IP
### 8. SSH 端口不对
**错误**deploy 步骤超时或连接拒绝
**原因**`appleboy/drone-ssh` 默认使用 22 端口
**解决**:在 `.drone.yml` 的 deploy settings 中显式指定 `port: <actual-port>`
### 9. Docker 权限不足
**错误**:生产服务器 `permission denied while trying to connect to the Docker daemon socket`
**解决**`sudo usermod -aG docker <user>` 后重新登录
### 10. daemon.json 被覆盖
**错误**:修改 `insecure-registries` 时丢失了已有的 `registry-mirrors` 配置
**预防**:修改前先 `cat /etc/docker/daemon.json` 查看现有内容,合并修改
---
## 故障排查速查表
| 现象 | 检查方向 |
|------|---------|
| Pipeline 不触发 | Gitea Webhook 是否勾选"创建"事件;`.drone.yml` trigger 配置 |
| Step 一直 pending | Runner 是否连通 Server仓库是否 Trusted |
| 构建报 secret 为空 | 使用 `environment: from_secret` 而非 `secrets:` |
| Docker build 失败 | Dockerfile 是否正确build context 路径是否对 |
| Docker push 失败 (HTTPS) | 两台服务器 `insecure-registries` 配置 |
| Docker push 失败 (连接拒绝) | Registry 容器是否运行:`docker ps \| grep registry` |
| SSH 部署失败 | 密钥是否正确;端口是否匹配;用户是否有 Docker 权限 |
| 找不到部署脚本 | `deploy-remote.sh` 是否已复制到生产服务器的对应目录 |
| 镜像名 invalid reference | 生产服务器 `.env``DOCKER_REGISTRY` 变量是否正确 |
| compose pull 拉到旧镜像 | 检查 Registry 地址和镜像 tag 是否一致 |
| 数据库迁移失败 | `docker compose logs -f <service>` 查看报错 |
| 健康检查超时 | 增大 `MAX_ATTEMPTS`;检查服务是否正常启动 |
---
## 附录 ADrone CI 部署
如果 Drone CI 尚未部署,在 Drone CI 服务器上创建 `~/drone/docker-compose.yml`
```yaml
services:
drone-server:
image: drone/drone:2
container_name: drone-server
restart: always
ports:
- "3080:80" # Drone Web UI 端口,通过反向代理暴露 HTTPS
environment:
- DRONE_GITEA_SERVER=https://<your-gitea-domain>
- DRONE_GITEA_CLIENT_ID=<gitea-oauth-app-client-id>
- DRONE_GITEA_CLIENT_SECRET=<gitea-oauth-app-client-secret>
- DRONE_SERVER_HOST=<your-drone-domain>
- DRONE_SERVER_PROTO=https
- DRONE_RPC_SECRET=<随机生成的长字符串>
- DRONE_USER_CREATE=username:<your-gitea-username>,admin:true
volumes:
- ./data:/data
drone-runner:
image: drone/drone-runner-docker:1
container_name: drone-runner
restart: always
depends_on:
- drone-server
environment:
# Runner 通过 Docker 内网直连 server 的 80 端口
- DRONE_RPC_PROTO=http
- DRONE_RPC_HOST=drone-server
- DRONE_RPC_SECRET=< server 相同的 secret>
- DRONE_RUNNER_CAPACITY=2
- DRONE_RUNNER_NAME=drone-runner-1
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
启动:
```bash
cd ~/drone
docker compose up -d
```
### Gitea OAuth App 配置
1. Gitea → 站点管理 → 应用 → 创建 OAuth2 应用
2. 重定向 URI`https://<your-drone-domain>/login`
3. 将 Client ID 和 Client Secret 填入上面的 compose 配置
### 生成 RPC Secret
```bash
openssl rand -hex 16
```
### 反向代理配置Nginx 示例)
```nginx
server {
listen 443 ssl;
server_name drone.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:3080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
---
## 附录 B新项目接入 Checklist
为新项目接入 CI/CD 时,按此清单逐项完成:
```text
□ 基础设施(一次性,已完成则跳过)
□ Registry 运行中
□ insecure-registries 已配置Drone CI 服务器 + 生产服务器)
□ SSH 密钥已配置
□ Drone 面板
□ 仓库已激活ACTIVATE
□ Trusted 已勾选
□ Secrets 已添加image_repo, deploy_host, deploy_user, deploy_ssh_key, deploy_path
□ Cron Job 已配置(如需定时构建)
□ 项目代码
□ .drone.yml 已创建
□ scripts/deploy-remote.sh 已创建
□ 生产服务器
□ 部署目录已创建
□ docker-compose.prod.yml 已放置
□ .env 已配置DOCKER_REGISTRY 等)
□ scripts/deploy-remote.sh 已复制到部署目录
□ 首次手动部署成功
□ 验证
□ 推送 Tag 后 Drone 自动构建并部署成功
□ 生产服务正常运行
□ 回滚流程测试通过
```

901
externaldocs/tikhub_api.md Normal file
View File

@ -0,0 +1,901 @@
# TikHub API 参考文档
> 版本V5.2.92025-03-08
> Swagger UIhttps://api.tikhub.io
> ReDochttps://api.tikhub.io/docs
> Apifox 文档https://docs.tikhub.io
---
## 基础信息
| 项目 | 说明 |
|------|------|
| Base URL国际| `https://api.tikhub.io` |
| Base URL中国大陆| `https://api.tikhub.dev`(路径和参数完全相同,仅域名不同)|
| 认证方式 | `Authorization: Bearer {API_TOKEN}` |
| 计费 | $0.001 / 请求(按量付费,支持批量折扣)|
| 速率限制 | 令牌桶 10 req/s本项目在 `src/lib/tikhub.ts` 中已实现)|
| 超时 | 15s本项目已配置|
所有端点均为 `GET` 请求(除特别标注),返回 JSON。
---
## 错误码
| HTTP 状态码 | 含义 |
|------------|------|
| 400 | 参数错误 |
| 401 | API Key 无效或未提供 |
| 402 | 余额不足 |
| 403 | 无权限访问该端点 |
| 404 | 内容不存在 |
| 429 | 请求过于频繁 |
| 500 | 服务器内部错误 |
---
## 抖音Douyin
### 抖音网页版 API — `/api/v1/douyin/web/`
#### ★ `GET /api/v1/douyin/web/fetch_hot_search_result`
> **本项目使用** · 抖音热搜榜
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 无 | — | — | 无需参数 |
响应结构:
```json
{
"data": {
"data": {
"trending_list": [
{
"sentence_id": "string", // 热搜词 ID用作 ContentItem.id
"word": "string", // 热搜词文本
"word_cover": {
"url_list": ["string"] // 封面图列表
},
"hot_value": 10000, // 热度值(→ like_count
"view_count": 500000, // 阅读量(→ play_count
"discuss_video_count": 200, // 讨论视频数(→ comment_count
"event_time": 1709000000 // Unix 时间戳(秒)
}
],
"word_list": [/* 同上格式,普通热词 */]
}
}
}
```
> **注意**`trending_list` 为置顶热搜,`word_list` 为普通热词。本项目合并两者并按 `sentence_id` 去重。
---
#### ★ `GET /api/v1/douyin/web/fetch_one_video`
> **本项目使用** · 抖音视频详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `aweme_id` | string | 是 | 视频 ID |
响应结构:
```json
{
"data": {
"aweme_detail": {
"aweme_id": "string",
"desc": "string", // 视频标题/描述
"video": {
"cover": { "url_list": ["string"] },
"play_addr": { "url_list": ["string"] }
},
"author": {
"nickname": "string",
"avatar_thumb": { "url_list": ["string"] }
},
"statistics": {
"play_count": 100000,
"digg_count": 5000, // 点赞数(→ like_count
"comment_count": 200,
"share_count": 300
},
"create_time": 1709000000,
"text_extra": [{ "hashtag_name": "string" }]
}
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/douyin/web/fetch_user_profile` | 获取用户信息 | `unique_id`(用户名)或 `sec_user_id` |
| `GET /api/v1/douyin/web/fetch_user_post` | 获取用户作品 | `sec_user_id`, `cursor`(分页), `count` |
| `GET /api/v1/douyin/web/fetch_general_search_result` | 综合搜索 | `keyword`, `count`, `offset` |
| `GET /api/v1/douyin/web/fetch_video_search_result` | 视频搜索 | `keyword`, `count`, `offset` |
| `GET /api/v1/douyin/web/fetch_user_search_result_v2` | 用户搜索 | `keyword` |
| `GET /api/v1/douyin/web/fetch_post_comment` | 获取评论 | `aweme_id`, `cursor`, `count` |
| `GET /api/v1/douyin/app/v3/fetch_hot_search_list` | App 热搜榜App V3 推荐)| 无 |
| `GET /api/v1/douyin/billboard/*` | 各类榜单(热门/音乐/挑战等)| 参见 Swagger |
---
## TikTok
### TikTok 网页版 API — `/api/v1/tiktok/web/`
#### ★ `GET /api/v1/tiktok/web/fetch_explore_post`
> **本项目使用** · TikTok 探索/热门视频列表
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `count` | integer | 否 | 返回数量,默认 20 |
响应结构:
```json
{
"data": {
"itemList": [
{
"id": "string", // 视频 ID
"desc": "string", // 视频描述/标题
"video": {
"cover": "string", // 封面图 URL直接字符串
"playAddr": "string" // 播放地址
},
"author": {
"nickname": "string",
"avatarThumb": "string", // 头像 URL
"uniqueId": "string" // 用户名(用于构建主页链接)
},
"stats": {
"playCount": 500000,
"diggCount": 25000, // 点赞数(→ like_count
"commentCount": 1000,
"shareCount": 500
},
"createTime": 1709000000, // Unix 时间戳(秒)
"challenges": [{ "title": "string" }] // 话题标签(→ tags
}
]
}
}
```
> **注意**`video.cover` 是直接字符串,不是数组(与抖音不同)。
---
#### ★ `GET /api/v1/tiktok/web/fetch_post_detail`
> **本项目使用** · TikTok 视频详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `itemId` | string | 是 | 视频 ID |
响应结构:
```json
{
"data": {
"itemInfo": {
"itemStruct": { /* 同 itemList 中的单条格式 */ }
}
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/tiktok/web/fetch_user_profile` | 用户信息 | `uniqueId``secUid` |
| `GET /api/v1/tiktok/web/fetch_user_post` | 用户作品 | `secUid`, `cursor`, `count`, `post_item_list_request_type`(0=默认/1=热门/2=最旧) |
| `GET /api/v1/tiktok/web/fetch_user_like` | 用户喜欢(需公开)| `secUid`, `cursor`, `count` |
| `GET /api/v1/tiktok/web/fetch_post_comment` | 视频评论 | `aweme_id`, `cursor`, `count` |
| `GET /api/v1/tiktok/web/fetch_search_video` | 视频搜索 | `keyword`, `count`, `offset`, `search_id` |
| `GET /api/v1/tiktok/web/fetch_search_user` | 用户搜索 | `keyword`, `cursor`, `search_id` |
| `GET /api/v1/tiktok/web/fetch_tag_detail` | 话题详情 | `tag_name` |
| `GET /api/v1/tiktok/web/fetch_user_fans` | 粉丝列表 | `secUid`, `count`(默认30) |
| `GET /api/v1/tiktok/web/fetch_user_follow` | 关注列表 | `secUid`, `count`(默认30) |
| `POST /api/v1/tiktok/web/fetch_home_feed` | 首页推荐 | `count`, `cookie` |
---
## 小红书Xiaohongshu
### 小红书 Web V2 API — `/api/v1/xiaohongshu/web_v2/`
#### ★ `GET /api/v1/xiaohongshu/web_v2/fetch_hot_list`
> **本项目使用** · 获取热榜关键词列表(第一步)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 无 | — | — | 无需参数 |
响应结构:
```json
{
"data": {
"data": {
"items": [
{ "title": "string" } // 热词标题,取第一个作为搜索关键词
]
}
}
}
```
---
### 小红书 App V2 API — `/api/v1/xiaohongshu/app_v2/`(推荐)
#### ★ `GET /api/v1/xiaohongshu/app_v2/search_notes`
> **本项目使用** · 按关键词搜索笔记(第二步)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `keyword` | string | 是 | 搜索关键词 |
| `page` | integer | 否 | 页码,默认 1 |
响应结构:
```json
{
"data": {
"data": {
"items": [
{
"model_type": "note", // 只处理 model_type==='note' 的条目
"note": {
"id": "string",
"title": "string",
"desc": "string",
"type": "normal | video",
"timestamp": 1709000000,
"images_list": [
{
"url": "string",
"url_size_large": "string" // 优先使用大图
}
],
"user": {
"nickname": "string",
"images": "string", // 头像 URL
"user_id": "string"
},
"liked_count": 5000, // 点赞数(数字类型)
"comments_count": 200, // 评论数(注意:有 's'
"shared_count": 100,
"collected_count": 300,
"tag_info": [{ "name": "string" }]
}
}
]
}
}
}
```
> **注意**
> - `model_type` 不为 `"note"` 的条目(如广告)需过滤掉
> - 搜索结果中视频笔记的 `video_url` 不可用,仅详情接口可获取视频链接
---
#### ★ `GET /api/v1/xiaohongshu/app_v2/get_mixed_note_detail`
> **本项目使用** · 获取笔记详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `note_id` | string | 是 | 笔记 ID |
响应结构:
```json
{
"data": {
"id": "string",
"title": "string",
"name": "string", // 备用标题字段
"desc": "string",
"type": "normal | video",
"timestamp": 1709000000,
"likes": 5000, // 点赞数(注意:详情与搜索字段名不同)
"comments_count": 200,
"shared_count": 100,
"collected_count": 300,
"images_list": [
{ "url": "string", "url_size_large": "string" }
],
"user": {
"nickname": "string",
"images": "string"
},
"tag_info": [{ "name": "string" }],
"video_info_v2": {
"url": "string" // 仅视频笔记有此字段
}
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/xiaohongshu/app_v2/fetch_note_detail` | 笔记详情(旧版)| `note_id` |
| `GET /api/v1/xiaohongshu/web_v2/fetch_user_info` | 用户信息 | `user_id``username` |
| `GET /api/v1/xiaohongshu/web_v2/fetch_user_notes` | 用户笔记列表 | `user_id`, `cursor` |
| `GET /api/v1/xiaohongshu/app_v2/fetch_feed` | 推荐信息流 | `count` |
---
## 哔哩哔哩Bilibili
### Bilibili 网页版 API — `/api/v1/bilibili/web/`
#### ★ `GET /api/v1/bilibili/web/fetch_popular_video_list`
> **本项目使用** · 综合热门视频列表
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `pn` | integer | 否 | 页码,默认 1 |
| `ps` | integer | 否 | 每页数量,默认 20 |
响应结构:
```json
{
"data": {
"list": [
{
"bvid": "string", // 视频 BV 号(主要 ID
"aid": 123456, // av 号(备用)
"title": "string",
"pic": "string", // 封面图 URL直接字符串
"desc": "string",
"owner": {
"mid": 123456,
"name": "string",
"face": "string" // UP 主头像
},
"stat": {
"view": 100000, // 播放量(→ play_count
"like": 5000, // 点赞数
"reply": 200, // 评论数(→ comment_count
"share": 300,
"danmaku": 500, // 弹幕数(未映射)
"favorite": 1000, // 收藏数(未映射)
"coin": 800 // 投币数(未映射)
},
"pubdate": 1709000000, // Unix 时间戳(秒)
"duration": 300, // 时长(秒)
"tname": "string", // 分区名(→ tags[0]
"tags": [{ "tag_id": 1, "tag_name": "string" }]
}
]
}
}
```
---
#### ★ `GET /api/v1/bilibili/web/fetch_video_detail`
> **本项目使用** · 视频详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `bvid` | string | 是 | 视频 BV 号(如 `BV1xx411c7mD`|
响应结构:
```json
{
"data": { /* 同 list 中单条格式 */ }
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/bilibili/web/fetch_hot_search` | 热搜词 | 无 |
| `GET /api/v1/bilibili/web/fetch_user_info` | UP 主信息 | `mid`(用户 ID|
| `GET /api/v1/bilibili/web/fetch_user_videos` | UP 主视频列表 | `mid`, `pn`, `ps` |
| `GET /api/v1/bilibili/web/fetch_video_comment` | 视频评论 | `oid`aid, `pn`, `ps` |
| `GET /api/v1/bilibili/web/fetch_ranking` | 全站排行榜(每日更新)| `tid`(分区ID), `day`(1/3/7) |
---
## 微博Weibo
### 微博 App API — `/api/v1/weibo/app/`
#### ★ `GET /api/v1/weibo/app/fetch_hot_search`
> **本项目使用** · 微博热搜榜
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 无 | — | — | 无需参数 |
响应结构(两种格式,取其一):
**格式 1实际微博状态**
```json
{
"data": {
"statuses": [
{
"id": "string | number",
"mid": "string",
"text": "string", // HTML 格式,需 stripHtml 处理
"raw_text": "string",
"user": {
"screen_name": "string",
"profile_image_url": "string",
"avatar_hd": "string",
"avatar_large": "string"
},
"pic_ids": ["string"], // 图片 ID 列表
"pic_infos": {
"PIC_ID": {
"url": "string",
"large": { "url": "string" }
}
},
"attitudes_count": 5000, // 点赞数(→ like_count
"comments_count": 200,
"reposts_count": 300, // 转发数(→ share_count
"reads_count": 100000, // 阅读数(→ play_count
"created_at": "Mon Jan 01 00:00:00 +0800 2024"
}
]
}
}
```
**格式 2热搜话题**
```json
{
"data": {
"band_list": [
{
"word": "string", // 热搜词
"num": 10000, // 热度(→ play_count
"label_name": "string", // 标签(热、沸)
"mid": "string",
"rank": 1
}
],
"realtime": [/* 同上 */]
}
}
```
> **注意**
> - `text` 字段含 HTML 标签(`<a>`, `<span>` 等),需 strip 处理
> - 图片 URL 构建:`https://ww1.sinaimg.cn/large/{pic_id}.jpg`
> - 时间格式:`"Mon Jan 01 00:00:00 +0800 2024"`,可直接用 `new Date()` 解析
---
#### ★ `GET /api/v1/weibo/app/fetch_post_detail`
> **本项目使用** · 微博详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `mid` | string | 是 | 微博 ID |
响应结构:
```json
{
"data": { /* 同 statuses 中单条格式 */ }
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/weibo/web/fetch_hot_search` | 网页版热搜榜 | 无 |
| `GET /api/v1/weibo/web_v2/fetch_hot_search` | 网页版 V2 热搜榜 | 无 |
| `GET /api/v1/weibo/web/fetch_user_info` | 用户信息 | `uid``screen_name` |
| `GET /api/v1/weibo/web/fetch_user_statuses` | 用户微博列表 | `uid`, `page`, `count` |
---
## YouTube
### YouTube 网页版 API — `/api/v1/youtube/web/`
#### ★ `GET /api/v1/youtube/web/fetch_trending_video`
> **本项目使用** · YouTube 热门视频列表
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `count` | integer | 否 | 返回数量,默认 20 |
响应结构:
```json
{
"data": {
"items": [
{
"id": "string | { videoId: string }", // 视频 ID可能是字符串或对象
"snippet": {
"title": "string",
"description": "string",
"channelTitle": "string",
"publishedAt": "2024-01-15T10:00:00Z", // ISO 8601
"thumbnails": {
"maxres": { "url": "string" },
"high": { "url": "string" },
"medium": { "url": "string" },
"default": { "url": "string" }
},
"tags": ["string"]
},
"statistics": {
"viewCount": "100000", // 注意YouTube 统计字段是字符串类型
"likeCount": "5000", // 需 parseInt() 转换
"commentCount": "200"
}
}
]
}
}
```
> **注意**
> - `statistics.viewCount` 等字段为**字符串类型**,需用 `parseInt()` 转换
> - `id` 字段可能是字符串或 `{ videoId: string }` 对象,需兼容处理
> - 缩略图优先级:`maxres > high > medium > default`
---
#### ★ `GET /api/v1/youtube/web/fetch_video_detail`
> **本项目使用** · YouTube 视频详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `video_id` | string | 是 | 视频 ID`dQw4w9WgXcQ`|
响应结构:
```json
{
"data": {
"items": [{ /* 同 trending 中单条格式 */ }]
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/youtube/web/fetch_channel_info` | 频道信息 | `channel_id``username` |
| `GET /api/v1/youtube/web/fetch_channel_videos` | 频道视频列表 | `channel_id`, `count`, `page_token` |
| `GET /api/v1/youtube/web/fetch_search_results` | 视频搜索 | `keyword`, `count`, `page_token` |
| `GET /api/v1/youtube/web/fetch_playlist_videos` | 播放列表视频 | `playlist_id`, `count`, `page_token` |
---
## Instagram
### Instagram 网页版 API — `/api/v1/instagram/web/`
#### ★ `GET /api/v1/instagram/web/fetch_explore_feed`
> **本项目使用** · Instagram 探索页内容
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `count` | integer | 否 | 返回数量,默认 20 |
响应结构(两种格式,需同时处理):
**格式 1扁平 items 数组**
```json
{
"data": {
"items": [
{
"media": { /* InstagramMediaItem */ }
}
]
}
}
```
**格式 2分区 sectional_items**
```json
{
"data": {
"sectional_items": [
{
"layout_content": {
"medias": [
{ "media": { /* InstagramMediaItem */ } }
]
}
}
]
}
}
```
**InstagramMediaItem 结构**
```json
{
"pk": "string",
"id": "string",
"code": "string", // shortcode用于构建 URL 和作为 ID
"media_type": 1, // 1=图片, 2=视频, 8=轮播
"caption": { "text": "string" } | "string" | null,
"image_versions2": {
"candidates": [
{ "url": "string", "width": 1080, "height": 1080 }
]
},
"thumbnail_url": "string", // 视频封面(备用)
"video_url": "string", // 仅 media_type=2 时有值
"user": {
"username": "string",
"full_name": "string",
"profile_pic_url": "string"
},
"like_count": 5000,
"comment_count": 200,
"taken_at": 1709000000 // Unix 时间戳(秒)
}
```
> **注意**
> - 以 `code`shortcode作为内容 IDURL 格式:`https://www.instagram.com/p/{code}/`
> - `caption` 字段格式不固定,可能是对象、字符串或 null需兼容处理
---
#### ★ `GET /api/v1/instagram/web/fetch_post_detail`
> **本项目使用** · Instagram 帖子详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `shortcode` | string | 是 | 帖子 shortcode即内容 ID|
响应结构:
```json
{
"data": {
"items": [{ /* InstagramMediaItem */ }]
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/instagram/web/fetch_user_info` | 用户信息 | `username` |
| `GET /api/v1/instagram/web/fetch_user_posts` | 用户帖子 | `username`, `cursor`, `count` |
| `GET /api/v1/instagram/web/fetch_search_result` | 搜索用户/标签 | `keyword` |
| `GET /api/v1/instagram/web/fetch_hashtag_posts` | 标签内容 | `hashtag`, `cursor` |
---
## Twitter / X
### Twitter 网页版 API — `/api/v1/twitter/web/`
#### ★ `GET /api/v1/twitter/web/fetch_trending_topics`
> **本项目使用** · Twitter 热门话题和趋势推文
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `count` | integer | 否 | 返回数量,默认 20 |
响应结构(三种格式,需全部处理):
**格式 1推文数组**
```json
{
"data": {
"tweets": [
{
"id_str": "string",
"full_text": "string", // 优先使用
"text": "string", // 备用
"user": {
"name": "string",
"screen_name": "string", // 用户名(@后面的部分
"profile_image_url_https": "string"
},
"favorite_count": 5000, // 点赞数(→ like_count
"reply_count": 200, // 回复数(→ comment_count
"retweet_count": 300, // 转推数(→ share_count
"created_at": "Mon Jan 01 00:00:00 +0000 2024",
"entities": {
"media": [
{ "media_url_https": "string", "type": "photo | video" }
],
"hashtags": [{ "text": "string" }]
},
"extended_entities": {
"media": [/* 同上,优先使用 */]
}
}
]
}
}
```
**格式 2GraphQL 时间线Timeline Instructions**
```json
{
"data": {
"timeline": {
"instructions": [
{
"entries": [
{
"content": {
"itemContent": {
"tweet_results": {
"result": {
"legacy": { /* 同 TwitterTweet 格式 */ },
"core": {
"user_results": {
"result": {
"legacy": { /* TwitterUser 格式 */ }
}
}
}
}
}
}
}
}
]
}
]
}
}
}
```
**格式 3热门话题列表**
```json
{
"data": {
"trends": [
{
"name": "string", // 话题名称
"tweet_volume": 10000, // 推文量(→ play_count
"query": "string" // URL 编码的搜索查询
}
]
}
}
```
> **注意**
> - 时间格式:`"Mon Jan 01 00:00:00 +0000 2024"`,可直接用 `new Date()` 解析
> - 封面图来自 `extended_entities.media`(优先)或 `entities.media`,找 `type === 'photo'` 的条目
> - 原帖 URL 格式:`https://x.com/{screen_name}/status/{id_str}`
---
#### ★ `GET /api/v1/twitter/web/fetch_tweet_detail`
> **本项目使用** · 推文详情
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `tweet_id` | string | 是 | 推文 ID |
响应结构(两种格式):
```json
// 格式 1GraphQL
{
"data": {
"tweetResult": {
"result": {
"legacy": { /* TwitterTweet */ },
"core": { "user_results": { "result": { "legacy": { /* TwitterUser */ } } } }
}
}
}
}
// 格式 2直接对象
{
"data": {
"tweet": { /* TwitterTweet */ }
}
}
```
---
#### 其他常用端点
| 端点 | 说明 | 关键参数 |
|------|------|----------|
| `GET /api/v1/twitter/web/fetch_user_info` | 用户信息 | `screen_name` |
| `GET /api/v1/twitter/web/fetch_user_tweets` | 用户推文 | `screen_name`, `cursor`, `count` |
| `GET /api/v1/twitter/web/fetch_search_timeline` | 搜索推文 | `keyword`, `cursor`, `count` |
| `GET /api/v1/twitter/web/fetch_tweet_replies` | 推文回复 | `tweet_id`, `cursor` |
---
## 通用说明
### 本项目中使用的端点汇总
| 平台 | fetchHotList 端点 | fetchDetail 端点 |
|------|------------------|-----------------|
| 抖音 | `douyin/web/fetch_hot_search_result` | `douyin/web/fetch_one_video` → 回退 `fetch_hot_search_result` |
| TikTok | `tiktok/web/fetch_explore_post` | `tiktok/web/fetch_post_detail` |
| 小红书 | `xiaohongshu/web_v2/fetch_hot_list` + `xiaohongshu/app_v2/search_notes` | `xiaohongshu/app_v2/get_mixed_note_detail` |
| YouTube | `youtube/web/fetch_trending_video` | `youtube/web/fetch_video_detail` |
| Instagram | `instagram/web/fetch_explore_feed` | `instagram/web/fetch_post_detail` |
| Twitter | `twitter/web/fetch_trending_topics` | `twitter/web/fetch_tweet_detail` |
| 哔哩哔哩 | `bilibili/web/fetch_popular_video_list` | `bilibili/web/fetch_video_detail` |
| 微博 | `weibo/app/fetch_hot_search` | `weibo/app/fetch_post_detail` |
### 时间戳处理
| 平台 | 时间格式 | 处理方式 |
|------|---------|----------|
| 抖音、TikTok、小红书、B站、微博、Instagram | Unix 时间戳(秒)| `new Date(timestamp * 1000).toISOString()` |
| YouTube | ISO 8601 字符串 | 直接使用 |
| Twitter | `"Mon Jan 01 00:00:00 +0000 2024"` | `new Date(dateStr).toISOString()` |
| 微博 | `"Mon Jan 01 00:00:00 +0800 2024"` | `new Date(dateStr).toISOString()` |
### 图片 URL 类型
| 平台 | 图片字段类型 |
|------|------------|
| 抖音 | `url_list: string[]`(取 `[0]`|
| TikTok | `cover: string`(直接字符串)|
| 小红书 | `images_list[0].url_size_large \|\| url` |
| B站 | `pic: string`(直接字符串)|
| YouTube | `thumbnails.maxres.url`(对象嵌套)|
| Instagram | `image_versions2.candidates[0].url` |
| Twitter | `entities/extended_entities.media[0].media_url_https` |
| 微博 | `pic_infos[id].large.url``https://ww1.sinaimg.cn/large/{id}.jpg` |
---
*文档基于项目实际使用端点 + TikHub OpenAPI 规范整理。完整端点列表700+)请访问 https://api.tikhub.io*

View File

@ -3,35 +3,17 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.576.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.11"
"dev": "pnpm -r --parallel dev",
"build": "pnpm -r build",
"test": "pnpm -r test",
"test:watch": "pnpm -r --parallel test:watch",
"test:coverage": "pnpm -r test:coverage",
"lint": "pnpm --filter @muse/frontend lint",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:real": "playwright test --config=playwright-real.config.ts"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"@playwright/test": "^1.58.2"
}
}

View File

@ -0,0 +1,25 @@
{
"name": "@muse/backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@muse/shared": "workspace:*",
"hono": "^4.7.0",
"@hono/node-server": "^1.14.0"
},
"devDependencies": {
"typescript": "^5",
"tsx": "^4.19.0",
"vitest": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"@types/node": "^20"
}
}

View File

@ -0,0 +1,20 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { tikhubRoutes } from "./routes/tikhub";
import { settingsRoutes } from "./routes/settings";
const app = new Hono();
app.use(
"*",
cors({
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
})
);
app.route("/api/tikhub", tikhubRoutes);
app.route("/api/settings", settingsRoutes);
app.get("/health", (c) => c.json({ status: "ok" }));
export { app };

View File

@ -0,0 +1,8 @@
import { serve } from "@hono/node-server";
import { app } from "./app";
const port = parseInt(process.env.PORT || "3001", 10);
serve({ fetch: app.fetch, port }, () => {
console.log(`🚀 Muse Backend running on http://localhost:${port}`);
});

View File

@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { BilibiliAdapter } from "./bilibili";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("BilibiliAdapter", () => {
let adapter: BilibiliAdapter;
beforeEach(() => {
adapter = new BilibiliAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from popular video list", async () => {
// Bilibili double-wraps: tikhubFetch unwraps outer, inner is { code, data: { list } }
mockFetch.mockResolvedValueOnce({
code: 0,
message: "OK",
data: {
list: [
{
aid: 123456789,
bvid: "BV1xx411c7mD",
title: "B站热门视频",
pic: "https://i0.hdslb.com/cover.jpg",
owner: {
name: "UP主小明",
face: "https://i0.hdslb.com/avatar.jpg",
},
stat: {
aid: 123456789,
view: 1000000,
like: 50000,
favorite: 20000,
reply: 3000,
share: 5000,
},
pubdate: 1709424000,
tname: "科技",
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("123456789");
expect(items[0].title).toBe("B站热门视频");
expect(items[0].platform).toBe("bilibili");
expect(items[0].author_name).toBe("UP主小明");
expect(items[0].play_count).toBe(1000000);
expect(items[0].like_count).toBe(50000);
expect(items[0].collect_count).toBe(20000);
expect(items[0].comment_count).toBe(3000);
expect(items[0].share_count).toBe(5000);
expect(items[0].cover_url).toBe("https://i0.hdslb.com/cover.jpg");
expect(items[0].tags).toEqual(["科技"]);
expect(items[0].original_url).toBe("https://www.bilibili.com/video/BV1xx411c7mD");
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("uses default values for missing fields", async () => {
mockFetch.mockResolvedValueOnce({
data: { list: [{}] },
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("无标题");
expect(items[0].author_name).toBe("未知作者");
expect(items[0].play_count).toBeUndefined();
});
it("slices results to requested count", async () => {
const list = Array.from({ length: 30 }, (_, i) => ({
bvid: `BV${i}`,
title: `Video ${i}`,
}));
mockFetch.mockResolvedValueOnce({ data: { list } });
const items = await adapter.fetchTrending(5);
expect(items).toHaveLength(5);
});
it("maps aid as primary ID", async () => {
mockFetch.mockResolvedValueOnce({
data: {
list: [
{
aid: 999888,
bvid: "BV1abc",
title: "AID Test",
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items[0].id).toBe("999888");
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from video detail", async () => {
// Detail response: { code, message, data: { View: {...} } }
mockFetch.mockResolvedValueOnce({
code: 0,
message: "OK",
data: {
View: {
aid: 111222333,
bvid: "BV1detail",
title: "详情视频",
pic: "https://i0.hdslb.com/detail.jpg",
owner: { name: "详情UP主", face: "https://face.jpg" },
stat: {
view: 500000,
like: 25000,
favorite: 10000,
reply: 1500,
share: 3000,
},
pubdate: 1709424000,
},
},
});
const item = await adapter.fetchDetail("111222333");
expect(item.id).toBe("111222333");
expect(item.title).toBe("详情视频");
expect(item.platform).toBe("bilibili");
expect(item.play_count).toBe(500000);
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("BV999");
expect(item.title).toBe("无标题");
expect(item.author_name).toBe("未知作者");
});
});
});

View File

@ -0,0 +1,66 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
export class BilibiliAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/bilibili/web/fetch_com_popular"
);
// tikhubFetch unwraps outer { code, data } envelope, but Bilibili wraps again:
// data = { code: 0, message: "OK", data: { list: [...] } }
const inner = data?.data || data;
const list = inner?.list || data?.list || [];
const items = Array.isArray(list) ? list : [];
return items
.slice(0, count)
.map((item: Record<string, unknown>, index: number) =>
this.mapToContentItem(item, index)
);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/bilibili/web/fetch_video_detail",
{ aid: id }
);
// Response: { code, message, data: { View: {...} } }
const inner = data?.data || data;
const videoData = inner?.View || inner || {};
return this.mapToContentItem(videoData, 0);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number): ContentItem {
const stat = raw?.stat || {};
const owner = raw?.owner || {};
const bvid = raw?.bvid || raw?.id || `bl-${index}`;
const aid = raw?.aid || stat?.aid || "";
return {
id: String(aid || bvid),
title: raw?.title || "无标题",
cover_url: raw?.pic || undefined,
video_url: undefined,
author_name: owner?.name || raw?.author || "未知作者",
author_avatar: owner?.face || undefined,
play_count: stat?.view ?? undefined,
like_count: stat?.like ?? undefined,
collect_count: stat?.favorite ?? undefined,
comment_count: stat?.reply ?? undefined,
share_count: stat?.share ?? undefined,
publish_time: raw?.pubdate
? new Date(raw.pubdate * 1000).toISOString()
: raw?.ctime
? new Date(raw.ctime * 1000).toISOString()
: new Date().toISOString(),
platform: "bilibili",
original_url: `https://www.bilibili.com/video/${bvid}`,
tags: raw?.tname ? [raw.tname] : undefined,
};
}
}

View File

@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { DouyinAdapter } from "./douyin";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("DouyinAdapter", () => {
let adapter: DouyinAdapter;
beforeEach(() => {
adapter = new DouyinAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from hot video list", async () => {
mockFetch.mockResolvedValueOnce({
data: {
objs: [
{
item_id: "123",
item_title: "测试视频",
item_cover_url: "https://img.douyin.com/cover.jpg",
item_url: "https://douyin.com/video/123",
nick_name: "测试作者",
avatar_url: "https://img.douyin.com/avatar.jpg",
play_cnt: 10000,
like_cnt: 500,
publish_time: 1709424000,
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("123");
expect(items[0].title).toBe("测试视频");
expect(items[0].platform).toBe("douyin");
expect(items[0].author_name).toBe("测试作者");
expect(items[0].play_count).toBe(10000);
expect(items[0].like_count).toBe(500);
expect(items[0].cover_url).toBe("https://img.douyin.com/cover.jpg");
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({ data: {} });
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("uses default values for missing fields", async () => {
mockFetch.mockResolvedValueOnce({
data: { objs: [{}] },
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("无标题");
expect(items[0].author_name).toBe("未知作者");
expect(items[0].play_count).toBeUndefined();
});
it("slices results to requested count", async () => {
const objs = Array.from({ length: 30 }, (_, i) => ({
item_id: String(i),
item_title: `Video ${i}`,
}));
mockFetch.mockResolvedValueOnce({ data: { objs } });
const items = await adapter.fetchTrending(5);
expect(items).toHaveLength(5);
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from video detail", async () => {
mockFetch.mockResolvedValueOnce({
aweme_detail: {
aweme_id: "456",
desc: "详情视频描述",
video: {
cover: { url_list: ["https://cover.jpg"] },
play_addr: { url_list: ["https://video.mp4"] },
},
author: {
nickname: "作者名",
avatar_thumb: { url_list: ["https://avatar.jpg"] },
},
statistics: {
play_count: 50000,
digg_count: 2000,
comment_count: 100,
share_count: 50,
collect_count: 300,
},
create_time: 1709424000,
share_url: "https://www.douyin.com/video/456",
text_extra: [{ hashtag_name: "热门" }, { hashtag_name: "创意" }],
},
});
const item = await adapter.fetchDetail("456");
expect(item.id).toBe("456");
expect(item.title).toBe("详情视频描述");
expect(item.cover_url).toBe("https://cover.jpg");
expect(item.video_url).toBe("https://video.mp4");
expect(item.author_name).toBe("作者名");
expect(item.play_count).toBe(50000);
expect(item.like_count).toBe(2000);
expect(item.tags).toEqual(["热门", "创意"]);
expect(item.platform).toBe("douyin");
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("999");
expect(item.id).toBe("unknown");
expect(item.title).toBe("无标题");
expect(item.author_name).toBe("未知作者");
});
});
});

View File

@ -0,0 +1,88 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
export class DouyinAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/douyin/billboard/fetch_hot_total_video_list",
undefined,
"POST"
);
const list = data?.data?.objs || data?.data?.list || data?.data || [];
const items = Array.isArray(list) ? list : [];
return items.slice(0, count).map((item: Record<string, unknown>, index: number) =>
this.mapToContentItem(item, index)
);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/douyin/web/fetch_one_video",
{ aweme_id: id }
);
const videoData = data?.aweme_detail || data?.data?.aweme_detail || data?.data || {};
return this.mapDetailItem(videoData);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number): ContentItem {
return {
id: String(raw?.item_id || `douyin-${index}`),
title: raw?.item_title || "无标题",
cover_url: raw?.item_cover_url || undefined,
video_url: raw?.item_url || undefined,
author_name: raw?.nick_name || "未知作者",
author_avatar: raw?.avatar_url || undefined,
play_count: raw?.play_cnt ?? undefined,
like_count: raw?.like_cnt ?? undefined,
collect_count: undefined,
comment_count: undefined,
share_count: undefined,
publish_time: raw?.publish_time
? new Date(raw.publish_time * 1000).toISOString()
: new Date().toISOString(),
platform: "douyin",
original_url: `https://www.douyin.com/video/${raw?.item_id || ""}`,
tags: undefined,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapDetailItem(raw: any): ContentItem {
const stats = raw?.statistics || raw?.stats || {};
const author = raw?.author || {};
return {
id: String(raw?.aweme_id || raw?.id || "unknown"),
title: raw?.desc || raw?.title || "无标题",
cover_url:
raw?.video?.cover?.url_list?.[0] ||
raw?.video?.dynamic_cover?.url_list?.[0] ||
undefined,
video_url: raw?.video?.play_addr?.url_list?.[0] || undefined,
author_name: author?.nickname || "未知作者",
author_avatar: author?.avatar_thumb?.url_list?.[0] || undefined,
play_count: stats?.play_count ?? undefined,
like_count: stats?.digg_count ?? undefined,
collect_count: stats?.collect_count ?? undefined,
comment_count: stats?.comment_count ?? undefined,
share_count: stats?.share_count ?? undefined,
publish_time: raw?.create_time
? new Date(raw.create_time * 1000).toISOString()
: new Date().toISOString(),
platform: "douyin",
original_url:
raw?.share_url ||
`https://www.douyin.com/video/${raw?.aweme_id || raw?.id || ""}`,
tags:
raw?.text_extra
?.map((t: { hashtag_name?: string }) => t.hashtag_name)
.filter(Boolean) || undefined,
};
}
}

View File

@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { getAdapter, getSupportedPlatforms } from "./index";
describe("getAdapter", () => {
it("returns DouyinAdapter for douyin", () => {
const adapter = getAdapter("douyin");
expect(adapter).not.toBeNull();
expect(adapter!.fetchTrending).toBeDefined();
expect(adapter!.fetchDetail).toBeDefined();
});
it("returns TikTokAdapter for tiktok", () => {
const adapter = getAdapter("tiktok");
expect(adapter).not.toBeNull();
});
it("returns XiaohongshuAdapter for xiaohongshu", () => {
const adapter = getAdapter("xiaohongshu");
expect(adapter).not.toBeNull();
});
it("returns YouTubeAdapter for youtube", () => {
const adapter = getAdapter("youtube");
expect(adapter).not.toBeNull();
});
it("returns InstagramAdapter for instagram", () => {
const adapter = getAdapter("instagram");
expect(adapter).not.toBeNull();
});
it("returns TwitterAdapter for twitter", () => {
const adapter = getAdapter("twitter");
expect(adapter).not.toBeNull();
});
it("returns BilibiliAdapter for bilibili", () => {
const adapter = getAdapter("bilibili");
expect(adapter).not.toBeNull();
});
it("returns WeiboAdapter for weibo", () => {
const adapter = getAdapter("weibo");
expect(adapter).not.toBeNull();
});
});
describe("getSupportedPlatforms", () => {
it("returns all registered platforms", () => {
const platforms = getSupportedPlatforms();
expect(platforms).toContain("douyin");
expect(platforms).toContain("tiktok");
expect(platforms).toContain("xiaohongshu");
expect(platforms).toContain("youtube");
expect(platforms).toContain("instagram");
expect(platforms).toContain("twitter");
expect(platforms).toContain("bilibili");
expect(platforms).toContain("weibo");
expect(platforms).toHaveLength(8);
});
});

View File

@ -1,12 +1,22 @@
import type { Platform, PlatformAdapter } from "@/types/content";
import type { Platform, PlatformAdapter } from "@muse/shared";
import { DouyinAdapter } from "./douyin";
import { TikTokAdapter } from "./tiktok";
import { XiaohongshuAdapter } from "./xiaohongshu";
import { YouTubeAdapter } from "./youtube";
import { InstagramAdapter } from "./instagram";
import { TwitterAdapter } from "./twitter";
import { BilibiliAdapter } from "./bilibili";
import { WeiboAdapter } from "./weibo";
const adapters: Partial<Record<Platform, PlatformAdapter>> = {
douyin: new DouyinAdapter(),
tiktok: new TikTokAdapter(),
xiaohongshu: new XiaohongshuAdapter(),
youtube: new YouTubeAdapter(),
instagram: new InstagramAdapter(),
twitter: new TwitterAdapter(),
bilibili: new BilibiliAdapter(),
weibo: new WeiboAdapter(),
};
export function getAdapter(platform: Platform): PlatformAdapter | null {

View File

@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { InstagramAdapter } from "./instagram";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("InstagramAdapter", () => {
let adapter: InstagramAdapter;
beforeEach(() => {
adapter = new InstagramAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from flat items format", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "ABC123",
caption: { text: "Beautiful sunset photo" },
image_versions2: {
candidates: [{ url: "https://ig.com/photo.jpg" }],
},
user: { username: "photographer", profile_pic_url: "https://ig.com/avatar.jpg" },
like_count: 5000,
comment_count: 200,
taken_at: 1709424000,
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("ABC123");
expect(items[0].title).toBe("Beautiful sunset photo");
expect(items[0].platform).toBe("instagram");
expect(items[0].author_name).toBe("photographer");
expect(items[0].like_count).toBe(5000);
expect(items[0].cover_url).toBe("https://ig.com/photo.jpg");
});
it("returns mapped ContentItem[] from sections format", async () => {
mockFetch.mockResolvedValueOnce({
sections: [
{
section_id: "123",
name: "Fashion",
subsections: [
{
medias: [
{
code: "SEC001",
caption: "Section post",
user: { username: "user1" },
like_count: 1000,
},
],
},
],
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("SEC001");
expect(items[0].title).toBe("Section post");
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("handles caption as object with text", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "cap-obj",
caption: { text: "Caption from object" },
user: { username: "test" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("Caption from object");
});
it("handles caption as string", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "cap-str",
caption: "String caption",
user: { username: "test" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("String caption");
});
it("handles null caption", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "cap-null",
caption: null,
user: { username: "test" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("Untitled");
});
it("uses thumbnail_url as fallback cover", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "thumb-test",
thumbnail_url: "https://ig.com/thumb.jpg",
user: { username: "test" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].cover_url).toBe("https://ig.com/thumb.jpg");
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from post detail", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "detail-789",
caption: { text: "Detail post" },
user: { username: "detail_user", full_name: "Detail User" },
like_count: 10000,
comment_count: 500,
taken_at: 1709424000,
},
],
});
const item = await adapter.fetchDetail("detail-789");
expect(item.id).toBe("detail-789");
expect(item.title).toBe("Detail post");
expect(item.platform).toBe("instagram");
expect(item.like_count).toBe(10000);
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("999");
expect(item.title).toBe("Untitled");
expect(item.author_name).toBe("Unknown");
});
});
});

View File

@ -0,0 +1,96 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
export class InstagramAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/instagram/v1/fetch_explore_sections"
);
// Response: { sections: [{ subsections: [{ medias: [...] }] }] }
// Or flat: { items: [...] } or { sectional_items: [...] }
let items: unknown[] = [];
if (Array.isArray(data?.sections)) {
for (const section of data.sections) {
const subsections = section?.subsections || [];
for (const sub of subsections) {
const medias = sub?.medias || [];
for (const m of medias) {
items.push(m?.media || m);
}
}
}
} else if (Array.isArray(data?.items)) {
items = data.items;
} else if (Array.isArray(data?.sectional_items)) {
for (const section of data.sectional_items) {
const medias = section?.layout_content?.medias || [];
for (const m of medias) {
items.push(m?.media || m);
}
}
}
return items
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapToContentItem(item as Record<string, unknown>, index)
);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/instagram/v2/fetch_post_info",
{ shortcode: id }
);
const postData = data?.items?.[0] || data?.data?.items?.[0] || data || {};
return this.mapToContentItem(postData, 0);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number): ContentItem {
const postId = raw?.code || raw?.shortcode || raw?.pk || raw?.id || `ig-${index}`;
// caption can be { text }, string, or null
let title = "";
const caption = raw?.caption;
if (caption && typeof caption === "object" && caption.text) {
title = caption.text;
} else if (typeof caption === "string") {
title = caption;
}
if (!title) title = raw?.title || "Untitled";
const coverUrl =
raw?.image_versions2?.candidates?.[0]?.url ||
raw?.thumbnail_url ||
raw?.display_url ||
undefined;
const user = raw?.user || raw?.owner || {};
return {
id: String(postId),
title: title.slice(0, 200),
cover_url: coverUrl,
video_url: raw?.video_url || undefined,
author_name: user?.username || user?.full_name || "Unknown",
author_avatar: user?.profile_pic_url || undefined,
play_count: raw?.video_view_count ?? raw?.play_count ?? undefined,
like_count: raw?.like_count ?? undefined,
collect_count: raw?.saved_count ?? undefined,
comment_count: raw?.comment_count ?? undefined,
share_count: raw?.reshare_count ?? undefined,
publish_time: raw?.taken_at
? new Date(raw.taken_at * 1000).toISOString()
: new Date().toISOString(),
platform: "instagram",
original_url: `https://www.instagram.com/p/${postId}/`,
tags: undefined,
};
}
}

View File

@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TikTokAdapter } from "./tiktok";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("TikTokAdapter", () => {
let adapter: TikTokAdapter;
beforeEach(() => {
adapter = new TikTokAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from explore posts", async () => {
mockFetch.mockResolvedValueOnce({
itemList: [
{
id: "tt-123",
desc: "Trending video",
video: {
cover: "https://tiktok.com/cover.jpg",
playAddr: "https://tiktok.com/play.mp4",
},
author: {
nickname: "Creator",
uniqueId: "creator123",
avatarThumb: "https://tiktok.com/avatar.jpg",
},
stats: {
playCount: 100000,
diggCount: 5000,
commentCount: 200,
shareCount: 80,
collectCount: 150,
},
createTime: 1709424000,
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("tt-123");
expect(items[0].title).toBe("Trending video");
expect(items[0].platform).toBe("tiktok");
expect(items[0].author_name).toBe("Creator");
expect(items[0].play_count).toBe(100000);
expect(items[0].like_count).toBe(5000);
expect(items[0].cover_url).toBe("https://tiktok.com/cover.jpg");
});
it("handles empty response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("handles alternative data shapes", async () => {
mockFetch.mockResolvedValueOnce({
data: {
itemList: [
{
id: "alt-1",
desc: "Alt format",
video: {},
author: {},
stats: {},
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("alt-1");
});
it("uses default values for missing fields", async () => {
mockFetch.mockResolvedValueOnce({
itemList: [{ video: {}, author: {}, stats: {} }],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("Untitled");
expect(items[0].author_name).toBe("Unknown");
});
it("slices results to count", async () => {
const itemList = Array.from({ length: 25 }, (_, i) => ({
id: String(i),
desc: `Video ${i}`,
video: {},
author: {},
stats: {},
}));
mockFetch.mockResolvedValueOnce({ itemList });
const items = await adapter.fetchTrending(10);
expect(items).toHaveLength(10);
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from post detail", async () => {
mockFetch.mockResolvedValueOnce({
itemInfo: {
itemStruct: {
id: "detail-1",
desc: "Detail video",
video: { cover: "https://cover.jpg" },
author: { nickname: "Author" },
stats: { playCount: 999, diggCount: 100 },
createTime: 1709424000,
},
},
});
const item = await adapter.fetchDetail("detail-1");
expect(item.id).toBe("detail-1");
expect(item.title).toBe("Detail video");
expect(item.play_count).toBe(999);
});
it("extracts tags from challenges array", async () => {
mockFetch.mockResolvedValueOnce({
itemInfo: {
itemStruct: {
id: "tag-1",
video: {},
author: {},
stats: {},
challenges: [{ title: "trending" }, { title: "viral" }],
},
},
});
const item = await adapter.fetchDetail("tag-1");
expect(item.tags).toEqual(["trending", "viral"]);
});
});
});

View File

@ -0,0 +1,89 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
export class TikTokAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/tiktok/web/fetch_explore_post"
);
const list =
data?.itemList ||
data?.data?.itemList ||
data?.items ||
[];
const items = Array.isArray(list) ? list : [];
return items.slice(0, count).map((item: Record<string, unknown>, index: number) =>
this.mapToContentItem(item, index)
);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/tiktok/web/fetch_post_detail",
{ itemId: id }
);
const videoData =
data?.data?.aweme_detail || data?.itemInfo?.itemStruct || data?.data || data || {};
return this.mapToContentItem(videoData, 0);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number): ContentItem {
const stats = raw?.stats || raw?.statistics || {};
const author = raw?.author || {};
const video = raw?.video || {};
const coverUrl =
(typeof video?.cover === "string" ? video.cover : null) ||
video?.cover?.url_list?.[0] ||
video?.originCover ||
video?.dynamicCover ||
raw?.cover ||
undefined;
return {
id: String(raw?.id || raw?.aweme_id || `tiktok-${index}`),
title: raw?.desc || raw?.title || "Untitled",
cover_url: coverUrl,
video_url:
(typeof video?.playAddr === "string" ? video.playAddr : null) ||
video?.playAddr?.url_list?.[0] ||
video?.play_addr?.url_list?.[0] ||
undefined,
author_name:
author?.nickname || author?.uniqueId || author?.unique_id || "Unknown",
author_avatar:
author?.avatarThumb || author?.avatarMedium ||
author?.avatar_thumb?.url_list?.[0] ||
undefined,
play_count: stats?.playCount ?? stats?.play_count ?? undefined,
like_count: stats?.diggCount ?? stats?.digg_count ?? undefined,
collect_count: stats?.collectCount ?? stats?.collect_count ?? undefined,
comment_count: stats?.commentCount ?? stats?.comment_count ?? undefined,
share_count: stats?.shareCount ?? stats?.share_count ?? undefined,
publish_time: raw?.createTime
? new Date(raw.createTime * 1000).toISOString()
: raw?.create_time
? new Date(raw.create_time * 1000).toISOString()
: new Date().toISOString(),
platform: "tiktok",
original_url:
raw?.share_url ||
`https://www.tiktok.com/@${author?.uniqueId || author?.unique_id || "user"}/video/${raw?.id || raw?.aweme_id || ""}`,
tags:
raw?.textExtra
?.map((t: { hashtagName?: string; hashtag_name?: string }) => t.hashtagName || t.hashtag_name)
.filter(Boolean) ||
raw?.challenges
?.map((c: { title?: string }) => c.title)
.filter(Boolean) ||
undefined,
};
}
}

View File

@ -0,0 +1,213 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TwitterAdapter } from "./twitter";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("TwitterAdapter", () => {
let adapter: TwitterAdapter;
beforeEach(() => {
adapter = new TwitterAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from tweets format", async () => {
mockFetch.mockResolvedValueOnce({
tweets: [
{
id_str: "1234567890",
full_text: "This is a trending tweet!",
user: {
name: "Test User",
screen_name: "testuser",
profile_image_url_https: "https://pbs.twimg.com/avatar.jpg",
},
favorite_count: 5000,
retweet_count: 2000,
reply_count: 300,
created_at: "Mon Jan 15 08:00:00 +0000 2024",
entities: {
hashtags: [{ text: "trending" }, { text: "test" }],
},
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("1234567890");
expect(items[0].title).toBe("This is a trending tweet!");
expect(items[0].platform).toBe("twitter");
expect(items[0].author_name).toBe("Test User");
expect(items[0].like_count).toBe(5000);
expect(items[0].share_count).toBe(2000);
expect(items[0].tags).toEqual(["trending", "test"]);
});
it("returns mapped ContentItem[] from GraphQL format", async () => {
mockFetch.mockResolvedValueOnce({
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
legacy: {
id_str: "gql-001",
full_text: "GraphQL tweet",
user: { name: "GQL User" },
favorite_count: 100,
},
},
},
},
},
},
],
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("gql-001");
expect(items[0].title).toBe("GraphQL tweet");
});
it("returns mapped ContentItem[] from trends format", async () => {
mockFetch.mockResolvedValueOnce({
trends: [
{
name: "#TrendingTopic",
tweet_volume: 50000,
url: "https://twitter.com/search?q=%23TrendingTopic",
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].title).toBe("#TrendingTopic");
expect(items[0].play_count).toBe(50000);
expect(items[0].author_name).toBe("Twitter Trending");
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("strips HTML from tweet text", async () => {
mockFetch.mockResolvedValueOnce({
tweets: [
{
id_str: "html-001",
full_text: "Check out <a href='https://t.co/test'>this link</a>!",
user: { name: "HTML User" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("Check out this link!");
});
it("extracts media from extended_entities", async () => {
mockFetch.mockResolvedValueOnce({
tweets: [
{
id_str: "media-001",
full_text: "Tweet with media",
user: { name: "Media User" },
extended_entities: {
media: [
{
media_url_https: "https://pbs.twimg.com/media/test.jpg",
video_info: { variants: [{ url: "https://video.twimg.com/test.mp4" }] },
},
],
},
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].cover_url).toBe("https://pbs.twimg.com/media/test.jpg");
expect(items[0].video_url).toBe("https://video.twimg.com/test.mp4");
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from GraphQL detail format", async () => {
mockFetch.mockResolvedValueOnce({
tweetResult: {
result: {
legacy: {
id_str: "detail-001",
full_text: "Detail tweet content",
favorite_count: 1000,
retweet_count: 500,
created_at: "Wed Feb 01 12:00:00 +0000 2024",
},
core: {
user_results: {
result: {
legacy: {
name: "Detail Author",
screen_name: "detailauthor",
},
},
},
},
},
},
});
const item = await adapter.fetchDetail("detail-001");
expect(item.id).toBe("detail-001");
expect(item.title).toBe("Detail tweet content");
expect(item.author_name).toBe("Detail Author");
expect(item.like_count).toBe(1000);
});
it("returns mapped ContentItem from direct tweet format", async () => {
mockFetch.mockResolvedValueOnce({
tweet: {
legacy: {
id_str: "direct-001",
full_text: "Direct tweet",
user: { name: "Direct User" },
favorite_count: 200,
},
},
});
const item = await adapter.fetchDetail("direct-001");
expect(item.id).toBe("direct-001");
expect(item.title).toBe("Direct tweet");
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("999");
expect(item.title).toBe("Untitled");
});
});
});

View File

@ -0,0 +1,133 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
function stripHtml(text: string): string {
return text.replace(/<[^>]*>/g, "").trim();
}
function parseTwitterDate(dateStr: string): string {
// Twitter format: "Mon Jan 01 00:00:00 +0000 2024"
const d = new Date(dateStr);
return isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
}
export class TwitterAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/twitter/web/fetch_trending"
);
// Response formats:
// 1. { trends: [{ name, description, context }] } — trending topics
// 2. { tweets: [...] } — tweet objects
// 3. GraphQL timeline.instructions[].entries[]
let items: unknown[] = [];
if (Array.isArray(data?.trends)) {
items = data.trends;
} else if (Array.isArray(data?.tweets)) {
items = data.tweets;
} else if (data?.timeline?.instructions) {
const instructions = data.timeline.instructions;
for (const inst of instructions) {
const entries = inst?.entries || [];
for (const entry of entries) {
const tweet =
entry?.content?.itemContent?.tweet_results?.result?.legacy ||
entry?.content?.itemContent?.tweet_results?.result ||
null;
if (tweet) items.push(tweet);
}
}
}
return items
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapToContentItem(item as Record<string, unknown>, index)
);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/twitter/web/fetch_tweet_detail",
{ tweet_id: id }
);
// Two formats: GraphQL tweetResult.result or direct tweet object
const tweetData =
data?.tweetResult?.result?.legacy ||
data?.tweetResult?.result ||
data?.tweet?.legacy ||
data?.tweet ||
data || {};
const userResult =
data?.tweetResult?.result?.core?.user_results?.result?.legacy ||
data?.tweet?.core?.user_results?.result?.legacy ||
null;
return this.mapToContentItem(tweetData, 0, userResult);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number, userOverride?: any): ContentItem {
const tweetId = raw?.id_str || raw?.rest_id || raw?.id || `tw-${index}`;
// For trend items (from fetch_trending: { name, description, context })
if (raw?.name && !raw?.full_text && !raw?.text) {
return {
id: String(tweetId || `tw-trend-${index}`),
title: raw.name,
cover_url: undefined,
video_url: undefined,
author_name: raw?.context || "Twitter Trending",
author_avatar: undefined,
play_count: raw?.tweet_volume ?? undefined,
like_count: undefined,
collect_count: undefined,
comment_count: undefined,
share_count: undefined,
publish_time: new Date().toISOString(),
platform: "twitter",
original_url: raw?.url || `https://twitter.com/search?q=${encodeURIComponent(raw.name)}`,
tags: undefined,
};
}
const text = raw?.full_text || raw?.text || "";
const title = stripHtml(text).slice(0, 200) || "Untitled";
const user = userOverride || raw?.user || {};
const media =
raw?.extended_entities?.media?.[0] ||
raw?.entities?.media?.[0] ||
null;
const coverUrl = media?.media_url_https || media?.media_url || undefined;
return {
id: String(tweetId),
title,
cover_url: coverUrl,
video_url: media?.video_info?.variants?.[0]?.url || undefined,
author_name: user?.name || user?.screen_name || "Unknown",
author_avatar: user?.profile_image_url_https || undefined,
play_count: undefined,
like_count: raw?.favorite_count ?? undefined,
collect_count: raw?.bookmark_count ?? undefined,
comment_count: raw?.reply_count ?? undefined,
share_count: raw?.retweet_count ?? undefined,
publish_time: raw?.created_at
? parseTwitterDate(raw.created_at)
: new Date().toISOString(),
platform: "twitter",
original_url: `https://twitter.com/i/status/${tweetId}`,
tags: raw?.entities?.hashtags
?.map((h: { text?: string }) => h.text)
.filter(Boolean) || undefined,
};
}
}

View File

@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { WeiboAdapter } from "./weibo";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("WeiboAdapter", () => {
let adapter: WeiboAdapter;
beforeEach(() => {
adapter = new WeiboAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from statuses format", async () => {
mockFetch.mockResolvedValueOnce({
statuses: [
{
mid: "5012345678",
text: "今天天气真好 <a href='https://t.cn/test'>链接</a>",
user: {
screen_name: "微博用户",
id: "1234567",
profile_image_url: "https://tvax.sinaimg.cn/avatar.jpg",
},
attitudes_count: 5000,
comments_count: 1200,
reposts_count: 800,
created_at: "Mon Jan 15 08:00:00 +0800 2024",
pic_infos: {
"pic001": {
large: { url: "https://ww1.sinaimg.cn/large/pic001.jpg" },
},
},
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("5012345678");
expect(items[0].title).toBe("今天天气真好 链接");
expect(items[0].platform).toBe("weibo");
expect(items[0].author_name).toBe("微博用户");
expect(items[0].like_count).toBe(5000);
expect(items[0].comment_count).toBe(1200);
expect(items[0].share_count).toBe(800);
expect(items[0].cover_url).toBe("https://ww1.sinaimg.cn/large/pic001.jpg");
});
it("returns mapped ContentItem[] from band_list format", async () => {
mockFetch.mockResolvedValueOnce({
band_list: [
{
word: "热搜话题一",
num: 1500000,
category: "社会",
realpos: 1,
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].title).toBe("热搜话题一");
expect(items[0].play_count).toBe(1500000);
expect(items[0].author_name).toBe("微博热搜");
expect(items[0].tags).toEqual(["社会"]);
});
it("returns mapped ContentItem[] from realtime format", async () => {
mockFetch.mockResolvedValueOnce({
realtime: [
{
word: "实时热搜",
raw_hot: 2000000,
realpos: 2,
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].title).toBe("实时热搜");
expect(items[0].play_count).toBe(2000000);
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("strips HTML from weibo text", async () => {
mockFetch.mockResolvedValueOnce({
statuses: [
{
mid: "html-001",
text: "<b>粗体</b>和<a href='#'>链接</a>文字",
user: { screen_name: "测试" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("粗体和链接文字");
});
it("constructs image URL from pic_ids when pic_infos missing", async () => {
mockFetch.mockResolvedValueOnce({
statuses: [
{
mid: "picid-001",
text: "有图微博",
user: { screen_name: "test" },
pic_ids: ["abc123def"],
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].cover_url).toBe("https://ww1.sinaimg.cn/large/abc123def.jpg");
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from post detail", async () => {
mockFetch.mockResolvedValueOnce({
data: {
mid: "detail-001",
text: "详情微博内容",
user: {
screen_name: "详情作者",
id: "9876543",
},
attitudes_count: 10000,
comments_count: 2000,
reposts_count: 1500,
created_at: "Wed Feb 01 12:00:00 +0800 2024",
},
});
const item = await adapter.fetchDetail("detail-001");
expect(item.id).toBe("detail-001");
expect(item.title).toBe("详情微博内容");
expect(item.platform).toBe("weibo");
expect(item.like_count).toBe(10000);
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("999");
expect(item.title).toBe("无标题");
expect(item.author_name).toBe("未知作者");
});
});
});

View File

@ -0,0 +1,152 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
function stripHtml(text: string): string {
return text.replace(/<[^>]*>/g, "").trim();
}
function parseWeiboDate(dateStr: string): string {
// Weibo format: "Mon Jan 01 00:00:00 +0800 2024"
const d = new Date(dateStr);
return isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
}
export class WeiboAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/weibo/app/fetch_hot_search"
);
// Response formats:
// 1. { items: [{ type: "vertical", category: "group", items: [{ data: { desc, ... } }] }] }
// — App hot search with nested card structure
// 2. { statuses: [...] } — weibo posts
// 3. { band_list: [...] } or { realtime: [...] } — flat hot topics
if (Array.isArray(data?.statuses)) {
return data.statuses
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapStatusToContentItem(item as Record<string, unknown>, index)
);
}
// App nested card format: items[] > group items > card data with desc
if (Array.isArray(data?.items)) {
const topics: unknown[] = [];
for (const item of data.items) {
if (item?.type === "vertical" && item?.category === "group") {
const innerItems = item?.items || [];
for (const inner of innerItems) {
const cardData = inner?.data;
if (cardData?.desc) {
topics.push(cardData);
}
}
}
}
if (topics.length > 0) {
return topics
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapTopicToContentItem(item as Record<string, unknown>, index)
);
}
}
const topics = data?.band_list || data?.realtime || [];
if (Array.isArray(topics) && topics.length > 0) {
return topics
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapTopicToContentItem(item as Record<string, unknown>, index)
);
}
return [];
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/weibo/app/fetch_status_detail",
{ status_id: id }
);
const postData = data?.data || data || {};
return this.mapStatusToContentItem(postData, 0);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapStatusToContentItem(raw: any, index: number): ContentItem {
const user = raw?.user || {};
const postId = raw?.mid || raw?.id || raw?.idstr || `wb-${index}`;
const text = raw?.text || raw?.text_raw || "";
const title = stripHtml(text).slice(0, 200) || "无标题";
// Extract image URL from pic_infos or pic_ids
let coverUrl: string | undefined;
if (raw?.pic_infos) {
const firstPicId = Object.keys(raw.pic_infos)[0];
if (firstPicId) {
coverUrl = raw.pic_infos[firstPicId]?.large?.url || raw.pic_infos[firstPicId]?.original?.url;
}
}
if (!coverUrl && raw?.pic_ids?.[0]) {
coverUrl = `https://ww1.sinaimg.cn/large/${raw.pic_ids[0]}.jpg`;
}
if (!coverUrl) {
coverUrl = raw?.thumbnail_pic || undefined;
}
return {
id: String(postId),
title,
cover_url: coverUrl,
video_url: raw?.page_info?.media_info?.mp4_720p_mp4 || undefined,
author_name: user?.screen_name || user?.name || "未知作者",
author_avatar: user?.profile_image_url || user?.avatar_large || undefined,
play_count: raw?.reads_count ?? raw?.page_info?.play_count ?? undefined,
like_count: raw?.attitudes_count ?? undefined,
collect_count: undefined,
comment_count: raw?.comments_count ?? undefined,
share_count: raw?.reposts_count ?? undefined,
publish_time: raw?.created_at
? parseWeiboDate(raw.created_at)
: new Date().toISOString(),
platform: "weibo",
original_url: `https://weibo.com/${user?.id || "u"}/${postId}`,
tags: undefined,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapTopicToContentItem(raw: any, index: number): ContentItem {
// App card format: { desc, scheme, pic, icon, ... }
// Band list format: { word, num, category, ... }
const word = raw?.desc || raw?.word || raw?.note || raw?.query || "";
const category = raw?.category || raw?.label_name || "";
return {
id: String(raw?.mid || raw?.realpos || index),
title: word || "热搜话题",
cover_url: raw?.pic || raw?.icon?.url || undefined,
video_url: undefined,
author_name: "微博热搜",
author_avatar: undefined,
play_count: raw?.num || raw?.raw_hot || undefined,
like_count: undefined,
collect_count: undefined,
comment_count: undefined,
share_count: undefined,
publish_time: new Date().toISOString(),
platform: "weibo",
original_url: raw?.scheme
? `https://s.weibo.com/weibo?q=${encodeURIComponent(word)}`
: `https://s.weibo.com/weibo?q=${encodeURIComponent(word)}`,
tags: category ? [category] : undefined,
};
}
}

View File

@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { XiaohongshuAdapter } from "./xiaohongshu";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("XiaohongshuAdapter", () => {
let adapter: XiaohongshuAdapter;
beforeEach(() => {
adapter = new XiaohongshuAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from hot inspiration feed", async () => {
mockFetch.mockResolvedValueOnce({
data: {
items: [
{
hot_id: "xhs-001",
title: "热门话题测试",
cover: "https://xhs.com/cover.jpg",
score: 5000000,
score_text: "500万人在看",
type: "美妆",
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("xhs-001");
expect(items[0].title).toBe("热门话题测试");
expect(items[0].platform).toBe("xiaohongshu");
expect(items[0].cover_url).toBe("https://xhs.com/cover.jpg");
expect(items[0].play_count).toBe(5000000);
expect(items[0].tags).toEqual(["美妆"]);
});
it("parses score_text with 万 unit", async () => {
mockFetch.mockResolvedValueOnce({
data: {
items: [
{ hot_id: "1", title: "Topic", score_text: "1000万人在看" },
],
},
});
const items = await adapter.fetchTrending(20);
expect(items[0].play_count).toBe(10000000);
});
it("parses score_text with 亿 unit", async () => {
mockFetch.mockResolvedValueOnce({
data: {
items: [
{ hot_id: "2", title: "Topic", score_text: "1.5亿人在看" },
],
},
});
const items = await adapter.fetchTrending(20);
expect(items[0].play_count).toBe(150000000);
});
it("filters out items with title 无标题", async () => {
mockFetch.mockResolvedValueOnce({
data: {
items: [
{ hot_id: "1", title: "有标题" },
{ hot_id: "2" },
],
},
});
const items = await adapter.fetchTrending(20);
expect(items.every((i) => i.title !== "无标题")).toBe(true);
});
it("extracts title from deeplink when title is missing", async () => {
const deeplink = encodeURIComponent(
'{"content":"#春天穿搭[话题]"}'
);
mockFetch.mockResolvedValueOnce({
data: {
items: [{ hot_id: "3", deeplink }],
},
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("春天穿搭");
});
it("handles empty response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from note detail", async () => {
mockFetch.mockResolvedValueOnce({
data: {
note_list: [
{
note_id: "note-123",
display_title: "笔记标题",
images_list: [{ url: "https://xhs.com/img.jpg" }],
user: {
nickname: "小红书博主",
avatar: "https://xhs.com/avatar.jpg",
},
interact_info: {
liked_count: 300,
collected_count: 50,
comment_count: 20,
share_count: 10,
},
time: 1709424000,
tag_list: [{ name: "穿搭" }, { name: "日常" }],
},
],
},
});
const item = await adapter.fetchDetail("note-123");
expect(item.id).toBe("note-123");
expect(item.title).toBe("笔记标题");
expect(item.author_name).toBe("小红书博主");
expect(item.like_count).toBe(300);
expect(item.collect_count).toBe(50);
expect(item.tags).toEqual(["穿搭", "日常"]);
expect(item.platform).toBe("xiaohongshu");
});
it("handles missing detail data", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("missing");
expect(item.title).toBe("无标题");
expect(item.author_name).toBe("未知作者");
});
});
});

View File

@ -0,0 +1,158 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
export class XiaohongshuAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/xiaohongshu/app_v2/get_creator_hot_inspiration_feed",
{ cursor: "" }
);
const list =
data?.data?.items ||
data?.items ||
[];
const items = Array.isArray(list) ? list : [];
return items
.slice(0, count)
.map((item: Record<string, unknown>, index: number) =>
this.mapHotItemToContentItem(item, index)
)
.filter((item: ContentItem) => item.title !== "无标题");
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/xiaohongshu/app/get_note_info",
{ note_id: id }
);
const noteData =
data?.data?.note_list?.[0] ||
data?.data?.items?.[0]?.note ||
data?.data ||
data ||
{};
return this.mapNoteToContentItem(noteData, 0);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapHotItemToContentItem(raw: any, index: number): ContentItem {
let title = raw?.title || "";
if (!title && raw?.deeplink) {
try {
const decoded = decodeURIComponent(raw.deeplink);
const match = decoded.match(/"content":"([^"]+)"/);
if (match) {
title = match[1]
.trim()
.replace(/#/g, "")
.replace(/\[话题\]/g, "")
.trim();
}
} catch {
// ignore decode errors
}
}
if (!title) title = "无标题";
let viewCount: number | undefined;
const score = raw?.score;
const scoreText = raw?.score_text || "";
if (typeof score === "number" && score > 0) {
viewCount = score;
} else if (scoreText) {
const numMatch = scoreText.match(/([\d.]+)/);
if (numMatch) {
const num = parseFloat(numMatch[1]);
if (scoreText.includes("亿")) {
viewCount = Math.round(num * 100000000);
} else if (scoreText.includes("万")) {
viewCount = Math.round(num * 10000);
} else {
viewCount = Math.round(num);
}
}
}
const hotId = raw?.hot_id || raw?.id || "";
const coverUrl = raw?.cover || undefined;
return {
id: String(hotId || `xhs-${index}`),
title,
cover_url: coverUrl,
video_url: undefined,
author_name: "小红书热榜",
author_avatar: undefined,
play_count: viewCount,
like_count: undefined,
collect_count: undefined,
comment_count: undefined,
share_count: undefined,
publish_time: new Date().toISOString(),
platform: "xiaohongshu",
original_url: `https://www.xiaohongshu.com/search_result?keyword=${encodeURIComponent(title)}&type=51`,
tags: raw?.type ? [raw.type] : undefined,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapNoteToContentItem(note: any, index: number): ContentItem {
const user = note?.user || {};
const interactInfo = note?.interact_info || {};
const images = note?.images_list || note?.image_list || [];
const coverFromImages = images[0]?.url || images[0]?.url_default || undefined;
const coverObj = note?.cover || {};
const coverFromCover =
(typeof coverObj === "string" ? coverObj : null) ||
coverObj?.url ||
coverObj?.url_default ||
coverObj?.url_pre ||
undefined;
return {
id: String(note?.note_id || note?.id || `xhs-${index}`),
title:
note?.display_title || note?.title || note?.desc?.slice(0, 60) || "无标题",
cover_url: coverFromImages || coverFromCover || undefined,
video_url: note?.video?.url || undefined,
author_name: user?.nickname || user?.name || "未知作者",
author_avatar: user?.avatar || user?.image || undefined,
play_count: undefined,
like_count:
interactInfo?.liked_count ??
note?.liked_count ??
note?.likes ??
undefined,
collect_count:
interactInfo?.collected_count ??
note?.collected_count ??
undefined,
comment_count:
interactInfo?.comment_count ??
note?.comment_count ??
undefined,
share_count:
interactInfo?.share_count ??
note?.share_count ??
undefined,
publish_time: note?.time
? new Date(note.time * 1000).toISOString()
: note?.timestamp
? new Date(note.timestamp * 1000).toISOString()
: new Date().toISOString(),
platform: "xiaohongshu",
original_url: `https://www.xiaohongshu.com/explore/${note?.note_id || note?.id || ""}`,
tags:
note?.tag_list
?.map((t: { name?: string }) => t.name)
.filter(Boolean) || undefined,
};
}
}

View File

@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { YouTubeAdapter } from "./youtube";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("YouTubeAdapter", () => {
let adapter: YouTubeAdapter;
beforeEach(() => {
adapter = new YouTubeAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from trending videos", async () => {
mockFetch.mockResolvedValueOnce({
videos: [
{
video_id: "abc123",
snippet: {
title: "Test Video",
channelTitle: "Test Channel",
publishedAt: "2024-01-15T08:00:00Z",
thumbnails: {
high: { url: "https://img.youtube.com/high.jpg" },
default: { url: "https://img.youtube.com/default.jpg" },
},
tags: ["music", "trending"],
},
statistics: {
viewCount: "1500000",
likeCount: "80000",
commentCount: "3500",
},
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("abc123");
expect(items[0].title).toBe("Test Video");
expect(items[0].platform).toBe("youtube");
expect(items[0].author_name).toBe("Test Channel");
expect(items[0].play_count).toBe(1500000);
expect(items[0].like_count).toBe(80000);
expect(items[0].comment_count).toBe(3500);
expect(items[0].cover_url).toBe("https://img.youtube.com/high.jpg");
expect(items[0].tags).toEqual(["music", "trending"]);
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("uses default values for missing fields", async () => {
mockFetch.mockResolvedValueOnce({
videos: [{}],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("Untitled");
expect(items[0].author_name).toBe("Unknown");
expect(items[0].play_count).toBeUndefined();
});
it("handles id as object with videoId", async () => {
mockFetch.mockResolvedValueOnce({
videos: [
{
id: { videoId: "obj-id-123" },
snippet: { title: "Object ID Video" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].id).toBe("obj-id-123");
});
it("parses string statistics correctly", async () => {
mockFetch.mockResolvedValueOnce({
videos: [
{
video_id: "stat-test",
statistics: {
viewCount: "999",
likeCount: "50",
commentCount: "10",
},
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].play_count).toBe(999);
expect(items[0].like_count).toBe(50);
expect(items[0].comment_count).toBe(10);
});
it("prefers maxres thumbnail", async () => {
mockFetch.mockResolvedValueOnce({
videos: [
{
video_id: "thumb-test",
snippet: {
thumbnails: {
maxres: { url: "https://img.youtube.com/maxres.jpg" },
high: { url: "https://img.youtube.com/high.jpg" },
},
},
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].cover_url).toBe("https://img.youtube.com/maxres.jpg");
});
it("slices results to requested count", async () => {
const ytItems = Array.from({ length: 30 }, (_, i) => ({
video_id: `yt-${i}`,
title: `Video ${i}`,
}));
mockFetch.mockResolvedValueOnce({ videos: ytItems });
const items = await adapter.fetchTrending(5);
expect(items).toHaveLength(5);
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from video detail", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
id: "detail-456",
snippet: {
title: "Detail Video",
channelTitle: "Detail Channel",
publishedAt: "2024-02-01T12:00:00Z",
thumbnails: {
high: { url: "https://img.youtube.com/detail.jpg" },
},
},
statistics: {
viewCount: "50000",
likeCount: "2000",
},
},
],
});
const item = await adapter.fetchDetail("detail-456");
expect(item.id).toBe("detail-456");
expect(item.title).toBe("Detail Video");
expect(item.platform).toBe("youtube");
expect(item.play_count).toBe(50000);
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("999");
expect(item.title).toBe("Untitled");
expect(item.author_name).toBe("Unknown");
});
});
});

View File

@ -0,0 +1,80 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
export class YouTubeAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/youtube/web/get_trending_videos"
);
// Response: { videos: [...], number_of_videos, country, ... }
const list = data?.videos || data?.items || [];
const items = Array.isArray(list) ? list : [];
return items
.slice(0, count)
.map((item: Record<string, unknown>, index: number) =>
this.mapToContentItem(item, index)
);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/youtube/web/get_video_info",
{ video_id: id }
);
const videoData = data?.items?.[0] || data || {};
return this.mapToContentItem(videoData, 0);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number): ContentItem {
// get_trending_videos format: { video_id, title, channel, views, ... }
// get_video_info / YouTube Data API format: { id, snippet: {...}, statistics: {...} }
const snippet = raw?.snippet || {};
const stats = raw?.statistics || {};
const videoId =
raw?.video_id ||
(typeof raw?.id === "string" ? raw.id : raw?.id?.videoId) ||
raw?.videoId ||
`yt-${index}`;
// Thumbnails: trending format uses direct fields, Data API uses snippet.thumbnails
const thumbs = snippet?.thumbnails || raw?.thumbnails || {};
const coverUrl =
thumbs?.maxres?.url ||
thumbs?.high?.url ||
thumbs?.medium?.url ||
thumbs?.default?.url ||
raw?.thumbnail ||
undefined;
// Views/likes: trending format may use direct number fields
const viewCount = raw?.views ?? stats?.viewCount;
const likeCount = raw?.likes ?? stats?.likeCount;
const commentCount = stats?.commentCount;
return {
id: String(videoId),
title: raw?.title || snippet?.title || "Untitled",
cover_url: coverUrl,
video_url: `https://www.youtube.com/watch?v=${videoId}`,
author_name:
raw?.channel || snippet?.channelTitle || raw?.channelTitle || "Unknown",
author_avatar: undefined,
play_count: viewCount != null ? parseInt(String(viewCount), 10) || undefined : undefined,
like_count: likeCount != null ? parseInt(String(likeCount), 10) || undefined : undefined,
collect_count: undefined,
comment_count: commentCount != null ? parseInt(String(commentCount), 10) || undefined : undefined,
share_count: undefined,
publish_time: raw?.published_at || snippet?.publishedAt || raw?.publishedAt || new Date().toISOString(),
platform: "youtube",
original_url: `https://www.youtube.com/watch?v=${videoId}`,
tags: snippet?.tags || raw?.tags || undefined,
};
}
}

View File

@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
let canMakeRequest: () => boolean;
let recordRequest: () => void;
let waitForSlot: () => Promise<void>;
beforeEach(async () => {
vi.useFakeTimers();
vi.resetModules();
const mod = await import("./rate-limiter");
canMakeRequest = mod.canMakeRequest;
recordRequest = mod.recordRequest;
waitForSlot = mod.waitForSlot;
});
afterEach(() => {
vi.useRealTimers();
});
describe("canMakeRequest", () => {
it("returns true when no requests have been made", () => {
expect(canMakeRequest()).toBe(true);
});
it("returns true for fewer than 10 requests in window", () => {
for (let i = 0; i < 9; i++) {
recordRequest();
}
expect(canMakeRequest()).toBe(true);
});
it("returns false when 10 requests made within 1 second", () => {
for (let i = 0; i < 10; i++) {
recordRequest();
}
expect(canMakeRequest()).toBe(false);
});
it("returns true after window expires", () => {
for (let i = 0; i < 10; i++) {
recordRequest();
}
expect(canMakeRequest()).toBe(false);
vi.advanceTimersByTime(1001);
expect(canMakeRequest()).toBe(true);
});
});
describe("recordRequest", () => {
it("records a request timestamp", () => {
expect(canMakeRequest()).toBe(true);
for (let i = 0; i < 10; i++) {
recordRequest();
}
expect(canMakeRequest()).toBe(false);
});
});
describe("waitForSlot", () => {
it("resolves immediately when a slot is available", async () => {
await waitForSlot();
expect(true).toBe(true);
});
it("waits until a slot opens when at capacity", async () => {
for (let i = 0; i < 9; i++) {
recordRequest();
}
await waitForSlot();
expect(canMakeRequest()).toBe(false);
const promise = waitForSlot();
vi.advanceTimersByTime(1100);
await promise;
expect(true).toBe(true);
});
});

View File

@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
TikHubError,
getApiKey,
setRuntimeApiKey,
tikhubFetch,
} from "./tikhub";
vi.mock("./rate-limiter", () => ({
waitForSlot: vi.fn().mockResolvedValue(undefined),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("TikHubError", () => {
it("creates error with statusCode and message", () => {
const err = new TikHubError(401, "Unauthorized");
expect(err.statusCode).toBe(401);
expect(err.message).toBe("Unauthorized");
expect(err.name).toBe("TikHubError");
expect(err).toBeInstanceOf(Error);
});
});
describe("getApiKey / setRuntimeApiKey", () => {
const originalEnv = process.env.TIKHUB_API_KEY;
afterEach(() => {
setRuntimeApiKey("");
if (originalEnv !== undefined) {
process.env.TIKHUB_API_KEY = originalEnv;
} else {
delete process.env.TIKHUB_API_KEY;
}
});
it("returns null when no key is configured", () => {
setRuntimeApiKey("");
delete process.env.TIKHUB_API_KEY;
expect(getApiKey()).toBeNull();
});
it("returns env variable when set", () => {
process.env.TIKHUB_API_KEY = "env-key-123";
setRuntimeApiKey("");
expect(getApiKey()).toBe("env-key-123");
});
it("returns runtime key with priority over env", () => {
process.env.TIKHUB_API_KEY = "env-key-123";
setRuntimeApiKey("runtime-key-456");
expect(getApiKey()).toBe("runtime-key-456");
});
});
describe("tikhubFetch", () => {
beforeEach(() => {
mockFetch.mockReset();
setRuntimeApiKey("test-api-key");
});
afterEach(() => {
setRuntimeApiKey("");
});
it("throws TikHubError 401 when no API key", async () => {
setRuntimeApiKey("");
delete process.env.TIKHUB_API_KEY;
await expect(tikhubFetch("/test")).rejects.toThrow(TikHubError);
await expect(tikhubFetch("/test")).rejects.toMatchObject({
statusCode: 401,
});
});
it("makes GET request with correct headers", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: "ok" }),
});
await tikhubFetch("/api/v1/test");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/test"),
expect.objectContaining({
method: "GET",
headers: {
Authorization: "Bearer test-api-key",
"Content-Type": "application/json",
},
})
);
});
it("appends query params for GET requests", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: [] }),
});
await tikhubFetch("/api/v1/test", { foo: "bar", count: "20" });
const calledUrl = mockFetch.mock.calls[0][0];
expect(calledUrl).toContain("foo=bar");
expect(calledUrl).toContain("count=20");
});
it("makes POST request with body", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ code: 0, data: { items: [] } }),
});
await tikhubFetch("/api/v1/test", undefined, "POST", { key: "value" });
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: "POST",
body: JSON.stringify({ key: "value" }),
})
);
});
it("unwraps TikHub envelope { code, data }", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ code: 0, data: { items: [1, 2, 3] } }),
});
const result = await tikhubFetch("/api/v1/test");
expect(result).toEqual({ items: [1, 2, 3] });
});
it("returns raw json when no envelope", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ items: [1, 2, 3] }),
});
const result = await tikhubFetch("/api/v1/test");
expect(result).toEqual({ items: [1, 2, 3] });
});
it("throws TikHubError 401 on unauthorized response", async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
await expect(tikhubFetch("/test")).rejects.toMatchObject({
statusCode: 401,
});
});
it("throws TikHubError 429 on rate limit response", async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 429 });
await expect(tikhubFetch("/test")).rejects.toMatchObject({
statusCode: 429,
});
});
it("throws TikHubError on other HTTP errors", async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
await expect(tikhubFetch("/test")).rejects.toMatchObject({
statusCode: 500,
});
});
});

View File

@ -15,7 +15,9 @@ export function getApiKey(): string | null {
export async function tikhubFetch<T>(
endpoint: string,
params?: Record<string, string>
params?: Record<string, string>,
method: "GET" | "POST" = "GET",
body?: Record<string, unknown>
): Promise<T> {
const apiKey = getApiKey();
if (!apiKey) {
@ -30,11 +32,12 @@ export async function tikhubFetch<T>(
}
const res = await fetch(url.toString(), {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
cache: "no-store",
...(method === "POST" ? { body: JSON.stringify(body || {}) } : {}),
});
if (!res.ok) {
@ -47,7 +50,14 @@ export async function tikhubFetch<T>(
throw new TikHubError(res.status, `TikHub API 错误: ${res.status}`);
}
return res.json();
const json = await res.json();
// TikHub wraps all responses in { code, data, ... } envelope
// Unwrap to return the inner data directly
if (json?.code !== undefined && json?.data !== undefined) {
return json.data as T;
}
return json as T;
}
export class TikHubError extends Error {

View File

@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../lib/tikhub", () => ({
setRuntimeApiKey: vi.fn(),
getApiKey: vi.fn(),
}));
import { setRuntimeApiKey, getApiKey } from "../lib/tikhub";
import { app } from "../app";
const mockSetKey = vi.mocked(setRuntimeApiKey);
const mockGetKey = vi.mocked(getApiKey);
describe("POST /api/settings", () => {
beforeEach(() => {
mockSetKey.mockReset();
});
it("saves valid API key and returns success", async () => {
const res = await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: "test-key-123" }),
});
const data = await res.json();
expect(res.status).toBe(200);
expect(data.success).toBe(true);
expect(mockSetKey).toHaveBeenCalledWith("test-key-123");
});
it("trims whitespace from API key", async () => {
await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: " key-with-spaces " }),
});
expect(mockSetKey).toHaveBeenCalledWith("key-with-spaces");
});
it("returns 400 for empty API key", async () => {
const res = await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: "" }),
});
expect(res.status).toBe(400);
});
it("returns 400 for whitespace-only API key", async () => {
const res = await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: " " }),
});
expect(res.status).toBe(400);
});
it("returns 400 when apiKey field is missing", async () => {
const res = await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
it("returns 500 for invalid JSON body", async () => {
const res = await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res.status).toBe(500);
});
});
describe("GET /api/settings", () => {
it("returns hasKey: true when key is configured", async () => {
mockGetKey.mockReturnValue("some-key");
const res = await app.request("/api/settings");
const data = await res.json();
expect(data.hasKey).toBe(true);
});
it("returns hasKey: false when no key configured", async () => {
mockGetKey.mockReturnValue(null);
const res = await app.request("/api/settings");
const data = await res.json();
expect(data.hasKey).toBe(false);
});
});

View File

@ -0,0 +1,29 @@
import { Hono } from "hono";
import { setRuntimeApiKey, getApiKey } from "../lib/tikhub";
const settingsRoutes = new Hono();
// POST / — save API Key
settingsRoutes.post("/", async (c) => {
try {
const body = await c.req.json();
const { apiKey } = body;
if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") {
return c.json({ error: "请输入有效的 API Key" }, 400);
}
setRuntimeApiKey(apiKey.trim());
return c.json({ success: true, message: "API Key 已保存" });
} catch {
return c.json({ error: "保存失败,请重试" }, 500);
}
});
// GET / — check if API Key is configured
settingsRoutes.get("/", (c) => {
const hasKey = !!getApiKey();
return c.json({ hasKey });
});
export { settingsRoutes };

View File

@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../lib/adapters", () => ({
getAdapter: vi.fn(),
}));
vi.mock("../lib/tikhub", () => {
class TikHubError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
this.name = "TikHubError";
}
}
return { TikHubError };
});
import { getAdapter } from "../lib/adapters";
import { TikHubError } from "../lib/tikhub";
import { app } from "../app";
const mockGetAdapter = vi.mocked(getAdapter);
describe("GET /api/tikhub/:platform", () => {
beforeEach(() => {
mockGetAdapter.mockReset();
});
it("returns content items for valid platform", async () => {
const mockItems = [{ id: "1", title: "Test", platform: "douyin" }];
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn().mockResolvedValue(mockItems),
fetchDetail: vi.fn(),
});
const res = await app.request("/api/tikhub/douyin");
const data = await res.json();
expect(res.status).toBe(200);
expect(data.data).toEqual(mockItems);
});
it("passes count parameter to adapter", async () => {
const mockFetchTrending = vi.fn().mockResolvedValue([]);
mockGetAdapter.mockReturnValue({
fetchTrending: mockFetchTrending,
fetchDetail: vi.fn(),
});
await app.request("/api/tikhub/tiktok?count=50");
expect(mockFetchTrending).toHaveBeenCalledWith(50);
});
it("defaults count to 20", async () => {
const mockFetchTrending = vi.fn().mockResolvedValue([]);
mockGetAdapter.mockReturnValue({
fetchTrending: mockFetchTrending,
fetchDetail: vi.fn(),
});
await app.request("/api/tikhub/douyin");
expect(mockFetchTrending).toHaveBeenCalledWith(20);
});
it("returns 400 for unsupported platform", async () => {
mockGetAdapter.mockReturnValue(null);
const res = await app.request("/api/tikhub/unknown");
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("不支持的平台");
});
it("returns TikHub error status on TikHubError", async () => {
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn().mockRejectedValue(
new TikHubError(401, "API Key 无效")
),
fetchDetail: vi.fn(),
});
const res = await app.request("/api/tikhub/douyin");
expect(res.status).toBe(401);
const data = await res.json();
expect(data.error).toContain("API Key");
});
it("returns 500 for unexpected errors", async () => {
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn().mockRejectedValue(new Error("unexpected")),
fetchDetail: vi.fn(),
});
const res = await app.request("/api/tikhub/douyin");
expect(res.status).toBe(500);
const data = await res.json();
expect(data.error).toBe("服务器内部错误");
});
});
describe("GET /api/tikhub/:platform/detail", () => {
beforeEach(() => {
mockGetAdapter.mockReset();
});
it("returns detail for valid platform and id", async () => {
const mockItem = { id: "123", title: "Detail Item", platform: "douyin" };
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn(),
fetchDetail: vi.fn().mockResolvedValue(mockItem),
});
const res = await app.request("/api/tikhub/douyin/detail?id=123");
const data = await res.json();
expect(res.status).toBe(200);
expect(data.data).toEqual(mockItem);
});
it("returns 400 when id parameter is missing", async () => {
const res = await app.request("/api/tikhub/douyin/detail");
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("id");
});
it("returns 400 for unsupported platform", async () => {
mockGetAdapter.mockReturnValue(null);
const res = await app.request("/api/tikhub/youtube/detail?id=123");
expect(res.status).toBe(400);
});
it("returns TikHub error status on TikHubError", async () => {
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn(),
fetchDetail: vi.fn().mockRejectedValue(
new TikHubError(429, "请求过于频繁")
),
});
const res = await app.request("/api/tikhub/douyin/detail?id=123");
expect(res.status).toBe(429);
});
it("returns 500 for unexpected errors", async () => {
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn(),
fetchDetail: vi.fn().mockRejectedValue(new Error("fail")),
});
const res = await app.request("/api/tikhub/douyin/detail?id=123");
expect(res.status).toBe(500);
});
});

View File

@ -0,0 +1,54 @@
import { Hono } from "hono";
import { getAdapter } from "../lib/adapters";
import { TikHubError } from "../lib/tikhub";
import type { Platform } from "@muse/shared";
const tikhubRoutes = new Hono();
// GET /:platform — trending content
tikhubRoutes.get("/:platform", async (c) => {
try {
const platform = c.req.param("platform");
const count = parseInt(c.req.query("count") || "20", 10);
const adapter = getAdapter(platform as Platform);
if (!adapter) {
return c.json({ error: `不支持的平台: ${platform}` }, 400);
}
const items = await adapter.fetchTrending(count);
return c.json({ data: items });
} catch (error) {
if (error instanceof TikHubError) {
return c.json({ error: error.message }, error.statusCode as 400);
}
return c.json({ error: "服务器内部错误" }, 500);
}
});
// GET /:platform/detail — content detail
tikhubRoutes.get("/:platform/detail", async (c) => {
try {
const platform = c.req.param("platform");
const id = c.req.query("id");
if (!id) {
return c.json({ error: "缺少参数: id" }, 400);
}
const adapter = getAdapter(platform as Platform);
if (!adapter) {
return c.json({ error: `不支持的平台: ${platform}` }, 400);
}
const item = await adapter.fetchDetail(id);
return c.json({ data: item });
} catch (error) {
if (error instanceof TikHubError) {
return c.json({ error: error.message }, error.statusCode as 400);
}
return c.json({ error: "服务器内部错误" }, 500);
}
});
export { tikhubRoutes };

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"resolveJsonModule": true,
"paths": {
"@muse/shared": ["../shared/src/index.ts"]
}
},
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"@muse/shared": path.resolve(__dirname, "../shared/src/index.ts"),
},
},
test: {
globals: true,
include: ["src/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "text-summary", "lcov"],
include: ["src/**"],
exclude: ["src/**/*.test.*"],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
});

View File

@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@muse/shared"],
images: {
remotePatterns: [
// 抖音
@ -10,14 +11,21 @@ const nextConfig: NextConfig = {
{ protocol: "https", hostname: "p*.douyinpic.com" },
// TikTok
{ protocol: "https", hostname: "*.tiktokcdn.com" },
{ protocol: "https", hostname: "*.tiktokcdn-us.com" },
{ protocol: "https", hostname: "p16-sign-sg.tiktokcdn.com" },
{ protocol: "https", hostname: "p16-common-sign.tiktokcdn-us.com" },
// 小红书
{ protocol: "https", hostname: "*.xhscdn.com" },
{ protocol: "https", hostname: "sns-webpic-qc.xhscdn.com" },
{ protocol: "https", hostname: "sns-avatar-qc.xhscdn.com" },
{ protocol: "https", hostname: "sns-na-i6.xhscdn.com" },
{ protocol: "https", hostname: "ci.xiaohongshu.com" },
{ protocol: "http", hostname: "ci.xiaohongshu.com" },
{ protocol: "https", hostname: "picasso-static.xiaohongshu.com" },
// 通用 CDN
{ protocol: "https", hostname: "*.pstatp.com" },
{ protocol: "https", hostname: "*.snssdk.com" },
{ protocol: "https", hostname: "*.douyinvod.com" },
],
},
};

View File

@ -0,0 +1,48 @@
{
"name": "@muse/frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@muse/shared": "workspace:*",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.576.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"happy-dom": "^20.8.3",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.0.18"
}
}

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -21,7 +21,7 @@ export default function FavoritesPage() {
<h1 className="text-lg font-semibold text-slate-800">
</h1>
<span className="text-sm text-slate-400">
<span data-testid="favorites-count" className="text-sm text-slate-400">
{favorites.length}
</span>
</div>

View File

@ -5,7 +5,7 @@ import { useContentQuery, useRefreshContent } from "@/hooks/useContentQuery";
import { PlatformTabs } from "@/components/layout/PlatformTabs";
import { SortToolbar, type SortField, type SortOrder } from "@/components/layout/SortToolbar";
import { ContentGrid } from "@/components/card/ContentGrid";
import type { ContentItem } from "@/types/content";
import type { ContentItem } from "@muse/shared";
function sortItems(
items: ContentItem[],
@ -34,7 +34,7 @@ export default function Home() {
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [lastRefreshTime, setLastRefreshTime] = useState<string | null>(null);
const { data, isLoading, isFetching } = useContentQuery(platform);
const { data, isLoading, isFetching, isError, error, refetch } = useContentQuery(platform);
const { refresh } = useRefreshContent();
const sortedItems = useMemo(() => {
@ -69,8 +69,25 @@ export default function Home() {
lastRefreshTime={lastRefreshTime}
/>
{!isLoading && sortedItems.length === 0 ? (
{isError ? (
<div className="text-center py-20">
<p className="text-4xl mb-4">😵</p>
<h2 className="text-lg font-medium text-slate-700 mb-2">
</h2>
<p className="text-sm text-slate-500 mb-4">
{error?.message || "请求出错,请稍后重试"}
</p>
<button
data-testid="error-retry"
onClick={() => refetch()}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
</div>
) : !isLoading && sortedItems.length === 0 ? (
<div data-testid="empty-state" className="text-center py-20">
<p className="text-4xl mb-4">📭</p>
<h2 className="text-lg font-medium text-slate-700 mb-2">

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import { ArrowLeft, Eye, EyeOff, Check } from "lucide-react";
import Link from "next/link";
import { useSettingsStore } from "@/stores/settings";
import { API_BASE_URL } from "@/lib/api";
import { toast } from "sonner";
const REFRESH_OPTIONS: { value: 5 | 10 | 15 | 30 | 60; label: string }[] = [
@ -31,7 +32,7 @@ export default function SettingsPage() {
setSaving(true);
try {
const res = await fetch("/api/settings", {
const res = await fetch(`${API_BASE_URL}/api/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: trimmed }),
@ -83,6 +84,7 @@ export default function SettingsPage() {
<div className="flex gap-2">
<div className="relative flex-1">
<input
data-testid="apikey-input"
type={showKey ? "text" : "password"}
value={inputKey}
onChange={(e) => setInputKey(e.target.value)}
@ -102,6 +104,7 @@ export default function SettingsPage() {
</button>
</div>
<button
data-testid="apikey-save"
onClick={handleSaveApiKey}
disabled={saving}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"

View File

@ -2,11 +2,11 @@
import Image from "next/image";
import Link from "next/link";
import { Play, Heart, MessageCircle } from "lucide-react";
import { getPlatformConfig } from "@/lib/platforms";
import { formatCount } from "@/lib/format";
import { Play, Heart, Bookmark, Clock } from "lucide-react";
import { getPlatformConfig } from "@muse/shared";
import { formatCount, formatTime } from "@/lib/format";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import type { ContentItem } from "@/types/content";
import type { ContentItem } from "@muse/shared";
import { useState } from "react";
interface ContentCardProps {
@ -19,11 +19,12 @@ export function ContentCard({ item }: ContentCardProps) {
const playCount = formatCount(item.play_count);
const likeCount = formatCount(item.like_count);
const commentCount = formatCount(item.comment_count);
const collectCount = formatCount(item.collect_count);
return (
<Link
href={`/detail/${item.platform}/${encodeURIComponent(item.id)}`}
data-testid="content-card"
className="group block rounded-lg border border-slate-200 bg-white shadow-sm overflow-hidden transition-all hover:-translate-y-0.5 hover:shadow-md"
>
{/* Cover image */}
@ -33,6 +34,7 @@ export function ContentCard({ item }: ContentCardProps) {
src={item.cover_url}
alt={item.title}
fill
unoptimized
className="object-cover"
loading="lazy"
sizes="(max-width: 640px) 100vw, (max-width: 960px) 50vw, (max-width: 1240px) 33vw, 25vw"
@ -47,18 +49,26 @@ export function ContentCard({ item }: ContentCardProps) {
{/* Content */}
<div className="p-3">
{/* Platform tag */}
{platform && (
<span
className="inline-block text-xs px-1.5 py-0.5 rounded mb-1.5"
style={{
backgroundColor: `${platform.color}15`,
color: platform.color,
}}
>
{platform.icon} {platform.name}
</span>
)}
{/* Platform tag + publish time */}
<div className="flex items-center justify-between mb-1.5">
{platform && (
<span
className="inline-block text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${platform.color}15`,
color: platform.color,
}}
>
{platform.icon} {platform.name}
</span>
)}
{item.publish_time && (
<span className="flex items-center gap-0.5 text-[11px] text-slate-400">
<Clock className="w-3 h-3" />
{formatTime(item.publish_time)}
</span>
)}
</div>
{/* Title */}
<h3 className="text-sm font-medium text-slate-800 line-clamp-2 mb-2 leading-snug">
@ -73,6 +83,7 @@ export function ContentCard({ item }: ContentCardProps) {
alt={item.author_name}
width={20}
height={20}
unoptimized
className="rounded-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
@ -103,10 +114,10 @@ export function ContentCard({ item }: ContentCardProps) {
{likeCount}
</span>
)}
{commentCount && (
{collectCount && (
<span className="flex items-center gap-0.5">
<MessageCircle className="w-3 h-3" />
{commentCount}
<Bookmark className="w-3 h-3" />
{collectCount}
</span>
)}
</div>

View File

@ -2,7 +2,7 @@
import { ContentCard } from "./ContentCard";
import { CardSkeleton } from "./CardSkeleton";
import type { ContentItem } from "@/types/content";
import type { ContentItem } from "@muse/shared";
interface ContentGridProps {
items: ContentItem[];
@ -26,7 +26,7 @@ export function ContentGrid({
}
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
<div data-testid="content-grid" className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
{items.map((item) => (
<ContentCard key={`${item.platform}-${item.id}`} item={item} />
))}

View File

@ -2,7 +2,7 @@
import { Heart } from "lucide-react";
import { useFavoritesStore } from "@/stores/favorites";
import type { ContentItem } from "@/types/content";
import type { ContentItem } from "@muse/shared";
import { cn } from "@/lib/utils";
import { useState } from "react";
@ -34,6 +34,7 @@ export function FavoriteButton({ item, size = "sm" }: FavoriteButtonProps) {
return (
<button
data-testid="favorite-btn"
onClick={handleClick}
className={cn(
"inline-flex items-center justify-center rounded-md transition-colors",

View File

@ -1,12 +1,12 @@
"use client";
import Image from "next/image";
import { ArrowLeft, Play, Heart, MessageCircle, Share2, ExternalLink } from "lucide-react";
import { ArrowLeft, Play, Heart, Bookmark, MessageCircle, Share2, ExternalLink } from "lucide-react";
import { useRouter } from "next/navigation";
import { getPlatformConfig } from "@/lib/platforms";
import { getPlatformConfig } from "@muse/shared";
import { formatCount, formatTime } from "@/lib/format";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import type { ContentItem } from "@/types/content";
import type { ContentItem } from "@muse/shared";
import { useState } from "react";
interface DetailPanelProps {
@ -16,6 +16,7 @@ interface DetailPanelProps {
const STAT_ITEMS = [
{ key: "play_count" as const, label: "播放", icon: Play },
{ key: "like_count" as const, label: "点赞", icon: Heart },
{ key: "collect_count" as const, label: "收藏", icon: Bookmark },
{ key: "comment_count" as const, label: "评论", icon: MessageCircle },
{ key: "share_count" as const, label: "分享", icon: Share2 },
];
@ -29,6 +30,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
<div className="max-w-3xl mx-auto">
{/* Back button */}
<button
data-testid="detail-back"
onClick={() => router.back()}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700 mb-4 transition-colors"
>
@ -43,6 +45,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
src={item.cover_url}
alt={item.title}
fill
unoptimized
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 768px"
@ -83,6 +86,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
alt={item.author_name}
width={40}
height={40}
unoptimized
className="rounded-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
@ -106,7 +110,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
</div>
{/* Stats panel */}
<div className="grid grid-cols-4 gap-3">
<div className="grid grid-cols-5 gap-3">
{STAT_ITEMS.map(({ key, label, icon: Icon }) => {
const value = item[key];
return (
@ -141,6 +145,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
{/* Actions */}
<div className="flex items-center gap-3 pt-2">
<button
data-testid="view-original"
onClick={() => window.open(item.original_url, "_blank")}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>

View File

@ -1,6 +1,6 @@
"use client";
import { MVP_PLATFORMS } from "@/lib/platforms";
import { MVP_PLATFORMS } from "@muse/shared";
import { cn } from "@/lib/utils";
interface PlatformTabsProps {
@ -18,6 +18,7 @@ export function PlatformTabs({ active, onChange }: PlatformTabsProps) {
{tabs.map((tab) => (
<button
key={tab.id}
data-testid={`platform-tab-${tab.id}`}
onClick={() => onChange(tab.id)}
className={cn(
"relative px-3 py-1.5 text-sm font-medium rounded-md transition-colors whitespace-nowrap",

View File

@ -3,12 +3,13 @@
import { RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
export type SortField = "play_count" | "like_count" | "comment_count" | "publish_time";
export type SortField = "play_count" | "like_count" | "collect_count" | "comment_count" | "publish_time";
export type SortOrder = "asc" | "desc";
const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: "play_count", label: "播放量" },
{ value: "like_count", label: "点赞数" },
{ value: "collect_count", label: "收藏量" },
{ value: "comment_count", label: "评论数" },
{ value: "publish_time", label: "发布时间" },
];
@ -37,6 +38,7 @@ export function SortToolbar({
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">:</span>
<select
data-testid="sort-select"
value={sortBy}
onChange={(e) => onSortByChange(e.target.value as SortField)}
className="text-sm border border-slate-200 rounded-md px-2 py-1 bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-500"
@ -48,6 +50,7 @@ export function SortToolbar({
))}
</select>
<button
data-testid="sort-order"
onClick={onSortOrderChange}
className="text-sm border border-slate-200 rounded-md px-2 py-1 bg-white text-slate-700 hover:bg-slate-50 transition-colors"
>
@ -57,6 +60,7 @@ export function SortToolbar({
<div className="flex items-center gap-2">
<button
data-testid="refresh-btn"
onClick={onRefresh}
disabled={isRefreshing}
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 disabled:opacity-50 transition-colors"

Some files were not shown because too many files have changed in this diff Show More