@modesto_ma/dsh-channels 0.5.0-beta.3 → 0.5.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +88 -27
  2. package/docs/attachment-pipeline-v2.md +1 -1
  3. package/docs/feishu-onboarding.md +3 -3
  4. package/lib/client.js +1427 -1338
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +7958 -6710
  7. package/lib/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/access/controller.ts +32 -1
  10. package/src/analyze/contract.ts +121 -0
  11. package/src/analyze/launcher.ts +353 -0
  12. package/src/analyze/server.ts +201 -0
  13. package/src/cards/analyze.ts +116 -0
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/streaming.ts +58 -0
  16. package/src/client/api.ts +15 -0
  17. package/src/client/number-field.ts +97 -0
  18. package/src/client/panel/access-tab.tsx +61 -20
  19. package/src/client/panel/advanced-tab.tsx +59 -68
  20. package/src/client/panel/connection-tab.tsx +147 -164
  21. package/src/client/panel/credentials-card.tsx +124 -87
  22. package/src/client/panel/fields-tab.tsx +27 -0
  23. package/src/client/panel/index.tsx +126 -55
  24. package/src/client/panel/shared.tsx +369 -252
  25. package/src/client/panel/whitelist.tsx +197 -0
  26. package/src/client/panel/wiki-tab.tsx +48 -0
  27. package/src/client/styles.ts +108 -91
  28. package/src/config.ts +30 -4
  29. package/src/domain.ts +55 -0
  30. package/src/feishu/scopes.ts +13 -7
  31. package/src/feishu/tools.ts +3 -1
  32. package/src/health.ts +12 -0
  33. package/src/host/agent.ts +5 -1
  34. package/src/index.ts +2 -2
  35. package/src/interactions/pending.ts +11 -2
  36. package/src/routing/inbound.ts +57 -14
  37. package/src/service.ts +161 -28
  38. package/src/settings/contract.ts +222 -146
  39. package/src/settings/routes.ts +66 -14
  40. package/src/settings/surface.ts +234 -203
  41. package/src/streamer.ts +156 -7
  42. package/src/workspaces.ts +31 -0
  43. package/src/client/panel/access-card.tsx +0 -303
  44. package/src/client/panel/model-tab.tsx +0 -23
  45. package/src/client/panel/sessions-tab.tsx +0 -21
  46. package/src/client/panel/workspace-tab.tsx +0 -24
package/lib/client.js CHANGED
@@ -11,7 +11,7 @@ window.__ModuleLoader__.load({
11
11
  * Web 设置面板的线上契约:Host 半侧的路由/面板数据与 Browser 半侧的渲染共用。
12
12
  *
13
13
  * 本模块**没有运行时依赖**:Host 与 Client bundle 都直接内联它。因此这里只放
14
- * 常量、字段清单与可序列化的 wire 类型,不 import zod / node / DSH 内部包。
14
+ * 常量、字段清单、可序列化的 wire 类型与无依赖的纯文案函数,不 import zod / node / DSH 内部包。
15
15
  *
16
16
  * @module dsh-channels/settings/contract
17
17
  */
@@ -32,6 +32,8 @@ window.__ModuleLoader__.load({
32
32
  const ACCESS_GROUPS_ROUTE = "/dsh-channels/access/groups";
33
33
  /** 用户白名单增删(POST/DELETE,body `{ userId, name? }` / `{ userId }`)。 */
34
34
  const ACCESS_USERS_ROUTE = "/dsh-channels/access/users";
35
+ /** 知识库白名单增删(POST/DELETE,body `{ userId, name? }` / `{ userId }`)。 */
36
+ const ACCESS_WIKI_USERS_ROUTE = "/dsh-channels/access/wiki-users";
35
37
  /** 结算一条待处理授权申请(POST,body `{ token, decision }`)。 */
36
38
  const ACCESS_REQUESTS_ROUTE = "/dsh-channels/access/requests";
37
39
  /**
@@ -54,108 +56,114 @@ window.__ModuleLoader__.load({
54
56
  },
55
57
  {
56
58
  id: "sessions",
57
- label: "会话与行为"
59
+ label: "会话"
58
60
  },
59
61
  {
60
62
  id: "model",
61
63
  label: "模型"
62
64
  },
63
65
  {
64
- id: "workspace",
65
- label: "工作区"
66
+ id: "attachments",
67
+ label: "附件与工具"
66
68
  },
67
69
  {
68
70
  id: "access",
69
71
  label: "访问授权"
70
72
  },
73
+ {
74
+ id: "wiki",
75
+ label: "知识库"
76
+ },
71
77
  {
72
78
  id: "advanced",
73
79
  label: "高级"
74
80
  }
75
81
  ];
76
- /** Which editable field groups each tab renders (the tabs own one or two). */
82
+ /** Which editable field groups each tab renders, in section order. */
77
83
  const TAB_FIELD_GROUPS = {
78
84
  connection: [],
79
85
  sessions: [
80
- "behavior",
81
- "content",
82
- "tools"
86
+ "trigger",
87
+ "workspace",
88
+ "cards",
89
+ "features"
83
90
  ],
84
91
  model: ["model"],
85
- workspace: ["workspace"],
92
+ attachments: [
93
+ "attachments",
94
+ "tools",
95
+ "limits"
96
+ ],
86
97
  access: ["access"],
87
- advanced: ["streaming"]
98
+ wiki: ["wiki"],
99
+ advanced: ["analyze", "logging"]
88
100
  };
89
101
  /** Section heading for one field group inside its tab. */
90
102
  const FIELD_GROUP_LABELS = {
91
- workspace: "工作区与文件",
92
- behavior: "行为",
93
- content: "内容与附件",
94
- tools: "模型工具",
95
- model: "模型与推理",
96
- streaming: "流式卡片与日志",
97
- access: "准入与授权"
103
+ trigger: "触发",
104
+ workspace: "工作区",
105
+ cards: "话题与卡片",
106
+ features: "飞书侧功能",
107
+ model: "模型",
108
+ attachments: "入站附件",
109
+ tools: "模型可用的飞书工具",
110
+ limits: "限额与超时",
111
+ access: "准入",
112
+ wiki: "知识库工具",
113
+ analyze: "日志分析接口",
114
+ logging: "日志"
98
115
  };
116
+ /** Groups rendered collapsed behind one summary row (rarely changed values). */
117
+ const COLLAPSED_FIELD_GROUPS = ["limits"];
99
118
  /** Display labels for the panel header/cards (kept on the wire so the client owns no copy). */
100
119
  const PANEL_COPY = {
101
120
  title: "飞书渠道",
102
- description: "飞书长连接、应用凭据与渠道配置。凭据只写入 DSH 凭据库且不回显;标注「需重连」的项在连接建立时读取。",
103
- statusCard: "连接状态",
104
121
  phaseUnconfigured: "待配置",
105
122
  phaseIdle: "未连接",
106
123
  phaseConnecting: "连接中",
107
124
  phaseConnected: "已连接",
108
125
  phaseError: "连接失败",
109
- configuredBadge: "凭据已配置",
110
- unconfiguredBadge: "凭据未配置",
111
- statusSummaryConnected: "飞书长连接已建立,机器人可以收发消息。",
112
- statusSummaryUnconfigured: "尚未配置飞书应用凭据:插件已加载,但不会连接飞书。填写凭据并保存后会自动连接。",
113
- statusSummaryIdle: "飞书应用凭据已就绪,但当前没有建立长连接。",
114
- statusSummaryConnecting: "正在连接飞书长连接…",
115
- statusSummaryError: "最近一次连接飞书失败,请检查凭据与网络后重试。",
126
+ statusSummaryConnected: "机器人在线,可以收发消息",
127
+ statusSummaryUnconfigured: "还没有填写飞书应用凭据,插件不会连接飞书",
128
+ statusSummaryIdle: "凭据已就绪,还没有建立连接",
129
+ statusSummaryConnecting: "正在连接飞书…",
130
+ statusSummaryError: "连接飞书失败,请检查凭据与网络后重试",
131
+ uptimePrefix: "运行",
116
132
  lastError: "最近一次连接失败",
117
- scopeCheckTitle: "权限自检",
118
- scopeCheckOk: "已开通插件需要的全部飞书权限。",
119
- scopeCheckMissing: "以下飞书权限未开通,开通后需在开发者后台发布新版本:",
120
- scopeCheckOpenAll: "在开发者后台开通全部缺失权限",
133
+ checksTitle: "自检",
134
+ scopeCheckOk: "插件需要的飞书权限都已开通",
135
+ scopeCheckMissing: "{n} 项权限未开通",
136
+ scopeCheckMissingRequired: "{n} 项必需权限未开通,相关功能无法使用",
137
+ scopeCheckMissingOptional: "不影响收发消息,只有相关功能不可用。开通后需在开发者后台发布新版本。",
138
+ scopeCheckPublish: "开通后需在开发者后台发布新版本。",
139
+ scopeCheckOpenAll: "全部开通",
121
140
  scopeCheckOpenOne: "去开通",
122
- scopeCheckOptional: "可选",
123
141
  scopeCheckRequired: "必需",
124
- scopeCheckError: "无法读取应用的权限授予状态:",
142
+ scopeCheckError: "无法读取权限开通情况",
125
143
  slashCommandSyncTitle: "斜杠指令面板",
126
- slashCommandSyncOk: "插件命令已同步到飞书原生斜杠指令面板,在聊天框输入 / 即可选择(飞书客户端约 5 分钟后刷新)。",
127
- slashCommandSyncTotal: "命令数",
128
- slashCommandSyncCreated: "本次新增",
129
- slashCommandSyncUpdated: "本次更新",
130
- slashCommandSyncRemoved: "本次删除",
131
- slashCommandSyncError: "同步飞书原生斜杠指令面板失败(不影响直接输入命令):",
144
+ slashCommandSyncOk: "已同步 {total} 条命令,飞书客户端约 5 分钟后刷新",
145
+ slashCommandSyncError: "同步失败,直接输入命令不受影响",
132
146
  missingConfig: "缺少配置",
133
- statusHintUnconfigured: "配置完成后插件会自动连接;也可以稍后在本页手动连接。",
134
- connectionInfo: "连接信息",
135
- connectionStateLabel: "飞书长连接",
136
- botOpenId: "Bot open_id",
137
- uptime: "运行时长",
138
147
  routeCount: "话题路由",
139
148
  liveAgentCount: "活动会话",
140
149
  inflightTurnCount: "在途回复",
150
+ botOpenId: "Bot open_id",
141
151
  healthReasons: "健康判定",
142
152
  unknownValue: "—",
143
153
  connect: "连接飞书",
144
154
  connecting: "连接中…",
145
155
  onboardingTitle: "开始使用飞书渠道",
146
- onboardingIntro: "只需三步:在飞书开放平台创建自建应用,把 App ID / App Secret 填入本页,保存后自动连接。",
147
156
  onboardingStep1Title: "创建飞书自建应用",
148
- onboardingStep1Text: "在飞书开放平台创建企业自建应用,开启机器人能力;事件订阅选择「长连接」,订阅 im.message.receive_v1(接收消息)。",
157
+ onboardingStep1Text: "在 open.feishu.cn/app 创建企业自建应用,开启机器人能力;事件订阅选「长连接」,订阅 im.message.receive_v1。",
149
158
  onboardingStep2Title: "填写应用凭据",
150
- onboardingStep2Text: "把应用的 App ID(cli_ 开头)与 App Secret 填入下方「飞书应用凭据」,值会写入 DSH 凭据库。",
151
- onboardingStep3Title: "保存并等待连接",
152
- onboardingStep3Text: "两项凭据都保存后插件会自动连接;状态变为「已连接」即可在飞书里私聊机器人。",
153
- onboardingDocsHint: "开放平台地址:open.feishu.cn/app;应用需要开通 im:message、im:message:send_as_bot 等消息权限。",
154
- credentialsCard: "飞书应用凭据",
155
- credentialsCardHelp: "凭据值经 DSH 凭据服务写入本机凭据库;面板只显示「已配置 / 未配置」,永不回显已存值。两项都配置后才会连接飞书。",
156
- appIdLabel: "飞书 App ID",
157
- appSecretLabel: "飞书 App Secret",
158
- credentialsRef: "凭据引用",
159
+ onboardingStep2Text: "把 App ID(cli_ 开头)和 App Secret 填到下方「应用凭据」。",
160
+ onboardingStep3Title: "保存后自动连接",
161
+ onboardingStep3Text: "两项都保存后插件会自动连接,状态变成「已连接」就能在飞书里私聊机器人。",
162
+ credentialsTitle: "应用凭据",
163
+ credentialsHint: "值只存在本机凭据库,面板不回显。",
164
+ appIdLabel: "App ID",
165
+ appSecretLabel: "App Secret",
166
+ credentialStore: "凭据库",
159
167
  saveCredentials: "保存凭据",
160
168
  savingCredentials: "保存中…",
161
169
  credentialSaved: "凭据已保存",
@@ -165,26 +173,29 @@ window.__ModuleLoader__.load({
165
173
  credentialAutoConnectFailed: "凭据已保存,但自动连接失败",
166
174
  credentialRemoved: "已移除存储的凭据",
167
175
  credentialRemovedPending: "已移除存储的凭据,重启飞书连接后生效",
168
- removeCredential: "移除已存值",
176
+ replaceCredential: "更换",
177
+ setCredential: "设置",
178
+ cancel: "取消",
179
+ removeCredential: "移除",
169
180
  removingCredential: "移除中…",
170
181
  credentialConfigured: "已配置",
171
182
  credentialMissing: "未配置",
172
183
  credentialReadOnly: "当前凭据存储不可写",
173
184
  appIdPlaceholder: "输入 App ID(cli_…)",
174
185
  appSecretPlaceholder: "输入 App Secret",
175
- credentialsPendingHint: "填写后点「保存凭据」;两项齐全时插件会自动连接飞书。",
176
- connectionTabHelp: "连接状态与飞书应用凭据。凭据不完整时不会连接飞书。",
177
- sessionsTabHelp: "私聊、群聊触发方式与入站内容:附件存储/预取、模型工具开关与读取预算;「需重连」项在下次连接时读取。",
178
- modelTabHelp: "飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。",
179
- workspaceTabHelp: "每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。入站附件默认存于 DSH 原生附件库,不占用话题目录。插件不会自动删除工作区或话题目录;过期目录可手动删除,话题下次对话时会重建空目录。",
180
- accessTabHelp: "准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。",
181
- advancedTabHelp: "流式刷新等低层参数、Web 覆盖文件与只读信息。",
182
- save: "保存更改",
186
+ credentialRemoveConfirm: "确定移除已存储的凭据值?移除后不会回显,需要时须重新填写。",
187
+ save: "保存",
183
188
  saving: "保存中…",
184
- saveHintDirty: "只保存改动过的字段;标注「需重连」的项在下次连接时生效。",
185
- discardTab: "放弃本页更改",
186
- saveHintClean: "没有未保存的更改。",
187
- dirtyCount: "未保存项",
189
+ discard: "放弃",
190
+ dirtyCount: "{n} 项未保存",
191
+ dirtyRestart: ",其中 {n} 项需要重连后生效",
192
+ details: "详情",
193
+ expand: "展开",
194
+ collapse: "收起",
195
+ collapsedCount: "{n} 项",
196
+ collapsedClean: "一般不用改",
197
+ collapsedChanged: "{n} 项已在面板修改",
198
+ restartTag: "需重连",
188
199
  listAdd: "添加",
189
200
  listRemove: "移除",
190
201
  listEntry: "第 {n} 项",
@@ -196,55 +207,55 @@ window.__ModuleLoader__.load({
196
207
  directoryPicked: "已选择目录",
197
208
  directoryPickFailed: "无法打开系统目录选择器",
198
209
  saved: "已保存",
199
- cleared: "已清除 Web 覆盖,恢复为 profile 配置",
200
- clear: "清除 Web 覆盖",
201
- clearing: "清除中…",
202
- noOverrides: "当前没有 Web 覆盖",
203
- overridden: "Web 覆盖",
204
- inherited: "继承 profile",
205
- restartTag: "需重连",
206
- restartBanner: "以下配置需重启飞书连接后生效:",
207
- restartWarning: "重启会断开当前飞书长连接并中断进行中的回复,请确认空闲时操作。",
208
- reconnect: "重启飞书连接",
210
+ saveInvalid: "请先修正标红的配置项",
211
+ numberInvalid: "请输入数字",
212
+ numberMin: "不得小于 {n}",
213
+ numberMax: "不得大于 {n}",
214
+ modelFootnote: "对之后新建的会话生效。",
215
+ catalogFailed: "模型目录加载失败,已退回手填。",
216
+ catalogLoading: "正在读取宿主机选项…",
217
+ catalogUnavailable: "宿主机未提供可选项,请手填。",
218
+ restartBanner: "以下配置要重启飞书连接后生效:",
219
+ restartWarning: "重启会断开当前飞书连接并中断进行中的回复,确定现在重启?",
220
+ reconnect: "重启连接",
209
221
  reconnecting: "重连中…",
210
- refresh: "刷新状态",
222
+ refresh: "刷新",
211
223
  retry: "重试",
212
224
  pollFailed: "状态刷新失败",
213
225
  connectDone: "已连接飞书",
214
226
  reconnectDone: "已重启飞书连接",
215
- saveInvalid: "请先修正标红的配置项",
216
- enabled: "已启用",
217
- disabled: "已关闭",
218
- credentialRemoveConfirm: "确定移除已存储的凭据值?移除后不会回显,需要时须重新填写。",
219
- accessNotLoaded: "飞书连接建立后加载授权列表",
220
227
  loading: "加载中…",
221
228
  loadFailed: "设置面板加载失败",
222
- catalogFailed: "模型目录加载失败,已退回手填。",
223
- catalogLoading: "正在读取宿主机选项…",
224
- catalogUnavailable: "宿主机未提供可选项,请手填。",
225
- version: "插件版本",
229
+ analyzeTokenLabel: "访问令牌",
230
+ analyzeTokenSummary: "请求头 Authorization: Bearer 的值,保存后立即生效;未配置时接口拒绝所有请求",
231
+ analyzeTokenPlaceholder: "填写新令牌(建议 32 位以上随机串)",
232
+ analyzeTokenSaved: "令牌已保存,下一次请求生效",
233
+ analyzeTokenRemoved: "令牌已移除,接口将拒绝所有请求",
234
+ overlayTitle: "Web 覆盖",
226
235
  overlayPath: "覆盖文件",
227
- overlayPathHelp: "可以直接编辑该 JSON 文件;面板保存会整体重写它,「清除 Web 覆盖」会删除它并回落到 profile 配置。",
228
- advancedCard: "只读与高级信息",
229
- advancedHelp: "这些值来自 profile 配置,不能在面板里编辑。",
230
- readOnlyCard: "部署级参数",
231
- accessCard: "访问与授权",
232
- accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 user_id。",
233
- accessModeOpenWarning: "当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。",
234
- accessAdminMissingWarning: "当前准入=guarded 但未配置管理员,也无法自动使用应用所有者,所有对话都会被拒绝。请在上方「准入与授权」填写管理员 user_id(私聊机器人发送 /userid 获取;私聊模式关闭时 /userid 不可用,可在飞书管理后台的成员详情里查看用户 ID)。",
235
- accessAdminOwnerNotice: "未配置管理员,已自动使用应用所有者作为管理员:",
236
+ overlayCount: "{n} 项配置由面板覆盖",
237
+ noOverrides: "当前没有面板覆盖的配置",
238
+ overlayPathHelp: "面板保存的配置写在这个 JSON 文件里,优先级高于 profile。可以直接编辑;面板保存会整体重写它。",
239
+ clear: "恢复 profile 配置",
240
+ clearing: "恢复中…",
241
+ clearConfirm: "确定删除所有面板覆盖,恢复为 profile 配置?",
242
+ cleared: "已恢复为 profile 配置",
243
+ debugModeWarning: "调试模式已打开:应用可用范围内的任何人都能和机器人对话。用完请在「访问授权」关闭。",
244
+ accessAdminMissingWarning: "没有可用的管理员,所有对话都会被拒绝。请在下方填写管理员 user_id(私聊机器人发送 /userid 获取)。",
245
+ accessAdminOwnerSummary: "未单独设置,正在使用应用所有者 {id}",
246
+ accessNotLoaded: "飞书连接建立后加载授权列表",
247
+ accessRequestsTitle: "待处理申请",
236
248
  accessGroupsTitle: "已授权群",
237
- accessGroupsEmpty: "尚未授权任何群。机器人被拉进群后,管理员会收到带 chat_id 的群信息卡;也可在群内发送 /groupid 获取。",
238
249
  accessUsersTitle: "已授权用户",
239
- accessUsersEmpty: "尚未授权任何用户。已授权用户可在所有已授权群里与机器人对话(私聊仍只开给管理员)。",
240
- accessRequestsTitle: "待处理申请",
241
- accessRequestsEmpty: "没有待处理的授权申请。",
242
- accessGroupChatIdPlaceholder: "群 chat_id(oc_…)",
250
+ accessGroupsEmpty: "还没有授权任何群",
251
+ accessUsersEmpty: "还没有授权任何用户",
252
+ accessGroupChatIdPlaceholder: "chat_id(oc_…,群里发 /groupid 获取)",
243
253
  accessGroupNamePlaceholder: "群名(可选)",
244
- accessUserIdPlaceholder: "用户 user_id",
254
+ accessUserIdPlaceholder: "user_id(私聊机器人发 /userid 获取)",
245
255
  accessUserNamePlaceholder: "昵称(可选)",
246
256
  accessAddGroup: "添加群",
247
257
  accessAddUser: "添加用户",
258
+ accessAdd: "添加",
248
259
  accessRemove: "移除",
249
260
  accessAllow: "授权",
250
261
  accessDeny: "忽略",
@@ -252,19 +263,36 @@ window.__ModuleLoader__.load({
252
263
  accessSourceCommand: "对话命令",
253
264
  accessSourceCard: "授权卡",
254
265
  accessSourcePanel: "设置面板",
255
- accessRequestGroup: "群申请",
256
- accessRequestUser: "用户申请",
257
- accessRequestRequester: "申请人",
258
- accessRequestExpires: "有效期至",
266
+ accessRequestGroup: "申请授权群",
267
+ accessRequestUser: "申请使用",
268
+ accessRequestIn: "在「{group}」",
269
+ accessRequestExpires: "{date} 前有效",
259
270
  accessGroupAdded: "已添加群授权",
260
271
  accessGroupRemoved: "已移除群授权",
261
272
  accessUserAdded: "已添加用户授权",
262
273
  accessUserRemoved: "已移除用户授权",
263
274
  accessRequestAllowed: "已授权",
264
275
  accessRequestDenied: "已忽略",
265
- accessHints: "三种授权入口:群内 /auth 命令、入群与用户授权卡、本面板。撤销立即生效;白名单只约束非管理员(管理员在任何私聊/群聊都直接放行),非管理员需要群和用户两层同时命中。",
266
- accessChatIdHint: "chat_id 可从入群群信息卡或群内 /groupid 获取;user_id 可私聊机器人发送 /userid 获取。"
276
+ wikiUsersTitle: "可使用的人",
277
+ wikiUsersAdminPlus: "管理员 + {n}",
278
+ wikiUsersEmpty: "目前只有管理员发起的话题能用知识库工具",
279
+ wikiUsersHint: "也可以在飞书发 /auth wiki add @用户",
280
+ wikiUserAdded: "已加入知识库白名单",
281
+ wikiUserRemoved: "已移出知识库白名单"
267
282
  };
283
+ /**
284
+ * Fill `{name}` placeholders of one `PANEL_COPY` template.
285
+ *
286
+ * @param template - Copy containing `{name}` placeholders.
287
+ * @param values - Replacement per placeholder name.
288
+ * @returns The filled string; unknown placeholders stay as written.
289
+ */
290
+ function fillCopy(template, values) {
291
+ return template.replace(/\{(\w+)\}/g, (match, name) => {
292
+ const value = values[name];
293
+ return value === void 0 ? match : String(value);
294
+ });
295
+ }
268
296
  //#endregion
269
297
  //#region src/client/api.ts
270
298
  /**
@@ -469,6 +497,17 @@ window.__ModuleLoader__.load({
469
497
  async function removeAccessUser(userId) {
470
498
  return mutateAccess(ACCESS_USERS_ROUTE, "DELETE", { userId });
471
499
  }
500
+ /** Add one user `user_id` to the wiki whitelist. */
501
+ async function addWikiUser(userId, name) {
502
+ return mutateAccess(ACCESS_WIKI_USERS_ROUTE, "POST", name === void 0 || name === "" ? { userId } : {
503
+ userId,
504
+ name
505
+ });
506
+ }
507
+ /** Remove one user from the wiki whitelist (takes effect on the next wiki tool call). */
508
+ async function removeWikiUser(userId) {
509
+ return mutateAccess(ACCESS_WIKI_USERS_ROUTE, "DELETE", { userId });
510
+ }
472
511
  /** Settle one pending authorization request (same resolver as the Feishu card). */
473
512
  async function resolveAccessRequest(token, decision) {
474
513
  return mutateAccess(ACCESS_REQUESTS_ROUTE, "POST", {
@@ -570,6 +609,110 @@ window.__ModuleLoader__.load({
570
609
  });
571
610
  }
572
611
  //#endregion
612
+ //#region src/client/number-field.ts
613
+ /**
614
+ * 浏览器半区的 `number` 字段辅助:按 Host 描述的 `scale` 在显示单位与存储
615
+ * 单位之间换算、保存前校验上下界,以及字节数的 MiB 读数。
616
+ *
617
+ * 不含 JSX/React,便于单测。Host 保存时按 config schema 复核。
618
+ *
619
+ * @module dsh-channels/client/number-field
620
+ */
621
+ /** Drop float noise from `stored / scale` (e.g. 0.30000000000000004). */
622
+ function tidy(value) {
623
+ return Number(value.toPrecision(12));
624
+ }
625
+ /**
626
+ * Value the input shows for one field.
627
+ *
628
+ * @param field - The Host descriptor (`kind: 'number'`).
629
+ * @param value - Draft (the typed string) or effective value (stored units).
630
+ * @returns The input text.
631
+ */
632
+ function numberInputValue(field, value) {
633
+ if (typeof value === "string") return value;
634
+ if (typeof value !== "number") return "";
635
+ return String(field.scale === void 0 ? value : tidy(value / field.scale));
636
+ }
637
+ /**
638
+ * Input `step` in display units.
639
+ *
640
+ * @param field - The Host descriptor (`kind: 'number'`).
641
+ * @returns The step, or `undefined` when the Host gives none.
642
+ */
643
+ function numberInputStep(field) {
644
+ if (field.step === void 0) return void 0;
645
+ return field.scale === void 0 ? field.step : tidy(field.step / field.scale);
646
+ }
647
+ /** One bound in display units with the field's unit appended. */
648
+ function boundLabel(field, stored) {
649
+ const shown = field.scale === void 0 ? stored : tidy(stored / field.scale);
650
+ return field.unit === void 0 ? String(shown) : `${shown} ${field.unit}`;
651
+ }
652
+ /**
653
+ * Turn one number draft into its save payload, checking the Host bounds.
654
+ *
655
+ * @param field - The Host descriptor (`kind: 'number'`).
656
+ * @param raw - The draft: the typed string, or a stored-unit number.
657
+ * @returns The stored-unit value (`''` clears an optional field), or the reason it is rejected.
658
+ */
659
+ function numberDraftValue(field, raw) {
660
+ let stored;
661
+ if (typeof raw === "number") stored = raw;
662
+ else {
663
+ const text = typeof raw === "string" ? raw.trim() : "";
664
+ if (text === "") return field.optional === true ? {
665
+ ok: true,
666
+ value: ""
667
+ } : {
668
+ ok: false,
669
+ message: PANEL_COPY.numberInvalid
670
+ };
671
+ const parsed = Number(text);
672
+ if (!Number.isFinite(parsed)) return {
673
+ ok: false,
674
+ message: PANEL_COPY.numberInvalid
675
+ };
676
+ stored = field.scale === void 0 ? parsed : Math.round(parsed * field.scale);
677
+ }
678
+ if (field.min !== void 0 && stored < field.min) return {
679
+ ok: false,
680
+ message: fillCopy(PANEL_COPY.numberMin, { n: boundLabel(field, field.min) })
681
+ };
682
+ if (field.max !== void 0 && stored > field.max) return {
683
+ ok: false,
684
+ message: fillCopy(PANEL_COPY.numberMax, { n: boundLabel(field, field.max) })
685
+ };
686
+ return {
687
+ ok: true,
688
+ value: stored
689
+ };
690
+ }
691
+ /**
692
+ * Human reading of a byte count, e.g. `20 MiB`.
693
+ *
694
+ * @param text - The input text (bytes).
695
+ * @returns The reading, or `undefined` when the text is not a positive number.
696
+ */
697
+ function bytesReading(text) {
698
+ const bytes = Number(text);
699
+ if (text.trim() === "" || !Number.isFinite(bytes) || bytes <= 0) return void 0;
700
+ const units = [
701
+ "B",
702
+ "KiB",
703
+ "MiB",
704
+ "GiB",
705
+ "TiB"
706
+ ];
707
+ let value = bytes;
708
+ let unit = 0;
709
+ while (value >= 1024 && unit < units.length - 1) {
710
+ value /= 1024;
711
+ unit += 1;
712
+ }
713
+ return `${Number(value.toFixed(value >= 100 || unit === 0 ? 0 : 1))} ${units[unit]}`;
714
+ }
715
+ //#endregion
573
716
  //#region src/feishu/wiki/links.ts
574
717
  /**
575
718
  * Feishu wiki (知识库) link parsing: the `wikiRoots` allow-list entries and
@@ -769,8 +912,8 @@ window.__ModuleLoader__.load({
769
912
  //#endregion
770
913
  //#region src/client/panel/shared.tsx
771
914
  /**
772
- * 设置面板共享层:页面状态类型、Host 字段渲染原语(FieldRow / FieldsCard /
773
- * SaveBar)、生命周期徽标与纯格式化工具。
915
+ * 设置面板共享层:页面状态类型、Host 字段渲染原语(SettingRow /
916
+ * FieldSections / SaveBar)、分区与列表原语、生命周期徽标与纯格式化工具。
774
917
  *
775
918
  * 这一层不发请求、不持有页面状态,只把 Host 下发的 wire 数据渲染成控件;
776
919
  * 页面外壳在 `index.tsx`,各 tab 只组合这些原语。
@@ -780,7 +923,7 @@ window.__ModuleLoader__.load({
780
923
  function messageOf(error) {
781
924
  return error instanceof Error ? error.message : String(error);
782
925
  }
783
- /** Compact duration for the connection card. */
926
+ /** Compact duration for the page header. */
784
927
  function formatUptime(ms) {
785
928
  if (ms === void 0 || !Number.isFinite(ms)) return PANEL_COPY.unknownValue;
786
929
  const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
@@ -800,21 +943,9 @@ window.__ModuleLoader__.load({
800
943
  function fieldLabel(fields, key) {
801
944
  return fields.find((field) => field.key === key)?.label ?? key;
802
945
  }
803
- /** Effective access-mode label, reusing the Host field options for consistency. */
804
- function accessModeLabel(panel, mode) {
805
- return panel.config.fields.find((item) => item.key === "accessMode")?.options?.find((option) => option.value === mode)?.label ?? mode;
806
- }
807
- /** Numeric draft kept as the raw input string; parsed on save. */
808
- function numericDraft(field, raw) {
809
- if (typeof raw === "number") return raw;
810
- if (typeof raw !== "string" || raw.trim() === "") return void 0;
811
- const parsed = Number(raw);
812
- return Number.isFinite(parsed) ? parsed : void 0;
813
- }
814
946
  /**
815
947
  * Turn the dirty draft into the wire payload, keeping the Host-descriptor
816
- * bounds check client-side. Field messages stay technical (the Host re-validates
817
- * every value on save).
948
+ * bounds check client-side (the Host re-validates every value on save).
818
949
  */
819
950
  function validateDraft(panel, draft) {
820
951
  const values = {};
@@ -823,29 +954,12 @@ window.__ModuleLoader__.load({
823
954
  if (!(field.key in draft)) continue;
824
955
  const raw = draft[field.key];
825
956
  if (field.kind === "number") {
826
- const parsed = numericDraft(field, raw);
827
- if (parsed === void 0) {
828
- issues.push({
829
- key: field.key,
830
- message: "请输入数字"
831
- });
832
- continue;
833
- }
834
- if (field.min !== void 0 && parsed < field.min) {
835
- issues.push({
836
- key: field.key,
837
- message: `不得小于 ${field.min}`
838
- });
839
- continue;
840
- }
841
- if (field.max !== void 0 && parsed > field.max) {
842
- issues.push({
843
- key: field.key,
844
- message: `不得大于 ${field.max}`
845
- });
846
- continue;
847
- }
848
- values[field.key] = parsed;
957
+ const parsed = numberDraftValue(field, raw);
958
+ if (parsed.ok) values[field.key] = parsed.value;
959
+ else issues.push({
960
+ key: field.key,
961
+ message: parsed.message
962
+ });
849
963
  continue;
850
964
  }
851
965
  if (field.kind === "boolean") {
@@ -865,7 +979,7 @@ window.__ModuleLoader__.load({
865
979
  issues
866
980
  };
867
981
  }
868
- /** Header badge for the Host-derived lifecycle phase. */
982
+ /** Header pill for the Host-derived lifecycle phase. */
869
983
  function phaseBadge(phase) {
870
984
  switch (phase) {
871
985
  case "connected": return {
@@ -890,7 +1004,7 @@ window.__ModuleLoader__.load({
890
1004
  };
891
1005
  }
892
1006
  }
893
- /** One-line explanation shown under the page title for the current phase. */
1007
+ /** One-line explanation of the current phase (connection tab status block). */
894
1008
  function phaseSummary(phase) {
895
1009
  switch (phase) {
896
1010
  case "connected": return PANEL_COPY.statusSummaryConnected;
@@ -900,6 +1014,99 @@ window.__ModuleLoader__.load({
900
1014
  default: return PANEL_COPY.statusSummaryUnconfigured;
901
1015
  }
902
1016
  }
1017
+ /** Small rounded status pill. */
1018
+ function Pill(props) {
1019
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1020
+ className: `dsh-channels-cfg__pill dsh-channels-cfg__pill--${props.tone}`,
1021
+ children: [props.dot === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__dot" }) : null, props.children]
1022
+ });
1023
+ }
1024
+ /** One titled section: a muted heading above a bordered row list. */
1025
+ function Section(props) {
1026
+ const { title, footnote, tone, children } = props;
1027
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1028
+ className: "dsh-channels-cfg__sec",
1029
+ children: [
1030
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1031
+ className: "dsh-channels-cfg__secTitle",
1032
+ children: title
1033
+ }),
1034
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1035
+ className: `dsh-channels-cfg__list${tone === "warn" ? " dsh-channels-cfg__list--warn" : ""}`,
1036
+ children
1037
+ }),
1038
+ footnote === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1039
+ className: "dsh-channels-cfg__secFoot",
1040
+ children: footnote
1041
+ })
1042
+ ]
1043
+ });
1044
+ }
1045
+ /**
1046
+ * One setting row: label (with tags and the「详情」toggle) and a one-line
1047
+ * summary on the left, the control on the right. `details` renders below the
1048
+ * row once expanded; `wide` puts the control under the text.
1049
+ */
1050
+ function Row(props) {
1051
+ const { label, tags, summary, details, control, error, wide = false, leading } = props;
1052
+ const [open, setOpen] = (0, react.useState)(false);
1053
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1054
+ className: "dsh-channels-cfg__rowWrap",
1055
+ children: [
1056
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1057
+ className: `dsh-channels-cfg__row${wide ? " dsh-channels-cfg__row--wide" : ""}`,
1058
+ children: [
1059
+ leading,
1060
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1061
+ className: "dsh-channels-cfg__rowText",
1062
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1063
+ className: "dsh-channels-cfg__rowLabel",
1064
+ children: [
1065
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }),
1066
+ tags,
1067
+ details === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1068
+ type: "button",
1069
+ className: "dsh-channels-cfg__more",
1070
+ "aria-expanded": open,
1071
+ onClick: () => setOpen(!open),
1072
+ children: PANEL_COPY.details
1073
+ })
1074
+ ]
1075
+ }), summary === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1076
+ className: "dsh-channels-cfg__rowSummary",
1077
+ children: summary
1078
+ })]
1079
+ }),
1080
+ control === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1081
+ className: "dsh-channels-cfg__rowCtl",
1082
+ children: control
1083
+ })
1084
+ ]
1085
+ }),
1086
+ open && details !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1087
+ className: "dsh-channels-cfg__details",
1088
+ children: details
1089
+ }) : null,
1090
+ error === void 0 || error === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1091
+ className: "dsh-channels-cfg__rowError",
1092
+ children: error
1093
+ })
1094
+ ]
1095
+ });
1096
+ }
1097
+ /** Accessible on/off switch. */
1098
+ function Switch(props) {
1099
+ const { checked, label, disabled = false, onChange } = props;
1100
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1101
+ type: "button",
1102
+ role: "switch",
1103
+ className: "dsh-channels-cfg__switch",
1104
+ "aria-checked": checked,
1105
+ "aria-label": label,
1106
+ disabled,
1107
+ onClick: () => onChange(!checked)
1108
+ });
1109
+ }
903
1110
  /**
904
1111
  * Text input paired with the host-native「选择目录…」button. The button owns
905
1112
  * its own busy state: the OS dialog can stay open for a long time, so the
@@ -928,7 +1135,7 @@ window.__ModuleLoader__.load({
928
1135
  onChange: (event) => onChange(field.key, event.target.value)
929
1136
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
930
1137
  type: "button",
931
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--browse",
1138
+ className: "dsh-channels-cfg__btn",
932
1139
  disabled: disabled || picking,
933
1140
  onClick: () => {
934
1141
  pick();
@@ -939,7 +1146,7 @@ window.__ModuleLoader__.load({
939
1146
  }
940
1147
  /**
941
1148
  * `string-list` control: one single-line input per entry, each with a remove
942
- * button, and an add button below (disabled at `maxItems` inputs). An empty
1149
+ * button, and an add link below (disabled at `maxItems` inputs). An empty
943
1150
  * list renders one blank input so the first entry can be typed directly.
944
1151
  * Issues carrying an `index` mark that input and show the reason under it.
945
1152
  */
