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
@@ -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;
}
.sidebar__nav-item[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.82;
}
.sidebar__nav-item[aria-disabled="true"]:hover {
color: #334155;
background: rgba(255, 255, 255, 0.28);
.sidebar__nav-item:hover {
color: var(--primary-hover);
background: var(--primary-soft);
}
.sidebar__label {
@@ -575,6 +570,183 @@ a {
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 {
position: relative;
display: inline-flex;
@@ -626,6 +798,12 @@ a {
scrollbar-width: thin;
}
@media (min-width: 1280px) {
.tool-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1199px) {
:root {
--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(
<AppShell
categories={categories}
@@ -150,13 +150,52 @@ describe("AppShell", () => {
</AppShell>,
)
expect(
screen.getByRole("button", {
name: "开发工具,分类页将在下一阶段提供",
}),
).toHaveAttribute("aria-disabled", "true")
expect(
screen.queryByRole("link", { name: /开发工具/ }),
).not.toBeInTheDocument()
expect(screen.getByRole("link", { name: "首页" })).toHaveAttribute(
"aria-current",
"page",
)
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
"href",
"/categories/dev",
)
})
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[]
siteName: string
defaultCollapsed: boolean
activeCategoryId?: string
children: ReactNode
}
@@ -85,6 +86,7 @@ export function AppShell({
categories,
siteName,
defaultCollapsed,
activeCategoryId,
children,
}: AppShellProps) {
const getSnapshot = useCallback(
@@ -119,6 +121,7 @@ export function AppShell({
categories={categories}
siteName={siteName}
collapsed={collapsed}
activeCategoryId={activeCategoryId}
onToggle={toggleSidebar}
/>
<div className="app-shell__content">{children}</div>
@@ -17,6 +17,7 @@ type SidebarProps = {
categories: Category[]
siteName: string
collapsed: boolean
activeCategoryId?: string
onToggle: () => void
}
@@ -24,10 +25,12 @@ export function Sidebar({
categories,
siteName,
collapsed,
activeCategoryId,
onToggle,
}: SidebarProps) {
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose
const toggleLabel = collapsed ? "展开侧边栏" : "折叠侧边栏"
const homeIsActive = activeCategoryId === undefined
return (
<TooltipProvider>
@@ -62,9 +65,12 @@ export function Sidebar({
<nav className="sidebar__nav" aria-label="站点导航">
<Link
className="sidebar__nav-item sidebar__nav-item--active"
className={cn(
"sidebar__nav-item",
homeIsActive && "sidebar__nav-item--active",
)}
href="/"
aria-current="page"
aria-current={homeIsActive ? "page" : undefined}
>
<Home aria-hidden="true" />
<span className={cn("sidebar__label", collapsed && "sr-only")}>
@@ -73,30 +79,32 @@ export function Sidebar({
</Link>
<div className="sidebar__categories">
{/* 本次只验收首页;分类页完成前保持可见但禁用,避免用户进入 404。 */}
{categories.map((category) => (
<Tooltip key={category.id}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
className="sidebar__nav-item"
aria-disabled="true"
aria-label={`${category.title},分类页将在下一阶段提供`}
>
<CategoryIcon name={category.icon} />
<span
className={cn("sidebar__label", collapsed && "sr-only")}
{categories.map((category) => {
const isActive = category.id === activeCategoryId
return (
<Tooltip key={category.id}>
<TooltipTrigger asChild>
<Link
className={cn(
"sidebar__nav-item",
isActive && "sidebar__nav-item--active",
)}
href={`/categories/${category.id}`}
aria-current={isActive ? "page" : undefined}
>
{category.title}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
分类页将在下一阶段提供
</TooltipContent>
</Tooltip>
))}
<CategoryIcon name={category.icon} />
<span
className={cn("sidebar__label", collapsed && "sr-only")}
>
{category.title}
</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">{category.title}</TooltipContent>
</Tooltip>
)
})}
</div>
</nav>
</aside>
@@ -8,3 +8,8 @@ export function getCategories(): Category[] {
.map((category) => ({ ...category }))
.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)
}
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[] {
// 首页顺序必须独立于分类内 sort,否则 Vercel 与 Figma 无法同时满足两处设计。
return links
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"
import { getCategories } from "@/services/categories"
import { getLinks, getPinnedLinks } from "@/services/links"
import { getCategories, getCategoryById } from "@/services/categories"
import { getLinks, getLinksByCategory, getPinnedLinks } from "@/services/links"
import { getSettings, parseSettings } from "@/services/settings"
describe("静态数据 service", () => {
@@ -21,6 +21,42 @@ describe("静态数据 service", () => {
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 个常用链接", () => {
expect(getPinnedLinks().map(({ id }) => id)).toEqual([
"github",