@modesto_ma/dsh-channels 0.1.0-beta.1 → 0.2.0

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.
@@ -1,1475 +0,0 @@
1
- /**
2
- * 设置 →「飞书渠道」页面。
3
- *
4
- * 数据全部来自 Host 的 `/dsh-channels/*` 路由:状态卡每 5 秒轮询一次,
5
- * 常用配置表单由 Host 下发的字段描述(`fields`)通用渲染,Host 是唯一的
6
- * 校验与持久化方。组件不 import 任何 DSH 客户端包,只依赖 react 与平台
7
- * 的 `settings.section` 槽位。
8
- *
9
- * @module dsh-channels/client/panel
10
- */
11
-
12
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
13
- import { PANEL_COPY } from '../settings/contract.ts'
14
- import type {
15
- AccessGroupWire,
16
- AccessRequestWire,
17
- AccessUserWire,
18
- AccessWire,
19
- CatalogWire,
20
- ChannelStatusWire,
21
- CleanupPreviewWire,
22
- CleanupRunWire,
23
- ConfigValue,
24
- CredentialField,
25
- CredentialRefWire,
26
- EditableFieldWire,
27
- EditableKey,
28
- EditableValues,
29
- FieldIssueWire,
30
- PanelWire,
31
- } from '../settings/contract.ts'
32
- import {
33
- addAccessGroup,
34
- addAccessUser,
35
- clearConfig,
36
- clearCredential,
37
- fetchCatalog,
38
- fetchPanel,
39
- fetchStatus,
40
- reconnect,
41
- removeAccessGroup,
42
- removeAccessUser,
43
- resolveAccessRequest,
44
- runCleanup,
45
- saveConfig,
46
- saveCredentials,
47
- } from './api.ts'
48
- import { CATALOG_KEYS, applyCatalogFields, catalogFromFields } from './catalog.ts'
49
-
50
- /** Owner props handed to a `settings.section` contribution (unused beyond parity). */
51
- export interface FeishuChannelsSectionProps {
52
- close?: () => void
53
- }
54
-
55
- type LoadState =
56
- | { phase: 'loading' }
57
- | { phase: 'error'; message: string }
58
- | { phase: 'ready'; panel: PanelWire }
59
-
60
- type Notice = { kind: 'ok' | 'error'; text: string; issues?: FieldIssueWire[] }
61
-
62
- /** One in-flight panel mutation (only one button is disabled at a time). */
63
- type BusyAction = 'save' | 'clear' | 'reconnect' | 'credentials' | 'credentials-clear' | 'cleanup' | 'access'
64
-
65
- const GROUP_ORDER = ['workspace', 'behavior', 'content', 'model', 'streaming'] as const
66
- const GROUP_LABELS: Record<(typeof GROUP_ORDER)[number], string> = {
67
- workspace: '工作区与文件',
68
- behavior: '行为',
69
- content: '内容与附件',
70
- model: '模型',
71
- streaming: '流式卡片',
72
- }
73
-
74
- function messageOf(error: unknown): string {
75
- return error instanceof Error ? error.message : String(error)
76
- }
77
-
78
- /** Human-readable byte size for the cleanup preview/result (base-2 units). */
79
- function formatBytes(bytes: number): string {
80
- if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
81
- const units = ['B', 'KB', 'MB', 'GB', 'TB']
82
- let value = bytes
83
- let unit = 0
84
- while (value >= 1024 && unit < units.length - 1) {
85
- value /= 1024
86
- unit += 1
87
- }
88
- const digits = unit === 0 || value >= 10 ? 0 : 1
89
- return `${value.toFixed(digits)} ${units[unit] ?? 'B'}`
90
- }
91
-
92
- /** Display label for the effective retention value, reusing the Host field options. */
93
- function retentionLabel(panel: PanelWire, cleanup: CleanupPreviewWire): string {
94
- const field = panel.config.fields.find(item => item.key === 'workspaceRetention')
95
- return field?.options?.find(option => option.value === cleanup.retention)?.label
96
- ?? (cleanup.retention === 'off' ? PANEL_COPY.cleanupRetentionOff : cleanup.retention)
97
- }
98
-
99
- function formatUptime(ms: number | undefined): string {
100
- if (ms === undefined || !Number.isFinite(ms)) return '—'
101
- const totalSeconds = Math.max(0, Math.floor(ms / 1_000))
102
- const days = Math.floor(totalSeconds / 86_400)
103
- const hours = Math.floor((totalSeconds % 86_400) / 3_600)
104
- const minutes = Math.floor((totalSeconds % 3_600) / 60)
105
- if (days > 0) return `${days} 天 ${hours} 小时`
106
- if (hours > 0) return `${hours} 小时 ${minutes} 分`
107
- if (minutes > 0) return `${minutes} 分 ${totalSeconds % 60} 秒`
108
- return `${totalSeconds} 秒`
109
- }
110
-
111
- function connectionLabel(state: string | undefined): { text: string; tone: 'ok' | 'warn' | 'bad' | 'muted' } {
112
- switch (state) {
113
- case 'connected': return { text: '已连接', tone: 'ok' }
114
- case 'connecting': return { text: '连接中', tone: 'warn' }
115
- case 'reconnecting': return { text: '重连中', tone: 'warn' }
116
- case 'failed': return { text: '连接失败', tone: 'bad' }
117
- case 'idle': return { text: '未连接', tone: 'muted' }
118
- default: return { text: state ?? '未知', tone: 'muted' }
119
- }
120
- }
121
-
122
- function statusBadge(status: ChannelStatusWire): { text: string; tone: 'ok' | 'warn' | 'bad' | 'muted' } {
123
- if (status.busy) return { text: '重连中', tone: 'warn' }
124
- if (!status.running) return { text: '未运行', tone: 'bad' }
125
- if (status.level === 'healthy') return { text: '运行中 · 健康', tone: 'ok' }
126
- if (status.level === 'degraded') return { text: '运行中 · 降级', tone: 'warn' }
127
- return { text: '运行中 · 异常', tone: 'bad' }
128
- }
129
-
130
- /** Local `YYYY-MM-DD HH:mm` rendering for authorization timestamps. */
131
- function formatAccessDate(ms: number): string {
132
- if (!Number.isFinite(ms) || ms <= 0) return '—'
133
- const date = new Date(ms)
134
- const pad = (value: number): string => String(value).padStart(2, '0')
135
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
136
- }
137
-
138
- /** Which authorization entry produced one grant. */
139
- function accessSourceLabel(source: AccessGroupWire['source']): string {
140
- switch (source) {
141
- case 'command': return PANEL_COPY.accessSourceCommand
142
- case 'card': return PANEL_COPY.accessSourceCard
143
- default: return PANEL_COPY.accessSourcePanel
144
- }
145
- }
146
-
147
- /** Effective access-mode label, reusing the Host field options for consistency. */
148
- function accessModeLabel(panel: PanelWire, mode: AccessWire['mode']): string {
149
- const field = panel.config.fields.find(item => item.key === 'accessMode')
150
- return field?.options?.find(option => option.value === mode)?.label ?? mode
151
- }
152
-
153
- function fieldLabel(fields: readonly EditableFieldWire[], key: EditableKey): string {
154
- return fields.find(field => field.key === key)?.label ?? key
155
- }
156
-
157
- function effectiveValue(panel: PanelWire, key: EditableKey): ConfigValue | undefined {
158
- return panel.config.effective[key] ?? panel.config.base[key]
159
- }
160
-
161
- /** Numeric draft kept as the raw input string; parsed on save. */
162
- function numericDraft(field: EditableFieldWire, raw: ConfigValue | undefined): number | undefined {
163
- if (typeof raw === 'number') return raw
164
- if (typeof raw !== 'string' || raw.trim() === '') return undefined
165
- const parsed = Number(raw)
166
- return Number.isFinite(parsed) ? parsed : undefined
167
- }
168
-
169
- function validateDraft(
170
- panel: PanelWire,
171
- draft: EditableValues,
172
- only?: ReadonlySet<EditableKey>,
173
- ): { values: EditableValues; issues: FieldIssueWire[] } {
174
- const values: EditableValues = {}
175
- const issues: FieldIssueWire[] = []
176
- for (const field of panel.config.fields) {
177
- if (!(field.key in draft)) continue
178
- if (only !== undefined && !only.has(field.key)) continue
179
- const raw = draft[field.key]
180
- if (field.kind === 'number') {
181
- const parsed = numericDraft(field, raw)
182
- if (parsed === undefined) {
183
- issues.push({ key: field.key, message: '请输入数字' })
184
- continue
185
- }
186
- if (field.min !== undefined && parsed < field.min) {
187
- issues.push({ key: field.key, message: `不得小于 ${field.min}` })
188
- continue
189
- }
190
- if (field.max !== undefined && parsed > field.max) {
191
- issues.push({ key: field.key, message: `不得大于 ${field.max}` })
192
- continue
193
- }
194
- values[field.key] = parsed
195
- continue
196
- }
197
- if (field.kind === 'boolean') {
198
- values[field.key] = raw === true
199
- continue
200
- }
201
- values[field.key] = String(raw ?? '')
202
- }
203
- return { values, issues }
204
- }
205
-
206
- /** One generic form control driven by the Host field descriptor. */
207
- function FieldRow(props: {
208
- field: EditableFieldWire
209
- value: ConfigValue | undefined
210
- issues: FieldIssueWire[]
211
- onChange: (key: EditableKey, value: ConfigValue) => void
212
- }) {
213
- const { field, value, issues, onChange } = props
214
- const issue = issues.find(item => item.key === field.key)
215
- const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
216
- const fieldClass = `dsh-channels-cfg__field${field.wide === true ? ' dsh-channels-cfg__field--wide' : ''}`
217
- return (
218
- <div className={fieldClass}>
219
- <div className="dsh-channels-cfg__labelRow">
220
- <span className="dsh-channels-cfg__label">{field.label}</span>
221
- {field.restartRequired ? <span className="dsh-channels-cfg__tag dsh-channels-cfg__tag--restart">{PANEL_COPY.restartTag}</span> : null}
222
- </div>
223
- {field.kind === 'boolean'
224
- ? (
225
- <label className="dsh-channels-cfg__check">
226
- <input
227
- type="checkbox"
228
- className="dsh-channels-cfg__checkbox"
229
- checked={value === true}
230
- onChange={event => onChange(field.key, event.target.checked)}
231
- />
232
- <span>{value === true ? '已启用' : '已关闭'}</span>
233
- </label>
234
- )
235
- : null}
236
- {field.kind === 'select'
237
- ? (
238
- <select
239
- className={inputClass}
240
- value={String(value ?? '')}
241
- disabled={field.disabled === true}
242
- onChange={event => onChange(field.key, event.target.value)}
243
- >
244
- {(field.options ?? []).map(option => (
245
- <option key={option.value} value={option.value}>{option.label}</option>
246
- ))}
247
- </select>
248
- )
249
- : null}
250
- {field.kind === 'number'
251
- ? (
252
- <input
253
- type="number"
254
- className={inputClass}
255
- value={value === undefined || value === '' ? '' : String(value)}
256
- min={field.min}
257
- max={field.max}
258
- step={field.step}
259
- onChange={event => onChange(field.key, event.target.value)}
260
- />
261
- )
262
- : null}
263
- {field.kind === 'text'
264
- ? (
265
- <input
266
- type="text"
267
- className={inputClass}
268
- value={String(value ?? '')}
269
- placeholder={field.placeholder}
270
- onChange={event => onChange(field.key, event.target.value)}
271
- />
272
- )
273
- : null}
274
- {field.help === undefined ? null : <span className="dsh-channels-cfg__help">{field.help}</span>}
275
- {issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
276
- </div>
277
- )
278
- }
279
-
280
- /** Status card: badge, connection facts, and the two panel actions. */
281
- function StatusCard(props: {
282
- status: ChannelStatusWire
283
- pollError: string | null
284
- busy: string | null
285
- onRefresh: () => void
286
- onReconnect: () => void
287
- }) {
288
- const { status, pollError, busy, onRefresh, onReconnect } = props
289
- const badge = statusBadge(status)
290
- const connection = connectionLabel(status.connection?.state)
291
- const facts = [
292
- { label: '飞书连接', value: status.connection === undefined ? '未知' : connection.text },
293
- { label: 'Bot open_id', value: status.botOpenIdTail ?? '—' },
294
- { label: '运行时长', value: formatUptime(status.uptimeMs) },
295
- { label: '话题路由', value: String(status.routeCount) },
296
- { label: '活动会话', value: String(status.liveAgentCount) },
297
- { label: '在途回复', value: String(status.inflightTurnCount) },
298
- ]
299
- return (
300
- <section className="dsh-channels-cfg__card">
301
- <div className="dsh-channels-cfg__cardHead">
302
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.statusCard}</span>
303
- <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${badge.tone}`}>
304
- <span className="dsh-channels-cfg__dot" />
305
- {badge.text}
306
- </span>
307
- <span className="dsh-channels-cfg__cardMeta">每 5 秒自动刷新</span>
308
- </div>
309
- {status.lastStartError === undefined
310
- ? null
311
- : (
312
- <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
313
- <strong>{PANEL_COPY.lastError}</strong>
314
- <span className="dsh-channels-cfg__mono">{status.lastStartError}</span>
315
- </div>
316
- )}
317
- <div className="dsh-channels-cfg__facts">
318
- {facts.map(fact => (
319
- <div className="dsh-channels-cfg__fact" key={fact.label}>
320
- <span className="dsh-channels-cfg__factLabel">{fact.label}</span>
321
- <span className="dsh-channels-cfg__factValue">{fact.value}</span>
322
- </div>
323
- ))}
324
- </div>
325
- {status.reasons.length === 0
326
- ? null
327
- : (
328
- <span className={`dsh-channels-cfg__help${status.level === 'unhealthy' ? ' dsh-channels-cfg__reasonBad' : ''}`}>
329
- 健康判定:{status.reasons.join(';')}
330
- </span>
331
- )}
332
- {pollError === null ? null : <span className="dsh-channels-cfg__fieldError">状态刷新失败:{pollError}</span>}
333
- <div className="dsh-channels-cfg__actions">
334
- <button type="button" className="dsh-channels-cfg__btn" onClick={onRefresh} disabled={busy !== null}>
335
- {PANEL_COPY.refresh}
336
- </button>
337
- <button
338
- type="button"
339
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--danger"
340
- onClick={onReconnect}
341
- disabled={busy !== null}
342
- >
343
- {busy === 'reconnect' ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect}
344
- </button>
345
- <span className="dsh-channels-cfg__help">{PANEL_COPY.restartWarning}</span>
346
- </div>
347
- </section>
348
- )
349
- }
350
-
351
- /** Credential editor: writes through the Host credentials seam, never echoes values. */
352
- function CredentialsCard(props: {
353
- panel: PanelWire
354
- appId: string
355
- appSecret: string
356
- issues: FieldIssueWire[]
357
- busy: string | null
358
- onAppIdChange: (value: string) => void
359
- onAppSecretChange: (value: string) => void
360
- onSave: () => void
361
- onClear: (field: CredentialField) => void
362
- }) {
363
- const { panel, appId, appSecret, issues, busy, onAppIdChange, onAppSecretChange, onSave, onClear } = props
364
- const appIdState = panel.config.credentials.appId
365
- const appSecretState = panel.config.credentials.appSecret
366
- const writable = appIdState.writable || appSecretState.writable
367
- const disabled = busy !== null || !writable
368
- const pending = appId.trim() !== '' || appSecret.trim() !== ''
369
-
370
- const row = (
371
- field: CredentialField,
372
- label: string,
373
- state: CredentialRefWire,
374
- value: string,
375
- type: 'text' | 'password',
376
- placeholder: string,
377
- onChange: (next: string) => void,
378
- ): JSX.Element => {
379
- const issue = issues.find(item => item.key === field)
380
- const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
381
- return (
382
- <div className="dsh-channels-cfg__field" key={field}>
383
- <div className="dsh-channels-cfg__labelRow">
384
- <span className="dsh-channels-cfg__label">{label}</span>
385
- <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${state.configured ? 'ok' : 'bad'}`}>
386
- {state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing}
387
- </span>
388
- </div>
389
- <div className="dsh-channels-cfg__cred">
390
- <input
391
- type={type}
392
- className={inputClass}
393
- value={value}
394
- placeholder={placeholder}
395
- autoComplete="new-password"
396
- disabled={disabled}
397
- onChange={event => onChange(event.target.value)}
398
- />
399
- {state.configured
400
- ? (
401
- <button
402
- type="button"
403
- className="dsh-channels-cfg__btn"
404
- disabled={disabled}
405
- onClick={() => onClear(field)}
406
- >
407
- {busy === 'credentials-clear' ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential}
408
- </button>
409
- )
410
- : null}
411
- </div>
412
- <span className="dsh-channels-cfg__help">
413
- {PANEL_COPY.credentialsRef}:<span className="dsh-channels-cfg__mono">{state.ref}</span>
414
- {state.source === undefined ? '' : `(来源:${state.source})`}
415
- </span>
416
- {issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
417
- </div>
418
- )
419
- }
420
-
421
- return (
422
- <section className="dsh-channels-cfg__card">
423
- <div className="dsh-channels-cfg__cardHead">
424
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.credentialsCard}</span>
425
- <span className="dsh-channels-cfg__cardMeta">DSH 凭据库</span>
426
- </div>
427
- {row('appId', PANEL_COPY.appIdLabel, appIdState, appId, 'text', PANEL_COPY.appIdPlaceholder, onAppIdChange)}
428
- {row('appSecret', PANEL_COPY.appSecretLabel, appSecretState, appSecret, 'password', PANEL_COPY.appSecretPlaceholder, onAppSecretChange)}
429
- {writable ? null : <span className="dsh-channels-cfg__fieldError">{PANEL_COPY.credentialReadOnly}</span>}
430
- <div className="dsh-channels-cfg__footer">
431
- <button
432
- type="button"
433
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
434
- onClick={onSave}
435
- disabled={disabled || !pending}
436
- >
437
- {busy === 'credentials' ? PANEL_COPY.savingCredentials : PANEL_COPY.saveCredentials}
438
- </button>
439
- <span className="dsh-channels-cfg__spacer" />
440
- <span className="dsh-channels-cfg__help">{PANEL_COPY.credentialsHelp}</span>
441
- </div>
442
- </section>
443
- )
444
- }
445
-
446
- /** Read-only card: credentials, storage layer, and advanced limits. */
447
- function AdvancedCard(props: { panel: PanelWire }) {
448
- const { panel } = props
449
- const { readOnly } = panel.config
450
- return (
451
- <section className="dsh-channels-cfg__card">
452
- <div className="dsh-channels-cfg__cardHead">
453
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.advancedCard}</span>
454
- </div>
455
- <div className="dsh-channels-cfg__readOnly">
456
- {readOnly.map(field => (
457
- <div className="dsh-channels-cfg__kv" key={field.key}>
458
- <span className="dsh-channels-cfg__kvKey">{field.label}</span>
459
- <span className="dsh-channels-cfg__kvValue">{field.value}</span>
460
- {field.help === undefined ? null : <span className="dsh-channels-cfg__help">{field.help}</span>}
461
- </div>
462
- ))}
463
- <div className="dsh-channels-cfg__kv">
464
- <span className="dsh-channels-cfg__kvKey">{PANEL_COPY.overlayPath}</span>
465
- <span className="dsh-channels-cfg__kvValue dsh-channels-cfg__mono">{panel.status.overlayPath}</span>
466
- <span className="dsh-channels-cfg__help">可以直接编辑该 JSON 文件;面板保存会整体重写它,「清除 Web 覆盖」会删除它。</span>
467
- </div>
468
- <div className="dsh-channels-cfg__kv">
469
- <span className="dsh-channels-cfg__kvKey">{PANEL_COPY.version}</span>
470
- <span className="dsh-channels-cfg__kvValue dsh-channels-cfg__mono">{panel.status.version}</span>
471
- </div>
472
- </div>
473
- </section>
474
- )
475
- }
476
-
477
- /** Workspace cleanup card: deletion preview + the irreversible manual action. */
478
- function CleanupCard(props: {
479
- panel: PanelWire
480
- cleanup: CleanupPreviewWire
481
- busy: BusyAction | null
482
- result: CleanupRunWire | null
483
- error: string | null
484
- onRun: () => void
485
- }) {
486
- const { panel, cleanup, busy, result, error, onRun } = props
487
- const autoText = cleanup.retention === 'off'
488
- ? PANEL_COPY.cleanupAutoInactive
489
- : cleanup.auto ? PANEL_COPY.cleanupAutoOn : PANEL_COPY.cleanupAutoOff
490
- const blocked = cleanup.error !== undefined
491
- ? `${PANEL_COPY.cleanupPreviewFailed}:${cleanup.error}`
492
- : cleanup.retention === 'off'
493
- ? PANEL_COPY.cleanupDisabledOff
494
- : cleanup.candidates === 0
495
- ? PANEL_COPY.cleanupDisabledEmpty
496
- : null
497
- const facts = [
498
- { label: PANEL_COPY.cleanupRetention, value: retentionLabel(panel, cleanup) },
499
- { label: PANEL_COPY.cleanupAutoLabel, value: autoText },
500
- { label: PANEL_COPY.cleanupCandidates, value: `${cleanup.candidates} / ${cleanup.workspaces} 个` },
501
- { label: PANEL_COPY.cleanupCandidateBytes, value: formatBytes(cleanup.candidateBytes) },
502
- ]
503
- const running = busy === 'cleanup'
504
- const failed = result !== null && (!result.ok || error !== null)
505
- return (
506
- <section className="dsh-channels-cfg__card">
507
- <div className="dsh-channels-cfg__cardHead">
508
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.cleanupCard}</span>
509
- <span className="dsh-channels-cfg__cardMeta">{retentionLabel(panel, cleanup)}</span>
510
- </div>
511
- <span className="dsh-channels-cfg__help">{PANEL_COPY.cleanupCardHelp}</span>
512
- <div className="dsh-channels-cfg__facts">
513
- {facts.map(fact => (
514
- <div className="dsh-channels-cfg__fact" key={fact.label}>
515
- <span className="dsh-channels-cfg__factLabel">{fact.label}</span>
516
- <span className="dsh-channels-cfg__factValue">{fact.value}</span>
517
- </div>
518
- ))}
519
- </div>
520
- {blocked === null ? null : <span className="dsh-channels-cfg__help">{blocked}</span>}
521
- {result === null
522
- ? null
523
- : (
524
- <div className={`dsh-channels-cfg__notice dsh-channels-cfg__notice--${failed ? 'error' : 'ok'}`}>
525
- {result.ok
526
- ? (
527
- <span>
528
- {PANEL_COPY.cleanupDone}:{PANEL_COPY.cleanupDeleted} {result.deletedWorkspaces} 个,
529
- {PANEL_COPY.cleanupRetired} {result.retiredRoutes} 条,
530
- {PANEL_COPY.cleanupFreed} {formatBytes(result.freedBytes)}。
531
- </span>
532
- )
533
- : (
534
- <span>
535
- {PANEL_COPY.cleanupFailed}
536
- {result.error === undefined ? '' : `:${result.error}`}
537
- </span>
538
- )}
539
- {result.failures.length === 0
540
- ? null
541
- : (
542
- <>
543
- <span>{PANEL_COPY.cleanupFailures}</span>
544
- <ul className="dsh-channels-cfg__issues">
545
- {result.failures.map((failure, index) => (
546
- <li key={`${index}-${failure}`} className="dsh-channels-cfg__mono">{failure}</li>
547
- ))}
548
- </ul>
549
- </>
550
- )}
551
- </div>
552
- )}
553
- {error === null ? null : <span className="dsh-channels-cfg__fieldError">{error}</span>}
554
- <div className="dsh-channels-cfg__actions">
555
- <button
556
- type="button"
557
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--danger"
558
- onClick={onRun}
559
- disabled={running || busy !== null || blocked !== null}
560
- >
561
- {running ? PANEL_COPY.cleanupRunning : PANEL_COPY.cleanupNow}
562
- </button>
563
- <span className="dsh-channels-cfg__help">{PANEL_COPY.cleanupConfirmHint}</span>
564
- </div>
565
- </section>
566
- )
567
- }
568
-
569
- /** Access card: fail-closed warning, administrator, whitelists, pending requests. */
570
- function AccessCard(props: {
571
- panel: PanelWire
572
- busy: BusyAction | null
573
- notice: string | null
574
- error: string | null
575
- onAddGroup: (chatId: string, name: string) => Promise<boolean>
576
- onRemoveGroup: (chatId: string) => void
577
- onAddUser: (openId: string, name: string) => Promise<boolean>
578
- onRemoveUser: (openId: string) => void
579
- onResolveRequest: (token: string, decision: 'allow' | 'deny') => void
580
- }) {
581
- const { panel, busy, notice, error, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props
582
- const { access } = panel
583
- const [groupChatId, setGroupChatId] = useState('')
584
- const [groupName, setGroupName] = useState('')
585
- const [userOpenId, setUserOpenId] = useState('')
586
- const [userName, setUserName] = useState('')
587
- const groups: readonly AccessGroupWire[] = access.groups
588
- const users: readonly AccessUserWire[] = access.users
589
- const requests: readonly AccessRequestWire[] = access.requests
590
- const working = busy === 'access'
591
- const disabled = busy !== null
592
-
593
- const submitGroup = async (): Promise<void> => {
594
- if (groupChatId.trim() === '') return
595
- if (await onAddGroup(groupChatId, groupName)) {
596
- setGroupChatId('')
597
- setGroupName('')
598
- }
599
- }
600
-
601
- const submitUser = async (): Promise<void> => {
602
- if (userOpenId.trim() === '') return
603
- if (await onAddUser(userOpenId, userName)) {
604
- setUserOpenId('')
605
- setUserName('')
606
- }
607
- }
608
-
609
- return (
610
- <section className="dsh-channels-cfg__card">
611
- <div className="dsh-channels-cfg__cardHead">
612
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.accessCard}</span>
613
- <span className="dsh-channels-cfg__cardMeta">{accessModeLabel(panel, access.mode)}</span>
614
- </div>
615
- <span className="dsh-channels-cfg__help">{PANEL_COPY.accessCardHelp}</span>
616
-
617
- {access.mode === 'guarded' && !access.admin.configured
618
- ? (
619
- <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
620
- <strong>{PANEL_COPY.accessAdminMissingWarning}</strong>
621
- </div>
622
- )
623
- : null}
624
- {access.mode === 'open'
625
- ? <div className="dsh-channels-cfg__banner">{PANEL_COPY.accessModeOpenWarning}</div>
626
- : null}
627
-
628
- <div className="dsh-channels-cfg__kv">
629
- <span className="dsh-channels-cfg__kvKey">{PANEL_COPY.accessAdminLabel}</span>
630
- <span className={`dsh-channels-cfg__kvValue dsh-channels-cfg__mono${access.admin.configured ? '' : ' dsh-channels-cfg__reasonBad'}`}>
631
- {access.admin.openId ?? PANEL_COPY.accessAdminMissing}
632
- </span>
633
- <span className="dsh-channels-cfg__help">{PANEL_COPY.accessAdminHint}</span>
634
- </div>
635
-
636
- <div className="dsh-channels-cfg__accessSection">
637
- <div className="dsh-channels-cfg__labelRow">
638
- <span className="dsh-channels-cfg__label">{PANEL_COPY.accessGroupsTitle}</span>
639
- <span className="dsh-channels-cfg__cardMeta">{groups.length} 个</span>
640
- </div>
641
- {groups.length === 0
642
- ? <span className="dsh-channels-cfg__help">{PANEL_COPY.accessGroupsEmpty}</span>
643
- : (
644
- <ul className="dsh-channels-cfg__accessList">
645
- {groups.map(group => (
646
- <li className="dsh-channels-cfg__accessRow" key={group.chatId}>
647
- <span className="dsh-channels-cfg__accessMain">
648
- <span>{group.name ?? group.chatId}</span>
649
- <span className="dsh-channels-cfg__accessMeta">
650
- <span className="dsh-channels-cfg__mono">{group.chatId}</span>
651
- {` · ${accessSourceLabel(group.source)} · ${formatAccessDate(group.addedAt)}`}
652
- </span>
653
- </span>
654
- <button
655
- type="button"
656
- className="dsh-channels-cfg__btn"
657
- disabled={disabled}
658
- onClick={() => onRemoveGroup(group.chatId)}
659
- >
660
- {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove}
661
- </button>
662
- </li>
663
- ))}
664
- </ul>
665
- )}
666
- <div className="dsh-channels-cfg__accessAdd">
667
- <input
668
- type="text"
669
- className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
670
- value={groupChatId}
671
- placeholder={PANEL_COPY.accessGroupChatIdPlaceholder}
672
- disabled={disabled}
673
- onChange={event => setGroupChatId(event.target.value)}
674
- />
675
- <input
676
- type="text"
677
- className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
678
- value={groupName}
679
- placeholder={PANEL_COPY.accessGroupNamePlaceholder}
680
- disabled={disabled}
681
- onChange={event => setGroupName(event.target.value)}
682
- />
683
- <button
684
- type="button"
685
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
686
- disabled={disabled || groupChatId.trim() === ''}
687
- onClick={() => { void submitGroup() }}
688
- >
689
- {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddGroup}
690
- </button>
691
- </div>
692
- </div>
693
-
694
- <div className="dsh-channels-cfg__accessSection">
695
- <div className="dsh-channels-cfg__labelRow">
696
- <span className="dsh-channels-cfg__label">{PANEL_COPY.accessUsersTitle}</span>
697
- <span className="dsh-channels-cfg__cardMeta">{users.length} 个</span>
698
- </div>
699
- {users.length === 0
700
- ? <span className="dsh-channels-cfg__help">{PANEL_COPY.accessUsersEmpty}</span>
701
- : (
702
- <ul className="dsh-channels-cfg__accessList">
703
- {users.map(user => (
704
- <li className="dsh-channels-cfg__accessRow" key={user.openId}>
705
- <span className="dsh-channels-cfg__accessMain">
706
- <span>{user.name ?? user.openId}</span>
707
- <span className="dsh-channels-cfg__accessMeta">
708
- <span className="dsh-channels-cfg__mono">{user.openId}</span>
709
- {` · ${accessSourceLabel(user.source)} · ${formatAccessDate(user.addedAt)}`}
710
- </span>
711
- </span>
712
- <button
713
- type="button"
714
- className="dsh-channels-cfg__btn"
715
- disabled={disabled}
716
- onClick={() => onRemoveUser(user.openId)}
717
- >
718
- {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove}
719
- </button>
720
- </li>
721
- ))}
722
- </ul>
723
- )}
724
- <div className="dsh-channels-cfg__accessAdd">
725
- <input
726
- type="text"
727
- className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
728
- value={userOpenId}
729
- placeholder={PANEL_COPY.accessUserOpenIdPlaceholder}
730
- disabled={disabled}
731
- onChange={event => setUserOpenId(event.target.value)}
732
- />
733
- <input
734
- type="text"
735
- className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
736
- value={userName}
737
- placeholder={PANEL_COPY.accessUserNamePlaceholder}
738
- disabled={disabled}
739
- onChange={event => setUserName(event.target.value)}
740
- />
741
- <button
742
- type="button"
743
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
744
- disabled={disabled || userOpenId.trim() === ''}
745
- onClick={() => { void submitUser() }}
746
- >
747
- {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddUser}
748
- </button>
749
- </div>
750
- </div>
751
-
752
- <div className="dsh-channels-cfg__accessSection">
753
- <div className="dsh-channels-cfg__labelRow">
754
- <span className="dsh-channels-cfg__label">{PANEL_COPY.accessRequestsTitle}</span>
755
- <span className="dsh-channels-cfg__cardMeta">{requests.length} 条</span>
756
- </div>
757
- {requests.length === 0
758
- ? <span className="dsh-channels-cfg__help">{PANEL_COPY.accessRequestsEmpty}</span>
759
- : (
760
- <ul className="dsh-channels-cfg__accessList">
761
- {requests.map(request => (
762
- <li className="dsh-channels-cfg__accessRow" key={request.token}>
763
- <span className="dsh-channels-cfg__accessMain">
764
- <span>
765
- {request.kind === 'group' ? PANEL_COPY.accessRequestGroup : PANEL_COPY.accessRequestUser}
766
- {':'}
767
- {request.name ?? request.targetId}
768
- </span>
769
- <span className="dsh-channels-cfg__accessMeta">
770
- <span className="dsh-channels-cfg__mono">{request.targetId}</span>
771
- {` · ${formatAccessDate(request.createdAt)} · ${PANEL_COPY.accessRequestExpires} ${formatAccessDate(request.expiresAt)}`}
772
- {request.kind === 'user' && request.requester !== undefined
773
- ? ` · ${PANEL_COPY.accessRequestRequester} ${request.requester}`
774
- : ''}
775
- {request.kind === 'user' && request.groupName !== undefined
776
- ? ` · 群 ${request.groupName}`
777
- : request.kind === 'user' && request.chatId !== undefined
778
- ? ` · 群 ${request.chatId}`
779
- : ''}
780
- </span>
781
- </span>
782
- <span className="dsh-channels-cfg__accessActions">
783
- <button
784
- type="button"
785
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
786
- disabled={disabled}
787
- onClick={() => onResolveRequest(request.token, 'allow')}
788
- >
789
- {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAllow}
790
- </button>
791
- <button
792
- type="button"
793
- className="dsh-channels-cfg__btn"
794
- disabled={disabled}
795
- onClick={() => onResolveRequest(request.token, 'deny')}
796
- >
797
- {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessDeny}
798
- </button>
799
- </span>
800
- </li>
801
- ))}
802
- </ul>
803
- )}
804
- </div>
805
-
806
- {notice === null ? null : (
807
- <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">
808
- <span>{notice}</span>
809
- </div>
810
- )}
811
- {error === null ? null : <span className="dsh-channels-cfg__fieldError">{error}</span>}
812
- <span className="dsh-channels-cfg__help">{PANEL_COPY.accessHints}</span>
813
- <span className="dsh-channels-cfg__help">{PANEL_COPY.accessChatIdHint}</span>
814
- </section>
815
- )
816
- }
817
-
818
- /** The settings.section page: status card + generic config form + read-only facts. */
819
- export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.Element {
820
- const [load, setLoad] = useState<LoadState>({ phase: 'loading' })
821
- const [draft, setDraft] = useState<EditableValues>({})
822
- const [notice, setNotice] = useState<Notice | null>(null)
823
- const [busy, setBusy] = useState<BusyAction | null>(null)
824
- const [status, setStatus] = useState<ChannelStatusWire | null>(null)
825
- const [pollError, setPollError] = useState<string | null>(null)
826
- const [appIdInput, setAppIdInput] = useState('')
827
- const [appSecretInput, setAppSecretInput] = useState('')
828
- const [catalog, setCatalog] = useState<CatalogWire | null>(null)
829
- const [catalogBusy, setCatalogBusy] = useState(false)
830
- const [cleanupResult, setCleanupResult] = useState<CleanupRunWire | null>(null)
831
- const [cleanupError, setCleanupError] = useState<string | null>(null)
832
- const [accessNotice, setAccessNotice] = useState<string | null>(null)
833
- const [accessError, setAccessError] = useState<string | null>(null)
834
- const catalogTicket = useRef(0)
835
- const panel = load.phase === 'ready' ? load.panel : null
836
- const currentStatus = status ?? panel?.status ?? null
837
-
838
- const loadPanel = useCallback(async (): Promise<void> => {
839
- setLoad({ phase: 'loading' })
840
- setNotice(null)
841
- setCleanupResult(null)
842
- setCleanupError(null)
843
- setAccessNotice(null)
844
- setAccessError(null)
845
- try {
846
- const next = await fetchPanel()
847
- setLoad({ phase: 'ready', panel: next })
848
- setStatus(next.status)
849
- setDraft({})
850
- setAppIdInput('')
851
- setAppSecretInput('')
852
- } catch (error) {
853
- setLoad({ phase: 'error', message: messageOf(error) })
854
- }
855
- }, [])
856
-
857
- useEffect(() => {
858
- void loadPanel()
859
- }, [loadPanel])
860
-
861
- // The Host panel payload already carries options resolved for the effective
862
- // route; re-seed the local catalog whenever a fresh panel arrives (load/save).
863
- useEffect(() => {
864
- if (panel === null) {
865
- setCatalog(null)
866
- return
867
- }
868
- setCatalog(catalogFromFields(panel.config.fields))
869
- setCatalogBusy(false)
870
- }, [panel])
871
-
872
- useEffect(() => {
873
- if (load.phase !== 'ready') return undefined
874
- let cancelled = false
875
- const tick = async (): Promise<void> => {
876
- try {
877
- const next = await fetchStatus()
878
- if (cancelled) return
879
- setStatus(next)
880
- setPollError(null)
881
- } catch (error) {
882
- if (!cancelled) setPollError(messageOf(error))
883
- }
884
- }
885
- const timer = window.setInterval(() => { void tick() }, 5_000)
886
- return () => {
887
- cancelled = true
888
- window.clearInterval(timer)
889
- }
890
- }, [load.phase])
891
-
892
- /** Fetch the selector catalog for a draft route (latest request wins). */
893
- const refreshCatalog = useCallback(async (provider: string, model: string): Promise<void> => {
894
- catalogTicket.current += 1
895
- const ticket = catalogTicket.current
896
- setCatalogBusy(true)
897
- try {
898
- const next = await fetchCatalog(provider, model)
899
- if (catalogTicket.current === ticket) setCatalog(next)
900
- } catch {
901
- if (catalogTicket.current !== ticket) return
902
- // Never keep another route's options: degrade the dependent fields to the
903
- // free-text controls instead of offering stale values.
904
- setCatalog(previous => previous === null ? null : {
905
- provider: previous.provider,
906
- model: { selectable: false, options: [] },
907
- reasoningEffort: { selectable: false, options: [] },
908
- agentPreset: previous.agentPreset,
909
- })
910
- setNotice({ kind: 'error', text: PANEL_COPY.catalogFailed })
911
- } finally {
912
- if (catalogTicket.current === ticket) setCatalogBusy(false)
913
- }
914
- }, [])
915
-
916
- /** Current form values, used to keep unlisted configured values visible. */
917
- const currentValues = useMemo(() => {
918
- if (panel === null) return {}
919
- const valueOf = (key: EditableKey): string | undefined => {
920
- const raw = draft[key] ?? effectiveValue(panel, key)
921
- return typeof raw === 'string' && raw !== '' ? raw : undefined
922
- }
923
- return {
924
- provider: valueOf('provider'),
925
- model: valueOf('model'),
926
- reasoningEffort: valueOf('reasoningEffort'),
927
- agentPreset: valueOf('agentPreset'),
928
- }
929
- }, [draft, panel])
930
-
931
- const renderedFields = useMemo(
932
- () => applyCatalogFields(panel?.config.fields ?? [], catalog, currentValues),
933
- [panel, catalog, currentValues],
934
- )
935
-
936
- /** Whether one selector currently renders as a dropdown for this form. */
937
- const isSelectField = useCallback(
938
- (key: EditableKey): boolean => renderedFields.some(field => field.key === key && field.kind === 'select'),
939
- [renderedFields],
940
- )
941
-
942
- const updateDraft = useCallback((key: EditableKey, value: ConfigValue) => {
943
- if (panel === null) return
944
- const providerNow = String(draft.provider ?? effectiveValue(panel, 'provider') ?? '')
945
- setNotice(null)
946
- if (key === 'provider') {
947
- // Changing the provider invalidates the model/effort below it. When the
948
- // selected provider is the configured one, keep showing its configured
949
- // model; otherwise fall back to the inherit choice.
950
- const nextProvider = String(value ?? '')
951
- const configuredProvider = String(effectiveValue(panel, 'provider') ?? '')
952
- const keepModel = nextProvider === configuredProvider
953
- ? String(effectiveValue(panel, 'model') ?? '')
954
- : ''
955
- setDraft(previous => ({ ...previous, provider: value, model: '', reasoningEffort: '' }))
956
- if (catalog !== null && isSelectField('provider')) void refreshCatalog(nextProvider, keepModel)
957
- return
958
- }
959
- if (key === 'model') {
960
- setDraft(previous => ({ ...previous, model: value, reasoningEffort: '' }))
961
- if (catalog !== null && isSelectField('model')) void refreshCatalog(providerNow, String(value ?? ''))
962
- return
963
- }
964
- setDraft(previous => ({ ...previous, [key]: value }))
965
- }, [catalog, draft.provider, isSelectField, panel, refreshCatalog])
966
-
967
- const refreshStatus = useCallback(async (): Promise<void> => {
968
- try {
969
- const next = await fetchStatus()
970
- setStatus(next)
971
- setPollError(null)
972
- } catch (error) {
973
- setPollError(messageOf(error))
974
- }
975
- }, [])
976
-
977
- /**
978
- * Save one scope of the form. The global footer saves every draft key; the
979
- * model card's own button saves only the four model-facing keys, so editing
980
- * the model does not require scrolling to the bottom.
981
- */
982
- const onSave = useCallback(async (scope?: 'model'): Promise<void> => {
983
- if (panel === null) return
984
- const only = scope === 'model' ? new Set<EditableKey>(CATALOG_KEYS) : undefined
985
- const { values, issues } = validateDraft(panel, draft, only)
986
- if (issues.length > 0) {
987
- setNotice({ kind: 'error', text: '请先修正标红的配置项', issues })
988
- return
989
- }
990
- if (Object.keys(values).length === 0) return
991
- setBusy('save')
992
- setNotice(null)
993
- try {
994
- const result = await saveConfig(values)
995
- if (!result.ok) {
996
- setNotice({ kind: 'error', text: result.error, issues: result.issues })
997
- return
998
- }
999
- setLoad({ phase: 'ready', panel: result.panel })
1000
- setStatus(result.panel.status)
1001
- setCleanupResult(null)
1002
- setCleanupError(null)
1003
- const cleared = result.clearedKeys.length === 0
1004
- ? ''
1005
- : `;已清除覆盖:${result.clearedKeys.map(key => fieldLabel(result.panel.config.fields, key)).join('、')}`
1006
- setNotice({
1007
- kind: 'ok',
1008
- text: `${scope === 'model' ? PANEL_COPY.savedModel : PANEL_COPY.saved}${cleared}`,
1009
- })
1010
- setDraft((previous) => {
1011
- if (scope !== 'model') return {}
1012
- const next = { ...previous }
1013
- for (const key of CATALOG_KEYS) delete next[key]
1014
- return next
1015
- })
1016
- } catch (error) {
1017
- setNotice({ kind: 'error', text: messageOf(error) })
1018
- } finally {
1019
- setBusy(null)
1020
- }
1021
- }, [draft, panel])
1022
-
1023
- const onClear = useCallback(async (): Promise<void> => {
1024
- setBusy('clear')
1025
- setNotice(null)
1026
- try {
1027
- const result = await clearConfig()
1028
- if (!result.ok) {
1029
- setNotice({ kind: 'error', text: result.error })
1030
- return
1031
- }
1032
- setLoad({ phase: 'ready', panel: result.panel })
1033
- setStatus(result.panel.status)
1034
- setDraft({})
1035
- setCleanupResult(null)
1036
- setCleanupError(null)
1037
- setNotice({ kind: 'ok', text: PANEL_COPY.cleared })
1038
- } catch (error) {
1039
- setNotice({ kind: 'error', text: messageOf(error) })
1040
- } finally {
1041
- setBusy(null)
1042
- }
1043
- }, [])
1044
-
1045
- const onReconnect = useCallback(async (): Promise<void> => {
1046
- const confirmed = typeof window.confirm === 'function' ? window.confirm(PANEL_COPY.restartWarning) : true
1047
- if (!confirmed) return
1048
- setBusy('reconnect')
1049
- setNotice(null)
1050
- try {
1051
- const result = await reconnect()
1052
- if (!result.ok) {
1053
- setNotice({ kind: 'error', text: result.error })
1054
- await refreshStatus()
1055
- return
1056
- }
1057
- setLoad({ phase: 'ready', panel: result.panel })
1058
- setStatus(result.panel.status)
1059
- setNotice({ kind: 'ok', text: '已重启飞书连接' })
1060
- } catch (error) {
1061
- setNotice({ kind: 'error', text: messageOf(error) })
1062
- await refreshStatus()
1063
- } finally {
1064
- setBusy(null)
1065
- }
1066
- }, [refreshStatus])
1067
-
1068
- /**
1069
- * Delete stale workspaces after an explicit confirmation. The Host refreshes
1070
- * the panel in the same response, so the preview counts update immediately;
1071
- * partial failures stay visible in the card instead of being swallowed.
1072
- */
1073
- const onRunCleanup = useCallback(async (): Promise<void> => {
1074
- if (panel === null) return
1075
- const { cleanup } = panel
1076
- const confirmed = typeof window.confirm === 'function'
1077
- ? window.confirm(
1078
- `${PANEL_COPY.cleanupConfirm}\n本次将删除 ${cleanup.candidates} 个工作区,预计释放 ${formatBytes(cleanup.candidateBytes)}。`,
1079
- )
1080
- : true
1081
- if (!confirmed) return
1082
- setBusy('cleanup')
1083
- setNotice(null)
1084
- setCleanupResult(null)
1085
- setCleanupError(null)
1086
- try {
1087
- const response = await runCleanup()
1088
- if (!response.ok) {
1089
- setCleanupError(response.error)
1090
- return
1091
- }
1092
- setLoad({ phase: 'ready', panel: response.panel })
1093
- setStatus(response.panel.status)
1094
- setCleanupResult(response.result)
1095
- } catch (error) {
1096
- setCleanupError(messageOf(error))
1097
- } finally {
1098
- setBusy(null)
1099
- }
1100
- }, [panel])
1101
-
1102
- /** Replace the embedded access snapshot after a whitelist mutation. */
1103
- const applyAccess = useCallback((access: AccessWire): void => {
1104
- setLoad(previous => previous.phase === 'ready'
1105
- ? { phase: 'ready', panel: { ...previous.panel, access } }
1106
- : previous)
1107
- }, [])
1108
-
1109
- const onAddAccessGroup = useCallback(async (chatId: string, name: string): Promise<boolean> => {
1110
- setBusy('access')
1111
- setAccessError(null)
1112
- setAccessNotice(null)
1113
- try {
1114
- const result = await addAccessGroup(chatId, name)
1115
- if (!result.ok) {
1116
- setAccessError(result.error)
1117
- return false
1118
- }
1119
- applyAccess(result.access)
1120
- setAccessNotice(PANEL_COPY.accessGroupAdded)
1121
- return true
1122
- } catch (error) {
1123
- setAccessError(messageOf(error))
1124
- return false
1125
- } finally {
1126
- setBusy(null)
1127
- }
1128
- }, [applyAccess])
1129
-
1130
- const onRemoveAccessGroup = useCallback(async (chatId: string): Promise<void> => {
1131
- setBusy('access')
1132
- setAccessError(null)
1133
- setAccessNotice(null)
1134
- try {
1135
- const result = await removeAccessGroup(chatId)
1136
- if (!result.ok) {
1137
- setAccessError(result.error)
1138
- return
1139
- }
1140
- applyAccess(result.access)
1141
- setAccessNotice(PANEL_COPY.accessGroupRemoved)
1142
- } catch (error) {
1143
- setAccessError(messageOf(error))
1144
- } finally {
1145
- setBusy(null)
1146
- }
1147
- }, [applyAccess])
1148
-
1149
- const onAddAccessUser = useCallback(async (openId: string, name: string): Promise<boolean> => {
1150
- setBusy('access')
1151
- setAccessError(null)
1152
- setAccessNotice(null)
1153
- try {
1154
- const result = await addAccessUser(openId, name)
1155
- if (!result.ok) {
1156
- setAccessError(result.error)
1157
- return false
1158
- }
1159
- applyAccess(result.access)
1160
- setAccessNotice(PANEL_COPY.accessUserAdded)
1161
- return true
1162
- } catch (error) {
1163
- setAccessError(messageOf(error))
1164
- return false
1165
- } finally {
1166
- setBusy(null)
1167
- }
1168
- }, [applyAccess])
1169
-
1170
- const onRemoveAccessUser = useCallback(async (openId: string): Promise<void> => {
1171
- setBusy('access')
1172
- setAccessError(null)
1173
- setAccessNotice(null)
1174
- try {
1175
- const result = await removeAccessUser(openId)
1176
- if (!result.ok) {
1177
- setAccessError(result.error)
1178
- return
1179
- }
1180
- applyAccess(result.access)
1181
- setAccessNotice(PANEL_COPY.accessUserRemoved)
1182
- } catch (error) {
1183
- setAccessError(messageOf(error))
1184
- } finally {
1185
- setBusy(null)
1186
- }
1187
- }, [applyAccess])
1188
-
1189
- const onResolveAccessRequest = useCallback(async (token: string, decision: 'allow' | 'deny'): Promise<void> => {
1190
- setBusy('access')
1191
- setAccessError(null)
1192
- setAccessNotice(null)
1193
- try {
1194
- const result = await resolveAccessRequest(token, decision)
1195
- if (!result.ok) {
1196
- setAccessError(result.error)
1197
- return
1198
- }
1199
- applyAccess(result.access)
1200
- setAccessNotice(decision === 'allow' ? PANEL_COPY.accessRequestAllowed : PANEL_COPY.accessRequestDenied)
1201
- } catch (error) {
1202
- setAccessError(messageOf(error))
1203
- } finally {
1204
- setBusy(null)
1205
- }
1206
- }, [applyAccess])
1207
-
1208
- const onSaveCredentials = useCallback(async (): Promise<void> => {
1209
- const values: { appId?: string; appSecret?: string } = {}
1210
- if (appIdInput.trim() !== '') values.appId = appIdInput
1211
- if (appSecretInput.trim() !== '') values.appSecret = appSecretInput
1212
- if (values.appId === undefined && values.appSecret === undefined) return
1213
- setBusy('credentials')
1214
- setNotice(null)
1215
- try {
1216
- const result = await saveCredentials(values)
1217
- if (!result.ok) {
1218
- setNotice({ kind: 'error', text: result.error, issues: result.issues })
1219
- return
1220
- }
1221
- setLoad({ phase: 'ready', panel: result.panel })
1222
- setStatus(result.panel.status)
1223
- setAppIdInput('')
1224
- setAppSecretInput('')
1225
- const base = result.reconnect === 'pending'
1226
- ? PANEL_COPY.credentialSavedPending
1227
- : result.reconnect === 'auto'
1228
- ? PANEL_COPY.credentialSavedReconnected
1229
- : PANEL_COPY.credentialSaved
1230
- setNotice({
1231
- kind: result.autoReconnectError === undefined ? 'ok' : 'error',
1232
- text: result.autoReconnectError === undefined ? base : `${base};自动重连失败:${result.autoReconnectError}`,
1233
- })
1234
- } catch (error) {
1235
- setNotice({ kind: 'error', text: messageOf(error) })
1236
- } finally {
1237
- setBusy(null)
1238
- }
1239
- }, [appIdInput, appSecretInput])
1240
-
1241
- const onClearCredential = useCallback(async (field: CredentialField): Promise<void> => {
1242
- const label = field === 'appId' ? PANEL_COPY.appIdLabel : PANEL_COPY.appSecretLabel
1243
- const confirmed = typeof window.confirm === 'function'
1244
- ? window.confirm(`确定移除已存储的 ${label}?`)
1245
- : true
1246
- if (!confirmed) return
1247
- setBusy('credentials-clear')
1248
- setNotice(null)
1249
- try {
1250
- const result = await clearCredential(field)
1251
- if (!result.ok) {
1252
- setNotice({ kind: 'error', text: result.error, issues: result.issues })
1253
- return
1254
- }
1255
- setLoad({ phase: 'ready', panel: result.panel })
1256
- setStatus(result.panel.status)
1257
- setNotice({
1258
- kind: 'ok',
1259
- text: result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
1260
- })
1261
- } catch (error) {
1262
- setNotice({ kind: 'error', text: messageOf(error) })
1263
- } finally {
1264
- setBusy(null)
1265
- }
1266
- }, [])
1267
-
1268
- const groupedFields = useMemo(() => {
1269
- if (panel === null) return []
1270
- return GROUP_ORDER.map(group => ({
1271
- group,
1272
- label: GROUP_LABELS[group],
1273
- fields: renderedFields.filter(field => field.group === group),
1274
- })).filter(entry => entry.fields.length > 0)
1275
- }, [panel, renderedFields])
1276
-
1277
- // Model selection owns its own card: it depends on live host services and is
1278
- // the part users change most often, so it should not sit inside 常用配置.
1279
- const modelEntries = groupedFields.filter(entry => entry.group === 'model')
1280
- const configEntries = groupedFields.filter(entry => entry.group !== 'model')
1281
-
1282
- const dirty = Object.keys(draft).length > 0
1283
- const modelDirty = CATALOG_KEYS.some(key => draft[key] !== undefined)
1284
- const issues = notice?.kind === 'error' ? notice.issues ?? [] : []
1285
-
1286
- if (load.phase === 'loading') {
1287
- return <div className="dsh-channels-cfg__loading">{PANEL_COPY.loading}</div>
1288
- }
1289
- if (load.phase === 'error') {
1290
- return (
1291
- <div className="dsh-channels-cfg__error">
1292
- <strong>{PANEL_COPY.loadFailed}</strong>
1293
- <span>{load.message}</span>
1294
- <div>
1295
- <button type="button" className="dsh-channels-cfg__btn" onClick={() => { void loadPanel() }}>
1296
- {PANEL_COPY.retry}
1297
- </button>
1298
- </div>
1299
- </div>
1300
- )
1301
- }
1302
-
1303
- return (
1304
- <div className="dsh-channels-cfg">
1305
- <header className="dsh-channels-cfg__head">
1306
- <h2 className="dsh-channels-cfg__title">{PANEL_COPY.title}</h2>
1307
- <p className="dsh-channels-cfg__desc">{PANEL_COPY.description}</p>
1308
- </header>
1309
-
1310
- {currentStatus === null
1311
- ? null
1312
- : <StatusCard status={currentStatus} pollError={pollError} busy={busy} onRefresh={() => { void refreshStatus() }} onReconnect={() => { void onReconnect() }} />}
1313
-
1314
- {notice === null
1315
- ? null
1316
- : (
1317
- <div className={`dsh-channels-cfg__notice dsh-channels-cfg__notice--${notice.kind === 'ok' ? 'ok' : 'error'}`}>
1318
- <span>{notice.text}</span>
1319
- </div>
1320
- )}
1321
-
1322
- {panel === null
1323
- ? null
1324
- : (
1325
- <CredentialsCard
1326
- panel={panel}
1327
- appId={appIdInput}
1328
- appSecret={appSecretInput}
1329
- issues={issues}
1330
- busy={busy}
1331
- onAppIdChange={setAppIdInput}
1332
- onAppSecretChange={setAppSecretInput}
1333
- onSave={() => { void onSaveCredentials() }}
1334
- onClear={field => { void onClearCredential(field) }}
1335
- />
1336
- )}
1337
-
1338
- {panel === null
1339
- ? null
1340
- : (
1341
- <>
1342
- {panel.config.restartPendingKeys.length === 0
1343
- ? null
1344
- : (
1345
- <div className="dsh-channels-cfg__banner">
1346
- <span>
1347
- {PANEL_COPY.restartBanner}
1348
- {panel.config.restartPendingKeys.map(key => fieldLabel(panel.config.fields, key)).join('、')}
1349
- </span>
1350
- <button
1351
- type="button"
1352
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--danger"
1353
- onClick={() => { void onReconnect() }}
1354
- disabled={busy !== null}
1355
- >
1356
- {busy === 'reconnect' ? PANEL_COPY.reconnecting : PANEL_COPY.reconnect}
1357
- </button>
1358
- </div>
1359
- )}
1360
- {modelEntries.length === 0
1361
- ? null
1362
- : (
1363
- <section className="dsh-channels-cfg__card">
1364
- <div className="dsh-channels-cfg__cardHead">
1365
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.modelCard}</span>
1366
- <span className="dsh-channels-cfg__cardMeta">
1367
- {catalogBusy ? PANEL_COPY.catalogLoading : '选项来自宿主机目录'}
1368
- </span>
1369
- </div>
1370
- <span className="dsh-channels-cfg__help">{PANEL_COPY.modelCardHelp}</span>
1371
- {modelEntries.map(entry => (
1372
- <div className="dsh-channels-cfg__grid" key={entry.group}>
1373
- {entry.fields.map(field => (
1374
- <FieldRow
1375
- key={field.key}
1376
- field={field}
1377
- value={draft[field.key] ?? effectiveValue(panel, field.key)}
1378
- issues={issues}
1379
- onChange={updateDraft}
1380
- />
1381
- ))}
1382
- </div>
1383
- ))}
1384
- <div className="dsh-channels-cfg__footer">
1385
- <button
1386
- type="button"
1387
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
1388
- onClick={() => { void onSave('model') }}
1389
- disabled={!modelDirty || busy !== null}
1390
- >
1391
- {busy === 'save' ? PANEL_COPY.saving : PANEL_COPY.saveModel}
1392
- </button>
1393
- <span className="dsh-channels-cfg__spacer" />
1394
- <span className="dsh-channels-cfg__help">
1395
- {modelDirty ? '有未保存的模型修改' : PANEL_COPY.modelActionsHint}
1396
- </span>
1397
- </div>
1398
- </section>
1399
- )}
1400
- <section className="dsh-channels-cfg__card">
1401
- <div className="dsh-channels-cfg__cardHead">
1402
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.configCard}</span>
1403
- <span className="dsh-channels-cfg__cardMeta">
1404
- {panel.config.overriddenKeys.length === 0
1405
- ? PANEL_COPY.inherited
1406
- : `${PANEL_COPY.overridden} ${panel.config.overriddenKeys.length} 项`}
1407
- </span>
1408
- </div>
1409
- {configEntries.map(entry => (
1410
- <div key={entry.group}>
1411
- <div className="dsh-channels-cfg__cardHead">
1412
- <span className="dsh-channels-cfg__cardTitle">{entry.label}</span>
1413
- </div>
1414
- <div className="dsh-channels-cfg__grid">
1415
- {entry.fields.map(field => (
1416
- <FieldRow
1417
- key={field.key}
1418
- field={field}
1419
- value={draft[field.key] ?? effectiveValue(panel, field.key)}
1420
- issues={issues}
1421
- onChange={updateDraft}
1422
- />
1423
- ))}
1424
- </div>
1425
- </div>
1426
- ))}
1427
- </section>
1428
- <div className="dsh-channels-cfg__footer">
1429
- <button
1430
- type="button"
1431
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
1432
- onClick={() => { void onSave() }}
1433
- disabled={!dirty || busy !== null}
1434
- >
1435
- {busy === 'save' ? PANEL_COPY.saving : PANEL_COPY.saveAll}
1436
- </button>
1437
- <button
1438
- type="button"
1439
- className="dsh-channels-cfg__btn"
1440
- onClick={() => { void onClear() }}
1441
- disabled={panel.config.overriddenKeys.length === 0 || busy !== null}
1442
- title={panel.config.overriddenKeys.length === 0 ? PANEL_COPY.noOverrides : undefined}
1443
- >
1444
- {busy === 'clear' ? PANEL_COPY.clearing : PANEL_COPY.clear}
1445
- </button>
1446
- <span className="dsh-channels-cfg__spacer" />
1447
- <span className="dsh-channels-cfg__help">
1448
- {dirty ? '有未保存的修改' : '只保存改动过的字段'}
1449
- </span>
1450
- </div>
1451
- <CleanupCard
1452
- panel={panel}
1453
- cleanup={panel.cleanup}
1454
- busy={busy}
1455
- result={cleanupResult}
1456
- error={cleanupError}
1457
- onRun={() => { void onRunCleanup() }}
1458
- />
1459
- <AccessCard
1460
- panel={panel}
1461
- busy={busy}
1462
- notice={accessNotice}
1463
- error={accessError}
1464
- onAddGroup={onAddAccessGroup}
1465
- onRemoveGroup={chatId => { void onRemoveAccessGroup(chatId) }}
1466
- onAddUser={onAddAccessUser}
1467
- onRemoveUser={openId => { void onRemoveAccessUser(openId) }}
1468
- onResolveRequest={(token, decision) => { void onResolveAccessRequest(token, decision) }}
1469
- />
1470
- <AdvancedCard panel={panel} />
1471
- </>
1472
- )}
1473
- </div>
1474
- )
1475
- }