返回 AiToEarn
1 /**
2 * MaterialSelectionModal - 素材选择器弹窗
3 * 支持从素材库选择图片或视频
4 * - 图片模式:多选
5 * - 视频模式:单选
6 * - 瀑布流布局 + 无限滚动
7 * - 支持 AI 生成素材分组
8 */
9
10 'use client'
11
12 import type { MaterialSelectionModalProps, MediaGroup, MediaItem, MediaType } from './types'
13 import { ArrowLeft, Check, Loader2 } from 'lucide-react'
14 import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
15 import InfiniteScroll from 'react-infinite-scroll-component'
16 import Masonry from 'react-masonry-css'
17 import { getAgentAssets } from '@/api/ai/ai.api'
18 import { apiGetMaterialGroupList, getMediaList } from '@/api/materials/material.api'
19
20 import { useTransClient } from '@/app/i18n/client'
21 import { Button } from '@/components/ui/button'
22 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
23 import { convertAssetToMediaItem, filterAssetsByMediaType } from '@/utils/agent/asset'
24 import { cn } from '@/utils/className'
25 import {
26 AgentGroupCard,
27 EmptyState,
28 GroupCard,
29 GroupCardSkeleton,
30 MediaCardSkeleton,
31 SelectableMediaCard,
32 } from './components'
33
34 /**
35 * 瀑布流断点配置 - 分组列表
36 */
37 const GROUP_BREAKPOINTS = {
38 default: 4, // > 1024px
39 1024: 3, // <= 1024px
40 768: 2, // <= 768px
41 }
42
43 /**
44 * 瀑布流断点配置 - 媒体列表
45 */
46 const MEDIA_BREAKPOINTS = {
47 default: 5, // > 1280px
48 1280: 4, // <= 1280px
49 1024: 3, // <= 1024px
50 768: 3, // <= 768px
51 640: 2, // <= 640px
52 }
53
54 /**
55 * 每页数量
56 */
57 const PAGE_SIZE = 20
58
59 /**
60 * 将草稿箱 PromotionPlan 转换为 MediaGroup 兼容结构
61 */
62 function adaptPlansToGroups(plans: { id: string, name: string, title?: string, desc?: string, isDefault?: boolean, createdAt?: string, updatedAt?: string }[]): MediaGroup[] {
63 return plans.map(plan => ({
64 _id: plan.id,
65 title: plan.name || plan.title || '',
66 type: 'img',
67 desc: plan.desc,
68 count: 0,
69 isDefault: plan.isDefault,
70 cover: undefined,
71 previewMedia: null,
72 userId: '',
73 userType: '',
74 createdAt: plan.createdAt || '',
75 updatedAt: plan.updatedAt || '',
76 }))
77 }
78
79 /**
80 * 内部内容组件 - 只在弹窗打开时渲染
81 * 使用 useTransClient('material') 加载翻译
82 */
83 const MaterialSelectionModalContent = memo(
84 ({ onOpenChange, mediaTypes, onSelect }: Omit<MaterialSelectionModalProps, 'open'>) => {
85 const { t } = useTransClient('material')
86 const scrollContainerRef = useRef<HTMLDivElement>(null)
87
88 // 将 mediaTypes 统一转为数组
89 const typesArray = useMemo<MediaType[]>(
90 () => (Array.isArray(mediaTypes) ? mediaTypes : [mediaTypes]),
91 [mediaTypes],
92 )
93
94 // 是否支持多种类型
95 const isMultipleTypes = typesArray.length > 1
96
97 // 当前视图:groups(分组列表)或 media(媒体列表)
98 const [currentView, setCurrentView] = useState<'groups' | 'media'>('groups')
99
100 // 分组相关状态
101 const [groups, setGroups] = useState<MediaGroup[]>([])
102 const [groupsLoading, setGroupsLoading] = useState(false)
103 const [selectedGroup, setSelectedGroup] = useState<MediaGroup | null>(null)
104
105 // 是否选中了 AI 生成素材分组(虚拟分组)
106 const [isAgentGroup, setIsAgentGroup] = useState(false)
107
108 // 媒体相关状态
109 const [mediaList, setMediaList] = useState<MediaItem[]>([])
110 const [mediaLoading, setMediaLoading] = useState(false)
111 const [mediaTotal, setMediaTotal] = useState(0)
112 const [mediaPage, setMediaPage] = useState(1)
113 const [hasMoreMedia, setHasMoreMedia] = useState(true)
114 const [isLoadingMore, setIsLoadingMore] = useState(false)
115
116 // 多选状态(仅图片模式)
117 const [selectedMedia, setSelectedMedia] = useState<Set<string>>(new Set())
118
119 // 是否为多选模式:包含图片类型即启用多选
120 const isMultiSelect = typesArray.includes('img')
121
122 // 获取草稿箱分组列表
123 const fetchGroups = useCallback(async () => {
124 setGroupsLoading(true)
125 try {
126 const response = await apiGetMaterialGroupList(1, 100)
127 if (response?.data?.list) {
128 setGroups(adaptPlansToGroups(response.data.list))
129 }
130 }
131 catch (error) {
132 console.error('Failed to fetch groups:', error)
133 }
134 finally {
135 setGroupsLoading(false)
136 }
137 }, [])
138
139 // 使用 ref 稳定化 fetchGroups 引用,避免 useEffect 因函数引用变化而重复执行
140 const fetchGroupsRef = useRef(fetchGroups)
141 fetchGroupsRef.current = fetchGroups
142
143 // 获取媒体列表(首次加载)
144 const fetchMediaList = useCallback(async (materialGroupId: string) => {
145 setMediaLoading(true)
146 setMediaPage(1)
147 setHasMoreMedia(true)
148 try {
149 const response = await getMediaList({ materialGroupId }, 1, PAGE_SIZE)
150 if (response?.data?.list) {
151 const list = response.data.list
152 const total = response.data.total || 0
153 setMediaList(list)
154 setMediaTotal(total)
155 setHasMoreMedia(list.length < total)
156 }
157 else {
158 setMediaList([])
159 setMediaTotal(0)
160 setHasMoreMedia(false)
161 }
162 }
163 catch (error) {
164 console.error('Failed to fetch media list:', error)
165 setMediaList([])
166 setMediaTotal(0)
167 setHasMoreMedia(false)
168 }
169 finally {
170 setMediaLoading(false)
171 }
172 }, [])
173
174 // 获取 AI 生成素材列表(首次加载)
175 const fetchAgentAssets = useCallback(async () => {
176 setMediaLoading(true)
177 setMediaPage(1)
178 setHasMoreMedia(true)
179 try {
180 const response = await getAgentAssets({ page: 1, pageSize: PAGE_SIZE })
181 if (response?.data?.list) {
182 // 根据 mediaTypes 过滤素材
183 const filteredAssets = filterAssetsByMediaType(response.data.list, typesArray)
184 // 转换为 MediaItem 格式
185 const convertedList = filteredAssets.map(convertAssetToMediaItem)
186 // 计算过滤后的总数(近似值,实际应该后端支持)
187 const total = response.data.total || 0
188 const filteredTotal = Math.floor(
189 total * (filteredAssets.length / (response.data.list.length || 1)),
190 )
191
192 setMediaList(convertedList)
193 setMediaTotal(filteredTotal)
194 setHasMoreMedia(convertedList.length < filteredTotal)
195 }
196 else {
197 setMediaList([])
198 setMediaTotal(0)
199 setHasMoreMedia(false)
200 }
201 }
202 catch (error) {
203 console.error('Failed to fetch agent assets:', error)
204 setMediaList([])
205 setMediaTotal(0)
206 setHasMoreMedia(false)
207 }
208 finally {
209 setMediaLoading(false)
210 }
211 }, [typesArray])
212
213 // 加载更多媒体(无限滚动)
214 const loadMoreMedia = useCallback(async () => {
215 if (isLoadingMore || !hasMoreMedia)
216 return
217
218 // AI 生成素材分组加载更多
219 if (isAgentGroup) {
220 const nextPage = mediaPage + 1
221 setIsLoadingMore(true)
222
223 try {
224 const response = await getAgentAssets({ page: nextPage, pageSize: PAGE_SIZE })
225 if (response?.data?.list) {
226 const filteredAssets = filterAssetsByMediaType(response.data.list, typesArray)
227 const convertedList = filteredAssets.map(convertAssetToMediaItem)
228 const combinedList = [...mediaList, ...convertedList]
229 const total = response.data.total || 0
230 const filteredTotal = Math.floor(
231 total * (filteredAssets.length / (response.data.list.length || 1)),
232 )
233
234 setMediaList(combinedList)
235 setMediaTotal(filteredTotal)
236 setMediaPage(nextPage)
237 setHasMoreMedia(combinedList.length < filteredTotal)
238 }
239 else {
240 setHasMoreMedia(false)
241 }
242 }
243 catch (error) {
244 console.error('Failed to load more agent assets:', error)
245 }
246 finally {
247 setIsLoadingMore(false)
248 }
249 return
250 }
251
252 // 普通分组加载更多
253 if (!selectedGroup)
254 return
255
256 const nextPage = mediaPage + 1
257 setIsLoadingMore(true)
258
259 try {
260 const response = await getMediaList({ materialGroupId: selectedGroup._id }, nextPage, PAGE_SIZE)
261 if (response?.data?.list) {
262 const newList = response.data.list
263 const total = response.data.total || 0
264 const combinedList = [...mediaList, ...newList]
265
266 setMediaList(combinedList)
267 setMediaTotal(total)
268 setMediaPage(nextPage)
269 setHasMoreMedia(combinedList.length < total)
270 }
271 else {
272 setHasMoreMedia(false)
273 }
274 }
275 catch (error) {
276 console.error('Failed to load more media:', error)
277 }
278 finally {
279 setIsLoadingMore(false)
280 }
281 }, [selectedGroup, isAgentGroup, mediaPage, mediaList, isLoadingMore, hasMoreMedia, typesArray])
282
283 // 组件挂载时加载分组(组件只在 open=true 时渲染)
284 useEffect(() => {
285 fetchGroupsRef.current()
286 }, [])
287
288 // 点击 AI 生成素材分组
289 const handleAgentGroupClick = useCallback(() => {
290 setIsAgentGroup(true)
291 setSelectedGroup(null)
292 setCurrentView('media')
293 setSelectedMedia(new Set())
294 fetchAgentAssets()
295 }, [fetchAgentAssets])
296
297 // 点击普通分组
298 const handleGroupClick = useCallback(
299 (group: MediaGroup) => {
300 setIsAgentGroup(false)
301 setSelectedGroup(group)
302 setCurrentView('media')
303 setSelectedMedia(new Set())
304 fetchMediaList(group._id)
305 },
306 [fetchMediaList],
307 )
308
309 // 返回分组列表
310 const handleBack = useCallback(() => {
311 setCurrentView('groups')
312 setSelectedGroup(null)
313 setIsAgentGroup(false)
314 setMediaList([])
315 setMediaTotal(0)
316 setMediaPage(1)
317 setHasMoreMedia(true)
318 setSelectedMedia(new Set())
319 }, [])
320
321 // 点击媒体
322 const handleMediaClick = useCallback(
323 (media: MediaItem) => {
324 // 图片多选,视频单选
325 const currentIsMultiSelect = media.type === 'img'
326
327 if (currentIsMultiSelect) {
328 // 图片多选:切换选中状态
329 setSelectedMedia((prev) => {
330 const next = new Set(prev)
331 if (next.has(media._id)) {
332 next.delete(media._id)
333 }
334 else {
335 next.add(media._id)
336 }
337 return next
338 })
339 }
340 else {
341 // 视频单选:直接选中并关闭
342 onSelect(media)
343 onOpenChange(false)
344 }
345 },
346 [onSelect, onOpenChange],
347 )
348
349 // 确认选择(多选模式)
350 const handleConfirm = useCallback(() => {
351 const selected = mediaList.filter(m => selectedMedia.has(m._id))
352 if (selected.length > 0) {
353 onSelect(selected)
354 onOpenChange(false)
355 }
356 }, [mediaList, selectedMedia, onSelect, onOpenChange])
357
358 // 获取标题
359 const getTitle = () => {
360 if (currentView === 'media') {
361 // AI 生成素材分组标题
362 if (isAgentGroup) {
363 return t('agentAssets.title')
364 }
365 // 普通分组标题
366 if (selectedGroup) {
367 return selectedGroup.title
368 }
369 }
370 // 根据支持的类型显示标题
371 if (isMultipleTypes) {
372 return t('mediaManagement.mediaResources')
373 }
374 return typesArray[0] === 'video'
375 ? t('mediaManagement.selectVideo')
376 : t('mediaManagement.selectImage')
377 }
378
379 // 滚动容器 ID
380 const scrollContainerId = 'material-selection-scroll-container'
381
382 return (
383 <Dialog open onOpenChange={onOpenChange}>
384 <DialogContent className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0">
385 {/* 头部 */}
386 <DialogHeader className="px-6 py-4 border-b shrink-0">
387 <div className="flex items-center gap-3">
388 {/* 返回按钮 */}
389 {currentView === 'media' && (
390 <Button
391 variant="ghost"
392 size="icon"
393 className="shrink-0 cursor-pointer"
394 onClick={handleBack}
395 >
396 <ArrowLeft className="w-5 h-5" />
397 </Button>
398 )}
399 <div className="flex-1 min-w-0">
400 <DialogTitle className="text-lg font-semibold truncate">{getTitle()}</DialogTitle>
401 {currentView === 'media' && mediaTotal > 0 && (
402 <p className="text-sm text-muted-foreground mt-0.5">
403 {mediaTotal}
404 {' '}
405 {t('mediaManagement.resources')}
406 </p>
407 )}
408 </div>
409 </div>
410 </DialogHeader>
411
412 {/* 内容区域 */}
413 <div
414 id={scrollContainerId}
415 ref={scrollContainerRef}
416 className="flex-1 overflow-y-auto px-6"
417 >
418 <div className="py-4">
419 {currentView === 'groups' ? (
420 // 分组列表
421 groupsLoading ? (
422 <Masonry
423 breakpointCols={GROUP_BREAKPOINTS}
424 className="flex -ml-4 w-auto"
425 columnClassName="pl-4 bg-clip-padding"
426 >
427 {Array.from({ length: 8 }).map((_, index) => (
428 <div key={index} className="mb-4">
429 <GroupCardSkeleton />
430 </div>
431 ))}
432 </Masonry>
433 ) : groups.length === 0 ? (
434 <EmptyState type="group" />
435 ) : (
436 <Masonry
437 breakpointCols={GROUP_BREAKPOINTS}
438 className="flex -ml-4 w-auto"
439 columnClassName="pl-4 bg-clip-padding"
440 >
441 {/* AI 生成素材分组卡片 - 始终显示在首位 */}
442 <div className="mb-4">
443 <AgentGroupCard onClick={handleAgentGroupClick} />
444 </div>
445 {groups.map(group => (
446 <div key={group._id} className="mb-4">
447 <GroupCard group={group} onClick={handleGroupClick} />
448 </div>
449 ))}
450 </Masonry>
451 )
452 ) // 媒体列表
453 : mediaLoading ? (
454 <Masonry
455 breakpointCols={MEDIA_BREAKPOINTS}
456 className="flex -ml-4 w-auto"
457 columnClassName="pl-4 bg-clip-padding"
458 >
459 {Array.from({ length: 15 }).map((_, index) => (
460 <div key={index} className="mb-4">
461 <MediaCardSkeleton />
462 </div>
463 ))}
464 </Masonry>
465 ) : mediaList.length === 0 ? (
466 <EmptyState
467 type="media"
468 mediaType={isAgentGroup ? undefined : selectedGroup?.type}
469 isAgentGroup={isAgentGroup}
470 />
471 ) : (
472 <InfiniteScroll
473 dataLength={mediaList.length}
474 next={loadMoreMedia}
475 hasMore={hasMoreMedia}
476 scrollThreshold={0.8}
477 loader={(
478 <div className="flex justify-center py-6">
479 <Loader2 className="w-6 h-6 text-muted-foreground animate-spin" />
480 </div>
481 )}
482 endMessage={
483 mediaList.length > 0 && (
484 <div className="flex justify-center py-6 text-muted-foreground text-sm">
485 {t('mediaManagement.loadedAll')}
486 </div>
487 )
488 }
489 scrollableTarget={scrollContainerId}
490 >
491 <Masonry
492 breakpointCols={MEDIA_BREAKPOINTS}
493 className="flex -ml-4 w-auto"
494 columnClassName="pl-4 bg-clip-padding"
495 >
496 {mediaList.map(media => (
497 <div key={media._id} className="mb-4">
498 <SelectableMediaCard
499 media={media}
500 selected={selectedMedia.has(media._id)}
501 multiSelect={media.type === 'img'}
502 onClick={handleMediaClick}
503 />
504 </div>
505 ))}
506 </Masonry>
507 </InfiniteScroll>
508 )}
509 </div>
510 </div>
511
512 {/* 底部操作栏(多选模式) */}
513 {isMultiSelect && currentView === 'media' && (
514 <div className="px-6 py-4 border-t shrink-0 flex items-center justify-between bg-background">
515 <span className="text-sm text-muted-foreground">
516 {t('mediaManagement.selectedCount', { count: selectedMedia.size })}
517 </span>
518 <Button
519 onClick={handleConfirm}
520 disabled={selectedMedia.size === 0}
521 className={cn(
522 'min-w-[100px] cursor-pointer',
523 selectedMedia.size > 0 && 'bg-primary hover:bg-primary/90',
524 )}
525 >
526 <Check className="w-4 h-4 mr-2" />
527 {t('mediaManagement.confirm')}
528 </Button>
529 </div>
530 )}
531 </DialogContent>
532 </Dialog>
533 )
534 },
535 )
536
537 MaterialSelectionModalContent.displayName = 'MaterialSelectionModalContent'
538
539 /**
540 * 外层包装组件 - 只在 open=true 时渲染内部内容
541 * 避免 useTransClient('material') 在弹窗关闭时触发动态加载导致闪烁
542 */
543 export function MaterialSelectionModal({
544 open,
545 onOpenChange,
546 mediaTypes,
547 onSelect,
548 }: MaterialSelectionModalProps) {
549 // 只在打开时渲染内部组件,避免动态加载 material namespace 导致闪烁
550 if (!open)
551 return null
552
553 return (
554 <MaterialSelectionModalContent
555 onOpenChange={onOpenChange}
556 mediaTypes={mediaTypes}
557 onSelect={onSelect}
558 />
559 )
560 }
561
562 // 导出类型
563 export type { MaterialSelectionModalProps, MediaGroup, MediaItem, MediaType } from './types'
564
564 lines Plain Text