@modesto_ma/dsh-channels 0.5.0-beta.4 → 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.
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
  */
@@ -56,15 +56,15 @@ window.__ModuleLoader__.load({
56
56
  },
57
57
  {
58
58
  id: "sessions",
59
- label: "会话与行为"
59
+ label: "会话"
60
60
  },
61
61
  {
62
62
  id: "model",
63
63
  label: "模型"
64
64
  },
65
65
  {
66
- id: "workspace",
67
- label: "工作区"
66
+ id: "attachments",
67
+ label: "附件与工具"
68
68
  },
69
69
  {
70
70
  id: "access",
@@ -79,91 +79,91 @@ window.__ModuleLoader__.load({
79
79
  label: "高级"
80
80
  }
81
81
  ];
82
- /** Which editable field groups each tab renders (the tabs own one or two). */
82
+ /** Which editable field groups each tab renders, in section order. */
83
83
  const TAB_FIELD_GROUPS = {
84
84
  connection: [],
85
85
  sessions: [
86
- "behavior",
87
- "content",
88
- "tools"
86
+ "trigger",
87
+ "workspace",
88
+ "cards",
89
+ "features"
89
90
  ],
90
91
  model: ["model"],
91
- workspace: ["workspace"],
92
+ attachments: [
93
+ "attachments",
94
+ "tools",
95
+ "limits"
96
+ ],
92
97
  access: ["access"],
93
98
  wiki: ["wiki"],
94
- advanced: ["streaming"]
99
+ advanced: ["analyze", "logging"]
95
100
  };
96
101
  /** Section heading for one field group inside its tab. */
97
102
  const FIELD_GROUP_LABELS = {
98
- workspace: "工作区与文件",
99
- behavior: "行为",
100
- content: "内容与附件",
101
- tools: "模型工具",
102
- model: "模型与推理",
103
- streaming: "流式卡片与日志",
104
- access: "准入与授权",
105
- wiki: "知识库工具"
103
+ trigger: "触发",
104
+ workspace: "工作区",
105
+ cards: "话题与卡片",
106
+ features: "飞书侧功能",
107
+ model: "模型",
108
+ attachments: "入站附件",
109
+ tools: "模型可用的飞书工具",
110
+ limits: "限额与超时",
111
+ access: "准入",
112
+ wiki: "知识库工具",
113
+ analyze: "日志分析接口",
114
+ logging: "日志"
106
115
  };
116
+ /** Groups rendered collapsed behind one summary row (rarely changed values). */
117
+ const COLLAPSED_FIELD_GROUPS = ["limits"];
107
118
  /** Display labels for the panel header/cards (kept on the wire so the client owns no copy). */
108
119
  const PANEL_COPY = {
109
120
  title: "飞书渠道",
110
- description: "飞书长连接、应用凭据与渠道配置。凭据只写入 DSH 凭据库且不回显;标注「需重连」的项在连接建立时读取。",
111
- statusCard: "连接状态",
112
121
  phaseUnconfigured: "待配置",
113
122
  phaseIdle: "未连接",
114
123
  phaseConnecting: "连接中",
115
124
  phaseConnected: "已连接",
116
125
  phaseError: "连接失败",
117
- configuredBadge: "凭据已配置",
118
- unconfiguredBadge: "凭据未配置",
119
- statusSummaryConnected: "飞书长连接已建立,机器人可以收发消息。",
120
- statusSummaryUnconfigured: "尚未配置飞书应用凭据:插件已加载,但不会连接飞书。填写凭据并保存后会自动连接。",
121
- statusSummaryIdle: "飞书应用凭据已就绪,但当前没有建立长连接。",
122
- statusSummaryConnecting: "正在连接飞书长连接…",
123
- statusSummaryError: "最近一次连接飞书失败,请检查凭据与网络后重试。",
126
+ statusSummaryConnected: "机器人在线,可以收发消息",
127
+ statusSummaryUnconfigured: "还没有填写飞书应用凭据,插件不会连接飞书",
128
+ statusSummaryIdle: "凭据已就绪,还没有建立连接",
129
+ statusSummaryConnecting: "正在连接飞书…",
130
+ statusSummaryError: "连接飞书失败,请检查凭据与网络后重试",
131
+ uptimePrefix: "运行",
124
132
  lastError: "最近一次连接失败",
125
- scopeCheckTitle: "权限自检",
126
- scopeCheckOk: "已开通插件需要的全部飞书权限。",
127
- scopeCheckMissing: "以下飞书权限未开通,开通后需在开发者后台发布新版本:",
128
- scopeCheckOpenAll: "在开发者后台开通全部缺失权限",
133
+ checksTitle: "自检",
134
+ scopeCheckOk: "插件需要的飞书权限都已开通",
135
+ scopeCheckMissing: "{n} 项权限未开通",
136
+ scopeCheckMissingRequired: "{n} 项必需权限未开通,相关功能无法使用",
137
+ scopeCheckMissingOptional: "不影响收发消息,只有相关功能不可用。开通后需在开发者后台发布新版本。",
138
+ scopeCheckPublish: "开通后需在开发者后台发布新版本。",
139
+ scopeCheckOpenAll: "全部开通",
129
140
  scopeCheckOpenOne: "去开通",
130
- scopeCheckOptional: "可选",
131
141
  scopeCheckRequired: "必需",
132
- scopeCheckError: "无法读取应用的权限授予状态:",
142
+ scopeCheckError: "无法读取权限开通情况",
133
143
  slashCommandSyncTitle: "斜杠指令面板",
134
- slashCommandSyncOk: "插件命令已同步到飞书原生斜杠指令面板,在聊天框输入 / 即可选择(飞书客户端约 5 分钟后刷新)。",
135
- slashCommandSyncTotal: "命令数",
136
- slashCommandSyncCreated: "本次新增",
137
- slashCommandSyncUpdated: "本次更新",
138
- slashCommandSyncRemoved: "本次删除",
139
- slashCommandSyncError: "同步飞书原生斜杠指令面板失败(不影响直接输入命令):",
144
+ slashCommandSyncOk: "已同步 {total} 条命令,飞书客户端约 5 分钟后刷新",
145
+ slashCommandSyncError: "同步失败,直接输入命令不受影响",
140
146
  missingConfig: "缺少配置",
141
- statusHintUnconfigured: "配置完成后插件会自动连接;也可以稍后在本页手动连接。",
142
- connectionInfo: "连接信息",
143
- connectionStateLabel: "飞书长连接",
144
- botOpenId: "Bot open_id",
145
- uptime: "运行时长",
146
147
  routeCount: "话题路由",
147
148
  liveAgentCount: "活动会话",
148
149
  inflightTurnCount: "在途回复",
150
+ botOpenId: "Bot open_id",
149
151
  healthReasons: "健康判定",
150
152
  unknownValue: "—",
151
153
  connect: "连接飞书",
152
154
  connecting: "连接中…",
153
155
  onboardingTitle: "开始使用飞书渠道",
154
- onboardingIntro: "只需三步:在飞书开放平台创建自建应用,把 App ID / App Secret 填入本页,保存后自动连接。",
155
156
  onboardingStep1Title: "创建飞书自建应用",
156
- onboardingStep1Text: "在飞书开放平台创建企业自建应用,开启机器人能力;事件订阅选择「长连接」,订阅 im.message.receive_v1(接收消息)。",
157
+ onboardingStep1Text: "在 open.feishu.cn/app 创建企业自建应用,开启机器人能力;事件订阅选「长连接」,订阅 im.message.receive_v1。",
157
158
  onboardingStep2Title: "填写应用凭据",
158
- onboardingStep2Text: "把应用的 App ID(cli_ 开头)与 App Secret 填入下方「飞书应用凭据」,值会写入 DSH 凭据库。",
159
- onboardingStep3Title: "保存并等待连接",
160
- onboardingStep3Text: "两项凭据都保存后插件会自动连接;状态变为「已连接」即可在飞书里私聊机器人。",
161
- onboardingDocsHint: "开放平台地址:open.feishu.cn/app;应用需要开通 im:message、im:message:send_as_bot 等消息权限。",
162
- credentialsCard: "飞书应用凭据",
163
- credentialsCardHelp: "凭据值经 DSH 凭据服务写入本机凭据库;面板只显示「已配置 / 未配置」,永不回显已存值。两项都配置后才会连接飞书。",
164
- appIdLabel: "飞书 App ID",
165
- appSecretLabel: "飞书 App Secret",
166
- 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: "凭据库",
167
167
  saveCredentials: "保存凭据",
168
168
  savingCredentials: "保存中…",
169
169
  credentialSaved: "凭据已保存",
@@ -173,27 +173,29 @@ window.__ModuleLoader__.load({
173
173
  credentialAutoConnectFailed: "凭据已保存,但自动连接失败",
174
174
  credentialRemoved: "已移除存储的凭据",
175
175
  credentialRemovedPending: "已移除存储的凭据,重启飞书连接后生效",
176
- removeCredential: "移除已存值",
176
+ replaceCredential: "更换",
177
+ setCredential: "设置",
178
+ cancel: "取消",
179
+ removeCredential: "移除",
177
180
  removingCredential: "移除中…",
178
181
  credentialConfigured: "已配置",
179
182
  credentialMissing: "未配置",
180
183
  credentialReadOnly: "当前凭据存储不可写",
181
184
  appIdPlaceholder: "输入 App ID(cli_…)",
182
185
  appSecretPlaceholder: "输入 App Secret",
183
- credentialsPendingHint: "填写后点「保存凭据」;两项齐全时插件会自动连接飞书。",
184
- connectionTabHelp: "连接状态与飞书应用凭据。凭据不完整时不会连接飞书。",
185
- sessionsTabHelp: "私聊、群聊触发方式与入站内容:附件存储/预取、模型工具开关与读取预算;「需重连」项在下次连接时读取。",
186
- modelTabHelp: "飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。",
187
- workspaceTabHelp: "每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。入站附件默认存于 DSH 原生附件库,不占用话题目录。插件不会自动删除工作区或话题目录;过期目录可手动删除,话题下次对话时会重建空目录。",
188
- accessTabHelp: "准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。",
189
- wikiTabHelp: "知识库工具以应用身份访问「知识库范围」内的文档、电子表格和多维表格。只有管理员或知识库白名单里的用户发起的话题能用;每次打开、新建、修改仍需话题发起人在授权卡上确认。",
190
- advancedTabHelp: "流式刷新等低层参数、Web 覆盖文件与只读信息。",
191
- save: "保存更改",
186
+ credentialRemoveConfirm: "确定移除已存储的凭据值?移除后不会回显,需要时须重新填写。",
187
+ save: "保存",
192
188
  saving: "保存中…",
193
- saveHintDirty: "只保存改动过的字段;标注「需重连」的项在下次连接时生效。",
194
- discardTab: "放弃本页更改",
195
- saveHintClean: "没有未保存的更改。",
196
- 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: "需重连",
197
199
  listAdd: "添加",
198
200
  listRemove: "移除",
199
201
  listEntry: "第 {n} 项",
@@ -205,62 +207,55 @@ window.__ModuleLoader__.load({
205
207
  directoryPicked: "已选择目录",
206
208
  directoryPickFailed: "无法打开系统目录选择器",
207
209
  saved: "已保存",
208
- cleared: "已清除 Web 覆盖,恢复为 profile 配置",
209
- clear: "清除 Web 覆盖",
210
- clearing: "清除中…",
211
- noOverrides: "当前没有 Web 覆盖",
212
- overridden: "Web 覆盖",
213
- inherited: "继承 profile",
214
- restartTag: "需重连",
215
- restartBanner: "以下配置需重启飞书连接后生效:",
216
- restartWarning: "重启会断开当前飞书长连接并中断进行中的回复,请确认空闲时操作。",
217
- 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: "重启连接",
218
221
  reconnecting: "重连中…",
219
- refresh: "刷新状态",
222
+ refresh: "刷新",
220
223
  retry: "重试",
221
224
  pollFailed: "状态刷新失败",
222
225
  connectDone: "已连接飞书",
223
226
  reconnectDone: "已重启飞书连接",
224
- saveInvalid: "请先修正标红的配置项",
225
- enabled: "已启用",
226
- disabled: "已关闭",
227
- credentialRemoveConfirm: "确定移除已存储的凭据值?移除后不会回显,需要时须重新填写。",
228
- analyzeTokenCard: "日志分析接口令牌",
229
- analyzeTokenCardHelp: "日志平台调用分析接口时 Authorization: Bearer 后面的值。经 DSH 凭据服务写入本机凭据库,面板永不回显;保存或移除后下一次请求即生效,无需重连。未配置时接口对所有请求返回 503。",
227
+ loading: "加载中…",
228
+ loadFailed: "设置面板加载失败",
230
229
  analyzeTokenLabel: "访问令牌",
230
+ analyzeTokenSummary: "请求头 Authorization: Bearer 的值,保存后立即生效;未配置时接口拒绝所有请求",
231
231
  analyzeTokenPlaceholder: "填写新令牌(建议 32 位以上随机串)",
232
- saveAnalyzeToken: "保存令牌",
233
232
  analyzeTokenSaved: "令牌已保存,下一次请求生效",
234
233
  analyzeTokenRemoved: "令牌已移除,接口将拒绝所有请求",
235
- accessNotLoaded: "飞书连接建立后加载授权列表",
236
- loading: "加载中…",
237
- loadFailed: "设置面板加载失败",
238
- catalogFailed: "模型目录加载失败,已退回手填。",
239
- catalogLoading: "正在读取宿主机选项…",
240
- catalogUnavailable: "宿主机未提供可选项,请手填。",
241
- version: "插件版本",
234
+ overlayTitle: "Web 覆盖",
242
235
  overlayPath: "覆盖文件",
243
- overlayPathHelp: "可以直接编辑该 JSON 文件;面板保存会整体重写它,「清除 Web 覆盖」会删除它并回落到 profile 配置。",
244
- advancedCard: "只读与高级信息",
245
- advancedHelp: "这些值来自 profile 配置,不能在面板里编辑。",
246
- readOnlyCard: "部署级参数",
247
- accessCard: "访问与授权",
248
- accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 user_id。",
249
- accessModeOpenWarning: "当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。",
250
- accessAdminMissingWarning: "当前准入=guarded 但未配置管理员,也无法自动使用应用所有者,所有对话都会被拒绝。请在上方「准入与授权」填写管理员 user_id(私聊机器人发送 /userid 获取;私聊模式关闭时 /userid 不可用,可在飞书管理后台的成员详情里查看用户 ID)。",
251
- 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: "待处理申请",
252
248
  accessGroupsTitle: "已授权群",
253
- accessGroupsEmpty: "尚未授权任何群。机器人被拉进群后,管理员会收到带 chat_id 的群信息卡;也可在群内发送 /groupid 获取。",
254
249
  accessUsersTitle: "已授权用户",
255
- accessUsersEmpty: "尚未授权任何用户。已授权用户可在所有已授权群里与机器人对话(私聊仍只开给管理员)。",
256
- accessRequestsTitle: "待处理申请",
257
- accessRequestsEmpty: "没有待处理的授权申请。",
258
- accessGroupChatIdPlaceholder: "群 chat_id(oc_…)",
250
+ accessGroupsEmpty: "还没有授权任何群",
251
+ accessUsersEmpty: "还没有授权任何用户",
252
+ accessGroupChatIdPlaceholder: "chat_id(oc_…,群里发 /groupid 获取)",
259
253
  accessGroupNamePlaceholder: "群名(可选)",
260
- accessUserIdPlaceholder: "用户 user_id",
254
+ accessUserIdPlaceholder: "user_id(私聊机器人发 /userid 获取)",
261
255
  accessUserNamePlaceholder: "昵称(可选)",
262
256
  accessAddGroup: "添加群",
263
257
  accessAddUser: "添加用户",
258
+ accessAdd: "添加",
264
259
  accessRemove: "移除",
265
260
  accessAllow: "授权",
266
261
  accessDeny: "忽略",
@@ -268,24 +263,36 @@ window.__ModuleLoader__.load({
268
263
  accessSourceCommand: "对话命令",
269
264
  accessSourceCard: "授权卡",
270
265
  accessSourcePanel: "设置面板",
271
- accessRequestGroup: "群申请",
272
- accessRequestUser: "用户申请",
273
- accessRequestRequester: "申请人",
274
- accessRequestExpires: "有效期至",
266
+ accessRequestGroup: "申请授权群",
267
+ accessRequestUser: "申请使用",
268
+ accessRequestIn: "在「{group}」",
269
+ accessRequestExpires: "{date} 前有效",
275
270
  accessGroupAdded: "已添加群授权",
276
271
  accessGroupRemoved: "已移除群授权",
277
272
  accessUserAdded: "已添加用户授权",
278
273
  accessUserRemoved: "已移除用户授权",
279
274
  accessRequestAllowed: "已授权",
280
275
  accessRequestDenied: "已忽略",
281
- accessHints: "三种授权入口:群内 /auth 命令、入群与用户授权卡、本面板。撤销立即生效;白名单只约束非管理员(管理员在任何私聊/群聊都直接放行),非管理员需要群和用户两层同时命中。",
282
- wikiUsersCard: "知识库白名单",
283
- wikiUsersHelp: "与「访问授权」里的用户白名单相互独立:这里的用户(以及管理员)发起的话题可以使用知识库工具。也可以在飞书里发送 /auth wiki add @用户 或 /auth wiki remove @用户;移除立即生效。",
284
- wikiUsersEmpty: "知识库白名单为空:目前只有管理员发起的话题能使用知识库工具。",
276
+ wikiUsersTitle: "可使用的人",
277
+ wikiUsersAdminPlus: "管理员 + {n}",
278
+ wikiUsersEmpty: "目前只有管理员发起的话题能用知识库工具",
279
+ wikiUsersHint: "也可以在飞书发 /auth wiki add @用户",
285
280
  wikiUserAdded: "已加入知识库白名单",
286
- wikiUserRemoved: "已移出知识库白名单",
287
- accessChatIdHint: "chat_id 可从入群群信息卡或群内 /groupid 获取;user_id 可私聊机器人发送 /userid 获取。"
281
+ wikiUserRemoved: "已移出知识库白名单"
288
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
+ }
289
296
  //#endregion
290
297
  //#region src/client/api.ts
291
298
  /**
@@ -602,6 +609,110 @@ window.__ModuleLoader__.load({
602
609
  });
603
610
  }
604
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
605
716
  //#region src/feishu/wiki/links.ts
606
717
  /**
607
718
  * Feishu wiki (知识库) link parsing: the `wikiRoots` allow-list entries and
@@ -801,8 +912,8 @@ window.__ModuleLoader__.load({
801
912
  //#endregion
802
913
  //#region src/client/panel/shared.tsx
803
914
  /**
804
- * 设置面板共享层:页面状态类型、Host 字段渲染原语(FieldRow / FieldsCard /
805
- * SaveBar)、生命周期徽标与纯格式化工具。
915
+ * 设置面板共享层:页面状态类型、Host 字段渲染原语(SettingRow /
916
+ * FieldSections / SaveBar)、分区与列表原语、生命周期徽标与纯格式化工具。
806
917
  *
807
918
  * 这一层不发请求、不持有页面状态,只把 Host 下发的 wire 数据渲染成控件;
808
919
  * 页面外壳在 `index.tsx`,各 tab 只组合这些原语。
@@ -812,7 +923,7 @@ window.__ModuleLoader__.load({
812
923
  function messageOf(error) {
813
924
  return error instanceof Error ? error.message : String(error);
814
925
  }
815
- /** Compact duration for the connection card. */
926
+ /** Compact duration for the page header. */
816
927
  function formatUptime(ms) {
817
928
  if (ms === void 0 || !Number.isFinite(ms)) return PANEL_COPY.unknownValue;
818
929
  const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
@@ -832,21 +943,9 @@ window.__ModuleLoader__.load({
832
943
  function fieldLabel(fields, key) {
833
944
  return fields.find((field) => field.key === key)?.label ?? key;
834
945
  }
835
- /** Effective access-mode label, reusing the Host field options for consistency. */
836
- function accessModeLabel(panel, mode) {
837
- return panel.config.fields.find((item) => item.key === "accessMode")?.options?.find((option) => option.value === mode)?.label ?? mode;
838
- }
839
- /** Numeric draft kept as the raw input string; parsed on save. */
840
- function numericDraft(field, raw) {
841
- if (typeof raw === "number") return raw;
842
- if (typeof raw !== "string" || raw.trim() === "") return void 0;
843
- const parsed = Number(raw);
844
- return Number.isFinite(parsed) ? parsed : void 0;
845
- }
846
946
  /**
847
947
  * Turn the dirty draft into the wire payload, keeping the Host-descriptor
848
- * bounds check client-side. Field messages stay technical (the Host re-validates
849
- * every value on save).
948
+ * bounds check client-side (the Host re-validates every value on save).
850
949
  */
851
950
  function validateDraft(panel, draft) {
852
951
  const values = {};
@@ -855,29 +954,12 @@ window.__ModuleLoader__.load({
855
954
  if (!(field.key in draft)) continue;
856
955
  const raw = draft[field.key];
857
956
  if (field.kind === "number") {
858
- const parsed = numericDraft(field, raw);
859
- if (parsed === void 0) {
860
- issues.push({
861
- key: field.key,
862
- message: "请输入数字"
863
- });
864
- continue;
865
- }
866
- if (field.min !== void 0 && parsed < field.min) {
867
- issues.push({
868
- key: field.key,
869
- message: `不得小于 ${field.min}`
870
- });
871
- continue;
872
- }
873
- if (field.max !== void 0 && parsed > field.max) {
874
- issues.push({
875
- key: field.key,
876
- message: `不得大于 ${field.max}`
877
- });
878
- continue;
879
- }
880
- 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
+ });
881
963
  continue;
882
964
  }
883
965
  if (field.kind === "boolean") {
@@ -897,7 +979,7 @@ window.__ModuleLoader__.load({
897
979
  issues
898
980
  };
899
981
  }
900
- /** Header badge for the Host-derived lifecycle phase. */
982
+ /** Header pill for the Host-derived lifecycle phase. */
901
983
  function phaseBadge(phase) {
902
984
  switch (phase) {
903
985
  case "connected": return {
@@ -922,7 +1004,7 @@ window.__ModuleLoader__.load({
922
1004
  };
923
1005
  }
924
1006
  }
925
- /** One-line explanation shown under the page title for the current phase. */
1007
+ /** One-line explanation of the current phase (connection tab status block). */
926
1008
  function phaseSummary(phase) {
927
1009
  switch (phase) {
928
1010
  case "connected": return PANEL_COPY.statusSummaryConnected;
@@ -932,6 +1014,99 @@ window.__ModuleLoader__.load({
932
1014
  default: return PANEL_COPY.statusSummaryUnconfigured;
933
1015
  }
934
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
+ }
935
1110
  /**
936
1111
  * Text input paired with the host-native「选择目录…」button. The button owns
937
1112
  * its own busy state: the OS dialog can stay open for a long time, so the
@@ -960,7 +1135,7 @@ window.__ModuleLoader__.load({
960
1135
  onChange: (event) => onChange(field.key, event.target.value)
961
1136
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
962
1137
  type: "button",
963
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--browse",
1138
+ className: "dsh-channels-cfg__btn",
964
1139
  disabled: disabled || picking,
965
1140
  onClick: () => {
966
1141
  pick();
@@ -971,7 +1146,7 @@ window.__ModuleLoader__.load({
971
1146
  }
972
1147
  /**
973
1148
  * `string-list` control: one single-line input per entry, each with a remove
974
- * 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
975
1150
  * list renders one blank input so the first entry can be typed directly.
976
1151
  * Issues carrying an `index` mark that input and show the reason under it.
977
1152
  */
@@ -980,12 +1155,12 @@ window.__ModuleLoader__.load({
980
1155
  const empty = listItems(value).length === 0;
981
1156
  const rows = listRows(value);
982
1157
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
983
- className: "dsh-channels-cfg__list",
1158
+ className: "dsh-channels-cfg__stringList",
984
1159
  children: [rows.map((item, index) => {
985
1160
  const issue = issues.find((entry) => entry.index === index);
986
1161
  const position = PANEL_COPY.listEntry.replace("{n}", String(index + 1));
987
1162
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
988
- className: "dsh-channels-cfg__listItem",
1163
+ className: "dsh-channels-cfg__stringListItem",
989
1164
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
990
1165
  className: "dsh-channels-cfg__inputRow",
991
1166
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -999,7 +1174,7 @@ window.__ModuleLoader__.load({
999
1174
  onChange: (event) => onChange(field.key, withListEntry(rows, index, event.target.value))
1000
1175
  }), empty ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1001
1176
  type: "button",
1002
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--listRemove",
1177
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--remove",
1003
1178
  "aria-label": `${PANEL_COPY.listRemove} ${position}`,
1004
1179
  disabled,
1005
1180
  onClick: () => onChange(field.key, withoutListEntry(rows, index)),
@@ -1010,874 +1185,703 @@ window.__ModuleLoader__.load({
1010
1185
  children: issue.message
1011
1186
  })]
1012
1187
  }, index);
1013
- }), /* @__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", {
1014
1189
  type: "button",
1015
- className: "dsh-channels-cfg__btn",
1190
+ className: "dsh-channels-cfg__link",
1016
1191
  disabled: disabled || listIsFull(field, rows),
1017
1192
  onClick: () => onChange(field.key, withBlankListEntry(rows)),
1018
- children: PANEL_COPY.listAdd
1193
+ children: ["+ ", PANEL_COPY.listAdd]
1019
1194
  }) })]
1020
1195
  });
1021
1196
  }
1022
- /** One generic form control driven by the Host field descriptor. */
1023
- function FieldRow(props) {
1024
- const { field, value, issues, disabled = false, onChange, onPickDirectory } = props;
1025
- const issue = issues.find((item) => item.key === field.key && item.index === void 0);
1026
- const entryIssues = issues.filter((item) => item.key === field.key && item.index !== void 0);
1027
- 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;
1028
1202
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1029
- className: `dsh-channels-cfg__field${field.wide === true ? " dsh-channels-cfg__field--wide" : ""}`,
1203
+ className: "dsh-channels-cfg__inputRow",
1030
1204
  children: [
1031
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1032
- className: "dsh-channels-cfg__labelRow",
1033
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1034
- className: "dsh-channels-cfg__label",
1035
- children: field.label
1036
- }), field.restartRequired ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1037
- className: "dsh-channels-cfg__tag dsh-channels-cfg__tag--restart",
1038
- children: PANEL_COPY.restartTag
1039
- }) : null]
1205
+ reading === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1206
+ className: "dsh-channels-cfg__bytes",
1207
+ children: reading
1040
1208
  }),
1041
- field.kind === "boolean" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1042
- className: "dsh-channels-cfg__check",
1043
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1044
- type: "checkbox",
1045
- className: "dsh-channels-cfg__checkbox",
1046
- checked: value === true,
1047
- "aria-label": field.label,
1048
- disabled,
1049
- onChange: (event) => onChange(field.key, event.target.checked)
1050
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: value === true ? PANEL_COPY.enabled : PANEL_COPY.disabled })]
1051
- }) : null,
1052
- field.kind === "select" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1053
- className: inputClass,
1054
- value: String(value ?? ""),
1055
- disabled: disabled || field.disabled === true,
1056
- "aria-label": field.label,
1057
- onChange: (event) => onChange(field.key, event.target.value),
1058
- children: (field.options ?? []).map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1059
- value: option.value,
1060
- children: option.label
1061
- }, option.value))
1062
- }) : null,
1063
- field.kind === "number" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1209
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1064
1210
  type: "number",
1065
- className: inputClass,
1066
- value: value === void 0 || value === "" ? "" : String(value),
1067
- min: field.min,
1068
- max: field.max,
1069
- step: field.step,
1070
- "aria-label": field.label,
1071
- disabled,
1072
- onChange: (event) => onChange(field.key, event.target.value)
1073
- }) : null,
1074
- field.kind === "text" ? field.browse === "directory" && onPickDirectory !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DirectoryTextInput, {
1075
- field,
1076
- value,
1077
- inputClass,
1078
- disabled,
1079
- onChange,
1080
- onPick: onPickDirectory
1081
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1082
- type: "text",
1083
- className: inputClass,
1084
- value: String(value ?? ""),
1211
+ className: `${inputClass} dsh-channels-cfg__input--num`,
1212
+ value: text,
1085
1213
  placeholder: field.placeholder,
1214
+ step: numberInputStep(field),
1086
1215
  "aria-label": field.label,
1087
1216
  disabled,
1088
1217
  onChange: (event) => onChange(field.key, event.target.value)
1089
- }) : null,
1090
- field.kind === "string-list" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StringListInput, {
1091
- field,
1092
- value,
1093
- issues: entryIssues,
1094
- disabled,
1095
- onChange
1096
- }) : null,
1097
- field.help === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1098
- className: "dsh-channels-cfg__help",
1099
- children: field.help
1100
1218
  }),
