dsh-audiogen 0.3.4 → 0.4.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.
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Library view: curated audio assets, organized by type (voice / music / sfx /
3
+ * tts) with categories (voice: male/female/custom; tts: the speaking voice).
4
+ * Search + chips + card grid + detail drawer (full provenance) + batch mode.
5
+ */
6
+
7
+ import { useEffect, useMemo, useState } from 'react'
8
+ import type { AudiogenApi } from './api.ts'
9
+ import type { AudioMode, LibraryEntry, LibraryType } from '../protocol.ts'
10
+ import { AudioPlayer } from './audio-player.tsx'
11
+ import { LIBRARY_TYPE_LABELS } from './library-save-dialog.tsx'
12
+ import { CheckIcon, CopyIcon, ListIcon, MicIcon, MusicNoteIcon, SearchIcon, TrashIcon, WaveIcon } from './icons.tsx'
13
+ import css from './library.module.css'
14
+
15
+ const TYPE_ORDER: LibraryType[] = ['voice', 'music', 'sfx', 'tts']
16
+ const VOICE_CATEGORIES = ['male', 'female', 'custom']
17
+
18
+ function timeAgo(ts: number): string {
19
+ const diff = Date.now() - ts
20
+ const minute = 60_000
21
+ const hour = 60 * minute
22
+ const day = 24 * hour
23
+ if (diff < minute) return '刚刚'
24
+ if (diff < hour) return `${Math.floor(diff / minute)} 分钟前`
25
+ if (diff < day) return `${Math.floor(diff / hour)} 小时前`
26
+ if (diff < 7 * day) return `${Math.floor(diff / day)} 天前`
27
+ const date = new Date(ts)
28
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
29
+ }
30
+
31
+ function typeIcon(type: LibraryType): React.JSX.Element {
32
+ if (type === 'voice') return <MicIcon />
33
+ if (type === 'music') return <MusicNoteIcon />
34
+ if (type === 'sfx') return <WaveIcon />
35
+ return <ListIcon />
36
+ }
37
+
38
+ export interface LibraryReusePayload {
39
+ mode: AudioMode
40
+ voice?: string
41
+ voiceId?: string
42
+ model?: string
43
+ }
44
+
45
+ interface DraftState {
46
+ name: string
47
+ tags: string
48
+ note: string
49
+ type: LibraryType
50
+ category: string
51
+ }
52
+
53
+ export function LibraryView(props: {
54
+ api: AudiogenApi
55
+ revision: number
56
+ showToast: (text: string) => void
57
+ onReuseVoice: (payload: LibraryReusePayload) => void
58
+ }): React.JSX.Element {
59
+ const { api, revision } = props
60
+ const [entries, setEntries] = useState<LibraryEntry[]>([])
61
+ const [loading, setLoading] = useState(true)
62
+ const [error, setError] = useState<string | null>(null)
63
+ const [keyword, setKeyword] = useState('')
64
+ const [typeFilter, setTypeFilter] = useState<'all' | LibraryType>('all')
65
+ const [categoryFilter, setCategoryFilter] = useState<string>('all')
66
+ const [tagFilter, setTagFilter] = useState<string | null>(null)
67
+ const [detailId, setDetailId] = useState<string | null>(null)
68
+ const [batchMode, setBatchMode] = useState(false)
69
+ const [selected, setSelected] = useState<Set<string>>(new Set())
70
+ const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
71
+ const [busy, setBusy] = useState(false)
72
+ const [copied, setCopied] = useState<string | null>(null)
73
+
74
+ const load = (): void => {
75
+ setLoading(true)
76
+ setError(null)
77
+ api.libraryList()
78
+ .then(setEntries)
79
+ .catch(err => setError(err instanceof Error ? err.message : String(err)))
80
+ .finally(() => setLoading(false))
81
+ }
82
+ useEffect(() => { load() }, [revision])
83
+ useEffect(() => { setCategoryFilter('all') }, [typeFilter])
84
+
85
+ const categoriesOf = useMemo(() => {
86
+ const map = new Map<LibraryType, string[]>()
87
+ for (const entry of entries) {
88
+ if (entry.category === undefined || entry.category === '') continue
89
+ const list = map.get(entry.type) ?? []
90
+ if (!list.includes(entry.category)) list.push(entry.category)
91
+ map.set(entry.type, list)
92
+ }
93
+ return map
94
+ }, [entries])
95
+
96
+ const tags = useMemo(() => {
97
+ const counts = new Map<string, number>()
98
+ for (const entry of entries) {
99
+ for (const tag of entry.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1)
100
+ }
101
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 14)
102
+ }, [entries])
103
+
104
+ const filtered = useMemo(() => {
105
+ const needle = keyword.trim().toLowerCase()
106
+ return entries.filter(entry => {
107
+ if (typeFilter !== 'all' && entry.type !== typeFilter) return false
108
+ if (categoryFilter !== 'all' && (entry.category ?? '') !== categoryFilter) return false
109
+ if (tagFilter !== null && !entry.tags.includes(tagFilter)) return false
110
+ if (needle !== '') {
111
+ const haystack = [entry.name, ...entry.tags, entry.provenance.prompt, entry.provenance.model ?? '', entry.provenance.channel ?? ''].join(' ').toLowerCase()
112
+ if (!haystack.includes(needle)) return false
113
+ }
114
+ return true
115
+ })
116
+ }, [entries, keyword, typeFilter, categoryFilter, tagFilter])
117
+
118
+ const typeCounts = useMemo(() => {
119
+ const counts: Record<'all' | LibraryType, number> = { all: entries.length, voice: 0, music: 0, sfx: 0, tts: 0 }
120
+ for (const entry of entries) counts[entry.type] += 1
121
+ return counts
122
+ }, [entries])
123
+
124
+ const detail = entries.find(entry => entry.id === detailId) ?? null
125
+
126
+ const currentCategoryOptions = useMemo(() => {
127
+ if (typeFilter === 'all') return []
128
+ return [...new Set([...(categoriesOf.get(typeFilter) ?? []), ...(typeFilter === 'voice' ? VOICE_CATEGORIES : [])])]
129
+ }, [typeFilter, categoriesOf])
130
+
131
+ const toggleSelect = (id: string): void => {
132
+ setSelected(current => {
133
+ const next = new Set(current)
134
+ if (!next.delete(id)) next.add(id)
135
+ return next
136
+ })
137
+ }
138
+
139
+ const doDelete = async (ids: string[]): Promise<void> => {
140
+ setBusy(true)
141
+ try {
142
+ await api.libraryRemove(ids)
143
+ setEntries(current => current.filter(entry => !ids.includes(entry.id)))
144
+ setSelected(current => {
145
+ const next = new Set(current)
146
+ for (const id of ids) next.delete(id)
147
+ return next
148
+ })
149
+ if (detailId !== null && ids.includes(detailId)) setDetailId(null)
150
+ props.showToast(`已删除 ${ids.length} 个资源`)
151
+ } catch (err) {
152
+ props.showToast(err instanceof Error ? err.message : '删除失败')
153
+ } finally {
154
+ setBusy(false)
155
+ setConfirmDelete(null)
156
+ }
157
+ }
158
+
159
+ const copyText = async (text: string, key: string): Promise<void> => {
160
+ try {
161
+ await navigator.clipboard.writeText(text)
162
+ setCopied(key)
163
+ window.setTimeout(() => setCopied(null), 1400)
164
+ } catch {
165
+ props.showToast('复制失败')
166
+ }
167
+ }
168
+
169
+ // ---- detail drawer state -------------------------------------------------
170
+ const [draft, setDraft] = useState<DraftState | null>(null)
171
+ useEffect(() => {
172
+ if (detail !== null) {
173
+ setDraft({
174
+ name: detail.name,
175
+ tags: detail.tags.join(', '),
176
+ note: detail.note ?? '',
177
+ type: detail.type,
178
+ category: detail.category ?? '',
179
+ })
180
+ }
181
+ }, [detailId])
182
+
183
+ const saveDraft = async (): Promise<void> => {
184
+ if (detail === null || draft === null) return
185
+ setBusy(true)
186
+ try {
187
+ const result = await api.libraryUpdate({
188
+ id: detail.id,
189
+ name: draft.name,
190
+ tags: draft.tags.split(/[,,、\n]/).map(tag => tag.trim()).filter(tag => tag !== ''),
191
+ note: draft.note,
192
+ type: draft.type,
193
+ ...((draft.type === 'voice' || draft.type === 'tts') && draft.category.trim() !== '' ? { category: draft.category.trim() } : {}),
194
+ })
195
+ if (!result.ok || result.entry === undefined) {
196
+ props.showToast(result.message ?? '保存失败')
197
+ return
198
+ }
199
+ setEntries(current => current.map(entry => entry.id === result.entry!.id ? result.entry! : entry))
200
+ props.showToast('资源已更新')
201
+ } catch (err) {
202
+ props.showToast(err instanceof Error ? err.message : '保存失败')
203
+ } finally {
204
+ setBusy(false)
205
+ }
206
+ }
207
+
208
+ const batchMoveCategory = async (category: string, type: LibraryType): Promise<void> => {
209
+ if (selected.size === 0) return
210
+ setBusy(true)
211
+ try {
212
+ for (const id of selected) {
213
+ await api.libraryUpdate({ id, type, ...(category !== '' ? { category } : {}) })
214
+ }
215
+ await load()
216
+ setSelected(new Set())
217
+ props.showToast(`已移动 ${selected.size} 个资源`)
218
+ } catch (err) {
219
+ props.showToast(err instanceof Error ? err.message : '移动失败')
220
+ } finally {
221
+ setBusy(false)
222
+ }
223
+ }
224
+
225
+ const [batchTypeSelect, setBatchTypeSelect] = useState<LibraryType>('voice')
226
+ const [batchCategorySelect, setBatchCategorySelect] = useState('')
227
+
228
+ return (
229
+ <div className={css.library}>
230
+ <div className={css.toolbar}>
231
+ <label className={css.searchBox}>
232
+ <SearchIcon />
233
+ <input
234
+ className={css.searchInput}
235
+ value={keyword}
236
+ onChange={event => setKeyword(event.target.value)}
237
+ placeholder="搜索名称、标签、提示词、模型…"
238
+ />
239
+ </label>
240
+ <div className={css.typeChips}>
241
+ <button type="button" className={css.chip} data-active={typeFilter === 'all' ? 'true' : 'false'} onClick={() => setTypeFilter('all')}>
242
+ 全部 <span className={css.chipCount}>{typeCounts.all}</span>
243
+ </button>
244
+ {TYPE_ORDER.map(type => (
245
+ <button key={type} type="button" className={css.chip} data-active={typeFilter === type ? 'true' : 'false'} onClick={() => setTypeFilter(type)}>
246
+ {typeIcon(type)} {LIBRARY_TYPE_LABELS[type]} <span className={css.chipCount}>{typeCounts[type]}</span>
247
+ </button>
248
+ ))}
249
+ </div>
250
+ </div>
251
+
252
+ {typeFilter !== 'all' && currentCategoryOptions.length > 0 ? (
253
+ <div className={css.filterRow}>
254
+ <span className={css.filterLabel}>分类</span>
255
+ <button type="button" className={css.smallChip} data-active={categoryFilter === 'all' ? 'true' : 'false'} onClick={() => setCategoryFilter('all')}>全部</button>
256
+ {currentCategoryOptions.map(category => (
257
+ <button key={category} type="button" className={css.smallChip} data-active={categoryFilter === category ? 'true' : 'false'} onClick={() => setCategoryFilter(category)}>
258
+ {typeFilter === 'voice' ? ({ male: '男声', female: '女声', custom: '未分级' }[category] ?? category) : category}
259
+ </button>
260
+ ))}
261
+ </div>
262
+ ) : null}
263
+
264
+ {tags.length > 0 ? (
265
+ <div className={css.filterRow}>
266
+ <span className={css.filterLabel}>标签</span>
267
+ {tags.map(([tag, count]) => (
268
+ <button key={tag} type="button" className={css.smallChip} data-active={tagFilter === tag ? 'true' : 'false'} onClick={() => setTagFilter(tagFilter === tag ? null : tag)}>
269
+ {tag} <span className={css.chipCount}>{count}</span>
270
+ </button>
271
+ ))}
272
+ </div>
273
+ ) : null}
274
+
275
+ <div className={css.listHead}>
276
+ <span className={css.listCount}>{filtered.length} 个资源</span>
277
+ <div className={css.listActions}>
278
+ {batchMode ? (
279
+ <>
280
+ <span className={css.selCount}>已选 {selected.size}</span>
281
+ <select
282
+ className={css.smallSelect}
283
+ value={batchTypeSelect}
284
+ onChange={event => {
285
+ setBatchTypeSelect(event.target.value as LibraryType)
286
+ setBatchCategorySelect('')
287
+ }}
288
+ >
289
+ {TYPE_ORDER.map(type => <option key={type} value={type}>{LIBRARY_TYPE_LABELS[type]}</option>)}
290
+ </select>
291
+ <input className={css.smallSelect} list="library-category-options" placeholder="分类(可选)" value={batchCategorySelect} onChange={event => setBatchCategorySelect(event.target.value)} />
292
+ <datalist id="library-category-options">
293
+ {(categoriesOf.get(batchTypeSelect) ?? []).map(category => <option key={category} value={category} />)}
294
+ </datalist>
295
+ <button type="button" className={css.ghostBtn} disabled={busy || selected.size === 0} onClick={() => void batchMoveCategory(batchCategorySelect, batchTypeSelect)}>
296
+ <CheckIcon /> 移动
297
+ </button>
298
+ <button
299
+ type="button"
300
+ className={css.dangerBtn}
301
+ disabled={busy || selected.size === 0}
302
+ onClick={() => {
303
+ if (confirmDelete === 'batch') void doDelete([...selected])
304
+ else setConfirmDelete('batch')
305
+ }}
306
+ >
307
+ <TrashIcon /> {confirmDelete === 'batch' ? '确认删除' : '批量删除'}
308
+ </button>
309
+ <button type="button" className={css.ghostBtn} onClick={() => { setBatchMode(false); setSelected(new Set()); setConfirmDelete(null) }}>
310
+ 退出
311
+ </button>
312
+ </>
313
+ ) : (
314
+ <button type="button" className={css.ghostBtn} onClick={() => setBatchMode(true)}>
315
+ 多选管理
316
+ </button>
317
+ )}
318
+ </div>
319
+ </div>
320
+
321
+ {loading ? <p className={css.stateNote}>加载中…</p> : null}
322
+ {error !== null ? <p className={css.stateNote} data-error>{error}</p> : null}
323
+ {!loading && error === null && filtered.length === 0 ? (
324
+ <div className={css.empty}>
325
+ <span className={css.emptyIcon}>🎧</span>
326
+ <p>资源库还是空的</p>
327
+ <p className={css.emptyHint}>在生成页点击「加入资源库」,把满意的音频沉淀为可管理的资源</p>
328
+ </div>
329
+ ) : null}
330
+
331
+ <div className={css.grid}>
332
+ {filtered.map(entry => {
333
+ const fileCount = entry.files.length
334
+ const first = entry.files[0]
335
+ return (
336
+ <article
337
+ key={entry.id}
338
+ className={css.card}
339
+ data-selected={selected.has(entry.id) ? 'true' : 'false'}
340
+ onClick={() => {
341
+ if (batchMode) toggleSelect(entry.id)
342
+ else setDetailId(entry.id)
343
+ }}
344
+ role="button"
345
+ tabIndex={0}
346
+ onKeyDown={event => {
347
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); if (batchMode) toggleSelect(entry.id); else setDetailId(entry.id) }
348
+ }}
349
+ >
350
+ {batchMode ? (
351
+ <span className={css.cardCheck} onClick={event => event.stopPropagation()}>
352
+ <input type="checkbox" checked={selected.has(entry.id)} onChange={() => toggleSelect(entry.id)} />
353
+ </span>
354
+ ) : null}
355
+ <div className={css.cardHead}>
356
+ <span className={css.typeBadge} data-type={entry.type}>{typeIcon(entry.type)}{LIBRARY_TYPE_LABELS[entry.type]}</span>
357
+ <span className={css.cardTime}>{timeAgo(entry.createdAt)}</span>
358
+ </div>
359
+ <strong className={css.cardName} title={entry.name}>{entry.name}</strong>
360
+ {first !== undefined ? (
361
+ <div onClick={event => event.stopPropagation()}>
362
+ <AudioPlayer src={first.url} compact itemKey={entry.id} />
363
+ </div>
364
+ ) : null}
365
+ <div className={css.cardFoot}>
366
+ {entry.category !== undefined && entry.category !== '' ? (
367
+ <span className={css.metaChip}>{entry.type === 'voice' ? ({ male: '男声', female: '女声', custom: '未分级' }[entry.category] ?? entry.category) : entry.category}</span>
368
+ ) : null}
369
+ {entry.provenance.model !== undefined ? <span className={css.metaChip} title={`模型:${entry.provenance.model}`}>{entry.provenance.model}</span> : null}
370
+ {fileCount > 1 ? <span className={css.metaChip}>×{fileCount}</span> : null}
371
+ {entry.tags.length > 0 ? <span className={css.metaChip} data-tag>{entry.tags[0]}{entry.tags.length > 1 ? ` +${entry.tags.length - 1}` : ''}</span> : null}
372
+ </div>
373
+ </article>
374
+ )
375
+ })}
376
+ </div>
377
+
378
+ {detail !== null ? (
379
+ <div className={css.drawerMask} onClick={() => setDetailId(null)}>
380
+ <aside className={css.drawer} onClick={event => event.stopPropagation()}>
381
+ <header className={css.drawerHead}>
382
+ <strong className={css.drawerTitle}>{detail.name}</strong>
383
+ <button type="button" className={css.iconBtn} aria-label="关闭" onClick={() => setDetailId(null)}>×</button>
384
+ </header>
385
+
386
+ <div className={css.drawerBody}>
387
+ <div className={css.drawerSection}>
388
+ <div className={css.drawerPlayerList}>
389
+ {detail.files.map(file => (
390
+ <div key={file.rel} className={css.drawerPlayer}>
391
+ <span className={css.drawerPlayerName}>{file.rel.split('/').pop()}</span>
392
+ <AudioPlayer src={file.url} itemKey={`${file.rel}-${detail.createdAt}`} />
393
+ </div>
394
+ ))}
395
+ </div>
396
+ </div>
397
+
398
+ {draft !== null ? (
399
+ <div className={css.drawerSection}>
400
+ <div className={css.drawerLabel}>编辑</div>
401
+ <label className={css.drawerField}>
402
+ <span>名称</span>
403
+ <input className={css.input} value={draft.name} onChange={event => setDraft({ ...draft, name: event.target.value })} />
404
+ </label>
405
+ <label className={css.drawerField}>
406
+ <span>标签(逗号分隔)</span>
407
+ <input className={css.input} value={draft.tags} onChange={event => setDraft({ ...draft, tags: event.target.value })} />
408
+ </label>
409
+ <label className={css.drawerField}>
410
+ <span>备注</span>
411
+ <textarea className={css.textarea} rows={2} value={draft.note} onChange={event => setDraft({ ...draft, note: event.target.value })} />
412
+ </label>
413
+ <div className={css.drawerSplit}>
414
+ <label className={css.drawerField}>
415
+ <span>类型</span>
416
+ <select className={css.input} value={draft.type} onChange={event => setDraft({ ...draft, type: event.target.value as LibraryType, category: '' })}>
417
+ {TYPE_ORDER.map(type => <option key={type} value={type}>{LIBRARY_TYPE_LABELS[type]}</option>)}
418
+ </select>
419
+ </label>
420
+ {(draft.type === 'voice' || draft.type === 'tts') ? (
421
+ <label className={css.drawerField}>
422
+ <span>{draft.type === 'voice' ? '分级(男/女)' : '音色键'}</span>
423
+ <input className={css.input} list="library-category-options" value={draft.category} onChange={event => setDraft({ ...draft, category: event.target.value })} placeholder={draft.type === 'voice' ? 'male / female / custom' : 'voice 键'} />
424
+ </label>
425
+ ) : null}
426
+ </div>
427
+ <button type="button" className={css.primaryBtn} disabled={busy} onClick={() => void saveDraft()}><CheckIcon /> 保存修改</button>
428
+ </div>
429
+ ) : null}
430
+
431
+ <div className={css.drawerSection}>
432
+ <div className={css.drawerLabel}>来源(可追溯)</div>
433
+ <div className={css.provenance}>
434
+ <div className={css.provRow}><span className={css.provKey}>类型</span><span className={css.provValue}>{LIBRARY_TYPE_LABELS[detail.type]} · {detail.provenance.mode}</span></div>
435
+ {detail.provenance.channel !== undefined ? <div className={css.provRow}><span className={css.provKey}>渠道</span><span className={css.provValue}>{detail.provenance.channel}</span></div> : null}
436
+ {detail.provenance.apiUrl !== undefined ? <div className={css.provRow}><span className={css.provKey}>API 地址</span><span className={`${css.provValue} ${css.mono}`}>{detail.provenance.apiUrl}</span></div> : null}
437
+ {detail.provenance.model !== undefined ? (
438
+ <div className={css.provRow}>
439
+ <span className={css.provKey}>模型</span>
440
+ <span className={css.provValue}>{detail.provenance.model}{detail.provenance.upstream !== undefined && detail.provenance.upstream !== detail.provenance.model ? ` → ${detail.provenance.upstream}` : ''}</span>
441
+ </div>
442
+ ) : null}
443
+ {detail.provenance.voice !== undefined ? <div className={css.provRow}><span className={css.provKey}>音色</span><span className={css.provValue}>{detail.provenance.voice}</span></div> : null}
444
+ {detail.provenance.voiceId !== undefined ? (
445
+ <div className={css.provRow}>
446
+ <span className={css.provKey}>Voice ID</span>
447
+ <span className={css.provValue}>
448
+ <code className={css.code}>{detail.provenance.voiceId}</code>
449
+ <button type="button" className={css.iconBtn} title="复制 Voice ID" onClick={() => void copyText(detail.provenance.voiceId!, `vid-${detail.id}`)}>
450
+ {copied === `vid-${detail.id}` ? <CheckIcon /> : <CopyIcon />}
451
+ </button>
452
+ </span>
453
+ </div>
454
+ ) : null}
455
+ <div className={css.provRow}><span className={css.provKey}>提示词</span><span className={`${css.provValue} ${css.promptText}`}>{detail.provenance.prompt || '—'}</span></div>
456
+ <div className={css.provRow}><span className={css.provKey}>创建时间</span><span className={css.provValue}>{new Date(detail.createdAt).toLocaleString('zh-CN', { hour12: false })}</span></div>
457
+ {detail.provenance.params !== undefined ? (
458
+ <div className={css.provRow}>
459
+ <span className={css.provKey}>生成参数</span>
460
+ <span className={css.provValue}>
461
+ <details className={css.paramsDetails}>
462
+ <summary>查看参数快照</summary>
463
+ <pre className={css.code}>{JSON.stringify(detail.provenance.params, null, 2)}</pre>
464
+ </details>
465
+ <button type="button" className={css.iconBtn} title="复制参数 JSON" onClick={() => void copyText(JSON.stringify(detail.provenance.params, null, 2), `params-${detail.id}`)}>
466
+ {copied === `params-${detail.id}` ? <CheckIcon /> : <CopyIcon />}
467
+ </button>
468
+ </span>
469
+ </div>
470
+ ) : null}
471
+ <div className={css.provRow}><span className={css.provKey}>文件</span><span className={`${css.provValue} ${css.mono}`}>{detail.files.length} 段 · {detail.files.map(file => file.rel).join(',')}</span></div>
472
+ </div>
473
+ </div>
474
+
475
+ <div className={css.drawerActions}>
476
+ {detail.provenance.voiceId !== undefined || detail.provenance.voice !== undefined ? (
477
+ <button
478
+ type="button"
479
+ className={css.primaryBtn}
480
+ onClick={() => {
481
+ props.onReuseVoice({
482
+ mode: 'tts',
483
+ ...(detail.provenance.voiceId !== undefined ? { voiceId: detail.provenance.voiceId } : {}),
484
+ ...(detail.provenance.voice !== undefined ? { voice: detail.provenance.voice } : {}),
485
+ ...(detail.provenance.model !== undefined ? { model: detail.provenance.model } : {}),
486
+ })
487
+ setDetailId(null)
488
+ }}
489
+ >
490
+ <MicIcon /> 用此音色去 TTS
491
+ </button>
492
+ ) : null}
493
+ <button
494
+ type="button"
495
+ className={css.dangerBtn}
496
+ onClick={() => {
497
+ if (confirmDelete === detail.id) void doDelete([detail.id])
498
+ else setConfirmDelete(detail.id)
499
+ }}
500
+ disabled={busy}
501
+ >
502
+ <TrashIcon /> {confirmDelete === detail.id ? '确认删除' : '删除资源'}
503
+ </button>
504
+ </div>
505
+ </div>
506
+ </aside>
507
+ </div>
508
+ ) : null}
509
+ </div>
510
+ )
511
+ }