@nsnanocat/preference-panes 0.7.2 → 0.8.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.
@@ -1,11 +1,3 @@
1
- /**
2
- * 解析已经取得的 pathname,避免重复构造 URL。
3
- * Parse an existing pathname without constructing another URL.
4
- * @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
5
- * @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
6
- * @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
7
- */
8
-
9
1
  /**
10
2
  * 校验原始路径片段,不进行 URL 编码转换。
11
3
  * Validate raw path segments without URL encoding conversion.
@@ -116,7 +108,7 @@ function pageInputs(url, headers = {}) {
116
108
  const module = match[1];
117
109
  const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
118
110
  const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
119
- const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? `/settings/assets/${module}.css`;
111
+ const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
120
112
  if (!json.trim()) throw new TypeError("JSON resource URL is required");
121
113
  return { url: url.href, module, json, css };
122
114
  }
@@ -181,7 +173,123 @@ function errorView(error, retry) {
181
173
  return view;
182
174
  }
183
175
 
184
- var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
176
+ var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
177
+
178
+ /**
179
+ * 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
180
+ * Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
181
+ */
182
+ class ActionMenu {
183
+ #button;
184
+ #popup;
185
+ #backdrop;
186
+ #select;
187
+ #document;
188
+ #key = event => {
189
+ if (event.key === "Escape" && !this.#popup.hidden) {
190
+ event.preventDefault();
191
+ this.close();
192
+ this.#button.focus();
193
+ }
194
+ };
195
+
196
+ /**
197
+ * 创建菜单,操作逻辑由调用方提供。
198
+ * Create a menu whose actions are handled by the caller.
199
+ * @param {(id: string) => void} select 菜单选择回调 / Selection callback.
200
+ */
201
+ constructor(select) {
202
+ this.#document = document;
203
+ this.#select = select;
204
+ this.element = document.createElement("span");
205
+ const root = this.element.attachShadow({ mode: "open" });
206
+ root.innerHTML = `<style>
207
+ :host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}
208
+ :host([hidden]),[hidden]{display:none!important}
209
+ button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
210
+ button:disabled{opacity:.4;cursor:default}
211
+ button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
212
+ #trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
213
+ svg{display:block;width:24px;height:24px;fill:currentColor}
214
+ #backdrop{position:fixed;inset:0;z-index:1}
215
+ #items{position:absolute;right:0;top:46px;z-index:2;min-width:160px;padding:6px;background:var(--pp-surface,Canvas);color:var(--pp-text,CanvasText);border:1px solid var(--pp-border,#8884);border-radius:12px;box-shadow:0 8px 28px #0003}
216
+ #items button{display:block;text-align:left;white-space:nowrap;width:100%;padding:11px 14px;border-radius:8px;font:14px/1.4 system-ui,sans-serif}
217
+ #items button:hover{background:#8882}
218
+ #items button[data-danger]{color:#e45656}
219
+ </style><button id="trigger" type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false" aria-controls="items"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="4" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="20" cy="12" r="2"/></svg></button><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单" hidden></button><div id="items" role="menu" hidden></div>`;
220
+ this.#button = root.querySelector("#trigger");
221
+ this.#popup = root.querySelector("#items");
222
+ this.#backdrop = root.querySelector("#backdrop");
223
+ this.#button.onclick = () => {
224
+ const open = this.#popup.hidden;
225
+ this.#popup.hidden = this.#backdrop.hidden = !open;
226
+ this.#button.setAttribute("aria-expanded", String(open));
227
+ if (open) this.#popup.firstElementChild.focus();
228
+ };
229
+ this.#backdrop.onclick = () => this.close();
230
+ this.#popup.onkeydown = event => {
231
+ if (event.key === "Tab") {
232
+ this.close();
233
+ return;
234
+ }
235
+ const items = [...this.#popup.children];
236
+ const index = items.indexOf(root.activeElement);
237
+ const offsets = { ArrowDown: 1, ArrowUp: -1 };
238
+ if (event.key in offsets) {
239
+ event.preventDefault();
240
+ items[(index + offsets[event.key] + items.length) % items.length].focus();
241
+ }
242
+ };
243
+ document.addEventListener("keydown", this.#key);
244
+ this.update([]);
245
+ }
246
+
247
+ /**
248
+ * 同步可用操作和忙碌状态,不重建菜单触发按钮。
249
+ * Update actions and busy state without replacing the trigger button.
250
+ * @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
251
+ * @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
252
+ * @returns {void} 无返回值 / No return value.
253
+ */
254
+ update(items, disabled = false) {
255
+ this.close();
256
+ this.#button.disabled = disabled || items.length === 0;
257
+ this.#popup.replaceChildren(
258
+ ...items.map(item => {
259
+ const button = this.#document.createElement("button");
260
+ button.type = "button";
261
+ button.setAttribute("role", "menuitem");
262
+ button.textContent = item.label;
263
+ button.toggleAttribute("data-danger", Boolean(item.destructive));
264
+ button.onclick = () => {
265
+ this.close();
266
+ this.#select(item.id);
267
+ };
268
+ return button;
269
+ }),
270
+ );
271
+ }
272
+
273
+ /**
274
+ * 关闭菜单。
275
+ * Close the menu.
276
+ * @returns {void} 无返回值 / No return value.
277
+ */
278
+ close() {
279
+ this.#popup.hidden = this.#backdrop.hidden = true;
280
+ this.#button.setAttribute("aria-expanded", "false");
281
+ }
282
+
283
+ /**
284
+ * 移除监听器与节点。
285
+ * Remove listeners and elements.
286
+ * @returns {void} 无返回值 / No return value.
287
+ */
288
+ destroy() {
289
+ this.#document.removeEventListener("keydown", this.#key);
290
+ this.element.remove();
291
+ }
292
+ }
185
293
 
