@modesto_ma/dsh-channels 0.1.0-beta.1 → 0.2.0

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
@@ -20,6 +20,11 @@ window.__ModuleLoader__.load({
20
20
  const PANEL_ROUTE = "/dsh-channels/panel";
21
21
  const CONFIG_ROUTE = "/dsh-channels/config";
22
22
  const CREDENTIALS_ROUTE = "/dsh-channels/credentials";
23
+ /**
24
+ * Connection lifecycle action (POST). While the channel runs it restarts the
25
+ * long connection; while it is stopped it opens one — so the panel has exactly
26
+ * one "connect / reconnect" button. Credentials are resolved again either way.
27
+ */
23
28
  const RECONNECT_ROUTE = "/dsh-channels/reconnect";
24
29
  /** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
25
30
  const CATALOG_ROUTE = "/dsh-channels/catalog";
@@ -31,25 +36,115 @@ window.__ModuleLoader__.load({
31
36
  const ACCESS_USERS_ROUTE = "/dsh-channels/access/users";
32
37
  /** 结算一条待处理授权申请(POST,body `{ token, decision }`)。 */
33
38
  const ACCESS_REQUESTS_ROUTE = "/dsh-channels/access/requests";
39
+ /**
40
+ * 打开宿主机的系统目录选择框(POST,无请求体),返回绝对路径。
41
+ *
42
+ * 只有宿主挂载了 `directoryPicker` 的 native 后端(桌面/本地 GUI 会话)才
43
+ * 会注册实际选择逻辑;远程/无头会话的 browse 后端不可用,面板不下发
44
+ * [`EditableFieldWire.browse`],保留手写输入。
45
+ */
46
+ const PICK_DIRECTORY_ROUTE = "/dsh-channels/pick-directory";
47
+ /**
48
+ * Top-level panel tabs, in render order. The panel shows the connection state
49
+ * header above every tab, so switching tabs never hides whether the bridge is
50
+ * connected.
51
+ */
52
+ const PANEL_TABS = [
53
+ {
54
+ id: "connection",
55
+ label: "连接"
56
+ },
57
+ {
58
+ id: "sessions",
59
+ label: "会话与行为"
60
+ },
61
+ {
62
+ id: "model",
63
+ label: "模型"
64
+ },
65
+ {
66
+ id: "workspace",
67
+ label: "工作区"
68
+ },
69
+ {
70
+ id: "access",
71
+ label: "访问授权"
72
+ },
73
+ {
74
+ id: "advanced",
75
+ label: "高级"
76
+ }
77
+ ];
78
+ /** Which editable field groups each tab renders (the tabs own one or two). */
79
+ const TAB_FIELD_GROUPS = {
80
+ connection: [],
81
+ sessions: ["behavior", "content"],
82
+ model: ["model"],
83
+ workspace: ["workspace"],
84
+ access: ["access"],
85
+ advanced: ["streaming"]
86
+ };
87
+ /** Section heading for one field group inside its tab. */
88
+ const FIELD_GROUP_LABELS = {
89
+ workspace: "工作区与文件",
90
+ behavior: "行为",
91
+ content: "内容与附件",
92
+ model: "模型与推理",
93
+ streaming: "流式卡片与日志",
94
+ access: "准入与授权"
95
+ };
34
96
  /** Display labels for the panel header/cards (kept on the wire so the client owns no copy). */
35
97
  const PANEL_COPY = {
36
98
  title: "飞书渠道",
37
- description: "飞书长连接状态、应用凭据与常用配置。凭据写入 DSH 凭据库且不回显;标注「需重连」的项需重启飞书连接生效。",
99
+ description: "飞书长连接、应用凭据与渠道配置。凭据只写入 DSH 凭据库且不回显;标注「需重连」的项在连接建立时读取。",
38
100
  statusCard: "连接状态",
101
+ phaseUnconfigured: "待配置",
102
+ phaseIdle: "未连接",
103
+ phaseConnecting: "连接中",
104
+ phaseConnected: "已连接",
105
+ phaseError: "连接失败",
106
+ configuredBadge: "凭据已配置",
107
+ unconfiguredBadge: "凭据未配置",
108
+ statusSummaryConnected: "飞书长连接已建立,机器人可以收发消息。",
109
+ statusSummaryUnconfigured: "尚未配置飞书应用凭据:插件已加载,但不会连接飞书。填写凭据并保存后会自动连接。",
110
+ statusSummaryIdle: "飞书应用凭据已就绪,但当前没有建立长连接。",
111
+ statusSummaryConnecting: "正在连接飞书长连接…",
112
+ statusSummaryError: "最近一次连接飞书失败,请检查凭据与网络后重试。",
113
+ lastError: "最近一次连接失败",
114
+ missingConfig: "缺少配置",
115
+ statusHintUnconfigured: "配置完成后插件会自动连接;也可以稍后在本页手动连接。",
116
+ connectionInfo: "连接信息",
117
+ connectionStateLabel: "飞书长连接",
118
+ botOpenId: "Bot open_id",
119
+ uptime: "运行时长",
120
+ routeCount: "话题路由",
121
+ liveAgentCount: "活动会话",
122
+ inflightTurnCount: "在途回复",
123
+ healthReasons: "健康判定",
124
+ unknownValue: "—",
125
+ connect: "连接飞书",
126
+ connecting: "连接中…",
127
+ onboardingTitle: "开始使用飞书渠道",
128
+ onboardingIntro: "只需三步:在飞书开放平台创建自建应用,把 App ID / App Secret 填入本页,保存后自动连接。",
129
+ onboardingStep1Title: "创建飞书自建应用",
130
+ onboardingStep1Text: "在飞书开放平台创建企业自建应用,开启机器人能力;事件订阅选择「长连接」,订阅 im.message.receive_v1(接收消息)。",
131
+ onboardingStep2Title: "填写应用凭据",
132
+ onboardingStep2Text: "把应用的 App ID(cli_ 开头)与 App Secret 填入下方「飞书应用凭据」,值会写入 DSH 凭据库。",
133
+ onboardingStep3Title: "保存并等待连接",
134
+ onboardingStep3Text: "两项凭据都保存后插件会自动连接;状态变为「已连接」即可在飞书里私聊机器人。",
135
+ onboardingDocsHint: "开放平台地址:open.feishu.cn/app;应用需要开通 im:message、im:message:send_as_bot 等消息权限。",
39
136
  credentialsCard: "飞书应用凭据",
40
- modelCard: "模型与推理",
41
- modelCardHelp: "模型服务商、模型 id、推理档位与新会话的 Agent 预设。下拉选项来自宿主机实时目录;保存后对新会话生效。",
42
- configCard: "常用配置",
43
- advancedCard: "只读与高级信息",
137
+ credentialsCardHelp: "凭据值经 DSH 凭据服务写入本机凭据库;面板只显示「已配置 / 未配置」,永不回显已存值。两项都配置后才会连接飞书。",
44
138
  appIdLabel: "飞书 App ID",
45
139
  appSecretLabel: "飞书 App Secret",
46
- credentialsHelp: "值通过 DSH 凭据服务写入本机凭据库;面板只显示是否已配置,永不回显已存值。留空表示保持当前值。",
47
140
  credentialsRef: "凭据引用",
48
141
  saveCredentials: "保存凭据",
49
142
  savingCredentials: "保存中…",
50
143
  credentialSaved: "凭据已保存",
51
- credentialSavedReconnected: "凭据已保存,已自动重连飞书通道",
144
+ credentialSavedReconnected: "凭据已保存,飞书已连接",
52
145
  credentialSavedPending: "凭据已保存,重启飞书连接后生效",
146
+ credentialSavedUnconfigured: "凭据已保存;还缺少另一项凭据,补齐后会自动连接",
147
+ credentialAutoConnectFailed: "凭据已保存,但自动连接失败",
53
148
  credentialRemoved: "已移除存储的凭据",
54
149
  credentialRemovedPending: "已移除存储的凭据,重启飞书连接后生效",
55
150
  removeCredential: "移除已存值",
@@ -59,35 +154,56 @@ window.__ModuleLoader__.load({
59
154
  credentialReadOnly: "当前凭据存储不可写",
60
155
  appIdPlaceholder: "输入 App ID(cli_…)",
61
156
  appSecretPlaceholder: "输入 App Secret",
62
- save: "保存配置",
63
- saveAll: "保存全部配置",
64
- saveModel: "保存模型配置",
65
- modelActionsHint: "只保存模型与推理字段",
157
+ credentialsPendingHint: "填写后点「保存凭据」;两项齐全时插件会自动连接飞书。",
158
+ connectionTabHelp: "连接状态与飞书应用凭据。凭据不完整时不会连接飞书。",
159
+ sessionsTabHelp: "私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。",
160
+ modelTabHelp: "飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。",
161
+ workspaceTabHelp: "每个飞书话题一个独立工作区(模型/工具产生文件的落点);只影响之后新建的话题。",
162
+ accessTabHelp: "准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。",
163
+ advancedTabHelp: "流式刷新等低层参数、Web 覆盖文件与只读信息。",
164
+ save: "保存更改",
66
165
  saving: "保存中…",
166
+ saveHintDirty: "只保存改动过的字段;标注「需重连」的项在下次连接时生效。",
167
+ discardTab: "放弃本页更改",
168
+ saveHintClean: "没有未保存的更改。",
169
+ dirtyCount: "未保存项",
170
+ browseDirectory: "选择目录…",
171
+ browsingDirectory: "选择中…",
172
+ directoryPicked: "已选择目录",
173
+ directoryPickFailed: "无法打开系统目录选择器",
174
+ saved: "已保存",
175
+ cleared: "已清除 Web 覆盖,恢复为 profile 配置",
67
176
  clear: "清除 Web 覆盖",
68
177
  clearing: "清除中…",
178
+ noOverrides: "当前没有 Web 覆盖",
179
+ overridden: "Web 覆盖",
180
+ inherited: "继承 profile",
181
+ restartTag: "需重连",
182
+ restartBanner: "以下配置需重启飞书连接后生效:",
183
+ restartWarning: "重启会断开当前飞书长连接并中断进行中的回复,请确认空闲时操作。",
69
184
  reconnect: "重启飞书连接",
70
185
  reconnecting: "重连中…",
71
186
  refresh: "刷新状态",
72
187
  retry: "重试",
188
+ pollFailed: "状态刷新失败",
189
+ connectDone: "已连接飞书",
190
+ reconnectDone: "已重启飞书连接",
191
+ saveInvalid: "请先修正标红的配置项",
192
+ enabled: "已启用",
193
+ disabled: "已关闭",
194
+ credentialRemoveConfirm: "确定移除已存储的凭据值?移除后不会回显,需要时须重新填写。",
195
+ accessNotLoaded: "飞书连接建立后加载授权列表",
73
196
  loading: "加载中…",
74
197
  loadFailed: "设置面板加载失败",
75
- saved: "已保存",
76
- savedModel: "模型配置已保存",
77
- cleared: "已清除 Web 覆盖,恢复为 profile 配置",
78
198
  catalogFailed: "模型目录加载失败,已退回手填。",
79
199
  catalogLoading: "正在读取宿主机选项…",
80
200
  catalogUnavailable: "宿主机未提供可选项,请手填。",
81
- noOverrides: "当前没有 Web 覆盖",
82
- overridden: "Web 覆盖",
83
- inherited: "继承 profile",
84
- restartTag: "需重连",
85
- restartBanner: "以下配置需重启飞书连接后生效:",
86
- restartWarning: "重启会断开当前飞书长连接并中断进行中的回复,请确认空闲时操作。",
87
- lastError: "最近一次启动失败",
88
- notRunning: "未运行",
89
201
  version: "插件版本",
90
202
  overlayPath: "覆盖文件",
203
+ overlayPathHelp: "可以直接编辑该 JSON 文件;面板保存会整体重写它,「清除 Web 覆盖」会删除它并回落到 profile 配置。",
204
+ advancedCard: "只读与高级信息",
205
+ advancedHelp: "这些值来自 profile 配置,不能在面板里编辑。",
206
+ readOnlyCard: "部署级参数",
91
207
  cleanupCard: "工作区清理",
92
208
  cleanupCardHelp: "删除超过保留期且不再活跃的飞书工作区目录;只会删除本插件在「工作区根目录」下创建的目录,DSH 会话日志不删除。删除不可恢复:相关旧话题会失效并提示已过期,工作区列表记录同步移除。",
93
209
  cleanupRetention: "保留期",
@@ -102,7 +218,7 @@ window.__ModuleLoader__.load({
102
218
  cleanupRunning: "清理中…",
103
219
  cleanupConfirm: "确定清理过期工作区吗?该操作不可恢复,会删除这些工作区内的全部文件(包括 attachments/ 附件)。",
104
220
  cleanupConfirmHint: "删除前会再次确认;保留期为「关闭」或没有过期工作区时按钮不可用。",
105
- cleanupDisabledOff: "保留策略为「关闭」:请先在「常用配置 → 工作区与文件」中选择保留天数。",
221
+ cleanupDisabledOff: "保留策略为「关闭」:请先在上方「过期工作区保留期」中选择保留天数。",
106
222
  cleanupDisabledEmpty: "没有超过保留期的过期工作区。",
107
223
  cleanupPreviewFailed: "无法读取清理预览",
108
224
  cleanupDone: "清理完成",
@@ -112,12 +228,9 @@ window.__ModuleLoader__.load({
112
228
  cleanupFreed: "释放空间",
113
229
  cleanupFailures: "以下工作区未能删除:",
114
230
  accessCard: "访问与授权",
115
- accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员 open_id 在「常用配置 → 行为」中填写,私聊机器人发送 /userid 获取。",
231
+ accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。",
116
232
  accessModeOpenWarning: "当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。",
117
- accessAdminMissingWarning: "当前准入=guarded 但未配置管理员,所有对话都会被拒绝。请先在「常用配置 → 行为 → 管理员 open_id」填写。",
118
- accessAdminLabel: "管理员",
119
- accessAdminMissing: "未配置",
120
- accessAdminHint: "修改管理员请到「常用配置 → 行为 → 管理员 open_id」;私聊机器人发送 /userid 可查看自己的 open_id(ou_ 开头,不是 user_id/邮箱)。",
233
+ accessAdminMissingWarning: "当前准入=guarded 但未配置管理员,所有对话都会被拒绝。请在上方「准入与授权」填写管理员 open_id。",
121
234
  accessGroupsTitle: "已授权群",
122
235
  accessGroupsEmpty: "尚未授权任何群。机器人被拉进群后,管理员会收到带 chat_id 的群信息卡;也可在群内发送 /groupid 获取。",
123
236
  accessUsersTitle: "已授权用户",
@@ -275,6 +388,24 @@ window.__ModuleLoader__.load({
275
388
  }
276
389
  return JSON.parse(text);
277
390
  }
391
+ /**
392
+ * Ask the Host to open its native directory chooser (the panel's「选择目录…」
393
+ * button). The request stays open while the OS dialog does; a cancel comes back
394
+ * as `{ ok: true, canceled: true }`, not as an error.
395
+ */
396
+ async function pickDirectory() {
397
+ const response = await request(PICK_DIRECTORY_ROUTE, { method: "POST" });
398
+ const text = await response.text();
399
+ if (!response.ok) try {
400
+ return JSON.parse(text);
401
+ } catch {
402
+ return {
403
+ ok: false,
404
+ error: errorFromBody(response.status, text)
405
+ };
406
+ }
407
+ return JSON.parse(text);
408
+ }
278
409
  /** Store provided credential values (the request/response never carry stored values). */
279
410
  async function saveCredentials(values) {
280
411
  const response = await request(CREDENTIALS_ROUTE, {
@@ -367,7 +498,7 @@ window.__ModuleLoader__.load({
367
498
  * 浏览器半区的设置面板选择器目录折叠:把 Host 下发的字段描述与实时目录
368
499
  * (`/dsh-channels/catalog`)合成通用表单需要的 select 描述。
369
500
  *
370
- * 拆成独立模块(不含 JSX/React)以便单测;`panel.tsx` 只负责串起状态与渲染。
501
+ * 拆成独立模块(不含 JSX/React)以便单测;`panel/index.tsx` 只负责串起状态与渲染。
371
502
  *
372
503
  * @module dsh-channels/client/catalog
373
504
  */
@@ -455,31 +586,16 @@ window.__ModuleLoader__.load({
455
586
  });
456
587
  }
457
588
  //#endregion
458
- //#region src/client/panel.tsx
589
+ //#region src/client/panel/shared.tsx
459
590
  /**
460
- * 设置 →「飞书渠道」页面。
591
+ * 设置面板共享层:页面状态类型、Host 字段渲染原语(FieldRow / FieldsCard /
592
+ * SaveBar)、生命周期徽标与纯格式化工具。
461
593
  *
462
- * 数据全部来自 Host 的 `/dsh-channels/*` 路由:状态卡每 5 秒轮询一次,
463
- * 常用配置表单由 Host 下发的字段描述(`fields`)通用渲染,Host 是唯一的
464
- * 校验与持久化方。组件不 import 任何 DSH 客户端包,只依赖 react 与平台
465
- * 的 `settings.section` 槽位。
594
+ * 这一层不发请求、不持有页面状态,只把 Host 下发的 wire 数据渲染成控件;
595
+ * 页面外壳在 `index.tsx`,各 tab 只组合这些原语。
466
596
  *
467
- * @module dsh-channels/client/panel
597
+ * @module dsh-channels/client/panel/shared
468
598
  */
469
- const GROUP_ORDER = [
470
- "workspace",
471
- "behavior",
472
- "content",
473
- "model",
474
- "streaming"
475
- ];
476
- const GROUP_LABELS = {
477
- workspace: "工作区与文件",
478
- behavior: "行为",
479
- content: "内容与附件",
480
- model: "模型",
481
- streaming: "流式卡片"
482
- };
483
599
  function messageOf(error) {
484
600
  return error instanceof Error ? error.message : String(error);
485
601
  }
@@ -502,12 +618,9 @@ window.__ModuleLoader__.load({
502
618
  const digits = unit === 0 || value >= 10 ? 0 : 1;
503
619
  return `${value.toFixed(digits)} ${units[unit] ?? "B"}`;
504
620
  }
505
- /** Display label for the effective retention value, reusing the Host field options. */
506
- function retentionLabel(panel, cleanup) {
507
- return panel.config.fields.find((item) => item.key === "workspaceRetention")?.options?.find((option) => option.value === cleanup.retention)?.label ?? (cleanup.retention === "off" ? PANEL_COPY.cleanupRetentionOff : cleanup.retention);
508
- }
621
+ /** Compact duration for the connection card. */
509
622
  function formatUptime(ms) {
510
- if (ms === void 0 || !Number.isFinite(ms)) return "—";
623
+ if (ms === void 0 || !Number.isFinite(ms)) return PANEL_COPY.unknownValue;
511
624
  const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
512
625
  const days = Math.floor(totalSeconds / 86400);
513
626
  const hours = Math.floor(totalSeconds % 86400 / 3600);
@@ -517,81 +630,18 @@ window.__ModuleLoader__.load({
517
630
  if (minutes > 0) return `${minutes} 分 ${totalSeconds % 60} 秒`;
518
631
  return `${totalSeconds} 秒`;
519
632
  }
520
- function connectionLabel(state) {
521
- switch (state) {
522
- case "connected": return {
523
- text: "已连接",
524
- tone: "ok"
525
- };
526
- case "connecting": return {
527
- text: "连接中",
528
- tone: "warn"
529
- };
530
- case "reconnecting": return {
531
- text: "重连中",
532
- tone: "warn"
533
- };
534
- case "failed": return {
535
- text: "连接失败",
536
- tone: "bad"
537
- };
538
- case "idle": return {
539
- text: "未连接",
540
- tone: "muted"
541
- };
542
- default: return {
543
- text: state ?? "未知",
544
- tone: "muted"
545
- };
546
- }
547
- }
548
- function statusBadge(status) {
549
- if (status.busy) return {
550
- text: "重连中",
551
- tone: "warn"
552
- };
553
- if (!status.running) return {
554
- text: "未运行",
555
- tone: "bad"
556
- };
557
- if (status.level === "healthy") return {
558
- text: "运行中 · 健康",
559
- tone: "ok"
560
- };
561
- if (status.level === "degraded") return {
562
- text: "运行中 · 降级",
563
- tone: "warn"
564
- };
565
- return {
566
- text: "运行中 · 异常",
567
- tone: "bad"
568
- };
569
- }
570
- /** Local `YYYY-MM-DD HH:mm` rendering for authorization timestamps. */
571
- function formatAccessDate(ms) {
572
- if (!Number.isFinite(ms) || ms <= 0) return "—";
573
- const date = new Date(ms);
574
- const pad = (value) => String(value).padStart(2, "0");
575
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
633
+ /** Effective value for one key (falling back to the profile base). */
634
+ function effectiveValue(panel, key) {
635
+ return panel.config.effective[key] ?? panel.config.base[key];
576
636
  }
577
- /** Which authorization entry produced one grant. */
578
- function accessSourceLabel(source) {
579
- switch (source) {
580
- case "command": return PANEL_COPY.accessSourceCommand;
581
- case "card": return PANEL_COPY.accessSourceCard;
582
- default: return PANEL_COPY.accessSourcePanel;
583
- }
637
+ /** Host label for one editable key; the raw key is the technical fallback. */
638
+ function fieldLabel(fields, key) {
639
+ return fields.find((field) => field.key === key)?.label ?? key;
584
640
  }
585
641
  /** Effective access-mode label, reusing the Host field options for consistency. */
586
642
  function accessModeLabel(panel, mode) {
587
643
  return panel.config.fields.find((item) => item.key === "accessMode")?.options?.find((option) => option.value === mode)?.label ?? mode;
588
644
  }
589
- function fieldLabel(fields, key) {
590
- return fields.find((field) => field.key === key)?.label ?? key;
591
- }
592
- function effectiveValue(panel, key) {
593
- return panel.config.effective[key] ?? panel.config.base[key];
594
- }
595
645
  /** Numeric draft kept as the raw input string; parsed on save. */
596
646
  function numericDraft(field, raw) {
597
647
  if (typeof raw === "number") return raw;
@@ -599,12 +649,16 @@ window.__ModuleLoader__.load({
599
649
  const parsed = Number(raw);
600
650
  return Number.isFinite(parsed) ? parsed : void 0;
601
651
  }
602
- function validateDraft(panel, draft, only) {
652
+ /**
653
+ * Turn the dirty draft into the wire payload, keeping the Host-descriptor
654
+ * bounds check client-side. Field messages stay technical (the Host re-validates
655
+ * every value on save).
656
+ */
657
+ function validateDraft(panel, draft) {
603
658
  const values = {};
604
659
  const issues = [];
605
660
  for (const field of panel.config.fields) {
606
661
  if (!(field.key in draft)) continue;
607
- if (only !== void 0 && !only.has(field.key)) continue;
608
662
  const raw = draft[field.key];
609
663
  if (field.kind === "number") {
610
664
  const parsed = numericDraft(field, raw);
@@ -643,9 +697,81 @@ window.__ModuleLoader__.load({
643
697
  issues
644
698
  };
645
699
  }
700
+ /** Header badge for the Host-derived lifecycle phase. */
701
+ function phaseBadge(phase) {
702
+ switch (phase) {
703
+ case "connected": return {
704
+ text: PANEL_COPY.phaseConnected,
705
+ tone: "ok"
706
+ };
707
+ case "connecting": return {
708
+ text: PANEL_COPY.phaseConnecting,
709
+ tone: "warn"
710
+ };
711
+ case "error": return {
712
+ text: PANEL_COPY.phaseError,
713
+ tone: "bad"
714
+ };
715
+ case "idle": return {
716
+ text: PANEL_COPY.phaseIdle,
717
+ tone: "muted"
718
+ };
719
+ default: return {
720
+ text: PANEL_COPY.phaseUnconfigured,
721
+ tone: "muted"
722
+ };
723
+ }
724
+ }
725
+ /** One-line explanation shown under the page title for the current phase. */
726
+ function phaseSummary(phase) {
727
+ switch (phase) {
728
+ case "connected": return PANEL_COPY.statusSummaryConnected;
729
+ case "connecting": return PANEL_COPY.statusSummaryConnecting;
730
+ case "error": return PANEL_COPY.statusSummaryError;
731
+ case "idle": return PANEL_COPY.statusSummaryIdle;
732
+ default: return PANEL_COPY.statusSummaryUnconfigured;
733
+ }
734
+ }
735
+ /**
736
+ * Text input paired with the host-native「选择目录…」button. The button owns
737
+ * its own busy state: the OS dialog can stay open for a long time, so the
738
+ * rest of the form stays usable while the operator browses.
739
+ */
740
+ function DirectoryTextInput(props) {
741
+ const { field, value, inputClass, disabled, onChange, onPick } = props;
742
+ const [picking, setPicking] = (0, react.useState)(false);
743
+ const pick = async () => {
744
+ setPicking(true);
745
+ try {
746
+ await onPick();
747
+ } finally {
748
+ setPicking(false);
749
+ }
750
+ };
751
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
752
+ className: "dsh-channels-cfg__inputRow",
753
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
754
+ type: "text",
755
+ className: inputClass,
756
+ value: String(value ?? ""),
757
+ placeholder: field.placeholder,
758
+ "aria-label": field.label,
759
+ disabled: disabled || picking,
760
+ onChange: (event) => onChange(field.key, event.target.value)
761
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
762
+ type: "button",
763
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--browse",
764
+ disabled: disabled || picking,
765
+ onClick: () => {
766
+ pick();
767
+ },
768
+ children: picking ? PANEL_COPY.browsingDirectory : PANEL_COPY.browseDirectory
769
+ })]
770
+ });
771
+ }
646
772
  /** One generic form control driven by the Host field descriptor. */
647
773
  function FieldRow(props) {
648
- const { field, value, issues, onChange } = props;
774
+ const { field, value, issues, disabled = false, onChange, onPickDirectory } = props;
649
775
  const issue = issues.find((item) => item.key === field.key);
650
776
  const inputClass = `dsh-channels-cfg__input${issue === void 0 ? "" : " dsh-channels-cfg__input--invalid"}`;
651
777
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -667,13 +793,16 @@ window.__ModuleLoader__.load({
667
793
  type: "checkbox",
668
794
  className: "dsh-channels-cfg__checkbox",
669
795
  checked: value === true,
796
+ "aria-label": field.label,
797
+ disabled,
670
798
  onChange: (event) => onChange(field.key, event.target.checked)
671
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: value === true ? "已启用" : "已关闭" })]
799
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: value === true ? PANEL_COPY.enabled : PANEL_COPY.disabled })]
672
800
  }) : null,
673
801
  field.kind === "select" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
674
802
  className: inputClass,
675
803
  value: String(value ?? ""),
676
- disabled: field.disabled === true,
804
+ disabled: disabled || field.disabled === true,
805
+ "aria-label": field.label,
677
806
  onChange: (event) => onChange(field.key, event.target.value),
678
807
  children: (field.options ?? []).map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
679
808
  value: option.value,
@@ -687,13 +816,24 @@ window.__ModuleLoader__.load({
687
816
  min: field.min,
688
817
  max: field.max,
689
818
  step: field.step,
819
+ "aria-label": field.label,
820
+ disabled,
690
821
  onChange: (event) => onChange(field.key, event.target.value)
691
822
  }) : null,
692
- field.kind === "text" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
823
+ field.kind === "text" ? field.browse === "directory" && onPickDirectory !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DirectoryTextInput, {
824
+ field,
825
+ value,
826
+ inputClass,
827
+ disabled,
828
+ onChange,
829
+ onPick: onPickDirectory
830
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
693
831
  type: "text",
694
832
  className: inputClass,
695
833
  value: String(value ?? ""),
696
834
  placeholder: field.placeholder,
835
+ "aria-label": field.label,
836
+ disabled,
697
837
  onChange: (event) => onChange(field.key, event.target.value)
698
838
  }) : null,
699
839
  field.help === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -707,143 +847,657 @@ window.__ModuleLoader__.load({
707
847
  ]
708
848
  });
709
849
  }
710
- /** Status card: badge, connection facts, and the two panel actions. */
711
- function StatusCard(props) {
712
- const { status, pollError, busy, onRefresh, onReconnect } = props;
713
- const badge = statusBadge(status);
714
- const connection = connectionLabel(status.connection?.state);
715
- const facts = [
716
- {
717
- label: "飞书连接",
718
- value: status.connection === void 0 ? "未知" : connection.text
719
- },
720
- {
721
- label: "Bot open_id",
722
- value: status.botOpenIdTail ?? "—"
723
- },
724
- {
725
- label: "运行时长",
726
- value: formatUptime(status.uptimeMs)
727
- },
728
- {
729
- label: "话题路由",
730
- value: String(status.routeCount)
731
- },
732
- {
733
- label: "活动会话",
734
- value: String(status.liveAgentCount)
735
- },
736
- {
737
- label: "在途回复",
738
- value: String(status.inflightTurnCount)
739
- }
740
- ];
741
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
850
+ /**
851
+ * One tab's editable fields, partitioned by the Host field groups that the tab
852
+ * owns (`TAB_FIELD_GROUPS`). Empty groups render nothing, so a Host that moves
853
+ * every key out of a group does not leave a stray heading behind.
854
+ */
855
+ function FieldsCard(props) {
856
+ const { form, groups } = props;
857
+ const sections = groups.map((group) => ({
858
+ group,
859
+ label: FIELD_GROUP_LABELS[group],
860
+ fields: form.fields.filter((field) => field.group === group)
861
+ })).filter((section) => section.fields.length > 0);
862
+ if (sections.length === 0) return null;
863
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
742
864
  className: "dsh-channels-cfg__card",
865
+ children: sections.map((section) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
866
+ className: "dsh-channels-cfg__group",
867
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
868
+ className: "dsh-channels-cfg__labelRow",
869
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
870
+ className: "dsh-channels-cfg__cardTitle",
871
+ children: section.label
872
+ })
873
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
874
+ className: "dsh-channels-cfg__grid",
875
+ children: section.fields.map((field) => {
876
+ const onPick = form.onPickDirectory;
877
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldRow, {
878
+ field,
879
+ value: form.draft[field.key] ?? effectiveValue(form.panel, field.key),
880
+ issues: form.issues,
881
+ disabled: form.locked,
882
+ onChange: form.onChange,
883
+ onPickDirectory: onPick === void 0 ? void 0 : () => onPick(field.key)
884
+ }, field.key);
885
+ })
886
+ })]
887
+ }, section.group))
888
+ });
889
+ }
890
+ /** The unified per-tab action bar: save every dirty field or drop the draft. */
891
+ function SaveBar(props) {
892
+ const { form, tab } = props;
893
+ const groups = new Set(TAB_FIELD_GROUPS[tab]);
894
+ const pageDirty = form.fields.filter((field) => groups.has(field.group) && field.key in form.draft).length;
895
+ const dirty = form.dirtyCount > 0;
896
+ const disabled = !dirty || form.busy !== null || form.locked;
897
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
898
+ className: "dsh-channels-cfg__footer",
743
899
  children: [
744
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
745
- className: "dsh-channels-cfg__cardHead",
746
- children: [
747
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
748
- className: "dsh-channels-cfg__cardTitle",
749
- children: PANEL_COPY.statusCard
750
- }),
751
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
752
- className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${badge.tone}`,
753
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__dot" }), badge.text]
754
- }),
755
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
756
- className: "dsh-channels-cfg__cardMeta",
757
- children: "每 5 秒自动刷新"
758
- })
759
- ]
760
- }),
761
- status.lastStartError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
762
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
763
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.lastError }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
764
- className: "dsh-channels-cfg__mono",
765
- children: status.lastStartError
766
- })]
767
- }),
768
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
769
- className: "dsh-channels-cfg__facts",
770
- children: facts.map((fact) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
771
- className: "dsh-channels-cfg__fact",
772
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
773
- className: "dsh-channels-cfg__factLabel",
774
- children: fact.label
775
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
776
- className: "dsh-channels-cfg__factValue",
777
- children: fact.value
778
- })]
779
- }, fact.label))
780
- }),
781
- status.reasons.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
782
- className: `dsh-channels-cfg__help${status.level === "unhealthy" ? " dsh-channels-cfg__reasonBad" : ""}`,
783
- children: ["健康判定:", status.reasons.join(";")]
900
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
901
+ type: "button",
902
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
903
+ onClick: form.onSave,
904
+ disabled,
905
+ children: form.busy === "save" ? PANEL_COPY.saving : PANEL_COPY.save
784
906
  }),
785
- pollError === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
786
- className: "dsh-channels-cfg__fieldError",
787
- children: ["状态刷新失败:", pollError]
907
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
908
+ type: "button",
909
+ className: "dsh-channels-cfg__btn",
910
+ onClick: () => form.onDiscardTab(tab),
911
+ disabled: pageDirty === 0 || form.busy !== null || form.locked,
912
+ children: PANEL_COPY.discardTab
788
913
  }),
789
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
790
- className: "dsh-channels-cfg__actions",
914
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
915
+ pageDirty > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
916
+ className: "dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty",
791
917
  children: [
792
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
793
- type: "button",
794
- className: "dsh-channels-cfg__btn",
795
- onClick: onRefresh,
796
- disabled: busy !== null,
797
- children: PANEL_COPY.refresh
798
- }),
799
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
800
- type: "button",
801
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--danger",
802
- onClick: onReconnect,
803
- disabled: busy !== null,
804
- children: busy === "reconnect" ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect
805
- }),
806
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
807
- className: "dsh-channels-cfg__help",
808
- children: PANEL_COPY.restartWarning
809
- })
918
+ PANEL_COPY.dirtyCount,
919
+ " ",
920
+ pageDirty
921
+ ]
922
+ }) : dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
923
+ className: "dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty",
924
+ children: [
925
+ PANEL_COPY.dirtyCount,
926
+ " ",
927
+ form.dirtyCount,
928
+ "(其他标签页)"
810
929
  ]
930
+ }) : null,
931
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
932
+ className: "dsh-channels-cfg__help",
933
+ children: dirty ? PANEL_COPY.saveHintDirty : PANEL_COPY.saveHintClean
811
934
  })
812
935
  ]
813
936
  });
814
937
  }
815
- /** Credential editor: writes through the Host credentials seam, never echoes values. */
816
- function CredentialsCard(props) {
817
- const { panel, appId, appSecret, issues, busy, onAppIdChange, onAppSecretChange, onSave, onClear } = props;
818
- const appIdState = panel.config.credentials.appId;
819
- const appSecretState = panel.config.credentials.appSecret;
820
- const writable = appIdState.writable || appSecretState.writable;
821
- const disabled = busy !== null || !writable;
822
- const pending = appId.trim() !== "" || appSecret.trim() !== "";
823
- const row = (field, label, state, value, type, placeholder, onChange) => {
824
- const issue = issues.find((item) => item.key === field);
825
- const inputClass = `dsh-channels-cfg__input${issue === void 0 ? "" : " dsh-channels-cfg__input--invalid"}`;
826
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
827
- className: "dsh-channels-cfg__field",
828
- children: [
829
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
830
- className: "dsh-channels-cfg__labelRow",
831
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
832
- className: "dsh-channels-cfg__label",
833
- children: label
834
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
835
- className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${state.configured ? "ok" : "bad"}`,
836
- children: state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing
837
- })]
838
- }),
839
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
840
- className: "dsh-channels-cfg__cred",
841
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
842
- type,
843
- className: inputClass,
844
- value,
845
- placeholder,
938
+ /** Muted help paragraph introducing one tab. */
939
+ function TabHelp(props) {
940
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
941
+ className: "dsh-channels-cfg__tabHelp",
942
+ children: props.text
943
+ });
944
+ }
945
+ /** Transient page-level banner (save / credentials / reconnect / catalog). */
946
+ function NoticeBar(props) {
947
+ const { notice } = props;
948
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
949
+ className: `dsh-channels-cfg__notice dsh-channels-cfg__notice--${notice.kind === "ok" ? "ok" : "error"}`,
950
+ role: "status",
951
+ "aria-live": "polite",
952
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: notice.text })
953
+ });
954
+ }
955
+ /** Muted empty/blocked state (e.g. access lists before the channel connects). */
956
+ function EmptyState(props) {
957
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
958
+ className: "dsh-channels-cfg__emptyState",
959
+ children: props.text
960
+ });
961
+ }
962
+ /** Human label for the official SDK's WebSocket lifecycle state. */
963
+ function connectionStateLabel(state) {
964
+ switch (state) {
965
+ case "connected": return {
966
+ text: PANEL_COPY.phaseConnected,
967
+ tone: "ok"
968
+ };
969
+ case "connecting": return {
970
+ text: PANEL_COPY.phaseConnecting,
971
+ tone: "warn"
972
+ };
973
+ case "reconnecting": return {
974
+ text: PANEL_COPY.phaseConnecting,
975
+ tone: "warn"
976
+ };
977
+ case "failed": return {
978
+ text: PANEL_COPY.phaseError,
979
+ tone: "bad"
980
+ };
981
+ case "idle": return {
982
+ text: PANEL_COPY.phaseIdle,
983
+ tone: "muted"
984
+ };
985
+ default: return {
986
+ text: state ?? PANEL_COPY.unknownValue,
987
+ tone: "muted"
988
+ };
989
+ }
990
+ }
991
+ /** Retention label, reusing the Host field options so the copy stays on the Host. */
992
+ function retentionLabel(panel, cleanup) {
993
+ return panel.config.fields.find((item) => item.key === "workspaceRetention")?.options?.find((option) => option.value === cleanup.retention)?.label ?? (cleanup.retention === "off" ? PANEL_COPY.cleanupRetentionOff : cleanup.retention);
994
+ }
995
+ //#endregion
996
+ //#region src/client/panel/access-card.tsx
997
+ /**
998
+ * 访问与授权卡:fail-closed 警告、群/用户白名单增删、待处理申请处理。
999
+ *
1000
+ * `access.loaded === false`(飞书未连接、插件 storage 域未打开)时白名单与
1001
+ * 申请列表是「未知」而不是「空」,因此只渲染连接提示并禁用全部写操作。
1002
+ * 管理员与准入模式由同 tab 上方「准入与授权」字段表单编辑,这里不再重复。
1003
+ *
1004
+ * @module dsh-channels/client/panel/access-card
1005
+ */
1006
+ /** Local `YYYY-MM-DD HH:mm` rendering for authorization timestamps. */
1007
+ function formatAccessDate(ms) {
1008
+ if (!Number.isFinite(ms) || ms <= 0) return PANEL_COPY.unknownValue;
1009
+ const date = new Date(ms);
1010
+ const pad = (value) => String(value).padStart(2, "0");
1011
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
1012
+ }
1013
+ /** Which authorization entry produced one grant. */
1014
+ function accessSourceLabel(source) {
1015
+ switch (source) {
1016
+ case "command": return PANEL_COPY.accessSourceCommand;
1017
+ case "card": return PANEL_COPY.accessSourceCard;
1018
+ default: return PANEL_COPY.accessSourcePanel;
1019
+ }
1020
+ }
1021
+ function AccessCard(props) {
1022
+ const { panel, busy, locked, notice, error, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props;
1023
+ const { access } = panel;
1024
+ const [groupChatId, setGroupChatId] = (0, react.useState)("");
1025
+ const [groupName, setGroupName] = (0, react.useState)("");
1026
+ const [userOpenId, setUserOpenId] = (0, react.useState)("");
1027
+ const [userName, setUserName] = (0, react.useState)("");
1028
+ const groups = access.groups;
1029
+ const users = access.users;
1030
+ const requests = access.requests;
1031
+ const working = busy === "access";
1032
+ const disabled = busy !== null || locked || !access.loaded;
1033
+ const submitGroup = async () => {
1034
+ if (groupChatId.trim() === "") return;
1035
+ if (await onAddGroup(groupChatId, groupName)) {
1036
+ setGroupChatId("");
1037
+ setGroupName("");
1038
+ }
1039
+ };
1040
+ const submitUser = async () => {
1041
+ if (userOpenId.trim() === "") return;
1042
+ if (await onAddUser(userOpenId, userName)) {
1043
+ setUserOpenId("");
1044
+ setUserName("");
1045
+ }
1046
+ };
1047
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1048
+ className: "dsh-channels-cfg__card",
1049
+ children: [
1050
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1051
+ className: "dsh-channels-cfg__cardHead",
1052
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1053
+ className: "dsh-channels-cfg__cardTitle",
1054
+ children: PANEL_COPY.accessCard
1055
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1056
+ className: "dsh-channels-cfg__cardMeta",
1057
+ children: accessModeLabel(panel, access.mode)
1058
+ })]
1059
+ }),
1060
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1061
+ className: "dsh-channels-cfg__help",
1062
+ children: PANEL_COPY.accessCardHelp
1063
+ }),
1064
+ access.mode === "open" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1065
+ className: "dsh-channels-cfg__banner",
1066
+ children: PANEL_COPY.accessModeOpenWarning
1067
+ }) : null,
1068
+ access.loaded && access.mode === "guarded" && !access.admin.configured ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1069
+ className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
1070
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.accessAdminMissingWarning })
1071
+ }) : null,
1072
+ !access.loaded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, { text: PANEL_COPY.accessNotLoaded }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1073
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1074
+ className: "dsh-channels-cfg__accessSection",
1075
+ children: [
1076
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1077
+ className: "dsh-channels-cfg__labelRow",
1078
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1079
+ className: "dsh-channels-cfg__label",
1080
+ children: PANEL_COPY.accessGroupsTitle
1081
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1082
+ className: "dsh-channels-cfg__cardMeta",
1083
+ children: [groups.length, " 个"]
1084
+ })]
1085
+ }),
1086
+ groups.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1087
+ className: "dsh-channels-cfg__help",
1088
+ children: PANEL_COPY.accessGroupsEmpty
1089
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1090
+ className: "dsh-channels-cfg__accessList",
1091
+ children: groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1092
+ className: "dsh-channels-cfg__accessRow",
1093
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1094
+ className: "dsh-channels-cfg__accessMain",
1095
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: group.name ?? group.chatId }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1096
+ className: "dsh-channels-cfg__accessMeta",
1097
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1098
+ className: "dsh-channels-cfg__mono",
1099
+ children: group.chatId
1100
+ }), ` · ${accessSourceLabel(group.source)} · ${formatAccessDate(group.addedAt)}`]
1101
+ })]
1102
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1103
+ type: "button",
1104
+ className: "dsh-channels-cfg__btn",
1105
+ disabled,
1106
+ onClick: () => onRemoveGroup(group.chatId),
1107
+ children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove
1108
+ })]
1109
+ }, group.chatId))
1110
+ }),
1111
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1112
+ className: "dsh-channels-cfg__accessAdd",
1113
+ children: [
1114
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1115
+ type: "text",
1116
+ className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1117
+ value: groupChatId,
1118
+ placeholder: PANEL_COPY.accessGroupChatIdPlaceholder,
1119
+ "aria-label": PANEL_COPY.accessGroupChatIdPlaceholder,
1120
+ disabled,
1121
+ onChange: (event) => setGroupChatId(event.target.value)
1122
+ }),
1123
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1124
+ type: "text",
1125
+ className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1126
+ value: groupName,
1127
+ placeholder: PANEL_COPY.accessGroupNamePlaceholder,
1128
+ "aria-label": PANEL_COPY.accessGroupNamePlaceholder,
1129
+ disabled,
1130
+ onChange: (event) => setGroupName(event.target.value)
1131
+ }),
1132
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1133
+ type: "button",
1134
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1135
+ disabled: disabled || groupChatId.trim() === "",
1136
+ onClick: () => {
1137
+ submitGroup();
1138
+ },
1139
+ children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddGroup
1140
+ })
1141
+ ]
1142
+ })
1143
+ ]
1144
+ }),
1145
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1146
+ className: "dsh-channels-cfg__accessSection",
1147
+ children: [
1148
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1149
+ className: "dsh-channels-cfg__labelRow",
1150
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1151
+ className: "dsh-channels-cfg__label",
1152
+ children: PANEL_COPY.accessUsersTitle
1153
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1154
+ className: "dsh-channels-cfg__cardMeta",
1155
+ children: [users.length, " 个"]
1156
+ })]
1157
+ }),
1158
+ users.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1159
+ className: "dsh-channels-cfg__help",
1160
+ children: PANEL_COPY.accessUsersEmpty
1161
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1162
+ className: "dsh-channels-cfg__accessList",
1163
+ children: users.map((user) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1164
+ className: "dsh-channels-cfg__accessRow",
1165
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1166
+ className: "dsh-channels-cfg__accessMain",
1167
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: user.name ?? user.openId }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1168
+ className: "dsh-channels-cfg__accessMeta",
1169
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1170
+ className: "dsh-channels-cfg__mono",
1171
+ children: user.openId
1172
+ }), ` · ${accessSourceLabel(user.source)} · ${formatAccessDate(user.addedAt)}`]
1173
+ })]
1174
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1175
+ type: "button",
1176
+ className: "dsh-channels-cfg__btn",
1177
+ disabled,
1178
+ onClick: () => onRemoveUser(user.openId),
1179
+ children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove
1180
+ })]
1181
+ }, user.openId))
1182
+ }),
1183
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1184
+ className: "dsh-channels-cfg__accessAdd",
1185
+ children: [
1186
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1187
+ type: "text",
1188
+ className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1189
+ value: userOpenId,
1190
+ placeholder: PANEL_COPY.accessUserOpenIdPlaceholder,
1191
+ "aria-label": PANEL_COPY.accessUserOpenIdPlaceholder,
1192
+ disabled,
1193
+ onChange: (event) => setUserOpenId(event.target.value)
1194
+ }),
1195
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1196
+ type: "text",
1197
+ className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1198
+ value: userName,
1199
+ placeholder: PANEL_COPY.accessUserNamePlaceholder,
1200
+ "aria-label": PANEL_COPY.accessUserNamePlaceholder,
1201
+ disabled,
1202
+ onChange: (event) => setUserName(event.target.value)
1203
+ }),
1204
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1205
+ type: "button",
1206
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1207
+ disabled: disabled || userOpenId.trim() === "",
1208
+ onClick: () => {
1209
+ submitUser();
1210
+ },
1211
+ children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddUser
1212
+ })
1213
+ ]
1214
+ })
1215
+ ]
1216
+ }),
1217
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1218
+ className: "dsh-channels-cfg__accessSection",
1219
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1220
+ className: "dsh-channels-cfg__labelRow",
1221
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1222
+ className: "dsh-channels-cfg__label",
1223
+ children: PANEL_COPY.accessRequestsTitle
1224
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1225
+ className: "dsh-channels-cfg__cardMeta",
1226
+ children: [requests.length, " 条"]
1227
+ })]
1228
+ }), requests.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1229
+ className: "dsh-channels-cfg__help",
1230
+ children: PANEL_COPY.accessRequestsEmpty
1231
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1232
+ className: "dsh-channels-cfg__accessList",
1233
+ children: requests.map((request) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1234
+ className: "dsh-channels-cfg__accessRow",
1235
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1236
+ className: "dsh-channels-cfg__accessMain",
1237
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
1238
+ request.kind === "group" ? PANEL_COPY.accessRequestGroup : PANEL_COPY.accessRequestUser,
1239
+ ":",
1240
+ request.name ?? request.targetId
1241
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1242
+ className: "dsh-channels-cfg__accessMeta",
1243
+ children: [
1244
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1245
+ className: "dsh-channels-cfg__mono",
1246
+ children: request.targetId
1247
+ }),
1248
+ ` · ${formatAccessDate(request.createdAt)} · ${PANEL_COPY.accessRequestExpires} ${formatAccessDate(request.expiresAt)}`,
1249
+ request.kind === "user" && request.requester !== void 0 ? ` · ${PANEL_COPY.accessRequestRequester} ${request.requester}` : "",
1250
+ request.kind === "user" && request.groupName !== void 0 ? ` · ${request.groupName}` : request.kind === "user" && request.chatId !== void 0 ? ` · ${request.chatId}` : ""
1251
+ ]
1252
+ })]
1253
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1254
+ className: "dsh-channels-cfg__accessActions",
1255
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1256
+ type: "button",
1257
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1258
+ disabled,
1259
+ onClick: () => onResolveRequest(request.token, "allow"),
1260
+ children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAllow
1261
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1262
+ type: "button",
1263
+ className: "dsh-channels-cfg__btn",
1264
+ disabled,
1265
+ onClick: () => onResolveRequest(request.token, "deny"),
1266
+ children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessDeny
1267
+ })]
1268
+ })]
1269
+ }, request.token))
1270
+ })]
1271
+ })
1272
+ ] }),
1273
+ notice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1274
+ className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--ok",
1275
+ role: "status",
1276
+ "aria-live": "polite",
1277
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: notice })
1278
+ }),
1279
+ error === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1280
+ className: "dsh-channels-cfg__fieldError",
1281
+ role: "alert",
1282
+ children: error
1283
+ }),
1284
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1285
+ className: "dsh-channels-cfg__help",
1286
+ children: PANEL_COPY.accessHints
1287
+ }),
1288
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1289
+ className: "dsh-channels-cfg__help",
1290
+ children: PANEL_COPY.accessChatIdHint
1291
+ })
1292
+ ]
1293
+ });
1294
+ }
1295
+ //#endregion
1296
+ //#region src/client/panel/access-tab.tsx
1297
+ /**
1298
+ * 「访问授权」tab:准入与授权字段表单(管理员 open_id / 准入模式 /
1299
+ * 申请有效期)+ 白名单与待处理申请卡。表单保存走全局 draft,白名单
1300
+ * 增删是独立即时 mutation。
1301
+ *
1302
+ * @module dsh-channels/client/panel/access-tab
1303
+ */
1304
+ function AccessTab(props) {
1305
+ const { form, accessNotice, accessError, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props;
1306
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1307
+ className: "dsh-channels-cfg__tabBody",
1308
+ children: [
1309
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.accessTabHelp }),
1310
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
1311
+ form,
1312
+ groups: TAB_FIELD_GROUPS.access
1313
+ }),
1314
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
1315
+ form,
1316
+ tab: "access"
1317
+ }),
1318
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessCard, {
1319
+ panel: form.panel,
1320
+ busy: form.busy,
1321
+ locked: form.locked,
1322
+ notice: accessNotice,
1323
+ error: accessError,
1324
+ onAddGroup,
1325
+ onRemoveGroup,
1326
+ onAddUser,
1327
+ onRemoveUser,
1328
+ onResolveRequest
1329
+ })
1330
+ ]
1331
+ });
1332
+ }
1333
+ //#endregion
1334
+ //#region src/client/panel/advanced-tab.tsx
1335
+ /**
1336
+ * 「高级」tab:流式刷新等低层字段 + 只读部署信息(overlayPath / version /
1337
+ * 部署级参数)+ 「清除 Web 覆盖」。清除覆盖是整体动作,不走 draft。
1338
+ *
1339
+ * @module dsh-channels/client/panel/advanced-tab
1340
+ */
1341
+ function AdvancedTab(props) {
1342
+ const { form, onClear } = props;
1343
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1344
+ className: "dsh-channels-cfg__tabBody",
1345
+ children: [
1346
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.advancedTabHelp }),
1347
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
1348
+ form,
1349
+ groups: TAB_FIELD_GROUPS.advanced
1350
+ }),
1351
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
1352
+ form,
1353
+ tab: "advanced"
1354
+ }),
1355
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdvancedCard, {
1356
+ panel: form.panel,
1357
+ busy: form.busy,
1358
+ locked: form.locked,
1359
+ onClear
1360
+ })
1361
+ ]
1362
+ });
1363
+ }
1364
+ /** Read-only deployment facts; the only write action here drops the overlay file. */
1365
+ function AdvancedCard(props) {
1366
+ const { panel, busy, locked, onClear } = props;
1367
+ const { readOnly } = panel.config;
1368
+ const overridden = panel.config.overriddenKeys.length;
1369
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1370
+ className: "dsh-channels-cfg__card",
1371
+ children: [
1372
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1373
+ className: "dsh-channels-cfg__cardHead",
1374
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1375
+ className: "dsh-channels-cfg__cardTitle",
1376
+ children: PANEL_COPY.advancedCard
1377
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1378
+ className: "dsh-channels-cfg__cardMeta",
1379
+ children: overridden === 0 ? PANEL_COPY.inherited : `${PANEL_COPY.overridden} ${overridden} 项`
1380
+ })]
1381
+ }),
1382
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1383
+ className: "dsh-channels-cfg__help",
1384
+ children: PANEL_COPY.advancedHelp
1385
+ }),
1386
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1387
+ className: "dsh-channels-cfg__labelRow",
1388
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1389
+ className: "dsh-channels-cfg__cardTitle",
1390
+ children: PANEL_COPY.readOnlyCard
1391
+ })
1392
+ }),
1393
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1394
+ className: "dsh-channels-cfg__readOnly",
1395
+ children: [
1396
+ readOnly.map((field) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1397
+ className: "dsh-channels-cfg__kv",
1398
+ children: [
1399
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1400
+ className: "dsh-channels-cfg__kvKey",
1401
+ children: field.label
1402
+ }),
1403
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1404
+ className: "dsh-channels-cfg__kvValue",
1405
+ children: field.value
1406
+ }),
1407
+ field.help === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1408
+ className: "dsh-channels-cfg__help",
1409
+ children: field.help
1410
+ })
1411
+ ]
1412
+ }, field.key)),
1413
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1414
+ className: "dsh-channels-cfg__kv",
1415
+ children: [
1416
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1417
+ className: "dsh-channels-cfg__kvKey",
1418
+ children: PANEL_COPY.overlayPath
1419
+ }),
1420
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1421
+ className: "dsh-channels-cfg__kvValue dsh-channels-cfg__mono",
1422
+ children: panel.status.overlayPath
1423
+ }),
1424
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1425
+ className: "dsh-channels-cfg__help",
1426
+ children: PANEL_COPY.overlayPathHelp
1427
+ })
1428
+ ]
1429
+ }),
1430
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1431
+ className: "dsh-channels-cfg__kv",
1432
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1433
+ className: "dsh-channels-cfg__kvKey",
1434
+ children: PANEL_COPY.version
1435
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1436
+ className: "dsh-channels-cfg__kvValue dsh-channels-cfg__mono",
1437
+ children: panel.status.version
1438
+ })]
1439
+ })
1440
+ ]
1441
+ }),
1442
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1443
+ className: "dsh-channels-cfg__actions",
1444
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1445
+ type: "button",
1446
+ className: "dsh-channels-cfg__btn",
1447
+ onClick: onClear,
1448
+ disabled: overridden === 0 || busy !== null || locked,
1449
+ children: busy === "clear" ? PANEL_COPY.clearing : PANEL_COPY.clear
1450
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1451
+ className: "dsh-channels-cfg__help",
1452
+ children: overridden === 0 ? PANEL_COPY.noOverrides : `${PANEL_COPY.overridden} ${overridden} 项`
1453
+ })]
1454
+ })
1455
+ ]
1456
+ });
1457
+ }
1458
+ //#endregion
1459
+ //#region src/client/panel/credentials-card.tsx
1460
+ /**
1461
+ * 飞书应用凭据编辑器:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示
1462
+ * 「已配置 / 未配置」,两个方向都不回显已存值。保存动作由页面外壳持有
1463
+ * (凭据保存独立于配置 draft)。
1464
+ *
1465
+ * @module dsh-channels/client/panel/credentials-card
1466
+ */
1467
+ function CredentialsCard(props) {
1468
+ const { panel, credentials } = props;
1469
+ const { appId, appSecret, issues, busy, locked, onAppIdChange, onAppSecretChange, onSave, onClear } = credentials;
1470
+ const appIdState = panel.config.credentials.appId;
1471
+ const appSecretState = panel.config.credentials.appSecret;
1472
+ const configured = appIdState.configured && appSecretState.configured;
1473
+ const writable = appIdState.writable || appSecretState.writable;
1474
+ const disabled = busy !== null || locked || !writable;
1475
+ const pending = appId.trim() !== "" || appSecret.trim() !== "";
1476
+ const row = (field, label, state, value, type, placeholder, onChange) => {
1477
+ const issue = issues.find((item) => item.key === field);
1478
+ const inputClass = `dsh-channels-cfg__input${issue === void 0 ? "" : " dsh-channels-cfg__input--invalid"}`;
1479
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1480
+ className: "dsh-channels-cfg__field",
1481
+ children: [
1482
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1483
+ className: "dsh-channels-cfg__labelRow",
1484
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1485
+ className: "dsh-channels-cfg__label",
1486
+ children: label
1487
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1488
+ className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${state.configured ? "ok" : "bad"}`,
1489
+ children: state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing
1490
+ })]
1491
+ }),
1492
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1493
+ className: "dsh-channels-cfg__cred",
1494
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1495
+ type,
1496
+ className: inputClass,
1497
+ value,
1498
+ placeholder,
846
1499
  autoComplete: "new-password",
1500
+ "aria-label": label,
847
1501
  disabled,
848
1502
  onChange: (event) => onChange(event.target.value)
849
1503
  }), state.configured ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -863,7 +1517,7 @@ window.__ModuleLoader__.load({
863
1517
  className: "dsh-channels-cfg__mono",
864
1518
  children: state.ref
865
1519
  }),
866
- state.source === void 0 ? "" : `(来源:${state.source})`
1520
+ state.source === void 0 ? "" : ` · ${state.source}`
867
1521
  ]
868
1522
  }),
869
1523
  issue === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -882,10 +1536,14 @@ window.__ModuleLoader__.load({
882
1536
  className: "dsh-channels-cfg__cardTitle",
883
1537
  children: PANEL_COPY.credentialsCard
884
1538
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
885
- className: "dsh-channels-cfg__cardMeta",
886
- children: "DSH 凭据库"
1539
+ className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${configured ? "ok" : "muted"}`,
1540
+ children: configured ? PANEL_COPY.configuredBadge : PANEL_COPY.unconfiguredBadge
887
1541
  })]
