dsh-calorie 0.3.10 → 0.3.13

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/dist/client.js CHANGED
@@ -28,137 +28,52 @@ window.__ModuleLoader__.load({
28
28
  //#endregion
29
29
  let react = require("react");
30
30
  react = __toESM(react, 1);
31
- //#region src/contract.ts
32
- /** dsh-calorie 端口(port):DSH 世界与卡路里 hexagon 之间的契约。
31
+ //#region ../plugin-manager/dist/config-panel-contract.js
32
+ /** 共用配置面板 · 契约:行表形状、配置面回执、几条两侧共用的常量。
33
33
  *
34
- * browser 安全:零 node 导入。host 适配器(注册 RPC 通道)与 client
35
- * 适配器(调用 RPC)共用同一份定义;单测与 loader 回路以此为准。
36
- * 通道命名遵守宿主约束:单段且不等于 /api(见 cookbook「RPC 通道」)。
34
+ * 为什么住这里:六个单品插件的设置页各有一份逐字相同的那套代码(卡片骨架/行渲染/样式表/
35
+ * 状态机/配置面三通电话约 3105 行)。本件是抽件之后的**唯一一处定义地**。
36
+ *
37
+ * 本件不认识任何一家能力:行表由调用方给,分支只能由行表数据与配置面回执决定,
38
+ * 里面不出现任何一个能力的名字(结构纪律「依赖方向」)。
39
+ *
40
+ * 对外只经 `config-panel-api.ts` 那一道门;本件自己不出门。
37
41
  */
38
- const RPC_CHANNEL = "/ilife-calorie";
39
- const RPC_ENDPOINT_READ = "read";
40
- /** #676 配置端点:读设置页整面、保存一份取值、重置为默认。同走一条通道,靠端点名分发。 */
42
+ /** 面板对配置面的三通电话:读整面/保存一份取值/重置为默认。三个名字各家逐字相同,收在这里一处。 */
41
43
  const RPC_ENDPOINT_CONFIG_GET = "config.get";
42
44
  const RPC_ENDPOINT_CONFIG_SAVE = "config.save";
43
45
  const RPC_ENDPOINT_CONFIG_RESET = "config.reset";
44
- /** 面板默认读键(与 HITL 一致性对比同键)。 */
45
- const DEFAULT_READ_KEY = "calorie.view.home";
46
- /** 信封守卫:供 client 拆包前校验(port 拥有信封定义,守卫住 port)。 */
47
- function isRpcResult(raw) {
46
+ /** 高级组标题与副文案(六家逐字相同,收在这里一处)。 */
47
+ const ADVANCED_GROUP_TITLE = "高级";
48
+ const ADVANCED_GROUP_NOTE = "不常改。留空=用默认值。";
49
+ /** 请求超时毫秒:与宿主侧 SPAWN_TIMEOUT_MS 同级,界面永不无限转圈。 */
50
+ const READ_TIMEOUT_MS$1 = 2e4;
51
+ /** 复制那一枚按钮就地变「已复制」之后,多久变回「复制」(定稿 v3 第三条:1.5 秒,不弹提示)。 */
52
+ const COPY_FEEDBACK_MS = 1500;
53
+ /** 宿主给「这是哪一种目录选择能力」时用的那两格原语的名字(软依赖:按名字现取,缺席即没有入口)。 */
54
+ const REMOTE_DIRECTORY_PICKER = "remote.directoryPicker";
55
+ /** 宿主在「这条路不给」时回的那个码(`directory-browser-state` 的 `openRowBrowser` 只照比不认名)。 */
56
+ const DIRECTORY_PICKER_REFUSED = "directory-picker/unavailable";
57
+ /** 信封守卫:拆包前校验(信封形状的定义地在这里)。 */
58
+ function isRpcResult$1(raw) {
48
59
  if (typeof raw !== "object" || raw === null) return false;
49
- const ok = raw.ok;
50
- return ok === true || ok === false;
60
+ return raw.ok === true || raw.ok === false;
51
61
  }
52
62
  //#endregion
53
- //#region src/slot.ts
54
- const SLOT_TITLE = "卡路里";
55
- //#endregion
56
- //#region src/settings.ts
57
- /** 常用项:数据目录/库文件名/产物目录名/照片目录/训记 KEY(可改 3 + 只读 2)。 */
58
- const COMMON = [
59
- {
60
- key: "db.dir",
61
- title: "数据目录",
62
- tier: "common",
63
- control: "directory",
64
- prefillFrom: "dataDir",
65
- hint: "库文件与各类产物的根目录。留空=用默认目录。"
66
- },
67
- {
68
- key: "db.name",
69
- title: "库文件名",
70
- tier: "common",
71
- control: "text",
72
- readonly: true,
73
- resolveFrom: "dbFile",
74
- hint: "数据目录下的数据库文件。只读,要改请编辑配置文件。"
75
- },
76
- {
77
- key: "html.dir",
78
- title: "HTML 产物目录名",
79
- tier: "common",
80
- control: "text",
81
- readonly: true,
82
- resolveFrom: "htmlDir",
83
- hint: "数据目录下存放交付页面的子目录。只读,要改请编辑配置文件。"
84
- },
85
- {
86
- key: "photos.dir",
87
- title: "照片目录",
88
- tier: "common",
89
- control: "directory",
90
- prefillResolved: "photosDir",
91
- hint: "身材照的存放目录。留空=用默认目录(数据目录下的 photos)。"
92
- },
93
- {
94
- key: "xunji.key",
95
- title: "训记 KEY",
96
- tier: "common",
97
- control: "text",
98
- hint: "训记的凭据。留空=未配置,推送与回写会被拦下。"
99
- }
100
- ];
101
- /** 高级项:默认收起(只读 6 项)。改错了多半只是自己撞上麻烦,不确定就别动。 */
102
- const ADVANCED = [
103
- {
104
- key: "photos.gifs",
105
- title: "照片 GIF 子目录",
106
- tier: "advanced",
107
- control: "text",
108
- readonly: true,
109
- resolveFrom: "gifsDir",
110
- hint: "照片目录下存放 GIF 的子目录。只读,要改请编辑配置文件。"
111
- },
112
- {
113
- key: "xunji.stateDir",
114
- title: "训记状态文件目录",
115
- tier: "advanced",
116
- control: "directory",
117
- readonly: true,
118
- resolveFrom: "stateDir",
119
- hint: "训记的限频与同步游标存放目录。只读,要改请编辑配置文件。"
120
- },
121
- {
122
- key: "xunji.catalog",
123
- title: "训记动作库路径",
124
- tier: "advanced",
125
- control: "text",
126
- readonly: true,
127
- resolveFrom: "catalog",
128
- hint: "动作名判定用的动作库。只读,要改请编辑配置文件。"
129
- },
130
- {
131
- key: "xunji.backfillDays",
132
- title: "回写默认天数",
133
- tier: "advanced",
134
- control: "number",
135
- readonly: true,
136
- hint: "从训记拉实绩时默认回溯的天数。只读,要改请编辑配置文件。"
137
- },
138
- {
139
- key: "land.xunjiSeconds",
140
- title: "训记调用限时(秒)",
141
- tier: "advanced",
142
- control: "number",
143
- readonly: true,
144
- hint: "调用训记的超时秒数。只读,要改请编辑配置文件。"
145
- },
146
- {
147
- key: "land.landSeconds",
148
- title: "落地调用限时(秒)",
149
- tier: "advanced",
150
- control: "number",
151
- readonly: true,
152
- hint: "落地训练各步骤的超时秒数。只读,要改请编辑配置文件。"
153
- }
154
- ];
155
- /** 设置页的行表,顺序即页面顺序。 */
156
- const CONFIG_ITEMS = [...COMMON, ...ADVANCED];
157
- /** 常用项行数(页面上直接画出来的那些)。 */
158
- const COMMON_ITEM_COUNT = COMMON.length;
159
- /** 高级组标题与副文案。 */
160
- const ADVANCED_GROUP_TITLE = "高级";
161
- const ADVANCED_GROUP_NOTE = "不常改。留空=用默认值。";
63
+ //#region ../plugin-manager/dist/config-panel-value.js
64
+ /** 共用配置面板 · 取值与填值半:一份配置取值 ↔ 一张表单态,外加脏基线、跟随键、人话报错与三通电话。
65
+ *
66
+ * 本件**不认识 React**,也不认识宿主:喂一份配置面回执与一张行表,它把「页面上每一行该显示什么」
67
+ * 算出来(取值),再把「表单态里哪几格真能改」还原成一份取值(填值)。因此它在 Node 里就能直测。
68
+ *
69
+ * 三条口径(照 #749 起的定稿):
70
+ * · **只读行显示技能算好的绝对值**(回执 `resolved` 组那一格):面板一个字都不算;回执缺那一格
71
+ * (旧技能)就显示空串,绝不编一条路径出来。
72
+ * · **可改行取值空着**时,直接把回执里那个落点绝对路径显示上去——用户不必自己拼路径。
73
+ * · **保存只提交真能改的行**:只读行显示的是绝对路径,把它当配置值写回去就是把「显示」当「配置」。
74
+ *
75
+ * 对外只经 `config-panel-api.ts` 那一道门;本件自己不出门。
76
+ */
162
77
  /** 按 `a.b` 路径从配置取值里读(缺层或类型不符一律回 undefined)。 */
163
78
  function readPath(values, key) {
164
79
  const parts = key.split(".");
@@ -181,31 +96,142 @@ window.__ModuleLoader__.load({
181
96
  }
182
97
  cur[parts[parts.length - 1]] = value;
183
98
  }
184
- //#endregion
185
- //#region src/dsh-ctx.ts
186
- /** 宿主目录选择命名空间的服务名(#736;出处与禁令见 cookbook §13)。 */
187
- const REMOTE_DIRECTORY_PICKER = "remote.directoryPicker";
188
- /** 宿主「这条路没给」的回执码(#744):一次组合里只服务一种能力(native 或 browse),
189
- * 另一条路上的动词一律回它。
99
+ /** 落点来源取一格:先看回执 `resolved` 组同名格,再看回执顶层同名格(两种写法收成同一格)。
190
100
  *
191
- * 出处:`@deepseek-ai/dsh-api-workspace-controller/lib/types/directory-picker.js` 的 `requireCapability()`
192
- * ——`RemoteError('directory-picker/unavailable', …)`。三条动词在客户端命名空间上都在,
193
- * 所以只有真调一次、看它拒没拒,才知道组合里服务哪种能力。
194
- * 共用件不认识这个名字(只照比对),于是这条宿主事实住在本镜像里。 */
195
- const DIRECTORY_PICKER_REFUSED = "directory-picker/unavailable";
196
- /** 总管那条电话通道的两个字面量(#744):载体第一段与第二段。
101
+ * 顶层那一格为什么只认 `dataDir`:它是**技能算出来的生效数据目录**这个概念的格名,各家的回执
102
+ * 都叫它 `dataDir`;其余落点(照片目录、附件目录等)一律住在 `resolved` 组里。本件不猜别的名字。 */
103
+ function prefillValueOf(source, name) {
104
+ const fromResolved = source.resolved?.[name];
105
+ if (typeof fromResolved === "string" && fromResolved !== "") return fromResolved;
106
+ if (name === "dataDir" && typeof source.dataDir === "string" && source.dataDir !== "") return source.dataDir;
107
+ }
108
+ /** 只读行显示什么:标了 `resolveFrom` ⇒ 回执 `resolved` 组那一格(技能算好的绝对路径);
109
+ * 没标 ⇒ 配置文件里那个值本身(数字类只读项走这一档:显示的是生效数字)。 */
110
+ function readonlyTextOf(item, raw, source) {
111
+ if (item.resolveFrom === void 0) return raw;
112
+ const shown = source.resolved?.[item.resolveFrom];
113
+ return typeof shown === "string" ? shown : "";
114
+ }
115
+ /** 把配置取值铺成「行键 → 控件里的文本」(页面表单态;值缺项即空串,不返空留白)。 */
116
+ function toDraft(items, values, source) {
117
+ const draft = {};
118
+ for (const item of items) {
119
+ const v = readPath(values, item.key);
120
+ const raw = v === void 0 || v === null ? "" : String(v);
121
+ if (item.readonly === true) {
122
+ draft[item.key] = readonlyTextOf(item, raw, source);
123
+ continue;
124
+ }
125
+ const fallback = item.prefillFrom === void 0 ? void 0 : prefillValueOf(source, item.prefillFrom);
126
+ draft[item.key] = raw === "" && fallback !== void 0 ? fallback : raw;
127
+ }
128
+ return draft;
129
+ }
130
+ /** 表单态 → 配置取值(按控件种类还原类型;空串对文本项照收,语义由「按默认落点」承担)。
131
+ *
132
+ * **只读行不收**:它们显示的是技能算好的绝对路径,写回配置就是把「显示的路径」当成「配置值」——
133
+ * 保存只提交真能改的那些行,其余键由技能侧做组内合并保留现值。 */
134
+ function fromDraft(items, draft) {
135
+ const values = {};
136
+ for (const item of items) {
137
+ if (item.readonly === true) continue;
138
+ const raw = draft[item.key] ?? "";
139
+ if (item.control === "number") {
140
+ const n = Number(raw);
141
+ writePath(values, item.key, Number.isFinite(n) ? n : 0);
142
+ } else if (item.control === "switch") writePath(values, item.key, raw === "true");
143
+ else writePath(values, item.key, raw);
144
+ }
145
+ return values;
146
+ }
147
+ /** 可改行(脏基线只看这一档)。 */
148
+ function editableItemsOf(items) {
149
+ return items.filter((item) => item.readonly !== true);
150
+ }
151
+ /** 脏键:表单态里与「刚读到的整面」不一致的**可改行**。
152
+ *
153
+ * 只读行不进来:它们显示的是技能算好的绝对路径,拿它当「改动」会让面板一打开就显示「未保存」。 */
154
+ function dirtyKeysOf(items, draft, baseline) {
155
+ return editableItemsOf(items).filter((item) => (draft[item.key] ?? "") !== (baseline[item.key] ?? "")).map((item) => item.key);
156
+ }
157
+ /** 跟随映射的缺省:没有映射就是「一行都不跟随」。 */
158
+ function noFollowKeys() {
159
+ return [];
160
+ }
161
+ /** 配置面报错 → 人话指引。
197
162
  *
198
- * 出处:主管包 `packages/plugin-manager/src/update-contract.ts` 的 `MANAGER_RPC.base`/`.endpoint`
199
- * ——那边是一处定义,这边是镜像(本包不许 import 总管的宿主半:会把 node 内建带进浏览器束)。
200
- * 传错第一段就是 404:总管自己在 #678 栽过一次、各家的配置体检在 #735 又栽过一次。 */
201
- const MANAGER_RPC_BASE = "/api";
202
- const MANAGER_RPC_ENDPOINT = "ilife-manager";
203
- /** 总管那通「本机根清单」电话名(#744):入参 `{}`,回包 `{roots: [{path, kind}]}`。
163
+ * 报文由技能侧给出(`base-link-core` 的 `ConfigError` 已带行号与文件名,技能 `cli/config.ts`
164
+ * 原样交出,插件 bridge 原样带上),所以这里**不重写报文**,只按报错类补一句「接下来怎么办」。 */
165
+ function humanizeConfigFailure(code, message) {
166
+ const text = message.trim().length > 0 ? message.trim() : `(${code},宿主未给报文)`;
167
+ if (/解析|YAML|parse/i.test(text)) return `${text}\n改回「键: 值」的写法,或点「重置为默认」。`;
168
+ if (/不认识|未知键|UNKNOWN_KEY/i.test(text)) return `${text}\n删掉页面上没有的行,或点「重置为默认」。`;
169
+ if (/类型|TYPE_MISMATCH/i.test(text)) return `${text}\n按本页的控件形状填,或点「重置为默认」。`;
170
+ if (/缺席|missing-cli/i.test(text)) return `${text}\n技能出口没找到:先确认技能包已装好,再点「重新读取」。`;
171
+ return `${text}\n改不动就点「重置为默认」,或照上面那条路径手工改配置文件。`;
172
+ }
173
+ /** 有界等待:借平台 `AbortSignal.timeout` 的 abort 事件做**一次**超时拒绝。
204
174
  *
205
- * 出处:主管包 `update-contract.ts` 的 `MANAGER_ACTIONS.roots`。为什么要走总管:
206
- * 浏览器里没有卷清单接口,宿主那条目录选择接缝也只有 `list`/`createDirectory` 两格,
207
- * 「这台机器上有哪些盘」只能由宿主去问操作系统——那份实现在总管宿主半的 `roots.ts`。 */
208
- const MANAGER_ROOTS_METHOD = "ilife-manager.roots";
175
+ * 为什么不自建定时器:本仓冻结边界是「无数据轮询」,而设置页等的是一次电话回执,不是轮询;
176
+ * 用平台自带的一次性超时既守住边界的本意,也把「不转圈」落到实处。同一个 signal 也交给传输,
177
+ * 双保险:它搭理 signal 就早收工,不理也有这一道兜底(桌面端自研传输会忽略 signal,见 #760)。 */
178
+ function withDeadline(promise, ms) {
179
+ const signal = AbortSignal.timeout(ms);
180
+ const limit = new Promise((_, reject) => {
181
+ signal.addEventListener("abort", () => {
182
+ const e = /* @__PURE__ */ new Error(`配置请求超时(${Math.round(ms / 1e3)}s):宿主未回`);
183
+ e.name = "TimeoutError";
184
+ reject(e);
185
+ }, { once: true });
186
+ });
187
+ return Promise.race([promise, limit]);
188
+ }
189
+ /** 一次配置电话(走调用方给的那条通道,靠端点名分发;永不抛,失败落字)。 */
190
+ async function configRpc(call, channel, method, payload) {
191
+ if (typeof call !== "function") return {
192
+ ok: false,
193
+ message: "宿主连接缺席:connection.rpc.call 不可用"
194
+ };
195
+ try {
196
+ const raw = await withDeadline(call("/api", channel.replace(/^\//, ""), {
197
+ method,
198
+ payload
199
+ }, AbortSignal.timeout(READ_TIMEOUT_MS$1)), READ_TIMEOUT_MS$1);
200
+ if (!isRpcResult$1(raw)) return {
201
+ ok: false,
202
+ message: "回执信封异常(非 ok 信封)"
203
+ };
204
+ if (!raw.ok) return {
205
+ ok: false,
206
+ message: humanizeConfigFailure(raw.error?.code ?? "unknown", raw.error?.message ?? "")
207
+ };
208
+ return {
209
+ ok: true,
210
+ surface: raw.value
211
+ };
212
+ } catch (e) {
213
+ if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) return {
214
+ ok: false,
215
+ message: `配置请求超时(${Math.round(READ_TIMEOUT_MS$1 / 1e3)}s):宿主未回`
216
+ };
217
+ return {
218
+ ok: false,
219
+ message: `配置失败:${e instanceof Error ? e.message : String(e)}`
220
+ };
221
+ }
222
+ }
223
+ /** 读设置页整面。 */
224
+ function fetchConfigSurface(call, channel) {
225
+ return configRpc(call, channel, RPC_ENDPOINT_CONFIG_GET, {});
226
+ }
227
+ /** 保存一份取值(回执只有 `{path, values}`,**不是**整面:写完要重新读一份整面)。 */
228
+ function saveConfigSurface(call, channel, values) {
229
+ return configRpc(call, channel, RPC_ENDPOINT_CONFIG_SAVE, { values });
230
+ }
231
+ /** 重置为默认(技能侧先落一份备份;回执同样不是整面,写完要重新读一份整面)。 */
232
+ function resetConfigSurface(call, channel) {
233
+ return configRpc(call, channel, RPC_ENDPOINT_CONFIG_RESET, {});
234
+ }
209
235
  //#endregion
210
236
  //#region ../plugin-manager/dist/directory-browser-contract.js
211
237
  /** 目录浏览器的纯逻辑半(**不认识宿主**:只认两格原语)。
@@ -239,7 +265,7 @@ window.__ModuleLoader__.load({
239
265
  * 与组合里服务哪种能力无关),所以两格原语在=这台宿主**可能**给浏览;真调一次才知道给不给,
240
266
  * 不给({@link CAPABILITY_REFUSED})再由调用方换系统对话框。反过来先认 `pick` 就会在
241
267
  * Desktop 这种「只服务 browse」的宿主上每次都去唤一次系统对话框、被拒一次(#744 实测)。 */
242
- function pickerModeOf$1(raw) {
268
+ function pickerModeOf(raw) {
243
269
  if (isBrowseFace(raw)) return "browse";
244
270
  return hasPickFn(raw) ? "native" : "none";
245
271
  }
@@ -248,7 +274,7 @@ window.__ModuleLoader__.load({
248
274
  * 出处:`@deepseek-ai/dsh-api-gateway/lib/client.js` 的 `invoke()`(失败不抛、成功也不回裸值),
249
275
  * 第一方消费方 `@deepseek-ai/dsh-client-ui-workspace/lib/client.js:99-103` 就照这个信封拆。
250
276
  * 裸串照收(老形状兜底),认不出的形状当「供不了」报出来,不当取消吞掉(#743 的教训)。 */
251
- function readPickAnswer$1(raw) {
277
+ function readPickAnswer(raw) {
252
278
  if (typeof raw === "string") return raw.trim() === "" ? { kind: "cancelled" } : {
253
279
  kind: "picked",
254
280
  path: raw
@@ -643,48 +669,6 @@ window.__ModuleLoader__.load({
643
669
  });
644
670
  }
645
671
  //#endregion
646
- //#region ../plugin-manager/dist/directory-browser-roots.js
647
- /** 「本机根清单」的取数接线(客户端半):把一通电话变成图上那一行可跳转的根。
648
- *
649
- * **不认识宿主**:载体基段、通道名(第二段)、电话名全由调用方传进来——各家从自己的宿主镜像里拿
650
- * 那几个字面量(和 `openRowBrowser` 的 `refusalCode` 同一条规矩)。本件只做两件事:
651
- * 发一次调用、把回执信封拆成根清单;**任何失败都当空清单**(图上少画一行,不报错、不挡路)。
652
- */
653
- /** 认一认某个值像不像一个根(认不出的行丢掉:图的干净比多画一行重要)。 */
654
- function isRootRow(raw) {
655
- if (typeof raw !== "object" || raw === null) return false;
656
- const row = raw;
657
- return typeof row.path === "string" && row.path !== "" && typeof row.kind === "string";
658
- }
659
- /** 信封 → 根清单(纯函数,**永不抛**):`{ok:true, value:{roots:[…]}}` 之外的形状一律当空。 */
660
- function readRootsAnswer(raw) {
661
- const answer = typeof raw === "object" && raw !== null ? raw : {};
662
- if (answer.ok !== true) return [];
663
- const value = typeof answer.value === "object" && answer.value !== null ? answer.value : {};
664
- if (!Array.isArray(value.roots)) return [];
665
- return value.roots.filter(isRootRow).map((row) => ({
666
- path: row.path,
667
- kind: row.kind
668
- }));
669
- }
670
- /** 建一个「根清单取数器」:给调用口与三个名字,回一个 `() => Promise<RootRow[]>`。
671
- *
672
- * 界面按需调它(开图一次),不在渲染里调,也不缓存到这里——缓存是宿主半的事。 */
673
- function createRootsSource(input) {
674
- return async () => {
675
- const call = input.getCall();
676
- if (typeof call !== "function") return [];
677
- try {
678
- return readRootsAnswer(await call(input.base, input.endpoint, {
679
- method: input.method,
680
- payload: {}
681
- }));
682
- } catch {
683
- return [];
684
- }
685
- };
686
- }
687
- //#endregion
688
672
  //#region ../plugin-manager/dist/directory-browser-parts.js
689
673
  /** 文件浏览器视图半的**零件**:样式表与四行控件(面包屑/根/一行子目录/新建行)。
690
674
  *
@@ -1064,145 +1048,1177 @@ window.__ModuleLoader__.load({
1064
1048
  });
1065
1049
  }
1066
1050
  //#endregion
1067
- //#region src/client.ts
1068
- /** dsh-calorie client 适配器(六边形:port=contract+dsh-ctx 镜像,adapter=本文件)。
1051
+ //#region ../plugin-manager/dist/config-panel-view.js
1052
+ /** 共用配置面板 · 视图半:一行怎么画(`Row`)、整面怎么画(`PanelBody`)。
1069
1053
  *
1070
- * 产物经 tsdown 打成 loader 工厂包(browser/CJS,见 tsdown.config.ts)。
1071
- * 本文件传递闭包:禁 node、禁 DOM 直写(document/window/process)。
1072
- * 注册两处(grill 定案,新术语见 CONTEXT.md):
1073
- * (a) 技能设置页 → 爱生活页签槽(总管声明的 children,各技能自研自家配置页);
1074
- * (b) 技能功能页 → sidebar槽(better-sidebar 服务,非 DSH slot;软依赖,
1075
- * 缺席即跳过,不断链。DSH右侧槽 + sidebar.footer.action 彻底出局,已删)。
1076
- * 有界重试例外依据(唯一允许的定时器):注册重试非数据轮询,照抄
1077
- * dsh-mattpocock-skills-deck/src/client/panelAssembly.js:100-108
1078
- * (betterSidebar 服务可能晚于本模块到达,最多 10 次、间隔 1000ms;卸载清理)。
1079
- * 样式:React 内联 style + DSH 主题别名(var(--dsw-alias-*),带回退),无外部样式表、
1080
- * 无 <style> 注入、无类名冲突;只用 dsh-ctx 镜像内成员(slots.inject/register、
1081
- * connection.rpc.call 经闭包现取、betterSidebar 与 #736 的目录选择命名空间经 ctx.get 运行时取);
1082
- * 取数只经 connection.rpc.call 进 host 通道;缺席/错误纯条件渲染,不返空冒充。
1083
- * 组件 React.createElement 手写,不引入 JSX。
1054
+ * 本件是**纯函数**:吃一份状态与一组回调,回一棵 React 元素树,自己不留任何状态、不取任何数。
1055
+ * 为什么这么分:状态机与副作用住在 `config-panel.ts` 那一层,于是本件在 Node 里就能直测
1056
+ * (照 `directory-browser-ui.ts` 的既有作法:用例把组件当普通函数调,直接读回那棵树)。
1084
1057
  *
1085
- * #676:技能设置页从「只读设置行 + 状态读数」换成**真配置页**——行表来自 settings.ts,
1086
- * 取值来自宿主侧配置件(`config.get`/`config.save`/`config.reset` 三个端点)。
1087
- * 本文件仍禁 node:不读盘、不算默认值,默认值由宿主一并送来。
1088
- * 照 CONTEXT.md,「技能设置页」只配置、不干活:本页没有任何取数/记一餐入口,
1089
- * 状态读数也已移出(读数属技能功能页)。
1058
+ * 手写 `React.createElement`(本包 client 束禁 JSX)。样式表 `S` 就在本件里——面板怎么画只有这一处。
1059
+ *
1060
+ * **外观真源=`docs/agents/配置区域视觉模型-v3.1.html`(#920 阶段二起)**:本件的 `S` 逐项照它的
1061
+ * CSS(`:32-205`)、行模板照它的 `rowShell`/`masterShell`/`cardShell`(`:349-451`)、状态徽标照它的
1062
+ * `badgeHTML`(`:544-552`)那七档。**颜色/间距/圆角/字号/层级一律照取值,不许自己重新设计**;
1063
+ * 与本件不同的只有下面这四条(每条都在这里写明为什么,改之前先读 #920 的证据件):
1064
+ * ① **字号一律 em,没有 px**:跨包锁 `test/panel-type-739.test.mjs` 钉死「零 `font:` 简写/零字族/
1065
+ * 字号一律相对单位」——宿主没有界面字号 token,绝对 px 会在别的窗口尺寸上失真。v3.1 的 px 按它
1066
+ * 自己已锁死的那两对锚点换算(`13.5px↔1.08em`、`11.5px↔0.92em` ⇒ **1px=0.08em**),一个不丢。
1067
+ * ② **值列不写等宽字族**:v3.1 的值列要 `font-family: var(--ic-mono)`,而 `#739 ①` 明令共用件零
1068
+ * `fontFamily`/零 `font:` 简写 ⇒ 值继承宿主字族。这是与 v3.1 的**一处已知不同**(#920 证据件有账)。
1069
+ * ③ **换行判据交给 CSS,不按字符数估宽**:v3.1 靠 JS 量文本宽在两套模板间两选一(审查 P0-4/P0-5:
1070
+ * 每列 6.048px、真阈值 40–43 列而注释按 34 估 ⇒ 9 行被无谓拆两行、快面一次性长高 194px)。
1071
+ * 本件没有 DOM 可量,故照审查给的修法把同一个决定交给排版引擎:行是 `flex-wrap` 容器,标签列定宽、
1072
+ * 值列 `flex-basis:auto`(=内容的自然宽)——装不下时**整行让位**(值落第二行、按钮仍在第一行行尾,
1073
+ * 与 v3.1 的 `.is-wide` 同形);值再长就在自己那一行里 `overflow-wrap:anywhere` 折下去。
1074
+ * **零省略号、零截断**(`white-space` 不用 `nowrap`、`text-overflow` 一次都不出现)。
1075
+ * ④ **画不出来的三样**用等价形态顶上:分组箭头 `::before`/`[open]` 旋转/keyframes 动画内联样式
1076
+ * 给不出 ⇒ 画静态「›」与静态环(形状与 v3.1 同,只少了动)。
1077
+ *
1078
+ * 三条定稿(v3)在本件落地:
1079
+ * ① **只读行的目录按钮不画**(不画一枚点了也没反应的死按钮);
1080
+ * ② 目录行的按钮字面**两档合一**,一律「浏览文件夹」(入口供不了时整枚不画);
1081
+ * ③ **每行一枚「复制」**:复制这一行完好的值文本;点下去按钮就地变「已复制」再变回,不弹提示。
1082
+ *
1083
+ * 对外只经 `config-panel-api.ts` 那一道门;本件自己不出门。
1090
1084
  */
1091
- /** client 短名声明:只有这两个(#736 的目录选择走**可选查找**,不写进来——
1092
- * 写进来=硬依赖,提供方缺席时整包被停靠,设置页会跟着装不上;见 cookbook §13)。 */
1093
- const inject = ["slots", "connection"];
1094
- /** 面板视觉(内联 style;颜色走 DSH 主题别名,深浅主题自适应,写死值只做回退)。
1085
+ /** 面板的样式表:取值逐项照 v3.1(颜色走 DSH 主题别名,写死值只做回退;字号一律相对单位 em)。
1095
1086
  *
1096
- * **六家逐项同形**(备忘·卡路里·记账·作息·居家·大厨):同一项在这六份里逐字相同,
1097
- * 改任一条要六家一起改,锁见 `test/panel-copy-743.test.mjs` 第 ⑤ 条。 */
1087
+ * 键名一个都不许改:六家附加块真消费 `okText`/`error`/`rows`/`label`/`muted`/`hint`/`info`/
1088
+ * `bar`/`btn` 这九格(`plugin-memo-ilife/src/client.ts`、`plugin-schedule-ilife/src/client.ts` 的
1089
+ * `PanelStyleSlots`),跨包锁另钉 `card`/`input`/`btnPrimary`/`btnPick`/`summary`/`title`/`version`。 */
1098
1090
  const S = {
1091
+ /** `.ic-card`:`background:var(--ic-surface)`(=bg-layer-1)+`border:1px solid var(--ic-line-strong)`
1092
+ * +`border-radius:12px`+`padding:14px`+`box-shadow:inset 0 1px 0 rgba(255,255,255,.06)`。
1093
+ * 基准字号**不钉在卡片上**(#739 ⑤:跟着宿主走)。 */
1099
1094
  card: {
1100
- padding: "12px 14px",
1101
- borderRadius: 10,
1102
- border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.35))",
1103
- background: "var(--dsw-alias-bg-base, transparent)",
1104
- color: "var(--dsw-alias-label-primary, inherit)",
1105
- lineHeight: 1.6
1095
+ padding: 14,
1096
+ borderRadius: 12,
1097
+ border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
1098
+ background: "var(--dsw-alias-bg-layer-1, #232324)",
1099
+ color: "var(--dsw-alias-label-primary, #f9fafb)",
1100
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,.06)"
1101
+ },
1102
+ /** `.ic-head{display:flex;align-items:center;gap:10px;min-height:22px}`(徽标靠 `margin-left:auto` 右贴)。 */
1103
+ head: {
1104
+ display: "flex",
1105
+ alignItems: "center",
1106
+ gap: 10,
1107
+ minHeight: 22
1106
1108
  },
1109
+ /** `.ic-title{font-size:13.5px;font-weight:640}`——字号按 60 行那条锚点换算成 `1.08em`。 */
1107
1110
  title: {
1108
1111
  fontSize: "1.08em",
1109
- fontWeight: 700,
1110
- marginBottom: 8
1112
+ fontWeight: 640
1111
1113
  },
1112
- total: {
1113
- fontSize: 22,
1114
- fontWeight: 700,
1115
- margin: "2px 0 4px"
1114
+ /** `.ic-file{margin-top:4px}` 的那 4px 挂在两行的公共容器上:`info` 自己一格 margin 都不写
1115
+ * (#743 ①:头部两行间距不一家一个数,故这一项一律不写 margin)。 */
1116
+ fileWrap: { marginTop: 4 },
1117
+ /** `.ic-file b{color:var(--ic-faint);font-weight:400;margin-right:5px}`(「配置文件」「数据目录」那两个字)。 */
1118
+ fileLabel: {
1119
+ color: "var(--dsw-alias-label-tertiary, #adb2b8)",
1120
+ fontWeight: 400,
1121
+ marginRight: 5
1122
+ },
1123
+ /** `.ic-file{color:var(--ic-muted);font-size:11.5px;word-break:break-all}`(11.5px ⇒ `0.92em`)。 */
1124
+ info: {
1125
+ color: "var(--dsw-alias-label-secondary, #cfd3d6)",
1126
+ fontSize: "0.92em",
1127
+ wordBreak: "break-all"
1116
1128
  },
1129
+ /** `.ic-muted` 那一档(次要字)。 */
1117
1130
  muted: {
1118
- color: "var(--dsw-alias-label-secondary, #9a9a9a)",
1131
+ color: "var(--dsw-alias-label-secondary, #cfd3d6)",
1119
1132
  fontSize: "0.92em"
1120
1133
  },
1134
+ /** 错误字:取 v3.1 的 `--ic-danger`;字号 `1em`(#739 钉死)。 */
1121
1135
  error: {
1122
1136
  marginTop: 8,
1123
- color: "var(--dsw-alias-label-error, #b3261e)",
1137
+ color: "var(--dsw-alias-state-error-primary, #e0685f)",
1124
1138
  fontSize: "1em",
1125
1139
  whiteSpace: "pre-wrap"
1126
1140
  },
1141
+ /** 成功字:取 v3.1 的 `--ic-ok`;字号 `0.96em`(#739 钉死)。 */
1127
1142
  okText: {
1128
1143
  marginTop: 8,
1129
- color: "var(--dsw-alias-state-success-primary, #12805c)",
1144
+ color: "var(--dsw-alias-state-success-primary, #4ec9a0)",
1130
1145
  fontSize: "0.96em"
1131
1146
  },
1132
- rows: {
1133
- marginTop: 8,
1134
- borderTop: "1px solid var(--dsw-alias-border, rgba(128,128,128,.25))",
1135
- paddingTop: 8
1147
+ /** 行组的容器(六家附加块也拿它当外框;v3.1 没有对应类,故只给一个中性块)。 */
1148
+ rows: { display: "block" },
1149
+ /** `.ic-row`:三列栅格 + `gap:4px 10px` + `padding:7px 0` + 一条 `.ic-line` 下边线。
1150
+ * 本件把它落成**换行弹性盒**(见文件头 ③):标签定宽在第一行,值装不下时整行让位,
1151
+ * 动作槽绝对定位贴在行尾(`.ic-row .ic-slot[data-slot^="a:"]` 那一格)。 */
1152
+ row: {
1153
+ position: "relative",
1154
+ display: "flex",
1155
+ flexWrap: "wrap",
1156
+ alignItems: "center",
1157
+ gap: "4px 10px",
1158
+ padding: "7px 0",
1159
+ borderBottom: "1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.06))"
1136
1160
  },
1137
- version: {
1138
- marginTop: 8,
1139
- paddingTop: 8,
1140
- borderTop: "1px dashed var(--dsw-alias-border, rgba(128,128,128,.25))",
1141
- color: "var(--dsw-alias-label-tertiary, #8a8a8a)",
1142
- fontSize: "0.92em"
1161
+ /** `.ic-row:last-child{border-bottom:0}`——内联样式没有 `:last-child`,由画它的人明说。 */
1162
+ rowLast: {
1163
+ position: "relative",
1164
+ display: "flex",
1165
+ flexWrap: "wrap",
1166
+ alignItems: "center",
1167
+ gap: "4px 10px",
1168
+ padding: "7px 0"
1143
1169
  },
1144
- info: {
1145
- color: "var(--dsw-alias-label-secondary, #9a9a9a)",
1170
+ /** `.ic-under{margin:9px 0 0 8px;border-left:1px solid var(--ic-line-strong);padding-left:13px}`:
1171
+ * **跟随行**相对它的可改行(母版行)缩进 8+1+13=**22px**,左边挂一条线。
1172
+ *
1173
+ * 取值逐字照抄;只有一处形态差别要说清:`.ic-under` 在 v3.1 里是**一整块**(同一个可改行的一批
1174
+ * 跟随者共用一个 div、共一条左边线、块首还有 9px 上边距),而共用件手上只有「谁是跟随行」这一个
1175
+ * 集合(由六家交的 `followKeysOf` 钩子逐键探出),**没有**「这一行归哪一块」的分块归属
1176
+ * (同一个键可以同时挂在两张跟随表上,钩子只回一个去重后的并集)。故这里按**行**落这条缩进:
1177
+ * 缩进量与左边线同值,块首那 9px 上边距不落(落下去会在每两行之间多出一道 9px 的缝)。 */
1178
+ followerRow: {
1179
+ position: "relative",
1180
+ display: "flex",
1181
+ flexWrap: "wrap",
1182
+ alignItems: "center",
1183
+ gap: "4px 10px",
1184
+ padding: "7px 0 7px 13px",
1185
+ marginLeft: 8,
1186
+ borderLeft: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
1187
+ borderBottom: "1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.06))"
1188
+ },
1189
+ /** 跟随行的最后一行:同上,去掉下边线(照 `.ic-row:last-child{border-bottom:0}`)。 */
1190
+ followerRowLast: {
1191
+ position: "relative",
1192
+ display: "flex",
1193
+ flexWrap: "wrap",
1194
+ alignItems: "center",
1195
+ gap: "4px 10px",
1196
+ padding: "7px 0 7px 13px",
1197
+ marginLeft: 8,
1198
+ borderLeft: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))"
1199
+ },
1200
+ /** `.ic-row .ic-k{font-size:11.5px;color:var(--ic-muted)}`;`--ic-kw:118px` 是标签列的定宽。 */
1201
+ rowKey: {
1202
+ flex: "0 0 auto",
1203
+ minWidth: 118,
1146
1204
  fontSize: "0.92em",
1147
- overflowWrap: "anywhere"
1205
+ color: "var(--dsw-alias-label-secondary, #cfd3d6)"
1206
+ },
1207
+ /** `.ic-row .ic-v{font-size:11px;min-width:0}`(11px ⇒ `0.88em`)+ 文件头 ③ 的让位口径:
1208
+ * `flex:1 1 auto` ⇒ 装不下整行让位;`overflow-wrap:anywhere` ⇒ 折下去、零省略号;
1209
+ * `paddingRight` 给行尾那枚按钮(+列间距)让出宽度——就是 `.ic-row` 第三列那一格。 */
1210
+ rowValue: {
1211
+ flex: "1 1 auto",
1212
+ minWidth: 0,
1213
+ paddingRight: 56,
1214
+ fontSize: "0.88em",
1215
+ whiteSpace: "normal",
1216
+ overflowWrap: "anywhere",
1217
+ lineHeight: 1.5
1218
+ },
1219
+ /** 行尾动作槽(复制/跟随标记):绝对定位贴行尾,行一换行它仍留在第一行行尾(=`.is-wide` 那一形)。 */
1220
+ rowActs: {
1221
+ position: "absolute",
1222
+ right: 0,
1223
+ top: 7,
1224
+ display: "flex",
1225
+ gap: 4,
1226
+ alignItems: "center"
1148
1227
  },
1149
- row: { marginBottom: 10 },
1150
- label: { fontWeight: 600 },
1228
+ /** `.ic-follow{font-size:11px;color:var(--ic-attn);white-space:nowrap}`(11px ⇒ `0.88em`)。 */
1229
+ follow: {
1230
+ fontSize: "0.88em",
1231
+ color: "var(--dsw-alias-state-warn-primary, #e0a33e)",
1232
+ whiteSpace: "nowrap",
1233
+ display: "inline-flex",
1234
+ alignItems: "center",
1235
+ gap: 4
1236
+ },
1237
+ /** `.ic-follow::before{content:'↳';opacity:.8}`——内联样式给不出伪元素,画成真的那一个字符。 */
1238
+ followMark: { opacity: .8 },
1239
+ /** `.ic-master`:母版行那一块盒子(`--ic-sunken` 底 + `border-radius:8px` + `padding:11px 12px`
1240
+ * + `margin-top:11px` + 内高光)。 */
1241
+ master: {
1242
+ background: "var(--dsw-alias-bg-layer-3, #353638)",
1243
+ border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
1244
+ borderRadius: 8,
1245
+ padding: "11px 12px",
1246
+ marginTop: 11,
1247
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,.05)"
1248
+ },
1249
+ /** `.ic-master .ic-lab{font-weight:600;display:flex;align-items:center;gap:7px}`;
1250
+ * 字号**不写**(#739 ④:`label` 这一项该继承宿主字号)。 */
1251
+ label: {
1252
+ display: "flex",
1253
+ alignItems: "center",
1254
+ gap: 7,
1255
+ fontWeight: 600
1256
+ },
1257
+ /** `.ic-chip{font-size:10.5px;border:1px solid var(--ic-accent);border-radius:999px;padding:0 6px}`(10.5px ⇒ `0.84em`)。 */
1258
+ chip: {
1259
+ fontSize: "0.84em",
1260
+ border: "1px solid var(--dsw-alias-brand-primary, #f9fafb)",
1261
+ borderRadius: 999,
1262
+ padding: "0 6px",
1263
+ color: "var(--dsw-alias-brand-primary, #f9fafb)",
1264
+ whiteSpace: "nowrap"
1265
+ },
1266
+ /** `.ic-master .ic-hint{color:var(--ic-muted);font-size:11.5px;margin-top:3px;line-height:1.5}`。 */
1151
1267
  hint: {
1152
- color: "var(--dsw-alias-label-secondary, #9a9a9a)",
1268
+ color: "var(--dsw-alias-label-secondary, #cfd3d6)",
1153
1269
  fontSize: "0.92em",
1154
- marginBottom: 4
1270
+ marginTop: 3,
1271
+ lineHeight: 1.5
1155
1272
  },
1273
+ /** `.ic-master input`:整行宽、`margin-top:7px`、`padding:5px 8px`、`--ic-bg` 底、`--ic-line-strong` 描边、
1274
+ * `border-radius:6px`。字号与字族**不写**(#739 ④/①:`input` 继承宿主)。 */
1156
1275
  input: {
1276
+ display: "block",
1157
1277
  width: "100%",
1278
+ marginTop: 7,
1279
+ padding: "5px 8px",
1158
1280
  boxSizing: "border-box",
1159
- padding: "4px 8px",
1281
+ background: "var(--dsw-alias-bg-base, #151517)",
1282
+ border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
1160
1283
  borderRadius: 6,
1161
- border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.45))",
1162
- background: "var(--dsw-alias-bg-base, transparent)",
1163
- color: "var(--dsw-alias-label-primary, inherit)"
1284
+ color: "var(--dsw-alias-label-primary, #f9fafb)"
1285
+ },
1286
+ /** `.ic-master input.is-dirty`:`border:2px solid var(--ic-attn)` + `padding:4px 7px`
1287
+ * + `box-shadow:0 0 0 3px <琥珀 22%>`;2px 描边与 5→4 的内边距对消,宽高不变
1288
+ * (审查「站得住」第 7 条)。 */
1289
+ inputDirty: {
1290
+ display: "block",
1291
+ width: "100%",
1292
+ marginTop: 7,
1293
+ padding: "4px 7px",
1294
+ boxSizing: "border-box",
1295
+ background: "var(--dsw-alias-bg-base, #151517)",
1296
+ border: "2px solid var(--dsw-alias-state-warn-primary, #e0a33e)",
1297
+ borderRadius: 6,
1298
+ color: "var(--dsw-alias-label-primary, #f9fafb)",
1299
+ boxShadow: "0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-warn-primary, #e0a33e) 22%, transparent)"
1164
1300
  },
1165
- pickRow: {
1301
+ /** 布尔档那枚方框(v3.1 的母版行只有文本框,这一格按输入框的同一套外边距对齐)。 */
1302
+ checkbox: { marginTop: 7 },
1303
+ /** `.ic-acts{display:flex;gap:5px;margin-top:7px;flex-wrap:wrap;align-items:center}`(母版行的按钮行)。 */
1304
+ acts: {
1166
1305
  display: "flex",
1167
- gap: 6,
1306
+ gap: 5,
1307
+ marginTop: 7,
1308
+ flexWrap: "wrap",
1168
1309
  alignItems: "center"
1169
1310
  },
1311
+ /** `.ic-btn.is-mini{font-size:11px;padding:2px 7px;color:var(--ic-muted)}`——`btnPick` 是锁钉死的那一格,
1312
+ * 字号**不写**(#739 ④:该继承宿主字号),形状照 v3.1。 */
1170
1313
  btnPick: {
1171
- flex: "0 0 auto",
1172
- padding: "4px 10px",
1314
+ padding: "2px 7px",
1173
1315
  borderRadius: 6,
1174
- border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.45))",
1175
- background: "var(--dsw-alias-bg-base, transparent)",
1176
- color: "var(--dsw-alias-label-primary, inherit)",
1177
- whiteSpace: "nowrap",
1178
- cursor: "pointer"
1316
+ border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
1317
+ background: "transparent",
1318
+ color: "var(--dsw-alias-label-secondary, #cfd3d6)",
1319
+ cursor: "pointer",
1320
+ whiteSpace: "nowrap"
1321
+ },
1322
+ /** `.ic-invalid{margin-top:7px;padding:7px 9px;border-radius:6px;font-size:11.5px;line-height:1.5;
1323
+ * background:color-mix(in srgb, var(--ic-attn) 16%, transparent);border:1px solid var(--ic-attn)}`。 */
1324
+ invalid: {
1325
+ marginTop: 7,
1326
+ padding: "7px 9px",
1327
+ borderRadius: 6,
1328
+ fontSize: "0.92em",
1329
+ lineHeight: 1.5,
1330
+ background: "color-mix(in srgb, var(--dsw-alias-state-warn-primary, #e0a33e) 16%, transparent)",
1331
+ border: "1px solid var(--dsw-alias-state-warn-primary, #e0a33e)",
1332
+ color: "var(--dsw-alias-label-primary, #f9fafb)"
1333
+ },
1334
+ /** `.ic-group{margin-top:14px}`(高级组默认收起)。 */
1335
+ advanced: { marginTop: 14 },
1336
+ /** `.ic-group > .ic-ghead{display:flex;align-items:center;gap:8px;cursor:pointer;list-style:none;
1337
+ * font-weight:600;padding:2px 0}`——字号**不写**(#739 ④:`summary` 该继承宿主字号)。 */
1338
+ summary: {
1339
+ display: "flex",
1340
+ alignItems: "center",
1341
+ gap: 8,
1342
+ cursor: "pointer",
1343
+ listStyle: "none",
1344
+ fontWeight: 600,
1345
+ padding: "2px 0"
1179
1346
  },
1347
+ /** `.ic-ghead::before{content:'›';color:var(--ic-muted)}`——画成真的那一个字符(内联样式给不出伪元素)。 */
1348
+ groupMark: { color: "var(--dsw-alias-label-secondary, #cfd3d6)" },
1349
+ /** `.ic-ghead .ic-gnote{color:var(--ic-faint);font-weight:400;font-size:11.5px}`(11.5px ⇒ `0.92em`)。 */
1350
+ groupNote: {
1351
+ color: "var(--dsw-alias-label-tertiary, #adb2b8)",
1352
+ fontWeight: 400,
1353
+ fontSize: "0.92em"
1354
+ },
1355
+ /** `.ic-group > .ic-gbody{margin-top:9px;padding:11px 12px;background:var(--ic-sunken);
1356
+ * border-radius:8px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}`。 */
1357
+ groupBody: {
1358
+ marginTop: 9,
1359
+ padding: "11px 12px",
1360
+ background: "var(--dsw-alias-bg-layer-3, #353638)",
1361
+ border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
1362
+ borderRadius: 8,
1363
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,.04)"
1364
+ },
1365
+ /** `.ic-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:14px;padding-top:12px;
1366
+ * border-top:1px solid var(--ic-line)}` + **贴下沿吸住**(v3.1 的 `.ic-attnbar.is-sticky` 是它唯一的
1367
+ * 吸底口径:`position:sticky;bottom:6px;z-index:3`;本票按票面「贴下沿」取 0,好让底栏与下沿齐平)。 */
1180
1368
  bar: {
1181
1369
  display: "flex",
1182
1370
  gap: 8,
1183
- marginTop: 12,
1184
- flexWrap: "wrap"
1371
+ alignItems: "center",
1372
+ flexWrap: "wrap",
1373
+ marginTop: 14,
1374
+ paddingTop: 12,
1375
+ borderTop: "1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.06))",
1376
+ position: "sticky",
1377
+ bottom: 0,
1378
+ zIndex: 3,
1379
+ background: "var(--dsw-alias-bg-layer-1, #232324)"
1380
+ },
1381
+ /** `.ic-bar .ic-note{margin-left:auto;color:var(--ic-faint);font-size:11.5px}`(就绪态那半句)。 */
1382
+ barNote: {
1383
+ marginLeft: "auto",
1384
+ color: "var(--dsw-alias-label-tertiary, #adb2b8)",
1385
+ fontSize: "0.92em"
1386
+ },
1387
+ /** 有改动时底栏那半句(黄字,粘在按钮组左侧)。 */
1388
+ saveMsg: {
1389
+ marginLeft: "auto",
1390
+ color: "var(--dsw-alias-state-warn-primary, #e0a33e)",
1391
+ fontSize: "0.92em"
1185
1392
  },
1393
+ /** `.ic-btn{font-size:12px;padding:4px 10px;border-radius:6px;border:1px solid var(--ic-line-strong);
1394
+ * background:transparent;color:var(--ic-text)}`——字号**不写**(#739 ④)。 */
1186
1395
  btn: {
1187
- padding: "4px 12px",
1396
+ padding: "4px 10px",
1188
1397
  borderRadius: 6,
1189
- border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.45))",
1190
- background: "var(--dsw-alias-bg-base, transparent)",
1191
- color: "var(--dsw-alias-label-primary, inherit)",
1192
- cursor: "pointer"
1398
+ border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
1399
+ background: "transparent",
1400
+ color: "var(--dsw-alias-label-primary, #f9fafb)",
1401
+ cursor: "pointer",
1402
+ whiteSpace: "nowrap"
1403
+ },
1404
+ /** `.ic-btn.is-primary{background:var(--ic-accent);border-color:var(--ic-accent);color:var(--ic-accent-ink);
1405
+ * font-weight:500}`——`--ic-accent` 取 `--dsw-alias-brand-primary`,前景取 `label-primary-foreground`。 */
1406
+ btnPrimary: {
1407
+ padding: "4px 10px",
1408
+ borderRadius: 6,
1409
+ border: "1px solid var(--dsw-alias-brand-primary, #f9fafb)",
1410
+ background: "var(--dsw-alias-brand-primary, #f9fafb)",
1411
+ color: "var(--dsw-alias-label-primary-foreground, #0f1115)",
1412
+ fontWeight: 500,
1413
+ cursor: "pointer",
1414
+ whiteSpace: "nowrap"
1415
+ },
1416
+ /** 附加块(版本行/状态行)那份字面:`--ic-faint` + `0.92em`(#739 ③ 钉死这一格)。 */
1417
+ version: {
1418
+ marginTop: 8,
1419
+ paddingTop: 8,
1420
+ borderTop: "1px dashed var(--dsw-alias-border-l1, rgba(255,255,255,.06))",
1421
+ color: "var(--dsw-alias-label-tertiary, #adb2b8)",
1422
+ fontSize: "0.92em"
1423
+ },
1424
+ /** `.ic-badge{font-size:11px;line-height:1;padding:4px 9px;border-radius:999px;white-space:nowrap;
1425
+ * display:inline-flex;align-items:center;gap:6px;border:1px solid transparent}`(11px ⇒ `0.88em`)。 */
1426
+ badge: {
1427
+ fontSize: "0.88em",
1428
+ lineHeight: 1,
1429
+ padding: "4px 9px",
1430
+ borderRadius: 999,
1431
+ whiteSpace: "nowrap",
1432
+ display: "inline-flex",
1433
+ alignItems: "center",
1434
+ gap: 6,
1435
+ border: "1px solid transparent"
1436
+ },
1437
+ /** `.ic-badge .ic-bd{width:6px;height:6px;border-radius:50%;background:currentColor}`。 */
1438
+ badgeDot: {
1439
+ width: 6,
1440
+ height: 6,
1441
+ borderRadius: "50%",
1442
+ background: "currentColor"
1443
+ },
1444
+ /** `.ic-spin{width:11px;height:11px;border-radius:50%;border:1.5px solid rgba(0,0,0,.25);
1445
+ * border-top-color:currentColor}`——旋转那半要 `@keyframes`(内联样式给不出),故只画环。 */
1446
+ badgeSpinner: {
1447
+ width: 11,
1448
+ height: 11,
1449
+ borderRadius: "50%",
1450
+ border: "1.5px solid rgba(0,0,0,.25)",
1451
+ borderTopColor: "currentColor",
1452
+ display: "inline-block"
1453
+ },
1454
+ /** `.ic-badge.is-dirty`:`--ic-attn` 实底 + `--ic-attn-ink` 深字(那 3:1 是设计专门解过的对比度)。 */
1455
+ badgeDirty: {
1456
+ color: "#241703",
1457
+ background: "var(--dsw-alias-state-warn-primary, #e0a33e)",
1458
+ borderColor: "var(--dsw-alias-state-warn-primary, #e0a33e)",
1459
+ fontWeight: 600
1460
+ },
1461
+ /** `.ic-badge.is-busy`:与 dirty 同色(写的时候还在那一档上)。 */
1462
+ badgeBusy: {
1463
+ color: "#241703",
1464
+ background: "var(--dsw-alias-state-warn-primary, #e0a33e)",
1465
+ borderColor: "var(--dsw-alias-state-warn-primary, #e0a33e)",
1466
+ fontWeight: 600
1467
+ },
1468
+ /** `.ic-badge.is-ok{color:var(--ic-ok);border-color:var(--ic-ok);background:color-mix(in srgb, var(--ic-ok) 14%, transparent)}`。 */
1469
+ badgeOk: {
1470
+ color: "var(--dsw-alias-state-success-primary, #4ec9a0)",
1471
+ borderColor: "var(--dsw-alias-state-success-primary, #4ec9a0)",
1472
+ background: "color-mix(in srgb, var(--dsw-alias-state-success-primary, #4ec9a0) 14%, transparent)"
1473
+ },
1474
+ /** `.ic-badge.is-bad{color:var(--ic-danger-ink);background:var(--ic-danger);border-color:var(--ic-danger)}`。 */
1475
+ badgeBad: {
1476
+ color: "#fff5f4",
1477
+ background: "var(--dsw-alias-state-error-primary, #e0685f)",
1478
+ borderColor: "var(--dsw-alias-state-error-primary, #e0685f)",
1479
+ fontWeight: 600
1480
+ }
1481
+ };
1482
+ /** 复制那一枚按钮的字面(定稿 v3 第三条:就地变,不弹提示)。 */
1483
+ function copyLabelOf(state) {
1484
+ if (state === "done") return "已复制";
1485
+ if (state === "failed") return "复制失败";
1486
+ return "复制";
1487
+ }
1488
+ /** 状态 → 徽标那一档(v3.1 `paint()` 里的先后次序:读取失败/写入中/写入失败/值非法/有改动/已保存/就绪)。 */
1489
+ function panelBadgeOf(facts) {
1490
+ if (facts.readFailed) return "readFailed";
1491
+ if (facts.busy) return "busy";
1492
+ if (facts.writeFailed) return "writeFailed";
1493
+ if (facts.invalid) return "invalid";
1494
+ if (facts.dirtyCount > 0) return "dirty";
1495
+ if (facts.saved) return "saved";
1496
+ return "ready";
1497
+ }
1498
+ /** 徽标那一档画的字(v3.1 `badgeHTML` 的字面,只把「已保存 · 刚刚」的演示时刻换成档名)。 */
1499
+ function badgeTextOf(kind, dirtyCount) {
1500
+ if (kind === "dirty") return `${dirtyCount} 项未保存`;
1501
+ if (kind === "busy") return "正在写入";
1502
+ if (kind === "saved") return "已保存";
1503
+ if (kind === "invalid") return "配置里有值用不了";
1504
+ if (kind === "writeFailed") return "写入失败";
1505
+ return "";
1506
+ }
1507
+ /** 徽标那一档的底色(v3.1 的四条 `.ic-badge.is-*`)。 */
1508
+ function badgeStyleOf(kind) {
1509
+ if (kind === "dirty" || kind === "invalid") return {
1510
+ ...S.badge,
1511
+ ...S.badgeDirty
1512
+ };
1513
+ if (kind === "busy") return {
1514
+ ...S.badge,
1515
+ ...S.badgeBusy
1516
+ };
1517
+ if (kind === "saved") return {
1518
+ ...S.badge,
1519
+ ...S.badgeOk
1520
+ };
1521
+ if (kind === "writeFailed") return {
1522
+ ...S.badge,
1523
+ ...S.badgeBad
1524
+ };
1525
+ return S.badge;
1526
+ }
1527
+ /** 画卡片标题行右端那枚状态徽标(就绪态与读取失败那一支不画)。 */
1528
+ function PanelBadgeView(props) {
1529
+ const text = badgeTextOf(props.kind, props.dirtyCount);
1530
+ if (text === "") return null;
1531
+ return react.createElement("span", { style: badgeStyleOf(props.kind) }, props.kind === "busy" ? react.createElement("span", { style: S.badgeSpinner }) : react.createElement("span", { style: S.badgeDot }), text);
1532
+ }
1533
+ /** 一行怎么画(照 v3.1 的两套行模板):
1534
+ *
1535
+ * **只读行**(`.ic-row`)=标签—值同行、值装不下整行让位、行尾一枚「复制」——**不画输入框**
1536
+ * (值来自技能侧解析,页面上本来就不给改),也不画那行说明(v3.1 的 `rowShell` 里没有它)。
1537
+ * **可改行**(`.ic-master`)=标签 +「可改」小徽标 + 说明 + 整行输入框 + 按钮行。
1538
+ *
1539
+ * 目录档=文本框 + 一枚唤起目录选择的按钮;入口三态是 `none`(命名空间拿不到、或这条路已被拒)
1540
+ * **就不画按钮**,文本框照旧——那是该缝自己的契约(供不了就收起入口,不是失败)。
1541
+ * 只读行:目录入口**一枚都不画**(定稿 v3 ①)。 */
1542
+ function Row(props) {
1543
+ const { item } = props;
1544
+ const readonly = item.readonly === true;
1545
+ const disabled = props.disabled || readonly;
1546
+ const copy = react.createElement("button", {
1547
+ style: S.btnPick,
1548
+ type: "button",
1549
+ disabled: props.onCopy === void 0,
1550
+ onClick: () => props.onCopy?.(item.key, props.value)
1551
+ }, copyLabelOf(props.copyState));
1552
+ const follow = props.follow === true ? react.createElement("span", { style: S.follow }, react.createElement("span", { style: S.followMark }, "↳"), "将跟随更新") : null;
1553
+ if (readonly) {
1554
+ /** 跟随行落 `.ic-under` 那一档缩进;其余按是不是本组最后一行挑有没有下边线。 */
1555
+ const rowStyle = props.nested === true ? props.last === true ? S.followerRowLast : S.followerRow : props.last === true ? S.rowLast : S.row;
1556
+ return react.createElement("div", { style: rowStyle }, react.createElement("span", { style: S.rowKey }, item.title), react.createElement("span", { style: S.rowValue }, props.value), react.createElement("span", { style: S.rowActs }, follow, copy));
1557
+ }
1558
+ const onChange = (e) => props.onChange(item.key, e.target.value);
1559
+ const control = item.control === "switch" ? react.createElement("input", {
1560
+ type: "checkbox",
1561
+ style: S.checkbox,
1562
+ checked: props.value === "true",
1563
+ disabled,
1564
+ onChange: (e) => props.onChange(item.key, e.target.checked ? "true" : "false")
1565
+ }) : react.createElement("input", {
1566
+ style: props.dirty === true ? S.inputDirty : S.input,
1567
+ type: item.control === "number" ? "number" : "text",
1568
+ value: props.value,
1569
+ disabled,
1570
+ spellCheck: false,
1571
+ onChange
1572
+ });
1573
+ /** 目录入口:只读行不画(定稿 v3 ①),供不了(`none`/缺席)也不画。 */
1574
+ const entry = props.browser ?? null;
1575
+ const browse = item.control === "directory" && entry !== null && entry.mode !== "none" ? react.createElement("button", {
1576
+ style: S.btnPick,
1577
+ type: "button",
1578
+ disabled,
1579
+ onClick: () => entry.onOpen(item.key)
1580
+ }, "浏览文件夹") : null;
1581
+ return react.createElement("div", { style: S.master }, react.createElement("div", { style: S.label }, item.title, react.createElement("span", { style: S.chip }, "可改")), react.createElement("div", { style: S.hint }, item.hint), control, react.createElement("div", { style: S.acts }, browse, copy), props.alert == null ? null : react.createElement("div", { style: S.invalid }, props.alert.message));
1582
+ }
1583
+ /** 应用内浏览器对话框的文案(面板自带,逐条对应图上那几个位置)。 */
1584
+ const BROWSER_LABELS = {
1585
+ title: "选择文件夹",
1586
+ close: "关闭",
1587
+ up: "上一级",
1588
+ pathPlaceholder: "直接填绝对路径,回车即进入",
1589
+ go: "转到",
1590
+ showHidden: (n) => "显示隐藏目录(" + n + ")",
1591
+ empty: "这个目录里没有子目录。",
1592
+ loading: "正在读取",
1593
+ newFolder: "新建文件夹",
1594
+ createConfirm: "创建",
1595
+ createCancel: "取消",
1596
+ select: "选",
1597
+ selected: "已选",
1598
+ open: "选定这个目录",
1599
+ cancel: "取消",
1600
+ willPick: "将选定:"
1601
+ };
1602
+ /** 画整面(四支控件、三支错误落位、底栏、附加块槽都在这里)。 */
1603
+ function PanelBody(props) {
1604
+ const { state, title } = props;
1605
+ const surface = state.kind === "ready" ? state.surface : null;
1606
+ const headText = title === void 0 ? "配置" : `${title} · 配置`;
1607
+ const headDataDir = surface === null ? "" : surface.resolved?.["dbDir"] ?? surface.dataDir;
1608
+ const ready = state.kind === "ready";
1609
+ const dirtyCount = ready ? props.dirtyKeys.length : 0;
1610
+ const badge = panelBadgeOf({
1611
+ readFailed: state.kind === "failed",
1612
+ busy: props.busy,
1613
+ writeFailed: props.writeError !== null,
1614
+ invalid: props.invalid === true,
1615
+ dirtyCount,
1616
+ saved: props.notice !== null
1617
+ });
1618
+ const head = react.createElement("div", { style: S.head }, react.createElement("div", { style: S.title }, headText), react.createElement(PanelBadgeView, {
1619
+ kind: badge,
1620
+ dirtyCount
1621
+ }));
1622
+ const fileLines = surface === null ? null : react.createElement("div", { style: S.fileWrap }, react.createElement("div", { style: S.info }, react.createElement("b", { style: S.fileLabel }, "配置文件"), surface.path), react.createElement("div", { style: S.info }, react.createElement("b", { style: S.fileLabel }, "数据目录"), headDataDir), surface.created ? react.createElement("div", { style: S.muted }, "(配置文件刚按默认值生成)") : null);
1623
+ const parts = {
1624
+ styles: S,
1625
+ reply: surface
1626
+ };
1627
+ const extra = props.extra === void 0 ? null : props.extra(parts);
1628
+ if (state.kind === "failed") return react.createElement("div", { style: S.card }, head, fileLines, react.createElement("div", { style: S.error }, state.message), extra, react.createElement("div", { style: S.bar }, react.createElement("button", {
1629
+ style: S.btn,
1630
+ type: "button",
1631
+ onClick: props.onRetry
1632
+ }, "重试"), react.createElement("button", {
1633
+ style: S.btn,
1634
+ type: "button",
1635
+ onClick: props.onReset
1636
+ }, "重置为默认")));
1637
+ const common = props.items.filter((item) => item.tier === "common");
1638
+ const advanced = props.items.filter((item) => item.tier === "advanced");
1639
+ /** 还没读到整面(`loading`)时**框架照画**:行表与控件形状是客户端常量,不必等任何回执。
1640
+ * 值先空着、控件与三枚底栏键一律不可用;回执到了由 `ready` 那一支把值填上(见 `config-panel.ts`)。 */
1641
+ const frozen = props.busy || !ready;
1642
+ const followers = props.followerKeys ?? [];
1643
+ const renderRow = (item, last) => react.createElement(Row, {
1644
+ key: item.key,
1645
+ item,
1646
+ value: props.draft[item.key] ?? "",
1647
+ disabled: frozen,
1648
+ onChange: props.onChange,
1649
+ browser: props.rowEntry,
1650
+ dirty: ready && props.dirtyKeys.includes(item.key),
1651
+ follow: ready && props.followKeys.includes(item.key),
1652
+ onCopy: ready ? props.onCopy : void 0,
1653
+ copyState: props.copy !== null && props.copy.key === item.key ? props.copy.ok ? "done" : "failed" : "idle",
1654
+ alert: ready ? surface?.alerts?.[item.key] ?? null : null,
1655
+ last,
1656
+ nested: followers.includes(item.key)
1657
+ });
1658
+ /** 每组最后那一行才免掉下边线(`.ic-row:last-child`);母版行自己不带线,不必管。 */
1659
+ const lastIndexOf = (list) => {
1660
+ for (let i = list.length - 1; i >= 0; i -= 1) if (list[i].readonly === true) return i;
1661
+ return -1;
1662
+ };
1663
+ const commonLast = lastIndexOf(common);
1664
+ const advancedLast = lastIndexOf(advanced);
1665
+ const dirty = ready && dirtyCount > 0;
1666
+ return react.createElement("div", { style: S.card }, head, fileLines, ready ? null : react.createElement("div", { style: S.muted }, "配置读取中"), react.createElement("div", { style: S.rows }, common.map((item, index) => renderRow(item, index === commonLast))), advanced.length === 0 ? null : react.createElement("details", { style: S.advanced }, react.createElement("summary", { style: S.summary }, react.createElement("span", { style: S.groupMark }, "›"), ADVANCED_GROUP_TITLE, react.createElement("span", { style: S.groupNote }, ADVANCED_GROUP_NOTE)), react.createElement("div", { style: S.groupBody }, advanced.map((item, index) => renderRow(item, index === advancedLast)))), extra, react.createElement("div", { style: S.bar }, react.createElement("button", {
1667
+ style: dirty ? S.btnPrimary : S.btn,
1668
+ type: "button",
1669
+ disabled: frozen || !dirty,
1670
+ onClick: props.onSave
1671
+ }, props.busy ? "处理中" : dirty ? `保存(${dirtyCount} 项未保存)` : "保存"), react.createElement("button", {
1672
+ style: S.btn,
1673
+ type: "button",
1674
+ disabled: frozen,
1675
+ onClick: props.onReset
1676
+ }, "重置为默认"), react.createElement("button", {
1677
+ style: S.btn,
1678
+ type: "button",
1679
+ disabled: frozen,
1680
+ onClick: props.onRetry
1681
+ }, "重新读取"), dirty ? react.createElement("div", { style: S.saveMsg }, `浏览改动后请点保存(${dirtyCount} 项未保存),保存后跟随项自动更新`) : ready ? react.createElement("div", { style: S.barNote }, "没有未保存的改动") : null), props.picking ? react.createElement("div", { style: S.muted }, "已唤起系统文件夹对话框:选中后自动填上,取消则不动。") : null, props.browseRow !== null ? react.createElement(DirectoryBrowserFromRow, {
1682
+ open: true,
1683
+ row: props.browseRow,
1684
+ labels: BROWSER_LABELS
1685
+ }) : null, props.writeError !== null ? react.createElement("div", {
1686
+ style: S.error,
1687
+ role: "alert"
1688
+ }, props.writeError) : null, props.error !== null ? react.createElement("div", { style: S.error }, props.error) : null, props.notice !== null ? react.createElement("div", { style: S.okText }, props.notice) : null);
1689
+ }
1690
+ //#endregion
1691
+ //#region ../plugin-manager/dist/directory-browser-roots.js
1692
+ /** 「本机根清单」的取数接线(客户端半):把一通电话变成图上那一行可跳转的根。
1693
+ *
1694
+ * **不认识宿主**:载体基段、通道名(第二段)、电话名全由调用方传进来——各家从自己的宿主镜像里拿
1695
+ * 那几个字面量(和 `openRowBrowser` 的 `refusalCode` 同一条规矩)。本件只做两件事:
1696
+ * 发一次调用、把回执信封拆成根清单;**任何失败都当空清单**(图上少画一行,不报错、不挡路)。
1697
+ */
1698
+ /** 认一认某个值像不像一个根(认不出的行丢掉:图的干净比多画一行重要)。 */
1699
+ function isRootRow(raw) {
1700
+ if (typeof raw !== "object" || raw === null) return false;
1701
+ const row = raw;
1702
+ return typeof row.path === "string" && row.path !== "" && typeof row.kind === "string";
1703
+ }
1704
+ /** 信封 → 根清单(纯函数,**永不抛**):`{ok:true, value:{roots:[…]}}` 之外的形状一律当空。 */
1705
+ function readRootsAnswer(raw) {
1706
+ const answer = typeof raw === "object" && raw !== null ? raw : {};
1707
+ if (answer.ok !== true) return [];
1708
+ const value = typeof answer.value === "object" && answer.value !== null ? answer.value : {};
1709
+ if (!Array.isArray(value.roots)) return [];
1710
+ return value.roots.filter(isRootRow).map((row) => ({
1711
+ path: row.path,
1712
+ kind: row.kind
1713
+ }));
1714
+ }
1715
+ /** 建一个「根清单取数器」:给调用口与三个名字,回一个 `() => Promise<RootRow[]>`。
1716
+ *
1717
+ * 界面按需调它(开图一次),不在渲染里调,也不缓存到这里——缓存是宿主半的事。 */
1718
+ function createRootsSource(input) {
1719
+ return async () => {
1720
+ const call = input.getCall();
1721
+ if (typeof call !== "function") return [];
1722
+ try {
1723
+ return readRootsAnswer(await call(input.base, input.endpoint, {
1724
+ method: input.method,
1725
+ payload: {}
1726
+ }));
1727
+ } catch {
1728
+ return [];
1729
+ }
1730
+ };
1731
+ }
1732
+ /** 载体:宿主注册 `connection.fetch.register` 的路径 / 面板 `connection.rpc.call` 的两段参数。 */
1733
+ const MANAGER_RPC = {
1734
+ /** 载体基段(`connection.rpc.call` 的**第一段**参数)。传错第一段就是 404:真机上踩过一次。 */
1735
+ base: "/api",
1736
+ /** 单段通道名,须 match `/^\/[A-Za-z0-9._~-]+$/`(cookbook §6)。 */
1737
+ channel: "/ilife-manager",
1738
+ /** 面板侧第二段参数(等于通道名去掉前导斜杠那一段)。 */
1739
+ endpoint: "ilife-manager",
1740
+ /** 宿主侧注册的完整路径(= `base` + `channel`)。 */
1741
+ path: "/api/ilife-manager"
1742
+ };
1743
+ /** 总管自有电话名:更新包的三个动作名是冻结集合(`config.ts:14`),这里另起名字不撞车。 */
1744
+ const MANAGER_ACTIONS = {
1745
+ /** 装上缺席的目标包:入参 `{packageName, version}`,回包 `{packageName, version}`。 */
1746
+ install: "ilife-manager.install",
1747
+ /** 七个更新目标的表:入参 `{}`,回包 `{targets: [{key, title, packageName, phones, runningVersion, installedVersion, panelRegistered, skill}]}`。
1748
+ *
1749
+ * 电话名为什么由宿主转交而不是面板侧写死:更新包的三个电话名只有它自己知道
1750
+ * (`update.phoneNames`,`host.ts:335`);面板侧既不能 import 更新包(浏览器产物纯度门)
1751
+ * 也不该把名字再写一份(写两份必然走散)。宿主把名字表连同版本行一起交出去,
1752
+ * 面板一行字面量都不留。 */
1753
+ targets: "ilife-manager.targets",
1754
+ /** 总管自述版本(票 #737):入参 `{}`,回包 `{version}`。
1755
+ *
1756
+ * 宿主读**自己这份已安装包**的 `package.json`(`manager-version.ts`),面板只渲染读到的值。
1757
+ * 为什么走电话:面板是浏览器产物,禁 node 内建(`test/client-bundle-48.test.mjs` 看门),
1758
+ * 读盘只许在宿主半;与卡路里 #130 同一条路(host 读 → RPC → client 纯渲染)。 */
1759
+ version: "ilife-manager.version",
1760
+ /** 本机「根」清单(票 #744):入参 `{}`,回包 `{roots: [{path, kind}]}`。
1761
+ *
1762
+ * 为什么这条能力住在总管:浏览器里没有卷清单接口,而宿主那条目录选择接缝只有
1763
+ * `list`/`createDirectory` 两格——「这台机器上有哪些盘」只能由宿主去问操作系统。
1764
+ * 六家的目录浏览器都取这一通电话,故名字与实现都只有这一处(清单本身见 `roots.ts`)。
1765
+ * 回包**恒成功**:列不出来是空清单,不是错误码(界面据此只是不画那一行)。 */
1766
+ roots: "ilife-manager.roots"
1767
+ };
1768
+ //#endregion
1769
+ //#region ../plugin-manager/dist/config-panel.js
1770
+ /** 共用配置面板 · 组件本体:一台状态机 + 一圈副作用,渲染交给 `config-panel-view.ts`。
1771
+ *
1772
+ * 这一件就是六个技能设置页的那张卡片:六家只交「连哪条通道」与「有哪些配置项」(行表),
1773
+ * 另加三个可选钩子(跟随映射/卡片标题/自家附加块)。卡片怎么画、值怎么取又怎么填回去、
1774
+ * 哪些行被改过还没保存、保存/重置/重新读取怎么走、出错时说什么话,全在本件与它下面那几件里。
1775
+ *
1776
+ * 状态:读到了没(loading/ready/failed)、哪些行被改过(脏基线)、正在写(busy)、
1777
+ * 写完的结果(notice/writeError)、就地失败(error)、目录选择那一路(picking/browseRow)、
1778
+ * 复制回执(copy)。**脏基线只看可改行**:只读行显示的是技能算好的绝对路径,拿它当「改动」
1779
+ * 会让面板一打开就显示「未保存」。**写完一律重新读一份整面**:写回执不是整面,拿它当整面用
1780
+ * 会让页头那行「数据目录」显示成空。
1781
+ *
1782
+ * 只挂一个副作用(挂载期读一次整面):取数口是**取用器**,每次调它现取,所以那个 effect
1783
+ * 不需要跟着任何一格重跑——否则回执一落定就再读一次,会自己把自己转起来。
1784
+ *
1785
+ * 对外只经 `config-panel-api.ts` 那一道门;本件自己不出门。
1786
+ */
1787
+ /** 认一认某个值像不像目录选择命名空间(认不出就当没有,绝不把设置页带下来)。 */
1788
+ function isPickerFace(raw) {
1789
+ if (typeof raw !== "object" || raw === null) return false;
1790
+ return typeof raw.pick === "function";
1791
+ }
1792
+ /** 现取目录选择命名空间:**软依赖**,拿不到回 null(页面据此不出入口)。 */
1793
+ function resolvePicker(getService) {
1794
+ if (getService === void 0) return null;
1795
+ try {
1796
+ const raw = getService(REMOTE_DIRECTORY_PICKER);
1797
+ return isPickerFace(raw) ? raw : null;
1798
+ } catch {
1799
+ return null;
1800
+ }
1801
+ }
1802
+ /** 唤起一次系统文件夹选择器并归一结果(**永不抛**)。 */
1803
+ async function pickDirectory(picker) {
1804
+ try {
1805
+ return readPickAnswer(await picker.pick());
1806
+ } catch (e) {
1807
+ return {
1808
+ kind: "unavailable",
1809
+ message: "打不开系统文件夹对话框(" + (e instanceof Error ? e.message : String(e)) + "):请直接在框里填绝对路径。"
1810
+ };
1811
+ }
1812
+ }
1813
+ /** 写一次剪贴板:**不碰 DOM**,只读当刻环境的 `globalThis.navigator`(拿不到就回 false,不抛)。
1814
+ *
1815
+ * 为什么不照原型那样退到 `document.execCommand`:本仓插件侧禁 DOM 直写(`document`/`window`),
1816
+ * 所以没有那条兜底;拿不到剪贴板就如实回 false,界面上那一枚按钮就地显示「复制失败」。 */
1817
+ async function writeClipboard(text) {
1818
+ try {
1819
+ const clip = globalThis.navigator?.clipboard;
1820
+ if (clip === void 0 || typeof clip.writeText !== "function") return false;
1821
+ await clip.writeText(text);
1822
+ return true;
1823
+ } catch {
1824
+ return false;
1825
+ }
1826
+ }
1827
+ /** **静态**跟随行名单:给行表里每个键喂一遍六家交的钩子,收它返回的跟随者。
1828
+ *
1829
+ * 为什么能这么探:`followKeysOf` 是**纯函数**,六家的实现都只是「脏键里有没有这一格」那一下判断
1830
+ * (各家 `src/client.ts`),喂单键即得「谁跟着它」——**与当刻脏不脏无关**。有了这个集合,共用件就能
1831
+ * 照 v3.1 的 `.ic-under` 把跟随行相对它的可改行缩进(#920 补),**六家源码一个字节不用改**。
1832
+ * 拿不到钩子时回空表(一行都不缩进,照旧平铺)。 */
1833
+ function followerKeysOf(items, hook) {
1834
+ if (hook === void 0) return [];
1835
+ const out = /* @__PURE__ */ new Set();
1836
+ for (const item of items) for (const key of hook([item.key])) out.add(key);
1837
+ return [...out];
1838
+ }
1839
+ /** 技能设置页:承载一家自己的全部可配置项(只配置,不干活)。 */
1840
+ function ConfigPanel(props) {
1841
+ const [state, setState] = react.useState({ kind: "loading" });
1842
+ const [pickerGone, setPickerGone] = react.useState(false);
1843
+ const [picking, setPicking] = react.useState(false);
1844
+ const [draft, setDraft] = react.useState({});
1845
+ const [busy, setBusy] = react.useState(false);
1846
+ const [notice, setNotice] = react.useState(null);
1847
+ const [writeError, setWriteError] = react.useState(null);
1848
+ const [error, setError] = react.useState(null);
1849
+ const [browseRow, setBrowseRow] = react.useState(null);
1850
+ const [copy, setCopy] = react.useState(null);
1851
+ /** 复制回执的复位定时器:**一次性 UI 回执**,不是数据轮询——点一次起一个,到点自我复位,
1852
+ * 下一次点击先清掉旧的,卸载时也清掉(下面那个清扫 effect 就是那一半)。 */
1853
+ const copyTimer = react.useRef(null);
1854
+ react.useEffect(() => () => {
1855
+ if (copyTimer.current !== null) {
1856
+ clearTimeout(copyTimer.current);
1857
+ copyTimer.current = null;
1858
+ }
1859
+ }, []);
1860
+ /** 挂载期读一次整面:卸载之后回来的回执丢掉(不许往已经没了的页面上写)。 */
1861
+ react.useEffect(() => {
1862
+ let alive = true;
1863
+ (async () => {
1864
+ const r = await fetchConfigSurface(props.getCall(), props.channel);
1865
+ if (!alive) return;
1866
+ if (r.ok) {
1867
+ setState({
1868
+ kind: "ready",
1869
+ surface: r.surface
1870
+ });
1871
+ setDraft(toDraft(props.items, r.surface.values, r.surface));
1872
+ } else setState({
1873
+ kind: "failed",
1874
+ message: r.message
1875
+ });
1876
+ })();
1877
+ return () => {
1878
+ alive = false;
1879
+ };
1880
+ }, []);
1881
+ /** 把一份整面铺到页面上(读到之后唯一那一处落点)。 */
1882
+ const apply = (surface) => {
1883
+ setState({
1884
+ kind: "ready",
1885
+ surface
1886
+ });
1887
+ setDraft(toDraft(props.items, surface.values, surface));
1888
+ };
1889
+ const load = async () => {
1890
+ setNotice(null);
1891
+ setWriteError(null);
1892
+ setError(null);
1893
+ const r = await fetchConfigSurface(props.getCall(), props.channel);
1894
+ if (r.ok) apply(r.surface);
1895
+ else setState({
1896
+ kind: "failed",
1897
+ message: r.message
1898
+ });
1899
+ };
1900
+ /** 写完(保存/重置)之后重新读一份整面:写回执只有 `{path, values}`、重置回执只有
1901
+ * `{path, backupPath}`,都不是整面——拿它当整面用,页头那行「数据目录」会显示成空。 */
1902
+ const writeThenReload = async (done) => {
1903
+ setBusy(true);
1904
+ setNotice(null);
1905
+ setWriteError(null);
1906
+ setError(null);
1907
+ const r = await fetchConfigSurface(props.getCall(), props.channel);
1908
+ if (r.ok) {
1909
+ apply(r.surface);
1910
+ setNotice(done);
1911
+ } else setState({
1912
+ kind: "failed",
1913
+ message: r.message
1914
+ });
1915
+ setBusy(false);
1916
+ };
1917
+ const onChange = (key, next) => {
1918
+ setDraft((prev) => ({
1919
+ ...prev,
1920
+ [key]: next
1921
+ }));
1922
+ setNotice(null);
1923
+ setWriteError(null);
1924
+ setError(null);
1925
+ };
1926
+ const picker = pickerGone ? null : resolvePicker(props.getService);
1927
+ /** 唤起系统文件夹选择器那一条路(应用内浏览被拒时的兜底,也是命名空间只给 `pick` 时的唯一一条)。 */
1928
+ const openNative = async (key) => {
1929
+ const face = pickerGone ? null : resolvePicker(props.getService);
1930
+ if (face === null) return;
1931
+ setPicking(true);
1932
+ setError(null);
1933
+ const outcome = await pickDirectory(face);
1934
+ setPicking(false);
1935
+ if (outcome.kind === "picked") onChange(key, outcome.path);
1936
+ else if (outcome.kind === "unavailable") {
1937
+ setError(outcome.message);
1938
+ setPickerGone(true);
1939
+ }
1940
+ };
1941
+ /** 目录行的入口动作:**先应用内浏览,宿主没给这条路再换系统对话框**(判断在共用件里)。 */
1942
+ const onOpenRow = async (key) => {
1943
+ setError(null);
1944
+ const opened = openRowBrowser({
1945
+ picker,
1946
+ initialPath: draft[key] ?? "",
1947
+ onChange: (next) => onChange(key, next),
1948
+ onRow: setBrowseRow,
1949
+ refusalCode: DIRECTORY_PICKER_REFUSED,
1950
+ rootsSource: createRootsSource({
1951
+ getCall: props.getCall,
1952
+ base: MANAGER_RPC.base,
1953
+ endpoint: MANAGER_RPC.endpoint,
1954
+ method: MANAGER_ACTIONS.roots
1955
+ })
1956
+ });
1957
+ if (opened === void 0 || await opened === "refused") await openNative(key);
1958
+ };
1959
+ /** 复制一行(定稿 v3 第三条):写完就地变「已复制」(写不进去就地变「复制失败」),1.5 秒后复位,不弹提示。 */
1960
+ const onCopy = (key, text) => {
1961
+ writeClipboard(text).then((ok) => {
1962
+ setCopy({
1963
+ key,
1964
+ ok
1965
+ });
1966
+ if (copyTimer.current !== null) clearTimeout(copyTimer.current);
1967
+ copyTimer.current = setTimeout(() => {
1968
+ copyTimer.current = null;
1969
+ setCopy(null);
1970
+ }, COPY_FEEDBACK_MS);
1971
+ });
1972
+ };
1973
+ const onSave = async () => {
1974
+ setBusy(true);
1975
+ setNotice(null);
1976
+ setWriteError(null);
1977
+ setError(null);
1978
+ const r = await saveConfigSurface(props.getCall(), props.channel, fromDraft(props.items, draft));
1979
+ setBusy(false);
1980
+ if (r.ok) await writeThenReload("已保存,立即生效(不用重启宿主)");
1981
+ else setWriteError(r.message);
1982
+ };
1983
+ const onReset = async () => {
1984
+ setBusy(true);
1985
+ setNotice(null);
1986
+ setWriteError(null);
1987
+ setError(null);
1988
+ const r = await resetConfigSurface(props.getCall(), props.channel);
1989
+ setBusy(false);
1990
+ if (r.ok) await writeThenReload("已重置为默认(原配置已另存一份 .bak)");
1991
+ else setWriteError(r.message);
1992
+ };
1993
+ const surface = state.kind === "ready" ? state.surface : null;
1994
+ const baseline = surface !== null ? toDraft(props.items, surface.values, surface) : null;
1995
+ const dirtyKeys = baseline === null ? [] : dirtyKeysOf(props.items, draft, baseline);
1996
+ const followKeys = (props.followKeysOf ?? noFollowKeys)(dirtyKeys);
1997
+ /** 技能侧报出「有格子真用不了」⇒ 标题行那枚「配置里有值用不了」徽标才亮(#915 第二步)。 */
1998
+ const hasAlert = surface !== null && surface.alerts !== void 0 && Object.keys(surface.alerts).length > 0;
1999
+ /** 静态那一份(与脏不脏无关):画跟随行的缩进用(v3.1 的 `.ic-under`)。 */
2000
+ const followerKeys = followerKeysOf(props.items, props.followKeysOf);
2001
+ const mode = pickerModeOf(picker);
2002
+ const rowEntry = mode === "none" ? null : {
2003
+ mode,
2004
+ onOpen: onOpenRow
2005
+ };
2006
+ return react.createElement(PanelBody, {
2007
+ title: props.title,
2008
+ items: props.items,
2009
+ state,
2010
+ draft,
2011
+ busy,
2012
+ notice,
2013
+ writeError,
2014
+ error,
2015
+ picking,
2016
+ browseRow,
2017
+ rowEntry,
2018
+ dirtyKeys,
2019
+ followKeys,
2020
+ followerKeys,
2021
+ invalid: hasAlert,
2022
+ copy,
2023
+ onCopy,
2024
+ onChange,
2025
+ onSave: () => void onSave(),
2026
+ onReset: () => void onReset(),
2027
+ onRetry: () => void load(),
2028
+ extra: props.extra
2029
+ });
2030
+ }
2031
+ //#endregion
2032
+ //#region src/contract.ts
2033
+ /** dsh-calorie 端口(port):DSH 世界与卡路里 hexagon 之间的契约。
2034
+ *
2035
+ * browser 安全:零 node 导入。host 适配器(注册 RPC 通道)与 client
2036
+ * 适配器(调用 RPC)共用同一份定义;单测与 loader 回路以此为准。
2037
+ * 通道命名遵守宿主约束:单段且不等于 /api(见 cookbook「RPC 通道」)。
2038
+ */
2039
+ const RPC_CHANNEL = "/ilife-calorie";
2040
+ const RPC_ENDPOINT_READ = "read";
2041
+ /** 面板默认读键(与 HITL 一致性对比同键)。 */
2042
+ const DEFAULT_READ_KEY = "calorie.view.home";
2043
+ /** 信封守卫:供 client 拆包前校验(port 拥有信封定义,守卫住 port)。 */
2044
+ function isRpcResult(raw) {
2045
+ if (typeof raw !== "object" || raw === null) return false;
2046
+ const ok = raw.ok;
2047
+ return ok === true || ok === false;
2048
+ }
2049
+ //#endregion
2050
+ //#region src/slot.ts
2051
+ const SLOT_TITLE = "卡路里";
2052
+ const PLUGIN = "dsh-calorie";
2053
+ //#endregion
2054
+ //#region src/settings.ts
2055
+ /** 常用项:数据目录/库文件名/产物目录名/照片目录/训记 KEY(可改 3 + 只读 2)。 */
2056
+ const COMMON = [
2057
+ {
2058
+ key: "db.dir",
2059
+ title: "数据目录",
2060
+ tier: "common",
2061
+ control: "directory",
2062
+ prefillFrom: "dataDir",
2063
+ hint: "库文件与各类产物的根目录。留空=用默认目录。"
2064
+ },
2065
+ {
2066
+ key: "db.name",
2067
+ title: "库文件名",
2068
+ tier: "common",
2069
+ control: "text",
2070
+ readonly: true,
2071
+ resolveFrom: "dbFile",
2072
+ hint: "数据目录下的数据库文件。"
2073
+ },
2074
+ {
2075
+ key: "html.dir",
2076
+ title: "HTML 产物目录名",
2077
+ tier: "common",
2078
+ control: "text",
2079
+ readonly: true,
2080
+ resolveFrom: "htmlDir",
2081
+ hint: "数据目录下存放交付页面的子目录。"
2082
+ },
2083
+ {
2084
+ key: "photos.dir",
2085
+ title: "照片目录",
2086
+ tier: "common",
2087
+ control: "directory",
2088
+ prefillFrom: "photosDir",
2089
+ hint: "身材照的存放目录。留空=用默认目录(数据目录下的 photos)。"
2090
+ },
2091
+ {
2092
+ key: "xunji.key",
2093
+ title: "训记 KEY",
2094
+ tier: "common",
2095
+ control: "text",
2096
+ hint: "训记的凭据。留空=未配置,推送与回写会被拦下。"
2097
+ }
2098
+ ];
2099
+ /** 高级项:默认收起(只读 6 项)。改错了多半只是自己撞上麻烦,不确定就别动。 */
2100
+ const ADVANCED = [
2101
+ {
2102
+ key: "photos.gifs",
2103
+ title: "照片 GIF 子目录",
2104
+ tier: "advanced",
2105
+ control: "text",
2106
+ readonly: true,
2107
+ resolveFrom: "gifsDir",
2108
+ hint: "照片目录下存放 GIF 的子目录。"
2109
+ },
2110
+ {
2111
+ key: "xunji.stateDir",
2112
+ title: "训记状态文件目录",
2113
+ tier: "advanced",
2114
+ control: "directory",
2115
+ readonly: true,
2116
+ resolveFrom: "stateDir",
2117
+ hint: "训记的限频与同步游标存放目录。"
2118
+ },
2119
+ {
2120
+ key: "xunji.catalog",
2121
+ title: "训记动作库路径",
2122
+ tier: "advanced",
2123
+ control: "text",
2124
+ readonly: true,
2125
+ resolveFrom: "catalog",
2126
+ hint: "动作名判定用的动作库。"
2127
+ },
2128
+ {
2129
+ key: "xunji.backfillDays",
2130
+ title: "回写默认天数",
2131
+ tier: "advanced",
2132
+ control: "number",
2133
+ readonly: true,
2134
+ hint: "从训记拉实绩时默认回溯的天数。"
2135
+ },
2136
+ {
2137
+ key: "land.xunjiSeconds",
2138
+ title: "训记调用限时(秒)",
2139
+ tier: "advanced",
2140
+ control: "number",
2141
+ readonly: true,
2142
+ hint: "调用训记的超时秒数。"
2143
+ },
2144
+ {
2145
+ key: "land.landSeconds",
2146
+ title: "落地调用限时(秒)",
2147
+ tier: "advanced",
2148
+ control: "number",
2149
+ readonly: true,
2150
+ hint: "落地训练各步骤的超时秒数。"
2151
+ }
2152
+ ];
2153
+ /** 设置页的行表,顺序即页面顺序。 */
2154
+ const CONFIG_ITEMS = [...COMMON, ...ADVANCED];
2155
+ COMMON.length;
2156
+ //#endregion
2157
+ //#region src/client.ts
2158
+ /** dsh-calorie client 适配器(六边形:port=contract,adapter=本文件)。
2159
+ *
2160
+ * 产物经 tsdown 打成 loader 工厂包(browser/CJS,见 tsdown.config.ts)。
2161
+ * 本文件传递闭包:禁 node、禁 DOM 直写(document/window/process)。
2162
+ * 两处注册:**技能设置页** → 爱生活页签槽(总管声明的 children);**技能功能页** → sidebar 槽(软依赖)。
2163
+ * 取数只经 connection.rpc.call 进 host 通道;缺席/错误纯条件渲染,不返空冒充。
2164
+ * 组件 React.createElement 手写,不引入 JSX。
2165
+ *
2166
+ * #909:设置页本体(样式表/行渲染/卡片骨架/状态机/取值与填值/配置面三通电话/
2167
+ * 目录选择入口)**收进共用件** `dsh-life-pack/config-panel`,本文件只留四处接线:
2168
+ * ① 行表(住 `settings.ts`——那是本家自己的配置数据,不是 UI);
2169
+ * ② 通道路由名(`RPC_CHANNEL`,本家唯一那个真变量);
2170
+ * ③ 三个可选钩子(卡片标题/跟随映射/自家附加块=版本行);
2171
+ * ④ 两格宿主取数接线(`getCall`/`getService`)。
2172
+ * 本家**不再有**设置页的样式表与行渲染函数:改一次共用面板,六家一起变。
2173
+ * 文件里剩下那张 `W` 样式表是**技能功能页**(sidebar 槽那张卡)自己的,与设置页无关。
2174
+ * 照 CONTEXT.md,「技能设置页」只配置、不干活:本页没有任何取数/记一餐入口,
2175
+ * 状态读数也已移出(读数属技能功能页)。
2176
+ */
2177
+ /** client 短名声明:只有这两个(#736 的目录选择走**可选查找**,不写进来——
2178
+ * 写进来=硬依赖,提供方缺席时整包被停靠,设置页会跟着装不上;见 cookbook §13)。 */
2179
+ const inject = ["slots", "connection"];
2180
+ /** **技能功能页**(sidebar 槽那张卡)的视觉。设置页的样式表不在这里——它收进了共用件
2181
+ * `dsh-life-pack/config-panel`(#909)。这张表只服务本家自己的功能页,故不参与六家同形面。 */
2182
+ const W = {
2183
+ card: {
2184
+ padding: "12px 14px",
2185
+ borderRadius: 10,
2186
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.35))",
2187
+ background: "var(--dsw-alias-bg-base, transparent)",
2188
+ color: "var(--dsw-alias-label-primary, inherit)",
2189
+ lineHeight: 1.6
2190
+ },
2191
+ title: {
2192
+ fontSize: "1.08em",
2193
+ fontWeight: 700,
2194
+ marginBottom: 8
2195
+ },
2196
+ total: {
2197
+ fontSize: 22,
2198
+ fontWeight: 700,
2199
+ margin: "2px 0 4px"
2200
+ },
2201
+ muted: {
2202
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)",
2203
+ fontSize: "0.92em"
2204
+ },
2205
+ error: {
2206
+ marginTop: 8,
2207
+ color: "var(--dsw-alias-label-error, #b3261e)",
2208
+ fontSize: "1em",
2209
+ whiteSpace: "pre-wrap"
1193
2210
  },
