@modesto_ma/dsh-channels 0.5.0-beta.4 → 0.5.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +49 -28
  2. package/docs/attachment-pipeline-v2.md +1 -1
  3. package/docs/feishu-onboarding.md +6 -5
  4. package/lib/client.js +1874 -1510
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +736 -353
  7. package/lib/index.js.map +1 -1
  8. package/package.json +2 -2
  9. package/src/client/api.ts +45 -2
  10. package/src/client/avatar.tsx +36 -0
  11. package/src/client/icon.tsx +25 -0
  12. package/src/client/index.ts +78 -23
  13. package/src/client/number-field.ts +97 -0
  14. package/src/client/origin-cache.ts +45 -0
  15. package/src/client/origin.tsx +201 -0
  16. package/src/client/panel/access-tab.tsx +90 -21
  17. package/src/client/panel/advanced-tab.tsx +59 -68
  18. package/src/client/panel/connection-tab.tsx +147 -164
  19. package/src/client/panel/credentials-card.tsx +124 -87
  20. package/src/client/panel/fields-tab.tsx +27 -0
  21. package/src/client/panel/index.tsx +77 -54
  22. package/src/client/panel/profiles.ts +73 -0
  23. package/src/client/panel/shared.tsx +358 -253
  24. package/src/client/panel/whitelist.tsx +203 -0
  25. package/src/client/panel/wiki-tab.tsx +33 -39
  26. package/src/client/styles.ts +132 -92
  27. package/src/domain.ts +42 -0
  28. package/src/feishu/scopes.ts +2 -1
  29. package/src/feishu-channel.ts +87 -0
  30. package/src/index.ts +1 -1
  31. package/src/routing/inbound.ts +13 -5
  32. package/src/service.ts +146 -3
  33. package/src/session/routes.ts +45 -4
  34. package/src/settings/contract.ts +265 -152
  35. package/src/settings/routes.ts +47 -0
  36. package/src/settings/surface.ts +201 -197
  37. package/src/client/panel/access-card.tsx +0 -329
  38. package/src/client/panel/analyze-token-card.tsx +0 -70
  39. package/src/client/panel/model-tab.tsx +0 -23
  40. package/src/client/panel/sessions-tab.tsx +0 -24
  41. package/src/client/panel/workspace-tab.tsx +0 -24
@@ -1,20 +1,19 @@
1
1
  /**
2
- * 「连接」tab:按 Host 派生的生命周期阶段渲染连接引导。
2
+ * 「连接」tab:按 Host 派生的生命周期阶段渲染。
3
3
  *
4
- * - unconfigured:onboarding 三步 + 凭据卡(不渲染可用的「连接」按钮)
5
- * - idle:连接信息卡 + 「连接飞书」
6
- * - error:连接信息卡 + 最近一次连接失败 + 「重试」
7
- * - connected:连接信息卡 + 「重启飞书连接」+ 凭据卡
8
- * - connecting:禁用按钮显示「连接中…」
9
- * - 已连接且权限自检完成:权限自检卡(缺失权限红字 + 开发者后台深链)
4
+ * - unconfigured:上手三步 + 应用凭据
5
+ * - idle / error / connecting / connected:状态块(当前阶段的唯一操作 + 运行数字)
6
+ * - 连接后:自检(权限缺口可展开、斜杠指令面板同步结果)
7
+ * - 有需重连才生效的已保存配置:顶部「重启连接」横幅
10
8
  *
11
9
  * @module dsh-channels/client/panel/connection-tab
12
10
  */
13
11
 
14
- import { PANEL_COPY } from '../../settings/contract.ts'
12
+ import { useState } from 'react'
13
+ import { fillCopy, PANEL_COPY } from '../../settings/contract.ts'
15
14
  import type { ChannelStatusWire, PanelWire, ScopeCheckWire, SlashCommandSyncWire } from '../../settings/contract.ts'
16
- import { CredentialsCard } from './credentials-card.tsx'
17
- import { connectionStateLabel, fieldLabel, formatUptime, phaseBadge, TabHelp } from './shared.tsx'
15
+ import { CredentialsSection } from './credentials-card.tsx'
16
+ import { fieldLabel, phaseBadge, phaseSummary, Pill, Row, Section } from './shared.tsx'
18
17
  import type { BusyAction, CredentialsView } from './shared.tsx'
