返回 AiToEarn
1 /**
2 * AllListSection - 全部列表区域
3 * 合并草稿、视频、图片三种数据源,瀑布流布局 + IntersectionObserver 无限滚动
4 * 根据数据来源分发渲染 DraftCard 或 MediaCard
5 * 支持批量选择和混合删除(草稿调 apiBatchDeleteMaterials,媒体调 batchDeleteMedia)
6 */
7
8 'use client'
9
10 import type { MediaItem, PromotionMaterial } from '@/api/materials/material.types'
11
12 import type { MediaPreviewItem } from '@/components/common/MediaPreview'
13 import { ArrowRightLeft, Check, Inbox, Loader2, Trash2 } from 'lucide-react'
14 import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
15 import Masonry from 'react-masonry-css'
16 import { useShallow } from 'zustand/react/shallow'
17 import { useTransClient } from '@/app/i18n/client'
18 import { MediaPreview } from '@/components/common/MediaPreview'
19 import { Button } from '@/components/ui/button'
20 import { Checkbox } from '@/components/ui/checkbox'
21 import { Skeleton } from '@/components/ui/skeleton'
22 import { usePlanDetailStore } from '@/store/draft-box/planDetailStore'
23 import { useTransferDraftDialogStore } from '@/store/draft-box/transferDraftDialogStore'
24 import { cn } from '@/utils/className'
25 import { getOssUrl } from '@/utils/oss'
26 import { confirm } from '@/utils/ui/confirm'
27 import { toast } from '@/utils/ui/toast'
28 import { useContainerMasonryColumns } from '../../hooks/useContainerMasonryColumns'
29 import { useDraftReferenceMaxImages } from '../../hooks/useDraftReferenceMaxImages'
30 import { useMediaTabStore } from '../ContentTabs/mediaTabStore'
31 import { GeneratingTaskCard, shouldShowDraftGenerationTaskCard } from '../GeneratingCard'
32 import { LazyImage } from '../LazyImage'
33 import { LOAD_MORE_OBSERVER_OPTIONS } from '../loadMoreObserver'
34 import { MediaAddToReferenceAction } from '../MediaAddToReferenceAction'
35 import { MediaCard } from '../MediaCard'
36 import { PublishDialogDragSource } from '../PublishDialogDragSource'
37 import { VideoCreateDraftAction } from '../VideoCreateDraftAction'
38
39 /**
40 * 瀑布流断点配置
41 */
42 const MASONRY_BREAKPOINTS = {
43 default: 5,
44 1280: 4,
45 1024: 3,
46 768: 3,
47 640: 2,
48 }
49
50 // 骨架屏
51 function AllCardSkeleton({ index }: { index: number }) {
52 const heights = [120, 160, 200, 140, 180, 150, 170, 190]
53 const height = heights[index % heights.length]
54
55 return (
56 <div className="mb-4">
57 <Skeleton className="w-full rounded-xl" style={{ height: `${height}px` }} />
58 <div className="pt-2 px-1">
59 <Skeleton className="h-4 w-full" />
60 </div>
61 </div>
62 )
63 }
64
65 // 加载更多指示器
66 const LoadingIndicator = memo(({ label }: { label: string }) => (
67 <div className="flex justify-center py-4">
68 <div className="flex items-center gap-2 text-sm text-muted-foreground">
69 <div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
70 <span>{label}</span>
71 </div>
72 </div>
73 ))
74 LoadingIndicator.displayName = 'LoadingIndicator'
75
76 /** 草稿卡片(用于全部列表,支持批量模式) */
77 const AllDraftCard = memo(({ material, onClick, batchMode, selected, onToggleSelect, enablePublishDrag }: {
78 material: PromotionMaterial
79 onClick: () => void
80 batchMode?: boolean
81 selected?: boolean
82 onToggleSelect?: () => void
83 enablePublishDrag?: boolean
84 }) => {
85 const coverUrl = material.coverUrl || '/images/placeholder.png'
86 const canPublishDrag = !!enablePublishDrag && !batchMode
87
88 const handleClick = useCallback(() => {
89 if (batchMode) {
90 onToggleSelect?.()
91 }
92 else {
93 onClick()
94 }
95 }, [batchMode, onClick, onToggleSelect])
96
97 const cardNode = (
98 <div
99 className={cn(
100 'mb-4 cursor-pointer group relative',
101 batchMode && selected && 'rounded-xl shadow-lg',
102 )}
103 onClick={handleClick}
104 >
105 {/* 批量模式圆形勾选指示器 */}
106 {batchMode && (
107 <div
108 className={cn(
109 'absolute top-2 right-2 z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all duration-200 shadow-sm',
110 selected
111 ? 'border-transparent bg-gradient-back scale-110'
112 : 'bg-background/90 border-muted-foreground/30 group-hover:border-primary group-hover:scale-105',
113 )}
114 onClick={(e) => { e.stopPropagation(); onToggleSelect?.() }}
115 >
116 {selected && <Check className="w-3.5 h-3.5 text-gradient-foreground" />}
117 </div>
118 )}
119
120 <div className="relative w-full overflow-hidden rounded-xl">
121 <LazyImage
122 src={coverUrl}
123 alt={material.title || ''}
124 width={400}
125 height={300}
126 className="w-full h-auto transition-transform duration-300 group-hover:scale-105"
127 skeletonClassName="rounded-xl"
128 placeholderHeight={150}
129 style={{ aspectRatio: 'auto' }}
130 useOssThumbnail
131 />
132 {!batchMode && material.desc && (
133 <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end p-3 rounded-xl">
134 <p className="text-white text-xs line-clamp-4">
135 {material.desc}
136 </p>
137 </div>
138 )}
139 {/* 选中遮罩 */}
140 {batchMode && selected && (
141 <div className="absolute inset-0 bg-primary/15 pointer-events-none rounded-xl" />
142 )}
143 </div>
144 <div className="pt-2 px-1">
145 <p className="text-sm font-medium text-foreground line-clamp-2">
146 {material.title || ''}
147 </p>
148 {material.model && (
149 <span className="inline-block mt-1 px-1.5 py-0.5 text-xs rounded bg-muted text-muted-foreground">
150 {material.model}
151 </span>
152 )}
153 </div>
154 </div>
155 )
156
157 if (!canPublishDrag)
158 return cardNode
159
160 return (
161 <PublishDialogDragSource dragItem={{ kind: 'draft', material }}>
162 {cardNode}
163 </PublishDialogDragSource>
164 )
165 })
166 AllDraftCard.displayName = 'AllDraftCard'
167
168 interface AllListSectionProps {
169 materialGroupId: string
170 showBatchDeleteTrigger?: boolean
171 batchActionPosition?: 'fixed' | 'sticky'
172 useContainerResponsive?: boolean
173 enablePublishDrag?: boolean
174 }
175
176 export const AllListSection = memo(({
177 materialGroupId,
178 showBatchDeleteTrigger = true,
179 batchActionPosition = 'fixed',
180 useContainerResponsive = false,
181 enablePublishDrag = false,
182 }: AllListSectionProps) => {
183 const { t } = useTransClient('material')
184 const { t: tBrand } = useTransClient('brandPromotion')
185 const loadMoreRef = useRef<HTMLDivElement>(null)
186 const draftReferenceMaxImages = useDraftReferenceMaxImages(materialGroupId)
187 const { containerRef, masonryColumns } = useContainerMasonryColumns(useContainerResponsive)
188 const masonryBreakpointCols = useContainerResponsive ? masonryColumns : MASONRY_BREAKPOINTS
189
190 const generationTasks = usePlanDetailStore(state => state.generationTasks)
191 const openDraftDetailDialog = usePlanDetailStore(state => state.openDraftDetailDialog)
192 const openGenerationDetailDialog = usePlanDetailStore(state => state.openGenerationDetailDialog)
193
194 const { mergedList, loading, initialized, allExhausted } = useMediaTabStore(
195 useShallow(state => ({
196 mergedList: state.all.mergedList,
197 loading: state.all.loading,
198 initialized: state.all.initialized,
199 allExhausted: state.all.allExhausted,
200 })),
201 )
202
203 const { batchMode, selectedItems, batchDeleting } = useMediaTabStore(
204 useShallow(state => ({
205 batchMode: state.batchMode,
206 selectedItems: state.selectedItems,
207 batchDeleting: state.batchDeleting,
208 })),
209 )
210
211 const fetchAllList = useMediaTabStore(state => state.fetchAllList)
212 const loadMoreAll = useMediaTabStore(state => state.loadMoreAll)
213 const enterBatchMode = useMediaTabStore(state => state.enterBatchMode)
214 const exitBatchMode = useMediaTabStore(state => state.exitBatchMode)
215 const toggleSelection = useMediaTabStore(state => state.toggleSelection)
216 const selectAllLoaded = useMediaTabStore(state => state.selectAllLoaded)
217 const deselectAll = useMediaTabStore(state => state.deselectAll)
218 const batchDeleteAll = useMediaTabStore(state => state.batchDeleteAll)
219 const openTransferDialog = useTransferDraftDialogStore(state => state.openDialog)
220
221 // 媒体预览状态
222 const { previewOpen, previewIndex, previewType } = useMediaTabStore(
223 useShallow(state => ({
224 previewOpen: state.previewOpen,
225 previewIndex: state.previewIndex,
226 previewType: state.previewType,
227 })),
228 )
229 const openPreview = useMediaTabStore(state => state.openPreview)
230 const closePreview = useMediaTabStore(state => state.closePreview)
231
232 const selectedCount = Object.keys(selectedItems).length
233 const allSelected = mergedList.length > 0 && selectedCount === mergedList.length
234 const visibleGenerationTasks = useMemo(
235 () => generationTasks.filter(shouldShowDraftGenerationTaskCard),
236 [generationTasks],
237 )
238 const showGenerationTasks = !batchMode && visibleGenerationTasks.length > 0
239
240 // 首次加载
241 useEffect(() => {
242 if (!initialized && materialGroupId) {
243 fetchAllList(materialGroupId, materialGroupId)
244 }
245 }, [initialized, materialGroupId, fetchAllList])
246
247 // IntersectionObserver 无限滚动
248 useEffect(() => {
249 const loadMoreElement = loadMoreRef.current
250 if (!loadMoreElement)
251 return
252
253 const observer = new IntersectionObserver(
254 (entries) => {
255 const [entry] = entries
256 if (entry.isIntersecting && !allExhausted && !loading && materialGroupId) {
257 loadMoreAll(materialGroupId, materialGroupId)
258 }
259 },
260 LOAD_MORE_OBSERVER_OPTIONS,
261 )
262
263 observer.observe(loadMoreElement)
264 return () => observer.disconnect()
265 }, [allExhausted, loading, materialGroupId, loadMoreAll])
266
267 // 媒体卡片点击 - 打开预览
268 const handleMediaClick = useCallback((media: MediaItem) => {
269 // 找到在合并列表中同类型媒体的索引(用于预览导航)
270 const mediaItems = mergedList.filter(item => item.source === media.type)
271 const index = mediaItems.findIndex(item => item.id === media._id)
272 if (index !== -1) {
273 openPreview(media.type as 'video' | 'img', index)
274 }
275 }, [mergedList, openPreview])
276
277 // 全选/取消全选
278 const handleToggleSelectAll = useCallback(() => {
279 if (allSelected) {
280 deselectAll()
281 }
282 else {
283 selectAllLoaded('all')
284 }
285 }, [allSelected, deselectAll, selectAllLoaded])
286
287 // 批量删除
288 const handleBatchDelete = useCallback(() => {
289 if (selectedCount === 0)
290 return
291
292 confirm({
293 title: t('mediaManagement.batchDeleteConfirmTitle'),
294 content: t('mediaManagement.batchDeleteConfirmDesc', { count: selectedCount }),
295 okType: 'destructive',
296 onOk: async () => {
297 const success = await batchDeleteAll(materialGroupId, materialGroupId)
298 if (success) {
299 toast.success(t('mediaManagement.batchDeleteSuccess'))
300 }
301 else {
302 toast.error(t('mediaManagement.batchDeleteFailed'))
303 }
304 },
305 })
306 }, [selectedCount, batchDeleteAll, materialGroupId, t])
307
308 const handleTransfer = useCallback(() => {
309 if (selectedCount === 0) {
310 return
311 }
312
313 const draftIds: string[] = []
314 const mediaIds: string[] = []
315
316 Object.entries(selectedItems).forEach(([id, source]) => {
317 if (source === 'draft') {
318 draftIds.push(id)
319 }
320 else {
321 mediaIds.push(id)
322 }
323 })
324
325 openTransferDialog({
326 currentPlanId: materialGroupId,
327 draftIds,
328 mediaIds,
329 })
330 }, [materialGroupId, openTransferDialog, selectedCount, selectedItems])
331
332 // 预览项列表(按当前预览类型过滤)
333 const previewItems = useMemo((): MediaPreviewItem[] => {
334 return mergedList
335 .filter(item => item.source === previewType)
336 .map((item) => {
337 const media = item.data as MediaItem
338 return {
339 type: media.type === 'video' ? 'video' as const : 'image' as const,
340 src: getOssUrl(media.url),
341 title: media.title,
342 }
343 })
344 }, [mergedList, previewType])
345
346 // 初始加载骨架屏
347 if (loading && mergedList.length === 0) {
348 return (
349 <div ref={useContainerResponsive ? containerRef : undefined}>
350 <Masonry
351 breakpointCols={masonryBreakpointCols}
352 className="flex -ml-4 w-auto"
353 columnClassName="pl-4 bg-clip-padding"
354 >
355 {Array.from({ length: 8 }).map((_, i) => (
356 <AllCardSkeleton key={i} index={i} />
357 ))}
358 </Masonry>
359 </div>
360 )
361 }
362
363 // 空状态
364 if (initialized && mergedList.length === 0 && !showGenerationTasks) {
365 return (
366 <div className="flex flex-col items-center justify-center py-12">
367 <div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
368 <Inbox className="w-8 h-8 text-muted-foreground" />
369 </div>
370 <p className="text-sm font-medium text-foreground mb-1">
371 {t('mediaManagement.noMedia')}
372 </p>
373 <p className="text-sm text-muted-foreground">
374 {t('mediaManagement.noMediaDesc')}
375 </p>
376 </div>
377 )
378 }
379
380 return (
381 <div ref={useContainerResponsive ? containerRef : undefined}>
382 {/* 工具栏 */}
383 {batchMode
384 ? (
385 <div className={cn('mb-4 flex items-center gap-3', useContainerResponsive && 'flex-wrap @min-[640px]:flex-nowrap')}>
386 <div className="flex items-center gap-2 cursor-pointer" onClick={handleToggleSelectAll}>
387 <Checkbox checked={allSelected} onCheckedChange={handleToggleSelectAll} />
388 <span className="text-sm">{t('mediaManagement.selectAll')}</span>
389 </div>
390 <span className="text-sm text-muted-foreground">
391 {t('mediaManagement.selectedCount', { count: selectedCount })}
392 </span>
393 <div className={useContainerResponsive ? 'flex-[1_1_100%] @min-[640px]:flex-1' : 'flex-1'} />
394 <Button
395 variant="outline"
396 size="sm"
397 onClick={handleTransfer}
398 disabled={selectedCount === 0 || batchDeleting}
399 className="cursor-pointer gap-1.5"
400 >
401 <ArrowRightLeft className="h-3.5 w-3.5" />
402 {tBrand('draftManage.transfer')}
403 </Button>
404 <Button variant="ghost" size="sm" onClick={exitBatchMode} className="cursor-pointer">
405 {t('mediaManagement.cancel')}
406 </Button>
407 </div>
408 )
409 : (
410 showBatchDeleteTrigger
411 ? (
412 <div className={cn('mb-4 flex items-center gap-3', useContainerResponsive && 'flex-wrap @min-[640px]:flex-nowrap')}>
413 <div className={useContainerResponsive ? 'flex-[1_1_100%] @min-[640px]:flex-1' : 'flex-1'} />
414 <Button
415 variant="outline"
416 size="sm"
417 onClick={enterBatchMode}
418 className="cursor-pointer gap-1.5"
419 >
420 <ArrowRightLeft className="h-3.5 w-3.5" />
421 {tBrand('draftManage.batchTransfer')}
422 </Button>
423 <Button
424 variant="outline"
425 size="sm"
426 onClick={enterBatchMode}
427 className="cursor-pointer gap-1.5"
428 >
429 <Trash2 className="h-3.5 w-3.5" />
430 {t('mediaManagement.batchDelete')}
431 </Button>
432 </div>
433 )
434 : null
435 )}
436
437 <Masonry
438 breakpointCols={masonryBreakpointCols}
439 className="flex -ml-4 w-auto"
440 columnClassName="pl-4 bg-clip-padding"
441 >
442 {showGenerationTasks && visibleGenerationTasks.map(task => (
443 <GeneratingTaskCard key={task.id} task={task} onClick={openGenerationDetailDialog} />
444 ))}
445 {mergedList.map((item) => {
446 if (item.source === 'draft') {
447 const material = item.data as PromotionMaterial
448 return (
449 <AllDraftCard
450 key={`draft-${item.id}`}
451 material={material}
452 onClick={() => openDraftDetailDialog(material)}
453 batchMode={batchMode}
454 selected={!!selectedItems[item.id]}
455 onToggleSelect={() => toggleSelection(item.id, 'draft')}
456 enablePublishDrag={enablePublishDrag}
457 />
458 )
459 }
460 else {
461 const media = item.data as MediaItem
462 return (
463 <MediaCard
464 key={`${item.source}-${item.id}`}
465 media={media}
466 onClick={handleMediaClick}
467 useOssThumbnail
468 batchMode={batchMode}
469 selected={!!selectedItems[item.id]}
470 onToggleSelect={() => toggleSelection(item.id, item.source as 'video' | 'img')}
471 enablePublishDrag={enablePublishDrag}
472 actions={
473 item.source === 'video'
474 ? <VideoCreateDraftAction media={media} groupId={materialGroupId} />
475 : <MediaAddToReferenceAction media={media} groupId={materialGroupId} maxImages={draftReferenceMaxImages} />
476 }
477 />
478 )
479 }
480 })}
481 </Masonry>
482
483 {/* 加载触发器 */}
484 <div ref={loadMoreRef} />
485
486 {/* 加载更多指示器 */}
487 {loading && <LoadingIndicator label={tBrand('common.loading')} />}
488
489 {/* 没有更多数据 */}
490 {allExhausted && mergedList.length > 0 && (
491 <div className="flex items-center justify-center py-4">
492 <span className="text-sm text-muted-foreground">
493 {t('mediaManagement.loadedAll')}
494 </span>
495 </div>
496 )}
497
498 {/* 批量模式底部操作栏 */}
499 {batchMode && (
500 <div
501 className={cn(
502 'bottom-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 px-6 py-3',
503 batchActionPosition === 'fixed' ? 'fixed left-0 right-0' : 'sticky -mx-4 sm:-mx-6',
504 )}
505 >
506 <div className={useContainerResponsive ? 'mx-auto flex max-w-screen-2xl flex-col gap-3 @min-[640px]:flex-row @min-[640px]:items-center @min-[640px]:justify-between' : 'flex items-center justify-between max-w-screen-2xl mx-auto'}>
507 <span className="text-sm text-muted-foreground">
508 {t('mediaManagement.selectedCount', { count: selectedCount })}
509 </span>
510 <div className={useContainerResponsive ? 'flex flex-wrap items-center gap-2' : 'flex items-center gap-2'}>
511 <Button
512 variant="outline"
513 size="sm"
514 onClick={handleTransfer}
515 disabled={selectedCount === 0 || batchDeleting}
516 className="cursor-pointer gap-1.5"
517 >
518 <ArrowRightLeft className="h-3.5 w-3.5" />
519 {tBrand('draftManage.transfer')}
520 </Button>
521 <Button variant="ghost" size="sm" onClick={exitBatchMode} className="cursor-pointer">
522 {t('mediaManagement.cancel')}
523 </Button>
524 <Button
525 variant="destructive"
526 size="sm"
527 onClick={handleBatchDelete}
528 disabled={selectedCount === 0 || batchDeleting}
529 className="cursor-pointer gap-1.5"
530 >
531 {batchDeleting
532 ? <Loader2 className="h-3.5 w-3.5 animate-spin" />
533 : <Trash2 className="h-3.5 w-3.5" />}
534 {t('mediaManagement.delete')}
535 </Button>
536 </div>
537 </div>
538 </div>
539 )}
540
541 {/* 媒体预览弹窗 */}
542 <MediaPreview
543 open={previewOpen}
544 items={previewItems}
545 initialIndex={previewIndex}
546 onClose={closePreview}
547 />
548 </div>
549 )
550 })
551
552 AllListSection.displayName = 'AllListSection'
553
553 lines Plain Text