1101
- issue === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1102
- className: "dsh-channels-cfg__fieldError",
1103
- 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
1104
1222
  })
1105
1223
  ]
1106
1224
  });
1107
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
+ }
1108
1331
  /**
1109
- * One tab's editable fields, partitioned by the Host field groups that the tab
1110
- * owns (`TAB_FIELD_GROUPS`). Empty groups render nothing, so a Host that moves
1111
- * 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.
1112
1334
  */
1113
- function FieldsCard(props) {
1114
- const { form, groups } = props;
1115
- const sections = groups.map((group) => ({
1116
- group,
1117
- label: FIELD_GROUP_LABELS[group],
1118
- fields: form.fields.filter((field) => field.group === group)
1119
- })).filter((section) => section.fields.length > 0);
1120
- if (sections.length === 0) return null;
1121
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
1122
- className: "dsh-channels-cfg__card",
1123
- children: sections.map((section) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1124
- className: "dsh-channels-cfg__group",
1125
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1126
- className: "dsh-channels-cfg__labelRow",
1127
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1128
- className: "dsh-channels-cfg__cardTitle",
1129
- children: section.label
1130
- })
1131
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1132
- className: "dsh-channels-cfg__grid",
1133
- children: section.fields.map((field) => {
1134
- const onPick = form.onPickDirectory;
1135
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldRow, {
1136
- field,
1137
- value: form.draft[field.key] ?? effectiveValue(form.panel, field.key),
1138
- issues: form.issues,
1139
- disabled: form.locked,
1140
- onChange: form.onChange,
1141
- onPickDirectory: onPick === void 0 ? void 0 : () => onPick(field.key)
1142
- }, field.key);
1143
- })
1144
- })]
1145
- }, section.group))
1146
- });
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] });
1147
1355
  }
