dsh-life-pack 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -134,11 +134,13 @@ window.__ModuleLoader__.load({
134
134
  //#region src/update-contract.ts
135
135
  /** 载体:宿主注册 `connection.fetch.register` 的路径 / 面板 `connection.rpc.call` 的两段参数。 */
136
136
  const MANAGER_RPC = {
137
+ /** 载体基段(`connection.rpc.call` 的**第一段**参数)。传错第一段就是 404:真机上踩过一次。 */
138
+ base: "/api",
137
139
  /** 单段通道名,须 match `/^\/[A-Za-z0-9._~-]+$/`(cookbook §6)。 */
138
140
  channel: "/ilife-manager",
139
- /** 面板侧第二段参数(等于注册路径去掉 `/api` 前缀后那一段)。 */
141
+ /** 面板侧第二段参数(等于通道名去掉前导斜杠那一段)。 */
140
142
  endpoint: "ilife-manager",
141
- /** 宿主侧注册的完整路径(`/api` 是 DSH 公开载体)。 */
143
+ /** 宿主侧注册的完整路径(= `base` + `channel`)。 */
142
144
  path: "/api/ilife-manager"
143
145
  };
144
146
  /** 总管自有电话名:更新包的三个动作名是冻结集合(`config.ts:14`),这里另起名字不撞车。 */
@@ -216,7 +218,7 @@ window.__ModuleLoader__.load({
216
218
  if (typeof call !== "function") return failure("internal", { detail: "宿主连接缺席:connection.rpc.call 不可用" });
217
219
  let result;
218
220
  try {
219
- result = await call(MANAGER_RPC.channel, MANAGER_RPC.endpoint, {
221
+ result = await call(MANAGER_RPC.base, MANAGER_RPC.endpoint, {
220
222
  method,
221
223
  payload
222
224
  });
@@ -357,6 +359,15 @@ window.__ModuleLoader__.load({
357
359
  if (installed === running) return "⚠️ 新版 " + installed + " 已装好,重启宿主后生效。";
358
360
  return "⚠️ 新版 " + installed + " 已装好,正在跑的还是 " + running + ",重启宿主后生效。";
359
361
  }
362
+ /** 面板展示的那条可复制命令(用户会照着敲的那一条)。
363
+ *
364
+ * 缺席包给人人都会用的**单命令双包**口径(#674 冻结的装入口径:不带版本号、不带任何开关);
365
+ * 已装包给更新包回包里那条(`--save-exact ... --registry=` 是它 README 第 9 节的冻结合同,
366
+ * 本仓不动它——要改只能提上游)。规范词见 docs/plugins/plugin-manager/t678-缺席即装与检查更新.md。 */
367
+ function manualForDisplay(target, manual) {
368
+ if (isAbsent(target)) return dualInstallCmd(target.packageName);
369
+ return manual;
370
+ }
360
371
  /** 版本行两行文字:插件包(本家) + 技能包(随插件,不单独查更新)。 */
361
372
  function versionLines(target, snapshot) {
362
373
  const running = snapshot?.runningVersion ?? target.runningVersion ?? "unknown";
@@ -490,7 +501,7 @@ window.__ModuleLoader__.load({
490
501
  marginTop: 4
491
502
  }
492
503
  };
493
- const IDLE = {
504
+ const IDLE$1 = {
494
505
  phase: "idle",
495
506
  outcome: null,
496
507
  failure: null
@@ -555,7 +566,7 @@ window.__ModuleLoader__.load({
555
566
  targets
556
567
  ]),
557
568
  act: react.useCallback((target) => {
558
- const current = rows[target.key] ?? IDLE;
569
+ const current = rows[target.key] ?? IDLE$1;
559
570
  patch(target.key, {
560
571
  ...current,
561
572
  phase: "installing",
@@ -651,7 +662,7 @@ window.__ModuleLoader__.load({
651
662
  const row = rows[target.key];
652
663
  const snapshot = row.outcome?.snapshot ?? null;
653
664
  const verdict = snapshot ? verdictOf(target, snapshot) : null;
654
- const manual = row.failure?.manual ?? row.outcome?.manual ?? null;
665
+ const manual = manualForDisplay(target, row.failure?.manual ?? row.outcome?.manual ?? null);
655
666
  const busy = row.phase === "installing";
656
667
  return react.createElement("div", {
657
668
  key: target.key,
@@ -678,7 +689,7 @@ window.__ModuleLoader__.load({
678
689
  const target = props.target;
679
690
  const row = target ? props.face.rows[target.key] : null;
680
691
  const busy = row?.phase === "installing";
681
- const manual = row?.failure?.manual ?? row?.outcome?.manual ?? props.fallbackCommand;
692
+ const manual = (target ? manualForDisplay(target, row?.failure?.manual ?? row?.outcome?.manual ?? null) : null) ?? props.fallbackCommand;
682
693
  let reason = row?.failure?.message ?? null;
683
694
  if (target && reason === null && row?.outcome && row.outcome.snapshot.blockedReason !== null) reason = verdictOf(target, row.outcome.snapshot).text;
684
695
  return react.createElement("div", { style: PANEL_STYLE.reco }, react.createElement("div", null, target ? "未安装[" + target.packageName + "],装上后这个页签就能用了。" : "这个页签对应的插件还没装。"), target ? react.createElement("button", {
@@ -696,6 +707,393 @@ window.__ModuleLoader__.load({
696
707
  }, busy ? "正在装,请稍候…" : "装上") : null, reason ? react.createElement("div", { style: PANEL_STYLE.reason }, reason) : null, react.createElement("code", { style: PANEL_STYLE.cmd }, manual));
697
708
  }
698
709
  //#endregion
710
+ //#region src/health-contract.ts
711
+ /** 面板第二段参数(与各家的端点名同值,和各家的 `RPC_CHANNEL` 配成一次调用)。 */
712
+ const HEALTH_ENDPOINT = "config.check";
713
+ /** 三档排序:数字大的更严重(总览一行取**最严重**那档当那一家的灯)。 */
714
+ const RANK = {
715
+ green: 0,
716
+ yellow: 1,
717
+ red: 2
718
+ };
719
+ /** 一家的总判据:全部项里最严重的那一档;一条都没有时回 `null`(不冒充绿)。 */
720
+ function worstStatus(items) {
721
+ let worst = null;
722
+ for (const item of items) if (worst === null || RANK[item.status] > RANK[worst]) worst = item.status;
723
+ return worst;
724
+ }
725
+ /** 三档计数(面板那一行「红 N 黄 N」用的就是它)。 */
726
+ function countByStatus(items) {
727
+ const out = {
728
+ red: 0,
729
+ yellow: 0,
730
+ green: 0
731
+ };
732
+ for (const item of items) out[item.status] += 1;
733
+ return out;
734
+ }
735
+ /** 回执校验:面板只渲染自己认得的形状,认不出即报「回执异常」,不猜、不返空冒充正常。 */
736
+ function isHealthReport(raw) {
737
+ if (typeof raw !== "object" || raw === null) return false;
738
+ const report = raw;
739
+ if (typeof report.skill !== "string" || report.skill.length === 0) return false;
740
+ if (typeof report.configPath !== "string" || typeof report.dataDir !== "string") return false;
741
+ if (!Array.isArray(report.items)) return false;
742
+ return report.items.every((item) => {
743
+ if (typeof item !== "object" || item === null) return false;
744
+ const row = item;
745
+ return typeof row.id === "string" && typeof row.title === "string" && (row.status === "red" || row.status === "yellow" || row.status === "green") && typeof row.message === "string" && typeof row.action === "string";
746
+ });
747
+ }
748
+ //#endregion
749
+ //#region src/health-fetch.ts
750
+ /** 面板到各家的配置体检:一次取数、六份报告(纯异步函数:无 DOM、无 react;传输口注入,便于单测)。
751
+ *
752
+ * 三条纪律:
753
+ * · **只走各家自己的通道**(`rpc.call(channel, HEALTH_ENDPOINT, …)`)——总管不认任何一家、也不加
754
+ * 任何单品的依赖;通道名从「爱生活页签槽」的账本条目带出来(各家注册时写进 options)。
755
+ * · **一次取数,六份报告**:总览那行与各家那张表读的是同一份结果(见 `health-panel.ts` 的单一状态源),
756
+ * 不许两处各算一遍。
757
+ * · 有界超时:一家不回不该拖死整屏(技能侧 spawn 上限 20 s,这里给 25 s)。
758
+ */
759
+ /** 一次取数的时限(毫秒):技能侧 spawn 上限 20 s,这里留一点余量。 */
760
+ const HEALTH_TIMEOUT_MS = 25e3;
761
+ function timeoutText() {
762
+ return "体检超时(" + String(Math.round(HEALTH_TIMEOUT_MS / 1e3)) + "s):技能没在时限内回话。";
763
+ }
764
+ async function fetchOne(call, row) {
765
+ try {
766
+ const result = await call(row.channel, HEALTH_ENDPOINT, {}, AbortSignal.timeout(HEALTH_TIMEOUT_MS));
767
+ if (!result || typeof result !== "object" || typeof result.ok !== "boolean") return {
768
+ report: null,
769
+ error: "回执信封异常(非 ok 信封)。"
770
+ };
771
+ if (!result.ok) {
772
+ const error = result.error;
773
+ return {
774
+ report: null,
775
+ error: typeof error?.message === "string" && error.message.length > 0 ? error.message : "体检没跑起来。"
776
+ };
777
+ }
778
+ const value = result.value;
779
+ if (!isHealthReport(value)) return {
780
+ report: null,
781
+ error: "体检报告形状认不出(技能包与总管版本对不上?)。"
782
+ };
783
+ return {
784
+ report: value,
785
+ error: null
786
+ };
787
+ } catch (error) {
788
+ if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) return {
789
+ report: null,
790
+ error: timeoutText()
791
+ };
792
+ return {
793
+ report: null,
794
+ error: "体检没跑起来:" + (error instanceof Error ? error.message : String(error))
795
+ };
796
+ }
797
+ }
798
+ /** 取六家的体检报告(缺席的包不在此列,由调用方按账本判断)。 */
799
+ async function loadHealthReports(call, tabs) {
800
+ if (typeof call !== "function") return {
801
+ rows: [],
802
+ error: "宿主连接缺席:connection.rpc.call 不可用"
803
+ };
804
+ return {
805
+ rows: await Promise.all(tabs.map(async (tab) => {
806
+ const one = await fetchOne(call, tab);
807
+ return {
808
+ id: tab.id,
809
+ report: one.report,
810
+ error: one.error
811
+ };
812
+ })),
813
+ error: null
814
+ };
815
+ }
816
+ //#endregion
817
+ //#region src/health-panel.ts
818
+ /** 配置体检的状态源:**一份快照**喂两处(面板顶部那行总览 + 各家页签那张表)。
819
+ *
820
+ * 为什么是一个 hook 而不是两处各取一次(票 #706 验收第三条「同一份数据,不许两处各算一遍」):
821
+ * 这一处取数、这一处存状态,总览与各家表都从它读 —— 那条验收因此是**结构上的性质**,不是靠自觉。
822
+ *
823
+ * 取数是**按需**的(点「体检一次」才跑):没有轮询、没有定时器(静态门那条「无数据轮询」照旧)。
824
+ */
825
+ const IDLE = {
826
+ phase: "idle",
827
+ report: null,
828
+ error: null
829
+ };
830
+ /** 体检状态源:挂载时不自动跑(体检要读盘、要起子进程,按需跑),点一次跑六家。 */
831
+ function useHealthPanel(getCall, tabs) {
832
+ const [rows, setRows] = react.useState({});
833
+ const [error, setError] = react.useState(null);
834
+ const [running, setRunning] = react.useState(false);
835
+ const face = {
836
+ rows,
837
+ error,
838
+ running,
839
+ run: react.useCallback(() => {
840
+ const diag = globalThis.__T706_RUN__;
841
+ if (Array.isArray(diag)) diag.push("run:tabs=" + String(tabs.length));
842
+ setRunning(true);
843
+ setError(null);
844
+ const targets = tabs;
845
+ setRows(Object.fromEntries(targets.map((tab) => [tab.id, {
846
+ ...IDLE,
847
+ phase: "running"
848
+ }])));
849
+ (async () => {
850
+ const loaded = await loadHealthReports(getCall(), targets);
851
+ try {
852
+ const diag = globalThis.__T706_RUN__;
853
+ if (Array.isArray(diag)) diag.push("rows=" + String(loaded.rows.length) + " err=" + String(loaded.error) + " first=" + String(loaded.rows[0]?.error ?? "null"));
854
+ } catch {}
855
+ const next = {};
856
+ for (const row of loaded.rows) next[row.id] = {
857
+ phase: row.report ? "ready" : "failed",
858
+ report: row.report,
859
+ error: row.error
860
+ };
861
+ setRows(next);
862
+ setError(loaded.error);
863
+ setRunning(false);
864
+ })();
865
+ }, [getCall, tabs])
866
+ };
867
+ const sink = globalThis.__T706_FACE_SINK__;
868
+ if (typeof sink === "function") try {
869
+ sink(face);
870
+ } catch {}
871
+ return face;
872
+ }
873
+ //#endregion
874
+ //#region src/health-view.ts
875
+ /** 配置体检的两个画法:面板顶部那行总览(六家一盏灯)+ 各家那张表。
876
+ *
877
+ * 面板侧纪律(沿 `client.ts`/`update-panel.ts` 既有口径):`createElement` 手写(禁 JSX)、内联 style、
878
+ * 颜色走 DSH 主题别名(深浅主题自适应,写死值只做回退)、不 import 任何单品包、不 import 技能实现。
879
+ *
880
+ * 判据与文案**全部来自各家技能的报告**,本件一个字都不重写——只按三档上色、按状态挑文案。
881
+ */
882
+ /** 三档的配色(绿走主题别名;红黄用实色,理由见下)。
883
+ *
884
+ * 红与黄为什么必须落在**不同明度带**上:这两档要一起出现在同一行灯里,用户扫一眼要能分开
885
+ * 「红了(用不了)」与「黄了(能用但要撞上)」。评审连着两轮点「黄灯偏暗与红相近」,所以:
886
+ * · 红=深红(暗、重);
887
+ * · 黄=高饱和的琥珀(亮、跳)——它在白底上的对比度仍然够(`#b45309` 对白 ≈ 4.9:1)。
888
+ * 绿不给底色:它是「正常」,不该和要处理的那两档抢注意力。 */
889
+ const STATUS_COLOR = {
890
+ red: "#c0392b",
891
+ yellow: "#e08a00",
892
+ green: "var(--dsw-alias-state-success-primary, #4ec9a0)"
893
+ };
894
+ /** 整行底色的浅一档:饱和降一档,免得红黄行「刺眼」压过正文(评审第二轮的扣分点)。 */
895
+ const STATUS_TINT = {
896
+ red: "rgba(192, 57, 43, 0.08)",
897
+ yellow: "rgba(224, 138, 0, 0.14)",
898
+ green: "transparent"
899
+ };
900
+ /** 档位词(灯里那几个字)用的色:红黄要**够深**才读得清,故另取一枚比灯更深的值。 */
901
+ const STATUS_TEXT = {
902
+ red: "#a5281b",
903
+ yellow: "#8a5200",
904
+ green: "var(--dsw-alias-state-success-primary, #4ec9a0)"
905
+ };
906
+ const STATUS_LABEL = {
907
+ red: "红",
908
+ yellow: "黄",
909
+ green: "绿"
910
+ };
911
+ /** 面板上的文字色与分隔线(与 `client.ts` 同一套别名)。 */
912
+ const INK = "var(--dsw-alias-label-primary, inherit)";
913
+ const INK_DIM = "var(--dsw-alias-label-secondary, #9a9a9a)";
914
+ const BORDER = "var(--dsw-alias-border, rgba(128,128,128,.35))";
915
+ const HEALTH_STYLE = {
916
+ box: {
917
+ padding: "10px 12px",
918
+ marginBottom: 10,
919
+ borderRadius: 10,
920
+ border: "1px solid " + BORDER
921
+ },
922
+ headRow: {
923
+ display: "flex",
924
+ flexWrap: "wrap",
925
+ alignItems: "center",
926
+ gap: 8,
927
+ marginBottom: 6
928
+ },
929
+ head: {
930
+ fontSize: 13,
931
+ fontWeight: 700,
932
+ color: INK
933
+ },
934
+ btn: {
935
+ marginLeft: "auto",
936
+ border: "1px solid " + BORDER,
937
+ background: "transparent",
938
+ color: INK,
939
+ borderRadius: 8,
940
+ padding: "3px 10px",
941
+ fontSize: 12.5,
942
+ cursor: "pointer"
943
+ },
944
+ lightsRow: {
945
+ display: "flex",
946
+ flexWrap: "wrap",
947
+ gap: 8
948
+ },
949
+ light: {
950
+ display: "inline-flex",
951
+ alignItems: "center",
952
+ gap: 4,
953
+ background: "transparent",
954
+ border: "1px solid " + BORDER,
955
+ borderRadius: 999,
956
+ padding: "3px 10px",
957
+ fontSize: 12.5,
958
+ color: INK,
959
+ cursor: "pointer"
960
+ },
961
+ lightIdle: {
962
+ cursor: "default",
963
+ color: INK_DIM
964
+ },
965
+ meta: {
966
+ color: INK_DIM,
967
+ fontSize: 12,
968
+ lineHeight: 1.7,
969
+ marginTop: 12
970
+ },
971
+ error: {
972
+ color: STATUS_COLOR.red,
973
+ fontSize: 12,
974
+ lineHeight: 1.7,
975
+ marginTop: 6
976
+ }
977
+ };
978
+ /** 值得一眼看见的那两档(档位名与整行文字都上色;绿行保持淡)。 */
979
+ function isAttention(status) {
980
+ return status === "red" || status === "yellow";
981
+ }
982
+ /** 「去哪修」那一行的正文:各家交回来的 action 里若已自带「去哪修:」,这里剥掉一层——
983
+ * 面板已经带了这个前缀,两边都写就会印成「去哪修:去哪修:…」。只剥开头那一次,别的一字不动。 */
984
+ function actionText(action) {
985
+ const trimmed = action.trim();
986
+ return trimmed.startsWith("去哪修:") ? trimmed.slice(4).trim() : trimmed;
987
+ }
988
+ /** 从六份报告数出那一行的灯(每家按最严重那一档上色)。 */
989
+ function lightsOf(tabs, reports) {
990
+ return tabs.map((tab) => {
991
+ const report = reports[tab.id];
992
+ return {
993
+ id: tab.id,
994
+ title: tab.title,
995
+ status: report ? worstStatus(report.items) : null,
996
+ counts: countByStatus(report?.items ?? [])
997
+ };
998
+ });
999
+ }
1000
+ /** 一家的一句话读数:「红 1 黄 2」,全绿就是「绿」。 */
1001
+ function countsText(counts) {
1002
+ const parts = [];
1003
+ if (counts.red > 0) parts.push("红 " + String(counts.red));
1004
+ if (counts.yellow > 0) parts.push("黄 " + String(counts.yellow));
1005
+ if (parts.length === 0) return "绿";
1006
+ return parts.join(" · ");
1007
+ }
1008
+ /** 顶部那行总览:六家一盏灯 + 一个「体检一次」按钮。 */
1009
+ function HealthOverview(props) {
1010
+ return react.createElement("div", {
1011
+ style: HEALTH_STYLE.box,
1012
+ "data-ilife-health": "overview"
1013
+ }, react.createElement("div", { style: HEALTH_STYLE.headRow }, react.createElement("div", { style: HEALTH_STYLE.head }, "配置体检"), react.createElement("button", {
1014
+ type: "button",
1015
+ style: props.running ? {
1016
+ ...HEALTH_STYLE.btn,
1017
+ opacity: .55,
1018
+ cursor: "default"
1019
+ } : HEALTH_STYLE.btn,
1020
+ disabled: props.running,
1021
+ onClick: props.onRun,
1022
+ title: "对六家各跑一次配置体检(只看不改:不建目录、不改配置)"
1023
+ }, props.running ? "体检中…" : "体检一次")), react.createElement("div", {
1024
+ style: HEALTH_STYLE.lightsRow,
1025
+ "data-ilife-health": "lights"
1026
+ }, props.lights.map((light) => react.createElement("button", {
1027
+ key: light.id,
1028
+ type: "button",
1029
+ "data-ilife-health": "light",
1030
+ "data-skill": light.id,
1031
+ style: light.status === null ? {
1032
+ ...HEALTH_STYLE.light,
1033
+ ...HEALTH_STYLE.lightIdle
1034
+ } : {
1035
+ ...HEALTH_STYLE.light,
1036
+ borderColor: STATUS_COLOR[light.status],
1037
+ borderWidth: 1.5,
1038
+ background: STATUS_TINT[light.status]
1039
+ },
1040
+ disabled: light.status === null,
1041
+ onClick: () => props.onJump(light.id),
1042
+ title: light.status === null ? "还没体检(或这一家没装)" : light.title + ":" + countsText(light.counts)
1043
+ }, react.createElement("span", { style: {
1044
+ width: 10,
1045
+ height: 10,
1046
+ borderRadius: "50%",
1047
+ background: light.status === null ? INK_DIM : STATUS_COLOR[light.status],
1048
+ display: "inline-block"
1049
+ } }), react.createElement("span", { style: {
1050
+ color: INK,
1051
+ fontWeight: 650
1052
+ } }, light.title), react.createElement("span", { style: {
1053
+ color: light.status === null ? INK_DIM : STATUS_TEXT[light.status],
1054
+ fontWeight: 700
1055
+ } }, light.status === null ? "—" : countsText(light.counts))))), react.createElement("div", { style: HEALTH_STYLE.meta }, props.error !== null ? props.error : "只看不改 · 灯=那一家最严重的一档 · 点一下跳到那家配置页"));
1056
+ }
1057
+ /** 一家那张表:每条一行,正常的也都在(绿行字淡一点)。 */
1058
+ function HealthTable(props) {
1059
+ const { report } = props;
1060
+ return react.createElement("div", {
1061
+ style: HEALTH_STYLE.box,
1062
+ "data-ilife-health": "table"
1063
+ }, react.createElement("div", { style: HEALTH_STYLE.headRow }, react.createElement("div", { style: HEALTH_STYLE.head }, props.title + " · 体检"), report ? react.createElement("div", { style: {
1064
+ color: INK_DIM,
1065
+ fontSize: 12
1066
+ } }, countsText(countByStatus(report.items))) : null), props.phase === "idle" ? react.createElement("div", { style: HEALTH_STYLE.meta }, "还没体检:点上面那个「体检一次」。") : null, props.phase === "running" ? react.createElement("div", { style: HEALTH_STYLE.meta }, "正在体检…") : null, props.error !== null ? react.createElement("div", { style: HEALTH_STYLE.error }, props.error) : null, report ? react.createElement("div", null, react.createElement("div", { style: HEALTH_STYLE.meta }, "配置文件 " + report.configPath + " · 数据目录 " + report.dataDir), report.items.map((item) => react.createElement("div", {
1067
+ key: item.id,
1068
+ style: {
1069
+ padding: "7px 8px",
1070
+ margin: "2px 0",
1071
+ borderTop: "1px solid " + BORDER,
1072
+ borderRadius: 6,
1073
+ fontSize: 12.5,
1074
+ lineHeight: 1.7,
1075
+ background: isAttention(item.status) ? "color-mix(in srgb, " + STATUS_COLOR[item.status] + " 14%, transparent)" : "transparent"
1076
+ }
1077
+ }, react.createElement("div", { style: {
1078
+ display: "flex",
1079
+ flexWrap: "wrap",
1080
+ alignItems: "center",
1081
+ gap: 6
1082
+ } }, react.createElement("span", { style: {
1083
+ width: 8,
1084
+ height: 8,
1085
+ borderRadius: "50%",
1086
+ display: "inline-block",
1087
+ background: STATUS_COLOR[item.status]
1088
+ } }), react.createElement("span", { style: {
1089
+ fontWeight: 650,
1090
+ color: INK
1091
+ } }, item.title), react.createElement("span", { style: {
1092
+ color: isAttention(item.status) ? STATUS_TEXT[item.status] : INK_DIM,
1093
+ fontWeight: 700
1094
+ } }, STATUS_LABEL[item.status]), item.source ? react.createElement("span", { style: { color: INK_DIM } }, "· 来自" + item.source) : null), react.createElement("div", { style: { color: isAttention(item.status) ? INK : INK_DIM } }, item.message), item.action.length > 0 ? react.createElement("div", { style: { color: INK_DIM } }, "去哪修:" + actionText(item.action)) : null))) : null);
1095
+ }
1096
+ //#endregion
699
1097
  //#region src/client.ts
700
1098
  /** dsh-life-pack client 适配器(六边形:browser 侧;爱生活单卡方案)。
701
1099
  *
@@ -902,6 +1300,10 @@ window.__ModuleLoader__.load({
902
1300
  const rows = props.useTabs((value) => value);
903
1301
  const present = new Set(rows.map((r) => r.id));
904
1302
  const face = useUpdateRows(props.getCall);
1303
+ const health = useHealthPanel(() => props.getCall(), react.useMemo(() => rows.filter((row) => row.channel.length > 0).map((row) => ({
1304
+ id: row.id,
1305
+ channel: row.channel
1306
+ })), [rows]));
905
1307
  const [activeId, setActiveId] = react.useState(void 0);
906
1308
  const [visitedIds, setVisitedIds] = react.useState(() => /* @__PURE__ */ new Set());
907
1309
  const active = MANAGER_TABS.some((t) => t.plugin === activeId) ? activeId : MANAGER_TABS[0].plugin;
@@ -915,6 +1317,13 @@ window.__ModuleLoader__.load({
915
1317
  const row = rows.find((r) => r.id === tab.plugin);
916
1318
  return row && row.label.length > 0 ? row.label : tab.title;
917
1319
  }
1320
+ /** 按包名取页签标题(总览那行按账本行过,账本里没有的退到导航表的标题)。 */
1321
+ function labelForTab(id) {
1322
+ const row = rows.find((r) => r.id === id);
1323
+ if (row && row.label.length > 0) return row.label;
1324
+ const tab = MANAGER_TABS.find((t) => t.plugin === id);
1325
+ return tab ? tab.title : id;
1326
+ }
918
1327
  function targetForTab(tab) {
919
1328
  return face.targets.find((t) => t.packageName === tab.plugin) ?? null;
920
1329
  }
@@ -940,7 +1349,16 @@ window.__ModuleLoader__.load({
940
1349
  setActiveId(next.plugin);
941
1350
  tabRefs.current[nextIndex]?.focus();
942
1351
  }
943
- return react.createElement("div", null, react.createElement("div", { style: S.headRow }, react.createElement("div", { style: S.head }, "爱生活"), react.createElement("div", { style: S.headActions }, react.createElement(CheckUpdateButton, { face }), react.createElement(PanelActions, null))), react.createElement("div", { style: S.meta }, react.createElement("div", null, "总开关 · 开关(缺省启用,只读)"), react.createElement("div", null, "总管 dsh-life-pack · 0.2.1")), react.createElement(UpdateResults, { face }), react.createElement("div", {
1352
+ return react.createElement("div", null, react.createElement("div", { style: S.headRow }, react.createElement("div", { style: S.head }, "爱生活"), react.createElement("div", { style: S.headActions }, react.createElement(CheckUpdateButton, { face }), react.createElement(PanelActions, null))), react.createElement("div", { style: S.meta }, react.createElement("div", null, "总开关 · 开关(缺省启用,只读)"), react.createElement("div", null, "总管 dsh-life-pack · 0.2.3")), react.createElement(UpdateResults, { face }), react.createElement(HealthOverview, {
1353
+ lights: lightsOf(rows.map((row) => ({
1354
+ id: row.id,
1355
+ title: labelForTab(row.id)
1356
+ })), Object.fromEntries(Object.entries(health.rows).map(([id, row]) => [id, row.report ?? void 0]))),
1357
+ running: health.running,
1358
+ error: health.error,
1359
+ onRun: health.run,
1360
+ onJump: setActiveId
1361
+ }), react.createElement("div", {
944
1362
  role: "tablist",
945
1363
  "aria-label": "爱生活技能页签",
946
1364
  style: S.tablist
@@ -976,7 +1394,12 @@ window.__ModuleLoader__.load({
976
1394
  role: "tabpanel",
977
1395
  "aria-labelledby": tabsId + "-tab-" + tab.plugin,
978
1396
  hidden: !selected
979
- }, present.has(tab.plugin) ? props.renderSlot(CONFIG_TAB_SLOT, {}, { only: tab.plugin }) : react.createElement(AbsentCard, {
1397
+ }, present.has(tab.plugin) ? react.createElement("div", null, props.renderSlot(CONFIG_TAB_SLOT, {}, { only: tab.plugin }), react.createElement(HealthTable, {
1398
+ title: labelFor(tab),
1399
+ phase: health.rows[tab.plugin]?.phase ?? "idle",
1400
+ report: health.rows[tab.plugin]?.report ?? null,
1401
+ error: health.rows[tab.plugin]?.error ?? null
1402
+ })) : react.createElement(AbsentCard, {
980
1403
  target: targetForTab(tab),
981
1404
  fallbackCommand: recoFor(tab).installCmd,
982
1405
  face
@@ -995,7 +1418,8 @@ window.__ModuleLoader__.load({
995
1418
  tabs = ctx.slots.entries(CONFIG_TAB_SLOT).map((entry) => ({
996
1419
  id: entry.options.id ?? "",
997
1420
  order: entry.options.order ?? 0,
998
- label: resolveLabel(entry.options.label)
1421
+ label: resolveLabel(entry.options.label),
1422
+ channel: entry.options.channel ?? ""
999
1423
  })).sort((a, b) => a.order - b.order);
1000
1424
  }
1001
1425
  return tabs;