'use client' import { useEffect, useState, useRef, useCallback, useDeferredValue, useMemo } from 'react' import { useSession } from 'next-auth/react' import { useRouter, usePathname } from 'next/navigation' import gsap from 'gsap' import { useGSAP } from '@gsap/react' import UploadModal from '@/components/UploadModal' import MaterialsHeader from '@/components/board/MaterialsHeader' import BrandFluidField from '@/components/board/BrandFluidField' import RankingPanel from '@/components/RankingPanel' import AIChatWidget from '@/components/AIChatWidget' import VideoPlayerModal from '@/components/VideoPlayerModal' import ToastBubble from '@/components/ToastBubble' import { useToast } from '@/hooks/useToast' import { parseDownloadError } from '@/lib/download-errors' import { validateAndTriggerDownload } from '@/lib/client-download' import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler' import { normalizeVideoMaterialType, isVideoMaterialType as isVideoMaterialTypeValue, shouldPollVideoTranscode, } from '@/lib/video-material-types' import { buildPublicMaterialShareUrl } from '@/lib/public-app-url' import { normalizeMaterialPreviewUrl, pickFirstMaterialPreviewUrl } from '@/lib/material-preview-url' // 首页拆分组件 import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home' gsap.registerPlugin(useGSAP) const focusRing = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]' interface Material { id: string userId: string previewUrl: string productModel: string categoryName: string sceneName: string platformName?: string // 平台分类 productCategoryName?: string // 品类分类 materialType: string isAiGenerated?: boolean taskDetailSourceType?: string | null renderer: string authorName: string zipFileName: string zipFileSize: string | number downloadCount: number likeCount: number isLiked: boolean isFavorited: boolean // 收藏状态 createdAt: string displayImages?: string[] // 展示图片数组(详情页/模特类型) videoSourceUrl?: string | null // 原始视频URL videoPreview540Url?: string | null // 720p预览视频URL(字段名保持540Url以兼容数据库) videoPreviewClipUrl?: string | null // 视频片段URL videoCoverUrl?: string | null // 视频封面URL videoProcessingStatus?: string | null // 视频处理状态 videoPreviewBlocked?: boolean videoPreviewUnavailableMessage?: string | null videoPreviewRemainingDays?: number | null videoDuration?: number | null // 视频时长(秒) transcodeLastError?: string | null // 转码错误信息 transcodeAttempts?: number | null // 转码尝试次数 transcodeProgress?: number | null // 转码进度百分比 transcodeStage?: string | null // 转码阶段:preview/clip/cover transcodeQueueWaitingIndex?: number | null // 队列等待索引 transcodeQueueWaitingTotal?: number | null // 队列等待总数 transcodeQueueProcessingIds?: string[] // 正在处理的ID列表 transcodeQueueMaxConcurrent?: number | null // 最大并发数 user: { username: string avatar?: string | null } } interface FacetItem { id: string name: string count: number } interface MonthFacetItem { value: string label: string count: number } interface FacetTotals { materialType: number category: number scene: number platform: number productCategory: number month: number } const EMPTY_FACET_TOTALS: FacetTotals = { materialType: 0, category: 0, scene: 0, platform: 0, productCategory: 0, month: 0, } export default function Home() { const { data: session, status } = useSession() const router = useRouter() const pathname = usePathname() const pageRef = useRef(null) const [materials, setMaterials] = useState([]) const [loading, setLoading] = useState(true) const [facetsLoading, setFacetsLoading] = useState(true) const [showUploadModal, setShowUploadModal] = useState(false) const [searchKeyword, setSearchKeyword] = useState('') const [selectedCategory, setSelectedCategory] = useState('') const [selectedScene, setSelectedScene] = useState('') const [selectedMaterialType, setSelectedMaterialType] = useState('') // 素材类型筛选,默认展示全部 // const [selectedMaterialType, setSelectedMaterialType] = useState('3D场景') // 素材类型筛选,默认展示3D场景(已注释,改为默认展示全部) const [selectedPlatform, setSelectedPlatform] = useState('') // 平台分类筛选 const [selectedProductCategory, setSelectedProductCategory] = useState('') // 品类筛选 const [categories, setCategories] = useState([]) const [scenes, setScenes] = useState([]) const [platforms, setPlatforms] = useState([]) const [productCategories, setProductCategories] = useState([]) const [isFilterCollapsed, setIsFilterCollapsed] = useState(true) // 筛选区域折叠状态,默认折叠 // 素材类型列表(已分类排序) const [materialTypes, setMaterialTypes] = useState<{ value: string; label: string; count?: number }[]>([]) const [materialTypeCounts, setMaterialTypeCounts] = useState>({}) const [isTransitionLoading, setIsTransitionLoading] = useState(false) const [downloadingId, setDownloadingId] = useState(null) const [downloadProgress, setDownloadProgress] = useState(0) const [selectedMonth, setSelectedMonth] = useState('') const [months, setMonths] = useState([]) const [sortBy, setSortBy] = useState('createdAt') // createdAt | downloadCount const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc') // 排序方向:desc 倒序(默认)| asc 正序 const [previewImage, setPreviewImage] = useState<{ url: string; title: string } | null>(null) const [showVideoModal, setShowVideoModal] = useState(false) const [videoSrc, setVideoSrc] = useState(null) const transcodePollTimers = useRef>(new Map()) const transcodePollingFlags = useRef>(new Map()) // 标记是否正在轮询 const fetchMaterialsRef = useRef(null) // 存储fetchMaterials函数引用 const materialsRequestIdRef = useRef(0) const materialsAbortRef = useRef(null) const facetsRequestIdRef = useRef(0) const facetsAbortRef = useRef(null) // 排队和单线程转码可能超过30分钟,不能按固定时长误停正常轮询。 // done/failed 会主动停止;素材离开当前列表时立即清理对应定时器。 useEffect(() => { const currentMaterialIds = new Set(materials.map(m => m.id)) transcodePollTimers.current.forEach((timer, materialId) => { if (!currentMaterialIds.has(materialId)) { clearTimeout(timer) transcodePollTimers.current.delete(materialId) transcodePollingFlags.current.delete(materialId) } }) }, [materials]) // 音频播放状态 const audioRef = useRef(null) const [playingAudioId, setPlayingAudioId] = useState(null) const [audioProgress, setAudioProgress] = useState(0) // 0-100 const [audioDuration, setAudioDuration] = useState(0) // 音频总时长(秒) const [audioCurrentTime, setAudioCurrentTime] = useState(0) // 当前播放时间(秒) const [audioElement, setAudioElement] = useState(null) // 用于AudioVisualizer const [currentMaterialsTotalCount, setCurrentMaterialsTotalCount] = useState(0) const [facetTotals, setFacetTotals] = useState(EMPTY_FACET_TOTALS) // 分页+懒加载相关状态 const [displayedCount, setDisplayedCount] = useState(24) // 当前显示的素材数量 const [currentPage, setCurrentPage] = useState(1) // 当前加载到第几页 const [hasMore, setHasMore] = useState(true) // 是否还有更多数据 const [isLoadingMore, setIsLoadingMore] = useState(false) // 是否正在加载更多 const pageSize = 20 const [shareUrl, setShareUrl] = useState(null) const [showShareModal, setShowShareModal] = useState(false) const [showShareNotification, setShowShareNotification] = useState(false) const themeColor = '#1a1c1d' const [favoriteNotification, setFavoriteNotification] = useState(null) // 收藏操作提示 const { toast, showToast } = useToast() // 统一的Toast提示 // 实时搜索:使用 useDeferredValue 实现非阻塞搜索 const deferredSearchKeyword = useDeferredValue(searchKeyword) const isSearchPending = searchKeyword !== deferredSearchKeyword const materialTypesWithCounts = useMemo( () => materialTypes.map((type) => ({ ...type, count: materialTypeCounts[type.value] || 0, })), [materialTypes, materialTypeCounts], ) const buildFilterParams = useCallback( (options: { includeSort?: boolean; includePaging?: boolean; page?: number } = {}) => { const { includeSort = true, includePaging = false, page = 1 } = options const params = new URLSearchParams() const searchValue = deferredSearchKeyword.trim() if (searchValue) params.append('search', searchValue) if (selectedCategory) params.append('category', selectedCategory) if (selectedScene) params.append('scene', selectedScene) if (selectedMaterialType) params.append('materialType', selectedMaterialType) if (selectedPlatform) params.append('platform', selectedPlatform) if (selectedProductCategory) params.append('productCategory', selectedProductCategory) if (selectedMonth) params.append('month', selectedMonth) if (includeSort) { if (sortBy) params.append('sortBy', sortBy) if (sortOrder) params.append('sortOrder', sortOrder) } if (includePaging) { params.append('page', page.toString()) params.append('pageSize', pageSize.toString()) params.append('deferTotal', '1') } return params }, [ deferredSearchKeyword, selectedCategory, selectedScene, selectedMaterialType, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder, pageSize, ], ) // 实时搜索处理函数 - 输入立即更新,搜索由 useDeferredValue 控制 const handleSearchChange = useCallback((value: string) => { setSearchKeyword(value) }, []) const handleCategoryChange = useCallback((value: string) => { setSelectedCategory(value) }, []) // 移除页面卸载时保存状态的逻辑(避免滚动问题) useEffect(() => { if (status === 'unauthenticated') { router.push('/login') } }, [status, router]) // 从URL参数恢复筛选状态(在组件初始化时执行一次) useEffect(() => { if (typeof window === 'undefined') return const params = new URLSearchParams(window.location.search) const materialType = params.get('materialType') const category = params.get('category') const scene = params.get('scene') const platform = params.get('platform') const productCategory = params.get('productCategory') const month = params.get('month') const search = params.get('search') const sort = params.get('sortBy') const sortOrderParam = params.get('sortOrder') if (materialType) setSelectedMaterialType(normalizeVideoMaterialType(materialType)) if (category) setSelectedCategory(category) if (scene) setSelectedScene(scene) if (platform) setSelectedPlatform(platform) if (productCategory) setSelectedProductCategory(productCategory) if (month) setSelectedMonth(month) if (search) setSearchKeyword(search) if (sort) setSortBy(sort) if (sortOrderParam === 'asc' || sortOrderParam === 'desc') setSortOrder(sortOrderParam) }, []) // 同步筛选状态到URL参数 useEffect(() => { if (typeof window === 'undefined') return const params = new URLSearchParams() const searchValue = deferredSearchKeyword.trim() if (searchValue) params.set('search', searchValue) if (selectedMaterialType) params.set('materialType', selectedMaterialType) // 只有选择了素材类型才添加参数 if (selectedCategory) params.set('category', selectedCategory) if (selectedScene) params.set('scene', selectedScene) if (selectedPlatform) params.set('platform', selectedPlatform) if (selectedProductCategory) params.set('productCategory', selectedProductCategory) if (selectedMonth) params.set('month', selectedMonth) if (sortBy && sortBy !== 'createdAt') params.set('sortBy', sortBy) if (sortOrder && sortOrder !== 'desc') params.set('sortOrder', sortOrder) const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname window.history.replaceState({}, '', newUrl) }, [deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder]) // 加载当前用户可见的素材类型 useEffect(() => { const loadViewableTypes = async () => { if (status !== 'authenticated') return try { const mapMaterialType = (t: string) => { // 映射老类型到新类型 if (t === '3DMAX') return '3D场景' if (t === '产品全案') return '任务-详情' if (t === '详情页') return '详情模板' return normalizeVideoMaterialType(t) } const res = await fetch('/api/materials/viewable-types') if (res.ok) { const data = await res.json() const types = (data.materialTypes || []) as string[] const normalizeTypes = (arr: string[]) => Array.from( new Set( arr .map(mapMaterialType) .filter((t) => t && t !== '全部') ) ) const source = normalizeTypes(types) const finalList = source.map((t) => ({ value: t, label: t, })) const hasSelected = finalList.some((t) => t.value === selectedMaterialType) // const preferredDefault = '3D场景' // 已注释,改为默认展示全部 // const hasPreferred = finalList.some((t) => t.value === preferredDefault) if (!hasSelected && selectedMaterialType) { // 如果当前选中的类型不在列表中,且有选中值,则重置为空(全部) setSelectedMaterialType('') } const mapped = finalList setMaterialTypes(mapped) } else { setMaterialTypes([]) if (selectedMaterialType) { setSelectedMaterialType('') } } } catch (error) { console.error('获取可见素材类型失败:', error) setMaterialTypes([]) if (selectedMaterialType) { setSelectedMaterialType('') } } } loadViewableTypes() }, [status]) // 恢复滚动位置和页面状态(从详情页返回时) // 使用 pathname 作为依赖,当路由变为首页时触发恢复 const hasRestoredRef = useRef(false) useEffect(() => { if (typeof window === 'undefined') return // 只在首页路径时执行 if (pathname !== '/materials') return // 避免重复恢复 if (hasRestoredRef.current) return try { const savedScrollPosition = sessionStorage.getItem('home_scroll_position') const savedCache = sessionStorage.getItem('home_materials_cache') if (savedCache) { const cache = JSON.parse(savedCache) const cacheAge = Date.now() - (cache.timestamp || 0) const CACHE_EXPIRY = 5 * 60 * 1000 // 5分钟 const cachedMaterials = Array.isArray(cache.materials) ? cache.materials : [] // 如果缓存未过期,恢复状态 if (cacheAge < CACHE_EXPIRY && cachedMaterials.length > 0) { // 恢复筛选条件 if (cache.selectedCategory) setSelectedCategory(cache.selectedCategory) if (cache.selectedScene) setSelectedScene(cache.selectedScene) if (cache.selectedMaterialType) setSelectedMaterialType(normalizeVideoMaterialType(cache.selectedMaterialType)) if (cache.selectedPlatform) setSelectedPlatform(cache.selectedPlatform) if (cache.selectedProductCategory) setSelectedProductCategory(cache.selectedProductCategory) if (cache.selectedMonth) setSelectedMonth(cache.selectedMonth) if (cache.sortBy) setSortBy(cache.sortBy) if (cache.sortOrder) setSortOrder(cache.sortOrder) if (cache.searchKeyword) setSearchKeyword(cache.searchKeyword) // 恢复素材数据和显示数量 if (cachedMaterials.length > 0) { setMaterials(cachedMaterials) setDisplayedCount(cache.displayedCount || cachedMaterials.length) setCurrentPage(cache.currentPage || 1) } // 立即清除loading状态(使用缓存时不需要加载) setLoading(false) // 恢复滚动位置(延迟执行,确保内容已渲染) if (savedScrollPosition) { // 等待内容渲染完成后再恢复滚动位置 const restoreScroll = () => { const scrollY = parseInt(savedScrollPosition, 10) if (scrollY > 0) { // 多次尝试恢复,确保内容已完全渲染 let attempts = 0 const tryScroll = () => { attempts++ window.scrollTo({ top: scrollY, behavior: 'auto' }) // 如果滚动位置不对,且尝试次数少于5次,继续尝试 if (Math.abs(window.scrollY - scrollY) > 10 && attempts < 5) { setTimeout(tryScroll, 100) } else { // 清除已恢复的缓存 sessionStorage.removeItem('home_scroll_position') sessionStorage.removeItem('home_materials_cache') hasRestoredRef.current = true } } // 使用多个 requestAnimationFrame 确保DOM已渲染 requestAnimationFrame(() => { requestAnimationFrame(() => { setTimeout(tryScroll, 50) }) }) } else { // 没有滚动位置,直接清除缓存 sessionStorage.removeItem('home_scroll_position') sessionStorage.removeItem('home_materials_cache') hasRestoredRef.current = true } } restoreScroll() } else { // 没有滚动位置,直接清除缓存 sessionStorage.removeItem('home_scroll_position') sessionStorage.removeItem('home_materials_cache') hasRestoredRef.current = true } return // 使用缓存,不重新获取数据 } else { // 缓存过期,清除 sessionStorage.removeItem('home_scroll_position') sessionStorage.removeItem('home_materials_cache') // 缓存过期时,不设置 hasRestoredRef,让正常的数据获取逻辑执行 } } else if (savedScrollPosition) { // 只有滚动位置,没有缓存数据,恢复滚动位置(但不阻止数据获取) const scrollY = parseInt(savedScrollPosition, 10) if (scrollY > 0) { let attempts = 0 const tryScroll = () => { attempts++ window.scrollTo({ top: scrollY, behavior: 'auto' }) // 如果滚动位置不对,且尝试次数少于5次,继续尝试 if (Math.abs(window.scrollY - scrollY) > 10 && attempts < 5) { setTimeout(tryScroll, 100) } else { sessionStorage.removeItem('home_scroll_position') // 不设置 hasRestoredRef,让数据正常获取 } } requestAnimationFrame(() => { requestAnimationFrame(() => { setTimeout(tryScroll, 50) }) }) } else { sessionStorage.removeItem('home_scroll_position') // 不设置 hasRestoredRef,让数据正常获取 } } // 如果没有缓存也没有滚动位置,不设置 hasRestoredRef,让正常的数据获取逻辑执行 } catch (e) { console.warn('[Home] Failed to restore state from sessionStorage:', e) sessionStorage.removeItem('home_scroll_position') sessionStorage.removeItem('home_materials_cache') // 出错时,不设置 hasRestoredRef,让正常的数据获取逻辑执行 } }, [pathname]) // 当路由变化到首页时执行 // 当从其他页面返回首页时,重置恢复标记 useEffect(() => { if (pathname === '/materials') { // 延迟重置,确保恢复逻辑已执行 const timer = setTimeout(() => { hasRestoredRef.current = false }, 1000) return () => clearTimeout(timer) } }, [pathname]) // 当筛选条件变化时,重新获取素材列表(延迟执行,避免初始化时重复调用) useEffect(() => { // 只有在已登录状态下才获取素材 if (status !== 'authenticated') return // 如果已从缓存恢复,且恢复标记还在,不重新获取 // 注意:这里不删除缓存,因为恢复逻辑会自己处理 if (hasRestoredRef.current) { // 恢复完成后,清除恢复标记,允许后续筛选变化时正常获取 const timer = setTimeout(() => { hasRestoredRef.current = false }, 2000) // 给恢复逻辑足够的时间 return () => clearTimeout(timer) } // 延迟执行,确保状态已恢复 const timer = setTimeout(() => { fetchMaterials(1, false) }, 100) return () => clearTimeout(timer) }, [status, deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder]) // 移除筛选条件保存逻辑(避免滚动问题) // 获取素材列表(支持分页和追加模式) const fetchMaterials = useCallback( async (page = 1, append = false) => { const requestId = ++materialsRequestIdRef.current materialsAbortRef.current?.abort() const controller = new AbortController() materialsAbortRef.current = controller try { // 修复:只在首次加载(无搜索词且无结果)时显示全屏加载 // 搜索时只使用 isTransitionLoading,避免整个页面被替换导致 SearchBar 丢失焦点 const isInitialLoad = !append && page === 1 && materials.length === 0 && !deferredSearchKeyword if (isInitialLoad) { setLoading(true) } else if (!append) { setIsTransitionLoading(true) } if (!append) setHasMore(true) const params = buildFilterParams({ includePaging: true, page }) const url = `/api/materials${params.toString() ? '?' + params.toString() : ''}` const response = await fetch(url, { signal: controller.signal }) if (!response.ok) { throw new Error(`素材加载失败 (${response.status})`) } if (requestId === materialsRequestIdRef.current) { const data = await response.json() // 清理素材 ID,并为预览图补齐统一 fallback const materialsList = ((data.materials || data) as Material[]) .flatMap((e): Material[] => { if (!e.id || typeof e.id !== 'string') return [] const trimmedId = e.id.trim() if (!trimmedId) return [] const displayImages = (Array.isArray(e.displayImages) ? e.displayImages : []) .map(normalizeMaterialPreviewUrl) .filter((url): url is string => Boolean(url)) const videoSourceUrl = normalizeMaterialPreviewUrl(e.videoSourceUrl) const videoPreview540Url = normalizeMaterialPreviewUrl(e.videoPreview540Url) const videoPreviewClipUrl = normalizeMaterialPreviewUrl(e.videoPreviewClipUrl) const videoCoverUrl = normalizeMaterialPreviewUrl(e.videoCoverUrl) // 只允许非空字符串进入预览 URL,避免历史异常布尔值挡住后续合法地址。 const previewUrl = pickFirstMaterialPreviewUrl( e.previewUrl, videoCoverUrl, ...displayImages, ) || '' return [{ ...e, id: trimmedId, previewUrl, displayImages, videoSourceUrl, videoPreview540Url, videoPreviewClipUrl, videoCoverUrl, }] }) let appendedItems: Material[] = materialsList if (append) { setMaterials((prev) => { const existingIds = new Set(prev.map((item) => item.id)) const deduped = materialsList.filter((item) => !existingIds.has(item.id)) appendedItems = deduped if (deduped.length === 0) return prev return [...prev, ...deduped] }) } else { setMaterials(materialsList) setDisplayedCount(materialsList.length) } const appendedCount = append ? appendedItems.length : materialsList.length setCurrentPage(page) if (typeof data.hasMore === 'boolean') { setHasMore(data.hasMore) } else { const totalPages = data.totalPages || 1 setHasMore(appendedCount > 0 && page < totalPages) } if (typeof data.total === 'number') { setCurrentMaterialsTotalCount(data.total) } else if (typeof data.totalCount === 'number') { setCurrentMaterialsTotalCount(data.totalCount) } // 对需要转码的视频素材启动轮询 appendedItems.forEach((material, index) => { if (shouldPollVideoTranscode(material.materialType, material.videoProcessingStatus)) { // 避免一页多个视频在同一毫秒冲击状态接口。 startTranscodePoll(material, Math.min(index * 250, 3000)) } }) return appendedCount } } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return 0 console.error('Error fetching materials:', error) if (requestId === materialsRequestIdRef.current) { showToast(error instanceof Error ? error.message : '素材加载失败', { variant: 'error' }) } } finally { if (!append && requestId === materialsRequestIdRef.current) { setLoading(false) setIsTransitionLoading(false) } if (materialsAbortRef.current === controller) materialsAbortRef.current = null } return 0 }, [materials.length, buildFilterParams, showToast] ) // 更新fetchMaterialsRef useEffect(() => { fetchMaterialsRef.current = fetchMaterials }, [fetchMaterials]) // 判断是否为视频类型素材 const isVideoMaterial = useCallback((materialType: string): boolean => { return isVideoMaterialTypeValue(materialType) }, []) // 停止转码轮询 const stopTranscodePoll = useCallback((materialId: string) => { const timer = transcodePollTimers.current.get(materialId) if (timer) { clearTimeout(timer) transcodePollTimers.current.delete(materialId) } transcodePollingFlags.current.delete(materialId) }, []) // 启动转码轮询 const startTranscodePoll = useCallback((material: Material, delay: number = 5000) => { if (!isVideoMaterial(material.materialType)) return if (!material.id || typeof material.id !== 'string') { console.error('[TranscodePoll] Invalid material ID (empty or not string):', material.id) return } const materialId = material.id.trim() if (!materialId || materialId.length < 10 || materialId.length > 100) { console.error('[TranscodePoll] Invalid material ID format:', material.id, 'Length:', materialId.length) return } // 如果已完成,停止轮询 if (material.videoProcessingStatus === 'done') { stopTranscodePoll(materialId) return } // 如果已经在轮询,不重复启动 if (transcodePollingFlags.current.get(materialId)) { return } const poll = async () => { try { const response = await fetch(`/api/materials/transcode?materialId=${encodeURIComponent(materialId)}`) if (!response.ok) { const errorData = await response.json().catch(() => ({})) console.error(`[TranscodePoll] GET request failed for material ${materialId}:`, response.status, errorData) if (response.status === 429) { const retryAfterSeconds = Number(response.headers.get('Retry-After') || '15') const retryDelay = Number.isFinite(retryAfterSeconds) ? Math.max(5000, Math.min(retryAfterSeconds * 1000, 60000)) : 15000 const timer = setTimeout(poll, retryDelay) transcodePollTimers.current.set(materialId, timer) return } if ([400, 401, 403, 404].includes(response.status)) { stopTranscodePoll(materialId) return } throw new Error(`status ${response.status}: ${errorData.error || 'Unknown error'}`) } const data = await response.json() // 更新素材状态 setMaterials((prev) => prev.map((m) => (m.id?.trim() || m.id) === materialId || m.id === material.id ? { ...m, videoProcessingStatus: data.status, // 状态接口只有 has* 布尔标志,真实 URL 由完成后的列表刷新取得。 videoPreview540Url: normalizeMaterialPreviewUrl(m.videoPreview540Url), videoPreviewClipUrl: normalizeMaterialPreviewUrl(m.videoPreviewClipUrl), videoCoverUrl: normalizeMaterialPreviewUrl(m.videoCoverUrl), videoDuration: data.duration ?? m.videoDuration, transcodeAttempts: data.attempts ?? m.transcodeAttempts, transcodeProgress: data.progress ?? m.transcodeProgress, transcodeStage: data.stage ?? m.transcodeStage, transcodeQueueWaitingIndex: data.queue?.waitingIndex ?? null, transcodeQueueWaitingTotal: data.queue?.waitingTotal ?? null, transcodeQueueProcessingIds: data.queue?.processingIds ?? [], transcodeQueueMaxConcurrent: data.queue?.maxConcurrent ?? m.transcodeQueueMaxConcurrent, transcodeLastError: data.lastError ?? m.transcodeLastError, } : m ) ) // 如果已完成但缺少预览URL,刷新列表 if (data.status === 'done' && !material.videoPreviewClipUrl && !material.videoPreview540Url) { console.log('[TranscodePoll] Transcode done but missing preview URLs, refreshing material list...') fetchMaterialsRef.current?.(1, false) } // 根据状态决定是否继续轮询 if (data.status === 'done') { stopTranscodePoll(materialId) setTimeout(() => { fetchMaterialsRef.current?.(1, false) }, 500) } else if (data.status === 'failed') { stopTranscodePoll(materialId) } else { // 转码是分钟级后台任务;15秒轮询既能及时刷新,也不会让一页多卡片打爆状态接口。 const timer = setTimeout(poll, 15000) transcodePollTimers.current.set(materialId, timer) } } catch (error) { console.error(`[TranscodePoll] Poll error for material ${materialId}:`, error) // 出错后8秒重试 const timer = setTimeout(poll, 8000) transcodePollTimers.current.set(materialId, timer) } } // 标记为正在轮询 transcodePollingFlags.current.set(materialId, true) // 延迟启动轮询 const timer = setTimeout(poll, delay) transcodePollTimers.current.set(materialId, timer) }, [isVideoMaterial, stopTranscodePoll]) // 组件卸载时清理所有转码轮询 useEffect(() => { const pollTimers = transcodePollTimers.current const pollingFlags = transcodePollingFlags.current return () => { pollTimers.forEach((timer) => { clearTimeout(timer) }) pollTimers.clear() pollingFlags.clear() } }, []) // 音频播放控制 const handleAudioPlay = useCallback((materialId: string, audioUrl: string) => { console.log('[Home] handleAudioPlay called:', { materialId, audioUrl }) if (!audioUrl) { console.error('[Home] No audio URL provided for material:', materialId) showToast('音频文件不存在', { variant: 'error' }) return } // 防止重复调用:如果正在加载或播放同一个音频,直接返回 if (playingAudioId === materialId && audioRef.current) { // 如果点击的是当前正在播放的音频,则暂停 if (audioRef.current && !audioRef.current.paused) { audioRef.current.pause() audioRef.current = null setAudioElement(null) setPlayingAudioId(null) setAudioProgress(0) setAudioDuration(0) setAudioCurrentTime(0) return } } // 如果正在加载另一个音频,先停止并清理 if (audioRef.current && playingAudioId !== materialId) { // 清理所有事件监听器 const oldAudio = audioRef.current oldAudio.pause() oldAudio.src = '' oldAudio.load() audioRef.current = null } // 清理旧的 audio 元素引用,确保 AudioVisualizer 能正确切换到新的音频 setAudioElement(null) // 重置状态,避免进度条闪烁 setAudioProgress(0) setAudioDuration(0) setAudioCurrentTime(0) // 创建新的音频元素 const audio = new Audio(audioUrl) audio.crossOrigin = 'anonymous' // 允许跨域访问(用于AudioVisualizer) audio.preload = 'auto' // 预加载音频 audioRef.current = audio // 监听加载错误 const handleError = (e: Event) => { const error = audio.error console.error('[Home] Audio error:', { code: error?.code, message: error?.message, audioUrl, materialId }) showToast(`音频加载失败: ${error?.message || '未知错误'}`, { variant: 'error' }) setPlayingAudioId(null) setAudioProgress(0) setAudioDuration(0) setAudioCurrentTime(0) if (audioRef.current === audio) { audioRef.current = null setAudioElement(null) } audio.removeEventListener('error', handleError) } audio.addEventListener('error', handleError) // 监听元数据加载完成(获取时长) const handleLoadedMetadata = () => { console.log('[Home] Audio metadata loaded, duration:', audio.duration) if (audioRef.current === audio) { setAudioDuration(audio.duration || 0) } } audio.addEventListener('loadedmetadata', handleLoadedMetadata) // 监听可以播放(使用canplay事件,有足够数据就开始播放,减少延迟) const handleCanPlay = () => { // 检查是否仍然是当前音频(防止切换音频时的竞态条件) if (audioRef.current !== audio) { audio.removeEventListener('canplay', handleCanPlay) return } console.log('[Home] Audio can play:', audioUrl) audio.play().then(() => { // 再次检查是否仍然是当前音频 if (audioRef.current !== audio) { audio.pause() return } console.log('[Home] Audio playback started:', audioUrl) setAudioElement(audio) // 设置audio元素供AudioVisualizer使用 setPlayingAudioId(materialId) }).catch((error) => { console.error('[Home] Audio play failed:', error, 'URL:', audioUrl) showToast(`播放失败: ${error.message || '未知错误'}`, { variant: 'error' }) if (audioRef.current === audio) { setPlayingAudioId(null) audioRef.current = null setAudioElement(null) } }) audio.removeEventListener('canplay', handleCanPlay) } audio.addEventListener('canplay', handleCanPlay) // 如果音频已经可以播放,立即播放 if (audio.readyState >= 2) { handleCanPlay() } // 监听播放进度 const handleTimeUpdate = () => { // 检查是否仍然是当前音频 if (audioRef.current !== audio) { // 如果不是当前音频,移除监听器 audio.removeEventListener('timeupdate', handleTimeUpdate) return } // 只有当音频有时长且正在播放时才更新进度 if (audio.duration && !audio.paused) { setAudioProgress((audio.currentTime / audio.duration) * 100) setAudioCurrentTime(audio.currentTime) } } audio.addEventListener('timeupdate', handleTimeUpdate) // 监听播放结束 const handleEnded = () => { if (audioRef.current === audio) { setPlayingAudioId(null) setAudioProgress(0) setAudioDuration(0) setAudioCurrentTime(0) audioRef.current = null setAudioElement(null) } audio.removeEventListener('ended', handleEnded) } audio.addEventListener('ended', handleEnded) // 开始加载音频 audio.load() }, [playingAudioId, showToast]) // 组件卸载时清理音频 useEffect(() => { return () => { if (audioRef.current) { audioRef.current.pause() audioRef.current = null } } }, []) // 分页+懒加载函数(完全参考flnxi_sucai实现) const handleLoadMore = useCallback(async () => { if (isLoadingMore) return // 如果当前显示的数量小于已加载的素材数量,直接增加显示数量 if (displayedCount < materials.length) { setIsLoadingMore(true) setDisplayedCount(materials.length) setIsLoadingMore(false) return } // 如果没有更多数据,不再加载 if (!hasMore) return // 加载下一页数据 setIsLoadingMore(true) const appended = await fetchMaterials(currentPage + 1, true) if (appended > 0) { setDisplayedCount((prev) => prev + appended) } setIsLoadingMore(false) }, [isLoadingMore, displayedCount, materials.length, hasMore, currentPage, fetchMaterials]) // IntersectionObserver 逻辑已移至 LoadMoreTrigger 组件 const fetchFacets = useCallback(async () => { const requestId = ++facetsRequestIdRef.current facetsAbortRef.current?.abort() const controller = new AbortController() facetsAbortRef.current = controller setFacetsLoading(true) try { const params = buildFilterParams({ includeSort: false, includePaging: false }) const url = `/api/materials/facets${params.toString() ? '?' + params.toString() : ''}` const response = await fetch(url, { signal: controller.signal }) if (!response.ok) throw new Error(`筛选数据加载失败 (${response.status})`) if (requestId === facetsRequestIdRef.current) { const data = await response.json() setCategories(data.categories || []) setScenes(data.scenes || []) setPlatforms(data.platforms || []) setProductCategories(data.productCategories || []) setMonths(data.months || []) setFacetTotals({ ...EMPTY_FACET_TOTALS, ...(data.totals || {}) }) if (typeof data.total === 'number') { setCurrentMaterialsTotalCount(data.total) } setMaterialTypeCounts( Object.fromEntries( ((data.materialTypes || []) as FacetItem[]).map((item) => [item.name, item.count]), ), ) const nextCategories = (data.categories || []) as FacetItem[] const nextScenes = (data.scenes || []) as FacetItem[] const nextPlatforms = (data.platforms || []) as FacetItem[] const nextProductCategories = (data.productCategories || []) as FacetItem[] const nextMonths = (data.months || []) as MonthFacetItem[] if (selectedCategory && !nextCategories.some((item) => item.name === selectedCategory)) { setSelectedCategory('') } if (selectedScene && !nextScenes.some((item) => item.name === selectedScene)) { setSelectedScene('') } if (selectedPlatform && !nextPlatforms.some((item) => item.name === selectedPlatform)) { setSelectedPlatform('') } if (selectedProductCategory && !nextProductCategories.some((item) => item.name === selectedProductCategory)) { setSelectedProductCategory('') } if (selectedMonth) { const monthStillValid = nextMonths.some((item) => selectedMonth.length === 4 ? item.value.startsWith(`${selectedMonth}-`) : item.value === selectedMonth, ) if (!monthStillValid) setSelectedMonth('') } } } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return console.error('Error fetching material facets:', error) if (requestId === facetsRequestIdRef.current) { showToast(error instanceof Error ? error.message : '筛选数据加载失败', { variant: 'error' }) } } finally { if (requestId === facetsRequestIdRef.current) { setFacetsLoading(false) } if (facetsAbortRef.current === controller) facetsAbortRef.current = null } }, [buildFilterParams, showToast]) useEffect(() => () => { materialsAbortRef.current?.abort() facetsAbortRef.current?.abort() }, []) useEffect(() => { if (status !== 'authenticated') return fetchFacets() }, [status, fetchFacets]) // 根据素材类型获取角标背景颜色 const getMaterialTypeBadgeColor = (materialType: string): string => { // 判断是否为视频类型 const isVideoType = isVideoMaterial(materialType) if (materialType === '3D场景' || materialType === '3DMAX') { return 'bg-red-500' } else if (isVideoType) { return 'bg-blue-600' } else if (materialType === '音乐') { return 'bg-purple-600' } else if (materialType === 'C4D模型') { return 'bg-blue-500' } else if (materialType === '详情页' || materialType === '详情模板' || materialType === '矢量文件') { return 'bg-amber-500' } else { return 'bg-gray-700' } } // 格式化文件大小 const formatFileSize = (bytes: string | number): string => { const size = typeof bytes === 'string' ? parseInt(bytes) : bytes if (size === 0) return '未知' const units = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(size) / Math.log(1024)) return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}` } // 分享功能 const handleShare = async (materialId: string) => { const url = buildPublicMaterialShareUrl(materialId) setShareUrl(url) try { await navigator.clipboard.writeText(url) setShowShareNotification(true) setTimeout(() => setShowShareNotification(false), 2000) } catch (error) { setShowShareModal(true) } } const copyShareUrl = async () => { if (!shareUrl) return try { await navigator.clipboard.writeText(shareUrl) setShowShareNotification(true) setTimeout(() => setShowShareNotification(false), 2000) setShowShareModal(false) } catch (error) { console.error('复制失败,尝试降级方案:', error) // 降级方案:使用传统方法复制 const textarea = document.createElement('textarea') textarea.value = shareUrl textarea.style.position = 'fixed' textarea.style.left = '-999999px' textarea.style.top = '-999999px' document.body.appendChild(textarea) textarea.focus() textarea.select() try { const successful = document.execCommand('copy') if (successful) { setShowShareNotification(true) setTimeout(() => setShowShareNotification(false), 2000) setShowShareModal(false) } else { showToast('复制失败,请手动复制', { variant: 'error' }) } } catch (err) { console.error('降级复制也失败:', err) showToast('复制失败,请手动复制', { variant: 'error' }) } document.body.removeChild(textarea) } } const handleDownloadByDirectLink = (id: string) => { const runDownload = async () => { if (downloadingId === id) { return } setDownloadingId(id) setDownloadProgress(10) try { const downloadUrl = `/api/materials/download?id=${encodeURIComponent(id)}` await validateAndTriggerDownload(downloadUrl) setDownloadProgress(100) setMaterials(prev => prev.map(item => item.id === id ? { ...item, downloadCount: (item.downloadCount || 0) + 1 } : item ) ) } catch (error) { const { message } = parseDownloadError(error) showToast(message, { variant: 'error' }) } finally { setTimeout(() => { setDownloadingId(null) setDownloadProgress(0) }, 300) } } void runDownload() } const handleLike = async (id: string) => { try { const response = await fetch('/api/materials/like', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ materialId: id }), }) if (response.ok) { // 刷新列表以更新点赞状态 fetchMaterials() } else { const error = await parseFetchError(response) showToast(error.message, { variant: 'error' }) } } catch (error) { console.error('Like error:', error) handleError(error, showToast) } } // 收藏/取消收藏 const handleFavorite = async (id: string) => { if (!session?.user) { setFavoriteNotification('请先登录') setTimeout(() => setFavoriteNotification(null), 2000) return } try { const response = await fetch('/api/materials/favorite', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId: id }), }) if (!response.ok) { const error = await parseFetchError(response) setFavoriteNotification(error.message) setTimeout(() => setFavoriteNotification(null), 2000) return } const result = await response.json() // 更新本地状态 setMaterials(prev => prev.map(m => m.id === id ? { ...m, isFavorited: result.isFavorited } : m )) setFavoriteNotification(result.isFavorited ? '收藏成功' : '取消收藏成功') setTimeout(() => setFavoriteNotification(null), 2000) } catch (error) { console.error('收藏操作失败:', error) const { message } = parseApiError(error) setFavoriteNotification(message) setTimeout(() => setFavoriteNotification(null), 2000) } } // 排序切换(点击同一字段切换正/倒序,点击不同字段重置为倒序) const handleSortChange = (field: string) => { if (sortBy === field) { setSortOrder(prev => prev === 'desc' ? 'asc' : 'desc') } else { setSortBy(field) setSortOrder('desc') } } // 编辑素材 const handleEdit = (material: Material) => { router.push(`/edit/${material.id}`) } // 素材点击处理(保存状态并跳转) const handleMaterialClick = (material: Material) => { // 保存滚动位置和页面状态(异步,不阻塞跳转) try { sessionStorage.setItem('home_scroll_position', window.scrollY.toString()) sessionStorage.setItem('home_materials_cache', JSON.stringify({ materials: materials, displayedCount: displayedCount, currentPage: currentPage, selectedCategory: selectedCategory, selectedScene: selectedScene, selectedMaterialType: selectedMaterialType, selectedPlatform: selectedPlatform, selectedProductCategory: selectedProductCategory, selectedMonth: selectedMonth, sortBy: sortBy, sortOrder: sortOrder, searchKeyword: searchKeyword, timestamp: Date.now(), })) } catch (e) { console.warn('[Home] Failed to save state to sessionStorage:', e) } // 立即跳转,不等待任何操作(使用window.location.href确保立即跳转) window.location.href = `/share/${material.id}` } // 重试转码 const handleRetryTranscode = async (materialId: string) => { try { const response = await fetch('/api/materials/transcode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId, forceRetry: true }), }) if (response.ok) { // 刷新素材列表 fetchMaterials(1, false) } else { const error = await parseFetchError(response) showToast(error.message, { variant: 'error' }) } } catch (error) { console.error('Retry transcode error:', error) handleError(error, showToast) } } // 删除素材 const handleDelete = async (materialId: string) => { if (!confirm('确定要删除这个素材吗?素材将移至回收站。')) return try { const response = await fetch(`/api/materials?id=${materialId}`, { method: 'DELETE', }) if (response.ok) { // 从本地状态移除 setMaterials(prev => prev.filter(m => m.id !== materialId)) showToast('删除成功', { variant: 'success' }) } else { const error = await parseFetchError(response) showToast(error.message, { variant: 'error' }) } } catch (error) { console.error('Error deleting material:', error) handleError(error, showToast) } } useGSAP(() => { if (status === 'loading' || typeof window.matchMedia !== 'function') return if (!pageRef.current?.querySelector('[data-hero-title]')) return const motion = gsap.matchMedia() motion.add('(prefers-reduced-motion: no-preference)', () => { const tl = gsap.timeline({ defaults: { ease: 'expo.out' } }) tl .fromTo( '[data-hero-title]', { y: 18, autoAlpha: 0, filter: 'blur(10px)' }, { y: 0, autoAlpha: 1, filter: 'blur(0px)', duration: 0.85, ease: 'power4.out', clearProps: 'filter,opacity,visibility,transform' }, ) .fromTo( '[data-hero]', { y: 10, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.5, clearProps: 'opacity,visibility,transform' }, '-=0.6', ) .fromTo( '[data-toolbar]', { y: 12, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.5, clearProps: 'opacity,visibility,transform' }, '-=0.35', ) }) return () => motion.revert() }, { scope: pageRef, dependencies: [status] }) useGSAP(() => { if (loading || status === 'loading' || typeof window.matchMedia !== 'function') return if (!pageRef.current?.querySelector('[data-card]')) return const motion = gsap.matchMedia() motion.add('(prefers-reduced-motion: no-preference)', () => { gsap.fromTo( '[data-card]', { y: 16, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.45, stagger: 0.06, ease: 'power3.out', clearProps: 'opacity,visibility,transform', }, ) }) return () => motion.revert() }, { scope: pageRef, dependencies: [loading, status] }) if (status === 'unauthenticated') { return null } return (
{/* THESIS: 创意资产库是白皮书的下一章——编号索引落下之后,资产以图录而不是后台表格出现。 OWN-WORLD: 白纸 #fafafa、墨字、发丝线、平面无阴影、唯一柔紫光晕、墨色 8px 主按钮。 STORY: 打开即读「01 创意资产库」,搜索是可见的输入框,上传跟在后面,类型是文字分页。 FIRST VIEWPORT: 顶栏 → 01/巨标 → 搜索+上传 → 类型索引 → 三列图录。 FORM: Stitch 桌面稿 + 看板首页下限。FINISH: unreviewed and undocumented is unfinished. */} {/* 收藏操作提示气泡 */} {favoriteNotification && (
{favoriteNotification}
)}