1148
- /** The unified per-tab action bar: save every dirty field or drop the draft. */
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
+ }) });
1380
+ }
1381
+ /**
1382
+ * Sticky bar shown only while the draft has unsaved keys. Saving submits the
1383
+ * whole draft (every tab); discarding drops it.
1384
+ */
1149
1385
  function SaveBar(props) {
1150
- const { form, tab } = props;
1151
- const groups = new Set(TAB_FIELD_GROUPS[tab]);
1152
- const pageDirty = form.fields.filter((field) => groups.has(field.group) && field.key in form.draft).length;
1153
- const dirty = form.dirtyCount > 0;
1154
- 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;
1155
1389
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1156
- className: "dsh-channels-cfg__footer",
1390
+ className: "dsh-channels-cfg__saveBar",
1391
+ role: "region",
1392
+ "aria-label": PANEL_COPY.save,
1157
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
+ }),
1158
1398
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1159
1399
  type: "button",
1160
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1161
- onClick: form.onSave,
1400
+ className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost",
1401
+ onClick: onDiscard,
1162
1402
  disabled,
1163
- children: form.busy === "save" ? PANEL_COPY.saving : PANEL_COPY.save
1403
+ children: PANEL_COPY.discard
1164
1404
  }),
1165
1405
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1166
1406
  type: "button",
1167
- className: "dsh-channels-cfg__btn",
1168
- onClick: () => form.onDiscardTab(tab),
1169
- disabled: pageDirty === 0 || form.busy !== null || form.locked,
1170
- children: PANEL_COPY.discardTab
1171
- }),
1172
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
1173
- pageDirty > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1174
- className: "dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty",
1175
- children: [
1176
- PANEL_COPY.dirtyCount,
1177
- " ",
1178
- pageDirty
1179
- ]
1180
- }) : dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1181
- className: "dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty",
1182
- children: [
1183
- PANEL_COPY.dirtyCount,
1184
- " ",
1185
- form.dirtyCount,
1186
- "(其他标签页)"
1187
- ]
1188
- }) : null,
1189
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1190
- className: "dsh-channels-cfg__help",
1191
- 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
1192
1411
  })
1193
1412
  ]
1194
1413
  });
1195
1414
  }
1196
- /** Muted help paragraph introducing one tab. */
1197
- function TabHelp(props) {
1198
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1199
- className: "dsh-channels-cfg__tabHelp",
1200
- children: props.text
1201
- });
1202
- }
1203
- /** Transient page-level banner (save / credentials / reconnect / catalog). */
1415
+ /** Transient banner (save / credentials / reconnect / catalog / access). */
1204
1416
  function NoticeBar(props) {
1205
- const { notice } = props;
1417
+ const { kind, text } = props;
1206
1418
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1207
- className: `dsh-channels-cfg__notice dsh-channels-cfg__notice--${notice.kind === "ok" ? "ok" : "error"}`,
1208
- role: "status",
1419
+ className: `dsh-channels-cfg__notice dsh-channels-cfg__notice--${kind}`,
1420
+ role: kind === "error" ? "alert" : "status",
1209
1421
  "aria-live": "polite",
1210
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: notice.text })
1422
+ children: text
1211
1423
  });
1212
1424
  }
1213
- /** Muted empty/blocked state (e.g. access lists before the channel connects). */
1214
- function EmptyState(props) {
1425
+ /** Muted row inside a list (empty or not-yet-loaded state). */
1426
+ function EmptyRow(props) {
1215
1427
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1216
- className: "dsh-channels-cfg__emptyState",
1428
+ className: "dsh-channels-cfg__emptyRow",
1217
1429
  children: props.text
1218
1430
  });
1219
1431
  }
1220
- /** Human label for the official SDK's WebSocket lifecycle state. */
1221
- function connectionStateLabel(state) {
1222
- switch (state) {
1223
- case "connected": return {
1224
- text: PANEL_COPY.phaseConnected,
1225
- tone: "ok"
1226
- };
1227
- case "connecting": return {
1228
- text: PANEL_COPY.phaseConnecting,
1229
- tone: "warn"
1230
- };
1231
- case "reconnecting": return {
1232
- text: PANEL_COPY.phaseConnecting,
1233
- tone: "warn"
1234
- };
1235
- case "failed": return {
1236
- text: PANEL_COPY.phaseError,
1237
- tone: "bad"
1238
- };
1239
- case "idle": return {
1240
- text: PANEL_COPY.phaseIdle,
1241
- tone: "muted"
1242
- };
1243
- default: return {
1244
- text: state ?? PANEL_COPY.unknownValue,
1245
- tone: "muted"
1246
- };
1247
- }
1248
- }
1249
1432
  //#endregion
1250
- //#region src/client/panel/access-card.tsx
1433
+ //#region src/client/panel/whitelist.tsx
1251
1434
  /**
1252
- * 访问与授权卡:fail-closed 警告、群/用户白名单增删、待处理申请处理。
1435
+ * 白名单列表:群白名单、用户白名单与知识库白名单共用的条目行、添加表单
1436
+ * (点「+ 添加」才展开)与待处理申请行。
1253
1437
  *
1254
- * `access.loaded === false`(飞书未连接、插件 storage 域未打开)时白名单与
1255
- * 申请列表是「未知」而不是「空」,因此只渲染连接提示并禁用全部写操作。
1256
- * 管理员与准入模式由同 tab 上方「准入与授权」字段表单编辑,这里不再重复。
1257
- *
1258
- * @module dsh-channels/client/panel/access-card
1438
+ * @module dsh-channels/client/panel/whitelist
1259
1439
  */
