diff --git a/apps/web/components/AiToolbar.tsx b/apps/web/components/AiToolbar.tsx index 2851ff2..fc42561 100644 --- a/apps/web/components/AiToolbar.tsx +++ b/apps/web/components/AiToolbar.tsx @@ -30,7 +30,7 @@ export function AiToolbar({ projectId, activeNav }: AiToolbarProps) { return ( ) : null} ); diff --git a/apps/web/components/AppShell.tsx b/apps/web/components/AppShell.tsx index 83ba601..81ef7cb 100644 --- a/apps/web/components/AppShell.tsx +++ b/apps/web/components/AppShell.tsx @@ -3,8 +3,9 @@ import type { ReactNode } from "react"; import { Settings } from "lucide-react"; 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 { CommandSearchButton } from "./command/CommandPaletteMount"; import { LeftNav } from "./LeftNav"; import { NavDrawer } from "./NavDrawer"; import { ThemeToggle } from "./ThemeToggle"; @@ -39,11 +40,11 @@ export function AppShell({ > 跳到正文 -
+
墨痕 @@ -62,6 +63,7 @@ export function AppShell({ aria-label="快捷操作" className="ml-auto flex shrink-0 items-center gap-2 text-sm" > + (null); + const closeRef = useRef(null); const wasOpenRef = useRef(false); + useBodyScrollLock(open); + useEffect(() => { if (!open) { if (wasOpenRef.current) { @@ -44,7 +51,8 @@ export function Drawer({ } }; window.addEventListener("keydown", onKey); - panelRef.current?.focus(); + // 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。 + closeRef.current?.focus(); return () => window.removeEventListener("keydown", onKey); }, [open, onClose, triggerRef]); @@ -53,10 +61,9 @@ export function Drawer({ const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l"; return ( -
+
+
+ +
{children}
diff --git a/apps/web/components/LeftNav.tsx b/apps/web/components/LeftNav.tsx index e136836..e506b4a 100644 --- a/apps/web/components/LeftNav.tsx +++ b/apps/web/components/LeftNav.tsx @@ -11,7 +11,7 @@ interface LeftNavProps { export function LeftNav({ projectId, activeNav }: LeftNavProps) { return (
,避免夜读首帧渲染错误图标。 +// SSR 守卫:服务端无 document → 回落默认(与 ThemeScript 的默认一致)。 +function initialThemeMode(): ThemeMode { + if (typeof document === "undefined") return DEFAULT_THEME_MODE; + return normalizeThemeMode(document.documentElement.dataset.theme); +} + function writeStoredThemeMode(mode: ThemeMode) { try { window.localStorage.setItem(THEME_STORAGE_KEY, mode); @@ -35,9 +42,10 @@ function writeStoredThemeMode(mode: ThemeMode) { } export function ThemeToggle() { - const [mode, setMode] = useState(DEFAULT_THEME_MODE); + const [mode, setMode] = useState(initialThemeMode); useEffect(() => { + // 水合后以 localStorage 为权威源校正(dataset 已由 ThemeScript 同样依据写入,通常一致)。 const initial = readStoredThemeMode(); setMode(initial); applyThemeMode(initial); diff --git a/apps/web/components/command/CommandPalette.tsx b/apps/web/components/command/CommandPalette.tsx index d4fe8a3..028b148 100644 --- a/apps/web/components/command/CommandPalette.tsx +++ b/apps/web/components/command/CommandPalette.tsx @@ -1,6 +1,12 @@ "use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { useRouter } from "next/navigation"; import { @@ -13,6 +19,52 @@ import { } from "@/lib/command/palette"; import { handleTabTrap } from "@/lib/a11y/focusTrap"; 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//...)。 function projectIdFromPath(pathname: string): string | null { @@ -25,14 +77,17 @@ interface CommandPaletteProps { } // 命令面板(⌘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) { const router = useRouter(); - const [open, setOpen] = useState(false); + const open = useCommandPaletteOpen(); const [query, setQuery] = useState(""); const [highlight, setHighlight] = useState(0); const inputRef = useRef(null); const dialogRef = useRef(null); + // 打开前的焦点元素:关闭时归还焦点(WCAG 2.4.3)。 + const restoreFocusRef = useRef(null); // 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。 const [tools, setTools] = useState([]); const toolsLoadedRef = useRef(false); @@ -51,7 +106,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) { ); const close = useCallback((): void => { - setOpen(false); + setCommandPaletteOpen(false); setQuery(""); setHighlight(0); }, []); @@ -65,21 +120,29 @@ export function CommandPalette({ pathname }: CommandPaletteProps) { [close, router], ); + useBodyScrollLock(open); + // ⌘K / Ctrl+K 全局开关。 useEffect(() => { const onKey = (e: KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); - setOpen((prev) => !prev); + toggleCommandPalette(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); - // 打开时聚焦输入框;切查询重置高亮。 + // 打开时记住先前焦点并聚焦输入框;关闭后把焦点归还触发元素。 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]); // 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen- 命令。 @@ -110,6 +173,8 @@ export function CommandPalette({ pathname }: CommandPaletteProps) { if (!open) return null; + const activeCmd = results[highlight]; + const onInputKey = (e: React.KeyboardEvent): void => { if (e.key === "Escape") { e.preventDefault(); @@ -128,7 +193,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) { return (
e.stopPropagation()} onKeyDown={(e) => { // focus trap:Tab/Shift+Tab 在对话框内循环,不逃逸到背景(WCAG 2.1.2)。 @@ -149,15 +214,19 @@ export function CommandPalette({ pathname }: CommandPaletteProps) { onChange={(e) => setQuery(e.target.value)} onKeyDown={onInputKey} placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)" + role="combobox" aria-label="命令搜索" - aria-controls="command-list" - className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none" + aria-expanded={true} + 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}`} />
    {results.length === 0 ? (
  • 无匹配命令
  • @@ -165,6 +234,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) { results.map((cmd, i) => (
  • setHighlight(i)} diff --git a/apps/web/components/command/CommandPaletteMount.tsx b/apps/web/components/command/CommandPaletteMount.tsx index 986efd2..ef55004 100644 --- a/apps/web/components/command/CommandPaletteMount.tsx +++ b/apps/web/components/command/CommandPaletteMount.tsx @@ -1,8 +1,10 @@ "use client"; +import { Search } from "lucide-react"; import { usePathname } from "next/navigation"; -import { CommandPalette } from "./CommandPalette"; +import { buttonClass } from "@/lib/ui/variants"; +import { CommandPalette, openCommandPalette } from "./CommandPalette"; // 全局挂载命令面板(⌘K):读当前 pathname 注入项目上下文。 // 放在 RootLayout 内,对所有页面生效。 @@ -10,3 +12,27 @@ export function CommandPaletteMount() { const pathname = usePathname(); return ; } + +// 命令面板的可见入口(顶栏常驻):让不知道 ⌘K 的用户也能发现搜索。 +// 与 ⌘K 触发同一外部 store,移动端折叠为图标。 +export function CommandSearchButton() { + return ( + + ); +} diff --git a/apps/web/components/settings/ProvidersSettings.tsx b/apps/web/components/settings/ProvidersSettings.tsx index 4c3a56f..4e43e72 100644 --- a/apps/web/components/settings/ProvidersSettings.tsx +++ b/apps/web/components/settings/ProvidersSettings.tsx @@ -7,6 +7,7 @@ import { Route, Save, XCircle, + type LucideIcon, } from "lucide-react"; import { useState, type ReactNode } from "react"; @@ -49,12 +50,35 @@ interface TestResult { type SettingsSection = "routing" | "oauth" | "keys"; -const SECTION_OPTIONS: Array<{ value: SettingsSection; label: string }> = [ - { value: "routing", label: "路由" }, - { value: "oauth", label: "OAuth" }, - { value: "keys", label: "API Key" }, +// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。 +const SETTINGS_SECTIONS: Array<{ + value: SettingsSection; + 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 = { + 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 连接区。 export function ProvidersSettings({ initial, @@ -76,6 +100,16 @@ export function ProvidersSettings({ const maskedFor = (id: string): string | 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 => { setRouting((prev) => prev.map((d) => @@ -163,28 +197,17 @@ export function ProvidersSettings({ return (
    @@ -236,15 +259,23 @@ export function ProvidersSettings({ - - updateRoutingModel(row.tier, e.target.value) - } - placeholder="model" - className="font-mono" - /> +
    + + updateRoutingModel(row.tier, e.target.value) + } + placeholder="model" + className="w-full font-mono" + list={`route-models-${row.tier}`} + /> + + {(MODEL_SUGGESTIONS[row.provider] ?? []).map((m) => ( + +
  • ))}
@@ -445,16 +476,13 @@ function CapabilityBadges({ caps }: { caps: CapabilitiesView }) { return (
{badges.map((b) => ( - {b.label} - + ))}
);