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.
package/lib/index.d.ts CHANGED
@@ -77,7 +77,7 @@ export declare const name = "config-manager";
77
77
  /** Services required before the engine can mount (present in every profile). */
78
78
  export declare const inject: string[];
79
79
  /** Plugin version, kept in sync with package.json ("version"). */
80
- export declare const PLUGIN_VERSION = "0.1.82";
80
+ export declare const PLUGIN_VERSION = "0.1.83";
81
81
  /**
82
82
  * Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
83
83
  * 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
package/lib/index.js CHANGED
@@ -136,7 +136,7 @@ export const name = 'config-manager';
136
136
  /** Services required before the engine can mount (present in every profile). */
137
137
  export const inject = ['settings', 'credentials'];
138
138
  /** Plugin version, kept in sync with package.json ("version"). */
139
- export const PLUGIN_VERSION = '0.1.82';
139
+ export const PLUGIN_VERSION = '0.1.83';
140
140
  /** Plugin own package name — excluded from its own exported plugins list. */
141
141
  const PLUGIN_NAME = 'dsh-config-manager';
142
142
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-config-manager",
3
- "version": "0.1.82",
3
+ "version": "0.1.83",
4
4
  "license": "MIT",
5
5
  "description": "Backup, restore, export, import, migrate and sync your DeepSeek Harness (DSH) configuration: settings, plugins, MCP servers, skills, agent presets and workspaces. One-click migration to another machine. DSH 配置一键备份、恢复、导出、导入、迁移与同步插件。",
6
6
  "icon": "./icon.svg",
@@ -34,6 +34,7 @@ import { CommandPalette } from './common/CommandPalette.tsx'
34
34
  import { TaskShell } from './task/TaskShell.tsx'
35
35
  import { LibraryPanel } from './library/LibraryPanel.tsx'
36
36
  import { LibraryActions } from './library/LibraryActions.tsx'
37
+ import type { ArtifactPendingAction } from './library/ArtifactRow.tsx'
37
38
  import type { ArtifactCapability, ArtifactRow } from '../ui/artifact-view.ts'
38
39
  import { HomePanel } from './home/HomePanel.tsx'
39
40
  import { ExportView } from './export/ExportView.tsx'
@@ -156,6 +157,14 @@ const navLayoutResult = navWidths === null
156
157
  const [libraryAction, setLibraryAction] = useState<{ capability: ArtifactCapability; row: ArtifactRow } | null>(null)
157
158
  /** 产物列表刷新信号:恢复 / 删除 / 置顶完成后递增,让面板重拉四份清单 */
158
159
  const [libraryTick, setLibraryTick] = useState(0)
160
+ /**
161
+ * 产物库行内动作的进行中态(DESIGN §8.11「行内动作 pending」)。
162
+ * 动作实在 LibraryActions(面板的兄弟节点)里跑的,行的读点在 LibraryPanel ⇒
163
+ * 由动作上报到这里,再作为 prop 传回面板;行内按钮据此转圈/禁用。
164
+ * 放壳层(而不是面板或动作组件)还有一个好处:`pagePad` 带 key=panel,
165
+ * 切页会重挂面板,忙态若存面板里会随切页蒸发。
166
+ */
167
+ const [libraryPending, setLibraryPending] = useState<ArtifactPendingAction | null>(null)
159
168
 
160
169
  /**
161
170
  * 导入面板当前是否停在「从其它 agent 导入」这一页(t17)。
@@ -491,6 +500,7 @@ const navLayoutResult = navWidths === null
491
500
  onOpenExport={() => { openTask('export') }}
492
501
  onOpenPublish={() => { openTask('market', { subView: 'myconfigs' }) }}
493
502
  refreshTick={libraryTick}
503
+ pending={libraryPending}
494
504
  />
495
505
  )
496
506
  break
@@ -623,6 +633,7 @@ const navLayoutResult = navWidths === null
623
633
  target={libraryAction}
624
634
  onDone={() => { setLibraryAction(null) }}
625
635
  onChanged={() => { setLibraryTick((n) => n + 1) }}
636
+ onPendingChange={setLibraryPending}
626
637
  onOpenTask={(kind, marketId) => { openTask(kind, marketId === undefined ? undefined : { marketId }) }}
627
638
  />
628
639
  {activeTask !== null && (
@@ -43,9 +43,15 @@ export interface MoreMenuProps {
43
43
  onSelect: (id: string) => void
44
44
  /** 按钮与菜单的无障碍名(如「更多页面」) */