@@ -948,12 +1155,12 @@ window.__ModuleLoader__.load({
948
1155
  const empty = listItems(value).length === 0;
949
1156
  const rows = listRows(value);
950
1157
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
951
- className: "dsh-channels-cfg__list",
1158
+ className: "dsh-channels-cfg__stringList",
952
1159
  children: [rows.map((item, index) => {
953
1160
  const issue = issues.find((entry) => entry.index === index);
954
1161
  const position = PANEL_COPY.listEntry.replace("{n}", String(index + 1));
955
1162
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
956
- className: "dsh-channels-cfg__listItem",
1163
+ className: "dsh-channels-cfg__stringListItem",
957
1164
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
958
1165
  className: "dsh-channels-cfg__inputRow",
959
1166
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -967,7 +1174,7 @@ window.__ModuleLoader__.load({
967
1174
  onChange: (event) => onChange(field.key, withListEntry(rows, index, event.target.value))
968
1175
  }), empty ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
969
1176
  type: "button",
970
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--listRemove",
1177
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--remove",
971
1178
  "aria-label": `${PANEL_COPY.listRemove} ${position}`,
972
1179
  disabled,
973
1180
  onClick: () => onChange(field.key, withoutListEntry(rows, index)),
@@ -978,861 +1185,703 @@ window.__ModuleLoader__.load({
978
1185
  children: issue.message
979
1186
  })]
980
1187
  }, index);
981
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1188
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
982
1189
  type: "button",
983
- className: "dsh-channels-cfg__btn",
1190
+ className: "dsh-channels-cfg__link",
984
1191
  disabled: disabled || listIsFull(field, rows),
985
1192
  onClick: () => onChange(field.key, withBlankListEntry(rows)),
986
- children: PANEL_COPY.listAdd
1193
+ children: ["+ ", PANEL_COPY.listAdd]
987
1194
  }) })]
988
1195
  });
989
1196
  }
