| 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 |