给博客增加单个和全部分类页面
2035 字
10 分钟
给博客增加单个和全部分类页面
文件目录
src/├── components/│ └── layout/│ ├── CategoryBar.astro [分类栏组件:显示主页、归档、分类按钮,支持横向滚动]├── config/│ └── navBarConfig.ts [导航栏配置:动态生成导航链接,添加分类菜单项]├── i18n/│ ├── i18nKey.ts [国际化键定义:新增 allCategories 和 searchAlbums 键]│ └── languages/│ ├── en.ts [英文翻译文件]│ ├── ja.ts [日文翻译文件]│ ├── ru.ts [俄文翻译文件]│ ├── zh_CN.ts [简体中文翻译文件]│ └── zh_TW.ts [繁体中文翻译文件]├── pages/│ ├── categories.astro [全部分类页面:显示所有分类卡片列表(新增)]│ └── categories/│ └── [category].astro [单个分类详情页:显示该分类文章和标签筛选(新增)]└── utils/ └── url-utils.ts [URL工具函数:新增分类相关URL生成函数]创建全部分类页面
文件路径:src/pages/categories.astro
---import { Icon } from "astro-icon/components";import MainGridLayout from "@layouts/MainGridLayout.astro";import { getCategoryList } from "@/utils/content-utils";import I18nKey from "@i18n/i18nKey";import { i18n } from "@i18n/translation";
const categories = await getCategoryList();const totalPosts = categories.reduce((sum, cat) => sum + cat.count, 0);---
<MainGridLayout title={i18n(I18nKey.categories)}> <!-- 页面标题 --> <div class="card-base px-8 py-6 mb-6"> <h1 class="text-2xl font-bold text-(--primary) mb-2"> {i18n(I18nKey.categories)} </h1> <p class="text-30 text-sm"> {i18n(I18nKey.allCategories)} · {totalPosts} {i18n(I18nKey.postsCount)} </p> </div>
<!-- 分类卡片列表 --> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6"> { categories.map((category, index) => ( <a href={category.url} class="card-base p-6 flex items-center gap-4 transition-all duration-300 hover:scale-[1.02] hover:shadow-lg active:scale-[0.98] onload-animation" style={`animation-delay: calc(var(--content-delay) + ${index * 50}ms);`} > <div class="flex-shrink-0 w-12 h-12 rounded-full bg-(--primary)/10 flex items-center justify-center"> <Icon name="material-symbols:folder-open" class="text-2xl text-(--primary)" /> </div> <div class="flex-1 min-w-0"> <h2 class="text-lg font-bold text-90 truncate"> {category.name} </h2> <p class="text-sm text-30 mt-1"> {category.count} {i18n(I18nKey.postsCount)} </p> </div> <div class="flex-shrink-0"> <Icon name="material-symbols:chevron-right-rounded" class="text-2xl text-30 transition-transform duration-200 group-hover:translate-x-1" /> </div> </a> )) } </div>
<!-- 空状态 --> { categories.length === 0 && ( <div class="card-base px-8 py-12 text-center"> <Icon name="material-symbols:folder-off" class="text-5xl text-30 mx-auto mb-4" /> <p class="text-30">{i18n(I18nKey.bangumiNoData)}</p> </div> ) }</MainGridLayout>
<style> .card-base { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius-large); }
a.card-base:hover { border-color: var(--primary); }
a.card-base:active { background: var(--card-bg-active); }</style>创建单个分类详情页
文件路径:src/pages/categories/[category].astro
---import type { GetStaticPaths } from "astro";import type { CollectionEntry } from "astro:content";import { getCollection } from "astro:content";import { Icon } from "astro-icon/components";import PostCard from "@/components/layout/PostCard.astro";import MainGridLayout from "@layouts/MainGridLayout.astro";import { getCategoryList } from "@/utils/content-utils";import { getTagUrl, getCategoryPageUrl, getPostUrlBySlug } from "@/utils/url-utils";import I18nKey from "@i18n/i18nKey";import { i18n } from "@i18n/translation";import { siteConfig } from "@/config";
type PostEntry = CollectionEntry<"posts">;
interface Props { categoryName: string; posts: PostEntry[]; totalPosts: number; tags: { name: string; count: number }[];}
export const getStaticPaths = (async () => { const categories = await getCategoryList(); const allPosts = await getCollection("posts", ({ data }) => { return import.meta.env.PROD ? data.draft !== true : true; }) as PostEntry[];
const paths: any[] = [];
for (const category of categories) { // 过滤该分类下的文章 const categoryPosts = allPosts.filter((post) => { const postCategory = post.data.category?.trim() || ""; const uncategorized = i18n(I18nKey.uncategorized).toLowerCase();
if (!category.name || category.name.toLowerCase() === uncategorized) { // 未分类:没有分类或分类为空/未分类 return !postCategory || postCategory.toLowerCase() === uncategorized; } return postCategory === category.name; });
// 按日期排序 categoryPosts.sort((a, b) => { if (a.data.pinned && !b.data.pinned) return -1; if (!a.data.pinned && b.data.pinned) return 1; return new Date(b.data.published).getTime() - new Date(a.data.published).getTime(); });
// 收集该分类下所有标签 const tagCountMap: Record<string, number> = {}; categoryPosts.forEach((post) => { (post.data.tags || []).forEach((tag: string) => { tagCountMap[tag] = (tagCountMap[tag] || 0) + 1; }); }); const tags = Object.entries(tagCountMap) .sort((a, b) => b[1] - a[1]) .map(([name, count]) => ({ name, count }));
paths.push({ params: { category: category.name }, props: { categoryName: category.name, posts: categoryPosts, totalPosts: categoryPosts.length, tags, }, }); }
return paths;}) satisfies GetStaticPaths;
const { categoryName, posts, totalPosts, tags } = Astro.props;
// 解码分类名称const decodedCategory = decodeURIComponent(categoryName);
let delay = 0;const interval = 50;
const masonryEnabled = siteConfig.postListLayout.grid.masonry;const columnWidth = siteConfig.postListLayout.grid.columnWidth || 280;
const defaultLayout = siteConfig.postListLayout.defaultMode || "list";const mobileDefaultLayout = siteConfig.postListLayout.mobileDefaultMode || defaultLayout;const initialLayoutClass = defaultLayout === "grid" ? "post-grid-auto grid-mode" : "flex flex-col gap-4 md:gap-4 list-mode";---
<MainGridLayout title={`${decodedCategory} - ${i18n(I18nKey.categories)}`}> <!-- 面包屑导航 --> <div class="card-base px-6 py-4 mb-6"> <nav class="flex items-center gap-2 text-sm text-30"> <a href="/" class="hover:text-(--primary) transition-colors"> <Icon name="material-symbols:home" class="inline-block align-middle" /> </a> <span class="opacity-50">/</span> <a href={getCategoryPageUrl()} class="hover:text-(--primary) transition-colors"> {i18n(I18nKey.categories)} </a> <span class="opacity-50">/</span> <span class="text-90 font-medium">{decodedCategory}</span> </nav> </div>
<!-- 分类标题 --> <div class="card-base px-8 py-6 mb-6"> <div class="flex items-center gap-4"> <div class="w-14 h-14 rounded-full bg-(--primary)/10 flex items-center justify-center flex-shrink-0"> <Icon name="material-symbols:folder-open" class="text-3xl text-(--primary)" /> </div> <div> <h1 class="text-2xl font-bold text-90">{decodedCategory}</h1> <p class="text-sm text-30 mt-1"> {totalPosts} {i18n(I18nKey.postsCount)} </p> </div> </div> </div>
<!-- 标签筛选区域 --> { tags.length > 0 && ( <div class="card-base px-6 py-5 mb-6"> <div class="flex items-center gap-3 mb-3"> <Icon name="material-symbols:label" class="text-lg text-(--primary)" /> <span class="text-sm font-medium text-90">{i18n(I18nKey.tags)}</span> <span class="text-xs text-30">({tags.length})</span> </div> <div class="flex flex-wrap gap-2"> {tags.map((tag: { name: string; count: number }) => ( <a href={getTagUrl(tag.name)} class="btn-regular px-3 py-1.5 text-sm rounded-md transition-all duration-200 hover:scale-105 hover:bg-(--primary)/20 active:scale-95" > #{tag.name} <span class="ml-1 text-xs opacity-60">({tag.count})</span> </a> ))} </div> </div> ) }
<!-- 文章列表 --> <div id="post-list-container" class={`transition-all duration-500 ease-in-out mb-4 ${initialLayoutClass}`} data-default-layout={defaultLayout} data-mobile-default-layout={mobileDefaultLayout} data-masonry-enabled={masonryEnabled} data-column-width={columnWidth} style={`--post-card-min-width: ${columnWidth}px;`} > { posts.length > 0 ? ( posts.map((entry: PostEntry, index: number) => ( <PostCard entry={entry} title={entry.data.title} tags={entry.data.tags} category={entry.data.category} published={entry.data.published} updated={entry.data.updated} url={getPostUrlBySlug(entry.id)} image={entry.data.image} description={entry.data.description} draft={entry.data.draft} pinned={entry.data.pinned} password={!!entry.data.password} loading={index < 2 ? "eager" : "lazy"} class:list="onload-animation post-card-item" style={`animation-delay: calc(var(--content-delay) + ${delay++ * interval}ms);`} /> )) ) : ( <div class="card-base px-8 py-12 text-center w-full"> <Icon name="material-symbols:article" class="text-5xl text-30 mx-auto mb-4" /> <p class="text-30">{i18n(I18nKey.bangumiNoData)}</p> </div> ) } </div></MainGridLayout>
<script is:inline define:vars={{ defaultLayout, mobileDefaultLayout, columnWidth }}> (function() { const savedLayout = localStorage.getItem('postListLayout'); const effectiveDefault = window.innerWidth < 780 ? mobileDefaultLayout : defaultLayout; const effectiveLayout = window.innerWidth < 380 ? 'grid' : (savedLayout || effectiveDefault);
if (effectiveLayout !== defaultLayout) { const container = document.getElementById('post-list-container');
if (container) { container.style.transition = 'none'; container.classList.remove('list-mode', 'grid-mode', 'post-grid-auto', 'flex', 'flex-col', 'gap-4', 'md:gap-4');
if (effectiveLayout === 'grid') { container.classList.add('grid-mode', 'post-grid-auto'); } else { container.classList.add('list-mode', 'flex', 'flex-col', 'gap-4', 'md:gap-4'); }
container.offsetHeight; container.style.transition = ''; } } })();</script>
<script> function initLayout() { const postListContainer = document.getElementById("post-list-container"); if (!postListContainer) return;
const savedLayout = localStorage.getItem("postListLayout"); const defaultLayout = postListContainer.getAttribute("data-default-layout") || "list"; const mobileDefaultLayout = postListContainer.getAttribute("data-mobile-default-layout") || defaultLayout;
const effectiveDefault = window.innerWidth < 780 ? mobileDefaultLayout : defaultLayout; let currentLayout = savedLayout || effectiveDefault;
if (window.innerWidth < 380) { currentLayout = "grid"; }
updatePostListLayout(currentLayout); }
function updatePostListLayout(layout: string) { const postListContainer = document.getElementById("post-list-container"); if (!postListContainer) return;
const isCurrentGrid = postListContainer.classList.contains("grid-mode"); const isCurrentList = postListContainer.classList.contains("list-mode"); const currentLayout = isCurrentGrid ? "grid" : (isCurrentList ? "list" : null);
const masonryEnabled = postListContainer.getAttribute("data-masonry-enabled") === "true";
const applyClasses = () => { postListContainer.classList.remove("list-mode", "grid-mode", "post-grid-auto"); if (layout === "grid") { postListContainer.classList.add("grid-mode"); postListContainer.classList.remove("flex", "flex-col"); if (masonryEnabled) { postListContainer.classList.remove("post-grid-auto"); applyMasonryLayout(); } else { postListContainer.classList.add("post-grid-auto"); resetMasonryLayout(); } } else { postListContainer.classList.add("list-mode"); postListContainer.classList.add("flex", "flex-col", "gap-4", "md:gap-4"); postListContainer.classList.remove("post-grid-auto"); resetMasonryLayout(); } };
if (!currentLayout) { applyClasses(); return; }
if (currentLayout === layout) { if (layout === "grid" && masonryEnabled) { applyMasonryLayout(); } return; }
postListContainer.classList.add("layout-switching");
setTimeout(() => { applyClasses();
requestAnimationFrame(() => { postListContainer.classList.remove("layout-switching"); }); }, 200); }
function resetMasonryLayout() { const container = document.getElementById("post-list-container"); if (!container) return;
container.style.height = ""; container.style.position = ""; container.style.display = "";
const items = container.querySelectorAll(".post-card-item"); items.forEach((item) => { // @ts-ignore item.style.position = ""; // @ts-ignore item.style.top = ""; // @ts-ignore item.style.left = ""; // @ts-ignore item.style.width = ""; }); }
function applyMasonryLayout() { const container = document.getElementById("post-list-container"); if (!container) return;
const masonryEnabled = container.getAttribute("data-masonry-enabled") === "true"; if (!masonryEnabled) return;
if (!container.classList.contains("grid-mode")) return;
const items = Array.from(container.querySelectorAll(".post-card-item")); if (items.length === 0) return;
const gap = 16; const columnWidth = parseInt(container.getAttribute("data-column-width") || "280");
container.style.position = "relative"; container.style.display = "block";
const containerWidth = container.offsetWidth; const colCount = Math.max(1, Math.floor((containerWidth + gap) / (columnWidth + gap))); const itemWidth = (containerWidth - (colCount - 1) * gap) / colCount;
const colHeights = new Array(colCount).fill(0);
items.forEach((item) => { const colIndex = colHeights.indexOf(Math.min(...colHeights)); // @ts-ignore item.style.position = "absolute"; // @ts-ignore item.style.width = `${itemWidth}px`; // @ts-ignore item.style.setProperty('height', 'auto', 'important');
// @ts-ignore const height = item.offsetHeight; const top = colHeights[colIndex]; const left = colIndex * (itemWidth + gap);
// @ts-ignore item.style.top = `${top}px`; // @ts-ignore item.style.left = `${left}px`;
colHeights[colIndex] += height + gap; });
container.style.height = `${Math.max(...colHeights)}px`; }
document.addEventListener("DOMContentLoaded", function () { setTimeout(initLayout, 50);
const imgs = document.querySelectorAll('#post-list-container img'); imgs.forEach(img => { // @ts-ignore if(img.complete) return; img.addEventListener('load', () => { applyMasonryLayout(); }); }); });
document.addEventListener("visibilitychange", function () { if (!document.hidden) { setTimeout(initLayout, 100); } });
window.addEventListener("layoutChange", function (event) { // @ts-ignore const newLayout = event.detail.layout; const postListContainer = document.getElementById("post-list-container"); if (!postListContainer) return;
updatePostListLayout(newLayout); });
let resizeTimeout: any; window.addEventListener("resize", function () { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(function () { initLayout(); }, 250); });
document.addEventListener("astro:page-load", function () { setTimeout(initLayout, 50);
const imgs = document.querySelectorAll('#post-list-container img'); imgs.forEach(img => { // @ts-ignore if(img.complete) return; img.addEventListener('load', () => { applyMasonryLayout(); }); }); });
document.addEventListener("astro:after-swap", function () { setTimeout(initLayout, 50); });
setTimeout(initLayout, 0);</script>
<style is:global> .post-grid-auto { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--post-card-min-width, 320px), 1fr)); gap: 1rem; }
@media (max-width: 767px) { .list-mode .post-meta-root { transform: scale(0.72) !important; transform-origin: top left !important; margin-bottom: 0.25rem !important; width: 139% !important; } .list-mode :global(.post-meta) { margin-bottom: 0.25rem !important; gap: 0.25rem !important; } }</style>
<style> #post-list-container { transition: opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1), transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
#post-list-container > :global(*) { transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
#post-list-container { transition: opacity 0.2s ease-out, transform 0.2s ease-out; }
#post-list-container.layout-switching { opacity: 0; transform: translateY(10px); }
#post-list-container.grid-mode :global(.post-card-wrapper) { flex-direction: column-reverse !important; }
#post-list-container.grid-mode :global(.post-card-image) { width: 100% !important; position: relative !important; top: auto !important; right: auto !important; bottom: auto !important; border-radius: var(--radius-large) var(--radius-large) 0 0 !important; }
#post-list-container.grid-mode :global(.post-card-content) { width: 100% !important; padding: 1rem !important; }
#post-list-container.grid-mode :global(.no-cover .post-card-content) { padding-right: 4.5rem !important; }
#post-list-container.grid-mode :global(.post-card-title) { font-size: 1.35rem !important; line-height: 1.75rem !important; margin-bottom: 0.5rem !important; }
#post-list-container.grid-mode :global(.post-card-title::before) { display: none !important; }
#post-list-container.grid-mode :global(.post-meta) { margin-bottom: 0.5rem !important; gap: 0.5rem !important; }
#post-list-container.grid-mode :global(.post-meta .text-xl) { font-size: 1rem !important; line-height: 1.25rem !important; }
#post-list-container.grid-mode :global(.meta-icon) { width: 1.5rem !important; height: 1.5rem !important; margin-right: 0.25rem !important; }
#post-list-container.grid-mode :global(.post-meta .text-sm) { font-size: 0.75rem !important; line-height: 1rem !important; }
#post-list-container.grid-mode :global(.post-meta .pinned-btn) { padding: 0.25rem 0.375rem !important; }</style>创建URL工具函数
文件路径:src/utils/url-utils.ts
export function getCategoryUrl(category: string | null): string { if ( !category || category.trim() === "" || category.trim().toLowerCase() === i18n(I18nKey.uncategorized).toLowerCase() ) return url("/categories/uncategorized/"); return url(`/categories/${encodeURIComponent(category.trim())}/`);}
export function getCategoryPageUrl(): string { return url("/categories/");}更新分类栏组件
文件路径:src/components/layout/CategoryBar.astro
---import { url, getCategoryPageUrl } from "@/utils/url-utils";const categoriesUrl = getCategoryPageUrl();---
<a href={categoriesUrl} class="category-pill text-sm px-3 py-1 rounded-lg whitespace-nowrap flex-shrink-0 transition-colors duration-200 flex items-center justify-center" data-category-name="__categories__" > {i18n(I18nKey.categories)} <span class="text-xs opacity-60 ml-1">{categories.length}</span></a>导航栏配置
文件路径:src/config/navBarConfig.ts
// 根据页面开关动态生成导航栏配置const getDynamicNavBarConfig = (): NavBarConfig => { // 基础导航栏链接 const links: (NavBarLink | LinkPreset)[] = [ // 主页 LinkPreset.Home,
// 文章分类 { name: "分类", url: "/categories/", icon: "material-symbols:folder-open", },
// 归档 LinkPreset.Archive, ];}语言配置
- 文件路径:
src/i18n/i18nKey.ts
allCategories = "allCategories",- 文件路径:
src/i18n/languages/en.ts
[Key.allCategories]: "All Categories",- 文件路径:
src/i18n/languages/ja.ts
[Key.allCategories]: "すべてのカテゴリ",- 文件路径:
src/i18n/languages/ru.ts
[Key.allCategories]: "Все категории",- 文件路径:
src/i18n/languages/zh_CN.ts
[Key.allCategories]: "全部分类",- 文件路径:
src/i18n/languages/zh_TW.ts
[Key.allCategories]: "全部分類",支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!