@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,6 +1,6 @@
1
1
  /**
2
- * 设置面板共享层:页面状态类型、Host 字段渲染原语(FieldRow / FieldsCard /
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 { FIELD_GROUP_LABELS, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
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 FieldRow. */
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 badge; maps onto `dsh-channels-cfg__badge--*` classes. */
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 connection card. */
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. Field messages stay technical (the Host re-validates
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 = numericDraft(field, raw)
119
- if (parsed === undefined) {
120
- issues.push({ key: field.key, message: '请输入数字' })
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 badge for the Host-derived lifecycle phase. */
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 shown under the page title for the current phase. */
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 sessions tab. */
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「保存更改」). Absent when the Host
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 dsh-channels-cfg__btn--browse"
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 button below (disabled at `maxItems` inputs). An empty
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-cfg__list">
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-cfg__listItem" key={index}>
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--listRemove"
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-cfg__btn"
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
- /** One generic form control driven by the Host field descriptor. */
334
- export function FieldRow(props: {
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
- issues: FieldIssueWire[]
338
- disabled?: boolean
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, issues, disabled = false, onChange, onPickDirectory } = props
344
- // Entry issues (`index` set) are rendered next to their input by StringListInput.
345
- const issue = issues.find(item => item.key === field.key && item.index === undefined)
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={fieldClass}>
351
- <div className="dsh-channels-cfg__labelRow">
352
- <span className="dsh-channels-cfg__label">{field.label}</span>
353
- {field.restartRequired
354
- ? <span className="dsh-channels-cfg__tag dsh-channels-cfg__tag--restart">{PANEL_COPY.restartTag}</span>
355
- : null}
356
- </div>
357
- {field.kind === 'boolean'
358
- ? (
359
- <label className="dsh-channels-cfg__check">
360
- <input
361
- type="checkbox"
362
- className="dsh-channels-cfg__checkbox"
363
- checked={value === true}
364
- aria-label={field.label}
365
- disabled={disabled}
366
- onChange={event => onChange(field.key, event.target.checked)}
367
- />
368
- <span>{value === true ? PANEL_COPY.enabled : PANEL_COPY.disabled}</span>
369
- </label>
370
- )
371
- : null}
372
- {field.kind === 'select'
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
- <select
375
- className={inputClass}
376
- value={String(value ?? '')}
377
- disabled={disabled || field.disabled === true}
378
- aria-label={field.label}
379
- onChange={event => onChange(field.key, event.target.value)}
380
- >
381
- {(field.options ?? []).map(option => (
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
- : null}
387
- {field.kind === 'number'
388
- ? (
491
+ : (
389
492
  <input
390
- type="number"
391
- className={inputClass}
392
- value={value === undefined || value === '' ? '' : String(value)}
393
- min={field.min}
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
- : null}
402
- {field.kind === 'text'
403
- ? (field.browse === 'directory' && onPickDirectory !== undefined
404
- ? (
405
- <DirectoryTextInput
406
- field={field}
407
- value={value}
408
- inputClass={inputClass}
409
- disabled={disabled}
410
- onChange={onChange}
411
- onPick={onPickDirectory}
412
- />
413
- )
414
- : (
415
- <input
416
- type="text"
417
- className={inputClass}
418
- value={String(value ?? '')}
419
- placeholder={field.placeholder}
420
- aria-label={field.label}
421
- disabled={disabled}
422
- onChange={event => onChange(field.key, event.target.value)}
423
- />
424
- ))
425
- : null}
426
- {field.kind === 'string-list'
427
- ? (
428
- <StringListInput
429
- field={field}
430
- value={value}
431
- issues={entryIssues}
432
- disabled={disabled}
433
- onChange={onChange}
434
- />
435
- )
436
- : null}
437
- {field.help === undefined ? null : <span className="dsh-channels-cfg__help">{field.help}</span>}
438
- {issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
439
- </div>
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
- * One tab's editable fields, partitioned by the Host field groups that the tab
445
- * owns (`TAB_FIELD_GROUPS`). Empty groups render nothing, so a Host that moves
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
- export function FieldsCard(props: {
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
- }): JSX.Element | null {
452
- const { form, groups } = props
453
- const sections = groups
454
- .map(group => ({
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
- <section className="dsh-channels-cfg__card">
463
- {sections.map(section => (
464
- <div className="dsh-channels-cfg__group" key={section.group}>
465
- <div className="dsh-channels-cfg__labelRow">
466
- <span className="dsh-channels-cfg__cardTitle">{section.label}</span>
467
- </div>
468
- <div className="dsh-channels-cfg__grid">
469
- {section.fields.map((field) => {
470
- const onPick = form.onPickDirectory
471
- return (
472
- <FieldRow
473
- key={field.key}
474
- field={field}
475
- value={form.draft[field.key] ?? effectiveValue(form.panel, field.key)}
476
- issues={form.issues}
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
- /** The unified per-tab action bar: save every dirty field or drop the draft. */
491
- export function SaveBar(props: { form: FormView; tab: PanelTabId }): JSX.Element {
492
- const { form, tab } = props
493
- const groups = new Set<string>(TAB_FIELD_GROUPS[tab])
494
- const pageDirty = form.fields.filter(field => groups.has(field.group) && field.key in form.draft).length
495
- // Saving submits the whole draft, so any dirty key (any tab) enables it;
496
- // discarding only clears this tab, so it follows this tab's own count.
497
- const dirty = form.dirtyCount > 0
498
- const disabled = !dirty || form.busy !== null || form.locked
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-cfg__footer">
501
- <button
502
- type="button"
503
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
504
- onClick={form.onSave}
505
- disabled={disabled}
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
- type="button"
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
- /** Muted help paragraph introducing one tab. */
539
- export function TabHelp(props: { text: string }): JSX.Element {
540
- return <p className="dsh-channels-cfg__tabHelp">{props.text}</p>
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--${notice.kind === 'ok' ? 'ok' : 'error'}`}
549
- role="status"
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
- <span>{notice.text}</span>
669
+ {text}
553
670
  </div>
554
671
  )
555
672
  }
556
673
 
557
- /** Muted empty/blocked state (e.g. access lists before the channel connects). */
558
- export function EmptyState(props: { text: string }): JSX.Element {
559
- return <div className="dsh-channels-cfg__emptyState">{props.text}</div>
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
  }