@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.
- package/README.md +49 -28
- package/docs/attachment-pipeline-v2.md +1 -1
- package/docs/feishu-onboarding.md +6 -5
- package/lib/client.js +1874 -1510
- package/lib/client.js.map +1 -1
- package/lib/index.js +736 -353
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/api.ts +45 -2
- package/src/client/avatar.tsx +36 -0
- package/src/client/icon.tsx +25 -0
- package/src/client/index.ts +78 -23
- package/src/client/number-field.ts +97 -0
- package/src/client/origin-cache.ts +45 -0
- package/src/client/origin.tsx +201 -0
- package/src/client/panel/access-tab.tsx +90 -21
- package/src/client/panel/advanced-tab.tsx +59 -68
- package/src/client/panel/connection-tab.tsx +147 -164
- package/src/client/panel/credentials-card.tsx +124 -87
- package/src/client/panel/fields-tab.tsx +27 -0
- package/src/client/panel/index.tsx +77 -54
- package/src/client/panel/profiles.ts +73 -0
- package/src/client/panel/shared.tsx +358 -253
- package/src/client/panel/whitelist.tsx +203 -0
- package/src/client/panel/wiki-tab.tsx +33 -39
- package/src/client/styles.ts +132 -92
- package/src/domain.ts +42 -0
- package/src/feishu/scopes.ts +2 -1
- package/src/feishu-channel.ts +87 -0
- package/src/index.ts +1 -1
- package/src/routing/inbound.ts +13 -5
- package/src/service.ts +146 -3
- package/src/session/routes.ts +45 -4
- package/src/settings/contract.ts +265 -152
- package/src/settings/routes.ts +47 -0
- package/src/settings/surface.ts +201 -197
- package/src/client/panel/access-card.tsx +0 -329
- package/src/client/panel/analyze-token-card.tsx +0 -70
- package/src/client/panel/model-tab.tsx +0 -23
- package/src/client/panel/sessions-tab.tsx +0 -24
- 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
|
|
5
|
-
* - idle
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
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 {
|
|
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 {
|
|
17
|
-
import {
|
|
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
|
-
? <
|
|
38
|
-
: <
|
|
39
|
-
{
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
<
|
|
56
|
-
<div className="dsh-channels-
|
|
57
|
-
<
|
|
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-
|
|
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
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
</div>
|
|
78
|
+
<span className="dsh-channels-cfg__rowSummary">
|
|
79
|
+
{PANEL_COPY.missingConfig}:{status.missingConfig.join('、')}
|
|
80
|
+
</span>
|
|
81
81
|
)}
|
|
82
|
-
|
|
83
|
-
<span className="dsh-channels-cfg__help">{PANEL_COPY.onboardingDocsHint}</span>
|
|
84
|
-
</section>
|
|
82
|
+
</div>
|
|
85
83
|
)
|
|
86
84
|
}
|
|
87
85
|
|
|
88
|
-
/**
|
|
89
|
-
function
|
|
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
|
|
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
|
-
<
|
|
111
|
-
<div className="dsh-channels-
|
|
112
|
-
<
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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:
|
|
193
|
-
function
|
|
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
|
-
|
|
197
|
-
<
|
|
198
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
:
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
<
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
<
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
218
|
+
</>
|
|
230
219
|
)
|
|
231
220
|
}
|
|
232
221
|
|
|
233
|
-
/** Post-connect native slash-command panel sync:
|
|
234
|
-
function
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
11
|
-
import
|
|
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
|
-
|
|
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
|
-
<
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
}
|