From d015a53a12f4c6e4d066f31de15fce0600c2927c Mon Sep 17 00:00:00 2001 From: Spirale <2635131263@qq.com> Date: Tue, 15 Sep 2026 19:24:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(polaris):=20=E5=AE=9E=E7=8E=B0=E9=9D=99?= =?UTF-8?q?=E6=80=81=E5=88=86=E7=B1=BB=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增七个预生成分类路由、工具卡片网格与空状态。启用侧栏分类导航和当前分类选中态,修复 Windows 静态导出 RSC 路径并补充产物验证,保持静态导出与安全外链约束。 Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-09-07-polaris-category-pages.md | 1576 +++++++++++++++++ ts/web/nextjs/polaris/package.json | 1 + ts/web/nextjs/polaris/patches/README.md | 29 + .../nextjs/polaris/patches/next@16.3.3.patch | 28 + ts/web/nextjs/polaris/pnpm-lock.yaml | 7 +- ts/web/nextjs/polaris/pnpm-workspace.yaml | 3 + .../polaris/scripts/verify-static-export.mjs | 46 + .../app/categories/[categoryId]/page.test.tsx | 50 + .../src/app/categories/[categoryId]/page.tsx | 42 + ts/web/nextjs/polaris/src/app/globals.css | 194 +- .../components/category-content.test.tsx | 80 + .../category/components/category-content.tsx | 32 + .../components/category-empty-state.tsx | 13 + .../category/components/category-header.tsx | 14 + .../components/category-section-header.tsx | 14 + .../category/components/tool-card.test.tsx | 35 + .../category/components/tool-card.tsx | 26 + .../category/components/tool-grid.tsx | 18 + .../navigation/components/app-shell.test.tsx | 57 +- .../navigation/components/app-shell.tsx | 3 + .../navigation/components/sidebar.tsx | 58 +- .../nextjs/polaris/src/services/categories.ts | 5 + ts/web/nextjs/polaris/src/services/links.ts | 7 + .../polaris/src/services/services.test.ts | 40 +- 24 files changed, 2332 insertions(+), 46 deletions(-) create mode 100644 ts/web/nextjs/polaris/docs/superpowers/plans/2026-09-07-polaris-category-pages.md create mode 100644 ts/web/nextjs/polaris/patches/README.md create mode 100644 ts/web/nextjs/polaris/patches/next@16.3.3.patch create mode 100644 ts/web/nextjs/polaris/scripts/verify-static-export.mjs create mode 100644 ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx create mode 100644 ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.tsx create mode 100644 ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx create mode 100644 ts/web/nextjs/polaris/src/features/category/components/category-content.tsx create mode 100644 ts/web/nextjs/polaris/src/features/category/components/category-empty-state.tsx create mode 100644 ts/web/nextjs/polaris/src/features/category/components/category-header.tsx create mode 100644 ts/web/nextjs/polaris/src/features/category/components/category-section-header.tsx create mode 100644 ts/web/nextjs/polaris/src/features/category/components/tool-card.test.tsx create mode 100644 ts/web/nextjs/polaris/src/features/category/components/tool-card.tsx create mode 100644 ts/web/nextjs/polaris/src/features/category/components/tool-grid.tsx diff --git a/ts/web/nextjs/polaris/docs/superpowers/plans/2026-09-07-polaris-category-pages.md b/ts/web/nextjs/polaris/docs/superpowers/plans/2026-09-07-polaris-category-pages.md new file mode 100644 index 0000000..996fbd4 --- /dev/null +++ b/ts/web/nextjs/polaris/docs/superpowers/plans/2026-09-07-polaris-category-pages.md @@ -0,0 +1,1576 @@ +# Polaris 分类页实施计划 + +> **收尾状态(2026-09-15):** 分类页实现已落地;Task 8 的静态导出阻塞已于 9 月 13 日解除。本轮 Task 9 完整门禁和静态产物检查重新通过,提交范围已扩展到配套补丁与验证脚本。下文原始 Task 1–8 勾选框保留为实施步骤,不作为当前进度来源;当前进度以 Task 8、Task 9 的执行记录为准,最终提交结果以 `feat/polaris-category-pages` 的 Git 记录为准。 + +> **For agentic workers:** REQUIRED SUB-SKILL: 使用 `superpowers:executing-plans` 在当前会话内逐 Task 执行。用户已明确要求精简流程:禁止 TDD、禁止 `subagent-driven-development`、禁止 fan-out 审查;每个 Task 仅在末尾做一次自审,整个阶段只在最终门禁通过后创建一次提交。 + +**Goal:** 在现有 Polaris 首页基础上实现 7 个静态分类页,使侧栏分类导航可用,并提供分类标题、链接数量、工具卡片网格和空状态。 + +**Architecture:** 保持 `src/app/` 只负责路由与页面组合,分类页 UI 放入新的 `src/features/category/`;所有分类和链接数据继续只通过 `src/services/` 获取。动态路由使用 Next.js 16 的异步 `params`、`generateStaticParams()` 和 `dynamicParams = false`,以兼容当前 `output: "export"` 静态导出;页面主体保持 Server Component,仅复用现有 AppShell 的最小 Client Component 边界。 + +**Tech Stack:** Next.js 16.3.3 App Router、React 19.2.8、TypeScript 5.9.3 strict、Tailwind CSS 4.3.3 CSS-first、Radix Tooltip、Lucide React、Vitest 4.1.11、Testing Library、Prettier、ESLint。 + +**Spec:** `docs/design/Polaris 分类页设计文档.md` + +## 精简执行规则 + +1. 实施 Task 1 前必须切换到 `feat/polaris-category-pages`,不得在 `main` 上实施;若工作目录正被其他 Claude Code 会话使用,先停止并协调,不得直接切换共享工作目录的分支。 +2. 不使用 TDD:每个 Task 先完成实现,再补充或更新与该 Task 直接相关的测试,最后运行一次定向验证。 +3. 不调用实现代理或审查代理,不进行多代理 fan-out。 +4. 每个 Task 只在末尾进行一次自审;发现明确问题时直接修复并重跑该 Task 的定向验证,不追加第二轮审查。 +5. Task 1–8 不创建 commit;Task 9 在完整门禁、真实浏览器验收和最终自审全部通过后创建本阶段唯一一次 commit。 +6. 不 push,不创建 PR,不修改 Go API、数据库、管理页、深色模式或移动端抽屉。 +7. 保持关键规则、非显然决策、fallback 和边界条件使用简洁中文注释;普通 JSX 和显然代码不堆叠注释。 +8. 所有外链卡片使用真实 `` 元素,并带 `target="_blank"` 与 `rel="noopener noreferrer"`。 +9. 分类页不展示搜索框、不注册 `Ctrl+K` / `⌘K`,不加入筛选、排序切换、编辑、删除、新增或访问统计。 +10. 当前已验收的星空背景、冷蓝磨砂 token 和无卡片外投影方案是视觉基线;设计文档里的旧 rgba、旧大阴影和 CSS 渐变示例不得覆盖当前实现。 +11. 第一版桌面范围为 1024px 及以上;`>= 1280px` 三列,`1024px–1279px` 两列。移动端单列和抽屉留到后续阶段。 +12. 分类内链接按 `sort` 升序;`pinnedSort` 只控制首页顺序,不得用于分类页。 +13. 数据范围保持不变:开发工具 15 条、AI 工具 1 条、效率工具 2 条、娱乐媒体 2 条;设计资源、学习资料、生活服务显示空状态。 +14. `next.config.ts` 当前为 `output: "export"`、`trailingSlash: true`、`images.unoptimized: true`,本阶段不得改变这些约束。 +15. Next.js 16 本地文档规定 `params` 是 Promise,页面必须 `await params`;静态导出的动态路由必须提供完整 `generateStaticParams()`,且不支持 `dynamicParams: true`。 + +--- + +## 文件职责与最终结构 + +### 新建文件 + +```text +src/app/categories/[categoryId]/page.tsx +src/app/categories/[categoryId]/page.test.tsx +src/features/category/components/category-content.tsx +src/features/category/components/category-content.test.tsx +src/features/category/components/category-empty-state.tsx +src/features/category/components/category-header.tsx +src/features/category/components/category-section-header.tsx +src/features/category/components/tool-card.tsx +src/features/category/components/tool-card.test.tsx +src/features/category/components/tool-grid.tsx +``` + +### 修改文件 + +```text +src/services/categories.ts +src/services/links.ts +src/services/services.test.ts +src/features/navigation/components/app-shell.tsx +src/features/navigation/components/sidebar.tsx +src/features/navigation/components/app-shell.test.tsx +src/app/globals.css +docs/superpowers/plans/2026-09-07-polaris-category-pages.md +``` + +### 明确不创建 + +```text +src/app/categories/[categoryId]/loading.tsx +src/app/categories/[categoryId]/error.tsx +src/app/categories/[categoryId]/not-found.tsx +src/features/category/hooks/* +src/features/category/lib/* +``` + +本阶段数据全部来自同步本地 JSON,不需要 loading、请求错误状态、分类页 Client hook 或额外状态管理。未知分类统一交给 Next.js 的 `notFound()` 和静态站点 `404.html`。 + +--- + +### Task 1:补齐分类页数据查询接口 + +**Files:** + +- Modify: `src/services/categories.ts` +- Modify: `src/services/links.ts` +- Modify: `src/services/services.test.ts` + +**Interfaces:** + +- Consumes: `Category`、`NavLink`、现有 `categoriesData` 与 `linksData`。 +- Produces: `getCategoryById(id: string): Category | undefined`。 +- Produces: `getLinksByCategory(categoryId: string): NavLink[]`。 +- Invariant: 所有返回对象和 `keywords` 数组均为副本,调用方修改结果不得污染模块内静态数据。 + +- [ ] **Step 1:在 categories service 中实现按 ID 查询** + +在 `src/services/categories.ts` 保留 `getCategories()`,新增: + +```ts +export function getCategoryById(id: string): Category | undefined { + const category = categories.find((item) => item.id === id) + return category ? { ...category } : undefined +} +``` + +- [ ] **Step 2:在 links service 中实现分类筛选与排序** + +在 `src/services/links.ts` 复用现有 `cloneLink()`,新增: + +```ts +export function getLinksByCategory(categoryId: string): NavLink[] { + return links + .filter((link) => link.categoryId === categoryId) + .map(cloneLink) + .sort((a, b) => a.sort - b.sort) +} +``` + +不要通过 `getLinks()` 二次复制后再筛选,也不要读取 `pinnedSort`。 + +- [ ] **Step 3:实现后补充 service 测试** + +更新 `src/services/services.test.ts` 的 import: + +```ts +import { getCategories, getCategoryById } from "@/services/categories" +import { getLinks, getLinksByCategory, getPinnedLinks } from "@/services/links" +``` + +在现有 describe 中新增以下断言: + +```ts +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([]) +}) +``` + +- [ ] **Step 4:运行一次定向验证** + +Run: + +```bash +corepack pnpm -C "d:/GitRepo/lab/ts/web/nextjs/polaris" exec vitest run src/services/services.test.ts +``` + +Expected: `src/services/services.test.ts` 全部通过。 + +- [ ] **Step 5:Task 1 最终自审一次** + +检查:未知分类返回 `undefined`;开发工具恰好 15 条;结果按 `sort` 升序;没有复用 `pinnedSort`;返回数据不会污染 JSON 模块缓存。修复明确问题后只重跑 Step 4,不创建 commit。 + +--- + +### Task 2:启用侧栏分类导航与选中态 + +**Files:** + +- Modify: `src/features/navigation/components/app-shell.tsx` +- Modify: `src/features/navigation/components/sidebar.tsx` +- Modify: `src/features/navigation/components/app-shell.test.tsx` +- Modify: `src/app/globals.css` + +**Interfaces:** + +- Consumes: `Category[]`、现有折叠状态与 `Sidebar`。 +- Produces: `AppShell` 新增可选属性 `activeCategoryId?: string`。 +- Produces: `Sidebar` 新增可选属性 `activeCategoryId?: string`。 +- Invariant: 未传 `activeCategoryId` 时首页选中;传入有效 ID 时对应分类选中。 + +- [ ] **Step 1:扩展 AppShell 属性并透传当前分类** + +将 `AppShellProps` 改为: + +```ts +type AppShellProps = { + categories: Category[] + siteName: string + defaultCollapsed: boolean + activeCategoryId?: string + children: ReactNode +} +``` + +在参数解构和 `` 调用中加入 `activeCategoryId`: + +```tsx +export function AppShell({ + categories, + siteName, + defaultCollapsed, + activeCategoryId, + children, +}: AppShellProps) { +``` + +```tsx + +``` + +- [ ] **Step 2:将禁用分类按钮替换为真实导航链接** + +在 `SidebarProps` 加入: + +```ts +activeCategoryId?: string +``` + +在 `Sidebar` 内计算: + +```ts +const homeIsActive = activeCategoryId === undefined +``` + +首页链接改为条件选中: + +```tsx + + + + + {link.title} + {link.description} + + + ) +} +``` + +- [ ] **Step 2:添加卡片基础样式** + +在 `src/app/globals.css` 的 quick link 样式之后、site icon 样式之前加入: + +```css +.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-card` 添加外部 drop shadow;现有 `--shadow-soft` 只包含 inset 高光,用于避免首页已修复过的行尾色带问题。 + +- [ ] **Step 3:实现后创建 ToolCard 测试** + +创建 `src/features/category/components/tool-card.test.tsx`: + +```tsx +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() + + 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", + ) + }) +}) +``` + +- [ ] **Step 4:运行一次定向验证** + +Run: + +```bash +corepack pnpm -C "d:/GitRepo/lab/ts/web/nextjs/polaris" exec vitest run src/features/category/components/tool-card.test.tsx +``` + +Expected: ToolCard 测试通过。 + +- [ ] **Step 5:Task 3 最终自审一次** + +检查:组件没有 `"use client"`;链接文字形成可访问名称;外链图标不进入名称;SiteIcon fallback 被复用;长标题和描述不会撑破网格;无外部卡片阴影。修复后只重跑 Step 4,不创建 commit。 + +--- + +### Task 4:实现分类标题、计数、网格和空状态组合 + +**Files:** + +- Create: `src/features/category/components/category-header.tsx` +- Create: `src/features/category/components/category-section-header.tsx` +- Create: `src/features/category/components/category-empty-state.tsx` +- Create: `src/features/category/components/tool-grid.tsx` +- Create: `src/features/category/components/category-content.tsx` +- Create: `src/features/category/components/category-content.test.tsx` + +**Interfaces:** + +- Consumes: `Category`、按 `sort` 排序的 `NavLink[]`、Task 3 的 `ToolCard`。 +- Produces: `CategoryContent({ category, links })`,作为动态路由的完整主内容。 +- Invariant: 无链接时仍显示分类标题、描述、“全部工具”和“共 0 个”,随后显示空状态。 + +- [ ] **Step 1:创建 CategoryHeader** + +创建 `category-header.tsx`: + +```tsx +import type { Category } from "@/types/navigation" + +type CategoryHeaderProps = { + category: Category +} + +export function CategoryHeader({ category }: CategoryHeaderProps) { + return ( +
+