990
- /** One generic form control driven by the Host field descriptor. */
991
- function FieldRow(props) {
992
- const { field, value, issues, disabled = false, onChange, onPickDirectory } = props;
993
- const issue = issues.find((item) => item.key === field.key && item.index === void 0);
994
- const entryIssues = issues.filter((item) => item.key === field.key && item.index !== void 0);
995
- const inputClass = `dsh-channels-cfg__input${issue === void 0 ? "" : " dsh-channels-cfg__input--invalid"}`;
1197
+ /** Number input with its unit (and a MiB reading for byte counts). */
1198
+ function NumberInput(props) {
1199
+ const { field, value, inputClass, disabled, onChange } = props;
1200
+ const text = numberInputValue(field, value);
1201
+ const reading = field.unit === "字节" ? bytesReading(text) : void 0;
996
1202
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
997
- className: `dsh-channels-cfg__field${field.wide === true ? " dsh-channels-cfg__field--wide" : ""}`,
1203
+ className: "dsh-channels-cfg__inputRow",
998
1204
  children: [
999
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1000
- className: "dsh-channels-cfg__labelRow",
1001
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1002
- className: "dsh-channels-cfg__label",
1003
- children: field.label
1004
- }), field.restartRequired ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1005
- className: "dsh-channels-cfg__tag dsh-channels-cfg__tag--restart",
1006
- children: PANEL_COPY.restartTag
1007
- }) : null]
1205
+ reading === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1206
+ className: "dsh-channels-cfg__bytes",
1207
+ children: reading
1008
1208
  }),
1009
- field.kind === "boolean" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1010
- className: "dsh-channels-cfg__check",
1011
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1012
- type: "checkbox",
1013
- className: "dsh-channels-cfg__checkbox",
1014
- checked: value === true,
1015
- "aria-label": field.label,
1016
- disabled,
1017
- onChange: (event) => onChange(field.key, event.target.checked)
1018
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: value === true ? PANEL_COPY.enabled : PANEL_COPY.disabled })]
1019
- }) : null,
1020
- field.kind === "select" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1021
- className: inputClass,
1022
- value: String(value ?? ""),
1023
- disabled: disabled || field.disabled === true,
1024
- "aria-label": field.label,
1025
- onChange: (event) => onChange(field.key, event.target.value),
1026
- children: (field.options ?? []).map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1027
- value: option.value,
1028
- children: option.label
1029
- }, option.value))
1030
- }) : null,
1031
- field.kind === "number" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1209
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1032
1210
  type: "number",
1033
- className: inputClass,
1034
- value: value === void 0 || value === "" ? "" : String(value),
1035
- min: field.min,
1036
- max: field.max,
1037
- step: field.step,
1038
- "aria-label": field.label,
1039
- disabled,
1040
- onChange: (event) => onChange(field.key, event.target.value)
1041
- }) : null,
1042
- field.kind === "text" ? field.browse === "directory" && onPickDirectory !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DirectoryTextInput, {
1043
- field,
1044
- value,
1045
- inputClass,
1046
- disabled,
1047
- onChange,
1048
- onPick: onPickDirectory
1049
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1050
- type: "text",
1051
- className: inputClass,
1052
- value: String(value ?? ""),
1211
+ className: `${inputClass} dsh-channels-cfg__input--num`,
1212
+ value: text,
1053
1213
  placeholder: field.placeholder,
1214
+ step: numberInputStep(field),
1054
1215
  "aria-label": field.label,
1055
1216
  disabled,
1056
1217
  onChange: (event) => onChange(field.key, event.target.value)
1057
- }) : null,
1058
- field.kind === "string-list" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StringListInput, {
1059
- field,
1060
- value,
1061
- issues: entryIssues,
1062
- disabled,
1063
- onChange
1064
- }) : null,
1065
- field.help === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1066
- className: "dsh-channels-cfg__help",
1067
- children: field.help
1068
1218
  }),
1069
- issue === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1070
- className: "dsh-channels-cfg__fieldError",
1071
- children: issue.message
1219
+ field.unit === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1220
+ className: "dsh-channels-cfg__unit",
1221
+ children: field.unit
1072
1222
  })
1073
1223
  ]
1074
1224
  });
1075
1225
  }
1226
+ /** The control for one Host field descriptor. */
1227
+ function FieldControl(props) {
1228
+ const { field, value, issues, invalid, disabled, onChange, onPickDirectory } = props;
1229
+ const inputClass = `dsh-channels-cfg__input${invalid ? " dsh-channels-cfg__input--invalid" : ""}`;
1230
+ switch (field.kind) {
1231
+ case "boolean": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Switch, {
1232
+ checked: value === true,
1233
+ label: field.label,
1234
+ disabled,
1235
+ onChange: (next) => onChange(field.key, next)
1236
+ });
1237
+ case "select": {
1238
+ const toggle = field.toggle;
1239
+ if (toggle !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Switch, {
1240
+ checked: value === toggle.on,
1241
+ label: field.label,
1242
+ disabled,
1243
+ onChange: (next) => onChange(field.key, next ? toggle.on : toggle.off)
1244
+ });
1245
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1246
+ className: `${inputClass} dsh-channels-cfg__select`,
1247
+ value: String(value ?? ""),
1248
+ disabled: disabled || field.disabled === true,
1249
+ "aria-label": field.label,
1250
+ onChange: (event) => onChange(field.key, event.target.value),
1251
+ children: (field.options ?? []).map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1252
+ value: option.value,
1253
+ children: option.label
1254
+ }, option.value))
1255
+ });
1256
+ }
1257
+ case "number": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NumberInput, {
1258
+ field,
1259
+ value,
1260
+ inputClass,
1261
+ disabled,
1262
+ onChange
1263
+ });
1264
+ case "text": return field.browse === "directory" && onPickDirectory !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DirectoryTextInput, {
1265
+ field,
1266
+ value,
1267
+ inputClass,
1268
+ disabled,
1269
+ onChange,
1270
+ onPick: onPickDirectory
1271
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1272
+ type: "text",
1273
+ className: `${inputClass}${field.wide === true ? "" : " dsh-channels-cfg__input--text"}`,
1274
+ value: String(value ?? ""),
1275
+ placeholder: field.placeholder,
1276
+ "aria-label": field.label,
1277
+ disabled,
1278
+ onChange: (event) => onChange(field.key, event.target.value)
1279
+ });
1280
+ case "string-list": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StringListInput, {
1281
+ field,
1282
+ value,
1283
+ issues,
1284
+ disabled,
1285
+ onChange
1286
+ });
1287
+ default: return null;
1288
+ }
1289
+ }
1290
+ /** One Host field rendered as a setting row. */
1291
+ function SettingRow(props) {
1292
+ const { field, value, issues, disabled = false, onChange, onPickDirectory } = props;
1293
+ const issue = issues.find((item) => item.key === field.key && item.index === void 0);
1294
+ const entryIssues = issues.filter((item) => item.key === field.key && item.index !== void 0);
1295
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
1296
+ label: field.label,
1297
+ tags: field.restartRequired ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1298
+ className: "dsh-channels-cfg__tag",
1299
+ children: PANEL_COPY.restartTag
1300
+ }) : void 0,
1301
+ summary: field.summary,
1302
+ details: field.help,
1303
+ wide: field.wide === true,
1304
+ error: issue?.message,
1305
+ control: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldControl, {
1306
+ field,
1307
+ value,
1308
+ issues: entryIssues,
1309
+ invalid: issue !== void 0,
1310
+ disabled,
1311
+ onChange,
1312
+ onPickDirectory
1313
+ })
1314
+ });
1315
+ }
1316
+ /** Rows for every field of one group. */
1317
+ function GroupRows(props) {
1318
+ const { form, fields } = props;
1319
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: fields.map((field) => {
1320
+ const onPick = form.onPickDirectory;
1321
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingRow, {
1322
+ field,
1323
+ value: form.draft[field.key] ?? effectiveValue(form.panel, field.key),
1324
+ issues: form.issues,
1325
+ disabled: form.locked,
1326
+ onChange: form.onChange,
1327
+ onPickDirectory: onPick === void 0 ? void 0 : () => onPick(field.key)
1328
+ }, field.key);
1329
+ }) });
1330
+ }
1076
1331
  /**
1077
- * One tab's editable fields, partitioned by the Host field groups that the tab
1078
- * owns (`TAB_FIELD_GROUPS`). Empty groups render nothing, so a Host that moves
1079
- * every key out of a group does not leave a stray heading behind.
1332
+ * A group of rarely changed fields behind one summary row. It opens on its
1333
+ * own when one of its fields has a validation issue.
1080
1334
  */
1081
- function FieldsCard(props) {
1082
- const { form, groups } = props;
1083
- const sections = groups.map((group) => ({
1084
- group,
1085
- label: FIELD_GROUP_LABELS[group],
1086
- fields: form.fields.filter((field) => field.group === group)
1087
- })).filter((section) => section.fields.length > 0);
1088
- if (sections.length === 0) return null;
1089
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
1090
- className: "dsh-channels-cfg__card",
1091
- children: sections.map((section) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1092
- className: "dsh-channels-cfg__group",
1093
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1094
- className: "dsh-channels-cfg__labelRow",
1095
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1096
- className: "dsh-channels-cfg__cardTitle",
1097
- children: section.label
1098
- })
1099
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1100
- className: "dsh-channels-cfg__grid",
1101
- children: section.fields.map((field) => {
1102
- const onPick = form.onPickDirectory;
1103
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldRow, {
1104
- field,
1105
- value: form.draft[field.key] ?? effectiveValue(form.panel, field.key),
1106
- issues: form.issues,
1107
- disabled: form.locked,
1108
- onChange: form.onChange,
1109
- onPickDirectory: onPick === void 0 ? void 0 : () => onPick(field.key)
1110
- }, field.key);
1111
- })
1112
- })]
1113
- }, section.group))
1114
- });
1335
+ function CollapsedGroup(props) {
1336
+ const { form, fields } = props;
1337
+ const [open, setOpen] = (0, react.useState)(false);
1338
+ const keys = new Set(fields.map((field) => field.key));
1339
+ const changed = fields.filter((field) => form.panel.config.overriddenKeys.includes(field.key) || field.key in form.draft).length;
1340
+ const expanded = open || form.issues.some((issue) => keys.has(issue.key));
1341
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
1342
+ label: fillCopy(PANEL_COPY.collapsedCount, { n: fields.length }),
1343
+ summary: changed === 0 ? PANEL_COPY.collapsedClean : fillCopy(PANEL_COPY.collapsedChanged, { n: changed }),
1344
+ control: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1345
+ type: "button",
1346
+ className: "dsh-channels-cfg__more",
1347
+ "aria-expanded": expanded,
1348
+ onClick: () => setOpen(!expanded),
1349
+ children: expanded ? PANEL_COPY.collapse : PANEL_COPY.expand
1350
+ })
1351
+ }), expanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GroupRows, {
1352
+ form,
1353
+ fields
1354
+ }) : null] });
1355
+ }
1356
+ /**
1357
+ * One tab's editable fields as titled sections, one per Host field group, in
1358
+ * the given order. Empty groups render nothing, so a Host that moves every key
1359
+ * out of a group does not leave a stray heading behind. `extra` appends rows
1360
+ * that are not Host fields (e.g. a credential) to one group's list.
1361
+ */
1362
+ function FieldSections(props) {
1363
+ const { form, groups, extra = {}, footnotes = {} } = props;
1364
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: groups.map((group) => {
1365
+ const fields = form.fields.filter((field) => field.group === group);
1366
+ const more = extra[group];
1367
+ if (fields.length === 0 && more === void 0) return null;
1368
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Section, {
1369
+ title: FIELD_GROUP_LABELS[group],
1370
+ footnote: footnotes[group],
1371
+ children: [COLLAPSED_FIELD_GROUPS.includes(group) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CollapsedGroup, {
1372
+ form,
1373
+ fields
1374
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GroupRows, {
1375
+ form,
1376
+ fields
1377
+ }), more]
1378
+ }, group);
1379
+ }) });
1115
1380
  }
1116
- /** The unified per-tab action bar: save every dirty field or drop the draft. */
1381
+ /**
1382
+ * Sticky bar shown only while the draft has unsaved keys. Saving submits the
1383
+ * whole draft (every tab); discarding drops it.
1384
+ */
1117
1385
  function SaveBar(props) {
1118
- const { form, tab } = props;
1119
- const groups = new Set(TAB_FIELD_GROUPS[tab]);
1120
- const pageDirty = form.fields.filter((field) => groups.has(field.group) && field.key in form.draft).length;
1121
- const dirty = form.dirtyCount > 0;
1122
- const disabled = !dirty || form.busy !== null || form.locked;
1386
+ const { dirtyCount, restartCount, busy, locked, onSave, onDiscard } = props;
1387
+ if (dirtyCount === 0) return null;
1388
+ const disabled = busy !== null || locked;
1123
1389
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1124
- className: "dsh-channels-cfg__footer",
1390
+ className: "dsh-channels-cfg__saveBar",
1391
+ role: "region",
1392
+ "aria-label": PANEL_COPY.save,
1125
1393
  children: [
1394
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1395
+ className: "dsh-channels-cfg__saveMsg",
1396
+ children: [fillCopy(PANEL_COPY.dirtyCount, { n: dirtyCount }), restartCount === 0 ? "" : fillCopy(PANEL_COPY.dirtyRestart, { n: restartCount })]
1397
+ }),
1126
1398
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1127
1399
  type: "button",
1128
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1129
- onClick: form.onSave,
1400
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost",
1401
+ onClick: onDiscard,
1130
1402
  disabled,
1131
- children: form.busy === "save" ? PANEL_COPY.saving : PANEL_COPY.save
1403
+ children: PANEL_COPY.discard
1132
1404
  }),
1133
1405
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1134
1406
  type: "button",
1135
- className: "dsh-channels-cfg__btn",
1136
- onClick: () => form.onDiscardTab(tab),
1137
- disabled: pageDirty === 0 || form.busy !== null || form.locked,
1138
- children: PANEL_COPY.discardTab
1139
- }),
1140
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
1141
- pageDirty > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1142
- className: "dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty",
1143
- children: [
1144
- PANEL_COPY.dirtyCount,
1145
- " ",
1146
- pageDirty
1147
- ]
1148
- }) : dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1149
- className: "dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty",
1150
- children: [
1151
- PANEL_COPY.dirtyCount,
1152
- " ",
1153
- form.dirtyCount,
1154
- "(其他标签页)"
1155
- ]
1156
- }) : null,
1157
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1158
- className: "dsh-channels-cfg__help",
1159
- children: dirty ? PANEL_COPY.saveHintDirty : PANEL_COPY.saveHintClean
1407
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1408
+ onClick: onSave,
1409
+ disabled,
1410
+ children: busy === "save" ? PANEL_COPY.saving : PANEL_COPY.save
1160
1411
  })
1161
1412
  ]
1162
1413
  });
1163
1414
  }
1164
- /** Muted help paragraph introducing one tab. */
1165
- function TabHelp(props) {
1166
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1167
- className: "dsh-channels-cfg__tabHelp",
1168
- children: props.text
1169
- });
1170
- }
1171
- /** Transient page-level banner (save / credentials / reconnect / catalog). */
1415
+ /** Transient banner (save / credentials / reconnect / catalog / access). */
1172
1416
  function NoticeBar(props) {
1173
- const { notice } = props;
1417
+ const { kind, text } = props;
1174
1418
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1175
- className: `dsh-channels-cfg__notice dsh-channels-cfg__notice--${notice.kind === "ok" ? "ok" : "error"}`,
1176
- role: "status",
1419
+ className: `dsh-channels-cfg__notice dsh-channels-cfg__notice--${kind}`,
1420
+ role: kind === "error" ? "alert" : "status",
1177
1421
  "aria-live": "polite",
1178
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: notice.text })
1422
+ children: text
1179
1423
  });
1180
1424
  }
1181
- /** Muted empty/blocked state (e.g. access lists before the channel connects). */
1182
- function EmptyState(props) {
1425
+ /** Muted row inside a list (empty or not-yet-loaded state). */
1426
+ function EmptyRow(props) {
1183
1427
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1184
- className: "dsh-channels-cfg__emptyState",
1428
+ className: "dsh-channels-cfg__emptyRow",
1185
1429
  children: props.text
1186
1430
  });
1187
1431
  }
1188
- /** Human label for the official SDK's WebSocket lifecycle state. */
1189
- function connectionStateLabel(state) {
1190
- switch (state) {
1191
- case "connected": return {
1192
- text: PANEL_COPY.phaseConnected,
1193
- tone: "ok"
1194
- };
1195
- case "connecting": return {
1196
- text: PANEL_COPY.phaseConnecting,
1197
- tone: "warn"
1198
- };
1199
- case "reconnecting": return {
1200
- text: PANEL_COPY.phaseConnecting,
1201
- tone: "warn"
1202
- };
1203
- case "failed": return {
1204
- text: PANEL_COPY.phaseError,
1205
- tone: "bad"
1206
- };
1207
- case "idle": return {
1208
- text: PANEL_COPY.phaseIdle,
1209
- tone: "muted"
1210
- };
1211
- default: return {
1212
- text: state ?? PANEL_COPY.unknownValue,
1213
- tone: "muted"
1214
- };
1215
- }
1216
- }
1217
1432
  //#endregion
1218
- //#region src/client/panel/access-card.tsx
1433
+ //#region src/client/panel/whitelist.tsx
1219
1434
  /**
1220
- * 访问与授权卡:fail-closed 警告、群/用户白名单增删、待处理申请处理。
1221
- *
1222
- * `access.loaded === false`(飞书未连接、插件 storage 域未打开)时白名单与
1223
- * 申请列表是「未知」而不是「空」,因此只渲染连接提示并禁用全部写操作。
1224
- * 管理员与准入模式由同 tab 上方「准入与授权」字段表单编辑,这里不再重复。
1435
+ * 白名单列表:群白名单、用户白名单与知识库白名单共用的条目行、添加表单
1436
+ * (点「+ 添加」才展开)与待处理申请行。
1225
1437
  *
1226
- * @module dsh-channels/client/panel/access-card
1438
+ * @module dsh-channels/client/panel/whitelist
1227
1439
  */
1228
- /** Local `YYYY-MM-DD HH:mm` rendering for authorization timestamps. */
1440
+ /** Local `MM-DD HH:mm` rendering for authorization timestamps. */
1229
1441
  function formatAccessDate(ms) {
1230
1442
  if (!Number.isFinite(ms) || ms <= 0) return PANEL_COPY.unknownValue;
1231
1443
  const date = new Date(ms);
1232
1444
  const pad = (value) => String(value).padStart(2, "0");
1233
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
1445
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
1234
1446
  }
1235
1447
  /** Which authorization entry produced one grant. */
