feat(web): 命令面板 combobox+可见入口 + sticky 导航壳 + 焦点环统一 + 抽屉锁滚动/关闭按钮 + 主题首帧修复 + 设置徽标/datalist
This commit is contained in:
@@ -30,7 +30,7 @@ export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
|
|||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="AI 工具条"
|
aria-label="AI 工具条"
|
||||||
className="flex h-12 items-center gap-2 border-b border-line bg-panel px-4 sm:px-6"
|
className="sticky top-16 z-20 flex h-12 items-center gap-2 border-b border-line bg-panel px-4 sm:px-6"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden lg:hidden">
|
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden lg:hidden">
|
||||||
{primaryItems.map((item) => {
|
{primaryItems.map((item) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useEffect, useId, useRef, useState } from "react";
|
|||||||
|
|
||||||
import type { AiToolItem } from "@/lib/nav/ai-tools";
|
import type { AiToolItem } from "@/lib/nav/ai-tools";
|
||||||
import type { ActiveNav } from "@/lib/nav/items";
|
import type { ActiveNav } from "@/lib/nav/items";
|
||||||
import { buttonClass, cn } from "@/lib/ui/variants";
|
import { buttonClass, cn, focusRing } from "@/lib/ui/variants";
|
||||||
|
|
||||||
interface AiToolbarMoreMenuProps {
|
interface AiToolbarMoreMenuProps {
|
||||||
items: AiToolItem[];
|
items: AiToolItem[];
|
||||||
@@ -52,7 +52,7 @@ export function AiToolbarMoreMenu({
|
|||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="true"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={menuId}
|
aria-controls={menuId}
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={() => setOpen((value) => !value)}
|
||||||
@@ -62,9 +62,10 @@ export function AiToolbarMoreMenu({
|
|||||||
更多
|
更多
|
||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open ? (
|
||||||
<div
|
// 诚实的 Tab-only 折叠面板:普通导航链接,不冒充 ARIA menu(无 roving tabindex/方向键)。
|
||||||
|
<nav
|
||||||
id={menuId}
|
id={menuId}
|
||||||
role="menu"
|
aria-label="更多工具"
|
||||||
className="absolute right-0 z-30 mt-2 min-w-36 rounded border border-line bg-panel p-1 shadow-paper"
|
className="absolute right-0 z-30 mt-2 min-w-36 rounded border border-line bg-panel p-1 shadow-paper"
|
||||||
>
|
>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
@@ -73,11 +74,11 @@ export function AiToolbarMoreMenu({
|
|||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
role="menuitem"
|
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"block rounded px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
|
"block rounded px-3 py-2 text-sm transition-colors",
|
||||||
|
focusRing,
|
||||||
active
|
active
|
||||||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||||
: "text-ink hover:bg-bg hover:text-cinnabar",
|
: "text-ink hover:bg-bg hover:text-cinnabar",
|
||||||
@@ -87,7 +88,7 @@ export function AiToolbarMoreMenu({
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</nav>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import type { ReactNode } from "react";
|
|||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
|
|
||||||
import type { ActiveNav } from "@/lib/nav/items";
|
import type { ActiveNav } from "@/lib/nav/items";
|
||||||
import { buttonClass } from "@/lib/ui/variants";
|
import { buttonClass, focusRing } from "@/lib/ui/variants";
|
||||||
import { AiToolbar } from "./AiToolbar";
|
import { AiToolbar } from "./AiToolbar";
|
||||||
|
import { CommandSearchButton } from "./command/CommandPaletteMount";
|
||||||
import { LeftNav } from "./LeftNav";
|
import { LeftNav } from "./LeftNav";
|
||||||
import { NavDrawer } from "./NavDrawer";
|
import { NavDrawer } from "./NavDrawer";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
@@ -39,11 +40,11 @@ export function AppShell({
|
|||||||
>
|
>
|
||||||
跳到正文
|
跳到正文
|
||||||
</a>
|
</a>
|
||||||
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6">
|
<header className="sticky top-0 z-30 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
|
||||||
href="/"
|
href="/"
|
||||||
className="shrink-0 whitespace-nowrap font-serif text-xl text-cinnabar"
|
className={`shrink-0 whitespace-nowrap rounded font-serif text-xl text-cinnabar ${focusRing}`}
|
||||||
aria-label="返回作品库"
|
aria-label="返回作品库"
|
||||||
>
|
>
|
||||||
墨痕
|
墨痕
|
||||||
@@ -62,6 +63,7 @@ export function AppShell({
|
|||||||
aria-label="快捷操作"
|
aria-label="快捷操作"
|
||||||
className="ml-auto flex shrink-0 items-center gap-2 text-sm"
|
className="ml-auto flex shrink-0 items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
|
<CommandSearchButton />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Link
|
<Link
|
||||||
href="/settings/providers"
|
href="/settings/providers"
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||||
|
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
||||||
|
import { overlayScrim } from "@/lib/ui/variants";
|
||||||
|
|
||||||
interface DrawerProps {
|
interface DrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -15,7 +19,7 @@ interface DrawerProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动端侧滑抽屉(<lg;遮罩点击 / Esc 关闭 + 打开聚焦面板)。
|
// 移动端侧滑抽屉(<lg;遮罩点击 / Esc 关闭 + 打开聚焦具名关闭按钮)。
|
||||||
// 复用命令面板的 a11y 模式(CommandPalette);桌面用静态布局,不渲染本抽屉。
|
// 复用命令面板的 a11y 模式(CommandPalette);桌面用静态布局,不渲染本抽屉。
|
||||||
export function Drawer({
|
export function Drawer({
|
||||||
open,
|
open,
|
||||||
@@ -26,8 +30,11 @@ export function Drawer({
|
|||||||
children,
|
children,
|
||||||
}: DrawerProps) {
|
}: DrawerProps) {
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
const wasOpenRef = useRef(false);
|
const wasOpenRef = useRef(false);
|
||||||
|
|
||||||
|
useBodyScrollLock(open);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
if (wasOpenRef.current) {
|
if (wasOpenRef.current) {
|
||||||
@@ -44,7 +51,8 @@ export function Drawer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
panelRef.current?.focus();
|
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
|
||||||
|
closeRef.current?.focus();
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [open, onClose, triggerRef]);
|
}, [open, onClose, triggerRef]);
|
||||||
|
|
||||||
@@ -53,10 +61,9 @@ export function Drawer({
|
|||||||
const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden" onClick={onClose}>
|
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
tabIndex={-1}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
@@ -65,8 +72,19 @@ export function Drawer({
|
|||||||
// focus trap:Tab/Shift+Tab 在抽屉内循环,不逃逸到背景(WCAG 2.1.2)。
|
// focus trap:Tab/Shift+Tab 在抽屉内循环,不逃逸到背景(WCAG 2.1.2)。
|
||||||
if (panelRef.current) handleTabTrap(panelRef.current, e);
|
if (panelRef.current) handleTabTrap(panelRef.current, e);
|
||||||
}}
|
}}
|
||||||
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
|
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
|
||||||
>
|
>
|
||||||
|
<div className="mb-2 flex justify-end px-2">
|
||||||
|
<Button
|
||||||
|
ref={closeRef}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={`关闭${label}`}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface LeftNavProps {
|
|||||||
export function LeftNav({ projectId, activeNav }: LeftNavProps) {
|
export function LeftNav({ projectId, activeNav }: LeftNavProps) {
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="hidden w-44 shrink-0 border-r border-line bg-panel py-4 lg:block"
|
className="sticky top-[var(--chrome)] hidden max-h-[calc(100vh-var(--chrome))] w-44 shrink-0 self-start overflow-y-auto border-r border-line bg-panel py-4 lg:block"
|
||||||
aria-label="主导航"
|
aria-label="主导航"
|
||||||
>
|
>
|
||||||
<NavItems projectId={projectId} activeNav={activeNav} />
|
<NavItems projectId={projectId} activeNav={activeNav} />
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Menu } from "lucide-react";
|
import { Menu, Search } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { openCommandPalette } from "@/components/command/CommandPalette";
|
||||||
import type { ActiveNav } from "@/lib/nav/items";
|
import type { ActiveNav } from "@/lib/nav/items";
|
||||||
|
import { focusRing } from "@/lib/ui/variants";
|
||||||
import { Drawer } from "./Drawer";
|
import { Drawer } from "./Drawer";
|
||||||
import { NavItems } from "./NavItems";
|
import { NavItems } from "./NavItems";
|
||||||
|
|
||||||
@@ -18,6 +20,11 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const openSearch = (): void => {
|
||||||
|
setOpen(false);
|
||||||
|
openCommandPalette();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -38,6 +45,19 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
|||||||
label="主导航"
|
label="主导航"
|
||||||
triggerRef={triggerRef}
|
triggerRef={triggerRef}
|
||||||
>
|
>
|
||||||
|
<div className="px-2 pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openSearch}
|
||||||
|
className={`flex w-full items-center gap-2 rounded border border-line bg-bg px-3 py-2 text-sm text-ink-soft transition-colors hover:text-cinnabar ${focusRing}`}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
搜索命令或页面
|
||||||
|
<kbd className="ml-auto rounded border border-line bg-panel px-1 font-mono text-2xs">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<NavItems
|
<NavItems
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
activeNav={activeNav}
|
activeNav={activeNav}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type ActiveNav,
|
type ActiveNav,
|
||||||
type NavItem,
|
type NavItem,
|
||||||
} from "@/lib/nav/items";
|
} from "@/lib/nav/items";
|
||||||
|
import { focusRing } from "@/lib/ui/variants";
|
||||||
|
|
||||||
interface NavItemsProps {
|
interface NavItemsProps {
|
||||||
// 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿,UX §3.1)。
|
// 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿,UX §3.1)。
|
||||||
@@ -101,7 +102,7 @@ function NavLink({
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm transition-colors ${
|
className={`flex items-center gap-2 rounded px-3 py-2 text-sm transition-colors ${focusRing} ${
|
||||||
active
|
active
|
||||||
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||||
: "border-l-2 border-transparent text-ink hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar"
|
: "border-l-2 border-transparent text-ink hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar"
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ function readStoredThemeMode(): ThemeMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始模式直接读 ThemeScript 在水合前写入的 <html data-theme>,避免夜读首帧渲染错误图标。
|
||||||
|
// SSR 守卫:服务端无 document → 回落默认(与 ThemeScript 的默认一致)。
|
||||||
|
function initialThemeMode(): ThemeMode {
|
||||||
|
if (typeof document === "undefined") return DEFAULT_THEME_MODE;
|
||||||
|
return normalizeThemeMode(document.documentElement.dataset.theme);
|
||||||
|
}
|
||||||
|
|
||||||
function writeStoredThemeMode(mode: ThemeMode) {
|
function writeStoredThemeMode(mode: ThemeMode) {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||||
@@ -35,9 +42,10 @@ function writeStoredThemeMode(mode: ThemeMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
|
const [mode, setMode] = useState<ThemeMode>(initialThemeMode);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 水合后以 localStorage 为权威源校正(dataset 已由 ThemeScript 同样依据写入,通常一致)。
|
||||||
const initial = readStoredThemeMode();
|
const initial = readStoredThemeMode();
|
||||||
setMode(initial);
|
setMode(initial);
|
||||||
applyThemeMode(initial);
|
applyThemeMode(initial);
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import {
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +19,52 @@ import {
|
|||||||
} from "@/lib/command/palette";
|
} from "@/lib/command/palette";
|
||||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
|
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
||||||
|
import { focusRing, overlayScrim } from "@/lib/ui/variants";
|
||||||
|
|
||||||
|
// 命令面板开关的极简外部 store:⌘K 与可见入口(顶栏按钮 / 移动抽屉)共享同一开态。
|
||||||
|
// 用 useSyncExternalStore 订阅,避免把 open 提升到 RootLayout context 造成全树重渲染。
|
||||||
|
let paletteOpen = false;
|
||||||
|
const paletteListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function emitPalette(): void {
|
||||||
|
for (const listener of paletteListeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCommandPaletteOpen(next: boolean): void {
|
||||||
|
if (paletteOpen === next) return;
|
||||||
|
paletteOpen = next;
|
||||||
|
emitPalette();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCommandPalette(): void {
|
||||||
|
setCommandPaletteOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleCommandPalette(): void {
|
||||||
|
setCommandPaletteOpen(!paletteOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribePalette(callback: () => void): () => void {
|
||||||
|
paletteListeners.add(callback);
|
||||||
|
return () => paletteListeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPaletteSnapshot(): boolean {
|
||||||
|
return paletteOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSR 快照恒为 false(命令面板永不在服务端渲染开态),避免水合不一致。
|
||||||
|
export function useCommandPaletteOpen(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribePalette,
|
||||||
|
getPaletteSnapshot,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIST_ID = "command-list";
|
||||||
|
const optionId = (cmd: Command): string => `command-option-${cmd.id}`;
|
||||||
|
|
||||||
// 从 pathname 抽取当前 projectId(/projects/<id>/...)。
|
// 从 pathname 抽取当前 projectId(/projects/<id>/...)。
|
||||||
function projectIdFromPath(pathname: string): string | null {
|
function projectIdFromPath(pathname: string): string | null {
|
||||||
@@ -25,14 +77,17 @@ interface CommandPaletteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。
|
// 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。
|
||||||
// 焦点管理(打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11y(role=dialog/listbox)。
|
// WAI-ARIA combobox/listbox:input role=combobox(aria-expanded/controls/activedescendant),
|
||||||
|
// 列表 role=listbox,每项稳定 id + role=option。焦点管理:打开聚焦输入框、关闭归还触发元素。
|
||||||
export function CommandPalette({ pathname }: CommandPaletteProps) {
|
export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const open = useCommandPaletteOpen();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [highlight, setHighlight] = useState(0);
|
const [highlight, setHighlight] = useState(0);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
// 打开前的焦点元素:关闭时归还焦点(WCAG 2.4.3)。
|
||||||
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
// 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。
|
// 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。
|
||||||
const [tools, setTools] = useState<ToolCommandInfo[]>([]);
|
const [tools, setTools] = useState<ToolCommandInfo[]>([]);
|
||||||
const toolsLoadedRef = useRef(false);
|
const toolsLoadedRef = useRef(false);
|
||||||
@@ -51,7 +106,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const close = useCallback((): void => {
|
const close = useCallback((): void => {
|
||||||
setOpen(false);
|
setCommandPaletteOpen(false);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setHighlight(0);
|
setHighlight(0);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -65,21 +120,29 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
|||||||
[close, router],
|
[close, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useBodyScrollLock(open);
|
||||||
|
|
||||||
// ⌘K / Ctrl+K 全局开关。
|
// ⌘K / Ctrl+K 全局开关。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen((prev) => !prev);
|
toggleCommandPalette();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 打开时聚焦输入框;切查询重置高亮。
|
// 打开时记住先前焦点并聚焦输入框;关闭后把焦点归还触发元素。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) inputRef.current?.focus();
|
if (open) {
|
||||||
|
restoreFocusRef.current = document.activeElement as HTMLElement | null;
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreFocusRef.current?.focus();
|
||||||
|
restoreFocusRef.current = null;
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
|
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
|
||||||
@@ -110,6 +173,8 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
|||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
const activeCmd = results[highlight];
|
||||||
|
|
||||||
const onInputKey = (e: React.KeyboardEvent): void => {
|
const onInputKey = (e: React.KeyboardEvent): void => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -128,7 +193,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/30 pt-[15vh] motion-safe:transition-opacity"
|
className={`${overlayScrim} flex items-start justify-center pt-[15vh] motion-safe:transition-opacity`}
|
||||||
onClick={close}
|
onClick={close}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -136,7 +201,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="命令面板"
|
aria-label="命令面板"
|
||||||
className="w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-paper"
|
className="w-full max-w-lg overflow-hidden rounded border border-line bg-panel shadow-paper"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// focus trap:Tab/Shift+Tab 在对话框内循环,不逃逸到背景(WCAG 2.1.2)。
|
// focus trap:Tab/Shift+Tab 在对话框内循环,不逃逸到背景(WCAG 2.1.2)。
|
||||||
@@ -149,15 +214,19 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={onInputKey}
|
onKeyDown={onInputKey}
|
||||||
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
|
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
|
||||||
|
role="combobox"
|
||||||
aria-label="命令搜索"
|
aria-label="命令搜索"
|
||||||
aria-controls="command-list"
|
aria-expanded={true}
|
||||||
className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none"
|
aria-controls={LIST_ID}
|
||||||
|
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
className={`w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink ${focusRing}`}
|
||||||
/>
|
/>
|
||||||
<ul
|
<ul
|
||||||
id="command-list"
|
id={LIST_ID}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="命令列表"
|
aria-label="命令列表"
|
||||||
className="max-h-80 overflow-auto py-1"
|
className="max-h-80 overflow-auto py-1 overscroll-contain"
|
||||||
>
|
>
|
||||||
{results.length === 0 ? (
|
{results.length === 0 ? (
|
||||||
<li className="px-4 py-3 text-sm text-ink-soft">无匹配命令</li>
|
<li className="px-4 py-3 text-sm text-ink-soft">无匹配命令</li>
|
||||||
@@ -165,6 +234,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
|||||||
results.map((cmd, i) => (
|
results.map((cmd, i) => (
|
||||||
<li
|
<li
|
||||||
key={cmd.id}
|
key={cmd.id}
|
||||||
|
id={optionId(cmd)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === highlight}
|
aria-selected={i === highlight}
|
||||||
onMouseEnter={() => setHighlight(i)}
|
onMouseEnter={() => setHighlight(i)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Search } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import { CommandPalette } from "./CommandPalette";
|
import { buttonClass } from "@/lib/ui/variants";
|
||||||
|
import { CommandPalette, openCommandPalette } from "./CommandPalette";
|
||||||
|
|
||||||
// 全局挂载命令面板(⌘K):读当前 pathname 注入项目上下文。
|
// 全局挂载命令面板(⌘K):读当前 pathname 注入项目上下文。
|
||||||
// 放在 RootLayout 内,对所有页面生效。
|
// 放在 RootLayout 内,对所有页面生效。
|
||||||
@@ -10,3 +12,27 @@ export function CommandPaletteMount() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return <CommandPalette pathname={pathname ?? "/"} />;
|
return <CommandPalette pathname={pathname ?? "/"} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 命令面板的可见入口(顶栏常驻):让不知道 ⌘K 的用户也能发现搜索。
|
||||||
|
// 与 ⌘K 触发同一外部 store,移动端折叠为图标。
|
||||||
|
export function CommandSearchButton() {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openCommandPalette()}
|
||||||
|
aria-label="搜索命令或页面"
|
||||||
|
title="搜索命令或页面(⌘K)"
|
||||||
|
className={buttonClass({
|
||||||
|
variant: "ghost",
|
||||||
|
size: "sm",
|
||||||
|
className: "border-transparent",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<span className="hidden sm:inline">搜索</span>
|
||||||
|
<kbd className="hidden rounded border border-line bg-bg px-1 font-mono text-2xs text-ink-soft sm:inline">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Route,
|
Route,
|
||||||
Save,
|
Save,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
@@ -49,12 +50,35 @@ interface TestResult {
|
|||||||
|
|
||||||
type SettingsSection = "routing" | "oauth" | "keys";
|
type SettingsSection = "routing" | "oauth" | "keys";
|
||||||
|
|
||||||
const SECTION_OPTIONS: Array<{ value: SettingsSection; label: string }> = [
|
// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。
|
||||||
{ value: "routing", label: "路由" },
|
const SETTINGS_SECTIONS: Array<{
|
||||||
{ value: "oauth", label: "OAuth" },
|
value: SettingsSection;
|
||||||
{ value: "keys", label: "API Key" },
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}> = [
|
||||||
|
{ value: "routing", label: "档位路由", icon: Route },
|
||||||
|
{ value: "oauth", label: "OAuth", icon: PlugZap },
|
||||||
|
{ value: "keys", label: "API Key", icon: KeyRound },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SECTION_OPTIONS = SETTINGS_SECTIONS.map(({ value, label }) => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 档位路由「模型」输入框的候选 model id(datalist 建议,仍可自由输入)。按 provider 分组。
|
||||||
|
const MODEL_SUGGESTIONS: Record<string, readonly string[]> = {
|
||||||
|
anthropic: ["claude-opus-4", "claude-sonnet-4", "claude-3-5-haiku"],
|
||||||
|
deepseek: ["deepseek-chat", "deepseek-reasoner"],
|
||||||
|
kimi: ["moonshot-v1-128k", "moonshot-v1-32k", "kimi-k2"],
|
||||||
|
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
|
||||||
|
qwen: ["qwen-max", "qwen-plus", "qwen-turbo"],
|
||||||
|
glm: ["glm-4-plus", "glm-4-air", "glm-4-flash"],
|
||||||
|
gemini: ["gemini-2.0-flash", "gemini-1.5-pro"],
|
||||||
|
"kimi-code-key": ["kimi-for-coding"],
|
||||||
|
"kimi-code": ["kimi-for-coding"],
|
||||||
|
};
|
||||||
|
|
||||||
// 设置页主体(UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
|
// 设置页主体(UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
|
||||||
export function ProvidersSettings({
|
export function ProvidersSettings({
|
||||||
initial,
|
initial,
|
||||||
@@ -76,6 +100,16 @@ export function ProvidersSettings({
|
|||||||
const maskedFor = (id: string): string | null =>
|
const maskedFor = (id: string): string | null =>
|
||||||
providers.find((p) => p.provider === id)?.masked_key ?? null;
|
providers.find((p) => p.provider === id)?.masked_key ?? null;
|
||||||
|
|
||||||
|
const sectionDetail = (value: SettingsSection): string => {
|
||||||
|
if (value === "routing") {
|
||||||
|
return `${routing.filter((r) => r.provider && r.model).length}/3 已配置`;
|
||||||
|
}
|
||||||
|
if (value === "oauth") {
|
||||||
|
return kimiOauth.connected ? "Kimi 已连接" : "未连接";
|
||||||
|
}
|
||||||
|
return `${providers.length} 个凭据`;
|
||||||
|
};
|
||||||
|
|
||||||
const updateRouting = (tier: string, providerId: string): void => {
|
const updateRouting = (tier: string, providerId: string): void => {
|
||||||
setRouting((prev) =>
|
setRouting((prev) =>
|
||||||
prev.map((d) =>
|
prev.map((d) =>
|
||||||
@@ -163,28 +197,17 @@ export function ProvidersSettings({
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
|
||||||
<aside className="hidden lg:block">
|
<aside className="hidden lg:block">
|
||||||
<nav aria-label="设置分组" className={cardClass("sticky top-4 p-2")}>
|
<nav aria-label="设置分组" className={cardClass("sticky top-20 p-2")}>
|
||||||
<SettingsNavButton
|
{SETTINGS_SECTIONS.map((section) => (
|
||||||
active={activeSection === "routing"}
|
<SettingsNavButton
|
||||||
icon={<Route className="h-4 w-4" aria-hidden="true" />}
|
key={section.value}
|
||||||
label="档位路由"
|
active={activeSection === section.value}
|
||||||
detail={`${routing.filter((r) => r.provider && r.model).length}/3 已配置`}
|
icon={<section.icon className="h-4 w-4" aria-hidden="true" />}
|
||||||
onClick={() => setActiveSection("routing")}
|
label={section.label}
|
||||||
/>
|
detail={sectionDetail(section.value)}
|
||||||
<SettingsNavButton
|
onClick={() => setActiveSection(section.value)}
|
||||||
active={activeSection === "oauth"}
|
/>
|
||||||
icon={<PlugZap className="h-4 w-4" aria-hidden="true" />}
|
))}
|
||||||
label="OAuth"
|
|
||||||
detail={kimiOauth.connected ? "Kimi 已连接" : "未连接"}
|
|
||||||
onClick={() => setActiveSection("oauth")}
|
|
||||||
/>
|
|
||||||
<SettingsNavButton
|
|
||||||
active={activeSection === "keys"}
|
|
||||||
icon={<KeyRound className="h-4 w-4" aria-hidden="true" />}
|
|
||||||
label="API Key"
|
|
||||||
detail={`${providers.length} 个凭据`}
|
|
||||||
onClick={() => setActiveSection("keys")}
|
|
||||||
/>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -236,15 +259,23 @@ export function ProvidersSettings({
|
|||||||
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
|
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
|
||||||
{TIER_LABELS[row.tier] ?? row.tier} 模型
|
{TIER_LABELS[row.tier] ?? row.tier} 模型
|
||||||
</label>
|
</label>
|
||||||
<TextInput
|
<div className="min-w-0">
|
||||||
id={`route-model-${row.tier}`}
|
<TextInput
|
||||||
value={row.model}
|
id={`route-model-${row.tier}`}
|
||||||
onChange={(e) =>
|
value={row.model}
|
||||||
updateRoutingModel(row.tier, e.target.value)
|
onChange={(e) =>
|
||||||
}
|
updateRoutingModel(row.tier, e.target.value)
|
||||||
placeholder="model"
|
}
|
||||||
className="font-mono"
|
placeholder="model"
|
||||||
/>
|
className="w-full font-mono"
|
||||||
|
list={`route-models-${row.tier}`}
|
||||||
|
/>
|
||||||
|
<datalist id={`route-models-${row.tier}`}>
|
||||||
|
{(MODEL_SUGGESTIONS[row.provider] ?? []).map((m) => (
|
||||||
|
<option key={m} value={m} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -445,16 +476,13 @@ function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{badges.map((b) => (
|
{badges.map((b) => (
|
||||||
<span
|
<Badge
|
||||||
key={b.label}
|
key={b.label}
|
||||||
className={`rounded border px-2 py-0.5 text-[11px] ${
|
variant={b.on ? "accent" : "neutral"}
|
||||||
b.on
|
className="text-2xs"
|
||||||
? "border-cinnabar/20 bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
|
||||||
: "border-line bg-bg text-ink-soft/50"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{b.label}
|
{b.label}
|
||||||
</span>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user