dsh-ledger-memory 0.0.0-stage → 0.1.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/lib/client.js ADDED
@@ -0,0 +1,596 @@
1
+ /**
2
+ * dsh-ledger-memory —— 客户端半边(浏览器里跑的那一半)
3
+ * ============================================================================
4
+ *
5
+ * 形态:**经典脚本**,不是 ESM。
6
+ * ★ 不能出现顶层 `export` —— 浏览器半边靠 `window.__ModuleLoader__.load()`
7
+ * 注册(平台既定的加载协议)。参照本工作区另一个已验证插件的 `lib/client.js`。
8
+ *
9
+ * ★★ 四条硬约束(本工作区踩过真实事故,改代码前先读):
10
+ *
11
+ * ① `id` **必须等于包名**(`dsh-ledger-memory`)。
12
+ * 与 `package.json` 的 `name`、`cordis.patch.yml` 的 `id`/`name`、
13
+ * `lib/index.js` 的 `export const name` 一共四处身份位,漏改一处就是加载不上。
14
+ *
15
+ * ② `require()` 只用**平台共享模块表里的词**。
16
+ * 本文件**只 `require("react")`** —— 与那个插件同一个策略:
17
+ * 少引一个词就少一个"装了但界面全空"的失败模式,全部用
18
+ * `React.createElement` 手搓。
19
+ * ⚠️ 那条策略有**实证依据**:`dshmarket` 在渲染前专门检查
20
+ * `REQUIRED_PRIMITIVES`(Menu/DisclosureRow/Tooltip/Toast)在不在,
21
+ * 注释逐字写着 —— 缺一个就会 *"rendering would throw and blank the whole
22
+ * settings dialog"*(**把整个设置弹窗弄白**)。
23
+ * ⇒ 我们不引任何 UI 组件库,从根上没有这个失败模式。
24
+ *
25
+ * ③ `ctx.slots.inject(key, cb)` **必须是方法调用**(摘成局部变量会丢 `this`),
26
+ * 且 `cb` 的返回值是卸载函数。注册失败**必须记进 diagnostics + globalThis**,
27
+ * 不许静默吞 —— 否则现象是"bundle 加载了、界面一个挂载点都没有、还不报错"。
28
+ *
29
+ * ④ **CSS 只引用真实存在的 `--dsw-*` 变量**(本文件用的每一个都用
30
+ * `cordis_inspect_query(Theme)` 核过),且**自己画背景的元素必须同时显式定
31
+ * `color`**,不许靠 `inherit` —— 浅色主题下 `inherit` 拿到近黑文字,
32
+ * 压在自己给的深色背景上就看不见了(`7.模型路由` 2026-09-25 真实事故)。
33
+ *
34
+ * ── 这个面板挂哪、为什么挂这 ──────────────────────────────────────────────
35
+ * 挂 `settings.section`:`kind: "list"`、`scope: "root"`、`replaceRisk: "none"`
36
+ * ⇒ **纯新增**,不遮蔽官方 UI。设置导航里出现独立一页(与插件市场同一种做法)。
37
+ * ★ 该 slot 只投影 `id` / `order` / `label` **三个字段、没有 `icon`**
38
+ * (`dshmarket` 源码注释逐字:*"a registrant has no icon to pass … Every
39
+ * third-party section therefore wears the gear, the market included."*)
40
+ * ⇒ 市场是用 MutationObserver 改官方 DOM 硬塞图标的。**我们不学那个**(脆,
41
+ * 而且官方一改选择器就失效)。⭐ 但**用中文加括号标出身份**,见 LABEL。
42
+ *
43
+ * ── 数据从哪来 ────────────────────────────────────────────────────────────
44
+ * 面板**不自建存储**:读写都打到宿主半边开的两段路由(`webServer.register`,
45
+ * 前缀 `/dsh-ledger-memory-settings`)。宿主那边用官方 `configEditor.edit()`
46
+ * 落盘 ⇒ 唯一真源是 Loader entry 的 `config`,与内核实际在用的那份是同一个。
47
+ */
48
+
49
+ window.__ModuleLoader__.load({
50
+ id: "dsh-ledger-memory",
51
+ factory: (require) => {
52
+ "use strict";
53
+
54
+ const React = require("react");
55
+ const h = React.createElement;
56
+ const { useState, useEffect, useCallback, useRef } = React;
57
+
58
+ // ───────────────────────────────────────────────────────────────────────
59
+ // 常量
60
+ // ───────────────────────────────────────────────────────────────────────
61
+ const PLUGIN_ID = "dsh-ledger-memory";
62
+ const SLOT = "settings.section";
63
+ /** 导航项 id。★ 必须与官方已占用的 id 不同(已占:models / agent-presets / plugins / market)。 */
64
+ const SECTION_ID = "project-ledger";
65
+ /** 排在世界之后(市场是 40)。越小越靠上。 */
66
+ const SECTION_ORDER = 45;
67
+ /**
68
+ * 导航里的名字。
69
+ * ★ 括号里那半句是**故意的**:这个 slot 没有 `icon` 字段,第三方分区全都顶着
70
+ * 官方的齿轮图标(见文件头)。加一句"台账"让人一眼知道这是哪个插件,
71
+ * 而不必去猜那个齿轮是什么。
72
+ */
73
+ const LABEL = "项目台账(台账 / 压缩 / 日志)";
74
+ const API_BASE = "/" + PLUGIN_ID + "-settings";
75
+ const API_CONFIG = API_BASE + "/config.json";
76
+ const CSS_ID = PLUGIN_ID + "/client.css";
77
+
78
+ /** 水位档位那个文本输入框的提示(与宿主默认值一致)。 */
79
+ const TIERS_PLACEHOLDER = "0.3, 0.5, 0.7";
80
+
81
+ // 诊断:与那个插件同款。挂到 globalThis 供排障与自检读。
82
+ const diagnostics = { slots: {}, errors: [], at: null };
83
+
84
+ function recordSlot(id, ok, message) {
85
+ diagnostics.slots[id] = ok ? { ok: true } : { ok: false, message: String(message || "") };
86
+ if (!ok) diagnostics.errors.push(id + ": " + String(message || ""));
87
+ }
88
+
89
+ function safeRegister(ctx, options, component) {
90
+ try {
91
+ const d = ctx.slots.register(options, component);
92
+ recordSlot(options.id || options.key || "(unnamed)", true);
93
+ return typeof d === "function" ? d : () => {};
94
+ } catch (e) {
95
+ recordSlot(options.id || options.key || "(unnamed)", false, e && e.message ? e.message : String(e));
96
+ return () => {};
97
+ }
98
+ }
99
+
100
+ function errText(e) {
101
+ if (e === undefined || e === null) return "未知错误";
102
+ if (typeof e === "string") return e;
103
+ if (e.message) return e.message;
104
+ try {
105
+ return JSON.stringify(e);
106
+ } catch {
107
+ return String(e);
108
+ }
109
+ }
110
+
111
+ // ───────────────────────────────────────────────────────────────────────
112
+ // 纯函数(露给自检钉死;不依赖 DOM、不依赖 React)
113
+ // ───────────────────────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * 把"0.3, 0.5, 0.7"解析成 `[0.3, 0.5, 0.7]`。
117
+ *
118
+ * ★ 为什么用文本框而不是几个数字输入框:档位是**可变长**的(可以只要一档、
119
+ * 也可以四档),固定几个框反而要处理"空着的那几个算不算"。
120
+ * ★ 非法项**丢掉**而不是报错:用户打字打到一半("0.3,")不该让界面报红。
121
+ * 解析结果的去重与排序交给宿主(它那边是权威,会再归一化一遍)。
122
+ */
123
+ function parseTiers(text) {
124
+ if (typeof text !== "string") return [];
125
+ return text
126
+ .split(/[,,\s]+/)
127
+ .map((s) => s.trim())
128
+ .filter((s) => s !== "")
129
+ .map((s) => Number(s))
130
+ .filter((n) => Number.isFinite(n) && n > 0 && n < 1);
131
+ }
132
+
133
+ /** 把档位数组变回可编辑的文本。 */
134
+ function formatTiers(arr) {
135
+ if (!Array.isArray(arr)) return "";
136
+ return arr.filter((n) => Number.isFinite(n)).join(", ");
137
+ }
138
+
139
+ /**
140
+ * 按控件类型,把"界面上的值"变成"要提交的补丁"。
141
+ *
142
+ * ★ 为什么单独一个函数而不是散在 JSX 里:**自检要能在没有 React 的情况下
143
+ * 把"界面值 → 提交值"这条转换钉死**(尤其是 levels 与 tiers 这两个复合类型)。
144
+ */
145
+ function valueToPatch(spec, uiValue) {
146
+ if (spec.kind === "levels") return { [spec.key]: uiValue };
147
+ if (spec.kind === "tiers") return { [spec.key]: parseTiers(uiValue) };
148
+ if (spec.kind === "number") {
149
+ const n = Number(uiValue);
150
+ return { [spec.key]: Number.isFinite(n) ? n : undefined };
151
+ }
152
+ return { [spec.key]: uiValue };
153
+ }
154
+
155
+ /** 从当前设置里取出某个控件要显示的初值。 */
156
+ function settingsToUi(spec, settings) {
157
+ const v = settings === undefined ? undefined : settings[spec.key];
158
+ if (spec.kind === "tiers") return formatTiers(v);
159
+ if (spec.kind === "levels") return v === undefined || v === null ? {} : v;
160
+ if (spec.kind === "number") return v === undefined || v === null ? "" : String(v);
161
+ return v === true;
162
+ }
163
+
164
+ /** 两个值是否等价(用来判断"有没有真的改动")。 */
165
+ function sameValue(a, b) {
166
+ if (a === b) return true;
167
+ if (Array.isArray(a) && Array.isArray(b)) {
168
+ return a.length === b.length && a.every((x, i) => x === b[i]);
169
+ }
170
+ if (a && b && typeof a === "object" && typeof b === "object") {
171
+ const ka = Object.keys(a);
172
+ const kb = Object.keys(b);
173
+ return ka.length === kb.length && ka.every((k) => sameValue(a[k], b[k]));
174
+ }
175
+ return false;
176
+ }
177
+
178
+ /**
179
+ * 算出"草稿相对于已保存值"的差异 —— 只提交**改过的键**。
180
+ *
181
+ * ★★ 为什么只提交差异(而不是整份 settings):服务端是**按补丁合并**的,
182
+ * 整份提交会把"面板打开期间别处改过的键"覆盖回去 —— 那是典型的
183
+ * "用旧快照盖掉新改动"。只提交差异,冲突面就降到最小。
184
+ */
185
+ function diffPatch(specs, draft, saved) {
186
+ const out = {};
187
+ for (const spec of specs) {
188
+ const next = valueToPatch(spec, draft[spec.key]);
189
+ const prev = saved === undefined ? undefined : saved[spec.key];
190
+ if (next[spec.key] === undefined) continue; // 空输入框:不提交
191
+ if (!sameValue(next[spec.key], prev)) Object.assign(out, next);
192
+ }
193
+ return out;
194
+ }
195
+
196
+ // ───────────────────────────────────────────────────────────────────────
197
+ // HTTP
198
+ // ───────────────────────────────────────────────────────────────────────
199
+ async function fetchJson(url, options) {
200
+ const opts = Object.assign({ credentials: "same-origin", cache: "no-store" }, options || {});
201
+ const res = await fetch(url, opts);
202
+ const text = await res.text();
203
+ let data;
204
+ try {
205
+ data = text ? JSON.parse(text) : {};
206
+ } catch {
207
+ throw new Error("宿主返回的不是 JSON(HTTP " + res.status + "):" + text.slice(0, 200));
208
+ }
209
+ if (!res.ok && data && data.ok !== true) throw new Error(data.error || "HTTP " + res.status);
210
+ return data;
211
+ }
212
+
213
+ function postJson(url, body) {
214
+ return fetchJson(url, {
215
+ method: "POST",
216
+ headers: { "content-type": "application/json" },
217
+ body: JSON.stringify(body),
218
+ });
219
+ }
220
+
221
+ // ───────────────────────────────────────────────────────────────────────
222
+ // CSS(只引用**核实过存在**的 --dsw-* 变量;画背景的必同时定 color)
223
+ // ───────────────────────────────────────────────────────────────────────
224
+ const CSS = `
225
+ .pl-settings { display: flex; flex-direction: column; gap: 14px; padding: 4px 2px 18px; }
226
+ .pl-settings__intro { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.65; }
227
+ .pl-settings__row {
228
+ display: flex; gap: 14px; align-items: flex-start;
229
+ padding: 12px 14px;
230
+ background: var(--dsw-alias-bg-layer-1);
231
+ color: var(--dsw-alias-label-primary);
232
+ border: 1px solid var(--dsw-alias-border-l1);
233
+ border-radius: 10px;
234
+ }
235
+ .pl-settings__row--stack { flex-direction: column; gap: 8px; }
236
+ .pl-settings__main { flex: 1 1 auto; min-width: 0; }
237
+ .pl-settings__label { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); }
238
+ .pl-settings__hint { margin-top: 4px; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); }
239
+ .pl-settings__ctl { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
240
+ .pl-settings__levels { display: flex; flex-wrap: wrap; gap: 12px; }
241
+ .pl-settings__lvl { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--dsw-alias-label-primary); }
242
+ .pl-settings__num {
243
+ width: 110px; padding: 5px 8px; font-size: 13px; font-family: inherit;
244
+ background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary);
245
+ border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px;
246
+ }
247
+ .pl-settings__num--wide { width: 180px; }
248
+ .pl-settings__bar { display: flex; align-items: center; gap: 10px; padding-top: 4px; }
249
+ .pl-settings__btn {
250
+ padding: 6px 16px; font-size: 13px; font-family: inherit; cursor: pointer;
251
+ background: var(--dsw-alias-brand-primary); color: #ffffff;
252
+ border: 1px solid transparent; border-radius: 6px;
253
+ }
254
+ .pl-settings__btn:disabled { opacity: .5; cursor: not-allowed; }
255
+ .pl-settings__btn--ghost {
256
+ background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
257
+ border: 1px solid var(--dsw-alias-border-l2);
258
+ }
259
+ .pl-settings__msg { font-size: 12px; line-height: 1.6; }
260
+ .pl-settings__msg--ok { color: var(--dsw-alias-state-success-primary); }
261
+ .pl-settings__msg--err { color: var(--dsw-alias-state-error-primary); }
262
+ .pl-settings__msg--warn { color: var(--dsw-alias-state-warn-primary); }
263
+ .pl-settings__note {
264
+ padding: 10px 12px; font-size: 12px; line-height: 1.65;
265
+ background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary);
266
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px;
267
+ }
268
+ .pl-settings__mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
269
+ `;
270
+
271
+ function installCss(ctx) {
272
+ return ctx.effect(() => {
273
+ if (typeof document === "undefined") return () => {};
274
+ if (document.querySelector('style[data-plugin-css="' + CSS_ID + '"]') !== null) return () => {};
275
+ const tag = document.createElement("style");
276
+ tag.dataset.plugin = PLUGIN_ID;
277
+ tag.dataset.pluginCss = CSS_ID;
278
+ tag.textContent = CSS;
279
+ document.head.appendChild(tag);
280
+ return () => {
281
+ tag.remove();
282
+ };
283
+ }, PLUGIN_ID + ": styles");
284
+ }
285
+
286
+ // ───────────────────────────────────────────────────────────────────────
287
+ // 面板
288
+ // ───────────────────────────────────────────────────────────────────────
289
+
290
+ /** 一行:左边标题 + 说明,右边控件。 */
291
+ function Row(props) {
292
+ return h(
293
+ "div",
294
+ { className: "pl-settings__row" + (props.stack === true ? " pl-settings__row--stack" : "") },
295
+ h(
296
+ "div",
297
+ { className: "pl-settings__main" },
298
+ h("div", { className: "pl-settings__label" }, props.label),
299
+ props.hint ? h("div", { className: "pl-settings__hint" }, props.hint) : null
300
+ ),
301
+ h("div", { className: "pl-settings__ctl" }, props.children)
302
+ );
303
+ }
304
+
305
+ /** 布尔行。 */
306
+ function BoolRow(props) {
307
+ const { spec, value, onChange, disabled } = props;
308
+ return h(Row, { label: spec.label, hint: spec.hint }, [
309
+ h("input", {
310
+ key: "c",
311
+ type: "checkbox",
312
+ checked: value === true,
313
+ disabled: disabled === true,
314
+ onChange: (e) => onChange(e.target.checked),
315
+ }),
316
+ ]);
317
+ }
318
+
319
+ /** 数字行。 */
320
+ function NumberRow(props) {
321
+ const { spec, value, onChange, disabled } = props;
322
+ return h(Row, { label: spec.label, hint: spec.hint }, [
323
+ h("input", {
324
+ key: "n",
325
+ className: "pl-settings__num",
326
+ type: "number",
327
+ value: value,
328
+ min: spec.min,
329
+ max: spec.max,
330
+ step: spec.step,
331
+ disabled: disabled === true,
332
+ onChange: (e) => onChange(e.target.value),
333
+ }),
334
+ ]);
335
+ }
336
+
337
+ /** 档位行(文本框,逗号分隔)。 */
338
+ function TiersRow(props) {
339
+ const { spec, value, onChange, disabled } = props;
340
+ return h(Row, { label: spec.label, hint: spec.hint }, [
341
+ h("input", {
342
+ key: "t",
343
+ className: "pl-settings__num pl-settings__num--wide",
344
+ type: "text",
345
+ value: value,
346
+ placeholder: TIERS_PLACEHOLDER,
347
+ disabled: disabled === true,
348
+ onChange: (e) => onChange(e.target.value),
349
+ }),
350
+ ]);
351
+ }
352
+
353
+ /** 日志档位行(五档复选)。单独排一行(横排放不下)。 */
354
+ function LevelsRow(props) {
355
+ const { spec, value, onChange, disabled } = props;
356
+ const levels = ["L0", "L1", "L2", "L3", "L4"];
357
+ const names = {
358
+ L0: "L0 里程碑",
359
+ L1: "L1 回合摘要",
360
+ L2: "L2 文件流水",
361
+ L3: "L3 工具流水",
362
+ L4: "L4 用户原话",
363
+ };
364
+ return h(
365
+ "div",
366
+ { className: "pl-settings__row pl-settings__row--stack" },
367
+ h(
368
+ "div",
369
+ { className: "pl-settings__main" },
370
+ h("div", { className: "pl-settings__label" }, spec.label),
371
+ spec.hint ? h("div", { className: "pl-settings__hint" }, spec.hint) : null
372
+ ),
373
+ h(
374
+ "div",
375
+ { className: "pl-settings__levels" },
376
+ levels.map((k) =>
377
+ h(
378
+ "label",
379
+ { key: k, className: "pl-settings__lvl" },
380
+ h("input", {
381
+ type: "checkbox",
382
+ checked: value[k] === true,
383
+ disabled: disabled === true,
384
+ onChange: (e) => onChange(Object.assign({}, value, { [k]: e.target.checked })),
385
+ }),
386
+ h("span", null, names[k])
387
+ )
388
+ )
389
+ )
390
+ );
391
+ }
392
+
393
+ function controlFor(spec, draft, setDraft, disabled) {
394
+ const value = draft[spec.key];
395
+ const set = (v) => setDraft(Object.assign({}, draft, { [spec.key]: v }));
396
+ if (spec.kind === "boolean") return h(BoolRow, { key: spec.key, spec, value, onChange: set, disabled });
397
+ if (spec.kind === "number") return h(NumberRow, { key: spec.key, spec, value, onChange: set, disabled });
398
+ if (spec.kind === "tiers") return h(TiersRow, { key: spec.key, spec, value, onChange: set, disabled });
399
+ if (spec.kind === "levels") return h(LevelsRow, { key: spec.key, spec, value, onChange: set, disabled });
400
+ return null;
401
+ }
402
+
403
+ /**
404
+ * 设置页本体。
405
+ *
406
+ * 生命周期:挂载时 GET 一次;保存时 POST 差异补丁;保存成功后用服务端回报的
407
+ * **生效值**刷新草稿(不是用本地草稿 —— 服务端会归一化越界值,本地那份可能是错的)。
408
+ */
409
+ function SettingsPage() {
410
+ const [view, setView] = useState({ loading: true, error: null, data: null });
411
+ const [draft, setDraft] = useState(null);
412
+ const [saving, setSaving] = useState(false);
413
+ const [msg, setMsg] = useState(null);
414
+ const alive = useRef(true);
415
+
416
+ useEffect(() => {
417
+ alive.current = true;
418
+ fetchJson(API_CONFIG)
419
+ .then((data) => {
420
+ if (!alive.current) return;
421
+ setView({ loading: false, error: null, data });
422
+ setDraft(buildDraft(data));
423
+ })
424
+ .catch((e) => {
425
+ if (!alive.current) return;
426
+ setView({ loading: false, error: errText(e), data: null });
427
+ });
428
+ return () => {
429
+ alive.current = false;
430
+ };
431
+ }, []);
432
+
433
+ const onSave = useCallback(() => {
434
+ if (view.data === null || draft === null) return;
435
+ const patch = diffPatch(view.data.specs, draft, view.data.settings);
436
+ if (Object.keys(patch).length === 0) {
437
+ setMsg({ kind: "warn", text: "没有改动。" });
438
+ return;
439
+ }
440
+ setSaving(true);
441
+ setMsg(null);
442
+ postJson(API_CONFIG, { settings: patch })
443
+ .then((r) => {
444
+ if (!alive.current) return;
445
+ setSaving(false);
446
+ // ★ 用**服务端回报的生效值**刷新,而不是本地草稿:越界值会被夹紧
447
+ // (例如水位填 0.9 → 服务端夹到 0.79),本地那份是错的。
448
+ const next = Object.assign({}, view.data, { settings: r.settings });
449
+ setView(Object.assign({}, view, { data: next }));
450
+ setDraft(buildDraft(next));
451
+ const extra = [];
452
+ if (Array.isArray(r.clamped) && r.clamped.length > 0) extra.push("已夹紧:" + r.clamped.join("、"));
453
+ if (Array.isArray(r.dropped) && r.dropped.length > 0) extra.push("已忽略:" + r.dropped.join("、"));
454
+ setMsg({
455
+ kind: extra.length > 0 ? "warn" : "ok",
456
+ text: "已保存。" + (extra.length > 0 ? " " + extra.join(" ") : ""),
457
+ });
458
+ })
459
+ .catch((e) => {
460
+ if (!alive.current) return;
461
+ setSaving(false);
462
+ setMsg({ kind: "err", text: "保存失败:" + errText(e) });
463
+ });
464
+ }, [view, draft]);
465
+
466
+ const onReset = useCallback(() => {
467
+ if (view.data === null) return;
468
+ setDraft(buildDraft(view.data));
469
+ setMsg(null);
470
+ }, [view]);
471
+
472
+ if (view.loading) {
473
+ return h("div", { className: "pl-settings" }, h("div", { className: "pl-settings__intro" }, "正在读取设置…"));
474
+ }
475
+ if (view.error !== null) {
476
+ return h(
477
+ "div",
478
+ { className: "pl-settings" },
479
+ h("div", { className: "pl-settings__msg pl-settings__msg--err" }, "读取设置失败:" + view.error),
480
+ h(
481
+ "div",
482
+ { className: "pl-settings__note" },
483
+ "这条界面只影响设置面板本身,**台账功能不受影响** —— 插件照常工作。"
484
+ )
485
+ );
486
+ }
487
+
488
+ const data = view.data;
489
+ const disabled = data.writable !== true || saving;
490
+ const dirty = draft !== null && Object.keys(diffPatch(data.specs, draft, data.settings)).length > 0;
491
+
492
+ return h(
493
+ "div",
494
+ { className: "pl-settings" },
495
+ h(
496
+ "div",
497
+ { className: "pl-settings__intro" },
498
+ "这些是**插件级**默认设置。个别项目自己的选择(例如 .dsh-ledger.json 里的",
499
+ h("span", { className: "pl-settings__mono" }, " maxActiveLines"),
500
+ " 与 ",
501
+ h("span", { className: "pl-settings__mono" }, "logLevels"),
502
+ ")**优先于**这里的值 —— 这里管的是**新项目登记成什么**。"
503
+ ),
504
+ data.writable !== true
505
+ ? h(
506
+ "div",
507
+ { className: "pl-settings__note" },
508
+ "⚠ " + (data.why || "当前无法保存设置。") + "(下面显示的仍是实际生效的值。)"
509
+ )
510
+ : null,
511
+ draft === null ? null : data.specs.map((spec) => controlFor(spec, draft, setDraft, disabled)),
512
+ h(
513
+ "div",
514
+ { className: "pl-settings__bar" },
515
+ h(
516
+ "button",
517
+ {
518
+ className: "pl-settings__btn",
519
+ type: "button",
520
+ disabled: disabled || !dirty,
521
+ onClick: onSave,
522
+ },
523
+ saving ? "保存中…" : "保存"
524
+ ),
525
+ h(
526
+ "button",
527
+ {
528
+ className: "pl-settings__btn pl-settings__btn--ghost",
529
+ type: "button",
530
+ disabled: disabled || !dirty,
531
+ onClick: onReset,
532
+ },
533
+ "还原"
534
+ ),
535
+ msg === null
536
+ ? null
537
+ : h("span", { className: "pl-settings__msg pl-settings__msg--" + msg.kind }, msg.text)
538
+ )
539
+ );
540
+ }
541
+
542
+ /** 由服务端视图造一份可编辑草稿。 */
543
+ function buildDraft(data) {
544
+ const draft = {};
545
+ for (const spec of data.specs) draft[spec.key] = settingsToUi(spec, data.settings);
546
+ return draft;
547
+ }
548
+
549
+ // ───────────────────────────────────────────────────────────────────────
550
+ // 插件
551
+ // ───────────────────────────────────────────────────────────────────────
552
+ const name = PLUGIN_ID;
553
+
554
+ /**
555
+ * ★ 只写**确定存在**的服务。
556
+ * `slots` 是渲染器提供的一等服务。多写一个不存在的名字 = 插件**永远不 apply**
557
+ * = 界面什么都没有、**且不报错**。少写比多写安全。
558
+ */
559
+ const inject = ["slots"];
560
+
561
+ function apply(ctx) {
562
+ installCss(ctx);
563
+
564
+ ctx.slots.inject(SLOT, () =>
565
+ safeRegister(
566
+ ctx,
567
+ { name: SLOT, id: SECTION_ID, order: SECTION_ORDER, label: LABEL },
568
+ () => h(SettingsPage, null)
569
+ )
570
+ );
571
+
572
+ diagnostics.at = new Date().toISOString();
573
+ try {
574
+ globalThis.__dshProjectLedgerSettings = {
575
+ plugin: PLUGIN_ID,
576
+ slot: SLOT,
577
+ sectionId: SECTION_ID,
578
+ label: LABEL,
579
+ api: API_CONFIG,
580
+ diagnostics,
581
+ apiConfig: () => fetchJson(API_CONFIG),
582
+ /**
583
+ * ★ 把面板的**纯函数**露出来,只为可测与可排障。
584
+ * 露出来之后,自检可以在**不用真 React** 的情况下把它们逐条钉死。
585
+ * 它们本来就是闭包里的纯函数,露出来不改变任何行为。
586
+ */
587
+ helpers: { parseTiers, formatTiers, valueToPatch, settingsToUi, sameValue, diffPatch, buildDraft },
588
+ };
589
+ } catch {
590
+ /* 没有 globalThis 就算了。 */
591
+ }
592
+ }
593
+
594
+ return { name, inject, apply };
595
+ },
596
+ });