dsh-skill-hub 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/lib/client.js +1249 -407
- package/lib/client.js.map +1 -1
- package/lib/index.js +439 -81
- package/lib/types/client/api.d.ts +12 -2
- package/lib/types/client/locales.d.ts +13 -0
- package/lib/types/client/panel/ScenesView.d.ts +1 -1
- package/lib/types/client/panel/SourcesView.d.ts +1 -1
- package/lib/types/client/panel/useSkillHub.d.ts +20 -2
- package/lib/types/protocol.d.ts +93 -6
- package/lib/types/repo.d.ts +18 -11
- package/lib/types/store.d.ts +14 -2
- package/package.json +1 -1
- package/src/client/api.ts +61 -1
- package/src/client/locales.ts +26 -0
- package/src/client/panel/MarketView.tsx +201 -47
- package/src/client/panel/ScenesView.tsx +27 -2
- package/src/client/panel/SkillHubPanel.tsx +18 -19
- package/src/client/panel/SkillRow.tsx +8 -8
- package/src/client/panel/SourcesView.tsx +203 -26
- package/src/client/panel/panel.module.css +17 -6
- package/src/client/panel/useSkillHub.ts +219 -12
- package/src/index.ts +18 -0
- package/src/protocol.ts +89 -6
- package/src/repo.test.ts +61 -19
- package/src/repo.ts +86 -23
- package/src/routes.test.ts +18 -4
- package/src/routes.ts +269 -47
- package/src/skillfs.ts +17 -4
- package/src/store.ts +72 -4
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* update-all actions and the repo scan result with the importable checklist.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { Fragment, type JSX } from 'react'
|
|
8
|
+
import { Fragment, useEffect, useMemo, useState, type JSX } from 'react'
|
|
9
9
|
import { tt } from '../helpers.ts'
|
|
10
10
|
import { MARKET_CATALOG } from '../market-catalog.ts'
|
|
11
|
-
import type { MarketSourceRecord } from '../../protocol.ts'
|
|
11
|
+
import type { MarketSourceRecord, RepoSkillEntry } from '../../protocol.ts'
|
|
12
12
|
import { ConfirmDialog } from './dialogs.tsx'
|
|
13
13
|
import type { SkillHubState } from './useSkillHub.ts'
|
|
14
14
|
import css from './panel.module.css'
|
|
@@ -19,19 +19,60 @@ function formatBytes(bytes: number): string {
|
|
|
19
19
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
20
20
|
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
|
21
21
|
}
|
|
22
|
+
function formatSpeed(bps?: number): string {
|
|
23
|
+
if (bps === undefined || bps <= 0) return ''
|
|
24
|
+
if (bps < 1024) return bps + ' B/s'
|
|
25
|
+
if (bps < 1024 * 1024) return (bps / 1024).toFixed(1) + ' KB/s'
|
|
26
|
+
return (bps / 1024 / 1024).toFixed(1) + ' MB/s'
|
|
27
|
+
}
|
|
22
28
|
|
|
23
29
|
export function MarketView(props: { hub: SkillHubState }): JSX.Element {
|
|
24
30
|
const { hub } = props
|
|
25
|
-
const { marketState, marketCheck, sourceCheck, sourcesState, repoDiscoverState, scanningRepo, repoSelected, setRepoSelected, repoImporting, repoResult, newSourceName, setNewSourceName, syncingMarket, tagBusy, addSource, addMarketSource, removeMarketSource, scanRepo, checkMarket, checkSources, syncMarketSource, toggleRepoSelected, importRepo, updateAllDialog, setUpdateAllDialog, updateAll } = hub
|
|
31
|
+
const { marketState, marketCheck, sourceCheck, sourcesState, repoDiscoverState, scanningRepo, repoSelected, setRepoSelected, repoImporting, repoResult, newSourceName, setNewSourceName, syncingMarket, tagBusy, checkingSource, addSource, addMarketSource, removeMarketSource, scanRepo, checkMarket, checkSources, syncMarketSource, toggleRepoSelected, importRepo, cancelImport, updateAllDialog, setUpdateAllDialog, updateAll } = hub
|
|
26
32
|
/** 有可更新技能的来源(全部更新按钮与确认列表共用)。 */
|
|
27
33
|
const updatableRepos = Object.entries(sourceCheck).filter(([, check]) => check.changed && check.updated.length > 0)
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
// 276 列表本地状态:搜索 / 过滤 / 分页(精简:扁平列表,无 A-Z 分组)
|
|
36
|
+
const [repoSearch, setRepoSearch] = useState('')
|
|
37
|
+
const [repoFilter, setRepoFilter] = useState<string>('all')
|
|
38
|
+
const [visibleCount, setVisibleCount] = useState(50)
|
|
39
|
+
useEffect(() => { setVisibleCount(50) }, [repoDiscoverState, repoSearch, repoFilter])
|
|
40
|
+
|
|
41
|
+
/** 一个已添加来源的完整行:状态徽章 + 操作按钮。B 方案:扫描主按钮 + 智能更新按钮(检查/更新二合一)+ 溢出移除 */
|
|
30
42
|
const sourceRow = (record: MarketSourceRecord): JSX.Element => {
|
|
31
43
|
const releaseCheck = marketCheck[record.repo]
|
|
32
44
|
const skillCheck = sourceCheck[record.repo]
|
|
33
45
|
const installedCount = sourcesState?.sources.find((source) => source.repo === record.repo)?.skills.length ?? 0
|
|
34
46
|
const scanning = scanningRepo === record.repo
|
|
47
|
+
const syncing = syncingMarket === record.repo
|
|
48
|
+
const checking = checkingSource === record.repo
|
|
49
|
+
const hasSkillUpdate = skillCheck?.changed === true && skillCheck.updated.length > 0
|
|
50
|
+
const hasReleaseUpdate = releaseCheck?.updateAvailable === true
|
|
51
|
+
const hasUpdate = hasSkillUpdate || hasReleaseUpdate
|
|
52
|
+
const isChecked = releaseCheck !== undefined || skillCheck !== undefined
|
|
53
|
+
// 智能按钮三态:未检查→检查更新,未检查完/有更新→更新 N 个,已检查无更新→已是最新
|
|
54
|
+
let updateLabel: string
|
|
55
|
+
let updateDisabled = false
|
|
56
|
+
let updateDanger = false
|
|
57
|
+
let updateAction: () => void = () => {}
|
|
58
|
+
if (syncing) {
|
|
59
|
+
updateLabel = tt('market.syncing')
|
|
60
|
+
updateDisabled = true
|
|
61
|
+
} else if (checking) {
|
|
62
|
+
updateLabel = tt('market.checking')
|
|
63
|
+
updateDisabled = true
|
|
64
|
+
} else if (!isChecked) {
|
|
65
|
+
updateLabel = tt('market.checkUpdate')
|
|
66
|
+
updateAction = () => { void checkSources(record.repo); void checkMarket() }
|
|
67
|
+
} else if (hasUpdate) {
|
|
68
|
+
const count = hasSkillUpdate ? skillCheck!.updated.length : 1
|
|
69
|
+
updateLabel = count > 1 || hasSkillUpdate ? tt('market.updateCount', { count }) : tt('market.update')
|
|
70
|
+
updateDanger = true
|
|
71
|
+
updateAction = () => { void syncMarketSource(record.repo) }
|
|
72
|
+
} else {
|
|
73
|
+
updateLabel = tt('market.upToDate')
|
|
74
|
+
updateDisabled = true
|
|
75
|
+
}
|
|
35
76
|
return (
|
|
36
77
|
<div className={css.row + ' ' + css.rowStatic}>
|
|
37
78
|
<div className={css.rowMain}>
|
|
@@ -39,25 +80,38 @@ export function MarketView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
39
80
|
<a className={css.sourceLink} href={'https://github.com/' + record.repo} target='_blank' rel='noreferrer'>{record.repo}</a>
|
|
40
81
|
{record.ref !== undefined ? <span className={css.badge + ' ' + css.badgeSource}>{record.ref}</span> : null}
|
|
41
82
|
{installedCount > 0 ? <span className={css.badge + ' ' + css.badgeCount}>{tt('market.installed', { count: installedCount })}</span> : null}
|
|
42
|
-
{
|
|
43
|
-
? <span className={css.badge + ' ' + css.statusUpdated}>{tt('market.updatable', { count: skillCheck
|
|
83
|
+
{hasSkillUpdate
|
|
84
|
+
? <span className={css.badge + ' ' + css.statusUpdated}>{tt('market.updatable', { count: skillCheck!.updated.length })}</span>
|
|
44
85
|
: null}
|
|
45
86
|
{skillCheck !== undefined && skillCheck.deleted.length > 0
|
|
46
87
|
? <span className={css.badge + ' ' + css.statusError}>{tt('market.deletedUpstream', { count: skillCheck.deleted.length })}</span>
|
|
47
88
|
: null}
|
|
48
|
-
{
|
|
49
|
-
? <span className={css.badge + ' ' + css.statusUpdated}>{releaseCheck
|
|
89
|
+
{hasReleaseUpdate
|
|
90
|
+
? <span className={css.badge + ' ' + css.statusUpdated}>{releaseCheck!.latestTag !== undefined ? tt('market.newRelease', { version: releaseCheck!.latestTag }) : tt('market.updated')}</span>
|
|
50
91
|
: null}
|
|
51
92
|
</div>
|
|
52
93
|
</div>
|
|
53
|
-
<button type='button' className={css.
|
|
54
|
-
<button type='button' className={css.opBtn + (releaseCheck?.updateAvailable === true ? ' ' + css.opDanger : '')} disabled={syncingMarket === record.repo} onClick={() => { void syncMarketSource(record.repo) }}>
|
|
55
|
-
{syncingMarket === record.repo ? tt('market.syncing') : tt('market.sync')}
|
|
56
|
-
</button>
|
|
57
|
-
<button type='button' className={css.opBtn} disabled={scanning} onClick={() => { void scanRepo(record.repo) }}>
|
|
94
|
+
<button type='button' className={css.button + ' ' + css.primary} style={{ padding:'4px 10px', fontSize:12 }} disabled={scanning} onClick={() => { void scanRepo(record.repo) }}>
|
|
58
95
|
{scanning ? tt('market.scanning') : tt('market.scan')}
|
|
59
96
|
</button>
|
|
60
|
-
<button
|
|
97
|
+
<button
|
|
98
|
+
type='button'
|
|
99
|
+
className={css.opBtn + (updateDanger ? ' ' + css.opDanger : '')}
|
|
100
|
+
disabled={updateDisabled}
|
|
101
|
+
title={hasUpdate ? '将同步到上游最新版并提示可更新的本地技能' : undefined}
|
|
102
|
+
onClick={updateAction}
|
|
103
|
+
>
|
|
104
|
+
{updateLabel}
|
|
105
|
+
</button>
|
|
106
|
+
<button
|
|
107
|
+
type='button'
|
|
108
|
+
className={css.opBtn}
|
|
109
|
+
disabled={tagBusy}
|
|
110
|
+
title={tt('market.removeHint')}
|
|
111
|
+
aria-label={tt('market.deleteSource')}
|
|
112
|
+
onClick={() => { void removeMarketSource(record.repo) }}
|
|
113
|
+
style={{ padding:'4px 8px', minWidth:28 }}
|
|
114
|
+
>×</button>
|
|
61
115
|
</div>
|
|
62
116
|
)
|
|
63
117
|
}
|
|
@@ -122,53 +176,153 @@ export function MarketView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
122
176
|
{marketState.status !== 'loading' && rows.length === 0 ? <div className={css.empty}>{tt('market.noSources')}</div> : null}
|
|
123
177
|
{marketState.status !== 'loading' ? rows.map((row) => <Fragment key={row.key}>{row.element}</Fragment>) : null}
|
|
124
178
|
</section>
|
|
125
|
-
{
|
|
126
|
-
{repoDiscoverState.status
|
|
127
|
-
|
|
179
|
+
{/* 扫描结果归属卡片:用同一 section 卡片包裹扫描中/错误/就绪,标题直接点明所属仓库,消除割裂感 */}
|
|
180
|
+
{(repoDiscoverState.status !== 'idle' || repoResult !== null) ? (
|
|
181
|
+
<section className={css.section} style={{ borderLeft: '3px solid var(--hub-model, #2f81f7)', background: 'rgba(47,129,247,0.04)' }}>
|
|
182
|
+
<div className={css.sectionTitle + ' ' + css.sectionHeadRow} style={{ margin: '10px 14px 6px' }}>
|
|
183
|
+
<span className={css.sectionTitleFill} style={{ display:'flex', alignItems:'center', gap:6 }}>
|
|
184
|
+
<span style={{ width:7, height:7, borderRadius:'50%', background:'var(--hub-model, #2f81f7)', display:'inline-block' }} />
|
|
185
|
+
扫描结果
|
|
186
|
+
<span style={{ fontWeight:400, opacity:.75 }}>
|
|
187
|
+
— {repoDiscoverState.status === 'ready' ? repoDiscoverState.data.repo : repoDiscoverState.status === 'error' ? (scanningRepo ?? '') : (scanningRepo ?? '')}
|
|
188
|
+
{repoDiscoverState.status === 'ready' && repoDiscoverState.data.ref !== null ? <span style={{ opacity:.5, marginLeft:6 }}>ref {repoDiscoverState.data.ref}</span> : null}
|
|
189
|
+
</span>
|
|
190
|
+
</span>
|
|
191
|
+
<button type='button' className={css.opBtn} onClick={() => { hub.clearScan() }}>关闭</button>
|
|
192
|
+
</div>
|
|
193
|
+
<div style={{ padding:'0 12px 12px' }}>
|
|
194
|
+
{repoDiscoverState.status === 'scanning' ? <div className={css.empty} style={{ padding:'12px 0' }}>{tt('market.scanning')}</div> : null}
|
|
195
|
+
{repoDiscoverState.status === 'error' ? <div className={css.errorBanner}>{repoDiscoverState.message}</div> : null}
|
|
196
|
+
{repoDiscoverState.status === 'ready' ? (() => {
|
|
128
197
|
const entries = repoDiscoverState.data.entries
|
|
129
|
-
|
|
198
|
+
// 导入后 catalog 已包含新技能,但 scan 快照的 existing 仍为 false,需用实时 catalog + 本次导入结果兜底,避免已导入仍可选
|
|
199
|
+
const installedNames = new Set<string>([
|
|
200
|
+
...((hub.catalog?.skills ?? []).map((s) => s.name)),
|
|
201
|
+
...((hub.catalog?.disabled ?? []).map((d) => d.name)),
|
|
202
|
+
...((repoResult?.imported ?? []).map((r) => r.name)),
|
|
203
|
+
...((repoResult?.skipped ?? []).map((r) => r.name)),
|
|
204
|
+
])
|
|
205
|
+
const isExisting = (entry: RepoSkillEntry): boolean => entry.existing || installedNames.has(entry.name)
|
|
206
|
+
const selected = entries.filter((entry) => repoSelected.has(entry.path) && !isExisting(entry))
|
|
207
|
+
const selectedBytes = selected.reduce((s, e) => s + e.totalBytes, 0)
|
|
208
|
+
// 过滤 + 搜索(276 场景核心)
|
|
209
|
+
const filtered = entries.filter((entry) => {
|
|
210
|
+
if (repoFilter !== 'all' && entry.root !== repoFilter) return false
|
|
211
|
+
if (repoSearch.trim() !== '' && !entry.name.toLowerCase().includes(repoSearch.trim().toLowerCase())) return false
|
|
212
|
+
return true
|
|
213
|
+
}).sort((a, b) => a.name.localeCompare(b.name))
|
|
214
|
+
const paged = filtered.slice(0, visibleCount)
|
|
130
215
|
return (
|
|
131
216
|
<>
|
|
132
|
-
<p className={css.hintLine + ' ' + css.hintInline}>{tt('repo.ready', { count: entries.length })}</p>
|
|
217
|
+
<p className={css.hintLine + ' ' + css.hintInline}>{tt('repo.ready', { count: entries.length })} · {repoDiscoverState.data.ref !== null ? `ref ${repoDiscoverState.data.ref}` : ''}</p>
|
|
133
218
|
{entries.length === 0 ? <div className={css.empty}>{tt('repo.empty')}</div> : (
|
|
134
219
|
<>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
220
|
+
{/* 搜索 + 过滤 + 统计 — 精简 */}
|
|
221
|
+
{(() => {
|
|
222
|
+
// 动态根:按已知顺序 skills/design-templates 优先,其余按字母序
|
|
223
|
+
const order = new Map<string, number>([['skills', 0], ['design-templates', 1]])
|
|
224
|
+
const roots = [...new Set(entries.map((e) => e.root))].sort((a, b) => {
|
|
225
|
+
const ao = order.get(a) ?? 99
|
|
226
|
+
const bo = order.get(b) ?? 99
|
|
227
|
+
return ao !== bo ? ao - bo : a.localeCompare(b)
|
|
228
|
+
})
|
|
229
|
+
const counts = new Map<string, number>()
|
|
230
|
+
for (const e of entries) counts.set(e.root, (counts.get(e.root) ?? 0) + 1)
|
|
142
231
|
return (
|
|
143
|
-
<div
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<div className={css.rowName}>{entry.name}</div>
|
|
155
|
-
<div className={css.rowDesc}>{entry.dir} · {tt('repo.files', { count: entry.fileCount, size: formatBytes(entry.totalBytes) })}</div>
|
|
156
|
-
</div>
|
|
157
|
-
{entry.existing
|
|
158
|
-
? <span className={css.badge + ' ' + css.badgeReadonly}>{tt('repo.existing')}</span>
|
|
159
|
-
: <span className={css.badge + ' ' + css.badgeSource}>{entry.origin}</span>}
|
|
160
|
-
</label>
|
|
232
|
+
<div style={{ display:'flex', gap:8, marginBottom:8, flexWrap:'wrap' }}>
|
|
233
|
+
<input
|
|
234
|
+
className={css.input}
|
|
235
|
+
style={{ flex:1, minWidth:140 }}
|
|
236
|
+
value={repoSearch}
|
|
237
|
+
onChange={(e) => setRepoSearch(e.target.value)}
|
|
238
|
+
placeholder="搜索技能名…"
|
|
239
|
+
/>
|
|
240
|
+
<button type='button' className={css.button + (repoFilter === 'all' ? ' ' + css.primary : '')} style={{ padding:'6px 10px', fontSize:12 }} onClick={() => setRepoFilter('all')}>全部 {entries.length}</button>
|
|
241
|
+
{roots.map((root) => (
|
|
242
|
+
<button key={root} type='button' className={css.button + (repoFilter === root ? ' ' + css.primary : '')} style={{ padding:'6px 10px', fontSize:12 }} onClick={() => setRepoFilter(root)}>{root} {counts.get(root) ?? 0}</button>
|
|
161
243
|
))}
|
|
162
244
|
</div>
|
|
163
245
|
)
|
|
164
|
-
})}
|
|
165
|
-
<
|
|
246
|
+
})()}
|
|
247
|
+
<div className={css.hintLine} style={{ display:'flex', justifyContent:'space-between', flexWrap:'wrap', gap:8 }}>
|
|
248
|
+
<span>已选 {selected.length}/{entries.length} · {formatBytes(selectedBytes)} · 显示 {paged.length}/{filtered.length}(过滤后)</span>
|
|
249
|
+
<span style={{ display:'flex', gap:6 }}>
|
|
250
|
+
<button type='button' className={css.button} style={{ padding:'4px 8px', fontSize:11 }} onClick={() => { setRepoSelected(new Set(filtered.filter((e)=>!isExisting(e)).slice(0, visibleCount).map((e)=>e.path))) }}>全选当前显示</button>
|
|
251
|
+
<button type='button' className={css.button} style={{ padding:'4px 8px', fontSize:11 }} onClick={() => { setRepoSelected(new Set(entries.filter((e)=>!isExisting(e)).map((e)=>e.path))) }}>全选全部 {entries.filter(e=>!isExisting(e)).length}</button>
|
|
252
|
+
<button type='button' className={css.button} style={{ padding:'4px 8px', fontSize:11 }} onClick={() => { setRepoSelected(new Set()) }}>{tt('repo.clearAll')}</button>
|
|
253
|
+
</span>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{/* 扁平虚拟滚动容器 — 主题自适应,CSS Modules */}
|
|
257
|
+
<div className={css.scanList}>
|
|
258
|
+
{paged.length === 0 ? <div className={css.empty} style={{ padding:20 }}>无匹配技能</div> : paged.map((entry) => {
|
|
259
|
+
const existing = isExisting(entry)
|
|
260
|
+
return (
|
|
261
|
+
<label key={entry.path} className={css.row + (existing ? ' ' + css.rowMuted : '')}>
|
|
262
|
+
<input
|
|
263
|
+
type='checkbox'
|
|
264
|
+
checked={repoSelected.has(entry.path)}
|
|
265
|
+
disabled={existing}
|
|
266
|
+
onChange={(event) => { toggleRepoSelected(entry.path, event.target.checked) }}
|
|
267
|
+
/>
|
|
268
|
+
<div className={css.rowMain}>
|
|
269
|
+
<div className={css.rowName}>{entry.name}</div>
|
|
270
|
+
<div className={css.rowDesc}>{entry.dir} · {tt('repo.files', { count: entry.fileCount, size: formatBytes(entry.totalBytes) })}</div>
|
|
271
|
+
</div>
|
|
272
|
+
{existing
|
|
273
|
+
? <span className={css.badge + ' ' + css.badgeReadonly}>{tt('repo.existing')}</span>
|
|
274
|
+
: <span className={css.badge + ' ' + css.badgeSource}>{entry.origin}</span>}
|
|
275
|
+
</label>
|
|
276
|
+
)})}
|
|
277
|
+
</div>
|
|
278
|
+
{visibleCount < filtered.length ? (
|
|
279
|
+
<div style={{ textAlign:'center', marginTop:8 }}>
|
|
280
|
+
<button type='button' className={css.button} onClick={() => setVisibleCount((n) => n + 50)}>加载更多 50 (剩余 {filtered.length - visibleCount})</button>
|
|
281
|
+
</div>
|
|
282
|
+
) : null}
|
|
283
|
+
|
|
284
|
+
<div className={css.buttons + ' ' + css.actionsTop}>
|
|
285
|
+
<button type='button' className={css.button + ' ' + css.primary} disabled={selected.length === 0 || repoImporting} onClick={() => { void importRepo() }}>{repoImporting ? tt('repo.importing') : `${tt('repo.import', { count: selected.length })} · ${formatBytes(selectedBytes)}`}</button>
|
|
286
|
+
{repoImporting ? <button type='button' className={css.button} onClick={() => { void cancelImport() }}>{tt('repo.cancel')}</button> : null}
|
|
287
|
+
</div>
|
|
288
|
+
{repoResult !== null && repoResult.status === 'running' ? (() => {
|
|
289
|
+
const pct = repoResult.totalBytes > 0 ? Math.min(100, Math.round(repoResult.downloadedBytes / repoResult.totalBytes * 100)) : (repoResult.total > 0 ? Math.round(repoResult.done / repoResult.total * 100) : 0)
|
|
290
|
+
const speed = formatSpeed(repoResult.bytesPerSecond)
|
|
291
|
+
return (
|
|
292
|
+
<div className={css.formSuccess + ' ' + css.actionsTop}>
|
|
293
|
+
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:6 }}>
|
|
294
|
+
<div className={css.scanProgressTrack}>
|
|
295
|
+
<div className={css.scanProgressFill} style={{ width: `${pct}%` }} />
|
|
296
|
+
</div>
|
|
297
|
+
<span style={{ fontSize:11, opacity:.75, minWidth:32, textAlign:'right' }}>{pct}%</span>
|
|
298
|
+
</div>
|
|
299
|
+
<div className={css.hintLine}>
|
|
300
|
+
{formatBytes(repoResult.downloadedBytes)} / {formatBytes(repoResult.totalBytes)}
|
|
301
|
+
{speed !== '' ? ` · ${speed}` : ''} · {repoResult.done}/{repoResult.total} 个技能
|
|
302
|
+
</div>
|
|
303
|
+
<div className={css.hintLine} style={{marginTop:4}}>
|
|
304
|
+
{repoResult.current !== undefined ? tt('repo.importingCurrent', { name: repoResult.current, done: repoResult.done + 1, total: repoResult.total }) : tt('repo.importing')}
|
|
305
|
+
{repoResult.currentFile !== undefined ? ` · ${repoResult.currentFile}` : ''}
|
|
306
|
+
</div>
|
|
307
|
+
{repoResult.failed.length > 0 ? <div className={css.errorBanner} style={{marginTop:6}}>{repoResult.failed.map(f => `${f.name}: ${f.error}`).join('; ')}</div> : null}
|
|
308
|
+
</div>
|
|
309
|
+
)
|
|
310
|
+
})() : null}
|
|
166
311
|
</>
|
|
167
312
|
)}
|
|
168
313
|
</>
|
|
169
314
|
)
|
|
170
315
|
})() : null}
|
|
171
|
-
{repoResult !== null
|
|
316
|
+
{repoResult !== null && repoResult.status !== 'running' ? (
|
|
317
|
+
<div className={repoResult.status === 'cancelled' ? css.errorBanner + ' ' + css.actionsTop : css.formSuccess + ' ' + css.actionsTop}>
|
|
318
|
+
{repoResult.status === 'cancelled' ? tt('repo.cancelled', { imported: repoResult.imported.length, total: repoResult.total }) : `${tt('repo.imported', { count: repoResult.imported.length })} · ${tt('repo.skippedExisting', { count: repoResult.skipped.length })} · ${tt('repo.failed', { count: repoResult.failed.length })}`}
|
|
319
|
+
{repoResult.failed.length > 0 ? <div style={{marginTop:6}}>{repoResult.failed.map(f => `${f.name}: ${f.error}`).join('; ')}</div> : null}
|
|
320
|
+
{repoResult.status === 'error' && repoResult.error ? <div style={{marginTop:6}}>{repoResult.error}</div> : null}
|
|
321
|
+
</div>
|
|
322
|
+
) : null}
|
|
323
|
+
</div>
|
|
324
|
+
</section>
|
|
325
|
+
) : null}
|
|
172
326
|
|
|
173
327
|
{updateAllDialog ? (
|
|
174
328
|
<ConfirmDialog
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* are managed in the sources tab.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { useState, type JSX } from 'react'
|
|
9
9
|
import { tt } from '../helpers.ts'
|
|
10
10
|
import { groupSwitchView } from '../grouping.ts'
|
|
11
11
|
import { SkillRow } from './SkillRow.tsx'
|
|
@@ -17,6 +17,19 @@ import css from './panel.module.css'
|
|
|
17
17
|
export function ScenesView(props: { hub: SkillHubState }): JSX.Element {
|
|
18
18
|
const { hub } = props
|
|
19
19
|
const { catalog, groupsState, sorted, normalized, collapsedGroups, viewNames, actionNames, batchBusy, busyNames, newTagName, setNewTagName, tagBusy, createTag, toggleGroupCollapse, toggleGroup, setEditingTag, setEditName, setMembersDraft, setEditSearch, enableDisabled } = hub
|
|
20
|
+
const [dragId, setDragId] = useState<string | null>(null)
|
|
21
|
+
const [overId, setOverId] = useState<string | null>(null)
|
|
22
|
+
const handleDrop = (targetId: string): void => {
|
|
23
|
+
if (dragId === null || dragId === targetId || groupsState === null) return
|
|
24
|
+
const ids = groupsState.tags.map((t) => t.id)
|
|
25
|
+
const from = ids.indexOf(dragId)
|
|
26
|
+
const to = ids.indexOf(targetId)
|
|
27
|
+
if (from === -1 || to === -1) return
|
|
28
|
+
const next = [...ids]
|
|
29
|
+
const [moved] = next.splice(from, 1)
|
|
30
|
+
next.splice(to, 0, moved)
|
|
31
|
+
void hub.reorderTags(next)
|
|
32
|
+
}
|
|
20
33
|
return (
|
|
21
34
|
<>
|
|
22
35
|
<form className={css.form} onSubmit={(event) => { void createTag(event) }}>
|
|
@@ -38,9 +51,21 @@ export function ScenesView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
38
51
|
const collapsed = collapsedGroups.has('tag:' + tag.id)
|
|
39
52
|
const view = groupSwitchView(tag.skillNames, viewNames)
|
|
40
53
|
const hasWritable = tag.skillNames.some((name) => actionNames.has(name))
|
|
54
|
+
const isDragging = dragId === tag.id
|
|
55
|
+
const isOver = overId === tag.id && dragId !== tag.id
|
|
41
56
|
return (
|
|
42
|
-
<section
|
|
57
|
+
<section
|
|
58
|
+
key={'tag:' + tag.id}
|
|
59
|
+
className={css.section + (isDragging ? ' ' + css.dragging : '') + (isOver ? ' ' + css.dragOver : '')}
|
|
60
|
+
draggable
|
|
61
|
+
onDragStart={(e) => { setDragId(tag.id); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', tag.id) }}
|
|
62
|
+
onDragOver={(e) => { e.preventDefault(); if (overId !== tag.id) setOverId(tag.id) }}
|
|
63
|
+
onDragLeave={() => { if (overId === tag.id) setOverId(null) }}
|
|
64
|
+
onDrop={(e) => { e.preventDefault(); handleDrop(tag.id); setOverId(null) }}
|
|
65
|
+
onDragEnd={() => { setDragId(null); setOverId(null) }}
|
|
66
|
+
>
|
|
43
67
|
<div className={css.groupHead}>
|
|
68
|
+
<span className={css.dragHandle} aria-hidden title="拖拽调整顺序">⋮⋮</span>
|
|
44
69
|
<button type='button' className={css.disclosure} aria-expanded={!collapsed} onClick={() => { toggleGroupCollapse('tag:' + tag.id) }}>
|
|
45
70
|
<span className={css.chevron + (collapsed ? ' ' + css.chevronCollapsed : '')} />
|
|
46
71
|
<span className={css.groupTitle}>
|
|
@@ -24,7 +24,6 @@ import { TagEditorView } from './TagEditorView.tsx'
|
|
|
24
24
|
import { SourcesView } from './SourcesView.tsx'
|
|
25
25
|
import { ScenesView } from './ScenesView.tsx'
|
|
26
26
|
import { MarketView } from './MarketView.tsx'
|
|
27
|
-
import { DisabledRow } from './DisabledRow.tsx'
|
|
28
27
|
import { BranchChoiceDialog, ConfirmDialog, ConflictDialog, MarketSyncDialog } from './dialogs.tsx'
|
|
29
28
|
import { useSkillHub } from './useSkillHub.ts'
|
|
30
29
|
import css from './panel.module.css'
|
|
@@ -42,14 +41,14 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
|
|
|
42
41
|
formRoot, formBusy, formMessage, hubConfig, tab, skillView, sourceFilter, sortKey, search,
|
|
43
42
|
workspace, setWorkspace,
|
|
44
43
|
sourcesState, tagBusy, busyNames, normalized, origins, sourceOptions, filtered,
|
|
45
|
-
conflictDialog, confirmDialog, deleteSkillDialog, confirmClearTrash, branchChoice, branchBusy, marketSyncDialog,
|
|
44
|
+
conflictDialog, confirmDialog, deleteSkillDialog, deleteGroupDialog, confirmClearTrash, branchChoice, branchBusy, marketSyncDialog,
|
|
46
45
|
syncBusy, editingTag, editName, membersDraft, editSearch, uses, groupsState, sourceCheck, checkingSource, syncingSource,
|
|
47
|
-
showLegend,
|
|
46
|
+
showLegend, editMode,
|
|
48
47
|
setLoadError, setSuccessBanner, setDetail, setShowForm, setFormName, setFormDesc, setFormRoot, setFormMessage, setTab,
|
|
49
|
-
setSkillView, setSourceFilter, setSortKey, setSearch, setConflictDialog, setConfirmDialog, setDeleteSkillDialog,
|
|
48
|
+
setSkillView, setSourceFilter, setSortKey, setSearch, setConflictDialog, setConfirmDialog, setDeleteSkillDialog, setDeleteGroupDialog,
|
|
50
49
|
setConfirmClearTrash, setBranchChoice, setMarketSyncDialog, setEditingTag, setEditName, setMembersDraft, setEditSearch,
|
|
51
|
-
setShowLegend,
|
|
52
|
-
checkUpdate, loadMarket, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, runDeleteSkill,
|
|
50
|
+
setShowLegend, setEditMode,
|
|
51
|
+
checkUpdate, loadMarket, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, runDeleteSkill, runDeleteGroup,
|
|
53
52
|
runConfirmed, resolveConflict, confirmBranchChoice, confirmMarketSync, create, saveTag, deleteTag, enableDisabled,
|
|
54
53
|
} = hub
|
|
55
54
|
|
|
@@ -236,6 +235,7 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
|
|
|
236
235
|
<option value='uses'>{tt('sort.uses')}</option>
|
|
237
236
|
</select>
|
|
238
237
|
<input className={css.search} value={search} onChange={(event) => { setSearch(event.target.value) }} placeholder={tt('panel.search')} />
|
|
238
|
+
<button type='button' className={css.button + (editMode ? ' ' + css.primary : '')} style={{ marginLeft:'auto' }} onClick={() => setEditMode((v) => !v)}>{editMode ? '完成' : '编辑'}</button>
|
|
239
239
|
</div>
|
|
240
240
|
|
|
241
241
|
{filtered.length === 0 && search.trim() !== '' ? <div className={css.empty}>{tt('panel.empty')}</div> : null}
|
|
@@ -263,19 +263,6 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
|
|
|
263
263
|
</section>
|
|
264
264
|
) : null}
|
|
265
265
|
|
|
266
|
-
{catalog.disabled.length > 0 ? (
|
|
267
|
-
<section className={css.section}>
|
|
268
|
-
<div className={css.sectionTitle}>{tt('panel.disabled')}</div>
|
|
269
|
-
{catalog.disabled
|
|
270
|
-
.filter((record) =>
|
|
271
|
-
(normalized.length === 0 || record.name.toLocaleLowerCase().includes(normalized) || record.description.toLocaleLowerCase().includes(normalized))
|
|
272
|
-
&& (sourceFilter === 'all' || (origins[record.name] ?? PRIVATE_SOURCE) === sourceFilter))
|
|
273
|
-
.map((record) => (
|
|
274
|
-
<DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} onEnable={() => { void enableDisabled(record) }} />
|
|
275
|
-
))}
|
|
276
|
-
</section>
|
|
277
|
-
) : null}
|
|
278
|
-
|
|
279
266
|
{catalog.diagnostics.length > 0 ? (
|
|
280
267
|
<section className={css.section}>
|
|
281
268
|
<div className={css.sectionTitle}>{tt('panel.diagnostics')}</div>
|
|
@@ -349,6 +336,18 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
|
|
|
349
336
|
/>
|
|
350
337
|
) : null}
|
|
351
338
|
|
|
339
|
+
{deleteGroupDialog !== null ? (
|
|
340
|
+
<ConfirmDialog
|
|
341
|
+
title={tt('source.deleteGroupTitle')}
|
|
342
|
+
text={tt('source.deleteGroupText', { name: deleteGroupDialog.name, count: deleteGroupDialog.skillNames.length })}
|
|
343
|
+
items={deleteGroupDialog.skillNames}
|
|
344
|
+
confirmLabel={tt('source.deleteGroup')}
|
|
345
|
+
danger
|
|
346
|
+
onCancel={() => { setDeleteGroupDialog(null) }}
|
|
347
|
+
onConfirm={() => { void runDeleteGroup() }}
|
|
348
|
+
/>
|
|
349
|
+
) : null}
|
|
350
|
+
|
|
352
351
|
{confirmClearTrash ? (
|
|
353
352
|
<ConfirmDialog
|
|
354
353
|
title={tt('source.clearTrashConfirmTitle')}
|
|
@@ -50,14 +50,6 @@ export function SkillRow(props: { skill: CatalogSkill; hub: SkillHubState }): JS
|
|
|
50
50
|
</div>
|
|
51
51
|
{skill.writable
|
|
52
52
|
? <>
|
|
53
|
-
<button
|
|
54
|
-
type='button'
|
|
55
|
-
className={css.opBtn + ' ' + css.opDanger + ' ' + css.iconBtn}
|
|
56
|
-
disabled={hub.busyNames.has(skill.name) || hub.tagBusy}
|
|
57
|
-
title={tt('row.delete')}
|
|
58
|
-
aria-label={tt('row.delete')}
|
|
59
|
-
onClick={(event) => { event.stopPropagation(); hub.requestDeleteSkill(skill.name) }}
|
|
60
|
-
><IconTrashOutline16 size={14} /></button>
|
|
61
53
|
<button
|
|
62
54
|
type='button'
|
|
63
55
|
role='switch'
|
|
@@ -67,6 +59,14 @@ export function SkillRow(props: { skill: CatalogSkill; hub: SkillHubState }): JS
|
|
|
67
59
|
disabled={hub.busyNames.has(skill.name)}
|
|
68
60
|
onClick={(event) => { event.stopPropagation(); void hub.toggle(skill, false) }}
|
|
69
61
|
><span className={css.switchThumb} /></button>
|
|
62
|
+
{hub.editMode ? <button
|
|
63
|
+
type='button'
|
|
64
|
+
className={css.opBtn + ' ' + css.opDanger + ' ' + css.iconBtn}
|
|
65
|
+
disabled={hub.busyNames.has(skill.name) || hub.tagBusy}
|
|
66
|
+
title={tt('row.delete')}
|
|
67
|
+
aria-label={tt('row.delete')}
|
|
68
|
+
onClick={(event) => { event.stopPropagation(); hub.requestDeleteSkill(skill.name) }}
|
|
69
|
+
><IconTrashOutline16 size={14} /></button> : null}
|
|
70
70
|
</>
|
|
71
71
|
: <span className={css.badge + ' ' + css.badgeReadonly}>{tt('row.readonly')}</span>}
|
|
72
72
|
</div>
|