Xris_tin@pixiv
1892 字
9 分钟
项目驱动,从零入门 React 前端(番外+接入TMDB)
导言
这篇是个番外,介绍一下笔者是怎么接入 TMDB 的收藏 API 制作对应的 page 的,从而完成 Blog 想要完成的观影展示功能功能。
市面上常见的实现都是基于 Bangumi API,然而我觉得显然 TMDB 的 API 更具有泛用性。
这里整体的实现抄了一下 fuwari 的 comment 分支。
欢迎学习交流,也学习 comment 分支加入了评论功能。
实现
首先我们看一下对应的请求和返回值的示例。
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 无所谓。
这里先配置一下鉴权需要的一些配置
export type TmdbConfig = { accountId: string accessToken: string}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 下声明对应的类型:
// 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 有两种类型,movie 和 tv-show,首先设计一个带这两个类型的 page,整体参照 pages 文件夹下的风格,并加入导航栏,要涉及到的东西较多,单独拉几个文件夹:
src/pages/moviedb.astro用来存放页面src/components/moviedb用来存放组件
页面结构
首先创建主页面 src/pages/moviedb.astro,这个页面负责:
- 配置 TMDB API 参数
- 获取收藏数据(支持多页并发获取)
- 按年份降序排序
- 渲染页面布局
// 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
提供电视剧和电影之间的切换功能,支持响应式设计和深色模式。
<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
显示单个电影/电视剧的卡片,包含海报、标题、日期和简介。
<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
支持客户端分页,包含智能页码显示(省略号处理)。
{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
管理特定媒体类型(电视剧/电影)的显示区域,包含网格布局和分页功能。
<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>客户端交互
标签切换
通过自定义事件实现标签间的平滑切换:
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"); } }); }); });}分页功能
使用自定义事件系统实现客户端分页:
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 的导航栏配置中添加观影页面链接:
export const navBarConfig: NavBarConfig = { links: [ LinkPreset.Home, LinkPreset.Archive, LinkPreset.Moviedb, // 新增观影页面 LinkPreset.About, // ... ],};最终实现了功能。