888
1542
  }),
1543
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1544
+ className: "dsh-channels-cfg__help",
1545
+ children: PANEL_COPY.credentialsCardHelp
1546
+ }),
889
1547
  row("appId", PANEL_COPY.appIdLabel, appIdState, appId, "text", PANEL_COPY.appIdPlaceholder, onAppIdChange),
890
1548
  row("appSecret", PANEL_COPY.appSecretLabel, appSecretState, appSecret, "password", PANEL_COPY.appSecretPlaceholder, onAppSecretChange),
891
1549
  writable ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -905,101 +1563,154 @@ window.__ModuleLoader__.load({
905
1563
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
906
1564
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
907
1565
  className: "dsh-channels-cfg__help",
908
- children: PANEL_COPY.credentialsHelp
1566
+ children: PANEL_COPY.credentialsPendingHint
909
1567
  })
910
1568
  ]
911
1569
  })
912
1570
  ]
913
1571
  });
914
1572
  }
915
- /** Read-only card: credentials, storage layer, and advanced limits. */
916
- function AdvancedCard(props) {
917
- const { panel } = props;
918
- const { readOnly } = panel.config;
1573
+ //#endregion
1574
+ //#region src/client/panel/connection-tab.tsx
1575
+ /**
1576
+ * 「连接」tab:按 Host 派生的生命周期阶段渲染连接引导。
1577
+ *
1578
+ * - unconfigured:onboarding 三步 + 凭据卡(不渲染可用的「连接」按钮)
1579
+ * - idle:连接信息卡 + 「连接飞书」
1580
+ * - error:连接信息卡 + 最近一次连接失败 + 「重试」
1581
+ * - connected:连接信息卡 + 「重启飞书连接」+ 凭据卡
1582
+ * - connecting:禁用按钮显示「连接中…」
1583
+ *
1584
+ * @module dsh-channels/client/panel/connection-tab
1585
+ */
1586
+ function ConnectionTab(props) {
1587
+ const { panel, status, busy, credentials, onReconnect } = props;
1588
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1589
+ className: "dsh-channels-cfg__tabBody",
1590
+ children: [
1591
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.connectionTabHelp }),
1592
+ panel.config.restartPendingKeys.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RestartBanner, {
1593
+ panel,
1594
+ busy,
1595
+ locked: status.busy,
1596
+ onReconnect
1597
+ }),
1598
+ status.phase === "unconfigured" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OnboardingCard, { status }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConnectionInfoCard, {
1599
+ status,
1600
+ busy,
1601
+ onReconnect
1602
+ }),
1603
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CredentialsCard, {
1604
+ panel,
1605
+ credentials
1606
+ })
1607
+ ]
1608
+ });
1609
+ }
1610
+ /** First-run guide: create the app, fill the credentials, save and wait. */
1611
+ function OnboardingCard(props) {
1612
+ const { status } = props;
1613
+ const steps = [
1614
+ {
1615
+ title: PANEL_COPY.onboardingStep1Title,
1616
+ text: PANEL_COPY.onboardingStep1Text
1617
+ },
1618
+ {
1619
+ title: PANEL_COPY.onboardingStep2Title,
1620
+ text: PANEL_COPY.onboardingStep2Text
1621
+ },
1622
+ {
1623
+ title: PANEL_COPY.onboardingStep3Title,
1624
+ text: PANEL_COPY.onboardingStep3Text
1625
+ }
1626
+ ];
919
1627
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
920
1628
  className: "dsh-channels-cfg__card",
921
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
922
- className: "dsh-channels-cfg__cardHead",
923
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
924
- className: "dsh-channels-cfg__cardTitle",
925
- children: PANEL_COPY.advancedCard
926
- })
927
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
928
- className: "dsh-channels-cfg__readOnly",
929
- children: [
930
- readOnly.map((field) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
931
- className: "dsh-channels-cfg__kv",
932
- children: [
933
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
934
- className: "dsh-channels-cfg__kvKey",
935
- children: field.label
936
- }),
937
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
938
- className: "dsh-channels-cfg__kvValue",
939
- children: field.value
940
- }),
941
- field.help === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
942
- className: "dsh-channels-cfg__help",
943
- children: field.help
944
- })
945
- ]
946
- }, field.key)),
947
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
948
- className: "dsh-channels-cfg__kv",
949
- children: [
950
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
951
- className: "dsh-channels-cfg__kvKey",
952
- children: PANEL_COPY.overlayPath
953
- }),
954
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
955
- className: "dsh-channels-cfg__kvValue dsh-channels-cfg__mono",
956
- children: panel.status.overlayPath
957
- }),
958
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
959
- className: "dsh-channels-cfg__help",
960
- children: "可以直接编辑该 JSON 文件;面板保存会整体重写它,「清除 Web 覆盖」会删除它。"
961
- })
962
- ]
963
- }),
964
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
965
- className: "dsh-channels-cfg__kv",
1629
+ children: [
1630
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1631
+ className: "dsh-channels-cfg__cardHead",
1632
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1633
+ className: "dsh-channels-cfg__cardTitle",
1634
+ children: PANEL_COPY.onboardingTitle
1635
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1636
+ className: "dsh-channels-cfg__badge dsh-channels-cfg__badge--muted",
1637
+ children: PANEL_COPY.phaseUnconfigured
1638
+ })]
1639
+ }),
1640
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1641
+ className: "dsh-channels-cfg__help",
1642
+ children: PANEL_COPY.onboardingIntro
1643
+ }),
1644
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ol", {
1645
+ className: "dsh-channels-cfg__steps",
1646
+ children: steps.map((step, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1647
+ className: "dsh-channels-cfg__step",
966
1648
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
967
- className: "dsh-channels-cfg__kvKey",
968
- children: PANEL_COPY.version
969
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
970
- className: "dsh-channels-cfg__kvValue dsh-channels-cfg__mono",
971
- children: panel.status.version
1649
+ className: "dsh-channels-cfg__stepIndex",
1650
+ children: index + 1
1651
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1652
+ className: "dsh-channels-cfg__stepBody",
1653
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1654
+ className: "dsh-channels-cfg__stepTitle",
1655
+ children: step.title
1656
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1657
+ className: "dsh-channels-cfg__help",
1658
+ children: step.text
1659
+ })]
972
1660
  })]
