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:
48
apps/web/components/AiToolbar.tsx
Normal file
48
apps/web/components/AiToolbar.tsx
Normal 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`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
28
apps/web/lib/nav/ai-tools.test.ts
Normal file
28
apps/web/lib/nav/ai-tools.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
29
apps/web/lib/nav/ai-tools.ts
Normal file
29
apps/web/lib/nav/ai-tools.ts
Normal 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" },
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user