my-blog 博客列表页改造


my-blog 博客列表页改造

目标:在 my-blog 上做一套完整的 /blog/ 文章列表体系,提供两条可选路线:

  • 路线一(复刻 mare):竖向堆叠的横向条目卡片(左蓝竖条 + 标题 + 描述 + 日期 + 标签 + 右侧箭头)。见第一~六章。
  • 路线二(纯竖向列表,如 pudding):无卡片,日期 ↔ 标题 极简排列,或左侧虚线时间线(圆点 + 标题 hover)。见第七章。

两条路线都支持分页条(第八章,复用第四章分页器);并额外提供标签页 /blog/tags/(标签云 + 单标签筛选页,第九章),标签链接统一指向 /blog/tags/<tag>(第九章第 4 节)。

本文只给方案,不修改任何 my-blog 项目文件。所有改动落在新建文件上,不影响现有首页与文章页。


一、mare 是怎么实现的(原理)

层 文件 作用
路由 src/pages/blog/[...page].astro 用 paginate() 把文章按 pubDate 倒序分页,每页 siteConfig.postsPerPage(默认 6)篇
卡片 src/components/PostCard.astro 渲染一张横向条目卡片(article.flex.items-stretch.gap-4):左内容右箭头
排列 列表页用 <div class="flex flex-col gap-6"> 把卡片竖向堆叠 不是网格方块,是“一行一行”的列表
分页条 列表页底部 <nav> 渲染上一页 / 页码 / 下一页 用 page.url.prev / next、pageLinks

卡片外观要点(PostCard.astro):

  • 整卡:rounded-lg bg-white p-5 shadow-sm(圆角白底浅阴影)
  • 标题:左侧 h-8 w-1 rounded-full bg-blue-500 蓝竖条 + 标题文字,hover 变蓝 + 右侧 chevron 箭头淡入
  • 描述:line-clamp-2 最多两行
  • 日期:灰色圆角徽章(bg-gray-50),带日历图标
  • 标签:bg-blue-50/70 浅蓝 chips,hover 变实心蓝
  • 右侧:一个竖长箭头按钮,整行可点

二、my-blog 现状对比

项 my-blog 现状 需要做什么
/blog/ 列表页 不存在(只有 blog/[...slug].astro 文章详情页) 新建 src/pages/blog/index.astro(或 [...page].astro)
文章列表卡片 无 新建 src/components/PostCard.astro
内容字段 title / description / pubDate / tags 已有 直接可用
图标 用 astro-icon 的 lucide 图标 my-blog 未装 astro-icon → 改用内联 SVG(零依赖)
暗色模式 my-blog 无暗色模式 去掉 mare 里的 dark: 类(留着也无害,只是不生效)
标签链接 mare 指向 /blog/tags/... 标签页 my-blog 无标签页 → 暂用 /blog?tag=xxx 或 #
文章 URL blog/[...slug].astro 用 params:{slug:post.id} → /blog/<id> 卡片链接写 /blog/${post.id} 即可
首页 默认 starter 模板 按你要求不动;可顺手加个“查看全部文章”链接到 /blog

三、改动文件清单

序号 操作 文件 说明
1 新建 src/pages/blog/index.astro 列表页(方案 A 单页 / 方案 B 分页)
2 新建 src/components/PostCard.astro 横向条目卡片(适配 my-blog:内联 SVG、无 dark)
3 (可选)改 src/consts.ts 加 POSTS_PER_PAGE 常量(仅方案 B 用)
4 (可选)改 src/pages/index.astro 加一个指向 /blog 的入口链接(首页不动布局)
5 新建 src/components/PostList.astro 路线二纯列表组件(极简 / 时间线两种变体,无卡片)
6 新建 src/pages/blog/[...page].astro 路线二 + 分页版列表页(paginate(),每页 8 篇)
7 新建 src/pages/blog/tags/index.astro 标签页首页:列出全部标签 + 每标签文章数
8 新建 src/pages/blog/tags/[tag].astro 单标签页:按标签筛选,复用路线二列表

不涉及 astro.config.mjs、global.css、BlogPost.astro 等现有文件,风险低。


四、具体实现

4.1 新建 src/components/PostCard.astro