973
- })
974
- ]
975
- })]
1661
+ }, step.title))
1662
+ }),
1663
+ status.missingConfig.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1664
+ className: "dsh-channels-cfg__banner",
1665
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("strong", { children: [PANEL_COPY.missingConfig, ":"] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: status.missingConfig.join("、") })]
1666
+ }),
1667
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1668
+ className: "dsh-channels-cfg__help",
1669
+ children: PANEL_COPY.statusHintUnconfigured
1670
+ }),
1671
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1672
+ className: "dsh-channels-cfg__help",
1673
+ children: PANEL_COPY.onboardingDocsHint
1674
+ })
1675
+ ]
976
1676
  });
977
1677
  }
978
- /** Workspace cleanup card: deletion preview + the irreversible manual action. */
979
- function CleanupCard(props) {
980
- const { panel, cleanup, busy, result, error, onRun } = props;
981
- const autoText = cleanup.retention === "off" ? PANEL_COPY.cleanupAutoInactive : cleanup.auto ? PANEL_COPY.cleanupAutoOn : PANEL_COPY.cleanupAutoOff;
982
- const blocked = cleanup.error !== void 0 ? `${PANEL_COPY.cleanupPreviewFailed}:${cleanup.error}` : cleanup.retention === "off" ? PANEL_COPY.cleanupDisabledOff : cleanup.candidates === 0 ? PANEL_COPY.cleanupDisabledEmpty : null;
1678
+ /** Runtime facts plus the one lifecycle action available in the current phase. */
1679
+ function ConnectionInfoCard(props) {
1680
+ const { status, busy, onReconnect } = props;
1681
+ const badge = phaseBadge(status.phase);
1682
+ const actionBusy = busy !== null || status.busy;
1683
+ const connection = connectionStateLabel(status.connection?.state);
983
1684
  const facts = [
984
1685
  {
985
- label: PANEL_COPY.cleanupRetention,
986
- value: retentionLabel(panel, cleanup)
1686
+ label: PANEL_COPY.statusCard,
1687
+ value: badge.text
1688
+ },
1689
+ {
1690
+ label: PANEL_COPY.connectionStateLabel,
1691
+ value: connection.text
1692
+ },
1693
+ {
1694
+ label: PANEL_COPY.botOpenId,
1695
+ value: status.botOpenIdTail ?? PANEL_COPY.unknownValue
1696
+ },
1697
+ {
1698
+ label: PANEL_COPY.uptime,
1699
+ value: formatUptime(status.uptimeMs)
987
1700
  },
988
1701
  {
989
- label: PANEL_COPY.cleanupAutoLabel,
990
- value: autoText
1702
+ label: PANEL_COPY.routeCount,
1703
+ value: String(status.routeCount)
991
1704
  },
992
1705
  {
993
- label: PANEL_COPY.cleanupCandidates,
994
- value: `${cleanup.candidates} / ${cleanup.workspaces} 个`
1706
+ label: PANEL_COPY.liveAgentCount,
1707
+ value: String(status.liveAgentCount)
995
1708
  },
996
1709
  {
997
- label: PANEL_COPY.cleanupCandidateBytes,
998
- value: formatBytes(cleanup.candidateBytes)
1710
+ label: PANEL_COPY.inflightTurnCount,
1711
+ value: String(status.inflightTurnCount)
999
1712
  }
1000
1713
  ];
1001
- const running = busy === "cleanup";
1002
- const failed = result !== null && (!result.ok || error !== null);
1003
1714
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1004
1715
  className: "dsh-channels-cfg__card",
1005
1716
  children: [
@@ -1007,15 +1718,18 @@ window.__ModuleLoader__.load({
1007
1718
  className: "dsh-channels-cfg__cardHead",
1008
1719
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1009
1720
  className: "dsh-channels-cfg__cardTitle",
1010
- children: PANEL_COPY.cleanupCard
1721
+ children: PANEL_COPY.connectionInfo
1011
1722
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1012
- className: "dsh-channels-cfg__cardMeta",
1013
- children: retentionLabel(panel, cleanup)
1723
+ className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${status.configured ? "ok" : "bad"}`,
1724
+ children: status.configured ? PANEL_COPY.configuredBadge : PANEL_COPY.unconfiguredBadge
1014
1725
  })]
1015
1726
  }),
1016
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1017
- className: "dsh-channels-cfg__help",
1018
- children: PANEL_COPY.cleanupCardHelp
1727
+ status.lastStartError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1728
+ className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
1729
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.lastError }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1730
+ className: "dsh-channels-cfg__mono",
1731
+ children: status.lastStartError
1732
+ })]
1019
1733
  }),
1020
1734
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1021
1735
  className: "dsh-channels-cfg__facts",
@@ -1030,82 +1744,162 @@ window.__ModuleLoader__.load({
1030
1744
  })]
1031
1745
  }, fact.label))
1032
1746
  }),
1033
- blocked === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1034
- className: "dsh-channels-cfg__help",
1035
- children: blocked
1036
- }),
1037
- result === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1038
- className: `dsh-channels-cfg__notice dsh-channels-cfg__notice--${failed ? "error" : "ok"}`,
1039
- children: [result.ok ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
1040
- PANEL_COPY.cleanupDone,
1747
+ status.reasons.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1748
+ className: `dsh-channels-cfg__help${status.level === "unhealthy" ? " dsh-channels-cfg__reasonBad" : ""}`,
1749
+ children: [
1750
+ PANEL_COPY.healthReasons,
1041
1751
  ":",
1042
- PANEL_COPY.cleanupDeleted,
1043
- " ",
1044
- result.deletedWorkspaces,
1045
- " 个,",
1046
- PANEL_COPY.cleanupRetired,
1047
- " ",
1048
- result.retiredRoutes,
1049
- " 条,",
1050
- PANEL_COPY.cleanupFreed,
1051
- " ",
1052
- formatBytes(result.freedBytes),
1053
- "。"
1054
- ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [PANEL_COPY.cleanupFailed, result.error === void 0 ? "" : `:${result.error}`] }), result.failures.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: PANEL_COPY.cleanupFailures }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1055
- className: "dsh-channels-cfg__issues",
1056
- children: result.failures.map((failure, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
1057
- className: "dsh-channels-cfg__mono",
1058
- children: failure
1059
- }, `${index}-${failure}`))
1060
- })] })]
1061
- }),
1062
- error === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1063
- className: "dsh-channels-cfg__fieldError",
1064
- children: error
1752
+ status.reasons.join(";")
1753
+ ]
1065
1754
  }),
1066
1755
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1067
1756
  className: "dsh-channels-cfg__actions",
1068
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1069
- type: "button",
1070
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--danger",
1071
- onClick: onRun,
1072
- disabled: running || busy !== null || blocked !== null,
1073
- children: running ? PANEL_COPY.cleanupRunning : PANEL_COPY.cleanupNow
1074
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1075
- className: "dsh-channels-cfg__help",
1076
- children: PANEL_COPY.cleanupConfirmHint
1077
- })]
1757
+ children: [
1758
+ status.phase === "connecting" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1759
+ type: "button",
1760
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1761
+ disabled: true,
1762
+ children: PANEL_COPY.connecting
1763
+ }) : null,
1764
+ status.phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1765
+ type: "button",
1766
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1767
+ onClick: onReconnect,
1768
+ disabled: actionBusy,
1769
+ children: busy === "reconnect" ? PANEL_COPY.connecting : PANEL_COPY.connect
1770
+ }) : null,
1771
+ status.phase === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1772
+ type: "button",
1773
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1774
+ onClick: onReconnect,
1775
+ disabled: actionBusy,
1776
+ children: busy === "reconnect" ? PANEL_COPY.connecting : PANEL_COPY.retry
1777
+ }) : null,
1778
+ status.phase === "connected" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1779
+ type: "button",
1780
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--danger",
1781
+ onClick: onReconnect,
1782
+ disabled: actionBusy,
1783
+ children: busy === "reconnect" ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect
1784
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1785
+ className: "dsh-channels-cfg__help",
1786
+ children: PANEL_COPY.restartWarning
1787
+ })] }) : null
1788
+ ]
1078
1789
  })
1079
1790
  ]
1080
1791
  });
1081
1792
  }
1082
- /** Access card: fail-closed warning, administrator, whitelists, pending requests. */
1083
- function AccessCard(props) {
1084
- const { panel, busy, notice, error, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props;
1085
- const { access } = panel;
1086
- const [groupChatId, setGroupChatId] = (0, react.useState)("");
1087
- const [groupName, setGroupName] = (0, react.useState)("");
1088
- const [userOpenId, setUserOpenId] = (0, react.useState)("");
1089
- const [userName, setUserName] = (0, react.useState)("");
1090
- const groups = access.groups;
1091
- const users = access.users;
1092
- const requests = access.requests;
1093
- const working = busy === "access";
1094
- const disabled = busy !== null;
1095
- const submitGroup = async () => {
1096
- if (groupChatId.trim() === "") return;
1097
- if (await onAddGroup(groupChatId, groupName)) {
1098
- setGroupChatId("");
1099
- setGroupName("");
1100
- }
1101
- };
1102
- const submitUser = async () => {
1103
- if (userOpenId.trim() === "") return;
1104
- if (await onAddUser(userOpenId, userName)) {
1105
- setUserOpenId("");
1106
- setUserName("");
1793
+ /** Config saved but only applied on the next connect: offer the one-click restart. */
1794
+ function RestartBanner(props) {
1795
+ const { panel, busy, locked, onReconnect } = props;
1796
+ const keys = panel.config.restartPendingKeys.map((key) => fieldLabel(panel.config.fields, key)).join("、");
1797
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1798
+ className: "dsh-channels-cfg__banner",
1799
+ children: [
1800
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [PANEL_COPY.restartBanner, keys] }),
1801
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1802
+ type: "button",
1803
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--danger",
1804
+ onClick: onReconnect,
1805
+ disabled: busy !== null || locked,
1806
+ children: busy === "reconnect" ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect
1807
+ }),
1808
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1809
+ className: "dsh-channels-cfg__help",
1810
+ children: PANEL_COPY.restartWarning
1811
+ })
1812
+ ]
1813
+ });
1814
+ }
1815
+ //#endregion
1816
+ //#region src/client/panel/model-tab.tsx
1817
+ /**
1818
+ * 「模型」tab:模型服务商 / 模型 id / 推理档位 / Agent 预设四个选择器。
1819
+ * 选项来自 Host 实时目录(页面外壳负责 provider→model→effort 级联刷新),
1820
+ * 目录读取中时在表单上方提示。
1821
+ *
1822
+ * @module dsh-channels/client/panel/model-tab
1823
+ */
1824
+ function ModelTab(props) {
1825
+ const { form, catalogBusy } = props;
1826
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1827
+ className: "dsh-channels-cfg__tabBody",
1828
+ children: [
1829
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.modelTabHelp }),
1830
+ catalogBusy ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1831
+ className: "dsh-channels-cfg__help",
1832
+ children: PANEL_COPY.catalogLoading
1833
+ }) : null,
1834
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
1835
+ form,
1836
+ groups: TAB_FIELD_GROUPS.model
1837
+ }),
1838
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
1839
+ form,
1840
+ tab: "model"
1841
+ })
1842
+ ]
1843
+ });
1844
+ }
1845
+ //#endregion
1846
+ //#region src/client/panel/sessions-tab.tsx
1847
+ /**
1848
+ * 「会话与行为」tab:行为 + 内容与附件两组字段(私聊/群聊触发方式、
1849
+ * 首次引导、权限切换、附件接收)。表单字段完全由 Host 描述驱动。
1850
+ *
1851
+ * @module dsh-channels/client/panel/sessions-tab
1852
+ */
1853
+ function SessionsTab(props) {
1854
+ const { form } = props;
1855
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1856
+ className: "dsh-channels-cfg__tabBody",
1857
+ children: [
1858
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.sessionsTabHelp }),
1859
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
1860
+ form,
1861
+ groups: TAB_FIELD_GROUPS.sessions
1862
+ }),
1863
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
1864
+ form,
1865
+ tab: "sessions"
1866
+ })
1867
+ ]
1868
+ });
1869
+ }
1870
+ //#endregion
1871
+ //#region src/client/panel/cleanup-card.tsx
1872
+ /**
1873
+ * 工作区清理卡:展示清理预览(保留期 / 自动清理 / 候选数量与空间),
1874
+ * 执行前二次确认,结果与部分失败逐条展示。`cleanup.error` 非空时
1875
+ * (例如未连接、无法扫描工作区)按钮天然禁用并显示原因。
1876
+ *
1877
+ * @module dsh-channels/client/panel/cleanup-card
1878
+ */
1879
+ function CleanupCard(props) {
1880
+ const { panel, cleanup, busy, locked, result, error, onRun } = props;
1881
+ const autoText = cleanup.retention === "off" ? PANEL_COPY.cleanupAutoInactive : cleanup.auto ? PANEL_COPY.cleanupAutoOn : PANEL_COPY.cleanupAutoOff;
1882
+ const blocked = cleanup.error !== void 0 ? `${PANEL_COPY.cleanupPreviewFailed}:${cleanup.error}` : cleanup.retention === "off" ? PANEL_COPY.cleanupDisabledOff : cleanup.candidates === 0 ? PANEL_COPY.cleanupDisabledEmpty : null;
1883
+ const facts = [
1884
+ {
1885
+ label: PANEL_COPY.cleanupRetention,
1886
+ value: retentionLabel(panel, cleanup)
1887
+ },
1888
+ {
1889
+ label: PANEL_COPY.cleanupAutoLabel,
1890
+ value: autoText
1891
+ },
1892
+ {
1893
+ label: PANEL_COPY.cleanupCandidates,
1894
+ value: `${cleanup.candidates} / ${cleanup.workspaces} 个`
1895
+ },
1896
+ {
1897
+ label: PANEL_COPY.cleanupCandidateBytes,
1898
+ value: formatBytes(cleanup.candidateBytes)
1107
1899
  }
1108
- };
1900
+ ];
1901
+ const running = busy === "cleanup";
1902
+ const failed = result !== null && (!result.ok || error !== null);
1109
1903
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1110
1904
  className: "dsh-channels-cfg__card",
1111
1905
  children: [
@@ -1113,258 +1907,129 @@ window.__ModuleLoader__.load({
1113
1907
  className: "dsh-channels-cfg__cardHead",
1114
1908
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1115
1909
  className: "dsh-channels-cfg__cardTitle",
1116
- children: PANEL_COPY.accessCard
1910
+ children: PANEL_COPY.cleanupCard
1117
1911
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1118
1912
  className: "dsh-channels-cfg__cardMeta",
1119
- children: accessModeLabel(panel, access.mode)
1913
+ children: retentionLabel(panel, cleanup)
1120
1914
  })]
1121
1915
  }),
1122
1916
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1123
1917
  className: "dsh-channels-cfg__help",
1124
- children: PANEL_COPY.accessCardHelp
1125
- }),
1126
- access.mode === "guarded" && !access.admin.configured ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1127
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
1128
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.accessAdminMissingWarning })
1129
- }) : null,
1130
- access.mode === "open" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1131
- className: "dsh-channels-cfg__banner",
1132
- children: PANEL_COPY.accessModeOpenWarning
1133
- }) : null,
1134
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1135
- className: "dsh-channels-cfg__kv",
1136
- children: [
1137
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1138
- className: "dsh-channels-cfg__kvKey",
1139
- children: PANEL_COPY.accessAdminLabel
1140
- }),
1141
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1142
- className: `dsh-channels-cfg__kvValue dsh-channels-cfg__mono${access.admin.configured ? "" : " dsh-channels-cfg__reasonBad"}`,
1143
- children: access.admin.openId ?? PANEL_COPY.accessAdminMissing
1144
- }),
1145
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1146
- className: "dsh-channels-cfg__help",
1147
- children: PANEL_COPY.accessAdminHint
1148
- })
1149
- ]
1150
- }),
1151
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1152
- className: "dsh-channels-cfg__accessSection",
1153
- children: [
1154
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1155
- className: "dsh-channels-cfg__labelRow",
1156
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1157
- className: "dsh-channels-cfg__label",
1158
- children: PANEL_COPY.accessGroupsTitle
1159
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1160
- className: "dsh-channels-cfg__cardMeta",
1161
- children: [groups.length, " 个"]
1162
- })]
1163
- }),
1164
- groups.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1165
- className: "dsh-channels-cfg__help",
1166
- children: PANEL_COPY.accessGroupsEmpty
1167
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1168
- className: "dsh-channels-cfg__accessList",
1169
- children: groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1170
- className: "dsh-channels-cfg__accessRow",
1171
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1172
- className: "dsh-channels-cfg__accessMain",
1173
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: group.name ?? group.chatId }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1174
- className: "dsh-channels-cfg__accessMeta",
1175
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1176
- className: "dsh-channels-cfg__mono",
1177
- children: group.chatId
1178
- }), ` · ${accessSourceLabel(group.source)} · ${formatAccessDate(group.addedAt)}`]
1179
- })]
1180
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1181
- type: "button",
1182
- className: "dsh-channels-cfg__btn",
1183
- disabled,
1184
- onClick: () => onRemoveGroup(group.chatId),
1185
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove
1186
- })]
1187
- }, group.chatId))
1188
- }),
1189
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1190
- className: "dsh-channels-cfg__accessAdd",
1191
- children: [
1192
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1193
- type: "text",
1194
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1195
- value: groupChatId,
1196
- placeholder: PANEL_COPY.accessGroupChatIdPlaceholder,
1197
- disabled,
1198
- onChange: (event) => setGroupChatId(event.target.value)
1199
- }),
1200
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1201
- type: "text",
1202
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1203
- value: groupName,
1204
- placeholder: PANEL_COPY.accessGroupNamePlaceholder,
1205
- disabled,
1206
- onChange: (event) => setGroupName(event.target.value)
1207
- }),
1208
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1209
- type: "button",
1210
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1211
- disabled: disabled || groupChatId.trim() === "",
1212
- onClick: () => {
1213
- submitGroup();
1214
- },
1215
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddGroup
1216
- })
1217
- ]
1218
- })
1219
- ]
1220
- }),
1221
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1222
- className: "dsh-channels-cfg__accessSection",
1223
- children: [
1224
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1225
- className: "dsh-channels-cfg__labelRow",
1226
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1227
- className: "dsh-channels-cfg__label",
1228
- children: PANEL_COPY.accessUsersTitle
1229
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1230
- className: "dsh-channels-cfg__cardMeta",
1231
- children: [users.length, " 个"]
1232
- })]
1233
- }),
1234
- users.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1235
- className: "dsh-channels-cfg__help",
1236
- children: PANEL_COPY.accessUsersEmpty
1237
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1238
- className: "dsh-channels-cfg__accessList",
1239
- children: users.map((user) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1240
- className: "dsh-channels-cfg__accessRow",
1241
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1242
- className: "dsh-channels-cfg__accessMain",
1243
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: user.name ?? user.openId }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1244
- className: "dsh-channels-cfg__accessMeta",
1245
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1246
- className: "dsh-channels-cfg__mono",
1247
- children: user.openId
1248
- }), ` · ${accessSourceLabel(user.source)} · ${formatAccessDate(user.addedAt)}`]
1249
- })]
1250
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1251
- type: "button",
1252
- className: "dsh-channels-cfg__btn",
1253
- disabled,
1254
- onClick: () => onRemoveUser(user.openId),
1255
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove
1256
- })]
1257
- }, user.openId))
1258
- }),
1259
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1260
- className: "dsh-channels-cfg__accessAdd",
1261
- children: [
1262
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1263
- type: "text",
1264
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1265
- value: userOpenId,
1266
- placeholder: PANEL_COPY.accessUserOpenIdPlaceholder,
1267
- disabled,
1268
- onChange: (event) => setUserOpenId(event.target.value)
1269
- }),
1270
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1271
- type: "text",
1272
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1273
- value: userName,
1274
- placeholder: PANEL_COPY.accessUserNamePlaceholder,
1275
- disabled,
1276
- onChange: (event) => setUserName(event.target.value)
1277
- }),
1278
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1279
- type: "button",
1280
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1281
- disabled: disabled || userOpenId.trim() === "",
1282
- onClick: () => {
1283
- submitUser();
1284
- },
1285
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddUser
1286
- })
1287
- ]
1288
- })
1289
- ]
1290
- }),
1291
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1292
- className: "dsh-channels-cfg__accessSection",
1293
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1294
- className: "dsh-channels-cfg__labelRow",
1918
+ children: PANEL_COPY.cleanupCardHelp
1919
+ }),
1920
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1921
+ className: "dsh-channels-cfg__facts",
1922
+ children: facts.map((fact) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1923
+ className: "dsh-channels-cfg__fact",
1295
1924
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1296
- className: "dsh-channels-cfg__label",
1297
- children: PANEL_COPY.accessRequestsTitle
1298
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1299
- className: "dsh-channels-cfg__cardMeta",
1300
- children: [requests.length, " 条"]
1925
+ className: "dsh-channels-cfg__factLabel",
1926
+ children: fact.label
1927
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1928
+ className: "dsh-channels-cfg__factValue",
1929
+ children: fact.value
1301
1930
  })]
1302
- }), requests.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1303
- className: "dsh-channels-cfg__help",
1304
- children: PANEL_COPY.accessRequestsEmpty
1305
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1306
- className: "dsh-channels-cfg__accessList",
1307
- children: requests.map((request) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1308
- className: "dsh-channels-cfg__accessRow",
1309
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1310
- className: "dsh-channels-cfg__accessMain",
1311
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
1312
- request.kind === "group" ? PANEL_COPY.accessRequestGroup : PANEL_COPY.accessRequestUser,
1313
- ":",
1314
- request.name ?? request.targetId
1315
- ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1316
- className: "dsh-channels-cfg__accessMeta",
1317
- children: [
1318
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1319
- className: "dsh-channels-cfg__mono",
1320
- children: request.targetId
1321
- }),
1322
- ` · ${formatAccessDate(request.createdAt)} · ${PANEL_COPY.accessRequestExpires} ${formatAccessDate(request.expiresAt)}`,
1323
- request.kind === "user" && request.requester !== void 0 ? ` · ${PANEL_COPY.accessRequestRequester} ${request.requester}` : "",
1324
- request.kind === "user" && request.groupName !== void 0 ? ` · 群 ${request.groupName}` : request.kind === "user" && request.chatId !== void 0 ? ` · 群 ${request.chatId}` : ""
1325
- ]
1326
- })]
1327
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1328
- className: "dsh-channels-cfg__accessActions",
1329
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1330
- type: "button",
1331
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1332
- disabled,
1333
- onClick: () => onResolveRequest(request.token, "allow"),
1334
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAllow
1335
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1336
- type: "button",
1337
- className: "dsh-channels-cfg__btn",
1338
- disabled,
1339
- onClick: () => onResolveRequest(request.token, "deny"),
1340
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessDeny
1341
- })]
1342
- })]
1343
- }, request.token))
1344
- })]
1931
+ }, fact.label))
1345
1932
  }),
1346
- notice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1347
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--ok",
1348
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: notice })
1933
+ blocked === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1934
+ className: "dsh-channels-cfg__help",
1935
+ children: blocked
1936
+ }),
1937
+ result === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1938
+ className: `dsh-channels-cfg__notice dsh-channels-cfg__notice--${failed ? "error" : "ok"}`,
1939
+ children: [result.ok ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
1940
+ PANEL_COPY.cleanupDone,
1941
+ ":",
1942
+ PANEL_COPY.cleanupDeleted,
1943
+ " ",
1944
+ result.deletedWorkspaces,
1945
+ " 个,",
1946
+ PANEL_COPY.cleanupRetired,
1947
+ " ",
1948
+ result.retiredRoutes,
1949
+ " 条,",
1950
+ PANEL_COPY.cleanupFreed,
1951
+ " ",
1952
+ formatBytes(result.freedBytes),
1953
+ "。"
1954
+ ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [PANEL_COPY.cleanupFailed, result.error === void 0 ? "" : `:${result.error}`] }), result.failures.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: PANEL_COPY.cleanupFailures }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1955
+ className: "dsh-channels-cfg__issues",
1956
+ children: result.failures.map((failure, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
1957
+ className: "dsh-channels-cfg__mono",
1958
+ children: failure
1959
+ }, `${index}-${failure}`))
1960
+ })] })]
1349
1961
  }),
1350
1962
  error === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1351
1963
  className: "dsh-channels-cfg__fieldError",
1352
1964
  children: error
1353
1965
  }),
1354
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1355
- className: "dsh-channels-cfg__help",
1356
- children: PANEL_COPY.accessHints
1966
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1967
+ className: "dsh-channels-cfg__actions",
1968
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1969
+ type: "button",
1970
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--danger",
1971
+ onClick: onRun,
1972
+ disabled: running || busy !== null || locked || blocked !== null,
1973
+ children: running ? PANEL_COPY.cleanupRunning : PANEL_COPY.cleanupNow
1974
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1975
+ className: "dsh-channels-cfg__help",
1976
+ children: PANEL_COPY.cleanupConfirmHint
1977
+ })]
1978
+ })
1979
+ ]
1980
+ });
1981
+ }
1982
+ //#endregion
1983
+ //#region src/client/panel/workspace-tab.tsx
1984
+ /**
1985
+ * 「工作区」tab:工作区根目录 / 过期保留策略 / 自动清理字段 + 工作区清理卡。
1986
+ * 表单保存栏在字段卡正下方,清理卡(独立动作,不走 draft)在其后。
1987
+ *
1988
+ * @module dsh-channels/client/panel/workspace-tab
1989
+ */
1990
+ function WorkspaceTab(props) {
1991
+ const { form, cleanup, cleanupResult, cleanupError, onRunCleanup } = props;
1992
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1993
+ className: "dsh-channels-cfg__tabBody",
1994
+ children: [
1995
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.workspaceTabHelp }),
1996
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
1997
+ form,
1998
+ groups: TAB_FIELD_GROUPS.workspace
1357
1999
  }),
1358
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1359
- className: "dsh-channels-cfg__help",
1360
- children: PANEL_COPY.accessChatIdHint
2000
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
2001
+ form,
2002
+ tab: "workspace"
2003
+ }),
2004
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CleanupCard, {
2005
+ panel: form.panel,
2006
+ cleanup,
2007
+ busy: form.busy,
2008
+ locked: form.locked,
2009
+ result: cleanupResult,
2010
+ error: cleanupError,
2011
+ onRun: onRunCleanup
1361
2012
  })
1362
2013
  ]
1363
2014
  });
1364
2015
  }
1365
- /** The settings.section page: status card + generic config form + read-only facts. */
2016
+ //#endregion
2017
+ //#region src/client/panel/index.tsx
2018
+ /**
2019
+ * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft 与顶部状态头 /
2020
+ * tab 导航的外壳。
2021
+ *
2022
+ * 所有 Host 交互(panel / status / catalog / config / credentials / cleanup /
2023
+ * access / pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
2024
+ * 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `settings.section`
2025
+ * 槽位。
2026
+ *
2027
+ * @module dsh-channels/client/panel
2028
+ */
2029
+ /** Page shell: one panel payload, one status poll, one global config draft. */
1366
2030
  function FeishuChannelsSection(_props) {
1367
2031
  const [load, setLoad] = (0, react.useState)({ phase: "loading" });
2032
+ const [activeTab, setActiveTab] = (0, react.useState)("connection");
1368
2033
  const [draft, setDraft] = (0, react.useState)({});
1369
2034
  const [notice, setNotice] = (0, react.useState)(null);
1370
2035
  const [busy, setBusy] = (0, react.useState)(null);
@@ -1379,8 +2044,14 @@ window.__ModuleLoader__.load({
1379
2044
  const [accessNotice, setAccessNotice] = (0, react.useState)(null);
1380
2045
  const [accessError, setAccessError] = (0, react.useState)(null);
1381
2046
  const catalogTicket = (0, react.useRef)(0);
2047
+ /** Latest ready panel, read from the poll loop without restarting it. */
2048
+ const panelRef = (0, react.useRef)(null);
1382
2049
  const panel = load.phase === "ready" ? load.panel : null;
1383
- const currentStatus = status ?? panel?.status ?? null;
2050
+ /** Host-side lifecycle busy: connecting/restarting blocks every panel write. */
2051
+ const hostBusy = (status ?? panel?.status)?.busy === true;
2052
+ (0, react.useEffect)(() => {
2053
+ panelRef.current = panel;
2054
+ }, [panel]);
1384
2055
  const loadPanel = (0, react.useCallback)(async () => {
1385
2056
  setLoad({ phase: "loading" });
1386
2057
  setNotice(null);
@@ -1421,10 +2092,21 @@ window.__ModuleLoader__.load({
1421
2092
  let cancelled = false;
1422
2093
  const tick = async () => {
1423
2094
  try {
2095
+ const previousPhase = panelRef.current?.status.phase;
1424
2096
  const next = await fetchStatus();
1425
2097
  if (cancelled) return;
1426
2098
  setStatus(next);
1427
2099
  setPollError(null);
2100
+ if (previousPhase !== void 0 && previousPhase !== next.phase) try {
2101
+ const refreshed = await fetchPanel();
2102
+ if (!cancelled) {
2103
+ setLoad({
2104
+ phase: "ready",
2105
+ panel: refreshed
2106
+ });
2107
+ setStatus(refreshed.status);
2108
+ }
2109
+ } catch {}
1428
2110
  } catch (error) {
1429
2111
  if (!cancelled) setPollError(messageOf(error));
1430
2112
  }
@@ -1471,7 +2153,7 @@ window.__ModuleLoader__.load({
1471
2153
  const currentValues = (0, react.useMemo)(() => {
1472
2154
  if (panel === null) return {};
1473
2155
  const valueOf = (key) => {
1474
- const raw = draft[key] ?? effectiveValue(panel, key);
2156
+ const raw = draft[key] ?? panel.config.effective[key] ?? panel.config.base[key];
1475
2157
  return typeof raw === "string" && raw !== "" ? raw : void 0;
1476
2158
  };
1477
2159
  return {
@@ -1488,13 +2170,14 @@ window.__ModuleLoader__.load({
1488
2170
  ]);
1489
2171
  /** Whether one selector currently renders as a dropdown for this form. */
1490
2172
  const isSelectField = (0, react.useCallback)((key) => renderedFields.some((field) => field.key === key && field.kind === "select"), [renderedFields]);
2173
+ /** Draft one field, cascading provider → model → effort like the Host catalog. */
1491
2174
  const updateDraft = (0, react.useCallback)((key, value) => {
1492
2175
  if (panel === null) return;
1493
- const providerNow = String(draft.provider ?? effectiveValue(panel, "provider") ?? "");
2176
+ const providerNow = String(draft.provider ?? panel.config.effective.provider ?? panel.config.base.provider ?? "");
1494
2177
  setNotice(null);
1495
2178
  if (key === "provider") {
1496
2179
  const nextProvider = String(value ?? "");
1497
- const keepModel = nextProvider === String(effectiveValue(panel, "provider") ?? "") ? String(effectiveValue(panel, "model") ?? "") : "";
2180
+ const keepModel = nextProvider === String(panel.config.effective.provider ?? panel.config.base.provider ?? "") ? String(panel.config.effective.model ?? panel.config.base.model ?? "") : "";
1498
2181
  setDraft((previous) => ({
1499
2182
  ...previous,
1500
2183
  provider: value,
@@ -1534,18 +2217,34 @@ window.__ModuleLoader__.load({
1534
2217
  }
1535
2218
  }, []);
1536
2219
  /**
1537
- * Save one scope of the form. The global footer saves every draft key; the
1538
- * model card's own button saves only the four model-facing keys, so editing
1539
- * the model does not require scrolling to the bottom.
2220
+ * Manual refresh: reload the whole panel payload so access lists, the cleanup
2221
+ * preview and `restartPendingKeys` catch up with the Host. The dirty draft is
2222
+ * deliberately preserved — refreshing never discards user input.
1540
2223
  */
1541
- const onSave = (0, react.useCallback)(async (scope) => {
2224
+ const onRefresh = (0, react.useCallback)(async () => {
2225
+ try {
2226
+ const next = await fetchPanel();
2227
+ setLoad({
2228
+ phase: "ready",
2229
+ panel: next
2230
+ });
2231
+ setStatus(next.status);
2232
+ setPollError(null);
2233
+ setAccessNotice(null);
2234
+ setAccessError(null);
2235
+ } catch (error) {
2236
+ setPollError(messageOf(error));
2237
+ }
2238
+ }, []);
2239
+ /** Submit every dirty field at once; the Host persists and re-validates. */
2240
+ const onSave = (0, react.useCallback)(async () => {
1542
2241
  if (panel === null) return;
1543
- const only = scope === "model" ? new Set(CATALOG_KEYS) : void 0;
1544
- const { values, issues } = validateDraft(panel, draft, only);
2242
+ const { values, issues } = validateDraft(panel, draft);
1545
2243
  if (issues.length > 0) {
2244
+ const detail = issues.map((issue) => `${fieldLabel(panel.config.fields, issue.key)} ${issue.message}`).join(";");
1546
2245
  setNotice({
1547
2246
  kind: "error",
1548
- text: "请先修正标红的配置项",
2247
+ text: `${PANEL_COPY.saveInvalid}:${detail}`,
1549
2248
  issues
1550
2249
  });
1551
2250
  return;
@@ -1570,17 +2269,15 @@ window.__ModuleLoader__.load({
1570
2269
  setStatus(result.panel.status);
1571
2270
  setCleanupResult(null);
1572
2271
  setCleanupError(null);
1573
- const cleared = result.clearedKeys.length === 0 ? "" : `;已清除覆盖:${result.clearedKeys.map((key) => fieldLabel(result.panel.config.fields, key)).join("、")}`;
1574
- setNotice({
1575
- kind: "ok",
1576
- text: `${scope === "model" ? PANEL_COPY.savedModel : PANEL_COPY.saved}${cleared}`
1577
- });
1578
2272
  setDraft((previous) => {
1579
- if (scope !== "model") return {};
1580
2273
  const next = { ...previous };
1581
- for (const key of CATALOG_KEYS) delete next[key];
2274
+ for (const key of Object.keys(values)) delete next[key];
1582
2275
  return next;
1583
2276
  });
2277
+ setNotice({
2278
+ kind: "ok",
2279
+ text: PANEL_COPY.saved
2280
+ });
1584
2281
  } catch (error) {
1585
2282
  setNotice({
1586
2283
  kind: "error",
@@ -1590,6 +2287,56 @@ window.__ModuleLoader__.load({
1590
2287
  setBusy(null);
1591
2288
  }
1592
2289
  }, [draft, panel]);
2290
+ /** Drop the draft keys owned by one tab (nothing is written). */
2291
+ const discardTab = (0, react.useCallback)((tab) => {
2292
+ if (panel === null) return;
2293
+ const groups = new Set(TAB_FIELD_GROUPS[tab]);
2294
+ const keys = new Set(panel.config.fields.filter((field) => groups.has(field.group)).map((field) => field.key));
2295
+ setDraft((previous) => {
2296
+ const next = { ...previous };
2297
+ for (const key of keys) delete next[key];
2298
+ return next;
2299
+ });
2300
+ setNotice(null);
2301
+ if (tab === "model") {
2302
+ catalogTicket.current += 1;
2303
+ setCatalog(catalogFromFields(panel.config.fields));
2304
+ setCatalogBusy(false);
2305
+ }
2306
+ }, [panel]);
2307
+ /**
2308
+ * Open the host's native directory chooser (workspace root「选择目录…」) and
2309
+ * stage the picked absolute path as a draft value. Nothing is persisted until
2310
+ *「保存更改」; a canceled dialog is a no-op, not an error.
2311
+ */
2312
+ const onPickDirectory = (0, react.useCallback)(async (key) => {
2313
+ setNotice(null);
2314
+ try {
2315
+ const result = await pickDirectory();
2316
+ if (!result.ok) {
2317
+ setNotice({
2318
+ kind: "error",
2319
+ text: result.error
2320
+ });
2321
+ return;
2322
+ }
2323
+ if ("canceled" in result) return;
2324
+ setDraft((previous) => ({
2325
+ ...previous,
2326
+ [key]: result.path
2327
+ }));
2328
+ setNotice({
2329
+ kind: "ok",
2330
+ text: `${PANEL_COPY.directoryPicked}:${result.path}`
2331
+ });
2332
+ } catch (error) {
2333
+ setNotice({
2334
+ kind: "error",
2335
+ text: messageOf(error)
2336
+ });
2337
+ }
2338
+ }, []);
2339
+ /** Drop every Web override and return the refreshed panel. */
1593
2340
  const onClear = (0, react.useCallback)(async () => {
1594
2341
  setBusy("clear");
1595
2342
  setNotice(null);
@@ -1623,8 +2370,15 @@ window.__ModuleLoader__.load({
1623
2370
  setBusy(null);
1624
2371
  }
1625
2372
  }, []);
2373
+ const running = (status ?? panel?.status)?.running === true;
2374
+ /**
2375
+ * The single connect/reconnect action: open a stopped channel or restart a
2376
+ * running one. Only a restart interrupts live replies, so only it confirms.
2377
+ */
1626
2378
  const onReconnect = (0, react.useCallback)(async () => {
1627
- if (!(typeof window.confirm === "function" ? window.confirm(PANEL_COPY.restartWarning) : true)) return;
2379
+ if (running) {
2380
+ if (!(typeof window.confirm === "function" ? window.confirm(PANEL_COPY.restartWarning) : true)) return;
2381
+ }
1628
2382
  setBusy("reconnect");
1629
2383
  setNotice(null);
1630
2384
  try {
@@ -1634,6 +2388,7 @@ window.__ModuleLoader__.load({
1634
2388
  kind: "error",
1635
2389
  text: result.error
1636
2390
  });
2391
+ if (result.unconfigured === true) setActiveTab("connection");
1637
2392
  await refreshStatus();
1638
2393
  return;
1639
2394
  }
@@ -1644,7 +2399,7 @@ window.__ModuleLoader__.load({
1644
2399
  setStatus(result.panel.status);
1645
2400
  setNotice({
1646
2401
  kind: "ok",
1647
- text: "已重启飞书连接"
2402
+ text: running ? PANEL_COPY.reconnectDone : PANEL_COPY.connectDone
1648
2403
  });
1649
2404
  } catch (error) {
1650
2405
  setNotice({
@@ -1655,7 +2410,7 @@ window.__ModuleLoader__.load({
1655
2410
  } finally {
1656
2411
  setBusy(null);
1657
2412
  }
1658
- }, [refreshStatus]);
2413
+ }, [refreshStatus, running]);
1659
2414
  /**
1660
2415
  * Delete stale workspaces after an explicit confirmation. The Host refreshes
1661
2416
  * the panel in the same response, so the preview counts update immediately;
@@ -1664,7 +2419,7 @@ window.__ModuleLoader__.load({
1664
2419
  const onRunCleanup = (0, react.useCallback)(async () => {
1665
2420
  if (panel === null) return;
1666
2421
  const { cleanup } = panel;
1667
- if (!(typeof window.confirm === "function" ? window.confirm(`${PANEL_COPY.cleanupConfirm}\n本次将删除 ${cleanup.candidates} 个工作区,预计释放 ${formatBytes(cleanup.candidateBytes)}。`) : true)) return;
2422
+ if (!(typeof window.confirm === "function" ? window.confirm(`${PANEL_COPY.cleanupConfirm}\n${PANEL_COPY.cleanupCandidates}:${cleanup.candidates} · ${PANEL_COPY.cleanupCandidateBytes}:${formatBytes(cleanup.candidateBytes)}`) : true)) return;
1668
2423
  setBusy("cleanup");
1669
2424
  setNotice(null);
1670
2425
  setCleanupResult(null);
@@ -1791,6 +2546,7 @@ window.__ModuleLoader__.load({
1791
2546
  setBusy(null);
1792
2547
  }
1793
2548
  }, [applyAccess]);
2549
+ /** Save provided credential values; the Host reconnects automatically when ready. */
1794
2550
  const onSaveCredentials = (0, react.useCallback)(async () => {
1795
2551
  const values = {};
1796
2552
  if (appIdInput.trim() !== "") values.appId = appIdInput;
@@ -1815,10 +2571,10 @@ window.__ModuleLoader__.load({
1815
2571
  setStatus(result.panel.status);
1816
2572
  setAppIdInput("");
1817
2573
  setAppSecretInput("");
1818
- const base = result.reconnect === "pending" ? PANEL_COPY.credentialSavedPending : result.reconnect === "auto" ? PANEL_COPY.credentialSavedReconnected : PANEL_COPY.credentialSaved;
2574
+ const base = result.reconnect === "pending" ? PANEL_COPY.credentialSavedPending : result.reconnect === "auto" ? PANEL_COPY.credentialSavedReconnected : result.reconnect === "unconfigured" ? PANEL_COPY.credentialSavedUnconfigured : result.reconnect === "failed" ? PANEL_COPY.credentialAutoConnectFailed : PANEL_COPY.credentialSaved;
1819
2575
  setNotice({
1820
- kind: result.autoReconnectError === void 0 ? "ok" : "error",
1821
- text: result.autoReconnectError === void 0 ? base : `${base};自动重连失败:${result.autoReconnectError}`
2576
+ kind: result.reconnect === "failed" || result.autoReconnectError !== void 0 ? "error" : "ok",
2577
+ text: result.autoReconnectError === void 0 ? base : `${base}:${result.autoReconnectError}`
1822
2578
  });
1823
2579
  } catch (error) {
1824
2580
  setNotice({
@@ -1830,8 +2586,7 @@ window.__ModuleLoader__.load({
1830
2586
  }
1831
2587
  }, [appIdInput, appSecretInput]);
1832
2588
  const onClearCredential = (0, react.useCallback)(async (field) => {
1833
- const label = field === "appId" ? PANEL_COPY.appIdLabel : PANEL_COPY.appSecretLabel;
1834
- if (!(typeof window.confirm === "function" ? window.confirm(`确定移除已存储的 ${label}?`) : true)) return;
2589
+ if (!(typeof window.confirm === "function" ? window.confirm(PANEL_COPY.credentialRemoveConfirm) : true)) return;
1835
2590
  setBusy("credentials-clear");
1836
2591
  setNotice(null);
1837
2592
  try {
@@ -1862,229 +2617,279 @@ window.__ModuleLoader__.load({
1862
2617
  setBusy(null);
1863
2618
  }
1864
2619
  }, []);
1865
- const groupedFields = (0, react.useMemo)(() => {
1866
- if (panel === null) return [];
1867
- return GROUP_ORDER.map((group) => ({
1868
- group,
1869
- label: GROUP_LABELS[group],
1870
- fields: renderedFields.filter((field) => field.group === group)
1871
- })).filter((entry) => entry.fields.length > 0);
1872
- }, [panel, renderedFields]);
1873
- const modelEntries = groupedFields.filter((entry) => entry.group === "model");
1874
- const configEntries = groupedFields.filter((entry) => entry.group !== "model");
1875
- const dirty = Object.keys(draft).length > 0;
1876
- const modelDirty = CATALOG_KEYS.some((key) => draft[key] !== void 0);
1877
- const issues = notice?.kind === "error" ? notice.issues ?? [] : [];
2620
+ const issues = (0, react.useMemo)(() => notice?.kind === "error" ? notice.issues ?? [] : [], [notice]);
2621
+ const dirtyCount = Object.keys(draft).length;
2622
+ /** Which tabs currently own a dirty key (the dot is per tab, not global). */
2623
+ const dirtyTabs = (0, react.useMemo)(() => {
2624
+ const tabs = /* @__PURE__ */ new Set();
2625
+ if (panel === null || dirtyCount === 0) return tabs;
2626
+ const groupOf = new Map(panel.config.fields.map((field) => [field.key, field.group]));
2627
+ for (const key of Object.keys(draft)) {
2628
+ const group = groupOf.get(key);
2629
+ if (group === void 0) continue;
2630
+ for (const tab of PANEL_TABS) if (TAB_FIELD_GROUPS[tab.id].includes(group)) tabs.add(tab.id);
2631
+ }
2632
+ return tabs;
2633
+ }, [
2634
+ dirtyCount,
2635
+ draft,
2636
+ panel
2637
+ ]);
2638
+ const form = (0, react.useMemo)(() => {
2639
+ if (panel === null) return null;
2640
+ return {
2641
+ panel,
2642
+ fields: renderedFields,
2643
+ draft,
2644
+ issues,
2645
+ busy,
2646
+ locked: hostBusy,
2647
+ dirtyCount,
2648
+ onChange: updateDraft,
2649
+ onSave: () => {
2650
+ onSave();
2651
+ },
2652
+ onDiscardTab: discardTab,
2653
+ onPickDirectory
2654
+ };
2655
+ }, [
2656
+ busy,
2657
+ discardTab,
2658
+ dirtyCount,
2659
+ draft,
2660
+ hostBusy,
2661
+ issues,
2662
+ onPickDirectory,
2663
+ onSave,
2664
+ panel,
2665
+ renderedFields,
2666
+ updateDraft
2667
+ ]);
2668
+ const credentials = (0, react.useMemo)(() => ({
2669
+ appId: appIdInput,
2670
+ appSecret: appSecretInput,
2671
+ issues,
2672
+ busy,
2673
+ locked: hostBusy,
2674
+ onAppIdChange: setAppIdInput,
2675
+ onAppSecretChange: setAppSecretInput,
2676
+ onSave: () => {
2677
+ onSaveCredentials();
2678
+ },
2679
+ onClear: (field) => {
2680
+ onClearCredential(field);
2681
+ }
2682
+ }), [
2683
+ appIdInput,
2684
+ appSecretInput,
2685
+ busy,
2686
+ hostBusy,
2687
+ issues,
2688
+ onClearCredential,
2689
+ onSaveCredentials
2690
+ ]);
1878
2691
  if (load.phase === "loading") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1879
- className: "dsh-channels-cfg__loading",
1880
- children: PANEL_COPY.loading
2692
+ className: "dsh-channels-cfg",
2693
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2694
+ className: "dsh-channels-cfg__loading",
2695
+ children: PANEL_COPY.loading
2696
+ })
1881
2697
  });
1882
- if (load.phase === "error") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1883
- className: "dsh-channels-cfg__error",
1884
- children: [
1885
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.loadFailed }),
1886
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: load.message }),
1887
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1888
- type: "button",
1889
- className: "dsh-channels-cfg__btn",
1890
- onClick: () => {
1891
- loadPanel();
1892
- },
1893
- children: PANEL_COPY.retry
1894
- }) })
1895
- ]
2698
+ if (load.phase === "error") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2699
+ className: "dsh-channels-cfg",
2700
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2701
+ className: "dsh-channels-cfg__error",
2702
+ children: [
2703
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.loadFailed }),
2704
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: load.message }),
2705
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2706
+ type: "button",
2707
+ className: "dsh-channels-cfg__btn",
2708
+ onClick: () => {
2709
+ loadPanel();
2710
+ },
2711
+ children: PANEL_COPY.retry
2712
+ }) })
2713
+ ]
2714
+ })
1896
2715
  });
2716
+ if (form === null) return null;
2717
+ const current = status ?? load.panel.status;
1897
2718
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1898
2719
  className: "dsh-channels-cfg",
1899
2720
  children: [
1900
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
1901
- className: "dsh-channels-cfg__head",
1902
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1903
- className: "dsh-channels-cfg__title",
1904
- children: PANEL_COPY.title
1905
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1906
- className: "dsh-channels-cfg__desc",
1907
- children: PANEL_COPY.description
1908
- })]
1909
- }),
1910
- currentStatus === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusCard, {
1911
- status: currentStatus,
2721
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PanelHeader, {
2722
+ status: current,
1912
2723
  pollError,
1913
2724
  busy,
2725
+ locked: hostBusy,
1914
2726
  onRefresh: () => {
1915
- refreshStatus();
1916
- },
1917
- onReconnect: () => {
1918
- onReconnect();
2727
+ onRefresh();
1919
2728
  }
1920
2729
  }),
1921
- notice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1922
- className: `dsh-channels-cfg__notice dsh-channels-cfg__notice--${notice.kind === "ok" ? "ok" : "error"}`,
1923
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: notice.text })
1924
- }),
1925
- panel === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CredentialsCard, {
1926
- panel,
1927
- appId: appIdInput,
1928
- appSecret: appSecretInput,
1929
- issues,
1930
- busy,
1931
- onAppIdChange: setAppIdInput,
1932
- onAppSecretChange: setAppSecretInput,
1933
- onSave: () => {
1934
- onSaveCredentials();
1935
- },
1936
- onClear: (field) => {
1937
- onClearCredential(field);
1938
- }
2730
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabNav, {
2731
+ activeTab,
2732
+ dirtyTabs,
2733
+ onChange: setActiveTab
1939
2734
  }),
1940
- panel === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1941
- panel.config.restartPendingKeys.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1942
- className: "dsh-channels-cfg__banner",
1943
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [PANEL_COPY.restartBanner, panel.config.restartPendingKeys.map((key) => fieldLabel(panel.config.fields, key)).join("、")] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1944
- type: "button",
1945
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--danger",
1946
- onClick: () => {
2735
+ notice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeBar, { notice }),
2736
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2737
+ role: "tabpanel",
2738
+ id: `dsh-channels-tabpanel-${activeTab}`,
2739
+ "aria-labelledby": `dsh-channels-tab-${activeTab}`,
2740
+ children: [
2741
+ activeTab === "connection" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConnectionTab, {
2742
+ panel: form.panel,
2743
+ status: current,
2744
+ busy,
2745
+ credentials,
2746
+ onReconnect: () => {
1947
2747
  onReconnect();
2748
+ }
2749
+ }) : null,
2750
+ activeTab === "sessions" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionsTab, { form }) : null,
2751
+ activeTab === "model" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelTab, {
2752
+ form,
2753
+ catalogBusy
2754
+ }) : null,
2755
+ activeTab === "workspace" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WorkspaceTab, {
2756
+ form,
2757
+ cleanup: form.panel.cleanup,
2758
+ cleanupResult,
2759
+ cleanupError,
2760
+ onRunCleanup: () => {
2761
+ onRunCleanup();
2762
+ }
2763
+ }) : null,
2764
+ activeTab === "access" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessTab, {
2765
+ form,
2766
+ accessNotice,
2767
+ accessError,
2768
+ onAddGroup: onAddAccessGroup,
2769
+ onRemoveGroup: (chatId) => {
2770
+ onRemoveAccessGroup(chatId);
1948
2771
  },
1949
- disabled: busy !== null,
1950
- children: busy === "reconnect" ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect
1951
- })]
1952
- }),
1953
- modelEntries.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1954
- className: "dsh-channels-cfg__card",
1955
- children: [
1956
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1957
- className: "dsh-channels-cfg__cardHead",
1958
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1959
- className: "dsh-channels-cfg__cardTitle",
1960
- children: PANEL_COPY.modelCard
1961
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1962
- className: "dsh-channels-cfg__cardMeta",
1963
- children: catalogBusy ? PANEL_COPY.catalogLoading : "选项来自宿主机目录"
1964
- })]
1965
- }),
1966
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1967
- className: "dsh-channels-cfg__help",
1968
- children: PANEL_COPY.modelCardHelp
1969
- }),
1970
- modelEntries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1971
- className: "dsh-channels-cfg__grid",
1972
- children: entry.fields.map((field) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldRow, {
1973
- field,
1974
- value: draft[field.key] ?? effectiveValue(panel, field.key),
1975
- issues,
1976
- onChange: updateDraft
1977
- }, field.key))
1978
- }, entry.group)),
1979
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1980
- className: "dsh-channels-cfg__footer",
1981
- children: [
1982
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1983
- type: "button",
1984
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1985
- onClick: () => {
1986
- onSave("model");
1987
- },
1988
- disabled: !modelDirty || busy !== null,
1989
- children: busy === "save" ? PANEL_COPY.saving : PANEL_COPY.saveModel
1990
- }),
1991
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
1992
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1993
- className: "dsh-channels-cfg__help",
1994
- children: modelDirty ? "有未保存的模型修改" : PANEL_COPY.modelActionsHint
1995
- })
1996
- ]
1997
- })
1998
- ]
1999
- }),
2000
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2001
- className: "dsh-channels-cfg__card",
2002
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2003
- className: "dsh-channels-cfg__cardHead",
2004
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2005
- className: "dsh-channels-cfg__cardTitle",
2006
- children: PANEL_COPY.configCard
2007
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2008
- className: "dsh-channels-cfg__cardMeta",
2009
- children: panel.config.overriddenKeys.length === 0 ? PANEL_COPY.inherited : `${PANEL_COPY.overridden} ${panel.config.overriddenKeys.length} 项`
2010
- })]
2011
- }), configEntries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2012
- className: "dsh-channels-cfg__cardHead",
2013
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2014
- className: "dsh-channels-cfg__cardTitle",
2015
- children: entry.label
2016
- })
2017
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2018
- className: "dsh-channels-cfg__grid",
2019
- children: entry.fields.map((field) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldRow, {
2020
- field,
2021
- value: draft[field.key] ?? effectiveValue(panel, field.key),
2022
- issues,
2023
- onChange: updateDraft
2024
- }, field.key))
2025
- })] }, entry.group))]
2026
- }),
2027
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2028
- className: "dsh-channels-cfg__footer",
2029
- children: [
2030
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2031
- type: "button",
2032
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
2033
- onClick: () => {
2034
- onSave();
2035
- },
2036
- disabled: !dirty || busy !== null,
2037
- children: busy === "save" ? PANEL_COPY.saving : PANEL_COPY.saveAll
2038
- }),
2039
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2040
- type: "button",
2041
- className: "dsh-channels-cfg__btn",
2042
- onClick: () => {
2043
- onClear();
2044
- },
2045
- disabled: panel.config.overriddenKeys.length === 0 || busy !== null,
2046
- title: panel.config.overriddenKeys.length === 0 ? PANEL_COPY.noOverrides : void 0,
2047
- children: busy === "clear" ? PANEL_COPY.clearing : PANEL_COPY.clear
2048
- }),
2049
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
2050
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2051
- className: "dsh-channels-cfg__help",
2052
- children: dirty ? "有未保存的修改" : "只保存改动过的字段"
2053
- })
2054
- ]
2055
- }),
2056
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CleanupCard, {
2057
- panel,
2058
- cleanup: panel.cleanup,
2059
- busy,
2060
- result: cleanupResult,
2061
- error: cleanupError,
2062
- onRun: () => {
2063
- onRunCleanup();
2064
- }
2065
- }),
2066
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessCard, {
2067
- panel,
2068
- busy,
2069
- notice: accessNotice,
2070
- error: accessError,
2071
- onAddGroup: onAddAccessGroup,
2072
- onRemoveGroup: (chatId) => {
2073
- onRemoveAccessGroup(chatId);
2074
- },
2075
- onAddUser: onAddAccessUser,
2076
- onRemoveUser: (openId) => {
2077
- onRemoveAccessUser(openId);
2078
- },
2079
- onResolveRequest: (token, decision) => {
2080
- onResolveAccessRequest(token, decision);
2081
- }
2082
- }),
2083
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdvancedCard, { panel })
2084
- ] })
2772
+ onAddUser: onAddAccessUser,
2773
+ onRemoveUser: (openId) => {
2774
+ onRemoveAccessUser(openId);
2775
+ },
2776
+ onResolveRequest: (token, decision) => {
2777
+ onResolveAccessRequest(token, decision);
2778
+ }
2779
+ }) : null,
2780
+ activeTab === "advanced" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdvancedTab, {
2781
+ form,
2782
+ onClear: () => {
2783
+ onClear();
2784
+ }
2785
+ }) : null
2786
+ ]
2787
+ })
2788
+ ]
2789
+ });
2790
+ }
2791
+ /** Page header: title + phase badge + refresh, visible on every tab. */
2792
+ function PanelHeader(props) {
2793
+ const { status, pollError, busy, locked, onRefresh } = props;
2794
+ const badge = phaseBadge(status.phase);
2795
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
2796
+ className: "dsh-channels-cfg__panelHead",
2797
+ title: PANEL_COPY.description,
2798
+ children: [
2799
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2800
+ className: "dsh-channels-cfg__headRow",
2801
+ children: [
2802
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
2803
+ className: "dsh-channels-cfg__title",
2804
+ children: PANEL_COPY.title
2805
+ }),
2806
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2807
+ className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${badge.tone}`,
2808
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__dot" }), badge.text]
2809
+ }),
2810
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
2811
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2812
+ type: "button",
2813
+ className: "dsh-channels-cfg__btn",
2814
+ onClick: onRefresh,
2815
+ disabled: busy !== null || locked,
2816
+ children: PANEL_COPY.refresh
2817
+ })
2818
+ ]
2819
+ }),
2820
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2821
+ className: "dsh-channels-cfg__summary",
2822
+ children: phaseSummary(status.phase)
2823
+ }),
2824
+ pollError === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2825
+ className: "dsh-channels-cfg__fieldError",
2826
+ role: "status",
2827
+ "aria-live": "polite",
2828
+ children: [
2829
+ PANEL_COPY.pollFailed,
2830
+ ":",
2831
+ pollError
2832
+ ]
2833
+ })
2085
2834
  ]
