@noob-stupid/dsh-plugin-console 0.5.95 → 0.5.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js
CHANGED
|
@@ -387,6 +387,69 @@
|
|
|
387
387
|
gateRowFromUpgrade: "来源:框架升级预扫",
|
|
388
388
|
gateRowAt: " · {t}",
|
|
389
389
|
gateRowAdoptable: " · 已通过源码扫描,可解锁",
|
|
390
|
+
// ★ 0.5.98 加法(用户 2026-10-28):适配门**记录**的如实标注 + 删除入口(两段式)。
|
|
391
|
+
// 现场:3 条 pending 记录的补丁行早已不存在、moduleName 也是 null —— 面板必须如实说出来。
|
|
392
|
+
gateRowModule: "模块:",
|
|
393
|
+
gateRowMissing: "该行已不存在",
|
|
394
|
+
gateStaleLine: "另有 {n} 条记录的补丁行已不存在(永远等不到适配,可以清掉)",
|
|
395
|
+
gateForgetRow: "删除记录",
|
|
396
|
+
gateForgetRowTitle: "只删这条记录:不动插件、不动补丁文件(点一下先确认)",
|
|
397
|
+
gateForgetStale: "清理无效记录",
|
|
398
|
+
gateForgetStaleTitle: "只清掉补丁行已不存在的记录:不动插件、不动补丁文件(点一下先确认)",
|
|
399
|
+
gateForgetArmRow: "将删除记录 {id}(模块:{name})。",
|
|
400
|
+
gateForgetArmStale: "将清理 {n} 条失效记录(补丁行已不存在的那几条)。真实待适配与历史记录都不会被删。",
|
|
401
|
+
gateForgetSafety: "只删 compat-pending.json 里的记录:不动插件、不动补丁文件,写盘前先备份。",
|
|
402
|
+
gateForgetConfirm: "确认删除",
|
|
403
|
+
gateForgetCancel: "取消",
|
|
404
|
+
gateForgetDoneRow: "已删除记录 {id}(插件与补丁一个字都没动)",
|
|
405
|
+
gateForgetDoneStale: "已清理 {n} 条失效记录(插件与补丁一个字都没动)",
|
|
406
|
+
gateForgetNone: "没有可删除的记录(文件未改动)",
|
|
407
|
+
gateForgetFailed: "删除失败:",
|
|
408
|
+
// ★ 0.5.x 加法(用户 2026-10-18):「安全启动」在界面上的入口 —— 门控面板里的一个分区。
|
|
409
|
+
// 用户原话「现在这能力藏得太深,只有命令行能用」;这里只做展示 + 两段式上膛,
|
|
410
|
+
// 判据与动作白名单全在服务端 POST /plugin-console/safe-boot。
|
|
411
|
+
safeBootSectionTitle: "安全启动",
|
|
412
|
+
safeBootToggleTitle: "展开/收起安全启动面板(只有展开那一下才读一次快照列表,收起时零请求)",
|
|
413
|
+
safeBootSectionHint: "服务还能应答时的出路:回到上次好的补丁状态、或只禁可疑行。服务起不来时用下面的命令行兜底。",
|
|
414
|
+
safeBootLoading: "正在读取快照列表…",
|
|
415
|
+
safeBootReload: "重新读取",
|
|
416
|
+
safeBootListFailed: "读取快照失败:",
|
|
417
|
+
safeBootEmpty: "还没有快照:",
|
|
418
|
+
safeBootNotWritten: "本次没有写新快照:",
|
|
419
|
+
safeBootReasonPatchInvalid: "补丁结构校验没过,快照闸门没放行",
|
|
420
|
+
safeBootReasonNoPatch: "没有补丁文件,没有可写的材料",
|
|
421
|
+
safeBootReasonEnvChanged: "环境指纹刚变过,还没确认服务正常",
|
|
422
|
+
safeBootReasonUnchanged: "内容与最新快照逐字节相同,无需再写一份",
|
|
423
|
+
safeBootReasonNotYet: "还没到该写的时机(快照只在服务确认正常、且环境指纹未变时才写)",
|
|
424
|
+
safeBootCount: "快照 {n} 份",
|
|
425
|
+
safeBootLastGoodId: "最后标记为良好:",
|
|
426
|
+
safeBootLastGood: "良好(last-good)",
|
|
427
|
+
safeBootRowAt: "时间",
|
|
428
|
+
safeBootRowSha: "sha256",
|
|
429
|
+
safeBootRestore: "恢复上次好的状态",
|
|
430
|
+
safeBootRestoreTitle: "两段式:第一下只是上膛并写明要恢复到哪份快照,确认后才真发请求",
|
|
431
|
+
safeBootDisable: "只禁用可疑行",
|
|
432
|
+
safeBootDisableTitle: "两段式:只给自动预检点名的行追加 disabled: true,不做整树回滚",
|
|
433
|
+
safeBootMarkGood: "标记当前为好的状态",
|
|
434
|
+
safeBootMarkGoodTitle: "把最新这份快照标记为良好(永不随保留策略删除);成功后自动刷新列表",
|
|
435
|
+
safeBootArmRestore: "要恢复到的快照:",
|
|
436
|
+
safeBootArmRestoreOverwrite: "会把这个补丁文件整份覆盖成快照内容(不做合并),改前先另存备份:",
|
|
437
|
+
safeBootArmDisable: "只给这些行追加 disabled: true:",
|
|
438
|
+
safeBootArmDisableNote: "不删任何行、不做整树回滚,改前先另存备份。",
|
|
439
|
+
safeBootConfirm: "确认执行",
|
|
440
|
+
safeBootCancel: "取消(不发任何请求)",
|
|
441
|
+
safeBootDoneRestore: "已按快照逐字节恢复(改前状态已备份)",
|
|
442
|
+
safeBootDoneDisable: "已只给点名行追加 disabled: true(其余逐字节未动):",
|
|
443
|
+
safeBootDoneMarkGood: "已标记为良好:这份快照不再随保留策略删除:",
|
|
444
|
+
safeBootDisabledSkipped: "跳过",
|
|
445
|
+
safeBootNoopRestore: "当前补丁与快照逐字节一致,无需恢复(已备份当前状态)",
|
|
446
|
+
safeBootNoSuspects: "服务端没点名任何可疑行,一个字节都没改",
|
|
447
|
+
safeBootSuspectsTitle: "自动预检点名的可疑行:",
|
|
448
|
+
safeBootSuspectsNone: "自动预检还没点名任何可疑行 —— 所以这里不显示「只禁用可疑行」按钮(点了也没东西可禁)",
|
|
449
|
+
safeBootNoSnapshotActions: "没有快照可操作:「恢复」与「标记为良好」都要先有快照,所以这两个按钮不显示",
|
|
450
|
+
safeBootFailed: "操作失败:",
|
|
451
|
+
safeBootCmdHint: "服务起不来时(命令行兜底,无需服务在跑):",
|
|
452
|
+
safeBootBusy: "进行中…",
|
|
390
453
|
starHint: "喜欢这个插件?去 GitHub 点个 Star",
|
|
391
454
|
starHintTitle: "在新标签页打开 GitHub 项目",
|
|
392
455
|
fwFailedButUpgraded: "框架本体其实已经升到 {v} 并已生效——失败的只是脚本最后「重启服务」那一步。可点「重启服务」确认,或必要时回滚。",
|
|
@@ -1118,8 +1181,67 @@
|
|
|
1118
1181
|
gateRowReason: "Reason: {reason}",
|
|
1119
1182
|
gateRowFromBoot: "Source: boot-failure quarantine",
|
|
1120
1183
|
gateRowFromUpgrade: "Source: framework upgrade pre-scan",
|
|
1184
|
+
// ★ 0.5.98 additive: honest labelling of adaptation-gate **records** + a delete entry (two-step).
|
|
1185
|
+
gateRowModule: "Module: ",
|
|
1186
|
+
gateRowMissing: "this row no longer exists",
|
|
1187
|
+
gateStaleLine: "{n} more record(s) point to rows that no longer exist (they can never be adapted — safe to clean up)",
|
|
1188
|
+
gateForgetRow: "Delete record",
|
|
1189
|
+
gateForgetRowTitle: "Deletes this record only: no plugin and no patch file is touched (click once to confirm)",
|
|
1190
|
+
gateForgetStale: "Clean up invalid records",
|
|
1191
|
+
gateForgetStaleTitle: "Only removes records whose patch row no longer exists: no plugin and no patch file is touched (click once to confirm)",
|
|
1192
|
+
gateForgetArmRow: "Record {id} (module: {name}) will be deleted.",
|
|
1193
|
+
gateForgetArmStale: "{n} invalid record(s) (their patch rows no longer exist) will be removed. Real pending rows and history stay untouched.",
|
|
1194
|
+
gateForgetSafety: "Records in compat-pending.json only: no plugin and no patch file is touched, and a backup is written first.",
|
|
1195
|
+
gateForgetConfirm: "Confirm delete",
|
|
1196
|
+
gateForgetCancel: "Cancel",
|
|
1197
|
+
gateForgetDoneRow: "Deleted record {id} (plugins and patch file untouched)",
|
|
1198
|
+
gateForgetDoneStale: "Removed {n} invalid record(s) (plugins and patch file untouched)",
|
|
1199
|
+
gateForgetNone: "Nothing to delete (file unchanged)",
|
|
1200
|
+
gateForgetFailed: "Delete failed: ",
|
|
1121
1201
|
gateRowAt: " · {t}",
|
|
1122
1202
|
gateRowAdoptable: " · passed the source scan, can be unlocked",
|
|
1203
|
+
safeBootSectionTitle: "Safe boot",
|
|
1204
|
+
safeBootToggleTitle: "Expand/collapse the safe-boot panel (the snapshot list is read once per expand — zero requests while collapsed)",
|
|
1205
|
+
safeBootSectionHint: "Ways out while the service still answers: return to the last good patch state, or disable suspect rows only. When the service cannot start, use the command below.",
|
|
1206
|
+
safeBootLoading: "Reading the snapshot list…",
|
|
1207
|
+
safeBootReload: "Reload",
|
|
1208
|
+
safeBootListFailed: "Could not read snapshots: ",
|
|
1209
|
+
safeBootEmpty: "No snapshot yet: ",
|
|
1210
|
+
safeBootNotWritten: "No new snapshot was written this time: ",
|
|
1211
|
+
safeBootReasonPatchInvalid: "the patch failed the structural check, so the snapshot gate refused to write",
|
|
1212
|
+
safeBootReasonNoPatch: "there is no patch file, so there is nothing to snapshot",
|
|
1213
|
+
safeBootReasonEnvChanged: "the environment fingerprint just changed, the service is not confirmed healthy yet",
|
|
1214
|
+
safeBootReasonUnchanged: "byte-identical to the newest snapshot, no need to write another one",
|
|
1215
|
+
safeBootReasonNotYet: "the moment has not arrived yet (snapshots are written only once the service is confirmed healthy and the environment fingerprint has not changed)",
|
|
1216
|
+
safeBootCount: "Snapshots: {n}",
|
|
1217
|
+
safeBootLastGoodId: "Last marked good: ",
|
|
1218
|
+
safeBootLastGood: "good (last-good)",
|
|
1219
|
+
safeBootRowAt: "at",
|
|
1220
|
+
safeBootRowSha: "sha256",
|
|
1221
|
+
safeBootRestore: "Restore last-good state",
|
|
1222
|
+
safeBootRestoreTitle: "Two steps: the first click only arms it and names the snapshot, the request goes out after you confirm",
|
|
1223
|
+
safeBootDisable: "Disable suspect rows only",
|
|
1224
|
+
safeBootDisableTitle: "Two steps: append disabled: true to the rows named by auto-preflight only, never a whole-tree rollback",
|
|
1225
|
+
safeBootMarkGood: "Mark the current state as good",
|
|
1226
|
+
safeBootMarkGoodTitle: "Mark the newest snapshot as good (never removed by retention); the list refreshes afterwards",
|
|
1227
|
+
safeBootArmRestore: "Snapshot to restore:",
|
|
1228
|
+
safeBootArmRestoreOverwrite: "It overwrites this whole patch file with the snapshot content (no merge); the current state is backed up first:",
|
|
1229
|
+
safeBootArmDisable: "Append disabled: true to these rows only:",
|
|
1230
|
+
safeBootArmDisableNote: "No row is deleted and there is no whole-tree rollback; the current state is backed up first.",
|
|
1231
|
+
safeBootConfirm: "Confirm",
|
|
1232
|
+
safeBootCancel: "Cancel (sends nothing)",
|
|
1233
|
+
safeBootDoneRestore: "Restored byte-for-byte from the snapshot (previous state backed up)",
|
|
1234
|
+
safeBootDoneDisable: "Appended disabled: true to the named rows only (everything else untouched):",
|
|
1235
|
+
safeBootDoneMarkGood: "Marked as good: this snapshot is never removed by retention:",
|
|
1236
|
+
safeBootDisabledSkipped: "skipped",
|
|
1237
|
+
safeBootNoopRestore: "The patch is already byte-identical to the snapshot — nothing to restore (current state was backed up)",
|
|
1238
|
+
safeBootNoSuspects: "The server named no suspect rows, so not a single byte changed",
|
|
1239
|
+
safeBootSuspectsTitle: "Rows named by auto-preflight:",
|
|
1240
|
+
safeBootSuspectsNone: "Auto-preflight has not named any suspect row yet — so the \"disable suspect rows only\" button is not shown (there would be nothing to disable)",
|
|
1241
|
+
safeBootNoSnapshotActions: "Nothing to act on: restore and mark-as-good both need an existing snapshot, so those two buttons are not shown",
|
|
1242
|
+
safeBootFailed: "Action failed: ",
|
|
1243
|
+
safeBootCmdHint: "When the service cannot start (offline fallback, no running server needed):",
|
|
1244
|
+
safeBootBusy: "working…",
|
|
1123
1245
|
starHint: "Enjoying this plugin? Star it on GitHub",
|
|
1124
1246
|
starHintTitle: "Open the GitHub project in a new tab",
|
|
1125
1247
|
fwFailedButUpgraded: "The framework itself is already on {v} and active — only the final \"restart service\" step failed. Use Restart service to confirm, or roll back if needed.",
|
|
@@ -5689,12 +5811,378 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
5689
5811
|
const gateReady = state !== null && state.status === "ready";
|
|
5690
5812
|
const gateAutoDisable = !gateReady || (state.data.compatGate ?? {}).autoDisable !== false;
|
|
5691
5813
|
const gateAutoDetect = !gateReady || (state.data.compatGate ?? {}).autoDetect !== false;
|
|
5692
|
-
|
|
5814
|
+
// ★ 0.5.98 改错(用户 2026-10-28 实测):计数只数**真待适配**的 —— `status=pending`
|
|
5815
|
+
// **且**该行此刻仍在补丁里。旧口径取 `compatPending.pending.length`,把两种"不是待办"的
|
|
5816
|
+
// 记录算了进去:① 已经 adopted 的历史(服务端本来就滤掉了,但语义上也不该数);
|
|
5817
|
+
// ② **行早已不存在的 pending**(真机现场 3 条,moduleName 也是 null)——
|
|
5818
|
+
// 于是面板一直挂着"有 N 行待适配",而实际一行都没有。
|
|
5819
|
+
// 判据的唯一真源在服务端(domain/compat-pending-records.js#isActionablePending,
|
|
5820
|
+
// /state 下发 actionableCount / staleCount);拿不到这两个字段时(老服务端),
|
|
5821
|
+
// 这里按**同一个判据**用 state.data.patch 的既有行清单(disables/forced/inserts,
|
|
5822
|
+
// 就是补丁解析的产物)回退算,**绝不退回数组长度**。
|
|
5823
|
+
const gatePatchRowIds = new Set([
|
|
5824
|
+
...((state !== null && state.data && state.data.patch && Array.isArray(state.data.patch.disables)) ? state.data.patch.disables : []),
|
|
5825
|
+
...((state !== null && state.data && state.data.patch && Array.isArray(state.data.patch.forced)) ? state.data.patch.forced : []),
|
|
5826
|
+
...((state !== null && state.data && state.data.patch && Array.isArray(state.data.patch.inserts)) ? state.data.patch.inserts : []),
|
|
5827
|
+
]);
|
|
5828
|
+
const gatePendingRaw = gateReady ? (state.data.compatPending?.pending ?? []) : [];
|
|
5829
|
+
/** 一条记录"行还在不在"(服务端 rowMissing 优先;老服务端按本地行清单算 —— 同一判据)。 */
|
|
5830
|
+
const gateRowMissing = (r) => (r !== null && r !== undefined && typeof r.rowMissing === "boolean"
|
|
5831
|
+
? r.rowMissing
|
|
5832
|
+
: !(r !== null && r !== undefined && typeof r.rowId === "string" && gatePatchRowIds.has(r.rowId)));
|
|
5833
|
+
/** 一条记录"是不是真待适配"(服务端 actionable 优先;老服务端按同一判据回退)。 */
|
|
5834
|
+
const gateRowActionable = (r) => (r !== null && r !== undefined && typeof r.actionable === "boolean"
|
|
5835
|
+
? r.actionable
|
|
5836
|
+
: ((r?.status ?? "pending") === "pending" && !gateRowMissing(r)));
|
|
5837
|
+
const gateActionableRows = gatePendingRaw.filter((p) => (p?.status ?? "pending") === "pending" && !gateRowMissing(p));
|
|
5838
|
+
const gateStaleRows = gatePendingRaw.filter((p) => (p?.status ?? "pending") === "pending" && gateRowMissing(p));
|
|
5839
|
+
const gatePendingCount = gateReady
|
|
5840
|
+
? (Number.isFinite(state.data.compatPending?.actionableCount) ? state.data.compatPending.actionableCount : gateActionableRows.length)
|
|
5841
|
+
: 0;
|
|
5842
|
+
const gateStaleCount = gateReady
|
|
5843
|
+
? (Number.isFinite(state.data.compatPending?.staleCount) ? state.data.compatPending.staleCount : gateStaleRows.length)
|
|
5844
|
+
: 0;
|
|
5693
5845
|
// 门控明细清单(用户 2026-09-24 要求:面板要显示"是哪些插件",不只是数量)
|
|
5694
5846
|
const [gateShowAll, setGateShowAll] = react.useState(false);
|
|
5695
5847
|
const [compatNoticeClosed, setCompatNoticeClosed] = react.useState(false);
|
|
5696
5848
|
const gateRows = gateReady ? (state.data.gating ?? null) : null;
|
|
5697
5849
|
const gateTime = (ms) => (typeof ms === "number" && ms > 0 ? new Date(ms).toLocaleString() : "—");
|
|
5850
|
+
// ★ 0.5.98 加法(用户 2026-10-28 明确要求):**删记录**的两个入口(单条 / 批量清理失效)。
|
|
5851
|
+
// 三条硬约束:① 只删记录 —— 不动插件、不动补丁文件(服务端只改 compat-pending.json);
|
|
5852
|
+
// ② **两段式** —— 第一下只上膛(零请求,并把"要删什么、不影响什么"写清楚),确认后才真发;
|
|
5853
|
+
// ③ 失败一律把服务端原文摆出来(不静默、不谎报成功)。
|
|
5854
|
+
const [gateForgetArmed, setGateForgetArmed] = react.useState(null); // { kind: "row", rowId, moduleName } | { kind: "stale" }
|
|
5855
|
+
const [gateForgetBusy, setGateForgetBusy] = react.useState(false);
|
|
5856
|
+
const [gateForgetNote, setGateForgetNote] = react.useState(null); // { text, level }
|
|
5857
|
+
/** 两段式的第一段:只上膛,一个请求都不发(换目标 = 换载荷,旧载荷不会跟着发出去)。 */
|
|
5858
|
+
const gateForgetArm = (next) => { setGateForgetNote(null); setGateForgetArmed(next); };
|
|
5859
|
+
/** 唯一出口:真发删除请求(成功后刷新 /state,列表与计数一起变)。 */
|
|
5860
|
+
const gateForgetSend = (armed) => {
|
|
5861
|
+
if (armed === null || gateForgetBusy) return;
|
|
5862
|
+
setGateForgetArmed(null); // 一旦真发请求,上膛状态作废(绝不留"看着像待确认"的残影)
|
|
5863
|
+
setGateForgetBusy(true);
|
|
5864
|
+
setGateForgetNote(null);
|
|
5865
|
+
const body = armed.kind === "stale" ? { action: "forget-stale" } : { action: "forget", rowId: String(armed.rowId) };
|
|
5866
|
+
call("/plugin-console/compat-gate", body).then(
|
|
5867
|
+
(data) => {
|
|
5868
|
+
const removed = Array.isArray(data?.removed) ? data.removed : [];
|
|
5869
|
+
const text = data !== null && data !== undefined && data.changed === false
|
|
5870
|
+
? t("gateForgetNone")
|
|
5871
|
+
: (armed.kind === "stale"
|
|
5872
|
+
? t("gateForgetDoneStale").replace("{n}", String(removed.length))
|
|
5873
|
+
: t("gateForgetDoneRow").replace("{id}", String(armed.rowId)));
|
|
5874
|
+
setGateForgetNote({ text, level: null });
|
|
5875
|
+
refresh(); // 删完必须重拉 /state(计数与列表都跟着变,不留旧数字)
|
|
5876
|
+
},
|
|
5877
|
+
(error) => setGateForgetNote({ text: t("gateForgetFailed") + friendlyGithubError(error).message, level: "error" }),
|
|
5878
|
+
).finally(() => setGateForgetBusy(false));
|
|
5879
|
+
};
|
|
5880
|
+
// ══ 安全启动(门控面板里的一个分区)══════════════════════════════════════════════
|
|
5881
|
+
// 用户 2026-10-18 原话:「现在这能力藏得太深,只有命令行能用」。后端 POST /safe-boot 早就有了
|
|
5882
|
+
// (lib/server/routes/safe-boot.js;动作白名单 list / restore-last-good / disable-suspects /
|
|
5883
|
+
// mark-good),界面上却一个入口都没有 —— 这里补的就是这四个动作的手套。
|
|
5884
|
+
//
|
|
5885
|
+
// 为什么放**门控面板**里(不是 fwPanel、更不是新工具栏按钮):
|
|
5886
|
+
// safe-boot 救的正是「门控/兼容判断没拦住、框架被别的通道换坏之后打不开」那种局面 ——
|
|
5887
|
+
// 它和门控是**同一条链**(先拦、拦不住再回退),放在一起,用户出事时只有一个地方要想起。
|
|
5888
|
+
// 用户也明确不许再加工具栏按钮(已经挤),所以它挂在已有门控面板里,排在门控正文之后。
|
|
5889
|
+
//
|
|
5890
|
+
// 三条硬约束(用户定):① 不许"点了没反应" —— 每个动作要么有成功/失败/进行中反馈,要么这个
|
|
5891
|
+
// 按钮根本不显示;② 危险写动作**两段式** —— 第一下只上膛并写明影响面(改哪份文件、会覆盖什么),
|
|
5892
|
+
// 确认后才真发请求;③ 拿不到的数据如实显示「—」,不编造。
|
|
5893
|
+
// 判据一条都不搬过来:动作白名单、快照挑选、写盘闸门全在服务端 —— 客户端不重算、不预测结果。
|
|
5894
|
+
const [safeBootOpen, setSafeBootOpen] = react.useState(false);
|
|
5895
|
+
const [safeBootData, setSafeBootData] = react.useState(null); // list 的响应(null = 还没读过 / 读取失败)
|
|
5896
|
+
const [safeBootError, setSafeBootError] = react.useState(null); // 读取失败原文(不静默、不留空白)
|
|
5897
|
+
const [safeBootBusy, setSafeBootBusy] = react.useState(null); // 'list' | 动作名 | null(进行中反馈)
|
|
5898
|
+
const [safeBootArmed, setSafeBootArmed] = react.useState(null); // 两段式的"上膛":{ action, id?, rowIds? }
|
|
5899
|
+
const [safeBootNote, setSafeBootNote] = react.useState(null); // 写动作的结果/失败原文:{ text, level }
|
|
5900
|
+
const safeBootErrText = (error) => {
|
|
5901
|
+
const msg = error !== null && error !== undefined && typeof error.message === "string" ? error.message : String(error ?? "");
|
|
5902
|
+
return msg === "" ? t("safetySwitchFailed") : msg;
|
|
5903
|
+
};
|
|
5904
|
+
const safeBootTime = (ms) => (typeof ms === "number" && ms > 0 ? new Date(ms).toLocaleString() : "—");
|
|
5905
|
+
/** 快照 sha256 只显示前几位(列表里够辨认;完整值在快照文件里)。拿不到就是「—」。 */
|
|
5906
|
+
const safeBootSha = (sha) => (typeof sha === "string" && sha !== "" ? sha.slice(0, 10) : "—");
|
|
5907
|
+
/** 「为什么没写快照」的原因词表 —— 与 lib/server/routes/compat.js#maybeWriteSnapshot 是**同一套字符串**。
|
|
5908
|
+
* 不认识的 reason 原样显示(不翻译成瞎话,也不吞掉)。 */
|
|
5909
|
+
const SAFE_BOOT_REASON_KEYS = {
|
|
5910
|
+
"patch-not-yaml-valid": "safeBootReasonPatchInvalid", // 结构校验没过(有东西可写,但闸门不放行)
|
|
5911
|
+
"no-patch-file": "safeBootReasonNoPatch",
|
|
5912
|
+
"environment-changed": "safeBootReasonEnvChanged", // 时机未到
|
|
5913
|
+
"unchanged": "safeBootReasonUnchanged", // 时机未到(已有一份一样的)
|
|
5914
|
+
"not-yet": "safeBootReasonNotYet", // 时机未到(这个字符串由本面板推导出来,见下)
|
|
5915
|
+
};
|
|
5916
|
+
const safeBootReasonText = (reason) => {
|
|
5917
|
+
const key = typeof reason === "string" ? SAFE_BOOT_REASON_KEYS[reason] : undefined;
|
|
5918
|
+
if (key !== undefined) return t(key);
|
|
5919
|
+
return typeof reason === "string" && reason !== "" ? reason : "—";
|
|
5920
|
+
};
|
|
5921
|
+
/**
|
|
5922
|
+
* 「这一次该写快照却没写,是因为什么」——**只在能说出原因时**返回 `{ written: false, reason, problems }`,
|
|
5923
|
+
* 说不出来就返回 null(面板就不显示这一行,不编一句话凑数)。
|
|
5924
|
+
*
|
|
5925
|
+
* 数据来源两条,都不猜:
|
|
5926
|
+
* ① 响应里真带 `snapshot.written`(与 compat-status 的 safeBoot.snapshot 同形)→ 原样用它的
|
|
5927
|
+
* written/reason;written === true 时返回 null(没东西可抱怨)。
|
|
5928
|
+
* ② 没带时,只用 list **确实下发**的事实推导:patch.exists / patch.check(服务端当场跑
|
|
5929
|
+
* validatePatchYaml 的结论)/ snapshots。于是两类原因分得清清楚楚:
|
|
5930
|
+
* · 结构校验没过 → `patch-not-yaml-valid`(有补丁、但校验结论 ok:false)
|
|
5931
|
+
* · 还没到该写的时机 → `not-yet`(补丁没问题,只是根本还没有一份快照;
|
|
5932
|
+
* 服务端只在「服务确认正常 + 环境指纹未变」时才写 —— 见 compat.js 的那段判据)
|
|
5933
|
+
* 两个文案完全不同,绝不糊成一句"没有快照"。
|
|
5934
|
+
*/
|
|
5935
|
+
const safeBootGateOf = (data) => {
|
|
5936
|
+
if (data === null || typeof data !== "object") return null;
|
|
5937
|
+
if (data.snapshot !== null && typeof data.snapshot === "object" && typeof data.snapshot.written === "boolean") {
|
|
5938
|
+
if (data.snapshot.written === true) return null;
|
|
5939
|
+
return { written: false, reason: data.snapshot.reason ?? null, problems: Array.isArray(data.snapshot.problems) ? data.snapshot.problems : [] };
|
|
5940
|
+
}
|
|
5941
|
+
const snaps = Array.isArray(data.snapshots) ? data.snapshots : [];
|
|
5942
|
+
const patch = data.patch !== null && typeof data.patch === "object" ? data.patch : null;
|
|
5943
|
+
if (patch === null || patch.exists !== true) return { written: false, reason: "no-patch-file", problems: [] };
|
|
5944
|
+
const check = patch.check !== null && typeof patch.check === "object" ? patch.check : null;
|
|
5945
|
+
if (check !== null && check.ok !== true) return { written: false, reason: "patch-not-yaml-valid", problems: Array.isArray(check.problems) ? check.problems : [] };
|
|
5946
|
+
if (snaps.length === 0) return { written: false, reason: "not-yet", problems: [] };
|
|
5947
|
+
return null;
|
|
5948
|
+
};
|
|
5949
|
+
/** 快照清单(新→旧;只认形状对的条目,坏数据不会把整块渲染拖崩)。 */
|
|
5950
|
+
const safeBootSnapsOf = (data) => (data !== null && typeof data === "object" && Array.isArray(data.snapshots) ? data.snapshots : [])
|
|
5951
|
+
.filter((s) => s !== null && typeof s === "object" && typeof s.id === "string")
|
|
5952
|
+
.sort((a, b) => Number(b.at ?? 0) - Number(a.at ?? 0));
|
|
5953
|
+
/** 自动预检点名的可疑行(服务端 lastPlan.suspects);没有就是空数组 —— 那就**不显示**这个按钮。 */
|
|
5954
|
+
const safeBootSuspectsOf = (data) => (data !== null && typeof data === "object" && data.lastPlan !== null && typeof data.lastPlan === "object" && Array.isArray(data.lastPlan.suspects)
|
|
5955
|
+
? data.lastPlan.suspects.filter((x) => typeof x === "string" && x !== "")
|
|
5956
|
+
: []);
|
|
5957
|
+
/** 读一次快照列表(**只**在"展开那一下"和写动作之后调用;收起状态零请求)。
|
|
5958
|
+
* 失败时保留上一次的数据但把错误原文摆出来 —— 一次读取失手不该把已有信息抹掉。 */
|
|
5959
|
+
const safeBootRead = async () => {
|
|
5960
|
+
try {
|
|
5961
|
+
const data = await call("/plugin-console/safe-boot", { action: "list" });
|
|
5962
|
+
if (data === null || typeof data !== "object") { setSafeBootError(t("safeBootListFailed") + "—"); return; }
|
|
5963
|
+
setSafeBootData(data);
|
|
5964
|
+
setSafeBootError(null);
|
|
5965
|
+
} catch (error) {
|
|
5966
|
+
setSafeBootError(t("safeBootListFailed") + safeBootErrText(error));
|
|
5967
|
+
}
|
|
5968
|
+
};
|
|
5969
|
+
/** 写动作结束后的一句结果(服务端说什么就说什么:changed:false / no-suspects / 具体行)。 */
|
|
5970
|
+
const safeBootDoneText = (action, data) => {
|
|
5971
|
+
if (data === null || typeof data !== "object") return t("safeBootFailed") + "—";
|
|
5972
|
+
if (action === "restore-last-good") return data.changed === false ? t("safeBootNoopRestore") : t("safeBootDoneRestore");
|
|
5973
|
+
if (action === "disable-suspects") {
|
|
5974
|
+
if (data.reason === "no-suspects") return t("safeBootNoSuspects");
|
|
5975
|
+
const added = Array.isArray(data.added) ? data.added.filter((x) => typeof x === "string") : [];
|
|
5976
|
+
const skipped = Array.isArray(data.skipped) ? data.skipped : [];
|
|
5977
|
+
if (added.length === 0) return t("safeBootNoSuspects");
|
|
5978
|
+
return t("safeBootDoneDisable") + " " + added.join("、") + (skipped.length === 0 ? "" : " · " + t("safeBootDisabledSkipped") + " " + String(skipped.length));
|
|
5979
|
+
}
|
|
5980
|
+
if (action === "mark-good") {
|
|
5981
|
+
const id = data.snapshot !== null && typeof data.snapshot === "object" && typeof data.snapshot.id === "string" ? data.snapshot.id : null;
|
|
5982
|
+
return t("safeBootDoneMarkGood") + " " + (id ?? "—");
|
|
5983
|
+
}
|
|
5984
|
+
return "—";
|
|
5985
|
+
};
|
|
5986
|
+
/** 真发请求(唯一出口):list / restore-last-good / disable-suspects / mark-good */
|
|
5987
|
+
const safeBootSend = async (action, extra) => {
|
|
5988
|
+
if (safeBootBusy !== null) return; // 进行中不叠加请求(按钮此刻也是 disabled)
|
|
5989
|
+
setSafeBootBusy(action);
|
|
5990
|
+
setSafeBootNote(null);
|
|
5991
|
+
setSafeBootArmed(null); // 一旦真发请求,上膛状态作废(绝不留一个"看着像待确认"的残影)
|
|
5992
|
+
if (action === "list") setSafeBootError(null);
|
|
5993
|
+
try {
|
|
5994
|
+
const body = { action };
|
|
5995
|
+
// 只放服务端白名单认得的两个载荷字段(id / rowIds);其余一概不传。
|
|
5996
|
+
if (extra !== null && extra !== undefined) {
|
|
5997
|
+
if (typeof extra.id === "string" && extra.id !== "") body.id = extra.id;
|
|
5998
|
+
if (Array.isArray(extra.rowIds) && extra.rowIds.length > 0) body.rowIds = extra.rowIds.slice();
|
|
5999
|
+
}
|
|
6000
|
+
const data = await call("/plugin-console/safe-boot", body);
|
|
6001
|
+
if (action === "list") {
|
|
6002
|
+
if (data === null || typeof data !== "object") setSafeBootError(t("safeBootListFailed") + "—");
|
|
6003
|
+
else { setSafeBootData(data); setSafeBootError(null); }
|
|
6004
|
+
} else {
|
|
6005
|
+
setSafeBootNote({ text: safeBootDoneText(action, data), level: null });
|
|
6006
|
+
// 写动作之后**重新读一次列表**(mark-good 尤其必须:last-good 标记要随之变化)
|
|
6007
|
+
await safeBootRead();
|
|
6008
|
+
}
|
|
6009
|
+
} catch (error) {
|
|
6010
|
+
// 失败一律把服务端给的原文摆出来(call() 已经把 {ok:false,error} 抛成 Error.message)
|
|
6011
|
+
if (action === "list") setSafeBootError(t("safeBootListFailed") + safeBootErrText(error));
|
|
6012
|
+
else setSafeBootNote({ text: t("safeBootFailed") + safeBootErrText(error), level: "error" });
|
|
6013
|
+
} finally { setSafeBootBusy(null); }
|
|
6014
|
+
};
|
|
6015
|
+
/** 展开/收起:**展开那一下**才读一次 list(收起状态零请求);收起时把上膛清掉。 */
|
|
6016
|
+
const safeBootToggle = () => {
|
|
6017
|
+
const next = !safeBootOpen;
|
|
6018
|
+
setSafeBootOpen(next);
|
|
6019
|
+
setSafeBootArmed(null);
|
|
6020
|
+
setSafeBootNote(null);
|
|
6021
|
+
if (next) void safeBootSend("list");
|
|
6022
|
+
};
|
|
6023
|
+
/** 两段式的第一段:只上膛,一个请求都不发(换动作 = 换载荷,上一个动作的载荷不会跟着发出去)。 */
|
|
6024
|
+
const safeBootArm = (next) => {
|
|
6025
|
+
setSafeBootNote(null);
|
|
6026
|
+
setSafeBootArmed(next);
|
|
6027
|
+
};
|
|
6028
|
+
const safeBootPanel = () => {
|
|
6029
|
+
const data = safeBootData;
|
|
6030
|
+
const gate = safeBootGateOf(data);
|
|
6031
|
+
const snaps = safeBootSnapsOf(data);
|
|
6032
|
+
const suspects = safeBootSuspectsOf(data);
|
|
6033
|
+
// 恢复目标的选择口径与服务端 domain/safe-boot.js#pickSnapshot 一致(kind=good 优先,其次最新);
|
|
6034
|
+
// 选好后把 id **显式传下去** —— 面板上写的是哪份,恢复的就是哪份,不可能"说 A 做 B"。
|
|
6035
|
+
const goodOne = snaps.filter((s) => s.kind === "good")[0] ?? null;
|
|
6036
|
+
const restoreTarget = goodOne ?? snaps[0] ?? null;
|
|
6037
|
+
const newest = snaps[0] ?? null;
|
|
6038
|
+
const lastGoodId = data !== null && typeof data === "object" && typeof data.lastGoodId === "string" && data.lastGoodId !== "" ? data.lastGoodId : null;
|
|
6039
|
+
const patchPath = data !== null && typeof data === "object" && typeof data.patchPath === "string" && data.patchPath !== "" ? data.patchPath : null;
|
|
6040
|
+
const commands = data !== null && typeof data === "object" && data.commands !== null && typeof data.commands === "object" ? data.commands : null;
|
|
6041
|
+
// 上膛的那份快照对象(按 id 回查 —— 显示的时间/sha256 与"会恢复的那一份"必然是同一份)
|
|
6042
|
+
const armedSnap = safeBootArmed === null || typeof safeBootArmed.id !== "string" ? null : (snaps.find((s) => s.id === safeBootArmed.id) ?? null);
|
|
6043
|
+
const busy = safeBootBusy !== null;
|
|
6044
|
+
const reasonOf = (x) => (x === null || typeof x !== "object" ? null : x.reason ?? null);
|
|
6045
|
+
return el("div", {
|
|
6046
|
+
"data-safeboot": "true",
|
|
6047
|
+
style: { flexDirection: "column", gap: 6, display: "flex", marginTop: 4, paddingTop: 8, borderTop: "1px solid var(--dsw-alias-border-l2)" },
|
|
6048
|
+
},
|
|
6049
|
+
el("button", {
|
|
6050
|
+
type: "button",
|
|
6051
|
+
className: styles.toggle,
|
|
6052
|
+
"data-safeboot-toggle": "true",
|
|
6053
|
+
"data-open": safeBootOpen ? "true" : "false",
|
|
6054
|
+
"aria-label": "safeBootToggle",
|
|
6055
|
+
"aria-expanded": safeBootOpen ? "true" : "false",
|
|
6056
|
+
title: t("safeBootToggleTitle"),
|
|
6057
|
+
onClick: safeBootToggle,
|
|
6058
|
+
}, (safeBootOpen ? "▾ " : "▸ ") + t("safeBootSectionTitle") + (busy ? " · " + t("safeBootBusy") : "")),
|
|
6059
|
+
safeBootOpen ? el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
|
|
6060
|
+
el("p", { className: styles.status }, t("safeBootSectionHint")),
|
|
6061
|
+
// 进行中:明确说在做什么("点了没反应"就是这个面板最不能犯的错)
|
|
6062
|
+
busy ? el("p", { className: styles.status, "data-safeboot-busy": "true" }, t("safeBootLoading")) : null,
|
|
6063
|
+
// 读取失败:错误原文 + 一个「重新读取」(有反馈的出口,不是一个哑按钮)
|
|
6064
|
+
safeBootError !== null
|
|
6065
|
+
? el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
|
|
6066
|
+
el("p", { className: styles.message, "data-error": "true", "data-safeboot-error": "true" }, safeBootError),
|
|
6067
|
+
el("button", {
|
|
6068
|
+
type: "button",
|
|
6069
|
+
className: styles.toggle,
|
|
6070
|
+
"aria-label": "safeBootReload",
|
|
6071
|
+
disabled: busy,
|
|
6072
|
+
onClick: () => { void safeBootSend("list"); },
|
|
6073
|
+
}, t("safeBootReload")))
|
|
6074
|
+
: null,
|
|
6075
|
+
data === null ? null : el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
|
|
6076
|
+
// ① 「这次没写快照」+ 原因:结构校验没过 / 时机未到 —— 两句话不一样,原因原文也照抄
|
|
6077
|
+
gate === null
|
|
6078
|
+
? null
|
|
6079
|
+
: el("p", { className: styles.status, "data-safeboot-reason": "true", "data-reason": gate.reason ?? "—" },
|
|
6080
|
+
t("safeBootNotWritten") + " " + safeBootReasonText(reasonOf(gate)) + "(" + (reasonOf(gate) ?? "—") + ")"
|
|
6081
|
+
+ (gate.problems.length === 0 ? "" : " · " + gate.problems.slice(0, 3).join(";"))),
|
|
6082
|
+
// ② 计数 + last-good(拿不到就「—」);data-count 是这份份数本身(界面上不以它为唯一来源)
|
|
6083
|
+
el("p", { className: styles.status, "data-safeboot-count": "true", "data-count": String(snaps.length) },
|
|
6084
|
+
t("safeBootCount").replace("{n}", String(snaps.length)) + " · " + t("safeBootLastGoodId") + " " + (lastGoodId ?? "—")),
|
|
6085
|
+
// ③ 清单 / 空态(空态必须说原因,不能只说"没有快照"就完了)
|
|
6086
|
+
snaps.length === 0
|
|
6087
|
+
? el("p", { className: styles.status, "data-safeboot-empty": "true" },
|
|
6088
|
+
t("safeBootEmpty") + " " + safeBootReasonText(reasonOf(gate)) + "(" + (reasonOf(gate) ?? "—") + ")")
|
|
6089
|
+
: el("div", { style: { flexDirection: "column", gap: 4, display: "flex" }, "data-safeboot-list": "true" },
|
|
6090
|
+
snaps.map((s) => el("div", {
|
|
6091
|
+
key: "sb-" + String(s.id),
|
|
6092
|
+
className: styles.row,
|
|
6093
|
+
"data-safeboot-row": "true",
|
|
6094
|
+
"data-snapshot-id": String(s.id),
|
|
6095
|
+
},
|
|
6096
|
+
el("div", { className: styles.rowTop },
|
|
6097
|
+
el("code", { className: styles.name, style: { fontSize: 11, whiteSpace: "normal", overflow: "visible", textOverflow: "clip", wordBreak: "break-all" }, title: String(s.id) }, String(s.id)),
|
|
6098
|
+
s.kind === "good" || s.id === lastGoodId
|
|
6099
|
+
? el("span", { className: styles.tag, "data-lastgood": "true" }, t("safeBootLastGood"))
|
|
6100
|
+
: null),
|
|
6101
|
+
el("div", { className: styles.rowTop, style: { gap: 6 } },
|
|
6102
|
+
el("span", { className: styles.status }, t("safeBootRowAt") + " " + safeBootTime(s.at)),
|
|
6103
|
+
" · ",
|
|
6104
|
+
el("span", { className: styles.status, "data-snapshot-sha": "true" }, t("safeBootRowSha") + " " + safeBootSha(s.patchSha256)))))),
|
|
6105
|
+
// ④ 动作:没有可操作对象时**一个按钮都不显示**,并说明为什么(不许出现点了必失败的按钮)
|
|
6106
|
+
snaps.length === 0
|
|
6107
|
+
? el("p", { className: styles.status, "data-safeboot-noaction": "true" }, t("safeBootNoSnapshotActions"))
|
|
6108
|
+
: el("div", { style: { flexDirection: "row", gap: 8, display: "flex", flexWrap: "wrap" } },
|
|
6109
|
+
el("button", {
|
|
6110
|
+
type: "button",
|
|
6111
|
+
className: styles.toggle,
|
|
6112
|
+
"aria-label": "safeBootRestore",
|
|
6113
|
+
"data-armed": safeBootArmed !== null && safeBootArmed.action === "restore-last-good" ? "true" : undefined,
|
|
6114
|
+
disabled: busy || restoreTarget === null,
|
|
6115
|
+
title: t("safeBootRestoreTitle"),
|
|
6116
|
+
onClick: () => safeBootArm({ action: "restore-last-good", id: restoreTarget === null ? null : String(restoreTarget.id) }),
|
|
6117
|
+
}, t("safeBootRestore")),
|
|
6118
|
+
suspects.length === 0
|
|
6119
|
+
? null // 没有点名的行 ⇒ 不显示这个按钮(点了只会得到 no-suspects,属于"点了没反应")
|
|
6120
|
+
: el("button", {
|
|
6121
|
+
type: "button",
|
|
6122
|
+
className: styles.toggle,
|
|
6123
|
+
"aria-label": "safeBootDisable",
|
|
6124
|
+
"data-armed": safeBootArmed !== null && safeBootArmed.action === "disable-suspects" ? "true" : undefined,
|
|
6125
|
+
disabled: busy,
|
|
6126
|
+
title: t("safeBootDisableTitle"),
|
|
6127
|
+
onClick: () => safeBootArm({ action: "disable-suspects", rowIds: suspects.slice() }),
|
|
6128
|
+
}, t("safeBootDisable")),
|
|
6129
|
+
el("button", {
|
|
6130
|
+
type: "button",
|
|
6131
|
+
className: styles.toggle,
|
|
6132
|
+
"aria-label": "safeBootMarkGood",
|
|
6133
|
+
disabled: busy,
|
|
6134
|
+
title: t("safeBootMarkGoodTitle"),
|
|
6135
|
+
onClick: () => { void safeBootSend("mark-good", { id: newest === null ? null : String(newest.id) }); },
|
|
6136
|
+
}, t("safeBootMarkGood"))),
|
|
6137
|
+
snaps.length === 0
|
|
6138
|
+
? null
|
|
6139
|
+
: (suspects.length === 0
|
|
6140
|
+
? el("p", { className: styles.status, "data-safeboot-suspects": "none" }, t("safeBootSuspectsNone"))
|
|
6141
|
+
: el("p", { className: styles.status, "data-safeboot-suspects": "true" }, t("safeBootSuspectsTitle") + " " + suspects.join("、"))),
|
|
6142
|
+
// ⑤ 两段式:上膛后把"要改什么、改哪份文件"写清楚,取消 = 零请求
|
|
6143
|
+
safeBootArmed === null
|
|
6144
|
+
? null
|
|
6145
|
+
: el("div", { "data-safeboot-armed": "true", style: { flexDirection: "column", gap: 6, display: "flex", borderLeft: "2px solid var(--dsw-alias-state-warning-primary)", paddingLeft: 8 } },
|
|
6146
|
+
el("p", { className: styles.message, "data-warn": "true", "data-safeboot-armedtext": "true" },
|
|
6147
|
+
safeBootArmed.action === "restore-last-good"
|
|
6148
|
+
? t("safeBootArmRestore") + " " + String(safeBootArmed.id ?? "—") + " · " + t("safeBootRowAt") + " "
|
|
6149
|
+
+ safeBootTime(armedSnap === null ? null : armedSnap.at) + " · sha256 "
|
|
6150
|
+
+ safeBootSha(armedSnap === null ? null : armedSnap.patchSha256)
|
|
6151
|
+
: t("safeBootArmDisable") + " " + (Array.isArray(safeBootArmed.rowIds) ? safeBootArmed.rowIds.join("、") : "—")),
|
|
6152
|
+
safeBootArmed.action === "restore-last-good"
|
|
6153
|
+
? el("p", { className: styles.status }, t("safeBootArmRestoreOverwrite") + " " + (patchPath ?? "—"))
|
|
6154
|
+
: el("p", { className: styles.status }, t("safeBootArmDisableNote")),
|
|
6155
|
+
el("div", { style: { flexDirection: "row", gap: 8, display: "flex" } },
|
|
6156
|
+
el("button", {
|
|
6157
|
+
type: "button",
|
|
6158
|
+
className: styles.toggle,
|
|
6159
|
+
"aria-label": "safeBootConfirm",
|
|
6160
|
+
disabled: busy,
|
|
6161
|
+
onClick: () => { void safeBootSend(safeBootArmed.action, safeBootArmed); },
|
|
6162
|
+
}, t("safeBootConfirm")),
|
|
6163
|
+
el("button", {
|
|
6164
|
+
type: "button",
|
|
6165
|
+
className: styles.toggle,
|
|
6166
|
+
"aria-label": "safeBootCancel",
|
|
6167
|
+
onClick: () => { setSafeBootArmed(null); setSafeBootNote(null); },
|
|
6168
|
+
}, t("safeBootCancel")))),
|
|
6169
|
+
// ⑥ 结果反馈(成功/无操作/失败原文,都留在面板上)——
|
|
6170
|
+
// 失败走既有 error 样式;成功用既有 success 色(没有新的 CSS 类,只借颜色令牌)。
|
|
6171
|
+
safeBootNote === null
|
|
6172
|
+
? null
|
|
6173
|
+
: el("p", {
|
|
6174
|
+
className: styles.message,
|
|
6175
|
+
"data-safeboot-note": "true",
|
|
6176
|
+
"data-error": safeBootNote.level === "error" ? "true" : undefined,
|
|
6177
|
+
style: safeBootNote.level === "error" ? undefined : { color: "var(--dsw-alias-state-success-primary)" },
|
|
6178
|
+
}, String(safeBootNote.text ?? "—")),
|
|
6179
|
+
// ⑦ 服务起不来时的命令行兜底(服务端下发的原始命令,不自己拼)
|
|
6180
|
+
commands !== null && typeof commands.list === "string"
|
|
6181
|
+
? el("p", { className: styles.status, "data-safeboot-cmd": "true" }, t("safeBootCmdHint") + " " + commands.list)
|
|
6182
|
+
: null))
|
|
6183
|
+
: null,
|
|
6184
|
+
);
|
|
6185
|
+
};
|
|
5698
6186
|
// 「功能包 → 框架」按钮角标:升级进行中 ⟳ / 上次失败 ✕ / 有可用更新 ↑(优先级从高到低)
|
|
5699
6187
|
const fwRunning = frameworkStatus !== null
|
|
5700
6188
|
&& frameworkStatus.status !== "idle" && frameworkStatus.status !== "done" && frameworkStatus.status !== "failed";
|
|
@@ -6318,20 +6806,40 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
6318
6806
|
onClick: () => setCompatGate({ autoDetect: !gateAutoDetect }),
|
|
6319
6807
|
}, el("div", { className: styles.pcSwitchKnob }))),
|
|
6320
6808
|
el("p", { className: styles.message }, t("compatGateHint")),
|
|
6321
|
-
el("p", { className: styles.status, "data-pending": gatePendingCount > 0 ? "true" : undefined },
|
|
6809
|
+
el("p", { className: styles.status, "data-pending": gatePendingCount > 0 ? "true" : undefined, "data-gate-pending": String(gatePendingCount), "data-gate-stale": String(gateStaleCount) },
|
|
6322
6810
|
gatePendingCount > 0 ? t("gatePendingInfo").replace("{n}", String(gatePendingCount)) : t("gatePendingNone")),
|
|
6323
6811
|
// 明细清单(2026-09-24 用户要求:面板要能看到是哪些插件,有列表数据)
|
|
6324
6812
|
gateRows !== null && gateRows.pendingCount + gateRows.adoptedCount > 0
|
|
6325
6813
|
? el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
|
|
6326
|
-
|
|
6814
|
+
// ★ 0.5.98 改错:这里的数字改成"真待适配"的条数(与服务端 actionableCount 同一判据)。
|
|
6815
|
+
// 下面仍列出**全部** pending 记录(含失效的),失效的逐条标注「该行已不存在」——
|
|
6816
|
+
// 藏着不显示才是骗人,标出来才是如实。
|
|
6817
|
+
el("strong", { className: styles.name }, t("gateListPending").replace("{n}", String(gateRows.pending.filter(gateRowActionable).length))),
|
|
6327
6818
|
gateRows.pending.length === 0
|
|
6328
6819
|
? el("p", { className: styles.status }, t("gatePendingNone"))
|
|
6329
6820
|
: null,
|
|
6330
6821
|
(gateShowAll ? gateRows.pending : gateRows.pending.slice(0, 8)).map((r) => el("div", {
|
|
6331
6822
|
key: "p-" + String(r.rowId),
|
|
6332
6823
|
style: { flexDirection: "column", gap: 2, display: "flex", padding: "4px 0" },
|
|
6824
|
+
"data-gate-row": String(r.rowId),
|
|
6825
|
+
"data-row-missing": gateRowMissing(r) ? "true" : "false",
|
|
6333
6826
|
},
|
|
6334
6827
|
el("span", { className: styles.name }, (r.enabled === false ? "⛔ " : "• ") + String(r.name) + (r.version ? " v" + r.version : "")),
|
|
6828
|
+
// ★ 0.5.98 加法:模块名单独一行 —— 记录里没有 moduleName 就如实显示「—」,**绝不编造**。
|
|
6829
|
+
// 行已不在补丁里的,紧跟一个标记;删除入口就在这一行(两段式)。
|
|
6830
|
+
el("span", { className: styles.status, "data-gate-module": "true" },
|
|
6831
|
+
t("gateRowModule") + " " + (typeof r.moduleName === "string" && r.moduleName !== "" ? r.moduleName : "—"),
|
|
6832
|
+
gateRowMissing(r) ? " · " + t("gateRowMissing") : "",
|
|
6833
|
+
el("button", {
|
|
6834
|
+
type: "button",
|
|
6835
|
+
className: styles.toggle,
|
|
6836
|
+
style: { marginLeft: 8, fontSize: 11, padding: "1px 6px" },
|
|
6837
|
+
"aria-label": "gateRowForget",
|
|
6838
|
+
"data-gate-forget-row": String(r.rowId),
|
|
6839
|
+
disabled: gateForgetBusy,
|
|
6840
|
+
title: t("gateForgetRowTitle"),
|
|
6841
|
+
onClick: () => gateForgetArm({ kind: "row", rowId: String(r.rowId), moduleName: typeof r.moduleName === "string" ? r.moduleName : null }),
|
|
6842
|
+
}, t("gateForgetRow"))),
|
|
6335
6843
|
el("span", { className: styles.status }, t("gateRowReason").replace("{reason}", String(r.note ?? "—"))),
|
|
6336
6844
|
el("span", { className: styles.status },
|
|
6337
6845
|
(r.source === "boot-quarantine" ? t("gateRowFromBoot") : t("gateRowFromUpgrade"))
|
|
@@ -6341,6 +6849,21 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
6341
6849
|
? el("button", { type: "button", className: styles.toggle, onClick: () => setGateShowAll((v) => !v) },
|
|
6342
6850
|
gateShowAll ? t("gateListFold") : t("gateListMore").replace("{n}", String(gateRows.pendingCount)))
|
|
6343
6851
|
: null,
|
|
6852
|
+
// ★ 0.5.98 加法:失效记录摘要 + 「清理无效记录」(**只在真有可清理的**时才摆按钮 ——
|
|
6853
|
+
// 零失效时连按钮都不出现,绝不摆一个点了必然没反应的按钮)。
|
|
6854
|
+
gateStaleCount > 0
|
|
6855
|
+
? el("div", { "data-gate-stale-block": "true", style: { flexDirection: "column", gap: 4, display: "flex", paddingTop: 4 } },
|
|
6856
|
+
el("p", { className: styles.status, "data-gate-stale-line": "true" },
|
|
6857
|
+
t("gateStaleLine").replace("{n}", String(gateStaleCount))),
|
|
6858
|
+
el("button", {
|
|
6859
|
+
type: "button",
|
|
6860
|
+
className: styles.toggle,
|
|
6861
|
+
"aria-label": "gateForgetStale",
|
|
6862
|
+
disabled: gateForgetBusy,
|
|
6863
|
+
title: t("gateForgetStaleTitle"),
|
|
6864
|
+
onClick: () => gateForgetArm({ kind: "stale" }),
|
|
6865
|
+
}, t("gateForgetStale")))
|
|
6866
|
+
: null,
|
|
6344
6867
|
gateRows.adoptedCount > 0
|
|
6345
6868
|
? el("div", { style: { flexDirection: "column", gap: 2, display: "flex" } },
|
|
6346
6869
|
// ★ 0.5.80:默认折叠,只留一行摘要(点开才列明细)—— 历史不该把当前态挤下去
|
|
@@ -6352,10 +6875,57 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
6352
6875
|
+ " · " + t("gateListAdopted").replace("{n}", String(gateRows.adoptedCount))),
|
|
6353
6876
|
gateHistoryOpen
|
|
6354
6877
|
? gateRows.adopted.map((r) => el("span", { key: "a-" + String(r.rowId), className: styles.status },
|
|
6355
|
-
"✓ " + String(r.name) + (r.version ? " v" + r.version : "") + (r.adoptedAt ? " · " + gateTime(r.adoptedAt) : "")
|
|
6878
|
+
"✓ " + String(r.name) + (r.version ? " v" + r.version : "") + (r.adoptedAt ? " · " + gateTime(r.adoptedAt) : ""),
|
|
6879
|
+
// ★ 0.5.98:历史记录也能逐条删(同样是两段式)—— 每条记录都有删除入口
|
|
6880
|
+
el("button", {
|
|
6881
|
+
type: "button",
|
|
6882
|
+
className: styles.toggle,
|
|
6883
|
+
style: { marginLeft: 6, fontSize: 11, padding: "1px 6px" },
|
|
6884
|
+
"aria-label": "gateRowForget",
|
|
6885
|
+
"data-gate-forget-row": String(r.rowId),
|
|
6886
|
+
disabled: gateForgetBusy,
|
|
6887
|
+
title: t("gateForgetRowTitle"),
|
|
6888
|
+
onClick: () => gateForgetArm({ kind: "row", rowId: String(r.rowId), moduleName: typeof r.moduleName === "string" ? r.moduleName : null }),
|
|
6889
|
+
}, t("gateForgetRow"))))
|
|
6356
6890
|
: null)
|
|
6357
6891
|
: null)
|
|
6358
|
-
: null
|
|
6892
|
+
: null,
|
|
6893
|
+
// ★ 0.5.98 加法:删除的**两段式**确认区 + 结果反馈(都留在面板上,不弹系统对话框)。
|
|
6894
|
+
// 上膛时把"要删什么、不会动什么"写清楚:只删记录,不动插件、不动补丁文件。
|
|
6895
|
+
gateForgetArmed === null
|
|
6896
|
+
? null
|
|
6897
|
+
: el("div", { "data-gate-forget-armed": "true", style: { flexDirection: "column", gap: 6, display: "flex", borderLeft: "2px solid var(--dsw-alias-state-warning-primary)", paddingLeft: 8 } },
|
|
6898
|
+
el("p", { className: styles.message, "data-warn": "true", "data-gate-forget-text": "true" },
|
|
6899
|
+
gateForgetArmed.kind === "stale"
|
|
6900
|
+
? t("gateForgetArmStale").replace("{n}", String(gateStaleCount))
|
|
6901
|
+
: t("gateForgetArmRow").replace("{id}", String(gateForgetArmed.rowId)).replace("{name}", String(gateForgetArmed.moduleName ?? "—"))),
|
|
6902
|
+
el("p", { className: styles.status }, t("gateForgetSafety")),
|
|
6903
|
+
el("div", { style: { flexDirection: "row", gap: 8, display: "flex" } },
|
|
6904
|
+
el("button", {
|
|
6905
|
+
type: "button",
|
|
6906
|
+
className: styles.toggle,
|
|
6907
|
+
"aria-label": "gateForgetConfirm",
|
|
6908
|
+
disabled: gateForgetBusy,
|
|
6909
|
+
onClick: () => { gateForgetSend(gateForgetArmed); },
|
|
6910
|
+
}, t("gateForgetConfirm")),
|
|
6911
|
+
el("button", {
|
|
6912
|
+
type: "button",
|
|
6913
|
+
className: styles.toggle,
|
|
6914
|
+
"aria-label": "gateForgetCancel",
|
|
6915
|
+
onClick: () => { setGateForgetArmed(null); setGateForgetNote(null); },
|
|
6916
|
+
}, t("gateForgetCancel")))),
|
|
6917
|
+
gateForgetNote === null
|
|
6918
|
+
? null
|
|
6919
|
+
: el("p", {
|
|
6920
|
+
className: styles.message,
|
|
6921
|
+
"data-gate-forget-note": "true",
|
|
6922
|
+
"data-error": gateForgetNote.level === "error" ? "true" : undefined,
|
|
6923
|
+
style: gateForgetNote.level === "error" ? undefined : { color: "var(--dsw-alias-state-success-primary)" },
|
|
6924
|
+
}, String(gateForgetNote.text ?? "—"))),
|
|
6925
|
+
// ★ 0.5.x 加法(用户 2026-10-18):「安全启动」分区 —— 挂在门控面板里(不新增工具栏按钮)。
|
|
6926
|
+
// 默认收起,展开那一下才读一次快照列表;四个动作见 safeBootPanel(list / restore-last-good /
|
|
6927
|
+
// disable-suspects / mark-good)。它排在门控正文**之后**,Star 行仍在最后(贴底那条不变)。
|
|
6928
|
+
safeBootPanel(),
|
|
6359
6929
|
// 0.5.42 加法(用户点名):右侧贴底的 Star 提示 —— 只加这一行 UI,
|
|
6360
6930
|
// 不动任何开关逻辑/路由/门控语义。它是卡片的最后一个子项(整行,不占左侧内容宽度),
|
|
6361
6931
|
// margin-top:auto 让它在卡片有富余高度时贴底;position:sticky;bottom:0 让正文
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// L1 · domain —— compat-pending-records.js(「真待适配」判据的**唯一真源** + 记录删除的写盘纪律)
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
|
+
//
|
|
4
|
+
// ── 真机背景(2026-10-28,用户实测)────────────────────────────────────────────────
|
|
5
|
+
// `~/.dsh/plugin-console/compat-pending.json` 自 2026-10-04 之后就没再被写过,里面 24 条记录:
|
|
6
|
+
// · 20 条 `status:"adopted"`(已适配解锁 —— 历史,不是待办);
|
|
7
|
+
// · 3 条 `status:"pending"` 且 `moduleName: null`(web-ui-describe-image / web-ui-doctor /
|
|
8
|
+
// web-ui-better-sidebar)—— **对应的补丁行早已不存在**,永远不可能被适配;
|
|
9
|
+
// · 全部 `check:"unknown"`。
|
|
10
|
+
// 结果门控面板照旧把它们算成"待适配的行",用户看到"有 N 行待适配",其实**一行都没有**。
|
|
11
|
+
//
|
|
12
|
+
// 两处根因,本模块各治一处:
|
|
13
|
+
// ① **计数口径错**:数的是 `pending` 数组长度,而不是"status=pending 且该行仍在补丁里"。
|
|
14
|
+
// → `isActionablePending()` / `countPendingRecords()` 是唯一判据,路由与面板都只认它。
|
|
15
|
+
// ② **记录从不清理**:adopted 与"行已消失"的记录永久留在文件里。
|
|
16
|
+
// → `planForgetRecord()` / `planForgetStale()` + `applyPendingForget()` 提供删除(只删记录)。
|
|
17
|
+
//
|
|
18
|
+
// ── 三条硬边界(不许放宽)──────────────────────────────────────────────────────────
|
|
19
|
+
// · 「行是否存在」只取**补丁解析的既有产物**(`readPatchState()` 的 disables / forced / inserts,
|
|
20
|
+
// 见 domain/patch.js)—— 这里不另写一套正则,避免两套判据分叉(本仓已为分叉付过代价)。
|
|
21
|
+
// · 删除**只动 compat-pending.json**:不动插件、不动补丁、不动 bundles 清单。
|
|
22
|
+
// 「清理无效记录」只清 stale(status=pending 且行已不存在);真 pending 与 adopted **保留**。
|
|
23
|
+
// · 写盘:备份 → 原子写(临时文件 + rename)→ **读回核实** → 失败回滚;坏文件**拒绝覆盖**
|
|
24
|
+
// (那是用户的文件,覆盖等于丢数据),读侧则降级为空结构并如实报原因。
|
|
25
|
+
import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
26
|
+
import { compatPendingFile } from './compat-verdict.js'
|
|
27
|
+
|
|
28
|
+
/** 适配门记录里"待办"这一侧的状态字(其它一切状态都算历史)。 */
|
|
29
|
+
const PENDING_STATUS = 'pending'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 当前补丁里**真实存在**的行 id 集合(唯一真源:`readPatchState()` 的解析结果)。
|
|
33
|
+
* 三个来源都是"这一行在补丁里有位置"的等价证据:
|
|
34
|
+
* inserts —— 本控制台插入的启用行;disables / forced —— 针对该行的禁用/强制覆盖块。
|
|
35
|
+
* @param {{disables?: string[], forced?: string[], inserts?: string[]}|null} patchState
|
|
36
|
+
* @returns {Set<string>}
|
|
37
|
+
*/
|
|
38
|
+
function liveRowIdsFromPatch(patchState) {
|
|
39
|
+
const out = new Set()
|
|
40
|
+
if (patchState === null || typeof patchState !== 'object') return out
|
|
41
|
+
for (const key of ['disables', 'forced', 'inserts']) {
|
|
42
|
+
const list = Array.isArray(patchState[key]) ? patchState[key] : []
|
|
43
|
+
for (const id of list) if (typeof id === 'string' && id !== '') out.add(id)
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const statusOf = (record) => (typeof record?.status === 'string' && record.status !== '' ? record.status : PENDING_STATUS)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 「真待适配」判据(**纯函数、唯一真源**):status 仍是 pending,**且**该行此刻还在补丁里。
|
|
52
|
+
* 行已不存在 ⇒ 这条记录永远等不到"适配"(它的行都不在了),所以它不是待办。
|
|
53
|
+
*/
|
|
54
|
+
function isActionablePending(record, liveRowIds) {
|
|
55
|
+
if (record === null || typeof record !== 'object') return false
|
|
56
|
+
if (statusOf(record) !== PENDING_STATUS) return false
|
|
57
|
+
if (typeof record.rowId !== 'string' || record.rowId === '') return false
|
|
58
|
+
return liveRowIds instanceof Set && liveRowIds.has(record.rowId)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 「失效记录」判据:记录写着待适配,但它的补丁行已经不在了(用户眼前那条"删不掉的待适配")。
|
|
63
|
+
* 注意:adopted 记录**不算** stale —— 那是历史,用户明确要求保留、只在历史区展示。
|
|
64
|
+
*/
|
|
65
|
+
function isStalePending(record, liveRowIds) {
|
|
66
|
+
if (record === null || typeof record !== 'object') return false
|
|
67
|
+
if (statusOf(record) !== PENDING_STATUS) return false
|
|
68
|
+
if (typeof record.rowId !== 'string' || record.rowId === '') return false
|
|
69
|
+
return !(liveRowIds instanceof Set) || !liveRowIds.has(record.rowId)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 计数(面板/接口只认这三个数):真待适配 / 已失效 / 历史已适配。
|
|
74
|
+
* @returns {{ totalCount: number, actionableCount: number, staleCount: number, adoptedCount: number }}
|
|
75
|
+
*/
|
|
76
|
+
function countPendingRecords(records, liveRowIds) {
|
|
77
|
+
const list = Array.isArray(records) ? records : []
|
|
78
|
+
let actionableCount = 0
|
|
79
|
+
let staleCount = 0
|
|
80
|
+
let adoptedCount = 0
|
|
81
|
+
for (const rec of list) {
|
|
82
|
+
if (rec === null || typeof rec !== 'object') continue
|
|
83
|
+
if (statusOf(rec) !== PENDING_STATUS) { adoptedCount += 1; continue }
|
|
84
|
+
if (isActionablePending(rec, liveRowIds)) actionableCount += 1
|
|
85
|
+
else staleCount += 1
|
|
86
|
+
}
|
|
87
|
+
return { totalCount: list.length, actionableCount, staleCount, adoptedCount }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 给门控明细行补两个**只增不改**的字段(面板据此如实标注"该行已不存在"并给删除入口):
|
|
92
|
+
* rowMissing —— 这一行的 rowId 已不在当前补丁里(null/空 rowId 也算 missing:无所指)
|
|
93
|
+
* actionable —— 与 `isActionablePending` 同一个判据(pending 且行还在)
|
|
94
|
+
*/
|
|
95
|
+
function annotateGateRows(rows, liveRowIds) {
|
|
96
|
+
const list = Array.isArray(rows) ? rows : []
|
|
97
|
+
return list.map((row) => {
|
|
98
|
+
const hasRow = typeof row?.rowId === 'string' && row.rowId !== '' && liveRowIds instanceof Set && liveRowIds.has(row.rowId)
|
|
99
|
+
return { ...row, rowMissing: !hasRow, actionable: isActionablePending(row, liveRowIds) }
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 读记录文件(**只读**)。坏文件/空文件按"没有内容"处理,但**把原因如实带出来**:
|
|
105
|
+
* 调用方(写动作)据此拒绝覆盖,界面据此如实显示"读不出来,所以没有可清理的记录"。
|
|
106
|
+
* @returns {{ file: string, exists: boolean, corrupt: boolean, raw: object|null, records: object[], error: string|null }}
|
|
107
|
+
*/
|
|
108
|
+
function readPendingRecords(file = compatPendingFile()) {
|
|
109
|
+
const empty = { file, exists: false, corrupt: false, raw: null, records: [], error: null }
|
|
110
|
+
if (typeof file !== 'string' || file === '') return { ...empty, corrupt: true, error: '拿不到记录文件路径' }
|
|
111
|
+
if (!existsSync(file)) return empty
|
|
112
|
+
let text = null
|
|
113
|
+
try {
|
|
114
|
+
text = readFileSync(file, 'utf8')
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return { ...empty, exists: true, corrupt: true, error: `读取失败:${String(error?.message ?? error)}` }
|
|
117
|
+
}
|
|
118
|
+
// 空文件(含只有空白 / BOM 的)按**没有内容**处理:它不是坏文件 ⇒ 读侧降级为空结构,
|
|
119
|
+
// 写侧照旧"没有可删的记录"(绝不为了删一条记录就把空文件改写成别的形状)。
|
|
120
|
+
if (text.replace(/^\uFEFF/u, '').trim() === '') return { ...empty, exists: true, empty: true, raw: null, records: [] }
|
|
121
|
+
let raw = null
|
|
122
|
+
try {
|
|
123
|
+
raw = JSON.parse(text)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return { ...empty, exists: true, corrupt: true, error: `不是合法 JSON:${String(error?.message ?? error)}` }
|
|
126
|
+
}
|
|
127
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
128
|
+
return { ...empty, exists: true, corrupt: true, error: '内容不是 JSON 对象' }
|
|
129
|
+
}
|
|
130
|
+
const records = (Array.isArray(raw.pending) ? raw.pending : []).filter((r) => r !== null && typeof r === 'object')
|
|
131
|
+
return { file, exists: true, corrupt: false, raw, records, error: null }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 写动作的公共前置校验:坏文件**拒绝覆盖**(保住用户数据),并给出可读原因。 */
|
|
135
|
+
function refuseIfUnsafe(read) {
|
|
136
|
+
if (read.exists !== true) return { ok: false, status: 404, error: `${read.file} 不存在(没有可删的记录)` }
|
|
137
|
+
if (read.corrupt === true) {
|
|
138
|
+
return { ok: false, status: 409, error: `${read.file} 读不出来(${read.error ?? '格式非法'});为保住你的数据,本动作已停止(请先手工修好或删掉它)` }
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 规划「删掉某一条记录」(**纯函数,不写盘**)。
|
|
145
|
+
* 不存在的 rowId ⇒ 404 + 明确原因(绝不静默成功)。
|
|
146
|
+
*/
|
|
147
|
+
function planForgetRecord({ read, rowId } = {}) {
|
|
148
|
+
const base = { ok: false, status: 400, error: null, file: read?.file ?? null, next: null, changed: false, removedRowIds: [], keptRowIds: [] }
|
|
149
|
+
if (read === null || typeof read !== 'object') return { ...base, error: '内部错误:没有读到记录文件' }
|
|
150
|
+
const refused = refuseIfUnsafe(read)
|
|
151
|
+
if (refused !== null) return { ...base, ...refused }
|
|
152
|
+
if (typeof rowId !== 'string' || rowId === '') return { ...base, error: '缺少 rowId(要删哪一条?)' }
|
|
153
|
+
const index = read.records.findIndex((r) => r.rowId === rowId)
|
|
154
|
+
if (index < 0) return { ...base, status: 404, error: `没有 rowId 为 ${rowId} 的记录(文件未改动)` }
|
|
155
|
+
const kept = read.records.filter((_, i) => i !== index)
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
status: 200,
|
|
159
|
+
error: null,
|
|
160
|
+
file: read.file,
|
|
161
|
+
next: { ...read.raw, pending: kept },
|
|
162
|
+
changed: true,
|
|
163
|
+
removedRowIds: [rowId],
|
|
164
|
+
keptRowIds: kept.map((r) => r.rowId).filter((id) => typeof id === 'string'),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 规划「清掉所有失效记录」(**纯函数,不写盘**):只删 status=pending 且行已不存在的;
|
|
170
|
+
* 真 pending 与 adopted 一条都不动。没有可清的 ⇒ `changed:false`(**零写盘**,不是错误)。
|
|
171
|
+
*/
|
|
172
|
+
function planForgetStale({ read, liveRowIds } = {}) {
|
|
173
|
+
const base = { ok: false, status: 400, error: null, file: read?.file ?? null, next: null, changed: false, removedRowIds: [], keptRowIds: [], staleCount: 0 }
|
|
174
|
+
if (read === null || typeof read !== 'object') return { ...base, error: '内部错误:没有读到记录文件' }
|
|
175
|
+
const refused = refuseIfUnsafe(read)
|
|
176
|
+
if (refused !== null) return { ...base, ...refused }
|
|
177
|
+
const stale = read.records.filter((r) => isStalePending(r, liveRowIds))
|
|
178
|
+
if (stale.length === 0) {
|
|
179
|
+
return { ...base, ok: true, status: 200, changed: false, keptRowIds: read.records.map((r) => r.rowId).filter((id) => typeof id === 'string'), staleCount: 0 }
|
|
180
|
+
}
|
|
181
|
+
const staleIds = new Set(stale.map((r) => r.rowId))
|
|
182
|
+
const kept = read.records.filter((r) => !staleIds.has(r.rowId))
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
status: 200,
|
|
186
|
+
error: null,
|
|
187
|
+
file: read.file,
|
|
188
|
+
next: { ...read.raw, pending: kept },
|
|
189
|
+
changed: true,
|
|
190
|
+
removedRowIds: [...staleIds],
|
|
191
|
+
keptRowIds: kept.map((r) => r.rowId).filter((id) => typeof id === 'string'),
|
|
192
|
+
staleCount: stale.length,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 执行写入:备份 → 原子写(临时文件 + rename)→ **读回核实** → 任何一步不过就回滚并如实报错。
|
|
198
|
+
* `deps` 全是注入缝(写失败 / 读回不符的单测靠它,生产路径不传 ⇒ 真实 fs)。
|
|
199
|
+
* @returns 计划的字段 + `{ backup, verified, rolledBack }`
|
|
200
|
+
*/
|
|
201
|
+
function applyPendingForget(plan, deps = {}) {
|
|
202
|
+
if (plan === null || typeof plan !== 'object' || plan.ok !== true || plan.changed !== true) return plan
|
|
203
|
+
const io = {
|
|
204
|
+
copy: deps.copyFileSync ?? copyFileSync,
|
|
205
|
+
write: deps.writeFileSync ?? writeFileSync,
|
|
206
|
+
rename: deps.renameSync ?? renameSync,
|
|
207
|
+
remove: deps.unlinkSync ?? unlinkSync,
|
|
208
|
+
exists: deps.existsSync ?? existsSync,
|
|
209
|
+
read: deps.readFileSync ?? readFileSync,
|
|
210
|
+
}
|
|
211
|
+
const { file, next } = plan
|
|
212
|
+
const stamp = Date.now()
|
|
213
|
+
const backup = `${file}.bak-forget-${stamp}`
|
|
214
|
+
const tmp = `${file}.tmp-forget-${stamp}`
|
|
215
|
+
const rollback = (extra) => {
|
|
216
|
+
try { if (io.exists(backup)) io.copy(backup, file) } catch {}
|
|
217
|
+
try { if (io.exists(tmp)) io.remove(tmp) } catch {}
|
|
218
|
+
return { ...plan, ...extra, backup, verified: false, rolledBack: io.exists(backup) }
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
if (io.exists(file)) io.copy(file, backup)
|
|
222
|
+
io.write(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
223
|
+
io.rename(tmp, file)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return rollback({ ok: false, status: 500, error: `写入 ${file} 失败:${String(error?.message ?? error)}(已回滚,文件保持原样)` })
|
|
226
|
+
}
|
|
227
|
+
// 读回核实:删掉的那条必须真的不在、保留的每一条必须还在 —— "写了但没生效"是最坏的一种假绿
|
|
228
|
+
let back = null
|
|
229
|
+
try {
|
|
230
|
+
back = JSON.parse(io.read(file, 'utf8'))
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return rollback({ ok: false, status: 500, error: `写入后读回失败:${String(error?.message ?? error)}(已回滚)` })
|
|
233
|
+
}
|
|
234
|
+
const rows = (Array.isArray(back?.pending) ? back.pending : []).filter((r) => r !== null && typeof r === 'object')
|
|
235
|
+
const stillThere = plan.removedRowIds.filter((id) => rows.some((r) => r.rowId === id))
|
|
236
|
+
const lost = plan.keptRowIds.filter((id) => !rows.some((r) => r.rowId === id))
|
|
237
|
+
if (stillThere.length > 0 || lost.length > 0) {
|
|
238
|
+
const why = stillThere.length > 0 ? `要删的记录仍在(${stillThere.join('、')})` : `保留的记录丢了(${lost.slice(0, 5).join('、')})`
|
|
239
|
+
return rollback({ ok: false, status: 500, error: `写入后读回内容与期望不一致(${why}),已回滚` })
|
|
240
|
+
}
|
|
241
|
+
return { ...plan, backup, verified: true, rolledBack: false }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export {
|
|
245
|
+
PENDING_STATUS,
|
|
246
|
+
liveRowIdsFromPatch,
|
|
247
|
+
isActionablePending,
|
|
248
|
+
isStalePending,
|
|
249
|
+
countPendingRecords,
|
|
250
|
+
annotateGateRows,
|
|
251
|
+
readPendingRecords,
|
|
252
|
+
planForgetRecord,
|
|
253
|
+
planForgetStale,
|
|
254
|
+
applyPendingForget,
|
|
255
|
+
}
|
|
@@ -63,6 +63,16 @@ function blockScalarIndicator(value) {
|
|
|
63
63
|
function scanValueQuotes(value, initialQuote = null) {
|
|
64
64
|
const text = String(value)
|
|
65
65
|
let quote = initialQuote
|
|
66
|
+
// 函数头注释 ⑤ 的前提「值不以引号开头 ⇒ 不跨行扫描」原先只写在注释里、代码没实现。
|
|
67
|
+
// 代价(issue #26):`- 'D:/x/y.js'` 这类带盘符的序列项里,盘符的冒号被 key:value 正则
|
|
68
|
+
// 当成分隔符吃掉,value 变成 `/x/y.js'`(首字符不是引号),于是行尾那个 `'` 被当成
|
|
69
|
+
// 「引号开口」并一直挂到文件末尾 ⇒ 误报「文件末尾仍有未闭合的引号」,
|
|
70
|
+
// 而 safeBoot 的写盘前闸门正是它 ⇒ 快照/恢复/只禁可疑行全部退化成空转。
|
|
71
|
+
// 值不以引号开头时,本行的引号不可能跨行开口。
|
|
72
|
+
if (quote === null) {
|
|
73
|
+
const first = text.trimStart()[0]
|
|
74
|
+
if (first !== "'" && first !== '"') return { quote: null }
|
|
75
|
+
}
|
|
66
76
|
for (let i = 0; i < text.length; i += 1) {
|
|
67
77
|
const ch = text[i]
|
|
68
78
|
if (quote === null) {
|
|
@@ -26,6 +26,9 @@ import { DEFAULT_TIMEOUT_MS, autoPreflightView, runAutoPreflight } from '../doma
|
|
|
26
26
|
import { runAutoPreflightScan } from '../domain/auto-preflight-run.js'
|
|
27
27
|
import { NOTICE_MODES, detectCompat, readCompatAck, readCompatGate, readCompatPending, rowIdModuleMap, writeCompatAck, writeCompatGate, writeCompatPending } from '../domain/compat.js'
|
|
28
28
|
import { compareFingerprint, computeFingerprint, planDesktopHostSwitch, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
|
|
29
|
+
// 0.5.98 加法:适配门**记录**的删除(真待适配判据 + 写盘纪律都在该模块;本路由只映射状态码)
|
|
30
|
+
import { applyPendingForget, countPendingRecords, liveRowIdsFromPatch, planForgetRecord, planForgetStale, readPendingRecords } from '../domain/compat-pending-records.js'
|
|
31
|
+
import { readPatchState } from '../domain/patch.js'
|
|
29
32
|
import { currentFrameworkVersion, detectHostShape } from '../domain/framework.js'
|
|
30
33
|
import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
|
|
31
34
|
import { isProtectedModule } from '../domain/runtime.js'
|
|
@@ -39,6 +42,17 @@ import { preflightRoots } from './framework-preflight.js'
|
|
|
39
42
|
|
|
40
43
|
const PREFLIGHT_TIMEOUT_ENV = 'DSH_AUTO_PREFLIGHT_TIMEOUT_MS'
|
|
41
44
|
/** 自动预检的预算:到点降级成「未完成,可手动重跑」,状态查询照常返回(env 可调)。 */
|
|
45
|
+
/**
|
|
46
|
+
* 安全启动的命令行兜底:拼成**可直接复制运行**的绝对路径命令。
|
|
47
|
+
* 现场问题(2026-10-29):原先给的是相对路径 `node scripts/safe-boot.mjs --list`,
|
|
48
|
+
* 只有先 cd 到插件安装目录才成立 —— 而那是 node_modules 里的深层哈希目录,
|
|
49
|
+
* 服务起不来、界面打不开的时候,用户根本无从找到它。救火命令必须是能直接粘的。
|
|
50
|
+
*/
|
|
51
|
+
function safeBootCommand(args = []) {
|
|
52
|
+
const script = join(pluginRoot(), 'scripts', 'safe-boot.mjs')
|
|
53
|
+
return ['node', JSON.stringify(script), ...args].join(' ')
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
function preflightTimeoutMs() {
|
|
43
57
|
const raw = Number(process.env[PREFLIGHT_TIMEOUT_ENV])
|
|
44
58
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS
|
|
@@ -218,7 +232,7 @@ function autoDisableNotice(entry = lastAutoDisable, { enabled = null } = {}) {
|
|
|
218
232
|
if (entry === null || entry === undefined) return null
|
|
219
233
|
if (on !== true && entry.code === 'auto-disabled') return null
|
|
220
234
|
const added = Array.isArray(entry.disabled) ? entry.disabled : []
|
|
221
|
-
const restore = '
|
|
235
|
+
const restore = safeBootCommand(['--restore-last-good'])
|
|
222
236
|
if (added.length > 0) {
|
|
223
237
|
return {
|
|
224
238
|
at: entry.at ?? null,
|
|
@@ -318,7 +332,7 @@ async function routeCompatStatusGet(req, res, rc) {
|
|
|
318
332
|
onEvidence: readCompatGate().autoDisableOnEvidence === true,
|
|
319
333
|
switch: { file: 'compat-gate.json', key: 'autoDisableOnEvidence', route: '/plugin-console/compat-ack', body: { autoDisableOnEvidence: false } },
|
|
320
334
|
notice: autoDisableNotice(),
|
|
321
|
-
restore: { route: { path: '/plugin-console/safe-boot', body: { action: 'restore-last-good' } }, command: '
|
|
335
|
+
restore: { route: { path: '/plugin-console/safe-boot', body: { action: 'restore-last-good' } }, command: safeBootCommand(['--restore-last-good']) },
|
|
322
336
|
logHint: '记录落在 dshHome()/plugin-console/auto-disable.log(追加式 JSONL)',
|
|
323
337
|
},
|
|
324
338
|
// D2 加法字段:安全启动材料(last-known-good 快照 + 恢复入口提示)
|
|
@@ -326,7 +340,7 @@ async function routeCompatStatusGet(req, res, rc) {
|
|
|
326
340
|
snapshot: { written: snapshot.written === true, reason: snapshot.reason ?? null, id: snapshot.snapshot?.id ?? null, at: snapshot.snapshot?.at ?? null, sha256: snapshot.snapshot?.patchSha256 ?? null },
|
|
327
341
|
snapshots: listSnapshots().slice(0, 5),
|
|
328
342
|
lastGoodId: readSafeBootState().lastGoodId,
|
|
329
|
-
command: { list: '
|
|
343
|
+
command: { list: safeBootCommand(['--list']), restore: safeBootCommand(['--restore-last-good']), disable: safeBootCommand(['--disable-suspects']) },
|
|
330
344
|
route: { action: 'restore-last-good | disable-suspects | list | mark-good', path: '/plugin-console/safe-boot' },
|
|
331
345
|
},
|
|
332
346
|
})
|
|
@@ -444,4 +458,74 @@ async function routeCompatAck(req, res, rc) {
|
|
|
444
458
|
const ack = writeCompatAck(version)
|
|
445
459
|
sendJson(res, 200, { ok: true, ack, noticeMode: readCompatGate().noticeMode })
|
|
446
460
|
}
|
|
447
|
-
|
|
461
|
+
/**
|
|
462
|
+
* POST /plugin-console/compat-gate `{ action: 'forget' | 'forget-stale' }`
|
|
463
|
+
* —— 适配门**记录**的删除(0.5.98 加法,用户 2026-10-28 明确要求)。
|
|
464
|
+
*
|
|
465
|
+
* 为什么挂在**既有的 /compat-gate** 上而不是新开一条路由:门控面板的所有写入口必须同源
|
|
466
|
+
* (否则又要登记一份路由清单,两处判据迟早分叉)。body 里没有 `action` 时**逐字**走原来的
|
|
467
|
+
* 总开关逻辑(既有契约零变化,装配层的分流见 routes/index.js#routeCompatGateAny)。
|
|
468
|
+
*
|
|
469
|
+
* 语义(三条硬边界,用户定案):
|
|
470
|
+
* · 只删 **记录**:不动插件、不动补丁文件、不动 bundles 清单;
|
|
471
|
+
* · `forget` 删指定 rowId;`forget-stale` 只清「status=pending 且该行已不在补丁里」的失效记录,
|
|
472
|
+
* 真 pending 与 adopted 一条都不动;没有可清的 ⇒ `changed:false` 且**零写盘**;
|
|
473
|
+
* · 删不存在的 rowId ⇒ 404 + 明确原因(绝不静默成功);坏文件 ⇒ 409 拒绝覆盖(保住用户数据)。
|
|
474
|
+
*
|
|
475
|
+
* 写盘纪律全在 domain/compat-pending-records.js:备份 → 原子写(临时文件 + rename)→ 读回核实 → 失败回滚。
|
|
476
|
+
* 「行是否存在」只取补丁解析的既有产物(domain/patch.js#readPatchState),不另写一套正则。
|
|
477
|
+
*/
|
|
478
|
+
async function routeCompatPendingForget(req, res, rc) {
|
|
479
|
+
const body = rc.body ?? {}
|
|
480
|
+
const action = typeof body.action === 'string' ? body.action.trim() : ''
|
|
481
|
+
if (action !== 'forget' && action !== 'forget-stale') {
|
|
482
|
+
sendError(res, 400, `未知动作 ${JSON.stringify(body.action)}(可选 forget / forget-stale)`, { ok: false, error: '未知动作', code: 'unknown-action' })
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
const read = readPendingRecords()
|
|
486
|
+
// 判据唯一真源:补丁此刻真实存在的行(与 /state 里 patch.disables/forced/inserts 同源)
|
|
487
|
+
let liveRowIds = new Set()
|
|
488
|
+
try {
|
|
489
|
+
const patchPath = findPatchPath(rc.ctx)
|
|
490
|
+
liveRowIds = liveRowIdsFromPatch(await readPatchState(patchPath))
|
|
491
|
+
} catch {
|
|
492
|
+
// 读不到补丁 ⇒ 只能认定"没有任何行存在";此时 forget-stale 会把 pending 全判成 stale,
|
|
493
|
+
// 那等于凭一个读失败删用户记录 —— 所以这条通道**必须停下**(宁可红,也不报假的绿)。
|
|
494
|
+
sendError(res, 503, '读不到 profile 补丁文件,无法判断哪些记录还指向真实的行;本次删除已停止(文件未改动)', { ok: false, code: 'patch-unreadable' })
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
const plan = action === 'forget'
|
|
498
|
+
? planForgetRecord({ read, rowId: body.rowId })
|
|
499
|
+
: planForgetStale({ read, liveRowIds })
|
|
500
|
+
const result = plan.ok === true && plan.changed === true ? applyPendingForget(plan) : plan
|
|
501
|
+
const removed = Array.isArray(result.removedRowIds) ? result.removedRowIds : []
|
|
502
|
+
const removedCount = removed.length
|
|
503
|
+
const note = result.ok !== true
|
|
504
|
+
? null
|
|
505
|
+
: (action === 'forget'
|
|
506
|
+
? `已删除记录 ${String(body.rowId)}(其余记录原样保留;插件与补丁一个字都没动)`
|
|
507
|
+
: (removedCount > 0
|
|
508
|
+
? `已清理 ${removedCount} 条失效记录(真实待适配与历史记录一条没动;插件与补丁一个字都没动)`
|
|
509
|
+
: '没有可清理的失效记录(文件未改动)'))
|
|
510
|
+
const payload = {
|
|
511
|
+
ok: result.ok === true,
|
|
512
|
+
action,
|
|
513
|
+
changed: result.changed === true,
|
|
514
|
+
removed,
|
|
515
|
+
removedCount,
|
|
516
|
+
keptCount: Array.isArray(result.keptRowIds) ? result.keptRowIds.length : 0,
|
|
517
|
+
// 删完之后按**同一判据**重算一遍(面板据此重绘;不复用旧计数,避免"删了但数字没动")
|
|
518
|
+
counts: (() => {
|
|
519
|
+
try { return countPendingRecords(readPendingRecords().records, liveRowIds) } catch { return null }
|
|
520
|
+
})(),
|
|
521
|
+
file: read.file,
|
|
522
|
+
backup: result.backup ?? null,
|
|
523
|
+
verified: result.verified === true,
|
|
524
|
+
error: result.error ?? null,
|
|
525
|
+
note,
|
|
526
|
+
}
|
|
527
|
+
if (result.ok === true) sendJson(res, 200, payload)
|
|
528
|
+
else sendError(res, result.status ?? 400, result.error ?? '删除记录失败', payload)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export { hostShapeOf, maybeSwitchToObserver, maybeWriteSnapshot, preflightTimeoutMs, resetHostSwitchChecked, routeCompatAck, routeCompatMode, routeCompatPendingForget, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled, scheduleAutoPreflight }
|
|
@@ -16,7 +16,7 @@ import { readBody, sendError } from '../infra/httpd.js'
|
|
|
16
16
|
import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowerPlan, routeAiEmpowerRun, routeAiEmpowerStatus } from './ai.js'
|
|
17
17
|
import { routeRunSuggested } from './actions.js'
|
|
18
18
|
import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
|
|
19
|
-
import { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
|
|
19
|
+
import { routeCompatAck, routeCompatMode, routeCompatPendingForget, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
|
|
20
20
|
import { routeSafeBoot } from './safe-boot.js'
|
|
21
21
|
// issue #22 ②③ 加法:用户备注 / 用户徽章 / 用户自建分组(独立文件,与插件列表完全解耦)
|
|
22
22
|
import { routeAnnotationsGet, routeAnnotationsPost } from './annotations.js'
|
|
@@ -81,6 +81,23 @@ function routeDeps() {
|
|
|
81
81
|
return { runInstallJob, runSuiteInstallJob, webPort, readExtraBundleRows, detectAdoptablePending, listEntries, detectCompat, detectFrameworkUpgrade, DEFAULT_SEARCH, aiEmpowerExecute, aiEmpowerPlan, backupProfileSnapshot, currentFrameworkVersion, frameworkCompatReportFor, isProtectedModule, pnpmRemove, preflightDisableIncompatible, rowIdModuleMap, fetchJson: fetchJsonUrl }
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* `/compat-gate` 的**只增不改**分流(0.5.98 加法)。
|
|
86
|
+
*
|
|
87
|
+
* 为什么分流装在这一层:门控面板的写入口必须挂在**既有的 /compat-gate** 上(不新开路由,
|
|
88
|
+
* 否则又要登记一份清单);而那条路由的 handler(routes/framework.js#routeCompatGate)所在文件
|
|
89
|
+
* 已顶格 600/600 行(tests/test-architecture-guard.mjs 的硬门槛),**一个字都不能加**。
|
|
90
|
+
* 于是"分流"留在装配层,"动作实现"落在 routes/compat.js(适配门记录那一族)。
|
|
91
|
+
*
|
|
92
|
+
* 契约:body 里没有 `action`(或不是非空字符串)⇒ **逐字**走原来的总开关逻辑,
|
|
93
|
+
* 既有请求/响应一字不变;带 `action` 才进删除记录那一族(forget / forget-stale)。
|
|
94
|
+
*/
|
|
95
|
+
async function routeCompatGateAny(req, res, rc) {
|
|
96
|
+
const action = typeof rc.body?.action === 'string' ? rc.body.action : ''
|
|
97
|
+
if (action !== '') return routeCompatPendingForget(req, res, rc)
|
|
98
|
+
return routeCompatGate(req, res, rc)
|
|
99
|
+
}
|
|
100
|
+
|
|
84
101
|
async function handle(ctx, req, res) {
|
|
85
102
|
const url = new URL(req.url ?? '/', 'http://x')
|
|
86
103
|
const pathname = url.pathname
|
|
@@ -126,7 +143,7 @@ const ROUTES = [
|
|
|
126
143
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/skill-toggle`, handler: routeSkillToggle },
|
|
127
144
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/install`, handler: routeInstall },
|
|
128
145
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/install-status`, handler: routeInstallStatus }, { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-check`, handler: routeFrameworkCheck },
|
|
129
|
-
{ methods: ['POST'], path: `${ROUTE_PREFIX}/compat-gate`, handler:
|
|
146
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/compat-gate`, handler: routeCompatGateAny },
|
|
130
147
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/check-update`, handler: routeCheckUpdate },
|
|
131
148
|
// issue #22 ① 加法:批量检测(已安装列表的「一键检测全部更新」)。
|
|
132
149
|
// ★ 与 /check-update 共用同一个探针(probePackageUpdate),判据不分叉;并发上限在 domain/update-batch.js。
|
|
@@ -12,6 +12,8 @@ import { bundleVetoList } from '../domain/bundle-veto.js'
|
|
|
12
12
|
import { buildLinkInventory } from '../domain/link-inventory.js'
|
|
13
13
|
import { buildInstallHealth } from '../domain/install-health.js'
|
|
14
14
|
import { applyExemption } from '../domain/compat-exempt.js'
|
|
15
|
+
// 0.5.98 加法:适配门记录的「真待适配」计数(唯一真源,见 domain/compat-pending-records.js)
|
|
16
|
+
import { annotateGateRows, countPendingRecords, liveRowIdsFromPatch } from '../domain/compat-pending-records.js'
|
|
15
17
|
import { addDevRoot, readDevRoots, removeDevRoot } from '../domain/dev-roots.js'
|
|
16
18
|
import { applyPinDevDir, planPinDevDir } from '../domain/pin-dev-dir.js'
|
|
17
19
|
import { reconcileLockfile } from '../domain/selfupdate.js'
|
|
@@ -68,6 +70,13 @@ async function routeStateGet(req, res, rc) {
|
|
|
68
70
|
const compatPending = pendingState.pending
|
|
69
71
|
const compatVerdicts = pendingState.verdicts
|
|
70
72
|
const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
|
|
73
|
+
// ★ 0.5.98 加法(用户 2026-10-28 实测):**「真待适配」的唯一口径**。
|
|
74
|
+
// 现场:compat-pending.json 里 20 条 adopted + 3 条 pending 且 moduleName 为 null(补丁行早已不存在),
|
|
75
|
+
// 面板把 `pending` 数组长度当"待适配",用户看到"有 N 行待适配",其实一行都没有。
|
|
76
|
+
// 判据只认 domain/compat-pending-records.js#isActionablePending(status=pending 且该行仍在补丁里);
|
|
77
|
+
// 「行是否存在」取的是**补丁解析的既有产物**(上面 patch 的 disables/forced/inserts),不另写正则。
|
|
78
|
+
const patchRowIds = liveRowIdsFromPatch(patch)
|
|
79
|
+
const pendingCounts = countPendingRecords(compatPending?.pending ?? [], patchRowIds)
|
|
71
80
|
// 兼容门总开关 + 自动检测(只提示不自动开)
|
|
72
81
|
const compatGate = readCompatGate()
|
|
73
82
|
const adoptable = compatGate.autoDetect ? detectAdoptablePending(ctx) : new Map()
|
|
@@ -264,7 +273,14 @@ async function routeStateGet(req, res, rc) {
|
|
|
264
273
|
} catch (error) {
|
|
265
274
|
lockAutofix = { error: error instanceof Error ? error.message : String(error), scheduled: false }
|
|
266
275
|
}
|
|
267
|
-
|
|
276
|
+
// ★ 0.5.98 加法:门控明细行补两个**只增不改**的字段 —— `rowMissing`(这一行的 rowId 已不在
|
|
277
|
+
// 当前补丁里)与 `actionable`(与计数同一个判据)。面板据此如实标注「该行已不存在」并给删除入口;
|
|
278
|
+
// 既有字段(pendingCount / rows 的全部旧字段)一个不少、语义不变。
|
|
279
|
+
const gatingOut = (() => {
|
|
280
|
+
const g = gatingSummary(ctx, compatPending, entries, compatVerdicts)
|
|
281
|
+
return { ...g, pending: annotateGateRows(g.pending, patchRowIds), adopted: annotateGateRows(g.adopted, patchRowIds) }
|
|
282
|
+
})()
|
|
283
|
+
sendJson(res, 200, { ok: true, entries: allEntries, skippedBundles, host, health, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, lockAutofix, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingOut, compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null, verdict: compatVerdicts.get(p.rowId) ?? null })), verdictRefresh: pendingState.refresh === null ? null : { version: pendingState.refresh.version ?? null, updated: pendingState.refresh.updated ?? [], unchanged: pendingState.refresh.unchanged ?? [], unresolved: pendingState.refresh.unresolved ?? [], wrote: pendingState.refresh.wrote === true, writeError: pendingState.refresh.writeError ?? null }, actionableCount: pendingCounts.actionableCount, staleCount: pendingCounts.staleCount }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
|
|
268
284
|
return
|
|
269
285
|
}
|
|
270
286
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.98",
|
|
4
4
|
"description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|