WuYongJie bea82d67b0 feat: add live clip batch upload and date platform filters
Allow multi-file live clip uploads with shared date/host/platform and
per-clip titles, filter materials by live platform and date range, and
fix the migration to alter the mapped materials table.
2026-09-22 18:59:05 +08:00

1658 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<HTMLDivElement>(null)
const [materials, setMaterials] = useState<Material[]>([])
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 [selectedLivePlatform, setSelectedLivePlatform] = useState('')
const [liveDateFrom, setLiveDateFrom] = useState('')
const [liveDateTo, setLiveDateTo] = useState('')
const [selectedProductCategory, setSelectedProductCategory] = useState('') // 品类筛选
const [categories, setCategories] = useState<FacetItem[]>([])
const [scenes, setScenes] = useState<FacetItem[]>([])
const [platforms, setPlatforms] = useState<FacetItem[]>([])
const [productCategories, setProductCategories] = useState<FacetItem[]>([])
const [isFilterCollapsed, setIsFilterCollapsed] = useState(true) // 筛选区域折叠状态,默认折叠
// 素材类型列表(已分类排序)
const [materialTypes, setMaterialTypes] = useState<{ value: string; label: string; count?: number }[]>([])
const [materialTypeCounts, setMaterialTypeCounts] = useState<Record<string, number>>({})
const [isTransitionLoading, setIsTransitionLoading] = useState(false)
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const [downloadProgress, setDownloadProgress] = useState(0)
const [selectedMonth, setSelectedMonth] = useState('')
const [months, setMonths] = useState<MonthFacetItem[]>([])
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<string | null>(null)
const transcodePollTimers = useRef<Map<string, NodeJS.Timeout>>(new Map())
const transcodePollingFlags = useRef<Map<string, boolean>>(new Map()) // 标记是否正在轮询
const fetchMaterialsRef = useRef<typeof fetchMaterials | null>(null) // 存储fetchMaterials函数引用
const materialsRequestIdRef = useRef(0)
const materialsAbortRef = useRef<AbortController | null>(null)
const facetsRequestIdRef = useRef(0)
const facetsAbortRef = useRef<AbortController | null>(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<HTMLAudioElement | null>(null)
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null)
const [audioProgress, setAudioProgress] = useState(0) // 0-100
const [audioDuration, setAudioDuration] = useState(0) // 音频总时长(秒)
const [audioCurrentTime, setAudioCurrentTime] = useState(0) // 当前播放时间(秒)
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null) // 用于AudioVisualizer
const [currentMaterialsTotalCount, setCurrentMaterialsTotalCount] = useState(0)
const [facetTotals, setFacetTotals] = useState<FacetTotals>(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<string | null>(null)
const [showShareModal, setShowShareModal] = useState(false)
const [showShareNotification, setShowShareNotification] = useState(false)
const themeColor = '#1a1c1d'
const [favoriteNotification, setFavoriteNotification] = useState<string | null>(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 (selectedLivePlatform) params.append('livePlatform', selectedLivePlatform)
if (liveDateFrom) params.append('liveDateFrom', liveDateFrom)
if (liveDateTo) params.append('liveDateTo', liveDateTo)
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 (selectedLivePlatform) params.set('livePlatform', selectedLivePlatform)
if (liveDateFrom) params.set('liveDateFrom', liveDateFrom)
if (liveDateTo) params.set('liveDateTo', liveDateTo)
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 (
<div ref={pageRef} className="materials-archive min-h-screen bg-[#fafafa] font-display text-[#1a1c1d] antialiased">
<style>{`.materials-archive ::selection { background: #eef0fa; color: #1a1c1d; }`}</style>
{/*
THESIS: 创意资产库是白皮书的下一章——编号索引落下之后,资产以图录而不是后台表格出现。
OWN-WORLD: 白纸 #fafafa、墨字、发丝线、平面无阴影、唯一柔紫光晕、墨色 8px 主按钮。
STORY: 打开即读「01 创意资产库」,搜索是可见的输入框,上传跟在后面,类型是文字分页。
FIRST VIEWPORT: 顶栏 → 01/巨标 → 搜索+上传 → 类型索引 → 三列图录。
FORM: Stitch 桌面稿 + 看板首页下限。FINISH: unreviewed and undocumented is unfinished.
*/}
<MaterialsHeader session={session ?? null} />
{/* 收藏操作提示气泡 */}
{favoriteNotification && (
<div className="fixed top-20 right-4 z-50 px-6 py-3 bg-[#1a1c1d] text-white rounded-[8px] animate-fade-in">
{favoriteNotification}
</div>
)}
<RankingPanel />
<main className="relative mx-auto max-w-[1280px] px-6 pb-20">
<section className="relative isolate overflow-hidden pb-8 pt-16 md:pb-10 md:pt-20">
<BrandFluidField />
<div className="relative z-10">
<p className="font-en text-[11px] uppercase tracking-[0.14em] text-[#86898e]">
<span className="mr-3 tabular-nums">01</span>
Creative Assets
</p>
<h1 data-hero-title className="mt-3 font-display text-[clamp(36px,5vw,56px)] font-semibold leading-[1.08] tracking-[-0.03em]">
创意资产库
</h1>
<p data-hero className="mt-3 max-w-xl text-[14px] leading-relaxed text-[#6b7075] md:text-[15px]">
品牌中心的素材都在这里。浏览、上传、分享与下载照常可用。
</p>
</div>
</section>
<div data-toolbar>
<div className="mb-8 flex items-center gap-3">
<div className="min-w-0 flex-1">
<SearchBar
value={searchKeyword}
onChange={handleSearchChange}
placeholder="搜索素材 ID、SPU、任务号、店铺、场景或作者"
isSearching={isSearchPending}
/>
</div>
<button
onClick={() => setShowUploadModal(true)}
className={`h-12 min-w-[148px] shrink-0 rounded-[8px] bg-[#1a1c1d] px-8 text-[14px] font-medium tracking-[-0.02em] text-white transition-colors hover:bg-[#2a2d2f] ${focusRing}`}
>
上传素材
</button>
</div>
{/* 筛选面板组件 */}
<FilterPanel
selectedMaterialType={selectedMaterialType}
setSelectedMaterialType={setSelectedMaterialType}
selectedCategory={selectedCategory}
setSelectedCategory={handleCategoryChange}
selectedScene={selectedScene}
setSelectedScene={setSelectedScene}
selectedPlatform={selectedPlatform}
setSelectedPlatform={setSelectedPlatform}
selectedLivePlatform={selectedLivePlatform}
setSelectedLivePlatform={setSelectedLivePlatform}
liveDateFrom={liveDateFrom}
setLiveDateFrom={setLiveDateFrom}
liveDateTo={liveDateTo}
setLiveDateTo={setLiveDateTo}
selectedProductCategory={selectedProductCategory}
setSelectedProductCategory={setSelectedProductCategory}
selectedMonth={selectedMonth}
setSelectedMonth={setSelectedMonth}
sortBy={sortBy}
setSortBy={setSortBy}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
materialTypes={materialTypesWithCounts}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
months={months}
totalMaterialsCount={currentMaterialsTotalCount}
materialsCount={currentMaterialsTotalCount}
facetTotals={facetTotals}
isFilterCollapsed={isFilterCollapsed}
setIsFilterCollapsed={setIsFilterCollapsed}
themeColor={themeColor}
facetsLoading={facetsLoading || status === 'loading'}
/>
{(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth) && (
<div className="mb-8 flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-[#e7e8ea] pb-4 text-[13px] text-[#6b7075]">
<span className="text-[#86898e]">当前筛选</span>
{searchKeyword && (
<span className="inline-flex items-center gap-1.5">
搜索 {searchKeyword}
<button onClick={() => setSearchKeyword('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
</span>
)}
{selectedMaterialType && (
<span className="inline-flex items-center gap-1.5">
{selectedMaterialType}
<button onClick={() => setSelectedMaterialType('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
</span>
)}
{selectedCategory && (
<span className="inline-flex items-center gap-1.5">
{selectedCategory}
<button onClick={() => handleCategoryChange('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
</span>
)}
{selectedScene && (
<span className="inline-flex items-center gap-1.5">
{selectedScene}
<button onClick={() => setSelectedScene('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
</span>
)}
{selectedPlatform && (
<span className="inline-flex items-center gap-1.5">
{selectedPlatform}
<button onClick={() => setSelectedPlatform('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
</span>
)}
{selectedProductCategory && (
<span className="inline-flex items-center gap-1.5">
{selectedProductCategory}
<button onClick={() => setSelectedProductCategory('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
</span>
)}
{selectedMonth && (
<span className="inline-flex items-center gap-1.5">
{selectedMonth.length === 4
? `${selectedMonth}年`
: months.find(m => m.value === selectedMonth)?.label || selectedMonth}
<button onClick={() => setSelectedMonth('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
</span>
)}
<button
onClick={() => {
setSearchKeyword('')
setSelectedMaterialType('')
setSelectedCategory('')
setSelectedScene('')
setSelectedPlatform('')
setSelectedProductCategory('')
setSelectedMonth('')
}}
className={`text-[#5a66b8] underline-offset-4 hover:underline ${focusRing} rounded-sm`}
>
清除所有筛选
</button>
</div>
)}
</div>
{/* Materials Grid - 使用 MaterialGrid 组件 */}
<MaterialGrid
materials={materials as any}
displayedCount={displayedCount}
session={session ? { user: { id: (session.user as any)?.id || '', role: (session.user as any)?.role || '' } } : null}
loading={loading}
isTransitionLoading={isTransitionLoading}
playingAudioId={playingAudioId}
audioElement={audioElement}
audioProgress={audioProgress}
audioCurrentTime={audioCurrentTime}
audioDuration={audioDuration}
audioRef={audioRef}
downloadingId={downloadingId}
downloadProgress={downloadProgress}
themeColor={themeColor}
searchKeyword={searchKeyword}
hasFilters={!!(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth)}
onAudioPlay={handleAudioPlay}
onDownload={handleDownloadByDirectLink}
onLike={handleLike}
onFavorite={handleFavorite}
onShare={handleShare}
onEdit={handleEdit as any}
onDelete={handleDelete}
onMaterialClick={handleMaterialClick as any}
onRetryTranscode={handleRetryTranscode}
showToast={showToast}
/>
{/* 分页+懒加载 */}
{materials.length > 0 && !loading && (
<LoadMoreTrigger
hasMore={hasMore}
isLoading={isLoadingMore}
onLoadMore={handleLoadMore}
themeColor={themeColor}
displayedCount={displayedCount}
totalCount={materials.length}
showEndMessage={true}
/>
)}
</main>
{/* Upload Modal */}
{showUploadModal && (
<UploadModal
onClose={() => setShowUploadModal(false)}
onSuccess={() => {
setShowUploadModal(false)
// 延迟刷新列表,确保数据库事务全部提交完成(修复批量上传后素材显示不全的问题)
// 延迟1秒,给数据库足够的时间完成所有事务提交
setTimeout(() => {
fetchMaterials()
}, 1000)
fetchFacets()
}}
/>
)}
{/* Image Preview Modal */}
{previewImage && (
<div
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4"
onClick={() => setPreviewImage(null)}
>
<div className="relative max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center">
<button
onClick={() => setPreviewImage(null)}
className="absolute top-4 right-4 z-10 bg-white/10 hover:bg-white/20 text-white rounded-full p-2 transition-all"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="text-center">
<img
src={previewImage.url}
alt={previewImage.title}
className="max-w-full max-h-[80vh] object-contain mx-auto"
onClick={(e) => e.stopPropagation()}
/>
<p className="text-white mt-4 text-lg font-medium">{previewImage.title}</p>
</div>
</div>
</div>
)}
{/* Share Notification */}
{showShareNotification && (
<ToastBubble message="分享链接已复制到剪贴板!" variant="success" />
)}
{/* 统一的Toast提示 */}
{toast && toast.show && (
<ToastBubble message={toast.message} variant={toast.variant} />
)}
{/* Share Modal */}
{showShareModal && shareUrl && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[200]"
onClick={() => setShowShareModal(false)}
>
<div
className="bg-white rounded-[12px] border border-[#e7e8ea] p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold mb-4 text-[#1a1c1d]">分享链接</h3>
<div className="bg-[#fafafa] border border-[#e7e8ea] p-3 rounded-[8px] mb-4 break-all text-sm text-[#1a1c1d]">
{shareUrl}
</div>
<div className="flex gap-2">
<button
onClick={copyShareUrl}
className="flex-1 bg-[#1a1c1d] text-white px-4 py-2 rounded-[8px] transition-colors hover:bg-[#2a2d2f] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
>
复制链接
</button>
<button
onClick={() => setShowShareModal(false)}
className="flex-1 border border-[#1a1c1d] bg-white hover:bg-[#1a1c1d] hover:text-white text-[#1a1c1d] px-4 py-2 rounded-[8px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
>
关闭
</button>
</div>
</div>
</div>
)}
{/* AI聊天助手 */}
<AIChatWidget />
</div>
)
}