2086
2835
  });
2087
2836
  }
2837
+ /** Top-level tab navigation; the dirty dot keeps unsaved work visible per tab. */
2838
+ function TabNav(props) {
2839
+ const { activeTab, dirtyTabs, onChange } = props;
2840
+ const refs = (0, react.useRef)([]);
2841
+ /** Roving-focus keyboard navigation between tabs (WAI-ARIA tabs pattern). */
2842
+ const onKeyDown = (index, event) => {
2843
+ const last = PANEL_TABS.length - 1;
2844
+ let next;
2845
+ switch (event.key) {
2846
+ case "ArrowRight":
2847
+ next = index === last ? 0 : index + 1;
2848
+ break;
2849
+ case "ArrowLeft":
2850
+ next = index === 0 ? last : index - 1;
2851
+ break;
2852
+ case "Home":
2853
+ next = 0;
2854
+ break;
2855
+ case "End":
2856
+ next = last;
2857
+ break;
2858
+ default: return;
2859
+ }
2860
+ event.preventDefault();
2861
+ onChange(PANEL_TABS[next].id);
2862
+ refs.current[next]?.focus();
2863
+ };
2864
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("nav", {
2865
+ className: "dsh-channels-cfg__tabs",
2866
+ role: "tablist",
2867
+ "aria-label": PANEL_COPY.title,
2868
+ children: PANEL_TABS.map((tab, index) => {
2869
+ const selected = tab.id === activeTab;
2870
+ const dirty = dirtyTabs.has(tab.id);
2871
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2872
+ ref: (node) => {
2873
+ refs.current[index] = node;
2874
+ },
2875
+ type: "button",
2876
+ role: "tab",
2877
+ id: `dsh-channels-tab-${tab.id}`,
2878
+ "aria-selected": selected,
2879
+ "aria-controls": selected ? `dsh-channels-tabpanel-${tab.id}` : void 0,
2880
+ tabIndex: selected ? 0 : -1,
2881
+ className: "dsh-channels-cfg__tab",
2882
+ onClick: () => onChange(tab.id),
2883
+ onKeyDown: (event) => onKeyDown(index, event),
2884
+ children: [tab.label, dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2885
+ className: "dsh-channels-cfg__tabDot",
2886
+ title: PANEL_COPY.dirtyCount,
2887
+ "aria-label": PANEL_COPY.dirtyCount
2888
+ }) : null]
2889
+ }, tab.id);
2890
+ })
2891
+ });
2892
+ }
2088
2893
  //#endregion