186
294
  /**
187
295
  * 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
@@ -322,31 +430,31 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
322
430
  */
323
431
  const sessions = new Map();
324
432
  /**
325
- * 发送同源请求,处理超时与取消;数据 GET 404 交给调用方处理。
326
- * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
327
- * @param {string} path 相对请求路径 / Relative request path.
328
- * @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
329
- * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
433
+ * form 发送完整存储键;读取 404 交给调用方处理。
434
+ * Send a complete storage key as form data; callers handle missing reads.
435
+ * @param {string} path 完整 @root.path / Complete @root.path.
436
+ * @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
437
+ * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
330
438
  * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
331
439
  * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
332
440
  * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
333
441
  */
334
- async function send(path, method, body, signal) {
442
+ async function send(path, action, body, signal) {
335
443
  const controller = new AbortController();
336
444
  const abort = () => controller.abort();
337
445
  if (signal?.aborted) abort();
338
446
  signal?.addEventListener("abort", abort, { once: true });
339
447
  const timer = setTimeout(abort, timeout);
340
448
  try {
341
- const response = await request(path, {
342
- method,
449
+ const response = await request(`/api/${action}`, {
450
+ method: "POST",
343
451
  credentials: "omit",
344
452
  cache: "no-store",
345
453
  signal: controller.signal,
346
- headers: { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
347
- ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
454
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
455
+ body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
348
456
  });
349
- if (response.status !== 200 && !(method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
457
+ if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
350
458
  return response;
351
459
  } finally {
352
460
  clearTimeout(timer);
@@ -370,21 +478,21 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
370
478
  * Serialize single-key mutations and update a still-active session only after success.
371
479
  * @param {string} module 已打开模块 / Open module.
372
480
  * @param {string} key 完整点分字段路径 / Complete dotted field path.
373
- * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
481
+ * @param {"set" | "delete"} action 写入或删除 / Write or delete.
374
482
  * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
375
483
  * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
376
484
  * @returns {Promise<void>} 操作完成 / Operation completion.
377
485
  * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
378
486
  */
379
- async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
487
+ async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
380
488
  const state = sessions.get(module);
381
489
  if (!state?.definition) throw new Error("Open the module first");
382
490
  if (state.saving) throw new Error("A settings write is already in progress");
383
491
  const field = state.definition.fields.find(field => field.key === key);
384
492
  state.saving = true;
385
493
  try {
386
- if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
387
- await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
494
+ if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
495
+ await send(`@${state.definition.storageKey}.${key}`, action, value);
388
496
  if (sessions.get(module) === state) {
389
497
  switch (operation) {
390
498
  case "write":
@@ -427,7 +535,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
427
535
  sessions.set(module, state);
428
536
  try {
429
537
  const definition = normalizeBoxJs(catalog.select(module), module);
430
- const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
538
+ const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
431
539
  let subtree = response.status === 404 ? {} : await response.json();
432
540
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
433
541
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
@@ -457,7 +565,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
457
565
  async readCaches(module) {
458
566
  const state = sessions.get(module);
459
567
  if (!state?.definition) throw new Error("Open the module first");
460
- const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
568
+ const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
461
569
  return response.status === 404 ? undefined : response.json();
462
570
  },
463
571
  /**
@@ -466,14 +574,14 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
466
574
  * @param {string} module 已打开模块 / Open module.
467
575
  * @returns {Promise<void>} 清理完成 / Cleanup completion.
468
576
  */
469
- clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
577
+ clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
470
578
  /**
471
579
  * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
472
580
  * Delete module persistence and reset the page cache using current BoxJS defaults.
473
581
  * @param {string} module 已打开模块 / Open module.
474
582
  * @returns {Promise<void>} 重置完成 / Reset completion.
475
583
  */
476
- reset: module => change(module, module, "DELETE", undefined, "reset"),
584
+ reset: module => change(module, module, "delete", undefined, "reset"),
477
585
  /**
478
586
  * 取消读取并清除会话,不撤销已发送的写入。
479
587
  * Abort reads and clear the session without undoing dispatched writes.
@@ -492,7 +600,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
492
600
  * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
493
601
  * @returns {Promise<void>} 写入完成 / Write completion.
494
602
  */
495
- set: (module, key, value) => change(module, key, "POST", value),
603
+ set: (module, key, value) => change(module, key, "set", value),
496
604
  /**
497
605
  * 删除单键覆盖值并显示默认值。
498
606
  * Delete one override and display its default value.
@@ -500,7 +608,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
500
608
  * @param {string} key 点分字段路径 / Dotted field path.
501
609
  * @returns {Promise<void>} 删除完成 / Delete completion.
502
610
  */
503
- remove: (module, key) => change(module, key, "DELETE"),
611
+ remove: (module, key) => change(module, key, "delete"),
504
612
  };
505
613
  }
506
614
 
@@ -681,6 +789,15 @@ function mountPanel(root, catalog) {
681
789
  back.setAttribute("aria-label", "返回");
682
790
  back.type = "button";
683
791
  const heading = element("h1", "pp-title", title);
792
+ const handlers = new Map();
793
+ const menuItems = [
794
+ { id: "viewCaches", label: "查看缓存" },
795
+ { id: "clearCaches", label: "清空缓存", destructive: true },
796
+ { id: "reset", label: "重置模块", destructive: true },
797
+ ];
798
+ const menu = new ActionMenu(id => handlers.get(id)());
799
+ const trailing = element("span", "pp-nav-spacer");
800
+ trailing.append(menu.element);
684
801
  const brand = element("div", "pp-brand");
685
802
  const logo = element("span", "pp-brand-icon");
686
803
  logo.setAttribute("aria-hidden", "true");
@@ -691,20 +808,26 @@ function mountPanel(root, catalog) {
691
808
  const toast = element("div", "pp-toast");
692
809
  toast.setAttribute("role", "status");
693
810
  toast.hidden = true;
694
- header.append(back, brand, element("span", "pp-nav-spacer"));
811
+ header.append(back, brand, trailing);
695
812
  shell.append(header, viewport, toast);
696
813
  root.append(shell);
697
814
  // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
698
815
  // Embedded mode publishes navigation state without host reads or mutations of the module DOM.
699
816
  const publishNavigation = () => {
817
+ const actions = handlers.size ? menuItems : [];
818
+ menu.update(actions, saving);
700
819
  const frame = window.frameElement;
701
820
  if (!frame?.dataset.preferencePanes) return;
702
821
  frame.dispatchEvent(
703
822
  new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
704
- detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
823
+ detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled, actions },
705
824
  }),
706
825
  );
707
826
  };
827
+ const onAction = event => {
828
+ if (!saving && handlers.has(event.detail)) handlers.get(event.detail)();
829
+ };
830
+ window.frameElement?.addEventListener("preferencepanes:action", onAction);
708
831
  let timer,
709
832
  navigation,
710
833
  generation = 0,
@@ -1014,17 +1137,13 @@ function mountPanel(root, catalog) {
1014
1137
  if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
1015
1138
  groups.get(group).append(row);
1016
1139
  }
1017
- const maintenance = element("section", "pp-maintenance");
1018
- maintenance.append(element("h2", "pp-title", "模块数据"));
1019
- const actions = element("div", "pp-actions");
1020
- const cacheView = element("button", "", "查看 Caches");
1021
- const cacheClear = element("button", "", "清空 Caches");
1022
- const reset = element("button", "pp-danger", "重置模块");
1140
+ const cachePage = element("section", "pp-cache-page");
1023
1141
  const output = element("pre", "pp-cache");
1024
- output.hidden = true;
1142
+ output.textContent = "暂无缓存";
1025
1143
  output.setAttribute("aria-label", "Caches 内容");
1026
- for (const button of [cacheView, cacheClear, reset]) button.type = "button";
1027
- cacheView.onclick = () => {
1144
+ cachePage.append(output);
1145
+ editors.set("$caches", { node: cachePage, title: "缓存" });
1146
+ handlers.set("viewCaches", () => {
1028
1147
  if (saving) return;
1029
1148
  let value;
1030
1149
  return perform(
@@ -1038,12 +1157,11 @@ function mountPanel(root, catalog) {
1038
1157
  },
1039
1158
  () => {
1040
1159
  output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
1041
- output.hidden = false;
1042
- cacheView.textContent = "刷新 Caches";
1160
+ navigation.open("$caches");
1043
1161
  },
1044
1162
  );
1045
- };
1046
- cacheClear.onclick = () => {
1163
+ });
1164
+ handlers.set("clearCaches", () => {
1047
1165
  if (saving) return;
1048
1166
  if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
1049
1167
  return perform(
@@ -1052,15 +1170,12 @@ function mountPanel(root, catalog) {
1052
1170
  output.textContent = "暂无缓存";
1053
1171
  },
1054
1172
  );
1055
- };
1056
- reset.onclick = () => {
1173
+ });
1174
+ handlers.set("reset", () => {
1057
1175
  if (saving) return;
1058
1176
  if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
1059
1177
  return perform(() => client.reset(active), controls);
1060
- };
1061
- actions.append(cacheView, cacheClear, reset);
1062
- maintenance.append(actions, output);
1063
- view.append(maintenance);
1178
+ });
1064
1179
  navigation?.destroy();
1065
1180
  navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
1066
1181
  navigation.addEventListener("change", updateNavigation);
@@ -1086,6 +1201,8 @@ function mountPanel(root, catalog) {
1086
1201
  */
1087
1202
  destroy() {
1088
1203
  destroyed = true;
1204
+ menu.destroy();
1205
+ window.frameElement?.removeEventListener("preferencepanes:action", onAction);
1089
1206
  navigation?.destroy();
1090
1207
  generation++;
1091
1208
  if (active && !saving) client.leave(active);
@@ -9,6 +9,6 @@
9
9
  </head>
10
10
  <body>
11
11
  <main id="preferences"></main>
12
- <script type="module" src="/settings/assets/app.mjs?v=0.7.2"></script>
12
+ <script type="module" src="/settings/assets/app.mjs?v=0.8.1"></script>
13
13
  </body>
14
14
  </html>