/** * dsh-liquid-glass · Client 半体 * * 分层玻璃(v1.9.2):**中间工作区全透明,两侧栏与浮层磨砂玻璃**。 * * 三个关键设计: * 1. 折射用 SVG 滤镜(feTurbulence + feDisplacementMap),背景会真的弯折, * 而不是单纯糊掉 —— 这是液态玻璃和普通毛玻璃的区别。 * 2. 目标元素靠**运行时 DOM 探测**定位:能拿到的稳定钩子(DSH 自己的 * data-* 属性、AppFrame 的列类名、UI 包里的模块类名)优先, * 拿不到就用**几何特征**兜底。哈希类名(`LdtX1G_xxx`)永远不当选择器。 * 3. 面板颜色全部**从当前主题令牌派生**,浅色/深色主题都不会压掉文字对比度。 * * 为什么中间全透明、两侧要磨砂: * 全透明(v1.8 的老行为)会让侧栏文字直接压在壁纸上,两块字叠在一起看不清。 * 所以中间工作区保持全透明(壁纸完整透出来,也是视觉焦点), * 左栏 / 右栏 / 审批卡 / 弹窗各给一档**模糊 + 淡色玻璃**,文字才压得住。 */ /* 模块加载器一碰这个文件就留痕(先于 factory 执行)。 用来区分「文件没被送到浏览器」和「factory 没被调用」—— 这两种情况的表象完全一样(界面毫无变化),只能靠留痕分辨。 */ try { if (!window.__DSHLG_LOAD__) window.__DSHLG_LOAD__ = []; window.__DSHLG_LOAD__.push({ at: Date.now(), href: location.href, hasLoader: !!window.__ModuleLoader__ }); } catch { /* 忽略 */ } window.__ModuleLoader__.load({ id: '@local/dsh-liquid-glass', factory() { try { window.__DSHLG_FACTORY__ = (window.__DSHLG_FACTORY__ || 0) + 1; } catch { /* 忽略 */ } /* 加载期最外层兜底:factory 里任何未捕获异常都不能冒泡出去, 否则 DSH 会判「entry 未激活」并影响同一次 boot 的其他插件。 */ try { /* 版本号与 package.json 保持一致(2026-09-28 统一:之前两边分叉成 2.5.2 / 1.10.0)。 */ const VERSION = '2.6.0'; const STYLE_ID = 'dshlg-critical'; // 内联兜底材质 const STYLE_ID_DYN = 'dshlg-style'; // 运行时动态部分(令牌/几何相关) const STYLE_LINK_ID = 'dshlg-stylesheet'; const SVG_ID = 'dshlg-svg'; const FILTER_REF = 'dshlg-refract'; const BG_ID = 'dshlg-backdrop'; const WALL_ID = 'dshlg-wall'; const CTRL_ID = 'dshlg-controls'; const GALLERY_ID = 'dshlg-gallery'; const RESTORE_ID = 'dshlg-restore'; // 控制条收起后的「找回」入口 const SETTINGS_ID = 'dshlg-settings'; // 设置面板 const GEAR_ID = 'dshlg-gear'; // 打开设置面板的齿轮按钮 const SETTINGS_KEY = 'dshlg.settings'; // 本地持久化键名 /** * 请求重画一遍的**模块级**入口。 * * 为什么需要它:pass() 是 applyGlass 内部的局部函数,而设置面板/拖动这些 * 代码位于模块级作用域 —— 直接调用 pass() 会抛 ReferenceError * (实测:点壁纸卡片报「pass is not defined」,设置改完也不重绘)。 * 这里放一个回调,由 applyGlass 在 pass 定义好之后赋值给它。 */ let requestPass = () => { /* applyGlass 启动后会被替换 */ }; const SAPPHIRE_ID = 'dshlg-sapphire-defs'; // 深蓝宝石渐变定义容器 const FADE_TOP_ID = 'dshlg-fade-top'; // 页面顶部渐隐带 const FADE_BOTTOM_ID = 'dshlg-fade-bottom'; // 页面底部渐隐带 const BANNER_ID = 'dshlg-banner'; const CLS_GLASS = 'dshlg-glass'; const CLS_TOOLBAR = 'dshlg-toolbar'; const CLS_SIDE = 'dshlg-side'; const CLS_RIGHT = 'dshlg-right'; const CLS_APPROVAL = 'dshlg-approval'; const CLS_MODAL = 'dshlg-modal'; const CLS_BAR = 'dshlg-bar'; // 左栏会话行(和右栏同款玻璃) const CLS_BRAND = 'dshlg-brand'; // 左上角品牌区(蓝色液态玻璃) /* ══════════════════════════════════════════════════════════════ * 控制中心(M0 外壳 + M1 工作区管理) * * 为什么整块放在**模块级**而不是塞进 applyGlass(): * · applyGlass 已经是 700+ 行的单个函数,再塞进去没法维护; * · 模块级函数**不能**引用 applyGlass 内部的局部量(如 pass)—— * 本项目踩过「点了必抛 ReferenceError」的坑,所以这里只走 * 已经存在的模块级挂钩 requestPass()。 * · 不引入任何新的色调变量:材质全部复用已有的 --dshlg-bar-tone * 与 --dshlg-panel-veil / --dshlg-panel-ink(和设置面板同一套)。 * · 面板本体**不做 backdrop-filter**(全屏/容器上的 backdrop 会闪, * 而且会变成 fixed 后代的包含块)——玻璃感由描边 + 顶光给出, * 与控制条 / 设置面板完全一致。 * ══════════════════════════════════════════════════════════════ */ const CC_ID = 'dshlg-cc'; // 面板本体 const CC_BACKDROP_ID = 'dshlg-cc-backdrop'; // 面板外部的点击兜底面 const CC_POS_KEY = 'cc'; // 位置记忆键(与 'bar' / 'gear' 同一套) /* ══ 控制中心错误埋点 ═══════════════════════════════════════════ 用户报「点任何位置都没反应(包括关闭)」—— 本项目有「静默异常」前科: 模块级函数引用了 applyGlass 内部的标识符、或只插了调用没插定义, 都会让点击 handler 一进去就抛,表现就是「点了像没点」。 这里把四个入口都包起来,错误落到 window.__DSHLG_CC_ERR(**不冒到 window**,否则 settings.test.mjs 的「插件无异常」会变红)。 同时记 where / when / target / count,便于判断是哪一个入口。 */ const ccErr = { count: 0, where: '—', message: '', stack: '', when: '', target: '' }; try { window.__DSHLG_CC_ERR = ccErr; } catch { /* 忽略 */ } function ccNoteError(where, error, event) { ccErr.count += 1; ccErr.where = where; ccErr.message = String((error && error.message) || error); ccErr.stack = String((error && error.stack) || '').split('\n').slice(0, 6).join(' | '); ccErr.when = new Date().toISOString(); try { const t = event && event.target; ccErr.target = t ? (t.tagName || '?') + (t.className && typeof t.className === 'string' ? '.' + t.className.split(' ')[0] : '') + (t.dataset && t.dataset.ccAct ? '[act=' + t.dataset.ccAct + ']' : '') + (t.dataset && t.dataset.ccWs ? '[ws=' + t.dataset.ccWs + ']' : '') : '—'; } catch { ccErr.target = '?'; } try { console.error('[dsh-liquid-glass] 控制中心 ' + where + ' 失败:', error); } catch { /* 忽略 */ } } /* 控制中心 API 基址。 ⚠️ 千万不能用同源相对路径:DSH 页面的同源服务器上没有这个路由 (用户实测面板显示「控制服务未连接 · HTTP 404」)。 宿主半体把 /dshlg-control/* 挂在**插件自己的 3932x 本地服务**上, 所以要在**调用时**取当时探到的端口。 ccPort 是模块级的:控制中心是模块级代码,看不到 applyGlass 里的 wallPort。 */ let ccPort = null; const CC_API_FALLBACK = '/dshlg-control'; function ccApi() { return ccPort ? 'http://127.0.0.1:' + ccPort + '/dshlg-control' : CC_API_FALLBACK; } const CC_TABS = [ { id: 'workspaces', label: '工作区' }, { id: 'sessions', label: '会话' }, { id: 'plugins', label: '插件' }, { id: 'system', label: '系统' }, ]; /* 面板状态集中在一个对象里:散落的模块级 let 最容易出现 「只插了调用没插定义」,集中一处一眼能看全,也不容易撞名。 */ const ccState = { tab: 'workspaces', lastFocus: null, workspaces: null, // null = 还没拉到 workspacesError: null, sessions: null, sessionsError: null, health: null, sortAsc: true, showArchived: false, wizard: null, // { step:'path'|'name', path, name, error } confirm: null, // { kind, id, name, sessions } 危险动作二次确认 renamingId: null, busy: false, notice: null, // 人话提示(成功 / 失败都走它) renderError: null, // 错误边界:分页渲染抛异常时记这里 sig: '', // 上一次渲染的状态签名(相同就整段跳过) /* 插件市场(2026-09-27 新增)。list=null 表示还没加载过清单 */ market: { at: 0, // 清单时间戳(缓存判活用) list: null, // 精简后的清单(见 ccMarketTrim) categories: null, // { id: { zh, en } } loading: false, error: '', // 清单加载失败的人话原因 cached: false, // 这份是缓存来的(界面上标注出来) triedCache: false, // 是否已经试过读本地缓存(只试一次) q: '', // 搜索词 cat: '', // 分类筛选('' = 全部) sort: 'downloads', // downloads | stars | added shown: 40, // 当前渲染多少条(点「显示更多」翻倍) }, }; /** * 渲染签名。只把**影响这个分页内容**的状态放进来: * 数据(工作区/会话/健康)+ 当前分页 + 排序筛选 + 向导/确认/重命名 * 的行身份 + 提示文案。不含 busy(避免转圈时反复重建 DOM)。 * 与本项目在控制条上用的 state-stamp 是同一个思路。 */ function ccSig() { const j = (v) => { try { return JSON.stringify(v); } catch { return 'x'; } }; const w = ccState.workspaces; return [ ccState.tab, ccState.sortAsc ? 'a' : 'd', ccState.showArchived ? 'h' : 'v', w === null ? 'w:null' : 'w:' + j(w.map((x) => [x.id, x.name, x.path, x.current ? 1 : 0, x.pinned ? 1 : 0, x.archived ? 1 : 0, x.sessionCount])), ccState.workspacesError ? 'werr' : '', ccState.sessions === null ? 's:null' : 's:' + j(ccState.sessions.map((x) => [x.id, x.severity, x.title])), ccState.sessionsError ? 'serr' : '', ccState.health ? 'hl:' + (ccState.health.ok ? 'ok' : 'bad') + ':' + (ccState.health.version || '') : 'hl:null', ccState.notice ? ccState.notice.kind + ':' + ccState.notice.text : '', ccState.wizard ? 'wz:' + ccState.wizard.step + ':' + (ccState.wizard.error || '') : '', ccState.confirm ? 'cf:' + ccState.confirm.kind + ':' + ccState.confirm.id : '', ccState.renamingId ? 'rn:' + ccState.renamingId : '', ccState.renderError || '', /* 插件市场:清单/筛选/搜索任何一项变了都要重绘 */ 'mk:' + ccState.market.at + ':' + (ccState.market.list ? ccState.market.list.length : -1) + ':' + (ccState.market.loading ? 1 : 0) + ':' + ccState.market.error + ':' + ccState.market.q + ':' + ccState.market.cat + ':' + ccState.market.sort + ':' + ccState.market.shown, ].join('|'); } /* 健康状态的短缓存:避免每次切分页都打一次宿主 */ const ccCache = { health: null, healthAt: 0, healthTried: 0 }; /* ── 小工具(全部定义在使用点之前)──────────────────────────── */ /** 转义后放进 innerHTML。 */ function ccEsc(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * 错误人话化:宿主返回 { ok:false, error:{ code, message } }。 * 认不出的 code 就把原文附上 —— 绝不显示「操作失败」这种没信息量的话。 */ function ccHumanError(error) { const code = String((error && error.code) || '').toLowerCase(); const raw = String((error && error.message) || error || '').trim(); const map = { name_taken: '这个名字已经被别的空间用了,换一个。', duplicate: '这个名字已经被别的空间用了,换一个。', exists: '这个名字或目录已经存在了。', invalid_name: '名字不合法:不能为空,也不能包含 \\ / : * ? " < > | 这些字符。', bad_name: '名字不合法:不能为空,也不能包含 \\ / : * ? " < > | 这些字符。', not_found: '找不到这个空间,可能已经被改名或归档了。刷新一下列表。', no_such_workspace: '找不到这个空间,可能已经被改名或归档了。刷新一下列表。', enoent: '目录不存在,或者当前账号没有权限读它。', eacces: '没有权限访问这个目录。', eperm: '没有权限访问这个目录。', eexist: '目标目录已经存在了。', not_a_directory: '这个路径不是一个目录。', enotdir: '这个路径不是一个目录。', unsupported: '当前宿主版本还不支持这个操作(需要升级宿主半体)。', not_implemented: '当前宿主版本还没有实现这个操作。', timeout: '宿主没有在预期时间内响应,稍后再试。', offline: '控制服务未连接,稍后再试。', busy: '宿主正忙,稍后再试。', internal: '宿主内部出错,看 DSH 日志能拿到细节。', }; if (code && map[code]) return map[code]; if (/ENOENT/i.test(raw)) return '目录不存在,或者当前账号没有权限读它。'; if (/EACCES|EPERM/i.test(raw)) return '没有权限访问这个目录。'; if (/EEXIST/i.test(raw)) return '目标目录已经存在了。'; if (/fetch|network|failed to load|连接/i.test(raw)) return '控制服务未连接:' + (raw || '网络请求失败'); return raw || '未知错误(宿主没有给出原因)。'; } /** 统一取 JSON。返回 { ok, data, error },**绝不抛异常**。 */ async function ccFetchJson(url, options) { try { const res = await fetch(url, Object.assign({ cache: 'no-store' }, options || {})); if (!res.ok) { return { ok: false, error: { code: 'http_' + res.status, message: 'HTTP ' + res.status } }; } const data = await res.json().catch(() => null); if (data === null) return { ok: false, error: { code: 'bad_json', message: '返回内容不是 JSON' } }; return { ok: true, data }; } catch (error) { return { ok: false, error: { code: 'offline', message: String((error && error.message) || error) } }; } } /** 宿主健康状态(缓存 8 秒;force=true 给「重试」按钮用)。 */ async function ccLoadHealth(force) { const now = Date.now(); if (!force && ccCache.health && now - ccCache.healthAt < 8000) return ccCache.health; if (!force && ccCache.healthTried && now - ccCache.healthTried < 8000) return ccCache.health; ccCache.healthTried = now; const out = await ccFetchJson(ccApi() + '/health'); ccCache.health = out.ok ? { ok: out.data && out.data.ok !== false, version: out.data && out.data.version, services: (out.data && out.data.services) || {}, /* 宿主会挑出不可用的官方服务名,直接透出去给「系统」页显示 (实测 /dshlg-control/health 顶层字段含 degraded 与 carrier) */ degraded: Array.isArray(out.data && out.data.degraded) ? out.data.degraded : [], carrier: (out.data && out.data.carrier) || '', } : { ok: false, error: out.error }; ccCache.healthAt = Date.now(); ccState.health = ccCache.health; return ccCache.health; } /** * 把「宿主返回体」里的数组取出来。 * * 宿主的路由统一是 { ok, ...payload } 的形状(health / workspaces / * sessions 都是),数组在 payload 的某个字段里,**不是**顶层数组。 * 这里做容错:数组直接用;对象按已知字段名依次试。 * 找不到就返回 null,让调用方走「形状不认识」的降级提示 —— 而不是 * 静默变成空列表(上一版就是这样:页面显示「共 0 个」,而宿主明明有 2 个)。 */ function ccPickArray(data, keys) { if (Array.isArray(data)) return data; if (!data || typeof data !== 'object') return null; for (const k of keys) { if (Array.isArray(data[k])) return data[k]; } return null; } async function ccLoadWorkspaces() { const out = await ccFetchJson(ccApi() + '/workspaces'); if (!out.ok) { ccState.workspaces = null; ccState.workspacesError = out.error; return null; } const list = ccPickArray(out.data, ['workspaces', 'items', 'list', 'data', 'rows']); if (list === null) { ccState.workspaces = null; ccState.workspacesError = { code: 'bad_shape', message: '返回体里找不到工作区数组(顶层字段:' + Object.keys(out.data || {}).join(', ') + ')' }; return null; } ccState.workspaces = list; ccState.workspacesError = null; return ccState.workspaces; } async function ccLoadSessions() { const out = await ccFetchJson(ccApi() + '/sessions/inspect'); if (!out.ok) { ccState.sessions = null; ccState.sessionsError = out.error; return null; } const list = ccPickArray(out.data, ['sessions', 'items', 'list', 'data', 'rows']); if (list === null) { ccState.sessions = null; ccState.sessionsError = { code: 'bad_shape', message: '返回体里找不到会话数组(顶层字段:' + Object.keys(out.data || {}).join(', ') + ')' }; return null; } ccState.sessions = list; ccState.sessionsError = null; return ccState.sessions; } /* ── 面板的建 / 开 / 关 ───────────────────────────────────── */ function ccEl() { return document.getElementById(CC_ID); } function ccIsOpen() { const el = ccEl(); return !!el && el.hasAttribute('data-dshlg-cc-open'); } function ccSetNotice(kind, text) { ccState.notice = text ? { kind, text } : null; } /** 面板壳:标题 + 可选动作按钮(用 DOM 追加,不重写 innerHTML)。 */ function ccShell(titleText, actionHtml) { const wrap = document.createElement('div'); wrap.className = 'cc-sec'; const h = document.createElement('h4'); h.textContent = titleText; wrap.appendChild(h); if (actionHtml) { const box = document.createElement('div'); box.innerHTML = actionHtml; wrap.appendChild(box); } return wrap; } function ccNoticeNode() { const note = ccState.notice; if (!note) return null; const div = document.createElement('div'); div.className = 'cc-note ' + (note.kind === 'error' ? 'cc-err' : 'cc-ok'); div.textContent = note.text; return div; } /** 降级页:宿主不可达时给人话,而不是空白面板。 */ function ccDegradedNode(what, error) { const box = document.createElement('div'); box.className = 'cc-degraded'; const svc = (ccState.health && ccState.health.services) || {}; const lines = Object.keys(svc).map((k) => k + (svc[k] ? ' ✓' : ' ✗')); box.innerHTML = '控制服务未连接' + '
' + ccEsc(what) + '暂时不可用。' + (error ? '原因:' + ccEsc(ccHumanError(error)) : '') + '
' + '
当前可用:' + ccEsc(lines.length ? lines.join(' ') : '(宿主没有上报服务清单)') + '
' + '
' + '' + '需要宿主半体(index.js)提供 ' + ccEsc(ccApi()) + ' 路由' + '
'; return box; } /* ── 分页 1:工作区(M1)──────────────────────────────────── */ function ccTabWorkspaces() { const frag = document.createDocumentFragment(); const notice = ccNoticeNode(); if (notice) frag.appendChild(notice); if (ccState.workspacesError) { frag.appendChild(ccDegradedNode('工作区列表', ccState.workspacesError)); return frag; } if (ccState.workspaces === null) { const loading = document.createElement('div'); loading.className = 'cc-empty'; loading.textContent = '正在读取工作区…'; frag.appendChild(loading); void ccLoadWorkspaces().then(() => { if (ccIsOpen()) ccRender(); }); return frag; } const bar = document.createElement('div'); bar.className = 'cc-actions'; bar.innerHTML = '' + '' + '' + '' + '共 ' + ccState.workspaces.length + ' 个'; frag.appendChild(bar); if (ccState.wizard) frag.appendChild(ccWizardNode()); const list = ccState.workspaces .filter((w) => ccState.showArchived || !w.archived) .slice() .sort((a, b) => { if (!!b.pinned !== !!a.pinned) return (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0); const r = String(a.name || '').localeCompare(String(b.name || ''), 'zh-Hans-CN'); return ccState.sortAsc ? r : -r; }); if (list.length === 0) { const empty = document.createElement('div'); empty.className = 'cc-empty'; empty.textContent = ccState.workspaces.length === 0 ? '宿主没返回任何工作区。' : '当前筛选下没有工作区。'; frag.appendChild(empty); } for (const w of list) frag.appendChild(ccWorkspaceRow(w)); const tail = document.createElement('div'); tail.className = 'cc-tail'; tail.textContent = '删除一律只做「归档」—— 本项目还没验证过真删除的语义边界,所以不提供。'; frag.appendChild(tail); return frag; } function ccWorkspaceRow(w) { const row = document.createElement('div'); row.className = 'cc-row' + (w.current ? ' is-current' : ''); const id = String(w.id ?? ''); const chips = []; if (w.current) chips.push('当前'); if (w.pinned) chips.push('置顶'); if (w.archived) chips.push('已归档'); chips.push('' + Number(w.sessionCount || 0) + ' 会话'); row.innerHTML = '
' + '
' + ccEsc(w.name || '(未命名)') + '
' + '
' + ccEsc(w.path || '(无路径)') + '
' + '
' + '
' + chips.join('') + '
'; const main = row.querySelector('.cc-main'); const acts = document.createElement('div'); acts.className = 'cc-actions'; acts.innerHTML = (w.current ? '' : '') + '' + '' + (w.archived ? '' : ''); main.appendChild(acts); /* 危险动作二次确认:就地展开,并显示会影响多少个会话 */ if (ccState.confirm && String(ccState.confirm.id) === id) { const c = ccState.confirm; const box = document.createElement('div'); box.className = 'cc-confirm'; box.innerHTML = '确定要' + (c.kind === 'archive' ? '归档' : '取消归档') + '「' + ccEsc(c.name) + '」吗?' + '
会影响 ' + Number(c.sessions || 0) + ' 个会话。' + (c.kind === 'archive' ? '归档不会删除任何文件,之后可以取消归档。' : '') + '
' + '
' + '' + '' + '
'; main.appendChild(box); } /* 重命名:就地输入 */ if (ccState.renamingId === id) { const form = document.createElement('div'); form.className = 'cc-form'; form.innerHTML = '' + '' + '
' + '' + '' + '
'; main.appendChild(form); } return row; } function ccWizardNode() { const wiz = ccState.wizard; const box = document.createElement('div'); box.className = 'cc-confirm'; if (!wiz) return box; if (wiz.step === 'path') { box.innerHTML = '新建空间 · 第 1 步:选目录' + '
填一个已存在目录的绝对路径。宿主会校验它是否存在、是不是目录、有没有权限。
' + '
' + '' + (wiz.error ? '
' + ccEsc(wiz.error) + '
' : '') + '
' + '' + '' + '
'; return box; } box.innerHTML = '新建空间 · 第 2 步:起名字' + '
目录:' + ccEsc(wiz.path || '') + '
' + '
' + '' + (wiz.error ? '
' + ccEsc(wiz.error) + '
' : '') + '
' + '' + '' + '' + '
'; return box; } /* ── 分页 2:会话 ─────────────────────────────────────────── */ function ccTabSessions() { const frag = document.createDocumentFragment(); const intro = ccShell('会话巡检', '
只读:列出有问题的会话和修复建议,本面板不会替你改会话。
'); frag.appendChild(intro); const notice = ccNoticeNode(); if (notice) frag.appendChild(notice); if (ccState.sessionsError) { frag.appendChild(ccDegradedNode('会话巡检', ccState.sessionsError)); return frag; } if (ccState.sessions === null) { const loading = document.createElement('div'); loading.className = 'cc-empty'; loading.textContent = '正在读取…'; frag.appendChild(loading); void ccLoadSessions().then(() => { if (ccIsOpen()) ccRender(); }); return frag; } if (ccState.sessions.length === 0) { const empty = document.createElement('div'); empty.className = 'cc-empty'; empty.textContent = '没有发现需要处理的会话。'; frag.appendChild(empty); return frag; } const order = { high: 0, error: 0, warn: 1, medium: 1, low: 2, info: 3 }; const rows = ccState.sessions .slice() .sort((a, b) => (order[String(a.severity || 'info').toLowerCase()] ?? 9) - (order[String(b.severity || 'info').toLowerCase()] ?? 9)); for (const s of rows) { const row = document.createElement('div'); row.className = 'cc-row'; const sev = String(s.severity || 'info').toLowerCase(); const chipCls = (sev === 'high' || sev === 'error') ? 'cc-bad' : ((sev === 'warn' || sev === 'medium') ? 'cc-warn' : ''); row.innerHTML = '
' + '
' + ccEsc(s.title || s.id || '(无标题)') + '
' + '
' + ccEsc(s.symptom || '') + '
' + (s.suggestion ? '
建议:' + ccEsc(s.suggestion) + '
' : '') + '
' + '
' + ccEsc(sev) + '
'; frag.appendChild(row); } const tail = document.createElement('div'); tail.className = 'cc-tail'; tail.textContent = '共 ' + rows.length + ' 条。'; frag.appendChild(tail); return frag; } /* ── 分页 3:插件市场 ─────────────────────────────────────── 2026-09-27 新增(用户要「一个能下载插件的地方」)。 三条硬约束(都查证过,不是猜的): ① **不自己实现安装**。DSH 自带官方插件管理器 (@deepseek-ai/dsh-client-ui-plugin-manager + 宿主 dsh-plugin-manager), 装插件时它在 profile 里跑 pnpm,并带「供应链校验 + 授权确认 + 风险提示」。 自己跑 pnpm = 重复实现一条安全关键路径,还可能改坏 profile 让 DSH 起不来。 所以本页只做:**找到 → 复制安装标识 → 打开官方管理器**,真正的安装由它执行。 ② **清单不自己维护**。社区已有公开清单(4377 个插件、23 分类、中英说明、 star/下载量、权限能力与红线警告),ACAO:* 可以直接从页面拉。 想加自己的私藏清单,往下面 MARKET_URLS 再加一条 URL 就行。 ③ **5MB 清单不静默拉**。第一次要用户点一下「加载清单」,之后 24h 内走本地缓存。 安全观:清单是第三方数据 → 一律 ccEsc 之后才进 innerHTML; 权限红线(capabilityRedLines)直接标红展示,不藏。 */ /** 清单来源:第一条是社区公开清单,第二条是你自己仓库里的补充清单(可以留空数组)。 */ const MARKET_URLS = [ 'https://awesome-dsh-plugin.com/plugins.json', 'https://raw.githubusercontent.com/pure-serendipity-five/dsh-liquid-glass-studio/main/market/index.json', ]; const MARKET_CACHE_KEY = 'dshlg-market-v1'; const MARKET_TTL = 24 * 60 * 60 * 1000; const MARKET_PAGE = 40; // 一次渲染多少条(4377 条全渲染会把 DOM 拖死) /** 只保留界面要用的字段 —— 4377 条全字段存不进 localStorage 配额。 */ function ccMarketTrim(p) { const d = (p && p.description) || {}; const desc = String((d && (d.zh || d.en)) || p.desc || p.title || '') .replace(/\s+/g, ' ').slice(0, 180); return { name: String(p.name || ''), owner: String(p.owner || ''), url: String(p.url || ''), page: String(p.page || ''), cat: String(p.category || ''), desc, install: String(p.install || ''), npm: p.npm ? String(p.npm) : '', version: p.version ? String(p.version) : '', stars: Number(p.stars || 0), downloads: p.downloads == null ? null : Number(p.downloads), added: String(p.added || ''), caps: Array.isArray(p.capabilities) ? p.capabilities.slice(0, 8).map(String) : [], red: Array.isArray(p.capabilityRedLines) ? p.capabilityRedLines.slice(0, 3).map(String) : [], }; } /** 从清单条目的 install 命令里取出「装什么」—— 官方管理器的「包名」框要的就是这一段。 社区清单的 install 形如: dsh plugin --profile web add @anonyjcy/dsh-j-space dsh plugin --profile web add github:CAI-MH/dsh-quality-review dsh plugin --profile web add github:ayahunter/dsh-trail#path:/packages/bundle */ function ccMarketSpec(p) { const raw = String((p && p.install) || '').trim(); const m = /\badd\s+(.+)$/.exec(raw); let spec = m ? m[1].trim() : ''; spec = spec.replace(/^["']|["']$/g, ''); if (!spec && p) spec = p.npm || ('github:' + p.owner + '/' + p.name); return spec; } function ccMarketCatLabel(id) { const cats = ccState.market.categories; const c = cats && cats[id]; return (c && (c.zh || c.en)) || id || ''; } function ccTimeAgo(ts) { if (!ts) return '未知'; const s = Math.max(0, Math.round((Date.now() - ts) / 1000)); if (s < 90) return s + ' 秒前'; if (s < 5400) return Math.round(s / 60) + ' 分钟前'; if (s < 172800) return Math.round(s / 3600) + ' 小时前'; return Math.round(s / 86400) + ' 天前'; } /** 读本地缓存(不联网)。坏缓存当没有,绝不让它把面板带崩。 */ function ccMarketCacheLoad() { try { const raw = localStorage.getItem(MARKET_CACHE_KEY); if (!raw) return false; const o = JSON.parse(raw); if (!o || !Array.isArray(o.list) || !o.list.length) return false; ccState.market.list = o.list; ccState.market.categories = o.categories || null; ccState.market.at = Number(o.at || 0); ccState.market.cached = true; return true; } catch { return false; } } /** 拉清单(多源合并;一条都没拿到就保住旧清单并把原因说清楚)。 */ async function ccLoadMarket(force) { const mk = ccState.market; if (mk.loading) return; if (!force && mk.list && Date.now() - mk.at < MARKET_TTL) { mk.cached = true; return; } mk.loading = true; mk.error = ''; if (ccIsOpen()) ccRender(); const merged = []; const seen = new Set(); const errs = []; let got = 0; for (const url of MARKET_URLS) { try { const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) { errs.push(url + ' → HTTP ' + res.status); continue; } const data = await res.json(); const list = Array.isArray(data) ? data : ((data && Array.isArray(data.plugins)) ? data.plugins : []); if (data && data.categories && !mk.categories) mk.categories = data.categories; for (const p of list) { if (!p || !p.name) continue; const key = String(p.name) + '@' + String(p.owner || ''); if (seen.has(key)) continue; seen.add(key); merged.push(ccMarketTrim(p)); } got += 1; } catch (error) { errs.push(url + ' → ' + String((error && error.message) || error)); } } mk.loading = false; if (!got) { mk.error = '清单没拉到:' + errs.join(';'); if (ccIsOpen()) ccRender(); return; } mk.list = merged; mk.at = Date.now(); mk.cached = false; mk.shown = MARKET_PAGE; try { localStorage.setItem(MARKET_CACHE_KEY, JSON.stringify({ at: mk.at, categories: mk.categories, list: merged })); } catch { /* 配额不够就只留内存,不影响使用 */ } if (ccIsOpen()) ccRender(); } /** 筛选 + 排序(每次都用一份新数组,别把原清单顺序打乱)。 */ function ccMarketFiltered() { const mk = ccState.market; const list = mk.list || []; const q = mk.q.trim().toLowerCase(); const out = []; for (const p of list) { if (mk.cat && p.cat !== mk.cat) continue; if (q) { const hay = (p.name + ' ' + p.owner + ' ' + p.desc + ' ' + p.npm).toLowerCase(); if (hay.indexOf(q) < 0) continue; } out.push(p); } const s = mk.sort; out.sort((a, b) => { if (s === 'stars') return (b.stars || 0) - (a.stars || 0); if (s === 'added') return String(b.added).localeCompare(String(a.added)); if (s === 'name') return String(a.name).localeCompare(String(b.name)); return (b.downloads || 0) - (a.downloads || 0); }); return out; } /** 复制到剪贴板。navigator.clipboard 在没焦点时会失败,所以留一条 execCommand 兜底。 */ async function ccCopyText(text) { try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return true; } } catch { /* 落兜底 */ } try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return ok; } catch { return false; } } /** * 打开 DSH 自带的插件管理器(左侧栏那个「插件」入口)。 * 为什么用「找 DOM 入口并点它」:DSH 没对外暴露调起插件管理器的接口(查过 app.asar), * 而它是官方唯一的安装入口。找不到就返回 false,让人话提示去引导,绝不硬点别的元素。 */ function ccOpenPluginManager() { const wanted = ['插件', 'Plugins', 'Plugin']; const nodes = document.querySelectorAll('button, a, [role="button"], [role="menuitem"], [role="tab"]'); let fallback = null; for (const el of nodes) { const txt = String(el.textContent || '').replace(/\s+/g, ''); if (wanted.indexOf(txt) < 0) continue; if (el.closest('#' + CC_ID) || el.closest('#' + CTRL_ID) || el.closest('#' + GEAR_ID)) continue; if (el.closest('[data-conversation-region], [data-conversation-scroll]')) continue; if (el.closest('[class*="sidebar"], [class*="Sidebar"], nav, [role="navigation"]')) { try { el.click(); return true; } catch { /* 试下一个 */ } } if (!fallback) fallback = el; } if (fallback) { try { fallback.click(); return true; } catch { /* 放弃 */ } } return false; } /** 外部链接:开不了就把地址复制走(宿主可能拦 window.open)。 */ function ccOpenLink(url) { if (!url) return; let opened = null; try { opened = window.open(url, '_blank', 'noopener,noreferrer'); } catch { opened = null; } if (!opened) { void ccCopyText(url); ccSetNotice('ok', '外部链接没打开(可能被宿主拦了),地址已复制到剪贴板:' + url); ccRender(); } } /** 「安装」= 复制安装标识 + 打开官方管理器。**不**自己跑安装。 */ async function ccMarketInstall(spec, name) { if (!spec) { ccSetNotice('error', '这条清单没有安装标识,点「仓库」去看它的 README 吧。'); ccRender(); return; } const copied = await ccCopyText(spec); const opened = ccOpenPluginManager(); ccSetNotice('ok', (name ? name + ':' : '') + (copied ? '安装标识已复制(' : '请手动复制(') + spec + (copied ? ')' : ')') + (opened ? ',已打开 DSH 插件管理器,粘进「包名」框回车即可。' : ',请点左侧栏的「插件」打开插件管理器,粘进「包名」框回车。') + '装完要完全退出 DSH(托盘右键 → 退出)再启动才生效。'); ccRender(); } function ccMarketRow(p) { const row = document.createElement('div'); row.className = 'cc-row'; const chips = []; if (p.version) chips.push('v' + ccEsc(p.version) + ''); if (p.downloads != null) chips.push('↓ ' + p.downloads + ''); if (p.stars) chips.push('★ ' + p.stars + ''); const cat = ccMarketCatLabel(p.cat); if (cat) chips.push('' + ccEsc(cat) + ''); const main = document.createElement('div'); main.className = 'cc-main'; main.innerHTML = '
' + ccEsc(p.name) + ' ' + ccEsc(p.owner) + '
' + '
' + ccEsc(p.desc || '(作者没写说明)') + '
' + '
' + ccEsc(ccMarketSpec(p)) + '
'; const meta = document.createElement('div'); meta.className = 'cc-meta'; meta.innerHTML = chips.join(''); row.appendChild(main); row.appendChild(meta); const acts = document.createElement('div'); acts.className = 'cc-actions'; acts.innerHTML = '' + (p.url ? '' : '') + (p.page ? '' : ''); main.appendChild(acts); /* 权限能力与红线:安全相关的信息不藏,直接摊开 */ if (p.red && p.red.length) { const warn = document.createElement('div'); warn.className = 'cc-mk-desc'; warn.innerHTML = '权限红线 ' + ccEsc(p.red.join(';')) + (p.caps.length ? ' 能力:' + ccEsc(p.caps.join(' ')) + '' : ''); main.appendChild(warn); } else if (p.caps.length) { const caps = document.createElement('div'); caps.className = 'cc-mk-desc'; caps.innerHTML = '能力 ' + ccEsc(p.caps.join(' ')) + ''; main.appendChild(caps); } return row; } function ccTabPlugins() { const frag = document.createDocumentFragment(); const notice = ccNoticeNode(); if (notice) frag.appendChild(notice); const mk = ccState.market; /* ① 本插件自己的状态(出问题时一眼看全) */ const tagged = document.querySelectorAll('[data-dshlg-region]').length; frag.appendChild(ccShell('本插件', '
版本v' + ccEsc(VERSION) + '
' + '
运行期样式' + (document.getElementById(STYLE_ID_DYN) ? '已注入' : '未注入') + ' 静态材质:' + (document.getElementById(STYLE_LINK_ID) ? '已加载' : '内联兜底') + '
' + '
已标记玻璃面' + tagged + ' 块
')); /* ② 没缓存也没加载过:先说清楚拉多大、从哪拉,再由用户点 */ if (mk.list === null && !mk.loading && !mk.triedCache) { mk.triedCache = true; ccMarketCacheLoad(); } if (mk.loading && mk.list === null) { const box = document.createElement('div'); box.className = 'cc-empty'; box.textContent = '正在加载插件清单…(约 5 MB,第一次会慢一点)'; frag.appendChild(box); return frag; } if (mk.list === null) { frag.appendChild(ccShell('插件市场', '
还没加载插件清单。' + '
来源:' + ccEsc(MARKET_URLS[0]) + '' + '(约 5 MB,加载一次后本机缓存 24 小时)。
' + '
装插件走 DSH 官方插件管理器:本页只负责「找到 + 复制」,' + '真正的安装由它执行(它自己会做供应链校验与授权确认)。
' + (mk.error ? '
' + ccEsc(mk.error) + '
' : '') + '
' + '' + '' + '
')); return frag; } /* ③ 已加载:工具栏 + 分类 + 排序 + 列表 */ const all = mk.list.length; const filtered = ccMarketFiltered(); const shown = filtered.slice(0, mk.shown); const cats = mk.categories ? Object.keys(mk.categories) : []; const sortDefs = [['downloads', '按下载'], ['stars', '按星标'], ['added', '按收录'], ['name', '按名字']]; const catChips = [''] .concat(cats.map((id) => '')); const sortChips = sortDefs.map(([id, label]) => ''); const sec = ccShell('插件市场', '
' + '' + '' + '' + '
' + '
' + catChips.join('') + '
' + '
' + sortChips.join('') + '
' + '
共 ' + all + ' 个插件' + (mk.cached ? '(本地缓存,' + ccEsc(ccTimeAgo(mk.at)) + '更新的;点「刷新清单」可拉最新)' : '(在线,' + ccEsc(ccTimeAgo(mk.at)) + '更新)') + ' 筛选出 ' + filtered.length + ' 个' + (filtered.length > shown.length ? ',先显示前 ' + shown.length + ' 个' : '') + (mk.error ? ' ⚠ ' + ccEsc(mk.error) : '') + '
'); const list = document.createElement('div'); list.className = 'cc-list'; if (!filtered.length) { const empty = document.createElement('div'); empty.className = 'cc-empty'; empty.textContent = '没有匹配的插件,换个词或点「全部」。'; list.appendChild(empty); } for (const p of shown) list.appendChild(ccMarketRow(p)); sec.appendChild(list); if (filtered.length > shown.length) { const more = document.createElement('div'); more.className = 'cc-actions'; more.innerHTML = ''; sec.appendChild(more); } frag.appendChild(sec); const tip = document.createElement('div'); tip.className = 'cc-tail'; tip.textContent = '本页只负责「找到 + 复制安装标识」;真正安装由 DSH 官方插件管理器执行' + '(它会做供应链校验与授权确认)。装完要完全退出 DSH(托盘右键 → 退出)再启动才生效。'; frag.appendChild(tip); return frag; } /* ── 分页 4:系统 ─────────────────────────────────────────── */ /** * 壁纸诊断:一次把「壁纸层在哪 / 谁盖住了它」量出来。 * * 为什么要有这个:2026-09-28 用户报「壁纸只显示在左上角一小块」, * 而**连不上真机 DOM**(DSH 没开调试端口),复现台里又是正常的 —— * 与其猜,不如让用户把这块截图发来,一次定位。 */ function ccWallProbe() { const vw = window.innerWidth; const vh = window.innerHeight; const vpArea = Math.max(1, vw * vh); const out = { viewport: [vw, vh], brightWall: document.documentElement.getAttribute('data-dshlg-bright-wall'), wall: null, covering: [], overlayLike: [], tokens: {}, }; const w = document.getElementById(WALL_ID); if (w) { const r = w.getBoundingClientRect(); const c = getComputedStyle(w); const frame = w.querySelector('iframe'); out.wall = { rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)], position: c.position, inset: c.inset, display: c.display, opacity: c.opacity, zIndex: c.zIndex, background: c.backgroundColor, parent: w.parentElement ? (w.parentElement.tagName + (w.parentElement.id ? '#' + w.parentElement.id : '')) : '?', iframeSrc: frame ? String(frame.src || '').slice(0, 120) : '(没有 iframe)', iframeRect: frame ? [Math.round(frame.getBoundingClientRect().width), Math.round(frame.getBoundingClientRect().height)] : null, }; } else { out.wall = { missing: true }; } /* 找出「盖住 ≥35% 视口 且底色不透明」的元素 —— 壁纸就是被这些东西挡住的 */ try { for (const el of document.querySelectorAll('#root *')) { if (out.covering.length >= 12) break; const r = el.getBoundingClientRect(); if (r.width * r.height < vpArea * 0.35) continue; const c = getComputedStyle(el); const m = String(c.backgroundColor).match(/rgba?\(([^)]+)\)/); if (!m) continue; const parts = m[1].split(',').map(Number); const alpha = parts.length > 3 ? parts[3] : 1; if (!(alpha >= 0.5)) continue; out.covering.push({ el: el.tagName + '.' + String(el.className || '').split(' ').slice(0, 2).join('.'), bg: c.backgroundColor, size: Math.round(r.width) + 'x' + Math.round(r.height), pos: c.position, z: c.zIndex, }); } } catch (error) { out.covering = ['扫描失败:' + String((error && error.message) || error)]; } /* 所有类名里带 _overlay / _scrim 的元素(本插件为它们写过规则) */ try { for (const el of document.querySelectorAll('[class*="_overlay"], [class*="_scrim"]')) { const c = getComputedStyle(el); const r = el.getBoundingClientRect(); out.overlayLike.push({ el: String(el.className || '').split(' ').slice(0, 2).join('.'), bg: c.backgroundColor, size: Math.round(r.width) + 'x' + Math.round(r.height), z: c.zIndex, }); } } catch { /* 忽略 */ } try { const cs = getComputedStyle(document.documentElement); for (const k of ['--dsw-alias-bg-base', '--dsw-alias-bg-layer-1', '--dsw-alias-bg-mask-1', '--dsw-menu-surface-fill']) { out.tokens[k] = String(cs.getPropertyValue(k) || '').trim() || '(空)'; } } catch { /* 忽略 */ } return out; } function ccTabSystem() { const frag = document.createDocumentFragment(); const h = ccState.health; if (!h) { const loading = document.createElement('div'); loading.className = 'cc-empty'; loading.textContent = '正在读取宿主状态…'; frag.appendChild(loading); void ccLoadHealth().then(() => { if (ccIsOpen()) ccRender(); }); return frag; } if (!h.ok) { frag.appendChild(ccDegradedNode('宿主控制接口', h.error)); } else { const svc = h.services || {}; const keys = Object.keys(svc); frag.appendChild(ccShell('宿主', '
状态已连接
' + '
宿主版本' + ccEsc(h.version || '(未上报)') + '
' + '
服务' + (keys.length ? keys.map((k) => ccEsc(k) + (svc[k] ? ' ✓' : ' ✗')).join(' ') : '(宿主没有上报服务清单)') + '
' + '
不可用服务' + ccEsc((h.degraded && h.degraded.length) ? h.degraded.join(' ') : '无') + '
' + '
接口载体' + ccEsc(h.carrier || ccApi()) + '
')); } frag.appendChild(ccShell('应用', '
页面来源' + ccEsc(String(location.origin || location.href).slice(0, 120)) + '
' + '
视口' + window.innerWidth + ' × ' + window.innerHeight + '
' + '
材质来源' + ccEsc((stylesheetUrls && stylesheetUrls()[0]) || '(未知)') + '
')); /* 壁纸诊断:壁纸显示不对时,把下面这块截图就够了(不用开 F12) */ const wallSec = ccShell('壁纸诊断', '
壁纸显示不对时,把下面这块整体截图给我即可 —— ' + '它列出了壁纸层的位置、以及「盖住视口且不透明」的元素(就是挡住壁纸的那些)。
'); const pre = document.createElement('pre'); pre.className = 'cc-pre'; try { pre.textContent = JSON.stringify(ccWallProbe(), null, 1); } catch (error) { pre.textContent = '诊断失败:' + String((error && error.message) || error); } wallSec.appendChild(pre); frag.appendChild(wallSec); const acts = document.createElement('div'); acts.className = 'cc-actions'; acts.innerHTML = '' + ''; frag.appendChild(acts); const tail = document.createElement('div'); tail.className = 'cc-tail'; tail.textContent = '凭据 / 密钥管理刻意不做:宿主 HTTP 服务绝不承载任何密钥读写。'; frag.appendChild(tail); return frag; } function ccTabNode(tabId) { if (tabId === 'sessions') return ccTabSessions(); if (tabId === 'plugins') return ccTabPlugins(); if (tabId === 'system') return ccTabSystem(); return ccTabWorkspaces(); } /* ── 渲染(含错误边界)────────────────────────────────────── */ /** * 错误边界:任一分页渲染抛异常 → 只把**这一个分页**换成 * 「这个分页出错了 + 重试」,整页和另外几个分页都不受影响。 * 这里刻意自己 try/catch,绝不让异常冒到 window.onerror。 */ function ccRender() { try { return ccRenderInner(); } catch (error) { /* 这一层很关键:ccRender 开头那段(取 body/tabs、算签名、建 tab 按钮) 原来**没有**保护,那里一旦抛,整个点击就静默死掉。 注意这里不要引用 flk():本项目没有这个函数(曾因此差点插进来)。 */ ccNoteError('ccRender', error, null); return undefined; } } function ccRenderInner() { const el = ccEl(); if (!el) return; const body = el.querySelector('.cc-body'); const tabs = el.querySelector('.cc-tabs'); if (!body || !tabs) return; /* 去重闸门:签名没变就一个节点都不碰。 面板关着时也一律不重绘 —— 关闭状态下重建 DOM 只会去动 一个 display:none 的子树,白烧帧,还可能带起合成层重绘。 */ const sig = ccSig(); if (!ccEl().hasAttribute('data-dshlg-cc-open') && ccState.sig) return; if (sig === ccState.sig) return; ccState.sig = sig; tabs.replaceChildren(); /* 自适应可读性:复用设置面板那套(背后太亮/有文字 → 加厚 + 换深字)。 放在 rAF 里 —— 要先完成布局才能采样背后。 */ requestAnimationFrame(() => { try { ccAdaptReadability(el); } catch { /* 忽略 */ } }); for (const tab of CC_TABS) { const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = tab.label; btn.dataset.ccTab = tab.id; btn.setAttribute('role', 'tab'); btn.setAttribute('aria-selected', tab.id === ccState.tab ? 'true' : 'false'); if (tab.id === ccState.tab) btn.classList.add('is-on'); tabs.appendChild(btn); } ccState.renderError = null; try { body.replaceChildren(ccTabNode(ccState.tab)); } catch (error) { ccState.renderError = String((error && error.message) || error); console.error('[dsh-liquid-glass] 控制中心分页渲染失败(已隔离):', error); const box = document.createElement('div'); box.className = 'cc-degraded cc-err'; box.innerHTML = '这个分页出错了' + '
' + ccEsc(ccState.renderError) + '
' + '
' + '' + '
'; body.replaceChildren(box); } } /* ── 宿主动作(全部经 requestPass 挂钩,不引用 applyGlass 内部量)── */ async function ccPost(action, payload) { const out = await ccFetchJson(ccApi() + '/workspace/' + action, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}), }); if (!out.ok) { ccSetNotice('error', ccHumanError(out.error)); return false; } if (out.data && out.data.ok === false) { ccSetNotice('error', ccHumanError(out.data.error)); return false; } return true; } async function ccDoWorkspaceAction(kind, id) { if (ccState.busy) return; const list = ccState.workspaces || []; const w = list.find((x) => String(x.id) === String(id)) || { id, name: id, sessionCount: 0 }; ccState.busy = true; try { if (kind === 'switch') { const ok = await ccPost('switch', { id }); if (ok) { ccSetNotice('ok', '已请求切换到「' + (w.name || id) + '」。'); await ccLoadWorkspaces(); } else { /* 契约里没有 switch 端点:说人话 + 给退路,别只丢一个「不支持」 */ ccSetNotice('error', '宿主还不支持从插件里切换空间,请用 DSH 自己的工作区入口。' + '(这个能力要等宿主把 ' + ccApi() + '/workspace/switch 做出来)'); } } else if (kind === 'pin') { const ok = await ccPost('pin', { id, pinned: !w.pinned }); if (ok) { ccSetNotice('ok', (w.pinned ? '已取消置顶' : '已置顶') + '「' + (w.name || id) + '」'); await ccLoadWorkspaces(); } } else if (kind === 'archive' || kind === 'unarchive') { const ok = await ccPost('archive', { id, archived: kind === 'archive' }); if (ok) { ccSetNotice('ok', (kind === 'archive' ? '已归档' : '已取消归档') + '「' + (w.name || id) + '」'); await ccLoadWorkspaces(); } } else if (kind === 'rename') { const input = document.querySelector('#' + CC_ID + ' [data-cc-rename-input]'); const name = input ? String(input.value || '').trim() : ''; if (!name) { ccSetNotice('error', '名字不能为空。'); return; } const ok = await ccPost('rename', { id, name }); if (ok) { ccSetNotice('ok', '已改名为「' + name + '」'); ccState.renamingId = null; await ccLoadWorkspaces(); } } } finally { ccState.busy = false; ccState.confirm = null; } } async function ccDoCreate() { const wiz = ccState.wizard; if (!wiz || ccState.busy) return; ccState.busy = true; try { const ok = await ccPost('create', { path: wiz.path, name: wiz.name }); if (ok) { ccState.wizard = null; ccSetNotice('ok', '空间已创建:' + (wiz.name || wiz.path)); await ccLoadWorkspaces(); } else if (ccState.notice && ccState.notice.kind === 'error' && ccState.wizard) { /* 把宿主的错误显示在向导里,用户不用抬头找提示 */ ccState.wizard.error = ccState.notice.text; } } finally { ccState.busy = false; } } /* ── 事件(面板只建一次,全部走委托)──────────────────────── */ function ccOnClick(event) { try { return ccOnClickInner(event); } catch (error) { ccNoteError('ccOnClick', error, event); return undefined; } } function ccOnClickInner(event) { const el = ccEl(); if (!el || !event || !event.target || !event.target.closest) return; /* 回执:handler 真的进来了就留痕(判断「点了没反应」时先看这个) */ try { window.__DSHLG_CC_LAST = { at: Date.now(), target: String(event.target.tagName || '') }; } catch { /* 忽略 */ } const tabBtn = event.target.closest('#' + CC_ID + ' .cc-tabs button'); if (tabBtn && tabBtn.dataset.ccTab) { ccState.tab = tabBtn.dataset.ccTab; ccState.notice = null; ccState.renderError = null; ccState.sig = ''; // 状态签名作废:强制重绘这一次 if (ccState.tab === 'sessions' && ccState.sessions === null && !ccState.sessionsError) void ccLoadSessions(); if (ccState.tab === 'system') void ccLoadHealth(); ccRender(); return; } const act = event.target.closest('[data-cc-act]'); if (act) { const a = act.dataset.ccAct; /* ── 插件市场 ─────────────────────────────────────────── */ if (a === 'mk-load') { void ccLoadMarket(true); return; } if (a === 'mk-open-mgr') { const ok = ccOpenPluginManager(); ccSetNotice(ok ? 'ok' : 'error', ok ? '已打开 DSH 插件管理器(左侧栏那个「插件」页)。把包名或仓库标识粘进去即可。' : '没找到「插件」入口。请点左侧栏的「插件」,或重启一次 DSH 后再试。'); ccRender(); return; } if (a === 'mk-cat') { ccState.market.cat = act.dataset.cat || ''; ccState.market.shown = MARKET_PAGE; ccRender(); return; } if (a === 'mk-sort') { ccState.market.sort = act.dataset.sort || 'downloads'; ccRender(); return; } if (a === 'mk-more') { ccState.market.shown += MARKET_PAGE; ccRender(); return; } if (a === 'mk-install') { void ccMarketInstall(act.dataset.mk || '', act.dataset.mkname || ''); return; } if (a === 'mk-link') { ccOpenLink(act.dataset.url || ''); return; } if (a === 'close') { ccClose(); return; } if (a === 'retry-tab') { ccState.renderError = null; ccRender(); return; } if (a === 'retry') { ccCache.health = null; ccCache.healthAt = 0; ccCache.healthTried = 0; ccState.workspaces = null; ccState.workspacesError = null; ccState.sessions = null; ccState.sessionsError = null; ccSetNotice(null, null); void ccLoadHealth(true).then(() => { if (ccIsOpen()) ccRender(); }); return; } if (a === 'reload') { ccState.workspaces = null; ccSetNotice(null, null); void ccLoadWorkspaces().then(() => { if (ccIsOpen()) ccRender(); }); return; } if (a === 'sort') { ccState.sortAsc = !ccState.sortAsc; ccRender(); return; } if (a === 'show-archived') { ccState.showArchived = !ccState.showArchived; ccRender(); return; } if (a === 'new') { ccState.wizard = { step: 'path', path: '', name: '', error: null }; ccSetNotice(null, null); ccRender(); return; } if (a === 'reset-pos') { clearPos(); el.removeAttribute('style'); el.removeAttribute('data-dshlg-moved'); delete el.dataset.dshlgMoved; ccRender(); return; } if (a === 'reload-health') { ccCache.healthAt = 0; void ccLoadHealth(true).then(() => { if (ccIsOpen()) ccRender(); }); return; } if (a === 'log-report') { try { console.info('[dsh-liquid-glass] 控制中心诊断', { version: VERSION, health: ccState.health, workspaces: ccState.workspaces, sessions: ccState.sessions, viewport: [window.innerWidth, window.innerHeight], }); ccSetNotice('ok', '诊断信息已打到控制台(F12 → Console)。'); } catch (error) { ccSetNotice('error', String((error && error.message) || error)); } ccRender(); return; } } const wizBtn = event.target.closest('[data-cc-wiz]'); if (wizBtn && ccState.wizard) { const k = wizBtn.dataset.ccWiz; if (k === 'cancel') { ccState.wizard = null; ccRender(); return; } if (k === 'back') { ccState.wizard.step = 'path'; ccState.wizard.error = null; ccRender(); return; } if (k === 'path-next') { const input = el.querySelector('[data-cc-wiz-path]'); const path = input ? String(input.value || '').trim() : ''; if (!path) { ccState.wizard.error = '请填一个目录的绝对路径。'; ccRender(); return; } if (!/^[a-zA-Z]:[\\/]/.test(path) && path.charAt(0) !== '/') { ccState.wizard.error = '看起来不是绝对路径:Windows 上应形如 D:\\目录,Unix 上以 / 开头。'; ccRender(); return; } ccState.wizard.path = path; ccState.wizard.step = 'name'; ccState.wizard.error = null; ccRender(); return; } if (k === 'create') { const input = el.querySelector('[data-cc-wiz-name]'); ccState.wizard.name = input ? String(input.value || '').trim() : ''; void ccDoCreate().then(() => { if (ccIsOpen()) ccRender(); }); return; } } const cf = event.target.closest('[data-cc-confirm]'); if (cf) { if (cf.dataset.ccConfirm === 'no') { ccState.confirm = null; ccRender(); return; } const c = ccState.confirm; if (!c) return; const kind = c.kind; const id = c.id; ccState.confirm = null; void ccDoWorkspaceAction(kind, id).then(() => { if (ccIsOpen()) ccRender(); }); return; } const ws = event.target.closest('[data-cc-ws]'); if (ws) { const kind = ws.dataset.ccWs; const id = ws.dataset.id; if (kind === 'rename') { ccState.renamingId = id; ccState.notice = null; ccRender(); return; } if (kind === 'rename-cancel') { ccState.renamingId = null; ccRender(); return; } if (kind === 'rename-ok') { void ccDoWorkspaceAction('rename', id).then(() => { if (ccIsOpen()) ccRender(); }); return; } if (kind === 'archive' || kind === 'unarchive') { const w = (ccState.workspaces || []).find((x) => String(x.id) === String(id)) || {}; ccState.confirm = { kind, id, name: w.name || id, sessions: w.sessionCount || 0 }; ccRender(); return; } void ccDoWorkspaceAction(kind, id).then(() => { if (ccIsOpen()) ccRender(); }); } } /** Esc 通道。向导/重命名打开时,Esc 先退那一层而不是整个面板。 */ function ccOnKeydown(event) { if (event.key !== 'Escape' && event.key !== 'Esc') return; if (!ccIsOpen()) return; const t = event.target; const inPanel = t && t.closest && t.closest('#' + CC_ID); if (inPanel && (ccState.wizard || ccState.renamingId)) { ccState.wizard = null; ccState.renamingId = null; ccRender(); } else { ccClose(); } event.preventDefault(); event.stopPropagation(); } /** * 销毁:插件被 dispose 时由 applyGlass 的 cleanup 调用。 * * 存在的理由:控制中心在 **document** 上挂了两个捕获监听 * (点外部 + Esc),而 cleanup 里那份 id 清单不认识 CC_ID / * CC_BACKDROP_ID,也不会摘 document 级监听 —— 不在这里收拾, * 插件卸载后会留下一对「对着已删节点做事的」全局监听。 */ function ccTeardown() { try { document.removeEventListener('pointerdown', ccOnOutsidePointer, true); } catch { /* 忽略 */ } try { document.removeEventListener('keydown', ccOnKeydown, true); } catch { /* 忽略 */ } for (const id of [CC_ID, CC_BACKDROP_ID]) { try { document.getElementById(id)?.remove(); } catch { /* 忽略 */ } } ccState.sig = ''; } /** * 让控制中心面板也吃设置面板那套自适应可读性。 * * adaptPanelReadability() 是写死给设置面板的(读 SETTINGS_ID、 * 把 --dshlg-panel-veil / --dshlg-panel-ink 写在那个元素上)。 * 这里**不改它**(改它要动 settings.test.mjs 正在测的那段), * 而是临时借一下 id:在 rAF 里同步执行、没有 await, * 所以借 id 的窗口就是一次函数调用,不会被别的事件插进来。 * 结果落到控制中心自己的行内变量上 —— 两个面板同一个材质, * 但各自独立自适应,不会互相串。 */ function ccAdaptReadability(el) { const box = el || ccEl(); if (!box || box.hasAttribute('hidden')) return null; const real = document.getElementById(SETTINGS_ID); const owned = !real ? document.createElement('div') : null; if (owned) { owned.id = SETTINGS_ID; owned.setAttribute('hidden', ''); document.body.appendChild(owned); } const borrow = document.getElementById(SETTINGS_ID); const mine = borrow !== box; const backup = mine ? borrow.id : null; if (mine) borrow.id = SETTINGS_ID + '-backup'; box.id = SETTINGS_ID; try { return adaptPanelReadability(); } finally { box.id = CC_ID; if (mine && backup) borrow.id = backup; owned?.remove(); } } /** * 插件市场搜索框:输入即筛,但要**防抖 250ms** —— * 清单有 4000+ 条,逐字符重绘面板会卡;而且重绘会重建这个 input * (焦点会丢),所以重绘后要把焦点和光标位置还回去。 */ let ccSearchTimer = 0; function ccMarketSearch(value) { ccState.market.q = String(value || ''); ccState.market.shown = MARKET_PAGE; ccRender(); /* 重绘后把焦点与光标还回搜索框,否则打第二个字就得重新点一次 */ try { const box = ccEl(); const inp = box && box.querySelector('[data-cc-search]'); if (inp && document.activeElement !== inp) { inp.focus({ preventScroll: true }); const n = String(inp.value || '').length; if (typeof inp.setSelectionRange === 'function') inp.setSelectionRange(n, n); } } catch { /* 忽略 */ } } function ccOnInput(event) { const t = event.target; if (!t || !t.hasAttribute || !t.hasAttribute('data-cc-search')) return; const value = String(t.value || ''); if (ccSearchTimer) { clearTimeout(ccSearchTimer); ccSearchTimer = 0; } ccSearchTimer = setTimeout(() => { ccSearchTimer = 0; ccMarketSearch(value); }, 250); } /** 输入框里按 Enter = 点同组的确认按钮(键盘用户不用去够鼠标)。 */ function ccOnKeydownInner(event) { if (event.key !== 'Enter') return; const t = event.target; if (!t || !t.closest) return; const el = ccEl(); if (!el || !el.contains(t)) return; /* 市场搜索框:Enter 立刻搜(跳过防抖) */ if (t.hasAttribute('data-cc-search')) { if (ccSearchTimer) { clearTimeout(ccSearchTimer); ccSearchTimer = 0; } ccMarketSearch(t.value); return; } if (t.hasAttribute('data-cc-rename-input')) { const ok = t.closest('.cc-form')?.querySelector('[data-cc-ws="rename-ok"]'); if (ok) { event.preventDefault(); ok.click(); } return; } if (t.hasAttribute('data-cc-wiz-path')) { const next = el.querySelector('[data-cc-wiz="path-next"]'); if (next) { event.preventDefault(); next.click(); } return; } if (t.hasAttribute('data-cc-wiz-name')) { const create = el.querySelector('[data-cc-wiz="create"]'); if (create) { event.preventDefault(); create.click(); } } } /** 点外部通道:兜底面 + document 捕获各一条,互为保险。 */ function ccOnOutsidePointer(event) { try { return ccOnOutsidePointerInner(event); } catch (error) { ccNoteError('ccOnOutsidePointer', error, event); return undefined; } } function ccOnOutsidePointerInner(event) { if (!ccIsOpen()) return; const box = ccEl(); if (!box) return; const t = event.target; if (t && box.contains(t)) return; if (t && t.closest && t.closest('#' + CTRL_ID + ', #' + GEAR_ID)) return; // 点入口不算外部 ccClose(); } /* ── 开 / 关 ─────────────────────────────────────────────── */ function ccOpen(tabId) { /* 需要时先建起来 —— 万一入口走的是「齿轮里的按钮」而控制条被收起, 这时 ensureControlCenter 可能还没被调过。幂等,重复调没代价。 */ if (!ccEl()) ensureControlCenter(); const el = ccEl(); if (!el) return false; if (tabId) ccState.tab = tabId; try { ccState.lastFocus = document.activeElement; } catch { ccState.lastFocus = null; } el.setAttribute('data-dshlg-cc-open', ''); el.removeAttribute('hidden'); const back = document.getElementById(CC_BACKDROP_ID); if (back) back.setAttribute('data-dshlg-cc-open', ''); ccState.wizard = null; ccState.confirm = null; ccState.renderError = null; ccRender(); void ccLoadHealth().then(() => { if (ccIsOpen() && ccState.tab === 'system') ccRender(); }); /* 焦点:落在第一个控件(不是整个面板),键盘用户 Tab 一下就能走 */ requestAnimationFrame(() => { try { const first = el.querySelector('.cc-tabs button') || el.querySelector('button'); if (first && typeof first.focus === 'function') first.focus({ preventScroll: true }); } catch { /* 忽略 */ } }); return true; } function ccClose() { try { return ccCloseInner(); } catch (error) { ccNoteError('ccClose', error, null); return false; } } function ccCloseInner() { const el = ccEl(); if (!el) return false; el.removeAttribute('data-dshlg-cc-open'); el.setAttribute('hidden', ''); const back = document.getElementById(CC_BACKDROP_ID); if (back) back.removeAttribute('data-dshlg-cc-open'); ccState.wizard = null; ccState.confirm = null; ccState.renamingId = null; const prev = ccState.lastFocus; ccState.lastFocus = null; requestAnimationFrame(() => { try { if (prev && prev.isConnected && typeof prev.focus === 'function') prev.focus({ preventScroll: true }); else { const g = document.getElementById(GEAR_ID); if (g && typeof g.focus === 'function') g.focus({ preventScroll: true }); } } catch { /* 忽略 */ } }); return false; } function ccToggle(tabId) { return ccIsOpen() ? ccClose() : ccOpen(tabId); } /** * 建面板(幂等)。只在第一次真正建 DOM + 挂监听。 * 面板本体不做 backdrop-filter(见控制中心材质的注释)。 */ function ensureControlCenter() { let el = ccEl(); if (!el) { let back = document.getElementById(CC_BACKDROP_ID); if (!back) { back = document.createElement('div'); back.id = CC_BACKDROP_ID; back.dataset.dshlgKeep = '1'; back.setAttribute('aria-hidden', 'true'); /* 点外部通道 1:兜底面独立覆盖整个视口,最容易命中 */ back.addEventListener('pointerdown', (event) => { const t = event.target; const box = ccEl(); if (t && t.closest && t.closest('#' + CTRL_ID + ', #' + GEAR_ID)) return; if (box && t && box.contains(t)) return; if (ccIsOpen()) ccClose(); }); document.body.appendChild(back); } el = document.createElement('div'); el.id = CC_ID; el.dataset.dshlgKeep = '1'; el.setAttribute('hidden', ''); el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'false'); el.setAttribute('aria-label', '控制中心'); el.innerHTML = '
控制中心' + '' + '
' + '
' + '
'; el.addEventListener('click', ccOnClick); el.addEventListener('keydown', ccOnKeydownInner); el.addEventListener('input', ccOnInput); // 插件市场的搜索框 document.body.appendChild(el); /* 点外部通道 2:document 捕获。兜底面可能被别的浮层盖住 / 被 pointer-events 挡住 */ document.addEventListener('pointerdown', ccOnOutsidePointer, true); /* 关闭通道 3:Esc。挂 document 捕获,面板没聚焦也能关 */ document.addEventListener('keydown', ccOnKeydown, true); /* 拖动 + 位置记忆:直接复用项目里成熟的那两个(不重写)。 注意 makeDraggable 的约定:位置写在 **left / bottom** 上 (见 applySavedPos / savePos),所以面板的 CSS 必须用 left + bottom 定位,**不能**用 top/transform 居中 —— 否则拖动时 top 与 bottom 会同时生效,面板被抻成一条。 回调签名是 onMoved(el),不是 onMoved(pos)。 */ try { makeDraggable(el, CC_POS_KEY, (node) => { if (node) node.setAttribute('data-dshlg-moved', ''); }); applySavedPos(el, CC_POS_KEY); } catch (error) { console.warn('[dsh-liquid-glass] 控制中心拖动初始化失败(不影响打开/关闭):', error); } } ccRender(); return el; } /* ══════════════════════════════════════════════════════════ * ★ 改这里调效果。存盘后按 Ctrl+R 生效。 * * 这一版按 **iOS 26 Liquid Glass** 规格调: * · 半透明白色叠层 ≈8%、背景 18px 高斯模糊、饱和度 1.6× * · 圆角 20px、描边 1px 白 18% * · **只有边缘 10~15% 宽度向内折射**(透镜感),中间不扭 * · 最外缘 ≤2px 低强度彩虹色差 * · 顶部 1px 白色高光线(≈30%),四周一圈菲涅尔边缘光 * · 玻璃下柔和浮动阴影 0 8px 32px / 35% 黑 * · 激活(hover/聚焦)时:模糊减弱、折射与高光增强 * · 高光随指针移动(--dshlg-mx/my) * ══════════════════════════════════════════════════════════ */ const CONFIG = { /* ── 四档通透度(设计规格:8% / 3% / 1.5% / 0)─────────── * 改这一行就能整体换档,各区域的相对厚薄关系保持不变。 * 注意取舍:越透越好看,但**文字压壁纸的可读性越差**。 * sidebar/right 这类大面积为读字服务,所以默认给 'regular'。 */ /* 用户定稿:**全透明玻璃,只有边框看得出是玻璃**。 'off' = 白纱 0;玻璃感全部由 1px 描边 + 顶部高光 + 菲涅尔内环给出。 */ glassTier: 'off', /* 自制界面的位置微调 */ ui: { /* 齿轮按钮与控制条右边界之间的间距(px)。 控制条右边若是头像/其它按钮,调大这个值即可避开。 */ gearGap: 72, }, /* ── 玻璃质感(iOS 26 规格,全区域共用)───────────────── * 每档还可以单独覆盖 alpha / frost,不写就吃这里的默认值。 */ glass: { /* 半透明白色叠层的不透明度。0.08 = 规格值。 * 越小人越「无色」,但压字能力也越弱。 */ /* 背景高斯模糊半径 px(18 = 规格值) */ blur: 18, /* 饱和度提升(1.6 = 规格值) */ saturate: 1.6, /* 圆角 px(20 = 规格值) */ radius: 20, /* 描边:1px 白,18% */ border: 1, borderAlpha: 0.18, /* 顶部 1px 白色高光线(30% 亮度) */ topLine: 0.3, /* 四周菲涅尔边缘光强度(0~1),0 = 关 */ fresnel: 0.22, /* 浮动阴影:0 8px 32px,35% 黑 */ shadowY: 8, shadowBlur: 32, shadowAlpha: 0.35, /* 折射(透镜感):只有边缘这一圈向内弯折。 * edgeRefract —— 边缘折射强度 px(0 = 不折射) * edgeWidth —— 边缘带占短边的比例(0.10~0.15 是规格区间) */ edgeRefract: 0, edgeWidth: 0.12, /* 最外缘的彩虹色差:宽度 px(≤2)与强度(0 = 关) */ chroma: 2, chromaAlpha: 0.22, /* 激活态(鼠标悬停 / 键盘聚焦):模糊减弱、折射与高光增强 */ activeBlur: 0.7, activeRefract: 1.6, activeHighlight: 1.5, /* 高光随指针滑移(跟着 --dshlg-mx/my 走) */ pointerFollow: true, }, /* ── 分层玻璃:每一个区域单独一档 ─────────────────────── * 这一版的核心:不再用一个 panelAlpha 把整个界面抹平, * 而是「哪一层该透、哪一层该糊」分开控制。 * * 每档两个数: * alpha —— 玻璃底色不透明度。0 = 全透明(文字会压在壁纸上) * null = 用 CONFIG.glass.tint(规格值 0.08) * frost —— backdrop-filter 的模糊半径 px。0 = 不模糊 * null = 用 CONFIG.glass.blur(规格值 18) * * 中间工作区刻意**没有**开关:它永远是全透明的。 */ middle: { alpha: 0, frost: 0 }, /* 左侧「会话工作区」。面积大、字多,给它比规格稍厚一点点的底, * 否则花哨壁纸上的会话标题会糊成一片。 */ /* frost: 0 = 不模糊(纯透明 + 边缘光学);调成 18 就是磨砂玻璃 */ sidebarGlass: { alpha: null, frost: 0 }, /* 右侧栏(会话面板)。和左栏一档,保持「同一层」的观感。 */ rightGlass: { alpha: null, frost: 0 }, /* 聊天输入卡片(composer)。用户要求「和工作区输入栏也改一下」—— 以前它跟随 panelAlpha(≈0,几乎没有底),现在和侧栏同一档: 可读性够、又不会变成一块白的。 */ composerGlass: { alpha: null, frost: 0 }, /* 下拉菜单、popover、菜单面板。菜单要读得快,底厚一点。 */ /* ⚠️ 弹窗/菜单/popover 是**可读卡片**,不是结构性窗格,不能跟着 α0.138 走。 上一轮「六面统一」把它们一起压薄,导致 DSH 自己的设置对话框变成全透明、 文字与工作区重叠看不清 —— 这是那个问题的根因。这里恢复厚底。 */ overlayGlass: { alpha: 0.74, frost: null }, /* 审批对话框(「等待审批 / 允许一次」那张卡)。 * 浮在对话流里,必须压得住标题与命令,但仍要透出壁纸。 */ approvalGlass: { alpha: null, frost: 0 }, /* 真正的模态弹窗(设置确认、风险确认这类居中对话框)。 */ modalGlass: { alpha: null, frost: 0 }, /* ── 左上角品牌区(DeepSeek 标 + 名字)─────────────── * 用户要求:改「蓝色液态玻璃质感」,旁边的白块也一并收干净。 * 做法是**叠加蓝色玻璃底**(不是替换图标):文字与 logo 都留着, * 只在下面垫一层蓝色玻璃 + 边缘高光。 * alpha —— 蓝色叠加强度;0 = 关掉(恢复原样,只保留去白块) * hue —— 蓝色(HSL 色相角度,210 ≈ DeepSeek 蓝) */ /* 品牌区按用户明确规格:**浅蓝色玻璃底 + 深蓝色字标/鱼标**。 不跟随主题推导 —— 用户要的就是这个配色,深浅模式下都一致。 alpha 是玻璃浓度(0.2 时在壁纸上几乎看不出蓝,只有一圈淡描边)。 */ /* 用户定稿:品牌区也要**全透明** —— alpha 0 = 不画任何底, 只留 1px 描边与顶部高光(和其余玻璃面一致)。 想要回浅蓝玻璃底,把它调回 0.42 即可。 */ brand: { alpha: 0, hue: 210, /* 玻璃底:浅蓝(blue-200) */ base: { r: 191, g: 219, b: 254 }, /* 字标与鱼标:深蓝(blue-800) */ ink: { r: 30, g: 64, b: 175 }, /* 品牌区显示的文字。设成空字符串则保留官方原始字标。 为什么默认替换:官方是矢量字标 + 铭牌,我们清不掉铭牌那块底。 */ label: 'Agent-枝星', /* 宝石配色:ice(冰钻白蓝,默认,暗色壁纸上最跳)/ gold(香槟金)/ aqua(青玉) 深蓝在暗色壁纸上会看不清,所以默认不用深蓝。 */ gem: 'ice', }, /* ── 右栏收起后的表现 ───────────────────────────────── * DSH 的右栏「收起」是把面板滑出视口、并去掉 data-sidebar-right-open * (track 那一格仍然占位),所以收起后如果还留着玻璃, * 就会在右边留下一条**模糊的空带子** —— 用户要的是「和工作区一样透明」。 * 'auto' —— 面板真的开着才给玻璃;收起后全透明(默认) * 'keep' —— 不管开合都保留玻璃 */ rightWhenClosed: 'auto', /* 侧栏/右栏是否也做边缘折射。 * 默认关:整列面积太大,SVG 折射在这个尺度上很吃显卡, * 而且「整列透镜」看着不像 iOS。要试就改成 true。 */ refractRegions: false, /* 浮层的渲染方式: * 'frost' —— backdrop-filter 模糊(默认,最通透) * 'clear' —— 不模糊,改用更实的半透明色面板(显卡吃不消时用) * 'off' —— 浮层完全不处理,交给 DSH 自己的材质 */ overlayMode: 'frost', /* ── 通用透明度(保留旧语义,向后兼容)────────────────── */ panelAlpha: 0, /* 浮层(下拉菜单 / 弹窗 / 工具提示)的令牌级不透明度。 * overlay.alpha 是给「我们自己识别出来的浮层」用的; * 这一项是给**没被识别到的**浮层令牌兜底。 */ overlayAlpha: 0.9, /* 面板很透时给文字加一层衬底阴影 —— 压在壁纸上可读性会好很多。 * 0 = 关闭;0.55 左右比较自然。 */ textShadow: 0.55, /* ── 内容块底色(对话里的卡片 / 代码块 / 工具调用框)───── * 'glass' —— 只清掉不透明底色,另给一层极淡的玻璃底(默认,推荐) * 'none' —— 一块不留,连内容块也全透明(v1.8 的老行为,字会压壁纸) * 'keep' —— 内容块完全不动,保留 DSH 自己的底色 * 无论哪一档,**两侧栏与浮层的玻璃底都不受影响**。 */ contentFill: 'glass', contentFillAlpha: 0.06, /* 逐层清掉容器底色(contentFill 之外的兜底,默认给 0 不参与)。 */ clearBackgroundDepth: 0, /* ── 文字颜色 ────────────────────────────────────────── * 空字符串 = 用应用自己的配色(跟随明暗主题)。 * 填颜色(如 '#ffffff')= 强制所有文字用这个颜色。 * 当前:统一白字(用户明确要求,深浅主题都白)。 */ textColor: '#ffffff', /* true 时连「写死了颜色」的元素也一起强制(#root * { color: … !important })。 * 不打开的话「统一白字」做不到 —— DSH 里大量文字是写死颜色/走状态色的 * (标题、正文、按钮、时间戳),只改令牌覆盖不到。 * 代价:链接色、成功/警告/错误这类状态色也会变白。 * 若发现某处本该有颜色却变白了,把它改回 false。 */ textColorAggressive: true, /* 次要文字(时间戳、说明文字)是否跟着一起变。 * false = 保留应用原本的次要文字色(通常更淡)。 */ textColorSecondaryToo: true, /* ── 各区域的开关 ─────────────────────────────────────── */ // 聊天框(消息输入卡片) composer: true, // 聊天框内的按钮行 toolbar: true, // 左侧会话工作区(这一版默认开) sidebar: true, // 右侧栏(这一版默认开) right: true, // 顶栏(Windows 上其实是窗口标题条区域,默认不动它) topbar: false, // 审批对话框 / 模态弹窗 玻璃化 approval: true, modal: true, // 模糊半径(px)—— 液态玻璃一般 6~12 blur: 7, // 饱和度提升,让透过来的颜色更"活" saturate: 1.7, // 聊天框折射强度(px)—— 0 关闭折射,只要毛玻璃。12~24 效果明显 refract: 0, // 0 = 不做边缘折射(SVG 滤镜 backdrop 在动画壁纸上会闪烁) // 圆角(px) radius: 18, // 背景层:为了让玻璃"有东西可折射", // 在应用后面铺一层随主题走的柔和光斑。 // 接上壁纸后会自动让位(壁纸垫底效果更好)。 backdrop: true, /* ── 壁纸 ────────────────────────────────────────────── * 由宿主半体(index.js)起的本地只读服务提供文件。 * 这里填相对 wallpapers/ 的入口路径。 * 宅邸禅院 → 'zen/index.html' */ wallpaper: { enabled: true, entry: 'zen/index.html', // 宿主服务监听的候选端口,顺序与 index.js 保持一致 ports: [39321, 39322, 39323, 39324], // 壁纸整体压暗 0~1(壁纸太亮会影响读字) dim: 0.15, // 壁纸整体模糊 px。⚠️ 壁纸是跨源 iframe(独立合成层), // 外面套 filter 会把它变成一片黑,所以默认 0,别乱调。 blur: 0, // 壁纸不透明度 opacity: 1, // 显示左下角的玻璃控制条(切场景 / 环境音 / 音量) controls: true, // 隐藏壁纸自带的 HUD(否则那个「院」按钮会浮在右边) hideWallpaperHud: true, // 下拉框里是否也列出 scene / application 类型的壁纸。 // 那些依赖 Wallpaper Engine 的 DirectX 运行时,选了也渲染不出来,默认不显示。 showUnsupported: false, // 壁纸画廊(缩略图九宫格)默认展开还是收起 galleryOpen: false, }, // 右下角状态横幅。已关闭;排查问题时改回 true 即可。 // (无论开关,Console 里始终有详细日志。) report: false, }; /* ── 工具 ───────────────────────────────────────────── */ function parseColor(value) { const s = String(value ?? '').trim(); if (!s || s === 'transparent' || s === 'none') return null; let m = /^#([0-9a-f]{3})$/i.exec(s); if (m) { const h = m[1]; return { r: Number.parseInt(h[0] + h[0], 16), g: Number.parseInt(h[1] + h[1], 16), b: Number.parseInt(h[2] + h[2], 16), }; } m = /^#([0-9a-f]{6})$/i.exec(s); if (m) { const n = Number.parseInt(m[1], 16); return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; } m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i.exec(s); if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]) }; return null; } const toHex = (c) => '#' + [c.r, c.g, c.b] .map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0')) .join(''); /** 把 0~1 的两端夹住;非数字回落到 fallback。 */ function clamp01(v, fallback = 0) { const n = Number(v); if (!Number.isFinite(n)) return fallback; return Math.max(0, Math.min(1, n)); } /** 非负数值;非数字回落 fallback。 */ function num(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? Math.max(0, n) : fallback; } /** 四档通透度 → 白纱不透明度(设计规格 8% / 3% / 1.5% / 0)。 */ const GLASS_TIERS = { clear: 0.08, // 规格里的 Clear 档基准 regular: 0.03, // 默认:更透,仍压得住大部分壁纸 thin: 0.015, // 很透,花哨壁纸上字会吃力 off: 0, // 完全无色(只剩模糊与边缘光学) }; /** * 各区域的**厚度倍数**(乘在档位值上)。 * 为什么用倍数而不是绝对值:换通透度档时,各区域之间的相对关系 * 必须保持不变 —— 否则换一档就要把所有区域重调一遍。 * 倍数取舍来自「这块面上的字有多重要」: * 审批卡 > 弹窗 > 浮层菜单 > 左栏 ≈ 右栏 > 输入卡片。 */ /* 用户定稿:**所有玻璃面都对齐「右侧栏展开」那一档**(α = 0.03 × 4.6 ≈ 0.138), 不再按区域分厚薄 —— 之前审批卡 ×10、弹窗 ×13 明显偏厚,看着不像同一套材质。 */ const REGION_MULT = { sidebarGlass: 4.6, rightGlass: 4.6, composerGlass: 4.6, overlayGlass: 4.6, approvalGlass: 4.6, modalGlass: 4.6, }; /** 取 iOS 26 规格里的一项(CONFIG.glass 的兜底)。 */ function g(key, fallback) { const raw = CONFIG.glass && typeof CONFIG.glass === 'object' ? CONFIG.glass : {}; let v = raw[key]; if (v === null || v === undefined) { /* tint 没显式给 → 取通透度档位 */ if (key === 'tint') { const tier = String(CONFIG.glassTier ?? 'regular'); v = GLASS_TIERS[tier] ?? GLASS_TIERS.regular; } else { return fallback; } } return v; } /** * 取某一档玻璃配置。 * * `alpha` 写 null(或不写)= **档位值 × 该区域倍数**; * 写数字 = 该区域单独指定,不受档位影响。 * `frost` 写 null = 用规格模糊半径(18px)。 * 老配置把整档写成数字也照样认(向后兼容)。 */ function layer(name, fallbackAlpha) { const raw = CONFIG[name]; const obj = raw && typeof raw === 'object' ? raw : {}; const pick = (v, spec, fb) => { if (v === null || v === undefined) { if (typeof raw === 'number') return raw; // 老写法:整档就是一个数字 return g(spec, fb); } return v; }; let alpha = obj.alpha; if (alpha === null || alpha === undefined) { // 档位值 × 区域倍数 const mult = REGION_MULT[name] ?? 5; alpha = clamp01(num(g('tint', 0.03), 0.03) * mult); } return { alpha: clamp01(pick(alpha, 'tint', fallbackAlpha)), frost: num(pick(obj.frost, 'blur', 18), 18), }; } /** * DSH 背景令牌的**参考默认值**。 * 实际生效的透明度由 CONFIG 各档决定(见 buildBaseCss), * 这里只用于「读不到令牌时」的兜底颜色。 */ const TOKEN_ALPHAS = { '--dsw-alias-bg-layer-1': 0.86, '--dsw-alias-bg-layer-2': 0.74, '--dsw-alias-bg-overlay': 0.96, '--dsw-specific-sidebar-fill': 0.84, }; /** * 读当前主题。 * 先把自己的样式摘掉再读,否则读到的会是自己上一次写进去的值。 * * 除了判断明暗,还要**记下这些令牌的原始颜色** —— 后面只把它们变半透明, * 颜色沿用应用自己的,这样浅色/深色主题都不会压掉文字对比度。 */ function readTheme() { const mine = document.getElementById(STYLE_ID_DYN); const parent = mine ? mine.parentNode : null; const out = { base: null, dark: true, raw: '', tokens: {} }; try { if (mine && parent) parent.removeChild(mine); const read = (name) => { let v = getComputedStyle(document.documentElement).getPropertyValue(name); if (!String(v).trim()) v = getComputedStyle(document.body).getPropertyValue(name); return String(v).trim(); }; /* 品牌蓝要用它做基准(只换色相,保住主题的明度) */ const labelRgb = parseColor(read('--dsw-alias-label-primary')); if (labelRgb) out.tokens['--dsw-alias-label-primary'] = labelRgb; for (const name of Object.keys(TOKEN_ALPHAS)) { const rgb = parseColor(read(name)); if (rgb) out.tokens[name] = rgb; } const baseRaw = read('--dsw-alias-bg-base'); const labelRaw = read('--dsw-alias-label-primary'); out.raw = baseRaw; out.base = parseColor(baseRaw); const label = parseColor(labelRaw); if (out.base) { out.dark = (0.2126 * out.base.r + 0.7152 * out.base.g + 0.0722 * out.base.b) / 255 < 0.5; } else if (label) { out.dark = (0.2126 * label.r + 0.7152 * label.g + 0.0722 * label.b) / 255 > 0.5; } } catch (error) { out.error = String(error && error.message); } finally { if (mine && parent) parent.appendChild(mine); } return out; } function rgbaOf(rgb, alpha) { const a = Math.max(0, Math.min(1, Number(alpha))); return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${Number.isFinite(a) ? a : 1})`; } /** * 把颜色换成指定色相(保留明度/饱和度),用来做品牌蓝。 * 为什么要换算而不是直接写死 #4d6bfe: * 深浅主题下品牌字与 logo 的明度本来就不同,写死会一边糊一边飘。 * 只换色相 → 深浅主题各自保持自己的对比度,观感却统一成蓝色。 */ function toHue(c, hue) { const r = c.r / 255; const gg = c.g / 255; const b = c.b / 255; const max = Math.max(r, gg, b); const min = Math.min(r, gg, b); const l = (max + min) / 2; const d = max - min; /* 源色近中性(白/灰)时饱和度≈0,只换色相等于换了个白 —— 所以设一个饱和度下限,让品牌蓝在任何主题下都真的蓝。 */ const rawSat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)); const sat = Math.max(rawSat, 0.55); const h = Number(hue) / 360; const q = l < 0.5 ? l * (1 + sat) : l + sat - l * sat; const p = 2 * l - q; const f = (t0) => { let t = t0; if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; return { r: Math.round(f(h + 1 / 3) * 255), g: Math.round(f(h) * 255), b: Math.round(f(h - 1 / 3) * 255), }; } /** CSS 字符串里的 rgb 三连("77, 107, 254")按给定色相重算。 */ function hueShiftCssLinear(value, hue) { return String(value).replace( /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)([^)]*)\)/g, (all, r, gg, b, rest) => { const c = toHue({ r: +r, g: +gg, b: +b }, hue); return `rgba(${c.r}, ${c.g}, ${c.b}${rest})`; }, ); } /** * 左上角品牌区的玻璃参数。 * * 叠加两层蓝色:一层径向做「液态」的体积感,一层线性做上亮下暗。 * 色相由 CONFIG.brand.hue 定,明度由主题的 label-primary 派生 —— * 于是深色主题是「浅蓝玻璃字」,浅色主题是「深蓝玻璃字」。 */ function buildBrandVars(theme) { const cfg = CONFIG.brand && typeof CONFIG.brand === 'object' ? CONFIG.brand : {}; const alpha = clamp01(cfg.alpha === undefined ? 0.42 : cfg.alpha); /* 配色写死成规格值,不再从主题推 —— 用户要「浅蓝底 + 深蓝字标」, 主题推导会在深色模式下把底变成近白,反而不符合要求。 */ const blue = cfg.base ?? { r: 191, g: 219, b: 254 }; const ink = cfg.ink ?? { r: 30, g: 64, b: 175 }; const inkStr = `${ink.r}, ${ink.g}, ${ink.b}`; const blueStr = `${blue.r}, ${blue.g}, ${blue.b}`; const blur = num(layer('sidebarGlass', 0.12).frost, 18); const sat = num(g('saturate', 1.6), 1.6); const edgeRefract = num(g('edgeRefract', 10), 10); const filter = edgeRefract > 0 ? `blur(${blur}px) saturate(${sat}) url(#${FILTER_REF})` : `blur(${blur}px) saturate(${sat})`; return `--dshlg-brand-ink: ${inkStr}; -dshlg-brand-tone: ${blueStr}; --dshlg-brand-a: ${alpha}; --dshlg-brand-filter: ${alpha > 0 ? filter : 'none'};`.replace('-dshlg-brand-tone', '--dshlg-brand-tone'); } /** 计算值是不是「透明」(空串 / transparent / none / rgba(...,0))。 */ function isClear(v) { const s = String(v ?? '').trim().toLowerCase(); if (!s || s === 'transparent' || s === 'none' || s === 'rgba(0, 0, 0, 0)') return true; const m = /rgba?\(([^)]*)\)/.exec(s); if (m) { const parts = m[1].split(/[,/\s]+/).filter(Boolean); if (parts.length >= 4) return Number(parts[3]) === 0; } return false; } /* ── SVG 折射滤镜(只弯折边缘一圈)───────────────────── */ /** * 建「边缘透镜」滤镜;已存在时只更新强度与边缘带宽。 * * 为什么必须有 mask:`backdrop-filter` 里的 SVG 滤镜是**整面**生效的, * 直接挂 feDisplacementMap 会把整块玻璃扭成毛玻璃汤 —— 那不是 iOS。 * iOS 26 的做法是**只有边缘 10~15% 宽度**向内折射,中间保持清晰, * 于是用一张「外圈白、内部黑」的 mask 去限制位移图: * · mask 用 objectBoundingBox(默认)→ 每块面按自己的尺寸缩放, * 侧栏与审批卡拿到的是同一条**相对厚度**的边; * · 外圈白只给 35% 不透明度:feDisplacementMap 对 0.5 灰就取满幅位移, * 不压亮度边缘会扭过头。 */ function ensureFilter(scale) { const band = edgeBandPct(g('edgeWidth', 0.12)); const mid = band * 0.5; const inner = band * 1.05; const existing = document.getElementById(SVG_ID); if (existing) { const disp = existing.querySelector('feDisplacementMap'); if (disp) disp.setAttribute('scale', String(num(scale, 0))); const mask = existing.querySelector('mask'); if (mask) { const stops = mask.querySelectorAll('stop'); if (stops[1]) stops[1].setAttribute('offset', `${mid.toFixed(1)}%`); if (stops[2]) stops[2].setAttribute('offset', `${inner.toFixed(1)}%`); } return; } const NS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('id', SVG_ID); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('width', '0'); svg.setAttribute('height', '0'); svg.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;overflow:hidden;pointer-events:none'; // ① 边缘掩码:外圈亮、内部黑(stop 的透明度由 CSS 里的 .stop-* 给) const mask = document.createElementNS(NS, 'mask'); mask.setAttribute('id', `${FILTER_REF}-edge`); mask.setAttribute('x', '-20%'); mask.setAttribute('y', '-20%'); mask.setAttribute('width', '140%'); mask.setAttribute('height', '140%'); const mkStop = (offset, cls) => { const s = document.createElementNS(NS, 'stop'); s.setAttribute('offset', offset); s.setAttribute('class', cls); return s; }; const grad = document.createElementNS(NS, 'radialGradient'); grad.setAttribute('id', `${FILTER_REF}-edge-grad`); grad.appendChild(mkStop('0%', 'stop-outer')); grad.appendChild(mkStop(`${mid.toFixed(1)}%`, 'stop-mid')); grad.appendChild(mkStop(`${inner.toFixed(1)}%`, 'stop-inner')); grad.appendChild(mkStop('100%', 'stop-inner')); const defs = document.createElementNS(NS, 'defs'); defs.appendChild(grad); svg.appendChild(defs); const rect = document.createElementNS(NS, 'rect'); rect.setAttribute('x', '-20%'); rect.setAttribute('y', '-20%'); rect.setAttribute('width', '140%'); rect.setAttribute('height', '140%'); rect.setAttribute('fill', `url(#${FILTER_REF}-edge-grad)`); mask.appendChild(rect); svg.appendChild(mask); // ② 滤镜:噪声 → 柔化 → 位移(位移图被 mask 限制在边缘) const filter = document.createElementNS(NS, 'filter'); filter.setAttribute('id', FILTER_REF); // 滤镜区域放大,避免折射把边缘推出可视范围 filter.setAttribute('x', '-20%'); filter.setAttribute('y', '-20%'); filter.setAttribute('width', '140%'); filter.setAttribute('height', '140%'); filter.setAttribute('color-interpolation-filters', 'sRGB'); filter.setAttribute('mask', `url(#${FILTER_REF}-edge)`); const turb = document.createElementNS(NS, 'feTurbulence'); turb.setAttribute('type', 'fractalNoise'); turb.setAttribute('baseFrequency', '0.006 0.009'); turb.setAttribute('numOctaves', '2'); turb.setAttribute('seed', '7'); turb.setAttribute('result', 'noise'); const soft = document.createElementNS(NS, 'feGaussianBlur'); soft.setAttribute('in', 'noise'); soft.setAttribute('stdDeviation', '3'); soft.setAttribute('result', 'soft'); const disp = document.createElementNS(NS, 'feDisplacementMap'); disp.setAttribute('in', 'SourceGraphic'); disp.setAttribute('in2', 'soft'); disp.setAttribute('scale', String(num(scale, 0))); disp.setAttribute('xChannelSelector', 'R'); disp.setAttribute('yChannelSelector', 'G'); filter.appendChild(turb); filter.appendChild(soft); filter.appendChild(disp); svg.appendChild(filter); document.body.appendChild(svg); } /* ── DOM 探测 ───────────────────────────────────────── */ /** 圆角是否 >= min(读计算值,兼容 px / 百分比)。 */ function radiusOf(el) { const cs = getComputedStyle(el); const v = cs.borderTopLeftRadius || '0'; const n = Number.parseFloat(v); return Number.isFinite(n) ? n : 0; } /** 元素在 DOM 里的深度(越浅越靠近 #root)。 */ function depthOf(el) { let d = 0; for (let n = el; n; n = n.parentElement) d++; return d; } /** 尺寸描述,用来在报告里核对几何判据。 */ function rectOf(el) { if (!el || !el.getBoundingClientRect) return null; const r = el.getBoundingClientRect(); return { w: Math.round(r.width), h: Math.round(r.height), left: Math.round(r.left), right: Math.round(r.right), }; } /** * 找左侧那一栏「会话工作区」。 * * 为什么不按类名找:DSH 的类名是哈希的(`LdtX1G_xxx`),而且每次构建都会变, * 拿它当选择器一次升级就废。也不按结构猜(`root.children[0].children[0]` * 这种),布局一改就错。 * * 所以用**几何特征**,它由「左侧整列」这个事实本身决定: * · 左边缘贴着 x=0 * · 高度接近整个视口 * · 宽度是一栏的量级(不是整个窗口,也不是一根分隔线) * · 里面有若干个按钮(会话列表、设置入口…) * 满足这些的**最外层**元素就是它。 */ function findLeftColumn(root, vw, vh) { /* ① 语义钩子优先:ui-sidebar 的根容器类名是 _root, 它一定包着 logoRow / panelList / regionArea 这些稳定后缀。 */ const inner = root.querySelector('[class*="_logoRow"]') ?? root.querySelector('[class*="_panelList"]') ?? root.querySelector('[class*="_regionArea"]'); if (inner) { const outer = inner.closest('[class*="_root"]') ?? inner.parentElement; if (outer && outer.getBoundingClientRect().width >= 40) return outer; } /* ② 几何兜底(DSH 换结构时用):左缘贴视口、近满高、一栏宽、内含 ≥2 个按钮 */ const maxWidth = Math.min(520, vw * 0.45); let best = null; let bestDepth = Infinity; for (const el of root.querySelectorAll('*')) { const r = el.getBoundingClientRect(); if (r.width < 44 || r.width > maxWidth) continue; if (r.height < vh * 0.75) continue; if (r.left > 4) continue; if (el.querySelectorAll('button').length < 2) continue; const d = depthOf(el); if (d < bestDepth) { bestDepth = d; best = el; } } return best; } /** * 找右侧栏(会话面板)。 * * 它的结构是:右栏轨道(rightbarCol)里放一个 `position:absolute; right:0` * 的面板,面板**宽度可以超出轨道**(所以轨道本身不能套玻璃,否则只有窄窄一条)。 * * 判据(同样只看几何 + 结构事实): * · 贴着视口右边缘 * · 高度接近整个视口 * · 宽度 ≥ 240px 且 ≤ 视口 70% * · 里面**有东西**(按钮 / 文本 / 滚动容器),空的轨道不算 * 取最外层那个。 */ function findRightPanel(root, frame, vw, vh) { const maxWidth = vw * 0.7 + 8; const container = frame ?? root; let best = null; let bestDepth = Infinity; for (const el of container.querySelectorAll('*')) { if (el === frame) continue; const r = el.getBoundingClientRect(); if (r.width < 240 || r.width > maxWidth) continue; if (r.height < vh * 0.75) continue; if (Math.abs(r.right - vw) > 6) continue; const text = (el.textContent ?? '').trim(); if (el.querySelectorAll('button, input, textarea, a[href]').length === 0 && text.length < 8) { continue; } const d = depthOf(el); if (d < bestDepth) { bestDepth = d; best = el; } } return best; } /** * 找右栏面板本体(DSH 的 SidebarRight `.panel`)。 * * 它自己发布了稳定钩子(源码实测): * `data-sidebar-right-session=""` * `data-sidebar-right-panel="push|fullscreen"` * `data-sidebar-right-open` ← **只在展开时才有** * `aria-hidden="true"` ← 收起的另一面 * 这些比几何判据硬得多,所以优先用它们。 */ function resolveRightPanel(root, frame) { const container = frame ?? root ?? document; const hooked = container.querySelectorAll('[data-sidebar-right-session]'); if (hooked.length > 0) { /* 一个页面里可能有多个 session 面板(历史会话各留一个), 必须挑**开着的那个**,否则会拿到一个收起的、把开着的那块也判成收起。 代价只有一次遍历,收益是省掉一整类误判。 */ for (const el of hooked) { if (el.hasAttribute('data-sidebar-right-open')) return el; } return hooked[hooked.length - 1]; } return container.querySelector('[data-sidebar-right-panel]'); } /** * 右栏轨道里「真正画出内容」的那一层。 * * 两种情况都要覆盖: * · 面板是绝对定位、比轨道更宽(能露到轨道外面)→ 按右边缘贴视口找; * · 面板就在轨道里、宽度等于轨道(真实 DSH 的 .LdcXKW_panel 也是这样, * 它 top/bottom:0、right:0)→ 找轨道里「铺满轨道且有内容」的最外层。 * * 为什么非要往里找一层:轨道(网格列)本身没内容, * 把玻璃糊在轨道上,等于只糊了一条空带子。 */ function findPanelInsideColumn(col, vw, vh) { const track = col.getBoundingClientRect(); let wide = null; let filling = null; for (const el of col.querySelectorAll('*')) { const r = el.getBoundingClientRect(); if (r.width < 240 || r.height < vh * 0.6) continue; // A) 比轨道宽、右边缘贴视口 → 绝对定位的浮层面板 if ( r.width <= vw * 0.7 + 8 && r.width > track.width + 8 && Math.abs(r.right - vw) <= 6 ) { if (!wide || depthOf(el) < depthOf(wide)) wide = el; continue; } // B) 铺满轨道、且自己有内容 → 轨道内的面板本体 if ( Math.abs(r.width - track.width) <= 6 && Math.abs(r.height - track.height) <= 6 && el.querySelectorAll('button, input, textarea, a[href], [class*="_file"], [class*="_row"]').length > 0 ) { if (!filling || depthOf(el) < depthOf(filling)) filling = el; } } return wide ?? filling; } /** * 右栏面板「现在是不是真的打开着」。 * * ⚠️ 这里必须**属性优先**,不能靠几何: * · DSH 收起是把面板滑出视口,且 `transform` 作用在**子元素** * (`[data-dockkit-host]` 上),面板自己的 rect **不会变**; * · 面板自己的 `visibility` 也可能是 visible(隐藏落在子元素上)。 * 实测源码:`.LdcXKW_panel [data-dockkit-host]{visibility:hidden;transform:translateX(...)}` * 所以只要「面板自己没有 data-sidebar-right-open」就先倾向收起, * 再拿「dock 子元素是否真的出现在视口里」兜底。 */ function isOpenPanel(panel) { if (!panel) return false; try { /* ① DSH 自己发布的开关:展开时才有这个属性(源码实测) */ const owner = panel.closest('[data-sidebar-right-session]') ?? panel; const hasOpenAttr = owner.hasAttribute('data-sidebar-right-open'); if (!hasOpenAttr) return false; /* ② 面板自己都不显示 → 收起 */ const cs = getComputedStyle(panel); if (cs.display === 'none') return false; /* ③ dock 子元素是不是真的出现在视口里 (隐藏/位移都落在它们身上,所以这里才是可信的几何) */ const vw = window.innerWidth; const hosts = panel.querySelectorAll('[data-dockkit-host], [data-dockkit-pane], [data-dockkit-float]'); for (const h of hosts) { const hcs = getComputedStyle(h); if (hcs.display === 'none' || hcs.visibility === 'hidden') continue; const hr = h.getBoundingClientRect(); if (hr.width < 1 || hr.height < 1) continue; if (hr.right <= 1 || hr.left >= vw - 1) continue; return true; } /* ④ 一个 dock 子元素都没有(空面板):只要它在视口内就算开着 */ const r = panel.getBoundingClientRect(); return r.width > 1 && r.right > 1 && r.left < vw - 1; } catch { return false; } } /** * 右栏整体是否处于「已收起」。 * * 为什么不只用 isOpenPanel(某一块面):探测有可能选到轨道而不是面板本体, * 那时 isOpenPanel 会答「收起」,而**真正还开着的那块面板**却保留着玻璃。 * 所以这里独立判一次:只要容器里存在任何一个**开着**的 session 面板, * 右栏就算开着;一个都没有,就整列按收起处理。 * 这样无论探测选中的是哪一层,收起后都不会留下玻璃。 */ function rightColumnCollapsed(container, fallbackSurface) { if (!container) return !isOpenPanel(fallbackSurface); const panels = container.querySelectorAll('[data-sidebar-right-session]'); if (panels.length > 0) { for (const p of panels) { if (isOpenPanel(p)) return false; // 有一个开着 → 整列算开着 } return true; // 全收起 → 整列收起 } return !isOpenPanel(fallbackSurface); } /** * 给一个元素套玻璃前,先找一个更合适的宿主。 * * `backdrop-filter` 只作用于**自己身后的背景**,对后代无效: * · 如果宿主自己背景不透明 → 后代其实压在这块不透明底上,玻璃等于没用; * · 如果宿主是纯布局容器(背景透明)→ 直接套在上面,让玻璃盖住整个子树。 * 所以「有底色就地用,没底色往上顶一层」。 * * @param stop 到这一层就绝不再往上(一般是整帧,别把整个界面糊掉) * @param bounds 只许在这些祖先里找(空 = 从 el 到 stop 都行)。 * 传入 [container] 就等于「就地用,不许爬出去」—— * 右栏面板、审批卡面都用这个,因为它们本身就是要糊的那块面。 */ function hostFor(el, stop, maxUp = 3, bounds = null) { if (!el || !el.ownerDocument) return el; const frame = stop ?? null; const base = el.getBoundingClientRect(); let node = el; for (let i = 0; i < maxUp; i++) { const bg = getComputedStyle(node).backgroundColor; if (!isClear(bg)) return node; // 自己有底色 → 就地套 const parent = node.parentElement; if (!parent || parent === document.body || parent === document.documentElement) return node; if (frame && parent === frame) return node; // 别把整帧吃掉 if (bounds && !bounds.includes(parent)) return node; // 越界了 → 就地套 const pr = parent.getBoundingClientRect(); // 父节点明显比目标大(或者根本不是一块面)→ 顶上去就把别的区域也糊了 if (pr.width > base.width * 1.6 || pr.height > base.height * 1.2) return node; if (parent.querySelectorAll('button, input, textarea, a[href]').length > 80) return node; node = parent; } return node; } /** * 找模态层 / 模态卡。 * * 两种形态都要认: * · 外层「遮罩容器」自己带 backdrop-filter(DSH 的 Modal.root 就是这种) * → 返回它,调用方再往里找 .dialog; * · 遮罩只是个普通全屏层、模糊在 mask 兄弟节点上 * → 直接找 role=dialog / 类名含 dialog 的那张卡,就地糊它。 * * 判据放宽到「近全屏 + fixed/absolute + 有模糊」,是因为 * 不同 DSH 版本把 backdrop-filter 放在 root 还是 mask 上并不固定, * 按老写法(要求 display:flex + z-index≥100 + 自己带模糊)会整片漏掉。 */ function findModalCandidate(root, vw, vh) { let outer = null; for (const el of root.querySelectorAll('*')) { const cs = getComputedStyle(el); if (cs.position !== 'fixed' && cs.position !== 'absolute') continue; const r = el.getBoundingClientRect(); if (r.width < vw * 0.85 || r.height < vh * 0.85) continue; const blurry = !isClear(cs.backdropFilter) || !isClear(cs.webkitBackdropFilter); if (!blurry) continue; if (outer === null || depthOf(el) < depthOf(outer)) outer = el; } if (outer) { const dialog = outer.querySelector('[role="dialog"], [class*="_dialog"]'); if (dialog) return dialog; } // 兜底:直接找可见的对话框卡片(不管遮罩长什么样) let dialog = null; for (const el of root.querySelectorAll('[role="dialog"], [class*="_dialog"]')) { const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') continue; const r = el.getBoundingClientRect(); if (r.width < 200 || r.height < 80) continue; if (dialog === null || depthOf(el) < depthOf(dialog)) dialog = el; } return outer ?? dialog; } function discover() { const out = { editor: null, composer: null, toolbar: null, sidebar: null, right: null, approval: null, modal: null, notes: [], }; const root = document.getElementById('root') ?? document.body; const frame = root.querySelector('[class*="_frame"]') ?? root; const vw = window.innerWidth; const vh = window.innerHeight; // 1) 消息输入框 const editor = document.querySelector('[contenteditable="true"]') || document.querySelector('[contenteditable=""]') || document.querySelector('textarea') || /* ProseMirror / 富文本编辑器的常见落点 */ root.querySelector('[class*="_editor"]') || root.querySelector('[class*="_composer"] [contenteditable]') || null; out.editor = editor; if (!editor) { out.notes.push('没找到输入框([contenteditable] / textarea)'); } // 2) 聊天框卡片:从输入框往上,找「圆角 >= 4 且内部至少 2 个按钮」的最近祖先 if (editor) { let node = editor.parentElement; for (let depth = 0; node && depth < 10; depth++, node = node.parentElement) { if (node === document.body || node === document.documentElement) break; const buttons = node.querySelectorAll('button').length; if (radiusOf(node) >= 4 && buttons >= 2) { out.composer = node; out.notes.push(`聊天框:向上 ${depth + 1} 层命中(按钮 ${buttons} 个)`); break; } } if (!out.composer) { out.composer = editor.parentElement; out.notes.push('聊天框:未命中理想判据,回落到输入框的直接父节点'); } } // 3) 工具栏:卡片范围内「含 >=2 个按钮、且不含输入框」的最外层元素 const scope = out.composer ?? editor?.parentElement ?? null; if (scope && editor) { const all = Array.from(scope.querySelectorAll('*')); const candidates = all.filter((el) => { if (el.contains(editor)) return false; // 排除整个卡片 if (el.querySelectorAll('button').length < 2) return false; return true; }); out.toolbar = candidates.find((el) => !candidates.some((o) => o !== el && o.contains(el))) ?? null; if (out.toolbar) out.notes.push(`工具栏:在聊天框内找到(按钮 ${out.toolbar.querySelectorAll('button').length} 个)`); else out.notes.push('工具栏:聊天框内没找到按钮行'); } // 4) 左侧会话工作区 if (CONFIG.sidebar) { out.sidebar = findLeftColumn(root, vw, vh); if (out.sidebar) { const r = rectOf(out.sidebar); out.notes.push( `左栏:几何命中(${r.w}×${r.h},按钮 ${out.sidebar.querySelectorAll('button').length} 个)`, ); } else { out.notes.push('左栏:几何判据没命中(窗口太窄?或布局不是左右分栏)'); } } // 5) 右侧栏 if (CONFIG.right) { // 先认 DSH 自己发布的钩子(data-sidebar-right-*),命不中再用几何兜底 const hooked = resolveRightPanel(root, frame); if (hooked) { const open = isOpenPanel(hooked); const r = rectOf(hooked); out.right = hooked; out.rightOpen = open; out.rightVia = 'hook'; out.notes.push( `右栏:钩子命中 data-sidebar-right-session(${r.w}×${r.h})→ ${open ? '展开' : '收起'}`, ); } else { const geo = findRightPanel(root, frame, vw, vh); out.right = geo; out.rightOpen = geo ? isOpenPanel(geo) : false; out.rightVia = geo ? 'geometry' : null; out.notes.push( geo ? `右栏:几何命中(${rectOf(geo).w}×${rectOf(geo).h})→ ${out.rightOpen ? '展开' : '收起'}` : '右栏:钩子与几何都没命中(窗口太窄?右栏没打开?)', ); } } // 6) 审批卡 / 模态弹窗 if (CONFIG.approval) { // 几何宿主是整块「composer takeover」容器,但**有内容的是里面那张卡** // (圆角 + 描边 + 文字都在卡上),所以玻璃要贴在卡上, // 不能让卡里的字压在壁纸上。 const approvalRoot = document.querySelector('[data-approval-key]') ?? null; out.approval = approvalRoot ? (approvalRoot.querySelector('[class*="_card"]') ?? approvalRoot) : null; if (out.approval) { out.notes.push( `审批卡:命中 [data-approval-key]${out.approval !== approvalRoot ? ' → 卡面' : '(找不到卡面,用容器兜底)'}`, ); } } if (CONFIG.modal) { out.modal = findModalCandidate(root, vw, vh); if (out.modal) { out.notes.push(`弹窗:命中「${(out.modal.className || out.modal.tagName).toString().slice(0, 40)}」`); } } return out; } /** 打标记;返回是否新打上。 */ function tag(el, cls) { if (!el || !(el instanceof Element)) return false; if (el.classList.contains(cls)) return false; el.classList.add(cls); el.dataset.dshlg = cls; return true; } function untagAll() { document.querySelectorAll('[data-dshlg]').forEach((el) => { el.classList.remove(CLS_GLASS, CLS_TOOLBAR, CLS_SIDE, CLS_RIGHT, CLS_APPROVAL, CLS_MODAL); delete el.dataset.dshlg; }); document.querySelectorAll('[data-dshlg-closed]').forEach((el) => { delete el.dataset.dshlgClosed; }); } /* ── 背景层(给玻璃一点可折射的东西)───────────────── */ function buildBackdrop(theme) { if (!CONFIG.backdrop) return ''; const base = theme.base ?? { r: 13, g: 17, b: 23 }; // 色斑用「明暗两套」固定色调,而不是从底色推 —— 推算出来的太接近底色, // 玻璃等于没有东西可折射(这正是第一版看起来"没效果"的原因)。 const palette = theme.dark ? ['#1d3fa8', '#5b1f96', '#0e6b60'] : ['#b9d2ff', '#ffc9e2', '#bdf0e6']; const [c1, c2, c3] = palette.map(parseColor); const blobOpacity = theme.dark ? 0.72 : 0.9; return ` #${BG_ID} { position: fixed; inset: 0; z-index: 0; pointer-events: none; background: ${toHex(base)}; overflow: hidden; } #${BG_ID} i { position: absolute; width: 80vmax; height: 80vmax; border-radius: 50%; filter: blur(16vmax); opacity: ${blobOpacity}; mix-blend-mode: ${theme.dark ? 'screen' : 'normal'}; } #${BG_ID} i:nth-child(1) { background: radial-gradient(circle, ${toHex(c1)} 0%, transparent 68%); animation: dshlg-a 58s ease-in-out infinite; } #${BG_ID} i:nth-child(2) { background: radial-gradient(circle, ${toHex(c2)} 0%, transparent 68%); animation: dshlg-b 74s ease-in-out infinite; } #${BG_ID} i:nth-child(3) { background: radial-gradient(circle, ${toHex(c3)} 0%, transparent 68%); animation: dshlg-c 92s ease-in-out infinite; } @keyframes dshlg-a { 0%,100% { transform: translate(-24%,-18%) } 50% { transform: translate(12%,16%) } } @keyframes dshlg-b { 0%,100% { transform: translate(56%,10%) } 50% { transform: translate(16%,-22%) } } @keyframes dshlg-c { 0%,100% { transform: translate(6%,54%) } 50% { transform: translate(50%,24%) } } @media (prefers-reduced-motion: reduce) { #${BG_ID} i { animation: none } } `; } function ensureBackdrop(theme) { if (!CONFIG.backdrop) return; let el = document.getElementById(BG_ID); if (!el) { el = document.createElement('div'); el.id = BG_ID; el.setAttribute('aria-hidden', 'true'); el.innerHTML = ''; document.body.prepend(el); } const want = theme.dark ? 'dark' : 'light'; // 只在真的变了才写:这个属性也是 上的变动,会惊动我们自己的 observer if (el.dataset.theme !== want) el.dataset.theme = want; } /** * 移除内置背景层。 * * ⚠️ 必须先判存在再删。`?.remove()` 对**不存在的节点**也是安全的, * 但它在旧实现里每遍都会被调用 —— 而 `ChildList` 型 MutationObserver * 连「删除一个不存在的节点」这种空操作都不会触发,真正的问题在别处: * 一旦这个节点在两种状态间反复创建/删除,observer 就会互相触发形成churn。 * 所以这里保持「不存在就什么都不做」的语义,并且调用方也只在需要时调。 */ function removeBackdrop() { const el = document.getElementById(BG_ID); if (el) el.remove(); } /* ── 基础样式:透明化 + 令牌 + 内容块底色 ───────────── */ /** * 应用自身底色透明化 + 把 DSH 的背景令牌改成(半)透明。 * * 不管用不用壁纸都必须有这一段 —— 否则应用是不透明的, * 壁纸看不见、玻璃也没东西可折射。 * * 令牌**沿用应用自己的颜色**(readTheme 记下的原色),只改 alpha: * 浅色主题得浅色面板、深色主题得深色面板, * 文字对比度始终由应用自己的配色保证,不需要判断主题。 */ function buildBaseCss(theme) { const captured = theme?.tokens ?? {}; const fallback = theme?.base ?? { r: 13, g: 17, b: 23 }; const dark = theme?.dark !== false; const panel = clamp01(CONFIG.panelAlpha); const overlay = clamp01(CONFIG.overlayAlpha); /** 令牌取原色 + 指定透明度;全透明时直接输出 transparent,更干净。 */ const tokenValue = (name, alpha) => { const a = Math.max(0, alpha); if (a <= 0) return 'transparent'; return rgbaOf(captured[name] ?? fallback, a); }; // 文字衬底阴影:跟随「实际文字色」而不是主题 —— // 强制白字时,浅色主题原本的白阴影会失效,必须反转成深色。 const ts = clamp01(CONFIG.textShadow); const textColor = String(CONFIG.textColor ?? '').trim(); const tcRgb = parseColor(textColor); // 文字偏亮 → 用黑阴影;文字偏暗 → 用白阴影 const textIsLight = tcRgb ? (0.2126 * tcRgb.r + 0.7152 * tcRgb.g + 0.0722 * tcRgb.b) / 255 > 0.5 : !dark; const shadowColor = (a) => textIsLight ? `rgba(0,0,0,${a})` : `rgba(255,255,255,${Math.min(1, a + 0.25)})`; const shadowCss = ts > 0 ? ` /* 面板很透时,给文字加一层衬底 —— 压在壁纸上可读性会好很多。 text-shadow 是可继承属性,写在 #root 上即可覆盖整棵树。 阴影颜色跟着文字色走:白字配黑晕,深字配白晕。 */ #root { text-shadow: 0 1px 2px ${shadowColor(ts)}, 0 0 2px ${shadowColor(ts * 0.7)}; } ` : ''; // 文字颜色覆盖 let textCss = ''; if (textColor) { const secondary = CONFIG.textColorSecondaryToo ? textColor : ''; textCss = ` /* 强制文字颜色 */ html, body, #root { --dsw-alias-label-primary: ${textColor} !important;${secondary ? `\n --dsw-alias-label-secondary: ${secondary} !important;` : ''} } ${ CONFIG.textColorAggressive ? `/* 连写死颜色的元素也一起覆盖(含子元素) */ #root, #root * { color: ${textColor} !important; }` : '' } `; } /* ── 清除背景块 ──────────────────────────────────────── * 这是"不要白块"的主力,但**分层清**: * * · 不透明底色一律清掉 → 壁纸才透得出来; * · 内容块(对话卡片、代码块、工具调用框)另给一层**极淡的玻璃底**, * 否则文字直接压在壁纸上(v1.8 的老毛病); * · 侧栏 / 右栏 / 浮层 / 审批卡 的玻璃底**永不参与清理** —— 它们是 * 靠本插件自己的 `data-dshlg` / `data-dshlg-region` 标记认出来的, * 下面用 :not() 排除。 */ const fill = String(CONFIG.contentFill ?? 'glass'); const fillAlpha = clamp01(CONFIG.contentFillAlpha, 0.06); const keepList = ['[data-dshlg-keep]', '[data-dshlg]', '[data-dshlg-region]']; const notKeep = keepList.map((s) => `:not(${s})`).join(''); let structural = ''; let contentGlassCss = ''; if (fill === 'glass' || fill === 'none') { structural = ` /* 清掉不透明底色(保留 background-image:图标与渐变不受影响)。 !important 能压过元素上的内联 style="background-color:…"。 下面 :not() 里那几类是本插件自己的玻璃面,绝不能清。 */ #root, #root *${notKeep} { background-color: transparent !important; } `; if (fill === 'glass') { contentGlassCss = ` /* 内容块补一层极淡的玻璃底:只清不铺的话,字会直接压在壁纸上。 放在 html 上、用 :where() 保持零特异性 —— 任何组件自己的 背景规则(哪怕只写 background:)都能压过它。 */ html { --dshlg-content-fill: rgba(255, 255, 255, ${dark ? fillAlpha : Math.min(1, fillAlpha + 0.1)}); } :where(#root, #root *)${notKeep} { background-color: var(--dshlg-content-fill, transparent); } `; } } else { const depth = Math.max(0, Math.min(8, Number(CONFIG.clearBackgroundDepth) || 0)); if (depth > 0) { const selectors = []; let sel = '#root'; for (let i = 0; i < depth; i++) { sel += ' > *'; selectors.push(sel); } structural = ` /* 逐层清掉容器底色(${depth} 层)—— 应对没走主题令牌、写死底色的容器 */ ${selectors.join(',\n')} { background-color: transparent !important; } `; } } return ` html, body { background: transparent !important; background-color: transparent !important; } #root { position: relative; z-index: 1; background: transparent !important; } ${structural}${contentGlassCss}${shadowCss}${textCss} /* 半透明化 DSH 的背景令牌。!important 是关键:这些令牌是作为 内联样式写在 body 上的,而内联样式优先级高于普通样式表规则。 注意这里**不再**把层令牌做成全透明:中间工作区之所以能全透明, 是因为它的底色本来就来自 bg-base(上一行直接置 transparent); 两侧栏另有自己的玻璃面(见 buildRegionCss),不靠令牌「透」。 */ html, body, #root { --dsw-alias-bg-base: transparent !important; --dsw-alias-bg-layer-1: ${tokenValue('--dsw-alias-bg-layer-1', panel)} !important; --dsw-alias-bg-layer-2: ${tokenValue('--dsw-alias-bg-layer-2', Math.max(0, panel - 0.08))} !important; --dsw-alias-bg-overlay: ${tokenValue('--dsw-alias-bg-overlay', overlay)} !important; --dsw-specific-sidebar-fill: ${tokenValue('--dsw-specific-sidebar-fill', Math.max(0, panel - 0.05))} !important; } `; } function buildWallpaperCss() { const w = CONFIG.wallpaper; return ` #${WALL_ID} { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; background: #000; opacity: ${clamp01(w.opacity, 1)}; } #${WALL_ID} iframe { display: block; width: 100%; height: 100%; border: 0; ${num(w.blur) > 0 ? `filter: blur(${num(w.blur)}px);` : ''} } /* 压暗层:壁纸太亮会影响读字 */ #${WALL_ID}::after { content: ''; position: absolute; inset: 0; background: rgba(0, 0, 0, ${clamp01(w.dim)}); } `; } /* ── 分层玻璃 CSS(iOS 26 Liquid Glass)─────────────── */ /** 边缘带占短边的比例 → 让透镜感在「细边」上也是同一段厚度。 */ function edgeBandPct(widthRatio) { const ratio = Math.max(0.03, Math.min(0.45, num(widthRatio, 0.12))); return Math.max(3, Math.min(45, ratio * 200)); // 长边 / 短边 = 2 倍关系 } /** * 边缘透镜让位:折射滤镜的变量(全局一份)。 * * 只弯折**边缘一圈**,中间不扭: * · feTurbulence 造噪声 → feGaussianBlur 柔化 → feDisplacementMap 位移像素 * · 位移图用 `` 裁成「外圈亮、内部黑」,并且把外圈亮度调低(约 35%) * —— 因为 feDisplacementMap 对 0.5 灰就取 1.0 的位移量(0.5 灰 = 满幅), * 不压亮度的话边缘会扭得过分。 * · maskUnits=objectBoundingBox:掩码跟着**每块面自己的尺寸**缩放, * 所以侧栏(整列)与审批卡(小卡片)拿到的是同一条相对厚度的透镜边。 * * 不支持 backdrop-filter 里用 SVG 滤镜时,CSS 里的兜底声明(纯 blur)仍然生效。 */ /** * 关键材质兜底(**内联、永远在**)。 * * 为什么要有这一层:styles.css 是外置文件,加载可能失败 —— * 实测 `file://` 页面下 `` 会被当成跨源请求直接拦掉 * (file:// 是不透明源),于是整份材质都不生效,玻璃只剩一层白纱。 * 所以这里内联最关键的十几条:玻璃/模糊、收起透明、中栏透明、 * 高光与圆角。它保证「无论 styles.css 在不在,界面都是对的」。 * styles.css 里的完整版会在加载成功后覆盖这些(同选择器、更靠后)。 */ /** styles.css 只尝试加载一次 */ let styleLoadTried = false; const CRITICAL_CSS = ` /* 一块玻璃面:底色 + 模糊 + 描边 + 顶光(细节在 styles.css) ⚠️ 必须带 #root 前缀(提高一级特异性):动态样式里有一条 \`#root, #root * { background-color: transparent !important }\`, 单靠 \`[data-dshlg-region]\` 会在层叠里输给它 —— 结果就是 「有底色、没模糊」的半成品玻璃(实测踩过)。 */ #root [data-dshlg-region]:not(.dshlg-brand) { box-sizing: border-box !important; background-color: rgba(var(--dshlg-tone, 255, 255, 255), var(--dshlg-alpha, 0.08)) !important; border: var(--dshlg-border, 1px) solid rgba(255, 255, 255, var(--dshlg-border-a, 0.18)) !important; border-radius: var(--dshlg-radius, 20px) !important; backdrop-filter: var(--dshlg-filter, blur(18px) saturate(1.6)); -webkit-backdrop-filter: var(--dshlg-filter, blur(18px) saturate(1.6)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, var(--dshlg-topline, 0.3)) !important; } /* 中栏与收起态:透明(同样带特异性,免得被兜底规则按回去) */ #root [data-dshlg-region="middle"], #root [data-dshlg-region="middle"] *, #root [data-dshlg-region="right"][data-dshlg-closed], #root [data-dshlg-region="right"][data-dshlg-closed] * { background-color: transparent !important; background-image: none !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; } #root [data-dshlg-region="middle"], #root [data-dshlg-region="right"][data-dshlg-closed] { box-shadow: none !important; border: 0 !important; } /* 玻璃预算:超出的面不吃 backdrop-filter */ #root [data-dshlg-glass-over] { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; } /* 官方品牌节点被替换时隐藏(display:none 比 visibility 干净,不占位) */ #root .dshlg-brand [data-dshlg-hidden] { display: none !important; } /* 自定义品牌文字:深蓝宝石渐变(文字渐变不受 SVG 底板问题影响)。 注意特异性 (1,2,0) 高于上面那条清底的 (1,1,0),所以这里的渐变不会被清掉。 */ /* ══ 品牌文字:宝石质感 ══════════════════════════════════════ 两层背景(先写的在上层): ① 窄白高光带 —— 扫过字面时就是「布灵」的那一下 ② 切面渐变 —— 铺满文字,让每个字都有明暗过渡 配色按 html[data-dshlg-gem] 切换;默认 ice(整体提亮,暗色壁纸上清楚)。 */ /* 预设一:冰钻白蓝 —— 整体亮,暗色壁纸上最清楚(默认) */ /* 预设二:香槟金 —— 最像珠宝,冷暖壁纸都压得住 */ /* 预设三:青玉 —— 冷色系,偏翡翠 */ /* 放大 + 居中:字更大,并在品牌行内水平居中 */ /* 承载容器也要能居中(DSH 的品牌按钮默认左对齐) */ /* 鲸鱼/鱼标:彻底不显示(用户要求去掉)。直接按稳定类名后缀隐藏 —— 不依赖 JS 打的 data 属性,避免「标记没打上就不生效」。 */ #root .dshlg-brand [class*="_brandMark"] { display: none !important; } #root .dshlg-brand [class*="_brand"] { display: flex !important; justify-content: flex-start !important; /* 文字靠左 */ align-items: center !important; width: 100% !important; } #root .dshlg-brand .dshlg-brand-label { /* ══ 宝石质感(两层背景,先写的在上层)══════════════════════ ① 窄白高光带 —— 260% 宽、左右扫动,扫过字面时就是「布灵」那一下 ② 切面渐变 —— 铺满文字,每个字都有明暗过渡 两层都被 background-clip:text 裁进文字,只作用在字上。 配色用**具体值**写,不用 CSS 变量:变量一旦在计算值阶段无效, 整条 background-image 会退化成 none(实测踩过)。 */ background-image: linear-gradient(100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0) 41%, rgba(255, 255, 255, 0.98) 50%, rgba(255, 255, 255, 0) 59%, rgba(255, 255, 255, 0) 100%), linear-gradient(165deg, #ffffff 0%, #e0f2fe 16%, #bae6fd 32%, #7dd3fc 48%, #a5f3fc 64%, #e0f2fe 80%, #ffffff 100%) !important; background-size: 260% 100%, 100% 100% !important; background-position: -70% 50%, 0 0 !important; background-repeat: no-repeat, no-repeat !important; background-color: transparent !important; -webkit-background-clip: text !important; background-clip: text !important; -webkit-text-fill-color: transparent !important; color: #7dd3fc; /* 更大、更粗、居中 */ font: 800 23px/1.25 "Segoe UI", system-ui, "Microsoft YaHei", sans-serif !important; letter-spacing: .05em !important; white-space: nowrap; display: inline-flex; align-items: center; flex: 1 1 auto; width: 100%; padding-left: 2px; justify-content: flex-start; text-align: left; /* 外侧深投影(浅壁纸上读得出)+ 内侧蓝光(暗壁纸上发光) */ filter: drop-shadow(0 1px 1px rgba(8, 20, 60, 0.6)) drop-shadow(0 2px 6px rgba(8, 20, 60, 0.5)) drop-shadow(0 0 12px rgba(186, 230, 253, 0.75)); animation: dshlg-gem-shimmer 5.5s linear infinite; } /* 承载容器居中(DSH 的品牌按钮默认左对齐) */ #root .dshlg-brand [class*="_brand"] { display: flex !important; justify-content: center !important; width: 100% !important; } /* 预设二:香槟金 */ html[data-dshlg-gem='gold'] #root .dshlg-brand .dshlg-brand-label { background-image: linear-gradient(100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0) 41%, rgba(255, 255, 255, 0.98) 50%, rgba(255, 255, 255, 0) 59%, rgba(255, 255, 255, 0) 100%), linear-gradient(165deg, #fffdf5 0%, #fef3c7 18%, #fcd34d 36%, #f59e0b 54%, #fbbf24 70%, #fef9c3 86%, #ffffff 100%) !important; } /* 预设三:青玉 */ html[data-dshlg-gem='aqua'] #root .dshlg-brand .dshlg-brand-label { background-image: linear-gradient(100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0) 41%, rgba(255, 255, 255, 0.98) 50%, rgba(255, 255, 255, 0) 59%, rgba(255, 255, 255, 0) 100%), linear-gradient(165deg, #ffffff 0%, #ccfbf1 18%, #5eead4 36%, #2dd4bf 54%, #14b8a6 70%, #99f6e4 86%, #ffffff 100%) !important; } @keyframes dshlg-gem-shimmer { 0% { background-position: -70% 50%, 0 0; } 100% { background-position: 170% 50%, 0 0; } } /* 尊重「减少动态效果」:保留静态切面,不流光 */ @media (prefers-reduced-motion: reduce) { /* 品牌流光必须在这里关掉 —— 之前是个空规则体,等于没生效。 */ #root .dshlg-brand .dshlg-brand-label { animation: none !important; background-position: -20% 50%, 0 0 !important; } } /* ══ 品牌区内**任何**后代都不许有自己的底 ══════════════════════ 用户反馈:「harness 背后是一个蓝块」。这块底不是字标自带的(官方 BrandWordmark 是 7 个 fill=currentColor 的 path),而是某个包裹元素/伪元素照样带着背景。 与其逐个猜是哪个元素,这里一次性立规矩:品牌区内所有后代一律无底、无描边、 无阴影,伪元素不产生内容 —— 唯一允许的底是品牌行自己的 ::before 玻璃。 需要保留的交互反馈(悬停)由下面单独一条补回来。 */ #root .dshlg-brand *, #root .dshlg-brand *::before, #root .dshlg-brand *::after { background: none !important; background-color: transparent !important; background-image: none !important; border-color: transparent !important; box-shadow: none !important; } #root .dshlg-brand *::before, #root .dshlg-brand *::after { content: none !important; } /* 补两个「不是 background 也能画出底」的属性: border-image 会绕过 border-color: transparent 继续画; background-clip 也可能把渐变裁成色块。 */ #root .dshlg-brand *, #root .dshlg-brand *::before, #root .dshlg-brand *::after { border-image: none !important; background-clip: border-box !important; -webkit-background-clip: border-box !important; mix-blend-mode: normal !important; } #root .dshlg-brand [data-dshlg-flat]:hover { background-color: rgba(255, 255, 255, 0.08) !important; border-radius: 10px !important; } /* 品牌区内的任何「行级玻璃」一律透明 —— 品牌按钮不该有自己的底。 */ #root .dshlg-brand .dshlg-bar, #root .dshlg-brand [data-dshlg-flat] { background: none !important; background-color: transparent !important; background-image: none !important; border: 0 !important; box-shadow: none !important; } /* 左侧栏「行级玻璃」:淡底 + 内高光 + 圆角,**不做模糊**(一列十几行太贵) */ #root .dshlg-bar { /* 0.05 → 0.028:侧栏整列已有一层 0.138,行上再叠 0.05 会让侧栏 整体比右栏「厚」—— 用户反馈「右栏更透明」就是这么来的。 */ background-color: transparent !important; background-image: none !important; border: 1px solid rgba(255, 255, 255, 0.06) !important; border-radius: 10px !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1) !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; } /* ══ 左上角品牌:蓝色液态玻璃 ══════════════════════════════════ ⚠️ 关键:玻璃画在 ::before 上,.dshlg-brand 本身**绝不碰 backdrop-filter**。 原因:DSH 在 Windows 下把「收起侧边栏」按钮放在 logoRow **内部**, 而它是 position:fixed。按 CSS 规范,backdrop-filter 取 none 以外的值时, 该元素会成为 fixed 后代的**包含块** —— 于是那个按钮不再相对视口定位, 而是相对这块玻璃定位,**正好压在 DeepSeek Harness 字上**(用户报的重叠)。 挪到伪元素后,行本身不产生包含块,按钮回到视口定位。 (注意:这段在模板字符串内部,注释里不能出现反引号。) */ #root .dshlg-brand { position: relative; box-sizing: border-box; isolation: isolate; background: none !important; border: 0 !important; box-shadow: none !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; margin: 2px 4px 6px !important; padding: 4px 8px !important; } #root .dshlg-brand::before { content: ''; position: absolute; inset: 0; z-index: -1; border-radius: 14px; background-color: rgba(var(--dshlg-brand-tone, 147, 197, 253), calc(var(--dshlg-brand-a, 0.42) * 0.62)); background-image: radial-gradient(120% 160% at 10% 0%, rgba(255, 255, 255, calc(var(--dshlg-brand-a, 0.42) * 0.75)) 0%, rgba(255, 255, 255, 0) 62%), linear-gradient(180deg, rgba(255, 255, 255, calc(var(--dshlg-brand-a, 0.42) * 0.5)) 0%, rgba(255, 255, 255, 0) 58%); border: 1px solid rgba(var(--dshlg-brand-tone, 147, 197, 253), calc(var(--dshlg-brand-a, 0.42) * 1.6)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, calc(var(--dshlg-topline, 0.3) * 1.5)), inset 0 0 12px rgba(var(--dshlg-brand-tone, 147, 197, 253), calc(var(--dshlg-brand-a, 0.42) * 0.8)), 0 4px 18px rgba(0, 0, 0, 0.2); backdrop-filter: var(--dshlg-brand-filter, none); -webkit-backdrop-filter: var(--dshlg-brand-filter, none); } /* 品牌文字:白 → 蓝 渐变填充。 只写 background-image —— background 简写会把背景色一起重置,渐变丢失、 文字退回灰白(实测踩过)。配色从纯白过渡到深蓝,保证在浅色玻璃上也读得出。 */ /* ══ 关键:官方字标是 SVG,不是文字 ══════════════════════════ DSH 的品牌名由 dsh-client-ui-brand-official 注册,渲染的是 BrandWordmark —— 一个 viewBox 为 26 0 156 24 的矢量字标(宽 = 高 × 6.5), 内部 fill="none"、path 各自带 fill。 所以:对 _brandName 做文字渐变填充**完全没有作用**(没有文本节点), 真机上看到的就是一块被填成白色的字标 —— 用户说的「白块,都看不出来」。 正确做法是直接改 path 的 fill。 */ /* 鱼标与字标:深蓝宝石质感。 fill 指向运行时注入的渐变(#dshlg-sapphire)—— SVG 形状不能直接吃 CSS 渐变, 但 fill: url(#id) 可以引用文档里的 。 再叠一层「顶部白色高光 + 底部深色投影」,做出宝石的立体感。 */ #root .dshlg-brand svg path, #root .dshlg-brand svg rect, #root .dshlg-brand svg circle, #root .dshlg-brand svg polygon { fill: url(#dshlg-sapphire) !important; color: rgb(var(--dshlg-brand-ink, 30, 64, 175)) !important; } #root .dshlg-brand svg { filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.65)) drop-shadow(0 2px 3px rgba(12, 30, 80, 0.45)); } /* 文字兜底:若某版本品牌名真的是文本(fallbackBrandName / localBuildTitle), 深蓝实色填充即可(不用渐变,避免又走回被覆盖的老路)。 */ #root .dshlg-brand [class*="_brandName"], #root .dshlg-brand [class*="_fallbackBrandName"], #root .dshlg-brand [class*="_localBuildTitle"], #root .dshlg-brand [class*="_buildVersion"] { background-image: none !important; background-color: transparent !important; -webkit-text-fill-color: rgb(var(--dshlg-brand-ink, 30, 64, 175)) !important; color: rgb(var(--dshlg-brand-ink, 30, 64, 175)) !important; font-weight: 600 !important; } /* 鱼标:递归上蓝(DSH 的 SVG 可能是 currentColor,也可能是写死的白) */ #root .dshlg-brand [class*="_brandMark"] svg, #root .dshlg-brand [class*="_brandMark"] svg *, #root .dshlg-brand [class*="_brandName"] svg, #root .dshlg-brand [class*="_brandName"] svg * { color: rgb(var(--dshlg-brand-ink, 30, 64, 175)) !important; /* 宝石渐变(而不是纯色)—— 规则顺序上这条比 svg path 那条更靠后、特异性更高, 所以渐变必须也写在这里,否则会被纯色盖掉。 */ fill: url(#dshlg-sapphire) !important; } #root .dshlg-brand [class*="_brandMark"] { filter: drop-shadow(0 1px 1px rgba(255, 255, 255, 0.6)); } /* ══ 边缘渐隐带(借鉴 Aqua)══════════════════════════════════ 顶/底各一条 13px 模糊带:聊天内容滚到边缘时渐入模糊, 但更高的玻璃面(侧栏 / 输入栏 / 控制条)不受影响,保持清晰。 */ #dshlg-fade-top, #dshlg-fade-bottom { position: fixed; left: 0; right: 0; height: 13px; z-index: 7; pointer-events: none; backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); /* 极淡的一层纱:太浓会在玻璃面下显出一道膜 */ background: rgba(255, 255, 255, 0.2); } body[data-ds-dark-theme] #dshlg-fade-top, body[data-ds-dark-theme] #dshlg-fade-bottom { background: rgba(0, 0, 0, 0.15); } #dshlg-fade-top { top: 0; -webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 100%); mask-image: linear-gradient(180deg, #000 0%, transparent 100%); } #dshlg-fade-bottom { bottom: 0; -webkit-mask-image: linear-gradient(0deg, #000 0%, transparent 100%); mask-image: linear-gradient(0deg, #000 0%, transparent 100%); } @media (prefers-reduced-transparency: reduce) { #dshlg-fade-top, #dshlg-fade-bottom { display: none; } } /* ══ 可读性:正文暗描影(只在亮壁纸上开)═══════════════════════ 全透明下文字边缘直接贴壁纸,亮壁纸会「糊边」。 这里用 1px 暗描影把字边缘压住 —— 比盖纱轻得多,且不改变玻璃观感。 只在实测壁纸偏亮时开(html[data-dshlg-bright-wall]),暗壁纸保持纯净。 选择器放宽到 #root 整体(原规则盯的 [data-conversation-region] 真机上 只出现在输入区,等于没命中)。 */ html[data-dshlg-bright-wall] #root, html[data-dshlg-bright-wall] #root * { text-shadow: 0 0 1px rgba(0, 0, 0, 0.55), 0 1px 2px rgba(0, 0, 0, 0.35) !important; } /* 自制界面不跟着加(它们自带底色与描边)。 ⚠️ 但设置面板 / 控制中心现在是**深底**:亮壁纸下它们要的是暗描影(把白字 压住),不是 none —— 正确值在它们自己那条 data-dshlg-bright-wall 规则里。 */ html[data-dshlg-bright-wall] #dshlg-controls, html[data-dshlg-bright-wall] #dshlg-controls * { text-shadow: none !important; } /* ══ 弹窗/设置面板必须是**可读卡片**,不能全透明 ══════════════════ 结构性窗格(左右侧栏、输入栏)走全透明是设计定调; 但弹窗、菜单、设置对话框是「浮在内容之上的可读面」—— 全透明会让它和背后的工作区文字叠在一起,两边都读不了。 这里给它们一层真正的板(不是玻璃纱),保证字读得出。 */ #root [role='dialog'], #root [role='dialog'] *, #root [data-menu-material] { --dshlg-plate: 1; } #root [role='dialog'] { background-color: rgba(var(--dshlg-tone, 255, 255, 255), 0.88) !important; background-image: none !important; backdrop-filter: var(--dshlg-filter-plate, blur(18px) saturate(1.5)) !important; -webkit-backdrop-filter: var(--dshlg-filter-plate, blur(18px) saturate(1.5)) !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 24px 64px rgba(0, 0, 0, 0.42) !important; border: 1px solid rgba(255, 255, 255, 0.28) !important; } /* 深色主题下用深板,否则白板刺眼 */ body[data-ds-dark-theme] #root [role='dialog'] { background-color: rgba(24, 28, 38, 0.9) !important; border-color: rgba(255, 255, 255, 0.14) !important; } /* 厚底上的字必须是深色(深色主题下保持浅色)——底与字绑成一个决策 */ #root [role='dialog'], #root [role='dialog'] * { color: #16233a !important; } body[data-ds-dark-theme] #root [role='dialog'], body[data-ds-dark-theme] #root [role='dialog'] * { color: #eaf0fa !important; } /* 菜单/popover:与面板同一块深色玻璃(2026-09-27 用户定稿)。 ⚠️ 这条比 buildOverlayCss 里那条**特异性更高**(多了 #root),真正生效的是这里。 两处必须一起改 —— 只改一处会得到「改了没反应」的假象 (本项目的老坑:行内变量与特异性都会悄悄压过你以为生效的那条)。 */ #root [data-menu-material][data-menu-material][data-menu-material][data-menu-material] { /* 真正的底色在内部 .material 层(z-index:-1,用 --dsw-menu-surface-fill)—— 见上面 buildOverlayCss 里的详细说明;这里同样必须改令牌。 */ --dsw-menu-surface-fill: rgba(10, 14, 20, 0.82) !important; background-color: rgba(10, 14, 20, 0.82) !important; background-image: none !important; color: #eaf2ff !important; } /* 浅字只给「不带危险色」的项(保住「退出登录」这类红项) */ #root [data-menu-material] :not([class*='danger']):not([class*='Danger']) { color: #eaf2ff !important; } body[data-ds-dark-theme] #root [data-menu-material][data-menu-material][data-menu-material][data-menu-material] { background-color: rgba(8, 11, 17, 0.86) !important; } /* ⚠️ 上面属性为什么写四遍:动态样式里有一条 「#root, #root *:not([data-dshlg-keep]):not([data-dshlg]):not([data-dshlg-region]) { background-color: transparent !important }」,特异性高达 **(0,4,0)**, 会把 #root 里任何元素的底色清成透明。 真机那个账号菜单是 **portal 到 body** 的(不在 #root 里),所以它躲过了那条; 但万一某个版本把菜单放进 #root,普通写法就会被清掉 → 这里靠重复属性把 特异性抬到 (0,5,0) 压过它。重复四次是 CSS 里最省事的「提权」写法, 不是手滑,别删。(这类「改了没反应」的坑本项目踩过:行内变量、特异性。) */ /* ══ DSH 自带设置面板(SettingsShell 覆盖层)必须不透明 ══════════════ 真身:_overlay{position:fixed;inset:0;background:var(--dsw-alias-bg-base)} 本插件把 --dsw-alias-bg-base 改成透明(为了让壁纸透出), 这个全屏覆盖层就跟着变透明 → 面板文字与工作区文字叠在一起看不清。 按**稳定语义后缀**命中(DSH 的类名是模块哈希 + 语义后缀,后缀稳定)。 ⚠️ 2026-09-27 用户改主意了:要「深色半透明」(照图一那张任务卡), 不要白色实板(原话:图四「太实了」)。所以这里换成**深色半透明**。 随之而来的一条:字色不能再交给 DSH 令牌 —— 用户是浅色主题,DSH 给的是 深字,压在深底上就看不见了。改成在**面板**上强制浅字,见下面 _panel 段。 真身结构(从 DSH 自己的 app.asar 里挖出来的 CSS,不是猜的): .y7bFDa_overlay{position:fixed;inset:0;display:flex;justify-content:center;align-items:center} .y7bFDa_mask{background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur);position:absolute;inset:0} .y7bFDa_panel{z-index:1;width:800px} → 全屏那层白纱来自 **overlay 自己的 background**,而 panel 是 overlay 的**子元素**。 */ [class*='_overlay'], [class*='_scrim'] { background-color: var(--dshlg-plate-bg, rgba(9, 12, 18, 0.6)) !important; background-image: none !important; } body[data-ds-dark-theme] [class*='_overlay'], body[data-ds-dark-theme] [class*='_scrim'] { background-color: var(--dshlg-plate-bg-dark, rgba(8, 11, 17, 0.68)) !important; } /* 覆盖层里通常还有一层内层「面板」,一并给底,避免只有边缘实、内容区透 */ [class*='_overlay'] [class*='_panel'], [class*='_overlay'] [class*='_card'], [class*='_overlay'] [class*='_content'], [class*='_overlay'] [class*='_body'], [class*='_overlay'] [class*='_nav'], [class*='_overlay'] [class*='_sidebar'], [class*='_overlay'] [class*='_list'] { background-color: transparent !important; background-image: none !important; } /* ══ DSH 自带设置浮层的**面板本体**:也给同一块深色玻璃(2026-09-27 定稿)══ 用户要求「图四那张设置卡也按图一的样式」。它原来是一块**浅色实板**, 压在深色壁纸上很跳。 真身类名(从 DSH 自己的 app.asar 里挖出来的,不是猜的): y7bFDa_overlay(全屏层)+ y7bFDa_panel(卡片)+ _header / _nav / _content / _close —— 哈希会随 DSH 版本变,所以只认**语义后缀**(本项目一贯做法)。 ⚠️ 只动 _panel 那一层(卡片本体),**不动** _overlay(全屏那层): [class*='_overlay'] 这个选择器很宽,DSH 别的浮层(如 vPH_UG_overlay)也会命中。 底与字绑成一个决策:底深了字必须跟着浅,否则就是深底压深字。 ⚠️ 若 DSH 自己的**填充型**小控件(自带浅底的按钮)出现「白底白字」, 给它单独加一条 color 覆盖即可 —— 目前已核过 _header/_nav/_content/_close, 没有自带底的类型。 */ [class*='_overlay'] [class*='_panel'] { background-color: rgba(10, 14, 20, 0.82) !important; border: 1px solid rgba(147, 197, 253, 0.45) !important; border-radius: 16px !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 12px 34px rgba(0, 0, 0, 0.42) !important; } [class*='_overlay'] [class*='_panel'], [class*='_overlay'] [class*='_panel'] * { color: #eaf2ff !important; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); } /* ⚠️ 上面那条会把 DSH **自带浅底**的按钮也刷成白字 → 白底白字(实测踩到: 「打开配置文件」「查询用量」两个浅底按钮看不见字)。 这里把面板里的按钮/输入框统一成插件自己那一套(半透明白底 + 浅描边 + 白字), 既不会白底白字,观感也和插件面板一致 —— 顺带把 DSH 的主/次按钮差异抹平, 这正是「几个面板看起来像同一块材料」需要的。 */ [class*='_overlay'] [class*='_panel'] button, [class*='_overlay'] [class*='_panel'] [role='button'] { background-color: rgba(255, 255, 255, 0.1) !important; background-image: none !important; border: 1px solid rgba(255, 255, 255, 0.22) !important; color: #eaf2ff !important; } [class*='_overlay'] [class*='_panel'] button:hover, [class*='_overlay'] [class*='_panel'] [role='button']:hover { background-color: rgba(255, 255, 255, 0.2) !important; } [class*='_overlay'] [class*='_panel'] input, [class*='_overlay'] [class*='_panel'] select, [class*='_overlay'] [class*='_panel'] textarea { background-color: rgba(255, 255, 255, 0.08) !important; border: 1px solid rgba(255, 255, 255, 0.22) !important; color: #eaf2ff !important; } /* 自制界面不受影响(它们自带底色与描边) */ #dshlg-settings, #dshlg-controls, #dshlg-gallery, #dshlg-gear, #dshlg-restore { --dshlg-plate-bg: unset; } /* ══ 可读性:正文 1px 描影(借鉴 Aqua)════════════════════════ 全透明背景下文字边缘直接贴着壁纸,容易糊边。 1px 描影把字边缘的背景压住一点,是最省事的可读性提升。 */ #root [data-conversation-scroll], #root [data-conversation-region] { text-shadow: 0 0 1px rgba(0, 0, 0, 0.4); } body:not([data-ds-dark-theme]) #root [data-conversation-scroll], body:not([data-ds-dark-theme]) #root [data-conversation-region] { text-shadow: 0 0 1px rgba(255, 255, 255, 0.55), 0 1px 2px rgba(19, 45, 83, 0.08); } /* ══ 无障碍:焦点环 + 选区色(借鉴 Aqua)══════════════════════ 半透明界面上默认焦点环对比不足,这两条成本极低但影响可用性。 */ #root :focus-visible { outline: 2px solid rgba(110, 155, 232, 0.85); outline-offset: 1px; } #root ::selection { background: rgba(110, 155, 232, 0.35); } /* ══ 选中会话:左侧强调条 + 柔光(借鉴 Aqua)═════════════════ */ #root [role='treeitem'][aria-selected='true'] { box-shadow: inset 2px 0 0 var(--dsw-specific-sidebar-nav-item-active-accent, rgba(110, 155, 232, 0.9)), 0 0 16px rgba(110, 155, 232, 0.14); } /* ══ 关键防护(Aqua 源码里明确记录过的坑)═════════════════════ 侧栏内出现固定定位弹层时,侧栏不能带 backdrop-filter: 否则该弹层会被重新锚定进侧栏(Aqua 实测弹窗宽度被压到 254px)。 这与本项目踩到的「包含块」问题同源,故再兜一道, 防止将来把侧栏的 frost 调回非零时复发。 */ #root [data-dshlg-region="side"]:has([role='dialog']), #root .dshlg-brand:has([role='dialog']) { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; } /* ══ 品牌里那个「白块 / 蓝块」的真身 ══════════════════════════ DSH 把整组标识(鱼标 + 字标)包在一个按钮里(_brand), 这个按钮自带底色 —— 用户看到的「白块」就是它。 ⚠️ 不要给它画任何底(无论是白、透明还是蓝胶囊): · 画成透明 → 字标 currentColor 是白色,白字在浅底上看不见 · 画成蓝色胶囊 → 字标改深蓝后又变成「深蓝字在蓝块上」,一样看不见 正解:让它彻底无底、无描边、无阴影,字标直接落在品牌行的浅蓝玻璃上。 也**不能**在这里强制子 svg 变白 —— 那会和品牌墨色规则打架。 */ #root [data-dshlg-flat] { background: none !important; background-color: transparent !important; background-image: none !important; border: 0 !important; box-shadow: none !important; outline: none !important; } #root [data-dshlg-flat]:hover { background-color: rgba(255, 255, 255, 0.07) !important; border-radius: 10px !important; } `; /** * 找一个「脚本自己的基准 URL」。 * * 为什么不能用 import.meta.url:客户端半体是被 DSH 的加载器当作**普通 * 函数体**执行的(`window.__ModuleLoader__.load({factory})`),不是 ESM 模块, * 所以 `import.meta` 根本不存在。 * 也不能只靠 document.baseURI:那是页面地址(dsh-app://app/), * 插件文件却在 node_modules 下,拼出来必然 404。 * * 可靠办法是从