这是核心。相比 mare 的版本:① 图标全部换成内联 SVG(不依赖 astro-icon);② 删掉所有 dark: 类;③ 标签链接暂指向 /blog?tag=xxx;④ 日期格式改中文。

---
import type { CollectionEntry } from 'astro:content';

interface Props {
  post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
---
<article class="flex items-stretch gap-4 rounded-lg bg-white p-5 shadow-sm">
  <div class="mt-2.5 ml-2.5 flex min-w-0 flex-1 flex-col">
    <a href={`/blog/${post.id}`} class="group/title">
      <h2 class="flex items-center gap-3 text-2xl font-bold text-gray-800 transition group-hover/title:text-blue-500">
        <span class="h-8 w-1 shrink-0 rounded-full bg-blue-500"></span>
        <span>{post.data.title}</span>
        
        ![svg](data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iaC02IHctNiBzaHJpbmstMCAtdHJhbnNsYXRlLXgtMSB0ZXh0LWJsdWUtNTAwIG9wYWNpdHktMCB0cmFuc2l0aW9uIGR1cmF0aW9uLTI1MCBlYXNlLW91dCBncm91cC1ob3Zlci90aXRsZTp0cmFuc2xhdGUteC0wIGdyb3VwLWhvdmVyL3RpdGxlOm9wYWNpdHktMTAwIgogICAgICAgICAgICAgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIgogICAgICAgICAgICAgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBhcmlhLWhpZGRlbj0idHJ1ZSIgaGVpZ2h0PSIyNCI+CiAgICAgICAgICA8cGF0aCBkPSJtOSAxOCA2LTYtNi02IiAvPgogICAgICAgIDwvc3ZnPg==)
      </h2>
    </a>

    {post.data.description && (
      <p class="my-2 line-clamp-2 text-sm text-gray-600">{post.data.description}</p>
    )}

    <time datetime={post.data.pubDate.toISOString()}
          class="mr-auto flex inline-flex items-center gap-1 rounded-md bg-gray-50 p-2 text-xs font-medium text-gray-400">
      ![svg](data:image/svg+xml;base64,PHN2ZyBjbGFzcz0ibXItMSBoLTMuNSB3LTMuNSBzaHJpbmstMCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIgogICAgICAgICAgIHN0cm9rZT0iY3VycmVudENvbG9yIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIKICAgICAgICAgICBzdHJva2UtbGluZWpvaW49InJvdW5kIiBhcmlhLWhpZGRlbj0idHJ1ZSIgaGVpZ2h0PSIyNCI+CiAgICAgICAgPHJlY3QgeD0iMyIgeT0iNCIgd2lkdGg9IjE4IiBoZWlnaHQ9IjE4IiByeD0iMiIgLz4KICAgICAgICA8cGF0aCBkPSJNMTYgMnY0TTggMnY0TTMgMTBoMTgiIC8+CiAgICAgIDwvc3ZnPg==)
      <span>{post.data.pubDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })}</span>
    </time>

    {post.data.tags.length > 0 && (
      <div class="mt-4">
        <ul class="flex flex-wrap gap-2">
          {post.data.tags.map((tag: string) => (
            <li>
              <a href={`/blog?tag=${encodeURIComponent(tag)}`}
                 class="flex inline-flex min-h-5 items-center gap-1 rounded-md bg-blue-50/70 px-2.5 py-1.5 text-xs font-medium text-blue-600 no-underline transition-colors duration-200 hover:bg-blue-500 hover:text-white">
                ![svg](data:image/svg+xml;base64,PHN2ZyBjbGFzcz0ibXItMSBoLTMgdy0zIHNocmluay0wIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiCiAgICAgICAgICAgICAgICAgICAgIHN0cm9rZT0iY3VycmVudENvbG9yIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIKICAgICAgICAgICAgICAgICAgICAgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgYXJpYS1oaWRkZW49InRydWUiIGhlaWdodD0iMjQiPgogICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTIuNTg2IDIuNTg2QTIgMiAwIDAgMCAxMS4xNzIgMkg0YTIgMiAwIDAgMC0yIDJ2Ny4xNzJhMiAyIDAgMCAwIC41ODYgMS40MTRsOC43MDQgOC43MDRhMi40MjYgMi40MjYgMCAwIDAgMy40MiAwbDYuNTgtNi41OGEyLjQyNiAyLjQyNiAwIDAgMCAwLTMuNDJ6IiAvPgogICAgICAgICAgICAgICAgICA8Y2lyY2xlIGN4PSI3LjUiIGN5PSI3LjUiIHI9Ii41IiBmaWxsPSJjdXJyZW50Q29sb3IiIC8+CiAgICAgICAgICAgICAgICA8L3N2Zz4=)
                {tag}
              </a>
            </li>
          ))}
        </ul>
      </div>
    )}
  </div>

  <a href={`/blog/${post.id}`} aria-label={`阅读:${post.data.title}`}
     class="inline-flex shrink-0 items-center justify-center rounded-md border border-gray-200 px-1.5 py-5 text-gray-600 transition hover:bg-gray-100">
    ![svg](data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iaC02IHctNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIKICAgICAgICAgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGhlaWdodD0iMjQiPgogICAgICA8cGF0aCBkPSJtOSAxOCA2LTYtNi02IiAvPgogICAgPC9zdmc+)
  </a>
