dsh-remote-mobile 1.2.1 → 1.4.1
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/CHANGELOG.md +82 -0
- package/README.md +94 -13
- package/README_EN.md +93 -13
- package/lib/auth/crypto.js +2 -2
- package/lib/auth/gate.d.ts +15 -0
- package/lib/auth/gate.js +49 -0
- package/lib/auth/token.js +9 -4
- package/lib/bridge/compat.d.ts +79 -9
- package/lib/bridge/compat.js +562 -51
- package/lib/client.js +1114 -303
- package/lib/index.d.ts +1 -0
- package/lib/index.js +9 -6
- package/lib/routes/api.d.ts +6 -1
- package/lib/routes/api.js +85 -2
- package/lib/styles/style-snippets.d.ts +133 -0
- package/lib/styles/style-snippets.js +932 -0
- package/package.json +2 -2
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 移动端样式片段注册表 (Mobile Style Snippets Store)
|
|
3
|
+
*
|
|
4
|
+
* 【设计职责】
|
|
5
|
+
* 本模块是插件内部的「样式小插件」体系:把 DSH Web 界面的样式适配拆分成
|
|
6
|
+
* 一个个可独立启停的 CSS 片段(Snippet)。
|
|
7
|
+
*
|
|
8
|
+
* - 内置预设片段(Builtin Presets):随插件分发,按界面区域划分为三份——
|
|
9
|
+
* 侧边栏(preset-sidebar)、设置面板(preset-settings)、正文(preset-main);
|
|
10
|
+
* - 用户自定义片段(Custom Snippets):通过设置面板或 API 动态增删改,持久化于
|
|
11
|
+
* ~/.dsh/remote-mobile/style-snippets.json,无需修改插件代码、无需重启;
|
|
12
|
+
* - 每个片段(含内置预设)支持【PC 端 / 移动端】分别启停,且按【视口宽度】判定而非设备 UA:
|
|
13
|
+
* mobileEnabled → 包装 @media (max-width: 900px),窄视口生效(PC 拉小窗口同样生效);
|
|
14
|
+
* pcEnabled → 包装 @media (min-width: 901px),宽视口生效;两端都开 → 全宽度原样生效。
|
|
15
|
+
*
|
|
16
|
+
* 注入发生在网关层响应拦截(见 bridge/compat.ts 的 patchHttpServerWithVirtualizer),
|
|
17
|
+
* 对主工作区 index.html 与 /auth 登录页统一生效。
|
|
18
|
+
*/
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
|
|
22
|
+
/** 用户自定义样式片段持久化文件 */
|
|
23
|
+
export const DEFAULT_STYLE_FILE = join(homedir(), '.dsh', 'remote-mobile', 'style-snippets.json');
|
|
24
|
+
/** 移动端 User-Agent 识别正则 */
|
|
25
|
+
export const MOBILE_UA_REGEX = /Android|iPhone|iPod|iPad|Mobile Safari|Opera Mini|IEMobile|Windows Phone|BlackBerry|webOS|Mobi/i;
|
|
26
|
+
/** 单片段 CSS 长度上限(防配置膨胀) */
|
|
27
|
+
export const MAX_CSS_LENGTH = 200_000;
|
|
28
|
+
/** 片段名称长度上限 */
|
|
29
|
+
export const MAX_NAME_LENGTH = 80;
|
|
30
|
+
/** 内置预设 id 保留前缀(自定义片段不得占用) */
|
|
31
|
+
export const BUILTIN_ID_PREFIX = 'preset-';
|
|
32
|
+
/** 侧边栏预设:抽屉式导航 + 整体布局(overscroll / 三栏 0 占位 / 悬浮把手) */
|
|
33
|
+
const PRESET_SIDEBAR_CSS = `
|
|
34
|
+
/* ===== dsh-remote-mobile · 侧边栏抽屉导航与整体布局 ===== */
|
|
35
|
+
/* 注:窄屏媒体查询由注入层按端包裹(移动端包 @media (max-width:900px),PC 端原样生效) */
|
|
36
|
+
/* 0. 禁用浏览器原生橡皮筋下拉刷新与链式溢出滚动,彻底杜绝上下滑动误刷新页面 */
|
|
37
|
+
html,
|
|
38
|
+
body,
|
|
39
|
+
[class*="_frame"],
|
|
40
|
+
[role="dialog"],
|
|
41
|
+
[class*="_dialog"],
|
|
42
|
+
[class*="_modalContent"],
|
|
43
|
+
[class*="_overlay"],
|
|
44
|
+
[class*="_conversationArea"],
|
|
45
|
+
[class*="_centerCol"] {
|
|
46
|
+
overscroll-behavior-y: contain !important;
|
|
47
|
+
overscroll-behavior-x: none !important;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* 1. 最外层三栏布局:在移动端主工作区占满 100% 全宽,消除侧边栏强制占位。
|
|
51
|
+
选择器全部基于官方 CSS Modules 稳定后缀(_frame/_sidebarCol/_centerCol,
|
|
52
|
+
前缀 hash 随机、后缀稳定),不依赖 @linxin666/dsh-web-ui-all 注入的
|
|
53
|
+
data-pane / data-dsh-frame 属性,安装与否均生效。 */
|
|
54
|
+
[class*="_frame"] {
|
|
55
|
+
grid-template-columns: 0px minmax(0px, 1fr) 0px !important;
|
|
56
|
+
width: 100vw !important;
|
|
57
|
+
max-width: 100vw !important;
|
|
58
|
+
overflow-x: hidden !important;
|
|
59
|
+
position: relative !important;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
[class*="_centerCol"] {
|
|
63
|
+
width: 100vw !important;
|
|
64
|
+
max-width: 100vw !important;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* 2. 侧边栏最外层容器:作为整体抽屉浮层(阴影仅加在最外层,严禁污染内部列表) */
|
|
68
|
+
[class*="_sidebarCol"] {
|
|
69
|
+
position: fixed !important;
|
|
70
|
+
top: 0 !important;
|
|
71
|
+
bottom: 0 !important;
|
|
72
|
+
left: 0 !important;
|
|
73
|
+
height: 100vh !important;
|
|
74
|
+
height: 100dvh !important;
|
|
75
|
+
/* 层级守卫:抽屉必须低于官方弹层体系(模态弹窗 z=1000、下拉/弹出菜单 z=1100),
|
|
76
|
+
否则设置面板的操作菜单(portal 到 body)会被抽屉盖住无法交互。
|
|
77
|
+
官方内容区元素均在 100 层级以下,900 足以保证抽屉覆盖正文。
|
|
78
|
+
展开态宽度:装了 web-ui 时由它接管(min(88vw,320px));未装时沿用官方
|
|
79
|
+
内联 width(280px 左右),宽度差由下方内层铺满规则消除。 */
|
|
80
|
+
min-width: 0 !important;
|
|
81
|
+
z-index: 900 !important;
|
|
82
|
+
background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
|
|
83
|
+
box-shadow: 6px 0 30px rgba(0, 0, 0, 0.25) !important;
|
|
84
|
+
border-right: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.15)) !important;
|
|
85
|
+
/* 关键守卫:官方会在侧边栏列上留下恒等 transform(matrix(1,0,0,1,0,0)),
|
|
86
|
+
任何 transform/filter 等都会把 position:fixed 后代的包含块「困」在本列内,
|
|
87
|
+
叠加官方 overflow:hidden 后,设置弹窗(内联渲染于侧边栏 DOM)会被裁剪成
|
|
88
|
+
抽屉宽度、表现为「弹窗出现在侧边栏里面」。显式清除全部包含块触发属性。 */
|
|
89
|
+
transform: none !important;
|
|
90
|
+
filter: none !important;
|
|
91
|
+
backdrop-filter: none !important;
|
|
92
|
+
perspective: none !important;
|
|
93
|
+
will-change: auto !important;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* 遮罩:抽屉外全屏黑纱必须压在抽屉(900)之下,否则抽屉内容会被黑纱罩住并
|
|
97
|
+
拦截全部点击。压到 899:低于抽屉、仍高于正文(≤100),「点抽屉外暗处
|
|
98
|
+
自动收回」的能力保留。
|
|
99
|
+
- 装了 web-ui 时:它生成 [data-dsh-frame]:not([data-sidebar-collapsed])::after
|
|
100
|
+
(z-index:1050),由下方 [class*="_frame"]::after 的 !important 统一压到 899。
|
|
101
|
+
- 未装 web-ui 时:官方无遮罩,由下方 :not([data-sidebar-collapsed]) 规则补齐。 */
|
|
102
|
+
[class*="_frame"]::after {
|
|
103
|
+
z-index: 899 !important;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
[class*="_frame"]:not([data-sidebar-collapsed])::after {
|
|
107
|
+
content: "";
|
|
108
|
+
position: fixed;
|
|
109
|
+
inset: 0;
|
|
110
|
+
z-index: 899;
|
|
111
|
+
background: rgb(0 0 0 / 24%);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* 内层根节点铺满抽屉:覆盖官方响应式脚本写入的内联 width,
|
|
115
|
+
消除「外层 ~320 / 内层内联 280」的宽度差与右缘空白条 */
|
|
116
|
+
[class*="_sidebarCol"] > [data-slot="sidebar"] > div {
|
|
117
|
+
width: 100% !important;
|
|
118
|
+
min-width: 0 !important;
|
|
119
|
+
max-width: none !important;
|
|
120
|
+
height: 100% !important;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* 3. 折叠状态:侧边栏收起为 0px 并隐藏整体阴影和多余按钮。
|
|
124
|
+
data-sidebar-collapsed 属性与 _collapsed class 均由官方 sidebar 插件写入,
|
|
125
|
+
两种环境(装/不装 web-ui)行为一致,这里用无值匹配 [data-sidebar-collapsed]
|
|
126
|
+
保证属性不带值也能命中。 */
|
|
127
|
+
[class*="_sidebarCol"]:has([class*="_collapsed"]),
|
|
128
|
+
[class*="_frame"][data-sidebar-collapsed] [class*="_sidebarCol"] {
|
|
129
|
+
width: 0px !important;
|
|
130
|
+
min-width: 0px !important;
|
|
131
|
+
border: none !important;
|
|
132
|
+
box-shadow: none !important;
|
|
133
|
+
background: transparent !important;
|
|
134
|
+
pointer-events: none !important;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
[class*="_sidebarCol"] [class*="_collapsed"] > :not([class*="_logoRow"]),
|
|
138
|
+
[class*="_frame"][data-sidebar-collapsed] [class*="_sidebarCol"] > div > div > :not([class*="_logoRow"]) {
|
|
139
|
+
display: none !important;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* 折叠状态下,Logo 按钮悬浮在屏幕左上角作为展开把手(与 dsh-draggable-nav 拖拽脚本联动) */
|
|
143
|
+
[class*="_sidebarCol"] [class*="_collapsed"] [class*="_logoRow"],
|
|
144
|
+
[class*="_frame"][data-sidebar-collapsed] [class*="_sidebarCol"] [class*="_logoRow"] {
|
|
145
|
+
position: fixed !important;
|
|
146
|
+
top: 10px !important;
|
|
147
|
+
left: 10px !important;
|
|
148
|
+
width: 40px !important;
|
|
149
|
+
height: 40px !important;
|
|
150
|
+
border-radius: 10px !important;
|
|
151
|
+
background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.95)) !important;
|
|
152
|
+
backdrop-filter: blur(12px) !important;
|
|
153
|
+
-webkit-backdrop-filter: blur(12px) !important;
|
|
154
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.2)) !important;
|
|
155
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1) !important;
|
|
156
|
+
display: flex !important;
|
|
157
|
+
align-items: center !important;
|
|
158
|
+
justify-content: center !important;
|
|
159
|
+
pointer-events: auto !important;
|
|
160
|
+
/* 与抽屉(900)同层体系:高于正文内容,低于官方弹窗(1000)/菜单(1100) */
|
|
161
|
+
z-index: 901 !important;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* 4. 触屏设备没有 hover:官方 Tooltip 气泡(黑底,如折叠把手的「收起侧边栏」)
|
|
165
|
+
显示依赖 mouseenter/focus,而消失只认 mouseleave/blur —— 触摸点击后这两个
|
|
166
|
+
事件永远不会到来,气泡会永久滞留在屏幕上。移动端 UA 下整体隐藏官方气泡
|
|
167
|
+
(按钮均保留 aria-label,可访问性不受影响);PC 窄窗口仍保留悬停提示。 */
|
|
168
|
+
html[data-dsh-mobile="1"] [role="tooltip"] {
|
|
169
|
+
display: none !important;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* 5. 窄屏隐藏官方详情(details)列:官方会把未折叠的详情面板做成全屏浮层
|
|
173
|
+
盖在 conversation 顶部(web-ui 窄屏折叠态也是 display:none),这里直接隐藏,
|
|
174
|
+
彻底杜绝缩小窗口时详情面板与顶部 header 重叠。 */
|
|
175
|
+
[class*="_detailsCol"] {
|
|
176
|
+
display: none !important;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* 6. 已装 web-ui 时,其移动端 conversation-header 为折叠 rail 预留 60px 左内边距;
|
|
180
|
+
本插件折叠为 0px + 悬浮把手,header 无需让位,覆盖为 8px。
|
|
181
|
+
用更高特异性((0,2,1) > web-ui 的 (0,2,0))保证无论加载顺序都压过 web-ui;
|
|
182
|
+
仅在与 web-ui 相同的 ≤768px 断点生效,避免干扰 769-900px 的官方 header。 */
|
|
183
|
+
@media (max-width: 768px) {
|
|
184
|
+
[class*="_frame"] header[data-dsh-responsive-part="conversation-header"] {
|
|
185
|
+
padding-left: 8px !important;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* 7. 覆盖 web-ui 给 session-utilities 按钮强制的 min-height: 44px(min-width 保留)。
|
|
189
|
+
该规则特异性与 web-ui 同为 (0,3,0),故补一个重复属性选择器抬到 (0,4,0)
|
|
190
|
+
确保无论加载顺序都压过 web-ui;仅在同断点 ≤768px 生效。 */
|
|
191
|
+
[class*="_frame"] [data-dsh-responsive-part="session-utilities"][data-dsh-responsive-part] :is(button, [role="button"]) {
|
|
192
|
+
min-height: 0 !important;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
`;
|
|
196
|
+
/** 设置面板预设:官方设置弹窗整体微缩 + 遮罩锁滚动 + 内容横向滑动 */
|
|
197
|
+
const PRESET_SETTINGS_CSS = `
|
|
198
|
+
/* ===== dsh-remote-mobile · 设置面板适配 ===== */
|
|
199
|
+
/* 注:窄屏媒体查询由注入层按端包裹 */
|
|
200
|
+
/* 遮罩彻底锁死防漏底,弹窗整体居中 */
|
|
201
|
+
[data-slot*="overlay"]:has([role="dialog"]),
|
|
202
|
+
[class*="_overlay"]:has([role="dialog"]),
|
|
203
|
+
[class*="_modalWrapper"]:has([role="dialog"]) {
|
|
204
|
+
position: fixed !important;
|
|
205
|
+
inset: 0 !important;
|
|
206
|
+
display: flex !important;
|
|
207
|
+
align-items: center !important;
|
|
208
|
+
justify-content: center !important;
|
|
209
|
+
overflow: hidden !important;
|
|
210
|
+
z-index: 100005 !important;
|
|
211
|
+
background: rgba(0, 0, 0, 0.5) !important;
|
|
212
|
+
overscroll-behavior: contain !important;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* 移动端设置弹窗顶部固定提示:内容较宽(530px 等比微缩)需左右滑动浏览。
|
|
216
|
+
挂在 overlay 容器 ::before 上(absolute 定位相对 fixed overlay),
|
|
217
|
+
不随弹窗内容滚动,也不参与 flex 居中布局。内测声明等通用 Modal
|
|
218
|
+
(_root 容器,不在本选择器集合内)不会出现此提示。 */
|
|
219
|
+
[data-slot*="overlay"]:has([role="dialog"])::before,
|
|
220
|
+
[class*="_overlay"]:has([role="dialog"])::before,
|
|
221
|
+
[class*="_modalWrapper"]:has([role="dialog"])::before {
|
|
222
|
+
content: "↔ 左右滑动浏览设置";
|
|
223
|
+
position: absolute;
|
|
224
|
+
top: 16px;
|
|
225
|
+
left: 50%;
|
|
226
|
+
transform: translateX(-50%);
|
|
227
|
+
z-index: 100006 !important;
|
|
228
|
+
box-sizing: border-box;
|
|
229
|
+
white-space: nowrap;
|
|
230
|
+
max-width: calc(100vw - 32px);
|
|
231
|
+
overflow: hidden;
|
|
232
|
+
text-overflow: ellipsis;
|
|
233
|
+
background: rgba(0, 0, 0, 0.62);
|
|
234
|
+
color: #ffffff;
|
|
235
|
+
font-size: 12px;
|
|
236
|
+
line-height: 18px;
|
|
237
|
+
border-radius: 999px;
|
|
238
|
+
padding: 4px 14px;
|
|
239
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
|
240
|
+
pointer-events: none;
|
|
241
|
+
text-align: center;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* 弹窗卡片:仅命中设置弹窗——外层 overlay 容器内含 role=dialog(设置弹窗由
|
|
245
|
+
data-slot overlay / overlay 类包裹)。内测声明等通用 Modal 直接挂在 body、
|
|
246
|
+
无 data-slot overlay,天然排除,不会被 94vw / 530px 覆写。 */
|
|
247
|
+
[data-slot*="overlay"]:has([role="dialog"]) [role="dialog"],
|
|
248
|
+
[data-slot*="overlay"]:has([role="dialog"]) [class*="_dialog"],
|
|
249
|
+
[data-slot*="overlay"]:has([role="dialog"]) [class*="_modalContent"],
|
|
250
|
+
[class*="_overlay"]:has([role="dialog"]) [role="dialog"],
|
|
251
|
+
[class*="_overlay"]:has([role="dialog"]) [class*="_dialog"],
|
|
252
|
+
[class*="_overlay"]:has([role="dialog"]) [class*="_modalContent"],
|
|
253
|
+
[class*="_modalWrapper"]:has([role="dialog"]) [role="dialog"],
|
|
254
|
+
[class*="_modalWrapper"]:has([role="dialog"]) [class*="_dialog"],
|
|
255
|
+
[class*="_modalWrapper"]:has([role="dialog"]) [class*="_modalContent"],
|
|
256
|
+
[class*="_settingsModal"] {
|
|
257
|
+
position: relative !important;
|
|
258
|
+
width: 94vw !important;
|
|
259
|
+
max-width: 94vw !important;
|
|
260
|
+
height: 86vh !important;
|
|
261
|
+
height: 86dvh !important;
|
|
262
|
+
max-height: 88vh !important;
|
|
263
|
+
max-height: 88dvh !important;
|
|
264
|
+
margin: auto !important;
|
|
265
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4) !important;
|
|
266
|
+
border-radius: 16px !important;
|
|
267
|
+
background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
|
|
268
|
+
overflow-x: auto !important;
|
|
269
|
+
overflow-y: hidden !important;
|
|
270
|
+
-webkit-overflow-scrolling: touch !important;
|
|
271
|
+
overscroll-behavior: contain !important;
|
|
272
|
+
font-size: 13.5px !important;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* 弹窗内部等比微缩:dialog 的全部直接子元素(如设置弹窗的 nav 左侧按钮列表 +
|
|
276
|
+
content 右侧内容)统一 zoom 0.88,保证整窗一致缩放。 */
|
|
277
|
+
[data-slot*="overlay"]:has([role="dialog"]) [role="dialog"] > *,
|
|
278
|
+
[data-slot*="overlay"]:has([role="dialog"]) [class*="_dialog"] > *,
|
|
279
|
+
[data-slot*="overlay"]:has([role="dialog"]) [class*="_modalContent"] > *,
|
|
280
|
+
[class*="_overlay"]:has([role="dialog"]) [role="dialog"] > *,
|
|
281
|
+
[class*="_overlay"]:has([role="dialog"]) [class*="_dialog"] > *,
|
|
282
|
+
[class*="_overlay"]:has([role="dialog"]) [class*="_modalContent"] > *,
|
|
283
|
+
[class*="_modalWrapper"]:has([role="dialog"]) [role="dialog"] > *,
|
|
284
|
+
[class*="_modalWrapper"]:has([role="dialog"]) [class*="_dialog"] > *,
|
|
285
|
+
[class*="_modalWrapper"]:has([role="dialog"]) [class*="_modalContent"] > *,
|
|
286
|
+
[class*="_settingsModal"] > * {
|
|
287
|
+
zoom: 0.88 !important;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* 弹窗右侧内容主体(直接 div 子元素):固定最小宽度并铺满高度,单屏显示更多
|
|
291
|
+
内容且字号更精致。左侧导航列表不强制宽度,保持官方窄宽。
|
|
292
|
+
同样限定在设置弹窗上下文内,避免波及通用 Modal。 */
|
|
293
|
+
[data-slot*="overlay"]:has([role="dialog"]) [role="dialog"] > div,
|
|
294
|
+
[data-slot*="overlay"]:has([role="dialog"]) [class*="_dialog"] > div,
|
|
295
|
+
[data-slot*="overlay"]:has([role="dialog"]) [class*="_modalContent"] > div,
|
|
296
|
+
[class*="_overlay"]:has([role="dialog"]) [role="dialog"] > div,
|
|
297
|
+
[class*="_overlay"]:has([role="dialog"]) [class*="_dialog"] > div,
|
|
298
|
+
[class*="_overlay"]:has([role="dialog"]) [class*="_modalContent"] > div,
|
|
299
|
+
[class*="_modalWrapper"]:has([role="dialog"]) [role="dialog"] > div,
|
|
300
|
+
[class*="_modalWrapper"]:has([role="dialog"]) [class*="_dialog"] > div,
|
|
301
|
+
[class*="_modalWrapper"]:has([role="dialog"]) [class*="_modalContent"] > div,
|
|
302
|
+
[class*="_settingsModal"] > div {
|
|
303
|
+
min-width: 530px !important;
|
|
304
|
+
width: 530px !important;
|
|
305
|
+
height: 100% !important;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* 重置插件市场等弹窗在窄屏隐藏左侧 nav 的行为:保持导航列表可见。
|
|
309
|
+
该插件规则(@media max-width:560px 隐藏)无 !important,
|
|
310
|
+
我们同选择器加 !important 即可稳压。 */
|
|
311
|
+
@media (max-width: 560px) {
|
|
312
|
+
[role="dialog"]:has([data-dsh-market-root]) > nav {
|
|
313
|
+
display: block !important;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
`;
|
|
317
|
+
/** 正文预设:对话区紧凑高密度排版(小字号/紧凑行距/等宽代码/防溢出) */
|
|
318
|
+
const PRESET_MAIN_CSS = `
|
|
319
|
+
/* ===== dsh-remote-mobile · 对话正文高密度排版 ===== */
|
|
320
|
+
/* 注:窄屏媒体查询由注入层按端包裹 */
|
|
321
|
+
/* 目标:小字号 + 紧凑行距 + 收紧边距,让每行展示更多内容。
|
|
322
|
+
不做布局缩放(zoom 会挤压 1fr 网格轨道、产生右侧空白并使页面破版);
|
|
323
|
+
字号规则主要基于稳定 HTML 元素(p/h1/li/pre/code 不随 hash 变化),
|
|
324
|
+
内容容器用 CSS Modules localName 后缀(只有前缀 hash 随机,后缀稳定)。 */
|
|
325
|
+
|
|
326
|
+
/* 1. 消息内容容器:紧凑字号与行距 */
|
|
327
|
+
[class*="_centerCol"],
|
|
328
|
+
[data-slot^="conversation.chat"],
|
|
329
|
+
[class*="_markdown"],
|
|
330
|
+
[class*="_message"],
|
|
331
|
+
[class*="_bubble"],
|
|
332
|
+
[class*="_text"] {
|
|
333
|
+
font-size: 12.5px !important;
|
|
334
|
+
line-height: 1.45 !important;
|
|
335
|
+
letter-spacing: -0.01em !important;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* 2. HTML 元素级(稳定):段落与标题层级 */
|
|
339
|
+
[class*="_centerCol"] p,
|
|
340
|
+
[data-slot^="conversation.chat"] p,
|
|
341
|
+
[class*="_markdown"] p,
|
|
342
|
+
[class*="_message"] p,
|
|
343
|
+
[class*="_bubble"] p {
|
|
344
|
+
font-size: 12.5px !important;
|
|
345
|
+
line-height: 1.45 !important;
|
|
346
|
+
margin: 0 0 0.4em 0 !important;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
[class*="_centerCol"] h1,
|
|
350
|
+
[class*="_centerCol"] h2,
|
|
351
|
+
[class*="_centerCol"] h3,
|
|
352
|
+
[class*="_centerCol"] h4,
|
|
353
|
+
[data-slot^="conversation.chat"] h1,
|
|
354
|
+
[data-slot^="conversation.chat"] h2,
|
|
355
|
+
[data-slot^="conversation.chat"] h3,
|
|
356
|
+
[class*="_markdown"] h1,
|
|
357
|
+
[class*="_markdown"] h2,
|
|
358
|
+
[class*="_markdown"] h3 {
|
|
359
|
+
font-size: 13.5px !important;
|
|
360
|
+
font-weight: 700 !important;
|
|
361
|
+
margin: 0.5em 0 0.25em 0 !important;
|
|
362
|
+
line-height: 1.3 !important;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
[class*="_centerCol"] ul,
|
|
366
|
+
[class*="_centerCol"] ol,
|
|
367
|
+
[class*="_markdown"] ul,
|
|
368
|
+
[class*="_markdown"] ol {
|
|
369
|
+
margin: 0.2em 0 0.4em 0 !important;
|
|
370
|
+
padding-left: 1.2em !important;
|
|
371
|
+
}
|
|
372
|
+
[class*="_centerCol"] li,
|
|
373
|
+
[class*="_markdown"] li {
|
|
374
|
+
font-size: 12.5px !important;
|
|
375
|
+
line-height: 1.45 !important;
|
|
376
|
+
margin-bottom: 0.15em !important;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* 3. 行内代码与代码块:小等宽 + 防横向溢出 */
|
|
380
|
+
[class*="_centerCol"] code,
|
|
381
|
+
[data-slot^="conversation.chat"] code,
|
|
382
|
+
[class*="_markdown"] code {
|
|
383
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
|
|
384
|
+
font-size: 11.5px !important;
|
|
385
|
+
}
|
|
386
|
+
[class*="_centerCol"] pre,
|
|
387
|
+
[data-slot^="conversation.chat"] pre,
|
|
388
|
+
[class*="_markdown"] pre {
|
|
389
|
+
font-size: 11.5px !important;
|
|
390
|
+
line-height: 1.4 !important;
|
|
391
|
+
max-width: 100% !important;
|
|
392
|
+
overflow-x: auto !important;
|
|
393
|
+
-webkit-overflow-scrolling: touch !important;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* 表格:紧凑字号/内边距 + 窄屏横向滚动防撑破(display:block 让表格宽度受容器约束) */
|
|
397
|
+
[class*="_centerCol"] table,
|
|
398
|
+
[data-slot^="conversation.chat"] table,
|
|
399
|
+
[class*="_markdown"] table {
|
|
400
|
+
font-size: 11.5px !important;
|
|
401
|
+
line-height: 1.4 !important;
|
|
402
|
+
display: block !important;
|
|
403
|
+
max-width: 100% !important;
|
|
404
|
+
overflow-x: auto !important;
|
|
405
|
+
-webkit-overflow-scrolling: touch !important;
|
|
406
|
+
}
|
|
407
|
+
[class*="_centerCol"] table th,
|
|
408
|
+
[class*="_centerCol"] table td,
|
|
409
|
+
[data-slot^="conversation.chat"] table th,
|
|
410
|
+
[data-slot^="conversation.chat"] table td,
|
|
411
|
+
[class*="_markdown"] table th,
|
|
412
|
+
[class*="_markdown"] table td {
|
|
413
|
+
font-size: 11.5px !important;
|
|
414
|
+
line-height: 1.4 !important;
|
|
415
|
+
padding: 3px 7px !important;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/* 3.5 可折叠披露行(data-disclosure-row,如“Code/思考”等行):标题与摘要跟随紧凑字号 */
|
|
419
|
+
[class*="_centerCol"] [data-disclosure-row],
|
|
420
|
+
[data-slot^="conversation.chat"] [data-disclosure-row] {
|
|
421
|
+
font-size: 12.5px !important;
|
|
422
|
+
line-height: 1.45 !important;
|
|
423
|
+
}
|
|
424
|
+
[class*="_centerCol"] [data-disclosure-row] [class*="_title"],
|
|
425
|
+
[class*="_centerCol"] [data-disclosure-row] [class*="_summary"],
|
|
426
|
+
[data-slot^="conversation.chat"] [data-disclosure-row] [class*="_title"],
|
|
427
|
+
[data-slot^="conversation.chat"] [data-disclosure-row] [class*="_summary"] {
|
|
428
|
+
font-size: 12.5px !important;
|
|
429
|
+
line-height: 1.45 !important;
|
|
430
|
+
letter-spacing: -0.01em !important;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/* 3.7 消息列表双层滚动容器(wSkVaW_scrollBody + Md3f7G_scroll)padding 叠加:
|
|
434
|
+
官方各带 12~16px(合计每侧最多 28px),统一每层压到 8px(每侧合计 16px)。
|
|
435
|
+
均排除输入区滚动容器,避免误伤 composer。 */
|
|
436
|
+
[class*="_centerCol"] [class*="_scrollBody"],
|
|
437
|
+
[class*="_centerCol"] [class*="_scroll"]:not([data-composer-card] [class*="_scroll"]) {
|
|
438
|
+
padding: 8px 8px !important;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/* 4. 消息操作条(时间戳/复制等)与内行图标:显式收敛(SVG 不随字号缩放,需显式尺寸) */
|
|
442
|
+
[class*="_centerCol"] [class*="_actions"],
|
|
443
|
+
[data-slot^="conversation.chat"] [class*="_actions"] {
|
|
444
|
+
font-size: 11px !important;
|
|
445
|
+
line-height: 1.2 !important;
|
|
446
|
+
}
|
|
447
|
+
[class*="_centerCol"] [class*="_actions"] [class*="_timeStart"],
|
|
448
|
+
[class*="_centerCol"] [class*="_actions"] [class*="_timeEnd"],
|
|
449
|
+
[class*="_centerCol"] [class*="_actions"] [class*="_time"],
|
|
450
|
+
[data-slot^="conversation.chat"] [class*="_actions"] [class*="_timeStart"],
|
|
451
|
+
[data-slot^="conversation.chat"] [class*="_actions"] [class*="_timeEnd"],
|
|
452
|
+
[data-slot^="conversation.chat"] [class*="_actions"] [class*="_time"] {
|
|
453
|
+
font-size: 11px !important;
|
|
454
|
+
}
|
|
455
|
+
/* 用时统计内各指标/分隔点间距收紧(margin: 0) */
|
|
456
|
+
[class*="_centerCol"] [class*="_actions"] [class*="_timeEnd"] span,
|
|
457
|
+
[data-slot^="conversation.chat"] [class*="_actions"] [class*="_timeEnd"] span {
|
|
458
|
+
margin: 0 !important;
|
|
459
|
+
}
|
|
460
|
+
/* 统计行:单行优先,空间不足才折行(不再强制独占整行);折行后与消息左缘对齐(margin-left 清零) */
|
|
461
|
+
[class*="_centerCol"] [class*="_actions"] [class*="_timeEnd"],
|
|
462
|
+
[data-slot^="conversation.chat"] [class*="_actions"] [class*="_timeEnd"] {
|
|
463
|
+
flex: 0 1 auto !important;
|
|
464
|
+
min-width: 0 !important;
|
|
465
|
+
height: auto !important;
|
|
466
|
+
white-space: normal !important;
|
|
467
|
+
overflow: visible !important;
|
|
468
|
+
text-overflow: clip !important;
|
|
469
|
+
padding-left: 6px !important; /* 折行时也保留 6px 缩进,与上方内容区分 */
|
|
470
|
+
}
|
|
471
|
+
[class*="_centerCol"] [class*="_actions"],
|
|
472
|
+
[data-slot^="conversation.chat"] [class*="_actions"] {
|
|
473
|
+
flex-wrap: wrap !important;
|
|
474
|
+
height: auto !important;
|
|
475
|
+
overflow: visible !important;
|
|
476
|
+
}
|
|
477
|
+
[class*="_centerCol"] [class*="_actions"] svg,
|
|
478
|
+
[data-slot^="conversation.chat"] [class*="_actions"] svg,
|
|
479
|
+
[class*="_centerCol"] [class*="_leading"] svg,
|
|
480
|
+
[data-slot^="conversation.chat"] [class*="_leading"] svg {
|
|
481
|
+
width: 13px !important;
|
|
482
|
+
height: 13px !important;
|
|
483
|
+
max-width: 13px !important;
|
|
484
|
+
max-height: 13px !important;
|
|
485
|
+
vertical-align: middle !important;
|
|
486
|
+
flex: none !important;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* 5. 聊天输入区(composer)紧凑:ghost-textarea 三元件(backdrop 文字层 / input 输入层 / mirror 撑高层)
|
|
490
|
+
官方共用同一排版:card 16px/24px,三层皆 inherit,且官方 JS 读 mirror 的 lineHeight 做光标滚动定位。
|
|
491
|
+
因此必须整体改 card 一个基准让三层同步,绝不能只改其中一两层,否则光标随行数错位、高度不撑开。
|
|
492
|
+
注:插件已设 user-scalable=no,iOS 聚焦不会放大,14px 安全。 */
|
|
493
|
+
[data-composer-card="true"] {
|
|
494
|
+
font-size: 14px !important;
|
|
495
|
+
line-height: 20px !important;
|
|
496
|
+
}
|
|
497
|
+
/* ghost-textarea 三件套(backdrop 文字层 / input 输入层 / mirror 撑高层)全参数锁定:
|
|
498
|
+
与官方共享样式基准一致(仅字号行高改小),根除继承链差异 → 光标/高度/滚动定位同步 */
|
|
499
|
+
[data-composer-card="true"] textarea,
|
|
500
|
+
[data-composer-card="true"] [data-dsh-part="composer-input"],
|
|
501
|
+
[data-composer-card="true"] [data-input-mirror="true"],
|
|
502
|
+
[data-composer-card="true"] [data-input-backdrop="true"] {
|
|
503
|
+
font-family: var(--dsw-font-family, inherit) !important;
|
|
504
|
+
font-size: 14px !important;
|
|
505
|
+
line-height: 20px !important;
|
|
506
|
+
letter-spacing: 0 !important;
|
|
507
|
+
white-space: pre-wrap !important;
|
|
508
|
+
word-break: break-word !important;
|
|
509
|
+
overflow-wrap: anywhere !important;
|
|
510
|
+
padding: 4px 12px 0 16px !important;
|
|
511
|
+
}
|
|
512
|
+
[data-composer-card="true"] button,
|
|
513
|
+
[data-composer-card="true"] [role="button"],
|
|
514
|
+
[data-composer-card="true"] [class*="_triggerLabel"] {
|
|
515
|
+
font-size: 13px !important;
|
|
516
|
+
}
|
|
517
|
+
/* 输入框整体加宽:composer 外层容器左右留白 16px → 8px(:has 精准定位 card 的直接父,避免误伤其它 _root) */
|
|
518
|
+
[class*="_root"]:has(> [data-composer-card="true"]) {
|
|
519
|
+
padding: 0 8px 8px !important;
|
|
520
|
+
}
|
|
521
|
+
[data-composer-card="true"] svg {
|
|
522
|
+
width: 13px !important;
|
|
523
|
+
height: 13px !important;
|
|
524
|
+
max-width: 13px !important;
|
|
525
|
+
max-height: 13px !important;
|
|
526
|
+
flex: none !important;
|
|
527
|
+
}
|
|
528
|
+
/* composer 工具栏紧凑:保持两行布局不变,仅缩小控件尺寸。
|
|
529
|
+
以工具栏行(_row,即 uV2eYG_row)为锚点,避免误伤输入文本区内的
|
|
530
|
+
文本引用 / chip 等行内元素。特异性 (0,2,0)~ (0,3,0) 均高于官方单类 (0,1,0)。 */
|
|
531
|
+
[data-composer-card="true"] [class*="_row"] {
|
|
532
|
+
gap: 6px !important;
|
|
533
|
+
padding: 2px 6px 4px !important;
|
|
534
|
+
}
|
|
535
|
+
[data-composer-card="true"] [class*="_row"] [class*="_tools"],
|
|
536
|
+
[data-composer-card="true"] [class*="_row"] [class*="_modes"],
|
|
537
|
+
[data-composer-card="true"] [class*="_row"] [class*="_trailing"] {
|
|
538
|
+
gap: 6px !important;
|
|
539
|
+
}
|
|
540
|
+
[data-composer-card="true"] [class*="_row"] [class*="_add"],
|
|
541
|
+
[data-composer-card="true"] [class*="_row"] [class*="_trigger"] {
|
|
542
|
+
padding: 0 4px !important;
|
|
543
|
+
gap: 2px !important;
|
|
544
|
+
}
|
|
545
|
+
[data-composer-card="true"] [class*="_row"] [class*="_add"] {
|
|
546
|
+
width: 26px !important;
|
|
547
|
+
min-width: 26px !important;
|
|
548
|
+
height: 26px !important;
|
|
549
|
+
min-height: 26px !important;
|
|
550
|
+
}
|
|
551
|
+
[data-composer-card="true"] [class*="_row"] [class*="_triggerLabel"],
|
|
552
|
+
[data-composer-card="true"] [class*="_row"] [class*="_triggerEffort"] {
|
|
553
|
+
font-size: 12px !important;
|
|
554
|
+
line-height: 16px !important;
|
|
555
|
+
}
|
|
556
|
+
[data-composer-card="true"] [class*="_row"] [class*="_primary"] {
|
|
557
|
+
width: 30px !important;
|
|
558
|
+
height: 30px !important;
|
|
559
|
+
min-width: 30px !important;
|
|
560
|
+
min-height: 30px !important;
|
|
561
|
+
}
|
|
562
|
+
[data-composer-card="true"] [class*="_row"] svg {
|
|
563
|
+
width: 12px !important;
|
|
564
|
+
height: 12px !important;
|
|
565
|
+
max-width: 12px !important;
|
|
566
|
+
max-height: 12px !important;
|
|
567
|
+
flex: none !important;
|
|
568
|
+
}
|
|
569
|
+
`;
|
|
570
|
+
/**
|
|
571
|
+
* 内置官方预设:按界面区域划分的三段预设。
|
|
572
|
+
* 默认【移动端启用(≤900px 窄视口)、PC 关闭(>900px 宽视口)】——预设面向窄屏优化,宽屏按需开启。
|
|
573
|
+
*/
|
|
574
|
+
export const BUILTIN_PRESETS = [
|
|
575
|
+
{
|
|
576
|
+
id: 'preset-sidebar',
|
|
577
|
+
name: { zh: '侧边栏抽屉导航', en: 'Sidebar Drawer' },
|
|
578
|
+
description: {
|
|
579
|
+
zh: '折叠时侧边栏 0 宽度占用,官方展开按钮浮动在屏幕左上角并可拖动记忆位置;点击直接触发官方原生展开。',
|
|
580
|
+
en: 'Zero-width sidebar when collapsed with a draggable floating toggle; expands to the native official layout.',
|
|
581
|
+
},
|
|
582
|
+
defaultPcEnabled: false,
|
|
583
|
+
defaultMobileEnabled: true,
|
|
584
|
+
css: PRESET_SIDEBAR_CSS,
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
id: 'preset-settings',
|
|
588
|
+
name: { zh: '设置面板适配', en: 'Settings Panel' },
|
|
589
|
+
description: {
|
|
590
|
+
zh: '官方设置弹窗居中微缩显示更多内容,遮罩锁死滚动,内部支持左右横滑。',
|
|
591
|
+
en: 'Centers and scales down the settings dialog, locks overlay scroll, and enables horizontal panning.',
|
|
592
|
+
},
|
|
593
|
+
defaultPcEnabled: false,
|
|
594
|
+
defaultMobileEnabled: true,
|
|
595
|
+
css: PRESET_SETTINGS_CSS,
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
id: 'preset-main',
|
|
599
|
+
name: { zh: '正文紧凑排版', en: 'Conversation Layout' },
|
|
600
|
+
description: {
|
|
601
|
+
zh: '对话正文高密度排版:小字号(12.5px)+ 紧凑行距 + 收紧边距,每行展示更多内容;不做布局缩放以免产生轨道空白。',
|
|
602
|
+
en: 'High-density conversation typography: compact 12.5px text, tight line-height and margins → more content per line; no layout scaling so no gaps appear.',
|
|
603
|
+
},
|
|
604
|
+
defaultPcEnabled: false,
|
|
605
|
+
defaultMobileEnabled: true,
|
|
606
|
+
css: PRESET_MAIN_CSS,
|
|
607
|
+
},
|
|
608
|
+
];
|
|
609
|
+
/** 持久化文件的结构版本(v2:双端开关 presetStates + custom.pcEnabled/mobileEnabled) */
|
|
610
|
+
export const STORE_VERSION = 2;
|
|
611
|
+
/** 判断请求是否来自移动端浏览器 */
|
|
612
|
+
export function isMobileUserAgent(ua = '') {
|
|
613
|
+
return MOBILE_UA_REGEX.test(ua);
|
|
614
|
+
}
|
|
615
|
+
/** 给 <html> 打上 data-dsh-mobile 标记(幂等) */
|
|
616
|
+
export function applyDataMobileAttr(html) {
|
|
617
|
+
if (html.includes('data-dsh-mobile'))
|
|
618
|
+
return html;
|
|
619
|
+
return html.replace(/<html([^>]*)>/i, '<html$1 data-dsh-mobile="1">');
|
|
620
|
+
}
|
|
621
|
+
/** 把收集到的 CSS 包装成 <style> 注入标签(空内容返回空串) */
|
|
622
|
+
export function buildMobileStyleTag(css) {
|
|
623
|
+
if (!css)
|
|
624
|
+
return '';
|
|
625
|
+
// HTML raw-text 元素防护:转义 `</style`,防止片段意外/恶意提前闭合 <style> 标签结构
|
|
626
|
+
// (与 dsh-host-webserver 对 IndexInjection 'style' 行的约束一致:text 不得包含 </style)
|
|
627
|
+
const safeCss = css.replace(/<\/style/gi, '<\\/style');
|
|
628
|
+
return `<style id="dsh-remote-mobile-style-snippets">\n${safeCss.trim()}\n</style>`;
|
|
629
|
+
}
|
|
630
|
+
/** 简单的 UTF-8 校验,防止坏 JSON 拖垮存储 */
|
|
631
|
+
function isPlainObject(v) {
|
|
632
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* 样式片段存储:内置预设 + 用户自定义的启停状态与内容管理。
|
|
636
|
+
* 采用防抖 + 原子写盘,与 SessionStore 的持久化策略保持一致。
|
|
637
|
+
*/
|
|
638
|
+
export class StyleSnippetStore {
|
|
639
|
+
filePath;
|
|
640
|
+
presetStates = new Map();
|
|
641
|
+
custom = new Map();
|
|
642
|
+
customOrder = [];
|
|
643
|
+
persistTimer = null;
|
|
644
|
+
dirty = false;
|
|
645
|
+
constructor(filePath = DEFAULT_STYLE_FILE) {
|
|
646
|
+
this.filePath = filePath;
|
|
647
|
+
this.load();
|
|
648
|
+
// 注册到进程退出兜底落盘集合(与 SessionStore 保持一致):
|
|
649
|
+
// 保证剩余改动在事件循环清空时也能 flush 到磁盘
|
|
650
|
+
activeStores.add(this);
|
|
651
|
+
}
|
|
652
|
+
/** 文件路径(用于界面展示) */
|
|
653
|
+
get persistPath() {
|
|
654
|
+
return this.filePath;
|
|
655
|
+
}
|
|
656
|
+
/** 读取持久化文件(自动迁移 v1 → v2 双端开关格式) */
|
|
657
|
+
load() {
|
|
658
|
+
try {
|
|
659
|
+
if (!existsSync(this.filePath))
|
|
660
|
+
return;
|
|
661
|
+
const raw = readFileSync(this.filePath, 'utf8');
|
|
662
|
+
const data = JSON.parse(raw);
|
|
663
|
+
if (!isPlainObject(data))
|
|
664
|
+
return;
|
|
665
|
+
const version = typeof data.version === 'number' ? data.version : 1;
|
|
666
|
+
if (version >= 2 && isPlainObject(data.presetStates)) {
|
|
667
|
+
for (const [id, st] of Object.entries(data.presetStates)) {
|
|
668
|
+
if (!isPlainObject(st))
|
|
669
|
+
continue;
|
|
670
|
+
const rec = {};
|
|
671
|
+
if (typeof st.pc === 'boolean')
|
|
672
|
+
rec.pc = st.pc;
|
|
673
|
+
if (typeof st.mobile === 'boolean')
|
|
674
|
+
rec.mobile = st.mobile;
|
|
675
|
+
this.presetStates.set(id, rec);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
else if (isPlainObject(data.builtinEnabled)) {
|
|
679
|
+
// v1 迁移:旧语义为「enabled + mobileOnly」,内置预设全部面向移动端
|
|
680
|
+
for (const [id, enabled] of Object.entries(data.builtinEnabled)) {
|
|
681
|
+
this.presetStates.set(id, { pc: false, mobile: Boolean(enabled) });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const customs = Array.isArray(data.custom) ? data.custom : [];
|
|
685
|
+
for (const item of customs) {
|
|
686
|
+
if (!isPlainObject(item) || typeof item.id !== 'string' || typeof item.css !== 'string')
|
|
687
|
+
continue;
|
|
688
|
+
let pcEnabled;
|
|
689
|
+
let mobileEnabled;
|
|
690
|
+
if (typeof item.pcEnabled === 'boolean' || typeof item.mobileEnabled === 'boolean') {
|
|
691
|
+
pcEnabled = item.pcEnabled !== false;
|
|
692
|
+
mobileEnabled = item.mobileEnabled !== false;
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
// v1 自定义片段迁移:mobileOnly=true → 仅移动端;false → 双端
|
|
696
|
+
const enabled = item.enabled !== false;
|
|
697
|
+
const mobileOnly = item.mobileOnly !== false;
|
|
698
|
+
pcEnabled = enabled && !mobileOnly;
|
|
699
|
+
mobileEnabled = enabled;
|
|
700
|
+
}
|
|
701
|
+
this.custom.set(item.id, {
|
|
702
|
+
id: item.id,
|
|
703
|
+
name: typeof item.name === 'string' ? item.name : item.id,
|
|
704
|
+
description: typeof item.description === 'string' ? item.description : undefined,
|
|
705
|
+
css: item.css,
|
|
706
|
+
pcEnabled,
|
|
707
|
+
mobileEnabled,
|
|
708
|
+
createdAt: typeof item.createdAt === 'number' ? item.createdAt : undefined,
|
|
709
|
+
updatedAt: typeof item.updatedAt === 'number' ? item.updatedAt : undefined,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
const order = Array.isArray(data.customOrder) ? data.customOrder : [];
|
|
713
|
+
this.customOrder = order.filter((id) => typeof id === 'string' && this.custom.has(id));
|
|
714
|
+
// 未在 order 中的历史片段按创建时间补齐
|
|
715
|
+
for (const id of [...this.custom.keys()]) {
|
|
716
|
+
if (!this.customOrder.includes(id))
|
|
717
|
+
this.customOrder.push(id);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// 损坏文件不阻塞启动:保留原文件供排查,内存从空开始
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/** 触发防抖持久化 */
|
|
725
|
+
schedulePersist() {
|
|
726
|
+
this.dirty = true;
|
|
727
|
+
if (this.persistTimer)
|
|
728
|
+
return;
|
|
729
|
+
this.persistTimer = setTimeout(() => {
|
|
730
|
+
this.persistTimer = null;
|
|
731
|
+
this.flushPersistedData();
|
|
732
|
+
}, 300);
|
|
733
|
+
if (typeof this.persistTimer.unref === 'function') {
|
|
734
|
+
;
|
|
735
|
+
this.persistTimer.unref();
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/** 立即落盘(进程退出前也会被调用) */
|
|
739
|
+
flushPersistedData() {
|
|
740
|
+
if (this.persistTimer) {
|
|
741
|
+
clearTimeout(this.persistTimer);
|
|
742
|
+
this.persistTimer = null;
|
|
743
|
+
}
|
|
744
|
+
if (!this.dirty)
|
|
745
|
+
return;
|
|
746
|
+
this.dirty = false;
|
|
747
|
+
// 分组落盘:恒写入全部内置预设(preset-sidebar / preset-settings / preset-main 三组齐全),
|
|
748
|
+
// 每组为「用户覆盖 ?? 代码默认」的当前有效状态,pc/mobile 两字段齐全
|
|
749
|
+
const presetStates = {};
|
|
750
|
+
for (const preset of BUILTIN_PRESETS) {
|
|
751
|
+
const st = this.presetStates.get(preset.id) ?? {};
|
|
752
|
+
presetStates[preset.id] = {
|
|
753
|
+
pc: st.pc ?? preset.defaultPcEnabled ?? false,
|
|
754
|
+
mobile: st.mobile ?? preset.defaultMobileEnabled ?? true,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
const payload = {
|
|
758
|
+
version: STORE_VERSION,
|
|
759
|
+
presetStates,
|
|
760
|
+
custom: [...this.custom.values()],
|
|
761
|
+
customOrder: this.customOrder,
|
|
762
|
+
};
|
|
763
|
+
const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
764
|
+
try {
|
|
765
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
766
|
+
writeFileSync(tmpPath, JSON.stringify(payload, null, 2), 'utf8');
|
|
767
|
+
renameSync(tmpPath, this.filePath);
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
try {
|
|
771
|
+
if (existsSync(tmpPath))
|
|
772
|
+
unlinkSync(tmpPath);
|
|
773
|
+
}
|
|
774
|
+
catch { }
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
/** 全部片段:内置预设(按注册序)+ 自定义片段(按 customOrder)。lang 决定内置预设名称/描述的本地化。 */
|
|
778
|
+
list(lang = 'zh') {
|
|
779
|
+
const l = lang === 'en' ? 'en' : 'zh';
|
|
780
|
+
const out = [];
|
|
781
|
+
for (const preset of BUILTIN_PRESETS) {
|
|
782
|
+
const st = this.presetStates.get(preset.id) ?? {};
|
|
783
|
+
out.push({
|
|
784
|
+
id: preset.id,
|
|
785
|
+
name: preset.name[l],
|
|
786
|
+
description: preset.description[l],
|
|
787
|
+
css: preset.css,
|
|
788
|
+
pcEnabled: st.pc ?? preset.defaultPcEnabled ?? false,
|
|
789
|
+
mobileEnabled: st.mobile ?? preset.defaultMobileEnabled ?? true,
|
|
790
|
+
builtin: true,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
for (const id of this.customOrder) {
|
|
794
|
+
const s = this.custom.get(id);
|
|
795
|
+
if (s)
|
|
796
|
+
out.push({ ...s });
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
}
|
|
800
|
+
/** 按 id 获取片段(内置或自定义) */
|
|
801
|
+
get(id, lang = 'zh') {
|
|
802
|
+
return this.list(lang).find((s) => s.id === id);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* 按端启停任意片段(内置与自定义均可)。
|
|
806
|
+
* @param scope - pc:桌面端开关;mobile:移动端开关
|
|
807
|
+
*/
|
|
808
|
+
setEnabled(id, scope, enabled) {
|
|
809
|
+
if (this.custom.has(id)) {
|
|
810
|
+
const s = this.custom.get(id);
|
|
811
|
+
if (scope === 'pc')
|
|
812
|
+
s.pcEnabled = enabled;
|
|
813
|
+
else
|
|
814
|
+
s.mobileEnabled = enabled;
|
|
815
|
+
s.updatedAt = Date.now();
|
|
816
|
+
this.custom.set(id, s);
|
|
817
|
+
this.schedulePersist();
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
const preset = BUILTIN_PRESETS.find((p) => p.id === id);
|
|
821
|
+
if (preset) {
|
|
822
|
+
// 按端精确记录:仅写入被操作的那一端,未操作端保持缺省(运行时回落预设默认值)
|
|
823
|
+
const cur = this.presetStates.get(id) ?? {};
|
|
824
|
+
if (scope === 'pc')
|
|
825
|
+
cur.pc = enabled;
|
|
826
|
+
else
|
|
827
|
+
cur.mobile = enabled;
|
|
828
|
+
this.presetStates.set(id, cur);
|
|
829
|
+
this.schedulePersist();
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
/** 重置所有片段的启停状态:内置恢复各自默认(移动端开/PC 关),自定义恢复为移动端开/PC 关 */
|
|
835
|
+
resetEnabled() {
|
|
836
|
+
this.presetStates.clear();
|
|
837
|
+
for (const s of this.custom.values()) {
|
|
838
|
+
s.pcEnabled = false;
|
|
839
|
+
s.mobileEnabled = true;
|
|
840
|
+
}
|
|
841
|
+
this.schedulePersist();
|
|
842
|
+
}
|
|
843
|
+
/** 新增或更新一个自定义片段 */
|
|
844
|
+
upsertCustom(input) {
|
|
845
|
+
const name = String(input.name || '').trim();
|
|
846
|
+
const css = String(input.css || '');
|
|
847
|
+
if (!name)
|
|
848
|
+
throw new Error('snippet name is required');
|
|
849
|
+
if (name.length > MAX_NAME_LENGTH)
|
|
850
|
+
throw new Error('snippet name too long');
|
|
851
|
+
if (!css.trim())
|
|
852
|
+
throw new Error('snippet css is required');
|
|
853
|
+
if (css.length > MAX_CSS_LENGTH)
|
|
854
|
+
throw new Error('snippet css too large');
|
|
855
|
+
// 防冲突:自定义片段不得占用内置预设 id 或 preset- 保留前缀(否则 list/setEnabled/删除均会歧义)
|
|
856
|
+
if (input.id) {
|
|
857
|
+
if (BUILTIN_PRESETS.some((p) => p.id === input.id) || input.id.startsWith(BUILTIN_ID_PREFIX)) {
|
|
858
|
+
throw new Error('snippet id is reserved for builtin presets');
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const now = Date.now();
|
|
862
|
+
const existingId = input.id && this.custom.has(input.id) ? input.id : null;
|
|
863
|
+
const snippet = {
|
|
864
|
+
id: existingId || `custom-${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
865
|
+
name,
|
|
866
|
+
description: input.description ? String(input.description).trim() : undefined,
|
|
867
|
+
css,
|
|
868
|
+
pcEnabled: input.pcEnabled ?? false,
|
|
869
|
+
mobileEnabled: input.mobileEnabled ?? true,
|
|
870
|
+
createdAt: existingId ? this.custom.get(existingId).createdAt : now,
|
|
871
|
+
updatedAt: now,
|
|
872
|
+
};
|
|
873
|
+
if (existingId) {
|
|
874
|
+
this.custom.set(existingId, snippet);
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
this.custom.set(snippet.id, snippet);
|
|
878
|
+
this.customOrder.push(snippet.id);
|
|
879
|
+
}
|
|
880
|
+
this.schedulePersist();
|
|
881
|
+
return { ...snippet };
|
|
882
|
+
}
|
|
883
|
+
/** 删除一个用户自定义片段(内置预设不可删除) */
|
|
884
|
+
removeCustom(id) {
|
|
885
|
+
if (!this.custom.has(id))
|
|
886
|
+
return false;
|
|
887
|
+
this.custom.delete(id);
|
|
888
|
+
this.customOrder = this.customOrder.filter((x) => x !== id);
|
|
889
|
+
this.schedulePersist();
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* 收集最终注入的 CSS(与设备 UA 无关,按视口宽度档生效):
|
|
894
|
+
* - mobileEnabled && !pcEnabled → 包 @media (max-width: 900px),窄视口(≤900px)生效;
|
|
895
|
+
* - pcEnabled && !mobileEnabled → 包 @media (min-width: 901px),宽视口(>900px)生效;
|
|
896
|
+
* - 两端都开 → 原样注入,全宽度生效。
|
|
897
|
+
*/
|
|
898
|
+
collectCss() {
|
|
899
|
+
const parts = [];
|
|
900
|
+
for (const snippet of this.list()) {
|
|
901
|
+
const pc = snippet.pcEnabled;
|
|
902
|
+
const mobile = snippet.mobileEnabled;
|
|
903
|
+
if (!pc && !mobile)
|
|
904
|
+
continue;
|
|
905
|
+
const rawCss = snippet.css.trim();
|
|
906
|
+
let cssText;
|
|
907
|
+
if (pc && mobile) {
|
|
908
|
+
cssText = rawCss;
|
|
909
|
+
}
|
|
910
|
+
else if (mobile) {
|
|
911
|
+
cssText = `@media (max-width: 900px) {\n${rawCss}\n}`;
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
cssText = `@media (min-width: 901px) {\n${rawCss}\n}`;
|
|
915
|
+
}
|
|
916
|
+
parts.push(`/* ${snippet.name} (${snippet.id}) */\n${cssText}`);
|
|
917
|
+
}
|
|
918
|
+
return parts.join('\n\n');
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
// 进程退出前兜底落盘(与 SessionStore 思路一致)
|
|
922
|
+
const activeStores = new Set();
|
|
923
|
+
if (typeof process !== 'undefined' && typeof process.on === 'function') {
|
|
924
|
+
process.on('beforeExit', () => {
|
|
925
|
+
for (const s of activeStores) {
|
|
926
|
+
try {
|
|
927
|
+
s.flushPersistedData();
|
|
928
|
+
}
|
|
929
|
+
catch { }
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
}
|