1892 字
9 分钟
项目驱动,从零入门 React 前端(番外+接入TMDB)
2025-10-31

导言#

这篇是个番外,介绍一下笔者是怎么接入 TMDB 的收藏 API 制作对应的 page 的,从而完成 Blog 想要完成的观影展示功能功能。

市面上常见的实现都是基于 Bangumi API,然而我觉得显然 TMDB 的 API 更具有泛用性。

这里整体的实现抄了一下 fuwaricomment 分支。

欢迎学习交流,也学习 comment 分支加入了评论功能。

实现#

首先我们看一下对应的请求和返回值的示例。

Terminal window
curl --request GET \
--url 'https://api.themoviedb.org/4/account/690420084b590bea30080862/tv/favorites?page=1&language=zh-CN&sort_by=created_at.asc' \
--header 'Authorization: Bearer ex' \
--header 'accept: application/json'

请求显然是要带鉴权的,这里涉及到 Access Token,显然这玩意应该放到环境变量里面,按下不表,另外一个 Account ID 无所谓。

这里先配置一下鉴权需要的一些配置

src/types/config.ts
export type TmdbConfig = {
accountId: string
accessToken: string
}
src/config.ts
export const tmdbConfig: TmdbConfig = {
accountId: "690420084b590bea30080862",
accessToken: import.meta.env.TMDB_ACCESS_TOKEN
}

注意到这里导入的是环境变量,可以在 .env 或者部署平台的 ENVIROMENT 配置。

// 返回值
{
"page": 1,
"results": [
{
"adult": false,
"backdrop_path": "/kCqOvdTazSJYorYTZMJUrbzcdxW.jpg",
"genre_ids": [
16,
18
],
"id": 262700,
"origin_country": [
"JP"
],
"original_language": "ja",
"original_name": "ウマ娘 シンデレラグレイ",
"overview": "笠松特雷森学园,这所衰颓的地方赛区学园,迎来了一名赛马娘,其名为小栗帽。她以压倒性的奔跑,颠覆所有常识。这名终将被称为“怪物”的灰色少女,此刻开始书写全新的传说……\n\n驰骋青春的灰姑娘故事,终于开跑!!",
"popularity": 17.8517,
"poster_path": "/rtixC4leK95pethd4brah2PzAwh.jpg",
"first_air_date": "2025-04-06",
"name": "赛马娘 芦毛灰姑娘",
"vote_average": 7.167,
"vote_count": 12
},
{
"adult": false,
"backdrop_path": "/zX12leCOrQLCIyNKYXKcVPvAJiY.jpg",
"genre_ids": [
16,
35,
10759,
10765
],
"id": 42421,
"origin_country": [
"JP"
],
"original_language": "ja",
"original_name": "パンティ&ストッキングwithガーターベルト",
"overview": "位于天堂与地狱之间的堕天市,长期遭受由人类欲望实体化而成的恶灵侵扰。因行为不检被逐出天堂的天使姐妹——Panty与Stocking,必须在神父加特贝鲁的指挥下消灭恶灵,收集代表功绩的天堂币作为赎罪,集满定额方可重返天堂。然而这对问题天使在堕天市的日常生活,却充满了各种不可描述的糟糕癖好……",
"popularity": 12.6436,
"poster_path": "/RPiVM7JIoAsOZEX0rW8tKQFvCR.jpg",
"first_air_date": "2010-10-02",
"name": "吊带袜天使",
"vote_average": 7.7,
"vote_count": 82
}
],
"total_pages": 1,
"total_results": 2
}

显然 TMDB 的数据里比较有用的还是:

{
"id": 42421,
"original_name": "パンティ&ストッキングwithガーターベルト",
"overview": "位于天堂与地狱之间的堕天市,长期遭受由人类欲望实体化而成的恶灵侵扰。因行为不检被逐出天堂的天使姐妹——Panty与Stocking,必须在神父加特贝鲁的指挥下消灭恶灵,收集代表功绩的天堂币作为赎罪,集满定额方可重返天堂。然而这对问题天使在堕天市的日常生活,却充满了各种不可描述的糟糕癖好……",
"poster_path": "/RPiVM7JIoAsOZEX0rW8tKQFvCR.jpg",
"first_air_date": "2010-10-02",
"name": "吊带袜天使"
}

