feat(polaris): 实现静态分类页

新增七个预生成分类路由、工具卡片网格与空状态。启用侧栏分类导航和当前分类选中态,修复 Windows 静态导出 RSC 路径并补充产物验证,保持静态导出与安全外链约束。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-15 19:24:57 +08:00
co-authored by Claude Opus 5
parent e9f3bf6934
commit 4a032ea742
24 changed files with 2332 additions and 46 deletions
+1
View File
@@ -7,6 +7,7 @@
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"verify:export": "node scripts/verify-static-export.mjs",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
+29
View File
@@ -0,0 +1,29 @@
# Next.js Windows 静态导出补丁
## 原因与范围
`next@16.3.3` 的静态导出器将 `path.relative()` 产生的文件系统路径直接用于 RSC segment 文件名编码。Windows 的反斜杠不会被编码器转换为点号,导致浏览器请求扁平文件名、实际文件却落在嵌套目录。
- 上游问题:<https://github.com/vercel/next.js/issues/85374>
- Windows 根因复现:<https://github.com/vercel/next.js/issues/92339>
- 2026-09-13 核对:`16.3.5` 的相应导出逻辑仍未包含路径标准化。
补丁只修改导出器的 CommonJS / ESM 两份入口,在文件名编码前用系统路径分隔符拆分并以 `/` 连接。读取源文件仍使用原始文件系统路径。Linux/macOS 的 `/` 路径保持不变,浏览器运行时代码不变。
## 安装与验证
补丁通过 `pnpm-workspace.yaml` 的精确版本 `patchedDependencies` 和锁文件登记,正常安装会自动应用,无需手动编辑 `node_modules`。
```bash
corepack pnpm install --frozen-lockfile
corepack pnpm build
corepack pnpm verify:export
```
`verify:export` 只读取产物:检查首页、全部分类页和 404,并将每个源 RSC segment 与浏览器所需扁平路径下的导出文件逐字节比较。它不会复制、移动或重命名 `out/` 文件。部署前还需在真实静态服务器上验证预取和客户端导航。
## 后续升级
补丁仅绑定 `next@16.3.3`,不会自动迁移到其他版本。升级 Next.js 时必须重新检查上游导出实现、运行构建与 `verify:export`,再进行静态浏览器验收;不能仅以 `next build` 成功判断问题已修复。
上游修复并验收通过后,使用 pnpm 的补丁移除命令清理对应登记和补丁文件,更新锁文件,保留产物回归检查。
@@ -0,0 +1,28 @@
diff --git a/dist/esm/export/index.js b/dist/esm/export/index.js
index 6c35f4f47f704caea869a4f89721e0fc71f7f458..4c4e25fec3d1aecb439cea4994f8747ea68df0fe 100644
--- a/dist/esm/export/index.js
+++ b/dist/esm/export/index.js
@@ -717,7 +717,8 @@ async function exportAppImpl(dir, options, span, staticWorker) {
const segmentsDirDest = join(outDir, unnormalizedRoute);
const segmentPaths = await collectSegmentPaths(segmentsDir);
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
- const segmentPath = '/' + segmentFileSrc.slice(0, -RSC_SEGMENT_SUFFIX.length);
+ // Windows 文件系统路径先转为 URL 路径,避免 RSC 文件被导出为嵌套目录。
+ const segmentPath = '/' + segmentFileSrc.split(sep).join('/').slice(0, -RSC_SEGMENT_SUFFIX.length);
const segmentFilename = convertSegmentPathToStaticExportFilename(segmentPath);
const segmentFileDest = join(segmentsDirDest, segmentFilename);
await fs.mkdir(dirname(segmentFileDest), {
diff --git a/dist/export/index.js b/dist/export/index.js
index a19941bec99b5c5673834872eaf8a95deb584a34..b7492b7ba76823380b0013bd1c08527388c82ed7 100644
--- a/dist/export/index.js
+++ b/dist/export/index.js
@@ -785,7 +785,8 @@ async function exportAppImpl(dir, options, span, staticWorker) {
const segmentsDirDest = (0, _path.join)(outDir, unnormalizedRoute);
const segmentPaths = await collectSegmentPaths(segmentsDir);
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
- const segmentPath = '/' + segmentFileSrc.slice(0, -_constants.RSC_SEGMENT_SUFFIX.length);
+ // Windows 文件系统路径先转为 URL 路径,避免 RSC 文件被导出为嵌套目录。
+ const segmentPath = '/' + segmentFileSrc.split(_path.sep).join('/').slice(0, -_constants.RSC_SEGMENT_SUFFIX.length);
const segmentFilename = (0, _segmentvalueencoding.convertSegmentPathToStaticExportFilename)(segmentPath);
const segmentFileDest = (0, _path.join)(segmentsDirDest, segmentFilename);
await _fs.promises.mkdir((0, _path.dirname)(segmentFileDest), {
+5 -2
View File
@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
patchedDependencies:
next@16.3.3: f08d1d901d70f79530e06048a3099b814c365af9161c02a6ac2e1c36195a5d57
importers: importers:
.: .:
dependencies: dependencies:
@@ -27,7 +30,7 @@ importers:
version: 1.34.0(react@19.2.8) version: 1.34.0(react@19.2.8)
next: next:
specifier: ^16.3.3 specifier: ^16.3.3
version: 16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 16.3.3(patch_hash=f08d1d901d70f79530e06048a3099b814c365af9161c02a6ac2e1c36195a5d57)(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: react:
specifier: ^19.2.8 specifier: ^19.2.8
version: 19.2.8 version: 19.2.8
@@ -7176,7 +7179,7 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): next@16.3.3(patch_hash=f08d1d901d70f79530e06048a3099b814c365af9161c02a6ac2e1c36195a5d57)(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies: dependencies:
"@next/env": 16.3.3 "@next/env": 16.3.3
"@swc/helpers": 0.5.23 "@swc/helpers": 0.5.23
@@ -3,3 +3,6 @@ packages:
allowBuilds: allowBuilds:
unrs-resolver: true unrs-resolver: true
patchedDependencies:
next@16.3.3: patches/next@16.3.3.patch
@@ -0,0 +1,46 @@
import assert from "node:assert/strict"
import { readFile, readdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const projectDir = fileURLToPath(new URL("../", import.meta.url))
const appDir = path.join(projectDir, ".next/server/app")
const outDir = path.join(projectDir, "out")
const categories = JSON.parse(
await readFile(path.join(projectDir, "src/data/categories.json"), "utf8"),
)
const routes = ["/", ...categories.map(({ id }) => `/categories/${id}`)]
let checkedSegments = 0
// 只读构建产物:验证浏览器需要的扁平 URL,绝不修补或复制 out/ 文件。
for (const route of routes) {
const routeDir = path.join(outDir, route)
assert((await readFile(path.join(routeDir, "index.html"))).length > 0)
assert((await readFile(path.join(routeDir, "index.txt"))).length > 0)
const segmentsDir = path.join(
appDir,
`${route === "/" ? "index" : route}.segments`,
)
const files = (await readdir(segmentsDir, { recursive: true })).filter(
(file) => file.endsWith(".segment.rsc"),
)
assert(files.length > 0, `${route} 缺少 RSC segment 构建产物`)
for (const file of files) {
const segment = file
.replaceAll("\\", "/")
.slice(0, -".segment.rsc".length)
.replaceAll("/", ".")
const filename = `__next.${segment}.txt`
const source = await readFile(path.join(segmentsDir, file))
const exported = await readFile(path.join(routeDir, filename))
assert.deepEqual(exported, source, `${route}/${filename} 内容不一致`)
checkedSegments += 1
}
}
assert((await readFile(path.join(outDir, "404.html"))).length > 0)
console.log(
`静态导出检查通过:${routes.length} 个页面、${checkedSegments} 个 RSC 文件及 404.html。`,
)
@@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import CategoryPage, { dynamicParams, generateStaticParams } from "./page"
describe("CategoryPage", () => {
it("为全部 7 个分类生成静态参数并关闭运行时动态参数", () => {
expect(generateStaticParams()).toEqual([
{ categoryId: "ai" },
{ categoryId: "dev" },
{ categoryId: "design" },
{ categoryId: "learning" },
{ categoryId: "efficiency" },
{ categoryId: "media" },
{ categoryId: "life" },
])
expect(dynamicParams).toBe(false)
})
it("通过 service 组合开发工具分类页", async () => {
const page = await CategoryPage({
params: Promise.resolve({ categoryId: "dev" }),
})
const { container } = render(page)
expect(
screen.getByRole("heading", { level: 1, name: "开发工具" }),
).toBeInTheDocument()
expect(screen.getByText("共 15 个")).toBeInTheDocument()
expect(container.querySelectorAll(".tool-card")).toHaveLength(15)
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
"aria-current",
"page",
)
expect(screen.queryByRole("searchbox")).not.toBeInTheDocument()
})
it("通过同一路由组合空分类页", async () => {
const page = await CategoryPage({
params: Promise.resolve({ categoryId: "life" }),
})
render(page)
expect(
screen.getByRole("heading", { level: 1, name: "生活服务" }),
).toBeInTheDocument()
expect(screen.getByText("共 0 个")).toBeInTheDocument()
expect(screen.getByText("暂无工具")).toBeInTheDocument()
})
})
@@ -0,0 +1,42 @@
import { notFound } from "next/navigation"
import { CategoryContent } from "@/features/category/components/category-content"
import { AppShell } from "@/features/navigation/components/app-shell"
import { getCategories, getCategoryById } from "@/services/categories"
import { getLinksByCategory } from "@/services/links"
import { getSettings } from "@/services/settings"
type CategoryPageProps = {
params: Promise<{ categoryId: string }>
}
// 静态导出不支持运行时补生成动态路径,未知分类统一返回 404。
export const dynamicParams = false
export function generateStaticParams() {
return getCategories().map((category) => ({
categoryId: category.id,
}))
}
export default async function CategoryPage({ params }: CategoryPageProps) {
const { categoryId } = await params
const category = getCategoryById(categoryId)
if (!category) notFound()
const categories = getCategories()
const links = getLinksByCategory(category.id)
const settings = getSettings()
return (
<AppShell
categories={categories}
siteName={settings.siteName}
defaultCollapsed={settings.sidebar.defaultCollapsed}
activeCategoryId={category.id}
>
<CategoryContent category={category} links={links} />
</AppShell>
)
}
+186 -8
View File
@@ -255,14 +255,9 @@ a {
font-weight: 650; font-weight: 650;
} }
.sidebar__nav-item[aria-disabled="true"] { .sidebar__nav-item:hover {
cursor: not-allowed; color: var(--primary-hover);
opacity: 0.82; background: var(--primary-soft);
}
.sidebar__nav-item[aria-disabled="true"]:hover {
color: #334155;
background: rgba(255, 255, 255, 0.28);
} }
.sidebar__label { .sidebar__label {
@@ -575,6 +570,183 @@ a {
white-space: nowrap; white-space: nowrap;
} }
.category-page {
min-height: 100svh;
padding: 20px 20px 20px 0;
}
.category-page__panel {
min-height: calc(100svh - 40px);
padding: 48px clamp(32px, 4vw, 64px) 56px;
background: var(--surface-sidebar);
box-shadow: var(--shadow-panel);
-webkit-backdrop-filter: blur(18px) saturate(132%);
backdrop-filter: blur(18px) saturate(132%);
}
.category-header {
max-width: 720px;
}
.category-header__title {
margin: 0;
color: var(--text-primary);
font-size: clamp(36px, 3.4vw, 44px);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.03em;
}
.category-header__description {
margin: 12px 0 0;
color: var(--text-secondary);
font-size: 16px;
line-height: 1.7;
}
.category-tools {
margin-top: 36px;
}
.category-section-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.category-section-header__title {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.category-section-header__count {
padding: 4px 10px;
color: var(--text-muted);
font-size: 14px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 999px;
background: rgba(255, 255, 255, 0.3);
}
.tool-card {
position: relative;
display: flex;
min-width: 0;
height: 96px;
align-items: center;
gap: 16px;
padding: 20px 22px;
color: var(--text-primary);
text-decoration: none;
border: 1px solid var(--border-glass);
border-radius: 18px;
background: var(--surface-card);
box-shadow: var(--shadow-soft);
-webkit-backdrop-filter: blur(16px) saturate(134%);
backdrop-filter: blur(16px) saturate(134%);
transition:
transform 180ms ease,
background 180ms ease,
border-color 180ms ease;
}
.tool-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.54);
background: var(--surface-card-hover);
}
.tool-card__icon {
flex: none;
}
.tool-card__copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 4px;
}
.tool-card__title,
.tool-card__description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-card__title {
font-size: 17px;
font-weight: 700;
}
.tool-card__description {
color: var(--text-secondary);
font-size: 14px;
line-height: 1.5;
}
.tool-card__external {
width: 16px;
height: 16px;
flex: none;
align-self: flex-start;
color: var(--text-secondary);
opacity: 0.72;
transition:
color 180ms ease,
opacity 180ms ease;
}
.tool-card:hover .tool-card__external {
color: var(--primary-hover);
opacity: 1;
}
.tool-grid {
display: grid;
margin: 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px 24px;
}
.tool-grid__item {
min-width: 0;
}
.category-empty-state {
display: flex;
min-height: 280px;
align-items: center;
justify-content: center;
flex-direction: column;
color: var(--text-secondary);
text-align: center;
}
.category-empty-state > svg {
width: 36px;
height: 36px;
color: var(--text-muted);
}
.category-empty-state__title {
margin: 16px 0 0;
color: var(--text-primary);
font-size: 17px;
font-weight: 700;
}
.category-empty-state__description {
margin: 6px 0 0;
font-size: 14px;
line-height: 1.6;
}
.site-icon { .site-icon {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@@ -626,6 +798,12 @@ a {
scrollbar-width: thin; scrollbar-width: thin;
} }
@media (min-width: 1280px) {
.tool-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1199px) { @media (max-width: 1199px) {
:root { :root {
--sidebar-expanded: 220px; --sidebar-expanded: 220px;
@@ -0,0 +1,80 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { getCategoryById } from "@/services/categories"
import { getLinksByCategory } from "@/services/links"
import { CategoryContent } from "./category-content"
describe("CategoryContent", () => {
it("展示开发工具标题、描述、数量和 15 张卡片", () => {
const category = getCategoryById("dev")
if (!category) throw new Error("测试数据缺少 dev 分类")
const { container } = render(
<CategoryContent
category={category}
links={getLinksByCategory(category.id)}
/>,
)
expect(
screen.getByRole("heading", { level: 1, name: "开发工具" }),
).toBeInTheDocument()
expect(screen.getByText(category.description)).toBeInTheDocument()
expect(screen.getByText("共 15 个")).toBeInTheDocument()
expect(container.querySelectorAll(".tool-card")).toHaveLength(15)
expect(screen.queryByText("暂无工具")).not.toBeInTheDocument()
const toolLinks = screen
.getByRole("list", { name: "工具列表" })
.querySelectorAll("a")
expect(toolLinks).toHaveLength(15)
for (const link of toolLinks) {
expect(link).toHaveAttribute("target", "_blank")
expect(link).toHaveAttribute("rel", "noopener noreferrer")
}
})
it("为空分类展示计数和纯浏览空状态", () => {
const category = getCategoryById("design")
if (!category) throw new Error("测试数据缺少 design 分类")
render(
<CategoryContent
category={category}
links={getLinksByCategory(category.id)}
/>,
)
expect(screen.getByText("共 0 个")).toBeInTheDocument()
expect(screen.getByText("暂无工具")).toBeInTheDocument()
expect(screen.getByText("这个分类下还没有添加链接。")).toBeInTheDocument()
expect(screen.queryByRole("button")).not.toBeInTheDocument()
expect(
screen.queryByRole("list", { name: "工具列表" }),
).not.toBeInTheDocument()
})
it.each([
["ai", 1],
["dev", 15],
["design", 0],
["learning", 0],
["efficiency", 2],
["media", 2],
["life", 0],
] as const)("%s 分类展示正确数量", (categoryId, count) => {
const category = getCategoryById(categoryId)
if (!category) throw new Error(`测试数据缺少 ${categoryId} 分类`)
render(
<CategoryContent
category={category}
links={getLinksByCategory(category.id)}
/>,
)
expect(screen.getByText(`共 ${count} 个`)).toBeInTheDocument()
})
})
@@ -0,0 +1,32 @@
import { GlassPanel } from "@/components/shared/glass-panel"
import { CategoryEmptyState } from "@/features/category/components/category-empty-state"
import { CategoryHeader } from "@/features/category/components/category-header"
import { CategorySectionHeader } from "@/features/category/components/category-section-header"
import { ToolGrid } from "@/features/category/components/tool-grid"
import type { Category, NavLink } from "@/types/navigation"
type CategoryContentProps = {
category: Category
links: NavLink[]
}
export function CategoryContent({ category, links }: CategoryContentProps) {
return (
<main className="category-page">
<GlassPanel className="category-page__panel">
<CategoryHeader category={category} />
<section
className="category-tools"
aria-labelledby="category-tools-title"
>
<CategorySectionHeader count={links.length} />
{links.length > 0 ? (
<ToolGrid links={links} />
) : (
<CategoryEmptyState />
)}
</section>
</GlassPanel>
</main>
)
}
@@ -0,0 +1,13 @@
import { PackageOpen } from "lucide-react"
export function CategoryEmptyState() {
return (
<div className="category-empty-state">
<PackageOpen aria-hidden="true" />
<p className="category-empty-state__title">暂无工具</p>
<p className="category-empty-state__description">
这个分类下还没有添加链接。
</p>
</div>
)
}
@@ -0,0 +1,14 @@
import type { Category } from "@/types/navigation"
type CategoryHeaderProps = {
category: Category
}
export function CategoryHeader({ category }: CategoryHeaderProps) {
return (
<header className="category-header">
<h1 className="category-header__title">{category.title}</h1>
<p className="category-header__description">{category.description}</p>
</header>
)
}
@@ -0,0 +1,14 @@
type CategorySectionHeaderProps = {
count: number
}
export function CategorySectionHeader({ count }: CategorySectionHeaderProps) {
return (
<div className="category-section-header">
<h2 id="category-tools-title" className="category-section-header__title">
全部工具
</h2>
<span className="category-section-header__count">共 {count} 个</span>
</div>
)
}
@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import type { NavLink } from "@/types/navigation"
import { ToolCard } from "./tool-card"
const link: NavLink = {
id: "github",
title: "GitHub",
description: "全球最大的代码托管平台",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: ["代码", "git"],
sort: 10,
}
describe("ToolCard", () => {
it("展示名称与描述并使用安全的新标签页链接", () => {
render(<ToolCard link={link} />)
const card = screen.getByRole("link", { name: /GitHub/ })
expect(card).toHaveAttribute("href", "https://github.com")
expect(card).toHaveAttribute("target", "_blank")
expect(card).toHaveAttribute("rel", "noopener noreferrer")
expect(screen.getByText("全球最大的代码托管平台")).toBeInTheDocument()
expect(card.querySelector(".tool-card__external")).toHaveAttribute(
"aria-hidden",
"true",
)
})
})
@@ -0,0 +1,26 @@
import { ExternalLink } from "lucide-react"
import { SiteIcon } from "@/components/shared/site-icon"
import type { NavLink } from "@/types/navigation"
type ToolCardProps = {
link: NavLink
}
export function ToolCard({ link }: ToolCardProps) {
return (
<a
className="tool-card"
href={link.url}
target="_blank"
rel="noopener noreferrer"
>
<SiteIcon className="tool-card__icon" link={link} size={44} />
<span className="tool-card__copy">
<span className="tool-card__title">{link.title}</span>
<span className="tool-card__description">{link.description}</span>
</span>
<ExternalLink className="tool-card__external" aria-hidden="true" />
</a>
)
}
@@ -0,0 +1,18 @@
import { ToolCard } from "@/features/category/components/tool-card"
import type { NavLink } from "@/types/navigation"
type ToolGridProps = {
links: NavLink[]
}
export function ToolGrid({ links }: ToolGridProps) {
return (
<ul className="tool-grid" aria-label="工具列表">
{links.map((link) => (
<li key={link.id} className="tool-grid__item">
<ToolCard link={link} />
</li>
))}
</ul>
)
}
@@ -139,7 +139,7 @@ describe("AppShell", () => {
) )
}) })
it("分类项可聚焦但保持禁用", () => { it("首页默认选中并启用分类链接", () => {
render( render(
<AppShell <AppShell
categories={categories} categories={categories}
@@ -150,13 +150,52 @@ describe("AppShell", () => {
</AppShell>, </AppShell>,
) )
expect( expect(screen.getByRole("link", { name: "首页" })).toHaveAttribute(
screen.getByRole("button", { "aria-current",
name: "开发工具,分类页将在下一阶段提供", "page",
}), )
).toHaveAttribute("aria-disabled", "true") expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
expect( "href",
screen.queryByRole("link", { name: /开发工具/ }), "/categories/dev",
).not.toBeInTheDocument() )
})
it("分类页只选中当前分类", () => {
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
activeCategoryId="dev"
>
<main>分类内容</main>
</AppShell>,
)
expect(screen.getByRole("link", { name: "首页" })).not.toHaveAttribute(
"aria-current",
)
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
"aria-current",
"page",
)
})
it("折叠状态仍保留分类链接的可访问名称", () => {
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={true}
activeCategoryId="dev"
>
<main>分类内容</main>
</AppShell>,
)
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
"aria-current",
"page",
)
}) })
}) })
@@ -13,6 +13,7 @@ type AppShellProps = {
categories: Category[] categories: Category[]
siteName: string siteName: string
defaultCollapsed: boolean defaultCollapsed: boolean
activeCategoryId?: string
children: ReactNode children: ReactNode
} }
@@ -85,6 +86,7 @@ export function AppShell({
categories, categories,
siteName, siteName,
defaultCollapsed, defaultCollapsed,
activeCategoryId,
children, children,
}: AppShellProps) { }: AppShellProps) {
const getSnapshot = useCallback( const getSnapshot = useCallback(
@@ -119,6 +121,7 @@ export function AppShell({
categories={categories} categories={categories}
siteName={siteName} siteName={siteName}
collapsed={collapsed} collapsed={collapsed}
activeCategoryId={activeCategoryId}
onToggle={toggleSidebar} onToggle={toggleSidebar}
/> />
<div className="app-shell__content">{children}</div> <div className="app-shell__content">{children}</div>
@@ -17,6 +17,7 @@ type SidebarProps = {
categories: Category[] categories: Category[]
siteName: string siteName: string
collapsed: boolean collapsed: boolean
activeCategoryId?: string
onToggle: () => void onToggle: () => void
} }
@@ -24,10 +25,12 @@ export function Sidebar({
categories, categories,
siteName, siteName,
collapsed, collapsed,
activeCategoryId,
onToggle, onToggle,
}: SidebarProps) { }: SidebarProps) {
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose
const toggleLabel = collapsed ? "展开侧边栏" : "折叠侧边栏" const toggleLabel = collapsed ? "展开侧边栏" : "折叠侧边栏"
const homeIsActive = activeCategoryId === undefined
return ( return (
<TooltipProvider> <TooltipProvider>
@@ -62,9 +65,12 @@ export function Sidebar({
<nav className="sidebar__nav" aria-label="站点导航"> <nav className="sidebar__nav" aria-label="站点导航">
<Link <Link
className="sidebar__nav-item sidebar__nav-item--active" className={cn(
"sidebar__nav-item",
homeIsActive && "sidebar__nav-item--active",
)}
href="/" href="/"
aria-current="page" aria-current={homeIsActive ? "page" : undefined}
> >
<Home aria-hidden="true" /> <Home aria-hidden="true" />
<span className={cn("sidebar__label", collapsed && "sr-only")}> <span className={cn("sidebar__label", collapsed && "sr-only")}>
@@ -73,30 +79,32 @@ export function Sidebar({
</Link> </Link>
<div className="sidebar__categories"> <div className="sidebar__categories">
{/* 本次只验收首页;分类页完成前保持可见但禁用,避免用户进入 404。 */} {categories.map((category) => {
{categories.map((category) => ( const isActive = category.id === activeCategoryId
<Tooltip key={category.id}>
<TooltipTrigger asChild> return (
<Button <Tooltip key={category.id}>
type="button" <TooltipTrigger asChild>
variant="ghost" <Link
className="sidebar__nav-item" className={cn(
aria-disabled="true" "sidebar__nav-item",
aria-label={`${category.title},分类页将在下一阶段提供`} isActive && "sidebar__nav-item--active",
> )}
<CategoryIcon name={category.icon} /> href={`/categories/${category.id}`}
<span aria-current={isActive ? "page" : undefined}
className={cn("sidebar__label", collapsed && "sr-only")}
> >
{category.title} <CategoryIcon name={category.icon} />
</span> <span
</Button> className={cn("sidebar__label", collapsed && "sr-only")}
</TooltipTrigger> >
<TooltipContent side="right"> {category.title}
分类页将在下一阶段提供 </span>
</TooltipContent> </Link>
</Tooltip> </TooltipTrigger>
))} <TooltipContent side="right">{category.title}</TooltipContent>
</Tooltip>
)
})}
</div> </div>
</nav> </nav>
</aside> </aside>
@@ -8,3 +8,8 @@ export function getCategories(): Category[] {
.map((category) => ({ ...category })) .map((category) => ({ ...category }))
.sort((a, b) => a.sort - b.sort) .sort((a, b) => a.sort - b.sort)
} }
export function getCategoryById(id: string): Category | undefined {
const category = categories.find((item) => item.id === id)
return category ? { ...category } : undefined
}
@@ -11,6 +11,13 @@ export function getLinks(): NavLink[] {
return links.map(cloneLink) return links.map(cloneLink)
} }
export function getLinksByCategory(categoryId: string): NavLink[] {
return links
.filter((link) => link.categoryId === categoryId)
.map(cloneLink)
.sort((a, b) => a.sort - b.sort)
}
export function getPinnedLinks(limit = 8): NavLink[] { export function getPinnedLinks(limit = 8): NavLink[] {
// 首页顺序必须独立于分类内 sort,否则 Vercel 与 Figma 无法同时满足两处设计。 // 首页顺序必须独立于分类内 sort,否则 Vercel 与 Figma 无法同时满足两处设计。
return links return links
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { getCategories } from "@/services/categories" import { getCategories, getCategoryById } from "@/services/categories"
import { getLinks, getPinnedLinks } from "@/services/links" import { getLinks, getLinksByCategory, getPinnedLinks } from "@/services/links"
import { getSettings, parseSettings } from "@/services/settings" import { getSettings, parseSettings } from "@/services/settings"
describe("静态数据 service", () => { describe("静态数据 service", () => {
@@ -21,6 +21,42 @@ describe("静态数据 service", () => {
expect(first).not.toBe(second) expect(first).not.toBe(second)
}) })
it("按 ID 返回分类副本并拒绝未知分类", () => {
const category = getCategoryById("dev")
expect(category).toMatchObject({ id: "dev", title: "开发工具" })
expect(category).not.toBe(getCategoryById("dev"))
expect(getCategoryById("unknown")).toBeUndefined()
})
it("按 sort 返回分类链接并提供深层副本", () => {
const first = getLinksByCategory("dev")
const second = getLinksByCategory("dev")
expect(first).toHaveLength(15)
expect(first.map(({ id }) => id)).toEqual([
"github",
"gitlab",
"bitbucket",
"vscode",
"jetbrains",
"goland",
"postman",
"docker",
"kubernetes",
"vercel",
"netlify",
"render",
"npm",
"pnpm",
"figma",
])
expect(first).not.toBe(second)
expect(first[0]).not.toBe(second[0])
expect(first[0].keywords).not.toBe(second[0].keywords)
expect(getLinksByCategory("design")).toEqual([])
})
it("按 pinnedSort 返回首页 8 个常用链接", () => { it("按 pinnedSort 返回首页 8 个常用链接", () => {
expect(getPinnedLinks().map(({ id }) => id)).toEqual([ expect(getPinnedLinks().map(({ id }) => id)).toEqual([
"github", "github",