1260
- /** Local `YYYY-MM-DD HH:mm` rendering for authorization timestamps. */
1440
+ /** Local `MM-DD HH:mm` rendering for authorization timestamps. */
1261
1441
  function formatAccessDate(ms) {
1262
1442
  if (!Number.isFinite(ms) || ms <= 0) return PANEL_COPY.unknownValue;
1263
1443
  const date = new Date(ms);
1264
1444
  const pad = (value) => String(value).padStart(2, "0");
1265
- 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())}`;
1266
1446
  }
1267
1447
  /** Which authorization entry produced one grant. */
1268
- function accessSourceLabel(source) {
1448
+ function sourceLabel(source) {
1269
1449
  switch (source) {
1270
1450
  case "command": return PANEL_COPY.accessSourceCommand;
1271
1451
  case "card": return PANEL_COPY.accessSourceCard;
1272
1452
  default: return PANEL_COPY.accessSourcePanel;
1273
1453
  }
1274
1454
  }
1275
- function AccessCard(props) {
1276
- const { panel, busy, locked, notice, error, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props;
1277
- const { access } = panel;
1278
- const [groupChatId, setGroupChatId] = (0, react.useState)("");
1279
- const [groupName, setGroupName] = (0, react.useState)("");
1280
- const groups = access.groups;
1281
- const requests = access.requests;
1282
- const working = busy === "access";
1283
- const disabled = busy !== null || locked || !access.loaded;
1284
- const submitGroup = async () => {
1285
- if (groupChatId.trim() === "") return;
1286
- if (await onAddGroup(groupChatId, groupName)) {
1287
- setGroupChatId("");
1288
- setGroupName("");
1289
- }
1290
- };
1291
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1292
- className: "dsh-channels-cfg__card",
1293
- children: [
1294
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1295
- className: "dsh-channels-cfg__cardHead",
1296
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1297
- className: "dsh-channels-cfg__cardTitle",
1298
- children: PANEL_COPY.accessCard
1299
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1300
- className: "dsh-channels-cfg__cardMeta",
1301
- children: accessModeLabel(panel, access.mode)
1302
- })]
1303
- }),
1304
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1305
- className: "dsh-channels-cfg__help",
1306
- children: PANEL_COPY.accessCardHelp
1307
- }),
1308
- access.mode === "open" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1309
- className: "dsh-channels-cfg__banner",
1310
- children: PANEL_COPY.accessModeOpenWarning
1311
- }) : null,
1312
- access.loaded && access.mode === "guarded" && !access.admin.configured ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1313
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
1314
- 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 })]
1315
- }) : null,
1316
- access.mode === "guarded" && access.admin.source === "owner" && access.admin.userId !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1317
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--ok",
1318
- children: [PANEL_COPY.accessAdminOwnerNotice, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1319
- className: "dsh-channels-cfg__mono",
1320
- children: access.admin.userId
1321
- })]
1322
- }) : null,
1323
- !access.loaded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, { text: PANEL_COPY.accessNotLoaded }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1324
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1325
- className: "dsh-channels-cfg__accessSection",
1326
- children: [
1327
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1328
- className: "dsh-channels-cfg__labelRow",
1329
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1330
- className: "dsh-channels-cfg__label",
1331
- children: PANEL_COPY.accessGroupsTitle
1332
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1333
- className: "dsh-channels-cfg__cardMeta",
1334
- children: [groups.length, " 个"]
1335
- })]
1336
- }),
1337
- groups.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1338
- className: "dsh-channels-cfg__help",
1339
- children: PANEL_COPY.accessGroupsEmpty
1340
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1341
- className: "dsh-channels-cfg__accessList",
1342
- children: groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1343
- className: "dsh-channels-cfg__accessRow",
1344
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1345
- className: "dsh-channels-cfg__accessMain",
1346
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: group.name ?? group.chatId }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1347
- className: "dsh-channels-cfg__accessMeta",
1348
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1349
- className: "dsh-channels-cfg__mono",
1350
- children: group.chatId
1351
- }), ` · ${accessSourceLabel(group.source)} · ${formatAccessDate(group.addedAt)}`]
1352
- })]
1353
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1354
- type: "button",
1355
- className: "dsh-channels-cfg__btn",
1356
- disabled,
1357
- onClick: () => onRemoveGroup(group.chatId),
1358
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove
1359
- })]
1360
- }, group.chatId))
1361
- }),
1362
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1363
- className: "dsh-channels-cfg__accessAdd",
1364
- children: [
1365
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1366
- type: "text",
1367
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1368
- value: groupChatId,
1369
- placeholder: PANEL_COPY.accessGroupChatIdPlaceholder,
1370
- "aria-label": PANEL_COPY.accessGroupChatIdPlaceholder,
1371
- disabled,
1372
- onChange: (event) => setGroupChatId(event.target.value)
1373
- }),
1374
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1375
- type: "text",
1376
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1377
- value: groupName,
1378
- placeholder: PANEL_COPY.accessGroupNamePlaceholder,
1379
- "aria-label": PANEL_COPY.accessGroupNamePlaceholder,
1380
- disabled,
1381
- onChange: (event) => setGroupName(event.target.value)
1382
- }),
1383
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1384
- type: "button",
1385
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1386
- disabled: disabled || groupChatId.trim() === "",
1387
- onClick: () => {
1388
- submitGroup();
1389
- },
1390
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddGroup
1391
- })
1392
- ]
1393
- })
1394
- ]
1395
- }),
1396
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserWhitelist, {
1397
- title: PANEL_COPY.accessUsersTitle,
1398
- empty: PANEL_COPY.accessUsersEmpty,
1399
- addLabel: PANEL_COPY.accessAddUser,
1400
- users: access.users,
1401
- disabled,
1402
- working,
1403
- onAdd: onAddUser,
1404
- onRemove: onRemoveUser
1405
- }),
1406
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1407
- className: "dsh-channels-cfg__accessSection",
1408
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1409
- className: "dsh-channels-cfg__labelRow",
1410
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1411
- className: "dsh-channels-cfg__label",
1412
- children: PANEL_COPY.accessRequestsTitle
1413
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1414
- className: "dsh-channels-cfg__cardMeta",
1415
- children: [requests.length, " 条"]
1416
- })]
1417
- }), requests.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1418
- className: "dsh-channels-cfg__help",
1419
- children: PANEL_COPY.accessRequestsEmpty
1420
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1421
- className: "dsh-channels-cfg__accessList",
1422
- children: requests.map((request) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1423
- className: "dsh-channels-cfg__accessRow",
1424
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1425
- className: "dsh-channels-cfg__accessMain",
1426
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
1427
- request.kind === "group" ? PANEL_COPY.accessRequestGroup : PANEL_COPY.accessRequestUser,
1428
- ":",
1429
- request.name ?? request.targetId
1430
- ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1431
- className: "dsh-channels-cfg__accessMeta",
1432
- children: [
1433
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1434
- className: "dsh-channels-cfg__mono",
1435
- children: request.targetId
1436
- }),
1437
- ` · ${formatAccessDate(request.createdAt)} · ${PANEL_COPY.accessRequestExpires} ${formatAccessDate(request.expiresAt)}`,
1438
- request.kind === "user" && request.requester !== void 0 ? ` · ${PANEL_COPY.accessRequestRequester} ${request.requester}` : "",
1439
- request.kind === "user" && request.groupName !== void 0 ? ` · ${request.groupName}` : request.kind === "user" && request.chatId !== void 0 ? ` · ${request.chatId}` : ""
1440
- ]
1441
- })]
1442
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1443
- className: "dsh-channels-cfg__accessActions",
1444
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1445
- type: "button",
1446
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1447
- disabled,
1448
- onClick: () => onResolveRequest(request.token, "allow"),
1449
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAllow
1450
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1451
- type: "button",
1452
- className: "dsh-channels-cfg__btn",
1453
- disabled,
1454
- onClick: () => onResolveRequest(request.token, "deny"),
1455
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessDeny
1456
- })]
1457
- })]
1458
- }, request.token))
1459
- })]
1460
- })
1461
- ] }),
1462
- notice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1463
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--ok",
1464
- role: "status",
1465
- "aria-live": "polite",
1466
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: notice })
1467
- }),
1468
- error === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1469
- className: "dsh-channels-cfg__fieldError",
1470
- role: "alert",
1471
- children: error
1472
- }),
1473
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1474
- className: "dsh-channels-cfg__help",
1475
- children: PANEL_COPY.accessHints
1476
- }),
1477
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1478
- className: "dsh-channels-cfg__help",
1479
- children: PANEL_COPY.accessChatIdHint
1480
- })
1481
- ]
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] ?? "?"
1482
1461
  });
1483
1462
  }
1484
- /** One user whitelist (user `user_id` list with add/remove), shared by the access and wiki tabs. */
1485
- function UserWhitelist(props) {
1486
- const { title, empty, addLabel, users, disabled, working, onAdd, onRemove } = props;
1487
- const [userUserId, setUserUserId] = (0, react.useState)("");
1488
- const [userName, setUserName] = (0, react.useState)("");
1489
- const submitUser = async () => {
1490
- if (userUserId.trim() === "") return;
1491
- if (await onAdd(userUserId, userName)) {
1492
- setUserUserId("");
1493
- setUserName("");
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);
1494
1478
  }
1495
1479
  };
1496
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1497
- className: "dsh-channels-cfg__accessSection",
1480
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Section, {
1481
+ title: loaded ? `${title} · ${countLabel ?? entries.length}` : title,
1482
+ footnote,
1498
1483
  children: [
1499
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1500
- className: "dsh-channels-cfg__labelRow",
1501
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1502
- className: "dsh-channels-cfg__label",
1503
- children: title
1504
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1505
- className: "dsh-channels-cfg__cardMeta",
1506
- children: [users.length, " 个"]
1507
- })]
1508
- }),
1509
- users.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1510
- className: "dsh-channels-cfg__help",
1511
- children: empty
1512
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1513
- className: "dsh-channels-cfg__accessList",
1514
- children: users.map((user) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1515
- className: "dsh-channels-cfg__accessRow",
1516
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1517
- className: "dsh-channels-cfg__accessMain",
1518
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: user.name ?? user.userId }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1519
- className: "dsh-channels-cfg__accessMeta",
1520
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1521
- className: "dsh-channels-cfg__mono",
1522
- children: user.userId
1523
- }), ` · ${accessSourceLabel(user.source)} · ${formatAccessDate(user.addedAt)}`]
1524
- })]
1525
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1526
- type: "button",
1527
- className: "dsh-channels-cfg__btn",
1528
- disabled,
1529
- onClick: () => onRemove(user.userId),
1530
- children: working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove
1531
- })]
1532
- }, user.userId))
1533
- }),
1534
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1535
- className: "dsh-channels-cfg__accessAdd",
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",
1536
1507
  children: [
1537
1508
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1538
1509
  type: "text",
1539
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1540
- value: userUserId,
1541
- placeholder: PANEL_COPY.accessUserIdPlaceholder,
1542
- "aria-label": PANEL_COPY.accessUserIdPlaceholder,
1510
+ className: "dsh-channels-cfg__input",
1511
+ value: id,
1512
+ placeholder: idPlaceholder,
1513
+ "aria-label": idPlaceholder,
1543
1514
  disabled,
1544
- onChange: (event) => setUserUserId(event.target.value)
1515
+ onChange: (event) => setId(event.target.value)
1545
1516
  }),
1546
1517
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1547
1518
  type: "text",
1548
- className: "dsh-channels-cfg__input dsh-channels-cfg__accessInput",
1549
- value: userName,
1550
- placeholder: PANEL_COPY.accessUserNamePlaceholder,
1551
- "aria-label": PANEL_COPY.accessUserNamePlaceholder,
1519
+ className: "dsh-channels-cfg__input",
1520
+ value: name,
1521
+ placeholder: namePlaceholder,
1522
+ "aria-label": namePlaceholder,
1552
1523
  disabled,
1553
- onChange: (event) => setUserName(event.target.value)
1524
+ onChange: (event) => setName(event.target.value)
1554
1525
  }),
1555
1526
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1556
1527
  type: "button",
1557
1528
  className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1558
- disabled: disabled || userUserId.trim() === "",
1529
+ disabled: disabled || id.trim() === "",
1559
1530
  onClick: () => {
1560
- submitUser();
1531
+ submit();
1561
1532
  },
1562
- children: working ? PANEL_COPY.accessWorking : addLabel
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
1563
1540
  })
1564
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]
1550
+ })
1565
1551
  })
1566
1552
  ]
1567
1553
  });
1568
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
+ }
1569
1591
  //#endregion
1570
1592
  //#region src/client/panel/access-tab.tsx
1571
1593
  /**
1572
- * 「访问授权」tab:准入与授权字段表单(管理员 user_id / 准入模式 /
1573
- * 申请有效期)+ 白名单与待处理申请卡。表单保存走全局 draft,白名单
1574
- * 增删是独立即时 mutation。
1594
+ * 「访问授权」tab:待处理申请(有才显示,置顶)、准入字段(调试模式 /
1595
+ * 管理员 / 申请有效期)与群、用户白名单。字段保存走全局 draft,白名单增删
1596
+ * 与申请处理是独立即时 mutation。
1597
+ *
1598
+ * `access.loaded === false`(飞书未连接、插件 storage 域未打开)时白名单与
1599
+ * 申请列表是「未知」而不是「空」,因此只渲染连接提示并禁用全部写操作。
1575
1600
  *
1576
1601
  * @module dsh-channels/client/panel/access-tab
1577
1602
  */
1578
1603
  function AccessTab(props) {
1579
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);
1580
1613
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1581
1614
  className: "dsh-channels-cfg__tabBody",
1582
1615
  children: [
1583
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.accessTabHelp }),
1584
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
1585
- 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
+ },
1586
1639
  groups: TAB_FIELD_GROUPS.access
1587
1640
  }),
1588
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
1589
- form,
1590
- 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
1591
1658
  }),
1592
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessCard, {
1593
- panel: form.panel,
1594
- busy: form.busy,
1595
- locked: form.locked,
1596
- notice: accessNotice,
1597
- error: accessError,
1598
- onAddGroup,
1599
- onRemoveGroup,
1600
- onAddUser,
1601
- onRemoveUser,
1602
- 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
1603
1675
  })
1604
1676
  ]
1605
1677
  });
1606
1678
  }
1607
1679
  //#endregion
1608
- //#region src/client/panel/advanced-tab.tsx
1680
+ //#region src/client/panel/credentials-card.tsx
1609
1681
  /**
1610
- * 「高级」tab:流式刷新等低层字段 + 只读部署信息(overlayPath / version /
1611
- * 部署级参数)+ 「清除 Web 覆盖」。清除覆盖是整体动作,不走 draft。
1682
+ * 凭据行:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示「已配置 /
1683
+ * 未配置」,两个方向都不回显已存值。已配置时只显示「更换 / 移除」,点「更换」
1684
+ * 才出现输入框。保存动作由页面外壳持有(凭据保存独立于配置 draft)。
1612
1685
  *
1613
- * @module dsh-channels/client/panel/advanced-tab
1686
+ * @module dsh-channels/client/panel/credentials-card
1614
1687
  */
1615
- function AdvancedTab(props) {
1616
- const { form, onClear } = props;
1617
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1618
- className: "dsh-channels-cfg__tabBody",
1619
- children: [
1620
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.advancedTabHelp }),
1621
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
1622
- form,
1623
- groups: TAB_FIELD_GROUPS.advanced
1624
- }),
1625
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
1626
- form,
1627
- tab: "advanced"
1628
- }),
1629
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdvancedCard, {
1630
- panel: form.panel,
1631
- busy: form.busy,
1632
- locked: form.locked,
1633
- onClear
1634
- })
1635
- ]
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
+ })
1636
1757
  });
1637
1758
  }
1638
- /** Read-only deployment facts; the only write action here drops the overlay file. */
1639
- function AdvancedCard(props) {
1640
- const { panel, busy, locked, onClear } = props;
1641
- const { readOnly } = panel.config;
1642
- const overridden = panel.config.overriddenKeys.length;
1643
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1644
- className: "dsh-channels-cfg__card",
1645
- children: [
1646
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1647
- className: "dsh-channels-cfg__cardHead",
1648
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1649
- className: "dsh-channels-cfg__cardTitle",
1650
- children: PANEL_COPY.advancedCard
1651
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1652
- className: "dsh-channels-cfg__cardMeta",
1653
- children: overridden === 0 ? PANEL_COPY.inherited : `${PANEL_COPY.overridden} ${overridden} 项`
1654
- })]
1655
- }),
1656
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1657
- className: "dsh-channels-cfg__help",
1658
- children: PANEL_COPY.advancedHelp
1659
- }),
1660
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1661
- className: "dsh-channels-cfg__labelRow",
1662
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1663
- className: "dsh-channels-cfg__cardTitle",
1664
- children: PANEL_COPY.readOnlyCard
1665
- })
1666
- }),
1667
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1668
- className: "dsh-channels-cfg__readOnly",
1669
- children: [
1670
- readOnly.map((field) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1671
- className: "dsh-channels-cfg__kv",
1672
- children: [
1673
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1674
- className: "dsh-channels-cfg__kvKey",
1675
- children: field.label
1676
- }),
1677
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1678
- className: "dsh-channels-cfg__kvValue",
1679
- children: field.value
1680
- }),
1681
- field.help === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1682
- className: "dsh-channels-cfg__help",
1683
- children: field.help
1684
- })
1685
- ]
1686
- }, field.key)),
1687
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1688
- className: "dsh-channels-cfg__kv",
1689
- children: [
1690
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1691
- className: "dsh-channels-cfg__kvKey",
1692
- children: PANEL_COPY.overlayPath
1693
- }),
1694
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1695
- className: "dsh-channels-cfg__kvValue dsh-channels-cfg__mono",
1696
- children: panel.status.overlayPath
1697
- }),
1698
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1699
- className: "dsh-channels-cfg__help",
1700
- children: PANEL_COPY.overlayPathHelp
1701
- })
1702
- ]
1703
- }),
1704
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1705
- className: "dsh-channels-cfg__kv",
1706
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1707
- className: "dsh-channels-cfg__kvKey",
1708
- children: PANEL_COPY.version
1709
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1710
- className: "dsh-channels-cfg__kvValue dsh-channels-cfg__mono",
1711
- children: panel.status.version
1712
- })]
1713
- })
1714
- ]
1715
- }),
1716
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1717
- className: "dsh-channels-cfg__actions",
1718
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1719
- type: "button",
1720
- className: "dsh-channels-cfg__btn",
1721
- onClick: onClear,
1722
- disabled: overridden === 0 || busy !== null || locked,
1723
- children: busy === "clear" ? PANEL_COPY.clearing : PANEL_COPY.clear
1724
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1725
- className: "dsh-channels-cfg__help",
1726
- children: overridden === 0 ? PANEL_COPY.noOverrides : `${PANEL_COPY.overridden} ${overridden} 项`
1727
- })]
1728
- })
1729
- ]
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
+ })]
1730
1793
  });
1731
1794
  }
1732
1795
  //#endregion
1733
- //#region src/client/panel/credentials-card.tsx
1796
+ //#region src/client/panel/advanced-tab.tsx
1734
1797
  /**
1735
- * 飞书应用凭据编辑器:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示
1736
- * 「已配置 / 未配置」,两个方向都不回显已存值。保存动作由页面外壳持有
1737
- * (凭据保存独立于配置 draft)。
1798
+ * 「高级」tab:日志分析接口(监听地址字段 + 访问令牌凭据行)、SDK 日志级别
1799
+ * 与 Web 覆盖文件(「恢复 profile 配置」删除整个覆盖层,不走 draft)。
1738
1800
  *
1739
- * @module dsh-channels/client/panel/credentials-card
1801
+ * @module dsh-channels/client/panel/advanced-tab
1740
1802
  */
1741
- function CredentialsCard(props) {
1742
- const { panel, credentials } = props;
1743
- const { appId, appSecret, issues, busy, locked, onAppIdChange, onAppSecretChange, onSave, onClear } = credentials;
1744
- const appIdState = panel.config.credentials.appId;
1745
- const appSecretState = panel.config.credentials.appSecret;
1746
- const configured = appIdState.configured && appSecretState.configured;
1747
- const writable = appIdState.writable || appSecretState.writable;
1748
- const disabled = busy !== null || locked || !writable;
1749
- const pending = appId.trim() !== "" || appSecret.trim() !== "";
1750
- const row = (field, label, state, value, type, placeholder, onChange) => {
1751
- const issue = issues.find((item) => item.key === field);
1752
- const inputClass = `dsh-channels-cfg__input${issue === void 0 ? "" : " dsh-channels-cfg__input--invalid"}`;
1753
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1754
- className: "dsh-channels-cfg__field",
1755
- children: [
1756
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1757
- className: "dsh-channels-cfg__labelRow",
1758
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1759
- className: "dsh-channels-cfg__label",
1760
- children: label
1761
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1762
- className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${state.configured ? "ok" : "bad"}`,
1763
- children: state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing
1764
- })]
1765
- }),
1766
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1767
- className: "dsh-channels-cfg__cred",
1768
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1769
- type,
1770
- className: inputClass,
1771
- value,
1772
- placeholder,
1773
- autoComplete: "new-password",
1774
- "aria-label": label,
1775
- disabled,
1776
- onChange: (event) => onChange(event.target.value)
1777
- }), state.configured ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1778
- type: "button",
1779
- className: "dsh-channels-cfg__btn",
1780
- disabled,
1781
- onClick: () => onClear(field),
1782
- children: busy === "credentials-clear" ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential
1783
- }) : null]
1784
- }),
1785
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1786
- className: "dsh-channels-cfg__help",
1787
- children: [
1788
- PANEL_COPY.credentialsRef,
1789
- ":",
1790
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1791
- className: "dsh-channels-cfg__mono",
1792
- children: state.ref
1793
- }),
1794
- state.source === void 0 ? "" : ` · ${state.source}`
1795
- ]
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
1796
1834
  }),
