dsh-home-ilife 0.3.8 → 0.3.12
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 +751 -470
- package/dist/client.js.map +1 -1
- package/dist/contract.d.ts +33 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/settings.d.ts +22 -6
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +32 -23
- package/dist/settings.js.map +1 -1
- package/package.json +3 -3
package/dist/client.js
CHANGED
|
@@ -28,64 +28,52 @@ window.__ModuleLoader__.load({
|
|
|
28
28
|
//#endregion
|
|
29
29
|
let react = require("react");
|
|
30
30
|
react = __toESM(react, 1);
|
|
31
|
-
//#region
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
key: "db.name",
|
|
48
|
-
title: "库文件名",
|
|
49
|
-
tier: "common",
|
|
50
|
-
control: "text",
|
|
51
|
-
hint: "数据目录下的数据库文件名。改名=换库,旧数据不会被读入。"
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
key: "html.dir",
|
|
55
|
-
title: "HTML 产物目录名",
|
|
56
|
-
tier: "common",
|
|
57
|
-
control: "text",
|
|
58
|
-
hint: "数据目录下存放交付页面的子目录名。"
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
key: "backup.dir",
|
|
62
|
-
title: "备份目录",
|
|
63
|
-
tier: "common",
|
|
64
|
-
control: "directory",
|
|
65
|
-
hint: "备份写出的目录。留空=数据目录下的 backups。"
|
|
66
|
-
}
|
|
67
|
-
];
|
|
68
|
-
/** 高级项:默认收起。改错了多半只是自己撞上麻烦,不确定就别动。 */
|
|
69
|
-
const ADVANCED = [{
|
|
70
|
-
key: "files.help",
|
|
71
|
-
title: "产物文件名主体 · HELP",
|
|
72
|
-
tier: "advanced",
|
|
73
|
-
control: "text",
|
|
74
|
-
hint: "HELP 文件名前缀,后面自动加时间戳。"
|
|
75
|
-
}, {
|
|
76
|
-
key: "files.lookup",
|
|
77
|
-
title: "产物文件名主体 · 速查表",
|
|
78
|
-
tier: "advanced",
|
|
79
|
-
control: "text",
|
|
80
|
-
hint: "速查表文件名前缀。"
|
|
81
|
-
}];
|
|
82
|
-
/** 设置页的行表,顺序即页面顺序。 */
|
|
83
|
-
const CONFIG_ITEMS = [...COMMON, ...ADVANCED];
|
|
84
|
-
/** 常用项行数(页面上直接画出来的那些)。 */
|
|
85
|
-
const COMMON_ITEM_COUNT = COMMON.length;
|
|
86
|
-
/** 高级组标题与副文案。 */
|
|
31
|
+
//#region ../plugin-manager/dist/config-panel-contract.js
|
|
32
|
+
/** 共用配置面板 · 契约:行表形状、配置面回执、几条两侧共用的常量。
|
|
33
|
+
*
|
|
34
|
+
* 为什么住这里:六个单品插件的设置页各有一份逐字相同的那套代码(卡片骨架/行渲染/样式表/
|
|
35
|
+
* 状态机/配置面三通电话约 3105 行)。本件是抽件之后的**唯一一处定义地**。
|
|
36
|
+
*
|
|
37
|
+
* 本件不认识任何一家能力:行表由调用方给,分支只能由行表数据与配置面回执决定,
|
|
38
|
+
* 里面不出现任何一个能力的名字(结构纪律「依赖方向」)。
|
|
39
|
+
*
|
|
40
|
+
* 对外只经 `config-panel-api.ts` 那一道门;本件自己不出门。
|
|
41
|
+
*/
|
|
42
|
+
/** 面板对配置面的三通电话:读整面/保存一份取值/重置为默认。三个名字各家逐字相同,收在这里一处。 */
|
|
43
|
+
const RPC_ENDPOINT_CONFIG_GET = "config.get";
|
|
44
|
+
const RPC_ENDPOINT_CONFIG_SAVE = "config.save";
|
|
45
|
+
const RPC_ENDPOINT_CONFIG_RESET = "config.reset";
|
|
46
|
+
/** 高级组标题与副文案(六家逐字相同,收在这里一处)。 */
|
|
87
47
|
const ADVANCED_GROUP_TITLE = "高级";
|
|
88
48
|
const ADVANCED_GROUP_NOTE = "不常改。留空=用默认值。";
|
|
49
|
+
/** 请求超时毫秒:与宿主侧 SPAWN_TIMEOUT_MS 同级,界面永不无限转圈。 */
|
|
50
|
+
const READ_TIMEOUT_MS = 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(raw) {
|
|
59
|
+
if (typeof raw !== "object" || raw === null) return false;
|
|
60
|
+
return raw.ok === true || raw.ok === false;
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
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
|
+
*/
|
|
89
77
|
/** 按 `a.b` 路径从配置取值里读(缺层或类型不符一律回 undefined)。 */
|
|
90
78
|
function readPath(values, key) {
|
|
91
79
|
const parts = key.split(".");
|
|
@@ -108,52 +96,157 @@ window.__ModuleLoader__.load({
|
|
|
108
96
|
}
|
|
109
97
|
cur[parts[parts.length - 1]] = value;
|
|
110
98
|
}
|
|
111
|
-
|
|
112
|
-
//#region src/contract.ts
|
|
113
|
-
/** dsh-home-ilife 端口(port):DSH 世界与居家管家六边形之间的契约。
|
|
99
|
+
/** 落点来源取一格:先看回执 `resolved` 组同名格,再看回执顶层同名格(两种写法收成同一格)。
|
|
114
100
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
+
/** 表单态 → 配置取值(按控件种类还原类型;空串对文本项照收,语义由「按默认落点」承担)。
|
|
117
131
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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;
|
|
131
146
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* 另一条路上的动词一律回它。
|
|
147
|
+
/** 可改行(脏基线只看这一档)。 */
|
|
148
|
+
function editableItemsOf(items) {
|
|
149
|
+
return items.filter((item) => item.readonly !== true);
|
|
150
|
+
}
|
|
151
|
+
/** 脏键:表单态里与「刚读到的整面」不一致的**可改行**。
|
|
138
152
|
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
/** 这一行有没有「写的值 ≠ 生效值」这回事:只要是可改行、标了落点来源、写的非空、
|
|
162
|
+
* 且技能算出来的生效值另有其值(去掉尾分隔符后仍不同),就报这两个事实。 */
|
|
163
|
+
function fallbackFactsOf(item, raw, source) {
|
|
164
|
+
if (item.readonly === true || item.prefillFrom === void 0) return null;
|
|
165
|
+
const written = raw.trim();
|
|
166
|
+
if (written === "") return null;
|
|
167
|
+
const effective = prefillValueOf(source, item.prefillFrom);
|
|
168
|
+
if (effective === void 0) return null;
|
|
169
|
+
const strip = (path) => path.replace(/[\\/]+$/, "");
|
|
170
|
+
if (strip(written) === strip(effective)) return null;
|
|
171
|
+
return {
|
|
172
|
+
written,
|
|
173
|
+
effective
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** 配置面报错 → 人话指引。
|
|
145
177
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
178
|
+
* 报文由技能侧给出(`base-link-core` 的 `ConfigError` 已带行号与文件名,技能 `cli/config.ts`
|
|
179
|
+
* 原样交出,插件 bridge 原样带上),所以这里**不重写报文**,只按报错类补一句「接下来怎么办」。 */
|
|
180
|
+
function humanizeConfigFailure(code, message) {
|
|
181
|
+
const text = message.trim().length > 0 ? message.trim() : `(${code},宿主未给报文)`;
|
|
182
|
+
if (/解析|YAML|parse/i.test(text)) return `${text}\n改回「键: 值」的写法,或点「重置为默认」。`;
|
|
183
|
+
if (/不认识|未知键|UNKNOWN_KEY/i.test(text)) return `${text}\n删掉页面上没有的行,或点「重置为默认」。`;
|
|
184
|
+
if (/类型|TYPE_MISMATCH/i.test(text)) return `${text}\n按本页的控件形状填,或点「重置为默认」。`;
|
|
185
|
+
if (/缺席|missing-cli/i.test(text)) return `${text}\n技能出口没找到:先确认技能包已装好,再点「重新读取」。`;
|
|
186
|
+
return `${text}\n改不动就点「重置为默认」,或照上面那条路径手工改配置文件。`;
|
|
187
|
+
}
|
|
188
|
+
/** 有界等待:借平台 `AbortSignal.timeout` 的 abort 事件做**一次**超时拒绝。
|
|
152
189
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
|
|
190
|
+
* 为什么不自建定时器:本仓冻结边界是「无数据轮询」,而设置页等的是一次电话回执,不是轮询;
|
|
191
|
+
* 用平台自带的一次性超时既守住边界的本意,也把「不转圈」落到实处。同一个 signal 也交给传输,
|
|
192
|
+
* 双保险:它搭理 signal 就早收工,不理也有这一道兜底(桌面端自研传输会忽略 signal,见 #760)。 */
|
|
193
|
+
function withDeadline(promise, ms) {
|
|
194
|
+
const signal = AbortSignal.timeout(ms);
|
|
195
|
+
const limit = new Promise((_, reject) => {
|
|
196
|
+
signal.addEventListener("abort", () => {
|
|
197
|
+
const e = /* @__PURE__ */ new Error(`配置请求超时(${Math.round(ms / 1e3)}s):宿主未回`);
|
|
198
|
+
e.name = "TimeoutError";
|
|
199
|
+
reject(e);
|
|
200
|
+
}, { once: true });
|
|
201
|
+
});
|
|
202
|
+
return Promise.race([promise, limit]);
|
|
203
|
+
}
|
|
204
|
+
/** 一次配置电话(走调用方给的那条通道,靠端点名分发;永不抛,失败落字)。 */
|
|
205
|
+
async function configRpc(call, channel, method, payload) {
|
|
206
|
+
if (typeof call !== "function") return {
|
|
207
|
+
ok: false,
|
|
208
|
+
message: "宿主连接缺席:connection.rpc.call 不可用"
|
|
209
|
+
};
|
|
210
|
+
try {
|
|
211
|
+
const raw = await withDeadline(call("/api", channel.replace(/^\//, ""), {
|
|
212
|
+
method,
|
|
213
|
+
payload
|
|
214
|
+
}, AbortSignal.timeout(READ_TIMEOUT_MS)), READ_TIMEOUT_MS);
|
|
215
|
+
if (!isRpcResult(raw)) return {
|
|
216
|
+
ok: false,
|
|
217
|
+
message: "回执信封异常(非 ok 信封)"
|
|
218
|
+
};
|
|
219
|
+
if (!raw.ok) return {
|
|
220
|
+
ok: false,
|
|
221
|
+
message: humanizeConfigFailure(raw.error?.code ?? "unknown", raw.error?.message ?? "")
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
surface: raw.value
|
|
226
|
+
};
|
|
227
|
+
} catch (e) {
|
|
228
|
+
if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) return {
|
|
229
|
+
ok: false,
|
|
230
|
+
message: `配置请求超时(${Math.round(READ_TIMEOUT_MS / 1e3)}s):宿主未回`
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
message: `配置失败:${e instanceof Error ? e.message : String(e)}`
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** 读设置页整面。 */
|
|
239
|
+
function fetchConfigSurface(call, channel) {
|
|
240
|
+
return configRpc(call, channel, RPC_ENDPOINT_CONFIG_GET, {});
|
|
241
|
+
}
|
|
242
|
+
/** 保存一份取值(回执只有 `{path, values}`,**不是**整面:写完要重新读一份整面)。 */
|
|
243
|
+
function saveConfigSurface(call, channel, values) {
|
|
244
|
+
return configRpc(call, channel, RPC_ENDPOINT_CONFIG_SAVE, { values });
|
|
245
|
+
}
|
|
246
|
+
/** 重置为默认(技能侧先落一份备份;回执同样不是整面,写完要重新读一份整面)。 */
|
|
247
|
+
function resetConfigSurface(call, channel) {
|
|
248
|
+
return configRpc(call, channel, RPC_ENDPOINT_CONFIG_RESET, {});
|
|
249
|
+
}
|
|
157
250
|
//#endregion
|
|
158
251
|
//#region ../plugin-manager/dist/directory-browser-contract.js
|
|
159
252
|
/** 目录浏览器的纯逻辑半(**不认识宿主**:只认两格原语)。
|
|
@@ -187,7 +280,7 @@ window.__ModuleLoader__.load({
|
|
|
187
280
|
* 与组合里服务哪种能力无关),所以两格原语在=这台宿主**可能**给浏览;真调一次才知道给不给,
|
|
188
281
|
* 不给({@link CAPABILITY_REFUSED})再由调用方换系统对话框。反过来先认 `pick` 就会在
|
|
189
282
|
* Desktop 这种「只服务 browse」的宿主上每次都去唤一次系统对话框、被拒一次(#744 实测)。 */
|
|
190
|
-
function pickerModeOf
|
|
283
|
+
function pickerModeOf(raw) {
|
|
191
284
|
if (isBrowseFace(raw)) return "browse";
|
|
192
285
|
return hasPickFn(raw) ? "native" : "none";
|
|
193
286
|
}
|
|
@@ -196,7 +289,7 @@ window.__ModuleLoader__.load({
|
|
|
196
289
|
* 出处:`@deepseek-ai/dsh-api-gateway/lib/client.js` 的 `invoke()`(失败不抛、成功也不回裸值),
|
|
197
290
|
* 第一方消费方 `@deepseek-ai/dsh-client-ui-workspace/lib/client.js:99-103` 就照这个信封拆。
|
|
198
291
|
* 裸串照收(老形状兜底),认不出的形状当「供不了」报出来,不当取消吞掉(#743 的教训)。 */
|
|
199
|
-
function readPickAnswer
|
|
292
|
+
function readPickAnswer(raw) {
|
|
200
293
|
if (typeof raw === "string") return raw.trim() === "" ? { kind: "cancelled" } : {
|
|
201
294
|
kind: "picked",
|
|
202
295
|
path: raw
|
|
@@ -591,48 +684,6 @@ window.__ModuleLoader__.load({
|
|
|
591
684
|
});
|
|
592
685
|
}
|
|
593
686
|
//#endregion
|
|
594
|
-
//#region ../plugin-manager/dist/directory-browser-roots.js
|
|
595
|
-
/** 「本机根清单」的取数接线(客户端半):把一通电话变成图上那一行可跳转的根。
|
|
596
|
-
*
|
|
597
|
-
* **不认识宿主**:载体基段、通道名(第二段)、电话名全由调用方传进来——各家从自己的宿主镜像里拿
|
|
598
|
-
* 那几个字面量(和 `openRowBrowser` 的 `refusalCode` 同一条规矩)。本件只做两件事:
|
|
599
|
-
* 发一次调用、把回执信封拆成根清单;**任何失败都当空清单**(图上少画一行,不报错、不挡路)。
|
|
600
|
-
*/
|
|
601
|
-
/** 认一认某个值像不像一个根(认不出的行丢掉:图的干净比多画一行重要)。 */
|
|
602
|
-
function isRootRow(raw) {
|
|
603
|
-
if (typeof raw !== "object" || raw === null) return false;
|
|
604
|
-
const row = raw;
|
|
605
|
-
return typeof row.path === "string" && row.path !== "" && typeof row.kind === "string";
|
|
606
|
-
}
|
|
607
|
-
/** 信封 → 根清单(纯函数,**永不抛**):`{ok:true, value:{roots:[…]}}` 之外的形状一律当空。 */
|
|
608
|
-
function readRootsAnswer(raw) {
|
|
609
|
-
const answer = typeof raw === "object" && raw !== null ? raw : {};
|
|
610
|
-
if (answer.ok !== true) return [];
|
|
611
|
-
const value = typeof answer.value === "object" && answer.value !== null ? answer.value : {};
|
|
612
|
-
if (!Array.isArray(value.roots)) return [];
|
|
613
|
-
return value.roots.filter(isRootRow).map((row) => ({
|
|
614
|
-
path: row.path,
|
|
615
|
-
kind: row.kind
|
|
616
|
-
}));
|
|
617
|
-
}
|
|
618
|
-
/** 建一个「根清单取数器」:给调用口与三个名字,回一个 `() => Promise<RootRow[]>`。
|
|
619
|
-
*
|
|
620
|
-
* 界面按需调它(开图一次),不在渲染里调,也不缓存到这里——缓存是宿主半的事。 */
|
|
621
|
-
function createRootsSource(input) {
|
|
622
|
-
return async () => {
|
|
623
|
-
const call = input.getCall();
|
|
624
|
-
if (typeof call !== "function") return [];
|
|
625
|
-
try {
|
|
626
|
-
return readRootsAnswer(await call(input.base, input.endpoint, {
|
|
627
|
-
method: input.method,
|
|
628
|
-
payload: {}
|
|
629
|
-
}));
|
|
630
|
-
} catch {
|
|
631
|
-
return [];
|
|
632
|
-
}
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
//#endregion
|
|
636
687
|
//#region ../plugin-manager/dist/directory-browser-parts.js
|
|
637
688
|
/** 文件浏览器视图半的**零件**:样式表与四行控件(面包屑/根/一行子目录/新建行)。
|
|
638
689
|
*
|
|
@@ -1012,25 +1063,23 @@ window.__ModuleLoader__.load({
|
|
|
1012
1063
|
});
|
|
1013
1064
|
}
|
|
1014
1065
|
//#endregion
|
|
1015
|
-
//#region
|
|
1016
|
-
/**
|
|
1066
|
+
//#region ../plugin-manager/dist/config-panel-view.js
|
|
1067
|
+
/** 共用配置面板 · 视图半:一行怎么画(`Row`)、整面怎么画(`PanelBody`)。
|
|
1017
1068
|
*
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1069
|
+
* 本件是**纯函数**:吃一份状态与一组回调,回一棵 React 元素树,自己不留任何状态、不取任何数。
|
|
1070
|
+
* 为什么这么分:状态机与副作用住在 `config-panel.ts` 那一层,于是本件在 Node 里就能直测
|
|
1071
|
+
* (照 `directory-browser-ui.ts` 的既有作法:用例把组件当普通函数调,直接读回那棵树)。
|
|
1021
1072
|
*
|
|
1022
|
-
*
|
|
1023
|
-
* 本文件传递闭包:禁 node、禁 DOM 直写(document/window/process),
|
|
1024
|
-
* 不 import 任何 host 实现(含 bridge,见 #130 纪律),只用自家 contract/settings/slot 与 dsh-ctx 的 type。
|
|
1025
|
-
* 组件 React.createElement 手写,不引入 JSX。
|
|
1026
|
-
*/
|
|
1027
|
-
/** client 短名声明:只有这两个(#736 的目录选择走**可选查找**,不写进来——
|
|
1028
|
-
* 写进来=硬依赖,提供方缺席时整包被停靠,设置页会跟着装不上;见 cookbook §13)。 */
|
|
1029
|
-
const inject = ["slots", "connection"];
|
|
1030
|
-
/** 面板视觉(内联 style;颜色走 DSH 主题别名,深浅主题自适应,写死值只做回退)。
|
|
1073
|
+
* 手写 `React.createElement`(本包 client 束禁 JSX)。样式表 `S` 就在本件里——面板怎么画只有这一处。
|
|
1031
1074
|
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1075
|
+
* 三条定稿(v3)在本件落地:
|
|
1076
|
+
* ① **只读行的目录按钮不画**(不画一枚点了也没反应的死按钮);
|
|
1077
|
+
* ② 目录行的按钮字面**两档合一**,一律「浏览文件夹」(入口供不了时整枚不画);
|
|
1078
|
+
* ③ **每行一枚「复制」**:复制这一行完好的值文本;点下去按钮就地变「已复制」再变回,不弹提示。
|
|
1079
|
+
*
|
|
1080
|
+
* 对外只经 `config-panel-api.ts` 那一道门;本件自己不出门。
|
|
1081
|
+
*/
|
|
1082
|
+
/** 面板的样式表:颜色走 DSH 主题别名(深浅主题自适应,写死值只做回退),字号一律相对单位(em)。 */
|
|
1034
1083
|
const S = {
|
|
1035
1084
|
card: {
|
|
1036
1085
|
padding: "12px 14px",
|
|
@@ -1049,16 +1098,27 @@ window.__ModuleLoader__.load({
|
|
|
1049
1098
|
color: "var(--dsw-alias-label-secondary, #9a9a9a)",
|
|
1050
1099
|
fontSize: "0.92em"
|
|
1051
1100
|
},
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1101
|
+
error: {
|
|
1102
|
+
marginTop: 8,
|
|
1103
|
+
color: "var(--dsw-alias-label-error, #b3261e)",
|
|
1104
|
+
fontSize: "1em",
|
|
1105
|
+
whiteSpace: "pre-wrap"
|
|
1106
|
+
},
|
|
1107
|
+
okText: {
|
|
1108
|
+
marginTop: 8,
|
|
1109
|
+
color: "var(--dsw-alias-state-success-primary, #12805c)",
|
|
1110
|
+
fontSize: "0.96em"
|
|
1056
1111
|
},
|
|
1057
1112
|
rows: {
|
|
1058
1113
|
marginTop: 8,
|
|
1059
1114
|
borderTop: "1px solid var(--dsw-alias-border, rgba(128,128,128,.25))",
|
|
1060
1115
|
paddingTop: 8
|
|
1061
1116
|
},
|
|
1117
|
+
info: {
|
|
1118
|
+
color: "var(--dsw-alias-label-secondary, #9a9a9a)",
|
|
1119
|
+
fontSize: "0.92em",
|
|
1120
|
+
overflowWrap: "anywhere"
|
|
1121
|
+
},
|
|
1062
1122
|
row: { marginBottom: 10 },
|
|
1063
1123
|
label: { fontWeight: 600 },
|
|
1064
1124
|
hint: {
|
|
@@ -1080,6 +1140,12 @@ window.__ModuleLoader__.load({
|
|
|
1080
1140
|
gap: 6,
|
|
1081
1141
|
alignItems: "center"
|
|
1082
1142
|
},
|
|
1143
|
+
acts: {
|
|
1144
|
+
display: "flex",
|
|
1145
|
+
gap: 6,
|
|
1146
|
+
alignItems: "center",
|
|
1147
|
+
flex: "0 0 auto"
|
|
1148
|
+
},
|
|
1083
1149
|
btnPick: {
|
|
1084
1150
|
flex: "0 0 auto",
|
|
1085
1151
|
padding: "4px 10px",
|
|
@@ -1090,16 +1156,43 @@ window.__ModuleLoader__.load({
|
|
|
1090
1156
|
whiteSpace: "nowrap",
|
|
1091
1157
|
cursor: "pointer"
|
|
1092
1158
|
},
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1159
|
+
invalid: {
|
|
1160
|
+
marginTop: 6,
|
|
1161
|
+
padding: "6px 8px",
|
|
1162
|
+
borderRadius: 6,
|
|
1163
|
+
background: "color-mix(in srgb, var(--dsw-alias-state-warning-primary, #b26a00) 16%, transparent)",
|
|
1164
|
+
border: "1px solid var(--dsw-alias-state-warning-primary, #b26a00)",
|
|
1165
|
+
color: "var(--dsw-alias-label-primary, inherit)",
|
|
1166
|
+
fontSize: "0.92em"
|
|
1097
1167
|
},
|
|
1098
1168
|
bar: {
|
|
1099
1169
|
display: "flex",
|
|
1100
1170
|
gap: 8,
|
|
1101
1171
|
marginTop: 12,
|
|
1102
|
-
flexWrap: "wrap"
|
|
1172
|
+
flexWrap: "wrap",
|
|
1173
|
+
position: "sticky",
|
|
1174
|
+
bottom: 0,
|
|
1175
|
+
zIndex: 1,
|
|
1176
|
+
background: "var(--dsw-alias-bg-layer-1, #232329)",
|
|
1177
|
+
padding: "8px 0",
|
|
1178
|
+
borderTop: "1px solid var(--dsw-alias-border, rgba(128,128,128,.25))"
|
|
1179
|
+
},
|
|
1180
|
+
saveMsg: {
|
|
1181
|
+
marginRight: "auto",
|
|
1182
|
+
alignSelf: "center",
|
|
1183
|
+
color: "var(--dsw-alias-state-warning-primary, #b26a00)",
|
|
1184
|
+
fontSize: "0.9em"
|
|
1185
|
+
},
|
|
1186
|
+
dirtyDot: {
|
|
1187
|
+
color: "var(--dsw-alias-state-warning-primary, #b26a00)",
|
|
1188
|
+
fontSize: "0.85em",
|
|
1189
|
+
marginLeft: 6,
|
|
1190
|
+
fontWeight: 400
|
|
1191
|
+
},
|
|
1192
|
+
followNote: {
|
|
1193
|
+
color: "var(--dsw-alias-state-warning-primary, #b26a00)",
|
|
1194
|
+
fontSize: "0.85em",
|
|
1195
|
+
marginBottom: 4
|
|
1103
1196
|
},
|
|
1104
1197
|
btn: {
|
|
1105
1198
|
padding: "4px 12px",
|
|
@@ -1117,126 +1210,275 @@ window.__ModuleLoader__.load({
|
|
|
1117
1210
|
color: "#fff",
|
|
1118
1211
|
cursor: "pointer"
|
|
1119
1212
|
},
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1213
|
+
advanced: { marginTop: 12 },
|
|
1214
|
+
summary: {
|
|
1215
|
+
cursor: "pointer",
|
|
1216
|
+
fontWeight: 600
|
|
1124
1217
|
},
|
|
1125
|
-
|
|
1218
|
+
version: {
|
|
1126
1219
|
marginTop: 8,
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1220
|
+
paddingTop: 8,
|
|
1221
|
+
borderTop: "1px dashed var(--dsw-alias-border, rgba(128,128,128,.25))",
|
|
1222
|
+
color: "var(--dsw-alias-label-tertiary, #8a8a8a)",
|
|
1223
|
+
fontSize: "0.92em"
|
|
1130
1224
|
}
|
|
1131
1225
|
};
|
|
1132
|
-
/**
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
* 插件 bridge 原样带上(`fetch-failed:出口非 0(1):<那一句>`)。所以这里**不重写报文**,
|
|
1138
|
-
* 只按报错类补一句「接下来怎么办」——免得两处各写一套人话。
|
|
1139
|
-
*/
|
|
1140
|
-
function humanizeConfigFailure(code, message) {
|
|
1141
|
-
const text = message.trim().length > 0 ? message.trim() : `(${code},宿主未给报文)`;
|
|
1142
|
-
if (/解析|YAML|parse/i.test(text)) return `${text}\n改回「键: 值」的写法,或点「重置为默认」。`;
|
|
1143
|
-
if (/不认识|未知键|UNKNOWN_KEY/i.test(text)) return `${text}\n删掉页面上没有的行,或点「重置为默认」。`;
|
|
1144
|
-
if (/类型|TYPE_MISMATCH/i.test(text)) return `${text}\n按本页的控件形状填,或点「重置为默认」。`;
|
|
1145
|
-
if (/缺席|missing-cli/i.test(text)) return `${text}\n技能出口没找到:先确认技能包已装好,再点「重新读取」。`;
|
|
1146
|
-
return `${text}\n改不动就点「重置为默认」,或照上面那条路径手工改配置文件。`;
|
|
1226
|
+
/** 复制那一枚按钮的字面(定稿 v3 第三条:就地变,不弹提示)。 */
|
|
1227
|
+
function copyLabelOf(state) {
|
|
1228
|
+
if (state === "done") return "已复制";
|
|
1229
|
+
if (state === "failed") return "复制失败";
|
|
1230
|
+
return "复制";
|
|
1147
1231
|
}
|
|
1148
|
-
/**
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1232
|
+
/** 一行输入(四种控件对齐受限 YAML 子集:文本/数字/布尔/目录)。
|
|
1233
|
+
*
|
|
1234
|
+
* 目录档=文本框 + 一枚唤起目录选择的按钮;入口三态是 `none`(命名空间拿不到、或这条路已被拒)
|
|
1235
|
+
* **就不画按钮**,文本框照旧——那是该缝自己的契约(供不了就收起入口,不是失败)。
|
|
1236
|
+
* **只读行**:控件一律 `disabled`、`onChange` 不接,目录行的按钮**一枚都不画**(定稿 v3 ①)。 */
|
|
1237
|
+
function Row(props) {
|
|
1238
|
+
const { item } = props;
|
|
1239
|
+
const readonly = item.readonly === true;
|
|
1240
|
+
const disabled = props.disabled || readonly;
|
|
1241
|
+
const onChange = readonly ? void 0 : (e) => props.onChange(item.key, e.target.value);
|
|
1242
|
+
const control = item.control === "switch" ? react.createElement("input", {
|
|
1243
|
+
type: "checkbox",
|
|
1244
|
+
checked: props.value === "true",
|
|
1245
|
+
disabled,
|
|
1246
|
+
onChange: readonly ? void 0 : (e) => props.onChange(item.key, e.target.checked ? "true" : "false")
|
|
1247
|
+
}) : react.createElement("input", {
|
|
1248
|
+
style: props.dirty === true ? {
|
|
1249
|
+
...S.input,
|
|
1250
|
+
borderColor: "var(--dsw-alias-state-warning-primary, #b26a00)"
|
|
1251
|
+
} : readonly && props.follow === true ? {
|
|
1252
|
+
...S.input,
|
|
1253
|
+
opacity: .55
|
|
1254
|
+
} : S.input,
|
|
1255
|
+
type: item.control === "number" ? "number" : "text",
|
|
1256
|
+
value: props.value,
|
|
1257
|
+
disabled,
|
|
1258
|
+
spellCheck: false,
|
|
1259
|
+
onChange
|
|
1260
|
+
});
|
|
1261
|
+
/** 目录入口:只读行不画(定稿 v3 ①),供不了(`none`/缺席)也不画。 */
|
|
1262
|
+
const entry = props.browser ?? null;
|
|
1263
|
+
const browse = item.control === "directory" && !readonly && entry !== null && entry.mode !== "none" ? react.createElement("button", {
|
|
1264
|
+
style: S.btnPick,
|
|
1265
|
+
type: "button",
|
|
1266
|
+
disabled,
|
|
1267
|
+
onClick: () => entry.onOpen(item.key)
|
|
1268
|
+
}, "浏览文件夹") : null;
|
|
1269
|
+
const copy = react.createElement("button", {
|
|
1270
|
+
style: S.btnPick,
|
|
1271
|
+
type: "button",
|
|
1272
|
+
disabled: props.onCopy === void 0,
|
|
1273
|
+
onClick: () => props.onCopy?.(item.key, props.value)
|
|
1274
|
+
}, copyLabelOf(props.copyState));
|
|
1275
|
+
const acts = react.createElement("div", { style: S.acts }, browse, copy);
|
|
1276
|
+
const facts = props.fallback ?? null;
|
|
1277
|
+
return react.createElement("div", { style: S.row }, react.createElement("div", { style: S.label }, item.title, props.dirty === true ? react.createElement("span", { style: S.dirtyDot }, "●已改动") : null), react.createElement("div", { style: S.hint }, item.hint), readonly && props.follow === true ? react.createElement("div", { style: S.followNote }, "将跟随更新,保存后生效。") : null, facts === null ? null : react.createElement("div", { style: S.invalid }, "配置里写的是 ", react.createElement("b", null, facts.written), ";现在生效的是 ", react.createElement("b", null, facts.effective), "。"), react.createElement("div", { style: S.pickRow }, control, acts));
|
|
1185
1278
|
}
|
|
1186
|
-
/**
|
|
1187
|
-
|
|
1188
|
-
|
|
1279
|
+
/** 应用内浏览器对话框的文案(面板自带,逐条对应图上那几个位置)。 */
|
|
1280
|
+
const BROWSER_LABELS = {
|
|
1281
|
+
title: "选择文件夹",
|
|
1282
|
+
close: "关闭",
|
|
1283
|
+
up: "上一级",
|
|
1284
|
+
pathPlaceholder: "直接填绝对路径,回车即进入",
|
|
1285
|
+
go: "转到",
|
|
1286
|
+
showHidden: (n) => "显示隐藏目录(" + n + ")",
|
|
1287
|
+
empty: "这个目录里没有子目录。",
|
|
1288
|
+
loading: "正在读取",
|
|
1289
|
+
newFolder: "新建文件夹",
|
|
1290
|
+
createConfirm: "创建",
|
|
1291
|
+
createCancel: "取消",
|
|
1292
|
+
select: "选",
|
|
1293
|
+
selected: "已选",
|
|
1294
|
+
open: "选定这个目录",
|
|
1295
|
+
cancel: "取消",
|
|
1296
|
+
willPick: "将选定:"
|
|
1297
|
+
};
|
|
1298
|
+
/** 画整面(四支控件、三支错误落位、底栏、附加块槽都在这里)。 */
|
|
1299
|
+
function PanelBody(props) {
|
|
1300
|
+
const { state, title } = props;
|
|
1301
|
+
const surface = state.kind === "ready" ? state.surface : null;
|
|
1302
|
+
const headText = title === void 0 ? "配置" : `${title} · 配置`;
|
|
1303
|
+
const headDataDir = surface === null ? "" : surface.resolved?.["dbDir"] ?? surface.dataDir;
|
|
1304
|
+
const head = react.createElement("div", null, react.createElement("div", { style: S.title }, headText), surface === null ? 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));
|
|
1305
|
+
const parts = {
|
|
1306
|
+
styles: S,
|
|
1307
|
+
reply: surface
|
|
1308
|
+
};
|
|
1309
|
+
const extra = props.extra === void 0 ? null : props.extra(parts);
|
|
1310
|
+
if (state.kind === "loading") return react.createElement("div", { style: S.card }, head, react.createElement("div", { style: S.muted }, "配置读取中"), extra);
|
|
1311
|
+
if (state.kind === "failed") return react.createElement("div", { style: S.card }, head, react.createElement("div", { style: S.error }, state.message), extra, react.createElement("div", { style: S.bar }, react.createElement("button", {
|
|
1312
|
+
style: S.btn,
|
|
1313
|
+
type: "button",
|
|
1314
|
+
onClick: props.onRetry
|
|
1315
|
+
}, "重试"), react.createElement("button", {
|
|
1316
|
+
style: S.btn,
|
|
1317
|
+
type: "button",
|
|
1318
|
+
onClick: props.onReset
|
|
1319
|
+
}, "重置为默认")));
|
|
1320
|
+
const common = props.items.filter((item) => item.tier === "common");
|
|
1321
|
+
const advanced = props.items.filter((item) => item.tier === "advanced");
|
|
1322
|
+
const renderRow = (item) => react.createElement(Row, {
|
|
1323
|
+
key: item.key,
|
|
1324
|
+
item,
|
|
1325
|
+
value: props.draft[item.key] ?? "",
|
|
1326
|
+
disabled: props.busy,
|
|
1327
|
+
onChange: props.onChange,
|
|
1328
|
+
browser: props.rowEntry,
|
|
1329
|
+
dirty: props.dirtyKeys.includes(item.key),
|
|
1330
|
+
follow: props.followKeys.includes(item.key),
|
|
1331
|
+
onCopy: props.onCopy,
|
|
1332
|
+
copyState: props.copy !== null && props.copy.key === item.key ? props.copy.ok ? "done" : "failed" : "idle",
|
|
1333
|
+
fallback: item.readonly !== true && item.prefillFrom !== void 0 ? fallbackFactsOf(item, props.draft[item.key] ?? "", draftSourceOf(surface)) : null
|
|
1334
|
+
});
|
|
1335
|
+
const dirtyCount = props.dirtyKeys.length;
|
|
1336
|
+
const dirty = dirtyCount > 0;
|
|
1337
|
+
return react.createElement("div", { style: S.card }, head, react.createElement("div", { style: S.rows }, common.map(renderRow)), advanced.length === 0 ? null : 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)), extra, react.createElement("div", { style: S.bar }, dirty ? react.createElement("div", { style: S.saveMsg }, `浏览改动后请点保存(${dirtyCount} 项未保存),保存后跟随项自动更新`) : null, react.createElement("button", {
|
|
1338
|
+
style: dirty ? S.btnPrimary : S.btn,
|
|
1339
|
+
type: "button",
|
|
1340
|
+
disabled: props.busy || !dirty,
|
|
1341
|
+
onClick: props.onSave
|
|
1342
|
+
}, props.busy ? "处理中" : dirty ? `保存(${dirtyCount} 项未保存)` : "保存"), react.createElement("button", {
|
|
1343
|
+
style: S.btn,
|
|
1344
|
+
type: "button",
|
|
1345
|
+
disabled: props.busy,
|
|
1346
|
+
onClick: props.onReset
|
|
1347
|
+
}, "重置为默认"), react.createElement("button", {
|
|
1348
|
+
style: S.btn,
|
|
1349
|
+
type: "button",
|
|
1350
|
+
disabled: props.busy,
|
|
1351
|
+
onClick: props.onRetry
|
|
1352
|
+
}, "重新读取")), props.picking ? react.createElement("div", { style: S.muted }, "已唤起系统文件夹对话框:选中后自动填上,取消则不动。") : null, props.browseRow !== null ? react.createElement(DirectoryBrowserFromRow, {
|
|
1353
|
+
open: true,
|
|
1354
|
+
row: props.browseRow,
|
|
1355
|
+
labels: BROWSER_LABELS
|
|
1356
|
+
}) : null, props.writeError !== null ? react.createElement("div", {
|
|
1357
|
+
style: S.error,
|
|
1358
|
+
role: "alert"
|
|
1359
|
+
}, 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);
|
|
1189
1360
|
}
|
|
1190
|
-
/**
|
|
1191
|
-
function
|
|
1192
|
-
|
|
1361
|
+
/** 「写的值 ≠ 生效值」两个事实要的那几格:整面回执里取值本身、落点顶层那一个、`resolved` 组。 */
|
|
1362
|
+
function draftSourceOf(surface) {
|
|
1363
|
+
if (surface === null) return { values: {} };
|
|
1364
|
+
return {
|
|
1365
|
+
values: surface.values,
|
|
1366
|
+
dataDir: surface.dataDir,
|
|
1367
|
+
resolved: surface.resolved
|
|
1368
|
+
};
|
|
1193
1369
|
}
|
|
1194
|
-
|
|
1370
|
+
//#endregion
|
|
1371
|
+
//#region ../plugin-manager/dist/directory-browser-roots.js
|
|
1372
|
+
/** 「本机根清单」的取数接线(客户端半):把一通电话变成图上那一行可跳转的根。
|
|
1195
1373
|
*
|
|
1196
|
-
*
|
|
1197
|
-
*
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
}
|
|
1206
|
-
return draft;
|
|
1374
|
+
* **不认识宿主**:载体基段、通道名(第二段)、电话名全由调用方传进来——各家从自己的宿主镜像里拿
|
|
1375
|
+
* 那几个字面量(和 `openRowBrowser` 的 `refusalCode` 同一条规矩)。本件只做两件事:
|
|
1376
|
+
* 发一次调用、把回执信封拆成根清单;**任何失败都当空清单**(图上少画一行,不报错、不挡路)。
|
|
1377
|
+
*/
|
|
1378
|
+
/** 认一认某个值像不像一个根(认不出的行丢掉:图的干净比多画一行重要)。 */
|
|
1379
|
+
function isRootRow(raw) {
|
|
1380
|
+
if (typeof raw !== "object" || raw === null) return false;
|
|
1381
|
+
const row = raw;
|
|
1382
|
+
return typeof row.path === "string" && row.path !== "" && typeof row.kind === "string";
|
|
1207
1383
|
}
|
|
1208
|
-
/**
|
|
1209
|
-
function
|
|
1210
|
-
const
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1384
|
+
/** 信封 → 根清单(纯函数,**永不抛**):`{ok:true, value:{roots:[…]}}` 之外的形状一律当空。 */
|
|
1385
|
+
function readRootsAnswer(raw) {
|
|
1386
|
+
const answer = typeof raw === "object" && raw !== null ? raw : {};
|
|
1387
|
+
if (answer.ok !== true) return [];
|
|
1388
|
+
const value = typeof answer.value === "object" && answer.value !== null ? answer.value : {};
|
|
1389
|
+
if (!Array.isArray(value.roots)) return [];
|
|
1390
|
+
return value.roots.filter(isRootRow).map((row) => ({
|
|
1391
|
+
path: row.path,
|
|
1392
|
+
kind: row.kind
|
|
1393
|
+
}));
|
|
1217
1394
|
}
|
|
1218
|
-
/**
|
|
1219
|
-
|
|
1395
|
+
/** 建一个「根清单取数器」:给调用口与三个名字,回一个 `() => Promise<RootRow[]>`。
|
|
1396
|
+
*
|
|
1397
|
+
* 界面按需调它(开图一次),不在渲染里调,也不缓存到这里——缓存是宿主半的事。 */
|
|
1398
|
+
function createRootsSource(input) {
|
|
1399
|
+
return async () => {
|
|
1400
|
+
const call = input.getCall();
|
|
1401
|
+
if (typeof call !== "function") return [];
|
|
1402
|
+
try {
|
|
1403
|
+
return readRootsAnswer(await call(input.base, input.endpoint, {
|
|
1404
|
+
method: input.method,
|
|
1405
|
+
payload: {}
|
|
1406
|
+
}));
|
|
1407
|
+
} catch {
|
|
1408
|
+
return [];
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
/** 载体:宿主注册 `connection.fetch.register` 的路径 / 面板 `connection.rpc.call` 的两段参数。 */
|
|
1413
|
+
const MANAGER_RPC = {
|
|
1414
|
+
/** 载体基段(`connection.rpc.call` 的**第一段**参数)。传错第一段就是 404:真机上踩过一次。 */
|
|
1415
|
+
base: "/api",
|
|
1416
|
+
/** 单段通道名,须 match `/^\/[A-Za-z0-9._~-]+$/`(cookbook §6)。 */
|
|
1417
|
+
channel: "/ilife-manager",
|
|
1418
|
+
/** 面板侧第二段参数(等于通道名去掉前导斜杠那一段)。 */
|
|
1419
|
+
endpoint: "ilife-manager",
|
|
1420
|
+
/** 宿主侧注册的完整路径(= `base` + `channel`)。 */
|
|
1421
|
+
path: "/api/ilife-manager"
|
|
1422
|
+
};
|
|
1423
|
+
/** 总管自有电话名:更新包的三个动作名是冻结集合(`config.ts:14`),这里另起名字不撞车。 */
|
|
1424
|
+
const MANAGER_ACTIONS = {
|
|
1425
|
+
/** 装上缺席的目标包:入参 `{packageName, version}`,回包 `{packageName, version}`。 */
|
|
1426
|
+
install: "ilife-manager.install",
|
|
1427
|
+
/** 七个更新目标的表:入参 `{}`,回包 `{targets: [{key, title, packageName, phones, runningVersion, installedVersion, panelRegistered, skill}]}`。
|
|
1428
|
+
*
|
|
1429
|
+
* 电话名为什么由宿主转交而不是面板侧写死:更新包的三个电话名只有它自己知道
|
|
1430
|
+
* (`update.phoneNames`,`host.ts:335`);面板侧既不能 import 更新包(浏览器产物纯度门)
|
|
1431
|
+
* 也不该把名字再写一份(写两份必然走散)。宿主把名字表连同版本行一起交出去,
|
|
1432
|
+
* 面板一行字面量都不留。 */
|
|
1433
|
+
targets: "ilife-manager.targets",
|
|
1434
|
+
/** 总管自述版本(票 #737):入参 `{}`,回包 `{version}`。
|
|
1435
|
+
*
|
|
1436
|
+
* 宿主读**自己这份已安装包**的 `package.json`(`manager-version.ts`),面板只渲染读到的值。
|
|
1437
|
+
* 为什么走电话:面板是浏览器产物,禁 node 内建(`test/client-bundle-48.test.mjs` 看门),
|
|
1438
|
+
* 读盘只许在宿主半;与卡路里 #130 同一条路(host 读 → RPC → client 纯渲染)。 */
|
|
1439
|
+
version: "ilife-manager.version",
|
|
1440
|
+
/** 本机「根」清单(票 #744):入参 `{}`,回包 `{roots: [{path, kind}]}`。
|
|
1441
|
+
*
|
|
1442
|
+
* 为什么这条能力住在总管:浏览器里没有卷清单接口,而宿主那条目录选择接缝只有
|
|
1443
|
+
* `list`/`createDirectory` 两格——「这台机器上有哪些盘」只能由宿主去问操作系统。
|
|
1444
|
+
* 六家的目录浏览器都取这一通电话,故名字与实现都只有这一处(清单本身见 `roots.ts`)。
|
|
1445
|
+
* 回包**恒成功**:列不出来是空清单,不是错误码(界面据此只是不画那一行)。 */
|
|
1446
|
+
roots: "ilife-manager.roots"
|
|
1447
|
+
};
|
|
1448
|
+
//#endregion
|
|
1449
|
+
//#region ../plugin-manager/dist/config-panel.js
|
|
1450
|
+
/** 共用配置面板 · 组件本体:一台状态机 + 一圈副作用,渲染交给 `config-panel-view.ts`。
|
|
1451
|
+
*
|
|
1452
|
+
* 这一件就是六个技能设置页的那张卡片:六家只交「连哪条通道」与「有哪些配置项」(行表),
|
|
1453
|
+
* 另加三个可选钩子(跟随映射/卡片标题/自家附加块)。卡片怎么画、值怎么取又怎么填回去、
|
|
1454
|
+
* 哪些行被改过还没保存、保存/重置/重新读取怎么走、出错时说什么话,全在本件与它下面那几件里。
|
|
1455
|
+
*
|
|
1456
|
+
* 状态:读到了没(loading/ready/failed)、哪些行被改过(脏基线)、正在写(busy)、
|
|
1457
|
+
* 写完的结果(notice/writeError)、就地失败(error)、目录选择那一路(picking/browseRow)、
|
|
1458
|
+
* 复制回执(copy)。**脏基线只看可改行**:只读行显示的是技能算好的绝对路径,拿它当「改动」
|
|
1459
|
+
* 会让面板一打开就显示「未保存」。**写完一律重新读一份整面**:写回执不是整面,拿它当整面用
|
|
1460
|
+
* 会让页头那行「数据目录」显示成空。
|
|
1461
|
+
*
|
|
1462
|
+
* 只挂一个副作用(挂载期读一次整面):取数口是**取用器**,每次调它现取,所以那个 effect
|
|
1463
|
+
* 不需要跟着任何一格重跑——否则回执一落定就再读一次,会自己把自己转起来。
|
|
1464
|
+
*
|
|
1465
|
+
* 对外只经 `config-panel-api.ts` 那一道门;本件自己不出门。
|
|
1466
|
+
*/
|
|
1467
|
+
/** 认一认某个值像不像目录选择命名空间(认不出就当没有,绝不把设置页带下来)。 */
|
|
1468
|
+
function isPickerFace(raw) {
|
|
1220
1469
|
if (typeof raw !== "object" || raw === null) return false;
|
|
1221
1470
|
return typeof raw.pick === "function";
|
|
1222
1471
|
}
|
|
1223
|
-
/** 现取目录选择命名空间:**软依赖**,拿不到回 null(页面据此不出入口)。
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
* 设置页会跟着装不上。这里按平台给的「可选查找」办:缺席只是没有入口。
|
|
1227
|
-
* 守卫拒绝或提供方中途卸载会抛,一律当「没有」,绝不把设置页带下来。 */
|
|
1228
|
-
function resolveDirectoryPicker(getService) {
|
|
1229
|
-
if (typeof getService !== "function") return null;
|
|
1472
|
+
/** 现取目录选择命名空间:**软依赖**,拿不到回 null(页面据此不出入口)。 */
|
|
1473
|
+
function resolvePicker(getService) {
|
|
1474
|
+
if (getService === void 0) return null;
|
|
1230
1475
|
try {
|
|
1231
1476
|
const raw = getService(REMOTE_DIRECTORY_PICKER);
|
|
1232
|
-
return
|
|
1477
|
+
return isPickerFace(raw) ? raw : null;
|
|
1233
1478
|
} catch {
|
|
1234
1479
|
return null;
|
|
1235
1480
|
}
|
|
1236
1481
|
}
|
|
1237
|
-
/** 平台回执/入口三态:**已经收进共用件**(票 #744),这里只同名转出。 */
|
|
1238
|
-
const readPickAnswer = readPickAnswer$1;
|
|
1239
|
-
const pickerModeOf = pickerModeOf$1;
|
|
1240
1482
|
/** 唤起一次系统文件夹选择器并归一结果(**永不抛**)。 */
|
|
1241
1483
|
async function pickDirectory(picker) {
|
|
1242
1484
|
try {
|
|
@@ -1248,84 +1490,54 @@ window.__ModuleLoader__.load({
|
|
|
1248
1490
|
};
|
|
1249
1491
|
}
|
|
1250
1492
|
}
|
|
1251
|
-
/**
|
|
1252
|
-
*
|
|
1253
|
-
* 判定取自共用件(一处定义)。本函数只读那个命名空间,不做 IO。 */
|
|
1254
|
-
function directoryEntryMode(picker) {
|
|
1255
|
-
return picker === null ? "none" : pickerModeOf(picker);
|
|
1256
|
-
}
|
|
1257
|
-
/** 「选择文件夹」按钮的真装配函数(目录行渲染出来的按钮,onClick 就是它)。
|
|
1258
|
-
*
|
|
1259
|
-
* 回 Promise 是为了本地可判(用例直接 await);接进 React 时调用方 `void` 掉。 */
|
|
1260
|
-
function createBrowseHandler(deps) {
|
|
1261
|
-
return async (key) => {
|
|
1262
|
-
const outcome = await pickDirectory(deps.picker);
|
|
1263
|
-
if (outcome.kind === "picked") deps.onChange(key, outcome.path);
|
|
1264
|
-
else if (outcome.kind === "unavailable") deps.onUnavailable(outcome.message);
|
|
1265
|
-
};
|
|
1266
|
-
}
|
|
1267
|
-
/** 一行输入(四种控件对齐受限 YAML 子集:文本/数字/布尔/目录)。
|
|
1493
|
+
/** 写一次剪贴板:**不碰 DOM**,只读当刻环境的 `globalThis.navigator`(拿不到就回 false,不抛)。
|
|
1268
1494
|
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
function
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
type: item.control === "number" ? "number" : "text",
|
|
1281
|
-
value: props.value,
|
|
1282
|
-
disabled: props.disabled,
|
|
1283
|
-
spellCheck: false,
|
|
1284
|
-
onChange: (e) => props.onChange(item.key, e.target.value)
|
|
1285
|
-
});
|
|
1286
|
-
/** 三态入口:供不了(`none`/缺席)就不画,不摆一个点了没反应的死按钮。 */
|
|
1287
|
-
const entry = props.browser ?? null;
|
|
1288
|
-
const browse = item.control === "directory" && entry !== null && entry.mode !== "none" ? react.createElement("button", {
|
|
1289
|
-
style: S.btnPick,
|
|
1290
|
-
type: "button",
|
|
1291
|
-
disabled: props.disabled,
|
|
1292
|
-
onClick: () => entry.onOpen(item.key)
|
|
1293
|
-
}, entry.mode === "native" ? "选择文件夹…" : "浏览…") : null;
|
|
1294
|
-
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));
|
|
1495
|
+
* 为什么不照原型那样退到 `document.execCommand`:本仓插件侧禁 DOM 直写(`document`/`window`),
|
|
1496
|
+
* 所以没有那条兜底;拿不到剪贴板就如实回 false,界面上那一枚按钮就地显示「复制失败」。 */
|
|
1497
|
+
async function writeClipboard(text) {
|
|
1498
|
+
try {
|
|
1499
|
+
const clip = globalThis.navigator?.clipboard;
|
|
1500
|
+
if (clip === void 0 || typeof clip.writeText !== "function") return false;
|
|
1501
|
+
await clip.writeText(text);
|
|
1502
|
+
return true;
|
|
1503
|
+
} catch {
|
|
1504
|
+
return false;
|
|
1505
|
+
}
|
|
1295
1506
|
}
|
|
1296
|
-
/**
|
|
1297
|
-
function
|
|
1507
|
+
/** 技能设置页:承载一家自己的全部可配置项(只配置,不干活)。 */
|
|
1508
|
+
function ConfigPanel(props) {
|
|
1298
1509
|
const [state, setState] = react.useState({ kind: "loading" });
|
|
1299
1510
|
const [pickerGone, setPickerGone] = react.useState(false);
|
|
1300
1511
|
const [picking, setPicking] = react.useState(false);
|
|
1301
1512
|
const [draft, setDraft] = react.useState({});
|
|
1302
1513
|
const [busy, setBusy] = react.useState(false);
|
|
1303
1514
|
const [notice, setNotice] = react.useState(null);
|
|
1515
|
+
const [writeError, setWriteError] = react.useState(null);
|
|
1304
1516
|
const [error, setError] = react.useState(null);
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1517
|
+
const [browseRow, setBrowseRow] = react.useState(null);
|
|
1518
|
+
const [copy, setCopy] = react.useState(null);
|
|
1519
|
+
/** 复制回执的复位定时器:**一次性 UI 回执**,不是数据轮询——点一次起一个,到点自我复位,
|
|
1520
|
+
* 下一次点击先清掉旧的,卸载时也清掉(下面那个清扫 effect 就是那一半)。 */
|
|
1521
|
+
const copyTimer = react.useRef(null);
|
|
1522
|
+
react.useEffect(() => () => {
|
|
1523
|
+
if (copyTimer.current !== null) {
|
|
1524
|
+
clearTimeout(copyTimer.current);
|
|
1525
|
+
copyTimer.current = null;
|
|
1526
|
+
}
|
|
1311
1527
|
}, []);
|
|
1312
|
-
|
|
1313
|
-
setNotice(null);
|
|
1314
|
-
setError(null);
|
|
1315
|
-
const r = await fetchConfigSurface(props.getCall());
|
|
1316
|
-
if (r.ok) apply(r.surface);
|
|
1317
|
-
else setState({
|
|
1318
|
-
kind: "failed",
|
|
1319
|
-
message: r.message
|
|
1320
|
-
});
|
|
1321
|
-
}, [apply, props.getCall]);
|
|
1528
|
+
/** 挂载期读一次整面:卸载之后回来的回执丢掉(不许往已经没了的页面上写)。 */
|
|
1322
1529
|
react.useEffect(() => {
|
|
1323
1530
|
let alive = true;
|
|
1324
1531
|
(async () => {
|
|
1325
|
-
const r = await fetchConfigSurface(props.getCall());
|
|
1532
|
+
const r = await fetchConfigSurface(props.getCall(), props.channel);
|
|
1326
1533
|
if (!alive) return;
|
|
1327
|
-
if (r.ok)
|
|
1328
|
-
|
|
1534
|
+
if (r.ok) {
|
|
1535
|
+
setState({
|
|
1536
|
+
kind: "ready",
|
|
1537
|
+
surface: r.surface
|
|
1538
|
+
});
|
|
1539
|
+
setDraft(toDraft(props.items, r.surface.values, r.surface));
|
|
1540
|
+
} else setState({
|
|
1329
1541
|
kind: "failed",
|
|
1330
1542
|
message: r.message
|
|
1331
1543
|
});
|
|
@@ -1333,24 +1545,56 @@ window.__ModuleLoader__.load({
|
|
|
1333
1545
|
return () => {
|
|
1334
1546
|
alive = false;
|
|
1335
1547
|
};
|
|
1336
|
-
}, [
|
|
1337
|
-
|
|
1548
|
+
}, []);
|
|
1549
|
+
/** 把一份整面铺到页面上(读到之后唯一那一处落点)。 */
|
|
1550
|
+
const apply = (surface) => {
|
|
1551
|
+
setState({
|
|
1552
|
+
kind: "ready",
|
|
1553
|
+
surface
|
|
1554
|
+
});
|
|
1555
|
+
setDraft(toDraft(props.items, surface.values, surface));
|
|
1556
|
+
};
|
|
1557
|
+
const load = async () => {
|
|
1558
|
+
setNotice(null);
|
|
1559
|
+
setWriteError(null);
|
|
1560
|
+
setError(null);
|
|
1561
|
+
const r = await fetchConfigSurface(props.getCall(), props.channel);
|
|
1562
|
+
if (r.ok) apply(r.surface);
|
|
1563
|
+
else setState({
|
|
1564
|
+
kind: "failed",
|
|
1565
|
+
message: r.message
|
|
1566
|
+
});
|
|
1567
|
+
};
|
|
1568
|
+
/** 写完(保存/重置)之后重新读一份整面:写回执只有 `{path, values}`、重置回执只有
|
|
1569
|
+
* `{path, backupPath}`,都不是整面——拿它当整面用,页头那行「数据目录」会显示成空。 */
|
|
1570
|
+
const writeThenReload = async (done) => {
|
|
1571
|
+
setBusy(true);
|
|
1572
|
+
setNotice(null);
|
|
1573
|
+
setWriteError(null);
|
|
1574
|
+
setError(null);
|
|
1575
|
+
const r = await fetchConfigSurface(props.getCall(), props.channel);
|
|
1576
|
+
if (r.ok) {
|
|
1577
|
+
apply(r.surface);
|
|
1578
|
+
setNotice(done);
|
|
1579
|
+
} else setState({
|
|
1580
|
+
kind: "failed",
|
|
1581
|
+
message: r.message
|
|
1582
|
+
});
|
|
1583
|
+
setBusy(false);
|
|
1584
|
+
};
|
|
1585
|
+
const onChange = (key, next) => {
|
|
1338
1586
|
setDraft((prev) => ({
|
|
1339
1587
|
...prev,
|
|
1340
1588
|
[key]: next
|
|
1341
1589
|
}));
|
|
1342
1590
|
setNotice(null);
|
|
1591
|
+
setWriteError(null);
|
|
1343
1592
|
setError(null);
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
*
|
|
1347
|
-
* **软依赖**:拿不到命名空间就没有入口(留文本框),被拒一次即收起入口,不留死按钮。
|
|
1348
|
-
* 状态全部住在组件自己(hook 顺序与旧版同形):浏览器那一份只存「当前开着的那条」。 */
|
|
1349
|
-
const picker = pickerGone ? null : props.pickerSource();
|
|
1350
|
-
const [browseRow, setBrowseRow] = react.useState(null);
|
|
1593
|
+
};
|
|
1594
|
+
const picker = pickerGone ? null : resolvePicker(props.getService);
|
|
1351
1595
|
/** 唤起系统文件夹选择器那一条路(应用内浏览被拒时的兜底,也是命名空间只给 `pick` 时的唯一一条)。 */
|
|
1352
|
-
const openNative =
|
|
1353
|
-
const face = pickerGone ? null : props.
|
|
1596
|
+
const openNative = async (key) => {
|
|
1597
|
+
const face = pickerGone ? null : resolvePicker(props.getService);
|
|
1354
1598
|
if (face === null) return;
|
|
1355
1599
|
setPicking(true);
|
|
1356
1600
|
setError(null);
|
|
@@ -1361,15 +1605,9 @@ window.__ModuleLoader__.load({
|
|
|
1361
1605
|
setError(outcome.message);
|
|
1362
1606
|
setPickerGone(true);
|
|
1363
1607
|
}
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
props.pickerSource
|
|
1368
|
-
]);
|
|
1369
|
-
/** 目录行的入口动作:**先应用内浏览,宿主没给这条路再换系统对话框**(判断在共用件里)。
|
|
1370
|
-
*
|
|
1371
|
-
* 两条都供不了就不给入口(`undefined` 时 `rowEntry` 为 null ⇒ Row 不画按钮)。 */
|
|
1372
|
-
const onOpenRow = react.useCallback(async (key) => {
|
|
1608
|
+
};
|
|
1609
|
+
/** 目录行的入口动作:**先应用内浏览,宿主没给这条路再换系统对话框**(判断在共用件里)。 */
|
|
1610
|
+
const onOpenRow = async (key) => {
|
|
1373
1611
|
setError(null);
|
|
1374
1612
|
const opened = openRowBrowser({
|
|
1375
1613
|
picker,
|
|
@@ -1377,158 +1615,201 @@ window.__ModuleLoader__.load({
|
|
|
1377
1615
|
onChange: (next) => onChange(key, next),
|
|
1378
1616
|
onRow: setBrowseRow,
|
|
1379
1617
|
refusalCode: DIRECTORY_PICKER_REFUSED,
|
|
1380
|
-
rootsSource:
|
|
1618
|
+
rootsSource: createRootsSource({
|
|
1619
|
+
getCall: props.getCall,
|
|
1620
|
+
base: MANAGER_RPC.base,
|
|
1621
|
+
endpoint: MANAGER_RPC.endpoint,
|
|
1622
|
+
method: MANAGER_ACTIONS.roots
|
|
1623
|
+
})
|
|
1381
1624
|
});
|
|
1382
1625
|
if (opened === void 0 || await opened === "refused") await openNative(key);
|
|
1383
|
-
}, [
|
|
1384
|
-
picker,
|
|
1385
|
-
onChange,
|
|
1386
|
-
draft,
|
|
1387
|
-
openNative,
|
|
1388
|
-
props.rootsSource
|
|
1389
|
-
]);
|
|
1390
|
-
/** 给 Row 的三态入口:`none` 时给 null(不画按钮,文本框照旧)。 */
|
|
1391
|
-
const rowEntry = pickerModeOf(picker) === "none" ? null : {
|
|
1392
|
-
mode: pickerModeOf(picker),
|
|
1393
|
-
onOpen: onOpenRow
|
|
1394
1626
|
};
|
|
1395
|
-
|
|
1396
|
-
const
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
kind: "failed",
|
|
1408
|
-
message: r.message
|
|
1627
|
+
/** 复制一行(定稿 v3 第三条):写完就地变「已复制」(写不进去就地变「复制失败」),1.5 秒后复位,不弹提示。 */
|
|
1628
|
+
const onCopy = (key, text) => {
|
|
1629
|
+
writeClipboard(text).then((ok) => {
|
|
1630
|
+
setCopy({
|
|
1631
|
+
key,
|
|
1632
|
+
ok
|
|
1633
|
+
});
|
|
1634
|
+
if (copyTimer.current !== null) clearTimeout(copyTimer.current);
|
|
1635
|
+
copyTimer.current = setTimeout(() => {
|
|
1636
|
+
copyTimer.current = null;
|
|
1637
|
+
setCopy(null);
|
|
1638
|
+
}, COPY_FEEDBACK_MS);
|
|
1409
1639
|
});
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
const onSave = react.useCallback(async () => {
|
|
1640
|
+
};
|
|
1641
|
+
const onSave = async () => {
|
|
1413
1642
|
setBusy(true);
|
|
1414
1643
|
setNotice(null);
|
|
1644
|
+
setWriteError(null);
|
|
1415
1645
|
setError(null);
|
|
1416
|
-
const r = await saveConfigSurface(props.getCall(), fromDraft(draft));
|
|
1646
|
+
const r = await saveConfigSurface(props.getCall(), props.channel, fromDraft(props.items, draft));
|
|
1417
1647
|
setBusy(false);
|
|
1418
1648
|
if (r.ok) await writeThenReload("已保存,立即生效(不用重启宿主)");
|
|
1419
|
-
else
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
props.getCall,
|
|
1423
|
-
writeThenReload
|
|
1424
|
-
]);
|
|
1425
|
-
const onReset = react.useCallback(async () => {
|
|
1649
|
+
else setWriteError(r.message);
|
|
1650
|
+
};
|
|
1651
|
+
const onReset = async () => {
|
|
1426
1652
|
setBusy(true);
|
|
1427
1653
|
setNotice(null);
|
|
1654
|
+
setWriteError(null);
|
|
1428
1655
|
setError(null);
|
|
1429
|
-
const r = await resetConfigSurface(props.getCall());
|
|
1656
|
+
const r = await resetConfigSurface(props.getCall(), props.channel);
|
|
1430
1657
|
setBusy(false);
|
|
1431
1658
|
if (r.ok) await writeThenReload("已重置为默认(原配置已另存一份 .bak)");
|
|
1432
|
-
else
|
|
1433
|
-
}
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1659
|
+
else setWriteError(r.message);
|
|
1660
|
+
};
|
|
1661
|
+
const surface = state.kind === "ready" ? state.surface : null;
|
|
1662
|
+
const baseline = surface !== null ? toDraft(props.items, surface.values, surface) : null;
|
|
1663
|
+
const dirtyKeys = baseline === null ? [] : dirtyKeysOf(props.items, draft, baseline);
|
|
1664
|
+
const followKeys = (props.followKeysOf ?? noFollowKeys)(dirtyKeys);
|
|
1665
|
+
const mode = pickerModeOf(picker);
|
|
1666
|
+
const rowEntry = mode === "none" ? null : {
|
|
1667
|
+
mode,
|
|
1668
|
+
onOpen: onOpenRow
|
|
1669
|
+
};
|
|
1670
|
+
return react.createElement(PanelBody, {
|
|
1671
|
+
title: props.title,
|
|
1672
|
+
items: props.items,
|
|
1673
|
+
state,
|
|
1674
|
+
draft,
|
|
1675
|
+
busy,
|
|
1676
|
+
notice,
|
|
1677
|
+
writeError,
|
|
1678
|
+
error,
|
|
1679
|
+
picking,
|
|
1680
|
+
browseRow,
|
|
1681
|
+
rowEntry,
|
|
1682
|
+
dirtyKeys,
|
|
1683
|
+
followKeys,
|
|
1684
|
+
copy,
|
|
1685
|
+
onCopy,
|
|
1452
1686
|
onChange,
|
|
1453
|
-
|
|
1687
|
+
onSave: () => void onSave(),
|
|
1688
|
+
onReset: () => void onReset(),
|
|
1689
|
+
onRetry: () => void load(),
|
|
1690
|
+
extra: props.extra
|
|
1454
1691
|
});
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
}
|
|
1692
|
+
}
|
|
1693
|
+
//#endregion
|
|
1694
|
+
//#region src/contract.ts
|
|
1695
|
+
/** dsh-home-ilife 端口(port):DSH 世界与居家管家六边形之间的契约。
|
|
1696
|
+
*
|
|
1697
|
+
* browser 安全:零 node 导入。host 适配器(注册 RPC 通道)与 client 适配器(调用 RPC)
|
|
1698
|
+
* 共用同一份定义。通道命名遵守宿主约束:单段且不等于 /api(见 cookbook「RPC 通道」)。
|
|
1699
|
+
*
|
|
1700
|
+
* 照 #56 卡路里/#677 记账/#696 大厨样板同形:居家插件原先**没有任何 RPC 通道**,本件是它第一份端口定义
|
|
1701
|
+
* (#696:设置页要读改配置,走的就是这条通道)。
|
|
1702
|
+
*/
|
|
1703
|
+
const RPC_CHANNEL = "/ilife-home-ilife";
|
|
1704
|
+
//#endregion
|
|
1705
|
+
//#region src/slot.ts
|
|
1706
|
+
const SLOT_TITLE = "居家管家";
|
|
1707
|
+
const PLUGIN = "dsh-home-ilife";
|
|
1708
|
+
//#endregion
|
|
1709
|
+
//#region src/settings.ts
|
|
1710
|
+
/** 常用项:库与产物落在哪,放页面上。 */
|
|
1711
|
+
const COMMON = [
|
|
1712
|
+
{
|
|
1713
|
+
key: "db.dir",
|
|
1714
|
+
title: "数据目录",
|
|
1715
|
+
tier: "common",
|
|
1716
|
+
control: "directory",
|
|
1717
|
+
prefillFrom: "dataDir",
|
|
1718
|
+
hint: "库文件与产物的根目录。留空=用默认目录。"
|
|
1719
|
+
},
|
|
1720
|
+
{
|
|
1721
|
+
key: "db.name",
|
|
1722
|
+
title: "库文件名",
|
|
1723
|
+
tier: "common",
|
|
1724
|
+
control: "text",
|
|
1725
|
+
readonly: true,
|
|
1726
|
+
resolveFrom: "dbFile",
|
|
1727
|
+
hint: "数据目录下的库文件。"
|
|
1728
|
+
},
|
|
1729
|
+
{
|
|
1730
|
+
key: "html.dir",
|
|
1731
|
+
title: "HTML 产物目录",
|
|
1732
|
+
tier: "common",
|
|
1733
|
+
control: "text",
|
|
1734
|
+
readonly: true,
|
|
1735
|
+
resolveFrom: "htmlDir",
|
|
1736
|
+
hint: "交付页面的存放目录。`.`或留空=产物放库根。"
|
|
1737
|
+
}
|
|
1738
|
+
];
|
|
1739
|
+
/** 高级项:默认收起。改错了多半只是自己撞上麻烦,不确定就别动。 */
|
|
1740
|
+
const ADVANCED = [{
|
|
1741
|
+
key: "backup.dir",
|
|
1742
|
+
title: "备份目录",
|
|
1743
|
+
tier: "advanced",
|
|
1744
|
+
control: "directory",
|
|
1745
|
+
readonly: true,
|
|
1746
|
+
resolveFrom: "backupDir",
|
|
1747
|
+
hint: "备份与恢复的读写目录。"
|
|
1748
|
+
}, {
|
|
1749
|
+
key: "key.file",
|
|
1750
|
+
title: "主密钥文件",
|
|
1751
|
+
tier: "advanced",
|
|
1752
|
+
control: "text",
|
|
1753
|
+
readonly: true,
|
|
1754
|
+
resolveFrom: "keyFile",
|
|
1755
|
+
hint: "主密钥文件的位置。"
|
|
1756
|
+
}];
|
|
1757
|
+
/** 设置页的行表,顺序即页面顺序。 */
|
|
1758
|
+
const CONFIG_ITEMS = [...COMMON, ...ADVANCED];
|
|
1759
|
+
COMMON.length;
|
|
1760
|
+
//#endregion
|
|
1761
|
+
//#region src/client.ts
|
|
1762
|
+
/** dsh-home-ilife client 适配器(六边形:port=contract,adapter=本文件)。
|
|
1763
|
+
*
|
|
1764
|
+
* 产物经 tsdown 打成 loader 工厂包(browser/CJS,见 tsdown.config.ts)。
|
|
1765
|
+
* 本文件传递闭包:禁 node、禁 DOM 直写(document/window/process)。
|
|
1766
|
+
* 注册一处:**技能设置页** → 爱生活页签槽(总管声明的 children,各技能自研自家配置页)。
|
|
1767
|
+
*
|
|
1768
|
+
* #909:设置页本体(样式表/行渲染/卡片骨架/状态机/取值与填值/配置面三通电话/
|
|
1769
|
+
* 目录选择入口)**收进共用件** `dsh-life-pack/config-panel`,本文件只留四处接线:
|
|
1770
|
+
* ① 行表(住 `settings.ts`——那是本家自己的配置数据,不是 UI);
|
|
1771
|
+
* ② 通道路由名(`RPC_CHANNEL`,本家唯一那个真变量);
|
|
1772
|
+
* ③ 三个可选钩子(卡片标题/跟随映射/自家附加块;本家只用到前两个);
|
|
1773
|
+
* ④ 两格宿主取数接线(`getCall`/`getService`)。
|
|
1774
|
+
* 本家**不再有**自己的样式表与行渲染函数:改一次共用面板,六家一起变。
|
|
1775
|
+
* 照 CONTEXT.md,「技能设置页」只配置、不干活:本页没有查物品、盘物品之类的入口。
|
|
1776
|
+
*/
|
|
1777
|
+
/** client 短名声明:只有这两个(#736 的目录选择走**可选查找**,不写进来——
|
|
1778
|
+
* 写进来=硬依赖,提供方缺席时整包被停靠,设置页会跟着装不上;见 cookbook §13)。 */
|
|
1779
|
+
const inject = ["slots", "connection"];
|
|
1780
|
+
/** 跟随映射(#863):触发键一脏,这些只读派生行就进“将跟随更新”态。纯函数,面板与单测共用。 */
|
|
1781
|
+
const DB_FOLLOWERS = [
|
|
1782
|
+
"db.name",
|
|
1783
|
+
"html.dir",
|
|
1784
|
+
"backup.dir",
|
|
1785
|
+
"key.file"
|
|
1786
|
+
];
|
|
1787
|
+
/** 脏键 → 跟随行(#863 纯函数,交给共用面板当钩子)。 */
|
|
1788
|
+
function followKeysOf(dirtyKeys) {
|
|
1789
|
+
return dirtyKeys.includes("db.dir") ? [...DB_FOLLOWERS] : [];
|
|
1492
1790
|
}
|
|
1493
1791
|
function apply(ctx) {
|
|
1494
1792
|
const getCall = () => ctx.connection?.rpc?.call ?? null;
|
|
1495
|
-
const
|
|
1793
|
+
const getService = (name) => typeof ctx.get === "function" ? ctx.get(name) : void 0;
|
|
1496
1794
|
ctx.slots.inject("ilife.config-tab", () => ctx.slots.register({
|
|
1497
1795
|
name: "ilife.config-tab",
|
|
1498
1796
|
id: PLUGIN,
|
|
1499
1797
|
order: 90,
|
|
1500
1798
|
label: () => SLOT_TITLE,
|
|
1501
1799
|
channel: RPC_CHANNEL
|
|
1502
|
-
}, () => react.createElement(
|
|
1800
|
+
}, () => react.createElement(ConfigPanel, {
|
|
1801
|
+
channel: RPC_CHANNEL,
|
|
1802
|
+
items: CONFIG_ITEMS,
|
|
1803
|
+
title: SLOT_TITLE,
|
|
1804
|
+
followKeysOf,
|
|
1503
1805
|
getCall,
|
|
1504
|
-
|
|
1505
|
-
rootsSource: createRootsSource({
|
|
1506
|
-
getCall,
|
|
1507
|
-
base: MANAGER_RPC_BASE,
|
|
1508
|
-
endpoint: MANAGER_RPC_ENDPOINT,
|
|
1509
|
-
method: MANAGER_ROOTS_METHOD
|
|
1510
|
-
})
|
|
1806
|
+
getService
|
|
1511
1807
|
})));
|
|
1512
1808
|
}
|
|
1513
1809
|
//#endregion
|
|
1514
|
-
exports.READ_TIMEOUT_MS = READ_TIMEOUT_MS;
|
|
1515
|
-
exports.Row = Row;
|
|
1516
1810
|
exports.apply = apply;
|
|
1517
|
-
exports.
|
|
1518
|
-
exports.directoryEntryMode = directoryEntryMode;
|
|
1519
|
-
exports.fetchConfigSurface = fetchConfigSurface;
|
|
1520
|
-
exports.fromDraft = fromDraft;
|
|
1521
|
-
exports.humanizeConfigFailure = humanizeConfigFailure;
|
|
1811
|
+
exports.followKeysOf = followKeysOf;
|
|
1522
1812
|
exports.inject = inject;
|
|
1523
|
-
exports.isDirectoryPicker = isDirectoryPicker;
|
|
1524
|
-
exports.openRowBrowser = openRowBrowser;
|
|
1525
|
-
exports.pickDirectory = pickDirectory;
|
|
1526
|
-
exports.pickerModeOf = pickerModeOf;
|
|
1527
|
-
exports.readPickAnswer = readPickAnswer;
|
|
1528
|
-
exports.resetConfigSurface = resetConfigSurface;
|
|
1529
|
-
exports.resolveDirectoryPicker = resolveDirectoryPicker;
|
|
1530
|
-
exports.saveConfigSurface = saveConfigSurface;
|
|
1531
|
-
exports.toDraft = toDraft;
|
|
1532
1813
|
return module.exports;
|
|
1533
1814
|
}
|
|
1534
1815
|
});
|