返回 AiToEarn
1 /**
2 * GenerationParamsCard 组件
3 * 展示草稿或生成任务的 AI 请求参数
4 */
5
6 'use client'
7
8 import type { MaterialGenerationParams } from '@/api/materials/material.types'
9 import type { PlatType } from '@/app/config/platConfig'
10 import { Copy, Music, Play } from 'lucide-react'
11 import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
12 import { MediaPreview } from '@/components/common/MediaPreview'
13
14 import { OssImage } from '@/components/common/OssImage'
15 import { Badge } from '@/components/ui/badge'
16 import { Button } from '@/components/ui/button'
17 import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
18 import { useVideoThumbnail } from '@/hooks/useVideoThumbnail'
19 import { useDraftBoxConfigStore } from '@/store/draft-box/draftBoxConfigStore'
20 import { getPlatformInfoSync } from '@/store/platformMetadata'
21 import { cn } from '@/utils/className'
22 import { getOssUrl } from '@/utils/oss'
23 import { toast } from '@/utils/ui/toast'
24 import { getDraftBoxMediaFileName } from '../../utils/mediaFileName'
25 import MediaMentionPromptText from '../MediaMentionPromptText'
26
27 interface GenerationParamsCardProps {
28 params?: MaterialGenerationParams | null
29 t: (key: string, options?: Record<string, unknown>) => string
30 className?: string
31 compact?: boolean
32 showPlatforms?: boolean
33 applyTargetGroupId?: string | null
34 onApplied?: () => void
35 }
36
37 interface PromptPreviewProps {
38 prompt: string
39 title: string
40 imageUrls: string[]
41 videoUrls: string[]
42 audioUrls: string[]
43 copyLabel: string
44 copySuccessMessage: string
45 copyFailedMessage: string
46 compact?: boolean
47 }
48
49 const PromptPreview = memo(({
50 prompt,
51 title,
52 imageUrls,
53 videoUrls,
54 audioUrls,
55 copyLabel,
56 copySuccessMessage,
57 copyFailedMessage,
58 compact = false,
59 }: PromptPreviewProps) => {
60 const handleCopyPrompt = useCallback(async () => {
61 try {
62 await navigator.clipboard.writeText(prompt)
63 toast.success(copySuccessMessage)
64 }
65 catch {
66 toast.error(copyFailedMessage)
67 }
68 }, [copyFailedMessage, copySuccessMessage, prompt])
69
70 return (
71 <HoverCard openDelay={120} closeDelay={120}>
72 <HoverCardTrigger asChild>
73 <button
74 type="button"
75 className={cn(
76 'w-full rounded-lg border border-border/50 bg-background/70 px-3 py-2 text-left transition-colors hover:border-border/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
77 'cursor-pointer',
78 )}
79 >
80 <p
81 className={cn(
82 'line-clamp-2 text-foreground',
83 compact ? 'text-xs leading-5' : 'text-sm leading-6',
84 )}
85 >
86 <MediaMentionPromptText
87 prompt={prompt}
88 imageUrls={imageUrls}
89 videoUrls={videoUrls}
90 audioUrls={audioUrls}
91 compact={compact}
92 />
93 </p>
94 </button>
95 </HoverCardTrigger>
96 <HoverCardContent
97 align="start"
98 sideOffset={8}
99 className="w-80 max-w-[calc(100vw-2rem)] space-y-3 rounded-xl border border-border/60 bg-popover p-3 shadow-lg sm:w-96"
100 allowInnerScroll
101 >
102 <div className="flex items-center justify-between gap-3">
103 <p className="text-xs font-medium text-muted-foreground">
104 {title}
105 </p>
106 <Button
107 type="button"
108 size="sm"
109 variant="ghost"
110 className="h-8 cursor-pointer gap-1.5 px-2.5 text-xs"
111 onClick={handleCopyPrompt}
112 >
113 <Copy className="h-3.5 w-3.5" />
114 {copyLabel}
115 </Button>
116 </div>
117 <div
118 className={cn(
119 'max-h-72 overflow-y-auto pr-1 text-foreground select-text',
120 compact ? 'text-xs leading-5' : 'text-sm leading-6',
121 )}
122 >
123 <MediaMentionPromptText
124 prompt={prompt}
125 imageUrls={imageUrls}
126 videoUrls={videoUrls}
127 audioUrls={audioUrls}
128 compact={compact}
129 />
130 </div>
131 </HoverCardContent>
132 </HoverCard>
133 )
134 })
135
136 PromptPreview.displayName = 'PromptPreview'
137
138 const VIDEO_THUMBNAIL_OBSERVER_OPTIONS: IntersectionObserverInit = {
139 rootMargin: '120px 0px',
140 threshold: 0.01,
141 }
142
143 const ReferenceVideoThumbnail = memo(({
144 url,
145 compact,
146 className,
147 onClick,
148 }: {
149 url: string
150 compact: boolean
151 className?: string
152 onClick: () => void
153 }) => {
154 const buttonRef = useRef<HTMLButtonElement>(null)
155 const [shouldLoadThumbnail, setShouldLoadThumbnail] = useState(false)
156 const videoUrl = getOssUrl(url)
157 const thumbnailUrl = useVideoThumbnail(shouldLoadThumbnail ? videoUrl : null)
158 const thumbnailSize = compact ? 40 : 48
159
160 useEffect(() => {
161 if (shouldLoadThumbnail)
162 return
163
164 const button = buttonRef.current
165 if (!button)
166 return
167
168 if (typeof IntersectionObserver === 'undefined') {
169 setShouldLoadThumbnail(true)
170 return
171 }
172
173 const observer = new IntersectionObserver((entries) => {
174 if (!entries.some(entry => entry.isIntersecting))
175 return
176
177 setShouldLoadThumbnail(true)
178 observer.disconnect()
179 }, VIDEO_THUMBNAIL_OBSERVER_OPTIONS)
180
181 observer.observe(button)
182 return () => observer.disconnect()
183 }, [shouldLoadThumbnail])
184
185 return (
186 <button
187 ref={buttonRef}
188 type="button"
189 className={cn(
190 'relative flex items-center justify-center overflow-hidden rounded-lg border border-border/60 bg-muted shrink-0 cursor-pointer',
191 className,
192 )}
193 title={getDraftBoxMediaFileName(url)}
194 onClick={onClick}
195 >
196 {thumbnailUrl && (
197 <OssImage
198 src={thumbnailUrl}
199 alt=""
200 fill
201 className="object-cover"
202 sizes={`${thumbnailSize}px`}
203 thumbnailSize={thumbnailSize}
204 unoptimized={thumbnailUrl.startsWith('data:')}
205 />
206 )}
207 <div
208 className={cn(
209 'absolute inset-0 flex items-center justify-center',
210 thumbnailUrl ? 'bg-foreground/20' : 'bg-muted',
211 )}
212 >
213 <Play
214 className={cn(
215 'h-4 w-4',
216 thumbnailUrl ? 'fill-background text-background drop-shadow-sm' : 'text-muted-foreground',
217 )}
218 />
219 </div>
220 </button>
221 )
222 })
223
224 ReferenceVideoThumbnail.displayName = 'ReferenceVideoThumbnail'
225
226 export const GenerationParamsCard = memo(({
227 params,
228 t,
229 className,
230 compact = false,
231 showPlatforms = true,
232 applyTargetGroupId,
233 onApplied,
234 }: GenerationParamsCardProps) => {
235 const [previewOpen, setPreviewOpen] = useState(false)
236 const [previewIndex, setPreviewIndex] = useState(0)
237 const applyGenerationParams = useDraftBoxConfigStore(state => state.applyGenerationParams)
238
239 const imageUrls = params?.imageUrls ?? []
240 const videoUrls = params?.videoUrls ?? []
241 const audioUrls = params?.audioUrls ?? []
242
243 const previewItems = useMemo(() => {
244 return [
245 ...imageUrls.map(url => ({ type: 'image' as const, src: getOssUrl(url) })),
246 ...videoUrls.map(url => ({ type: 'video' as const, src: getOssUrl(url) })),
247 ]
248 }, [imageUrls, videoUrls])
249
250 const tags = useMemo(() => {
251 if (!params)
252 return []
253
254 const items: { label: string, value: string }[] = []
255
256 if (params.model) {
257 items.push({ label: t('detail.modelType'), value: params.model })
258 }
259 if (params.imageModel) {
260 items.push({ label: t('detail.imageModel'), value: params.imageModel })
261 }
262 if (params.duration) {
263 items.push({ label: t('detail.duration'), value: `${params.duration}s` })
264 }
265 if (params.resolution) {
266 items.push({ label: t('detail.videoResolution'), value: params.resolution })
267 }
268 if (params.aspectRatio) {
269 items.push({ label: t('detail.aspectRatio'), value: params.aspectRatio })
270 }
271 if (params.imageCount) {
272 items.push({ label: t('detail.imageCount'), value: `${params.imageCount}` })
273 }
274 if (params.imageSize) {
275 items.push({ label: t('detail.imageResolution'), value: params.imageSize })
276 }
277
278 return items
279 }, [params, t])
280
281 const draftTypeLabel = useMemo(() => {
282 if (!params?.draftType)
283 return null
284
285 if (params.draftType === 'draft')
286 return t('detail.draftModeOn')
287
288 if (params.draftType === 'video')
289 return t('detail.draftModeOffVideo')
290
291 if (params.draftType === 'image')
292 return t('detail.draftModeOffImage')
293
294 return null
295 }, [params?.draftType, t])
296
297 if (!params)
298 return null
299
300 const promptTitle = params.imageModel ? t('detail.imagePromptTitle') : t('detail.promptTitle')
301 const canApplyParams = !!applyTargetGroupId
302 const thumbSizeClassName = compact ? 'h-10 w-10' : 'h-12 w-12'
303
304 const handleApplyParams = useCallback(() => {
305 if (!applyTargetGroupId) {
306 return
307 }
308
309 applyGenerationParams(applyTargetGroupId, params)
310 onApplied?.()
311 toast.success(t('detail.applyToCurrentInputSuccess'))
312 }, [applyGenerationParams, applyTargetGroupId, onApplied, params, t])
313
314 const applyButton = canApplyParams
315 ? (
316 <Button
317 type="button"
318 variant="ghost"
319 size="sm"
320 className={cn(
321 'cursor-pointer rounded-md px-1.5 text-muted-foreground hover:bg-transparent hover:text-foreground',
322 compact ? 'h-5 text-[11px]' : 'h-6 text-xs',
323 )}
324 onClick={handleApplyParams}
325 >
326 {t('detail.applyToCurrentInput')}
327 </Button>
328 )
329 : null
330
331 return (
332 <div className={cn('space-y-3', className)}>
333 {(tags.length > 0 || draftTypeLabel) && (
334 <div className="flex flex-wrap gap-2">
335 {tags.map(tag => (
336 <div
337 key={tag.label}
338 className={cn(
339 'inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-background/80 px-2.5 py-1',
340 compact ? 'text-[11px]' : 'text-xs',
341 )}
342 >
343 <span className="text-muted-foreground">{tag.label}</span>
344 <span className="text-foreground">{tag.value}</span>
345 </div>
346 ))}
347 {draftTypeLabel && (
348 <Badge variant="secondary" className={cn('rounded-full', compact ? 'text-[11px]' : 'text-xs')}>
349 {draftTypeLabel}
350 </Badge>
351 )}
352 </div>
353 )}
354
355 {params.prompt && (
356 <div className="space-y-1.5">
357 <div className="flex items-center justify-between gap-2">
358 <p className={cn('font-medium text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
359 {promptTitle}
360 </p>
361 {applyButton}
362 </div>
363 <PromptPreview
364 prompt={params.prompt}
365 title={promptTitle}
366 imageUrls={imageUrls}
367 videoUrls={videoUrls}
368 audioUrls={audioUrls}
369 copyLabel={t('detail.copy')}
370 copySuccessMessage={t('detail.copySuccess')}
371 copyFailedMessage={t('detail.copyFailed')}
372 compact={compact}
373 />
374 </div>
375 )}
376
377 {params.captionPrompt && (
378 <div className="space-y-1.5">
379 <div className="flex flex-wrap items-center gap-2">
380 {params.plannerModel && (
381 <div
382 className={cn(
383 'inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-background/80 px-2 py-0.5',
384 compact ? 'text-[11px]' : 'text-xs',
385 )}
386 >
387 <span className="text-muted-foreground">{t('detail.captionModel')}</span>
388 <span className="text-foreground">{params.plannerModel}</span>
389 </div>
390 )}
391 <p className={cn('font-medium text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
392 {t('detail.captionPrompt')}
393 </p>
394 </div>
395 <PromptPreview
396 prompt={params.captionPrompt}
397 title={t('detail.captionPrompt')}
398 imageUrls={imageUrls}
399 videoUrls={videoUrls}
400 audioUrls={audioUrls}
401 copyLabel={t('detail.copy')}
402 copySuccessMessage={t('detail.copySuccess')}
403 copyFailedMessage={t('detail.copyFailed')}
404 compact={compact}
405 />
406 </div>
407 )}
408
409 {!params.prompt && applyButton && (
410 <div className="flex justify-end">
411 {applyButton}
412 </div>
413 )}
414
415 {imageUrls.length > 0 && (
416 <div className="space-y-1.5">
417 <p className={cn('font-medium text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
418 {t('detail.referenceImages')}
419 </p>
420 <div className="flex flex-wrap gap-2">
421 {imageUrls.map((url, index) => (
422 <button
423 key={`${url}-${index}`}
424 type="button"
425 className={cn(
426 'relative overflow-hidden rounded-lg border border-border/60 bg-muted shrink-0 cursor-pointer',
427 thumbSizeClassName,
428 )}
429 onClick={() => {
430 setPreviewIndex(index)
431 setPreviewOpen(true)
432 }}
433 >
434 <OssImage
435 src={getOssUrl(url)}
436 alt={`reference-image-${index + 1}`}
437 fill
438 className="object-cover"
439 sizes={compact ? '40px' : '48px'}
440 />
441 </button>
442 ))}
443 </div>
444 </div>
445 )}
446
447 {videoUrls.length > 0 && (
448 <div className="space-y-1.5">
449 <p className={cn('font-medium text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
450 {t('detail.referenceVideos')}
451 </p>
452 <div className="flex flex-wrap gap-2">
453 {videoUrls.map((url, index) => (
454 <ReferenceVideoThumbnail
455 key={`${url}-${index}`}
456 url={url}
457 compact={compact}
458 className={thumbSizeClassName}
459 onClick={() => {
460 setPreviewIndex(imageUrls.length + index)
461 setPreviewOpen(true)
462 }}
463 />
464 ))}
465 </div>
466 </div>
467 )}
468
469 {audioUrls.length > 0 && (
470 <div className="space-y-1.5">
471 <p className={cn('font-medium text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
472 {t('detail.referenceAudios')}
473 </p>
474 <div className="flex flex-wrap gap-2">
475 {audioUrls.map((url, index) => (
476 <a
477 key={`${url}-${index}`}
478 href={getOssUrl(url)}
479 target="_blank"
480 rel="noreferrer"
481 className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-border/60 bg-background/70 px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
482 title={getDraftBoxMediaFileName(url)}
483 >
484 <Music className="h-3.5 w-3.5 shrink-0" />
485 <span className="truncate">{getDraftBoxMediaFileName(url)}</span>
486 </a>
487 ))}
488 </div>
489 </div>
490 )}
491
492 {showPlatforms && params.platforms && params.platforms.length > 0 && (
493 <div className="space-y-1.5">
494 <p className={cn('font-medium text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
495 {t('detail.targetPlatforms')}
496 </p>
497 <div className="flex flex-wrap gap-2">
498 {params.platforms.map((platform) => {
499 const platInfo = getPlatformInfoSync(platform as PlatType)
500
501 return (
502 <Badge
503 key={platform}
504 variant="outline"
505 className={cn(
506 'inline-flex items-center gap-1.5 rounded-full bg-background/70',
507 compact ? 'text-[11px]' : 'text-xs',
508 )}
509 >
510 {platInfo?.icon && (
511 <OssImage
512 src={platInfo.icon}
513 alt={platInfo.name}
514 width={14}
515 height={14}
516 className="h-3.5 w-3.5"
517 unoptimized
518 />
519 )}
520 <span>{platInfo?.name || platform}</span>
521 </Badge>
522 )
523 })}
524 </div>
525 </div>
526 )}
527
528 {previewItems.length > 0 && (
529 <MediaPreview
530 open={previewOpen}
531 items={previewItems}
532 initialIndex={previewIndex}
533 onClose={() => setPreviewOpen(false)}
534 />
535 )}
536 </div>
537 )
538 })
539
540 GenerationParamsCard.displayName = 'GenerationParamsCard'
541
541 lines Plain Text