dsh-config-manager 0.1.82 → 0.1.83

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.
@@ -30,6 +30,7 @@ import { ConfirmDialog } from '../common/ConfirmDialog.tsx'
30
30
  import { Modal } from '../common/Modal.tsx'
31
31
  import { toast } from '../common/toast-store.ts'
32
32
  import { ConsultCard } from '../consult/ConsultCard.tsx'
33
+ import type { ArtifactPendingAction } from './ArtifactRow.tsx'
33
34
  import { RestorePlanView } from './RestorePlanView.tsx'
34
35
  import { BackupInspectView } from './BackupInspectView.tsx'
35
36
  import { redact } from '../../security/redaction.ts'
@@ -76,6 +77,14 @@ export interface LibraryActionsProps {
76
77
  * 经壳层转成 Task 入参;其余入口不带(= 打开市场列表)。
77
78
  */
78
79
  onOpenTask: (kind: 'import' | 'market', marketId?: string) => void
80
+ /**
81
+ * 把「哪一行的哪个动作正在跑」上报给壳层(DESIGN §8.11「行内动作 pending」)。
82
+ *
83
+ * 为什么必须上报:本组件是产物库面板的**兄弟节点**(见文件头),行的读点在面板里。
84
+ * 此前这里是 `const [busy, setBusy] = useState(false)` + `void busy` —— 写了一个
85
+ * **读不到**的状态:行内按钮既不转圈也不禁用,同一个动作可以连点。
86
+ */
87
+ onPendingChange: (pending: ArtifactPendingAction | null) => void
79
88
  }
80
89
 
81
90
  /** 恢复计划弹窗的本地态(dry-run 结果 + 咨询报告) */
@@ -100,7 +109,7 @@ interface PullState {
100
109
  error: string | null
101
110
  }
102
111
 
