From 260cbaa6052d9a5066cdcc86c82247a6ae11d150 Mon Sep 17 00:00:00 2001 From: Yangshifu1024 Date: Fri, 16 Oct 2026 17:40:04 +0800 Subject: [PATCH] refactor(settings): merge font slots into three rows and trim filler hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The appearance page rendered six rows for three font slots, each carrying its own gray hint line — the same two sentences repeated three times. Content that was noise: - "12–20px, set independently; defaults: UI 14px, content 14px, code 12px, applies immediately" restated what the Select, the current value, the reset button and the preview row already show, and repeated the other slots' defaults under each slot's own row. - "applies on Enter" was simply wrong: a family commits on Enter, blur, ~600ms idle and unmount, so Enter is never required. Layout: - Merge each slot into one row (interface / content / code): family input and size Select side by side, one preview below. The old two previews per slot rendered the identical sample (same family + same size), so one of them was pure duplication. - Both controls keep their own anchor (ui.font_ / ui.font_size_), so settings search still locates either one. - Left column drops from 50% to a fixed narrow column now that the label is two characters; the reclaimed space goes to the controls. - Height mismatch fixed at the source: the size Select no longer carries size="small", which made it shorter than the Input beside it. - One reset per row, at the far right, clearing both the family and the size of that slot. It submits once — the two commitFont* calls are chained but only the final state is persisted, avoiding two rounds through the serial queue. - Shared guidance (comma-separated families, empty = default) now says it once in the section header instead of per row. Dimensions live in SETTINGS_VISUAL so page CSS carries no literals. --- docs/chat-and-interface.md | 6 +- docs/settings-guide.md | 2 +- ui/src/__tests__/app.smoke.test.tsx | 24 +- ui/src/__tests__/font.settings.test.tsx | 33 +- ui/src/__tests__/font.sizes.test.tsx | 6 +- ui/src/__tests__/settings.style.test.ts | 25 +- ui/src/features/panels/FontSettings.tsx | 366 ++++++++++-------- .../panels/settings/SettingsTheme.tsx | 8 + .../panels/settings/settings-theme.css | 49 ++- ui/src/features/panels/settingsRegistry.ts | 4 +- ui/src/i18n/en-US.ts | 8 +- ui/src/i18n/zh-CN.ts | 8 +- 12 files changed, 325 insertions(+), 214 deletions(-) diff --git a/docs/chat-and-interface.md b/docs/chat-and-interface.md index 2935365e..f62dd8c9 100644 --- a/docs/chat-and-interface.md +++ b/docs/chat-and-interface.md @@ -36,7 +36,11 @@ #### 1.2.x 字体与字号三槽 -每槽独立保存字体族与字号,配置为 `config.ui.{font_sans, font_content, font_mono, font_size_sans, font_size_content, font_size_mono}`,WebView 的 `localStorage` 只作首帧防闪缓存。设置页按「**界面字体 → 界面字号 → 内容字体 → 内容字号 → 代码字体 → 代码字号**」排列,字号 Select 与字体输入同宽,每项带独立预览和还原默认入口。字号为连续 **12–20px** 整数;默认及还原值是 **14 / 14 / 12px**。已有合法显式值保留,新增内容字段缺失时默认 14px;还原只清除本槽缓存覆盖并同步本槽默认值。 +每槽独立保存字体族与字号,配置为 `config.ui.{font_sans, font_content, font_mono, font_size_sans, font_size_content, font_size_mono}`,WebView 的 `localStorage` 只作首帧防闪缓存。设置页按「**界面 → 内容 → 代码**」三行排列,每行左侧只标槽名(定宽窄列,与右侧控件垂直居中)、行内并排放字体族输入与定宽字号 Select、**行末一个重置按钮**,下方一行预览。三个控件等高——字号 Select 不挂 `size="small"`(会比 Input 矮一截、下边线对不齐),重置也不再藏在两个控件的 `suffix` / `suffixIcon` 里。字号为连续 **12–20px** 整数;默认及还原值是 **14 / 14 / 12px**。已有合法显式值保留,新增内容字段缺失时默认 14px;还原只清除本槽缓存覆盖并同步本槽默认值。 + +行末重置**一按清两样**(字体族清空 + 字号回默认档),因为两者同属一槽、分开两个按钮只会让人猜哪个管哪个;实现上顺序调用 `commitFontSlot` 与 `commitFontSizeSlot`(两者都基于 `readStoredFonts()` 叠加、返回全量六字段),但**只提交最终结果一次**,避免在串行提交队列里连发两轮。 + +合并成三行是因为同槽字体族与字号原本各带一个预览行、而两者样本完全相同(同字体族 + 同字号),属纯重复。**两个控件的锚点仍各自独立**(`ui.font_` / `ui.font_size_`),设置搜索能分别定位。公共说明只在「字体」分段标题右侧说一次(`settings.fontHint`),不再逐项重复:字号范围、槽间独立性、默认值与生效方式都由控件本身、当前选中值、还原按钮和预览行体现;唯一无法自明的是输入格式(逗号分隔多个、留空用默认),保留在这行说明里——注意是**即时生效**(回车 / 失焦 / 停手约 600ms / 卸载四条路径都会提交),不是「需按回车」。 界面槽覆盖标题栏、导航、设置、输入控件、工具标题、状态和元数据;内容槽覆盖用户/助手正文,以及子代理、计划、技能、发布说明、Markdown 与纯文本预览中的正文。代码槽覆盖行内代码、代码块、源码预览、diff 与日志,包括内容中的代码。按钮(含正文内复制按钮)仍取界面槽。正文、代码基础字号直接取对应槽;标题和辅助文字保留相对层级,界面/内容偏移基准各为 14px、代码为 12px。三槽不互相继承已选字体或字号,不添加统一 +1/导航 +2 补偿;图标、布局尺寸、图片、PDF 与文档自身排版不随字体设置整体缩放。 diff --git a/docs/settings-guide.md b/docs/settings-guide.md index 8cb1a54b..96fbe92d 100644 --- a/docs/settings-guide.md +++ b/docs/settings-guide.md @@ -77,7 +77,7 @@ - `SETTINGS_VISUAL` 是设置区字号、密度和尺寸入口:内容最大宽度 960px,窄/中/宽控件基准 180/240/360px。使用 `SettingsThemeProvider` 的局部 antd `ConfigProvider` / `App`;Portal 弹框通过 `SettingsThemeScope` 继承公共样式与变量。 - `SettingsForm`、`SettingsFormItem`、`SettingsSection` 统一左右式行与带边框分组:标题/说明在左,控件在右,校验反馈靠近控件,关闭自动冒号。内容容器宽度不超过 680px 时变成上下排列;MCP 编辑器继续标签在上,窄键值行可堆叠。 - 宽度类 `.w-narrow` / `.w-mid` / `.w-wide` 都有 `max-width:100%`;局部页体适配由共享 CSS 收口。不要在页体增加像素内联宽度,或重新引入旧“内容只占右栏 50%”/所有表单强制 vertical 的规则。 -- 字体使用 `--ws-font-sans` / `--ws-font-content` / `--ws-font-mono`,颜色走 `--ws-*` 与 antd token,不新增硬编码字体链或彩色强调色。外观页按语言、主题、字体排列;主题使用预览卡,三组分别为界面、内容、代码,默认字号 14/14/12px,各带字体与字号预览和还原入口;作用范围见[界面指南](./chat-and-interface.md#12x-字体与字号三槽)。 +- 字体使用 `--ws-font-sans` / `--ws-font-content` / `--ws-font-mono`,颜色走 `--ws-*` 与 antd token,不新增硬编码字体链或彩色强调色。外观页按语言、主题、字体排列;主题使用预览卡。字体分组三行(界面 / 内容 / 代码),每行左侧只标槽名、与右侧控件垂直居中且取**定宽窄列**(`SETTINGS_VISUAL.fontSlotLabelWidth`,不再是三档宽度档或半行宽),行内并排字体名输入与定宽字号 Select(`fontSizeSelectWidth`)、行末一个 `fontResetWidth` 宽的重置按钮(一按清本槽字体族与字号),下方一行预览。三个控件 `align-items: center` 等高:字号 Select 不得挂 `size="small"`(会比同行 Input 矮一截),重置不得再藏在控件的 suffix / suffixIcon 里。共性说明只写在分段标题右侧一次,不逐项重复。默认字号 14/14/12px;作用范围见[界面指南](./chat-and-interface.md#12x-字体与字号三槽)。 - 供应商当前是响应式卡片:标题为名称与协议,正文为端点和模型摘要(上下文/输出、图片/视频、active 标记),卡片底部编辑/删除。编辑先在局部表单暂存,不能退回旧“字段变化直接污染页级草稿”行为;字段校验与输出预算见 [供应商与网络](./providers-and-network.md)。 - 网络代理卡片的 `.proxy-mode-list` 必须显式 `font-size: var(--ws-font-size)`,变量由 theme bridge 桥接 antd fontSize。Radio.Group 自带 font-size:0,单靠 DOM 文本存在不能保证标题肉眼可见。manual 校验只看支持协议前缀,空串允许保存;界面默认显示系统代理不等于后端把 null 自动改为显式配置。 - MCP 当前是服务器卡片与编辑弹框,支持用户级/项目级、测试、断开/重连和工具说明。状态按作用域及服务器名匹配;刷新只重读状态。结构化编辑须保留未知字段,解析不了时保留原始 JSON 供修复,不能降成空列表覆盖原文。详细界面约束见下一节,后端入口为 [mcp.rs](../src-tauri/src/host/commands/mcp.rs)。 diff --git a/ui/src/__tests__/app.smoke.test.tsx b/ui/src/__tests__/app.smoke.test.tsx index c231f7a3..9e171fcf 100644 --- a/ui/src/__tests__/app.smoke.test.tsx +++ b/ui/src/__tests__/app.smoke.test.tsx @@ -303,21 +303,21 @@ describe("App 渲染冒烟", () => { expect(document.querySelector('[data-testid="settings-page"] .ant-form-horizontal')).toBeTruthy(); expect(document.querySelector('[data-testid="settings-page"] .ant-form-vertical')).toBeFalsy(); - // Appearance: language, theme, output display and three independent font/size previews. + // Appearance: language, theme, output display and three font slots (one row each). expect(document.querySelectorAll('[data-testid="settings-page"] .ant-select').length).toBeGreaterThanOrEqual(1); - expect(document.body.textContent ?? "").toContain("界面字体"); - expect(document.body.textContent ?? "").toContain("内容字体"); - expect(document.body.textContent ?? "").toContain("代码字体"); - expect(document.querySelectorAll(".font-preview-row").length).toBe(6); + expect(document.body.textContent ?? "").toContain("界面"); + expect(document.body.textContent ?? "").toContain("内容"); + expect(document.body.textContent ?? "").toContain("代码"); + // 三槽合并成三行后,每槽只有一行预览(原字体族/字号各一行、样本完全相同) + expect(document.querySelectorAll(".font-preview-row").length).toBe(3); expect(Array.from(document.querySelectorAll(".settings-section-title")).map((node) => node.textContent)).toEqual(["界面语言", "主题", "输出展示", "字体"]); expect(document.querySelector('[data-setting-id="ui.output_display"]')).toBeTruthy(); - expect(document.querySelector('[data-setting-id="ui.font_sans"] .font-preview-row')).toBeTruthy(); - expect(document.querySelector('[data-setting-id="ui.font_mono"] .font-preview-row')).toBeTruthy(); - expect(document.querySelector('[data-setting-id="ui.font_content"] .font-preview-row')).toBeTruthy(); - expect(document.querySelector('[data-setting-id="ui.font_size_content"] .font-preview-row')).toBeTruthy(); - // Each size row also previews its applied family and size. - expect(document.querySelector('[data-setting-id="ui.font_size_sans"] .font-preview-row')).toBeTruthy(); - expect(document.querySelector('[data-setting-id="ui.font_size_mono"] .font-preview-row')).toBeTruthy(); + // 字体族与字号控件同处一行:各槽两个锚点都在,彼此独立可搜 + for (const slot of ["sans", "content", "mono"]) { + const row = document.querySelector(`[data-setting-id="ui.font_${slot}"]`)!.closest(".settings-font-pair")!; + expect(row.querySelector(`[data-setting-id="ui.font_size_${slot}"]`)).toBeTruthy(); + expect(row.querySelector(".font-preview-row")).toBeTruthy(); + } // 模型与供应商页([docs/providers-and-network](../../../docs/providers-and-network.md)):列表 + 编辑表单字段 + AI 回复语言 await clickTab("模型与供应商"); diff --git a/ui/src/__tests__/font.settings.test.tsx b/ui/src/__tests__/font.settings.test.tsx index 69af2ffa..670c4e25 100644 --- a/ui/src/__tests__/font.settings.test.tsx +++ b/ui/src/__tests__/font.settings.test.tsx @@ -9,6 +9,7 @@ import { useSettings } from "../stores/settings"; import { useUi } from "../stores/ui"; import { configSnapshot, configSaved } from "./configFixtures"; import { AppearanceSettings } from "../features/panels/FontSettings"; +import type { FontSlot } from "../utils/fonts"; const calls: Array<[string, any]> = []; @@ -64,12 +65,17 @@ function mount() { ); } -/** 按 Form.Item 的 label 文本找字体输入框 */ +/** 按字体槽定位输入框:走设置项锚点(ui.font_),不依赖行标签文字。 + * 字体与字号已合并成三行、行标签简化为「界面 / 内容 / 代码」,按 Form.Item label 找已不成立。 */ +const FAMILY_SLOT_BY_LABEL: Record = { + 界面字体: "sans", 内容字体: "content", 代码字体: "mono", +}; + function fontInput(label: string): HTMLInputElement { - const item = Array.from(document.querySelectorAll(".ant-form-item")).find((el) => - (el.querySelector(".ant-form-item-label")?.textContent ?? "").includes(label), - ); - const el = item?.querySelector("input") as HTMLInputElement | null; + const slot = FAMILY_SLOT_BY_LABEL[label]; + const el = (slot + ? document.querySelector(`[data-setting-id="ui.font_${slot}"] input`) + : null) as HTMLInputElement | null; if (!el) throw new Error(`找不到字体输入框:${label}`); return el; } @@ -159,14 +165,18 @@ describe("界面字体:提交不丢与后端落盘", () => { expect(fontPrefCalls()).toHaveLength(0); }); - it("「恢复默认」清空该槽并把空值同步到后端", async () => { + it("行末「恢复默认」一次清掉本槽字体族与字号,且只提交一次", async () => { localStorage.setItem("ws_font_sans", "Inter"); + localStorage.setItem("ws_font_size_sans", "17"); mount(); expect(fontInput("界面字体").value).toBe("Inter"); - fireEvent.click(screen.getByLabelText(/恢复默认・界面字体/)); + fireEvent.click(screen.getByLabelText("恢复默认・界面字体 + 界面字号")); await waitFor(() => expect(localStorage.getItem("ws_font_sans")).toBeNull()); - await waitFor(() => expect(fontPrefCalls().some((a) => a?.sans === "")).toBe(true)); + // 一次点击 = 一次落盘(两个 commitFont* 串行后只提交最终结果) + await waitFor(() => expect(fontPrefCalls()).toHaveLength(1)); + expect(fontPrefCalls()[0]).toMatchObject({ sans: "", fontSizeSans: 14 }); + await waitFor(() => expect(useSettings.getState().config?.ui.font_sans).toBe("")); }); it("authoritative font receipt updates saved state", async () => { mount(); @@ -226,17 +236,18 @@ describe("界面字体:提交不丢与后端落盘", () => { await waitFor(() => expect(useSettings.getState().config?.ui.font_content).toBe("Source Han Serif")); expect(fontInput("界面字体").value).toBe("Inter"); expect(fontInput("代码字体").value).toBe("Menlo"); - const preview = input.closest(".settings-font-control")!.querySelector(".font-preview-row")!; + const preview = input.closest(".settings-font-pair")!.querySelector(".font-preview-row")!; expect(preview.style.fontFamily).toBe('"Source Han Serif", var(--ws-font-content-fallback)'); expect(preview.style.fontSize).toBe("18px"); }); - it("restoring the content family preserves interface/code families and all sizes", async () => { + it("restoring the content slot preserves interface/code families", async () => { localStorage.setItem("ws_font_sans", "Inter"); localStorage.setItem("ws_font_content", "Georgia"); localStorage.setItem("ws_font_mono", "Menlo"); mount(); - fireEvent.click(screen.getByLabelText("恢复默认・内容字体")); + // 行末重置一次清内容槽的字体族与字号;另外两槽不动 + fireEvent.click(screen.getByLabelText("恢复默认・内容字体 + 内容字号")); await waitFor(() => expect(fontPrefCalls()).toHaveLength(1)); expect(fontPrefCalls()[0]).toMatchObject({ sans: "Inter", content: "", mono: "Menlo", diff --git a/ui/src/__tests__/font.sizes.test.tsx b/ui/src/__tests__/font.sizes.test.tsx index 24e67249..1da4c390 100644 --- a/ui/src/__tests__/font.sizes.test.tsx +++ b/ui/src/__tests__/font.sizes.test.tsx @@ -96,7 +96,9 @@ describe("font sizes in the runtime UI", () => { for (const [slot, label, px, family] of [["sans", "17px", "17px", "Saved Sans"], ["content", "16px", "16px", "Saved Content"], ["mono", "19px", "19px", "Saved Mono"]] as const) { const sizeField = document.querySelector(`[data-setting-id="ui.font_size_${slot}"]`)!; expect(sizeField.querySelector(".ant-select")?.textContent).toContain(label); - const preview = sizeField.querySelector(".font-preview-row")!; + // 预览行已从字号控件内部上移到整行容器(三槽合并后只留一行预览) + const preview = sizeField.closest(".settings-font-pair")! + .querySelector(".font-preview-row")!; expect(preview.style.fontSize).toBe(px); expect(preview.style.fontFamily).toContain(family); expect(document.querySelector(`[data-setting-id="ui.font_${slot}"] input`)?.value).toBe(family); @@ -156,7 +158,7 @@ describe("font sizes in the runtime UI", () => { fireEvent.click(screen.getByRole("button", { name: "重试・界面字号" })); await waitFor(() => expect(useSettings.getState().config?.ui.font_size_sans).toBe(19)); expect(invoke).toHaveBeenLastCalledWith("set_font_prefs", expect.objectContaining({ fontSizeSans: 19, fontSizeContent: 14, fontSizeMono: 12 })); - fireEvent.click(screen.getByRole("button", { name: "恢复默认・界面字号" })); + fireEvent.click(screen.getByRole("button", { name: "恢复默认・界面字体 + 界面字号" })); expect(screen.getByTestId("app-size").textContent).toBe("14"); await waitFor(() => expect(useSettings.getState().config?.ui.font_size_sans).toBe(14)); }); diff --git a/ui/src/__tests__/settings.style.test.ts b/ui/src/__tests__/settings.style.test.ts index 32354524..77ff8175 100644 --- a/ui/src/__tests__/settings.style.test.ts +++ b/ui/src/__tests__/settings.style.test.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const theme = readFileSync(join(here, "../features/panels/settings/SettingsTheme.tsx"), "utf8"); const css = readFileSync(join(here, "../features/panels/settings/settings-theme.css"), "utf8"); +const fontSettings = readFileSync(join(here, "../features/panels/FontSettings.tsx"), "utf8"); describe("设置页共享视觉契约(settings-ui-unification)", () => { it("所有面板共享一个 antd 主题层与左右式 Form", () => { @@ -30,14 +31,32 @@ describe("设置页共享视觉契约(settings-ui-unification)", () => { expect(css).toMatch(/@container settings-content \(max-width: 680px\)[\s\S]*?\.settings-provider-model-form\s*\{\s*grid-template-columns:\s*minmax\(0, 1fr\)/); }); - it("主题预览铺满卡片,字体输入与各自预览占同一半宽设置行", () => { + it("主题预览铺满卡片,字体行左列为定宽窄列、行内双控件占满右侧", () => { expect(css).toMatch(/\.settings-theme-root \.settings-theme-picker\s*\{\s*width:\s*100%/); expect(css).toMatch(/\.settings-row\.settings-row-full \.ant-form-item-control\s*\{[^}]*flex:\s*0 0 100%/); - expect(css).toMatch(/\.settings-row\.settings-font-row \.ant-form-item-control\s*\{[^}]*flex:\s*0 0 50%/); - expect(css).toMatch(/\.settings-row\.settings-font-row \.ant-form-item-label\s*\{[^}]*margin-inline-end:\s*var\(--settings-row-gap\)/); + // 三槽合并成三行后左列只剩「界面 / 内容 / 代码」两字标签:定宽窄列,右侧控件吃掉余量 + expect(css).toMatch(/\.settings-row\.settings-font-row \.ant-form-item-label\s*\{[^}]*flex:\s*0 0 var\(--settings-font-slot-width\)/); + expect(css).toMatch(/\.settings-row\.settings-font-row \.ant-form-item-control\s*\{[^}]*flex:\s*1 1 auto/); + expect(css).toMatch(/\.settings-row\.settings-font-row \.ant-form-item-label\s*\{[^}]*align-items:\s*center/); + // 三个尺寸都进 SETTINGS_VISUAL,页体不写字面量 + expect(theme).toMatch(/fontSlotLabelWidth:\s*72/); + expect(theme).toMatch(/fontSizeSelectWidth:\s*88/); + expect(theme).toMatch(/fontResetWidth:\s*32/); expect(css).toMatch(/\.settings-theme-root \.settings-font-control\s*\{[^}]*flex-direction:\s*column/); }); + it("字体行三个控件等高居中,行末重置按钮独立成列", () => { + // Select 不能靠 size="small" 缩高(会比同行 Input 矮一截、下边线对不齐), + // 重置也不能再藏在 suffixIcon 里——两者都已移到行末的整槽重置按钮 + const sizeSelect = fontSettings.match(/[\s\S]*?\/>/)?.[0] ?? ""; + expect(sizeSelect).not.toBe(""); + expect(sizeSelect).not.toMatch(/size="small"/); + expect(sizeSelect).not.toMatch(/suffixIcon/); + expect(css).toMatch(/\.settings-font-pair\s*\{[^}]*grid-template-columns:[^}]*var\(--settings-font-size-select-width\)[^}]*var\(--settings-font-reset-width\)/); + expect(css).toMatch(/\.settings-font-pair\s*\{[^}]*align-items:\s*center/); + expect(css).toMatch(/\.settings-theme-root \.settings-font-reset\s*\{[^}]*align-items:\s*center/); + }); + it("内容宽度、行卡片、控制宽度和窄屏堆叠由公共常量与样式管理", () => { expect(theme).toMatch(/contentWidth:\s*960/); expect(theme).toMatch(/controlWidth:\s*\{\s*narrow:\s*180,\s*mid:\s*240,\s*wide:\s*360/); diff --git a/ui/src/features/panels/FontSettings.tsx b/ui/src/features/panels/FontSettings.tsx index 4496eaad..5a878942 100644 --- a/ui/src/features/panels/FontSettings.tsx +++ b/ui/src/features/panels/FontSettings.tsx @@ -84,8 +84,9 @@ export function AppearanceSettings({ draft, patchDraft }: { - {/* Each family is followed by its independent size and preview. */} - + {/* 三槽各占一行(界面 / 内容 / 代码),行内是「字体族输入 + 字号下拉」加该槽预览。 + 共性说明只在分段标题右侧说一次,见 fontHint。 */} + @@ -122,19 +123,144 @@ function persistFontPrefsToBackend(prefs: FontPreferences): Promise<"saved" | "f return attempt; } -/** 单个字体槽输入项:回车 / 失焦 / 停手 / 卸载四处都提交,值有变化才落盘,附一键恢复默认按钮。 +/** Three independent groups: interface, content, and code. */ +function FontSettings() { + const { t } = useTranslation(); + const applied = useFontPreferences(); + + return ( + <> + + + + + ); +} + +/** 单槽合并行:左侧短标签,右上行内「字体族 + 字号 + 行末重置」,下方一行预览。 + * + * 为什么一槽只占一行(而不是字体族、字号各一行):同槽两者原本各带一个预览行,而两个预览样本 + * 完全相同(同字体族 + 同字号),纯属重复;控件同属一槽,合并后预览与控件也归到同一行,行数 6 → 3。 + * 两个控件各自保留独立的锚点(ui.font_ / ui.font_size_),设置搜索仍能分别定位。 + * + * 重置只有一个按钮(行末):字体族与字号同属一槽,分开两个按钮反而让人猜哪个管哪个。 + * 点击后本槽的字体族清空(回系统默认)、字号回默认档,一次提交写全六字段。 */ +function FontSlotRow({ slot, label, familyLabel, sizeLabel, appliedFont, appliedLevel }: { + slot: FontSlot; + /** 行标签:只说清是哪一槽(界面 / 内容 / 代码) */ + label: string; + /** 字体项的无障碍名(「界面字体」):与所在行的短标签区分 */ + familyLabel: string; + /** 字号项的无障碍名(「界面字号」):与所在行的短标签区分 */ + sizeLabel: string; + appliedFont: string; + appliedLevel: FontSizeLevel; +}) { + const { t } = useTranslation(); + const [saveFailed, setSaveFailed] = useState(false); + const [retrying, setRetrying] = useState(false); + const saveGeneration = useRef(0); + /** 行末重置按钮的无障碍名:说清它一次清两样(字体族 + 字号),避免与旧的单一重置混淆 */ + const resetLabel = `${t("settings.fontReset")}・${familyLabel} + ${sizeLabel}`; + + function commit(next: FontPreferences) { + const generation = ++saveGeneration.current; + setRetrying(true); + void persistFontPrefsToBackend(next).then((saved) => { + if (generation !== saveGeneration.current) return; + setSaveFailed(saved === "failed"); + setRetrying(false); + }); + } + + /** 整槽重置:字体族清空 + 字号回默认档,只提交一次**。 + * commitFontSlot / commitFontSizeSlot 都基于 readStoredFonts() 叠加(各自返回全量六字段), + * 两者串行执行后只提交最终结果:既保留各自的净化与去重逻辑,也不会打两次请求。 + * 注意不能写成提交两次——那会在同一个串行队列里连发两轮,界面无必要地闪两次保存态。 */ + function resetSlot() { + commitFontSlot(slot, ""); + commit(commitFontSizeSlot(slot, DEFAULT_FONT_SIZE_LEVEL[slot])); + } + + return ( + +
+ + + + )} + {/* 该槽预览样本:字体族与字号都取本槽已生效值 */} +
+ {PREVIEW_SAMPLE} +
+
+
+ ); +} + +/** 单槽字体族输入控件:回车 / 失焦 / 停手 / 卸载四处都提交,值有变化才落盘,附一键恢复默认按钮。 * - * 为什么要四处提交(2026-09-19 修缺陷):原来只有「回车或失焦」两个时机,而提交前有个 - * 「没变化就 return」的短路——输入后没碰回车、也没点别处(直接关设置页/关窗口),就等于什么都没发生, - * 界面看上去就是「字体没保存」。 */ -function FontField({ slot, label, hint, settingId, appliedFont, appliedLevel }: { + * 为什么要四处提交(2026-09-19 修缺陷):原来只有「回车或失焦」两个时机,而提交前有个 + * 「没变化就 return」的短路——输入后没碰回车、也没点别处(直接关设置页/关窗口),就等于什么都没发生, + * 界面看上去就是「字体没保存」。 */ +function FontFamilyControl({ slot, label, settingId, appliedFont }: { slot: FontSlot; label: string; - hint: string; /** 设置项锚点 id(= 注册表 SETTINGS_ITEMS.id):搜索命中定位与临时高亮靠它查 */ settingId: string; appliedFont: string; - appliedLevel: FontSizeLevel; }) { const { t } = useTranslation(); const [draft, setDraft] = useState(() => readStoredFonts()[slot]); @@ -211,130 +337,52 @@ function FontField({ slot, label, hint, settingId, appliedFont, appliedLevel }: }, [slot]); return ( - - {/* 字体槽占用半行宽度(不参与宽度三档,见 WIDTH_EXEMPT_ITEM_IDS)。 */} -
- { - edited.current = true; - setDraft(e.target.value); - scheduleIdleCommit(); - }} - onCompositionStart={() => { - composing.current = true; - }} - onCompositionEnd={() => { - composing.current = false; - scheduleIdleCommit(); - }} - onPressEnter={() => commit(draft)} - onBlur={() => { - // 失焦时先把组合态强制结束:compositionend 漏发(某些输入法/粘贴场景)时 - // 不能让 composing 永久为真——否则回车、停手、卸载三条提交路径全被挡住 - composing.current = false; - commit(draft, false); - // 失焦回填:显示值与存储对齐(已存值 / 刚净化后的值),避免两者漂移 - setDraft(readStoredFonts()[slot]); - }} - suffix={ - } -
- {PREVIEW_SAMPLE} -
-
-
- ); -} - -/** Three independent groups: interface, content, and code. */ -function FontSettings() { - const { t } = useTranslation(); - const applied = useFontPreferences(); - - return ( - <> - {/* 界面字体(sans)—— 字体族输入框 */} - - {/* 界面字号(sans)—— 紧贴界面字体下方的字号 Select,v2 布局 */} - + { + edited.current = true; + setDraft(e.target.value); + scheduleIdleCommit(); + }} + onCompositionStart={() => { + composing.current = true; + }} + onCompositionEnd={() => { + composing.current = false; + scheduleIdleCommit(); + }} + onPressEnter={() => commit(draft)} + onBlur={() => { + // 失焦时先把组合态强制结束:compositionend 漏发(某些输入法/粘贴场景)时 + // 不能让 composing 永久为真——否则回车、停手、卸载三条提交路径全被挡住 + composing.current = false; + commit(draft, false); + // 失焦回填:显示值与存储对齐(已存值 / 刚净化后的值),避免两者漂移 + setDraft(readStoredFonts()[slot]); + }} /> - - - {/* 代码字体(mono)—— 字体族输入框 */} - - {/* 代码字号(mono)—— 紧贴代码字体下方的字号 Select */} - - + {saveFailed && } + ); } /** Independent size slot: integer 12–20px choices, serialized with all families. - * 改即提交:不需要“停手 / 回车 / 失焦”三处提交(控件本身是枚举选择、不存在部分输入态)。 */ -function FontSizeField({ slot, label, hint, settingId, appliedLevel, appliedFont }: { + * 改即提交:不需要“停手 / 回车 / 失焦”三处提交(控件本身是枚举选择、不存在部分输入态)。 + * 重置入口在行末(FontSlotRow),因此这里不挂 size="small" 的折叠箭头按钮: + * 它会让 Select 比同行 Input 矮一截、两条下边线对不齐。 */ +function FontSizeControl({ slot, label, settingId, appliedLevel }: { slot: FontSlot; label: string; - hint: string; + /** 设置项锚点 id(= 注册表 SETTINGS_ITEMS.id):搜索命中定位与临时高亮靠它查 */ settingId: string; appliedLevel: FontSizeLevel; - appliedFont: string; }) { const { t } = useTranslation(); const [saveFailed, setSaveFailed] = useState(false); @@ -352,55 +400,29 @@ function FontSizeField({ slot, label, hint, settingId, appliedLevel, appliedFont } return ( - -
- - className="settings-font-size-select" - value={appliedLevel} - onChange={(v) => { - const next = commitFontSizeSlot(slot, v); - commit(next); - }} - options={FONT_SIZE_LEVELS.map((lvl) => ({ value: lvl, label: `${lvl}px` }))} - suffixIcon={ - - )} - {/* 该槽预览样本:字号本档、字体族取已生效值 */} -
+ + className="settings-font-size-select" + value={appliedLevel} + onChange={(v) => { + const next = commitFontSizeSlot(slot, v); + commit(next); + }} + options={FONT_SIZE_LEVELS.map((lvl) => ({ value: lvl, label: `${lvl}px` }))} + /> + {saveFailed && ( +
-
-
+ {t("common.retry")} + + )} + ); } diff --git a/ui/src/features/panels/settings/SettingsTheme.tsx b/ui/src/features/panels/settings/SettingsTheme.tsx index 5329d311..8154b944 100644 --- a/ui/src/features/panels/settings/SettingsTheme.tsx +++ b/ui/src/features/panels/settings/SettingsTheme.tsx @@ -18,6 +18,11 @@ export const SETTINGS_VISUAL = { rowGap: 24, rowPaddingBlock: 10, rowPaddingInline: 20, + // 字体行左列(槽名「界面 / 内容 / 代码」)定宽:三槽合并成三行后标签只有两个字, + // 旧的半行宽会空出大片留白,余量让给右侧控件。 + fontSlotLabelWidth: 72, + fontSizeSelectWidth: 88, + fontResetWidth: 32, controlWidth: { narrow: 180, mid: 240, wide: 360 }, } as const; @@ -40,6 +45,9 @@ const SETTINGS_STYLE = { "--settings-control-width-narrow": `${SETTINGS_VISUAL.controlWidth.narrow}px`, "--settings-control-width-mid": `${SETTINGS_VISUAL.controlWidth.mid}px`, "--settings-control-width-wide": `${SETTINGS_VISUAL.controlWidth.wide}px`, + "--settings-font-slot-width": `${SETTINGS_VISUAL.fontSlotLabelWidth}px`, + "--settings-font-size-select-width": `${SETTINGS_VISUAL.fontSizeSelectWidth}px`, + "--settings-font-reset-width": `${SETTINGS_VISUAL.fontResetWidth}px`, } as CSSProperties; /** Locally themes settings and all antd overlays while preserving the application's light/dark algorithm. */ diff --git a/ui/src/features/panels/settings/settings-theme.css b/ui/src/features/panels/settings/settings-theme.css index 43846c2f..c4ecfbc5 100644 --- a/ui/src/features/panels/settings/settings-theme.css +++ b/ui/src/features/panels/settings/settings-theme.css @@ -287,14 +287,41 @@ } .settings-theme-root .settings-theme-picker { width: 100%; } .settings-theme-root .settings-theme-picker-row .ant-form-item-control-input-content { justify-content: flex-start; } +/* 字体行左列:槽名只有两个字(界面 / 内容 / 代码),给定宽窄列。 + 原来是 50% —— 那是字体族 / 字号各占一行、说明文案较长时的布局;合并成三行后左列只剩 + 两字标签,半行宽就成大片留白,故改为定宽(SETTINGS_VISUAL.fontSlotLabelWidth)并把余量让给控件。 */ .settings-theme-root .settings-form .settings-row.settings-font-row .ant-form-item-label { - flex: 0 0 calc(50% - var(--settings-row-gap)); - max-width: calc(50% - var(--settings-row-gap)); + flex: 0 0 var(--settings-font-slot-width); + max-width: var(--settings-font-slot-width); margin-inline-end: var(--settings-row-gap); + /* 行标签与右侧双控件(输入框 + 字号下拉)垂直居中;否则短标签会偏在预览行上方 */ + display: flex; + align-items: center; } .settings-theme-root .settings-form .settings-row.settings-font-row .ant-form-item-control { - flex: 0 0 50%; - max-width: 50%; + flex: 1 1 auto; + max-width: 100%; +} +/* 字体行右侧容器:上行「字体族 + 字号 + 行末重置」并排,下行预览占满整行。 + 字体族吃掉剩余宽度,字号与重置按钮定宽——两者分别是枚举输入与图标动作,不靠百分比互相挤。 + 用 grid 而非嵌套 flex:预览只需 grid-column: 1 / -1 就能自动换行到第二个格子行。 + align-items: center:字号 Select 必须与 Input 等高——antd 的 Select 默认带自身上下内边距, + 配 size="small" 会比同行的 Input 矮一截,两条下边线对不齐。 */ +.settings-theme-root .settings-font-pair { + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: + minmax(0, 1fr) + var(--settings-font-size-select-width) + var(--settings-font-reset-width); + align-items: center; + column-gap: var(--settings-row-gap); + row-gap: 8px; +} +.settings-theme-root .settings-font-pair > .font-preview-row { + grid-column: 1 / -1; + overflow-wrap: anywhere; } .settings-theme-root .settings-font-control { display: flex; @@ -305,8 +332,14 @@ } .settings-theme-root .settings-font-control .ant-input-affix-wrapper { width: 100%; } .settings-theme-root .settings-font-control .font-preview-row { overflow-wrap: anywhere; } -/* 字号行(ui.font_size_sans / ui.font_size_content / ui.font_size_mono):与上方字体族行同构。 - antd Select 默认 inline-block 按内容撑宽,缺 width:100% 会与上方满宽输入框右侧错位。 */ +/* 整槽重置按钮:与同行两个控件等高的方形图标动作,一键清掉本槽的字体族与字号。 */ +.settings-theme-root .settings-font-reset { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-width: 0; +} .settings-theme-root .settings-font-size-control { display: flex; width: 100%; @@ -756,6 +789,10 @@ .settings-theme-root .settings-form .settings-row.settings-font-row .ant-form-item-label, .settings-theme-root .settings-form .settings-row.settings-font-row .ant-form-item-control { flex: 0 0 100%; max-width: 100%; } .settings-theme-root .settings-form .settings-row.settings-font-row .ant-form-item-label { margin-inline-end: 0; } + /* 堆叠后字号列不再是右侧窄格,按可用宽度均分(否则定宽在窄容器里显得突兀) */ + .settings-theme-root .settings-font-pair { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) var(--settings-font-reset-width); + } .settings-theme-root .settings-form .settings-row.settings-row-wide-control .ant-form-item-label, .settings-theme-root .settings-form .settings-row.settings-row-wide-control .ant-form-item-control { flex: 0 0 100%; max-width: 100%; } .settings-theme-root .settings-form .settings-row.settings-row-wide-control .ant-form-item-label { margin-inline-end: 0; } diff --git a/ui/src/features/panels/settingsRegistry.ts b/ui/src/features/panels/settingsRegistry.ts index 7178b015..4f0a0d03 100644 --- a/ui/src/features/panels/settingsRegistry.ts +++ b/ui/src/features/panels/settingsRegistry.ts @@ -390,9 +390,9 @@ export const SHELL_SETTING_KEYS: string[] = [ "themeLight", // → ui.theme 选项 "themeDark", // → ui.theme 选项 "outputDisplayHint", // → ui.output_display 展示说明 - "fontHint", // → ui.font_sans / ui.font_mono 的输入说明 + "fontHint", // → 字体分组标题旁的共性说明(三槽合并成一行后只说一次) + "fontSlotSans", "fontSlotContent", "fontSlotMono", // → 字体行短标签(界面 / 内容 / 代码) "fontReset", // → 字体槽的「恢复默认」按钮 - "fontSizeHint", // → 字号 Select 说明(v2);选项只显 px 数字,不再用 i18n 档位文案 // —— 模型与供应商页的从属文案(项已登记:ui.ai_language) —— "providerGeneral", // 模型与供应商页的通用分组标题 diff --git a/ui/src/i18n/en-US.ts b/ui/src/i18n/en-US.ts index 902ab6e5..8d187f36 100644 --- a/ui/src/i18n/en-US.ts +++ b/ui/src/i18n/en-US.ts @@ -133,8 +133,12 @@ export default { // Batch ④: the placeholder used to borrow composer.effortDefault (cross-segment key); now its own key (same value) aiLanguagePlaceholder: "Default", compactThreshold: "Auto-compact threshold", compactTimeout: "Compact request timeout (seconds, 30–3600)", theme: "Theme", themeSystem: "Follow system", themeLight: "Light", themeDark: "Dark", - fontGroup: "Fonts", uiFont: "UI font", contentFont: "Content font", monoFont: "Code font", fontHint: "Font families installed on this machine, comma-separated; empty = default, applies on Enter", - uiFontSize: "UI font size", contentFontSize: "Content font size", monoFontSize: "Code font size", fontSizeHint: "12–20px, set independently; defaults: UI 14px, content 14px, code 12px. Applies immediately", + fontGroup: "Fonts", uiFont: "UI font", contentFont: "Content font", monoFont: "Code font", + // Short per-row labels (each row already holds both the family input and the size select); + // the six item display names stay for search, accessible names and registry labelKeys. + fontSlotSans: "UI", fontSlotContent: "Content", fontSlotMono: "Code", + fontHint: "Locally installed font names, comma-separated; empty uses the default. Changes apply immediately", + uiFontSize: "UI font size", contentFontSize: "Content font size", monoFontSize: "Code font size", fontReset: "Reset to default", approvalEnabled: "Start new sessions in Plan mode", approvalEnabledHint: "On: new sessions start in Plan mode. Off: new sessions start in Full Access mode. Existing sessions are unaffected.", confirmOutside: "Confirm writes creating paths outside workspace", confirmPush: "Confirm before git push", autoConfirm: "Auto-confirm recommended option after 5 minutes", autoConfirmHint: "Checked: approvals auto-approve after 5 minutes without response; unchecked: wait for your choice indefinitely", diff --git a/ui/src/i18n/zh-CN.ts b/ui/src/i18n/zh-CN.ts index 7852d8ef..87c1379a 100644 --- a/ui/src/i18n/zh-CN.ts +++ b/ui/src/i18n/zh-CN.ts @@ -132,8 +132,12 @@ export default { // 批④:占位符原先借用 composer.effortDefault(跨段借键),本批拆出独立键(值不变) aiLanguagePlaceholder: "默认", compactThreshold: "自动压缩阈值", compactTimeout: "压缩请求超时(秒,30–3600)", theme: "主题", themeSystem: "跟随系统", themeLight: "亮色", themeDark: "暗色", - fontGroup: "字体", uiFont: "界面字体", contentFont: "内容字体", monoFont: "代码字体", fontHint: "本机已安装的字体名,多个用逗号分隔;留空恢复默认,回车生效", - uiFontSize: "界面字号", contentFontSize: "内容字号", monoFontSize: "代码字号", fontSizeHint: "12–20px,三组独立;默认界面 14px、内容 14px、代码 12px,改即生效", + fontGroup: "字体", uiFont: "界面字体", contentFont: "内容字体", monoFont: "代码字体", + // 字体行的短标签(行内已含字体族与字号两个控件,行标签只说清是哪一槽); + // 六个设置项的显示名(uiFont / uiFontSize 等)仍用于搜索、无障碍名与注册表 labelKey。 + fontSlotSans: "界面", fontSlotContent: "内容", fontSlotMono: "代码", + fontHint: "本机字体名,多个用逗号分隔;留空使用默认,改动即时生效", + uiFontSize: "界面字号", contentFontSize: "内容字号", monoFontSize: "代码字号", fontReset: "恢复默认", approvalEnabled: "新会话默认计划模式", approvalEnabledHint: "开启时新会话从计划模式开始;关闭时从完全访问模式开始。已有会话不受影响。", confirmOutside: "工作区外新建路径时确认", confirmPush: "git push 前确认", autoConfirm: "5 分钟后自动确认推荐选项", autoConfirmHint: "勾选:审批 5 分钟无响应自动允许;不勾选:始终等待你的选择",