我们首先在 src/types/moviedb.ts 下声明对应的类型:

src/types/moviedb.ts
// TMDB API 返回的单个电视剧/电影项目类型
export type TmdbItem = {
id: number;
original_name?: string;
name?: string;
overview?: string;
poster_path?: string;
first_air_date?: string;
backdrop_path?: string;
genre_ids?: number[];
origin_country?: string[];
original_language?: string;
popularity?: number;
vote_average?: number;
vote_count?: number;
adult?: boolean;
};
// TMDB API 返回的收藏列表响应类型
export type TmdbFavoritesResponse = {
page: number;
results: TmdbItem[];
total_pages: number;
total_results: number;
};

接下来就要写前端了,我们知道 tmdb 有两种类型,movietv-show,首先设计一个带这两个类型的 page,整体参照 pages 文件夹下的风格,并加入导航栏,要涉及到的东西较多,单独拉几个文件夹:

  • src/pages/moviedb.astro 用来存放页面
  • src/components/moviedb 用来存放组件

页面结构#

首先创建主页面 src/pages/moviedb.astro,这个页面负责:

  1. 配置 TMDB API 参数
  2. 获取收藏数据(支持多页并发获取)
  3. 按年份降序排序
  4. 渲染页面布局
src/pages/moviedb.astro
// TMDB API 配置
const apiBaseUrl = "https://api.themoviedb.org/4";
const accountId = tmdbConfig.accountId;
const accessToken = tmdbConfig.accessToken;
// 定义媒体类型
const mediaTypes = [
{ id: "tv", name: "电视剧", icon: "📺", endpoint: "tv" },
{ id: "movie", name: "电影", icon: "🎬", endpoint: "movie" },
];
// 获取所有收藏数据(支持多页)
async function fetchAllFavorites(mediaType: string): Promise<TmdbItem[]> {
const allItems: TmdbItem[] = [];
let currentPage = 1;
const maxPages = 10; // 限制最多获取10页,避免请求过多
try {
// 获取第一页
const firstPageData = await fetchFavoritesPage(mediaType, 1);
if (!firstPageData) return [];
allItems.push(...firstPageData.results);
const totalPages = Math.min(firstPageData.total_pages, maxPages);
// 如果有多页,并发获取剩余页面
if (totalPages > 1) {
const remainingPages = Array.from(
{ length: totalPages - 1 },
(_, i) => i + 2,
);
const pagePromises = remainingPages.map((page) =>
fetchFavoritesPage(mediaType, page),
);
const results = await Promise.all(pagePromises);
for (const data of results) {
if (data?.results) {
allItems.push(...data.results);
}
}
}
console.log(
`Fetched ${allItems.length} ${mediaType} items from ${totalPages} pages`,
);
return allItems;
} catch (error) {
console.error(`Failed to fetch all ${mediaType} favorites:`, error);
return allItems; // 返回已获取的数据
}
}

组件设计#

1. 标签导航组件 TabNav.astro#

提供电视剧和电影之间的切换功能,支持响应式设计和深色模式。

src/components/moviedb/TabNav.astro
<div class="tab-nav">
<div class="tab-list">
{
tabs.map((tab) => (
<button
class:list={["tab-button", { active: tab.id === activeTab }]}
data-tab={tab.id}
>
<span class="tab-icon">{tab.icon}</span>
<span class="tab-name">{tab.name}</span>
{tab.count !== undefined && (
<span class="tab-count">({tab.count})</span>
)}
</button>
))
}
</div>
</div>

2. 媒体卡片组件 Card.astro#

显示单个电影/电视剧的卡片,包含海报、标题、日期和简介。

src/components/moviedb/Card.astro
<div class="movie-card">
<div class="poster-container">
{hasPoster ? (
<img
src={posterUrl}
alt={title}
class="poster-image"
loading="lazy"
/>
) : (
<div class="poster-placeholder">
<span class="placeholder-icon">🎬</span>
<span class="placeholder-text">{title}</span>
</div>
)}
<div class="overlay">
<div class="info">
<p class="title">{title}</p>
<p class="date">{dateText}</p>
{item.overview && (
<p class="overview">{item.overview}</p>
)}
</div>
</div>
</div>
</div>

