dsh-calorie 0.2.4 → 0.2.6

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
@@ -37,6 +37,10 @@ window.__ModuleLoader__.load({
37
37
  */
38
38
  const RPC_CHANNEL = "/ilife-calorie";
39
39
  const RPC_ENDPOINT_READ = "read";
40
+ /** #676 配置端点:读设置页整面、保存一份取值、重置为默认。同走一条通道,靠端点名分发。 */
41
+ const RPC_ENDPOINT_CONFIG_GET = "config.get";
42
+ const RPC_ENDPOINT_CONFIG_SAVE = "config.save";
43
+ const RPC_ENDPOINT_CONFIG_RESET = "config.reset";
40
44
  /** 面板默认读键(与 HITL 一致性对比同键)。 */
41
45
  const DEFAULT_READ_KEY = "calorie.view.home";
42
46
  /** 信封守卫:供 client 拆包前校验(port 拥有信封定义,守卫住 port)。 */
@@ -50,18 +54,134 @@ window.__ModuleLoader__.load({
50
54
  const SLOT_TITLE = "卡路里";
51
55
  const PLUGIN = "dsh-calorie";
52
56
  const SKILL_PACKAGE = "skill-calorie";
53
- /** 版本行唯一来源(面板展示用;smoke 断言与两处 package.json 一致,防漂移)。
54
- * 发版 bump 时同步改这里(与 package.json 同值)。 */
55
- const PLUGIN_VERSION = "0.2.4";
56
- const SKILL_VERSION = "0.2.3";
57
57
  //#endregion
58
58
  //#region src/settings.ts
59
- /** 脚手架期唯一设置行:总开关(缺省启用,与官方关闭语义对齐)。 */
60
- const SETTING_ROWS = [{
61
- key: "enabled",
62
- title: "卡路里面板启用",
63
- control: "switch"
64
- }];
59
+ /** 常用项:用户裁决三点的五项——改了就影响「库与产物落在哪、训记认不认你」,放页面上。 */
60
+ const COMMON = [
61
+ {
62
+ key: "db.dir",
63
+ title: "数据目录",
64
+ tier: "common",
65
+ control: "text",
66
+ hint: "库文件与各类产物的根目录。留空=按默认落点(配置目录下的 data/),也就是改造前那个位置。"
67
+ },
68
+ {
69
+ key: "db.name",
70
+ title: "库文件名",
71
+ tier: "common",
72
+ control: "text",
73
+ hint: "数据目录下的数据库文件名。改它等于换一个库,老数据留在旧文件里不会跟过来。"
74
+ },
75
+ {
76
+ key: "html.dir",
77
+ title: "HTML 产物目录名",
78
+ tier: "common",
79
+ control: "text",
80
+ hint: "数据目录下放交付页面的子目录名。改它之后新页面落新地方,旧页面留在原处。"
81
+ },
82
+ {
83
+ key: "photos.dir",
84
+ title: "照片目录",
85
+ tier: "common",
86
+ control: "text",
87
+ hint: "身材照的存放目录,绝对路径。留空=未配:读照片不报错,出 GIF 与写照片会被拦下。"
88
+ },
89
+ {
90
+ key: "xunji.key",
91
+ title: "训记 KEY",
92
+ tier: "common",
93
+ control: "text",
94
+ hint: "训记用的凭据。留空=没配,推送与回写会被拦下;它同时是敏感值,别贴给别人。"
95
+ }
96
+ ];
97
+ /** 高级项:默认收起(13 项减常用 5 项=8 项)。改错了多半只是自己撞上麻烦,不确定就别动。 */
98
+ const ADVANCED = [
99
+ {
100
+ key: "photos.gifs",
101
+ title: "照片 GIF 子目录",
102
+ tier: "advanced",
103
+ control: "text",
104
+ hint: "照片目录下放 GIF 的子目录名。"
105
+ },
106
+ {
107
+ key: "xunji.stateDir",
108
+ title: "训记状态文件目录",
109
+ tier: "advanced",
110
+ control: "text",
111
+ hint: "限频记账点与同步游标放哪。留空=家目录下的 .mavis,也就是改造前那个位置。"
112
+ },
113
+ {
114
+ key: "xunji.catalog",
115
+ title: "训记动作库路径",
116
+ tier: "advanced",
117
+ control: "text",
118
+ hint: "动作名判定用哪份库。留空=用包内预置的那份,也就是改造前那个位置。"
119
+ },
120
+ {
121
+ key: "xunji.backfillDays",
122
+ title: "回写默认天数",
123
+ tier: "advanced",
124
+ control: "number",
125
+ hint: "从训记拉实绩时不带天数参数时的窗口,整数天。"
126
+ },
127
+ {
128
+ key: "land.scheduleCli",
129
+ title: "跨技能出口 · 作息",
130
+ tier: "advanced",
131
+ control: "text",
132
+ hint: "「落地训练」调作息那一步的入口文件。留空=按包布局算(改造前那个位置)。"
133
+ },
134
+ {
135
+ key: "land.memoCli",
136
+ title: "跨技能出口 · 备忘",
137
+ tier: "advanced",
138
+ control: "text",
139
+ hint: "「落地训练」调备忘那一步的入口文件,同上。"
140
+ },
141
+ {
142
+ key: "land.xunjiSeconds",
143
+ title: "训记调用限时(秒)",
144
+ tier: "advanced",
145
+ control: "number",
146
+ hint: "外部调训记超过这个秒数即 exit 4。机器慢就调大。"
147
+ },
148
+ {
149
+ key: "land.landSeconds",
150
+ title: "落地调用限时(秒)",
151
+ tier: "advanced",
152
+ control: "number",
153
+ hint: "落地训练那几步超过这个秒数即 exit 4,同上。"
154
+ }
155
+ ];
156
+ /** 设置页的行表,顺序即页面顺序。 */
157
+ const CONFIG_ITEMS = [...COMMON, ...ADVANCED];
158
+ /** 常用项行数(页面上直接画出来的那些)。 */
159
+ const COMMON_ITEM_COUNT = COMMON.length;
160
+ /** 高级组标题与副文案。 */
161
+ const ADVANCED_GROUP_TITLE = "高级";
162
+ const ADVANCED_GROUP_NOTE = "不常改。留空=按默认落点(改造前那个位置),不确定就别动。";
163
+ /** 按 `a.b` 路径从配置取值里读(缺层或类型不符一律回 undefined)。 */
164
+ function readPath(values, key) {
165
+ const parts = key.split(".");
166
+ let cur = values;
167
+ for (const part of parts) {
168
+ if (typeof cur !== "object" || cur === null || Array.isArray(cur)) return void 0;
169
+ cur = cur[part];
170
+ }
171
+ return cur;
172
+ }
173
+ /** 按 `a.b` 路径把值写进配置取值(中间层缺就建一层空组;已存在非组即覆盖成组)。 */
174
+ function writePath(values, key, value) {
175
+ const parts = key.split(".");
176
+ let cur = values;
177
+ for (let i = 0; i < parts.length - 1; i++) {
178
+ const part = parts[i];
179
+ const next = cur[part];
180
+ if (typeof next !== "object" || next === null || Array.isArray(next)) cur[part] = {};
181
+ cur = cur[part];
182
+ }
183
+ cur[parts[parts.length - 1]] = value;
184
+ }
65
185
  //#endregion
66
186
  //#region src/client.ts
67
187
  /** dsh-calorie client 适配器(六边形:port=contract+dsh-ctx 镜像,adapter=本文件)。
@@ -80,6 +200,12 @@ window.__ModuleLoader__.load({
80
200
  * connection.rpc.call 经闭包现取、betterSidebar 经 ctx.get 运行时取);
81
201
  * 取数只经 connection.rpc.call 进 host 通道;缺席/错误纯条件渲染,不返空冒充。
82
202
  * 组件 React.createElement 手写,不引入 JSX。
203
+ *
204
+ * #676:技能设置页从「只读设置行 + 状态读数」换成**真配置页**——行表来自 settings.ts,
205
+ * 取值来自宿主侧配置件(`config.get`/`config.save`/`config.reset` 三个端点)。
206
+ * 本文件仍禁 node:不读盘、不算默认值,默认值由宿主一并送来。
207
+ * 照 CONTEXT.md,「技能设置页」只配置、不干活:本页没有任何取数/记一餐入口,
208
+ * 状态读数也已移出(读数属技能功能页)。
83
209
  */
84
210
  const inject = ["slots", "connection"];
85
211
  /** 面板视觉(内联 style;颜色走 DSH 主题别名,深浅主题自适应,写死值只做回退)。 */
@@ -111,6 +237,10 @@ window.__ModuleLoader__.load({
111
237
  color: "var(--dsw-alias-state-error-primary, #ff6b6b)",
112
238
  fontSize: 13
113
239
  },
240
+ okText: {
241
+ color: "var(--dsw-alias-state-success-primary, #12805c)",
242
+ fontSize: 12.5
243
+ },
114
244
  rows: {
115
245
  marginTop: 8,
116
246
  borderTop: "1px solid var(--dsw-alias-border, rgba(128,128,128,.25))",
@@ -122,11 +252,118 @@ window.__ModuleLoader__.load({
122
252
  borderTop: "1px dashed var(--dsw-alias-border, rgba(128,128,128,.25))",
123
253
  color: "var(--dsw-alias-label-tertiary, #8a8a8a)",
124
254
  fontSize: 12
255
+ },
256
+ info: {
257
+ margin: "6px 0 0",
258
+ font: "12px/1.5 ui-monospace,Consolas,monospace",
259
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)",
260
+ wordBreak: "break-all"
261
+ },
262
+ row: { marginTop: 10 },
263
+ label: {
264
+ fontSize: 12.5,
265
+ fontWeight: 600
266
+ },
267
+ hint: {
268
+ color: "var(--dsw-alias-label-tertiary, #8a8a8a)",
269
+ fontSize: 11.5,
270
+ margin: "1px 0 4px"
271
+ },
272
+ input: {
273
+ width: "100%",
274
+ boxSizing: "border-box",
275
+ padding: "4px 8px",
276
+ borderRadius: 6,
277
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.45))",
278
+ background: "var(--dsw-alias-bg-base, transparent)",
279
+ color: "var(--dsw-alias-label-primary, inherit)",
280
+ font: "12.5px/1.5 ui-monospace,Consolas,monospace"
281
+ },
282
+ bar: {
283
+ marginTop: 14,
284
+ display: "flex",
285
+ gap: 8,
286
+ alignItems: "center",
287
+ flexWrap: "wrap"
288
+ },
289
+ btn: {
290
+ padding: "5px 14px",
291
+ borderRadius: 7,
292
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.45))",
293
+ background: "var(--dsw-alias-bg-base, transparent)",
294
+ color: "var(--dsw-alias-label-primary, inherit)",
295
+ fontSize: 12.5,
296
+ cursor: "pointer"
297
+ },
298
+ btnPrimary: {
299
+ padding: "5px 14px",
300
+ borderRadius: 7,
301
+ border: "1px solid var(--dsw-alias-brand-primary, #2f6fed)",
302
+ background: "var(--dsw-alias-brand-primary, #2f6fed)",
303
+ color: "#fff",
304
+ fontSize: 12.5,
305
+ cursor: "pointer"
306
+ },
307
+ adv: {
308
+ marginTop: 12,
309
+ paddingTop: 8,
310
+ borderTop: "1px solid var(--dsw-alias-border, rgba(128,128,128,.25))"
311
+ },
312
+ advSummary: {
313
+ cursor: "pointer",
314
+ fontSize: 12.5,
315
+ fontWeight: 600
125
316
  }
126
317
  };
127
- /** 版本行:插件与技能双版本号(用户要求:每技能设置页自报家门)。 */
128
- function VersionLine() {
129
- return react.createElement("div", { style: S.version }, `${PLUGIN} ${PLUGIN_VERSION} · ${SKILL_PACKAGE} ${SKILL_VERSION}`);
318
+ /** #130 版本通道(host 侧动态读取已安装版本,client 纯渲染+unknown 降级)。
319
+ *
320
+ * 唯一真相源=磁盘 package.json(host 侧 bridge.readInstalledVersions 读取双 package.json,
321
+ * 经现有 connection.rpc.call 模式〈同 READ 端点、VERSION_READ_KEY 魔键〉传值,读失败 host 写 warn)。
322
+ * 本文件禁 node/禁 DOM 直写(document/window/process),故不得直读文件;
323
+ * 只经 connection.rpc.call 取版本,取不到侧显示 unknown,骨架不变,余部照常,永不抛、永不重试风暴。
324
+ * VERSION_READ_KEY 与 bridge.VERSION_READ_KEY 同值(client 禁直引 bridge〈会带入 node〉,故镜像定义,权威出处见 bridge.ts)。
325
+ * 不引入新运行时依赖;不改唤醒词/triggers/HELP/设置行。
326
+ */
327
+ const VERSION_READ_KEY = "dsh-calorie.version";
328
+ const VERSION_UNKNOWN = "unknown";
329
+ /** 版本归一:非空字符串原样(去首尾空格),余者一律 unknown(降级骨架不断、永不抛)。 */
330
+ function normalizeVersion(v) {
331
+ return typeof v === "string" && v.trim().length > 0 ? v.trim() : VERSION_UNKNOWN;
332
+ }
333
+ /** 版本行文本(纯函数,供 VersionLine 与单测复用;骨架恒为 `dsh-calorie X · skill-calorie Y`)。 */
334
+ function formatVersionLine(pluginVersion, skillVersion) {
335
+ return `${PLUGIN} ${normalizeVersion(pluginVersion)} · ${SKILL_PACKAGE} ${normalizeVersion(skillVersion)}`;
336
+ }
337
+ /** 版本行:纯渲染 host 传来的值+unknown 降级(用户要求:每技能设置页自报家门)。 */
338
+ function VersionLine(props) {
339
+ return react.createElement("div", { style: S.version }, formatVersionLine(props.pluginVersion, props.skillVersion));
340
+ }
341
+ /** 版本取值(与 fetchRead 同 connection.rpc.call 模式;单次、无轮询;任何失败→双 unknown,永不抛)。 */
342
+ async function fetchVersions(call) {
343
+ const fallback = {
344
+ plugin: VERSION_UNKNOWN,
345
+ skill: VERSION_UNKNOWN
346
+ };
347
+ if (typeof call !== "function") return { ...fallback };
348
+ try {
349
+ const raw = await withTimeout(call("/api", RPC_CHANNEL.slice(1), {
350
+ method: RPC_ENDPOINT_READ,
351
+ payload: {
352
+ key: VERSION_READ_KEY,
353
+ params: {}
354
+ }
355
+ }, AbortSignal.timeout(READ_TIMEOUT_MS)), READ_TIMEOUT_MS);
356
+ if (!isRpcResult(raw) || !raw.ok) return { ...fallback };
357
+ const v = raw.value;
358
+ if (typeof v !== "object" || v === null) return { ...fallback };
359
+ const rec = v;
360
+ return {
361
+ plugin: normalizeVersion(rec.plugin),
362
+ skill: normalizeVersion(rec.skill)
363
+ };
364
+ } catch {
365
+ return { ...fallback };
366
+ }
130
367
  }
131
368
  /** 本地今日 YYYY-MM-DD(与面板默认读键的 date 参数同形)。 */
132
369
  function todayString() {
@@ -230,6 +467,10 @@ window.__ModuleLoader__.load({
230
467
  /** 技能功能页:sidebar槽里展开的干活区(查数调数,不管配置)。挂载期一次 RPC。 */
231
468
  function CalorieWork(props) {
232
469
  const [state, setState] = react.useState({ kind: "loading" });
470
+ const [versions, setVersions] = react.useState({
471
+ plugin: VERSION_UNKNOWN,
472
+ skill: VERSION_UNKNOWN
473
+ });
233
474
  react.useEffect(() => {
234
475
  let alive = true;
235
476
  const date = todayString();
@@ -264,46 +505,173 @@ window.__ModuleLoader__.load({
264
505
  alive = false;
265
506
  };
266
507
  }, [props.getCall]);
508
+ react.useEffect(() => {
509
+ let alive = true;
510
+ (async () => {
511
+ try {
512
+ const v = await fetchVersions(props.getCall());
513
+ if (!alive) return;
514
+ setVersions(v);
515
+ } catch {}
516
+ })();
517
+ return () => {
518
+ alive = false;
519
+ };
520
+ }, [props.getCall]);
267
521
  if (state.kind === "loading") return react.createElement("div", { style: S.card }, react.createElement("div", { style: S.muted }, "卡路里加载中…"));
268
- 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, null));
269
- return react.createElement("div", { style: S.card }, react.createElement("div", { style: state.kind === "absent" ? S.muted : S.error }, state.message), react.createElement(VersionLine, null));
522
+ 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, {
523
+ pluginVersion: versions.plugin,
524
+ skillVersion: versions.skill
525
+ }));
526
+ return react.createElement("div", { style: S.card }, react.createElement("div", { style: state.kind === "absent" ? S.muted : S.error }, state.message), react.createElement(VersionLine, {
527
+ pluginVersion: versions.plugin,
528
+ skillVersion: versions.skill
529
+ }));
530
+ }
531
+ /** 配置面报错 → 人话指引。
532
+ *
533
+ * 报文由技能侧给出:base-link-core 的 `ConfigError` 已经带行号与文件名,技能
534
+ * `cli/config.ts` 原样交出来,插件 bridge 原样带上(`fetch-failed:出口非 0(1):<那一句>`)。
535
+ * 所以这里**不重写报文**,只按报错类补一句「接下来怎么办」——免得两处各写一套人话。
536
+ */
537
+ function humanizeConfigFailure(code, message) {
538
+ const text = message.trim().length > 0 ? message.trim() : `(${code},宿主未给报文)`;
539
+ if (/解析|YAML|parse/i.test(text)) return `${text}\n改回「键: 值」的写法,或点「重置为默认」重来一份(重置会先留一份 .bak)。`;
540
+ if (/不认识|未知键|UNKNOWN_KEY/i.test(text)) return `${text}\n本页只认表里这些项;多余的行删掉,或点「重置为默认」。`;
541
+ if (/类型|TYPE_MISMATCH/i.test(text)) return `${text}\n按本页给的形状填(文本填文本、数字填数字),或点「重置为默认」。`;
542
+ if (/缺席|missing-cli/.test(text)) return `${text}\n技能出口没找着:先确认技能包已构建(packages/skill-calorie/dist/cli/cmd_read.js)。`;
543
+ return `${text}\n改不动就点「重置为默认」(会先留一份 .bak),或照报文里的路径手工改配置文件。`;
544
+ }
545
+ /** 一次配置 RPC(与 fetchRead 同通道、同超时纪律;永不抛,失败落字)。 */
546
+ async function configRpc(call, method, payload) {
547
+ if (typeof call !== "function") return {
548
+ ok: false,
549
+ message: "宿主连接缺席:connection.rpc.call 不可用"
550
+ };
551
+ try {
552
+ const raw = await withTimeout(call("/api", RPC_CHANNEL.slice(1), {
553
+ method,
554
+ payload
555
+ }, AbortSignal.timeout(READ_TIMEOUT_MS)), READ_TIMEOUT_MS);
556
+ if (!isRpcResult(raw)) return {
557
+ ok: false,
558
+ message: "回执信封异常(非 ok 信封)"
559
+ };
560
+ if (!raw.ok) return {
561
+ ok: false,
562
+ message: humanizeConfigFailure(raw.error?.code ?? "unknown", raw.error?.message ?? "")
563
+ };
564
+ return {
565
+ ok: true,
566
+ surface: raw.value
567
+ };
568
+ } catch (e) {
569
+ if (e instanceof Error && e.name === "AbortError") return {
570
+ ok: false,
571
+ message: `配置读取超时(${Math.round(READ_TIMEOUT_MS / 1e3)}s):宿主未回`
572
+ };
573
+ if (e instanceof Error && e.name === "TimeoutError") return {
574
+ ok: false,
575
+ message: e.message
576
+ };
577
+ return {
578
+ ok: false,
579
+ message: `配置失败:${e instanceof Error ? e.message : String(e)}`
580
+ };
581
+ }
582
+ }
583
+ /** 读设置页整面。 */
584
+ function fetchConfigSurface(call) {
585
+ return configRpc(call, RPC_ENDPOINT_CONFIG_GET, {});
270
586
  }
271
- function controlLabel(control) {
272
- if (control === "switch") return "开关(缺省启用,只读)";
273
- if (control === "text") return "文本(只读)";
274
- if (control === "number") return "数字(只读)";
275
- return `${control}(只读)`;
587
+ /** 保存(回全表)。 */
588
+ function saveConfigSurface(call, values) {
589
+ return configRpc(call, RPC_ENDPOINT_CONFIG_SAVE, { values });
276
590
  }
277
- /** 技能设置页:爱生活页签条下的一页(只读设置行 + 状态读数 + 版本行,禁做假开关)。 */
591
+ /** 重置为默认(配置件先落 .bak)。 */
592
+ function resetConfigSurface(call) {
593
+ return configRpc(call, RPC_ENDPOINT_CONFIG_RESET, {});
594
+ }
595
+ /** 把配置取值铺成「行键 → 输入框文本」(页面表单态;值缺项即空串,不返空留白)。 */
596
+ function toDraft(values) {
597
+ const draft = {};
598
+ for (const item of CONFIG_ITEMS) {
599
+ const v = readPath(values, item.key);
600
+ draft[item.key] = v === void 0 || v === null ? "" : String(v);
601
+ }
602
+ return draft;
603
+ }
604
+ /** 表单态 → 配置取值(按控件种类还原类型;空串对文本项照收,语义由「未配」承担)。 */
605
+ function fromDraft(draft) {
606
+ const values = {};
607
+ for (const item of CONFIG_ITEMS) {
608
+ const raw = draft[item.key] ?? "";
609
+ const value = item.control === "number" ? Number(raw) : item.control === "switch" ? raw === "true" : raw;
610
+ writePath(values, item.key, item.control === "number" && !Number.isFinite(value) ? 0 : value);
611
+ }
612
+ return values;
613
+ }
614
+ /** 一行输入(三种控件对齐受限 YAML 子集:文本/数字/布尔)。 */
615
+ function Row(props) {
616
+ const { item } = props;
617
+ const onChange = (e) => props.onChange(item.key, e.target.value);
618
+ const control = item.control === "switch" ? react.createElement("input", {
619
+ type: "checkbox",
620
+ checked: props.value === "true",
621
+ disabled: props.disabled,
622
+ onChange: (e) => props.onChange(item.key, e.target.checked ? "true" : "false")
623
+ }) : react.createElement("input", {
624
+ style: S.input,
625
+ type: item.control === "number" ? "number" : "text",
626
+ value: props.value,
627
+ disabled: props.disabled,
628
+ spellCheck: false,
629
+ onChange
630
+ });
631
+ return react.createElement("div", { style: S.row }, react.createElement("div", { style: S.label }, item.title), react.createElement("div", { style: S.hint }, item.hint), control);
632
+ }
633
+ /** 技能设置页:承载卡路里自己的全部可配置项(只配置,不干活)。
634
+ *
635
+ * 三态:loading(一次 RPC 内)/ready(真表单)/failed(人话报错 + 指引,不返空、不转圈)。
636
+ * 没有任何干活入口——查数、记一餐在技能功能页(sidebar槽)。 */
278
637
  function CalorieConfig(props) {
279
638
  const [state, setState] = react.useState({ kind: "loading" });
639
+ const [draft, setDraft] = react.useState({});
640
+ const [busy, setBusy] = react.useState(false);
641
+ const [notice, setNotice] = react.useState(null);
642
+ const [error, setError] = react.useState(null);
643
+ const [versions, setVersions] = react.useState({
644
+ plugin: VERSION_UNKNOWN,
645
+ skill: VERSION_UNKNOWN
646
+ });
647
+ const load = react.useCallback(async () => {
648
+ setNotice(null);
649
+ setError(null);
650
+ const r = await fetchConfigSurface(props.getCall());
651
+ if (r.ok) {
652
+ setState({
653
+ kind: "ready",
654
+ surface: r.surface
655
+ });
656
+ setDraft(toDraft(r.surface.values));
657
+ } else setState({
658
+ kind: "failed",
659
+ message: r.message
660
+ });
661
+ }, [props.getCall]);
280
662
  react.useEffect(() => {
281
663
  let alive = true;
282
- const date = todayString();
283
664
  (async () => {
284
- let r;
285
- try {
286
- r = await fetchRead(props.getCall(), DEFAULT_READ_KEY, { date });
287
- } catch (e) {
288
- if (!alive) return;
665
+ const r = await fetchConfigSurface(props.getCall());
666
+ if (!alive) return;
667
+ if (r.ok) {
289
668
  setState({
290
- kind: "error",
291
- message: `取数失败:${e instanceof Error ? e.message : String(e)}`
669
+ kind: "ready",
670
+ surface: r.surface
292
671
  });
293
- return;
294
- }
295
- if (!alive) return;
296
- if (r.ok) setState({
297
- kind: "data",
298
- date,
299
- total: r.text
300
- });
301
- else if (r.absent) setState({
302
- kind: "absent",
303
- message: r.message
304
- });
305
- else setState({
306
- kind: "error",
672
+ setDraft(toDraft(r.surface.values));
673
+ } else setState({
674
+ kind: "failed",
307
675
  message: r.message
308
676
  });
309
677
  })();
@@ -311,8 +679,101 @@ window.__ModuleLoader__.load({
311
679
  alive = false;
312
680
  };
313
681
  }, [props.getCall]);
314
- const status = state.kind === "loading" ? react.createElement("div", { style: S.muted }, "状态读取中…") : state.kind === "data" ? react.createElement("div", null, react.createElement("div", { style: S.title }, SLOT_TITLE), react.createElement("div", { style: S.total }, `total ${state.total}`), react.createElement("div", { style: S.muted }, `今日 ${state.date}`)) : react.createElement("div", { style: state.kind === "absent" ? S.muted : S.error }, state.message);
315
- return react.createElement("div", { style: S.card }, status, react.createElement("div", { style: S.rows }, SETTING_ROWS.map((row) => react.createElement("div", { key: row.key }, `${row.title} · ${controlLabel(row.control)}`))), react.createElement(VersionLine, null));
682
+ react.useEffect(() => {
683
+ let alive = true;
684
+ (async () => {
685
+ try {
686
+ const v = await fetchVersions(props.getCall());
687
+ if (!alive) return;
688
+ setVersions(v);
689
+ } catch {}
690
+ })();
691
+ return () => {
692
+ alive = false;
693
+ };
694
+ }, [props.getCall]);
695
+ const onChange = react.useCallback((key, next) => {
696
+ setDraft((prev) => ({
697
+ ...prev,
698
+ [key]: next
699
+ }));
700
+ setNotice(null);
701
+ setError(null);
702
+ }, []);
703
+ const surface = state.kind === "ready" ? state.surface : null;
704
+ const dirty = surface !== null && CONFIG_ITEMS.some((i) => (draft[i.key] ?? "") !== (toDraft(surface.values)[i.key] ?? ""));
705
+ const onSave = react.useCallback(async () => {
706
+ setBusy(true);
707
+ setNotice(null);
708
+ setError(null);
709
+ const r = await saveConfigSurface(props.getCall(), fromDraft(draft));
710
+ setBusy(false);
711
+ if (r.ok) {
712
+ setState({
713
+ kind: "ready",
714
+ surface: r.surface
715
+ });
716
+ setDraft(toDraft(r.surface.values));
717
+ setNotice("已保存,立即生效(不用重启宿主)");
718
+ } else setError(r.message);
719
+ }, [draft, props.getCall]);
720
+ const onReset = react.useCallback(async () => {
721
+ setBusy(true);
722
+ setNotice(null);
723
+ setError(null);
724
+ const r = await resetConfigSurface(props.getCall());
725
+ setBusy(false);
726
+ if (r.ok) {
727
+ setState({
728
+ kind: "ready",
729
+ surface: r.surface
730
+ });
731
+ setDraft(toDraft(r.surface.values));
732
+ setNotice("已重置为默认(原配置已另存一份 .bak)");
733
+ } else setError(r.message);
734
+ }, [props.getCall]);
735
+ 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 }, `数据目录 ${surface.dataDir}`), surface.created ? react.createElement("div", { style: S.muted }, "(这份配置是刚按默认值建出来的)") : null) : null);
736
+ if (state.kind === "loading") return react.createElement("div", { style: S.card }, head, react.createElement("div", { style: S.muted }, "配置读取中…"));
737
+ 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", {
738
+ style: S.btn,
739
+ type: "button",
740
+ onClick: () => void load()
741
+ }, "重试"), react.createElement("button", {
742
+ style: S.btn,
743
+ type: "button",
744
+ onClick: () => void onReset()
745
+ }, "重置为默认")), react.createElement(VersionLine, {
746
+ pluginVersion: versions.plugin,
747
+ skillVersion: versions.skill
748
+ }));
749
+ const common = CONFIG_ITEMS.slice(0, COMMON_ITEM_COUNT);
750
+ const advanced = CONFIG_ITEMS.slice(COMMON_ITEM_COUNT);
751
+ const renderRow = (item) => react.createElement(Row, {
752
+ key: item.key,
753
+ item,
754
+ value: draft[item.key] ?? "",
755
+ disabled: busy,
756
+ onChange
757
+ });
758
+ return react.createElement("div", { style: S.card }, head, react.createElement("div", { style: S.rows }, common.map(renderRow)), react.createElement("details", { style: S.adv }, react.createElement("summary", { style: S.advSummary }, ADVANCED_GROUP_TITLE), react.createElement("div", { style: S.muted }, ADVANCED_GROUP_NOTE), advanced.map(renderRow)), react.createElement("div", { style: S.bar }, react.createElement("button", {
759
+ style: dirty ? S.btnPrimary : S.btn,
760
+ type: "button",
761
+ disabled: busy || !dirty,
762
+ onClick: () => void onSave()
763
+ }, busy ? "处理中…" : "保存"), react.createElement("button", {
764
+ style: S.btn,
765
+ type: "button",
766
+ disabled: busy,
767
+ onClick: () => void onReset()
768
+ }, "重置为默认"), react.createElement("button", {
769
+ style: S.btn,
770
+ type: "button",
771
+ disabled: busy,
772
+ onClick: () => void load()
773
+ }, "重新读取")), notice !== null ? react.createElement("div", { style: S.okText }, notice) : null, error !== null ? react.createElement("div", { style: S.error }, error) : null, react.createElement(VersionLine, {
774
+ pluginVersion: versions.plugin,
775
+ skillVersion: versions.skill
776
+ }));
316
777
  }