1797
- issue === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1798
- className: "dsh-channels-cfg__fieldError",
1799
- children: issue.message
1800
- })
1801
- ]
1802
- }, field);
1803
- };
1804
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1805
- className: "dsh-channels-cfg__card",
1806
- children: [
1807
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1808
- className: "dsh-channels-cfg__cardHead",
1809
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1810
- className: "dsh-channels-cfg__cardTitle",
1811
- children: PANEL_COPY.credentialsCard
1812
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1813
- className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${configured ? "ok" : "muted"}`,
1814
- children: configured ? PANEL_COPY.configuredBadge : PANEL_COPY.unconfiguredBadge
1815
- })]
1816
- }),
1817
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1818
- className: "dsh-channels-cfg__help",
1819
- children: PANEL_COPY.credentialsCardHelp
1820
- }),
1821
- row("appId", PANEL_COPY.appIdLabel, appIdState, appId, "text", PANEL_COPY.appIdPlaceholder, onAppIdChange),
1822
- row("appSecret", PANEL_COPY.appSecretLabel, appSecretState, appSecret, "password", PANEL_COPY.appSecretPlaceholder, onAppSecretChange),
1823
- writable ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1824
- className: "dsh-channels-cfg__fieldError",
1825
- children: PANEL_COPY.credentialReadOnly
1826
- }),
1827
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1828
- className: "dsh-channels-cfg__footer",
1829
- children: [
1830
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1831
- type: "button",
1832
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
1833
- onClick: onSave,
1834
- disabled: disabled || !pending,
1835
- children: busy === "credentials" ? PANEL_COPY.savingCredentials : PANEL_COPY.saveCredentials
1836
- }),
1837
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
1838
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1839
- className: "dsh-channels-cfg__help",
1840
- children: PANEL_COPY.credentialsPendingHint
1841
- })
1842
- ]
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
+ })
1843
1847
  })
1844
- ]
1848
+ })]
1845
1849
  });
1846
1850
  }
1847
1851
  //#endregion
1848
1852
  //#region src/client/panel/connection-tab.tsx
1849
1853
  /**
1850
- * 「连接」tab:按 Host 派生的生命周期阶段渲染连接引导。
1854
+ * 「连接」tab:按 Host 派生的生命周期阶段渲染。
1851
1855
  *
1852
- * - unconfigured:onboarding 三步 + 凭据卡(不渲染可用的「连接」按钮)
1853
- * - idle:连接信息卡 + 「连接飞书」
1854
- * - error:连接信息卡 + 最近一次连接失败 + 「重试」
1855
- * - connected:连接信息卡 + 「重启飞书连接」+ 凭据卡
1856
- * - connecting:禁用按钮显示「连接中…」
1857
- * - 已连接且权限自检完成:权限自检卡(缺失权限红字 + 开发者后台深链)
1856
+ * - unconfigured:上手三步 + 应用凭据
1857
+ * - idle / error / connecting / connected:状态块(当前阶段的唯一操作 + 运行数字)
1858
+ * - 连接后:自检(权限缺口可展开、斜杠指令面板同步结果)
1859
+ * - 有需重连才生效的已保存配置:顶部「重启连接」横幅
1858
1860
  *
1859
1861
  * @module dsh-channels/client/panel/connection-tab
1860
1862
  */
1861
1863
  function ConnectionTab(props) {
1862
1864
  const { panel, status, busy, credentials, onReconnect } = props;
1865
+ const checks = status.scopeCheck !== void 0 || status.slashCommandSync !== void 0;
1863
1866
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1864
1867
  className: "dsh-channels-cfg__tabBody",
1865
1868
  children: [
1866
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.connectionTabHelp }),
1867
1869
  panel.config.restartPendingKeys.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RestartBanner, {
1868
1870
  panel,
1869
1871
  busy,
1870
1872
  locked: status.busy,
1871
1873
  onReconnect
1872
1874
  }),
1873
- 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, {
1874
1876
  status,
1875
1877
  busy,
1876
1878
  onReconnect
1877
1879
  }),
1878
- status.scopeCheck === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScopeCheckCard, { check: status.scopeCheck }),
1879
- status.slashCommandSync === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SlashCommandSyncCard, { sync: status.slashCommandSync }),
1880
- /* @__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, {
1881
1885
  panel,
1882
1886
  credentials
1883
1887
  })
@@ -1885,7 +1889,7 @@ window.__ModuleLoader__.load({
1885
1889
  });
1886
1890
  }
1887
1891
  /** First-run guide: create the app, fill the credentials, save and wait. */
1888
- function OnboardingCard(props) {
1892
+ function OnboardingSection(props) {
1889
1893
  const { status } = props;
1890
1894
  const steps = [
1891
1895
  {
@@ -1901,22 +1905,12 @@ window.__ModuleLoader__.load({
1901
1905
  text: PANEL_COPY.onboardingStep3Text
1902
1906
  }
1903
1907
  ];
1904
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1905
- className: "dsh-channels-cfg__card",
1908
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1909
+ className: "dsh-channels-cfg__hero",
1906
1910
  children: [
1907
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1908
- className: "dsh-channels-cfg__cardHead",
1909
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1910
- className: "dsh-channels-cfg__cardTitle",
1911
- children: PANEL_COPY.onboardingTitle
1912
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1913
- className: "dsh-channels-cfg__badge dsh-channels-cfg__badge--muted",
1914
- children: PANEL_COPY.phaseUnconfigured
1915
- })]
1916
- }),
1917
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1918
- className: "dsh-channels-cfg__help",
1919
- 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 })
1920
1914
  }),
1921
1915
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ol", {
1922
1916
  className: "dsh-channels-cfg__steps",
@@ -1931,50 +1925,29 @@ window.__ModuleLoader__.load({
1931
1925
  className: "dsh-channels-cfg__stepTitle",
1932
1926
  children: step.title
1933
1927
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1934
- className: "dsh-channels-cfg__help",
1928
+ className: "dsh-channels-cfg__rowSummary",
1935
1929
  children: step.text
1936
1930
  })]
1937
1931
  })]
1938
1932
  }, step.title))
1939
1933
  }),
1940
- status.missingConfig.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1941
- className: "dsh-channels-cfg__banner",
1942
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("strong", { children: [PANEL_COPY.missingConfig, ":"] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: status.missingConfig.join("、") })]
1943
- }),
1944
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1945
- className: "dsh-channels-cfg__help",
1946
- children: PANEL_COPY.statusHintUnconfigured
1947
- }),
1948
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1949
- className: "dsh-channels-cfg__help",
1950
- 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
+ ]
1951
1941
  })
1952
1942
  ]
1953
1943
  });
1954
1944
  }
1955
- /** Runtime facts plus the one lifecycle action available in the current phase. */
1956
- function ConnectionInfoCard(props) {
1945
+ /** Phase, the one lifecycle action available in it, and the runtime counters. */
1946
+ function StatusBlock(props) {
1957
1947
  const { status, busy, onReconnect } = props;
1958
1948
  const badge = phaseBadge(status.phase);
1959
1949
  const actionBusy = busy !== null || status.busy;
1960
- const connection = connectionStateLabel(status.connection?.state);
1961
- const facts = [
1962
- {
1963
- label: PANEL_COPY.statusCard,
1964
- value: badge.text
1965
- },
1966
- {
1967
- label: PANEL_COPY.connectionStateLabel,
1968
- value: connection.text
1969
- },
1970
- {
1971
- label: PANEL_COPY.botOpenId,
1972
- value: status.botOpenIdTail ?? PANEL_COPY.unknownValue
1973
- },
1974
- {
1975
- label: PANEL_COPY.uptime,
1976
- value: formatUptime(status.uptimeMs)
1977
- },
1950
+ const stats = [
1978
1951
  {
1979
1952
  label: PANEL_COPY.routeCount,
1980
1953
  value: String(status.routeCount)
@@ -1986,188 +1959,167 @@ window.__ModuleLoader__.load({
1986
1959
  {
1987
1960
  label: PANEL_COPY.inflightTurnCount,
1988
1961
  value: String(status.inflightTurnCount)
1962
+ },
1963
+ {
1964
+ label: PANEL_COPY.botOpenId,
1965
+ value: status.botOpenIdTail ?? PANEL_COPY.unknownValue,
1966
+ mono: true
1989
1967
  }
1990
1968
  ];
1991
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1992
- 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",
1993
1992
  children: [
1994
1993
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1995
- className: "dsh-channels-cfg__cardHead",
1996
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1997
- className: "dsh-channels-cfg__cardTitle",
1998
- children: PANEL_COPY.connectionInfo
1999
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2000
- className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${status.configured ? "ok" : "bad"}`,
2001
- children: status.configured ? PANEL_COPY.configuredBadge : PANEL_COPY.unconfiguredBadge
2002
- })]
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
+ ]
2003
2005
  }),
