@mzzsfy/dsh-settings-nav-icons 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -9
- package/package.json +1 -1
- package/src/client.js +379 -16
- package/src/logic.mjs +83 -7
- package/test/orchestration.test.mjs +205 -17
- package/test/parity.test.mjs +60 -5
package/README.md
CHANGED
|
@@ -6,26 +6,39 @@ DeepSeek Harness 纯前端插件:设置弹窗左侧导航与 dsh-market 插件
|
|
|
6
6
|
|
|
7
7
|
| 场景 | 行为 |
|
|
8
8
|
|---|---|
|
|
9
|
-
| 分区当前是官方齿轮 | 改写官方 svg 内部内容为专属图形(声明 → 内置映射 → 关键词 → 哈希) |
|
|
9
|
+
| 分区当前是官方齿轮 | 改写官方 svg 内部内容为专属图形(用户覆盖 → 声明 → 内置映射 → 关键词 → 哈希) |
|
|
10
10
|
| 分区当前非齿轮(官方原生图标 / 第三方插件供给的图标) | 不干预,映射或声明命中也不例外 |
|
|
11
|
+
| 右键导航分区 | 打开图标编辑浮层:保存/清除用户覆盖(localStorage 持久),非法值行内提示 |
|
|
11
12
|
| 左侧导航分区溢出(弹窗高度不足) | 列表区域滚动,标题固定;官方无滚动机制,溢出被弹窗裁剪致底部分区不可达 |
|
|
12
13
|
| 市场卡片头像槽(有名称锚) | 按插件名取图替换作者头像/字母色块 |
|
|
13
|
-
| 语言切换导致 label 变化 | 已改写的分区按新 label
|
|
14
|
+
| 语言切换导致 label 变化 | 已改写的分区按新 label 重新取图改写;双语分区覆盖/声明按等价组跨语言命中 |
|
|
14
15
|
| 面板关闭后重新打开 / 会话切换 | 新渲染的齿轮自动再改写 |
|
|
15
16
|
| 声明变更(注册新值/撤销) | 受影响分区 svg 内容就地重写,头像槽重新取图 |
|
|
16
17
|
| 插件卸载 | 观察器随 `ctx.effect` 销毁,重载页面即恢复官方默认 |
|
|
17
18
|
|
|
18
19
|
改写采用**内容制**:直接替换官方齿轮 svg 的内部内容,不新建节点、不动属性——节点、class、DOM 位置全部保持官方原样,其他插件针对官方图标的 CSS(如按 `> svg:first-child` 隐藏、伪元素 mask 叠加)照常作用于改写结果,不会出现双图标。
|
|
19
20
|
|
|
21
|
+
## 用户自定义覆盖
|
|
22
|
+
|
|
23
|
+
右键点击设置弹窗左侧导航任意分区,弹出图标编辑浮层:
|
|
24
|
+
|
|
25
|
+
- 输入内置 glyph 名(见下表)或完整 16×16 `<svg>` 字符串,「保存」即时生效并写入 localStorage(`__navicUserIcons`),刷新页面后仍然生效
|
|
26
|
+
- 「清除」删除该分区覆盖,回到默认取图管线;Esc 或点击浮层外部关闭
|
|
27
|
+
- 浮层展示当前取图来源(用户覆盖 / 插件声明 / 内置映射 / 自动推导)与当前图标预览
|
|
28
|
+
- 覆盖值与声明走同一安全门(未知 glyph 拒绝、svg 过闸),存储值被篡改时该键跳过,不直通注入
|
|
29
|
+
- 官方双语分区(通用设置 / General)的覆盖与声明按等价组生效:写在任一语言键,两种语言下都命中;保存/清除/重写均按组内全键触达,两侧同步
|
|
30
|
+
|
|
31
|
+
插件卸载后浮层与样式随生命周期销毁;localStorage 中的覆盖数据不影响官方 UI,重装即恢复。
|
|
32
|
+
|
|
20
33
|
## 取图优先级
|
|
21
34
|
|
|
22
35
|
```
|
|
23
|
-
外部声明(register) → 内置映射(ICONS) → 名称关键词(NAME_RULES) → 稳定哈希备用池(FALLBACK)
|
|
36
|
+
用户覆盖(localStorage) → 外部声明(register) → 内置映射(ICONS) → 名称关键词(NAME_RULES) → 稳定哈希备用池(FALLBACK)
|
|
24
37
|
```
|
|
25
38
|
|
|
26
39
|
- **外部声明**:其他插件运行时注册自己的 label/插件名 → 图标,优先级最高(见下节)。
|
|
27
40
|
- **内置映射**:官方分区与无法改源的第三方分区。
|
|
28
|
-
- **关键词**:插件名/分区名整词命中语义关键词得主题图(git→分支、im→机器人、search→放大镜、usage
|
|
41
|
+
- **关键词**:插件名/分区名整词命中语义关键词得主题图(git→分支、im→机器人、search→放大镜、usage→图表、wallet→钱包、dash→表盘等 21 组规则);整词边界匹配,`im` 不误伤 `important`。
|
|
29
42
|
- **备用池**:星芒/层叠/标签/网格四个中性图形,按名称稳定哈希取一个——同一名称永远同一图形,重渲染不闪动。dsh-market 目录内全部插件(含下载量前 1000)至此都有图标。
|
|
30
43
|
|
|
31
44
|
## 图标声明机制(其他插件接入)
|
|
@@ -40,13 +53,13 @@ window.__navicIcons.register({ '消息通知': 'bell' })
|
|
|
40
53
|
;(window.__navicIconQueue ??= []).push({ '消息通知': 'bell' })
|
|
41
54
|
```
|
|
42
55
|
|
|
43
|
-
- 键:分区显示文本(设置导航)或插件名(dsh-market 卡片)
|
|
44
|
-
- 值:内置 glyph 名(`tune/theme/bot/market/cube/mcp/shield/cards/plan/bell/wrench/archive/spark/layers/tag/grid/git/search/term/chart/code/doc/db/flow/globe/lock/image/zap`)或完整 16×16 `<svg>` 字符串。svg 字符串过安全门:完整开标签(大小写不敏感,拒绝 `<svgx` 残串)且单根闭合(首个 `</svg>` 后不得再有内容,堵尾缀活动 HTML);不带 `on*` 事件属性(`\b` 前界堵斜杠分隔绕过)、不带 `<script>`/`<style>`(内联样式全文档生效且 @import 可外联)/`<foreignObject>`/SMIL 动画(`<animate>`/`<set>` 等)载体;不带 `href`/`xlink:href` 及 `attributeName="href"` 注入(16×16 静态图标无合法引用/动画场景,外联请求一并封死);不带 `javascript:`(纵深);长度 ≤4096 字符;glyph 名查表经 `typeof` 收口,原型链成员不可能被注入。
|
|
56
|
+
- 键:分区显示文本(设置导航)或插件名(dsh-market 卡片)。两域共用一张表,同名时以先命中者生效,键请取不易与官方分区撞名的插件名。官方双语分区按等价组解析:任一语言 label 作键,两种语言下均命中(消除语言切换的声明键漂移)。
|
|
57
|
+
- 值:内置 glyph 名(`tune/theme/bot/market/cube/mcp/shield/cards/plan/bell/wrench/archive/spark/layers/tag/grid/git/search/term/chart/code/doc/db/flow/globe/lock/image/zap/wallet/gauge`)或完整 16×16 `<svg>` 字符串。svg 字符串过安全门:完整开标签(大小写不敏感,拒绝 `<svgx` 残串)且单根闭合(首个 `</svg>` 后不得再有内容,堵尾缀活动 HTML);不带 `on*` 事件属性(`\b` 前界堵斜杠分隔绕过)、不带 `<script>`/`<style>`(内联样式全文档生效且 @import 可外联)/`<foreignObject>`/SMIL 动画(`<animate>`/`<set>` 等)载体;不带 `href`/`xlink:href` 及 `attributeName="href"` 注入(16×16 静态图标无合法引用/动画场景,外联请求一并封死);不带 `javascript:`(纵深);长度 ≤4096 字符;glyph 名查表经 `typeof` 收口,原型链成员不可能被注入。
|
|
45
58
|
- 声明值经归一化后写入注册表;同值重复注册幂等短路;非法值(未知 glyph/被安全门拒绝/非字符串)撤销该键声明,该分区回到内置映射或关键词/哈希默认管线,已改写的 nav svg 内容就地重写。
|
|
46
59
|
- 声明持久化在 `window.__navicIconDeclarations`:本插件 client 半区热重载会重跑工厂而生产者不重发注册,持久层让重装实例恢复声明,页面刷新随 window 释放。
|
|
47
60
|
- 污染面收敛在 `window.__navicIcons` 单一命名空间,插件卸载时移除 API、取消已排定的重绘、队列恢复数组形态——卸载后生产者按上方「方式二」入队等待下一实例,不再驱动 DOM 改写;重载页面后全部还原为官方图标。
|
|
48
61
|
|
|
49
|
-
|
|
62
|
+
本仓库全部插件包(含本包自画像与仅 host 半区的包)都走此机制显式声明,不依赖关键词/哈希推导;键为「分区 label + 市场短名」双形态:usage-panel(账号余额、dsh-usage-panel → wallet)、usage-dash(使用统计/Usage、dsh-usage-dash → gauge)、turn-notify(消息通知、dsh-turn-notify → bell)、maintain(版本与运维、dsh-maintain → wrench)、session-manager(会话归档、dsh-session-manager → archive)、rs-workflow(若水工作流、dsh-rs-workflow → flow)、cron-board(dsh-cron-board → plan)、think-expand(dsh-think-expand → spark)、model-capability-editor(dsh-model-capability-editor → cube)、auto-trust-all(dsh-auto-trust-all → shield)、llm-pi-gateway(dsh-llm-pi-gateway → globe)、settings-nav-icons(dsh-settings-nav-icons → tune);仅 host 半区的 auto-trust-all 与 llm-pi-gateway 为声明专设最小 client 半区。注册样板由本包测试做契约锁定。
|
|
50
63
|
|
|
51
64
|
## 内置映射表
|
|
52
65
|
|
|
@@ -87,7 +100,7 @@ node scripts/dev-link.mjs dsh-settings-nav-icons
|
|
|
87
100
|
|
|
88
101
|
前提:profile `package.json` 的 dependencies 有本包 semver 行、`dsh.profile.bundles` 有本包名(未发布包 registry 拉取会失败,junction 覆盖 node_modules 物理目录后启动只走 realpath)。junction 会被 `pnpm install` / `dsh plugin add` 抹掉,之后重跑本脚本即可。
|
|
89
102
|
|
|
90
|
-
重启 dsh
|
|
103
|
+
重启 dsh 后设置面板即生效;设置项即「用户自定义覆盖」的右键浮层,无独立设置页。
|
|
91
104
|
|
|
92
105
|
## 升级 / 卸载
|
|
93
106
|
|
|
@@ -106,7 +119,7 @@ pnpm --dir packages/dsh-settings-nav-icons test
|
|
|
106
119
|
node --test packages/dsh-settings-nav-icons/test/*.test.mjs
|
|
107
120
|
```
|
|
108
121
|
|
|
109
|
-
覆盖:上表全部行为场景(齿轮强补 / 非齿轮不动 / svg 记账幂等与 label 重写 / trim 与空 label / 无 svg 降级 / 内容改写与不建节点 / 0.1.x 残留清理 / 声明安全门 / 撤销回退取图链 / 关键词边界 / 头像槽 img+div / 导航滚动样式注入与卸载)、映射表契约、双实现全量同源 parity、client.js 注册 id 守卫、编排层契约(注册校验 / 就地重写 / 队列三态 / 生命周期 / 异常隔离 / 持久层恢复)、四生产者样板契约。
|
|
122
|
+
覆盖:上表全部行为场景(齿轮强补 / 非齿轮不动 / svg 记账幂等与 label 重写 / trim 与空 label / 无 svg 降级 / 内容改写与不建节点 / 0.1.x 残留清理 / 声明安全门 / 撤销回退取图链 / 关键词边界 / 头像槽 img+div / 导航滚动样式注入与卸载)、映射表契约、双实现全量同源 parity、client.js 注册 id 守卫、编排层契约(注册校验 / 就地重写 / 队列三态 / 生命周期 / 异常隔离 / 持久层恢复)、用户覆盖(localStorage 恢复安全门 / 右键浮层保存与非法行内提示 / 清除回默认管线 / 等价组双语触达 / 浮层开闭)、别名等价查询(lookupWithAlias / aliasKeysOf / iconSourceOf)、四生产者样板契约。
|
|
110
123
|
|
|
111
124
|
## License
|
|
112
125
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
// 背景见 README「实现边界」:settings.* 子槽声明权被原版条目占用,
|
|
4
4
|
// 接管 Shell 不可行,DOM 观察是唯一不依赖官方契约变更的路径。
|
|
5
5
|
|
|
6
|
+
// 本插件市场短名自画像:经同一套声明机制注册(先于 load,渲染端同文件就绪)
|
|
7
|
+
if (typeof window !== 'undefined') {
|
|
8
|
+
const NAV_ICON = { 'dsh-settings-nav-icons': 'tune' }
|
|
9
|
+
if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
|
|
10
|
+
else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
|
|
11
|
+
else window.__navicIconQueue = [NAV_ICON]
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
window.__ModuleLoader__.load({
|
|
7
15
|
id: '@mzzsfy/dsh-settings-nav-icons',
|
|
8
16
|
factory(require) {
|
|
@@ -12,6 +20,266 @@ window.__ModuleLoader__.load({
|
|
|
12
20
|
const SELECTOR_LABEL = '.VOzbGW_navLabel'
|
|
13
21
|
const ATTR_MARK = 'data-navic'
|
|
14
22
|
|
|
23
|
+
// 恢复路径内容刷新:页面刷新后 DOM 全新无此需求;HMR 热重载复用旧 DOM,
|
|
24
|
+
// 记账与 label 相同会被 replacePass 幂等跳过,恢复的声明/覆盖必须就地重算。
|
|
25
|
+
function refreshMarkedContent() {
|
|
26
|
+
const keys = []
|
|
27
|
+
for (const el of document.querySelectorAll('[' + ATTR_MARK + ']')) {
|
|
28
|
+
const key = el.dataset.navic
|
|
29
|
+
if (key !== undefined && key !== '' && key !== '1' && keys.indexOf(key) < 0) keys.push(key)
|
|
30
|
+
}
|
|
31
|
+
if (keys.length === 0) return
|
|
32
|
+
rewriteTouched(keys)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---- 用户逐分区覆盖:localStorage 持久层 + 右键浮层 UI ----
|
|
36
|
+
|
|
37
|
+
// 覆盖持久化跨会话存活(localStorage),与声明持久层(window 属性,页面级)
|
|
38
|
+
// 的差异:声明服务于热重载恢复,覆盖服务于用户长期自定义。恢复走同一
|
|
39
|
+
// resolveIcon 安全门收口,被篡改的存储不直通注入。
|
|
40
|
+
const USER_STORE_KEY = '__navicUserIcons'
|
|
41
|
+
|
|
42
|
+
function restoreUserOverrides() {
|
|
43
|
+
let raw
|
|
44
|
+
try {
|
|
45
|
+
raw = window.localStorage.getItem(USER_STORE_KEY)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn('[nav-icons] 用户覆盖存储不可读,已跳过', error)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
if (typeof raw !== 'string' || raw === '') return
|
|
51
|
+
let saved
|
|
52
|
+
try {
|
|
53
|
+
saved = JSON.parse(raw)
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.warn('[nav-icons] 用户覆盖存储解析失败,已跳过', error)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (saved === null || typeof saved !== 'object' || Array.isArray(saved)) return
|
|
59
|
+
for (const key of Object.keys(saved)) {
|
|
60
|
+
if (key === '__proto__' || key === '1' || key.length > KEY_MAX_CHARS) continue
|
|
61
|
+
try {
|
|
62
|
+
if (resolveIcon(saved[key]) !== undefined) USER_OVERRIDES[key] = saved[key]
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.warn('[nav-icons] 用户覆盖键 ' + key + ' 恢复失败,已跳过', error)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function persistUserOverrides() {
|
|
70
|
+
try {
|
|
71
|
+
window.localStorage.setItem(USER_STORE_KEY, JSON.stringify(Object.assign({}, USER_OVERRIDES)))
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.warn('[nav-icons] 用户覆盖存储不可写', error)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 覆盖写入/清除:值 undefined 视为清除(等价组全键删除);非法值返回 false
|
|
78
|
+
// 交由浮层行内提示,不落库不重贴。变更后按等价组全键触达重写。
|
|
79
|
+
function setUserOverride(key, value) {
|
|
80
|
+
if (!active) return false
|
|
81
|
+
if (key === '' || key === '__proto__' || key === '1' || key.length > KEY_MAX_CHARS) return false
|
|
82
|
+
if (value === undefined || value === null) {
|
|
83
|
+
let cleared = false
|
|
84
|
+
for (const k of aliasKeysOf(key)) {
|
|
85
|
+
if (USER_OVERRIDES[k] !== undefined) {
|
|
86
|
+
delete USER_OVERRIDES[k]
|
|
87
|
+
cleared = true
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (cleared) {
|
|
91
|
+
persistUserOverrides()
|
|
92
|
+
rewriteTouched(aliasKeysOf(key))
|
|
93
|
+
schedule()
|
|
94
|
+
}
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
let resolved
|
|
98
|
+
try {
|
|
99
|
+
resolved = resolveIcon(value)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
if (resolved === undefined) return false
|
|
104
|
+
let changed = false
|
|
105
|
+
for (const k of aliasKeysOf(key)) {
|
|
106
|
+
if (k !== key && USER_OVERRIDES[k] !== undefined) {
|
|
107
|
+
delete USER_OVERRIDES[k]
|
|
108
|
+
changed = true
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// 存原始输入(与声明表同语义,glyph 名回填浮层可读),查询侧过闸展开
|
|
112
|
+
if (USER_OVERRIDES[key] !== value) {
|
|
113
|
+
USER_OVERRIDES[key] = value
|
|
114
|
+
changed = true
|
|
115
|
+
}
|
|
116
|
+
if (changed) {
|
|
117
|
+
persistUserOverrides()
|
|
118
|
+
rewriteTouched(aliasKeysOf(key))
|
|
119
|
+
schedule()
|
|
120
|
+
}
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 覆盖浮层:右键 nav 单元格打开,单例常驻 body,随插件 stop 移除。
|
|
125
|
+
// 类名全仓唯一前缀 sni-(dsh-settings-nav-icons 缩写),样式随启动注入。
|
|
126
|
+
const OVERLAY_CLASS = 'sni-ov'
|
|
127
|
+
const OVERLAY_CSS =
|
|
128
|
+
'.' + OVERLAY_CLASS + '{position:fixed;z-index:2147483647;width:264px;padding:10px;' +
|
|
129
|
+
'border-radius:8px;color-scheme:light dark;background:light-dark(#ffffff,#1f1f1f);' +
|
|
130
|
+
'color:light-dark(#111111,#eeeeee);box-shadow:0 4px 16px rgba(0,0,0,.28);' +
|
|
131
|
+
'font:12px/1.5 system-ui,sans-serif}' +
|
|
132
|
+
'.' + OVERLAY_CLASS + '__title{font-weight:600;margin-bottom:2px;word-break:break-all}' +
|
|
133
|
+
'.' + OVERLAY_CLASS + '__src{opacity:.65;margin-bottom:6px}' +
|
|
134
|
+
'.' + OVERLAY_CLASS + '__input{width:100%;box-sizing:border-box;margin-bottom:4px;' +
|
|
135
|
+
'padding:4px 6px;border:1px solid rgba(128,128,128,.5);border-radius:6px;' +
|
|
136
|
+
'background:transparent;color:inherit;font:inherit}' +
|
|
137
|
+
'.' + OVERLAY_CLASS + '__input:focus-visible{outline:2px solid #4d6bfe;outline-offset:-1px}' +
|
|
138
|
+
'.' + OVERLAY_CLASS + '__bad{display:none;color:#d5484c;margin-bottom:4px}' +
|
|
139
|
+
'.' + OVERLAY_CLASS + '__bad--on{display:block}' +
|
|
140
|
+
'.' + OVERLAY_CLASS + '__cur{display:flex;align-items:center;gap:6px;margin-bottom:8px;' +
|
|
141
|
+
'opacity:.9}' +
|
|
142
|
+
'.' + OVERLAY_CLASS + '__row{display:flex;gap:6px;justify-content:flex-end}' +
|
|
143
|
+
'.' + OVERLAY_CLASS + '__btn{padding:4px 12px;border:1px solid rgba(128,128,128,.5);' +
|
|
144
|
+
'border-radius:6px;background:transparent;color:inherit;font:inherit;cursor:pointer}' +
|
|
145
|
+
'.' + OVERLAY_CLASS + '__btn:hover{border-color:#4d6bfe}' +
|
|
146
|
+
'.' + OVERLAY_CLASS + '__btn:focus-visible{outline:2px solid #4d6bfe;outline-offset:1px}' +
|
|
147
|
+
'.' + OVERLAY_CLASS + '__btn--primary{background:#4d6bfe;border-color:#4d6bfe;color:#ffffff}'
|
|
148
|
+
|
|
149
|
+
const SOURCE_LABELS = {
|
|
150
|
+
user: '用户覆盖',
|
|
151
|
+
declared: '插件声明',
|
|
152
|
+
builtin: '内置映射',
|
|
153
|
+
derived: '自动推导',
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let overlayStyle = null
|
|
157
|
+
let overlay = null
|
|
158
|
+
let overlayCell = null
|
|
159
|
+
|
|
160
|
+
function removeOverlay() {
|
|
161
|
+
if (overlay === null) return
|
|
162
|
+
overlay.remove()
|
|
163
|
+
overlay = null
|
|
164
|
+
overlayCell = null
|
|
165
|
+
if (slot !== null) slot.overlayEl = null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 就地取当前覆盖原文作输入框回填:等价组任一侧键命中都回填。
|
|
169
|
+
function overlayInputValue(label) {
|
|
170
|
+
const hit = lookupWithAlias(USER_OVERRIDES, label)
|
|
171
|
+
return hit === undefined ? '' : hit
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function openOverlay(cell) {
|
|
175
|
+
const labelNode = cell.querySelector(SELECTOR_LABEL)
|
|
176
|
+
if (labelNode === null) return
|
|
177
|
+
const label = (labelNode.textContent || '').trim()
|
|
178
|
+
if (label === '') return
|
|
179
|
+
removeOverlay()
|
|
180
|
+
overlayCell = cell
|
|
181
|
+
overlay = document.createElement('div')
|
|
182
|
+
overlay.className = OVERLAY_CLASS
|
|
183
|
+
const title = document.createElement('div')
|
|
184
|
+
title.className = OVERLAY_CLASS + '__title'
|
|
185
|
+
title.textContent = label
|
|
186
|
+
const src = document.createElement('div')
|
|
187
|
+
src.className = OVERLAY_CLASS + '__src'
|
|
188
|
+
src.textContent = '当前来源:' + (SOURCE_LABELS[iconSourceOf(label)] || SOURCE_LABELS.derived)
|
|
189
|
+
const cur = document.createElement('span')
|
|
190
|
+
cur.className = OVERLAY_CLASS + '__cur'
|
|
191
|
+
cur.innerHTML = resolveForLabel(label)
|
|
192
|
+
cur.title = '当前图标'
|
|
193
|
+
const input = document.createElement('input')
|
|
194
|
+
input.className = OVERLAY_CLASS + '__input'
|
|
195
|
+
input.placeholder = 'glyph 名或完整 <svg>(留空不清除,用清除按钮)'
|
|
196
|
+
input.value = overlayInputValue(label)
|
|
197
|
+
const bad = document.createElement('div')
|
|
198
|
+
bad.className = OVERLAY_CLASS + '__bad'
|
|
199
|
+
bad.textContent = '非法值:须为内置 glyph 名或过安全门的 16×16 <svg>'
|
|
200
|
+
const row = document.createElement('div')
|
|
201
|
+
row.className = OVERLAY_CLASS + '__row'
|
|
202
|
+
const clearBtn = document.createElement('button')
|
|
203
|
+
clearBtn.className = OVERLAY_CLASS + '__btn'
|
|
204
|
+
clearBtn.textContent = '清除'
|
|
205
|
+
const saveBtn = document.createElement('button')
|
|
206
|
+
saveBtn.className = OVERLAY_CLASS + '__btn ' + OVERLAY_CLASS + '__btn--primary'
|
|
207
|
+
saveBtn.textContent = '保存'
|
|
208
|
+
row.appendChild(clearBtn)
|
|
209
|
+
row.appendChild(saveBtn)
|
|
210
|
+
overlay.appendChild(title)
|
|
211
|
+
overlay.appendChild(src)
|
|
212
|
+
overlay.appendChild(cur)
|
|
213
|
+
overlay.appendChild(input)
|
|
214
|
+
overlay.appendChild(bad)
|
|
215
|
+
overlay.appendChild(row)
|
|
216
|
+
document.body.appendChild(overlay)
|
|
217
|
+
if (slot !== null) slot.overlayEl = overlay
|
|
218
|
+
input.focus()
|
|
219
|
+
saveBtn.addEventListener('click', function () {
|
|
220
|
+
if (setUserOverride(label, input.value)) removeOverlay()
|
|
221
|
+
else bad.classList.add(OVERLAY_CLASS + '__bad--on')
|
|
222
|
+
})
|
|
223
|
+
clearBtn.addEventListener('click', function () {
|
|
224
|
+
setUserOverride(label, undefined)
|
|
225
|
+
removeOverlay()
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 文档级监听:右键开浮层、浮层外点击/Escape 关闭;start 挂载,stop 卸载。
|
|
230
|
+
let contextHandler = null
|
|
231
|
+
let pointerHandler = null
|
|
232
|
+
let keyHandler = null
|
|
233
|
+
|
|
234
|
+
function installOverlayListeners() {
|
|
235
|
+
contextHandler = function (event) {
|
|
236
|
+
const target = event.target
|
|
237
|
+
const cell = target != null && typeof target.closest === 'function'
|
|
238
|
+
? target.closest(SELECTOR_CELL)
|
|
239
|
+
: null
|
|
240
|
+
if (cell === null) return
|
|
241
|
+
event.preventDefault()
|
|
242
|
+
openOverlay(cell)
|
|
243
|
+
}
|
|
244
|
+
pointerHandler = function (event) {
|
|
245
|
+
if (overlay === null) return
|
|
246
|
+
const target = event.target
|
|
247
|
+
if (target != null && typeof target.closest === 'function'
|
|
248
|
+
&& target.closest('.' + OVERLAY_CLASS) !== null) return
|
|
249
|
+
removeOverlay()
|
|
250
|
+
}
|
|
251
|
+
keyHandler = function (event) {
|
|
252
|
+
if (overlay === null) return
|
|
253
|
+
if (event.key === 'Escape') removeOverlay()
|
|
254
|
+
}
|
|
255
|
+
document.addEventListener('contextmenu', contextHandler)
|
|
256
|
+
document.addEventListener('pointerdown', pointerHandler)
|
|
257
|
+
document.addEventListener('keydown', keyHandler)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function uninstallOverlayListeners() {
|
|
261
|
+
if (contextHandler !== null) document.removeEventListener('contextmenu', contextHandler)
|
|
262
|
+
if (pointerHandler !== null) document.removeEventListener('pointerdown', pointerHandler)
|
|
263
|
+
if (keyHandler !== null) document.removeEventListener('keydown', keyHandler)
|
|
264
|
+
contextHandler = null
|
|
265
|
+
pointerHandler = null
|
|
266
|
+
keyHandler = null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function installOverlayStyle() {
|
|
270
|
+
overlayStyle = document.createElement('style')
|
|
271
|
+
overlayStyle.textContent = OVERLAY_CSS
|
|
272
|
+
document.head.appendChild(overlayStyle)
|
|
273
|
+
if (slot !== null) slot.overlayStyle = overlayStyle
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function uninstallOverlayStyle() {
|
|
277
|
+
if (overlayStyle === null) return
|
|
278
|
+
overlayStyle.remove()
|
|
279
|
+
overlayStyle = null
|
|
280
|
+
if (slot !== null) slot.overlayStyle = null
|
|
281
|
+
}
|
|
282
|
+
|
|
15
283
|
// ---- 设置导航滚动补偿:官方 navList 高度随内容撑开,溢出被 panel hidden
|
|
16
284
|
// 裁剪且无滚动机制,分区一多底部即不可达。样式表形态一次注入,对弹窗重开
|
|
17
285
|
// 等任意 React 重渲染持续生效;类名锚定与上方单元格同策略(哈希前缀字面量)。
|
|
@@ -127,6 +395,13 @@ window.__ModuleLoader__.load({
|
|
|
127
395
|
rectOf('2.5', '3', '11', '10', '1.2') +
|
|
128
396
|
circleOf('5.9', '6.1', '1') + pathOf('m3.6 12 3-3 2.3 2.3 1.7-1.7 1.9 1.9'))
|
|
129
397
|
const ZAP = svgOf(pathOf('M8.9 1.8 3.8 8.9h3.5L7 14.2l5.2-7.1H8.6z'))
|
|
398
|
+
const WALLET = svgOf(
|
|
399
|
+
rectOf('2.5', '3.5', '11', '9.5', '1.5') +
|
|
400
|
+
pathOf('M13.5 7.4h-2.1a1.6 1.6 0 0 0 0 3.2h2.1'))
|
|
401
|
+
const GAUGE = svgOf(
|
|
402
|
+
pathOf('M2.5 12a5.5 5.5 0 1 1 11 0') +
|
|
403
|
+
pathOf('M8 12 10.8 9.2') +
|
|
404
|
+
pathOf('M2.5 12h11'))
|
|
130
405
|
const FALLBACK = [SPARK, LAYERS, TAG, GRID]
|
|
131
406
|
|
|
132
407
|
// 内置 glyph 名称表:声明值可用名称引用内置图形,避免跨插件复制 svg。
|
|
@@ -136,6 +411,7 @@ window.__ModuleLoader__.load({
|
|
|
136
411
|
archive: ARCHIVE, spark: SPARK, layers: LAYERS, tag: TAG, grid: GRID,
|
|
137
412
|
git: GIT, search: SEARCH, term: TERM, chart: CHART, code: CODE, doc: DOC,
|
|
138
413
|
db: DB, flow: FLOW, globe: GLOBE, lock: LOCK, image: IMAGE, zap: ZAP,
|
|
414
|
+
wallet: WALLET, gauge: GAUGE,
|
|
139
415
|
}
|
|
140
416
|
|
|
141
417
|
// 声明值解析与安全门:svg 须完整开标签(大小写不敏感)且单根闭合(首个 </svg>
|
|
@@ -177,7 +453,9 @@ window.__ModuleLoader__.load({
|
|
|
177
453
|
['doc|note|file|markdown|wiki', DOC],
|
|
178
454
|
['database|sqlite|redis|cache|db', DB],
|
|
179
455
|
['session|archive|history', ARCHIVE],
|
|
456
|
+
['dash|dashboard|gauge', GAUGE],
|
|
180
457
|
['usage|stat|meter|monitor|radar|metric', CHART],
|
|
458
|
+
['wallet|balance|money|coin|fund', WALLET],
|
|
181
459
|
['flow|workflow|pipeline|task|job|queue', FLOW],
|
|
182
460
|
['auth|login|pass|secret|token|lock|guard|crypt', SHIELD],
|
|
183
461
|
['mcp', MCP],
|
|
@@ -189,10 +467,59 @@ window.__ModuleLoader__.load({
|
|
|
189
467
|
['zap|flash|fast|quick|boost|speed|turbo', ZAP],
|
|
190
468
|
].map(function (pair) { return { re: new RegExp('(^|[^a-z])(' + pair[0] + ')([^a-z]|$)'), icon: pair[1] } })
|
|
191
469
|
|
|
192
|
-
//
|
|
470
|
+
// 用户逐分区覆盖注册表:取图链最前端,值经 resolveIcon 归一化,持久化由
|
|
471
|
+
// 编排层落 localStorage(纯逻辑层不触碰存储)。
|
|
472
|
+
const USER_OVERRIDES = Object.create(null)
|
|
473
|
+
|
|
474
|
+
// 官方双语分区等价组:组内任一语言 label 作声明/覆盖键,组内其他语言取图时
|
|
475
|
+
// 同样命中(消除语言切换下声明键漂移)。仅收官方现有展示形态,不发明未知 id。
|
|
476
|
+
const ALIAS_GROUPS = [
|
|
477
|
+
['通用设置', 'General'],
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
// 表查询的别名等价扩展:先按原键直查,再沿等价组用同组成员试查。
|
|
481
|
+
// 表为 Object.create(null) 帧装,继承成员不可达。
|
|
482
|
+
function lookupWithAlias(table, label) {
|
|
483
|
+
const direct = table[label]
|
|
484
|
+
if (direct !== undefined) return direct
|
|
485
|
+
for (const group of ALIAS_GROUPS) {
|
|
486
|
+
if (group.indexOf(label) < 0) continue
|
|
487
|
+
for (const member of group) {
|
|
488
|
+
if (member === label) continue
|
|
489
|
+
const hit = table[member]
|
|
490
|
+
if (hit !== undefined) return hit
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return undefined
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 键的等价组展开:自身 + 所属组内其他成员;不入组的键只返回自身。
|
|
497
|
+
// 覆盖写入/清除与就地重写按该范围触达,保证双语分区两侧状态一致。
|
|
498
|
+
function aliasKeysOf(label) {
|
|
499
|
+
const keys = [label]
|
|
500
|
+
for (const group of ALIAS_GROUPS) {
|
|
501
|
+
if (group.indexOf(label) < 0) continue
|
|
502
|
+
for (const member of group) {
|
|
503
|
+
if (member !== label && keys.indexOf(member) < 0) keys.push(member)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return keys
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// 插件名/分区 label 两级取图:用户覆盖 → 外部声明 → 关键词主题图 → 稳定哈希备用池。
|
|
510
|
+
// 表值为原始声明(glyph 名或 svg),命中后过 resolveIcon 归一化展开;归一化失败
|
|
511
|
+
// (声明值被撤销/存储被篡改)视为该层未命中,继续下一层。
|
|
193
512
|
function themedIcon(name) {
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
513
|
+
const overridden = lookupWithAlias(USER_OVERRIDES, name)
|
|
514
|
+
if (overridden !== undefined) {
|
|
515
|
+
const resolved = resolveIcon(overridden)
|
|
516
|
+
if (resolved !== undefined) return resolved
|
|
517
|
+
}
|
|
518
|
+
const declared = lookupWithAlias(DECLARED_ICONS, name)
|
|
519
|
+
if (declared !== undefined) {
|
|
520
|
+
const resolved = resolveIcon(declared)
|
|
521
|
+
if (resolved !== undefined) return resolved
|
|
522
|
+
}
|
|
196
523
|
const n = name.toLowerCase()
|
|
197
524
|
for (const rule of NAME_RULES) {
|
|
198
525
|
if (rule.re.test(n)) return rule.icon
|
|
@@ -200,16 +527,33 @@ window.__ModuleLoader__.load({
|
|
|
200
527
|
return FALLBACK[poolIndexOf(name)]
|
|
201
528
|
}
|
|
202
529
|
|
|
203
|
-
//
|
|
204
|
-
//
|
|
530
|
+
// 分区取图全链:用户覆盖 → 声明(含别名) → 内置映射 → 关键词/哈希兜底。decide
|
|
531
|
+
// 与声明/覆盖变更就地重写共用;撤销时回退必须含内置映射,否则卡死在哈希图。
|
|
205
532
|
function resolveForLabel(label) {
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
533
|
+
const overridden = lookupWithAlias(USER_OVERRIDES, label)
|
|
534
|
+
if (overridden !== undefined) {
|
|
535
|
+
const resolved = resolveIcon(overridden)
|
|
536
|
+
if (resolved !== undefined) return resolved
|
|
537
|
+
}
|
|
538
|
+
const declared = lookupWithAlias(DECLARED_ICONS, label)
|
|
539
|
+
if (declared !== undefined) {
|
|
540
|
+
const resolved = resolveIcon(declared)
|
|
541
|
+
if (resolved !== undefined) return resolved
|
|
542
|
+
}
|
|
208
543
|
const mapped = ICONS[label]
|
|
209
544
|
if (mapped !== undefined) return mapped
|
|
210
545
|
return themedIcon(label)
|
|
211
546
|
}
|
|
212
547
|
|
|
548
|
+
// 当前 label 的取图来源:用户覆盖 / 插件声明 / 内置映射 / 关键词与哈希推导。
|
|
549
|
+
// 与 resolveForLabel 同序遍历,供覆盖浮层展示当前生效层。
|
|
550
|
+
function iconSourceOf(label) {
|
|
551
|
+
if (lookupWithAlias(USER_OVERRIDES, label) !== undefined) return 'user'
|
|
552
|
+
if (lookupWithAlias(DECLARED_ICONS, label) !== undefined) return 'declared'
|
|
553
|
+
if (ICONS[label] !== undefined) return 'builtin'
|
|
554
|
+
return 'derived'
|
|
555
|
+
}
|
|
556
|
+
|
|
213
557
|
// 市场卡片头像槽决策:img(作者头像)与 div(字母色块)同构处理——原子节点隐藏,
|
|
214
558
|
// 插件图标注入跟随。记账值 = 插件名,换名重贴由该比较驱动。
|
|
215
559
|
function decideAvatar(av, name) {
|
|
@@ -306,7 +650,6 @@ window.__ModuleLoader__.load({
|
|
|
306
650
|
}
|
|
307
651
|
|
|
308
652
|
/* LOGIC-END */
|
|
309
|
-
|
|
310
653
|
// ---- 市场卡片 DOM 锚点(css-modules 哈希前缀随版本变,按后缀匹配) ----
|
|
311
654
|
|
|
312
655
|
const SELECTOR_AV = '[class$="_av"]'
|
|
@@ -451,18 +794,29 @@ window.__ModuleLoader__.load({
|
|
|
451
794
|
}
|
|
452
795
|
if (touched.length === 0) return
|
|
453
796
|
persistDeclarations()
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
797
|
+
rewriteTouched(touched)
|
|
798
|
+
schedule()
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// 键命中元素就地重写:nav svg(记账为分区/插件名)直接重写内容且记账保留——
|
|
802
|
+
// 改写后的 svg 非齿轮,清账会让 isGear 判定失配卡死;头像槽(非 svg 节点)
|
|
803
|
+
// 清账后走 decideAvatar 重贴。'1' 为注入纯标记,不命中声明/覆盖键。
|
|
804
|
+
// 触达键先按等价组展开:声明/覆盖写在哪一侧语言键,双语分区两侧都立即生效。
|
|
805
|
+
function rewriteTouched(touched) {
|
|
806
|
+
const expanded = []
|
|
807
|
+
for (const key of touched) {
|
|
808
|
+
for (const k of aliasKeysOf(key)) {
|
|
809
|
+
if (expanded.indexOf(k) < 0) expanded.push(k)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
457
812
|
for (const el of document.querySelectorAll('[' + ATTR_MARK + ']')) {
|
|
458
|
-
if (
|
|
813
|
+
if (expanded.indexOf(el.dataset.navic) < 0) continue
|
|
459
814
|
if (el.tagName === 'svg') {
|
|
460
815
|
el.innerHTML = svgInner(resolveForLabel(el.dataset.navic))
|
|
461
816
|
} else {
|
|
462
817
|
delete el.dataset.navic
|
|
463
818
|
}
|
|
464
819
|
}
|
|
465
|
-
schedule()
|
|
466
820
|
}
|
|
467
821
|
|
|
468
822
|
// 滚动样式元素:start 注入 head,stop 移除,引用即状态源,幂等无守卫分支;
|
|
@@ -509,23 +863,32 @@ window.__ModuleLoader__.load({
|
|
|
509
863
|
if (previous !== undefined) {
|
|
510
864
|
if (previous.observer !== null) previous.observer.disconnect()
|
|
511
865
|
if (previous.rafId !== 0) cancelAnimationFrame(previous.rafId)
|
|
512
|
-
//
|
|
866
|
+
// 宽松判空:槽是跨代码版本通道,旧版槽缺新字段(undefined)
|
|
513
867
|
if (previous.scrollStyle != null) previous.scrollStyle.remove()
|
|
868
|
+
if (previous.overlayStyle != null) previous.overlayStyle.remove()
|
|
869
|
+
if (previous.overlayEl != null) previous.overlayEl.remove()
|
|
514
870
|
}
|
|
515
|
-
slot = { observer: null, rafId: 0, scrollStyle: null }
|
|
871
|
+
slot = { observer: null, rafId: 0, scrollStyle: null, overlayStyle: null, overlayEl: null }
|
|
516
872
|
window[SLOT_KEY] = slot
|
|
517
|
-
// 可抛步骤(
|
|
873
|
+
// 可抛步骤(覆盖/声明恢复、队列排水、样式注入)先于观察器挂载,失败不产生孤儿扫描器
|
|
874
|
+
installOverlayStyle()
|
|
518
875
|
installScrollStyle()
|
|
876
|
+
restoreUserOverrides()
|
|
519
877
|
restoreDeclarations()
|
|
520
878
|
drainQueue()
|
|
521
879
|
observer = new MutationObserver(onMutations)
|
|
522
880
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
523
881
|
slot.observer = observer
|
|
882
|
+
installOverlayListeners()
|
|
883
|
+
refreshMarkedContent()
|
|
524
884
|
replacePass()
|
|
525
885
|
}
|
|
526
886
|
|
|
527
887
|
function stop() {
|
|
528
888
|
active = false
|
|
889
|
+
uninstallOverlayListeners()
|
|
890
|
+
removeOverlay()
|
|
891
|
+
uninstallOverlayStyle()
|
|
529
892
|
uninstallScrollStyle()
|
|
530
893
|
if (observer !== null) {
|
|
531
894
|
observer.disconnect()
|
package/src/logic.mjs
CHANGED
|
@@ -109,6 +109,13 @@ export const IMAGE = svgOf(
|
|
|
109
109
|
rectOf('2.5', '3', '11', '10', '1.2') +
|
|
110
110
|
circleOf('5.9', '6.1', '1') + pathOf('m3.6 12 3-3 2.3 2.3 1.7-1.7 1.9 1.9'))
|
|
111
111
|
export const ZAP = svgOf(pathOf('M8.9 1.8 3.8 8.9h3.5L7 14.2l5.2-7.1H8.6z'))
|
|
112
|
+
export const WALLET = svgOf(
|
|
113
|
+
rectOf('2.5', '3.5', '11', '9.5', '1.5') +
|
|
114
|
+
pathOf('M13.5 7.4h-2.1a1.6 1.6 0 0 0 0 3.2h2.1'))
|
|
115
|
+
export const GAUGE = svgOf(
|
|
116
|
+
pathOf('M2.5 12a5.5 5.5 0 1 1 11 0') +
|
|
117
|
+
pathOf('M8 12 10.8 9.2') +
|
|
118
|
+
pathOf('M2.5 12h11'))
|
|
112
119
|
|
|
113
120
|
export const FALLBACK = [SPARK, LAYERS, TAG, GRID]
|
|
114
121
|
|
|
@@ -119,6 +126,7 @@ export const GLYPHS = {
|
|
|
119
126
|
archive: ARCHIVE, spark: SPARK, layers: LAYERS, tag: TAG, grid: GRID,
|
|
120
127
|
git: GIT, search: SEARCH, term: TERM, chart: CHART, code: CODE, doc: DOC,
|
|
121
128
|
db: DB, flow: FLOW, globe: GLOBE, lock: LOCK, image: IMAGE, zap: ZAP,
|
|
129
|
+
wallet: WALLET, gauge: GAUGE,
|
|
122
130
|
}
|
|
123
131
|
|
|
124
132
|
// 声明值解析与安全门:svg 须完整开标签(大小写不敏感)且单根闭合(首个 </svg>
|
|
@@ -183,7 +191,9 @@ export const NAME_RULES = [
|
|
|
183
191
|
['doc|note|file|markdown|wiki', DOC],
|
|
184
192
|
['database|sqlite|redis|cache|db', DB],
|
|
185
193
|
['session|archive|history', ARCHIVE],
|
|
194
|
+
['dash|dashboard|gauge', GAUGE],
|
|
186
195
|
['usage|stat|meter|monitor|radar|metric', CHART],
|
|
196
|
+
['wallet|balance|money|coin|fund', WALLET],
|
|
187
197
|
['flow|workflow|pipeline|task|job|queue', FLOW],
|
|
188
198
|
['auth|login|pass|secret|token|lock|guard|crypt', SHIELD],
|
|
189
199
|
['mcp', MCP],
|
|
@@ -195,10 +205,20 @@ export const NAME_RULES = [
|
|
|
195
205
|
['zap|flash|fast|quick|boost|speed|turbo', ZAP],
|
|
196
206
|
].map(function (pair) { return { re: new RegExp('(^|[^a-z])(' + pair[0] + ')([^a-z]|$)'), icon: pair[1] } })
|
|
197
207
|
|
|
198
|
-
// 插件名/分区 label
|
|
208
|
+
// 插件名/分区 label 两级取图:用户覆盖 → 外部声明 → 关键词主题图 → 稳定哈希备用池。
|
|
209
|
+
// 表值为原始声明(glyph 名或 svg),命中后过 resolveIcon 归一化展开;归一化失败
|
|
210
|
+
// (声明值被撤销/存储被篡改)视为该层未命中,继续下一层。
|
|
199
211
|
export function themedIcon(name) {
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
212
|
+
const overridden = lookupWithAlias(USER_OVERRIDES, name)
|
|
213
|
+
if (overridden !== undefined) {
|
|
214
|
+
const resolved = resolveIcon(overridden)
|
|
215
|
+
if (resolved !== undefined) return resolved
|
|
216
|
+
}
|
|
217
|
+
const declared = lookupWithAlias(DECLARED_ICONS, name)
|
|
218
|
+
if (declared !== undefined) {
|
|
219
|
+
const resolved = resolveIcon(declared)
|
|
220
|
+
if (resolved !== undefined) return resolved
|
|
221
|
+
}
|
|
202
222
|
const n = name.toLowerCase()
|
|
203
223
|
for (const rule of NAME_RULES) {
|
|
204
224
|
if (rule.re.test(n)) return rule.icon
|
|
@@ -206,16 +226,33 @@ export function themedIcon(name) {
|
|
|
206
226
|
return FALLBACK[poolIndexOf(name)]
|
|
207
227
|
}
|
|
208
228
|
|
|
209
|
-
//
|
|
210
|
-
//
|
|
229
|
+
// 分区取图全链:用户覆盖 → 声明(含别名) → 内置映射 → 关键词/哈希兜底。decide
|
|
230
|
+
// 与声明/覆盖变更就地重写共用;撤销时回退必须含内置映射,否则卡死在哈希图。
|
|
211
231
|
export function resolveForLabel(label) {
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
232
|
+
const overridden = lookupWithAlias(USER_OVERRIDES, label)
|
|
233
|
+
if (overridden !== undefined) {
|
|
234
|
+
const resolved = resolveIcon(overridden)
|
|
235
|
+
if (resolved !== undefined) return resolved
|
|
236
|
+
}
|
|
237
|
+
const declared = lookupWithAlias(DECLARED_ICONS, label)
|
|
238
|
+
if (declared !== undefined) {
|
|
239
|
+
const resolved = resolveIcon(declared)
|
|
240
|
+
if (resolved !== undefined) return resolved
|
|
241
|
+
}
|
|
214
242
|
const mapped = ICONS[label]
|
|
215
243
|
if (mapped !== undefined) return mapped
|
|
216
244
|
return themedIcon(label)
|
|
217
245
|
}
|
|
218
246
|
|
|
247
|
+
// 当前 label 的取图来源:用户覆盖 / 插件声明 / 内置映射 / 关键词与哈希推导。
|
|
248
|
+
// 与 resolveForLabel 同序遍历,供覆盖浮层展示当前生效层。
|
|
249
|
+
export function iconSourceOf(label) {
|
|
250
|
+
if (lookupWithAlias(USER_OVERRIDES, label) !== undefined) return 'user'
|
|
251
|
+
if (lookupWithAlias(DECLARED_ICONS, label) !== undefined) return 'declared'
|
|
252
|
+
if (ICONS[label] !== undefined) return 'builtin'
|
|
253
|
+
return 'derived'
|
|
254
|
+
}
|
|
255
|
+
|
|
219
256
|
// 市场卡片头像槽决策:img(作者头像)与 div(字母色块)同构处理——原子节点隐藏,
|
|
220
257
|
// 插件图标注入跟随。记账值 = 插件名,换名重贴由该比较驱动。
|
|
221
258
|
export function decideAvatar(av, name) {
|
|
@@ -255,6 +292,45 @@ export const ICONS = {
|
|
|
255
292
|
// 继承成员;污染面收敛在 window.__navicIcons 单一命名空间。
|
|
256
293
|
export const DECLARED_ICONS = Object.create(null)
|
|
257
294
|
|
|
295
|
+
// 用户逐分区覆盖注册表:取图链最前端,值经 resolveIcon 归一化,持久化由
|
|
296
|
+
// 编排层落 localStorage(纯逻辑层不触碰存储)。
|
|
297
|
+
export const USER_OVERRIDES = Object.create(null)
|
|
298
|
+
|
|
299
|
+
// 官方双语分区等价组:组内任一语言 label 作声明/覆盖键,组内其他语言取图时
|
|
300
|
+
// 同样命中(消除语言切换下声明键漂移)。仅收官方现有展示形态,不发明未知 id。
|
|
301
|
+
export const ALIAS_GROUPS = [
|
|
302
|
+
['通用设置', 'General'],
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
// 表查询的别名等价扩展:先按原键直查,再沿等价组用同组成员试查。
|
|
306
|
+
// 表为 Object.create(null) 帧装,继承成员不可达。
|
|
307
|
+
export function lookupWithAlias(table, label) {
|
|
308
|
+
const direct = table[label]
|
|
309
|
+
if (direct !== undefined) return direct
|
|
310
|
+
for (const group of ALIAS_GROUPS) {
|
|
311
|
+
if (group.indexOf(label) < 0) continue
|
|
312
|
+
for (const member of group) {
|
|
313
|
+
if (member === label) continue
|
|
314
|
+
const hit = table[member]
|
|
315
|
+
if (hit !== undefined) return hit
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return undefined
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 键的等价组展开:自身 + 所属组内其他成员;不入组的键只返回自身。
|
|
322
|
+
// 覆盖写入/清除与就地重写按该范围触达,保证双语分区两侧状态一致。
|
|
323
|
+
export function aliasKeysOf(label) {
|
|
324
|
+
const keys = [label]
|
|
325
|
+
for (const group of ALIAS_GROUPS) {
|
|
326
|
+
if (group.indexOf(label) < 0) continue
|
|
327
|
+
for (const member of group) {
|
|
328
|
+
if (member !== label && keys.indexOf(member) < 0) keys.push(member)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return keys
|
|
332
|
+
}
|
|
333
|
+
|
|
258
334
|
// 单元格改写判定(内容改写制):不新建 svg、不动属性,直接把官方齿轮 svg 的
|
|
259
335
|
// 内部内容改写为本插件图标;记账迁到官方 svg 自身(dataset.navic = label),
|
|
260
336
|
// 节点与 class/位置等属性保持官方原样,第三方对官方图标的 CSS/选择器处理
|
|
@@ -29,10 +29,21 @@ function fakeRaf() {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// 浮层桩内递归找节点:按钮嵌在 row 子层
|
|
33
|
+
function overlayFind(root, pred) {
|
|
34
|
+
for (const node of root.children) {
|
|
35
|
+
if (pred(node)) return node
|
|
36
|
+
const hit = overlayFind(node, pred)
|
|
37
|
+
if (hit) return hit
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
// 官方 svg 桩:gear=true 模拟官方齿轮路径,否则为无路径原生图形;内容改写制下
|
|
33
43
|
// applyDecision 直接写 innerHTML(桩以 innerHTML 属性承接)与 dataset.navic
|
|
34
44
|
function makeOfficialSvg({ gear = true } = {}) {
|
|
35
45
|
const svg = {
|
|
46
|
+
tagName: 'svg',
|
|
36
47
|
dataset: { navic: '' },
|
|
37
48
|
style: { display: '' },
|
|
38
49
|
innerHTML: '',
|
|
@@ -69,6 +80,7 @@ function makeCell(label, { gear = true, navic = '' } = {}) {
|
|
|
69
80
|
cell.official = official
|
|
70
81
|
cell.svgs.push(official)
|
|
71
82
|
cell.matches = (sel) => sel !== SELECTOR_AV
|
|
83
|
+
cell.closest = (sel) => (sel === SELECTOR_CELL ? cell : null)
|
|
72
84
|
cell.querySelector = (sel) => (sel === SELECTOR_LABEL ? { textContent: label } : null)
|
|
73
85
|
cell.querySelectorAll = (sel) => {
|
|
74
86
|
if (sel === 'svg') return cell.svgs
|
|
@@ -121,11 +133,29 @@ function fakeDocument() {
|
|
|
121
133
|
appendChild(el) { doc.head.children.push(el); doc.events.push('style') },
|
|
122
134
|
},
|
|
123
135
|
createdStyles: [],
|
|
136
|
+
listeners: {},
|
|
124
137
|
createElement(tag) {
|
|
125
138
|
const el = {
|
|
126
139
|
tag,
|
|
127
140
|
textContent: '',
|
|
141
|
+
children: [],
|
|
142
|
+
className: '',
|
|
143
|
+
value: '',
|
|
144
|
+
placeholder: '',
|
|
145
|
+
innerHTML: '',
|
|
146
|
+
appendChild(child) { el.children.push(child) },
|
|
147
|
+
focus() { el.focused = true },
|
|
148
|
+
addEventListener(type, fn) { el.handlers[type] = fn },
|
|
149
|
+
handlers: {},
|
|
150
|
+
classList: {
|
|
151
|
+
add(cls) { el.classes.add(cls) },
|
|
152
|
+
remove(cls) { el.classes.delete(cls) },
|
|
153
|
+
},
|
|
154
|
+
classes: new Set(),
|
|
128
155
|
remove() {
|
|
156
|
+
const list = doc.bodyChildren
|
|
157
|
+
const at = list.indexOf(el)
|
|
158
|
+
if (at >= 0) list.splice(at, 1)
|
|
129
159
|
doc.createdStyles.splice(doc.createdStyles.indexOf(el), 1)
|
|
130
160
|
doc.head.children.splice(doc.head.children.indexOf(el), 1)
|
|
131
161
|
},
|
|
@@ -133,6 +163,18 @@ function fakeDocument() {
|
|
|
133
163
|
if (tag === 'style') doc.createdStyles.push(el)
|
|
134
164
|
return el
|
|
135
165
|
},
|
|
166
|
+
bodyChildren: [],
|
|
167
|
+
addEventListener(type, fn) {
|
|
168
|
+
;(doc.listeners[type] ??= []).push(fn)
|
|
169
|
+
},
|
|
170
|
+
removeEventListener(type, fn) {
|
|
171
|
+
const list = doc.listeners[type] ?? []
|
|
172
|
+
const at = list.indexOf(fn)
|
|
173
|
+
if (at >= 0) list.splice(at, 1)
|
|
174
|
+
},
|
|
175
|
+
dispatch(type, event) {
|
|
176
|
+
for (const fn of [...(doc.listeners[type] ?? [])]) fn(event)
|
|
177
|
+
},
|
|
136
178
|
querySelectorAll(sel) {
|
|
137
179
|
if (sel === SELECTOR_ALL) return [...doc.cells, ...doc.avatars]
|
|
138
180
|
if (sel === SELECTOR_CELL) return doc.cells
|
|
@@ -141,6 +183,7 @@ function fakeDocument() {
|
|
|
141
183
|
return []
|
|
142
184
|
},
|
|
143
185
|
}
|
|
186
|
+
doc.body.appendChild = (el) => { doc.bodyChildren.push(el) }
|
|
144
187
|
return doc
|
|
145
188
|
}
|
|
146
189
|
|
|
@@ -164,6 +207,11 @@ function loadClient() {
|
|
|
164
207
|
__ModuleLoader__: { load(mod) { win.__loaded = mod } },
|
|
165
208
|
requestAnimationFrame: raf.requestAnimationFrame,
|
|
166
209
|
cancelAnimationFrame: raf.cancelAnimationFrame,
|
|
210
|
+
localStorage: {
|
|
211
|
+
map: new Map(),
|
|
212
|
+
getItem(k) { return win.localStorage.map.has(k) ? win.localStorage.map.get(k) : null },
|
|
213
|
+
setItem(k, v) { win.localStorage.map.set(k, String(v)) },
|
|
214
|
+
},
|
|
167
215
|
}
|
|
168
216
|
const doc = fakeDocument()
|
|
169
217
|
new Function('window', 'document', 'MutationObserver', 'requestAnimationFrame', 'cancelAnimationFrame', 'console', src)(
|
|
@@ -194,21 +242,22 @@ test('boot 观察:body 目标与三开关齐全,卸载后断开', () => {
|
|
|
194
242
|
assert.equal(observed.length, 0, '卸载后 disconnect')
|
|
195
243
|
})
|
|
196
244
|
|
|
197
|
-
test('
|
|
245
|
+
test('启动样式:覆盖浮层样式与滚动样式注入 head,卸载移除,重装恢复,先于观察器挂载', () => {
|
|
198
246
|
const { win, doc, observed } = loadClient()
|
|
199
247
|
assert.equal(doc.createdStyles.length, 0, '启动前无注入')
|
|
200
248
|
const unload = boot(win.__loaded, doc)
|
|
201
249
|
try {
|
|
202
|
-
assert.equal(doc.createdStyles.length,
|
|
203
|
-
assert.
|
|
204
|
-
assert.
|
|
205
|
-
assert.
|
|
250
|
+
assert.equal(doc.createdStyles.length, 2, '恰两次注入(浮层+滚动)')
|
|
251
|
+
assert.ok(doc.createdStyles.some((el) => el.textContent.includes('sni-ov')), '浮层样式随启动注入')
|
|
252
|
+
assert.ok(doc.createdStyles.some((el) => el.textContent === NAVLIST_SCROLL_CSS), '滚动规则与实现同源')
|
|
253
|
+
assert.equal(doc.head.children.length, 2, '样式挂载在 head')
|
|
254
|
+
assert.deepEqual(doc.events, ['style', 'style', 'observe'], '样式注入先于观察器挂载')
|
|
206
255
|
unload()
|
|
207
256
|
assert.equal(doc.createdStyles.length, 0, '卸载后移除')
|
|
208
257
|
assert.equal(doc.head.children.length, 0, 'head 无残留')
|
|
209
258
|
const unload2 = boot(win.__loaded, doc)
|
|
210
259
|
try {
|
|
211
|
-
assert.equal(doc.createdStyles.length,
|
|
260
|
+
assert.equal(doc.createdStyles.length, 2, '重装恢复注入')
|
|
212
261
|
} finally {
|
|
213
262
|
unload2()
|
|
214
263
|
}
|
|
@@ -218,16 +267,18 @@ test('导航滚动样式:启动注入 head,卸载移除,重装恢复,先于观
|
|
|
218
267
|
}
|
|
219
268
|
})
|
|
220
269
|
|
|
221
|
-
test('
|
|
270
|
+
test('启动样式代际:HMR 不卸载重评估由新实例代拆,旧形槽跨版本容忍', () => {
|
|
222
271
|
// HMR 路径:旧实例未 stop 直接二次 boot(新实例读槽代拆上一代样式)
|
|
223
272
|
const { win, doc } = loadClient()
|
|
224
273
|
const unload1 = boot(win.__loaded, doc)
|
|
225
|
-
const
|
|
274
|
+
const oldStyles = [...doc.createdStyles]
|
|
226
275
|
const unload2 = boot(win.__loaded, doc)
|
|
227
276
|
try {
|
|
228
|
-
assert.equal(doc.createdStyles.length,
|
|
229
|
-
assert.equal(doc.head.children.length,
|
|
230
|
-
|
|
277
|
+
assert.equal(doc.createdStyles.length, 2, '代拆后恰两份样式')
|
|
278
|
+
assert.equal(doc.head.children.length, 2, 'head 无旧代残留')
|
|
279
|
+
for (const old of oldStyles) {
|
|
280
|
+
assert.ok(!doc.createdStyles.includes(old), '旧代样式被替换')
|
|
281
|
+
}
|
|
231
282
|
unload2()
|
|
232
283
|
assert.equal(doc.createdStyles.length, 0, '新实例卸载后干净')
|
|
233
284
|
} finally {
|
|
@@ -235,12 +286,12 @@ test('导航滚动样式代际:HMR 不卸载重评估由新实例代拆,旧形
|
|
|
235
286
|
unload2()
|
|
236
287
|
}
|
|
237
288
|
|
|
238
|
-
// 升级路径:上一代是旧版本槽(无 scrollStyle 字段),代拆不得抛错
|
|
289
|
+
// 升级路径:上一代是旧版本槽(无 scrollStyle/overlayStyle 字段),代拆不得抛错
|
|
239
290
|
const { win: win2, doc: doc2 } = loadClient()
|
|
240
291
|
win2[Symbol.for('@mzzsfy/dsh-settings-nav-icons')] = { observer: null, rafId: 0 }
|
|
241
292
|
const unload3 = boot(win2.__loaded, doc2)
|
|
242
293
|
try {
|
|
243
|
-
assert.equal(doc2.createdStyles.length,
|
|
294
|
+
assert.equal(doc2.createdStyles.length, 2, '旧形槽不阻塞注入')
|
|
244
295
|
} finally {
|
|
245
296
|
unload3()
|
|
246
297
|
}
|
|
@@ -249,11 +300,12 @@ test('导航滚动样式代际:HMR 不卸载重评估由新实例代拆,旧形
|
|
|
249
300
|
test('registerIcons 输入校验:数组与空值拒收', () => {
|
|
250
301
|
const { win, doc, raf } = loadClient()
|
|
251
302
|
const unload = boot(win.__loaded, doc)
|
|
303
|
+
raf.flush() // 排空启动期自声明的重贴,后续断言只看本次入参的增量
|
|
252
304
|
try {
|
|
253
305
|
win.__navicIcons.register(['bell'])
|
|
254
306
|
win.__navicIcons.register(null)
|
|
255
307
|
win.__navicIcons.register('bell')
|
|
256
|
-
assert.deepEqual(Object.keys(win.__navicIconDeclarations ?? {}), [], '非法入参不写入注册表')
|
|
308
|
+
assert.deepEqual(Object.keys(win.__navicIconDeclarations ?? {}), ['dsh-settings-nav-icons'], '非法入参不写入注册表(仅启动期自声明在场)')
|
|
257
309
|
assert.equal(raf.size(), 0, '非法入参不触发重贴')
|
|
258
310
|
assert.equal(doc.marked.length, 0)
|
|
259
311
|
} finally {
|
|
@@ -351,6 +403,7 @@ test('队列时序:畸形条目不中断排空,同键后者覆盖,排空先于
|
|
|
351
403
|
test('卸载:__navicIcons 删除,rAF 取消,异常不外抛', () => {
|
|
352
404
|
const { win, doc, raf } = loadClient()
|
|
353
405
|
const unload = boot(win.__loaded, doc)
|
|
406
|
+
raf.flush() // 排空启动期自声明的重贴,断言只看本次注册的增量
|
|
354
407
|
win.__navicIcons.register({ bell: 'bell' })
|
|
355
408
|
assert.equal(raf.size(), 1, '重贴已排定')
|
|
356
409
|
unload()
|
|
@@ -485,6 +538,7 @@ test('头像槽换名:重贴清行内旧注入(父容器范围),外来兄弟不
|
|
|
485
538
|
test('rAF 合批:同一帧内多次 register 只排一帧,flush 后重新排定', () => {
|
|
486
539
|
const { win, doc, raf } = loadClient()
|
|
487
540
|
const unload = boot(win.__loaded, doc)
|
|
541
|
+
raf.flush() // 排空启动期自声明的重贴,断言只看本次注册的增量
|
|
488
542
|
try {
|
|
489
543
|
win.__navicIcons.register({ a: 'bell' })
|
|
490
544
|
win.__navicIcons.register({ b: 'wrench' })
|
|
@@ -516,6 +570,7 @@ test('replacePass 逐项异常隔离:单项失败不中断其余', () => {
|
|
|
516
570
|
test('onMutations 过滤:元素级变更唤醒,文本节点与域外文本不唤醒', () => {
|
|
517
571
|
const { win, doc, raf, observers } = loadClient()
|
|
518
572
|
const unload = boot(win.__loaded, doc)
|
|
573
|
+
raf.flush() // 排空启动期自声明的重贴,断言只看本次变更的增量
|
|
519
574
|
try {
|
|
520
575
|
const observer = observers[observers.length - 1]
|
|
521
576
|
const inCellText = { parentElement: { closest(sel) { return sel.includes('navCell') ? {} : null } } }
|
|
@@ -558,10 +613,143 @@ test('语言切换端到端:characterData 域内变更经回调触发重贴', ()
|
|
|
558
613
|
}
|
|
559
614
|
})
|
|
560
615
|
|
|
561
|
-
//
|
|
616
|
+
// 用户覆盖:恢复走安全门、写入幂等/非法拒绝、等价组全键触达、localStorage 持久往返
|
|
617
|
+
test('用户覆盖:启动从 localStorage 恢复,非法键值跳过', () => {
|
|
618
|
+
const { win, doc } = loadClient()
|
|
619
|
+
doc.cells.push(makeCell('插件市场'))
|
|
620
|
+
win.localStorage.map.set('__navicUserIcons', JSON.stringify({
|
|
621
|
+
'插件市场': 'bell',
|
|
622
|
+
'坏 glyph': 'no-such-glyph',
|
|
623
|
+
'活动 html': '<svg><script>alert(1)</script></svg>',
|
|
624
|
+
'1': 'bell',
|
|
625
|
+
__proto__: 'bell',
|
|
626
|
+
'超长键': 'x'.repeat(4097),
|
|
627
|
+
}))
|
|
628
|
+
const unload = boot(win.__loaded, doc)
|
|
629
|
+
try {
|
|
630
|
+
const market = doc.cells[0]
|
|
631
|
+
assert.equal(market.official.dataset.navic, '插件市场')
|
|
632
|
+
assert.ok(market.official.innerHTML.includes('M8 2.2'), '合法覆盖生效(bell 内容)')
|
|
633
|
+
assert.ok(!doc.marked.some((el) => el.dataset.navic === '坏 glyph'), '未知 glyph 不入表')
|
|
634
|
+
assert.ok(!doc.marked.some((el) => el.dataset.navic === '活动 html'), '安全门拒绝直通')
|
|
635
|
+
} finally {
|
|
636
|
+
unload()
|
|
637
|
+
}
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
test('用户覆盖:右键浮层保存/非法行内提示/清除,持久化往返,卸载后重装恢复', () => {
|
|
641
|
+
const { win, doc, raf } = loadClient()
|
|
642
|
+
doc.cells.push(makeCell('插件市场'))
|
|
643
|
+
const unload = boot(win.__loaded, doc)
|
|
644
|
+
try {
|
|
645
|
+
const cell = doc.cells[0]
|
|
646
|
+
doc.marked.push(cell.official)
|
|
647
|
+
// 右键打开浮层:preventDefault + 单例 + 输入聚焦
|
|
648
|
+
const evt = { target: cell, preventDefault() { evt.prevented = true } }
|
|
649
|
+
doc.dispatch('contextmenu', evt)
|
|
650
|
+
assert.ok(evt.prevented, '右键默认菜单被阻止')
|
|
651
|
+
const overlay = doc.bodyChildren[doc.bodyChildren.length - 1]
|
|
652
|
+
assert.ok(overlay.className.includes('sni-ov'), '浮层挂 body')
|
|
653
|
+
const input = overlayFind(overlay, (n) => n.tag === 'input')
|
|
654
|
+
const save = overlayFind(overlay, (n) => n.tag === 'button' && n.textContent === '保存')
|
|
655
|
+
const clear = overlayFind(overlay, (n) => n.tag === 'button' && n.textContent === '清除')
|
|
656
|
+
assert.ok(input && save && clear, '浮层含输入与双按钮')
|
|
657
|
+
assert.equal(input.focused, true, '打开即聚焦')
|
|
658
|
+
// 非法值:行内提示,不落库
|
|
659
|
+
input.value = 'no-such-glyph'
|
|
660
|
+
save.handlers.click()
|
|
661
|
+
const bad = overlayFind(overlay, (n) => (n.classes ?? new Set()).size > 0)
|
|
662
|
+
assert.ok(bad && [...bad.classes].some((c) => c.includes('__bad--on')), '非法值行内提示')
|
|
663
|
+
assert.equal(win.localStorage.map.get('__navicUserIcons'), undefined, '非法值不持久化')
|
|
664
|
+
// 合法保存:落库 + 单元格就地重写 + 浮层关闭
|
|
665
|
+
input.value = 'bell'
|
|
666
|
+
save.handlers.click()
|
|
667
|
+
assert.ok(!doc.bodyChildren.includes(overlay), '保存成功关闭浮层')
|
|
668
|
+
assert.ok(cell.official.innerHTML.includes('M8 2.2'), '覆盖内容生效')
|
|
669
|
+
const saved = JSON.parse(win.localStorage.map.get('__navicUserIcons'))
|
|
670
|
+
assert.ok(saved['插件市场'], '覆盖持久化')
|
|
671
|
+
// 再开浮层:回填当前覆盖值;清除:回默认管线 + 删键
|
|
672
|
+
doc.dispatch('contextmenu', { target: cell, preventDefault() {} })
|
|
673
|
+
const overlay2 = doc.bodyChildren[doc.bodyChildren.length - 1]
|
|
674
|
+
const input2 = overlayFind(overlay2, (n) => n.tag === 'input')
|
|
675
|
+
const clear2 = overlayFind(overlay2, (n) => n.tag === 'button' && n.textContent === '清除')
|
|
676
|
+
assert.equal(input2.value, 'bell', '回填当前覆盖原文(glyph 名)')
|
|
677
|
+
clear2.handlers.click()
|
|
678
|
+
assert.equal(win.localStorage.map.get('__navicUserIcons'), '{}', '清除后持久化为空表')
|
|
679
|
+
assert.ok(!cell.official.innerHTML.includes('M8 2.2'), '清除后回默认管线(店面图标)')
|
|
680
|
+
assert.ok(doc.bodyChildren.indexOf(overlay2) < 0, '清除关闭浮层')
|
|
681
|
+
// 浮层外点击与 Escape 关闭
|
|
682
|
+
doc.dispatch('contextmenu', { target: cell, preventDefault() {} })
|
|
683
|
+
const overlay3 = doc.bodyChildren[doc.bodyChildren.length - 1]
|
|
684
|
+
doc.dispatch('pointerdown', { target: cell })
|
|
685
|
+
assert.ok(!doc.bodyChildren.includes(overlay3), '外部点击关闭')
|
|
686
|
+
doc.dispatch('contextmenu', { target: cell, preventDefault() {} })
|
|
687
|
+
const overlay4 = doc.bodyChildren[doc.bodyChildren.length - 1]
|
|
688
|
+
doc.dispatch('keydown', { key: 'Escape' })
|
|
689
|
+
assert.ok(!doc.bodyChildren.includes(overlay4), 'Escape 关闭')
|
|
690
|
+
// 卸载重装:localStorage 覆盖恢复生效
|
|
691
|
+
win.localStorage.map.set('__navicUserIcons', JSON.stringify({ '插件市场': 'bell' }))
|
|
692
|
+
} finally {
|
|
693
|
+
unload()
|
|
694
|
+
}
|
|
695
|
+
const unload2 = boot(win.__loaded, doc)
|
|
696
|
+
try {
|
|
697
|
+
assert.ok(doc.cells[0].official.innerHTML.includes('M8 2.2'), '重装恢复用户覆盖')
|
|
698
|
+
} finally {
|
|
699
|
+
unload2()
|
|
700
|
+
}
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
test('用户覆盖:别名等价键写入/清除,双语分区两侧触达', () => {
|
|
704
|
+
const { win, doc } = loadClient()
|
|
705
|
+
// 双语两格均按齿轮改写链记账:'通用设置' 与 'General' 各持内置映射
|
|
706
|
+
doc.cells.push(makeCell('通用设置'))
|
|
707
|
+
doc.cells.push(makeCell('General'))
|
|
708
|
+
const unload = boot(win.__loaded, doc)
|
|
709
|
+
try {
|
|
710
|
+
const zhCell = doc.cells[0]
|
|
711
|
+
const enCell = doc.cells[1]
|
|
712
|
+
doc.marked.push(zhCell.official, enCell.official)
|
|
713
|
+
doc.dispatch('contextmenu', { target: zhCell, preventDefault() {} })
|
|
714
|
+
const overlay = doc.bodyChildren[doc.bodyChildren.length - 1]
|
|
715
|
+
const input = overlayFind(overlay, (n) => n.tag === 'input')
|
|
716
|
+
const save = overlayFind(overlay, (n) => n.tag === 'button' && n.textContent === '保存')
|
|
717
|
+
input.value = 'bell'
|
|
718
|
+
save.handlers.click()
|
|
719
|
+
assert.equal(JSON.parse(win.localStorage.map.get('__navicUserIcons'))['通用设置'], 'bell', '按打开侧语言键落库')
|
|
720
|
+
assert.ok(zhCell.official.innerHTML.includes('M8 2.2'), '中文侧覆盖生效')
|
|
721
|
+
assert.ok(enCell.official.innerHTML.includes('M8 2.2'), '英文侧等价触达重写')
|
|
722
|
+
// 清除:等价组全键删除,两侧回各语言内置映射
|
|
723
|
+
doc.dispatch('contextmenu', { target: enCell, preventDefault() {} })
|
|
724
|
+
const overlay2 = doc.bodyChildren[doc.bodyChildren.length - 1]
|
|
725
|
+
assert.ok(overlayFind(overlay2, (n) => n.tag === 'input').value === 'bell', '英文侧打开回填中文键覆盖')
|
|
726
|
+
const clear2 = overlayFind(overlay2, (n) => n.tag === 'button' && n.textContent === '清除')
|
|
727
|
+
clear2.handlers.click()
|
|
728
|
+
assert.equal(JSON.parse(win.localStorage.map.get('__navicUserIcons'))['通用设置'], undefined, '清除走等价组全键')
|
|
729
|
+
assert.ok(!zhCell.official.innerHTML.includes('M8 2.2'), '中文侧回内置映射')
|
|
730
|
+
assert.ok(!enCell.official.innerHTML.includes('M8 2.2'), '英文侧回内置映射')
|
|
731
|
+
} finally {
|
|
732
|
+
unload()
|
|
733
|
+
}
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
test('用户覆盖:非 nav 单元格右键不弹浮层', () => {
|
|
737
|
+
const { win, doc } = loadClient()
|
|
738
|
+
const unload = boot(win.__loaded, doc)
|
|
739
|
+
try {
|
|
740
|
+
doc.dispatch('contextmenu', { target: doc.avatars[0], preventDefault() {} })
|
|
741
|
+
assert.equal(doc.bodyChildren.length, 0, '头像槽右键无浮层')
|
|
742
|
+
} finally {
|
|
743
|
+
unload()
|
|
744
|
+
}
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
// 声明侧契约:全部生产者包的注册样板(先探测入口、数组态入队),改契约须同步全部生产者。
|
|
562
748
|
// 包脱离 monorepo 布局(发布态)时跳过:无兄弟包源码可读。
|
|
563
|
-
test('
|
|
564
|
-
const producers = ['dsh-session-manager', 'dsh-usage-panel', 'dsh-turn-notify', 'dsh-maintain'
|
|
749
|
+
test('生产者样板契约:全包注册走 __navicIcons/__navicIconQueue', () => {
|
|
750
|
+
const producers = ['dsh-session-manager', 'dsh-usage-panel', 'dsh-turn-notify', 'dsh-maintain', 'dsh-rs-workflow',
|
|
751
|
+
'dsh-usage-dash', 'dsh-cron-board', 'dsh-think-expand', 'dsh-model-capability-editor', 'dsh-settings-nav-icons',
|
|
752
|
+
'dsh-auto-trust-all', 'dsh-llm-pi-gateway']
|
|
565
753
|
let checked = 0
|
|
566
754
|
for (const pkg of producers) {
|
|
567
755
|
const clientPath = join(PKG_ROOT, '..', pkg, 'src', 'client.js')
|
package/test/parity.test.mjs
CHANGED
|
@@ -26,7 +26,7 @@ function clientLogic() {
|
|
|
26
26
|
}
|
|
27
27
|
const factory = new Function(
|
|
28
28
|
'ATTR_MARK', 'SELECTOR_LABEL',
|
|
29
|
-
section + '; return { ICONS, DECLARED_ICONS, FALLBACK, GLYPHS, GEAR_PATH, SVG_MAX_CHARS, STROKE_ATTRS, NAME_RULES, ATTR_MARK, SELECTOR_LABEL, poolIndexOf, resolveIcon, svgInner, resolveForLabel, themedIcon, decide, applyDecision, decideAvatar, applyAvatar };',
|
|
29
|
+
section + '; return { ICONS, DECLARED_ICONS, USER_OVERRIDES, ALIAS_GROUPS, FALLBACK, GLYPHS, GEAR_PATH, SVG_MAX_CHARS, STROKE_ATTRS, NAME_RULES, ATTR_MARK, SELECTOR_LABEL, poolIndexOf, resolveIcon, svgInner, resolveForLabel, themedIcon, iconSourceOf, lookupWithAlias, aliasKeysOf, decide, applyDecision, decideAvatar, applyAvatar };',
|
|
30
30
|
)
|
|
31
31
|
return factory(pick('ATTR_MARK'), pick('SELECTOR_LABEL'))
|
|
32
32
|
}
|
|
@@ -274,7 +274,7 @@ function defineScenarios(prefix, L) {
|
|
|
274
274
|
})
|
|
275
275
|
|
|
276
276
|
test(prefix + '关键词规则整词边界与跨规则优先级', () => {
|
|
277
|
-
const { themedIcon, FALLBACK } = L
|
|
277
|
+
const { themedIcon, FALLBACK, GLYPHS } = L
|
|
278
278
|
assert.equal(themedIcon('important'), FALLBACK[poolIndexOf('important')], 'im 不命中 important')
|
|
279
279
|
assert.equal(themedIcon('dbtool'), FALLBACK[poolIndexOf('dbtool')], 'db 不命中 dbtool')
|
|
280
280
|
assert.equal(themedIcon('my db tool'), themedIcon('db'), '独立词 db 命中')
|
|
@@ -283,6 +283,58 @@ function defineScenarios(prefix, L) {
|
|
|
283
283
|
// 顺序即优先级,具体语义在前:双规则同打名取靠前者
|
|
284
284
|
assert.equal(themedIcon('search-bot'), themedIcon('search'), 'search 先于 bot')
|
|
285
285
|
assert.equal(themedIcon('git-notify'), themedIcon('git'), 'git 先于 notify')
|
|
286
|
+
// 余额语义:wallet 系关键词命中钱包图,plan 系关键词仍命中时钟
|
|
287
|
+
assert.equal(themedIcon('account-balance'), GLYPHS.wallet, 'balance 命中钱包')
|
|
288
|
+
assert.equal(themedIcon('my-wallet-tool'), GLYPHS.wallet, 'wallet 命中钱包')
|
|
289
|
+
assert.equal(themedIcon('cron-board'), GLYPHS.plan, 'cron 仍命中时钟')
|
|
290
|
+
// 仪表盘语义:dash 系关键词命中表盘图
|
|
291
|
+
assert.equal(themedIcon('usage-dash'), GLYPHS.gauge, 'dash 命中表盘')
|
|
292
|
+
assert.equal(themedIcon('my-dashboard'), GLYPHS.gauge, 'dashboard 命中表盘')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test(prefix + '别名等价:双语分区任一语言键命中,组外键不受影响', () => {
|
|
296
|
+
const { USER_OVERRIDES, lookupWithAlias, aliasKeysOf } = L
|
|
297
|
+
try {
|
|
298
|
+
USER_OVERRIDES['通用设置'] = 'bell'
|
|
299
|
+
assert.equal(lookupWithAlias(USER_OVERRIDES, 'General'), 'bell', '反向等价命中')
|
|
300
|
+
assert.equal(lookupWithAlias(USER_OVERRIDES, '通用设置'), 'bell', '正向直查')
|
|
301
|
+
assert.equal(aliasKeysOf('General').length, 2, '等价组键展开为双语两侧')
|
|
302
|
+
assert.deepEqual(aliasKeysOf('插件市场'), ['插件市场'], '组外键只返回自身')
|
|
303
|
+
} finally {
|
|
304
|
+
delete USER_OVERRIDES['通用设置']
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test(prefix + '用户覆盖插到取图链最前,撤销后回声明/内置/推导管线', () => {
|
|
309
|
+
const { USER_OVERRIDES, DECLARED_ICONS, ICONS, resolveForLabel, resolveIcon, iconSourceOf } = L
|
|
310
|
+
try {
|
|
311
|
+
DECLARED_ICONS['插件市场'] = 'git'
|
|
312
|
+
assert.equal(resolveForLabel('插件市场'), resolveIcon('git'), '基线:声明命中')
|
|
313
|
+
assert.equal(iconSourceOf('插件市场'), 'declared')
|
|
314
|
+
USER_OVERRIDES['插件市场'] = 'bell'
|
|
315
|
+
assert.equal(resolveForLabel('插件市场'), resolveIcon('bell'), '覆盖压过声明')
|
|
316
|
+
assert.equal(iconSourceOf('插件市场'), 'user')
|
|
317
|
+
delete USER_OVERRIDES['插件市场']
|
|
318
|
+
assert.equal(resolveForLabel('插件市场'), resolveIcon('git'), '撤销覆盖回声明')
|
|
319
|
+
delete DECLARED_ICONS['插件市场']
|
|
320
|
+
assert.equal(resolveForLabel('插件市场'), ICONS['插件市场'], '再撤声明回内置映射')
|
|
321
|
+
assert.equal(iconSourceOf('插件市场'), 'builtin')
|
|
322
|
+
assert.equal(iconSourceOf('无任何命中的分区'), 'derived', '无命中归推导')
|
|
323
|
+
} finally {
|
|
324
|
+
delete USER_OVERRIDES['插件市场']
|
|
325
|
+
delete DECLARED_ICONS['插件市场']
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test(prefix + '覆盖的别名等价:覆盖写在任一语言键,双语 label 都命中且来源为 user', () => {
|
|
330
|
+
const { USER_OVERRIDES, resolveForLabel, resolveIcon, iconSourceOf } = L
|
|
331
|
+
try {
|
|
332
|
+
USER_OVERRIDES['General'] = 'search'
|
|
333
|
+
assert.equal(resolveForLabel('通用设置'), resolveIcon('search'), '反向 label 命中覆盖')
|
|
334
|
+
assert.equal(iconSourceOf('通用设置'), 'user')
|
|
335
|
+
} finally {
|
|
336
|
+
delete USER_OVERRIDES['General']
|
|
337
|
+
}
|
|
286
338
|
})
|
|
287
339
|
|
|
288
340
|
test(prefix + '头像槽:原子节点隐藏记账,换名清旧注入不误删外来兄弟', () => {
|
|
@@ -351,6 +403,9 @@ test('两份实现常量同源', () => {
|
|
|
351
403
|
assert.equal(logic.SELECTOR_LABEL, client.SELECTOR_LABEL, 'label 选择器同源(精确匹配桩防子串容忍)')
|
|
352
404
|
assert.equal(logic.GEAR_PATH, client.GEAR_PATH)
|
|
353
405
|
assert.equal(logic.SVG_MAX_CHARS, client.SVG_MAX_CHARS)
|
|
406
|
+
assert.deepEqual(logic.ALIAS_GROUPS, client.ALIAS_GROUPS, '别名组同源')
|
|
407
|
+
assert.equal(Object.keys(logic.USER_OVERRIDES).length, 0, '用户覆盖表初始为空(logic 侧)')
|
|
408
|
+
assert.equal(Object.keys(client.USER_OVERRIDES).length, 0, '用户覆盖表初始为空(client 侧)')
|
|
354
409
|
})
|
|
355
410
|
|
|
356
411
|
test('两份实现 ICONS/GLYPHS/FALLBACK 全量同源', () => {
|
|
@@ -371,8 +426,8 @@ test('两份实现 NAME_RULES 全量同源(含标志位)', () => {
|
|
|
371
426
|
|
|
372
427
|
test('README 契约数量锁定', () => {
|
|
373
428
|
// README 声明的内置表规模,扩表须同步改 README
|
|
374
|
-
assert.equal(Object.keys(logic.GLYPHS).length,
|
|
375
|
-
assert.equal(logic.NAME_RULES.length,
|
|
429
|
+
assert.equal(Object.keys(logic.GLYPHS).length, 30)
|
|
430
|
+
assert.equal(logic.NAME_RULES.length, 21)
|
|
376
431
|
})
|
|
377
432
|
|
|
378
433
|
test('LOGIC 段与 logic.mjs 决策函数逐函数源码一致(归一化注释与空白)', () => {
|
|
@@ -380,7 +435,7 @@ test('LOGIC 段与 logic.mjs 决策函数逐函数源码一致(归一化注释
|
|
|
380
435
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
381
436
|
.replace(/\/\/[^\n]*/g, '')
|
|
382
437
|
.replace(/\s+/g, '')
|
|
383
|
-
for (const name of ['resolveIcon', 'svgInner', 'resolveForLabel', 'themedIcon', 'decide', 'applyDecision', 'decideAvatar', 'applyAvatar', 'poolIndexOf']) {
|
|
438
|
+
for (const name of ['resolveIcon', 'svgInner', 'resolveForLabel', 'themedIcon', 'iconSourceOf', 'lookupWithAlias', 'aliasKeysOf', 'decide', 'applyDecision', 'decideAvatar', 'applyAvatar', 'poolIndexOf']) {
|
|
384
439
|
assert.equal(
|
|
385
440
|
normalize(client[name].toString()),
|
|
386
441
|
normalize(logic[name].toString()),
|