1194
- btnPrimary: {
1195
- padding: "4px 12px",
1196
- borderRadius: 6,
1197
- border: "1px solid var(--dsw-alias-brand-primary, #2f6fed)",
1198
- background: "var(--dsw-alias-brand-primary, #2f6fed)",
1199
- color: "#fff",
1200
- cursor: "pointer"
2211
+ okText: {
2212
+ marginTop: 8,
2213
+ color: "var(--dsw-alias-state-success-primary, #12805c)",
2214
+ fontSize: "0.96em"
1201
2215
  },
1202
- advanced: { marginTop: 12 },
1203
- summary: {
1204
- cursor: "pointer",
1205
- fontWeight: 600
2216
+ version: {
2217
+ marginTop: 8,
2218
+ paddingTop: 8,
2219
+ borderTop: "1px dashed var(--dsw-alias-border, rgba(128,128,128,.25))",
2220
+ color: "var(--dsw-alias-label-tertiary, #8a8a8a)",
2221
+ fontSize: "0.92em"
1206
2222
  }
1207
2223
  };
1208
2224
  /** #130 版本通道(host 侧动态读取已安装版本,client 纯渲染+unknown 降级)。
@@ -1226,7 +2242,7 @@ window.__ModuleLoader__.load({
1226
2242
  }
1227
2243
  /** 版本行:纯渲染 host 传来的值+unknown 降级(用户要求:每技能设置页自报家门)。 */
1228
2244
  function VersionLine(props) {
1229
- return react.createElement("div", { style: S.version }, formatVersionLine(props.pluginVersion, props.skillVersion));
2245
+ return react.createElement("div", { style: W.version }, formatVersionLine(props.pluginVersion, props.skillVersion));
1230
2246
  }
1231
2247
  /** 版本取值(与 fetchRead 同 connection.rpc.call 模式;单次、无轮询;任何失败→双 unknown,永不抛)。 */
1232
2248
  async function fetchVersions(call) {
@@ -1408,264 +2424,40 @@ window.__ModuleLoader__.load({
1408
2424
  alive = false;
1409
2425
  };
1410
2426
  }, [props.getCall]);
1411
- if (state.kind === "loading") return react.createElement("div", { style: S.card }, react.createElement("div", { style: S.muted }, "卡路里加载中"));
1412
- if (state.kind === "data") return react.createElement("div", { style: S.card }, react.createElement("div", { style: S.title }, `卡路里 · ${state.date}`), react.createElement("div", { style: S.total }, `total ${state.total}`), react.createElement(VersionLine, {
2427
+ if (state.kind === "loading") return react.createElement("div", { style: W.card }, react.createElement("div", { style: W.muted }, "卡路里加载中"));
2428
+ if (state.kind === "data") return react.createElement("div", { style: W.card }, react.createElement("div", { style: W.title }, `卡路里 · ${state.date}`), react.createElement("div", { style: W.total }, `total ${state.total}`), react.createElement(VersionLine, {
1413
2429
  pluginVersion: versions.plugin,
1414
2430
  skillVersion: versions.skill
1415
2431
  }));
1416
- return react.createElement("div", { style: S.card }, react.createElement("div", { style: state.kind === "absent" ? S.muted : S.error }, state.message), react.createElement(VersionLine, {
2432
+ return react.createElement("div", { style: W.card }, react.createElement("div", { style: state.kind === "absent" ? W.muted : W.error }, state.message), react.createElement(VersionLine, {
1417
2433
  pluginVersion: versions.plugin,
1418
2434
  skillVersion: versions.skill
1419
2435
  }));
1420
2436
  }
1421
- /** 配置面报错 → 人话指引。
1422
- *
1423
- * 报文由技能侧给出:base-link-core 的 `ConfigError` 已经带行号与文件名,技能
1424
- * `cli/config.ts` 原样交出来,插件 bridge 原样带上(`fetch-failed:出口非 0(1):<那一句>`)。
1425
- * 所以这里**不重写报文**,只按报错类补一句「接下来怎么办」——免得两处各写一套人话。
1426
- */
1427
- function humanizeConfigFailure(code, message) {
1428
- const text = message.trim().length > 0 ? message.trim() : `(${code},宿主未给报文)`;
1429
- if (/解析|YAML|parse/i.test(text)) return `${text}\n改回「键: 值」的写法,或点「重置为默认」。`;
1430
- if (/不认识|未知键|UNKNOWN_KEY/i.test(text)) return `${text}\n删掉页面上没有的行,或点「重置为默认」。`;
1431
- if (/类型|TYPE_MISMATCH/i.test(text)) return `${text}\n按本页的控件形状填,或点「重置为默认」。`;
1432
- if (/缺席|missing-cli/.test(text)) return `${text}\n技能出口没找到:先确认技能包已装好,再点「重新读取」。`;
1433
- return `${text}\n改不动就点「重置为默认」,或照上面那条路径手工改配置文件。`;
1434
- }
1435
- /** 一次配置 RPC(与 fetchRead 同通道、同超时纪律;永不抛,失败落字)。 */
1436
- async function configRpc(call, method, payload) {
1437
- if (typeof call !== "function") return {
1438
- ok: false,
1439
- message: "宿主连接缺席:connection.rpc.call 不可用"
1440
- };
1441
- try {
1442
- const raw = await withTimeout(call("/api", RPC_CHANNEL.slice(1), {
1443
- method,
1444
- payload
1445
- }, AbortSignal.timeout(READ_TIMEOUT_MS)), READ_TIMEOUT_MS);
1446
- if (!isRpcResult(raw)) return {
1447
- ok: false,
1448
- message: "回执信封异常(非 ok 信封)"
1449
- };
1450
- if (!raw.ok) return {
1451
- ok: false,
1452
- message: humanizeConfigFailure(raw.error?.code ?? "unknown", raw.error?.message ?? "")
1453
- };
1454
- return {
1455
- ok: true,
1456
- surface: raw.value
1457
- };
1458
- } catch (e) {
1459
- if (e instanceof Error && e.name === "AbortError") return {
1460
- ok: false,
1461
- message: `配置读取超时(${Math.round(READ_TIMEOUT_MS / 1e3)}s):宿主未回`
1462
- };
1463
- if (e instanceof Error && e.name === "TimeoutError") return {
1464
- ok: false,
1465
- message: e.message
1466
- };
1467
- return {
1468
- ok: false,
1469
- message: `配置失败:${e instanceof Error ? e.message : String(e)}`
1470
- };
1471
- }
1472
- }
1473
- /** 读设置页整面。 */
1474
- function fetchConfigSurface(call) {
1475
- return configRpc(call, RPC_ENDPOINT_CONFIG_GET, {});
1476
- }
1477
- /** 保存(回全表)。 */
1478
- function saveConfigSurface(call, values) {
1479
- return configRpc(call, RPC_ENDPOINT_CONFIG_SAVE, { values });
1480
- }
1481
- /** 重置为默认(配置件先落 .bak)。 */
1482
- function resetConfigSurface(call) {
1483
- return configRpc(call, RPC_ENDPOINT_CONFIG_RESET, {});
1484
- }
1485
- /** 只读行显示什么:标了 `resolveFrom` ⇒ 回执 `resolved` 组那一格;没标 ⇒ 配置文件里那个值本身
1486
- * (数字类只读项走这一档:显示的是生效数字,见 #749 补注二的甲档)。 */
1487
- function readonlyTextOf(item, raw, prefill) {
1488
- if (item.resolveFrom === void 0) return raw;
1489
- const shown = prefill.resolved?.[item.resolveFrom];
1490
- return typeof shown === "string" ? shown : "";
1491
- }
1492
- function toDraft(values, prefill = {}) {
1493
- const draft = {};
1494
- for (const item of CONFIG_ITEMS) {
1495
- const v = readPath(values, item.key);
1496
- const raw = v === void 0 || v === null ? "" : String(v);
1497
- if (item.readonly === true) {
1498
- draft[item.key] = readonlyTextOf(item, raw, prefill);
1499
- continue;
1500
- }
1501
- if (raw !== "") {
1502
- draft[item.key] = raw;
1503
- continue;
1504
- }
1505
- const top = item.prefillFrom === void 0 ? void 0 : prefill[item.prefillFrom];
1506
- if (typeof top === "string" && top !== "") {
1507
- draft[item.key] = top;
1508
- continue;
1509
- }
1510
- const resolved = item.prefillResolved === void 0 ? void 0 : prefill.resolved?.[item.prefillResolved];
1511
- draft[item.key] = typeof resolved === "string" ? resolved : raw;
1512
- }
1513
- return draft;
1514
- }
1515
- /** 表单态 → 配置取值(按控件种类还原类型;空串对文本项照收,语义由「未配」承担)。
1516
- *
1517
- * **只读行不收**(#757):它们显示的是技能算好的绝对路径,写回配置就是把「显示的路径」当成「配置值」——
1518
- * 保存只提交真能改的那些行(本家=数据目录/照片目录/训记 KEY 三行),其余键由技能侧做组内合并保留现值。 */
1519
- function fromDraft(draft) {
1520
- const values = {};
1521
- for (const item of CONFIG_ITEMS) {
1522
- if (item.readonly === true) continue;
1523
- const raw = draft[item.key] ?? "";
1524
- if (item.control === "number") {
1525
- const n = Number(raw);
1526
- writePath(values, item.key, Number.isFinite(n) ? n : 0);
1527
- } else if (item.control === "switch") writePath(values, item.key, raw === "true");
1528
- else writePath(values, item.key, raw);
1529
- }
1530
- return values;
1531
- }
1532
- /** 形状守卫:拿到的那个东西像不像目录选择命名空间(本包只认 `pick` 是函数)。 */
1533
- function isDirectoryPicker(raw) {
1534
- if (typeof raw !== "object" || raw === null) return false;
1535
- return typeof raw.pick === "function";
1536
- }
1537
- /** 现取目录选择命名空间:**软依赖**,拿不到回 null(页面据此不出入口)。
1538
- *
1539
- * 为什么不用 `inject` 声明:见 cookbook §13——写进声明=硬依赖,提供方缺席时整包被停靠,
1540
- * 设置页会跟着装不上。这里按平台给的「可选查找」办:缺席只是没有入口。
1541
- * 守卫拒绝或提供方中途卸载会抛,一律当「没有」,绝不把设置页带下来。 */
1542
- function resolveDirectoryPicker(getService) {
1543
- if (typeof getService !== "function") return null;
1544
- try {
1545
- const raw = getService(REMOTE_DIRECTORY_PICKER);
1546
- return isDirectoryPicker(raw) ? raw : null;
1547
- } catch {
1548
- return null;
1549
- }
1550
- }
1551
- /** 平台回执/入口三态:**已经收进共用件**(票 #744),这里只同名转出。 */
1552
- const readPickAnswer = readPickAnswer$1;
1553
- const pickerModeOf = pickerModeOf$1;
1554
- /** 唤起一次系统文件夹选择器并归一结果(**永不抛**)。 */
1555
- async function pickDirectory(picker) {
1556
- try {
1557
- return readPickAnswer(await picker.pick());
1558
- } catch (e) {
1559
- return {
1560
- kind: "unavailable",
1561
- message: "打不开系统文件夹对话框(" + (e instanceof Error ? e.message : String(e)) + "):请直接在框里填绝对路径。"
1562
- };
1563
- }
1564
- }
1565
- /** 入口三态:有系统对话框(`pick`)/只有应用内浏览(`list` + `createDirectory`)/都没有。
1566
- *
1567
- * 判定取自共用件(一处定义)。本函数只读那个命名空间,不做 IO。 */
1568
- function directoryEntryMode(picker) {
1569
- return picker === null ? "none" : pickerModeOf(picker);
1570
- }
1571
- /** 「选择文件夹」按钮的真装配函数(目录行渲染出来的按钮,onClick 就是它)。
1572
- *
1573
- * 回 Promise 是为了本地可判(用例直接 await);接进 React 时调用方 `void` 掉。 */
1574
- function createBrowseHandler(deps) {
1575
- return async (key) => {
1576
- const outcome = await pickDirectory(deps.picker);
1577
- if (outcome.kind === "picked") deps.onChange(key, outcome.path);
1578
- else if (outcome.kind === "unavailable") deps.onUnavailable(outcome.message);
1579
- };
1580
- }
1581
- /** 一行输入(四种控件对齐受限 YAML 子集:文本/数字/布尔/目录)。
1582
- *
1583
- * 目录档=文本框 + 一枚唤起系统文件夹选择器的按钮;`onBrowse` 缺席(命名空间拿不到、
1584
- * 或这条路已被拒)时不画按钮,文本框照旧——那是该缝自己的契约(供不了就收起入口,不是失败)。
1585
- *
1586
- * **只读行(#757)**:`item.readonly` 为真时控件一律 `disabled`(值只经技能侧解析后显示、不给改),
1587
- * 目录行的浏览按钮**保留但不可点击**(#748 定稿:入口不作废,只是不能改)。控件文案**不出现省略号**
1588
- * (#746 处置 9)——按钮就写「选择文件夹」/「浏览」两个字面。 */
1589
- function Row(props) {
1590
- const { item } = props;
1591
- const readonly = item.readonly === true;
1592
- const disabled = props.disabled || readonly;
1593
- const onChange = readonly ? void 0 : (e) => props.onChange(item.key, e.target.value);
1594
- const control = item.control === "switch" ? react.createElement("input", {
1595
- type: "checkbox",
1596
- checked: props.value === "true",
1597
- disabled,
1598
- onChange: readonly ? void 0 : (e) => props.onChange(item.key, e.target.checked ? "true" : "false")
1599
- }) : react.createElement("input", {
1600
- style: S.input,
1601
- type: item.control === "number" ? "number" : "text",
1602
- value: props.value,
1603
- disabled,
1604
- spellCheck: false,
1605
- onChange
1606
- });
1607
- /** 三态入口:供不了(`none`/缺席)就不画,不摆一个点了没反应的死按钮。
1608
- * 只读行照画(按钮保留),但 `disabled` ⇒ 点不动。 */
1609
- const entry = props.browser ?? null;
1610
- const browse = item.control === "directory" && entry !== null && entry.mode !== "none" ? react.createElement("button", {
1611
- style: S.btnPick,
1612
- type: "button",
1613
- disabled,
1614
- onClick: () => entry.onOpen(item.key)
1615
- }, entry.mode === "native" ? "选择文件夹" : "浏览") : null;
1616
- return react.createElement("div", { style: S.row }, react.createElement("div", { style: S.label }, item.title), react.createElement("div", { style: S.hint }, item.hint), browse === null ? control : react.createElement("div", { style: S.pickRow }, control, browse));
2437
+ /** 跟随映射(#863):触发键一脏,这些只读派生行就进“将跟随更新”态。纯函数,面板与单测共用。 */
2438
+ const DB_FOLLOWERS = [
2439
+ "db.name",
2440
+ "html.dir",
2441
+ "xunji.stateDir",
2442
+ "photos.gifs"
2443
+ ];
2444
+ const PHOTOS_FOLLOWERS = ["photos.gifs"];
2445
+ /** 脏键 → 跟随行(#863 纯函数,交给共用面板当钩子)。 */
2446
+ function followKeysOf(dirtyKeys) {
2447
+ const keys = [];
2448
+ if (dirtyKeys.includes("db.dir")) keys.push(...DB_FOLLOWERS);
2449
+ if (dirtyKeys.includes("photos.dir")) keys.push(...PHOTOS_FOLLOWERS);
2450
+ return [...new Set(keys)];
1617
2451
  }
1618
- /** 技能设置页:承载卡路里自己的全部可配置项(只配置,不干活)。
2452
+ /** 自家附加块(设置页那张卡的版本行):共用面板留的插槽,画在面板主体之后、动作条之前。
1619
2453
  *
1620
- * 三态:loading(一次 RPC 内)/ready(真表单)/failed(人话报错 + 指引,不返空、不转圈)。
1621
- * 没有任何干活入口——查数、记一餐在技能功能页(sidebar槽)。 */
1622
- function CalorieConfig(props) {
1623
- const [state, setState] = react.useState({ kind: "loading" });
1624
- const [pickerGone, setPickerGone] = react.useState(false);
1625
- const [picking, setPicking] = react.useState(false);
1626
- const [draft, setDraft] = react.useState({});
1627
- const [busy, setBusy] = react.useState(false);
1628
- const [notice, setNotice] = react.useState(null);
1629
- const [error, setError] = react.useState(null);
2454
+ * 为什么单起一个组件:版本是**本家自己的一通电话**(`dsh-calorie.version`),共用面板不认识它;
2455
+ * 组件自己持状态,面板只管把它画进插槽。 */
2456
+ function CalorieVersionLine(props) {
1630
2457
  const [versions, setVersions] = react.useState({
1631
2458
  plugin: VERSION_UNKNOWN,
1632
2459
  skill: VERSION_UNKNOWN
1633
2460
  });
1634
- const load = react.useCallback(async () => {
1635
- setNotice(null);
1636
- setError(null);
1637
- const r = await fetchConfigSurface(props.getCall());
1638
- if (r.ok) {
1639
- setState({
1640
- kind: "ready",
1641
- surface: r.surface
1642
- });
1643
- setDraft(toDraft(r.surface.values, r.surface));
1644
- } else setState({
1645
- kind: "failed",
1646
- message: r.message
1647
- });
1648
- }, [props.getCall]);
1649
- react.useEffect(() => {
1650
- let alive = true;
1651
- (async () => {
1652
- const r = await fetchConfigSurface(props.getCall());
1653
- if (!alive) return;
1654
- if (r.ok) {
1655
- setState({
1656
- kind: "ready",
1657
- surface: r.surface
1658
- });
1659
- setDraft(toDraft(r.surface.values, r.surface));
1660
- } else setState({
1661
- kind: "failed",
1662
- message: r.message
1663
- });
1664
- })();
1665
- return () => {
1666
- alive = false;
1667
- };
1668
- }, [props.getCall]);
1669
2461
  react.useEffect(() => {
1670
2462
  let alive = true;
1671
2463
  (async () => {
@@ -1679,196 +2471,28 @@ window.__ModuleLoader__.load({
1679
2471
  alive = false;
1680
2472
  };
1681
2473
  }, [props.getCall]);
1682
- const onChange = react.useCallback((key, next) => {
1683
- setDraft((prev) => ({
1684
- ...prev,
1685
- [key]: next
1686
- }));
1687
- setNotice(null);
1688
- setError(null);
1689
- }, []);
1690
- /** 目录行入口:三态(系统对话框/应用内浏览/都没有)。
1691
- *
1692
- * **软依赖**:拿不到命名空间就没有入口(留文本框),被拒一次即收起入口,不留死按钮。
1693
- * 状态全部住在组件自己(hook 顺序与旧版同形):浏览器那一份只存「当前开着的那条」。 */
1694
- const picker = pickerGone ? null : props.pickerSource();
1695
- const [browseRow, setBrowseRow] = react.useState(null);
1696
- /** 唤起系统文件夹选择器那一条路(应用内浏览被拒时的兜底,也是命名空间只给 `pick` 时的唯一一条)。 */
1697
- const openNative = react.useCallback(async (key) => {
1698
- const face = pickerGone ? null : props.pickerSource();
1699
- if (face === null) return;
1700
- setPicking(true);
1701
- setError(null);
1702
- const outcome = await pickDirectory(face);
1703
- setPicking(false);
1704
- if (outcome.kind === "picked") onChange(key, outcome.path);
1705
- else if (outcome.kind === "unavailable") {
1706
- setError(outcome.message);
1707
- setPickerGone(true);
1708
- }
1709
- }, [
1710
- pickerGone,
1711
- onChange,
1712
- props.pickerSource
1713
- ]);
1714
- /** 目录行的入口动作:**先应用内浏览,宿主没给这条路再换系统对话框**(判断在共用件里)。
1715
- *
1716
- * 两条都供不了就不给入口(`undefined` 时 `rowEntry` 为 null ⇒ Row 不画按钮)。 */
1717
- const onOpenRow = react.useCallback(async (key) => {
1718
- setError(null);
1719
- const opened = openRowBrowser({
1720
- picker,
1721
- initialPath: draft[key] ?? "",
1722
- onChange: (next) => onChange(key, next),
1723
- onRow: setBrowseRow,
1724
- refusalCode: DIRECTORY_PICKER_REFUSED,
1725
- rootsSource: props.rootsSource
1726
- });
1727
- if (opened === void 0 || await opened === "refused") await openNative(key);
1728
- }, [
1729
- picker,
1730
- onChange,
1731
- draft,
1732
- openNative,
1733
- props.rootsSource
1734
- ]);
1735
- /** 给 Row 的三态入口:`none` 时给 null(不画按钮,文本框照旧)。 */
1736
- const rowEntry = pickerModeOf(picker) === "none" ? null : {
1737
- mode: pickerModeOf(picker),
1738
- onOpen: onOpenRow
1739
- };
1740
- const surface = state.kind === "ready" ? state.surface : null;
1741
- /** 脏值只看**可改行**(#757):只读行显示的是技能算好的绝对路径,拿它当「改动」会让打开面板就变「未保存」。 */
1742
- const editableItems = CONFIG_ITEMS.filter((i) => i.readonly !== true);
1743
- const dirty = surface !== null && editableItems.some((i) => (draft[i.key] ?? "") !== (toDraft(surface.values, surface)[i.key] ?? ""));
1744
- /** 写完(保存/重置)之后重新读一份整面:写回执是 `{path, values}`、重置回执是 `{path, backupPath}`,
1745
- * 都不是整面——拿写回执当整面用,页头那行「数据目录」会显示成 undefined(#743 实测)。 */
1746
- const writeThenReload = react.useCallback(async (done) => {
1747
- setBusy(true);
1748
- setNotice(null);
1749
- setError(null);
1750
- const r = await fetchConfigSurface(props.getCall());
1751
- if (r.ok) {
1752
- setState({
1753
- kind: "ready",
1754
- surface: r.surface
1755
- });
1756
- setDraft(toDraft(r.surface.values, r.surface));
1757
- setNotice(done);
1758
- } else setState({
1759
- kind: "failed",
1760
- message: r.message
1761
- });
1762
- setBusy(false);
1763
- }, [props.getCall]);
1764
- const onSave = react.useCallback(async () => {
1765
- setBusy(true);
1766
- setNotice(null);
1767
- setError(null);
1768
- const r = await saveConfigSurface(props.getCall(), fromDraft(draft));
1769
- setBusy(false);
1770
- if (r.ok) await writeThenReload("已保存,立即生效(不用重启宿主)");
1771
- else setError(r.message);
1772
- }, [
1773
- draft,
1774
- props.getCall,
1775
- writeThenReload
1776
- ]);
1777
- const onReset = react.useCallback(async () => {
1778
- setBusy(true);
1779
- setNotice(null);
1780
- setError(null);
1781
- const r = await resetConfigSurface(props.getCall());
1782
- setBusy(false);
1783
- if (r.ok) await writeThenReload("已重置为默认(原配置已另存一份 .bak)");
1784
- else setError(r.message);
1785
- }, [props.getCall, writeThenReload]);
1786
- /** 头部两行:配置文件路径与**生效**数据目录(#757:回执 `resolved.dbDir` 是技能按配置文件算出来的那一个,
1787
- * 装了旧技能没有那一组时回落到 `dataDir`——两个值在「数据目录留空」时逐字相同)。 */
1788
- const headDataDir = surface?.resolved?.dbDir ?? surface?.dataDir ?? "";
1789
- const head = react.createElement("div", null, react.createElement("div", { style: S.title }, `${SLOT_TITLE} · 配置`), surface !== null ? react.createElement("div", null, react.createElement("div", { style: S.info }, `配置文件 ${surface.path}`), react.createElement("div", { style: S.info }, `数据目录 ${headDataDir}`), surface.created ? react.createElement("div", { style: S.muted }, "(配置文件刚按默认值生成)") : null) : null);
1790
- if (state.kind === "loading") return react.createElement("div", { style: S.card }, head, react.createElement("div", { style: S.muted }, "配置读取中"));
1791
- if (state.kind === "failed") return react.createElement("div", { style: S.card }, react.createElement("div", { style: S.title }, `${SLOT_TITLE} · 配置`), react.createElement("div", { style: S.error }, state.message), react.createElement("div", { style: S.bar }, react.createElement("button", {
1792
- style: S.btn,
1793
- type: "button",
1794
- onClick: () => void load()
1795
- }, "重试"), react.createElement("button", {
1796
- style: S.btn,
1797
- type: "button",
1798
- onClick: () => void onReset()
1799
- }, "重置为默认")), react.createElement(VersionLine, {
2474
+ return react.createElement(VersionLine, {
1800
2475
  pluginVersion: versions.plugin,
1801
2476
  skillVersion: versions.skill
1802
- }));
1803
- const common = CONFIG_ITEMS.slice(0, COMMON_ITEM_COUNT);
1804
- const advanced = CONFIG_ITEMS.slice(COMMON_ITEM_COUNT);
1805
- const renderRow = (item) => react.createElement(Row, {
1806
- key: item.key,
1807
- item,
1808
- value: draft[item.key] ?? "",
1809
- disabled: busy,
1810
- onChange,
1811
- browser: rowEntry
1812
2477
  });
1813
- return react.createElement("div", { style: S.card }, head, react.createElement("div", { style: S.rows }, common.map(renderRow)), react.createElement("details", { style: S.advanced }, react.createElement("summary", { style: S.summary }, ADVANCED_GROUP_TITLE), react.createElement("div", { style: S.muted }, ADVANCED_GROUP_NOTE), advanced.map(renderRow)), react.createElement("div", { style: S.bar }, react.createElement("button", {
1814
- style: dirty ? S.btnPrimary : S.btn,
1815
- type: "button",
1816
- disabled: busy || !dirty,
1817
- onClick: () => void onSave()
1818
- }, busy ? "处理中" : "保存"), react.createElement("button", {
1819
- style: S.btn,
1820
- type: "button",
1821
- disabled: busy,
1822
- onClick: () => void onReset()
1823
- }, "重置为默认"), react.createElement("button", {
1824
- style: S.btn,
1825
- type: "button",
1826
- disabled: busy,
1827
- onClick: () => void load()
1828
- }, "重新读取")), picking ? react.createElement("div", { style: S.muted }, "已唤起系统文件夹对话框:选中后自动填上,取消则不动。") : null, browseRow !== null ? react.createElement(DirectoryBrowserFromRow, {
1829
- open: true,
1830
- row: browseRow,
1831
- labels: {
1832
- title: "选择文件夹",
1833
- close: "关闭",
1834
- up: "上一级",
1835
- pathPlaceholder: "直接填绝对路径,回车即进入",
1836
- go: "转到",
1837
- showHidden: (n) => "显示隐藏目录(" + n + ")",
1838
- empty: "这个目录里没有子目录。",
1839
- loading: "正在读取",
1840
- newFolder: "新建文件夹",
1841
- createConfirm: "创建",
1842
- createCancel: "取消",
1843
- select: "选",
1844
- selected: "已选",
1845
- open: "选定这个目录",
1846
- cancel: "取消",
1847
- willPick: "将选定:"
1848
- }
1849
- }) : null, notice !== null ? react.createElement("div", { style: S.okText }, notice) : null, error !== null ? react.createElement("div", { style: S.error }, error) : null, react.createElement(VersionLine, {
1850
- pluginVersion: versions.plugin,
1851
- skillVersion: versions.skill
1852
- }));
1853
2478
  }
1854
2479
  function apply(ctx) {
1855
2480
  const getCall = () => ctx.connection?.rpc?.call ?? null;
1856
- const getPicker = () => typeof ctx.get === "function" ? resolveDirectoryPicker((name) => ctx.get?.(name)) : null;
2481
+ const getService = (name) => typeof ctx.get === "function" ? ctx.get(name) : void 0;
1857
2482
  ctx.slots.inject("ilife.config-tab", () => ctx.slots.register({
1858
2483
  name: "ilife.config-tab",
1859
- id: "dsh-calorie",
2484
+ id: PLUGIN,
1860
2485
  order: 75,
1861
2486
  label: () => SLOT_TITLE,
1862
2487
  channel: RPC_CHANNEL
1863
- }, () => react.createElement(CalorieConfig, {
2488
+ }, () => react.createElement(ConfigPanel, {
2489
+ channel: RPC_CHANNEL,
2490
+ items: CONFIG_ITEMS,
2491
+ title: SLOT_TITLE,
2492
+ followKeysOf,
2493
+ extra: () => react.createElement(CalorieVersionLine, { getCall }),
1864
2494
  getCall,
1865
- pickerSource: getPicker,
1866
- rootsSource: createRootsSource({
1867
- getCall,
1868
- base: MANAGER_RPC_BASE,
1869
- endpoint: MANAGER_RPC_ENDPOINT,
1870
- method: MANAGER_ROOTS_METHOD
1871
- })
2495
+ getService
1872
2496
  })));
1873
2497
  const ensureWorkTab = () => {
1874
2498
  try {
@@ -1904,29 +2528,15 @@ window.__ModuleLoader__.load({
1904
2528
  }
1905
2529
  }
1906
2530
  //#endregion
1907
- exports.Row = Row;
1908
2531
  exports.VERSION_READ_KEY = VERSION_READ_KEY;
1909
2532
  exports.VERSION_UNKNOWN = VERSION_UNKNOWN;
1910
2533
  exports.VersionLine = VersionLine;
1911
2534
  exports.apply = apply;
1912
- exports.createBrowseHandler = createBrowseHandler;
1913
- exports.directoryEntryMode = directoryEntryMode;
1914
- exports.fetchConfigSurface = fetchConfigSurface;
1915
2535
  exports.fetchVersions = fetchVersions;
2536
+ exports.followKeysOf = followKeysOf;
1916
2537
  exports.formatVersionLine = formatVersionLine;
1917
- exports.fromDraft = fromDraft;
1918
- exports.humanizeConfigFailure = humanizeConfigFailure;
1919
2538
  exports.inject = inject;
1920
- exports.isDirectoryPicker = isDirectoryPicker;
1921
2539
  exports.normalizeVersion = normalizeVersion;
1922
- exports.openRowBrowser = openRowBrowser;
1923
- exports.pickDirectory = pickDirectory;
1924
- exports.pickerModeOf = pickerModeOf;
1925
- exports.readPickAnswer = readPickAnswer;
1926
- exports.resetConfigSurface = resetConfigSurface;
1927
- exports.resolveDirectoryPicker = resolveDirectoryPicker;
1928
- exports.saveConfigSurface = saveConfigSurface;
1929
- exports.toDraft = toDraft;
1930
2540
  return module.exports;
1931
2541
  }
1932
2542
  });