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