{category.title}

+

{category.description}

+
+ ) +} +``` + +- [ ] **Step 2:创建 CategorySectionHeader** + +创建 `category-section-header.tsx`: + +```tsx +type CategorySectionHeaderProps = { + count: number +} + +export function CategorySectionHeader({ count }: CategorySectionHeaderProps) { + return ( +
+

+ 全部工具 +

+ 共 {count} 个 +
+ ) +} +``` + +- [ ] **Step 3:创建空状态** + +创建 `category-empty-state.tsx`: + +```tsx +import { PackageOpen } from "lucide-react" + +export function CategoryEmptyState() { + return ( +
+
+ ) +} +``` + +不添加按钮、链接或管理入口。 + +- [ ] **Step 4:创建语义化 ToolGrid** + +创建 `tool-grid.tsx`: + +```tsx +import { ToolCard } from "@/features/category/components/tool-card" +import type { NavLink } from "@/types/navigation" + +type ToolGridProps = { + links: NavLink[] +} + +export function ToolGrid({ links }: ToolGridProps) { + return ( + + ) +} +``` + +- [ ] **Step 5:创建 CategoryContent 组合组件** + +创建 `category-content.tsx`: + +```tsx +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 ( +
+ + +
+ + {links.length > 0 ? ( + + ) : ( + + )} +
+
+
+ ) +} +``` + +- [ ] **Step 6:实现后创建组合测试** + +创建 `category-content.test.tsx`,使用 `getCategoryById()` 与 `getLinksByCategory()` 作为真实 fixture: + +```tsx +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( + , + ) + + 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() + }) + + it("为空分类展示计数和纯浏览空状态", () => { + const category = getCategoryById("design") + if (!category) throw new Error("测试数据缺少 design 分类") + + render( + , + ) + + 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() + }) +}) +``` + +- [ ] **Step 7:运行一次定向验证** + +Run: + +```bash +corepack pnpm -C "d:/GitRepo/lab/ts/web/nextjs/polaris" exec vitest run src/features/category/components/category-content.test.tsx +``` + +Expected: 有数据和空状态两条测试均通过。 + +- [ ] **Step 8:Task 4 最终自审一次** + +检查:只有一个 h1;工具区域由 h2 标记;网格使用 ul/li;空状态没有按钮;组件均保持 Server Component;没有复制 SiteIcon 或卡片逻辑。修复后只重跑 Step 7,不创建 commit。 + +--- + +### Task 5:接入 Next.js 16 静态动态路由 + +**Files:** + +- Create: `src/app/categories/[categoryId]/page.tsx` +- Create: `src/app/categories/[categoryId]/page.test.tsx` + +**Interfaces:** + +- Consumes: `getCategories()`、`getCategoryById()`、`getLinksByCategory()`、`getSettings()`、`AppShell`、`CategoryContent`。 +- Produces: 7 个 `/categories/{categoryId}/` 静态页面。 +- Produces: `generateStaticParams(): { categoryId: string }[]`。 +- Invariant: `params` 类型为 Promise 并被 await;未知分类执行 `notFound()`;`dynamicParams = false`。 + +- [ ] **Step 1:创建分类页路由** + +创建 `src/app/categories/[categoryId]/page.tsx`: + +```tsx +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 ( + + + + ) +} +``` + +不要在路由中直接 import JSON,不要添加 `"use client"`,不要同步读取 `params.categoryId`。 + +- [ ] **Step 2:实现后创建路由测试** + +创建 `src/app/categories/[categoryId]/page.test.tsx`: + +```tsx +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() + }) +}) +``` + +- [ ] **Step 3:运行一次定向验证** + +Run: + +```bash +corepack pnpm -C "d:/GitRepo/lab/ts/web/nextjs/polaris" exec vitest run "src/app/categories/[categoryId]/page.test.tsx" +``` + +Expected: 3 条路由测试通过。 + +- [ ] **Step 4:执行一次静态构建验证路由约束** + +Run: + +```bash +corepack pnpm -C "d:/GitRepo/lab/ts/web/nextjs/polaris" build +``` + +Expected: + +```text +构建成功 +out/categories/ai/index.html 存在 +out/categories/dev/index.html 存在 +out/categories/design/index.html 存在 +out/categories/learning/index.html 存在 +out/categories/efficiency/index.html 存在 +out/categories/media/index.html 存在 +out/categories/life/index.html 存在 +out/404.html 存在 +``` + +验证输出文件: + +```bash +for id in ai dev design learning efficiency media life; do + test -f "d:/GitRepo/lab/ts/web/nextjs/polaris/out/categories/$id/index.html" || exit 1 +done +test -f "d:/GitRepo/lab/ts/web/nextjs/polaris/out/404.html" +``` + +- [ ] **Step 5:Task 5 最终自审一次** + +检查:所有数据经 service;页面是 async Server Component;`params` 被 await;静态参数顺序与 categories sort 一致;未知路径不能运行时生成;构建没有静态导出警告。修复后只重跑 Steps 3–4,不创建 commit。 + +--- + +### Task 6:实现分类页桌面视觉与响应式网格 + +**Files:** + +- Modify: `src/app/globals.css` + +**Interfaces:** + +- Consumes: Task 3–4 定义的全部 category class name、当前 glass tokens 和 AppShell 布局。 +- Produces: 1024px–1279px 两列、>=1280px 三列的稳定桌面布局。 +- Invariant: 页面 body 不产生水平滚动;卡片不恢复外部投影;首页样式不回归。 + +- [ ] **Step 1:添加分类页主面板样式** + +在 category component 样式区加入: + +```css +.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); +} +``` + +- [ ] **Step 2:添加网格与空状态样式** + +```css +.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; +} + +@media (min-width: 1280px) { + .tool-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +``` + +如果项目现有 media block 顺序要求统一,则把 `min-width: 1280px` block 放到 `max-width: 1199px` block 之前,保持 mobile/reduced-motion block 最后。 + +- [ ] **Step 3:格式化 CSS 并执行一次静态检查** + +Run: + +```bash +corepack pnpm -C "d:/GitRepo/lab/ts/web/nextjs/polaris" exec prettier --write src/app/globals.css +corepack pnpm -C "d:/GitRepo/lab/ts/web/nextjs/polaris" exec eslint src/app/globals.css src/features/category src/app/categories src/features/navigation +``` + +Expected: Prettier 和 ESLint 均通过。 + +- [ ] **Step 4:Task 6 最终自审一次** + +检查:1024px 可容纳两列;1280px 切换三列;面板与 fixed sidebar 留有 20px 间距;所有 grid child 有 `min-width: 0`;长描述省略;focus-visible 使用全局规则;reduced-motion 自动覆盖 transform transition;卡片没有外部 drop shadow。修复后只重跑 Step 3,不创建 commit。 + +--- + +### Task 7:完成分类页集成与无障碍回归测试 + +**Files:** + +- Modify: `src/features/category/components/tool-card.test.tsx` +- Modify: `src/features/category/components/category-content.test.tsx` +- Modify: `src/features/navigation/components/app-shell.test.tsx` +- Modify: `src/app/categories/[categoryId]/page.test.tsx` + +**Interfaces:** + +- Consumes: Tasks 1–6 的最终接口。 +- Produces: 覆盖数据数量、外链安全属性、空状态、选中态和首页/分类页职责边界的稳定测试集。 + +- [ ] **Step 1:补齐所有分类的数据合同断言** + +在 `category-content.test.tsx` 增加 table-driven 测试: + +```tsx +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( + , + ) + + expect(screen.getByText(`共 ${count} 个`)).toBeInTheDocument() +}) +``` + +- [ ] **Step 2:补齐全部工具外链安全属性检查** + +在开发工具组合测试中读取工具列表内的链接: + +```tsx +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") +} +``` + +- [ ] **Step 3:补齐折叠侧栏分类链接可访问性测试** + +在 `app-shell.test.tsx` 新增: + +```tsx +it("折叠状态仍保留分类链接的可访问名称", () => { + render( + +
分类内容
+
, + ) + + expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute( + "aria-current", + "page", + ) +}) +``` + +- [ ] **Step 4:执行一次分类页相关测试集合** + +Run: + +```bash +corepack pnpm -C "d:/GitRepo/lab/ts/web/nextjs/polaris" exec vitest run src/services/services.test.ts src/features/navigation/components/app-shell.test.tsx src/features/category/components/tool-card.test.tsx src/features/category/components/category-content.test.tsx "src/app/categories/[categoryId]/page.test.tsx" +``` + +Expected: 上述 5 个测试文件全部通过。 + +- [ ] **Step 5:Task 7 最终自审一次** + +检查测试只验证外部行为,不绑定无意义 DOM 层级;没有复制已有 SiteIcon fallback 测试;没有测试 CSS 像素值;测试覆盖全部 7 个分类数量、空状态、导航选中态和安全外链。修复后只重跑 Step 4,不创建 commit。 + +--- + +### Task 8:执行真实浏览器与静态导出验收 + +**补充执行记录(2026-09-13):原静态导出阻塞已解除。** + +- 用户要求先尝试解决,并明确允许执行项目已有构建及配套质量检查;本轮不 commit、不 push。 +- 根因在 Next.js 导出器:Windows 相对路径未经标准化即用于 RSC 文件名编码。已核对 `16.3.5` 仍有相同逻辑,不做无效升级。 +- 新增精确绑定 `next@16.3.3` 的 pnpm 源码补丁,CommonJS / ESM 导出器均在文件名编码前以 `path.sep` 拆分并转为 `/` 路径。不修改浏览器代码,不关闭预取,不复制或重命名 `out/` 文件。 +- 新增 `scripts/verify-static-export.mjs` 和 `pnpm verify:export`,只读验证首页、7 个分类、全部源 RSC segment 对应的扁平导出文件及 404。 +- 本轮 Windows 构建生成 10/10 页面;产物检查通过:8 个页面、24 个 RSC 文件及 `404.html`。 +- 本轮 ESLint、TypeScript 与全量测试通过(16 个文件、88 项测试);离线 `--frozen-lockfile --ignore-scripts` 安装通过。对本轮补丁、检查脚本、配置和说明执行一次自审,未发现新的功能性问题。 +- 使用 Python 内置只读静态服务器与真实 Edge/CDP 重跑原静态验收,32 项通过。额外前后对照确认首页 RSC 预取 404 从 7 个降至 0 个,7 个分类均保持客户端无刷新跳转且内容数量正确。 +- 修复前的实际影响是预取 404 与预加载收益受损;7 个分类仍能跳转,开发工具和空分类直接访问正常,本次没有观察到白屏或内容丢失。 +- 本轮未重跑 9 月 9 日的开发服务器 130 项验收,不将旧结果计作本轮新验证。第三方 favicon 的跨域失败仍属于既有图标 fallback,不在本次 RSC 修复范围。 +- 本地证据:原 `acceptance-static-failure.json` 保留;新增 `acceptance-static.json`、`static-impact-before.json`、`static-impact-after.json`。该证据目录仍不纳入提交。 +- 本轮新增文件范围:`patches/next@16.3.3.patch`、`patches/README.md`、`scripts/verify-static-export.mjs`;另修改 `package.json`、`pnpm-workspace.yaml`、`pnpm-lock.yaml` 和本计划。原 Task 9 暂存清单尚未更新,不应直接提交旧暂存区。 + +**Files:** + +- No product file changes expected. +- Optional local evidence only: `.superpowers/sdd/2026-09-07-polaris-category-pages/`(该目录不纳入提交)。 + +**Interfaces:** + +- Consumes: 已完成的首页、7 个分类页和静态构建产物。 +- Produces: 分类页阶段的人工可复核验收结果。 + +- [ ] **Step 1:启动独立开发服务器** + +先确认 3108 端口空闲,再运行: + +```bash +corepack pnpm -C "d:/GitRepo/lab/ts/web/nextjs/polaris" exec next dev --hostname 127.0.0.1 --port 3108 +``` + +保持服务器后台运行,并等待日志出现 `Ready`。不要复用来源不明的旧 Next dev 进程。 + +- [ ] **Step 2:在 1440×900 验收开发工具页** + +打开: + +```text +http://127.0.0.1:3108/categories/dev/ +``` + +逐项确认: + +```text +标题为“开发工具” +描述与 categories.json 一致 +计数为“共 15 个” +卡片顺序从 GitHub 到 Figma,按 sort 排列 +三列、五行 +开发工具侧栏项 aria-current=page +首页侧栏项不带 aria-current +页面没有搜索框 +点击 GitHub 卡片打开新标签页 +GitHub 链接包含 noopener noreferrer +侧栏折叠、刷新后仍保持折叠 +浏览器 Console 无 error、assert 或 hydration mismatch +Network 中本站资源无 4xx/5xx +页面无水平滚动 +``` + +保存一张完整页面截图到本地验收目录。 + +- [ ] **Step 3:在 1024×768 验收缩窄桌面布局** + +确认: + +```text +侧栏保持桌面形态 +工具网格为两列 +主面板不覆盖侧栏 +标题、计数和 15 张卡片完整可访问 +页面无水平滚动 +没有新增移动端抽屉或单列规则 +``` + +保存截图。 + +- [ ] **Step 4:在 1920×1080 验收大屏布局** + +确认: + +```text +工具网格为三列 +主面板留白稳定,不无限拉宽文字行长 +星空背景、侧栏和分类面板玻璃质感与首页一致 +卡片 Hover 仅轻微上移和变亮 +Hover/刷新后卡片下方不出现横向异色带 +页面无水平滚动 +``` + +保存截图。 + +- [ ] **Step 5:验收有数据的其他分类** + +依次访问: + +```text +/categories/ai/ -> 共 1 个,ChatGPT +/categories/efficiency/ -> 共 2 个,Gmail、Notion +/categories/media/ -> 共 2 个,YouTube、Bilibili +``` + +确认每页标题、描述、数量、侧栏选中态和卡片内容正确。 + +- [ ] **Step 6:验收三个空分类** + +依次访问: + +```text +/categories/design/ +/categories/learning/ +/categories/life/ +``` + +确认每页显示: + +```text +共 0 个 +暂无工具 +这个分类下还没有添加链接。 +``` + +同时确认没有新增按钮、管理入口或空白卡片网格。 + +- [ ] **Step 7:验收 404 与首页回归** + +访问: + +```text +/categories/unknown/ +``` + +Expected: HTTP 404 或 Next.js not-found 页面,不生成未知分类内容。 + +返回 `/`,确认: + +```text +时间、日期、问候语正常 +Provider 闭合态只显示图标 +Local 搜索与 Ctrl+K 正常 +8 个常用链接正常 +分类侧栏链接已启用 +首页仍是 aria-current=page +``` + +- [ ] **Step 8:用静态产物执行一次路由 smoke check** + +完成 `pnpm build` 后,从 `out/` 启动只读静态服务器: + +```bash +corepack pnpm dlx serve@14.2.5 "d:/GitRepo/lab/ts/web/nextjs/polaris/out" -l 3109 +``` + +至少访问: + +```text +http://127.0.0.1:3109/ +http://127.0.0.1:3109/categories/dev/ +http://127.0.0.1:3109/categories/design/ +``` + +确认静态部署环境中的直接访问和侧栏客户端跳转均成功。验收后停止 3108 和 3109 两个由本 Task 启动的进程。 + +- [ ] **Step 9:Task 8 最终自审一次** + +只审查一次截图、Console、Network、三尺寸布局和静态产物。若发现产品问题,修复对应产品文件并重复受影响的验收项;不启动独立复审代理,不创建 commit。 + +--- + +### Task 9:完整门禁、最终审查与唯一提交 + +**收尾执行记录(2026-09-15):** + +- 用户要求执行收尾任务;继续使用现有 `feat/polaris-category-pages` 工作树,不修改主工作目录,不 push、不创建 PR、不合并到 `main`。 +- 环境入口为 `corepack pnpm`(Node.js `v24.11.1`、pnpm `11.24.0`),无需额外安装工具或修改系统配置。 +- 本轮重新执行 `format:check`、`lint`、`typecheck`、`test`、`build`、`verify:export`,全部通过;16 个测试文件、88 项测试通过,构建生成 10/10 页面,产物检查覆盖 8 个页面、24 个 RSC 文件及 `404.html`。 +- 浏览器验收沿用并核对本地历史证据:9 月 9 日开发服务器 130 项、9 月 13 日静态服务器 32 项;本轮未重新运行浏览器验收,不将历史结果计作本轮新验证。9 月 13 日的前后对照记录确认首页 RSC 预取 404 从 7 个降至 0 个,7 个分类保持客户端无刷新跳转。 +- 9 月 10 日的最终自审与 9 月 13 日的补丁自审均已完成;本轮只更新计划、重跑门禁并核对提交范围,不追加自审或使用代理。 +- 单提交范围为原 18 个文件加 6 个配套文件:`package.json`、`pnpm-workspace.yaml`、`pnpm-lock.yaml`、`patches/next@16.3.3.patch`、`patches/README.md`、`scripts/verify-static-export.mjs`。提交前重新暂存这些精确路径,不能直接使用 9 月 10 日的旧暂存区。 +- 保留但不提交 `next-env.d.ts` 的构建自动生成差异与工作树根目录 `.superpowers/` 本地验收材料;`.next/`、`out/` 不纳入提交。不为追求工作树全净而删除既有证据或覆盖生成文件。 +- Steps 9–10 为提交及提交后核对操作,其结果以 Git 记录和本轮最终报告为准,不在提交前预记结果。 + +**历史执行记录(2026-09-10,以下阻塞状态已被 9 月 13 日修复记录取代):** + +- 在现有 `feat/polaris-category-pages` 工作树执行;未切换或修改主工作目录,主目录原暂存计划保留。 +- Steps 1–8 已执行;Steps 9–10 暂停,Task 9 整体尚未完成。用户明确选择保留原提交条件:Task 8 静态部署验收通过前不创建 commit,不 push。 +- 完整门禁:`format:check`、`lint`、`typecheck`、`test`、`build` 全部通过;16 个测试文件、88 项测试通过,构建生成 10/10 页面(含全部 7 个分类和 404)。 +- 首次格式门禁失败:64 个文件仅有 CRLF/LF 差异,侧栏另有 JSX 排版问题。经用户授权按原 Prettier 规则修复并重跑全部门禁;未修改 Git 或格式配置,纯换行修复未引入无关内容 diff。 +- 已完成一次最终自审:未发现新的功能性缺陷;只进行了格式修复和复验,不追加第二次审查。 +- Task 8 保持阻塞:Windows 导出的 RSC 文件仍位于嵌套路径 `out/categories/dev/__next.categories/$d$categoryId/__PAGE__.txt`,与浏览器请求的扁平路径不一致。构建成功不代表静态部署验收通过,本次未重跑浏览器验收或增加 workaround。 +- Task 8 本地证据保留在工作树根目录 `.superpowers/sdd/2026-09-07-polaris-category-pages/`,不纳入暂存;9 月 9 日记录的 dev 浏览器 130 项通过不作为本次重新验收结果。 +- 提交准备只暂存计划声明的 18 个文件;`next-env.d.ts` 的构建自动生成路径差异与本地验收目录保留但不暂存,`.next/`、`out/` 和截图均不纳入提交。 + +**Files:** + +- Review all changed files. +- Include: `docs/superpowers/plans/2026-09-07-polaris-category-pages.md` + +**Interfaces:** + +- Consumes: Tasks 1–8 的全部实现和验收结果。 +- Produces: 可合并的 `feat/polaris-category-pages` 单提交阶段成果。 + +- [x] **Step 1:确认分支和变更范围** + +Run: + +```bash +git status --short --branch +git diff --stat +git diff --check +``` + +Expected: + +```text +当前分支为 feat/polaris-category-pages +提交范围只包含分类页计划、实现、service、navigation、CSS、相关测试及静态导出补丁和检查脚本 +没有 .next、out、截图、临时 CDP 脚本或敏感信息进入 Git 变更 +git diff --check 无错误 +``` + +- [x] **Step 2:运行完整格式门禁** + +```bash +corepack pnpm -C "d:/GitRepo/lab/.claude/worktrees/polaris-category-task1/ts/web/nextjs/polaris" format:check +``` + +Expected: 全部文件符合 Prettier。 + +- [x] **Step 3:运行完整 ESLint 门禁** + +```bash +corepack pnpm -C "d:/GitRepo/lab/.claude/worktrees/polaris-category-task1/ts/web/nextjs/polaris" lint +``` + +Expected: 0 error。 + +- [x] **Step 4:运行完整 TypeScript 门禁** + +```bash +corepack pnpm -C "d:/GitRepo/lab/.claude/worktrees/polaris-category-task1/ts/web/nextjs/polaris" typecheck +``` + +Expected: 0 error。 + +- [x] **Step 5:运行完整测试门禁** + +```bash +corepack pnpm -C "d:/GitRepo/lab/.claude/worktrees/polaris-category-task1/ts/web/nextjs/polaris" test +``` + +Expected: 全部测试文件和测试项通过,原 71 项首页测试无回归。 + +- [x] **Step 6:运行最终静态构建门禁** + +```bash +corepack pnpm -C "d:/GitRepo/lab/.claude/worktrees/polaris-category-task1/ts/web/nextjs/polaris" build +corepack pnpm -C "d:/GitRepo/lab/.claude/worktrees/polaris-category-task1/ts/web/nextjs/polaris" verify:export +``` + +Expected: 构建成功并生成首页、7 个分类页与 404 页面;`verify:export` 验证 8 个页面、24 个 RSC 文件和 `404.html`。 + +- [x] **Step 7:执行本阶段唯一一次最终代码审查** + +只审查一次完整 diff,按以下清单逐项判断: + +```text +Correctness:7 个分类路由、数量、排序、空状态、404 正确 +Architecture:app 只组合;feature 承载 UI;service 是 JSON 唯一入口 +Next.js 16:await params;generateStaticParams 完整;dynamicParams=false +Server/Client:分类 UI 未扩大 Client Component 边界 +Security:全部外链 target=_blank 且 rel=noopener noreferrer +Accessibility:标题层级、列表语义、aria-current、focus-visible 正确 +Visual:复用当前冷蓝玻璃与背景;无卡片外投影色带 +Scope:无搜索、筛选、管理、深色、移动端、API 等越界功能 +Tests:核心行为有覆盖,未对实现细节过拟合 +Repository:无生成物、临时文件、凭据或无关变更 +``` + +发现问题时直接修复,并只重跑受影响的定向验证以及 Steps 2–6 中被影响的门禁;不追加第二轮代码审查。 + +- [x] **Step 8:暂存精确文件并检查 staged diff** + +先使用 `git status --short` 获取实际文件清单,再暂存本计划声明的分类页文件。允许使用: + +```bash +git add \ + ts/web/nextjs/polaris/docs/superpowers/plans/2026-09-07-polaris-category-pages.md \ + ts/web/nextjs/polaris/src/app/categories \ + ts/web/nextjs/polaris/src/features/category \ + ts/web/nextjs/polaris/src/features/navigation/components/app-shell.tsx \ + ts/web/nextjs/polaris/src/features/navigation/components/app-shell.test.tsx \ + ts/web/nextjs/polaris/src/features/navigation/components/sidebar.tsx \ + ts/web/nextjs/polaris/src/services/categories.ts \ + ts/web/nextjs/polaris/src/services/links.ts \ + ts/web/nextjs/polaris/src/services/services.test.ts \ + ts/web/nextjs/polaris/src/app/globals.css \n ts/web/nextjs/polaris/package.json \n ts/web/nextjs/polaris/pnpm-workspace.yaml \n ts/web/nextjs/polaris/pnpm-lock.yaml \n ts/web/nextjs/polaris/patches/next@16.3.3.patch \n ts/web/nextjs/polaris/patches/README.md \n ts/web/nextjs/polaris/scripts/verify-static-export.mjs + +git diff --staged --check +git diff --staged --stat +``` + +若实际变更与清单不同,先查明原因;不要用 `git add -A` 掩盖无关文件。 + +**Step 9:创建本阶段唯一提交(结果见 Git 记录)** + +提交信息: + +```text +feat(polaris): 实现静态分类页 +``` + +执行: + +```bash +git commit -m "feat(polaris): 实现静态分类页" -m "新增七个预生成分类路由、工具卡片网格与空状态。启用侧栏分类导航和当前分类选中态,并保持静态导出与安全外链约束。" -m "Co-Authored-By: Claude Opus 5 (1M context) " +``` + +**Step 10:验证提交结果并停止(提交后执行)** + +Run: + +```bash +git status --short --branch +git log -1 --oneline +git rev-list --count main..HEAD +git diff --cached --exit-code +``` + +Expected: + +```text +当前分支仍为 feat/polaris-category-pages +暂存区为空;工作树仅保留已声明的 next-env.d.ts 生成差异和 .superpowers/ 本地材料 +最新提交为 feat(polaris): 实现静态分类页 +相对 main 只有本阶段唯一一次提交 +``` + +不要 push。向用户报告提交 ID、完整门禁结果、分类页浏览器验收结果和所有未执行事项。 + +--- + +## 规格覆盖自检 + +| 规格要求 | 对应 Task | +| ------------------------------------- | ---------------------------------- | +| 分类页动态路由与静态生成 | Task 5 | +| 分类标题和描述 | Task 4、Task 6 | +| “全部工具”与数量 | Task 4、Task 7 | +| 开发工具 15 条且按 sort | Task 1、Task 5、Task 7 | +| 三个非空跨分类页面 | Task 1、Task 8 | +| 三个空分类状态 | Task 4、Task 7、Task 8 | +| 三列/两列桌面网格 | Task 6、Task 8 | +| 工具名称、描述、图标和外链提示 | Task 3 | +| 新标签页安全外链 | Task 3、Task 7 | +| 侧栏分类链接和选中态 | Task 2、Task 5 | +| 侧栏折叠持久化复用 | Task 2、Task 8 | +| 无搜索框、无快捷键 | Task 5、Task 8 | +| 无管理、筛选、编辑功能 | Global Constraints、Task 4、Task 9 | +| 首页视觉一致性 | Task 3、Task 6、Task 8 | +| 静态导出和未知分类 404 | Task 5、Task 8 | +| 1024/1440/1920 浏览器验收 | Task 8 | +| 精简流程、单次审查、单次提交、不 push | Global Constraints、Task 9 | + +## 计划自检结论 + +- 规格覆盖:分类页第一阶段的路由、内容、卡片、空状态、导航、桌面响应式和静态导出均有明确 Task。 +- 范围控制:loading/error、移动端、深色模式、数据补全、Go API 和管理功能均明确排除。 +- 类型一致性:路由、service 和组件统一使用现有 `Category` 与 `NavLink`;新增函数签名在生产者和消费者之间一致。 +- Next.js 16 一致性:计划采用 Promise `params`、`await params`、完整 `generateStaticParams()` 和 `dynamicParams = false`。 +- 流程一致性:测试全部安排在实现之后;Task 1–8 不提交;Task 9 只审查一次并创建唯一提交。 diff --git a/ts/web/nextjs/polaris/package.json b/ts/web/nextjs/polaris/package.json index 7dd0c45..60ce3f0 100644 --- a/ts/web/nextjs/polaris/package.json +++ b/ts/web/nextjs/polaris/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "next dev", "build": "next build", + "verify:export": "node scripts/verify-static-export.mjs", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/ts/web/nextjs/polaris/patches/README.md b/ts/web/nextjs/polaris/patches/README.md new file mode 100644 index 0000000..03996c8 --- /dev/null +++ b/ts/web/nextjs/polaris/patches/README.md @@ -0,0 +1,29 @@ +# Next.js Windows 静态导出补丁 + +## 原因与范围 + +`next@16.3.3` 的静态导出器将 `path.relative()` 产生的文件系统路径直接用于 RSC segment 文件名编码。Windows 的反斜杠不会被编码器转换为点号,导致浏览器请求扁平文件名、实际文件却落在嵌套目录。 + +- 上游问题: +- Windows 根因复现: +- 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 的补丁移除命令清理对应登记和补丁文件,更新锁文件,保留产物回归检查。 diff --git a/ts/web/nextjs/polaris/patches/next@16.3.3.patch b/ts/web/nextjs/polaris/patches/next@16.3.3.patch new file mode 100644 index 0000000..52486a4 --- /dev/null +++ b/ts/web/nextjs/polaris/patches/next@16.3.3.patch @@ -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), { diff --git a/ts/web/nextjs/polaris/pnpm-lock.yaml b/ts/web/nextjs/polaris/pnpm-lock.yaml index 250a8e1..ccb8f0b 100644 --- a/ts/web/nextjs/polaris/pnpm-lock.yaml +++ b/ts/web/nextjs/polaris/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + next@16.3.3: f08d1d901d70f79530e06048a3099b814c365af9161c02a6ac2e1c36195a5d57 + importers: .: dependencies: @@ -27,7 +30,7 @@ importers: version: 1.34.0(react@19.2.8) next: 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: specifier: ^19.2.8 version: 19.2.8 @@ -7176,7 +7179,7 @@ snapshots: 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: "@next/env": 16.3.3 "@swc/helpers": 0.5.23 diff --git a/ts/web/nextjs/polaris/pnpm-workspace.yaml b/ts/web/nextjs/polaris/pnpm-workspace.yaml index 351dd78..af3a5c2 100644 --- a/ts/web/nextjs/polaris/pnpm-workspace.yaml +++ b/ts/web/nextjs/polaris/pnpm-workspace.yaml @@ -3,3 +3,6 @@ packages: allowBuilds: unrs-resolver: true + +patchedDependencies: + next@16.3.3: patches/next@16.3.3.patch diff --git a/ts/web/nextjs/polaris/scripts/verify-static-export.mjs b/ts/web/nextjs/polaris/scripts/verify-static-export.mjs new file mode 100644 index 0000000..5858de8 --- /dev/null +++ b/ts/web/nextjs/polaris/scripts/verify-static-export.mjs @@ -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。`, +) diff --git a/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx b/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx new file mode 100644 index 0000000..2929c70 --- /dev/null +++ b/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx @@ -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() + }) +}) diff --git a/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.tsx b/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.tsx new file mode 100644 index 0000000..1d34934 --- /dev/null +++ b/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.tsx @@ -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 ( + + + + ) +} diff --git a/ts/web/nextjs/polaris/src/app/globals.css b/ts/web/nextjs/polaris/src/app/globals.css index c8eacca..278762c 100644 --- a/ts/web/nextjs/polaris/src/app/globals.css +++ b/ts/web/nextjs/polaris/src/app/globals.css @@ -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; diff --git a/ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx b/ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx new file mode 100644 index 0000000..df27d01 --- /dev/null +++ b/ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx @@ -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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByText(`共 ${count} 个`)).toBeInTheDocument() + }) +}) diff --git a/ts/web/nextjs/polaris/src/features/category/components/category-content.tsx b/ts/web/nextjs/polaris/src/features/category/components/category-content.tsx new file mode 100644 index 0000000..8e2803e --- /dev/null +++ b/ts/web/nextjs/polaris/src/features/category/components/category-content.tsx @@ -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 ( +
+ + +
+ + {links.length > 0 ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/ts/web/nextjs/polaris/src/features/category/components/category-empty-state.tsx b/ts/web/nextjs/polaris/src/features/category/components/category-empty-state.tsx new file mode 100644 index 0000000..b08590a --- /dev/null +++ b/ts/web/nextjs/polaris/src/features/category/components/category-empty-state.tsx @@ -0,0 +1,13 @@ +import { PackageOpen } from "lucide-react" + +export function CategoryEmptyState() { + return ( +
+
+ ) +} diff --git a/ts/web/nextjs/polaris/src/features/category/components/category-header.tsx b/ts/web/nextjs/polaris/src/features/category/components/category-header.tsx new file mode 100644 index 0000000..f673042 --- /dev/null +++ b/ts/web/nextjs/polaris/src/features/category/components/category-header.tsx @@ -0,0 +1,14 @@ +import type { Category } from "@/types/navigation" + +type CategoryHeaderProps = { + category: Category +} + +export function CategoryHeader({ category }: CategoryHeaderProps) { + return ( +
+

{category.title}

+

{category.description}

+
+ ) +} diff --git a/ts/web/nextjs/polaris/src/features/category/components/category-section-header.tsx b/ts/web/nextjs/polaris/src/features/category/components/category-section-header.tsx new file mode 100644 index 0000000..d3e4ce8 --- /dev/null +++ b/ts/web/nextjs/polaris/src/features/category/components/category-section-header.tsx @@ -0,0 +1,14 @@ +type CategorySectionHeaderProps = { + count: number +} + +export function CategorySectionHeader({ count }: CategorySectionHeaderProps) { + return ( +
+

+ 全部工具 +

+ 共 {count} 个 +
+ ) +} diff --git a/ts/web/nextjs/polaris/src/features/category/components/tool-card.test.tsx b/ts/web/nextjs/polaris/src/features/category/components/tool-card.test.tsx new file mode 100644 index 0000000..8cdf324 --- /dev/null +++ b/ts/web/nextjs/polaris/src/features/category/components/tool-card.test.tsx @@ -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() + + 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", + ) + }) +}) diff --git a/ts/web/nextjs/polaris/src/features/category/components/tool-card.tsx b/ts/web/nextjs/polaris/src/features/category/components/tool-card.tsx new file mode 100644 index 0000000..5c0ffe8 --- /dev/null +++ b/ts/web/nextjs/polaris/src/features/category/components/tool-card.tsx @@ -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 ( + + + + {link.title} + {link.description} + + + ) +} diff --git a/ts/web/nextjs/polaris/src/features/category/components/tool-grid.tsx b/ts/web/nextjs/polaris/src/features/category/components/tool-grid.tsx new file mode 100644 index 0000000..7606da1 --- /dev/null +++ b/ts/web/nextjs/polaris/src/features/category/components/tool-grid.tsx @@ -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 ( +
    + {links.map((link) => ( +
  • + +
  • + ))} +
+ ) +} diff --git a/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.test.tsx b/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.test.tsx index bbfd630..e04a909 100644 --- a/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.test.tsx +++ b/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.test.tsx @@ -139,7 +139,7 @@ describe("AppShell", () => { ) }) - it("分类项可聚焦但保持禁用", () => { + it("首页默认选中并启用分类链接", () => { render( { , ) - 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( + +
分类内容
+
, + ) + + expect(screen.getByRole("link", { name: "首页" })).not.toHaveAttribute( + "aria-current", + ) + expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute( + "aria-current", + "page", + ) + }) + + it("折叠状态仍保留分类链接的可访问名称", () => { + render( + +
分类内容
+
, + ) + + expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute( + "aria-current", + "page", + ) }) }) diff --git a/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.tsx b/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.tsx index 4014109..58e8643 100644 --- a/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.tsx +++ b/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.tsx @@ -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} />
{children}
diff --git a/ts/web/nextjs/polaris/src/features/navigation/components/sidebar.tsx b/ts/web/nextjs/polaris/src/features/navigation/components/sidebar.tsx index 7b4b931..ad9d21c 100644 --- a/ts/web/nextjs/polaris/src/features/navigation/components/sidebar.tsx +++ b/ts/web/nextjs/polaris/src/features/navigation/components/sidebar.tsx @@ -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 ( @@ -62,9 +65,12 @@ export function Sidebar({ diff --git a/ts/web/nextjs/polaris/src/services/categories.ts b/ts/web/nextjs/polaris/src/services/categories.ts index b56b7b7..9bc7960 100644 --- a/ts/web/nextjs/polaris/src/services/categories.ts +++ b/ts/web/nextjs/polaris/src/services/categories.ts @@ -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 +} diff --git a/ts/web/nextjs/polaris/src/services/links.ts b/ts/web/nextjs/polaris/src/services/links.ts index 95501b9..ab73b59 100644 --- a/ts/web/nextjs/polaris/src/services/links.ts +++ b/ts/web/nextjs/polaris/src/services/links.ts @@ -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 diff --git a/ts/web/nextjs/polaris/src/services/services.test.ts b/ts/web/nextjs/polaris/src/services/services.test.ts index b7e7da8..c99aa55 100644 --- a/ts/web/nextjs/polaris/src/services/services.test.ts +++ b/ts/web/nextjs/polaris/src/services/services.test.ts @@ -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",