koishi-plugin-media-luna 0.0.5 → 0.0.7

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,738 @@
1
+ <template>
2
+ <div class="preset-picker">
3
+ <!-- 当前选中的预设显示 -->
4
+ <div class="selected-preset" @click="openPicker">
5
+ <div v-if="selectedPreset" class="preset-info">
6
+ <img v-if="selectedPreset.thumbnail" :src="selectedPreset.thumbnail" class="preset-thumb" />
7
+ <div v-else class="preset-thumb placeholder">
8
+ <k-icon name="image"></k-icon>
9
+ </div>
10
+ <div class="preset-details">
11
+ <span class="preset-name">{{ selectedPreset.name }}</span>
12
+ <span class="preset-source" :class="selectedPreset.source">
13
+ {{ selectedPreset.source === 'api' ? '远程' : '本地' }}
14
+ </span>
15
+ </div>
16
+ <k-icon name="times" class="clear-btn" @click.stop="clearSelection" title="清除选择"></k-icon>
17
+ </div>
18
+ <div v-else class="placeholder-text">
19
+ <k-icon name="layer-group"></k-icon>
20
+ <span>选择预设模板 (可选)</span>
21
+ </div>
22
+ <k-icon name="chevron-down" class="dropdown-icon"></k-icon>
23
+ </div>
24
+
25
+ <!-- 预设选择弹窗 - 复用 PresetsView 的布局 -->
26
+ <el-dialog
27
+ v-model="pickerVisible"
28
+ title="选择预设"
29
+ width="80vw"
30
+ top="5vh"
31
+ :close-on-click-modal="true"
32
+ class="preset-picker-dialog"
33
+ >
34
+ <!-- 搜索和筛选 - 复用 PresetsView 的筛选样式 -->
35
+ <div class="picker-header">
36
+ <TagFilter
37
+ v-model="selectedTags"
38
+ :all-tags="allTags"
39
+ :preset-tags="presetTags"
40
+ />
41
+ <span class="preset-count">共 {{ filteredPresets.length }} 个预设</span>
42
+ </div>
43
+
44
+ <!-- 预设卡片网格 - 瀑布流布局,复用 PresetsView 的结构 -->
45
+ <div class="picker-content">
46
+ <div class="ml-masonry">
47
+ <div v-for="preset in filteredPresets" :key="preset.id" class="ml-masonry-item">
48
+ <div
49
+ class="preset-card"
50
+ :class="{ selected: tempSelectedId === preset.id }"
51
+ @click="selectPreset(preset)"
52
+ >
53
+ <!-- 缩略图 -->
54
+ <div class="card-thumbnail" v-if="preset.thumbnail">
55
+ <img :src="preset.thumbnail" :alt="preset.name" loading="lazy" />
56
+ <div class="thumbnail-overlay">
57
+ <span class="ref-count" v-if="preset.referenceImages?.length">
58
+ <k-icon name="image"></k-icon> {{ preset.referenceImages.length }}
59
+ </span>
60
+ </div>
61
+ </div>
62
+ <div class="card-thumbnail placeholder" v-else>
63
+ <k-icon name="image" class="placeholder-icon"></k-icon>
64
+ </div>
65
+
66
+ <!-- 内容 -->
67
+ <div class="card-content">
68
+ <div class="card-title">{{ preset.name }}</div>
69
+ <div class="card-tags" v-if="preset.tags?.length">
70
+ <span v-for="tag in preset.tags.slice(0, 4)" :key="tag" class="mini-tag">
71
+ {{ tag }}
72
+ </span>
73
+ <span v-if="preset.tags.length > 4" class="mini-tag more">+{{ preset.tags.length - 4 }}</span>
74
+ </div>
75
+ </div>
76
+
77
+ <!-- 操作按钮区 -->
78
+ <div class="card-actions" @click.stop>
79
+ <k-button size="mini" @click="viewDetail(preset)">
80
+ <template #icon><k-icon name="eye"></k-icon></template>
81
+ 详情
82
+ </k-button>
83
+ </div>
84
+
85
+ <!-- 来源标记 -->
86
+ <div class="source-indicator" :class="preset.source">
87
+ {{ preset.source === 'api' ? '远程' : '本地' }}
88
+ </div>
89
+
90
+ <!-- 选中标记 -->
91
+ <div v-if="tempSelectedId === preset.id" class="selected-mark">
92
+ <k-icon name="check"></k-icon>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+
98
+ <!-- 空状态 -->
99
+ <div v-if="filteredPresets.length === 0" class="empty-state">
100
+ <k-icon name="search" class="empty-icon"></k-icon>
101
+ <p>没有找到匹配的预设</p>
102
+ </div>
103
+ </div>
104
+
105
+ <template #footer>
106
+ <div class="dialog-footer">
107
+ <k-button @click="pickerVisible = false">取消</k-button>
108
+ <k-button type="primary" @click="confirmSelection" :disabled="!tempSelectedId">
109
+ 确认选择
110
+ </k-button>
111
+ </div>
112
+ </template>
113
+ </el-dialog>
114
+
115
+ <!-- 预设详情弹窗 -->
116
+ <el-dialog
117
+ v-model="detailVisible"
118
+ :title="detailPreset?.name || '预设详情'"
119
+ width="600px"
120
+ :close-on-click-modal="true"
121
+ class="preset-detail-dialog"
122
+ >
123
+ <div v-if="detailPreset" class="preset-detail">
124
+ <!-- 缩略图 -->
125
+ <div class="detail-thumbnail" v-if="detailPreset.thumbnail">
126
+ <img :src="detailPreset.thumbnail" :alt="detailPreset.name" />
127
+ </div>
128
+
129
+ <!-- 基本信息 -->
130
+ <div class="detail-section">
131
+ <div class="detail-row">
132
+ <span class="detail-label">来源:</span>
133
+ <span class="source-badge" :class="detailPreset.source">
134
+ {{ detailPreset.source === 'api' ? '远程' : '本地' }}
135
+ </span>
136
+ </div>
137
+ <div class="detail-row" v-if="detailPreset.tags?.length">
138
+ <span class="detail-label">标签:</span>
139
+ <div class="detail-tags">
140
+ <span v-for="tag in detailPreset.tags" :key="tag" class="mini-tag">{{ tag }}</span>
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ <!-- Prompt 模板 -->
146
+ <div class="detail-section">
147
+ <div class="detail-label">Prompt 模板:</div>
148
+ <div class="prompt-preview">{{ detailPreset.promptTemplate }}</div>
149
+ </div>
150
+
151
+ <!-- 参考图 -->
152
+ <div class="detail-section" v-if="detailPreset.referenceImages?.length">
153
+ <div class="detail-label">参考图片 ({{ detailPreset.referenceImages.length }}):</div>
154
+ <div class="reference-images">
155
+ <img
156
+ v-for="(img, idx) in detailPreset.referenceImages"
157
+ :key="idx"
158
+ :src="img"
159
+ class="ref-image"
160
+ />
161
+ </div>
162
+ </div>
163
+
164
+ <!-- 参数覆盖 -->
165
+ <div class="detail-section" v-if="detailPreset.parameterOverrides && Object.keys(detailPreset.parameterOverrides).length">
166
+ <div class="detail-label">参数覆盖:</div>
167
+ <div class="param-preview">{{ JSON.stringify(detailPreset.parameterOverrides, null, 2) }}</div>
168
+ </div>
169
+ </div>
170
+
171
+ <template #footer>
172
+ <div class="dialog-footer">
173
+ <k-button @click="detailVisible = false">关闭</k-button>
174
+ <k-button type="primary" @click="selectFromDetail">
175
+ <template #icon><k-icon name="check"></k-icon></template>
176
+ 选择此预设
177
+ </k-button>
178
+ </div>
179
+ </template>
180
+ </el-dialog>
181
+ </div>
182
+ </template>
183
+
184
+ <script setup lang="ts">
185
+ import { ref, computed, watch } from 'vue'
186
+ import type { PresetData } from '../types'
187
+ import TagFilter from './TagFilter.vue'
188
+
189
+ const props = defineProps<{
190
+ modelValue?: number
191
+ presets: PresetData[]
192
+ }>()
193
+
194
+ const emit = defineEmits<{
195
+ (e: 'update:modelValue', value: number | undefined): void
196
+ }>()
197
+
198
+ // 预置标签(包含来源虚拟标签)- 复用 PresetsView 的定义
199
+ const presetTags = ['本地', '远程', 'text2img', 'img2img', 'NSFW']
200
+
201
+ // 状态
202
+ const pickerVisible = ref(false)
203
+ const detailVisible = ref(false)
204
+ const selectedTags = ref<string[]>([])
205
+ const tempSelectedId = ref<number | undefined>(props.modelValue)
206
+ const detailPreset = ref<PresetData | null>(null)
207
+
208
+ // 从所有预设中提取标签 - 复用 PresetsView 的逻辑
209
+ const allTags = computed(() => {
210
+ const tagSet = new Set<string>()
211
+ props.presets.forEach(p => {
212
+ (p.tags || []).forEach(t => tagSet.add(t))
213
+ })
214
+ return Array.from(tagSet).sort()
215
+ })
216
+
217
+ // 当前选中的预设
218
+ const selectedPreset = computed(() => {
219
+ if (!props.modelValue) return null
220
+ return props.presets.find(p => p.id === props.modelValue)
221
+ })
222
+
223
+ // 过滤后的预设列表
224
+ const filteredPresets = computed(() => {
225
+ let result = props.presets
226
+
227
+ if (selectedTags.value.length === 0) return result
228
+
229
+ return result.filter(p => {
230
+ return selectedTags.value.every(tag => {
231
+ // 虚拟标签:本地/远程
232
+ if (tag === '本地') return p.source === 'user'
233
+ if (tag === '远程') return p.source === 'api'
234
+ // 普通标签匹配
235
+ return (p.tags || []).includes(tag)
236
+ })
237
+ })
238
+ })
239
+
240
+ // 方法
241
+ const openPicker = () => {
242
+ tempSelectedId.value = props.modelValue
243
+ selectedTags.value = []
244
+ pickerVisible.value = true
245
+ }
246
+
247
+ const selectPreset = (preset: PresetData) => {
248
+ tempSelectedId.value = preset.id
249
+ }
250
+
251
+ const confirmSelection = () => {
252
+ emit('update:modelValue', tempSelectedId.value)
253
+ pickerVisible.value = false
254
+ }
255
+
256
+ const clearSelection = () => {
257
+ emit('update:modelValue', undefined)
258
+ }
259
+
260
+ const viewDetail = (preset: PresetData) => {
261
+ detailPreset.value = preset
262
+ detailVisible.value = true
263
+ }
264
+
265
+ const selectFromDetail = () => {
266
+ if (detailPreset.value) {
267
+ emit('update:modelValue', detailPreset.value.id)
268
+ detailVisible.value = false
269
+ pickerVisible.value = false
270
+ }
271
+ }
272
+
273
+ // 同步外部值变化
274
+ watch(() => props.modelValue, (newVal) => {
275
+ tempSelectedId.value = newVal
276
+ })
277
+ </script>
278
+
279
+ <style scoped>
280
+ @import '../styles/shared.css';
281
+
282
+ .preset-picker {
283
+ width: 100%;
284
+ }
285
+
286
+ /* 选中预设显示 */
287
+ .selected-preset {
288
+ display: flex;
289
+ align-items: center;
290
+ gap: 0.5rem;
291
+ padding: 0.375rem 0.625rem;
292
+ background-color: var(--k-color-bg-2);
293
+ border: 1px solid var(--k-color-border);
294
+ border-radius: 4px;
295
+ cursor: pointer;
296
+ transition: all 0.2s;
297
+ min-height: 36px;
298
+ }
299
+
300
+ .selected-preset:hover {
301
+ border-color: var(--k-color-active);
302
+ background-color: var(--k-color-bg-1);
303
+ }
304
+
305
+ .preset-info {
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 0.5rem;
309
+ flex: 1;
310
+ min-width: 0;
311
+ }
312
+
313
+ .preset-thumb {
314
+ width: 28px;
315
+ height: 28px;
316
+ border-radius: 4px;
317
+ object-fit: cover;
318
+ flex-shrink: 0;
319
+ }
320
+
321
+ .preset-thumb.placeholder {
322
+ display: flex;
323
+ align-items: center;
324
+ justify-content: center;
325
+ background-color: var(--k-color-bg-1);
326
+ color: var(--k-color-text-description);
327
+ font-size: 0.75rem;
328
+ }
329
+
330
+ .preset-details {
331
+ display: flex;
332
+ align-items: center;
333
+ gap: 0.375rem;
334
+ flex: 1;
335
+ min-width: 0;
336
+ }
337
+
338
+ .preset-name {
339
+ font-size: 0.85rem;
340
+ font-weight: 500;
341
+ color: var(--k-color-text);
342
+ overflow: hidden;
343
+ text-overflow: ellipsis;
344
+ white-space: nowrap;
345
+ }
346
+
347
+ .preset-source {
348
+ font-size: 0.65rem;
349
+ padding: 1px 5px;
350
+ border-radius: 3px;
351
+ flex-shrink: 0;
352
+ }
353
+
354
+ .preset-source.api {
355
+ background-color: var(--k-color-active);
356
+ color: white;
357
+ }
358
+
359
+ .preset-source.user {
360
+ background-color: var(--k-color-warning, #e6a23c);
361
+ color: white;
362
+ }
363
+
364
+ .clear-btn {
365
+ padding: 3px;
366
+ color: var(--k-color-text-description);
367
+ cursor: pointer;
368
+ transition: color 0.2s;
369
+ flex-shrink: 0;
370
+ font-size: 0.85rem;
371
+ }
372
+
373
+ .clear-btn:hover {
374
+ color: var(--k-color-error, #f56c6c);
375
+ }
376
+
377
+ .placeholder-text {
378
+ display: flex;
379
+ align-items: center;
380
+ gap: 0.4rem;
381
+ color: var(--k-color-text-description);
382
+ flex: 1;
383
+ font-size: 0.85rem;
384
+ }
385
+
386
+ .dropdown-icon {
387
+ color: var(--k-color-text-description);
388
+ flex-shrink: 0;
389
+ transition: transform 0.2s;
390
+ }
391
+
392
+ .selected-preset:hover .dropdown-icon {
393
+ color: var(--k-color-active);
394
+ }
395
+
396
+ /* 选择器弹窗 */
397
+ .picker-header {
398
+ display: flex;
399
+ align-items: center;
400
+ gap: 1rem;
401
+ margin-bottom: 1rem;
402
+ }
403
+
404
+ .preset-count {
405
+ font-size: 0.85rem;
406
+ color: var(--k-color-text-description);
407
+ flex-shrink: 0;
408
+ }
409
+
410
+ /* 预设网格 - 复用 ml-masonry 布局,添加滚动容器 */
411
+ .picker-content {
412
+ max-height: 65vh;
413
+ overflow-y: auto;
414
+ overflow-x: hidden;
415
+ padding: 0.5rem;
416
+ /* 隐藏式滚动条 */
417
+ scrollbar-width: thin;
418
+ scrollbar-color: transparent transparent;
419
+ }
420
+
421
+ .picker-content:hover {
422
+ scrollbar-color: var(--k-color-border) transparent;
423
+ }
424
+
425
+ .picker-content::-webkit-scrollbar {
426
+ width: 6px;
427
+ }
428
+
429
+ .picker-content::-webkit-scrollbar-track {
430
+ background: transparent;
431
+ }
432
+
433
+ .picker-content::-webkit-scrollbar-thumb {
434
+ background-color: transparent;
435
+ border-radius: 3px;
436
+ transition: background-color 0.2s;
437
+ }
438
+
439
+ .picker-content:hover::-webkit-scrollbar-thumb {
440
+ background-color: var(--k-color-border);
441
+ }
442
+
443
+ /* 确保对话框内瀑布流正确渲染 */
444
+ .picker-content .ml-masonry {
445
+ columns: 5;
446
+ column-gap: 1rem;
447
+ }
448
+
449
+ .picker-content .ml-masonry-item {
450
+ break-inside: avoid;
451
+ margin-bottom: 1rem;
452
+ }
453
+
454
+ @media (max-width: 1400px) {
455
+ .picker-content .ml-masonry { columns: 4; }
456
+ }
457
+
458
+ @media (max-width: 1100px) {
459
+ .picker-content .ml-masonry { columns: 3; }
460
+ }
461
+
462
+ @media (max-width: 800px) {
463
+ .picker-content .ml-masonry { columns: 2; }
464
+ }
465
+
466
+ /* ========== 复用 PresetsView 的卡片样式 ========== */
467
+
468
+ .preset-card {
469
+ background-color: var(--k-color-bg-1);
470
+ border: 2px solid var(--k-color-border);
471
+ border-radius: 8px;
472
+ overflow: hidden;
473
+ cursor: pointer;
474
+ transition: all 0.2s ease;
475
+ position: relative;
476
+ }
477
+
478
+ .preset-card:hover {
479
+ border-color: var(--k-color-active);
480
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
481
+ }
482
+
483
+ .preset-card.selected {
484
+ border-color: var(--k-color-active);
485
+ background-color: rgba(var(--k-color-primary-rgb), 0.05);
486
+ }
487
+
488
+ .card-thumbnail {
489
+ width: 100%;
490
+ aspect-ratio: auto;
491
+ position: relative;
492
+ overflow: hidden;
493
+ background-color: var(--k-color-bg-2);
494
+ }
495
+
496
+ .card-thumbnail img {
497
+ width: 100%;
498
+ height: auto;
499
+ display: block;
500
+ object-fit: cover;
501
+ }
502
+
503
+ .card-thumbnail.placeholder {
504
+ aspect-ratio: 4/3;
505
+ display: flex;
506
+ align-items: center;
507
+ justify-content: center;
508
+ }
509
+
510
+ .placeholder-icon {
511
+ font-size: 2rem;
512
+ color: var(--k-color-text-description);
513
+ opacity: 0.5;
514
+ }
515
+
516
+ .thumbnail-overlay {
517
+ position: absolute;
518
+ bottom: 0;
519
+ left: 0;
520
+ right: 0;
521
+ padding: 0.5rem;
522
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
523
+ display: flex;
524
+ justify-content: flex-end;
525
+ }
526
+
527
+ .ref-count {
528
+ display: flex;
529
+ align-items: center;
530
+ gap: 4px;
531
+ font-size: 0.75rem;
532
+ color: white;
533
+ background-color: rgba(0, 0, 0, 0.5);
534
+ padding: 2px 6px;
535
+ border-radius: 4px;
536
+ }
537
+
538
+ .card-content {
539
+ padding: 0.75rem;
540
+ }
541
+
542
+ .card-title {
543
+ font-size: 0.9rem;
544
+ font-weight: 600;
545
+ color: var(--k-color-text);
546
+ margin-bottom: 0.5rem;
547
+ overflow: hidden;
548
+ text-overflow: ellipsis;
549
+ white-space: nowrap;
550
+ }
551
+
552
+ .card-tags {
553
+ display: flex;
554
+ flex-wrap: wrap;
555
+ gap: 4px;
556
+ }
557
+
558
+ .mini-tag {
559
+ font-size: 0.7rem;
560
+ padding: 2px 6px;
561
+ background-color: var(--k-color-bg-2);
562
+ color: var(--k-color-text-description);
563
+ border-radius: 4px;
564
+ }
565
+
566
+ .mini-tag.more {
567
+ background-color: var(--k-color-active);
568
+ color: white;
569
+ }
570
+
571
+ .card-actions {
572
+ display: flex;
573
+ align-items: center;
574
+ gap: 0.5rem;
575
+ padding: 0.5rem 0.75rem;
576
+ border-top: 1px solid var(--k-color-border);
577
+ background-color: var(--k-color-bg-2);
578
+ }
579
+
580
+ .source-indicator {
581
+ position: absolute;
582
+ top: 0.5rem;
583
+ left: 0.5rem;
584
+ font-size: 0.65rem;
585
+ padding: 2px 6px;
586
+ border-radius: 4px;
587
+ font-weight: 600;
588
+ }
589
+
590
+ .source-indicator.api {
591
+ background-color: var(--k-color-active);
592
+ color: white;
593
+ }
594
+
595
+ .source-indicator.user {
596
+ background-color: var(--k-color-warning, #e6a23c);
597
+ color: white;
598
+ }
599
+
600
+ .selected-mark {
601
+ position: absolute;
602
+ top: 0.5rem;
603
+ right: 0.5rem;
604
+ width: 28px;
605
+ height: 28px;
606
+ background-color: var(--k-color-active);
607
+ border-radius: 50%;
608
+ display: flex;
609
+ align-items: center;
610
+ justify-content: center;
611
+ color: white;
612
+ font-size: 1rem;
613
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
614
+ }
615
+
616
+ /* 空状态 */
617
+ .empty-state {
618
+ grid-column: 1 / -1;
619
+ text-align: center;
620
+ padding: 3rem;
621
+ color: var(--k-color-text-description);
622
+ }
623
+
624
+ .empty-icon {
625
+ font-size: 2.5rem;
626
+ opacity: 0.3;
627
+ margin-bottom: 1rem;
628
+ }
629
+
630
+ /* ========== 详情弹窗样式 ========== */
631
+
632
+ .preset-detail {
633
+ display: flex;
634
+ flex-direction: column;
635
+ gap: 1rem;
636
+ }
637
+
638
+ .detail-thumbnail {
639
+ width: 100%;
640
+ max-height: 200px;
641
+ overflow: hidden;
642
+ border-radius: 8px;
643
+ background-color: var(--k-color-bg-2);
644
+ }
645
+
646
+ .detail-thumbnail img {
647
+ width: 100%;
648
+ height: auto;
649
+ object-fit: contain;
650
+ }
651
+
652
+ .detail-section {
653
+ display: flex;
654
+ flex-direction: column;
655
+ gap: 0.5rem;
656
+ }
657
+
658
+ .detail-row {
659
+ display: flex;
660
+ align-items: center;
661
+ gap: 0.75rem;
662
+ }
663
+
664
+ .detail-label {
665
+ font-size: 0.85rem;
666
+ font-weight: 600;
667
+ color: var(--k-color-text-description);
668
+ }
669
+
670
+ .detail-tags {
671
+ display: flex;
672
+ flex-wrap: wrap;
673
+ gap: 6px;
674
+ }
675
+
676
+ .source-badge {
677
+ display: inline-block;
678
+ font-size: 0.75rem;
679
+ padding: 2px 8px;
680
+ border-radius: 4px;
681
+ }
682
+
683
+ .source-badge.api {
684
+ background-color: var(--k-color-active);
685
+ color: white;
686
+ }
687
+
688
+ .source-badge.user {
689
+ background-color: var(--k-color-warning, #e6a23c);
690
+ color: white;
691
+ }
692
+
693
+ .prompt-preview {
694
+ padding: 0.75rem;
695
+ background-color: var(--k-color-bg-2);
696
+ border-radius: 6px;
697
+ font-size: 0.85rem;
698
+ color: var(--k-color-text);
699
+ white-space: pre-wrap;
700
+ word-break: break-word;
701
+ max-height: 150px;
702
+ overflow-y: auto;
703
+ font-family: monospace;
704
+ }
705
+
706
+ .reference-images {
707
+ display: flex;
708
+ gap: 0.5rem;
709
+ flex-wrap: wrap;
710
+ }
711
+
712
+ .ref-image {
713
+ width: 80px;
714
+ height: 80px;
715
+ object-fit: cover;
716
+ border-radius: 6px;
717
+ border: 1px solid var(--k-color-border);
718
+ }
719
+
720
+ .param-preview {
721
+ padding: 0.75rem;
722
+ background-color: var(--k-color-bg-2);
723
+ border-radius: 6px;
724
+ font-size: 0.8rem;
725
+ font-family: monospace;
726
+ white-space: pre-wrap;
727
+ color: var(--k-color-text);
728
+ max-height: 100px;
729
+ overflow-y: auto;
730
+ }
731
+
732
+ /* 弹窗底部 */
733
+ .dialog-footer {
734
+ display: flex;
735
+ gap: 0.75rem;
736
+ justify-content: flex-end;
737
+ }
738
+ </style>