feat(polaris): 实现静态分类页
新增七个预生成分类路由、工具卡片网格与空状态。启用侧栏分类导航和当前分类选中态,修复 Windows 静态导出 RSC 路径并补充产物验证,保持静态导出与安全外链约束。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user