1236
- function accessSourceLabel(source) {
1448
+ function sourceLabel(source) {
1237
1449
  switch (source) {
1238
1450
  case "command": return PANEL_COPY.accessSourceCommand;
1239
1451
  case "card": return PANEL_COPY.accessSourceCard;
1240
1452
  default: return PANEL_COPY.accessSourcePanel;
1241
1453
  }
1242
1454
  }
1243
- function AccessCard(props) {
1244
- const { panel, busy, locked, notice, error, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props;
1245
- const { access } = panel;
1246
- const [groupChatId, setGroupChatId] = (0, react.useState)("");
1247
- const [groupName, setGroupName] = (0, react.useState)("");
1248
- const [userUserId, setUserUserId] = (0, react.useState)("");
1249
- const [userName, setUserName] = (0, react.useState)("");
1250
- const groups = access.groups;
1251
- const users = access.users;
1252
- const requests = access.requests;
1253
- const working = busy === "access";
1254
- const disabled = busy !== null || locked || !access.loaded;
1255
- const submitGroup = async () => {
1256
- if (groupChatId.trim() === "") return;
1257
- if (await onAddGroup(groupChatId, groupName)) {
1258
- setGroupChatId("");
1259
- setGroupName("");
1260
- }
1261
- };
1262
- const submitUser = async () => {
1263
- if (userUserId.trim() === "") return;
1264
- if (await onAddUser(userUserId, userName)) {
1265
- setUserUserId("");
1266
- setUserName("");
1455
+ /** Round initial badge in front of a whitelist entry. */
1456
+ function Avatar(props) {
1457
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1458
+ className: "dsh-channels-cfg__avatar",
1459
+ "aria-hidden": "true",
1460
+ children: Array.from(props.text)[0] ?? "?"
1461
+ });
1462
+ }
1463
+ /**
1464
+ * One titled whitelist with remove buttons and a collapsed add form.
1465
+ * `onAdd` resolves true when the entry was added (the form then closes).
1466
+ */
1467
+ function Whitelist(props) {
1468
+ const { title, countLabel, entries, avatar, empty, loaded, addLabel, idPlaceholder, namePlaceholder, footnote, disabled, working, onAdd, onRemove } = props;
1469
+ const [adding, setAdding] = (0, react.useState)(false);
1470
+ const [id, setId] = (0, react.useState)("");
1471
+ const [name, setName] = (0, react.useState)("");
1472
+ const submit = async () => {
1473
+ if (id.trim() === "") return;
1474
+ if (await onAdd(id, name)) {
1475
+ setId("");
1476
+ setName("");
1477
+ setAdding(false);
1267
1478
  }
1268
1479
  };
1269
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1270
- className: "dsh-channels-cfg__card",
1480
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Section, {
1481
+ title: loaded ? `${title} · ${countLabel ?? entries.length}` : title,
1482
+ footnote,
1271
1483
  children: [
1272
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1273
- className: "dsh-channels-cfg__cardHead",
1274
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1275
- className: "dsh-channels-cfg__cardTitle",
1276
- children: PANEL_COPY.accessCard
1277
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1278
- className: "dsh-channels-cfg__cardMeta",
1279
- children: accessModeLabel(panel, access.mode)
1280
- })]
1281
- }),
1282
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1283
- className: "dsh-channels-cfg__help",
1284
- children: PANEL_COPY.accessCardHelp
1285
- }),
1286
- access.mode === "open" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1287
- className: "dsh-channels-cfg__banner",
1288
- children: PANEL_COPY.accessModeOpenWarning
1289
- }) : null,
1290
- access.loaded && access.mode === "guarded" && !access.admin.configured ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1291
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
1292
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.accessAdminMissingWarning }), access.admin.ownerLookupError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: access.admin.ownerLookupError })]
1293
- }) : null,
1294
- access.mode === "guarded" && access.admin.source === "owner" && access.admin.userId !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1295
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--ok",
1296
- children: [PANEL_COPY.accessAdminOwnerNotice, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1297
- className: "dsh-channels-cfg__mono",
1298
- children: access.admin.userId
1299
- })]
1300
- }) : null,
1301
- !access.loaded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, { text: PANEL_COPY.accessNotLoaded }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1302
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1303
- className: "dsh-channels-cfg__accessSection",
1304
- children: [
1305
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1306
- className: "dsh-channels-cfg__labelRow",
1307
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1308
- className: "dsh-channels-cfg__label",
1309
- children: PANEL_COPY.accessGroupsTitle
1310
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1311
- className: "dsh-channels-cfg__cardMeta",
1312
- children: [groups.length, " 个"]
1313
- })]
1314
- }),
1315
- groups.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1316
- className: "dsh-channels-cfg__help",
1317
- children: PANEL_COPY.accessGroupsEmpty
1318
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1319
- className: "dsh-channels-cfg__accessList",
1320
- children: groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1321
- className: "dsh-channels-cfg__accessRow",
1322
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1323
- className: "dsh-channels-cfg__accessMain",
1324
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: group.name ?? group.chatId }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1325
- className: "dsh-channels-cfg__accessMeta",
1326
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1327
- className: "dsh-channels-cfg__mono",
1328
- children: group.chatId
1329
- }), ` · ${accessSourceLabel(group.source)} · ${formatAccessDate(group.addedAt)}`]
1330
- })]
1331
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1332
- type: "button",
1333
- className: "dsh-channels-cfg__btn",
1334
- disabled,
1335
- onClick: () => onRemoveGroup(group.chatId),
1336
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove
1337
- })]
1338
- }, group.chatId))
1339
- }),
1340
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1341
- className: "dsh-channels-cfg__accessAdd",
1342
- children: [
1343
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1344
- type: "text",
1345
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1346
- value: groupChatId,
1347
- placeholder: PANEL_COPY.accessGroupChatIdPlaceholder,
1348
- "aria-label": PANEL_COPY.accessGroupChatIdPlaceholder,
1349
- disabled,
1350
- onChange: (event) => setGroupChatId(event.target.value)
1351
- }),
1352
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1353
- type: "text",
1354
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1355
- value: groupName,
1356
- placeholder: PANEL_COPY.accessGroupNamePlaceholder,
1357
- "aria-label": PANEL_COPY.accessGroupNamePlaceholder,
1358
- disabled,
1359
- onChange: (event) => setGroupName(event.target.value)
1360
- }),
1361
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1362
- type: "button",
1363
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1364
- disabled: disabled || groupChatId.trim() === "",
1365
- onClick: () => {
1366
- submitGroup();
1367
- },
1368
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddGroup
1369
- })
1370
- ]
1371
- })
1372
- ]
1373
- }),
1374
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1375
- className: "dsh-channels-cfg__accessSection",
1376
- children: [
1377
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1378
- className: "dsh-channels-cfg__labelRow",
1379
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1380
- className: "dsh-channels-cfg__label",
1381
- children: PANEL_COPY.accessUsersTitle
1382
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1383
- className: "dsh-channels-cfg__cardMeta",
1384
- children: [users.length, " 个"]
1385
- })]
1386
- }),
1387
- users.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1388
- className: "dsh-channels-cfg__help",
1389
- children: PANEL_COPY.accessUsersEmpty
1390
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1391
- className: "dsh-channels-cfg__accessList",
1392
- children: users.map((user) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1393
- className: "dsh-channels-cfg__accessRow",
1394
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1395
- className: "dsh-channels-cfg__accessMain",
1396
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: user.name ?? user.userId }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1397
- className: "dsh-channels-cfg__accessMeta",
1398
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1399
- className: "dsh-channels-cfg__mono",
1400
- children: user.userId
1401
- }), ` · ${accessSourceLabel(user.source)} · ${formatAccessDate(user.addedAt)}`]
1402
- })]
1403
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1404
- type: "button",
1405
- className: "dsh-channels-cfg__btn",
1406
- disabled,
1407
- onClick: () => onRemoveUser(user.userId),
1408
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove
1409
- })]
1410
- }, user.userId))
1411
- }),
1412
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1413
- className: "dsh-channels-cfg__accessAdd",
1414
- children: [
1415
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1416
- type: "text",
1417
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1418
- value: userUserId,
1419
- placeholder: PANEL_COPY.accessUserIdPlaceholder,
1420
- "aria-label": PANEL_COPY.accessUserIdPlaceholder,
1421
- disabled,
1422
- onChange: (event) => setUserUserId(event.target.value)
1423
- }),
1424
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1425
- type: "text",
1426
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1427
- value: userName,
1428
- placeholder: PANEL_COPY.accessUserNamePlaceholder,
1429
- "aria-label": PANEL_COPY.accessUserNamePlaceholder,
1430
- disabled,
1431
- onChange: (event) => setUserName(event.target.value)
1432
- }),
1433
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1434
- type: "button",
1435
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1436
- disabled: disabled || userUserId.trim() === "",
1437
- onClick: () => {
1438
- submitUser();
1439
- },
1440
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddUser
1441
- })
1442
- ]
1443
- })
1444
- ]
1445
- }),
1446
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1447
- className: "dsh-channels-cfg__accessSection",
1448
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1449
- className: "dsh-channels-cfg__labelRow",
1450
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1451
- className: "dsh-channels-cfg__label",
1452
- children: PANEL_COPY.accessRequestsTitle
1453
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1454
- className: "dsh-channels-cfg__cardMeta",
1455
- children: [requests.length, " 条"]
1456
- })]
1457
- }), requests.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1458
- className: "dsh-channels-cfg__help",
1459
- children: PANEL_COPY.accessRequestsEmpty
1460
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1461
- className: "dsh-channels-cfg__accessList",
1462
- children: requests.map((request) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1463
- className: "dsh-channels-cfg__accessRow",
1464
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1465
- className: "dsh-channels-cfg__accessMain",
1466
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
1467
- request.kind === "group" ? PANEL_COPY.accessRequestGroup : PANEL_COPY.accessRequestUser,
1468
- ":",
1469
- request.name ?? request.targetId
1470
- ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1471
- className: "dsh-channels-cfg__accessMeta",
1472
- children: [
1473
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1474
- className: "dsh-channels-cfg__mono",
1475
- children: request.targetId
1476
- }),
1477
- ` · ${formatAccessDate(request.createdAt)} · ${PANEL_COPY.accessRequestExpires} ${formatAccessDate(request.expiresAt)}`,
1478
- request.kind === "user" && request.requester !== void 0 ? ` · ${PANEL_COPY.accessRequestRequester} ${request.requester}` : "",
1479
- request.kind === "user" && request.groupName !== void 0 ? ` · ${request.groupName}` : request.kind === "user" && request.chatId !== void 0 ? ` · ${request.chatId}` : ""
1480
- ]
1481
- })]
1482
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1483
- className: "dsh-channels-cfg__accessActions",
1484
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1485
- type: "button",
1486
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1487
- disabled,
1488
- onClick: () => onResolveRequest(request.token, "allow"),
1489
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAllow
1490
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1491
- type: "button",
1492
- className: "dsh-channels-cfg__btn",
1493
- disabled,
1494
- onClick: () => onResolveRequest(request.token, "deny"),
1495
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessDeny
1496
- })]
1497
- })]
1498
- }, request.token))
1499
- })]
1484
+ !loaded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyRow, { text: PANEL_COPY.accessNotLoaded }) : null,
1485
+ loaded && entries.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyRow, { text: empty }) : null,
1486
+ entries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
1487
+ leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Avatar, { text: avatar ?? entry.name ?? entry.id }),
1488
+ label: entry.name ?? entry.id,
1489
+ summary: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1490
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1491
+ className: "dsh-channels-cfg__mono",
1492
+ children: entry.id
1493
+ }),
1494
+ " · ",
1495
+ sourceLabel(entry.source)
1496
+ ] }),
1497
+ control: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1498
+ type: "button",
1499
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--remove",
1500
+ disabled,
1501
+ onClick: () => onRemove(entry.id),
1502
+ children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove
1503
+ })
1504
+ }, entry.id)),
1505
+ !loaded ? null : adding ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1506
+ className: "dsh-channels-cfg__addRow",
1507
+ children: [
1508
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1509
+ type: "text",
1510
+ className: "dsh-channels-cfg__input",
1511
+ value: id,
1512
+ placeholder: idPlaceholder,
1513
+ "aria-label": idPlaceholder,
1514
+ disabled,
1515
+ onChange: (event) => setId(event.target.value)
1516
+ }),
1517
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1518
+ type: "text",
1519
+ className: "dsh-channels-cfg__input",
1520
+ value: name,
1521
+ placeholder: namePlaceholder,
1522
+ "aria-label": namePlaceholder,
1523
+ disabled,
1524
+ onChange: (event) => setName(event.target.value)
1525
+ }),
1526
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1527
+ type: "button",
1528
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1529
+ disabled: disabled || id.trim() === "",
1530
+ onClick: () => {
1531
+ submit();
1532
+ },
1533
+ children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAdd
1534
+ }),
1535
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1536
+ type: "button",
1537
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost",
1538
+ onClick: () => setAdding(false),
1539
+ children: PANEL_COPY.cancel
1540
+ })
1541
+ ]
1542
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1543
+ className: "dsh-channels-cfg__addRow",
1544
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1545
+ type: "button",
1546
+ className: "dsh-channels-cfg__link",
1547
+ disabled,
1548
+ onClick: () => setAdding(true),
1549
+ children: ["+ ", addLabel]
1500
1550
  })
1501
- ] }),
1502
- notice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1503
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--ok",
1504
- role: "status",
1505
- "aria-live": "polite",
1506
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: notice })
1507
- }),
1508
- error === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1509
- className: "dsh-channels-cfg__fieldError",
1510
- role: "alert",
1511
- children: error
1512
- }),
1513
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1514
- className: "dsh-channels-cfg__help",
1515
- children: PANEL_COPY.accessHints
1516
- }),
1517
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1518
- className: "dsh-channels-cfg__help",
1519
- children: PANEL_COPY.accessChatIdHint
1520
1551
  })
1521
1552
  ]
1522
1553
  });
1523
1554
  }
1555
+ /** Pending authorization requests, settled with the same resolver as the DM card. */
1556
+ function RequestList(props) {
1557
+ const { requests, disabled, working, onResolve } = props;
1558
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Section, {
1559
+ title: `${PANEL_COPY.accessRequestsTitle} · ${requests.length}`,
1560
+ tone: "warn",
1561
+ children: requests.map((request) => {
1562
+ const target = request.name ?? request.targetId;
1563
+ const where = request.kind === "user" && (request.groupName ?? request.chatId) !== void 0 ? fillCopy(PANEL_COPY.accessRequestIn, { group: request.groupName ?? request.chatId ?? "" }) : "";
1564
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
1565
+ leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Avatar, { text: target }),
1566
+ label: `${target} ${where}${request.kind === "group" ? PANEL_COPY.accessRequestGroup : PANEL_COPY.accessRequestUser}`,
1567
+ summary: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1568
+ className: "dsh-channels-cfg__mono",
1569
+ children: request.targetId
1570
+ }), ` · ${formatAccessDate(request.createdAt)} · ${fillCopy(PANEL_COPY.accessRequestExpires, { date: formatAccessDate(request.expiresAt) })}`] }),
1571
+ control: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1572
+ className: "dsh-channels-cfg__inputRow",
1573
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1574
+ type: "button",
1575
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost",
1576
+ disabled,
1577
+ onClick: () => onResolve(request.token, "deny"),
1578
+ children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessDeny
1579
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1580
+ type: "button",
1581
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1582
+ disabled,
1583
+ onClick: () => onResolve(request.token, "allow"),
1584
+ children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAllow
1585
+ })]
1586
+ })
1587
+ }, request.token);
1588
+ })
1589
+ });
1590
+ }
1524
1591
  //#endregion
1525
1592
  //#region src/client/panel/access-tab.tsx
1526
1593
  /**
1527
- * 「访问授权」tab:准入与授权字段表单(管理员 user_id / 准入模式 /
1528
- * 申请有效期)+ 白名单与待处理申请卡。表单保存走全局 draft,白名单
1529
- * 增删是独立即时 mutation。
1594
+ * 「访问授权」tab:待处理申请(有才显示,置顶)、准入字段(调试模式 /
1595
+ * 管理员 / 申请有效期)与群、用户白名单。字段保存走全局 draft,白名单增删
1596
+ * 与申请处理是独立即时 mutation。
1597
+ *
1598
+ * `access.loaded === false`(飞书未连接、插件 storage 域未打开)时白名单与
1599
+ * 申请列表是「未知」而不是「空」,因此只渲染连接提示并禁用全部写操作。
1530
1600
  *
1531
1601
  * @module dsh-channels/client/panel/access-tab
1532
1602
  */
1533
1603
  function AccessTab(props) {
1534
1604
  const { form, accessNotice, accessError, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props;
1605
+ const { access } = form.panel;
1606
+ const working = form.busy === "access";
1607
+ const disabled = form.busy !== null || form.locked || !access.loaded;
1608
+ const ownerAdmin = access.mode === "guarded" && access.admin.source === "owner" ? access.admin.userId : void 0;
1609
+ const fields = ownerAdmin === void 0 ? form.fields : form.fields.map((field) => field.key === "adminUserId" ? {
1610
+ ...field,
1611
+ summary: fillCopy(PANEL_COPY.accessAdminOwnerSummary, { id: ownerAdmin })
1612
+ } : field);
1535
1613
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1536
1614
  className: "dsh-channels-cfg__tabBody",
1537
1615
  children: [
1538
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.accessTabHelp }),
1539
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
1540
- form,
1616
+ accessNotice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeBar, {
1617
+ kind: "ok",
1618
+ text: accessNotice
1619
+ }),
1620
+ accessError === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeBar, {
1621
+ kind: "error",
1622
+ text: accessError
1623
+ }),
1624
+ access.loaded && access.mode === "guarded" && !access.admin.configured ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeBar, {
1625
+ kind: "error",
1626
+ text: access.admin.ownerLookupError === void 0 ? PANEL_COPY.accessAdminMissingWarning : `${PANEL_COPY.accessAdminMissingWarning}(${access.admin.ownerLookupError})`
1627
+ }) : null,
1628
+ access.loaded && access.requests.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RequestList, {
1629
+ requests: access.requests,
1630
+ disabled,
1631
+ working,
1632
+ onResolve: onResolveRequest
1633
+ }) : null,
1634
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldSections, {
1635
+ form: {
1636
+ ...form,
1637
+ fields
1638
+ },
1541
1639
  groups: TAB_FIELD_GROUPS.access
1542
1640
  }),
1543
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
1544
- form,
1545
- tab: "access"
1641
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Whitelist, {
1642
+ title: PANEL_COPY.accessGroupsTitle,
1643
+ entries: access.groups.map((group) => ({
1644
+ id: group.chatId,
1645
+ name: group.name,
1646
+ source: group.source
1647
+ })),
1648
+ avatar: "群",
1649
+ empty: PANEL_COPY.accessGroupsEmpty,
1650
+ loaded: access.loaded,
1651
+ addLabel: PANEL_COPY.accessAddGroup,
1652
+ idPlaceholder: PANEL_COPY.accessGroupChatIdPlaceholder,
1653
+ namePlaceholder: PANEL_COPY.accessGroupNamePlaceholder,
1654
+ disabled,
1655
+ working,
1656
+ onAdd: onAddGroup,
1657
+ onRemove: onRemoveGroup
1546
1658
  }),
1547
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessCard, {
1548
- panel: form.panel,
1549
- busy: form.busy,
1550
- locked: form.locked,
1551
- notice: accessNotice,
1552
- error: accessError,
1553
- onAddGroup,
1554
- onRemoveGroup,
1555
- onAddUser,
1556
- onRemoveUser,
1557
- onResolveRequest
1659
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Whitelist, {
1660
+ title: PANEL_COPY.accessUsersTitle,
1661
+ entries: access.users.map((user) => ({
1662
+ id: user.userId,
1663
+ name: user.name,
1664
+ source: user.source
1665
+ })),
1666
+ empty: PANEL_COPY.accessUsersEmpty,
1667
+ loaded: access.loaded,
1668
+ addLabel: PANEL_COPY.accessAddUser,
1669
+ idPlaceholder: PANEL_COPY.accessUserIdPlaceholder,
1670
+ namePlaceholder: PANEL_COPY.accessUserNamePlaceholder,
1671
+ disabled,
1672
+ working,
1673
+ onAdd: onAddUser,
1674
+ onRemove: onRemoveUser
1558
1675
  })
1559
1676
  ]
1560
1677
  });
1561
1678
  }
1562
1679
  //#endregion
1563
- //#region src/client/panel/advanced-tab.tsx
1680
+ //#region src/client/panel/credentials-card.tsx
1564
1681
  /**
1565
- * 「高级」tab:流式刷新等低层字段 + 只读部署信息(overlayPath / version /
1566
- * 部署级参数)+ 「清除 Web 覆盖」。清除覆盖是整体动作,不走 draft。
1682
+ * 凭据行:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示「已配置 /
1683
+ * 未配置」,两个方向都不回显已存值。已配置时只显示「更换 / 移除」,点「更换」
1684
+ * 才出现输入框。保存动作由页面外壳持有(凭据保存独立于配置 draft)。
1567
1685
  *
1568
- * @module dsh-channels/client/panel/advanced-tab
1686
+ * @module dsh-channels/client/panel/credentials-card
1569
1687
  */
1570
- function AdvancedTab(props) {
1571
- const { form, onClear } = props;
1572
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1573
- className: "dsh-channels-cfg__tabBody",
1574
- children: [
1575
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.advancedTabHelp }),
1576
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
1577
- form,
1578
- groups: TAB_FIELD_GROUPS.advanced
1579
- }),
1580
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
1581
- form,
1582
- tab: "advanced"
1583
- }),
1584
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdvancedCard, {
1585
- panel: form.panel,
1586
- busy: form.busy,
1587
- locked: form.locked,
1588
- onClear
1589
- })
1590
- ]
1688
+ /** One credential slot as a setting row with its own input and actions. */
1689
+ function CredentialRow(props) {
1690
+ const { label, summary, state, value, secret, placeholder, issue, busy, disabled, onChange, onSave, onClear } = props;
1691
+ const [editing, setEditing] = (0, react.useState)(false);
1692
+ const previousBusy = (0, react.useRef)(busy);
1693
+ (0, react.useEffect)(() => {
1694
+ if (previousBusy.current === "credentials" && busy === null) setEditing(false);
1695
+ previousBusy.current = busy;
1696
+ }, [busy]);
1697
+ const showInput = !state.configured || editing || value !== "";
1698
+ const source = `${PANEL_COPY.credentialStore} · ${state.ref}${state.source === void 0 ? "" : ` · ${state.source}`}`;
1699
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
1700
+ label,
1701
+ tags: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pill, {
1702
+ tone: state.configured ? "ok" : "muted",
1703
+ children: state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing
1704
+ }),
1705
+ summary: summary === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1706
+ className: "dsh-channels-cfg__mono",
1707
+ children: source
1708
+ }) : summary,
1709
+ error: issue?.message ?? (state.writable ? void 0 : PANEL_COPY.credentialReadOnly),
1710
+ control: showInput ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1711
+ className: "dsh-channels-cfg__inputRow",
1712
+ children: [
1713
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1714
+ type: secret ? "password" : "text",
1715
+ className: `dsh-channels-cfg__input dsh-channels-cfg__input--text${issue === void 0 ? "" : " dsh-channels-cfg__input--invalid"}`,
1716
+ value,
1717
+ placeholder,
1718
+ autoComplete: "new-password",
1719
+ "aria-label": label,
1720
+ disabled,
1721
+ onChange: (event) => onChange(event.target.value)
1722
+ }),
1723
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1724
+ type: "button",
1725
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1726
+ disabled: disabled || value.trim() === "",
1727
+ onClick: onSave,
1728
+ children: busy === "credentials" ? PANEL_COPY.savingCredentials : PANEL_COPY.saveCredentials
1729
+ }),
1730
+ state.configured ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1731
+ type: "button",
1732
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost",
1733
+ disabled: busy !== null,
1734
+ onClick: () => {
1735
+ setEditing(false);
1736
+ onChange("");
1737
+ },
1738
+ children: PANEL_COPY.cancel
1739
+ }) : null
1740
+ ]
1741
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1742
+ className: "dsh-channels-cfg__inputRow",
1743
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1744
+ type: "button",
1745
+ className: "dsh-channels-cfg__btn",
1746
+ disabled,
1747
+ onClick: () => setEditing(true),
1748
+ children: PANEL_COPY.replaceCredential
1749
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1750
+ type: "button",
1751
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--remove",
1752
+ disabled,
1753
+ onClick: onClear,
1754
+ children: busy === "credentials-clear" ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential
1755
+ })]
1756
+ })
1591
1757
  });
