@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.
Files changed (50) hide show
  1. package/README.md +89 -55
  2. package/docs/access-control-design.md +5 -5
  3. package/docs/feasibility-and-design.md +54 -26
  4. package/docs/feishu-onboarding.md +26 -20
  5. package/docs/persistence.md +4 -4
  6. package/docs/refactor-plan.md +6 -5
  7. package/docs/workspace-isolation-design.md +78 -0
  8. package/lib/client.js +1715 -882
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +1556 -680
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/cards/index.ts +1 -0
  14. package/src/cards/onboarding.ts +23 -0
  15. package/src/cards/primitives.ts +20 -0
  16. package/src/cards/streaming.ts +310 -291
  17. package/src/cleanup.ts +256 -119
  18. package/src/client/api.ts +20 -0
  19. package/src/client/catalog.ts +1 -1
  20. package/src/client/index.ts +1 -1
  21. package/src/client/panel/access-card.tsx +294 -0
  22. package/src/client/panel/access-tab.tsx +46 -0
  23. package/src/client/panel/advanced-tab.tsx +80 -0
  24. package/src/client/panel/cleanup-card.tsx +106 -0
  25. package/src/client/panel/connection-tab.tsx +214 -0
  26. package/src/client/panel/credentials-card.tsx +104 -0
  27. package/src/client/panel/index.tsx +862 -0
  28. package/src/client/panel/model-tab.tsx +23 -0
  29. package/src/client/panel/sessions-tab.tsx +21 -0
  30. package/src/client/panel/shared.tsx +488 -0
  31. package/src/client/panel/workspace-tab.tsx +40 -0
  32. package/src/client/styles.ts +24 -6
  33. package/src/config.ts +44 -12
  34. package/src/domain.ts +34 -3
  35. package/src/feishu/agent-context.ts +9 -5
  36. package/src/index.ts +32 -14
  37. package/src/routing/inbound.ts +88 -43
  38. package/src/service.ts +259 -65
  39. package/src/session/isolation.ts +277 -0
  40. package/src/session/manager.ts +63 -20
  41. package/src/session/repair.ts +2 -3
  42. package/src/session/routes.ts +2 -0
  43. package/src/session-commands.ts +41 -14
  44. package/src/settings/contract.ts +223 -47
  45. package/src/settings/directory-picker.ts +121 -0
  46. package/src/settings/routes.ts +49 -2
  47. package/src/settings/surface.ts +171 -25
  48. package/src/streamer.ts +70 -30
  49. package/src/workspaces.ts +50 -28
  50. package/src/client/panel.tsx +0 -1475
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { PANEL_COPY } from '../settings/contract.ts'
12
- import { FeishuChannelsSection } from './panel.tsx'
12
+ import { FeishuChannelsSection } from './panel/index.tsx'
13
13
  import { installStyles } from './styles.ts'
14
14
 
15
15
  // 平台 client runtime 的最小结构声明:只声明本 bundle 用到的两个能力。
