+
{loadError ? (
无法连接后端服务,请确认 API 已启动后刷新。
diff --git a/apps/web/components/AiToolbar.tsx b/apps/web/components/AiToolbar.tsx
index 7d86e82..2851ff2 100644
--- a/apps/web/components/AiToolbar.tsx
+++ b/apps/web/components/AiToolbar.tsx
@@ -1,7 +1,21 @@
import Link from "next/link";
+import {
+ BookOpen,
+ ClipboardCheck,
+ ListTree,
+ PenLine,
+ Sparkles,
+ type LucideIcon,
+} from "lucide-react";
-import { aiToolItems } from "@/lib/nav/ai-tools";
+import { AiToolbarMoreMenu } from "@/components/AiToolbarMoreMenu";
+import {
+ aiToolItems,
+ primaryAiToolItems,
+ secondaryAiToolItems,
+} from "@/lib/nav/ai-tools";
import type { ActiveNav } from "@/lib/nav/items";
+import { buttonClass } from "@/lib/ui/variants";
interface AiToolbarProps {
projectId: string;
@@ -9,40 +23,71 @@ interface AiToolbarProps {
}
// T4-a · 项目内页常驻 AI 工具条(顶栏下,UX §3/§5)。
-// 纯链接服务端组件:写本章=朱砂主动作,其余次级;激活项朱砂高亮。窄屏横向滚动。
+// 桌面展示完整工具条;窄屏只保留写本章/审稿,把低频入口收进更多菜单。
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
+ const primaryItems = primaryAiToolItems(projectId);
+ const secondaryItems = secondaryAiToolItems(projectId);
return (
);
}
+function toolIcon(key: ActiveNav): LucideIcon {
+ if (key === "write") return PenLine;
+ if (key === "review") return ClipboardCheck;
+ if (key === "outline") return ListTree;
+ if (key === "codex") return BookOpen;
+ return Sparkles;
+}
+
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`;
+ return buttonClass({ variant: "outline", size: "sm" });
}
if (isPrimary) {
- return `${base} border-cinnabar bg-cinnabar text-panel hover:border-cinnabar`;
+ return buttonClass({ variant: "primary", size: "sm" });
}
- return `${base} border-line text-ink-soft hover:border-cinnabar hover:text-cinnabar`;
+ return buttonClass({ variant: "secondary", size: "sm" });
}
diff --git a/apps/web/components/AiToolbarMoreMenu.tsx b/apps/web/components/AiToolbarMoreMenu.tsx
new file mode 100644
index 0000000..96ae2f7
--- /dev/null
+++ b/apps/web/components/AiToolbarMoreMenu.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import Link from "next/link";
+import { MoreHorizontal } from "lucide-react";
+import { useEffect, useId, useRef, useState } from "react";
+
+import type { AiToolItem } from "@/lib/nav/ai-tools";
+import type { ActiveNav } from "@/lib/nav/items";
+import { buttonClass, cn } from "@/lib/ui/variants";
+
+interface AiToolbarMoreMenuProps {
+ items: AiToolItem[];
+ activeNav?: ActiveNav;
+}
+
+export function AiToolbarMoreMenu({
+ items,
+ activeNav,
+}: AiToolbarMoreMenuProps) {
+ const [open, setOpen] = useState(false);
+ const menuId = useId();
+ const rootRef = useRef(null);
+ const buttonRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (event.key === "Escape") {
+ setOpen(false);
+ buttonRef.current?.focus();
+ }
+ };
+
+ const onPointerDown = (event: PointerEvent): void => {
+ const target = event.target;
+ if (target instanceof Node && !rootRef.current?.contains(target)) {
+ setOpen(false);
+ }
+ };
+
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener("pointerdown", onPointerDown);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("pointerdown", onPointerDown);
+ };
+ }, [open]);
+
+ return (
+
+
+ {open ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/components/AppShell.tsx b/apps/web/components/AppShell.tsx
index 372e7a5..8f6a39f 100644
--- a/apps/web/components/AppShell.tsx
+++ b/apps/web/components/AppShell.tsx
@@ -1,10 +1,13 @@
import Link from "next/link";
import type { ReactNode } from "react";
+import { Settings } from "lucide-react";
import type { ActiveNav } from "@/lib/nav/items";
+import { buttonClass } from "@/lib/ui/variants";
import { AiToolbar } from "./AiToolbar";
import { LeftNav } from "./LeftNav";
import { NavDrawer } from "./NavDrawer";
+import { ThemeToggle } from "./ThemeToggle";
interface AppShellProps {
children: ReactNode;
@@ -34,23 +37,33 @@ export function AppShell({
墨痕
{title ? (
- {title}
+
+ {title}
+
) : null}
{subtitle ? (
- {subtitle}
+
+ {subtitle}
+
) : null}
-