1592
1758
  }
1593
- /** Read-only deployment facts; the only write action here drops the overlay file. */
1594
- function AdvancedCard(props) {
1595
- const { panel, busy, locked, onClear } = props;
1596
- const { readOnly } = panel.config;
1597
- const overridden = panel.config.overriddenKeys.length;
1598
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1599
- className: "dsh-channels-cfg__card",
1600
- children: [
1601
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1602
- className: "dsh-channels-cfg__cardHead",
1603
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1604
- className: "dsh-channels-cfg__cardTitle",
1605
- children: PANEL_COPY.advancedCard
1606
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1607
- className: "dsh-channels-cfg__cardMeta",
1608
- children: overridden === 0 ? PANEL_COPY.inherited : `${PANEL_COPY.overridden} ${overridden} 项`
1609
- })]
1610
- }),
1611
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1612
- className: "dsh-channels-cfg__help",
1613
- children: PANEL_COPY.advancedHelp
1614
- }),
1615
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1616
- className: "dsh-channels-cfg__labelRow",
1617
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1618
- className: "dsh-channels-cfg__cardTitle",
1619
- children: PANEL_COPY.readOnlyCard
1620
- })
1621
- }),
1622
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1623
- className: "dsh-channels-cfg__readOnly",
1624
- children: [
1625
- readOnly.map((field) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1626
- className: "dsh-channels-cfg__kv",
1627
- children: [
1628
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1629
- className: "dsh-channels-cfg__kvKey",
1630
- children: field.label
1631
- }),
1632
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1633
- className: "dsh-channels-cfg__kvValue",
1634
- children: field.value
1635
- }),
1636
- field.help === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1637
- className: "dsh-channels-cfg__help",
1638
- children: field.help
1639
- })
1640
- ]
1641
- }, field.key)),
1642
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1643
- className: "dsh-channels-cfg__kv",
1644
- children: [
1645
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1646
- className: "dsh-channels-cfg__kvKey",
1647
- children: PANEL_COPY.overlayPath
1648
- }),
1649
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1650
- className: "dsh-channels-cfg__kvValue dsh-channels-cfg__mono",
1651
- children: panel.status.overlayPath
1652
- }),
1653
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1654
- className: "dsh-channels-cfg__help",
1655
- children: PANEL_COPY.overlayPathHelp
1656
- })
1657
- ]
1658
- }),
1659
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1660
- className: "dsh-channels-cfg__kv",
1661
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1662
- className: "dsh-channels-cfg__kvKey",
1663
- children: PANEL_COPY.version
1664
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1665
- className: "dsh-channels-cfg__kvValue dsh-channels-cfg__mono",
1666
- children: panel.status.version
1667
- })]
1668
- })
1669
- ]
1670
- }),
1671
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1672
- className: "dsh-channels-cfg__actions",
1673
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1674
- type: "button",
1675
- className: "dsh-channels-cfg__btn",
1676
- onClick: onClear,
1677
- disabled: overridden === 0 || busy !== null || locked,
1678
- children: busy === "clear" ? PANEL_COPY.clearing : PANEL_COPY.clear
1679
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1680
- className: "dsh-channels-cfg__help",
1681
- children: overridden === 0 ? PANEL_COPY.noOverrides : `${PANEL_COPY.overridden} ${overridden} 项`
1682
- })]
1683
- })
1684
- ]
1759
+ /** The Feishu application pair (App ID / App Secret). */
1760
+ function CredentialsSection(props) {
1761
+ const { panel, credentials } = props;
1762
+ const { appId, appSecret, issues, busy, locked, onAppIdChange, onAppSecretChange, onSave, onClear } = credentials;
1763
+ const appIdState = panel.config.credentials.appId;
1764
+ const appSecretState = panel.config.credentials.appSecret;
1765
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Section, {
1766
+ title: PANEL_COPY.credentialsTitle,
1767
+ footnote: PANEL_COPY.credentialsHint,
1768
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CredentialRow, {
1769
+ label: PANEL_COPY.appIdLabel,
1770
+ state: appIdState,
1771
+ value: appId,
1772
+ secret: false,
1773
+ placeholder: PANEL_COPY.appIdPlaceholder,
1774
+ issue: issues.find((item) => item.key === "appId"),
1775
+ busy,
1776
+ disabled: busy !== null || locked || !appIdState.writable,
1777
+ onChange: onAppIdChange,
1778
+ onSave,
1779
+ onClear: () => onClear("appId")
1780
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CredentialRow, {
1781
+ label: PANEL_COPY.appSecretLabel,
1782
+ state: appSecretState,
1783
+ value: appSecret,
1784
+ secret: true,
1785
+ placeholder: PANEL_COPY.appSecretPlaceholder,
1786
+ issue: issues.find((item) => item.key === "appSecret"),
1787
+ busy,
1788
+ disabled: busy !== null || locked || !appSecretState.writable,
1789
+ onChange: onAppSecretChange,
1790
+ onSave,
1791
+ onClear: () => onClear("appSecret")
1792
+ })]
1685
1793
  });
1686
1794
  }
1687
1795
  //#endregion
1688
- //#region src/client/panel/credentials-card.tsx
1796
+ //#region src/client/panel/advanced-tab.tsx
1689
1797
  /**
1690
- * 飞书应用凭据编辑器:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示
1691
- * 「已配置 / 未配置」,两个方向都不回显已存值。保存动作由页面外壳持有
1692
- * (凭据保存独立于配置 draft)。
1798
+ * 「高级」tab:日志分析接口(监听地址字段 + 访问令牌凭据行)、SDK 日志级别
1799
+ * 与 Web 覆盖文件(「恢复 profile 配置」删除整个覆盖层,不走 draft)。
1693
1800
  *
1694
- * @module dsh-channels/client/panel/credentials-card
1801
+ * @module dsh-channels/client/panel/advanced-tab
1695
1802
  */
1696
- function CredentialsCard(props) {
1697
- const { panel, credentials } = props;
1698
- const { appId, appSecret, issues, busy, locked, onAppIdChange, onAppSecretChange, onSave, onClear } = credentials;
1699
- const appIdState = panel.config.credentials.appId;
1700
- const appSecretState = panel.config.credentials.appSecret;
1701
- const configured = appIdState.configured && appSecretState.configured;
1702
- const writable = appIdState.writable || appSecretState.writable;
1703
- const disabled = busy !== null || locked || !writable;
1704
- const pending = appId.trim() !== "" || appSecret.trim() !== "";
1705
- const row = (field, label, state, value, type, placeholder, onChange) => {
1706
- const issue = issues.find((item) => item.key === field);
1707
- const inputClass = `dsh-channels-cfg__input${issue === void 0 ? "" : " dsh-channels-cfg__input--invalid"}`;
1708
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1709
- className: "dsh-channels-cfg__field",
1710
- children: [
1711
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1712
- className: "dsh-channels-cfg__labelRow",
1713
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1714
- className: "dsh-channels-cfg__label",
1715
- children: label
1716
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1717
- className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${state.configured ? "ok" : "bad"}`,
1718
- children: state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing
1719
- })]
1720
- }),
1721
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1722
- className: "dsh-channels-cfg__cred",
1723
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1724
- type,
1725
- className: inputClass,
1726
- value,
1727
- placeholder,
1728
- autoComplete: "new-password",
1729
- "aria-label": label,
1730
- disabled,
1731
- onChange: (event) => onChange(event.target.value)
1732
- }), state.configured ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1733
- type: "button",
1734
- className: "dsh-channels-cfg__btn",
1735
- disabled,
1736
- onClick: () => onClear(field),
1737
- children: busy === "credentials-clear" ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential
1738
- }) : null]
1739
- }),
1740
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1741
- className: "dsh-channels-cfg__help",
1742
- children: [
1743
- PANEL_COPY.credentialsRef,
1744
- ":",
1745
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1746
- className: "dsh-channels-cfg__mono",
1747
- children: state.ref
1748
- }),
1749
- state.source === void 0 ? "" : ` · ${state.source}`
1750
- ]
1751
- }),
1752
- issue === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1753
- className: "dsh-channels-cfg__fieldError",
1754
- children: issue.message
1755
- })
1756
- ]
1757
- }, field);
1758
- };
1759
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1760
- className: "dsh-channels-cfg__card",
1761
- children: [
1762
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1763
- className: "dsh-channels-cfg__cardHead",
1764
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1765
- className: "dsh-channels-cfg__cardTitle",
1766
- children: PANEL_COPY.credentialsCard
1767
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1768
- className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${configured ? "ok" : "muted"}`,
1769
- children: configured ? PANEL_COPY.configuredBadge : PANEL_COPY.unconfiguredBadge
1770
- })]
1771
- }),
1772
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1773
- className: "dsh-channels-cfg__help",
1774
- children: PANEL_COPY.credentialsCardHelp
1775
- }),
1776
- row("appId", PANEL_COPY.appIdLabel, appIdState, appId, "text", PANEL_COPY.appIdPlaceholder, onAppIdChange),
1777
- row("appSecret", PANEL_COPY.appSecretLabel, appSecretState, appSecret, "password", PANEL_COPY.appSecretPlaceholder, onAppSecretChange),
1778
- writable ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1779
- className: "dsh-channels-cfg__fieldError",
1780
- children: PANEL_COPY.credentialReadOnly
1781
- }),
1782
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1783
- className: "dsh-channels-cfg__footer",
1784
- children: [
1785
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1786
- type: "button",
1787
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1788
- onClick: onSave,
1789
- disabled: disabled || !pending,
1790
- children: busy === "credentials" ? PANEL_COPY.savingCredentials : PANEL_COPY.saveCredentials
1791
- }),
1792
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
1793
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1794
- className: "dsh-channels-cfg__help",
1795
- children: PANEL_COPY.credentialsPendingHint
1796
- })
1797
- ]
1803
+ function AdvancedTab(props) {
1804
+ const { form, analyzeToken, onClear } = props;
1805
+ const { panel, busy, locked } = form;
1806
+ const tokenState = panel.config.credentials.analyzeToken;
1807
+ const overridden = panel.config.overriddenKeys.length;
1808
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1809
+ className: "dsh-channels-cfg__tabBody",
1810
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldSections, {
1811
+ form,
1812
+ groups: TAB_FIELD_GROUPS.advanced,
1813
+ extra: { analyze: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CredentialRow, {
1814
+ label: PANEL_COPY.analyzeTokenLabel,
1815
+ summary: PANEL_COPY.analyzeTokenSummary,
1816
+ state: tokenState,
1817
+ value: analyzeToken.value,
1818
+ secret: true,
1819
+ placeholder: PANEL_COPY.analyzeTokenPlaceholder,
1820
+ issue: analyzeToken.issues.find((item) => item.key === "analyzeToken"),
1821
+ busy: analyzeToken.busy,
1822
+ disabled: analyzeToken.busy !== null || analyzeToken.locked || !tokenState.writable,
1823
+ onChange: analyzeToken.onChange,
1824
+ onSave: analyzeToken.onSave,
1825
+ onClear: analyzeToken.onClear
1826
+ }) }
1827
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Section, {
1828
+ title: PANEL_COPY.overlayTitle,
1829
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
1830
+ label: PANEL_COPY.overlayPath,
1831
+ summary: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1832
+ className: "dsh-channels-cfg__mono",
1833
+ children: panel.status.overlayPath
1834
+ }),
1835
+ details: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1836
+ PANEL_COPY.overlayPathHelp,
1837
+ " ",
1838
+ overridden === 0 ? PANEL_COPY.noOverrides : fillCopy(PANEL_COPY.overlayCount, { n: overridden })
1839
+ ] }),
1840
+ control: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1841
+ type: "button",
1842
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--danger",
1843
+ onClick: onClear,
1844
+ disabled: overridden === 0 || busy !== null || locked,
1845
+ children: busy === "clear" ? PANEL_COPY.clearing : PANEL_COPY.clear
1846
+ })
1798
1847
  })
1799
- ]
1848
+ })]
1800
1849
  });
1801
1850
  }
1802
1851
  //#endregion
1803
1852
  //#region src/client/panel/connection-tab.tsx
1804
1853
  /**
1805
- * 「连接」tab:按 Host 派生的生命周期阶段渲染连接引导。
1854
+ * 「连接」tab:按 Host 派生的生命周期阶段渲染。
1806
1855
  *
1807
- * - unconfigured:onboarding 三步 + 凭据卡(不渲染可用的「连接」按钮)
1808
- * - idle:连接信息卡 + 「连接飞书」
1809
- * - error:连接信息卡 + 最近一次连接失败 + 「重试」
1810
- * - connected:连接信息卡 + 「重启飞书连接」+ 凭据卡
1811
- * - connecting:禁用按钮显示「连接中…」
1812
- * - 已连接且权限自检完成:权限自检卡(缺失权限红字 + 开发者后台深链)
1856
+ * - unconfigured:上手三步 + 应用凭据
1857
+ * - idle / error / connecting / connected:状态块(当前阶段的唯一操作 + 运行数字)
1858
+ * - 连接后:自检(权限缺口可展开、斜杠指令面板同步结果)
1859
+ * - 有需重连才生效的已保存配置:顶部「重启连接」横幅
1813
1860
  *
1814
1861
  * @module dsh-channels/client/panel/connection-tab
1815
1862
  */
1816
1863
  function ConnectionTab(props) {
1817
1864
  const { panel, status, busy, credentials, onReconnect } = props;
1865
+ const checks = status.scopeCheck !== void 0 || status.slashCommandSync !== void 0;
1818
1866
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1819
1867
  className: "dsh-channels-cfg__tabBody",
1820
1868
  children: [
1821
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.connectionTabHelp }),
1822
1869
  panel.config.restartPendingKeys.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RestartBanner, {
1823
1870
  panel,
1824
1871
  busy,
1825
1872
  locked: status.busy,
1826
1873
  onReconnect
1827
1874
  }),
1828
- status.phase === "unconfigured" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OnboardingCard, { status }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConnectionInfoCard, {
1875
+ status.phase === "unconfigured" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OnboardingSection, { status }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusBlock, {
1829
1876
  status,
1830
1877
  busy,
1831
1878
  onReconnect
1832
1879
  }),
1833
- status.scopeCheck === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScopeCheckCard, { check: status.scopeCheck }),
1834
- status.slashCommandSync === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SlashCommandSyncCard, { sync: status.slashCommandSync }),
1835
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CredentialsCard, {
1880
+ checks ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Section, {
1881
+ title: PANEL_COPY.checksTitle,
1882
+ children: [status.scopeCheck === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScopeCheckRow, { check: status.scopeCheck }), status.slashCommandSync === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SlashCommandSyncRow, { sync: status.slashCommandSync })]
1883
+ }) : null,
1884
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CredentialsSection, {
1836
1885
  panel,
1837
1886
  credentials
1838
1887
  })
@@ -1840,7 +1889,7 @@ window.__ModuleLoader__.load({
1840
1889
  });
1841
1890
  }
1842
1891
  /** First-run guide: create the app, fill the credentials, save and wait. */
1843
- function OnboardingCard(props) {
1892
+ function OnboardingSection(props) {
1844
1893
  const { status } = props;
1845
1894
  const steps = [
1846
1895
  {
@@ -1856,22 +1905,12 @@ window.__ModuleLoader__.load({
1856
1905
  text: PANEL_COPY.onboardingStep3Text
1857
1906
  }
1858
1907
  ];
1859
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1860
- className: "dsh-channels-cfg__card",
1908
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1909
+ className: "dsh-channels-cfg__hero",
1861
1910
  children: [
1862
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1863
- className: "dsh-channels-cfg__cardHead",
1864
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1865
- className: "dsh-channels-cfg__cardTitle",
1866
- children: PANEL_COPY.onboardingTitle
1867
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1868
- className: "dsh-channels-cfg__badge dsh-channels-cfg__badge--muted",
1869
- children: PANEL_COPY.phaseUnconfigured
1870
- })]
1871
- }),
1872
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1873
- className: "dsh-channels-cfg__help",
1874
- children: PANEL_COPY.onboardingIntro
1911
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1912
+ className: "dsh-channels-cfg__heroTop",
1913
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.onboardingTitle })
1875
1914
  }),
1876
1915
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ol", {
1877
1916
  className: "dsh-channels-cfg__steps",
@@ -1886,50 +1925,29 @@ window.__ModuleLoader__.load({
1886
1925
  className: "dsh-channels-cfg__stepTitle",
1887
1926
  children: step.title
1888
1927
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1889
- className: "dsh-channels-cfg__help",
1928
+ className: "dsh-channels-cfg__rowSummary",
1890
1929
  children: step.text
1891
1930
  })]
1892
1931
  })]
1893
1932
  }, step.title))
1894
1933
  }),
1895
- status.missingConfig.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1896
- className: "dsh-channels-cfg__banner",
1897
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("strong", { children: [PANEL_COPY.missingConfig, ":"] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: status.missingConfig.join("、") })]
1898
- }),
1899
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1900
- className: "dsh-channels-cfg__help",
1901
- children: PANEL_COPY.statusHintUnconfigured
1902
- }),
1903
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1904
- className: "dsh-channels-cfg__help",
1905
- children: PANEL_COPY.onboardingDocsHint
1934
+ status.missingConfig.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1935
+ className: "dsh-channels-cfg__rowSummary",
1936
+ children: [
1937
+ PANEL_COPY.missingConfig,
1938
+ ":",
1939
+ status.missingConfig.join("、")
1940
+ ]
1906
1941
  })
1907
1942
  ]
1908
1943
  });
1909
1944
  }
