3680 字
18 分钟
Fuwari 功能增强 Episode2
前言
对 Fuwari 的做了一些扩展,增加了 Bangumi 收藏展示页面,严格来说并不算对 Fuwari 的修改。
NOTE本文方法适用于 Astro
Bangumi 收藏
在开始之前,我想先简要的说明一下我的实现方式。博客的 Bangumi 数据为编译时获取的,一旦编译完成,不会根据读者的访问时间自动更新。在不引入额外的服务端的情况下,我只能想到这样的方案。
Bangumi API 参考:Bangumi API
首先根据 API 文档,找到用户收藏数据的接口及其返回的数据格式。创建src/types/bangumi.ts文件,定义 API 返回的数据类型。
export type UserSubjectCollectionResponse = { data: UserSubjectCollection[]; total: number; limit: number; offset: number;};
export type UserSubjectCollection = { subject_id: number; // 条目 ID47 collapsed lines
subject_type: SubjectType; // 条目类型 rate: number; // 评分 type: CollectionType; // 收藏类型 comment?: string | null; // 评价 tags: string[]; // 标签 ep_status: number; // 章节进度 vol_status: number; // 卷进度 updated_at: string; // 更新时间(ISO 8601 格式) private: boolean; // 是否私有 subject: SlimSubject; // 条目信息};
// 1: 想看,2: 看过,3: 在看,4: 搁置,5: 抛弃export type CollectionType = 1 | 2 | 3 | 4 | 5;
export type SlimSubject = { id: number; // ID type: SubjectType; // 类型 name: string; // 名称 name_cn: string; // 中文名 short_summary: string; // 简介 date?: string | null; // 日期 YYYY-MM-DD images: SubjectImages; // 图片 volumes: number; // 卷数 eps: number; // 集数 collection_total: number; // 收藏人数 score: number; // 评分 rank: number; // 排名 tags: SubjectTag[]; // 标签};
// 1: 书籍,2: 动画,3: 音乐,4: 游戏,6: 三次元export type SubjectType = 1 | 2 | 3 | 4 | 6;
export type SubjectTag = { name: string; count: number; total_cont: number;};
export type SubjectImages = { large: string; common: string; medium: string; small: string; grid: string;};接下来创建页面组件,代码有点多,而且基本都是 UI 代码,就不详细说明了。
先创建文件夹 src\components\bangumi 用于存放页面需要用到的组件,然后创建以下组件:
Bangumi 内容区域
---import type { UserSubjectCollection } from "@/types/bangumi";import Card from "./Card.astro";import FilterControls from "./FilterControls.astro";import Pagination from "./Pagination.astro";
interface Props { sectionId: string; items: UserSubjectCollection[];72 collapsed lines
isActive: boolean; itemsPerPage?: number;}
const { sectionId, items, isActive, itemsPerPage = 12 } = Astro.props;
// 状态映射const statusMap = { 1: "wish", 2: "collect", 3: "doing", 4: "on_hold", 5: "dropped",};
// Get status filters with countsconst statusCounts = items.reduce( (acc, item) => { const status = statusMap[item.type as keyof typeof statusMap] || "unknown"; acc[status] = (acc[status] || 0) + 1; return acc; }, {} as Record<string, number>,);
const filters = [ { value: "all", label: "全部", count: items.length }, { value: "collect", label: "看过", count: statusCounts.collect || 0 }, { value: "doing", label: "在看", count: statusCounts.doing || 0 }, { value: "wish", label: "想看", count: statusCounts.wish || 0 }, { value: "on_hold", label: "搁置", count: statusCounts.on_hold || 0 }, { value: "dropped", label: "抛弃", count: statusCounts.dropped || 0 },].filter((filter) => filter.value === "all" || filter.count > 0);
const defaultFilter = "all"; // 默认显示全部,用户可以通过筛选器选择---
<div class:list={["bangumi-section", { "hidden": !isActive }]} data-section={sectionId}> {items.length > 0 ? ( <> <FilterControls filters={filters} activeFilter={defaultFilter} sectionId={sectionId} />
<div class="grid grid-cols-2 md:grid-cols-3 gap-6 md:gap-8"> {items.map((item) => ( <div class="bangumi-item" data-item-section={sectionId} data-item-status={statusMap[item.type as keyof typeof statusMap] || "unknown"} > <Card item={item} /> </div> ))} </div>
<Pagination totalItems={items.length} itemsPerPage={itemsPerPage} currentPage={1} sectionId={sectionId} /> </> ) : ( <div class="text-center py-12"> <h3 class="text-xl font-medium text-gray-600 dark:text-gray-400 mb-2">暂无数据</h3> <p class="text-gray-500 dark:text-gray-500">该分类下还没有任何条目</p> </div> )}</div>条目卡片组件
---import type { UserSubjectCollection } from "@/types/bangumi";
interface Props { item: UserSubjectCollection;}
const { item } = Astro.props;
90 collapsed lines
const subject_base_url = "https://bgm.tv/subject/";
const getStatusColor = (type: number) => { switch (type) { case 1: return "bg-blue-500"; case 2: return "bg-green-500"; case 3: return "bg-yellow-500"; case 4: return "bg-orange-500"; case 5: return "bg-red-500"; default: return "bg-gray-500"; }};
const getStatusText = (type: number) => { switch (type) { case 1: return "想看"; case 2: return "看过"; case 3: return "在看"; case 4: return "搁置"; case 5: return "抛弃"; default: return "未知"; }};---
<a href={`${subject_base_url}${item.subject.id}`} target="_blank" rel="noopener noreferrer nofollow" class="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-800 shadow-lg hover:shadow-2xl transition-all duration-200 hover:scale-105 block"> <div class="aspect-[2/3] relative overflow-hidden" > {item.subject?.images?.medium ? ( <img src={item.subject.images.medium} alt={item.subject.name_cn || item.subject.name} class="w-full h-full object-cover pointer-events-none" loading="lazy" /> ) : ( <div class="w-full h-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <div class="text-gray-400 text-4xl">📖</div> </div> )}
<!-- Status badge --> <div class={`absolute top-2 left-2 px-2 py-1 rounded-full text-xs text-white font-medium ${getStatusColor(item.type)}`}> {getStatusText(item.type)} </div> </div>
<!-- Info overlay on hover --> <div class="absolute inset-x-0 bottom-0 bg-black/80 text-white p-4 transform translate-y-full group-hover:translate-y-0 transition-transform duration-200"> <h3 class="font-bold text-sm mb-1 line-clamp-2"> {item.subject.name_cn || item.subject.name} </h3>
{(item.subject.score || item.comment) && ( <div class="flex items-center justify-between mb-2"> {item.subject.score && ( <div class="flex items-center gap-1"> <div class="text-yellow-400">⭐</div> <span class="text-sm">{item.subject.score}</span> </div> )}
{item.comment && ( <div class="relative group/comment"> <div class="text-sm text-gray-300 cursor-help">💬</div> <div class="absolute bottom-full right-0 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 group-hover/comment:opacity-100 transition-opacity duration-150 w-32 sm:w-44 xl:w-52 z-10 pointer-events-none"> {item.comment} </div> </div> )} </div> )} </div></a>状态筛选组件
---interface Filter { value: string; label: string; count?: number;}
interface Props { filters: Filter[];88 collapsed lines
activeFilter: string; sectionId: string;}
const { filters, activeFilter, sectionId } = Astro.props;---
<div class="flex flex-wrap gap-1.5 mb-4"> {filters.map((filter) => ( <button class:list={[ "px-3 py-1 rounded-full text-xs font-medium transition-all duration-200", { "bg-[var(--primary)] text-white shadow-md": filter.value === activeFilter, "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600": filter.value !== activeFilter } ]} data-filter={filter.value} data-section={sectionId} type="button" > {filter.label} {filter.count !== undefined && ( <span class="ml-1">({filter.count})</span> )} </button> ))}</div>
<script is:inline define:vars={{ sectionId }}> function initFilterControls() { const filterButtons = document.querySelectorAll(`[data-section="${sectionId}"][data-filter]`);
filterButtons.forEach(button => { button.addEventListener('click', function() { const filter = this.dataset.filter; const currentSectionId = this.dataset.section;
// Update active filter button for this section const sectionButtons = document.querySelectorAll(`[data-section="${currentSectionId}"][data-filter]`); sectionButtons.forEach(btn => { btn.classList.remove('bg-[var(--primary)]', 'text-white', 'shadow-md'); btn.classList.add('bg-gray-100', 'text-gray-700', 'hover:bg-gray-200', 'dark:bg-gray-700', 'dark:text-gray-300', 'dark:hover:bg-gray-600'); });
this.classList.remove('bg-gray-100', 'text-gray-700', 'hover:bg-gray-200', 'dark:bg-gray-700', 'dark:text-gray-300', 'dark:hover:bg-gray-600'); this.classList.add('bg-[var(--primary)]', 'text-white', 'shadow-md');
// Filter items const items = document.querySelectorAll(`[data-item-section="${currentSectionId}"]`); items.forEach(item => { const itemStatus = item.dataset.itemStatus;
if (filter === 'all' || itemStatus === filter) { item.classList.remove('hidden'); item.style.display = 'block'; } else { item.classList.add('hidden'); item.style.display = 'none'; } });
// Update pagination updatePagination(currentSectionId); }); }); }
function updatePagination(sectionId) { const visibleItems = document.querySelectorAll(`[data-item-section="${sectionId}"]:not(.hidden)`); const pagination = document.querySelector(`[data-pagination-section="${sectionId}"]`);
if (pagination) { // Trigger pagination update const event = new CustomEvent('updatePagination', { detail: { visibleCount: visibleItems.length } }); pagination.dispatchEvent(event); } }
// Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initFilterControls); } else { initFilterControls(); }</script>分页控制组件
---import { Icon } from "astro-icon/components";
interface Props { totalItems: number; itemsPerPage: number; currentPage: number; sectionId: string;}395 collapsed lines
const { totalItems, itemsPerPage, currentPage, sectionId } = Astro.props;const totalPages = Math.ceil(totalItems / itemsPerPage);
// 生成智能分页页码数组function generatePageNumbers(current: number, total: number) { const delta = 2; // 当前页左右显示的页码数量 const range = []; const rangeWithDots = [];
// 如果总页数小于等于7,显示所有页码 if (total <= 7) { for (let i = 1; i <= total; i++) { range.push(i); } return range; }
// 计算显示范围 const left = Math.max(2, current - delta); const right = Math.min(total - 1, current + delta);
// 始终显示第一页 rangeWithDots.push(1);
// 如果左边界大于2,添加省略号 if (left > 2) { rangeWithDots.push("..."); }
// 添加中间页码 for (let i = left; i <= right; i++) { rangeWithDots.push(i); }
// 如果右边界小于最后一页-1,添加省略号 if (right < total - 1) { rangeWithDots.push("..."); }
// 始终显示最后一页(如果总页数大于1) if (total > 1) { rangeWithDots.push(total); }
return rangeWithDots;}
const pageNumbers = generatePageNumbers(currentPage, totalPages);---
{totalPages > 1 && ( <div class="responsive-pagination flex justify-center items-center mt-8" data-pagination-section={sectionId}> <!-- 移动端简化版分页 --> <div class="mobile-pagination items-center space-x-2"> <button type="button" class="p-1.5 rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors duration-200" data-page="prev" data-section={sectionId} disabled={currentPage === 1} aria-label="上一页" > <Icon name="material-symbols:chevron-left-rounded" class="text-base" /> </button>
<!-- 移动端页码信息 --> <div class="flex items-center space-x-1"> <span class="text-sm text-gray-600 dark:text-gray-400">第</span> <span class="mobile-current-page px-2 py-1 text-sm font-medium bg-blue-500 text-white rounded">{currentPage}</span> <span class="text-sm text-gray-600 dark:text-gray-400">页,共</span> <span class="mobile-total-pages text-sm font-medium text-gray-700 dark:text-gray-300">{totalPages}</span> <span class="text-sm text-gray-600 dark:text-gray-400">页</span> </div>
<button type="button" class="p-1.5 rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors duration-200" data-page="next" data-section={sectionId} disabled={currentPage === totalPages} aria-label="下一页" > <Icon name="material-symbols:chevron-right-rounded" class="text-base" /> </button> </div>
<!-- 桌面端完整版分页 --> <div class="desktop-pagination items-center space-x-2"> <button type="button" class="p-2 rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600" data-page="prev" data-section={sectionId} disabled={currentPage === 1} aria-label="上一页" > <Icon name="material-symbols:chevron-left-rounded" class="text-lg" /> </button>
<div class="desktop-page-numbers flex flex-wrap justify-center space-x-1" data-page-numbers={sectionId}> {pageNumbers.map((pageItem) => ( pageItem === '...' ? ( <span class="px-2 py-2 text-sm text-gray-500 dark:text-gray-400">...</span> ) : ( <button type="button" class:list={[ "px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200", { "bg-blue-500 text-white shadow-md": pageItem === currentPage, "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600": pageItem !== currentPage } ]} data-page={pageItem} data-section={sectionId} aria-label={`第${pageItem}页`} aria-current={pageItem === currentPage ? "page" : undefined} > {pageItem} </button> ) ))} </div>
<button type="button" class="p-2 rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600" data-page="next" data-section={sectionId} disabled={currentPage === totalPages} aria-label="下一页" > <Icon name="material-symbols:chevron-right-rounded" class="text-lg" /> </button> </div> </div>)}
<script define:vars={{ itemsPerPage, sectionId }}> function initPagination() { let currentPage = 1;
const pagination = document.querySelector(`[data-pagination-section="${sectionId}"]`); if (!pagination) return;
// 更新移动端页码显示 function updateMobileDisplay() { const mobileCurrentPage = pagination.querySelector('.mobile-current-page'); if (mobileCurrentPage) { mobileCurrentPage.textContent = currentPage.toString(); } }
const pageButtons = pagination.querySelectorAll('[data-page]');
pageButtons.forEach(button => { button.addEventListener('click', function() { const page = this.dataset.page;
if (page === 'prev') { currentPage = Math.max(1, currentPage - 1); } else if (page === 'next') { const items = document.querySelectorAll(`[data-item-section="${sectionId}"]:not(.hidden)`); const totalPages = Math.ceil(items.length / itemsPerPage); currentPage = Math.min(totalPages, currentPage + 1); } else { currentPage = parseInt(page); }
updatePage(); updateMobileDisplay(); }); });
// Listen for filter updates if (pagination) { pagination.addEventListener('updatePagination', function(event) { currentPage = 1; updatePage(); updateMobileDisplay(); }); }
function updatePage() { const items = document.querySelectorAll(`[data-item-section="${sectionId}"]:not(.hidden)`); const totalPages = Math.ceil(items.length / itemsPerPage);
// Hide all items first for (const item of items) { item.style.display = 'none'; }
// Show items for current page const startIndex = (currentPage - 1) * itemsPerPage; const endIndex = startIndex + itemsPerPage; for (let i = startIndex; i < endIndex && i < items.length; i++) { items[i].style.display = 'block'; }
// Update pagination buttons updatePaginationButtons(totalPages); }
// 生成智能分页页码数组的JavaScript版本 function generatePageNumbers(current, total) { const delta = 2; // 当前页左右显示的页码数量 const rangeWithDots = [];
// 如果总页数小于等于7,显示所有页码 if (total <= 7) { for (let i = 1; i <= total; i++) { rangeWithDots.push(i); } return rangeWithDots; }
// 计算显示范围 const left = Math.max(2, current - delta); const right = Math.min(total - 1, current + delta);
// 始终显示第一页 rangeWithDots.push(1);
// 如果左边界大于2,添加省略号 if (left > 2) { rangeWithDots.push('...'); }
// 添加中间页码 for (let i = left; i <= right; i++) { rangeWithDots.push(i); }
// 如果右边界小于最后一页-1,添加省略号 if (right < total - 1) { rangeWithDots.push('...'); }
// 始终显示最后一页(如果总页数大于1) if (total > 1) { rangeWithDots.push(total); }
return rangeWithDots; }
function updatePaginationButtons(totalPages) { const prevButton = pagination.querySelector('[data-page="prev"]'); const nextButton = pagination.querySelector('[data-page="next"]'); const pageNumbersContainer = pagination.querySelector(`[data-page-numbers="${sectionId}"]`);
if (prevButton) { prevButton.disabled = currentPage === 1; }
if (nextButton) { nextButton.disabled = currentPage === totalPages; }
if (pageNumbersContainer) { pageNumbersContainer.innerHTML = ''; const pageNumbers = generatePageNumbers(currentPage, totalPages);
pageNumbers.forEach(pageItem => { if (pageItem === '...') { const span = document.createElement('span'); span.className = 'px-3 py-2 text-gray-500 dark:text-gray-400'; span.textContent = '...'; pageNumbersContainer.appendChild(span); } else { const button = document.createElement('button'); button.className = `px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${ pageItem === currentPage ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600' }`; button.dataset.page = pageItem.toString(); button.dataset.section = sectionId; button.textContent = pageItem.toString(); button.addEventListener('click', function() { currentPage = pageItem; updatePage(); }); pageNumbersContainer.appendChild(button); } }); } }
// Initial page setup updatePage(); }
// Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initPagination); } else { initPagination(); }</script>
<style> .responsive-pagination { /* 确保在所有设备上都能正确显示 */ max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
/* 移动端样式 (< 768px) */ .mobile-pagination { display: flex; padding: 0 1rem; }
.desktop-pagination { display: none; }
/* 桌面端样式 (>= 768px) */ @media (min-width: 768px) { .mobile-pagination { display: none; }
.desktop-pagination { display: flex; } }
/* 小屏手机优化 */ @media (max-width: 640px) { .mobile-pagination { padding: 0 0.5rem; } .mobile-pagination button { padding: 0.25rem; } }
/* 超小屏优化 */ @media (max-width: 480px) { .mobile-pagination { padding: 0 0.25rem; } .mobile-pagination .space-x-1 > * + * { margin-left: 0.125rem; } }
/* 平滑过渡效果 */ .responsive-pagination button { transition: all 0.2s ease-in-out; }
/* 高对比度模式支持 */ @media (prefers-contrast: high) { .responsive-pagination button { border: 1px solid currentColor; } }
/* 减少动画偏好支持 */ @media (prefers-reduced-motion: reduce) { .responsive-pagination button { transition: none; } }
/* 触摸设备优化 */ @media (hover: none) and (pointer: coarse) { .responsive-pagination button { min-height: 44px; /* iOS建议的最小触摸目标 */ min-width: 44px; }
/* 移动端触摸优化 */ .mobile-pagination button { min-height: 40px; min-width: 40px; } }
/* 横屏手机优化 */ @media (max-width: 768px) and (orientation: landscape) { .mobile-pagination { padding: 0 0.5rem; }
.mobile-pagination button { padding: 0.25rem; } }</style>标签页导航组件
---interface Tab { id: string; name: string; count?: number;}
interface Props { tabs: Tab[];70 collapsed lines
activeTab: string;}
const { tabs, activeTab } = Astro.props;---
<div class="border-b border-gray-200 dark:border-gray-700 mb-3"> <nav class="flex space-x-8" aria-label="Tabs"> {tabs.map((tab) => ( <button class:list={[ "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200", { "border-[var(--primary)] text-[var(--primary)]": tab.id === activeTab, "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300": tab.id !== activeTab } ]} data-tab={tab.id} type="button" > {tab.name} {tab.count !== undefined && ( <span class="ml-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 py-0.5 px-2 rounded-full text-xs"> {tab.count} </span> )} </button> ))} </nav></div>
<script> function initTabNavigation() { const tabButtons = document.querySelectorAll('[data-tab]'); const sections = document.querySelectorAll('[data-section]');
tabButtons.forEach(button => { button.addEventListener('click', (event) => { const currentButton = event.currentTarget as HTMLButtonElement; const targetTab = currentButton.dataset.tab;
// Update active tab tabButtons.forEach(btn => { btn.classList.remove('border-[var(--primary)]', 'text-[var(--primary)]'); btn.classList.add('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300', 'dark:text-gray-400', 'dark:hover:text-gray-300'); });
currentButton.classList.remove('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300', 'dark:text-gray-400', 'dark:hover:text-gray-300'); currentButton.classList.add('border-[var(--primary)]', 'text-[var(--primary)]');
// Show/hide sections sections.forEach(section => { const htmlSection = section as HTMLElement; if (htmlSection.dataset.section === targetTab) { htmlSection.classList.remove('hidden'); } else { htmlSection.classList.add('hidden'); } }); }); }); }
// Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initTabNavigation); } else { initTabNavigation(); }</script>接下来添加 Bangumi 页面文件,记得把 Bangumi 配置中的username修改为你自己的。如果你的博客编译环境无法正常访问 bangumi 的 api,可以修改apiUrl。categories请按需修改,如果你的收藏数据比较多,可以修改pagination.maxTotal 。
---import BangumiSection from "@/components/bangumi/BangumiSection.astro";import TabNav from "@/components/bangumi/TabNav.astro";import MainGridLayout from "@/layouts/MainGridLayout.astro";import type { UserSubjectCollection, UserSubjectCollectionResponse,} from "@/types/bangumi";
189 collapsed lines
//////////////// Bangumi 配置 ////////////////////////const bangumiConfig = { username: "114514", // 在这里配置你的Bangumi用户名 apiUrl: "https://api.bgm.tv", categories: { book: false, anime: true, music: false, game: true, real: false, }, // 数据获取设置 pagination: { limit: 50, // 每页获取数量 delay: 50, // 请求间隔毫秒数,避免频率限制 maxTotal: 1000, // 最大获取总数,防止无限循环(0=无限制) },};////////////////////////////////////////////////////
// 分类映射const categoryMap = { book: { id: "book", name: "书籍", subjectType: 1 }, anime: { id: "anime", name: "动画", subjectType: 2 }, music: { id: "music", name: "音乐", subjectType: 3 }, game: { id: "game", name: "游戏", subjectType: 4 }, real: { id: "real", name: "三次元", subjectType: 6 },};
// 获取Bangumi数据的函数 - 支持分页获取所有数据async function fetchBangumiData(username: string, subjectType: number) { try { const { limit, delay, maxTotal } = bangumiConfig.pagination; let offset = 0; let allData: UserSubjectCollection[] = []; let hasMore = true;
console.log( `[Bangumi] 开始获取用户 ${username} 的 subjectType ${subjectType} 数据...`, );
while (hasMore) { // 检查是否超过最大获取限制 if (maxTotal > 0 && allData.length >= maxTotal) { console.log(`[Bangumi] 已达到最大获取限制 ${maxTotal},停止获取`); break; }
const url = `${bangumiConfig.apiUrl}/v0/users/${username}/collections?subject_type=${subjectType}&limit=${limit}&offset=${offset}`;
console.log(`[Bangumi] 正在获取数据: ${url} (已获取: ${allData.length})`);
const response = await fetch(url, { headers: { "User-Agent": "YuuOuRou Blog", Accept: "application/json", }, });
if (!response.ok) { console.warn( `[Bangumi] 无法获取数据 (状态码: ${response.status}):`, url, ); break; }
const data = (await response.json()) as UserSubjectCollectionResponse; const currentBatch = data.data || [];
if (currentBatch.length > 0) { allData = allData.concat(currentBatch); offset += limit;
// 如果本次获取的数据少于limit,说明已经是最后一页 if (currentBatch.length < limit) { hasMore = false; } } else { hasMore = false; }
// 添加延迟避免请求过于频繁 if (hasMore) { await new Promise((resolve) => setTimeout(resolve, delay)); } }
console.log(`[Bangumi] 总共获取到 ${allData.length} 条数据`); return allData; } catch (error) { console.error("[Bangumi] 获取数据时出错:", error); return []; }}
// 获取所有启用分类的数据const bangumiData: Record<string, UserSubjectCollection[]> = {};const tabs: Array<{ id: string; name: string; count: number }> = [];
console.log("[Bangumi] 🌐 从 API 获取数据");
for (const [categoryKey, enabled] of Object.entries(bangumiConfig.categories)) { if (enabled && categoryMap[categoryKey as keyof typeof categoryMap]) { const categoryInfo = categoryMap[categoryKey as keyof typeof categoryMap]; try { const data = await fetchBangumiData( bangumiConfig.username, categoryInfo.subjectType, ); bangumiData[categoryKey] = data; tabs.push({ id: categoryKey, name: categoryInfo.name, count: data.length, }); } catch (error) { console.error(`[Bangumi] 获取 ${categoryInfo.name} 数据失败:`, error); bangumiData[categoryKey] = []; tabs.push({ id: categoryKey, name: categoryInfo.name, count: 0, }); } }}
const activeTab = tabs[0]?.id || "anime";---
<MainGridLayout title={"Bangumi"} description={"Bangumi Collection"}> <div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32"> <div class="card-base z-10 px-9 py-6 relative w-full"> <!-- 页面标题 --> <div class="text-center mb-8"> <h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-3"> Bangumi Collection </h1> <p class="text-gray-600 dark:text-gray-400 mb-2"> 对魔女而言二次元是最好的消遣 </p> </div>
<!-- Tab 导航和内容 --> {tabs.length > 0 ? ( <> <TabNav tabs={tabs} activeTab={activeTab} />
<!-- 内容区域 --> {tabs.map((tab) => ( <BangumiSection sectionId={tab.id} items={bangumiData[tab.id] || []} isActive={tab.id === activeTab} itemsPerPage={6} /> ))} </> ) : ( <div class="text-center py-12"> <h2 class="text-xl font-medium text-gray-600 dark:text-gray-400 mb-2"> 暂无数据 </h2> <p class="text-gray-500 dark:text-gray-400 mb-4"> 可能的原因:用户名不存在、网络连接问题或API限制 </p> <div class="text-sm text-gray-400 dark:text-gray-500 space-y-1"> <div>用户名: {bangumiConfig.username}</div> <div>API: {bangumiConfig.apiUrl}</div> <div class="mt-3 text-xs"> 提示:请在页面配置中设置正确的Bangumi用户名 </div> </div> </div> )} </div> </div></MainGridLayout>
<style> /* 自定义样式 */ .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }</style>最后在博客导航栏中添加 Bangumi 页面的链接即可。
export const navBarConfig: NavBarConfig = { links: [ // ... { name: "Bangumi", url: "/bangumi/", } ],};成果展示
请访问Bangumi 页面查看成果。
Fuwari 功能增强 Episode2
https://kasuha.com/posts/fuwari-enhance-ep2/