2004
2006
  status.lastStartError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2005
2007
  className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
2006
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.lastError }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2007
- className: "dsh-channels-cfg__mono",
2008
- children: status.lastStartError
2009
- })]
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
+ ]
2010
2016
  }),
2011
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2012
- className: "dsh-channels-cfg__facts",
2013
- children: facts.map((fact) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2014
- className: "dsh-channels-cfg__fact",
2015
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2016
- className: "dsh-channels-cfg__factLabel",
2017
- children: fact.label
2018
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2019
- className: "dsh-channels-cfg__factValue",
2020
- 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
2021
2024
  })]
2022
- }, fact.label))
2023
- }),
2025
+ }, stat.label))
2026
+ }) : null,
2024
2027
  status.reasons.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2025
- 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" : ""}`,
2026
2029
  children: [
2027
2030
  PANEL_COPY.healthReasons,
2028
2031
  ":",
2029
2032
  status.reasons.join(";")
2030
2033
  ]
2031
- }),
2032
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2033
- className: "dsh-channels-cfg__actions",
2034
- children: [
2035
- status.phase === "connecting" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2036
- type: "button",
2037
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
2038
- disabled: true,
2039
- children: PANEL_COPY.connecting
2040
- }) : null,
2041
- status.phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2042
- type: "button",
2043
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
2044
- onClick: onReconnect,
2045
- disabled: actionBusy,
2046
- children: busy === "reconnect" ? PANEL_COPY.connecting : PANEL_COPY.connect
2047
- }) : null,
2048
- status.phase === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2049
- type: "button",
2050
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
2051
- onClick: onReconnect,
2052
- disabled: actionBusy,
2053
- children: busy === "reconnect" ? PANEL_COPY.connecting : PANEL_COPY.retry
2054
- }) : null,
2055
- status.phase === "connected" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2056
- type: "button",
2057
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--danger",
2058
- onClick: onReconnect,
2059
- disabled: actionBusy,
2060
- children: busy === "reconnect" ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect
2061
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2062
- className: "dsh-channels-cfg__help",
2063
- children: PANEL_COPY.restartWarning
2064
- })] }) : null
2065
- ]
2066
2034
  })
2067
2035
  ]
2068
2036
  });
2069
2037
  }
2070
- /** Post-connect scope self-check: missing scopes in red, each with a console deep link. */
2071
- 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) {
2072
2048
  const { check } = props;
2073
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2074
- className: "dsh-channels-cfg__card",
2075
- children: [
2076
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2077
- className: "dsh-channels-cfg__cardHead",
2078
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2079
- className: "dsh-channels-cfg__cardTitle",
2080
- children: PANEL_COPY.scopeCheckTitle
2081
- })
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(" / ")
2082
2089
  }),
2083
- check.state === "ok" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2084
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--ok",
2085
- children: PANEL_COPY.scopeCheckOk
2086
- }) : null,
2087
- check.state === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2088
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
2089
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.scopeCheckError }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2090
- className: "dsh-channels-cfg__mono",
2091
- children: check.error
2092
- })]
2090
+ item.level === "required" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pill, {
2091
+ tone: "bad",
2092
+ children: PANEL_COPY.scopeCheckRequired
2093
2093
  }) : null,
2094
- check.state === "missing" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2095
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
2096
- children: [
2097
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: PANEL_COPY.scopeCheckMissing }),
2098
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2099
- className: "dsh-channels-cfg__scopeList",
2100
- children: check.missing.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
2101
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
2102
- "[",
2103
- item.level === "required" ? PANEL_COPY.scopeCheckRequired : PANEL_COPY.scopeCheckOptional,
2104
- "] "
2105
- ] }),
2106
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2107
- className: "dsh-channels-cfg__mono",
2108
- children: item.scopes.join(" / ")
2109
- }),
2110
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
2111
- ":",
2112
- item.purpose,
2113
- " "
2114
- ] }),
2115
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2116
- href: item.consoleUrl,
2117
- target: "_blank",
2118
- rel: "noreferrer noopener",
2119
- children: PANEL_COPY.scopeCheckOpenOne
2120
- })
2121
- ] }, item.scopes.join(",")))
2122
- }),
2123
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2124
- href: check.consoleUrl,
2125
- target: "_blank",
2126
- rel: "noreferrer noopener",
2127
- children: PANEL_COPY.scopeCheckOpenAll
2128
- })
2129
- ]
2130
- }) : null
2131
- ]
2132
- });
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] });
2133
2104
  }
2134
- /** Post-connect native slash-command panel sync: counts on success, the Feishu reason on failure. */
2135
- function SlashCommandSyncCard(props) {
2105
+ /** Post-connect native slash-command panel sync: the command count, or the Feishu reason. */
2106
+ function SlashCommandSyncRow(props) {
2136
2107
  const { sync } = props;
2137
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2138
- className: "dsh-channels-cfg__card",
2139
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2140
- className: "dsh-channels-cfg__cardHead",
2141
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2142
- className: "dsh-channels-cfg__cardTitle",
2143
- children: PANEL_COPY.slashCommandSyncTitle
2144
- })
2145
- }), sync.state === "ok" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2146
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--ok",
2147
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [PANEL_COPY.slashCommandSyncOk, " "] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
2148
- PANEL_COPY.slashCommandSyncTotal,
2149
- " ",
2150
- sync.total,
2151
- " · ",
2152
- PANEL_COPY.slashCommandSyncCreated,
2153
- " ",
2154
- sync.created,
2155
- " · ",
2156
- PANEL_COPY.slashCommandSyncUpdated,
2157
- " ",
2158
- sync.updated,
2159
- " · ",
2160
- PANEL_COPY.slashCommandSyncRemoved,
2161
- " ",
2162
- sync.removed
2163
- ] })]
2164
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2165
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--error",
2166
- 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", {
2167
2119
  className: "dsh-channels-cfg__mono",
2168
2120
  children: sync.error
2169
- })]
2170
- })]
2121
+ })
2122
+ ] })
2171
2123
  });
2172
2124
  }
2173
2125
  /** Config saved but only applied on the next connect: offer the one-click restart. */
