@modesto_ma/dsh-channels 0.1.0 → 0.3.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 +89 -55
- package/docs/access-control-design.md +5 -5
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +26 -20
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +6 -5
- package/docs/workspace-isolation-design.md +78 -0
- package/lib/client.js +1715 -882
- package/lib/client.js.map +1 -1
- package/lib/index.js +1556 -680
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +256 -119
- 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 +106 -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 +44 -12
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +32 -14
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +259 -65
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +63 -20
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +223 -47
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +171 -25
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
- package/src/client/panel.tsx +0 -1475
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「模型」tab:模型服务商 / 模型 id / 推理档位 / Agent 预设四个选择器。
|
|
3
|
+
* 选项来自 Host 实时目录(页面外壳负责 provider→model→effort 级联刷新),
|
|
4
|
+
* 目录读取中时在表单上方提示。
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-channels/client/panel/model-tab
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
|
|
10
|
+
import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
|
|
11
|
+
import type { FormView } from './shared.tsx'
|
|
12
|
+
|
|
13
|
+
export function ModelTab(props: { form: FormView; catalogBusy: boolean }): JSX.Element {
|
|
14
|
+
const { form, catalogBusy } = props
|
|
15
|
+
return (
|
|
16
|
+
<div className="dsh-channels-cfg__tabBody">
|
|
17
|
+
<TabHelp text={PANEL_COPY.modelTabHelp} />
|
|
18
|
+
{catalogBusy ? <span className="dsh-channels-cfg__help">{PANEL_COPY.catalogLoading}</span> : null}
|
|
19
|
+
<FieldsCard form={form} groups={TAB_FIELD_GROUPS.model} />
|
|
20
|
+
<SaveBar form={form} tab="model" />
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「会话与行为」tab:行为 + 内容与附件两组字段(私聊/群聊触发方式、
|
|
3
|
+
* 首次引导、权限切换、附件接收)。表单字段完全由 Host 描述驱动。
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-channels/client/panel/sessions-tab
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
|
|
9
|
+
import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
|
|
10
|
+
import type { FormView } from './shared.tsx'
|
|
11
|
+
|
|
12
|
+
export function SessionsTab(props: { form: FormView }): JSX.Element {
|
|
13
|
+
const { form } = props
|
|
14
|
+
return (
|
|
15
|
+
<div className="dsh-channels-cfg__tabBody">
|
|
16
|
+
<TabHelp text={PANEL_COPY.sessionsTabHelp} />
|
|
17
|
+
<FieldsCard form={form} groups={TAB_FIELD_GROUPS.sessions} />
|
|
18
|
+
<SaveBar form={form} tab="sessions" />
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置面板共享层:页面状态类型、Host 字段渲染原语(FieldRow / FieldsCard /
|
|
3
|
+
* SaveBar)、生命周期徽标与纯格式化工具。
|
|
4
|
+
*
|
|
5
|
+
* 这一层不发请求、不持有页面状态,只把 Host 下发的 wire 数据渲染成控件;
|
|
6
|
+
* 页面外壳在 `index.tsx`,各 tab 只组合这些原语。
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-channels/client/panel/shared
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState } from 'react'
|
|
12
|
+
import { FIELD_GROUP_LABELS, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
|
|
13
|
+
import type {
|
|
14
|
+
AccessWire,
|
|
15
|
+
ChannelPhaseWire,
|
|
16
|
+
CleanupPreviewWire,
|
|
17
|
+
ConfigValue,
|
|
18
|
+
CredentialField,
|
|
19
|
+
EditableFieldWire,
|
|
20
|
+
EditableKey,
|
|
21
|
+
EditableValues,
|
|
22
|
+
FieldGroup,
|
|
23
|
+
FieldIssueWire,
|
|
24
|
+
PanelTabId,
|
|
25
|
+
PanelWire,
|
|
26
|
+
} from '../../settings/contract.ts'
|
|
27
|
+
|
|
28
|
+
/** Initial panel load: one request, retried from the error card. */
|
|
29
|
+
export type LoadState =
|
|
30
|
+
| { phase: 'loading' }
|
|
31
|
+
| { phase: 'error'; message: string }
|
|
32
|
+
| { phase: 'ready'; panel: PanelWire }
|
|
33
|
+
|
|
34
|
+
/** One transient banner above the tab body (save/credentials/reconnect/catalog). */
|
|
35
|
+
export interface Notice {
|
|
36
|
+
kind: 'ok' | 'error'
|
|
37
|
+
text: string
|
|
38
|
+
/** Per-field validation failures rendered inline by FieldRow. */
|
|
39
|
+
issues?: FieldIssueWire[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One in-flight panel mutation (only one action runs at a time). */
|
|
43
|
+
export type BusyAction =
|
|
44
|
+
| 'save'
|
|
45
|
+
| 'clear'
|
|
46
|
+
| 'reconnect'
|
|
47
|
+
| 'credentials'
|
|
48
|
+
| 'credentials-clear'
|
|
49
|
+
| 'cleanup'
|
|
50
|
+
| 'access'
|
|
51
|
+
|
|
52
|
+
/** Tone of a small badge; maps onto `dsh-channels-cfg__badge--*` classes. */
|
|
53
|
+
export type BadgeTone = 'ok' | 'warn' | 'bad' | 'muted'
|
|
54
|
+
|
|
55
|
+
export function messageOf(error: unknown): string {
|
|
56
|
+
return error instanceof Error ? error.message : String(error)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Human-readable byte size for the cleanup preview/result (base-2 units). */
|
|
60
|
+
export function formatBytes(bytes: number): string {
|
|
61
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
|
62
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
63
|
+
let value = bytes
|
|
64
|
+
let unit = 0
|
|
65
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
66
|
+
value /= 1024
|
|
67
|
+
unit += 1
|
|
68
|
+
}
|
|
69
|
+
const digits = unit === 0 || value >= 10 ? 0 : 1
|
|
70
|
+
return `${value.toFixed(digits)} ${units[unit] ?? 'B'}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Compact duration for the connection card. */
|
|
74
|
+
export function formatUptime(ms: number | undefined): string {
|
|
75
|
+
if (ms === undefined || !Number.isFinite(ms)) return PANEL_COPY.unknownValue
|
|
76
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1_000))
|
|
77
|
+
const days = Math.floor(totalSeconds / 86_400)
|
|
78
|
+
const hours = Math.floor((totalSeconds % 86_400) / 3_600)
|
|
79
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60)
|
|
80
|
+
if (days > 0) return `${days} 天 ${hours} 小时`
|
|
81
|
+
if (hours > 0) return `${hours} 小时 ${minutes} 分`
|
|
82
|
+
if (minutes > 0) return `${minutes} 分 ${totalSeconds % 60} 秒`
|
|
83
|
+
return `${totalSeconds} 秒`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Effective value for one key (falling back to the profile base). */
|
|
87
|
+
export function effectiveValue(panel: PanelWire, key: EditableKey): ConfigValue | undefined {
|
|
88
|
+
return panel.config.effective[key] ?? panel.config.base[key]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Host label for one editable key; the raw key is the technical fallback. */
|
|
92
|
+
export function fieldLabel(fields: readonly EditableFieldWire[], key: EditableKey): string {
|
|
93
|
+
return fields.find(field => field.key === key)?.label ?? key
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Effective access-mode label, reusing the Host field options for consistency. */
|
|
97
|
+
export function accessModeLabel(panel: PanelWire, mode: AccessWire['mode']): string {
|
|
98
|
+
const field = panel.config.fields.find(item => item.key === 'accessMode')
|
|
99
|
+
return field?.options?.find(option => option.value === mode)?.label ?? mode
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Numeric draft kept as the raw input string; parsed on save. */
|
|
103
|
+
function numericDraft(field: EditableFieldWire, raw: ConfigValue | undefined): number | undefined {
|
|
104
|
+
if (typeof raw === 'number') return raw
|
|
105
|
+
if (typeof raw !== 'string' || raw.trim() === '') return undefined
|
|
106
|
+
const parsed = Number(raw)
|
|
107
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Turn the dirty draft into the wire payload, keeping the Host-descriptor
|
|
112
|
+
* bounds check client-side. Field messages stay technical (the Host re-validates
|
|
113
|
+
* every value on save).
|
|
114
|
+
*/
|
|
115
|
+
export function validateDraft(
|
|
116
|
+
panel: PanelWire,
|
|
117
|
+
draft: EditableValues,
|
|
118
|
+
): { values: EditableValues; issues: FieldIssueWire[] } {
|
|
119
|
+
const values: EditableValues = {}
|
|
120
|
+
const issues: FieldIssueWire[] = []
|
|
121
|
+
for (const field of panel.config.fields) {
|
|
122
|
+
if (!(field.key in draft)) continue
|
|
123
|
+
const raw = draft[field.key]
|
|
124
|
+
if (field.kind === 'number') {
|
|
125
|
+
const parsed = numericDraft(field, raw)
|
|
126
|
+
if (parsed === undefined) {
|
|
127
|
+
issues.push({ key: field.key, message: '请输入数字' })
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (field.min !== undefined && parsed < field.min) {
|
|
131
|
+
issues.push({ key: field.key, message: `不得小于 ${field.min}` })
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (field.max !== undefined && parsed > field.max) {
|
|
135
|
+
issues.push({ key: field.key, message: `不得大于 ${field.max}` })
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
values[field.key] = parsed
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (field.kind === 'boolean') {
|
|
142
|
+
values[field.key] = raw === true
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
values[field.key] = String(raw ?? '')
|
|
146
|
+
}
|
|
147
|
+
return { values, issues }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Header badge for the Host-derived lifecycle phase. */
|
|
151
|
+
export function phaseBadge(phase: ChannelPhaseWire): { text: string; tone: BadgeTone } {
|
|
152
|
+
switch (phase) {
|
|
153
|
+
case 'connected': return { text: PANEL_COPY.phaseConnected, tone: 'ok' }
|
|
154
|
+
case 'connecting': return { text: PANEL_COPY.phaseConnecting, tone: 'warn' }
|
|
155
|
+
case 'error': return { text: PANEL_COPY.phaseError, tone: 'bad' }
|
|
156
|
+
case 'idle': return { text: PANEL_COPY.phaseIdle, tone: 'muted' }
|
|
157
|
+
default: return { text: PANEL_COPY.phaseUnconfigured, tone: 'muted' }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One-line explanation shown under the page title for the current phase. */
|
|
162
|
+
export function phaseSummary(phase: ChannelPhaseWire): string {
|
|
163
|
+
switch (phase) {
|
|
164
|
+
case 'connected': return PANEL_COPY.statusSummaryConnected
|
|
165
|
+
case 'connecting': return PANEL_COPY.statusSummaryConnecting
|
|
166
|
+
case 'error': return PANEL_COPY.statusSummaryError
|
|
167
|
+
case 'idle': return PANEL_COPY.statusSummaryIdle
|
|
168
|
+
default: return PANEL_COPY.statusSummaryUnconfigured
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Credential editor data owned by the page shell, rendered by the connection tab. */
|
|
173
|
+
export interface CredentialsView {
|
|
174
|
+
appId: string
|
|
175
|
+
appSecret: string
|
|
176
|
+
issues: FieldIssueWire[]
|
|
177
|
+
busy: BusyAction | null
|
|
178
|
+
/** Host-side lifecycle busy (connecting/restarting): block writes. */
|
|
179
|
+
locked: boolean
|
|
180
|
+
onAppIdChange: (value: string) => void
|
|
181
|
+
onAppSecretChange: (value: string) => void
|
|
182
|
+
onSave: () => void
|
|
183
|
+
onClear: (field: CredentialField) => void
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Everything a field-bearing tab needs from the page shell's single draft. */
|
|
187
|
+
export interface FormView {
|
|
188
|
+
panel: PanelWire
|
|
189
|
+
/** Host descriptors after the live selector catalog has been layered on. */
|
|
190
|
+
fields: readonly EditableFieldWire[]
|
|
191
|
+
draft: EditableValues
|
|
192
|
+
issues: FieldIssueWire[]
|
|
193
|
+
busy: BusyAction | null
|
|
194
|
+
/** Host-side lifecycle busy (connecting/restarting): block writes. */
|
|
195
|
+
locked: boolean
|
|
196
|
+
dirtyCount: number
|
|
197
|
+
onChange: (key: EditableKey, value: ConfigValue) => void
|
|
198
|
+
onSave: () => void
|
|
199
|
+
/** Drop only the draft keys owned by one tab. */
|
|
200
|
+
onDiscardTab: (tab: PanelTabId) => void
|
|
201
|
+
/**
|
|
202
|
+
* Open the Host's native directory chooser and write the picked absolute
|
|
203
|
+
* path into the draft (not saved until「保存更改」). Absent when the Host
|
|
204
|
+
* cannot offer a native chooser.
|
|
205
|
+
*/
|
|
206
|
+
onPickDirectory?: (key: EditableKey) => Promise<void>
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Text input paired with the host-native「选择目录…」button. The button owns
|
|
211
|
+
* its own busy state: the OS dialog can stay open for a long time, so the
|
|
212
|
+
* rest of the form stays usable while the operator browses.
|
|
213
|
+
*/
|
|
214
|
+
function DirectoryTextInput(props: {
|
|
215
|
+
field: EditableFieldWire
|
|
216
|
+
value: ConfigValue | undefined
|
|
217
|
+
inputClass: string
|
|
218
|
+
disabled: boolean
|
|
219
|
+
onChange: (key: EditableKey, value: ConfigValue) => void
|
|
220
|
+
onPick: () => Promise<void>
|
|
221
|
+
}): JSX.Element {
|
|
222
|
+
const { field, value, inputClass, disabled, onChange, onPick } = props
|
|
223
|
+
const [picking, setPicking] = useState(false)
|
|
224
|
+
const pick = async (): Promise<void> => {
|
|
225
|
+
setPicking(true)
|
|
226
|
+
try {
|
|
227
|
+
await onPick()
|
|
228
|
+
} finally {
|
|
229
|
+
setPicking(false)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return (
|
|
233
|
+
<div className="dsh-channels-cfg__inputRow">
|
|
234
|
+
<input
|
|
235
|
+
type="text"
|
|
236
|
+
className={inputClass}
|
|
237
|
+
value={String(value ?? '')}
|
|
238
|
+
placeholder={field.placeholder}
|
|
239
|
+
aria-label={field.label}
|
|
240
|
+
disabled={disabled || picking}
|
|
241
|
+
onChange={event => onChange(field.key, event.target.value)}
|
|
242
|
+
/>
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--browse"
|
|
246
|
+
disabled={disabled || picking}
|
|
247
|
+
onClick={() => { void pick() }}
|
|
248
|
+
>
|
|
249
|
+
{picking ? PANEL_COPY.browsingDirectory : PANEL_COPY.browseDirectory}
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** One generic form control driven by the Host field descriptor. */
|
|
256
|
+
export function FieldRow(props: {
|
|
257
|
+
field: EditableFieldWire
|
|
258
|
+
value: ConfigValue | undefined
|
|
259
|
+
issues: FieldIssueWire[]
|
|
260
|
+
disabled?: boolean
|
|
261
|
+
onChange: (key: EditableKey, value: ConfigValue) => void
|
|
262
|
+
/** Present only for `field.browse === 'directory'` rows the page can serve. */
|
|
263
|
+
onPickDirectory?: () => Promise<void>
|
|
264
|
+
}): JSX.Element {
|
|
265
|
+
const { field, value, issues, disabled = false, onChange, onPickDirectory } = props
|
|
266
|
+
const issue = issues.find(item => item.key === field.key)
|
|
267
|
+
const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
|
|
268
|
+
const fieldClass = `dsh-channels-cfg__field${field.wide === true ? ' dsh-channels-cfg__field--wide' : ''}`
|
|
269
|
+
return (
|
|
270
|
+
<div className={fieldClass}>
|
|
271
|
+
<div className="dsh-channels-cfg__labelRow">
|
|
272
|
+
<span className="dsh-channels-cfg__label">{field.label}</span>
|
|
273
|
+
{field.restartRequired
|
|
274
|
+
? <span className="dsh-channels-cfg__tag dsh-channels-cfg__tag--restart">{PANEL_COPY.restartTag}</span>
|
|
275
|
+
: null}
|
|
276
|
+
</div>
|
|
277
|
+
{field.kind === 'boolean'
|
|
278
|
+
? (
|
|
279
|
+
<label className="dsh-channels-cfg__check">
|
|
280
|
+
<input
|
|
281
|
+
type="checkbox"
|
|
282
|
+
className="dsh-channels-cfg__checkbox"
|
|
283
|
+
checked={value === true}
|
|
284
|
+
aria-label={field.label}
|
|
285
|
+
disabled={disabled}
|
|
286
|
+
onChange={event => onChange(field.key, event.target.checked)}
|
|
287
|
+
/>
|
|
288
|
+
<span>{value === true ? PANEL_COPY.enabled : PANEL_COPY.disabled}</span>
|
|
289
|
+
</label>
|
|
290
|
+
)
|
|
291
|
+
: null}
|
|
292
|
+
{field.kind === 'select'
|
|
293
|
+
? (
|
|
294
|
+
<select
|
|
295
|
+
className={inputClass}
|
|
296
|
+
value={String(value ?? '')}
|
|
297
|
+
disabled={disabled || field.disabled === true}
|
|
298
|
+
aria-label={field.label}
|
|
299
|
+
onChange={event => onChange(field.key, event.target.value)}
|
|
300
|
+
>
|
|
301
|
+
{(field.options ?? []).map(option => (
|
|
302
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
303
|
+
))}
|
|
304
|
+
</select>
|
|
305
|
+
)
|
|
306
|
+
: null}
|
|
307
|
+
{field.kind === 'number'
|
|
308
|
+
? (
|
|
309
|
+
<input
|
|
310
|
+
type="number"
|
|
311
|
+
className={inputClass}
|
|
312
|
+
value={value === undefined || value === '' ? '' : String(value)}
|
|
313
|
+
min={field.min}
|
|
314
|
+
max={field.max}
|
|
315
|
+
step={field.step}
|
|
316
|
+
aria-label={field.label}
|
|
317
|
+
disabled={disabled}
|
|
318
|
+
onChange={event => onChange(field.key, event.target.value)}
|
|
319
|
+
/>
|
|
320
|
+
)
|
|
321
|
+
: null}
|
|
322
|
+
{field.kind === 'text'
|
|
323
|
+
? (field.browse === 'directory' && onPickDirectory !== undefined
|
|
324
|
+
? (
|
|
325
|
+
<DirectoryTextInput
|
|
326
|
+
field={field}
|
|
327
|
+
value={value}
|
|
328
|
+
inputClass={inputClass}
|
|
329
|
+
disabled={disabled}
|
|
330
|
+
onChange={onChange}
|
|
331
|
+
onPick={onPickDirectory}
|
|
332
|
+
/>
|
|
333
|
+
)
|
|
334
|
+
: (
|
|
335
|
+
<input
|
|
336
|
+
type="text"
|
|
337
|
+
className={inputClass}
|
|
338
|
+
value={String(value ?? '')}
|
|
339
|
+
placeholder={field.placeholder}
|
|
340
|
+
aria-label={field.label}
|
|
341
|
+
disabled={disabled}
|
|
342
|
+
onChange={event => onChange(field.key, event.target.value)}
|
|
343
|
+
/>
|
|
344
|
+
))
|
|
345
|
+
: null}
|
|
346
|
+
{field.help === undefined ? null : <span className="dsh-channels-cfg__help">{field.help}</span>}
|
|
347
|
+
{issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
|
|
348
|
+
</div>
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* One tab's editable fields, partitioned by the Host field groups that the tab
|
|
354
|
+
* owns (`TAB_FIELD_GROUPS`). Empty groups render nothing, so a Host that moves
|
|
355
|
+
* every key out of a group does not leave a stray heading behind.
|
|
356
|
+
*/
|
|
357
|
+
export function FieldsCard(props: {
|
|
358
|
+
form: FormView
|
|
359
|
+
groups: readonly FieldGroup[]
|
|
360
|
+
}): JSX.Element | null {
|
|
361
|
+
const { form, groups } = props
|
|
362
|
+
const sections = groups
|
|
363
|
+
.map(group => ({
|
|
364
|
+
group,
|
|
365
|
+
label: FIELD_GROUP_LABELS[group],
|
|
366
|
+
fields: form.fields.filter(field => field.group === group),
|
|
367
|
+
}))
|
|
368
|
+
.filter(section => section.fields.length > 0)
|
|
369
|
+
if (sections.length === 0) return null
|
|
370
|
+
return (
|
|
371
|
+
<section className="dsh-channels-cfg__card">
|
|
372
|
+
{sections.map(section => (
|
|
373
|
+
<div className="dsh-channels-cfg__group" key={section.group}>
|
|
374
|
+
<div className="dsh-channels-cfg__labelRow">
|
|
375
|
+
<span className="dsh-channels-cfg__cardTitle">{section.label}</span>
|
|
376
|
+
</div>
|
|
377
|
+
<div className="dsh-channels-cfg__grid">
|
|
378
|
+
{section.fields.map((field) => {
|
|
379
|
+
const onPick = form.onPickDirectory
|
|
380
|
+
return (
|
|
381
|
+
<FieldRow
|
|
382
|
+
key={field.key}
|
|
383
|
+
field={field}
|
|
384
|
+
value={form.draft[field.key] ?? effectiveValue(form.panel, field.key)}
|
|
385
|
+
issues={form.issues}
|
|
386
|
+
disabled={form.locked}
|
|
387
|
+
onChange={form.onChange}
|
|
388
|
+
onPickDirectory={onPick === undefined ? undefined : () => onPick(field.key)}
|
|
389
|
+
/>
|
|
390
|
+
)
|
|
391
|
+
})}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
))}
|
|
395
|
+
</section>
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The unified per-tab action bar: save every dirty field or drop the draft. */
|
|
400
|
+
export function SaveBar(props: { form: FormView; tab: PanelTabId }): JSX.Element {
|
|
401
|
+
const { form, tab } = props
|
|
402
|
+
const groups = new Set<string>(TAB_FIELD_GROUPS[tab])
|
|
403
|
+
const pageDirty = form.fields.filter(field => groups.has(field.group) && field.key in form.draft).length
|
|
404
|
+
// Saving submits the whole draft, so any dirty key (any tab) enables it;
|
|
405
|
+
// discarding only clears this tab, so it follows this tab's own count.
|
|
406
|
+
const dirty = form.dirtyCount > 0
|
|
407
|
+
const disabled = !dirty || form.busy !== null || form.locked
|
|
408
|
+
return (
|
|
409
|
+
<div className="dsh-channels-cfg__footer">
|
|
410
|
+
<button
|
|
411
|
+
type="button"
|
|
412
|
+
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
|
|
413
|
+
onClick={form.onSave}
|
|
414
|
+
disabled={disabled}
|
|
415
|
+
>
|
|
416
|
+
{form.busy === 'save' ? PANEL_COPY.saving : PANEL_COPY.save}
|
|
417
|
+
</button>
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
className="dsh-channels-cfg__btn"
|
|
421
|
+
onClick={() => form.onDiscardTab(tab)}
|
|
422
|
+
disabled={pageDirty === 0 || form.busy !== null || form.locked}
|
|
423
|
+
>
|
|
424
|
+
{PANEL_COPY.discardTab}
|
|
425
|
+
</button>
|
|
426
|
+
<span className="dsh-channels-cfg__spacer" />
|
|
427
|
+
{pageDirty > 0
|
|
428
|
+
? (
|
|
429
|
+
<span className="dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty">
|
|
430
|
+
{PANEL_COPY.dirtyCount} {pageDirty}
|
|
431
|
+
</span>
|
|
432
|
+
)
|
|
433
|
+
: dirty
|
|
434
|
+
? (
|
|
435
|
+
<span className="dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty">
|
|
436
|
+
{PANEL_COPY.dirtyCount} {form.dirtyCount}(其他标签页)
|
|
437
|
+
</span>
|
|
438
|
+
)
|
|
439
|
+
: null}
|
|
440
|
+
<span className="dsh-channels-cfg__help">
|
|
441
|
+
{dirty ? PANEL_COPY.saveHintDirty : PANEL_COPY.saveHintClean}
|
|
442
|
+
</span>
|
|
443
|
+
</div>
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Muted help paragraph introducing one tab. */
|
|
448
|
+
export function TabHelp(props: { text: string }): JSX.Element {
|
|
449
|
+
return <p className="dsh-channels-cfg__tabHelp">{props.text}</p>
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Transient page-level banner (save / credentials / reconnect / catalog). */
|
|
453
|
+
export function NoticeBar(props: { notice: Notice }): JSX.Element {
|
|
454
|
+
const { notice } = props
|
|
455
|
+
return (
|
|
456
|
+
<div
|
|
457
|
+
className={`dsh-channels-cfg__notice dsh-channels-cfg__notice--${notice.kind === 'ok' ? 'ok' : 'error'}`}
|
|
458
|
+
role="status"
|
|
459
|
+
aria-live="polite"
|
|
460
|
+
>
|
|
461
|
+
<span>{notice.text}</span>
|
|
462
|
+
</div>
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Muted empty/blocked state (e.g. access lists before the channel connects). */
|
|
467
|
+
export function EmptyState(props: { text: string }): JSX.Element {
|
|
468
|
+
return <div className="dsh-channels-cfg__emptyState">{props.text}</div>
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Human label for the official SDK's WebSocket lifecycle state. */
|
|
472
|
+
export function connectionStateLabel(state: string | undefined): { text: string; tone: BadgeTone } {
|
|
473
|
+
switch (state) {
|
|
474
|
+
case 'connected': return { text: PANEL_COPY.phaseConnected, tone: 'ok' }
|
|
475
|
+
case 'connecting': return { text: PANEL_COPY.phaseConnecting, tone: 'warn' }
|
|
476
|
+
case 'reconnecting': return { text: PANEL_COPY.phaseConnecting, tone: 'warn' }
|
|
477
|
+
case 'failed': return { text: PANEL_COPY.phaseError, tone: 'bad' }
|
|
478
|
+
case 'idle': return { text: PANEL_COPY.phaseIdle, tone: 'muted' }
|
|
479
|
+
default: return { text: state ?? PANEL_COPY.unknownValue, tone: 'muted' }
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Retention label, reusing the Host field options so the copy stays on the Host. */
|
|
484
|
+
export function retentionLabel(panel: PanelWire, cleanup: CleanupPreviewWire): string {
|
|
485
|
+
const field = panel.config.fields.find(item => item.key === 'workspaceRetention')
|
|
486
|
+
return field?.options?.find(option => option.value === cleanup.retention)?.label
|
|
487
|
+
?? (cleanup.retention === 'off' ? PANEL_COPY.cleanupRetentionOff : cleanup.retention)
|
|
488
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「工作区」tab:工作区根目录 / 过期保留策略 / 自动清理字段 + 目录清理卡。
|
|
3
|
+
* 表单保存栏在字段卡正下方,清理卡(独立动作,不走 draft)在其后。
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-channels/client/panel/workspace-tab
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
|
|
9
|
+
import type { CleanupPreviewWire, CleanupRunWire } from '../../settings/contract.ts'
|
|
10
|
+
import { CleanupCard } from './cleanup-card.tsx'
|
|
11
|
+
import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
|
|
12
|
+
import type { FormView } from './shared.tsx'
|
|
13
|
+
|
|
14
|
+
export interface WorkspaceTabProps {
|
|
15
|
+
form: FormView
|
|
16
|
+
cleanup: CleanupPreviewWire
|
|
17
|
+
cleanupResult: CleanupRunWire | null
|
|
18
|
+
cleanupError: string | null
|
|
19
|
+
onRunCleanup: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function WorkspaceTab(props: WorkspaceTabProps): JSX.Element {
|
|
23
|
+
const { form, cleanup, cleanupResult, cleanupError, onRunCleanup } = props
|
|
24
|
+
return (
|
|
25
|
+
<div className="dsh-channels-cfg__tabBody">
|
|
26
|
+
<TabHelp text={PANEL_COPY.workspaceTabHelp} />
|
|
27
|
+
<FieldsCard form={form} groups={TAB_FIELD_GROUPS.workspace} />
|
|
28
|
+
<SaveBar form={form} tab="workspace" />
|
|
29
|
+
<CleanupCard
|
|
30
|
+
panel={form.panel}
|
|
31
|
+
cleanup={cleanup}
|
|
32
|
+
busy={form.busy}
|
|
33
|
+
locked={form.locked}
|
|
34
|
+
result={cleanupResult}
|
|
35
|
+
error={cleanupError}
|
|
36
|
+
onRun={onRunCleanup}
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
package/src/client/styles.ts
CHANGED
|
@@ -10,14 +10,23 @@ const STYLE_ATTRIBUTE = 'data-dsh-channels-css'
|
|
|
10
10
|
|
|
11
11
|
const CSS = `
|
|
12
12
|
.dsh-channels-cfg{container-type:inline-size;display:flex;flex-direction:column;gap:12px;width:100%;max-width:720px;color:var(--dsw-alias-label-primary,#1f2328);font-size:13px;line-height:20px}
|
|
13
|
-
.dsh-channels-
|
|
13
|
+
.dsh-channels-cfg__panelHead{position:sticky;top:0;z-index:2;display:flex;flex-direction:column;gap:4px;padding:10px 0 8px;background:var(--dsw-alias-bg-base,#fff);border-bottom:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12))}
|
|
14
|
+
.dsh-channels-cfg__headRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
14
15
|
.dsh-channels-cfg__title{margin:0;font-size:16px;font-weight:500;line-height:24px}
|
|
15
|
-
.dsh-channels-
|
|
16
|
+
.dsh-channels-cfg__summary{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
|
|
17
|
+
.dsh-channels-cfg__tabs{display:flex;align-items:center;gap:2px;flex-wrap:wrap;border-bottom:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12))}
|
|
18
|
+
.dsh-channels-cfg__tab{display:inline-flex;align-items:center;gap:5px;min-height:32px;padding:0 10px;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary,#5c6169);font:inherit;font-size:13px;cursor:pointer;white-space:nowrap}
|
|
19
|
+
.dsh-channels-cfg__tab:hover{color:var(--dsw-alias-label-primary,#1f2328)}
|
|
20
|
+
.dsh-channels-cfg__tab:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#0a67d3);outline-offset:-2px}
|
|
21
|
+
.dsh-channels-cfg__tab[aria-selected="true"]{color:var(--dsw-alias-state-business-primary,#0a67d3);border-bottom-color:currentColor;font-weight:500}
|
|
22
|
+
.dsh-channels-cfg__tabDot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-warning-primary,#a15c00);flex:none}
|
|
23
|
+
.dsh-channels-cfg__tabBody{display:flex;flex-direction:column;gap:12px}
|
|
24
|
+
.dsh-channels-cfg__tabHelp{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
|
|
16
25
|
.dsh-channels-cfg__card{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:12px}
|
|
17
26
|
.dsh-channels-cfg__cardHead{display:flex;align-items:center;gap:8px;min-height:20px;flex-wrap:wrap}
|
|
18
27
|
.dsh-channels-cfg__cardTitle{font-size:13px;font-weight:500}
|
|
19
28
|
.dsh-channels-cfg__cardMeta{margin-left:auto;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px}
|
|
20
|
-
.dsh-channels-
|
|
29
|
+
.dsh-channels-cfg__group{display:flex;flex-direction:column;gap:8px}
|
|
21
30
|
.dsh-channels-cfg__badge{display:inline-flex;align-items:center;gap:5px;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));font-size:12px;line-height:18px;white-space:nowrap}
|
|
22
31
|
.dsh-channels-cfg__dot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:none}
|
|
23
32
|
.dsh-channels-cfg__badge--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);border-color:currentColor}
|
|
@@ -42,29 +51,37 @@ const CSS = `
|
|
|
42
51
|
.dsh-channels-cfg__labelRow{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
|
|
43
52
|
.dsh-channels-cfg__label{font-size:12px;color:var(--dsw-alias-label-secondary,#5c6169)}
|
|
44
53
|
.dsh-channels-cfg__tag{display:inline-flex;align-items:center;padding:0 5px;border-radius:4px;border:1px dashed currentColor;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#8a8f98);white-space:nowrap}
|
|
45
|
-
.dsh-channels-cfg__tag--override{color:var(--dsw-alias-state-business-primary,#0a67d3);border-style:solid}
|
|
46
54
|
.dsh-channels-cfg__tag--restart{color:var(--dsw-alias-state-warning-primary,#a15c00)}
|
|
55
|
+
.dsh-channels-cfg__tag--dirty{color:var(--dsw-alias-state-warning-primary,#a15c00);border-style:solid}
|
|
47
56
|
.dsh-channels-cfg__help{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:11px;line-height:16px}
|
|
48
57
|
.dsh-channels-cfg__reasonBad{color:var(--dsw-alias-state-danger-primary,#c62828)}
|
|
49
58
|
.dsh-channels-cfg__input{box-sizing:border-box;width:100%;min-height:28px;padding:3px 10px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px;background:var(--dsw-alias-bg-layer-1,transparent);color:inherit;font:inherit;font-size:13px;outline:none}
|
|
59
|
+
.dsh-channels-cfg__inputRow{display:flex;align-items:center;gap:8px;min-width:0}
|
|
60
|
+
.dsh-channels-cfg__inputRow .dsh-channels-cfg__input{flex:1 1 auto;width:auto;min-width:0}
|
|
61
|
+
.dsh-channels-cfg__btn--browse{flex:none}
|
|
50
62
|
.dsh-channels-cfg__input:focus{border-color:var(--dsw-alias-state-business-primary,#0a67d3)}
|
|
51
63
|
.dsh-channels-cfg__input--invalid{border-color:var(--dsw-alias-state-danger-primary,#c62828)}
|
|
52
64
|
select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
|
|
53
65
|
.dsh-channels-cfg__check{display:inline-flex;align-items:center;gap:6px;min-height:28px;cursor:pointer;font-size:13px}
|
|
54
66
|
.dsh-channels-cfg__checkbox{width:15px;height:15px;margin:0;accent-color:var(--dsw-alias-state-business-primary,#0a67d3);cursor:pointer}
|
|
55
67
|
.dsh-channels-cfg__fieldError{color:var(--dsw-alias-state-danger-primary,#c62828);font-size:11px;line-height:16px}
|
|
56
|
-
.dsh-channels-cfg__notice{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:18px}
|
|
68
|
+
.dsh-channels-cfg__notice{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:18px;overflow-wrap:anywhere}
|
|
57
69
|
.dsh-channels-cfg__notice--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);background:color-mix(in srgb,currentColor 8%,transparent)}
|
|
58
70
|
.dsh-channels-cfg__notice--error{color:var(--dsw-alias-state-danger-primary,#c62828);background:color-mix(in srgb,currentColor 8%,transparent)}
|
|
59
71
|
.dsh-channels-cfg__banner{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-radius:8px;border:1px dashed var(--dsw-alias-state-warning-primary,#a15c00);color:var(--dsw-alias-state-warning-primary,#a15c00);font-size:12px;line-height:18px}
|
|
60
72
|
.dsh-channels-cfg__footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:2px}
|
|
61
73
|
.dsh-channels-cfg__spacer{flex:1}
|
|
74
|
+
.dsh-channels-cfg__steps{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
|
|
75
|
+
.dsh-channels-cfg__step{display:flex;align-items:flex-start;gap:10px}
|
|
76
|
+
.dsh-channels-cfg__stepIndex{display:inline-flex;align-items:center;justify-content:center;flex:none;width:20px;height:20px;border-radius:50%;border:1px solid currentColor;color:var(--dsw-alias-state-business-primary,#0a67d3);font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}
|
|
77
|
+
.dsh-channels-cfg__stepBody{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
78
|
+
.dsh-channels-cfg__stepTitle{font-size:13px;font-weight:500}
|
|
79
|
+
.dsh-channels-cfg__emptyState{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
|
|
62
80
|
.dsh-channels-cfg__readOnly{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 20px}
|
|
63
81
|
.dsh-channels-cfg__kv{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
64
82
|
.dsh-channels-cfg__kvKey{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:16px}
|
|
65
83
|
.dsh-channels-cfg__kvValue{overflow-wrap:anywhere}
|
|
66
84
|
.dsh-channels-cfg__mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
|
|
67
|
-
.dsh-channels-cfg__creds{display:flex;flex-direction:column;gap:6px}
|
|
68
85
|
.dsh-channels-cfg__cred{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
69
86
|
.dsh-channels-cfg__cred .dsh-channels-cfg__input{flex:1 1 240px;min-width:0;width:auto}
|
|
70
87
|
.dsh-channels-cfg__accessSection{display:flex;flex-direction:column;gap:6px}
|
|
@@ -81,6 +98,7 @@ select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
|
|
|
81
98
|
@container (max-width:560px){
|
|
82
99
|
.dsh-channels-cfg__facts{grid-template-columns:repeat(2,minmax(0,1fr))}
|
|
83
100
|
.dsh-channels-cfg__grid,.dsh-channels-cfg__readOnly{grid-template-columns:minmax(0,1fr)}
|
|
101
|
+
.dsh-channels-cfg__tabs{flex-wrap:nowrap;overflow-x:auto}
|
|
84
102
|
}
|
|
85
103
|
`
|
|
86
104
|
|