3. 分页组件 Pagination.astro#

支持客户端分页,包含智能页码显示(省略号处理)。

src/components/moviedb/Pagination.astro
{totalPages > 1 && (
<div class="pagination" data-section={sectionId}>
<button
class="page-btn prev-btn"
data-page={currentPage - 1}
disabled={currentPage === 1}
>
<span>‹</span>
</button>
<div class="page-numbers">
{pageNumbers.map((pageNum) => (
pageNum === '...' ? (
<span class="ellipsis">···</span>
) : (
<button
class:list={["page-btn", "page-num", { active: pageNum === currentPage }]}
data-page={pageNum}
>
{pageNum}
</button>
)
))}
</div>
<button
class="page-btn next-btn"
data-page={currentPage + 1}
disabled={currentPage === totalPages}
>
<span>›</span>
</button>
</div>
)}

4. 媒体区域组件 MoviedbSection.astro#

管理特定媒体类型(电视剧/电影)的显示区域,包含网格布局和分页功能。

src/components/moviedb/MoviedbSection.astro
<div
class:list={["moviedb-section", { hidden: !isActive }]}
data-section={sectionId}
data-total-pages={totalPages}
data-items-per-page={itemsPerPage}
>
{
items.length === 0 ? (
<div class="empty-state">
<p class="empty-text">暂无{title}收藏</p>
</div>
) : (
<>
<div class="media-grid" data-grid={sectionId}>
{initialItems.map((item) => (
<Card item={item} />
))}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
sectionId={sectionId}
/>
</>
)
}
<!-- 隐藏的数据容器,用于客户端分页 -->
<script type="application/json" data-items={sectionId} set:html={JSON.stringify(items)} />
</div>

客户端交互#

标签切换#

通过自定义事件实现标签间的平滑切换:

src/components/moviedb/TabNav.astro
function initTabNavigation() {
const buttons = document.querySelectorAll(".tab-button");
buttons.forEach((button) => {
button.addEventListener("click", () => {
const tabId = button.getAttribute("data-tab");
if (!tabId) return;
// 更新 active 状态
buttons.forEach((btn) => btn.classList.remove("active"));
button.classList.add("active");
// 显示对应的内容区域
const sections = document.querySelectorAll(".moviedb-section");
sections.forEach((section) => {
if (section.getAttribute("data-section") === tabId) {
section.classList.remove("hidden");
} else {
section.classList.add("hidden");
}
});
});
});
}

分页功能#

使用自定义事件系统实现客户端分页:

src/components/moviedb/Pagination.astro
function initPagination() {
const paginations = document.querySelectorAll('.pagination');
paginations.forEach((pagination) => {
const sectionId = pagination.getAttribute('data-section');
if (!sectionId) return;
const buttons = pagination.querySelectorAll('.page-btn:not(.ellipsis)');
buttons.forEach((button) => {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const pageStr = button.getAttribute('data-page');
if (!pageStr) return;
const page = parseInt(pageStr, 10);
// 触发自定义事件到文档级别
const event = new CustomEvent('moviedb:pagechange', {
detail: { sectionId, page },
bubbles: true,
});
document.dispatchEvent(event);
});
});
});
}

样式设计#

响应式网格布局#

/* path: src/components/moviedb/MoviedbSection.astro */
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1.5rem;
width: 100%;
}
/* 响应式断点 */
@media (min-width: 640px) {
.media-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 2rem;
}
}
@media (min-width: 768px) {
.media-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
@media (min-width: 1024px) {
.media-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 2.5rem;
}
}

卡片悬停效果#

/* path: src/components/moviedb/Card.astro */
.movie-card {
transition: transform 0.3s ease;
cursor: pointer;
}
.movie-card:hover {
transform: translateY(-8px);
}
.overlay {
transform: translateY(calc(100% - 3rem));
transition: transform 0.3s ease;
}
.movie-card:hover .overlay {
transform: translateY(0);
}

导航栏集成#

src/config.ts 的导航栏配置中添加观影页面链接:

src/config.ts
export const navBarConfig: NavBarConfig = {
links: [
LinkPreset.Home,
LinkPreset.Archive,
LinkPreset.Moviedb, // 新增观影页面
LinkPreset.About,
// ...
],
};

最终实现了功能。