From 9b893a6c6109251d7bc6cce5ab239199f84a3eef Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 20 Jun 2026 11:26:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(ux):=20M1/M2=20=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=20=E2=80=94=20=E5=AF=BC=E8=88=AA=E6=B1=89?= =?UTF-8?q?=E5=A0=A1=E6=8A=BD=E5=B1=89=20+=20=E5=86=99=E4=BD=9C=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=9D=A1=E4=B8=8D=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 左导航响应式抽屉:抽出 lib/nav/items.ts(条目数据+激活判定,纯逻辑+vitest) 共用于桌面静态侧栏(hidden lg:block)与移动汉堡抽屉。新增通用 Drawer(遮罩/ Esc 关闭/打开聚焦,复用 CommandPalette a11y 模式)+ NavDrawer + NavItems; LeftNav 改 -
+
+ void; + // 滑出方向:导航在左、本章助手在右。 + side?: "left" | "right"; + // 无障碍标签(role=dialog)。 + label: string; + children: ReactNode; +} + +// 移动端侧滑抽屉((null); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", onKey); + panelRef.current?.focus(); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l"; + + return ( +
+
e.stopPropagation()} + className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`} + > + {children} +
+
+ ); +} diff --git a/apps/web/components/LeftNav.tsx b/apps/web/components/LeftNav.tsx index 65fafb4..e136836 100644 --- a/apps/web/components/LeftNav.tsx +++ b/apps/web/components/LeftNav.tsx @@ -1,17 +1,5 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -export type ActiveNav = - | "write" - | "outline" - | "foreshadow" - | "review" - | "style" - | "codex" - | "rules" - | "skills"; +import type { ActiveNav } from "@/lib/nav/items"; +import { NavItems } from "./NavItems"; interface LeftNavProps { // 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿,UX §3.1)。 @@ -19,140 +7,14 @@ interface LeftNavProps { activeNav?: ActiveNav; } -interface NavItem { - href: string; - label: string; - enabled: boolean; - // 项目级入口的激活键(与 activeNav 比对高亮)。 - key?: ActiveNav; -} - -const GLOBAL_ITEMS: NavItem[] = [ - { href: "/", label: "作品库", enabled: true }, - { href: "/settings/providers", label: "设置", enabled: true }, -]; - -function projectItems(projectId: string): NavItem[] { - return [ - { - href: `/projects/${projectId}/write`, - label: "写作", - enabled: true, - key: "write", - }, - { - href: `/projects/${projectId}/outline`, - label: "大纲", - enabled: true, - key: "outline", - }, - { - href: `/projects/${projectId}/foreshadow`, - label: "伏笔", - enabled: true, - key: "foreshadow", - }, - { - href: `/projects/${projectId}/review`, - label: "审稿", - enabled: true, - key: "review", - }, - { - href: `/projects/${projectId}/style`, - label: "文风", - enabled: true, - key: "style", - }, - { - href: `/projects/${projectId}/codex`, - label: "设定库", - enabled: true, - key: "codex", - }, - { - href: `/projects/${projectId}/rules`, - label: "规则", - enabled: true, - key: "rules", - }, - { - href: `/projects/${projectId}/skills`, - label: "技能库", - enabled: true, - key: "skills", - }, - ]; -} - -// 左导航:当前项朱砂竖条 + 浅晕底(UX §5.1 / §3.1)。 +// 桌面静态左导航(UX §5.1 / §3.1)。 -
    - {GLOBAL_ITEMS.map((item) => ( - - ))} - {scoped.length > 0 ? ( - - ) : null} - {scoped.map((item) => ( - - ))} -
+ ); } - -function NavLink({ item, active }: { item: NavItem; active: boolean }) { - if (!item.enabled) { - return ( -
  • - - {item.label} - -
  • - ); - } - return ( -
  • - - {item.label} - -
  • - ); -} diff --git a/apps/web/components/NavDrawer.tsx b/apps/web/components/NavDrawer.tsx new file mode 100644 index 0000000..f5f6536 --- /dev/null +++ b/apps/web/components/NavDrawer.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState } from "react"; + +import type { ActiveNav } from "@/lib/nav/items"; +import { Drawer } from "./Drawer"; +import { NavItems } from "./NavItems"; + +interface NavDrawerProps { + projectId?: string; + activeNav?: ActiveNav; +} + +// 移动端导航:汉堡按钮 + 左侧抽屉(仅 + + setOpen(false)} + side="left" + label="主导航" + > + setOpen(false)} + /> + + + ); +} diff --git a/apps/web/components/NavItems.tsx b/apps/web/components/NavItems.tsx new file mode 100644 index 0000000..09859f5 --- /dev/null +++ b/apps/web/components/NavItems.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { + GLOBAL_NAV_ITEMS, + isNavItemActive, + projectNavItems, + type ActiveNav, + type NavItem, +} from "@/lib/nav/items"; + +interface NavItemsProps { + // 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿,UX §3.1)。 + projectId?: string; + activeNav?: ActiveNav; + // 抽屉内点链接后关闭抽屉;静态侧栏不传。 + onNavigate?: () => void; +} + +// 导航条目列表(桌面静态侧栏与移动抽屉共用,避免渲染漂移)。 +export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) { + const pathname = usePathname() ?? "/"; + const scoped = projectId ? projectNavItems(projectId) : []; + + return ( +
      + {GLOBAL_NAV_ITEMS.map((item) => ( + + ))} + {scoped.length > 0 ? ( + + ) : null} + {scoped.map((item) => ( + + ))} +
    + ); +} + +function NavLink({ + item, + active, + onNavigate, +}: { + item: NavItem; + active: boolean; + onNavigate?: () => void; +}) { + return ( +
  • + + {item.label} + +
  • + ); +} diff --git a/apps/web/components/workbench/ChapterAssistant.tsx b/apps/web/components/workbench/ChapterAssistant.tsx index d71f3a1..c8d2669 100644 --- a/apps/web/components/workbench/ChapterAssistant.tsx +++ b/apps/web/components/workbench/ChapterAssistant.tsx @@ -17,15 +17,27 @@ interface ChapterAssistantProps { // 右栏「本章助手」(UX §6.3)。 // 「本章注入(透明)」读 B0 端点,列出确定性选中的设定/角色 + 入选理由徽标—— // 这是「看到的=写章用的」的信任牌(不变量 #6)。「四审」指向审稿页入口。 +// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。 export function ChapterAssistant({ projectId, chapterNo, +}: ChapterAssistantProps) { + return ( + + ); +} + +export function AssistantContent({ + projectId, + chapterNo, }: ChapterAssistantProps) { const injection = useInjection(projectId, chapterNo); const selected = injection.data?.selected ?? []; return ( - + ); } diff --git a/apps/web/components/workbench/ChapterList.tsx b/apps/web/components/workbench/ChapterList.tsx index f4ef596..206bc42 100644 --- a/apps/web/components/workbench/ChapterList.tsx +++ b/apps/web/components/workbench/ChapterList.tsx @@ -3,9 +3,18 @@ interface ChapterListProps { } // 左栏目录(UX §6.3)。M1 无章节列表端点 → 仅展示当前章占位。 +// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 ChapterListContent。 export function ChapterList({ currentChapterNo }: ChapterListProps) { return ( + ); +} + +export function ChapterListContent({ currentChapterNo }: ChapterListProps) { + return ( + <>

    目录

    @@ -19,6 +28,6 @@ export function ChapterList({ currentChapterNo }: ChapterListProps) {

    多卷多章目录将在大纲模块开放(后续里程碑)。

    - + ); } diff --git a/apps/web/components/workbench/Workbench.tsx b/apps/web/components/workbench/Workbench.tsx index 35dcdec..4384f4c 100644 --- a/apps/web/components/workbench/Workbench.tsx +++ b/apps/web/components/workbench/Workbench.tsx @@ -4,13 +4,14 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { AppShell } from "@/components/AppShell"; +import { Drawer } from "@/components/Drawer"; import type { ProjectResponse } from "@/lib/api/types"; import { friendlyError } from "@/lib/errors/messages"; import { useAutosave } from "@/lib/autosave/useAutosave"; import { useDraftStream } from "@/lib/stream/useDraftStream"; import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter"; -import { ChapterList } from "./ChapterList"; -import { ChapterAssistant } from "./ChapterAssistant"; +import { ChapterList, ChapterListContent } from "./ChapterList"; +import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { Editor } from "./Editor"; interface WorkbenchProps { @@ -19,6 +20,9 @@ interface WorkbenchProps { initialText?: string; } +// 移动端右栏(目录/助手)经抽屉触达;桌面用三栏栅格。 +type MobilePanel = "chapters" | "assistant" | null; + // M1:单章工作台。章节列表为占位(无章节列表端点);默认第 1 章。 // WORKBENCH_CHAPTER_NO 移到 lib/workbench/chapter.ts(非客户端模块),供 Server Component 安全导入。 @@ -27,6 +31,7 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) { // 用已存草稿作初值;流式开始后由下方 effect 覆盖(流不会被初值反扑—— // stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。 const [text, setText] = useState(initialText); + const [mobilePanel, setMobilePanel] = useState(null); const autosave = useAutosave(project.id, chapterNo); const stream = useDraftStream(); const lastStreamText = useRef(""); @@ -56,6 +61,7 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) { }; const wordCount = text.length; + const closePanel = (): void => setMobilePanel(null); return ( setMobilePanel("chapters")} + onOpenAssistant={() => setMobilePanel("assistant")} /> + + {/* 移动端:目录 / 本章助手经抽屉触达(桌面已在栅格里,抽屉 lg:hidden)。 */} + + + + + + ); } @@ -129,6 +155,8 @@ interface ToolbarProps { streamError: { code: string; message: string } | null; onWrite: () => void; onStop: () => void; + onOpenChapters: () => void; + onOpenAssistant: () => void; } function Toolbar({ @@ -141,11 +169,20 @@ function Toolbar({ streamError, onWrite, onStop, + onOpenChapters, + onOpenAssistant, }: ToolbarProps) { return ( -
    +
    {streamError ? : null} -
    +
    + {streaming ? ( { + it("builds the eight project entries scoped to the project id", () => { + const items = projectNavItems(PID); + + expect(items).toHaveLength(8); + expect(items.map((i) => i.key)).toEqual([ + "write", + "outline", + "foreshadow", + "review", + "style", + "codex", + "rules", + "skills", + ]); + expect(items.every((i) => i.href.startsWith(`/projects/${PID}/`))).toBe( + true, + ); + }); +}); + +describe("isNavItemActive", () => { + const write: NavItem = { + href: `/projects/${PID}/write`, + label: "写作", + key: "write", + }; + + it("matches a project item by its activeNav key regardless of pathname", () => { + expect( + isNavItemActive(write, { pathname: "/anything", activeNav: "write" }), + ).toBe(true); + }); + + it("matches a project item by exact pathname when no activeNav given", () => { + expect(isNavItemActive(write, { pathname: `/projects/${PID}/write` })).toBe( + true, + ); + }); + + it("does not match a project item when neither key nor path align", () => { + expect(isNavItemActive(write, { pathname: "/", activeNav: "outline" })).toBe( + false, + ); + }); + + it("matches a global item by exact pathname only", () => { + const works = GLOBAL_NAV_ITEMS[0]; + expect(isNavItemActive(works, { pathname: "/" })).toBe(true); + expect(isNavItemActive(works, { pathname: "/projects/p1/write" })).toBe( + false, + ); + }); + + it("ignores activeNav for global items (no key)", () => { + const settings = GLOBAL_NAV_ITEMS[1]; + expect( + isNavItemActive(settings, { pathname: "/", activeNav: "write" }), + ).toBe(false); + }); +}); diff --git a/apps/web/lib/nav/items.ts b/apps/web/lib/nav/items.ts new file mode 100644 index 0000000..a6e89d4 --- /dev/null +++ b/apps/web/lib/nav/items.ts @@ -0,0 +1,55 @@ +// 左导航条目数据 + 激活判定(纯逻辑,供桌面静态侧栏 / 移动抽屉共用,UX §5.1/§3.1)。 +// 组件只负责渲染;条目与高亮规则在此集中,避免侧栏与抽屉漂移。 + +export type ActiveNav = + | "write" + | "outline" + | "foreshadow" + | "review" + | "style" + | "codex" + | "rules" + | "skills"; + +export interface NavItem { + href: string; + label: string; + // 项目级入口的激活键(与 activeNav 比对高亮);全局项无 key。 + key?: ActiveNav; +} + +export const GLOBAL_NAV_ITEMS: readonly NavItem[] = [ + { href: "/", label: "作品库" }, + { href: "/settings/providers", label: "设置" }, +]; + +// 项目内页展开的项目级入口(UX §3.1)。 +export function projectNavItems(projectId: string): NavItem[] { + return [ + { href: `/projects/${projectId}/write`, label: "写作", key: "write" }, + { href: `/projects/${projectId}/outline`, label: "大纲", key: "outline" }, + { + href: `/projects/${projectId}/foreshadow`, + label: "伏笔", + key: "foreshadow", + }, + { href: `/projects/${projectId}/review`, label: "审稿", key: "review" }, + { href: `/projects/${projectId}/style`, label: "文风", key: "style" }, + { href: `/projects/${projectId}/codex`, label: "设定库", key: "codex" }, + { href: `/projects/${projectId}/rules`, label: "规则", key: "rules" }, + { href: `/projects/${projectId}/skills`, label: "技能库", key: "skills" }, + ]; +} + +export interface NavActiveContext { + pathname: string; + activeNav?: ActiveNav; +} + +// 全局项按精确路径匹配;项目项按 activeNav 键匹配(或路径相等兜底)。 +export function isNavItemActive(item: NavItem, ctx: NavActiveContext): boolean { + if (item.key !== undefined) { + return item.key === ctx.activeNav || ctx.pathname === item.href; + } + return ctx.pathname === item.href; +}