dsh-plugin-update 0.5.3 → 0.5.5

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/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  格式为 Keep-a-Changelog 子集:`Added/Fixed/Changed` 必写,`Deprecated/Removed/Security` 有则透传,`Unreleased` 面板忽略,禁止 git-log 直倒。面板缺日志时中性提示,不挡安装。
4
4
 
5
+ ## [0.5.5] - 2026-10-06
6
+
7
+ ### Added
8
+ - 档案卷主题收敛 archive 与面板紧凑化:运行时 data-theme 统一为 archive(d5-paper 仅作输入旧别名归一),entry/entry-batch/panel-batch 输出与 CSS 选择器同步,panel-batch setTheme 补 archive 并归一存储;根/卷头/横幅/版本条/章节/章头/队列行/footer 间距收紧;删 changelog-wrap 与章节线重影内边框;页脚去技术词,批量关闭 title 同步。
9
+ - 双语底座 #54(先行):bilingual 字典模块、entry key 映射链、noteVersion 运行时值;research/52-inventory 双语盘点与原型矩阵随附。
10
+
11
+ ### Fixed
12
+ - 安装/查失败常驻与复制诊断锁定(#58):失败横幅保留到下一次用户主动查/装的新结论、任务态或版本变化,只读轮询不再洗回可装页;「复制诊断」锁定致命那次回包(稳定码、摘要、版本、宿主、请求与检查编号冻结在失败时刻);失败旁新增「知道了」可显式确认回到可装页;健康无失败时复制改为当前状态快照,不再伪造失败码;04 章失败时变为卷宗(真实稳定码、本次查询键与失败时刻、证据冻结声明、凭编号对日志的现成查询,平时为路牌+中性行,可用 `showLogHint: false` 藏路牌);日志事件名收归 `log-events.ts` 单源(宿主发射与面板路牌同源);对抗审查补强:瞬态读失败不跨重挂、查锁存不再错配旧安装编号、瞬态失败换诚实文案、跨挂载表有界 100 家;`update-busy` 仍为瞬态不锁存;四电话形状与快照六字段零变更。
13
+
14
+ ## [0.5.4] - 2026-10-06
15
+
16
+ ### Added
17
+ - 批量感知入口件(#49):新子路径 `dsh-plugin-update/entry-batch`,一颗按钮看 N 家聚合(button/badge/inline;openOn 缺省 always,autoCheck 缺省 mount;只调 batchStatus/batchCheck,绝不自动装;聚合与批量总账同一份数法,忙失败占位翻回可更新)。
18
+ - 批量 dialog 关闭落地与单面板同口径(#50):`BatchPanelOptions` 新增可选 `onCloseRequested`,点关闭/Esc 先交调用方撤 DOM 再停轮询;入口件打开的 dialog 已内置;不传回退只停轮询;HTTP 直挂透传。
19
+
5
20
  ## [0.5.3] - 2026-10-06
6
21
 
7
22
  ### Added
package/README.md CHANGED
@@ -131,7 +131,7 @@ const panel = mountUpdatePanel(document.getElementById('update-slot'), {
131
131
  // 弹窗版想让「关闭」按钮真把弹窗撤掉:传 onCloseRequested(面板点关闭/Esc 时先调它撤 DOM,再停轮询;入口件打开的 dialog 已内置)。
132
132
  ```
133
133
 
134
- 组件内部消化的事(调用者不再写):按 `panelPollMs` 轮询查状态(下限 250 毫秒);安装按钮状态跟随快照的 `canInstall`,不另写门控规则;装不了的原因按第 5.2 节展示中文一句话;`pending-restart` 单独横幅加重启指引,不再给安装按钮;手工命令展示与复制;排队位置展示与 `showOthers` 开关;跳过按版本记(“已跳过 X.Y.Z · 恢复”在同一行,不藏进设置页);失败时旁边的“复制诊断”一键给出脱敏后的自包含文本(稳定码、版本、宿主、队列位置),深挖仍看日志(组件里留着第 6 节第 11 条的过滤口径)。
134
+ 组件内部消化的事(调用者不再写):按 `panelPollMs` 轮询查状态(下限 250 毫秒);安装按钮状态跟随快照的 `canInstall`,不另写门控规则;装不了的原因按第 5.2 节展示中文一句话;`pending-restart` 单独横幅加重启指引,不再给安装按钮;手工命令展示与复制;排队位置展示与 `showOthers` 开关;跳过按版本记(“已跳过 X.Y.Z · 恢复”在同一行,不藏进设置页);失败时旁边的“复制诊断”一键给出脱敏后的自包含文本(稳定码、版本、宿主、队列位置),深挖仍看日志(组件里留着第 6 节第 11 条的过滤口径)。失败横幅常驻:查/装失败后保留到下一次用户主动查/装的新结论、任务态或版本变化,只读轮询不再洗掉;复制诊断锁定致命那次回包的证据(码、摘要、版本、宿主、编号冻结在失败时刻);失败旁有「知道了」可显式确认回到可装页(只清面板提示,不调电话)。无失败时复制诊断给当前状态快照(版本、宿主、范围、队列),不伪造失败码。04 章平时是路牌(中性行+日志过滤口径,可用 `showLogHint: false` 隐藏);失败时变为卷宗:真实稳定码、本次查询键(请求/检查编号+失败时刻)、证据冻结声明、凭编号对日志的现成查询。日志事件名(`host.call`/`host.call.fail`/`update.install.exec`)由宿主与面板共用单源,改名即全改。
135
135
 
136
136
  待重启横幅上有个「重启宿主」入口。宿主没有“重启自己”的电话,所以默认点击只如实提示「请手动重启宿主」;想把重启流程接进来就传 `onRestartRequested`(面板调它,成败都回执):
137
137
 
@@ -195,6 +195,27 @@ mountUpdateEntry(el, { pluginId: 'p', prefix: 'notes', call, variant: 'badge',
195
195
  onActivate: ({ hasUpdate, latestVersion }) => { /* 自己跳自己的页面 */ } })
196
196
  ```
197
197
 
198
+ #### 批量感知的入口件(一颗按钮看 N 家,点开即批量面板)
199
+
200
+ 一个总管替 N 家管更新时,别再用单入口的 `manual` 桥接批量面板——徽标只反映总管自己一行,不是七家聚合。换这一颗:
201
+
202
+ ```js
203
+ import { mountUpdateBatchEntry } from 'dsh-plugin-update/entry-batch'
204
+
205
+ const entry = mountUpdateBatchEntry(document.getElementById('upd-entry'), {
206
+ prefix: 'life', // 批量电话前缀(五个批量电话从它派生)
207
+ call: (name, args) => host.call(name, args),
208
+ theme: 'archive', // 与单入口/面板同一套皮肤
209
+ })
210
+ // 点击先调 `life.batchCheck` 查一次 N 家,再以 dialog 形态开批量面板;徽标即七家聚合。
211
+ // 下游删掉 `openOn: 'manual' + onActivate` 桥接即跑,总管单行不再当状态源。
212
+ ```
213
+
214
+ 聚合口径与批量面板总账同一份数法(含「忙失败占位翻回可更新」):`忙 > 失败 > 待重启 > 可更新 > 待查`,
215
+ 文案为 `检查更新` / `N 家可更新` / `正在安装…` / `N 家待重启` / `N 家失败,点此查看`。
216
+ 三自由度与单入口同取值,缺省不同:`openOn` 缺省 `'always'`(查完总是开批量面板;`has-update` 下有事才开,否则原地给总账一句)。
217
+ `autoCheck` 缺省 `'mount'`(只调 `.batchStatus`,只读);铁律同单入口:任何路径都不自动安装。
218
+
198
219
  ### 第 2.6 节:多目标批量更新(一个插件管 N 个插件的更新)
199
220
 
200
221
  一个插件替自己**和另外几个插件**管更新时,别把 N 个面板并排——用户在那块界面上只问三件事:
@@ -239,6 +260,7 @@ const panel = mountUpdateBatchPanel(el, {
239
260
  theme: 'archive', // 与单插件面板同一套皮肤(archive = 档案卷,旧值 d5-paper 仍可用)
240
261
  })
241
262
  ```
263
+ // 弹窗版与单面板同口径:传 `onCloseRequested`(点关闭/Esc 时先调它撤 DOM,再停轮询;入口件打开的 dialog 已内置;不传即只停轮询)。
242
264
 
243
265
  批量面板 HTTP 版(无 `host.call` 环境即跑:同一传输内核,五电话走同一映射,取消走 `batchCancel`):
244
266
 
@@ -0,0 +1,39 @@
1
+ /** 字典版本钉(#53 Q3 解耦):key 冻即解阻塞 #54,文案按词标 draft/locked;开发钉此版本,转正只换文案不换 key。 */
2
+ export declare const BILINGUAL_DICT_VERSION = "2026-10-06-pin53";
3
+ /** 首批 key 全集(entry 7 键,命名沿 #52 §1 key 提案,area.group.name)。 */
4
+ export type BilingualKey = 'entry.label.idle' | 'entry.label.failed' | 'entry.label.busy' | 'entry.label.restart' | 'entry.label.has-update' | 'entry.action.checking' | 'entry.note.up-to-date';
5
+ /** 一条双语:en/zh 模板 + 成熟度。模板内具名槽如 {version},运行时值填入(值永不翻译)。 */
6
+ export interface BilingualEntry {
7
+ en: string;
8
+ zh: string;
9
+ /** true=待母语+域内双签(#53 Q3),false=已双签转正。首批全 draft(无评审不假锁)。 */
10
+ draft: boolean;
11
+ }
12
+ /**
13
+ * 集中字典(机器源,类型锁死:Record<BilingualKey, BilingualEntry> 缺键即 tsc 报错)。
14
+ * 人读源为 #57 规格 + #53 结论英文定调;英文终稿待母语评审,故全标 draft。
15
+ * zh 逐字等于 entry.ts 现有散落文案(兼容既有测试与接入方 label() 口径)。
16
+ */
17
+ export declare const BILINGUAL_STRINGS: Record<BilingualKey, BilingualEntry>;
18
+ /** 取一条双语原文(类型锁死:未知 key 在 tsc 即拦;运行时未知亦抛,不静默回退)。 */
19
+ export declare function bilingualEntry(key: BilingualKey): BilingualEntry;
20
+ /**
21
+ * 纯文本双语(一行,英文在前中文在后,空白分隔):用于 title / aria-label 等放不下 HTML 的属性位。
22
+ * 返回原文(未转义),调用方按上下文转义后再拼入属性。
23
+ */
24
+ export declare function bilingualText(key: BilingualKey, values?: Record<string, unknown>): string;
25
+ /**
26
+ * 双语语义块 HTML(唯一渲染出口):英文在前中文在后,空白分隔,lang 属性齐全。
27
+ * 返回已转义可直接拼入 innerHTML 的片段;调用方不得再对整体转义,不得拆 span 重组。
28
+ */
29
+ export declare function bilingualHTML(key: BilingualKey, values?: Record<string, unknown>): string;
30
+ /** 是否 draft(供 #56 门禁读零 draft 放行条件;#54 自身不断言零 draft)。 */
31
+ export declare function isDraft(key: BilingualKey): boolean;
32
+ /** 全表 draft key 一览(#56 门禁:空数组才放行合入主分支)。 */
33
+ export declare function draftKeys(): BilingualKey[];
34
+ /**
35
+ * 双语块最小样式(与 UPDATE_ENTRY_CSS 同缝注入):
36
+ * 平时行内双语(英文前中文后,空白分隔),窄处竖排不断行(@media 360px 以下 column)。
37
+ * 只管排版,不管字重字号(字重间距留给 #55 视觉矩阵验收)。
38
+ */
39
+ export declare const BILINGUAL_CSS: string;
@@ -0,0 +1,58 @@
1
+ // AUTO-GENERATED by node build.mjs — DO NOT EDIT. Source: src/bilingual.ts
2
+ const BILINGUAL_DICT_VERSION = "2026-10-06-pin53";
3
+ const BILINGUAL_STRINGS = {
4
+ "entry.label.idle": { en: "Check for updates", zh: "\u68C0\u67E5\u66F4\u65B0", draft: true },
5
+ "entry.label.failed": { en: "Update failed \u2014 View details", zh: "\u66F4\u65B0\u5931\u8D25\uFF0C\u70B9\u6B64\u67E5\u770B", draft: true },
6
+ "entry.label.busy": { en: "Installing\u2026", zh: "\u6B63\u5728\u5B89\u88C5\u2026", draft: true },
7
+ "entry.label.restart": { en: "Restart required", zh: "\u5F85\u91CD\u542F", draft: true },
8
+ "entry.label.has-update": { en: "Update available {version}", zh: "\u6709\u65B0\u7248 {version}", draft: true },
9
+ "entry.action.checking": { en: "Checking for updates\u2026", zh: "\u6B63\u5728\u67E5\u65B0\u7248\u2026", draft: true },
10
+ "entry.note.up-to-date": { en: "Up to date {version}", zh: "\u5DF2\u662F\u6700\u65B0 {version}", draft: true }
11
+ };
12
+ function escapeHtml(value) {
13
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
14
+ }
15
+ function formatTemplate(template, values) {
16
+ const vals = values ?? {};
17
+ return String(template).replace(/\{([A-Za-z0-9_]+)\}/g, (_, name) => {
18
+ const v = vals[name];
19
+ if (v === null || v === void 0) return "";
20
+ return String(v).trim();
21
+ });
22
+ }
23
+ function bilingualEntry(key) {
24
+ const hit = BILINGUAL_STRINGS[key];
25
+ if (!hit) throw new Error("[dsh-plugin-update] \u672A\u77E5\u6587\u6848 key\uFF1A" + String(key));
26
+ return hit;
27
+ }
28
+ function bilingualText(key, values) {
29
+ const e = bilingualEntry(key);
30
+ return formatTemplate(e.en, values) + " " + formatTemplate(e.zh, values);
31
+ }
32
+ function bilingualHTML(key, values) {
33
+ const e = bilingualEntry(key);
34
+ const en = escapeHtml(formatTemplate(e.en, values));
35
+ const zh = escapeHtml(formatTemplate(e.zh, values));
36
+ return '<span class="dsh-upd-bi"><span lang="en">' + en + '</span> <span lang="zh">' + zh + "</span></span>";
37
+ }
38
+ function isDraft(key) {
39
+ return bilingualEntry(key).draft;
40
+ }
41
+ function draftKeys() {
42
+ return Object.keys(BILINGUAL_STRINGS).filter((k) => BILINGUAL_STRINGS[k].draft);
43
+ }
44
+ const BILINGUAL_CSS = [
45
+ ".dsh-upd-bi{display:inline-flex;align-items:baseline;gap:.5em;max-width:100%}",
46
+ '.dsh-upd-bi [lang="en"],.dsh-upd-bi [lang="zh"]{overflow-wrap:anywhere}',
47
+ "@media (max-width:360px){.dsh-upd-bi{flex-direction:column;gap:0}}"
48
+ ].join("\n");
49
+ export {
50
+ BILINGUAL_CSS,
51
+ BILINGUAL_DICT_VERSION,
52
+ BILINGUAL_STRINGS,
53
+ bilingualEntry,
54
+ bilingualHTML,
55
+ bilingualText,
56
+ draftKeys,
57
+ isDraft
58
+ };
@@ -0,0 +1,92 @@
1
+ import { type BatchRowView } from './panel-batch.js';
2
+ import { type UpdatePanelTheme } from './panel.js';
3
+ /** 批量入口件的形态(与单入口同一套取值)。 */
4
+ export type BatchEntryVariant = 'button' | 'badge' | 'inline';
5
+ /** 什么时候主动查一次(只读;与单入口同一口径)。 */
6
+ export type BatchEntryAutoCheck = 'mount' | 'never';
7
+ /**
8
+ * 用户点下去之后做什么(与单入口同一套取值,缺省不同:批量缺省 'always')。
9
+ * 单入口缺省 'has-update'(有新版才开单面板);批量入口缺省 'always'(查完总是开批量面板——
10
+ * 批量面板的总账本身就是答案,空着也值得看一眼;下游删 manual 桥接即按此缺省跑)。
11
+ * 'has-update' 下只有聚合档非 idle 才开面板(忙/失败/待重启/可更新都算有事),否则原地给总账一句。
12
+ */
13
+ export type BatchEntryOpenOn = 'has-update' | 'always' | 'manual' | 'direct';
14
+ /** 批量入口件的状态档:只用来算文案与 data-state,不另立状态机。 */
15
+ export type BatchEntryStateKind = 'idle' | 'update' | 'busy' | 'restart' | 'failed';
16
+ /** 算文案要的全部输入:批量七行 + 最近一次批量电话的失败稳定码(没查过、没失败为 null)。 */
17
+ export interface UpdateBatchEntryState {
18
+ rows: readonly BatchRowView[] | null;
19
+ error: string | null;
20
+ }
21
+ /** 聚合后的读数(onActivate 与测试共用同一份,不各算一份)。 */
22
+ export interface BatchEntrySummary {
23
+ kind: BatchEntryStateKind;
24
+ label: string;
25
+ updatable: number;
26
+ installing: number;
27
+ pending: number;
28
+ restart: number;
29
+ failed: number;
30
+ skipped: number;
31
+ settled: number;
32
+ total: number;
33
+ hasUpdate: boolean;
34
+ }
35
+ export interface UpdateBatchEntryOptions {
36
+ /** 批量电话前缀(与宿主侧一致,例 'life')。 */
37
+ prefix: string;
38
+ /** 调宿主电话:(phoneName, args) => Promise<reply>。 */
39
+ call: (name: string, args: Record<string, unknown>) => Promise<unknown>;
40
+ variant?: BatchEntryVariant;
41
+ /** 主题:与单入口/面板同一套(default/archive,d5-paper 为旧别名仍可用)。 */
42
+ theme?: UpdatePanelTheme;
43
+ /** 缺省 'mount':进页面静默查一次(只调 .batchStatus,只读)。'never' 则只在用户点击时查。 */
44
+ autoCheck?: BatchEntryAutoCheck;
45
+ /** 缺省 'always':查完总是开批量面板(见本类型说明)。 */
46
+ openOn?: BatchEntryOpenOn;
47
+ /** 覆盖默认按钮文案(不传就用聚合文案)。 */
48
+ label?: string;
49
+ /** 可选:键 -> 中文名覆盖(透传给批量面板)。 */
50
+ titles?: Record<string, string>;
51
+ /** 轮询间隔(毫秒,透传给批量面板;缺省 1500,下限 250)。 */
52
+ pollMs?: number;
53
+ /**
54
+ * 详情行自动取日志(透传给批量面板,缺省开)。
55
+ * 展开行有新版时调该行自己的更新日志电话按需取;false 关闭。
56
+ */
57
+ autoChangelog?: boolean;
58
+ /** 「重启宿主」的落地(透传给批量面板):不传就只提示手动重启,不假装能重启。 */
59
+ onRestartRequested?: () => void | Promise<void>;
60
+ /** 复制文本的出口(透传给批量面板):不传即试浏览器剪贴板,都没有也不抛错。 */
61
+ copyText?: (text: string) => void | Promise<void>;
62
+ /** variant='badge' 或 openOn='manual' 时,点击交给接入方(自己跳自己的页面)。 */
63
+ onActivate?: (state: BatchEntrySummary) => void;
64
+ }
65
+ export interface UpdateBatchEntryController {
66
+ /** 立刻查一次并把状态刷到界面上(只读:调 .batchStatus)。 */
67
+ refresh(): Promise<void>;
68
+ /** 打开批量面板(dialog 形态)。 */
69
+ open(): void;
70
+ /** 关掉批量面板。 */
71
+ close(): void;
72
+ /** 当前按钮上的聚合文案(接入方做自定义排版时读它)。 */
73
+ label(): string;
74
+ /** 当前聚合读数(含七档计数,接入方排版与 onActivate 共用)。 */
75
+ summary(): BatchEntrySummary;
76
+ setTheme(theme: UpdatePanelTheme): void;
77
+ unmount(): void;
78
+ }
79
+ /**
80
+ * 聚合读数:计数唯一出处 batchLedgerCounts(与面板总账同一份,忙失败占位翻回可更新)。
81
+ * hasUpdate = 忙/失败/待重启/可更新任一非零(openOn has-update 按它开面板;全 settled/待查/跳过即 idle)。
82
+ */
83
+ export declare function batchEntrySummary(state: UpdateBatchEntryState | null | undefined): BatchEntrySummary;
84
+ /** 状态档(唯一出处:batchEntrySummary,不各写一份)。 */
85
+ export declare function batchEntryStateKind(state: UpdateBatchEntryState | null | undefined): BatchEntryStateKind;
86
+ /** 批量入口件文案(唯一出处:测试与接入方都读它,不各写一份)。 */
87
+ export declare function batchEntryLabelFor(state: UpdateBatchEntryState | null | undefined): string;
88
+ export declare function mountUpdateBatchEntry(container: {
89
+ innerHTML: string;
90
+ addEventListener?: (type: string, listener: (ev: unknown) => void) => void;
91
+ removeEventListener?: (type: string, listener: (ev: unknown) => void) => void;
92
+ }, options: UpdateBatchEntryOptions): UpdateBatchEntryController;
@@ -0,0 +1,355 @@
1
+ // AUTO-GENERATED by node build.mjs — DO NOT EDIT. Source: src/entry-batch.ts
2
+ import { MIN_PANEL_POLL_MS } from "./config.js";
3
+ import { UPDATE_ENTRY_CSS } from "./entry.js";
4
+ import {
5
+ batchLedgerCounts,
6
+ batchLedgerText,
7
+ buildBatchPhoneNames,
8
+ mountUpdateBatchPanel
9
+ } from "./panel-batch.js";
10
+ import {
11
+ failureCodeOf,
12
+ normalizePanelTheme
13
+ } from "./panel.js";
14
+ const LABEL_IDLE = "\u68C0\u67E5\u66F4\u65B0";
15
+ const LABEL_BUSY = "\u6B63\u5728\u5B89\u88C5\u2026";
16
+ function isObject(value) {
17
+ return !!value && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+ function asRows(value) {
20
+ if (!Array.isArray(value)) return null;
21
+ return value;
22
+ }
23
+ function escapeHtml(value) {
24
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
25
+ }
26
+ function batchEntrySummary(state) {
27
+ const rows = state?.rows ?? null;
28
+ const counts = batchLedgerCounts(Array.isArray(rows) ? rows : []);
29
+ const total = Array.isArray(rows) ? rows.length : 0;
30
+ let kind;
31
+ if (!Array.isArray(rows) || rows.length === 0) {
32
+ kind = state?.error ? "failed" : "idle";
33
+ } else if (counts.installing > 0) {
34
+ kind = "busy";
35
+ } else if (counts.failed > 0) {
36
+ kind = "failed";
37
+ } else if (counts.restart > 0) {
38
+ kind = "restart";
39
+ } else if (counts.updatable > 0) {
40
+ kind = "update";
41
+ } else {
42
+ kind = "idle";
43
+ }
44
+ if (kind !== "busy" && state?.error) {
45
+ if (Array.isArray(rows) && rows.length > 0 && counts.failed === 0 && counts.installing === 0) {
46
+ kind = "failed";
47
+ }
48
+ }
49
+ const hasUpdate = counts.installing > 0 || counts.failed > 0 || counts.restart > 0 || counts.updatable > 0;
50
+ let label;
51
+ switch (kind) {
52
+ case "busy":
53
+ label = LABEL_BUSY;
54
+ break;
55
+ case "failed":
56
+ label = counts.failed > 0 ? counts.failed + " \u5BB6\u5931\u8D25\uFF0C\u70B9\u6B64\u67E5\u770B" : "\u66F4\u65B0\u5931\u8D25\uFF0C\u70B9\u6B64\u67E5\u770B";
57
+ break;
58
+ case "restart":
59
+ label = counts.restart + " \u5BB6\u5F85\u91CD\u542F";
60
+ break;
61
+ case "update":
62
+ label = counts.updatable + " \u5BB6\u53EF\u66F4\u65B0";
63
+ break;
64
+ default:
65
+ label = LABEL_IDLE;
66
+ break;
67
+ }
68
+ return {
69
+ kind,
70
+ label,
71
+ updatable: counts.updatable,
72
+ installing: counts.installing,
73
+ pending: counts.pending,
74
+ restart: counts.restart,
75
+ failed: counts.failed,
76
+ skipped: counts.skipped,
77
+ settled: counts.settled,
78
+ total,
79
+ hasUpdate
80
+ };
81
+ }
82
+ function batchEntryStateKind(state) {
83
+ return batchEntrySummary(state).kind;
84
+ }
85
+ function batchEntryLabelFor(state) {
86
+ return batchEntrySummary(state).label;
87
+ }
88
+ function mountUpdateBatchEntry(container, options) {
89
+ if (!container || typeof container.innerHTML !== "string") {
90
+ throw new Error("[dsh-plugin-update] \u6302\u6279\u91CF\u5165\u53E3\u4EF6\u9700\u8981\u4E00\u4E2A\u6709 innerHTML \u7684\u5BB9\u5668");
91
+ }
92
+ if (!options || typeof options !== "object") {
93
+ throw new Error("[dsh-plugin-update] \u6302\u6279\u91CF\u5165\u53E3\u4EF6\u7F3A\u5C11\u914D\u7F6E\uFF1A\u6279\u91CF\u7535\u8BDD\u524D\u7F00 prefix \u5FC5\u586B");
94
+ }
95
+ if (typeof options.call !== "function") {
96
+ throw new Error("[dsh-plugin-update] \u6302\u6279\u91CF\u5165\u53E3\u4EF6\u9700\u8981\u4F20\u8F93\u51FD\u6570 call\uFF08\u5165\u53E3\u4EF6\u8C03\u5BBF\u4E3B\u7535\u8BDD\u7684\u552F\u4E00\u63A5\u89E6\u9762\uFF09");
97
+ }
98
+ const phones = buildBatchPhoneNames(options.prefix);
99
+ const variant = options.variant ?? "button";
100
+ if (variant !== "button" && variant !== "badge" && variant !== "inline") {
101
+ throw new Error("[dsh-plugin-update] \u5165\u53E3\u4EF6\u5F62\u6001\u975E\u6CD5\uFF1A\u53EA\u6536 button / badge / inline\uFF08\u6536\u5230 " + JSON.stringify(options.variant) + "\uFF09");
102
+ }
103
+ const autoCheck = options.autoCheck ?? "mount";
104
+ if (autoCheck !== "mount" && autoCheck !== "never") {
105
+ throw new Error("[dsh-plugin-update] \u81EA\u52A8\u68C0\u67E5\u65F6\u673A\u975E\u6CD5\uFF1A\u53EA\u6536 mount \u6216 never\uFF08\u6536\u5230 " + JSON.stringify(options.autoCheck) + "\uFF09");
106
+ }
107
+ const openOn = options.openOn ?? "always";
108
+ if (openOn !== "has-update" && openOn !== "always" && openOn !== "manual" && openOn !== "direct") {
109
+ throw new Error("[dsh-plugin-update] \u70B9\u51FB\u53BB\u5411\u975E\u6CD5\uFF1A\u53EA\u6536 has-update / always / manual / direct\uFF08\u6536\u5230 " + JSON.stringify(options.openOn) + "\uFF09");
110
+ }
111
+ if (options.theme !== void 0 && options.theme !== "default" && options.theme !== "archive" && options.theme !== "d5-paper") {
112
+ throw new Error("[dsh-plugin-update] \u4E3B\u9898\u975E\u6CD5\uFF1A\u53EA\u6536 default \u6216 archive\uFF08d5-paper \u4E3A\u65E7\u522B\u540D\u4ECD\u53EF\u7528\uFF09\uFF08\u6536\u5230 " + JSON.stringify(options.theme) + "\uFF09");
113
+ }
114
+ let theme = normalizePanelTheme(options.theme ?? "default");
115
+ const pollMs = options.pollMs;
116
+ if (pollMs !== void 0 && (typeof pollMs !== "number" || !Number.isFinite(pollMs) || pollMs < MIN_PANEL_POLL_MS)) {
117
+ throw new Error("[dsh-plugin-update] \u9762\u677F\u8F6E\u8BE2\u95F4\u9694\u975E\u6CD5\uFF1A\u4E0D\u5F97\u5C0F\u4E8E 250 \u6BEB\u79D2\uFF08\u6536\u5230 " + JSON.stringify(options.pollMs) + "\uFF09");
118
+ }
119
+ const labelOverride = typeof options.label === "string" && options.label ? options.label : null;
120
+ const titles = options.titles && typeof options.titles === "object" ? options.titles : {};
121
+ const onActivate = typeof options.onActivate === "function" ? options.onActivate : null;
122
+ const call = options.call;
123
+ const ENTRY_ATTR = "data-dsh-upd-entry";
124
+ const ENTRY_SELECTOR = "[data-dsh-upd-entry]";
125
+ let rows = null;
126
+ let error = null;
127
+ let note = null;
128
+ let loaded = false;
129
+ let activating = false;
130
+ let mounted = true;
131
+ let panel = null;
132
+ let panelMode = null;
133
+ const panelHost = {
134
+ get innerHTML() {
135
+ return container.innerHTML;
136
+ },
137
+ set innerHTML(value) {
138
+ container.innerHTML = value;
139
+ },
140
+ addEventListener(type, listener) {
141
+ ;
142
+ container.addEventListener?.(type, listener);
143
+ },
144
+ removeEventListener(type, listener) {
145
+ ;
146
+ container.removeEventListener?.(type, listener);
147
+ }
148
+ };
149
+ async function panelCall(name, args) {
150
+ const reply = await call(name, args);
151
+ return isObject(reply) ? reply : {};
152
+ }
153
+ function stateOf() {
154
+ return { rows, error };
155
+ }
156
+ function summaryOf() {
157
+ return batchEntrySummary(stateOf());
158
+ }
159
+ function currentLabel() {
160
+ return labelOverride ?? summaryOf().label;
161
+ }
162
+ function phoneArgs() {
163
+ return {};
164
+ }
165
+ function entryHTML() {
166
+ const summary = summaryOf();
167
+ const text = activating ? "\u6B63\u5728\u67E5\u65B0\u7248\u2026" : currentLabel();
168
+ const busyAttr = activating ? ' disabled aria-busy="true"' : "";
169
+ const themeAttr = theme === "archive" ? ' data-theme="archive"' : "";
170
+ const control = variant === "badge" ? '<button type="button" class="dsh-upd-entry-dot" ' + ENTRY_ATTR + '="activate" title="' + escapeHtml(text) + '" aria-label="' + escapeHtml(text) + '"' + busyAttr + "></button>" : '<button type="button" class="dsh-upd-entry-btn" ' + ENTRY_ATTR + '="activate"' + busyAttr + ">" + escapeHtml(text) + "</button>";
171
+ const noteHTML = note ? '<span class="dsh-upd-entry-note" data-dsh-upd-note="1">' + escapeHtml(note) + "</span>" : "";
172
+ return "<style>" + UPDATE_ENTRY_CSS + '</style>\n<span class="dsh-upd-entry" data-variant="' + variant + '" data-state="' + summary.kind + '"' + themeAttr + ">" + control + noteHTML + "</span>";
173
+ }
174
+ function render() {
175
+ if (!mounted) return;
176
+ if (panelMode !== null) return;
177
+ container.innerHTML = entryHTML();
178
+ }
179
+ function applyReply(reply) {
180
+ if (!isObject(reply) || reply["ok"] !== true) {
181
+ error = failureCodeOf(isObject(reply) ? reply : null, "check-failed");
182
+ return;
183
+ }
184
+ const next = asRows(reply["rows"]);
185
+ if (next) rows = next;
186
+ error = null;
187
+ loaded = true;
188
+ }
189
+ async function refresh() {
190
+ if (!mounted) return;
191
+ try {
192
+ const reply = await call(phones.status, phoneArgs());
193
+ if (!mounted) return;
194
+ applyReply(reply);
195
+ } catch {
196
+ if (!mounted) return;
197
+ error = "check-failed";
198
+ }
199
+ render();
200
+ }
201
+ async function checkNow() {
202
+ try {
203
+ const reply = await call(phones.check, phoneArgs());
204
+ if (!mounted) return;
205
+ applyReply(reply);
206
+ } catch {
207
+ if (!mounted) return;
208
+ error = "check-failed";
209
+ }
210
+ }
211
+ function mountPanel(mode) {
212
+ if (!mounted || panelMode !== null) return;
213
+ panel = mountUpdateBatchPanel(panelHost, {
214
+ prefix: options.prefix,
215
+ mode,
216
+ theme,
217
+ pollMs,
218
+ titles,
219
+ onRestartRequested: options.onRestartRequested,
220
+ copyText: options.copyText,
221
+ autoChangelog: options.autoChangelog,
222
+ // 面板点「关闭」即走入口件的完整关闭(收 dialog + 还原按钮 + 重查一次),不再是面板自己停轮询。
223
+ onCloseRequested: () => close(),
224
+ call: panelCall
225
+ });
226
+ panelMode = mode;
227
+ }
228
+ function openDialog() {
229
+ mountPanel("dialog");
230
+ }
231
+ function open() {
232
+ if (!mounted || panelMode !== null) return;
233
+ if (variant === "inline") return;
234
+ openDialog();
235
+ }
236
+ function close() {
237
+ if (!mounted || panelMode !== "dialog" || !panel) return;
238
+ const opened = panel;
239
+ panel = null;
240
+ panelMode = null;
241
+ try {
242
+ opened.unmount();
243
+ } catch {
244
+ }
245
+ render();
246
+ void refresh();
247
+ }
248
+ async function activate() {
249
+ if (!mounted || panelMode !== null || activating) return;
250
+ if (openOn === "direct" && variant === "button") {
251
+ openDialog();
252
+ return;
253
+ }
254
+ activating = true;
255
+ note = null;
256
+ render();
257
+ try {
258
+ await checkNow();
259
+ } finally {
260
+ activating = false;
261
+ }
262
+ if (!mounted) return;
263
+ if (variant === "badge" || openOn === "manual") {
264
+ if (onActivate) onActivate(summaryOf());
265
+ else openDialog();
266
+ render();
267
+ return;
268
+ }
269
+ const summary = summaryOf();
270
+ if (openOn === "always" || summary.hasUpdate || !loaded || error) {
271
+ openDialog();
272
+ return;
273
+ }
274
+ note = batchLedgerText({
275
+ updatable: summary.updatable,
276
+ installing: summary.installing,
277
+ pending: summary.pending,
278
+ restart: summary.restart,
279
+ failed: summary.failed,
280
+ skipped: summary.skipped,
281
+ settled: summary.settled
282
+ });
283
+ render();
284
+ }
285
+ function setTheme(next) {
286
+ if (next !== "default" && next !== "archive" && next !== "d5-paper") {
287
+ throw new Error("[dsh-plugin-update] \u4E3B\u9898\u975E\u6CD5\uFF1A\u53EA\u6536 default \u6216 archive\uFF08d5-paper \u4E3A\u65E7\u522B\u540D\u4ECD\u53EF\u7528\uFF09\uFF08\u6536\u5230 " + JSON.stringify(next) + "\uFF09");
288
+ }
289
+ theme = normalizePanelTheme(next);
290
+ if (panel) void panel.setTheme(theme);
291
+ else render();
292
+ }
293
+ function onClick(ev) {
294
+ if (!mounted) return;
295
+ try {
296
+ const target = ev?.target;
297
+ const closest = target && typeof target.closest === "function" ? target.closest.bind(target) : null;
298
+ if (!closest) return;
299
+ if (closest(ENTRY_SELECTOR)) {
300
+ void activate();
301
+ return;
302
+ }
303
+ } catch {
304
+ }
305
+ }
306
+ function onKeyDown(ev) {
307
+ try {
308
+ const e = ev;
309
+ if (!mounted || panelMode !== "dialog" || !e || e.key !== "Escape") return;
310
+ close();
311
+ } catch {
312
+ }
313
+ }
314
+ function unmount() {
315
+ if (!mounted) return;
316
+ mounted = false;
317
+ const opened = panel;
318
+ panel = null;
319
+ panelMode = null;
320
+ try {
321
+ opened?.unmount();
322
+ } catch {
323
+ }
324
+ try {
325
+ ;
326
+ container.removeEventListener?.("click", onClick);
327
+ } catch {
328
+ }
329
+ try {
330
+ ;
331
+ container.removeEventListener?.("keydown", onKeyDown);
332
+ } catch {
333
+ }
334
+ }
335
+ try {
336
+ ;
337
+ container.addEventListener?.("click", onClick);
338
+ } catch {
339
+ }
340
+ try {
341
+ ;
342
+ container.addEventListener?.("keydown", onKeyDown);
343
+ } catch {
344
+ }
345
+ if (variant === "inline") mountPanel("embedded");
346
+ else render();
347
+ if (autoCheck === "mount") void refresh();
348
+ return { refresh, open, close, label: currentLabel, summary: summaryOf, setTheme, unmount };
349
+ }
350
+ export {
351
+ batchEntryLabelFor,
352
+ batchEntryStateKind,
353
+ batchEntrySummary,
354
+ mountUpdateBatchEntry
355
+ };
package/dist/entry.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type UpdatePanelContainer, type UpdatePanelTheme } from './panel.js';
2
2
  import type { UpdateSnapshot } from './ports.js';
3
+ import { type BilingualKey } from './bilingual.js';
3
4
  /** 入口件的形态。 */
4
5
  export type EntryVariant = 'button' | 'badge' | 'inline';
5
6
  /** 什么时候主动查一次(只读,联网一次)。 */
@@ -65,6 +66,17 @@ export interface UpdateEntryController {
65
66
  * 待重启是正常终态(不是失败),所以排在失败前面;正在装的任务比一条陈旧的失败码更可信。
66
67
  */
67
68
  export declare function entryStateKind(state: UpdateEntryState | null | undefined): EntryStateKind;
69
+ /**
70
+ * 入口件双语 key(#54 底座调用链):与 entryStateKind 同一输入(快照 + 失败码),分支只认稳定码,永不读文案。
71
+ * 数据层只传 key + {version} 运行时值,表现层经 bilingualHTML 拼语义块。
72
+ */
73
+ export declare function entryBilingualKeyFor(state: UpdateEntryState | null | undefined): BilingualKey;
74
+ /** 双语插值(运行时值永不翻译:同一 version 在中英两 span 各出现一次)。 */
75
+ export declare function entryBilingualValuesFor(state: UpdateEntryState | null | undefined): Record<string, string>;
76
+ /** 一路调用链的语义块 HTML(entryHTML 按钮正文唯一来源,无覆盖时必走此函数)。 */
77
+ export declare function entryBilingualHTMLFor(state: UpdateEntryState | null | undefined): string;
78
+ /** 属性位纯文本双语(title / aria-label 用,放不下 HTML 时的同内容平面版)。 */
79
+ export declare function entryBilingualTextFor(state: UpdateEntryState | null | undefined): string;
68
80
  /** 入口件文案(唯一出处:测试与接入方都读它,不各写一份)。 */
69
81
  export declare function entryLabelFor(state: UpdateEntryState | null | undefined): string;
70
82
  export declare const UPDATE_ENTRY_CSS: string;