dsh-life-pack 0.2.2 → 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
  });
@@ -499,7 +501,7 @@ window.__ModuleLoader__.load({
499
501
  marginTop: 4
500
502
  }
501
503
  };
502
- const IDLE = {
504
+ const IDLE$1 = {
503
505
  phase: "idle",
504
506
  outcome: null,
505
507
  failure: null
@@ -564,7 +566,7 @@ window.__ModuleLoader__.load({
564
566
  targets
565
567
  ]),
566
568
  act: react.useCallback((target) => {
567
- const current = rows[target.key] ?? IDLE;
569
+ const current = rows[target.key] ?? IDLE$1;
568
570
  patch(target.key, {
569
571
  ...current,
570
572
  phase: "installing",
@@ -705,6 +707,393 @@ window.__ModuleLoader__.load({
705
707
  }, busy ? "正在装,请稍候…" : "装上") : null, reason ? react.createElement("div", { style: PANEL_STYLE.reason }, reason) : null, react.createElement("code", { style: PANEL_STYLE.cmd }, manual));
706
708
  }
707
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
708
1097
  //#region src/client.ts
709
1098
  /** dsh-life-pack client 适配器(六边形:browser 侧;爱生活单卡方案)。
710
1099
  *
@@ -911,6 +1300,10 @@ window.__ModuleLoader__.load({
911
1300
  const rows = props.useTabs((value) => value);
912
1301
  const present = new Set(rows.map((r) => r.id));
913
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]));
914
1307
  const [activeId, setActiveId] = react.useState(void 0);
915
1308
  const [visitedIds, setVisitedIds] = react.useState(() => /* @__PURE__ */ new Set());
916
1309
  const active = MANAGER_TABS.some((t) => t.plugin === activeId) ? activeId : MANAGER_TABS[0].plugin;
@@ -924,6 +1317,13 @@ window.__ModuleLoader__.load({
924
1317
  const row = rows.find((r) => r.id === tab.plugin);
925
1318
  return row && row.label.length > 0 ? row.label : tab.title;
926
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
+ }
927
1327
  function targetForTab(tab) {
928
1328
  return face.targets.find((t) => t.packageName === tab.plugin) ?? null;
929
1329
  }
@@ -949,7 +1349,16 @@ window.__ModuleLoader__.load({
949
1349
  setActiveId(next.plugin);
950
1350
  tabRefs.current[nextIndex]?.focus();
951
1351
  }
952
- 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.2")), 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", {
953
1362
  role: "tablist",
954
1363
  "aria-label": "爱生活技能页签",
955
1364
  style: S.tablist
@@ -985,7 +1394,12 @@ window.__ModuleLoader__.load({
985
1394
  role: "tabpanel",
986
1395
  "aria-labelledby": tabsId + "-tab-" + tab.plugin,
987
1396
  hidden: !selected
988
- }, 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, {
989
1403
  target: targetForTab(tab),
990
1404
  fallbackCommand: recoFor(tab).installCmd,
991
1405
  face
@@ -1004,7 +1418,8 @@ window.__ModuleLoader__.load({
1004
1418
  tabs = ctx.slots.entries(CONFIG_TAB_SLOT).map((entry) => ({
1005
1419
  id: entry.options.id ?? "",
1006
1420
  order: entry.options.order ?? 0,
1007
- label: resolveLabel(entry.options.label)
1421
+ label: resolveLabel(entry.options.label),
1422
+ channel: entry.options.channel ?? ""
1008
1423
  })).sort((a, b) => a.order - b.order);
1009
1424
  }
1010
1425
  return tabs;