Explorar o código

添加东方美学视觉风格方案

Mochocyang hai 2 meses
pai
achega
8445d69543

+ 22 - 0
cangzhuseceshiban-分支说明.md

@@ -0,0 +1,22 @@
+# 东方美学配色测试版分支说明
+
+## 使用要求
+
+- 本分支新增「东方美学配色测试版」全局视觉风格选择。
+- 设置页保留两层选择:第一层仍是自动、浅色、深色、深蓝护眼;第二层是视觉风格。
+- 原有经典配色必须保留,可以在设置中切回「经典原版」。
+- 默认视觉风格为经典原版。
+- 东方美学方案按主题色、背景色、辅助色三色关系设计。
+- 不修改 AI、文件同步、模型配置、项目数据等业务逻辑。
+
+## 更新记录
+
+- 20260630:开始实现苍苍竹色测试版视觉风格。
+- 20260630:新增视觉风格状态、设置页入口、持久化、启动加载应用和 `visual-cangzhu` 独立 CSS 变量层;原经典配色保持可切回。
+- 20260630:完成 `test:mocks`、`typecheck`、`build`、`build:portable` 和源码 dev server 冒烟验证。
+- 20260630:按「东方美学配色方案.html」继续加入天青釉色、青瓷墨韵、云山黛色、苍苍竹色、月白黛蓝、古墨流金 6 套视觉风格;经典原版仍为默认值。
+- 20260630:完成新增多方案后的 `test:mocks`、`typecheck`、`build`、`build:portable` 和源码 dev server 冒烟验证。
+
+## 提交状态
+
+- 未提交。

+ 11 - 1
src/App.tsx

@@ -5,7 +5,7 @@ import { useReviewStore } from "@/stores/review-store"
 import { isTauri, pickDirectory } from "@/lib/platform"
 import { useChatStore } from "@/stores/chat-store"
 import { listDirectory, openProject, fileExists } from "@/commands/fs"
-import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, saveLlmConfig, loadLastReadChapter } from "@/lib/project-store"
+import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter } from "@/lib/project-store"
 import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
 import { checkForAppUpdate } from "@/lib/app-updater"
@@ -22,6 +22,7 @@ import { toast } from "@/lib/toast"
 import type { WikiProject } from "@/types/wiki"
 import { applyTheme, watchSystemTheme } from "@/lib/theme-utils"
 import { applyUiFontFamily } from "@/lib/font-settings"