45
45
  label: string
46
+ /**
47
+ * 禁用整个入口(产物库行内动作进行中时,其余动作一律不可点 —— DESIGN §8.11)。
48
+ * 禁用只拦行为、不换外观:行内的「在忙」提示由调用方另行渲染(那里换的是转圈 + 动作名),
49
+ * 这里只负责「点不开、键盘也打不开」。
50
+ */
51
+ disabled?: boolean
46
52
  }
47
53
 
48
- export function MoreMenu({ items, activeId, onSelect, label }: MoreMenuProps) {
54
+ export function MoreMenu({ items, activeId, onSelect, label, disabled = false }: MoreMenuProps) {
49
55
  const [open, setOpen] = useState(false)
50
56
  const [active, setActive] = useState(-1)
51
57
  const [align, setAlign] = useState<'start' | 'end'>('start')
@@ -64,6 +70,7 @@ export function MoreMenu({ items, activeId, onSelect, label }: MoreMenuProps) {
64
70
  }
65
71
 
66
72
  const openMenu = (): void => {
73
+ if (disabled) return
67
74
  setActive(initialActiveIndex(options, activeId))
68
75
  setAlign(menuAlign(rootRef.current))
69
76
  setOpen(true)
@@ -76,6 +83,11 @@ export function MoreMenu({ items, activeId, onSelect, label }: MoreMenuProps) {
76
83
  close(true)
77
84
  }
78
85
 
86
+ // 打开期间变为禁用(如同行另一个动作开始跑):菜单必须收起,否则它悬空在禁用按钮上方
87
+ useEffect(() => {
88
+ if (disabled && open) close(false)
89
+ }, [disabled, open])
90
+
79
91
  // 点击外部关闭:mousedown 比 click 早,且能覆盖右键与拖拽结束
80
92
  useEffect(() => {
81
93
  if (!open) return
@@ -101,6 +113,8 @@ export function MoreMenu({ items, activeId, onSelect, label }: MoreMenuProps) {
101
113
  }, [open, active])
102
114
 
103
115
  const onKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>): void => {
116
+ // 禁用态整块不响应键盘(按钮本身已 disabled,这是对「打开中突变为禁用」的兜底)
117
+ if (disabled) return
104
118
  switch (event.key) {
105
119
  case 'ArrowDown':
106
120
  case 'ArrowUp': {
@@ -164,7 +178,8 @@ export function MoreMenu({ items, activeId, onSelect, label }: MoreMenuProps) {
164
178
  aria-controls={open ? menuId : undefined}
165
179
  aria-label={label}
166
180
  title={label}
167
- onClick={() => { if (open) close(false); else openMenu() }}
181
+ disabled={disabled}
182
+ onClick={() => { if (disabled) return; if (open) close(false); else openMenu() }}
168
183
  onKeyDown={onKeyDown}
169
184
  >
170
185
  {label}
@@ -257,12 +257,19 @@ body {
257
257
  white-space: nowrap;
258
258
  }
259
259
 
260
- .navTab:hover,
261
- .navMore:hover {
260
+ .navTab:hover:not(:disabled),
261
+ .navMore:hover:not(:disabled) {
262
262
  color: var(--dsw-alias-label-primary);
263
263
  background: var(--dsw-alias-interactive-bg-hover);
264
264
  }
265
265
 
266
+ /* 禁用态(产物库行内 ⋯:同一行有动作在跑时它不可点,DESIGN §8.11)——
267
+ 与 .primaryButton:disabled 同一档视觉,避免出现第二套「灰掉」的样子 */
268
+ .navMore:disabled {
269
+ opacity: 0.4;
270
+ cursor: default;
271
+ }
272
+
266
273
  .navTab:focus-visible,
267
274
  .navMore:focus-visible {
268
275
  outline: var(--cm-focus-width) solid var(--cm-focus-color);
@@ -35,7 +35,7 @@ import { runStore, toProfilesStoreSlice, type ProfilesStoreSlice } from '../run-
35
35
  import { RecoveryPanel } from '../recovery/RecoveryPanel.tsx'
36
36
  import type { RecoveryPort } from '../../ui/types.ts'
37
37
  import type { IncidentApi } from '../recovery/incident-api.ts'
38
- import { Badge, Banner, Button, Card, Checkbox, Empty, SectionTitle, Segmented, Spinner } from '../common/ui.tsx'
38
+ import { Badge, Banner, Button, Card, Checkbox, Empty, SectionTitle, Segmented } from '../common/ui.tsx'
39
39
  import { InfoHint } from '../common/InfoHint.tsx'
40
40
  import { Skeleton, SkeletonList } from '../common/Skeleton.tsx'
41
41
  import { Select } from '../common/Select.tsx'
@@ -465,9 +465,10 @@ export function EnvironmentPanel({ api, t, recoveryApi, recoveryT, incidentApi }
465
465
  variant="ghost"
466
466
  disabled={state.stopping !== null || record.current}
467
467
  title={record.current ? t('profiles.stop.currentHint') : record.owned ? t('profiles.stop.hint') : t('profiles.stop.hintExternal')}
468
+ loading={state.stopping === record.name}
468
469
  onClick={() => { patch({ stopTargetName: record.name, error: null }) }}
469
470
  >
470
- {state.stopping === record.name ? <Spinner label={t('profiles.stopping')} /> : t('profiles.stop')}
471
+ {t('profiles.stop')}
471
472
  </Button>
472
473
  </div>
473
474
  ))}
@@ -561,8 +562,8 @@ export function EnvironmentPanel({ api, t, recoveryApi, recoveryT, incidentApi }
561
562
  label: `${template.id} — ${template.bundles.join(' + ')}`,
562
563
  }))}
563
564
  />
564
- <Button variant="primary" disabled={state.creating || state.createName.trim() === '' || createIssue !== null} onClick={doCreate}>
565
- {state.creating ? <Spinner label={t('profiles.create.creating')} /> : t('profiles.create.action')}
565
+ <Button variant="primary" loading={state.creating} disabled={state.createName.trim() === '' || createIssue !== null} onClick={doCreate}>
566
+ {t('profiles.create.action')}
566
567
  </Button>
567
568
  </div>
568
569
  {createIssue !== null && <span className={css.formError}>{nameIssueText(t, state.createName.trim(), createIssue)}</span>}
@@ -661,10 +662,11 @@ export function EnvironmentPanel({ api, t, recoveryApi, recoveryT, incidentApi }
661
662
  <Button
662
663
  size="sm"
663
664
  title={runningRecord.owned ? t('profiles.stop.hint') : t('profiles.stop.hintExternal')}
665
+ loading={state.stopping === profile.name}
664
666
  disabled={state.stopping !== null}
665
667
  onClick={() => { patch({ stopTargetName: runningRecord.name, error: null }) }}
666
668
  >
667
- {state.stopping === profile.name ? <Spinner label={t('profiles.stopping')} /> : t('profiles.stop')}
669
+ {t('profiles.stop')}
668
670
  </Button>
669
671
  )}
670
672
  {caps.launchOrStop && rowAction === 'launch' && (
@@ -672,10 +674,11 @@ export function EnvironmentPanel({ api, t, recoveryApi, recoveryT, incidentApi }
672
674
  size="sm"
673
675
  variant="primary"
674
676
  title={canLaunchProfile(profile) ? t('profiles.launch.hint') : launchBlockReason(profile) === 'managed' ? t('profiles.launch.managed', { name: profile.name }) : t('profiles.launch.notWeb', { name: profile.name })}
677
+ loading={state.launching === profile.name}
675
678
  disabled={state.launching !== null || state.stopping !== null}
676
679
  onClick={() => { doLaunch(profile) }}
677
680
  >
678
- {state.launching === profile.name ? <Spinner label={t('profiles.launching')} /> : t('profiles.launch')}
681
+ {t('profiles.launch')}
679
682
  </Button>
680
683
  )}
681
684
  {caps.duplicate && (
@@ -37,7 +37,7 @@ import type { TranslateNS } from '../client-types.ts'
37
37
  import { resolveFailedSections } from '../api.ts'
38
38
  import type { ConfigManagerApi, ExportPreviewResponse } from '../api.ts'
39
39
  import { runStore } from '../run-store.ts'
40
- import { Banner, Button, Card, Checkbox, Spinner } from '../common/ui.tsx'
40
+ import { Banner, Button, Card, Checkbox } from '../common/ui.tsx'
41
41
  import { ContentPicker } from '../common/ContentPicker.tsx'
42
42
  import { SectionComposition } from '../common/SectionComposition.tsx'
43
43
  import { sectionLabel, sectionLabeler } from '../common/section-labels.ts'
@@ -376,13 +376,14 @@ export function ExportView({ api, t }: ExportViewProps) {
376
376
  且**「开始导出」在前、「选择要导出的内容」在后**(主操作在左,选择内容在它右边)。 */}
377
377
  <Button
378
378
  variant="primary"
379
- disabled={running || passwordInvalid || fileNameInvalid || nothingSelected}
379
+ loading={running}
380
+ disabled={passwordInvalid || fileNameInvalid || nothingSelected}
380
381
  onClick={() => { void runExport() }}
381
382
  >
382
- {running ? <Spinner /> : t('export.run')}
383
+ {t('export.run')}
383
384
  </Button>
384
- <Button size="sm" disabled={running} title={t('picker.title')} onClick={openPicker}>
385
- {invLoading ? <Spinner /> : <PreviewIcon size={13} />} {t('picker.title')}
385
+ <Button size="sm" loading={invLoading} disabled={running} title={t('picker.title')} onClick={openPicker}>
386
+ {invLoading ? null : <PreviewIcon size={13} />} {t('picker.title')}
386
387
  </Button>
387
388
  {/* 工具栏「模式提示」属「机制怎么工作 + 省事提示」(MOVE 类):收进 ⓘ,
388
389
  不再常驻占首屏一行(默认导出推荐分区 + 去哪里调整勾选)。 */}
@@ -42,6 +42,12 @@ interface PanelState {
42
42
  filter: HistoryFilter
43
43
  /** 导出中的格式(瞬态;结果反馈走全局 Toast,不再占页内一行灰字) */
44
44
  exporting: 'json' | 'markdown' | null
45
+ /**
46
+ * 「刷新」专用进行中态:**列表已经在了**,此时不该把它打回骨架
47
+ * (DESIGN UI-25 ②:页面必须不掉 —— 内容闪一下骨架会被读成「我刚筛好的东西没了」)。
48
+ * 首次加载仍走 status='loading' 的骨架分支。
49
+ */
50
+ refreshing: boolean
45
51
  }
46
52
 
47
53
  export function HistoryPanel({ historyApi, t }: HistoryPanelProps) {
@@ -51,6 +57,7 @@ export function HistoryPanel({ historyApi, t }: HistoryPanelProps) {
51
57
  result: null,
52
58
  filter: { query: '' },
53
59
  exporting: null,
60
+ refreshing: false,
54
61
  })
55
62
  const mounted = useRef(true)
56
63
  useEffect(() => {
@@ -58,13 +65,27 @@ export function HistoryPanel({ historyApi, t }: HistoryPanelProps) {
58
65
  return () => { mounted.current = false }
59
66
  }, [])
60
67
 
61
- const load = async (): Promise<void> => {
62
- setState((s) => ({ ...s, status: 'loading', error: null }))
68
+ /**
69
+ * 读列表。`quiet` = 页面上已有列表时的「刷新」:
70
+ * 不复位 status(否则整页回骨架、已筛好的清单在眼前消失),只点亮按钮自己的 loading;
71
+ * 失败也不清列表,改为 toast —— 旧数据仍在屏上,比空白页有用。
72
+ */
73
+ const load = async (quiet = false): Promise<void> => {
74
+ if (quiet) setState((s) => ({ ...s, refreshing: true, error: null }))
75
+ else setState((s) => ({ ...s, status: 'loading', error: null }))
63
76
  try {
64
77
  const result = await historyApi.list()
65
- if (mounted.current) setState((s) => ({ ...s, status: 'ready', result }))
78
+ if (mounted.current) setState((s) => ({ ...s, status: 'ready', refreshing: false, result }))
66
79
  } catch (error) {
67
- if (mounted.current) setState((s) => ({ ...s, status: 'error', error: error instanceof Error ? error.message : String(error) }))
80
+ const message = error instanceof Error ? error.message : String(error)
81
+ if (mounted.current) {
82
+ if (quiet) {
83
+ setState((s) => ({ ...s, refreshing: false }))
84
+ toast.error(`${t('history.refreshError')}: ${redact(message)}`)
85
+ } else {
86
+ setState((s) => ({ ...s, status: 'error', refreshing: false, error: message }))
87
+ }
88
+ }
68
89
  }
69
90
  }
70
91
  useEffect(() => { void load() }, [historyApi]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -182,7 +203,13 @@ export function HistoryPanel({ historyApi, t }: HistoryPanelProps) {
182
203
  value={state.filter.query}
183
204
  onChange={(e) => setFilter({ query: e.target.value })}
184
205
  />
185
- <Button onClick={() => void load()}>{t('history.refresh')}</Button>
206
+ <Button
207
+ onClick={() => void load(true)}
208
+ loading={state.refreshing}
209
+ disabled={state.refreshing}
210
+ >
211
+ {t('history.refresh')}
212
+ </Button>
186
213
  <Button
187
214
  onClick={() => void handleExport('json')}
188
215
  disabled={state.exporting !== null || summary.total === 0}
@@ -42,6 +42,7 @@ type CommonKey =
42
42
  | 'history.table.summary'
43
43
  | 'history.updatedAt'
44
44
  | 'history.refresh'
45
+ | 'history.refreshError'
45
46
  /** ⓘ 说明按钮的可访问名(与主字典 / market / sync / recovery 同口径,见 info-hint-guard t6-4) */
46
47
  | 'common.infoHint';
47
48
 
@@ -83,6 +84,7 @@ export const zh = {
83
84
  'history.table.summary': '摘要',
84
85
  'history.updatedAt': '更新于',
85
86
  'history.refresh': '刷新',
87
+ 'history.refreshError': '刷新失败',
86
88
  'history.kind.import': '导入',
87
89
  'history.kind.restore': '快照恢复',
88
90
  'history.kind.rollback': '回滚',
@@ -141,6 +143,7 @@ export const en = {
141
143
  'history.table.summary': 'Summary',
142
144
  'history.updatedAt': 'Updated',
143
145
  'history.refresh': 'Refresh',
146
+ 'history.refreshError': 'Refresh failed',
144
147
  'history.kind.import': 'Import',
145
148
  'history.kind.restore': 'Snapshot restore',
146
149
  'history.kind.rollback': 'Rollback',
@@ -34,7 +34,7 @@ import { describeSkipReason } from '../sync/history-model.ts'
34
34
  import { runStore } from '../run-store.ts'
35
35
  import type { ConfigManagerApi } from '../api.ts'
36
36
  import type { TranslateNS } from '../client-types.ts'
37
- import { Badge, Banner, Button, Card, Checkbox, Spinner, StatusDot } from '../common/ui.tsx'
37
+ import { Badge, Banner, Button, Card, Checkbox, StatusDot } from '../common/ui.tsx'
38
38
  import { Select } from '../common/Select.tsx'
39
39
  import { Skeleton } from '../common/Skeleton.tsx'
40
40
  import { InfoHint } from '../common/InfoHint.tsx'
@@ -221,14 +221,15 @@ export function BackupScheduleCard({ api, t, syncT, onBackupDone }: BackupSchedu
221
221
  <Button
222
222
  variant="primary"
223
223
  size="sm"
224
- disabled={busy || !dirty}
224
+ loading={saving}
225
+ disabled={running || !dirty}
225
226
  onClick={save}
226
227
  title={dirty ? undefined : t('backupSchedule.saved')}
227
228
  >
228
- {saving ? <Spinner /> : t('backupSchedule.save')}
229
+ {t('backupSchedule.save')}
229
230
  </Button>
230
- <Button size="sm" disabled={busy || !(saved?.enabled ?? false)} onClick={runNow}>
231
- {running ? <Spinner /> : t('backupSchedule.runNow')}
231
+ <Button size="sm" loading={running} disabled={saving || !(saved?.enabled ?? false)} onClick={runNow}>
232
+ {t('backupSchedule.runNow')}
232
233
  </Button>
233
234
  </div>
234
235
 
@@ -45,7 +45,7 @@ import {
45
45
  relTime,
46
46
  type OverviewMetricKey,
47
47
  } from '../../ui/overview-view.ts'
48
- import { Badge, Banner, Button, Card, Spinner, StatusDot, Stepper } from '../common/ui.tsx'
48
+ import { Badge, Banner, Button, Card, StatusDot, Stepper } from '../common/ui.tsx'
49
49
  import { SkeletonRows } from '../common/Skeleton.tsx'
50
50
  import { BackupIcon, ExportIcon, ImportIcon, SyncIcon } from '../common/Icon.tsx'
51
51
  import { CopyButton } from '../common/CopyButton.tsx'
@@ -372,11 +372,11 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
372
372
  <div className={css.toolRow}>
373
373
  <Button
374
374
  variant="primary"
375
- disabled={backupRunning}
375
+ loading={backupRunning}
376
376
  title={t('overview.quick.backupTitle')}
377
377
  onClick={() => { void runBackupNow() }}
378
378
  >
379
- {backupRunning ? <Spinner /> : <BackupIcon size={14} />} {t('overview.quick.backup')}
379
+ {backupRunning ? null : <BackupIcon size={14} />} {t('overview.quick.backup')}
380
380
  </Button>
381
381
  <Button title={t('overview.quick.exportTitle')} onClick={openExportTask}>
382
382
  <ExportIcon size={14} /> {t('nav.export')} ZIP
@@ -29,7 +29,7 @@ import type { TranslateNS } from '../client-types.ts'
29
29
  import type { ConfigManagerApi, ForeignImportResponse } from '../api.ts'
30
30
  import type { UiT } from '../../ui/i18n.ts'
31
31
  import { foreignLossyRows, foreignPathsText, foreignSourcesViewModel, type ForeignSourceRow } from '../../ui/foreign-view.ts'
32
- import { Badge, Banner, Button, Card, SectionTitle, Spinner } from '../common/ui.tsx'
32
+ import { Badge, Banner, Button, Card, SectionTitle } from '../common/ui.tsx'
33
33
  import { SkeletonList } from '../common/Skeleton.tsx'
34
34
  import { InfoHint } from '../common/InfoHint.tsx'
35
35
  import { ErrorBanner } from '../common/ErrorBanner.tsx'
@@ -310,8 +310,8 @@ export function ForeignImportView({ api, t, uiT, onReady }: ForeignImportViewPro
310
310
  <Button onClick={handleRefresh} disabled={importing}>
311
311
  {t('foreign.action.refresh')}
312
312
  </Button>
313
- <Button variant="primary" onClick={() => { void runImport() }} disabled={importing || selected === null}>
314
- {importing ? <Spinner label={t('foreign.importing')} /> : t('foreign.action.import')}
313
+ <Button variant="primary" loading={importing} onClick={() => { void runImport() }} disabled={selected === null}>
314
+ {t('foreign.action.import')}
315
315
  </Button>
316
316
  </div>
317
317
  </div>
@@ -13,7 +13,7 @@ import type { ConsultReport } from '../../core/migration-consult.ts'
13
13
  import type { TranslateNS } from '../client-types.ts'
14
14
  import type { UiT } from '../../ui/i18n.ts'
15
15
  import {
16
- Banner, Button, Card, Checkbox, Empty, SectionTitle, Spinner,
16
+ Banner, Button, Card, Checkbox, Empty, SectionTitle,
17
17
  } from '../common/ui.tsx'
18
18
  import { Skeleton } from '../common/Skeleton.tsx'
19
19
  import { InfoHint } from '../common/InfoHint.tsx'
@@ -133,10 +133,10 @@ export function SelectStep(props: {
133
133
  )}
134
134
  <Button
135
135
  variant="primary"
136
- disabled={uploading}
136
+ loading={uploading}
137
137
  onClick={() => { fileInput.current?.click() }}
138
138
  >
139
- {uploading ? <Spinner label={t('import.analyzing')} /> : t(browseLabelKey(selectModel.selectedName !== null))}
139
+ {t(browseLabelKey(selectModel.selectedName !== null))}
140
140
  </Button>
141
141
  </div>
142
142
  {error !== null && <ErrorBanner error={error} onRetry={onReset} t={apiT} />}
@@ -228,10 +228,11 @@ export function DecryptArchiveStep(props: {
228
228
  <Button variant="ghost" onClick={onReset}>{t('import.select.reselect')}</Button>
229
229
  <Button
230
230
  variant="primary"
231
- disabled={password === '' || unlocking}
231
+ loading={unlocking}
232
+ disabled={password === ''}
232
233
  onClick={onUnlock}
233
234
  >
234
- {unlocking ? <Spinner label={t('import.decryptArchive.unlocking')} /> : t('import.decryptArchive.unlock')}
235
+ {t('import.decryptArchive.unlock')}
235
236
  </Button>
236
237
  </div>
237
238
  </div>
@@ -18,7 +18,7 @@ import {
18
18
  } from '../../ui/artifact-view.ts'
19
19
  import type { UiT } from '../../ui/i18n.ts'
20
20
  import type { TranslateNS } from '../client-types.ts'
21
- import { Badge, Button } from '../common/ui.tsx'
21
+ import { Badge, Button, Spinner } from '../common/ui.tsx'
22
22
  import { ExpandChevron } from '../common/Icon.tsx'
23
23
  import { MoreMenu } from '../common/MoreMenu.tsx'
24
24
  import { ArtifactDetail } from './ArtifactDetail.tsx'
@@ -32,6 +32,17 @@ const BADGE_KIND: Record<ArtifactBadge, 'info' | 'ok' | 'warn' | 'error'> = {
32
32
  'unreadable': 'error',
33
33
  }
34
34
 
35
+ /**
36
+ * 行内动作的**进行中态**:哪一行的哪个动作正在跑。
37
+ * 生产者 = LibraryActions(动作实现处),持有者 = 壳层,消费者 = LibraryPanel → ArtifactRowView。
38
+ * 三处共用这一个类型,避免各写一份结构而漂移(此前正是「写了 busy 却没人读」)。
39
+ */
40
+ export interface ArtifactPendingAction {
41
+ /** 行的稳定键(ArtifactRow.key) */
42
+ key: string
43
+ capability: ArtifactCapability
44
+ }
45
+
35
46
  export interface ArtifactRowViewProps {
36
47
  row: ArtifactRow
37
48
  t: TranslateNS<'config-manager'>
@@ -41,8 +52,14 @@ export interface ArtifactRowViewProps {
41
52
  expanded: boolean
42
53
  onToggle: () => void
43
54
  onAction: (capability: ArtifactCapability, row: ArtifactRow) => void
44
- /** 该行有动作在跑:禁用行内按钮(防重复提交) */
45
- busy?: boolean
55
+ /**
56
+ * 该行**正在进行中的那个动作**(DESIGN.md §8.11「行内动作 pending」/ UI-25)。
57
+ * null = 空闲。有值时就地给可见反馈并禁用其余入口(防连点)。
58
+ *
59
+ * 为什么不是 boolean:走 ⋯ 菜单的动作在跑时,触发器已被换成「转圈 + 该动作文案」,
60
+ * 否则用户只看到「更多」位置上冒出个圈,根本不知道跑的是刚点的那条。
61
+ */
62
+ pending?: ArtifactCapability | null
46
63
  /**
47
64
  * cloud-sync 借鉴项 ⑥:该行远端快照的**推送方设备**文案(如「由 <设备名> 推送」)。
48
65
  * 只有快照 manifest 真的带 deviceName 时才由调用方给出 —— 缺省 undefined 表示**不渲染**
@@ -52,12 +69,20 @@ export interface ArtifactRowViewProps {
52
69
  }
53
70
 
54
71
  export function ArtifactRowView({
55
- row, t, uiT, formatTime, expanded, onToggle, onAction, busy = false, deviceLabel,
72
+ row, t, uiT, formatTime, expanded, onToggle, onAction, pending = null, deviceLabel,
56
73
  }: ArtifactRowViewProps) {
57
74
  const detailId = useId()
58
75
  const primary = row.capabilities[0]
59
76
  const rest = row.capabilities.slice(1)
60
77
  const encrypted = row.badges.includes('encrypted')
78
+ /**
79
+ * 三态:空闲 / 主操作在跑 / 某个 ⋯ 动作在跑。
80
+ * 主操作在跑 → 按钮自己 loading(Button 原语给图标),⋯ 整块禁用;
81
+ * ⋯ 动作在跑 → ⋯ 触发器换成「转圈 + 该动作文案」,主操作禁用。
82
+ * 两条路的共同点:**同一时刻只允许一个动作**(此前这里完全没有反馈,可以连点)。
83
+ */
84
+ const pendingPrimary = primary !== undefined && pending === primary
85
+ const pendingRest = rest.find((capability) => capability === pending)
61
86
  const name = row.title.kind === 'text' ? row.title.value : formatTime(row.title.iso)
62
87
  /** 加密徽章的可访问说明(「导入前需解锁」= 安全类文案,必须可见可读,不能只靠颜色) */
63
88
  const encryptedHint = t('backupFiles.encryptedHint')
@@ -103,20 +128,27 @@ export function ArtifactRowView({
103
128
  <Button
104
129
  size="sm"
105
130
  variant={primary === 'delete' ? 'danger' : 'primary'}
106
- disabled={busy}
131
+ // 进行中图标由 Button 原语按 loading 自动渲染(DESIGN §14.11:不要手写 Spinner)
132
+ loading={pendingPrimary}
133
+ disabled={pending !== null && !pendingPrimary}
107
134
  onClick={() => { onAction(primary, row) }}
108
135
  >
109
136
  {actionLabel(primary, encrypted, uiT)}
110
137
  </Button>
111
138
  )}
112
- {rest.length > 0 && (
139
+ {rest.length > 0 && (pendingRest === undefined ? (
113
140
  <MoreMenu
114
141
  items={rest.map((capability) => ({ id: capability, label: actionLabel(capability, encrypted, uiT) }))}
115
142
  activeId=""
116
143
  onSelect={(id) => { onAction(id as ArtifactCapability, row) }}
117
144
  label={t('library.moreActions')}
145
+ disabled={pending !== null}
118
146
  />
119
- )}
147
+ ) : (
148
+ /* 走 ⋯ 的动作在跑:菜单已收起,把触发器原地换成「转圈 + 该动作文案」——
149
+ 只留一个圈的话,用户看不出跑的是「下载」还是「迁移前咨询」 */
150
+ <Spinner label={actionLabel(pendingRest, encrypted, uiT)} />
151
+ ))}
120
152
  </div>
121
153
  </div>
122
154
  {/* **始终挂载**:展开/收回由 data-expanded 驱动 CSS 过渡。