返回 AiToEarn
agentAssetsPageCore.tsx
根目录 / project / aitoearn-web / src / app / [lng] / agent-assets / agentAssetsPageCore.tsx
1 /**
2 * AgentAssetsPageCore - AI 生成素材详情页核心组件
3 * 展示 AI 生成的所有图片和视频素材
4 * 特点:
5 * - 只读模式(无上传、删除功能)
6 * - 瀑布流布局 + 无限滚动
7 * - 混合显示视频和图片
8 */
9
10 'use client'
11
12 import type { MediaPreviewItem } from '@/components/common/MediaPreview'
13 import type { AssetVo } from '@/types/agent-asset'
14 import { Bot, Loader2 } from 'lucide-react'
15 import Link from 'next/link'
16 import { useCallback, useEffect, useMemo, useState } from 'react'
17 import InfiniteScroll from 'react-infinite-scroll-component'
18 import Masonry from 'react-masonry-css'
19 import { getAgentAssets } from '@/api/ai/ai.api'
20 import { useTransClient } from '@/app/i18n/client'
21 import { MediaPreview } from '@/components/common/MediaPreview'
22 import { Button } from '@/components/ui/button'
23 import { useDocumentTitle } from '@/hooks'
24 import { getAssetMediaType } from '@/utils/agent/asset'
25 import { getOssUrl } from '@/utils/oss'
26 import { AgentAssetCard } from './components/AgentAssetCard'
27 import { AgentAssetCardSkeleton } from './components/AgentAssetCard/AgentAssetCardSkeleton'
28 import { AgentAssetsHeader } from './components/AgentAssetsHeader'
29
30 /**
31 * 每页数量
32 */
33 const PAGE_SIZE = 20
34
35 /**
36 * 瀑布流断点配置
37 */
38 const MASONRY_BREAKPOINTS = {
39 default: 5, // > 1280px
40 1280: 4, // <= 1280px
41 1024: 3, // <= 1024px
42 768: 3, // <= 768px
43 640: 2, // <= 640px
44 }
45
46 export function AgentAssetsPageCore() {
47 const { t } = useTransClient('material')
48
49 // 动态更新页面标题
50 useDocumentTitle(t('agentAssets.title'))
51
52 // 数据状态
53 const [assets, setAssets] = useState<AssetVo[]>([])
54 const [total, setTotal] = useState(0)
55 const [page, setPage] = useState(1)
56 const [isLoading, setIsLoading] = useState(true)
57 const [isLoadingMore, setIsLoadingMore] = useState(false)
58 const [hasMore, setHasMore] = useState(true)
59
60 // 预览弹窗状态
61 const [previewOpen, setPreviewOpen] = useState(false)
62 const [previewIndex, setPreviewIndex] = useState(0)
63
64 /**
65 * 加载素材列表(首次加载)
66 */
67 const fetchAssets = useCallback(async () => {
68 setIsLoading(true)
69 try {
70 const result = await getAgentAssets({ page: 1, pageSize: PAGE_SIZE })
71 if (result?.data) {
72 const list = result.data.list || []
73 const totalCount = result.data.total || 0
74 setAssets(list)
75 setTotal(totalCount)
76 setPage(1)
77 setHasMore(list.length < totalCount)
78 }
79 }
80 catch (error) {
81 console.error('Failed to fetch agent assets:', error)
82 }
83 finally {
84 setIsLoading(false)
85 }
86 }, [])
87
88 /**
89 * 加载更多素材
90 */
91 const loadMore = useCallback(async () => {
92 if (isLoadingMore || !hasMore)
93 return
94
95 const nextPage = page + 1
96 setIsLoadingMore(true)
97
98 try {
99 const result = await getAgentAssets({ page: nextPage, pageSize: PAGE_SIZE })
100 if (result?.data) {
101 const newList = result.data.list || []
102 const totalCount = result.data.total || 0
103 const combinedList = [...assets, ...newList]
104
105 setAssets(combinedList)
106 setTotal(totalCount)
107 setPage(nextPage)
108 setHasMore(combinedList.length < totalCount)
109 }
110 }
111 catch (error) {
112 console.error('Failed to load more agent assets:', error)
113 }
114 finally {
115 setIsLoadingMore(false)
116 }
117 }, [page, assets, isLoadingMore, hasMore])
118
119 // 初始化加载
120 useEffect(() => {
121 fetchAssets()
122 }, [fetchAssets])
123
124 /**
125 * 处理素材点击(打开预览)
126 */
127 const handleAssetClick = useCallback(
128 (asset: AssetVo) => {
129 const index = assets.findIndex(a => a.id === asset.id)
130 if (index !== -1) {
131 setPreviewIndex(index)
132 setPreviewOpen(true)
133 }
134 },
135 [assets],
136 )
137
138 /**
139 * 获取预览项列表
140 */
141 const previewItems = useMemo<MediaPreviewItem[]>(() => {
142 return assets.map((asset) => {
143 const mediaType = getAssetMediaType(asset)
144 return {
145 type: mediaType === 'video' ? 'video' : 'image',
146 src: getOssUrl(asset.url),
147 title: asset.filename,
148 }
149 })
150 }, [assets])
151
152 return (
153 <div className="min-h-full bg-background">
154 {/* 顶部导航 */}
155 <AgentAssetsHeader total={total} />
156
157 {/* 主内容区 */}
158 <main className="px-4 py-6">
159 <div className="w-full mx-auto">
160 {isLoading ? (
161 // 加载骨架屏
162 <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
163 {Array.from({ length: PAGE_SIZE }).map((_, index) => (
164 <AgentAssetCardSkeleton key={index} index={index} />
165 ))}
166 </div>
167 ) : assets.length === 0 ? (
168 // 空状态
169 <div className="flex flex-col items-center justify-center py-20">
170 <div className="w-20 h-20 rounded-full bg-muted flex items-center justify-center mb-6">
171 <Bot className="w-10 h-10 text-muted-foreground" />
172 </div>
173 <h3 className="text-lg font-medium text-foreground mb-2">
174 {t('agentAssets.noAssets')}
175 </h3>
176 <p className="text-sm text-muted-foreground text-center max-w-md mb-6">
177 {t('agentAssets.noAssetsDesc')}
178 </p>
179 <Button asChild className="cursor-pointer">
180 <Link href="/chat">{t('agentAssets.goToChat')}</Link>
181 </Button>
182 </div>
183 ) : (
184 // 瀑布流 + 无限滚动
185 <InfiniteScroll
186 dataLength={assets.length}
187 next={loadMore}
188 hasMore={hasMore}
189 scrollThreshold={0.8}
190 loader={(
191 <div className="flex justify-center py-8">
192 <Loader2 className="w-8 h-8 text-muted-foreground animate-spin" />
193 </div>
194 )}
195 endMessage={
196 assets.length > 0 && (
197 <div className="flex justify-center py-8 text-muted-foreground text-sm">
198 {t('mediaManagement.loadedAll')}
199 {' · '}
200 {total}
201 {' '}
202 {t('mediaManagement.resources')}
203 </div>
204 )
205 }
206 scrollableTarget="main-content"
207 >
208 <Masonry
209 breakpointCols={MASONRY_BREAKPOINTS}
210 className="flex -ml-4 w-auto"
211 columnClassName="pl-4 bg-clip-padding"
212 >
213 {assets.map(asset => (
214 <div key={asset.id} className="mb-4">
215 <AgentAssetCard
216 asset={asset}
217 previewLabel={t('agentAssets.previewAsset')}
218 onClick={handleAssetClick}
219 />
220 </div>
221 ))}
222 </Masonry>
223 </InfiniteScroll>
224 )}
225 </div>
226 </main>
227
228 {/* 媒体预览弹窗 */}
229 <MediaPreview
230 open={previewOpen}
231 items={previewItems}
232 initialIndex={previewIndex}
233 onClose={() => setPreviewOpen(false)}
234 />
235 </div>
236 )
237 }
238
238 lines Plain Text