+import { applyVisualStyle } from "@/lib/visual-style-settings"
 
 function App() {
   const project = useWikiStore((s) => s.project)
@@ -31,6 +32,7 @@ function App() {
   const setActiveView = useWikiStore((s) => s.setActiveView)
   const uiFontSizeScale = useWikiStore((s) => s.uiFontSizeScale)
   const uiFontFamily = useWikiStore((s) => s.uiFontFamily)
+  const visualStyle = useWikiStore((s) => s.visualStyle)
   const communitySummaryError = useWikiStore((s) => s.communitySummaryError)
   const setCommunitySummaryError = useWikiStore((s) => s.setCommunitySummaryError)
   const [showCreateDialog, setShowCreateDialog] = useState(false)
@@ -44,6 +46,10 @@ function App() {
     applyUiFontFamily(uiFontFamily)
   }, [uiFontFamily])
 
+  useEffect(() => {
+    applyVisualStyle(visualStyle)
+  }, [visualStyle])
+
   // 监听社区摘要生成错误,弹窗提示
   useEffect(() => {
     if (communitySummaryError) {
@@ -111,6 +117,10 @@ function App() {
         const themeToUse = savedTheme ?? "system"
         useWikiStore.getState().setTheme(themeToUse)
         applyTheme(themeToUse)
+        const savedVisualStyle = await loadVisualStyle()
+        const visualStyleToUse = savedVisualStyle ?? useWikiStore.getState().visualStyle
+        useWikiStore.getState().setVisualStyle(visualStyleToUse)
+        applyVisualStyle(visualStyleToUse)
         const savedUiFontFamily = await loadUiFontFamily()
         if (savedUiFontFamily) {
           useWikiStore.getState().setUiFontFamily(savedUiFontFamily)

+ 79 - 0
src/components/chat/chat-input.resize-interaction.spec.tsx

@@ -114,6 +114,85 @@ describe("ChatInput resize interaction", () => {
     expect(releasePointerCapture).toHaveBeenCalledWith(17)
   })
 
+  it("keeps the manually resized height after sending a message", async () => {
+    const onSend = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <div
+          ref={(node) => {
+            if (!node) return
+            node.getBoundingClientRect = () => ({
+              x: 0,
+              y: 0,
+              top: 0,
+              right: 360,
+              bottom: 480,
+              left: 0,
+              width: 360,
+              height: 480,
+              toJSON: () => ({}),
+            })
+          }}
+        >
+          <ChatInput onSend={onSend} onStop={() => {}} isStreaming={false} />
+        </div>,
+      )
+    })
+
+    const resizeHandle = host.querySelector<HTMLElement>('[role="separator"]')
+    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
+    const sendButton = host.querySelector<HTMLButtonElement>("button")
+    if (!resizeHandle || !textarea || !sendButton) throw new Error("chat input elements not found")
+
+    resizeHandle.setPointerCapture = vi.fn()
+    resizeHandle.releasePointerCapture = vi.fn()
+
+    await act(async () => {
+      resizeHandle.dispatchEvent(createPointerEvent("pointerdown", {
+        button: 0,
+        buttons: 1,
+        clientY: 220,
+        pointerId: 20,
+      }))
+    })
+    await act(async () => {
+      window.dispatchEvent(createPointerEvent("pointermove", {
+        buttons: 1,
+        clientY: 120,
+        pointerId: 20,
+      }))
+    })
+    await act(async () => {
+      window.dispatchEvent(createPointerEvent("pointerup", { pointerId: 20 }))
+    })
+    await flush()
+
+    expect(textarea.style.height).toBe("144px")
+
+    await act(async () => {
+      const valueSetter = Object.getOwnPropertyDescriptor(
+        HTMLTextAreaElement.prototype,
+        "value",
+      )?.set
+      valueSetter?.call(textarea, "测试")
+      textarea.dispatchEvent(new InputEvent("input", {
+        bubbles: true,
+        data: "测试",
+        inputType: "insertText",
+      }))
+    })
+    await flush()
+
+    await act(async () => {
+      sendButton.click()
+    })
+    await flush()
+
+    expect(onSend).toHaveBeenCalledWith("测试")
+    expect(textarea.style.height).toBe("144px")
+  })
+
   it("uses the nearest usable panel height when the chat input is wrapped in a short footer", async () => {
     await act(async () => {
       root.render(

+ 0 - 6
src/components/chat/chat-input.tsx

@@ -303,12 +303,6 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder, leftContro
     if (!trimmed || isStreaming) return
     onSend(trimmed)
     setValue("")
-    setUserSetMinHeight(null)
-    setInputHeight(DEFAULT_RESIZABLE_INPUT_HEIGHT)
-    const ta = textareaRef.current
-    if (ta) {
-      ta.style.height = `${DEFAULT_RESIZABLE_INPUT_HEIGHT}px`
-    }
   }, [value, isStreaming, onSend, setValue])
 
   const handleKeyDown = useCallback(

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

@@ -24,6 +24,51 @@ const FONT_SIZE_PRESETS = [
   { label: "特大", value: 1.3 },
 ]
 
+const VISUAL_STYLE_OPTIONS = [
+  {
+    value: "classic",
+    label: "经典原版",
+    description: "保留当前浅色、深色、深蓝护眼配色。",
+    colors: ["#ffffff", "#171717", "#e5e5e5"],
+  },
+  {
+    value: "tianqing",
+    label: "天青釉色",
+    description: "天青主题色、冰裂浅底、鎏金点缀。",
+    colors: ["#3D8B9E", "#E3F3F8", "#C4A265"],
+  },
+  {
+    value: "qingci",
+    label: "青瓷墨韵",
+    description: "青瓷主色、宣纸暖底、檀木点缀。",
+    colors: ["#3E6360", "#EDE6D8", "#B8956A"],
+  },
+  {
+    value: "yunshan",
+    label: "云山黛色",
+    description: "黛蓝主题色、晨雾浅底、石绿辅助。",
+    colors: ["#35647A", "#DAEEF5", "#3A7A5C"],
+  },
+  {
+    value: "cangzhu",
+    label: "苍苍竹色",
+    description: "竹青主题色、竹编暖纸背景、金竹辅助色。",
+    colors: ["#3E6B58", "#EDE8D8", "#C8A96E"],
+  },
+  {
+    value: "yuebai",
+    label: "月白黛蓝",
+    description: "黛蓝主题色、霜月浅底、琥珀点缀。",
+    colors: ["#4A5E95", "#DDE5F0", "#C4956A"],
+  },
+  {
+    value: "gumo",
+    label: "古墨流金",
+    description: "古金主题色、象牙浅底、玄墨层次。",
+    colors: ["#C49A3C", "#F5F0E8", "#D4AD5A"],
+  },
+] as const
+
 const SIDEBAR_NAV_LABEL_KEYS: Record<SidebarNavItemId, string> = {
   wiki: "novel.nav.wiki",
   sources: "novel.nav.sources",
@@ -63,6 +108,42 @@ export function InterfaceSection({ draft, setDraft }: Props) {
         </p>
       </div>
 
+      <div className="space-y-3 rounded-lg border p-4">
+        <div>
+          <Label>视觉风格</Label>
+          <p className="mt-1 text-xs text-muted-foreground">
+            东方美学方案不会覆盖原设计,可随时切回经典原版。
+          </p>
+        </div>
+        <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
+          {VISUAL_STYLE_OPTIONS.map((option) => {
+            const active = draft.visualStyle === option.value
+            return (
+              <button
+                key={option.value}
+                type="button"
+                onClick={() => setDraft("visualStyle", option.value)}
+                className={`rounded-lg border p-3 text-left transition-colors ${
+                  active ? "border-primary bg-primary/5 ring-1 ring-primary/40" : "border-border hover:bg-accent/50"
+                }`}
+              >
+                <div className="mb-3 flex gap-1.5">
+                  {option.colors.map((color) => (
+                    <span
+                      key={color}
+                      className="h-7 flex-1 rounded-md border"
+                      style={{ backgroundColor: color }}
+                    />
+                  ))}
+                </div>
+                <div className="text-sm font-medium">{option.label}</div>
+                <p className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</p>
+              </button>
+            )
+          })}
+        </div>
+      </div>
+
       <div className="space-y-2">
         <Label>{t("settings.sections.interface.uiLanguage")}</Label>
         <div className="flex flex-wrap gap-2">

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

@@ -2,6 +2,7 @@ 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"
 import type { UiFontFamily } from "@/lib/font-settings"
+import type { VisualStyle } from "@/lib/visual-style-settings"
 
 /**
  * Shape of the draft state each section reads from and writes into.
@@ -68,6 +69,7 @@ export interface SettingsDraft {
   uiLanguage: string
   uiFontSizeScale: number
   uiFontFamily: UiFontFamily
+  visualStyle: VisualStyle
   sidebarNavConfig: SidebarNavConfig
 
   // Source folder auto watch

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

@@ -91,6 +91,7 @@ function initialDraft(
   uiLanguage: string,
   uiFontSizeScale: number,
   uiFontFamily: UiFontFamily,
+  visualStyle: SettingsDraft["visualStyle"],
   sidebarNavConfig: SidebarNavConfig,
   projectPath?: string,
 ): SettingsDraft {
@@ -150,6 +151,7 @@ function initialDraft(
     uiLanguage,
     uiFontSizeScale,
     uiFontFamily,
+    visualStyle,
     sidebarNavConfig,
   }
 }
@@ -185,6 +187,8 @@ export function SettingsView() {
   const setUiFontSizeScale = useWikiStore((s) => s.setUiFontSizeScale)
   const uiFontFamily = useWikiStore((s) => s.uiFontFamily)
   const setUiFontFamily = useWikiStore((s) => s.setUiFontFamily)
+  const visualStyle = useWikiStore((s) => s.visualStyle)
+  const setVisualStyle = useWikiStore((s) => s.setVisualStyle)
   const sidebarNavConfig = useWikiStore((s) => s.sidebarNavConfig)
   const setSidebarNavConfig = useWikiStore((s) => s.setSidebarNavConfig)
 
@@ -206,6 +210,7 @@ export function SettingsView() {
       i18n.language,
       uiFontSizeScale,
       uiFontFamily,
+      visualStyle,
       sidebarNavConfig,
       project?.path,
     ),
@@ -286,6 +291,7 @@ export function SettingsView() {
         prev.uiLanguage,
         uiFontSizeScale,
         uiFontFamily,
+        visualStyle,
         sidebarNavConfig,
         project?.path,
       ),
@@ -304,6 +310,7 @@ export function SettingsView() {
     maxHistoryMessages,
     uiFontSizeScale,
     uiFontFamily,
+    visualStyle,
     sidebarNavConfig,
     project,
   ])
@@ -327,6 +334,7 @@ export function SettingsView() {
       saveMaxHistoryMessages,
       saveUiFontSizeScale,
       saveUiFontFamily,
+      saveVisualStyle,
     } = await import("@/lib/project-store")
 
     const newLlm = {
@@ -453,6 +461,10 @@ export function SettingsView() {
     await saveUiFontSizeScale(draft.uiFontSizeScale, project?.id, project?.path)
     setUiFontFamily(draft.uiFontFamily)
     await saveUiFontFamily(draft.uiFontFamily)
+    setVisualStyle(draft.visualStyle)
+    await saveVisualStyle(draft.visualStyle)
+    const { applyVisualStyle } = await import("@/lib/visual-style-settings")
+    applyVisualStyle(draft.visualStyle)
     setSidebarNavConfig(draft.sidebarNavConfig)
 
     if (draft.uiLanguage !== i18n.language) {
@@ -479,6 +491,7 @@ export function SettingsView() {
     outputLanguage,
     setUiFontSizeScale,
     setUiFontFamily,
+    setVisualStyle,
     setSidebarNavConfig,
   ])
 

+ 419 - 5
src/index.css

@@ -83,6 +83,210 @@
     --sidebar-ring: oklch(0.708 0 0);
 }
 
+:root.visual-cangzhu {
+    --background: oklch(0.95 0.021 91);
+    --foreground: oklch(0.25 0.035 160);
+    --card: oklch(0.985 0.011 91);
+    --card-foreground: oklch(0.25 0.035 160);
+    --popover: oklch(0.985 0.011 91);
+    --popover-foreground: oklch(0.25 0.035 160);
+    --primary: oklch(0.47 0.078 160);
+    --primary-foreground: oklch(0.985 0.011 91);
+    --secondary: oklch(0.92 0.027 96);
+    --secondary-foreground: oklch(0.25 0.035 160);
+    --muted: oklch(0.92 0.027 96);
+    --muted-foreground: oklch(0.47 0.033 150);
+    --accent: oklch(0.9 0.04 150);
+    --accent-foreground: oklch(0.25 0.035 160);
+    --destructive: oklch(0.577 0.245 27.325);
+    --border: oklch(0.83 0.031 96);
+    --input: oklch(0.83 0.031 96);
+    --ring: oklch(0.47 0.078 160);
+    --chart-1: oklch(0.47 0.078 160);
+    --chart-2: oklch(0.58 0.077 158);
+    --chart-3: oklch(0.7 0.065 150);
+    --chart-4: oklch(0.73 0.078 88);
+    --chart-5: oklch(0.36 0.047 160);
+    --sidebar: oklch(0.92 0.027 96);
+    --sidebar-foreground: oklch(0.25 0.035 160);
+    --sidebar-primary: oklch(0.47 0.078 160);
+    --sidebar-primary-foreground: oklch(0.985 0.011 91);
+    --sidebar-accent: oklch(0.88 0.04 150);
+    --sidebar-accent-foreground: oklch(0.25 0.035 160);
+    --sidebar-border: oklch(0.82 0.031 96);
+    --sidebar-ring: oklch(0.47 0.078 160);
+}
+
+:root.visual-tianqing {
+    --background: #e3f3f8;
+    --foreground: #1b3d4f;
+    --card: #f0e6d3;
+    --card-foreground: #1b3d4f;
+    --popover: #f0e6d3;
+    --popover-foreground: #1b3d4f;
+    --primary: #3d8b9e;
+    --primary-foreground: #ffffff;
+    --secondary: #c5e8f0;
+    --secondary-foreground: #1b3d4f;
+    --muted: #c5e8f0;
+    --muted-foreground: #2a6478;
+    --accent: #9dd4e0;
+    --accent-foreground: #1b3d4f;
+    --destructive: oklch(0.577 0.245 27.325);
+    --border: #b7d1d6;
+    --input: #b7d1d6;
+    --ring: #3d8b9e;
+    --chart-1: #3d8b9e;
+    --chart-2: #6bb5c4;
+    --chart-3: #9dd4e0;
+    --chart-4: #c4a265;
+    --chart-5: #1b3d4f;
+    --sidebar: #c5e8f0;
+    --sidebar-foreground: #1b3d4f;
+    --sidebar-primary: #3d8b9e;
+    --sidebar-primary-foreground: #ffffff;
+    --sidebar-accent: #9dd4e0;
+    --sidebar-accent-foreground: #1b3d4f;
+    --sidebar-border: #b7d1d6;
+    --sidebar-ring: #3d8b9e;
+}
+
+:root.visual-qingci {
+    --background: #ede6d8;
+    --foreground: #1a2f2e;
+    --card: #f7f3ea;
+    --card-foreground: #1a2f2e;
+    --popover: #f7f3ea;
+    --popover-foreground: #1a2f2e;
+    --primary: #3e6360;
+    --primary-foreground: #ffffff;
+    --secondary: #d0ece8;
+    --secondary-foreground: #1a2f2e;
+    --muted: #d0ece8;
+    --muted-foreground: #2d4a47;
+    --accent: #a5d3cf;
+    --accent-foreground: #1a2f2e;
+    --destructive: oklch(0.577 0.245 27.325);
+    --border: #cfc7b8;
+    --input: #cfc7b8;
+    --ring: #3e6360;
+    --chart-1: #3e6360;
+    --chart-2: #5a8a86;
+    --chart-3: #7db0ab;
+    --chart-4: #b8956a;
+    --chart-5: #1a2f2e;
+    --sidebar: #e5ded0;
+    --sidebar-foreground: #1a2f2e;
+    --sidebar-primary: #3e6360;
+    --sidebar-primary-foreground: #ffffff;
+    --sidebar-accent: #d0ece8;
+    --sidebar-accent-foreground: #1a2f2e;
+    --sidebar-border: #cfc7b8;
+    --sidebar-ring: #3e6360;
+}
+
+:root.visual-yunshan {
+    --background: #daeef5;
+    --foreground: #1a2e3d;
+    --card: #f4fbfd;
+    --card-foreground: #1a2e3d;
+    --popover: #f4fbfd;
+    --popover-foreground: #1a2e3d;
+    --primary: #35647a;
+    --primary-foreground: #ffffff;
+    --secondary: #c6e3ec;
+    --secondary-foreground: #1a2e3d;
+    --muted: #c6e3ec;
+    --muted-foreground: #2a4a5e;
+    --accent: #82bdd0;
+    --accent-foreground: #1a2e3d;
+    --destructive: oklch(0.577 0.245 27.325);
+    --border: #b8d3dd;
+    --input: #b8d3dd;
+    --ring: #35647a;
+    --chart-1: #35647a;
+    --chart-2: #458298;
+    --chart-3: #5da0b4;
+    --chart-4: #3a7a5c;
+    --chart-5: #1a2e3d;
+    --sidebar: #c6e3ec;
+    --sidebar-foreground: #1a2e3d;
+    --sidebar-primary: #35647a;
+    --sidebar-primary-foreground: #ffffff;
+    --sidebar-accent: #82bdd0;
+    --sidebar-accent-foreground: #1a2e3d;
+    --sidebar-border: #b8d3dd;
+    --sidebar-ring: #35647a;
+}
+
+:root.visual-yuebai {
+    --background: #dde5f0;
+    --foreground: #1a1f35;
+    --card: #ede5d8;
+    --card-foreground: #1a1f35;
+    --popover: #ede5d8;
+    --popover-foreground: #1a1f35;
+    --primary: #4a5e95;
+    --primary-foreground: #ffffff;
+    --secondary: #c9d4e8;
+    --secondary-foreground: #1a1f35;
+    --muted: #c9d4e8;
+    --muted-foreground: #2a3055;
+    --accent: #8ea2ce;
+    --accent-foreground: #1a1f35;
+    --destructive: oklch(0.577 0.245 27.325);
+    --border: #c5ccda;
+    --input: #c5ccda;
+    --ring: #4a5e95;
+    --chart-1: #4a5e95;
+    --chart-2: #6a80b5;
+    --chart-3: #8ea2ce;
+    --chart-4: #c4956a;
+    --chart-5: #1a1f35;
+    --sidebar: #d4dcec;
+    --sidebar-foreground: #1a1f35;
+    --sidebar-primary: #4a5e95;
+    --sidebar-primary-foreground: #ffffff;
+    --sidebar-accent: #c9d4e8;
+    --sidebar-accent-foreground: #1a1f35;
+    --sidebar-border: #c5ccda;
+    --sidebar-ring: #4a5e95;
+}
+
+:root.visual-gumo {
+    --background: #f5f0e8;
+    --foreground: #1a1a1a;
+    --card: #e8e0d0;
+    --card-foreground: #1a1a1a;
+    --popover: #e8e0d0;
+    --popover-foreground: #1a1a1a;
+    --primary: #c49a3c;
+    --primary-foreground: #1a1a1a;
+    --secondary: #ded4bf;
+    --secondary-foreground: #1a1a1a;
+    --muted: #ded4bf;
+    --muted-foreground: #555555;
+    --accent: #d4ad5a;
+    --accent-foreground: #1a1a1a;
+    --destructive: oklch(0.577 0.245 27.325);
+    --border: #cfc2a8;
+    --input: #cfc2a8;
+    --ring: #c49a3c;
+    --chart-1: #c49a3c;
+    --chart-2: #d4ad5a;
+    --chart-3: #3d8b9e;
+    --chart-4: #8b4513;
+    --chart-5: #1a1a1a;
+    --sidebar: #e8e0d0;
+    --sidebar-foreground: #1a1a1a;
+    --sidebar-primary: #c49a3c;
+    --sidebar-primary-foreground: #1a1a1a;
+    --sidebar-accent: #d4ad5a;
+    --sidebar-accent-foreground: #1a1a1a;
+    --sidebar-border: #cfc2a8;
+    --sidebar-ring: #c49a3c;
+}
+
 .dark {
     --background: oklch(0.145 0 0);
     --foreground: oklch(0.985 0 0);
@@ -152,6 +356,216 @@
     --sidebar-ring: oklch(0.55 0.12 238);
 }
 
+.visual-cangzhu.dark,
+.visual-cangzhu.deep-blue {
+    --background: oklch(0.23 0.043 160);
+    --foreground: oklch(0.91 0.035 145);
+    --card: oklch(0.28 0.043 160);
+    --card-foreground: oklch(0.91 0.035 145);
+    --popover: oklch(0.28 0.043 160);
+    --popover-foreground: oklch(0.91 0.035 145);
+    --primary: oklch(0.72 0.065 150);
+    --primary-foreground: oklch(0.18 0.035 160);
+    --secondary: oklch(0.33 0.04 160);
+    --secondary-foreground: oklch(0.91 0.035 145);
+    --muted: oklch(0.33 0.04 160);
+    --muted-foreground: oklch(0.74 0.035 145);
+    --accent: oklch(0.38 0.049 155);
+    --accent-foreground: oklch(0.91 0.035 145);
+    --destructive: oklch(0.704 0.191 22.216);
+    --border: oklch(0.44 0.035 160);
+    --input: oklch(0.4 0.04 160);
+    --ring: oklch(0.72 0.065 150);
+    --chart-1: oklch(0.72 0.065 150);
+    --chart-2: oklch(0.64 0.064 158);
+    --chart-3: oklch(0.74 0.078 88);
+    --chart-4: oklch(0.56 0.06 160);
+    --chart-5: oklch(0.91 0.035 145);
+    --sidebar: oklch(0.19 0.036 160);
+    --sidebar-foreground: oklch(0.91 0.035 145);
+    --sidebar-primary: oklch(0.72 0.065 150);
+    --sidebar-primary-foreground: oklch(0.18 0.035 160);
+    --sidebar-accent: oklch(0.3 0.043 160);
+    --sidebar-accent-foreground: oklch(0.91 0.035 145);
+    --sidebar-border: oklch(0.36 0.035 160);
+    --sidebar-ring: oklch(0.72 0.065 150);
+}
+
+.visual-tianqing.dark,
+.visual-tianqing.deep-blue {
+    --background: #1b3d4f;
+    --foreground: #e3f3f8;
+    --card: #2a6478;
+    --card-foreground: #e3f3f8;
+    --popover: #2a6478;
+    --popover-foreground: #e3f3f8;
+    --primary: #6bb5c4;
+    --primary-foreground: #1b3d4f;
+    --secondary: #2a6478;
+    --secondary-foreground: #e3f3f8;
+    --muted: #2a6478;
+    --muted-foreground: #c5e8f0;
+    --accent: #3d8b9e;
+    --accent-foreground: #ffffff;
+    --destructive: oklch(0.704 0.191 22.216);
+    --border: #3d8b9e;
+    --input: #2a6478;
+    --ring: #6bb5c4;
+    --chart-1: #6bb5c4;
+    --chart-2: #3d8b9e;
+    --chart-3: #9dd4e0;
+    --chart-4: #c4a265;
+    --chart-5: #e3f3f8;
+    --sidebar: #183644;
+    --sidebar-foreground: #e3f3f8;
+    --sidebar-primary: #6bb5c4;
+    --sidebar-primary-foreground: #1b3d4f;
+    --sidebar-accent: #2a6478;
+    --sidebar-accent-foreground: #e3f3f8;
+    --sidebar-border: #3d8b9e;
+    --sidebar-ring: #6bb5c4;
+}
+
+.visual-qingci.dark,
+.visual-qingci.deep-blue {
+    --background: #1a2f2e;
+    --foreground: #d0ece8;
+    --card: #2d4a47;
+    --card-foreground: #d0ece8;
+    --popover: #2d4a47;
+    --popover-foreground: #d0ece8;
+    --primary: #7db0ab;
+    --primary-foreground: #1a2f2e;
+    --secondary: #2d4a47;
+    --secondary-foreground: #d0ece8;
+    --muted: #2d4a47;
+    --muted-foreground: #a5d3cf;
+    --accent: #5a8a86;
+    --accent-foreground: #ffffff;
+    --destructive: oklch(0.704 0.191 22.216);
+    --border: #3e6360;
+    --input: #2d4a47;
+    --ring: #7db0ab;
+    --chart-1: #7db0ab;
+    --chart-2: #5a8a86;
+    --chart-3: #a5d3cf;
+    --chart-4: #b8956a;
+    --chart-5: #d0ece8;
+    --sidebar: #162927;
+    --sidebar-foreground: #d0ece8;
+    --sidebar-primary: #7db0ab;
+    --sidebar-primary-foreground: #1a2f2e;
+    --sidebar-accent: #2d4a47;
+    --sidebar-accent-foreground: #d0ece8;
+    --sidebar-border: #3e6360;
+    --sidebar-ring: #7db0ab;
+}
+
+.visual-yunshan.dark,
+.visual-yunshan.deep-blue {
+    --background: #1a2e3d;
+    --foreground: #daeef5;
+    --card: #2a4a5e;
+    --card-foreground: #daeef5;
+    --popover: #2a4a5e;
+    --popover-foreground: #daeef5;
+    --primary: #82bdd0;
+    --primary-foreground: #1a2e3d;
+    --secondary: #2a4a5e;
+    --secondary-foreground: #daeef5;
+    --muted: #2a4a5e;
+    --muted-foreground: #82bdd0;
+    --accent: #458298;
+    --accent-foreground: #ffffff;
+    --destructive: oklch(0.704 0.191 22.216);
+    --border: #35647a;
+    --input: #2a4a5e;
+    --ring: #82bdd0;
+    --chart-1: #82bdd0;
+    --chart-2: #5da0b4;
+    --chart-3: #458298;
+    --chart-4: #3a7a5c;
+    --chart-5: #daeef5;
+    --sidebar: #162938;
+    --sidebar-foreground: #daeef5;
+    --sidebar-primary: #82bdd0;
+    --sidebar-primary-foreground: #1a2e3d;
+    --sidebar-accent: #2a4a5e;
+    --sidebar-accent-foreground: #daeef5;
+    --sidebar-border: #35647a;
+    --sidebar-ring: #82bdd0;
+}
+
+.visual-yuebai.dark,
+.visual-yuebai.deep-blue {
+    --background: #1a1f35;
+    --foreground: #dde5f0;
+    --card: #2a3055;
+    --card-foreground: #dde5f0;
+    --popover: #2a3055;
+    --popover-foreground: #dde5f0;
+    --primary: #8ea2ce;
+    --primary-foreground: #1a1f35;
+    --secondary: #2a3055;
+    --secondary-foreground: #dde5f0;
+    --muted: #2a3055;
+    --muted-foreground: #b5c4e0;
+    --accent: #6a80b5;
+    --accent-foreground: #ffffff;
+    --destructive: oklch(0.704 0.191 22.216);
+    --border: #4a5e95;
+    --input: #2a3055;
+    --ring: #8ea2ce;
+    --chart-1: #8ea2ce;
+    --chart-2: #6a80b5;
+    --chart-3: #4a5e95;
+    --chart-4: #c4956a;
+    --chart-5: #dde5f0;
+    --sidebar: #151a2e;
+    --sidebar-foreground: #dde5f0;
+    --sidebar-primary: #8ea2ce;
+    --sidebar-primary-foreground: #1a1f35;
+    --sidebar-accent: #2a3055;
+    --sidebar-accent-foreground: #dde5f0;
+    --sidebar-border: #4a5e95;
+    --sidebar-ring: #8ea2ce;
+}
+
+.visual-gumo.dark,
+.visual-gumo.deep-blue {
+    --background: #1a1a1a;
+    --foreground: #f5f0e8;
+    --card: #2d2d2d;
+    --card-foreground: #f5f0e8;
+    --popover: #2d2d2d;
+    --popover-foreground: #f5f0e8;
+    --primary: #d4ad5a;
+    --primary-foreground: #1a1a1a;
+    --secondary: #404040;
+    --secondary-foreground: #f5f0e8;
+    --muted: #404040;
+    --muted-foreground: #d0d0d0;
+    --accent: #c49a3c;
+    --accent-foreground: #1a1a1a;
+    --destructive: oklch(0.704 0.191 22.216);
+    --border: #555555;
+    --input: #404040;
+    --ring: #d4ad5a;
+    --chart-1: #d4ad5a;
+    --chart-2: #c49a3c;
+    --chart-3: #3d8b9e;
+    --chart-4: #8b4513;
+    --chart-5: #f5f0e8;
+    --sidebar: #141414;
+    --sidebar-foreground: #f5f0e8;
+    --sidebar-primary: #d4ad5a;
+    --sidebar-primary-foreground: #1a1a1a;
+    --sidebar-accent: #2d2d2d;
+    --sidebar-accent-foreground: #f5f0e8;
+    --sidebar-border: #555555;
+    --sidebar-ring: #d4ad5a;
+}
+
 @layer base {
   * {
     @apply border-border outline-ring/50;
@@ -166,12 +580,12 @@
 
 @layer components {
   .qm-selected {
-    background: color-mix(in oklch, #9acaca 38%, transparent);
+    background: color-mix(in oklch, var(--primary) 38%, transparent);
     color: #0f3f42;
   }
 
   .qm-selected:hover {
-    background: color-mix(in oklch, #9acaca 46%, transparent);
+    background: color-mix(in oklch, var(--primary) 46%, transparent);
   }
 
   .qm-selected-muted {
@@ -179,13 +593,13 @@
   }
 
   .qm-hover:hover {
-    background: color-mix(in oklch, #9acaca 18%, transparent);
+    background: color-mix(in oklch, var(--primary) 18%, transparent);
     color: var(--foreground);
   }
 
   .dark .qm-selected,
   .deep-blue .qm-selected {
-    background: color-mix(in oklch, #9acaca 27%, transparent);
+    background: color-mix(in oklch, var(--primary) 27%, transparent);
     color: #e8ffff;
   }
 
@@ -196,7 +610,7 @@
 
   .dark .qm-hover:hover,
   .deep-blue .qm-hover:hover {
-    background: color-mix(in oklch, #9acaca 17%, transparent);
+    background: color-mix(in oklch, var(--primary) 17%, transparent);
     color: var(--foreground);
   }
 }

+ 14 - 0
src/lib/project-store.ts

@@ -4,6 +4,7 @@ import type { LlmConfig, SearchApiConfig, EmbeddingConfig, MultimodalConfig, Out
 import { DEFAULT_NOVEL_CONFIG, DEFAULT_RERANK_CONFIG } from "@/stores/wiki-store"
 import { normalizeSourceWatchConfig } from "@/lib/source-watch-config"
 import { normalizeUiFontFamily, type UiFontFamily } from "@/lib/font-settings"
+import { normalizeVisualStyle, type VisualStyle } from "@/lib/visual-style-settings"
 import { normalizePath } from "@/lib/path-utils"
 import { readFile, writeFile, fileExists } from "@/commands/fs"
 
@@ -578,6 +579,7 @@ export async function loadRerankConfig(projectId?: string, projectPath?: string)
 }
 
 const THEME_KEY = "theme"
+const VISUAL_STYLE_KEY = "visualStyle"
 
 export async function saveTheme(theme: "light" | "dark" | "deep-blue" | "system"): Promise<void> {
   const store = await getStore()
@@ -590,6 +592,18 @@ export async function loadTheme(): Promise<"light" | "dark" | "deep-blue" | "sys
   return savedTheme ?? null
 }
 
+export async function saveVisualStyle(style: VisualStyle): Promise<void> {
+  const store = await getStore()
+  await store.set(VISUAL_STYLE_KEY, normalizeVisualStyle(style))
+  await store.save()
+}
+
+export async function loadVisualStyle(): Promise<VisualStyle | null> {
+  const store = await getStore()
+  const saved = await store.get<string>(VISUAL_STYLE_KEY)
+  return saved ? normalizeVisualStyle(saved) : null
+}
+
 const UI_FONT_SIZE_SCALE_KEY = "uiFontSizeScale"
 const UI_FONT_FAMILY_KEY = "uiFontFamily"
 const MAX_HISTORY_MESSAGES_KEY = "maxHistoryMessages"

+ 41 - 0
src/lib/visual-style-settings.spec.ts

@@ -0,0 +1,41 @@
+// @vitest-environment jsdom
+
+import { beforeEach, describe, expect, it } from "vitest"
+import { applyVisualStyle, normalizeVisualStyle } from "./visual-style-settings"
+
+describe("visual-style-settings", () => {
+  beforeEach(() => {
+    document.documentElement.className = ""
+  })
+
+  it("falls back to classic for invalid values", () => {
+    expect(normalizeVisualStyle("bad")).toBe("classic")
+    expect(normalizeVisualStyle(null)).toBe("classic")
+  })
+
+  it("accepts all oriental visual styles", () => {
+    expect(normalizeVisualStyle("classic")).toBe("classic")
+    expect(normalizeVisualStyle("tianqing")).toBe("tianqing")
+    expect(normalizeVisualStyle("qingci")).toBe("qingci")
+    expect(normalizeVisualStyle("yunshan")).toBe("yunshan")
+    expect(normalizeVisualStyle("cangzhu")).toBe("cangzhu")
+    expect(normalizeVisualStyle("yuebai")).toBe("yuebai")
+    expect(normalizeVisualStyle("gumo")).toBe("gumo")
+  })
+
+  it("toggles the selected visual style class", () => {
+    applyVisualStyle("cangzhu")
+    expect(document.documentElement.classList.contains("visual-cangzhu")).toBe(true)
+
+    applyVisualStyle("classic")
+    expect(document.documentElement.classList.contains("visual-cangzhu")).toBe(false)
+  })
+
+  it("keeps only one visual style class at a time", () => {
+    applyVisualStyle("tianqing")
+    applyVisualStyle("gumo")
+
+    expect(document.documentElement.classList.contains("visual-tianqing")).toBe(false)
+    expect(document.documentElement.classList.contains("visual-gumo")).toBe(true)
+  })
+})

+ 47 - 0
src/lib/visual-style-settings.ts

@@ -0,0 +1,47 @@
+export type VisualStyle =
+  | "classic"
+  | "tianqing"
+  | "qingci"
+  | "yunshan"
+  | "cangzhu"
+  | "yuebai"
+  | "gumo"
+
+export const DEFAULT_VISUAL_STYLE: VisualStyle = "classic"
+export const VISUAL_STYLE_STORAGE_KEY = "qmai-visual-style"
+
+const VISUAL_STYLE_CLASSES: Record<Exclude<VisualStyle, "classic">, string> = {
+  tianqing: "visual-tianqing",
+  qingci: "visual-qingci",
+  yunshan: "visual-yunshan",
+  cangzhu: "visual-cangzhu",
+  yuebai: "visual-yuebai",
+  gumo: "visual-gumo",
+}
+
+const VISUAL_STYLE_VALUES = new Set<VisualStyle>([
+  "classic",
+  "tianqing",
+  "qingci",
+  "yunshan",
+  "cangzhu",
+  "yuebai",
+  "gumo",
+])
+
+export function normalizeVisualStyle(value: unknown): VisualStyle {
+  return typeof value === "string" && VISUAL_STYLE_VALUES.has(value as VisualStyle)
+    ? value as VisualStyle
+    : DEFAULT_VISUAL_STYLE
+}
+
+export function applyVisualStyle(style: VisualStyle): void {
+  if (typeof document === "undefined") return
+  const html = document.documentElement
+  Object.values(VISUAL_STYLE_CLASSES).forEach((className) => {
+    html.classList.remove(className)
+  })
+  if (style !== "classic") {
+    html.classList.add(VISUAL_STYLE_CLASSES[style])
+  }
+}

+ 22 - 1
src/stores/wiki-store.ts

@@ -15,6 +15,12 @@ import {
   normalizeUiFontFamily,
   type UiFontFamily,
 } from "@/lib/font-settings"
+import {
+  DEFAULT_VISUAL_STYLE,
+  VISUAL_STYLE_STORAGE_KEY,
+  normalizeVisualStyle,
+  type VisualStyle,
+} from "@/lib/visual-style-settings"
 
 const GRAPH_LABEL_MODE_KEY = "lk-graph-label-display-mode"
 const GRAPH_EDGE_COLOR_KEY = "lk-graph-edge-color"
@@ -58,6 +64,11 @@ const readStoredUiFontFamily = (): UiFontFamily => {
   return normalizeUiFontFamily(localStorage.getItem(UI_FONT_FAMILY_KEY))
 }
 
+const readStoredVisualStyle = (): VisualStyle => {
+  if (typeof localStorage === "undefined") return DEFAULT_VISUAL_STYLE
+  return normalizeVisualStyle(localStorage.getItem(VISUAL_STYLE_STORAGE_KEY))
+}
+
 const readStoredSidebarNavConfig = (): SidebarNavConfig => {
   if (typeof localStorage === "undefined") return DEFAULT_SIDEBAR_NAV_CONFIG
   try {
@@ -568,6 +579,7 @@ interface WikiState {
   theme: "light" | "dark" | "deep-blue" | "system"
   uiFontSizeScale: number
   uiFontFamily: UiFontFamily
+  visualStyle: VisualStyle
   sidebarNavConfig: SidebarNavConfig
   dataVersion: number
   bindingVersion: number
@@ -635,6 +647,7 @@ interface WikiState {
   setTheme: (theme: "light" | "dark" | "deep-blue" | "system") => void
   setUiFontSizeScale: (scale: number) => void
   setUiFontFamily: (fontFamily: UiFontFamily) => void
+  setVisualStyle: (visualStyle: VisualStyle) => void
   setSidebarNavConfig: (config: Partial<SidebarNavConfig>) => void
   bumpDataVersion: () => void
   bumpBindingVersion: () => void
@@ -825,6 +838,7 @@ export const useWikiStore = create<WikiState>((set) => ({
   theme: "system",
   uiFontSizeScale: readStoredUiFontSizeScale(),
   uiFontFamily: readStoredUiFontFamily(),
+  visualStyle: readStoredVisualStyle(),
   sidebarNavConfig: readStoredSidebarNavConfig(),
 
   setLlmConfig: (llmConfig) => set({ llmConfig }),
@@ -875,6 +889,13 @@ export const useWikiStore = create<WikiState>((set) => ({
     }
     set({ uiFontFamily: normalized })
   },
+  setVisualStyle: (visualStyle) => {
+    const normalized = normalizeVisualStyle(visualStyle)
+    if (typeof localStorage !== "undefined") {
+      localStorage.setItem(VISUAL_STYLE_STORAGE_KEY, normalized)
+    }
+    set({ visualStyle: normalized })
+  },
   setSidebarNavConfig: (config) => {
     const normalized = normalizeSidebarNavConfig(config)
     if (typeof localStorage !== "undefined") {
@@ -886,4 +907,4 @@ export const useWikiStore = create<WikiState>((set) => ({
   bumpBindingVersion: () => set((state) => ({ bindingVersion: state.bindingVersion + 1 })),
 }))
 
-export type { WikiState, LlmConfig, SearchApiConfig, EmbeddingConfig, MultimodalConfig, OutputLanguage, ProxyConfig, ScheduledImportConfig, SourceWatchConfig }
+export type { WikiState, LlmConfig, SearchApiConfig, EmbeddingConfig, MultimodalConfig, OutputLanguage, ProxyConfig, ScheduledImportConfig, SourceWatchConfig, VisualStyle }