From 46256a1e03c18b101ae8eed36292f638a04287d9 Mon Sep 17 00:00:00 2001 From: Fishquito7 Date: Sat, 12 Sep 2026 19:16:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E3=80=8C=E6=89=8B=E6=9C=BA=E8=AE=BF=E9=97=AE=E3=80=8D=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E5=9B=BE=E6=A0=87=E6=8D=A2=E6=88=90=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DSH 0.1.x 的设置外壳把导航图标写死在 navIcon(id) 里(只认 models / agent-presets / plugins),settings.section 的注册项也只投影 id / order / label,没有 icon 字段可传,所以 dsh-pocket 一直显示外壳兜底的齿轮。 按社区通行做法补上:设置弹层挂载后按「自己那一行的可见文案」认行(文案走 translate('section'),locale 切换后由 MutationObserver 重新认行),给该行打 data 标记,再用一条只认该标记的样式隐藏兜底齿轮、以 CSS mask 画出 16×16 手机图标(background-color: currentColor,随主题与选中态)。标记与样式随 ctx.effect 释放一起回收。 图标按 16px 网格重新落点:设计稿描边只占机身宽 7.8%,等比缩到 16px 是 0.78px,与 DSH 图标族 1.5px 不一致,故描边统一取 1.5px;听筒与 Home 键改为 按留白落点,避免在 16px 下与机身边框糊成一条;Home 键壁厚缩到 0.53px 无法 成环,落成同直径实心点。窄屏跟随 mobile.css.ts 把图标缩到 14px。 认行判定 isOwnNavRow 是不碰 DOM 的纯函数,按仓库现有做法(nav-targets.mjs / layout-mode.mjs)用字符串桩覆盖:非本行不命中、文案缺失不撒网、切语言后旧行 失效、图标几何留白与设计稿比例、产物是否已重新打包。 上游若给 settings.section 增加 icon 字段,client/pocket-nav-icon.mjs 可整个删掉。 client/client.js 用仓库锁定的 esbuild(npm ci)重新打包,顺带对齐了产物与依赖 锁之间的 3 处 esbuild 版本格式差异(2 行非 ASCII 转义 + 1 处箭头函数体)。 --- client/client.js | 90 ++++++++++++++++++-- client/index.jsx | 5 ++ client/pocket-nav-icon.mjs | 156 +++++++++++++++++++++++++++++++++++ test/pocket-nav-icon.test.js | 139 +++++++++++++++++++++++++++++++ 4 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 client/pocket-nav-icon.mjs create mode 100644 test/pocket-nav-icon.test.js diff --git a/client/client.js b/client/client.js index d2eb711..8a7571f 100644 --- a/client/client.js +++ b/client/client.js @@ -105,6 +105,87 @@ function redactStatus(s) { }; } +// client/pocket-nav-icon.mjs +var NAV_ICON_MARKER = "data-dsh-pocket-nav-icon"; +var NAV_ROW_SELECTOR = '[role="dialog"] nav button'; +var NAV_ICON_SIZE = 16; +var NAV_ICON_SIZE_NARROW = 14; +var NARROW_MEDIA_QUERY = "(max-width: 1023px)"; +var PHONE_GLYPH_SVG = ''; +function phoneMaskUrl(svg = PHONE_GLYPH_SVG) { + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} +function isOwnNavRow(rowText, wantedLabel) { + const wanted = String(wantedLabel ?? "").trim(); + if (wanted.length === 0) return false; + return String(rowText ?? "").trim() === wanted; +} +function navIconCss(maskUrl) { + return [ + `[${NAV_ICON_MARKER}] > svg { display: none; }`, + `[${NAV_ICON_MARKER}]::before {`, + ` content: '';`, + ` flex: none;`, + ` width: ${NAV_ICON_SIZE}px;`, + ` height: ${NAV_ICON_SIZE}px;`, + ` background-color: currentColor;`, + ` -webkit-mask-image: url("${maskUrl}");`, + ` mask-image: url("${maskUrl}");`, + ` -webkit-mask-repeat: no-repeat;`, + ` mask-repeat: no-repeat;`, + ` -webkit-mask-position: center;`, + ` mask-position: center;`, + ` -webkit-mask-size: ${NAV_ICON_SIZE}px ${NAV_ICON_SIZE}px;`, + ` mask-size: ${NAV_ICON_SIZE}px ${NAV_ICON_SIZE}px;`, + `}`, + `@media ${NARROW_MEDIA_QUERY} {`, + ` [${NAV_ICON_MARKER}]::before {`, + ` width: ${NAV_ICON_SIZE_NARROW}px;`, + ` height: ${NAV_ICON_SIZE_NARROW}px;`, + ` -webkit-mask-size: ${NAV_ICON_SIZE_NARROW}px ${NAV_ICON_SIZE_NARROW}px;`, + ` mask-size: ${NAV_ICON_SIZE_NARROW}px ${NAV_ICON_SIZE_NARROW}px;`, + ` }`, + `}` + ].join("\n"); +} +function installSettingsNavIcon(ctx, resolveLabel) { + if (typeof document === "undefined") return; + ctx.effect(() => { + const tag = document.createElement("style"); + tag.dataset.plugin = "dsh-pocket"; + tag.dataset.pluginCss = "dsh-pocket/nav-icon"; + tag.textContent = navIconCss(phoneMaskUrl()); + document.head.appendChild(tag); + let disposed = false; + let scheduled = false; + const sync = () => { + scheduled = false; + if (disposed) return; + const wanted = resolveLabel(); + for (const row of document.querySelectorAll(NAV_ROW_SELECTOR)) { + if (isOwnNavRow(row.textContent, wanted)) row.setAttribute(NAV_ICON_MARKER, ""); + else row.removeAttribute(NAV_ICON_MARKER); + } + }; + const schedule = () => { + if (scheduled || disposed) return; + scheduled = true; + queueMicrotask(sync); + }; + sync(); + const observer = new MutationObserver(schedule); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + return () => { + disposed = true; + observer.disconnect(); + for (const row of document.querySelectorAll(`[${NAV_ICON_MARKER}]`)) { + row.removeAttribute(NAV_ICON_MARKER); + } + tag.remove(); + }; + }, "dsh-pocket: settings nav icon"); +} + // client/mobile/MobileNavToggle.tsx var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); function MobileNavToggle({ toggleSidebar, t }) { @@ -1799,8 +1880,8 @@ function mobileApply(ctx) { ctx.effect(() => { if (!narrow.matches) return () => { }; - const PHRASES = ["加载提供方目录失败", "Settings are unavailable in this browser"]; - const NOTICE = "手机上不支持模型设置,请去电脑端修改设置"; + const PHRASES = ["\u52A0\u8F7D\u63D0\u4F9B\u65B9\u76EE\u5F55\u5931\u8D25", "Settings are unavailable in this browser"]; + const NOTICE = "\u624B\u673A\u4E0A\u4E0D\u652F\u6301\u6A21\u578B\u8BBE\u7F6E\uFF0C\u8BF7\u53BB\u7535\u8111\u7AEF\u4FEE\u6539\u8BBE\u7F6E"; const findDeepest = (el) => { let deepest = el; for (const child of el.querySelectorAll("*")) { @@ -1821,9 +1902,7 @@ function mobileApply(ctx) { const observer = new MutationObserver(patch); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); patch(); - return () => { - observer.disconnect(); - }; + return () => observer.disconnect(); }, "dsh-mobile-nav: replace model-settings load error with mobile hint"); ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({ name: "conversation.session.header.actions", @@ -2718,6 +2797,7 @@ function apply(ctx) { const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(POCKET_RPC_CHANNEL, endpoint, payload, signal); const translate = ctx.locale.bind(NS2); ctx.effect(() => ctx.locale.register(NS2, { zh: zh2, en: en2 }), "dsh-pocket: pocket locale dictionaries"); + installSettingsNavIcon(ctx, () => translate("section")); ctx.slots.inject( "settings.section", () => ctx.slots.register( diff --git a/client/index.jsx b/client/index.jsx index 99f09ba..d70b6d6 100644 --- a/client/index.jsx +++ b/client/index.jsx @@ -10,6 +10,7 @@ import { createElement as h, useEffect, useRef, useState } from 'react'; import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, redactStatus, compareVersions } from './api.js'; +import { installSettingsNavIcon } from './pocket-nav-icon.mjs'; import { mobileApply } from './mobile/mobile-apply.tsx'; import { NS as POCKET_NS, zh as POCKET_ZH, en as POCKET_EN } from './pocket-locales.js'; @@ -647,6 +648,10 @@ export function apply(ctx) { const translate = ctx.locale.bind(POCKET_NS); ctx.effect(() => ctx.locale.register(POCKET_NS, { zh: POCKET_ZH, en: POCKET_EN }), 'dsh-pocket: pocket locale dictionaries'); + // 设置页导航一级入口的图标:外壳 navIcon() 只给内置 id 配图标、其余兜底齿轮, + // 而 settings.section 没有 icon 字段可传 —— 这里按本行文案认行后换成手机图标。 + installSettingsNavIcon(ctx, () => translate('section')); + // 设置一级入口(与 通用设置/模型/插件 同级,order 1 = 通用之后、最外层) ctx.slots.inject('settings.section', () => ctx.slots.register( diff --git a/client/pocket-nav-icon.mjs b/client/pocket-nav-icon.mjs new file mode 100644 index 0000000..194bb5a --- /dev/null +++ b/client/pocket-nav-icon.mjs @@ -0,0 +1,156 @@ +// 设置页「手机访问」的导航图标。 +// +// 背景:DSH 0.1.x 的设置外壳(dsh-client-ui-settings-general)把导航图标写死在 +// 内部的 navIcon(id) 里——只认 models / agent-presets / plugins 三个内置 id, +// 其余一律兜底成齿轮;而 settings.section 的注册项由外壳只投影 id / order / label +// (见 ui-settings 的 slots 契约与运行时 slot 清单),没有 icon 字段可传。 +// 所以在设置弹层挂载后,按「自己那一行的可见文案」认出自己的导航按钮,打一个 +// data 标记,再由 CSS 把兜底齿轮换成设计稿的手机图标。 +// +// 边界:只认自己这一行(文案是本插件自己的本地化文案,见 pocket-locales.js 的 +// section),不碰外壳结构;标记随 fiber 释放一起移除,locale 切换后由 +// MutationObserver 重新认行。上游若给 settings.section 增加 icon 字段,本模块可整个删掉。 +// +// 认行判定 isOwnNavRow 是纯函数、不碰 DOM,便于 test/ 里用桩元素喂进来 +// (与 client/mobile/nav-targets.mjs、layout-mode.mjs 同款做法)。 + +/** 标记属性:只落在本插件自己的那一行导航上。 */ +export const NAV_ICON_MARKER = 'data-dsh-pocket-nav-icon'; + +/** 认行范围:设置弹层导航里的按钮(外壳 SettingsPanel 的 DOM 契约)。 */ +export const NAV_ROW_SELECTOR = '[role="dialog"] nav button'; + +/** 图标盒尺寸(px)。与外壳 navIcon 渲染的 size 一致。 */ +export const NAV_ICON_SIZE = 16; + +/** 窄屏下 client/mobile/mobile.css.ts 把设置页导航图标缩到 14px,这里跟着缩, + * 免得自己这一格比别的大一圈。 */ +export const NAV_ICON_SIZE_NARROW = 14; + +/** 窄屏断点:与 mobile.css.ts 的 `@media (max-width: 1023px)` 对齐。 */ +export const NARROW_MEDIA_QUERY = '(max-width: 1023px)'; + +/** + * 设计稿手机图标(16×16 网格):圆角机身 + 顶部听筒 + 底部 Home 键。 + * + * 作为 CSS mask 使用,所以固定用黑色(黑 = 不透明 = 显示);实际颜色由 + * `background-color: currentColor` 决定,随主题/选中态走。 + * + * 比例取自设计稿(机身 345×535、听筒 102×27、Home 键外径 80、壁厚 19), + * 但按 16px 网格重新落点:设计稿描边只占机身宽的 7.8%,等比缩到 16px 是 0.78px, + * 与 DSH 图标族 1.5px 的描边不一致,故描边统一取 1.5px;听筒与 Home 键改为 + * 「按留白落点」——留出与机身边框的间隙,否则在 16px 下会和边框糊在一起。 + * Home 键缩到 16px 后壁厚只剩 0.53px,无法成环,落成同直径实心点。 + */ +export const PHONE_GLYPH_SVG = + '' + + '' + + '' + + '' + + ''; + +/** mask 用的 data URI(运行时编码,避免手写转义)。 */ +export function phoneMaskUrl(svg = PHONE_GLYPH_SVG) { + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +/** + * 这一行是不是本插件的导航行。 + * + * 纯函数,判定依据只有「该行的可见文案 === 本插件当前 locale 下的文案」。 + * wantedLabel 为空时一律不认——否则文案还没解析出来时会把标记撒到别的分区上。 + * + * @param rowText - 候选导航按钮的可见文案(通常取 textContent)。 + * @param wantedLabel - 本插件当前 locale 的 section 文案。 + * @returns 命中为 true。 + */ +export function isOwnNavRow(rowText, wantedLabel) { + const wanted = String(wantedLabel ?? '').trim(); + if (wanted.length === 0) return false; + return String(rowText ?? '').trim() === wanted; +} + +/** 注入的样式文本:隐藏外壳兜底的齿轮 svg,用 mask 画设计稿图标。 */ +export function navIconCss(maskUrl) { + return [ + `[${NAV_ICON_MARKER}] > svg { display: none; }`, + `[${NAV_ICON_MARKER}]::before {`, + ` content: '';`, + ` flex: none;`, + ` width: ${NAV_ICON_SIZE}px;`, + ` height: ${NAV_ICON_SIZE}px;`, + ` background-color: currentColor;`, + ` -webkit-mask-image: url("${maskUrl}");`, + ` mask-image: url("${maskUrl}");`, + ` -webkit-mask-repeat: no-repeat;`, + ` mask-repeat: no-repeat;`, + ` -webkit-mask-position: center;`, + ` mask-position: center;`, + ` -webkit-mask-size: ${NAV_ICON_SIZE}px ${NAV_ICON_SIZE}px;`, + ` mask-size: ${NAV_ICON_SIZE}px ${NAV_ICON_SIZE}px;`, + `}`, + `@media ${NARROW_MEDIA_QUERY} {`, + ` [${NAV_ICON_MARKER}]::before {`, + ` width: ${NAV_ICON_SIZE_NARROW}px;`, + ` height: ${NAV_ICON_SIZE_NARROW}px;`, + ` -webkit-mask-size: ${NAV_ICON_SIZE_NARROW}px ${NAV_ICON_SIZE_NARROW}px;`, + ` mask-size: ${NAV_ICON_SIZE_NARROW}px ${NAV_ICON_SIZE_NARROW}px;`, + ` }`, + `}`, + ].join('\n'); +} + +/** + * 把「手机访问」那一行的兜底齿轮换成设计稿的手机图标。 + * + * 只做两件事:给认出来的导航按钮打标记,注入一条只认该标记的样式。认行依据是 + * resolveLabel() 返回的本插件本地化文案,所以 locale 切换、设置弹层开关、外壳 + * 重渲染都能自愈。 + * + * @param ctx - client 根上下文(取其 effect 做随 fiber 释放的副作用登记)。 + * @param resolveLabel - 返回当前 locale 下「手机访问」文案的函数。 + */ +export function installSettingsNavIcon(ctx, resolveLabel) { + if (typeof document === 'undefined') return; + + ctx.effect(() => { + const tag = document.createElement('style'); + tag.dataset.plugin = 'dsh-pocket'; + tag.dataset.pluginCss = 'dsh-pocket/nav-icon'; + tag.textContent = navIconCss(phoneMaskUrl()); + document.head.appendChild(tag); + + let disposed = false; + let scheduled = false; + + const sync = () => { + scheduled = false; + if (disposed) return; + const wanted = resolveLabel(); + for (const row of document.querySelectorAll(NAV_ROW_SELECTOR)) { + if (isOwnNavRow(row.textContent, wanted)) row.setAttribute(NAV_ICON_MARKER, ''); + else row.removeAttribute(NAV_ICON_MARKER); + } + }; + + // 合并同一帧内的多次 DOM 变更,且在绘制前落地,避免图标闪一下齿轮。 + const schedule = () => { + if (scheduled || disposed) return; + scheduled = true; + queueMicrotask(sync); + }; + + sync(); + const observer = new MutationObserver(schedule); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + + return () => { + disposed = true; + observer.disconnect(); + for (const row of document.querySelectorAll(`[${NAV_ICON_MARKER}]`)) { + row.removeAttribute(NAV_ICON_MARKER); + } + tag.remove(); + }; + }, 'dsh-pocket: settings nav icon'); +} diff --git a/test/pocket-nav-icon.test.js b/test/pocket-nav-icon.test.js new file mode 100644 index 0000000..44233e2 --- /dev/null +++ b/test/pocket-nav-icon.test.js @@ -0,0 +1,139 @@ +// 设置页「手机访问」导航图标(client/pocket-nav-icon.mjs)。 +// +// 没有 DOM 可用,所以认行判定是纯函数 isOwnNavRow(),用字符串/桩元素喂进去即可; +// 样式与图标几何按文本断言(与 test/mobile-nav.test.js 同款做法)。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const { + NAV_ICON_MARKER, + NAV_ROW_SELECTOR, + NAV_ICON_SIZE, + NAV_ICON_SIZE_NARROW, + NARROW_MEDIA_QUERY, + PHONE_GLYPH_SVG, + phoneMaskUrl, + isOwnNavRow, + navIconCss, +} = await import('../client/pocket-nav-icon.mjs'); + +const { zh, en } = await import('../client/pocket-locales.js'); + +test('认行:只认「手机访问」自己那一行', () => { + assert.equal(isOwnNavRow(zh.section, zh.section), true, '中文文案命中'); + assert.equal(isOwnNavRow(en.section, en.section), true, '英文文案命中'); + assert.equal(isOwnNavRow(` ${zh.section}\n`, zh.section), true, '两侧空白不影响'); + // 其它分区不能被标记,否则会把别人的图标也换掉 + assert.equal(isOwnNavRow('通用设置', zh.section), false); + assert.equal(isOwnNavRow('模型', zh.section), false); + assert.equal(isOwnNavRow('', zh.section), false); + assert.equal(isOwnNavRow(undefined, zh.section), false); + assert.equal(isOwnNavRow(null, zh.section), false); +}); + +test('认行:文案取不到时一律不认(不能撒网式标记)', () => { + for (const empty of ['', ' ', undefined, null]) { + assert.equal(isOwnNavRow(zh.section, empty), false, `wantedLabel=${JSON.stringify(empty)} 不应命中`); + assert.equal(isOwnNavRow('通用设置', empty), false); + } +}); + +test('认行:切到英文后旧的中文行不再命中', () => { + assert.equal(isOwnNavRow(zh.section, en.section), false); + assert.equal(isOwnNavRow(en.section, zh.section), false); +}); + +test('标记属性与认行范围是外壳契约的一部分', () => { + assert.match(NAV_ICON_MARKER, /^data-[a-z0-9-]+$/, '必须是合法的 data 属性名'); + // 外壳 SettingsPanel:面板 role="dialog",导航在