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