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