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
co-authored by Claude Opus 4.6
parent ce736f197d
commit 6cc703ada2
136 changed files with 16805 additions and 520 deletions
+96
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
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
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
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
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/);
});
});