From 3c87b01379f4cd6e6693c8247e1f2fc0d3e1d46c Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Fri, 10 Jul 2026 18:14:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E5=B7=A6=E6=A0=8F=2010=20?= =?UTF-8?q?=E9=A1=B9=E6=8C=89=E5=86=99=E4=BD=9C/=E8=B5=84=E6=96=99?= =?UTF-8?q?=E5=BA=93/=E5=AE=A1=E9=98=85/=E6=9B=B4=E5=A4=9A=E5=88=86?= =?UTF-8?q?=E7=BB=84=EF=BC=88UX=20P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navSections(projectId?) 纯函数把平铺项目项归为四组,NavItems 复用「本作」小标题模式逐组渲染;链接/路由/激活键不变,工具箱收进「更多」不丢入口。 --- apps/web/components/NavItems.tsx | 49 +++++++++++++++----------------- apps/web/lib/nav/items.test.ts | 48 +++++++++++++++++++++++++++++++ apps/web/lib/nav/items.ts | 37 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 26 deletions(-) diff --git a/apps/web/components/NavItems.tsx b/apps/web/components/NavItems.tsx index f79c6d3..53a4ee8 100644 --- a/apps/web/components/NavItems.tsx +++ b/apps/web/components/NavItems.tsx @@ -1,5 +1,6 @@ "use client"; +import { Fragment } from "react"; import Link from "next/link"; import { Blocks, @@ -19,9 +20,8 @@ import { import { usePathname } from "next/navigation"; import { - GLOBAL_NAV_ITEMS, isNavItemActive, - projectNavItems, + navSections, type ActiveNav, type NavItem, } from "@/lib/nav/items"; @@ -36,35 +36,32 @@ interface NavItemsProps { } // 导航条目列表(桌面静态侧栏与移动抽屉共用,避免渲染漂移)。 +// 项目内页把 10 项按 写作/资料库/审阅/更多 分组(P2-1),每组一个小标题;全局组无标题直显于顶部。 export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) { const pathname = usePathname() ?? "/"; - const scoped = projectId ? projectNavItems(projectId) : []; + const sections = navSections(projectId); return ( ); diff --git a/apps/web/lib/nav/items.test.ts b/apps/web/lib/nav/items.test.ts index 085745f..7024425 100644 --- a/apps/web/lib/nav/items.test.ts +++ b/apps/web/lib/nav/items.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { GLOBAL_NAV_ITEMS, isNavItemActive, + navSections, projectNavItems, type NavItem, } from "./items"; @@ -32,6 +33,53 @@ describe("projectNavItems", () => { }); }); +describe("navSections", () => { + it("returns only the unlabeled global section outside a project", () => { + const sections = navSections(); + + expect(sections).toHaveLength(1); + expect(sections[0]?.label).toBeNull(); + expect(sections[0]?.items).toEqual(GLOBAL_NAV_ITEMS); + }); + + it("groups project entries under the four labeled sections (P2-1)", () => { + const sections = navSections(PID); + + // 全局组(无标题)+ 写作 / 资料库 / 审阅 / 更多。 + expect(sections.map((s) => s.label)).toEqual([ + null, + "写作", + "资料库", + "审阅", + "更多", + ]); + + const byLabel = (label: string): (string | undefined)[] => + sections.find((s) => s.label === label)?.items.map((i) => i.key) ?? []; + + expect(byLabel("写作")).toEqual(["write"]); + expect(byLabel("资料库")).toEqual([ + "outline", + "codex", + "foreshadow", + "style", + ]); + expect(byLabel("审阅")).toEqual(["review", "chains"]); + expect(byLabel("更多")).toEqual(["toolbox", "rules", "skills"]); + }); + + it("places every project entry exactly once (no dropped or duplicated links)", () => { + const sections = navSections(PID); + const grouped = sections + .filter((s) => s.label !== null) + .flatMap((s) => s.items.map((i) => i.key)); + const flat = projectNavItems(PID).map((i) => i.key); + + expect([...grouped].sort()).toEqual([...flat].sort()); + expect(grouped).toHaveLength(flat.length); + }); +}); + describe("isNavItemActive", () => { const write: NavItem = { href: `/projects/${PID}/write`, diff --git a/apps/web/lib/nav/items.ts b/apps/web/lib/nav/items.ts index 2bd2dda..2c29f33 100644 --- a/apps/web/lib/nav/items.ts +++ b/apps/web/lib/nav/items.ts @@ -46,6 +46,43 @@ export function projectNavItems(projectId: string): NavItem[] { ]; } +// 左栏一个分组:可选小标题 + 若干条目(P2-1)。全局组 label=null(顶部直显、无小标题); +// 项目组复用现有"本作"式小标题样式。分组只重排 + 加标题,链接/路由/激活键均不变(行为保持)。 +export interface NavSection { + label: string | null; + items: readonly NavItem[]; +} + +// 项目级入口的语义分组(P2-1):把 10 个平铺项归为 写作/资料库/审阅/更多,便于扫读。 +// 工具箱归入「更多」——原分组清单只列了 规则·技能库,收进"更多"以不丢任何入口 +// (见 memory/decisions.md 2026-07-10 条)。 +const PROJECT_GROUPS: readonly { label: string; keys: readonly ActiveNav[] }[] = + [ + { label: "写作", keys: ["write"] }, + { label: "资料库", keys: ["outline", "codex", "foreshadow", "style"] }, + { label: "审阅", keys: ["review", "chains"] }, + { label: "更多", keys: ["toolbox", "rules", "skills"] }, + ]; + +// 左导航分组(桌面侧栏 / 移动抽屉共用):全局组恒在顶部,项目内页再展开四个项目组。 +export function navSections(projectId?: string): NavSection[] { + const global: NavSection = { label: null, items: GLOBAL_NAV_ITEMS }; + if (projectId === undefined) return [global]; + + const byKey = new Map(projectNavItems(projectId).map((item) => [item.key, item])); + const projectSections = PROJECT_GROUPS.map((group) => ({ + label: group.label, + items: group.keys.map((key) => { + const item = byKey.get(key); + if (item === undefined) { + throw new Error(`navSections: 缺少项目入口 ${key}`); + } + return item; + }), + })); + return [global, ...projectSections]; +} + export interface NavActiveContext { pathname: string; activeNav?: ActiveNav;