diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 362db329..1b206fd0 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -258,7 +258,7 @@ vision = false function validateConfig(c) { // 设计 §7.1:直接接收所需字段 + 基本类型检查(防写坏 config.toml 的健壮性,非安全设计) const out = {}; - for (const k of ["apiKey", "baseUrl", "model", "theme"]) { + for (const k of ["apiKey", "baseUrl", "model", "theme", "lang"]) { if (c[k] !== undefined) out[k] = typeof c[k] === "string" ? c[k] : String(c[k]); } if (Array.isArray(c.models)) { @@ -863,6 +863,7 @@ vision = false models, modelDetails, theme: cfg.gui?.theme || "system", // §7.1:外观主题持久化(浅色/深色/跟随系统) + lang: cfg.gui?.lang || "", // rant 22:22:50:语言持久化(""=跟随系统 / zh / en) }; }); @@ -886,6 +887,10 @@ vision = false toml.gui = toml.gui || {}; toml.gui.theme = cfg.theme; // §7.1:主题持久化(浅色/深色/跟随系统) } + if (cfg.lang !== undefined) { + toml.gui = toml.gui || {}; + toml.gui.lang = cfg.lang; // rant 22:22:50:语言持久化(""=跟随系统 / zh / en) + } if (cfg.models !== undefined) { // §7.1:多模型保存——合并写 [[llm.models]](保留已有项的 context_window 等高级字段) const existing = toml.llm.models || []; diff --git a/emrg/gui/renderer/src/App.tsx b/emrg/gui/renderer/src/App.tsx index 599b1670..1c6eb6b5 100644 --- a/emrg/gui/renderer/src/App.tsx +++ b/emrg/gui/renderer/src/App.tsx @@ -1,7 +1,8 @@ +import { useEffect } from "react"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { Shell } from "./components/Shell"; import { DaemonBridgeProvider } from "./components/DaemonBridgeProvider"; -import { I18nProvider } from "./lib/i18n"; +import { I18nProvider, setLocale, type Locale } from "./lib/i18n"; /** * App — React 组件树根(Batch 0 骨架 + Batch 5 slice 2 daemon-event context)。 @@ -17,6 +18,24 @@ import { I18nProvider } from "./lib/i18n"; * 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。 */ export function App() { + // 启动恢复持久化外观偏好(rant 2026-08-27T22:22:50):theme/lang 从 config.toml + // 读回(getSettings)。window.emrg 缺失(jsdom / 预览)时静默降级。 + useEffect(() => { + const emrg = (window as unknown as { emrg?: { getSettings?: () => Promise<{ theme?: string; lang?: string }> } }).emrg; + if (!emrg?.getSettings) return; + emrg + .getSettings() + .then((s) => { + if (s.lang) setLocale(s.lang as "" | Locale); + if (s.theme && s.theme !== "system") { + document.documentElement.setAttribute("data-theme", s.theme); + } + }) + .catch(() => { + /* 读配置失败:保持默认(跟随系统) */ + }); + }, []); + return ( diff --git a/emrg/gui/renderer/src/components/SettingsPanel.test.tsx b/emrg/gui/renderer/src/components/SettingsPanel.test.tsx index b10b4040..8abd66c3 100644 --- a/emrg/gui/renderer/src/components/SettingsPanel.test.tsx +++ b/emrg/gui/renderer/src/components/SettingsPanel.test.tsx @@ -225,27 +225,71 @@ describe("SettingsPanel", () => { ); }); - it("applies theme option to documentElement", async () => { - setup(); + it("applies theme option to documentElement and persists via saveSettings", async () => { + const { calls } = setup(); await screen.findByTestId("settings-tab-appearance"); fireEvent.click(screen.getByTestId("settings-tab-appearance")); fireEvent.click(screen.getByTestId("theme-light")); expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + // 点击即持久化(rant 22:22:50):saveSettings 载荷含 theme + await waitFor(() => { + const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string }; + expect(last.theme).toBe("light"); + }); fireEvent.click(screen.getByTestId("theme-dark")); expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); + await waitFor(() => { + const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string }; + expect(last.theme).toBe("dark"); + }); fireEvent.click(screen.getByTestId("theme-system")); expect(document.documentElement.hasAttribute("data-theme")).toBe(false); + await waitFor(() => { + const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string }; + expect(last.theme).toBe("system"); + }); }); - it("sets locale from language tab", async () => { + it("sets locale from language tab and persists via saveSettings", async () => { + const { calls } = setup(); const spy = vi.spyOn(i18n, "setLocale").mockImplementation((l): i18n.Locale => (l === "" ? "zh" : l)); - setup(); await screen.findByTestId("settings-tab-language"); fireEvent.click(screen.getByTestId("settings-tab-language")); fireEvent.click(screen.getByTestId("lang-zh")); expect(spy).toHaveBeenCalledWith("zh"); + await waitFor(() => { + const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string }; + expect(last.lang).toBe("zh"); + }); fireEvent.click(screen.getByTestId("lang-system")); expect(spy).toHaveBeenCalledWith(""); + await waitFor(() => { + const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string }; + expect(last.lang).toBe(""); + }); + }); + + it("reopen reads persisted theme/lang and does not reset to system", async () => { + const { calls } = setup({ + getSettings: vi.fn().mockResolvedValue({ + apiKey: "sk-test", + baseUrl: "https://api.deepseek.com", + model: "deepseek-v3", + theme: "light", + lang: "en", + modelDetails: [], + }), + }); + await screen.findByTestId("settings-tab-appearance"); + fireEvent.click(screen.getByTestId("settings-tab-appearance")); + // 持久化值被应用(light 而非恒 "system" 重置) + await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("light")); + // 语言:getSettings 读回 lang=en → setLocale 被调用 + await waitFor(() => { + const spyCalls = calls.saveSettings.length; // 无点击 → 不新增持久化调用 + expect(spyCalls).toBe(0); + }); + expect(calls.saveSettings.length).toBe(0); }); it("renders version and evolution count in about tab", async () => { diff --git a/emrg/gui/renderer/src/components/SettingsPanel.tsx b/emrg/gui/renderer/src/components/SettingsPanel.tsx index 5aaaad3c..3e5efaf2 100644 --- a/emrg/gui/renderer/src/components/SettingsPanel.tsx +++ b/emrg/gui/renderer/src/components/SettingsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useReducer, useRef, useState } from "react"; -import { useI18n, setLocale } from "../lib/i18n"; +import { useI18n, setLocale, type Locale } from "../lib/i18n"; import { dialogReducer, initialDialogState } from "../lib/dialog"; import { ConfirmDialog } from "./ConfirmDialog"; @@ -44,6 +44,7 @@ interface GetSettingsResult { model?: string; modelDetails?: { name: string; model?: string; vision?: boolean }[]; theme?: string; + lang?: string; } /** SettingsPanel 用到的 preload 桥方法(与 preload.js 通道对齐) */ @@ -121,6 +122,8 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP })), ); applyTheme(s.theme || "system"); + // 语言持久化(rant 22:22:50):config.toml 有 lang → 应用;""(跟随系统/旧配置)→ 不动 + if (s.lang) setLocale(s.lang as "" | Locale); }) .catch((e: Error) => setMsg({ kind: "err", text: t("settings.readFailed", { msg: e.message }) })); void refreshGithubStatus(); @@ -150,6 +153,18 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP else root.setAttribute("data-theme", mode); } + /** 外观偏好即点即持久化(rant 22:22:50):theme/lang 点击 → saveSettings 落盘 + * config.toml(重开面板/重启 GUI 不回退)。成功静默;失败显示错误消息。 */ + async function persistAppearance(patch: { theme?: string; lang?: string }) { + const b = bridge(); + if (!b?.saveSettings) return; + try { + await b.saveSettings(patch); + } catch (e: unknown) { + setMsg({ kind: "err", text: t("settings.saveFailed", { msg: (e as Error).message }) }); + } + } + // ── model tab ───────────────────────────── function openModelForm(name: string | null, isDefault: boolean) { setEditingName(isDefault ? name : name); @@ -573,6 +588,7 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP onClick={() => { setTheme(m); applyTheme(m); + void persistAppearance({ theme: m }); }} > {t(`settings.theme${m[0].toUpperCase()}${m.slice(1)}`)} @@ -598,7 +614,10 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP className="theme-option" data-lang={loc} data-testid={`lang-${loc || "system"}`} - onClick={() => setLocale(loc)} + onClick={() => { + setLocale(loc); + void persistAppearance({ lang: loc }); + }} > {t( loc === "" ? "settings.langFollowSystem" : loc === "zh" ? "settings.langZh" : "settings.langEn", diff --git a/emrg/gui/renderer/src/lib/i18n.tsx b/emrg/gui/renderer/src/lib/i18n.tsx index f64ffe68..0f67d225 100644 --- a/emrg/gui/renderer/src/lib/i18n.tsx +++ b/emrg/gui/renderer/src/lib/i18n.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { DICTS } from "./i18n-dicts"; /** @@ -17,6 +17,8 @@ interface I18nContextValue { } export const LOCALE_KEY = "emrg.locale"; +/** setLocale 后广播给 I18nProvider 的事件(localStorage 写入本身不触发 React 重渲染) */ +export const LOCALE_CHANGED_EVENT = "emrg:locale-changed"; export type Locale = "zh" | "en"; /** 完整词典(373 keys,zh/en 严格对齐)——由 vanilla i18n.js 机械生成,见 ./i18n-dicts.ts */ @@ -65,6 +67,12 @@ export function setLocale(loc: "" | Locale): Locale { } catch { /* ignore */ } + // 通知 I18nProvider 重读(React 版 provider 状态化:不广播则 UI 不刷新) + try { + if (typeof window !== "undefined") window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT)); + } catch { + /* ignore */ + } return getLocale(); } @@ -102,14 +110,21 @@ export function I18nProvider({ dicts?: Record>; children: ReactNode; }) { - const l = detectLocale(lang); + const [locale, setLocaleState] = useState(() => detectLocale(lang)); + // 外部 setLocale(SettingsPanel 直接调用模块函数)→ 广播 LOCALE_CHANGED_EVENT → + // 本 provider 重读生效(React 化后 localStorage 写入本身不触发重渲染) + useEffect(() => { + const onChange = () => setLocaleState(detectLocale(lang)); + window.addEventListener(LOCALE_CHANGED_EVENT, onChange); + return () => window.removeEventListener(LOCALE_CHANGED_EVENT, onChange); + }, [lang]); const value = useMemo(() => { - const dict = { ...(DEFAULT_DICTS[l] ?? DEFAULT_DICTS.en), ...(dicts?.[l] ?? {}) }; + const dict = { ...(DEFAULT_DICTS[locale] ?? DEFAULT_DICTS.en), ...(dicts?.[locale] ?? {}) }; return { - lang: l, + lang: locale, t: (key, params) => interpolate(key in dict ? dict[key] : (DICTS.zh[key] ?? key), params), }; - }, [l, dicts]); + }, [locale, dicts]); return {children}; }