diff --git a/docs/chat-and-interface.md b/docs/chat-and-interface.md index 2935365..f62dd8c 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 8cb1a54..96fbe92 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 c231f7a..9e171fc 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 69af2ff..670c4e2 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 24e6724..1da4c39 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 3235452..77ff817 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 4496eaa..5a87894 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 5329d31..8154b94 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 43846c2..c4ecfbc 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 7178b01..4f0a0d0 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 902ab6e..8d187f3 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 7852d8e..87c1379 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 分钟无响应自动允许;不勾选:始终等待你的选择",