Explorar el Código

feat: add sidebar nav ordering and visibility settings

Mochocyang hace 2 meses
padre
commit
683d06ceb0

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "llm-wiki",
-  "version": "2.2.24",
+  "version": "2.2.30",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "llm-wiki",
-      "version": "2.2.24",
+      "version": "2.2.30",
       "dependencies": {
         "@base-ui/react": "^1.3.0",
         "@dnd-kit/core": "^6.3.1",

+ 1 - 1
package.json

@@ -1,7 +1,7 @@
 {
   "name": "llm-wiki",
   "private": true,
-  "version": "2.2.29",
+  "version": "2.2.30",
   "type": "module",
   "scripts": {
     "dev": "vite",

+ 1 - 1
src-tauri/Cargo.lock

@@ -4442,7 +4442,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
 
 [[package]]
 name = "llm-wiki"
-version = "2.2.29"
+version = "2.2.30"
 dependencies = [
  "arrow-array",
  "arrow-schema",

+ 1 - 1
src-tauri/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "llm-wiki"
-version = "2.2.29"
+version = "2.2.30"
 description = "LLM Wiki - A personal knowledge base for LLM concepts"
 authors = []
 edition = "2021"

+ 2 - 2
src-tauri/tauri.conf.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://schema.tauri.app/config/2",
   "productName": "QMaiWrite",
-  "version": "2.2.29",
+  "version": "2.2.30",
   "identifier": "com.qingmuai.writer",
   "build": {
     "beforeDevCommand": "npm run dev",
@@ -49,4 +49,4 @@
       ]
     }
   }
-}
+}

+ 146 - 79
src/components/layout/icon-sidebar.tsx

@@ -1,8 +1,22 @@
-import { useState, useRef, useEffect } from "react"
+import { useState, useRef, useEffect, useMemo } from "react"
 import {
   FileText, FolderOpen, Search, Network, Brain, Settings, ArrowLeftRight, Sun, Moon, Eye, SunMoon, Check, Trash2, Sparkles, LayoutDashboard, BookOpen, Drama,
 } from "lucide-react"
 import { createPortal } from "react-dom"
+import {
+  DndContext,
+  PointerSensor,
+  closestCenter,
+  useSensor,
+  useSensors,
+  type DragEndEvent,
+} from "@dnd-kit/core"
+import {
+  SortableContext,
+  useSortable,
+  verticalListSortingStrategy,
+} from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useReviewStore } from "@/stores/review-store"
@@ -11,24 +25,32 @@ import logoImg from "@/assets/QM-LOGO.png"
 import type { WikiState } from "@/stores/wiki-store"
 import { saveTheme } from "@/lib/project-store"
 import { applyTheme, type ThemeMode } from "@/lib/theme-utils"
+import {
+  isSidebarNavItemId,
+  reorderSidebarNavOrder,
+  type SidebarNavItemId,
+} from "@/lib/sidebar-nav-preferences"
 
 type NavView = WikiState["activeView"]
 