1910
- /** Runtime facts plus the one lifecycle action available in the current phase. */
1911
- function ConnectionInfoCard(props) {
1945
+ /** Phase, the one lifecycle action available in it, and the runtime counters. */
1946
+ function StatusBlock(props) {
1912
1947
  const { status, busy, onReconnect } = props;
1913
1948
  const badge = phaseBadge(status.phase);
1914
1949
  const actionBusy = busy !== null || status.busy;
1915
- const connection = connectionStateLabel(status.connection?.state);
1916
- const facts = [
1917
- {
1918
- label: PANEL_COPY.statusCard,
1919
- value: badge.text
1920
- },
1921
- {
1922
- label: PANEL_COPY.connectionStateLabel,
1923
- value: connection.text
1924
- },
1925
- {
1926
- label: PANEL_COPY.botOpenId,
1927
- value: status.botOpenIdTail ?? PANEL_COPY.unknownValue
1928
- },
1929
- {
1930
- label: PANEL_COPY.uptime,
1931
- value: formatUptime(status.uptimeMs)
1932
- },
1950
+ const stats = [
1933
1951
  {
1934
1952
  label: PANEL_COPY.routeCount,
1935
1953
  value: String(status.routeCount)
@@ -1941,188 +1959,167 @@ window.__ModuleLoader__.load({
1941
1959
  {
1942
1960
  label: PANEL_COPY.inflightTurnCount,
1943
1961
  value: String(status.inflightTurnCount)
1962
+ },
1963
+ {
1964
+ label: PANEL_COPY.botOpenId,
1965
+ value: status.botOpenIdTail ?? PANEL_COPY.unknownValue,
1966
+ mono: true
1944
1967
  }
1945
1968
  ];
1946
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1947
- className: "dsh-channels-cfg__card",
1969
+ let action = null;
1970
+ if (status.phase === "connecting") action = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1971
+ type: "button",
1972
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1973
+ disabled: true,
1974
+ children: PANEL_COPY.connecting
1975
+ });
1976
+ else if (status.phase === "idle" || status.phase === "error") action = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1977
+ type: "button",
1978
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1979
+ onClick: onReconnect,
1980
+ disabled: actionBusy,
1981
+ children: busy === "reconnect" ? PANEL_COPY.connecting : status.phase === "idle" ? PANEL_COPY.connect : PANEL_COPY.retry
1982
+ });
1983
+ else if (status.phase === "connected") action = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1984
+ type: "button",
1985
+ className: "dsh-channels-cfg__btn",
1986
+ onClick: onReconnect,
1987
+ disabled: actionBusy,
1988
+ children: busy === "reconnect" ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect
1989
+ });
1990
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1991
+ className: "dsh-channels-cfg__hero",
1948
1992
  children: [
1949
1993
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1950
- className: "dsh-channels-cfg__cardHead",
1951
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1952
- className: "dsh-channels-cfg__cardTitle",
1953
- children: PANEL_COPY.connectionInfo
1954
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1955
- className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${status.configured ? "ok" : "bad"}`,
1956
- children: status.configured ? PANEL_COPY.configuredBadge : PANEL_COPY.unconfiguredBadge
1957
- })]
1994
+ className: "dsh-channels-cfg__heroTop",
1995
+ children: [
1996
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pill, {
1997
+ tone: badge.tone,
1998
+ dot: true,
1999
+ children: badge.text
2000
+ }),
2001
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: phaseSummary(status.phase) }),
2002
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
2003
+ action
2004
+ ]
1958
2005
  }),
1959
2006
  status.lastStartError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1960
2007
  className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
1961
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.lastError }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1962
- className: "dsh-channels-cfg__mono",
1963
- children: status.lastStartError
1964
- })]
2008
+ children: [
2009
+ PANEL_COPY.lastError,
2010
+ ":",
2011
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2012
+ className: "dsh-channels-cfg__mono",
2013
+ children: status.lastStartError
2014
+ })
2015
+ ]
1965
2016
  }),
1966
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1967
- className: "dsh-channels-cfg__facts",
1968
- children: facts.map((fact) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1969
- className: "dsh-channels-cfg__fact",
1970
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1971
- className: "dsh-channels-cfg__factLabel",
1972
- children: fact.label
1973
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1974
- className: "dsh-channels-cfg__factValue",
1975
- children: fact.value
2017
+ status.phase === "connected" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2018
+ className: "dsh-channels-cfg__stats",
2019
+ children: stats.map((stat) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2020
+ className: "dsh-channels-cfg__stat",
2021
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: stat.label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
2022
+ className: stat.mono === true ? "dsh-channels-cfg__mono" : void 0,
2023
+ children: stat.value
1976
2024
  })]
1977
- }, fact.label))
1978
- }),
2025
+ }, stat.label))
2026
+ }) : null,
1979
2027
  status.reasons.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1980
- className: `dsh-channels-cfg__help${status.level === "unhealthy" ? " dsh-channels-cfg__reasonBad" : ""}`,
2028
+ className: `dsh-channels-cfg__rowSummary${status.level === "unhealthy" ? " dsh-channels-cfg__textBad" : ""}`,
1981
2029
  children: [
1982
2030
  PANEL_COPY.healthReasons,
1983
2031
  ":",
1984
2032
  status.reasons.join(";")
1985
2033
  ]
1986
- }),
1987
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1988
- className: "dsh-channels-cfg__actions",
1989
- children: [
1990
- status.phase === "connecting" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1991
- type: "button",
1992
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1993
- disabled: true,
1994
- children: PANEL_COPY.connecting
1995
- }) : null,
1996
- status.phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1997
- type: "button",
1998
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1999
- onClick: onReconnect,
2000
- disabled: actionBusy,
2001
- children: busy === "reconnect" ? PANEL_COPY.connecting : PANEL_COPY.connect
2002
- }) : null,
2003
- status.phase === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2004
- type: "button",
2005
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
2006
- onClick: onReconnect,
2007
- disabled: actionBusy,
2008
- children: busy === "reconnect" ? PANEL_COPY.connecting : PANEL_COPY.retry
2009
- }) : null,
2010
- status.phase === "connected" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2011
- type: "button",
2012
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--danger",
2013
- onClick: onReconnect,
2014
- disabled: actionBusy,
2015
- children: busy === "reconnect" ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect
2016
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2017
- className: "dsh-channels-cfg__help",
2018
- children: PANEL_COPY.restartWarning
2019
- })] }) : null
2020
- ]
2021
2034
  })
2022
2035
  ]
2023
2036
  });
2024
2037
  }
2025
- /** Post-connect scope self-check: missing scopes in red, each with a console deep link. */
2026
- function ScopeCheckCard(props) {
2038
+ /** Status icon in front of one check row. */
2039
+ function CheckIcon(props) {
2040
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2041
+ className: `dsh-channels-cfg__checkIcon dsh-channels-cfg__checkIcon--${props.tone}`,
2042
+ "aria-hidden": "true",
2043
+ children: props.tone === "ok" ? "✓" : "!"
2044
+ });
2045
+ }
2046
+ /** Post-connect scope self-check: the gap count, expandable to each scope with its console link. */
2047
+ function ScopeCheckRow(props) {
2027
2048
  const { check } = props;
2028
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2029
- className: "dsh-channels-cfg__card",
2030
- children: [
2031
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2032
- className: "dsh-channels-cfg__cardHead",
2033
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2034
- className: "dsh-channels-cfg__cardTitle",
2035
- children: PANEL_COPY.scopeCheckTitle
2036
- })
2049
+ const [open, setOpen] = (0, react.useState)(false);
2050
+ if (check.state === "ok") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
2051
+ leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckIcon, { tone: "ok" }),
2052
+ label: PANEL_COPY.scopeCheckOk
2053
+ });
2054
+ if (check.state === "error") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
2055
+ leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckIcon, { tone: "bad" }),
2056
+ label: PANEL_COPY.scopeCheckError,
2057
+ summary: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2058
+ className: "dsh-channels-cfg__mono",
2059
+ children: check.error
2060
+ })
2061
+ });
2062
+ const required = check.missing.filter((item) => item.level === "required").length;
2063
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
2064
+ leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckIcon, { tone: required > 0 ? "bad" : "warn" }),
2065
+ label: required > 0 ? fillCopy(PANEL_COPY.scopeCheckMissingRequired, { n: required }) : fillCopy(PANEL_COPY.scopeCheckMissing, { n: check.missing.length }),
2066
+ summary: required > 0 ? PANEL_COPY.scopeCheckPublish : PANEL_COPY.scopeCheckMissingOptional,
2067
+ control: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2068
+ className: "dsh-channels-cfg__inputRow",
2069
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2070
+ type: "button",
2071
+ className: "dsh-channels-cfg__more",
2072
+ "aria-expanded": open,
2073
+ onClick: () => setOpen(!open),
2074
+ children: open ? PANEL_COPY.collapse : PANEL_COPY.expand
2075
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2076
+ className: "dsh-channels-cfg__btn",
2077
+ href: check.consoleUrl,
2078
+ target: "_blank",
2079
+ rel: "noreferrer noopener",
2080
+ children: PANEL_COPY.scopeCheckOpenAll
2081
+ })]
2082
+ })
2083
+ }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2084
+ className: "dsh-channels-cfg__scopeList",
2085
+ children: check.missing.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
2086
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
2087
+ className: "dsh-channels-cfg__mono",
2088
+ children: item.scopes.join(" / ")
2037
2089
  }),
2038
- check.state === "ok" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2039
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--ok",
2040
- children: PANEL_COPY.scopeCheckOk
2041
- }) : null,
2042
- check.state === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2043
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
2044
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.scopeCheckError }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2045
- className: "dsh-channels-cfg__mono",
2046
- children: check.error
2047
- })]
2090
+ item.level === "required" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pill, {
2091
+ tone: "bad",
2092
+ children: PANEL_COPY.scopeCheckRequired
2048
2093
  }) : null,
2049
- check.state === "missing" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2050
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
2051
- children: [
2052
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.scopeCheckMissing }),
2053
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2054
- className: "dsh-channels-cfg__scopeList",
2055
- children: check.missing.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
2056
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
2057
- "[",
2058
- item.level === "required" ? PANEL_COPY.scopeCheckRequired : PANEL_COPY.scopeCheckOptional,
2059
- "] "
2060
- ] }),
2061
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2062
- className: "dsh-channels-cfg__mono",
2063
- children: item.scopes.join(" / ")
2064
- }),
2065
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
2066
- ":",
2067
- item.purpose,
2068
- " "
2069
- ] }),
2070
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2071
- href: item.consoleUrl,
2072
- target: "_blank",
2073
- rel: "noreferrer noopener",
2074
- children: PANEL_COPY.scopeCheckOpenOne
2075
- })
2076
- ] }, item.scopes.join(",")))
2077
- }),
2078
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2079
- href: check.consoleUrl,
2080
- target: "_blank",
2081
- rel: "noreferrer noopener",
2082
- children: PANEL_COPY.scopeCheckOpenAll
2083
- })
2084
- ]
2085
- }) : null
2086
- ]
2087
- });
2094
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: item.purpose }),
2095
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2096
+ className: "dsh-channels-cfg__link",
2097
+ href: item.consoleUrl,
2098
+ target: "_blank",
2099
+ rel: "noreferrer noopener",
2100
+ children: PANEL_COPY.scopeCheckOpenOne
2101
+ })
2102
+ ] }, item.scopes.join(",")))
2103
+ }) : null] });
2088
2104
  }
2089
- /** Post-connect native slash-command panel sync: counts on success, the Feishu reason on failure. */
2090
- function SlashCommandSyncCard(props) {
2105
+ /** Post-connect native slash-command panel sync: the command count, or the Feishu reason. */
2106
+ function SlashCommandSyncRow(props) {
2091
2107
  const { sync } = props;
2092
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2093
- className: "dsh-channels-cfg__card",
2094
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2095
- className: "dsh-channels-cfg__cardHead",
2096
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2097
- className: "dsh-channels-cfg__cardTitle",
2098
- children: PANEL_COPY.slashCommandSyncTitle
2099
- })
2100
- }), sync.state === "ok" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2101
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--ok",
2102
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [PANEL_COPY.slashCommandSyncOk, " "] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
2103
- PANEL_COPY.slashCommandSyncTotal,
2104
- " ",
2105
- sync.total,
2106
- " · ",
2107
- PANEL_COPY.slashCommandSyncCreated,
2108
- " ",
2109
- sync.created,
2110
- " · ",
2111
- PANEL_COPY.slashCommandSyncUpdated,
2112
- " ",
2113
- sync.updated,
2114
- " · ",
2115
- PANEL_COPY.slashCommandSyncRemoved,
2116
- " ",
2117
- sync.removed
2118
- ] })]
2119
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2120
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
2121
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.slashCommandSyncError }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2108
+ return sync.state === "ok" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
2109
+ leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckIcon, { tone: "ok" }),
2110
+ label: PANEL_COPY.slashCommandSyncTitle,
2111
+ summary: fillCopy(PANEL_COPY.slashCommandSyncOk, { total: sync.total })
2112
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
2113
+ leading: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckIcon, { tone: "warn" }),
2114
+ label: PANEL_COPY.slashCommandSyncTitle,
2115
+ summary: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
2116
+ PANEL_COPY.slashCommandSyncError,
2117
+ ":",
2118
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2122
2119
  className: "dsh-channels-cfg__mono",
2123
2120
  children: sync.error
2124
- })]
2125
- })]
2121
+ })
2122
+ ] })
2126
2123
  });
2127
2124
  }
2128
2125
  /** Config saved but only applied on the next connect: offer the one-click restart. */
@@ -2131,97 +2128,82 @@ window.__ModuleLoader__.load({
2131
2128
  const keys = panel.config.restartPendingKeys.map((key) => fieldLabel(panel.config.fields, key)).join("、");
2132
2129
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2133
2130
  className: "dsh-channels-cfg__banner",
2134
- children: [
2135
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [PANEL_COPY.restartBanner, keys] }),
2136
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2137
- type: "button",
2138
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--danger",
2139
- onClick: onReconnect,
2140
- disabled: busy !== null || locked,
2141
- children: busy === "reconnect" ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect
2142
- }),
2143
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2144
- className: "dsh-channels-cfg__help",
2145
- children: PANEL_COPY.restartWarning
2146
- })
2147
- ]
2131
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2132
+ className: "dsh-channels-cfg__bannerText",
2133
+ children: [PANEL_COPY.restartBanner, keys]
2134
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2135
+ type: "button",
2136
+ className: "dsh-channels-cfg__btn",
2137
+ onClick: onReconnect,
2138
+ disabled: busy !== null || locked,
2139
+ children: busy === "reconnect" ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect
2140
+ })]
2148
2141
  });
2149
2142
  }
2150
2143
  //#endregion
2151
- //#region src/client/panel/model-tab.tsx
2152
- /**
2153
- * 「模型」tab:模型服务商 / 模型 id / 推理档位 / Agent 预设四个选择器。
2154
- * 选项来自 Host 实时目录(页面外壳负责 provider→model→effort 级联刷新),
2155
- * 目录读取中时在表单上方提示。
2156
- *
2157
- * @module dsh-channels/client/panel/model-tab
2158
- */
2159
- function ModelTab(props) {
2160
- const { form, catalogBusy } = props;
2144
+ //#region src/client/panel/fields-tab.tsx
2145
+ /** Footnotes under one group's section, per group. */
2146
+ const FOOTNOTES = { model: PANEL_COPY.modelFootnote };
2147
+ function FieldsTab(props) {
2148
+ const { form, tab, note } = props;
2161
2149
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2162
2150
  className: "dsh-channels-cfg__tabBody",
2163
- children: [
2164
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.modelTabHelp }),
2165
- catalogBusy ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2166
- className: "dsh-channels-cfg__help",
2167
- children: PANEL_COPY.catalogLoading
2168
- }) : null,
2169
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
2170
- form,
2171
- groups: TAB_FIELD_GROUPS.model
2172
- }),
2173
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
2174
- form,
2175
- tab: "model"
2176
- })
2177
- ]
2151
+ children: [note === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2152
+ className: "dsh-channels-cfg__secFoot",
2153
+ children: note
2154
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldSections, {
2155
+ form,
2156
+ groups: TAB_FIELD_GROUPS[tab],
2157
+ footnotes: FOOTNOTES
2158
+ })]
2178
2159
  });
2179
2160
  }
2180
2161
  //#endregion
2181
- //#region src/client/panel/sessions-tab.tsx
2162
+ //#region src/client/panel/wiki-tab.tsx
2182
2163
  /**
2183
- * 「会话与行为」tab:行为 + 内容与附件两组字段(私聊/群聊触发方式、
2184
- * 首次引导、权限切换、附件接收)。表单字段完全由 Host 描述驱动。
2164
+ * 「知识库」tab:知识库工具字段(读取 / 编辑开关、可访问范围)+ 知识库
2165
+ * 白名单(「可使用的人」)。字段保存走全局 draft,白名单增删是独立即时
2166
+ * mutation。
2185
2167
  *
2186
- * @module dsh-channels/client/panel/sessions-tab
2168
+ * @module dsh-channels/client/panel/wiki-tab
2187
2169
  */
2188
- function SessionsTab(props) {
2189
- const { form } = props;
2170
+ function WikiTab(props) {
2171
+ const { form, accessNotice, accessError, onAddUser, onRemoveUser } = props;
2172
+ const { access } = form.panel;
2173
+ const disabled = form.busy !== null || form.locked || !access.loaded;
2190
2174
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2191
2175
  className: "dsh-channels-cfg__tabBody",
2192
2176
  children: [
2193
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.sessionsTabHelp }),
2194
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
2195
- form,
2196
- groups: TAB_FIELD_GROUPS.sessions
2177
+ accessNotice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeBar, {
2178
+ kind: "ok",
2179
+ text: accessNotice
2197
2180
  }),
2198
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
2199
- form,
2200
- tab: "sessions"
2201
- })
2202
- ]
2203
- });
2204
- }
2205
- //#endregion
2206
- //#region src/client/panel/workspace-tab.tsx
2207
- /**
2208
- * 「工作区」tab:工作区根目录字段与保存栏。
2209
- *
2210
- * @module dsh-channels/client/panel/workspace-tab
2211
- */
2212
- function WorkspaceTab(props) {
2213
- const { form } = props;
2214
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2215
- className: "dsh-channels-cfg__tabBody",
2216
- children: [
2217
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.workspaceTabHelp }),
2218
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
2219
- form,
2220
- groups: TAB_FIELD_GROUPS.workspace
2181
+ accessError === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeBar, {
2182
+ kind: "error",
2183
+ text: accessError
2221
2184
  }),
2222
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
2185
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldSections, {
2223
2186
  form,
2224
- tab: "workspace"
2187
+ groups: TAB_FIELD_GROUPS.wiki
2188
+ }),
2189
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Whitelist, {
2190
+ title: PANEL_COPY.wikiUsersTitle,
2191
+ countLabel: fillCopy(PANEL_COPY.wikiUsersAdminPlus, { n: access.wikiUsers.length }),
2192
+ entries: access.wikiUsers.map((user) => ({
2193
+ id: user.userId,
2194
+ name: user.name,
2195
+ source: user.source
2196
+ })),
2197
+ empty: PANEL_COPY.wikiUsersEmpty,
2198
+ loaded: access.loaded,
2199
+ addLabel: PANEL_COPY.accessAddUser,
2200
+ idPlaceholder: PANEL_COPY.accessUserIdPlaceholder,
2201
+ namePlaceholder: PANEL_COPY.accessUserNamePlaceholder,
2202
+ footnote: PANEL_COPY.wikiUsersHint,
2203
+ disabled,
2204
+ working: form.busy === "access",
2205
+ onAdd: onAddUser,
2206
+ onRemove: onRemoveUser
2225
2207
  })
2226
2208
  ]
2227
2209
  });
@@ -2229,8 +2211,8 @@ window.__ModuleLoader__.load({
2229
2211
  //#endregion
2230
2212
  //#region src/client/panel/index.tsx
2231
2213
  /**
2232
- * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft 与顶部状态头 /
2233
- * tab 导航的外壳。
2214
+ * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft、顶部状态头 /
2215
+ * tab 导航与底部保存条的外壳。
2234
2216
  *
2235
2217
  * 所有 Host 交互(panel / status / catalog / config / credentials / access /
2236
2218
  * pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
@@ -2250,6 +2232,7 @@ window.__ModuleLoader__.load({
2250
2232
  const [pollError, setPollError] = (0, react.useState)(null);
2251
2233
  const [appIdInput, setAppIdInput] = (0, react.useState)("");
2252
2234
  const [appSecretInput, setAppSecretInput] = (0, react.useState)("");
2235
+ const [analyzeTokenInput, setAnalyzeTokenInput] = (0, react.useState)("");
2253
2236
  const [catalog, setCatalog] = (0, react.useState)(null);
2254
2237
  const [catalogBusy, setCatalogBusy] = (0, react.useState)(false);
2255
2238
  const [accessNotice, setAccessNotice] = (0, react.useState)(null);
@@ -2278,6 +2261,7 @@ window.__ModuleLoader__.load({
2278
2261
  setDraft({});
2279
2262
  setAppIdInput("");
2280
2263
  setAppSecretInput("");
2264
+ setAnalyzeTokenInput("");
2281
2265
  } catch (error) {
2282
2266
  setLoad({
2283
2267
  phase: "error",
@@ -2494,22 +2478,14 @@ window.__ModuleLoader__.load({
2494
2478
  setBusy(null);
2495
2479
  }
2496
2480
  }, [draft, panel]);
2497
- /** Drop the draft keys owned by one tab (nothing is written). */
2498
- const discardTab = (0, react.useCallback)((tab) => {
2481
+ /** Drop the whole draft (nothing is written). */
2482
+ const discardDraft = (0, react.useCallback)(() => {
2499
2483
  if (panel === null) return;
2500
- const groups = new Set(TAB_FIELD_GROUPS[tab]);
2501
- const keys = new Set(panel.config.fields.filter((field) => groups.has(field.group)).map((field) => field.key));
2502
- setDraft((previous) => {
2503
- const next = { ...previous };
2504
- for (const key of keys) delete next[key];
2505
- return next;
2506
- });
2484
+ setDraft({});
2507
2485
  setNotice(null);
2508
- if (tab === "model") {
2509
- catalogTicket.current += 1;
2510
- setCatalog(catalogFromFields(panel.config.fields));
2511
- setCatalogBusy(false);
2512
- }
2486
+ catalogTicket.current += 1;
2487
+ setCatalog(catalogFromFields(panel.config.fields));
2488
+ setCatalogBusy(false);
2513
2489
  }, [panel]);
2514
2490
  /**
2515
2491
  * Open the host's native directory chooser (workspace root「选择目录…」) and
@@ -2545,6 +2521,7 @@ window.__ModuleLoader__.load({
2545
2521
  }, []);
2546
2522
  /** Drop every Web override and return the refreshed panel. */
2547
2523
  const onClear = (0, react.useCallback)(async () => {
2524
+ if (!(typeof window.confirm === "function" ? window.confirm(PANEL_COPY.clearConfirm) : true)) return;
2548
2525
  setBusy("clear");
2549
2526
  setNotice(null);
2550
2527
  try {
@@ -2664,18 +2641,19 @@ window.__ModuleLoader__.load({
2664
2641
  setBusy(null);
2665
2642
  }
2666
2643
  }, [applyAccess]);
2667
- const onAddAccessUser = (0, react.useCallback)(async (userId, name) => {
2644
+ /** Add one user to a whitelist; resolves true when the input should clear. */
2645
+ const addUser = (0, react.useCallback)(async (add, userId, name, notice) => {
2668
2646
  setBusy("access");
2669
2647
  setAccessError(null);
2670
2648
  setAccessNotice(null);
2671
2649
  try {
2672
- const result = await addAccessUser(userId, name);
2650
+ const result = await add(userId, name);
2673
2651
  if (!result.ok) {
2674
2652
  setAccessError(result.error);
2675
2653
  return false;
2676
2654
  }
2677
2655
  applyAccess(result.access);
2678
- setAccessNotice(PANEL_COPY.accessUserAdded);
2656
+ setAccessNotice(notice);
2679
2657
  return true;
2680
2658
  } catch (error) {
2681
2659
  setAccessError(messageOf(error));
@@ -2684,18 +2662,18 @@ window.__ModuleLoader__.load({
2684
2662
  setBusy(null);
2685
2663
  }
2686
2664
  }, [applyAccess]);
2687
- const onRemoveAccessUser = (0, react.useCallback)(async (userId) => {
2665
+ const removeUser = (0, react.useCallback)(async (remove, userId, notice) => {
2688
2666
  setBusy("access");
2689
2667
  setAccessError(null);
2690
2668
  setAccessNotice(null);
2691
2669
  try {
2692
- const result = await removeAccessUser(userId);
2670
+ const result = await remove(userId);
2693
2671
  if (!result.ok) {
2694
2672
  setAccessError(result.error);
2695
2673
  return;
2696
2674
  }
2697
2675
  applyAccess(result.access);
2698
- setAccessNotice(PANEL_COPY.accessUserRemoved);
2676
+ setAccessNotice(notice);
2699
2677
  } catch (error) {
2700
2678
  setAccessError(messageOf(error));
2701
2679
  } finally {
@@ -2759,6 +2737,40 @@ window.__ModuleLoader__.load({
2759
2737
  setBusy(null);
2760
2738
  }
2761
2739
  }, [appIdInput, appSecretInput]);
2740
+ /** Save the analyze API token; it applies to the next request, no reconnect. */
2741
+ const onSaveAnalyzeToken = (0, react.useCallback)(async () => {
2742
+ if (analyzeTokenInput.trim() === "") return;
2743
+ setBusy("credentials");
2744
+ setNotice(null);
2745
+ try {
2746
+ const result = await saveCredentials({ analyzeToken: analyzeTokenInput });
2747
+ if (!result.ok) {
2748
+ setNotice({
2749
+ kind: "error",
2750
+ text: result.error,
2751
+ issues: result.issues
2752
+ });
2753
+ return;
2754
+ }
2755
+ setLoad({
2756
+ phase: "ready",
2757
+ panel: result.panel
2758
+ });
2759
+ setStatus(result.panel.status);
2760
+ setAnalyzeTokenInput("");
2761
+ setNotice({
2762
+ kind: "ok",
2763
+ text: PANEL_COPY.analyzeTokenSaved
2764
+ });
2765
+ } catch (error) {
2766
+ setNotice({
2767
+ kind: "error",
2768
+ text: messageOf(error)
2769
+ });
2770
+ } finally {
2771
+ setBusy(null);
2772
+ }
2773
+ }, [analyzeTokenInput]);
2762
2774
  const onClearCredential = (0, react.useCallback)(async (field) => {
2763
2775
  if (!(typeof window.confirm === "function" ? window.confirm(PANEL_COPY.credentialRemoveConfirm) : true)) return;
2764
2776
  setBusy("credentials-clear");
@@ -2780,7 +2792,7 @@ window.__ModuleLoader__.load({
2780
2792
  setStatus(result.panel.status);
2781
2793
  setNotice({
2782
2794
  kind: "ok",
2783
- text: result.reconnect === "pending" ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved
2795
+ text: field === "analyzeToken" ? PANEL_COPY.analyzeTokenRemoved : result.reconnect === "pending" ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved
2784
2796
  });
2785
2797
  } catch (error) {
2786
2798
  setNotice({
@@ -2793,6 +2805,7 @@ window.__ModuleLoader__.load({
2793
2805
  }, []);
2794
2806
  const issues = (0, react.useMemo)(() => notice?.kind === "error" ? notice.issues ?? [] : [], [notice]);
2795
2807
  const dirtyCount = Object.keys(draft).length;
2808
+ const restartCount = (0, react.useMemo)(() => (panel?.config.fields ?? []).filter((field) => field.restartRequired && field.key in draft).length, [draft, panel]);
2796
2809
  /** Which tabs currently own a dirty key (the dot is per tab, not global). */
2797
2810
  const dirtyTabs = (0, react.useMemo)(() => {
2798
2811
  const tabs = /* @__PURE__ */ new Set();
@@ -2818,23 +2831,15 @@ window.__ModuleLoader__.load({
2818
2831
  issues,
2819
2832
  busy,
2820
2833
  locked: hostBusy,
2821
- dirtyCount,
2822
2834
  onChange: updateDraft,
2823
- onSave: () => {
2824
- onSave();
2825
- },
2826
- onDiscardTab: discardTab,
2827
2835
  onPickDirectory
2828
2836
  };
2829
2837
  }, [
2830
2838
  busy,
2831
- discardTab,
2832
- dirtyCount,
2833
2839
  draft,
2834
2840
  hostBusy,
2835
2841
  issues,
2836
2842
  onPickDirectory,
2837
- onSave,
2838
2843
  panel,
2839
2844
  renderedFields,
2840
2845
  updateDraft
@@ -2862,6 +2867,26 @@ window.__ModuleLoader__.load({
2862
2867
  onClearCredential,
2863
2868
  onSaveCredentials
2864
2869
  ]);
2870
+ const analyzeToken = (0, react.useMemo)(() => ({
2871
+ value: analyzeTokenInput,
2872
+ issues,
2873
+ busy,
2874
+ locked: hostBusy,
2875
+ onChange: setAnalyzeTokenInput,
2876
+ onSave: () => {
2877
+ onSaveAnalyzeToken();
2878
+ },
2879
+ onClear: () => {
2880
+ onClearCredential("analyzeToken");
2881
+ }
2882
+ }), [
2883
+ analyzeTokenInput,
2884
+ busy,
2885
+ hostBusy,
2886
+ issues,
2887
+ onClearCredential,
2888
+ onSaveAnalyzeToken
2889
+ ]);
2865
2890
  if (load.phase === "loading") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2866
2891
  className: "dsh-channels-cfg",
2867
2892
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -2904,9 +2929,17 @@ window.__ModuleLoader__.load({
2904
2929
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabNav, {
2905
2930
  activeTab,
2906
2931
  dirtyTabs,
2932
+ counts: { access: load.panel.access.loaded ? load.panel.access.requests.length : 0 },
2907
2933
  onChange: setActiveTab
2908
2934
  }),
2909
- notice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeBar, { notice }),
2935
+ load.panel.access.mode === "open" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2936
+ className: "dsh-channels-cfg__banner",
2937
+ children: PANEL_COPY.debugModeWarning
2938
+ }) : null,
2939
+ notice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeBar, {
2940
+ kind: notice.kind,
2941
+ text: notice.text
2942
+ }),
2910
2943
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2911
2944
  role: "tabpanel",
2912
2945
  id: `dsh-channels-tabpanel-${activeTab}`,
@@ -2921,12 +2954,19 @@ window.__ModuleLoader__.load({
2921
2954
  onReconnect();
2922
2955
  }
2923
2956
  }) : null,
2924
- activeTab === "sessions" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionsTab, { form }) : null,
2925
- activeTab === "model" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelTab, {
2957
+ activeTab === "sessions" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsTab, {
2958
+ form,
2959
+ tab: "sessions"
2960
+ }) : null,
2961
+ activeTab === "model" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsTab, {
2926
2962
  form,
2927
- catalogBusy
2963
+ tab: "model",
2964
+ note: catalogBusy ? PANEL_COPY.catalogLoading : void 0
2965
+ }) : null,
2966
+ activeTab === "attachments" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsTab, {
2967
+ form,
2968
+ tab: "attachments"
2928
2969
  }) : null,
2929
- activeTab === "workspace" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WorkspaceTab, { form }) : null,
2930
2970
  activeTab === "access" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessTab, {
2931
2971
  form,
2932
2972
  accessNotice,
@@ -2935,74 +2975,100 @@ window.__ModuleLoader__.load({
2935
2975
  onRemoveGroup: (chatId) => {
2936
2976
  onRemoveAccessGroup(chatId);
2937
2977
  },
2938
- onAddUser: onAddAccessUser,
2978
+ onAddUser: (userId, name) => addUser(addAccessUser, userId, name, PANEL_COPY.accessUserAdded),
2939
2979
  onRemoveUser: (userId) => {
2940
- onRemoveAccessUser(userId);
2980
+ removeUser(removeAccessUser, userId, PANEL_COPY.accessUserRemoved);
2941
2981
  },
2942
2982
  onResolveRequest: (token, decision) => {
2943
2983
  onResolveAccessRequest(token, decision);
2944
2984
  }
2945
2985
  }) : null,
2986
+ activeTab === "wiki" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WikiTab, {
2987
+ form,
2988
+ accessNotice,
2989
+ accessError,
2990
+ onAddUser: (userId, name) => addUser(addWikiUser, userId, name, PANEL_COPY.wikiUserAdded),
2991
+ onRemoveUser: (userId) => {
2992
+ removeUser(removeWikiUser, userId, PANEL_COPY.wikiUserRemoved);
2993
+ }
2994
+ }) : null,
2946
2995
  activeTab === "advanced" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdvancedTab, {
2947
2996
  form,
2997
+ analyzeToken,
2948
2998
  onClear: () => {
2949
2999
  onClear();
2950
3000
  }
2951
3001
  }) : null
2952
3002
  ]
3003
+ }),
3004
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
3005
+ dirtyCount,
3006
+ restartCount,
3007
+ busy,
3008
+ locked: hostBusy,
3009
+ onSave: () => {
3010
+ onSave();
3011
+ },
3012
+ onDiscard: discardDraft
2953
3013
  })
2954
3014
  ]
2955
3015
  });
2956
3016
  }
2957
- /** Page header: title + phase badge + refresh, visible on every tab. */
3017
+ /** Page header: title, phase pill, version and uptime, refresh; visible on every tab. */
2958
3018
  function PanelHeader(props) {
2959
3019
  const { status, pollError, busy, locked, onRefresh } = props;
2960
3020
  const badge = phaseBadge(status.phase);
2961
3021
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
2962
3022
  className: "dsh-channels-cfg__panelHead",
2963
- title: PANEL_COPY.description,
2964
- children: [
2965
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2966
- className: "dsh-channels-cfg__headRow",
2967
- children: [
2968
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
2969
- className: "dsh-channels-cfg__title",
2970
- children: PANEL_COPY.title
2971
- }),
2972
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2973
- className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${badge.tone}`,
2974
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__dot" }), badge.text]
2975
- }),
2976
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
2977
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2978
- type: "button",
2979
- className: "dsh-channels-cfg__btn",
2980
- onClick: onRefresh,
2981
- disabled: busy !== null || locked,
2982
- children: PANEL_COPY.refresh
2983
- })
2984
- ]
2985
- }),
2986
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2987
- className: "dsh-channels-cfg__summary",
2988
- children: phaseSummary(status.phase)
2989
- }),
2990
- pollError === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2991
- className: "dsh-channels-cfg__fieldError",
2992
- role: "status",
2993
- "aria-live": "polite",
2994
- children: [
2995
- PANEL_COPY.pollFailed,
2996
- ":",
2997
- pollError
2998
- ]
2999
- })
3000
- ]
3023
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3024
+ className: "dsh-channels-cfg__headRow",
3025
+ children: [
3026
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
3027
+ className: "dsh-channels-cfg__title",
3028
+ children: PANEL_COPY.title
3029
+ }),
3030
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pill, {
3031
+ tone: badge.tone,
3032
+ dot: true,
3033
+ children: badge.text
3034
+ }),
3035
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3036
+ className: "dsh-channels-cfg__meta dsh-channels-cfg__mono",
3037
+ children: ["v", status.version]
3038
+ }),
3039
+ status.uptimeMs === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3040
+ className: "dsh-channels-cfg__meta",
3041
+ children: [
3042
+ "· ",
3043
+ PANEL_COPY.uptimePrefix,
3044
+ " ",
3045
+ formatUptime(status.uptimeMs)
3046
+ ]
3047
+ }),
3048
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
3049
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3050
+ type: "button",
3051
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost",
3052
+ onClick: onRefresh,
3053
+ disabled: busy !== null || locked,
3054
+ children: PANEL_COPY.refresh
3055
+ })
3056
+ ]
3057
+ }), pollError === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3058
+ className: "dsh-channels-cfg__fieldError",
3059
+ role: "status",
3060
+ "aria-live": "polite",
3061
+ children: [
3062
+ PANEL_COPY.pollFailed,
3063
+ ":",
3064
+ pollError
3065
+ ]
3066
+ })]
3001
3067
  });