@@ -0,0 +1,294 @@
1
+ /**
2
+ * 访问与授权卡:fail-closed 警告、群/用户白名单增删、待处理申请处理。
3
+ *
4
+ * `access.loaded === false`(飞书未连接、插件 storage 域未打开)时白名单与
5
+ * 申请列表是「未知」而不是「空」,因此只渲染连接提示并禁用全部写操作。
6
+ * 管理员与准入模式由同 tab 上方「准入与授权」字段表单编辑,这里不再重复。
7
+ *
8
+ * @module dsh-channels/client/panel/access-card
9
+ */
10
+
11
+ import { useState } from 'react'
12
+ import { PANEL_COPY } from '../../settings/contract.ts'
13
+ import type {
14
+ AccessGroupWire,
15
+ AccessRequestWire,
16
+ AccessUserWire,
17
+ PanelWire,
18
+ } from '../../settings/contract.ts'
19
+ import { accessModeLabel, EmptyState } from './shared.tsx'
20
+ import type { BusyAction } from './shared.tsx'
21
+
22
+ /** Local `YYYY-MM-DD HH:mm` rendering for authorization timestamps. */
23
+ function formatAccessDate(ms: number): string {
24
+ if (!Number.isFinite(ms) || ms <= 0) return PANEL_COPY.unknownValue
25
+ const date = new Date(ms)
26
+ const pad = (value: number): string => String(value).padStart(2, '0')
27
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
28
+ }
29
+
30
+ /** Which authorization entry produced one grant. */
31
+ function accessSourceLabel(source: AccessGroupWire['source']): string {
32
+ switch (source) {
33
+ case 'command': return PANEL_COPY.accessSourceCommand
34
+ case 'card': return PANEL_COPY.accessSourceCard
35
+ default: return PANEL_COPY.accessSourcePanel
36
+ }
37
+ }
38
+
39
+ export interface AccessCardProps {
40
+ panel: PanelWire
41
+ busy: BusyAction | null
42
+ locked: boolean
43
+ notice: string | null
44
+ error: string | null
45
+ onAddGroup: (chatId: string, name: string) => Promise<boolean>
46
+ onRemoveGroup: (chatId: string) => void
47
+ onAddUser: (openId: string, name: string) => Promise<boolean>
48
+ onRemoveUser: (openId: string) => void
49
+ onResolveRequest: (token: string, decision: 'allow' | 'deny') => void
50
+ }
51
+
52
+ export function AccessCard(props: AccessCardProps): JSX.Element {
53
+ const { panel, busy, locked, notice, error, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props
54
+ const { access } = panel
55
+ const [groupChatId, setGroupChatId] = useState('')
56
+ const [groupName, setGroupName] = useState('')
57
+ const [userOpenId, setUserOpenId] = useState('')
58
+ const [userName, setUserName] = useState('')
59
+ const groups: readonly AccessGroupWire[] = access.groups
60
+ const users: readonly AccessUserWire[] = access.users
61
+ const requests: readonly AccessRequestWire[] = access.requests
62
+ const working = busy === 'access'
63
+ const disabled = busy !== null || locked || !access.loaded
64
+
65
+ const submitGroup = async (): Promise<void> => {
66
+ if (groupChatId.trim() === '') return
67
+ if (await onAddGroup(groupChatId, groupName)) {
68
+ setGroupChatId('')
69
+ setGroupName('')
70
+ }
71
+ }
72
+
73
+ const submitUser = async (): Promise<void> => {
74
+ if (userOpenId.trim() === '') return
75
+ if (await onAddUser(userOpenId, userName)) {
76
+ setUserOpenId('')
77
+ setUserName('')
78
+ }
79
+ }
80
+
81
+ return (
82
+ <section className="dsh-channels-cfg__card">
83
+ <div className="dsh-channels-cfg__cardHead">
84
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.accessCard}</span>
85
+ <span className="dsh-channels-cfg__cardMeta">{accessModeLabel(panel, access.mode)}</span>
86
+ </div>
87
+ <span className="dsh-channels-cfg__help">{PANEL_COPY.accessCardHelp}</span>
88
+
89
+ {access.mode === 'open'
90
+ ? <div className="dsh-channels-cfg__banner">{PANEL_COPY.accessModeOpenWarning}</div>
91
+ : null}
92
+ {access.loaded && access.mode === 'guarded' && !access.admin.configured
93
+ ? (
94
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
95
+ <strong>{PANEL_COPY.accessAdminMissingWarning}</strong>
96
+ </div>
97
+ )
98
+ : null}
99
+
100
+ {!access.loaded
101
+ ? <EmptyState text={PANEL_COPY.accessNotLoaded} />
102
+ : (
103
+ <>
104
+ <div className="dsh-channels-cfg__accessSection">
105
+ <div className="dsh-channels-cfg__labelRow">
106
+ <span className="dsh-channels-cfg__label">{PANEL_COPY.accessGroupsTitle}</span>
107
+ <span className="dsh-channels-cfg__cardMeta">{groups.length} 个</span>
108
+ </div>
109
+ {groups.length === 0
110
+ ? <span className="dsh-channels-cfg__help">{PANEL_COPY.accessGroupsEmpty}</span>
111
+ : (
112
+ <ul className="dsh-channels-cfg__accessList">
113
+ {groups.map(group => (
114
+ <li className="dsh-channels-cfg__accessRow" key={group.chatId}>
115
+ <span className="dsh-channels-cfg__accessMain">
116
+ <span>{group.name ?? group.chatId}</span>
117
+ <span className="dsh-channels-cfg__accessMeta">
118
+ <span className="dsh-channels-cfg__mono">{group.chatId}</span>
119
+ {` · ${accessSourceLabel(group.source)} · ${formatAccessDate(group.addedAt)}`}
120
+ </span>
121
+ </span>
122
+ <button
123
+ type="button"
124
+ className="dsh-channels-cfg__btn"
125
+ disabled={disabled}
126
+ onClick={() => onRemoveGroup(group.chatId)}
127
+ >
128
+ {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove}
129
+ </button>
130
+ </li>
131
+ ))}
132
+ </ul>
133
+ )}
134
+ <div className="dsh-channels-cfg__accessAdd">
135
+ <input
136
+ type="text"
137
+ className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
138
+ value={groupChatId}
139
+ placeholder={PANEL_COPY.accessGroupChatIdPlaceholder}
140
+ aria-label={PANEL_COPY.accessGroupChatIdPlaceholder}
141
+ disabled={disabled}
142
+ onChange={event => setGroupChatId(event.target.value)}
143
+ />
144
+ <input
145
+ type="text"
146
+ className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
147
+ value={groupName}
148
+ placeholder={PANEL_COPY.accessGroupNamePlaceholder}
149
+ aria-label={PANEL_COPY.accessGroupNamePlaceholder}
150
+ disabled={disabled}
151
+ onChange={event => setGroupName(event.target.value)}
152
+ />
153
+ <button
154
+ type="button"
155
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
156
+ disabled={disabled || groupChatId.trim() === ''}
157
+ onClick={() => { void submitGroup() }}
158
+ >
159
+ {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddGroup}
160
+ </button>
161
+ </div>
162
+ </div>
163
+
164
+ <div className="dsh-channels-cfg__accessSection">
165
+ <div className="dsh-channels-cfg__labelRow">
166
+ <span className="dsh-channels-cfg__label">{PANEL_COPY.accessUsersTitle}</span>
167
+ <span className="dsh-channels-cfg__cardMeta">{users.length} 个</span>
168
+ </div>
169
+ {users.length === 0
170
+ ? <span className="dsh-channels-cfg__help">{PANEL_COPY.accessUsersEmpty}</span>
171
+ : (
172
+ <ul className="dsh-channels-cfg__accessList">
173
+ {users.map(user => (
174
+ <li className="dsh-channels-cfg__accessRow" key={user.openId}>
175
+ <span className="dsh-channels-cfg__accessMain">
176
+ <span>{user.name ?? user.openId}</span>
177
+ <span className="dsh-channels-cfg__accessMeta">
178
+ <span className="dsh-channels-cfg__mono">{user.openId}</span>
179
+ {` · ${accessSourceLabel(user.source)} · ${formatAccessDate(user.addedAt)}`}
180
+ </span>
181
+ </span>
182
+ <button
183
+ type="button"
184
+ className="dsh-channels-cfg__btn"
185
+ disabled={disabled}
186
+ onClick={() => onRemoveUser(user.openId)}
187
+ >
188
+ {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove}
189
+ </button>
190
+ </li>
191
+ ))}
192
+ </ul>
193
+ )}
194
+ <div className="dsh-channels-cfg__accessAdd">
195
+ <input
196
+ type="text"
197
+ className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
198
+ value={userOpenId}
199
+ placeholder={PANEL_COPY.accessUserOpenIdPlaceholder}
200
+ aria-label={PANEL_COPY.accessUserOpenIdPlaceholder}
201
+ disabled={disabled}
202
+ onChange={event => setUserOpenId(event.target.value)}
203
+ />
204
+ <input
205
+ type="text"
206
+ className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
207
+ value={userName}
208
+ placeholder={PANEL_COPY.accessUserNamePlaceholder}
209
+ aria-label={PANEL_COPY.accessUserNamePlaceholder}
210
+ disabled={disabled}
211
+ onChange={event => setUserName(event.target.value)}
212
+ />
213
+ <button
214
+ type="button"
215
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
216
+ disabled={disabled || userOpenId.trim() === ''}
217
+ onClick={() => { void submitUser() }}
218
+ >
219
+ {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddUser}
220
+ </button>
221
+ </div>
222
+ </div>
223
+
224
+ <div className="dsh-channels-cfg__accessSection">
225
+ <div className="dsh-channels-cfg__labelRow">
226
+ <span className="dsh-channels-cfg__label">{PANEL_COPY.accessRequestsTitle}</span>
227
+ <span className="dsh-channels-cfg__cardMeta">{requests.length} 条</span>
228
+ </div>
229
+ {requests.length === 0
230
+ ? <span className="dsh-channels-cfg__help">{PANEL_COPY.accessRequestsEmpty}</span>
231
+ : (
232
+ <ul className="dsh-channels-cfg__accessList">
233
+ {requests.map(request => (
234
+ <li className="dsh-channels-cfg__accessRow" key={request.token}>
235
+ <span className="dsh-channels-cfg__accessMain">
236
+ <span>
237
+ {request.kind === 'group' ? PANEL_COPY.accessRequestGroup : PANEL_COPY.accessRequestUser}
238
+ {':'}
239
+ {request.name ?? request.targetId}
240
+ </span>
241
+ <span className="dsh-channels-cfg__accessMeta">
242
+ <span className="dsh-channels-cfg__mono">{request.targetId}</span>
243
+ {` · ${formatAccessDate(request.createdAt)} · ${PANEL_COPY.accessRequestExpires} ${formatAccessDate(request.expiresAt)}`}
244
+ {request.kind === 'user' && request.requester !== undefined
245
+ ? ` · ${PANEL_COPY.accessRequestRequester} ${request.requester}`
246
+ : ''}
247
+ {request.kind === 'user' && request.groupName !== undefined
248
+ ? ` · ${request.groupName}`
249
+ : request.kind === 'user' && request.chatId !== undefined
250
+ ? ` · ${request.chatId}`
251
+ : ''}
252
+ </span>
253
+ </span>
254
+ <span className="dsh-channels-cfg__accessActions">
255
+ <button
256
+ type="button"
257
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
258
+ disabled={disabled}
259
+ onClick={() => onResolveRequest(request.token, 'allow')}
260
+ >
261
+ {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAllow}
262
+ </button>
263
+ <button
264
+ type="button"
265
+ className="dsh-channels-cfg__btn"
266
+ disabled={disabled}
267
+ onClick={() => onResolveRequest(request.token, 'deny')}
268
+ >
269
+ {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessDeny}
270
+ </button>
271
+ </span>
272
+ </li>
273
+ ))}
274
+ </ul>
275
+ )}
276
+ </div>
277
+ </>
278
+ )}
279
+
280
+ {notice === null
281
+ ? null
282
+ : (
283
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok" role="status" aria-live="polite">
284
+ <span>{notice}</span>
285
+ </div>
286
+ )}
287
+ {error === null
288
+ ? null
289
+ : <span className="dsh-channels-cfg__fieldError" role="alert">{error}</span>}
290
+ <span className="dsh-channels-cfg__help">{PANEL_COPY.accessHints}</span>
291
+ <span className="dsh-channels-cfg__help">{PANEL_COPY.accessChatIdHint}</span>
292
+ </section>
293
+ )
294
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 「访问授权」tab:准入与授权字段表单(管理员 open_id / 准入模式 /
3
+ * 申请有效期)+ 白名单与待处理申请卡。表单保存走全局 draft,白名单
4
+ * 增删是独立即时 mutation。
5
+ *
6
+ * @module dsh-channels/client/panel/access-tab
7
+ */
8
+
9
+ import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
10
+ import { AccessCard } from './access-card.tsx'
11
+ import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
12
+ import type { FormView } from './shared.tsx'
13
+
14
+ export interface AccessTabProps {
15
+ form: FormView
16
+ accessNotice: string | null
17
+ accessError: string | null
18
+ onAddGroup: (chatId: string, name: string) => Promise<boolean>
19
+ onRemoveGroup: (chatId: string) => void
20
+ onAddUser: (openId: string, name: string) => Promise<boolean>
21
+ onRemoveUser: (openId: string) => void
22
+ onResolveRequest: (token: string, decision: 'allow' | 'deny') => void
23
+ }
24
+
25
+ export function AccessTab(props: AccessTabProps): JSX.Element {
26
+ const { form, accessNotice, accessError, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props
27
+ return (
28
+ <div className="dsh-channels-cfg__tabBody">
29
+ <TabHelp text={PANEL_COPY.accessTabHelp} />
30
+ <FieldsCard form={form} groups={TAB_FIELD_GROUPS.access} />
31
+ <SaveBar form={form} tab="access" />
32
+ <AccessCard
33
+ panel={form.panel}
34
+ busy={form.busy}
35
+ locked={form.locked}
36
+ notice={accessNotice}
37
+ error={accessError}
38
+ onAddGroup={onAddGroup}
39
+ onRemoveGroup={onRemoveGroup}
40
+ onAddUser={onAddUser}
41
+ onRemoveUser={onRemoveUser}
42
+ onResolveRequest={onResolveRequest}
43
+ />
44
+ </div>
45
+ )
46
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 「高级」tab:流式刷新等低层字段 + 只读部署信息(overlayPath / version /
3
+ * 部署级参数)+ 「清除 Web 覆盖」。清除覆盖是整体动作,不走 draft。
4
+ *
5
+ * @module dsh-channels/client/panel/advanced-tab
6
+ */
7
+
8
+ import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
9
+ import type { PanelWire } from '../../settings/contract.ts'
10
+ import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
11
+ import type { BusyAction, FormView } from './shared.tsx'
12
+
13
+ export function AdvancedTab(props: { form: FormView; onClear: () => void }): JSX.Element {
14
+ const { form, onClear } = props
15
+ return (
16
+ <div className="dsh-channels-cfg__tabBody">
17
+ <TabHelp text={PANEL_COPY.advancedTabHelp} />
18
+ <FieldsCard form={form} groups={TAB_FIELD_GROUPS.advanced} />
19
+ <SaveBar form={form} tab="advanced" />
20
+ <AdvancedCard panel={form.panel} busy={form.busy} locked={form.locked} onClear={onClear} />
21
+ </div>
22
+ )
23
+ }
24
+
25
+ /** Read-only deployment facts; the only write action here drops the overlay file. */
26
+ function AdvancedCard(props: {
27
+ panel: PanelWire
28
+ busy: BusyAction | null
29
+ locked: boolean
30
+ onClear: () => void
31
+ }): JSX.Element {
32
+ const { panel, busy, locked, onClear } = props
33
+ const { readOnly } = panel.config
34
+ const overridden = panel.config.overriddenKeys.length
35
+ return (
36
+ <section className="dsh-channels-cfg__card">
37
+ <div className="dsh-channels-cfg__cardHead">
38
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.advancedCard}</span>
39
+ <span className="dsh-channels-cfg__cardMeta">
40
+ {overridden === 0 ? PANEL_COPY.inherited : `${PANEL_COPY.overridden} ${overridden} 项`}
41
+ </span>
42
+ </div>
43
+ <span className="dsh-channels-cfg__help">{PANEL_COPY.advancedHelp}</span>
44
+ <div className="dsh-channels-cfg__labelRow">
45
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.readOnlyCard}</span>
46
+ </div>
47
+ <div className="dsh-channels-cfg__readOnly">
48
+ {readOnly.map(field => (
49
+ <div className="dsh-channels-cfg__kv" key={field.key}>
50
+ <span className="dsh-channels-cfg__kvKey">{field.label}</span>
51
+ <span className="dsh-channels-cfg__kvValue">{field.value}</span>
52
+ {field.help === undefined ? null : <span className="dsh-channels-cfg__help">{field.help}</span>}
53
+ </div>
54
+ ))}
55
+ <div className="dsh-channels-cfg__kv">
56
+ <span className="dsh-channels-cfg__kvKey">{PANEL_COPY.overlayPath}</span>
57
+ <span className="dsh-channels-cfg__kvValue dsh-channels-cfg__mono">{panel.status.overlayPath}</span>
58
+ <span className="dsh-channels-cfg__help">{PANEL_COPY.overlayPathHelp}</span>
59
+ </div>
60
+ <div className="dsh-channels-cfg__kv">
61
+ <span className="dsh-channels-cfg__kvKey">{PANEL_COPY.version}</span>
62
+ <span className="dsh-channels-cfg__kvValue dsh-channels-cfg__mono">{panel.status.version}</span>
63
+ </div>
64
+ </div>
65
+ <div className="dsh-channels-cfg__actions">
66
+ <button
67
+ type="button"
68
+ className="dsh-channels-cfg__btn"
69
+ onClick={onClear}
70
+ disabled={overridden === 0 || busy !== null || locked}
71
+ >
72
+ {busy === 'clear' ? PANEL_COPY.clearing : PANEL_COPY.clear}
73
+ </button>
74
+ <span className="dsh-channels-cfg__help">
75
+ {overridden === 0 ? PANEL_COPY.noOverrides : `${PANEL_COPY.overridden} ${overridden} 项`}
76
+ </span>
77
+ </div>
78
+ </section>
79
+ )
80
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 目录清理卡:展示清理预览(保留期 / 自动清理 / 候选数量与空间),
3
+ * 执行前二次确认,结果与部分失败逐条展示。`cleanup.error` 非空时
4
+ * (例如未连接、无法扫描工作区)按钮天然禁用并显示原因。
5
+ *
6
+ * @module dsh-channels/client/panel/cleanup-card
7
+ */
8
+
9
+ import { PANEL_COPY } from '../../settings/contract.ts'
10
+ import type { CleanupPreviewWire, CleanupRunWire, PanelWire } from '../../settings/contract.ts'
11
+ import { formatBytes, retentionLabel } from './shared.tsx'
12
+ import type { BusyAction } from './shared.tsx'
13
+
14
+ export function CleanupCard(props: {
15
+ panel: PanelWire
16
+ cleanup: CleanupPreviewWire
17
+ busy: BusyAction | null
18
+ locked: boolean
19
+ result: CleanupRunWire | null
20
+ error: string | null
21
+ onRun: () => void
22
+ }): JSX.Element {
23
+ const { panel, cleanup, busy, locked, result, error, onRun } = props
24
+ const autoText = cleanup.retention === 'off'
25
+ ? PANEL_COPY.cleanupAutoInactive
26
+ : cleanup.auto ? PANEL_COPY.cleanupAutoOn : PANEL_COPY.cleanupAutoOff
27
+ const blocked = cleanup.error !== undefined
28
+ ? `${PANEL_COPY.cleanupPreviewFailed}:${cleanup.error}`
29
+ : cleanup.retention === 'off'
30
+ ? PANEL_COPY.cleanupDisabledOff
31
+ : cleanup.candidates === 0
32
+ ? PANEL_COPY.cleanupDisabledEmpty
33
+ : null
34
+ const facts = [
35
+ { label: PANEL_COPY.cleanupRetention, value: retentionLabel(panel, cleanup) },
36
+ { label: PANEL_COPY.cleanupAutoLabel, value: autoText },
37
+ { label: PANEL_COPY.cleanupCandidates, value: `${cleanup.candidates} 个` },
38
+ { label: PANEL_COPY.cleanupWorkspaces, value: `${cleanup.workspaces} 个` },
39
+ { label: PANEL_COPY.cleanupCandidateBytes, value: formatBytes(cleanup.candidateBytes) },
40
+ ]
41
+ const running = busy === 'cleanup'
42
+ const failed = result !== null && (!result.ok || error !== null)
43
+ return (
44
+ <section className="dsh-channels-cfg__card">
45
+ <div className="dsh-channels-cfg__cardHead">
46
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.cleanupCard}</span>
47
+ <span className="dsh-channels-cfg__cardMeta">{retentionLabel(panel, cleanup)}</span>
48
+ </div>
49
+ <span className="dsh-channels-cfg__help">{PANEL_COPY.cleanupCardHelp}</span>
50
+ <div className="dsh-channels-cfg__facts">
51
+ {facts.map(fact => (
52
+ <div className="dsh-channels-cfg__fact" key={fact.label}>
53
+ <span className="dsh-channels-cfg__factLabel">{fact.label}</span>
54
+ <span className="dsh-channels-cfg__factValue">{fact.value}</span>
55
+ </div>
56
+ ))}
57
+ </div>
58
+ {blocked === null ? null : <span className="dsh-channels-cfg__help">{blocked}</span>}
59
+ {result === null
60
+ ? null
61
+ : (
62
+ <div className={`dsh-channels-cfg__notice dsh-channels-cfg__notice--${failed ? 'error' : 'ok'}`}>
63
+ {result.ok
64
+ ? (
65
+ <span>
66
+ {PANEL_COPY.cleanupDone}:{PANEL_COPY.cleanupDeleted} {result.deletedWorkspaces} 个,
67
+ {PANEL_COPY.cleanupDeletedDirs} {result.deletedSessionDirs} 个,
68
+ {PANEL_COPY.cleanupRetired} {result.retiredRoutes} 条,
69
+ {PANEL_COPY.cleanupFreed} {formatBytes(result.freedBytes)}。
70
+ </span>
71
+ )
72
+ : (
73
+ <span>
74
+ {PANEL_COPY.cleanupFailed}
75
+ {result.error === undefined ? '' : `:${result.error}`}
76
+ </span>
77
+ )}
78
+ {result.failures.length === 0
79
+ ? null
80
+ : (
81
+ <>
82
+ <span>{PANEL_COPY.cleanupFailures}</span>
83
+ <ul className="dsh-channels-cfg__issues">
84
+ {result.failures.map((failure, index) => (
85
+ <li key={`${index}-${failure}`} className="dsh-channels-cfg__mono">{failure}</li>
86
+ ))}
87
+ </ul>
88
+ </>
89
+ )}
90
+ </div>
91
+ )}
92
+ {error === null ? null : <span className="dsh-channels-cfg__fieldError">{error}</span>}
93
+ <div className="dsh-channels-cfg__actions">
94
+ <button
95
+ type="button"
96
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--danger"
97
+ onClick={onRun}
98
+ disabled={running || busy !== null || locked || blocked !== null}
99
+ >
100
+ {running ? PANEL_COPY.cleanupRunning : PANEL_COPY.cleanupNow}
101
+ </button>
102
+ <span className="dsh-channels-cfg__help">{PANEL_COPY.cleanupConfirmHint}</span>
103
+ </div>
104
+ </section>
105
+ )
106
+ }