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