</article>

卡片背景说明:上面用了 bg-white。如果你的 global.css 里 <body> 背景是浅灰(主题变量 --gray-light 很浅),白卡片会有轻微对比,正常;若想跟主题更融,可改成 bg-[rgb(var(--gray-light))] 或直接使用透明背景(去掉 bg-white)。


4.2 方案 A(推荐,简单):新建 src/pages/blog/index.astro(单页不分页)

适合文章数量不多(你目前 3 篇)的情况,全部列出,不用分页条。

---
import BaseHead from '../../components/BaseHead.astro';
import Footer from '../../components/Footer.astro';
import Header from '../../components/Header.astro';
import PostCard from '../../components/PostCard.astro';
import { getCollection } from 'astro:content';
import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts';

const posts = (await getCollection('blog'))
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<!doctype html>
<html lang="zh-CN">
  <head>
    <BaseHead title={`${SITE_TITLE} · 文章`} description={SITE_DESCRIPTION} />
  </head>
  <body>
    <Header />
    <main class="mx-auto w-full max-w-3xl px-4 py-10">
      <h1 class="mb-6 text-3xl font-bold">全部文章</h1>
      <div class="flex flex-col gap-6">
        {posts.map((post) => <PostCard post={post} />)}
      </div>
    </main>
    <Footer />
  </body>
</html>

容器用 max-w-3xl(约 768px)居中,和文章页 .prose 宽度接近,视觉统一。flex flex-col gap-6 就是“竖向堆叠卡片”的关键。


4.3 方案 B(还原 mare,带分页条):src/pages/blog/[...page].astro

若你想和 mare 一样有“页码导航”,用 paginate():

---
import BaseHead from '../../components/BaseHead.astro';
import Footer from '../../components/Footer.astro';
import Header from '../../components/Header.astro';
import PostCard from '../../components/PostCard.astro';
import { getCollection, type CollectionEntry } from 'astro:content';
import type { GetStaticPaths, Page } from 'astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts';

