This commit is contained in:
z.ai committed 2026-10-08 11:09:37 +08:00
1 parent c1541b80df
commit b4c7908029
97 files changed
+12403 -154

No files matched your search

+234
View File
@@ -0,0 +1,234 @@
import type { Metadata } from "next";
import { getTranslations, setRequestLocale } from "next-intl/server";
import {
Briefcase,
MapPin,
Mail,
Code2,
Gauge,
Palette,
Wrench,
} from "lucide-react";
import { Container, Section } from "@/components/ui/layout";
import { Card } from "@/components/ui/card";
import { buttonClassName } from "@/components/ui/button-variants";
import { Reveal } from "@/components/motion/reveal";
import { siteConfig } from "@/lib/site-config";
/**
* 关于页渲染策略:ISR 缓存 60 秒。
* 路由段配置必须是字面量(Next.js 会静态分析)。
*/
export const revalidate = 60;
type AboutPageProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata({
params,
}: AboutPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "About" });
return {
title: t("title"),
description: t("description"),
alternates: { canonical: `/${locale}/about` },
openGraph: {
type: "profile",
title: t("title"),
description: t("description"),
url: `${siteConfig.url}/${locale}/about`,
images: [{ url: siteConfig.ogImage }],
},
};
}
/** 关注方向 —— 图标仅为装饰,语义由文本承载 */
const FOCUS_AREAS = [
{
icon: Gauge,
title: "Web 性能",
description:
"关注核心网页指标、渲染路径与打包体积,把「快」当作可度量的工程目标而不是感觉。",
},
{
icon: Code2,
title: "开发者体验",
description:
"好的工具应该让正确的事情变简单。投入最多的地方是构建流程、类型系统与文档。",
},
{
icon: Palette,
title: "设计系统",
description:
"把设计规范翻译成可执行的代码约束,让一致性由类型系统保证,而不是靠代码评审。",
},
{
icon: Wrench,
title: "工程化实践",
description:
"测试策略、CI/CD、可观测性——那些看不见但决定了项目能走多远的基建。",
},
] as const;
export default async function AboutPage({ params }: AboutPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("About");
const { author } = siteConfig;
const facts = [
{ icon: Briefcase, label: t("role"), value: author.jobTitle },
{ icon: MapPin, label: t("location"), value: author.location },
{ icon: Mail, label: t("email"), value: author.email },
];
return (
<>
{/* 页头 */}
<Section
spacing="sm"
className="border-b border-hairline-soft bg-surface-soft"
>
<Container size="narrow">
<p className="text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel">
{t("eyebrow")}
</p>
<h1 className="mt-sm text-heading-1 font-semibold text-ink">
{t("title")}
</h1>
<p className="mt-md max-w-[640px] text-subtitle text-steel text-balance-pretty">
{t("description")}
</p>
</Container>
</Section>
<Section spacing="default">
<Container size="narrow">
<div className="grid grid-cols-1 gap-xxl lg:grid-cols-[minmax(0,1fr)_280px]">
{/* 主栏:简介 + 关注方向 */}
<div className="flex flex-col gap-section">
<section aria-labelledby="intro-heading">
<h2
id="intro-heading"
className="text-heading-3 font-semibold text-ink"
>
{t("introTitle")}
</h2>
<div className="mt-md flex flex-col gap-md text-body-md leading-[1.75] text-slate">
<p>{author.bio}</p>
<p>
这个博客用来记录我在日常工作中遇到的问题和解决思路。
我倾向于写下那些「当时找不到答案、后来自己想明白了」的内容,
因为这类经验对别人往往也最有价值。
</p>
<p>
写作风格上,我更在意准确而不是完整:与其罗列所有可能性,
不如把一条路径讲透,并说清楚它为什么成立、在什么情况下会失效。
</p>
</div>
</section>
<section aria-labelledby="focus-heading">
<h2
id="focus-heading"
className="text-heading-3 font-semibold text-ink"
>
{t("techTitle")}
</h2>
<p className="mt-xs text-body-sm text-steel">
{t("techDescription")}
</p>
<div className="mt-lg grid grid-cols-1 gap-md sm:grid-cols-2">
{FOCUS_AREAS.map((area, index) => (
<Reveal key={area.title} delay={index * 0.05}>
<Card className="flex h-full flex-col gap-xs" padding="md">
<area.icon
className="size-5 text-brand-green-deep"
aria-hidden="true"
/>
<h3 className="text-heading-5 font-semibold text-ink">
{area.title}
</h3>
<p className="text-body-sm text-steel">
{area.description}
</p>
</Card>
</Reveal>
))}
</div>
</section>
</div>
{/* 侧栏:基本信息 + 联系方式 */}
<aside className="flex flex-col gap-xl">
<Card
padding="md"
variant="feature"
className="flex flex-col gap-md"
>
<h2 className="text-heading-5 font-semibold text-ink">
基本信息
</h2>
<dl className="flex flex-col gap-sm">
{facts.map((fact) => (
<div key={fact.label} className="flex items-start gap-sm">
<fact.icon
className="mt-[2px] size-4 shrink-0 text-steel"
aria-hidden="true"
/>
<div className="min-w-0">
<dt className="text-caption text-steel">
{fact.label}
</dt>
<dd className="break-words text-body-sm text-ink">
{fact.value}
</dd>
</div>
</div>
))}
</dl>
</Card>
<Card padding="md" className="flex flex-col gap-sm">
<h2 className="text-heading-5 font-semibold text-ink">
{t("contactTitle")}
</h2>
<p className="text-body-sm text-steel">
{t("contactDescription")}
</p>
<ul className="mt-xs flex flex-col gap-xs">
{siteConfig.social.map((item) => (
<li key={item.label}>
<a
href={item.href}
target={
item.href.startsWith("mailto:") ? undefined : "_blank"
}
rel="noopener noreferrer"
className={buttonClassName({
variant: "secondary",
size: "sm",
className: "w-full justify-start",
})}
>
{item.label}
</a>
</li>
))}
</ul>
</Card>
</aside>
</div>
</Container>
</Section>
</>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useEffect } from "react";
import { RotateCcw, TriangleAlert } from "lucide-react";
/**
* 错误边界(语言段内)。
*
* 必须是客户端组件。捕获渲染期间的运行时错误,
* 提供重试入口,并把错误上报到控制台便于排查。
*
* 注意:`error.message` 在生产环境会被脱敏,因此面向用户只展示通用文案,
* 详细信息(digest)留给开发者排查。
*/
export default function LocaleError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[LocaleError]", error);
}, [error]);
return (
<section className="mx-auto flex min-h-[60vh] w-full max-w-[640px] flex-col items-center justify-center px-lg text-center">
<span
aria-hidden="true"
className="grid size-14 place-items-center rounded-full bg-brand-error/10"
>
<TriangleAlert className="size-6 text-brand-error" />
</span>
<h1 className="mt-lg text-heading-3 font-semibold text-ink">
出了点问题
</h1>
<p className="mt-sm max-w-[420px] text-body-md text-steel">
页面加载时发生了意外错误,请稍后重试。如果问题持续出现,请通过「关于」页的联系方式告诉我。
</p>
{error.digest ? (
<p className="mt-sm font-mono text-caption text-muted">
错误编号:{error.digest}
</p>
) : null}
<button
type="button"
onClick={reset}
className="mt-xxl inline-flex h-10 items-center gap-xs rounded-full bg-primary px-lg text-button-md font-medium text-on-primary transition-colors duration-150 active:bg-charcoal"
>
<RotateCcw className="size-4" aria-hidden="true" />
重试
</button>
</section>
);
}
+117
View File
@@ -0,0 +1,117 @@
import type { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import {
getMessages,
getTranslations,
setRequestLocale,
} from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
import { SiteHeader } from "@/components/layout/site-header";
import { SiteFooter } from "@/components/layout/site-footer";
import { siteConfig } from "@/lib/site-config";
/**
* 语言段布局。
*
* 文档骨架(<html> / <body>)由根布局 app/layout.tsx 提供,
* 这里只负责:校验 locale、注入 i18n Provider、渲染站点壳(页头 / 页脚)。
*
* 注意:因为根布局固定了 <html lang="zh-CN">,实际请求语言通过
* `app/[locale]/layout.tsx` 无法直接改写 <html>;如需精确的 lang 属性,
* 参见 README 的「已知限制」一节。
*/
/** 预生成所有语言的路由 */
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
type Locale = (typeof routing.locales)[number];
type LocaleLayoutProps = {
children: React.ReactNode;
params: Promise<{ locale: string }>;
};
/** 校验 locale 合法性,非法则交给 404 */
function assertLocale(locale: string): asserts locale is Locale {
if (!routing.locales.includes(locale as Locale)) {
notFound();
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
assertLocale(locale);
const t = await getTranslations({ locale, namespace: "Site" });
const title = `${siteConfig.name} · ${t("tagline")}`;
return {
title: {
default: title,
template: `%s · ${siteConfig.name}`,
},
description: siteConfig.description,
alternates: {
canonical: `/${locale}`,
languages: Object.fromEntries(
routing.locales.map((item) => [item, `/${item}`]),
),
},
openGraph: {
type: "website",
siteName: siteConfig.name,
locale,
url: `${siteConfig.url}/${locale}`,
title,
description: siteConfig.description,
images: [{ url: siteConfig.ogImage }],
},
twitter: {
card: "summary_large_image",
title,
description: siteConfig.description,
images: [siteConfig.ogImage],
},
};
}
export default async function LocaleLayout({
children,
params,
}: LocaleLayoutProps) {
const { locale } = await params;
assertLocale(locale);
// 启用静态渲染(next-intl 要求显式声明当前请求的 locale)
setRequestLocale(locale);
const messages = await getMessages();
const t = await getTranslations({ locale, namespace: "Nav" });
return (
<NextIntlClientProvider messages={messages}>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-md focus:top-md focus:z-[60] focus:rounded-full focus:bg-primary focus:px-lg focus:py-xs focus:text-body-sm focus:text-on-primary"
>
{t("skipToContent")}
</a>
<div className="flex min-h-dvh flex-col">
<SiteHeader />
<main id="main-content" className="flex-1">
{children}
</main>
<SiteFooter />
</div>
</NextIntlClientProvider>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { Link } from "@/i18n/navigation";
import { getTranslations } from "next-intl/server";
import { ArrowLeft, Compass } from "lucide-react";
import { Container, Section } from "@/components/ui/layout";
import { buttonClassName } from "@/components/ui/button-variants";
import { getAllPosts } from "@/lib/posts";
/**
* 404 页面(语言段内)。
*
* 位于 `app/[locale]/not-found.tsx`,因此能访问 next-intl 上下文并使用
* 站点的页头 / 页脚布局。同时列出最新文章作为"继续浏览"的出口,
* 避免用户走进死胡同。
*/
export default async function NotFound() {
const t = await getTranslations("NotFound");
const posts = (await getAllPosts()).slice(0, 3);
return (
<Section spacing="lg">
<Container size="narrow" className="flex flex-col items-center text-center">
<span
aria-hidden="true"
className="grid size-14 place-items-center rounded-full bg-surface"
>
<Compass className="size-6 text-steel" />
</span>
<p className="mt-lg font-mono text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-brand-error">
{t("eyebrow")}
</p>
<h1 className="mt-sm text-heading-1 font-semibold text-ink text-balance-pretty">
{t("title")}
</h1>
<p className="mt-md max-w-[480px] text-subtitle text-steel text-balance-pretty">
{t("description")}
</p>
<div className="mt-xxl flex flex-col gap-sm sm:flex-row">
<Link
href="/"
className={buttonClassName({ variant: "primary", size: "md" })}
>
<ArrowLeft className="size-4" aria-hidden="true" />
{t("backHome")}
</Link>
<Link
href="/#posts"
className={buttonClassName({ variant: "secondary", size: "md" })}
>
{t("browsePosts")}
</Link>
</div>
{posts.length > 0 ? (
<div className="mt-section w-full border-t border-hairline-soft pt-xl text-left">
<h2 className="text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel">
也许你想看看这些
</h2>
<ul className="mt-md flex flex-col divide-y divide-hairline-soft">
{posts.map((post) => (
<li key={post.slug}>
<Link
href={`/posts/${post.slug}`}
className="group flex flex-col gap-xxs py-md transition-colors duration-150"
>
<span className="text-body-md font-medium text-ink group-hover:text-brand-green-deep">
{post.title}
</span>
<span className="text-body-sm text-steel">
{post.summary}
</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
</Container>
</Section>
);
}
+135
View File
@@ -0,0 +1,135 @@
import type { Metadata } from "next";
import { Link } from "@/i18n/navigation";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { ArrowRight } from "lucide-react";
import { Container, Section } from "@/components/ui/layout";
import { SectionHeading } from "@/components/ui/section-heading";
import { buttonClassName } from "@/components/ui/button-variants";
import { PostCard } from "@/components/post/post-card";
import { MotionItem } from "@/components/motion/motion-item";
import { HomeHero } from "@/components/home/hero";
import { getAllPosts, getAllTags } from "@/lib/posts";
import { siteConfig } from "@/lib/site-config";
/**
* 首页渲染策略:ISR 缓存 60 秒。
*
* 内容来自数据库,因此不在构建期固化;访问时生成并缓存,
* 更新文章后最多 60 秒自动生效。
*
* 注意:Next.js 会静态分析路由段配置,这里必须写成**字面量**,
* 不能引用从其他模块导入的常量,否则会报
* "Invalid segment configuration export detected"。
*/
export const revalidate = 60;
type HomePageProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata({
params,
}: HomePageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "Site" });
return {
title: `${siteConfig.name} · ${t("tagline")}`,
description: siteConfig.description,
alternates: { canonical: `/${locale}` },
};
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("Home");
// Hero 需要的统计数据量很小,直接在此等待;
// 文章列表区块用 Suspense 包裹,先出骨架再填充内容。
const [posts, tags] = await Promise.all([getAllPosts(), getAllTags()]);
const earliestPost = posts.at(-1);
return (
<>
<HomeHero
eyebrow={t("eyebrow")}
title={t("heroTitle")}
subtitle={t("heroSubtitle")}
primaryLabel={t("ctaPrimary")}
secondaryLabel={t("ctaSecondary")}
stats={{
posts: posts.length,
tags: tags.length,
since: earliestPost ? earliestPost.date.slice(0, 4) : "—",
postsLabel: t("statsPosts"),
tagsLabel: t("statsTags"),
sinceLabel: t("statsSince"),
}}
/>
<Section id="posts" spacing="lg" aria-labelledby="latest-posts-heading">
<Container>
<SectionHeading
eyebrow={t("latestEyebrow")}
title={<span id="latest-posts-heading">{t("latestTitle")}</span>}
description={t("latestDescription")}
className="mb-xxl"
/>
{posts.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post, index) => (
<MotionItem
key={post.slug}
delay={Math.min(index, 6) * 0.05}
className="h-full"
>
<PostCard post={post} className="h-full" />
</MotionItem>
))}
</div>
)}
<div className="mt-xxl flex justify-center">
<Link
href="/about"
className={buttonClassName({ variant: "secondary" })}
>
{t("ctaSecondary")}
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
</div>
</Container>
</Section>
</>
);
}
/** 无文章时的空状态(skill 要求前端具备加载态 / 空态 / 错误态) */
function EmptyState() {
return (
<div className="flex flex-col items-center gap-sm rounded-lg border border-dashed border-hairline bg-surface-soft px-xxl py-section text-center">
<p className="text-heading-5 font-semibold text-ink">还没有已发布的文章</p>
<p className="max-w-[520px] text-body-sm text-steel">
调用{" "}
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
POST /api/posts
</code>{" "}
创建文章(或运行{" "}
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
pnpm seed
</code>{" "}
导入示例内容),状态为{" "}
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
published
</code>{" "}
的文章就会出现在这里。
</p>
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Link } from "@/i18n/navigation";
import { getLocale, getTranslations, setRequestLocale } from "next-intl/server";
import { ArrowLeft, ArrowRight, CalendarDays, Clock } from "lucide-react";
import { routing } from "@/i18n/routing";
import { Container, Section } from "@/components/ui/layout";
import { Badge } from "@/components/ui/badge";
import { ReadingProgress } from "@/components/motion/reading-progress";
import { TableOfContents } from "@/components/post/table-of-contents";
import { getAdjacentPosts, getPostBySlug } from "@/lib/posts";
import { compilePostMdx } from "@/lib/mdx";
import { extractToc } from "@/lib/toc";
import { formatDate, toDateTimeAttribute } from "@/lib/utils";
import { siteConfig } from "@/lib/site-config";
/**
* 文章详情页。
*
* 数据流:从数据库读取正文 → 编译 MDX(Shiki 语法高亮)
* → 提取 TOC → 渲染。
*
* 整页为 Server Component:正文与高亮结果都是服务端渲染的 HTML,
* 只有阅读进度条与目录高亮这两个交互点进入客户端 bundle。
*/
type PostPageProps = {
params: Promise<{ locale: string; slug: string }>;
};
/**
* 渲染策略:按需动态渲染,不做 ISR 缓存。
*
* ## 为什么这里不能用 `revalidate`
*
* 详情页需要根据文章是否存在返回**真正的 HTTP 404**。但一旦启用 ISR,
* Next.js 会把该路由视为可静态生成:首次请求时先以 200 开始渲染,
* 之后 `notFound()` 只替换页面内容,**状态码已经发出去了**,
* 结果就是「不存在/草稿的文章 → 内容显示 404、状态码却是 200」。
*
* 这会让搜索引擎把不存在的 URL 当成有效页面收录,属于 SEO 缺陷。
* 因此详情页保持动态渲染(内容本身仍很快,SQL 走 slug 唯一索引)。
*
* 首页、关于页、sitemap 不存在这个问题,仍使用 ISR 缓存。
*/
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
}: PostPageProps): Promise<Metadata> {
const { locale, slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return { title: "文章不存在" };
}
const description = post.description ?? post.summary;
const url = `${siteConfig.url}/${locale}/posts/${post.slug}`;
return {
title: post.title,
description,
keywords: post.tags,
authors: [{ name: siteConfig.author.name }],
alternates: {
canonical: `/${locale}/posts/${post.slug}`,
languages: Object.fromEntries(
routing.locales.map((item) => [item, `/${item}/posts/${post.slug}`]),
),
},
openGraph: {
type: "article",
url,
title: post.title,
description,
publishedTime: new Date(`${post.date}T00:00:00Z`).toISOString(),
authors: [siteConfig.author.name],
tags: post.tags,
images: [{ url: post.cover ?? siteConfig.ogImage }],
},
twitter: {
card: "summary_large_image",
title: post.title,
description,
images: [post.cover ?? siteConfig.ogImage],
},
};
}
export default async function PostPage({ params }: PostPageProps) {
const { locale, slug } = await params;
setRequestLocale(locale);
const post = await getPostBySlug(slug);
// 草稿与不存在的 slug 一律 404
if (!post) {
notFound();
}
const t = await getTranslations("Post");
const currentLocale = await getLocale();
const { content } = await compilePostMdx(post.content);
const toc = extractToc(post.content);
const { previous, next } = await getAdjacentPosts(post.slug);
// 结构化数据,帮助搜索引擎理解文章内容
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.description ?? post.summary,
datePublished: new Date(`${post.date}T00:00:00Z`).toISOString(),
author: {
"@type": "Person",
name: siteConfig.author.name,
},
keywords: post.tags?.join(", "),
inLanguage: locale,
mainEntityOfPage: {
"@type": "WebPage",
"@id": `${siteConfig.url}/${locale}/posts/${post.slug}`,
},
};
return (
<>
<ReadingProgress />
<script
type="application/ld+json"
// JSON.stringify 的输出已转义 < > &,可安全内联
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<Section spacing="default">
<Container>
{/* 返回首页入口 */}
<Link
href="/"
className="inline-flex items-center gap-xs text-body-sm text-steel transition-colors duration-150 hover:text-ink"
>
<ArrowLeft className="size-4" aria-hidden="true" />
{t("backHome")}
</Link>
<div className="mt-xl grid grid-cols-1 gap-xxl lg:grid-cols-[minmax(0,1fr)_200px]">
<article className="min-w-0">
{/* 文章头部 */}
<header className="flex flex-col gap-sm border-b border-hairline-soft pb-xl">
<div className="flex flex-wrap items-center gap-sm text-caption text-steel">
<span className="inline-flex items-center gap-[6px]">
<CalendarDays className="size-3.5" aria-hidden="true" />
<time dateTime={toDateTimeAttribute(post.date)}>
{formatDate(post.date, currentLocale)}
</time>
</span>
<span aria-hidden="true" className="text-muted">
·
</span>
<span className="inline-flex items-center gap-[6px]">
<Clock className="size-3.5" aria-hidden="true" />
{t("readingTime", { minutes: post.readingMinutes })}
</span>
</div>
<h1 className="text-heading-1 font-semibold text-ink text-balance-pretty">
{post.title}
</h1>
<p className="text-subtitle text-steel text-balance-pretty">
{post.summary}
</p>
{post.tags && post.tags.length > 0 ? (
<ul
aria-label={t("tags")}
className="mt-xs flex flex-wrap gap-xs"
>
{post.tags.map((tag) => (
<li key={tag}>
<Badge variant="tag">{tag}</Badge>
</li>
))}
</ul>
) : null}
</header>
{/* MDX 正文 */}
<div className="prose-blog mt-xl">{content}</div>
{/* 上一篇 / 下一篇 */}
{previous || next ? (
<nav
aria-label="文章导航"
className="mt-section grid grid-cols-1 gap-md border-t border-hairline-soft pt-xl sm:grid-cols-2"
>
{previous ? (
<Link
href={`/posts/${previous.slug}`}
className="group flex flex-col gap-xxs rounded-lg border border-hairline p-md transition-colors duration-150 hover:bg-surface-soft"
>
<span className="inline-flex items-center gap-xs text-caption text-steel">
<ArrowLeft className="size-3.5" aria-hidden="true" />
{t("previous")}
</span>
<span className="text-body-sm font-medium text-ink group-hover:text-brand-green-deep">
{previous.title}
</span>
</Link>
) : (
<span />
)}
{next ? (
<Link
href={`/posts/${next.slug}`}
className="group flex flex-col items-end gap-xxs rounded-lg border border-hairline p-md text-right transition-colors duration-150 hover:bg-surface-soft sm:col-start-2"
>
<span className="inline-flex items-center gap-xs text-caption text-steel">
{t("next")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</span>
<span className="text-body-sm font-medium text-ink group-hover:text-brand-green-deep">
{next.title}
</span>
</Link>
) : null}
</nav>
) : null}
</article>
{/* 右侧目录:移动端隐藏,lg 起显示为粘性侧栏 */}
{toc.length > 0 ? (
<aside className="hidden lg:block">
<div className="sticky top-[88px]">
<TableOfContents items={toc} />
</div>
</aside>
) : null}
</div>
</Container>
</Section>
</>
);
}