返回 AiToEarn
1 /**
2 * MediaListSection - 媒体列表区域
3 * 瀑布流布局 + IntersectionObserver 无限滚动展示视频/图片
4 * 支持批量选择和删除
5 */
6
7 'use client'
8
9 import type { MediaItem } from '@/api/materials/material.types'
10 import type { MediaPreviewItem } from '@/components/common/MediaPreview'
11 import { ArrowRightLeft, ImageIcon, Loader2, Trash2, Video } from 'lucide-react'
12 import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
13 import Masonry from 'react-masonry-css'
14 import { useShallow } from 'zustand/react/shallow'
15 import { useTransClient } from '@/app/i18n/client'
16 import { MediaPreview } from '@/components/common/MediaPreview'
17 import { Button } from '@/components/ui/button'
18 import { Checkbox } from '@/components/ui/checkbox'
19 import { Skeleton } from '@/components/ui/skeleton'
20 import { usePlanDetailStore } from '@/store/draft-box/planDetailStore'
21 import { useTransferDraftDialogStore } from '@/store/draft-box/transferDraftDialogStore'
22 import { cn } from '@/utils/className'
23 import { getOssUrl } from '@/utils/oss'
24 import { confirm } from '@/utils/ui/confirm'
25 import { toast } from '@/utils/ui/toast'
26 import { useContainerMasonryColumns } from '../../hooks/useContainerMasonryColumns'
27 import { useDraftReferenceMaxImages } from '../../hooks/useDraftReferenceMaxImages'
28 import { useMediaTabStore } from '../ContentTabs/mediaTabStore'
29 import {
30 GeneratingTaskCard,
31 getDraftGenerationTaskTarget,
32 shouldShowDraftGenerationTaskCard,
33 } from '../GeneratingCard'
34 import { LOAD_MORE_OBSERVER_OPTIONS } from '../loadMoreObserver'
35 import { MediaAddToReferenceAction } from '../MediaAddToReferenceAction'
36 import { MediaCard } from '../MediaCard'
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 interface MediaListSectionProps {
51 type: 'video' | 'img'
52 materialGroupId: string
53 showBatchDeleteTrigger?: boolean
54 batchActionPosition?: 'fixed' | 'sticky'
55 useContainerResponsive?: boolean
56 enablePublishDrag?: boolean
57 }
58
59 // 骨架屏
60 function MediaCardSkeleton({ index }: { index: number }) {
61 const heights = [120, 160, 200, 140, 180, 150, 170, 190]
62 const height = heights[index % heights.length]
63
64 return (
65 <div className="mb-4">
66 <Skeleton className="w-full rounded-xl" style={{ height: `${height}px` }} />
67 </div>
68 )
69 }
70
71 // 加载更多指示器
72 const LoadingIndicator = memo(() => (
73 <div className="flex justify-center py-4">
74 <div className="flex items-center gap-2 text-sm text-muted-foreground">
75 <div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
76 </div>
77 </div>
78 ))
79 LoadingIndicator.displayName = 'LoadingIndicator'
80
81 export const MediaListSection = memo(({
82 type,
83 materialGroupId,
84 showBatchDeleteTrigger = true,
85 batchActionPosition = 'fixed',
86 useContainerResponsive = false,
87 enablePublishDrag = false,
88 }: MediaListSectionProps) => {
89 const { t } = useTransClient('material')
90 const { t: tBrand } = useTransClient('brandPromotion')
91 const loadMoreRef = useRef<HTMLDivElement>(null)
92 const draftReferenceMaxImages = useDraftReferenceMaxImages(materialGroupId)
93 const { containerRef, masonryColumns } = useContainerMasonryColumns(useContainerResponsive)
94 const masonryBreakpointCols = useContainerResponsive ? masonryColumns : MASONRY_BREAKPOINTS
95 const generationTasks = usePlanDetailStore(state => state.generationTasks)
96 const openGenerationDetailDialog = usePlanDetailStore(state => state.openGenerationDetailDialog)
97
98 const { list, loading, hasMore, initialized, total } = useMediaTabStore(
99 useShallow(state => ({
100 list: state[type].list,
101 loading: state[type].loading,
102 hasMore: state[type].hasMore,
103 initialized: state[type].initialized,
104 total: state[type].total,
105 })),
106 )
107
108 const { previewOpen, previewIndex, previewType } = useMediaTabStore(
109 useShallow(state => ({
110 previewOpen: state.previewOpen,
111 previewIndex: state.previewIndex,
112 previewType: state.previewType,
113 })),
114 )
115
116 const { batchMode, selectedItems, batchDeleting } = useMediaTabStore(
117 useShallow(state => ({
118 batchMode: state.batchMode,
119 selectedItems: state.selectedItems,
120 batchDeleting: state.batchDeleting,
121 })),
122 )
123
124 const { fetchMediaList, loadMore, openPreview, closePreview, enterBatchMode, exitBatchMode, toggleSelection, selectAllLoaded, deselectAll, batchDeleteByType } = useMediaTabStore(
125 useShallow(state => ({
126 fetchMediaList: state.fetchMediaList,
127 loadMore: state.loadMore,
128 openPreview: state.openPreview,
129 closePreview: state.closePreview,
130 enterBatchMode: state.enterBatchMode,
131 exitBatchMode: state.exitBatchMode,
132 toggleSelection: state.toggleSelection,
133 selectAllLoaded: state.selectAllLoaded,
134 deselectAll: state.deselectAll,
135 batchDeleteByType: state.batchDeleteByType,
136 })),
137 )
138
139 const openTransferDialog = useTransferDraftDialogStore(state => state.openDialog)
140
141 // 当前 Tab 类型的选中 ID 列表
142 const selectedIds = useMemo(() => {
143 return Object.entries(selectedItems)
144 .filter(([_, source]) => source === type)
145 .map(([id]) => id)
146 }, [selectedItems, type])
147
148 const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
149 const allSelected = list.length > 0 && selectedIds.length === list.length
150 const visibleGenerationTasks = useMemo(() => {
151 const target = type === 'video' ? 'video' : 'img'
152 return generationTasks
153 .filter(shouldShowDraftGenerationTaskCard)
154 .filter(task => getDraftGenerationTaskTarget(task) === target)
155 }, [generationTasks, type])
156
157 // 首次加载
158 useEffect(() => {
159 if (!initialized && materialGroupId) {
160 fetchMediaList(materialGroupId, type)
161 }
162 }, [initialized, materialGroupId, type, fetchMediaList])
163
164 // IntersectionObserver 无限滚动
165 useEffect(() => {
166 const loadMoreElement = loadMoreRef.current
167 if (!loadMoreElement)
168 return
169
170 const observer = new IntersectionObserver(
171 (entries) => {
172 const [entry] = entries
173 if (entry.isIntersecting && hasMore && !loading && materialGroupId) {
174 loadMore(materialGroupId, type)
175 }
176 },
177 LOAD_MORE_OBSERVER_OPTIONS,
178 )
179
180 observer.observe(loadMoreElement)
181 return () => observer.disconnect()
182 }, [hasMore, loading, materialGroupId, type, loadMore])
183
184 // 点击媒体卡片
185 const handleMediaClick = useCallback((media: MediaItem) => {
186 const index = list.findIndex(m => m._id === media._id)
187 if (index !== -1) {
188 openPreview(type, index)
189 }
190 }, [list, type, openPreview])
191
192 // 全选/取消全选
193 const handleToggleSelectAll = useCallback(() => {
194 if (allSelected) {
195 deselectAll()
196 }
197 else {
198 selectAllLoaded(type)
199 }
200 }, [allSelected, deselectAll, selectAllLoaded, type])
201
202 // 批量删除
203 const handleBatchDelete = useCallback(() => {
204 if (selectedIds.length === 0)
205 return
206
207 confirm({
208 title: t('mediaManagement.batchDeleteConfirmTitle'),
209 content: t('mediaManagement.batchDeleteConfirmDesc', { count: selectedIds.length }),
210 okType: 'destructive',
211 onOk: async () => {
212 const success = await batchDeleteByType(materialGroupId, type)
213 if (success) {
214 toast.success(t('mediaManagement.batchDeleteSuccess'))
215 }
216 else {
217 toast.error(t('mediaManagement.batchDeleteFailed'))
218 }
219 },
220 })
221 }, [selectedIds.length, batchDeleteByType, materialGroupId, type, t])
222
223 const handleTransfer = useCallback(() => {
224 if (selectedIds.length === 0) {
225 return
226 }
227
228 openTransferDialog({
229 currentPlanId: materialGroupId,
230 draftIds: [],
231 mediaIds: selectedIds,
232 })
233 }, [materialGroupId, openTransferDialog, selectedIds])
234
235 // 预览项列表
236 const previewItems = useMemo((): MediaPreviewItem[] => {
237 return list.map(media => ({
238 type: media.type === 'video' ? 'video' : 'image',
239 src: getOssUrl(media.url),
240 title: media.title,
241 }))
242 }, [list])
243
244 // 初始加载骨架屏
245 if (loading && list.length === 0) {
246 return (
247 <div ref={useContainerResponsive ? containerRef : undefined}>
248 <Masonry
249 breakpointCols={masonryBreakpointCols}
250 className="flex -ml-4 w-auto"
251 columnClassName="pl-4 bg-clip-padding"
252 >
253 {Array.from({ length: 8 }).map((_, i) => (
254 <MediaCardSkeleton key={i} index={i} />
255 ))}
256 </Masonry>
257 </div>
258 )
259 }
260
261 // 空状态
262 if (initialized && list.length === 0 && visibleGenerationTasks.length === 0) {
263 const Icon = type === 'video' ? Video : ImageIcon
264 return (
265 <div className="flex flex-col items-center justify-center py-12">
266 <div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
267 <Icon className="w-8 h-8 text-muted-foreground" />
268 </div>
269 <p className="text-sm font-medium text-foreground mb-1">
270 {type === 'video' ? t('mediaManagement.noVideo') : t('mediaManagement.noImage')}
271 </p>
272 <p className="text-sm text-muted-foreground">
273 {type === 'video' ? t('mediaManagement.noVideoDesc') : t('mediaManagement.noImageDesc')}
274 </p>
275 </div>
276 )
277 }
278
279 return (
280 <div ref={useContainerResponsive ? containerRef : undefined}>
281 {/* 工具栏 */}
282 {batchMode
283 ? (
284 <div className={cn('mb-4 flex items-center gap-3', useContainerResponsive && 'flex-wrap @min-[640px]:flex-nowrap')}>
285 <div className="flex items-center gap-2 cursor-pointer" onClick={handleToggleSelectAll}>
286 <Checkbox checked={allSelected} onCheckedChange={handleToggleSelectAll} />
287 <span className="text-sm">{t('mediaManagement.selectAll')}</span>
288 </div>
289 <span className="text-sm text-muted-foreground">
290 {t('mediaManagement.selectedCount', { count: selectedIds.length })}
291 </span>
292 <div className={useContainerResponsive ? 'flex-[1_1_100%] @min-[640px]:flex-1' : 'flex-1'} />
293 <Button
294 variant="outline"
295 size="sm"
296 onClick={handleTransfer}
297 disabled={selectedIds.length === 0 || batchDeleting}
298 className="cursor-pointer gap-1.5"
299 >
300 <ArrowRightLeft className="h-3.5 w-3.5" />
301 {tBrand('draftManage.transfer')}
302 </Button>
303 <Button variant="ghost" size="sm" onClick={exitBatchMode} className="cursor-pointer">
304 {t('mediaManagement.cancel')}
305 </Button>
306 </div>
307 )
308 : (
309 showBatchDeleteTrigger
310 ? (
311 <div className={cn('mb-4 flex items-center gap-3', useContainerResponsive && 'flex-wrap @min-[640px]:flex-nowrap')}>
312 <div className={useContainerResponsive ? 'flex-[1_1_100%] @min-[640px]:flex-1' : 'flex-1'} />
313 <Button
314 variant="outline"
315 size="sm"
316 onClick={enterBatchMode}
317 className="cursor-pointer gap-1.5"
318 >
319 <ArrowRightLeft className="h-3.5 w-3.5" />
320 {tBrand('draftManage.batchTransfer')}
321 </Button>
322 <Button
323 variant="outline"
324 size="sm"
325 onClick={enterBatchMode}
326 className="cursor-pointer gap-1.5"
327 >
328 <Trash2 className="h-3.5 w-3.5" />
329 {t('mediaManagement.batchDelete')}
330 </Button>
331 </div>
332 )
333 : null
334 )}
335
336 <Masonry
337 breakpointCols={masonryBreakpointCols}
338 className="flex -ml-4 w-auto"
339 columnClassName="pl-4 bg-clip-padding"
340 >
341 {!batchMode && visibleGenerationTasks.map(task => (
342 <GeneratingTaskCard key={task.id} task={task} onClick={openGenerationDetailDialog} />
343 ))}
344 {list.map(media => (
345 <MediaCard
346 key={media._id}
347 media={media}
348 onClick={handleMediaClick}
349 batchMode={batchMode}
350 selected={selectedSet.has(media._id)}
351 onToggleSelect={() => toggleSelection(media._id, type)}
352 enablePublishDrag={enablePublishDrag}
353 actions={
354 media.type === 'video'
355 ? <VideoCreateDraftAction media={media} groupId={materialGroupId} />
356 : <MediaAddToReferenceAction media={media} groupId={materialGroupId} maxImages={draftReferenceMaxImages} />
357 }
358 />
359 ))}
360 </Masonry>
361
362 {/* 加载触发器 */}
363 <div ref={loadMoreRef} />
364
365 {/* 加载更多指示器 */}
366 {loading && <LoadingIndicator />}
367
368 {/* 没有更多数据 */}
369 {!hasMore && list.length > 0 && (
370 <div className="flex items-center justify-center py-4">
371 <span className="text-sm text-muted-foreground">
372 {t('mediaManagement.loadedAll')}
373 </span>
374 </div>
375 )}
376
377 {/* 批量模式底部操作栏 */}
378 {batchMode && (
379 <div
380 className={cn(
381 'bottom-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 px-6 py-3',
382 batchActionPosition === 'fixed' ? 'fixed left-0 right-0' : 'sticky -mx-4 sm:-mx-6',
383 )}
384 >
385 <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'}>
386 <span className="text-sm text-muted-foreground">
387 {t('mediaManagement.selectedCount', { count: selectedIds.length })}
388 </span>
389 <div className={useContainerResponsive ? 'flex flex-wrap items-center gap-2' : 'flex items-center gap-2'}>
390 <Button
391 variant="outline"
392 size="sm"
393 onClick={handleTransfer}
394 disabled={selectedIds.length === 0 || batchDeleting}
395 className="cursor-pointer gap-1.5"
396 >
397 <ArrowRightLeft className="h-3.5 w-3.5" />
398 {tBrand('draftManage.transfer')}
399 </Button>
400 <Button variant="ghost" size="sm" onClick={exitBatchMode} className="cursor-pointer">
401 {t('mediaManagement.cancel')}
402 </Button>
403 <Button
404 variant="destructive"
405 size="sm"
406 onClick={handleBatchDelete}
407 disabled={selectedIds.length === 0 || batchDeleting}
408 className="cursor-pointer gap-1.5"
409 >
410 {batchDeleting
411 ? <Loader2 className="h-3.5 w-3.5 animate-spin" />
412 : <Trash2 className="h-3.5 w-3.5" />}
413 {t('mediaManagement.delete')}
414 </Button>
415 </div>
416 </div>
417 </div>
418 )}
419
420 {/* 媒体预览弹窗 - 仅当前 type 匹配时渲染 */}
421 {previewType === type && (
422 <MediaPreview
423 open={previewOpen}
424 items={previewItems}
425 initialIndex={previewIndex}
426 onClose={closePreview}
427 />
428 )}
429 </div>
430 )
431 })
432
433 MediaListSection.displayName = 'MediaListSection'
434
434 lines Plain Text