返回 AiToEarn
1 /**
2 * 生成任务详情弹框
3 * 展示 AI 批量生成任务的列表,支持无限滚动加载
4 */
5
6 'use client'
7
8 import type { DraftGenerationResponse, DraftGenerationTask } from '@/api/ai/ai.types'
9 import { AlertCircle, CheckCircle2, Loader2, Play } from 'lucide-react'
10 import { memo, useCallback, useEffect, useRef, useState } from 'react'
11 import { useShallow } from 'zustand/react/shallow'
12 import { apiGetDraftGenerationList } from '@/api/ai/ai.api'
13 import { useTransClient } from '@/app/i18n/client'
14 import { MediaPreview } from '@/components/common/MediaPreview'
15 import { OssImage } from '@/components/common/OssImage'
16 import { Badge } from '@/components/ui/badge'
17 import {
18 Dialog,
19 DialogContent,
20 DialogHeader,
21 DialogTitle,
22 } from '@/components/ui/dialog'
23 import { ScrollArea } from '@/components/ui/scroll-area'
24 import { usePlanDetailStore } from '@/store/draft-box/planDetailStore'
25 import { cn } from '@/utils/className'
26 import { formatRelativeTime } from '@/utils/format'
27 import { getOssUrl } from '@/utils/oss'
28 import { getDraftGenerationQueueDisplay } from '../../utils/draftGenerationQueue'
29 import { GenerationParamsCard } from '../GenerationParamsCard'
30 import { LOAD_MORE_OBSERVER_OPTIONS } from '../loadMoreObserver'
31
32 function getTaskResponse(task: DraftGenerationTask): DraftGenerationResponse | undefined {
33 return task.response && typeof task.response === 'object' ? task.response : undefined
34 }
35
36 const consumedPointsFormatter = new Intl.NumberFormat(undefined, {
37 maximumFractionDigits: 2,
38 })
39
40 function formatConsumedPoints(points: number) {
41 return consumedPointsFormatter.format(points)
42 }
43
44 function getStringValue(record: Record<string, unknown> | undefined, key: string) {
45 const value = record?.[key]
46 return typeof value === 'string' && value.trim() ? value : undefined
47 }
48
49 function getResponseTitle(response: DraftGenerationResponse) {
50 return response.title || getStringValue(response.plan, 'title')
51 }
52
53 function getResponseDescription(response: DraftGenerationResponse) {
54 return response.description || getStringValue(response.plan, 'description')
55 }
56
57 function getResponseTopics(response: DraftGenerationResponse) {
58 const topics = response.topics ?? response.plan?.topics
59 if (!Array.isArray(topics))
60 return []
61 return topics.filter((topic): topic is string => typeof topic === 'string' && topic.trim().length > 0)
62 }
63
64 function mergeTaskList(current: DraftGenerationTask[], incoming: DraftGenerationTask[]) {
65 const taskMap = new Map<string, DraftGenerationTask>()
66 current.forEach(task => taskMap.set(task.id, task))
67 incoming.forEach((task) => {
68 const currentTask = taskMap.get(task.id)
69 taskMap.set(task.id, currentTask ? { ...currentTask, ...task, queue: task.queue } : task)
70 })
71 return Array.from(taskMap.values()).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
72 }
73
74 // 状态图标映射
75 function StatusIcon({ status }: { status: DraftGenerationTask['status'] }) {
76 switch (status) {
77 case 'generating':
78 return <Loader2 className="h-4 w-4 animate-spin text-primary" />
79 case 'success':
80 return <CheckCircle2 className="h-4 w-4 text-green-500" />
81 case 'failed':
82 return <AlertCircle className="h-4 w-4 text-destructive" />
83 }
84 }
85
86 // 状态 Badge 变体映射
87 function getStatusVariant(status: DraftGenerationTask['status']): 'default' | 'secondary' | 'destructive' {
88 switch (status) {
89 case 'generating':
90 return 'default'
91 case 'success':
92 return 'secondary'
93 case 'failed':
94 return 'destructive'
95 }
96 }
97
98 function getStatusClassName(status: DraftGenerationTask['status']) {
99 if (status === 'generating')
100 return 'border-primary/20 bg-primary/10 text-primary shadow-none'
101 }
102
103 // 任务条目
104 const TaskItem = memo(({
105 task,
106 t,
107 applyTargetGroupId,
108 onApplied,
109 }: {
110 task: DraftGenerationTask
111 t: (key: string, options?: Record<string, unknown>) => string
112 applyTargetGroupId?: string | null
113 onApplied?: () => void
114 }) => {
115 const [previewOpen, setPreviewOpen] = useState(false)
116 const [previewIndex, setPreviewIndex] = useState(0)
117 const response = getTaskResponse(task)
118
119 // 构建预览项列表和封面
120 const hasVideo = !!response?.videoUrl
121 const hasImages = (response?.imageUrls?.length ?? 0) > 0
122 const coverSrc = response?.coverUrl
123 ? getOssUrl(response.coverUrl)
124 : response?.imageUrls?.[0]
125 ? getOssUrl(response.imageUrls[0])
126 : undefined
127 const title = response ? getResponseTitle(response) : ''
128 const description = response ? getResponseDescription(response) : ''
129 const topics = response ? getResponseTopics(response) : []
130 const queueDisplay = getDraftGenerationQueueDisplay(task)
131 const queueStatusText = queueDisplay
132 ? queueDisplay.type === 'queued'
133 ? t('detail.queueAhead', { count: queueDisplay.count })
134 : t('detail.generatingDraft')
135 : ''
136
137 // 预览项:视频任务只预览视频,图片任务预览所有图片
138 const previewItems = response
139 ? hasVideo && response.videoUrl
140 ? [{ type: 'video' as const, src: getOssUrl(response.videoUrl), title }]
141 : (response.imageUrls || []).map(url => ({ type: 'image' as const, src: getOssUrl(url), title }))
142 : []
143
144 return (
145 <div className="flex items-start gap-3 p-3 rounded-lg border border-border/50 bg-muted/20">
146 <div className="mt-0.5">
147 <StatusIcon status={task.status} />
148 </div>
149 <div className="flex-1 min-w-0">
150 <div className="flex items-center gap-2 flex-wrap">
151 <Badge variant={getStatusVariant(task.status)} className={cn('text-xs', getStatusClassName(task.status))}>
152 {t(`detail.taskStatus.${task.status}`)}
153 </Badge>
154 {task.points > 0 && (
155 <span className="text-xs text-muted-foreground">
156 {t('detail.pointsConsumed', { points: formatConsumedPoints(task.points) })}
157 </span>
158 )}
159 {queueStatusText && (
160 <span className="text-xs text-muted-foreground">
161 {queueStatusText}
162 </span>
163 )}
164 </div>
165
166 {/* 请求参数 */}
167 {task.request && (
168 <GenerationParamsCard
169 params={task.request}
170 t={t}
171 className="mt-2"
172 compact
173 applyTargetGroupId={applyTargetGroupId}
174 onApplied={onApplied}
175 />
176 )}
177
178 {/* 已生成的部分结果 */}
179 {response && (
180 <div className="mt-2">
181 {response.requestedImageCount && response.requestedImageCount > 0 && (
182 <p className="mb-2 text-xs text-muted-foreground">
183 {t('detail.generatedImageProgress', {
184 generated: response.generatedImageCount ?? response.imageUrls?.length ?? 0,
185 requested: response.requestedImageCount,
186 })}
187 </p>
188 )}
189
190 {/* 视频任务:封面 + 文字信息 */}
191 {hasVideo && coverSrc && (
192 <div className="flex gap-3">
193 <div
194 className="relative shrink-0 w-20 h-20 rounded-md overflow-hidden bg-muted cursor-pointer"
195 onClick={() => { setPreviewIndex(0); setPreviewOpen(true) }}
196 >
197 <OssImage
198 src={coverSrc}
199 alt={title || ''}
200 fill
201 className="object-cover"
202 sizes="80px"
203 />
204 <div className="absolute inset-0 flex items-center justify-center bg-black/30">
205 <Play className="h-5 w-5 text-white fill-white" />
206 </div>
207 </div>
208 <div className="flex-1 min-w-0">
209 {title && (
210 <p className="text-sm font-medium line-clamp-1">{title}</p>
211 )}
212 {description && (
213 <p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{description}</p>
214 )}
215 {topics.length > 0 && (
216 <div className="flex flex-wrap gap-1 mt-1">
217 {topics.map(topic => (
218 <span key={topic} className="text-xs text-primary">
219 #
220 {topic}
221 </span>
222 ))}
223 </div>
224 )}
225 </div>
226 </div>
227 )}
228
229 {/* 图片任务:展示所有生成图片 */}
230 {!hasVideo && hasImages && (
231 <div className="overflow-x-auto max-w-full">
232 <div className="flex gap-1.5">
233 {response.imageUrls!.map((url, i) => (
234 <div
235 key={i}
236 className="relative w-20 h-20 rounded-md overflow-hidden bg-muted cursor-pointer"
237 onClick={() => { setPreviewIndex(i); setPreviewOpen(true) }}
238 >
239 <OssImage
240 src={getOssUrl(url)}
241 alt={`result-${i + 1}`}
242 fill
243 className="object-cover"
244 sizes="80px"
245 />
246 </div>
247 ))}
248 </div>
249 </div>
250 )}
251
252 {/* 预览 */}
253 {previewItems.length > 0 && (
254 <MediaPreview
255 open={previewOpen}
256 items={previewItems}
257 initialIndex={previewIndex}
258 onClose={() => setPreviewOpen(false)}
259 />
260 )}
261 </div>
262 )}
263
264 {task.errorMessage && task.status === 'failed' && (
265 <p className="text-xs text-destructive mt-1 break-all">{task.errorMessage}</p>
266 )}
267 <p className="text-xs text-muted-foreground mt-1">
268 {formatRelativeTime(new Date(task.createdAt))}
269 </p>
270 </div>
271 </div>
272 )
273 })
274
275 TaskItem.displayName = 'TaskItem'
276
277 // 弹框内容
278 const GenerationDetailContent = memo(({
279 onClose,
280 applyTargetGroupId,
281 }: {
282 onClose: () => void
283 applyTargetGroupId?: string | null
284 }) => {
285 const { t } = useTransClient('brandPromotion')
286 const [tasks, setTasks] = useState<DraftGenerationTask[]>([])
287 const [loading, setLoading] = useState(true)
288 const [hasMore, setHasMore] = useState(true)
289 const [page, setPage] = useState(1)
290 const loadMoreRef = useRef<HTMLDivElement>(null)
291 const pageSize = 20
292 const liveGenerationTasks = usePlanDetailStore(state => state.generationTasks)
293
294 const fetchTasks = useCallback(async (pageNum: number) => {
295 setLoading(true)
296 try {
297 const res = await apiGetDraftGenerationList(pageNum, pageSize)
298 if (res?.data) {
299 const list = res.data.list || []
300 setTasks(prev => pageNum === 1 ? list : [...prev, ...list])
301 setHasMore(list.length === pageSize)
302 }
303 }
304 catch {
305 // 静默失败
306 }
307 finally {
308 setLoading(false)
309 }
310 }, [])
311
312 // 初始加载
313 useEffect(() => {
314 fetchTasks(1)
315 }, [fetchTasks])
316
317 useEffect(() => {
318 if (liveGenerationTasks.length === 0)
319 return
320 setTasks(prev => mergeTaskList(prev, liveGenerationTasks))
321 }, [liveGenerationTasks])
322
323 // IntersectionObserver 无限滚动
324 useEffect(() => {
325 const el = loadMoreRef.current
326 if (!el)
327 return
328
329 const observer = new IntersectionObserver(
330 (entries) => {
331 if (entries[0].isIntersecting && hasMore && !loading) {
332 const nextPage = page + 1
333 setPage(nextPage)
334 fetchTasks(nextPage)
335 }
336 },
337 LOAD_MORE_OBSERVER_OPTIONS,
338 )
339
340 observer.observe(el)
341 return () => observer.disconnect()
342 }, [hasMore, loading, page, fetchTasks])
343
344 return (
345 <>
346 <DialogHeader>
347 <DialogTitle>{t('detail.generationDetailTitle')}</DialogTitle>
348 </DialogHeader>
349
350 <ScrollArea className="max-h-[60vh]">
351 <div className="space-y-2 pr-2">
352 {tasks.map(task => (
353 <TaskItem
354 key={task.id}
355 task={task}
356 t={t}
357 applyTargetGroupId={applyTargetGroupId}
358 onApplied={onClose}
359 />
360 ))}
361
362 {/* 加载触发器 */}
363 <div ref={loadMoreRef} className="h-px" />
364
365 {/* 加载中 */}
366 {loading && (
367 <div className="flex justify-center py-4">
368 <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
369 </div>
370 )}
371
372 {/* 空状态 */}
373 {!loading && tasks.length === 0 && (
374 <div className="flex justify-center py-8 text-sm text-muted-foreground">
375 {t('detail.noGenerationTasks')}
376 </div>
377 )}
378 </div>
379 </ScrollArea>
380 </>
381 )
382 })
383
384 GenerationDetailContent.displayName = 'GenerationDetailContent'
385
386 export const GenerationDetailDialog = memo(() => {
387 const { generationDetailDialogOpen, closeGenerationDetailDialog, currentPlanId } = usePlanDetailStore(
388 useShallow(state => ({
389 generationDetailDialogOpen: state.generationDetailDialogOpen,
390 closeGenerationDetailDialog: state.closeGenerationDetailDialog,
391 currentPlanId: state.currentPlan?.id ?? null,
392 })),
393 )
394
395 // 两层组件模式
396 if (!generationDetailDialogOpen)
397 return null
398
399 return (
400 <Dialog open onOpenChange={closeGenerationDetailDialog}>
401 <DialogContent data-testid="draftbox-generation-detail-dialog" className="sm:max-w-2xl">
402 <GenerationDetailContent
403 onClose={closeGenerationDetailDialog}
404 applyTargetGroupId={currentPlanId}
405 />
406 </DialogContent>
407 </Dialog>
408 )
409 })
410
411 GenerationDetailDialog.displayName = 'GenerationDetailDialog'
412
412 lines Plain Text