返回 AiToEarn
DraftDetailDialog.tsx
根目录 / project / aitoearn-web / src / components / draft-box / components / DraftDetailDialog.tsx
1 /**
2 * 草稿详情弹框组件
3 * 展示草稿的完整信息,支持编辑和删除操作
4 * PC端左右布局:左侧媒体资源,右侧信息
5 */
6
7 'use client'
8
9 import type { PromotionMaterial } from '@/api/materials/material.types'
10 import type { PlatType } from '@/app/config/platConfig'
11 import { ArrowRightLeft, Calendar, Edit, Image as ImageIcon, Loader2, Send, Trash2, Video } from 'lucide-react'
12 import NextImage from 'next/image'
13 import { memo, useCallback, useState } from 'react'
14 import { Navigation, Pagination } from 'swiper/modules'
15 import { Swiper, SwiperSlide } from 'swiper/react'
16 import { useShallow } from 'zustand/react/shallow'
17 import { useTransClient } from '@/app/i18n/client'
18 import { OssImage } from '@/components/common/OssImage'
19
20 import {
21 AlertDialog,
22 AlertDialogCancel,
23 AlertDialogContent,
24 AlertDialogDescription,
25 AlertDialogFooter,
26 AlertDialogHeader,
27 AlertDialogTitle,
28 } from '@/components/ui/alert-dialog'
29 import { Badge } from '@/components/ui/badge'
30 import { Button } from '@/components/ui/button'
31 import {
32 Dialog,
33 DialogContent,
34 DialogTitle,
35 } from '@/components/ui/dialog'
36 import { ScrollArea } from '@/components/ui/scroll-area'
37 import { usePlanDetailStore } from '@/store/draft-box/planDetailStore'
38 import { useTransferDraftDialogStore } from '@/store/draft-box/transferDraftDialogStore'
39 import { getPlatformInfoSync } from '@/store/platformMetadata'
40 import { cn } from '@/utils/className'
41 import { formatDate } from '@/utils/format'
42 import { getOssThumbnailUrl } from '@/utils/oss'
43 import { toast } from '@/utils/ui/toast'
44 import { getMaterialUseCountLabels } from '../utils/materialUseCount'
45 import styles from './DraftDetailDialog.module.scss'
46 import { GenerationParamsCard } from './GenerationParamsCard'
47 import { LazyImage } from './LazyImage'
48 import 'swiper/css'
49 import 'swiper/css/navigation'
50 import 'swiper/css/pagination'
51
52 // 带 loading 状态的图片组件
53 function MediaImage({ src, alt }: { src: string, alt: string }) {
54 const [loaded, setLoaded] = useState(false)
55
56 return (
57 <div className="relative flex items-center justify-center w-full h-full">
58 {/* Loading 骨架 - 增强效果 */}
59 {!loaded && (
60 <div className="absolute inset-0 flex items-center justify-center bg-muted/80">
61 <div className="h-8 w-8 animate-spin rounded-full border-4 border-muted-foreground/30 border-t-primary" />
62 </div>
63 )}
64 <OssImage
65 src={src}
66 alt={alt}
67 width={800}
68 height={600}
69 className={cn(
70 'max-w-full max-h-full object-contain transition-opacity duration-300',
71 loaded ? 'opacity-100' : 'opacity-0',
72 )}
73 onLoad={() => setLoaded(true)}
74 sizes="(max-width: 768px) 100vw, 60vw"
75 unoptimized
76 />
77 </div>
78 )
79 }
80
81 // 媒体预览组件 - 使用 Swiper 轮播
82 const MediaPreview = memo(({ material }: { material: PromotionMaterial }) => {
83 const mediaList = material.mediaList || []
84 const [currentIndex, setCurrentIndex] = useState(0)
85 const [isHovered, setIsHovered] = useState(false)
86
87 // 检查是否全是图片(非视频)
88 const isAllImages = mediaList.length > 0 && !mediaList.some(m => m.type === 'video')
89
90 // 无媒体但有封面
91 if (mediaList.length === 0 && material.coverUrl) {
92 return (
93 <div className="relative w-full h-full rounded-lg overflow-hidden bg-muted">
94 <LazyImage
95 src={material.coverUrl}
96 alt={material.title || '草稿封面'}
97 fill
98 className="object-cover"
99 skeletonClassName="rounded-lg"
100 sizes="(max-width: 768px) 100vw, 60vw"
101 useOssThumbnail
102 />
103 </div>
104 )
105 }
106
107 // 无媒体无封面
108 if (mediaList.length === 0) {
109 return (
110 <div className="flex items-center justify-center w-full h-full rounded-lg bg-muted">
111 <ImageIcon className="h-12 w-12 text-muted-foreground" />
112 </div>
113 )
114 }
115
116 // 有媒体 - 使用 Swiper
117 return (
118 <div
119 className={cn(
120 'w-full h-full min-h-[300px] rounded-lg overflow-hidden bg-muted relative',
121 styles.draftMediaSwiper,
122 isHovered ? styles.swiperVisible : styles.swiperHidden,
123 )}
124 onMouseEnter={() => setIsHovered(true)}
125 onMouseLeave={() => setIsHovered(false)}
126 >
127 <Swiper
128 data-testid="draftbox-detail-swiper"
129 modules={[Navigation, Pagination]}
130 navigation={mediaList.length > 1}
131 pagination={{ clickable: true }}
132 loop={mediaList.length > 1}
133 observer={true}
134 observeParents={true}
135 onSlideChange={swiper => setCurrentIndex(swiper.realIndex)}
136 className="h-full w-full"
137 >
138 {mediaList.map((media, index) => (
139 <SwiperSlide key={index} className="!flex items-center justify-center">
140 {media.type === 'video'
141 ? (
142 <video
143 src={media.url}
144 controls
145 autoPlay
146 loop
147 playsInline
148 className="w-full h-full object-contain bg-white"
149 poster={material.coverUrl ? getOssThumbnailUrl(material.coverUrl, { width: 960, quality: 75 }) : undefined}
150 />
151 )
152 : (
153 <MediaImage
154 src={media.url}
155 alt={material.title || `媒体 ${index + 1}`}
156 />
157 )}
158 </SwiperSlide>
159 ))}
160 </Swiper>
161
162 {/* 右上角页码指示器 - 仅图片且多于1张时显示 */}
163 {isAllImages && mediaList.length > 1 && (
164 <div className={cn(
165 'absolute top-3 right-3 z-10 px-2.5 py-1 rounded-full text-xs font-medium',
166 'bg-black/50 text-white backdrop-blur-sm',
167 'transition-opacity duration-200',
168 isHovered ? 'opacity-100' : 'opacity-0',
169 )}
170 >
171 {currentIndex + 1}
172 {' '}
173 /
174 {mediaList.length}
175 </div>
176 )}
177 </div>
178 )
179 })
180
181 MediaPreview.displayName = 'MediaPreview'
182
183 // 详情弹框内容组件
184 interface DraftDetailContentProps {
185 allowTransfer?: boolean
186 }
187
188 const DraftDetailContent = memo(({ allowTransfer = true }: DraftDetailContentProps) => {
189 const { t } = useTransClient('brandPromotion')
190 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
191
192 const {
193 selectedDraft,
194 isSubmitting,
195 openEditMaterialModal,
196 closeDraftDetailDialog,
197 deleteMaterial,
198 openPublishDialog,
199 } = usePlanDetailStore(
200 useShallow(state => ({
201 selectedDraft: state.selectedDraft,
202 isSubmitting: state.isSubmitting,
203 openEditMaterialModal: state.openEditMaterialModal,
204 closeDraftDetailDialog: state.closeDraftDetailDialog,
205 deleteMaterial: state.deleteMaterial,
206 openPublishDialog: state.openPublishDialog,
207 })),
208 )
209
210 const openTransferDialog = useTransferDraftDialogStore(state => state.openDialog)
211
212 // 处理编辑
213 const handleEdit = useCallback(() => {
214 if (selectedDraft) {
215 closeDraftDetailDialog()
216 openEditMaterialModal(selectedDraft)
217 }
218 }, [selectedDraft, closeDraftDetailDialog, openEditMaterialModal])
219
220 // 处理发布
221 const handlePublish = useCallback(() => {
222 if (selectedDraft) {
223 closeDraftDetailDialog()
224 openPublishDialog(selectedDraft)
225 }
226 }, [selectedDraft, closeDraftDetailDialog, openPublishDialog])
227
228 const handleTransfer = useCallback(() => {
229 if (!selectedDraft) {
230 return
231 }
232
233 closeDraftDetailDialog()
234 openTransferDialog({
235 currentPlanId: selectedDraft.groupId,
236 draftIds: [selectedDraft.id],
237 mediaIds: [],
238 })
239 }, [closeDraftDetailDialog, openTransferDialog, selectedDraft])
240
241 // 处理删除
242 const handleDelete = useCallback(async () => {
243 if (!selectedDraft)
244 return
245
246 const success = await deleteMaterial(selectedDraft.id)
247 if (success) {
248 toast.success(t('plan.deleteSuccess'))
249 closeDraftDetailDialog()
250 }
251 else {
252 toast.error(t('plan.deleteFailed'))
253 }
254 setDeleteConfirmOpen(false)
255 }, [selectedDraft, deleteMaterial, closeDraftDetailDialog, t])
256
257 if (!selectedDraft)
258 return null
259
260 return (
261 <>
262 {/* 无障碍:隐藏的标题 */}
263 <DialogTitle className="sr-only">{t('draft.detailTitle')}</DialogTitle>
264
265 {/* PC端左右布局,移动端垂直布局 */}
266 <div className="flex flex-col md:flex-row md:gap-6 md:h-[80vh]">
267 {/* 左侧:媒体区域 */}
268 <div className="md:w-3/5 flex-shrink-0 h-[40vh] md:h-full">
269 <MediaPreview material={selectedDraft} />
270 </div>
271
272 {/* 右侧:信息区域 - 移动端限制最大高度使 ScrollArea 生效 */}
273 <div className="md:w-2/5 mt-4 md:mt-0 flex flex-col max-h-[35vh] md:max-h-none md:h-full">
274 {/* 可滚动内容 */}
275 <ScrollArea className="flex-1 min-h-0">
276 <div className="space-y-4 pr-2">
277 {/* 标题 */}
278 <div>
279 <h3 className="text-lg font-medium">
280 {selectedDraft.title || t('material.untitled')}
281 </h3>
282 </div>
283
284 {/* 描述 */}
285 {selectedDraft.desc && (
286 <div>
287 <p className="text-sm text-muted-foreground whitespace-pre-wrap">
288 {selectedDraft.desc}
289 </p>
290 </div>
291 )}
292
293 {/* 话题 */}
294 {selectedDraft.topics && selectedDraft.topics.length > 0 && (
295 <div className="flex flex-wrap gap-x-2 gap-y-1">
296 {selectedDraft.topics.map((topic, index) => (
297 <span key={index} className="text-sm text-primary">
298 #
299 {topic}
300 </span>
301 ))}
302 </div>
303 )}
304
305 {/* AI 生成参数 */}
306 {selectedDraft.generationParams && (
307 <div className="rounded-xl border border-border/60 bg-muted/20 p-3">
308 <GenerationParamsCard
309 params={selectedDraft.generationParams}
310 t={t}
311 showPlatforms={false}
312 applyTargetGroupId={selectedDraft.groupId}
313 onApplied={closeDraftDetailDialog}
314 />
315 </div>
316 )}
317
318 {/* 统计信息 */}
319 <div className="flex flex-wrap items-center gap-2">
320 {getMaterialUseCountLabels(selectedDraft, t, { showZeroTotal: true }).map(label => (
321 <Badge key={label} variant="secondary">
322 {label}
323 </Badge>
324 ))}
325 {selectedDraft.mediaList && selectedDraft.mediaList.length > 0 && (
326 <Badge variant="outline">
327 {selectedDraft.mediaList.some(m => m.type === 'video')
328 ? (
329 <>
330 <Video className="h-3 w-3 mr-1" />
331 {t('planType.video')}
332 </>
333 )
334 : (
335 <>
336 <ImageIcon className="h-3 w-3 mr-1" />
337 {t('planType.article')}
338 {selectedDraft.mediaList.length > 1 && (
339 <span className="ml-1">
340 (
341 {selectedDraft.mediaList.length}
342 )
343 </span>
344 )}
345 </>
346 )}
347 </Badge>
348 )}
349 </div>
350
351 {/* 平台图标 */}
352 {selectedDraft.accountTypes && selectedDraft.accountTypes.length > 0 && (
353 <div className="flex flex-wrap items-center gap-2">
354 {selectedDraft.accountTypes.map((type) => {
355 const platInfo = getPlatformInfoSync(type as PlatType)
356 if (!platInfo)
357 return null
358 return (
359 <NextImage
360 key={type}
361 src={platInfo.icon}
362 alt={platInfo.name}
363 width={20}
364 height={20}
365 className="w-5 h-5"
366 unoptimized
367 />
368 )
369 })}
370 </div>
371 )}
372
373 {/* 创建时间 */}
374 {selectedDraft.createdAt && (
375 <div className="flex items-center gap-2 text-sm text-muted-foreground">
376 <Calendar className="h-4 w-4" />
377 <span>
378 {t('draft.createdAt')}
379 :
380 {' '}
381 {formatDate(selectedDraft.createdAt)}
382 </span>
383 </div>
384 )}
385 </div>
386 </ScrollArea>
387
388 {/* 固定底部的操作按钮 */}
389 <div className="mt-4 flex flex-wrap items-center gap-2 border-t pt-4 flex-shrink-0">
390 <Button
391 data-testid="draftbox-detail-edit-btn"
392 variant="outline"
393 className="min-w-[calc(50%-0.25rem)] flex-1 cursor-pointer md:min-w-0"
394 onClick={handleEdit}
395 >
396 <Edit className="h-4 w-4 mr-2" />
397 {t('draft.edit')}
398 </Button>
399 {allowTransfer && (
400 <Button
401 variant="outline"
402 className="min-w-[calc(50%-0.25rem)] flex-1 cursor-pointer md:min-w-0"
403 onClick={handleTransfer}
404 >
405 <ArrowRightLeft className="h-4 w-4 mr-2" />
406 {t('draftManage.transfer')}
407 </Button>
408 )}
409 <Button
410 data-testid="draftbox-detail-publish-btn"
411 className="min-w-[calc(50%-0.25rem)] flex-1 cursor-pointer md:min-w-0"
412 onClick={handlePublish}
413 >
414 <Send className="h-4 w-4 mr-2" />
415 {t('draft.publish')}
416 </Button>
417 <Button
418 data-testid="draftbox-detail-delete-btn"
419 variant="outline"
420 className="min-w-[calc(50%-0.25rem)] flex-1 cursor-pointer text-destructive hover:text-destructive md:min-w-0"
421 onClick={() => setDeleteConfirmOpen(true)}
422 >
423 <Trash2 className="h-4 w-4 mr-2" />
424 {t('draft.delete')}
425 </Button>
426 </div>
427 </div>
428 </div>
429
430 {/* 删除确认弹窗 */}
431 <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
432 <AlertDialogContent>
433 <AlertDialogHeader>
434 <AlertDialogTitle>{t('plan.deleteConfirmTitle')}</AlertDialogTitle>
435 <AlertDialogDescription>
436 {t('plan.deleteConfirmDesc', { name: selectedDraft.title || t('material.untitled') })}
437 </AlertDialogDescription>
438 </AlertDialogHeader>
439 <AlertDialogFooter>
440 <AlertDialogCancel className="cursor-pointer">{t('common.cancel')}</AlertDialogCancel>
441 <Button
442 className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
443 onClick={handleDelete}
444 disabled={isSubmitting}
445 >
446 {isSubmitting && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
447 {t('common.delete')}
448 </Button>
449 </AlertDialogFooter>
450 </AlertDialogContent>
451 </AlertDialog>
452 </>
453 )
454 })
455
456 DraftDetailContent.displayName = 'DraftDetailContent'
457
458 // 主组件
459 interface DraftDetailDialogProps {
460 allowTransfer?: boolean
461 }
462
463 export const DraftDetailDialog = memo(({ allowTransfer = true }: DraftDetailDialogProps) => {
464 const { draftDetailDialogOpen } = usePlanDetailStore(
465 useShallow(state => ({
466 draftDetailDialogOpen: state.draftDetailDialogOpen,
467 })),
468 )
469
470 const closeDraftDetailDialog = usePlanDetailStore(state => state.closeDraftDetailDialog)
471
472 // 根据疑难杂症记录 #2,拆成两层组件避免闪烁
473 if (!draftDetailDialogOpen)
474 return null
475
476 return (
477 <Dialog open onOpenChange={closeDraftDetailDialog}>
478 <DialogContent data-testid="draftbox-detail-dialog" className="sm:max-w-md md:max-w-6xl">
479 <DraftDetailContent allowTransfer={allowTransfer} />
480 </DialogContent>
481 </Dialog>
482 )
483 })
484
485 DraftDetailDialog.displayName = 'DraftDetailDialog'
486
486 lines Plain Text