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.
@@ -24,14 +24,7 @@
24
24
 
25
25
  <div class="form-item">
26
26
  <div class="label">预设模板</div>
27
- <el-select v-model="presetId" placeholder="选择预设模板 (可选)" style="width: 100%" clearable @change="applyPreset">
28
- <el-option
29
- v-for="preset in presets"
30
- :key="preset.id"
31
- :label="preset.name"
32
- :value="preset.id"
33
- />
34
- </el-select>
27
+ <PresetPicker v-model="presetId" :presets="presets" />
35
28
  </div>
36
29
  </div>
37
30
 
@@ -97,9 +90,26 @@
97
90
 
98
91
  <!-- 右侧预览区 -->
99
92
  <div class="preview-panel">
100
- <div v-if="result" class="result-container">
101
- <div v-if="result.success" class="success-result">
102
- <div class="output-grid" v-if="result.output && result.output.length">
93
+ <!-- 生成中状态 -->
94
+ <div v-if="generating" class="generating-state">
95
+ <div class="generating-content">
96
+ <div class="loader"></div>
97
+ <div class="generating-info">
98
+ <p class="generating-title">正在生成中...</p>
99
+ <p class="generating-timer">
100
+ <k-icon name="stopwatch"></k-icon>
101
+ 已用时间: {{ formatElapsedTime(elapsedTime) }}
102
+ </p>
103
+ <p class="generating-hint" v-if="currentTaskId">任务 ID: {{ currentTaskId }}</p>
104
+ </div>
105
+ </div>
106
+ </div>
107
+
108
+ <!-- 有结果 -->
109
+ <div v-else-if="result" class="result-container">
110
+ <!-- 成功状态 -->
111
+ <div v-if="result.success && result.output && result.output.length" class="success-result">
112
+ <div class="output-grid">
103
113
  <div v-for="(asset, idx) in result.output" :key="idx" class="output-wrapper">
104
114
  <!-- 图片 -->
105
115
  <template v-if="asset.kind === 'image'">
@@ -136,25 +146,37 @@
136
146
  </div>
137
147
  </div>
138
148
  <div class="result-meta">
149
+ <span class="meta-item success-badge">
150
+ <k-icon name="check-circle"></k-icon> 生成成功
151
+ </span>
139
152
  <span class="meta-item" v-if="result.duration">
140
- <k-icon name="stopwatch"></k-icon> 耗时: {{ (result.duration / 1000).toFixed(2) }}s
153
+ <k-icon name="stopwatch"></k-icon> 耗时: {{ formatElapsedTime(result.duration) }}
141
154
  </span>
142
155
  <span class="meta-item" v-if="result.taskId">
143
156
  <k-icon name="list-alt"></k-icon> 任务 ID: {{ result.taskId }}
144
157
  </span>
145
158
  </div>
146
159
  </div>
160
+
161
+ <!-- 失败状态 -->
147
162
  <div v-else class="error-result">
148
- <k-icon name="warning" class="error-icon"></k-icon>
149
- <div class="error-msg">{{ result.error || '生成失败' }}</div>
163
+ <div class="error-content">
164
+ <k-icon name="exclamation-triangle" class="error-icon"></k-icon>
165
+ <div class="error-info">
166
+ <p class="error-title">生成失败</p>
167
+ <p class="error-msg">{{ result.error || '未知错误' }}</p>
168
+ <p class="error-meta" v-if="result.taskId">任务 ID: {{ result.taskId }}</p>
169
+ <p class="error-meta" v-if="result.duration">耗时: {{ formatElapsedTime(result.duration) }}</p>
170
+ </div>
171
+ </div>
172
+ <k-button class="retry-btn" @click="generate">
173
+ <template #icon><k-icon name="refresh"></k-icon></template>
174
+ 重新生成
175
+ </k-button>
150
176
  </div>
151
177
  </div>
152
178
 
153
- <div v-else-if="generating" class="generating-state">
154
- <div class="loader"></div>
155
- <p>正在生成中,请稍候...</p>
156
- </div>
157
-
179
+ <!-- 空状态 -->
158
180
  <div v-else class="empty-state">
159
181
  <k-icon name="image" class="empty-icon"></k-icon>
160
182
  <p>在左侧配置并点击生成</p>
@@ -178,12 +200,13 @@
178
200
  </template>
179
201
 
180
202
  <script setup lang="ts">
