@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/README.md +26 -22
- package/docs/attachment-pipeline-v2.md +1 -1
- package/docs/feishu-onboarding.md +2 -2
- package/lib/client.js +1304 -1490
- package/lib/client.js.map +1 -1
- package/lib/index.js +400 -348
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/number-field.ts +97 -0
- package/src/client/panel/access-tab.tsx +61 -20
- 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 +55 -46
- package/src/client/panel/shared.tsx +358 -253
- package/src/client/panel/whitelist.tsx +197 -0
- package/src/client/panel/wiki-tab.tsx +24 -38
- package/src/client/styles.ts +108 -91
- package/src/index.ts +1 -1
- package/src/settings/contract.ts +197 -152
- package/src/settings/surface.ts +179 -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,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 设置面板共享层:页面状态类型、Host 字段渲染原语(
|
|
3
|
-
* SaveBar
|
|
2
|
+
* 设置面板共享层:页面状态类型、Host 字段渲染原语(SettingRow /
|
|
3
|
+
* FieldSections / SaveBar)、分区与列表原语、生命周期徽标与纯格式化工具。
|
|
4
4
|
*
|
|
5
5
|
* 这一层不发请求、不持有页面状态,只把 Host 下发的 wire 数据渲染成控件;
|
|
6
6
|
* 页面外壳在 `index.tsx`,各 tab 只组合这些原语。
|
|
@@ -9,9 +9,15 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { useState } from 'react'
|
|
12
|
-
import {
|
|
12
|
+
import type { ReactNode } from 'react'
|
|
13
|
+
import {
|
|
14
|
+
BYTES_UNIT,
|
|
15
|
+
COLLAPSED_FIELD_GROUPS,
|
|
16
|
+
FIELD_GROUP_LABELS,
|
|
17
|
+
fillCopy,
|
|
18
|
+
PANEL_COPY,
|
|
19
|
+
} from '../../settings/contract.ts'
|
|
13
20
|
import type {
|
|
14
|
-
AccessWire,
|
|
15
21
|
ChannelPhaseWire,
|
|
16
22
|
ConfigValue,
|
|
17
23
|
CredentialField,
|
|
@@ -20,9 +26,9 @@ import type {
|
|
|
20
26
|
EditableValues,
|
|
21
27
|
FieldGroup,
|
|
22
28
|
FieldIssueWire,
|
|
23
|
-
PanelTabId,
|
|
24
29
|
PanelWire,
|
|
25
30
|
} from '../../settings/contract.ts'
|
|
31
|
+
import { bytesReading, numberDraftValue, numberInputStep, numberInputValue } from '../number-field.ts'
|
|
26
32
|
import {
|
|
27
33
|
listIsFull,
|
|
28
34
|
listItems,
|
|
@@ -43,7 +49,7 @@ export type LoadState =
|
|
|
43
49
|
export interface Notice {
|
|
44
50
|
kind: 'ok' | 'error'
|
|
45
51
|
text: string
|
|
46
|
-
/** Per-field validation failures rendered inline by
|
|
52
|
+
/** Per-field validation failures rendered inline by SettingRow. */
|
|
47
53
|
issues?: FieldIssueWire[]
|
|
48
54
|
}
|
|
49
55
|
|
|
@@ -56,14 +62,14 @@ export type BusyAction =
|
|
|
56
62
|
| 'credentials-clear'
|
|
57
63
|
| 'access'
|
|
58
64
|
|
|
59
|
-
/** Tone of a small
|
|
65
|
+
/** Tone of a small pill; maps onto `dsh-channels-cfg__pill--*` classes. */
|
|
60
66
|
export type BadgeTone = 'ok' | 'warn' | 'bad' | 'muted'
|
|
61
67
|
|
|
62
68
|
export function messageOf(error: unknown): string {
|
|
63
69
|
return error instanceof Error ? error.message : String(error)
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
/** Compact duration for the
|
|
72
|
+
/** Compact duration for the page header. */
|
|
67
73
|
export function formatUptime(ms: number | undefined): string {
|
|
68
74
|
if (ms === undefined || !Number.isFinite(ms)) return PANEL_COPY.unknownValue
|
|
69
75
|
const totalSeconds = Math.max(0, Math.floor(ms / 1_000))
|
|
@@ -86,24 +92,9 @@ export function fieldLabel(fields: readonly EditableFieldWire[], key: EditableKe
|
|
|
86
92
|
return fields.find(field => field.key === key)?.label ?? key
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
/** Effective access-mode label, reusing the Host field options for consistency. */
|
|
90
|
-
export function accessModeLabel(panel: PanelWire, mode: AccessWire['mode']): string {
|
|
91
|
-
const field = panel.config.fields.find(item => item.key === 'accessMode')
|
|
92
|
-
return field?.options?.find(option => option.value === mode)?.label ?? mode
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Numeric draft kept as the raw input string; parsed on save. */
|
|
96
|
-
function numericDraft(field: EditableFieldWire, raw: ConfigValue | undefined): number | undefined {
|
|
97
|
-
if (typeof raw === 'number') return raw
|
|
98
|
-
if (typeof raw !== 'string' || raw.trim() === '') return undefined
|
|
99
|
-
const parsed = Number(raw)
|
|
100
|
-
return Number.isFinite(parsed) ? parsed : undefined
|
|
101
|
-
}
|
|
102
|
-
|
|
103
95
|
/**
|
|
104
96
|
* Turn the dirty draft into the wire payload, keeping the Host-descriptor
|
|
105
|
-
* bounds check client-side
|
|
106
|
-
* every value on save).
|
|
97
|
+
* bounds check client-side (the Host re-validates every value on save).
|
|
107
98
|
*/
|
|
108
99
|
export function validateDraft(
|
|
109
100
|
panel: PanelWire,
|
|
@@ -115,20 +106,9 @@ export function validateDraft(
|
|
|
115
106
|
if (!(field.key in draft)) continue
|
|
116
107
|
const raw = draft[field.key]
|
|
117
108
|
if (field.kind === 'number') {
|
|
118
|
-
const parsed =
|
|
119
|
-
if (parsed
|
|
120
|
-
|
|
121
|
-
continue
|
|
122
|
-
}
|
|
123
|
-
if (field.min !== undefined && parsed < field.min) {
|
|
124
|
-
issues.push({ key: field.key, message: `不得小于 ${field.min}` })
|
|
125
|
-
continue
|
|
126
|
-
}
|
|
127
|
-
if (field.max !== undefined && parsed > field.max) {
|
|
128
|
-
issues.push({ key: field.key, message: `不得大于 ${field.max}` })
|
|
129
|
-
continue
|
|
130
|
-
}
|
|
131
|
-
values[field.key] = parsed
|
|
109
|
+
const parsed = numberDraftValue(field, raw)
|
|
110
|
+
if (parsed.ok) values[field.key] = parsed.value
|
|
111
|
+
else issues.push({ key: field.key, message: parsed.message })
|
|
132
112
|
continue
|
|
133
113
|
}
|
|
134
114
|
if (field.kind === 'boolean') {
|
|
@@ -146,7 +126,7 @@ export function validateDraft(
|
|
|
146
126
|
return { values, issues }
|
|
147
127
|
}
|
|
148
128
|
|
|
149
|
-
/** Header
|
|
129
|
+
/** Header pill for the Host-derived lifecycle phase. */
|
|
150
130
|
export function phaseBadge(phase: ChannelPhaseWire): { text: string; tone: BadgeTone } {
|
|
151
131
|
switch (phase) {
|
|
152
132
|
case 'connected': return { text: PANEL_COPY.phaseConnected, tone: 'ok' }
|
|
@@ -157,7 +137,7 @@ export function phaseBadge(phase: ChannelPhaseWire): { text: string; tone: Badge
|
|
|
157
137
|
}
|
|
158
138
|
}
|
|
159
139
|
|
|
160
|
-
/** One-line explanation
|
|
140
|
+
/** One-line explanation of the current phase (connection tab status block). */
|
|
161
141
|
export function phaseSummary(phase: ChannelPhaseWire): string {
|
|
162
142
|
switch (phase) {
|
|
163
143
|
case 'connected': return PANEL_COPY.statusSummaryConnected
|
|
@@ -182,7 +162,7 @@ export interface CredentialsView {
|
|
|
182
162
|
onClear: (field: CredentialField) => void
|
|
183
163
|
}
|
|
184
164
|
|
|
185
|
-
/** Analyze API token editor data owned by the page shell, rendered by the
|
|
165
|
+
/** Analyze API token editor data owned by the page shell, rendered by the advanced tab. */
|
|
186
166
|
export interface AnalyzeTokenView {
|
|
187
167
|
value: string
|
|
188
168
|
issues: FieldIssueWire[]
|
|
@@ -204,19 +184,113 @@ export interface FormView {
|
|
|
204
184
|
busy: BusyAction | null
|
|
205
185
|
/** Host-side lifecycle busy (connecting/restarting): block writes. */
|
|
206
186
|
locked: boolean
|
|
207
|
-
dirtyCount: number
|
|
208
187
|
onChange: (key: EditableKey, value: ConfigValue) => void
|
|
209
|
-
onSave: () => void
|
|
210
|
-
/** Drop only the draft keys owned by one tab. */
|
|
211
|
-
onDiscardTab: (tab: PanelTabId) => void
|
|
212
188
|
/**
|
|
213
189
|
* Open the Host's native directory chooser and write the picked absolute
|
|
214
|
-
* path into the draft (not saved until
|
|
190
|
+
* path into the draft (not saved until「保存」). Absent when the Host
|
|
215
191
|
* cannot offer a native chooser.
|
|
216
192
|
*/
|
|
217
193
|
onPickDirectory?: (key: EditableKey) => Promise<void>
|
|
218
194
|
}
|
|
219
195
|
|
|
196
|
+
/** Small rounded status pill. */
|
|
197
|
+
export function Pill(props: { tone: BadgeTone; dot?: boolean; children: ReactNode }): JSX.Element {
|
|
198
|
+
return (
|
|
199
|
+
<span className={`dsh-channels-cfg__pill dsh-channels-cfg__pill--${props.tone}`}>
|
|
200
|
+
{props.dot === true ? <span className="dsh-channels-cfg__dot" /> : null}
|
|
201
|
+
{props.children}
|
|
202
|
+
</span>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** One titled section: a muted heading above a bordered row list. */
|
|
207
|
+
export function Section(props: {
|
|
208
|
+
title: ReactNode
|
|
209
|
+
footnote?: ReactNode
|
|
210
|
+
tone?: 'warn'
|
|
211
|
+
children: ReactNode
|
|
212
|
+
}): JSX.Element {
|
|
213
|
+
const { title, footnote, tone, children } = props
|
|
214
|
+
return (
|
|
215
|
+
<section className="dsh-channels-cfg__sec">
|
|
216
|
+
<h3 className="dsh-channels-cfg__secTitle">{title}</h3>
|
|
217
|
+
<div className={`dsh-channels-cfg__list${tone === 'warn' ? ' dsh-channels-cfg__list--warn' : ''}`}>
|
|
218
|
+
{children}
|
|
219
|
+
</div>
|
|
220
|
+
{footnote === undefined ? null : <p className="dsh-channels-cfg__secFoot">{footnote}</p>}
|
|
221
|
+
</section>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One setting row: label (with tags and the「详情」toggle) and a one-line
|
|
227
|
+
* summary on the left, the control on the right. `details` renders below the
|
|
228
|
+
* row once expanded; `wide` puts the control under the text.
|
|
229
|
+
*/
|
|
230
|
+
export function Row(props: {
|
|
231
|
+
label: ReactNode
|
|
232
|
+
tags?: ReactNode
|
|
233
|
+
summary?: ReactNode
|
|
234
|
+
details?: ReactNode
|
|
235
|
+
control?: ReactNode
|
|
236
|
+
error?: ReactNode
|
|
237
|
+
wide?: boolean
|
|
238
|
+
leading?: ReactNode
|
|
239
|
+
}): JSX.Element {
|
|
240
|
+
const { label, tags, summary, details, control, error, wide = false, leading } = props
|
|
241
|
+
const [open, setOpen] = useState(false)
|
|
242
|
+
return (
|
|
243
|
+
<div className="dsh-channels-cfg__rowWrap">
|
|
244
|
+
<div className={`dsh-channels-cfg__row${wide ? ' dsh-channels-cfg__row--wide' : ''}`}>
|
|
245
|
+
{leading}
|
|
246
|
+
<div className="dsh-channels-cfg__rowText">
|
|
247
|
+
<div className="dsh-channels-cfg__rowLabel">
|
|
248
|
+
<span>{label}</span>
|
|
249
|
+
{tags}
|
|
250
|
+
{details === undefined
|
|
251
|
+
? null
|
|
252
|
+
: (
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
className="dsh-channels-cfg__more"
|
|
256
|
+
aria-expanded={open}
|
|
257
|
+
onClick={() => setOpen(!open)}
|
|
258
|
+
>
|
|
259
|
+
{PANEL_COPY.details}
|
|
260
|
+
</button>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
{summary === undefined ? null : <div className="dsh-channels-cfg__rowSummary">{summary}</div>}
|
|
264
|
+
</div>
|
|
265
|
+
{control === undefined ? null : <div className="dsh-channels-cfg__rowCtl">{control}</div>}
|
|
266
|
+
</div>
|
|
267
|
+
{open && details !== undefined ? <div className="dsh-channels-cfg__details">{details}</div> : null}
|
|
268
|
+
{error === undefined || error === null ? null : <div className="dsh-channels-cfg__rowError">{error}</div>}
|
|
269
|
+
</div>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Accessible on/off switch. */
|
|
274
|
+
export function Switch(props: {
|
|
275
|
+
checked: boolean
|
|
276
|
+
label: string
|
|
277
|
+
disabled?: boolean
|
|
278
|
+
onChange: (next: boolean) => void
|
|
279
|
+
}): JSX.Element {
|
|
280
|
+
const { checked, label, disabled = false, onChange } = props
|
|
281
|
+
return (
|
|
282
|
+
<button
|
|
283
|
+
type="button"
|
|
284
|
+
role="switch"
|
|
285
|
+
className="dsh-channels-cfg__switch"
|
|
286
|
+
aria-checked={checked}
|
|
287
|
+
aria-label={label}
|
|
288
|
+
disabled={disabled}
|
|
289
|
+
onClick={() => onChange(!checked)}
|
|
290
|
+
/>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
220
294
|
/**
|
|
221
295
|
* Text input paired with the host-native「选择目录…」button. The button owns
|
|
222
296
|
* its own busy state: the OS dialog can stay open for a long time, so the
|
|
@@ -253,7 +327,7 @@ function DirectoryTextInput(props: {
|
|
|
253
327
|
/>
|
|
254
328
|
<button
|
|
255
329
|
type="button"
|
|
256
|
-
className="dsh-channels-cfg__btn
|
|
330
|
+
className="dsh-channels-cfg__btn"
|
|
257
331
|
disabled={disabled || picking}
|
|
258
332
|
onClick={() => { void pick() }}
|
|
259
333
|
>
|
|
@@ -265,7 +339,7 @@ function DirectoryTextInput(props: {
|
|
|
265
339
|
|
|
266
340
|
/**
|
|
267
341
|
* `string-list` control: one single-line input per entry, each with a remove
|
|
268
|
-
* button, and an add
|
|
342
|
+
* button, and an add link below (disabled at `maxItems` inputs). An empty
|
|
269
343
|
* list renders one blank input so the first entry can be typed directly.
|
|
270
344
|
* Issues carrying an `index` mark that input and show the reason under it.
|
|
271
345
|
*/
|
|
@@ -280,13 +354,13 @@ function StringListInput(props: {
|
|
|
280
354
|
const empty = listItems(value).length === 0
|
|
281
355
|
const rows = listRows(value)
|
|
282
356
|
return (
|
|
283
|
-
<div className="dsh-channels-
|
|
357
|
+
<div className="dsh-channels-cfg__stringList">
|
|
284
358
|
{rows.map((item, index) => {
|
|
285
359
|
const issue = issues.find(entry => entry.index === index)
|
|
286
360
|
const position = PANEL_COPY.listEntry.replace('{n}', String(index + 1))
|
|
287
361
|
// Entries have no identity beyond their position, so the index is the key.
|
|
288
362
|
return (
|
|
289
|
-
<div className="dsh-channels-
|
|
363
|
+
<div className="dsh-channels-cfg__stringListItem" key={index}>
|
|
290
364
|
<div className="dsh-channels-cfg__inputRow">
|
|
291
365
|
<input
|
|
292
366
|
type="text"
|
|
@@ -303,7 +377,7 @@ function StringListInput(props: {
|
|
|
303
377
|
: (
|
|
304
378
|
<button
|
|
305
379
|
type="button"
|
|
306
|
-
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--
|
|
380
|
+
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--remove"
|
|
307
381
|
aria-label={`${PANEL_COPY.listRemove} ${position}`}
|
|
308
382
|
disabled={disabled}
|
|
309
383
|
onClick={() => onChange(field.key, withoutListEntry(rows, index))}
|
|
@@ -319,254 +393,285 @@ function StringListInput(props: {
|
|
|
319
393
|
<div>
|
|
320
394
|
<button
|
|
321
395
|
type="button"
|
|
322
|
-
className="dsh-channels-
|
|
396
|
+
className="dsh-channels-cfg__link"
|
|
323
397
|
disabled={disabled || listIsFull(field, rows)}
|
|
324
398
|
onClick={() => onChange(field.key, withBlankListEntry(rows))}
|
|
325
399
|
>
|
|
326
|
-
{PANEL_COPY.listAdd}
|
|
400
|
+
+ {PANEL_COPY.listAdd}
|
|
327
401
|
</button>
|
|
328
402
|
</div>
|
|
329
403
|
</div>
|
|
330
404
|
)
|
|
331
405
|
}
|
|
332
406
|
|
|
333
|
-
/**
|
|
334
|
-
|
|
407
|
+
/** Number input with its unit (and a MiB reading for byte counts). */
|
|
408
|
+
function NumberInput(props: {
|
|
335
409
|
field: EditableFieldWire
|
|
336
410
|
value: ConfigValue | undefined
|
|
337
|
-
|
|
338
|
-
disabled
|
|
411
|
+
inputClass: string
|
|
412
|
+
disabled: boolean
|
|
339
413
|
onChange: (key: EditableKey, value: ConfigValue) => void
|
|
340
|
-
/** Present only for `field.browse === 'directory'` rows the page can serve. */
|
|
341
|
-
onPickDirectory?: () => Promise<void>
|
|
342
414
|
}): JSX.Element {
|
|
343
|
-
const { field, value,
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
const entryIssues = issues.filter(item => item.key === field.key && item.index !== undefined)
|
|
347
|
-
const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
|
|
348
|
-
const fieldClass = `dsh-channels-cfg__field${field.wide === true ? ' dsh-channels-cfg__field--wide' : ''}`
|
|
415
|
+
const { field, value, inputClass, disabled, onChange } = props
|
|
416
|
+
const text = numberInputValue(field, value)
|
|
417
|
+
const reading = field.unit === BYTES_UNIT ? bytesReading(text) : undefined
|
|
349
418
|
return (
|
|
350
|
-
<div className=
|
|
351
|
-
<
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
419
|
+
<div className="dsh-channels-cfg__inputRow">
|
|
420
|
+
{reading === undefined ? null : <span className="dsh-channels-cfg__bytes">{reading}</span>}
|
|
421
|
+
<input
|
|
422
|
+
type="number"
|
|
423
|
+
className={`${inputClass} dsh-channels-cfg__input--num`}
|
|
424
|
+
value={text}
|
|
425
|
+
placeholder={field.placeholder}
|
|
426
|
+
step={numberInputStep(field)}
|
|
427
|
+
aria-label={field.label}
|
|
428
|
+
disabled={disabled}
|
|
429
|
+
onChange={event => onChange(field.key, event.target.value)}
|
|
430
|
+
/>
|
|
431
|
+
{field.unit === undefined ? null : <span className="dsh-channels-cfg__unit">{field.unit}</span>}
|
|
432
|
+
</div>
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The control for one Host field descriptor. */
|
|
437
|
+
function FieldControl(props: {
|
|
438
|
+
field: EditableFieldWire
|
|
439
|
+
value: ConfigValue | undefined
|
|
440
|
+
issues: readonly FieldIssueWire[]
|
|
441
|
+
invalid: boolean
|
|
442
|
+
disabled: boolean
|
|
443
|
+
onChange: (key: EditableKey, value: ConfigValue) => void
|
|
444
|
+
onPickDirectory?: () => Promise<void>
|
|
445
|
+
}): JSX.Element | null {
|
|
446
|
+
const { field, value, issues, invalid, disabled, onChange, onPickDirectory } = props
|
|
447
|
+
const inputClass = `dsh-channels-cfg__input${invalid ? ' dsh-channels-cfg__input--invalid' : ''}`
|
|
448
|
+
switch (field.kind) {
|
|
449
|
+
case 'boolean':
|
|
450
|
+
return <Switch checked={value === true} label={field.label} disabled={disabled} onChange={next => onChange(field.key, next)} />
|
|
451
|
+
case 'select': {
|
|
452
|
+
const toggle = field.toggle
|
|
453
|
+
if (toggle !== undefined) {
|
|
454
|
+
return (
|
|
455
|
+
<Switch
|
|
456
|
+
checked={value === toggle.on}
|
|
457
|
+
label={field.label}
|
|
458
|
+
disabled={disabled}
|
|
459
|
+
onChange={next => onChange(field.key, next ? toggle.on : toggle.off)}
|
|
460
|
+
/>
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
return (
|
|
464
|
+
<select
|
|
465
|
+
className={`${inputClass} dsh-channels-cfg__select`}
|
|
466
|
+
value={String(value ?? '')}
|
|
467
|
+
disabled={disabled || field.disabled === true}
|
|
468
|
+
aria-label={field.label}
|
|
469
|
+
onChange={event => onChange(field.key, event.target.value)}
|
|
470
|
+
>
|
|
471
|
+
{(field.options ?? []).map(option => (
|
|
472
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
473
|
+
))}
|
|
474
|
+
</select>
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
case 'number':
|
|
478
|
+
return <NumberInput field={field} value={value} inputClass={inputClass} disabled={disabled} onChange={onChange} />
|
|
479
|
+
case 'text':
|
|
480
|
+
return field.browse === 'directory' && onPickDirectory !== undefined
|
|
373
481
|
? (
|
|
374
|
-
<
|
|
375
|
-
|
|
376
|
-
value={
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
onChange={
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
<option key={option.value} value={option.value}>{option.label}</option>
|
|
383
|
-
))}
|
|
384
|
-
</select>
|
|
482
|
+
<DirectoryTextInput
|
|
483
|
+
field={field}
|
|
484
|
+
value={value}
|
|
485
|
+
inputClass={inputClass}
|
|
486
|
+
disabled={disabled}
|
|
487
|
+
onChange={onChange}
|
|
488
|
+
onPick={onPickDirectory}
|
|
489
|
+
/>
|
|
385
490
|
)
|
|
386
|
-
:
|
|
387
|
-
{field.kind === 'number'
|
|
388
|
-
? (
|
|
491
|
+
: (
|
|
389
492
|
<input
|
|
390
|
-
type="
|
|
391
|
-
className={inputClass}
|
|
392
|
-
value={value
|
|
393
|
-
|
|
394
|
-
max={field.max}
|
|
395
|
-
step={field.step}
|
|
493
|
+
type="text"
|
|
494
|
+
className={`${inputClass}${field.wide === true ? '' : ' dsh-channels-cfg__input--text'}`}
|
|
495
|
+
value={String(value ?? '')}
|
|
496
|
+
placeholder={field.placeholder}
|
|
396
497
|
aria-label={field.label}
|
|
397
498
|
disabled={disabled}
|
|
398
499
|
onChange={event => onChange(field.key, event.target.value)}
|
|
399
500
|
/>
|
|
400
501
|
)
|
|
401
|
-
|
|
402
|
-
{field
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
{field.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
502
|
+
case 'string-list':
|
|
503
|
+
return <StringListInput field={field} value={value} issues={issues} disabled={disabled} onChange={onChange} />
|
|
504
|
+
default:
|
|
505
|
+
// Unknown kinds come from a newer Host; render nothing instead of a broken control.
|
|
506
|
+
return null
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** One Host field rendered as a setting row. */
|
|
511
|
+
export function SettingRow(props: {
|
|
512
|
+
field: EditableFieldWire
|
|
513
|
+
value: ConfigValue | undefined
|
|
514
|
+
issues: readonly FieldIssueWire[]
|
|
515
|
+
disabled?: boolean
|
|
516
|
+
onChange: (key: EditableKey, value: ConfigValue) => void
|
|
517
|
+
/** Present only for `field.browse === 'directory'` rows the page can serve. */
|
|
518
|
+
onPickDirectory?: () => Promise<void>
|
|
519
|
+
}): JSX.Element {
|
|
520
|
+
const { field, value, issues, disabled = false, onChange, onPickDirectory } = props
|
|
521
|
+
// Entry issues (`index` set) are rendered next to their input by StringListInput.
|
|
522
|
+
const issue = issues.find(item => item.key === field.key && item.index === undefined)
|
|
523
|
+
const entryIssues = issues.filter(item => item.key === field.key && item.index !== undefined)
|
|
524
|
+
return (
|
|
525
|
+
<Row
|
|
526
|
+
label={field.label}
|
|
527
|
+
tags={field.restartRequired ? <span className="dsh-channels-cfg__tag">{PANEL_COPY.restartTag}</span> : undefined}
|
|
528
|
+
summary={field.summary}
|
|
529
|
+
details={field.help}
|
|
530
|
+
wide={field.wide === true}
|
|
531
|
+
error={issue?.message}
|
|
532
|
+
control={(
|
|
533
|
+
<FieldControl
|
|
534
|
+
field={field}
|
|
535
|
+
value={value}
|
|
536
|
+
issues={entryIssues}
|
|
537
|
+
invalid={issue !== undefined}
|
|
538
|
+
disabled={disabled}
|
|
539
|
+
onChange={onChange}
|
|
540
|
+
onPickDirectory={onPickDirectory}
|
|
541
|
+
/>
|
|
542
|
+
)}
|
|
543
|
+
/>
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Rows for every field of one group. */
|
|
548
|
+
function GroupRows(props: { form: FormView; fields: readonly EditableFieldWire[] }): JSX.Element {
|
|
549
|
+
const { form, fields } = props
|
|
550
|
+
return (
|
|
551
|
+
<>
|
|
552
|
+
{fields.map((field) => {
|
|
553
|
+
const onPick = form.onPickDirectory
|
|
554
|
+
return (
|
|
555
|
+
<SettingRow
|
|
556
|
+
key={field.key}
|
|
557
|
+
field={field}
|
|
558
|
+
value={form.draft[field.key] ?? effectiveValue(form.panel, field.key)}
|
|
559
|
+
issues={form.issues}
|
|
560
|
+
disabled={form.locked}
|
|
561
|
+
onChange={form.onChange}
|
|
562
|
+
onPickDirectory={onPick === undefined ? undefined : () => onPick(field.key)}
|
|
563
|
+
/>
|
|
564
|
+
)
|
|
565
|
+
})}
|
|
566
|
+
</>
|
|
440
567
|
)
|
|
441
568
|
}
|
|
442
569
|
|
|
443
570
|
/**
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
* every key out of a group does not leave a stray heading behind.
|
|
571
|
+
* A group of rarely changed fields behind one summary row. It opens on its
|
|
572
|
+
* own when one of its fields has a validation issue.
|
|
447
573
|
*/
|
|
448
|
-
|
|
574
|
+
function CollapsedGroup(props: { form: FormView; fields: readonly EditableFieldWire[] }): JSX.Element {
|
|
575
|
+
const { form, fields } = props
|
|
576
|
+
const [open, setOpen] = useState(false)
|
|
577
|
+
const keys = new Set<string>(fields.map(field => field.key))
|
|
578
|
+
const changed = fields.filter(field =>
|
|
579
|
+
form.panel.config.overriddenKeys.includes(field.key) || field.key in form.draft).length
|
|
580
|
+
const expanded = open || form.issues.some(issue => keys.has(issue.key))
|
|
581
|
+
return (
|
|
582
|
+
<>
|
|
583
|
+
<Row
|
|
584
|
+
label={fillCopy(PANEL_COPY.collapsedCount, { n: fields.length })}
|
|
585
|
+
summary={changed === 0 ? PANEL_COPY.collapsedClean : fillCopy(PANEL_COPY.collapsedChanged, { n: changed })}
|
|
586
|
+
control={(
|
|
587
|
+
<button type="button" className="dsh-channels-cfg__more" aria-expanded={expanded} onClick={() => setOpen(!expanded)}>
|
|
588
|
+
{expanded ? PANEL_COPY.collapse : PANEL_COPY.expand}
|
|
589
|
+
</button>
|
|
590
|
+
)}
|
|
591
|
+
/>
|
|
592
|
+
{expanded ? <GroupRows form={form} fields={fields} /> : null}
|
|
593
|
+
</>
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* One tab's editable fields as titled sections, one per Host field group, in
|
|
599
|
+
* the given order. Empty groups render nothing, so a Host that moves every key
|
|
600
|
+
* out of a group does not leave a stray heading behind. `extra` appends rows
|
|
601
|
+
* that are not Host fields (e.g. a credential) to one group's list.
|
|
602
|
+
*/
|
|
603
|
+
export function FieldSections(props: {
|
|
449
604
|
form: FormView
|
|
450
605
|
groups: readonly FieldGroup[]
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
group,
|
|
456
|
-
label: FIELD_GROUP_LABELS[group],
|
|
457
|
-
fields: form.fields.filter(field => field.group === group),
|
|
458
|
-
}))
|
|
459
|
-
.filter(section => section.fields.length > 0)
|
|
460
|
-
if (sections.length === 0) return null
|
|
606
|
+
extra?: Partial<Record<FieldGroup, ReactNode>>
|
|
607
|
+
footnotes?: Partial<Record<FieldGroup, ReactNode>>
|
|
608
|
+
}): JSX.Element {
|
|
609
|
+
const { form, groups, extra = {}, footnotes = {} } = props
|
|
461
610
|
return (
|
|
462
|
-
|
|
463
|
-
{
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
<
|
|
469
|
-
{
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
disabled={form.locked}
|
|
478
|
-
onChange={form.onChange}
|
|
479
|
-
onPickDirectory={onPick === undefined ? undefined : () => onPick(field.key)}
|
|
480
|
-
/>
|
|
481
|
-
)
|
|
482
|
-
})}
|
|
483
|
-
</div>
|
|
484
|
-
</div>
|
|
485
|
-
))}
|
|
486
|
-
</section>
|
|
611
|
+
<>
|
|
612
|
+
{groups.map((group) => {
|
|
613
|
+
const fields = form.fields.filter(field => field.group === group)
|
|
614
|
+
const more = extra[group]
|
|
615
|
+
if (fields.length === 0 && more === undefined) return null
|
|
616
|
+
return (
|
|
617
|
+
<Section key={group} title={FIELD_GROUP_LABELS[group]} footnote={footnotes[group]}>
|
|
618
|
+
{COLLAPSED_FIELD_GROUPS.includes(group)
|
|
619
|
+
? <CollapsedGroup form={form} fields={fields} />
|
|
620
|
+
: <GroupRows form={form} fields={fields} />}
|
|
621
|
+
{more}
|
|
622
|
+
</Section>
|
|
623
|
+
)
|
|
624
|
+
})}
|
|
625
|
+
</>
|
|
487
626
|
)
|
|
488
627
|
}
|
|
489
628
|
|
|
490
|
-
/**
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
629
|
+
/**
|
|
630
|
+
* Sticky bar shown only while the draft has unsaved keys. Saving submits the
|
|
631
|
+
* whole draft (every tab); discarding drops it.
|
|
632
|
+
*/
|
|
633
|
+
export function SaveBar(props: {
|
|
634
|
+
dirtyCount: number
|
|
635
|
+
restartCount: number
|
|
636
|
+
busy: BusyAction | null
|
|
637
|
+
locked: boolean
|
|
638
|
+
onSave: () => void
|
|
639
|
+
onDiscard: () => void
|
|
640
|
+
}): JSX.Element | null {
|
|
641
|
+
const { dirtyCount, restartCount, busy, locked, onSave, onDiscard } = props
|
|
642
|
+
if (dirtyCount === 0) return null
|
|
643
|
+
const disabled = busy !== null || locked
|
|
499
644
|
return (
|
|
500
|
-
<div className="dsh-channels-
|
|
501
|
-
<
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
{form.busy === 'save' ? PANEL_COPY.saving : PANEL_COPY.save}
|
|
645
|
+
<div className="dsh-channels-cfg__saveBar" role="region" aria-label={PANEL_COPY.save}>
|
|
646
|
+
<span className="dsh-channels-cfg__saveMsg">
|
|
647
|
+
{fillCopy(PANEL_COPY.dirtyCount, { n: dirtyCount })}
|
|
648
|
+
{restartCount === 0 ? '' : fillCopy(PANEL_COPY.dirtyRestart, { n: restartCount })}
|
|
649
|
+
</span>
|
|
650
|
+
<button type="button" className="dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost" onClick={onDiscard} disabled={disabled}>
|
|
651
|
+
{PANEL_COPY.discard}
|
|
508
652
|
</button>
|
|
509
|
-
<button
|
|
510
|
-
|
|
511
|
-
className="dsh-channels-cfg__btn"
|
|
512
|
-
onClick={() => form.onDiscardTab(tab)}
|
|
513
|
-
disabled={pageDirty === 0 || form.busy !== null || form.locked}
|
|
514
|
-
>
|
|
515
|
-
{PANEL_COPY.discardTab}
|
|
653
|
+
<button type="button" className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary" onClick={onSave} disabled={disabled}>
|
|
654
|
+
{busy === 'save' ? PANEL_COPY.saving : PANEL_COPY.save}
|
|
516
655
|
</button>
|
|
517
|
-
<span className="dsh-channels-cfg__spacer" />
|
|
518
|
-
{pageDirty > 0
|
|
519
|
-
? (
|
|
520
|
-
<span className="dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty">
|
|
521
|
-
{PANEL_COPY.dirtyCount} {pageDirty}
|
|
522
|
-
</span>
|
|
523
|
-
)
|
|
524
|
-
: dirty
|
|
525
|
-
? (
|
|
526
|
-
<span className="dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty">
|
|
527
|
-
{PANEL_COPY.dirtyCount} {form.dirtyCount}(其他标签页)
|
|
528
|
-
</span>
|
|
529
|
-
)
|
|
530
|
-
: null}
|
|
531
|
-
<span className="dsh-channels-cfg__help">
|
|
532
|
-
{dirty ? PANEL_COPY.saveHintDirty : PANEL_COPY.saveHintClean}
|
|
533
|
-
</span>
|
|
534
656
|
</div>
|
|
535
657
|
)
|
|
536
658
|
}
|
|
537
659
|
|
|
538
|
-
/**
|
|
539
|
-
export function
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/** Transient page-level banner (save / credentials / reconnect / catalog). */
|
|
544
|
-
export function NoticeBar(props: { notice: Notice }): JSX.Element {
|
|
545
|
-
const { notice } = props
|
|
660
|
+
/** Transient banner (save / credentials / reconnect / catalog / access). */
|
|
661
|
+
export function NoticeBar(props: { kind: 'ok' | 'error'; text: string }): JSX.Element {
|
|
662
|
+
const { kind, text } = props
|
|
546
663
|
return (
|
|
547
664
|
<div
|
|
548
|
-
className={`dsh-channels-cfg__notice dsh-channels-cfg__notice--${
|
|
549
|
-
role=
|
|
665
|
+
className={`dsh-channels-cfg__notice dsh-channels-cfg__notice--${kind}`}
|
|
666
|
+
role={kind === 'error' ? 'alert' : 'status'}
|
|
550
667
|
aria-live="polite"
|
|
551
668
|
>
|
|
552
|
-
|
|
669
|
+
{text}
|
|
553
670
|
</div>
|
|
554
671
|
)
|
|
555
672
|
}
|
|
556
673
|
|
|
557
|
-
/** Muted
|
|
558
|
-
export function
|
|
559
|
-
return <div className="dsh-channels-
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/** Human label for the official SDK's WebSocket lifecycle state. */
|
|
563
|
-
export function connectionStateLabel(state: string | undefined): { text: string; tone: BadgeTone } {
|
|
564
|
-
switch (state) {
|
|
565
|
-
case 'connected': return { text: PANEL_COPY.phaseConnected, tone: 'ok' }
|
|
566
|
-
case 'connecting': return { text: PANEL_COPY.phaseConnecting, tone: 'warn' }
|
|
567
|
-
case 'reconnecting': return { text: PANEL_COPY.phaseConnecting, tone: 'warn' }
|
|
568
|
-
case 'failed': return { text: PANEL_COPY.phaseError, tone: 'bad' }
|
|
569
|
-
case 'idle': return { text: PANEL_COPY.phaseIdle, tone: 'muted' }
|
|
570
|
-
default: return { text: state ?? PANEL_COPY.unknownValue, tone: 'muted' }
|
|
571
|
-
}
|
|
674
|
+
/** Muted row inside a list (empty or not-yet-loaded state). */
|
|
675
|
+
export function EmptyRow(props: { text: string }): JSX.Element {
|
|
676
|
+
return <div className="dsh-channels-cfg__emptyRow">{props.text}</div>
|
|
572
677
|
}
|