19
18
 
20
19
  export interface ConnectionTabProps {
@@ -27,24 +26,30 @@ export interface ConnectionTabProps {
27
26
 
28
27
  export function ConnectionTab(props: ConnectionTabProps): JSX.Element {
29
28
  const { panel, status, busy, credentials, onReconnect } = props
29
+ const checks = status.scopeCheck !== undefined || status.slashCommandSync !== undefined
30
30
  return (
31
31
  <div className="dsh-channels-cfg__tabBody">
32
- <TabHelp text={PANEL_COPY.connectionTabHelp} />
33
32
  {panel.config.restartPendingKeys.length === 0
34
33
  ? null
35
34
  : <RestartBanner panel={panel} busy={busy} locked={status.busy} onReconnect={onReconnect} />}
36
35
  {status.phase === 'unconfigured'
37
- ? <OnboardingCard status={status} />
38
- : <ConnectionInfoCard status={status} busy={busy} onReconnect={onReconnect} />}
39
- {status.scopeCheck === undefined ? null : <ScopeCheckCard check={status.scopeCheck} />}
40
- {status.slashCommandSync === undefined ? null : <SlashCommandSyncCard sync={status.slashCommandSync} />}
41
- <CredentialsCard panel={panel} credentials={credentials} />
36
+ ? <OnboardingSection status={status} />
37
+ : <StatusBlock status={status} busy={busy} onReconnect={onReconnect} />}
38
+ {checks
39
+ ? (
40
+ <Section title={PANEL_COPY.checksTitle}>
41
+ {status.scopeCheck === undefined ? null : <ScopeCheckRow check={status.scopeCheck} />}
42
+ {status.slashCommandSync === undefined ? null : <SlashCommandSyncRow sync={status.slashCommandSync} />}
43
+ </Section>
44
+ )
45
+ : null}
46
+ <CredentialsSection panel={panel} credentials={credentials} />
42
47
  </div>
43
48
  )
44
49
  }
45
50
 
46
51
  /** First-run guide: create the app, fill the credentials, save and wait. */
47
- function OnboardingCard(props: { status: ChannelStatusWire }): JSX.Element {
52
+ function OnboardingSection(props: { status: ChannelStatusWire }): JSX.Element {
48
53
  const { status } = props
49
54
  const steps = [
50
55
  { title: PANEL_COPY.onboardingStep1Title, text: PANEL_COPY.onboardingStep1Text },
@@ -52,21 +57,17 @@ function OnboardingCard(props: { status: ChannelStatusWire }): JSX.Element {
52
57
  { title: PANEL_COPY.onboardingStep3Title, text: PANEL_COPY.onboardingStep3Text },
53
58
  ]
54
59
  return (
55
- <section className="dsh-channels-cfg__card">
56
- <div className="dsh-channels-cfg__cardHead">
57
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.onboardingTitle}</span>
58
- <span className="dsh-channels-cfg__badge dsh-channels-cfg__badge--muted">
59
- {PANEL_COPY.phaseUnconfigured}
60
- </span>
60
+ <div className="dsh-channels-cfg__hero">
61
+ <div className="dsh-channels-cfg__heroTop">
62
+ <strong>{PANEL_COPY.onboardingTitle}</strong>
61
63
  </div>
62
- <span className="dsh-channels-cfg__help">{PANEL_COPY.onboardingIntro}</span>
63
64
  <ol className="dsh-channels-cfg__steps">
64
65
  {steps.map((step, index) => (
65
66
  <li className="dsh-channels-cfg__step" key={step.title}>
66
67
  <span className="dsh-channels-cfg__stepIndex">{index + 1}</span>
67
68
  <span className="dsh-channels-cfg__stepBody">
68
69
  <span className="dsh-channels-cfg__stepTitle">{step.title}</span>
69
- <span className="dsh-channels-cfg__help">{step.text}</span>
70
+ <span className="dsh-channels-cfg__rowSummary">{step.text}</span>
70
71
  </span>
71
72
  </li>
72
73
  ))}
@@ -74,19 +75,16 @@ function OnboardingCard(props: { status: ChannelStatusWire }): JSX.Element {
74
75
  {status.missingConfig.length === 0
75
76
  ? null
76
77
  : (
77
- <div className="dsh-channels-cfg__banner">
78
- <strong>{PANEL_COPY.missingConfig}:</strong>
79
- <span>{status.missingConfig.join('、')}</span>
80
- </div>
78
+ <span className="dsh-channels-cfg__rowSummary">
79
+ {PANEL_COPY.missingConfig}:{status.missingConfig.join('、')}
80
+ </span>
81
81
  )}
82
- <span className="dsh-channels-cfg__help">{PANEL_COPY.statusHintUnconfigured}</span>
83
- <span className="dsh-channels-cfg__help">{PANEL_COPY.onboardingDocsHint}</span>
84
- </section>
82
+ </div>
85
83
  )
86
84
  }
87
85
 
88
- /** Runtime facts plus the one lifecycle action available in the current phase. */
89
- function ConnectionInfoCard(props: {
86
+ /** Phase, the one lifecycle action available in it, and the runtime counters. */
87
+ function StatusBlock(props: {
90
88
  status: ChannelStatusWire
91
89
  busy: BusyAction | null
92
90
  onReconnect: () => void
@@ -96,163 +94,149 @@ function ConnectionInfoCard(props: {
96
94
  // `status.busy` covers a connection attempt started outside this panel; the
97
95
  // local busy flag covers the panel's own action. Either way the button waits.
98
96
  const actionBusy = busy !== null || status.busy
99
- const connection = connectionStateLabel(status.connection?.state)
100
- const facts = [
101
- { label: PANEL_COPY.statusCard, value: badge.text },
102
- { label: PANEL_COPY.connectionStateLabel, value: connection.text },
103
- { label: PANEL_COPY.botOpenId, value: status.botOpenIdTail ?? PANEL_COPY.unknownValue },
104
- { label: PANEL_COPY.uptime, value: formatUptime(status.uptimeMs) },
97
+ const stats = [
105
98
  { label: PANEL_COPY.routeCount, value: String(status.routeCount) },
106
99
  { label: PANEL_COPY.liveAgentCount, value: String(status.liveAgentCount) },
107
100
  { label: PANEL_COPY.inflightTurnCount, value: String(status.inflightTurnCount) },
101
+ { label: PANEL_COPY.botOpenId, value: status.botOpenIdTail ?? PANEL_COPY.unknownValue, mono: true },
108
102
  ]
103
+ let action: JSX.Element | null = null
104
+ if (status.phase === 'connecting') {
105
+ action = <button type="button" className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary" disabled>{PANEL_COPY.connecting}</button>
106
+ } else if (status.phase === 'idle' || status.phase === 'error') {
107
+ action = (
108
+ <button type="button" className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary" onClick={onReconnect} disabled={actionBusy}>
109
+ {busy === 'reconnect' ? PANEL_COPY.connecting : status.phase === 'idle' ? PANEL_COPY.connect : PANEL_COPY.retry}
110
+ </button>
111
+ )
112
+ } else if (status.phase === 'connected') {
113
+ action = (
114
+ <button type="button" className="dsh-channels-cfg__btn" onClick={onReconnect} disabled={actionBusy}>
115
+ {busy === 'reconnect' ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect}
116
+ </button>
117
+ )
118
+ }
109
119
  return (
110
- <section className="dsh-channels-cfg__card">
111
- <div className="dsh-channels-cfg__cardHead">
112
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.connectionInfo}</span>
113
- <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${status.configured ? 'ok' : 'bad'}`}>
114
- {status.configured ? PANEL_COPY.configuredBadge : PANEL_COPY.unconfiguredBadge}
115
- </span>
120
+ <div className="dsh-channels-cfg__hero">
121
+ <div className="dsh-channels-cfg__heroTop">
122
+ <Pill tone={badge.tone} dot>{badge.text}</Pill>
123
+ <strong>{phaseSummary(status.phase)}</strong>
124
+ <span className="dsh-channels-cfg__spacer" />
125
+ {action}
116
126
  </div>
117
127
  {status.lastStartError === undefined
118
128
  ? null
119
129
  : (
120
130
  <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
121
- <strong>{PANEL_COPY.lastError}</strong>
122
- <span className="dsh-channels-cfg__mono">{status.lastStartError}</span>
131
+ {PANEL_COPY.lastError}:<span className="dsh-channels-cfg__mono">{status.lastStartError}</span>
123
132
  </div>
124
133
  )}
125
- <div className="dsh-channels-cfg__facts">
126
- {facts.map(fact => (
127
- <div className="dsh-channels-cfg__fact" key={fact.label}>
128
- <span className="dsh-channels-cfg__factLabel">{fact.label}</span>
129
- <span className="dsh-channels-cfg__factValue">{fact.value}</span>
130
- </div>
131
- ))}
132
- </div>
134
+ {status.phase === 'connected'
135
+ ? (
136
+ <div className="dsh-channels-cfg__stats">
137
+ {stats.map(stat => (
138
+ <div className="dsh-channels-cfg__stat" key={stat.label}>
139
+ <span>{stat.label}</span>
140
+ <strong className={stat.mono === true ? 'dsh-channels-cfg__mono' : undefined}>{stat.value}</strong>
141
+ </div>
142
+ ))}
143
+ </div>
144
+ )
145
+ : null}
133
146
  {status.reasons.length === 0
134
147
  ? null
135
148
  : (
136
- <span className={`dsh-channels-cfg__help${status.level === 'unhealthy' ? ' dsh-channels-cfg__reasonBad' : ''}`}>
149
+ <span className={`dsh-channels-cfg__rowSummary${status.level === 'unhealthy' ? ' dsh-channels-cfg__textBad' : ''}`}>
137
150
  {PANEL_COPY.healthReasons}:{status.reasons.join(';')}
138
151
  </span>
139
152
  )}
140
- <div className="dsh-channels-cfg__actions">
141
- {status.phase === 'connecting'
142
- ? (
143
- <button type="button" className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary" disabled>
144
- {PANEL_COPY.connecting}
145
- </button>
146
- )
147
- : null}
148
- {status.phase === 'idle'
149
- ? (
150
- <button
151
- type="button"
152
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
153
- onClick={onReconnect}
154
- disabled={actionBusy}
155
- >
156
- {busy === 'reconnect' ? PANEL_COPY.connecting : PANEL_COPY.connect}
157
- </button>
158
- )
159
- : null}
160
- {status.phase === 'error'
161
- ? (
162
- <button
163
- type="button"
164
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
165
- onClick={onReconnect}
166
- disabled={actionBusy}
167
- >
168
- {busy === 'reconnect' ? PANEL_COPY.connecting : PANEL_COPY.retry}
169
- </button>
170
- )
171
- : null}
172
- {status.phase === 'connected'
173
- ? (
174
- <>
175
- <button
176
- type="button"
177
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--danger"
178
- onClick={onReconnect}
179
- disabled={actionBusy}
180
- >
181
- {busy === 'reconnect' ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect}
182
- </button>
183
- <span className="dsh-channels-cfg__help">{PANEL_COPY.restartWarning}</span>
184
- </>
185
- )
186
- : null}
187
- </div>
188
- </section>
153
+ </div>
154
+ )
155
+ }
156
+
157
+ /** Status icon in front of one check row. */
158
+ function CheckIcon(props: { tone: 'ok' | 'warn' | 'bad' }): JSX.Element {
159
+ return (
160
+ <span className={`dsh-channels-cfg__checkIcon dsh-channels-cfg__checkIcon--${props.tone}`} aria-hidden="true">
161
+ {props.tone === 'ok' ? '✓' : '!'}
162
+ </span>
189
163
  )
190
164
  }
191
165
 
192
- /** Post-connect scope self-check: missing scopes in red, each with a console deep link. */
193
- function ScopeCheckCard(props: { check: ScopeCheckWire }): JSX.Element {
166
+ /** Post-connect scope self-check: the gap count, expandable to each scope with its console link. */
167
+ function ScopeCheckRow(props: { check: ScopeCheckWire }): JSX.Element {
194
168
  const { check } = props
169
+ const [open, setOpen] = useState(false)
170
+ if (check.state === 'ok') {
171
+ return <Row leading={<CheckIcon tone="ok" />} label={PANEL_COPY.scopeCheckOk} />
172
+ }
173
+ if (check.state === 'error') {
174
+ return (
175
+ <Row
176
+ leading={<CheckIcon tone="bad" />}
177
+ label={PANEL_COPY.scopeCheckError}
178
+ summary={<span className="dsh-channels-cfg__mono">{check.error}</span>}
179
+ />
180
+ )
181
+ }
182
+ const required = check.missing.filter(item => item.level === 'required').length
195
183
  return (
196
- <section className="dsh-channels-cfg__card">
197
- <div className="dsh-channels-cfg__cardHead">
198
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.scopeCheckTitle}</span>
199
- </div>
200
- {check.state === 'ok'
201
- ? <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">{PANEL_COPY.scopeCheckOk}</div>
202
- : null}
203
- {check.state === 'error'
204
- ? (
205
- <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
206
- <strong>{PANEL_COPY.scopeCheckError}</strong>
207
- <span className="dsh-channels-cfg__mono">{check.error}</span>
208
- </div>
209
- )
210
- : null}
211
- {check.state === 'missing'
184
+ <>
185
+ <Row
186
+ leading={<CheckIcon tone={required > 0 ? 'bad' : 'warn'} />}
187
+ label={required > 0
188
+ ? fillCopy(PANEL_COPY.scopeCheckMissingRequired, { n: required })
189
+ : fillCopy(PANEL_COPY.scopeCheckMissing, { n: check.missing.length })}
190
+ summary={required > 0 ? PANEL_COPY.scopeCheckPublish : PANEL_COPY.scopeCheckMissingOptional}
191
+ control={(
192
+ <div className="dsh-channels-cfg__inputRow">
193
+ <button type="button" className="dsh-channels-cfg__more" aria-expanded={open} onClick={() => setOpen(!open)}>
194
+ {open ? PANEL_COPY.collapse : PANEL_COPY.expand}
195
+ </button>
196
+ <a className="dsh-channels-cfg__btn" href={check.consoleUrl} target="_blank" rel="noreferrer noopener">
197
+ {PANEL_COPY.scopeCheckOpenAll}
198
+ </a>
199
+ </div>
200
+ )}
201
+ />
202
+ {open
212
203
  ? (
213
- <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
214
- <strong>{PANEL_COPY.scopeCheckMissing}</strong>
215
- <ul className="dsh-channels-cfg__scopeList">
216
- {check.missing.map(item => (
217
- <li key={item.scopes.join(',')}>
218
- <span>[{item.level === 'required' ? PANEL_COPY.scopeCheckRequired : PANEL_COPY.scopeCheckOptional}] </span>
219
- <span className="dsh-channels-cfg__mono">{item.scopes.join(' / ')}</span>
220
- <span>:{item.purpose} </span>
221
- <a href={item.consoleUrl} target="_blank" rel="noreferrer noopener">{PANEL_COPY.scopeCheckOpenOne}</a>
222
- </li>
223
- ))}
224
- </ul>
225
- <a href={check.consoleUrl} target="_blank" rel="noreferrer noopener">{PANEL_COPY.scopeCheckOpenAll}</a>
226
- </div>
204
+ <ul className="dsh-channels-cfg__scopeList">
205
+ {check.missing.map(item => (
206
+ <li key={item.scopes.join(',')}>
207
+ <code className="dsh-channels-cfg__mono">{item.scopes.join(' / ')}</code>
208
+ {item.level === 'required' ? <Pill tone="bad">{PANEL_COPY.scopeCheckRequired}</Pill> : null}
209
+ <span>{item.purpose}</span>
210
+ <a className="dsh-channels-cfg__link" href={item.consoleUrl} target="_blank" rel="noreferrer noopener">
211
+ {PANEL_COPY.scopeCheckOpenOne}
212
+ </a>
213
+ </li>
214
+ ))}
215
+ </ul>
227
216
  )
228
217
  : null}
229
- </section>
218
+ </>
230
219
  )
231
220
  }
232
221
 
233
- /** Post-connect native slash-command panel sync: counts on success, the Feishu reason on failure. */
234
- function SlashCommandSyncCard(props: { sync: SlashCommandSyncWire }): JSX.Element {
222
+ /** Post-connect native slash-command panel sync: the command count, or the Feishu reason. */
223
+ function SlashCommandSyncRow(props: { sync: SlashCommandSyncWire }): JSX.Element {
235
224
  const { sync } = props
236
- return (
237
- <section className="dsh-channels-cfg__card">
238
- <div className="dsh-channels-cfg__cardHead">
239
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.slashCommandSyncTitle}</span>
240
- </div>
241
- {sync.state === 'ok'
242
- ? (
243
- <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">
244
- <span>{PANEL_COPY.slashCommandSyncOk} </span>
245
- <span>{PANEL_COPY.slashCommandSyncTotal} {sync.total} · {PANEL_COPY.slashCommandSyncCreated} {sync.created} · {PANEL_COPY.slashCommandSyncUpdated} {sync.updated} · {PANEL_COPY.slashCommandSyncRemoved} {sync.removed}</span>
246
- </div>
247
- )
248
- : (
249
- <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
250
- <strong>{PANEL_COPY.slashCommandSyncError}</strong>
251
- <span className="dsh-channels-cfg__mono">{sync.error}</span>
252
- </div>
253
- )}
254
- </section>
255
- )
225
+ return sync.state === 'ok'
226
+ ? (
227
+ <Row
228
+ leading={<CheckIcon tone="ok" />}
229
+ label={PANEL_COPY.slashCommandSyncTitle}
230
+ summary={fillCopy(PANEL_COPY.slashCommandSyncOk, { total: sync.total })}
231
+ />
232
+ )
233
+ : (
234
+ <Row
235
+ leading={<CheckIcon tone="warn" />}
236
+ label={PANEL_COPY.slashCommandSyncTitle}
237
+ summary={<>{PANEL_COPY.slashCommandSyncError}:<span className="dsh-channels-cfg__mono">{sync.error}</span></>}
238
+ />
239
+ )
256
240
  }
257
241
 
258
242
  /** Config saved but only applied on the next connect: offer the one-click restart. */
@@ -268,16 +252,15 @@ function RestartBanner(props: {
268
252
  .join('、')
269
253
  return (
270
254
  <div className="dsh-channels-cfg__banner">
271
- <span>{PANEL_COPY.restartBanner}{keys}</span>
255
+ <span className="dsh-channels-cfg__bannerText">{PANEL_COPY.restartBanner}{keys}</span>
272
256
  <button
273
257
  type="button"
274
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--danger"
258
+ className="dsh-channels-cfg__btn"
275
259
  onClick={onReconnect}
276
260
  disabled={busy !== null || locked}
277
261
  >
278
262
  {busy === 'reconnect' ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect}
279
263
  </button>
280
- <span className="dsh-channels-cfg__help">{PANEL_COPY.restartWarning}</span>
281
264
  </div>
282
265
  )
283
266
  }
@@ -1,16 +1,106 @@
1
1
  /**
2
- * 飞书应用凭据编辑器:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示
3
- * 「已配置 / 未配置」,两个方向都不回显已存值。保存动作由页面外壳持有
4
- * (凭据保存独立于配置 draft)。
2
+ * 凭据行:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示「已配置 /
3
+ * 未配置」,两个方向都不回显已存值。已配置时只显示「更换 / 移除」,点「更换」
4
+ * 才出现输入框。保存动作由页面外壳持有(凭据保存独立于配置 draft)。
5
5
  *
6
6
  * @module dsh-channels/client/panel/credentials-card
7
7
  */
8
8
 
9
+ import { useEffect, useRef, useState } from 'react'
9
10
  import { PANEL_COPY } from '../../settings/contract.ts'
10
- import type { CredentialField, CredentialRefWire, PanelWire } from '../../settings/contract.ts'
11
- import type { CredentialsView } from './shared.tsx'
11
+ import type { CredentialRefWire, FieldIssueWire, PanelWire } from '../../settings/contract.ts'
12
+ import { Pill, Row, Section } from './shared.tsx'
13
+ import type { BusyAction, CredentialsView } from './shared.tsx'
12
14
 
13
- export function CredentialsCard(props: {
15
+ /** One credential slot as a setting row with its own input and actions. */
16
+ export function CredentialRow(props: {
17
+ label: string
18
+ summary?: string
19
+ state: CredentialRefWire
20
+ value: string
21
+ secret: boolean
22
+ placeholder: string
23
+ issue?: FieldIssueWire
24
+ busy: BusyAction | null
25
+ disabled: boolean
26
+ onChange: (value: string) => void
27
+ onSave: () => void
28
+ onClear: () => void
29
+ }): JSX.Element {
30
+ const { label, summary, state, value, secret, placeholder, issue, busy, disabled, onChange, onSave, onClear } = props
31
+ const [editing, setEditing] = useState(false)
32
+ // Close the replace input once a credential save finishes (the shell clears the value).
33
+ const previousBusy = useRef(busy)
34
+ useEffect(() => {
35
+ if (previousBusy.current === 'credentials' && busy === null) setEditing(false)
36
+ previousBusy.current = busy
37
+ }, [busy])
38
+ const showInput = !state.configured || editing || value !== ''
39
+ const source = `${PANEL_COPY.credentialStore} · ${state.ref}${state.source === undefined ? '' : ` · ${state.source}`}`
40
+ return (
41
+ <Row
42
+ label={label}
43
+ tags={(
44
+ <Pill tone={state.configured ? 'ok' : 'muted'}>
45
+ {state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing}
46
+ </Pill>
47
+ )}
48
+ summary={summary === undefined ? <span className="dsh-channels-cfg__mono">{source}</span> : summary}
49
+ error={issue?.message ?? (state.writable ? undefined : PANEL_COPY.credentialReadOnly)}
50
+ control={showInput
51
+ ? (
52
+ <div className="dsh-channels-cfg__inputRow">
53
+ <input
54
+ type={secret ? 'password' : 'text'}
55
+ className={`dsh-channels-cfg__input dsh-channels-cfg__input--text${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`}
56
+ value={value}
57
+ placeholder={placeholder}
58
+ autoComplete="new-password"
59
+ aria-label={label}
60
+ disabled={disabled}
61
+ onChange={event => onChange(event.target.value)}
62
+ />
63
+ <button
64
+ type="button"
65
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
66
+ disabled={disabled || value.trim() === ''}
67
+ onClick={onSave}
68
+ >
69
+ {busy === 'credentials' ? PANEL_COPY.savingCredentials : PANEL_COPY.saveCredentials}
70
+ </button>
71
+ {state.configured
72
+ ? (
73
+ <button
74
+ type="button"
75
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost"
76
+ disabled={busy !== null}
77
+ onClick={() => {
78
+ setEditing(false)
79
+ onChange('')
80
+ }}
81
+ >
82
+ {PANEL_COPY.cancel}
83
+ </button>
84
+ )
85
+ : null}
86
+ </div>
87
+ )
88
+ : (
89
+ <div className="dsh-channels-cfg__inputRow">
90
+ <button type="button" className="dsh-channels-cfg__btn" disabled={disabled} onClick={() => setEditing(true)}>
91
+ {PANEL_COPY.replaceCredential}
92
+ </button>
93
+ <button type="button" className="dsh-channels-cfg__btn dsh-channels-cfg__btn--remove" disabled={disabled} onClick={onClear}>
94
+ {busy === 'credentials-clear' ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential}
95
+ </button>
96
+ </div>
97
+ )}
98
+ />
99
+ )
100
+ }
101
+
102
+ /** The Feishu application pair (App ID / App Secret). */
103
+ export function CredentialsSection(props: {
14
104
  panel: PanelWire
15
105
  credentials: CredentialsView
16
106
  }): JSX.Element {
@@ -18,87 +108,34 @@ export function CredentialsCard(props: {
18
108
  const { appId, appSecret, issues, busy, locked, onAppIdChange, onAppSecretChange, onSave, onClear } = credentials
19
109
  const appIdState = panel.config.credentials.appId
20
110
  const appSecretState = panel.config.credentials.appSecret
21
- const configured = appIdState.configured && appSecretState.configured
22
- const writable = appIdState.writable || appSecretState.writable
23
- const disabled = busy !== null || locked || !writable
24
- const pending = appId.trim() !== '' || appSecret.trim() !== ''
25
-
26
- const row = (
27
- field: CredentialField,
28
- label: string,
29
- state: CredentialRefWire,
30
- value: string,
31
- type: 'text' | 'password',
32
- placeholder: string,
33
- onChange: (next: string) => void,
34
- ): JSX.Element => {
35
- const issue = issues.find(item => item.key === field)
36
- const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
37
- return (
38
- <div className="dsh-channels-cfg__field" key={field}>
39
- <div className="dsh-channels-cfg__labelRow">
40
- <span className="dsh-channels-cfg__label">{label}</span>
41
- <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${state.configured ? 'ok' : 'bad'}`}>
42
- {state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing}
43
- </span>
44
- </div>
45
- <div className="dsh-channels-cfg__cred">
46
- <input
47
- type={type}
48
- className={inputClass}
49
- value={value}
50
- placeholder={placeholder}
51
- autoComplete="new-password"
52
- aria-label={label}
53
- disabled={disabled}
54
- onChange={event => onChange(event.target.value)}
55
- />
56
- {state.configured
57
- ? (
58
- <button
59
- type="button"
60
- className="dsh-channels-cfg__btn"
61
- disabled={disabled}
62
- onClick={() => onClear(field)}
63
- >
64
- {busy === 'credentials-clear' ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential}
65
- </button>
66
- )
67
- : null}
68
- </div>
69
- <span className="dsh-channels-cfg__help">
70
- {PANEL_COPY.credentialsRef}:<span className="dsh-channels-cfg__mono">{state.ref}</span>
71
- {state.source === undefined ? '' : ` · ${state.source}`}
72
- </span>
73
- {issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
74
- </div>
75
- )
76
- }
77
-
78
111
  return (
79
- <section className="dsh-channels-cfg__card">
80
- <div className="dsh-channels-cfg__cardHead">
81
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.credentialsCard}</span>
82
- <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${configured ? 'ok' : 'muted'}`}>
83
- {configured ? PANEL_COPY.configuredBadge : PANEL_COPY.unconfiguredBadge}
84
- </span>
85
- </div>
86
- <span className="dsh-channels-cfg__help">{PANEL_COPY.credentialsCardHelp}</span>
87
- {row('appId', PANEL_COPY.appIdLabel, appIdState, appId, 'text', PANEL_COPY.appIdPlaceholder, onAppIdChange)}
88
- {row('appSecret', PANEL_COPY.appSecretLabel, appSecretState, appSecret, 'password', PANEL_COPY.appSecretPlaceholder, onAppSecretChange)}
89
- {writable ? null : <span className="dsh-channels-cfg__fieldError">{PANEL_COPY.credentialReadOnly}</span>}
90
- <div className="dsh-channels-cfg__footer">
91
- <button
92
- type="button"
93
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
94
- onClick={onSave}
95
- disabled={disabled || !pending}
96
- >
97
- {busy === 'credentials' ? PANEL_COPY.savingCredentials : PANEL_COPY.saveCredentials}
98
- </button>
99
- <span className="dsh-channels-cfg__spacer" />
100
- <span className="dsh-channels-cfg__help">{PANEL_COPY.credentialsPendingHint}</span>
101
- </div>
102
- </section>
112
+ <Section title={PANEL_COPY.credentialsTitle} footnote={PANEL_COPY.credentialsHint}>
113
+ <CredentialRow
114
+ label={PANEL_COPY.appIdLabel}
115
+ state={appIdState}
116
+ value={appId}
117
+ secret={false}
118
+ placeholder={PANEL_COPY.appIdPlaceholder}
119
+ issue={issues.find(item => item.key === 'appId')}
120
+ busy={busy}
121
+ disabled={busy !== null || locked || !appIdState.writable}
122
+ onChange={onAppIdChange}
123
+ onSave={onSave}
124
+ onClear={() => onClear('appId')}
125
+ />
126
+ <CredentialRow
127
+ label={PANEL_COPY.appSecretLabel}
128
+ state={appSecretState}
129
+ value={appSecret}
130
+ secret
131
+ placeholder={PANEL_COPY.appSecretPlaceholder}
132
+ issue={issues.find(item => item.key === 'appSecret')}
133
+ busy={busy}
134
+ disabled={busy !== null || locked || !appSecretState.writable}
135
+ onChange={onAppSecretChange}
136
+ onSave={onSave}
137
+ onClear={() => onClear('appSecret')}
138
+ />
139
+ </Section>
103
140
  )
104
141
  }