export const getStaticPaths = (async ({ paginate }) => {
  const posts = (await getCollection('blog')).sort(
    (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
  );
  return paginate(posts, { pageSize: 6 });   // ← 每页篇数
}) satisfies GetStaticPaths;

type Props = { page: Page<CollectionEntry<'blog'>> };
const { page } = Astro.props;
const pageTitle = page.currentPage === 1 ? '全部文章' : `全部文章 - 第 ${page.currentPage} 页`;
const pageLinks = Array.from({ length: page.lastPage }, (_, i) => i + 1);
---
<!doctype html>
<html lang="zh-CN">
  <head>
    <BaseHead title={`${SITE_TITLE} · ${pageTitle}`} description={SITE_DESCRIPTION} />
  </head>
  <body>
    <Header />
    <main class="mx-auto w-full max-w-3xl px-4 py-10">
      <h1 class="mb-6 text-3xl font-bold">{pageTitle}</h1>
      <div class="flex flex-col gap-6">
        {page.data.map((post) => <PostCard post={post} />)}
      </div>

      {page.lastPage > 1 && (
        <nav aria-label="分页" class="mt-8 flex flex-wrap items-center justify-center gap-2 text-sm">
          {page.url.prev ? (
            <a href={page.url.prev} aria-label="上一页"
               class="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-700 transition hover:border-blue-600 hover:bg-blue-50 hover:text-blue-600">‹</a>
          ) : (
            <span class="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 bg-gray-100 text-gray-400">‹</span>
          )}
          {pageLinks.map((n) => (
            <a href={n === 1 ? '/blog' : `/blog/${n}`}
               aria-current={page.currentPage === n ? 'page' : undefined}
               class={`flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 text-sm ${page.currentPage === n ? 'bg-blue-600 font-medium text-white' : 'bg-white text-gray-700 hover:bg-blue-50 hover:text-blue-600'}`}>
              {n}
            </a>
          ))}
          {page.url.next ? (
            <a href={page.url.next} aria-label="下一页"
               class="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-700 transition hover:border-blue-600 hover:bg-blue-50 hover:text-blue-600">›</a>
          ) : (
            <span class="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 bg-gray-100 text-gray-400">›</span>
          )}
        </nav>
      )}
    </main>
    <Footer />
  </body>
</html>

pageSize: 6 可抽到 consts.ts 做 POSTS_PER_PAGE(可选)。文章少于 6 篇时 page.lastPage === 1,分页条不渲染,第一页就全显示——和单页效果一致,但保留了扩展能力。


4.4 (可选)在首页加入口链接

保留首页原布局,只是在合适位置加一句:

<p class="mt-6">
  <a href="/blog" class="text-blue-600 underline">查看全部文章 →</a>
</p>

五、移植到 my-blog 的关键适配点

要点 说明
图标 mare 用 astro-icon(Icon name="lucide:...")。my-blog 没装,所以上面全部改用内联 SVG,零依赖。若你愿意装 @astrojs/icon,也可照搬 mare 写法。

| 暗色模式 | mare 卡片有大量 dark: 类。my-blog 无暗色模式,这些类不会生效(无害),上面已删除以保持干净。 |
| 标签链接 | mare 指向 /blog/tags/<tag>(需建标签页)。my-blog 暂无,先写成 /blog?tag=<tag>(占位),以后做标签页再改。 |
| 日期格式 | mare 是英文 toLocaleString('en-US', ...)。上面改 toLocaleDateString('zh-CN', {month:'long'}) 显示“2026年10月10日”。想保留英文就改回去。 |
| Tailwind 类 | line-clamp-2、bg-blue-500、shadow-sm、rounded-lg 等都是 Tailwind v4 内置,my-blog 的 @tailwindcss/vite 开箱即用,不用额外配置。 |
| 文章 URL | blog/[...slug].astro 用 post.id 作 slug,所以卡片 href="/blog/${post.id}" 正确。若你的 id 含目录层级(如 2026/post),URL 会自动带上去,无需改。 |
| 卡片背景 | 见 4.1 末尾说明,按主题背景微调 bg-white。 |


六、验证

cd D:\blog\my-blog
npm run dev

打开 http://localhost:4321/blog,应看到:

  • 竖向排列的横向卡片,每张有左蓝竖条 + 标题(hover 变蓝 + 箭头出现)+ 描述 + 日期 + 标签 + 右侧箭头按钮
  • 点卡片或右侧箭头跳到对应文章
  • 文章少时无需翻页;用方案 B 且文章 > 6 篇时出现分页条

npm run build 后 /blog/ 会作为静态页进 dist,照常部署到 VPS。


七、路线二:纯竖向列表(无卡片,如 pudding)

如果你不想要卡片方块,只想像 my-blog-pudding 那样按顺序竖向排成一条时间线,用下面这套。核心是一个可复用的 PostList.astro 组件,提供两种视觉:

  • 7.1 极简列表:一行一个,日期 ↔ 标题,靠 border-b 分隔,最干净。
  • 7.2 时间线(pudding 风):左侧一条虚线 + 圆点,每个条目上方显示日期、下方是标题(hover 变主题色)。

7.0 新建 src/components/PostList.astro(两种变体合一)

用 variant 切换:'simple'(7.1)或 'timeline'(7.2,默认)。颜色全部走主题变量:var(--accent)(hex)用于主题色,rgb(var(--gray-*))(RGB 分量)用于灰阶;无 dark: 类。

---
import type { CollectionEntry } from 'astro:content';

interface Props {
  posts: CollectionEntry<'blog'>[];
  variant?: 'simple' | 'timeline';
}
const { posts, variant = 'timeline' } = Astro.props;
const fmt = (d: Date) =>
  d.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
---
{
  variant === 'simple' ? (
    <ul class="divide-y divide-[rgb(var(--gray-light))]">
      {posts.map((post) => (
        <li>
          <a href={`/blog/${post.id}`}
             class="group flex items-baseline justify-between gap-4 py-3 no-underline">
            <span class="text-lg text-[rgb(var(--gray-dark))] transition group-hover:text-[var(--accent)]">
              {post.data.title}
            </span>
            <time datetime={post.data.pubDate.toISOString()}
                  class="shrink-0 text-sm text-[rgb(var(--gray))]">{fmt(post.data.pubDate)}</time>
          </a>
        </li>
      ))}
    </ul>
  ) : (
    <ul class="relative ml-2 border-l border-dashed border-[rgb(var(--gray-light))]">
      {posts.map((post) => (
        <li class="relative pl-6 py-3">
          <span class="absolute -left-[5px] top-6 h-2 w-2 rounded-full bg-[var(--accent)]"></span>
          <a href={`/blog/${post.id}`} class="group no-underline">
            <time datetime={post.data.pubDate.toISOString()}
                  class="block text-xs text-[rgb(var(--gray))] mb-1">{fmt(post.data.pubDate)}</time>
            <span class="text-lg text-[rgb(var(--gray-dark))] transition group-hover:text-[var(--accent)]">
              {post.data.title}
            </span>
          </a>
        </li>
      ))}
    </ul>
  )
}

7.1 极简列表用法(落地文件:src/pages/blog/index.astro)

“列表页”就是 src/pages/blog/index.astro——和路线一方案 A(4.2)是同一个文件,走路线二就在这里用 PostList 取代 PostCard 即可。新建 / 修改这个文件:

---
import BaseHead from '../../components/BaseHead.astro';
import Footer from '../../components/Footer.astro';
import Header from '../../components/Header.astro';
import PostList from '../../components/PostList.astro';
import { getCollection } from 'astro:content';
import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts';

const posts = (await getCollection('blog'))
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<!doctype html>
<html lang="zh-CN">
  <head>
    <BaseHead title={`${SITE_TITLE} · 文章`} description={SITE_DESCRIPTION} />
  </head>
  <body>
    <Header />
    <main class="mx-auto w-full max-w-3xl px-4 py-10">
      <h1 class="mb-6 text-3xl font-bold">全部文章</h1>
      <PostList posts={posts} variant="simple" />
    </main>
    <Footer />
  </body>
</html>

关键就一行 <PostList posts={posts} variant="simple" /> 取代了路线一里的 <PostCard post={post} />。文章少(你目前 3 篇)用这个单页最省事,无需分页条。

二选一提醒:blog/index.astro(本文件,单页)与第八章 blog/[...page].astro(分页)都匹配 /blog/,不要同时存在,否则 Astro 路由冲突。要分页就删掉本文件、用第八章;要单页就别建 [...page].astro。

7.2 pudding 时间线用法

落地文件与 7.1 完全相同(src/pages/blog/index.astro),只是把那一行换成:

<PostList posts={posts} variant="timeline" />

(或分页版第八章里把 <PostList posts={page.data} variant="timeline" /> 保持即可,想要极简就改 variant="simple")。效果即左侧虚线时间线:日期在上、标题在下、hover 变主题色。


八、路线二 + 分页(src/pages/blog/[...page].astro)

把第七章的纯列表套进 paginate(),每页 8 篇,分页条直接复用 4.3 的结构(只是配色改成主题变量)。

---
import BaseHead from '../../components/BaseHead.astro';
import Footer from '../../components/Footer.astro';
import Header from '../../components/Header.astro';
import PostList from '../../components/PostList.astro';
import { getCollection, type CollectionEntry } from 'astro:content';
import type { GetStaticPaths, Page } from 'astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts';

export const getStaticPaths = (async ({ paginate }) => {
  const posts = (await getCollection('blog')).sort(
    (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
  );
  return paginate(posts, { pageSize: 8 });   // ← 路线二每页 8 篇
}) satisfies GetStaticPaths;

type Props = { page: Page<CollectionEntry<'blog'>> };
const { page } = Astro.props;
const pageTitle = page.currentPage === 1 ? '全部文章' : `全部文章 - 第 ${page.currentPage} 页`;
const pageLinks = Array.from({ length: page.lastPage }, (_, i) => i + 1);
---
<!doctype html>
<html lang="zh-CN">
  <head>
    <BaseHead title={`${SITE_TITLE} · ${pageTitle}`} description={SITE_DESCRIPTION} />
  </head>
  <body>
    <Header />
    <main class="mx-auto w-full max-w-3xl px-4 py-10">
      <h1 class="mb-6 text-3xl font-bold">全部文章</h1>
      <PostList posts={page.data} variant="timeline" />

      {page.lastPage > 1 && (
        <nav aria-label="分页" class="mt-8 flex flex-wrap items-center justify-center gap-2 text-sm">
          {page.url.prev ? (
            <a href={page.url.prev} aria-label="上一页"
               class="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-[rgb(var(--gray-light))] bg-white text-[rgb(var(--gray-dark))] transition hover:border-[var(--accent)] hover:bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] hover:text-[var(--accent)]">‹</a>
          ) : (
            <span class="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-[rgb(var(--gray-light))] bg-[rgb(var(--gray-light))] text-[rgb(var(--gray))]">‹</span>
          )}
          {pageLinks.map((n) => (
            <a href={n === 1 ? '/blog' : `/blog/${n}`}
               aria-current={page.currentPage === n ? 'page' : undefined}
               class={`flex h-10 w-10 items-center justify-center rounded-lg border border-[rgb(var(--gray-light))] text-sm ${page.currentPage === n ? 'bg-[var(--accent)] font-medium text-white' : 'bg-white text-[rgb(var(--gray-dark))] hover:bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] hover:text-[var(--accent)]'}`}>
              {n}
            </a>
          ))}
          {page.url.next ? (
            <a href={page.url.next} aria-label="下一页"
               class="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-[rgb(var(--gray-light))] bg-white text-[rgb(var(--gray-dark))] transition hover:border-[var(--accent)] hover:bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] hover:text-[var(--accent)]">›</a>
          ) : (
            <span class="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-[rgb(var(--gray-light))] bg-[rgb(var(--gray-light))] text-[rgb(var(--gray))]">›</span>
          )}
        </nav>
      )}
    </main>
    <Footer />
  </body>
</html>

配色要点:高亮态用 bg-[var(--accent)](hex 直接当背景色没问题),hover 浅底用 bg-[color-mix(in_srgb,var(--accent)_10%,transparent)](Tailwind 任意值里空格写成 _);灰阶一律 rgb(var(--gray-*))。文章 ≤ 8 篇时 page.lastPage === 1,分页条不渲染,和单页一致。


九、标签页 /blog/tags/

9.1 路由优先级提醒

src/pages/blog/ 下现在同时有:

  • index.astro(路线一单页)或 [...page].astro(路线二分页)
  • tags/index.astro(标签首页)
  • tags/[tag].astro(单标签)

Astro 静态路由优先级:具体路径 > 动态参数 > rest([...page])。所以 /blog/tags 和 /blog/tags/foo 会优先匹配 tags/*,不会被 [...page] 吃掉,无需额外处理。

9.2 新建 src/pages/blog/tags/index.astro(标签云 + 计数)

---
import BaseHead from '../../../components/BaseHead.astro';
import Footer from '../../../components/Footer.astro';
import Header from '../../../components/Header.astro';
import { getCollection } from 'astro:content';
import { SITE_TITLE, SITE_DESCRIPTION } from '../../../consts';

const posts = await getCollection('blog');
const counts = new Map<string, number>();
for (const p of posts) for (const t of p.data.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
const tags = [...counts.entries()].sort((a, b) => b[1] - a[1]);
---
<!doctype html>
<html lang="zh-CN">
  <head>
    <BaseHead title={`${SITE_TITLE} · 标签`} description={SITE_DESCRIPTION} />
  </head>
  <body>
    <Header />
    <main class="mx-auto w-full max-w-3xl px-4 py-10">
      <h1 class="mb-6 text-3xl font-bold">全部标签</h1>
      <div class="flex flex-wrap gap-3">
        {tags.map(([tag, count]) => (
          <a href={`/blog/tags/${encodeURIComponent(tag)}`}
             class="inline-flex items-center gap-1.5 rounded-full border border-[rgb(var(--gray-light))] px-4 py-2 text-sm text-[rgb(var(--gray-dark))] no-underline transition hover:border-[var(--accent)] hover:text-[var(--accent)]">
            {tag}<span class="text-xs text-[rgb(var(--gray))]">({count})</span>
          </a>
        ))}
      </div>
    </main>
    <Footer />
  </body>
</html>

9.3 新建 src/pages/blog/tags/[tag].astro(单标签筛选页)

---
import BaseHead from '../../../components/BaseHead.astro';
import Footer from '../../../components/Footer.astro';
import Header from '../../../components/Header.astro';
import PostList from '../../../components/PostList.astro';
import { getCollection } from 'astro:content';
import type { GetStaticPaths } from 'astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '../../../consts';

export const getStaticPaths = (async () => {
  const posts = await getCollection('blog');
  const tags = new Set<string>();
  posts.forEach((p) => p.data.tags.forEach((t) => tags.add(t)));
  return [...tags].map((tag) => ({
    params: { tag: encodeURIComponent(tag) },
    props: {
      tag,
      posts: posts
        .filter((p) => p.data.tags.includes(tag))
        .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()),
    },
  }));
}) satisfies GetStaticPaths;

const { tag, posts } = Astro.props;
const decoded = decodeURIComponent(tag);
---
<!doctype html>
<html lang="zh-CN">
  <head>
    <BaseHead title={`${SITE_TITLE} · 标签:${decoded}`} description={SITE_DESCRIPTION} />
  </head>
  <body>
    <Header />
    <main class="mx-auto w-full max-w-3xl px-4 py-10">
      <p class="mb-2 text-sm text-[rgb(var(--gray))]">
        <a href="/blog/tags" class="no-underline text-[var(--accent)]">标签</a> /
      </p>
      <h1 class="mb-6 text-3xl font-bold">#{decoded}</h1>
      <PostList posts={posts} variant="timeline" />
    </main>
    <Footer />
  </body>
</html>

params.tag 用 encodeURIComponent(tag),这样中文/带空格的标签也能生成合法 URL;页面内 decodeURIComponent 还原显示。

9.4 把标签链接统一指向 /blog/tags/...

之前的占位链接(PostCard 里的 /blog?tag=...、路线二里的 #)全部改为真实标签页:

  • PostCard.astro(4.1)第 98–99 行:
    <a href={`/blog/tags/${encodeURIComponent(tag)}`}
       class="...">
  • 若 PostList 也想显示标签(可选):在条目里加一组同样的 <a href={/blog/tags/${encodeURIComponent(tag)}}>,样式沿用 9.2 的胶囊。

十、验证(路线二 + 分页 + 标签页)

cd D:\blog\my-blog
npm run dev

打开以下地址逐一确认:

地址 应看到
/blog 路线二时间线列表(第一页,8 篇以内不分页)
/blog/2、/blog/3 第 2、3 页;底部出现分页条,当前页高亮为主题色
/blog/tags 全部标签胶囊,每个带文章数计数
/blog/tags/<某标签> 该标签下的文章,用路线二列表呈现;面包屑 标签 / 可跳回标签首页

npm run build 后,/blog/、/blog/2...、/blog/tags/、/blog/tags/<tag> 全部作为静态页进 dist,照常部署到 VPS(标签页是 getStaticPaths 预渲染的,无需服务端逻辑)。