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.
@@ -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
- {skillCheck?.changed === true && skillCheck.updated.length > 0
43
- ? <span className={css.badge + ' ' + css.statusUpdated}>{tt('market.updatable', { count: skillCheck.updated.length })}</span>
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
- {releaseCheck?.updateAvailable === true
49
- ? <span className={css.badge + ' ' + css.statusUpdated}>{releaseCheck.latestTag !== undefined ? tt('market.newRelease', { version: releaseCheck.latestTag }) : tt('market.updated')}</span>
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.opBtn} disabled={scanning} onClick={() => { void checkMarket() }}>{tt('market.check')}</button>
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 type='button' className={css.opBtn} disabled={tagBusy} title={tt('market.removeHint')} onClick={() => { void removeMarketSource(record.repo) }}>{tt('market.deleteSource')}</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
- {repoDiscoverState.status === 'scanning' ? <div className={css.empty}>{tt('market.scanning')}</div> : null}
126
- {repoDiscoverState.status === 'error' ? <div className={css.errorBanner}>{repoDiscoverState.message}</div> : null}
127
- {repoDiscoverState.status === 'ready' ? (() => {
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
- const selected = entries.filter((entry) => repoSelected.has(entry.path) && !entry.existing)
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
- <div className={css.buttons + ' ' + css.actionsBottom}>
136
- <button type='button' className={css.button} onClick={() => { setRepoSelected(new Set(entries.filter((entry) => !entry.existing).map((entry) => entry.path))) }}>{tt('repo.selectAll')}</button>
137
- <button type='button' className={css.button} onClick={() => { setRepoSelected(new Set()) }}>{tt('repo.clearAll')}</button>
138
- </div>
139
- {(['skills', 'design-templates'] as const).map((root) => {
140
- const rootEntries = entries.filter((entry) => entry.root === root)
141
- if (rootEntries.length === 0) return null
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 key={root} className={css.section}>
144
- <div className={css.sectionTitle}>{root === 'skills' ? tt('repo.root.skills') : tt('repo.root.designTemplates')}</div>
145
- {rootEntries.map((entry) => (
146
- <label key={entry.path} className={css.row + (entry.existing ? ' ' + css.rowMuted : '')}>
147
- <input
148
- type='checkbox'
149
- checked={repoSelected.has(entry.path)}
150
- disabled={entry.existing}
151
- onChange={(event) => { toggleRepoSelected(entry.path, event.target.checked) }}
152
- />
153
- <div className={css.rowMain}>
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
- <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 })}</button>
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 ? <div className={css.formSuccess + ' ' + css.actionsTop}>{tt('repo.imported', { count: repoResult.imported.length })} · {tt('repo.skippedExisting', { count: repoResult.skipped.length })} · {tt('repo.failed', { count: repoResult.failed.length })}</div> : 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 { JSX } from 'react'
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 key={'tag:' + tag.id} className={css.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>