181
- import { ref, onMounted } from 'vue'
203
+ import { ref, onMounted, onUnmounted } from 'vue'
182
204
  import { message } from '@koishijs/client'
183
205
  import { ChannelConfig, PresetData, GenerationResult, ClientFileData } from '../types'
184
206
  import { channelApi, presetApi, generateApi, taskApi } from '../api'
185
207
  import HistoryGallery from './HistoryGallery.vue'
186
208
  import ImageLightbox from './ImageLightbox.vue'
209
+ import PresetPicker from './PresetPicker.vue'
187
210
 
188
211
  /** 本地文件项 */
189
212
  interface LocalFile {
@@ -202,6 +225,12 @@ const fileInput = ref<HTMLInputElement>()
202
225
  const historyGalleryRef = ref<InstanceType<typeof HistoryGallery>>()
203
226
  let fileUid = 0
204
227
 
228
+ // 计时器相关
229
+ const elapsedTime = ref(0)
230
+ const currentTaskId = ref<number | null>(null)
231
+ let timerInterval: ReturnType<typeof setInterval> | null = null
232
+ let startTime = 0
233
+
205
234
  const form = ref({
206
235
  channel: undefined as number | undefined,
207
236
  prompt: '',
@@ -216,6 +245,33 @@ const lightboxImages = ref<string[]>([])
216
245
  const lightboxIndex = ref(0)
217
246
  const lightboxPrompt = ref('') // 存储最终提示词
218
247
 
248
+ // 格式化耗时
249
+ const formatElapsedTime = (ms: number) => {
250
+ if (ms < 1000) return `${ms}ms`
251
+ const seconds = ms / 1000
252
+ if (seconds < 60) return `${seconds.toFixed(1)}s`
253
+ const minutes = Math.floor(seconds / 60)
254
+ const remainingSeconds = (seconds % 60).toFixed(0)
255
+ return `${minutes}m ${remainingSeconds}s`
256
+ }
257
+
258
+ // 开始计时
259
+ const startTimer = () => {
260
+ startTime = Date.now()
261
+ elapsedTime.value = 0
262
+ timerInterval = setInterval(() => {
263
+ elapsedTime.value = Date.now() - startTime
264
+ }, 100)
265
+ }
266
+
267
+ // 停止计时
268
+ const stopTimer = () => {
269
+ if (timerInterval) {
270
+ clearInterval(timerInterval)
271
+ timerInterval = null
272
+ }
273
+ }
274
+
219
275
  // 打开图片预览
220
276
  const openImagePreview = (index: number) => {
221
277
  if (result.value?.output) {
@@ -241,12 +297,6 @@ const fetchData = async () => {
241
297
  }
242
298
  }
243
299
 
244
- const applyPreset = () => {
245
- // Logic to pre-fill prompt or params based on preset could go here
246
- // But mostly the backend handles preset logic via ID or name
247
- // The API 'media-luna/generate' accepts parameters.preset name
248
- }
249
-
250
300
  // 文件转 base64
251
301
  const fileToBase64 = (file: File): Promise<string> => {
252
302
  return new Promise((resolve, reject) => {
@@ -324,6 +374,38 @@ const removeFile = (index: number) => {
324
374
  }
325
375
  }
326
376
 
377
+ // 尝试通过 taskId 获取结果
378
+ const fetchTaskResult = async (taskId: number): Promise<GenerationResult | null> => {
379
+ try {
380
+ const task = await taskApi.get(taskId)
381
+
382
+ // 获取最终提示词
383
+ lightboxPrompt.value = (task.middlewareLogs as any)?.preset?.transformedPrompt
384
+ || task.requestSnapshot?.prompt
385
+ || ''
386
+
387
+ if (task.status === 'success' && task.responseSnapshot && task.responseSnapshot.length > 0) {
388
+ return {
389
+ success: true,
390
+ output: task.responseSnapshot,
391
+ taskId: task.id,
392
+ duration: task.duration || undefined
393
+ }
394
+ } else if (task.status === 'failed') {
395
+ const errorInfo = (task.middlewareLogs as any)?._error
396
+ return {
397
+ success: false,
398
+ error: errorInfo?.message || '生成失败',
399
+ taskId: task.id,
400
+ duration: task.duration || undefined
401
+ }
402
+ }
403
+ return null
404
+ } catch {
405
+ return null
406
+ }
407
+ }
408
+
327
409
  const generate = async () => {
328
410
  if (!form.value.channel) {
329
411
  message.warning('请选择渠道')
@@ -332,6 +414,8 @@ const generate = async () => {
332
414
 
333
415
  generating.value = true
334
416
  result.value = null
417
+ currentTaskId.value = null
418
+ startTimer()
335
419
 
336
420
  try {
337
421
  const params: any = {
@@ -353,30 +437,64 @@ const generate = async () => {
353
437
  }
354
438
 
355
439
  const res = await generateApi.generate(params)
356
- result.value = res
357
440
 
358
- // 生成成功后刷新历史画廊,并获取最终提示词
441
+ // 更新 taskId
442
+ if (res.taskId) {
443
+ currentTaskId.value = res.taskId
444
+ }
445
+
446
+ // 如果成功,直接使用结果
359
447
  if (res.success) {
448
+ result.value = res
360
449
  historyGalleryRef.value?.refresh()
361
- // 获取任务详情以获得最终提示词
450
+
451
+ // 获取最终提示词
362
452
  if (res.taskId) {
363
453
  try {
364
454
  const task = await taskApi.get(res.taskId)
365
- // 优先使用预设中间件处理后的最终提示词
366
455
  lightboxPrompt.value = (task.middlewareLogs as any)?.preset?.transformedPrompt
367
456
  || task.requestSnapshot?.prompt
368
457
  || ''
369
458
  } catch {
370
- // 如果获取失败,使用输入的提示词
371
459
  lightboxPrompt.value = form.value.prompt
372
460
  }
373
461
  } else {
374
462
  lightboxPrompt.value = form.value.prompt
375
463
  }
464
+ } else {
465
+ // API 返回失败,但可能任务实际成功了,尝试通过 taskId 获取
466
+ if (res.taskId) {
467
+ // 等待一小段时间让后端完成处理
468
+ await new Promise(resolve => setTimeout(resolve, 500))
469
+ const taskResult = await fetchTaskResult(res.taskId)
470
+ if (taskResult && taskResult.success) {
471
+ result.value = taskResult
472
+ historyGalleryRef.value?.refresh()
473
+ } else {
474
+ result.value = res
475
+ }
476
+ } else {
477
+ result.value = res
478
+ }
479
+ }
480
+ } catch (e: any) {
481
+ // 请求异常,尝试通过 taskId 恢复
482
+ if (currentTaskId.value) {
483
+ await new Promise(resolve => setTimeout(resolve, 500))
484
+ const taskResult = await fetchTaskResult(currentTaskId.value)
485
+ if (taskResult) {
486
+ result.value = taskResult
487
+ if (taskResult.success) {
488
+ historyGalleryRef.value?.refresh()
489
+ }
490
+ } else {
491
+ result.value = { success: false, error: e.message || '请求失败' }
492
+ }
493
+ } else {
494
+ result.value = { success: false, error: e.message || '请求失败' }
376
495
  }
377
- } catch (e) {
378
- result.value = { success: false, error: '请求失败' }
379
496
  } finally {
497
+ stopTimer()
380
498
  generating.value = false
381
499
  }
382
500
  }
@@ -391,6 +509,10 @@ const handleHistorySelect = (task: { prompt: string }) => {
391
509
  onMounted(() => {
392
510
  fetchData()
393
511
  })
512
+
513
+ onUnmounted(() => {
514
+ stopTimer()
515
+ })
394
516
  </script>
395
517
 
396
518
  <style scoped>
@@ -417,7 +539,7 @@ onMounted(() => {
417
539
  }
418
540
 
419
541
  .config-panel {
420
- width: 380px;
542
+ width: 320px;
421
543
  flex-shrink: 0;
422
544
  display: flex;
423
545
  flex-direction: column;
@@ -425,7 +547,7 @@ onMounted(() => {
425
547
  }
426
548
 
427
549
  .config-card {
428
- padding: 1.5rem;
550
+ padding: 1.25rem;
429
551
  flex: 1 1 0;
430
552
  min-height: 0;
431
553
  overflow-y: auto;
@@ -464,7 +586,11 @@ onMounted(() => {
464
586
  }
465
587
 
466
588
  .form-section {
467
- margin-bottom: 1.5rem;
589
+ margin-bottom: 1.25rem;
590
+ }
591
+
592
+ .form-section:last-of-type {
593
+ margin-bottom: 0;
468
594
  }
469
595
 
470
596
  .form-section.flex-grow {
@@ -483,12 +609,12 @@ onMounted(() => {
483
609
 
484
610
  .section-title {
485
611
  font-weight: 600;
486
- margin-bottom: 0.75rem;
612
+ margin-bottom: 0.5rem;
487
613
  color: var(--k-color-text);
488
- font-size: 0.95rem;
614
+ font-size: 0.9rem;
489
615
  display: flex;
490
616
  align-items: center;
491
- gap: 0.5rem;
617
+ gap: 0.4rem;
492
618
  }
493
619
 
494
620
  .section-title .k-icon {
@@ -496,23 +622,29 @@ onMounted(() => {
496
622
  }
497
623
 
498
624
  .form-item {
499
- margin-bottom: 1rem;
625
+ margin-bottom: 0.75rem;
626
+ }
627
+
628
+ .form-item:last-child {
629
+ margin-bottom: 0;
500
630
  }
501
631
 
502
632
  .label {
503
- font-size: 0.85rem;
633
+ font-size: 0.8rem;
504
634
  color: var(--k-color-text-description);
505
- margin-bottom: 0.35rem;
635
+ margin-bottom: 0.25rem;
506
636
  }
507
637
 
508
638
  .form-actions {
509
- margin-top: 1.5rem;
639
+ margin-top: 1.25rem;
640
+ padding-top: 1rem;
641
+ border-top: 1px solid var(--k-color-border);
510
642
  }
511
643
 
512
644
  .generate-btn {
513
645
  width: 100%;
514
- height: 44px;
515
- font-size: 1rem;
646
+ height: 40px;
647
+ font-size: 0.95rem;
516
648
  font-weight: 600;
517
649
  transition: all 0.2s;
518
650
  background: linear-gradient(135deg, var(--k-color-primary) 0%, var(--k-color-primary-dark, var(--k-color-primary)) 100%);
@@ -568,7 +700,7 @@ onMounted(() => {
568
700
  }
569
701
 
570
702
  /* States */
571
- .empty-state, .generating-state {
703
+ .empty-state {
572
704
  text-align: center;
573
705
  color: var(--k-color-text-description);
574
706
  display: flex;
@@ -585,14 +717,34 @@ onMounted(() => {
585
717
  color: var(--k-color-text);
586
718
  }
587
719
 
720
+ /* 生成中状态 - 增强样式 */
721
+ .generating-state {
722
+ display: flex;
723
+ flex-direction: column;
724
+ align-items: center;
725
+ justify-content: center;
726
+ height: 100%;
727
+ width: 100%;
728
+ }
729
+
730
+ .generating-content {
731
+ display: flex;
732
+ flex-direction: column;
733
+ align-items: center;
734
+ gap: 1.5rem;
735
+ padding: 2rem;
736
+ background: linear-gradient(135deg, rgba(var(--k-color-primary-rgb), 0.05) 0%, rgba(var(--k-color-primary-rgb), 0.02) 100%);
737
+ border-radius: 16px;
738
+ border: 1px solid rgba(var(--k-color-primary-rgb), 0.1);
739
+ }
740
+
588
741
  .loader {
589
742
  border: 4px solid var(--k-color-bg-2);
590
743
  border-top: 4px solid var(--k-color-active);
591
744
  border-radius: 50%;
592
- width: 40px;
593
- height: 40px;
745
+ width: 48px;
746
+ height: 48px;
594
747
  animation: spin 1s linear infinite;
595
- margin: 0 auto 1rem;
596
748
  }
597
749
 
598
750
  @keyframes spin {
@@ -600,6 +752,35 @@ onMounted(() => {
600
752
  100% { transform: rotate(360deg); }
601
753
  }
602
754
 
755
+ .generating-info {
756
+ text-align: center;
757
+ }
758
+
759
+ .generating-title {
760
+ font-size: 1.1rem;
761
+ font-weight: 600;
762
+ color: var(--k-color-text);
763
+ margin: 0 0 0.75rem 0;
764
+ }
765
+
766
+ .generating-timer {
767
+ display: flex;
768
+ align-items: center;
769
+ justify-content: center;
770
+ gap: 0.5rem;
771
+ font-size: 1.5rem;
772
+ font-weight: 700;
773
+ color: var(--k-color-active);
774
+ margin: 0 0 0.5rem 0;
775
+ font-variant-numeric: tabular-nums;
776
+ }
777
+
778
+ .generating-hint {
779
+ font-size: 0.85rem;
780
+ color: var(--k-color-text-description);
781
+ margin: 0;
782
+ }
783
+
603
784
  /* Result */
604
785
  .result-container {
605
786
  width: 100%;
@@ -682,6 +863,7 @@ onMounted(() => {
682
863
  margin-top: 1.5rem;
683
864
  display: flex;
684
865
  gap: 1.5rem;
866
+ flex-wrap: wrap;
685
867
  color: var(--k-color-text-description);
686
868
  font-size: 0.9rem;
687
869
  border-top: 1px solid var(--k-color-border);
@@ -694,19 +876,68 @@ onMounted(() => {
694
876
  gap: 0.5rem;
695
877
  }
696
878
 
879
+ .success-badge {
880
+ color: var(--k-color-success, #67c23a);
881
+ font-weight: 600;
882
+ }
883
+
884
+ /* 错误状态 - 增强样式 */
697
885
  .error-result {
698
- text-align: center;
699
- color: var(--k-color-error);
886
+ display: flex;
887
+ flex-direction: column;
888
+ align-items: center;
889
+ justify-content: center;
890
+ height: 100%;
891
+ gap: 1.5rem;
892
+ }
893
+
894
+ .error-content {
895
+ display: flex;
896
+ flex-direction: column;
897
+ align-items: center;
898
+ gap: 1rem;
899
+ padding: 2rem;
900
+ background: linear-gradient(135deg, rgba(245, 108, 108, 0.08) 0%, rgba(245, 108, 108, 0.02) 100%);
901
+ border-radius: 16px;
902
+ border: 1px solid rgba(245, 108, 108, 0.2);
700
903
  }
701
904
 
702
905
  .error-icon {
703
906
  font-size: 3rem;
704
- margin-bottom: 1rem;
907
+ color: var(--k-color-error, #f56c6c);
908
+ }
909
+
910
+ .error-info {
911
+ text-align: center;
912
+ }
913
+
914
+ .error-title {
915
+ font-size: 1.1rem;
916
+ font-weight: 600;
917
+ color: var(--k-color-error, #f56c6c);
918
+ margin: 0 0 0.5rem 0;
919
+ }
920
+
921
+ .error-msg {
922
+ color: var(--k-color-text);
923
+ margin: 0 0 0.5rem 0;
924
+ max-width: 400px;
925
+ word-break: break-word;
926
+ }
927
+
928
+ .error-meta {
929
+ font-size: 0.85rem;
930
+ color: var(--k-color-text-description);
931
+ margin: 0;
932
+ }
933
+
934
+ .retry-btn {
935
+ margin-top: 0.5rem;
705
936
  }
706
937
 
707
938
  /* Upload Area */
708
939
  .upload-area {
709
- margin-top: 0.5rem;
940
+ margin-top: 0.25rem;
710
941
  }
711
942
 
712
943
  .upload-list {
@@ -718,9 +949,9 @@ onMounted(() => {
718
949
 
719
950
  .upload-item {
720
951
  position: relative;
721
- width: 64px;
722
- height: 64px;
723
- border-radius: 8px;
952
+ width: 56px;
953
+ height: 56px;
954
+ border-radius: 6px;
724
955
  overflow: hidden;
725
956
  border: 1px solid var(--k-color-border);
726
957
  background-color: var(--k-color-bg-2);
@@ -750,10 +981,10 @@ onMounted(() => {
750
981
  }
751
982
 
752
983
  .upload-trigger {
753
- width: 64px;
754
- height: 64px;
984
+ width: 56px;
985
+ height: 56px;
755
986
  border: 2px dashed var(--k-color-border);
756
- border-radius: 8px;
987
+ border-radius: 6px;
757
988
  display: flex;
758
989
  align-items: center;
759
990
  justify-content: center;
@@ -778,9 +1009,10 @@ onMounted(() => {
778
1009
  }
779
1010
 
780
1011
  .upload-tip {
781
- font-size: 0.75rem;
1012
+ font-size: 0.7rem;
782
1013
  color: var(--k-color-text-description);
783
- margin-top: 0.5rem;
1014
+ margin-top: 0.35rem;
1015
+ opacity: 0.8;
784
1016
  }
785
1017
 
786
1018
  /* Text output */
@@ -810,4 +1042,4 @@ onMounted(() => {
810
1042
  .file-link:hover {
811
1043
  background-color: var(--k-color-bg-3);
812
1044
  }
813
- </style>
1045
+ </style>