01 Creative Assets

创意资产库

品牌中心的素材都在这里。浏览、上传、分享与下载照常可用。

{/* 筛选面板组件 */} {(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth) && (
当前筛选 {searchKeyword && ( 搜索 {searchKeyword} )} {selectedMaterialType && ( {selectedMaterialType} )} {selectedCategory && ( {selectedCategory} )} {selectedScene && ( {selectedScene} )} {selectedPlatform && ( {selectedPlatform} )} {selectedProductCategory && ( {selectedProductCategory} )} {selectedMonth && ( {selectedMonth.length === 4 ? `${selectedMonth}年` : months.find(m => m.value === selectedMonth)?.label || selectedMonth} )}
)}
{/* Materials Grid - 使用 MaterialGrid 组件 */} {/* 分页+懒加载 */} {materials.length > 0 && !loading && ( )}
{/* Upload Modal */} {showUploadModal && ( setShowUploadModal(false)} onSuccess={() => { setShowUploadModal(false) // 延迟刷新列表,确保数据库事务全部提交完成(修复批量上传后素材显示不全的问题) // 延迟1秒,给数据库足够的时间完成所有事务提交 setTimeout(() => { fetchMaterials() }, 1000) fetchFacets() }} /> )} {/* Image Preview Modal */} {previewImage && (
setPreviewImage(null)} >
{previewImage.title} e.stopPropagation()} />

{previewImage.title}

)} {/* Share Notification */} {showShareNotification && ( )} {/* 统一的Toast提示 */} {toast && toast.show && ( )} {/* Share Modal */} {showShareModal && shareUrl && (
setShowShareModal(false)} >
e.stopPropagation()} >

分享链接

{shareUrl}
)} {/* AI聊天助手 */}
) }