@@ -2176,179 +2128,42 @@ window.__ModuleLoader__.load({
2176
2128
  const keys = panel.config.restartPendingKeys.map((key) => fieldLabel(panel.config.fields, key)).join("、");
2177
2129
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2178
2130
  className: "dsh-channels-cfg__banner",
2179
- children: [
2180
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [PANEL_COPY.restartBanner, keys] }),
2181
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2182
- type: "button",
2183
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--danger",
2184
- onClick: onReconnect,
2185
- disabled: busy !== null || locked,
2186
- children: busy === "reconnect" ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect
2187
- }),
2188
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2189
- className: "dsh-channels-cfg__help",
2190
- children: PANEL_COPY.restartWarning
2191
- })
2192
- ]
2193
- });
2194
- }
2195
- //#endregion
2196
- //#region src/client/panel/model-tab.tsx
2197
- /**
2198
- * 「模型」tab:模型服务商 / 模型 id / 推理档位 / Agent 预设四个选择器。
2199
- * 选项来自 Host 实时目录(页面外壳负责 provider→model→effort 级联刷新),
2200
- * 目录读取中时在表单上方提示。
2201
- *
2202
- * @module dsh-channels/client/panel/model-tab
2203
- */
2204
- function ModelTab(props) {
2205
- const { form, catalogBusy } = props;
2206
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2207
- className: "dsh-channels-cfg__tabBody",
2208
- children: [
2209
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.modelTabHelp }),
2210
- catalogBusy ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2211
- className: "dsh-channels-cfg__help",
2212
- children: PANEL_COPY.catalogLoading
2213
- }) : null,
2214
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
2215
- form,
2216
- groups: TAB_FIELD_GROUPS.model
2217
- }),
2218
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
2219
- form,
2220
- tab: "model"
2221
- })
2222
- ]
2223
- });
2224
- }
2225
- //#endregion
2226
- //#region src/client/panel/analyze-token-card.tsx
2227
- /**
2228
- * 日志分析接口令牌编辑器:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示
2229
- * 「已配置 / 未配置」,不回显已存值;保存或移除后下一次接口请求即生效,不触发重连。
2230
- *
2231
- * @module dsh-channels/client/panel/analyze-token-card
2232
- */
2233
- function AnalyzeTokenCard(props) {
2234
- const { panel, token } = props;
2235
- const state = panel.config.credentials.analyzeToken;
2236
- const disabled = token.busy !== null || token.locked || !state.writable;
2237
- const issue = token.issues.find((item) => item.key === "analyzeToken");
2238
- const inputClass = `dsh-channels-cfg__input${issue === void 0 ? "" : " dsh-channels-cfg__input--invalid"}`;
2239
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2240
- className: "dsh-channels-cfg__card",
2241
- children: [
2242
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2243
- className: "dsh-channels-cfg__cardHead",
2244
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2245
- className: "dsh-channels-cfg__cardTitle",
2246
- children: PANEL_COPY.analyzeTokenCard
2247
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2248
- className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${state.configured ? "ok" : "muted"}`,
2249
- children: state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing
2250
- })]
2251
- }),
2252
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2253
- className: "dsh-channels-cfg__help",
2254
- children: PANEL_COPY.analyzeTokenCardHelp
2255
- }),
2256
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2257
- className: "dsh-channels-cfg__field",
2258
- children: [
2259
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2260
- className: "dsh-channels-cfg__label",
2261
- children: PANEL_COPY.analyzeTokenLabel
2262
- }),
2263
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2264
- className: "dsh-channels-cfg__cred",
2265
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2266
- type: "password",
2267
- className: inputClass,
2268
- value: token.value,
2269
- placeholder: PANEL_COPY.analyzeTokenPlaceholder,
2270
- autoComplete: "new-password",
2271
- "aria-label": PANEL_COPY.analyzeTokenLabel,
2272
- disabled,
2273
- onChange: (event) => token.onChange(event.target.value)
2274
- }), state.configured ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2275
- type: "button",
2276
- className: "dsh-channels-cfg__btn",
2277
- disabled,
2278
- onClick: token.onClear,
2279
- children: token.busy === "credentials-clear" ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential
2280
- }) : null]
2281
- }),
2282
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2283
- className: "dsh-channels-cfg__help",
2284
- children: [
2285
- PANEL_COPY.credentialsRef,
2286
- ":",
2287
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2288
- className: "dsh-channels-cfg__mono",
2289
- children: state.ref
2290
- }),
2291
- state.source === void 0 ? "" : ` · ${state.source}`
2292
- ]
2293
- }),
2294
- issue === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2295
- className: "dsh-channels-cfg__fieldError",
2296
- children: issue.message
2297
- })
2298
- ]
2299
- }),
2300
- state.writable ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2301
- className: "dsh-channels-cfg__fieldError",
2302
- children: PANEL_COPY.credentialReadOnly
2303
- }),
2304
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2305
- className: "dsh-channels-cfg__footer",
2306
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2307
- type: "button",
2308
- className: "dsh-channels-cfg__btn dsh-channels-cfg__btn--primary",
2309
- onClick: token.onSave,
2310
- disabled: disabled || token.value.trim() === "",
2311
- children: token.busy === "credentials" ? PANEL_COPY.savingCredentials : PANEL_COPY.saveAnalyzeToken
2312
- })
2313
- })
2314
- ]
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
+ })]
2315
2141
  });
2316
2142
  }
2317
2143
  //#endregion
2318
- //#region src/client/panel/sessions-tab.tsx
2319
- /**
2320
- * 「会话与行为」tab:行为 + 内容与附件两组字段(私聊/群聊触发方式、
2321
- * 首次引导、权限切换、附件接收、日志分析接口)。表单字段完全由 Host 描述驱动;
2322
- * 日志分析接口令牌是凭据,单独一张卡片。
2323
- *
2324
- * @module dsh-channels/client/panel/sessions-tab
2325
- */
2326
- function SessionsTab(props) {
2327
- const { form, analyzeToken } = 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;
2328
2149
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2329
2150
  className: "dsh-channels-cfg__tabBody",
2330
- children: [
2331
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.sessionsTabHelp }),
2332
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
2333
- form,
2334
- groups: TAB_FIELD_GROUPS.sessions
2335
- }),
2336
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
2337
- form,
2338
- tab: "sessions"
2339
- }),
2340
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnalyzeTokenCard, {
2341
- panel: form.panel,
2342
- token: analyzeToken
2343
- })
2344
- ]
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
+ })]
2345
2159
  });
2346
2160
  }
2347
2161
  //#endregion
2348
2162
  //#region src/client/panel/wiki-tab.tsx
2349
2163
  /**
2350
- * 「知识库」tab:知识库工具字段表单(读取/写入开关、知识库范围)+ 知识库
2351
- * 白名单卡。表单保存走全局 draft,白名单增删是独立即时 mutation。
2164
+ * 「知识库」tab:知识库工具字段(读取 / 编辑开关、可访问范围)+ 知识库
2165
+ * 白名单(「可使用的人」)。字段保存走全局 draft,白名单增删是独立即时
2166
+ * mutation。
2352
2167
  *
2353
2168
  * @module dsh-channels/client/panel/wiki-tab
2354
2169
  */
@@ -2359,75 +2174,36 @@ window.__ModuleLoader__.load({
2359
2174
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2360
2175
  className: "dsh-channels-cfg__tabBody",
2361
2176
  children: [
2362
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.wikiTabHelp }),
2363
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
2364
- form,
2365
- groups: TAB_FIELD_GROUPS.wiki
2177
+ accessNotice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeBar, {
2178
+ kind: "ok",
2179
+ text: accessNotice
2366
2180
  }),
2367
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
2368
- form,
2369
- tab: "wiki"
2181
+ accessError === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeBar, {
2182
+ kind: "error",
2183
+ text: accessError
2370
2184
  }),
2371
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2372
- className: "dsh-channels-cfg__card",
2373
- children: [
2374
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2375
- className: "dsh-channels-cfg__cardHead",
2376
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2377
- className: "dsh-channels-cfg__cardTitle",
2378
- children: PANEL_COPY.wikiUsersCard
2379
- })
2380
- }),
2381
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2382
- className: "dsh-channels-cfg__help",
2383
- children: PANEL_COPY.wikiUsersHelp
2384
- }),
2385
- !access.loaded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, { text: PANEL_COPY.accessNotLoaded }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserWhitelist, {
2386
- title: PANEL_COPY.wikiUsersCard,
2387
- empty: PANEL_COPY.wikiUsersEmpty,
2388
- addLabel: PANEL_COPY.accessAddUser,
2389
- users: access.wikiUsers,
2390
- disabled,
2391
- working: form.busy === "access",
2392
- onAdd: onAddUser,
2393
- onRemove: onRemoveUser
2394
- }),
2395
- accessNotice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2396
- className: "dsh-channels-cfg__notice dsh-channels-cfg__notice--ok",
2397
- role: "status",
2398
- "aria-live": "polite",
2399
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: accessNotice })
2400
- }),
2401
- accessError === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2402
- className: "dsh-channels-cfg__fieldError",
2403
- role: "alert",
2404
- children: accessError
2405
- })
2406
- ]
2407
- })
2408
- ]
2409
- });
2410
- }
2411
- //#endregion
2412
- //#region src/client/panel/workspace-tab.tsx
2413
- /**
2414
- * 「工作区」tab:工作区根目录字段与保存栏。
2415
- *
2416
- * @module dsh-channels/client/panel/workspace-tab
2417
- */
2418
- function WorkspaceTab(props) {
2419
- const { form } = props;
2420
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2421
- className: "dsh-channels-cfg__tabBody",
2422
- children: [
2423
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabHelp, { text: PANEL_COPY.workspaceTabHelp }),
2424
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsCard, {
2185
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldSections, {
2425
2186
  form,
2426
- groups: TAB_FIELD_GROUPS.workspace
2187
+ groups: TAB_FIELD_GROUPS.wiki
2427
2188
  }),
2428
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SaveBar, {
2429
- form,
2430
- tab: "workspace"
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
2431
2207
  })
2432
2208
  ]
2433
2209
  });
@@ -2435,8 +2211,8 @@ window.__ModuleLoader__.load({
2435
2211
  //#endregion
2436
2212
  //#region src/client/panel/index.tsx
2437
2213
  /**
2438
- * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft 与顶部状态头 /
2439
- * tab 导航的外壳。
2214
+ * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft、顶部状态头 /
2215
+ * tab 导航与底部保存条的外壳。
2440
2216
  *
2441
2217
  * 所有 Host 交互(panel / status / catalog / config / credentials / access /
2442
2218
  * pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
@@ -2702,22 +2478,14 @@ window.__ModuleLoader__.load({
2702
2478
  setBusy(null);
2703
2479
  }
2704
2480
  }, [draft, panel]);
2705
- /** Drop the draft keys owned by one tab (nothing is written). */
2706
- const discardTab = (0, react.useCallback)((tab) => {
2481
+ /** Drop the whole draft (nothing is written). */
2482
+ const discardDraft = (0, react.useCallback)(() => {
2707
2483
  if (panel === null) return;
2708
- const groups = new Set(TAB_FIELD_GROUPS[tab]);
2709
- const keys = new Set(panel.config.fields.filter((field) => groups.has(field.group)).map((field) => field.key));
2710
- setDraft((previous) => {
2711
- const next = { ...previous };
2712
- for (const key of keys) delete next[key];
2713
- return next;
2714
- });
2484
+ setDraft({});
2715
2485
  setNotice(null);
2716
- if (tab === "model") {
2717
- catalogTicket.current += 1;
2718
- setCatalog(catalogFromFields(panel.config.fields));
2719
- setCatalogBusy(false);
2720
- }
2486
+ catalogTicket.current += 1;
2487
+ setCatalog(catalogFromFields(panel.config.fields));
2488
+ setCatalogBusy(false);
2721
2489
  }, [panel]);
2722
2490
  /**
2723
2491
  * Open the host's native directory chooser (workspace root「选择目录…」) and
@@ -2753,6 +2521,7 @@ window.__ModuleLoader__.load({
2753
2521
  }, []);
2754
2522
  /** Drop every Web override and return the refreshed panel. */
2755
2523
  const onClear = (0, react.useCallback)(async () => {
2524
+ if (!(typeof window.confirm === "function" ? window.confirm(PANEL_COPY.clearConfirm) : true)) return;
2756
2525
  setBusy("clear");
2757
2526
  setNotice(null);
2758
2527
  try {
@@ -3036,6 +2805,7 @@ window.__ModuleLoader__.load({
3036
2805
  }, []);
3037
2806
  const issues = (0, react.useMemo)(() => notice?.kind === "error" ? notice.issues ?? [] : [], [notice]);
3038
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]);
3039
2809
  /** Which tabs currently own a dirty key (the dot is per tab, not global). */
3040
2810
  const dirtyTabs = (0, react.useMemo)(() => {
3041
2811
  const tabs = /* @__PURE__ */ new Set();
@@ -3061,23 +2831,15 @@ window.__ModuleLoader__.load({
3061
2831
  issues,
3062
2832
  busy,
3063
2833
  locked: hostBusy,
3064
- dirtyCount,
3065
2834
  onChange: updateDraft,
3066
- onSave: () => {
3067
- onSave();
3068
- },
3069
- onDiscardTab: discardTab,
3070
2835
  onPickDirectory
3071
2836
  };
3072
2837
  }, [
3073
2838
  busy,
3074
- discardTab,
3075
- dirtyCount,
3076
2839
  draft,
3077
2840
  hostBusy,
3078
2841
  issues,
3079
2842
  onPickDirectory,
3080
- onSave,
3081
2843
  panel,
3082
2844
  renderedFields,
3083
2845
  updateDraft
@@ -3167,9 +2929,17 @@ window.__ModuleLoader__.load({
3167
2929
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TabNav, {
3168
2930
  activeTab,
3169
2931
  dirtyTabs,
2932
+ counts: { access: load.panel.access.loaded ? load.panel.access.requests.length : 0 },
3170
2933
  onChange: setActiveTab
3171
2934
  }),
3172
- 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
+ }),
3173
2943
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3174
2944
  role: "tabpanel",
3175
2945
  id: `dsh-channels-tabpanel-${activeTab}`,
@@ -3184,15 +2954,19 @@ window.__ModuleLoader__.load({
3184
2954
  onReconnect();
3185
2955
  }
3186
2956
  }) : null,
3187
- activeTab === "sessions" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionsTab, {
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, {
3188
2962
  form,
3189
- analyzeToken
2963
+ tab: "model",
2964
+ note: catalogBusy ? PANEL_COPY.catalogLoading : void 0
3190
2965
  }) : null,
3191
- activeTab === "model" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelTab, {
2966
+ activeTab === "attachments" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldsTab, {
3192
2967
  form,
3193
- catalogBusy
2968
+ tab: "attachments"
3194
2969
  }) : null,
3195
- activeTab === "workspace" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WorkspaceTab, { form }) : null,
3196
2970
  activeTab === "access" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessTab, {
3197
2971
  form,
3198
2972
  accessNotice,
@@ -3220,64 +2994,81 @@ window.__ModuleLoader__.load({
3220
2994
  }) : null,
3221
2995
  activeTab === "advanced" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdvancedTab, {
3222
2996
  form,
2997
+ analyzeToken,
3223
2998
  onClear: () => {
3224
2999
  onClear();
3225
3000
  }
3226
3001
  }) : null
3227
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
3228
3013
  })
3229
3014
  ]
3230
3015
  });
3231
3016
  }
3232
- /** Page header: title + phase badge + refresh, visible on every tab. */
3017
+ /** Page header: title, phase pill, version and uptime, refresh; visible on every tab. */
3233
3018
  function PanelHeader(props) {
3234
3019
  const { status, pollError, busy, locked, onRefresh } = props;
3235
3020
  const badge = phaseBadge(status.phase);
3236
3021
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
3237
3022
  className: "dsh-channels-cfg__panelHead",
3238
- title: PANEL_COPY.description,
3239
- children: [
3240
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3241
- className: "dsh-channels-cfg__headRow",
3242
- children: [
3243
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
3244
- className: "dsh-channels-cfg__title",
3245
- children: PANEL_COPY.title
3246
- }),
3247
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3248
- className: `dsh-channels-cfg__badge dsh-channels-cfg__badge--${badge.tone}`,
3249
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__dot" }), badge.text]
3250
- }),
3251
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsh-channels-cfg__spacer" }),
3252
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3253
- type: "button",
3254
- className: "dsh-channels-cfg__btn",
3255
- onClick: onRefresh,
3256
- disabled: busy !== null || locked,
3257
- children: PANEL_COPY.refresh
3258
- })
3259
- ]
3260
- }),
3261
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3262
- className: "dsh-channels-cfg__summary",
3263
- children: phaseSummary(status.phase)
3264
- }),
3265
- pollError === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3266
- className: "dsh-channels-cfg__fieldError",
3267
- role: "status",
3268
- "aria-live": "polite",
3269
- children: [
3270
- PANEL_COPY.pollFailed,
3271
- ":",
3272
- pollError
3273
- ]
3274
- })
3275
- ]
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
+ })]
3276
3067
  });
3277
3068
  }
3278
3069
  /** Top-level tab navigation; the dirty dot keeps unsaved work visible per tab. */
3279
3070
  function TabNav(props) {
3280
- const { activeTab, dirtyTabs, onChange } = props;
3071
+ const { activeTab, dirtyTabs, counts, onChange } = props;
3281
3072
  const refs = (0, react.useRef)([]);
3282
3073
  /** Roving-focus keyboard navigation between tabs (WAI-ARIA tabs pattern). */
3283
3074
  const onKeyDown = (index, event) => {
@@ -3322,11 +3113,17 @@ window.__ModuleLoader__.load({
3322
3113
  className: "dsh-channels-cfg__tab",
3323
3114
  onClick: () => onChange(tab.id),
3324
3115
  onKeyDown: (event) => onKeyDown(index, event),
3325
- children: [tab.label, dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3326
- className: "dsh-channels-cfg__tabDot",
3327
- title: PANEL_COPY.dirtyCount,
3328
- "aria-label": PANEL_COPY.dirtyCount
3329
- }) : 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
+ ]
3330
3127
  }, tab.id);
3331
3128
  })
3332
3129
  });
@@ -3335,108 +3132,125 @@ window.__ModuleLoader__.load({
3335
3132
  //#region src/client/styles.ts
3336
3133
  /**
3337
3134
  * Settings panel styling. DSH semantic tokens (`--dsw-alias-*`) with neutral
3338
- * fallbacks so the page follows the active theme; only pseudo-classes and
3339
- * 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.
3340
3137
  *
3341
3138
  * @module dsh-channels/client/styles
3342
3139
  */
3343
3140
  const STYLE_ATTRIBUTE = "data-dsh-channels-css";
3344
3141
  const CSS = `
3345
- .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}
3346
- .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)}
3347
3144
  .dsh-channels-cfg__headRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