2089
2894
  //#region src/client/styles.ts
2090
2895
  /**
@@ -2097,14 +2902,23 @@ window.__ModuleLoader__.load({
2097
2902
  const STYLE_ATTRIBUTE = "data-dsh-channels-css";
2098
2903
  const CSS = `
2099
2904
  .dsh-channels-cfg{container-type:inline-size;display:flex;flex-direction:column;gap:12px;width:100%;max-width:720px;color:var(--dsw-alias-label-primary,#1f2328);font-size:13px;line-height:20px}
2100
- .dsh-channels-cfg__head{display:flex;flex-direction:column;gap:4px}
2905
+ .dsh-channels-cfg__panelHead{position:sticky;top:0;z-index:2;display:flex;flex-direction:column;gap:4px;padding:10px 0 8px;background:var(--dsw-alias-bg-base,#fff);border-bottom:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12))}
2906
+ .dsh-channels-cfg__headRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
2101
2907
  .dsh-channels-cfg__title{margin:0;font-size:16px;font-weight:500;line-height:24px}
2102
- .dsh-channels-cfg__desc{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
2908
+ .dsh-channels-cfg__summary{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
2909
+ .dsh-channels-cfg__tabs{display:flex;align-items:center;gap:2px;flex-wrap:wrap;border-bottom:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12))}
2910
+ .dsh-channels-cfg__tab{display:inline-flex;align-items:center;gap:5px;min-height:32px;padding:0 10px;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary,#5c6169);font:inherit;font-size:13px;cursor:pointer;white-space:nowrap}
2911
+ .dsh-channels-cfg__tab:hover{color:var(--dsw-alias-label-primary,#1f2328)}
2912
+ .dsh-channels-cfg__tab:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#0a67d3);outline-offset:-2px}
2913
+ .dsh-channels-cfg__tab[aria-selected="true"]{color:var(--dsw-alias-state-business-primary,#0a67d3);border-bottom-color:currentColor;font-weight:500}
2914
+ .dsh-channels-cfg__tabDot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-warning-primary,#a15c00);flex:none}
2915
+ .dsh-channels-cfg__tabBody{display:flex;flex-direction:column;gap:12px}
2916
+ .dsh-channels-cfg__tabHelp{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
2103
2917
  .dsh-channels-cfg__card{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:12px}
2104
2918
  .dsh-channels-cfg__cardHead{display:flex;align-items:center;gap:8px;min-height:20px;flex-wrap:wrap}
2105
2919
  .dsh-channels-cfg__cardTitle{font-size:13px;font-weight:500}
2106
2920
  .dsh-channels-cfg__cardMeta{margin-left:auto;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px}
2107
- .dsh-channels-cfg__statusRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
2921
+ .dsh-channels-cfg__group{display:flex;flex-direction:column;gap:8px}
2108
2922
  .dsh-channels-cfg__badge{display:inline-flex;align-items:center;gap:5px;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));font-size:12px;line-height:18px;white-space:nowrap}
2109
2923
  .dsh-channels-cfg__dot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:none}
2110
2924
  .dsh-channels-cfg__badge--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);border-color:currentColor}
@@ -2129,29 +2943,37 @@ window.__ModuleLoader__.load({
2129
2943
  .dsh-channels-cfg__labelRow{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
2130
2944
  .dsh-channels-cfg__label{font-size:12px;color:var(--dsw-alias-label-secondary,#5c6169)}
2131
2945
  .dsh-channels-cfg__tag{display:inline-flex;align-items:center;padding:0 5px;border-radius:4px;border:1px dashed currentColor;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#8a8f98);white-space:nowrap}
2132
- .dsh-channels-cfg__tag--override{color:var(--dsw-alias-state-business-primary,#0a67d3);border-style:solid}
2133
2946
  .dsh-channels-cfg__tag--restart{color:var(--dsw-alias-state-warning-primary,#a15c00)}
2947
+ .dsh-channels-cfg__tag--dirty{color:var(--dsw-alias-state-warning-primary,#a15c00);border-style:solid}
2134
2948
  .dsh-channels-cfg__help{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:11px;line-height:16px}
2135
2949
  .dsh-channels-cfg__reasonBad{color:var(--dsw-alias-state-danger-primary,#c62828)}
2136
2950
  .dsh-channels-cfg__input{box-sizing:border-box;width:100%;min-height:28px;padding:3px 10px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px;background:var(--dsw-alias-bg-layer-1,transparent);color:inherit;font:inherit;font-size:13px;outline:none}
2951
+ .dsh-channels-cfg__inputRow{display:flex;align-items:center;gap:8px;min-width:0}
2952
+ .dsh-channels-cfg__inputRow .dsh-channels-cfg__input{flex:1 1 auto;width:auto;min-width:0}
2953
+ .dsh-channels-cfg__btn--browse{flex:none}
2137
2954
  .dsh-channels-cfg__input:focus{border-color:var(--dsw-alias-state-business-primary,#0a67d3)}
2138
2955
  .dsh-channels-cfg__input--invalid{border-color:var(--dsw-alias-state-danger-primary,#c62828)}
2139
2956
  select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
2140
2957
  .dsh-channels-cfg__check{display:inline-flex;align-items:center;gap:6px;min-height:28px;cursor:pointer;font-size:13px}
2141
2958
  .dsh-channels-cfg__checkbox{width:15px;height:15px;margin:0;accent-color:var(--dsw-alias-state-business-primary,#0a67d3);cursor:pointer}
2142
2959
  .dsh-channels-cfg__fieldError{color:var(--dsw-alias-state-danger-primary,#c62828);font-size:11px;line-height:16px}
2143
- .dsh-channels-cfg__notice{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:18px}
2960
+ .dsh-channels-cfg__notice{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:18px;overflow-wrap:anywhere}
2144
2961
  .dsh-channels-cfg__notice--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);background:color-mix(in srgb,currentColor 8%,transparent)}
2145
2962
  .dsh-channels-cfg__notice--error{color:var(--dsw-alias-state-danger-primary,#c62828);background:color-mix(in srgb,currentColor 8%,transparent)}
2146
2963
  .dsh-channels-cfg__banner{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-radius:8px;border:1px dashed var(--dsw-alias-state-warning-primary,#a15c00);color:var(--dsw-alias-state-warning-primary,#a15c00);font-size:12px;line-height:18px}
2147
2964
  .dsh-channels-cfg__footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:2px}
2148
2965
  .dsh-channels-cfg__spacer{flex:1}
2966
+ .dsh-channels-cfg__steps{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
2967
+ .dsh-channels-cfg__step{display:flex;align-items:flex-start;gap:10px}
2968
+ .dsh-channels-cfg__stepIndex{display:inline-flex;align-items:center;justify-content:center;flex:none;width:20px;height:20px;border-radius:50%;border:1px solid currentColor;color:var(--dsw-alias-state-business-primary,#0a67d3);font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}
2969
+ .dsh-channels-cfg__stepBody{display:flex;flex-direction:column;gap:2px;min-width:0}
2970
+ .dsh-channels-cfg__stepTitle{font-size:13px;font-weight:500}
2971
+ .dsh-channels-cfg__emptyState{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
2149
2972
  .dsh-channels-cfg__readOnly{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 20px}
2150
2973
  .dsh-channels-cfg__kv{display:flex;flex-direction:column;gap:2px;min-width:0}
2151
2974
  .dsh-channels-cfg__kvKey{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:16px}
2152
2975
  .dsh-channels-cfg__kvValue{overflow-wrap:anywhere}
2153
2976
  .dsh-channels-cfg__mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
2154
- .dsh-channels-cfg__creds{display:flex;flex-direction:column;gap:6px}
2155
2977
  .dsh-channels-cfg__cred{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
2156
2978
  .dsh-channels-cfg__cred .dsh-channels-cfg__input{flex:1 1 240px;min-width:0;width:auto}
2157
2979
  .dsh-channels-cfg__accessSection{display:flex;flex-direction:column;gap:6px}
@@ -2168,6 +2990,7 @@ select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
2168
2990
  @container (max-width:560px){
2169
2991
  .dsh-channels-cfg__facts{grid-template-columns:repeat(2,minmax(0,1fr))}
2170
2992
  .dsh-channels-cfg__grid,.dsh-channels-cfg__readOnly{grid-template-columns:minmax(0,1fr)}
2993
+ .dsh-channels-cfg__tabs{flex-wrap:nowrap;overflow-x:auto}
2171
2994
  }
2172
2995
  `;
2173
2996
  /** Append the panel stylesheet once (idempotent across HMR / remounts). */