103
- export function LibraryActions({ api, syncApi, t, target, onDone, onChanged, onOpenTask }: LibraryActionsProps) {
112
+ export function LibraryActions({ api, syncApi, t, target, onDone, onChanged, onOpenTask, onPendingChange }: LibraryActionsProps) {
104
113
  const mounted = useRef(true)
105
114
  useEffect(() => () => { mounted.current = false }, [])
106
115
 
@@ -122,10 +131,14 @@ export function LibraryActions({ api, syncApi, t, target, onDone, onChanged, onO
122
131
  const [confirmRestore, setConfirmRestore] = useState(false)
123
132
  const [confirmDelete, setConfirmDelete] = useState<ArtifactRow | null>(null)
124
133
  const [inspect, setInspect] = useState<{ name: string; loading: boolean; error: string | null; result: BackupInspectResult | null } | null>(null)
125
- // 忙态总开关:下载 / 置顶 / 咨询是**无弹窗**的即时动作,正在跑时不该再接受第二次点击。
126
- // 它的读点在 LibraryPanel(行内按钮 disabled)——这里只负责写。
127
- const [busy, setBusy] = useState(false)
128
- void busy
134
+ /**
135
+ * 行内 pending 的统一出口:本组件不再自存忙态(自存就是上一个 bug 的成因),
136
+ * 一律写进壳层,由壳层传回面板(`ArtifactRowView.pending`)。
137
+ * 传 null = 这一行的动作收尾(成功或失败都要清,否则行会永远转圈)。
138
+ */
139
+ const reportPending = useCallback((key: string, capability: ArtifactCapability | null): void => {
140
+ onPendingChange(capability === null ? null : { key, capability })
141
+ }, [onPendingChange])
129
142
 
130
143
  /** 每次触发动作递增:快速连点时作废在途的 dry-run 响应(防晚到结果覆盖新选择)。 */
131
144
  const generation = useRef(0)
@@ -137,6 +150,8 @@ export function LibraryActions({ api, syncApi, t, target, onDone, onChanged, onO
137
150
  switch (capability) {
138
151
  case 'restore': {
139
152
  setPlan({ snapshotId: row.ref.snapshotId ?? '', loading: true, plan: null, changeSummary: undefined, planFingerprint: undefined, error: null })
153
+ // dry-run 是网络往返:弹窗里已有骨架,行上也标出来(两份反馈不冲突,各自回答一个问题)
154
+ reportPending(row.key, 'restore')
140
155
  // v3(用户要求):恢复计划弹窗**不再内嵌「迁移前咨询」卡** —— 那个弹窗的职责是
141
156
  // 「这次恢复会改什么 + 确认执行」;咨询报告有自己的入口(产物库行 ⋯ 菜单 →「迁移前咨询」),
142
157
  // 想看报告的人可以单独打开。顺带省掉一次重量级请求(consult 要读快照内容做评分)。
@@ -144,10 +159,12 @@ export function LibraryActions({ api, syncApi, t, target, onDone, onChanged, onO
144
159
  (res) => {
145
160
  if (!mounted.current || gen !== generation.current) return
146
161
  setPlan({ snapshotId: row.ref.snapshotId ?? '', loading: false, plan: res.plan ?? null, changeSummary: res.changeSummary, planFingerprint: res.planFingerprint, error: null })
162
+ reportPending(row.key, null)
147
163
  },
148
164
  (err) => {
149
165
  if (!mounted.current || gen !== generation.current) return
150
166
  setPlan({ snapshotId: row.ref.snapshotId ?? '', loading: false, plan: null, changeSummary: undefined, planFingerprint: undefined, error: err instanceof Error ? err.message : String(err) })
167
+ reportPending(row.key, null)
151
168
  },
152
169
  )
153
170
  return
@@ -249,13 +266,15 @@ export function LibraryActions({ api, syncApi, t, target, onDone, onChanged, onO
249
266
  )
250
267
  return
251
268
  }
252
- setBusy(true)
269
+ // 本机备份的下载**没有弹窗**(Blob + <a download>,立即),所以反馈只能落在行上:
270
+ // 点击那颗按钮立刻转圈,直到浏览器收下这份文件(此前这里 setBusy 后无人读 ⇒ 零反馈)。
271
+ reportPending(row.key, 'download')
253
272
  // 用户要求:直接走浏览器下载(落到「下载」目录),不弹系统保存对话框 ——
254
273
  // 备份文件是**可再生成的产物**,多一步选路径没有收益。
255
274
  // api.download 缺省即此语义(Blob + <a download>);不要传 saveDialog: true。
256
275
  void api.download(row.ref.path ?? '')
257
276
  .catch((err) => { toast.error(redactErrorText(err)) })
258
- .finally(() => { if (mounted.current) { setBusy(false); onDone() } })
277
+ .finally(() => { if (mounted.current) { reportPending(row.key, null); onDone() } })
259
278
  return
260
279
  }
261
280
  case 'delete': {
@@ -263,29 +282,35 @@ export function LibraryActions({ api, syncApi, t, target, onDone, onChanged, onO
263
282
  return
264
283
  }
265
284
  case 'consult': {
266
- setBusy(true)
267
- // 四种可迁移源共用一个入口(§6.8 ②):咨询永远进 ⋯,不按来源造分叉
268
- // 远端快照的咨询要带通道地址(宿主 consult 的远端分支需要 repoUrl/url,只给 id 会 400)
269
- const input = await consultInput(row, syncApi)
270
- if (!mounted.current) return
271
- // 报告要**有自己的弹窗**:此前只 setConsult 而不开弹窗,而 ConsultCard 挂在
272
- // 「恢复计划预览」弹窗内部(由 plan !== null 控制)⇒ 点 ⋯ 完全没反应。
285
+ // 四种可迁移源共用一个入口(§6.8 ②):咨询永远进 ⋯,不按来源造分叉。
286
+ //
287
+ // **先开弹窗、再去取参数**(顺序不能反):consultInput 对远端快照要走 /sync/status
288
+ // 拿通道地址(网络),旧顺序是「先 await 再开弹窗」⇒ 那一段页面完全没反应;
289
+ // 而且它会**抛错**(无通道地址时 'sync channel address is unavailable'),
290
+ // 先 await 的写法会让异常直接逃出 dispatch,弹窗永远不出现。
273
291
  setConsult({ row, loading: true, error: null, report: null })
274
- api.consult(input).then(
275
- (rep) => { if (mounted.current) setConsult({ row, loading: false, error: null, report: rep }) },
276
- (err) => {
292
+ reportPending(row.key, 'consult')
293
+ void (async () => {
294
+ try {
295
+ const input = await consultInput(row, syncApi)
296
+ if (!mounted.current) return
297
+ const rep = await api.consult(input)
298
+ if (mounted.current) setConsult({ row, loading: false, error: null, report: rep })
299
+ } catch (err) {
277
300
  const message = redactErrorText(err)
278
301
  if (mounted.current) setConsult({ row, loading: false, error: message, report: null })
279
302
  // Toast 与弹窗内错误都给:弹窗可能被立刻关掉,Toast 保证反馈不丢
280
303
  toast.error(message)
281
- },
282
- ).finally(() => { if (mounted.current) { setBusy(false); onDone() } })
304
+ } finally {
305
+ if (mounted.current) { reportPending(row.key, null); onDone() }
306
+ }
307
+ })()
283
308
  return
284
309
  }
285
310
  default:
286
311
  onDone()
287
312
  }
288
- }, [api, t, onDone, onChanged, onOpenTask])
313
+ }, [api, t, onDone, onChanged, onOpenTask, reportPending])
289
314
 
290
315
  /**
291
316
  * 供 LibraryPanel 调用:把点击转进来。用 ref 暴露,避免面板每次渲染重建回调。
@@ -325,38 +350,42 @@ export function LibraryActions({ api, syncApi, t, target, onDone, onChanged, onO
325
350
  ).finally(() => { runStoreStopWatch() })
326
351
  }
327
352
 
328
- const doDelete = (): void => {
353
+ /**
354
+ * 执行删除。**返回 Promise 是有意的**(DESIGN UI-25 ①档):ConfirmDialog 只在 onConfirm
355
+ * 返回 Promise 时自管 busy —— 这样弹窗在删除期间转圈、确认键与取消/遮罩/Esc 全禁用,
356
+ * 用户不会在还在删的时候点第二下(此前的 void 版本:弹窗一动不动,可重复提交)。
357
+ * 远端行还多一步 remotePayload(要通道地址),更要挡住重复点击。
358
+ * 失败时**关窗 + toast**:错误文案在弹窗里既没人读,也会被下一次打开冲掉。
359
+ */
360
+ const doDelete = async (): Promise<void> => {
329
361
  const row = confirmDelete
330
362
  if (row === null) return
331
363
  const done = (): void => { if (mounted.current) { setConfirmDelete(null); onChanged(); onDone() } }
332
- if (row.kind === 'snapshot') {
333
- api.deleteSnapshot(row.ref.snapshotId ?? '').then(
334
- () => { toast.ok(t('snapshots.deleted')); done() },
335
- (err) => { if (mounted.current) setConfirmDelete(null); toast.error(snapshotDeleteErrorText(err, t)) },
336
- )
337
- return
338
- }
339
- if (row.kind === 'backup-file') {
340
- api.deleteBackupFile(fileNameOf(row)).then(
341
- () => { toast.ok(t('backupFiles.deleted', { name: fileNameOf(row) })); done() },
342
- (err) => { if (mounted.current) setConfirmDelete(null); toast.error(redactErrorText(err)) },
343
- )
344
- return
345
- }
346
- if (row.kind === 'remote-snapshot') {
347
- // 删的是**远端那一份**(只动远端,见宿主 SyncEngine.deleteSnapshot):需要通道 + 地址,
348
- // 与拉取/咨询同源取(prepareSync 只认 repoUrl/url)。
349
- void (async () => {
350
- try {
351
- const payload = await remotePayload(syncApi)
352
- await syncApi.deleteSnapshot({ ...payload, snapshotId: row.ref.remoteId ?? '' })
353
- toast.ok(t('library.remoteDeleted'))
354
- done()
355
- } catch (err) {
356
- if (mounted.current) setConfirmDelete(null)
357
- toast.error(redactErrorText(err))
358
- }
359
- })()
364
+ try {
365
+ if (row.kind === 'snapshot') {
366
+ await api.deleteSnapshot(row.ref.snapshotId ?? '')
367
+ toast.ok(t('snapshots.deleted'))
368
+ done()
369
+ return
370
+ }
371
+ if (row.kind === 'backup-file') {
372
+ await api.deleteBackupFile(fileNameOf(row))
373
+ toast.ok(t('backupFiles.deleted', { name: fileNameOf(row) }))
374
+ done()
375
+ return
376
+ }
377
+ if (row.kind === 'remote-snapshot') {
378
+ // 删的是**远端那一份**(只动远端,见宿主 SyncEngine.deleteSnapshot):需要通道 + 地址,
379
+ // 与拉取/咨询同源取(prepareSync 只认 repoUrl/url)。
380
+ const payload = await remotePayload(syncApi)
381
+ await syncApi.deleteSnapshot({ ...payload, snapshotId: row.ref.remoteId ?? '' })
382
+ toast.ok(t('library.remoteDeleted'))
383
+ done()
384
+ }
385
+ } catch (err) {
386
+ if (mounted.current) setConfirmDelete(null)
387
+ // 快照删除有两条可映射的宿主码(被事故恢复引用 / 清单读不出),其余走脱敏原文
388
+ toast.error(row.kind === 'snapshot' ? snapshotDeleteErrorText(err, t) : redactErrorText(err))
360
389
  }
361
390
  }
362
391
 
@@ -27,7 +27,7 @@ import { Badge, Button, Card, Empty } from '../common/ui.tsx'
27
27
  import { SkeletonRows } from '../common/Skeleton.tsx'
28
28
  import { redact } from '../../security/redaction.ts'
29
29
  import { InfoHint } from '../common/InfoHint.tsx'
30
- import { ArtifactRowView } from './ArtifactRow.tsx'
30
+ import { ArtifactRowView, type ArtifactPendingAction } from './ArtifactRow.tsx'
31
31
  import css from '../config-manager.module.css'
32
32
 
33
33
  export interface LibraryPanelProps {
@@ -51,6 +51,13 @@ export interface LibraryPanelProps {
51
51
  onOpenPublish: () => void
52
52
  /** 外部刷新信号(动作完成 / 恢复 / 删除后递增)→ 重拉四份清单 */
53
53
  refreshTick: number
54
+ /**
55
+ * 行内动作的进行中态(DESIGN §8.11「行内动作 pending」)。
56
+ * 由壳层持有:动作实现在 **LibraryActions**(本面板的兄弟节点,见其文件头),
57
+ * 它跑起来时把「哪一行的哪个动作」上报给壳层,壳层再以本 prop 传回 →
58
+ * 行内按钮就地转圈/禁用。放壳层还顺带解决了「切页把面板重挂后忙态蒸发」的问题。
59
+ */
60
+ pending: ArtifactPendingAction | null
54
61
  }
55
62
 
56
63
  type SourceStatus = 'loading' | 'ready' | 'error'
@@ -63,7 +70,7 @@ interface SourceState<T> {
63
70
 
64
71
  function initialSource<T>(): SourceState<T> { return { status: 'loading', items: [], error: null } }
65
72
 
66
- export function LibraryPanel({ api, syncApi, marketApi, t, uiT, onAction, onOpenTask, onOpenExport, onOpenPublish, refreshTick }: LibraryPanelProps) {
73
+ export function LibraryPanel({ api, syncApi, marketApi, t, uiT, onAction, onOpenTask, onOpenExport, onOpenPublish, refreshTick, pending }: LibraryPanelProps) {
67
74
  const state = useSyncExternalStore(runStore.subscribe, runStore.getSnapshot)
68
75
  const lib = state.library
69
76
 
@@ -263,6 +270,8 @@ export function LibraryPanel({ api, syncApi, marketApi, t, uiT, onAction, onOpen
263
270
  runStore.patch({ library: { expandedKey: lib.expandedKey === row.key ? null : row.key } })
264
271
  }}
265
272
  onAction={onAction}
273
+ // 只把**本行**的进行中动作交给行组件:其余行保持可点(一次只挡同一行)
274
+ pending={pending !== null && pending.key === row.key ? pending.capability : null}
266
275
  deviceLabel={row.kind === 'remote-snapshot' ? deviceLabelOf.get(row.ref.remoteId ?? '') : undefined}
267
276
  />
268
277
  ))}
@@ -37,7 +37,7 @@ import {
37
37
  type MarketPickerFilter, type MarketReviewStep,
38
38
  } from '../../ui/market-import.ts'
39
39
  import { redact } from '../../security/redaction.ts'
40
- import { Badge, Banner, Button, Segmented, Spinner, Stepper } from '../common/ui.tsx'
40
+ import { Badge, Banner, Button, Segmented, Stepper } from '../common/ui.tsx'
41
41
  import { ContentPicker } from '../common/ContentPicker.tsx'
42
42
  import { ConflictList } from '../import/ConflictList.tsx'
43
43
  import { ConfirmDialog } from '../common/ConfirmDialog.tsx'
@@ -337,10 +337,11 @@ export function MarketImportReview({
337
337
  <Button onClick={() => { go(prevMarketStep(active, hasConflicts)) }}>{t('review.back')}</Button>
338
338
  <Button
339
339
  variant="primary"
340
- disabled={importing || !summary.canImport}
340
+ loading={importing}
341
+ disabled={!summary.canImport}
341
342
  onClick={() => { void runImport() }}
342
343
  >
343
- {importing ? <Spinner label={t('common.loading')} /> : t('detail.import')}
344
+ {t('detail.import')}
344
345
  </Button>
345
346
  </div>
346
347
  </>
@@ -25,7 +25,7 @@ import type { TranslateNS } from '../client-types.ts'
25
25
  import type { ConfigManagerApi } from '../api.ts'
26
26
  import type { ImportResult, ItemResolution } from '../../core/types.ts'
27
27
  import type { SectionId } from '../../schema/types.ts'
28
- import { Badge, Banner, Button, Card, Empty, SectionTitle, Spinner } from '../common/ui.tsx'
28
+ import { Badge, Banner, Button, Card, Empty, SectionTitle } from '../common/ui.tsx'
29
29
  import { InfoHint } from '../common/InfoHint.tsx'
30
30
  import { Skeleton, SkeletonList } from '../common/Skeleton.tsx'
31
31
  import { Select } from '../common/Select.tsx'
@@ -477,11 +477,11 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t, cmT, ope
477
477
  <Card>
478
478
  <span className={css.groupLabel}>{t('config.title')}</span>
479
479
  <div className={css.actionRow}>
480
- <Button variant="primary" disabled={state.refreshing || state.importing || state.browsing} onClick={() => { void runRefresh() }}>
481
- {state.refreshing ? <Spinner label={t('config.refreshing')} /> : t('config.refresh')}
480
+ <Button variant="primary" loading={state.refreshing} disabled={state.importing || state.browsing} onClick={() => { void runRefresh() }}>
481
+ {t('config.refresh')}
482
482
  </Button>
483
- <Button disabled={state.browsing || state.refreshing || state.importing} onClick={() => { void runBrowse() }}>
484
- {state.browsing ? <Spinner label={t('list.loading')} /> : t('list.browse')}
483
+ <Button loading={state.browsing} disabled={state.refreshing || state.importing} onClick={() => { void runBrowse() }}>
484
+ {t('list.browse')}
485
485
  </Button>
486
486
  </div>
487
487
  </Card>
@@ -712,8 +712,8 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t, cmT, ope
712
712
  </div>
713
713
  </div>
714
714
  {/* 下载按钮:任一下载进行中全部禁用(防止并发下载详情串扰 + 防重复点击) */}
715
- <Button disabled={state.downloadingId !== null} onClick={() => { openDownload(it) }}>
716
- {state.downloadingId === it.id ? <Spinner label={t('common.loading')} /> : t('list.download')}
715
+ <Button loading={state.downloadingId === it.id} disabled={state.downloadingId !== null} onClick={() => { openDownload(it) }}>
716
+ {t('list.download')}
717
717
  </Button>
718
718
  </div>
719
719
  )
@@ -7,7 +7,7 @@ import type { TranslateNS } from '../client-types.ts'
7
7
  import type { MyItemEntry } from './my-configs-api.ts'
8
8
  import type { ItemStatusBadge, MyInstallState, MyItemView, MyItemsSummary } from './my-configs-view.ts'
9
9
  import { redact } from '../../security/redaction.ts'
10
- import { Badge, Banner, Button, Card, Empty, Spinner } from '../common/ui.tsx'
10
+ import { Badge, Banner, Button, Card, Empty } from '../common/ui.tsx'
11
11
  import { SkeletonList } from '../common/Skeleton.tsx'
12
12
  import css from '../config-manager.module.css'
13
13
 
@@ -65,8 +65,8 @@ export function MyConfigsList({
65
65
  })}
66
66
  </Badge>
67
67
  )}
68
- <Button disabled={listLoading} onClick={() => { onRefresh() }}>
69
- {listLoading ? <Spinner label={t('myconfigs.list.loading')} /> : t('myconfigs.list.refresh')}
68
+ <Button loading={listLoading} onClick={() => { onRefresh() }}>
69
+ {t('myconfigs.list.refresh')}
70
70
  </Button>
71
71
  </div>
72
72
  {myItemsError !== null && <Banner kind="error">{redact(myItemsError)}</Banner>}
@@ -697,7 +697,7 @@ export function MyConfigsView({
697
697
  onFormField={onFormField}
698
698
  onRunUpload={() => { void runUpload() }}
699
699
  onCancelUpdate={cancelUpdate}
700
- onRelist={(itemId) => { void runRelist(itemId) }}
700
+ onRelist={runRelist}
701
701
  onReset={resetWizard}
702
702
  />
703
703
  )
@@ -4,12 +4,13 @@
4
4
  * 本文件只渲染 + 上抛回调:向导全量状态是受控 prop,所有状态迁移与请求(校验/上传/更新/重试收录)
5
5
  * 都在 MyConfigsView.tsx;PR 链接来源优先级复用 ../../ui/my-configs-view.ts 的 myPrLinkSource。
6
6
  */
7
+ import { useState } from 'react'
7
8
  import type { ChangeEvent } from 'react'
8
9
  import type { TranslateNS } from '../client-types.ts'
9
10
  import type { ListingStatusResponse } from '../../market/my-repo.ts'
10
11
  import { redact } from '../../security/redaction.ts'
11
12
  import { myPrLinkSource } from '../../ui/my-configs-view.ts'
12
- import { Badge, Banner, Button, Field, Spinner } from '../common/ui.tsx'
13
+ import { Badge, Banner, Button, Field } from '../common/ui.tsx'
13
14
  import { Modal } from '../common/Modal.tsx'
14
15
  import { InfoHint } from '../common/InfoHint.tsx'
15
16
  import { myConfigFormValid } from './my-configs-view.ts'
@@ -37,8 +38,12 @@ export interface MyConfigsWizardProps {
37
38
  onRunUpload: () => void
38
39
  /** 取消更新:向导回到「一键上传」初始态(弹窗保持打开) */
39
40
  onCancelUpdate: () => void
40
- /** 结果卡「重新收录」 */
41
- onRelist: (itemId: string) => void
41
+ /**
42
+ * 结果卡「重新收录」。**返回 Promise**:重试是一次网络往返 + 重新轮询,
43
+ * 本组件要等它结束才放开按钮(此前返回 void,调用方只能 `void onRelist(...)`,
44
+ * 点击后按钮毫无变化,用户会以为没点上而连点)。
45
+ */
46
+ onRelist: (itemId: string) => Promise<void>
42
47
  /** 重选 zip:update 轻量重置(保留预填表单)/ upload 完全重置 */
43
48
  onReset: () => void
44
49
  }
@@ -47,6 +52,18 @@ export function MyConfigsWizard({
47
52
  wizard, listingStatus, t, fileInput, autoBadges,
48
53
  onClose, onPickFile, onFormField, onRunUpload, onCancelUpdate, onRelist, onReset,
49
54
  }: MyConfigsWizardProps) {
55
+ /**
56
+ * 「重新收录」的在途态。是**一次点击的瞬时 UI 态**,不是向导的领域状态
57
+ * (向导状态仍全部归容器),所以留在本组件里;等 Promise 落定即放开。
58
+ */
59
+ const [relisting, setRelisting] = useState(false)
60
+ const runRelist = (itemId: string): void => {
61
+ if (relisting) return
62
+ setRelisting(true)
63
+ // 失败文案由容器统一走常驻 Toast(runRelist 内部已 catch),这里只管收尾
64
+ void onRelist(itemId).finally(() => { setRelisting(false) })
65
+ }
66
+
50
67
  /** PR 链接(优先实时任务状态;收录完成后由轮询补上,或直接取同步结果) */
51
68
  const prLink = ((): { url: string; label: string } | null => {
52
69
  // 来源优先级(实时任务状态 → 向导结果)在 src/ui/my-configs-view.ts 的 myPrLinkSource
@@ -109,10 +126,10 @@ export function MyConfigsWizard({
109
126
  <div className={css.actionRow}>
110
127
  <Button
111
128
  variant="primary"
112
- disabled={wizard.validating}
129
+ loading={wizard.validating}
113
130
  onClick={onReset}
114
131
  >
115
- {wizard.validating ? <Spinner label={t('myconfigs.upload.validating')} /> : t('myconfigs.upload.reselect')}
132
+ {t('myconfigs.upload.reselect')}
116
133
  </Button>
117
134
  </div>
118
135
  {wizard.validationError !== null && <Banner kind="error">{redact(wizard.validationError)}</Banner>}
@@ -197,15 +214,14 @@ export function MyConfigsWizard({
197
214
  <div className={css.actionRow}>
198
215
  <Button
199
216
  variant="primary"
217
+ loading={wizard.running}
200
218
  disabled={
201
- wizard.validated !== true || wizard.running || wizard.zipPath === null
219
+ wizard.validated !== true || wizard.zipPath === null
202
220
  || !myConfigFormValid(wizard.formErrors)
203
221
  }
204
222
  onClick={() => { onRunUpload() }}
205
223
  >
206
- {wizard.running
207
- ? <Spinner label={wizard.mode === 'update' ? t('myconfigs.update.running') : t('myconfigs.upload.running')} />
208
- : (wizard.mode === 'update' ? t('myconfigs.update.run') : t('myconfigs.upload.run'))}
224
+ {wizard.mode === 'update' ? t('myconfigs.update.run') : t('myconfigs.upload.run')}
209
225
  </Button>
210
226
  {wizard.mode === 'update' && (
211
227
  <Button disabled={wizard.running || wizard.validating} onClick={onCancelUpdate}>{t('common.cancel')}</Button>
@@ -233,7 +249,8 @@ export function MyConfigsWizard({
233
249
  {listingStatus !== null && listingStatus.listing === 'failed' ? (
234
250
  <>
235
251
  <Badge kind="error">{t('myconfigs.result.listingFailed')}</Badge>
236
- <Button variant="danger" onClick={() => { void onRelist(wizard.result!.itemId) }}>
252
+ {/* 转圈图标由 Button 原语按 loading 自动渲染(DESIGN §14.11) */}
253
+ <Button variant="danger" loading={relisting} onClick={() => { runRelist(wizard.result!.itemId) }}>
237
254
  {t('myconfigs.result.relist')}
238
255
  </Button>
239
256
  </>
@@ -7,7 +7,7 @@
7
7
  import type { ChangeEvent } from 'react'
8
8
  import type { TranslateNS } from '../client-types.ts'
9
9
  import type { UiT } from '../../ui/i18n.ts'
10
- import { Badge, Banner, Button, Checkbox, Spinner } from '../common/ui.tsx'
10
+ import { Badge, Banner, Button, Checkbox } from '../common/ui.tsx'
11
11
  import { InfoHint } from '../common/InfoHint.tsx'
12
12
  import { Modal } from '../common/Modal.tsx'
13
13
  import { Select } from '../common/Select.tsx'
@@ -444,10 +444,11 @@ export function ChannelConfigDialog({ open, onClose, api, t, uiT, form, cloudIss
444
444
  <div className={css.actionRow}>
445
445
  <Button
446
446
  variant="primary"
447
- disabled={busy || savingConfig || !remoteReady}
447
+ loading={savingConfig}
448
+ disabled={busy || !remoteReady}
448
449
  onClick={() => { onSave() }}
449
450
  >
450
- {savingConfig ? <Spinner label={t('config.saving')} /> : t('config.save')}
451
+ {t('config.save')}
451
452
  </Button>
452
453
  <InfoHint text={t('config.saveHint')} label={t('common.infoHint')} />
453
454
  </div>
@@ -13,7 +13,7 @@ import type { ReactNode } from 'react'
13
13
  import { useEffect } from 'react'
14
14
 
15
15
  import { redact } from '../../security/redaction.ts'
16
- import { Badge, Banner, Button, Spinner } from '../common/ui.tsx'
16
+ import { Badge, Banner, Button } from '../common/ui.tsx'
17
17
  import { Skeleton } from '../common/Skeleton.tsx'
18
18
  import { toast } from '../common/toast-store.ts'
19
19
  import { ConsultCard } from '../consult/ConsultCard.tsx'
@@ -494,10 +494,10 @@ function ApplyResultCard({ result, busy, t, onRollback }: ApplyResultCardProps):
494
494
  {result.restoreId !== '' && (
495
495
  <Button
496
496
  variant="danger"
497
- disabled={busy}
497
+ loading={busy}
498
498
  onClick={() => { void onRollback(result.restoreId) }}
499
499
  >
500
- {busy ? <Spinner label={t('syncflow.rollingBack')} /> : t('syncflow.rollback')}
500
+ {t('syncflow.rollback')}
501
501
  </Button>
502
502
  )}
503
503
  </>
@@ -44,7 +44,7 @@ import type { ReactNode } from 'react'
44
44
  import type { TranslateNS } from '../client-types.ts'
45
45
  import type { SyncPullReport, SyncPushPreview, SyncPushReport } from '../../sync/sync-engine.ts'
46
46
  import type { SectionId } from '../../schema/types.ts'
47
- import { Badge, Banner, Button, Card, SectionTitle, Spinner } from '../common/ui.tsx'
47
+ import { Badge, Banner, Button, Card, SectionTitle } from '../common/ui.tsx'
48
48
  import { ErrorBanner } from '../common/ErrorBanner.tsx'
49
49
  import { InfoHint } from '../common/InfoHint.tsx'
50
50
  import { Select } from '../common/Select.tsx'
@@ -1312,13 +1312,12 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
1312
1312
  </label>
1313
1313
  <span className={css.pickerAction}>
1314
1314
  <Button
1315
- disabled={busy || cs.loadingSnapshots === true}
1315
+ loading={cs.loadingSnapshots === true}
1316
+ disabled={busy}
1316
1317
  onClick={() => { void loadSnapshots(undefined, channel, true) }}
1317
1318
  title={t('syncflow.refreshSnapshots')}
1318
1319
  >
1319
- {cs.loadingSnapshots === true
1320
- ? <Spinner label={t('syncflow.refreshingSnapshots')} />
1321
- : <><RefreshIcon size={14} /> {t('syncflow.refreshSnapshots')}</>}
1320
+ {cs.loadingSnapshots === true ? null : <RefreshIcon size={14} />} {t('syncflow.refreshSnapshots')}
1322
1321
  </Button>
1323
1322
  </span>
1324
1323
  </div>
@@ -1489,10 +1488,10 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
1489
1488
  </Button>
1490
1489
  <Button
1491
1490
  variant="primary"
1492
- disabled={state.busy === 'push'}
1491
+ loading={state.busy === 'push'}
1493
1492
  onClick={() => { void runPush() }}
1494
1493
  >
1495
- {state.busy === 'push' ? <Spinner label={t('syncflow.pushing')} /> : t('syncflow.pushConfirm')}
1494
+ {t('syncflow.pushConfirm')}
1496
1495
  </Button>
1497
1496
  </Modal.Footer>
1498
1497
  </Modal>
@@ -23,7 +23,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
23
23
  import type { ReactNode } from 'react'
24
24
  import type { TranslateNS } from '../client-types.ts'
25
25
  import type { SyncApi, SyncProbeResult, SyncPushPayload } from './sync-api.ts'
26
- import { Banner, Button, Spinner } from '../common/ui.tsx'
26
+ import { Banner, Button } from '../common/ui.tsx'
27
27
  import { InfoHint } from '../common/InfoHint.tsx'
28
28
  import { projectProbeResult } from './probe-view.ts'
29
29
  import { toast } from '../common/toast-store.ts'
@@ -104,8 +104,8 @@ export function SyncProbeRow({ api, t, body, remoteReady, busy, presentation = '
104
104
  const view = result === null ? null : projectProbeResult(result, t('probe.unsupported'))
105
105
 
106
106
  const button = (
107
- <Button onClick={() => { void run() }} disabled={busy || loading || !remoteReady}>
108
- {loading ? <Spinner label={t('probe.running')} /> : t('probe.run')}
107
+ <Button onClick={() => { void run() }} loading={loading} disabled={busy || !remoteReady}>
108
+ {t('probe.run')}
109
109
  </Button>
110
110
  )
111
111
  const hintNode = <InfoHint text={t('probe.hint')} label={t('common.infoHint')} />
@@ -20,7 +20,7 @@ import type { ChangeEvent } from 'react'
20
20
 
21
21
  import type { TranslateNS } from '../client-types.ts'
22
22
  import { redact } from '../../security/redaction.ts'
23
- import { Button, Spinner } from '../common/ui.tsx'
23
+ import { Button } from '../common/ui.tsx'
24
24
  import { InfoHint } from '../common/InfoHint.tsx'
25
25
  import { Select } from '../common/Select.tsx'
26
26
  import { toast } from '../common/toast-store.ts'
@@ -159,8 +159,8 @@ export function SyncRepositoryPicker({ open, api, t, repoUrl, busy, onPick }: Sy
159
159
  onOpen={() => { if (!loaded && !loading) void load() }}
160
160
  onChange={pick}
161
161
  />
162
- <Button size="sm" disabled={busy || loading} onClick={() => { void load() }}>
163
- {loading ? <Spinner label={t('repoPicker.refreshing')} /> : t('repoPicker.refresh')}
162
+ <Button size="sm" loading={loading} disabled={busy} onClick={() => { void load() }}>
163
+ {t('repoPicker.refresh')}
164
164
  </Button>
165
165
  </div>
166
166
  {/* 空态 / 失败说明留在原地(不进 ⓘ):用户正卡在这里,说明必须看得见 */}
@@ -200,8 +200,8 @@ export function SyncRepositoryPicker({ open, api, t, repoUrl, busy, onPick }: Sy
200
200
  {nameHint !== null && <span className={css.formError}>{nameHint}</span>}
201
201
  {createError !== null && <span className={css.formError}>{redact(createError)}</span>}
202
202
  <div className={css.actionRowTop}>
203
- <Button variant="primary" size="sm" disabled={busy || creating} onClick={() => { void submitCreate() }}>
204
- {creating ? <Spinner label={t('repoPicker.creating')} /> : t('repoPicker.createSubmit')}
203
+ <Button variant="primary" size="sm" loading={creating} disabled={busy} onClick={() => { void submitCreate() }}>
204
+ {t('repoPicker.createSubmit')}
205
205
  </Button>
206
206
  <Button size="sm" disabled={creating} onClick={() => { setCreateOpen(false) }}>
207
207
  {t('repoPicker.createCancel')}
package/src/index.ts CHANGED
@@ -180,7 +180,7 @@ export const name = 'config-manager'
180
180
  export const inject = ['settings', 'credentials']
181
181
 
182
182
  /** Plugin version, kept in sync with package.json ("version"). */
183
- export const PLUGIN_VERSION = '0.1.82'
183
+ export const PLUGIN_VERSION = '0.1.83'
184
184
 
185
185
  /** Plugin own package name — excluded from its own exported plugins list. */
186
186
  const PLUGIN_NAME = 'dsh-config-manager'