317
778
  function apply(ctx) {
318
779
  const getCall = () => ctx.connection?.rpc?.call ?? null;
@@ -320,7 +781,8 @@ window.__ModuleLoader__.load({
320
781
  name: "ilife.config-tab",
321
782
  id: "dsh-calorie",
322
783
  order: 75,
323
- label: () => SLOT_TITLE
784
+ label: () => SLOT_TITLE,
785
+ channel: RPC_CHANNEL
324
786
  }, () => react.createElement(CalorieConfig, { getCall })));
325
787
  const ensureWorkTab = () => {
326
788
  try {
@@ -356,8 +818,20 @@ window.__ModuleLoader__.load({
356
818
  }
357
819
  }
358
820
  //#endregion
821
+ exports.VERSION_READ_KEY = VERSION_READ_KEY;
822
+ exports.VERSION_UNKNOWN = VERSION_UNKNOWN;
823
+ exports.VersionLine = VersionLine;
359
824
  exports.apply = apply;
825
+ exports.fetchConfigSurface = fetchConfigSurface;
826
+ exports.fetchVersions = fetchVersions;
827
+ exports.formatVersionLine = formatVersionLine;
828
+ exports.fromDraft = fromDraft;
829
+ exports.humanizeConfigFailure = humanizeConfigFailure;
360
830
  exports.inject = inject;
831
+ exports.normalizeVersion = normalizeVersion;
832
+ exports.resetConfigSurface = resetConfigSurface;
833
+ exports.saveConfigSurface = saveConfigSurface;
834
+ exports.toDraft = toDraft;
361
835
  return module.exports;
362
836
  }
363
837
  });