@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.
- package/README.md +39 -29
- package/docs/access-control-design.md +4 -4
- package/docs/feishu-onboarding.md +11 -7
- package/lib/client.js +1698 -875
- package/lib/client.js.map +1 -1
- package/lib/index.js +447 -78
- package/lib/index.js.map +1 -1
- package/package.json +16 -14
- package/src/client/api.ts +20 -0
- package/src/client/catalog.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/panel/access-card.tsx +294 -0
- package/src/client/panel/access-tab.tsx +46 -0
- package/src/client/panel/advanced-tab.tsx +80 -0
- package/src/client/panel/cleanup-card.tsx +104 -0
- package/src/client/panel/connection-tab.tsx +214 -0
- package/src/client/panel/credentials-card.tsx +104 -0
- package/src/client/panel/index.tsx +862 -0
- package/src/client/panel/model-tab.tsx +23 -0
- package/src/client/panel/sessions-tab.tsx +21 -0
- package/src/client/panel/shared.tsx +488 -0
- package/src/client/panel/workspace-tab.tsx +40 -0
- package/src/client/styles.ts +24 -6
- package/src/config.ts +17 -4
- package/src/index.ts +24 -8
- package/src/service.ts +142 -26
- package/src/settings/contract.ts +207 -35
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +159 -21
- package/src/client/panel.tsx +0 -1475
package/src/client/panel.tsx
DELETED
|
@@ -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
|
-
}
|