3002
3068
  }
3003
3069
  /** Top-level tab navigation; the dirty dot keeps unsaved work visible per tab. */
3004
3070
  function TabNav(props) {
3005
- const { activeTab, dirtyTabs, onChange } = props;
3071
+ const { activeTab, dirtyTabs, counts, onChange } = props;
3006
3072
  const refs = (0, react.useRef)([]);
3007
3073
  /** Roving-focus keyboard navigation between tabs (WAI-ARIA tabs pattern). */
3008
3074
  const onKeyDown = (index, event) => {
@@ -3047,11 +3113,17 @@ window.__ModuleLoader__.load({
3047
3113
  className: "dsh-channels-cfg__tab",
3048
3114
  onClick: () => onChange(tab.id),
3049
3115
  onKeyDown: (event) => onKeyDown(index, event),
3050
- children: [tab.label, dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3051
- className: "dsh-channels-cfg__tabDot",
3052
- title: PANEL_COPY.dirtyCount,
3053
- "aria-label": PANEL_COPY.dirtyCount
3054
- }) : null]
3116
+ children: [
3117
+ tab.label,
3118
+ (counts[tab.id] ?? 0) > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3119
+ className: "dsh-channels-cfg__tabCount",
3120
+ children: counts[tab.id]
3121
+ }) : null,
3122
+ dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3123
+ className: "dsh-channels-cfg__tabDot",
3124
+ "aria-hidden": "true"
3125
+ }) : null
3126
+ ]
3055
3127
  }, tab.id);
3056
3128
  })
3057
3129
  });
