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/client.d.ts +1 -1
- package/lib/client.js +749 -643
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/package.json +1 -1
- package/src/client/ConfigManagerSection.tsx +11 -0
- package/src/client/common/MoreMenu.tsx +17 -2
- package/src/client/config-manager.module.css +9 -2
- package/src/client/environment/EnvironmentPanel.tsx +9 -6
- package/src/client/export/ExportView.tsx +6 -5
- package/src/client/history/HistoryPanel.tsx +32 -5
- package/src/client/history/history-locales.ts +3 -0
- package/src/client/home/BackupScheduleCard.tsx +6 -5
- package/src/client/home/HomePanel.tsx +3 -3
- package/src/client/import/ForeignImportView.tsx +3 -3
- package/src/client/import/import-wizard-steps.tsx +6 -5
- package/src/client/library/ArtifactRow.tsx +39 -7
- package/src/client/library/LibraryActions.tsx +78 -49
- package/src/client/library/LibraryPanel.tsx +11 -2
- package/src/client/market/MarketImportReview.tsx +4 -3
- package/src/client/market/MarketPanel.tsx +7 -7
- package/src/client/market/MyConfigsList.tsx +3 -3
- package/src/client/market/MyConfigsView.tsx +1 -1
- package/src/client/market/MyConfigsWizard.tsx +27 -10
- package/src/client/sync/ChannelConfigDialog.tsx +4 -3
- package/src/client/sync/SyncConfirmView.tsx +3 -3
- package/src/client/sync/SyncPanel.tsx +6 -7
- package/src/client/sync/SyncProbeRow.tsx +3 -3
- package/src/client/sync/SyncRepositoryPicker.tsx +5 -5
- package/src/index.ts +1 -1
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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"
|
|
565
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
379
|
+
loading={running}
|
|
380
|
+
disabled={passwordInvalid || fileNameInvalid || nothingSelected}
|
|
380
381
|
onClick={() => { void runExport() }}
|
|
381
382
|
>
|
|
382
|
-
{
|
|
383
|
+
{t('export.run')}
|
|
383
384
|
</Button>
|
|
384
|
-
<Button size="sm" disabled={running} title={t('picker.title')} onClick={openPicker}>
|
|
385
|
-
{invLoading ?
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
224
|
+
loading={saving}
|
|
225
|
+
disabled={running || !dirty}
|
|
225
226
|
onClick={save}
|
|
226
227
|
title={dirty ? undefined : t('backupSchedule.saved')}
|
|
227
228
|
>
|
|
228
|
-
{
|
|
229
|
+
{t('backupSchedule.save')}
|
|
229
230
|
</Button>
|
|
230
|
-
<Button size="sm" disabled={
|
|
231
|
-
{
|
|
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,
|
|
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
|
-
|
|
375
|
+
loading={backupRunning}
|
|
376
376
|
title={t('overview.quick.backupTitle')}
|
|
377
377
|
onClick={() => { void runBackupNow() }}
|
|
378
378
|
>
|
|
379
|
-
{backupRunning ?
|
|
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
|
|
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={
|
|
314
|
-
{
|
|
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,
|
|
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
|
-
|
|
136
|
+
loading={uploading}
|
|
137
137
|
onClick={() => { fileInput.current?.click() }}
|
|
138
138
|
>
|
|
139
|
-
{
|
|
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
|
-
|
|
231
|
+
loading={unlocking}
|
|
232
|
+
disabled={password === ''}
|
|
232
233
|
onClick={onUnlock}
|
|
233
234
|
>
|
|
234
|
-
{
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 过渡。
|