@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.2
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 +83 -57
- package/docs/access-control-design.md +8 -5
- package/docs/attachment-pipeline-v2.md +33 -41
- package/docs/feishu-onboarding.md +36 -33
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +1 -1
- package/docs/workspace-isolation-design.md +7 -8
- package/lib/client.js +444 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +9017 -6794
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +43 -13
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +108 -0
- package/src/cards/streaming.ts +56 -7
- package/src/cards/wiki.ts +124 -0
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +96 -24
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +5 -0
- package/src/commands/registry.ts +8 -5
- package/src/config.ts +55 -54
- package/src/domain.ts +36 -123
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/inbound-attachments.ts +1 -1
- package/src/feishu/manifest.ts +8 -31
- package/src/feishu/message-read.ts +97 -24
- package/src/feishu/scopes.ts +194 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +72 -112
- package/src/feishu/wiki/api.ts +346 -0
- package/src/feishu/wiki/consent.ts +183 -0
- package/src/feishu/wiki/links.ts +122 -0
- package/src/feishu/wiki/scope.ts +66 -0
- package/src/feishu/wiki/tools.ts +984 -0
- package/src/feishu-channel.ts +280 -9
- package/src/index.ts +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +73 -106
- package/src/service.ts +545 -440
- package/src/session/manager.ts +1 -1
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +110 -89
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +144 -94
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +21 -29
- package/src/cleanup.ts +0 -546
- package/src/client/panel/cleanup-card.tsx +0 -106
- package/src/feishu/pending-attachments.ts +0 -562
|
@@ -44,8 +44,8 @@ export interface AccessCardProps {
|
|
|
44
44
|
error: string | null
|
|
45
45
|
onAddGroup: (chatId: string, name: string) => Promise<boolean>
|
|
46
46
|
onRemoveGroup: (chatId: string) => void
|
|
47
|
-
onAddUser: (
|
|
48
|
-
onRemoveUser: (
|
|
47
|
+
onAddUser: (userId: string, name: string) => Promise<boolean>
|
|
48
|
+
onRemoveUser: (userId: string) => void
|
|
49
49
|
onResolveRequest: (token: string, decision: 'allow' | 'deny') => void
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -54,7 +54,7 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
|
|
|
54
54
|
const { access } = panel
|
|
55
55
|
const [groupChatId, setGroupChatId] = useState('')
|
|
56
56
|
const [groupName, setGroupName] = useState('')
|
|
57
|
-
const [
|
|
57
|
+
const [userUserId, setUserUserId] = useState('')
|
|
58
58
|
const [userName, setUserName] = useState('')
|
|
59
59
|
const groups: readonly AccessGroupWire[] = access.groups
|
|
60
60
|
const users: readonly AccessUserWire[] = access.users
|
|
@@ -71,9 +71,9 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
const submitUser = async (): Promise<void> => {
|
|
74
|
-
if (
|
|
75
|
-
if (await onAddUser(
|
|
76
|
-
|
|
74
|
+
if (userUserId.trim() === '') return
|
|
75
|
+
if (await onAddUser(userUserId, userName)) {
|
|
76
|
+
setUserUserId('')
|
|
77
77
|
setUserName('')
|
|
78
78
|
}
|
|
79
79
|
}
|
|
@@ -93,6 +93,15 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
|
|
|
93
93
|
? (
|
|
94
94
|
<div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
|
|
95
95
|
<strong>{PANEL_COPY.accessAdminMissingWarning}</strong>
|
|
96
|
+
{access.admin.ownerLookupError === undefined ? null : <div>{access.admin.ownerLookupError}</div>}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
: null}
|
|
100
|
+
{access.mode === 'guarded' && access.admin.source === 'owner' && access.admin.userId !== undefined
|
|
101
|
+
? (
|
|
102
|
+
<div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">
|
|
103
|
+
{PANEL_COPY.accessAdminOwnerNotice}
|
|
104
|
+
<span className="dsh-channels-cfg__mono">{access.admin.userId}</span>
|
|
96
105
|
</div>
|
|
97
106
|
)
|
|
98
107
|
: null}
|
|
@@ -171,11 +180,11 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
|
|
|
171
180
|
: (
|
|
172
181
|
<ul className="dsh-channels-cfg__accessList">
|
|
173
182
|
{users.map(user => (
|
|
174
|
-
<li className="dsh-channels-cfg__accessRow" key={user.
|
|
183
|
+
<li className="dsh-channels-cfg__accessRow" key={user.userId}>
|
|
175
184
|
<span className="dsh-channels-cfg__accessMain">
|
|
176
|
-
<span>{user.name ?? user.
|
|
185
|
+
<span>{user.name ?? user.userId}</span>
|
|
177
186
|
<span className="dsh-channels-cfg__accessMeta">
|
|
178
|
-
<span className="dsh-channels-cfg__mono">{user.
|
|
187
|
+
<span className="dsh-channels-cfg__mono">{user.userId}</span>
|
|
179
188
|
{` · ${accessSourceLabel(user.source)} · ${formatAccessDate(user.addedAt)}`}
|
|
180
189
|
</span>
|
|
181
190
|
</span>
|
|
@@ -183,7 +192,7 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
|
|
|
183
192
|
type="button"
|
|
184
193
|
className="dsh-channels-cfg__btn"
|
|
185
194
|
disabled={disabled}
|
|
186
|
-
onClick={() => onRemoveUser(user.
|
|
195
|
+
onClick={() => onRemoveUser(user.userId)}
|
|
187
196
|
>
|
|
188
197
|
{working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove}
|
|
189
198
|
</button>
|
|
@@ -195,11 +204,11 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
|
|
|
195
204
|
<input
|
|
196
205
|
type="text"
|
|
197
206
|
className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
|
|
198
|
-
value={
|
|
199
|
-
placeholder={PANEL_COPY.
|
|
200
|
-
aria-label={PANEL_COPY.
|
|
207
|
+
value={userUserId}
|
|
208
|
+
placeholder={PANEL_COPY.accessUserIdPlaceholder}
|
|
209
|
+
aria-label={PANEL_COPY.accessUserIdPlaceholder}
|
|
201
210
|
disabled={disabled}
|
|
202
|
-
onChange={event =>
|
|
211
|
+
onChange={event => setUserUserId(event.target.value)}
|
|
203
212
|
/>
|
|
204
213
|
<input
|
|
205
214
|
type="text"
|
|
@@ -213,7 +222,7 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
|
|
|
213
222
|
<button
|
|
214
223
|
type="button"
|
|
215
224
|
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
|
|
216
|
-
disabled={disabled ||
|
|
225
|
+
disabled={disabled || userUserId.trim() === ''}
|
|
217
226
|
onClick={() => { void submitUser() }}
|
|
218
227
|
>
|
|
219
228
|
{working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddUser}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 「访问授权」tab:准入与授权字段表单(管理员
|
|
2
|
+
* 「访问授权」tab:准入与授权字段表单(管理员 user_id / 准入模式 /
|
|
3
3
|
* 申请有效期)+ 白名单与待处理申请卡。表单保存走全局 draft,白名单
|
|
4
4
|
* 增删是独立即时 mutation。
|
|
5
5
|
*
|
|
@@ -17,8 +17,8 @@ export interface AccessTabProps {
|
|
|
17
17
|
accessError: string | null
|
|
18
18
|
onAddGroup: (chatId: string, name: string) => Promise<boolean>
|
|
19
19
|
onRemoveGroup: (chatId: string) => void
|
|
20
|
-
onAddUser: (
|
|
21
|
-
onRemoveUser: (
|
|
20
|
+
onAddUser: (userId: string, name: string) => Promise<boolean>
|
|
21
|
+
onRemoveUser: (userId: string) => void
|
|
22
22
|
onResolveRequest: (token: string, decision: 'allow' | 'deny') => void
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* - error:连接信息卡 + 最近一次连接失败 + 「重试」
|
|
7
7
|
* - connected:连接信息卡 + 「重启飞书连接」+ 凭据卡
|
|
8
8
|
* - connecting:禁用按钮显示「连接中…」
|
|
9
|
+
* - 已连接且权限自检完成:权限自检卡(缺失权限红字 + 开发者后台深链)
|
|
9
10
|
*
|
|
10
11
|
* @module dsh-channels/client/panel/connection-tab
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { PANEL_COPY } from '../../settings/contract.ts'
|
|
14
|
-
import type { ChannelStatusWire, PanelWire } from '../../settings/contract.ts'
|
|
15
|
+
import type { ChannelStatusWire, PanelWire, ScopeCheckWire, SlashCommandSyncWire } from '../../settings/contract.ts'
|
|
15
16
|
import { CredentialsCard } from './credentials-card.tsx'
|
|
16
17
|
import { connectionStateLabel, fieldLabel, formatUptime, phaseBadge, TabHelp } from './shared.tsx'
|
|
17
18
|
import type { BusyAction, CredentialsView } from './shared.tsx'
|
|
@@ -35,6 +36,8 @@ export function ConnectionTab(props: ConnectionTabProps): JSX.Element {
|
|
|
35
36
|
{status.phase === 'unconfigured'
|
|
36
37
|
? <OnboardingCard status={status} />
|
|
37
38
|
: <ConnectionInfoCard status={status} busy={busy} onReconnect={onReconnect} />}
|
|
39
|
+
{status.scopeCheck === undefined ? null : <ScopeCheckCard check={status.scopeCheck} />}
|
|
40
|
+
{status.slashCommandSync === undefined ? null : <SlashCommandSyncCard sync={status.slashCommandSync} />}
|
|
38
41
|
<CredentialsCard panel={panel} credentials={credentials} />
|
|
39
42
|
</div>
|
|
40
43
|
)
|
|
@@ -186,6 +189,72 @@ function ConnectionInfoCard(props: {
|
|
|
186
189
|
)
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
/** Post-connect scope self-check: missing scopes in red, each with a console deep link. */
|
|
193
|
+
function ScopeCheckCard(props: { check: ScopeCheckWire }): JSX.Element {
|
|
194
|
+
const { check } = props
|
|
195
|
+
return (
|
|
196
|
+
<section className="dsh-channels-cfg__card">
|
|
197
|
+
<div className="dsh-channels-cfg__cardHead">
|
|
198
|
+
<span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.scopeCheckTitle}</span>
|
|
199
|
+
</div>
|
|
200
|
+
{check.state === 'ok'
|
|
201
|
+
? <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">{PANEL_COPY.scopeCheckOk}</div>
|
|
202
|
+
: null}
|
|
203
|
+
{check.state === 'error'
|
|
204
|
+
? (
|
|
205
|
+
<div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
|
|
206
|
+
<strong>{PANEL_COPY.scopeCheckError}</strong>
|
|
207
|
+
<span className="dsh-channels-cfg__mono">{check.error}</span>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
: null}
|
|
211
|
+
{check.state === 'missing'
|
|
212
|
+
? (
|
|
213
|
+
<div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
|
|
214
|
+
<strong>{PANEL_COPY.scopeCheckMissing}</strong>
|
|
215
|
+
<ul className="dsh-channels-cfg__scopeList">
|
|
216
|
+
{check.missing.map(item => (
|
|
217
|
+
<li key={item.scopes.join(',')}>
|
|
218
|
+
<span>[{item.level === 'required' ? PANEL_COPY.scopeCheckRequired : PANEL_COPY.scopeCheckOptional}] </span>
|
|
219
|
+
<span className="dsh-channels-cfg__mono">{item.scopes.join(' / ')}</span>
|
|
220
|
+
<span>:{item.purpose} </span>
|
|
221
|
+
<a href={item.consoleUrl} target="_blank" rel="noreferrer noopener">{PANEL_COPY.scopeCheckOpenOne}</a>
|
|
222
|
+
</li>
|
|
223
|
+
))}
|
|
224
|
+
</ul>
|
|
225
|
+
<a href={check.consoleUrl} target="_blank" rel="noreferrer noopener">{PANEL_COPY.scopeCheckOpenAll}</a>
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
: null}
|
|
229
|
+
</section>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Post-connect native slash-command panel sync: counts on success, the Feishu reason on failure. */
|
|
234
|
+
function SlashCommandSyncCard(props: { sync: SlashCommandSyncWire }): JSX.Element {
|
|
235
|
+
const { sync } = props
|
|
236
|
+
return (
|
|
237
|
+
<section className="dsh-channels-cfg__card">
|
|
238
|
+
<div className="dsh-channels-cfg__cardHead">
|
|
239
|
+
<span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.slashCommandSyncTitle}</span>
|
|
240
|
+
</div>
|
|
241
|
+
{sync.state === 'ok'
|
|
242
|
+
? (
|
|
243
|
+
<div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">
|
|
244
|
+
<span>{PANEL_COPY.slashCommandSyncOk} </span>
|
|
245
|
+
<span>{PANEL_COPY.slashCommandSyncTotal} {sync.total} · {PANEL_COPY.slashCommandSyncCreated} {sync.created} · {PANEL_COPY.slashCommandSyncUpdated} {sync.updated} · {PANEL_COPY.slashCommandSyncRemoved} {sync.removed}</span>
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
: (
|
|
249
|
+
<div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
|
|
250
|
+
<strong>{PANEL_COPY.slashCommandSyncError}</strong>
|
|
251
|
+
<span className="dsh-channels-cfg__mono">{sync.error}</span>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</section>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
189
258
|
/** Config saved but only applied on the next connect: offer the one-click restart. */
|
|
190
259
|
function RestartBanner(props: {
|
|
191
260
|
panel: PanelWire
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft 与顶部状态头 /
|
|
3
3
|
* tab 导航的外壳。
|
|
4
4
|
*
|
|
5
|
-
* 所有 Host 交互(panel / status / catalog / config / credentials /
|
|
6
|
-
*
|
|
5
|
+
* 所有 Host 交互(panel / status / catalog / config / credentials / access /
|
|
6
|
+
* pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
|
|
7
7
|
* 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `settings.section`
|
|
8
8
|
* 槽位。
|
|
9
9
|
*
|
|
@@ -17,7 +17,6 @@ import type {
|
|
|
17
17
|
AccessWire,
|
|
18
18
|
CatalogWire,
|
|
19
19
|
ChannelStatusWire,
|
|
20
|
-
CleanupRunWire,
|
|
21
20
|
ConfigValue,
|
|
22
21
|
CredentialField,
|
|
23
22
|
EditableKey,
|
|
@@ -39,7 +38,6 @@ import {
|
|
|
39
38
|
removeAccessGroup,
|
|
40
39
|
removeAccessUser,
|
|
41
40
|
resolveAccessRequest,
|
|
42
|
-
runCleanup,
|
|
43
41
|
saveConfig,
|
|
44
42
|
saveCredentials,
|
|
45
43
|
} from '../api.ts'
|
|
@@ -51,7 +49,6 @@ import { ModelTab } from './model-tab.tsx'
|
|
|
51
49
|
import { SessionsTab } from './sessions-tab.tsx'
|
|
52
50
|
import {
|
|
53
51
|
fieldLabel,
|
|
54
|
-
formatBytes,
|
|
55
52
|
messageOf,
|
|
56
53
|
NoticeBar,
|
|
57
54
|
phaseBadge,
|
|
@@ -85,8 +82,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
85
82
|
const [appSecretInput, setAppSecretInput] = useState('')
|
|
86
83
|
const [catalog, setCatalog] = useState<CatalogWire | null>(null)
|
|
87
84
|
const [catalogBusy, setCatalogBusy] = useState(false)
|
|
88
|
-
const [cleanupResult, setCleanupResult] = useState<CleanupRunWire | null>(null)
|
|
89
|
-
const [cleanupError, setCleanupError] = useState<string | null>(null)
|
|
90
85
|
const [accessNotice, setAccessNotice] = useState<string | null>(null)
|
|
91
86
|
const [accessError, setAccessError] = useState<string | null>(null)
|
|
92
87
|
const catalogTicket = useRef(0)
|
|
@@ -102,8 +97,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
102
97
|
const loadPanel = useCallback(async (): Promise<void> => {
|
|
103
98
|
setLoad({ phase: 'loading' })
|
|
104
99
|
setNotice(null)
|
|
105
|
-
setCleanupResult(null)
|
|
106
|
-
setCleanupError(null)
|
|
107
100
|
setAccessNotice(null)
|
|
108
101
|
setAccessError(null)
|
|
109
102
|
try {
|
|
@@ -145,7 +138,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
145
138
|
setPollError(null)
|
|
146
139
|
// A phase transition changes what the panel payload can offer: the
|
|
147
140
|
// storage domain opens/closes with the connection, so the access
|
|
148
|
-
// whitelists
|
|
141
|
+
// whitelists go stale. Re-read the panel but
|
|
149
142
|
// never touch the dirty draft.
|
|
150
143
|
if (previousPhase !== undefined && previousPhase !== next.phase) {
|
|
151
144
|
try {
|
|
@@ -260,8 +253,8 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
260
253
|
}, [])
|
|
261
254
|
|
|
262
255
|
/**
|
|
263
|
-
* Manual refresh: reload the whole panel payload so access lists
|
|
264
|
-
*
|
|
256
|
+
* Manual refresh: reload the whole panel payload so access lists and
|
|
257
|
+
* `restartPendingKeys` catch up with the Host. The dirty draft is
|
|
265
258
|
* deliberately preserved — refreshing never discards user input.
|
|
266
259
|
*/
|
|
267
260
|
const onRefresh = useCallback(async (): Promise<void> => {
|
|
@@ -301,8 +294,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
301
294
|
}
|
|
302
295
|
setLoad({ phase: 'ready', panel: result.panel })
|
|
303
296
|
setStatus(result.panel.status)
|
|
304
|
-
setCleanupResult(null)
|
|
305
|
-
setCleanupError(null)
|
|
306
297
|
// Clear only the submitted keys: edits typed while the request was in
|
|
307
298
|
// flight stay in the draft instead of being silently discarded.
|
|
308
299
|
setDraft(previous => {
|
|
@@ -372,8 +363,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
372
363
|
setLoad({ phase: 'ready', panel: result.panel })
|
|
373
364
|
setStatus(result.panel.status)
|
|
374
365
|
setDraft({})
|
|
375
|
-
setCleanupResult(null)
|
|
376
|
-
setCleanupError(null)
|
|
377
366
|
setNotice({ kind: 'ok', text: PANEL_COPY.cleared })
|
|
378
367
|
} catch (error) {
|
|
379
368
|
setNotice({ kind: 'error', text: messageOf(error) })
|
|
@@ -417,40 +406,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
417
406
|
}
|
|
418
407
|
}, [refreshStatus, running])
|
|
419
408
|
|
|
420
|
-
/**
|
|
421
|
-
* Delete stale workspaces after an explicit confirmation. The Host refreshes
|
|
422
|
-
* the panel in the same response, so the preview counts update immediately;
|
|
423
|
-
* partial failures stay visible in the card instead of being swallowed.
|
|
424
|
-
*/
|
|
425
|
-
const onRunCleanup = useCallback(async (): Promise<void> => {
|
|
426
|
-
if (panel === null) return
|
|
427
|
-
const { cleanup } = panel
|
|
428
|
-
const confirmed = typeof window.confirm === 'function'
|
|
429
|
-
? window.confirm(
|
|
430
|
-
`${PANEL_COPY.cleanupConfirm}\n${PANEL_COPY.cleanupCandidates}:${cleanup.candidates} · ${PANEL_COPY.cleanupCandidateBytes}:${formatBytes(cleanup.candidateBytes)}`,
|
|
431
|
-
)
|
|
432
|
-
: true
|
|
433
|
-
if (!confirmed) return
|
|
434
|
-
setBusy('cleanup')
|
|
435
|
-
setNotice(null)
|
|
436
|
-
setCleanupResult(null)
|
|
437
|
-
setCleanupError(null)
|
|
438
|
-
try {
|
|
439
|
-
const response = await runCleanup()
|
|
440
|
-
if (!response.ok) {
|
|
441
|
-
setCleanupError(response.error)
|
|
442
|
-
return
|
|
443
|
-
}
|
|
444
|
-
setLoad({ phase: 'ready', panel: response.panel })
|
|
445
|
-
setStatus(response.panel.status)
|
|
446
|
-
setCleanupResult(response.result)
|
|
447
|
-
} catch (error) {
|
|
448
|
-
setCleanupError(messageOf(error))
|
|
449
|
-
} finally {
|
|
450
|
-
setBusy(null)
|
|
451
|
-
}
|
|
452
|
-
}, [panel])
|
|
453
|
-
|
|
454
409
|
/** Replace the embedded access snapshot after a whitelist mutation. */
|
|
455
410
|
const applyAccess = useCallback((access: AccessWire): void => {
|
|
456
411
|
setLoad(previous => previous.phase === 'ready'
|
|
@@ -498,12 +453,12 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
498
453
|
}
|
|
499
454
|
}, [applyAccess])
|
|
500
455
|
|
|
501
|
-
const onAddAccessUser = useCallback(async (
|
|
456
|
+
const onAddAccessUser = useCallback(async (userId: string, name: string): Promise<boolean> => {
|
|
502
457
|
setBusy('access')
|
|
503
458
|
setAccessError(null)
|
|
504
459
|
setAccessNotice(null)
|
|
505
460
|
try {
|
|
506
|
-
const result = await addAccessUser(
|
|
461
|
+
const result = await addAccessUser(userId, name)
|
|
507
462
|
if (!result.ok) {
|
|
508
463
|
setAccessError(result.error)
|
|
509
464
|
return false
|
|
@@ -519,12 +474,12 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
519
474
|
}
|
|
520
475
|
}, [applyAccess])
|
|
521
476
|
|
|
522
|
-
const onRemoveAccessUser = useCallback(async (
|
|
477
|
+
const onRemoveAccessUser = useCallback(async (userId: string): Promise<void> => {
|
|
523
478
|
setBusy('access')
|
|
524
479
|
setAccessError(null)
|
|
525
480
|
setAccessNotice(null)
|
|
526
481
|
try {
|
|
527
|
-
const result = await removeAccessUser(
|
|
482
|
+
const result = await removeAccessUser(userId)
|
|
528
483
|
if (!result.ok) {
|
|
529
484
|
setAccessError(result.error)
|
|
530
485
|
return
|
|
@@ -729,13 +684,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
729
684
|
{activeTab === 'model' ? <ModelTab form={form} catalogBusy={catalogBusy} /> : null}
|
|
730
685
|
{activeTab === 'workspace'
|
|
731
686
|
? (
|
|
732
|
-
<WorkspaceTab
|
|
733
|
-
form={form}
|
|
734
|
-
cleanup={form.panel.cleanup}
|
|
735
|
-
cleanupResult={cleanupResult}
|
|
736
|
-
cleanupError={cleanupError}
|
|
737
|
-
onRunCleanup={() => { void onRunCleanup() }}
|
|
738
|
-
/>
|
|
687
|
+
<WorkspaceTab form={form} />
|
|
739
688
|
)
|
|
740
689
|
: null}
|
|
741
690
|
{activeTab === 'access'
|
|
@@ -747,7 +696,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
|
|
|
747
696
|
onAddGroup={onAddAccessGroup}
|
|
748
697
|
onRemoveGroup={chatId => { void onRemoveAccessGroup(chatId) }}
|
|
749
698
|
onAddUser={onAddAccessUser}
|
|
750
|
-
onRemoveUser={
|
|
699
|
+
onRemoveUser={userId => { void onRemoveAccessUser(userId) }}
|
|
751
700
|
onResolveRequest={(token, decision) => { void onResolveAccessRequest(token, decision) }}
|
|
752
701
|
/>
|
|
753
702
|
)
|
|
@@ -13,7 +13,6 @@ import { FIELD_GROUP_LABELS, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings
|
|
|
13
13
|
import type {
|
|
14
14
|
AccessWire,
|
|
15
15
|
ChannelPhaseWire,
|
|
16
|
-
CleanupPreviewWire,
|
|
17
16
|
ConfigValue,
|
|
18
17
|
CredentialField,
|
|
19
18
|
EditableFieldWire,
|
|
@@ -24,6 +23,15 @@ import type {
|
|
|
24
23
|
PanelTabId,
|
|
25
24
|
PanelWire,
|
|
26
25
|
} from '../../settings/contract.ts'
|
|
26
|
+
import {
|
|
27
|
+
listIsFull,
|
|
28
|
+
listItems,
|
|
29
|
+
listRows,
|
|
30
|
+
stringListDraftValue,
|
|
31
|
+
withBlankListEntry,
|
|
32
|
+
withListEntry,
|
|
33
|
+
withoutListEntry,
|
|
34
|
+
} from '../string-list.ts'
|
|
27
35
|
|
|
28
36
|
/** Initial panel load: one request, retried from the error card. */
|
|
29
37
|
export type LoadState =
|
|
@@ -46,7 +54,6 @@ export type BusyAction =
|
|
|
46
54
|
| 'reconnect'
|
|
47
55
|
| 'credentials'
|
|
48
56
|
| 'credentials-clear'
|
|
49
|
-
| 'cleanup'
|
|
50
57
|
| 'access'
|
|
51
58
|
|
|
52
59
|
/** Tone of a small badge; maps onto `dsh-channels-cfg__badge--*` classes. */
|
|
@@ -56,20 +63,6 @@ export function messageOf(error: unknown): string {
|
|
|
56
63
|
return error instanceof Error ? error.message : String(error)
|
|
57
64
|
}
|
|
58
65
|
|
|
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
66
|
/** Compact duration for the connection card. */
|
|
74
67
|
export function formatUptime(ms: number | undefined): string {
|
|
75
68
|
if (ms === undefined || !Number.isFinite(ms)) return PANEL_COPY.unknownValue
|
|
@@ -142,6 +135,12 @@ export function validateDraft(
|
|
|
142
135
|
values[field.key] = raw === true
|
|
143
136
|
continue
|
|
144
137
|
}
|
|
138
|
+
if (field.kind === 'string-list') {
|
|
139
|
+
const list = stringListDraftValue(field, raw)
|
|
140
|
+
if (list.ok) values[field.key] = list.value
|
|
141
|
+
else issues.push(...list.issues)
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
145
144
|
values[field.key] = String(raw ?? '')
|
|
146
145
|
}
|
|
147
146
|
return { values, issues }
|
|
@@ -252,6 +251,73 @@ function DirectoryTextInput(props: {
|
|
|
252
251
|
)
|
|
253
252
|
}
|
|
254
253
|
|
|
254
|
+
/**
|
|
255
|
+
* `string-list` control: one single-line input per entry, each with a remove
|
|
256
|
+
* button, and an add button below (disabled at `maxItems` inputs). An empty
|
|
257
|
+
* list renders one blank input so the first entry can be typed directly.
|
|
258
|
+
* Issues carrying an `index` mark that input and show the reason under it.
|
|
259
|
+
*/
|
|
260
|
+
function StringListInput(props: {
|
|
261
|
+
field: EditableFieldWire
|
|
262
|
+
value: ConfigValue | undefined
|
|
263
|
+
issues: readonly FieldIssueWire[]
|
|
264
|
+
disabled: boolean
|
|
265
|
+
onChange: (key: EditableKey, value: ConfigValue) => void
|
|
266
|
+
}): JSX.Element {
|
|
267
|
+
const { field, value, issues, disabled, onChange } = props
|
|
268
|
+
const empty = listItems(value).length === 0
|
|
269
|
+
const rows = listRows(value)
|
|
270
|
+
return (
|
|
271
|
+
<div className="dsh-channels-cfg__list">
|
|
272
|
+
{rows.map((item, index) => {
|
|
273
|
+
const issue = issues.find(entry => entry.index === index)
|
|
274
|
+
const position = PANEL_COPY.listEntry.replace('{n}', String(index + 1))
|
|
275
|
+
// Entries have no identity beyond their position, so the index is the key.
|
|
276
|
+
return (
|
|
277
|
+
<div className="dsh-channels-cfg__listItem" key={index}>
|
|
278
|
+
<div className="dsh-channels-cfg__inputRow">
|
|
279
|
+
<input
|
|
280
|
+
type="text"
|
|
281
|
+
className={`dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`}
|
|
282
|
+
value={item}
|
|
283
|
+
placeholder={field.placeholder}
|
|
284
|
+
aria-label={`${field.label} ${position}`}
|
|
285
|
+
aria-invalid={issue === undefined ? undefined : true}
|
|
286
|
+
disabled={disabled}
|
|
287
|
+
onChange={event => onChange(field.key, withListEntry(rows, index, event.target.value))}
|
|
288
|
+
/>
|
|
289
|
+
{empty
|
|
290
|
+
? null
|
|
291
|
+
: (
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--listRemove"
|
|
295
|
+
aria-label={`${PANEL_COPY.listRemove} ${position}`}
|
|
296
|
+
disabled={disabled}
|
|
297
|
+
onClick={() => onChange(field.key, withoutListEntry(rows, index))}
|
|
298
|
+
>
|
|
299
|
+
{PANEL_COPY.listRemove}
|
|
300
|
+
</button>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
{issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
|
|
304
|
+
</div>
|
|
305
|
+
)
|
|
306
|
+
})}
|
|
307
|
+
<div>
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
className="dsh-channels-cfg__btn"
|
|
311
|
+
disabled={disabled || listIsFull(field, rows)}
|
|
312
|
+
onClick={() => onChange(field.key, withBlankListEntry(rows))}
|
|
313
|
+
>
|
|
314
|
+
{PANEL_COPY.listAdd}
|
|
315
|
+
</button>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
255
321
|
/** One generic form control driven by the Host field descriptor. */
|
|
256
322
|
export function FieldRow(props: {
|
|
257
323
|
field: EditableFieldWire
|
|
@@ -263,7 +329,9 @@ export function FieldRow(props: {
|
|
|
263
329
|
onPickDirectory?: () => Promise<void>
|
|
264
330
|
}): JSX.Element {
|
|
265
331
|
const { field, value, issues, disabled = false, onChange, onPickDirectory } = props
|
|
266
|
-
|
|
332
|
+
// Entry issues (`index` set) are rendered next to their input by StringListInput.
|
|
333
|
+
const issue = issues.find(item => item.key === field.key && item.index === undefined)
|
|
334
|
+
const entryIssues = issues.filter(item => item.key === field.key && item.index !== undefined)
|
|
267
335
|
const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
|
|
268
336
|
const fieldClass = `dsh-channels-cfg__field${field.wide === true ? ' dsh-channels-cfg__field--wide' : ''}`
|
|
269
337
|
return (
|
|
@@ -343,6 +411,17 @@ export function FieldRow(props: {
|
|
|
343
411
|
/>
|
|
344
412
|
))
|
|
345
413
|
: null}
|
|
414
|
+
{field.kind === 'string-list'
|
|
415
|
+
? (
|
|
416
|
+
<StringListInput
|
|
417
|
+
field={field}
|
|
418
|
+
value={value}
|
|
419
|
+
issues={entryIssues}
|
|
420
|
+
disabled={disabled}
|
|
421
|
+
onChange={onChange}
|
|
422
|
+
/>
|
|
423
|
+
)
|
|
424
|
+
: null}
|
|
346
425
|
{field.help === undefined ? null : <span className="dsh-channels-cfg__help">{field.help}</span>}
|
|
347
426
|
{issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
|
|
348
427
|
</div>
|
|
@@ -479,10 +558,3 @@ export function connectionStateLabel(state: string | undefined): { text: string;
|
|
|
479
558
|
default: return { text: state ?? PANEL_COPY.unknownValue, tone: 'muted' }
|
|
480
559
|
}
|
|
481
560
|
}
|
|
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
|
-
}
|
|
@@ -1,40 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 「工作区」tab
|
|
3
|
-
* 表单保存栏在字段卡正下方,清理卡(独立动作,不走 draft)在其后。
|
|
2
|
+
* 「工作区」tab:工作区根目录字段与保存栏。
|
|
4
3
|
*
|
|
5
4
|
* @module dsh-channels/client/panel/workspace-tab
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
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
8
|
import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
|
|
12
9
|
import type { FormView } from './shared.tsx'
|
|
13
10
|
|
|
14
11
|
export interface WorkspaceTabProps {
|
|
15
12
|
form: FormView
|
|
16
|
-
cleanup: CleanupPreviewWire
|
|
17
|
-
cleanupResult: CleanupRunWire | null
|
|
18
|
-
cleanupError: string | null
|
|
19
|
-
onRunCleanup: () => void
|
|
20
13
|
}
|
|
21
14
|
|
|
22
15
|
export function WorkspaceTab(props: WorkspaceTabProps): JSX.Element {
|
|
23
|
-
const { form
|
|
16
|
+
const { form } = props
|
|
24
17
|
return (
|
|
25
18
|
<div className="dsh-channels-cfg__tabBody">
|
|
26
19
|
<TabHelp text={PANEL_COPY.workspaceTabHelp} />
|
|
27
20
|
<FieldsCard form={form} groups={TAB_FIELD_GROUPS.workspace} />
|
|
28
21
|
<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
22
|
</div>
|
|
39
23
|
)
|
|
40
24
|
}
|