-const SEARCH_NAV_ITEM: { view: NavView; icon: typeof FileText; labelKey: string } = {
-  view: "search",
-  icon: Search,
-  labelKey: "novel.nav.search",
+interface ConfigurableNavItem {
+  id: SidebarNavItemId
+  view: NavView
+  icon: typeof FileText
+  labelKey: string
 }
 
-const NAV_ITEMS: { view: NavView; icon: typeof FileText; labelKey: string }[] = [
-  { view: "wiki", icon: FileText, labelKey: "novel.nav.wiki" },
-  { view: "sources", icon: FolderOpen, labelKey: "novel.nav.sources" },
-  { view: "graph", icon: Network, labelKey: "novel.nav.graph" },
-  { view: "lint", icon: Brain, labelKey: "novel.nav.lint" },
-  { view: "soul", icon: Sparkles, labelKey: "novel.nav.soul" },
-  { view: "bookAnalysis", icon: BookOpen, labelKey: "novel.nav.dismantling" },
-  { view: "reviewCenter", icon: LayoutDashboard, labelKey: "novel.nav.reviewCenter" },
-  { view: "storySimulation", icon: Drama, labelKey: "novel.nav.storySimulation" },
+const CONFIGURABLE_NAV_ITEMS: ConfigurableNavItem[] = [
+  { id: "wiki", view: "wiki", icon: FileText, labelKey: "novel.nav.wiki" },
+  { id: "sources", view: "sources", icon: FolderOpen, labelKey: "novel.nav.sources" },
+  { id: "graph", view: "graph", icon: Network, labelKey: "novel.nav.graph" },
+  { id: "lint", view: "lint", icon: Brain, labelKey: "novel.nav.lint" },
+  { id: "soul", view: "soul", icon: Sparkles, labelKey: "novel.nav.soul" },
+  { id: "bookAnalysis", view: "bookAnalysis", icon: BookOpen, labelKey: "novel.nav.dismantling" },
+  { id: "reviewCenter", view: "reviewCenter", icon: LayoutDashboard, labelKey: "novel.nav.reviewCenter" },
+  { id: "storySimulation", view: "storySimulation", icon: Drama, labelKey: "novel.nav.storySimulation" },
+  { id: "search", view: "search", icon: Search, labelKey: "novel.nav.search" },
+  { id: "trash", view: "trash", icon: Trash2, labelKey: "nav.trash" },
 ]
 
 interface IconSidebarProps {
@@ -44,6 +66,59 @@ const THEME_OPTIONS: { value: ThemeMode; icon: typeof Sun; labelKey: string }[]
   { value: "system", icon: SunMoon, labelKey: "theme.system" },
 ]
 
+interface SortableNavButtonProps {
+  item: ConfigurableNavItem
+  activeView: NavView
+  pendingCount: number
+  label: string
+  onClick: (item: ConfigurableNavItem) => void
+}
+
+function SortableNavButton({ item, activeView, pendingCount, label, onClick }: SortableNavButtonProps) {
+  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id })
+  const Icon = item.icon
+  const isActive = activeView === item.view
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+  }
+
+  return (
+    <Tooltip>
+      <TooltipTrigger
+        ref={setNodeRef}
+        type="button"
+        onClick={() => onClick(item)}
+        className={`relative flex h-10 w-10 touch-none items-center justify-center rounded-md transition-colors ${
+          isActive
+            ? "qm-selected"
+            : "text-muted-foreground qm-hover"
+        } ${isDragging ? "z-10 opacity-80 shadow-sm" : ""}`}
+        style={style}
+        {...attributes}
+        {...listeners}
+      >
+        <Icon className="h-5 w-5" />
+        {item.view === "reviewCenter" && pendingCount > 0 && (
+          <span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground">
+            {pendingCount > 99 ? "99+" : pendingCount}
+          </span>
+        )}
+        {item.view === "storySimulation" && (
+          <span className="absolute -right-1 -top-0.5 flex h-3.5 items-center justify-center rounded bg-amber-500 px-1 text-[9px] font-bold leading-none text-white">
+            BETA
+          </span>
+        )}
+      </TooltipTrigger>
+      <TooltipContent side="right">
+        {label}
+        {item.view === "reviewCenter" && pendingCount > 0 && ` (${pendingCount})`}
+        {item.view === "storySimulation" && " (测试版)"}
+      </TooltipContent>
+    </Tooltip>
+  )
+}
+
 export function IconSidebar({ onToggleSidebar, onOpenSidebar, onSwitchProject }: IconSidebarProps) {
   const { t } = useTranslation()
   const activeView = useWikiStore((s) => s.activeView)
@@ -53,12 +128,31 @@ export function IconSidebar({ onToggleSidebar, onOpenSidebar, onSwitchProject }:
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
   const theme = useWikiStore((s) => s.theme)
   const setTheme = useWikiStore((s) => s.setTheme)
+  const sidebarNavConfig = useWikiStore((s) => s.sidebarNavConfig)
+  const setSidebarNavConfig = useWikiStore((s) => s.setSidebarNavConfig)
   const pendingCount = useReviewStore((s) => s.items.filter((i) => !i.resolved).length)
 
   // 主题下拉框状态
   const [themeMenuOpen, setThemeMenuOpen] = useState(false)
   const themeTriggerRef = useRef<HTMLButtonElement>(null)
   const [themeMenuStyle, setThemeMenuStyle] = useState<{ left: number; top: number } | null>(null)
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: { distance: 5 },
+    }),
+  )
+  const navItemsById = useMemo(
+    () => new Map(CONFIGURABLE_NAV_ITEMS.map((item) => [item.id, item])),
+    [],
+  )
+  const hiddenNavIds = useMemo(
+    () => new Set(sidebarNavConfig.hidden),
+    [sidebarNavConfig.hidden],
+  )
+  const visibleNavItems = sidebarNavConfig.order
+    .map((id) => navItemsById.get(id))
+    .filter((item): item is ConfigurableNavItem => item !== undefined && !hiddenNavIds.has(item.id))
+  const visibleNavIds = visibleNavItems.map((item) => item.id)
 
   const getThemeIcon = () => {
     const option = THEME_OPTIONS.find((o) => o.value === theme)
@@ -119,9 +213,23 @@ export function IconSidebar({ onToggleSidebar, onOpenSidebar, onSwitchProject }:
     setActiveView(view)
   }
 
-  const handleSearchClick = () => {
-    setSearchPanelOpen(false)
-    setActiveView("search")
+  const handleConfigurableNavClick = (item: ConfigurableNavItem) => {
+    handleNavClick(item.view)
+    if (item.view === "trash") {
+      onOpenSidebar?.()
+    }
+  }
+
+  const handleDragEnd = (event: DragEndEvent) => {
+    const { active, over } = event
+    if (!over || active.id === over.id) return
+    const activeId = String(active.id)
+    const overId = String(over.id)
+    if (!isSidebarNavItemId(activeId) || !isSidebarNavItemId(overId)) return
+    setSidebarNavConfig({
+      ...sidebarNavConfig,
+      order: reorderSidebarNavOrder(sidebarNavConfig.order, activeId, overId),
+    })
   }
 
   return (
@@ -139,69 +247,28 @@ export function IconSidebar({ onToggleSidebar, onOpenSidebar, onSwitchProject }:
             className="h-6 w-6 rounded-[22%]"
           />
         </button>
-        {/* Top: main nav items */}
+        {/* Top: configurable feature entries */}
         <div className="flex flex-1 flex-col items-center gap-1">
-          {NAV_ITEMS.map(({ view, icon: Icon, labelKey }) => (
-            <Tooltip key={view}>
-              <TooltipTrigger
-                onClick={() => handleNavClick(view)}
-                className={`relative flex h-10 w-10 items-center justify-center rounded-md transition-colors ${
-                  activeView === view
-                    ? "qm-selected"
-                    : "text-muted-foreground qm-hover"
-                }`}
-              >
-                <Icon className="h-5 w-5" />
-                {view === "reviewCenter" && pendingCount > 0 && (
-                  <span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground">
-                    {pendingCount > 99 ? "99+" : pendingCount}
-                  </span>
-                )}
-                {view === "storySimulation" && (
-                  <span className="absolute -right-1 -top-0.5 flex h-3.5 items-center justify-center rounded bg-amber-500 px-1 text-[9px] font-bold leading-none text-white">
-                    BETA
-                  </span>
-                )}
-              </TooltipTrigger>
-              <TooltipContent side="right">
-                {t(labelKey)}
-                {view === "reviewCenter" && pendingCount > 0 && ` (${pendingCount})`}
-                {view === "storySimulation" && " (测试版)"}
-              </TooltipContent>
-            </Tooltip>
-          ))}
-          <Tooltip>
-            <TooltipTrigger
-              onClick={handleSearchClick}
-              className={`relative flex h-10 w-10 items-center justify-center rounded-md transition-colors ${
-                activeView === "search"
-                  ? "qm-selected"
-                  : "text-muted-foreground qm-hover"
-              }`}
-            >
-              <Search className="h-5 w-5" />
-            </TooltipTrigger>
-            <TooltipContent side="right">
-              {t(SEARCH_NAV_ITEM.labelKey)}
-            </TooltipContent>
-          </Tooltip>
-          <Tooltip>
-            <TooltipTrigger
-              onClick={() => {
-                setSearchPanelOpen(false)
-                setActiveView("trash")
-                onOpenSidebar?.()
-              }}
-              className={`relative flex h-10 w-10 items-center justify-center rounded-md transition-colors ${
-                activeView === "trash"
-                  ? "qm-selected"
-                  : "text-muted-foreground qm-hover"
-              }`}
-            >
-              <Trash2 className="h-5 w-5" />
-            </TooltipTrigger>
-            <TooltipContent side="right">{t("nav.trash")}</TooltipContent>
-          </Tooltip>
+          <DndContext
+            sensors={sensors}
+            collisionDetection={closestCenter}
+            onDragEnd={handleDragEnd}
+          >
+            <SortableContext items={visibleNavIds} strategy={verticalListSortingStrategy}>
+              <div className="flex flex-col items-center gap-1">
+                {visibleNavItems.map((item) => (
+                  <SortableNavButton
+                    key={item.id}
+                    item={item}
+                    activeView={activeView}
+                    pendingCount={pendingCount}
+                    label={t(item.labelKey)}
+                    onClick={handleConfigurableNavClick}
+                  />
+                ))}
+              </div>
+            </SortableContext>
+          </DndContext>
         </div>
         {/* Bottom: daemon status + theme toggle + settings + switch project */}
         <div className="flex flex-col items-center gap-1 pb-1">

+ 25 - 0
src/components/settings/interface-sidebar-nav.spec.ts

@@ -0,0 +1,25 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+import zh from "@/i18n/zh.json"
+
+const interfaceSectionSource = readFileSync(resolve(__dirname, "sections/interface-section.tsx"), "utf8")
+const settingsTypesSource = readFileSync(resolve(__dirname, "settings-types.ts"), "utf8")
+const settingsViewSource = readFileSync(resolve(__dirname, "settings-view.tsx"), "utf8")
+
+describe("settings sidebar nav preferences", () => {
+  it("stores sidebar nav config in the settings draft and saves it through the wiki store", () => {
+    expect(settingsTypesSource).toContain("sidebarNavConfig")
+    expect(settingsViewSource).toContain("const sidebarNavConfig = useWikiStore((s) => s.sidebarNavConfig)")
+    expect(settingsViewSource).toContain("const setSidebarNavConfig = useWikiStore((s) => s.setSidebarNavConfig)")
+    expect(settingsViewSource).toContain("setSidebarNavConfig(draft.sidebarNavConfig)")
+  })
+
+  it("renders a Chinese sidebar feature visibility section in interface settings", () => {
+    expect(interfaceSectionSource).toContain("SIDEBAR_NAV_LABEL_KEYS")
+    expect(interfaceSectionSource).toContain('setDraft("sidebarNavConfig"')
+    expect(interfaceSectionSource).toContain('type="checkbox"')
+    expect(zh.settings.sections.interface.sidebarNavTitle).toBe("左侧功能栏")
+    expect(zh.settings.sections.interface.sidebarNavDescription).toBe("勾选要在左侧显示的功能,取消勾选后该功能入口会隐藏。")
+  })
+})

+ 61 - 0
src/components/settings/sections/interface-section.tsx

@@ -1,6 +1,10 @@
 import { useTranslation } from "react-i18next"
 import { Label } from "@/components/ui/label"
 import type { SettingsDraft, DraftSetter } from "../settings-types"
+import {
+  normalizeSidebarNavConfig,
+  type SidebarNavItemId,
+} from "@/lib/sidebar-nav-preferences"
 
 interface Props {
   draft: SettingsDraft
@@ -19,9 +23,34 @@ const FONT_SIZE_PRESETS = [
   { label: "特大", value: 1.3 },
 ]
 
+const SIDEBAR_NAV_LABEL_KEYS: Record<SidebarNavItemId, string> = {
+  wiki: "novel.nav.wiki",
+  sources: "novel.nav.sources",
+  graph: "novel.nav.graph",
+  lint: "novel.nav.lint",
+  soul: "novel.nav.soul",
+  bookAnalysis: "novel.nav.dismantling",
+  reviewCenter: "novel.nav.reviewCenter",
+  storySimulation: "novel.nav.storySimulation",
+  search: "novel.nav.search",
+  trash: "nav.trash",
+}
+
 export function InterfaceSection({ draft, setDraft }: Props) {
   const { t } = useTranslation()
   const scalePercent = Math.round(draft.uiFontSizeScale * 100)
+  const sidebarNavConfig = normalizeSidebarNavConfig(draft.sidebarNavConfig)
+  const hiddenSidebarNavIds = new Set(sidebarNavConfig.hidden)
+
+  const handleToggleSidebarNavItem = (id: SidebarNavItemId, visible: boolean) => {
+    const hidden = visible
+      ? sidebarNavConfig.hidden.filter((itemId) => itemId !== id)
+      : [...sidebarNavConfig.hidden, id]
+    setDraft("sidebarNavConfig", normalizeSidebarNavConfig({
+      ...sidebarNavConfig,
+      hidden,
+    }))
+  }
 
   return (
     <div className="space-y-6">
@@ -58,6 +87,38 @@ export function InterfaceSection({ draft, setDraft }: Props) {
         </p>
       </div>
 
+      <div className="space-y-3 rounded-lg border p-4">
+        <div>
+          <Label>{t("settings.sections.interface.sidebarNavTitle")}</Label>
+          <p className="mt-1 text-xs text-muted-foreground">
+            {t("settings.sections.interface.sidebarNavDescription")}
+          </p>
+        </div>
+        <div className="grid gap-2">
+          {sidebarNavConfig.order.map((id) => {
+            const visible = !hiddenSidebarNavIds.has(id)
+            return (
+              <label
+                key={id}
+                className="flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-accent/40"
+              >
+                <span className="truncate">{t(SIDEBAR_NAV_LABEL_KEYS[id])}</span>
+                <input
+                  type="checkbox"
+                  checked={visible}
+                  onChange={(e) => handleToggleSidebarNavItem(id, e.target.checked)}
+                  className="h-4 w-4 accent-primary"
+                  aria-label={t(SIDEBAR_NAV_LABEL_KEYS[id])}
+                />
+              </label>
+            )
+          })}
+        </div>
+        <p className="text-xs text-muted-foreground">
+          {t("settings.sections.interface.sidebarNavOrderHint")}
+        </p>
+      </div>
+
       <div className="space-y-3 rounded-lg border p-4">
         <div className="flex items-center justify-between">
           <Label>界面字号</Label>

+ 2 - 0
src/components/settings/settings-types.ts

@@ -1,5 +1,6 @@
 import type { CustomApiMode } from "./llm-presets"
 import type { AzureModelFamily, ReasoningConfig, SourceWatchConfig, RevisionFeedbackWindowConfig, NovelConfig, RerankConfig, OutputLanguage } from "@/stores/wiki-store"
+import type { SidebarNavConfig } from "@/lib/sidebar-nav-preferences"
 
 /**
  * Shape of the draft state each section reads from and writes into.
@@ -65,6 +66,7 @@ export interface SettingsDraft {
   // UI
   uiLanguage: string
   uiFontSizeScale: number
+  sidebarNavConfig: SidebarNavConfig
 
   // Source folder auto watch
   sourceWatchConfig: SourceWatchConfig

+ 10 - 0
src/components/settings/settings-view.tsx

@@ -23,6 +23,7 @@ import { useChatStore } from "@/stores/chat-store"
 import { loadSourceWatchConfig, saveLanguage, loadNovelConfig, loadRerankConfig } from "@/lib/project-store"
 import type { SettingsDraft, DraftSetter } from "./settings-types"
 import { normalizeSourceWatchConfig } from "@/lib/source-watch-config"
+import type { SidebarNavConfig } from "@/lib/sidebar-nav-preferences"
 import { LlmProviderSection } from "./sections/llm-provider-section"
 import { EmbeddingSection } from "./sections/embedding-section"
 import { RerankSection } from "./sections/rerank-section"
@@ -88,6 +89,7 @@ function initialDraft(
   maxHistoryMessages: number,
   uiLanguage: string,
   uiFontSizeScale: number,
+  sidebarNavConfig: SidebarNavConfig,
   projectPath?: string,
 ): SettingsDraft {
   // Show absolute path: if stored path is empty, show default using project path
@@ -145,6 +147,7 @@ function initialDraft(
     novelConfig,
     uiLanguage,
     uiFontSizeScale,
+    sidebarNavConfig,
   }
 }
 
@@ -177,6 +180,8 @@ export function SettingsView() {
   const setMaxHistoryMessages = useChatStore((s) => s.setMaxHistoryMessages)
   const uiFontSizeScale = useWikiStore((s) => s.uiFontSizeScale)
   const setUiFontSizeScale = useWikiStore((s) => s.setUiFontSizeScale)
+  const sidebarNavConfig = useWikiStore((s) => s.sidebarNavConfig)
+  const setSidebarNavConfig = useWikiStore((s) => s.setSidebarNavConfig)
 
   const [active, setActive] = useState<CategoryId>("llm")
   const [saved, setSaved] = useState(false)
@@ -195,6 +200,7 @@ export function SettingsView() {
       maxHistoryMessages,
       i18n.language,
       uiFontSizeScale,
+      sidebarNavConfig,
       project?.path,
     ),
   )
@@ -273,6 +279,7 @@ export function SettingsView() {
         maxHistoryMessages,
         prev.uiLanguage,
         uiFontSizeScale,
+        sidebarNavConfig,
         project?.path,
       ),
     )
@@ -289,6 +296,7 @@ export function SettingsView() {
     novelConfig,
     maxHistoryMessages,
     uiFontSizeScale,
+    sidebarNavConfig,
     project,
   ])
 
@@ -434,6 +442,7 @@ export function SettingsView() {
     await saveMaxHistoryMessages(draft.maxHistoryMessages, project?.id, project?.path)
     setUiFontSizeScale(draft.uiFontSizeScale)
     await saveUiFontSizeScale(draft.uiFontSizeScale, project?.id, project?.path)
+    setSidebarNavConfig(draft.sidebarNavConfig)
 
     if (draft.uiLanguage !== i18n.language) {
       await i18n.changeLanguage(draft.uiLanguage)
@@ -458,6 +467,7 @@ export function SettingsView() {
     setMaxHistoryMessages,
     outputLanguage,
     setUiFontSizeScale,
+    setSidebarNavConfig,
   ])
 
   const body = useMemo(() => {

+ 8 - 5
src/i18n/en.json

@@ -967,11 +967,14 @@
         "historyCurrent": "Currently {{count}} messages (about {{turns}} turns)"
       },
       "interface": {
-        "title": "Interface",
-        "description": "Interface language and visual presentation settings.",
-        "uiLanguage": "UI language",
-        "uiLanguageHint": "Only affects menus, labels, and other interface text. AI output language is configured separately under Output."
-      },
+      "title": "Interface",
+      "description": "Interface language and visual presentation settings.",
+      "uiLanguage": "UI language",
+      "uiLanguageHint": "Only affects menus, labels, and other interface text. AI output language is configured separately under Output.",
+      "sidebarNavTitle": "Left Feature Bar",
+      "sidebarNavDescription": "Select the features shown on the left. Unselected features are hidden from the bar.",
+      "sidebarNavOrderHint": "Long-press and drag icons in the left feature bar to change their order."
+    },
       "dataManagement": {
         "title": "Data Management",
         "description": "Backup and restore all your data, including model configs, AI chats, novel content, outlines, memory, book analysis results, etc.",

+ 8 - 5
src/i18n/zh.json

@@ -727,11 +727,14 @@
         "historyCurrent": "当前 {{count}} 条消息(约 {{turns}} 轮对话)"
       },
       "interface": {
-        "title": "界面",
-        "description": "界面语言和外观样式。切换后立即生效并持久化。",
-        "uiLanguage": "UI 语言",
-        "uiLanguageHint": "只影响按钮、标签这些 UI 文案,不影响 AI 输出语言(那个在\"输出偏好\"里单独设置)。"
-      },
+      "title": "界面",
+      "description": "界面语言和外观样式。切换后立即生效并持久化。",
+      "uiLanguage": "UI 语言",
+      "uiLanguageHint": "只影响按钮、标签这些 UI 文案,不影响 AI 输出语言(那个在\"输出偏好\"里单独设置)。",
+      "sidebarNavTitle": "左侧功能栏",
+      "sidebarNavDescription": "勾选要在左侧显示的功能,取消勾选后该功能入口会隐藏。",
+      "sidebarNavOrderHint": "图标顺序可在左侧功能栏中长按拖动调整。"
+    },
       "novel": {
         "title": "小说",
         "description": "项目级小说写作模式和修改反馈窗口设置。",

+ 59 - 0
src/lib/sidebar-nav-preferences.spec.ts

@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest"
+import {
+  DEFAULT_SIDEBAR_NAV_ORDER,
+  normalizeSidebarNavConfig,
+  reorderSidebarNavOrder,
+  type SidebarNavConfig,
+} from "./sidebar-nav-preferences"
+
+describe("sidebar nav preferences", () => {
+  it("keeps the supported feature entries in the default order", () => {
+    expect(DEFAULT_SIDEBAR_NAV_ORDER).toEqual([
+      "wiki",
+      "sources",
+      "graph",
+      "lint",
+      "soul",
+      "bookAnalysis",
+      "reviewCenter",
+      "storySimulation",
+      "search",
+      "trash",
+    ])
+  })
+
+  it("normalizes persisted order by removing unknown ids, deduping, and appending missing ids", () => {
+    const config = normalizeSidebarNavConfig({
+      order: ["search", "wiki", "unknown", "search", "trash"],
+      hidden: [],
+    } as unknown as SidebarNavConfig)
+
+    expect(config.order).toEqual([
+      "search",
+      "wiki",
+      "trash",
+      "sources",
+      "graph",
+      "lint",
+      "soul",
+      "bookAnalysis",
+      "reviewCenter",
+      "storySimulation",
+    ])
+  })
+
+  it("normalizes hidden entries to known feature ids only", () => {
+    const config = normalizeSidebarNavConfig({
+      order: [...DEFAULT_SIDEBAR_NAV_ORDER],
+      hidden: ["graph", "settings", "trash", "theme"],
+    } as unknown as SidebarNavConfig)
+
+    expect(config.hidden).toEqual(["graph", "trash"])
+  })
+
+  it("moves a feature id relative to another feature id", () => {
+    const order = reorderSidebarNavOrder(DEFAULT_SIDEBAR_NAV_ORDER, "trash", "wiki")
+
+    expect(order.slice(0, 3)).toEqual(["trash", "wiki", "sources"])
+  })
+})

+ 72 - 0
src/lib/sidebar-nav-preferences.ts

@@ -0,0 +1,72 @@
+export const DEFAULT_SIDEBAR_NAV_ORDER = [
+  "wiki",
+  "sources",
+  "graph",
+  "lint",
+  "soul",
+  "bookAnalysis",
+  "reviewCenter",
+  "storySimulation",
+  "search",
+  "trash",
+] as const
+
+export type SidebarNavItemId = (typeof DEFAULT_SIDEBAR_NAV_ORDER)[number]
+
+export interface SidebarNavConfig {
+  order: SidebarNavItemId[]
+  hidden: SidebarNavItemId[]
+}
+
+export const DEFAULT_SIDEBAR_NAV_CONFIG: SidebarNavConfig = {
+  order: [...DEFAULT_SIDEBAR_NAV_ORDER],
+  hidden: [],
+}
+
+const SIDEBAR_NAV_ITEM_IDS = new Set<string>(DEFAULT_SIDEBAR_NAV_ORDER)
+
+export function isSidebarNavItemId(value: string): value is SidebarNavItemId {
+  return SIDEBAR_NAV_ITEM_IDS.has(value)
+}
+
+function normalizeIdList(values: unknown): SidebarNavItemId[] {
+  if (!Array.isArray(values)) return []
+  const result: SidebarNavItemId[] = []
+  for (const value of values) {
+    if (typeof value !== "string" || !isSidebarNavItemId(value)) continue
+    if (!result.includes(value)) {
+      result.push(value)
+    }
+  }
+  return result
+}
+
+export function normalizeSidebarNavConfig(config?: Partial<SidebarNavConfig> | null): SidebarNavConfig {
+  const order = normalizeIdList(config?.order)
+  for (const id of DEFAULT_SIDEBAR_NAV_ORDER) {
+    if (!order.includes(id)) {
+      order.push(id)
+    }
+  }
+  return {
+    order,
+    hidden: normalizeIdList(config?.hidden),
+  }
+}
+
+export function reorderSidebarNavOrder(
+  order: readonly SidebarNavItemId[],
+  activeId: SidebarNavItemId,
+  overId: SidebarNavItemId,
+): SidebarNavItemId[] {
+  const normalized = normalizeSidebarNavConfig({ order: [...order] }).order
+  const activeIndex = normalized.indexOf(activeId)
+  const overIndex = normalized.indexOf(overId)
+  if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex) {
+    return normalized
+  }
+  const next = [...normalized]
+  const [moved] = next.splice(activeIndex, 1)
+  next.splice(overIndex, 0, moved)
+  return next
+}

+ 26 - 0
src/stores/wiki-store.ts

@@ -5,6 +5,11 @@ import type { LintResult } from "@/lib/lint"
 import type { NovelReviewResult } from "@/lib/novel/review-adapter"
 import type { DimensionReviewResult, SixReviewDimensionKey } from "@/lib/novel/dimension-review-adapter"
 import type { TrashItem } from "@/lib/trash"
+import {
+  DEFAULT_SIDEBAR_NAV_CONFIG,
+  normalizeSidebarNavConfig,
+  type SidebarNavConfig,
+} from "@/lib/sidebar-nav-preferences"
 
 const GRAPH_LABEL_MODE_KEY = "lk-graph-label-display-mode"
 const GRAPH_EDGE_COLOR_KEY = "lk-graph-edge-color"
@@ -13,6 +18,7 @@ const GRAPH_EDGE_STYLE_KEY = "lk-graph-edge-style"
 const GRAPH_EDGE_LABELS_ALWAYS_KEY = "lk-graph-edge-labels-always"
 const CHAT_DOCK_POSITION_KEY = "qmai-chat-dock-position"
 const UI_FONT_SIZE_SCALE_KEY = "qmai-ui-font-size-scale"
+const SIDEBAR_NAV_CONFIG_KEY = "qmai-sidebar-nav-config"
 
 export type ChatDockPosition = "bottom" | "right"
 export type SettingsCategoryId =
@@ -41,6 +47,16 @@ const readStoredUiFontSizeScale = (): number => {
   return Number.isFinite(saved) ? Math.max(0.85, Math.min(1.3, Number(saved.toFixed(2)))) : 1
 }
 
+const readStoredSidebarNavConfig = (): SidebarNavConfig => {
+  if (typeof localStorage === "undefined") return DEFAULT_SIDEBAR_NAV_CONFIG
+  try {
+    const saved = localStorage.getItem(SIDEBAR_NAV_CONFIG_KEY)
+    return normalizeSidebarNavConfig(saved ? JSON.parse(saved) : null)
+  } catch {
+    return DEFAULT_SIDEBAR_NAV_CONFIG
+  }
+}
+
 const readStoredGraphLabelDisplayMode = (): string => {
   if (typeof localStorage === "undefined") return "all"
   const saved = localStorage.getItem(GRAPH_LABEL_MODE_KEY)
@@ -531,6 +547,7 @@ interface WikiState {
   reviewRun: ReviewRunState | null
   theme: "light" | "dark" | "deep-blue" | "system"
   uiFontSizeScale: number
+  sidebarNavConfig: SidebarNavConfig
   dataVersion: number
   bindingVersion: number
 
@@ -594,6 +611,7 @@ interface WikiState {
   clearTransientTaskState: () => void
   setTheme: (theme: "light" | "dark" | "deep-blue" | "system") => void
   setUiFontSizeScale: (scale: number) => void
+  setSidebarNavConfig: (config: Partial<SidebarNavConfig>) => void
   bumpDataVersion: () => void
   bumpBindingVersion: () => void
 }
@@ -758,6 +776,7 @@ export const useWikiStore = create<WikiState>((set) => ({
   reviewRun: null,
   theme: "system",
   uiFontSizeScale: readStoredUiFontSizeScale(),
+  sidebarNavConfig: readStoredSidebarNavConfig(),
 
   setLlmConfig: (llmConfig) => set({ llmConfig }),
   setAiChatModel: (aiChatModel) => set({ aiChatModel }),
@@ -800,6 +819,13 @@ export const useWikiStore = create<WikiState>((set) => ({
     }
     set({ uiFontSizeScale: clamped })
   },
+  setSidebarNavConfig: (config) => {
+    const normalized = normalizeSidebarNavConfig(config)
+    if (typeof localStorage !== "undefined") {
+      localStorage.setItem(SIDEBAR_NAV_CONFIG_KEY, JSON.stringify(normalized))
+    }
+    set({ sidebarNavConfig: normalized })
+  },
   bumpDataVersion: () => set((state) => ({ dataVersion: state.dataVersion + 1 })),
   bumpBindingVersion: () => set((state) => ({ bindingVersion: state.bindingVersion + 1 })),
 }))