3348
- .dsh-channels-cfg__title{margin:0;font-size:16px;font-weight:500;line-height:24px}
3349
- .dsh-channels-cfg__summary{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
3350
- .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))}
3351
- .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}
3352
- .dsh-channels-cfg__tab:hover{color:var(--dsw-alias-label-primary,#1f2328)}
3353
- .dsh-channels-cfg__tab:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#0a67d3);outline-offset:-2px}
3354
- .dsh-channels-cfg__tab[aria-selected="true"]{color:var(--dsw-alias-state-business-primary,#0a67d3);border-bottom-color:currentColor;font-weight:500}
3355
- .dsh-channels-cfg__tabDot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-warning-primary,#a15c00);flex:none}
3356
- .dsh-channels-cfg__tabBody{display:flex;flex-direction:column;gap:12px}
3357
- .dsh-channels-cfg__tabHelp{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
3358
- .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}
3359
- .dsh-channels-cfg__cardHead{display:flex;align-items:center;gap:8px;min-height:20px;flex-wrap:wrap}
3360
- .dsh-channels-cfg__cardTitle{font-size:13px;font-weight:500}
3361
- .dsh-channels-cfg__cardMeta{margin-left:auto;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px}
3362
- .dsh-channels-cfg__group{display:flex;flex-direction:column;gap:8px}
3363
- .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}
3364
- .dsh-channels-cfg__dot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:none}
3365
- .dsh-channels-cfg__badge--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);border-color:currentColor}
3366
- .dsh-channels-cfg__badge--warn{color:var(--dsw-alias-state-warning-primary,#a15c00);border-color:currentColor}
3367
- .dsh-channels-cfg__badge--bad{color:var(--dsw-alias-state-danger-primary,#c62828);border-color:currentColor}
3368
- .dsh-channels-cfg__badge--muted{color:var(--dsw-alias-label-tertiary,#8a8f98)}
3369
- .dsh-channels-cfg__facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 16px}
3370
- .dsh-channels-cfg__fact{display:flex;flex-direction:column;gap:2px;min-width:0}
3371
- .dsh-channels-cfg__factLabel{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:16px}
3372
- .dsh-channels-cfg__factValue{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
3373
- .dsh-channels-cfg__actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
3374
- .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}
3375
- .dsh-channels-cfg__btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05))}
3376
- .dsh-channels-cfg__btn:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#0a67d3);outline-offset:2px}
3377
- .dsh-channels-cfg__btn:disabled{color:var(--dsw-alias-label-dimmed,#b0b4ba);cursor:default}
3378
- .dsh-channels-cfg__btn--primary{background:var(--dsw-alias-button-primary-fill,#0a67d3);border-color:transparent;color:var(--dsw-alias-label-primary-foreground,#fff)}
3379
- .dsh-channels-cfg__btn--primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#0b5cba)}
3380
- .dsh-channels-cfg__btn--danger{color:var(--dsw-alias-state-danger-primary,#c62828);border-color:currentColor}
3381
- .dsh-channels-cfg__grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 20px;align-items:start}
3382
- .dsh-channels-cfg__field{display:flex;flex-direction:column;gap:4px;min-width:0}
3383
- .dsh-channels-cfg__field--wide{grid-column:1/-1}
3384
- .dsh-channels-cfg__labelRow{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
3385
- .dsh-channels-cfg__label{font-size:12px;color:var(--dsw-alias-label-secondary,#5c6169)}
3386
- .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}
3387
- .dsh-channels-cfg__tag--restart{color:var(--dsw-alias-state-warning-primary,#a15c00)}
3388
- .dsh-channels-cfg__tag--dirty{color:var(--dsw-alias-state-warning-primary,#a15c00);border-style:solid}
3389
- .dsh-channels-cfg__help{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:11px;line-height:16px}
3390
- .dsh-channels-cfg__reasonBad{color:var(--dsw-alias-state-danger-primary,#c62828)}
3391
- .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}
3392
- .dsh-channels-cfg__inputRow{display:flex;align-items:center;gap:8px;min-width:0}
3393
- .dsh-channels-cfg__inputRow .dsh-channels-cfg__input{flex:1 1 auto;width:auto;min-width:0}
3394
- .dsh-channels-cfg__btn--browse{flex:none}
3395
- .dsh-channels-cfg__list{display:flex;flex-direction:column;gap:6px;min-width:0}
3396
- .dsh-channels-cfg__listItem{display:flex;flex-direction:column;gap:2px;min-width:0}
3397
- .dsh-channels-cfg__btn--listRemove{flex:none}
3398
- .dsh-channels-cfg__input:focus{border-color:var(--dsw-alias-state-business-primary,#0a67d3)}
3399
- .dsh-channels-cfg__input--invalid{border-color:var(--dsw-alias-state-danger-primary,#c62828)}
3400
- select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
3401
- .dsh-channels-cfg__check{display:inline-flex;align-items:center;gap:6px;min-height:28px;cursor:pointer;font-size:13px}
3402
- .dsh-channels-cfg__checkbox{width:15px;height:15px;margin:0;accent-color:var(--dsw-alias-state-business-primary,#0a67d3);cursor:pointer}
3403
- .dsh-channels-cfg__fieldError{color:var(--dsw-alias-state-danger-primary,#c62828);font-size:11px;line-height:16px}
3404
- .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}
3405
- .dsh-channels-cfg__notice--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);background:color-mix(in srgb,currentColor 8%,transparent)}
3406
- .dsh-channels-cfg__notice--error{color:var(--dsw-alias-state-danger-primary,#c62828);background:color-mix(in srgb,currentColor 8%,transparent)}
3407
- .dsh-channels-cfg__notice a{color:inherit;text-decoration:underline}
3408
- .dsh-channels-cfg__scopeList{margin:2px 0;padding-left:18px;display:flex;flex-direction:column;gap:2px}
3409
- .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}
3410
- .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}
3411
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}
3412
3242
  .dsh-channels-cfg__steps{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
3413
3243
  .dsh-channels-cfg__step{display:flex;align-items:flex-start;gap:10px}
3414
- .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}
3415
3245
  .dsh-channels-cfg__stepBody{display:flex;flex-direction:column;gap:2px;min-width:0}
3416
- .dsh-channels-cfg__stepTitle{font-size:13px;font-weight:500}
3417
- .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}
3418
- .dsh-channels-cfg__readOnly{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 20px}
3419
- .dsh-channels-cfg__kv{display:flex;flex-direction:column;gap:2px;min-width:0}
3420
- .dsh-channels-cfg__kvKey{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:16px}
3421
- .dsh-channels-cfg__kvValue{overflow-wrap:anywhere}
3422
- .dsh-channels-cfg__mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
3423
- .dsh-channels-cfg__cred{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
3424
- .dsh-channels-cfg__cred .dsh-channels-cfg__input{flex:1 1 240px;min-width:0;width:auto}
3425
- .dsh-channels-cfg__accessSection{display:flex;flex-direction:column;gap:6px}
3426
- .dsh-channels-cfg__accessList{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
3427
- .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}
3428
- .dsh-channels-cfg__accessMain{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1 1 200px;overflow-wrap:anywhere}
3429
- .dsh-channels-cfg__accessMeta{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:11px;line-height:16px;overflow-wrap:anywhere}
3430
- .dsh-channels-cfg__accessActions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
3431
- .dsh-channels-cfg__accessAdd{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
3432
- .dsh-channels-cfg__accessInput{flex:1 1 140px;min-width:0;width:auto}
3433
- .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}
3434
- .dsh-channels-cfg__loading{padding:16px 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px}
3435
- .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}}
3436
3250
  @container (max-width:560px){
3437
- .dsh-channels-cfg__facts{grid-template-columns:repeat(2,minmax(0,1fr))}
3438
- .dsh-channels-cfg__grid,.dsh-channels-cfg__readOnly{grid-template-columns:minmax(0,1fr)}
3439
- .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%}
3440
3254
  }
3441
3255
  `;
3442
3256
  /** Append the panel stylesheet once (idempotent across HMR / remounts). */