koishi-plugin-media-luna 1.2.8 → 1.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.
@@ -1,1526 +1,1527 @@
1
- <template>
2
- <div class="presets-view">
3
- <!-- 紧凑工具栏 -->
4
- <div class="compact-toolbar pop-card no-hover">
5
- <!-- 左侧:视图切换 + 筛选 + 搜索 -->
6
- <div class="toolbar-left">
7
- <div class="btn-group">
8
- <button
9
- class="group-btn"
10
- :class="{ active: viewMode === 'list' }"
11
- @click="viewMode = 'list'"
12
- title="列表视图"
13
- >
14
- 📋
15
- </button>
16
- <button
17
- class="group-btn"
18
- :class="{ active: viewMode === 'card' }"
19
- @click="viewMode = 'card'"
20
- title="卡片视图"
21
- >
22
- 🎴
23
- </button>
24
- </div>
25
- <div class="filter-divider"></div>
26
- <!-- 搜索框 -->
27
- <div class="search-box">
28
- <span class="search-icon">🔍</span>
29
- <input
30
- v-model="searchQuery"
31
- class="pop-input small search-input"
32
- placeholder="搜索名称或Prompt..."
33
- />
34
- <button
35
- v-if="searchQuery"
36
- class="search-clear"
37
- @click="searchQuery = ''"
38
- title="清除搜索"
39
- >✕</button>
40
- </div>
41
- <div class="filter-divider"></div>
42
- <select v-model="filter.source" class="pop-select small">
43
- <option value="">来源</option>
44
- <option value="user">本地</option>
45
- <option value="api">远程</option>
46
- </select>
47
- <select v-model="filter.enabled" class="pop-select small">
48
- <option :value="undefined">状态</option>
49
- <option :value="true">已启用</option>
50
- <option :value="false">已禁用</option>
51
- </select>
52
- <span class="result-count">共{{ filteredPresets.length }}个预设</span>
53
- </div>
54
- <!-- 右侧:批量管理 + 操作按钮 -->
55
- <div class="toolbar-right">
56
- <!-- 批量管理模式 -->
57
- <template v-if="batchMode">
58
- <span class="batch-info">已选 {{ selectedIds.size }} 项</span>
59
- <button
60
- class="pop-btn small"
61
- :disabled="selectedIds.size === 0"
62
- @click="batchToggle(true)"
63
- title="批量启用"
64
- >✅ 启用</button>
65
- <button
66
- class="pop-btn small"
67
- :disabled="selectedIds.size === 0"
68
- @click="batchToggle(false)"
69
- title="批量禁用"
70
- >⛔ 禁用</button>
71
- <button
72
- class="pop-btn small danger"
73
- :disabled="selectedIds.size === 0 || !canBatchDelete"
74
- @click="batchDelete"
75
- title="批量删除(仅本地预设)"
76
- >🗑️ 删除</button>
77
- <div class="filter-divider"></div>
78
- <button class="pop-btn small" @click="exitBatchMode">取消</button>
79
- </template>
80
- <!-- 常规模式 -->
81
- <template v-else>
82
- <button class="pop-btn small" @click="enterBatchMode" title="批量管理">📦 批量</button>
83
- <button class="pop-btn small" @click="fetchData" title="刷新">🔄</button>
84
- <button class="pop-btn small primary" @click="openCreateDialog">
85
- ➕ 新建
86
- </button>
87
- </template>
88
- </div>
89
- </div>
90
-
91
- <!-- 内容区域 -->
92
- <div class="view-content pop-scrollbar">
93
- <!-- 列表视图 -->
94
- <template v-if="viewMode === 'list'">
95
- <div class="list-table pop-card no-hover">
96
- <table class="preset-table">
97
- <thead>
98
- <tr>
99
- <th v-if="batchMode" style="width: 40px">
100
- <label class="batch-checkbox">
101
- <input
102
- type="checkbox"
103
- :checked="isAllSelected"
104
- :indeterminate="isPartialSelected"
105
- @change="toggleSelectAll"
106
- />
107
- <span class="checkmark"></span>
108
- </label>
109
- </th>
110
- <th style="width: 70px">缩略图</th>
111
- <th style="width: 160px">名称</th>
112
- <th>Prompt 模板</th>
113
- <th style="width: 80px">参考图</th>
114
- <th style="width: 90px">来源</th>
115
- <th style="width: 70px">状态</th>
116
- <th style="width: 50px"></th>
117
- <th style="width: 50px"></th>
118
- </tr>
119
- </thead>
120
- <tbody>
121
- <tr v-for="row in displayPresets" :key="row.id" @click="handleRowClick(row)">
122
- <td v-if="batchMode" @click.stop>
123
- <label class="batch-checkbox">
124
- <input
125
- type="checkbox"
126
- :checked="selectedIds.has(row.id)"
127
- @change="toggleSelect(row.id)"
128
- />
129
- <span class="checkmark"></span>
130
- </label>
131
- </td>
132
- <td>
133
- <div class="thumb-cell">
134
- <img v-if="row.thumbnail" :src="row.thumbnail" class="thumb-img" />
135
- <div v-else class="thumb-empty">🖼️</div>
136
- </div>
137
- </td>
138
- <td>
139
- <span class="preset-name">{{ row.name }}</span>
140
- </td>
141
- <td>
142
- <div class="prompt-cell" :title="row.promptTemplate">
143
- {{ truncate(row.promptTemplate, 60) }}
144
- </div>
145
- </td>
146
- <td style="text-align: center">
147
- <span v-if="row.referenceImages?.length" class="badge-count">
148
- {{ row.referenceImages.length }}
149
- </span>
150
- <span v-else class="text-muted">-</span>
151
- </td>
152
- <td style="text-align: center">
153
- <span class="source-tag" :class="row.source">
154
- {{ row.source === 'api' ? '远程' : '本地' }}
155
- </span>
156
- </td>
157
- <td style="text-align: center" @click.stop>
158
- <label class="toggle-switch">
159
- <input type="checkbox" v-model="row.enabled" @change="handleToggle(row)" />
160
- <span class="toggle-slider"></span>
161
- </label>
162
- </td>
163
- <td style="text-align: center">
164
- <span
165
- v-if="row.source === 'user' && row.thumbnail"
166
- class="action-btn upload"
167
- title="上传到云端"
168
- @click.stop="handleUpload(row)"
169
- >☁️</span>
170
- </td>
171
- <td style="text-align: center">
172
- <span
173
- v-if="row.source === 'user'"
174
- class="action-btn delete"
175
- title="删除"
176
- @click.stop="handleDelete(row)"
177
- >🗑️</span>
178
- </td>
179
- </tr>
180
- </tbody>
181
- </table>
182
- </div>
183
- </template>
184
-
185
- <!-- 卡片视图 -->
186
- <template v-else-if="viewMode === 'card'">
187
- <div v-if="displayPresets.length === 0" class="empty-view">
188
- <span class="empty-icon">📦</span>
189
- <p>暂无预设</p>
190
- </div>
191
- <MasonryGrid
192
- v-else
193
- :items="displayPresets"
194
- :item-key="(preset) => preset.id"
195
- :min-column-width="200"
196
- :gap="16"
197
- >
198
- <template #default="{ item: preset }">
199
- <div
200
- class="preset-card pop-card"
201
- :class="{ 'batch-selected': batchMode && selectedIds.has(preset.id) }"
202
- @click="batchMode ? toggleSelect(preset.id) : openEditDialog(preset)"
203
- >
204
- <!-- 批量选择复选框 -->
205
- <div v-if="batchMode" class="card-checkbox" @click.stop>
206
- <label class="batch-checkbox">
207
- <input
208
- type="checkbox"
209
- :checked="selectedIds.has(preset.id)"
210
- @change="toggleSelect(preset.id)"
211
- />
212
- <span class="checkmark"></span>
213
- </label>
214
- </div>
215
- <!-- 缩略图 - 卡片主体 -->
216
- <div class="card-thumb" v-if="preset.thumbnail">
217
- <img :src="preset.thumbnail" loading="lazy" />
218
- <!-- 悬浮时显示的中央操作区 -->
219
- <div class="thumb-overlay">
220
- <div class="overlay-controls" @click.stop>
221
- <label class="toggle-switch">
222
- <input type="checkbox" v-model="preset.enabled" @change="handleToggle(preset)" />
223
- <span class="toggle-slider"></span>
224
- </label>
225
- <button class="overlay-btn" title="复制为新预设" @click="handleCopy(preset)">📋</button>
226
- <button
227
- v-if="preset.source === 'user'"
228
- class="overlay-btn danger"
229
- title="删除"
230
- @click="handleDelete(preset)"
231
- >🗑️</button>
232
- <button class="overlay-btn upload" v-if="preset.source === 'user'" title="上传到云端" @click="handleUpload(preset)">☁️</button>
233
- </div>
234
- </div>
235
- <!-- 参考图数量 -->
236
- <span v-if="preset.referenceImages?.length" class="ref-badge">
237
- 🖼️ {{ preset.referenceImages.length }}
238
- </span>
239
- </div>
240
- <div class="card-thumb empty" v-else>
241
- <span class="empty-thumb-icon">🖼️</span>
242
- <!-- 悬浮时显示的中央操作区 -->
243
- <div class="thumb-overlay">
244
- <div class="overlay-controls" @click.stop>
245
- <label class="toggle-switch">
246
- <input type="checkbox" v-model="preset.enabled" @change="handleToggle(preset)" />
247
- <span class="toggle-slider"></span>
248
- </label>
249
- <button class="overlay-btn" title="复制为新预设" @click="handleCopy(preset)">📋</button>
250
- <button
251
- v-if="preset.source === 'user'"
252
- class="overlay-btn danger"
253
- title="删除"
254
- @click="handleDelete(preset)"
255
- >🗑️</button>
256
- </div>
257
- </div>
258
- </div>
259
-
260
- <!-- 紧凑底部:名称 + 标签 -->
261
- <div class="card-info">
262
- <div class="card-name">{{ preset.name }}</div>
263
- <div class="card-tags" v-if="preset.tags?.length">
264
- <span v-for="tag in preset.tags.slice(0, 3)" :key="tag" class="tag-item">{{ tag }}</span>
265
- <span v-if="preset.tags.length > 3" class="tag-more">+{{ preset.tags.length - 3 }}</span>
266
- </div>
267
- </div>
268
-
269
- <!-- 来源标记 -->
270
- <div class="card-source" :class="preset.source">
271
- {{ preset.source === 'api' ? '远程' : '本地' }}
272
- </div>
273
- </div>
274
- </template>
275
- </MasonryGrid>
276
- </template>
277
- </div>
278
-
279
- <!-- 分页栏 -->
280
- <div class="pagination-bar pop-card no-hover">
281
- <div class="page-size-select">
282
- <span class="page-size-label">每页</span>
283
- <select v-model="pageSize" class="pop-select small" @change="page = 1">
284
- <option :value="20">20</option>
285
- <option :value="50">50</option>
286
- <option :value="100">100</option>
287
- </select>
288
- <span class="page-size-label">条</span>
289
- </div>
290
- <div class="page-nav">
291
- <button class="pop-btn small" :disabled="page <= 1" @click="page--">⬅️</button>
292
- <span class="page-info">{{ page }} / {{ totalPages }}</span>
293
- <button class="pop-btn small" :disabled="page >= totalPages" @click="page++">➡️</button>
294
- </div>
295
- <div class="page-total">共 {{ filteredPresets.length }} 条</div>
296
- </div>
297
-
298
- <!-- 编辑对话框 -->
299
- <Teleport to="#ml-teleport-container" defer>
300
- <div v-if="dialogVisible" class="modal-overlay" @click.self="dialogVisible = false">
301
- <div class="modal-dialog pop-card no-hover">
302
- <div class="modal-header">
303
- <h3>{{ isEdit ? '编辑预设' : '新建预设' }}</h3>
304
- <button class="modal-close" @click="dialogVisible = false">✕</button>
305
- </div>
306
- <div class="modal-body pop-scrollbar">
307
- <div class="form-group">
308
- <label class="form-label required">名称</label>
309
- <input
310
- v-model="form.name"
311
- :disabled="isEdit && form.source === 'api'"
312
- class="pop-input"
313
- placeholder="预设名称"
314
- />
315
- </div>
316
-
317
- <div class="form-group">
318
- <label class="form-label required">Prompt 模板</label>
319
- <textarea
320
- v-model="form.promptTemplate"
321
- class="pop-textarea"
322
- rows="4"
323
- placeholder="提示词模板,可用 {prompt} 指定用户输入位置"
324
- ></textarea>
325
- </div>
326
-
327
- <div class="form-group" v-if="form.source === 'user'">
328
- <label class="form-label">缩略图</label>
329
- <ImageUpload v-model="thumbnailList" :max-count="1" />
330
- </div>
331
-
332
- <div class="form-group">
333
- <label class="form-label">参考图</label>
334
- <ImageUpload v-model="form.referenceImages!" :max-count="5" />
335
- </div>
336
-
337
- <div class="form-divider">
338
- <span>高级设置</span>
339
- </div>
340
-
341
- <div class="form-group">
342
- <label class="form-label">标签</label>
343
- <TagInput v-model="form.tags!" placeholder="添加标签" />
344
- </div>
345
-
346
- <div class="form-group">
347
- <label class="form-label">参数覆盖</label>
348
- <JsonEditor v-model="form.parameterOverrides" :rows="3" />
349
- </div>
350
-
351
- <div class="form-group inline">
352
- <label class="form-label">启用</label>
353
- <label class="toggle-switch">
354
- <input type="checkbox" v-model="form.enabled" />
355
- <span class="toggle-slider"></span>
356
- </label>
357
- </div>
358
- </div>
359
- <div class="modal-footer">
360
- <button class="pop-btn" @click="dialogVisible = false">取消</button>
361
- <button class="pop-btn primary" @click="handleSubmit">保存</button>
362
- </div>
363
- </div>
364
- </div>
365
- </Teleport>
366
-
367
- <!-- 上传对话框 -->
368
- <UploadDialog
369
- v-if="uploadPreset"
370
- v-model="uploadDialogVisible"
371
- mode="preset"
372
- :preset-data="{
373
- name: uploadPreset.name,
374
- promptTemplate: uploadPreset.promptTemplate,
375
- thumbnail: uploadPreset.thumbnail,
376
- tags: uploadPreset.tags,
377
- referenceImages: uploadPreset.referenceImages
378
- }"
379
- @success="loadPresets"
380
- />
381
- </div>
382
- </template>
383
-
384
- <script setup lang="ts">
385
- import { ref, computed, onMounted, watch } from 'vue'
386
- import type { PresetData } from '../types'
387
- import { presetApi } from '../api'
388
- import TagInput from './TagInput.vue'
389
- import JsonEditor from './JsonEditor.vue'
390
- import ImageUpload from './ImageUpload.vue'
391
- import MasonryGrid from './MasonryGrid.vue'
392
- import UploadDialog from './UploadDialog.vue'
393
-
394
- type ViewMode = 'list' | 'card'
395
-
396
- // 预置标签
397
- const presetTags = ['本地', '远程', 'text2img', 'img2img', 'NSFW']
398
-
399
- // 视图状态
400
- const viewMode = ref<ViewMode>('card')
401
- const loading = ref(false)
402
- const presets = ref<PresetData[]>([])
403
- const selectedTags = ref<string[]>([])
404
- const searchQuery = ref('')
405
-
406
- // 批量管理
407
- const batchMode = ref(false)
408
- const selectedIds = ref<Set<string>>(new Set())
409
-
410
- // 筛选
411
- const filter = ref({
412
- source: '',
413
- enabled: undefined as boolean | undefined
414
- })
415
-
416
- // 分页
417
- const page = ref(1)
418
- const pageSize = ref(20)
419
-
420
- // 对话框
421
- const dialogVisible = ref(false)
422
- const isEdit = ref(false)
423
- const form = ref<Partial<PresetData>>({})
424
- const thumbnailList = ref<string[]>([])
425
-
426
- // 从预设中提取自定义标签(排除预置标签)
427
- const customTags = computed(() => {
428
- const tagSet = new Set<string>()
429
- presets.value.forEach(p => {
430
- (p.tags || []).forEach(t => {
431
- if (!presetTags.includes(t)) tagSet.add(t)
432
- })
433
- })
434
- return Array.from(tagSet).sort()
435
- })
436
-
437
- // 筛选后的数据
438
- const filteredPresets = computed(() => {
439
- const query = searchQuery.value.toLowerCase().trim()
440
- return presets.value.filter(p => {
441
- // 搜索筛选
442
- if (query) {
443
- const nameMatch = p.name.toLowerCase().includes(query)
444
- const promptMatch = p.promptTemplate?.toLowerCase().includes(query)
445
- if (!nameMatch && !promptMatch) return false
446
- }
447
- // 来源筛选
448
- if (filter.value.source && p.source !== filter.value.source) return false
449
- // 状态筛选
450
- if (filter.value.enabled !== undefined && p.enabled !== filter.value.enabled) return false
451
- // 标签筛选
452
- if (selectedTags.value.length > 0) {
453
- const match = selectedTags.value.every(tag => {
454
- if (tag === '本地') return p.source === 'user'
455
- if (tag === '远程') return p.source === 'api'
456
- return (p.tags || []).includes(tag)
457
- })
458
- if (!match) return false
459
- }
460
- return true
461
- })
462
- })
463
-
464
- // 总页数
465
- const totalPages = computed(() => Math.max(1, Math.ceil(filteredPresets.value.length / pageSize.value)))
466
-
467
- // 当前页数据
468
- const displayPresets = computed(() => {
469
- const start = (page.value - 1) * pageSize.value
470
- return filteredPresets.value.slice(start, start + pageSize.value)
471
- })
472
-
473
- // 缩略图同步
474
- watch(() => form.value.thumbnail, val => {
475
- thumbnailList.value = val ? [val] : []
476
- }, { immediate: true })
477
-
478
- watch(thumbnailList, val => {
479
- form.value.thumbnail = val[0] || ''
480
- })
481
-
482
- // 方法
483
- const fetchData = async () => {
484
- loading.value = true
485
- try {
486
- presets.value = await presetApi.list()
487
- } catch {
488
- alert('加载失败')
489
- } finally {
490
- loading.value = false
491
- }
492
- }
493
-
494
- const truncate = (text: string, len: number) => {
495
- if (!text) return '-'
496
- const s = text.replace(/\s+/g, ' ').trim()
497
- return s.length > len ? s.slice(0, len) + '...' : s
498
- }
499
-
500
- const openCreateDialog = () => {
501
- isEdit.value = false
502
- form.value = {
503
- name: '',
504
- promptTemplate: '',
505
- referenceImages: [],
506
- tags: ['text2img', 'img2img'],
507
- parameterOverrides: {},
508
- enabled: true,
509
- source: 'user'
510
- }
511
- dialogVisible.value = true
512
- }
513
-
514
- const openEditDialog = (preset: PresetData) => {
515
- isEdit.value = true
516
- form.value = JSON.parse(JSON.stringify(preset))
517
- dialogVisible.value = true
518
- }
519
-
520
- const handleRowClick = (row: PresetData) => openEditDialog(row)
521
-
522
- const handleToggle = async (preset: PresetData) => {
523
- try {
524
- await presetApi.toggle(preset.id, preset.enabled)
525
- } catch {
526
- preset.enabled = !preset.enabled
527
- alert('操作失败')
528
- }
529
- }
530
-
531
- const handleCopy = (preset: PresetData) => {
532
- isEdit.value = false
533
- const copy = JSON.parse(JSON.stringify(preset))
534
- delete copy.id
535
- copy.name = `${preset.name} (副本)`
536
- copy.source = 'user'
537
- form.value = copy
538
- dialogVisible.value = true
539
- }
540
-
541
- const handleDelete = async (preset: PresetData) => {
542
- if (!confirm(`确定删除预设 "${preset.name}"?`)) return
543
- try {
544
- await presetApi.delete(preset.id)
545
- alert('已删除')
546
- fetchData()
547
- } catch {
548
- alert('删除失败')
549
- }
550
- }
551
-
552
- // ============ 批量管理 ============
553
- // 是否全选当前页
554
- const isAllSelected = computed(() => {
555
- if (displayPresets.value.length === 0) return false
556
- return displayPresets.value.every(p => selectedIds.value.has(p.id))
557
- })
558
-
559
- // 是否部分选中
560
- const isPartialSelected = computed(() => {
561
- if (displayPresets.value.length === 0) return false
562
- const selected = displayPresets.value.filter(p => selectedIds.value.has(p.id)).length
563
- return selected > 0 && selected < displayPresets.value.length
564
- })
565
-
566
- // 选中的预设中是否有可删除的(本地预设)
567
- const canBatchDelete = computed(() => {
568
- return presets.value.some(p => selectedIds.value.has(p.id) && p.source === 'user')
569
- })
570
-
571
- // 进入批量管理模式
572
- const enterBatchMode = () => {
573
- batchMode.value = true
574
- selectedIds.value = new Set()
575
- }
576
-
577
- // 退出批量管理模式
578
- const exitBatchMode = () => {
579
- batchMode.value = false
580
- selectedIds.value = new Set()
581
- }
582
-
583
- // 切换单个选择
584
- const toggleSelect = (id: string) => {
585
- const newSet = new Set(selectedIds.value)
586
- if (newSet.has(id)) {
587
- newSet.delete(id)
588
- } else {
589
- newSet.add(id)
590
- }
591
- selectedIds.value = newSet
592
- }
593
-
594
- // 全选/取消全选当前页
595
- const toggleSelectAll = () => {
596
- const newSet = new Set(selectedIds.value)
597
- if (isAllSelected.value) {
598
- // 取消全选当前页
599
- displayPresets.value.forEach(p => newSet.delete(p.id))
600
- } else {
601
- // 全选当前页
602
- displayPresets.value.forEach(p => newSet.add(p.id))
603
- }
604
- selectedIds.value = newSet
605
- }
606
-
607
- // 批量切换启用状态
608
- const batchToggle = async (enabled: boolean) => {
609
- const ids = Array.from(selectedIds.value)
610
- if (ids.length === 0) return
611
-
612
- const action = enabled ? '启用' : '禁用'
613
- if (!confirm(`确定${action} ${ids.length} 个预设?`)) return
614
-
615
- try {
616
- let successCount = 0
617
- for (const id of ids) {
618
- try {
619
- await presetApi.toggle(id, enabled)
620
- successCount++
621
- } catch (e) {
622
- console.error(`Failed to toggle preset ${id}:`, e)
623
- }
624
- }
625
- alert(`已${action} ${successCount} 个预设`)
626
- fetchData()
627
- exitBatchMode()
628
- } catch {
629
- alert('操作失败')
630
- }
631
- }
632
-
633
- // 批量删除(仅本地预设)
634
- const batchDelete = async () => {
635
- const ids = Array.from(selectedIds.value)
636
- const deletablePresets = presets.value.filter(p => ids.includes(p.id) && p.source === 'user')
637
-
638
- if (deletablePresets.length === 0) {
639
- alert('没有可删除的本地预设')
640
- return
641
- }
642
-
643
- if (!confirm(`确定删除 ${deletablePresets.length} 个本地预设?(远程预设不会被删除)`)) return
644
-
645
- try {
646
- let successCount = 0
647
- for (const preset of deletablePresets) {
648
- try {
649
- await presetApi.delete(preset.id)
650
- successCount++
651
- } catch (e) {
652
- console.error(`Failed to delete preset ${preset.id}:`, e)
653
- }
654
- }
655
- alert(`已删除 ${successCount} 个预设`)
656
- fetchData()
657
- exitBatchMode()
658
- } catch {
659
- alert('删除失败')
660
- }
661
- }
662
-
663
- // 上传相关
664
- const uploadDialogVisible = ref(false)
665
- const uploadPreset = ref<PresetData | null>(null)
666
-
667
- const handleUpload = (preset: PresetData) => {
668
- // 需要有缩略图才能上传
669
- if (!preset.thumbnail) {
670
- alert('预设没有缩略图,无法上传')
671
- return
672
- }
673
- uploadPreset.value = preset
674
- uploadDialogVisible.value = true
675
- }
676
-
677
- const handleSubmit = async () => {
678
- if (!form.value.name || !form.value.promptTemplate) {
679
- alert('请填写必要信息')
680
- return
681
- }
682
- try {
683
- if (isEdit.value && form.value.id) {
684
- await presetApi.update(form.value.id, form.value)
685
- alert('已保存')
686
- } else {
687
- await presetApi.create(form.value as Omit<PresetData, 'id'>)
688
- alert('已创建')
689
- }
690
- dialogVisible.value = false
691
- fetchData()
692
- } catch {
693
- alert('保存失败')
694
- }
695
- }
696
-
697
- onMounted(fetchData)
698
- </script>
699
-
700
- <style lang="scss">
701
- @use '../styles/theme.scss';
702
- </style>
703
-
704
- <style scoped lang="scss">
705
- /* ============ 视图容器 ============ */
706
- .presets-view {
707
- height: 100%;
708
- display: flex;
709
- flex-direction: column;
710
- min-height: 0;
711
- gap: 16px;
712
- overflow: hidden; /* 视图本身不滚动 */
713
- }
714
-
715
- /* ============ 紧凑工具栏 ============ */
716
- .compact-toolbar {
717
- flex-shrink: 0;
718
- display: flex;
719
- justify-content: space-between;
720
- align-items: center;
721
- padding: 12px 16px;
722
- gap: 12px;
723
- }
724
-
725
- .toolbar-left {
726
- display: flex;
727
- align-items: center;
728
- gap: 12px;
729
- flex-wrap: wrap;
730
- }
731
-
732
- .toolbar-right {
733
- display: flex;
734
- align-items: center;
735
- gap: 8px;
736
- }
737
-
738
- .filter-divider {
739
- width: 2px;
740
- height: 20px;
741
- background: var(--ml-border-color);
742
- border-radius: 1px;
743
- }
744
-
745
- .result-count {
746
- font-size: 13px;
747
- font-weight: 600;
748
- color: var(--ml-text-muted);
749
- white-space: nowrap;
750
- }
751
-
752
- /* ============ 搜索框 ============ */
753
- .search-box {
754
- position: relative;
755
- display: flex;
756
- align-items: center;
757
- }
758
-
759
- .search-icon {
760
- position: absolute;
761
- left: 10px;
762
- font-size: 14px;
763
- pointer-events: none;
764
- z-index: 1;
765
- }
766
-
767
- .search-input {
768
- padding-left: 32px !important;
769
- padding-right: 28px !important;
770
- width: 180px;
771
- }
772
-
773
- .search-clear {
774
- position: absolute;
775
- right: 6px;
776
- display: flex;
777
- align-items: center;
778
- justify-content: center;
779
- width: 20px;
780
- height: 20px;
781
- border: none;
782
- background: var(--ml-bg-alt);
783
- color: var(--ml-text-muted);
784
- border-radius: 50%;
785
- cursor: pointer;
786
- font-size: 10px;
787
- transition: all 0.15s;
788
- }
789
-
790
- .search-clear:hover {
791
- background: var(--ml-danger);
792
- color: white;
793
- }
794
-
795
- /* ============ 批量管理 ============ */
796
- .batch-info {
797
- font-size: 13px;
798
- font-weight: 700;
799
- color: var(--ml-primary-dark);
800
- white-space: nowrap;
801
- }
802
-
803
- .batch-checkbox {
804
- position: relative;
805
- display: inline-flex;
806
- align-items: center;
807
- justify-content: center;
808
- width: 20px;
809
- height: 20px;
810
- cursor: pointer;
811
- }
812
-
813
- .batch-checkbox input {
814
- position: absolute;
815
- opacity: 0;
816
- width: 100%;
817
- height: 100%;
818
- margin: 0;
819
- cursor: pointer;
820
- }
821
-
822
- .batch-checkbox .checkmark {
823
- width: 18px;
824
- height: 18px;
825
- background: var(--ml-surface);
826
- border: 2px solid var(--ml-border-color);
827
- border-radius: 4px;
828
- display: flex;
829
- align-items: center;
830
- justify-content: center;
831
- transition: all 0.15s;
832
- }
833
-
834
- .batch-checkbox input:checked + .checkmark {
835
- background: var(--ml-primary);
836
- border-color: var(--ml-primary-dark);
837
- }
838
-
839
- .batch-checkbox input:checked + .checkmark::after {
840
- content: "✓";
841
- font-size: 12px;
842
- font-weight: 700;
843
- color: var(--ml-text);
844
- }
845
-
846
- .batch-checkbox input:indeterminate + .checkmark {
847
- background: var(--ml-primary-light);
848
- border-color: var(--ml-primary);
849
- }
850
-
851
- .batch-checkbox input:indeterminate + .checkmark::after {
852
- content: "−";
853
- font-size: 14px;
854
- font-weight: 700;
855
- color: var(--ml-text);
856
- }
857
-
858
- /* 卡片视图批量选择 */
859
- .card-checkbox {
860
- position: absolute;
861
- top: 8px;
862
- right: 8px;
863
- z-index: 10;
864
- background: rgba(255, 255, 255, 0.9);
865
- border-radius: 6px;
866
- padding: 4px;
867
- border: 2px solid var(--ml-border-color);
868
- }
869
-
870
- .preset-card.batch-selected {
871
- border-color: var(--ml-primary);
872
- box-shadow: 0 0 0 3px var(--ml-primary-light), var(--ml-shadow);
873
- }
874
-
875
- .btn-group {
876
- display: flex;
877
- background: var(--ml-bg-alt);
878
- border: var(--ml-border);
879
- border-radius: var(--ml-radius);
880
- padding: 4px;
881
- gap: 4px;
882
- }
883
-
884
- .group-btn {
885
- display: flex;
886
- align-items: center;
887
- justify-content: center;
888
- padding: 6px 10px;
889
- border: none;
890
- background: transparent;
891
- color: var(--ml-text-muted);
892
- cursor: pointer;
893
- border-radius: calc(var(--ml-radius) - 4px);
894
- font-size: 14px;
895
- transition: all 0.15s;
896
- }
897
-
898
- .group-btn:hover {
899
- color: var(--ml-text);
900
- background: var(--ml-bg);
901
- }
902
-
903
- .group-btn.active {
904
- color: var(--ml-text);
905
- background: var(--ml-primary);
906
- box-shadow: var(--ml-shadow-sm);
907
- }
908
-
909
- /* ============ 内容区域 ============ */
910
- .view-content {
911
- flex: 1;
912
- min-height: 0;
913
- overflow-y: auto;
914
- overflow-x: hidden;
915
- padding: 4px;
916
- /* 隐藏式滚动条 */
917
- scrollbar-width: thin;
918
- scrollbar-color: transparent transparent;
919
- }
920
-
921
- .view-content:hover {
922
- scrollbar-color: var(--ml-border-color) transparent;
923
- }
924
-
925
- .view-content::-webkit-scrollbar {
926
- width: 6px;
927
- }
928
-
929
- .view-content::-webkit-scrollbar-track {
930
- background: transparent;
931
- }
932
-
933
- .view-content::-webkit-scrollbar-thumb {
934
- background-color: transparent;
935
- border-radius: 3px;
936
- }
937
-
938
- .view-content:hover::-webkit-scrollbar-thumb {
939
- background-color: var(--ml-border-color);
940
- }
941
-
942
- /* ============ 列表表格 ============ */
943
- .list-table {
944
- overflow: hidden;
945
- }
946
-
947
- .preset-table {
948
- width: 100%;
949
- border-collapse: collapse;
950
- table-layout: fixed;
951
- }
952
-
953
- .preset-table thead {
954
- background: var(--ml-bg-alt);
955
- }
956
-
957
- .preset-table th {
958
- padding: 12px 16px;
959
- font-size: 12px;
960
- font-weight: 700;
961
- color: var(--ml-text-muted);
962
- text-align: left;
963
- text-transform: uppercase;
964
- letter-spacing: 0.05em;
965
- border-bottom: var(--ml-border);
966
- }
967
-
968
- .preset-table tbody tr {
969
- cursor: pointer;
970
- transition: background-color 0.15s;
971
- }
972
-
973
- .preset-table tbody tr:hover {
974
- background: var(--ml-primary-light);
975
- }
976
-
977
- .preset-table td {
978
- padding: 12px 16px;
979
- border-bottom: 2px solid var(--ml-border-color);
980
- vertical-align: middle;
981
- }
982
-
983
- .preset-table tbody tr:last-child td {
984
- border-bottom: none;
985
- }
986
-
987
- .thumb-cell {
988
- width: 40px;
989
- height: 40px;
990
- border-radius: 8px;
991
- overflow: hidden;
992
- background: var(--ml-bg-alt);
993
- border: 2px solid var(--ml-border-color);
994
- }
995
-
996
- .thumb-img {
997
- width: 100%;
998
- height: 100%;
999
- object-fit: cover;
1000
- }
1001
-
1002
- .thumb-empty {
1003
- width: 100%;
1004
- height: 100%;
1005
- display: flex;
1006
- align-items: center;
1007
- justify-content: center;
1008
- font-size: 16px;
1009
- opacity: 0.5;
1010
- }
1011
-
1012
- .preset-name {
1013
- font-weight: 700;
1014
- color: var(--ml-text);
1015
- }
1016
-
1017
- .prompt-cell {
1018
- color: var(--ml-text-muted);
1019
- font-size: 13px;
1020
- overflow: hidden;
1021
- text-overflow: ellipsis;
1022
- white-space: nowrap;
1023
- }
1024
-
1025
- .badge-count {
1026
- display: inline-flex;
1027
- align-items: center;
1028
- justify-content: center;
1029
- min-width: 24px;
1030
- height: 24px;
1031
- padding: 0 8px;
1032
- background: var(--ml-primary);
1033
- border: 2px solid var(--ml-border-color);
1034
- border-radius: 12px;
1035
- font-size: 12px;
1036
- font-weight: 700;
1037
- color: var(--ml-text);
1038
- }
1039
-
1040
- .source-tag {
1041
- font-size: 11px;
1042
- padding: 4px 8px;
1043
- border-radius: 8px;
1044
- font-weight: 700;
1045
- border: 2px solid var(--ml-border-color);
1046
- }
1047
-
1048
- .source-tag.api {
1049
- background: var(--ml-info);
1050
- color: white;
1051
- }
1052
-
1053
- .source-tag.user {
1054
- background: var(--ml-warning);
1055
- color: var(--ml-text);
1056
- }
1057
-
1058
- .text-muted {
1059
- color: var(--ml-text-muted);
1060
- opacity: 0.5;
1061
- }
1062
-
1063
- .action-btn {
1064
- display: inline-flex;
1065
- align-items: center;
1066
- justify-content: center;
1067
- width: 28px;
1068
- height: 28px;
1069
- border-radius: 8px;
1070
- cursor: pointer;
1071
- font-size: 14px;
1072
- transition: all 0.15s;
1073
- }
1074
-
1075
- .action-btn:hover {
1076
- transform: scale(1.15);
1077
- }
1078
-
1079
- .action-btn.delete:hover {
1080
- background: var(--ml-danger);
1081
- }
1082
-
1083
- .action-btn.upload:hover {
1084
- background: var(--ml-info);
1085
- }
1086
-
1087
- /* ============ 分页栏 ============ */
1088
- .pagination-bar {
1089
- flex-shrink: 0;
1090
- display: flex;
1091
- align-items: center;
1092
- justify-content: center;
1093
- gap: 24px;
1094
- padding: 12px 16px;
1095
- }
1096
-
1097
- .page-size-select {
1098
- display: flex;
1099
- align-items: center;
1100
- gap: 8px;
1101
- }
1102
-
1103
- .page-size-label {
1104
- font-size: 13px;
1105
- font-weight: 600;
1106
- color: var(--ml-text-muted);
1107
- }
1108
-
1109
- .page-nav {
1110
- display: flex;
1111
- align-items: center;
1112
- gap: 12px;
1113
- }
1114
-
1115
- .page-info {
1116
- font-size: 14px;
1117
- font-weight: 700;
1118
- color: var(--ml-text);
1119
- min-width: 60px;
1120
- text-align: center;
1121
- }
1122
-
1123
- .page-total {
1124
- font-size: 13px;
1125
- font-weight: 600;
1126
- color: var(--ml-text-muted);
1127
- }
1128
-
1129
- /* ============ 卡片视图 ============ */
1130
- .empty-view {
1131
- display: flex;
1132
- flex-direction: column;
1133
- align-items: center;
1134
- justify-content: center;
1135
- padding: 64px;
1136
- color: var(--ml-text-muted);
1137
- }
1138
-
1139
- .empty-icon {
1140
- font-size: 48px;
1141
- margin-bottom: 16px;
1142
- opacity: 0.5;
1143
- }
1144
-
1145
- .preset-card {
1146
- cursor: pointer;
1147
- position: relative;
1148
- overflow: hidden;
1149
- transition: all 0.2s;
1150
- }
1151
-
1152
- .preset-card:hover {
1153
- transform: translateY(-4px);
1154
- }
1155
-
1156
- /* 缩略图区域 */
1157
- .card-thumb {
1158
- width: 100%;
1159
- position: relative;
1160
- overflow: hidden;
1161
- background: var(--ml-bg-alt);
1162
- }
1163
-
1164
- .card-thumb img {
1165
- width: 100%;
1166
- height: auto;
1167
- display: block;
1168
- transition: transform 0.3s;
1169
- }
1170
-
1171
- .preset-card:hover .card-thumb img {
1172
- transform: scale(1.05);
1173
- }
1174
-
1175
- .card-thumb.empty {
1176
- aspect-ratio: 4/3;
1177
- display: flex;
1178
- align-items: center;
1179
- justify-content: center;
1180
- }
1181
-
1182
- .empty-thumb-icon {
1183
- font-size: 32px;
1184
- opacity: 0.3;
1185
- }
1186
-
1187
- /* 悬浮遮罩层 */
1188
- .thumb-overlay {
1189
- position: absolute;
1190
- inset: 0;
1191
- background: rgba(0, 0, 0, 0.6);
1192
- display: flex;
1193
- align-items: center;
1194
- justify-content: center;
1195
- opacity: 0;
1196
- transition: opacity 0.2s;
1197
- }
1198
-
1199
- .preset-card:hover .thumb-overlay {
1200
- opacity: 1;
1201
- }
1202
-
1203
- .overlay-controls {
1204
- display: flex;
1205
- align-items: center;
1206
- gap: 12px;
1207
- padding: 10px 18px;
1208
- background: rgba(0, 0, 0, 0.7);
1209
- border-radius: 24px;
1210
- backdrop-filter: blur(4px);
1211
- }
1212
-
1213
- .overlay-btn {
1214
- display: flex;
1215
- align-items: center;
1216
- justify-content: center;
1217
- width: 32px;
1218
- height: 32px;
1219
- border: none;
1220
- border-radius: 50%;
1221
- background: rgba(255, 255, 255, 0.2);
1222
- color: white;
1223
- cursor: pointer;
1224
- font-size: 14px;
1225
- transition: all 0.15s;
1226
- }
1227
-
1228
- .overlay-btn:hover {
1229
- background: rgba(255, 255, 255, 0.4);
1230
- transform: scale(1.1);
1231
- }
1232
-
1233
- .overlay-btn.danger:hover {
1234
- background: var(--ml-danger);
1235
- }
1236
-
1237
- .overlay-btn.upload:hover {
1238
- background: var(--ml-info);
1239
- }
1240
-
1241
- /* 参考图徽章 */
1242
- .ref-badge {
1243
- position: absolute;
1244
- bottom: 8px;
1245
- right: 8px;
1246
- display: flex;
1247
- align-items: center;
1248
- gap: 4px;
1249
- padding: 4px 8px;
1250
- background: rgba(0, 0, 0, 0.7);
1251
- color: white;
1252
- font-size: 11px;
1253
- font-weight: 700;
1254
- border-radius: 8px;
1255
- }
1256
-
1257
- /* 紧凑底部 */
1258
- .card-info {
1259
- padding: 10px 12px;
1260
- }
1261
-
1262
- .card-name {
1263
- font-size: 13px;
1264
- font-weight: 700;
1265
- color: var(--ml-text);
1266
- overflow: hidden;
1267
- text-overflow: ellipsis;
1268
- white-space: nowrap;
1269
- margin-bottom: 6px;
1270
- }
1271
-
1272
- .card-tags {
1273
- display: flex;
1274
- flex-wrap: wrap;
1275
- gap: 4px;
1276
- }
1277
-
1278
- .tag-item {
1279
- font-size: 10px;
1280
- padding: 2px 6px;
1281
- background: var(--ml-bg-alt);
1282
- color: var(--ml-text-muted);
1283
- border: 1px solid var(--ml-border-color);
1284
- border-radius: 6px;
1285
- font-weight: 600;
1286
- }
1287
-
1288
- .tag-more {
1289
- font-size: 10px;
1290
- padding: 2px 6px;
1291
- background: var(--ml-primary);
1292
- color: var(--ml-text);
1293
- border-radius: 6px;
1294
- font-weight: 700;
1295
- }
1296
-
1297
- /* 来源标记 */
1298
- .card-source {
1299
- position: absolute;
1300
- top: 8px;
1301
- left: 8px;
1302
- font-size: 10px;
1303
- padding: 3px 8px;
1304
- border-radius: 6px;
1305
- font-weight: 700;
1306
- border: 2px solid rgba(255, 255, 255, 0.3);
1307
- }
1308
-
1309
- .card-source.api {
1310
- background: var(--ml-info);
1311
- color: white;
1312
- }
1313
-
1314
- .card-source.user {
1315
- background: var(--ml-warning);
1316
- color: var(--ml-text);
1317
- }
1318
-
1319
- /* ============ Toggle Switch ============ */
1320
- .toggle-switch {
1321
- position: relative;
1322
- display: inline-block;
1323
- width: 44px;
1324
- height: 24px;
1325
- }
1326
-
1327
- .toggle-switch input {
1328
- opacity: 0;
1329
- width: 0;
1330
- height: 0;
1331
- }
1332
-
1333
- .toggle-slider {
1334
- position: absolute;
1335
- cursor: pointer;
1336
- top: 0;
1337
- left: 0;
1338
- right: 0;
1339
- bottom: 0;
1340
- background-color: var(--ml-bg-alt);
1341
- border: 2px solid var(--ml-border-color);
1342
- border-radius: 24px;
1343
- transition: 0.2s;
1344
- }
1345
-
1346
- .toggle-slider::before {
1347
- position: absolute;
1348
- content: "";
1349
- height: 16px;
1350
- width: 16px;
1351
- left: 2px;
1352
- bottom: 2px;
1353
- background-color: var(--ml-border-color);
1354
- border-radius: 50%;
1355
- transition: 0.2s;
1356
- }
1357
-
1358
- .toggle-switch input:checked + .toggle-slider {
1359
- background-color: var(--ml-success);
1360
- border-color: var(--ml-success-dark);
1361
- }
1362
-
1363
- .toggle-switch input:checked + .toggle-slider::before {
1364
- transform: translateX(20px);
1365
- background-color: white;
1366
- }
1367
-
1368
- /* ============ 模态框 ============ */
1369
- .modal-overlay {
1370
- position: fixed;
1371
- inset: 0;
1372
- background: rgba(0, 0, 0, 0.5);
1373
- display: flex;
1374
- align-items: center;
1375
- justify-content: center;
1376
- z-index: 1000;
1377
- padding: 24px;
1378
- }
1379
-
1380
- .modal-dialog {
1381
- width: 100%;
1382
- max-width: 600px;
1383
- max-height: 90vh;
1384
- display: flex;
1385
- flex-direction: column;
1386
- animation: modal-pop 0.2s ease-out;
1387
- }
1388
-
1389
- @keyframes modal-pop {
1390
- from {
1391
- opacity: 0;
1392
- transform: scale(0.95) translateY(-10px);
1393
- }
1394
- to {
1395
- opacity: 1;
1396
- transform: scale(1) translateY(0);
1397
- }
1398
- }
1399
-
1400
- .modal-header {
1401
- flex-shrink: 0;
1402
- display: flex;
1403
- justify-content: space-between;
1404
- align-items: center;
1405
- padding: 16px 20px;
1406
- border-bottom: var(--ml-border);
1407
- }
1408
-
1409
- .modal-header h3 {
1410
- margin: 0;
1411
- font-size: 18px;
1412
- font-weight: 800;
1413
- color: var(--ml-text);
1414
- }
1415
-
1416
- .modal-close {
1417
- display: flex;
1418
- align-items: center;
1419
- justify-content: center;
1420
- width: 32px;
1421
- height: 32px;
1422
- border: none;
1423
- background: transparent;
1424
- color: var(--ml-text-muted);
1425
- cursor: pointer;
1426
- font-size: 18px;
1427
- border-radius: 8px;
1428
- transition: all 0.15s;
1429
- }
1430
-
1431
- .modal-close:hover {
1432
- background: var(--ml-danger);
1433
- color: white;
1434
- }
1435
-
1436
- .modal-body {
1437
- flex: 1;
1438
- min-height: 0;
1439
- overflow-y: auto;
1440
- padding: 20px;
1441
- /* 隐藏式滚动条 */
1442
- scrollbar-width: thin;
1443
- scrollbar-color: transparent transparent;
1444
- }
1445
-
1446
- .modal-body:hover {
1447
- scrollbar-color: var(--ml-border-color) transparent;
1448
- }
1449
-
1450
- .modal-body::-webkit-scrollbar {
1451
- width: 6px;
1452
- }
1453
-
1454
- .modal-body::-webkit-scrollbar-track {
1455
- background: transparent;
1456
- }
1457
-
1458
- .modal-body::-webkit-scrollbar-thumb {
1459
- background-color: transparent;
1460
- border-radius: 3px;
1461
- }
1462
-
1463
- .modal-body:hover::-webkit-scrollbar-thumb {
1464
- background-color: var(--ml-border-color);
1465
- }
1466
-
1467
- .modal-footer {
1468
- flex-shrink: 0;
1469
- display: flex;
1470
- justify-content: flex-end;
1471
- gap: 12px;
1472
- padding: 16px 20px;
1473
- border-top: var(--ml-border);
1474
- }
1475
-
1476
- /* ============ 表单 ============ */
1477
- .form-group {
1478
- margin-bottom: 20px;
1479
- }
1480
-
1481
- .form-group.inline {
1482
- display: flex;
1483
- align-items: center;
1484
- gap: 16px;
1485
- }
1486
-
1487
- .form-label {
1488
- display: block;
1489
- font-size: 14px;
1490
- font-weight: 700;
1491
- color: var(--ml-text);
1492
- margin-bottom: 8px;
1493
- }
1494
-
1495
- .form-group.inline .form-label {
1496
- margin-bottom: 0;
1497
- }
1498
-
1499
- .form-label.required::after {
1500
- content: " *";
1501
- color: var(--ml-danger);
1502
- }
1503
-
1504
- .form-divider {
1505
- display: flex;
1506
- align-items: center;
1507
- margin: 24px 0;
1508
- gap: 16px;
1509
- }
1510
-
1511
- .form-divider::before,
1512
- .form-divider::after {
1513
- content: "";
1514
- flex: 1;
1515
- height: 2px;
1516
- background: var(--ml-border-color);
1517
- }
1518
-
1519
- .form-divider span {
1520
- font-size: 12px;
1521
- font-weight: 700;
1522
- color: var(--ml-text-muted);
1523
- text-transform: uppercase;
1524
- letter-spacing: 0.05em;
1525
- }
1526
- </style>
1
+ <template>
2
+ <div class="presets-view">
3
+ <!-- 紧凑工具栏 -->
4
+ <div class="compact-toolbar pop-card no-hover">
5
+ <!-- 左侧:视图切换 + 筛选 + 搜索 -->
6
+ <div class="toolbar-left">
7
+ <div class="btn-group">
8
+ <button
9
+ class="group-btn"
10
+ :class="{ active: viewMode === 'list' }"
11
+ @click="viewMode = 'list'"
12
+ title="列表视图"
13
+ >
14
+ 📋
15
+ </button>
16
+ <button
17
+ class="group-btn"
18
+ :class="{ active: viewMode === 'card' }"
19
+ @click="viewMode = 'card'"
20
+ title="卡片视图"
21
+ >
22
+ 🎴
23
+ </button>
24
+ </div>
25
+ <div class="filter-divider"></div>
26
+ <!-- 搜索框 -->
27
+ <div class="search-box">
28
+ <span class="search-icon">🔍</span>
29
+ <input
30
+ v-model="searchQuery"
31
+ class="pop-input small search-input"
32
+ placeholder="搜索名称或Prompt..."
33
+ />
34
+ <button
35
+ v-if="searchQuery"
36
+ class="search-clear"
37
+ @click="searchQuery = ''"
38
+ title="清除搜索"
39
+ >✕</button>
40
+ </div>
41
+ <div class="filter-divider"></div>
42
+ <select v-model="filter.source" class="pop-select small">
43
+ <option value="">来源</option>
44
+ <option value="user">本地</option>
45
+ <option value="api">远程</option>
46
+ </select>
47
+ <select v-model="filter.enabled" class="pop-select small">
48
+ <option :value="undefined">状态</option>
49
+ <option :value="true">已启用</option>
50
+ <option :value="false">已禁用</option>
51
+ </select>
52
+ <span class="result-count">共{{ filteredPresets.length }}个预设</span>
53
+ </div>
54
+ <!-- 右侧:批量管理 + 操作按钮 -->
55
+ <div class="toolbar-right">
56
+ <!-- 批量管理模式 -->
57
+ <template v-if="batchMode">
58
+ <span class="batch-info">已选 {{ selectedIds.size }} 项</span>
59
+ <button
60
+ class="pop-btn small"
61
+ :disabled="selectedIds.size === 0"
62
+ @click="batchToggle(true)"
63
+ title="批量启用"
64
+ >✅ 启用</button>
65
+ <button
66
+ class="pop-btn small"
67
+ :disabled="selectedIds.size === 0"
68
+ @click="batchToggle(false)"
69
+ title="批量禁用"
70
+ >⛔ 禁用</button>
71
+ <button
72
+ class="pop-btn small danger"
73
+ :disabled="selectedIds.size === 0 || !canBatchDelete"
74
+ @click="batchDelete"
75
+ title="批量删除(仅本地预设)"
76
+ >🗑️ 删除</button>
77
+ <div class="filter-divider"></div>
78
+ <button class="pop-btn small" @click="exitBatchMode">取消</button>
79
+ </template>
80
+ <!-- 常规模式 -->
81
+ <template v-else>
82
+ <button class="pop-btn small" @click="enterBatchMode" title="批量管理">📦 批量</button>
83
+ <button class="pop-btn small" @click="fetchData" title="刷新">🔄</button>
84
+ <button class="pop-btn small primary" @click="openCreateDialog">
85
+ ➕ 新建
86
+ </button>
87
+ </template>
88
+ </div>
89
+ </div>
90
+
91
+ <!-- 内容区域 -->
92
+ <div class="view-content pop-scrollbar">
93
+ <!-- 列表视图 -->
94
+ <template v-if="viewMode === 'list'">
95
+ <div class="list-table pop-card no-hover">
96
+ <table class="preset-table">
97
+ <thead>
98
+ <tr>
99
+ <th v-if="batchMode" style="width: 40px">
100
+ <label class="batch-checkbox">
101
+ <input
102
+ type="checkbox"
103
+ :checked="isAllSelected"
104
+ :indeterminate="isPartialSelected"
105
+ @change="toggleSelectAll"
106
+ />
107
+ <span class="checkmark"></span>
108
+ </label>
109
+ </th>
110
+ <th style="width: 70px">缩略图</th>
111
+ <th style="width: 160px">名称</th>
112
+ <th>Prompt 模板</th>
113
+ <th style="width: 80px">参考图</th>
114
+ <th style="width: 90px">来源</th>
115
+ <th style="width: 70px">状态</th>
116
+ <th style="width: 50px"></th>
117
+ <th style="width: 50px"></th>
118
+ </tr>
119
+ </thead>
120
+ <tbody>
121
+ <tr v-for="row in displayPresets" :key="row.id" @click="handleRowClick(row)">
122
+ <td v-if="batchMode" @click.stop>
123
+ <label class="batch-checkbox">
124
+ <input
125
+ type="checkbox"
126
+ :checked="selectedIds.has(row.id)"
127
+ @change="toggleSelect(row.id)"
128
+ />
129
+ <span class="checkmark"></span>
130
+ </label>
131
+ </td>
132
+ <td>
133
+ <div class="thumb-cell">
134
+ <img v-if="row.thumbnail" :src="row.thumbnail" class="thumb-img" />
135
+ <div v-else class="thumb-empty">🖼️</div>
136
+ </div>
137
+ </td>
138
+ <td>
139
+ <span class="preset-name">{{ row.name }}</span>
140
+ </td>
141
+ <td>
142
+ <div class="prompt-cell" :title="row.promptTemplate">
143
+ {{ truncate(row.promptTemplate, 60) }}
144
+ </div>
145
+ </td>
146
+ <td style="text-align: center">
147
+ <span v-if="row.referenceImages?.length" class="badge-count">
148
+ {{ row.referenceImages.length }}
149
+ </span>
150
+ <span v-else class="text-muted">-</span>
151
+ </td>
152
+ <td style="text-align: center">
153
+ <span class="source-tag" :class="row.source">
154
+ {{ row.source === 'api' ? '远程' : '本地' }}
155
+ </span>
156
+ </td>
157
+ <td style="text-align: center" @click.stop>
158
+ <label class="toggle-switch">
159
+ <input type="checkbox" v-model="row.enabled" @change="handleToggle(row)" />
160
+ <span class="toggle-slider"></span>
161
+ </label>
162
+ </td>
163
+ <td style="text-align: center">
164
+ <span
165
+ v-if="row.source === 'user' && row.thumbnail"
166
+ class="action-btn upload"
167
+ title="上传到云端"
168
+ @click.stop="handleUpload(row)"
169
+ >☁️</span>
170
+ </td>
171
+ <td style="text-align: center">
172
+ <span
173
+ v-if="row.source === 'user'"
174
+ class="action-btn delete"
175
+ title="删除"
176
+ @click.stop="handleDelete(row)"
177
+ >🗑️</span>
178
+ </td>
179
+ </tr>
180
+ </tbody>
181
+ </table>
182
+ </div>
183
+ </template>
184
+
185
+ <!-- 卡片视图 -->
186
+ <template v-else-if="viewMode === 'card'">
187
+ <div v-if="displayPresets.length === 0" class="empty-view">
188
+ <span class="empty-icon">📦</span>
189
+ <p>暂无预设</p>
190
+ </div>
191
+ <MasonryGrid
192
+ v-else
193
+ :items="displayPresets"
194
+ :item-key="(preset) => preset.id"
195
+ :min-column-width="200"
196
+ :gap="16"
197
+ >
198
+ <template #default="{ item: preset }">
199
+ <div
200
+ class="preset-card pop-card"
201
+ :class="{ 'batch-selected': batchMode && selectedIds.has(preset.id) }"
202
+ @click="batchMode ? toggleSelect(preset.id) : openEditDialog(preset)"
203
+ >
204
+ <!-- 批量选择复选框 -->
205
+ <div v-if="batchMode" class="card-checkbox" @click.stop>
206
+ <label class="batch-checkbox">
207
+ <input
208
+ type="checkbox"
209
+ :checked="selectedIds.has(preset.id)"
210
+ @change="toggleSelect(preset.id)"
211
+ />
212
+ <span class="checkmark"></span>
213
+ </label>
214
+ </div>
215
+ <!-- 缩略图 - 卡片主体 -->
216
+ <div class="card-thumb" v-if="preset.thumbnail">
217
+ <img :src="preset.thumbnail" loading="lazy" />
218
+ <!-- 悬浮时显示的中央操作区 -->
219
+ <div class="thumb-overlay">
220
+ <div class="overlay-controls" @click.stop>
221
+ <label class="toggle-switch">
222
+ <input type="checkbox" v-model="preset.enabled" @change="handleToggle(preset)" />
223
+ <span class="toggle-slider"></span>
224
+ </label>
225
+ <button class="overlay-btn" title="复制为新预设" @click="handleCopy(preset)">📋</button>
226
+ <button
227
+ v-if="preset.source === 'user'"
228
+ class="overlay-btn danger"
229
+ title="删除"
230
+ @click="handleDelete(preset)"
231
+ >🗑️</button>
232
+ <button class="overlay-btn upload" v-if="preset.source === 'user'" title="上传到云端" @click="handleUpload(preset)">☁️</button>
233
+ </div>
234
+ </div>
235
+ <!-- 参考图数量 -->
236
+ <span v-if="preset.referenceImages?.length" class="ref-badge">
237
+ 🖼️ {{ preset.referenceImages.length }}
238
+ </span>
239
+ </div>
240
+ <div class="card-thumb empty" v-else>
241
+ <span class="empty-thumb-icon">🖼️</span>
242
+ <!-- 悬浮时显示的中央操作区 -->
243
+ <div class="thumb-overlay">
244
+ <div class="overlay-controls" @click.stop>
245
+ <label class="toggle-switch">
246
+ <input type="checkbox" v-model="preset.enabled" @change="handleToggle(preset)" />
247
+ <span class="toggle-slider"></span>
248
+ </label>
249
+ <button class="overlay-btn" title="复制为新预设" @click="handleCopy(preset)">📋</button>
250
+ <button
251
+ v-if="preset.source === 'user'"
252
+ class="overlay-btn danger"
253
+ title="删除"
254
+ @click="handleDelete(preset)"
255
+ >🗑️</button>
256
+ </div>
257
+ </div>
258
+ </div>
259
+
260
+ <!-- 紧凑底部:名称 + 标签 -->
261
+ <div class="card-info">
262
+ <div class="card-name">{{ preset.name }}</div>
263
+ <div class="card-tags" v-if="preset.tags?.length">
264
+ <span v-for="tag in preset.tags.slice(0, 3)" :key="tag" class="tag-item">{{ tag }}</span>
265
+ <span v-if="preset.tags.length > 3" class="tag-more">+{{ preset.tags.length - 3 }}</span>
266
+ </div>
267
+ </div>
268
+
269
+ <!-- 来源标记 -->
270
+ <div class="card-source" :class="preset.source">
271
+ {{ preset.source === 'api' ? '远程' : '本地' }}
272
+ </div>
273
+ </div>
274
+ </template>
275
+ </MasonryGrid>
276
+ </template>
277
+ </div>
278
+
279
+ <!-- 分页栏 -->
280
+ <div class="pagination-bar pop-card no-hover">
281
+ <div class="page-size-select">
282
+ <span class="page-size-label">每页</span>
283
+ <select v-model="pageSize" class="pop-select small" @change="page = 1">
284
+ <option :value="20">20</option>
285
+ <option :value="50">50</option>
286
+ <option :value="100">100</option>
287
+ </select>
288
+ <span class="page-size-label">条</span>
289
+ </div>
290
+ <div class="page-nav">
291
+ <button class="pop-btn small" :disabled="page <= 1" @click="page--">⬅️</button>
292
+ <span class="page-info">{{ page }} / {{ totalPages }}</span>
293
+ <button class="pop-btn small" :disabled="page >= totalPages" @click="page++">➡️</button>
294
+ </div>
295
+ <div class="page-total">共 {{ filteredPresets.length }} 条</div>
296
+ </div>
297
+
298
+ <!-- 编辑对话框 -->
299
+ <Teleport to="#ml-teleport-container" defer>
300
+ <div v-if="dialogVisible" class="modal-overlay" @click.self="dialogVisible = false">
301
+ <div class="modal-dialog pop-card no-hover">
302
+ <div class="modal-header">
303
+ <h3>{{ isEdit ? '编辑预设' : '新建预设' }}</h3>
304
+ <button class="modal-close" @click="dialogVisible = false">✕</button>
305
+ </div>
306
+ <div class="modal-body pop-scrollbar">
307
+ <div class="form-group">
308
+ <label class="form-label required">名称</label>
309
+ <input
310
+ v-model="form.name"
311
+ :disabled="isEdit && form.source === 'api'"
312
+ class="pop-input"
313
+ placeholder="预设名称"
314
+ />
315
+ </div>
316
+
317
+ <div class="form-group">
318
+ <label class="form-label required">Prompt 模板</label>
319
+ <textarea
320
+ v-model="form.promptTemplate"
321
+ class="pop-textarea"
322
+ rows="4"
323
+ placeholder="提示词模板,可用 {prompt} 指定用户输入位置"
324
+ ></textarea>
325
+ </div>
326
+
327
+ <div class="form-group" v-if="form.source === 'user'">
328
+ <label class="form-label">缩略图</label>
329
+ <ImageUpload v-model="thumbnailList" :max-count="1" />
330
+ </div>
331
+
332
+ <div class="form-group">
333
+ <label class="form-label">参考图</label>
334
+ <ImageUpload v-model="form.referenceImages!" :max-count="5" />
335
+ </div>
336
+
337
+ <div class="form-divider">
338
+ <span>高级设置</span>
339
+ </div>
340
+
341
+ <div class="form-group">
342
+ <label class="form-label">标签</label>
343
+ <TagInput v-model="form.tags!" placeholder="添加标签" />
344
+ </div>
345
+
346
+ <div class="form-group">
347
+ <label class="form-label">参数覆盖</label>
348
+ <JsonEditor v-model="form.parameterOverrides" :rows="3" />
349
+ </div>
350
+
351
+ <div class="form-group inline">
352
+ <label class="form-label">启用</label>
353
+ <label class="toggle-switch">
354
+ <input type="checkbox" v-model="form.enabled" />
355
+ <span class="toggle-slider"></span>
356
+ </label>
357
+ </div>
358
+ </div>
359
+ <div class="modal-footer">
360
+ <button class="pop-btn" @click="dialogVisible = false">取消</button>
361
+ <button class="pop-btn primary" @click="handleSubmit">保存</button>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ </Teleport>
366
+
367
+ <!-- 上传对话框 -->
368
+ <UploadDialog
369
+ v-if="uploadPreset"
370
+ v-model="uploadDialogVisible"
371
+ mode="preset"
372
+ :preset-data="{
373
+ name: uploadPreset.name,
374
+ promptTemplate: uploadPreset.promptTemplate,
375
+ thumbnail: uploadPreset.thumbnail,
376
+ tags: uploadPreset.tags,
377
+ referenceImages: uploadPreset.referenceImages
378
+ }"
379
+ @success="loadPresets"
380
+ />
381
+ </div>
382
+ </template>
383
+
384
+ <script setup lang="ts">
385
+ import { ref, computed, onMounted, watch } from 'vue'
386
+ import type { PresetData } from '../types'
387
+ import { presetApi } from '../api'
388
+ import TagInput from './TagInput.vue'
389
+ import JsonEditor from './JsonEditor.vue'
390
+ import ImageUpload from './ImageUpload.vue'
391
+ import MasonryGrid from './MasonryGrid.vue'
392
+ import UploadDialog from './UploadDialog.vue'
393
+
394
+ type ViewMode = 'list' | 'card'
395
+
396
+ // 预置标签
397
+ const presetTags = ['本地', '远程', 'text2img', 'img2img', 'NSFW']
398
+
399
+ // 视图状态
400
+ const viewMode = ref<ViewMode>('card')
401
+ const loading = ref(false)
402
+ const presets = ref<PresetData[]>([])
403
+ const selectedTags = ref<string[]>([])
404
+ const searchQuery = ref('')
405
+
406
+ // 批量管理
407
+ const batchMode = ref(false)
408
+ const selectedIds = ref<Set<string>>(new Set())
409
+
410
+ // 筛选
411
+ const filter = ref({
412
+ source: '',
413
+ enabled: undefined as boolean | undefined
414
+ })
415
+
416
+ // 分页
417
+ const page = ref(1)
418
+ const pageSize = ref(20)
419
+
420
+ // 对话框
421
+ const dialogVisible = ref(false)
422
+ const isEdit = ref(false)
423
+ const form = ref<Partial<PresetData>>({})
424
+ const thumbnailList = ref<string[]>([])
425
+
426
+ // 从预设中提取自定义标签(排除预置标签)
427
+ const customTags = computed(() => {
428
+ const tagSet = new Set<string>()
429
+ presets.value.forEach(p => {
430
+ (p.tags || []).forEach(t => {
431
+ if (!presetTags.includes(t)) tagSet.add(t)
432
+ })
433
+ })
434
+ return Array.from(tagSet).sort()
435
+ })
436
+
437
+ // 筛选后的数据
438
+ const filteredPresets = computed(() => {
439
+ const query = searchQuery.value.toLowerCase().trim()
440
+ return presets.value.filter(p => {
441
+ // 搜索筛选
442
+ if (query) {
443
+ const nameMatch = p.name.toLowerCase().includes(query)
444
+ const promptMatch = p.promptTemplate?.toLowerCase().includes(query)
445
+ if (!nameMatch && !promptMatch) return false
446
+ }
447
+ // 来源筛选
448
+ if (filter.value.source && p.source !== filter.value.source) return false
449
+ // 状态筛选
450
+ if (filter.value.enabled !== undefined && p.enabled !== filter.value.enabled) return false
451
+ // 标签筛选
452
+ if (selectedTags.value.length > 0) {
453
+ const match = selectedTags.value.every(tag => {
454
+ if (tag === '本地') return p.source === 'user'
455
+ if (tag === '远程') return p.source === 'api'
456
+ return (p.tags || []).includes(tag)
457
+ })
458
+ if (!match) return false
459
+ }
460
+ return true
461
+ })
462
+ })
463
+
464
+ // 总页数
465
+ const totalPages = computed(() => Math.max(1, Math.ceil(filteredPresets.value.length / pageSize.value)))
466
+
467
+ // 当前页数据
468
+ const displayPresets = computed(() => {
469
+ const start = (page.value - 1) * pageSize.value
470
+ return filteredPresets.value.slice(start, start + pageSize.value)
471
+ })
472
+
473
+ // 缩略图同步
474
+ watch(() => form.value.thumbnail, val => {
475
+ thumbnailList.value = val ? [val] : []
476
+ }, { immediate: true })
477
+
478
+ watch(thumbnailList, val => {
479
+ form.value.thumbnail = val[0] || ''
480
+ })
481
+
482
+ // 方法
483
+ const fetchData = async () => {
484
+ loading.value = true
485
+ try {
486
+ presets.value = await presetApi.list()
487
+ } catch {
488
+ alert('加载失败')
489
+ } finally {
490
+ loading.value = false
491
+ }
492
+ }
493
+
494
+ const truncate = (text: string, len: number) => {
495
+ if (!text) return '-'
496
+ const s = text.replace(/\s+/g, ' ').trim()
497
+ return s.length > len ? s.slice(0, len) + '...' : s
498
+ }
499
+
500
+ const openCreateDialog = () => {
501
+ isEdit.value = false
502
+ form.value = {
503
+ name: '',
504
+ promptTemplate: '',
505
+ referenceImages: [],
506
+ tags: ['text2img', 'img2img'],
507
+ parameterOverrides: {},
508
+ enabled: true,
509
+ source: 'user'
510
+ }
511
+ dialogVisible.value = true
512
+ }
513
+
514
+ const openEditDialog = (preset: PresetData) => {
515
+ isEdit.value = true
516
+ form.value = JSON.parse(JSON.stringify(preset))
517
+ dialogVisible.value = true
518
+ }
519
+
520
+ const handleRowClick = (row: PresetData) => openEditDialog(row)
521
+
522
+ const handleToggle = async (preset: PresetData) => {
523
+ try {
524
+ await presetApi.toggle(preset.id, preset.enabled)
525
+ } catch {
526
+ preset.enabled = !preset.enabled
527
+ alert('操作失败')
528
+ }
529
+ }
530
+
531
+ const handleCopy = (preset: PresetData) => {
532
+ isEdit.value = false
533
+ const copy = JSON.parse(JSON.stringify(preset))
534
+ delete copy.id
535
+ copy.name = `${preset.name} (副本)`
536
+ copy.source = 'user'
537
+ form.value = copy
538
+ dialogVisible.value = true
539
+ }
540
+
541
+ const handleDelete = async (preset: PresetData) => {
542
+ if (!confirm(`确定删除预设 "${preset.name}"?`)) return
543
+ try {
544
+ await presetApi.delete(preset.id)
545
+ alert('已删除')
546
+ fetchData()
547
+ } catch {
548
+ alert('删除失败')
549
+ }
550
+ }
551
+
552
+ // ============ 批量管理 ============
553
+ // 是否全选当前页
554
+ const isAllSelected = computed(() => {
555
+ if (displayPresets.value.length === 0) return false
556
+ return displayPresets.value.every(p => selectedIds.value.has(p.id))
557
+ })
558
+
559
+ // 是否部分选中
560
+ const isPartialSelected = computed(() => {
561
+ if (displayPresets.value.length === 0) return false
562
+ const selected = displayPresets.value.filter(p => selectedIds.value.has(p.id)).length
563
+ return selected > 0 && selected < displayPresets.value.length
564
+ })
565
+
566
+ // 选中的预设中是否有可删除的(本地预设)
567
+ const canBatchDelete = computed(() => {
568
+ return presets.value.some(p => selectedIds.value.has(p.id) && p.source === 'user')
569
+ })
570
+
571
+ // 进入批量管理模式
572
+ const enterBatchMode = () => {
573
+ batchMode.value = true
574
+ selectedIds.value = new Set()
575
+ }
576
+
577
+ // 退出批量管理模式
578
+ const exitBatchMode = () => {
579
+ batchMode.value = false
580
+ selectedIds.value = new Set()
581
+ }
582
+
583
+ // 切换单个选择
584
+ const toggleSelect = (id: string) => {
585
+ const newSet = new Set(selectedIds.value)
586
+ if (newSet.has(id)) {
587
+ newSet.delete(id)
588
+ } else {
589
+ newSet.add(id)
590
+ }
591
+ selectedIds.value = newSet
592
+ }
593
+
594
+ // 全选/取消全选当前页
595
+ const toggleSelectAll = () => {
596
+ const newSet = new Set(selectedIds.value)
597
+ if (isAllSelected.value) {
598
+ // 取消全选当前页
599
+ displayPresets.value.forEach(p => newSet.delete(p.id))
600
+ } else {
601
+ // 全选当前页
602
+ displayPresets.value.forEach(p => newSet.add(p.id))
603
+ }
604
+ selectedIds.value = newSet
605
+ }
606
+
607
+ // 批量切换启用状态
608
+ const batchToggle = async (enabled: boolean) => {
609
+ const ids = Array.from(selectedIds.value)
610
+ if (ids.length === 0) return
611
+
612
+ const action = enabled ? '启用' : '禁用'
613
+ if (!confirm(`确定${action} ${ids.length} 个预设?`)) return
614
+
615
+ try {
616
+ let successCount = 0
617
+ for (const id of ids) {
618
+ try {
619
+ await presetApi.toggle(id, enabled)
620
+ successCount++
621
+ } catch (e) {
622
+ console.error(`Failed to toggle preset ${id}:`, e)
623
+ }
624
+ }
625
+ alert(`已${action} ${successCount} 个预设`)
626
+ fetchData()
627
+ exitBatchMode()
628
+ } catch {
629
+ alert('操作失败')
630
+ }
631
+ }
632
+
633
+ // 批量删除(仅本地预设)
634
+ const batchDelete = async () => {
635
+ const ids = Array.from(selectedIds.value)
636
+ const deletablePresets = presets.value.filter(p => ids.includes(p.id) && p.source === 'user')
637
+
638
+ if (deletablePresets.length === 0) {
639
+ alert('没有可删除的本地预设')
640
+ return
641
+ }
642
+
643
+ if (!confirm(`确定删除 ${deletablePresets.length} 个本地预设?(远程预设不会被删除)`)) return
644
+
645
+ try {
646
+ let successCount = 0
647
+ for (const preset of deletablePresets) {
648
+ try {
649
+ await presetApi.delete(preset.id)
650
+ successCount++
651
+ } catch (e) {
652
+ console.error(`Failed to delete preset ${preset.id}:`, e)
653
+ }
654
+ }
655
+ alert(`已删除 ${successCount} 个预设`)
656
+ fetchData()
657
+ exitBatchMode()
658
+ } catch {
659
+ alert('删除失败')
660
+ }
661
+ }
662
+
663
+ // 上传相关
664
+ const uploadDialogVisible = ref(false)
665
+ const uploadPreset = ref<PresetData | null>(null)
666
+
667
+ const handleUpload = (preset: PresetData) => {
668
+ // 需要有缩略图才能上传
669
+ if (!preset.thumbnail) {
670
+ alert('预设没有缩略图,无法上传')
671
+ return
672
+ }
673
+ uploadPreset.value = preset
674
+ uploadDialogVisible.value = true
675
+ }
676
+
677
+ const handleSubmit = async () => {
678
+ if (!form.value.name || !form.value.promptTemplate) {
679
+ alert('请填写必要信息')
680
+ return
681
+ }
682
+ try {
683
+ if (isEdit.value && form.value.id) {
684
+ await presetApi.update(form.value.id, form.value)
685
+ alert('已保存')
686
+ } else {
687
+ await presetApi.create(form.value as Omit<PresetData, 'id'>)
688
+ alert('已创建')
689
+ }
690
+ dialogVisible.value = false
691
+ fetchData()
692
+ } catch {
693
+ alert('保存失败')
694
+ }
695
+ }
696
+
697
+ onMounted(fetchData)
698
+ </script>
699
+
700
+ <style lang="scss">
701
+ @use '../styles/theme.scss';
702
+ </style>
703
+
704
+ <style scoped lang="scss">
705
+ /* ============ 视图容器 ============ */
706
+ .presets-view {
707
+ height: 100%;
708
+ display: flex;
709
+ flex-direction: column;
710
+ min-height: 0;
711
+ gap: 16px;
712
+ overflow: hidden; /* 视图本身不滚动 */
713
+ }
714
+
715
+ /* ============ 紧凑工具栏 ============ */
716
+ .compact-toolbar {
717
+ flex-shrink: 0;
718
+ display: flex;
719
+ justify-content: space-between;
720
+ align-items: center;
721
+ padding: 12px 16px;
722
+ gap: 12px;
723
+ }
724
+
725
+ .toolbar-left {
726
+ display: flex;
727
+ align-items: center;
728
+ gap: 12px;
729
+ flex-wrap: wrap;
730
+ }
731
+
732
+ .toolbar-right {
733
+ display: flex;
734
+ align-items: center;
735
+ gap: 8px;
736
+ }
737
+
738
+ .filter-divider {
739
+ width: 2px;
740
+ height: 20px;
741
+ background: var(--ml-border-color);
742
+ border-radius: 1px;
743
+ }
744
+
745
+ .result-count {
746
+ font-size: 13px;
747
+ font-weight: 600;
748
+ color: var(--ml-text-muted);
749
+ white-space: nowrap;
750
+ }
751
+
752
+ /* ============ 搜索框 ============ */
753
+ .search-box {
754
+ position: relative;
755
+ display: flex;
756
+ align-items: center;
757
+ }
758
+
759
+ .search-icon {
760
+ position: absolute;
761
+ left: 10px;
762
+ font-size: 14px;
763
+ pointer-events: none;
764
+ z-index: 1;
765
+ }
766
+
767
+ .search-input {
768
+ padding-left: 32px !important;
769
+ padding-right: 28px !important;
770
+ width: 180px;
771
+ }
772
+
773
+ .search-clear {
774
+ position: absolute;
775
+ right: 6px;
776
+ display: flex;
777
+ align-items: center;
778
+ justify-content: center;
779
+ width: 20px;
780
+ height: 20px;
781
+ border: none;
782
+ background: var(--ml-bg-alt);
783
+ color: var(--ml-text-muted);
784
+ border-radius: 50%;
785
+ cursor: pointer;
786
+ font-size: 10px;
787
+ transition: all 0.15s;
788
+ }
789
+
790
+ .search-clear:hover {
791
+ background: var(--ml-danger);
792
+ color: white;
793
+ }
794
+
795
+ /* ============ 批量管理 ============ */
796
+ .batch-info {
797
+ font-size: 13px;
798
+ font-weight: 700;
799
+ color: var(--ml-primary-dark);
800
+ white-space: nowrap;
801
+ }
802
+
803
+ .batch-checkbox {
804
+ position: relative;
805
+ display: inline-flex;
806
+ align-items: center;
807
+ justify-content: center;
808
+ width: 20px;
809
+ height: 20px;
810
+ cursor: pointer;
811
+ }
812
+
813
+ .batch-checkbox input {
814
+ position: absolute;
815
+ opacity: 0;
816
+ width: 100%;
817
+ height: 100%;
818
+ margin: 0;
819
+ cursor: pointer;
820
+ }
821
+
822
+ .batch-checkbox .checkmark {
823
+ width: 18px;
824
+ height: 18px;
825
+ background: var(--ml-surface);
826
+ border: 2px solid var(--ml-border-color);
827
+ border-radius: 4px;
828
+ display: flex;
829
+ align-items: center;
830
+ justify-content: center;
831
+ transition: all 0.15s;
832
+ }
833
+
834
+ .batch-checkbox input:checked + .checkmark {
835
+ background: var(--ml-primary);
836
+ border-color: var(--ml-primary-dark);
837
+ }
838
+
839
+ .batch-checkbox input:checked + .checkmark::after {
840
+ content: "✓";
841
+ font-size: 12px;
842
+ font-weight: 700;
843
+ color: var(--ml-text);
844
+ }
845
+
846
+ .batch-checkbox input:indeterminate + .checkmark {
847
+ background: var(--ml-primary-light);
848
+ border-color: var(--ml-primary);
849
+ }
850
+
851
+ .batch-checkbox input:indeterminate + .checkmark::after {
852
+ content: "−";
853
+ font-size: 14px;
854
+ font-weight: 700;
855
+ color: var(--ml-text);
856
+ }
857
+
858
+ /* 卡片视图批量选择 */
859
+ .card-checkbox {
860
+ position: absolute;
861
+ top: 8px;
862
+ right: 8px;
863
+ z-index: 10;
864
+ background: rgba(255, 255, 255, 0.9);
865
+ border-radius: 6px;
866
+ padding: 4px;
867
+ border: 2px solid var(--ml-border-color);
868
+ }
869
+
870
+ .preset-card.batch-selected {
871
+ border-color: var(--ml-primary);
872
+ box-shadow: 0 0 0 3px var(--ml-primary-light), var(--ml-shadow);
873
+ }
874
+
875
+ .btn-group {
876
+ display: flex;
877
+ background: var(--ml-bg-alt);
878
+ border: var(--ml-border);
879
+ border-radius: var(--ml-radius);
880
+ padding: 4px;
881
+ gap: 4px;
882
+ }
883
+
884
+ .group-btn {
885
+ display: flex;
886
+ align-items: center;
887
+ justify-content: center;
888
+ padding: 6px 10px;
889
+ border: none;
890
+ background: transparent;
891
+ color: var(--ml-text-muted);
892
+ cursor: pointer;
893
+ border-radius: calc(var(--ml-radius) - 4px);
894
+ font-size: 14px;
895
+ transition: all 0.15s;
896
+ }
897
+
898
+ .group-btn:hover {
899
+ color: var(--ml-text);
900
+ background: var(--ml-bg);
901
+ }
902
+
903
+ .group-btn.active {
904
+ color: var(--ml-text);
905
+ background: var(--ml-primary);
906
+ box-shadow: var(--ml-shadow-sm);
907
+ }
908
+
909
+ /* ============ 内容区域 ============ */
910
+ .view-content {
911
+ flex: 1;
912
+ min-height: 0;
913
+ overflow-y: auto;
914
+ overflow-x: hidden;
915
+ padding: 4px;
916
+ /* 隐藏式滚动条 */
917
+ scrollbar-width: thin;
918
+ scrollbar-color: transparent transparent;
919
+ }
920
+
921
+ .view-content:hover {
922
+ scrollbar-color: var(--ml-border-color) transparent;
923
+ }
924
+
925
+ .view-content::-webkit-scrollbar {
926
+ width: 6px;
927
+ }
928
+
929
+ .view-content::-webkit-scrollbar-track {
930
+ background: transparent;
931
+ }
932
+
933
+ .view-content::-webkit-scrollbar-thumb {
934
+ background-color: transparent;
935
+ border-radius: 3px;
936
+ }
937
+
938
+ .view-content:hover::-webkit-scrollbar-thumb {
939
+ background-color: var(--ml-border-color);
940
+ }
941
+
942
+ /* ============ 列表表格 ============ */
943
+ .list-table {
944
+ overflow: hidden;
945
+ }
946
+
947
+ .preset-table {
948
+ width: 100%;
949
+ border-collapse: collapse;
950
+ table-layout: fixed;
951
+ }
952
+
953
+ .preset-table thead {
954
+ background: var(--ml-bg-alt);
955
+ }
956
+
957
+ .preset-table th {
958
+ padding: 12px 16px;
959
+ font-size: 12px;
960
+ font-weight: 700;
961
+ color: var(--ml-text-muted);
962
+ text-align: left;
963
+ text-transform: uppercase;
964
+ letter-spacing: 0.05em;
965
+ border-bottom: var(--ml-border);
966
+ }
967
+
968
+ .preset-table tbody tr {
969
+ cursor: pointer;
970
+ transition: background-color 0.15s;
971
+ }
972
+
973
+ .preset-table tbody tr:hover {
974
+ background: var(--ml-primary-light);
975
+ }
976
+
977
+ .preset-table td {
978
+ padding: 12px 16px;
979
+ border-bottom: 2px solid var(--ml-border-color);
980
+ vertical-align: middle;
981
+ }
982
+
983
+ .preset-table tbody tr:last-child td {
984
+ border-bottom: none;
985
+ }
986
+
987
+ .thumb-cell {
988
+ width: 40px;
989
+ height: 40px;
990
+ border-radius: 8px;
991
+ overflow: hidden;
992
+ background: var(--ml-bg-alt);
993
+ border: 2px solid var(--ml-border-color);
994
+ }
995
+
996
+ .thumb-img {
997
+ width: 100%;
998
+ height: 100%;
999
+ object-fit: cover;
1000
+ }
1001
+
1002
+ .thumb-empty {
1003
+ width: 100%;
1004
+ height: 100%;
1005
+ display: flex;
1006
+ align-items: center;
1007
+ justify-content: center;
1008
+ font-size: 16px;
1009
+ opacity: 0.5;
1010
+ }
1011
+
1012
+ .preset-name {
1013
+ font-weight: 700;
1014
+ color: var(--ml-text);
1015
+ }
1016
+
1017
+ .prompt-cell {
1018
+ color: var(--ml-text-muted);
1019
+ font-size: 13px;
1020
+ overflow: hidden;
1021
+ text-overflow: ellipsis;
1022
+ white-space: nowrap;
1023
+ }
1024
+
1025
+ .badge-count {
1026
+ display: inline-flex;
1027
+ align-items: center;
1028
+ justify-content: center;
1029
+ min-width: 24px;
1030
+ height: 24px;
1031
+ padding: 0 8px;
1032
+ background: var(--ml-primary);
1033
+ border: 2px solid var(--ml-border-color);
1034
+ border-radius: 12px;
1035
+ font-size: 12px;
1036
+ font-weight: 700;
1037
+ color: var(--ml-text);
1038
+ }
1039
+
1040
+ .source-tag {
1041
+ font-size: 11px;
1042
+ padding: 4px 8px;
1043
+ border-radius: 8px;
1044
+ font-weight: 700;
1045
+ border: 2px solid var(--ml-border-color);
1046
+ }
1047
+
1048
+ .source-tag.api {
1049
+ background: var(--ml-info);
1050
+ color: white;
1051
+ }
1052
+
1053
+ .source-tag.user {
1054
+ background: var(--ml-warning);
1055
+ color: var(--ml-text);
1056
+ }
1057
+
1058
+ .text-muted {
1059
+ color: var(--ml-text-muted);
1060
+ opacity: 0.5;
1061
+ }
1062
+
1063
+ .action-btn {
1064
+ display: inline-flex;
1065
+ align-items: center;
1066
+ justify-content: center;
1067
+ width: 28px;
1068
+ height: 28px;
1069
+ border-radius: 8px;
1070
+ cursor: pointer;
1071
+ font-size: 14px;
1072
+ transition: all 0.15s;
1073
+ }
1074
+
1075
+ .action-btn:hover {
1076
+ transform: scale(1.15);
1077
+ }
1078
+
1079
+ .action-btn.delete:hover {
1080
+ background: var(--ml-danger);
1081
+ }
1082
+
1083
+ .action-btn.upload:hover {
1084
+ background: var(--ml-info);
1085
+ }
1086
+
1087
+ /* ============ 分页栏 ============ */
1088
+ .pagination-bar {
1089
+ flex-shrink: 0;
1090
+ display: flex;
1091
+ align-items: center;
1092
+ justify-content: center;
1093
+ gap: 24px;
1094
+ padding: 12px 16px;
1095
+ }
1096
+
1097
+ .page-size-select {
1098
+ display: flex;
1099
+ align-items: center;
1100
+ gap: 8px;
1101
+ }
1102
+
1103
+ .page-size-label {
1104
+ font-size: 13px;
1105
+ font-weight: 600;
1106
+ color: var(--ml-text-muted);
1107
+ }
1108
+
1109
+ .page-nav {
1110
+ display: flex;
1111
+ align-items: center;
1112
+ gap: 12px;
1113
+ }
1114
+
1115
+ .page-info {
1116
+ font-size: 14px;
1117
+ font-weight: 700;
1118
+ color: var(--ml-text);
1119
+ min-width: 60px;
1120
+ text-align: center;
1121
+ }
1122
+
1123
+ .page-total {
1124
+ font-size: 13px;
1125
+ font-weight: 600;
1126
+ color: var(--ml-text-muted);
1127
+ }
1128
+
1129
+ /* ============ 卡片视图 ============ */
1130
+ .empty-view {
1131
+ display: flex;
1132
+ flex-direction: column;
1133
+ align-items: center;
1134
+ justify-content: center;
1135
+ padding: 64px;
1136
+ color: var(--ml-text-muted);
1137
+ }
1138
+
1139
+ .empty-icon {
1140
+ font-size: 48px;
1141
+ margin-bottom: 16px;
1142
+ opacity: 0.5;
1143
+ }
1144
+
1145
+ .preset-card {
1146
+ cursor: pointer;
1147
+ position: relative;
1148
+ overflow: hidden;
1149
+ transition: all 0.2s;
1150
+ }
1151
+
1152
+ .preset-card:hover {
1153
+ transform: translateY(-4px);
1154
+ }
1155
+
1156
+ /* 缩略图区域 */
1157
+ .card-thumb {
1158
+ width: 100%;
1159
+ position: relative;
1160
+ overflow: hidden;
1161
+ background: var(--ml-bg-alt);
1162
+ }
1163
+
1164
+ .card-thumb img {
1165
+ width: 100%;
1166
+ height: auto;
1167
+ display: block;
1168
+ transition: transform 0.3s;
1169
+ }
1170
+
1171
+ .preset-card:hover .card-thumb img {
1172
+ transform: scale(1.05);
1173
+ }
1174
+
1175
+ .card-thumb.empty {
1176
+ aspect-ratio: 4/3;
1177
+ display: flex;
1178
+ align-items: center;
1179
+ justify-content: center;
1180
+ }
1181
+
1182
+ .empty-thumb-icon {
1183
+ font-size: 32px;
1184
+ opacity: 0.3;
1185
+ }
1186
+
1187
+ /* 悬浮遮罩层 */
1188
+ .thumb-overlay {
1189
+ position: absolute;
1190
+ inset: 0;
1191
+ background: rgba(0, 0, 0, 0.6);
1192
+ display: flex;
1193
+ align-items: center;
1194
+ justify-content: center;
1195
+ opacity: 0;
1196
+ transition: opacity 0.2s;
1197
+ }
1198
+
1199
+ .preset-card:hover .thumb-overlay {
1200
+ opacity: 1;
1201
+ }
1202
+
1203
+ .overlay-controls {
1204
+ display: flex;
1205
+ align-items: center;
1206
+ gap: 12px;
1207
+ padding: 10px 18px;
1208
+ background: rgba(0, 0, 0, 0.7);
1209
+ border-radius: 24px;
1210
+ backdrop-filter: blur(4px);
1211
+ }
1212
+
1213
+ .overlay-btn {
1214
+ display: flex;
1215
+ align-items: center;
1216
+ justify-content: center;
1217
+ width: 32px;
1218
+ height: 32px;
1219
+ border: none;
1220
+ border-radius: 50%;
1221
+ background: rgba(255, 255, 255, 0.2);
1222
+ color: white;
1223
+ cursor: pointer;
1224
+ font-size: 14px;
1225
+ transition: all 0.15s;
1226
+ }
1227
+
1228
+ .overlay-btn:hover {
1229
+ background: rgba(255, 255, 255, 0.4);
1230
+ transform: scale(1.1);
1231
+ }
1232
+
1233
+ .overlay-btn.danger:hover {
1234
+ background: var(--ml-danger);
1235
+ }
1236
+
1237
+ .overlay-btn.upload:hover {
1238
+ background: var(--ml-info);
1239
+ }
1240
+
1241
+ /* 参考图徽章 */
1242
+ .ref-badge {
1243
+ position: absolute;
1244
+ bottom: 8px;
1245
+ right: 8px;
1246
+ display: flex;
1247
+ align-items: center;
1248
+ gap: 4px;
1249
+ padding: 4px 8px;
1250
+ background: rgba(0, 0, 0, 0.7);
1251
+ color: white;
1252
+ font-size: 11px;
1253
+ font-weight: 700;
1254
+ border-radius: 8px;
1255
+ }
1256
+
1257
+ /* 紧凑底部 */
1258
+ .card-info {
1259
+ padding: 10px 12px;
1260
+ }
1261
+
1262
+ .card-name {
1263
+ font-size: 13px;
1264
+ font-weight: 700;
1265
+ color: var(--ml-text);
1266
+ overflow: hidden;
1267
+ text-overflow: ellipsis;
1268
+ white-space: nowrap;
1269
+ margin-bottom: 6px;
1270
+ }
1271
+
1272
+ .card-tags {
1273
+ display: flex;
1274
+ flex-wrap: wrap;
1275
+ gap: 4px;
1276
+ }
1277
+
1278
+ .tag-item {
1279
+ font-size: 10px;
1280
+ padding: 2px 6px;
1281
+ background: var(--ml-bg-alt);
1282
+ color: var(--ml-text-muted);
1283
+ border: 1px solid var(--ml-border-color);
1284
+ border-radius: 6px;
1285
+ font-weight: 600;
1286
+ }
1287
+
1288
+ .tag-more {
1289
+ font-size: 10px;
1290
+ padding: 2px 6px;
1291
+ background: var(--ml-bg-alt);
1292
+ color: var(--ml-text-muted);
1293
+ border: 1px solid var(--ml-border-color);
1294
+ border-radius: 6px;
1295
+ font-weight: 700;
1296
+ }
1297
+
1298
+ /* 来源标记 */
1299
+ .card-source {
1300
+ position: absolute;
1301
+ top: 8px;
1302
+ left: 8px;
1303
+ font-size: 10px;
1304
+ padding: 3px 8px;
1305
+ border-radius: 6px;
1306
+ font-weight: 700;
1307
+ border: 2px solid rgba(255, 255, 255, 0.3);
1308
+ }
1309
+
1310
+ .card-source.api {
1311
+ background: var(--ml-info);
1312
+ color: white;
1313
+ }
1314
+
1315
+ .card-source.user {
1316
+ background: var(--ml-warning);
1317
+ color: var(--ml-text);
1318
+ }
1319
+
1320
+ /* ============ Toggle Switch ============ */
1321
+ .toggle-switch {
1322
+ position: relative;
1323
+ display: inline-block;
1324
+ width: 44px;
1325
+ height: 24px;
1326
+ }
1327
+
1328
+ .toggle-switch input {
1329
+ opacity: 0;
1330
+ width: 0;
1331
+ height: 0;
1332
+ }
1333
+
1334
+ .toggle-slider {
1335
+ position: absolute;
1336
+ cursor: pointer;
1337
+ top: 0;
1338
+ left: 0;
1339
+ right: 0;
1340
+ bottom: 0;
1341
+ background-color: var(--ml-bg-alt);
1342
+ border: 2px solid var(--ml-border-color);
1343
+ border-radius: 24px;
1344
+ transition: 0.2s;
1345
+ }
1346
+
1347
+ .toggle-slider::before {
1348
+ position: absolute;
1349
+ content: "";
1350
+ height: 16px;
1351
+ width: 16px;
1352
+ left: 2px;
1353
+ bottom: 2px;
1354
+ background-color: var(--ml-border-color);
1355
+ border-radius: 50%;
1356
+ transition: 0.2s;
1357
+ }
1358
+
1359
+ .toggle-switch input:checked + .toggle-slider {
1360
+ background-color: var(--ml-success);
1361
+ border-color: var(--ml-success-dark);
1362
+ }
1363
+
1364
+ .toggle-switch input:checked + .toggle-slider::before {
1365
+ transform: translateX(20px);
1366
+ background-color: white;
1367
+ }
1368
+
1369
+ /* ============ 模态框 ============ */
1370
+ .modal-overlay {
1371
+ position: fixed;
1372
+ inset: 0;
1373
+ background: rgba(0, 0, 0, 0.5);
1374
+ display: flex;
1375
+ align-items: center;
1376
+ justify-content: center;
1377
+ z-index: 1000;
1378
+ padding: 24px;
1379
+ }
1380
+
1381
+ .modal-dialog {
1382
+ width: 100%;
1383
+ max-width: 600px;
1384
+ max-height: 90vh;
1385
+ display: flex;
1386
+ flex-direction: column;
1387
+ animation: modal-pop 0.2s ease-out;
1388
+ }
1389
+
1390
+ @keyframes modal-pop {
1391
+ from {
1392
+ opacity: 0;
1393
+ transform: scale(0.95) translateY(-10px);
1394
+ }
1395
+ to {
1396
+ opacity: 1;
1397
+ transform: scale(1) translateY(0);
1398
+ }
1399
+ }
1400
+
1401
+ .modal-header {
1402
+ flex-shrink: 0;
1403
+ display: flex;
1404
+ justify-content: space-between;
1405
+ align-items: center;
1406
+ padding: 16px 20px;
1407
+ border-bottom: var(--ml-border);
1408
+ }
1409
+
1410
+ .modal-header h3 {
1411
+ margin: 0;
1412
+ font-size: 18px;
1413
+ font-weight: 800;
1414
+ color: var(--ml-text);
1415
+ }
1416
+
1417
+ .modal-close {
1418
+ display: flex;
1419
+ align-items: center;
1420
+ justify-content: center;
1421
+ width: 32px;
1422
+ height: 32px;
1423
+ border: none;
1424
+ background: transparent;
1425
+ color: var(--ml-text-muted);
1426
+ cursor: pointer;
1427
+ font-size: 18px;
1428
+ border-radius: 8px;
1429
+ transition: all 0.15s;
1430
+ }
1431
+
1432
+ .modal-close:hover {
1433
+ background: var(--ml-danger);
1434
+ color: white;
1435
+ }
1436
+
1437
+ .modal-body {
1438
+ flex: 1;
1439
+ min-height: 0;
1440
+ overflow-y: auto;
1441
+ padding: 20px;
1442
+ /* 隐藏式滚动条 */
1443
+ scrollbar-width: thin;
1444
+ scrollbar-color: transparent transparent;
1445
+ }
1446
+
1447
+ .modal-body:hover {
1448
+ scrollbar-color: var(--ml-border-color) transparent;
1449
+ }
1450
+
1451
+ .modal-body::-webkit-scrollbar {
1452
+ width: 6px;
1453
+ }
1454
+
1455
+ .modal-body::-webkit-scrollbar-track {
1456
+ background: transparent;
1457
+ }
1458
+
1459
+ .modal-body::-webkit-scrollbar-thumb {
1460
+ background-color: transparent;
1461
+ border-radius: 3px;
1462
+ }
1463
+
1464
+ .modal-body:hover::-webkit-scrollbar-thumb {
1465
+ background-color: var(--ml-border-color);
1466
+ }
1467
+
1468
+ .modal-footer {
1469
+ flex-shrink: 0;
1470
+ display: flex;
1471
+ justify-content: flex-end;
1472
+ gap: 12px;
1473
+ padding: 16px 20px;
1474
+ border-top: var(--ml-border);
1475
+ }
1476
+
1477
+ /* ============ 表单 ============ */
1478
+ .form-group {
1479
+ margin-bottom: 20px;
1480
+ }
1481
+
1482
+ .form-group.inline {
1483
+ display: flex;
1484
+ align-items: center;
1485
+ gap: 16px;
1486
+ }
1487
+
1488
+ .form-label {
1489
+ display: block;
1490
+ font-size: 14px;
1491
+ font-weight: 700;
1492
+ color: var(--ml-text);
1493
+ margin-bottom: 8px;
1494
+ }
1495
+
1496
+ .form-group.inline .form-label {
1497
+ margin-bottom: 0;
1498
+ }
1499
+
1500
+ .form-label.required::after {
1501
+ content: " *";
1502
+ color: var(--ml-danger);
1503
+ }
1504
+
1505
+ .form-divider {
1506
+ display: flex;
1507
+ align-items: center;
1508
+ margin: 24px 0;
1509
+ gap: 16px;
1510
+ }
1511
+
1512
+ .form-divider::before,
1513
+ .form-divider::after {
1514
+ content: "";
1515
+ flex: 1;
1516
+ height: 2px;
1517
+ background: var(--ml-border-color);
1518
+ }
1519
+
1520
+ .form-divider span {
1521
+ font-size: 12px;
1522
+ font-weight: 700;
1523
+ color: var(--ml-text-muted);
1524
+ text-transform: uppercase;
1525
+ letter-spacing: 0.05em;
1526
+ }
1527
+ </style>