给博客增加单个和全部分类页面

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]: "全部分類",

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
给博客增加单个和全部分类页面
https://hyde.seasir.top/blog/categories/
作者
Hyde
发布于
2026-06-02
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Hyde
Hello, I'm Hyde.
📢 欢迎来访者
👋🏻 Hi,我是Hyde,欢迎您!
分类
标签
站点统计
文章
9
分类
2
标签
3
总字数
3,019
运行时长
0
最后活动
0 天前
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
我和宝宝在一起已经
---------TSH ❤️ CXY---------
---------TSH
❤️
CXY---------
0 0 0 0 0 00
✨ 今日一言
" 人生如逆旅,我亦是行人。 "
—— 苏轼
统计

文章目录

✨️ 复制成功,转载请标注本文地址