3680 字
18 分钟
Fuwari 功能增强 Episode2

前言#

对 Fuwari 的做了一些扩展,增加了 Bangumi 收藏展示页面,严格来说并不算对 Fuwari 的修改。

NOTE

本文方法适用于 Astro

Bangumi 收藏#

在开始之前,我想先简要的说明一下我的实现方式。博客的 Bangumi 数据为编译时获取的,一旦编译完成,不会根据读者的访问时间自动更新。在不引入额外的服务端的情况下,我只能想到这样的方案。

Bangumi API 参考:Bangumi API

首先根据 API 文档,找到用户收藏数据的接口及其返回的数据格式。创建src/types/bangumi.ts文件,定义 API 返回的数据类型。

src/types/bangumi.ts
export type UserSubjectCollectionResponse = {
data: UserSubjectCollection[];
total: number;
limit: number;
offset: number;
};
export type UserSubjectCollection = {
subject_id: number; // 条目 ID
47 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 内容区域

src/components/bangumi/BangumiSection.astro
---
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 counts
const 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>

条目卡片组件

src/components/bangumi/Card.astro
---
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>

状态筛选组件

src/components/bangumi/FilterControls.astro
---
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>

分页控制组件

src/components/bangumi/Pagination.astro
---
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>

标签页导航组件

src/components/bangumi/TabNav.astro
---
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,可以修改apiUrlcategories请按需修改,如果你的收藏数据比较多,可以修改pagination.maxTotal

src/pages/bangumi.astro
---
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 页面的链接即可。

src/config.ts
export const navBarConfig: NavBarConfig = {
links: [
// ...
{
name: "Bangumi",
url: "/bangumi/",
}
],
};

成果展示#

请访问Bangumi 页面查看成果。

Fuwari 功能增强 Episode2
https://kasuha.com/posts/fuwari-enhance-ep2/
作者
霞葉
发布于
2025-09-16
许可协议
CC BY-NC-SA 4.0
评论加载中...