feat(ux): T4-a 全局 AI 工具条 — 项目页顶部常驻写本章/审稿/大纲/设定库/工具箱

新 lib/nav/ai-tools.ts (aiToolItems) + components/AiToolbar.tsx 服务端组件;AppShell 顶栏下渲染(仅项目页),用 --chrome CSS 变量统一 chrome 高度,6 个固定高度页改用 calc(100vh-var(--chrome,4rem)) 适配。TDD: ai-tools 测。前端门禁绿: lint/tsc/vitest/build。
This commit is contained in:
Yaojia Wang
2026-06-20 18:19:46 +02:00
parent 6e5d0e88f6
commit 8779530806
10 changed files with 122 additions and 8 deletions

View File

@@ -0,0 +1,48 @@
import Link from "next/link";
import { aiToolItems } from "@/lib/nav/ai-tools";
import type { ActiveNav } from "@/lib/nav/items";
interface AiToolbarProps {
projectId: string;
activeNav?: ActiveNav;
}
// T4-a · 项目内页常驻 AI 工具条顶栏下UX §3/§5
// 纯链接服务端组件:写本章=朱砂主动作,其余次级;激活项朱砂高亮。窄屏横向滚动。
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
return (
<nav
aria-label="AI 工具条"
className="flex h-12 items-center gap-2 overflow-x-auto whitespace-nowrap border-b border-line bg-panel px-4 sm:px-6"
>
{aiToolItems(projectId).map((item) => {
const isActive = item.key === activeNav;
const className = toolClassName(item.primary === true, isActive);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={className}
>
<span aria-hidden="true">{item.glyph}</span>
<span>{item.label}</span>
</Link>
);
})}
</nav>
);
}
function toolClassName(isPrimary: boolean, isActive: boolean): string {
const base =
"flex items-center gap-1.5 rounded border px-3 py-1.5 text-sm transition-colors";
if (isActive) {
return `${base} border-cinnabar text-cinnabar`;
}
if (isPrimary) {
return `${base} border-cinnabar bg-cinnabar text-panel hover:border-cinnabar`;
}
return `${base} border-line text-ink-soft hover:border-cinnabar hover:text-cinnabar`;
}

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { ActiveNav } from "@/lib/nav/items"; import type { ActiveNav } from "@/lib/nav/items";
import { AiToolbar } from "./AiToolbar";
import { LeftNav } from "./LeftNav"; import { LeftNav } from "./LeftNav";
import { NavDrawer } from "./NavDrawer"; import { NavDrawer } from "./NavDrawer";
@@ -25,7 +26,10 @@ export function AppShell({
activeNav, activeNav,
}: AppShellProps) { }: AppShellProps) {
return ( return (
<div className="min-h-screen"> <div
className="min-h-screen"
style={{ ["--chrome" as string]: projectId ? "7rem" : "4rem" }}
>
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6"> <header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6">
<NavDrawer projectId={projectId} activeNav={activeNav} /> <NavDrawer projectId={projectId} activeNav={activeNav} />
<Link <Link
@@ -50,9 +54,14 @@ export function AppShell({
</Link> </Link>
</nav> </nav>
</header> </header>
{projectId ? (
<AiToolbar projectId={projectId} activeNav={activeNav} />
) : null}
<div className="flex"> <div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} /> <LeftNav projectId={projectId} activeNav={activeNav} />
<main className="min-h-[calc(100vh-4rem)] flex-1 bg-bg">{children}</main> <main className="min-h-[calc(100vh-var(--chrome,4rem))] flex-1 bg-bg">
{children}
</main>
</div> </div>
</div> </div>
); );

View File

@@ -59,7 +59,7 @@ export function CodexPage({
projectId={project.id} projectId={project.id}
activeNav="codex" activeNav="codex"
> >
<div className="flex h-[calc(100vh-4rem)] flex-col p-4"> <div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
<div className="mb-4 flex items-center gap-2" role="tablist"> <div className="mb-4 flex items-center gap-2" role="tablist">
{TABS.map((t) => ( {TABS.map((t) => (
<button <button

View File

@@ -46,7 +46,7 @@ export function ForeshadowBoard({
projectId={project.id} projectId={project.id}
activeNav="foreshadow" activeNav="foreshadow"
> >
<div className="flex h-[calc(100vh-4rem)] flex-col p-4"> <div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
<div className="mb-4 flex items-start justify-between gap-4"> <div className="mb-4 flex items-start justify-between gap-4">
<h1 className="font-serif text-lg text-ink"></h1> <h1 className="font-serif text-lg text-ink"></h1>
<RegisterForm <RegisterForm

View File

@@ -31,7 +31,7 @@ export function OutlineEditor({
projectId={project.id} projectId={project.id}
activeNav="outline" activeNav="outline"
> >
<div className="flex h-[calc(100vh-4rem)] flex-col p-6"> <div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
<div className="mb-4 flex items-center gap-3"> <div className="mb-4 flex items-center gap-3">
<h1 className="font-serif text-lg text-ink"></h1> <h1 className="font-serif text-lg text-ink"></h1>
<label htmlFor="vol" className="ml-auto text-xs text-ink-soft"> <label htmlFor="vol" className="ml-auto text-xs text-ink-soft">

View File

@@ -215,7 +215,7 @@ export function ReviewReport({
projectId={project.id} projectId={project.id}
activeNav="review" activeNav="review"
> >
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[1fr_24rem]"> <div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[1fr_24rem]">
{/* 左:终稿正文 + 就地标注 */} {/* 左:终稿正文 + 就地标注 */}
<section className="flex min-w-0 flex-col bg-bg"> <section className="flex min-w-0 flex-col bg-bg">
<div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3"> <div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3">

View File

@@ -27,7 +27,7 @@ export function StylePage({ project, initialFingerprint }: StylePageProps) {
projectId={project.id} projectId={project.id}
activeNav="style" activeNav="style"
> >
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[28rem_1fr]"> <div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[28rem_1fr]">
<section className="flex flex-col overflow-auto border-r border-line bg-panel px-6 py-6"> <section className="flex flex-col overflow-auto border-r border-line bg-panel px-6 py-6">
<h1 className="mb-3 font-serif text-lg text-ink"></h1> <h1 className="mb-3 font-serif text-lg text-ink"></h1>
<StyleUpload <StyleUpload

View File

@@ -70,7 +70,7 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
projectId={project.id} projectId={project.id}
activeNav="write" activeNav="write"
> >
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]"> <div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
<ChapterList currentChapterNo={chapterNo} /> <ChapterList currentChapterNo={chapterNo} />
<section className="flex min-w-0 flex-col bg-bg"> <section className="flex min-w-0 flex-col bg-bg">

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { aiToolItems } from "./ai-tools";
describe("aiToolItems", () => {
it("returns 5 items", () => {
expect(aiToolItems("p1")).toHaveLength(5);
});
it("leads with 写本章 (primary, key write)", () => {
const [first] = aiToolItems("p1");
expect(first.label).toBe("写本章");
expect(first.primary).toBe(true);
expect(first.key).toBe("write");
});
it("scopes every href under the project path", () => {
for (const item of aiToolItems("abc")) {
expect(item.href.startsWith("/projects/abc/")).toBe(true);
}
});
it("maps 审稿 to the review page and key", () => {
const review = aiToolItems("p1").find((item) => item.label === "审稿");
expect(review?.href).toBe("/projects/p1/review");
expect(review?.key).toBe("review");
});
});

View File

@@ -0,0 +1,29 @@
// T4-a · 顶部 AI 工具条数据纯逻辑UX §3/§5
// 把高频 AI 动作(写本章/审稿/大纲/设定库/工具箱)做成常驻一排,根治「命令面板太隐蔽」。
// 只露已实现能力;新生成器(创作工具箱 B 组)落地后并入工具箱入口。
// 组件只渲染;条目与激活键集中在此,复用 ActiveNav 高亮规则。
import type { ActiveNav } from "./items";
export interface AiToolItem {
href: string;
label: string;
// 行首字形纸感非语义aria-hidden 渲染)。
glyph: string;
// 高亮键(与 AppShell activeNav 比对)。
key: ActiveNav;
// 主动作(写本章)朱砂强调,其余次级。
primary?: boolean;
}
// 项目内页 AI 工具条条目(按已实现能力露出)。
export function aiToolItems(projectId: string): AiToolItem[] {
const base = `/projects/${projectId}`;
return [
{ href: `${base}/write`, label: "写本章", glyph: "✍", key: "write", primary: true },
{ href: `${base}/review`, label: "审稿", glyph: "✦", key: "review" },
{ href: `${base}/outline`, label: "大纲", glyph: "❡", key: "outline" },
{ href: `${base}/codex`, label: "设定库", glyph: "❖", key: "codex" },
{ href: `${base}/skills`, label: "工具箱", glyph: "✧", key: "skills" },
];
}