@@ -3060,108 +3132,125 @@ window.__ModuleLoader__.load({
3060
3132
  //#region src/client/styles.ts
3061
3133
  /**
3062
3134
  * Settings panel styling. DSH semantic tokens (`--dsw-alias-*`) with neutral
3063
- * fallbacks so the page follows the active theme; only pseudo-classes and
3064
- * responsive rules live here (everything else is inline in the component).
3135
+ * fallbacks, aliased once to local `--dc-*` variables, so the page follows
3136
+ * the active theme.
3065
3137
  *
3066
3138
  * @module dsh-channels/client/styles
3067
3139
  */
3068
3140
  const STYLE_ATTRIBUTE = "data-dsh-channels-css";
3069
3141
  const CSS = `
3070
- .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}
3071
- .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))}
3142
+ .dsh-channels-cfg{--dc-fg:var(--dsw-alias-label-primary,#1f2328);--dc-fg2:var(--dsw-alias-label-secondary,#5c6169);--dc-fg3:var(--dsw-alias-label-tertiary,#8a8f98);--dc-dim:var(--dsw-alias-label-dimmed,#b0b4ba);--dc-bg:var(--dsw-alias-bg-base,#fff);--dc-line:var(--dsw-alias-border-l2,rgba(0,0,0,.12));--dc-hover:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));--dc-accent:var(--dsw-alias-state-business-primary,#0a67d3);--dc-ok:var(--dsw-alias-state-success-primary,#0a7d33);--dc-warn:var(--dsw-alias-state-warn-primary,#a15c00);--dc-bad:var(--dsw-alias-state-error-primary,#c62828);--dc-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;container-type:inline-size;display:flex;flex-direction:column;gap:14px;width:100%;max-width:720px;color:var(--dc-fg);font-size:13px;line-height:20px}
3143
+ .dsh-channels-cfg__panelHead{position:sticky;top:0;z-index:2;display:flex;flex-direction:column;gap:4px;padding:10px 0 6px;background:var(--dc-bg)}
3072
3144
  .dsh-channels-cfg__headRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
3073
- .dsh-channels-cfg__title{margin:0;font-size:16px;font-weight:500;line-height:24px}
3074
- .dsh-channels-cfg__summary{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
3075
- .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))}
3076
- .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}
3077
- .dsh-channels-cfg__tab:hover{color:var(--dsw-alias-label-primary,#1f2328)}
3078
- .dsh-channels-cfg__tab:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#0a67d3);outline-offset:-2px}
3079
- .dsh-channels-cfg__tab[aria-selected="true"]{color:var(--dsw-alias-state-business-primary,#0a67d3);border-bottom-color:currentColor;font-weight:500}
3080
- .dsh-channels-cfg__tabDot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-warning-primary,#a15c00);flex:none}
3081
- .dsh-channels-cfg__tabBody{display:flex;flex-direction:column;gap:12px}
3082
- .dsh-channels-cfg__tabHelp{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
3083
- .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}
3084
- .dsh-channels-cfg__cardHead{display:flex;align-items:center;gap:8px;min-height:20px;flex-wrap:wrap}
3085
- .dsh-channels-cfg__cardTitle{font-size:13px;font-weight:500}
3086
- .dsh-channels-cfg__cardMeta{margin-left:auto;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px}
3087
- .dsh-channels-cfg__group{display:flex;flex-direction:column;gap:8px}
3088
- .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}
3089
- .dsh-channels-cfg__dot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:none}
3090
- .dsh-channels-cfg__badge--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);border-color:currentColor}
3091
- .dsh-channels-cfg__badge--warn{color:var(--dsw-alias-state-warning-primary,#a15c00);border-color:currentColor}
3092
- .dsh-channels-cfg__badge--bad{color:var(--dsw-alias-state-danger-primary,#c62828);border-color:currentColor}
3093
- .dsh-channels-cfg__badge--muted{color:var(--dsw-alias-label-tertiary,#8a8f98)}
3094
- .dsh-channels-cfg__facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 16px}
3095
- .dsh-channels-cfg__fact{display:flex;flex-direction:column;gap:2px;min-width:0}
3096
- .dsh-channels-cfg__factLabel{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:16px}
3097
- .dsh-channels-cfg__factValue{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
3098
- .dsh-channels-cfg__actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
3099
- .dsh-channels-cfg__btn{display:inline-flex;align-items:center;justify-content:center;gap:4px;min-height:28px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:13px;cursor:pointer;white-space:nowrap}
3100
- .dsh-channels-cfg__btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05))}
3101
- .dsh-channels-cfg__btn:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#0a67d3);outline-offset:2px}
3102
- .dsh-channels-cfg__btn:disabled{color:var(--dsw-alias-label-dimmed,#b0b4ba);cursor:default}
3103
- .dsh-channels-cfg__btn--primary{background:var(--dsw-alias-button-primary-fill,#0a67d3);border-color:transparent;color:var(--dsw-alias-label-primary-foreground,#fff)}
3104
- .dsh-channels-cfg__btn--primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#0b5cba)}
3105
- .dsh-channels-cfg__btn--danger{color:var(--dsw-alias-state-danger-primary,#c62828);border-color:currentColor}
3106
- .dsh-channels-cfg__grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 20px;align-items:start}
3107
- .dsh-channels-cfg__field{display:flex;flex-direction:column;gap:4px;min-width:0}
3108
- .dsh-channels-cfg__field--wide{grid-column:1/-1}
3109
- .dsh-channels-cfg__labelRow{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
3110
- .dsh-channels-cfg__label{font-size:12px;color:var(--dsw-alias-label-secondary,#5c6169)}
3111
- .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}
3112
- .dsh-channels-cfg__tag--restart{color:var(--dsw-alias-state-warning-primary,#a15c00)}
3113
- .dsh-channels-cfg__tag--dirty{color:var(--dsw-alias-state-warning-primary,#a15c00);border-style:solid}
3114
- .dsh-channels-cfg__help{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:11px;line-height:16px}
3115
- .dsh-channels-cfg__reasonBad{color:var(--dsw-alias-state-danger-primary,#c62828)}
3116
- .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}
3117
- .dsh-channels-cfg__inputRow{display:flex;align-items:center;gap:8px;min-width:0}
3118
- .dsh-channels-cfg__inputRow .dsh-channels-cfg__input{flex:1 1 auto;width:auto;min-width:0}
3119
- .dsh-channels-cfg__btn--browse{flex:none}
3120
- .dsh-channels-cfg__list{display:flex;flex-direction:column;gap:6px;min-width:0}
3121
- .dsh-channels-cfg__listItem{display:flex;flex-direction:column;gap:2px;min-width:0}
3122
- .dsh-channels-cfg__btn--listRemove{flex:none}
3123
- .dsh-channels-cfg__input:focus{border-color:var(--dsw-alias-state-business-primary,#0a67d3)}
3124
- .dsh-channels-cfg__input--invalid{border-color:var(--dsw-alias-state-danger-primary,#c62828)}
3125
- select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
3126
- .dsh-channels-cfg__check{display:inline-flex;align-items:center;gap:6px;min-height:28px;cursor:pointer;font-size:13px}
3127
- .dsh-channels-cfg__checkbox{width:15px;height:15px;margin:0;accent-color:var(--dsw-alias-state-business-primary,#0a67d3);cursor:pointer}
3128
- .dsh-channels-cfg__fieldError{color:var(--dsw-alias-state-danger-primary,#c62828);font-size:11px;line-height:16px}
3129
- .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}
3130
- .dsh-channels-cfg__notice--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);background:color-mix(in srgb,currentColor 8%,transparent)}
3131
- .dsh-channels-cfg__notice--error{color:var(--dsw-alias-state-danger-primary,#c62828);background:color-mix(in srgb,currentColor 8%,transparent)}
3132
- .dsh-channels-cfg__notice a{color:inherit;text-decoration:underline}
3133
- .dsh-channels-cfg__scopeList{margin:2px 0;padding-left:18px;display:flex;flex-direction:column;gap:2px}
3134
- .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}
3135
- .dsh-channels-cfg__footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:2px}
3145
+ .dsh-channels-cfg__title{margin:0 2px 0 0;font-size:17px;font-weight:600;line-height:26px}
3146
+ .dsh-channels-cfg__meta{color:var(--dc-fg3);font-size:12px}
3136
3147
  .dsh-channels-cfg__spacer{flex:1}
3148
+ .dsh-channels-cfg__mono{font-family:var(--dc-mono);font-size:12px}
3149
+ .dsh-channels-cfg__pill{display:inline-flex;align-items:center;gap:5px;padding:0 8px;border-radius:999px;font-size:12px;line-height:20px;font-weight:400;white-space:nowrap;color:var(--dc-fg3);background:color-mix(in srgb,currentColor 10%,transparent)}
3150
+ .dsh-channels-cfg__pill--ok{color:var(--dc-ok)}
3151
+ .dsh-channels-cfg__pill--warn{color:var(--dc-warn)}
3152
+ .dsh-channels-cfg__pill--bad{color:var(--dc-bad)}
3153
+ .dsh-channels-cfg__dot{width:6px;height:6px;border-radius:50%;background:currentColor;flex:none}
3154
+ .dsh-channels-cfg__tabs{display:flex;align-items:center;gap:18px;border-bottom:1px solid var(--dc-line);overflow-x:auto;scrollbar-width:none}
3155
+ .dsh-channels-cfg__tab{display:inline-flex;align-items:center;gap:5px;min-height:34px;padding:0;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--dc-fg2);font:inherit;font-size:13px;cursor:pointer;white-space:nowrap}
3156
+ .dsh-channels-cfg__tab:hover{color:var(--dc-fg)}
3157
+ .dsh-channels-cfg__tab[aria-selected="true"]{color:var(--dc-fg);border-bottom-color:currentColor;font-weight:600}
3158
+ .dsh-channels-cfg__tabCount{padding:0 5px;border-radius:8px;font-size:11px;line-height:16px;font-weight:500;color:var(--dc-warn);background:color-mix(in srgb,var(--dc-warn) 12%,transparent)}
3159
+ .dsh-channels-cfg__tabDot{width:6px;height:6px;border-radius:50%;background:var(--dc-accent);flex:none}
3160
+ .dsh-channels-cfg__tabBody{display:flex;flex-direction:column;gap:22px;padding:4px 0 8px}
3161
+ .dsh-channels-cfg__sec{display:flex;flex-direction:column;gap:6px;margin:0}
3162
+ .dsh-channels-cfg__secTitle{margin:0;font-size:12px;font-weight:600;line-height:18px;color:var(--dc-fg3);letter-spacing:.02em}
3163
+ .dsh-channels-cfg__secFoot{margin:0;color:var(--dc-fg3);font-size:12px;line-height:18px}
3164
+ .dsh-channels-cfg__list{display:flex;flex-direction:column;border:1px solid var(--dc-line);border-radius:10px}
3165
+ .dsh-channels-cfg__list>*+*{border-top:1px solid var(--dc-line)}
3166
+ .dsh-channels-cfg__list--warn{border-color:color-mix(in srgb,var(--dc-warn) 45%,transparent);background:color-mix(in srgb,var(--dc-warn) 7%,transparent)}
3167
+ .dsh-channels-cfg__row{display:flex;align-items:center;gap:16px;padding:11px 14px;min-height:28px}
3168
+ .dsh-channels-cfg__row--wide{flex-direction:column;align-items:stretch;gap:8px}
3169
+ .dsh-channels-cfg__rowText{display:flex;flex-direction:column;gap:1px;flex:1 1 auto;min-width:0}
3170
+ .dsh-channels-cfg__rowLabel{display:flex;align-items:center;gap:6px;flex-wrap:wrap;overflow-wrap:anywhere}
3171
+ .dsh-channels-cfg__rowSummary{color:var(--dc-fg3);font-size:12px;line-height:18px;overflow-wrap:anywhere}
3172
+ .dsh-channels-cfg__rowCtl{flex:none;display:flex;align-items:center;justify-content:flex-end;min-width:0;max-width:62%}
3173
+ .dsh-channels-cfg__row--wide .dsh-channels-cfg__rowCtl{max-width:none;justify-content:stretch}
3174
+ .dsh-channels-cfg__row--wide .dsh-channels-cfg__rowCtl>*{flex:1 1 auto}
3175
+ .dsh-channels-cfg__details{padding:0 14px 12px;color:var(--dc-fg2);font-size:12px;line-height:19px;overflow-wrap:anywhere}
3176
+ .dsh-channels-cfg__rowError{padding:0 14px 10px;color:var(--dc-bad);font-size:12px;line-height:18px}
3177
+ .dsh-channels-cfg__list>.dsh-channels-cfg__rowWrap{border-top-width:0}
3178
+ .dsh-channels-cfg__list>.dsh-channels-cfg__rowWrap+*{border-top-width:1px}
3179
+ .dsh-channels-cfg__tag{display:inline-flex;align-items:center;padding:0 5px;border-radius:4px;font-size:11px;line-height:16px;white-space:nowrap;color:var(--dc-warn);background:color-mix(in srgb,var(--dc-warn) 12%,transparent)}
3180
+ .dsh-channels-cfg__more{padding:0;border:0;background:none;color:var(--dc-fg3);font:inherit;font-size:12px;line-height:18px;cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px;white-space:nowrap}
3181
+ .dsh-channels-cfg__more:hover{color:var(--dc-fg2)}
3182
+ .dsh-channels-cfg__link{padding:0;border:0;background:none;color:var(--dc-accent);font:inherit;font-size:13px;cursor:pointer;text-decoration:none;white-space:nowrap}
3183
+ .dsh-channels-cfg__link:disabled{color:var(--dc-dim);cursor:default}
3184
+ .dsh-channels-cfg__switch{position:relative;flex:none;width:34px;height:20px;padding:0;border:0;border-radius:10px;background:var(--dc-dim);cursor:pointer;transition:background .15s}
3185
+ .dsh-channels-cfg__switch::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
3186
+ .dsh-channels-cfg__switch[aria-checked="true"]{background:var(--dc-accent)}
3187
+ .dsh-channels-cfg__switch[aria-checked="true"]::after{transform:translateX(14px)}
3188
+ .dsh-channels-cfg__switch:disabled{opacity:.5;cursor:default}
3189
+ .dsh-channels-cfg__input{box-sizing:border-box;width:100%;min-width:0;height:30px;padding:0 10px;border:1px solid var(--dc-line);border-radius:7px;background:var(--dsw-alias-bg-layer-1,transparent);color:inherit;font:inherit;font-size:13px;outline:none}
3190
+ .dsh-channels-cfg__input:focus{border-color:var(--dc-accent)}
3191
+ .dsh-channels-cfg__input--invalid{border-color:var(--dc-bad)}
3192
+ .dsh-channels-cfg__input--num{width:120px;text-align:right;font-variant-numeric:tabular-nums}
3193
+ .dsh-channels-cfg__input--text{width:260px}
3194
+ .dsh-channels-cfg__select{width:220px;appearance:auto;cursor:pointer}
3195
+ .dsh-channels-cfg__unit{min-width:36px;color:var(--dc-fg3);font-size:12px}
3196
+ .dsh-channels-cfg__bytes{color:var(--dc-fg3);font-size:12px;white-space:nowrap}
3197
+ .dsh-channels-cfg__inputRow{display:flex;align-items:center;gap:8px;min-width:0}
3198
+ .dsh-channels-cfg__inputRow>.dsh-channels-cfg__input:not(.dsh-channels-cfg__input--num){flex:1 1 auto;width:auto}
3199
+ .dsh-channels-cfg__stringList{display:flex;flex-direction:column;gap:6px;min-width:0}
3200
+ .dsh-channels-cfg__stringListItem{display:flex;flex-direction:column;gap:2px;min-width:0}
3201
+ .dsh-channels-cfg__fieldError{color:var(--dc-bad);font-size:12px;line-height:18px}
3202
+ .dsh-channels-cfg__textBad{color:var(--dc-bad)}
3203
+ .dsh-channels-cfg__btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:28px;padding:0 12px;border:1px solid var(--dc-line);border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:13px;cursor:pointer;white-space:nowrap;text-decoration:none}
3204
+ .dsh-channels-cfg__btn:hover:not(:disabled){background:var(--dc-hover)}
3205
+ .dsh-channels-cfg__btn:disabled{color:var(--dc-dim);cursor:default}
3206
+ .dsh-channels-cfg__btn--primary{background:var(--dsw-alias-button-primary-fill,var(--dc-accent));border-color:transparent;color:var(--dsw-alias-label-primary-foreground,#fff)}
3207
+ .dsh-channels-cfg__btn--primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#0b5cba)}
3208
+ .dsh-channels-cfg__btn--primary:disabled{opacity:.45;color:var(--dsw-alias-label-primary-foreground,#fff)}
3209
+ .dsh-channels-cfg__btn--ghost{border-color:transparent;color:var(--dc-fg2)}
3210
+ .dsh-channels-cfg__btn--danger{color:var(--dc-bad)}
3211
+ .dsh-channels-cfg__btn--remove{color:var(--dc-bad);border-color:color-mix(in srgb,var(--dc-bad) 55%,transparent);background:color-mix(in srgb,var(--dc-bad) 8%,transparent)}
3212
+ .dsh-channels-cfg__btn--remove:hover:not(:disabled){background:color-mix(in srgb,var(--dc-bad) 15%,transparent)}
3213
+ .dsh-channels-cfg__btn--remove:disabled{opacity:.5;color:var(--dc-bad)}
3214
+ .dsh-channels-cfg__btn:focus-visible,.dsh-channels-cfg__tab:focus-visible,.dsh-channels-cfg__switch:focus-visible,.dsh-channels-cfg__link:focus-visible,.dsh-channels-cfg__more:focus-visible{outline:2px solid var(--dc-accent);outline-offset:2px}
3215
+ .dsh-channels-cfg__notice{padding:8px 12px;border-radius:8px;font-size:12px;line-height:18px;overflow-wrap:anywhere}
3216
+ .dsh-channels-cfg__notice--ok{color:var(--dc-ok);background:color-mix(in srgb,var(--dc-ok) 9%,transparent)}
3217
+ .dsh-channels-cfg__notice--error{color:var(--dc-bad);background:color-mix(in srgb,var(--dc-bad) 8%,transparent)}
3218
+ .dsh-channels-cfg__banner{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:9px 12px;border-radius:8px;background:color-mix(in srgb,var(--dc-warn) 12%,transparent);color:var(--dc-fg);font-size:12px;line-height:18px}
3219
+ .dsh-channels-cfg__bannerText{flex:1;min-width:0}
3220
+ .dsh-channels-cfg__hero{display:flex;flex-direction:column;gap:12px;padding:14px 16px;border:1px solid var(--dc-line);border-radius:10px}
3221
+ .dsh-channels-cfg__heroTop{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
3222
+ .dsh-channels-cfg__heroTop strong{font-size:14px;font-weight:600}
3223
+ .dsh-channels-cfg__stats{display:flex;gap:28px;flex-wrap:wrap}
3224
+ .dsh-channels-cfg__stat{display:flex;flex-direction:column}
3225
+ .dsh-channels-cfg__stat span{color:var(--dc-fg3);font-size:12px}
3226
+ .dsh-channels-cfg__stat strong{font-size:18px;font-weight:600;line-height:26px;font-variant-numeric:tabular-nums}
3227
+ .dsh-channels-cfg__stat strong.dsh-channels-cfg__mono{font-size:14px}
3228
+ .dsh-channels-cfg__checkIcon{display:grid;place-items:center;flex:none;width:18px;height:18px;border-radius:50%;font-size:11px;font-weight:700;align-self:flex-start;margin-top:1px}
3229
+ .dsh-channels-cfg__checkIcon--ok{color:var(--dc-ok);background:color-mix(in srgb,var(--dc-ok) 12%,transparent)}
3230
+ .dsh-channels-cfg__checkIcon--warn{color:var(--dc-warn);background:color-mix(in srgb,var(--dc-warn) 14%,transparent)}
3231
+ .dsh-channels-cfg__checkIcon--bad{color:var(--dc-bad);background:color-mix(in srgb,var(--dc-bad) 12%,transparent)}
3232
+ .dsh-channels-cfg__scopeList{margin:0;padding:0 14px 12px 46px;list-style:none;display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--dc-fg2)}
3233
+ .dsh-channels-cfg__scopeList li{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
3234
+ .dsh-channels-cfg__scopeList .dsh-channels-cfg__link{font-size:12px}
3235
+ .dsh-channels-cfg__list>.dsh-channels-cfg__scopeList{border-top-width:0}
3236
+ .dsh-channels-cfg__avatar{display:grid;place-items:center;flex:none;width:26px;height:26px;border-radius:50%;background:var(--dc-hover);color:var(--dc-fg2);font-size:12px}
3237
+ .dsh-channels-cfg__addRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 14px}
3238
+ .dsh-channels-cfg__addRow .dsh-channels-cfg__input{flex:1 1 160px;width:auto}
3239
+ .dsh-channels-cfg__emptyRow{padding:11px 14px;color:var(--dc-fg3);font-size:12px}
3240
+ .dsh-channels-cfg__saveBar{position:sticky;bottom:0;z-index:2;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 0;border-top:1px solid var(--dc-line);background:var(--dc-bg)}
3241
+ .dsh-channels-cfg__saveMsg{flex:1;min-width:0;color:var(--dc-fg2);font-size:12px}
3137
3242
  .dsh-channels-cfg__steps{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
3138
3243
  .dsh-channels-cfg__step{display:flex;align-items:flex-start;gap:10px}
3139
- .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}
3244
+ .dsh-channels-cfg__stepIndex{display:grid;place-items:center;flex:none;width:20px;height:20px;border-radius:50%;color:var(--dc-accent);background:color-mix(in srgb,var(--dc-accent) 12%,transparent);font-size:12px;font-variant-numeric:tabular-nums}
3140
3245
  .dsh-channels-cfg__stepBody{display:flex;flex-direction:column;gap:2px;min-width:0}
3141
- .dsh-channels-cfg__stepTitle{font-size:13px;font-weight:500}
3142
- .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}
3143
- .dsh-channels-cfg__readOnly{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 20px}
3144
- .dsh-channels-cfg__kv{display:flex;flex-direction:column;gap:2px;min-width:0}
3145
- .dsh-channels-cfg__kvKey{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:16px}
3146
- .dsh-channels-cfg__kvValue{overflow-wrap:anywhere}
3147
- .dsh-channels-cfg__mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
3148
- .dsh-channels-cfg__cred{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
3149
- .dsh-channels-cfg__cred .dsh-channels-cfg__input{flex:1 1 240px;min-width:0;width:auto}
3150
- .dsh-channels-cfg__accessSection{display:flex;flex-direction:column;gap:6px}
3151
- .dsh-channels-cfg__accessList{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
3152
- .dsh-channels-cfg__accessRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 8px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px}
3153
- .dsh-channels-cfg__accessMain{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1 1 200px;overflow-wrap:anywhere}
3154
- .dsh-channels-cfg__accessMeta{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:11px;line-height:16px;overflow-wrap:anywhere}
3155
- .dsh-channels-cfg__accessActions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
3156
- .dsh-channels-cfg__accessAdd{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
3157
- .dsh-channels-cfg__accessInput{flex:1 1 140px;min-width:0;width:auto}
3158
- .dsh-channels-cfg__error{padding:10px 12px;border:1px solid var(--dsw-alias-state-danger-primary,#c62828);border-radius:10px;color:var(--dsw-alias-state-danger-primary,#c62828);font-size:12px;line-height:18px;display:flex;flex-direction:column;gap:8px}
3159
- .dsh-channels-cfg__loading{padding:16px 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px}
3160
- .dsh-channels-cfg__issues{margin:0;padding-left:18px}
3246
+ .dsh-channels-cfg__stepTitle{font-weight:500}
3247
+ .dsh-channels-cfg__error{padding:10px 12px;border:1px solid var(--dc-bad);border-radius:10px;color:var(--dc-bad);font-size:12px;line-height:18px;display:flex;flex-direction:column;gap:8px}
3248
+ .dsh-channels-cfg__loading{padding:16px 0;color:var(--dc-fg3);font-size:12px}
3249
+ @media (prefers-reduced-motion:reduce){.dsh-channels-cfg__switch,.dsh-channels-cfg__switch::after{transition:none}}
3161
3250
  @container (max-width:560px){
3162
- .dsh-channels-cfg__facts{grid-template-columns:repeat(2,minmax(0,1fr))}
3163
- .dsh-channels-cfg__grid,.dsh-channels-cfg__readOnly{grid-template-columns:minmax(0,1fr)}
3164
- .dsh-channels-cfg__tabs{flex-wrap:nowrap;overflow-x:auto}
3251
+ .dsh-channels-cfg__row:not(.dsh-channels-cfg__row--wide){flex-wrap:wrap}
3252
+ .dsh-channels-cfg__rowCtl{max-width:100%}
3253
+ .dsh-channels-cfg__select,.dsh-channels-cfg__input--text{width:100%}
3165
3254
  }
3166
3255
  `;
3167
3256
  /** Append the panel stylesheet once (idempotent across HMR / remounts). */